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
@@ -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)
@@ -6,8 +6,28 @@ import { TStreamProcedureRegistration } from '../../../index.js'
6
6
  import { ExtractConfig, ExtractContext, ProceduresFactory, RPCConfig } from '../../types.js'
7
7
  import { HonoStreamFactoryItem, StreamHttpRouteDoc, StreamMode } from './types.js'
8
8
  import { ProcedureValidationError } from '../../../errors.js'
9
+ import {
10
+ ErrorTaxonomy,
11
+ ErrorTaxonomyEntry,
12
+ UnknownErrorConfig,
13
+ defineErrorTaxonomy,
14
+ resolveErrorResponse,
15
+ } from '../error-taxonomy.js'
9
16
 
10
17
  export type { StreamHttpRouteDoc, StreamMode }
18
+ export { defineErrorTaxonomy }
19
+ export type { ErrorTaxonomy, ErrorTaxonomyEntry, UnknownErrorConfig }
20
+
21
+ /**
22
+ * Context passed to the `onRequestError` observer. `raw` is the Hono
23
+ * `Context` for the in-flight request — cast at the use site if you need
24
+ * framework-specific fields.
25
+ */
26
+ export type OnRequestErrorContext = {
27
+ err: unknown
28
+ procedure: TStreamProcedureRegistration
29
+ raw: Context
30
+ }
11
31
 
12
32
  export type SSEOptions = {
13
33
  event?: string
@@ -54,14 +74,38 @@ export type HonoStreamAppBuilderConfig<TErrorData = unknown> = {
54
74
  onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
55
75
  onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
56
76
  /**
57
- * Called for errors BEFORE streaming starts (validation, auth, context resolution).
58
- * Return value IS used as the HTTP response.
77
+ * Declarative error-to-response mapping (one of the two peer error modes).
78
+ * Thrown error classes map to status codes + bodies declaratively. Mid-stream
79
+ * errors still go through `onMidStreamError` — the HTTP status is already
80
+ * committed once streaming starts. See hono-api for the full taxonomy
81
+ * contract.
82
+ */
83
+ errors?: ErrorTaxonomy
84
+ /**
85
+ * Fallback serializer when a taxonomy is configured but no entry matches.
59
86
  */
60
- onPreStreamError?: (
87
+ unknownError?: UnknownErrorConfig
88
+ /**
89
+ * Imperative pre-stream error callback — the other peer error mode.
90
+ * Receives every pre-stream error directly and returns the HTTP response.
91
+ * Use this instead of `errors` when you want full control over the response
92
+ * shape, or alongside it for the tail of errors the taxonomy doesn't cover.
93
+ *
94
+ * Mid-stream errors always go through `onMidStreamError`.
95
+ */
96
+ onError?: (
61
97
  procedure: TStreamProcedureRegistration,
62
98
  c: Context,
63
99
  error: ProcedureValidationError | Error
64
100
  ) => Response | Promise<Response>
101
+ /**
102
+ * Cross-cutting observer — fires for every caught pre-stream error, BEFORE
103
+ * dispatch to the taxonomy or `onError`. Awaited. Cannot mutate the
104
+ * response. Intended for logging, tracing, and metrics (Sentry, Datadog,
105
+ * OpenTelemetry). Any error thrown inside the observer is swallowed and
106
+ * logged so the primary dispatch flow is never disrupted.
107
+ */
108
+ onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
65
109
  /**
66
110
  * Called for errors DURING streaming (generator throws).
67
111
  * Return value is written to the stream as a yield.
@@ -195,20 +239,17 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
195
239
  ? Object.fromEntries(new URL(c.req.url).searchParams)
196
240
  : await c.req.json().catch(() => ({}))
197
241
 
198
- // Validate params BEFORE starting the stream
242
+ // Validate params BEFORE starting the stream. Throw so both the
243
+ // validation path and the outer-catch (context resolution) path flow
244
+ // through one dispatch site.
199
245
  if (procedure.config.validation?.params) {
200
246
  const { errors } = procedure.config.validation.params(params)
201
247
  if (errors) {
202
- const error = new ProcedureValidationError(
248
+ throw new ProcedureValidationError(
203
249
  procedure.name,
204
250
  `Validation error for ${procedure.name}`,
205
251
  errors
206
252
  )
207
- // Use onPreStreamError if provided
208
- if (this.config?.onPreStreamError) {
209
- return this.config.onPreStreamError(procedure, c, error)
210
- }
211
- return c.json({ error: error.message }, 400)
212
253
  }
213
254
  }
214
255
 
@@ -222,9 +263,34 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
222
263
  return this.handleTextStream(procedure, context, params, c)
223
264
  }
224
265
  } catch (error) {
225
- // Use onPreStreamError for context resolution errors
226
- if (this.config?.onPreStreamError) {
227
- return this.config.onPreStreamError(procedure, c, error as Error)
266
+ // Observer fires first cross-cutting, cannot alter dispatch or the
267
+ // response. Swallow any throw from the observer so the primary flow
268
+ // never breaks because of instrumentation.
269
+ if (this.config?.onRequestError) {
270
+ try {
271
+ await this.config.onRequestError({ err: error, procedure, raw: c })
272
+ } catch (observerErr) {
273
+ console.error('[ts-procedures hono-stream] onRequestError threw — swallowed:', observerErr)
274
+ }
275
+ }
276
+
277
+ // Dispatch: taxonomy → onError → hard default. The two modes are
278
+ // peers; developers configure whichever fits their app.
279
+ if (this.config?.errors || this.config?.unknownError) {
280
+ const resolved = resolveErrorResponse({
281
+ err: error,
282
+ userTaxonomy: this.config.errors,
283
+ unknownError: this.config.unknownError,
284
+ procedure,
285
+ raw: c,
286
+ })
287
+ if (resolved) {
288
+ await resolved.runOnCatch()
289
+ return c.json(resolved.body, resolved.statusCode as never)
290
+ }
291
+ }
292
+ if (this.config?.onError) {
293
+ return this.config.onError(procedure, c, error as Error)
228
294
  }
229
295
  return c.json({ error: (error as Error).message }, 500)
230
296
  }
@@ -442,6 +508,10 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
442
508
  jsonSchema,
443
509
  }
444
510
 
511
+ if (config.errors && config.errors.length > 0) {
512
+ base.errors = [...config.errors]
513
+ }
514
+
445
515
  let extendedDoc: object = {}
446
516
 
447
517
  if (extendProcedureDoc) {
@@ -0,0 +1,201 @@
1
+ /* eslint-disable @typescript-eslint/no-empty-object-type */
2
+ /**
3
+ * Cross-builder tests for the `onRequestError` observer hook.
4
+ *
5
+ * Invariants verified once per builder:
6
+ * 1. Fires exactly once for every caught error.
7
+ * 2. Runs BEFORE dispatch (taxonomy / onError / hard default).
8
+ * 3. Is awaited — async observer work completes before the response is sent.
9
+ * 4. Doesn't replace dispatch — response shape when observer is configured
10
+ * matches the response shape when it's not.
11
+ * 5. Thrown errors inside the observer are swallowed — the primary dispatch
12
+ * path still produces a response.
13
+ */
14
+ import { describe, expect, test, vi } from 'vitest'
15
+ import request from 'supertest'
16
+ import { Type } from 'typebox'
17
+ import { Procedures } from '../../index.js'
18
+ import { APIConfig, RPCConfig } from '../types.js'
19
+ import { HonoAPIAppBuilder } from './hono-api/index.js'
20
+ import { HonoRPCAppBuilder } from './hono-rpc/index.js'
21
+ import { ExpressRPCAppBuilder } from './express-rpc/index.js'
22
+ import { HonoStreamAppBuilder } from './hono-stream/index.js'
23
+
24
+ describe('onRequestError — HonoAPIAppBuilder', () => {
25
+ function boomApp(config: ConstructorParameters<typeof HonoAPIAppBuilder>[0]) {
26
+ const API = Procedures<{}, APIConfig>()
27
+ API.Create(
28
+ 'Boom',
29
+ { path: '/boom', method: 'get', schema: { returnType: Type.Object({}) } },
30
+ async () => {
31
+ throw new TypeError('boom')
32
+ }
33
+ )
34
+ return new HonoAPIAppBuilder(config).register(API, () => ({})).build()
35
+ }
36
+
37
+ test('fires exactly once and before dispatch', async () => {
38
+ const order: string[] = []
39
+ const onRequestError = vi.fn(() => {
40
+ order.push('observer')
41
+ })
42
+ const onError = vi.fn((_p: any, c: any) => {
43
+ order.push('onError')
44
+ return c.json({ handled: true }, 418)
45
+ })
46
+
47
+ const app = boomApp({ onRequestError, onError })
48
+ await app.request('/boom')
49
+ expect(onRequestError).toHaveBeenCalledTimes(1)
50
+ expect(order).toEqual(['observer', 'onError'])
51
+ })
52
+
53
+ test('is awaited — async work completes before response is sent', async () => {
54
+ const log: string[] = []
55
+ const onRequestError = async () => {
56
+ await new Promise((r) => setTimeout(r, 5))
57
+ log.push('observer-done')
58
+ }
59
+ const app = boomApp({
60
+ onRequestError,
61
+ onError: (_p, c) => {
62
+ log.push('onError-called')
63
+ return c.json({ ok: true }, 500)
64
+ },
65
+ })
66
+ await app.request('/boom')
67
+ expect(log).toEqual(['observer-done', 'onError-called'])
68
+ })
69
+
70
+ test('observer-thrown errors are swallowed — dispatch still produces a response', async () => {
71
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
72
+ const app = boomApp({
73
+ onRequestError: () => {
74
+ throw new Error('observer exploded')
75
+ },
76
+ })
77
+ const res = await app.request('/boom')
78
+ expect(res.status).toBe(500)
79
+ consoleSpy.mockRestore()
80
+ })
81
+
82
+ test('omitting observer preserves existing behavior', async () => {
83
+ const app = boomApp({ onError: (_p, c) => c.json({ ok: true }, 418) })
84
+ const res = await app.request('/boom')
85
+ expect(res.status).toBe(418)
86
+ })
87
+ })
88
+
89
+ describe('onRequestError — HonoRPCAppBuilder', () => {
90
+ function boomApp(config: ConstructorParameters<typeof HonoRPCAppBuilder>[0]) {
91
+ const RPC = Procedures<{}, RPCConfig>()
92
+ RPC.Create(
93
+ 'Boom',
94
+ { scope: 'test', version: 1, schema: { params: Type.Object({}) } },
95
+ async () => {
96
+ throw new TypeError('boom')
97
+ }
98
+ )
99
+ return new HonoRPCAppBuilder(config).register(RPC, () => ({})).build()
100
+ }
101
+
102
+ test('fires once; runs before onError', async () => {
103
+ const order: string[] = []
104
+ const onRequestError = vi.fn(() => {
105
+ order.push('observer')
106
+ })
107
+ const onError = vi.fn((_p: any, c: any) => {
108
+ order.push('onError')
109
+ return c.json({}, 500)
110
+ })
111
+
112
+ const app = boomApp({ onRequestError, onError })
113
+ await app.request('/test/boom/1', { method: 'POST', body: '{}' })
114
+ expect(onRequestError).toHaveBeenCalledTimes(1)
115
+ expect(order).toEqual(['observer', 'onError'])
116
+ })
117
+
118
+ test('observer throws are swallowed', async () => {
119
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
120
+ const app = boomApp({
121
+ onRequestError: () => {
122
+ throw new Error('boom in observer')
123
+ },
124
+ })
125
+ const res = await app.request('/test/boom/1', { method: 'POST', body: '{}' })
126
+ expect(res.status).toBe(500)
127
+ consoleSpy.mockRestore()
128
+ })
129
+ })
130
+
131
+ describe('onRequestError — ExpressRPCAppBuilder', () => {
132
+ function boomApp(config: ConstructorParameters<typeof ExpressRPCAppBuilder>[0]) {
133
+ const RPC = Procedures<{}, RPCConfig>()
134
+ RPC.Create(
135
+ 'Boom',
136
+ { scope: 'test', version: 1, schema: { params: Type.Object({}) } },
137
+ async () => {
138
+ throw new TypeError('boom')
139
+ }
140
+ )
141
+ return new ExpressRPCAppBuilder(config).register(RPC, () => ({})).build()
142
+ }
143
+
144
+ test('fires once; raw is { req, res }', async () => {
145
+ let rawSeen: any
146
+ const app = boomApp({
147
+ onRequestError: (ctx) => {
148
+ rawSeen = ctx.raw
149
+ },
150
+ })
151
+ await request(app).post('/test/boom/1').send({})
152
+ expect(rawSeen.req).toBeDefined()
153
+ expect(rawSeen.res).toBeDefined()
154
+ })
155
+
156
+ test('observer throws are swallowed', async () => {
157
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
158
+ const app = boomApp({
159
+ onRequestError: () => {
160
+ throw new Error('observer bug')
161
+ },
162
+ })
163
+ const res = await request(app).post('/test/boom/1').send({})
164
+ expect(res.status).toBe(500)
165
+ consoleSpy.mockRestore()
166
+ })
167
+ })
168
+
169
+ describe('onRequestError — HonoStreamAppBuilder (pre-stream)', () => {
170
+ function boomApp(config: ConstructorParameters<typeof HonoStreamAppBuilder>[0]) {
171
+ const RPC = Procedures<{}, RPCConfig>()
172
+ RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
173
+ yield { ok: true }
174
+ })
175
+ return new HonoStreamAppBuilder(config)
176
+ .register(RPC, () => {
177
+ throw new TypeError('context failed')
178
+ })
179
+ .build()
180
+ }
181
+
182
+ test('fires once for pre-stream errors', async () => {
183
+ const onRequestError = vi.fn()
184
+ const app = boomApp({ onRequestError })
185
+ await app.request('/test/stream/1')
186
+ expect(onRequestError).toHaveBeenCalledTimes(1)
187
+ expect(onRequestError.mock.calls[0]![0].err).toBeInstanceOf(TypeError)
188
+ })
189
+
190
+ test('observer throws are swallowed', async () => {
191
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
192
+ const app = boomApp({
193
+ onRequestError: () => {
194
+ throw new Error('observer fail')
195
+ },
196
+ })
197
+ const res = await app.request('/test/stream/1')
198
+ expect(res.status).toBe(500)
199
+ consoleSpy.mockRestore()
200
+ })
201
+ })
@@ -0,0 +1,176 @@
1
+ /* eslint-disable @typescript-eslint/no-empty-object-type */
2
+ import { describe, expect, test } from 'vitest'
3
+ import { Type } from 'typebox'
4
+ import { Procedures } from '../../index.js'
5
+ import { APIConfig, RPCConfig } from '../types.js'
6
+ import { HonoAPIAppBuilder } from './hono-api/index.js'
7
+ import { HonoRPCAppBuilder } from './hono-rpc/index.js'
8
+ import { ExpressRPCAppBuilder } from './express-rpc/index.js'
9
+ import { HonoStreamAppBuilder } from './hono-stream/index.js'
10
+ import { DocRegistry } from './doc-registry.js'
11
+ import { defineErrorTaxonomy } from './error-taxonomy.js'
12
+
13
+ class UseCaseError extends Error {
14
+ constructor(readonly externalMsg: string) {
15
+ super(externalMsg)
16
+ this.name = 'UseCaseError'
17
+ Object.setPrototypeOf(this, UseCaseError.prototype)
18
+ }
19
+ }
20
+
21
+ class AuthError extends Error {}
22
+
23
+ const appErrors = defineErrorTaxonomy({
24
+ UseCaseError: { class: UseCaseError, statusCode: 422 },
25
+ AuthError: { class: AuthError, statusCode: 401 },
26
+ })
27
+
28
+ describe('per-route errors declaration', () => {
29
+ test('hono-api copies config.errors onto the route doc', () => {
30
+ const API = Procedures<{}, APIConfig<keyof typeof appErrors & string>>()
31
+ API.Create(
32
+ 'GetUser',
33
+ {
34
+ path: '/users/:id',
35
+ method: 'get',
36
+ errors: ['UseCaseError', 'AuthError'],
37
+ schema: {
38
+ input: { pathParams: Type.Object({ id: Type.String() }) },
39
+ returnType: Type.Object({}),
40
+ },
41
+ },
42
+ async () => ({})
43
+ )
44
+ const builder = new HonoAPIAppBuilder().register(API, () => ({}))
45
+ builder.build()
46
+ expect(builder.docs[0]!.errors).toEqual(['UseCaseError', 'AuthError'])
47
+ })
48
+
49
+ test('hono-api route doc omits errors when not declared', () => {
50
+ const API = Procedures<{}, APIConfig>()
51
+ API.Create(
52
+ 'Health',
53
+ { path: '/health', method: 'get', schema: { returnType: Type.Object({}) } },
54
+ async () => ({})
55
+ )
56
+ const builder = new HonoAPIAppBuilder().register(API, () => ({}))
57
+ builder.build()
58
+ expect(builder.docs[0]!.errors).toBeUndefined()
59
+ })
60
+
61
+ test('hono-rpc copies config.errors onto the route doc', () => {
62
+ const RPC = Procedures<{}, RPCConfig<keyof typeof appErrors & string>>()
63
+ RPC.Create(
64
+ 'DoThing',
65
+ {
66
+ scope: 'things',
67
+ version: 1,
68
+ errors: ['UseCaseError'],
69
+ schema: { params: Type.Object({}) },
70
+ },
71
+ async () => ({})
72
+ )
73
+ const builder = new HonoRPCAppBuilder().register(RPC, () => ({}))
74
+ builder.build()
75
+ expect(builder.docs[0]!.errors).toEqual(['UseCaseError'])
76
+ })
77
+
78
+ test('express-rpc copies config.errors onto the route doc', () => {
79
+ const RPC = Procedures<{}, RPCConfig<keyof typeof appErrors & string>>()
80
+ RPC.Create(
81
+ 'DoThing',
82
+ {
83
+ scope: 'things',
84
+ version: 1,
85
+ errors: ['AuthError'],
86
+ schema: { params: Type.Object({}) },
87
+ },
88
+ async () => ({})
89
+ )
90
+ const builder = new ExpressRPCAppBuilder().register(RPC, () => ({}))
91
+ builder.build()
92
+ expect(builder.docs[0]!.errors).toEqual(['AuthError'])
93
+ })
94
+
95
+ test('hono-stream copies config.errors onto the route doc', () => {
96
+ const Stream = Procedures<{}, RPCConfig<keyof typeof appErrors & string>>()
97
+ Stream.CreateStream(
98
+ 'Watch',
99
+ { scope: 'watch', version: 1, errors: ['AuthError'] },
100
+ async function* () {
101
+ yield { ok: true }
102
+ }
103
+ )
104
+ const builder = new HonoStreamAppBuilder().register(Stream, () => ({}))
105
+ builder.build()
106
+ expect(builder.docs[0]!.errors).toEqual(['AuthError'])
107
+ })
108
+
109
+ test('compile-time: errors typed as keyof taxonomy narrows valid keys', () => {
110
+ // TS-only: this test compiles only if the generic narrows correctly.
111
+ type Narrow = APIConfig<keyof typeof appErrors & string>['errors']
112
+ const _valid: Narrow = ['UseCaseError', 'AuthError']
113
+ // @ts-expect-error - 'NotRegistered' is not in the taxonomy
114
+ const _invalid: Narrow = ['NotRegistered']
115
+ expect(_valid).toBeDefined()
116
+ expect(_invalid).toBeDefined()
117
+ })
118
+ })
119
+
120
+ describe('DocRegistry with taxonomy input', () => {
121
+ test('seeds envelope errors from the taxonomy + framework defaults', () => {
122
+ const registry = new DocRegistry({ errors: appErrors, basePath: '/api' })
123
+ const envelope = registry.toJSON()
124
+ const names = envelope.errors.map((e) => e.name)
125
+ expect(names).toContain('UseCaseError')
126
+ expect(names).toContain('AuthError')
127
+ expect(names).toContain('ProcedureValidationError')
128
+ expect(names).toContain('ProcedureRegistrationError')
129
+ expect(envelope.basePath).toBe('/api')
130
+ })
131
+
132
+ test('includeDefaults: false omits framework entries', () => {
133
+ const registry = new DocRegistry({ errors: appErrors, includeDefaults: false })
134
+ const names = registry.toJSON().errors.map((e) => e.name)
135
+ expect(names).toEqual(['UseCaseError', 'AuthError'])
136
+ })
137
+
138
+ test('user entry with same key as default takes precedence (deduped)', () => {
139
+ const overridden = defineErrorTaxonomy({
140
+ ProcedureError: {
141
+ class: Error,
142
+ statusCode: 418,
143
+ description: 'custom override',
144
+ },
145
+ })
146
+ const envelope = new DocRegistry({ errors: overridden }).toJSON()
147
+ const proc = envelope.errors.find((e) => e.name === 'ProcedureError')
148
+ expect(proc?.statusCode).toBe(418)
149
+ expect(proc?.description).toBe('custom override')
150
+ const count = envelope.errors.filter((e) => e.name === 'ProcedureError').length
151
+ expect(count).toBe(1)
152
+ })
153
+
154
+ test('registered route errors survive DocRegistry composition', () => {
155
+ const API = Procedures<{}, APIConfig<keyof typeof appErrors & string>>()
156
+ API.Create(
157
+ 'GetUser',
158
+ {
159
+ path: '/users/:id',
160
+ method: 'get',
161
+ errors: ['UseCaseError'],
162
+ schema: {
163
+ input: { pathParams: Type.Object({ id: Type.String() }) },
164
+ returnType: Type.Object({}),
165
+ },
166
+ },
167
+ async () => ({})
168
+ )
169
+ const app = new HonoAPIAppBuilder().register(API, () => ({}))
170
+ app.build()
171
+
172
+ const envelope = new DocRegistry({ errors: appErrors }).from(app).toJSON()
173
+ const route = envelope.routes.find((r) => r.kind === 'api' && r.name === 'GetUser')
174
+ expect(route?.errors).toEqual(['UseCaseError'])
175
+ })
176
+ })