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,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
  }
@@ -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,7 @@ import type {
7
7
  StreamDescriptor,
8
8
  ProcedureCallOptions,
9
9
  TypedStream,
10
+ Result,
10
11
  } from './types.js'
11
12
 
12
13
  // ── createClient ──────────────────────────────────────────
@@ -56,6 +57,21 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
56
57
  })
57
58
  },
58
59
 
60
+ safeCall<TResponse, ETyped = never>(
61
+ descriptor: CallDescriptor,
62
+ options?: ProcedureCallOptions,
63
+ ): Promise<Result<TResponse, ETyped>> {
64
+ return executeSafeCall<TResponse, ETyped>({
65
+ descriptor,
66
+ basePath,
67
+ adapter,
68
+ hooks: globalHooks,
69
+ defaults: globalDefaults,
70
+ options,
71
+ errorRegistry,
72
+ })
73
+ },
74
+
59
75
  stream<TYield, TReturn>(
60
76
  descriptor: StreamDescriptor,
61
77
  options?: ProcedureCallOptions,
@@ -133,15 +149,35 @@ export type {
133
149
  ErrorRegistry,
134
150
  ErrorFactory,
135
151
  ErrorResponseMeta,
152
+ ClientErrorMap,
153
+ FrameworkFailure,
154
+ Result,
155
+ ResultNoTyped,
136
156
  } from './types.js'
137
157
 
138
158
  export { dispatchTypedError } from './error-dispatch.js'
139
159
 
140
- export { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'
160
+ export {
161
+ ClientHttpError,
162
+ ClientRequestError,
163
+ ClientPathParamError,
164
+ ClientStreamError,
165
+ ClientNetworkError,
166
+ ClientTimeoutError,
167
+ ClientAbortError,
168
+ ClientParseError,
169
+ } from './errors.js'
141
170
 
142
171
  export { createTypedStream } from './stream.js'
143
- export { executeCall } from './call.js'
172
+ export { executeCall, executeSafeCall } from './call.js'
144
173
  export { executeStream } from './stream.js'
145
174
 
146
175
  export { createFetchAdapter } from './fetch-adapter.js'
147
176
  export type { FetchAdapterConfig } from './fetch-adapter.js'
177
+
178
+ export { defaultClassifyError } from './classify-error.js'
179
+ export type {
180
+ ErrorClassifier,
181
+ ClassifyErrorContext,
182
+ ClassifiedError,
183
+ } 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
  })