220 lines
12 KiB
TypeScript
220 lines
12 KiB
TypeScript
/**
|
|
* Server side of the fetch carrier: maps an ApiProxy onto a pure
|
|
* WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method +
|
|
* path==method) -> payload dispatched per method. HTTP status expresses only the carrier
|
|
* (404 unknown path / 400 non-JSON body / 500 handler crash); business errors are always
|
|
* 200 + ServerResponse.
|
|
*/
|
|
|
|
import { randomUUID } from 'node:crypto'
|
|
import type { z } from 'zod'
|
|
import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts'
|
|
import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
|
|
import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts'
|
|
import { RpcId } from '../api/rpc.ts'
|
|
import type { Wire } from '../api/rpc.schema.ts'
|
|
import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
|
|
import {
|
|
sessionCancelRequestSchema,
|
|
sessionCreateRequestSchema,
|
|
sessionHistoryRequestSchema,
|
|
sessionListRequestSchema,
|
|
sessionPromptRequestSchema,
|
|
} from '../api/sessions.schema.ts'
|
|
import { hostDescribeRequestSchema, hostPickDirectoryRequestSchema } from '../api/host.schema.ts'
|
|
import {
|
|
workspaceCreateRequestSchema,
|
|
workspaceDeleteRequestSchema,
|
|
workspaceInsertSessionBeforeRequestSchema,
|
|
workspaceListRequestSchema,
|
|
workspaceRenameRequestSchema,
|
|
} from '../api/workspace.schema.ts'
|
|
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
|
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
|
|
|
/**
|
|
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
|
* route row fails to compile, and each row's schema/invoke pair is checked against that row's
|
|
* payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
|
|
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
|
|
* documented on Wire); the dispatch point carries the one Wire→exact cast.
|
|
* Every invoke receives the carrier Request's signal; methods whose contract
|
|
* declares a signal parameter (command.execute) forward it, the rest ignore it.
|
|
*/
|
|
type UnaryRoutes = {
|
|
[K in keyof RpcMethodMap]: {
|
|
schema: z.ZodType<Wire<RequestPayload<K>>>
|
|
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>, signal: AbortSignal): Promise<RpcResponse<ResponseValue<K>>>
|
|
}
|
|
}
|
|
|
|
const UNARY_ROUTES: UnaryRoutes = {
|
|
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
|
|
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
|
|
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
|
|
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
|
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
|
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
|
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
|
|
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
|
|
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
|
|
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
|
|
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
|
|
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
|
|
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
|
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
|
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
|
}
|
|
|
|
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
|
function methodFor(path: string): keyof RpcMethodMap | undefined {
|
|
return Object.hasOwn(UNARY_ROUTES, path) ? path as keyof RpcMethodMap : undefined
|
|
}
|
|
|
|
/**
|
|
* Sentinel rpcId for error responses to envelopes whose own rpcId is unreadable: the response
|
|
* must still be a valid ServerResponse (a self-violating shape would turn the server's explicit
|
|
* bad-request report into a client-side parse failure). Fixed value, documented here as wire contract.
|
|
*/
|
|
const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
|
|
|
|
/** Wrap a business error as a ServerResponse full form (rpcId backfilled; an unreadable rpcId uses the invalid-request sentinel). */
|
|
function errorResponse(rpcId: RpcId, error: RpcError): Response {
|
|
const body: ServerResponse = { type: 'server-response', rpcId, result: { ok: false, error } }
|
|
return Response.json(body)
|
|
}
|
|
|
|
/** Complete the impl's narrow form into a ServerResponse full form. */
|
|
function fullResponse(narrow: RpcResponse<unknown>): Response {
|
|
const body: ServerResponse = { type: 'server-response', rpcId: narrow.rpcId, result: narrow.result }
|
|
return Response.json(body)
|
|
}
|
|
|
|
/**
|
|
* Parse the payload and invoke one unary route. Generic over the map key so
|
|
* the row's schema/invoke pairing typechecks; the only cast collapses the
|
|
* Wire<> widening back to the exact payload (undefined-valued properties and
|
|
* absent ones are indistinguishable after JSON transport).
|
|
*/
|
|
// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
|
|
// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
|
async function handleUnary<K extends keyof RpcMethodMap>(
|
|
api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
|
|
): Promise<Response> {
|
|
const route = UNARY_ROUTES[method]
|
|
const payload = route.schema.safeParse(message.payload)
|
|
if (!payload.success) {
|
|
return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } })
|
|
}
|
|
try {
|
|
return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }, signal))
|
|
} catch (error: unknown) {
|
|
// The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer.
|
|
return new Response(`handler failure: ${String(error)}`, { status: 500 })
|
|
}
|
|
}
|
|
|
|
/** SSE frame: complete the narrow RpcRequest<frame> into a ServerRequest full form (method = frame type). */
|
|
function fullFrame(narrow: RpcRequest<MuxFrame | HostFrame>): ServerRequest {
|
|
return { type: 'server-request', rpcId: narrow.rpcId, method: narrow.payload.type, payload: narrow.payload }
|
|
}
|
|
|
|
/**
|
|
* Wrap a frame stream as an SSE Response; stops when req.signal aborts. An
|
|
* impl throw mid-stream emits one stream/error frame and then closes.
|
|
*/
|
|
function sseResponse(frames: AsyncIterable<RpcRequest<MuxFrame | HostFrame>>): Response {
|
|
const encoder = new TextEncoder()
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
try {
|
|
// Send an SSE comment line on open so clients/proxies see a live channel (the host
|
|
// stream has no baseline frames and would otherwise emit zero bytes while idle;
|
|
// a comment line is not a frame, so client frame parsing skips it naturally).
|
|
controller.enqueue(encoder.encode(': connected\n\n'))
|
|
for await (const narrow of frames) {
|
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame(narrow))}\n\n`))
|
|
}
|
|
} catch (error: unknown) {
|
|
// Mid-stream impl failure → one stream/error frame, then close: the client must see
|
|
// the failure instead of a silent end (which reads as a normal disconnect). A fresh
|
|
// rpcId is minted — this is a server-initiated push like any other frame.
|
|
const failure: MuxFrame | HostFrame = { type: 'stream/error', error: { code: 'internal', message: String(error), details: {} } }
|
|
try {
|
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame({ rpcId: RpcId(randomUUID()), payload: failure }))}\n\n`))
|
|
} catch {
|
|
// Consumer already cancelled the stream: enqueue-after-cancel is the
|
|
// only reachable error, and there is no one left to tell.
|
|
}
|
|
} finally {
|
|
try {
|
|
controller.close()
|
|
} catch { /* already cancelled by the consumer: a double close is the only reachable error */ }
|
|
}
|
|
},
|
|
})
|
|
return new Response(stream, {
|
|
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient).
|
|
* @param api - the host-side ApiProxy implementation.
|
|
* @returns an object holding `fetch(Request)`; paths outside /api/ return 404.
|
|
*/
|
|
export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
|
|
return {
|
|
// Signature matches global fetch: the isomorphic point hands this function to InProcessApiClient as its transport aspect,
|
|
// Clients call in (url, init) form — normalize to Request before handling.
|
|
async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
const req = input instanceof Request ? input : new Request(input, init)
|
|
const url = new URL(req.url)
|
|
const path = url.pathname
|
|
|
|
if (path === '/api/events.mux' && req.method === 'GET') {
|
|
return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
|
|
}
|
|
if (path === '/api/events.host' && req.method === 'GET') {
|
|
return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
|
|
}
|
|
|
|
if (req.method !== 'POST' || !path.startsWith('/api/')) {
|
|
return new Response('not found', { status: 404 })
|
|
}
|
|
|
|
let body: unknown
|
|
try {
|
|
body = await req.json()
|
|
} catch {
|
|
// 400 = carrier layer (body is not even JSON); valid JSON with a bad shape goes 200 + bad-request.
|
|
return new Response('body is not JSON', { status: 400 })
|
|
}
|
|
|
|
if (path === '/api/respond') {
|
|
const parsed = clientResponseSchema.safeParse(body)
|
|
if (!parsed.success) return Response.json({ accepted: false, reason: 'bad-response' })
|
|
return Response.json(await api.respond(parsed.data))
|
|
}
|
|
|
|
const method = methodFor(path.slice('/api/'.length))
|
|
if (method === undefined) return new Response('not found', { status: 404 })
|
|
|
|
const envelope = clientRequestSchema.safeParse(body)
|
|
if (!envelope.success) {
|
|
// Best effort at correlation: salvage a string rpcId from the raw body;
|
|
// otherwise the fixed sentinel keeps the response a valid ServerResponse.
|
|
const rawId = (body as { rpcId?: unknown } | null)?.rpcId
|
|
const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
|
|
return errorResponse(rpcId, { code: 'bad-request', message: 'invalid client-request message', details: { issues: envelope.error.issues } })
|
|
}
|
|
const message: ClientRequest = envelope.data
|
|
if (message.method !== method) {
|
|
return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } })
|
|
}
|
|
return handleUnary(api, method, message, req.signal)
|
|
},
|
|
}
|
|
}
|