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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { defaultClassifyError } from './classify-error.js'
|
|
3
|
+
import {
|
|
4
|
+
ClientNetworkError,
|
|
5
|
+
ClientTimeoutError,
|
|
6
|
+
ClientAbortError,
|
|
7
|
+
} from './errors.js'
|
|
8
|
+
|
|
9
|
+
const meta = { procedureName: 'GetUser', scope: 'users' }
|
|
10
|
+
|
|
11
|
+
describe('defaultClassifyError', () => {
|
|
12
|
+
it('classifies fetch TypeError as network', () => {
|
|
13
|
+
const result = defaultClassifyError(new TypeError('Failed to fetch'), { ...meta })
|
|
14
|
+
expect(result?.kind).toBe('network')
|
|
15
|
+
expect(result?.error).toBeInstanceOf(ClientNetworkError)
|
|
16
|
+
expect(result?.error.cause).toBeInstanceOf(TypeError)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('classifies AbortError as timeout when timeout signal fired', () => {
|
|
20
|
+
const timeoutSignal = AbortSignal.timeout(0)
|
|
21
|
+
// wait a tick for the timeout to fire
|
|
22
|
+
return new Promise<void>((resolve) => setTimeout(() => {
|
|
23
|
+
const abortErr = new DOMException('aborted', 'AbortError')
|
|
24
|
+
const result = defaultClassifyError(abortErr, { ...meta, timeoutSignal, timeoutMs: 5000 })
|
|
25
|
+
expect(result?.kind).toBe('timeout')
|
|
26
|
+
expect(result?.error).toBeInstanceOf(ClientTimeoutError)
|
|
27
|
+
expect((result?.error as ClientTimeoutError).timeoutMs).toBe(5000)
|
|
28
|
+
resolve()
|
|
29
|
+
}, 1))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('classifies AbortError as aborted when user signal fired', () => {
|
|
33
|
+
const userController = new AbortController()
|
|
34
|
+
userController.abort('user-cancelled')
|
|
35
|
+
const abortErr = new DOMException('aborted', 'AbortError')
|
|
36
|
+
const result = defaultClassifyError(abortErr, { ...meta, userSignal: userController.signal })
|
|
37
|
+
expect(result?.kind).toBe('aborted')
|
|
38
|
+
expect(result?.error).toBeInstanceOf(ClientAbortError)
|
|
39
|
+
expect((result?.error as ClientAbortError).reason).toBe('user-cancelled')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('classifies AbortError as timeout when both fired (timeout precedence)', () => {
|
|
43
|
+
const timeoutSignal = AbortSignal.timeout(0)
|
|
44
|
+
const userController = new AbortController()
|
|
45
|
+
return new Promise<void>((resolve) => setTimeout(() => {
|
|
46
|
+
userController.abort('user')
|
|
47
|
+
const abortErr = new DOMException('aborted', 'AbortError')
|
|
48
|
+
const result = defaultClassifyError(abortErr, {
|
|
49
|
+
...meta, timeoutSignal, timeoutMs: 1, userSignal: userController.signal,
|
|
50
|
+
})
|
|
51
|
+
expect(result?.kind).toBe('timeout')
|
|
52
|
+
resolve()
|
|
53
|
+
}, 1))
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns null for unknown errors', () => {
|
|
57
|
+
const result = defaultClassifyError(new Error('weird'), { ...meta })
|
|
58
|
+
expect(result).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns null for non-Error throws', () => {
|
|
62
|
+
expect(defaultClassifyError('string-thrown', { ...meta })).toBeNull()
|
|
63
|
+
expect(defaultClassifyError(42, { ...meta })).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientNetworkError,
|
|
3
|
+
ClientTimeoutError,
|
|
4
|
+
ClientAbortError,
|
|
5
|
+
} from './errors.js'
|
|
6
|
+
import type { ErrorClassifier } from './types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default classifier — recognizes:
|
|
10
|
+
* - `TypeError` from fetch → `ClientNetworkError`
|
|
11
|
+
* - `DOMException` with `name: 'AbortError'` + timeout-signal-aborted → `ClientTimeoutError`
|
|
12
|
+
* - `DOMException` with `name: 'AbortError'` + user-signal-aborted → `ClientAbortError`
|
|
13
|
+
*
|
|
14
|
+
* Returns `null` for anything else. Timeout precedence: when both signals
|
|
15
|
+
* fired, classifies as `timeout`.
|
|
16
|
+
*/
|
|
17
|
+
export const defaultClassifyError: ErrorClassifier = (raw, ctx) => {
|
|
18
|
+
const meta = { procedureName: ctx.procedureName, scope: ctx.scope }
|
|
19
|
+
|
|
20
|
+
if (raw instanceof TypeError) {
|
|
21
|
+
return {
|
|
22
|
+
kind: 'network',
|
|
23
|
+
error: new ClientNetworkError({ ...meta, cause: raw, message: raw.message }),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (
|
|
28
|
+
raw instanceof DOMException &&
|
|
29
|
+
raw.name === 'AbortError'
|
|
30
|
+
) {
|
|
31
|
+
if (ctx.timeoutSignal?.aborted) {
|
|
32
|
+
return {
|
|
33
|
+
kind: 'timeout',
|
|
34
|
+
error: new ClientTimeoutError({
|
|
35
|
+
...meta,
|
|
36
|
+
timeoutMs: ctx.timeoutMs ?? 0,
|
|
37
|
+
cause: raw,
|
|
38
|
+
}),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (ctx.userSignal?.aborted) {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'aborted',
|
|
44
|
+
error: new ClientAbortError({
|
|
45
|
+
...meta,
|
|
46
|
+
reason: ctx.userSignal.reason,
|
|
47
|
+
cause: raw,
|
|
48
|
+
}),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// AbortError without a tracked source — treat as user abort with no reason.
|
|
52
|
+
return {
|
|
53
|
+
kind: 'aborted',
|
|
54
|
+
error: new ClientAbortError({ ...meta, cause: raw }),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
@@ -9,7 +9,7 @@ import type { ErrorRegistry, ErrorResponseMeta } from './types.js'
|
|
|
9
9
|
* - `fromResponse` returns a non-Error value (defensive — registry entries
|
|
10
10
|
* are expected to return `Error` subclasses).
|
|
11
11
|
*
|
|
12
|
-
* Callers fall back to `
|
|
12
|
+
* Callers fall back to `ClientHttpError` when this returns `null`.
|
|
13
13
|
*/
|
|
14
14
|
export function dispatchTypedError(
|
|
15
15
|
registry: ErrorRegistry | undefined,
|
|
@@ -1,9 +1,40 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ClientHttpError,
|
|
4
|
+
ClientRequestError,
|
|
5
|
+
ClientPathParamError,
|
|
6
|
+
ClientStreamError,
|
|
7
|
+
ClientNetworkError,
|
|
8
|
+
ClientTimeoutError,
|
|
9
|
+
ClientAbortError,
|
|
10
|
+
ClientParseError,
|
|
11
|
+
} from './errors.js'
|
|
12
|
+
|
|
13
|
+
describe('ClientHttpError', () => {
|
|
14
|
+
it('exposes status, headers, body, procedureName, scope', () => {
|
|
15
|
+
const err = new ClientHttpError({
|
|
16
|
+
status: 500, headers: { 'x-trace': 'abc' }, body: { error: 'boom' },
|
|
17
|
+
procedureName: 'GetUser', scope: 'users',
|
|
18
|
+
})
|
|
19
|
+
expect(err.status).toBe(500)
|
|
20
|
+
expect(err.headers['x-trace']).toBe('abc')
|
|
21
|
+
expect(err.body).toEqual({ error: 'boom' })
|
|
22
|
+
expect(err.procedureName).toBe('GetUser')
|
|
23
|
+
expect(err.scope).toBe('users')
|
|
24
|
+
expect(err.name).toBe('ClientHttpError')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('accepts cause', () => {
|
|
28
|
+
const cause = new Error('underlying')
|
|
29
|
+
const err = new ClientHttpError({
|
|
30
|
+
status: 500, headers: {}, body: null,
|
|
31
|
+
procedureName: 'X', scope: 'y', cause,
|
|
32
|
+
})
|
|
33
|
+
expect(err.cause).toBe(cause)
|
|
34
|
+
})
|
|
3
35
|
|
|
4
|
-
describe('ClientRequestError', () => {
|
|
5
36
|
it('includes status, headers, and body', () => {
|
|
6
|
-
const err = new
|
|
37
|
+
const err = new ClientHttpError({
|
|
7
38
|
status: 401,
|
|
8
39
|
headers: { 'x-request-id': 'abc' },
|
|
9
40
|
body: { message: 'Unauthorized' },
|
|
@@ -11,7 +42,7 @@ describe('ClientRequestError', () => {
|
|
|
11
42
|
scope: 'users',
|
|
12
43
|
})
|
|
13
44
|
expect(err).toBeInstanceOf(Error)
|
|
14
|
-
expect(err.name).toBe('
|
|
45
|
+
expect(err.name).toBe('ClientHttpError')
|
|
15
46
|
expect(err.status).toBe(401)
|
|
16
47
|
expect(err.headers['x-request-id']).toBe('abc')
|
|
17
48
|
expect(err.body).toEqual({ message: 'Unauthorized' })
|
|
@@ -21,6 +52,23 @@ describe('ClientRequestError', () => {
|
|
|
21
52
|
})
|
|
22
53
|
})
|
|
23
54
|
|
|
55
|
+
describe('ClientRequestError deprecated alias', () => {
|
|
56
|
+
it('is identical to ClientHttpError', () => {
|
|
57
|
+
expect(ClientRequestError).toBe(ClientHttpError)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('instanceof check works against thrown ClientHttpError', () => {
|
|
61
|
+
// Consumers on the previous major used `catch (e) { if (e instanceof
|
|
62
|
+
// ClientRequestError) ... }`. The alias must keep that pattern working
|
|
63
|
+
// until the alias is removed in the next minor cycle's deprecation window.
|
|
64
|
+
const err = new ClientHttpError({
|
|
65
|
+
status: 500, headers: {}, body: null,
|
|
66
|
+
procedureName: 'X', scope: 'y',
|
|
67
|
+
})
|
|
68
|
+
expect(err instanceof ClientRequestError).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
24
72
|
describe('ClientPathParamError', () => {
|
|
25
73
|
it('reports missing param', () => {
|
|
26
74
|
const err = new ClientPathParamError('id', '/users/:id', 'GetUser')
|
|
@@ -29,6 +77,12 @@ describe('ClientPathParamError', () => {
|
|
|
29
77
|
expect(err.message).toContain('id')
|
|
30
78
|
expect(err.message).toContain('/users/:id')
|
|
31
79
|
})
|
|
80
|
+
|
|
81
|
+
it('accepts and stores cause', () => {
|
|
82
|
+
const cause = new TypeError('underlying')
|
|
83
|
+
const err = new ClientPathParamError('id', '/u/:id', 'GetUser', cause)
|
|
84
|
+
expect(err.cause).toBe(cause)
|
|
85
|
+
})
|
|
32
86
|
})
|
|
33
87
|
|
|
34
88
|
describe('ClientStreamError', () => {
|
|
@@ -40,4 +94,54 @@ describe('ClientStreamError', () => {
|
|
|
40
94
|
expect(err.scope).toBe('events')
|
|
41
95
|
expect(err.message).toBe('stream interrupted')
|
|
42
96
|
})
|
|
97
|
+
|
|
98
|
+
it('accepts and stores cause', () => {
|
|
99
|
+
const cause = new Error('underlying')
|
|
100
|
+
const err = new ClientStreamError('boom', 'StreamUsers', 'users', cause)
|
|
101
|
+
expect(err.cause).toBe(cause)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('ClientNetworkError', () => {
|
|
106
|
+
it('carries procedureName, scope, cause', () => {
|
|
107
|
+
const cause = new TypeError('fetch failed')
|
|
108
|
+
const err = new ClientNetworkError({
|
|
109
|
+
procedureName: 'X', scope: 'y', cause,
|
|
110
|
+
})
|
|
111
|
+
expect(err.name).toBe('ClientNetworkError')
|
|
112
|
+
expect(err.procedureName).toBe('X')
|
|
113
|
+
expect(err.scope).toBe('y')
|
|
114
|
+
expect(err.cause).toBe(cause)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('ClientTimeoutError', () => {
|
|
119
|
+
it('carries timeoutMs', () => {
|
|
120
|
+
const err = new ClientTimeoutError({
|
|
121
|
+
procedureName: 'X', scope: 'y', timeoutMs: 5000,
|
|
122
|
+
})
|
|
123
|
+
expect(err.name).toBe('ClientTimeoutError')
|
|
124
|
+
expect(err.timeoutMs).toBe(5000)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('ClientAbortError', () => {
|
|
129
|
+
it('carries reason from abort signal', () => {
|
|
130
|
+
const err = new ClientAbortError({
|
|
131
|
+
procedureName: 'X', scope: 'y', reason: 'user-cancelled',
|
|
132
|
+
})
|
|
133
|
+
expect(err.name).toBe('ClientAbortError')
|
|
134
|
+
expect(err.reason).toBe('user-cancelled')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('ClientParseError', () => {
|
|
139
|
+
it('carries procedureName, scope, cause', () => {
|
|
140
|
+
const cause = new SyntaxError('Unexpected token')
|
|
141
|
+
const err = new ClientParseError({
|
|
142
|
+
procedureName: 'X', scope: 'y', cause,
|
|
143
|
+
})
|
|
144
|
+
expect(err.name).toBe('ClientParseError')
|
|
145
|
+
expect(err.cause).toBe(cause)
|
|
146
|
+
})
|
|
43
147
|
})
|
package/src/client/errors.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export class
|
|
2
|
-
readonly name = '
|
|
1
|
+
export class ClientHttpError extends Error {
|
|
2
|
+
readonly name = 'ClientHttpError'
|
|
3
3
|
readonly status: number
|
|
4
4
|
readonly headers: Record<string, string>
|
|
5
5
|
readonly body: unknown
|
|
@@ -12,8 +12,12 @@ export class ClientRequestError extends Error {
|
|
|
12
12
|
body: unknown
|
|
13
13
|
procedureName: string
|
|
14
14
|
scope: string
|
|
15
|
+
cause?: unknown
|
|
15
16
|
}) {
|
|
16
|
-
super(
|
|
17
|
+
super(
|
|
18
|
+
`${opts.procedureName} (${opts.scope}) failed with status ${opts.status}`,
|
|
19
|
+
{ cause: opts.cause }
|
|
20
|
+
)
|
|
17
21
|
this.status = opts.status
|
|
18
22
|
this.headers = opts.headers
|
|
19
23
|
this.body = opts.body
|
|
@@ -22,11 +26,18 @@ export class ClientRequestError extends Error {
|
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
/** @deprecated Renamed to `ClientHttpError`. The alias is retained for one minor release after the 7.0.0 major bump and will be removed in a subsequent minor (e.g. 7.1.0). Migrate to `ClientHttpError` now. */
|
|
30
|
+
// eslint-disable-next-line no-redeclare
|
|
31
|
+
export const ClientRequestError = ClientHttpError
|
|
32
|
+
/** @deprecated Renamed to `ClientHttpError`. The alias is retained for one minor release after the 7.0.0 major bump and will be removed in a subsequent minor (e.g. 7.1.0). Migrate to `ClientHttpError` now. */
|
|
33
|
+
// eslint-disable-next-line no-redeclare
|
|
34
|
+
export type ClientRequestError = ClientHttpError
|
|
35
|
+
|
|
25
36
|
export class ClientPathParamError extends Error {
|
|
26
37
|
readonly name = 'ClientPathParamError'
|
|
27
38
|
|
|
28
|
-
constructor(param: string, path: string, procedureName: string) {
|
|
29
|
-
super(`Missing path parameter "${param}" in "${path}" for procedure ${procedureName}
|
|
39
|
+
constructor(param: string, path: string, procedureName: string, cause?: unknown) {
|
|
40
|
+
super(`Missing path parameter "${param}" in "${path}" for procedure ${procedureName}`, { cause })
|
|
30
41
|
}
|
|
31
42
|
}
|
|
32
43
|
|
|
@@ -35,9 +46,61 @@ export class ClientStreamError extends Error {
|
|
|
35
46
|
readonly procedureName: string
|
|
36
47
|
readonly scope: string
|
|
37
48
|
|
|
38
|
-
constructor(message: string, procedureName: string, scope: string) {
|
|
39
|
-
super(message)
|
|
49
|
+
constructor(message: string, procedureName: string, scope: string, cause?: unknown) {
|
|
50
|
+
super(message, { cause })
|
|
40
51
|
this.procedureName = procedureName
|
|
41
52
|
this.scope = scope
|
|
42
53
|
}
|
|
43
54
|
}
|
|
55
|
+
|
|
56
|
+
export class ClientNetworkError extends Error {
|
|
57
|
+
readonly name = 'ClientNetworkError'
|
|
58
|
+
readonly procedureName: string
|
|
59
|
+
readonly scope: string
|
|
60
|
+
|
|
61
|
+
constructor(opts: { procedureName: string; scope: string; cause?: unknown; message?: string }) {
|
|
62
|
+
super(opts.message ?? `${opts.procedureName} (${opts.scope}) failed: network error`, { cause: opts.cause })
|
|
63
|
+
this.procedureName = opts.procedureName
|
|
64
|
+
this.scope = opts.scope
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ClientTimeoutError extends Error {
|
|
69
|
+
readonly name = 'ClientTimeoutError'
|
|
70
|
+
readonly procedureName: string
|
|
71
|
+
readonly scope: string
|
|
72
|
+
readonly timeoutMs: number
|
|
73
|
+
|
|
74
|
+
constructor(opts: { procedureName: string; scope: string; timeoutMs: number; cause?: unknown }) {
|
|
75
|
+
super(`${opts.procedureName} (${opts.scope}) timed out after ${opts.timeoutMs}ms`, { cause: opts.cause })
|
|
76
|
+
this.procedureName = opts.procedureName
|
|
77
|
+
this.scope = opts.scope
|
|
78
|
+
this.timeoutMs = opts.timeoutMs
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ClientAbortError extends Error {
|
|
83
|
+
readonly name = 'ClientAbortError'
|
|
84
|
+
readonly procedureName: string
|
|
85
|
+
readonly scope: string
|
|
86
|
+
readonly reason: unknown
|
|
87
|
+
|
|
88
|
+
constructor(opts: { procedureName: string; scope: string; reason?: unknown; cause?: unknown }) {
|
|
89
|
+
super(`${opts.procedureName} (${opts.scope}) aborted`, { cause: opts.cause })
|
|
90
|
+
this.procedureName = opts.procedureName
|
|
91
|
+
this.scope = opts.scope
|
|
92
|
+
this.reason = opts.reason
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class ClientParseError extends Error {
|
|
97
|
+
readonly name = 'ClientParseError'
|
|
98
|
+
readonly procedureName: string
|
|
99
|
+
readonly scope: string
|
|
100
|
+
|
|
101
|
+
constructor(opts: { procedureName: string; scope: string; cause?: unknown; message?: string }) {
|
|
102
|
+
super(opts.message ?? `${opts.procedureName} (${opts.scope}) response could not be parsed`, { cause: opts.cause })
|
|
103
|
+
this.procedureName = opts.procedureName
|
|
104
|
+
this.scope = opts.scope
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -338,3 +338,18 @@ describe('createFetchAdapter — stream()', () => {
|
|
|
338
338
|
expect(items).toEqual([{ data: { ok: true }, event: 'message', id: undefined }])
|
|
339
339
|
})
|
|
340
340
|
})
|
|
341
|
+
|
|
342
|
+
// ── classifyError config ──────────────────────────────
|
|
343
|
+
|
|
344
|
+
describe('createFetchAdapter classifyError', () => {
|
|
345
|
+
it('passes through to adapter.classifyError', () => {
|
|
346
|
+
const customClassifier = vi.fn()
|
|
347
|
+
const adapter = createFetchAdapter({ classifyError: customClassifier })
|
|
348
|
+
expect(adapter.classifyError).toBe(customClassifier)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('does not set classifyError when not provided', () => {
|
|
352
|
+
const adapter = createFetchAdapter()
|
|
353
|
+
expect(adapter.classifyError).toBeUndefined()
|
|
354
|
+
})
|
|
355
|
+
})
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { ClientAdapter, AdapterRequest, AdapterResponse, AdapterStreamResponse } from './types.js'
|
|
1
|
+
import type { ClientAdapter, AdapterRequest, AdapterResponse, AdapterStreamResponse, ErrorClassifier } from './types.js'
|
|
2
2
|
|
|
3
3
|
// ── Config ────────────────────────────────────────────────
|
|
4
4
|
|
|
5
5
|
export interface FetchAdapterConfig {
|
|
6
6
|
headers?: Record<string, string>
|
|
7
|
+
classifyError?: ErrorClassifier
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
// ── SSE parser ────────────────────────────────────────────
|
|
@@ -180,7 +181,7 @@ export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
|
|
|
180
181
|
|
|
181
182
|
// Non-2xx responses on a stream endpoint are JSON, not SSE. Parse the
|
|
182
183
|
// body eagerly and surface it via errorBody so the client can dispatch
|
|
183
|
-
// a typed error (or fall back to
|
|
184
|
+
// a typed error (or fall back to ClientHttpError with a real body).
|
|
184
185
|
if (response.status < 200 || response.status >= 300) {
|
|
185
186
|
const errorBody = await parseResponseBody(response)
|
|
186
187
|
return { status: response.status, headers, body: emptyBody, errorBody }
|
|
@@ -193,5 +194,7 @@ export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
|
|
|
193
194
|
const body = parseSseStream(response.body as ReadableStream<Uint8Array>)
|
|
194
195
|
return { status: response.status, headers, body }
|
|
195
196
|
},
|
|
197
|
+
|
|
198
|
+
classifyError: config?.classifyError,
|
|
196
199
|
}
|
|
197
200
|
}
|
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,7 @@ import type {
|
|
|
7
7
|
StreamDescriptor,
|
|
8
8
|
ProcedureCallOptions,
|
|
9
9
|
TypedStream,
|
|
10
|
+
Result,
|
|
10
11
|
} from './types.js'
|
|
11
12
|
|
|
12
13
|
// ── createClient ──────────────────────────────────────────
|
|
@@ -56,6 +57,21 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
|
|
|
56
57
|
})
|
|
57
58
|
},
|
|
58
59
|
|
|
60
|
+
safeCall<TResponse, ETyped = never>(
|
|
61
|
+
descriptor: CallDescriptor,
|
|
62
|
+
options?: ProcedureCallOptions,
|
|
63
|
+
): Promise<Result<TResponse, ETyped>> {
|
|
64
|
+
return executeSafeCall<TResponse, ETyped>({
|
|
65
|
+
descriptor,
|
|
66
|
+
basePath,
|
|
67
|
+
adapter,
|
|
68
|
+
hooks: globalHooks,
|
|
69
|
+
defaults: globalDefaults,
|
|
70
|
+
options,
|
|
71
|
+
errorRegistry,
|
|
72
|
+
})
|
|
73
|
+
},
|
|
74
|
+
|
|
59
75
|
stream<TYield, TReturn>(
|
|
60
76
|
descriptor: StreamDescriptor,
|
|
61
77
|
options?: ProcedureCallOptions,
|
|
@@ -133,15 +149,35 @@ export type {
|
|
|
133
149
|
ErrorRegistry,
|
|
134
150
|
ErrorFactory,
|
|
135
151
|
ErrorResponseMeta,
|
|
152
|
+
ClientErrorMap,
|
|
153
|
+
FrameworkFailure,
|
|
154
|
+
Result,
|
|
155
|
+
ResultNoTyped,
|
|
136
156
|
} from './types.js'
|
|
137
157
|
|
|
138
158
|
export { dispatchTypedError } from './error-dispatch.js'
|
|
139
159
|
|
|
140
|
-
export {
|
|
160
|
+
export {
|
|
161
|
+
ClientHttpError,
|
|
162
|
+
ClientRequestError,
|
|
163
|
+
ClientPathParamError,
|
|
164
|
+
ClientStreamError,
|
|
165
|
+
ClientNetworkError,
|
|
166
|
+
ClientTimeoutError,
|
|
167
|
+
ClientAbortError,
|
|
168
|
+
ClientParseError,
|
|
169
|
+
} from './errors.js'
|
|
141
170
|
|
|
142
171
|
export { createTypedStream } from './stream.js'
|
|
143
|
-
export { executeCall } from './call.js'
|
|
172
|
+
export { executeCall, executeSafeCall } from './call.js'
|
|
144
173
|
export { executeStream } from './stream.js'
|
|
145
174
|
|
|
146
175
|
export { createFetchAdapter } from './fetch-adapter.js'
|
|
147
176
|
export type { FetchAdapterConfig } from './fetch-adapter.js'
|
|
177
|
+
|
|
178
|
+
export { defaultClassifyError } from './classify-error.js'
|
|
179
|
+
export type {
|
|
180
|
+
ErrorClassifier,
|
|
181
|
+
ClassifyErrorContext,
|
|
182
|
+
ClassifiedError,
|
|
183
|
+
} from './types.js'
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
resolveHeaders,
|
|
6
6
|
resolveMeta,
|
|
7
7
|
resolveSignal,
|
|
8
|
+
resolveSignalSources,
|
|
8
9
|
} from './resolve-options.js'
|
|
9
10
|
import type { AdapterRequest, ProcedureCallDefaults, ProcedureCallOptions } from './types.js'
|
|
10
11
|
|
|
@@ -141,8 +142,72 @@ describe('resolveSignal', () => {
|
|
|
141
142
|
})
|
|
142
143
|
})
|
|
143
144
|
|
|
145
|
+
// ── resolveSignalSources ──────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe('resolveSignalSources', () => {
|
|
148
|
+
it('returns timeout signal alongside combined', () => {
|
|
149
|
+
const result = resolveSignalSources(undefined, { timeout: 100 })
|
|
150
|
+
expect(result.timeoutSignal).toBeInstanceOf(AbortSignal)
|
|
151
|
+
expect(result.timeoutMs).toBe(100)
|
|
152
|
+
expect(result.combined).toBeInstanceOf(AbortSignal)
|
|
153
|
+
expect(result.userSignal).toBeUndefined()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns user signal alongside combined', () => {
|
|
157
|
+
const ctrl = new AbortController()
|
|
158
|
+
const result = resolveSignalSources(undefined, { signal: ctrl.signal })
|
|
159
|
+
expect(result.userSignal).toBe(ctrl.signal)
|
|
160
|
+
expect(result.timeoutSignal).toBeUndefined()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('combines both via AbortSignal.any', () => {
|
|
164
|
+
const ctrl = new AbortController()
|
|
165
|
+
const result = resolveSignalSources(undefined, { signal: ctrl.signal, timeout: 100 })
|
|
166
|
+
expect(result.timeoutSignal).toBeInstanceOf(AbortSignal)
|
|
167
|
+
expect(result.userSignal).toBe(ctrl.signal)
|
|
168
|
+
expect(result.combined).toBeInstanceOf(AbortSignal)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('returns undefined combined when no signals', () => {
|
|
172
|
+
const result = resolveSignalSources(undefined, undefined)
|
|
173
|
+
expect(result.combined).toBeUndefined()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('per-call timeout: 0 disables inherited default timeout', () => {
|
|
177
|
+
const result = resolveSignalSources({ timeout: 1000 }, { timeout: 0 })
|
|
178
|
+
expect(result.timeoutSignal).toBeUndefined()
|
|
179
|
+
expect(result.timeoutMs).toBe(0)
|
|
180
|
+
expect(result.combined).toBeUndefined()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('merges default signal with per-call signal', () => {
|
|
184
|
+
const defaultCtrl = new AbortController()
|
|
185
|
+
const callCtrl = new AbortController()
|
|
186
|
+
const result = resolveSignalSources(
|
|
187
|
+
{ signal: defaultCtrl.signal },
|
|
188
|
+
{ signal: callCtrl.signal },
|
|
189
|
+
)
|
|
190
|
+
// userSignal is the per-call signal (takes precedence)
|
|
191
|
+
expect(result.userSignal).toBe(callCtrl.signal)
|
|
192
|
+
// combined should be a new AbortSignal.any
|
|
193
|
+
expect(result.combined).toBeInstanceOf(AbortSignal)
|
|
194
|
+
expect(result.combined).not.toBe(callCtrl.signal)
|
|
195
|
+
expect(result.combined).not.toBe(defaultCtrl.signal)
|
|
196
|
+
// aborting either source aborts the combined signal
|
|
197
|
+
defaultCtrl.abort()
|
|
198
|
+
expect(result.combined!.aborted).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
144
202
|
// ── applyRequestOptions ───────────────────────────────────
|
|
145
203
|
|
|
204
|
+
// Helper so existing tests can destructure .request without boilerplate
|
|
205
|
+
const apply = (
|
|
206
|
+
req: AdapterRequest,
|
|
207
|
+
d: ProcedureCallDefaults | undefined,
|
|
208
|
+
o: ProcedureCallOptions | undefined,
|
|
209
|
+
) => applyRequestOptions(req, d, o).request
|
|
210
|
+
|
|
146
211
|
describe('applyRequestOptions', () => {
|
|
147
212
|
const baseRequest: AdapterRequest = {
|
|
148
213
|
url: 'https://api.example.com/foo',
|
|
@@ -151,7 +216,7 @@ describe('applyRequestOptions', () => {
|
|
|
151
216
|
}
|
|
152
217
|
|
|
153
218
|
it('returns the request unchanged when nothing is provided', () => {
|
|
154
|
-
const result =
|
|
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
|
})
|