handler.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  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 type { z } from 'zod'
  9. import type { ApiProxy } from '../api/index.ts'
  10. import { sessionLogQuerySchema } from '../api/downloads.schema.ts'
  11. import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
  12. import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerResponse } from '../api/rpc.ts'
  13. import { RpcId } from '../api/rpc.ts'
  14. import type { Wire } from '../api/rpc.schema.ts'
  15. import { clientRequestSchema } from '../api/rpc.schema.ts'
  16. import {
  17. hostDescribeRequestSchema, hostOpenPathRequestSchema,
  18. } from '../api/host.schema.ts'
  19. import { skillListRequestSchema } from '../api/skills.schema.ts'
  20. import {
  21. agentPresetOpenDocumentRequestSchema,
  22. } from '../api/agent-presets.schema.ts'
  23. import {
  24. settingsOpenDocumentRequestSchema,
  25. } from '../api/settings.schema.ts'
  26. import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
  27. /**
  28. * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
  29. * route row fails to compile, and each row's schema/invoke pair is checked against that row's
  30. * payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
  31. * Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
  32. * documented on Wire); the dispatch point carries the one Wire→exact cast.
  33. * Every invoke receives the carrier Request's signal; routes whose contract
  34. * declares a signal parameter forward it, and the rest ignore it.
  35. */
  36. type UnaryRoutes = {
  37. [K in keyof RpcMethodMap]: {
  38. schema: z.ZodType<Wire<RequestPayload<K>>>
  39. invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>, signal: AbortSignal): Promise<RpcResponse<ResponseValue<K>>>
  40. }
  41. }
  42. const UNARY_ROUTES: UnaryRoutes = {
  43. 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
  44. 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
  45. 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
  46. 'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
  47. 'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) },
  48. 'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) },
  49. 'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) },
  50. 'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) },
  51. }
  52. /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
  53. function methodFor(path: string): keyof RpcMethodMap | undefined {
  54. return Object.hasOwn(UNARY_ROUTES, path) ? path as keyof RpcMethodMap : undefined
  55. }
  56. /**
  57. * Sentinel rpcId for error responses to envelopes whose own rpcId is unreadable: the response
  58. * must still be a valid ServerResponse (a self-violating shape would turn the server's explicit
  59. * bad-request report into a client-side parse failure). Fixed value, documented here as wire contract.
  60. */
  61. const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
  62. /** Wrap a business error as a ServerResponse full form (rpcId backfilled; an unreadable rpcId uses the invalid-request sentinel). */
  63. function errorResponse(rpcId: RpcId, error: RpcError): Response {
  64. const body: ServerResponse = { type: 'server-response', rpcId, result: { ok: false, error } }
  65. return Response.json(body)
  66. }
  67. /** Complete the impl's narrow form into a ServerResponse full form. */
  68. function fullResponse(narrow: RpcResponse<unknown>): Response {
  69. const body: ServerResponse = { type: 'server-response', rpcId: narrow.rpcId, result: narrow.result }
  70. return Response.json(body)
  71. }
  72. /**
  73. * Parse the payload and invoke one unary route. Generic over the map key so
  74. * the row's schema/invoke pairing typechecks; the only cast collapses the
  75. * Wire<> widening back to the exact payload (undefined-valued properties and
  76. * absent ones are indistinguishable after JSON transport).
  77. */
  78. // K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
  79. // schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
  80. // oxlint-disable-next-line typescript/no-unnecessary-type-parameters
  81. async function handleUnary<K extends keyof RpcMethodMap>(
  82. api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
  83. ): Promise<Response> {
  84. const route = UNARY_ROUTES[method]
  85. const payload = route.schema.safeParse(message.payload)
  86. if (!payload.success) {
  87. return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } })
  88. }
  89. try {
  90. return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }, signal))
  91. } catch (error: unknown) {
  92. // The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer.
  93. return new Response(`handler failure: ${String(error)}`, { status: 500 })
  94. }
  95. }
  96. /**
  97. * Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient).
  98. * @param api - the host-side ApiProxy implementation.
  99. * @returns an object holding `fetch(Request)`; paths outside /api/ return 404.
  100. */
  101. export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
  102. return {
  103. // Signature matches global fetch: the isomorphic point hands this function to InProcessApiClient as its transport aspect,
  104. // Clients call in (url, init) form — normalize to Request before handling.
  105. async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
  106. const req = input instanceof Request ? input : new Request(input, init)
  107. const url = new URL(req.url)
  108. const path = url.pathname
  109. // No-envelope Host-only download channel:
  110. // physical routes that answer directly, without a wire envelope.
  111. if (path === '/api/session.export' && (req.method === 'GET' || req.method === 'HEAD')) {
  112. // Query params are a different boundary from the POST envelope, but
  113. // the request still casts its brands only through the domain schema.
  114. const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams))
  115. if (!parsed.success) {
  116. return new Response('missing or invalid sessionId query parameter', { status: 400 })
  117. }
  118. const response = await api.downloads.sessionLog(parsed.data, req.signal)
  119. if (req.method === 'GET') return response
  120. await response.body?.cancel()
  121. return new Response(null, { status: response.status, headers: response.headers })
  122. }
  123. if (req.method !== 'POST' || !path.startsWith('/api/')) {
  124. return new Response('not found', { status: 404 })
  125. }
  126. // Cross-site write fence: browsers send "simple" POSTs (text/plain,
  127. // form encodings) without a CORS preflight, so a malicious page could
  128. // otherwise execute side-effectful RPCs blind — the response stays
  129. // unreadable cross-origin, but the requested mutation would still run. Only the
  130. // JSON media type is accepted; anything else is forced into a preflight
  131. // this server never answers. 415 = carrier layer, like the 400 below.
  132. const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
  133. if (mediaType !== 'application/json') {
  134. return new Response('content type must be application/json', { status: 415 })
  135. }
  136. let body: unknown
  137. try {
  138. body = await req.json()
  139. } catch {
  140. // 400 = carrier layer (body is not even JSON); valid JSON with a bad shape goes 200 + bad-request.
  141. return new Response('body is not JSON', { status: 400 })
  142. }
  143. const method = methodFor(path.slice('/api/'.length))
  144. if (method === undefined) return new Response('not found', { status: 404 })
  145. const envelope = clientRequestSchema.safeParse(body)
  146. if (!envelope.success) {
  147. // Best effort at correlation: salvage a string rpcId from the raw body;
  148. // otherwise the fixed sentinel keeps the response a valid ServerResponse.
  149. const rawId = (body as { rpcId?: unknown } | null)?.rpcId
  150. const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
  151. return errorResponse(rpcId, { code: 'bad-request', message: 'invalid client-request message', details: { issues: envelope.error.issues } })
  152. }
  153. const message: ClientRequest = envelope.data
  154. if (message.method !== method) {
  155. return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } })
  156. }
  157. return handleUnary(api, method, message, req.signal)
  158. },
  159. }
  160. }