ts-procedures 5.15.0 → 5.16.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 (47) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +1 -1
  2. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +57 -4
  3. package/agent_config/claude-code/skills/ts-procedures/patterns.md +102 -3
  4. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +33 -5
  5. package/agent_config/copilot/copilot-instructions.md +55 -7
  6. package/agent_config/cursor/cursorrules +55 -7
  7. package/build/client/call.d.ts +18 -9
  8. package/build/client/call.js +25 -19
  9. package/build/client/call.js.map +1 -1
  10. package/build/client/call.test.js +167 -17
  11. package/build/client/call.test.js.map +1 -1
  12. package/build/client/index.d.ts +1 -1
  13. package/build/client/index.js +18 -3
  14. package/build/client/index.js.map +1 -1
  15. package/build/client/index.test.js +104 -0
  16. package/build/client/index.test.js.map +1 -1
  17. package/build/client/resolve-options.d.ts +45 -0
  18. package/build/client/resolve-options.js +82 -0
  19. package/build/client/resolve-options.js.map +1 -0
  20. package/build/client/resolve-options.test.d.ts +1 -0
  21. package/build/client/resolve-options.test.js +158 -0
  22. package/build/client/resolve-options.test.js.map +1 -0
  23. package/build/client/stream.d.ts +18 -9
  24. package/build/client/stream.js +24 -19
  25. package/build/client/stream.js.map +1 -1
  26. package/build/client/stream.test.js +102 -46
  27. package/build/client/stream.test.js.map +1 -1
  28. package/build/client/types.d.ts +68 -1
  29. package/build/client/types.js +1 -1
  30. package/build/codegen/e2e.test.js +141 -0
  31. package/build/codegen/e2e.test.js.map +1 -1
  32. package/build/codegen/emit-client-runtime.js +3 -0
  33. package/build/codegen/emit-client-runtime.js.map +1 -1
  34. package/docs/client-and-codegen.md +123 -2
  35. package/package.json +1 -1
  36. package/src/client/call.test.ts +202 -29
  37. package/src/client/call.ts +41 -28
  38. package/src/client/index.test.ts +117 -0
  39. package/src/client/index.ts +25 -8
  40. package/src/client/resolve-options.test.ts +205 -0
  41. package/src/client/resolve-options.ts +113 -0
  42. package/src/client/stream.test.ts +132 -107
  43. package/src/client/stream.ts +40 -25
  44. package/src/client/types.ts +74 -2
  45. package/src/codegen/e2e.test.ts +151 -0
  46. package/src/codegen/emit-client-runtime.ts +3 -0
  47. package/src/implementations/http/README.md +9 -1
@@ -7,6 +7,8 @@ import type {
7
7
  AdapterResponse,
8
8
  ClientHooks,
9
9
  CallDescriptor,
10
+ ProcedureCallDefaults,
11
+ ProcedureCallOptions,
10
12
  } from './types.js'
11
13
 
12
14
  // ── helpers ───────────────────────────────────────────────
@@ -37,43 +39,55 @@ function makeAdapter(response?: Partial<AdapterResponse>): ClientAdapter {
37
39
  }
38
40
  }
39
41
 
42
+ interface RunConfig {
43
+ adapter: ClientAdapter
44
+ hooks?: ClientHooks
45
+ defaults?: ProcedureCallDefaults
46
+ options?: ProcedureCallOptions
47
+ descriptor?: CallDescriptor
48
+ basePath?: string
49
+ }
50
+
51
+ function run<T>({
52
+ adapter,
53
+ hooks = {},
54
+ defaults,
55
+ options,
56
+ descriptor = makeDescriptor(),
57
+ basePath = 'https://api.example.com',
58
+ }: RunConfig): Promise<T> {
59
+ return executeCall<T>({ descriptor, basePath, adapter, hooks, defaults, options })
60
+ }
61
+
40
62
  // ── executeCall ───────────────────────────────────────────
41
63
 
42
64
  describe('executeCall', () => {
43
65
  it('calls adapter.request and returns body', async () => {
44
66
  const adapter = makeAdapter({ body: { id: '1', name: 'Bob' } })
45
- const result = await executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
67
+ const result = await run({ adapter })
46
68
  expect(adapter.request).toHaveBeenCalledOnce()
47
69
  expect(result).toEqual({ id: '1', name: 'Bob' })
48
70
  })
49
71
 
50
72
  it('throws ClientRequestError on 4xx response', async () => {
51
73
  const adapter = makeAdapter({ status: 404, body: { message: 'Not Found' } })
52
- await expect(
53
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
54
- ).rejects.toThrow(ClientRequestError)
74
+ await expect(run({ adapter })).rejects.toThrow(ClientRequestError)
55
75
  })
56
76
 
57
77
  it('throws ClientRequestError on 5xx response', async () => {
58
78
  const adapter = makeAdapter({ status: 500, body: { message: 'Server Error' } })
59
- await expect(
60
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
61
- ).rejects.toThrow(ClientRequestError)
79
+ await expect(run({ adapter })).rejects.toThrow(ClientRequestError)
62
80
  })
63
81
 
64
82
  it('throws ClientRequestError on 199 response (below 200)', async () => {
65
83
  const adapter = makeAdapter({ status: 199, body: null })
66
- await expect(
67
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
68
- ).rejects.toThrow(ClientRequestError)
84
+ await expect(run({ adapter })).rejects.toThrow(ClientRequestError)
69
85
  })
70
86
 
71
87
  it('does not throw on 2xx boundary responses (200, 201, 299)', async () => {
72
88
  for (const status of [200, 201, 204, 299]) {
73
89
  const adapter = makeAdapter({ status, body: null })
74
- await expect(
75
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
76
- ).resolves.not.toThrow()
90
+ await expect(run({ adapter })).resolves.not.toThrow()
77
91
  }
78
92
  })
79
93
 
@@ -87,7 +101,7 @@ describe('executeCall', () => {
87
101
  stream: vi.fn(async () => { throw new Error('not expected') }),
88
102
  }
89
103
 
90
- const globalHooks: ClientHooks = {
104
+ const hooks: ClientHooks = {
91
105
  onBeforeRequest: (ctx) => ({
92
106
  ...ctx,
93
107
  request: {
@@ -97,7 +111,7 @@ describe('executeCall', () => {
97
111
  }),
98
112
  }
99
113
 
100
- await executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
114
+ await run({ adapter, hooks })
101
115
  expect(capturedHeaders[0]?.['x-auth']).toBe('token-123')
102
116
  })
103
117
 
@@ -110,26 +124,23 @@ describe('executeCall', () => {
110
124
  }),
111
125
  stream: vi.fn(async () => { throw new Error('not expected') }),
112
126
  }
113
- const globalHooks: ClientHooks = {
127
+ const hooks: ClientHooks = {
114
128
  onAfterResponse: () => { order.push('afterResponse') },
115
129
  }
116
130
 
117
- await executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
131
+ await run({ adapter, hooks })
118
132
  expect(order).toEqual(['adapter', 'afterResponse'])
119
133
  })
120
134
 
121
135
  it('does not throw when onAfterResponse swallows non-2xx by mutating status', async () => {
122
136
  const adapter = makeAdapter({ status: 401, body: { message: 'Unauthorized' } })
123
- const globalHooks: ClientHooks = {
137
+ const hooks: ClientHooks = {
124
138
  onAfterResponse: (ctx) => {
125
- // Swallow the error by setting status to 200
126
139
  ctx.response.status = 200
127
140
  },
128
141
  }
129
142
 
130
- await expect(
131
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
132
- ).resolves.not.toThrow()
143
+ await expect(run({ adapter, hooks })).resolves.not.toThrow()
133
144
  })
134
145
 
135
146
  it('runs onError on adapter failure and re-throws', async () => {
@@ -139,24 +150,186 @@ describe('executeCall', () => {
139
150
  stream: vi.fn(async () => { throw new Error('not expected') }),
140
151
  }
141
152
  const receivedErrors: unknown[] = []
142
- const globalHooks: ClientHooks = {
153
+ const hooks: ClientHooks = {
143
154
  onError: (ctx) => { receivedErrors.push(ctx.error) },
144
155
  }
145
156
 
146
- await expect(
147
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
148
- ).rejects.toThrow('Network failure')
157
+ await expect(run({ adapter, hooks })).rejects.toThrow('Network failure')
149
158
  expect(receivedErrors[0]).toBe(adapterError)
150
159
  })
151
160
 
152
- it('passes per-procedure hooks as local hooks', async () => {
161
+ it('passes per-procedure hooks as local options', async () => {
153
162
  const adapter = makeAdapter()
154
163
  const localOrder: string[] = []
155
- const localHooks: ClientHooks = {
164
+ const options: ProcedureCallOptions = {
156
165
  onBeforeRequest: (ctx) => { localOrder.push('local-before'); return ctx },
157
166
  }
158
167
 
159
- await executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, localHooks)
168
+ await run({ adapter, options })
160
169
  expect(localOrder).toContain('local-before')
161
170
  })
171
+
172
+ // ── Per-call options: signal / timeout / headers / basePath / meta ──
173
+
174
+ it('per-call timeout attaches a signal via AbortSignal.timeout', async () => {
175
+ const spy = vi.spyOn(AbortSignal, 'timeout')
176
+ try {
177
+ let observedSignal: AbortSignal | undefined
178
+ const adapter: ClientAdapter = {
179
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
180
+ observedSignal = req.signal
181
+ return { status: 200, headers: {}, body: {} }
182
+ }),
183
+ stream: vi.fn(async () => { throw new Error('not expected') }),
184
+ }
185
+
186
+ await run({ adapter, options: { timeout: 5000 } })
187
+ expect(spy).toHaveBeenCalledWith(5000)
188
+ expect(observedSignal).toBeDefined()
189
+ } finally {
190
+ spy.mockRestore()
191
+ }
192
+ })
193
+
194
+ it('adapter receives a signal that reflects abort when the caller cancels', async () => {
195
+ const controller = new AbortController()
196
+ const adapter: ClientAdapter = {
197
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
198
+ return new Promise((_resolve, reject) => {
199
+ const abort = () => reject(new Error('aborted'))
200
+ if (req.signal?.aborted) abort()
201
+ else req.signal?.addEventListener('abort', abort, { once: true })
202
+ })
203
+ }),
204
+ stream: vi.fn(async () => { throw new Error('not expected') }),
205
+ }
206
+
207
+ const promise = run({ adapter, options: { signal: controller.signal } })
208
+ // Let executeCall reach the adapter before aborting
209
+ await Promise.resolve()
210
+ controller.abort()
211
+ await expect(promise).rejects.toThrow('aborted')
212
+ })
213
+
214
+ it('per-call signal is forwarded to the adapter', async () => {
215
+ const controller = new AbortController()
216
+ let observedSignal: AbortSignal | undefined
217
+ const adapter: ClientAdapter = {
218
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
219
+ observedSignal = req.signal
220
+ return { status: 200, headers: {}, body: {} }
221
+ }),
222
+ stream: vi.fn(async () => { throw new Error('not expected') }),
223
+ }
224
+
225
+ await run({ adapter, options: { signal: controller.signal } })
226
+ expect(observedSignal).toBe(controller.signal)
227
+ })
228
+
229
+ it('per-call headers are merged into the request before hooks', async () => {
230
+ const capturedHeaders: Record<string, string>[] = []
231
+ const seenByHook: Record<string, string>[] = []
232
+ const adapter: ClientAdapter = {
233
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
234
+ capturedHeaders.push(req.headers ?? {})
235
+ return { status: 200, headers: {}, body: {} }
236
+ }),
237
+ stream: vi.fn(async () => { throw new Error('not expected') }),
238
+ }
239
+
240
+ const hooks: ClientHooks = {
241
+ onBeforeRequest: (ctx) => {
242
+ seenByHook.push({ ...ctx.request.headers })
243
+ return ctx
244
+ },
245
+ }
246
+
247
+ await run({ adapter, hooks, options: { headers: { 'x-request-id': 'req-123' } } })
248
+ expect(seenByHook[0]?.['x-request-id']).toBe('req-123')
249
+ expect(capturedHeaders[0]?.['x-request-id']).toBe('req-123')
250
+ })
251
+
252
+ it('per-call basePath overrides the client base path', async () => {
253
+ const capturedUrls: string[] = []
254
+ const adapter: ClientAdapter = {
255
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
256
+ capturedUrls.push(req.url)
257
+ return { status: 200, headers: {}, body: {} }
258
+ }),
259
+ stream: vi.fn(async () => { throw new Error('not expected') }),
260
+ }
261
+
262
+ await run({
263
+ adapter,
264
+ descriptor: makeDescriptor({ path: '/users' }),
265
+ basePath: 'https://default.example.com',
266
+ options: { basePath: 'https://override.example.com' },
267
+ })
268
+ expect(capturedUrls[0]).toBe('https://override.example.com/users')
269
+ })
270
+
271
+ it('global defaults apply when no per-call options', async () => {
272
+ const capturedHeaders: Record<string, string>[] = []
273
+ const capturedUrls: string[] = []
274
+ const adapter: ClientAdapter = {
275
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
276
+ capturedHeaders.push(req.headers ?? {})
277
+ capturedUrls.push(req.url)
278
+ return { status: 200, headers: {}, body: {} }
279
+ }),
280
+ stream: vi.fn(async () => { throw new Error('not expected') }),
281
+ }
282
+
283
+ await run({
284
+ adapter,
285
+ descriptor: makeDescriptor({ path: '/users' }),
286
+ basePath: 'https://default.example.com',
287
+ defaults: {
288
+ headers: { 'x-client-version': '1.0' },
289
+ basePath: 'https://region-us.example.com',
290
+ },
291
+ })
292
+ expect(capturedHeaders[0]?.['x-client-version']).toBe('1.0')
293
+ expect(capturedUrls[0]).toBe('https://region-us.example.com/users')
294
+ })
295
+
296
+ it('onBeforeRequest can still override resolved signal/headers', async () => {
297
+ let observedSignal: AbortSignal | undefined
298
+ const hookController = new AbortController()
299
+ const adapter: ClientAdapter = {
300
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
301
+ observedSignal = req.signal
302
+ return { status: 200, headers: {}, body: {} }
303
+ }),
304
+ stream: vi.fn(async () => { throw new Error('not expected') }),
305
+ }
306
+
307
+ const hooks: ClientHooks = {
308
+ onBeforeRequest: (ctx) => ({
309
+ ...ctx,
310
+ request: { ...ctx.request, signal: hookController.signal },
311
+ }),
312
+ }
313
+
314
+ await run({ adapter, hooks, options: { timeout: 10_000 } })
315
+ expect(observedSignal).toBe(hookController.signal)
316
+ })
317
+
318
+ it('merges default + per-call meta; per-call keys win', async () => {
319
+ let observedMeta: unknown
320
+ const adapter: ClientAdapter = {
321
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
322
+ observedMeta = req.meta
323
+ return { status: 200, headers: {}, body: {} }
324
+ }),
325
+ stream: vi.fn(async () => { throw new Error('not expected') }),
326
+ }
327
+
328
+ await run({
329
+ adapter,
330
+ defaults: { meta: { traceId: 'default-trace', attempt: 1 } as never },
331
+ options: { meta: { traceId: 'override' } as never },
332
+ })
333
+ expect(observedMeta).toEqual({ traceId: 'override', attempt: 1 })
334
+ })
162
335
  })
@@ -1,64 +1,77 @@
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'
4
5
  import type {
5
6
  ClientAdapter,
6
7
  ClientHooks,
7
8
  CallDescriptor,
9
+ ProcedureCallDefaults,
10
+ ProcedureCallOptions,
8
11
  } from './types.js'
9
12
 
13
+ export interface ExecuteCallConfig {
14
+ descriptor: CallDescriptor
15
+ basePath: string
16
+ adapter: ClientAdapter
17
+ hooks: ClientHooks
18
+ defaults?: ProcedureCallDefaults
19
+ options?: ProcedureCallOptions
20
+ }
21
+
10
22
  /**
11
23
  * Executes a single procedure call through the adapter.
12
24
  *
13
25
  * Flow:
14
- * 1. Build AdapterRequest from descriptor
15
- * 2. Run onBeforeRequest hooks (global then local)
16
- * 3. Call adapter.request()
17
- * 4. On adapter error: run onError hooks, re-throw
18
- * 5. Run onAfterResponse hooks (hooks may mutate response.status)
19
- * 6. If response status is non-2xx: throw ClientRequestError
20
- * 7. Return response.body as TResponse
26
+ * 1. Resolve base path (per-call > defaults > config) and build AdapterRequest
27
+ * 2. Apply request options (headers, signal, timeout, meta) from defaults + per-call
28
+ * 3. Run onBeforeRequest hooks (global then local) — may further mutate request
29
+ * 4. Call adapter.request()
30
+ * 5. On adapter error: run onError hooks, re-throw
31
+ * 6. Run onAfterResponse hooks (may mutate response.status to swallow errors)
32
+ * 7. If response status is non-2xx: throw ClientRequestError
33
+ * 8. Return response.body as TResponse
21
34
  */
22
- export async function executeCall<TResponse>(
23
- descriptor: CallDescriptor,
24
- basePath: string,
25
- adapter: ClientAdapter,
26
- globalHooks: ClientHooks,
27
- localHooks: ClientHooks | undefined
28
- ): Promise<TResponse> {
29
- // 1. Build the initial request
30
- let request = buildAdapterRequest(descriptor, basePath)
35
+ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise<TResponse> {
36
+ const { descriptor, basePath, adapter, hooks, defaults, options } = config
37
+
38
+ // 1. Build the initial request (path/query/body from descriptor)
39
+ const resolvedBasePath = resolveBasePath(defaults, options, basePath)
40
+ let request = buildAdapterRequest(descriptor, resolvedBasePath)
41
+
42
+ // 2. Apply request-level options (headers, signal, timeout, meta)
43
+ request = applyRequestOptions(request, defaults, options)
31
44
 
32
- // 2. Run before-request hooks — they may mutate the request
45
+ // 3. Run before-request hooks — they may further mutate the request
33
46
  const beforeCtx = await runBeforeRequest(
34
47
  { procedureName: descriptor.name, scope: descriptor.scope, request },
35
- globalHooks,
36
- localHooks
48
+ hooks,
49
+ options,
37
50
  )
38
51
  request = beforeCtx.request
39
52
 
40
- // 3. Call the adapter
53
+ // 4. Call the adapter
41
54
  let response
42
55
  try {
43
56
  response = await adapter.request(request)
44
57
  } catch (err) {
45
- // 4. On adapter error: run error hooks, re-throw
58
+ // 5. On adapter error: run error hooks, re-throw
46
59
  await runOnError(
47
60
  { procedureName: descriptor.name, scope: descriptor.scope, request, error: err },
48
- globalHooks,
49
- localHooks
61
+ hooks,
62
+ options,
50
63
  )
51
64
  throw err
52
65
  }
53
66
 
54
- // 5. Run after-response hooks — they may mutate response.status to swallow errors
67
+ // 6. Run after-response hooks — they may mutate response.status to swallow errors
55
68
  await runAfterResponse(
56
69
  { procedureName: descriptor.name, scope: descriptor.scope, request, response },
57
- globalHooks,
58
- localHooks
70
+ hooks,
71
+ options,
59
72
  )
60
73
 
61
- // 6. Check status AFTER hooks (hooks may have swallowed the error by mutating status)
74
+ // 7. Check status AFTER hooks (hooks may have swallowed the error by mutating status)
62
75
  if (response.status < 200 || response.status >= 300) {
63
76
  throw new ClientRequestError({
64
77
  status: response.status,
@@ -69,6 +82,6 @@ export async function executeCall<TResponse>(
69
82
  })
70
83
  }
71
84
 
72
- // 7. Return the body
85
+ // 8. Return the body
73
86
  return response.body as TResponse
74
87
  }
@@ -287,4 +287,121 @@ describe('createClient', () => {
287
287
  const { ClientRequestError: CRE } = await import('./index.js')
288
288
  expect(CRE).toBeDefined()
289
289
  })
290
+
291
+ // ── defaults + per-call options ───────────────────────────
292
+
293
+ it('applies config.defaults to every call', async () => {
294
+ const capturedHeaders: Record<string, string>[] = []
295
+ const adapter: ClientAdapter = {
296
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
297
+ capturedHeaders.push(req.headers ?? {})
298
+ return { status: 200, headers: {}, body: {} }
299
+ }),
300
+ stream: vi.fn(async (): Promise<AdapterStreamResponse> => ({
301
+ status: 200,
302
+ headers: {},
303
+ body: makeAsyncIterable([]),
304
+ })),
305
+ }
306
+
307
+ const client = createClient({
308
+ adapter,
309
+ basePath: 'https://api.example.com',
310
+ defaults: { headers: { 'x-client': 'web' } },
311
+ scopes: (instance: ClientInstance) => ({
312
+ users: {
313
+ getUser: () => instance.call<unknown>(makeCallDescriptor()),
314
+ },
315
+ }),
316
+ })
317
+
318
+ await client.users.getUser()
319
+ expect(capturedHeaders[0]?.['x-client']).toBe('web')
320
+ })
321
+
322
+ it('per-call options override defaults — headers merge, per-call wins', async () => {
323
+ const capturedHeaders: Record<string, string>[] = []
324
+ const adapter: ClientAdapter = {
325
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
326
+ capturedHeaders.push(req.headers ?? {})
327
+ return { status: 200, headers: {}, body: {} }
328
+ }),
329
+ stream: vi.fn(async (): Promise<AdapterStreamResponse> => ({
330
+ status: 200,
331
+ headers: {},
332
+ body: makeAsyncIterable([]),
333
+ })),
334
+ }
335
+
336
+ const client = createClient({
337
+ adapter,
338
+ basePath: 'https://api.example.com',
339
+ defaults: { headers: { 'x-client': 'web', 'x-trace': 'default' } },
340
+ scopes: (instance: ClientInstance) => ({
341
+ users: {
342
+ getUser: (options?: { headers?: Record<string, string> }) =>
343
+ instance.call<unknown>(makeCallDescriptor(), options),
344
+ },
345
+ }),
346
+ })
347
+
348
+ await client.users.getUser({ headers: { 'x-trace': 'override', 'x-extra': 'yes' } })
349
+ expect(capturedHeaders[0]).toMatchObject({
350
+ 'x-client': 'web',
351
+ 'x-trace': 'override',
352
+ 'x-extra': 'yes',
353
+ })
354
+ })
355
+
356
+ it('per-call timeout attaches an AbortSignal.timeout signal', async () => {
357
+ const spy = vi.spyOn(AbortSignal, 'timeout')
358
+ try {
359
+ let observedSignal: AbortSignal | undefined
360
+ const adapter: ClientAdapter = {
361
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
362
+ observedSignal = req.signal
363
+ return { status: 200, headers: {}, body: {} }
364
+ }),
365
+ stream: vi.fn(async (): Promise<AdapterStreamResponse> => ({
366
+ status: 200,
367
+ headers: {},
368
+ body: makeAsyncIterable([]),
369
+ })),
370
+ }
371
+
372
+ const client = createClient({
373
+ adapter,
374
+ basePath: 'https://api.example.com',
375
+ scopes: (instance: ClientInstance) => ({
376
+ users: {
377
+ call: () => instance.call<unknown>(makeCallDescriptor(), { timeout: 5000 }),
378
+ },
379
+ }),
380
+ })
381
+
382
+ await client.users.call()
383
+ expect(spy).toHaveBeenCalledWith(5000)
384
+ expect(observedSignal).toBeDefined()
385
+ } finally {
386
+ spy.mockRestore()
387
+ }
388
+ })
389
+
390
+ it('ClientInstance exposes defaults', () => {
391
+ const adapter = makeAdapter()
392
+ const defaults = { timeout: 5000, headers: { 'x-client': 'test' } }
393
+
394
+ let capturedInstance: ClientInstance | undefined
395
+ createClient({
396
+ adapter,
397
+ basePath: 'https://api.example.com',
398
+ defaults,
399
+ scopes: (instance: ClientInstance) => {
400
+ capturedInstance = instance
401
+ return {}
402
+ },
403
+ })
404
+
405
+ expect(capturedInstance?.defaults).toEqual(defaults)
406
+ })
290
407
  })
@@ -25,23 +25,37 @@ import type {
25
25
  * - The outer `.result` is wired up to the inner stream's `.result`.
26
26
  */
27
27
  export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TScopes {
28
- const { adapter, basePath, hooks: globalHooks = {}, scopes } = config
28
+ const {
29
+ adapter,
30
+ basePath,
31
+ hooks: globalHooks = {},
32
+ defaults: globalDefaults = {},
33
+ scopes,
34
+ } = config
29
35
 
30
36
  const instance: ClientInstance = {
31
37
  basePath,
32
38
  adapter,
33
39
  hooks: globalHooks,
40
+ defaults: globalDefaults,
34
41
 
35
42
  call<TResponse>(
36
43
  descriptor: CallDescriptor,
37
- options?: ProcedureCallOptions
44
+ options?: ProcedureCallOptions,
38
45
  ): Promise<TResponse> {
39
- return executeCall<TResponse>(descriptor, basePath, adapter, globalHooks, options)
46
+ return executeCall<TResponse>({
47
+ descriptor,
48
+ basePath,
49
+ adapter,
50
+ hooks: globalHooks,
51
+ defaults: globalDefaults,
52
+ options,
53
+ })
40
54
  },
41
55
 
42
56
  stream<TYield, TReturn>(
43
57
  descriptor: StreamDescriptor,
44
- options?: ProcedureCallOptions
58
+ options?: ProcedureCallOptions,
45
59
  ): TypedStream<TYield, TReturn> {
46
60
  // executeStream is async but stream() must be synchronous.
47
61
  // Create a deferred TypedStream that wraps the async executeStream call.
@@ -58,13 +72,14 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
58
72
  async function* deferredGenerator(): AsyncGenerator<TYield> {
59
73
  let innerStream: TypedStream<TYield, TReturn>
60
74
  try {
61
- innerStream = await executeStream<TYield, TReturn>(
75
+ innerStream = await executeStream<TYield, TReturn>({
62
76
  descriptor,
63
77
  basePath,
64
78
  adapter,
65
- globalHooks,
66
- options
67
- )
79
+ hooks: globalHooks,
80
+ defaults: globalDefaults,
81
+ options,
82
+ })
68
83
  } catch (err) {
69
84
  rejectResult(err)
70
85
  throw err
@@ -107,8 +122,10 @@ export type {
107
122
  StreamDescriptor,
108
123
  TypedStream,
109
124
  ClientInstance,
125
+ ProcedureCallDefaults,
110
126
  ProcedureCallOptions,
111
127
  CreateClientConfig,
128
+ RequestMeta,
112
129
  } from './types.js'
113
130
 
114
131
  export { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'