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
|
@@ -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,177 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
|
2
|
+
import { describe, expect, test } from 'vitest'
|
|
3
|
+
import { Type } from 'typebox'
|
|
4
|
+
import { Procedures } from '../../index.js'
|
|
5
|
+
import { APIConfig, RPCConfig } from '../types.js'
|
|
6
|
+
import { HonoAPIAppBuilder } from './hono-api/index.js'
|
|
7
|
+
import { HonoRPCAppBuilder } from './hono-rpc/index.js'
|
|
8
|
+
import { ExpressRPCAppBuilder } from './express-rpc/index.js'
|
|
9
|
+
import { HonoStreamAppBuilder } from './hono-stream/index.js'
|
|
10
|
+
import { DocRegistry } from './doc-registry.js'
|
|
11
|
+
import { defineErrorTaxonomy } from './error-taxonomy.js'
|
|
12
|
+
|
|
13
|
+
class UseCaseError extends Error {
|
|
14
|
+
constructor(readonly externalMsg: string) {
|
|
15
|
+
super(externalMsg)
|
|
16
|
+
this.name = 'UseCaseError'
|
|
17
|
+
Object.setPrototypeOf(this, UseCaseError.prototype)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class AuthError extends Error {}
|
|
22
|
+
|
|
23
|
+
const appErrors = defineErrorTaxonomy({
|
|
24
|
+
UseCaseError: { class: UseCaseError, statusCode: 422 },
|
|
25
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('per-route errors declaration', () => {
|
|
29
|
+
test('hono-api copies config.errors onto the route doc', () => {
|
|
30
|
+
const API = Procedures<{}, APIConfig<keyof typeof appErrors & string>>()
|
|
31
|
+
API.Create(
|
|
32
|
+
'GetUser',
|
|
33
|
+
{
|
|
34
|
+
path: '/users/:id',
|
|
35
|
+
method: 'get',
|
|
36
|
+
errors: ['UseCaseError', 'AuthError'],
|
|
37
|
+
schema: {
|
|
38
|
+
input: { pathParams: Type.Object({ id: Type.String() }) },
|
|
39
|
+
returnType: Type.Object({}),
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async () => ({})
|
|
43
|
+
)
|
|
44
|
+
const builder = new HonoAPIAppBuilder().register(API, () => ({}))
|
|
45
|
+
builder.build()
|
|
46
|
+
expect(builder.docs[0]!.errors).toEqual(['UseCaseError', 'AuthError'])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('hono-api route doc omits errors when not declared', () => {
|
|
50
|
+
const API = Procedures<{}, APIConfig>()
|
|
51
|
+
API.Create(
|
|
52
|
+
'Health',
|
|
53
|
+
{ path: '/health', method: 'get', schema: { returnType: Type.Object({}) } },
|
|
54
|
+
async () => ({})
|
|
55
|
+
)
|
|
56
|
+
const builder = new HonoAPIAppBuilder().register(API, () => ({}))
|
|
57
|
+
builder.build()
|
|
58
|
+
expect(builder.docs[0]!.errors).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('hono-rpc copies config.errors onto the route doc', () => {
|
|
62
|
+
const RPC = Procedures<{}, RPCConfig<keyof typeof appErrors & string>>()
|
|
63
|
+
RPC.Create(
|
|
64
|
+
'DoThing',
|
|
65
|
+
{
|
|
66
|
+
scope: 'things',
|
|
67
|
+
version: 1,
|
|
68
|
+
errors: ['UseCaseError'],
|
|
69
|
+
schema: { params: Type.Object({}) },
|
|
70
|
+
},
|
|
71
|
+
async () => ({})
|
|
72
|
+
)
|
|
73
|
+
const builder = new HonoRPCAppBuilder().register(RPC, () => ({}))
|
|
74
|
+
builder.build()
|
|
75
|
+
expect(builder.docs[0]!.errors).toEqual(['UseCaseError'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('express-rpc copies config.errors onto the route doc', () => {
|
|
79
|
+
const RPC = Procedures<{}, RPCConfig<keyof typeof appErrors & string>>()
|
|
80
|
+
RPC.Create(
|
|
81
|
+
'DoThing',
|
|
82
|
+
{
|
|
83
|
+
scope: 'things',
|
|
84
|
+
version: 1,
|
|
85
|
+
errors: ['AuthError'],
|
|
86
|
+
schema: { params: Type.Object({}) },
|
|
87
|
+
},
|
|
88
|
+
async () => ({})
|
|
89
|
+
)
|
|
90
|
+
const builder = new ExpressRPCAppBuilder().register(RPC, () => ({}))
|
|
91
|
+
builder.build()
|
|
92
|
+
expect(builder.docs[0]!.errors).toEqual(['AuthError'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('hono-stream copies config.errors onto the route doc', () => {
|
|
96
|
+
const Stream = Procedures<{}, RPCConfig<keyof typeof appErrors & string>>()
|
|
97
|
+
Stream.CreateStream(
|
|
98
|
+
'Watch',
|
|
99
|
+
{ scope: 'watch', version: 1, errors: ['AuthError'] },
|
|
100
|
+
async function* () {
|
|
101
|
+
yield { ok: true }
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
const builder = new HonoStreamAppBuilder().register(Stream, () => ({}))
|
|
105
|
+
builder.build()
|
|
106
|
+
expect(builder.docs[0]!.errors).toEqual(['AuthError'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('compile-time: errors typed as keyof taxonomy narrows valid keys', () => {
|
|
110
|
+
// TS-only: this test compiles only if the generic narrows correctly.
|
|
111
|
+
type Narrow = APIConfig<keyof typeof appErrors & string>['errors']
|
|
112
|
+
const _valid: Narrow = ['UseCaseError', 'AuthError']
|
|
113
|
+
// @ts-expect-error - 'NotRegistered' is not in the taxonomy
|
|
114
|
+
const _invalid: Narrow = ['NotRegistered']
|
|
115
|
+
expect(_valid).toBeDefined()
|
|
116
|
+
expect(_invalid).toBeDefined()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('DocRegistry.fromTaxonomy', () => {
|
|
121
|
+
test('seeds envelope errors from the taxonomy + framework defaults', () => {
|
|
122
|
+
const registry = DocRegistry.fromTaxonomy(appErrors, { basePath: '/api' })
|
|
123
|
+
const envelope = registry.toJSON()
|
|
124
|
+
const names = envelope.errors.map((e) => e.name)
|
|
125
|
+
expect(names).toContain('UseCaseError')
|
|
126
|
+
expect(names).toContain('AuthError')
|
|
127
|
+
expect(names).toContain('ProcedureValidationError')
|
|
128
|
+
expect(names).toContain('ProcedureRegistrationError')
|
|
129
|
+
expect(envelope.basePath).toBe('/api')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('includeDefaults: false omits framework entries', () => {
|
|
133
|
+
const registry = DocRegistry.fromTaxonomy(appErrors, { includeDefaults: false })
|
|
134
|
+
const names = registry.toJSON().errors.map((e) => e.name)
|
|
135
|
+
expect(names).toEqual(['UseCaseError', 'AuthError'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('user entry with same key as default takes precedence (deduped)', () => {
|
|
139
|
+
const overridden = defineErrorTaxonomy({
|
|
140
|
+
ProcedureError: {
|
|
141
|
+
class: Error,
|
|
142
|
+
statusCode: 418,
|
|
143
|
+
description: 'custom override',
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
const envelope = DocRegistry.fromTaxonomy(overridden).toJSON()
|
|
147
|
+
const proc = envelope.errors.find((e) => e.name === 'ProcedureError')
|
|
148
|
+
expect(proc?.statusCode).toBe(418)
|
|
149
|
+
expect(proc?.description).toBe('custom override')
|
|
150
|
+
// no duplicates
|
|
151
|
+
const count = envelope.errors.filter((e) => e.name === 'ProcedureError').length
|
|
152
|
+
expect(count).toBe(1)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('registered route errors survive DocRegistry composition', () => {
|
|
156
|
+
const API = Procedures<{}, APIConfig<keyof typeof appErrors & string>>()
|
|
157
|
+
API.Create(
|
|
158
|
+
'GetUser',
|
|
159
|
+
{
|
|
160
|
+
path: '/users/:id',
|
|
161
|
+
method: 'get',
|
|
162
|
+
errors: ['UseCaseError'],
|
|
163
|
+
schema: {
|
|
164
|
+
input: { pathParams: Type.Object({ id: Type.String() }) },
|
|
165
|
+
returnType: Type.Object({}),
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
async () => ({})
|
|
169
|
+
)
|
|
170
|
+
const app = new HonoAPIAppBuilder().register(API, () => ({}))
|
|
171
|
+
app.build()
|
|
172
|
+
|
|
173
|
+
const envelope = DocRegistry.fromTaxonomy(appErrors).from(app).toJSON()
|
|
174
|
+
const route = envelope.routes.find((r) => r.kind === 'api' && r.name === 'GetUser')
|
|
175
|
+
expect(route?.errors).toEqual(['UseCaseError'])
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { Procedures } from '../index.js'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* @typeParam TErrorKey - Union of valid taxonomy keys. Defaults to `string`
|
|
5
|
+
* (unconstrained). Narrow it by passing `keyof typeof yourTaxonomy & string`
|
|
6
|
+
* to get compile-time typo protection on `errors`.
|
|
7
|
+
*/
|
|
8
|
+
export interface RPCConfig<TErrorKey extends string = string> {
|
|
4
9
|
// Scope or scopes (scope segments) required to access the RPC
|
|
5
10
|
scope: string | string[]
|
|
6
11
|
version: number
|
|
12
|
+
/**
|
|
13
|
+
* Taxonomy keys for errors this procedure may emit. Purely informational at
|
|
14
|
+
* runtime (nothing is rejected), but populates the DocEnvelope per-route
|
|
15
|
+
* error list so generated clients can narrow their catch types.
|
|
16
|
+
*/
|
|
17
|
+
errors?: TErrorKey[]
|
|
7
18
|
}
|
|
8
19
|
|
|
9
20
|
export type FactoryItem<C> = {
|
|
@@ -20,6 +31,8 @@ export interface RPCHttpRouteDoc extends RPCConfig {
|
|
|
20
31
|
body?: Record<string, unknown>
|
|
21
32
|
response?: Record<string, unknown>
|
|
22
33
|
}
|
|
34
|
+
/** Taxonomy keys for errors this route may emit. */
|
|
35
|
+
errors?: string[]
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
export type StreamMode = 'sse' | 'text'
|
|
@@ -30,7 +43,12 @@ export type StreamMode = 'sse' | 'text'
|
|
|
30
43
|
|
|
31
44
|
export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'
|
|
32
45
|
|
|
33
|
-
|
|
46
|
+
/**
|
|
47
|
+
* @typeParam TErrorKey - Union of valid taxonomy keys. Defaults to `string`.
|
|
48
|
+
* Narrow it by passing `keyof typeof yourTaxonomy & string` for compile-time
|
|
49
|
+
* typo protection on `errors`.
|
|
50
|
+
*/
|
|
51
|
+
export interface APIConfig<TErrorKey extends string = string> {
|
|
34
52
|
/** HTTP route path (supports Hono path params, e.g., '/users/:id') */
|
|
35
53
|
path: string
|
|
36
54
|
/** HTTP method for this endpoint */
|
|
@@ -39,6 +57,12 @@ export interface APIConfig {
|
|
|
39
57
|
successStatus?: number
|
|
40
58
|
/** Optional scope for grouping API routes in generated client files */
|
|
41
59
|
scope?: string
|
|
60
|
+
/**
|
|
61
|
+
* Taxonomy keys for errors this procedure may emit. Purely informational at
|
|
62
|
+
* runtime (nothing is rejected), but populates the DocEnvelope per-route
|
|
63
|
+
* error list so generated clients can narrow their catch types.
|
|
64
|
+
*/
|
|
65
|
+
errors?: TErrorKey[]
|
|
42
66
|
}
|
|
43
67
|
|
|
44
68
|
export interface APIHttpRouteDoc extends APIConfig {
|
|
@@ -53,6 +77,8 @@ export interface APIHttpRouteDoc extends APIConfig {
|
|
|
53
77
|
headers?: Record<string, unknown>
|
|
54
78
|
response?: Record<string, unknown>
|
|
55
79
|
}
|
|
80
|
+
/** Taxonomy keys for errors this route may emit. */
|
|
81
|
+
errors?: string[]
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
/**
|
|
@@ -90,6 +116,8 @@ export interface StreamHttpRouteDoc extends RPCConfig {
|
|
|
90
116
|
yieldType?: Record<string, unknown> // Schema for each streamed value
|
|
91
117
|
returnType?: Record<string, unknown> // Final return (optional)
|
|
92
118
|
}
|
|
119
|
+
/** Taxonomy keys for errors this route may emit (pre-stream only). */
|
|
120
|
+
errors?: string[]
|
|
93
121
|
}
|
|
94
122
|
|
|
95
123
|
// ================
|