ts-procedures 5.16.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +85 -17
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +163 -5
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +169 -13
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +22 -15
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
- package/agent_config/copilot/copilot-instructions.md +77 -12
- package/agent_config/cursor/cursorrules +77 -12
- package/build/client/call.d.ts +2 -1
- package/build/client/call.js +9 -1
- package/build/client/call.js.map +1 -1
- package/build/client/error-dispatch.d.ts +13 -0
- package/build/client/error-dispatch.js +26 -0
- package/build/client/error-dispatch.js.map +1 -0
- package/build/client/error-dispatch.test.d.ts +1 -0
- package/build/client/error-dispatch.test.js +56 -0
- package/build/client/error-dispatch.test.js.map +1 -0
- package/build/client/fetch-adapter.js +10 -4
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/index.d.ts +2 -1
- package/build/client/index.js +5 -1
- package/build/client/index.js.map +1 -1
- package/build/client/stream.d.ts +2 -1
- package/build/client/stream.js +13 -3
- package/build/client/stream.js.map +1 -1
- package/build/client/typed-error-dispatch.test.d.ts +1 -0
- package/build/client/typed-error-dispatch.test.js +168 -0
- package/build/client/typed-error-dispatch.test.js.map +1 -0
- package/build/client/types.d.ts +37 -0
- package/build/codegen/e2e.test.js +9 -4
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +4 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +17 -6
- package/build/codegen/emit-errors.integration.test.d.ts +1 -0
- package/build/codegen/emit-errors.integration.test.js +162 -0
- package/build/codegen/emit-errors.integration.test.js.map +1 -0
- package/build/codegen/emit-errors.js +50 -39
- package/build/codegen/emit-errors.js.map +1 -1
- package/build/codegen/emit-errors.test.js +75 -78
- package/build/codegen/emit-errors.test.js.map +1 -1
- package/build/codegen/emit-index.d.ts +7 -0
- package/build/codegen/emit-index.js +26 -4
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-index.test.js +55 -23
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.d.ts +8 -0
- package/build/codegen/emit-scope.js +82 -7
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/pipeline.js +22 -2
- package/build/codegen/pipeline.js.map +1 -1
- package/build/implementations/http/doc-registry.d.ts +21 -0
- package/build/implementations/http/doc-registry.js +51 -78
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +8 -6
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/error-taxonomy.d.ts +240 -0
- package/build/implementations/http/error-taxonomy.js +230 -0
- package/build/implementations/http/error-taxonomy.js.map +1 -0
- package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/error-taxonomy.test.js +399 -0
- package/build/implementations/http/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/index.d.ts +39 -8
- package/build/implementations/http/express-rpc/index.js +39 -8
- package/build/implementations/http/express-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-api/index.d.ts +38 -1
- package/build/implementations/http/hono-api/index.js +32 -0
- package/build/implementations/http/hono-api/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-rpc/index.d.ts +34 -7
- package/build/implementations/http/hono-rpc/index.js +31 -4
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-stream/index.d.ts +40 -3
- package/build/implementations/http/hono-stream/index.js +37 -10
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +45 -18
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/http/on-request-error.test.d.ts +1 -0
- package/build/implementations/http/on-request-error.test.js +173 -0
- package/build/implementations/http/on-request-error.test.js.map +1 -0
- package/build/implementations/http/route-errors.test.d.ts +1 -0
- package/build/implementations/http/route-errors.test.js +140 -0
- package/build/implementations/http/route-errors.test.js.map +1 -0
- package/build/implementations/types.d.ts +30 -2
- package/docs/client-and-codegen.md +105 -12
- package/docs/core.md +14 -5
- package/docs/http-integrations.md +135 -4
- package/docs/streaming.md +3 -1
- package/package.json +7 -2
- package/src/client/call.ts +10 -1
- package/src/client/error-dispatch.test.ts +72 -0
- package/src/client/error-dispatch.ts +27 -0
- package/src/client/fetch-adapter.ts +11 -5
- package/src/client/index.ts +9 -0
- package/src/client/stream.ts +14 -3
- package/src/client/typed-error-dispatch.test.ts +211 -0
- package/src/client/types.ts +42 -0
- package/src/codegen/e2e.test.ts +9 -4
- package/src/codegen/emit-client-runtime.ts +4 -0
- package/src/codegen/emit-errors.integration.test.ts +183 -0
- package/src/codegen/emit-errors.test.ts +91 -87
- package/src/codegen/emit-errors.ts +123 -41
- package/src/codegen/emit-index.test.ts +68 -24
- package/src/codegen/emit-index.ts +66 -4
- package/src/codegen/emit-scope.ts +124 -7
- package/src/codegen/pipeline.ts +25 -2
- package/src/implementations/http/README.md +19 -4
- package/src/implementations/http/doc-registry.test.ts +10 -6
- package/src/implementations/http/doc-registry.ts +63 -80
- package/src/implementations/http/error-taxonomy.test.ts +438 -0
- package/src/implementations/http/error-taxonomy.ts +337 -0
- package/src/implementations/http/express-rpc/README.md +21 -22
- package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
- package/src/implementations/http/express-rpc/index.ts +75 -14
- package/src/implementations/http/hono-api/README.md +284 -0
- package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
- package/src/implementations/http/hono-api/index.ts +76 -1
- package/src/implementations/http/hono-rpc/README.md +18 -19
- package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
- package/src/implementations/http/hono-rpc/index.ts +65 -9
- package/src/implementations/http/hono-stream/README.md +44 -25
- package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
- package/src/implementations/http/hono-stream/index.test.ts +54 -18
- package/src/implementations/http/hono-stream/index.ts +83 -13
- package/src/implementations/http/on-request-error.test.ts +201 -0
- package/src/implementations/http/route-errors.test.ts +177 -0
- package/src/implementations/types.ts +30 -2
|
@@ -0,0 +1,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
|
+
})
|
|
@@ -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)
|