ts-procedures 6.2.0 → 7.0.0-beta.1

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 (113) 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 +253 -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/bind-callable.test.d.ts +1 -0
  13. package/build/client/bind-callable.test.js +132 -0
  14. package/build/client/bind-callable.test.js.map +1 -0
  15. package/build/client/call.d.ts +14 -2
  16. package/build/client/call.js +96 -9
  17. package/build/client/call.js.map +1 -1
  18. package/build/client/call.test.js +50 -1
  19. package/build/client/call.test.js.map +1 -1
  20. package/build/client/classify-error.d.ts +11 -0
  21. package/build/client/classify-error.js +49 -0
  22. package/build/client/classify-error.js.map +1 -0
  23. package/build/client/classify-error.test.d.ts +1 -0
  24. package/build/client/classify-error.test.js +55 -0
  25. package/build/client/classify-error.test.js.map +1 -0
  26. package/build/client/error-dispatch.d.ts +1 -1
  27. package/build/client/error-dispatch.js +1 -1
  28. package/build/client/errors.d.ts +55 -4
  29. package/build/client/errors.js +54 -7
  30. package/build/client/errors.js.map +1 -1
  31. package/build/client/errors.test.js +89 -4
  32. package/build/client/errors.test.js.map +1 -1
  33. package/build/client/fetch-adapter.d.ts +2 -1
  34. package/build/client/fetch-adapter.js +2 -1
  35. package/build/client/fetch-adapter.js.map +1 -1
  36. package/build/client/fetch-adapter.test.js +12 -0
  37. package/build/client/fetch-adapter.test.js.map +1 -1
  38. package/build/client/index.d.ts +5 -3
  39. package/build/client/index.js +29 -3
  40. package/build/client/index.js.map +1 -1
  41. package/build/client/resolve-options.d.ts +32 -1
  42. package/build/client/resolve-options.js +32 -16
  43. package/build/client/resolve-options.js.map +1 -1
  44. package/build/client/resolve-options.test.js +67 -6
  45. package/build/client/resolve-options.test.js.map +1 -1
  46. package/build/client/result-type.test-d.d.ts +1 -0
  47. package/build/client/result-type.test-d.js +28 -0
  48. package/build/client/result-type.test-d.js.map +1 -0
  49. package/build/client/safe-call.test.d.ts +1 -0
  50. package/build/client/safe-call.test.js +137 -0
  51. package/build/client/safe-call.test.js.map +1 -0
  52. package/build/client/stream.d.ts +1 -1
  53. package/build/client/stream.js +22 -8
  54. package/build/client/stream.js.map +1 -1
  55. package/build/client/stream.test.js +11 -1
  56. package/build/client/stream.test.js.map +1 -1
  57. package/build/client/types.d.ts +117 -3
  58. package/build/codegen/bundle-size.test.d.ts +1 -0
  59. package/build/codegen/bundle-size.test.js +70 -0
  60. package/build/codegen/bundle-size.test.js.map +1 -0
  61. package/build/codegen/e2e.test.js +108 -7
  62. package/build/codegen/e2e.test.js.map +1 -1
  63. package/build/codegen/emit-client-runtime.js +8 -0
  64. package/build/codegen/emit-client-runtime.js.map +1 -1
  65. package/build/codegen/emit-client-runtime.test.js +6 -2
  66. package/build/codegen/emit-client-runtime.test.js.map +1 -1
  67. package/build/codegen/emit-client-types.d.ts +7 -2
  68. package/build/codegen/emit-client-types.js +29 -8
  69. package/build/codegen/emit-client-types.js.map +1 -1
  70. package/build/codegen/emit-client-types.test.js +20 -8
  71. package/build/codegen/emit-client-types.test.js.map +1 -1
  72. package/build/codegen/emit-errors.d.ts +1 -1
  73. package/build/codegen/emit-errors.js +1 -1
  74. package/build/codegen/emit-index.js +1 -1
  75. package/build/codegen/emit-index.js.map +1 -1
  76. package/build/codegen/emit-scope.js +37 -25
  77. package/build/codegen/emit-scope.js.map +1 -1
  78. package/build/codegen/emit-scope.test.js +310 -14
  79. package/build/codegen/emit-scope.test.js.map +1 -1
  80. package/docs/client-and-codegen.md +77 -7
  81. package/docs/client-error-handling.md +357 -0
  82. package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
  83. package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
  84. package/package.json +1 -1
  85. package/src/client/augment-error-map.test-d.ts +22 -0
  86. package/src/client/bind-callable.test.ts +137 -0
  87. package/src/client/call.test.ts +65 -1
  88. package/src/client/call.ts +111 -9
  89. package/src/client/classify-error.test.ts +65 -0
  90. package/src/client/classify-error.ts +59 -0
  91. package/src/client/error-dispatch.ts +1 -1
  92. package/src/client/errors.test.ts +108 -4
  93. package/src/client/errors.ts +70 -7
  94. package/src/client/fetch-adapter.test.ts +15 -0
  95. package/src/client/fetch-adapter.ts +5 -2
  96. package/src/client/index.ts +60 -3
  97. package/src/client/resolve-options.test.ts +83 -5
  98. package/src/client/resolve-options.ts +61 -16
  99. package/src/client/result-type.test-d.ts +51 -0
  100. package/src/client/safe-call.test.ts +157 -0
  101. package/src/client/stream.test.ts +13 -1
  102. package/src/client/stream.ts +25 -8
  103. package/src/client/types.ts +137 -3
  104. package/src/codegen/bundle-size.test.ts +76 -0
  105. package/src/codegen/e2e.test.ts +113 -7
  106. package/src/codegen/emit-client-runtime.test.ts +7 -2
  107. package/src/codegen/emit-client-runtime.ts +8 -0
  108. package/src/codegen/emit-client-types.test.ts +22 -7
  109. package/src/codegen/emit-client-types.ts +35 -10
  110. package/src/codegen/emit-errors.ts +1 -1
  111. package/src/codegen/emit-index.ts +1 -1
  112. package/src/codegen/emit-scope.test.ts +337 -14
  113. package/src/codegen/emit-scope.ts +39 -35
@@ -1,4 +1,4 @@
1
- import { executeCall } from './call.js'
1
+ import { executeCall, executeSafeCall } from './call.js'
2
2
  import { executeStream, createTypedStream } from './stream.js'
3
3
  import type {
4
4
  CreateClientConfig,
@@ -7,6 +7,8 @@ import type {
7
7
  StreamDescriptor,
8
8
  ProcedureCallOptions,
9
9
  TypedStream,
10
+ Result,
11
+ ResultNoTyped,
10
12
  } from './types.js'
11
13
 
12
14
  // ── createClient ──────────────────────────────────────────
@@ -56,6 +58,41 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
56
58
  })
57
59
  },
58
60
 
61
+ safeCall<TResponse, ETyped = never>(
62
+ descriptor: CallDescriptor,
63
+ options?: ProcedureCallOptions,
64
+ ): Promise<Result<TResponse, ETyped>> {
65
+ return executeSafeCall<TResponse, ETyped>({
66
+ descriptor,
67
+ basePath,
68
+ adapter,
69
+ hooks: globalHooks,
70
+ defaults: globalDefaults,
71
+ options,
72
+ errorRegistry,
73
+ })
74
+ },
75
+
76
+ bindCallable<TParams, TResponse>(descriptor: Omit<CallDescriptor, 'params'>) {
77
+ const call = (params: TParams, options?: ProcedureCallOptions) =>
78
+ instance.call<TResponse>({ ...descriptor, params }, options)
79
+ Object.defineProperty(call, 'name', { value: descriptor.name, configurable: true })
80
+ return Object.assign(call, {
81
+ safe: (params: TParams, options?: ProcedureCallOptions) =>
82
+ instance.safeCall<TResponse>({ ...descriptor, params }, options) as Promise<ResultNoTyped<TResponse>>,
83
+ })
84
+ },
85
+
86
+ bindCallableTyped<TParams, TResponse, ETyped>(descriptor: Omit<CallDescriptor, 'params'>) {
87
+ const call = (params: TParams, options?: ProcedureCallOptions) =>
88
+ instance.call<TResponse>({ ...descriptor, params }, options)
89
+ Object.defineProperty(call, 'name', { value: descriptor.name, configurable: true })
90
+ return Object.assign(call, {
91
+ safe: (params: TParams, options?: ProcedureCallOptions) =>
92
+ instance.safeCall<TResponse, ETyped>({ ...descriptor, params }, options),
93
+ })
94
+ },
95
+
59
96
  stream<TYield, TReturn>(
60
97
  descriptor: StreamDescriptor,
61
98
  options?: ProcedureCallOptions,
@@ -133,15 +170,35 @@ export type {
133
170
  ErrorRegistry,
134
171
  ErrorFactory,
135
172
  ErrorResponseMeta,
173
+ ClientErrorMap,
174
+ FrameworkFailure,
175
+ Result,
176
+ ResultNoTyped,
136
177
  } from './types.js'
137
178
 
138
179
  export { dispatchTypedError } from './error-dispatch.js'
139
180
 
140
- export { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'
181
+ export {
182
+ ClientHttpError,
183
+ ClientRequestError,
184
+ ClientPathParamError,
185
+ ClientStreamError,
186
+ ClientNetworkError,
187
+ ClientTimeoutError,
188
+ ClientAbortError,
189
+ ClientParseError,
190
+ } from './errors.js'
141
191
 
142
192
  export { createTypedStream } from './stream.js'
143
- export { executeCall } from './call.js'
193
+ export { executeCall, executeSafeCall } from './call.js'
144
194
  export { executeStream } from './stream.js'
145
195
 
146
196
  export { createFetchAdapter } from './fetch-adapter.js'
147
197
  export type { FetchAdapterConfig } from './fetch-adapter.js'
198
+
199
+ export { defaultClassifyError } from './classify-error.js'
200
+ export type {
201
+ ErrorClassifier,
202
+ ClassifyErrorContext,
203
+ ClassifiedError,
204
+ } from './types.js'
@@ -5,6 +5,7 @@ import {
5
5
  resolveHeaders,
6
6
  resolveMeta,
7
7
  resolveSignal,
8
+ resolveSignalSources,
8
9
  } from './resolve-options.js'
9
10
  import type { AdapterRequest, ProcedureCallDefaults, ProcedureCallOptions } from './types.js'
10
11
 
@@ -141,8 +142,72 @@ describe('resolveSignal', () => {
141
142
  })
142
143
  })
143
144
 
145
+ // ── resolveSignalSources ──────────────────────────────────
146
+
147
+ describe('resolveSignalSources', () => {
148
+ it('returns timeout signal alongside combined', () => {
149
+ const result = resolveSignalSources(undefined, { timeout: 100 })
150
+ expect(result.timeoutSignal).toBeInstanceOf(AbortSignal)
151
+ expect(result.timeoutMs).toBe(100)
152
+ expect(result.combined).toBeInstanceOf(AbortSignal)
153
+ expect(result.userSignal).toBeUndefined()
154
+ })
155
+
156
+ it('returns user signal alongside combined', () => {
157
+ const ctrl = new AbortController()
158
+ const result = resolveSignalSources(undefined, { signal: ctrl.signal })
159
+ expect(result.userSignal).toBe(ctrl.signal)
160
+ expect(result.timeoutSignal).toBeUndefined()
161
+ })
162
+
163
+ it('combines both via AbortSignal.any', () => {
164
+ const ctrl = new AbortController()
165
+ const result = resolveSignalSources(undefined, { signal: ctrl.signal, timeout: 100 })
166
+ expect(result.timeoutSignal).toBeInstanceOf(AbortSignal)
167
+ expect(result.userSignal).toBe(ctrl.signal)
168
+ expect(result.combined).toBeInstanceOf(AbortSignal)
169
+ })
170
+
171
+ it('returns undefined combined when no signals', () => {
172
+ const result = resolveSignalSources(undefined, undefined)
173
+ expect(result.combined).toBeUndefined()
174
+ })
175
+
176
+ it('per-call timeout: 0 disables inherited default timeout', () => {
177
+ const result = resolveSignalSources({ timeout: 1000 }, { timeout: 0 })
178
+ expect(result.timeoutSignal).toBeUndefined()
179
+ expect(result.timeoutMs).toBe(0)
180
+ expect(result.combined).toBeUndefined()
181
+ })
182
+
183
+ it('merges default signal with per-call signal', () => {
184
+ const defaultCtrl = new AbortController()
185
+ const callCtrl = new AbortController()
186
+ const result = resolveSignalSources(
187
+ { signal: defaultCtrl.signal },
188
+ { signal: callCtrl.signal },
189
+ )
190
+ // userSignal is the per-call signal (takes precedence)
191
+ expect(result.userSignal).toBe(callCtrl.signal)
192
+ // combined should be a new AbortSignal.any
193
+ expect(result.combined).toBeInstanceOf(AbortSignal)
194
+ expect(result.combined).not.toBe(callCtrl.signal)
195
+ expect(result.combined).not.toBe(defaultCtrl.signal)
196
+ // aborting either source aborts the combined signal
197
+ defaultCtrl.abort()
198
+ expect(result.combined!.aborted).toBe(true)
199
+ })
200
+ })
201
+
144
202
  // ── applyRequestOptions ───────────────────────────────────
145
203
 
204
+ // Helper so existing tests can destructure .request without boilerplate
205
+ const apply = (
206
+ req: AdapterRequest,
207
+ d: ProcedureCallDefaults | undefined,
208
+ o: ProcedureCallOptions | undefined,
209
+ ) => applyRequestOptions(req, d, o).request
210
+
146
211
  describe('applyRequestOptions', () => {
147
212
  const baseRequest: AdapterRequest = {
148
213
  url: 'https://api.example.com/foo',
@@ -151,7 +216,7 @@ describe('applyRequestOptions', () => {
151
216
  }
152
217
 
153
218
  it('returns the request unchanged when nothing is provided', () => {
154
- const result = applyRequestOptions(baseRequest, undefined, undefined)
219
+ const result = apply(baseRequest, undefined, undefined)
155
220
  expect(result.url).toBe(baseRequest.url)
156
221
  expect(result.body).toEqual({ hello: 'world' })
157
222
  expect(result.headers).toBeUndefined()
@@ -164,7 +229,7 @@ describe('applyRequestOptions', () => {
164
229
  ...baseRequest,
165
230
  headers: { 'content-type': 'application/json', 'x-route': 'declared' },
166
231
  }
167
- const result = applyRequestOptions(
232
+ const result = apply(
168
233
  reqWithHeaders,
169
234
  { headers: { 'x-default': 'd', 'x-route': 'from-default' } },
170
235
  { headers: { 'x-call': 'c', 'x-route': 'from-call' } },
@@ -179,7 +244,7 @@ describe('applyRequestOptions', () => {
179
244
  })
180
245
 
181
246
  it('attaches meta to the request when provided', () => {
182
- const result = applyRequestOptions(baseRequest, undefined, {
247
+ const result = apply(baseRequest, undefined, {
183
248
  meta: { traceId: 'abc' } as never,
184
249
  })
185
250
  expect(result.meta).toEqual({ traceId: 'abc' })
@@ -187,14 +252,14 @@ describe('applyRequestOptions', () => {
187
252
 
188
253
  it('passes per-call signal through', () => {
189
254
  const controller = new AbortController()
190
- const result = applyRequestOptions(baseRequest, undefined, { signal: controller.signal })
255
+ const result = apply(baseRequest, undefined, { signal: controller.signal })
191
256
  expect(result.signal).toBe(controller.signal)
192
257
  })
193
258
 
194
259
  it('attaches a signal when per-call timeout is set', () => {
195
260
  const spy = vi.spyOn(AbortSignal, 'timeout')
196
261
  try {
197
- const result = applyRequestOptions(baseRequest, undefined, { timeout: 100 })
262
+ const result = apply(baseRequest, undefined, { timeout: 100 })
198
263
  expect(spy).toHaveBeenCalledWith(100)
199
264
  expect(result.signal).toBeDefined()
200
265
  expect(result.signal?.aborted).toBe(false)
@@ -202,4 +267,17 @@ describe('applyRequestOptions', () => {
202
267
  spy.mockRestore()
203
268
  }
204
269
  })
270
+
271
+ it('exposes signalSources alongside the request', () => {
272
+ const ctrl = new AbortController()
273
+ const { request, signalSources } = applyRequestOptions(baseRequest, undefined, {
274
+ signal: ctrl.signal,
275
+ timeout: 500,
276
+ })
277
+ expect(request.signal).toBeInstanceOf(AbortSignal)
278
+ expect(signalSources.userSignal).toBe(ctrl.signal)
279
+ expect(signalSources.timeoutSignal).toBeInstanceOf(AbortSignal)
280
+ expect(signalSources.timeoutMs).toBe(500)
281
+ expect(signalSources.combined).toBe(request.signal)
282
+ })
205
283
  })
@@ -17,6 +17,48 @@ export function resolveBasePath(
17
17
  return options?.basePath ?? defaults?.basePath ?? fallback
18
18
  }
19
19
 
20
+ /**
21
+ * Resolves signal sources individually so callers can distinguish which
22
+ * underlying signal fired (e.g. timeout vs user abort in the error classifier).
23
+ *
24
+ * - `userSignal`: the per-call signal (if any); separate from the default signal
25
+ * - `timeoutSignal`: created via `AbortSignal.timeout(timeoutMs)` when timeout > 0
26
+ * - `timeoutMs`: the resolved timeout value (may be 0 if per-call explicitly disables)
27
+ * - `combined`: `AbortSignal.any([...])` of all active signals, or undefined if none
28
+ *
29
+ * Per-call `timeout: 0` disables an inherited default timeout (same as `resolveSignal`).
30
+ * Both `defaults.signal` and `options.signal` are included in `combined` when present.
31
+ */
32
+ export interface SignalSources {
33
+ combined?: AbortSignal
34
+ timeoutSignal?: AbortSignal
35
+ /** The per-call signal, if provided. Separate from the default signal. */
36
+ userSignal?: AbortSignal
37
+ timeoutMs?: number
38
+ }
39
+
40
+ export function resolveSignalSources(
41
+ defaults: ProcedureCallDefaults | undefined,
42
+ options: ProcedureCallOptions | undefined,
43
+ ): SignalSources {
44
+ const userSignal = options?.signal
45
+ // Use explicit undefined check so timeout: 0 overrides defaults (not just nullish)
46
+ const timeoutMs = options?.timeout !== undefined ? options.timeout : defaults?.timeout
47
+ const timeoutSignal =
48
+ timeoutMs != null && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined
49
+
50
+ const signals: AbortSignal[] = []
51
+ if (defaults?.signal) signals.push(defaults.signal)
52
+ if (userSignal) signals.push(userSignal)
53
+ if (timeoutSignal) signals.push(timeoutSignal)
54
+
55
+ let combined: AbortSignal | undefined
56
+ if (signals.length === 1) combined = signals[0]
57
+ else if (signals.length > 1) combined = AbortSignal.any(signals)
58
+
59
+ return { combined, timeoutSignal, userSignal, timeoutMs }
60
+ }
61
+
20
62
  /**
21
63
  * Resolves the effective AbortSignal by combining (via `AbortSignal.any`):
22
64
  * - default signal (if any)
@@ -25,24 +67,15 @@ export function resolveBasePath(
25
67
  *
26
68
  * Returns undefined when none apply. Per-call `timeout: 0` disables an
27
69
  * inherited default timeout.
70
+ *
71
+ * @deprecated Prefer `resolveSignalSources` when you need access to individual
72
+ * signal references (e.g. for abort-cause classification).
28
73
  */
29
74
  export function resolveSignal(
30
75
  defaults: ProcedureCallDefaults | undefined,
31
76
  options: ProcedureCallOptions | undefined,
32
77
  ): AbortSignal | undefined {
33
- const signals: AbortSignal[] = []
34
-
35
- if (defaults?.signal) signals.push(defaults.signal)
36
- if (options?.signal) signals.push(options.signal)
37
-
38
- const timeout = options?.timeout ?? defaults?.timeout
39
- if (timeout != null && timeout > 0) {
40
- signals.push(AbortSignal.timeout(timeout))
41
- }
42
-
43
- if (signals.length === 0) return undefined
44
- if (signals.length === 1) return signals[0]
45
- return AbortSignal.any(signals)
78
+ return resolveSignalSources(defaults, options).combined
46
79
  }
47
80
 
48
81
  /**
@@ -84,6 +117,11 @@ export function resolveMeta(
84
117
  return { ...defaultMeta, ...callMeta } as RequestMeta
85
118
  }
86
119
 
120
+ export interface ApplyRequestOptionsResult {
121
+ request: AdapterRequest
122
+ signalSources: SignalSources
123
+ }
124
+
87
125
  /**
88
126
  * Applies resolved default + per-call options to an AdapterRequest.
89
127
  *
@@ -94,13 +132,17 @@ export function resolveMeta(
94
132
  * API routes) are preserved; resolved headers merge underneath them so the
95
133
  * route-declared headers win, matching the adapter.config → defaults → call
96
134
  * → route-declared → hooks precedence chain documented in the types.
135
+ *
136
+ * Returns both the merged request and the raw `signalSources` so callers (e.g.
137
+ * the error classifier in Task 6) can distinguish timeout from user abort without
138
+ * losing provenance after `AbortSignal.any` collapses the originals.
97
139
  */
98
140
  export function applyRequestOptions(
99
141
  request: AdapterRequest,
100
142
  defaults: ProcedureCallDefaults | undefined,
101
143
  options: ProcedureCallOptions | undefined,
102
- ): AdapterRequest {
103
- const signal = resolveSignal(defaults, options)
144
+ ): ApplyRequestOptionsResult {
145
+ const signalSources = resolveSignalSources(defaults, options)
104
146
  const resolvedHeaders = resolveHeaders(defaults, options)
105
147
  const meta = resolveMeta(defaults, options)
106
148
 
@@ -109,5 +151,8 @@ export function applyRequestOptions(
109
151
  ? { ...resolvedHeaders, ...request.headers }
110
152
  : undefined
111
153
 
112
- return { ...request, headers, signal, meta }
154
+ return {
155
+ request: { ...request, headers, signal: signalSources.combined, meta },
156
+ signalSources,
157
+ }
113
158
  }
@@ -0,0 +1,51 @@
1
+ import { describe, it, expectTypeOf } from 'vitest'
2
+ import type {
3
+ Result,
4
+ ResultNoTyped,
5
+ ClientErrorMap,
6
+ } from './types.js'
7
+ import type {
8
+ ClientHttpError,
9
+ ClientNetworkError,
10
+ ClientTimeoutError,
11
+ ClientAbortError,
12
+ ClientParseError,
13
+ ClientPathParamError,
14
+ } from './errors.js'
15
+
16
+ describe('Result type', () => {
17
+ it('includes ok=true with value', () => {
18
+ type R = Result<number, Error>
19
+ type Ok = Extract<R, { ok: true }>
20
+ expectTypeOf<Ok['value']>().toEqualTypeOf<number>()
21
+ })
22
+
23
+ it('includes typed kind with ETyped error', () => {
24
+ class MyTyped extends Error {}
25
+ type R = Result<number, MyTyped>
26
+ type Typed = Extract<R, { kind: 'typed' }>
27
+ expectTypeOf<Typed['error']>().toEqualTypeOf<MyTyped>()
28
+ })
29
+
30
+ it('includes every default framework kind', () => {
31
+ type R = Result<number, Error>
32
+ expectTypeOf<Extract<R, { kind: 'http' }>['error']>().toEqualTypeOf<ClientHttpError>()
33
+ expectTypeOf<Extract<R, { kind: 'network' }>['error']>().toEqualTypeOf<ClientNetworkError>()
34
+ expectTypeOf<Extract<R, { kind: 'timeout' }>['error']>().toEqualTypeOf<ClientTimeoutError>()
35
+ expectTypeOf<Extract<R, { kind: 'aborted' }>['error']>().toEqualTypeOf<ClientAbortError>()
36
+ expectTypeOf<Extract<R, { kind: 'parse' }>['error']>().toEqualTypeOf<ClientParseError>()
37
+ expectTypeOf<Extract<R, { kind: 'usage' }>['error']>().toEqualTypeOf<ClientPathParamError>()
38
+ expectTypeOf<Extract<R, { kind: 'unknown' }>['error']>().toEqualTypeOf<unknown>()
39
+ })
40
+ })
41
+
42
+ describe('ResultNoTyped', () => {
43
+ it('omits the typed arm', () => {
44
+ type R = ResultNoTyped<number>
45
+ type Typed = Extract<R, { kind: 'typed' }>
46
+ expectTypeOf<Typed>().toEqualTypeOf<never>()
47
+ })
48
+ })
49
+
50
+ // Suppress unused-var warnings on imports we use only for types
51
+ void (null as unknown as ClientErrorMap)
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { executeSafeCall } from './call.js'
3
+ import {
4
+ ClientHttpError,
5
+ ClientNetworkError,
6
+ ClientTimeoutError,
7
+ ClientAbortError,
8
+ ClientPathParamError,
9
+ } from './errors.js'
10
+ import type { ClientAdapter, CallDescriptor } from './types.js'
11
+
12
+ const baseDescriptor: CallDescriptor = {
13
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'GET',
14
+ kind: 'rpc', params: { id: '42' },
15
+ }
16
+
17
+ const ok = (body: unknown): ClientAdapter => ({
18
+ request: vi.fn(async () => ({ status: 200, headers: {}, body })),
19
+ stream: vi.fn(async () => { throw new Error('n/a') }),
20
+ })
21
+
22
+ const failWith = (err: unknown): ClientAdapter => ({
23
+ request: vi.fn(async () => { throw err }),
24
+ stream: vi.fn(async () => { throw new Error('n/a') }),
25
+ })
26
+
27
+ describe('executeSafeCall', () => {
28
+ it('returns ok=true with value on success', async () => {
29
+ const r = await executeSafeCall({
30
+ descriptor: baseDescriptor,
31
+ basePath: 'https://api.x',
32
+ adapter: ok({ id: '42' }),
33
+ hooks: {},
34
+ })
35
+ expect(r.ok).toBe(true)
36
+ if (r.ok) expect(r.value).toEqual({ id: '42' })
37
+ })
38
+
39
+ it("returns kind='http' on non-2xx without registry match", async () => {
40
+ const r = await executeSafeCall({
41
+ descriptor: baseDescriptor,
42
+ basePath: 'https://api.x',
43
+ adapter: {
44
+ request: vi.fn(async () => ({ status: 500, headers: {}, body: { msg: 'oops' } })),
45
+ stream: vi.fn(async () => { throw new Error('n/a') }),
46
+ },
47
+ hooks: {},
48
+ })
49
+ expect(r.ok).toBe(false)
50
+ if (!r.ok) {
51
+ expect(r.kind).toBe('http')
52
+ expect(r.error).toBeInstanceOf(ClientHttpError)
53
+ }
54
+ })
55
+
56
+ it("returns kind='network' on TypeError", async () => {
57
+ const r = await executeSafeCall({
58
+ descriptor: baseDescriptor,
59
+ basePath: 'https://api.x',
60
+ adapter: failWith(new TypeError('Failed to fetch')),
61
+ hooks: {},
62
+ })
63
+ expect(r.ok).toBe(false)
64
+ if (!r.ok) {
65
+ expect(r.kind).toBe('network')
66
+ expect(r.error).toBeInstanceOf(ClientNetworkError)
67
+ }
68
+ })
69
+
70
+ it("returns kind='timeout' when timeout fires", async () => {
71
+ // Simulate fetch-style abort wait, mirroring Task 6's fix
72
+ const r = await executeSafeCall({
73
+ descriptor: baseDescriptor,
74
+ basePath: 'https://api.x',
75
+ adapter: {
76
+ request: vi.fn(async (req) => {
77
+ await new Promise<void>((_resolve, reject) => {
78
+ req.signal?.addEventListener(
79
+ 'abort',
80
+ () => reject(new DOMException('aborted', 'AbortError')),
81
+ { once: true },
82
+ )
83
+ })
84
+ throw new Error('unreachable')
85
+ }),
86
+ stream: vi.fn(async () => { throw new Error('n/a') }),
87
+ },
88
+ hooks: {},
89
+ options: { timeout: 1 },
90
+ })
91
+ expect(r.ok).toBe(false)
92
+ if (!r.ok) {
93
+ expect(r.kind).toBe('timeout')
94
+ expect(r.error).toBeInstanceOf(ClientTimeoutError)
95
+ }
96
+ })
97
+
98
+ it("returns kind='aborted' when user signal fires", async () => {
99
+ const ctrl = new AbortController()
100
+ ctrl.abort('user')
101
+ const r = await executeSafeCall({
102
+ descriptor: baseDescriptor,
103
+ basePath: 'https://api.x',
104
+ adapter: failWith(new DOMException('aborted', 'AbortError')),
105
+ hooks: {},
106
+ options: { signal: ctrl.signal },
107
+ })
108
+ if (!r.ok) {
109
+ expect(r.kind).toBe('aborted')
110
+ expect(r.error).toBeInstanceOf(ClientAbortError)
111
+ }
112
+ })
113
+
114
+ it("returns kind='usage' on pre-adapter ClientPathParamError, does NOT invoke onError", async () => {
115
+ // Spec contract: pre-adapter usage errors bypass the classifier AND the
116
+ // onError hook entirely. Path 1 of the three failure-source paths.
117
+ const seen: unknown[] = []
118
+ const r = await executeSafeCall({
119
+ // path requires :id but params don't supply it
120
+ descriptor: { ...baseDescriptor, params: {} },
121
+ basePath: 'https://api.x',
122
+ adapter: ok({}),
123
+ hooks: { onError: ({ error }) => { seen.push(error) } },
124
+ })
125
+ if (!r.ok) {
126
+ expect(r.kind).toBe('usage')
127
+ expect(r.error).toBeInstanceOf(ClientPathParamError)
128
+ }
129
+ // onError must NOT fire — by design (per spec architectural decision #1).
130
+ expect(seen).toEqual([])
131
+ })
132
+
133
+ it("returns kind='unknown' for unrecognized throws", async () => {
134
+ const r = await executeSafeCall({
135
+ descriptor: baseDescriptor,
136
+ basePath: 'https://api.x',
137
+ adapter: failWith('weird-string-throw'),
138
+ hooks: {},
139
+ })
140
+ if (!r.ok) {
141
+ expect(r.kind).toBe('unknown')
142
+ expect(r.error).toBe('weird-string-throw')
143
+ }
144
+ })
145
+
146
+ it('invokes onError hook on failure (cross-cutting telemetry)', async () => {
147
+ const seen: unknown[] = []
148
+ const r = await executeSafeCall({
149
+ descriptor: baseDescriptor,
150
+ basePath: 'https://api.x',
151
+ adapter: failWith(new TypeError('x')),
152
+ hooks: { onError: ({ error }) => { seen.push(error) } },
153
+ })
154
+ expect(r.ok).toBe(false)
155
+ expect(seen[0]).toBeInstanceOf(ClientNetworkError)
156
+ })
157
+ })
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
2
  import { createTypedStream, executeStream } from './stream.js'
3
- import { ClientRequestError } from './errors.js'
3
+ import { ClientRequestError, ClientNetworkError } from './errors.js'
4
4
  import type {
5
5
  ClientAdapter,
6
6
  AdapterRequest,
@@ -354,3 +354,15 @@ describe('executeStream', () => {
354
354
  expect(observedMeta).toEqual({ traceId: 'stream-trace' })
355
355
  })
356
356
  })
357
+
358
+ // ── executeStream classifier integration ──────────────────
359
+
360
+ describe('executeStream classifier integration', () => {
361
+ it('classifies pre-stream TypeError as ClientNetworkError', async () => {
362
+ const adapter: ClientAdapter = {
363
+ request: vi.fn(async () => { throw new Error('n/a') }),
364
+ stream: vi.fn(async () => { throw new TypeError('Failed to fetch') }),
365
+ }
366
+ await expect(run({ adapter })).rejects.toBeInstanceOf(ClientNetworkError)
367
+ })
368
+ })
@@ -1,11 +1,13 @@
1
1
  import { buildAdapterRequest } from './request-builder.js'
2
2
  import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
3
3
  import { applyRequestOptions, resolveBasePath } from './resolve-options.js'
4
- import { ClientRequestError } from './errors.js'
4
+ import { ClientHttpError } from './errors.js'
5
5
  import { dispatchTypedError } from './error-dispatch.js'
6
+ import { defaultClassifyError } from './classify-error.js'
6
7
  import type {
7
8
  ClientAdapter,
8
9
  ClientHooks,
10
+ ClassifyErrorContext,
9
11
  ErrorRegistry,
10
12
  StreamDescriptor,
11
13
  TypedStream,
@@ -115,7 +117,7 @@ export interface ExecuteStreamConfig {
115
117
  * 4. Call adapter.stream()
116
118
  * 5. On adapter error: run onError hooks, re-throw
117
119
  * 6. Run onAfterResponse immediately (before iteration), body is null
118
- * 7. If non-2xx: throw ClientRequestError
120
+ * 7. If non-2xx: throw ClientHttpError
119
121
  * 8. Return createTypedStream(streamResponse.body, descriptor.streamMode)
120
122
  */
121
123
  export async function executeStream<TYield, TReturn = void>(
@@ -128,7 +130,9 @@ export async function executeStream<TYield, TReturn = void>(
128
130
  let request = buildAdapterRequest(descriptor, resolvedBasePath)
129
131
 
130
132
  // 2. Apply request-level options (headers, signal, timeout, meta)
131
- request = applyRequestOptions(request, defaults, options)
133
+ const applied = applyRequestOptions(request, defaults, options)
134
+ request = applied.request
135
+ const signalSources = applied.signalSources
132
136
 
133
137
  // 3. Run before-request hooks
134
138
  const beforeCtx = await runBeforeRequest(
@@ -142,14 +146,27 @@ export async function executeStream<TYield, TReturn = void>(
142
146
  let streamResponse
143
147
  try {
144
148
  streamResponse = await adapter.stream(request)
145
- } catch (err) {
146
- // 5. On adapter error: run error hooks, re-throw
149
+ } catch (rawErr) {
150
+ // 5. On adapter error: classify (adapter > default > fallthrough), then run
151
+ // onError hooks with the normalized error, then throw.
152
+ const classifyCtx: ClassifyErrorContext = {
153
+ procedureName: descriptor.name,
154
+ scope: descriptor.scope,
155
+ timeoutSignal: signalSources.timeoutSignal,
156
+ userSignal: signalSources.userSignal,
157
+ timeoutMs: signalSources.timeoutMs,
158
+ }
159
+ const classified =
160
+ adapter.classifyError?.(rawErr, classifyCtx) ??
161
+ defaultClassifyError(rawErr, classifyCtx)
162
+ const finalError = classified?.error ?? rawErr
163
+
147
164
  await runOnError(
148
- { procedureName: descriptor.name, scope: descriptor.scope, request, error: err },
165
+ { procedureName: descriptor.name, scope: descriptor.scope, request, error: finalError },
149
166
  hooks,
150
167
  options,
151
168
  )
152
- throw err
169
+ throw finalError
153
170
  }
154
171
 
155
172
  // Build an AdapterResponse shape for the hooks. For success the body is null
@@ -176,7 +193,7 @@ export async function executeStream<TYield, TReturn = void>(
176
193
  scope: descriptor.scope,
177
194
  })
178
195
  if (typed) throw typed
179
- throw new ClientRequestError({
196
+ throw new ClientHttpError({
180
197
  status: responseForHooks.status,
181
198
  headers: responseForHooks.headers,
182
199
  body: responseForHooks.body,