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,284 @@
1
+ # Hono API (REST-style) Integration
2
+
3
+ REST-style HTTP integration for Hono — routes are dispatched by HTTP method with per-channel input validation (`schema.input.pathParams`, `query`, `body`, `headers`). Works with Bun, Deno, Cloudflare Workers, and Node.js.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install ts-procedures hono
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { Procedures } from 'ts-procedures'
15
+ import { HonoAPIAppBuilder, defineErrorTaxonomy } from 'ts-procedures/hono-api'
16
+ import type { APIConfig } from 'ts-procedures/hono-api'
17
+ import { Type } from 'typebox'
18
+
19
+ // ─── Procedures ───────────────────────────────────────────
20
+
21
+ const API = Procedures<{ userId: string }, APIConfig>()
22
+
23
+ API.Create('GetUser', {
24
+ path: '/users/:id',
25
+ method: 'get',
26
+ schema: {
27
+ input: {
28
+ pathParams: Type.Object({ id: Type.String() }),
29
+ },
30
+ returnType: Type.Object({ id: Type.String(), name: Type.String() }),
31
+ },
32
+ }, async (ctx, { pathParams }) => {
33
+ return { id: pathParams.id, name: 'John Doe' }
34
+ })
35
+
36
+ API.Create('CreateUser', {
37
+ path: '/users',
38
+ method: 'post', // → 201 by default
39
+ schema: {
40
+ input: { body: Type.Object({ name: Type.String(), email: Type.String() }) },
41
+ returnType: Type.Object({ id: Type.String() }),
42
+ },
43
+ }, async (ctx, { body }) => {
44
+ return { id: await createUser(body) }
45
+ })
46
+
47
+ // ─── Build ────────────────────────────────────────────────
48
+
49
+ const app = new HonoAPIAppBuilder({ pathPrefix: '/api' })
50
+ .register(API, (c) => ({ userId: c.req.header('x-user-id') || 'anonymous' }))
51
+ .build()
52
+
53
+ // Routes:
54
+ // GET /api/users/:id → 200
55
+ // POST /api/users → 201
56
+ ```
57
+
58
+ ## Configuration
59
+
60
+ ```typescript
61
+ export type HonoAPIAppBuilderConfig = {
62
+ app?: Hono // Reuse an existing Hono instance
63
+ pathPrefix?: string // Prepend to every route
64
+ queryParser?: QueryParser // Custom query-string parser
65
+ onRequestStart?: (c: Context) => void
66
+ onRequestEnd?: (c: Context) => void
67
+ onSuccess?: (procedure, c: Context) => void
68
+ errors?: ErrorTaxonomy // Declarative error-to-response mapping
69
+ unknownError?: UnknownErrorConfig // Fallback for unmatched errors
70
+ onError?: (procedure, c, error) => Response // Imperative error callback (peer of `errors` above)
71
+ onRequestError?: (ctx) => void | Promise<void> // Cross-cutting observer for logging/tracing
72
+ }
73
+ ```
74
+
75
+ | Option | Description |
76
+ |---|---|
77
+ | `app` | Existing Hono instance to register routes on. If omitted, a new `Hono()` is created. |
78
+ | `pathPrefix` | Prefix applied to every route (e.g. `/api/v1`). Leading slash is optional. |
79
+ | `queryParser` | Override the default native `URLSearchParams` parser (see *Query Parsing* below). |
80
+ | `onRequestStart` / `onRequestEnd` | Global lifecycle hooks — wrap every registered route. |
81
+ | `onSuccess` | Called after a handler returns successfully, before the response is sent. |
82
+ | `errors` / `unknownError` | Declarative error handling — see *Error Handling* below. |
83
+ | `onError` | Imperative error callback — first-class peer of the declarative taxonomy. |
84
+ | `onRequestError` | Cross-cutting observer — fires for every caught error before dispatch. Awaited; can't mutate the response. For logging / tracing / metrics. |
85
+
86
+ ## schema.input — Multi-Channel Structured Input
87
+
88
+ REST endpoints carry input via several transport channels. `schema.input` lets you type and validate each independently:
89
+
90
+ ```typescript
91
+ API.Create('UpdatePost', {
92
+ path: '/posts/:id',
93
+ method: 'put',
94
+ schema: {
95
+ input: {
96
+ pathParams: Type.Object({ id: Type.String() }),
97
+ query: Type.Object({ draft: Type.Optional(Type.Boolean()) }),
98
+ body: Type.Object({ title: Type.String(), body: Type.String() }),
99
+ headers: Type.Object({ 'if-match': Type.String() }),
100
+ },
101
+ returnType: Type.Object({ id: Type.String(), version: Type.Number() }),
102
+ },
103
+ }, async (ctx, { pathParams, query, body, headers }) => {
104
+ // All four channels typed independently.
105
+ // AJV validates each channel; `removeAdditional` strips undeclared keys from headers.
106
+ })
107
+ ```
108
+
109
+ Supported channels: `pathParams`, `query`, `body`, `headers`. `schema.input` is mutually exclusive with `schema.params` — defining both throws `ProcedureRegistrationError` at registration time.
110
+
111
+ `HonoAPIAppBuilder` performs build-time consistency checks: `:id` in the path template must match a `pathParams.id` entry in the schema, or the builder throws at `.build()`.
112
+
113
+ ### APIInput helper
114
+
115
+ ```typescript
116
+ import type { APIInput } from 'ts-procedures/hono-api'
117
+
118
+ const schema = {
119
+ input: {
120
+ pathParams: Type.Object({ id: Type.String() }),
121
+ qurey: Type.Object({ /* ... */ }), // ← TS error: 'qurey' not in APIInput
122
+ } satisfies APIInput,
123
+ }
124
+ ```
125
+
126
+ `APIInput` constrains channel names so typos become compile errors.
127
+
128
+ ## Default Success Status
129
+
130
+ | Method | Default status |
131
+ |---|---|
132
+ | `post` | 201 |
133
+ | `delete` | 204 |
134
+ | `get`, `put`, `patch`, `head` | 200 |
135
+
136
+ Override via `successStatus` on the per-route config:
137
+
138
+ ```typescript
139
+ API.Create('RemoveUser', {
140
+ path: '/users/:id',
141
+ method: 'delete',
142
+ successStatus: 200, // Override the default 204
143
+ schema: { input: { pathParams: Type.Object({ id: Type.String() }) } },
144
+ }, async (ctx, { pathParams }) => { /* ... */ })
145
+ ```
146
+
147
+ ## Query Parsing
148
+
149
+ Default: native `URLSearchParams`. Handles flat keys (`?page=2`) and repeated keys (`?tag=a&tag=b → { tag: ['a', 'b'] }`). It does **not** parse bracket objects, bracket arrays, dot paths, or comma-split arrays.
150
+
151
+ Opt in to richer parsing via `qs`:
152
+
153
+ ```typescript
154
+ import qs from 'qs'
155
+
156
+ new HonoAPIAppBuilder({
157
+ queryParser: (raw) => qs.parse(raw) as Record<string, unknown>,
158
+ })
159
+ ```
160
+
161
+ ## Context Resolution
162
+
163
+ The context resolver receives Hono's `Context` object:
164
+
165
+ ```typescript
166
+ builder.register(API, (c) => ({
167
+ userId: c.req.header('x-user-id') || 'anonymous',
168
+ requestId: c.req.header('x-request-id') ?? crypto.randomUUID(),
169
+ }))
170
+
171
+ // Async context resolution — authenticate per request
172
+ builder.register(API, async (c) => {
173
+ const token = c.req.header('authorization')?.replace('Bearer ', '')
174
+ const user = await verifyToken(token)
175
+ return { userId: user.id, roles: user.roles }
176
+ })
177
+ ```
178
+
179
+ ## Abort Signal
180
+
181
+ `HonoAPIAppBuilder` injects `c.req.raw.signal` as `ctx.signal` in every handler so downstream async calls (fetch, DB queries) can cancel when the client disconnects.
182
+
183
+ ```typescript
184
+ API.Create('StreamingQuery', { /* ... */ }, async (ctx) => {
185
+ const result = await db.query(sql, { signal: ctx.signal })
186
+ return result
187
+ })
188
+ ```
189
+
190
+ ## Error Handling
191
+
192
+ Declare error classes via `defineErrorTaxonomy` and pass them to the builder's `errors` option. Handlers `throw` their classes; the builder auto-serializes to the configured status + body.
193
+
194
+ ```typescript
195
+ import { defineErrorTaxonomy } from 'ts-procedures/hono-api'
196
+
197
+ const appErrors = defineErrorTaxonomy({
198
+ NotFoundError: { class: NotFoundError, statusCode: 404 },
199
+ })
200
+
201
+ new HonoAPIAppBuilder({
202
+ errors: appErrors,
203
+ unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
204
+ })
205
+ ```
206
+
207
+ Full contract (both peer error modes, `onRequestError` observer, per-route narrowing via `APIConfig<keyof typeof appErrors & string>`): see **[docs/http-integrations.md § Error Handling](../../../../docs/http-integrations.md#error-handling)** — the canonical explanation shared across all four HTTP builders.
208
+
209
+ ## Extending Procedure Documentation
210
+
211
+ Like the other builders, `register()` accepts an optional third argument that extends each route's generated doc object:
212
+
213
+ ```typescript
214
+ builder.register(API, ctxResolver, ({ base, procedure }) => ({
215
+ summary: procedure.config.description,
216
+ tags: [base.scope ?? 'default'],
217
+ deprecated: procedure.config.description?.toLowerCase().includes('deprecated') ?? false,
218
+ }))
219
+ ```
220
+
221
+ The extended doc is spread onto the `APIHttpRouteDoc` before `base`, so base fields (`kind`, `name`, `method`, `path`, `fullPath`, `jsonSchema`, `errors`) always win.
222
+
223
+ ## Using an Existing Hono App
224
+
225
+ ```typescript
226
+ const app = new Hono()
227
+ app.use('*', cors())
228
+ app.get('/health', (c) => c.json({ ok: true }))
229
+
230
+ new HonoAPIAppBuilder({ app, pathPrefix: '/api' })
231
+ .register(API, contextResolver)
232
+ .build()
233
+
234
+ // API routes added alongside your custom routes.
235
+ ```
236
+
237
+ ## Route Documentation
238
+
239
+ Each registered procedure generates an `APIHttpRouteDoc` accessible via `builder.docs`:
240
+
241
+ ```typescript
242
+ interface APIHttpRouteDoc {
243
+ kind: 'api'
244
+ name: string
245
+ scope?: string
246
+ path: string
247
+ fullPath: string // path with pathPrefix applied
248
+ method: HttpMethod
249
+ successStatus?: number
250
+ jsonSchema: {
251
+ pathParams?: Record<string, unknown>
252
+ query?: Record<string, unknown>
253
+ body?: Record<string, unknown>
254
+ headers?: Record<string, unknown>
255
+ response?: Record<string, unknown>
256
+ }
257
+ errors?: string[] // Taxonomy keys this route may emit
258
+ }
259
+ ```
260
+
261
+ Feed these into `DocRegistry` to compose a single `/docs` endpoint from multiple builders — see [docs/http-integrations.md § DocRegistry](../../../../docs/http-integrations.md#docregistry--composing-docs-from-multiple-builders).
262
+
263
+ ## Runtime Compatibility
264
+
265
+ Runs wherever Hono runs: Bun, Deno, Cloudflare Workers, Node.js 18+. Uses standard Fetch API (`c.req.raw.signal`, `Request`, `Response`) — no Node-specific APIs.
266
+
267
+ ## TypeScript Types
268
+
269
+ ```typescript
270
+ import type {
271
+ APIConfig,
272
+ APIHttpRouteDoc,
273
+ APIInput,
274
+ HttpMethod,
275
+ HonoAPIAppBuilderConfig,
276
+ QueryParser,
277
+ ErrorTaxonomy,
278
+ ErrorTaxonomyEntry,
279
+ UnknownErrorConfig,
280
+ OnRequestErrorContext,
281
+ } from 'ts-procedures/hono-api'
282
+
283
+ import { HonoAPIAppBuilder, defineErrorTaxonomy } from 'ts-procedures/hono-api'
284
+ ```
@@ -0,0 +1,179 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+ import { Type } from 'typebox'
3
+ import { Procedures } from '../../../index.js'
4
+ import { APIConfig } from '../../types.js'
5
+ import { HonoAPIAppBuilder, 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('HonoAPIAppBuilder — error taxonomy', () => {
19
+ test('taxonomy serializes user error with configured status + body', 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 API = Procedures<{}, APIConfig>()
29
+ API.Create(
30
+ 'Boom',
31
+ {
32
+ path: '/boom',
33
+ method: 'get',
34
+ schema: { returnType: Type.Object({}) },
35
+ },
36
+ async () => {
37
+ throw new UseCaseError('public detail', 'private stack')
38
+ }
39
+ )
40
+
41
+ const app = new HonoAPIAppBuilder({ errors }).register(API, () => ({})).build()
42
+ const res = await app.request('/boom')
43
+ expect(res.status).toBe(422)
44
+ expect(await res.json()).toEqual({ name: 'UseCaseError', message: 'public detail' })
45
+ })
46
+
47
+ test('onCatch is awaited before response is sent', async () => {
48
+ const logged: string[] = []
49
+ const onCatch = vi.fn(async (err: UseCaseError) => {
50
+ await Promise.resolve()
51
+ logged.push(err.internalMsg)
52
+ })
53
+
54
+ const errors = defineErrorTaxonomy({
55
+ UseCaseError: {
56
+ class: UseCaseError,
57
+ statusCode: 422,
58
+ onCatch,
59
+ },
60
+ })
61
+
62
+ const API = Procedures<{}, APIConfig>()
63
+ API.Create(
64
+ 'Boom',
65
+ { path: '/boom', method: 'get', schema: { returnType: Type.Object({}) } },
66
+ async () => {
67
+ throw new UseCaseError('ext', 'int-log')
68
+ }
69
+ )
70
+
71
+ const app = new HonoAPIAppBuilder({ errors }).register(API, () => ({})).build()
72
+ await app.request('/boom')
73
+ expect(onCatch).toHaveBeenCalledOnce()
74
+ expect(logged).toEqual(['int-log'])
75
+ })
76
+
77
+ test('unknownError catches errors the taxonomy does not match', async () => {
78
+ const API = Procedures<{}, APIConfig>()
79
+ API.Create(
80
+ 'Boom',
81
+ { path: '/boom', method: 'get', schema: { returnType: Type.Object({}) } },
82
+ async () => {
83
+ throw new TypeError('ts-broke')
84
+ }
85
+ )
86
+
87
+ const app = new HonoAPIAppBuilder({
88
+ unknownError: {
89
+ statusCode: 503,
90
+ toResponse: () => ({ name: 'ServiceUnavailable' }),
91
+ },
92
+ })
93
+ .register(API, () => ({}))
94
+ .build()
95
+
96
+ const res = await app.request('/boom')
97
+ expect(res.status).toBe(503)
98
+ expect(await res.json()).toEqual({ name: 'ServiceUnavailable' })
99
+ })
100
+
101
+ test('default taxonomy catches ProcedureValidationError at 400 when user opts in', async () => {
102
+ const API = Procedures<{}, APIConfig>()
103
+ API.Create(
104
+ 'Validate',
105
+ {
106
+ path: '/v',
107
+ method: 'post',
108
+ schema: {
109
+ input: { body: Type.Object({ n: Type.Number() }) },
110
+ returnType: Type.Object({}),
111
+ },
112
+ },
113
+ async () => ({})
114
+ )
115
+ // Empty user taxonomy opts into the default chain.
116
+ const app = new HonoAPIAppBuilder({ errors: {} }).register(API, () => ({})).build()
117
+ const res = await app.request('/v', {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({ n: 'not-a-number' }),
121
+ })
122
+ expect(res.status).toBe(400)
123
+ const body = (await res.json()) as any
124
+ expect(body.name).toBe('ProcedureValidationError')
125
+ })
126
+
127
+ test('onError callback runs when taxonomy/unknownError do not match', async () => {
128
+ const onError = vi.fn(async (_p: any, c: any) => c.json({ legacy: true }, 418))
129
+ const API = Procedures<{}, APIConfig>()
130
+ API.Create(
131
+ 'Boom',
132
+ { path: '/boom', method: 'get', schema: { returnType: Type.Object({}) } },
133
+ async () => {
134
+ throw new TypeError('legacy path')
135
+ }
136
+ )
137
+ const app = new HonoAPIAppBuilder({ onError }).register(API, () => ({})).build()
138
+ const res = await app.request('/boom')
139
+ expect(res.status).toBe(418)
140
+ expect(await res.json()).toEqual({ legacy: true })
141
+ expect(onError).toHaveBeenCalledOnce()
142
+ })
143
+
144
+ test('no config → hard default unchanged ({ error: message }, 500)', async () => {
145
+ const API = Procedures<{}, APIConfig>()
146
+ API.Create(
147
+ 'Boom',
148
+ { path: '/boom', method: 'get', schema: { returnType: Type.Object({}) } },
149
+ async () => {
150
+ throw new TypeError('untouched')
151
+ }
152
+ )
153
+ const app = new HonoAPIAppBuilder().register(API, () => ({})).build()
154
+ const res = await app.request('/boom')
155
+ expect(res.status).toBe(500)
156
+ // The handler's TypeError is wrapped by the core into a ProcedureError with
157
+ // `cause` set. The default ProcedureError entry skips wrappers, no
158
+ // unknownError is configured, and no onError is provided — so we hit the
159
+ // hard default: { error: message }.
160
+ expect(await res.json()).toEqual({ error: expect.stringContaining('untouched') })
161
+ })
162
+
163
+ test('ctx.error() is serialized by the default ProcedureError taxonomy entry when opted in', async () => {
164
+ const API = Procedures<{}, APIConfig>()
165
+ API.Create(
166
+ 'Boom',
167
+ { path: '/boom', method: 'get', schema: { returnType: Type.Object({}) } },
168
+ async (ctx) => {
169
+ throw ctx.error('direct from ctx.error', { code: 'E1' })
170
+ }
171
+ )
172
+ const app = new HonoAPIAppBuilder({ errors: {} }).register(API, () => ({})).build()
173
+ const res = await app.request('/boom')
174
+ expect(res.status).toBe(500)
175
+ const body = (await res.json()) as any
176
+ expect(body.name).toBe('ProcedureError')
177
+ expect(body.procedureName).toBe('Boom')
178
+ })
179
+ })
@@ -9,9 +9,18 @@ import {
9
9
  APIInput,
10
10
  HttpMethod,
11
11
  } from '../../types.js'
12
+ import {
13
+ ErrorTaxonomy,
14
+ ErrorTaxonomyEntry,
15
+ UnknownErrorConfig,
16
+ defineErrorTaxonomy,
17
+ resolveErrorResponse,
18
+ } from '../error-taxonomy.js'
12
19
  import { HonoAPIFactoryItem } from './types.js'
13
20
 
14
21
  export type { APIConfig, APIHttpRouteDoc, APIInput, HttpMethod }
22
+ export { defineErrorTaxonomy }
23
+ export type { ErrorTaxonomy, ErrorTaxonomyEntry, UnknownErrorConfig }
15
24
 
16
25
  // ================
17
26
  // Query string parsing
@@ -94,13 +103,48 @@ export type HonoAPIAppBuilderConfig = {
94
103
  onRequestEnd?: (c: Context) => void
95
104
  onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
96
105
  /**
97
- * Error handler called when a procedure throws an error.
106
+ * Declarative error-to-response mapping (one of the two peer error modes).
107
+ * When a thrown error matches an entry (by `class` or `match`), the builder
108
+ * serializes it automatically. User entries are checked before the framework
109
+ * defaults (`ProcedureValidationError`, `ProcedureYieldValidationError`,
110
+ * `ProcedureError`).
111
+ */
112
+ errors?: ErrorTaxonomy
113
+ /**
114
+ * Fallback serializer for errors not matched by the taxonomy. Used together
115
+ * with `errors` for apps that want declarative dispatch plus a well-defined
116
+ * shape for unexpected errors.
117
+ */
118
+ unknownError?: UnknownErrorConfig
119
+ /**
120
+ * Imperative error callback — the other peer error mode. Receives every
121
+ * error directly and returns the HTTP response. Use this when you want full
122
+ * control over the response shape, or alongside `errors` for the tail of
123
+ * errors the taxonomy doesn't cover.
98
124
  */
99
125
  onError?: (
100
126
  procedure: TProcedureRegistration,
101
127
  c: Context,
102
128
  error: Error
103
129
  ) => Response | Promise<Response>
130
+ /**
131
+ * Cross-cutting observer — fires for every caught error, BEFORE dispatch to
132
+ * the taxonomy or `onError`. Awaited. Cannot mutate the response. Intended
133
+ * for logging, tracing, and metrics (Sentry, Datadog, OpenTelemetry). Any
134
+ * error thrown inside the observer is swallowed and logged so the primary
135
+ * dispatch flow is never disrupted.
136
+ */
137
+ onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
138
+ }
139
+
140
+ /**
141
+ * Context passed to the `onRequestError` observer. `raw` is the Hono
142
+ * `Context` for the in-flight request.
143
+ */
144
+ export type OnRequestErrorContext = {
145
+ err: unknown
146
+ procedure: TProcedureRegistration
147
+ raw: Context
104
148
  }
105
149
 
106
150
  /**
@@ -327,6 +371,33 @@ export class HonoAPIAppBuilder {
327
371
 
328
372
  return c.json(result, successStatus as any)
329
373
  } catch (error) {
374
+ // Observer fires first — cross-cutting, cannot alter dispatch or the
375
+ // response. Swallow any throw from the observer so instrumentation
376
+ // bugs never break the primary error-response flow.
377
+ if (this.config?.onRequestError) {
378
+ try {
379
+ await this.config.onRequestError({ err: error, procedure, raw: c })
380
+ } catch (observerErr) {
381
+ console.error('[ts-procedures hono-api] onRequestError threw — swallowed:', observerErr)
382
+ }
383
+ }
384
+
385
+ // Dispatch: taxonomy → onError → hard default. The two modes are
386
+ // peers; apps configure whichever fits (or both, in which case the
387
+ // taxonomy handles what it covers and `onError` handles the tail).
388
+ if (this.config?.errors || this.config?.unknownError) {
389
+ const resolved = resolveErrorResponse({
390
+ err: error,
391
+ userTaxonomy: this.config.errors,
392
+ unknownError: this.config.unknownError,
393
+ procedure,
394
+ raw: c,
395
+ })
396
+ if (resolved) {
397
+ await resolved.runOnCatch()
398
+ return c.json(resolved.body, resolved.statusCode as never)
399
+ }
400
+ }
330
401
  if (this.config?.onError) {
331
402
  return this.config.onError(procedure, c, error as Error)
332
403
  }
@@ -430,6 +501,10 @@ export class HonoAPIAppBuilder {
430
501
  base.successStatus = config.successStatus
431
502
  }
432
503
 
504
+ if (config.errors && config.errors.length > 0) {
505
+ base.errors = [...config.errors]
506
+ }
507
+
433
508
  let extendedDoc: object = {}
434
509
 
435
510
  if (extendProcedureDoc) {
@@ -162,27 +162,22 @@ const RPC = Procedures<AppContext, RPCConfig>()
162
162
 
163
163
  ## Error Handling
164
164
 
165
- Custom error handler receives the procedure, context, and error. **Must return a Response:**
165
+ Declare error classes via `defineErrorTaxonomy` and pass them to the builder's `errors` option. Handlers `throw` their classes; the builder auto-serializes to the configured status + body.
166
166
 
167
167
  ```typescript
168
- const builder = new HonoRPCAppBuilder({
169
- onError: (procedure, c, error) => {
170
- console.error(`Error in ${procedure.name}:`, error)
171
-
172
- if (error instanceof ValidationError) {
173
- return c.json({ error: error.message, code: 'VALIDATION_ERROR' }, 400)
174
- }
168
+ import { defineErrorTaxonomy } from 'ts-procedures/hono-rpc'
175
169
 
176
- if (error instanceof AuthError) {
177
- return c.json({ error: 'Unauthorized', code: 'AUTH_ERROR' }, 401)
178
- }
170
+ const appErrors = defineErrorTaxonomy({
171
+ AuthError: { class: AuthError, statusCode: 401 },
172
+ })
179
173
 
180
- return c.json({ error: 'Internal server error' }, 500)
181
- },
174
+ new HonoRPCAppBuilder({
175
+ errors: appErrors,
176
+ unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
182
177
  })
183
178
  ```
184
179
 
185
- **Default error handling:** Returns `{ error: message }` with status 500.
180
+ Full contract (both peer error modes, `onRequestError` observer, per-route narrowing via `RPCConfig<keyof typeof appErrors & string>`): see **[docs/http-integrations.md § Error Handling](../../../../docs/http-integrations.md#error-handling)** — the canonical explanation shared across all four HTTP builders.
186
181
 
187
182
  ## Using Existing Hono App
188
183
 
@@ -269,11 +264,15 @@ new HonoRPCAppBuilder(config?: HonoRPCAppBuilderConfig)
269
264
  ## TypeScript Types
270
265
 
271
266
  ```typescript
272
- import {
273
- HonoRPCAppBuilder,
267
+ import { HonoRPCAppBuilder, defineErrorTaxonomy } from 'ts-procedures/hono-rpc'
268
+ import type {
274
269
  HonoRPCAppBuilderConfig,
275
270
  RPCConfig,
276
271
  RPCHttpRouteDoc,
272
+ ErrorTaxonomy,
273
+ ErrorTaxonomyEntry,
274
+ UnknownErrorConfig,
275
+ OnRequestErrorContext,
277
276
  } from 'ts-procedures/hono-rpc'
278
277
  ```
279
278
 
@@ -345,10 +344,10 @@ builder
345
344
  const app = builder.build()
346
345
 
347
346
  // Generated routes:
348
- // POST /rpc/health/1
347
+ // POST /rpc/health/health-check/1
349
348
  // POST /rpc/system/version/get-version/1
350
- // POST /rpc/users/profile/get-user/1
351
- // POST /rpc/users/profile/get-user/2
349
+ // POST /rpc/users/profile/get-profile/1
350
+ // POST /rpc/users/profile/update-profile/2
352
351
 
353
352
  console.log(
354
353
  'Routes:',