feat(sdk): add oagen effect emitter

This commit is contained in:
opencode-agent[bot] 2026-06-03 14:22:41 +00:00
parent 8851e4de2b
commit 321d257beb
8 changed files with 4281 additions and 952 deletions

1640
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -11,9 +11,11 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./effect": "./src/effect.ts",
"./server": "./src/server.ts",
"./v2": "./src/v2/index.ts",
"./v2/client": "./src/v2/client.ts",
"./v2/effect": "./src/v2/effect.ts",
"./v2/gen/client": "./src/v2/gen/client/index.ts",
"./v2/server": "./src/v2/server.ts"
},
@ -25,10 +27,12 @@
"@tsconfig/node22": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@workos/oagen": "0.21.1",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
},
"dependencies": {
"cross-spawn": "catalog:"
"cross-spawn": "catalog:",
"effect": "catalog:"
}
}

View file

@ -8,6 +8,8 @@ import { $ } from "bun"
import path from "path"
import { createClient } from "@hey-api/openapi-ts"
import { generateFiles, parseSpec } from "@workos/oagen"
import { effectEmitter } from "./effect-emitter.js"
const opencode = path.resolve(dir, "../../opencode")
@ -58,8 +60,41 @@ if (sseTypesPatched === sseTypesSource) {
}
await Bun.write(sseTypesPath, sseTypesPatched)
const openapi = await Bun.file("./openapi.json").json()
const effect = generateFiles(await parseSpec("./openapi.json"), effectEmitter, {
namespace: "Opencode",
outputDir: "./src/v2/gen",
emitterOptions: {
serverSentEvents: serverSentEvents(openapi),
},
})
for (const file of effect.files) {
await Bun.write(path.join("./src/v2/gen", file.path), file.content)
}
await $`bun prettier --write src/gen`
await $`bun prettier --write src/v2`
await $`rm -rf dist`
await $`bun tsc`
await $`rm openapi.json`
function serverSentEvents(spec: unknown) {
if (!spec || typeof spec !== "object" || !("paths" in spec) || !spec.paths || typeof spec.paths !== "object")
return []
return Object.entries(spec.paths).flatMap(([route, value]) => {
if (!value || typeof value !== "object") return []
return Object.entries(value).flatMap(([method, operation]) => {
if (!operation || typeof operation !== "object" || !("responses" in operation)) return []
if (!hasEventStream(operation.responses)) return []
return [`${method.toUpperCase()} ${route}`]
})
})
}
function hasEventStream(value: unknown): boolean {
if (!value || typeof value !== "object") return false
if ("text/event-stream" in value) return true
return Object.values(value).some(hasEventStream)
}

View file

@ -0,0 +1,209 @@
import type { ApiSpec, Emitter, EmitterContext, GeneratedFile, Operation, Service } from "@workos/oagen"
type EffectEmitterOptions = {
serverSentEvents?: string[]
}
export const effectEmitter: Emitter = {
language: "effect",
generateModels(): GeneratedFile[] {
return []
},
generateEnums(): GeneratedFile[] {
return []
},
generateResources(): GeneratedFile[] {
return []
},
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
return [generateEffectClient(spec, ctx)]
},
generateErrors(): GeneratedFile[] {
return []
},
generateTests(): GeneratedFile[] {
return []
},
fileHeader() {
return "// This file is auto-generated by oagen. Do not edit."
},
}
function generateEffectClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
const sse = new Set((ctx.emitterOptions as EffectEmitterOptions | undefined)?.serverSentEvents ?? [])
const operations = spec.services.flatMap((service) => service.operations.map((operation) => ({ service, operation })))
const typeImports = operations.flatMap(({ operation }) => {
const type = operationType(operation)
if (operation.errors.length === 0) return [`${type}Data`, `${type}Response`, `${type}Responses`]
return [`${type}Data`, `${type}Error`, `${type}Errors`, `${type}Response`, `${type}Responses`]
})
return {
path: "effect.gen.ts",
content: [
`import { Effect } from "effect"`,
`import type { Client, Options, TDataShape } from "./client/index.js"`,
`import type { ServerSentEventsResult } from "./core/serverSentEvents.gen.js"`,
`import type {`,
...Array.from(new Set(typeImports))
.sort()
.map((name) => ` ${name},`),
`} from "./types.gen.js"`,
``,
`export type OpencodeEffectOptions<TData extends TDataShape, TResponse> = Omit<`,
` Options<TData, true, TResponse, "data">,`,
` "responseStyle" | "throwOnError"`,
`>`,
``,
`export interface ${ctx.namespacePascal}EffectClient {`,
...spec.services.flatMap((service) => serviceShape(service, sse)),
`}`,
``,
`export function create${ctx.namespacePascal}EffectClient(client: Client): ${ctx.namespacePascal}EffectClient {`,
` return {`,
...spec.services.flatMap((service) => serviceFactory(service, sse)),
` }`,
`}`,
``,
`function request<T, E>(evaluate: () => Promise<T>) {`,
` return Effect.tryPromise({`,
` try: evaluate,`,
` catch: (error) => error as E | Error,`,
` })`,
`}`,
].join("\n"),
}
}
function serviceShape(service: Service, sse: Set<string>) {
return [
` ${propertyName(service.name)}: {`,
...service.operations.flatMap((operation) => [doc(operation, " "), ` ${methodSignature(operation, sse)}`]),
` }`,
]
}
function serviceFactory(service: Service, sse: Set<string>) {
return [
` ${propertyName(service.name)}: {`,
...service.operations.map(
(operation) => ` ${propertyName(operation.name)}: ${methodFactory(operation, sse)},`,
),
` },`,
]
}
function methodSignature(operation: Operation, sse: Set<string>) {
const optional = hasRequiredOptions(operation) ? "" : "?"
return `${propertyName(operation.name)}(options${optional}: OpencodeEffectOptions<${operationType(
operation,
)}Data, ${operationType(operation)}Responses>): Effect.Effect<${operationResponse(operation, sse)}, ${operationError(
operation,
)}>`
}
function methodFactory(operation: Operation, sse: Set<string>) {
const type = operationType(operation)
const args = [
`url: ${JSON.stringify(operation.path)}`,
`...options`,
`throwOnError: true`,
`responseStyle: "data"`,
contentType(operation),
].filter((line): line is string => Boolean(line))
const request = isSse(operation, sse)
? `client.sse.${operation.httpMethod}<${type}Responses, ${operationErrorTypes(operation)}, true, "data">({ ${args.join(
", ",
)} })`
: `client.${operation.httpMethod}<${type}Responses, ${operationErrorTypes(operation)}, true, "data">({ ${args.join(
", ",
)} })`
return `(options${hasRequiredOptions(operation) ? "" : "?"}) => request<${operationResponse(
operation,
sse,
)}, ${operationErrorValue(operation)}>(() => ${request})`
}
function operationResponse(operation: Operation, sse: Set<string>) {
const type = operationType(operation)
if (isSse(operation, sse)) return `ServerSentEventsResult<${type}Responses>`
return `${type}Response`
}
function operationError(operation: Operation) {
if (operation.errors.length === 0) return "Error"
return `${operationType(operation)}Error | Error`
}
function operationErrorValue(operation: Operation) {
if (operation.errors.length === 0) return "never"
return `${operationType(operation)}Error`
}
function operationErrorTypes(operation: Operation) {
if (operation.errors.length === 0) return "unknown"
return `${operationType(operation)}Errors`
}
function contentType(operation: Operation) {
if (!operation.requestBody) return
const value = {
binary: "application/octet-stream",
json: "application/json",
text: "text/plain",
"form-urlencoded": "application/x-www-form-urlencoded",
"form-data": undefined,
}[operation.requestBodyEncoding ?? "json"]
if (!value) return
return `headers: { "Content-Type": ${JSON.stringify(value)}, ...options?.headers }`
}
function hasRequiredOptions(operation: Operation) {
return (
operation.pathParams.length > 0 ||
operation.queryParams.some((item) => item.required) ||
operation.headerParams.some((item) => item.required)
)
}
function isSse(operation: Operation, sse: Set<string>) {
return sse.has(`${operation.httpMethod.toUpperCase()} ${operation.path}`)
}
function operationType(operation: Operation) {
return identifier(operation.name)
}
function propertyName(value: string) {
return value.charAt(0).toLowerCase() + value.slice(1)
}
function identifier(value: string) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[^A-Za-z0-9]+/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join("")
}
function doc(operation: Operation, indent: string) {
if (!operation.description) return `${indent}/** ${operation.httpMethod.toUpperCase()} ${operation.path} */`
return [
`${indent}/**`,
...operation.description
.replaceAll("*/", "* /")
.split("\n")
.map((line) => `${indent} * ${line}`),
`${indent} */`,
].join("\n")
}

View file

@ -0,0 +1 @@
export * from "./v2/effect.js"

View file

@ -4,7 +4,8 @@ import { createClient } from "./gen/client/client.gen.js"
import { type Config } from "./gen/client/types.gen.js"
import { OpencodeClient } from "./gen/sdk.gen.js"
import { wrapClientError } from "../error-interceptor.js"
export { type Config as OpencodeClientConfig, OpencodeClient }
export { OpencodeClient }
export type OpencodeClientConfig = Config & { directory?: string; experimental_workspaceID?: string }
function pick(value: string | null, fallback?: string, encode?: (value: string) => string) {
if (!value) return
@ -44,7 +45,7 @@ function rewrite(request: Request, values: { directory?: string; workspace?: str
return next
}
export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
export function createOpencodeFetchClient(config?: OpencodeClientConfig) {
if (!config?.fetch) {
const customFetch: any = (req: any) => {
// @ts-ignore
@ -86,5 +87,9 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
return response
})
client.interceptors.error.use(wrapClientError)
return new OpencodeClient({ client })
return client
}
export function createOpencodeClient(config?: OpencodeClientConfig) {
return new OpencodeClient({ client: createOpencodeFetchClient(config) })
}

View file

@ -0,0 +1,8 @@
export * from "./gen/effect.gen.js"
import { createOpencodeFetchClient, type OpencodeClientConfig } from "./client.js"
import { createOpencodeEffectClient as createGeneratedOpencodeEffectClient } from "./gen/effect.gen.js"
export function createOpencodeEffectClient(config?: OpencodeClientConfig) {
return createGeneratedOpencodeEffectClient(createOpencodeFetchClient(config))
}

File diff suppressed because it is too large Load diff