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.
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +57 -4
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +102 -3
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +33 -5
- package/agent_config/copilot/copilot-instructions.md +55 -7
- package/agent_config/cursor/cursorrules +55 -7
- package/build/client/call.d.ts +18 -9
- package/build/client/call.js +25 -19
- package/build/client/call.js.map +1 -1
- package/build/client/call.test.js +167 -17
- package/build/client/call.test.js.map +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +18 -3
- package/build/client/index.js.map +1 -1
- package/build/client/index.test.js +104 -0
- package/build/client/index.test.js.map +1 -1
- package/build/client/resolve-options.d.ts +45 -0
- package/build/client/resolve-options.js +82 -0
- package/build/client/resolve-options.js.map +1 -0
- package/build/client/resolve-options.test.d.ts +1 -0
- package/build/client/resolve-options.test.js +158 -0
- package/build/client/resolve-options.test.js.map +1 -0
- package/build/client/stream.d.ts +18 -9
- package/build/client/stream.js +24 -19
- package/build/client/stream.js.map +1 -1
- package/build/client/stream.test.js +102 -46
- package/build/client/stream.test.js.map +1 -1
- package/build/client/types.d.ts +68 -1
- package/build/client/types.js +1 -1
- package/build/codegen/e2e.test.js +141 -0
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +3 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/docs/client-and-codegen.md +123 -2
- package/package.json +1 -1
- package/src/client/call.test.ts +202 -29
- package/src/client/call.ts +41 -28
- package/src/client/index.test.ts +117 -0
- package/src/client/index.ts +25 -8
- package/src/client/resolve-options.test.ts +205 -0
- package/src/client/resolve-options.ts +113 -0
- package/src/client/stream.test.ts +132 -107
- package/src/client/stream.ts +40 -25
- package/src/client/types.ts +74 -2
- package/src/codegen/e2e.test.ts +151 -0
- package/src/codegen/emit-client-runtime.ts +3 -0
- package/src/implementations/http/README.md +9 -1
package/src/client/call.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
127
|
+
const hooks: ClientHooks = {
|
|
114
128
|
onAfterResponse: () => { order.push('afterResponse') },
|
|
115
129
|
}
|
|
116
130
|
|
|
117
|
-
await
|
|
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
|
|
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
|
|
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
|
|
161
|
+
it('passes per-procedure hooks as local options', async () => {
|
|
153
162
|
const adapter = makeAdapter()
|
|
154
163
|
const localOrder: string[] = []
|
|
155
|
-
const
|
|
164
|
+
const options: ProcedureCallOptions = {
|
|
156
165
|
onBeforeRequest: (ctx) => { localOrder.push('local-before'); return ctx },
|
|
157
166
|
}
|
|
158
167
|
|
|
159
|
-
await
|
|
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
|
})
|
package/src/client/call.ts
CHANGED
|
@@ -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.
|
|
15
|
-
* 2.
|
|
16
|
-
* 3.
|
|
17
|
-
* 4.
|
|
18
|
-
* 5.
|
|
19
|
-
* 6.
|
|
20
|
-
* 7.
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
36
|
-
|
|
48
|
+
hooks,
|
|
49
|
+
options,
|
|
37
50
|
)
|
|
38
51
|
request = beforeCtx.request
|
|
39
52
|
|
|
40
|
-
//
|
|
53
|
+
// 4. Call the adapter
|
|
41
54
|
let response
|
|
42
55
|
try {
|
|
43
56
|
response = await adapter.request(request)
|
|
44
57
|
} catch (err) {
|
|
45
|
-
//
|
|
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
|
-
|
|
49
|
-
|
|
61
|
+
hooks,
|
|
62
|
+
options,
|
|
50
63
|
)
|
|
51
64
|
throw err
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
//
|
|
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
|
-
|
|
58
|
-
|
|
70
|
+
hooks,
|
|
71
|
+
options,
|
|
59
72
|
)
|
|
60
73
|
|
|
61
|
-
//
|
|
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
|
-
//
|
|
85
|
+
// 8. Return the body
|
|
73
86
|
return response.body as TResponse
|
|
74
87
|
}
|
package/src/client/index.test.ts
CHANGED
|
@@ -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
|
})
|
package/src/client/index.ts
CHANGED
|
@@ -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 {
|
|
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>(
|
|
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
|
-
|
|
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'
|