ts-procedures 5.16.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) 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 +87 -19
  5. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +162 -16
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +179 -16
  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 +78 -12
  16. package/agent_config/cursor/cursorrules +78 -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 +17 -1
  61. package/build/implementations/http/doc-registry.js +47 -79
  62. package/build/implementations/http/doc-registry.js.map +1 -1
  63. package/build/implementations/http/doc-registry.test.js +149 -16
  64. package/build/implementations/http/doc-registry.test.js.map +1 -1
  65. package/build/implementations/http/error-taxonomy.d.ts +249 -0
  66. package/build/implementations/http/error-taxonomy.js +252 -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 +139 -0
  102. package/build/implementations/http/route-errors.test.js.map +1 -0
  103. package/build/implementations/types.d.ts +43 -3
  104. package/docs/client-and-codegen.md +105 -12
  105. package/docs/core.md +14 -5
  106. package/docs/http-integrations.md +138 -5
  107. package/docs/streaming.md +3 -1
  108. package/docs/superpowers/plans/2026-04-24-doc-registry-simplification.md +886 -0
  109. package/package.json +7 -2
  110. package/src/client/call.ts +10 -1
  111. package/src/client/error-dispatch.test.ts +72 -0
  112. package/src/client/error-dispatch.ts +27 -0
  113. package/src/client/fetch-adapter.ts +11 -5
  114. package/src/client/index.ts +9 -0
  115. package/src/client/stream.ts +14 -3
  116. package/src/client/typed-error-dispatch.test.ts +211 -0
  117. package/src/client/types.ts +42 -0
  118. package/src/codegen/e2e.test.ts +9 -4
  119. package/src/codegen/emit-client-runtime.ts +4 -0
  120. package/src/codegen/emit-errors.integration.test.ts +183 -0
  121. package/src/codegen/emit-errors.test.ts +91 -87
  122. package/src/codegen/emit-errors.ts +123 -41
  123. package/src/codegen/emit-index.test.ts +68 -24
  124. package/src/codegen/emit-index.ts +66 -4
  125. package/src/codegen/emit-scope.ts +124 -7
  126. package/src/codegen/pipeline.ts +25 -2
  127. package/src/implementations/http/README.md +21 -7
  128. package/src/implementations/http/doc-registry.test.ts +164 -16
  129. package/src/implementations/http/doc-registry.ts +58 -82
  130. package/src/implementations/http/error-taxonomy.test.ts +438 -0
  131. package/src/implementations/http/error-taxonomy.ts +361 -0
  132. package/src/implementations/http/express-rpc/README.md +23 -24
  133. package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
  134. package/src/implementations/http/express-rpc/index.ts +75 -14
  135. package/src/implementations/http/hono-api/README.md +284 -0
  136. package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
  137. package/src/implementations/http/hono-api/index.ts +76 -1
  138. package/src/implementations/http/hono-rpc/README.md +20 -21
  139. package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
  140. package/src/implementations/http/hono-rpc/index.ts +65 -9
  141. package/src/implementations/http/hono-stream/README.md +44 -25
  142. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
  143. package/src/implementations/http/hono-stream/index.test.ts +54 -18
  144. package/src/implementations/http/hono-stream/index.ts +83 -13
  145. package/src/implementations/http/on-request-error.test.ts +201 -0
  146. package/src/implementations/http/route-errors.test.ts +176 -0
  147. package/src/implementations/types.ts +43 -3
@@ -9,9 +9,18 @@ import {
9
9
  APIInput,
10
10
  HttpMethod,
11
11
  } from '../../types.js'
12
+ import {
13
+ ErrorTaxonomy,
14
+ ErrorTaxonomyEntry,
15
+ UnknownErrorConfig,
16
+ defineErrorTaxonomy,
17
+ resolveErrorResponse,
18
+ } from '../error-taxonomy.js'
12
19
  import { HonoAPIFactoryItem } from './types.js'
13
20
 
14
21
  export type { APIConfig, APIHttpRouteDoc, APIInput, HttpMethod }
22
+ export { defineErrorTaxonomy }
23
+ export type { ErrorTaxonomy, ErrorTaxonomyEntry, UnknownErrorConfig }
15
24
 
16
25
  // ================
17
26
  // Query string parsing
@@ -94,13 +103,48 @@ export type HonoAPIAppBuilderConfig = {
94
103
  onRequestEnd?: (c: Context) => void
95
104
  onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
96
105
  /**
97
- * Error handler called when a procedure throws an error.
106
+ * Declarative error-to-response mapping (one of the two peer error modes).
107
+ * When a thrown error matches an entry (by `class` or `match`), the builder
108
+ * serializes it automatically. User entries are checked before the framework
109
+ * defaults (`ProcedureValidationError`, `ProcedureYieldValidationError`,
110
+ * `ProcedureError`).
111
+ */
112
+ errors?: ErrorTaxonomy
113
+ /**
114
+ * Fallback serializer for errors not matched by the taxonomy. Used together
115
+ * with `errors` for apps that want declarative dispatch plus a well-defined
116
+ * shape for unexpected errors.
117
+ */
118
+ unknownError?: UnknownErrorConfig
119
+ /**
120
+ * Imperative error callback — the other peer error mode. Receives every
121
+ * error directly and returns the HTTP response. Use this when you want full
122
+ * control over the response shape, or alongside `errors` for the tail of
123
+ * errors the taxonomy doesn't cover.
98
124
  */
99
125
  onError?: (
100
126
  procedure: TProcedureRegistration,
101
127
  c: Context,
102
128
  error: Error
103
129
  ) => Response | Promise<Response>
130
+ /**
131
+ * Cross-cutting observer — fires for every caught error, BEFORE dispatch to
132
+ * the taxonomy or `onError`. Awaited. Cannot mutate the response. Intended
133
+ * for logging, tracing, and metrics (Sentry, Datadog, OpenTelemetry). Any
134
+ * error thrown inside the observer is swallowed and logged so the primary
135
+ * dispatch flow is never disrupted.
136
+ */
137
+ onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
138
+ }
139
+
140
+ /**
141
+ * Context passed to the `onRequestError` observer. `raw` is the Hono
142
+ * `Context` for the in-flight request.
143
+ */
144
+ export type OnRequestErrorContext = {
145
+ err: unknown
146
+ procedure: TProcedureRegistration
147
+ raw: Context
104
148
  }
105
149
 
106
150
  /**
@@ -327,6 +371,33 @@ export class HonoAPIAppBuilder {
327
371
 
328
372
  return c.json(result, successStatus as any)
329
373
  } catch (error) {
374
+ // Observer fires first — cross-cutting, cannot alter dispatch or the
375
+ // response. Swallow any throw from the observer so instrumentation
376
+ // bugs never break the primary error-response flow.
377
+ if (this.config?.onRequestError) {
378
+ try {
379
+ await this.config.onRequestError({ err: error, procedure, raw: c })
380
+ } catch (observerErr) {
381
+ console.error('[ts-procedures hono-api] onRequestError threw — swallowed:', observerErr)
382
+ }
383
+ }
384
+
385
+ // Dispatch: taxonomy → onError → hard default. The two modes are
386
+ // peers; apps configure whichever fits (or both, in which case the
387
+ // taxonomy handles what it covers and `onError` handles the tail).
388
+ if (this.config?.errors || this.config?.unknownError) {
389
+ const resolved = resolveErrorResponse({
390
+ err: error,
391
+ userTaxonomy: this.config.errors,
392
+ unknownError: this.config.unknownError,
393
+ procedure,
394
+ raw: c,
395
+ })
396
+ if (resolved) {
397
+ await resolved.runOnCatch()
398
+ return c.json(resolved.body, resolved.statusCode as never)
399
+ }
400
+ }
330
401
  if (this.config?.onError) {
331
402
  return this.config.onError(procedure, c, error as Error)
332
403
  }
@@ -430,6 +501,10 @@ export class HonoAPIAppBuilder {
430
501
  base.successStatus = config.successStatus
431
502
  }
432
503
 
504
+ if (config.errors && config.errors.length > 0) {
505
+ base.errors = [...config.errors]
506
+ }
507
+
433
508
  let extendedDoc: object = {}
434
509
 
435
510
  if (extendProcedureDoc) {
@@ -64,7 +64,7 @@ type HonoRPCAppBuilderConfig = {
64
64
  onRequestStart?: (c: Context) => void
65
65
  onRequestEnd?: (c: Context) => void
66
66
  onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
67
- error?: (
67
+ onError?: (
68
68
  procedure: TProcedureRegistration,
69
69
  c: Context,
70
70
  error: Error
@@ -79,7 +79,7 @@ type HonoRPCAppBuilderConfig = {
79
79
  | `onRequestStart` | `(c) => void` | Called at start of each request |
80
80
  | `onRequestEnd` | `(c) => void` | Called after handler completes |
81
81
  | `onSuccess` | `(proc, c) => void` | Called on successful handler execution |
82
- | `error` | `(proc, c, err) => Response` | Custom error handler (must return Response) |
82
+ | `onError` | `(proc, c, err) => Response` | Imperative error handler (peer of `errors` taxonomy — see Error Handling) |
83
83
 
84
84
  ## Context Resolution
85
85
 
@@ -162,27 +162,22 @@ const RPC = Procedures<AppContext, RPCConfig>()
162
162
 
163
163
  ## Error Handling
164
164
 
165
- Custom error handler receives the procedure, context, and error. **Must return a Response:**
165
+ 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.
166
166
 
167
167
  ```typescript
168
- const builder = new HonoRPCAppBuilder({
169
- onError: (procedure, c, error) => {
170
- console.error(`Error in ${procedure.name}:`, error)
171
-
172
- if (error instanceof ValidationError) {
173
- return c.json({ error: error.message, code: 'VALIDATION_ERROR' }, 400)
174
- }
168
+ import { defineErrorTaxonomy } from 'ts-procedures/hono-rpc'
175
169
 
176
- if (error instanceof AuthError) {
177
- return c.json({ error: 'Unauthorized', code: 'AUTH_ERROR' }, 401)
178
- }
170
+ const appErrors = defineErrorTaxonomy({
171
+ AuthError: { class: AuthError, statusCode: 401 },
172
+ })
179
173
 
180
- return c.json({ error: 'Internal server error' }, 500)
181
- },
174
+ new HonoRPCAppBuilder({
175
+ errors: appErrors,
176
+ unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
182
177
  })
183
178
  ```
184
179
 
185
- **Default error handling:** Returns `{ error: message }` with status 500.
180
+ 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.
186
181
 
187
182
  ## Using Existing Hono App
188
183
 
@@ -269,11 +264,15 @@ new HonoRPCAppBuilder(config?: HonoRPCAppBuilderConfig)
269
264
  ## TypeScript Types
270
265
 
271
266
  ```typescript
272
- import {
273
- HonoRPCAppBuilder,
267
+ import { HonoRPCAppBuilder, defineErrorTaxonomy } from 'ts-procedures/hono-rpc'
268
+ import type {
274
269
  HonoRPCAppBuilderConfig,
275
270
  RPCConfig,
276
271
  RPCHttpRouteDoc,
272
+ ErrorTaxonomy,
273
+ ErrorTaxonomyEntry,
274
+ UnknownErrorConfig,
275
+ OnRequestErrorContext,
277
276
  } from 'ts-procedures/hono-rpc'
278
277
  ```
279
278
 
@@ -345,10 +344,10 @@ builder
345
344
  const app = builder.build()
346
345
 
347
346
  // Generated routes:
348
- // POST /rpc/health/1
347
+ // POST /rpc/health/health-check/1
349
348
  // POST /rpc/system/version/get-version/1
350
- // POST /rpc/users/profile/get-user/1
351
- // POST /rpc/users/profile/get-user/2
349
+ // POST /rpc/users/profile/get-profile/1
350
+ // POST /rpc/users/profile/update-profile/2
352
351
 
353
352
  console.log(
354
353
  'Routes:',
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+ import { Type } from 'typebox'
3
+ import { Procedures } from '../../../index.js'
4
+ import { RPCConfig } from '../../types.js'
5
+ import { HonoRPCAppBuilder, defineErrorTaxonomy } from './index.js'
6
+
7
+ class UseCaseError extends Error {
8
+ constructor(
9
+ readonly externalMsg: string,
10
+ readonly internalMsg: string
11
+ ) {
12
+ super(externalMsg)
13
+ this.name = 'UseCaseError'
14
+ Object.setPrototypeOf(this, UseCaseError.prototype)
15
+ }
16
+ }
17
+
18
+ describe('HonoRPCAppBuilder — error taxonomy', () => {
19
+ test('taxonomy catches user error thrown from RPC handler', async () => {
20
+ const errors = defineErrorTaxonomy({
21
+ UseCaseError: {
22
+ class: UseCaseError,
23
+ statusCode: 422,
24
+ toResponse: (err) => ({ name: 'UseCaseError', message: err.externalMsg }),
25
+ },
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
+
37
+ const app = new HonoRPCAppBuilder({ errors }).register(RPC, () => ({})).build()
38
+ const res = await app.request('/test/boom/1', {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: '{}',
42
+ })
43
+ expect(res.status).toBe(422)
44
+ expect(await res.json()).toEqual({ name: 'UseCaseError', message: 'ext' })
45
+ })
46
+
47
+ test('unknownError catches unmapped errors', async () => {
48
+ const RPC = Procedures<{}, RPCConfig>()
49
+ RPC.Create(
50
+ 'Boom',
51
+ { scope: 'test', version: 1, schema: { params: Type.Object({}) } },
52
+ async () => {
53
+ throw new TypeError('db down')
54
+ }
55
+ )
56
+ const app = new HonoRPCAppBuilder({
57
+ unknownError: { statusCode: 503, toResponse: () => ({ name: 'ServiceUnavailable' }) },
58
+ })
59
+ .register(RPC, () => ({}))
60
+ .build()
61
+
62
+ const res = await app.request('/test/boom/1', { method: 'POST', body: '{}' })
63
+ expect(res.status).toBe(503)
64
+ expect(await res.json()).toEqual({ name: 'ServiceUnavailable' })
65
+ })
66
+
67
+ test('onError callback runs when taxonomy does not match', async () => {
68
+ const onError = vi.fn(async (_p: any, c: any) => c.json({ legacy: true }, 418))
69
+ const RPC = Procedures<{}, RPCConfig>()
70
+ RPC.Create(
71
+ 'Boom',
72
+ { scope: 'test', version: 1, schema: { params: Type.Object({}) } },
73
+ async () => {
74
+ throw new TypeError('legacy')
75
+ }
76
+ )
77
+ const app = new HonoRPCAppBuilder({ onError }).register(RPC, () => ({})).build()
78
+ const res = await app.request('/test/boom/1', { method: 'POST', body: '{}' })
79
+ expect(res.status).toBe(418)
80
+ expect(onError).toHaveBeenCalledOnce()
81
+ })
82
+ })
@@ -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 { HonoFactoryItem } 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 HonoRPCAppBuilderConfig = {
17
26
  /**
@@ -25,16 +34,39 @@ export type HonoRPCAppBuilderConfig = {
25
34
  onRequestEnd?: (c: Context) => void
26
35
  onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
27
36
  /**
28
- * Error handler called when a procedure throws an error.
29
- * @param procedure
30
- * @param c
31
- * @param error
37
+ * Declarative error-to-response mapping (one of the two peer error modes).
38
+ * See hono-api for the full taxonomy contract. User entries take precedence
39
+ * over the framework default taxonomy.
40
+ */
41
+ errors?: ErrorTaxonomy
42
+ /** Fallback serializer for errors not matched by the taxonomy. */
43
+ unknownError?: UnknownErrorConfig
44
+ /**
45
+ * Imperative error callback — the other peer error mode. Receives every
46
+ * error directly and returns the HTTP response. Use this when you want full
47
+ * control over the response shape, or alongside `errors` for the tail of
48
+ * errors the taxonomy doesn't cover.
32
49
  */
33
50
  onError?: (
34
51
  procedure: TProcedureRegistration,
35
52
  c: Context,
36
53
  error: Error
37
54
  ) => Response | Promise<Response>
55
+ /**
56
+ * Cross-cutting observer — fires for every caught error, BEFORE dispatch.
57
+ * Awaited. Cannot mutate the response. Intended for logging, tracing, and
58
+ * metrics. Thrown errors inside the observer are swallowed and logged.
59
+ */
60
+ onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
61
+ }
62
+
63
+ /**
64
+ * Context passed to the `onRequestError` observer.
65
+ */
66
+ export type OnRequestErrorContext = {
67
+ err: unknown
68
+ procedure: TProcedureRegistration
69
+ raw: Context
38
70
  }
39
71
 
40
72
  /**
@@ -80,11 +112,13 @@ export class HonoRPCAppBuilder {
80
112
 
81
113
  /**
82
114
  * Generates the RPC route path based on the RPC configuration.
83
- * The RPCConfig name can be a string or an array of strings to form nested paths.
115
+ * `RPCConfig.scope` can be a string or an array of strings to form nested paths.
84
116
  *
85
117
  * Example
86
- * name: ['string', 'string-string', 'string']
87
- * path: /string/string-string/string/version
118
+ * name: 'GetUser'
119
+ * scope: ['users', 'profile']
120
+ * version: 1
121
+ * path: /users/profile/get-user/1
88
122
  * @param config
89
123
  */
90
124
  static makeRPCHttpRoutePath({
@@ -178,10 +212,29 @@ export class HonoRPCAppBuilder {
178
212
  // Hono returns Response objects via c.json()
179
213
  return c.json(result)
180
214
  } catch (error) {
215
+ if (this.config?.onRequestError) {
216
+ try {
217
+ await this.config.onRequestError({ err: error, procedure, raw: c })
218
+ } catch (observerErr) {
219
+ console.error('[ts-procedures hono-rpc] onRequestError threw — swallowed:', observerErr)
220
+ }
221
+ }
222
+ if (this.config?.errors || this.config?.unknownError) {
223
+ const resolved = resolveErrorResponse({
224
+ err: error,
225
+ userTaxonomy: this.config.errors,
226
+ unknownError: this.config.unknownError,
227
+ procedure,
228
+ raw: c,
229
+ })
230
+ if (resolved) {
231
+ await resolved.runOnCatch()
232
+ return c.json(resolved.body, resolved.statusCode as never)
233
+ }
234
+ }
181
235
  if (this.config?.onError) {
182
236
  return this.config.onError(procedure, c, error as Error)
183
237
  }
184
- // Default error handling
185
238
  return c.json({ error: (error as Error).message }, 500)
186
239
  }
187
240
  })
@@ -215,7 +268,7 @@ export class HonoRPCAppBuilder {
215
268
  jsonSchema.response = config.schema.returnType
216
269
  }
217
270
 
218
- const base = {
271
+ const base: RPCHttpRouteDoc = {
219
272
  kind: 'rpc' as const,
220
273
  name: procedure.name,
221
274
  version: config.version,
@@ -224,6 +277,9 @@ export class HonoRPCAppBuilder {
224
277
  method,
225
278
  jsonSchema,
226
279
  }
280
+ if (config.errors && config.errors.length > 0) {
281
+ base.errors = [...config.errors]
282
+ }
227
283
  let extendedDoc: object = {}
228
284
 
229
285
  if (extendProcedureDoc) {
@@ -200,8 +200,11 @@ interface HonoStreamAppBuilderConfig<TErrorData = unknown> {
200
200
  onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
201
201
  onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
202
202
 
203
- // Error handling callbacks (see Error Handling section)
204
- onPreStreamError?: (procedure, c, error: ProcedureValidationError | Error) => Response | Promise<Response>
203
+ // Error handling (see Error Handling section)
204
+ errors?: ErrorTaxonomy
205
+ unknownError?: UnknownErrorConfig
206
+ onError?: (procedure, c, error: ProcedureValidationError | Error) => Response | Promise<Response>
207
+ onRequestError?: (ctx) => void | Promise<void>
205
208
  onMidStreamError?: (procedure, c, error) => MidStreamErrorResult<TErrorData> | undefined
206
209
  }
207
210
  ```
@@ -253,34 +256,27 @@ This design allows for:
253
256
 
254
257
  Streaming has two distinct error phases with different semantics:
255
258
 
256
- | Phase | When | Response Type | Callback |
257
- |-------|------|---------------|----------|
258
- | **Pre-stream** | Validation, auth, context resolution | HTTP Response (400/500) | `onPreStreamError` |
259
- | **Mid-stream** | Generator throws during iteration | Value written to stream | `onMidStreamError` |
259
+ | Phase | When | Handling |
260
+ |-------|------|---------|
261
+ | **Pre-stream** | Validation, auth, context resolution | Declarative taxonomy via `errors` + `unknownError` — same shape as all other HTTP builders |
262
+ | **Mid-stream** | Generator throws during iteration | `onMidStreamError` writes a value into the open stream (status is already committed) |
260
263
 
261
- ### Pre-Stream Errors (`onPreStreamError`)
262
-
263
- Errors that occur **before** the stream starts (validation failures, context resolution errors). The `error` parameter is typed as `ProcedureValidationError | Error`, allowing `instanceof` narrowing. The return value **IS** used as the HTTP response:
264
+ ### Pre-Stream Error Taxonomy
264
265
 
265
266
  ```typescript
267
+ import { defineErrorTaxonomy } from 'ts-procedures/hono-stream'
268
+
266
269
  const builder = new HonoStreamAppBuilder({
267
- onPreStreamError: (procedure, c, error) => {
268
- if (error instanceof ProcedureValidationError) {
269
- return c.json({
270
- error: 'Invalid parameters',
271
- details: error.errors,
272
- procedure: procedure.name,
273
- }, 400)
274
- }
275
- return c.json({ error: error.message }, 500)
276
- },
270
+ errors: defineErrorTaxonomy({
271
+ AuthError: { class: AuthError, statusCode: 401 },
272
+ }),
273
+ unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
274
+ onMidStreamError: (/* ... */) => ({ /* mid-stream only */ }),
277
275
  })
278
-
279
- // Without handler, returns default JSON response:
280
- // Status: 400 (validation) or 500 (other)
281
- // Body: { "error": "Validation error for ProcedureName - ..." }
282
276
  ```
283
277
 
278
+ Full contract (both peer error modes, `onRequestError` observer, per-route `errors` narrowing): see **[docs/http-integrations.md § Error Handling](../../../../docs/http-integrations.md#error-handling)** — the canonical explanation shared across all four HTTP builders.
279
+
284
280
  ### Mid-Stream Errors (`onMidStreamError`)
285
281
 
286
282
  Errors that occur **during** streaming (generator throws). Since the stream is already open, HTTP status cannot change. Return `{ data, closeStream? }` — the `data` value is written as the SSE `data:` field content.
@@ -474,12 +470,19 @@ RPC.CreateStream('LongStream', config, async function* (ctx) {
474
470
  ```typescript
475
471
  import {
476
472
  HonoStreamAppBuilder,
473
+ defineErrorTaxonomy,
474
+ sse, // Helper to attach SSE metadata (event/id/retry) to yielded objects
475
+ } from 'ts-procedures/hono-stream'
476
+ import type {
477
477
  HonoStreamAppBuilderConfig, // Generic: HonoStreamAppBuilderConfig<TErrorData = unknown>
478
478
  StreamHttpRouteDoc,
479
479
  StreamMode,
480
- sse, // Helper to attach SSE metadata (event/id/retry) to yielded objects
481
480
  SSEOptions, // Type for sse() options: { event?, id?, retry? }
482
481
  MidStreamErrorResult, // Generic: MidStreamErrorResult<TErrorData = unknown>
482
+ ErrorTaxonomy,
483
+ ErrorTaxonomyEntry,
484
+ UnknownErrorConfig,
485
+ OnRequestErrorContext, // Passed to the onRequestError observer: { err, procedure, raw: Context }
483
486
  } from 'ts-procedures/hono-stream'
484
487
  ```
485
488
 
@@ -522,4 +525,20 @@ onStreamEnd: (procedure, c, streamMode) => { ... }
522
525
 
523
526
  **New: Generic `TErrorData` parameter** on `HonoStreamAppBuilder` and `MidStreamErrorResult` for type-safe `onMidStreamError` callbacks.
524
527
 
525
- **New: `onPreStreamError` error parameter** typed as `ProcedureValidationError | Error` for `instanceof` narrowing.
528
+ **Breaking: `onPreStreamError` renamed to `onError`** for consistency with the other three HTTP builders. Signature unchanged.
529
+
530
+ ```typescript
531
+ // Before (v5)
532
+ new HonoStreamAppBuilder({
533
+ onPreStreamError: (proc, c, err) => c.json({ error: err.message }, 400),
534
+ })
535
+
536
+ // After (v6)
537
+ new HonoStreamAppBuilder({
538
+ onError: (proc, c, err) => c.json({ error: err.message }, 400),
539
+ })
540
+ ```
541
+
542
+ **Breaking: hard-default status for `ProcedureValidationError` changed from 400 to 500.** Previously, `HonoStreamAppBuilder` special-cased validation errors to 400 when no `errors` / `onError` was configured. v6 drops this inconsistency — the hard default is now 500 across all four builders. To preserve the 400 response, configure the taxonomy (`errors: {}` engages framework defaults) or handle it in `onError`.
543
+
544
+ **New: `onRequestError` cross-cutting observer.** Fires for every caught pre-stream error, before dispatch. Awaited, cannot mutate the response. Use for logging, tracing, metrics. See [docs/http-integrations.md § Error Handling](../../../../docs/http-integrations.md#error-handling).
@@ -0,0 +1,98 @@
1
+ /* eslint-disable @typescript-eslint/no-empty-object-type */
2
+ import { describe, expect, test, vi } from 'vitest'
3
+ import { Type } from 'typebox'
4
+ import { Procedures } from '../../../index.js'
5
+ import { RPCConfig } from '../../types.js'
6
+ import { HonoStreamAppBuilder, defineErrorTaxonomy } from './index.js'
7
+
8
+ class AuthError extends Error {
9
+ constructor(readonly reason: 'unauthenticated' | 'forbidden') {
10
+ super(reason)
11
+ this.name = 'AuthError'
12
+ Object.setPrototypeOf(this, AuthError.prototype)
13
+ }
14
+ }
15
+
16
+ describe('HonoStreamAppBuilder — error taxonomy (pre-stream)', () => {
17
+ test('taxonomy catches errors thrown during context resolution', async () => {
18
+ const errors = defineErrorTaxonomy({
19
+ AuthError: {
20
+ class: AuthError,
21
+ statusCode: 401,
22
+ toResponse: (err) => ({ name: 'AuthError', reason: err.reason }),
23
+ },
24
+ })
25
+ const RPC = Procedures<{}, RPCConfig>()
26
+ RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
27
+ yield { msg: 'ok' }
28
+ })
29
+
30
+ const app = new HonoStreamAppBuilder({ errors })
31
+ .register(RPC, () => {
32
+ throw new AuthError('forbidden')
33
+ })
34
+ .build()
35
+
36
+ const res = await app.request('/test/stream/1')
37
+ expect(res.status).toBe(401)
38
+ expect(await res.json()).toEqual({ name: 'AuthError', reason: 'forbidden' })
39
+ })
40
+
41
+ test('default taxonomy serializes ProcedureValidationError at 400 when user opts in', async () => {
42
+ const RPC = Procedures<{}, RPCConfig>()
43
+ RPC.CreateStream(
44
+ 'Stream',
45
+ {
46
+ scope: 'test',
47
+ version: 1,
48
+ schema: { params: Type.Object({ n: Type.Number() }) },
49
+ },
50
+ async function* () {
51
+ yield { ok: true }
52
+ }
53
+ )
54
+ const app = new HonoStreamAppBuilder({ errors: {} }).register(RPC, () => ({})).build()
55
+ const res = await app.request('/test/stream/1?n=not-a-number')
56
+ expect(res.status).toBe(400)
57
+ const body = (await res.json()) as any
58
+ expect(body.name).toBe('ProcedureValidationError')
59
+ })
60
+
61
+ test('onError callback runs when taxonomy does not match', async () => {
62
+ const onError = vi.fn(async (_p: any, c: any) => c.json({ handled: true }, 418))
63
+ const RPC = Procedures<{}, RPCConfig>()
64
+ RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
65
+ yield { ok: true }
66
+ })
67
+ const app = new HonoStreamAppBuilder({ onError })
68
+ .register(RPC, () => {
69
+ throw new TypeError('context failed')
70
+ })
71
+ .build()
72
+
73
+ const res = await app.request('/test/stream/1')
74
+ expect(res.status).toBe(418)
75
+ expect(onError).toHaveBeenCalledOnce()
76
+ })
77
+
78
+ test('unknownError is used for unmapped pre-stream errors', async () => {
79
+ const RPC = Procedures<{}, RPCConfig>()
80
+ RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
81
+ yield { ok: true }
82
+ })
83
+ const app = new HonoStreamAppBuilder({
84
+ unknownError: {
85
+ statusCode: 503,
86
+ toResponse: () => ({ name: 'ServiceUnavailable' }),
87
+ },
88
+ })
89
+ .register(RPC, () => {
90
+ throw new TypeError('infra down')
91
+ })
92
+ .build()
93
+
94
+ const res = await app.request('/test/stream/1')
95
+ expect(res.status).toBe(503)
96
+ expect(await res.json()).toEqual({ name: 'ServiceUnavailable' })
97
+ })
98
+ })