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.
- package/README.md +2 -0
- package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +85 -17
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +163 -5
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +169 -13
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +22 -15
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
- package/agent_config/copilot/copilot-instructions.md +77 -12
- package/agent_config/cursor/cursorrules +77 -12
- package/build/client/call.d.ts +2 -1
- package/build/client/call.js +9 -1
- package/build/client/call.js.map +1 -1
- package/build/client/error-dispatch.d.ts +13 -0
- package/build/client/error-dispatch.js +26 -0
- package/build/client/error-dispatch.js.map +1 -0
- package/build/client/error-dispatch.test.d.ts +1 -0
- package/build/client/error-dispatch.test.js +56 -0
- package/build/client/error-dispatch.test.js.map +1 -0
- package/build/client/fetch-adapter.js +10 -4
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/index.d.ts +2 -1
- package/build/client/index.js +5 -1
- package/build/client/index.js.map +1 -1
- package/build/client/stream.d.ts +2 -1
- package/build/client/stream.js +13 -3
- package/build/client/stream.js.map +1 -1
- package/build/client/typed-error-dispatch.test.d.ts +1 -0
- package/build/client/typed-error-dispatch.test.js +168 -0
- package/build/client/typed-error-dispatch.test.js.map +1 -0
- package/build/client/types.d.ts +37 -0
- package/build/codegen/e2e.test.js +9 -4
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +4 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +17 -6
- package/build/codegen/emit-errors.integration.test.d.ts +1 -0
- package/build/codegen/emit-errors.integration.test.js +162 -0
- package/build/codegen/emit-errors.integration.test.js.map +1 -0
- package/build/codegen/emit-errors.js +50 -39
- package/build/codegen/emit-errors.js.map +1 -1
- package/build/codegen/emit-errors.test.js +75 -78
- package/build/codegen/emit-errors.test.js.map +1 -1
- package/build/codegen/emit-index.d.ts +7 -0
- package/build/codegen/emit-index.js +26 -4
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-index.test.js +55 -23
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.d.ts +8 -0
- package/build/codegen/emit-scope.js +82 -7
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/pipeline.js +22 -2
- package/build/codegen/pipeline.js.map +1 -1
- package/build/implementations/http/doc-registry.d.ts +21 -0
- package/build/implementations/http/doc-registry.js +51 -78
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +8 -6
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/error-taxonomy.d.ts +240 -0
- package/build/implementations/http/error-taxonomy.js +230 -0
- package/build/implementations/http/error-taxonomy.js.map +1 -0
- package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/error-taxonomy.test.js +399 -0
- package/build/implementations/http/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/index.d.ts +39 -8
- package/build/implementations/http/express-rpc/index.js +39 -8
- package/build/implementations/http/express-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-api/index.d.ts +38 -1
- package/build/implementations/http/hono-api/index.js +32 -0
- package/build/implementations/http/hono-api/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-rpc/index.d.ts +34 -7
- package/build/implementations/http/hono-rpc/index.js +31 -4
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-stream/index.d.ts +40 -3
- package/build/implementations/http/hono-stream/index.js +37 -10
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +45 -18
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/http/on-request-error.test.d.ts +1 -0
- package/build/implementations/http/on-request-error.test.js +173 -0
- package/build/implementations/http/on-request-error.test.js.map +1 -0
- package/build/implementations/http/route-errors.test.d.ts +1 -0
- package/build/implementations/http/route-errors.test.js +140 -0
- package/build/implementations/http/route-errors.test.js.map +1 -0
- package/build/implementations/types.d.ts +30 -2
- package/docs/client-and-codegen.md +105 -12
- package/docs/core.md +14 -5
- package/docs/http-integrations.md +135 -4
- package/docs/streaming.md +3 -1
- package/package.json +7 -2
- package/src/client/call.ts +10 -1
- package/src/client/error-dispatch.test.ts +72 -0
- package/src/client/error-dispatch.ts +27 -0
- package/src/client/fetch-adapter.ts +11 -5
- package/src/client/index.ts +9 -0
- package/src/client/stream.ts +14 -3
- package/src/client/typed-error-dispatch.test.ts +211 -0
- package/src/client/types.ts +42 -0
- package/src/codegen/e2e.test.ts +9 -4
- package/src/codegen/emit-client-runtime.ts +4 -0
- package/src/codegen/emit-errors.integration.test.ts +183 -0
- package/src/codegen/emit-errors.test.ts +91 -87
- package/src/codegen/emit-errors.ts +123 -41
- package/src/codegen/emit-index.test.ts +68 -24
- package/src/codegen/emit-index.ts +66 -4
- package/src/codegen/emit-scope.ts +124 -7
- package/src/codegen/pipeline.ts +25 -2
- package/src/implementations/http/README.md +19 -4
- package/src/implementations/http/doc-registry.test.ts +10 -6
- package/src/implementations/http/doc-registry.ts +63 -80
- package/src/implementations/http/error-taxonomy.test.ts +438 -0
- package/src/implementations/http/error-taxonomy.ts +337 -0
- package/src/implementations/http/express-rpc/README.md +21 -22
- package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
- package/src/implementations/http/express-rpc/index.ts +75 -14
- package/src/implementations/http/hono-api/README.md +284 -0
- package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
- package/src/implementations/http/hono-api/index.ts +76 -1
- package/src/implementations/http/hono-rpc/README.md +18 -19
- package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
- package/src/implementations/http/hono-rpc/index.ts +65 -9
- package/src/implementations/http/hono-stream/README.md +44 -25
- package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
- package/src/implementations/http/hono-stream/index.test.ts +54 -18
- package/src/implementations/http/hono-stream/index.ts +83 -13
- package/src/implementations/http/on-request-error.test.ts +201 -0
- package/src/implementations/http/route-errors.test.ts +177 -0
- 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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
170
|
+
const appErrors = defineErrorTaxonomy({
|
|
171
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
172
|
+
})
|
|
179
173
|
|
|
180
|
-
|
|
181
|
-
|
|
174
|
+
new HonoRPCAppBuilder({
|
|
175
|
+
errors: appErrors,
|
|
176
|
+
unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
|
|
182
177
|
})
|
|
183
178
|
```
|
|
184
179
|
|
|
185
|
-
|
|
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
|
-
|
|
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-
|
|
351
|
-
// POST /rpc/users/profile/
|
|
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:',
|