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
@@ -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
  }
@@ -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,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 { adapter, basePath, hooks: globalHooks = {}, scopes } = config
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>(descriptor, basePath, adapter, globalHooks, options)
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
- options
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
+ }