ts-procedures 6.2.0 → 7.0.0-beta.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 (109) hide show
  1. package/README.md +2 -0
  2. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -1
  3. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +38 -1
  4. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +215 -3
  5. package/agent_config/claude-code/skills/ts-procedures/patterns.md +60 -2
  6. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +1 -1
  7. package/agent_config/copilot/copilot-instructions.md +3 -0
  8. package/agent_config/cursor/cursorrules +3 -0
  9. package/build/client/augment-error-map.test-d.d.ts +10 -0
  10. package/build/client/augment-error-map.test-d.js +14 -0
  11. package/build/client/augment-error-map.test-d.js.map +1 -0
  12. package/build/client/call.d.ts +14 -2
  13. package/build/client/call.js +96 -9
  14. package/build/client/call.js.map +1 -1
  15. package/build/client/call.test.js +50 -1
  16. package/build/client/call.test.js.map +1 -1
  17. package/build/client/classify-error.d.ts +11 -0
  18. package/build/client/classify-error.js +49 -0
  19. package/build/client/classify-error.js.map +1 -0
  20. package/build/client/classify-error.test.d.ts +1 -0
  21. package/build/client/classify-error.test.js +55 -0
  22. package/build/client/classify-error.test.js.map +1 -0
  23. package/build/client/error-dispatch.d.ts +1 -1
  24. package/build/client/error-dispatch.js +1 -1
  25. package/build/client/errors.d.ts +55 -4
  26. package/build/client/errors.js +54 -7
  27. package/build/client/errors.js.map +1 -1
  28. package/build/client/errors.test.js +89 -4
  29. package/build/client/errors.test.js.map +1 -1
  30. package/build/client/fetch-adapter.d.ts +2 -1
  31. package/build/client/fetch-adapter.js +2 -1
  32. package/build/client/fetch-adapter.js.map +1 -1
  33. package/build/client/fetch-adapter.test.js +12 -0
  34. package/build/client/fetch-adapter.test.js.map +1 -1
  35. package/build/client/index.d.ts +5 -3
  36. package/build/client/index.js +15 -3
  37. package/build/client/index.js.map +1 -1
  38. package/build/client/resolve-options.d.ts +32 -1
  39. package/build/client/resolve-options.js +32 -16
  40. package/build/client/resolve-options.js.map +1 -1
  41. package/build/client/resolve-options.test.js +67 -6
  42. package/build/client/resolve-options.test.js.map +1 -1
  43. package/build/client/result-type.test-d.d.ts +1 -0
  44. package/build/client/result-type.test-d.js +28 -0
  45. package/build/client/result-type.test-d.js.map +1 -0
  46. package/build/client/safe-call.test.d.ts +1 -0
  47. package/build/client/safe-call.test.js +137 -0
  48. package/build/client/safe-call.test.js.map +1 -0
  49. package/build/client/stream.d.ts +1 -1
  50. package/build/client/stream.js +22 -8
  51. package/build/client/stream.js.map +1 -1
  52. package/build/client/stream.test.js +11 -1
  53. package/build/client/stream.test.js.map +1 -1
  54. package/build/client/types.d.ts +96 -3
  55. package/build/codegen/bundle-size.test.d.ts +1 -0
  56. package/build/codegen/bundle-size.test.js +68 -0
  57. package/build/codegen/bundle-size.test.js.map +1 -0
  58. package/build/codegen/e2e.test.js +103 -1
  59. package/build/codegen/e2e.test.js.map +1 -1
  60. package/build/codegen/emit-client-runtime.js +7 -0
  61. package/build/codegen/emit-client-runtime.js.map +1 -1
  62. package/build/codegen/emit-client-runtime.test.js +6 -2
  63. package/build/codegen/emit-client-runtime.test.js.map +1 -1
  64. package/build/codegen/emit-client-types.d.ts +7 -2
  65. package/build/codegen/emit-client-types.js +29 -8
  66. package/build/codegen/emit-client-types.js.map +1 -1
  67. package/build/codegen/emit-client-types.test.js +20 -8
  68. package/build/codegen/emit-client-types.test.js.map +1 -1
  69. package/build/codegen/emit-errors.d.ts +1 -1
  70. package/build/codegen/emit-errors.js +1 -1
  71. package/build/codegen/emit-index.js +1 -1
  72. package/build/codegen/emit-index.js.map +1 -1
  73. package/build/codegen/emit-scope.js +94 -26
  74. package/build/codegen/emit-scope.js.map +1 -1
  75. package/build/codegen/emit-scope.test.js +297 -2
  76. package/build/codegen/emit-scope.test.js.map +1 -1
  77. package/docs/client-and-codegen.md +77 -7
  78. package/docs/client-error-handling.md +357 -0
  79. package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
  80. package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
  81. package/package.json +1 -1
  82. package/src/client/augment-error-map.test-d.ts +22 -0
  83. package/src/client/call.test.ts +65 -1
  84. package/src/client/call.ts +111 -9
  85. package/src/client/classify-error.test.ts +65 -0
  86. package/src/client/classify-error.ts +59 -0
  87. package/src/client/error-dispatch.ts +1 -1
  88. package/src/client/errors.test.ts +108 -4
  89. package/src/client/errors.ts +70 -7
  90. package/src/client/fetch-adapter.test.ts +15 -0
  91. package/src/client/fetch-adapter.ts +5 -2
  92. package/src/client/index.ts +39 -3
  93. package/src/client/resolve-options.test.ts +83 -5
  94. package/src/client/resolve-options.ts +61 -16
  95. package/src/client/result-type.test-d.ts +51 -0
  96. package/src/client/safe-call.test.ts +157 -0
  97. package/src/client/stream.test.ts +13 -1
  98. package/src/client/stream.ts +25 -8
  99. package/src/client/types.ts +112 -3
  100. package/src/codegen/bundle-size.test.ts +74 -0
  101. package/src/codegen/e2e.test.ts +108 -1
  102. package/src/codegen/emit-client-runtime.test.ts +7 -2
  103. package/src/codegen/emit-client-runtime.ts +7 -0
  104. package/src/codegen/emit-client-types.test.ts +22 -7
  105. package/src/codegen/emit-client-types.ts +35 -10
  106. package/src/codegen/emit-errors.ts +1 -1
  107. package/src/codegen/emit-index.ts +1 -1
  108. package/src/codegen/emit-scope.test.ts +324 -2
  109. package/src/codegen/emit-scope.ts +98 -36
@@ -0,0 +1,2293 @@
1
+ # Safe Result API & Normalized Client Errors — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add a `.safe()` sibling form to every generated RPC/API callable that returns a discriminated `Result<T, E>` — and back it with a classifier that normalizes platform errors (`TypeError`, `DOMException`) into framework error classes. Make the `kind` set extensible via `ClientErrorMap` interface augmentation, mirroring the existing `RequestMeta` pattern. Throwing form remains canonical; `.safe()` is opt-in.
6
+
7
+ **Architecture:** Three failure-source paths in `executeCall` (pre-adapter usage error, adapter throw → classifier, adapter non-2xx → registry-or-fallback) each map to exactly one `Result.kind`. Default classifier is composed with optional adapter-provided `classifyError`. Codegen emits `.safe` as a sibling property on each callable (RPC/API only, streams excluded), branching between `Result<T, RouteErrors>` and `ResultNoTyped<T>` based on whether `route.errors` is declared.
8
+
9
+ **Tech Stack:** TypeScript, Vitest, existing `ts-procedures` codegen pipeline. No new runtime dependencies.
10
+
11
+ **Spec:** [docs/superpowers/specs/2026-04-29-safe-result-api-design.md](../specs/2026-04-29-safe-result-api-design.md)
12
+
13
+ **Branch:** Recommend a fresh worktree off `master` named `safe-result-api`. This is a major version (7.0.0) so changes will not merge back into a minor.
14
+
15
+ ---
16
+
17
+ ## File map
18
+
19
+ **Create:**
20
+ - `src/client/classify-error.ts` — default classifier + `ErrorClassifier` type
21
+ - `src/client/classify-error.test.ts`
22
+ - `src/client/safe-call.test.ts` — covers `executeSafeCall` and `ClientInstance.safeCall`
23
+ - `src/client/augment-error-map.test-d.ts` — type-level test verifying augmentation widens `Result`
24
+ - `src/codegen/bundle-size.test.ts` — regression guard on per-route byte cost
25
+ - `docs/client-error-handling.md` — consumer guide
26
+
27
+ **Modify:**
28
+ - `src/client/errors.ts` — rename `ClientRequestError` → `ClientHttpError`; add `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`, `ClientParseError`; add `cause` constructor contract
29
+ - `src/client/types.ts` — add `ClientErrorMap`, `Result`, `ResultNoTyped`, `FrameworkFailure`; extend `ClientAdapter` with optional `classifyError`; extend `ClientInstance` with `safeCall`
30
+ - `src/client/resolve-options.ts` — return signal sources alongside the merged request so classifier can recover provenance
31
+ - `src/client/call.ts` — wrap adapter call in classifier; add `executeSafeCall`
32
+ - `src/client/stream.ts` — wrap pre-stream adapter call in classifier
33
+ - `src/client/fetch-adapter.ts` — accept `classifyError` in config (composes with default)
34
+ - `src/client/index.ts` — export new classes, classifier, `Result`, `ResultNoTyped`, `ClientErrorMap`, `defaultClassifyError`
35
+ - `src/client/call.test.ts`, `src/client/stream.test.ts`, `src/client/fetch-adapter.test.ts`, `src/client/errors.test.ts`, `src/client/index.test.ts` — update for new shapes
36
+ - `src/codegen/emit-scope.ts` — emit `.safe` sibling for RPC/API; branch between `Result` and `ResultNoTyped`
37
+ - `src/codegen/emit-scope.test.ts` — extend coverage
38
+ - `src/codegen/__fixtures__/users-envelope.json` (review for any envelope augmentation needed for the bundle-size test) — likely unchanged
39
+ - `src/codegen/__fixtures__/users-golden.ts` (or per-target equivalent) — refresh with `.safe` siblings
40
+ - `CLAUDE.md` — add normalized error taxonomy + `.safe` form notes
41
+ - `agent_config/claude-code/skills/ts-procedures/patterns.md`, `anti-patterns.md` — error-handling patterns
42
+ - `agent_config/copilot/copilot-instructions.md`, `agent_config/cursor/cursorrules` — condensed equivalents
43
+ - `package.json` — bump to `7.0.0`
44
+ - `CHANGELOG.md` (or release notes file) — major bump entry
45
+
46
+ ---
47
+
48
+ ## Phase 1 — Error class taxonomy + cause contract
49
+
50
+ ### Task 1: Add `cause` constructor contract to existing error classes
51
+
52
+ **Files:**
53
+ - Modify: `src/client/errors.ts`
54
+ - Test: `src/client/errors.test.ts`
55
+
56
+ - [ ] **Step 1: Read the existing test file to understand convention**
57
+
58
+ ```bash
59
+ cat src/client/errors.test.ts
60
+ ```
61
+
62
+ - [ ] **Step 2: Write failing test for cause on existing classes**
63
+
64
+ Add to `src/client/errors.test.ts`:
65
+
66
+ ```ts
67
+ import { describe, it, expect } from 'vitest'
68
+ import { ClientPathParamError, ClientStreamError } from './errors.js'
69
+
70
+ describe('ClientPathParamError cause', () => {
71
+ it('accepts and stores cause', () => {
72
+ const cause = new TypeError('underlying')
73
+ const err = new ClientPathParamError('id', '/u/:id', 'GetUser', cause)
74
+ expect(err.cause).toBe(cause)
75
+ })
76
+ })
77
+
78
+ describe('ClientStreamError cause', () => {
79
+ it('accepts and stores cause', () => {
80
+ const cause = new Error('underlying')
81
+ const err = new ClientStreamError('boom', 'StreamUsers', 'users', cause)
82
+ expect(err.cause).toBe(cause)
83
+ })
84
+ })
85
+ ```
86
+
87
+ - [ ] **Step 3: Run tests to verify they fail**
88
+
89
+ ```bash
90
+ npx vitest run src/client/errors.test.ts -t 'cause'
91
+ ```
92
+ Expected: FAIL — constructor doesn't accept `cause`.
93
+
94
+ - [ ] **Step 4: Update `ClientPathParamError` and `ClientStreamError` constructors**
95
+
96
+ Edit `src/client/errors.ts`:
97
+
98
+ ```ts
99
+ export class ClientPathParamError extends Error {
100
+ readonly name = 'ClientPathParamError'
101
+
102
+ constructor(param: string, path: string, procedureName: string, cause?: unknown) {
103
+ super(`Missing path parameter "${param}" in "${path}" for procedure ${procedureName}`, { cause })
104
+ }
105
+ }
106
+
107
+ export class ClientStreamError extends Error {
108
+ readonly name = 'ClientStreamError'
109
+ readonly procedureName: string
110
+ readonly scope: string
111
+
112
+ constructor(message: string, procedureName: string, scope: string, cause?: unknown) {
113
+ super(message, { cause })
114
+ this.procedureName = procedureName
115
+ this.scope = scope
116
+ }
117
+ }
118
+ ```
119
+
120
+ - [ ] **Step 5: Run tests to verify pass**
121
+
122
+ ```bash
123
+ npx vitest run src/client/errors.test.ts
124
+ ```
125
+ Expected: PASS.
126
+
127
+ - [ ] **Step 6: Commit**
128
+
129
+ ```bash
130
+ git add src/client/errors.ts src/client/errors.test.ts
131
+ git commit -m "feat(client/errors): add cause arg to existing error classes"
132
+ ```
133
+
134
+ ### Task 2: Rename `ClientRequestError` → `ClientHttpError` with deprecated alias
135
+
136
+ **Files:**
137
+ - Modify: `src/client/errors.ts`
138
+ - Modify: `src/client/index.ts`
139
+ - Modify: `src/client/call.ts`, `src/client/stream.ts`, `src/client/error-dispatch.ts` (any other in-tree references)
140
+ - Test: `src/client/errors.test.ts`
141
+
142
+ - [ ] **Step 1: Find every in-tree reference to `ClientRequestError`**
143
+
144
+ ```bash
145
+ grep -rn 'ClientRequestError' src/ --include='*.ts'
146
+ ```
147
+ Note the file:line list — every one needs to point at `ClientHttpError` after the rename.
148
+
149
+ - [ ] **Step 2: Write failing test for the new name + alias**
150
+
151
+ Add to `src/client/errors.test.ts`:
152
+
153
+ ```ts
154
+ import { ClientHttpError, ClientRequestError } from './errors.js'
155
+
156
+ describe('ClientHttpError', () => {
157
+ it('exposes status, headers, body, procedureName, scope', () => {
158
+ const err = new ClientHttpError({
159
+ status: 500, headers: { 'x-trace': 'abc' }, body: { error: 'boom' },
160
+ procedureName: 'GetUser', scope: 'users',
161
+ })
162
+ expect(err.status).toBe(500)
163
+ expect(err.headers['x-trace']).toBe('abc')
164
+ expect(err.body).toEqual({ error: 'boom' })
165
+ expect(err.procedureName).toBe('GetUser')
166
+ expect(err.scope).toBe('users')
167
+ expect(err.name).toBe('ClientHttpError')
168
+ })
169
+
170
+ it('accepts cause', () => {
171
+ const cause = new Error('underlying')
172
+ const err = new ClientHttpError({
173
+ status: 500, headers: {}, body: null,
174
+ procedureName: 'X', scope: 'y', cause,
175
+ })
176
+ expect(err.cause).toBe(cause)
177
+ })
178
+ })
179
+
180
+ describe('ClientRequestError deprecated alias', () => {
181
+ it('is identical to ClientHttpError', () => {
182
+ expect(ClientRequestError).toBe(ClientHttpError)
183
+ })
184
+
185
+ it('instanceof check works against thrown ClientHttpError', () => {
186
+ // Consumers on the previous major used `catch (e) { if (e instanceof
187
+ // ClientRequestError) ... }`. The alias must keep that pattern working
188
+ // until the alias is removed in the next minor cycle's deprecation window.
189
+ const err = new ClientHttpError({
190
+ status: 500, headers: {}, body: null,
191
+ procedureName: 'X', scope: 'y',
192
+ })
193
+ expect(err instanceof ClientRequestError).toBe(true)
194
+ })
195
+ })
196
+ ```
197
+
198
+ - [ ] **Step 3: Run tests to verify they fail**
199
+
200
+ ```bash
201
+ npx vitest run src/client/errors.test.ts -t 'ClientHttpError|alias'
202
+ ```
203
+ Expected: FAIL — `ClientHttpError` does not exist.
204
+
205
+ - [ ] **Step 4: Rename in `src/client/errors.ts`, add cause + alias**
206
+
207
+ Replace the `ClientRequestError` class definition:
208
+
209
+ ```ts
210
+ export class ClientHttpError extends Error {
211
+ readonly name = 'ClientHttpError'
212
+ readonly status: number
213
+ readonly headers: Record<string, string>
214
+ readonly body: unknown
215
+ readonly procedureName: string
216
+ readonly scope: string
217
+
218
+ constructor(opts: {
219
+ status: number
220
+ headers: Record<string, string>
221
+ body: unknown
222
+ procedureName: string
223
+ scope: string
224
+ cause?: unknown
225
+ }) {
226
+ super(
227
+ `${opts.procedureName} (${opts.scope}) failed with status ${opts.status}`,
228
+ { cause: opts.cause }
229
+ )
230
+ this.status = opts.status
231
+ this.headers = opts.headers
232
+ this.body = opts.body
233
+ this.procedureName = opts.procedureName
234
+ this.scope = opts.scope
235
+ }
236
+ }
237
+
238
+ /** @deprecated Renamed to `ClientHttpError`. The alias will be removed in the next major release. */
239
+ export const ClientRequestError = ClientHttpError
240
+ /** @deprecated Renamed to `ClientHttpError`. */
241
+ export type ClientRequestError = ClientHttpError
242
+ ```
243
+
244
+ - [ ] **Step 5: Update every in-tree consumer**
245
+
246
+ For each file from Step 1, change `ClientRequestError` → `ClientHttpError` (use `Edit` tool's `replace_all` per file). Skip the deprecated-alias test itself.
247
+
248
+ - [ ] **Step 6: Update `src/client/index.ts` exports**
249
+
250
+ Ensure both `ClientHttpError` and `ClientRequestError` (alias) are exported.
251
+
252
+ - [ ] **Step 7: Run full test suite to verify no regressions**
253
+
254
+ ```bash
255
+ npm run test
256
+ ```
257
+ Expected: PASS for everything except the next phases (which haven't shipped yet — but the rename should not break anything).
258
+
259
+ - [ ] **Step 8: Commit**
260
+
261
+ ```bash
262
+ git add src/client/errors.ts src/client/errors.test.ts src/client/index.ts src/client/call.ts src/client/stream.ts src/client/error-dispatch.ts
263
+ git commit -m "feat(client/errors)!: rename ClientRequestError to ClientHttpError
264
+
265
+ Deprecated alias retained for one minor cycle.
266
+ BREAKING CHANGE: ClientRequestError will be removed next major."
267
+ ```
268
+
269
+ ### Task 3: Add new framework error classes
270
+
271
+ **Files:**
272
+ - Modify: `src/client/errors.ts`
273
+ - Test: `src/client/errors.test.ts`
274
+ - Modify: `src/client/index.ts`
275
+
276
+ - [ ] **Step 1: Write failing tests for new classes**
277
+
278
+ Append to `src/client/errors.test.ts`:
279
+
280
+ ```ts
281
+ import {
282
+ ClientNetworkError,
283
+ ClientTimeoutError,
284
+ ClientAbortError,
285
+ ClientParseError,
286
+ } from './errors.js'
287
+
288
+ describe('ClientNetworkError', () => {
289
+ it('carries procedureName, scope, cause', () => {
290
+ const cause = new TypeError('fetch failed')
291
+ const err = new ClientNetworkError({
292
+ procedureName: 'X', scope: 'y', cause,
293
+ })
294
+ expect(err.name).toBe('ClientNetworkError')
295
+ expect(err.procedureName).toBe('X')
296
+ expect(err.scope).toBe('y')
297
+ expect(err.cause).toBe(cause)
298
+ })
299
+ })
300
+
301
+ describe('ClientTimeoutError', () => {
302
+ it('carries timeoutMs', () => {
303
+ const err = new ClientTimeoutError({
304
+ procedureName: 'X', scope: 'y', timeoutMs: 5000,
305
+ })
306
+ expect(err.name).toBe('ClientTimeoutError')
307
+ expect(err.timeoutMs).toBe(5000)
308
+ })
309
+ })
310
+
311
+ describe('ClientAbortError', () => {
312
+ it('carries reason from abort signal', () => {
313
+ const err = new ClientAbortError({
314
+ procedureName: 'X', scope: 'y', reason: 'user-cancelled',
315
+ })
316
+ expect(err.name).toBe('ClientAbortError')
317
+ expect(err.reason).toBe('user-cancelled')
318
+ })
319
+ })
320
+
321
+ describe('ClientParseError', () => {
322
+ it('carries procedureName, scope, cause', () => {
323
+ const cause = new SyntaxError('Unexpected token')
324
+ const err = new ClientParseError({
325
+ procedureName: 'X', scope: 'y', cause,
326
+ })
327
+ expect(err.name).toBe('ClientParseError')
328
+ expect(err.cause).toBe(cause)
329
+ })
330
+ })
331
+ ```
332
+
333
+ - [ ] **Step 2: Run tests to verify they fail**
334
+
335
+ ```bash
336
+ npx vitest run src/client/errors.test.ts -t 'ClientNetworkError|ClientTimeoutError|ClientAbortError|ClientParseError'
337
+ ```
338
+ Expected: FAIL — classes don't exist.
339
+
340
+ - [ ] **Step 3: Add the new classes**
341
+
342
+ Append to `src/client/errors.ts`:
343
+
344
+ ```ts
345
+ export class ClientNetworkError extends Error {
346
+ readonly name = 'ClientNetworkError'
347
+ readonly procedureName: string
348
+ readonly scope: string
349
+
350
+ constructor(opts: { procedureName: string; scope: string; cause?: unknown; message?: string }) {
351
+ super(opts.message ?? `${opts.procedureName} (${opts.scope}) failed: network error`, { cause: opts.cause })
352
+ this.procedureName = opts.procedureName
353
+ this.scope = opts.scope
354
+ }
355
+ }
356
+
357
+ export class ClientTimeoutError extends Error {
358
+ readonly name = 'ClientTimeoutError'
359
+ readonly procedureName: string
360
+ readonly scope: string
361
+ readonly timeoutMs: number
362
+
363
+ constructor(opts: { procedureName: string; scope: string; timeoutMs: number; cause?: unknown }) {
364
+ super(`${opts.procedureName} (${opts.scope}) timed out after ${opts.timeoutMs}ms`, { cause: opts.cause })
365
+ this.procedureName = opts.procedureName
366
+ this.scope = opts.scope
367
+ this.timeoutMs = opts.timeoutMs
368
+ }
369
+ }
370
+
371
+ export class ClientAbortError extends Error {
372
+ readonly name = 'ClientAbortError'
373
+ readonly procedureName: string
374
+ readonly scope: string
375
+ readonly reason: unknown
376
+
377
+ constructor(opts: { procedureName: string; scope: string; reason?: unknown; cause?: unknown }) {
378
+ super(`${opts.procedureName} (${opts.scope}) aborted`, { cause: opts.cause })
379
+ this.procedureName = opts.procedureName
380
+ this.scope = opts.scope
381
+ this.reason = opts.reason
382
+ }
383
+ }
384
+
385
+ export class ClientParseError extends Error {
386
+ readonly name = 'ClientParseError'
387
+ readonly procedureName: string
388
+ readonly scope: string
389
+
390
+ constructor(opts: { procedureName: string; scope: string; cause?: unknown; message?: string }) {
391
+ super(opts.message ?? `${opts.procedureName} (${opts.scope}) response could not be parsed`, { cause: opts.cause })
392
+ this.procedureName = opts.procedureName
393
+ this.scope = opts.scope
394
+ }
395
+ }
396
+ ```
397
+
398
+ - [ ] **Step 4: Export from `src/client/index.ts`**
399
+
400
+ Add the four new classes to the existing barrel exports.
401
+
402
+ - [ ] **Step 5: Run tests to verify pass**
403
+
404
+ ```bash
405
+ npx vitest run src/client/errors.test.ts
406
+ ```
407
+ Expected: PASS.
408
+
409
+ - [ ] **Step 6: Commit**
410
+
411
+ ```bash
412
+ git add src/client/errors.ts src/client/errors.test.ts src/client/index.ts
413
+ git commit -m "feat(client/errors): add ClientNetworkError, ClientTimeoutError, ClientAbortError, ClientParseError"
414
+ ```
415
+
416
+ ---
417
+
418
+ ## Phase 2 — Default classifier
419
+
420
+ ### Task 4: Create `classify-error.ts` and default classifier
421
+
422
+ **Files:**
423
+ - Create: `src/client/classify-error.ts`
424
+ - Test: `src/client/classify-error.test.ts`
425
+
426
+ - [ ] **Step 1: Write failing tests**
427
+
428
+ Create `src/client/classify-error.test.ts`:
429
+
430
+ ```ts
431
+ import { describe, it, expect } from 'vitest'
432
+ import { defaultClassifyError } from './classify-error.js'
433
+ import {
434
+ ClientNetworkError,
435
+ ClientTimeoutError,
436
+ ClientAbortError,
437
+ } from './errors.js'
438
+
439
+ const meta = { procedureName: 'GetUser', scope: 'users' }
440
+
441
+ describe('defaultClassifyError', () => {
442
+ it('classifies fetch TypeError as network', () => {
443
+ const result = defaultClassifyError(new TypeError('Failed to fetch'), { ...meta })
444
+ expect(result?.kind).toBe('network')
445
+ expect(result?.error).toBeInstanceOf(ClientNetworkError)
446
+ expect(result?.error.cause).toBeInstanceOf(TypeError)
447
+ })
448
+
449
+ it('classifies AbortError as timeout when timeout signal fired', () => {
450
+ const timeoutSignal = AbortSignal.timeout(0)
451
+ // wait a tick for the timeout to fire
452
+ return new Promise<void>((resolve) => setTimeout(() => {
453
+ const abortErr = new DOMException('aborted', 'AbortError')
454
+ const result = defaultClassifyError(abortErr, { ...meta, timeoutSignal, timeoutMs: 5000 })
455
+ expect(result?.kind).toBe('timeout')
456
+ expect(result?.error).toBeInstanceOf(ClientTimeoutError)
457
+ expect((result?.error as ClientTimeoutError).timeoutMs).toBe(5000)
458
+ resolve()
459
+ }, 1))
460
+ })
461
+
462
+ it('classifies AbortError as aborted when user signal fired', () => {
463
+ const userController = new AbortController()
464
+ userController.abort('user-cancelled')
465
+ const abortErr = new DOMException('aborted', 'AbortError')
466
+ const result = defaultClassifyError(abortErr, { ...meta, userSignal: userController.signal })
467
+ expect(result?.kind).toBe('aborted')
468
+ expect(result?.error).toBeInstanceOf(ClientAbortError)
469
+ expect((result?.error as ClientAbortError).reason).toBe('user-cancelled')
470
+ })
471
+
472
+ it('classifies AbortError as timeout when both fired (timeout precedence)', () => {
473
+ const timeoutSignal = AbortSignal.timeout(0)
474
+ const userController = new AbortController()
475
+ return new Promise<void>((resolve) => setTimeout(() => {
476
+ userController.abort('user')
477
+ const abortErr = new DOMException('aborted', 'AbortError')
478
+ const result = defaultClassifyError(abortErr, {
479
+ ...meta, timeoutSignal, timeoutMs: 1, userSignal: userController.signal,
480
+ })
481
+ expect(result?.kind).toBe('timeout')
482
+ resolve()
483
+ }, 1))
484
+ })
485
+
486
+ it('returns null for unknown errors', () => {
487
+ const result = defaultClassifyError(new Error('weird'), { ...meta })
488
+ expect(result).toBeNull()
489
+ })
490
+
491
+ it('returns null for non-Error throws', () => {
492
+ expect(defaultClassifyError('string-thrown', { ...meta })).toBeNull()
493
+ expect(defaultClassifyError(42, { ...meta })).toBeNull()
494
+ })
495
+ })
496
+ ```
497
+
498
+ - [ ] **Step 2: Run tests to verify they fail**
499
+
500
+ ```bash
501
+ npx vitest run src/client/classify-error.test.ts
502
+ ```
503
+ Expected: FAIL — file does not exist.
504
+
505
+ - [ ] **Step 3: Implement `classify-error.ts`**
506
+
507
+ Create `src/client/classify-error.ts`:
508
+
509
+ ```ts
510
+ import {
511
+ ClientNetworkError,
512
+ ClientTimeoutError,
513
+ ClientAbortError,
514
+ } from './errors.js'
515
+
516
+ /**
517
+ * Classification context — supplies provenance the classifier needs that's
518
+ * not derivable from the raw error itself.
519
+ *
520
+ * `timeoutSignal` and `userSignal` let the classifier distinguish a timeout
521
+ * abort from a user-initiated abort when an `AbortError` lands.
522
+ */
523
+ export interface ClassifyErrorContext {
524
+ procedureName: string
525
+ scope: string
526
+ timeoutSignal?: AbortSignal
527
+ userSignal?: AbortSignal
528
+ timeoutMs?: number
529
+ }
530
+
531
+ /**
532
+ * The output shape of a successful classification. Contract: `error` is
533
+ * always an `Error` subclass (the framework class). Non-`Error` values fall
534
+ * through to `null` (handled by `executeCall` as `kind: 'unknown'`).
535
+ */
536
+ export interface ClassifiedError {
537
+ kind: string
538
+ error: Error
539
+ }
540
+
541
+ /**
542
+ * Adapter-provided classifier — runs before `defaultClassifyError`. Return
543
+ * `null` to fall through to the default. Adapter authors should compose with
544
+ * the default explicitly:
545
+ *
546
+ * classifyError: (e, ctx) => myClassify(e, ctx) ?? defaultClassifyError(e, ctx)
547
+ */
548
+ export type ErrorClassifier = (
549
+ raw: unknown,
550
+ ctx: ClassifyErrorContext,
551
+ ) => ClassifiedError | null
552
+
553
+ /**
554
+ * Default classifier — recognizes:
555
+ * - `TypeError` from fetch → `ClientNetworkError`
556
+ * - `DOMException` with `name: 'AbortError'` + timeout-signal-aborted → `ClientTimeoutError`
557
+ * - `DOMException` with `name: 'AbortError'` + user-signal-aborted → `ClientAbortError`
558
+ *
559
+ * Returns `null` for anything else. Timeout precedence: when both signals
560
+ * fired, classifies as `timeout`.
561
+ */
562
+ export const defaultClassifyError: ErrorClassifier = (raw, ctx) => {
563
+ const meta = { procedureName: ctx.procedureName, scope: ctx.scope }
564
+
565
+ if (raw instanceof TypeError) {
566
+ return {
567
+ kind: 'network',
568
+ error: new ClientNetworkError({ ...meta, cause: raw, message: raw.message }),
569
+ }
570
+ }
571
+
572
+ if (
573
+ raw instanceof DOMException &&
574
+ raw.name === 'AbortError'
575
+ ) {
576
+ if (ctx.timeoutSignal?.aborted) {
577
+ return {
578
+ kind: 'timeout',
579
+ error: new ClientTimeoutError({
580
+ ...meta,
581
+ timeoutMs: ctx.timeoutMs ?? 0,
582
+ cause: raw,
583
+ }),
584
+ }
585
+ }
586
+ if (ctx.userSignal?.aborted) {
587
+ return {
588
+ kind: 'aborted',
589
+ error: new ClientAbortError({
590
+ ...meta,
591
+ reason: ctx.userSignal.reason,
592
+ cause: raw,
593
+ }),
594
+ }
595
+ }
596
+ // AbortError without a tracked source — treat as user abort with no reason.
597
+ return {
598
+ kind: 'aborted',
599
+ error: new ClientAbortError({ ...meta, cause: raw }),
600
+ }
601
+ }
602
+
603
+ return null
604
+ }
605
+ ```
606
+
607
+ - [ ] **Step 4: Run tests to verify pass**
608
+
609
+ ```bash
610
+ npx vitest run src/client/classify-error.test.ts
611
+ ```
612
+ Expected: PASS.
613
+
614
+ - [ ] **Step 5: Export classifier from `src/client/index.ts`**
615
+
616
+ Add: `export { defaultClassifyError, type ErrorClassifier, type ClassifyErrorContext, type ClassifiedError } from './classify-error.js'`
617
+
618
+ - [ ] **Step 6: Commit**
619
+
620
+ ```bash
621
+ git add src/client/classify-error.ts src/client/classify-error.test.ts src/client/index.ts
622
+ git commit -m "feat(client): add error classifier with default platform-error coverage"
623
+ ```
624
+
625
+ ---
626
+
627
+ ## Phase 3 — Wire classifier into runtime
628
+
629
+ ### Task 5: Refactor `resolveSignal` to expose source signals
630
+
631
+ **Files:**
632
+ - Modify: `src/client/resolve-options.ts`
633
+ - Test: `src/client/resolve-options.test.ts`
634
+
635
+ The classifier needs the original timeout + user signals (not just the combined `AbortSignal.any` result). This task surfaces them.
636
+
637
+ - [ ] **Step 1: Write failing test**
638
+
639
+ Add to `src/client/resolve-options.test.ts`:
640
+
641
+ ```ts
642
+ import { resolveSignalSources } from './resolve-options.js'
643
+
644
+ describe('resolveSignalSources', () => {
645
+ it('returns timeout signal alongside combined', () => {
646
+ const result = resolveSignalSources(undefined, { timeout: 100 })
647
+ expect(result.timeoutSignal).toBeInstanceOf(AbortSignal)
648
+ expect(result.timeoutMs).toBe(100)
649
+ expect(result.combined).toBeInstanceOf(AbortSignal)
650
+ expect(result.userSignal).toBeUndefined()
651
+ })
652
+
653
+ it('returns user signal alongside combined', () => {
654
+ const ctrl = new AbortController()
655
+ const result = resolveSignalSources(undefined, { signal: ctrl.signal })
656
+ expect(result.userSignal).toBe(ctrl.signal)
657
+ expect(result.timeoutSignal).toBeUndefined()
658
+ })
659
+
660
+ it('combines both via AbortSignal.any', () => {
661
+ const ctrl = new AbortController()
662
+ const result = resolveSignalSources(undefined, { signal: ctrl.signal, timeout: 100 })
663
+ expect(result.timeoutSignal).toBeInstanceOf(AbortSignal)
664
+ expect(result.userSignal).toBe(ctrl.signal)
665
+ expect(result.combined).toBeInstanceOf(AbortSignal)
666
+ })
667
+
668
+ it('returns undefined combined when no signals', () => {
669
+ const result = resolveSignalSources(undefined, undefined)
670
+ expect(result.combined).toBeUndefined()
671
+ })
672
+ })
673
+ ```
674
+
675
+ - [ ] **Step 2: Run test to verify it fails**
676
+
677
+ ```bash
678
+ npx vitest run src/client/resolve-options.test.ts -t 'resolveSignalSources'
679
+ ```
680
+ Expected: FAIL.
681
+
682
+ - [ ] **Step 3: Implement `resolveSignalSources`**
683
+
684
+ Add to `src/client/resolve-options.ts`:
685
+
686
+ ```ts
687
+ export interface SignalSources {
688
+ combined?: AbortSignal
689
+ timeoutSignal?: AbortSignal
690
+ userSignal?: AbortSignal
691
+ timeoutMs?: number
692
+ }
693
+
694
+ export function resolveSignalSources(
695
+ defaults: ProcedureCallDefaults | undefined,
696
+ options: ProcedureCallOptions | undefined,
697
+ ): SignalSources {
698
+ const userSignal = options?.signal ?? defaults?.signal
699
+ const timeoutMs = options?.timeout ?? defaults?.timeout
700
+ const timeoutSignal =
701
+ timeoutMs != null && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined
702
+
703
+ const signals: AbortSignal[] = []
704
+ if (userSignal) signals.push(userSignal)
705
+ if (timeoutSignal) signals.push(timeoutSignal)
706
+
707
+ let combined: AbortSignal | undefined
708
+ if (signals.length === 1) combined = signals[0]
709
+ else if (signals.length > 1) combined = AbortSignal.any(signals)
710
+
711
+ return { combined, timeoutSignal, userSignal, timeoutMs }
712
+ }
713
+ ```
714
+
715
+ - [ ] **Step 4: Refactor `applyRequestOptions` to use `resolveSignalSources` and return both the request and the sources**
716
+
717
+ Replace `applyRequestOptions`:
718
+
719
+ ```ts
720
+ export interface ApplyRequestOptionsResult {
721
+ request: AdapterRequest
722
+ signalSources: SignalSources
723
+ }
724
+
725
+ export function applyRequestOptions(
726
+ request: AdapterRequest,
727
+ defaults: ProcedureCallDefaults | undefined,
728
+ options: ProcedureCallOptions | undefined,
729
+ ): ApplyRequestOptionsResult {
730
+ const signalSources = resolveSignalSources(defaults, options)
731
+ const resolvedHeaders = resolveHeaders(defaults, options)
732
+ const meta = resolveMeta(defaults, options)
733
+
734
+ const headers =
735
+ resolvedHeaders || request.headers
736
+ ? { ...resolvedHeaders, ...request.headers }
737
+ : undefined
738
+
739
+ return {
740
+ request: { ...request, headers, signal: signalSources.combined, meta },
741
+ signalSources,
742
+ }
743
+ }
744
+ ```
745
+
746
+ Keep `resolveSignal` exported as a thin wrapper for backwards-compat with anyone importing it directly:
747
+
748
+ ```ts
749
+ export function resolveSignal(
750
+ defaults: ProcedureCallDefaults | undefined,
751
+ options: ProcedureCallOptions | undefined,
752
+ ): AbortSignal | undefined {
753
+ return resolveSignalSources(defaults, options).combined
754
+ }
755
+ ```
756
+
757
+ - [ ] **Step 5: Update existing `resolve-options.test.ts` tests to handle the new return shape**
758
+
759
+ Find every `applyRequestOptions(...)` call in tests and unwrap `.request`. Easiest: introduce a helper at top of test file:
760
+ ```ts
761
+ const apply = (req, d, o) => applyRequestOptions(req, d, o).request
762
+ ```
763
+ Then `replace_all` `applyRequestOptions(` with `apply(` *only* in test files (not in src!).
764
+
765
+ - [ ] **Step 6: Update `executeCall` and `executeStream` callers to destructure `request` from the return value**
766
+
767
+ In `src/client/call.ts`, change:
768
+ ```ts
769
+ request = applyRequestOptions(request, defaults, options)
770
+ ```
771
+ To:
772
+ ```ts
773
+ const applied = applyRequestOptions(request, defaults, options)
774
+ request = applied.request
775
+ const signalSources = applied.signalSources // used in Task 6
776
+ ```
777
+
778
+ Same change in `src/client/stream.ts`.
779
+
780
+ - [ ] **Step 7: Verify no stale `applyRequestOptions(` callers were missed**
781
+
782
+ ```bash
783
+ grep -rn 'applyRequestOptions(' src/ --include='*.ts'
784
+ ```
785
+ Expected output: only the implementation in `src/client/resolve-options.ts` and the in-test helper `apply` definition. If any other test file still references `applyRequestOptions(...)` directly without the `.request` unwrap, fix before continuing.
786
+
787
+ - [ ] **Step 8: Run full suite to confirm no regressions**
788
+
789
+ ```bash
790
+ npm run test
791
+ ```
792
+ Expected: PASS.
793
+
794
+ - [ ] **Step 9: Commit**
795
+
796
+ ```bash
797
+ git add src/client/resolve-options.ts src/client/resolve-options.test.ts src/client/call.ts src/client/stream.ts
798
+ git commit -m "refactor(client): expose signal sources from applyRequestOptions
799
+
800
+ Lets the classifier distinguish timeout abort from user abort by
801
+ checking which underlying signal fired first."
802
+ ```
803
+
804
+ ### Task 6: Wire classifier into `executeCall`
805
+
806
+ **Files:**
807
+ - Modify: `src/client/call.ts`
808
+ - Modify: `src/client/types.ts` (extend `ClientAdapter`)
809
+ - Test: `src/client/call.test.ts`
810
+
811
+ - [ ] **Step 1: Extend `ClientAdapter` interface with optional `classifyError`**
812
+
813
+ In `src/client/types.ts`:
814
+
815
+ ```ts
816
+ import type { ErrorClassifier } from './classify-error.js'
817
+
818
+ export interface ClientAdapter {
819
+ request(config: AdapterRequest): Promise<AdapterResponse>
820
+ stream(config: AdapterRequest): Promise<AdapterStreamResponse>
821
+ /** Optional adapter-level error classifier — composes with `defaultClassifyError`. */
822
+ classifyError?: ErrorClassifier
823
+ }
824
+ ```
825
+
826
+ - [ ] **Step 2: Write failing test for normalized errors**
827
+
828
+ Add to `src/client/call.test.ts`:
829
+
830
+ ```ts
831
+ import {
832
+ ClientNetworkError,
833
+ ClientTimeoutError,
834
+ ClientAbortError,
835
+ ClientHttpError,
836
+ } from './errors.js'
837
+
838
+ describe('executeCall classifier integration', () => {
839
+ it('throws ClientNetworkError when adapter throws TypeError', async () => {
840
+ const adapter: ClientAdapter = {
841
+ request: vi.fn(async () => { throw new TypeError('Failed to fetch') }),
842
+ stream: vi.fn(async () => { throw new Error('n/a') }),
843
+ }
844
+ await expect(run({ adapter })).rejects.toBeInstanceOf(ClientNetworkError)
845
+ })
846
+
847
+ it('throws ClientTimeoutError when adapter rejects with AbortError on timeout signal', async () => {
848
+ const adapter: ClientAdapter = {
849
+ request: vi.fn(async () => { throw new DOMException('aborted', 'AbortError') }),
850
+ stream: vi.fn(async () => { throw new Error('n/a') }),
851
+ }
852
+ await expect(
853
+ run({ adapter, options: { timeout: 1 } })
854
+ ).rejects.toBeInstanceOf(ClientTimeoutError)
855
+ })
856
+
857
+ it('uses adapter-provided classifier first', async () => {
858
+ class CustomError extends Error { readonly name = 'CustomError' }
859
+ const adapter: ClientAdapter = {
860
+ request: vi.fn(async () => { throw new TypeError('x') }),
861
+ stream: vi.fn(async () => { throw new Error('n/a') }),
862
+ classifyError: () => ({ kind: 'custom', error: new CustomError('classified') }),
863
+ }
864
+ await expect(run({ adapter })).rejects.toBeInstanceOf(CustomError)
865
+ })
866
+
867
+ it('still throws ClientHttpError for non-2xx responses (registry path unchanged)', async () => {
868
+ const adapter = makeAdapter({ status: 500, body: { message: 'oops' } })
869
+ await expect(run({ adapter })).rejects.toBeInstanceOf(ClientHttpError)
870
+ })
871
+
872
+ it('hooks see the normalized error, not the raw TypeError', async () => {
873
+ const seen: unknown[] = []
874
+ const adapter: ClientAdapter = {
875
+ request: vi.fn(async () => { throw new TypeError('x') }),
876
+ stream: vi.fn(async () => { throw new Error('n/a') }),
877
+ }
878
+ const hooks = { onError: ({ error }: { error: unknown }) => { seen.push(error) } }
879
+ await expect(run({ adapter, hooks })).rejects.toBeInstanceOf(ClientNetworkError)
880
+ expect(seen[0]).toBeInstanceOf(ClientNetworkError)
881
+ })
882
+ })
883
+ ```
884
+
885
+ - [ ] **Step 3: Run tests to verify they fail**
886
+
887
+ ```bash
888
+ npx vitest run src/client/call.test.ts -t 'classifier integration'
889
+ ```
890
+ Expected: FAIL.
891
+
892
+ - [ ] **Step 4: Update `executeCall` to classify adapter errors**
893
+
894
+ Replace the try/catch around `adapter.request()` in `src/client/call.ts`:
895
+
896
+ ```ts
897
+ import { defaultClassifyError } from './classify-error.js'
898
+ // ... existing imports ...
899
+
900
+ // inside executeCall, after `applied = applyRequestOptions(...)`:
901
+
902
+ let response
903
+ try {
904
+ response = await adapter.request(request)
905
+ } catch (rawErr) {
906
+ // Classify: adapter classifier first, default second, fallthrough = re-throw raw
907
+ const classifyCtx = {
908
+ procedureName: descriptor.name,
909
+ scope: descriptor.scope,
910
+ timeoutSignal: signalSources.timeoutSignal,
911
+ userSignal: signalSources.userSignal,
912
+ timeoutMs: signalSources.timeoutMs,
913
+ }
914
+ const classified =
915
+ adapter.classifyError?.(rawErr, classifyCtx) ??
916
+ defaultClassifyError(rawErr, classifyCtx)
917
+ const finalError = classified?.error ?? rawErr
918
+
919
+ // Hooks see the normalized error
920
+ await runOnError(
921
+ { procedureName: descriptor.name, scope: descriptor.scope, request, error: finalError },
922
+ hooks,
923
+ options,
924
+ )
925
+ throw finalError
926
+ }
927
+ ```
928
+
929
+ - [ ] **Step 5: Run tests to verify pass**
930
+
931
+ ```bash
932
+ npx vitest run src/client/call.test.ts
933
+ ```
934
+ Expected: PASS.
935
+
936
+ - [ ] **Step 6: Commit**
937
+
938
+ ```bash
939
+ git add src/client/types.ts src/client/call.ts src/client/call.test.ts
940
+ git commit -m "feat(client/call): classify adapter errors into framework classes
941
+
942
+ executeCall now wraps adapter throws in ClientNetworkError /
943
+ ClientTimeoutError / ClientAbortError via the classifier composition
944
+ (adapter classifier > default > fallthrough). Hooks receive the
945
+ normalized error.
946
+
947
+ BREAKING CHANGE: Raw DOMException / TypeError no longer reach
948
+ consumer catch blocks — the framework throws normalized classes.
949
+ Migration: catch the framework class instead (instanceof
950
+ ClientTimeoutError, etc.)."
951
+ ```
952
+
953
+ ### Task 7: Wire classifier into `executeStream` pre-stream path
954
+
955
+ **Files:**
956
+ - Modify: `src/client/stream.ts`
957
+ - Test: `src/client/stream.test.ts`
958
+
959
+ - [ ] **Step 1: Write failing test**
960
+
961
+ Add to `src/client/stream.test.ts` (mirror the call.test.ts pattern, scoped to pre-stream errors only):
962
+
963
+ ```ts
964
+ import { ClientNetworkError } from './errors.js'
965
+
966
+ describe('executeStream classifier integration', () => {
967
+ it('classifies pre-stream TypeError as ClientNetworkError', async () => {
968
+ const adapter: ClientAdapter = {
969
+ request: vi.fn(async () => { throw new Error('n/a') }),
970
+ stream: vi.fn(async () => { throw new TypeError('Failed to fetch') }),
971
+ }
972
+ await expect(runStream({ adapter })).rejects.toBeInstanceOf(ClientNetworkError)
973
+ })
974
+ })
975
+ ```
976
+
977
+ (Use the existing `runStream` helper or add one mirroring `run`.)
978
+
979
+ - [ ] **Step 2: Run to verify fail**
980
+
981
+ ```bash
982
+ npx vitest run src/client/stream.test.ts -t 'classifier integration'
983
+ ```
984
+
985
+ - [ ] **Step 3: Apply the same classifier composition to the pre-stream try/catch**
986
+
987
+ In `src/client/stream.ts`, update the try/catch around `adapter.stream()` identically to Task 6 Step 4 (using `signalSources` from the applied options).
988
+
989
+ - [ ] **Step 4: Run to verify pass**
990
+
991
+ ```bash
992
+ npx vitest run src/client/stream.test.ts
993
+ ```
994
+
995
+ - [ ] **Step 5: Commit**
996
+
997
+ ```bash
998
+ git add src/client/stream.ts src/client/stream.test.ts
999
+ git commit -m "feat(client/stream): classify pre-stream adapter errors
1000
+
1001
+ Mid-stream errors are unchanged — only the pre-stream connection
1002
+ path uses the classifier."
1003
+ ```
1004
+
1005
+ ### Task 8: Wire classifier into `createFetchAdapter`
1006
+
1007
+ **Files:**
1008
+ - Modify: `src/client/fetch-adapter.ts`
1009
+ - Test: `src/client/fetch-adapter.test.ts`
1010
+
1011
+ - [ ] **Step 1: Write failing test**
1012
+
1013
+ Add to `src/client/fetch-adapter.test.ts`:
1014
+
1015
+ ```ts
1016
+ import { defaultClassifyError } from './classify-error.js'
1017
+
1018
+ describe('createFetchAdapter classifyError', () => {
1019
+ it('passes through to adapter.classifyError', () => {
1020
+ const customClassifier = vi.fn()
1021
+ const adapter = createFetchAdapter({ classifyError: customClassifier })
1022
+ expect(adapter.classifyError).toBe(customClassifier)
1023
+ })
1024
+
1025
+ it('does not set classifyError when not provided', () => {
1026
+ const adapter = createFetchAdapter()
1027
+ expect(adapter.classifyError).toBeUndefined()
1028
+ })
1029
+ })
1030
+ ```
1031
+
1032
+ - [ ] **Step 2: Run to verify fail**
1033
+
1034
+ ```bash
1035
+ npx vitest run src/client/fetch-adapter.test.ts -t 'classifyError'
1036
+ ```
1037
+
1038
+ - [ ] **Step 3: Update `createFetchAdapter`**
1039
+
1040
+ In `src/client/fetch-adapter.ts`:
1041
+
1042
+ ```ts
1043
+ export interface FetchAdapterConfig {
1044
+ headers?: Record<string, string>
1045
+ classifyError?: ErrorClassifier
1046
+ }
1047
+
1048
+ export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
1049
+ // ... existing body ...
1050
+ return {
1051
+ async request(req) { /* existing */ },
1052
+ async stream(req) { /* existing */ },
1053
+ classifyError: config?.classifyError,
1054
+ }
1055
+ }
1056
+ ```
1057
+
1058
+ - [ ] **Step 4: Run to verify pass**
1059
+
1060
+ ```bash
1061
+ npx vitest run src/client/fetch-adapter.test.ts
1062
+ ```
1063
+
1064
+ - [ ] **Step 5: Commit**
1065
+
1066
+ ```bash
1067
+ git add src/client/fetch-adapter.ts src/client/fetch-adapter.test.ts
1068
+ git commit -m "feat(client/fetch-adapter): accept optional classifyError config"
1069
+ ```
1070
+
1071
+ ---
1072
+
1073
+ ## Phase 4 — Result type derivation
1074
+
1075
+ ### Task 8.5: Enable vitest type-checking for `.test-d.ts` files
1076
+
1077
+ **Files:**
1078
+ - Modify: `vitest.config.ts`
1079
+
1080
+ The `.test-d.ts` files in Tasks 9, 10 use `expectTypeOf` from vitest. These only run type-level assertions when vitest's `typecheck` mode is enabled — without it, the files are parsed but type errors are silently ignored. This task wires it up before the first `.test-d.ts` lands.
1081
+
1082
+ - [ ] **Step 1: Update `vitest.config.ts`**
1083
+
1084
+ ```ts
1085
+ import { defineConfig } from 'vitest/config'
1086
+
1087
+ export default defineConfig({
1088
+ test: {
1089
+ globals: true,
1090
+ environment: 'node',
1091
+ exclude: ['**/node_modules/**', '**/build/**', '**/dist/**'],
1092
+ typecheck: {
1093
+ enabled: true,
1094
+ include: ['**/*.test-d.ts'],
1095
+ tsconfig: './tsconfig.json',
1096
+ },
1097
+ },
1098
+ })
1099
+ ```
1100
+
1101
+ - [ ] **Step 2: Sanity check — write a deliberately-failing type-test, run it, confirm vitest catches it, then revert**
1102
+
1103
+ ```bash
1104
+ cat > /tmp/sanity.test-d.ts <<'EOF'
1105
+ import { expectTypeOf } from 'vitest'
1106
+ expectTypeOf<string>().toEqualTypeOf<number>() // should fail
1107
+ EOF
1108
+ cp /tmp/sanity.test-d.ts src/client/sanity.test-d.ts
1109
+ npx vitest run src/client/sanity.test-d.ts
1110
+ # Expected: FAIL with type-mismatch error
1111
+ rm src/client/sanity.test-d.ts
1112
+ ```
1113
+
1114
+ If this passes silently, the typecheck wiring isn't active — debug before proceeding.
1115
+
1116
+ - [ ] **Step 3: Commit**
1117
+
1118
+ ```bash
1119
+ git add vitest.config.ts
1120
+ git commit -m "test(vitest): enable typecheck for .test-d.ts files
1121
+
1122
+ Required so the type-level tests added in subsequent tasks
1123
+ (Result, ResultNoTyped, ClientErrorMap augmentation) actually
1124
+ run their assertions."
1125
+ ```
1126
+
1127
+ ### Task 9: Add `ClientErrorMap`, `Result`, `ResultNoTyped`, `FrameworkFailure`
1128
+
1129
+ **Files:**
1130
+ - Modify: `src/client/types.ts`
1131
+ - Test: `src/client/result-type.test-d.ts` (type-only test)
1132
+ - Modify: `src/client/index.ts`
1133
+
1134
+ - [ ] **Step 1: Add the type definitions to `src/client/types.ts`**
1135
+
1136
+ Append:
1137
+
1138
+ ```ts
1139
+ import type {
1140
+ ClientHttpError,
1141
+ ClientNetworkError,
1142
+ ClientTimeoutError,
1143
+ ClientAbortError,
1144
+ ClientParseError,
1145
+ ClientPathParamError,
1146
+ } from './errors.js'
1147
+
1148
+ /**
1149
+ * Augmentable map of `kind` discriminant → error class for the framework's
1150
+ * non-typed failure categories. Mirrors the `RequestMeta` augmentation
1151
+ * pattern: extend via TypeScript declaration merging.
1152
+ *
1153
+ * @example
1154
+ * ```ts
1155
+ * declare module 'ts-procedures/client' {
1156
+ * interface ClientErrorMap {
1157
+ * rateLimited: MyRateLimitError
1158
+ * paymentRequired: MyPaymentError
1159
+ * }
1160
+ * }
1161
+ * ```
1162
+ *
1163
+ * **`'typed'` is reserved** — it's the discriminant used by `Result` for
1164
+ * route-declared errors and is not part of `ClientErrorMap`. Attempting to
1165
+ * register it produces a TS error about overlapping discriminants.
1166
+ */
1167
+ export interface ClientErrorMap {
1168
+ http: ClientHttpError
1169
+ network: ClientNetworkError
1170
+ timeout: ClientTimeoutError
1171
+ aborted: ClientAbortError
1172
+ parse: ClientParseError
1173
+ usage: ClientPathParamError
1174
+ unknown: unknown
1175
+ }
1176
+
1177
+ /** Distributed union of every framework failure kind. */
1178
+ export type FrameworkFailure = {
1179
+ [K in keyof ClientErrorMap]: { ok: false; kind: K; error: ClientErrorMap[K] }
1180
+ }[keyof ClientErrorMap]
1181
+
1182
+ /**
1183
+ * Discriminated result type for the `.safe()` form. `kind: 'typed'` carries
1184
+ * route-declared errors (registry-dispatched); other kinds carry framework
1185
+ * failures. Use `ResultNoTyped<T>` for routes without declared errors.
1186
+ */
1187
+ export type Result<T, ETyped> =
1188
+ | { ok: true; value: T }
1189
+ | { ok: false; kind: 'typed'; error: ETyped }
1190
+ | FrameworkFailure
1191
+
1192
+ /**
1193
+ * `Result` for routes that don't declare typed errors. Omits the `'typed'`
1194
+ * arm entirely so IDE hovers stay clean (TS doesn't collapse `never`-payload
1195
+ * arms in tooltip output).
1196
+ */
1197
+ export type ResultNoTyped<T> =
1198
+ | { ok: true; value: T }
1199
+ | FrameworkFailure
1200
+ ```
1201
+
1202
+ - [ ] **Step 2: Create `src/client/result-type.test-d.ts`**
1203
+
1204
+ Type-level test using `expectTypeOf` from vitest:
1205
+
1206
+ ```ts
1207
+ import { describe, it, expectTypeOf } from 'vitest'
1208
+ import type {
1209
+ Result,
1210
+ ResultNoTyped,
1211
+ ClientErrorMap,
1212
+ } from './types.js'
1213
+ import type {
1214
+ ClientHttpError,
1215
+ ClientNetworkError,
1216
+ ClientTimeoutError,
1217
+ ClientAbortError,
1218
+ ClientParseError,
1219
+ ClientPathParamError,
1220
+ } from './errors.js'
1221
+
1222
+ describe('Result type', () => {
1223
+ it('includes ok=true with value', () => {
1224
+ type R = Result<number, Error>
1225
+ type Ok = Extract<R, { ok: true }>
1226
+ expectTypeOf<Ok['value']>().toEqualTypeOf<number>()
1227
+ })
1228
+
1229
+ it('includes typed kind with ETyped error', () => {
1230
+ class MyTyped extends Error {}
1231
+ type R = Result<number, MyTyped>
1232
+ type Typed = Extract<R, { kind: 'typed' }>
1233
+ expectTypeOf<Typed['error']>().toEqualTypeOf<MyTyped>()
1234
+ })
1235
+
1236
+ it('includes every default framework kind', () => {
1237
+ type R = Result<number, Error>
1238
+ expectTypeOf<Extract<R, { kind: 'http' }>['error']>().toEqualTypeOf<ClientHttpError>()
1239
+ expectTypeOf<Extract<R, { kind: 'network' }>['error']>().toEqualTypeOf<ClientNetworkError>()
1240
+ expectTypeOf<Extract<R, { kind: 'timeout' }>['error']>().toEqualTypeOf<ClientTimeoutError>()
1241
+ expectTypeOf<Extract<R, { kind: 'aborted' }>['error']>().toEqualTypeOf<ClientAbortError>()
1242
+ expectTypeOf<Extract<R, { kind: 'parse' }>['error']>().toEqualTypeOf<ClientParseError>()
1243
+ expectTypeOf<Extract<R, { kind: 'usage' }>['error']>().toEqualTypeOf<ClientPathParamError>()
1244
+ expectTypeOf<Extract<R, { kind: 'unknown' }>['error']>().toEqualTypeOf<unknown>()
1245
+ })
1246
+ })
1247
+
1248
+ describe('ResultNoTyped', () => {
1249
+ it('omits the typed arm', () => {
1250
+ type R = ResultNoTyped<number>
1251
+ type Typed = Extract<R, { kind: 'typed' }>
1252
+ expectTypeOf<Typed>().toEqualTypeOf<never>()
1253
+ })
1254
+ })
1255
+ ```
1256
+
1257
+ - [ ] **Step 3: Run type-test**
1258
+
1259
+ ```bash
1260
+ npx vitest run src/client/result-type.test-d.ts
1261
+ ```
1262
+ Expected: PASS (vitest runs `.test-d.ts` files; `expectTypeOf` is type-checked at build time).
1263
+
1264
+ - [ ] **Step 4: Re-export from `src/client/index.ts`**
1265
+
1266
+ Add:
1267
+ ```ts
1268
+ export type {
1269
+ ClientErrorMap,
1270
+ FrameworkFailure,
1271
+ Result,
1272
+ ResultNoTyped,
1273
+ } from './types.js'
1274
+ ```
1275
+
1276
+ - [ ] **Step 5: Commit**
1277
+
1278
+ ```bash
1279
+ git add src/client/types.ts src/client/result-type.test-d.ts src/client/index.ts
1280
+ git commit -m "feat(client/types): add Result, ResultNoTyped, ClientErrorMap"
1281
+ ```
1282
+
1283
+ ### Task 10: Augmentation type-test
1284
+
1285
+ **Files:**
1286
+ - Create: `src/client/augment-error-map.test-d.ts`
1287
+
1288
+ - [ ] **Step 1: Write the augmentation test**
1289
+
1290
+ ```ts
1291
+ import { describe, it, expectTypeOf } from 'vitest'
1292
+ import type { Result } from './types.js'
1293
+
1294
+ class RateLimitError extends Error {
1295
+ constructor(public retryAfter: number) { super('rate limited') }
1296
+ }
1297
+
1298
+ declare module './types.js' {
1299
+ interface ClientErrorMap {
1300
+ rateLimited: RateLimitError
1301
+ }
1302
+ }
1303
+
1304
+ describe('ClientErrorMap augmentation', () => {
1305
+ it('adds rateLimited kind to Result', () => {
1306
+ type R = Result<number, Error>
1307
+ type RateLimited = Extract<R, { kind: 'rateLimited' }>
1308
+ expectTypeOf<RateLimited['error']>().toEqualTypeOf<RateLimitError>()
1309
+ })
1310
+ })
1311
+ ```
1312
+
1313
+ - [ ] **Step 2: Run to verify pass**
1314
+
1315
+ ```bash
1316
+ npx vitest run src/client/augment-error-map.test-d.ts
1317
+ ```
1318
+
1319
+ - [ ] **Step 3: Commit**
1320
+
1321
+ ```bash
1322
+ git add src/client/augment-error-map.test-d.ts
1323
+ git commit -m "test(client): verify ClientErrorMap augmentation widens Result"
1324
+ ```
1325
+
1326
+ ---
1327
+
1328
+ ## Phase 5 — `executeSafeCall` and `ClientInstance.safeCall`
1329
+
1330
+ **Phase dependencies:** Requires Phase 3 (classifier wired into `executeCall` — Tasks 5, 6) and Phase 4 (Result types — Tasks 8.5, 9, 10) complete. Do not start Task 11 until both phases are committed.
1331
+
1332
+ ### Task 11: Implement `executeSafeCall`
1333
+
1334
+ **Files:**
1335
+ - Modify: `src/client/call.ts`
1336
+ - Modify: `src/client/types.ts`
1337
+ - Create: `src/client/safe-call.test.ts`
1338
+ - Modify: `src/client/index.ts` (re-import as needed)
1339
+
1340
+ - [ ] **Step 1: Extend `ClientInstance` interface**
1341
+
1342
+ In `src/client/types.ts`:
1343
+
1344
+ ```ts
1345
+ export interface ClientInstance {
1346
+ // ... existing ...
1347
+ safeCall<TResponse, ETyped = never>(
1348
+ descriptor: CallDescriptor,
1349
+ options?: ProcedureCallOptions,
1350
+ ): Promise<Result<TResponse, ETyped>>
1351
+ }
1352
+ ```
1353
+
1354
+ - [ ] **Step 2: Write failing tests**
1355
+
1356
+ Create `src/client/safe-call.test.ts`:
1357
+
1358
+ ```ts
1359
+ import { describe, it, expect, vi } from 'vitest'
1360
+ import { executeSafeCall } from './call.js'
1361
+ import {
1362
+ ClientHttpError,
1363
+ ClientNetworkError,
1364
+ ClientTimeoutError,
1365
+ ClientAbortError,
1366
+ ClientPathParamError,
1367
+ } from './errors.js'
1368
+ import type { ClientAdapter, CallDescriptor } from './types.js'
1369
+
1370
+ const baseDescriptor: CallDescriptor = {
1371
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'GET',
1372
+ kind: 'rpc', params: { id: '42' },
1373
+ }
1374
+
1375
+ const ok = (body: unknown): ClientAdapter => ({
1376
+ request: vi.fn(async () => ({ status: 200, headers: {}, body })),
1377
+ stream: vi.fn(async () => { throw new Error('n/a') }),
1378
+ })
1379
+
1380
+ const failWith = (err: unknown): ClientAdapter => ({
1381
+ request: vi.fn(async () => { throw err }),
1382
+ stream: vi.fn(async () => { throw new Error('n/a') }),
1383
+ })
1384
+
1385
+ describe('executeSafeCall', () => {
1386
+ it('returns ok=true with value on success', async () => {
1387
+ const r = await executeSafeCall({
1388
+ descriptor: baseDescriptor,
1389
+ basePath: 'https://api.x',
1390
+ adapter: ok({ id: '42' }),
1391
+ hooks: {},
1392
+ })
1393
+ expect(r.ok).toBe(true)
1394
+ if (r.ok) expect(r.value).toEqual({ id: '42' })
1395
+ })
1396
+
1397
+ it("returns kind='http' on non-2xx without registry match", async () => {
1398
+ const r = await executeSafeCall({
1399
+ descriptor: baseDescriptor,
1400
+ basePath: 'https://api.x',
1401
+ adapter: {
1402
+ request: vi.fn(async () => ({ status: 500, headers: {}, body: { msg: 'oops' } })),
1403
+ stream: vi.fn(async () => { throw new Error('n/a') }),
1404
+ },
1405
+ hooks: {},
1406
+ })
1407
+ expect(r.ok).toBe(false)
1408
+ if (!r.ok) {
1409
+ expect(r.kind).toBe('http')
1410
+ expect(r.error).toBeInstanceOf(ClientHttpError)
1411
+ }
1412
+ })
1413
+
1414
+ it("returns kind='network' on TypeError", async () => {
1415
+ const r = await executeSafeCall({
1416
+ descriptor: baseDescriptor,
1417
+ basePath: 'https://api.x',
1418
+ adapter: failWith(new TypeError('Failed to fetch')),
1419
+ hooks: {},
1420
+ })
1421
+ expect(r.ok).toBe(false)
1422
+ if (!r.ok) {
1423
+ expect(r.kind).toBe('network')
1424
+ expect(r.error).toBeInstanceOf(ClientNetworkError)
1425
+ }
1426
+ })
1427
+
1428
+ it("returns kind='timeout' when timeout fires", async () => {
1429
+ const r = await executeSafeCall({
1430
+ descriptor: baseDescriptor,
1431
+ basePath: 'https://api.x',
1432
+ adapter: failWith(new DOMException('aborted', 'AbortError')),
1433
+ hooks: {},
1434
+ options: { timeout: 1 },
1435
+ })
1436
+ if (!r.ok) {
1437
+ expect(r.kind).toBe('timeout')
1438
+ expect(r.error).toBeInstanceOf(ClientTimeoutError)
1439
+ }
1440
+ })
1441
+
1442
+ it("returns kind='aborted' when user signal fires", async () => {
1443
+ const ctrl = new AbortController()
1444
+ ctrl.abort('user')
1445
+ const r = await executeSafeCall({
1446
+ descriptor: baseDescriptor,
1447
+ basePath: 'https://api.x',
1448
+ adapter: failWith(new DOMException('aborted', 'AbortError')),
1449
+ hooks: {},
1450
+ options: { signal: ctrl.signal },
1451
+ })
1452
+ if (!r.ok) {
1453
+ expect(r.kind).toBe('aborted')
1454
+ expect(r.error).toBeInstanceOf(ClientAbortError)
1455
+ }
1456
+ })
1457
+
1458
+ it("returns kind='usage' on pre-adapter ClientPathParamError, does NOT invoke onError", async () => {
1459
+ // Spec contract: pre-adapter usage errors bypass the classifier AND the
1460
+ // onError hook entirely. Path 1 of the three failure-source paths.
1461
+ const seen: unknown[] = []
1462
+ const r = await executeSafeCall({
1463
+ // path requires :id but params don't supply it
1464
+ descriptor: { ...baseDescriptor, params: {} },
1465
+ basePath: 'https://api.x',
1466
+ adapter: ok({}),
1467
+ hooks: { onError: ({ error }) => { seen.push(error) } },
1468
+ })
1469
+ if (!r.ok) {
1470
+ expect(r.kind).toBe('usage')
1471
+ expect(r.error).toBeInstanceOf(ClientPathParamError)
1472
+ }
1473
+ // onError must NOT fire — this is by design (per spec architectural decision #1).
1474
+ expect(seen).toEqual([])
1475
+ })
1476
+
1477
+ it("returns kind='unknown' for unrecognized throws", async () => {
1478
+ const r = await executeSafeCall({
1479
+ descriptor: baseDescriptor,
1480
+ basePath: 'https://api.x',
1481
+ adapter: failWith('weird-string-throw'),
1482
+ hooks: {},
1483
+ })
1484
+ if (!r.ok) {
1485
+ expect(r.kind).toBe('unknown')
1486
+ expect(r.error).toBe('weird-string-throw')
1487
+ }
1488
+ })
1489
+
1490
+ it('invokes onError hook on failure (cross-cutting telemetry)', async () => {
1491
+ const seen: unknown[] = []
1492
+ const r = await executeSafeCall({
1493
+ descriptor: baseDescriptor,
1494
+ basePath: 'https://api.x',
1495
+ adapter: failWith(new TypeError('x')),
1496
+ hooks: { onError: ({ error }) => { seen.push(error) } },
1497
+ })
1498
+ expect(r.ok).toBe(false)
1499
+ expect(seen[0]).toBeInstanceOf(ClientNetworkError)
1500
+ })
1501
+ })
1502
+ ```
1503
+
1504
+ - [ ] **Step 3: Run to verify fail**
1505
+
1506
+ ```bash
1507
+ npx vitest run src/client/safe-call.test.ts
1508
+ ```
1509
+ Expected: FAIL — `executeSafeCall` does not exist.
1510
+
1511
+ - [ ] **Step 4: Implement `executeSafeCall`**
1512
+
1513
+ Add to `src/client/call.ts`:
1514
+
1515
+ ```ts
1516
+ import type { Result, ClientErrorMap, FrameworkFailure } from './types.js'
1517
+ import {
1518
+ ClientHttpError,
1519
+ ClientNetworkError,
1520
+ ClientTimeoutError,
1521
+ ClientAbortError,
1522
+ ClientParseError,
1523
+ ClientPathParamError,
1524
+ } from './errors.js'
1525
+
1526
+ /**
1527
+ * Wraps `executeCall` and returns a discriminated `Result` instead of throwing.
1528
+ *
1529
+ * Three failure-source paths map to distinct kinds:
1530
+ * 1. Pre-adapter throw (e.g. `ClientPathParamError`) → `kind: 'usage'`
1531
+ * 2. Adapter throw, classified → `kind: 'network' | 'timeout' | 'aborted' | <custom> | 'unknown'`
1532
+ * 3. Adapter returns non-2xx → `kind: 'typed'` (registry match) or `kind: 'http'`
1533
+ *
1534
+ * `onError` hook fires on every failure path (cross-cutting telemetry).
1535
+ */
1536
+ export async function executeSafeCall<TResponse, ETyped = never>(
1537
+ config: ExecuteCallConfig,
1538
+ ): Promise<Result<TResponse, ETyped>> {
1539
+ try {
1540
+ const value = await executeCall<TResponse>(config)
1541
+ return { ok: true, value }
1542
+ } catch (err) {
1543
+ return classifyThrownError<ETyped>(err)
1544
+ }
1545
+ }
1546
+
1547
+ function classifyThrownError<ETyped>(err: unknown): Result<never, ETyped> {
1548
+ // Path 1: pre-adapter usage
1549
+ if (err instanceof ClientPathParamError) {
1550
+ return { ok: false, kind: 'usage', error: err }
1551
+ }
1552
+ // Path 3: post-status-check (registry or fallback)
1553
+ if (err instanceof ClientHttpError) {
1554
+ return { ok: false, kind: 'http', error: err }
1555
+ }
1556
+ // Path 2: classifier output (already normalized by executeCall)
1557
+ if (err instanceof ClientNetworkError) {
1558
+ return { ok: false, kind: 'network', error: err }
1559
+ }
1560
+ if (err instanceof ClientTimeoutError) {
1561
+ return { ok: false, kind: 'timeout', error: err }
1562
+ }
1563
+ if (err instanceof ClientAbortError) {
1564
+ return { ok: false, kind: 'aborted', error: err }
1565
+ }
1566
+ if (err instanceof ClientParseError) {
1567
+ return { ok: false, kind: 'parse', error: err }
1568
+ }
1569
+ // Registry-dispatched typed error (any other Error subclass thrown by the registry)
1570
+ if (err instanceof Error && (err as { __tsProceduresTyped?: boolean }).__tsProceduresTyped) {
1571
+ return { ok: false, kind: 'typed', error: err as ETyped }
1572
+ }
1573
+ // Custom adapter-classified error — has a known kind tag.
1574
+ // The cast is intentional: the framework knows only the default ClientErrorMap
1575
+ // keys, but consumer-augmented kinds are valid at the consumer's site (where
1576
+ // the augmented map is in scope). The runtime kind string is whatever the
1577
+ // adapter classifier returned; we trust it to match a registered entry.
1578
+ if (err instanceof Error && typeof (err as { __tsProceduresKind?: string }).__tsProceduresKind === 'string') {
1579
+ const kind = (err as { __tsProceduresKind: string }).__tsProceduresKind
1580
+ return { ok: false, kind: kind as keyof ClientErrorMap, error: err } as FrameworkFailure
1581
+ }
1582
+ // Fallthrough
1583
+ return { ok: false, kind: 'unknown', error: err }
1584
+ }
1585
+ ```
1586
+
1587
+ **Note on the `__tsProceduresTyped` / `__tsProceduresKind` tagging:** the simplest way for `executeSafeCall` to distinguish "a generated typed error class" from "a custom-classified error" from "a fallthrough" is for the call-site that *threw* to mark the error before re-throwing. The tags are deliberate internal implementation details — add a brief comment on each tag site explaining that they exist solely to bridge information from the throw-site to `classifyThrownError` without re-doing classification work. Do NOT add tagging to `executeStream` (Task 7) "for symmetry" — streams have no `.safe` form, so `classifyThrownError` is never called on stream-thrown errors and the tags would be inert overhead. Two changes follow:
1588
+
1589
+ (a) In `executeCall`, when the registry dispatches a typed error, tag it:
1590
+ ```ts
1591
+ if (typed) {
1592
+ ;(typed as { __tsProceduresTyped?: boolean }).__tsProceduresTyped = true
1593
+ throw typed
1594
+ }
1595
+ ```
1596
+
1597
+ (b) In `executeCall`, when the classifier returns a custom kind (not one of the default kinds), tag with the kind:
1598
+ ```ts
1599
+ if (classified) {
1600
+ const defaultKinds = new Set(['network', 'timeout', 'aborted'])
1601
+ if (!defaultKinds.has(classified.kind)) {
1602
+ ;(classified.error as { __tsProceduresKind?: string }).__tsProceduresKind = classified.kind
1603
+ }
1604
+ // Hooks see normalized error, then throw
1605
+ await runOnError(...)
1606
+ throw classified.error
1607
+ }
1608
+ ```
1609
+
1610
+ Update `executeCall` accordingly. (This is a behavior detail not present in the spec — we picked the simplest reliable mechanism. Document the field as internal in the source.)
1611
+
1612
+ - [ ] **Step 5: Run tests to verify pass**
1613
+
1614
+ ```bash
1615
+ npx vitest run src/client/safe-call.test.ts
1616
+ ```
1617
+ Expected: PASS.
1618
+
1619
+ - [ ] **Step 6: Wire `executeSafeCall` into `ClientInstance` via `createClient`**
1620
+
1621
+ **Read `src/client/index.ts` first** — the `createClient` factory composes its `call` closure from several local bindings (`basePath`, `adapter`, `hooks`, `globalDefaults`, optional `errorRegistry`). The `safeCall` wiring must reuse exactly the same bindings so behavior is identical except for the throw-vs-return difference.
1622
+
1623
+ ```bash
1624
+ sed -n '1,90p' src/client/index.ts
1625
+ ```
1626
+
1627
+ Add `safeCall` on the returned `ClientInstance` mirroring the existing `call` wiring (use the same field names that `createClient` actually uses — the snippet below uses generic placeholders; substitute them):
1628
+
1629
+ ```ts
1630
+ return {
1631
+ // ... existing fields and call(...) ...
1632
+ safeCall: <TResponse, ETyped = never>(descriptor, options) =>
1633
+ executeSafeCall<TResponse, ETyped>({
1634
+ descriptor,
1635
+ basePath, // ← whatever local binding holds the resolved basePath
1636
+ adapter,
1637
+ hooks,
1638
+ defaults: globalDefaults, // ← whatever local binding holds the defaults
1639
+ options,
1640
+ errorRegistry,
1641
+ }),
1642
+ }
1643
+ ```
1644
+
1645
+ The TypeScript type on `ClientInstance.safeCall` (added in Step 1) requires `safeCall<TResponse, ETyped = never>(...)`. Match the existing `call` signature shape exactly so generated callables can call either uniformly.
1646
+
1647
+ - [ ] **Step 7: Run full client suite**
1648
+
1649
+ ```bash
1650
+ npx vitest run src/client/
1651
+ ```
1652
+ Expected: PASS.
1653
+
1654
+ - [ ] **Step 8: Commit**
1655
+
1656
+ ```bash
1657
+ git add src/client/call.ts src/client/types.ts src/client/index.ts src/client/safe-call.test.ts
1658
+ git commit -m "feat(client): add executeSafeCall and ClientInstance.safeCall
1659
+
1660
+ Returns a discriminated Result<T, E> instead of throwing. Three
1661
+ failure-source paths map to distinct kinds (usage / classifier /
1662
+ http-or-typed). onError fires on every failure (cross-cutting
1663
+ telemetry; consumers suppress per-call for retry loops)."
1664
+ ```
1665
+
1666
+ ---
1667
+
1668
+ ## Phase 6 — Codegen: emit `.safe` sibling
1669
+
1670
+ ### Task 12: Update `emit-scope.ts` for RPC routes
1671
+
1672
+ **Files:**
1673
+ - Modify: `src/codegen/emit-scope.ts`
1674
+ - Test: `src/codegen/emit-scope.test.ts`
1675
+
1676
+ - [ ] **Step 1: Read `emit-scope.test.ts` to understand existing test fixtures**
1677
+
1678
+ ```bash
1679
+ cat src/codegen/emit-scope.test.ts | head -80
1680
+ ```
1681
+
1682
+ - [ ] **Step 2: Write failing test**
1683
+
1684
+ Add to `src/codegen/emit-scope.test.ts`:
1685
+
1686
+ ```ts
1687
+ describe('emitScopeFile .safe sibling', () => {
1688
+ it('emits .safe property on RPC callable when route has errors', async () => {
1689
+ const group = makeGroupWithRpcRoute({ name: 'GetUser', errors: ['NotFound'] })
1690
+ const out = await emitScopeFile(group, { errorKeys: new Set(['NotFound']) })
1691
+ expect(out).toContain('GetUser.safe = ')
1692
+ expect(out).toMatch(/Promise<Result<.*GetUser\.Response, .*GetUser\.Errors>>/)
1693
+ })
1694
+
1695
+ it('emits .safe with ResultNoTyped when route has no errors', async () => {
1696
+ const group = makeGroupWithRpcRoute({ name: 'GetUser', errors: [] })
1697
+ const out = await emitScopeFile(group, {})
1698
+ expect(out).toContain('GetUser.safe = ')
1699
+ expect(out).toMatch(/Promise<ResultNoTyped<.*GetUser\.Response>>/)
1700
+ expect(out).not.toContain('Errors')
1701
+ })
1702
+ })
1703
+ ```
1704
+
1705
+ (The `makeGroupWithRpcRoute` helper may need to be added; check existing patterns and add minimally.)
1706
+
1707
+ - [ ] **Step 3: Run to verify fail**
1708
+
1709
+ ```bash
1710
+ npx vitest run src/codegen/emit-scope.test.ts -t '.safe sibling'
1711
+ ```
1712
+
1713
+ - [ ] **Step 4: Update `emitRpcRoute` in `src/codegen/emit-scope.ts`**
1714
+
1715
+ Locate the existing callable emission for RPC (around `src/codegen/emit-scope.ts:249-261`). Replace with:
1716
+
1717
+ ```ts
1718
+ // IMPORTANT: hasErrors must reflect the *filtered* error union (post-errorKeys
1719
+ // filter), not raw route.errors.length. Otherwise we may emit
1720
+ // Result<Response, Foo.Errors> when no Foo.Errors namespace member was
1721
+ // emitted (because all keys lacked schemas) — producing a compile error in
1722
+ // the generated client. Use the same buildErrorUnion result that
1723
+ // injectRouteErrors uses below.
1724
+ const errorUnion = buildErrorUnion(route.errors, ctx)
1725
+ const hasErrors = errorUnion !== null
1726
+ const errorsRef = ctx.namespaceTypes
1727
+ ? `${ctx.scopePascal}.${pascal}.Errors`
1728
+ : `${pascal}Errors`
1729
+ const resultType = hasErrors
1730
+ ? `Result<${responseTypeName}, ${errorsRef}>`
1731
+ : `ResultNoTyped<${responseTypeName}>`
1732
+
1733
+ const callable = [
1734
+ ` /** ${route.method.toUpperCase()} ${route.path} */`,
1735
+ ` ${pascal}: Object.assign(`,
1736
+ ` function ${pascal}(params: ${paramsTypeName}, options?: ProcedureCallOptions): Promise<${responseTypeName}> {`,
1737
+ ` return client.call<${responseTypeName}>({`,
1738
+ ` name: '${pascal}',`,
1739
+ ` scope: '${scopeStr}',`,
1740
+ ` path: '${route.path}',`,
1741
+ ` method: '${route.method}',`,
1742
+ ` kind: 'rpc',`,
1743
+ ` params,`,
1744
+ ` }, options)`,
1745
+ ` },`,
1746
+ ` {`,
1747
+ ` safe(params: ${paramsTypeName}, options?: ProcedureCallOptions): Promise<${resultType}> {`,
1748
+ ` return client.safeCall<${responseTypeName}${hasErrors ? `, ${errorsRef}` : ''}>({`,
1749
+ ` name: '${pascal}',`,
1750
+ ` scope: '${scopeStr}',`,
1751
+ ` path: '${route.path}',`,
1752
+ ` method: '${route.method}',`,
1753
+ ` kind: 'rpc',`,
1754
+ ` params,`,
1755
+ ` }, options)`,
1756
+ ` },`,
1757
+ ` },`,
1758
+ ` ),`,
1759
+ ].join('\n')
1760
+ ```
1761
+
1762
+ Pass the precomputed `errorUnion` into `injectRouteErrors` (instead of recomputing) so we don't call `buildErrorUnion` twice for the same route:
1763
+
1764
+ ```ts
1765
+ // Replace the existing call:
1766
+ // const hasErrors = injectRouteErrors(declarations, pascal, buildErrorUnion(route.errors, ctx), ctx.namespaceTypes)
1767
+ // With:
1768
+ const hasErrorsInjected = injectRouteErrors(declarations, pascal, errorUnion, ctx.namespaceTypes)
1769
+ return { typeDeclarations: declarations, callable, hasStream: false, hasErrors: hasErrorsInjected }
1770
+ ```
1771
+
1772
+ (`hasErrors` and `hasErrorsInjected` are equivalent here — `injectRouteErrors` returns `false` precisely when `errorUnion` is null. Use whichever name reads cleanest in the final code.)
1773
+
1774
+ Update the imports block at the top of the generated scope file to include `Result` / `ResultNoTyped`:
1775
+
1776
+ ```ts
1777
+ const clientImports = hasStream
1778
+ ? `import type { ClientInstance, ProcedureCallOptions, TypedStream, Result, ResultNoTyped } from '${clientImportPath}'`
1779
+ : `import type { ClientInstance, ProcedureCallOptions, Result, ResultNoTyped } from '${clientImportPath}'`
1780
+ ```
1781
+
1782
+ (Drop `Result`/`ResultNoTyped` from the import set if no callable in the scope used them — left as an optimization for the writing-plans review pass; safe to always include.)
1783
+
1784
+ - [ ] **Step 5: Run tests to verify pass**
1785
+
1786
+ ```bash
1787
+ npx vitest run src/codegen/emit-scope.test.ts
1788
+ ```
1789
+
1790
+ - [ ] **Step 6: Commit**
1791
+
1792
+ ```bash
1793
+ git add src/codegen/emit-scope.ts src/codegen/emit-scope.test.ts
1794
+ git commit -m "feat(codegen): emit .safe sibling on RPC callables"
1795
+ ```
1796
+
1797
+ ### Task 13: Update `emit-scope.ts` for API routes
1798
+
1799
+ **Files:** same as Task 12.
1800
+
1801
+ - [ ] **Step 1: Write failing test for API route .safe emission**
1802
+
1803
+ Mirror the Task 12 test for an API route (use `makeGroupWithApiRoute` or analogous helper).
1804
+
1805
+ - [ ] **Step 2: Update `emitApiRoute` with the same `Object.assign(fn, { safe })` pattern**
1806
+
1807
+ Apply the same changes from Task 12 Step 4 inside `emitApiRoute` (around `src/codegen/emit-scope.ts:326-338`).
1808
+
1809
+ - [ ] **Step 3: Run tests to verify pass**
1810
+
1811
+ ```bash
1812
+ npx vitest run src/codegen/emit-scope.test.ts
1813
+ ```
1814
+
1815
+ - [ ] **Step 4: Commit**
1816
+
1817
+ ```bash
1818
+ git add src/codegen/emit-scope.ts src/codegen/emit-scope.test.ts
1819
+ git commit -m "feat(codegen): emit .safe sibling on API callables"
1820
+ ```
1821
+
1822
+ ### Task 14: Confirm streams remain unchanged (no `.safe` emission)
1823
+
1824
+ **Files:**
1825
+ - Test: `src/codegen/emit-scope.test.ts`
1826
+
1827
+ - [ ] **Step 1: Add a test asserting stream callables do NOT have a `.safe` sibling**
1828
+
1829
+ ```ts
1830
+ it('does not emit .safe on stream callables', async () => {
1831
+ const group = makeGroupWithStreamRoute({ name: 'StreamUsers' })
1832
+ const out = await emitScopeFile(group, {})
1833
+ expect(out).toContain('StreamUsers(')
1834
+ expect(out).not.toContain('StreamUsers.safe')
1835
+ // Stream callables stay as plain functions — no Object.assign wrapper.
1836
+ expect(out).not.toMatch(/StreamUsers:\s*Object\.assign/)
1837
+ })
1838
+ ```
1839
+
1840
+ - [ ] **Step 2: Run to verify pass (no implementation change required if Tasks 12-13 only touched RPC/API)**
1841
+
1842
+ ```bash
1843
+ npx vitest run src/codegen/emit-scope.test.ts -t 'stream'
1844
+ ```
1845
+
1846
+ - [ ] **Step 3: Commit**
1847
+
1848
+ ```bash
1849
+ git add src/codegen/emit-scope.test.ts
1850
+ git commit -m "test(codegen): confirm stream callables omit .safe sibling"
1851
+ ```
1852
+
1853
+ ---
1854
+
1855
+ ## Phase 7 — Self-contained bundling
1856
+
1857
+ ### Task 15: Update self-contained bundler with new types and runtime file
1858
+
1859
+ **Files:**
1860
+ - Modify: `src/codegen/emit-client-runtime.ts`
1861
+ - Test: `src/codegen/emit-client-runtime.test.ts` (extend, or add if absent)
1862
+
1863
+ The self-contained bundler in `emit-client-runtime.ts` has two **hard-coded** lists that must be extended for the new types and runtime to flow through to consumers' bundled `_types.ts` / `_client.ts`:
1864
+
1865
+ 1. `TYPES_IMPORT` (around lines 6–25) — the inline import list at the top of the bundled `_client.ts`. Must include every new type from `src/client/types.ts` that the runtime references.
1866
+ 2. `SOURCE_FILES` (around lines 32–42) — the ordered list of `src/client/*.ts` files concatenated into the bundle. Must include `classify-error.ts`.
1867
+
1868
+ - [ ] **Step 1: Read `emit-client-runtime.ts` and confirm the hard-coded lists**
1869
+
1870
+ ```bash
1871
+ sed -n '1,50p' src/codegen/emit-client-runtime.ts
1872
+ ```
1873
+
1874
+ - [ ] **Step 2: Write a failing integration test**
1875
+
1876
+ Create or extend `src/codegen/emit-client-runtime.test.ts`:
1877
+
1878
+ ```ts
1879
+ import { describe, it, expect } from 'vitest'
1880
+ import { generateClient } from './index.js'
1881
+ import * as path from 'node:path'
1882
+ import * as os from 'node:os'
1883
+ import * as fs from 'node:fs/promises'
1884
+
1885
+ describe('self-contained bundling — safe-result symbols', () => {
1886
+ it('includes Result, ResultNoTyped, ClientErrorMap, FrameworkFailure in _client.ts', async () => {
1887
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'tsproc-self-'))
1888
+ await generateClient({
1889
+ envelope: { service: 'Api', routes: [], errors: [] },
1890
+ outDir: tmp,
1891
+ selfContained: true,
1892
+ })
1893
+ const client = await fs.readFile(path.join(tmp, '_client.ts'), 'utf-8')
1894
+ expect(client).toMatch(/Result\b/)
1895
+ expect(client).toMatch(/ResultNoTyped\b/)
1896
+ expect(client).toMatch(/ClientErrorMap\b/)
1897
+ expect(client).toMatch(/FrameworkFailure\b/)
1898
+ expect(client).toMatch(/defaultClassifyError\b/)
1899
+ expect(client).toMatch(/ClientNetworkError\b/)
1900
+ expect(client).toMatch(/ClientTimeoutError\b/)
1901
+ expect(client).toMatch(/ClientAbortError\b/)
1902
+ expect(client).toMatch(/ClientParseError\b/)
1903
+ expect(client).toMatch(/ClientHttpError\b/)
1904
+ expect(client).toMatch(/executeSafeCall\b/)
1905
+ })
1906
+
1907
+ it('bundled _client.ts type-checks under tsc --noEmit', async () => {
1908
+ // Confirms the bundled file is importable and self-consistent — would fail
1909
+ // if TYPES_IMPORT is missing a name the runtime references.
1910
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'tsproc-tsc-'))
1911
+ await generateClient({
1912
+ envelope: { service: 'Api', routes: [], errors: [] },
1913
+ outDir: tmp,
1914
+ selfContained: true,
1915
+ })
1916
+ // Write a minimal tsconfig + invoke tsc; or shell out to tsc with --noEmit
1917
+ // pointed at the temp dir. (Shape this against existing project conventions
1918
+ // for child-process tsc invocations, if any.)
1919
+ const { execSync } = await import('node:child_process')
1920
+ expect(() =>
1921
+ execSync(`npx tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext ${path.join(tmp, '_client.ts')} ${path.join(tmp, '_types.ts')}`, { stdio: 'pipe' })
1922
+ ).not.toThrow()
1923
+ })
1924
+ })
1925
+ ```
1926
+
1927
+ - [ ] **Step 3: Run to verify fail**
1928
+
1929
+ ```bash
1930
+ npx vitest run src/codegen/emit-client-runtime.test.ts
1931
+ ```
1932
+
1933
+ - [ ] **Step 4: Update `TYPES_IMPORT` in `src/codegen/emit-client-runtime.ts`**
1934
+
1935
+ Add the new symbols:
1936
+
1937
+ ```ts
1938
+ const TYPES_IMPORT = `import type {
1939
+ ClientAdapter,
1940
+ AdapterRequest,
1941
+ AdapterResponse,
1942
+ AdapterStreamResponse,
1943
+ ClientHooks,
1944
+ BeforeRequestContext,
1945
+ AfterResponseContext,
1946
+ ErrorContext,
1947
+ CallDescriptor,
1948
+ StreamDescriptor,
1949
+ TypedStream,
1950
+ ClientInstance,
1951
+ ProcedureCallDefaults,
1952
+ ProcedureCallOptions,
1953
+ CreateClientConfig,
1954
+ RequestMeta,
1955
+ ErrorRegistry,
1956
+ ErrorFactory,
1957
+ ErrorResponseMeta,
1958
+ ClientErrorMap,
1959
+ FrameworkFailure,
1960
+ Result,
1961
+ ResultNoTyped,
1962
+ ErrorClassifier,
1963
+ ClassifyErrorContext,
1964
+ ClassifiedError,
1965
+ } from './_types'`
1966
+ ```
1967
+
1968
+ - [ ] **Step 5: Update `SOURCE_FILES` to include `classify-error.ts`**
1969
+
1970
+ ```ts
1971
+ const SOURCE_FILES = [
1972
+ 'errors.ts',
1973
+ 'classify-error.ts', // ← new — must come before call.ts which imports it
1974
+ 'error-dispatch.ts',
1975
+ 'request-builder.ts',
1976
+ 'resolve-options.ts',
1977
+ 'hooks.ts',
1978
+ 'call.ts',
1979
+ 'stream.ts',
1980
+ 'fetch-adapter.ts',
1981
+ 'index.ts',
1982
+ ] as const
1983
+ ```
1984
+
1985
+ - [ ] **Step 6: Confirm the `_types.ts` bundling pipeline emits the new types**
1986
+
1987
+ `_types.ts` is generated by re-exporting from `src/client/types.ts`. Since Task 9 already added the new types to `types.ts`, they should flow through automatically. Verify by reading the relevant emitter (likely `emit-types-bundle.ts` or similar — find via `grep -n '_types.ts' src/codegen/ -r`). If the bundler uses an explicit re-export list, extend it the same way.
1988
+
1989
+ - [ ] **Step 7: Run tests to verify pass**
1990
+
1991
+ ```bash
1992
+ npx vitest run src/codegen/emit-client-runtime.test.ts
1993
+ ```
1994
+
1995
+ - [ ] **Step 8: Commit**
1996
+
1997
+ ```bash
1998
+ git add src/codegen/emit-client-runtime.ts src/codegen/emit-client-runtime.test.ts
1999
+ git commit -m "feat(codegen/self-contained): bundle Result types and classifier runtime
2000
+
2001
+ Extends TYPES_IMPORT with Result, ResultNoTyped, ClientErrorMap,
2002
+ FrameworkFailure, ErrorClassifier; adds classify-error.ts to
2003
+ SOURCE_FILES."
2004
+ ```
2005
+
2006
+ ### Task 16: Refresh golden fixture for the TS target
2007
+
2008
+ **Files:**
2009
+ - Modify: the golden output file used by the TS-target codegen integration test
2010
+
2011
+ - [ ] **Step 1: Identify the golden fixture file**
2012
+
2013
+ ```bash
2014
+ find src/codegen -name '*golden*' -o -name '*.golden.*' | head
2015
+ ```
2016
+
2017
+ - [ ] **Step 2: Run the integration test in update mode (or hand-edit the expected output)**
2018
+
2019
+ If a snapshot-update mode exists:
2020
+ ```bash
2021
+ npx vitest run src/codegen/ -u
2022
+ ```
2023
+ Otherwise hand-edit to include the new `.safe` siblings + `Result`/`ResultNoTyped` imports.
2024
+
2025
+ - [ ] **Step 3: Inspect the diff to confirm changes match expected emission**
2026
+
2027
+ ```bash
2028
+ git diff -- src/codegen/__fixtures__/
2029
+ ```
2030
+
2031
+ - [ ] **Step 4: Run integration test**
2032
+
2033
+ ```bash
2034
+ npx vitest run src/codegen/
2035
+ ```
2036
+
2037
+ - [ ] **Step 5: Commit**
2038
+
2039
+ ```bash
2040
+ git add src/codegen/__fixtures__/<files>
2041
+ git commit -m "test(codegen): refresh golden fixture with .safe siblings"
2042
+ ```
2043
+
2044
+ ---
2045
+
2046
+ ## Phase 8 — Bundle-size budget regression test
2047
+
2048
+ ### Task 17: Add a 100-route fixture and per-route byte-cost assertion
2049
+
2050
+ **Files:**
2051
+ - Create: `src/codegen/__fixtures__/100-routes-envelope.json` (generated programmatically — see step 1)
2052
+ - Create: `src/codegen/bundle-size.test.ts`
2053
+
2054
+ - [ ] **Step 1: Generate the 100-route envelope fixture**
2055
+
2056
+ Inline the generation in the test setup (no separate fixture file) — easier to maintain:
2057
+
2058
+ ```ts
2059
+ import { describe, it, expect, beforeAll } from 'vitest'
2060
+ import { generateClient } from './index.js'
2061
+ import * as path from 'node:path'
2062
+ import * as os from 'node:os'
2063
+ import * as fs from 'node:fs/promises'
2064
+
2065
+ function makeEnvelope(routeCount: number) {
2066
+ const routes = Array.from({ length: routeCount }, (_, i) => ({
2067
+ kind: 'rpc',
2068
+ name: `Op${i}`,
2069
+ scope: 'ops',
2070
+ method: 'POST',
2071
+ path: `/ops/op${i}`,
2072
+ jsonSchema: {
2073
+ body: { type: 'object', properties: { x: { type: 'string' } } },
2074
+ response: { type: 'object', properties: { y: { type: 'string' } } },
2075
+ },
2076
+ }))
2077
+ return { service: 'BenchApi', routes, errors: [] }
2078
+ }
2079
+ ```
2080
+
2081
+ - [ ] **Step 2: Generate, minify, measure**
2082
+
2083
+ ```ts
2084
+ describe('bundle size budget', () => {
2085
+ let perRouteDelta: number
2086
+
2087
+ beforeAll(async () => {
2088
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'tsproc-bundle-'))
2089
+ await generateClient({ envelope: makeEnvelope(100), outDir: tmp, selfContained: false })
2090
+ const scopeFile = await fs.readFile(path.join(tmp, 'ops.ts'), 'utf-8')
2091
+
2092
+ // Strip whitespace/comments as a stand-in for minification (esbuild would
2093
+ // be more accurate but adds a dep; this is a stable lower-bound proxy).
2094
+ const stripped = scopeFile.replace(/\s+/g, ' ').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
2095
+ perRouteDelta = stripped.length / 100
2096
+ })
2097
+
2098
+ it('stays within 1500 chars per route post-strip', () => {
2099
+ // Initial budget — refine after first measurement. The .safe sibling adds
2100
+ // roughly 400 chars of unminified emission per route (Object.assign wrapper +
2101
+ // duplicated descriptor literal). Post-strip should land well under 1500.
2102
+ //
2103
+ // RELATIONSHIP TO SPEC TARGET: The spec sets a "200 bytes per route
2104
+ // post-minify" goal. This whitespace-strip proxy is a coarse stand-in
2105
+ // chosen to avoid an esbuild dep — actual minified bytes will be lower
2106
+ // than `perRouteDelta` here. If a future task swaps in real esbuild
2107
+ // minification, tighten the budget to ~250 bytes (200 + 25% headroom)
2108
+ // and link back to spec section "Tests / item 10". For now this guard
2109
+ // catches catastrophic regressions only, not subtle bloat.
2110
+ //
2111
+ // Record the actual measurement when the test first passes:
2112
+ // PERROUTEDELTA_BASELINE: <fill in after first run>
2113
+ expect(perRouteDelta).toBeLessThan(1500)
2114
+ })
2115
+ })
2116
+ ```
2117
+
2118
+ **Note:** The whitespace-strip proxy is a coarse but stable measure. If the team wants a real minified-bytes budget later, swap in `esbuild` (already a transitive dep via vitest) — out of scope for this task. The point of the test is regression detection, not absolute accuracy.
2119
+
2120
+ - [ ] **Step 3: Run to establish baseline**
2121
+
2122
+ ```bash
2123
+ npx vitest run src/codegen/bundle-size.test.ts
2124
+ ```
2125
+ If it passes — good baseline. If it fails, log the actual delta and adjust the budget upward to current + 20% headroom, with a comment noting the baseline.
2126
+
2127
+ - [ ] **Step 4: Commit**
2128
+
2129
+ ```bash
2130
+ git add src/codegen/bundle-size.test.ts
2131
+ git commit -m "test(codegen): add per-route bundle-size budget regression guard"
2132
+ ```
2133
+
2134
+ ---
2135
+
2136
+ ## Phase 9 — Documentation
2137
+
2138
+ ### Task 18: Write `docs/client-error-handling.md`
2139
+
2140
+ **Files:**
2141
+ - Create: `docs/client-error-handling.md`
2142
+
2143
+ - [ ] **Step 1: Write the doc**
2144
+
2145
+ Sections required:
2146
+
2147
+ 1. **The throwing form** — framework error class taxonomy table; narrowing pattern with `instanceof`; example showing `try/catch` with branches for `ApiErrors.X`, `ClientHttpError`, `ClientTimeoutError`, `ClientNetworkError`, `ClientAbortError`.
2148
+ 2. **The `.safe` form** — when to use, full example with exhaustive switch on `kind`, narrowing inside each branch.
2149
+ 3. **Custom error categories** — `ClientErrorMap` augmentation example (both direct and self-contained import targets); `classifyError` adapter config example showing composition with `defaultClassifyError`.
2150
+ 4. **Migration** — `ClientRequestError` → `ClientHttpError`; raw `DOMException` / `TypeError` → framework classes; `onError` payload shape change. Concrete before/after for each.
2151
+ 5. **FAQ — `.safe` + retries** — explain why `onError` fires on every `.safe` failure, show the per-call no-op or `meta.isRetry` suppression patterns. Note the deliberate choice to not add a config knob in v1.
2152
+
2153
+ (Use the spec's "Where each `kind` originates" table verbatim — it's the canonical reference for failure-source paths.)
2154
+
2155
+ - [ ] **Step 2: Commit**
2156
+
2157
+ ```bash
2158
+ git add docs/client-error-handling.md
2159
+ git commit -m "docs: add client error-handling guide"
2160
+ ```
2161
+
2162
+ ### Task 19: Update `CLAUDE.md`
2163
+
2164
+ **Files:**
2165
+ - Modify: `CLAUDE.md`
2166
+
2167
+ - [ ] **Step 1: In the Client section, add a paragraph pointing at the new guide and the `.safe` form**
2168
+
2169
+ Append a bullet under "Important Patterns" referencing `docs/client-error-handling.md` and noting that every RPC/API generated callable has a `.safe` sibling returning `Result<T, E>`.
2170
+
2171
+ - [ ] **Step 2: Add a paragraph on the framework error class taxonomy**
2172
+
2173
+ Mention the six classes and that platform errors (`TypeError`, `DOMException`) are normalized at the `executeCall` boundary.
2174
+
2175
+ - [ ] **Step 3: Commit**
2176
+
2177
+ ```bash
2178
+ git add CLAUDE.md
2179
+ git commit -m "docs(CLAUDE.md): note safe form and normalized error taxonomy"
2180
+ ```
2181
+
2182
+ ### Task 20: Update `agent_config/` patterns and anti-patterns
2183
+
2184
+ **Files:**
2185
+ - Modify: `agent_config/claude-code/skills/ts-procedures/patterns.md`
2186
+ - Modify: `agent_config/claude-code/skills/ts-procedures/anti-patterns.md`
2187
+ - Modify: `agent_config/copilot/copilot-instructions.md`
2188
+ - Modify: `agent_config/cursor/cursorrules`
2189
+
2190
+ - [ ] **Step 1: Add an "Error handling in client code" section to `patterns.md`**
2191
+
2192
+ Show both the throwing form and the `.safe` form, with the framework class narrowing pattern. Mention `ClientErrorMap` augmentation briefly with a link to `docs/client-error-handling.md`.
2193
+
2194
+ - [ ] **Step 2: Add a "Don't catch raw `DOMException`/`TypeError`" entry to `anti-patterns.md`**
2195
+
2196
+ Show the wrong pattern (catching `DOMException` to detect timeout) and the right pattern (`instanceof ClientTimeoutError`).
2197
+
2198
+ - [ ] **Step 3: Mirror the additions in `copilot-instructions.md` and `cursorrules`**
2199
+
2200
+ These files are condensed equivalents — keep them tight (~10-15 lines per addition).
2201
+
2202
+ - [ ] **Step 4: Confirm no installer/postinstall changes are needed**
2203
+
2204
+ The `agent_config/` files are bundled as static documentation; the installer (`agent_config/bin/setup.mjs`) and postinstall (`agent_config/bin/postinstall.mjs`) do recursive file copies — they pick up new content automatically. No installer change required for this task; confirm with:
2205
+
2206
+ ```bash
2207
+ grep -n 'patterns.md\|anti-patterns.md' agent_config/bin/*.mjs
2208
+ ```
2209
+ Expected: no hard-coded references (or only references that already cover the modified files via globs).
2210
+
2211
+ - [ ] **Step 5: Commit**
2212
+
2213
+ ```bash
2214
+ git add agent_config/
2215
+ git commit -m "docs(agent_config): add safe-form patterns and DOMException anti-pattern"
2216
+ ```
2217
+
2218
+ ---
2219
+
2220
+ ## Phase 10 — Migration + version bump
2221
+
2222
+ ### Task 21: Update `CHANGELOG.md` (or release notes file)
2223
+
2224
+ **Files:**
2225
+ - Modify: `CHANGELOG.md` (if it exists) or create one
2226
+
2227
+ - [ ] **Step 1: Check if CHANGELOG exists**
2228
+
2229
+ ```bash
2230
+ ls CHANGELOG.md
2231
+ ```
2232
+
2233
+ - [ ] **Step 2: Add a `7.0.0` entry**
2234
+
2235
+ Required content:
2236
+ - **Breaking changes:**
2237
+ - `ClientRequestError` renamed to `ClientHttpError` (deprecated alias retained for one minor cycle)
2238
+ - Raw `DOMException` / `TypeError` from the adapter no longer reach consumer catch blocks — replaced with `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`
2239
+ - `onError` hook receives the normalized framework error in `ctx.error` (not the raw platform error; original is on `error.cause`)
2240
+ - **New features:**
2241
+ - `.safe()` sibling form on every generated RPC/API callable, returning `Result<T, E>`
2242
+ - `ClientErrorMap` interface for declaration-merging custom error kinds
2243
+ - `defaultClassifyError` exported for adapter-author composition
2244
+ - `ClientAdapter.classifyError` optional adapter-level classifier
2245
+ - `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`, `ClientParseError` framework classes (all carry `cause`)
2246
+ - **Migration:** see `docs/client-error-handling.md`
2247
+
2248
+ - [ ] **Step 3: Commit**
2249
+
2250
+ ```bash
2251
+ git add CHANGELOG.md
2252
+ git commit -m "docs(changelog): add 7.0.0 entry"
2253
+ ```
2254
+
2255
+ ### Task 22: Bump `package.json` to `7.0.0`
2256
+
2257
+ **Files:**
2258
+ - Modify: `package.json`
2259
+
2260
+ - [ ] **Step 1: Update version field**
2261
+
2262
+ Change `"version": "6.2.0"` to `"version": "7.0.0"`.
2263
+
2264
+ - [ ] **Step 2: Run lint + build + test**
2265
+
2266
+ ```bash
2267
+ npm run lint && npm run build && npm run test
2268
+ ```
2269
+ Expected: PASS.
2270
+
2271
+ - [ ] **Step 3: Commit**
2272
+
2273
+ Match the project's prior version-bump commit style (recent commits use the bare version: `6.2.0`, `6.1.0`):
2274
+
2275
+ ```bash
2276
+ git add package.json
2277
+ git commit -m "7.0.0"
2278
+ ```
2279
+
2280
+ ---
2281
+
2282
+ ## Final verification checklist
2283
+
2284
+ Before opening the PR / merging:
2285
+
2286
+ - [ ] `npm run test` passes (full suite)
2287
+ - [ ] `npm run lint` passes
2288
+ - [ ] `npm run build` succeeds without TS errors
2289
+ - [ ] `npm run check-docs` passes (docs consistency check)
2290
+ - [ ] Manually inspect a generated client against a small fixture envelope to confirm `.safe` callables hover correctly in an editor (`tsc --noEmit` in the test fixture's directory)
2291
+ - [ ] Bundle-size budget test (Task 17) is green and within reasonable range of expected
2292
+ - [ ] Migration doc walks through all three breaking changes with concrete before/after
2293
+ - [ ] Spec decision-log items all reflected in code