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
@@ -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,
@@ -30,11 +30,52 @@
30
30
  // eslint-disable-next-line @typescript-eslint/no-empty-object-type
31
31
  export interface RequestMeta {}
32
32
 
33
+ // ── Error Classifier ─────────────────────────────────────
34
+
35
+ /**
36
+ * Classification context — supplies provenance the classifier needs that's
37
+ * not derivable from the raw error itself.
38
+ *
39
+ * `timeoutSignal` and `userSignal` let the classifier distinguish a timeout
40
+ * abort from a user-initiated abort when an `AbortError` lands.
41
+ */
42
+ export interface ClassifyErrorContext {
43
+ procedureName: string
44
+ scope: string
45
+ timeoutSignal?: AbortSignal
46
+ userSignal?: AbortSignal
47
+ timeoutMs?: number
48
+ }
49
+
50
+ /**
51
+ * The output shape of a successful classification. Contract: `error` is
52
+ * always an `Error` subclass (the framework class). Non-`Error` values fall
53
+ * through to `null` (handled by `executeCall` as `kind: 'unknown'`).
54
+ */
55
+ export interface ClassifiedError {
56
+ kind: string
57
+ error: Error
58
+ }
59
+
60
+ /**
61
+ * Adapter-provided classifier — runs before `defaultClassifyError`. Return
62
+ * `null` to fall through to the default. Adapter authors should compose with
63
+ * the default explicitly:
64
+ *
65
+ * classifyError: (e, ctx) => myClassify(e, ctx) ?? defaultClassifyError(e, ctx)
66
+ */
67
+ export type ErrorClassifier = (
68
+ raw: unknown,
69
+ ctx: ClassifyErrorContext,
70
+ ) => ClassifiedError | null
71
+
33
72
  // ── Adapter ──────────────────────────────────────────────
34
73
 
35
74
  export interface ClientAdapter {
36
75
  request(config: AdapterRequest): Promise<AdapterResponse>
37
76
  stream(config: AdapterRequest): Promise<AdapterStreamResponse>
77
+ /** Optional adapter-level error classifier — composes with `defaultClassifyError`. */
78
+ classifyError?: ErrorClassifier
38
79
  }
39
80
 
40
81
  export interface AdapterRequest {
@@ -63,7 +104,7 @@ export interface AdapterStreamResponse {
63
104
  /**
64
105
  * Populated when `status` is non-2xx — the parsed response body. Surfaced so
65
106
  * `executeStream` can dispatch typed errors via the error registry instead
66
- * of always falling back to `ClientRequestError` with `body: null`.
107
+ * of always falling back to `ClientHttpError` with `body: null`.
67
108
  */
68
109
  errorBody?: unknown
69
110
  }
@@ -177,7 +218,7 @@ export interface ErrorFactory {
177
218
  /**
178
219
  * Maps `body.name` values (taxonomy keys) to error class factories. When the
179
220
  * client sees a non-2xx response whose body has a `name` matching a registry
180
- * entry, it throws the typed error instead of a generic `ClientRequestError`.
221
+ * entry, it throws the typed error instead of a generic `ClientHttpError`.
181
222
  */
182
223
  export type ErrorRegistry = Record<string, ErrorFactory>
183
224
 
@@ -191,6 +232,10 @@ export interface ClientInstance {
191
232
  /** Optional registry for runtime dispatch of typed errors by `body.name`. */
192
233
  errorRegistry?: ErrorRegistry
193
234
  call<TResponse>(descriptor: CallDescriptor, options?: ProcedureCallOptions): Promise<TResponse>
235
+ safeCall<TResponse, ETyped = never>(
236
+ descriptor: CallDescriptor,
237
+ options?: ProcedureCallOptions,
238
+ ): Promise<Result<TResponse, ETyped>>
194
239
  stream<TYield, TReturn>(descriptor: StreamDescriptor, options?: ProcedureCallOptions): TypedStream<TYield, TReturn>
195
240
  }
196
241
 
@@ -211,7 +256,71 @@ export interface CreateClientConfig<TScopes> {
211
256
  * Optional error-dispatch registry. When a non-2xx response body has a
212
257
  * `name` field matching a registry key, the client throws the typed error
213
258
  * constructed via that entry's `fromResponse`. When absent or when no key
214
- * matches, falls back to `ClientRequestError` (transport error shape).
259
+ * matches, falls back to `ClientHttpError` (transport error shape).
215
260
  */
216
261
  errorRegistry?: ErrorRegistry
217
262
  }
263
+
264
+ // ── Result Types ─────────────────────────────────────────
265
+
266
+ import type {
267
+ ClientHttpError,
268
+ ClientNetworkError,
269
+ ClientTimeoutError,
270
+ ClientAbortError,
271
+ ClientParseError,
272
+ ClientPathParamError,
273
+ } from './errors.js'
274
+
275
+ /**
276
+ * Augmentable map of `kind` discriminant → error class for the framework's
277
+ * non-typed failure categories. Mirrors the `RequestMeta` augmentation
278
+ * pattern: extend via TypeScript declaration merging.
279
+ *
280
+ * @example
281
+ * ```ts
282
+ * declare module 'ts-procedures/client' {
283
+ * interface ClientErrorMap {
284
+ * rateLimited: MyRateLimitError
285
+ * paymentRequired: MyPaymentError
286
+ * }
287
+ * }
288
+ * ```
289
+ *
290
+ * **`'typed'` is reserved** — it's the discriminant used by `Result` for
291
+ * route-declared errors and is not part of `ClientErrorMap`. Attempting to
292
+ * register it produces a TS error about overlapping discriminants.
293
+ */
294
+ export interface ClientErrorMap {
295
+ http: ClientHttpError
296
+ network: ClientNetworkError
297
+ timeout: ClientTimeoutError
298
+ aborted: ClientAbortError
299
+ parse: ClientParseError
300
+ usage: ClientPathParamError
301
+ unknown: unknown
302
+ }
303
+
304
+ /** Distributed union of every framework failure kind. */
305
+ export type FrameworkFailure = {
306
+ [K in keyof ClientErrorMap]: { ok: false; kind: K; error: ClientErrorMap[K] }
307
+ }[keyof ClientErrorMap]
308
+
309
+ /**
310
+ * Discriminated result type for the `.safe()` form. `kind: 'typed'` carries
311
+ * route-declared errors (registry-dispatched); other kinds carry framework
312
+ * failures. Use `ResultNoTyped<T>` for routes without declared errors.
313
+ */
314
+ export type Result<T, ETyped> =
315
+ | { ok: true; value: T }
316
+ | { ok: false; kind: 'typed'; error: ETyped }
317
+ | FrameworkFailure
318
+
319
+ /**
320
+ * `Result` for routes that don't declare typed errors. Omits the `'typed'`
321
+ * arm entirely so IDE hovers stay clean (TS doesn't collapse `never`-payload
322
+ * arms in tooltip output).
323
+ */
324
+ export type ResultNoTyped<T> =
325
+ | { ok: true; value: T }
326
+ | FrameworkFailure
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest'
2
+ import { generateClient } from './index.js'
3
+ import * as path from 'node:path'
4
+ import * as os from 'node:os'
5
+ import * as fs from 'node:fs/promises'
6
+ import type { DocEnvelope } from '../implementations/types.js'
7
+
8
+ function makeEnvelope(routeCount: number): DocEnvelope {
9
+ const routes = Array.from({ length: routeCount }, (_, i) => ({
10
+ kind: 'rpc' as const,
11
+ name: `Op${i}`,
12
+ scope: 'ops',
13
+ method: 'post' as const,
14
+ path: `/ops/op${i}`,
15
+ version: 1,
16
+ jsonSchema: {
17
+ body: { type: 'object' as const, properties: { x: { type: 'string' as const } } },
18
+ response: { type: 'object' as const, properties: { y: { type: 'string' as const } } },
19
+ },
20
+ errors: [] as string[],
21
+ }))
22
+ return { basePath: '/api', headers: [], errors: [], routes }
23
+ }
24
+
25
+ describe('bundle size budget', () => {
26
+ let perRouteDelta: number
27
+ let totalChars: number
28
+
29
+ beforeAll(async () => {
30
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'tsproc-bundle-'))
31
+ await generateClient({
32
+ envelope: makeEnvelope(100),
33
+ outDir: tmp,
34
+ selfContained: false,
35
+ })
36
+ const scopeFile = await fs.readFile(path.join(tmp, 'ops.ts'), 'utf-8')
37
+
38
+ // Strip whitespace, line comments, block comments. This is a stable proxy
39
+ // for minification — actual minified bytes will be lower, but the *delta*
40
+ // between commits is what this test guards against.
41
+ const stripped = scopeFile
42
+ .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
43
+ .replace(/\/\/.*$/gm, '') // line comments
44
+ .replace(/\s+/g, ' ') // collapse whitespace
45
+ totalChars = stripped.length
46
+ perRouteDelta = totalChars / 100
47
+ })
48
+
49
+ it('stays within 1500 chars per route post-strip', () => {
50
+ // Initial budget — refine after first measurement. The .safe sibling adds
51
+ // roughly 400 chars of unminified emission per route (Object.assign wrapper
52
+ // + duplicated descriptor literal). Post-strip should land well under 1500.
53
+ //
54
+ // RELATIONSHIP TO SPEC TARGET: The spec sets a "200 bytes per route
55
+ // post-minify" goal. This whitespace-strip proxy is a coarse stand-in
56
+ // chosen to avoid an esbuild dep — actual minified bytes will be lower
57
+ // than `perRouteDelta` here. If a future task swaps in real esbuild
58
+ // minification, tighten the budget to ~250 bytes (200 + 25% headroom)
59
+ // and link back to spec section "Tests / item 10". For now this guard
60
+ // catches catastrophic regressions only, not subtle bloat.
61
+ //
62
+ // PERROUTEDELTA_BASELINE: 613.2
63
+ expect(perRouteDelta).toBeLessThan(1500)
64
+ })
65
+
66
+ it('logs the baseline measurement for review', () => {
67
+ // This isn't a strict assertion — it surfaces the actual numbers to the
68
+ // test output so reviewers can update PERROUTEDELTA_BASELINE in the
69
+ // comment above without having to re-run locally.
70
+ // eslint-disable-next-line no-console
71
+ console.log(`[bundle-size] 100 routes, scope file = ${totalChars} chars stripped, per-route = ${perRouteDelta.toFixed(1)}`)
72
+ expect(totalChars).toBeGreaterThan(0)
73
+ })
74
+ })