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/README.md
CHANGED
|
@@ -52,6 +52,8 @@ const user2 = await procedure({}, { userId: '456' })
|
|
|
52
52
|
|
|
53
53
|
- **[Typed Error Handling](docs/http-integrations.md#error-handling)** — Declarative `defineErrorTaxonomy` maps thrown error classes to HTTP responses across every builder; generated clients throw typed class instances you can catch with `instanceof`.
|
|
54
54
|
|
|
55
|
+
- **[Client Error Handling](docs/client-error-handling.md)** — Normalized framework error classes (`ClientHttpError`, `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`, `ClientParseError`), `.safe()` Result API for exhaustive narrowing without try/catch, and augmentable `ClientErrorMap` for custom error categories.
|
|
56
|
+
|
|
55
57
|
- **[AI Agent Setup](docs/ai-agent-setup.md)** — Built-in configuration for Claude Code, Cursor, and GitHub Copilot. Auto-updates on `npm install`.
|
|
56
58
|
|
|
57
59
|
Full documentation is available on [GitHub](https://github.com/thermsio/ts-procedures).
|
|
@@ -175,7 +175,8 @@ The npm package ships user-facing documentation with narrative explanations and
|
|
|
175
175
|
| `docs/streaming.md` | Streaming procedures, AbortSignal, SSE patterns |
|
|
176
176
|
| `docs/http-integrations.md` | Express RPC, Hono RPC/Stream/API builders, **error taxonomy (canonical)**, DocRegistry (unified constructor, `.documentError()`) |
|
|
177
177
|
| `docs/client-and-codegen.md` | Client code generation, `createApiClient`/`createClient`, typed error dispatch, per-route `Errors` unions, per-call options, client-level defaults, typed RequestMeta augmentation, CLI options |
|
|
178
|
-
| `
|
|
178
|
+
| `docs/client-error-handling.md` | **Canonical guide** for 7.0+ client error surface: normalized error classes, `.safe()` Result API, `ClientErrorMap` augmentation, custom `ErrorClassifier`, migration from `ClientRequestError`. |
|
|
179
|
+
| `CHANGELOG.md` | Release notes — see `[7.0.0]` for the safe-result API, new error classes, and `ClientRequestError` → `ClientHttpError` rename. See `[6.0.0]` for the peer error-handling model (taxonomy + `onError` + `onRequestError`). |
|
|
179
180
|
|
|
180
181
|
## Workflow
|
|
181
182
|
|
|
@@ -612,7 +612,7 @@ Create('GetUser', {
|
|
|
612
612
|
|
|
613
613
|
## 20. Hand-writing `onError` instanceof ladders
|
|
614
614
|
|
|
615
|
-
**Problem:** Writing manual `instanceof` ladders inside `onError` to map each error class to a status + body. Every builder gets its own copy; generated clients see opaque `
|
|
615
|
+
**Problem:** Writing manual `instanceof` ladders inside `onError` to map each error class to a status + body. Every builder gets its own copy; generated clients see opaque `ClientHttpError` objects instead of typed instances; response shapes drift between routes.
|
|
616
616
|
|
|
617
617
|
```typescript
|
|
618
618
|
// BAD — duplicated across builders, invisible to generated clients
|
|
@@ -659,6 +659,42 @@ The same `errors` / `unknownError` shape plugs into every builder (`HonoAPIAppBu
|
|
|
659
659
|
|
|
660
660
|
---
|
|
661
661
|
|
|
662
|
+
## 21. Catching raw `DOMException` or `TypeError` from generated callables
|
|
663
|
+
|
|
664
|
+
**Problem:** Checking `instanceof DOMException` or `instanceof TypeError` in `catch` blocks after calling a generated RPC/API callable. The framework normalizes these platform errors into typed framework classes at the `executeCall` boundary — after 7.0.0, raw platform errors will not reach your `catch` block from a generated callable. The original platform error is preserved on `error.cause` if you need it.
|
|
665
|
+
|
|
666
|
+
```typescript
|
|
667
|
+
// BAD — platform error classes won't reach here from a generated callable
|
|
668
|
+
catch (e) {
|
|
669
|
+
if (e instanceof DOMException && e.name === 'AbortError') {
|
|
670
|
+
// Was this a timeout or a user cancel? Hard to tell.
|
|
671
|
+
}
|
|
672
|
+
if (e instanceof TypeError) {
|
|
673
|
+
// Network failure? Something else?
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
**Fix:** Catch the framework error classes instead.
|
|
679
|
+
|
|
680
|
+
```typescript
|
|
681
|
+
// GOOD
|
|
682
|
+
import { ClientTimeoutError, ClientAbortError, ClientNetworkError } from 'ts-procedures/client'
|
|
683
|
+
|
|
684
|
+
catch (e) {
|
|
685
|
+
if (e instanceof ClientTimeoutError) Alerts.error('Timed out')
|
|
686
|
+
else if (e instanceof ClientAbortError) { /* user cancelled — silent */ }
|
|
687
|
+
else if (e instanceof ClientNetworkError) Alerts.error('Network error')
|
|
688
|
+
else throw e // unknown — let it propagate
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
Or use the `.safe()` sibling for exhaustive Result-based narrowing (see `patterns.md` — "Handling errors in client code").
|
|
693
|
+
|
|
694
|
+
**Why:** The framework wraps `AbortError` (from timeout or signal) into `ClientTimeoutError` / `ClientAbortError` and `TypeError`/fetch network failures into `ClientNetworkError`. This gives you distinct, meaningful types and removes ambiguity. Streams keep the throwing form — `.safe()` is only available on RPC and API callables.
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
662
698
|
## Summary Table
|
|
663
699
|
|
|
664
700
|
| # | Anti-Pattern | Risk | Severity |
|
|
@@ -683,3 +719,4 @@ The same `errors` / `unknownError` shape plugs into every builder (`HonoAPIAppBu
|
|
|
683
719
|
| 18 | Both schema.params and schema.input | ProcedureRegistrationError at startup | CRITICAL |
|
|
684
720
|
| 19 | Mismatched path param names | Build-time error or confusing validation failures | CRITICAL |
|
|
685
721
|
| 20 | Hand-writing onError instanceof ladders | Drifting response shapes, untyped client errors | WARNING |
|
|
722
|
+
| 21 | Catching raw DOMException/TypeError from generated callables | Framework normalizes these; raw platform errors won't reach catch blocks after 7.0.0 | WARNING |
|
|
@@ -354,7 +354,7 @@ Returns a typed error instance when:
|
|
|
354
354
|
- `body` is an object with a string `name`, AND
|
|
355
355
|
- `registry[body.name].fromResponse(body, meta)` returns an `Error` subclass.
|
|
356
356
|
|
|
357
|
-
Otherwise returns `null`; callers fall back to `
|
|
357
|
+
Otherwise returns `null`; callers fall back to `ClientHttpError`.
|
|
358
358
|
|
|
359
359
|
### CreateClientConfig.errorRegistry
|
|
360
360
|
|
|
@@ -369,6 +369,227 @@ Threaded into both `executeCall` and `executeStream`. The generated `create${Ser
|
|
|
369
369
|
|
|
370
370
|
---
|
|
371
371
|
|
|
372
|
+
## Client Error Classes (7.0+)
|
|
373
|
+
|
|
374
|
+
All framework errors are normalized at the `executeCall` / `executeStream` boundary — raw `TypeError` and `DOMException` from the platform never reach consumer catch blocks.
|
|
375
|
+
|
|
376
|
+
### ClientHttpError
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
class ClientHttpError extends Error {
|
|
380
|
+
readonly status: number
|
|
381
|
+
readonly cause?: unknown
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Thrown for non-2xx HTTP responses whose body does not match any registry entry (or when no registry is configured). Renamed from `ClientRequestError` in 7.0.0; the old name is re-exported as a deprecated alias for one minor cycle.
|
|
386
|
+
|
|
387
|
+
### ClientNetworkError
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
class ClientNetworkError extends Error {
|
|
391
|
+
readonly cause?: unknown
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Thrown when the network request itself fails (e.g., DNS failure, connection refused — a `TypeError` from `fetch`). The original error is available on `cause`.
|
|
396
|
+
|
|
397
|
+
### ClientTimeoutError
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
class ClientTimeoutError extends Error {
|
|
401
|
+
readonly cause?: unknown
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Thrown when the request is aborted by a timeout (`AbortSignal.timeout`). The original `DOMException` is available on `cause`.
|
|
406
|
+
|
|
407
|
+
### ClientAbortError
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
class ClientAbortError extends Error {
|
|
411
|
+
readonly cause?: unknown
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Thrown when the request is aborted by a caller-supplied `AbortSignal`. The original `DOMException` is available on `cause`.
|
|
416
|
+
|
|
417
|
+
### ClientParseError
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
class ClientParseError extends Error {
|
|
421
|
+
readonly cause?: unknown
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Thrown when the response body cannot be parsed as JSON (e.g., the server returned HTML for a non-2xx status). The parse error is available on `cause`.
|
|
426
|
+
|
|
427
|
+
### ClientPathParamError
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
class ClientPathParamError extends Error {}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Thrown at call time when a required path parameter is missing in the options. This is a programming error (the generated callable could not interpolate the URL path).
|
|
434
|
+
|
|
435
|
+
### ClientStreamError
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
class ClientStreamError extends Error {
|
|
439
|
+
readonly cause?: unknown
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Thrown for stream-level transport errors (SSE connection failures, unexpected stream termination).
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## Safe-Result API (7.0+)
|
|
448
|
+
|
|
449
|
+
### Result\<T, ETyped\>
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
type Result<T, ETyped = never> =
|
|
453
|
+
| { ok: true; value: T }
|
|
454
|
+
| FrameworkFailure<ETyped>
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Returned by every generated `.safe()` callable. When `ok` is `true`, `value` is the typed response. When `ok` is `false`, the failure is discriminated by `kind`.
|
|
458
|
+
|
|
459
|
+
### ResultNoTyped\<T\>
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
type ResultNoTyped<T> = Result<T, never>
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Convenience alias when no typed error registry is wired (all failures are framework-level).
|
|
466
|
+
|
|
467
|
+
### FrameworkFailure\<ETyped\>
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
type FrameworkFailure<ETyped = never> =
|
|
471
|
+
| { ok: false; kind: 'typed'; error: ETyped }
|
|
472
|
+
| { ok: false; kind: 'http'; error: ClientHttpError }
|
|
473
|
+
| { ok: false; kind: 'network'; error: ClientNetworkError }
|
|
474
|
+
| { ok: false; kind: 'timeout'; error: ClientTimeoutError }
|
|
475
|
+
| { ok: false; kind: 'aborted'; error: ClientAbortError }
|
|
476
|
+
| { ok: false; kind: 'parse'; error: ClientParseError }
|
|
477
|
+
| { ok: false; kind: 'usage'; error: ClientPathParamError }
|
|
478
|
+
| { ok: false; kind: 'unknown'; error: unknown }
|
|
479
|
+
// + any augmented kinds from ClientErrorMap
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
The failure branch of `Result`. Discriminate on `kind` to narrow `error`.
|
|
483
|
+
|
|
484
|
+
### ClientErrorMap
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
interface ClientErrorMap {
|
|
488
|
+
// empty — designed for declaration merging
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Augment this interface to add custom `kind` entries to `FrameworkFailure`. Example:
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
declare module 'ts-procedures/client' {
|
|
496
|
+
interface ClientErrorMap {
|
|
497
|
+
rateLimit: RateLimitError
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
After augmentation, `r.kind === 'rateLimit'` is a valid branch in a `switch` over `FrameworkFailure`.
|
|
503
|
+
|
|
504
|
+
### ClientInstance.safeCall\<TResponse, ETyped\>(descriptor, options)
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
interface ClientInstance {
|
|
508
|
+
safeCall<TResponse, ETyped = never>(
|
|
509
|
+
descriptor: CallDescriptor,
|
|
510
|
+
options?: ProcedureCallOptions
|
|
511
|
+
): Promise<Result<TResponse, ETyped>>
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
The underlying method that all generated `.safe()` callables delegate to. When `ETyped` is provided, it is the type of the `typed` failure branch (the registry-dispatched error class). Streams do not expose a `.safe()` sibling — `safeCall` applies to RPC and API calls only.
|
|
516
|
+
|
|
517
|
+
### ClientInstance.bindCallable / bindCallableTyped
|
|
518
|
+
|
|
519
|
+
Helper methods used by codegen to wire each generated callable. Returns a callable function with a `.safe` sibling. The returned function's `.name` is set to `descriptor.name` for nicer stack traces.
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
// For routes without typed errors
|
|
523
|
+
bindCallable<TParams, TResponse>(
|
|
524
|
+
descriptor: Omit<CallDescriptor, 'params'>,
|
|
525
|
+
): {
|
|
526
|
+
(params: TParams, options?: ProcedureCallOptions): Promise<TResponse>
|
|
527
|
+
safe(params: TParams, options?: ProcedureCallOptions): Promise<ResultNoTyped<TResponse>>
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// For routes with typed errors
|
|
531
|
+
bindCallableTyped<TParams, TResponse, ETyped>(
|
|
532
|
+
descriptor: Omit<CallDescriptor, 'params'>,
|
|
533
|
+
): {
|
|
534
|
+
(params: TParams, options?: ProcedureCallOptions): Promise<TResponse>
|
|
535
|
+
safe(params: TParams, options?: ProcedureCallOptions): Promise<Result<TResponse, ETyped>>
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
`descriptor` is `Omit<CallDescriptor, 'params'>` — params is supplied at call time. Codegen emits a compact one-liner per route:
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
// Generated (no declared errors):
|
|
543
|
+
GetUser: client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>({
|
|
544
|
+
name: 'GetUser', scope: 'users', path: '/users/1', method: 'post', kind: 'rpc',
|
|
545
|
+
}),
|
|
546
|
+
|
|
547
|
+
// Generated (with declared errors):
|
|
548
|
+
GetUser: client.bindCallableTyped<Users.GetUser.Params, Users.GetUser.Response, Users.GetUser.Errors>({
|
|
549
|
+
name: 'GetUser', scope: 'users', path: '/users/1', method: 'post', kind: 'rpc',
|
|
550
|
+
}),
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
Hand-written clients that mimic the codegen pattern can also use these helpers directly on the `ClientInstance` passed to the `scopes` factory.
|
|
554
|
+
|
|
555
|
+
---
|
|
556
|
+
|
|
557
|
+
## Error Classifier API (7.0+)
|
|
558
|
+
|
|
559
|
+
### defaultClassifyError
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
function defaultClassifyError(err: unknown, ctx: ClassifyErrorContext): ClassifiedError | null
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The built-in classifier that maps raw platform errors to framework classes. Returns a `ClassifiedError` (with `kind` + `error`) when the error is recognized, or `null` to fall through to `unknown`. Pass a custom `ErrorClassifier` to `createFetchAdapter` / `ClientAdapter` to override or extend.
|
|
566
|
+
|
|
567
|
+
### ErrorClassifier
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
type ErrorClassifier = (err: unknown, ctx: ClassifyErrorContext) => ClassifiedError | null
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
### ClassifyErrorContext
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
interface ClassifyErrorContext {
|
|
577
|
+
request: AdapterRequest
|
|
578
|
+
signal?: AbortSignal
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
### ClassifiedError
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
interface ClassifiedError {
|
|
586
|
+
kind: keyof ClientErrorMap | 'network' | 'timeout' | 'aborted' | 'parse' | 'unknown'
|
|
587
|
+
error: Error
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
372
593
|
## Schema Utilities
|
|
373
594
|
|
|
374
595
|
### extractJsonSchema(libSchema)
|
|
@@ -986,12 +1207,14 @@ Creates a `ClientAdapter` using the global `fetch` API.
|
|
|
986
1207
|
```typescript
|
|
987
1208
|
function createFetchAdapter(options?: {
|
|
988
1209
|
fetch?: typeof globalThis.fetch
|
|
1210
|
+
classifyError?: ErrorClassifier
|
|
989
1211
|
}): ClientAdapter
|
|
990
1212
|
```
|
|
991
1213
|
|
|
992
1214
|
### Parameters
|
|
993
1215
|
|
|
994
1216
|
- `options.fetch` — Optional custom fetch implementation. Defaults to `globalThis.fetch`.
|
|
1217
|
+
- `options.classifyError` — Optional classifier override. When provided, replaces `defaultClassifyError` for mapping raw platform errors to framework classes. Use this to add custom `ClientErrorMap` categories or to adjust the default mapping.
|
|
995
1218
|
|
|
996
1219
|
### Return Value
|
|
997
1220
|
|
|
@@ -1212,8 +1435,35 @@ import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
|
|
|
1212
1435
|
import type { APIConfig, APIHttpRouteDoc, APIInput, HttpMethod } from 'ts-procedures/hono-api'
|
|
1213
1436
|
|
|
1214
1437
|
// Client
|
|
1215
|
-
import {
|
|
1216
|
-
|
|
1438
|
+
import {
|
|
1439
|
+
createClient,
|
|
1440
|
+
createFetchAdapter,
|
|
1441
|
+
dispatchTypedError,
|
|
1442
|
+
defaultClassifyError,
|
|
1443
|
+
// Error classes (7.0+)
|
|
1444
|
+
ClientHttpError, // renamed from ClientRequestError; deprecated alias retained one cycle
|
|
1445
|
+
ClientNetworkError,
|
|
1446
|
+
ClientTimeoutError,
|
|
1447
|
+
ClientAbortError,
|
|
1448
|
+
ClientParseError,
|
|
1449
|
+
ClientPathParamError,
|
|
1450
|
+
ClientStreamError,
|
|
1451
|
+
} from 'ts-procedures/client'
|
|
1452
|
+
import type {
|
|
1453
|
+
ClientAdapter,
|
|
1454
|
+
ClientHooks,
|
|
1455
|
+
ClientInstance,
|
|
1456
|
+
TypedStream,
|
|
1457
|
+
// Safe-result types (7.0+)
|
|
1458
|
+
Result,
|
|
1459
|
+
ResultNoTyped,
|
|
1460
|
+
FrameworkFailure,
|
|
1461
|
+
ClientErrorMap,
|
|
1462
|
+
// Classifier types (7.0+)
|
|
1463
|
+
ErrorClassifier,
|
|
1464
|
+
ClassifyErrorContext,
|
|
1465
|
+
ClassifiedError,
|
|
1466
|
+
} from 'ts-procedures/client'
|
|
1217
1467
|
|
|
1218
1468
|
// Code generation
|
|
1219
1469
|
import { generateClient } from 'ts-procedures/codegen'
|
|
@@ -242,7 +242,7 @@ try {
|
|
|
242
242
|
} else if (err instanceof ApiErrors.ApiProcedureError) {
|
|
243
243
|
// Catch-all for any generated service error.
|
|
244
244
|
} else {
|
|
245
|
-
// Transport error (network, non-JSON body) — still a
|
|
245
|
+
// Transport error (network, non-JSON body) — still a ClientHttpError.
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
```
|
|
@@ -263,13 +263,71 @@ export namespace Users {
|
|
|
263
263
|
catch (err: unknown) {
|
|
264
264
|
// Cast is manual today (TS has no typed throws); the union documents
|
|
265
265
|
// which errors this specific route can throw.
|
|
266
|
-
const e = err as Users.GetUser.Errors |
|
|
266
|
+
const e = err as Users.GetUser.Errors | ClientHttpError
|
|
267
267
|
if (e instanceof ApiErrors.UseCaseError) { /* ... */ }
|
|
268
268
|
}
|
|
269
269
|
```
|
|
270
270
|
|
|
271
271
|
---
|
|
272
272
|
|
|
273
|
+
## Handling errors in client code
|
|
274
|
+
|
|
275
|
+
The framework normalizes platform errors (`TypeError`, `DOMException`) into framework error classes at the `executeCall` boundary. Consumers see typed framework classes, not raw platform errors.
|
|
276
|
+
|
|
277
|
+
### Throwing form (canonical)
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
import { ClientHttpError, ClientTimeoutError, ClientNetworkError, ClientAbortError } from 'ts-procedures/client'
|
|
281
|
+
import { createApiClient, ApiErrors, createFetchAdapter } from './generated'
|
|
282
|
+
|
|
283
|
+
const api = createApiClient({ adapter: createFetchAdapter(), basePath: 'https://api.example.com' })
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const response = await api.records.DownloadRecord(params, { timeout: 60_000 })
|
|
287
|
+
if (response.pdfBase64) downloadBase64AsPdf(response.pdfBase64)
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err instanceof ApiErrors.UseCaseError) {
|
|
290
|
+
Alerts.error(err.body.message)
|
|
291
|
+
} else if (err instanceof ClientHttpError) {
|
|
292
|
+
Alerts.error(`Server returned ${err.status}`)
|
|
293
|
+
} else if (err instanceof ClientTimeoutError) {
|
|
294
|
+
Alerts.error('Request timed out')
|
|
295
|
+
} else if (err instanceof ClientNetworkError) {
|
|
296
|
+
Alerts.error('Network error')
|
|
297
|
+
} else if (err instanceof ClientAbortError) {
|
|
298
|
+
// user cancelled — silent
|
|
299
|
+
} else {
|
|
300
|
+
throw err // programmer/framework bug
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Safe form (Result-returning, exhaustive narrowing)
|
|
306
|
+
|
|
307
|
+
Every RPC and API callable has a `.safe()` sibling that returns `Result<T, ETyped>` instead of throwing:
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
const r = await api.records.DownloadRecord.safe(params, { timeout: 60_000 })
|
|
311
|
+
if (r.ok) {
|
|
312
|
+
if (r.value.pdfBase64) downloadBase64AsPdf(r.value.pdfBase64)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
switch (r.kind) {
|
|
316
|
+
case 'typed': /* registry-dispatched typed error, e.g. ApiErrors.UseCaseError */; break
|
|
317
|
+
case 'http': Alerts.error(`Server ${r.error.status}`); break
|
|
318
|
+
case 'network': Alerts.error('Network error'); break
|
|
319
|
+
case 'timeout': Alerts.error('Timed out'); break
|
|
320
|
+
case 'aborted': break // user cancelled
|
|
321
|
+
case 'parse':
|
|
322
|
+
case 'usage': throw r.error // programmer/framework bug
|
|
323
|
+
case 'unknown': console.error(r.error); break
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Streams keep the throwing form (no `.safe`). Custom error categories can be added via `ClientErrorMap` interface augmentation — see `docs/client-error-handling.md`.
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
273
331
|
## Stream Procedures
|
|
274
332
|
|
|
275
333
|
```typescript
|
|
@@ -50,7 +50,7 @@ export const {{name}}Client = createApiClient({
|
|
|
50
50
|
// } catch (err) {
|
|
51
51
|
// if (err instanceof ApiErrors.UseCaseError) { /* err.body typed; err.status, procedureName, scope */ }
|
|
52
52
|
// else if (err instanceof ApiErrors.ApiProcedureError) { /* catch-all for service errors */ }
|
|
53
|
-
// else { /* transport error —
|
|
53
|
+
// else { /* transport error — ClientHttpError */ }
|
|
54
54
|
// }
|
|
55
55
|
|
|
56
56
|
// --- RPC call example ---
|
|
@@ -280,6 +280,8 @@ try {
|
|
|
280
280
|
|
|
281
281
|
Per-route error unions: routes with `errors: [...]` get an `Errors` type in their namespace (e.g. `Users.GetUser.Errors = ApiErrors.UseCaseError | ApiErrors.AuthError`).
|
|
282
282
|
|
|
283
|
+
- Generated client error handling: catch the framework classes (`ClientHttpError`, `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`), not raw `DOMException`/`TypeError` — the framework normalizes platform errors at the `executeCall` boundary, so raw platform errors no longer reach `catch` blocks after 7.0.0 (original is on `error.cause`). Use the `.safe()` sibling on RPC/API callables for an exhaustive `Result<T, E>` switch (`ok`, `typed`, `http`, `network`, `timeout`, `aborted`, `parse`, `usage`, `unknown`). Streams keep the throwing form — no `.safe()`.
|
|
284
|
+
|
|
283
285
|
## Lifecycle Hook Order
|
|
284
286
|
|
|
285
287
|
### Standard RPC
|
|
@@ -327,6 +329,7 @@ onRequestStart → factoryContext() → validation → onStreamStart → handler
|
|
|
327
329
|
9. **Never assume extra params fields survive** — `removeAdditional: true` strips them
|
|
328
330
|
10. **Never manually parse types AJV coerces** — `coerceTypes: true` handles it
|
|
329
331
|
11. **Never define both schema.params and schema.input** — mutually exclusive, throws ProcedureRegistrationError
|
|
332
|
+
12. **Never catch raw DOMException/TypeError from generated callables** — catch `ClientTimeoutError`, `ClientAbortError`, `ClientNetworkError` instead; or use `.safe()` for Result-based narrowing
|
|
330
333
|
|
|
331
334
|
## Testing
|
|
332
335
|
|
|
@@ -280,6 +280,8 @@ try {
|
|
|
280
280
|
|
|
281
281
|
Per-route error unions: routes with `errors: [...]` get an `Errors` type in their namespace (e.g. `Users.GetUser.Errors = ApiErrors.UseCaseError | ApiErrors.AuthError`).
|
|
282
282
|
|
|
283
|
+
- Generated client error handling: catch the framework classes (`ClientHttpError`, `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`), not raw `DOMException`/`TypeError` — the framework normalizes platform errors at the `executeCall` boundary, so raw platform errors no longer reach `catch` blocks after 7.0.0 (original is on `error.cause`). Use the `.safe()` sibling on RPC/API callables for an exhaustive `Result<T, E>` switch (`ok`, `typed`, `http`, `network`, `timeout`, `aborted`, `parse`, `usage`, `unknown`). Streams keep the throwing form — no `.safe()`.
|
|
284
|
+
|
|
283
285
|
## Lifecycle Hook Order
|
|
284
286
|
|
|
285
287
|
### Standard RPC
|
|
@@ -327,6 +329,7 @@ onRequestStart → factoryContext() → validation → onStreamStart → handler
|
|
|
327
329
|
9. **Never assume extra params fields survive** — `removeAdditional: true` strips them
|
|
328
330
|
10. **Never manually parse types AJV coerces** — `coerceTypes: true` handles it
|
|
329
331
|
11. **Never define both schema.params and schema.input** — mutually exclusive, throws ProcedureRegistrationError
|
|
332
|
+
12. **Never catch raw DOMException/TypeError from generated callables** — catch `ClientTimeoutError`, `ClientAbortError`, `ClientNetworkError` instead; or use `.safe()` for Result-based narrowing
|
|
330
333
|
|
|
331
334
|
## Testing
|
|
332
335
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
+
class RateLimitError extends Error {
|
|
3
|
+
retryAfter;
|
|
4
|
+
constructor(retryAfter) {
|
|
5
|
+
super('rate limited');
|
|
6
|
+
this.retryAfter = retryAfter;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
describe('ClientErrorMap augmentation', () => {
|
|
10
|
+
it('adds rateLimited kind to Result', () => {
|
|
11
|
+
expectTypeOf().toEqualTypeOf();
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
//# sourceMappingURL=augment-error-map.test-d.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"augment-error-map.test-d.js","sourceRoot":"","sources":["../../src/client/augment-error-map.test-d.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAA;AAGnD,MAAM,cAAe,SAAQ,KAAK;IACb;IAAnB,YAAmB,UAAkB;QACnC,KAAK,CAAC,cAAc,CAAC,CAAA;QADJ,eAAU,GAAV,UAAU,CAAQ;IAErC,CAAC;CACF;AAQD,QAAQ,CAAC,6BAA6B,EAAE,GAAG,EAAE;IAC3C,EAAE,CAAC,iCAAiC,EAAE,GAAG,EAAE;QAGzC,YAAY,EAAwB,CAAC,aAAa,EAAkB,CAAA;IACtE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createClient } from './index.js';
|
|
3
|
+
import { ClientHttpError, ClientNetworkError } from './errors.js';
|
|
4
|
+
const okAdapter = {
|
|
5
|
+
request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '42' } })),
|
|
6
|
+
stream: vi.fn(async () => { throw new Error('n/a'); }),
|
|
7
|
+
};
|
|
8
|
+
describe('ClientInstance.bindCallable', () => {
|
|
9
|
+
it('returns a callable that throws on failure', async () => {
|
|
10
|
+
const client = createClient({
|
|
11
|
+
adapter: { request: vi.fn(async () => { throw new TypeError('x'); }), stream: vi.fn(async () => { throw new Error('n/a'); }) },
|
|
12
|
+
basePath: 'https://api.x',
|
|
13
|
+
scopes: (c) => ({
|
|
14
|
+
getUser: c.bindCallable({
|
|
15
|
+
name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
|
|
16
|
+
}),
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
await expect(client.getUser({ id: '1' })).rejects.toBeInstanceOf(ClientNetworkError);
|
|
20
|
+
});
|
|
21
|
+
it('exposes .safe returning ResultNoTyped', async () => {
|
|
22
|
+
const client = createClient({
|
|
23
|
+
adapter: { request: vi.fn(async () => ({ status: 500, headers: {}, body: {} })), stream: vi.fn(async () => { throw new Error('n/a'); }) },
|
|
24
|
+
basePath: 'https://api.x',
|
|
25
|
+
scopes: (c) => ({
|
|
26
|
+
getUser: c.bindCallable({
|
|
27
|
+
name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
|
|
28
|
+
}),
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
const r = await client.getUser.safe({ id: '1' });
|
|
32
|
+
expect(r.ok).toBe(false);
|
|
33
|
+
if (!r.ok) {
|
|
34
|
+
expect(r.kind).toBe('http');
|
|
35
|
+
expect(r.error).toBeInstanceOf(ClientHttpError);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it('preserves descriptor.name as fn.name for stack traces', () => {
|
|
39
|
+
const client = createClient({
|
|
40
|
+
adapter: okAdapter,
|
|
41
|
+
basePath: 'https://api.x',
|
|
42
|
+
scopes: (c) => ({
|
|
43
|
+
getUser: c.bindCallable({
|
|
44
|
+
name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
expect(client.getUser.name).toBe('GetUser');
|
|
49
|
+
});
|
|
50
|
+
it('resolves the response value on success', async () => {
|
|
51
|
+
const client = createClient({
|
|
52
|
+
adapter: { request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '42' } })), stream: vi.fn(async () => { throw new Error('n/a'); }) },
|
|
53
|
+
basePath: 'https://api.x',
|
|
54
|
+
scopes: (c) => ({
|
|
55
|
+
getUser: c.bindCallable({
|
|
56
|
+
name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
|
|
57
|
+
}),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
const result = await client.getUser({ id: '1' });
|
|
61
|
+
expect(result).toEqual({ id: '42' });
|
|
62
|
+
});
|
|
63
|
+
it('.safe resolves with ok: true on success', async () => {
|
|
64
|
+
const client = createClient({
|
|
65
|
+
adapter: { request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '42' } })), stream: vi.fn(async () => { throw new Error('n/a'); }) },
|
|
66
|
+
basePath: 'https://api.x',
|
|
67
|
+
scopes: (c) => ({
|
|
68
|
+
getUser: c.bindCallable({
|
|
69
|
+
name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
|
|
70
|
+
}),
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
const r = await client.getUser.safe({ id: '1' });
|
|
74
|
+
expect(r.ok).toBe(true);
|
|
75
|
+
if (r.ok) {
|
|
76
|
+
expect(r.value).toEqual({ id: '42' });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('ClientInstance.bindCallableTyped', () => {
|
|
81
|
+
it('exposes .safe returning Result with typed errors', async () => {
|
|
82
|
+
class ApiUseCaseError extends Error {
|
|
83
|
+
status;
|
|
84
|
+
constructor(status) {
|
|
85
|
+
super('use-case');
|
|
86
|
+
this.status = status;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const client = createClient({
|
|
90
|
+
adapter: { request: vi.fn(async () => ({ status: 500, headers: {}, body: {} })), stream: vi.fn(async () => { throw new Error('n/a'); }) },
|
|
91
|
+
basePath: 'https://api.x',
|
|
92
|
+
scopes: (c) => ({
|
|
93
|
+
getUser: c.bindCallableTyped({
|
|
94
|
+
name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
|
|
95
|
+
}),
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
const r = await client.getUser.safe({ id: '1' });
|
|
99
|
+
expect(r.ok).toBe(false);
|
|
100
|
+
// Falls through to 'http' since no registry was wired — the test verifies
|
|
101
|
+
// the .safe surface exists, not the typed dispatch path.
|
|
102
|
+
if (!r.ok) {
|
|
103
|
+
expect(['http', 'typed', 'network', 'timeout', 'aborted', 'parse', 'usage', 'unknown']).toContain(r.kind);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
it('preserves descriptor.name as fn.name for stack traces', () => {
|
|
107
|
+
const client = createClient({
|
|
108
|
+
adapter: okAdapter,
|
|
109
|
+
basePath: 'https://api.x',
|
|
110
|
+
scopes: (c) => ({
|
|
111
|
+
getUser: c.bindCallableTyped({
|
|
112
|
+
name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
|
|
113
|
+
}),
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
expect(client.getUser.name).toBe('GetUser');
|
|
117
|
+
});
|
|
118
|
+
it('resolves the response value on success', async () => {
|
|
119
|
+
const client = createClient({
|
|
120
|
+
adapter: { request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '99' } })), stream: vi.fn(async () => { throw new Error('n/a'); }) },
|
|
121
|
+
basePath: 'https://api.x',
|
|
122
|
+
scopes: (c) => ({
|
|
123
|
+
getUser: c.bindCallableTyped({
|
|
124
|
+
name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
|
|
125
|
+
}),
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
const result = await client.getUser({ id: '1' });
|
|
129
|
+
expect(result).toEqual({ id: '99' });
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
//# sourceMappingURL=bind-callable.test.js.map
|