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
|
@@ -503,7 +503,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
503
503
|
return c.json({ customError: error.message }, 400)
|
|
504
504
|
})
|
|
505
505
|
|
|
506
|
-
const builder = new HonoStreamAppBuilder({
|
|
506
|
+
const builder = new HonoStreamAppBuilder({ onError: errorHandler })
|
|
507
507
|
const RPC = Procedures<{}, RPCConfig>()
|
|
508
508
|
|
|
509
509
|
RPC.CreateStream(
|
|
@@ -575,8 +575,10 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
575
575
|
expect(JSON.parse(lines[1]!).error).toContain('Stream error')
|
|
576
576
|
})
|
|
577
577
|
|
|
578
|
-
test('validation errors return 400
|
|
579
|
-
|
|
578
|
+
test('validation errors return 400 when the default taxonomy is engaged (via `errors: {}`)', async () => {
|
|
579
|
+
// Opting into the taxonomy (even an empty one) engages the framework
|
|
580
|
+
// default entries — ProcedureValidationError → 400.
|
|
581
|
+
const builder = new HonoStreamAppBuilder({ errors: {} })
|
|
580
582
|
const RPC = Procedures<{}, RPCConfig>()
|
|
581
583
|
|
|
582
584
|
RPC.CreateStream(
|
|
@@ -598,22 +600,53 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
598
600
|
|
|
599
601
|
const res = await app.request('/validated/validated-stream/1?count=not-a-number')
|
|
600
602
|
|
|
601
|
-
// Default: returns 400 JSON error
|
|
602
603
|
expect(res.status).toBe(400)
|
|
603
604
|
const body = await res.json()
|
|
605
|
+
expect(body.name).toBe('ProcedureValidationError')
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
test('validation errors return 500 by default with no builder config (hard-default path)', async () => {
|
|
609
|
+
// With no taxonomy and no onError configured, every error falls through
|
|
610
|
+
// to the flat 500 hard default — consistent with the other three
|
|
611
|
+
// builders. Previously hono-stream special-cased ProcedureValidationError
|
|
612
|
+
// to 400; that predated the taxonomy and was removed in v6.
|
|
613
|
+
const builder = new HonoStreamAppBuilder()
|
|
614
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
615
|
+
|
|
616
|
+
RPC.CreateStream(
|
|
617
|
+
'ValidatedStream',
|
|
618
|
+
{
|
|
619
|
+
scope: 'validated',
|
|
620
|
+
version: 1,
|
|
621
|
+
schema: {
|
|
622
|
+
params: v.object({ count: v.number() }),
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
async function* (ctx, params) {
|
|
626
|
+
yield { count: params.count }
|
|
627
|
+
}
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
builder.register(RPC, () => ({}))
|
|
631
|
+
const app = builder.build()
|
|
632
|
+
|
|
633
|
+
const res = await app.request('/validated/validated-stream/1?count=not-a-number')
|
|
634
|
+
|
|
635
|
+
expect(res.status).toBe(500)
|
|
636
|
+
const body = await res.json()
|
|
604
637
|
expect(body.error).toContain('Validation error')
|
|
605
638
|
})
|
|
606
639
|
|
|
607
|
-
// Tests for
|
|
640
|
+
// Tests for onError and onMidStreamError callbacks
|
|
608
641
|
|
|
609
|
-
test('
|
|
610
|
-
const
|
|
642
|
+
test('onError handles validation errors with custom Response', async () => {
|
|
643
|
+
const onError = vi.fn((procedure, c, error) => {
|
|
611
644
|
return c.json(
|
|
612
645
|
{ customError: true, procedureName: procedure.name, details: error.message },
|
|
613
646
|
422
|
|
614
647
|
)
|
|
615
648
|
})
|
|
616
|
-
const builder = new HonoStreamAppBuilder({
|
|
649
|
+
const builder = new HonoStreamAppBuilder({ onError })
|
|
617
650
|
const RPC = Procedures<{}, RPCConfig>()
|
|
618
651
|
|
|
619
652
|
RPC.CreateStream(
|
|
@@ -641,14 +674,14 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
641
674
|
expect(body.procedureName).toBe('ValidatedStream')
|
|
642
675
|
expect(body.details).toContain('Validation error')
|
|
643
676
|
|
|
644
|
-
expect(
|
|
677
|
+
expect(onError).toHaveBeenCalledTimes(1)
|
|
645
678
|
})
|
|
646
679
|
|
|
647
|
-
test('
|
|
648
|
-
const
|
|
680
|
+
test('onError handles context resolution errors', async () => {
|
|
681
|
+
const onError = vi.fn((procedure, c, error) => {
|
|
649
682
|
return c.json({ contextError: error.message }, 401)
|
|
650
683
|
})
|
|
651
|
-
const builder = new HonoStreamAppBuilder({
|
|
684
|
+
const builder = new HonoStreamAppBuilder({ onError })
|
|
652
685
|
const RPC = Procedures<{ userId: string }, RPCConfig>()
|
|
653
686
|
|
|
654
687
|
RPC.CreateStream('SecureStream', { scope: 'secure', version: 1 }, async function* (ctx) {
|
|
@@ -666,7 +699,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
666
699
|
const body = await res.json()
|
|
667
700
|
expect(body.contextError).toBe('Authentication required')
|
|
668
701
|
|
|
669
|
-
expect(
|
|
702
|
+
expect(onError).toHaveBeenCalledTimes(1)
|
|
670
703
|
})
|
|
671
704
|
|
|
672
705
|
test('onMidStreamError returns custom value written to SSE stream', async () => {
|
|
@@ -1257,7 +1290,10 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1257
1290
|
|
|
1258
1291
|
test('invalid params are caught by HonoStreamAppBuilder before handler runs', async () => {
|
|
1259
1292
|
let handlerCalled = false
|
|
1260
|
-
|
|
1293
|
+
// Opt into the taxonomy (empty) so the default ProcedureValidationError
|
|
1294
|
+
// entry returns 400 — the recommended way to get a structured validation
|
|
1295
|
+
// response now that the hard default is a flat 500.
|
|
1296
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text', errors: {} })
|
|
1261
1297
|
const RPC = Procedures<{}, RPCConfig>()
|
|
1262
1298
|
|
|
1263
1299
|
RPC.CreateStream(
|
|
@@ -1283,7 +1319,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1283
1319
|
expect(res.status).toBe(400)
|
|
1284
1320
|
|
|
1285
1321
|
const body = await res.json()
|
|
1286
|
-
expect(body.
|
|
1322
|
+
expect(body.name).toBe('ProcedureValidationError')
|
|
1287
1323
|
|
|
1288
1324
|
// Handler should never be called since validation fails before streaming
|
|
1289
1325
|
expect(handlerCalled).toBe(false)
|
|
@@ -1646,14 +1682,14 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1646
1682
|
})
|
|
1647
1683
|
|
|
1648
1684
|
// --------------------------------------------------------------------------
|
|
1649
|
-
// ProcedureValidationError narrowing in
|
|
1685
|
+
// ProcedureValidationError narrowing in onError
|
|
1650
1686
|
// --------------------------------------------------------------------------
|
|
1651
1687
|
describe('ProcedureValidationError narrowing', () => {
|
|
1652
|
-
test('instanceof check works in
|
|
1688
|
+
test('instanceof check works in onError', async () => {
|
|
1653
1689
|
let wasValidationError = false
|
|
1654
1690
|
|
|
1655
1691
|
const builder = new HonoStreamAppBuilder({
|
|
1656
|
-
|
|
1692
|
+
onError: (procedure, c, error) => {
|
|
1657
1693
|
if (error instanceof ProcedureValidationError) {
|
|
1658
1694
|
wasValidationError = true
|
|
1659
1695
|
return c.json({ validation: true, errors: error.errors }, 422)
|
|
@@ -6,8 +6,28 @@ import { TStreamProcedureRegistration } from '../../../index.js'
|
|
|
6
6
|
import { ExtractConfig, ExtractContext, ProceduresFactory, RPCConfig } from '../../types.js'
|
|
7
7
|
import { HonoStreamFactoryItem, StreamHttpRouteDoc, StreamMode } from './types.js'
|
|
8
8
|
import { ProcedureValidationError } from '../../../errors.js'
|
|
9
|
+
import {
|
|
10
|
+
ErrorTaxonomy,
|
|
11
|
+
ErrorTaxonomyEntry,
|
|
12
|
+
UnknownErrorConfig,
|
|
13
|
+
defineErrorTaxonomy,
|
|
14
|
+
resolveErrorResponse,
|
|
15
|
+
} from '../error-taxonomy.js'
|
|
9
16
|
|
|
10
17
|
export type { StreamHttpRouteDoc, StreamMode }
|
|
18
|
+
export { defineErrorTaxonomy }
|
|
19
|
+
export type { ErrorTaxonomy, ErrorTaxonomyEntry, UnknownErrorConfig }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Context passed to the `onRequestError` observer. `raw` is the Hono
|
|
23
|
+
* `Context` for the in-flight request — cast at the use site if you need
|
|
24
|
+
* framework-specific fields.
|
|
25
|
+
*/
|
|
26
|
+
export type OnRequestErrorContext = {
|
|
27
|
+
err: unknown
|
|
28
|
+
procedure: TStreamProcedureRegistration
|
|
29
|
+
raw: Context
|
|
30
|
+
}
|
|
11
31
|
|
|
12
32
|
export type SSEOptions = {
|
|
13
33
|
event?: string
|
|
@@ -54,14 +74,38 @@ export type HonoStreamAppBuilderConfig<TErrorData = unknown> = {
|
|
|
54
74
|
onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
|
|
55
75
|
onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
|
|
56
76
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
77
|
+
* Declarative error-to-response mapping (one of the two peer error modes).
|
|
78
|
+
* Thrown error classes map to status codes + bodies declaratively. Mid-stream
|
|
79
|
+
* errors still go through `onMidStreamError` — the HTTP status is already
|
|
80
|
+
* committed once streaming starts. See hono-api for the full taxonomy
|
|
81
|
+
* contract.
|
|
82
|
+
*/
|
|
83
|
+
errors?: ErrorTaxonomy
|
|
84
|
+
/**
|
|
85
|
+
* Fallback serializer when a taxonomy is configured but no entry matches.
|
|
59
86
|
*/
|
|
60
|
-
|
|
87
|
+
unknownError?: UnknownErrorConfig
|
|
88
|
+
/**
|
|
89
|
+
* Imperative pre-stream error callback — the other peer error mode.
|
|
90
|
+
* Receives every pre-stream error directly and returns the HTTP response.
|
|
91
|
+
* Use this instead of `errors` when you want full control over the response
|
|
92
|
+
* shape, or alongside it for the tail of errors the taxonomy doesn't cover.
|
|
93
|
+
*
|
|
94
|
+
* Mid-stream errors always go through `onMidStreamError`.
|
|
95
|
+
*/
|
|
96
|
+
onError?: (
|
|
61
97
|
procedure: TStreamProcedureRegistration,
|
|
62
98
|
c: Context,
|
|
63
99
|
error: ProcedureValidationError | Error
|
|
64
100
|
) => Response | Promise<Response>
|
|
101
|
+
/**
|
|
102
|
+
* Cross-cutting observer — fires for every caught pre-stream error, BEFORE
|
|
103
|
+
* dispatch to the taxonomy or `onError`. Awaited. Cannot mutate the
|
|
104
|
+
* response. Intended for logging, tracing, and metrics (Sentry, Datadog,
|
|
105
|
+
* OpenTelemetry). Any error thrown inside the observer is swallowed and
|
|
106
|
+
* logged so the primary dispatch flow is never disrupted.
|
|
107
|
+
*/
|
|
108
|
+
onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
|
|
65
109
|
/**
|
|
66
110
|
* Called for errors DURING streaming (generator throws).
|
|
67
111
|
* Return value is written to the stream as a yield.
|
|
@@ -195,20 +239,17 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
|
|
|
195
239
|
? Object.fromEntries(new URL(c.req.url).searchParams)
|
|
196
240
|
: await c.req.json().catch(() => ({}))
|
|
197
241
|
|
|
198
|
-
// Validate params BEFORE starting the stream
|
|
242
|
+
// Validate params BEFORE starting the stream. Throw so both the
|
|
243
|
+
// validation path and the outer-catch (context resolution) path flow
|
|
244
|
+
// through one dispatch site.
|
|
199
245
|
if (procedure.config.validation?.params) {
|
|
200
246
|
const { errors } = procedure.config.validation.params(params)
|
|
201
247
|
if (errors) {
|
|
202
|
-
|
|
248
|
+
throw new ProcedureValidationError(
|
|
203
249
|
procedure.name,
|
|
204
250
|
`Validation error for ${procedure.name}`,
|
|
205
251
|
errors
|
|
206
252
|
)
|
|
207
|
-
// Use onPreStreamError if provided
|
|
208
|
-
if (this.config?.onPreStreamError) {
|
|
209
|
-
return this.config.onPreStreamError(procedure, c, error)
|
|
210
|
-
}
|
|
211
|
-
return c.json({ error: error.message }, 400)
|
|
212
253
|
}
|
|
213
254
|
}
|
|
214
255
|
|
|
@@ -222,9 +263,34 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
|
|
|
222
263
|
return this.handleTextStream(procedure, context, params, c)
|
|
223
264
|
}
|
|
224
265
|
} catch (error) {
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
266
|
+
// Observer fires first — cross-cutting, cannot alter dispatch or the
|
|
267
|
+
// response. Swallow any throw from the observer so the primary flow
|
|
268
|
+
// never breaks because of instrumentation.
|
|
269
|
+
if (this.config?.onRequestError) {
|
|
270
|
+
try {
|
|
271
|
+
await this.config.onRequestError({ err: error, procedure, raw: c })
|
|
272
|
+
} catch (observerErr) {
|
|
273
|
+
console.error('[ts-procedures hono-stream] onRequestError threw — swallowed:', observerErr)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Dispatch: taxonomy → onError → hard default. The two modes are
|
|
278
|
+
// peers; developers configure whichever fits their app.
|
|
279
|
+
if (this.config?.errors || this.config?.unknownError) {
|
|
280
|
+
const resolved = resolveErrorResponse({
|
|
281
|
+
err: error,
|
|
282
|
+
userTaxonomy: this.config.errors,
|
|
283
|
+
unknownError: this.config.unknownError,
|
|
284
|
+
procedure,
|
|
285
|
+
raw: c,
|
|
286
|
+
})
|
|
287
|
+
if (resolved) {
|
|
288
|
+
await resolved.runOnCatch()
|
|
289
|
+
return c.json(resolved.body, resolved.statusCode as never)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (this.config?.onError) {
|
|
293
|
+
return this.config.onError(procedure, c, error as Error)
|
|
228
294
|
}
|
|
229
295
|
return c.json({ error: (error as Error).message }, 500)
|
|
230
296
|
}
|
|
@@ -442,6 +508,10 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
|
|
|
442
508
|
jsonSchema,
|
|
443
509
|
}
|
|
444
510
|
|
|
511
|
+
if (config.errors && config.errors.length > 0) {
|
|
512
|
+
base.errors = [...config.errors]
|
|
513
|
+
}
|
|
514
|
+
|
|
445
515
|
let extendedDoc: object = {}
|
|
446
516
|
|
|
447
517
|
if (extendProcedureDoc) {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
|
2
|
+
/**
|
|
3
|
+
* Cross-builder tests for the `onRequestError` observer hook.
|
|
4
|
+
*
|
|
5
|
+
* Invariants verified once per builder:
|
|
6
|
+
* 1. Fires exactly once for every caught error.
|
|
7
|
+
* 2. Runs BEFORE dispatch (taxonomy / onError / hard default).
|
|
8
|
+
* 3. Is awaited — async observer work completes before the response is sent.
|
|
9
|
+
* 4. Doesn't replace dispatch — response shape when observer is configured
|
|
10
|
+
* matches the response shape when it's not.
|
|
11
|
+
* 5. Thrown errors inside the observer are swallowed — the primary dispatch
|
|
12
|
+
* path still produces a response.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
15
|
+
import request from 'supertest'
|
|
16
|
+
import { Type } from 'typebox'
|
|
17
|
+
import { Procedures } from '../../index.js'
|
|
18
|
+
import { APIConfig, RPCConfig } from '../types.js'
|
|
19
|
+
import { HonoAPIAppBuilder } from './hono-api/index.js'
|
|
20
|
+
import { HonoRPCAppBuilder } from './hono-rpc/index.js'
|
|
21
|
+
import { ExpressRPCAppBuilder } from './express-rpc/index.js'
|
|
22
|
+
import { HonoStreamAppBuilder } from './hono-stream/index.js'
|
|
23
|
+
|
|
24
|
+
describe('onRequestError — HonoAPIAppBuilder', () => {
|
|
25
|
+
function boomApp(config: ConstructorParameters<typeof HonoAPIAppBuilder>[0]) {
|
|
26
|
+
const API = Procedures<{}, APIConfig>()
|
|
27
|
+
API.Create(
|
|
28
|
+
'Boom',
|
|
29
|
+
{ path: '/boom', method: 'get', schema: { returnType: Type.Object({}) } },
|
|
30
|
+
async () => {
|
|
31
|
+
throw new TypeError('boom')
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
return new HonoAPIAppBuilder(config).register(API, () => ({})).build()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('fires exactly once and before dispatch', async () => {
|
|
38
|
+
const order: string[] = []
|
|
39
|
+
const onRequestError = vi.fn(() => {
|
|
40
|
+
order.push('observer')
|
|
41
|
+
})
|
|
42
|
+
const onError = vi.fn((_p: any, c: any) => {
|
|
43
|
+
order.push('onError')
|
|
44
|
+
return c.json({ handled: true }, 418)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const app = boomApp({ onRequestError, onError })
|
|
48
|
+
await app.request('/boom')
|
|
49
|
+
expect(onRequestError).toHaveBeenCalledTimes(1)
|
|
50
|
+
expect(order).toEqual(['observer', 'onError'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('is awaited — async work completes before response is sent', async () => {
|
|
54
|
+
const log: string[] = []
|
|
55
|
+
const onRequestError = async () => {
|
|
56
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
57
|
+
log.push('observer-done')
|
|
58
|
+
}
|
|
59
|
+
const app = boomApp({
|
|
60
|
+
onRequestError,
|
|
61
|
+
onError: (_p, c) => {
|
|
62
|
+
log.push('onError-called')
|
|
63
|
+
return c.json({ ok: true }, 500)
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
await app.request('/boom')
|
|
67
|
+
expect(log).toEqual(['observer-done', 'onError-called'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('observer-thrown errors are swallowed — dispatch still produces a response', async () => {
|
|
71
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
72
|
+
const app = boomApp({
|
|
73
|
+
onRequestError: () => {
|
|
74
|
+
throw new Error('observer exploded')
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
const res = await app.request('/boom')
|
|
78
|
+
expect(res.status).toBe(500)
|
|
79
|
+
consoleSpy.mockRestore()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('omitting observer preserves existing behavior', async () => {
|
|
83
|
+
const app = boomApp({ onError: (_p, c) => c.json({ ok: true }, 418) })
|
|
84
|
+
const res = await app.request('/boom')
|
|
85
|
+
expect(res.status).toBe(418)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('onRequestError — HonoRPCAppBuilder', () => {
|
|
90
|
+
function boomApp(config: ConstructorParameters<typeof HonoRPCAppBuilder>[0]) {
|
|
91
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
92
|
+
RPC.Create(
|
|
93
|
+
'Boom',
|
|
94
|
+
{ scope: 'test', version: 1, schema: { params: Type.Object({}) } },
|
|
95
|
+
async () => {
|
|
96
|
+
throw new TypeError('boom')
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
return new HonoRPCAppBuilder(config).register(RPC, () => ({})).build()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test('fires once; runs before onError', async () => {
|
|
103
|
+
const order: string[] = []
|
|
104
|
+
const onRequestError = vi.fn(() => {
|
|
105
|
+
order.push('observer')
|
|
106
|
+
})
|
|
107
|
+
const onError = vi.fn((_p: any, c: any) => {
|
|
108
|
+
order.push('onError')
|
|
109
|
+
return c.json({}, 500)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const app = boomApp({ onRequestError, onError })
|
|
113
|
+
await app.request('/test/boom/1', { method: 'POST', body: '{}' })
|
|
114
|
+
expect(onRequestError).toHaveBeenCalledTimes(1)
|
|
115
|
+
expect(order).toEqual(['observer', 'onError'])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('observer throws are swallowed', async () => {
|
|
119
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
120
|
+
const app = boomApp({
|
|
121
|
+
onRequestError: () => {
|
|
122
|
+
throw new Error('boom in observer')
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
const res = await app.request('/test/boom/1', { method: 'POST', body: '{}' })
|
|
126
|
+
expect(res.status).toBe(500)
|
|
127
|
+
consoleSpy.mockRestore()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('onRequestError — ExpressRPCAppBuilder', () => {
|
|
132
|
+
function boomApp(config: ConstructorParameters<typeof ExpressRPCAppBuilder>[0]) {
|
|
133
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
134
|
+
RPC.Create(
|
|
135
|
+
'Boom',
|
|
136
|
+
{ scope: 'test', version: 1, schema: { params: Type.Object({}) } },
|
|
137
|
+
async () => {
|
|
138
|
+
throw new TypeError('boom')
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
return new ExpressRPCAppBuilder(config).register(RPC, () => ({})).build()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
test('fires once; raw is { req, res }', async () => {
|
|
145
|
+
let rawSeen: any
|
|
146
|
+
const app = boomApp({
|
|
147
|
+
onRequestError: (ctx) => {
|
|
148
|
+
rawSeen = ctx.raw
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
await request(app).post('/test/boom/1').send({})
|
|
152
|
+
expect(rawSeen.req).toBeDefined()
|
|
153
|
+
expect(rawSeen.res).toBeDefined()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('observer throws are swallowed', async () => {
|
|
157
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
158
|
+
const app = boomApp({
|
|
159
|
+
onRequestError: () => {
|
|
160
|
+
throw new Error('observer bug')
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
const res = await request(app).post('/test/boom/1').send({})
|
|
164
|
+
expect(res.status).toBe(500)
|
|
165
|
+
consoleSpy.mockRestore()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('onRequestError — HonoStreamAppBuilder (pre-stream)', () => {
|
|
170
|
+
function boomApp(config: ConstructorParameters<typeof HonoStreamAppBuilder>[0]) {
|
|
171
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
172
|
+
RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
|
|
173
|
+
yield { ok: true }
|
|
174
|
+
})
|
|
175
|
+
return new HonoStreamAppBuilder(config)
|
|
176
|
+
.register(RPC, () => {
|
|
177
|
+
throw new TypeError('context failed')
|
|
178
|
+
})
|
|
179
|
+
.build()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
test('fires once for pre-stream errors', async () => {
|
|
183
|
+
const onRequestError = vi.fn()
|
|
184
|
+
const app = boomApp({ onRequestError })
|
|
185
|
+
await app.request('/test/stream/1')
|
|
186
|
+
expect(onRequestError).toHaveBeenCalledTimes(1)
|
|
187
|
+
expect(onRequestError.mock.calls[0]![0].err).toBeInstanceOf(TypeError)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('observer throws are swallowed', async () => {
|
|
191
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
192
|
+
const app = boomApp({
|
|
193
|
+
onRequestError: () => {
|
|
194
|
+
throw new Error('observer fail')
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
const res = await app.request('/test/stream/1')
|
|
198
|
+
expect(res.status).toBe(500)
|
|
199
|
+
consoleSpy.mockRestore()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
|
2
|
+
import { describe, expect, test } from 'vitest'
|
|
3
|
+
import { Type } from 'typebox'
|
|
4
|
+
import { Procedures } from '../../index.js'
|
|
5
|
+
import { APIConfig, RPCConfig } from '../types.js'
|
|
6
|
+
import { HonoAPIAppBuilder } from './hono-api/index.js'
|
|
7
|
+
import { HonoRPCAppBuilder } from './hono-rpc/index.js'
|
|
8
|
+
import { ExpressRPCAppBuilder } from './express-rpc/index.js'
|
|
9
|
+
import { HonoStreamAppBuilder } from './hono-stream/index.js'
|
|
10
|
+
import { DocRegistry } from './doc-registry.js'
|
|
11
|
+
import { defineErrorTaxonomy } from './error-taxonomy.js'
|
|
12
|
+
|
|
13
|
+
class UseCaseError extends Error {
|
|
14
|
+
constructor(readonly externalMsg: string) {
|
|
15
|
+
super(externalMsg)
|
|
16
|
+
this.name = 'UseCaseError'
|
|
17
|
+
Object.setPrototypeOf(this, UseCaseError.prototype)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class AuthError extends Error {}
|
|
22
|
+
|
|
23
|
+
const appErrors = defineErrorTaxonomy({
|
|
24
|
+
UseCaseError: { class: UseCaseError, statusCode: 422 },
|
|
25
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('per-route errors declaration', () => {
|
|
29
|
+
test('hono-api copies config.errors onto the route doc', () => {
|
|
30
|
+
const API = Procedures<{}, APIConfig<keyof typeof appErrors & string>>()
|
|
31
|
+
API.Create(
|
|
32
|
+
'GetUser',
|
|
33
|
+
{
|
|
34
|
+
path: '/users/:id',
|
|
35
|
+
method: 'get',
|
|
36
|
+
errors: ['UseCaseError', 'AuthError'],
|
|
37
|
+
schema: {
|
|
38
|
+
input: { pathParams: Type.Object({ id: Type.String() }) },
|
|
39
|
+
returnType: Type.Object({}),
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async () => ({})
|
|
43
|
+
)
|
|
44
|
+
const builder = new HonoAPIAppBuilder().register(API, () => ({}))
|
|
45
|
+
builder.build()
|
|
46
|
+
expect(builder.docs[0]!.errors).toEqual(['UseCaseError', 'AuthError'])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('hono-api route doc omits errors when not declared', () => {
|
|
50
|
+
const API = Procedures<{}, APIConfig>()
|
|
51
|
+
API.Create(
|
|
52
|
+
'Health',
|
|
53
|
+
{ path: '/health', method: 'get', schema: { returnType: Type.Object({}) } },
|
|
54
|
+
async () => ({})
|
|
55
|
+
)
|
|
56
|
+
const builder = new HonoAPIAppBuilder().register(API, () => ({}))
|
|
57
|
+
builder.build()
|
|
58
|
+
expect(builder.docs[0]!.errors).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('hono-rpc copies config.errors onto the route doc', () => {
|
|
62
|
+
const RPC = Procedures<{}, RPCConfig<keyof typeof appErrors & string>>()
|
|
63
|
+
RPC.Create(
|
|
64
|
+
'DoThing',
|
|
65
|
+
{
|
|
66
|
+
scope: 'things',
|
|
67
|
+
version: 1,
|
|
68
|
+
errors: ['UseCaseError'],
|
|
69
|
+
schema: { params: Type.Object({}) },
|
|
70
|
+
},
|
|
71
|
+
async () => ({})
|
|
72
|
+
)
|
|
73
|
+
const builder = new HonoRPCAppBuilder().register(RPC, () => ({}))
|
|
74
|
+
builder.build()
|
|
75
|
+
expect(builder.docs[0]!.errors).toEqual(['UseCaseError'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('express-rpc copies config.errors onto the route doc', () => {
|
|
79
|
+
const RPC = Procedures<{}, RPCConfig<keyof typeof appErrors & string>>()
|
|
80
|
+
RPC.Create(
|
|
81
|
+
'DoThing',
|
|
82
|
+
{
|
|
83
|
+
scope: 'things',
|
|
84
|
+
version: 1,
|
|
85
|
+
errors: ['AuthError'],
|
|
86
|
+
schema: { params: Type.Object({}) },
|
|
87
|
+
},
|
|
88
|
+
async () => ({})
|
|
89
|
+
)
|
|
90
|
+
const builder = new ExpressRPCAppBuilder().register(RPC, () => ({}))
|
|
91
|
+
builder.build()
|
|
92
|
+
expect(builder.docs[0]!.errors).toEqual(['AuthError'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('hono-stream copies config.errors onto the route doc', () => {
|
|
96
|
+
const Stream = Procedures<{}, RPCConfig<keyof typeof appErrors & string>>()
|
|
97
|
+
Stream.CreateStream(
|
|
98
|
+
'Watch',
|
|
99
|
+
{ scope: 'watch', version: 1, errors: ['AuthError'] },
|
|
100
|
+
async function* () {
|
|
101
|
+
yield { ok: true }
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
const builder = new HonoStreamAppBuilder().register(Stream, () => ({}))
|
|
105
|
+
builder.build()
|
|
106
|
+
expect(builder.docs[0]!.errors).toEqual(['AuthError'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('compile-time: errors typed as keyof taxonomy narrows valid keys', () => {
|
|
110
|
+
// TS-only: this test compiles only if the generic narrows correctly.
|
|
111
|
+
type Narrow = APIConfig<keyof typeof appErrors & string>['errors']
|
|
112
|
+
const _valid: Narrow = ['UseCaseError', 'AuthError']
|
|
113
|
+
// @ts-expect-error - 'NotRegistered' is not in the taxonomy
|
|
114
|
+
const _invalid: Narrow = ['NotRegistered']
|
|
115
|
+
expect(_valid).toBeDefined()
|
|
116
|
+
expect(_invalid).toBeDefined()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('DocRegistry with taxonomy input', () => {
|
|
121
|
+
test('seeds envelope errors from the taxonomy + framework defaults', () => {
|
|
122
|
+
const registry = new DocRegistry({ errors: appErrors, basePath: '/api' })
|
|
123
|
+
const envelope = registry.toJSON()
|
|
124
|
+
const names = envelope.errors.map((e) => e.name)
|
|
125
|
+
expect(names).toContain('UseCaseError')
|
|
126
|
+
expect(names).toContain('AuthError')
|
|
127
|
+
expect(names).toContain('ProcedureValidationError')
|
|
128
|
+
expect(names).toContain('ProcedureRegistrationError')
|
|
129
|
+
expect(envelope.basePath).toBe('/api')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('includeDefaults: false omits framework entries', () => {
|
|
133
|
+
const registry = new DocRegistry({ errors: appErrors, includeDefaults: false })
|
|
134
|
+
const names = registry.toJSON().errors.map((e) => e.name)
|
|
135
|
+
expect(names).toEqual(['UseCaseError', 'AuthError'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('user entry with same key as default takes precedence (deduped)', () => {
|
|
139
|
+
const overridden = defineErrorTaxonomy({
|
|
140
|
+
ProcedureError: {
|
|
141
|
+
class: Error,
|
|
142
|
+
statusCode: 418,
|
|
143
|
+
description: 'custom override',
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
const envelope = new DocRegistry({ errors: overridden }).toJSON()
|
|
147
|
+
const proc = envelope.errors.find((e) => e.name === 'ProcedureError')
|
|
148
|
+
expect(proc?.statusCode).toBe(418)
|
|
149
|
+
expect(proc?.description).toBe('custom override')
|
|
150
|
+
const count = envelope.errors.filter((e) => e.name === 'ProcedureError').length
|
|
151
|
+
expect(count).toBe(1)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('registered route errors survive DocRegistry composition', () => {
|
|
155
|
+
const API = Procedures<{}, APIConfig<keyof typeof appErrors & string>>()
|
|
156
|
+
API.Create(
|
|
157
|
+
'GetUser',
|
|
158
|
+
{
|
|
159
|
+
path: '/users/:id',
|
|
160
|
+
method: 'get',
|
|
161
|
+
errors: ['UseCaseError'],
|
|
162
|
+
schema: {
|
|
163
|
+
input: { pathParams: Type.Object({ id: Type.String() }) },
|
|
164
|
+
returnType: Type.Object({}),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
async () => ({})
|
|
168
|
+
)
|
|
169
|
+
const app = new HonoAPIAppBuilder().register(API, () => ({}))
|
|
170
|
+
app.build()
|
|
171
|
+
|
|
172
|
+
const envelope = new DocRegistry({ errors: appErrors }).from(app).toJSON()
|
|
173
|
+
const route = envelope.routes.find((r) => r.kind === 'api' && r.name === 'GetUser')
|
|
174
|
+
expect(route?.errors).toEqual(['UseCaseError'])
|
|
175
|
+
})
|
|
176
|
+
})
|