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.
Files changed (146) hide show
  1. package/README.md +2 -0
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
  3. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
  4. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +85 -17
  5. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +163 -5
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +169 -13
  7. package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
  8. package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
  9. package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
  10. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +22 -15
  11. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
  12. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
  13. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
  14. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
  15. package/agent_config/copilot/copilot-instructions.md +77 -12
  16. package/agent_config/cursor/cursorrules +77 -12
  17. package/build/client/call.d.ts +2 -1
  18. package/build/client/call.js +9 -1
  19. package/build/client/call.js.map +1 -1
  20. package/build/client/error-dispatch.d.ts +13 -0
  21. package/build/client/error-dispatch.js +26 -0
  22. package/build/client/error-dispatch.js.map +1 -0
  23. package/build/client/error-dispatch.test.d.ts +1 -0
  24. package/build/client/error-dispatch.test.js +56 -0
  25. package/build/client/error-dispatch.test.js.map +1 -0
  26. package/build/client/fetch-adapter.js +10 -4
  27. package/build/client/fetch-adapter.js.map +1 -1
  28. package/build/client/index.d.ts +2 -1
  29. package/build/client/index.js +5 -1
  30. package/build/client/index.js.map +1 -1
  31. package/build/client/stream.d.ts +2 -1
  32. package/build/client/stream.js +13 -3
  33. package/build/client/stream.js.map +1 -1
  34. package/build/client/typed-error-dispatch.test.d.ts +1 -0
  35. package/build/client/typed-error-dispatch.test.js +168 -0
  36. package/build/client/typed-error-dispatch.test.js.map +1 -0
  37. package/build/client/types.d.ts +37 -0
  38. package/build/codegen/e2e.test.js +9 -4
  39. package/build/codegen/e2e.test.js.map +1 -1
  40. package/build/codegen/emit-client-runtime.js +4 -0
  41. package/build/codegen/emit-client-runtime.js.map +1 -1
  42. package/build/codegen/emit-errors.d.ts +17 -6
  43. package/build/codegen/emit-errors.integration.test.d.ts +1 -0
  44. package/build/codegen/emit-errors.integration.test.js +162 -0
  45. package/build/codegen/emit-errors.integration.test.js.map +1 -0
  46. package/build/codegen/emit-errors.js +50 -39
  47. package/build/codegen/emit-errors.js.map +1 -1
  48. package/build/codegen/emit-errors.test.js +75 -78
  49. package/build/codegen/emit-errors.test.js.map +1 -1
  50. package/build/codegen/emit-index.d.ts +7 -0
  51. package/build/codegen/emit-index.js +26 -4
  52. package/build/codegen/emit-index.js.map +1 -1
  53. package/build/codegen/emit-index.test.js +55 -23
  54. package/build/codegen/emit-index.test.js.map +1 -1
  55. package/build/codegen/emit-scope.d.ts +8 -0
  56. package/build/codegen/emit-scope.js +82 -7
  57. package/build/codegen/emit-scope.js.map +1 -1
  58. package/build/codegen/pipeline.js +22 -2
  59. package/build/codegen/pipeline.js.map +1 -1
  60. package/build/implementations/http/doc-registry.d.ts +21 -0
  61. package/build/implementations/http/doc-registry.js +51 -78
  62. package/build/implementations/http/doc-registry.js.map +1 -1
  63. package/build/implementations/http/doc-registry.test.js +8 -6
  64. package/build/implementations/http/doc-registry.test.js.map +1 -1
  65. package/build/implementations/http/error-taxonomy.d.ts +240 -0
  66. package/build/implementations/http/error-taxonomy.js +230 -0
  67. package/build/implementations/http/error-taxonomy.js.map +1 -0
  68. package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
  69. package/build/implementations/http/error-taxonomy.test.js +399 -0
  70. package/build/implementations/http/error-taxonomy.test.js.map +1 -0
  71. package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
  72. package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
  73. package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
  74. package/build/implementations/http/express-rpc/index.d.ts +39 -8
  75. package/build/implementations/http/express-rpc/index.js +39 -8
  76. package/build/implementations/http/express-rpc/index.js.map +1 -1
  77. package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
  78. package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
  79. package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
  80. package/build/implementations/http/hono-api/index.d.ts +38 -1
  81. package/build/implementations/http/hono-api/index.js +32 -0
  82. package/build/implementations/http/hono-api/index.js.map +1 -1
  83. package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
  84. package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
  85. package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
  86. package/build/implementations/http/hono-rpc/index.d.ts +34 -7
  87. package/build/implementations/http/hono-rpc/index.js +31 -4
  88. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  89. package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
  90. package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
  91. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
  92. package/build/implementations/http/hono-stream/index.d.ts +40 -3
  93. package/build/implementations/http/hono-stream/index.js +37 -10
  94. package/build/implementations/http/hono-stream/index.js.map +1 -1
  95. package/build/implementations/http/hono-stream/index.test.js +45 -18
  96. package/build/implementations/http/hono-stream/index.test.js.map +1 -1
  97. package/build/implementations/http/on-request-error.test.d.ts +1 -0
  98. package/build/implementations/http/on-request-error.test.js +173 -0
  99. package/build/implementations/http/on-request-error.test.js.map +1 -0
  100. package/build/implementations/http/route-errors.test.d.ts +1 -0
  101. package/build/implementations/http/route-errors.test.js +140 -0
  102. package/build/implementations/http/route-errors.test.js.map +1 -0
  103. package/build/implementations/types.d.ts +30 -2
  104. package/docs/client-and-codegen.md +105 -12
  105. package/docs/core.md +14 -5
  106. package/docs/http-integrations.md +135 -4
  107. package/docs/streaming.md +3 -1
  108. package/package.json +7 -2
  109. package/src/client/call.ts +10 -1
  110. package/src/client/error-dispatch.test.ts +72 -0
  111. package/src/client/error-dispatch.ts +27 -0
  112. package/src/client/fetch-adapter.ts +11 -5
  113. package/src/client/index.ts +9 -0
  114. package/src/client/stream.ts +14 -3
  115. package/src/client/typed-error-dispatch.test.ts +211 -0
  116. package/src/client/types.ts +42 -0
  117. package/src/codegen/e2e.test.ts +9 -4
  118. package/src/codegen/emit-client-runtime.ts +4 -0
  119. package/src/codegen/emit-errors.integration.test.ts +183 -0
  120. package/src/codegen/emit-errors.test.ts +91 -87
  121. package/src/codegen/emit-errors.ts +123 -41
  122. package/src/codegen/emit-index.test.ts +68 -24
  123. package/src/codegen/emit-index.ts +66 -4
  124. package/src/codegen/emit-scope.ts +124 -7
  125. package/src/codegen/pipeline.ts +25 -2
  126. package/src/implementations/http/README.md +19 -4
  127. package/src/implementations/http/doc-registry.test.ts +10 -6
  128. package/src/implementations/http/doc-registry.ts +63 -80
  129. package/src/implementations/http/error-taxonomy.test.ts +438 -0
  130. package/src/implementations/http/error-taxonomy.ts +337 -0
  131. package/src/implementations/http/express-rpc/README.md +21 -22
  132. package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
  133. package/src/implementations/http/express-rpc/index.ts +75 -14
  134. package/src/implementations/http/hono-api/README.md +284 -0
  135. package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
  136. package/src/implementations/http/hono-api/index.ts +76 -1
  137. package/src/implementations/http/hono-rpc/README.md +18 -19
  138. package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
  139. package/src/implementations/http/hono-rpc/index.ts +65 -9
  140. package/src/implementations/http/hono-stream/README.md +44 -25
  141. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
  142. package/src/implementations/http/hono-stream/index.test.ts +54 -18
  143. package/src/implementations/http/hono-stream/index.ts +83 -13
  144. package/src/implementations/http/on-request-error.test.ts +201 -0
  145. package/src/implementations/http/route-errors.test.ts +177 -0
  146. 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
- Custom error handler receives the procedure, request, response, and error:
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
- const builder = new ExpressRPCAppBuilder({
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
- if (error instanceof AuthError) {
136
- res.status(401).json({ error: 'Unauthorized', code: 'AUTH_ERROR' })
137
- return
138
- }
128
+ const appErrors = defineErrorTaxonomy({
129
+ AuthError: { class: AuthError, statusCode: 401 },
130
+ })
139
131
 
140
- res.status(500).json({ error: 'Internal server error' })
141
- }
132
+ new ExpressRPCAppBuilder({
133
+ errors: appErrors,
134
+ unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
142
135
  })
143
136
  ```
144
137
 
145
- **Default error handling:** Returns `{ error: message }` with status 500.
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
- ExpressRPCAppBuilder,
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-user/1
277
- // POST /rpc/users/profile/get-user/2
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
- * Error handler called when a procedure throws an error.
34
- * @param procedure
35
- * @param req
36
- * @param res
37
- * @param error
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
- * The RPCConfig name can be a string or an array of strings to form nested paths.
128
+ * `RPCConfig.scope` can be a string or an array of strings to form nested paths.
96
129
  *
97
130
  * Example
98
- * name: ['string', 'string-string', 'string']
99
- * path: /string/string-string/string/version
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
- if (!res.status) {
208
- res.status(500)
209
- }
210
- // if no res.json set, set default error message
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) {