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.
- package/README.md +2 -0
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +38 -1
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +253 -3
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +60 -2
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +1 -1
- package/agent_config/copilot/copilot-instructions.md +3 -0
- package/agent_config/cursor/cursorrules +3 -0
- package/build/client/augment-error-map.test-d.d.ts +10 -0
- package/build/client/augment-error-map.test-d.js +14 -0
- package/build/client/augment-error-map.test-d.js.map +1 -0
- package/build/client/bind-callable.test.d.ts +1 -0
- package/build/client/bind-callable.test.js +132 -0
- package/build/client/bind-callable.test.js.map +1 -0
- package/build/client/call.d.ts +14 -2
- package/build/client/call.js +96 -9
- package/build/client/call.js.map +1 -1
- package/build/client/call.test.js +50 -1
- package/build/client/call.test.js.map +1 -1
- package/build/client/classify-error.d.ts +11 -0
- package/build/client/classify-error.js +49 -0
- package/build/client/classify-error.js.map +1 -0
- package/build/client/classify-error.test.d.ts +1 -0
- package/build/client/classify-error.test.js +55 -0
- package/build/client/classify-error.test.js.map +1 -0
- package/build/client/error-dispatch.d.ts +1 -1
- package/build/client/error-dispatch.js +1 -1
- package/build/client/errors.d.ts +55 -4
- package/build/client/errors.js +54 -7
- package/build/client/errors.js.map +1 -1
- package/build/client/errors.test.js +89 -4
- package/build/client/errors.test.js.map +1 -1
- package/build/client/fetch-adapter.d.ts +2 -1
- package/build/client/fetch-adapter.js +2 -1
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/fetch-adapter.test.js +12 -0
- package/build/client/fetch-adapter.test.js.map +1 -1
- package/build/client/index.d.ts +5 -3
- package/build/client/index.js +29 -3
- package/build/client/index.js.map +1 -1
- package/build/client/resolve-options.d.ts +32 -1
- package/build/client/resolve-options.js +32 -16
- package/build/client/resolve-options.js.map +1 -1
- package/build/client/resolve-options.test.js +67 -6
- package/build/client/resolve-options.test.js.map +1 -1
- package/build/client/result-type.test-d.d.ts +1 -0
- package/build/client/result-type.test-d.js +28 -0
- package/build/client/result-type.test-d.js.map +1 -0
- package/build/client/safe-call.test.d.ts +1 -0
- package/build/client/safe-call.test.js +137 -0
- package/build/client/safe-call.test.js.map +1 -0
- package/build/client/stream.d.ts +1 -1
- package/build/client/stream.js +22 -8
- package/build/client/stream.js.map +1 -1
- package/build/client/stream.test.js +11 -1
- package/build/client/stream.test.js.map +1 -1
- package/build/client/types.d.ts +117 -3
- package/build/codegen/bundle-size.test.d.ts +1 -0
- package/build/codegen/bundle-size.test.js +70 -0
- package/build/codegen/bundle-size.test.js.map +1 -0
- package/build/codegen/e2e.test.js +108 -7
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +8 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-client-runtime.test.js +6 -2
- package/build/codegen/emit-client-runtime.test.js.map +1 -1
- package/build/codegen/emit-client-types.d.ts +7 -2
- package/build/codegen/emit-client-types.js +29 -8
- package/build/codegen/emit-client-types.js.map +1 -1
- package/build/codegen/emit-client-types.test.js +20 -8
- package/build/codegen/emit-client-types.test.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +1 -1
- package/build/codegen/emit-errors.js +1 -1
- package/build/codegen/emit-index.js +1 -1
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-scope.js +37 -25
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +310 -14
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/docs/client-and-codegen.md +77 -7
- package/docs/client-error-handling.md +357 -0
- package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
- package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
- package/package.json +1 -1
- package/src/client/augment-error-map.test-d.ts +22 -0
- package/src/client/bind-callable.test.ts +137 -0
- package/src/client/call.test.ts +65 -1
- package/src/client/call.ts +111 -9
- package/src/client/classify-error.test.ts +65 -0
- package/src/client/classify-error.ts +59 -0
- package/src/client/error-dispatch.ts +1 -1
- package/src/client/errors.test.ts +108 -4
- package/src/client/errors.ts +70 -7
- package/src/client/fetch-adapter.test.ts +15 -0
- package/src/client/fetch-adapter.ts +5 -2
- package/src/client/index.ts +60 -3
- package/src/client/resolve-options.test.ts +83 -5
- package/src/client/resolve-options.ts +61 -16
- package/src/client/result-type.test-d.ts +51 -0
- package/src/client/safe-call.test.ts +157 -0
- package/src/client/stream.test.ts +13 -1
- package/src/client/stream.ts +25 -8
- package/src/client/types.ts +137 -3
- package/src/codegen/bundle-size.test.ts +76 -0
- package/src/codegen/e2e.test.ts +113 -7
- package/src/codegen/emit-client-runtime.test.ts +7 -2
- package/src/codegen/emit-client-runtime.ts +8 -0
- package/src/codegen/emit-client-types.test.ts +22 -7
- package/src/codegen/emit-client-types.ts +35 -10
- package/src/codegen/emit-errors.ts +1 -1
- package/src/codegen/emit-index.ts +1 -1
- package/src/codegen/emit-scope.test.ts +337 -14
- package/src/codegen/emit-scope.ts +39 -35
package/src/client/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { executeCall } from './call.js'
|
|
1
|
+
import { executeCall, executeSafeCall } from './call.js'
|
|
2
2
|
import { executeStream, createTypedStream } from './stream.js'
|
|
3
3
|
import type {
|
|
4
4
|
CreateClientConfig,
|
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
StreamDescriptor,
|
|
8
8
|
ProcedureCallOptions,
|
|
9
9
|
TypedStream,
|
|
10
|
+
Result,
|
|
11
|
+
ResultNoTyped,
|
|
10
12
|
} from './types.js'
|
|
11
13
|
|
|
12
14
|
// ── createClient ──────────────────────────────────────────
|
|
@@ -56,6 +58,41 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
|
|
|
56
58
|
})
|
|
57
59
|
},
|
|
58
60
|
|
|
61
|
+
safeCall<TResponse, ETyped = never>(
|
|
62
|
+
descriptor: CallDescriptor,
|
|
63
|
+
options?: ProcedureCallOptions,
|
|
64
|
+
): Promise<Result<TResponse, ETyped>> {
|
|
65
|
+
return executeSafeCall<TResponse, ETyped>({
|
|
66
|
+
descriptor,
|
|
67
|
+
basePath,
|
|
68
|
+
adapter,
|
|
69
|
+
hooks: globalHooks,
|
|
70
|
+
defaults: globalDefaults,
|
|
71
|
+
options,
|
|
72
|
+
errorRegistry,
|
|
73
|
+
})
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
bindCallable<TParams, TResponse>(descriptor: Omit<CallDescriptor, 'params'>) {
|
|
77
|
+
const call = (params: TParams, options?: ProcedureCallOptions) =>
|
|
78
|
+
instance.call<TResponse>({ ...descriptor, params }, options)
|
|
79
|
+
Object.defineProperty(call, 'name', { value: descriptor.name, configurable: true })
|
|
80
|
+
return Object.assign(call, {
|
|
81
|
+
safe: (params: TParams, options?: ProcedureCallOptions) =>
|
|
82
|
+
instance.safeCall<TResponse>({ ...descriptor, params }, options) as Promise<ResultNoTyped<TResponse>>,
|
|
83
|
+
})
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
bindCallableTyped<TParams, TResponse, ETyped>(descriptor: Omit<CallDescriptor, 'params'>) {
|
|
87
|
+
const call = (params: TParams, options?: ProcedureCallOptions) =>
|
|
88
|
+
instance.call<TResponse>({ ...descriptor, params }, options)
|
|
89
|
+
Object.defineProperty(call, 'name', { value: descriptor.name, configurable: true })
|
|
90
|
+
return Object.assign(call, {
|
|
91
|
+
safe: (params: TParams, options?: ProcedureCallOptions) =>
|
|
92
|
+
instance.safeCall<TResponse, ETyped>({ ...descriptor, params }, options),
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
|
|
59
96
|
stream<TYield, TReturn>(
|
|
60
97
|
descriptor: StreamDescriptor,
|
|
61
98
|
options?: ProcedureCallOptions,
|
|
@@ -133,15 +170,35 @@ export type {
|
|
|
133
170
|
ErrorRegistry,
|
|
134
171
|
ErrorFactory,
|
|
135
172
|
ErrorResponseMeta,
|
|
173
|
+
ClientErrorMap,
|
|
174
|
+
FrameworkFailure,
|
|
175
|
+
Result,
|
|
176
|
+
ResultNoTyped,
|
|
136
177
|
} from './types.js'
|
|
137
178
|
|
|
138
179
|
export { dispatchTypedError } from './error-dispatch.js'
|
|
139
180
|
|
|
140
|
-
export {
|
|
181
|
+
export {
|
|
182
|
+
ClientHttpError,
|
|
183
|
+
ClientRequestError,
|
|
184
|
+
ClientPathParamError,
|
|
185
|
+
ClientStreamError,
|
|
186
|
+
ClientNetworkError,
|
|
187
|
+
ClientTimeoutError,
|
|
188
|
+
ClientAbortError,
|
|
189
|
+
ClientParseError,
|
|
190
|
+
} from './errors.js'
|
|
141
191
|
|
|
142
192
|
export { createTypedStream } from './stream.js'
|
|
143
|
-
export { executeCall } from './call.js'
|
|
193
|
+
export { executeCall, executeSafeCall } from './call.js'
|
|
144
194
|
export { executeStream } from './stream.js'
|
|
145
195
|
|
|
146
196
|
export { createFetchAdapter } from './fetch-adapter.js'
|
|
147
197
|
export type { FetchAdapterConfig } from './fetch-adapter.js'
|
|
198
|
+
|
|
199
|
+
export { defaultClassifyError } from './classify-error.js'
|
|
200
|
+
export type {
|
|
201
|
+
ErrorClassifier,
|
|
202
|
+
ClassifyErrorContext,
|
|
203
|
+
ClassifiedError,
|
|
204
|
+
} from './types.js'
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
resolveHeaders,
|
|
6
6
|
resolveMeta,
|
|
7
7
|
resolveSignal,
|
|
8
|
+
resolveSignalSources,
|
|
8
9
|
} from './resolve-options.js'
|
|
9
10
|
import type { AdapterRequest, ProcedureCallDefaults, ProcedureCallOptions } from './types.js'
|
|
10
11
|
|
|
@@ -141,8 +142,72 @@ describe('resolveSignal', () => {
|
|
|
141
142
|
})
|
|
142
143
|
})
|
|
143
144
|
|
|
145
|
+
// ── resolveSignalSources ──────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe('resolveSignalSources', () => {
|
|
148
|
+
it('returns timeout signal alongside combined', () => {
|
|
149
|
+
const result = resolveSignalSources(undefined, { timeout: 100 })
|
|
150
|
+
expect(result.timeoutSignal).toBeInstanceOf(AbortSignal)
|
|
151
|
+
expect(result.timeoutMs).toBe(100)
|
|
152
|
+
expect(result.combined).toBeInstanceOf(AbortSignal)
|
|
153
|
+
expect(result.userSignal).toBeUndefined()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns user signal alongside combined', () => {
|
|
157
|
+
const ctrl = new AbortController()
|
|
158
|
+
const result = resolveSignalSources(undefined, { signal: ctrl.signal })
|
|
159
|
+
expect(result.userSignal).toBe(ctrl.signal)
|
|
160
|
+
expect(result.timeoutSignal).toBeUndefined()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('combines both via AbortSignal.any', () => {
|
|
164
|
+
const ctrl = new AbortController()
|
|
165
|
+
const result = resolveSignalSources(undefined, { signal: ctrl.signal, timeout: 100 })
|
|
166
|
+
expect(result.timeoutSignal).toBeInstanceOf(AbortSignal)
|
|
167
|
+
expect(result.userSignal).toBe(ctrl.signal)
|
|
168
|
+
expect(result.combined).toBeInstanceOf(AbortSignal)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('returns undefined combined when no signals', () => {
|
|
172
|
+
const result = resolveSignalSources(undefined, undefined)
|
|
173
|
+
expect(result.combined).toBeUndefined()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('per-call timeout: 0 disables inherited default timeout', () => {
|
|
177
|
+
const result = resolveSignalSources({ timeout: 1000 }, { timeout: 0 })
|
|
178
|
+
expect(result.timeoutSignal).toBeUndefined()
|
|
179
|
+
expect(result.timeoutMs).toBe(0)
|
|
180
|
+
expect(result.combined).toBeUndefined()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('merges default signal with per-call signal', () => {
|
|
184
|
+
const defaultCtrl = new AbortController()
|
|
185
|
+
const callCtrl = new AbortController()
|
|
186
|
+
const result = resolveSignalSources(
|
|
187
|
+
{ signal: defaultCtrl.signal },
|
|
188
|
+
{ signal: callCtrl.signal },
|
|
189
|
+
)
|
|
190
|
+
// userSignal is the per-call signal (takes precedence)
|
|
191
|
+
expect(result.userSignal).toBe(callCtrl.signal)
|
|
192
|
+
// combined should be a new AbortSignal.any
|
|
193
|
+
expect(result.combined).toBeInstanceOf(AbortSignal)
|
|
194
|
+
expect(result.combined).not.toBe(callCtrl.signal)
|
|
195
|
+
expect(result.combined).not.toBe(defaultCtrl.signal)
|
|
196
|
+
// aborting either source aborts the combined signal
|
|
197
|
+
defaultCtrl.abort()
|
|
198
|
+
expect(result.combined!.aborted).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
144
202
|
// ── applyRequestOptions ───────────────────────────────────
|
|
145
203
|
|
|
204
|
+
// Helper so existing tests can destructure .request without boilerplate
|
|
205
|
+
const apply = (
|
|
206
|
+
req: AdapterRequest,
|
|
207
|
+
d: ProcedureCallDefaults | undefined,
|
|
208
|
+
o: ProcedureCallOptions | undefined,
|
|
209
|
+
) => applyRequestOptions(req, d, o).request
|
|
210
|
+
|
|
146
211
|
describe('applyRequestOptions', () => {
|
|
147
212
|
const baseRequest: AdapterRequest = {
|
|
148
213
|
url: 'https://api.example.com/foo',
|
|
@@ -151,7 +216,7 @@ describe('applyRequestOptions', () => {
|
|
|
151
216
|
}
|
|
152
217
|
|
|
153
218
|
it('returns the request unchanged when nothing is provided', () => {
|
|
154
|
-
const result =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
262
|
+
const result = apply(baseRequest, undefined, { timeout: 100 })
|
|
198
263
|
expect(spy).toHaveBeenCalledWith(100)
|
|
199
264
|
expect(result.signal).toBeDefined()
|
|
200
265
|
expect(result.signal?.aborted).toBe(false)
|
|
@@ -202,4 +267,17 @@ describe('applyRequestOptions', () => {
|
|
|
202
267
|
spy.mockRestore()
|
|
203
268
|
}
|
|
204
269
|
})
|
|
270
|
+
|
|
271
|
+
it('exposes signalSources alongside the request', () => {
|
|
272
|
+
const ctrl = new AbortController()
|
|
273
|
+
const { request, signalSources } = applyRequestOptions(baseRequest, undefined, {
|
|
274
|
+
signal: ctrl.signal,
|
|
275
|
+
timeout: 500,
|
|
276
|
+
})
|
|
277
|
+
expect(request.signal).toBeInstanceOf(AbortSignal)
|
|
278
|
+
expect(signalSources.userSignal).toBe(ctrl.signal)
|
|
279
|
+
expect(signalSources.timeoutSignal).toBeInstanceOf(AbortSignal)
|
|
280
|
+
expect(signalSources.timeoutMs).toBe(500)
|
|
281
|
+
expect(signalSources.combined).toBe(request.signal)
|
|
282
|
+
})
|
|
205
283
|
})
|
|
@@ -17,6 +17,48 @@ export function resolveBasePath(
|
|
|
17
17
|
return options?.basePath ?? defaults?.basePath ?? fallback
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Resolves signal sources individually so callers can distinguish which
|
|
22
|
+
* underlying signal fired (e.g. timeout vs user abort in the error classifier).
|
|
23
|
+
*
|
|
24
|
+
* - `userSignal`: the per-call signal (if any); separate from the default signal
|
|
25
|
+
* - `timeoutSignal`: created via `AbortSignal.timeout(timeoutMs)` when timeout > 0
|
|
26
|
+
* - `timeoutMs`: the resolved timeout value (may be 0 if per-call explicitly disables)
|
|
27
|
+
* - `combined`: `AbortSignal.any([...])` of all active signals, or undefined if none
|
|
28
|
+
*
|
|
29
|
+
* Per-call `timeout: 0` disables an inherited default timeout (same as `resolveSignal`).
|
|
30
|
+
* Both `defaults.signal` and `options.signal` are included in `combined` when present.
|
|
31
|
+
*/
|
|
32
|
+
export interface SignalSources {
|
|
33
|
+
combined?: AbortSignal
|
|
34
|
+
timeoutSignal?: AbortSignal
|
|
35
|
+
/** The per-call signal, if provided. Separate from the default signal. */
|
|
36
|
+
userSignal?: AbortSignal
|
|
37
|
+
timeoutMs?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveSignalSources(
|
|
41
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
42
|
+
options: ProcedureCallOptions | undefined,
|
|
43
|
+
): SignalSources {
|
|
44
|
+
const userSignal = options?.signal
|
|
45
|
+
// Use explicit undefined check so timeout: 0 overrides defaults (not just nullish)
|
|
46
|
+
const timeoutMs = options?.timeout !== undefined ? options.timeout : defaults?.timeout
|
|
47
|
+
const timeoutSignal =
|
|
48
|
+
timeoutMs != null && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined
|
|
49
|
+
|
|
50
|
+
const signals: AbortSignal[] = []
|
|
51
|
+
if (defaults?.signal) signals.push(defaults.signal)
|
|
52
|
+
if (userSignal) signals.push(userSignal)
|
|
53
|
+
if (timeoutSignal) signals.push(timeoutSignal)
|
|
54
|
+
|
|
55
|
+
let combined: AbortSignal | undefined
|
|
56
|
+
if (signals.length === 1) combined = signals[0]
|
|
57
|
+
else if (signals.length > 1) combined = AbortSignal.any(signals)
|
|
58
|
+
|
|
59
|
+
return { combined, timeoutSignal, userSignal, timeoutMs }
|
|
60
|
+
}
|
|
61
|
+
|
|
20
62
|
/**
|
|
21
63
|
* Resolves the effective AbortSignal by combining (via `AbortSignal.any`):
|
|
22
64
|
* - default signal (if any)
|
|
@@ -25,24 +67,15 @@ export function resolveBasePath(
|
|
|
25
67
|
*
|
|
26
68
|
* Returns undefined when none apply. Per-call `timeout: 0` disables an
|
|
27
69
|
* inherited default timeout.
|
|
70
|
+
*
|
|
71
|
+
* @deprecated Prefer `resolveSignalSources` when you need access to individual
|
|
72
|
+
* signal references (e.g. for abort-cause classification).
|
|
28
73
|
*/
|
|
29
74
|
export function resolveSignal(
|
|
30
75
|
defaults: ProcedureCallDefaults | undefined,
|
|
31
76
|
options: ProcedureCallOptions | undefined,
|
|
32
77
|
): AbortSignal | undefined {
|
|
33
|
-
|
|
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
|
-
):
|
|
103
|
-
const
|
|
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 {
|
|
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
|
+
})
|
package/src/client/stream.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 (
|
|
146
|
-
// 5. On adapter error:
|
|
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:
|
|
165
|
+
{ procedureName: descriptor.name, scope: descriptor.scope, request, error: finalError },
|
|
149
166
|
hooks,
|
|
150
167
|
options,
|
|
151
168
|
)
|
|
152
|
-
throw
|
|
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
|
|
196
|
+
throw new ClientHttpError({
|
|
180
197
|
status: responseForHooks.status,
|
|
181
198
|
headers: responseForHooks.headers,
|
|
182
199
|
body: responseForHooks.body,
|