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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-procedures",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.",
|
|
5
5
|
"main": "build/exports.js",
|
|
6
6
|
"types": "build/exports.d.ts",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc",
|
|
14
14
|
"lint": "npx eslint src/ --quiet",
|
|
15
|
-
"
|
|
15
|
+
"check-docs": "bash scripts/check-docs-consistency.sh",
|
|
16
|
+
"prepublishOnly": "npm run lint && npm run build && npm run check-docs",
|
|
16
17
|
"postinstall": "node ./agent_config/bin/postinstall.mjs",
|
|
17
18
|
"test": "vitest run"
|
|
18
19
|
},
|
|
@@ -45,6 +46,10 @@
|
|
|
45
46
|
"types": "./build/implementations/http/doc-registry.d.ts",
|
|
46
47
|
"import": "./build/implementations/http/doc-registry.js"
|
|
47
48
|
},
|
|
49
|
+
"./http-errors": {
|
|
50
|
+
"types": "./build/implementations/http/error-taxonomy.d.ts",
|
|
51
|
+
"import": "./build/implementations/http/error-taxonomy.js"
|
|
52
|
+
},
|
|
48
53
|
"./client": {
|
|
49
54
|
"types": "./build/client/index.d.ts",
|
|
50
55
|
"import": "./build/client/index.js"
|
package/src/client/call.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { buildAdapterRequest } from './request-builder.js'
|
|
|
2
2
|
import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
|
|
3
3
|
import { applyRequestOptions, resolveBasePath } from './resolve-options.js'
|
|
4
4
|
import { ClientRequestError } from './errors.js'
|
|
5
|
+
import { dispatchTypedError } from './error-dispatch.js'
|
|
5
6
|
import type {
|
|
6
7
|
ClientAdapter,
|
|
7
8
|
ClientHooks,
|
|
8
9
|
CallDescriptor,
|
|
10
|
+
ErrorRegistry,
|
|
9
11
|
ProcedureCallDefaults,
|
|
10
12
|
ProcedureCallOptions,
|
|
11
13
|
} from './types.js'
|
|
@@ -17,6 +19,7 @@ export interface ExecuteCallConfig {
|
|
|
17
19
|
hooks: ClientHooks
|
|
18
20
|
defaults?: ProcedureCallDefaults
|
|
19
21
|
options?: ProcedureCallOptions
|
|
22
|
+
errorRegistry?: ErrorRegistry
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/**
|
|
@@ -33,7 +36,7 @@ export interface ExecuteCallConfig {
|
|
|
33
36
|
* 8. Return response.body as TResponse
|
|
34
37
|
*/
|
|
35
38
|
export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise<TResponse> {
|
|
36
|
-
const { descriptor, basePath, adapter, hooks, defaults, options } = config
|
|
39
|
+
const { descriptor, basePath, adapter, hooks, defaults, options, errorRegistry } = config
|
|
37
40
|
|
|
38
41
|
// 1. Build the initial request (path/query/body from descriptor)
|
|
39
42
|
const resolvedBasePath = resolveBasePath(defaults, options, basePath)
|
|
@@ -73,6 +76,12 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
|
|
|
73
76
|
|
|
74
77
|
// 7. Check status AFTER hooks (hooks may have swallowed the error by mutating status)
|
|
75
78
|
if (response.status < 200 || response.status >= 300) {
|
|
79
|
+
const typed = dispatchTypedError(errorRegistry, response.body, {
|
|
80
|
+
status: response.status,
|
|
81
|
+
procedureName: descriptor.name,
|
|
82
|
+
scope: descriptor.scope,
|
|
83
|
+
})
|
|
84
|
+
if (typed) throw typed
|
|
76
85
|
throw new ClientRequestError({
|
|
77
86
|
status: response.status,
|
|
78
87
|
headers: response.headers,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { dispatchTypedError } from './error-dispatch.js'
|
|
3
|
+
import type { ErrorRegistry, ErrorResponseMeta } from './types.js'
|
|
4
|
+
|
|
5
|
+
class SyntheticError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
message: string,
|
|
8
|
+
readonly props: string[]
|
|
9
|
+
) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = 'SyntheticError'
|
|
12
|
+
Object.setPrototypeOf(this, SyntheticError.prototype)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const meta: ErrorResponseMeta = { status: 422, procedureName: 'Test', scope: 'users' }
|
|
17
|
+
|
|
18
|
+
function makeRegistry(): ErrorRegistry {
|
|
19
|
+
return {
|
|
20
|
+
SyntheticError: {
|
|
21
|
+
fromResponse(body, m) {
|
|
22
|
+
const b = body as { message: string; props: string[] }
|
|
23
|
+
return new SyntheticError(b.message, b.props)
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('dispatchTypedError', () => {
|
|
30
|
+
test('returns a typed error when body.name matches a registry key', () => {
|
|
31
|
+
const err = dispatchTypedError(
|
|
32
|
+
makeRegistry(),
|
|
33
|
+
{ name: 'SyntheticError', message: 'bad', props: ['x'] },
|
|
34
|
+
meta
|
|
35
|
+
)
|
|
36
|
+
expect(err).toBeInstanceOf(SyntheticError)
|
|
37
|
+
expect(err).toBeInstanceOf(Error)
|
|
38
|
+
expect((err as SyntheticError).props).toEqual(['x'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('returns null when no registry is provided', () => {
|
|
42
|
+
const err = dispatchTypedError(undefined, { name: 'SyntheticError' }, meta)
|
|
43
|
+
expect(err).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('returns null when body is not an object', () => {
|
|
47
|
+
expect(dispatchTypedError(makeRegistry(), 'oops', meta)).toBeNull()
|
|
48
|
+
expect(dispatchTypedError(makeRegistry(), null, meta)).toBeNull()
|
|
49
|
+
expect(dispatchTypedError(makeRegistry(), 42, meta)).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('returns null when body.name is missing or not a string', () => {
|
|
53
|
+
expect(dispatchTypedError(makeRegistry(), {}, meta)).toBeNull()
|
|
54
|
+
expect(dispatchTypedError(makeRegistry(), { name: 123 }, meta)).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('returns null when body.name does not match any registry key', () => {
|
|
58
|
+
expect(
|
|
59
|
+
dispatchTypedError(makeRegistry(), { name: 'UnregisteredError' }, meta)
|
|
60
|
+
).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('returns null when fromResponse returns a non-Error value', () => {
|
|
64
|
+
const registry: ErrorRegistry = {
|
|
65
|
+
Broken: {
|
|
66
|
+
// Intentionally returns a non-Error — defensive guard in dispatch.
|
|
67
|
+
fromResponse: () => 'not an error' as unknown as Error,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
expect(dispatchTypedError(registry, { name: 'Broken' }, meta)).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ErrorRegistry, ErrorResponseMeta } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Attempts to construct a typed error from the response body using the
|
|
5
|
+
* registry. Returns `null` when:
|
|
6
|
+
* - no registry is configured,
|
|
7
|
+
* - the body is not a plain object with a `name` string,
|
|
8
|
+
* - no registry key matches the body's `name`, or
|
|
9
|
+
* - `fromResponse` returns a non-Error value (defensive — registry entries
|
|
10
|
+
* are expected to return `Error` subclasses).
|
|
11
|
+
*
|
|
12
|
+
* Callers fall back to `ClientRequestError` when this returns `null`.
|
|
13
|
+
*/
|
|
14
|
+
export function dispatchTypedError(
|
|
15
|
+
registry: ErrorRegistry | undefined,
|
|
16
|
+
body: unknown,
|
|
17
|
+
meta: ErrorResponseMeta
|
|
18
|
+
): Error | null {
|
|
19
|
+
if (!registry) return null
|
|
20
|
+
if (!body || typeof body !== 'object') return null
|
|
21
|
+
const name = (body as { name?: unknown }).name
|
|
22
|
+
if (typeof name !== 'string') return null
|
|
23
|
+
const factory = registry[name]
|
|
24
|
+
if (!factory?.fromResponse) return null
|
|
25
|
+
const result = factory.fromResponse(body, meta)
|
|
26
|
+
return result instanceof Error ? result : null
|
|
27
|
+
}
|
|
@@ -174,17 +174,23 @@ export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
|
|
|
174
174
|
})
|
|
175
175
|
|
|
176
176
|
const headers = extractHeaders(response)
|
|
177
|
+
const emptyBody: AsyncIterable<unknown> = {
|
|
178
|
+
[Symbol.asyncIterator]: async function* () {},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Non-2xx responses on a stream endpoint are JSON, not SSE. Parse the
|
|
182
|
+
// body eagerly and surface it via errorBody so the client can dispatch
|
|
183
|
+
// a typed error (or fall back to ClientRequestError with a real body).
|
|
184
|
+
if (response.status < 200 || response.status >= 300) {
|
|
185
|
+
const errorBody = await parseResponseBody(response)
|
|
186
|
+
return { status: response.status, headers, body: emptyBody, errorBody }
|
|
187
|
+
}
|
|
177
188
|
|
|
178
189
|
if (!response.body) {
|
|
179
|
-
// No body — return an empty async iterable
|
|
180
|
-
const emptyBody: AsyncIterable<unknown> = {
|
|
181
|
-
[Symbol.asyncIterator]: async function* () {},
|
|
182
|
-
}
|
|
183
190
|
return { status: response.status, headers, body: emptyBody }
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
const body = parseSseStream(response.body as ReadableStream<Uint8Array>)
|
|
187
|
-
|
|
188
194
|
return { status: response.status, headers, body }
|
|
189
195
|
},
|
|
190
196
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
|
|
|
30
30
|
basePath,
|
|
31
31
|
hooks: globalHooks = {},
|
|
32
32
|
defaults: globalDefaults = {},
|
|
33
|
+
errorRegistry,
|
|
33
34
|
scopes,
|
|
34
35
|
} = config
|
|
35
36
|
|
|
@@ -38,6 +39,7 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
|
|
|
38
39
|
adapter,
|
|
39
40
|
hooks: globalHooks,
|
|
40
41
|
defaults: globalDefaults,
|
|
42
|
+
errorRegistry,
|
|
41
43
|
|
|
42
44
|
call<TResponse>(
|
|
43
45
|
descriptor: CallDescriptor,
|
|
@@ -50,6 +52,7 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
|
|
|
50
52
|
hooks: globalHooks,
|
|
51
53
|
defaults: globalDefaults,
|
|
52
54
|
options,
|
|
55
|
+
errorRegistry,
|
|
53
56
|
})
|
|
54
57
|
},
|
|
55
58
|
|
|
@@ -79,6 +82,7 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
|
|
|
79
82
|
hooks: globalHooks,
|
|
80
83
|
defaults: globalDefaults,
|
|
81
84
|
options,
|
|
85
|
+
errorRegistry,
|
|
82
86
|
})
|
|
83
87
|
} catch (err) {
|
|
84
88
|
rejectResult(err)
|
|
@@ -126,8 +130,13 @@ export type {
|
|
|
126
130
|
ProcedureCallOptions,
|
|
127
131
|
CreateClientConfig,
|
|
128
132
|
RequestMeta,
|
|
133
|
+
ErrorRegistry,
|
|
134
|
+
ErrorFactory,
|
|
135
|
+
ErrorResponseMeta,
|
|
129
136
|
} from './types.js'
|
|
130
137
|
|
|
138
|
+
export { dispatchTypedError } from './error-dispatch.js'
|
|
139
|
+
|
|
131
140
|
export { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'
|
|
132
141
|
|
|
133
142
|
export { createTypedStream } from './stream.js'
|
package/src/client/stream.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { buildAdapterRequest } from './request-builder.js'
|
|
|
2
2
|
import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
|
|
3
3
|
import { applyRequestOptions, resolveBasePath } from './resolve-options.js'
|
|
4
4
|
import { ClientRequestError } from './errors.js'
|
|
5
|
+
import { dispatchTypedError } from './error-dispatch.js'
|
|
5
6
|
import type {
|
|
6
7
|
ClientAdapter,
|
|
7
8
|
ClientHooks,
|
|
9
|
+
ErrorRegistry,
|
|
8
10
|
StreamDescriptor,
|
|
9
11
|
TypedStream,
|
|
10
12
|
AdapterResponse,
|
|
@@ -100,6 +102,7 @@ export interface ExecuteStreamConfig {
|
|
|
100
102
|
hooks: ClientHooks
|
|
101
103
|
defaults?: ProcedureCallDefaults
|
|
102
104
|
options?: ProcedureCallOptions
|
|
105
|
+
errorRegistry?: ErrorRegistry
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
/**
|
|
@@ -118,7 +121,7 @@ export interface ExecuteStreamConfig {
|
|
|
118
121
|
export async function executeStream<TYield, TReturn = void>(
|
|
119
122
|
config: ExecuteStreamConfig,
|
|
120
123
|
): Promise<TypedStream<TYield, TReturn>> {
|
|
121
|
-
const { descriptor, basePath, adapter, hooks, defaults, options } = config
|
|
124
|
+
const { descriptor, basePath, adapter, hooks, defaults, options, errorRegistry } = config
|
|
122
125
|
|
|
123
126
|
// 1. Build the initial request
|
|
124
127
|
const resolvedBasePath = resolveBasePath(defaults, options, basePath)
|
|
@@ -149,11 +152,13 @@ export async function executeStream<TYield, TReturn = void>(
|
|
|
149
152
|
throw err
|
|
150
153
|
}
|
|
151
154
|
|
|
152
|
-
// Build an AdapterResponse shape for the hooks
|
|
155
|
+
// Build an AdapterResponse shape for the hooks. For success the body is null
|
|
156
|
+
// (the actual data flows through the async iterable); for non-2xx the adapter
|
|
157
|
+
// eagerly parses the JSON response body and surfaces it via `errorBody`.
|
|
153
158
|
const responseForHooks: AdapterResponse = {
|
|
154
159
|
status: streamResponse.status,
|
|
155
160
|
headers: streamResponse.headers,
|
|
156
|
-
body: null,
|
|
161
|
+
body: streamResponse.errorBody ?? null,
|
|
157
162
|
}
|
|
158
163
|
|
|
159
164
|
// 6. Run after-response hooks immediately (before iteration)
|
|
@@ -165,6 +170,12 @@ export async function executeStream<TYield, TReturn = void>(
|
|
|
165
170
|
|
|
166
171
|
// 7. Check status after hooks (hooks may mutate responseForHooks.status)
|
|
167
172
|
if (responseForHooks.status < 200 || responseForHooks.status >= 300) {
|
|
173
|
+
const typed = dispatchTypedError(errorRegistry, responseForHooks.body, {
|
|
174
|
+
status: responseForHooks.status,
|
|
175
|
+
procedureName: descriptor.name,
|
|
176
|
+
scope: descriptor.scope,
|
|
177
|
+
})
|
|
178
|
+
if (typed) throw typed
|
|
168
179
|
throw new ClientRequestError({
|
|
169
180
|
status: responseForHooks.status,
|
|
170
181
|
headers: responseForHooks.headers,
|
|
@@ -0,0 +1,211 @@
|
|
|
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 } from '../implementations/types.js'
|
|
6
|
+
import { HonoAPIAppBuilder, defineErrorTaxonomy } from '../implementations/http/hono-api/index.js'
|
|
7
|
+
import { createClient } from './index.js'
|
|
8
|
+
import { ClientRequestError } from './errors.js'
|
|
9
|
+
import type { ClientAdapter, ErrorRegistry } from './types.js'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Error taxonomy + simulated generated error classes
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
class UseCaseError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
readonly externalMsg: string,
|
|
18
|
+
readonly internalMsg: string
|
|
19
|
+
) {
|
|
20
|
+
super(externalMsg)
|
|
21
|
+
this.name = 'UseCaseError'
|
|
22
|
+
Object.setPrototypeOf(this, UseCaseError.prototype)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const appErrors = defineErrorTaxonomy({
|
|
27
|
+
UseCaseError: {
|
|
28
|
+
class: UseCaseError,
|
|
29
|
+
statusCode: 422,
|
|
30
|
+
toResponse: (err) => ({ message: err.externalMsg }),
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Simulates what the codegen emits: a runtime class + a registry entry the
|
|
35
|
+
// client uses for dispatch. In real usage this comes from the generated
|
|
36
|
+
// `_errors.ts`; here we inline it to test the client-side dispatch path end
|
|
37
|
+
// to end without invoking the codegen pipeline.
|
|
38
|
+
class ApiUseCaseError extends Error {
|
|
39
|
+
readonly status: number
|
|
40
|
+
readonly procedureName: string
|
|
41
|
+
readonly scope: string
|
|
42
|
+
readonly body: { name: 'UseCaseError'; message: string }
|
|
43
|
+
constructor(args: {
|
|
44
|
+
message: string
|
|
45
|
+
status: number
|
|
46
|
+
procedureName: string
|
|
47
|
+
scope: string
|
|
48
|
+
body: { name: 'UseCaseError'; message: string }
|
|
49
|
+
}) {
|
|
50
|
+
super(args.message)
|
|
51
|
+
this.name = 'UseCaseError'
|
|
52
|
+
this.status = args.status
|
|
53
|
+
this.procedureName = args.procedureName
|
|
54
|
+
this.scope = args.scope
|
|
55
|
+
this.body = args.body
|
|
56
|
+
Object.setPrototypeOf(this, ApiUseCaseError.prototype)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static fromResponse(
|
|
60
|
+
body: unknown,
|
|
61
|
+
meta: { status: number; procedureName: string; scope: string }
|
|
62
|
+
): ApiUseCaseError {
|
|
63
|
+
const b = body as { name: 'UseCaseError'; message: string }
|
|
64
|
+
return new ApiUseCaseError({
|
|
65
|
+
message: b.message,
|
|
66
|
+
status: meta.status,
|
|
67
|
+
procedureName: meta.procedureName,
|
|
68
|
+
scope: meta.scope,
|
|
69
|
+
body: b,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const errorRegistry: ErrorRegistry = { UseCaseError: ApiUseCaseError }
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Tiny adapter that routes through the in-memory Hono app (no real network)
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
interface HonoAppLike {
|
|
81
|
+
request(
|
|
82
|
+
url: string,
|
|
83
|
+
init: { method?: string; headers?: Record<string, string>; body?: string }
|
|
84
|
+
): Response | Promise<Response>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function honoAdapter(app: HonoAppLike): ClientAdapter {
|
|
88
|
+
return {
|
|
89
|
+
async request(req) {
|
|
90
|
+
const res = await Promise.resolve(
|
|
91
|
+
app.request(req.url, {
|
|
92
|
+
method: req.method,
|
|
93
|
+
headers: req.headers,
|
|
94
|
+
body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
const headers: Record<string, string> = {}
|
|
98
|
+
res.headers.forEach((v, k) => (headers[k] = v))
|
|
99
|
+
const body = await res.json().catch(() => null)
|
|
100
|
+
return { status: res.status, headers, body }
|
|
101
|
+
},
|
|
102
|
+
async stream() {
|
|
103
|
+
throw new Error('not used')
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Tests
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe('typed error dispatch — end-to-end', () => {
|
|
113
|
+
function buildApp() {
|
|
114
|
+
const API = Procedures<{}, APIConfig>()
|
|
115
|
+
API.Create(
|
|
116
|
+
'GetUser',
|
|
117
|
+
{
|
|
118
|
+
path: '/users/:id',
|
|
119
|
+
method: 'get',
|
|
120
|
+
schema: {
|
|
121
|
+
input: { pathParams: Type.Object({ id: Type.String() }) },
|
|
122
|
+
returnType: Type.Object({ id: Type.String() }),
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
async (_ctx, { pathParams }) => {
|
|
126
|
+
if (pathParams.id === 'missing') {
|
|
127
|
+
throw new UseCaseError('User not found', `no user with id=${pathParams.id}`)
|
|
128
|
+
}
|
|
129
|
+
return { id: pathParams.id }
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
return new HonoAPIAppBuilder({ errors: appErrors }).register(API, () => ({})).build()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test('server-thrown UseCaseError arrives on client as a typed class instance', async () => {
|
|
136
|
+
const app = buildApp()
|
|
137
|
+
const api = createClient({
|
|
138
|
+
adapter: honoAdapter(app),
|
|
139
|
+
basePath: '',
|
|
140
|
+
errorRegistry,
|
|
141
|
+
scopes: (client) => ({
|
|
142
|
+
getUser: (id: string) =>
|
|
143
|
+
client.call<{ id: string }>({
|
|
144
|
+
name: 'GetUser',
|
|
145
|
+
scope: 'users',
|
|
146
|
+
path: '/users/:id',
|
|
147
|
+
method: 'get',
|
|
148
|
+
kind: 'api',
|
|
149
|
+
params: { pathParams: { id } },
|
|
150
|
+
}),
|
|
151
|
+
}),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
await expect(api.getUser('missing')).rejects.toBeInstanceOf(ApiUseCaseError)
|
|
155
|
+
try {
|
|
156
|
+
await api.getUser('missing')
|
|
157
|
+
} catch (err) {
|
|
158
|
+
expect(err).toBeInstanceOf(ApiUseCaseError)
|
|
159
|
+
expect(err).toBeInstanceOf(Error)
|
|
160
|
+
expect((err as ApiUseCaseError).status).toBe(422)
|
|
161
|
+
expect((err as ApiUseCaseError).procedureName).toBe('GetUser')
|
|
162
|
+
expect((err as ApiUseCaseError).message).toBe('User not found')
|
|
163
|
+
expect((err as ApiUseCaseError).body.name).toBe('UseCaseError')
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('unregistered error body falls back to ClientRequestError', async () => {
|
|
168
|
+
const app = buildApp()
|
|
169
|
+
// Omit the registry so dispatch can't match; client sees the raw
|
|
170
|
+
// transport error instead of a typed class.
|
|
171
|
+
const api = createClient({
|
|
172
|
+
adapter: honoAdapter(app),
|
|
173
|
+
basePath: '',
|
|
174
|
+
scopes: (client) => ({
|
|
175
|
+
getUser: (id: string) =>
|
|
176
|
+
client.call<{ id: string }>({
|
|
177
|
+
name: 'GetUser',
|
|
178
|
+
scope: 'users',
|
|
179
|
+
path: '/users/:id',
|
|
180
|
+
method: 'get',
|
|
181
|
+
kind: 'api',
|
|
182
|
+
params: { pathParams: { id } },
|
|
183
|
+
}),
|
|
184
|
+
}),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
await expect(api.getUser('missing')).rejects.toBeInstanceOf(ClientRequestError)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('success responses are not disturbed by dispatch logic', async () => {
|
|
191
|
+
const app = buildApp()
|
|
192
|
+
const api = createClient({
|
|
193
|
+
adapter: honoAdapter(app),
|
|
194
|
+
basePath: '',
|
|
195
|
+
errorRegistry,
|
|
196
|
+
scopes: (client) => ({
|
|
197
|
+
getUser: (id: string) =>
|
|
198
|
+
client.call<{ id: string }>({
|
|
199
|
+
name: 'GetUser',
|
|
200
|
+
scope: 'users',
|
|
201
|
+
path: '/users/:id',
|
|
202
|
+
method: 'get',
|
|
203
|
+
kind: 'api',
|
|
204
|
+
params: { pathParams: { id } },
|
|
205
|
+
}),
|
|
206
|
+
}),
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
await expect(api.getUser('u_42')).resolves.toEqual({ id: 'u_42' })
|
|
210
|
+
})
|
|
211
|
+
})
|
package/src/client/types.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface AdapterStreamResponse {
|
|
|
60
60
|
status: number
|
|
61
61
|
headers: Record<string, string>
|
|
62
62
|
body: AsyncIterable<unknown>
|
|
63
|
+
/**
|
|
64
|
+
* Populated when `status` is non-2xx — the parsed response body. Surfaced so
|
|
65
|
+
* `executeStream` can dispatch typed errors via the error registry instead
|
|
66
|
+
* of always falling back to `ClientRequestError` with `body: null`.
|
|
67
|
+
*/
|
|
68
|
+
errorBody?: unknown
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
// ── Hooks ────────────────────────────────────────────────
|
|
@@ -148,6 +154,33 @@ export interface ProcedureCallDefaults {
|
|
|
148
154
|
*/
|
|
149
155
|
export interface ProcedureCallOptions extends ProcedureCallDefaults, ClientHooks {}
|
|
150
156
|
|
|
157
|
+
// ── Error Registry ───────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Metadata attached to a typed error at construction. Supplies the transport
|
|
161
|
+
* context (status, procedure, scope) that isn't part of the response body.
|
|
162
|
+
*/
|
|
163
|
+
export interface ErrorResponseMeta {
|
|
164
|
+
status: number
|
|
165
|
+
procedureName: string
|
|
166
|
+
scope: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A factory for a typed error class — constructed from the response body plus
|
|
171
|
+
* transport metadata. Generated error classes expose this as a static method.
|
|
172
|
+
*/
|
|
173
|
+
export interface ErrorFactory {
|
|
174
|
+
fromResponse(body: unknown, meta: ErrorResponseMeta): Error
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Maps `body.name` values (taxonomy keys) to error class factories. When the
|
|
179
|
+
* client sees a non-2xx response whose body has a `name` matching a registry
|
|
180
|
+
* entry, it throws the typed error instead of a generic `ClientRequestError`.
|
|
181
|
+
*/
|
|
182
|
+
export type ErrorRegistry = Record<string, ErrorFactory>
|
|
183
|
+
|
|
151
184
|
// ── Client Instance ──────────────────────────────────────
|
|
152
185
|
|
|
153
186
|
export interface ClientInstance {
|
|
@@ -155,6 +188,8 @@ export interface ClientInstance {
|
|
|
155
188
|
adapter: ClientAdapter
|
|
156
189
|
hooks: ClientHooks
|
|
157
190
|
defaults: ProcedureCallDefaults
|
|
191
|
+
/** Optional registry for runtime dispatch of typed errors by `body.name`. */
|
|
192
|
+
errorRegistry?: ErrorRegistry
|
|
158
193
|
call<TResponse>(descriptor: CallDescriptor, options?: ProcedureCallOptions): Promise<TResponse>
|
|
159
194
|
stream<TYield, TReturn>(descriptor: StreamDescriptor, options?: ProcedureCallOptions): TypedStream<TYield, TReturn>
|
|
160
195
|
}
|
|
@@ -172,4 +207,11 @@ export interface CreateClientConfig<TScopes> {
|
|
|
172
207
|
* fires first cancels the request).
|
|
173
208
|
*/
|
|
174
209
|
defaults?: ProcedureCallDefaults
|
|
210
|
+
/**
|
|
211
|
+
* Optional error-dispatch registry. When a non-2xx response body has a
|
|
212
|
+
* `name` field matching a registry key, the client throws the typed error
|
|
213
|
+
* constructed via that entry's `fromResponse`. When absent or when no key
|
|
214
|
+
* matches, falls back to `ClientRequestError` (transport error shape).
|
|
215
|
+
*/
|
|
216
|
+
errorRegistry?: ErrorRegistry
|
|
175
217
|
}
|
package/src/codegen/e2e.test.ts
CHANGED
|
@@ -339,12 +339,13 @@ describe('E2E: generateClient full pipeline', () => {
|
|
|
339
339
|
expect(existsSync(join(tmpDir, '_errors.ts'))).toBe(true)
|
|
340
340
|
})
|
|
341
341
|
|
|
342
|
-
it('_errors.ts contains ProcedureError
|
|
342
|
+
it('_errors.ts contains a runtime class for ProcedureError', async () => {
|
|
343
343
|
tmpDir = makeTmpDir()
|
|
344
344
|
await generateClient({ envelope, outDir: tmpDir })
|
|
345
345
|
|
|
346
346
|
const content = readFileSync(join(tmpDir, '_errors.ts'), 'utf-8')
|
|
347
|
-
expect(content).toContain('export
|
|
347
|
+
expect(content).toContain('export class ProcedureError')
|
|
348
|
+
expect(content).toContain('static fromResponse(')
|
|
348
349
|
})
|
|
349
350
|
|
|
350
351
|
it('_errors.ts contains the service-prefixed ProcedureErrorUnion', async () => {
|
|
@@ -357,12 +358,16 @@ describe('E2E: generateClient full pipeline', () => {
|
|
|
357
358
|
expect(content).toContain('ProcedureValidationError')
|
|
358
359
|
})
|
|
359
360
|
|
|
360
|
-
it('index.ts
|
|
361
|
+
it('index.ts imports the _errors registry as a runtime value when errors are present', async () => {
|
|
362
|
+
// PR 3 change: the error registry is imported as a value (not `import
|
|
363
|
+
// type`) so `createApiClient` can wire it into `createClient` regardless
|
|
364
|
+
// of `namespaceTypes`.
|
|
361
365
|
tmpDir = makeTmpDir()
|
|
362
366
|
await generateClient({ envelope, outDir: tmpDir })
|
|
363
367
|
|
|
364
368
|
const content = readFileSync(join(tmpDir, 'index.ts'), 'utf-8')
|
|
365
|
-
expect(content).
|
|
369
|
+
expect(content).toContain("import * as _errorsModule from './_errors'")
|
|
370
|
+
expect(content).toContain('errorRegistry: _errorsModule.ApiErrorRegistry')
|
|
366
371
|
})
|
|
367
372
|
|
|
368
373
|
it('index.ts folds errors into the service namespace when namespaceTypes is on', async () => {
|
|
@@ -20,6 +20,9 @@ const TYPES_IMPORT = `import type {
|
|
|
20
20
|
ProcedureCallOptions,
|
|
21
21
|
CreateClientConfig,
|
|
22
22
|
RequestMeta,
|
|
23
|
+
ErrorRegistry,
|
|
24
|
+
ErrorFactory,
|
|
25
|
+
ErrorResponseMeta,
|
|
23
26
|
} from './_types'`
|
|
24
27
|
|
|
25
28
|
/**
|
|
@@ -28,6 +31,7 @@ const TYPES_IMPORT = `import type {
|
|
|
28
31
|
*/
|
|
29
32
|
const SOURCE_FILES = [
|
|
30
33
|
'errors.ts',
|
|
34
|
+
'error-dispatch.ts',
|
|
31
35
|
'request-builder.ts',
|
|
32
36
|
'resolve-options.ts',
|
|
33
37
|
'hooks.ts',
|