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.
- 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 +215 -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/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 +15 -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 +96 -3
- package/build/codegen/bundle-size.test.d.ts +1 -0
- package/build/codegen/bundle-size.test.js +68 -0
- package/build/codegen/bundle-size.test.js.map +1 -0
- package/build/codegen/e2e.test.js +103 -1
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +7 -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 +94 -26
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +297 -2
- 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/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 +39 -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 +112 -3
- package/src/codegen/bundle-size.test.ts +74 -0
- package/src/codegen/e2e.test.ts +108 -1
- package/src/codegen/emit-client-runtime.test.ts +7 -2
- package/src/codegen/emit-client-runtime.ts +7 -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 +324 -2
- 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
|
-
|
|
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,
|
package/src/client/types.ts
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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 `
|
|
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
|
+
})
|