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
@@ -13,7 +13,7 @@ For a full cross-framework comparison (config interfaces, path generation, conte
13
13
  | Express RPC | `ts-procedures/express-rpc` | RPC (POST routes) | [README](../src/implementations/http/express-rpc/README.md) |
14
14
  | Hono RPC | `ts-procedures/hono-rpc` | RPC (POST routes) | [README](../src/implementations/http/hono-rpc/README.md) |
15
15
  | Hono Stream | `ts-procedures/hono-stream` | SSE/text streaming | [README](../src/implementations/http/hono-stream/README.md) |
16
- | Hono API | `ts-procedures/hono-api` | REST (method-based) | [Below](#hono-api-integration) |
16
+ | Hono API | `ts-procedures/hono-api` | REST (method-based) | [README](../src/implementations/http/hono-api/README.md) |
17
17
 
18
18
  ## Express RPC
19
19
 
@@ -27,7 +27,7 @@ const RPC = Procedures<AppContext, RPCConfig>()
27
27
  RPC.Create(
28
28
  'GetUser',
29
29
  {
30
- name: ['users', 'get'],
30
+ scope: ['users', 'get'],
31
31
  version: 1,
32
32
  schema: {
33
33
  params: Type.Object({ id: Type.String() }),
@@ -44,7 +44,7 @@ const app = new ExpressRPCAppBuilder()
44
44
  .build()
45
45
 
46
46
  app.listen(3000)
47
- // Route created: POST /rpc/users/get/1
47
+ // Route created: POST /users/get/get-user/1
48
48
  ```
49
49
 
50
50
  See the [Express RPC Integration Guide](../src/implementations/http/express-rpc/README.md) for complete setup including lifecycle hooks, error handling, and route documentation.
@@ -139,7 +139,7 @@ API.Create('CreateUser', {
139
139
  return await createUser(body)
140
140
  })
141
141
 
142
- const app = await new HonoAPIAppBuilder({ pathPrefix: '/api' })
142
+ const app = new HonoAPIAppBuilder({ pathPrefix: '/api' })
143
143
  .register(API, (c) => ({ userId: c.req.header('x-user-id') || 'anonymous' }))
144
144
  .build()
145
145
 
@@ -148,6 +148,129 @@ const app = await new HonoAPIAppBuilder({ pathPrefix: '/api' })
148
148
  // POST /api/users -> 201
149
149
  ```
150
150
 
151
+ See the [Hono API Integration Guide](../src/implementations/http/hono-api/README.md) for the full surface: `schema.input` channels, query parsing, success-status defaults, and error handling.
152
+
153
+ ## Error Handling
154
+
155
+ Every HTTP builder supports **two peer error-handling modes** — neither is "primary" or "fallback". Pick whichever fits your app, or combine them.
156
+
157
+ | Mode | When to pick | What you configure |
158
+ |---|---|---|
159
+ | **Declarative — the taxonomy** | Structured errors, typed client dispatch, DocEnvelope integration | `errors` (taxonomy) + optional `unknownError` |
160
+ | **Imperative — the `onError` callback** | Simple apps, gradual migration, full response control, no typed client contract | `onError` only |
161
+
162
+ Both modes also expose `onRequestError` — a cross-cutting observer for logging, tracing, and metrics that fires for every error regardless of dispatch outcome.
163
+
164
+ ### Declarative — the taxonomy
165
+
166
+ ```typescript
167
+ import { defineErrorTaxonomy } from 'ts-procedures/http-errors'
168
+ import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
169
+
170
+ class UseCaseError extends Error {
171
+ constructor(readonly externalMsg: string, readonly internalMsg: string) {
172
+ super(externalMsg); this.name = 'UseCaseError'
173
+ Object.setPrototypeOf(this, UseCaseError.prototype)
174
+ }
175
+ }
176
+
177
+ const appErrors = defineErrorTaxonomy({
178
+ UseCaseError: {
179
+ class: UseCaseError,
180
+ statusCode: 422,
181
+ toResponse: (err) => ({ message: err.externalMsg }), // `name: 'UseCaseError'` auto-injected
182
+ onCatch: (err) => logger.error(err.internalMsg),
183
+ },
184
+ // 3rd-party errors without a subclassable type use `match:` instead of `class:`.
185
+ MongoDuplicateKey: {
186
+ match: (err): err is Error =>
187
+ err instanceof Error && err.name === 'MongoServerError' && (err as any).code === 11000,
188
+ statusCode: 409,
189
+ toResponse: () => ({ message: 'Resource already exists' }),
190
+ },
191
+ })
192
+
193
+ new HonoAPIAppBuilder({
194
+ errors: appErrors,
195
+ unknownError: {
196
+ statusCode: 500,
197
+ toResponse: () => ({ name: 'InternalServerError', message: 'Unexpected error' }),
198
+ onCatch: (err, { procedure }) => logger.error({ procedure: procedure.name, err }),
199
+ },
200
+ }).register(API, /* ... */).build()
201
+ ```
202
+
203
+ The identical `errors` + `unknownError` shape plugs into `HonoRPCAppBuilder`, `ExpressRPCAppBuilder`, and `HonoStreamAppBuilder` (pre-stream only — mid-stream uses `onMidStreamError`).
204
+
205
+ ### Imperative — the `onError` callback
206
+
207
+ For apps that don't need typed client dispatch or declarative docs, configure `onError` directly and handle every error in one place:
208
+
209
+ ```typescript
210
+ new HonoAPIAppBuilder({
211
+ onError: (procedure, c, error) => {
212
+ logger.error(`[${procedure.name}]`, error)
213
+ return c.json({ error: error.message ?? 'unknown error' }, 500)
214
+ },
215
+ }).register(API, /* ... */).build()
216
+ ```
217
+
218
+ (Need to route different error classes to different status codes? Use the taxonomy — that's exactly what it's designed for. Hand-writing `instanceof` ladders inside `onError` is [anti-pattern #20](../agent_config/claude-code/skills/ts-procedures/anti-patterns.md).)
219
+
220
+ Signatures (differ by framework):
221
+
222
+ | Builder | `onError` signature |
223
+ |---|---|
224
+ | `HonoAPIAppBuilder`, `HonoRPCAppBuilder` | `(procedure, c: Context, error) => Response \| Promise<Response>` |
225
+ | `ExpressRPCAppBuilder` | `(procedure, req, res, error) => void` (write to `res`) |
226
+ | `HonoStreamAppBuilder` | `(procedure, c: Context, error) => Response \| Promise<Response>` — pre-stream only |
227
+
228
+ Picking between modes isn't irreversible: you can start with `onError`, migrate chunks to the taxonomy as your error model stabilizes, and leave the rest in `onError`. Both modes coexist in the dispatch order below.
229
+
230
+ ### Per-route error narrowing (taxonomy mode)
231
+
232
+ `APIConfig` and `RPCConfig` are generic over a `TErrorKey extends string` parameter so you can declare which errors a specific route may emit:
233
+
234
+ ```typescript
235
+ import type { APIConfig } from 'ts-procedures/http'
236
+
237
+ type MyAPIConfig = APIConfig<keyof typeof appErrors & string>
238
+ const API = Procedures<Ctx, MyAPIConfig>()
239
+
240
+ API.Create('GetUser', {
241
+ path: '/users/:id',
242
+ method: 'get',
243
+ errors: ['UseCaseError'], // typo-checked against the taxonomy keys
244
+ schema: { /* ... */ },
245
+ }, /* handler */)
246
+ ```
247
+
248
+ These per-route declarations flow into the DocEnvelope and drive typed `catch` blocks on generated clients — see [Client & Codegen → Typed Error Handling](./client-and-codegen.md#typed-error-handling).
249
+
250
+ ### Cross-cutting observability — `onRequestError`
251
+
252
+ `onRequestError` fires for every caught error, **before** dispatch. It's an observer — it can't mutate the response. Use it for APM, distributed tracing, custom logging, or metrics where you want one hook that sees every error regardless of which mode dispatched it.
253
+
254
+ ```typescript
255
+ new HonoAPIAppBuilder({
256
+ errors: appErrors,
257
+ onRequestError: async ({ err, procedure, raw }) => {
258
+ sentry.captureException(err, { procedure: procedure.name })
259
+ },
260
+ })
261
+ ```
262
+
263
+ The observer is awaited before the response is sent, and any error it throws is swallowed (logged to the console) so a broken instrumentation hook can't corrupt the primary flow.
264
+
265
+ ### Dispatch order inside each builder's catch block
266
+
267
+ 1. **`onRequestError`** (observer) — awaited, can't alter dispatch or response
268
+ 2. **`errors` taxonomy** — user entries checked, then framework defaults, then `unknownError`
269
+ 3. **`onError` callback** — imperative handler receives anything the taxonomy didn't match
270
+ 4. **Hard default** — `{ error: message }` at status 500 (produced only when nothing above handled the error)
271
+
272
+ Configuring only the taxonomy, only `onError`, both, or neither are all valid. When neither is configured the builder goes straight from step 1 to step 4.
273
+
151
274
  ## DocRegistry — Composing Docs from Multiple Builders
152
275
 
153
276
  Use `DocRegistry` to compose route documentation from any combination of HTTP builders into a typed envelope:
@@ -169,6 +292,14 @@ app.get('/docs', (c) => c.json(docs.toJSON()))
169
292
 
170
293
  `from()` stores a reference — routes are read lazily at `toJSON()` time, so builders can be registered before or after `.build()`. Supports optional `filter` and `transform` options for customizing output.
171
294
 
295
+ `DocRegistry.fromTaxonomy(taxonomy, config?)` is a convenience constructor that seeds `envelope.errors` from your taxonomy plus framework defaults in one call (deduped — your entries win when keys overlap):
296
+
297
+ ```typescript
298
+ const docs = DocRegistry.fromTaxonomy(appErrors, { basePath: '/api' })
299
+ .from(rpcBuilder)
300
+ .from(apiBuilder)
301
+ ```
302
+
172
303
  The `DocRegistry` output is the input for [Client Code Generation](./client-and-codegen.md).
173
304
 
174
305
  ## Type Exports
package/docs/streaming.md CHANGED
@@ -168,7 +168,9 @@ For the built-in Hono streaming integration, see the [Hono Stream README](../src
168
168
 
169
169
  ## Stream Errors
170
170
 
171
- Streaming procedures support the same error handling as regular procedures (see [Error Handling](./core.md#error-handling)):
171
+ Streaming procedures support the same error handling as regular procedures (see [Error Handling](./core.md#error-handling)).
172
+
173
+ For HTTP stream endpoints, pre-stream errors (validation, context resolution, anything thrown before the first yield) go through the same two peer error-handling modes as every other HTTP builder — either the declarative `errors` / `unknownError` taxonomy (from `defineErrorTaxonomy`) or the imperative `onError` callback on `HonoStreamAppBuilder`. Cross-cutting `onRequestError` observer fires for every pre-stream error. See [HTTP Integrations → Error Handling](./http-integrations.md#error-handling) for the full contract. Mid-stream errors (thrown after the first yield) still flow through `onMidStreamError` and are surfaced to the client as SSE error events — the HTTP status is already committed once streaming begins, so mid-stream is outside the peer error modes.
172
174
 
173
175
  ```typescript
174
176
  const { StreamWithErrors } = CreateStream(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-procedures",
3
- "version": "5.15.0",
3
+ "version": "6.0.0",
4
4
  "description": "A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.",
5
5
  "main": "build/exports.js",
6
6
  "types": "build/exports.d.ts",
@@ -12,7 +12,8 @@
12
12
  "scripts": {
13
13
  "build": "tsc",
14
14
  "lint": "npx eslint src/ --quiet",
15
- "prepublishOnly": "npm run lint && npm run build",
15
+ "check-docs": "bash scripts/check-docs-consistency.sh",
16
+ "prepublishOnly": "npm run lint && npm run build && npm run check-docs",
16
17
  "postinstall": "node ./agent_config/bin/postinstall.mjs",
17
18
  "test": "vitest run"
18
19
  },
@@ -45,6 +46,10 @@
45
46
  "types": "./build/implementations/http/doc-registry.d.ts",
46
47
  "import": "./build/implementations/http/doc-registry.js"
47
48
  },
49
+ "./http-errors": {
50
+ "types": "./build/implementations/http/error-taxonomy.d.ts",
51
+ "import": "./build/implementations/http/error-taxonomy.js"
52
+ },
48
53
  "./client": {
49
54
  "types": "./build/client/index.d.ts",
50
55
  "import": "./build/client/index.js"
@@ -7,6 +7,8 @@ import type {
7
7
  AdapterResponse,
8
8
  ClientHooks,
9
9
  CallDescriptor,
10
+ ProcedureCallDefaults,
11
+ ProcedureCallOptions,
10
12
  } from './types.js'
11
13
 
12
14
  // ── helpers ───────────────────────────────────────────────
@@ -37,43 +39,55 @@ function makeAdapter(response?: Partial<AdapterResponse>): ClientAdapter {
37
39
  }
38
40
  }
39
41
 
42
+ interface RunConfig {
43
+ adapter: ClientAdapter
44
+ hooks?: ClientHooks
45
+ defaults?: ProcedureCallDefaults
46
+ options?: ProcedureCallOptions
47
+ descriptor?: CallDescriptor
48
+ basePath?: string
49
+ }
50
+
51
+ function run<T>({
52
+ adapter,
53
+ hooks = {},
54
+ defaults,
55
+ options,
56
+ descriptor = makeDescriptor(),
57
+ basePath = 'https://api.example.com',
58
+ }: RunConfig): Promise<T> {
59
+ return executeCall<T>({ descriptor, basePath, adapter, hooks, defaults, options })
60
+ }
61
+
40
62
  // ── executeCall ───────────────────────────────────────────
41
63
 
42
64
  describe('executeCall', () => {
43
65
  it('calls adapter.request and returns body', async () => {
44
66
  const adapter = makeAdapter({ body: { id: '1', name: 'Bob' } })
45
- const result = await executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
67
+ const result = await run({ adapter })
46
68
  expect(adapter.request).toHaveBeenCalledOnce()
47
69
  expect(result).toEqual({ id: '1', name: 'Bob' })
48
70
  })
49
71
 
50
72
  it('throws ClientRequestError on 4xx response', async () => {
51
73
  const adapter = makeAdapter({ status: 404, body: { message: 'Not Found' } })
52
- await expect(
53
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
54
- ).rejects.toThrow(ClientRequestError)
74
+ await expect(run({ adapter })).rejects.toThrow(ClientRequestError)
55
75
  })
56
76
 
57
77
  it('throws ClientRequestError on 5xx response', async () => {
58
78
  const adapter = makeAdapter({ status: 500, body: { message: 'Server Error' } })
59
- await expect(
60
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
61
- ).rejects.toThrow(ClientRequestError)
79
+ await expect(run({ adapter })).rejects.toThrow(ClientRequestError)
62
80
  })
63
81
 
64
82
  it('throws ClientRequestError on 199 response (below 200)', async () => {
65
83
  const adapter = makeAdapter({ status: 199, body: null })
66
- await expect(
67
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
68
- ).rejects.toThrow(ClientRequestError)
84
+ await expect(run({ adapter })).rejects.toThrow(ClientRequestError)
69
85
  })
70
86
 
71
87
  it('does not throw on 2xx boundary responses (200, 201, 299)', async () => {
72
88
  for (const status of [200, 201, 204, 299]) {
73
89
  const adapter = makeAdapter({ status, body: null })
74
- await expect(
75
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
76
- ).resolves.not.toThrow()
90
+ await expect(run({ adapter })).resolves.not.toThrow()
77
91
  }
78
92
  })
79
93
 
@@ -87,7 +101,7 @@ describe('executeCall', () => {
87
101
  stream: vi.fn(async () => { throw new Error('not expected') }),
88
102
  }
89
103
 
90
- const globalHooks: ClientHooks = {
104
+ const hooks: ClientHooks = {
91
105
  onBeforeRequest: (ctx) => ({
92
106
  ...ctx,
93
107
  request: {
@@ -97,7 +111,7 @@ describe('executeCall', () => {
97
111
  }),
98
112
  }
99
113
 
100
- await executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
114
+ await run({ adapter, hooks })
101
115
  expect(capturedHeaders[0]?.['x-auth']).toBe('token-123')
102
116
  })
103
117
 
@@ -110,26 +124,23 @@ describe('executeCall', () => {
110
124
  }),
111
125
  stream: vi.fn(async () => { throw new Error('not expected') }),
112
126
  }
113
- const globalHooks: ClientHooks = {
127
+ const hooks: ClientHooks = {
114
128
  onAfterResponse: () => { order.push('afterResponse') },
115
129
  }
116
130
 
117
- await executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
131
+ await run({ adapter, hooks })
118
132
  expect(order).toEqual(['adapter', 'afterResponse'])
119
133
  })
120
134
 
121
135
  it('does not throw when onAfterResponse swallows non-2xx by mutating status', async () => {
122
136
  const adapter = makeAdapter({ status: 401, body: { message: 'Unauthorized' } })
123
- const globalHooks: ClientHooks = {
137
+ const hooks: ClientHooks = {
124
138
  onAfterResponse: (ctx) => {
125
- // Swallow the error by setting status to 200
126
139
  ctx.response.status = 200
127
140
  },
128
141
  }
129
142
 
130
- await expect(
131
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
132
- ).resolves.not.toThrow()
143
+ await expect(run({ adapter, hooks })).resolves.not.toThrow()
133
144
  })
134
145
 
135
146
  it('runs onError on adapter failure and re-throws', async () => {
@@ -139,24 +150,186 @@ describe('executeCall', () => {
139
150
  stream: vi.fn(async () => { throw new Error('not expected') }),
140
151
  }
141
152
  const receivedErrors: unknown[] = []
142
- const globalHooks: ClientHooks = {
153
+ const hooks: ClientHooks = {
143
154
  onError: (ctx) => { receivedErrors.push(ctx.error) },
144
155
  }
145
156
 
146
- await expect(
147
- executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
148
- ).rejects.toThrow('Network failure')
157
+ await expect(run({ adapter, hooks })).rejects.toThrow('Network failure')
149
158
  expect(receivedErrors[0]).toBe(adapterError)
150
159
  })
151
160
 
152
- it('passes per-procedure hooks as local hooks', async () => {
161
+ it('passes per-procedure hooks as local options', async () => {
153
162
  const adapter = makeAdapter()
154
163
  const localOrder: string[] = []
155
- const localHooks: ClientHooks = {
164
+ const options: ProcedureCallOptions = {
156
165
  onBeforeRequest: (ctx) => { localOrder.push('local-before'); return ctx },
157
166
  }
158
167
 
159
- await executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, localHooks)
168
+ await run({ adapter, options })
160
169
  expect(localOrder).toContain('local-before')
161
170
  })
171
+
172
+ // ── Per-call options: signal / timeout / headers / basePath / meta ──
173
+
174
+ it('per-call timeout attaches a signal via AbortSignal.timeout', async () => {
175
+ const spy = vi.spyOn(AbortSignal, 'timeout')
176
+ try {
177
+ let observedSignal: AbortSignal | undefined
178
+ const adapter: ClientAdapter = {
179
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
180
+ observedSignal = req.signal
181
+ return { status: 200, headers: {}, body: {} }
182
+ }),
183
+ stream: vi.fn(async () => { throw new Error('not expected') }),
184
+ }
185
+
186
+ await run({ adapter, options: { timeout: 5000 } })
187
+ expect(spy).toHaveBeenCalledWith(5000)
188
+ expect(observedSignal).toBeDefined()
189
+ } finally {
190
+ spy.mockRestore()
191
+ }
192
+ })
193
+
194
+ it('adapter receives a signal that reflects abort when the caller cancels', async () => {
195
+ const controller = new AbortController()
196
+ const adapter: ClientAdapter = {
197
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
198
+ return new Promise((_resolve, reject) => {
199
+ const abort = () => reject(new Error('aborted'))
200
+ if (req.signal?.aborted) abort()
201
+ else req.signal?.addEventListener('abort', abort, { once: true })
202
+ })
203
+ }),
204
+ stream: vi.fn(async () => { throw new Error('not expected') }),
205
+ }
206
+
207
+ const promise = run({ adapter, options: { signal: controller.signal } })
208
+ // Let executeCall reach the adapter before aborting
209
+ await Promise.resolve()
210
+ controller.abort()
211
+ await expect(promise).rejects.toThrow('aborted')
212
+ })
213
+
214
+ it('per-call signal is forwarded to the adapter', async () => {
215
+ const controller = new AbortController()
216
+ let observedSignal: AbortSignal | undefined
217
+ const adapter: ClientAdapter = {
218
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
219
+ observedSignal = req.signal
220
+ return { status: 200, headers: {}, body: {} }
221
+ }),
222
+ stream: vi.fn(async () => { throw new Error('not expected') }),
223
+ }
224
+
225
+ await run({ adapter, options: { signal: controller.signal } })
226
+ expect(observedSignal).toBe(controller.signal)
227
+ })
228
+
229
+ it('per-call headers are merged into the request before hooks', async () => {
230
+ const capturedHeaders: Record<string, string>[] = []
231
+ const seenByHook: Record<string, string>[] = []
232
+ const adapter: ClientAdapter = {
233
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
234
+ capturedHeaders.push(req.headers ?? {})
235
+ return { status: 200, headers: {}, body: {} }
236
+ }),
237
+ stream: vi.fn(async () => { throw new Error('not expected') }),
238
+ }
239
+
240
+ const hooks: ClientHooks = {
241
+ onBeforeRequest: (ctx) => {
242
+ seenByHook.push({ ...ctx.request.headers })
243
+ return ctx
244
+ },
245
+ }
246
+
247
+ await run({ adapter, hooks, options: { headers: { 'x-request-id': 'req-123' } } })
248
+ expect(seenByHook[0]?.['x-request-id']).toBe('req-123')
249
+ expect(capturedHeaders[0]?.['x-request-id']).toBe('req-123')
250
+ })
251
+
252
+ it('per-call basePath overrides the client base path', async () => {
253
+ const capturedUrls: string[] = []
254
+ const adapter: ClientAdapter = {
255
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
256
+ capturedUrls.push(req.url)
257
+ return { status: 200, headers: {}, body: {} }
258
+ }),
259
+ stream: vi.fn(async () => { throw new Error('not expected') }),
260
+ }
261
+
262
+ await run({
263
+ adapter,
264
+ descriptor: makeDescriptor({ path: '/users' }),
265
+ basePath: 'https://default.example.com',
266
+ options: { basePath: 'https://override.example.com' },
267
+ })
268
+ expect(capturedUrls[0]).toBe('https://override.example.com/users')
269
+ })
270
+
271
+ it('global defaults apply when no per-call options', async () => {
272
+ const capturedHeaders: Record<string, string>[] = []
273
+ const capturedUrls: string[] = []
274
+ const adapter: ClientAdapter = {
275
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
276
+ capturedHeaders.push(req.headers ?? {})
277
+ capturedUrls.push(req.url)
278
+ return { status: 200, headers: {}, body: {} }
279
+ }),
280
+ stream: vi.fn(async () => { throw new Error('not expected') }),
281
+ }
282
+
283
+ await run({
284
+ adapter,
285
+ descriptor: makeDescriptor({ path: '/users' }),
286
+ basePath: 'https://default.example.com',
287
+ defaults: {
288
+ headers: { 'x-client-version': '1.0' },
289
+ basePath: 'https://region-us.example.com',
290
+ },
291
+ })
292
+ expect(capturedHeaders[0]?.['x-client-version']).toBe('1.0')
293
+ expect(capturedUrls[0]).toBe('https://region-us.example.com/users')
294
+ })
295
+
296
+ it('onBeforeRequest can still override resolved signal/headers', async () => {
297
+ let observedSignal: AbortSignal | undefined
298
+ const hookController = new AbortController()
299
+ const adapter: ClientAdapter = {
300
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
301
+ observedSignal = req.signal
302
+ return { status: 200, headers: {}, body: {} }
303
+ }),
304
+ stream: vi.fn(async () => { throw new Error('not expected') }),
305
+ }
306
+
307
+ const hooks: ClientHooks = {
308
+ onBeforeRequest: (ctx) => ({
309
+ ...ctx,
310
+ request: { ...ctx.request, signal: hookController.signal },
311
+ }),
312
+ }
313
+
314
+ await run({ adapter, hooks, options: { timeout: 10_000 } })
315
+ expect(observedSignal).toBe(hookController.signal)
316
+ })
317
+
318
+ it('merges default + per-call meta; per-call keys win', async () => {
319
+ let observedMeta: unknown
320
+ const adapter: ClientAdapter = {
321
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
322
+ observedMeta = req.meta
323
+ return { status: 200, headers: {}, body: {} }
324
+ }),
325
+ stream: vi.fn(async () => { throw new Error('not expected') }),
326
+ }
327
+
328
+ await run({
329
+ adapter,
330
+ defaults: { meta: { traceId: 'default-trace', attempt: 1 } as never },
331
+ options: { meta: { traceId: 'override' } as never },
332
+ })
333
+ expect(observedMeta).toEqual({ traceId: 'override', attempt: 1 })
334
+ })
162
335
  })
@@ -1,65 +1,87 @@
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'
5
+ import { dispatchTypedError } from './error-dispatch.js'
4
6
  import type {
5
7
  ClientAdapter,
6
8
  ClientHooks,
7
9
  CallDescriptor,
10
+ ErrorRegistry,
11
+ ProcedureCallDefaults,
12
+ ProcedureCallOptions,
8
13
  } from './types.js'
9
14
 
15
+ export interface ExecuteCallConfig {
16
+ descriptor: CallDescriptor
17
+ basePath: string
18
+ adapter: ClientAdapter
19
+ hooks: ClientHooks
20
+ defaults?: ProcedureCallDefaults
21
+ options?: ProcedureCallOptions
22
+ errorRegistry?: ErrorRegistry
23
+ }
24
+
10
25
  /**
11
26
  * Executes a single procedure call through the adapter.
12
27
  *
13
28
  * 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
29
+ * 1. Resolve base path (per-call > defaults > config) and build AdapterRequest
30
+ * 2. Apply request options (headers, signal, timeout, meta) from defaults + per-call
31
+ * 3. Run onBeforeRequest hooks (global then local) — may further mutate request
32
+ * 4. Call adapter.request()
33
+ * 5. On adapter error: run onError hooks, re-throw
34
+ * 6. Run onAfterResponse hooks (may mutate response.status to swallow errors)
35
+ * 7. If response status is non-2xx: throw ClientRequestError
36
+ * 8. Return response.body as TResponse
21
37
  */
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)
38
+ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise<TResponse> {
39
+ const { descriptor, basePath, adapter, hooks, defaults, options, errorRegistry } = config
40
+
41
+ // 1. Build the initial request (path/query/body from descriptor)
42
+ const resolvedBasePath = resolveBasePath(defaults, options, basePath)
43
+ let request = buildAdapterRequest(descriptor, resolvedBasePath)
44
+
45
+ // 2. Apply request-level options (headers, signal, timeout, meta)
46
+ request = applyRequestOptions(request, defaults, options)
31
47
 
32
- // 2. Run before-request hooks — they may mutate the request
48
+ // 3. Run before-request hooks — they may further mutate the request
33
49
  const beforeCtx = await runBeforeRequest(
34
50
  { procedureName: descriptor.name, scope: descriptor.scope, request },
35
- globalHooks,
36
- localHooks
51
+ hooks,
52
+ options,
37
53
  )
38
54
  request = beforeCtx.request
39
55
 
40
- // 3. Call the adapter
56
+ // 4. Call the adapter
41
57
  let response
42
58
  try {
43
59
  response = await adapter.request(request)
44
60
  } catch (err) {
45
- // 4. On adapter error: run error hooks, re-throw
61
+ // 5. On adapter error: run error hooks, re-throw
46
62
  await runOnError(
47
63
  { procedureName: descriptor.name, scope: descriptor.scope, request, error: err },
48
- globalHooks,
49
- localHooks
64
+ hooks,
65
+ options,
50
66
  )
51
67
  throw err
52
68
  }
53
69
 
54
- // 5. Run after-response hooks — they may mutate response.status to swallow errors
70
+ // 6. Run after-response hooks — they may mutate response.status to swallow errors
55
71
  await runAfterResponse(
56
72
  { procedureName: descriptor.name, scope: descriptor.scope, request, response },
57
- globalHooks,
58
- localHooks
73
+ hooks,
74
+ options,
59
75
  )
60
76
 
61
- // 6. Check status AFTER hooks (hooks may have swallowed the error by mutating status)
77
+ // 7. Check status AFTER hooks (hooks may have swallowed the error by mutating status)
62
78
  if (response.status < 200 || response.status >= 300) {
79
+ const typed = dispatchTypedError(errorRegistry, response.body, {
80
+ status: response.status,
81
+ procedureName: descriptor.name,
82
+ scope: descriptor.scope,
83
+ })
84
+ if (typed) throw typed
63
85
  throw new ClientRequestError({
64
86
  status: response.status,
65
87
  headers: response.headers,
@@ -69,6 +91,6 @@ export async function executeCall<TResponse>(
69
91
  })
70
92
  }
71
93
 
72
- // 7. Return the body
94
+ // 8. Return the body
73
95
  return response.body as TResponse
74
96
  }