ts-procedures 5.16.0 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +87 -19
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +162 -16
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +179 -16
- 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 +78 -12
- package/agent_config/cursor/cursorrules +78 -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 +17 -1
- package/build/implementations/http/doc-registry.js +47 -79
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +149 -16
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/error-taxonomy.d.ts +249 -0
- package/build/implementations/http/error-taxonomy.js +252 -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 +139 -0
- package/build/implementations/http/route-errors.test.js.map +1 -0
- package/build/implementations/types.d.ts +43 -3
- package/docs/client-and-codegen.md +105 -12
- package/docs/core.md +14 -5
- package/docs/http-integrations.md +138 -5
- package/docs/streaming.md +3 -1
- package/docs/superpowers/plans/2026-04-24-doc-registry-simplification.md +886 -0
- 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 +21 -7
- package/src/implementations/http/doc-registry.test.ts +164 -16
- package/src/implementations/http/doc-registry.ts +58 -82
- package/src/implementations/http/error-taxonomy.test.ts +438 -0
- package/src/implementations/http/error-taxonomy.ts +361 -0
- package/src/implementations/http/express-rpc/README.md +23 -24
- 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 +20 -21
- 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 +176 -0
- package/src/implementations/types.ts +43 -3
|
@@ -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) {
|
|
@@ -64,7 +64,7 @@ type HonoRPCAppBuilderConfig = {
|
|
|
64
64
|
onRequestStart?: (c: Context) => void
|
|
65
65
|
onRequestEnd?: (c: Context) => void
|
|
66
66
|
onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
|
|
67
|
-
|
|
67
|
+
onError?: (
|
|
68
68
|
procedure: TProcedureRegistration,
|
|
69
69
|
c: Context,
|
|
70
70
|
error: Error
|
|
@@ -79,7 +79,7 @@ type HonoRPCAppBuilderConfig = {
|
|
|
79
79
|
| `onRequestStart` | `(c) => void` | Called at start of each request |
|
|
80
80
|
| `onRequestEnd` | `(c) => void` | Called after handler completes |
|
|
81
81
|
| `onSuccess` | `(proc, c) => void` | Called on successful handler execution |
|
|
82
|
-
| `
|
|
82
|
+
| `onError` | `(proc, c, err) => Response` | Imperative error handler (peer of `errors` taxonomy — see Error Handling) |
|
|
83
83
|
|
|
84
84
|
## Context Resolution
|
|
85
85
|
|
|
@@ -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:',
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { Type } from 'typebox'
|
|
3
|
+
import { Procedures } from '../../../index.js'
|
|
4
|
+
import { RPCConfig } from '../../types.js'
|
|
5
|
+
import { HonoRPCAppBuilder, defineErrorTaxonomy } from './index.js'
|
|
6
|
+
|
|
7
|
+
class UseCaseError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
readonly externalMsg: string,
|
|
10
|
+
readonly internalMsg: string
|
|
11
|
+
) {
|
|
12
|
+
super(externalMsg)
|
|
13
|
+
this.name = 'UseCaseError'
|
|
14
|
+
Object.setPrototypeOf(this, UseCaseError.prototype)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('HonoRPCAppBuilder — error taxonomy', () => {
|
|
19
|
+
test('taxonomy catches user error thrown from RPC handler', async () => {
|
|
20
|
+
const errors = defineErrorTaxonomy({
|
|
21
|
+
UseCaseError: {
|
|
22
|
+
class: UseCaseError,
|
|
23
|
+
statusCode: 422,
|
|
24
|
+
toResponse: (err) => ({ name: 'UseCaseError', message: err.externalMsg }),
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
29
|
+
RPC.Create(
|
|
30
|
+
'Boom',
|
|
31
|
+
{ scope: 'test', version: 1, schema: { params: Type.Object({}) } },
|
|
32
|
+
async () => {
|
|
33
|
+
throw new UseCaseError('ext', 'int')
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const app = new HonoRPCAppBuilder({ errors }).register(RPC, () => ({})).build()
|
|
38
|
+
const res = await app.request('/test/boom/1', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: '{}',
|
|
42
|
+
})
|
|
43
|
+
expect(res.status).toBe(422)
|
|
44
|
+
expect(await res.json()).toEqual({ name: 'UseCaseError', message: 'ext' })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('unknownError catches unmapped errors', async () => {
|
|
48
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
49
|
+
RPC.Create(
|
|
50
|
+
'Boom',
|
|
51
|
+
{ scope: 'test', version: 1, schema: { params: Type.Object({}) } },
|
|
52
|
+
async () => {
|
|
53
|
+
throw new TypeError('db down')
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
const app = new HonoRPCAppBuilder({
|
|
57
|
+
unknownError: { statusCode: 503, toResponse: () => ({ name: 'ServiceUnavailable' }) },
|
|
58
|
+
})
|
|
59
|
+
.register(RPC, () => ({}))
|
|
60
|
+
.build()
|
|
61
|
+
|
|
62
|
+
const res = await app.request('/test/boom/1', { method: 'POST', body: '{}' })
|
|
63
|
+
expect(res.status).toBe(503)
|
|
64
|
+
expect(await res.json()).toEqual({ name: 'ServiceUnavailable' })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('onError callback runs when taxonomy does not match', async () => {
|
|
68
|
+
const onError = vi.fn(async (_p: any, c: any) => c.json({ legacy: true }, 418))
|
|
69
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
70
|
+
RPC.Create(
|
|
71
|
+
'Boom',
|
|
72
|
+
{ scope: 'test', version: 1, schema: { params: Type.Object({}) } },
|
|
73
|
+
async () => {
|
|
74
|
+
throw new TypeError('legacy')
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
const app = new HonoRPCAppBuilder({ onError }).register(RPC, () => ({})).build()
|
|
78
|
+
const res = await app.request('/test/boom/1', { method: 'POST', body: '{}' })
|
|
79
|
+
expect(res.status).toBe(418)
|
|
80
|
+
expect(onError).toHaveBeenCalledOnce()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -8,10 +8,19 @@ import {
|
|
|
8
8
|
RPCConfig,
|
|
9
9
|
RPCHttpRouteDoc,
|
|
10
10
|
} from '../../types.js'
|
|
11
|
+
import {
|
|
12
|
+
ErrorTaxonomy,
|
|
13
|
+
ErrorTaxonomyEntry,
|
|
14
|
+
UnknownErrorConfig,
|
|
15
|
+
defineErrorTaxonomy,
|
|
16
|
+
resolveErrorResponse,
|
|
17
|
+
} from '../error-taxonomy.js'
|
|
11
18
|
import { castArray } from 'es-toolkit/compat'
|
|
12
19
|
import { HonoFactoryItem } from './types.js'
|
|
13
20
|
|
|
14
21
|
export type { RPCConfig, RPCHttpRouteDoc }
|
|
22
|
+
export { defineErrorTaxonomy }
|
|
23
|
+
export type { ErrorTaxonomy, ErrorTaxonomyEntry, UnknownErrorConfig }
|
|
15
24
|
|
|
16
25
|
export type HonoRPCAppBuilderConfig = {
|
|
17
26
|
/**
|
|
@@ -25,16 +34,39 @@ export type HonoRPCAppBuilderConfig = {
|
|
|
25
34
|
onRequestEnd?: (c: Context) => void
|
|
26
35
|
onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
|
|
27
36
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
37
|
+
* Declarative error-to-response mapping (one of the two peer error modes).
|
|
38
|
+
* See hono-api for the full taxonomy contract. User entries take precedence
|
|
39
|
+
* over the framework default taxonomy.
|
|
40
|
+
*/
|
|
41
|
+
errors?: ErrorTaxonomy
|
|
42
|
+
/** Fallback serializer for errors not matched by the taxonomy. */
|
|
43
|
+
unknownError?: UnknownErrorConfig
|
|
44
|
+
/**
|
|
45
|
+
* Imperative error callback — the other peer error mode. Receives every
|
|
46
|
+
* error directly and returns the HTTP response. Use this when you want full
|
|
47
|
+
* control over the response shape, or alongside `errors` for the tail of
|
|
48
|
+
* errors the taxonomy doesn't cover.
|
|
32
49
|
*/
|
|
33
50
|
onError?: (
|
|
34
51
|
procedure: TProcedureRegistration,
|
|
35
52
|
c: Context,
|
|
36
53
|
error: Error
|
|
37
54
|
) => Response | Promise<Response>
|
|
55
|
+
/**
|
|
56
|
+
* Cross-cutting observer — fires for every caught error, BEFORE dispatch.
|
|
57
|
+
* Awaited. Cannot mutate the response. Intended for logging, tracing, and
|
|
58
|
+
* metrics. Thrown errors inside the observer are swallowed and logged.
|
|
59
|
+
*/
|
|
60
|
+
onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Context passed to the `onRequestError` observer.
|
|
65
|
+
*/
|
|
66
|
+
export type OnRequestErrorContext = {
|
|
67
|
+
err: unknown
|
|
68
|
+
procedure: TProcedureRegistration
|
|
69
|
+
raw: Context
|
|
38
70
|
}
|
|
39
71
|
|
|
40
72
|
/**
|
|
@@ -80,11 +112,13 @@ export class HonoRPCAppBuilder {
|
|
|
80
112
|
|
|
81
113
|
/**
|
|
82
114
|
* Generates the RPC route path based on the RPC configuration.
|
|
83
|
-
*
|
|
115
|
+
* `RPCConfig.scope` can be a string or an array of strings to form nested paths.
|
|
84
116
|
*
|
|
85
117
|
* Example
|
|
86
|
-
* name:
|
|
87
|
-
*
|
|
118
|
+
* name: 'GetUser'
|
|
119
|
+
* scope: ['users', 'profile']
|
|
120
|
+
* version: 1
|
|
121
|
+
* path: /users/profile/get-user/1
|
|
88
122
|
* @param config
|
|
89
123
|
*/
|
|
90
124
|
static makeRPCHttpRoutePath({
|
|
@@ -178,10 +212,29 @@ export class HonoRPCAppBuilder {
|
|
|
178
212
|
// Hono returns Response objects via c.json()
|
|
179
213
|
return c.json(result)
|
|
180
214
|
} catch (error) {
|
|
215
|
+
if (this.config?.onRequestError) {
|
|
216
|
+
try {
|
|
217
|
+
await this.config.onRequestError({ err: error, procedure, raw: c })
|
|
218
|
+
} catch (observerErr) {
|
|
219
|
+
console.error('[ts-procedures hono-rpc] onRequestError threw — swallowed:', observerErr)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (this.config?.errors || this.config?.unknownError) {
|
|
223
|
+
const resolved = resolveErrorResponse({
|
|
224
|
+
err: error,
|
|
225
|
+
userTaxonomy: this.config.errors,
|
|
226
|
+
unknownError: this.config.unknownError,
|
|
227
|
+
procedure,
|
|
228
|
+
raw: c,
|
|
229
|
+
})
|
|
230
|
+
if (resolved) {
|
|
231
|
+
await resolved.runOnCatch()
|
|
232
|
+
return c.json(resolved.body, resolved.statusCode as never)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
181
235
|
if (this.config?.onError) {
|
|
182
236
|
return this.config.onError(procedure, c, error as Error)
|
|
183
237
|
}
|
|
184
|
-
// Default error handling
|
|
185
238
|
return c.json({ error: (error as Error).message }, 500)
|
|
186
239
|
}
|
|
187
240
|
})
|
|
@@ -215,7 +268,7 @@ export class HonoRPCAppBuilder {
|
|
|
215
268
|
jsonSchema.response = config.schema.returnType
|
|
216
269
|
}
|
|
217
270
|
|
|
218
|
-
const base = {
|
|
271
|
+
const base: RPCHttpRouteDoc = {
|
|
219
272
|
kind: 'rpc' as const,
|
|
220
273
|
name: procedure.name,
|
|
221
274
|
version: config.version,
|
|
@@ -224,6 +277,9 @@ export class HonoRPCAppBuilder {
|
|
|
224
277
|
method,
|
|
225
278
|
jsonSchema,
|
|
226
279
|
}
|
|
280
|
+
if (config.errors && config.errors.length > 0) {
|
|
281
|
+
base.errors = [...config.errors]
|
|
282
|
+
}
|
|
227
283
|
let extendedDoc: object = {}
|
|
228
284
|
|
|
229
285
|
if (extendProcedureDoc) {
|
|
@@ -200,8 +200,11 @@ interface HonoStreamAppBuilderConfig<TErrorData = unknown> {
|
|
|
200
200
|
onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
|
|
201
201
|
onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
|
|
202
202
|
|
|
203
|
-
// Error handling
|
|
204
|
-
|
|
203
|
+
// Error handling (see Error Handling section)
|
|
204
|
+
errors?: ErrorTaxonomy
|
|
205
|
+
unknownError?: UnknownErrorConfig
|
|
206
|
+
onError?: (procedure, c, error: ProcedureValidationError | Error) => Response | Promise<Response>
|
|
207
|
+
onRequestError?: (ctx) => void | Promise<void>
|
|
205
208
|
onMidStreamError?: (procedure, c, error) => MidStreamErrorResult<TErrorData> | undefined
|
|
206
209
|
}
|
|
207
210
|
```
|
|
@@ -253,34 +256,27 @@ This design allows for:
|
|
|
253
256
|
|
|
254
257
|
Streaming has two distinct error phases with different semantics:
|
|
255
258
|
|
|
256
|
-
| Phase | When |
|
|
257
|
-
|
|
258
|
-
| **Pre-stream** | Validation, auth, context resolution |
|
|
259
|
-
| **Mid-stream** | Generator throws during iteration |
|
|
259
|
+
| Phase | When | Handling |
|
|
260
|
+
|-------|------|---------|
|
|
261
|
+
| **Pre-stream** | Validation, auth, context resolution | Declarative taxonomy via `errors` + `unknownError` — same shape as all other HTTP builders |
|
|
262
|
+
| **Mid-stream** | Generator throws during iteration | `onMidStreamError` — writes a value into the open stream (status is already committed) |
|
|
260
263
|
|
|
261
|
-
### Pre-Stream
|
|
262
|
-
|
|
263
|
-
Errors that occur **before** the stream starts (validation failures, context resolution errors). The `error` parameter is typed as `ProcedureValidationError | Error`, allowing `instanceof` narrowing. The return value **IS** used as the HTTP response:
|
|
264
|
+
### Pre-Stream — Error Taxonomy
|
|
264
265
|
|
|
265
266
|
```typescript
|
|
267
|
+
import { defineErrorTaxonomy } from 'ts-procedures/hono-stream'
|
|
268
|
+
|
|
266
269
|
const builder = new HonoStreamAppBuilder({
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
procedure: procedure.name,
|
|
273
|
-
}, 400)
|
|
274
|
-
}
|
|
275
|
-
return c.json({ error: error.message }, 500)
|
|
276
|
-
},
|
|
270
|
+
errors: defineErrorTaxonomy({
|
|
271
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
272
|
+
}),
|
|
273
|
+
unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
|
|
274
|
+
onMidStreamError: (/* ... */) => ({ /* mid-stream only */ }),
|
|
277
275
|
})
|
|
278
|
-
|
|
279
|
-
// Without handler, returns default JSON response:
|
|
280
|
-
// Status: 400 (validation) or 500 (other)
|
|
281
|
-
// Body: { "error": "Validation error for ProcedureName - ..." }
|
|
282
276
|
```
|
|
283
277
|
|
|
278
|
+
Full contract (both peer error modes, `onRequestError` observer, per-route `errors` narrowing): see **[docs/http-integrations.md § Error Handling](../../../../docs/http-integrations.md#error-handling)** — the canonical explanation shared across all four HTTP builders.
|
|
279
|
+
|
|
284
280
|
### Mid-Stream Errors (`onMidStreamError`)
|
|
285
281
|
|
|
286
282
|
Errors that occur **during** streaming (generator throws). Since the stream is already open, HTTP status cannot change. Return `{ data, closeStream? }` — the `data` value is written as the SSE `data:` field content.
|
|
@@ -474,12 +470,19 @@ RPC.CreateStream('LongStream', config, async function* (ctx) {
|
|
|
474
470
|
```typescript
|
|
475
471
|
import {
|
|
476
472
|
HonoStreamAppBuilder,
|
|
473
|
+
defineErrorTaxonomy,
|
|
474
|
+
sse, // Helper to attach SSE metadata (event/id/retry) to yielded objects
|
|
475
|
+
} from 'ts-procedures/hono-stream'
|
|
476
|
+
import type {
|
|
477
477
|
HonoStreamAppBuilderConfig, // Generic: HonoStreamAppBuilderConfig<TErrorData = unknown>
|
|
478
478
|
StreamHttpRouteDoc,
|
|
479
479
|
StreamMode,
|
|
480
|
-
sse, // Helper to attach SSE metadata (event/id/retry) to yielded objects
|
|
481
480
|
SSEOptions, // Type for sse() options: { event?, id?, retry? }
|
|
482
481
|
MidStreamErrorResult, // Generic: MidStreamErrorResult<TErrorData = unknown>
|
|
482
|
+
ErrorTaxonomy,
|
|
483
|
+
ErrorTaxonomyEntry,
|
|
484
|
+
UnknownErrorConfig,
|
|
485
|
+
OnRequestErrorContext, // Passed to the onRequestError observer: { err, procedure, raw: Context }
|
|
483
486
|
} from 'ts-procedures/hono-stream'
|
|
484
487
|
```
|
|
485
488
|
|
|
@@ -522,4 +525,20 @@ onStreamEnd: (procedure, c, streamMode) => { ... }
|
|
|
522
525
|
|
|
523
526
|
**New: Generic `TErrorData` parameter** on `HonoStreamAppBuilder` and `MidStreamErrorResult` for type-safe `onMidStreamError` callbacks.
|
|
524
527
|
|
|
525
|
-
**
|
|
528
|
+
**Breaking: `onPreStreamError` renamed to `onError`** for consistency with the other three HTTP builders. Signature unchanged.
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
// Before (v5)
|
|
532
|
+
new HonoStreamAppBuilder({
|
|
533
|
+
onPreStreamError: (proc, c, err) => c.json({ error: err.message }, 400),
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
// After (v6)
|
|
537
|
+
new HonoStreamAppBuilder({
|
|
538
|
+
onError: (proc, c, err) => c.json({ error: err.message }, 400),
|
|
539
|
+
})
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
**Breaking: hard-default status for `ProcedureValidationError` changed from 400 to 500.** Previously, `HonoStreamAppBuilder` special-cased validation errors to 400 when no `errors` / `onError` was configured. v6 drops this inconsistency — the hard default is now 500 across all four builders. To preserve the 400 response, configure the taxonomy (`errors: {}` engages framework defaults) or handle it in `onError`.
|
|
543
|
+
|
|
544
|
+
**New: `onRequestError` cross-cutting observer.** Fires for every caught pre-stream error, before dispatch. Awaited, cannot mutate the response. Use for logging, tracing, metrics. See [docs/http-integrations.md § Error Handling](../../../../docs/http-integrations.md#error-handling).
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
|
2
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
3
|
+
import { Type } from 'typebox'
|
|
4
|
+
import { Procedures } from '../../../index.js'
|
|
5
|
+
import { RPCConfig } from '../../types.js'
|
|
6
|
+
import { HonoStreamAppBuilder, defineErrorTaxonomy } from './index.js'
|
|
7
|
+
|
|
8
|
+
class AuthError extends Error {
|
|
9
|
+
constructor(readonly reason: 'unauthenticated' | 'forbidden') {
|
|
10
|
+
super(reason)
|
|
11
|
+
this.name = 'AuthError'
|
|
12
|
+
Object.setPrototypeOf(this, AuthError.prototype)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('HonoStreamAppBuilder — error taxonomy (pre-stream)', () => {
|
|
17
|
+
test('taxonomy catches errors thrown during context resolution', async () => {
|
|
18
|
+
const errors = defineErrorTaxonomy({
|
|
19
|
+
AuthError: {
|
|
20
|
+
class: AuthError,
|
|
21
|
+
statusCode: 401,
|
|
22
|
+
toResponse: (err) => ({ name: 'AuthError', reason: err.reason }),
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
26
|
+
RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
|
|
27
|
+
yield { msg: 'ok' }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const app = new HonoStreamAppBuilder({ errors })
|
|
31
|
+
.register(RPC, () => {
|
|
32
|
+
throw new AuthError('forbidden')
|
|
33
|
+
})
|
|
34
|
+
.build()
|
|
35
|
+
|
|
36
|
+
const res = await app.request('/test/stream/1')
|
|
37
|
+
expect(res.status).toBe(401)
|
|
38
|
+
expect(await res.json()).toEqual({ name: 'AuthError', reason: 'forbidden' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('default taxonomy serializes ProcedureValidationError at 400 when user opts in', async () => {
|
|
42
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
43
|
+
RPC.CreateStream(
|
|
44
|
+
'Stream',
|
|
45
|
+
{
|
|
46
|
+
scope: 'test',
|
|
47
|
+
version: 1,
|
|
48
|
+
schema: { params: Type.Object({ n: Type.Number() }) },
|
|
49
|
+
},
|
|
50
|
+
async function* () {
|
|
51
|
+
yield { ok: true }
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
const app = new HonoStreamAppBuilder({ errors: {} }).register(RPC, () => ({})).build()
|
|
55
|
+
const res = await app.request('/test/stream/1?n=not-a-number')
|
|
56
|
+
expect(res.status).toBe(400)
|
|
57
|
+
const body = (await res.json()) as any
|
|
58
|
+
expect(body.name).toBe('ProcedureValidationError')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('onError callback runs when taxonomy does not match', async () => {
|
|
62
|
+
const onError = vi.fn(async (_p: any, c: any) => c.json({ handled: true }, 418))
|
|
63
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
64
|
+
RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
|
|
65
|
+
yield { ok: true }
|
|
66
|
+
})
|
|
67
|
+
const app = new HonoStreamAppBuilder({ onError })
|
|
68
|
+
.register(RPC, () => {
|
|
69
|
+
throw new TypeError('context failed')
|
|
70
|
+
})
|
|
71
|
+
.build()
|
|
72
|
+
|
|
73
|
+
const res = await app.request('/test/stream/1')
|
|
74
|
+
expect(res.status).toBe(418)
|
|
75
|
+
expect(onError).toHaveBeenCalledOnce()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('unknownError is used for unmapped pre-stream errors', async () => {
|
|
79
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
80
|
+
RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
|
|
81
|
+
yield { ok: true }
|
|
82
|
+
})
|
|
83
|
+
const app = new HonoStreamAppBuilder({
|
|
84
|
+
unknownError: {
|
|
85
|
+
statusCode: 503,
|
|
86
|
+
toResponse: () => ({ name: 'ServiceUnavailable' }),
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
.register(RPC, () => {
|
|
90
|
+
throw new TypeError('infra down')
|
|
91
|
+
})
|
|
92
|
+
.build()
|
|
93
|
+
|
|
94
|
+
const res = await app.request('/test/stream/1')
|
|
95
|
+
expect(res.status).toBe(503)
|
|
96
|
+
expect(await res.json()).toEqual({ name: 'ServiceUnavailable' })
|
|
97
|
+
})
|
|
98
|
+
})
|