| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333 |
- /**
- * Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting,
- * four-quadrant envelope wrap/unwrap, zod parsing, SSE frame decoding, and the payload-direct
- * IApiClient domain methods (business code never mints). Platform differences ride two aspects:
- * abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched.
- */
- import type { z } from 'zod'
- import type { ApiProxy, HostFrame, MuxFrame } from '../api/index.ts'
- import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
- import type { ClientRequest, ClientResponse, RpcMessage, RpcReceipt, RpcRequest, RpcResponse, ServerRequest } from '../api/rpc.ts'
- import { RpcId } from '../api/rpc.ts'
- import type { Wire } from '../api/rpc.schema.ts'
- import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
- import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
- import { hostDescribeValueSchema } from '../api/host.schema.ts'
- import {
- sessionCancelValueSchema,
- sessionCreateValueSchema,
- sessionHistoryValueSchema,
- sessionListValueSchema,
- sessionModelsValueSchema,
- sessionPromptValueSchema,
- sessionSelectModelValueSchema,
- } from '../api/sessions.schema.ts'
- import {
- workspaceCreateValueSchema,
- workspaceInsertSessionBeforeValueSchema,
- workspaceListValueSchema,
- workspaceRenameValueSchema,
- } from '../api/workspace.schema.ts'
- /**
- * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
- * methods take the business payload directly — the carrier mints the rpcId and wraps the
- * envelope. Business code needing the call's rpcId reads it from the RpcResponse echo.
- * Unary methods and respond accept an optional external AbortSignal as the last parameter
- * (merged with the instance timeout via AbortSignal.any; same "signal rides beside the
- * request, never on the wire" discipline as the stream signatures).
- * Stream methods accept an optional onOpen callback: it fires once the SSE transport is
- * readable (response headers received, before any frame) — the "stream established" signal
- * connection controllers need for the readiness handshake. Generators are lazy, so the
- * underlying fetch (and therefore onOpen) only happens once iteration starts.
- * Relationship: ApiProxy is the narrow-form signature contract the impl side implements;
- * IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two.
- * Derived per method key from RpcMethodMap so a map row addition updates this mechanically.
- */
- export interface IApiClient {
- sessions: {
- list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
- create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
- history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
- models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
- selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
- prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
- cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
- }
- host: {
- describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
- }
- workspace: {
- list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
- create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
- rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
- insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
- }
- events: {
- mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
- host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
- }
- /** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
- respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
- }
- /**
- * S→C second-level parse table: value schema by method (the response-path
- * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap).
- */
- const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
- 'session.list': sessionListValueSchema,
- 'session.create': sessionCreateValueSchema,
- 'session.history': sessionHistoryValueSchema,
- 'session.models': sessionModelsValueSchema,
- 'session.selectModel': sessionSelectModelValueSchema,
- 'session.prompt': sessionPromptValueSchema,
- 'session.cancel': sessionCancelValueSchema,
- 'host.describe': hostDescribeValueSchema,
- 'workspace.list': workspaceListValueSchema,
- 'workspace.create': workspaceCreateValueSchema,
- 'workspace.rename': workspaceRenameValueSchema,
- 'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
- }
- /** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
- const DEFAULT_TIMEOUT_MS = 30_000
- /** URL base for in-process handler injection (fake authority, opencode precedent). */
- const INTERNAL_BASE = 'http://dsh.internal'
- /**
- * Abstract fetch-carrier client. Subclasses supply the transport (doFetch) and may refine the
- * per-message tap (onEnvelope) — platform aspects stay in subclasses, protocol invariants stay
- * here. Envelope observation is a first-class aspect of this data middle layer: the instance
- * owns a microtask-batched buffer (frame storms must not cost one consumer update per frame),
- * and observers subscribe via subscribeEnvelopes. The isomorphic point survives: an in-process
- * subclass whose doFetch is toFetchHandler(api).fetch never touches the network.
- */
- export abstract class AbstractApiClient implements IApiClient {
- /** Instance-owned observation buffer (module-level state would leak across instances/tests). */
- private envelopeBatch: RpcMessage[] = []
- private flushScheduled = false
- private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>()
- /** @param timeoutMs - unary timeout; streams never time out (long-lived by nature). */
- constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
- /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */
- protected abstract doFetch(input: URL, init?: RequestInit): Promise<Response>
- /**
- * Subscribe to batched envelope observation (diagnostics/logging consumers).
- * Batches follow microtask boundaries; a listener throw is isolated (observation
- * must never break the carrier).
- * @param listener - receives each flushed batch in arrival order.
- * @returns unsubscribe function.
- */
- subscribeEnvelopes(listener: (batch: readonly RpcMessage[]) => void): () => void {
- this.envelopeListeners.add(listener)
- return () => {
- this.envelopeListeners.delete(listener)
- }
- }
- /** Per-message tap: feeds the instance buffer. Subclasses may override to observe unbatched (call super to keep batching). */
- protected onEnvelope(message: RpcMessage): void {
- if (this.envelopeListeners.size === 0) return
- this.envelopeBatch.push(message)
- if (this.flushScheduled) return
- this.flushScheduled = true
- queueMicrotask(() => {
- this.flushScheduled = false
- // Never empty here: a flush is only ever scheduled by the push above,
- // and this callback is the sole drain point.
- const batch = this.envelopeBatch
- this.envelopeBatch = []
- for (const notify of this.envelopeListeners) {
- try {
- notify(batch)
- } catch (error) {
- console.error('[apiproxy] envelope listener threw:', error)
- }
- }
- })
- }
- /** Browser = same-origin (a fake authority would fail DNS on real requests); no-location env (Node) = fake authority. */
- protected resolveBase(): string {
- const loc = (globalThis as { location?: { origin?: string } }).location
- return loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : INTERNAL_BASE
- }
- protected mintRpcId(): RpcId {
- // crypto.randomUUID is a Web API (browser + Node ≥19): keeps this base platform-neutral.
- return RpcId(crypto.randomUUID())
- }
- /**
- * Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
- * timeout merged with the caller's optional external signal, non-2xx → transport throw.
- */
- private async postJson(path: string, body: ClientRequest | ClientResponse, signal: AbortSignal | undefined): Promise<Response> {
- const timeout = AbortSignal.timeout(this.timeoutMs)
- const response = await this.doFetch(new URL(path, this.resolveBase()), {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify(body),
- signal: signal === undefined ? timeout : AbortSignal.any([timeout, signal]),
- })
- if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
- return response
- }
- /**
- * Unary protocol path: mint → tap → POST full form → envelope parse → verify
- * echo → value parse → tap → narrow. Virtual so a fake carrier (fixture) can
- * override transport at this layer.
- */
- protected async callUnary<K extends keyof RpcMethodMap>(
- method: K,
- payload: RequestPayload<K>,
- signal?: AbortSignal,
- ): Promise<RpcResponse<ResponseValue<K>>> {
- const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
- this.onEnvelope(message)
- const response = await this.postJson(`/api/${method}`, message, signal)
- const full = serverResponseSchema.parse(await response.json())
- this.onEnvelope(full)
- if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
- if (!full.result.ok) return { rpcId: full.rpcId, result: full.result }
- // Second-level S→C parse: the ok value must match the method's Value schema (mirror of the
- // handler's request-payload parse). The cast collapses the Wire<> widening, same as the handler side.
- const value = UNARY_VALUE_SCHEMAS[method].parse(full.result.value) as ResponseValue<K>
- return { rpcId: full.rpcId, result: { ok: true, value } }
- }
- /** Mux stream opener; virtual for the same override reason as callUnary. */
- protected openMux(_payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>> {
- return this.readSse('/api/events.mux', signal, muxFrameSchema, onOpen)
- }
- /** Host stream opener; virtual. */
- protected openHost(_payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>> {
- return this.readSse('/api/events.host', signal, hostFrameSchema, onOpen)
- }
- /**
- * SSE protocol path: streaming fetch (not EventSource), '\n\n' framing, ServerRequest envelope +
- * frame-schema parse, tap, narrow yield. onOpen fires once the response headers are in and the
- * body is readable — the stream-established signal, before any frame arrives. A frame that fails
- * either parse level is reported and skipped (one corrupt frame must not kill the stream; the
- * client's gap detection covers whatever the frame carried).
- */
- protected async *readSse<F extends MuxFrame | HostFrame>(
- path: string,
- signal: AbortSignal,
- frameSchema: z.ZodType<F>,
- onOpen?: () => void,
- ): AsyncGenerator<RpcRequest<F>> {
- const response = await this.doFetch(new URL(path, this.resolveBase()), { signal })
- if (!response.ok || response.body === null) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
- onOpen?.()
- const reader = response.body.getReader()
- const decoder = new TextDecoder()
- let buffer = ''
- try {
- while (true) {
- const { done, value } = await reader.read()
- if (done) return
- buffer += decoder.decode(value, { stream: true })
- let boundary: number
- while ((boundary = buffer.indexOf('\n\n')) !== -1) {
- const chunk = buffer.slice(0, boundary)
- buffer = buffer.slice(boundary + 2)
- const data = chunk.split('\n').filter(line => line.startsWith('data: ')).map(line => line.slice(6)).join('')
- if (data === '') continue
- let full: ServerRequest
- let frame: F
- try {
- full = serverRequestSchema.parse(JSON.parse(data))
- frame = frameSchema.parse(full.payload)
- } catch (error) {
- console.error(`[apiproxy] dropping malformed SSE frame on ${path}:`, error)
- continue
- }
- this.onEnvelope(full)
- yield { rpcId: full.rpcId, payload: frame }
- }
- }
- } finally {
- await reader.cancel().catch(() => undefined)
- }
- }
- // ---- IApiClient surface (arrow properties so destructured/passed references stay bound) ----
- readonly sessions: IApiClient['sessions'] = {
- list: (payload, signal) => this.callUnary('session.list', payload, signal),
- create: (payload, signal) => this.callUnary('session.create', payload, signal),
- history: (payload, signal) => this.callUnary('session.history', payload, signal),
- models: (payload, signal) => this.callUnary('session.models', payload, signal),
- selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
- prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
- cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
- }
- readonly host: IApiClient['host'] = {
- describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
- }
- readonly workspace: IApiClient['workspace'] = {
- list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
- create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
- rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
- insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
- }
- readonly events: IApiClient['events'] = {
- mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
- host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),
- }
- async respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt> {
- this.onEnvelope(message)
- const response = await this.postJson('/api/respond', message, signal)
- return rpcReceiptSchema.parse(await response.json())
- }
- }
- /**
- * In-process client over an injected fetch-shaped handler (the isomorphic point:
- * `new InProcessApiClient(toFetchHandler(api))` never touches the network). Lives here because
- * in-process injection is this package's own capability (handler and client are both local).
- */
- export class InProcessApiClient extends AbstractApiClient {
- constructor(private readonly handler: { fetch: typeof fetch }, timeoutMs?: number) {
- super(timeoutMs)
- }
- /**
- * Faithful to real fetch: reject on signal abort even when the in-process
- * handler ignores the signal (a hung impl must not defeat timeout/cancel).
- */
- protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
- const signal = init?.signal ?? undefined
- if (signal === undefined) return this.handler.fetch(input, init)
- if (signal.aborted) return Promise.reject(abortError(signal))
- return new Promise((resolve, reject) => {
- const onAbort = (): void => { reject(abortError(signal)) }
- signal.addEventListener('abort', onAbort, { once: true })
- this.handler.fetch(input, init)
- .then(resolve, reject)
- .finally(() => { signal.removeEventListener('abort', onAbort) })
- })
- }
- }
- /** Mirror fetch's abort rejection: the signal's reason when present, else a DOMException-style AbortError. */
- function abortError(signal: AbortSignal): Error {
- const reason: unknown = signal.reason
- if (reason instanceof Error) return reason
- if (typeof reason === 'string') return new Error(reason)
- return new Error('This operation was aborted')
- }
|