handler.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. /**
  2. * Server side of the fetch carrier: maps an ApiProxy onto a pure
  3. * WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method +
  4. * path==method) -> payload dispatched per method. HTTP status expresses only the carrier
  5. * (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash);
  6. * business errors are always 200 + ServerResponse.
  7. */
  8. import { randomUUID } from 'node:crypto'
  9. import type { z } from 'zod'
  10. import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts'
  11. import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
  12. import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts'
  13. import { RpcId } from '../api/rpc.ts'
  14. import type { Wire } from '../api/rpc.schema.ts'
  15. import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
  16. import {
  17. sessionCancelRequestSchema,
  18. sessionCreateRequestSchema,
  19. sessionForkRequestSchema,
  20. sessionHistoryRequestSchema,
  21. sessionListRequestSchema,
  22. sessionModelsRequestSchema,
  23. sessionPromptRequestSchema,
  24. sessionRenameRequestSchema,
  25. sessionSelectModelRequestSchema,
  26. sessionUpdateQueueRequestSchema,
  27. } from '../api/sessions.schema.ts'
  28. import {
  29. hostCreateDirectoryRequestSchema, hostDescribeRequestSchema,
  30. hostListDirectoryRequestSchema, hostOpenPathRequestSchema,
  31. hostPickDirectoryRequestSchema,
  32. } from '../api/host.schema.ts'
  33. import {
  34. workspaceCreateRequestSchema,
  35. workspaceDeleteRequestSchema,
  36. workspaceInsertSessionBeforeRequestSchema,
  37. workspaceListRequestSchema,
  38. workspaceRenameRequestSchema,
  39. } from '../api/workspace.schema.ts'
  40. import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
  41. import { skillListRequestSchema } from '../api/skills.schema.ts'
  42. import {
  43. goalCreateRequestSchema,
  44. goalEditRequestSchema,
  45. goalPauseRequestSchema,
  46. goalResumeRequestSchema,
  47. goalCompleteRequestSchema,
  48. goalClearRequestSchema,
  49. } from '../api/goals.schema.ts'
  50. /**
  51. * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
  52. * route row fails to compile, and each row's schema/invoke pair is checked against that row's
  53. * payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
  54. * Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
  55. * documented on Wire); the dispatch point carries the one Wire→exact cast.
  56. * Every invoke receives the carrier Request's signal; methods whose contract
  57. * declares a signal parameter (command.execute) forward it, the rest ignore it.
  58. */
  59. type UnaryRoutes = {
  60. [K in keyof RpcMethodMap]: {
  61. schema: z.ZodType<Wire<RequestPayload<K>>>
  62. invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>, signal: AbortSignal): Promise<RpcResponse<ResponseValue<K>>>
  63. }
  64. }
  65. const UNARY_ROUTES: UnaryRoutes = {
  66. 'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
  67. 'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
  68. 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
  69. 'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
  70. 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
  71. 'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
  72. 'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
  73. 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
  74. 'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
  75. 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
  76. 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
  77. 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
  78. 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },
  79. 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) },
  80. 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
  81. 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
  82. 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
  83. 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
  84. 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
  85. 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
  86. 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
  87. 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
  88. 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
  89. 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
  90. 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
  91. 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
  92. 'goal.resume': { schema: goalResumeRequestSchema, invoke: (api, r) => api.goals.resume(r) },
  93. 'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) },
  94. 'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) },
  95. }
  96. /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
  97. function methodFor(path: string): keyof RpcMethodMap | undefined {
  98. return Object.hasOwn(UNARY_ROUTES, path) ? path as keyof RpcMethodMap : undefined
  99. }
  100. /**
  101. * Sentinel rpcId for error responses to envelopes whose own rpcId is unreadable: the response
  102. * must still be a valid ServerResponse (a self-violating shape would turn the server's explicit
  103. * bad-request report into a client-side parse failure). Fixed value, documented here as wire contract.
  104. */
  105. const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
  106. /** Wrap a business error as a ServerResponse full form (rpcId backfilled; an unreadable rpcId uses the invalid-request sentinel). */
  107. function errorResponse(rpcId: RpcId, error: RpcError): Response {
  108. const body: ServerResponse = { type: 'server-response', rpcId, result: { ok: false, error } }
  109. return Response.json(body)
  110. }
  111. /** Complete the impl's narrow form into a ServerResponse full form. */
  112. function fullResponse(narrow: RpcResponse<unknown>): Response {
  113. const body: ServerResponse = { type: 'server-response', rpcId: narrow.rpcId, result: narrow.result }
  114. return Response.json(body)
  115. }
  116. /**
  117. * Parse the payload and invoke one unary route. Generic over the map key so
  118. * the row's schema/invoke pairing typechecks; the only cast collapses the
  119. * Wire<> widening back to the exact payload (undefined-valued properties and
  120. * absent ones are indistinguishable after JSON transport).
  121. */
  122. // K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
  123. // schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
  124. // oxlint-disable-next-line typescript/no-unnecessary-type-parameters
  125. async function handleUnary<K extends keyof RpcMethodMap>(
  126. api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
  127. ): Promise<Response> {
  128. const route = UNARY_ROUTES[method]
  129. const payload = route.schema.safeParse(message.payload)
  130. if (!payload.success) {
  131. return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } })
  132. }
  133. try {
  134. return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }, signal))
  135. } catch (error: unknown) {
  136. // The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer.
  137. return new Response(`handler failure: ${String(error)}`, { status: 500 })
  138. }
  139. }
  140. /** SSE frame: complete the narrow RpcRequest<frame> into a ServerRequest full form (method = frame type). */
  141. function fullFrame(narrow: RpcRequest<MuxFrame | HostFrame>): ServerRequest {
  142. return { type: 'server-request', rpcId: narrow.rpcId, method: narrow.payload.type, payload: narrow.payload }
  143. }
  144. /**
  145. * Wrap a frame stream as an SSE Response; stops when req.signal aborts. An
  146. * impl throw mid-stream emits one stream/error frame and then closes.
  147. */
  148. function sseResponse(frames: AsyncIterable<RpcRequest<MuxFrame | HostFrame>>): Response {
  149. const encoder = new TextEncoder()
  150. const stream = new ReadableStream<Uint8Array>({
  151. async start(controller) {
  152. try {
  153. // Send an SSE comment line on open so clients/proxies see a live channel (the host
  154. // stream has no baseline frames and would otherwise emit zero bytes while idle;
  155. // a comment line is not a frame, so client frame parsing skips it naturally).
  156. controller.enqueue(encoder.encode(': connected\n\n'))
  157. for await (const narrow of frames) {
  158. controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame(narrow))}\n\n`))
  159. }
  160. } catch (error: unknown) {
  161. // Mid-stream impl failure → one stream/error frame, then close: the client must see
  162. // the failure instead of a silent end (which reads as a normal disconnect). A fresh
  163. // rpcId is minted — this is a server-initiated push like any other frame.
  164. const failure: MuxFrame | HostFrame = { type: 'stream/error', error: { code: 'internal', message: String(error), details: {} } }
  165. try {
  166. controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame({ rpcId: RpcId(randomUUID()), payload: failure }))}\n\n`))
  167. } catch {
  168. // Consumer already cancelled the stream: enqueue-after-cancel is the
  169. // only reachable error, and there is no one left to tell.
  170. }
  171. } finally {
  172. try {
  173. controller.close()
  174. } catch { /* already cancelled by the consumer: a double close is the only reachable error */ }
  175. }
  176. },
  177. })
  178. return new Response(stream, {
  179. headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
  180. })
  181. }
  182. /**
  183. * Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient).
  184. * @param api - the host-side ApiProxy implementation.
  185. * @returns an object holding `fetch(Request)`; paths outside /api/ return 404.
  186. */
  187. export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
  188. return {
  189. // Signature matches global fetch: the isomorphic point hands this function to InProcessApiClient as its transport aspect,
  190. // Clients call in (url, init) form — normalize to Request before handling.
  191. async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
  192. const req = input instanceof Request ? input : new Request(input, init)
  193. const url = new URL(req.url)
  194. const path = url.pathname
  195. if (path === '/api/events.mux' && req.method === 'GET') {
  196. return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
  197. }
  198. if (path === '/api/events.host' && req.method === 'GET') {
  199. return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
  200. }
  201. if (req.method !== 'POST' || !path.startsWith('/api/')) {
  202. return new Response('not found', { status: 404 })
  203. }
  204. // Cross-site write fence: browsers send "simple" POSTs (text/plain,
  205. // form encodings) without a CORS preflight, so a malicious page could
  206. // otherwise execute side-effectful RPCs blind — the response stays
  207. // unreadable cross-origin, but session.prompt would still run. Only the
  208. // JSON media type is accepted; anything else is forced into a preflight
  209. // this server never answers. 415 = carrier layer, like the 400 below.
  210. const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
  211. if (mediaType !== 'application/json') {
  212. return new Response('content type must be application/json', { status: 415 })
  213. }
  214. let body: unknown
  215. try {
  216. body = await req.json()
  217. } catch {
  218. // 400 = carrier layer (body is not even JSON); valid JSON with a bad shape goes 200 + bad-request.
  219. return new Response('body is not JSON', { status: 400 })
  220. }
  221. if (path === '/api/respond') {
  222. const parsed = clientResponseSchema.safeParse(body)
  223. if (!parsed.success) return Response.json({ accepted: false, reason: 'bad-response' })
  224. return Response.json(await api.respond(parsed.data))
  225. }
  226. const method = methodFor(path.slice('/api/'.length))
  227. if (method === undefined) return new Response('not found', { status: 404 })
  228. const envelope = clientRequestSchema.safeParse(body)
  229. if (!envelope.success) {
  230. // Best effort at correlation: salvage a string rpcId from the raw body;
  231. // otherwise the fixed sentinel keeps the response a valid ServerResponse.
  232. const rawId = (body as { rpcId?: unknown } | null)?.rpcId
  233. const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
  234. return errorResponse(rpcId, { code: 'bad-request', message: 'invalid client-request message', details: { issues: envelope.error.issues } })
  235. }
  236. const message: ClientRequest = envelope.data
  237. if (message.method !== method) {
  238. return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } })
  239. }
  240. return handleUnary(api, method, message, req.signal)
  241. },
  242. }
  243. }