ts-procedures 5.15.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 +220 -9
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +271 -16
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +53 -18
- 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 +132 -19
- package/agent_config/cursor/cursorrules +132 -19
- package/build/client/call.d.ts +19 -9
- package/build/client/call.js +33 -19
- package/build/client/call.js.map +1 -1
- package/build/client/call.test.js +167 -17
- package/build/client/call.test.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 +22 -3
- package/build/client/index.js.map +1 -1
- package/build/client/index.test.js +104 -0
- package/build/client/index.test.js.map +1 -1
- package/build/client/resolve-options.d.ts +45 -0
- package/build/client/resolve-options.js +82 -0
- package/build/client/resolve-options.js.map +1 -0
- package/build/client/resolve-options.test.d.ts +1 -0
- package/build/client/resolve-options.test.js +158 -0
- package/build/client/resolve-options.test.js.map +1 -0
- package/build/client/stream.d.ts +19 -9
- package/build/client/stream.js +36 -21
- package/build/client/stream.js.map +1 -1
- package/build/client/stream.test.js +102 -46
- package/build/client/stream.test.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 +105 -1
- package/build/client/types.js +1 -1
- package/build/codegen/e2e.test.js +150 -4
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +7 -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 +228 -14
- 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.test.ts +202 -29
- package/src/client/call.ts +50 -28
- 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.test.ts +117 -0
- package/src/client/index.ts +34 -8
- package/src/client/resolve-options.test.ts +205 -0
- package/src/client/resolve-options.ts +113 -0
- package/src/client/stream.test.ts +132 -107
- package/src/client/stream.ts +53 -27
- package/src/client/typed-error-dispatch.test.ts +211 -0
- package/src/client/types.ts +116 -2
- package/src/codegen/e2e.test.ts +160 -4
- package/src/codegen/emit-client-runtime.ts +7 -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 +28 -5
- 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,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
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
// ── Request Metadata ─────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-request metadata visible to adapters and hooks. Defined as an empty
|
|
5
|
+
* interface so consumers can augment it via TypeScript declaration merging
|
|
6
|
+
* to get end-to-end type safety for their own metadata fields.
|
|
7
|
+
*
|
|
8
|
+
* @example With a non-self-contained client:
|
|
9
|
+
* ```ts
|
|
10
|
+
* declare module 'ts-procedures/client' {
|
|
11
|
+
* interface RequestMeta {
|
|
12
|
+
* traceId?: string
|
|
13
|
+
* priority?: 'high' | 'low'
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @example With a self-contained (code-generated) client:
|
|
19
|
+
* ```ts
|
|
20
|
+
* declare module './generated/_types' {
|
|
21
|
+
* interface RequestMeta {
|
|
22
|
+
* traceId?: string
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* After augmentation, `request.meta.traceId` is typed everywhere — per-call
|
|
28
|
+
* options, hooks, and adapters.
|
|
29
|
+
*/
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
31
|
+
export interface RequestMeta {}
|
|
32
|
+
|
|
1
33
|
// ── Adapter ──────────────────────────────────────────────
|
|
2
34
|
|
|
3
35
|
export interface ClientAdapter {
|
|
@@ -11,6 +43,11 @@ export interface AdapterRequest {
|
|
|
11
43
|
headers?: Record<string, string>
|
|
12
44
|
body?: unknown
|
|
13
45
|
signal?: AbortSignal
|
|
46
|
+
/**
|
|
47
|
+
* Per-request metadata. Augment `RequestMeta` via declaration merging to
|
|
48
|
+
* type your own fields. See {@link RequestMeta}.
|
|
49
|
+
*/
|
|
50
|
+
meta?: RequestMeta
|
|
14
51
|
}
|
|
15
52
|
|
|
16
53
|
export interface AdapterResponse {
|
|
@@ -23,6 +60,12 @@ export interface AdapterStreamResponse {
|
|
|
23
60
|
status: number
|
|
24
61
|
headers: Record<string, string>
|
|
25
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
|
|
26
69
|
}
|
|
27
70
|
|
|
28
71
|
// ── Hooks ────────────────────────────────────────────────
|
|
@@ -81,14 +124,72 @@ export interface TypedStream<TYield, TReturn = void> extends AsyncIterable<TYiel
|
|
|
81
124
|
result: Promise<TReturn>
|
|
82
125
|
}
|
|
83
126
|
|
|
84
|
-
// ──
|
|
127
|
+
// ── Request Options ──────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Request-level configuration that can be set as client-level defaults
|
|
131
|
+
* (via `CreateClientConfig.defaults`) or per-call (via `ProcedureCallOptions`).
|
|
132
|
+
*
|
|
133
|
+
* - `signal`: AbortSignal for cancellation. When both a default and per-call
|
|
134
|
+
* signal are provided, they're combined — whichever aborts first wins.
|
|
135
|
+
* - `timeout`: Timeout in milliseconds. Combined with `signal` the same way.
|
|
136
|
+
* A per-call `timeout: 0` disables an inherited default timeout.
|
|
137
|
+
* - `headers`: Extra headers merged into the request. Per-call keys win over
|
|
138
|
+
* default keys. Still subject to further mutation by `onBeforeRequest` hooks.
|
|
139
|
+
* - `basePath`: Override the base path for this call. Per-call > default > config.
|
|
140
|
+
* - `meta`: Per-request metadata typed via the {@link RequestMeta} interface.
|
|
141
|
+
* Merged shallowly (per-call keys win over default keys).
|
|
142
|
+
*/
|
|
143
|
+
export interface ProcedureCallDefaults {
|
|
144
|
+
signal?: AbortSignal
|
|
145
|
+
timeout?: number
|
|
146
|
+
headers?: Record<string, string>
|
|
147
|
+
basePath?: string
|
|
148
|
+
meta?: RequestMeta
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Per-call options. Extends both `ProcedureCallDefaults` (request config) and
|
|
153
|
+
* `ClientHooks` (hooks), so a single options bag covers both concerns.
|
|
154
|
+
*/
|
|
155
|
+
export interface ProcedureCallOptions extends ProcedureCallDefaults, ClientHooks {}
|
|
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
|
+
}
|
|
85
176
|
|
|
86
|
-
|
|
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
|
+
|
|
184
|
+
// ── Client Instance ──────────────────────────────────────
|
|
87
185
|
|
|
88
186
|
export interface ClientInstance {
|
|
89
187
|
basePath: string
|
|
90
188
|
adapter: ClientAdapter
|
|
91
189
|
hooks: ClientHooks
|
|
190
|
+
defaults: ProcedureCallDefaults
|
|
191
|
+
/** Optional registry for runtime dispatch of typed errors by `body.name`. */
|
|
192
|
+
errorRegistry?: ErrorRegistry
|
|
92
193
|
call<TResponse>(descriptor: CallDescriptor, options?: ProcedureCallOptions): Promise<TResponse>
|
|
93
194
|
stream<TYield, TReturn>(descriptor: StreamDescriptor, options?: ProcedureCallOptions): TypedStream<TYield, TReturn>
|
|
94
195
|
}
|
|
@@ -100,4 +201,17 @@ export interface CreateClientConfig<TScopes> {
|
|
|
100
201
|
basePath: string
|
|
101
202
|
scopes: (client: ClientInstance) => TScopes
|
|
102
203
|
hooks?: ClientHooks
|
|
204
|
+
/**
|
|
205
|
+
* Default request options applied to every call. Per-call options override
|
|
206
|
+
* these (except `signal`, which combines via AbortSignal.any — whichever
|
|
207
|
+
* fires first cancels the request).
|
|
208
|
+
*/
|
|
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
|
|
103
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 () => {
|
|
@@ -481,6 +486,157 @@ describe('E2E: generateClient full pipeline', () => {
|
|
|
481
486
|
execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
|
|
482
487
|
}).not.toThrow()
|
|
483
488
|
})
|
|
489
|
+
|
|
490
|
+
it('_types.ts exports RequestMeta (empty interface ready for augmentation)', async () => {
|
|
491
|
+
tmpDir = makeTmpDir()
|
|
492
|
+
await generateClient({ envelope, outDir: tmpDir, selfContained: true })
|
|
493
|
+
|
|
494
|
+
const content = readFileSync(join(tmpDir, '_types.ts'), 'utf-8')
|
|
495
|
+
expect(content).toContain('export interface RequestMeta')
|
|
496
|
+
// meta on AdapterRequest and ProcedureCallDefaults should be typed as RequestMeta
|
|
497
|
+
expect(content).toContain('meta?: RequestMeta')
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('developers can augment RequestMeta for typed per-call meta + typed hook/adapter access', async () => {
|
|
501
|
+
tmpDir = makeTmpDir()
|
|
502
|
+
await generateClient({ envelope, outDir: tmpDir, selfContained: true })
|
|
503
|
+
|
|
504
|
+
// Write a consumer file that augments RequestMeta, then uses typed meta
|
|
505
|
+
// end-to-end: per-call options, createClient defaults, onBeforeRequest, and adapter.
|
|
506
|
+
const consumer = `
|
|
507
|
+
import { createClient } from './_client'
|
|
508
|
+
import type { ClientAdapter } from './_types'
|
|
509
|
+
import { createApiBindings } from './index'
|
|
510
|
+
|
|
511
|
+
declare module './_types' {
|
|
512
|
+
interface RequestMeta {
|
|
513
|
+
traceId: string
|
|
514
|
+
priority?: 'high' | 'low'
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const typedAdapter: ClientAdapter = {
|
|
519
|
+
async request(req) {
|
|
520
|
+
// req.meta is now typed
|
|
521
|
+
const trace: string | undefined = req.meta?.traceId
|
|
522
|
+
const pri: 'high' | 'low' | undefined = req.meta?.priority
|
|
523
|
+
void trace; void pri
|
|
524
|
+
return { status: 200, headers: {}, body: {} }
|
|
525
|
+
},
|
|
526
|
+
async stream(req) {
|
|
527
|
+
const trace: string | undefined = req.meta?.traceId
|
|
528
|
+
void trace
|
|
529
|
+
return { status: 200, headers: {}, body: (async function*() {})() }
|
|
530
|
+
},
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const client = createClient({
|
|
534
|
+
adapter: typedAdapter,
|
|
535
|
+
basePath: 'https://api.example.com',
|
|
536
|
+
scopes: createApiBindings,
|
|
537
|
+
defaults: {
|
|
538
|
+
meta: { traceId: 'default-trace' }, // typed
|
|
539
|
+
},
|
|
540
|
+
hooks: {
|
|
541
|
+
onBeforeRequest(ctx) {
|
|
542
|
+
// ctx.request.meta is typed via declaration merging
|
|
543
|
+
const trace: string | undefined = ctx.request.meta?.traceId
|
|
544
|
+
void trace
|
|
545
|
+
return ctx
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
async function run(): Promise<void> {
|
|
551
|
+
// Per-call meta is typed — traceId is required, priority is optional
|
|
552
|
+
await client.users.GetUser(
|
|
553
|
+
{ id: '1' },
|
|
554
|
+
{ meta: { traceId: 'per-call-trace', priority: 'high' } },
|
|
555
|
+
)
|
|
556
|
+
// Timeout + signal + headers typecheck
|
|
557
|
+
await client.users.GetUser(
|
|
558
|
+
{ id: '2' },
|
|
559
|
+
{ timeout: 5000, headers: { 'X-Request-Id': 'abc' }, basePath: 'https://other' },
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
void run
|
|
563
|
+
`
|
|
564
|
+
const { writeFileSync } = await import('node:fs')
|
|
565
|
+
writeFileSync(join(tmpDir, 'consumer.ts'), consumer)
|
|
566
|
+
|
|
567
|
+
const tsconfig = {
|
|
568
|
+
compilerOptions: {
|
|
569
|
+
strict: true,
|
|
570
|
+
target: 'ES2022',
|
|
571
|
+
module: 'ES2022',
|
|
572
|
+
moduleResolution: 'bundler',
|
|
573
|
+
noEmit: true,
|
|
574
|
+
skipLibCheck: true,
|
|
575
|
+
},
|
|
576
|
+
include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', 'events.ts', '_errors.ts', 'consumer.ts'],
|
|
577
|
+
}
|
|
578
|
+
writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
|
|
579
|
+
|
|
580
|
+
const { execSync } = await import('node:child_process')
|
|
581
|
+
const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
|
|
582
|
+
expect(() => {
|
|
583
|
+
execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
|
|
584
|
+
}).not.toThrow()
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('augmented RequestMeta rejects wrong types (compile error)', async () => {
|
|
588
|
+
tmpDir = makeTmpDir()
|
|
589
|
+
await generateClient({ envelope, outDir: tmpDir, selfContained: true })
|
|
590
|
+
|
|
591
|
+
// Passing a number for `traceId` (declared as string) should fail tsc
|
|
592
|
+
const consumer = `
|
|
593
|
+
import { createClient, createFetchAdapter } from './_client'
|
|
594
|
+
import { createApiBindings } from './index'
|
|
595
|
+
// RequestMeta is imported only for augmentation below
|
|
596
|
+
import type {} from './_types'
|
|
597
|
+
|
|
598
|
+
declare module './_types' {
|
|
599
|
+
interface RequestMeta {
|
|
600
|
+
traceId: string
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const client = createClient({
|
|
605
|
+
adapter: createFetchAdapter(),
|
|
606
|
+
basePath: 'https://api.example.com',
|
|
607
|
+
scopes: createApiBindings,
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
async function run(): Promise<void> {
|
|
611
|
+
// @ts-expect-error traceId must be string, not number
|
|
612
|
+
await client.users.GetUser({ id: '1' }, { meta: { traceId: 42 } })
|
|
613
|
+
}
|
|
614
|
+
void run
|
|
615
|
+
`
|
|
616
|
+
const { writeFileSync } = await import('node:fs')
|
|
617
|
+
writeFileSync(join(tmpDir, 'consumer.ts'), consumer)
|
|
618
|
+
|
|
619
|
+
const tsconfig = {
|
|
620
|
+
compilerOptions: {
|
|
621
|
+
strict: true,
|
|
622
|
+
target: 'ES2022',
|
|
623
|
+
module: 'ES2022',
|
|
624
|
+
moduleResolution: 'bundler',
|
|
625
|
+
noEmit: true,
|
|
626
|
+
skipLibCheck: true,
|
|
627
|
+
},
|
|
628
|
+
include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', 'events.ts', '_errors.ts', 'consumer.ts'],
|
|
629
|
+
}
|
|
630
|
+
writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
|
|
631
|
+
|
|
632
|
+
const { execSync } = await import('node:child_process')
|
|
633
|
+
const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
|
|
634
|
+
// With @ts-expect-error in place, tsc should pass; if RequestMeta wasn't
|
|
635
|
+
// enforcing the type, @ts-expect-error would fail because there'd be no error.
|
|
636
|
+
expect(() => {
|
|
637
|
+
execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
|
|
638
|
+
}).not.toThrow()
|
|
639
|
+
})
|
|
484
640
|
})
|
|
485
641
|
|
|
486
642
|
// ── namespaceTypes mode ───────────────────────────────────────────────────
|
|
@@ -16,8 +16,13 @@ const TYPES_IMPORT = `import type {
|
|
|
16
16
|
StreamDescriptor,
|
|
17
17
|
TypedStream,
|
|
18
18
|
ClientInstance,
|
|
19
|
+
ProcedureCallDefaults,
|
|
19
20
|
ProcedureCallOptions,
|
|
20
21
|
CreateClientConfig,
|
|
22
|
+
RequestMeta,
|
|
23
|
+
ErrorRegistry,
|
|
24
|
+
ErrorFactory,
|
|
25
|
+
ErrorResponseMeta,
|
|
21
26
|
} from './_types'`
|
|
22
27
|
|
|
23
28
|
/**
|
|
@@ -26,7 +31,9 @@ const TYPES_IMPORT = `import type {
|
|
|
26
31
|
*/
|
|
27
32
|
const SOURCE_FILES = [
|
|
28
33
|
'errors.ts',
|
|
34
|
+
'error-dispatch.ts',
|
|
29
35
|
'request-builder.ts',
|
|
36
|
+
'resolve-options.ts',
|
|
30
37
|
'hooks.ts',
|
|
31
38
|
'call.ts',
|
|
32
39
|
'stream.ts',
|