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.
Files changed (164) hide show
  1. package/README.md +2 -0
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
  3. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
  4. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +85 -17
  5. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +220 -9
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +271 -16
  7. package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
  8. package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
  9. package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
  10. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +53 -18
  11. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
  12. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
  13. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
  14. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
  15. package/agent_config/copilot/copilot-instructions.md +132 -19
  16. package/agent_config/cursor/cursorrules +132 -19
  17. package/build/client/call.d.ts +19 -9
  18. package/build/client/call.js +33 -19
  19. package/build/client/call.js.map +1 -1
  20. package/build/client/call.test.js +167 -17
  21. package/build/client/call.test.js.map +1 -1
  22. package/build/client/error-dispatch.d.ts +13 -0
  23. package/build/client/error-dispatch.js +26 -0
  24. package/build/client/error-dispatch.js.map +1 -0
  25. package/build/client/error-dispatch.test.d.ts +1 -0
  26. package/build/client/error-dispatch.test.js +56 -0
  27. package/build/client/error-dispatch.test.js.map +1 -0
  28. package/build/client/fetch-adapter.js +10 -4
  29. package/build/client/fetch-adapter.js.map +1 -1
  30. package/build/client/index.d.ts +2 -1
  31. package/build/client/index.js +22 -3
  32. package/build/client/index.js.map +1 -1
  33. package/build/client/index.test.js +104 -0
  34. package/build/client/index.test.js.map +1 -1
  35. package/build/client/resolve-options.d.ts +45 -0
  36. package/build/client/resolve-options.js +82 -0
  37. package/build/client/resolve-options.js.map +1 -0
  38. package/build/client/resolve-options.test.d.ts +1 -0
  39. package/build/client/resolve-options.test.js +158 -0
  40. package/build/client/resolve-options.test.js.map +1 -0
  41. package/build/client/stream.d.ts +19 -9
  42. package/build/client/stream.js +36 -21
  43. package/build/client/stream.js.map +1 -1
  44. package/build/client/stream.test.js +102 -46
  45. package/build/client/stream.test.js.map +1 -1
  46. package/build/client/typed-error-dispatch.test.d.ts +1 -0
  47. package/build/client/typed-error-dispatch.test.js +168 -0
  48. package/build/client/typed-error-dispatch.test.js.map +1 -0
  49. package/build/client/types.d.ts +105 -1
  50. package/build/client/types.js +1 -1
  51. package/build/codegen/e2e.test.js +150 -4
  52. package/build/codegen/e2e.test.js.map +1 -1
  53. package/build/codegen/emit-client-runtime.js +7 -0
  54. package/build/codegen/emit-client-runtime.js.map +1 -1
  55. package/build/codegen/emit-errors.d.ts +17 -6
  56. package/build/codegen/emit-errors.integration.test.d.ts +1 -0
  57. package/build/codegen/emit-errors.integration.test.js +162 -0
  58. package/build/codegen/emit-errors.integration.test.js.map +1 -0
  59. package/build/codegen/emit-errors.js +50 -39
  60. package/build/codegen/emit-errors.js.map +1 -1
  61. package/build/codegen/emit-errors.test.js +75 -78
  62. package/build/codegen/emit-errors.test.js.map +1 -1
  63. package/build/codegen/emit-index.d.ts +7 -0
  64. package/build/codegen/emit-index.js +26 -4
  65. package/build/codegen/emit-index.js.map +1 -1
  66. package/build/codegen/emit-index.test.js +55 -23
  67. package/build/codegen/emit-index.test.js.map +1 -1
  68. package/build/codegen/emit-scope.d.ts +8 -0
  69. package/build/codegen/emit-scope.js +82 -7
  70. package/build/codegen/emit-scope.js.map +1 -1
  71. package/build/codegen/pipeline.js +22 -2
  72. package/build/codegen/pipeline.js.map +1 -1
  73. package/build/implementations/http/doc-registry.d.ts +21 -0
  74. package/build/implementations/http/doc-registry.js +51 -78
  75. package/build/implementations/http/doc-registry.js.map +1 -1
  76. package/build/implementations/http/doc-registry.test.js +8 -6
  77. package/build/implementations/http/doc-registry.test.js.map +1 -1
  78. package/build/implementations/http/error-taxonomy.d.ts +240 -0
  79. package/build/implementations/http/error-taxonomy.js +230 -0
  80. package/build/implementations/http/error-taxonomy.js.map +1 -0
  81. package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
  82. package/build/implementations/http/error-taxonomy.test.js +399 -0
  83. package/build/implementations/http/error-taxonomy.test.js.map +1 -0
  84. package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
  85. package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
  86. package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
  87. package/build/implementations/http/express-rpc/index.d.ts +39 -8
  88. package/build/implementations/http/express-rpc/index.js +39 -8
  89. package/build/implementations/http/express-rpc/index.js.map +1 -1
  90. package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
  91. package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
  92. package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
  93. package/build/implementations/http/hono-api/index.d.ts +38 -1
  94. package/build/implementations/http/hono-api/index.js +32 -0
  95. package/build/implementations/http/hono-api/index.js.map +1 -1
  96. package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
  97. package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
  98. package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
  99. package/build/implementations/http/hono-rpc/index.d.ts +34 -7
  100. package/build/implementations/http/hono-rpc/index.js +31 -4
  101. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  102. package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
  103. package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
  104. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
  105. package/build/implementations/http/hono-stream/index.d.ts +40 -3
  106. package/build/implementations/http/hono-stream/index.js +37 -10
  107. package/build/implementations/http/hono-stream/index.js.map +1 -1
  108. package/build/implementations/http/hono-stream/index.test.js +45 -18
  109. package/build/implementations/http/hono-stream/index.test.js.map +1 -1
  110. package/build/implementations/http/on-request-error.test.d.ts +1 -0
  111. package/build/implementations/http/on-request-error.test.js +173 -0
  112. package/build/implementations/http/on-request-error.test.js.map +1 -0
  113. package/build/implementations/http/route-errors.test.d.ts +1 -0
  114. package/build/implementations/http/route-errors.test.js +140 -0
  115. package/build/implementations/http/route-errors.test.js.map +1 -0
  116. package/build/implementations/types.d.ts +30 -2
  117. package/docs/client-and-codegen.md +228 -14
  118. package/docs/core.md +14 -5
  119. package/docs/http-integrations.md +135 -4
  120. package/docs/streaming.md +3 -1
  121. package/package.json +7 -2
  122. package/src/client/call.test.ts +202 -29
  123. package/src/client/call.ts +50 -28
  124. package/src/client/error-dispatch.test.ts +72 -0
  125. package/src/client/error-dispatch.ts +27 -0
  126. package/src/client/fetch-adapter.ts +11 -5
  127. package/src/client/index.test.ts +117 -0
  128. package/src/client/index.ts +34 -8
  129. package/src/client/resolve-options.test.ts +205 -0
  130. package/src/client/resolve-options.ts +113 -0
  131. package/src/client/stream.test.ts +132 -107
  132. package/src/client/stream.ts +53 -27
  133. package/src/client/typed-error-dispatch.test.ts +211 -0
  134. package/src/client/types.ts +116 -2
  135. package/src/codegen/e2e.test.ts +160 -4
  136. package/src/codegen/emit-client-runtime.ts +7 -0
  137. package/src/codegen/emit-errors.integration.test.ts +183 -0
  138. package/src/codegen/emit-errors.test.ts +91 -87
  139. package/src/codegen/emit-errors.ts +123 -41
  140. package/src/codegen/emit-index.test.ts +68 -24
  141. package/src/codegen/emit-index.ts +66 -4
  142. package/src/codegen/emit-scope.ts +124 -7
  143. package/src/codegen/pipeline.ts +25 -2
  144. package/src/implementations/http/README.md +28 -5
  145. package/src/implementations/http/doc-registry.test.ts +10 -6
  146. package/src/implementations/http/doc-registry.ts +63 -80
  147. package/src/implementations/http/error-taxonomy.test.ts +438 -0
  148. package/src/implementations/http/error-taxonomy.ts +337 -0
  149. package/src/implementations/http/express-rpc/README.md +21 -22
  150. package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
  151. package/src/implementations/http/express-rpc/index.ts +75 -14
  152. package/src/implementations/http/hono-api/README.md +284 -0
  153. package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
  154. package/src/implementations/http/hono-api/index.ts +76 -1
  155. package/src/implementations/http/hono-rpc/README.md +18 -19
  156. package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
  157. package/src/implementations/http/hono-rpc/index.ts +65 -9
  158. package/src/implementations/http/hono-stream/README.md +44 -25
  159. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
  160. package/src/implementations/http/hono-stream/index.test.ts +54 -18
  161. package/src/implementations/http/hono-stream/index.ts +83 -13
  162. package/src/implementations/http/on-request-error.test.ts +201 -0
  163. package/src/implementations/http/route-errors.test.ts +177 -0
  164. package/src/implementations/types.ts +30 -2
@@ -103,6 +103,166 @@ const { TransferFunds } = Create(
103
103
 
104
104
  ---
105
105
 
106
+ ## Error Taxonomy (declarative error-to-response mapping)
107
+
108
+ `defineErrorTaxonomy` replaces `instanceof` ladders inside `onError`. Register error classes once with their status code and serializer, pass to any HTTP builder. Works across `hono-api`, `hono-rpc`, `express-rpc`, and `hono-stream` pre-stream (mid-stream still uses `onMidStreamError`).
109
+
110
+ ```typescript
111
+ import { defineErrorTaxonomy } from 'ts-procedures/http-errors'
112
+ import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
113
+
114
+ class UseCaseError extends Error {
115
+ constructor(readonly externalMsg: string, readonly internalMsg: string) {
116
+ super(externalMsg); this.name = 'UseCaseError'
117
+ Object.setPrototypeOf(this, UseCaseError.prototype)
118
+ }
119
+ }
120
+
121
+ const appErrors = defineErrorTaxonomy({
122
+ AuthError: { class: AuthError, statusCode: 401 }, // default toResponse → { name, message }
123
+ UseCaseError: {
124
+ class: UseCaseError,
125
+ statusCode: 422,
126
+ toResponse: (err) => ({ message: err.externalMsg }), // `name: 'UseCaseError'` auto-injected
127
+ onCatch: (err, { procedure }) => logger.error({ procedure: procedure.name, internal: err.internalMsg }),
128
+ },
129
+ // 3rd-party / non-subclassable errors — use `match:`
130
+ MongoDuplicateKey: {
131
+ match: (err): err is Error & { code: number } =>
132
+ err instanceof Error && (err as any).code === 11000,
133
+ statusCode: 409,
134
+ toResponse: () => ({ message: 'Resource already exists' }),
135
+ },
136
+ })
137
+
138
+ new HonoAPIAppBuilder({
139
+ errors: appErrors,
140
+ unknownError: {
141
+ toResponse: () => ({ name: 'InternalServerError', message: 'Unexpected error' }),
142
+ onCatch: (err, { procedure }) => logger.error({ procedure: procedure.name, err }),
143
+ },
144
+ }).register(API, (c) => ({ requestId: c.req.header('x-request-id') })).build()
145
+ ```
146
+
147
+ Handlers throw directly — no try/catch, no ladders:
148
+
149
+ ```typescript
150
+ API.Create('GetUser', { /* ... */ }, async (ctx, { pathParams }) => {
151
+ const user = await usersRepo.findById(pathParams.id)
152
+ if (!user) throw new UseCaseError('User not found', `repo returned null for ${pathParams.id}`)
153
+ return user
154
+ })
155
+ ```
156
+
157
+ Key rules:
158
+
159
+ - `defineErrorTaxonomy` topologically sorts `class:` entries so a subclass always resolves before its base — declaration order inside the helper no longer matters for inheritance chains. `match:` entries keep declared order.
160
+ - The core wraps any non-`ProcedureError` thrown from a handler into a `ProcedureError` with `.cause` set. The resolver unwraps this automatically so your taxonomy sees the real class.
161
+ - Two peer modes: declarative (`errors` + `unknownError`) or imperative (`onError`). Both are first-class. When neither is configured, the builder falls through to a hard default (`{ error: message }`, 500).
162
+ - `onCatch`'s `raw` is the framework request object (Hono `Context` / `{ req, res }` for Express) typed as `unknown` — cast at the use site.
163
+
164
+ ### Per-route error declaration (typed)
165
+
166
+ Narrow `APIConfig` / `RPCConfig` to your taxonomy's keys for compile-time typo protection, and declare which errors each procedure may emit. These populate the DocEnvelope per-route so generated clients can type `catch` blocks precisely.
167
+
168
+ ```typescript
169
+ import { APIConfig } from 'ts-procedures/http'
170
+ import { appErrors } from './errors/taxonomy'
171
+
172
+ type MyAPIConfig = APIConfig<keyof typeof appErrors & string>
173
+
174
+ const API = Procedures<Ctx, MyAPIConfig>()
175
+
176
+ API.Create('GetUser', {
177
+ path: '/users/:id',
178
+ method: 'get',
179
+ errors: ['UseCaseError', 'AuthError'], // typed against taxonomy keys; typos are TS errors
180
+ schema: { /* ... */ },
181
+ }, async (ctx, params) => { /* ... */ })
182
+ ```
183
+
184
+ Seed the DocEnvelope with both taxonomy errors and framework defaults in one call:
185
+
186
+ ```typescript
187
+ import { DocRegistry } from 'ts-procedures/http-docs'
188
+
189
+ const envelope = DocRegistry.fromTaxonomy(appErrors, { basePath: '/api' })
190
+ .from(apiApp)
191
+ .from(rpcApp)
192
+ .toJSON()
193
+ // envelope.errors: all registered + framework defaults (deduped)
194
+ // envelope.routes[i].errors: per-route subset declared on config.errors
195
+ ```
196
+
197
+ ### Typed catch blocks on the client (via codegen)
198
+
199
+ The codegen emits runtime error classes (not just types) and a registry object. `createApiClient` wires the registry automatically so non-2xx responses arrive as typed class instances.
200
+
201
+ Generated client (simplified):
202
+
203
+ ```typescript
204
+ // Generated _errors.ts
205
+ export namespace ApiErrors {
206
+ export class ApiProcedureError<TBody> extends Error { /* ... */ }
207
+ export class UseCaseError extends ApiProcedureError<UseCaseErrorBody> {
208
+ static fromResponse(body, meta): UseCaseError { /* ... */ }
209
+ }
210
+ export const ApiErrorRegistry = { UseCaseError, AuthError, /* ... */ }
211
+ }
212
+
213
+ // Generated index.ts
214
+ export function createApiClient(config) {
215
+ return createClient({ ...config, errorRegistry: ApiErrors.ApiErrorRegistry, scopes: createApiBindings })
216
+ }
217
+ ```
218
+
219
+ Consumer code:
220
+
221
+ ```typescript
222
+ import { createApiClient, ApiErrors, createFetchAdapter } from './generated'
223
+
224
+ const api = createApiClient({
225
+ adapter: createFetchAdapter({ /* ... */ }),
226
+ basePath: 'https://api.example.com',
227
+ })
228
+
229
+ try {
230
+ const user = await api.users.getUser({ pathParams: { id: 'u_123' } })
231
+ } catch (err) {
232
+ if (err instanceof ApiErrors.UseCaseError) {
233
+ // err.message is typed; err.body is typed to UseCaseErrorBody;
234
+ // err.status, err.procedureName, err.scope are available.
235
+ } else if (err instanceof ApiErrors.ApiProcedureError) {
236
+ // Catch-all for any generated service error.
237
+ } else {
238
+ // Transport error (network, non-JSON body) — still a ClientRequestError.
239
+ }
240
+ }
241
+ ```
242
+
243
+ Per-route error narrowing: when a route declares `errors: [...]`, the generated scope file emits an `Errors` type union the consumer can use to annotate their catch:
244
+
245
+ ```typescript
246
+ // Generated scope file includes:
247
+ export namespace Users {
248
+ export namespace GetUser {
249
+ export type Params = { /* ... */ }
250
+ export type Response = { /* ... */ }
251
+ export type Errors = ApiErrors.UseCaseError | ApiErrors.AuthError
252
+ }
253
+ }
254
+
255
+ // Consumer:
256
+ catch (err: unknown) {
257
+ // Cast is manual today (TS has no typed throws); the union documents
258
+ // which errors this specific route can throw.
259
+ const e = err as Users.GetUser.Errors | ClientRequestError
260
+ if (e instanceof ApiErrors.UseCaseError) { /* ... */ }
261
+ }
262
+ ```
263
+
264
+ ---
265
+
106
266
  ## Stream Procedures
107
267
 
108
268
  ```typescript
@@ -275,15 +435,13 @@ const { UpdateUser } = Create(
275
435
  async (ctx, params) => updateUser(params)
276
436
  )
277
437
 
278
- // 2. Build Express app
438
+ // 2. Build Express app — the taxonomy handles framework errors (ProcedureValidationError
439
+ // maps to 400 by default) and anything the app throws; unknownError is the last resort.
279
440
  const app = new ExpressRPCAppBuilder({
280
441
  pathPrefix: '/api',
281
- onError: (procedure, req, res, error) => {
282
- if (error instanceof ProcedureValidationError) {
283
- res.status(400).json({ error: error.message, details: error.errors })
284
- } else {
285
- res.status(500).json({ error: error.message })
286
- }
442
+ errors: appErrors, // from defineErrorTaxonomy({ ... })
443
+ unknownError: {
444
+ toResponse: (err) => ({ error: (err as Error).message }),
287
445
  },
288
446
  })
289
447
  .register(
@@ -437,11 +595,9 @@ API.Create('DeleteUser', {
437
595
 
438
596
  const app = new HonoAPIAppBuilder({
439
597
  pathPrefix: '/api',
440
- onError: (procedure, c, error) => {
441
- if (error instanceof ProcedureValidationError) {
442
- return c.json({ error: error.message, details: error.errors }, 400)
443
- }
444
- return c.json({ error: error.message }, 500)
598
+ errors: appErrors, // taxonomy maps error classes to statuses/bodies; ProcedureValidationError → 400 by default
599
+ unknownError: {
600
+ toResponse: (err) => ({ error: (err as Error).message }),
445
601
  },
446
602
  })
447
603
  .register(API, (c) => ({
@@ -754,7 +910,7 @@ onRequestStart → factoryContext() → handler() → onSuccess → onRequestEnd
754
910
  ### Streaming (HonoStreamAppBuilder)
755
911
  ```
756
912
  onRequestStart → factoryContext() → params validation
757
- onPreStreamError (if invalid) → onRequestEnd
913
+ onError (if invalid) → onRequestEnd
758
914
  → onStreamStart → handler yields → onStreamEnd → onRequestEnd
759
915
  → onMidStreamError (if throw) → onStreamEnd → onRequestEnd
760
916
  ```
@@ -841,10 +997,11 @@ const result = await stream.result // Typed as WatchNotificationsReturn
841
997
 
842
998
  ---
843
999
 
844
- ## Per-Procedure Hook Override
1000
+ ## Per-Procedure Hooks
1001
+
1002
+ Per-call hooks run *after* global hooks (they don't replace them). They live in the same options bag as `timeout`, `signal`, `headers`, etc.
845
1003
 
846
1004
  ```typescript
847
- // Override hooks for a specific call
848
1005
  await client.users.GetUser({ pathParams: { id: '123' } }, {
849
1006
  onAfterResponse(ctx) {
850
1007
  const rateLimit = ctx.response.headers['x-rate-limit-remaining']
@@ -855,6 +1012,102 @@ await client.users.GetUser({ pathParams: { id: '123' } }, {
855
1012
 
856
1013
  ---
857
1014
 
1015
+ ## Per-Call Request Options (timeout, signal, headers, basePath)
1016
+
1017
+ Every generated callable takes an optional second argument for per-call config. The options bag covers both request-level config and hooks.
1018
+
1019
+ ```typescript
1020
+ // Timeout — aborts the request after 5 seconds
1021
+ const user = await client.users.GetUser({ pathParams: { id: '123' } }, { timeout: 5000 })
1022
+
1023
+ // Cancellation — supply your own AbortSignal
1024
+ const controller = new AbortController()
1025
+ const user = await client.users.GetUser(
1026
+ { pathParams: { id: '123' } },
1027
+ { signal: controller.signal },
1028
+ )
1029
+
1030
+ // Extra per-call headers
1031
+ await client.users.GetUser(
1032
+ { pathParams: { id: '123' } },
1033
+ { headers: { 'X-Request-Id': crypto.randomUUID() } },
1034
+ )
1035
+
1036
+ // Override base path for a single call (multi-region, dev/prod switching)
1037
+ await client.users.GetUser(
1038
+ { pathParams: { id: '123' } },
1039
+ { basePath: 'https://api-eu.example.com' },
1040
+ )
1041
+ ```
1042
+
1043
+ ---
1044
+
1045
+ ## Client-Level Defaults
1046
+
1047
+ Set defaults via `config.defaults` — applied to every call, overridden by per-call options. Headers merge (per-call keys win); `signal` combines via `AbortSignal.any` (whichever aborts first wins); per-call `timeout: 0` disables an inherited default timeout.
1048
+
1049
+ ```typescript
1050
+ const client = createClient({
1051
+ adapter: createFetchAdapter(),
1052
+ basePath: 'http://localhost:3000',
1053
+ scopes: createApiBindings,
1054
+ defaults: {
1055
+ timeout: 30_000,
1056
+ headers: { 'X-Client-Version': '1.0.0' },
1057
+ },
1058
+ })
1059
+ ```
1060
+
1061
+ ---
1062
+
1063
+ ## Typed Per-Request Meta (RequestMeta Augmentation)
1064
+
1065
+ `AdapterRequest.meta` is typed via the `RequestMeta` interface — declared empty so developers augment it via TypeScript declaration merging. Augmented fields are then typed end-to-end: per-call options, hook contexts, and adapter.
1066
+
1067
+ ```typescript
1068
+ // Self-contained (code-generated) client
1069
+ declare module './generated/_types' {
1070
+ interface RequestMeta {
1071
+ traceId: string
1072
+ priority?: 'high' | 'low'
1073
+ }
1074
+ }
1075
+
1076
+ // Or, when using ts-procedures/client directly
1077
+ declare module 'ts-procedures/client' {
1078
+ interface RequestMeta {
1079
+ traceId: string
1080
+ }
1081
+ }
1082
+
1083
+ // After augmentation, meta is typed everywhere:
1084
+ await client.users.GetUser(
1085
+ { pathParams: { id: '123' } },
1086
+ { meta: { traceId: 'req-abc', priority: 'high' } }, // typed
1087
+ )
1088
+
1089
+ // Typed in hooks
1090
+ hooks: {
1091
+ onBeforeRequest(ctx) {
1092
+ const trace = ctx.request.meta?.traceId // string | undefined
1093
+ return ctx
1094
+ },
1095
+ }
1096
+
1097
+ // Typed in adapters
1098
+ const adapter: ClientAdapter = {
1099
+ async request(req) {
1100
+ const priority = req.meta?.priority // 'high' | 'low' | undefined
1101
+ return { status: 200, headers: {}, body: {} }
1102
+ },
1103
+ async stream(req) { /* ... */ },
1104
+ }
1105
+ ```
1106
+
1107
+ If `RequestMeta` declares required fields, supply them in `defaults.meta` or per-call `options.meta` — the merged shape must contain them at runtime.
1108
+
1109
+ ---
1110
+
858
1111
  ## Custom Client Adapter (Axios)
859
1112
 
860
1113
  ```typescript
@@ -862,7 +1115,9 @@ import type { ClientAdapter } from 'ts-procedures/client'
862
1115
  import axios from 'axios'
863
1116
 
864
1117
  const axiosAdapter: ClientAdapter = {
865
- async request({ url, method, headers, body, signal }) {
1118
+ async request({ url, method, headers, body, signal, meta }) {
1119
+ // meta is typed via RequestMeta augmentation — use it for tracing,
1120
+ // priority routing, custom auth, etc.
866
1121
  const res = await axios({ url, method, headers, data: body, signal })
867
1122
  return {
868
1123
  status: res.status,
@@ -14,7 +14,7 @@ Parse `$ARGUMENTS` as a file or directory path. If a directory, review all `.ts`
14
14
  ## Instructions
15
15
 
16
16
  1. Read the target file(s).
17
- 2. Identify ts-procedures imports (`ts-procedures`, `ts-procedures/express-rpc`, `ts-procedures/hono-rpc`, `ts-procedures/hono-stream`, `ts-procedures/hono-api`, `ts-procedures/http`, `ts-procedures/http-docs`) to determine file types.
17
+ 2. Identify ts-procedures imports (`ts-procedures`, `ts-procedures/express-rpc`, `ts-procedures/hono-rpc`, `ts-procedures/hono-stream`, `ts-procedures/hono-api`, `ts-procedures/http`, `ts-procedures/http-docs`, `ts-procedures/http-errors`, `ts-procedures/client`, `ts-procedures/codegen`) to determine file types.
18
18
  3. Check each file against the categorized checklist in [checklist.md](checklist.md).
19
19
  4. For detailed code examples of each violation pattern, reference [anti-patterns.md](../ts-procedures/anti-patterns.md) — it shows 20 common mistakes with before/after code fixes and severity ratings.
20
20
  5. Output findings grouped by severity.
@@ -62,26 +62,29 @@
62
62
  ### CRITICAL
63
63
  - [ ] Standard `Create` procedures registered with `ExpressRPCAppBuilder` or `HonoRPCAppBuilder`, NOT `HonoStreamAppBuilder`
64
64
  - [ ] Stream `CreateStream` procedures registered with `HonoStreamAppBuilder`, NOT the RPC builders
65
- - [ ] `onError` callback handles `ProcedureValidationError` and `ProcedureError` differently
65
+ - [ ] Custom error classes are registered via `defineErrorTaxonomy` + builder's `errors` config — NOT hand-written `onError` instanceof ladders (anti-pattern #20)
66
+ - [ ] If a handler throws a non-`ProcedureError`, its class is either in the taxonomy or intentionally caught by `unknownError`
66
67
 
67
68
  ### WARNING
68
69
  - [ ] `pathPrefix` set consistently across builders
69
70
  - [ ] `scope` and `version` set on all procedures when using `RPCConfig`
70
71
  - [ ] Uses `extendProcedureDoc` for documentation generation instead of manual doc building
71
72
  - [ ] `onRequestEnd` used for cleanup/logging, not business logic
73
+ - [ ] `RPCConfig<keyof typeof appErrors & string>` used when the app wants compile-time typo protection on per-route `errors: [...]`
72
74
 
73
75
  ### SUGGESTION
74
76
  - [ ] Route documentation accessed via `builder.docs` for OpenAPI generation
75
- - [ ] Uses `DocRegistry` to compose docs from multiple builders instead of manual envelope assembly
77
+ - [ ] Uses `DocRegistry.fromTaxonomy(appErrors)` (or plain `DocRegistry` with explicit `errors`) to compose docs from multiple builders
76
78
  - [ ] Lifecycle hooks used for observability (logging, metrics)
79
+ - [ ] Per-route `errors: [...]` declared so generated clients can narrow `catch` types
77
80
 
78
81
  ---
79
82
 
80
83
  ## Streaming Checks (HonoStreamAppBuilder)
81
84
 
82
85
  ### CRITICAL
83
- - [ ] `onPreStreamError` handles validation errors before streaming starts (returns HTTP response)
84
- - [ ] `onMidStreamError` handles runtime errors during streaming (yields error event)
86
+ - [ ] Pre-stream errors handled via either peer mode — `errors` + `unknownError` taxonomy OR `onError` callback (the hono-stream `onPreStreamError` was renamed to `onError` in v6)
87
+ - [ ] `onMidStreamError` handles runtime errors during streaming (yields error event) — mid-stream is NOT covered by the taxonomy since HTTP status is already committed
85
88
  - [ ] Stream handler does not assume `signal.reason` is always `'stream-completed'` — check for external abort
86
89
 
87
90
  ### WARNING
@@ -98,20 +101,21 @@
98
101
  ## API Builder Checks (HonoAPIAppBuilder)
99
102
 
100
103
  ### CRITICAL
101
- - [ ] `build()` is `await`ed — returns `Promise<Hono>`, not `Hono`
104
+ - [ ] `build()` is called synchronously it returns `Hono`, NOT `Promise<Hono>`. Do not `await` it.
102
105
  - [ ] Path param names in route template match `schema.input.pathParams` property names exactly
103
106
  - [ ] Does not define both `schema.params` and `schema.input` on the same procedure
104
- - [ ] `onError` callback handles `ProcedureValidationError` and `ProcedureError` differently
107
+ - [ ] Custom error classes registered via `defineErrorTaxonomy` + builder `errors` config — not hand-written `onError` ladders
105
108
 
106
109
  ### WARNING
107
110
  - [ ] Uses `schema.input` channels appropriate for the HTTP method (no `body` on GET/HEAD)
108
111
  - [ ] `pathPrefix` set consistently
109
112
  - [ ] `successStatus` overridden only when default (POST→201, DELETE→204) is wrong
110
113
  - [ ] Uses `APIInput` type constraint (`satisfies APIInput`) to catch channel name typos
114
+ - [ ] `APIConfig<keyof typeof appErrors & string>` used when the app wants compile-time typo protection on per-route `errors: [...]`
111
115
 
112
116
  ### SUGGESTION
113
117
  - [ ] Route documentation accessed via `builder.docs` for OpenAPI generation
114
- - [ ] Uses `DocRegistry` to compose docs across builders instead of manual assembly
118
+ - [ ] Uses `DocRegistry.fromTaxonomy(appErrors)` to compose docs across builders instead of manual assembly
115
119
  - [ ] Custom `queryParser` provided if complex query string formats needed
116
120
  - [ ] Lifecycle hooks used for observability (logging, metrics)
117
121
 
@@ -120,18 +124,22 @@
120
124
  ## Error Handling Checks
121
125
 
122
126
  ### CRITICAL
123
- - [ ] Handler errors use `ctx.error(message, meta?)` — not `throw new Error()`
127
+ - [ ] Handler errors use `ctx.error(message, meta?)` OR throw a class registered in the app's `defineErrorTaxonomy` never `throw new Error()` directly
124
128
  - [ ] Does not catch and swallow errors without re-throwing (hides failures from caller)
125
- - [ ] HTTP builder has `onError` callback — default behavior may expose internal details
129
+ - [ ] HTTP builder has either an `errors` taxonomy or an `onError` callback configured both are first-class peer modes; leaving both off uses the hard default `{ error: message }` 500, which may expose internal details
130
+ - [ ] `defineErrorTaxonomy` entries use `class:` for `instanceof` dispatch or `match:` for 3rd-party errors (e.g. MongoServerError) — never both on the same entry
126
131
 
127
132
  ### WARNING
128
- - [ ] `ProcedureValidationError` handled separately (400 status) from `ProcedureError` (4xx/5xx)
133
+ - [ ] Framework errors (`ProcedureValidationError` 400, `ProcedureError` → 500) are covered by the default taxonomy automatically; don't re-declare them unless overriding
134
+ - [ ] `toResponse` output either includes a `name` field or lets the resolver auto-inject `{ name: key }` — required for typed client dispatch
129
135
  - [ ] Error `meta` object contains useful context (error codes, IDs) — not sensitive data
136
+ - [ ] Stack traces and `internalMsg`-style fields are kept server-side via `onCatch`, never included in `toResponse` output
130
137
  - [ ] Stack traces not exposed in production responses
131
138
 
132
139
  ### SUGGESTION
133
- - [ ] Consistent error response shape across all procedures
134
- - [ ] Error codes documented for API consumers
140
+ - [ ] Consistent error response shape across all procedures (guaranteed by the taxonomy's `name` field)
141
+ - [ ] Error codes documented for API consumers (feed `description` and `schema` on each taxonomy entry)
142
+ - [ ] Per-route `errors: [...]` declared on each procedure config so the generated client gets narrowed `Errors` union types
135
143
 
136
144
  ---
137
145
 
@@ -38,7 +38,8 @@ If either argument is missing, ask the user for `<type>` and `<Name>`.
38
38
 
39
39
  ## Rules
40
40
 
41
- - Use `ctx.error()` for business logic errors, never throw raw `Error`.
41
+ - Use `ctx.error()` for ad-hoc business errors; for structured errors with status codes and typed client dispatch, throw custom error classes and register them via `defineErrorTaxonomy` in the builder's `errors` config (see the HTTP builder templates).
42
+ - Never throw raw `Error` — either use `ctx.error()` or a typed class registered in the taxonomy.
42
43
  - `schema.params` is validated at runtime; `schema.returnType` is documentation only.
43
44
  - Pass `ctx.signal` to all downstream async calls.
44
45
  - Stream handlers always have `ctx.signal` (guaranteed `AbortSignal`).
@@ -3,16 +3,31 @@
3
3
  ## Implementation — `{{Name}}.client.ts`
4
4
 
5
5
  ```typescript
6
- import { createClient, createFetchAdapter } from 'ts-procedures/client'
7
- import { createApiBindings, Api } from './generated/api'
8
- // With --service-name {{Name}}: import { create{{Name}}Bindings, {{Name}} } from './generated/api'
9
-
10
- // Create the typed client
11
- export const {{name}}Client = createClient({
6
+ import { createFetchAdapter } from 'ts-procedures/client'
7
+ import { createApiClient, ApiErrors } from './generated/api'
8
+ // With --service-name {{Name}}: import { create{{Name}}Client, {{Name}}Errors } from './generated/api'
9
+
10
+ // --- Optional: type per-request meta end-to-end via declaration merging ---
11
+ // Self-contained clients (the default) augment './generated/_types':
12
+ declare module './generated/_types' {
13
+ interface RequestMeta {
14
+ // TODO: add your fields — typed in options.meta, ctx.request.meta, adapter req.meta
15
+ // traceId?: string
16
+ // priority?: 'high' | 'low'
17
+ }
18
+ }
19
+
20
+ // Create the typed client — createApiClient wires the error registry
21
+ // automatically so non-2xx responses arrive as typed class instances you can
22
+ // catch with `instanceof ApiErrors.<ErrorName>`.
23
+ export const {{name}}Client = createApiClient({
12
24
  adapter: createFetchAdapter(),
13
25
  basePath: 'http://localhost:3000', // TODO: configure base URL
14
- scopes: createApiBindings,
15
- // With --service-name {{Name}}: scopes: create{{Name}}Bindings,
26
+ defaults: {
27
+ // TODO: set client-wide defaults (overridden by per-call options)
28
+ // timeout: 30_000,
29
+ // headers: { 'X-Client-Version': '1.0.0' },
30
+ },
16
31
  hooks: {
17
32
  onBeforeRequest(ctx) {
18
33
  // TODO: add auth headers, request IDs, etc.
@@ -29,6 +44,15 @@ export const {{name}}Client = createClient({
29
44
  },
30
45
  })
31
46
 
47
+ // --- Typed error handling ---
48
+ // try {
49
+ // const user = await {{name}}Client.users.GetUser({ pathParams: { id: '123' } })
50
+ // } catch (err) {
51
+ // if (err instanceof ApiErrors.UseCaseError) { /* err.body typed; err.status, procedureName, scope */ }
52
+ // else if (err instanceof ApiErrors.ApiProcedureError) { /* catch-all for service errors */ }
53
+ // else { /* transport error — ClientRequestError */ }
54
+ // }
55
+
32
56
  // --- RPC call example ---
33
57
  // const user = await {{name}}Client.users.GetUser({ userId: '123' })
34
58
 
@@ -42,12 +66,24 @@ export const {{name}}Client = createClient({
42
66
  // }
43
67
  // const result = await stream.result // Typed from server returnType schema
44
68
 
45
- // --- Per-procedure hook override ---
46
- // const user = await {{name}}Client.users.GetUser({ id: '123' }, {
47
- // onAfterResponse(ctx) {
48
- // console.log('Rate limit:', ctx.response.headers['x-rate-limit-remaining'])
69
+ // --- Per-call options (timeout, signal, headers, basePath, meta, hooks) ---
70
+ // const user = await {{name}}Client.users.GetUser(
71
+ // { id: '123' },
72
+ // {
73
+ // timeout: 5000,
74
+ // headers: { 'X-Request-Id': crypto.randomUUID() },
75
+ // // meta is typed via RequestMeta augmentation above:
76
+ // // meta: { traceId: 'req-abc' },
77
+ // onAfterResponse(ctx) {
78
+ // console.log('Rate limit:', ctx.response.headers['x-rate-limit-remaining'])
79
+ // },
49
80
  // },
50
- // })
81
+ // )
82
+
83
+ // --- Cancellation with AbortController ---
84
+ // const controller = new AbortController()
85
+ // const promise = {{name}}Client.users.GetUser({ id: '123' }, { signal: controller.signal })
86
+ // setTimeout(() => controller.abort(), 3000)
51
87
  ```
52
88
 
53
89
  ## Test — `{{Name}}.client.test.ts`
@@ -77,11 +113,10 @@ describe('{{Name}} Client', () => {
77
113
  // expect(result.id).toBe('test-id')
78
114
  })
79
115
 
80
- test('handles validation errors from the server', async () => {
81
- // TODO: call with invalid params and verify error handling
82
- // await expect(
83
- // {{name}}Client.users.GetUser({} as any)
84
- // ).rejects.toThrow()
116
+ test('dispatches typed errors from the server via the registry', async () => {
117
+ // TODO: call with invalid params and verify typed error dispatch
118
+ // const call = {{name}}Client.users.GetUser({} as any)
119
+ // await expect(call).rejects.toBeInstanceOf(ApiErrors.ProcedureValidationError)
85
120
  })
86
121
 
87
122
  test('streams data with typed yields', async () => {
@@ -3,7 +3,8 @@
3
3
  ## Implementation — `{{Name}}.rpc.ts`
4
4
 
5
5
  ```typescript
6
- import { Procedures, ProcedureError, ProcedureValidationError } from 'ts-procedures'
6
+ import { Procedures } from 'ts-procedures'
7
+ import { defineErrorTaxonomy } from 'ts-procedures/express-rpc'
7
8
  import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
8
9
  import type { RPCConfig } from 'ts-procedures/http'
9
10
  import { Type } from 'typebox'
@@ -60,26 +61,28 @@ export const { ListItems } = RPC.Create(
60
61
  }
61
62
  )
62
63
 
64
+ // ─── Error Taxonomy ───────────────────────────────────────
65
+ // Declare the error classes this service throws. Framework errors
66
+ // (ProcedureValidationError → 400, ctx.error() → 500) are caught by the
67
+ // default taxonomy automatically. Add your own classes here — handlers just
68
+ // `throw` them and the builder serializes via this map. See
69
+ // docs/http-integrations.md#error-handling for the full contract.
70
+
71
+ const {{name}}Errors = defineErrorTaxonomy({
72
+ // Example — replace with your app's error classes:
73
+ // NotFoundError: { class: NotFoundError, statusCode: 404 },
74
+ // AuthError: { class: AuthError, statusCode: 401 },
75
+ })
76
+
63
77
  // ─── Express App Builder ──────────────────────────────────
64
78
 
65
79
  export const {{name}}App = new ExpressRPCAppBuilder({
66
80
  pathPrefix: '/api',
67
- onError: (procedure, req, res, error) => {
68
- if (error instanceof ProcedureValidationError) {
69
- res.status(400).json({
70
- error: error.message,
71
- details: error.errors,
72
- procedure: error.procedureName,
73
- })
74
- } else if (error instanceof ProcedureError) {
75
- res.status(422).json({
76
- error: error.message,
77
- meta: error.meta,
78
- procedure: error.procedureName,
79
- })
80
- } else {
81
- res.status(500).json({ error: 'Internal server error' })
82
- }
81
+ errors: {{name}}Errors,
82
+ unknownError: {
83
+ statusCode: 500,
84
+ toResponse: () => ({ name: 'InternalServerError', message: 'Unexpected error' }),
85
+ onCatch: (err, { procedure }) => console.error(`[${procedure.name}]`, err),
83
86
  },
84
87
  })
85
88
  .register(RPC, async (req) => ({
@@ -3,7 +3,8 @@
3
3
  ## Implementation — `{{Name}}.api.ts`
4
4
 
5
5
  ```typescript
6
- import { Procedures, ProcedureError, ProcedureValidationError } from 'ts-procedures'
6
+ import { Procedures } from 'ts-procedures'
7
+ import { defineErrorTaxonomy } from 'ts-procedures/hono-api'
7
8
  import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
8
9
  import type { APIConfig, APIInput } from 'ts-procedures/http'
9
10
  import { Type } from 'typebox'
@@ -81,25 +82,28 @@ export const { DeleteItem } = API.Create(
81
82
  }
82
83
  )
83
84
 
85
+ // ─── Error Taxonomy ───────────────────────────────────────
86
+ // Declare the error classes this service throws. Framework errors
87
+ // (ProcedureValidationError → 400, ctx.error() → 500) are caught by the
88
+ // default taxonomy automatically. Add your own classes here — handlers just
89
+ // `throw` them and the builder serializes via this map. See
90
+ // docs/http-integrations.md#error-handling for the full contract.
91
+
92
+ const {{name}}Errors = defineErrorTaxonomy({
93
+ // Example — replace with your app's error classes:
94
+ // NotFoundError: { class: NotFoundError, statusCode: 404 },
95
+ // AuthError: { class: AuthError, statusCode: 401 },
96
+ })
97
+
84
98
  // ─── Hono App Builder ─────────────────────────────────────
85
99
 
86
100
  export const {{name}}App = new HonoAPIAppBuilder({
87
101
  pathPrefix: '/api',
88
- onError: (procedure, c, error) => {
89
- if (error instanceof ProcedureValidationError) {
90
- return c.json({
91
- error: error.message,
92
- details: error.errors,
93
- procedure: error.procedureName,
94
- }, 400)
95
- } else if (error instanceof ProcedureError) {
96
- return c.json({
97
- error: error.message,
98
- meta: error.meta,
99
- procedure: error.procedureName,
100
- }, 422)
101
- }
102
- return c.json({ error: 'Internal server error' }, 500)
102
+ errors: {{name}}Errors,
103
+ unknownError: {
104
+ statusCode: 500,
105
+ toResponse: () => ({ name: 'InternalServerError', message: 'Unexpected error' }),
106
+ onCatch: (err, { procedure }) => console.error(`[${procedure.name}]`, err),
103
107
  },
104
108
  })
105
109
  .register(API, (c) => ({