ts-procedures 6.2.0 → 7.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +38 -1
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +253 -3
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +60 -2
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +1 -1
- package/agent_config/copilot/copilot-instructions.md +3 -0
- package/agent_config/cursor/cursorrules +3 -0
- package/build/client/augment-error-map.test-d.d.ts +10 -0
- package/build/client/augment-error-map.test-d.js +14 -0
- package/build/client/augment-error-map.test-d.js.map +1 -0
- package/build/client/bind-callable.test.d.ts +1 -0
- package/build/client/bind-callable.test.js +132 -0
- package/build/client/bind-callable.test.js.map +1 -0
- package/build/client/call.d.ts +14 -2
- package/build/client/call.js +96 -9
- package/build/client/call.js.map +1 -1
- package/build/client/call.test.js +50 -1
- package/build/client/call.test.js.map +1 -1
- package/build/client/classify-error.d.ts +11 -0
- package/build/client/classify-error.js +49 -0
- package/build/client/classify-error.js.map +1 -0
- package/build/client/classify-error.test.d.ts +1 -0
- package/build/client/classify-error.test.js +55 -0
- package/build/client/classify-error.test.js.map +1 -0
- package/build/client/error-dispatch.d.ts +1 -1
- package/build/client/error-dispatch.js +1 -1
- package/build/client/errors.d.ts +55 -4
- package/build/client/errors.js +54 -7
- package/build/client/errors.js.map +1 -1
- package/build/client/errors.test.js +89 -4
- package/build/client/errors.test.js.map +1 -1
- package/build/client/fetch-adapter.d.ts +2 -1
- package/build/client/fetch-adapter.js +2 -1
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/fetch-adapter.test.js +12 -0
- package/build/client/fetch-adapter.test.js.map +1 -1
- package/build/client/index.d.ts +5 -3
- package/build/client/index.js +29 -3
- package/build/client/index.js.map +1 -1
- package/build/client/resolve-options.d.ts +32 -1
- package/build/client/resolve-options.js +32 -16
- package/build/client/resolve-options.js.map +1 -1
- package/build/client/resolve-options.test.js +67 -6
- package/build/client/resolve-options.test.js.map +1 -1
- package/build/client/result-type.test-d.d.ts +1 -0
- package/build/client/result-type.test-d.js +28 -0
- package/build/client/result-type.test-d.js.map +1 -0
- package/build/client/safe-call.test.d.ts +1 -0
- package/build/client/safe-call.test.js +137 -0
- package/build/client/safe-call.test.js.map +1 -0
- package/build/client/stream.d.ts +1 -1
- package/build/client/stream.js +22 -8
- package/build/client/stream.js.map +1 -1
- package/build/client/stream.test.js +11 -1
- package/build/client/stream.test.js.map +1 -1
- package/build/client/types.d.ts +117 -3
- package/build/codegen/bundle-size.test.d.ts +1 -0
- package/build/codegen/bundle-size.test.js +70 -0
- package/build/codegen/bundle-size.test.js.map +1 -0
- package/build/codegen/e2e.test.js +108 -7
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +8 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-client-runtime.test.js +6 -2
- package/build/codegen/emit-client-runtime.test.js.map +1 -1
- package/build/codegen/emit-client-types.d.ts +7 -2
- package/build/codegen/emit-client-types.js +29 -8
- package/build/codegen/emit-client-types.js.map +1 -1
- package/build/codegen/emit-client-types.test.js +20 -8
- package/build/codegen/emit-client-types.test.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +1 -1
- package/build/codegen/emit-errors.js +1 -1
- package/build/codegen/emit-index.js +1 -1
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-scope.js +37 -25
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +310 -14
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/docs/client-and-codegen.md +77 -7
- package/docs/client-error-handling.md +357 -0
- package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
- package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
- package/package.json +1 -1
- package/src/client/augment-error-map.test-d.ts +22 -0
- package/src/client/bind-callable.test.ts +137 -0
- package/src/client/call.test.ts +65 -1
- package/src/client/call.ts +111 -9
- package/src/client/classify-error.test.ts +65 -0
- package/src/client/classify-error.ts +59 -0
- package/src/client/error-dispatch.ts +1 -1
- package/src/client/errors.test.ts +108 -4
- package/src/client/errors.ts +70 -7
- package/src/client/fetch-adapter.test.ts +15 -0
- package/src/client/fetch-adapter.ts +5 -2
- package/src/client/index.ts +60 -3
- package/src/client/resolve-options.test.ts +83 -5
- package/src/client/resolve-options.ts +61 -16
- package/src/client/result-type.test-d.ts +51 -0
- package/src/client/safe-call.test.ts +157 -0
- package/src/client/stream.test.ts +13 -1
- package/src/client/stream.ts +25 -8
- package/src/client/types.ts +137 -3
- package/src/codegen/bundle-size.test.ts +76 -0
- package/src/codegen/e2e.test.ts +113 -7
- package/src/codegen/emit-client-runtime.test.ts +7 -2
- package/src/codegen/emit-client-runtime.ts +8 -0
- package/src/codegen/emit-client-types.test.ts +22 -7
- package/src/codegen/emit-client-types.ts +35 -10
- package/src/codegen/emit-errors.ts +1 -1
- package/src/codegen/emit-index.ts +1 -1
- package/src/codegen/emit-scope.test.ts +337 -14
- package/src/codegen/emit-scope.ts +39 -35
package/src/client/types.ts
CHANGED
|
@@ -30,11 +30,52 @@
|
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
31
31
|
export interface RequestMeta {}
|
|
32
32
|
|
|
33
|
+
// ── Error Classifier ─────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Classification context — supplies provenance the classifier needs that's
|
|
37
|
+
* not derivable from the raw error itself.
|
|
38
|
+
*
|
|
39
|
+
* `timeoutSignal` and `userSignal` let the classifier distinguish a timeout
|
|
40
|
+
* abort from a user-initiated abort when an `AbortError` lands.
|
|
41
|
+
*/
|
|
42
|
+
export interface ClassifyErrorContext {
|
|
43
|
+
procedureName: string
|
|
44
|
+
scope: string
|
|
45
|
+
timeoutSignal?: AbortSignal
|
|
46
|
+
userSignal?: AbortSignal
|
|
47
|
+
timeoutMs?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The output shape of a successful classification. Contract: `error` is
|
|
52
|
+
* always an `Error` subclass (the framework class). Non-`Error` values fall
|
|
53
|
+
* through to `null` (handled by `executeCall` as `kind: 'unknown'`).
|
|
54
|
+
*/
|
|
55
|
+
export interface ClassifiedError {
|
|
56
|
+
kind: string
|
|
57
|
+
error: Error
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Adapter-provided classifier — runs before `defaultClassifyError`. Return
|
|
62
|
+
* `null` to fall through to the default. Adapter authors should compose with
|
|
63
|
+
* the default explicitly:
|
|
64
|
+
*
|
|
65
|
+
* classifyError: (e, ctx) => myClassify(e, ctx) ?? defaultClassifyError(e, ctx)
|
|
66
|
+
*/
|
|
67
|
+
export type ErrorClassifier = (
|
|
68
|
+
raw: unknown,
|
|
69
|
+
ctx: ClassifyErrorContext,
|
|
70
|
+
) => ClassifiedError | null
|
|
71
|
+
|
|
33
72
|
// ── Adapter ──────────────────────────────────────────────
|
|
34
73
|
|
|
35
74
|
export interface ClientAdapter {
|
|
36
75
|
request(config: AdapterRequest): Promise<AdapterResponse>
|
|
37
76
|
stream(config: AdapterRequest): Promise<AdapterStreamResponse>
|
|
77
|
+
/** Optional adapter-level error classifier — composes with `defaultClassifyError`. */
|
|
78
|
+
classifyError?: ErrorClassifier
|
|
38
79
|
}
|
|
39
80
|
|
|
40
81
|
export interface AdapterRequest {
|
|
@@ -63,7 +104,7 @@ export interface AdapterStreamResponse {
|
|
|
63
104
|
/**
|
|
64
105
|
* Populated when `status` is non-2xx — the parsed response body. Surfaced so
|
|
65
106
|
* `executeStream` can dispatch typed errors via the error registry instead
|
|
66
|
-
* of always falling back to `
|
|
107
|
+
* of always falling back to `ClientHttpError` with `body: null`.
|
|
67
108
|
*/
|
|
68
109
|
errorBody?: unknown
|
|
69
110
|
}
|
|
@@ -177,7 +218,7 @@ export interface ErrorFactory {
|
|
|
177
218
|
/**
|
|
178
219
|
* Maps `body.name` values (taxonomy keys) to error class factories. When the
|
|
179
220
|
* 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 `
|
|
221
|
+
* entry, it throws the typed error instead of a generic `ClientHttpError`.
|
|
181
222
|
*/
|
|
182
223
|
export type ErrorRegistry = Record<string, ErrorFactory>
|
|
183
224
|
|
|
@@ -191,7 +232,36 @@ export interface ClientInstance {
|
|
|
191
232
|
/** Optional registry for runtime dispatch of typed errors by `body.name`. */
|
|
192
233
|
errorRegistry?: ErrorRegistry
|
|
193
234
|
call<TResponse>(descriptor: CallDescriptor, options?: ProcedureCallOptions): Promise<TResponse>
|
|
235
|
+
safeCall<TResponse, ETyped = never>(
|
|
236
|
+
descriptor: CallDescriptor,
|
|
237
|
+
options?: ProcedureCallOptions,
|
|
238
|
+
): Promise<Result<TResponse, ETyped>>
|
|
194
239
|
stream<TYield, TReturn>(descriptor: StreamDescriptor, options?: ProcedureCallOptions): TypedStream<TYield, TReturn>
|
|
240
|
+
/**
|
|
241
|
+
* Wires a callable with a `.safe` sibling for routes without declared typed errors.
|
|
242
|
+
* Used by codegen to produce a compact one-line callable per route.
|
|
243
|
+
*
|
|
244
|
+
* The returned function's `.name` is set to `descriptor.name` for nicer stack traces.
|
|
245
|
+
*/
|
|
246
|
+
bindCallable<TParams, TResponse>(
|
|
247
|
+
descriptor: Omit<CallDescriptor, 'params'>,
|
|
248
|
+
): {
|
|
249
|
+
(params: TParams, options?: ProcedureCallOptions): Promise<TResponse>
|
|
250
|
+
safe(params: TParams, options?: ProcedureCallOptions): Promise<ResultNoTyped<TResponse>>
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Wires a callable with a `.safe` sibling for routes with declared typed errors.
|
|
254
|
+
* The `.safe` form returns `Result<TResponse, ETyped>` where `ETyped` is the
|
|
255
|
+
* union of route-declared error classes.
|
|
256
|
+
*
|
|
257
|
+
* The returned function's `.name` is set to `descriptor.name` for nicer stack traces.
|
|
258
|
+
*/
|
|
259
|
+
bindCallableTyped<TParams, TResponse, ETyped>(
|
|
260
|
+
descriptor: Omit<CallDescriptor, 'params'>,
|
|
261
|
+
): {
|
|
262
|
+
(params: TParams, options?: ProcedureCallOptions): Promise<TResponse>
|
|
263
|
+
safe(params: TParams, options?: ProcedureCallOptions): Promise<Result<TResponse, ETyped>>
|
|
264
|
+
}
|
|
195
265
|
}
|
|
196
266
|
|
|
197
267
|
// ── createClient Config ──────────────────────────────────
|
|
@@ -211,7 +281,71 @@ export interface CreateClientConfig<TScopes> {
|
|
|
211
281
|
* Optional error-dispatch registry. When a non-2xx response body has a
|
|
212
282
|
* `name` field matching a registry key, the client throws the typed error
|
|
213
283
|
* constructed via that entry's `fromResponse`. When absent or when no key
|
|
214
|
-
* matches, falls back to `
|
|
284
|
+
* matches, falls back to `ClientHttpError` (transport error shape).
|
|
215
285
|
*/
|
|
216
286
|
errorRegistry?: ErrorRegistry
|
|
217
287
|
}
|
|
288
|
+
|
|
289
|
+
// ── Result Types ─────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
import type {
|
|
292
|
+
ClientHttpError,
|
|
293
|
+
ClientNetworkError,
|
|
294
|
+
ClientTimeoutError,
|
|
295
|
+
ClientAbortError,
|
|
296
|
+
ClientParseError,
|
|
297
|
+
ClientPathParamError,
|
|
298
|
+
} from './errors.js'
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Augmentable map of `kind` discriminant → error class for the framework's
|
|
302
|
+
* non-typed failure categories. Mirrors the `RequestMeta` augmentation
|
|
303
|
+
* pattern: extend via TypeScript declaration merging.
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* ```ts
|
|
307
|
+
* declare module 'ts-procedures/client' {
|
|
308
|
+
* interface ClientErrorMap {
|
|
309
|
+
* rateLimited: MyRateLimitError
|
|
310
|
+
* paymentRequired: MyPaymentError
|
|
311
|
+
* }
|
|
312
|
+
* }
|
|
313
|
+
* ```
|
|
314
|
+
*
|
|
315
|
+
* **`'typed'` is reserved** — it's the discriminant used by `Result` for
|
|
316
|
+
* route-declared errors and is not part of `ClientErrorMap`. Attempting to
|
|
317
|
+
* register it produces a TS error about overlapping discriminants.
|
|
318
|
+
*/
|
|
319
|
+
export interface ClientErrorMap {
|
|
320
|
+
http: ClientHttpError
|
|
321
|
+
network: ClientNetworkError
|
|
322
|
+
timeout: ClientTimeoutError
|
|
323
|
+
aborted: ClientAbortError
|
|
324
|
+
parse: ClientParseError
|
|
325
|
+
usage: ClientPathParamError
|
|
326
|
+
unknown: unknown
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Distributed union of every framework failure kind. */
|
|
330
|
+
export type FrameworkFailure = {
|
|
331
|
+
[K in keyof ClientErrorMap]: { ok: false; kind: K; error: ClientErrorMap[K] }
|
|
332
|
+
}[keyof ClientErrorMap]
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Discriminated result type for the `.safe()` form. `kind: 'typed'` carries
|
|
336
|
+
* route-declared errors (registry-dispatched); other kinds carry framework
|
|
337
|
+
* failures. Use `ResultNoTyped<T>` for routes without declared errors.
|
|
338
|
+
*/
|
|
339
|
+
export type Result<T, ETyped> =
|
|
340
|
+
| { ok: true; value: T }
|
|
341
|
+
| { ok: false; kind: 'typed'; error: ETyped }
|
|
342
|
+
| FrameworkFailure
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* `Result` for routes that don't declare typed errors. Omits the `'typed'`
|
|
346
|
+
* arm entirely so IDE hovers stay clean (TS doesn't collapse `never`-payload
|
|
347
|
+
* arms in tooltip output).
|
|
348
|
+
*/
|
|
349
|
+
export type ResultNoTyped<T> =
|
|
350
|
+
| { ok: true; value: T }
|
|
351
|
+
| FrameworkFailure
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { generateClient } from './index.js'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import * as os from 'node:os'
|
|
5
|
+
import * as fs from 'node:fs/promises'
|
|
6
|
+
import type { DocEnvelope } from '../implementations/types.js'
|
|
7
|
+
|
|
8
|
+
function makeEnvelope(routeCount: number): DocEnvelope {
|
|
9
|
+
const routes = Array.from({ length: routeCount }, (_, i) => ({
|
|
10
|
+
kind: 'rpc' as const,
|
|
11
|
+
name: `Op${i}`,
|
|
12
|
+
scope: 'ops',
|
|
13
|
+
method: 'post' as const,
|
|
14
|
+
path: `/ops/op${i}`,
|
|
15
|
+
version: 1,
|
|
16
|
+
jsonSchema: {
|
|
17
|
+
body: { type: 'object' as const, properties: { x: { type: 'string' as const } } },
|
|
18
|
+
response: { type: 'object' as const, properties: { y: { type: 'string' as const } } },
|
|
19
|
+
},
|
|
20
|
+
errors: [] as string[],
|
|
21
|
+
}))
|
|
22
|
+
return { basePath: '/api', headers: [], errors: [], routes }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('bundle size budget', () => {
|
|
26
|
+
let perRouteDelta: number
|
|
27
|
+
let totalChars: number
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'tsproc-bundle-'))
|
|
31
|
+
await generateClient({
|
|
32
|
+
envelope: makeEnvelope(100),
|
|
33
|
+
outDir: tmp,
|
|
34
|
+
selfContained: false,
|
|
35
|
+
})
|
|
36
|
+
const scopeFile = await fs.readFile(path.join(tmp, 'ops.ts'), 'utf-8')
|
|
37
|
+
|
|
38
|
+
// Strip whitespace, line comments, block comments. This is a stable proxy
|
|
39
|
+
// for minification — actual minified bytes will be lower, but the *delta*
|
|
40
|
+
// between commits is what this test guards against.
|
|
41
|
+
const stripped = scopeFile
|
|
42
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
|
43
|
+
.replace(/\/\/.*$/gm, '') // line comments
|
|
44
|
+
.replace(/\s+/g, ' ') // collapse whitespace
|
|
45
|
+
totalChars = stripped.length
|
|
46
|
+
perRouteDelta = totalChars / 100
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('stays within 1500 chars per route post-strip', () => {
|
|
50
|
+
// Initial budget — refine after first measurement. The .safe sibling adds
|
|
51
|
+
// roughly 400 chars of unminified emission per route (Object.assign wrapper
|
|
52
|
+
// + duplicated descriptor literal). Post-strip should land well under 1500.
|
|
53
|
+
//
|
|
54
|
+
// RELATIONSHIP TO SPEC TARGET: The spec sets a "200 bytes per route
|
|
55
|
+
// post-minify" goal. This whitespace-strip proxy is a coarse stand-in
|
|
56
|
+
// chosen to avoid an esbuild dep — actual minified bytes will be lower
|
|
57
|
+
// than `perRouteDelta` here. If a future task swaps in real esbuild
|
|
58
|
+
// minification, tighten the budget to ~250 bytes (200 + 25% headroom)
|
|
59
|
+
// and link back to spec section "Tests / item 10". For now this guard
|
|
60
|
+
// catches catastrophic regressions only, not subtle bloat.
|
|
61
|
+
//
|
|
62
|
+
// PERROUTEDELTA_BASELINE: 613.2 (7.0.0-beta.0, Object.assign inline emission)
|
|
63
|
+
// After bindCallable refactor (beta.1): baseline dropped to 218.8 (~64% reduction)
|
|
64
|
+
// — Object.assign + duplicated descriptor removed; one helper call per route.
|
|
65
|
+
expect(perRouteDelta).toBeLessThan(1500)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('logs the baseline measurement for review', () => {
|
|
69
|
+
// This isn't a strict assertion — it surfaces the actual numbers to the
|
|
70
|
+
// test output so reviewers can update PERROUTEDELTA_BASELINE in the
|
|
71
|
+
// comment above without having to re-run locally.
|
|
72
|
+
// eslint-disable-next-line no-console
|
|
73
|
+
console.log(`[bundle-size] 100 routes, scope file = ${totalChars} chars stripped, per-route = ${perRouteDelta.toFixed(1)}`)
|
|
74
|
+
expect(totalChars).toBeGreaterThan(0)
|
|
75
|
+
})
|
|
76
|
+
})
|
package/src/codegen/e2e.test.ts
CHANGED
|
@@ -223,12 +223,12 @@ describe('E2E: generateClient full pipeline', () => {
|
|
|
223
223
|
expect(content).toContain('bindUsersScope')
|
|
224
224
|
})
|
|
225
225
|
|
|
226
|
-
it('users.ts uses client.
|
|
226
|
+
it('users.ts uses client.bindCallable for RPC route', async () => {
|
|
227
227
|
tmpDir = makeTmpDir()
|
|
228
228
|
await generateClient({ envelope, outDir: tmpDir })
|
|
229
229
|
|
|
230
230
|
const content = readFileSync(join(tmpDir, 'users.ts'), 'utf-8')
|
|
231
|
-
expect(content).toContain('client.
|
|
231
|
+
expect(content).toContain('client.bindCallable<')
|
|
232
232
|
})
|
|
233
233
|
|
|
234
234
|
// ── events.ts — Stream route ───────────────────────────────────────────────
|
|
@@ -422,7 +422,7 @@ describe('E2E: generateClient full pipeline', () => {
|
|
|
422
422
|
expect(content).toContain('ProcedureCallOptions')
|
|
423
423
|
})
|
|
424
424
|
|
|
425
|
-
it('_client.ts is generated and contains createClient, createFetchAdapter, ClientRequestError', async () => {
|
|
425
|
+
it('_client.ts is generated and contains createClient, createFetchAdapter, ClientHttpError (+ deprecated ClientRequestError alias)', async () => {
|
|
426
426
|
tmpDir = makeTmpDir()
|
|
427
427
|
await generateClient({ envelope, outDir: tmpDir, selfContained: true })
|
|
428
428
|
|
|
@@ -430,6 +430,7 @@ describe('E2E: generateClient full pipeline', () => {
|
|
|
430
430
|
const content = readFileSync(join(tmpDir, '_client.ts'), 'utf-8')
|
|
431
431
|
expect(content).toContain('createClient')
|
|
432
432
|
expect(content).toContain('createFetchAdapter')
|
|
433
|
+
expect(content).toContain('ClientHttpError')
|
|
433
434
|
expect(content).toContain('ClientRequestError')
|
|
434
435
|
})
|
|
435
436
|
|
|
@@ -584,6 +585,112 @@ void run
|
|
|
584
585
|
}).not.toThrow()
|
|
585
586
|
})
|
|
586
587
|
|
|
588
|
+
it('generated .safe callable type-checks against bundled Result/ResultNoTyped types', async () => {
|
|
589
|
+
// Use an envelope with two routes:
|
|
590
|
+
// - GetUser: has errors: ['ProcedureError'] → .safe returns Result<T, GetUserErrors>
|
|
591
|
+
// - ListUsers: has errors: [] → .safe returns ResultNoTyped<T>
|
|
592
|
+
// This verifies both the typed-error and no-typed-error branches of .safe.
|
|
593
|
+
tmpDir = makeTmpDir()
|
|
594
|
+
const safeEnvelope: DocEnvelope = {
|
|
595
|
+
basePath: '/api',
|
|
596
|
+
headers: [],
|
|
597
|
+
errors: [procedureErrorDoc],
|
|
598
|
+
routes: [
|
|
599
|
+
{
|
|
600
|
+
kind: 'rpc',
|
|
601
|
+
name: 'GetUser',
|
|
602
|
+
path: '/users/1',
|
|
603
|
+
method: 'post',
|
|
604
|
+
scope: 'users',
|
|
605
|
+
version: 1,
|
|
606
|
+
errors: ['ProcedureError'],
|
|
607
|
+
jsonSchema: {
|
|
608
|
+
body: {
|
|
609
|
+
type: 'object',
|
|
610
|
+
properties: { id: { type: 'string' } },
|
|
611
|
+
required: ['id'],
|
|
612
|
+
},
|
|
613
|
+
response: {
|
|
614
|
+
type: 'object',
|
|
615
|
+
properties: { name: { type: 'string' } },
|
|
616
|
+
required: ['name'],
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
} as RPCHttpRouteDoc,
|
|
620
|
+
{
|
|
621
|
+
kind: 'rpc',
|
|
622
|
+
name: 'ListUsers',
|
|
623
|
+
path: '/users',
|
|
624
|
+
method: 'post',
|
|
625
|
+
scope: 'users',
|
|
626
|
+
version: 1,
|
|
627
|
+
errors: [],
|
|
628
|
+
jsonSchema: {
|
|
629
|
+
body: { type: 'object' },
|
|
630
|
+
response: {
|
|
631
|
+
type: 'array',
|
|
632
|
+
items: {
|
|
633
|
+
type: 'object',
|
|
634
|
+
properties: { name: { type: 'string' } },
|
|
635
|
+
required: ['name'],
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
} as RPCHttpRouteDoc,
|
|
640
|
+
],
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
await generateClient({ envelope: safeEnvelope, outDir: tmpDir, selfContained: true })
|
|
644
|
+
|
|
645
|
+
// Consumer exercises .safe on both routes and verifies the Result types
|
|
646
|
+
// are assignable (type-only assertions via declared variables).
|
|
647
|
+
const consumer = `
|
|
648
|
+
import type { Result, ResultNoTyped } from './_types'
|
|
649
|
+
import { createApiClient } from './index'
|
|
650
|
+
import { createFetchAdapter } from './_client'
|
|
651
|
+
|
|
652
|
+
const client = createApiClient({
|
|
653
|
+
adapter: createFetchAdapter(),
|
|
654
|
+
basePath: 'https://api.example.com',
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
// Throwing form still compiles
|
|
658
|
+
const _p1: Promise<{ name: string }> = client.users.GetUser({ id: '1' })
|
|
659
|
+
void _p1
|
|
660
|
+
|
|
661
|
+
// .safe on a route with declared errors returns Result<T, Errors>
|
|
662
|
+
const _p2 = client.users.GetUser.safe({ id: '1' })
|
|
663
|
+
const _p2check: Promise<Result<{ name: string }, unknown>> = _p2 as Promise<Result<{ name: string }, unknown>>
|
|
664
|
+
void _p2check
|
|
665
|
+
|
|
666
|
+
// .safe on a route without declared errors returns ResultNoTyped<T>
|
|
667
|
+
const _p3 = client.users.ListUsers.safe({})
|
|
668
|
+
const _p3check: Promise<ResultNoTyped<unknown>> = _p3 as Promise<ResultNoTyped<unknown>>
|
|
669
|
+
void _p3check
|
|
670
|
+
`
|
|
671
|
+
const { writeFileSync } = await import('node:fs')
|
|
672
|
+
writeFileSync(join(tmpDir, 'consumer.ts'), consumer)
|
|
673
|
+
|
|
674
|
+
const tsconfig = {
|
|
675
|
+
compilerOptions: {
|
|
676
|
+
strict: true,
|
|
677
|
+
target: 'ES2022',
|
|
678
|
+
module: 'ES2022',
|
|
679
|
+
moduleResolution: 'bundler',
|
|
680
|
+
noEmit: true,
|
|
681
|
+
skipLibCheck: true,
|
|
682
|
+
},
|
|
683
|
+
include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', '_errors.ts', 'consumer.ts'],
|
|
684
|
+
}
|
|
685
|
+
writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
|
|
686
|
+
|
|
687
|
+
const { execSync } = await import('node:child_process')
|
|
688
|
+
const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
|
|
689
|
+
expect(() => {
|
|
690
|
+
execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
|
|
691
|
+
}).not.toThrow()
|
|
692
|
+
})
|
|
693
|
+
|
|
587
694
|
it('augmented RequestMeta rejects wrong types (compile error)', async () => {
|
|
588
695
|
tmpDir = makeTmpDir()
|
|
589
696
|
await generateClient({ envelope, outDir: tmpDir, selfContained: true })
|
|
@@ -657,10 +764,9 @@ void run
|
|
|
657
764
|
await generateClient({ envelope, outDir: tmpDir, namespaceTypes: true })
|
|
658
765
|
|
|
659
766
|
const content = readFileSync(join(tmpDir, 'users.ts'), 'utf-8')
|
|
660
|
-
|
|
661
|
-
expect(content).toContain('
|
|
662
|
-
expect(content).toContain('
|
|
663
|
-
expect(content).toContain('Promise<Users.UpdateUser.Response>')
|
|
767
|
+
// New emission: client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>({...})
|
|
768
|
+
expect(content).toContain('client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>')
|
|
769
|
+
expect(content).toContain('client.bindCallable<Users.UpdateUser.Params, Users.UpdateUser.Response>')
|
|
664
770
|
})
|
|
665
771
|
|
|
666
772
|
it('events.ts wraps stream types in namespace', async () => {
|
|
@@ -46,9 +46,14 @@ describe('emitClientRuntimeFile', () => {
|
|
|
46
46
|
expect(result).toMatch(/export function createFetchAdapter/)
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
-
it('contains export class ClientRequestError', async () => {
|
|
49
|
+
it('contains export class ClientHttpError (renamed from ClientRequestError)', async () => {
|
|
50
50
|
const result = await emitClientRuntimeFile()
|
|
51
|
-
expect(result).toMatch(/export class
|
|
51
|
+
expect(result).toMatch(/export class ClientHttpError/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('contains deprecated ClientRequestError alias', async () => {
|
|
55
|
+
const result = await emitClientRuntimeFile()
|
|
56
|
+
expect(result).toContain('ClientRequestError')
|
|
52
57
|
})
|
|
53
58
|
|
|
54
59
|
it('contains export class ClientPathParamError', async () => {
|
|
@@ -23,6 +23,13 @@ const TYPES_IMPORT = `import type {
|
|
|
23
23
|
ErrorRegistry,
|
|
24
24
|
ErrorFactory,
|
|
25
25
|
ErrorResponseMeta,
|
|
26
|
+
ErrorClassifier,
|
|
27
|
+
ClassifyErrorContext,
|
|
28
|
+
ClassifiedError,
|
|
29
|
+
Result,
|
|
30
|
+
ResultNoTyped,
|
|
31
|
+
ClientErrorMap,
|
|
32
|
+
FrameworkFailure,
|
|
26
33
|
} from './_types'`
|
|
27
34
|
|
|
28
35
|
/**
|
|
@@ -31,6 +38,7 @@ const TYPES_IMPORT = `import type {
|
|
|
31
38
|
*/
|
|
32
39
|
const SOURCE_FILES = [
|
|
33
40
|
'errors.ts',
|
|
41
|
+
'classify-error.ts',
|
|
34
42
|
'error-dispatch.ts',
|
|
35
43
|
'request-builder.ts',
|
|
36
44
|
'resolve-options.ts',
|
|
@@ -19,21 +19,36 @@ describe('emitClientTypesFile', () => {
|
|
|
19
19
|
|
|
20
20
|
it('no import statements', async () => {
|
|
21
21
|
const result = await emitClientTypesFile()
|
|
22
|
-
//
|
|
22
|
+
// errors.ts and types.ts imports are stripped — the emitted file should be fully self-contained
|
|
23
23
|
expect(result).not.toMatch(/^import\s/m)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
it('
|
|
26
|
+
it('includes errors.ts content before types.ts content', async () => {
|
|
27
27
|
const result = await emitClientTypesFile()
|
|
28
28
|
|
|
29
29
|
const __filename = fileURLToPath(import.meta.url)
|
|
30
30
|
const __dirname = dirname(__filename)
|
|
31
31
|
const packageRoot = resolve(__dirname, '../..')
|
|
32
|
-
const typesPath = resolve(packageRoot, 'src/client/types.ts')
|
|
33
|
-
const typesContent = await readFile(typesPath, 'utf-8')
|
|
34
32
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
expect(result).
|
|
33
|
+
// Check that both files' unique identifiers are present
|
|
34
|
+
// errors.ts contains class definitions
|
|
35
|
+
expect(result).toContain('class ClientHttpError')
|
|
36
|
+
expect(result).toContain('class ClientNetworkError')
|
|
37
|
+
// types.ts contains interfaces
|
|
38
|
+
expect(result).toContain('interface RequestMeta')
|
|
39
|
+
expect(result).toContain('interface ClientAdapter')
|
|
40
|
+
|
|
41
|
+
// errors.ts content should appear before types.ts content
|
|
42
|
+
const errorsIdx = result.indexOf('class ClientHttpError')
|
|
43
|
+
const typesIdx = result.indexOf('interface RequestMeta')
|
|
44
|
+
expect(errorsIdx).toBeLessThan(typesIdx)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('includes Result, ResultNoTyped, ClientErrorMap from types.ts', async () => {
|
|
48
|
+
const result = await emitClientTypesFile()
|
|
49
|
+
expect(result).toContain('interface ClientErrorMap')
|
|
50
|
+
expect(result).toContain('type FrameworkFailure')
|
|
51
|
+
expect(result).toContain('type Result<T, ETyped>')
|
|
52
|
+
expect(result).toContain('type ResultNoTyped<T>')
|
|
38
53
|
})
|
|
39
54
|
})
|
|
@@ -4,8 +4,26 @@ import { readFile, access } from 'node:fs/promises'
|
|
|
4
4
|
import { CODEGEN_HEADER } from './constants.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Strips all `import` statements from source content.
|
|
8
|
+
* These are inter-file imports that become unnecessary in a single bundled file.
|
|
9
|
+
*
|
|
10
|
+
* Note: the regex requires a `from` clause, so side-effect imports like
|
|
11
|
+
* `import './polyfill.js'` are NOT stripped. This is intentional — src/client/
|
|
12
|
+
* has no side-effect imports.
|
|
13
|
+
*/
|
|
14
|
+
function stripImports(content: string): string {
|
|
15
|
+
// Handle both single-line and multi-line import statements
|
|
16
|
+
return content.replace(/^import\s[\s\S]*?from\s+['"][^'"]+['"]\s*;?\s*$/gm, '')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads `src/client/errors.ts` and `src/client/types.ts` from the package
|
|
21
|
+
* root and returns them bundled as the content of a `_types.ts` file,
|
|
22
|
+
* prepended with the auto-generated header.
|
|
23
|
+
*
|
|
24
|
+
* `errors.ts` is included first (with imports stripped) because `types.ts`
|
|
25
|
+
* imports the error classes from it. Together they form a fully self-contained
|
|
26
|
+
* `_types.ts` with no external dependencies.
|
|
9
27
|
*
|
|
10
28
|
* This enables a self-contained codegen mode where consumers don't need
|
|
11
29
|
* `ts-procedures` as a runtime dependency.
|
|
@@ -15,13 +33,20 @@ export async function emitClientTypesFile(): Promise<string> {
|
|
|
15
33
|
const __dirname = dirname(__filename)
|
|
16
34
|
// Works from both src/codegen/ (source) and build/codegen/ (compiled)
|
|
17
35
|
const packageRoot = resolve(__dirname, '../..')
|
|
36
|
+
const errorsPath = resolve(packageRoot, 'src/client/errors.ts')
|
|
18
37
|
const typesPath = resolve(packageRoot, 'src/client/types.ts')
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
|
|
39
|
+
for (const [label, filePath] of [['errors.ts', errorsPath], ['types.ts', typesPath]] as const) {
|
|
40
|
+
await access(filePath).catch(() => {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`[ts-procedures-codegen] Cannot locate src/client/${label} at expected path: ${filePath}. ` +
|
|
43
|
+
`Ensure ts-procedures is installed correctly.`
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const errorsContent = stripImports(await readFile(errorsPath, 'utf-8')).trim()
|
|
49
|
+
const typesContent = stripImports(await readFile(typesPath, 'utf-8')).trim()
|
|
50
|
+
|
|
51
|
+
return [CODEGEN_HEADER, '', errorsContent, '', typesContent, ''].join('\n')
|
|
27
52
|
}
|
|
@@ -26,7 +26,7 @@ export interface EmitErrorsOptions {
|
|
|
26
26
|
*
|
|
27
27
|
* The registry `<ServiceName>ErrorRegistry` maps body `name` values to
|
|
28
28
|
* classes, consumed by the client's `dispatchTypedError` to produce typed
|
|
29
|
-
* errors instead of generic `
|
|
29
|
+
* errors instead of generic `ClientHttpError` instances.
|
|
30
30
|
*
|
|
31
31
|
* When `namespaceTypes` is on, everything is wrapped in `export namespace
|
|
32
32
|
* <ServiceName>Errors { ... }`. Returns `undefined` if no errors have schemas.
|
|
@@ -112,7 +112,7 @@ export function emitIndexFile(groups: ScopeGroup[], options?: EmitIndexOptions):
|
|
|
112
112
|
` * Creates a typed client for this service with the generated error`,
|
|
113
113
|
` * registry pre-configured. Non-2xx responses whose body \`name\` matches`,
|
|
114
114
|
` * a registered error are thrown as typed class instances instead of`,
|
|
115
|
-
` * generic \`
|
|
115
|
+
` * generic \`ClientHttpError\`s.`,
|
|
116
116
|
` */`,
|
|
117
117
|
`export function ${clientFactoryName}(`,
|
|
118
118
|
` config: Omit<CreateClientConfig<ReturnType<typeof ${factoryName}>>, 'scopes' | 'errorRegistry'>`,
|