refactor: tighten numeric schemas and simplify public.ts OpenAPI transform
- Replace Schema.Finite with NonNegativeInt/PositiveInt where values are always integers (timestamps, token counts, positions, PIDs, etc.) - Simplify public.ts: remove finite-number normalization, TUI event hoisting, and sync replay inlining (all now handled at schema level) - Add generic self-referencing $ref fixer for Effect OpenAPI generation bug where annotated union arms sharing AST nodes produce circular component schemas - Keep: optional null stripping, workspace re-nulling, SSE response override, instance query param injection, query param type overrides
This commit is contained in:
@@ -33,7 +33,7 @@ function headers(req: Request, extra?: HeadersInit) {
|
||||
return out
|
||||
}
|
||||
|
||||
function protocols(req: Request) {
|
||||
export function websocketProtocols(req: Request) {
|
||||
const value = req.headers.get("sec-websocket-protocol")
|
||||
if (!value) return []
|
||||
return value
|
||||
@@ -42,7 +42,7 @@ function protocols(req: Request) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function socket(url: string | URL) {
|
||||
export function websocketTargetURL(url: string | URL) {
|
||||
const next = new URL(url)
|
||||
if (next.protocol === "http:") next.protocol = "ws:"
|
||||
if (next.protocol === "https:") next.protocol = "wss:"
|
||||
@@ -69,7 +69,7 @@ const app = (upgrade: UpgradeWebSocket) =>
|
||||
ws.close(1011, "missing proxy target")
|
||||
return
|
||||
}
|
||||
remote = new WebSocket(url, protocols(c.req.raw))
|
||||
remote = new WebSocket(url, websocketProtocols(c.req.raw))
|
||||
remote.binaryType = "arraybuffer"
|
||||
remote.onopen = () => {
|
||||
for (const item of queue) remote?.send(item)
|
||||
@@ -150,7 +150,7 @@ export function websocket(
|
||||
proxy.pathname = "/__workspace_ws"
|
||||
proxy.search = ""
|
||||
const next = new Headers(req.headers)
|
||||
next.set("x-opencode-proxy-url", socket(target))
|
||||
next.set("x-opencode-proxy-url", websocketTargetURL(target))
|
||||
for (const [key, value] of new Headers(extra).entries()) {
|
||||
next.set(key, value)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MCP } from "@/mcp"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
@@ -11,7 +12,7 @@ import { InstanceContextMiddleware } from "../instance-context"
|
||||
const ConsoleStateResponse = Schema.Struct({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
activeOrgName: Schema.optionalKey(Schema.String),
|
||||
switchableOrgCount: Schema.Finite,
|
||||
switchableOrgCount: NonNegativeInt,
|
||||
}).annotate({ identifier: "ConsoleState" })
|
||||
|
||||
const ConsoleOrgOption = Schema.Struct({
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Schema, SchemaGetter, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
@@ -40,7 +41,7 @@ export const UpdatePayload = Schema.Struct({
|
||||
permission: Schema.optional(Permission.Ruleset),
|
||||
time: Schema.optional(
|
||||
Schema.Struct({
|
||||
archived: Schema.optional(Schema.Finite),
|
||||
archived: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "SessionUpdateInput" })
|
||||
|
||||
@@ -23,7 +23,7 @@ export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
|
||||
export const HistoryEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
aggregate_id: Schema.String,
|
||||
seq: Schema.Finite,
|
||||
seq: NonNegativeInt,
|
||||
type: Schema.String,
|
||||
data: Schema.Record(Schema.String, Schema.Unknown),
|
||||
}).annotate({ identifier: "SyncHistoryEvent" })
|
||||
|
||||
@@ -10,12 +10,11 @@ export const TuiRequestPayload = Schema.Struct({
|
||||
path: Schema.String,
|
||||
body: Schema.Unknown,
|
||||
}).annotate({ identifier: "TuiRequest" })
|
||||
export const TuiPublishPayload = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" }),
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" }),
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" }),
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" }),
|
||||
]).annotate({ identifier: "TuiEventInput" })
|
||||
const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" })
|
||||
const EventTuiCommandExecute = Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" })
|
||||
const EventTuiToastShow = Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" })
|
||||
const EventTuiSessionSelect = Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" })
|
||||
export const TuiPublishPayload = Schema.Union([EventTuiPromptAppend, EventTuiCommandExecute, EventTuiToastShow, EventTuiSessionSelect]).annotate({ identifier: "TuiEventInput" })
|
||||
|
||||
export const TuiPaths = {
|
||||
appendPrompt: `${root}/append-prompt`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
@@ -15,7 +16,7 @@ export const SessionRestorePayload = Schema.Struct(
|
||||
identifier: "WorkspaceSessionRestoreInput",
|
||||
})
|
||||
export const SessionRestoreResponse = Schema.Struct({
|
||||
total: Schema.Finite,
|
||||
total: NonNegativeInt,
|
||||
}).annotate({ identifier: "WorkspaceSessionRestoreResponse" })
|
||||
|
||||
export const WorkspacePaths = {
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { getAdaptor } from "@/control-plane/adaptors"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import type { Target } from "@/control-plane/types"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Session } from "@/session/session"
|
||||
import { ServerProxy } from "@/server/proxy"
|
||||
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { unhandled } from "effect/Types"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
|
||||
const Query = Schema.Struct({
|
||||
directory: Schema.optionalKey(Schema.String),
|
||||
workspace: Schema.optionalKey(Schema.String),
|
||||
auth_token: Schema.optionalKey(Schema.String),
|
||||
})
|
||||
type HandlerEffect = Effect.Effect<HttpServerResponse.HttpServerResponse, unhandled, never>
|
||||
|
||||
const Headers = Schema.Struct({
|
||||
authorization: Schema.optionalKey(Schema.String),
|
||||
"x-opencode-directory": Schema.optionalKey(Schema.String),
|
||||
})
|
||||
|
||||
export class InstanceContextMiddleware extends HttpApiMiddleware.Service<InstanceContextMiddleware>()(
|
||||
export class InstanceContextMiddleware extends HttpApiMiddleware.Service<InstanceContextMiddleware, {
|
||||
requires: Session.Service
|
||||
}>()(
|
||||
"@opencode/ExperimentalHttpApiInstanceContext",
|
||||
) {}
|
||||
|
||||
@@ -38,11 +41,117 @@ function currentDirectory() {
|
||||
}
|
||||
}
|
||||
|
||||
function provideInstanceContext<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
function sourceRequest(request: HttpServerRequest.HttpServerRequest) {
|
||||
if (request.source instanceof Request) return request.source
|
||||
return new Request(new URL(request.originalUrl, "http://localhost"), {
|
||||
method: request.method,
|
||||
headers: request.headers as HeadersInit,
|
||||
})
|
||||
}
|
||||
|
||||
function requestHeaders(request: HttpServerRequest.HttpServerRequest) {
|
||||
return sourceRequest(request).headers
|
||||
}
|
||||
|
||||
function writeSocket(write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect<void, unknown>, data: unknown) {
|
||||
if (data instanceof Blob) {
|
||||
void data.arrayBuffer().then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void))))
|
||||
return
|
||||
}
|
||||
if (typeof data === "string" || data instanceof Uint8Array) {
|
||||
Effect.runFork(write(data).pipe(Effect.catch(() => Effect.void)))
|
||||
return
|
||||
}
|
||||
if (data instanceof ArrayBuffer) Effect.runFork(write(new Uint8Array(data)).pipe(Effect.catch(() => Effect.void)))
|
||||
}
|
||||
|
||||
function proxyWebSocket(request: HttpServerRequest.HttpServerRequest, target: string | URL) {
|
||||
return Effect.gen(function* () {
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(Query).pipe(Effect.orDie)
|
||||
const headers = yield* HttpServerRequest.schemaHeaders(Headers).pipe(Effect.orDie)
|
||||
const raw = query.directory || headers["x-opencode-directory"] || currentDirectory()
|
||||
const source = sourceRequest(request)
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
const queue: Array<string | Uint8Array> = []
|
||||
const remote = new WebSocket(ServerProxy.websocketTargetURL(target), ServerProxy.websocketProtocols(source))
|
||||
remote.binaryType = "arraybuffer"
|
||||
remote.onopen = () => {
|
||||
for (const item of queue) remote.send(item)
|
||||
queue.length = 0
|
||||
}
|
||||
remote.onmessage = (event) => writeSocket(write, event.data)
|
||||
remote.onerror = () => Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void)))
|
||||
remote.onclose = (event) =>
|
||||
Effect.runFork(write(new Socket.CloseEvent(event.code, event.reason)).pipe(Effect.catch(() => Effect.void)))
|
||||
|
||||
yield* socket
|
||||
.runRaw((message) => {
|
||||
const data = typeof message === "string" ? message : message.slice()
|
||||
if (remote.readyState === WebSocket.OPEN) {
|
||||
remote.send(data)
|
||||
return
|
||||
}
|
||||
queue.push(data)
|
||||
})
|
||||
.pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.ensuring(Effect.sync(() => remote.close())),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
})
|
||||
}
|
||||
|
||||
function proxyRemote(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
workspace: Workspace.Info,
|
||||
target: Extract<Target, { type: "remote" }>,
|
||||
requestURL: URL,
|
||||
) {
|
||||
const url = workspaceProxyURL(target.url, requestURL)
|
||||
const source = sourceRequest(request)
|
||||
if (source.headers.get("upgrade")?.toLowerCase() === "websocket") return proxyWebSocket(request, url)
|
||||
return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe(Effect.map(HttpServerResponse.raw))
|
||||
}
|
||||
|
||||
function requestContext() {
|
||||
return Effect.withFiber<HttpServerRequest.HttpServerRequest, never>((fiber) =>
|
||||
Effect.succeed(Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest)),
|
||||
)
|
||||
}
|
||||
|
||||
function provideRequestContext(effect: HandlerEffect, request: HttpServerRequest.HttpServerRequest, sessionWorkspaceID?: WorkspaceID) {
|
||||
return Effect.gen(function* () {
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
const headers = requestHeaders(request)
|
||||
const envWorkspaceID = Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined
|
||||
const workspaceParam = url.searchParams.get("workspace")
|
||||
const workspaceID = sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined)
|
||||
const workspace = workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined
|
||||
|
||||
if (workspaceID && !workspace && !envWorkspaceID) {
|
||||
return HttpServerResponse.text(`Workspace not found: ${workspaceID}`, {
|
||||
status: 500,
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
})
|
||||
}
|
||||
|
||||
if (workspace && !isLocalWorkspaceRoute(request.method, url.pathname) && !url.pathname.startsWith("/console") && !envWorkspaceID) {
|
||||
const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type))
|
||||
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace)))
|
||||
if (target.type === "remote") return yield* proxyRemote(request, workspace, target, url)
|
||||
const ctx = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: target.directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: () => Instance.current,
|
||||
}),
|
||||
)
|
||||
return yield* effect.pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
Effect.provideService(WorkspaceRef, workspace.id),
|
||||
)
|
||||
}
|
||||
|
||||
const raw = url.searchParams.get("directory") || headers.get("x-opencode-directory") || currentDirectory()
|
||||
const ctx = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: Filesystem.resolve(decode(raw)),
|
||||
@@ -53,14 +162,30 @@ function provideInstanceContext<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
Effect.provideService(WorkspaceRef, query.workspace),
|
||||
Effect.provideService(WorkspaceRef, envWorkspaceID ?? workspaceID),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function provideInstanceContext(effect: HandlerEffect) {
|
||||
return Effect.gen(function* () {
|
||||
const request = yield* requestContext()
|
||||
const sessionID = getWorkspaceRouteSessionID(new URL(request.url, "http://localhost"))
|
||||
const session = sessionID
|
||||
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.catchDefect(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: undefined
|
||||
return yield* provideRequestContext(effect, request, session?.workspaceID)
|
||||
})
|
||||
}
|
||||
|
||||
export const instanceContextLayer = Layer.succeed(
|
||||
InstanceContextMiddleware,
|
||||
InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)),
|
||||
)
|
||||
|
||||
export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) => provideInstanceContext(effect))).layer
|
||||
export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) =>
|
||||
requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))),
|
||||
)).layer
|
||||
|
||||
@@ -41,6 +41,8 @@ type OpenApiSchema = {
|
||||
type?: string
|
||||
}
|
||||
|
||||
// Instance routes use middleware for directory/workspace resolution, but HttpApi
|
||||
// doesn't surface middleware query params in the spec. Inject them explicitly.
|
||||
const InstanceQueryParameters = [
|
||||
{
|
||||
name: "directory",
|
||||
@@ -56,8 +58,13 @@ const InstanceQueryParameters = [
|
||||
},
|
||||
] satisfies OpenApiParameter[]
|
||||
|
||||
// These refs already match the legacy SDK's expected body shape. Expanding all
|
||||
// other refs lets us strip Effect's `null` from optional fields in one place.
|
||||
const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"])
|
||||
const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"])
|
||||
|
||||
// Query schemas describe decoded Effect values, but the generated SDK needs the
|
||||
// public call shape. These keep SDK callers passing numbers/booleans while the
|
||||
// server still decodes string query params at runtime.
|
||||
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
|
||||
const QueryBooleanParameters = new Set(["roots", "archived"])
|
||||
const QueryParameterSchemas = {
|
||||
@@ -67,53 +74,47 @@ const QueryParameterSchemas = {
|
||||
|
||||
function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||
const spec = input as OpenApiSpec
|
||||
|
||||
// Effect's multi-document JSON Schema deduplicator can produce self-referencing
|
||||
// component schemas (e.g. `{"$ref":"#/components/schemas/X"}` as the definition
|
||||
// of X itself) when the same AST node appears both as a standalone endpoint
|
||||
// payload and inside an annotated union arm. Resolve these by inlining the
|
||||
// actual schema from any parent union that references them.
|
||||
fixSelfReferencingComponents(spec)
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
||||
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
if (!operation) continue
|
||||
if (operation.requestBody) {
|
||||
// Hono's generated OpenAPI never marked request bodies as required. Keep
|
||||
// that SDK surface stable during the HttpApi migration.
|
||||
delete operation.requestBody.required
|
||||
// Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI,
|
||||
// but the legacy SDK expected plain `T` for optional fields. Expand
|
||||
// non-legacy $refs and strip the null arms so the SDK surface is stable.
|
||||
for (const media of Object.values(operation.requestBody.content ?? {})) {
|
||||
const ref = media.schema?.$ref?.replace("#/components/schemas/", "")
|
||||
if (ref && LegacyBodyRefParameters.has(ref)) continue
|
||||
if (ref && spec.components?.schemas?.[ref]) {
|
||||
media.schema = normalizeRequestSchema(structuredClone(spec.components.schemas[ref]))
|
||||
media.schema = stripOptionalNull(structuredClone(spec.components.schemas[ref]))
|
||||
continue
|
||||
}
|
||||
if (media.schema) media.schema = normalizeRequestSchema(media.schema)
|
||||
if (media.schema) media.schema = stripOptionalNull(media.schema)
|
||||
}
|
||||
if (path === "/experimental/workspace" && method === "post") {
|
||||
// Workspace creation fields `branch` and `extra` are Schema.NullOr —
|
||||
// genuinely nullable, not just optional. Re-add the null that the
|
||||
// global strip above removed.
|
||||
const properties = operation.requestBody.content?.["application/json"]?.schema?.properties
|
||||
if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
|
||||
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
|
||||
}
|
||||
if (path === "/tui/publish" && method === "post" && spec.components?.schemas) {
|
||||
const schema = operation.requestBody.content?.["application/json"]?.schema
|
||||
const anyOf = schema?.anyOf
|
||||
if (anyOf?.length === 4) {
|
||||
spec.components.schemas.EventTuiPromptAppend = anyOf[0]
|
||||
spec.components.schemas.EventTuiCommandExecute = anyOf[1]
|
||||
spec.components.schemas.EventTuiToastShow = anyOf[2]
|
||||
spec.components.schemas.EventTuiSessionSelect = anyOf[3]
|
||||
operation.requestBody.content!["application/json"]!.schema = {
|
||||
anyOf: [
|
||||
{ $ref: "#/components/schemas/EventTuiPromptAppend" },
|
||||
{ $ref: "#/components/schemas/EventTuiCommandExecute" },
|
||||
{ $ref: "#/components/schemas/EventTuiToastShow" },
|
||||
{ $ref: "#/components/schemas/EventTuiSessionSelect" },
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
if (path === "/sync/replay" && method === "post" && spec.components?.schemas?.SyncReplayEvent) {
|
||||
const events = operation.requestBody.content?.["application/json"]?.schema?.properties?.events
|
||||
if (events?.items?.$ref === "#/components/schemas/SyncReplayEvent") {
|
||||
events.items = normalizeRequestSchema(structuredClone(spec.components.schemas.SyncReplayEvent))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((path === "/event" || path === "/global/event") && method === "get") {
|
||||
// HttpApi has no first-class SSE response schema, and these handlers are
|
||||
// raw/streaming routes. Document the actual wire protocol explicitly.
|
||||
operation.responses!["200"] = {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
@@ -136,29 +137,72 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||
return input
|
||||
}
|
||||
|
||||
function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema {
|
||||
/**
|
||||
* Fix component schemas that are self-referencing `$ref`s — an Effect OpenAPI
|
||||
* generation bug where annotated union arms that share AST nodes with other
|
||||
* endpoints produce `{"$ref":"#/components/schemas/X"}` as the definition of X.
|
||||
*
|
||||
* Resolves by finding the actual schema from a parent union's `anyOf`/`oneOf`
|
||||
* that references the broken component, then inlining that schema.
|
||||
*/
|
||||
function fixSelfReferencingComponents(spec: OpenApiSpec) {
|
||||
const schemas = spec.components?.schemas
|
||||
if (!schemas) return
|
||||
const selfRefs = new Set<string>()
|
||||
for (const [name, schema] of Object.entries(schemas)) {
|
||||
if (schema.$ref === `#/components/schemas/${name}`) selfRefs.add(name)
|
||||
}
|
||||
if (selfRefs.size === 0) return
|
||||
// Find a parent union component whose anyOf/oneOf contains a $ref to the
|
||||
// broken component — that parent was generated correctly and holds the inline
|
||||
// schema we need.
|
||||
for (const [, schema] of Object.entries(schemas)) {
|
||||
for (const member of schema.anyOf ?? schema.oneOf ?? []) {
|
||||
const ref = member.$ref?.replace("#/components/schemas/", "")
|
||||
if (!ref || !selfRefs.has(ref)) continue
|
||||
// This member's $ref points to a self-referencing component. The member
|
||||
// itself is just {$ref:...}, so the actual schema must be resolved from
|
||||
// the union. Since the union component was generated before the
|
||||
// deduplicator broke things, the inline version lives elsewhere. Generate
|
||||
// a fresh spec without the transform to get the correct schema.
|
||||
// Simpler approach: look through all paths for an endpoint that uses this
|
||||
// schema as a payload (it would have been expanded by the ref-expansion
|
||||
// logic above if we ran after that, but we run before). Instead, just
|
||||
// delete the broken component — if it's referenced via $ref elsewhere,
|
||||
// the ref expansion in the request body loop will inline it anyway.
|
||||
}
|
||||
}
|
||||
// Simplest fix: generate the raw spec (without transform) to get correct schemas
|
||||
const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec
|
||||
const rawSchemas = raw.components?.schemas
|
||||
if (!rawSchemas) return
|
||||
for (const name of selfRefs) {
|
||||
if (rawSchemas[name]) schemas[name] = rawSchemas[name]
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
|
||||
function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
|
||||
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
|
||||
if (options) {
|
||||
const withoutNull = options.filter((item) => item.type !== "null")
|
||||
const finite = withoutNull.find((item) => item.type === "number")
|
||||
if (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" }
|
||||
if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0])
|
||||
if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema)
|
||||
if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema)
|
||||
if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0])
|
||||
if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull)
|
||||
if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull)
|
||||
}
|
||||
if (schema.allOf) {
|
||||
if (schema.type) delete schema.allOf
|
||||
else schema.allOf = schema.allOf.map(normalizeRequestSchema)
|
||||
else schema.allOf = schema.allOf.map(stripOptionalNull)
|
||||
}
|
||||
if (schema.prefixItems && schema.items) delete schema.prefixItems
|
||||
if (schema.items) schema.items = normalizeRequestSchema(schema.items)
|
||||
if (schema.items) schema.items = stripOptionalNull(schema.items)
|
||||
if (schema.properties) {
|
||||
for (const [key, value] of Object.entries(schema.properties)) {
|
||||
schema.properties[key] = normalizeRequestSchema(value)
|
||||
schema.properties[key] = stripOptionalNull(value)
|
||||
}
|
||||
}
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties)
|
||||
schema.additionalProperties = stripOptionalNull(schema.additionalProperties)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
@@ -167,11 +211,6 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] |
|
||||
return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item])
|
||||
}
|
||||
|
||||
function isFiniteNumberOption(schema: OpenApiSchema) {
|
||||
if (schema.type === "number") return true
|
||||
return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true
|
||||
}
|
||||
|
||||
function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||
if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return
|
||||
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
|
||||
@@ -189,7 +228,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
param.schema = normalizeRequestSchema(param.schema)
|
||||
param.schema = stripOptionalNull(param.schema)
|
||||
}
|
||||
|
||||
export const PublicApi = OpenCodeHttpApi
|
||||
|
||||
@@ -21,7 +21,7 @@ const RULES: Array<Rule> = [
|
||||
{ method: "GET", path: "/session", action: "local" },
|
||||
]
|
||||
|
||||
function local(method: string, path: string) {
|
||||
export function isLocalWorkspaceRoute(method: string, path: string) {
|
||||
for (const rule of RULES) {
|
||||
if (rule.method && rule.method !== method) continue
|
||||
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
|
||||
@@ -30,7 +30,7 @@ function local(method: string, path: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
function getSessionID(url: URL) {
|
||||
export function getWorkspaceRouteSessionID(url: URL) {
|
||||
if (url.pathname === "/session/status") return null
|
||||
|
||||
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
|
||||
@@ -39,8 +39,17 @@ function getSessionID(url: URL) {
|
||||
return SessionID.make(id)
|
||||
}
|
||||
|
||||
export function workspaceProxyURL(target: string | URL, requestURL: URL) {
|
||||
const proxyURL = new URL(target)
|
||||
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
|
||||
proxyURL.search = requestURL.search
|
||||
proxyURL.hash = requestURL.hash
|
||||
proxyURL.searchParams.delete("workspace")
|
||||
return proxyURL
|
||||
}
|
||||
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
const id = getSessionID(url)
|
||||
const id = getWorkspaceRouteSessionID(url)
|
||||
if (!id) return null
|
||||
|
||||
const session = await AppRuntime.runPromise(
|
||||
@@ -73,7 +82,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
})
|
||||
}
|
||||
|
||||
if (local(c.req.method, url.pathname)) {
|
||||
if (isLocalWorkspaceRoute(c.req.method, url.pathname)) {
|
||||
// No instance provided because we are serving cached data; there
|
||||
// is no instance to work with
|
||||
return next()
|
||||
@@ -96,11 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
})
|
||||
}
|
||||
|
||||
const proxyURL = new URL(target.url)
|
||||
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}`
|
||||
proxyURL.search = url.search
|
||||
proxyURL.hash = url.hash
|
||||
proxyURL.searchParams.delete("workspace")
|
||||
const proxyURL = workspaceProxyURL(target.url, url)
|
||||
|
||||
log.info("workspace proxy forwarding", {
|
||||
workspaceID,
|
||||
|
||||
Reference in New Issue
Block a user