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,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
+ })
@@ -503,7 +503,7 @@ describe('HonoStreamAppBuilder', () => {
503
503
  return c.json({ customError: error.message }, 400)
504
504
  })
505
505
 
506
- const builder = new HonoStreamAppBuilder({ onPreStreamError: errorHandler })
506
+ const builder = new HonoStreamAppBuilder({ onError: errorHandler })
507
507
  const RPC = Procedures<{}, RPCConfig>()
508
508
 
509
509
  RPC.CreateStream(
@@ -575,8 +575,10 @@ describe('HonoStreamAppBuilder', () => {
575
575
  expect(JSON.parse(lines[1]!).error).toContain('Stream error')
576
576
  })
577
577
 
578
- test('validation errors return 400 by default when no error handler', async () => {
579
- const builder = new HonoStreamAppBuilder()
578
+ test('validation errors return 400 when the default taxonomy is engaged (via `errors: {}`)', async () => {
579
+ // Opting into the taxonomy (even an empty one) engages the framework
580
+ // default entries — ProcedureValidationError → 400.
581
+ const builder = new HonoStreamAppBuilder({ errors: {} })
580
582
  const RPC = Procedures<{}, RPCConfig>()
581
583
 
582
584
  RPC.CreateStream(
@@ -598,22 +600,53 @@ describe('HonoStreamAppBuilder', () => {
598
600
 
599
601
  const res = await app.request('/validated/validated-stream/1?count=not-a-number')
600
602
 
601
- // Default: returns 400 JSON error
602
603
  expect(res.status).toBe(400)
603
604
  const body = await res.json()
605
+ expect(body.name).toBe('ProcedureValidationError')
606
+ })
607
+
608
+ test('validation errors return 500 by default with no builder config (hard-default path)', async () => {
609
+ // With no taxonomy and no onError configured, every error falls through
610
+ // to the flat 500 hard default — consistent with the other three
611
+ // builders. Previously hono-stream special-cased ProcedureValidationError
612
+ // to 400; that predated the taxonomy and was removed in v6.
613
+ const builder = new HonoStreamAppBuilder()
614
+ const RPC = Procedures<{}, RPCConfig>()
615
+
616
+ RPC.CreateStream(
617
+ 'ValidatedStream',
618
+ {
619
+ scope: 'validated',
620
+ version: 1,
621
+ schema: {
622
+ params: v.object({ count: v.number() }),
623
+ },
624
+ },
625
+ async function* (ctx, params) {
626
+ yield { count: params.count }
627
+ }
628
+ )
629
+
630
+ builder.register(RPC, () => ({}))
631
+ const app = builder.build()
632
+
633
+ const res = await app.request('/validated/validated-stream/1?count=not-a-number')
634
+
635
+ expect(res.status).toBe(500)
636
+ const body = await res.json()
604
637
  expect(body.error).toContain('Validation error')
605
638
  })
606
639
 
607
- // Tests for onPreStreamError and onMidStreamError callbacks
640
+ // Tests for onError and onMidStreamError callbacks
608
641
 
609
- test('onPreStreamError handles validation errors with custom Response', async () => {
610
- const onPreStreamError = vi.fn((procedure, c, error) => {
642
+ test('onError handles validation errors with custom Response', async () => {
643
+ const onError = vi.fn((procedure, c, error) => {
611
644
  return c.json(
612
645
  { customError: true, procedureName: procedure.name, details: error.message },
613
646
  422
614
647
  )
615
648
  })
616
- const builder = new HonoStreamAppBuilder({ onPreStreamError })
649
+ const builder = new HonoStreamAppBuilder({ onError })
617
650
  const RPC = Procedures<{}, RPCConfig>()
618
651
 
619
652
  RPC.CreateStream(
@@ -641,14 +674,14 @@ describe('HonoStreamAppBuilder', () => {
641
674
  expect(body.procedureName).toBe('ValidatedStream')
642
675
  expect(body.details).toContain('Validation error')
643
676
 
644
- expect(onPreStreamError).toHaveBeenCalledTimes(1)
677
+ expect(onError).toHaveBeenCalledTimes(1)
645
678
  })
646
679
 
647
- test('onPreStreamError handles context resolution errors', async () => {
648
- const onPreStreamError = vi.fn((procedure, c, error) => {
680
+ test('onError handles context resolution errors', async () => {
681
+ const onError = vi.fn((procedure, c, error) => {
649
682
  return c.json({ contextError: error.message }, 401)
650
683
  })
651
- const builder = new HonoStreamAppBuilder({ onPreStreamError })
684
+ const builder = new HonoStreamAppBuilder({ onError })
652
685
  const RPC = Procedures<{ userId: string }, RPCConfig>()
653
686
 
654
687
  RPC.CreateStream('SecureStream', { scope: 'secure', version: 1 }, async function* (ctx) {
@@ -666,7 +699,7 @@ describe('HonoStreamAppBuilder', () => {
666
699
  const body = await res.json()
667
700
  expect(body.contextError).toBe('Authentication required')
668
701
 
669
- expect(onPreStreamError).toHaveBeenCalledTimes(1)
702
+ expect(onError).toHaveBeenCalledTimes(1)
670
703
  })
671
704
 
672
705
  test('onMidStreamError returns custom value written to SSE stream', async () => {
@@ -1257,7 +1290,10 @@ describe('HonoStreamAppBuilder', () => {
1257
1290
 
1258
1291
  test('invalid params are caught by HonoStreamAppBuilder before handler runs', async () => {
1259
1292
  let handlerCalled = false
1260
- const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
1293
+ // Opt into the taxonomy (empty) so the default ProcedureValidationError
1294
+ // entry returns 400 — the recommended way to get a structured validation
1295
+ // response now that the hard default is a flat 500.
1296
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text', errors: {} })
1261
1297
  const RPC = Procedures<{}, RPCConfig>()
1262
1298
 
1263
1299
  RPC.CreateStream(
@@ -1283,7 +1319,7 @@ describe('HonoStreamAppBuilder', () => {
1283
1319
  expect(res.status).toBe(400)
1284
1320
 
1285
1321
  const body = await res.json()
1286
- expect(body.error).toContain('Validation error')
1322
+ expect(body.name).toBe('ProcedureValidationError')
1287
1323
 
1288
1324
  // Handler should never be called since validation fails before streaming
1289
1325
  expect(handlerCalled).toBe(false)
@@ -1646,14 +1682,14 @@ describe('HonoStreamAppBuilder', () => {
1646
1682
  })
1647
1683
 
1648
1684
  // --------------------------------------------------------------------------
1649
- // ProcedureValidationError narrowing in onPreStreamError
1685
+ // ProcedureValidationError narrowing in onError
1650
1686
  // --------------------------------------------------------------------------
1651
1687
  describe('ProcedureValidationError narrowing', () => {
1652
- test('instanceof check works in onPreStreamError', async () => {
1688
+ test('instanceof check works in onError', async () => {
1653
1689
  let wasValidationError = false
1654
1690
 
1655
1691
  const builder = new HonoStreamAppBuilder({
1656
- onPreStreamError: (procedure, c, error) => {
1692
+ onError: (procedure, c, error) => {
1657
1693
  if (error instanceof ProcedureValidationError) {
1658
1694
  wasValidationError = true
1659
1695
  return c.json({ validation: true, errors: error.errors }, 422)