fetch-carrier.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import { describe, expect, it, vi } from 'vitest'
  2. import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts'
  3. import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts'
  4. import { RpcId } from '../src/api/rpc.ts'
  5. import { toFetchHandler } from '../src/fetch/handler.ts'
  6. import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts'
  7. /** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */
  8. function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFrame[]; crashOn: string }> = {}): ApiProxy {
  9. const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }]
  10. const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }]
  11. async function * stream<F>(frames: F[], signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
  12. for (const payload of frames) {
  13. if (signal.aborted) return
  14. yield { rpcId: RpcId(`frame-${String(frames.indexOf(payload))}`), payload }
  15. }
  16. }
  17. return {
  18. sessions: {
  19. async list(request) {
  20. if (overrides.crashOn === 'session.list') throw new Error('impl crashed')
  21. return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
  22. },
  23. async create(request) {
  24. return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
  25. },
  26. async history(request) {
  27. return {
  28. rpcId: request.rpcId,
  29. result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } },
  30. }
  31. },
  32. async prompt(request) {
  33. return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
  34. },
  35. async cancel(request) {
  36. return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
  37. },
  38. },
  39. host: {
  40. async describe(request) {
  41. return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
  42. },
  43. },
  44. events: {
  45. mux: (_request, signal) => stream(muxFrames, signal),
  46. host: (_request, signal) => stream(hostFrames, signal),
  47. },
  48. async respond(message: ClientResponse): Promise<RpcReceipt> {
  49. return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' }
  50. },
  51. }
  52. }
  53. function client(api: ApiProxy = fakeApi()): InProcessApiClient {
  54. return new InProcessApiClient(toFetchHandler(api))
  55. }
  56. async function collect<F>(stream: AsyncIterable<RpcRequest<F>>): Promise<RpcRequest<F>[]> {
  57. const out: RpcRequest<F>[] = []
  58. for await (const envelope of stream) out.push(envelope)
  59. return out
  60. }
  61. describe('unary round trip (handler ⇄ client, no network)', () => {
  62. it('carries a success result and echoes the minted rpcId', async () => {
  63. const response = await client().sessions.list({})
  64. expect(response.result).toEqual({ ok: true, value: { items: [] } })
  65. expect(response.rpcId).toMatch(/[0-9a-f-]{36}/)
  66. })
  67. it('carries a business error as 200 + error result', async () => {
  68. const response = await client().sessions.history({ sessionId: 'missing' as never })
  69. expect(response.result.ok).toBe(false)
  70. if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
  71. })
  72. it('covers create/prompt/cancel/describe passthrough', async () => {
  73. const c = client()
  74. expect((await c.sessions.create({})).result.ok).toBe(true)
  75. expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
  76. expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
  77. expect((await c.host.describe({})).result.ok).toBe(true)
  78. })
  79. })
  80. describe('handler carrier-layer statuses', () => {
  81. const handler = toFetchHandler(fakeApi())
  82. it('404s unknown paths and non-POST non-stream methods', async () => {
  83. expect((await handler.fetch(new Request('http://x/other', { method: 'POST', body: '{}' }))).status).toBe(404)
  84. expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404)
  85. expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
  86. })
  87. it('400s a non-JSON body', async () => {
  88. const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: 'not json' }))
  89. expect(response.status).toBe(400)
  90. })
  91. it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => {
  92. const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: JSON.stringify({ nope: true }) }))
  93. expect(response.status).toBe(200)
  94. const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
  95. expect(body.rpcId).toBe('invalid-request')
  96. expect(body.result.error?.code).toBe('bad-request')
  97. })
  98. it('rejects a method/path mismatch echoing the envelope rpcId', async () => {
  99. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} })
  100. const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
  101. const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } }
  102. expect(parsed.rpcId).toBe('r-9')
  103. expect(parsed.result.error?.message).toContain('does not match path')
  104. })
  105. it('rejects an invalid payload with the zod issues attached', async () => {
  106. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} })
  107. const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', body }))
  108. const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } }
  109. expect(parsed.result.error?.code).toBe('bad-request')
  110. expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0)
  111. })
  112. it('500s when the impl itself throws', async () => {
  113. const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' }))
  114. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} })
  115. const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
  116. expect(response.status).toBe(500)
  117. expect(await response.text()).toContain('impl crashed')
  118. })
  119. it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => {
  120. const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } })
  121. const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: good }))).json()
  122. expect(goodReceipt).toEqual({ accepted: true })
  123. const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} })
  124. const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: bad }))).json()
  125. expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' })
  126. })
  127. it('accepts (url, init) form fetch invocation', async () => {
  128. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} })
  129. const response = await handler.fetch('http://x/api/session.list', { method: 'POST', body })
  130. expect(response.status).toBe(200)
  131. })
  132. })
  133. describe('SSE streams through the carrier', () => {
  134. it('yields mux frames as ServerRequest narrow forms and completes', async () => {
  135. const ac = new AbortController()
  136. const frames = await collect(client().events.mux({}, ac.signal))
  137. expect(frames).toHaveLength(1)
  138. expect(frames[0]?.payload).toMatchObject({ type: 'session/subscribed' })
  139. expect(frames[0]?.rpcId).toBe('frame-0')
  140. })
  141. it('yields host frames', async () => {
  142. const ac = new AbortController()
  143. const frames = await collect(client().events.host({}, ac.signal))
  144. expect(frames[0]?.payload).toMatchObject({ type: 'host/session-removed' })
  145. })
  146. it('drops frames after the consumer aborts mid-stream', async () => {
  147. const many = Array.from({ length: 50 }, (_, i): MuxFrame => ({ type: 'session/subscribed', sessionId: `s${String(i)}` as never, lastSeq: i }))
  148. const ac = new AbortController()
  149. const received: RpcRequest<MuxFrame>[] = []
  150. for await (const envelope of client(fakeApi({ muxFrames: many })).events.mux({}, ac.signal)) {
  151. received.push(envelope)
  152. if (received.length === 2) break // generator return → reader.cancel path
  153. }
  154. expect(received).toHaveLength(2)
  155. })
  156. it('swallows a reader.cancel rejection on early exit', async () => {
  157. const encoder = new TextEncoder()
  158. const body = new ReadableStream<Uint8Array>({
  159. start(controller) {
  160. const frame = { type: 'server-request', rpcId: 'f0', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's', lastSeq: -1 } }
  161. controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`))
  162. // stream intentionally left open: the consumer breaks first
  163. },
  164. cancel() {
  165. throw new Error('cancel refused')
  166. },
  167. })
  168. const c = new InProcessApiClient({ fetch: async () => new Response(body, { headers: { 'content-type': 'text/event-stream' } }) })
  169. const received: RpcRequest<MuxFrame>[] = []
  170. for await (const envelope of c.events.mux({}, new AbortController().signal)) {
  171. received.push(envelope)
  172. break
  173. }
  174. expect(received).toHaveLength(1)
  175. })
  176. it('surfaces a mid-stream impl failure as one stream/error frame, then the stream ends', async () => {
  177. const api = fakeApi()
  178. api.events.mux = (_request, _signal) => (async function * (): AsyncGenerator<RpcRequest<MuxFrame>> {
  179. yield { rpcId: RpcId('f0'), payload: { type: 'session/subscribed', sessionId: 's' as never, lastSeq: -1 } }
  180. throw new Error('stream source died')
  181. })()
  182. const frames = await collect(client(api).events.mux({}, new AbortController().signal))
  183. expect(frames).toHaveLength(2)
  184. expect(frames[1]?.payload).toMatchObject({ type: 'stream/error', error: { code: 'internal' } })
  185. })
  186. })
  187. describe('client respond and transport failures', () => {
  188. it('passes a client-response through and parses the receipt', async () => {
  189. const receipt = await client().respond({ type: 'client-response', rpcId: RpcId('known'), result: { ok: true, value: null } })
  190. expect(receipt).toEqual({ accepted: true })
  191. const late = await client().respond({ type: 'client-response', rpcId: RpcId('late'), result: { ok: true, value: null } })
  192. expect(late).toEqual({ accepted: false, reason: 'not-pending' })
  193. })
  194. it('throws on non-OK unary and respond and stream transport', async () => {
  195. const broken = new InProcessApiClient({ fetch: async () => new Response('down', { status: 503 }) })
  196. await expect(broken.sessions.list({})).rejects.toThrow('transport failure for /api/session.list: HTTP 503')
  197. await expect(broken.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } }))
  198. .rejects.toThrow('transport failure for /api/respond')
  199. await expect(collect(broken.events.mux({}, new AbortController().signal))).rejects.toThrow('transport failure for /api/events.mux')
  200. })
  201. it('throws on an rpcId echo mismatch', async () => {
  202. const lying = new InProcessApiClient({
  203. fetch: async () => Response.json({ type: 'server-response', rpcId: 'someone-else', result: { ok: true, value: { items: [] } } }),
  204. })
  205. await expect(lying.sessions.list({})).rejects.toThrow('rpcId mismatch')
  206. })
  207. })
  208. describe('envelope observation', () => {
  209. it('batches envelopes per microtask and isolates a throwing listener', async () => {
  210. const c = client()
  211. const batches: (readonly RpcMessage[])[] = []
  212. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  213. const unsubscribeThrowing = c.subscribeEnvelopes(() => { throw new Error('observer bug') })
  214. const unsubscribe = c.subscribeEnvelopes((batch) => { batches.push(batch) })
  215. await c.sessions.list({})
  216. await new Promise((resolve) => { setTimeout(resolve, 0) })
  217. // request and response tap in separate microtask windows (the await between
  218. // them yields), so both arrive but batch count is timing-defined
  219. expect(batches.flatMap(batch => batch.map(message => message.type))).toEqual(['client-request', 'server-response'])
  220. expect(errorSpy).toHaveBeenCalled()
  221. unsubscribe()
  222. unsubscribeThrowing()
  223. errorSpy.mockRestore()
  224. })
  225. it('skips buffering entirely with no listeners and after unsubscribe', async () => {
  226. const c = client()
  227. const seen: RpcMessage[] = []
  228. const unsubscribe = c.subscribeEnvelopes((batch) => { seen.push(...batch) })
  229. unsubscribe()
  230. await c.sessions.list({})
  231. await new Promise((resolve) => { setTimeout(resolve, 0) })
  232. expect(seen).toHaveLength(0)
  233. })
  234. it('coalesces multiple calls in one microtask window into one flush', async () => {
  235. const c = client()
  236. const batches: (readonly RpcMessage[])[] = []
  237. c.subscribeEnvelopes((batch) => { batches.push(batch) })
  238. await Promise.all([c.sessions.list({}), c.host.describe({})])
  239. await new Promise((resolve) => { setTimeout(resolve, 0) })
  240. const total = batches.reduce((n, batch) => n + batch.length, 0)
  241. expect(total).toBe(4)
  242. })
  243. })
  244. describe('resolveBase', () => {
  245. it('prefers a real location.origin and falls back to the internal authority', async () => {
  246. class Probe extends AbstractApiClient {
  247. urls: string[] = []
  248. protected async doFetch(input: URL): Promise<Response> {
  249. this.urls.push(input.href)
  250. return Response.json({ type: 'server-response', rpcId: this.lastMinted, result: { ok: true, value: { items: [] } } })
  251. }
  252. lastMinted = ''
  253. protected override mintRpcId(): ReturnType<AbstractApiClient['mintRpcId']> {
  254. const id = super.mintRpcId()
  255. this.lastMinted = id
  256. return id
  257. }
  258. }
  259. const probe = new Probe()
  260. await probe.sessions.list({})
  261. expect(probe.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
  262. const globalWithLocation = globalThis as { location?: { origin?: string } }
  263. globalWithLocation.location = { origin: 'http://host.example' }
  264. try {
  265. const probe2 = new Probe()
  266. await probe2.sessions.list({})
  267. expect(probe2.urls[0]).toMatch(/^http:\/\/host\.example\//)
  268. globalWithLocation.location = { origin: 'null' } // sandboxed iframe shape
  269. const probe3 = new Probe()
  270. await probe3.sessions.list({})
  271. expect(probe3.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
  272. } finally {
  273. delete globalWithLocation.location
  274. }
  275. })
  276. })