ts-procedures 5.16.0 → 6.0.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/agents/ts-procedures-architect.md +13 -6
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +85 -17
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +163 -5
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +169 -13
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +22 -15
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
- package/agent_config/copilot/copilot-instructions.md +77 -12
- package/agent_config/cursor/cursorrules +77 -12
- package/build/client/call.d.ts +2 -1
- package/build/client/call.js +9 -1
- package/build/client/call.js.map +1 -1
- package/build/client/error-dispatch.d.ts +13 -0
- package/build/client/error-dispatch.js +26 -0
- package/build/client/error-dispatch.js.map +1 -0
- package/build/client/error-dispatch.test.d.ts +1 -0
- package/build/client/error-dispatch.test.js +56 -0
- package/build/client/error-dispatch.test.js.map +1 -0
- package/build/client/fetch-adapter.js +10 -4
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/index.d.ts +2 -1
- package/build/client/index.js +5 -1
- package/build/client/index.js.map +1 -1
- package/build/client/stream.d.ts +2 -1
- package/build/client/stream.js +13 -3
- package/build/client/stream.js.map +1 -1
- package/build/client/typed-error-dispatch.test.d.ts +1 -0
- package/build/client/typed-error-dispatch.test.js +168 -0
- package/build/client/typed-error-dispatch.test.js.map +1 -0
- package/build/client/types.d.ts +37 -0
- package/build/codegen/e2e.test.js +9 -4
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +4 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +17 -6
- package/build/codegen/emit-errors.integration.test.d.ts +1 -0
- package/build/codegen/emit-errors.integration.test.js +162 -0
- package/build/codegen/emit-errors.integration.test.js.map +1 -0
- package/build/codegen/emit-errors.js +50 -39
- package/build/codegen/emit-errors.js.map +1 -1
- package/build/codegen/emit-errors.test.js +75 -78
- package/build/codegen/emit-errors.test.js.map +1 -1
- package/build/codegen/emit-index.d.ts +7 -0
- package/build/codegen/emit-index.js +26 -4
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-index.test.js +55 -23
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.d.ts +8 -0
- package/build/codegen/emit-scope.js +82 -7
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/pipeline.js +22 -2
- package/build/codegen/pipeline.js.map +1 -1
- package/build/implementations/http/doc-registry.d.ts +21 -0
- package/build/implementations/http/doc-registry.js +51 -78
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +8 -6
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/error-taxonomy.d.ts +240 -0
- package/build/implementations/http/error-taxonomy.js +230 -0
- package/build/implementations/http/error-taxonomy.js.map +1 -0
- package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/error-taxonomy.test.js +399 -0
- package/build/implementations/http/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/index.d.ts +39 -8
- package/build/implementations/http/express-rpc/index.js +39 -8
- package/build/implementations/http/express-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-api/index.d.ts +38 -1
- package/build/implementations/http/hono-api/index.js +32 -0
- package/build/implementations/http/hono-api/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-rpc/index.d.ts +34 -7
- package/build/implementations/http/hono-rpc/index.js +31 -4
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-stream/index.d.ts +40 -3
- package/build/implementations/http/hono-stream/index.js +37 -10
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +45 -18
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/http/on-request-error.test.d.ts +1 -0
- package/build/implementations/http/on-request-error.test.js +173 -0
- package/build/implementations/http/on-request-error.test.js.map +1 -0
- package/build/implementations/http/route-errors.test.d.ts +1 -0
- package/build/implementations/http/route-errors.test.js +140 -0
- package/build/implementations/http/route-errors.test.js.map +1 -0
- package/build/implementations/types.d.ts +30 -2
- package/docs/client-and-codegen.md +105 -12
- package/docs/core.md +14 -5
- package/docs/http-integrations.md +135 -4
- package/docs/streaming.md +3 -1
- package/package.json +7 -2
- package/src/client/call.ts +10 -1
- package/src/client/error-dispatch.test.ts +72 -0
- package/src/client/error-dispatch.ts +27 -0
- package/src/client/fetch-adapter.ts +11 -5
- package/src/client/index.ts +9 -0
- package/src/client/stream.ts +14 -3
- package/src/client/typed-error-dispatch.test.ts +211 -0
- package/src/client/types.ts +42 -0
- package/src/codegen/e2e.test.ts +9 -4
- package/src/codegen/emit-client-runtime.ts +4 -0
- package/src/codegen/emit-errors.integration.test.ts +183 -0
- package/src/codegen/emit-errors.test.ts +91 -87
- package/src/codegen/emit-errors.ts +123 -41
- package/src/codegen/emit-index.test.ts +68 -24
- package/src/codegen/emit-index.ts +66 -4
- package/src/codegen/emit-scope.ts +124 -7
- package/src/codegen/pipeline.ts +25 -2
- package/src/implementations/http/README.md +19 -4
- package/src/implementations/http/doc-registry.test.ts +10 -6
- package/src/implementations/http/doc-registry.ts +63 -80
- package/src/implementations/http/error-taxonomy.test.ts +438 -0
- package/src/implementations/http/error-taxonomy.ts +337 -0
- package/src/implementations/http/express-rpc/README.md +21 -22
- package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
- package/src/implementations/http/express-rpc/index.ts +75 -14
- package/src/implementations/http/hono-api/README.md +284 -0
- package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
- package/src/implementations/http/hono-api/index.ts +76 -1
- package/src/implementations/http/hono-rpc/README.md +18 -19
- package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
- package/src/implementations/http/hono-rpc/index.ts +65 -9
- package/src/implementations/http/hono-stream/README.md +44 -25
- package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
- package/src/implementations/http/hono-stream/index.test.ts +54 -18
- package/src/implementations/http/hono-stream/index.ts +83 -13
- package/src/implementations/http/on-request-error.test.ts +201 -0
- package/src/implementations/http/route-errors.test.ts +177 -0
- package/src/implementations/types.ts +30 -2
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ProcedureError,
|
|
3
|
+
ProcedureValidationError,
|
|
4
|
+
ProcedureYieldValidationError,
|
|
5
|
+
} from '../../errors.js'
|
|
6
|
+
import type { TProcedureRegistration, TStreamProcedureRegistration } from '../../index.js'
|
|
7
|
+
import type { ErrorDoc } from '../types.js'
|
|
8
|
+
|
|
9
|
+
/** Either a regular or stream procedure registration — accepted by taxonomy callbacks. */
|
|
10
|
+
export type TAnyProcedureRegistration = TProcedureRegistration | TStreamProcedureRegistration
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An entry in an {@link ErrorTaxonomy}. Describes how to recognize a thrown
|
|
14
|
+
* error at runtime and how to serialize it into an HTTP response.
|
|
15
|
+
*
|
|
16
|
+
* Exactly one of `class` or `match` must be provided.
|
|
17
|
+
*/
|
|
18
|
+
export type ErrorTaxonomyEntry<TError = any, TBody = unknown> = {
|
|
19
|
+
/** `instanceof` discriminator. Mutually exclusive with `match`. */
|
|
20
|
+
class?: new (...args: any[]) => TError
|
|
21
|
+
/** Predicate discriminator — for 3rd-party errors that can't subclass a framework type. */
|
|
22
|
+
match?: (err: unknown) => err is TError
|
|
23
|
+
/** HTTP status code to send when this error is caught. */
|
|
24
|
+
statusCode: number
|
|
25
|
+
/** One-line description used by DocRegistry to document the error in the envelope. */
|
|
26
|
+
description?: string
|
|
27
|
+
/** Optional response-body JSON Schema — consumed by DocRegistry. */
|
|
28
|
+
schema?: Record<string, unknown>
|
|
29
|
+
/**
|
|
30
|
+
* Maps the caught error to a response body. When omitted, the body is
|
|
31
|
+
* `{ name: <key>, message: err.message }` where `<key>` is this entry's key.
|
|
32
|
+
* When the returned object lacks a `name` field, one is auto-injected —
|
|
33
|
+
* wire-protocol consistency (needed by the client dispatcher) is guaranteed.
|
|
34
|
+
*/
|
|
35
|
+
toResponse?: (err: TError, meta: { key: string }) => TBody
|
|
36
|
+
/**
|
|
37
|
+
* Side effect on catch (logging, metrics). Awaited before the response is sent.
|
|
38
|
+
* `raw` is the framework-specific request context (Hono `Context` /
|
|
39
|
+
* `{ req, res }` for Express) — cast as needed.
|
|
40
|
+
*/
|
|
41
|
+
onCatch?: (
|
|
42
|
+
err: TError,
|
|
43
|
+
ctx: { procedure: TAnyProcedureRegistration; key: string; raw: unknown }
|
|
44
|
+
) => void | Promise<void>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A named collection of {@link ErrorTaxonomyEntry} values. First match wins at
|
|
49
|
+
* resolution time. `defineErrorTaxonomy` topologically sorts class-based
|
|
50
|
+
* entries so a subclass is always checked before its ancestors regardless of
|
|
51
|
+
* declaration order; predicate entries retain declared order.
|
|
52
|
+
*/
|
|
53
|
+
export type ErrorTaxonomy = Record<string, ErrorTaxonomyEntry>
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Identity helper that preserves literal inference and:
|
|
57
|
+
* 1. validates each entry has exactly one discriminator (`class` xor `match`);
|
|
58
|
+
* 2. topologically sorts `class:` entries so subclasses precede base classes.
|
|
59
|
+
*
|
|
60
|
+
* The sort is stable — entries unrelated by inheritance keep their declared
|
|
61
|
+
* order. Predicate entries (with `match:`) always keep declared order relative
|
|
62
|
+
* to class entries, so a predicate that's intentionally narrower can be placed
|
|
63
|
+
* before a class entry to take precedence.
|
|
64
|
+
*/
|
|
65
|
+
export function defineErrorTaxonomy<T extends ErrorTaxonomy>(entries: T): T {
|
|
66
|
+
const pairs = Object.entries(entries)
|
|
67
|
+
|
|
68
|
+
for (const [key, entry] of pairs) {
|
|
69
|
+
const hasClass = entry.class !== undefined
|
|
70
|
+
const hasMatch = entry.match !== undefined
|
|
71
|
+
if (hasClass === hasMatch) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Error taxonomy entry "${key}" must define exactly one of { class, match }.`
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Stable sort: subclass-of-the-other → -1, other-subclass-of-this → 1, else 0.
|
|
79
|
+
pairs.sort(([, a], [, b]) => {
|
|
80
|
+
if (!a.class || !b.class) return 0
|
|
81
|
+
if (a.class === b.class) return 0
|
|
82
|
+
if (a.class.prototype instanceof b.class) return -1
|
|
83
|
+
if (b.class.prototype instanceof a.class) return 1
|
|
84
|
+
return 0
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return Object.fromEntries(pairs) as T
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Default taxonomy covering framework error classes that can be thrown by a
|
|
92
|
+
* handler at request time. Layered after the user taxonomy during resolution.
|
|
93
|
+
*
|
|
94
|
+
* `ProcedureError` uses `match:` rather than `class:` so it matches only
|
|
95
|
+
* direct throws (e.g. from `ctx.error()`). When the core wraps a non-ProcedureError
|
|
96
|
+
* into a ProcedureError with `cause`, that wrapper falls through — the
|
|
97
|
+
* resolver unwraps the cause and the user taxonomy / `unknownError` sees the
|
|
98
|
+
* real thrown value.
|
|
99
|
+
*/
|
|
100
|
+
export const defaultErrorTaxonomy = defineErrorTaxonomy({
|
|
101
|
+
ProcedureValidationError: {
|
|
102
|
+
class: ProcedureValidationError,
|
|
103
|
+
statusCode: 400,
|
|
104
|
+
description: 'Schema validation failed for the procedure input parameters.',
|
|
105
|
+
toResponse: (err) => ({
|
|
106
|
+
name: 'ProcedureValidationError' as const,
|
|
107
|
+
procedureName: err.procedureName,
|
|
108
|
+
message: err.message,
|
|
109
|
+
errors: err.errors,
|
|
110
|
+
}),
|
|
111
|
+
schema: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
name: { type: 'string', const: 'ProcedureValidationError' },
|
|
115
|
+
procedureName: { type: 'string' },
|
|
116
|
+
message: { type: 'string' },
|
|
117
|
+
errors: {
|
|
118
|
+
type: 'array',
|
|
119
|
+
items: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
instancePath: { type: 'string' },
|
|
123
|
+
message: { type: 'string' },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
required: ['name', 'procedureName', 'message'],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
ProcedureYieldValidationError: {
|
|
132
|
+
class: ProcedureYieldValidationError,
|
|
133
|
+
statusCode: 500,
|
|
134
|
+
description: 'Schema validation failed for a yielded value in a streaming procedure.',
|
|
135
|
+
toResponse: (err) => ({
|
|
136
|
+
name: 'ProcedureYieldValidationError' as const,
|
|
137
|
+
procedureName: err.procedureName,
|
|
138
|
+
message: err.message,
|
|
139
|
+
errors: err.errors,
|
|
140
|
+
}),
|
|
141
|
+
schema: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
name: { type: 'string', const: 'ProcedureYieldValidationError' },
|
|
145
|
+
procedureName: { type: 'string' },
|
|
146
|
+
message: { type: 'string' },
|
|
147
|
+
errors: {
|
|
148
|
+
type: 'array',
|
|
149
|
+
items: {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
instancePath: { type: 'string' },
|
|
153
|
+
message: { type: 'string' },
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
required: ['name', 'procedureName', 'message'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
ProcedureError: {
|
|
162
|
+
match: (err): err is ProcedureError =>
|
|
163
|
+
err instanceof ProcedureError && (err as { cause?: unknown }).cause === undefined,
|
|
164
|
+
statusCode: 500,
|
|
165
|
+
description: 'An error thrown from within a procedure handler via ctx.error().',
|
|
166
|
+
toResponse: (err) => ({
|
|
167
|
+
name: 'ProcedureError' as const,
|
|
168
|
+
procedureName: err.procedureName,
|
|
169
|
+
message: err.message,
|
|
170
|
+
meta: err.meta,
|
|
171
|
+
}),
|
|
172
|
+
schema: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
name: { type: 'string', const: 'ProcedureError' },
|
|
176
|
+
procedureName: { type: 'string' },
|
|
177
|
+
message: { type: 'string' },
|
|
178
|
+
meta: { type: 'object' },
|
|
179
|
+
},
|
|
180
|
+
required: ['name', 'procedureName', 'message'],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Converts a taxonomy into {@link ErrorDoc} objects suitable for a DocEnvelope.
|
|
187
|
+
* Single source of truth so the runtime mapping and the documented shape
|
|
188
|
+
* cannot drift apart.
|
|
189
|
+
*/
|
|
190
|
+
export function taxonomyToErrorDocs(taxonomy: ErrorTaxonomy): ErrorDoc[] {
|
|
191
|
+
return Object.entries(taxonomy).map(([key, entry]) => ({
|
|
192
|
+
name: key,
|
|
193
|
+
statusCode: entry.statusCode,
|
|
194
|
+
description: entry.description ?? '',
|
|
195
|
+
schema: entry.schema,
|
|
196
|
+
}))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Configuration for the fallback "unknown" error handler — applied when no
|
|
201
|
+
* taxonomy entry (user or default) matches a thrown value.
|
|
202
|
+
*/
|
|
203
|
+
export type UnknownErrorConfig = {
|
|
204
|
+
/** HTTP status code. Defaults to 500. */
|
|
205
|
+
statusCode?: number
|
|
206
|
+
/** Serializes the error into a response body. */
|
|
207
|
+
toResponse: (err: unknown) => unknown
|
|
208
|
+
/** Awaited before the response is sent. */
|
|
209
|
+
onCatch?: (
|
|
210
|
+
err: unknown,
|
|
211
|
+
ctx: { procedure: TAnyProcedureRegistration; raw: unknown }
|
|
212
|
+
) => void | Promise<void>
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Result of matching an error against a taxonomy chain. Consumers translate
|
|
217
|
+
* `{ statusCode, body }` into their framework's response primitive and must
|
|
218
|
+
* `await runOnCatch()` before sending.
|
|
219
|
+
*/
|
|
220
|
+
export type ResolvedErrorResponse = {
|
|
221
|
+
statusCode: number
|
|
222
|
+
body: unknown
|
|
223
|
+
runOnCatch: () => Promise<void>
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Injects `{ name: key }` when `rawBody` is an object without a `name` field.
|
|
228
|
+
* Guarantees wire-protocol consistency (client dispatchers discriminate on
|
|
229
|
+
* `body.name`) without forcing every `toResponse` to repeat it.
|
|
230
|
+
*/
|
|
231
|
+
function ensureName(rawBody: unknown, key: string): unknown {
|
|
232
|
+
if (rawBody && typeof rawBody === 'object' && !('name' in (rawBody as object))) {
|
|
233
|
+
return { name: key, ...(rawBody as object) }
|
|
234
|
+
}
|
|
235
|
+
return rawBody
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Matches a thrown value against the user taxonomy, then (if `includeDefaults`)
|
|
240
|
+
* the default taxonomy, then the `unknownError` config. Returns `null` when
|
|
241
|
+
* nothing matches — callers fall through to their builder's imperative
|
|
242
|
+
* `onError` callback and the hard default.
|
|
243
|
+
*
|
|
244
|
+
* The core wraps any non-ProcedureError thrown by a handler into a
|
|
245
|
+
* ProcedureError with `cause` set to the original. This resolver unwraps that:
|
|
246
|
+
* candidates are checked in the order `[cause, outer]` so a user taxonomy sees
|
|
247
|
+
* its own error classes rather than the wrapper. The default `ProcedureError`
|
|
248
|
+
* entry uses a `match:` predicate that excludes wrappers so they reach
|
|
249
|
+
* `unknownError` instead.
|
|
250
|
+
*
|
|
251
|
+
* Side effects (`onCatch`) are deferred into `runOnCatch` so the caller decides
|
|
252
|
+
* when to execute them relative to writing the response.
|
|
253
|
+
*/
|
|
254
|
+
export function resolveErrorResponse(params: {
|
|
255
|
+
err: unknown
|
|
256
|
+
userTaxonomy?: ErrorTaxonomy
|
|
257
|
+
/** Whether to apply {@link defaultErrorTaxonomy}. Defaults to `true`. */
|
|
258
|
+
includeDefaults?: boolean
|
|
259
|
+
unknownError?: UnknownErrorConfig
|
|
260
|
+
procedure: TAnyProcedureRegistration
|
|
261
|
+
raw: unknown
|
|
262
|
+
}): ResolvedErrorResponse | null {
|
|
263
|
+
const {
|
|
264
|
+
err,
|
|
265
|
+
userTaxonomy,
|
|
266
|
+
includeDefaults = true,
|
|
267
|
+
unknownError,
|
|
268
|
+
procedure,
|
|
269
|
+
raw,
|
|
270
|
+
} = params
|
|
271
|
+
|
|
272
|
+
const wrappedCause =
|
|
273
|
+
err instanceof ProcedureError && (err as { cause?: unknown }).cause !== undefined
|
|
274
|
+
? (err as { cause?: unknown }).cause
|
|
275
|
+
: undefined
|
|
276
|
+
// Most-specific candidate first so a matching user entry on `cause` wins over
|
|
277
|
+
// a matching entry on the outer wrapper.
|
|
278
|
+
const candidates: unknown[] =
|
|
279
|
+
wrappedCause !== undefined ? [wrappedCause, err] : [err]
|
|
280
|
+
|
|
281
|
+
const tryMatch = (tax: ErrorTaxonomy): ResolvedErrorResponse | null => {
|
|
282
|
+
for (const [key, entry] of Object.entries(tax)) {
|
|
283
|
+
for (const candidate of candidates) {
|
|
284
|
+
const matched = entry.class
|
|
285
|
+
? candidate instanceof entry.class
|
|
286
|
+
: entry.match
|
|
287
|
+
? entry.match(candidate)
|
|
288
|
+
: false
|
|
289
|
+
if (!matched) continue
|
|
290
|
+
|
|
291
|
+
const rawBody = entry.toResponse
|
|
292
|
+
? entry.toResponse(candidate as any, { key })
|
|
293
|
+
: {
|
|
294
|
+
name: key,
|
|
295
|
+
message: candidate instanceof Error ? candidate.message : String(candidate),
|
|
296
|
+
}
|
|
297
|
+
const body = ensureName(rawBody, key)
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
statusCode: entry.statusCode,
|
|
301
|
+
body,
|
|
302
|
+
runOnCatch: async () => {
|
|
303
|
+
if (entry.onCatch) {
|
|
304
|
+
await entry.onCatch(candidate as any, { procedure, key, raw })
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (userTaxonomy) {
|
|
314
|
+
const hit = tryMatch(userTaxonomy)
|
|
315
|
+
if (hit) return hit
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (includeDefaults) {
|
|
319
|
+
const hit = tryMatch(defaultErrorTaxonomy)
|
|
320
|
+
if (hit) return hit
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (unknownError) {
|
|
324
|
+
const mostSpecific = candidates[0]
|
|
325
|
+
return {
|
|
326
|
+
statusCode: unknownError.statusCode ?? 500,
|
|
327
|
+
body: unknownError.toResponse(mostSpecific),
|
|
328
|
+
runOnCatch: async () => {
|
|
329
|
+
if (unknownError.onCatch) {
|
|
330
|
+
await unknownError.onCatch(mostSpecific, { procedure, raw })
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
@@ -120,29 +120,24 @@ const RPC = Procedures<AppContext, RPCConfig>()
|
|
|
120
120
|
|
|
121
121
|
## Error Handling
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
Declare error classes via `defineErrorTaxonomy` and pass them to the builder's `errors` option. Handlers `throw` their classes; the builder auto-serializes to the configured status + body.
|
|
124
124
|
|
|
125
125
|
```typescript
|
|
126
|
-
|
|
127
|
-
onError: (procedure, req, res, error) => {
|
|
128
|
-
console.error(`Error in ${procedure.name}:`, error)
|
|
129
|
-
|
|
130
|
-
if (error instanceof ValidationError) {
|
|
131
|
-
res.status(400).json({ error: error.message, code: 'VALIDATION_ERROR' })
|
|
132
|
-
return
|
|
133
|
-
}
|
|
126
|
+
import { defineErrorTaxonomy } from 'ts-procedures/express-rpc'
|
|
134
127
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
128
|
+
const appErrors = defineErrorTaxonomy({
|
|
129
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
130
|
+
})
|
|
139
131
|
|
|
140
|
-
|
|
141
|
-
|
|
132
|
+
new ExpressRPCAppBuilder({
|
|
133
|
+
errors: appErrors,
|
|
134
|
+
unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
|
|
142
135
|
})
|
|
143
136
|
```
|
|
144
137
|
|
|
145
|
-
|
|
138
|
+
Express-specific note: `onCatch` receives `raw: { req, res }` as its framework request context (cast at the use site).
|
|
139
|
+
|
|
140
|
+
Full contract (both peer error modes, `onRequestError` observer, per-route narrowing via `RPCConfig<keyof typeof appErrors & string>`): see **[docs/http-integrations.md § Error Handling](../../../../docs/http-integrations.md#error-handling)** — the canonical explanation shared across all four HTTP builders.
|
|
146
141
|
|
|
147
142
|
## Using Existing Express App
|
|
148
143
|
|
|
@@ -195,11 +190,15 @@ new ExpressRPCAppBuilder(config?: ExpressRPCAppBuilderConfig)
|
|
|
195
190
|
## TypeScript Types
|
|
196
191
|
|
|
197
192
|
```typescript
|
|
198
|
-
import {
|
|
199
|
-
|
|
193
|
+
import { ExpressRPCAppBuilder, defineErrorTaxonomy } from 'ts-procedures/express-rpc'
|
|
194
|
+
import type {
|
|
200
195
|
ExpressRPCAppBuilderConfig,
|
|
201
196
|
RPCConfig,
|
|
202
|
-
RPCHttpRouteDoc
|
|
197
|
+
RPCHttpRouteDoc,
|
|
198
|
+
ErrorTaxonomy,
|
|
199
|
+
ErrorTaxonomyEntry,
|
|
200
|
+
UnknownErrorConfig,
|
|
201
|
+
OnRequestErrorContext,
|
|
203
202
|
} from 'ts-procedures/express-rpc'
|
|
204
203
|
```
|
|
205
204
|
|
|
@@ -271,10 +270,10 @@ builder
|
|
|
271
270
|
const app = builder.build()
|
|
272
271
|
|
|
273
272
|
// Generated routes:
|
|
274
|
-
// POST /rpc/health/1
|
|
273
|
+
// POST /rpc/health/health-check/1
|
|
275
274
|
// POST /rpc/system/version/get-version/1
|
|
276
|
-
// POST /rpc/users/profile/get-
|
|
277
|
-
// POST /rpc/users/profile/
|
|
275
|
+
// POST /rpc/users/profile/get-profile/1
|
|
276
|
+
// POST /rpc/users/profile/update-profile/2
|
|
278
277
|
|
|
279
278
|
console.log('Routes:', builder.docs.map(d => d.path))
|
|
280
279
|
app.listen(3000)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import request from 'supertest'
|
|
3
|
+
import { Type } from 'typebox'
|
|
4
|
+
import { Procedures } from '../../../index.js'
|
|
5
|
+
import { RPCConfig } from '../../types.js'
|
|
6
|
+
import { ExpressRPCAppBuilder, defineErrorTaxonomy } from './index.js'
|
|
7
|
+
|
|
8
|
+
class UseCaseError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
readonly externalMsg: string,
|
|
11
|
+
readonly internalMsg: string
|
|
12
|
+
) {
|
|
13
|
+
super(externalMsg)
|
|
14
|
+
this.name = 'UseCaseError'
|
|
15
|
+
Object.setPrototypeOf(this, UseCaseError.prototype)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('ExpressRPCAppBuilder — error taxonomy', () => {
|
|
20
|
+
test('taxonomy catches user error thrown from RPC handler', async () => {
|
|
21
|
+
const errors = defineErrorTaxonomy({
|
|
22
|
+
UseCaseError: {
|
|
23
|
+
class: UseCaseError,
|
|
24
|
+
statusCode: 422,
|
|
25
|
+
toResponse: (err) => ({ name: 'UseCaseError', message: err.externalMsg }),
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
29
|
+
RPC.Create(
|
|
30
|
+
'Boom',
|
|
31
|
+
{ scope: 'test', version: 1, schema: { params: Type.Object({}) } },
|
|
32
|
+
async () => {
|
|
33
|
+
throw new UseCaseError('ext', 'int')
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
const app = new ExpressRPCAppBuilder({ errors }).register(RPC, () => ({})).build()
|
|
37
|
+
const res = await request(app).post('/test/boom/1').send({})
|
|
38
|
+
expect(res.status).toBe(422)
|
|
39
|
+
expect(res.body).toEqual({ name: 'UseCaseError', message: 'ext' })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('unknownError catches unmapped errors', async () => {
|
|
43
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
44
|
+
RPC.Create(
|
|
45
|
+
'Boom',
|
|
46
|
+
{ scope: 'test', version: 1, schema: { params: Type.Object({}) } },
|
|
47
|
+
async () => {
|
|
48
|
+
throw new TypeError('db down')
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
const app = new ExpressRPCAppBuilder({
|
|
52
|
+
unknownError: { statusCode: 503, toResponse: () => ({ name: 'ServiceUnavailable' }) },
|
|
53
|
+
})
|
|
54
|
+
.register(RPC, () => ({}))
|
|
55
|
+
.build()
|
|
56
|
+
const res = await request(app).post('/test/boom/1').send({})
|
|
57
|
+
expect(res.status).toBe(503)
|
|
58
|
+
expect(res.body).toEqual({ name: 'ServiceUnavailable' })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('onCatch receives { req, res } as raw context', async () => {
|
|
62
|
+
let rawSeen: any
|
|
63
|
+
const errors = defineErrorTaxonomy({
|
|
64
|
+
UseCaseError: {
|
|
65
|
+
class: UseCaseError,
|
|
66
|
+
statusCode: 422,
|
|
67
|
+
onCatch: (_err, ctx) => {
|
|
68
|
+
rawSeen = ctx.raw
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
73
|
+
RPC.Create(
|
|
74
|
+
'Boom',
|
|
75
|
+
{ scope: 'test', version: 1, schema: { params: Type.Object({}) } },
|
|
76
|
+
async () => {
|
|
77
|
+
throw new UseCaseError('ext', 'int')
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
const app = new ExpressRPCAppBuilder({ errors }).register(RPC, () => ({})).build()
|
|
81
|
+
await request(app).post('/test/boom/1').send({})
|
|
82
|
+
expect(rawSeen.req).toBeDefined()
|
|
83
|
+
expect(rawSeen.res).toBeDefined()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('onError callback handles errors not matched by the taxonomy', async () => {
|
|
87
|
+
const onError = vi.fn((_p: any, _req: any, res: any) => {
|
|
88
|
+
res.status(418).json({ legacy: true })
|
|
89
|
+
})
|
|
90
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
91
|
+
RPC.Create(
|
|
92
|
+
'Boom',
|
|
93
|
+
{ scope: 'test', version: 1, schema: { params: Type.Object({}) } },
|
|
94
|
+
async () => {
|
|
95
|
+
throw new TypeError('legacy')
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
const app = new ExpressRPCAppBuilder({ onError }).register(RPC, () => ({})).build()
|
|
99
|
+
const res = await request(app).post('/test/boom/1').send({})
|
|
100
|
+
expect(res.status).toBe(418)
|
|
101
|
+
expect(onError).toHaveBeenCalledOnce()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -8,10 +8,19 @@ import {
|
|
|
8
8
|
RPCConfig,
|
|
9
9
|
RPCHttpRouteDoc,
|
|
10
10
|
} from '../../types.js'
|
|
11
|
+
import {
|
|
12
|
+
ErrorTaxonomy,
|
|
13
|
+
ErrorTaxonomyEntry,
|
|
14
|
+
UnknownErrorConfig,
|
|
15
|
+
defineErrorTaxonomy,
|
|
16
|
+
resolveErrorResponse,
|
|
17
|
+
} from '../error-taxonomy.js'
|
|
11
18
|
import { castArray } from 'es-toolkit/compat'
|
|
12
19
|
import { ExpressFactoryItem } from './types.js'
|
|
13
20
|
|
|
14
21
|
export type { RPCConfig, RPCHttpRouteDoc }
|
|
22
|
+
export { defineErrorTaxonomy }
|
|
23
|
+
export type { ErrorTaxonomy, ErrorTaxonomyEntry, UnknownErrorConfig }
|
|
15
24
|
|
|
16
25
|
export type ExpressRPCAppBuilderConfig = {
|
|
17
26
|
/**
|
|
@@ -30,11 +39,18 @@ export type ExpressRPCAppBuilderConfig = {
|
|
|
30
39
|
res: express.Response
|
|
31
40
|
) => void
|
|
32
41
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
* Declarative error-to-response mapping (one of the two peer error modes).
|
|
43
|
+
* See hono-api for the full taxonomy contract. The `raw` field passed to
|
|
44
|
+
* taxonomy callbacks is `{ req, res }`.
|
|
45
|
+
*/
|
|
46
|
+
errors?: ErrorTaxonomy
|
|
47
|
+
/** Fallback serializer for errors not matched by the taxonomy. */
|
|
48
|
+
unknownError?: UnknownErrorConfig
|
|
49
|
+
/**
|
|
50
|
+
* Imperative error callback — the other peer error mode. Receives every
|
|
51
|
+
* error directly and writes the response via `res`. Use this when you want
|
|
52
|
+
* full control, or alongside `errors` for the tail of errors the taxonomy
|
|
53
|
+
* doesn't cover.
|
|
38
54
|
*/
|
|
39
55
|
onError?: (
|
|
40
56
|
procedure: TProcedureRegistration,
|
|
@@ -42,6 +58,23 @@ export type ExpressRPCAppBuilderConfig = {
|
|
|
42
58
|
res: express.Response,
|
|
43
59
|
error: Error
|
|
44
60
|
) => void
|
|
61
|
+
/**
|
|
62
|
+
* Cross-cutting observer — fires for every caught error, BEFORE dispatch.
|
|
63
|
+
* Awaited. Cannot write to `res` (observer only — check `res.headersSent`
|
|
64
|
+
* if you must touch it). Thrown errors inside the observer are swallowed
|
|
65
|
+
* and logged.
|
|
66
|
+
*/
|
|
67
|
+
onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Context passed to the `onRequestError` observer. `raw` is `{ req, res }`
|
|
72
|
+
* for the in-flight request.
|
|
73
|
+
*/
|
|
74
|
+
export type OnRequestErrorContext = {
|
|
75
|
+
err: unknown
|
|
76
|
+
procedure: TProcedureRegistration
|
|
77
|
+
raw: { req: express.Request; res: express.Response }
|
|
45
78
|
}
|
|
46
79
|
|
|
47
80
|
/**
|
|
@@ -92,11 +125,13 @@ export class ExpressRPCAppBuilder {
|
|
|
92
125
|
|
|
93
126
|
/**
|
|
94
127
|
* Generates the RPC route path based on the RPC configuration.
|
|
95
|
-
*
|
|
128
|
+
* `RPCConfig.scope` can be a string or an array of strings to form nested paths.
|
|
96
129
|
*
|
|
97
130
|
* Example
|
|
98
|
-
* name:
|
|
99
|
-
*
|
|
131
|
+
* name: 'GetUser'
|
|
132
|
+
* scope: ['users', 'profile']
|
|
133
|
+
* version: 1
|
|
134
|
+
* path: /users/profile/get-user/1
|
|
100
135
|
* @param config
|
|
101
136
|
*/
|
|
102
137
|
static makeRPCHttpRoutePath({
|
|
@@ -200,16 +235,39 @@ export class ExpressRPCAppBuilder {
|
|
|
200
235
|
res.status(200)
|
|
201
236
|
}
|
|
202
237
|
} catch (error) {
|
|
238
|
+
if (this.config?.onRequestError) {
|
|
239
|
+
try {
|
|
240
|
+
await this.config.onRequestError({ err: error, procedure, raw: { req, res } })
|
|
241
|
+
} catch (observerErr) {
|
|
242
|
+
console.error('[ts-procedures express-rpc] onRequestError threw — swallowed:', observerErr)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (this.config?.errors || this.config?.unknownError) {
|
|
246
|
+
const resolved = resolveErrorResponse({
|
|
247
|
+
err: error,
|
|
248
|
+
userTaxonomy: this.config.errors,
|
|
249
|
+
unknownError: this.config.unknownError,
|
|
250
|
+
procedure,
|
|
251
|
+
raw: { req, res },
|
|
252
|
+
})
|
|
253
|
+
if (resolved) {
|
|
254
|
+
await resolved.runOnCatch()
|
|
255
|
+
if (!res.headersSent) {
|
|
256
|
+
res.status(resolved.statusCode).json(resolved.body)
|
|
257
|
+
}
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
}
|
|
203
261
|
if (this.config?.onError) {
|
|
204
262
|
this.config.onError(procedure, req, res, error as Error)
|
|
205
263
|
return
|
|
206
264
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
//
|
|
265
|
+
// Hard default — `res.status` is always truthy (it's a method),
|
|
266
|
+
// so the previous `if (!res.status)` guard never ran. Set status
|
|
267
|
+
// and body together unconditionally, respecting an already-sent
|
|
268
|
+
// response.
|
|
211
269
|
if (!res.headersSent) {
|
|
212
|
-
res.json({ error: (error as Error).message })
|
|
270
|
+
res.status(500).json({ error: (error as Error).message })
|
|
213
271
|
}
|
|
214
272
|
}
|
|
215
273
|
})
|
|
@@ -243,7 +301,7 @@ export class ExpressRPCAppBuilder {
|
|
|
243
301
|
jsonSchema.response = config.schema.returnType
|
|
244
302
|
}
|
|
245
303
|
|
|
246
|
-
const base = {
|
|
304
|
+
const base: RPCHttpRouteDoc = {
|
|
247
305
|
kind: 'rpc' as const,
|
|
248
306
|
name: procedure.name,
|
|
249
307
|
version: config.version,
|
|
@@ -252,6 +310,9 @@ export class ExpressRPCAppBuilder {
|
|
|
252
310
|
method,
|
|
253
311
|
jsonSchema,
|
|
254
312
|
}
|
|
313
|
+
if (config.errors && config.errors.length > 0) {
|
|
314
|
+
base.errors = [...config.errors]
|
|
315
|
+
}
|
|
255
316
|
let extendedDoc: object = {}
|
|
256
317
|
|
|
257
318
|
if (extendProcedureDoc) {
|