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.
- package/README.md +2 -0
- package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +85 -17
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +220 -9
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +271 -16
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +53 -18
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
- package/agent_config/copilot/copilot-instructions.md +132 -19
- package/agent_config/cursor/cursorrules +132 -19
- package/build/client/call.d.ts +19 -9
- package/build/client/call.js +33 -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/error-dispatch.d.ts +13 -0
- package/build/client/error-dispatch.js +26 -0
- package/build/client/error-dispatch.js.map +1 -0
- package/build/client/error-dispatch.test.d.ts +1 -0
- package/build/client/error-dispatch.test.js +56 -0
- package/build/client/error-dispatch.test.js.map +1 -0
- package/build/client/fetch-adapter.js +10 -4
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/index.d.ts +2 -1
- package/build/client/index.js +22 -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 +19 -9
- package/build/client/stream.js +36 -21
- 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/typed-error-dispatch.test.d.ts +1 -0
- package/build/client/typed-error-dispatch.test.js +168 -0
- package/build/client/typed-error-dispatch.test.js.map +1 -0
- package/build/client/types.d.ts +105 -1
- package/build/client/types.js +1 -1
- package/build/codegen/e2e.test.js +150 -4
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +7 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +17 -6
- package/build/codegen/emit-errors.integration.test.d.ts +1 -0
- package/build/codegen/emit-errors.integration.test.js +162 -0
- package/build/codegen/emit-errors.integration.test.js.map +1 -0
- package/build/codegen/emit-errors.js +50 -39
- package/build/codegen/emit-errors.js.map +1 -1
- package/build/codegen/emit-errors.test.js +75 -78
- package/build/codegen/emit-errors.test.js.map +1 -1
- package/build/codegen/emit-index.d.ts +7 -0
- package/build/codegen/emit-index.js +26 -4
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-index.test.js +55 -23
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.d.ts +8 -0
- package/build/codegen/emit-scope.js +82 -7
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/pipeline.js +22 -2
- package/build/codegen/pipeline.js.map +1 -1
- package/build/implementations/http/doc-registry.d.ts +21 -0
- package/build/implementations/http/doc-registry.js +51 -78
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +8 -6
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/error-taxonomy.d.ts +240 -0
- package/build/implementations/http/error-taxonomy.js +230 -0
- package/build/implementations/http/error-taxonomy.js.map +1 -0
- package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/error-taxonomy.test.js +399 -0
- package/build/implementations/http/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/index.d.ts +39 -8
- package/build/implementations/http/express-rpc/index.js +39 -8
- package/build/implementations/http/express-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-api/index.d.ts +38 -1
- package/build/implementations/http/hono-api/index.js +32 -0
- package/build/implementations/http/hono-api/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-rpc/index.d.ts +34 -7
- package/build/implementations/http/hono-rpc/index.js +31 -4
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-stream/index.d.ts +40 -3
- package/build/implementations/http/hono-stream/index.js +37 -10
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +45 -18
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/http/on-request-error.test.d.ts +1 -0
- package/build/implementations/http/on-request-error.test.js +173 -0
- package/build/implementations/http/on-request-error.test.js.map +1 -0
- package/build/implementations/http/route-errors.test.d.ts +1 -0
- package/build/implementations/http/route-errors.test.js +140 -0
- package/build/implementations/http/route-errors.test.js.map +1 -0
- package/build/implementations/types.d.ts +30 -2
- package/docs/client-and-codegen.md +228 -14
- package/docs/core.md +14 -5
- package/docs/http-integrations.md +135 -4
- package/docs/streaming.md +3 -1
- package/package.json +7 -2
- package/src/client/call.test.ts +202 -29
- package/src/client/call.ts +50 -28
- package/src/client/error-dispatch.test.ts +72 -0
- package/src/client/error-dispatch.ts +27 -0
- package/src/client/fetch-adapter.ts +11 -5
- package/src/client/index.test.ts +117 -0
- package/src/client/index.ts +34 -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 +53 -27
- package/src/client/typed-error-dispatch.test.ts +211 -0
- package/src/client/types.ts +116 -2
- package/src/codegen/e2e.test.ts +160 -4
- package/src/codegen/emit-client-runtime.ts +7 -0
- package/src/codegen/emit-errors.integration.test.ts +183 -0
- package/src/codegen/emit-errors.test.ts +91 -87
- package/src/codegen/emit-errors.ts +123 -41
- package/src/codegen/emit-index.test.ts +68 -24
- package/src/codegen/emit-index.ts +66 -4
- package/src/codegen/emit-scope.ts +124 -7
- package/src/codegen/pipeline.ts +25 -2
- package/src/implementations/http/README.md +28 -5
- package/src/implementations/http/doc-registry.test.ts +10 -6
- package/src/implementations/http/doc-registry.ts +63 -80
- package/src/implementations/http/error-taxonomy.test.ts +438 -0
- package/src/implementations/http/error-taxonomy.ts +337 -0
- package/src/implementations/http/express-rpc/README.md +21 -22
- package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
- package/src/implementations/http/express-rpc/index.ts +75 -14
- package/src/implementations/http/hono-api/README.md +284 -0
- package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
- package/src/implementations/http/hono-api/index.ts +76 -1
- package/src/implementations/http/hono-rpc/README.md +18 -19
- package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
- package/src/implementations/http/hono-rpc/index.ts +65 -9
- package/src/implementations/http/hono-stream/README.md +44 -25
- package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
- package/src/implementations/http/hono-stream/index.test.ts +54 -18
- package/src/implementations/http/hono-stream/index.ts +83 -13
- package/src/implementations/http/on-request-error.test.ts +201 -0
- package/src/implementations/http/route-errors.test.ts +177 -0
- package/src/implementations/types.ts +30 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { dispatchTypedError } from './error-dispatch.js'
|
|
3
|
+
import type { ErrorRegistry, ErrorResponseMeta } from './types.js'
|
|
4
|
+
|
|
5
|
+
class SyntheticError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
message: string,
|
|
8
|
+
readonly props: string[]
|
|
9
|
+
) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = 'SyntheticError'
|
|
12
|
+
Object.setPrototypeOf(this, SyntheticError.prototype)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const meta: ErrorResponseMeta = { status: 422, procedureName: 'Test', scope: 'users' }
|
|
17
|
+
|
|
18
|
+
function makeRegistry(): ErrorRegistry {
|
|
19
|
+
return {
|
|
20
|
+
SyntheticError: {
|
|
21
|
+
fromResponse(body, m) {
|
|
22
|
+
const b = body as { message: string; props: string[] }
|
|
23
|
+
return new SyntheticError(b.message, b.props)
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('dispatchTypedError', () => {
|
|
30
|
+
test('returns a typed error when body.name matches a registry key', () => {
|
|
31
|
+
const err = dispatchTypedError(
|
|
32
|
+
makeRegistry(),
|
|
33
|
+
{ name: 'SyntheticError', message: 'bad', props: ['x'] },
|
|
34
|
+
meta
|
|
35
|
+
)
|
|
36
|
+
expect(err).toBeInstanceOf(SyntheticError)
|
|
37
|
+
expect(err).toBeInstanceOf(Error)
|
|
38
|
+
expect((err as SyntheticError).props).toEqual(['x'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('returns null when no registry is provided', () => {
|
|
42
|
+
const err = dispatchTypedError(undefined, { name: 'SyntheticError' }, meta)
|
|
43
|
+
expect(err).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('returns null when body is not an object', () => {
|
|
47
|
+
expect(dispatchTypedError(makeRegistry(), 'oops', meta)).toBeNull()
|
|
48
|
+
expect(dispatchTypedError(makeRegistry(), null, meta)).toBeNull()
|
|
49
|
+
expect(dispatchTypedError(makeRegistry(), 42, meta)).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('returns null when body.name is missing or not a string', () => {
|
|
53
|
+
expect(dispatchTypedError(makeRegistry(), {}, meta)).toBeNull()
|
|
54
|
+
expect(dispatchTypedError(makeRegistry(), { name: 123 }, meta)).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('returns null when body.name does not match any registry key', () => {
|
|
58
|
+
expect(
|
|
59
|
+
dispatchTypedError(makeRegistry(), { name: 'UnregisteredError' }, meta)
|
|
60
|
+
).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('returns null when fromResponse returns a non-Error value', () => {
|
|
64
|
+
const registry: ErrorRegistry = {
|
|
65
|
+
Broken: {
|
|
66
|
+
// Intentionally returns a non-Error — defensive guard in dispatch.
|
|
67
|
+
fromResponse: () => 'not an error' as unknown as Error,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
expect(dispatchTypedError(registry, { name: 'Broken' }, meta)).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ErrorRegistry, ErrorResponseMeta } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Attempts to construct a typed error from the response body using the
|
|
5
|
+
* registry. Returns `null` when:
|
|
6
|
+
* - no registry is configured,
|
|
7
|
+
* - the body is not a plain object with a `name` string,
|
|
8
|
+
* - no registry key matches the body's `name`, or
|
|
9
|
+
* - `fromResponse` returns a non-Error value (defensive — registry entries
|
|
10
|
+
* are expected to return `Error` subclasses).
|
|
11
|
+
*
|
|
12
|
+
* Callers fall back to `ClientRequestError` when this returns `null`.
|
|
13
|
+
*/
|
|
14
|
+
export function dispatchTypedError(
|
|
15
|
+
registry: ErrorRegistry | undefined,
|
|
16
|
+
body: unknown,
|
|
17
|
+
meta: ErrorResponseMeta
|
|
18
|
+
): Error | null {
|
|
19
|
+
if (!registry) return null
|
|
20
|
+
if (!body || typeof body !== 'object') return null
|
|
21
|
+
const name = (body as { name?: unknown }).name
|
|
22
|
+
if (typeof name !== 'string') return null
|
|
23
|
+
const factory = registry[name]
|
|
24
|
+
if (!factory?.fromResponse) return null
|
|
25
|
+
const result = factory.fromResponse(body, meta)
|
|
26
|
+
return result instanceof Error ? result : null
|
|
27
|
+
}
|
|
@@ -174,17 +174,23 @@ export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
|
|
|
174
174
|
})
|
|
175
175
|
|
|
176
176
|
const headers = extractHeaders(response)
|
|
177
|
+
const emptyBody: AsyncIterable<unknown> = {
|
|
178
|
+
[Symbol.asyncIterator]: async function* () {},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Non-2xx responses on a stream endpoint are JSON, not SSE. Parse the
|
|
182
|
+
// body eagerly and surface it via errorBody so the client can dispatch
|
|
183
|
+
// a typed error (or fall back to ClientRequestError with a real body).
|
|
184
|
+
if (response.status < 200 || response.status >= 300) {
|
|
185
|
+
const errorBody = await parseResponseBody(response)
|
|
186
|
+
return { status: response.status, headers, body: emptyBody, errorBody }
|
|
187
|
+
}
|
|
177
188
|
|
|
178
189
|
if (!response.body) {
|
|
179
|
-
// No body — return an empty async iterable
|
|
180
|
-
const emptyBody: AsyncIterable<unknown> = {
|
|
181
|
-
[Symbol.asyncIterator]: async function* () {},
|
|
182
|
-
}
|
|
183
190
|
return { status: response.status, headers, body: emptyBody }
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
const body = parseSseStream(response.body as ReadableStream<Uint8Array>)
|
|
187
|
-
|
|
188
194
|
return { status: response.status, headers, body }
|
|
189
195
|
},
|
|
190
196
|
}
|
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,40 @@ 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
|
+
errorRegistry,
|
|
34
|
+
scopes,
|
|
35
|
+
} = config
|
|
29
36
|
|
|
30
37
|
const instance: ClientInstance = {
|
|
31
38
|
basePath,
|
|
32
39
|
adapter,
|
|
33
40
|
hooks: globalHooks,
|
|
41
|
+
defaults: globalDefaults,
|
|
42
|
+
errorRegistry,
|
|
34
43
|
|
|
35
44
|
call<TResponse>(
|
|
36
45
|
descriptor: CallDescriptor,
|
|
37
|
-
options?: ProcedureCallOptions
|
|
46
|
+
options?: ProcedureCallOptions,
|
|
38
47
|
): Promise<TResponse> {
|
|
39
|
-
return executeCall<TResponse>(
|
|
48
|
+
return executeCall<TResponse>({
|
|
49
|
+
descriptor,
|
|
50
|
+
basePath,
|
|
51
|
+
adapter,
|
|
52
|
+
hooks: globalHooks,
|
|
53
|
+
defaults: globalDefaults,
|
|
54
|
+
options,
|
|
55
|
+
errorRegistry,
|
|
56
|
+
})
|
|
40
57
|
},
|
|
41
58
|
|
|
42
59
|
stream<TYield, TReturn>(
|
|
43
60
|
descriptor: StreamDescriptor,
|
|
44
|
-
options?: ProcedureCallOptions
|
|
61
|
+
options?: ProcedureCallOptions,
|
|
45
62
|
): TypedStream<TYield, TReturn> {
|
|
46
63
|
// executeStream is async but stream() must be synchronous.
|
|
47
64
|
// Create a deferred TypedStream that wraps the async executeStream call.
|
|
@@ -58,13 +75,15 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
|
|
|
58
75
|
async function* deferredGenerator(): AsyncGenerator<TYield> {
|
|
59
76
|
let innerStream: TypedStream<TYield, TReturn>
|
|
60
77
|
try {
|
|
61
|
-
innerStream = await executeStream<TYield, TReturn>(
|
|
78
|
+
innerStream = await executeStream<TYield, TReturn>({
|
|
62
79
|
descriptor,
|
|
63
80
|
basePath,
|
|
64
81
|
adapter,
|
|
65
|
-
globalHooks,
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
hooks: globalHooks,
|
|
83
|
+
defaults: globalDefaults,
|
|
84
|
+
options,
|
|
85
|
+
errorRegistry,
|
|
86
|
+
})
|
|
68
87
|
} catch (err) {
|
|
69
88
|
rejectResult(err)
|
|
70
89
|
throw err
|
|
@@ -107,10 +126,17 @@ export type {
|
|
|
107
126
|
StreamDescriptor,
|
|
108
127
|
TypedStream,
|
|
109
128
|
ClientInstance,
|
|
129
|
+
ProcedureCallDefaults,
|
|
110
130
|
ProcedureCallOptions,
|
|
111
131
|
CreateClientConfig,
|
|
132
|
+
RequestMeta,
|
|
133
|
+
ErrorRegistry,
|
|
134
|
+
ErrorFactory,
|
|
135
|
+
ErrorResponseMeta,
|
|
112
136
|
} from './types.js'
|
|
113
137
|
|
|
138
|
+
export { dispatchTypedError } from './error-dispatch.js'
|
|
139
|
+
|
|
114
140
|
export { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'
|
|
115
141
|
|
|
116
142
|
export { createTypedStream } from './stream.js'
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
applyRequestOptions,
|
|
4
|
+
resolveBasePath,
|
|
5
|
+
resolveHeaders,
|
|
6
|
+
resolveMeta,
|
|
7
|
+
resolveSignal,
|
|
8
|
+
} from './resolve-options.js'
|
|
9
|
+
import type { AdapterRequest, ProcedureCallDefaults, ProcedureCallOptions } from './types.js'
|
|
10
|
+
|
|
11
|
+
// ── resolveBasePath ───────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe('resolveBasePath', () => {
|
|
14
|
+
it('uses fallback when nothing is set', () => {
|
|
15
|
+
expect(resolveBasePath(undefined, undefined, 'https://api.example.com')).toBe(
|
|
16
|
+
'https://api.example.com',
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('uses default when only defaults.basePath is set', () => {
|
|
21
|
+
expect(
|
|
22
|
+
resolveBasePath({ basePath: 'https://default.example.com' }, undefined, 'https://fallback'),
|
|
23
|
+
).toBe('https://default.example.com')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('per-call basePath overrides default', () => {
|
|
27
|
+
expect(
|
|
28
|
+
resolveBasePath(
|
|
29
|
+
{ basePath: 'https://default.example.com' },
|
|
30
|
+
{ basePath: 'https://percall.example.com' },
|
|
31
|
+
'https://fallback',
|
|
32
|
+
),
|
|
33
|
+
).toBe('https://percall.example.com')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// ── resolveHeaders ────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe('resolveHeaders', () => {
|
|
40
|
+
it('returns undefined when neither side sets headers', () => {
|
|
41
|
+
expect(resolveHeaders(undefined, undefined)).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns default headers when only defaults set', () => {
|
|
45
|
+
expect(resolveHeaders({ headers: { 'x-a': '1' } }, undefined)).toEqual({ 'x-a': '1' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('per-call keys override default keys', () => {
|
|
49
|
+
const defaults: ProcedureCallDefaults = { headers: { 'x-a': 'default', 'x-b': 'keep' } }
|
|
50
|
+
const options: ProcedureCallOptions = { headers: { 'x-a': 'override' } }
|
|
51
|
+
expect(resolveHeaders(defaults, options)).toEqual({
|
|
52
|
+
'x-a': 'override',
|
|
53
|
+
'x-b': 'keep',
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// ── resolveMeta ───────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe('resolveMeta', () => {
|
|
61
|
+
it('returns undefined when neither side sets meta', () => {
|
|
62
|
+
expect(resolveMeta(undefined, undefined)).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('merges default + per-call meta (shallow), per-call keys win', () => {
|
|
66
|
+
const defaults: ProcedureCallDefaults = { meta: { a: 1, b: 2 } as never }
|
|
67
|
+
const options: ProcedureCallOptions = { meta: { b: 99, c: 3 } as never }
|
|
68
|
+
expect(resolveMeta(defaults, options)).toEqual({ a: 1, b: 99, c: 3 })
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ── resolveSignal ─────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe('resolveSignal', () => {
|
|
75
|
+
it('returns undefined when nothing is set', () => {
|
|
76
|
+
expect(resolveSignal(undefined, undefined)).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns the default signal when no timeout and no per-call signal', () => {
|
|
80
|
+
const controller = new AbortController()
|
|
81
|
+
expect(resolveSignal({ signal: controller.signal }, undefined)).toBe(controller.signal)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('returns the per-call signal when no default and no timeout', () => {
|
|
85
|
+
const controller = new AbortController()
|
|
86
|
+
expect(resolveSignal(undefined, { signal: controller.signal })).toBe(controller.signal)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('combines default + per-call signals — default aborts combined signal', () => {
|
|
90
|
+
const defaultCtrl = new AbortController()
|
|
91
|
+
const callCtrl = new AbortController()
|
|
92
|
+
const signal = resolveSignal({ signal: defaultCtrl.signal }, { signal: callCtrl.signal })!
|
|
93
|
+
|
|
94
|
+
expect(signal.aborted).toBe(false)
|
|
95
|
+
defaultCtrl.abort(new Error('default-abort'))
|
|
96
|
+
expect(signal.aborted).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('combines default + per-call signals — per-call aborts combined signal', () => {
|
|
100
|
+
const defaultCtrl = new AbortController()
|
|
101
|
+
const callCtrl = new AbortController()
|
|
102
|
+
const signal = resolveSignal({ signal: defaultCtrl.signal }, { signal: callCtrl.signal })!
|
|
103
|
+
|
|
104
|
+
callCtrl.abort(new Error('call-abort'))
|
|
105
|
+
expect(signal.aborted).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('applies timeout via AbortSignal.timeout', () => {
|
|
109
|
+
const spy = vi.spyOn(AbortSignal, 'timeout')
|
|
110
|
+
try {
|
|
111
|
+
const signal = resolveSignal(undefined, { timeout: 100 })
|
|
112
|
+
expect(spy).toHaveBeenCalledWith(100)
|
|
113
|
+
expect(signal).toBeDefined()
|
|
114
|
+
} finally {
|
|
115
|
+
spy.mockRestore()
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('per-call timeout overrides default timeout', () => {
|
|
120
|
+
const spy = vi.spyOn(AbortSignal, 'timeout')
|
|
121
|
+
try {
|
|
122
|
+
resolveSignal({ timeout: 10_000 }, { timeout: 100 })
|
|
123
|
+
expect(spy).toHaveBeenCalledWith(100)
|
|
124
|
+
expect(spy).not.toHaveBeenCalledWith(10_000)
|
|
125
|
+
} finally {
|
|
126
|
+
spy.mockRestore()
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('combines signal + timeout — signal aborts first', () => {
|
|
131
|
+
const controller = new AbortController()
|
|
132
|
+
const signal = resolveSignal(undefined, { signal: controller.signal, timeout: 10_000 })!
|
|
133
|
+
expect(signal.aborted).toBe(false)
|
|
134
|
+
controller.abort()
|
|
135
|
+
expect(signal.aborted).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('per-call timeout: 0 disables default timeout', () => {
|
|
139
|
+
const signal = resolveSignal({ timeout: 1000 }, { timeout: 0 })
|
|
140
|
+
expect(signal).toBeUndefined()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ── applyRequestOptions ───────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('applyRequestOptions', () => {
|
|
147
|
+
const baseRequest: AdapterRequest = {
|
|
148
|
+
url: 'https://api.example.com/foo',
|
|
149
|
+
method: 'POST',
|
|
150
|
+
body: { hello: 'world' },
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
it('returns the request unchanged when nothing is provided', () => {
|
|
154
|
+
const result = applyRequestOptions(baseRequest, undefined, undefined)
|
|
155
|
+
expect(result.url).toBe(baseRequest.url)
|
|
156
|
+
expect(result.body).toEqual({ hello: 'world' })
|
|
157
|
+
expect(result.headers).toBeUndefined()
|
|
158
|
+
expect(result.signal).toBeUndefined()
|
|
159
|
+
expect(result.meta).toBeUndefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('merges default + per-call headers, preserving route-declared headers', () => {
|
|
163
|
+
const reqWithHeaders: AdapterRequest = {
|
|
164
|
+
...baseRequest,
|
|
165
|
+
headers: { 'content-type': 'application/json', 'x-route': 'declared' },
|
|
166
|
+
}
|
|
167
|
+
const result = applyRequestOptions(
|
|
168
|
+
reqWithHeaders,
|
|
169
|
+
{ headers: { 'x-default': 'd', 'x-route': 'from-default' } },
|
|
170
|
+
{ headers: { 'x-call': 'c', 'x-route': 'from-call' } },
|
|
171
|
+
)
|
|
172
|
+
expect(result.headers).toEqual({
|
|
173
|
+
'x-default': 'd',
|
|
174
|
+
'x-call': 'c',
|
|
175
|
+
// Route-declared headers WIN over resolved options (typed contract)
|
|
176
|
+
'content-type': 'application/json',
|
|
177
|
+
'x-route': 'declared',
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('attaches meta to the request when provided', () => {
|
|
182
|
+
const result = applyRequestOptions(baseRequest, undefined, {
|
|
183
|
+
meta: { traceId: 'abc' } as never,
|
|
184
|
+
})
|
|
185
|
+
expect(result.meta).toEqual({ traceId: 'abc' })
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('passes per-call signal through', () => {
|
|
189
|
+
const controller = new AbortController()
|
|
190
|
+
const result = applyRequestOptions(baseRequest, undefined, { signal: controller.signal })
|
|
191
|
+
expect(result.signal).toBe(controller.signal)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('attaches a signal when per-call timeout is set', () => {
|
|
195
|
+
const spy = vi.spyOn(AbortSignal, 'timeout')
|
|
196
|
+
try {
|
|
197
|
+
const result = applyRequestOptions(baseRequest, undefined, { timeout: 100 })
|
|
198
|
+
expect(spy).toHaveBeenCalledWith(100)
|
|
199
|
+
expect(result.signal).toBeDefined()
|
|
200
|
+
expect(result.signal?.aborted).toBe(false)
|
|
201
|
+
} finally {
|
|
202
|
+
spy.mockRestore()
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdapterRequest,
|
|
3
|
+
ProcedureCallDefaults,
|
|
4
|
+
ProcedureCallOptions,
|
|
5
|
+
RequestMeta,
|
|
6
|
+
} from './types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves the effective base path:
|
|
10
|
+
* per-call `basePath` > default `basePath` > config `basePath` (fallback).
|
|
11
|
+
*/
|
|
12
|
+
export function resolveBasePath(
|
|
13
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
14
|
+
options: ProcedureCallOptions | undefined,
|
|
15
|
+
fallback: string,
|
|
16
|
+
): string {
|
|
17
|
+
return options?.basePath ?? defaults?.basePath ?? fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves the effective AbortSignal by combining (via `AbortSignal.any`):
|
|
22
|
+
* - default signal (if any)
|
|
23
|
+
* - per-call signal (if any)
|
|
24
|
+
* - timeout signal (if resolved timeout > 0)
|
|
25
|
+
*
|
|
26
|
+
* Returns undefined when none apply. Per-call `timeout: 0` disables an
|
|
27
|
+
* inherited default timeout.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveSignal(
|
|
30
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
31
|
+
options: ProcedureCallOptions | undefined,
|
|
32
|
+
): AbortSignal | undefined {
|
|
33
|
+
const signals: AbortSignal[] = []
|
|
34
|
+
|
|
35
|
+
if (defaults?.signal) signals.push(defaults.signal)
|
|
36
|
+
if (options?.signal) signals.push(options.signal)
|
|
37
|
+
|
|
38
|
+
const timeout = options?.timeout ?? defaults?.timeout
|
|
39
|
+
if (timeout != null && timeout > 0) {
|
|
40
|
+
signals.push(AbortSignal.timeout(timeout))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (signals.length === 0) return undefined
|
|
44
|
+
if (signals.length === 1) return signals[0]
|
|
45
|
+
return AbortSignal.any(signals)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Merges headers with precedence: default < per-call. Returns undefined if
|
|
50
|
+
* no headers would be set.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveHeaders(
|
|
53
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
54
|
+
options: ProcedureCallOptions | undefined,
|
|
55
|
+
): Record<string, string> | undefined {
|
|
56
|
+
const defaultHeaders = defaults?.headers
|
|
57
|
+
const callHeaders = options?.headers
|
|
58
|
+
|
|
59
|
+
if (!defaultHeaders && !callHeaders) return undefined
|
|
60
|
+
|
|
61
|
+
return { ...defaultHeaders, ...callHeaders }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Merges meta with precedence: default < per-call. Returns undefined if
|
|
66
|
+
* no meta fields would be set.
|
|
67
|
+
*
|
|
68
|
+
* The cast is load-bearing: when a developer augments `RequestMeta` with
|
|
69
|
+
* required fields, spread of two `RequestMeta | undefined` values widens to
|
|
70
|
+
* a partial shape, which TypeScript can't prove satisfies `RequestMeta`.
|
|
71
|
+
* At runtime, the merged object carries whichever keys the caller supplied —
|
|
72
|
+
* the contract is "if you declare required fields in RequestMeta, supply them
|
|
73
|
+
* somewhere (defaults or per-call)."
|
|
74
|
+
*/
|
|
75
|
+
export function resolveMeta(
|
|
76
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
77
|
+
options: ProcedureCallOptions | undefined,
|
|
78
|
+
): RequestMeta | undefined {
|
|
79
|
+
const defaultMeta = defaults?.meta
|
|
80
|
+
const callMeta = options?.meta
|
|
81
|
+
|
|
82
|
+
if (!defaultMeta && !callMeta) return undefined
|
|
83
|
+
|
|
84
|
+
return { ...defaultMeta, ...callMeta } as RequestMeta
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Applies resolved default + per-call options to an AdapterRequest.
|
|
89
|
+
*
|
|
90
|
+
* Runs before hooks, so `onBeforeRequest` observes the merged request and can
|
|
91
|
+
* still override any field.
|
|
92
|
+
*
|
|
93
|
+
* Headers produced by the request builder (e.g., `schema.input.headers` for
|
|
94
|
+
* API routes) are preserved; resolved headers merge underneath them so the
|
|
95
|
+
* route-declared headers win, matching the adapter.config → defaults → call
|
|
96
|
+
* → route-declared → hooks precedence chain documented in the types.
|
|
97
|
+
*/
|
|
98
|
+
export function applyRequestOptions(
|
|
99
|
+
request: AdapterRequest,
|
|
100
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
101
|
+
options: ProcedureCallOptions | undefined,
|
|
102
|
+
): AdapterRequest {
|
|
103
|
+
const signal = resolveSignal(defaults, options)
|
|
104
|
+
const resolvedHeaders = resolveHeaders(defaults, options)
|
|
105
|
+
const meta = resolveMeta(defaults, options)
|
|
106
|
+
|
|
107
|
+
const headers =
|
|
108
|
+
resolvedHeaders || request.headers
|
|
109
|
+
? { ...resolvedHeaders, ...request.headers }
|
|
110
|
+
: undefined
|
|
111
|
+
|
|
112
|
+
return { ...request, headers, signal, meta }
|
|
113
|
+
}
|