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
@@ -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,177 @@
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.fromTaxonomy', () => {
121
+ test('seeds envelope errors from the taxonomy + framework defaults', () => {
122
+ const registry = DocRegistry.fromTaxonomy(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 = DocRegistry.fromTaxonomy(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 = DocRegistry.fromTaxonomy(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
+ // no duplicates
151
+ const count = envelope.errors.filter((e) => e.name === 'ProcedureError').length
152
+ expect(count).toBe(1)
153
+ })
154
+
155
+ test('registered route errors survive DocRegistry composition', () => {
156
+ const API = Procedures<{}, APIConfig<keyof typeof appErrors & string>>()
157
+ API.Create(
158
+ 'GetUser',
159
+ {
160
+ path: '/users/:id',
161
+ method: 'get',
162
+ errors: ['UseCaseError'],
163
+ schema: {
164
+ input: { pathParams: Type.Object({ id: Type.String() }) },
165
+ returnType: Type.Object({}),
166
+ },
167
+ },
168
+ async () => ({})
169
+ )
170
+ const app = new HonoAPIAppBuilder().register(API, () => ({}))
171
+ app.build()
172
+
173
+ const envelope = DocRegistry.fromTaxonomy(appErrors).from(app).toJSON()
174
+ const route = envelope.routes.find((r) => r.kind === 'api' && r.name === 'GetUser')
175
+ expect(route?.errors).toEqual(['UseCaseError'])
176
+ })
177
+ })
@@ -1,9 +1,20 @@
1
1
  import { Procedures } from '../index.js'
2
2
 
3
- export interface RPCConfig {
3
+ /**
4
+ * @typeParam TErrorKey - Union of valid taxonomy keys. Defaults to `string`
5
+ * (unconstrained). Narrow it by passing `keyof typeof yourTaxonomy & string`
6
+ * to get compile-time typo protection on `errors`.
7
+ */
8
+ export interface RPCConfig<TErrorKey extends string = string> {
4
9
  // Scope or scopes (scope segments) required to access the RPC
5
10
  scope: string | string[]
6
11
  version: number
12
+ /**
13
+ * Taxonomy keys for errors this procedure may emit. Purely informational at
14
+ * runtime (nothing is rejected), but populates the DocEnvelope per-route
15
+ * error list so generated clients can narrow their catch types.
16
+ */
17
+ errors?: TErrorKey[]
7
18
  }
8
19
 
9
20
  export type FactoryItem<C> = {
@@ -20,6 +31,8 @@ export interface RPCHttpRouteDoc extends RPCConfig {
20
31
  body?: Record<string, unknown>
21
32
  response?: Record<string, unknown>
22
33
  }
34
+ /** Taxonomy keys for errors this route may emit. */
35
+ errors?: string[]
23
36
  }
24
37
 
25
38
  export type StreamMode = 'sse' | 'text'
@@ -30,7 +43,12 @@ export type StreamMode = 'sse' | 'text'
30
43
 
31
44
  export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'
32
45
 
33
- export interface APIConfig {
46
+ /**
47
+ * @typeParam TErrorKey - Union of valid taxonomy keys. Defaults to `string`.
48
+ * Narrow it by passing `keyof typeof yourTaxonomy & string` for compile-time
49
+ * typo protection on `errors`.
50
+ */
51
+ export interface APIConfig<TErrorKey extends string = string> {
34
52
  /** HTTP route path (supports Hono path params, e.g., '/users/:id') */
35
53
  path: string
36
54
  /** HTTP method for this endpoint */
@@ -39,6 +57,12 @@ export interface APIConfig {
39
57
  successStatus?: number
40
58
  /** Optional scope for grouping API routes in generated client files */
41
59
  scope?: string
60
+ /**
61
+ * Taxonomy keys for errors this procedure may emit. Purely informational at
62
+ * runtime (nothing is rejected), but populates the DocEnvelope per-route
63
+ * error list so generated clients can narrow their catch types.
64
+ */
65
+ errors?: TErrorKey[]
42
66
  }
43
67
 
44
68
  export interface APIHttpRouteDoc extends APIConfig {
@@ -53,6 +77,8 @@ export interface APIHttpRouteDoc extends APIConfig {
53
77
  headers?: Record<string, unknown>
54
78
  response?: Record<string, unknown>
55
79
  }
80
+ /** Taxonomy keys for errors this route may emit. */
81
+ errors?: string[]
56
82
  }
57
83
 
58
84
  /**
@@ -90,6 +116,8 @@ export interface StreamHttpRouteDoc extends RPCConfig {
90
116
  yieldType?: Record<string, unknown> // Schema for each streamed value
91
117
  returnType?: Record<string, unknown> // Final return (optional)
92
118
  }
119
+ /** Taxonomy keys for errors this route may emit (pre-stream only). */
120
+ errors?: string[]
93
121
  }
94
122
 
95
123
  // ================