ts-procedures 5.14.0 → 5.16.0

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