client.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. /**
  2. * Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting,
  3. * four-quadrant envelope wrap/unwrap, zod parsing, SSE frame decoding, and the payload-direct
  4. * IApiClient domain methods (business code never mints). Platform differences ride two aspects:
  5. * abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched.
  6. */
  7. import type { z } from 'zod'
  8. import type { ApiProxy, HostFrame, MuxFrame } from '../api/index.ts'
  9. import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
  10. import type { ClientRequest, ClientResponse, RpcMessage, RpcReceipt, RpcRequest, RpcResponse, ServerRequest } from '../api/rpc.ts'
  11. import { RpcId } from '../api/rpc.ts'
  12. import type { Wire } from '../api/rpc.schema.ts'
  13. import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
  14. import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
  15. import { hostDescribeValueSchema } from '../api/host.schema.ts'
  16. import {
  17. sessionCancelValueSchema,
  18. sessionCreateValueSchema,
  19. sessionHistoryValueSchema,
  20. sessionListValueSchema,
  21. sessionModelsValueSchema,
  22. sessionPromptValueSchema,
  23. sessionSelectModelValueSchema,
  24. } from '../api/sessions.schema.ts'
  25. import {
  26. workspaceCreateValueSchema,
  27. workspaceInsertSessionBeforeValueSchema,
  28. workspaceListValueSchema,
  29. workspaceRenameValueSchema,
  30. } from '../api/workspace.schema.ts'
  31. /**
  32. * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
  33. * methods take the business payload directly — the carrier mints the rpcId and wraps the
  34. * envelope. Business code needing the call's rpcId reads it from the RpcResponse echo.
  35. * Unary methods and respond accept an optional external AbortSignal as the last parameter
  36. * (merged with the instance timeout via AbortSignal.any; same "signal rides beside the
  37. * request, never on the wire" discipline as the stream signatures).
  38. * Stream methods accept an optional onOpen callback: it fires once the SSE transport is
  39. * readable (response headers received, before any frame) — the "stream established" signal
  40. * connection controllers need for the readiness handshake. Generators are lazy, so the
  41. * underlying fetch (and therefore onOpen) only happens once iteration starts.
  42. * Relationship: ApiProxy is the narrow-form signature contract the impl side implements;
  43. * IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two.
  44. * Derived per method key from RpcMethodMap so a map row addition updates this mechanically.
  45. */
  46. export interface IApiClient {
  47. sessions: {
  48. list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
  49. create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
  50. history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
  51. models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
  52. selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
  53. prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
  54. cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
  55. }
  56. host: {
  57. describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
  58. }
  59. workspace: {
  60. list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
  61. create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
  62. rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
  63. insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
  64. }
  65. events: {
  66. mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
  67. host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
  68. }
  69. /** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
  70. respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
  71. }
  72. /**
  73. * S→C second-level parse table: value schema by method (the response-path
  74. * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap).
  75. */
  76. const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
  77. 'session.list': sessionListValueSchema,
  78. 'session.create': sessionCreateValueSchema,
  79. 'session.history': sessionHistoryValueSchema,
  80. 'session.models': sessionModelsValueSchema,
  81. 'session.selectModel': sessionSelectModelValueSchema,
  82. 'session.prompt': sessionPromptValueSchema,
  83. 'session.cancel': sessionCancelValueSchema,
  84. 'host.describe': hostDescribeValueSchema,
  85. 'workspace.list': workspaceListValueSchema,
  86. 'workspace.create': workspaceCreateValueSchema,
  87. 'workspace.rename': workspaceRenameValueSchema,
  88. 'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
  89. }
  90. /** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
  91. const DEFAULT_TIMEOUT_MS = 30_000
  92. /** URL base for in-process handler injection (fake authority, opencode precedent). */
  93. const INTERNAL_BASE = 'http://dsh.internal'
  94. /**
  95. * Abstract fetch-carrier client. Subclasses supply the transport (doFetch) and may refine the
  96. * per-message tap (onEnvelope) — platform aspects stay in subclasses, protocol invariants stay
  97. * here. Envelope observation is a first-class aspect of this data middle layer: the instance
  98. * owns a microtask-batched buffer (frame storms must not cost one consumer update per frame),
  99. * and observers subscribe via subscribeEnvelopes. The isomorphic point survives: an in-process
  100. * subclass whose doFetch is toFetchHandler(api).fetch never touches the network.
  101. */
  102. export abstract class AbstractApiClient implements IApiClient {
  103. /** Instance-owned observation buffer (module-level state would leak across instances/tests). */
  104. private envelopeBatch: RpcMessage[] = []
  105. private flushScheduled = false
  106. private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>()
  107. /** @param timeoutMs - unary timeout; streams never time out (long-lived by nature). */
  108. constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
  109. /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */
  110. protected abstract doFetch(input: URL, init?: RequestInit): Promise<Response>
  111. /**
  112. * Subscribe to batched envelope observation (diagnostics/logging consumers).
  113. * Batches follow microtask boundaries; a listener throw is isolated (observation
  114. * must never break the carrier).
  115. * @param listener - receives each flushed batch in arrival order.
  116. * @returns unsubscribe function.
  117. */
  118. subscribeEnvelopes(listener: (batch: readonly RpcMessage[]) => void): () => void {
  119. this.envelopeListeners.add(listener)
  120. return () => {
  121. this.envelopeListeners.delete(listener)
  122. }
  123. }
  124. /** Per-message tap: feeds the instance buffer. Subclasses may override to observe unbatched (call super to keep batching). */
  125. protected onEnvelope(message: RpcMessage): void {
  126. if (this.envelopeListeners.size === 0) return
  127. this.envelopeBatch.push(message)
  128. if (this.flushScheduled) return
  129. this.flushScheduled = true
  130. queueMicrotask(() => {
  131. this.flushScheduled = false
  132. // Never empty here: a flush is only ever scheduled by the push above,
  133. // and this callback is the sole drain point.
  134. const batch = this.envelopeBatch
  135. this.envelopeBatch = []
  136. for (const notify of this.envelopeListeners) {
  137. try {
  138. notify(batch)
  139. } catch (error) {
  140. console.error('[apiproxy] envelope listener threw:', error)
  141. }
  142. }
  143. })
  144. }
  145. /** Browser = same-origin (a fake authority would fail DNS on real requests); no-location env (Node) = fake authority. */
  146. protected resolveBase(): string {
  147. const loc = (globalThis as { location?: { origin?: string } }).location
  148. return loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : INTERNAL_BASE
  149. }
  150. protected mintRpcId(): RpcId {
  151. // crypto.randomUUID is a Web API (browser + Node ≥19): keeps this base platform-neutral.
  152. return RpcId(crypto.randomUUID())
  153. }
  154. /**
  155. * Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
  156. * timeout merged with the caller's optional external signal, non-2xx → transport throw.
  157. */
  158. private async postJson(path: string, body: ClientRequest | ClientResponse, signal: AbortSignal | undefined): Promise<Response> {
  159. const timeout = AbortSignal.timeout(this.timeoutMs)
  160. const response = await this.doFetch(new URL(path, this.resolveBase()), {
  161. method: 'POST',
  162. headers: { 'content-type': 'application/json' },
  163. body: JSON.stringify(body),
  164. signal: signal === undefined ? timeout : AbortSignal.any([timeout, signal]),
  165. })
  166. if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
  167. return response
  168. }
  169. /**
  170. * Unary protocol path: mint → tap → POST full form → envelope parse → verify
  171. * echo → value parse → tap → narrow. Virtual so a fake carrier (fixture) can
  172. * override transport at this layer.
  173. */
  174. protected async callUnary<K extends keyof RpcMethodMap>(
  175. method: K,
  176. payload: RequestPayload<K>,
  177. signal?: AbortSignal,
  178. ): Promise<RpcResponse<ResponseValue<K>>> {
  179. const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
  180. this.onEnvelope(message)
  181. const response = await this.postJson(`/api/${method}`, message, signal)
  182. const full = serverResponseSchema.parse(await response.json())
  183. this.onEnvelope(full)
  184. if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
  185. if (!full.result.ok) return { rpcId: full.rpcId, result: full.result }
  186. // Second-level S→C parse: the ok value must match the method's Value schema (mirror of the
  187. // handler's request-payload parse). The cast collapses the Wire<> widening, same as the handler side.
  188. const value = UNARY_VALUE_SCHEMAS[method].parse(full.result.value) as ResponseValue<K>
  189. return { rpcId: full.rpcId, result: { ok: true, value } }
  190. }
  191. /** Mux stream opener; virtual for the same override reason as callUnary. */
  192. protected openMux(_payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>> {
  193. return this.readSse('/api/events.mux', signal, muxFrameSchema, onOpen)
  194. }
  195. /** Host stream opener; virtual. */
  196. protected openHost(_payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>> {
  197. return this.readSse('/api/events.host', signal, hostFrameSchema, onOpen)
  198. }
  199. /**
  200. * SSE protocol path: streaming fetch (not EventSource), '\n\n' framing, ServerRequest envelope +
  201. * frame-schema parse, tap, narrow yield. onOpen fires once the response headers are in and the
  202. * body is readable — the stream-established signal, before any frame arrives. A frame that fails
  203. * either parse level is reported and skipped (one corrupt frame must not kill the stream; the
  204. * client's gap detection covers whatever the frame carried).
  205. */
  206. protected async *readSse<F extends MuxFrame | HostFrame>(
  207. path: string,
  208. signal: AbortSignal,
  209. frameSchema: z.ZodType<F>,
  210. onOpen?: () => void,
  211. ): AsyncGenerator<RpcRequest<F>> {
  212. const response = await this.doFetch(new URL(path, this.resolveBase()), { signal })
  213. if (!response.ok || response.body === null) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
  214. onOpen?.()
  215. const reader = response.body.getReader()
  216. const decoder = new TextDecoder()
  217. let buffer = ''
  218. try {
  219. while (true) {
  220. const { done, value } = await reader.read()
  221. if (done) return
  222. buffer += decoder.decode(value, { stream: true })
  223. let boundary: number
  224. while ((boundary = buffer.indexOf('\n\n')) !== -1) {
  225. const chunk = buffer.slice(0, boundary)
  226. buffer = buffer.slice(boundary + 2)
  227. const data = chunk.split('\n').filter(line => line.startsWith('data: ')).map(line => line.slice(6)).join('')
  228. if (data === '') continue
  229. let full: ServerRequest
  230. let frame: F
  231. try {
  232. full = serverRequestSchema.parse(JSON.parse(data))
  233. frame = frameSchema.parse(full.payload)
  234. } catch (error) {
  235. console.error(`[apiproxy] dropping malformed SSE frame on ${path}:`, error)
  236. continue
  237. }
  238. this.onEnvelope(full)
  239. yield { rpcId: full.rpcId, payload: frame }
  240. }
  241. }
  242. } finally {
  243. await reader.cancel().catch(() => undefined)
  244. }
  245. }
  246. // ---- IApiClient surface (arrow properties so destructured/passed references stay bound) ----
  247. readonly sessions: IApiClient['sessions'] = {
  248. list: (payload, signal) => this.callUnary('session.list', payload, signal),
  249. create: (payload, signal) => this.callUnary('session.create', payload, signal),
  250. history: (payload, signal) => this.callUnary('session.history', payload, signal),
  251. models: (payload, signal) => this.callUnary('session.models', payload, signal),
  252. selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
  253. prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
  254. cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
  255. }
  256. readonly host: IApiClient['host'] = {
  257. describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
  258. }
  259. readonly workspace: IApiClient['workspace'] = {
  260. list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
  261. create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
  262. rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
  263. insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
  264. }
  265. readonly events: IApiClient['events'] = {
  266. mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
  267. host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),
  268. }
  269. async respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt> {
  270. this.onEnvelope(message)
  271. const response = await this.postJson('/api/respond', message, signal)
  272. return rpcReceiptSchema.parse(await response.json())
  273. }
  274. }
  275. /**
  276. * In-process client over an injected fetch-shaped handler (the isomorphic point:
  277. * `new InProcessApiClient(toFetchHandler(api))` never touches the network). Lives here because
  278. * in-process injection is this package's own capability (handler and client are both local).
  279. */
  280. export class InProcessApiClient extends AbstractApiClient {
  281. constructor(private readonly handler: { fetch: typeof fetch }, timeoutMs?: number) {
  282. super(timeoutMs)
  283. }
  284. /**
  285. * Faithful to real fetch: reject on signal abort even when the in-process
  286. * handler ignores the signal (a hung impl must not defeat timeout/cancel).
  287. */
  288. protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
  289. const signal = init?.signal ?? undefined
  290. if (signal === undefined) return this.handler.fetch(input, init)
  291. if (signal.aborted) return Promise.reject(abortError(signal))
  292. return new Promise((resolve, reject) => {
  293. const onAbort = (): void => { reject(abortError(signal)) }
  294. signal.addEventListener('abort', onAbort, { once: true })
  295. this.handler.fetch(input, init)
  296. .then(resolve, reject)
  297. .finally(() => { signal.removeEventListener('abort', onAbort) })
  298. })
  299. }
  300. }
  301. /** Mirror fetch's abort rejection: the signal's reason when present, else a DOMException-style AbortError. */
  302. function abortError(signal: AbortSignal): Error {
  303. const reason: unknown = signal.reason
  304. if (reason instanceof Error) return reason
  305. if (typeof reason === 'string') return new Error(reason)
  306. return new Error('This operation was aborted')
  307. }