ts-procedures 5.15.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +2 -0
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
  3. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
  4. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +85 -17
  5. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +220 -9
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +271 -16
  7. package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
  8. package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
  9. package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
  10. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +53 -18
  11. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
  12. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
  13. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
  14. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
  15. package/agent_config/copilot/copilot-instructions.md +132 -19
  16. package/agent_config/cursor/cursorrules +132 -19
  17. package/build/client/call.d.ts +19 -9
  18. package/build/client/call.js +33 -19
  19. package/build/client/call.js.map +1 -1
  20. package/build/client/call.test.js +167 -17
  21. package/build/client/call.test.js.map +1 -1
  22. package/build/client/error-dispatch.d.ts +13 -0
  23. package/build/client/error-dispatch.js +26 -0
  24. package/build/client/error-dispatch.js.map +1 -0
  25. package/build/client/error-dispatch.test.d.ts +1 -0
  26. package/build/client/error-dispatch.test.js +56 -0
  27. package/build/client/error-dispatch.test.js.map +1 -0
  28. package/build/client/fetch-adapter.js +10 -4
  29. package/build/client/fetch-adapter.js.map +1 -1
  30. package/build/client/index.d.ts +2 -1
  31. package/build/client/index.js +22 -3
  32. package/build/client/index.js.map +1 -1
  33. package/build/client/index.test.js +104 -0
  34. package/build/client/index.test.js.map +1 -1
  35. package/build/client/resolve-options.d.ts +45 -0
  36. package/build/client/resolve-options.js +82 -0
  37. package/build/client/resolve-options.js.map +1 -0
  38. package/build/client/resolve-options.test.d.ts +1 -0
  39. package/build/client/resolve-options.test.js +158 -0
  40. package/build/client/resolve-options.test.js.map +1 -0
  41. package/build/client/stream.d.ts +19 -9
  42. package/build/client/stream.js +36 -21
  43. package/build/client/stream.js.map +1 -1
  44. package/build/client/stream.test.js +102 -46
  45. package/build/client/stream.test.js.map +1 -1
  46. package/build/client/typed-error-dispatch.test.d.ts +1 -0
  47. package/build/client/typed-error-dispatch.test.js +168 -0
  48. package/build/client/typed-error-dispatch.test.js.map +1 -0
  49. package/build/client/types.d.ts +105 -1
  50. package/build/client/types.js +1 -1
  51. package/build/codegen/e2e.test.js +150 -4
  52. package/build/codegen/e2e.test.js.map +1 -1
  53. package/build/codegen/emit-client-runtime.js +7 -0
  54. package/build/codegen/emit-client-runtime.js.map +1 -1
  55. package/build/codegen/emit-errors.d.ts +17 -6
  56. package/build/codegen/emit-errors.integration.test.d.ts +1 -0
  57. package/build/codegen/emit-errors.integration.test.js +162 -0
  58. package/build/codegen/emit-errors.integration.test.js.map +1 -0
  59. package/build/codegen/emit-errors.js +50 -39
  60. package/build/codegen/emit-errors.js.map +1 -1
  61. package/build/codegen/emit-errors.test.js +75 -78
  62. package/build/codegen/emit-errors.test.js.map +1 -1
  63. package/build/codegen/emit-index.d.ts +7 -0
  64. package/build/codegen/emit-index.js +26 -4
  65. package/build/codegen/emit-index.js.map +1 -1
  66. package/build/codegen/emit-index.test.js +55 -23
  67. package/build/codegen/emit-index.test.js.map +1 -1
  68. package/build/codegen/emit-scope.d.ts +8 -0
  69. package/build/codegen/emit-scope.js +82 -7
  70. package/build/codegen/emit-scope.js.map +1 -1
  71. package/build/codegen/pipeline.js +22 -2
  72. package/build/codegen/pipeline.js.map +1 -1
  73. package/build/implementations/http/doc-registry.d.ts +21 -0
  74. package/build/implementations/http/doc-registry.js +51 -78
  75. package/build/implementations/http/doc-registry.js.map +1 -1
  76. package/build/implementations/http/doc-registry.test.js +8 -6
  77. package/build/implementations/http/doc-registry.test.js.map +1 -1
  78. package/build/implementations/http/error-taxonomy.d.ts +240 -0
  79. package/build/implementations/http/error-taxonomy.js +230 -0
  80. package/build/implementations/http/error-taxonomy.js.map +1 -0
  81. package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
  82. package/build/implementations/http/error-taxonomy.test.js +399 -0
  83. package/build/implementations/http/error-taxonomy.test.js.map +1 -0
  84. package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
  85. package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
  86. package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
  87. package/build/implementations/http/express-rpc/index.d.ts +39 -8
  88. package/build/implementations/http/express-rpc/index.js +39 -8
  89. package/build/implementations/http/express-rpc/index.js.map +1 -1
  90. package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
  91. package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
  92. package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
  93. package/build/implementations/http/hono-api/index.d.ts +38 -1
  94. package/build/implementations/http/hono-api/index.js +32 -0
  95. package/build/implementations/http/hono-api/index.js.map +1 -1
  96. package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
  97. package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
  98. package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
  99. package/build/implementations/http/hono-rpc/index.d.ts +34 -7
  100. package/build/implementations/http/hono-rpc/index.js +31 -4
  101. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  102. package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
  103. package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
  104. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
  105. package/build/implementations/http/hono-stream/index.d.ts +40 -3
  106. package/build/implementations/http/hono-stream/index.js +37 -10
  107. package/build/implementations/http/hono-stream/index.js.map +1 -1
  108. package/build/implementations/http/hono-stream/index.test.js +45 -18
  109. package/build/implementations/http/hono-stream/index.test.js.map +1 -1
  110. package/build/implementations/http/on-request-error.test.d.ts +1 -0
  111. package/build/implementations/http/on-request-error.test.js +173 -0
  112. package/build/implementations/http/on-request-error.test.js.map +1 -0
  113. package/build/implementations/http/route-errors.test.d.ts +1 -0
  114. package/build/implementations/http/route-errors.test.js +140 -0
  115. package/build/implementations/http/route-errors.test.js.map +1 -0
  116. package/build/implementations/types.d.ts +30 -2
  117. package/docs/client-and-codegen.md +228 -14
  118. package/docs/core.md +14 -5
  119. package/docs/http-integrations.md +135 -4
  120. package/docs/streaming.md +3 -1
  121. package/package.json +7 -2
  122. package/src/client/call.test.ts +202 -29
  123. package/src/client/call.ts +50 -28
  124. package/src/client/error-dispatch.test.ts +72 -0
  125. package/src/client/error-dispatch.ts +27 -0
  126. package/src/client/fetch-adapter.ts +11 -5
  127. package/src/client/index.test.ts +117 -0
  128. package/src/client/index.ts +34 -8
  129. package/src/client/resolve-options.test.ts +205 -0
  130. package/src/client/resolve-options.ts +113 -0
  131. package/src/client/stream.test.ts +132 -107
  132. package/src/client/stream.ts +53 -27
  133. package/src/client/typed-error-dispatch.test.ts +211 -0
  134. package/src/client/types.ts +116 -2
  135. package/src/codegen/e2e.test.ts +160 -4
  136. package/src/codegen/emit-client-runtime.ts +7 -0
  137. package/src/codegen/emit-errors.integration.test.ts +183 -0
  138. package/src/codegen/emit-errors.test.ts +91 -87
  139. package/src/codegen/emit-errors.ts +123 -41
  140. package/src/codegen/emit-index.test.ts +68 -24
  141. package/src/codegen/emit-index.ts +66 -4
  142. package/src/codegen/emit-scope.ts +124 -7
  143. package/src/codegen/pipeline.ts +25 -2
  144. package/src/implementations/http/README.md +28 -5
  145. package/src/implementations/http/doc-registry.test.ts +10 -6
  146. package/src/implementations/http/doc-registry.ts +63 -80
  147. package/src/implementations/http/error-taxonomy.test.ts +438 -0
  148. package/src/implementations/http/error-taxonomy.ts +337 -0
  149. package/src/implementations/http/express-rpc/README.md +21 -22
  150. package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
  151. package/src/implementations/http/express-rpc/index.ts +75 -14
  152. package/src/implementations/http/hono-api/README.md +284 -0
  153. package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
  154. package/src/implementations/http/hono-api/index.ts +76 -1
  155. package/src/implementations/http/hono-rpc/README.md +18 -19
  156. package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
  157. package/src/implementations/http/hono-rpc/index.ts +65 -9
  158. package/src/implementations/http/hono-stream/README.md +44 -25
  159. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
  160. package/src/implementations/http/hono-stream/index.test.ts +54 -18
  161. package/src/implementations/http/hono-stream/index.ts +83 -13
  162. package/src/implementations/http/on-request-error.test.ts +201 -0
  163. package/src/implementations/http/route-errors.test.ts +177 -0
  164. package/src/implementations/types.ts +30 -2
@@ -7,6 +7,8 @@ import type {
7
7
  AdapterStreamResponse,
8
8
  ClientHooks,
9
9
  StreamDescriptor,
10
+ ProcedureCallDefaults,
11
+ ProcedureCallOptions,
10
12
  } from './types.js'
11
13
 
12
14
  // ── helpers ───────────────────────────────────────────────
@@ -47,6 +49,26 @@ function makeStreamAdapter(
47
49
  }
48
50
  }
49
51
 
52
+ interface RunConfig {
53
+ adapter: ClientAdapter
54
+ hooks?: ClientHooks
55
+ defaults?: ProcedureCallDefaults
56
+ options?: ProcedureCallOptions
57
+ descriptor?: StreamDescriptor
58
+ basePath?: string
59
+ }
60
+
61
+ function run<TYield, TReturn = void>({
62
+ adapter,
63
+ hooks = {},
64
+ defaults,
65
+ options,
66
+ descriptor = makeDescriptor(),
67
+ basePath = 'https://api.example.com',
68
+ }: RunConfig) {
69
+ return executeStream<TYield, TReturn>({ descriptor, basePath, adapter, hooks, defaults, options })
70
+ }
71
+
50
72
  // ── createTypedStream — SSE mode ──────────────────────────
51
73
 
52
74
  describe('createTypedStream — SSE mode', () => {
@@ -55,15 +77,10 @@ describe('createTypedStream — SSE mode', () => {
55
77
  { data: { count: 1 }, event: 'update' },
56
78
  { data: { count: 2 }, event: 'update' },
57
79
  ]
58
- const stream = createTypedStream<{ count: number }, void>(
59
- makeAsyncIterable(sseItems),
60
- 'sse'
61
- )
80
+ const stream = createTypedStream<{ count: number }, void>(makeAsyncIterable(sseItems), 'sse')
62
81
 
63
82
  const received: { count: number }[] = []
64
- for await (const item of stream) {
65
- received.push(item)
66
- }
83
+ for await (const item of stream) received.push(item)
67
84
  expect(received).toEqual([{ count: 1 }, { count: 2 }])
68
85
  })
69
86
 
@@ -74,34 +91,23 @@ describe('createTypedStream — SSE mode', () => {
74
91
  ]
75
92
  const stream = createTypedStream<{ count: number }, { total: number }>(
76
93
  makeAsyncIterable(sseItems),
77
- 'sse'
94
+ 'sse',
78
95
  )
79
96
 
80
97
  const yielded: { count: number }[] = []
81
- for await (const item of stream) {
82
- yielded.push(item)
83
- }
98
+ for await (const item of stream) yielded.push(item)
84
99
 
85
- // Only the 'update' event is yielded
86
100
  expect(yielded).toEqual([{ count: 1 }])
87
-
88
- // The 'return' event resolves .result
89
- const result = await stream.result
90
- expect(result).toEqual({ total: 99 })
101
+ await expect(stream.result).resolves.toEqual({ total: 99 })
91
102
  })
92
103
 
93
104
  it('resolves .result with undefined when no return event', async () => {
94
- const sseItems = [
95
- { data: 'hello', event: 'message' },
96
- ]
97
105
  const stream = createTypedStream<string, undefined>(
98
- makeAsyncIterable(sseItems),
99
- 'sse'
106
+ makeAsyncIterable([{ data: 'hello', event: 'message' }]),
107
+ 'sse',
100
108
  )
101
-
102
109
  for await (const _ of stream) { /* drain */ }
103
- const result = await stream.result
104
- expect(result).toBeUndefined()
110
+ await expect(stream.result).resolves.toBeUndefined()
105
111
  })
106
112
 
107
113
  it('rejects .result and re-throws on error', async () => {
@@ -111,30 +117,19 @@ describe('createTypedStream — SSE mode', () => {
111
117
  }
112
118
 
113
119
  const stream = createTypedStream<string, void>(errorIterable(), 'sse')
114
-
115
- // Consuming the stream should throw
116
120
  await expect(async () => {
117
121
  for await (const _ of stream) { /* drain */ }
118
122
  }).rejects.toThrow('stream broke')
119
-
120
- // .result should also reject
121
123
  await expect(stream.result).rejects.toThrow('stream broke')
122
124
  })
123
125
 
124
126
  it('handles SSE items without event field (defaults to yielding data)', async () => {
125
- const sseItems = [
126
- { data: 'a' },
127
- { data: 'b' },
128
- ]
129
127
  const stream = createTypedStream<string, void>(
130
- makeAsyncIterable(sseItems),
131
- 'sse'
128
+ makeAsyncIterable([{ data: 'a' }, { data: 'b' }]),
129
+ 'sse',
132
130
  )
133
-
134
131
  const yielded: string[] = []
135
- for await (const item of stream) {
136
- yielded.push(item)
137
- }
132
+ for await (const item of stream) yielded.push(item)
138
133
  expect(yielded).toEqual(['a', 'b'])
139
134
  })
140
135
  })
@@ -144,27 +139,16 @@ describe('createTypedStream — SSE mode', () => {
144
139
  describe('createTypedStream — text mode', () => {
145
140
  it('yields each chunk as-is', async () => {
146
141
  const chunks = ['chunk1', 'chunk2', 'chunk3']
147
- const stream = createTypedStream<string, void>(
148
- makeAsyncIterable(chunks),
149
- 'text'
150
- )
151
-
142
+ const stream = createTypedStream<string, void>(makeAsyncIterable(chunks), 'text')
152
143
  const received: string[] = []
153
- for await (const chunk of stream) {
154
- received.push(chunk)
155
- }
144
+ for await (const chunk of stream) received.push(chunk)
156
145
  expect(received).toEqual(chunks)
157
146
  })
158
147
 
159
148
  it('.result resolves to void on normal completion', async () => {
160
- const stream = createTypedStream<string, void>(
161
- makeAsyncIterable(['a', 'b']),
162
- 'text'
163
- )
164
-
149
+ const stream = createTypedStream<string, void>(makeAsyncIterable(['a', 'b']), 'text')
165
150
  for await (const _ of stream) { /* drain */ }
166
- const result = await stream.result
167
- expect(result).toBeUndefined()
151
+ await expect(stream.result).resolves.toBeUndefined()
168
152
  })
169
153
 
170
154
  it('rejects .result and re-throws on error', async () => {
@@ -172,13 +156,10 @@ describe('createTypedStream — text mode', () => {
172
156
  yield 'before error'
173
157
  throw new Error('text stream error')
174
158
  }
175
-
176
159
  const stream = createTypedStream<string, void>(errorIterable(), 'text')
177
-
178
160
  await expect(async () => {
179
161
  for await (const _ of stream) { /* drain */ }
180
162
  }).rejects.toThrow('text stream error')
181
-
182
163
  await expect(stream.result).rejects.toThrow('text stream error')
183
164
  })
184
165
  })
@@ -189,20 +170,11 @@ describe('executeStream', () => {
189
170
  it('calls adapter.stream and returns a TypedStream', async () => {
190
171
  const items = [{ data: 'hello', event: 'msg' }]
191
172
  const adapter = makeStreamAdapter({}, items)
192
-
193
- const stream = await executeStream<string, void>(
194
- makeDescriptor(),
195
- 'https://api.example.com',
196
- adapter,
197
- {},
198
- undefined
199
- )
173
+ const stream = await run<string, void>({ adapter })
200
174
 
201
175
  expect(adapter.stream).toHaveBeenCalledOnce()
202
176
  const received: string[] = []
203
- for await (const item of stream) {
204
- received.push(item)
205
- }
177
+ for await (const item of stream) received.push(item)
206
178
  expect(received).toEqual(['hello'])
207
179
  })
208
180
 
@@ -216,23 +188,15 @@ describe('executeStream', () => {
216
188
  }),
217
189
  }
218
190
 
219
- const globalHooks: ClientHooks = {
191
+ const hooks: ClientHooks = {
220
192
  onBeforeRequest: (ctx) => ({
221
193
  ...ctx,
222
194
  request: { ...ctx.request, headers: { 'x-stream-auth': 'stream-token' } },
223
195
  }),
224
196
  }
225
197
 
226
- const stream = await executeStream(
227
- makeDescriptor(),
228
- 'https://api.example.com',
229
- adapter,
230
- globalHooks,
231
- undefined
232
- )
233
- // Drain
234
- for await (const _ of stream) { /* noop */ }
235
-
198
+ const stream = await run({ adapter, hooks })
199
+ for await (const _ of stream) { /* drain */ }
236
200
  expect(capturedHeaders[0]?.['x-stream-auth']).toBe('stream-token')
237
201
  })
238
202
 
@@ -245,12 +209,11 @@ describe('executeStream', () => {
245
209
  return { status: 200, headers: {}, body: makeAsyncIterable([]) }
246
210
  }),
247
211
  }
248
- const globalHooks: ClientHooks = {
212
+ const hooks: ClientHooks = {
249
213
  onAfterResponse: () => { order.push('afterResponse') },
250
214
  }
251
215
 
252
- await executeStream(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
253
- // After executeStream returns (before iteration), afterResponse should have fired
216
+ await run({ adapter, hooks })
254
217
  expect(order).toEqual(['adapter', 'afterResponse'])
255
218
  })
256
219
 
@@ -263,10 +226,7 @@ describe('executeStream', () => {
263
226
  body: makeAsyncIterable([]),
264
227
  })),
265
228
  }
266
-
267
- await expect(
268
- executeStream(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
269
- ).rejects.toThrow(ClientRequestError)
229
+ await expect(run({ adapter })).rejects.toThrow(ClientRequestError)
270
230
  })
271
231
 
272
232
  it('runs onError on adapter failure and re-throws', async () => {
@@ -276,13 +236,11 @@ describe('executeStream', () => {
276
236
  stream: vi.fn(async () => { throw adapterError }),
277
237
  }
278
238
  const receivedErrors: unknown[] = []
279
- const globalHooks: ClientHooks = {
239
+ const hooks: ClientHooks = {
280
240
  onError: (ctx) => { receivedErrors.push(ctx.error) },
281
241
  }
282
242
 
283
- await expect(
284
- executeStream(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
285
- ).rejects.toThrow('stream connection failed')
243
+ await expect(run({ adapter, hooks })).rejects.toThrow('stream connection failed')
286
244
  expect(receivedErrors[0]).toBe(adapterError)
287
245
  })
288
246
 
@@ -293,18 +251,13 @@ describe('executeStream', () => {
293
251
  ]
294
252
  const adapter = makeStreamAdapter({}, sseItems)
295
253
 
296
- const stream = await executeStream<{ n: number }, { final: boolean }>(
297
- makeDescriptor({ streamMode: 'sse' }),
298
- 'https://api.example.com',
254
+ const stream = await run<{ n: number }, { final: boolean }>({
299
255
  adapter,
300
- {},
301
- undefined
302
- )
256
+ descriptor: makeDescriptor({ streamMode: 'sse' }),
257
+ })
303
258
 
304
259
  const yielded: { n: number }[] = []
305
- for await (const item of stream) {
306
- yielded.push(item)
307
- }
260
+ for await (const item of stream) yielded.push(item)
308
261
  expect(yielded).toEqual([{ n: 1 }])
309
262
  await expect(stream.result).resolves.toEqual({ final: true })
310
263
  })
@@ -313,19 +266,91 @@ describe('executeStream', () => {
313
266
  const chunks = ['line1', 'line2']
314
267
  const adapter = makeStreamAdapter({}, chunks)
315
268
 
316
- const stream = await executeStream<string, void>(
317
- makeDescriptor({ streamMode: 'text' }),
318
- 'https://api.example.com',
269
+ const stream = await run<string, void>({
319
270
  adapter,
320
- {},
321
- undefined
322
- )
271
+ descriptor: makeDescriptor({ streamMode: 'text' }),
272
+ })
323
273
 
324
274
  const received: string[] = []
325
- for await (const chunk of stream) {
326
- received.push(chunk)
327
- }
275
+ for await (const chunk of stream) received.push(chunk)
328
276
  expect(received).toEqual(chunks)
329
277
  await expect(stream.result).resolves.toBeUndefined()
330
278
  })
279
+
280
+ // ── Per-call options ──
281
+
282
+ it('per-call timeout attaches a signal via AbortSignal.timeout', async () => {
283
+ const spy = vi.spyOn(AbortSignal, 'timeout')
284
+ try {
285
+ let observedSignal: AbortSignal | undefined
286
+ const adapter: ClientAdapter = {
287
+ request: vi.fn(async (): Promise<never> => { throw new Error('not expected') }),
288
+ stream: vi.fn(async (req: AdapterRequest): Promise<AdapterStreamResponse> => {
289
+ observedSignal = req.signal
290
+ return { status: 200, headers: {}, body: makeAsyncIterable([]) }
291
+ }),
292
+ }
293
+
294
+ await run({ adapter, options: { timeout: 5000 } })
295
+ expect(spy).toHaveBeenCalledWith(5000)
296
+ expect(observedSignal).toBeDefined()
297
+ } finally {
298
+ spy.mockRestore()
299
+ }
300
+ })
301
+
302
+ it('adapter receives a signal that reflects abort when the caller cancels', async () => {
303
+ const controller = new AbortController()
304
+ const adapter: ClientAdapter = {
305
+ request: vi.fn(async (): Promise<never> => { throw new Error('not expected') }),
306
+ stream: vi.fn(async (req: AdapterRequest): Promise<AdapterStreamResponse> => {
307
+ return new Promise((_resolve, reject) => {
308
+ const abort = () => reject(new Error('aborted'))
309
+ if (req.signal?.aborted) abort()
310
+ else req.signal?.addEventListener('abort', abort, { once: true })
311
+ })
312
+ }),
313
+ }
314
+
315
+ const promise = run({ adapter, options: { signal: controller.signal } })
316
+ // Let executeStream reach the adapter before aborting
317
+ await Promise.resolve()
318
+ controller.abort()
319
+ await expect(promise).rejects.toThrow('aborted')
320
+ })
321
+
322
+ it('per-call basePath overrides base path for streams', async () => {
323
+ const capturedUrls: string[] = []
324
+ const adapter: ClientAdapter = {
325
+ request: vi.fn(async (): Promise<never> => { throw new Error('not expected') }),
326
+ stream: vi.fn(async (req: AdapterRequest): Promise<AdapterStreamResponse> => {
327
+ capturedUrls.push(req.url)
328
+ return { status: 200, headers: {}, body: makeAsyncIterable([]) }
329
+ }),
330
+ }
331
+
332
+ const stream = await run({
333
+ adapter,
334
+ descriptor: makeDescriptor({ path: '/tail' }),
335
+ basePath: 'https://default.example.com',
336
+ options: { basePath: 'https://override.example.com' },
337
+ })
338
+ for await (const _ of stream) { /* drain */ }
339
+
340
+ expect(capturedUrls[0]).toBe('https://override.example.com/tail')
341
+ })
342
+
343
+ it('per-call meta is forwarded to the adapter', async () => {
344
+ let observedMeta: unknown
345
+ const adapter: ClientAdapter = {
346
+ request: vi.fn(async (): Promise<never> => { throw new Error('not expected') }),
347
+ stream: vi.fn(async (req: AdapterRequest): Promise<AdapterStreamResponse> => {
348
+ observedMeta = req.meta
349
+ return { status: 200, headers: {}, body: makeAsyncIterable([]) }
350
+ }),
351
+ }
352
+
353
+ await run({ adapter, options: { meta: { traceId: 'stream-trace' } as never } })
354
+ expect(observedMeta).toEqual({ traceId: 'stream-trace' })
355
+ })
331
356
  })
@@ -1,12 +1,17 @@
1
1
  import { buildAdapterRequest } from './request-builder.js'
2
2
  import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
3
+ import { applyRequestOptions, resolveBasePath } from './resolve-options.js'
3
4
  import { ClientRequestError } from './errors.js'
5
+ import { dispatchTypedError } from './error-dispatch.js'
4
6
  import type {
5
7
  ClientAdapter,
6
8
  ClientHooks,
9
+ ErrorRegistry,
7
10
  StreamDescriptor,
8
11
  TypedStream,
9
12
  AdapterResponse,
13
+ ProcedureCallDefaults,
14
+ ProcedureCallOptions,
10
15
  } from './types.js'
11
16
 
12
17
  // ── SSE item shape ────────────────────────────────────────
@@ -90,66 +95,87 @@ export function createTypedStream<TYield, TReturn = void>(
90
95
 
91
96
  // ── executeStream ─────────────────────────────────────────
92
97
 
98
+ export interface ExecuteStreamConfig {
99
+ descriptor: StreamDescriptor
100
+ basePath: string
101
+ adapter: ClientAdapter
102
+ hooks: ClientHooks
103
+ defaults?: ProcedureCallDefaults
104
+ options?: ProcedureCallOptions
105
+ errorRegistry?: ErrorRegistry
106
+ }
107
+
93
108
  /**
94
109
  * Executes a streaming procedure call through the adapter.
95
110
  *
96
111
  * Flow:
97
- * 1. Build AdapterRequest from descriptor
98
- * 2. Run onBeforeRequest hooks
99
- * 3. Call adapter.stream()
100
- * 4. On adapter error: run onError hooks, re-throw
101
- * 5. Run onAfterResponse immediately (before iteration), body is null
102
- * 6. If non-2xx: throw ClientRequestError
103
- * 7. Return createTypedStream(streamResponse.body, descriptor.streamMode)
112
+ * 1. Resolve base path and build AdapterRequest
113
+ * 2. Apply request options (headers, signal, timeout, meta) from defaults + per-call
114
+ * 3. Run onBeforeRequest hooks
115
+ * 4. Call adapter.stream()
116
+ * 5. On adapter error: run onError hooks, re-throw
117
+ * 6. Run onAfterResponse immediately (before iteration), body is null
118
+ * 7. If non-2xx: throw ClientRequestError
119
+ * 8. Return createTypedStream(streamResponse.body, descriptor.streamMode)
104
120
  */
105
121
  export async function executeStream<TYield, TReturn = void>(
106
- descriptor: StreamDescriptor,
107
- basePath: string,
108
- adapter: ClientAdapter,
109
- globalHooks: ClientHooks,
110
- localHooks: ClientHooks | undefined
122
+ config: ExecuteStreamConfig,
111
123
  ): Promise<TypedStream<TYield, TReturn>> {
124
+ const { descriptor, basePath, adapter, hooks, defaults, options, errorRegistry } = config
125
+
112
126
  // 1. Build the initial request
113
- let request = buildAdapterRequest(descriptor, basePath)
127
+ const resolvedBasePath = resolveBasePath(defaults, options, basePath)
128
+ let request = buildAdapterRequest(descriptor, resolvedBasePath)
129
+
130
+ // 2. Apply request-level options (headers, signal, timeout, meta)
131
+ request = applyRequestOptions(request, defaults, options)
114
132
 
115
- // 2. Run before-request hooks
133
+ // 3. Run before-request hooks
116
134
  const beforeCtx = await runBeforeRequest(
117
135
  { procedureName: descriptor.name, scope: descriptor.scope, request },
118
- globalHooks,
119
- localHooks
136
+ hooks,
137
+ options,
120
138
  )
121
139
  request = beforeCtx.request
122
140
 
123
- // 3. Call the adapter
141
+ // 4. Call the adapter
124
142
  let streamResponse
125
143
  try {
126
144
  streamResponse = await adapter.stream(request)
127
145
  } catch (err) {
128
- // 4. On adapter error: run error hooks, re-throw
146
+ // 5. On adapter error: run error hooks, re-throw
129
147
  await runOnError(
130
148
  { procedureName: descriptor.name, scope: descriptor.scope, request, error: err },
131
- globalHooks,
132
- localHooks
149
+ hooks,
150
+ options,
133
151
  )
134
152
  throw err
135
153
  }
136
154
 
137
- // Build an AdapterResponse shape for the hooks (body is null for streams at this point)
155
+ // Build an AdapterResponse shape for the hooks. For success the body is null
156
+ // (the actual data flows through the async iterable); for non-2xx the adapter
157
+ // eagerly parses the JSON response body and surfaces it via `errorBody`.
138
158
  const responseForHooks: AdapterResponse = {
139
159
  status: streamResponse.status,
140
160
  headers: streamResponse.headers,
141
- body: null,
161
+ body: streamResponse.errorBody ?? null,
142
162
  }
143
163
 
144
- // 5. Run after-response hooks immediately (before iteration)
164
+ // 6. Run after-response hooks immediately (before iteration)
145
165
  await runAfterResponse(
146
166
  { procedureName: descriptor.name, scope: descriptor.scope, request, response: responseForHooks },
147
- globalHooks,
148
- localHooks
167
+ hooks,
168
+ options,
149
169
  )
150
170
 
151
- // 6. Check status after hooks (hooks may mutate responseForHooks.status)
171
+ // 7. Check status after hooks (hooks may mutate responseForHooks.status)
152
172
  if (responseForHooks.status < 200 || responseForHooks.status >= 300) {
173
+ const typed = dispatchTypedError(errorRegistry, responseForHooks.body, {
174
+ status: responseForHooks.status,
175
+ procedureName: descriptor.name,
176
+ scope: descriptor.scope,
177
+ })
178
+ if (typed) throw typed
153
179
  throw new ClientRequestError({
154
180
  status: responseForHooks.status,
155
181
  headers: responseForHooks.headers,
@@ -159,6 +185,6 @@ export async function executeStream<TYield, TReturn = void>(
159
185
  })
160
186
  }
161
187
 
162
- // 7. Return the typed stream
188
+ // 8. Return the typed stream
163
189
  return createTypedStream<TYield, TReturn>(streamResponse.body, descriptor.streamMode)
164
190
  }