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,15 +1,27 @@
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 {
5
+ ClientHttpError,
6
+ ClientNetworkError,
7
+ ClientTimeoutError,
8
+ ClientAbortError,
9
+ ClientParseError,
10
+ ClientPathParamError,
11
+ } from './errors.js'
5
12
  import { dispatchTypedError } from './error-dispatch.js'
13
+ import { defaultClassifyError } from './classify-error.js'
6
14
  import type {
7
15
  ClientAdapter,
8
16
  ClientHooks,
9
17
  CallDescriptor,
18
+ ClassifyErrorContext,
10
19
  ErrorRegistry,
11
20
  ProcedureCallDefaults,
12
21
  ProcedureCallOptions,
22
+ Result,
23
+ ClientErrorMap,
24
+ FrameworkFailure,
13
25
  } from './types.js'
14
26
 
15
27
  export interface ExecuteCallConfig {
@@ -32,7 +44,7 @@ export interface ExecuteCallConfig {
32
44
  * 4. Call adapter.request()
33
45
  * 5. On adapter error: run onError hooks, re-throw
34
46
  * 6. Run onAfterResponse hooks (may mutate response.status to swallow errors)
35
- * 7. If response status is non-2xx: throw ClientRequestError
47
+ * 7. If response status is non-2xx: throw ClientHttpError
36
48
  * 8. Return response.body as TResponse
37
49
  */
38
50
  export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise<TResponse> {
@@ -43,7 +55,9 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
43
55
  let request = buildAdapterRequest(descriptor, resolvedBasePath)
44
56
 
45
57
  // 2. Apply request-level options (headers, signal, timeout, meta)
46
- request = applyRequestOptions(request, defaults, options)
58
+ const applied = applyRequestOptions(request, defaults, options)
59
+ request = applied.request
60
+ const signalSources = applied.signalSources
47
61
 
48
62
  // 3. Run before-request hooks — they may further mutate the request
49
63
  const beforeCtx = await runBeforeRequest(
@@ -57,14 +71,36 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
57
71
  let response
58
72
  try {
59
73
  response = await adapter.request(request)
60
- } catch (err) {
61
- // 5. On adapter error: run error hooks, re-throw
74
+ } catch (rawErr) {
75
+ // 5. On adapter error: classify (adapter > default > fallthrough), then run
76
+ // onError hooks with the normalized error, then throw.
77
+ const classifyCtx: ClassifyErrorContext = {
78
+ procedureName: descriptor.name,
79
+ scope: descriptor.scope,
80
+ timeoutSignal: signalSources.timeoutSignal,
81
+ userSignal: signalSources.userSignal,
82
+ timeoutMs: signalSources.timeoutMs,
83
+ }
84
+ const classified =
85
+ adapter.classifyError?.(rawErr, classifyCtx) ??
86
+ defaultClassifyError(rawErr, classifyCtx)
87
+ // Tag the classified error so executeSafeCall can identify its kind without
88
+ // re-classifying. Default kinds are recognized via instanceof in
89
+ // classifyThrownError; only custom adapter kinds need an explicit tag.
90
+ if (classified) {
91
+ const defaultKinds = new Set(['network', 'timeout', 'aborted', 'parse'])
92
+ if (!defaultKinds.has(classified.kind)) {
93
+ ;(classified.error as unknown as { __tsProceduresKind?: string }).__tsProceduresKind = classified.kind
94
+ }
95
+ }
96
+ const finalError = classified?.error ?? rawErr
97
+
62
98
  await runOnError(
63
- { procedureName: descriptor.name, scope: descriptor.scope, request, error: err },
99
+ { procedureName: descriptor.name, scope: descriptor.scope, request, error: finalError },
64
100
  hooks,
65
101
  options,
66
102
  )
67
- throw err
103
+ throw finalError
68
104
  }
69
105
 
70
106
  // 6. Run after-response hooks — they may mutate response.status to swallow errors
@@ -81,8 +117,13 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
81
117
  procedureName: descriptor.name,
82
118
  scope: descriptor.scope,
83
119
  })
84
- if (typed) throw typed
85
- throw new ClientRequestError({
120
+ if (typed) {
121
+ // Tag so executeSafeCall can distinguish typed registry errors from plain
122
+ // ClientHttpError without re-inspecting the registry.
123
+ ;(typed as unknown as { __tsProceduresTyped?: boolean }).__tsProceduresTyped = true
124
+ throw typed
125
+ }
126
+ throw new ClientHttpError({
86
127
  status: response.status,
87
128
  headers: response.headers,
88
129
  body: response.body,
@@ -94,3 +135,64 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
94
135
  // 8. Return the body
95
136
  return response.body as TResponse
96
137
  }
138
+
139
+ /**
140
+ * Wraps `executeCall` and returns a discriminated `Result` instead of throwing.
141
+ *
142
+ * Three failure-source paths map to distinct kinds:
143
+ * 1. Pre-adapter throw (e.g. `ClientPathParamError`) → `kind: 'usage'`
144
+ * 2. Adapter throw, classified → `kind: 'network' | 'timeout' | 'aborted' | <custom> | 'unknown'`
145
+ * 3. Adapter returns non-2xx → `kind: 'typed'` (registry match) or `kind: 'http'`
146
+ *
147
+ * `onError` hook fires on path 2 and 3 (cross-cutting telemetry); NOT on
148
+ * path 1 (usage errors bypass the classifier and onError entirely).
149
+ */
150
+ export async function executeSafeCall<TResponse, ETyped = never>(
151
+ config: ExecuteCallConfig,
152
+ ): Promise<Result<TResponse, ETyped>> {
153
+ try {
154
+ const value = await executeCall<TResponse>(config)
155
+ return { ok: true, value }
156
+ } catch (err) {
157
+ return classifyThrownError<ETyped>(err)
158
+ }
159
+ }
160
+
161
+ function classifyThrownError<ETyped>(err: unknown): Result<never, ETyped> {
162
+ // Path 1: pre-adapter usage error — bypasses classifier and onError
163
+ if (err instanceof ClientPathParamError) {
164
+ return { ok: false, kind: 'usage', error: err }
165
+ }
166
+ // Path 3: post-status-check typed registry match — tagged by executeCall
167
+ if (err instanceof Error && (err as unknown as { __tsProceduresTyped?: boolean }).__tsProceduresTyped) {
168
+ return { ok: false, kind: 'typed', error: err as ETyped }
169
+ }
170
+ // Path 3: non-2xx fallback (no registry match)
171
+ if (err instanceof ClientHttpError) {
172
+ return { ok: false, kind: 'http', error: err }
173
+ }
174
+ // Path 2: classifier output (already normalized by executeCall)
175
+ if (err instanceof ClientNetworkError) {
176
+ return { ok: false, kind: 'network', error: err }
177
+ }
178
+ if (err instanceof ClientTimeoutError) {
179
+ return { ok: false, kind: 'timeout', error: err }
180
+ }
181
+ if (err instanceof ClientAbortError) {
182
+ return { ok: false, kind: 'aborted', error: err }
183
+ }
184
+ if (err instanceof ClientParseError) {
185
+ return { ok: false, kind: 'parse', error: err }
186
+ }
187
+ // Custom adapter-classified error — tagged with its kind by executeCall.
188
+ // The cast is intentional: the framework knows only the default ClientErrorMap
189
+ // keys, but consumer-augmented kinds are valid at the consumer's site (where
190
+ // the augmented map is in scope). The runtime kind string is whatever the
191
+ // adapter classifier returned; we trust it to match a registered entry.
192
+ if (err instanceof Error && typeof (err as unknown as { __tsProceduresKind?: string }).__tsProceduresKind === 'string') {
193
+ const kind = (err as unknown as { __tsProceduresKind: string }).__tsProceduresKind
194
+ return { ok: false, kind: kind as keyof ClientErrorMap, error: err } as FrameworkFailure
195
+ }
196
+ // Fallthrough — unrecognized throw type (non-Error or unclassified)
197
+ return { ok: false, kind: 'unknown', error: err }
198
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { defaultClassifyError } from './classify-error.js'
3
+ import {
4
+ ClientNetworkError,
5
+ ClientTimeoutError,
6
+ ClientAbortError,
7
+ } from './errors.js'
8
+
9
+ const meta = { procedureName: 'GetUser', scope: 'users' }
10
+
11
+ describe('defaultClassifyError', () => {
12
+ it('classifies fetch TypeError as network', () => {
13
+ const result = defaultClassifyError(new TypeError('Failed to fetch'), { ...meta })
14
+ expect(result?.kind).toBe('network')
15
+ expect(result?.error).toBeInstanceOf(ClientNetworkError)
16
+ expect(result?.error.cause).toBeInstanceOf(TypeError)
17
+ })
18
+
19
+ it('classifies AbortError as timeout when timeout signal fired', () => {
20
+ const timeoutSignal = AbortSignal.timeout(0)
21
+ // wait a tick for the timeout to fire
22
+ return new Promise<void>((resolve) => setTimeout(() => {
23
+ const abortErr = new DOMException('aborted', 'AbortError')
24
+ const result = defaultClassifyError(abortErr, { ...meta, timeoutSignal, timeoutMs: 5000 })
25
+ expect(result?.kind).toBe('timeout')
26
+ expect(result?.error).toBeInstanceOf(ClientTimeoutError)
27
+ expect((result?.error as ClientTimeoutError).timeoutMs).toBe(5000)
28
+ resolve()
29
+ }, 1))
30
+ })
31
+
32
+ it('classifies AbortError as aborted when user signal fired', () => {
33
+ const userController = new AbortController()
34
+ userController.abort('user-cancelled')
35
+ const abortErr = new DOMException('aborted', 'AbortError')
36
+ const result = defaultClassifyError(abortErr, { ...meta, userSignal: userController.signal })
37
+ expect(result?.kind).toBe('aborted')
38
+ expect(result?.error).toBeInstanceOf(ClientAbortError)
39
+ expect((result?.error as ClientAbortError).reason).toBe('user-cancelled')
40
+ })
41
+
42
+ it('classifies AbortError as timeout when both fired (timeout precedence)', () => {
43
+ const timeoutSignal = AbortSignal.timeout(0)
44
+ const userController = new AbortController()
45
+ return new Promise<void>((resolve) => setTimeout(() => {
46
+ userController.abort('user')
47
+ const abortErr = new DOMException('aborted', 'AbortError')
48
+ const result = defaultClassifyError(abortErr, {
49
+ ...meta, timeoutSignal, timeoutMs: 1, userSignal: userController.signal,
50
+ })
51
+ expect(result?.kind).toBe('timeout')
52
+ resolve()
53
+ }, 1))
54
+ })
55
+
56
+ it('returns null for unknown errors', () => {
57
+ const result = defaultClassifyError(new Error('weird'), { ...meta })
58
+ expect(result).toBeNull()
59
+ })
60
+
61
+ it('returns null for non-Error throws', () => {
62
+ expect(defaultClassifyError('string-thrown', { ...meta })).toBeNull()
63
+ expect(defaultClassifyError(42, { ...meta })).toBeNull()
64
+ })
65
+ })
@@ -0,0 +1,59 @@
1
+ import {
2
+ ClientNetworkError,
3
+ ClientTimeoutError,
4
+ ClientAbortError,
5
+ } from './errors.js'
6
+ import type { ErrorClassifier } from './types.js'
7
+
8
+ /**
9
+ * Default classifier — recognizes:
10
+ * - `TypeError` from fetch → `ClientNetworkError`
11
+ * - `DOMException` with `name: 'AbortError'` + timeout-signal-aborted → `ClientTimeoutError`
12
+ * - `DOMException` with `name: 'AbortError'` + user-signal-aborted → `ClientAbortError`
13
+ *
14
+ * Returns `null` for anything else. Timeout precedence: when both signals
15
+ * fired, classifies as `timeout`.
16
+ */
17
+ export const defaultClassifyError: ErrorClassifier = (raw, ctx) => {
18
+ const meta = { procedureName: ctx.procedureName, scope: ctx.scope }
19
+
20
+ if (raw instanceof TypeError) {
21
+ return {
22
+ kind: 'network',
23
+ error: new ClientNetworkError({ ...meta, cause: raw, message: raw.message }),
24
+ }
25
+ }
26
+
27
+ if (
28
+ raw instanceof DOMException &&
29
+ raw.name === 'AbortError'
30
+ ) {
31
+ if (ctx.timeoutSignal?.aborted) {
32
+ return {
33
+ kind: 'timeout',
34
+ error: new ClientTimeoutError({
35
+ ...meta,
36
+ timeoutMs: ctx.timeoutMs ?? 0,
37
+ cause: raw,
38
+ }),
39
+ }
40
+ }
41
+ if (ctx.userSignal?.aborted) {
42
+ return {
43
+ kind: 'aborted',
44
+ error: new ClientAbortError({
45
+ ...meta,
46
+ reason: ctx.userSignal.reason,
47
+ cause: raw,
48
+ }),
49
+ }
50
+ }
51
+ // AbortError without a tracked source — treat as user abort with no reason.
52
+ return {
53
+ kind: 'aborted',
54
+ error: new ClientAbortError({ ...meta, cause: raw }),
55
+ }
56
+ }
57
+
58
+ return null
59
+ }
@@ -9,7 +9,7 @@ import type { ErrorRegistry, ErrorResponseMeta } from './types.js'
9
9
  * - `fromResponse` returns a non-Error value (defensive — registry entries
10
10
  * are expected to return `Error` subclasses).
11
11
  *
12
- * Callers fall back to `ClientRequestError` when this returns `null`.
12
+ * Callers fall back to `ClientHttpError` when this returns `null`.
13
13
  */
14
14
  export function dispatchTypedError(
15
15
  registry: ErrorRegistry | undefined,
@@ -1,9 +1,40 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'
2
+ import {
3
+ ClientHttpError,
4
+ ClientRequestError,
5
+ ClientPathParamError,
6
+ ClientStreamError,
7
+ ClientNetworkError,
8
+ ClientTimeoutError,
9
+ ClientAbortError,
10
+ ClientParseError,
11
+ } from './errors.js'
12
+
13
+ describe('ClientHttpError', () => {
14
+ it('exposes status, headers, body, procedureName, scope', () => {
15
+ const err = new ClientHttpError({
16
+ status: 500, headers: { 'x-trace': 'abc' }, body: { error: 'boom' },
17
+ procedureName: 'GetUser', scope: 'users',
18
+ })
19
+ expect(err.status).toBe(500)
20
+ expect(err.headers['x-trace']).toBe('abc')
21
+ expect(err.body).toEqual({ error: 'boom' })
22
+ expect(err.procedureName).toBe('GetUser')
23
+ expect(err.scope).toBe('users')
24
+ expect(err.name).toBe('ClientHttpError')
25
+ })
26
+
27
+ it('accepts cause', () => {
28
+ const cause = new Error('underlying')
29
+ const err = new ClientHttpError({
30
+ status: 500, headers: {}, body: null,
31
+ procedureName: 'X', scope: 'y', cause,
32
+ })
33
+ expect(err.cause).toBe(cause)
34
+ })
3
35
 
4
- describe('ClientRequestError', () => {
5
36
  it('includes status, headers, and body', () => {
6
- const err = new ClientRequestError({
37
+ const err = new ClientHttpError({
7
38
  status: 401,
8
39
  headers: { 'x-request-id': 'abc' },
9
40
  body: { message: 'Unauthorized' },
@@ -11,7 +42,7 @@ describe('ClientRequestError', () => {
11
42
  scope: 'users',
12
43
  })
13
44
  expect(err).toBeInstanceOf(Error)
14
- expect(err.name).toBe('ClientRequestError')
45
+ expect(err.name).toBe('ClientHttpError')
15
46
  expect(err.status).toBe(401)
16
47
  expect(err.headers['x-request-id']).toBe('abc')
17
48
  expect(err.body).toEqual({ message: 'Unauthorized' })
@@ -21,6 +52,23 @@ describe('ClientRequestError', () => {
21
52
  })
22
53
  })
23
54
 
55
+ describe('ClientRequestError deprecated alias', () => {
56
+ it('is identical to ClientHttpError', () => {
57
+ expect(ClientRequestError).toBe(ClientHttpError)
58
+ })
59
+
60
+ it('instanceof check works against thrown ClientHttpError', () => {
61
+ // Consumers on the previous major used `catch (e) { if (e instanceof
62
+ // ClientRequestError) ... }`. The alias must keep that pattern working
63
+ // until the alias is removed in the next minor cycle's deprecation window.
64
+ const err = new ClientHttpError({
65
+ status: 500, headers: {}, body: null,
66
+ procedureName: 'X', scope: 'y',
67
+ })
68
+ expect(err instanceof ClientRequestError).toBe(true)
69
+ })
70
+ })
71
+
24
72
  describe('ClientPathParamError', () => {
25
73
  it('reports missing param', () => {
26
74
  const err = new ClientPathParamError('id', '/users/:id', 'GetUser')
@@ -29,6 +77,12 @@ describe('ClientPathParamError', () => {
29
77
  expect(err.message).toContain('id')
30
78
  expect(err.message).toContain('/users/:id')
31
79
  })
80
+
81
+ it('accepts and stores cause', () => {
82
+ const cause = new TypeError('underlying')
83
+ const err = new ClientPathParamError('id', '/u/:id', 'GetUser', cause)
84
+ expect(err.cause).toBe(cause)
85
+ })
32
86
  })
33
87
 
34
88
  describe('ClientStreamError', () => {
@@ -40,4 +94,54 @@ describe('ClientStreamError', () => {
40
94
  expect(err.scope).toBe('events')
41
95
  expect(err.message).toBe('stream interrupted')
42
96
  })
97
+
98
+ it('accepts and stores cause', () => {
99
+ const cause = new Error('underlying')
100
+ const err = new ClientStreamError('boom', 'StreamUsers', 'users', cause)
101
+ expect(err.cause).toBe(cause)
102
+ })
103
+ })
104
+
105
+ describe('ClientNetworkError', () => {
106
+ it('carries procedureName, scope, cause', () => {
107
+ const cause = new TypeError('fetch failed')
108
+ const err = new ClientNetworkError({
109
+ procedureName: 'X', scope: 'y', cause,
110
+ })
111
+ expect(err.name).toBe('ClientNetworkError')
112
+ expect(err.procedureName).toBe('X')
113
+ expect(err.scope).toBe('y')
114
+ expect(err.cause).toBe(cause)
115
+ })
116
+ })
117
+
118
+ describe('ClientTimeoutError', () => {
119
+ it('carries timeoutMs', () => {
120
+ const err = new ClientTimeoutError({
121
+ procedureName: 'X', scope: 'y', timeoutMs: 5000,
122
+ })
123
+ expect(err.name).toBe('ClientTimeoutError')
124
+ expect(err.timeoutMs).toBe(5000)
125
+ })
126
+ })
127
+
128
+ describe('ClientAbortError', () => {
129
+ it('carries reason from abort signal', () => {
130
+ const err = new ClientAbortError({
131
+ procedureName: 'X', scope: 'y', reason: 'user-cancelled',
132
+ })
133
+ expect(err.name).toBe('ClientAbortError')
134
+ expect(err.reason).toBe('user-cancelled')
135
+ })
136
+ })
137
+
138
+ describe('ClientParseError', () => {
139
+ it('carries procedureName, scope, cause', () => {
140
+ const cause = new SyntaxError('Unexpected token')
141
+ const err = new ClientParseError({
142
+ procedureName: 'X', scope: 'y', cause,
143
+ })
144
+ expect(err.name).toBe('ClientParseError')
145
+ expect(err.cause).toBe(cause)
146
+ })
43
147
  })
@@ -1,5 +1,5 @@
1
- export class ClientRequestError extends Error {
2
- readonly name = 'ClientRequestError'
1
+ export class ClientHttpError extends Error {
2
+ readonly name = 'ClientHttpError'
3
3
  readonly status: number
4
4
  readonly headers: Record<string, string>
5
5
  readonly body: unknown
@@ -12,8 +12,12 @@ export class ClientRequestError extends Error {
12
12
  body: unknown
13
13
  procedureName: string
14
14
  scope: string
15
+ cause?: unknown
15
16
  }) {
16
- super(`${opts.procedureName} (${opts.scope}) failed with status ${opts.status}`)
17
+ super(
18
+ `${opts.procedureName} (${opts.scope}) failed with status ${opts.status}`,
19
+ { cause: opts.cause }
20
+ )
17
21
  this.status = opts.status
18
22
  this.headers = opts.headers
19
23
  this.body = opts.body
@@ -22,11 +26,18 @@ export class ClientRequestError extends Error {
22
26
  }
23
27
  }
24
28
 
29
+ /** @deprecated Renamed to `ClientHttpError`. The alias is retained for one minor release after the 7.0.0 major bump and will be removed in a subsequent minor (e.g. 7.1.0). Migrate to `ClientHttpError` now. */
30
+ // eslint-disable-next-line no-redeclare
31
+ export const ClientRequestError = ClientHttpError
32
+ /** @deprecated Renamed to `ClientHttpError`. The alias is retained for one minor release after the 7.0.0 major bump and will be removed in a subsequent minor (e.g. 7.1.0). Migrate to `ClientHttpError` now. */
33
+ // eslint-disable-next-line no-redeclare
34
+ export type ClientRequestError = ClientHttpError
35
+
25
36
  export class ClientPathParamError extends Error {
26
37
  readonly name = 'ClientPathParamError'
27
38
 
28
- constructor(param: string, path: string, procedureName: string) {
29
- super(`Missing path parameter "${param}" in "${path}" for procedure ${procedureName}`)
39
+ constructor(param: string, path: string, procedureName: string, cause?: unknown) {
40
+ super(`Missing path parameter "${param}" in "${path}" for procedure ${procedureName}`, { cause })
30
41
  }
31
42
  }
32
43
 
@@ -35,9 +46,61 @@ export class ClientStreamError extends Error {
35
46
  readonly procedureName: string
36
47
  readonly scope: string
37
48
 
38
- constructor(message: string, procedureName: string, scope: string) {
39
- super(message)
49
+ constructor(message: string, procedureName: string, scope: string, cause?: unknown) {
50
+ super(message, { cause })
40
51
  this.procedureName = procedureName
41
52
  this.scope = scope
42
53
  }
43
54
  }
55
+
56
+ export class ClientNetworkError extends Error {
57
+ readonly name = 'ClientNetworkError'
58
+ readonly procedureName: string
59
+ readonly scope: string
60
+
61
+ constructor(opts: { procedureName: string; scope: string; cause?: unknown; message?: string }) {
62
+ super(opts.message ?? `${opts.procedureName} (${opts.scope}) failed: network error`, { cause: opts.cause })
63
+ this.procedureName = opts.procedureName
64
+ this.scope = opts.scope
65
+ }
66
+ }
67
+
68
+ export class ClientTimeoutError extends Error {
69
+ readonly name = 'ClientTimeoutError'
70
+ readonly procedureName: string
71
+ readonly scope: string
72
+ readonly timeoutMs: number
73
+
74
+ constructor(opts: { procedureName: string; scope: string; timeoutMs: number; cause?: unknown }) {
75
+ super(`${opts.procedureName} (${opts.scope}) timed out after ${opts.timeoutMs}ms`, { cause: opts.cause })
76
+ this.procedureName = opts.procedureName
77
+ this.scope = opts.scope
78
+ this.timeoutMs = opts.timeoutMs
79
+ }
80
+ }
81
+
82
+ export class ClientAbortError extends Error {
83
+ readonly name = 'ClientAbortError'
84
+ readonly procedureName: string
85
+ readonly scope: string
86
+ readonly reason: unknown
87
+
88
+ constructor(opts: { procedureName: string; scope: string; reason?: unknown; cause?: unknown }) {
89
+ super(`${opts.procedureName} (${opts.scope}) aborted`, { cause: opts.cause })
90
+ this.procedureName = opts.procedureName
91
+ this.scope = opts.scope
92
+ this.reason = opts.reason
93
+ }
94
+ }
95
+
96
+ export class ClientParseError extends Error {
97
+ readonly name = 'ClientParseError'
98
+ readonly procedureName: string
99
+ readonly scope: string
100
+
101
+ constructor(opts: { procedureName: string; scope: string; cause?: unknown; message?: string }) {
102
+ super(opts.message ?? `${opts.procedureName} (${opts.scope}) response could not be parsed`, { cause: opts.cause })
103
+ this.procedureName = opts.procedureName
104
+ this.scope = opts.scope
105
+ }
106
+ }
@@ -338,3 +338,18 @@ describe('createFetchAdapter — stream()', () => {
338
338
  expect(items).toEqual([{ data: { ok: true }, event: 'message', id: undefined }])
339
339
  })
340
340
  })
341
+
342
+ // ── classifyError config ──────────────────────────────
343
+
344
+ describe('createFetchAdapter classifyError', () => {
345
+ it('passes through to adapter.classifyError', () => {
346
+ const customClassifier = vi.fn()
347
+ const adapter = createFetchAdapter({ classifyError: customClassifier })
348
+ expect(adapter.classifyError).toBe(customClassifier)
349
+ })
350
+
351
+ it('does not set classifyError when not provided', () => {
352
+ const adapter = createFetchAdapter()
353
+ expect(adapter.classifyError).toBeUndefined()
354
+ })
355
+ })
@@ -1,9 +1,10 @@
1
- import type { ClientAdapter, AdapterRequest, AdapterResponse, AdapterStreamResponse } from './types.js'
1
+ import type { ClientAdapter, AdapterRequest, AdapterResponse, AdapterStreamResponse, ErrorClassifier } from './types.js'
2
2
 
3
3
  // ── Config ────────────────────────────────────────────────
4
4
 
5
5
  export interface FetchAdapterConfig {
6
6
  headers?: Record<string, string>
7
+ classifyError?: ErrorClassifier
7
8
  }
8
9
 
9
10
  // ── SSE parser ────────────────────────────────────────────
@@ -180,7 +181,7 @@ export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
180
181
 
181
182
  // Non-2xx responses on a stream endpoint are JSON, not SSE. Parse the
182
183
  // body eagerly and surface it via errorBody so the client can dispatch
183
- // a typed error (or fall back to ClientRequestError with a real body).
184
+ // a typed error (or fall back to ClientHttpError with a real body).
184
185
  if (response.status < 200 || response.status >= 300) {
185
186
  const errorBody = await parseResponseBody(response)
186
187
  return { status: response.status, headers, body: emptyBody, errorBody }
@@ -193,5 +194,7 @@ export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
193
194
  const body = parseSseStream(response.body as ReadableStream<Uint8Array>)
194
195
  return { status: response.status, headers, body }
195
196
  },
197
+
198
+ classifyError: config?.classifyError,
196
199
  }
197
200
  }