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
@@ -0,0 +1,211 @@
1
+ /* eslint-disable @typescript-eslint/no-empty-object-type */
2
+ import { describe, expect, test } from 'vitest'
3
+ import { Type } from 'typebox'
4
+ import { Procedures } from '../index.js'
5
+ import { APIConfig } from '../implementations/types.js'
6
+ import { HonoAPIAppBuilder, defineErrorTaxonomy } from '../implementations/http/hono-api/index.js'
7
+ import { createClient } from './index.js'
8
+ import { ClientRequestError } from './errors.js'
9
+ import type { ClientAdapter, ErrorRegistry } from './types.js'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Error taxonomy + simulated generated error classes
13
+ // ---------------------------------------------------------------------------
14
+
15
+ class UseCaseError extends Error {
16
+ constructor(
17
+ readonly externalMsg: string,
18
+ readonly internalMsg: string
19
+ ) {
20
+ super(externalMsg)
21
+ this.name = 'UseCaseError'
22
+ Object.setPrototypeOf(this, UseCaseError.prototype)
23
+ }
24
+ }
25
+
26
+ const appErrors = defineErrorTaxonomy({
27
+ UseCaseError: {
28
+ class: UseCaseError,
29
+ statusCode: 422,
30
+ toResponse: (err) => ({ message: err.externalMsg }),
31
+ },
32
+ })
33
+
34
+ // Simulates what the codegen emits: a runtime class + a registry entry the
35
+ // client uses for dispatch. In real usage this comes from the generated
36
+ // `_errors.ts`; here we inline it to test the client-side dispatch path end
37
+ // to end without invoking the codegen pipeline.
38
+ class ApiUseCaseError extends Error {
39
+ readonly status: number
40
+ readonly procedureName: string
41
+ readonly scope: string
42
+ readonly body: { name: 'UseCaseError'; message: string }
43
+ constructor(args: {
44
+ message: string
45
+ status: number
46
+ procedureName: string
47
+ scope: string
48
+ body: { name: 'UseCaseError'; message: string }
49
+ }) {
50
+ super(args.message)
51
+ this.name = 'UseCaseError'
52
+ this.status = args.status
53
+ this.procedureName = args.procedureName
54
+ this.scope = args.scope
55
+ this.body = args.body
56
+ Object.setPrototypeOf(this, ApiUseCaseError.prototype)
57
+ }
58
+
59
+ static fromResponse(
60
+ body: unknown,
61
+ meta: { status: number; procedureName: string; scope: string }
62
+ ): ApiUseCaseError {
63
+ const b = body as { name: 'UseCaseError'; message: string }
64
+ return new ApiUseCaseError({
65
+ message: b.message,
66
+ status: meta.status,
67
+ procedureName: meta.procedureName,
68
+ scope: meta.scope,
69
+ body: b,
70
+ })
71
+ }
72
+ }
73
+
74
+ const errorRegistry: ErrorRegistry = { UseCaseError: ApiUseCaseError }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Tiny adapter that routes through the in-memory Hono app (no real network)
78
+ // ---------------------------------------------------------------------------
79
+
80
+ interface HonoAppLike {
81
+ request(
82
+ url: string,
83
+ init: { method?: string; headers?: Record<string, string>; body?: string }
84
+ ): Response | Promise<Response>
85
+ }
86
+
87
+ function honoAdapter(app: HonoAppLike): ClientAdapter {
88
+ return {
89
+ async request(req) {
90
+ const res = await Promise.resolve(
91
+ app.request(req.url, {
92
+ method: req.method,
93
+ headers: req.headers,
94
+ body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
95
+ })
96
+ )
97
+ const headers: Record<string, string> = {}
98
+ res.headers.forEach((v, k) => (headers[k] = v))
99
+ const body = await res.json().catch(() => null)
100
+ return { status: res.status, headers, body }
101
+ },
102
+ async stream() {
103
+ throw new Error('not used')
104
+ },
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Tests
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe('typed error dispatch — end-to-end', () => {
113
+ function buildApp() {
114
+ const API = Procedures<{}, APIConfig>()
115
+ API.Create(
116
+ 'GetUser',
117
+ {
118
+ path: '/users/:id',
119
+ method: 'get',
120
+ schema: {
121
+ input: { pathParams: Type.Object({ id: Type.String() }) },
122
+ returnType: Type.Object({ id: Type.String() }),
123
+ },
124
+ },
125
+ async (_ctx, { pathParams }) => {
126
+ if (pathParams.id === 'missing') {
127
+ throw new UseCaseError('User not found', `no user with id=${pathParams.id}`)
128
+ }
129
+ return { id: pathParams.id }
130
+ }
131
+ )
132
+ return new HonoAPIAppBuilder({ errors: appErrors }).register(API, () => ({})).build()
133
+ }
134
+
135
+ test('server-thrown UseCaseError arrives on client as a typed class instance', async () => {
136
+ const app = buildApp()
137
+ const api = createClient({
138
+ adapter: honoAdapter(app),
139
+ basePath: '',
140
+ errorRegistry,
141
+ scopes: (client) => ({
142
+ getUser: (id: string) =>
143
+ client.call<{ id: string }>({
144
+ name: 'GetUser',
145
+ scope: 'users',
146
+ path: '/users/:id',
147
+ method: 'get',
148
+ kind: 'api',
149
+ params: { pathParams: { id } },
150
+ }),
151
+ }),
152
+ })
153
+
154
+ await expect(api.getUser('missing')).rejects.toBeInstanceOf(ApiUseCaseError)
155
+ try {
156
+ await api.getUser('missing')
157
+ } catch (err) {
158
+ expect(err).toBeInstanceOf(ApiUseCaseError)
159
+ expect(err).toBeInstanceOf(Error)
160
+ expect((err as ApiUseCaseError).status).toBe(422)
161
+ expect((err as ApiUseCaseError).procedureName).toBe('GetUser')
162
+ expect((err as ApiUseCaseError).message).toBe('User not found')
163
+ expect((err as ApiUseCaseError).body.name).toBe('UseCaseError')
164
+ }
165
+ })
166
+
167
+ test('unregistered error body falls back to ClientRequestError', async () => {
168
+ const app = buildApp()
169
+ // Omit the registry so dispatch can't match; client sees the raw
170
+ // transport error instead of a typed class.
171
+ const api = createClient({
172
+ adapter: honoAdapter(app),
173
+ basePath: '',
174
+ scopes: (client) => ({
175
+ getUser: (id: string) =>
176
+ client.call<{ id: string }>({
177
+ name: 'GetUser',
178
+ scope: 'users',
179
+ path: '/users/:id',
180
+ method: 'get',
181
+ kind: 'api',
182
+ params: { pathParams: { id } },
183
+ }),
184
+ }),
185
+ })
186
+
187
+ await expect(api.getUser('missing')).rejects.toBeInstanceOf(ClientRequestError)
188
+ })
189
+
190
+ test('success responses are not disturbed by dispatch logic', async () => {
191
+ const app = buildApp()
192
+ const api = createClient({
193
+ adapter: honoAdapter(app),
194
+ basePath: '',
195
+ errorRegistry,
196
+ scopes: (client) => ({
197
+ getUser: (id: string) =>
198
+ client.call<{ id: string }>({
199
+ name: 'GetUser',
200
+ scope: 'users',
201
+ path: '/users/:id',
202
+ method: 'get',
203
+ kind: 'api',
204
+ params: { pathParams: { id } },
205
+ }),
206
+ }),
207
+ })
208
+
209
+ await expect(api.getUser('u_42')).resolves.toEqual({ id: 'u_42' })
210
+ })
211
+ })
@@ -1,3 +1,35 @@
1
+ // ── Request Metadata ─────────────────────────────────────
2
+
3
+ /**
4
+ * Per-request metadata visible to adapters and hooks. Defined as an empty
5
+ * interface so consumers can augment it via TypeScript declaration merging
6
+ * to get end-to-end type safety for their own metadata fields.
7
+ *
8
+ * @example With a non-self-contained client:
9
+ * ```ts
10
+ * declare module 'ts-procedures/client' {
11
+ * interface RequestMeta {
12
+ * traceId?: string
13
+ * priority?: 'high' | 'low'
14
+ * }
15
+ * }
16
+ * ```
17
+ *
18
+ * @example With a self-contained (code-generated) client:
19
+ * ```ts
20
+ * declare module './generated/_types' {
21
+ * interface RequestMeta {
22
+ * traceId?: string
23
+ * }
24
+ * }
25
+ * ```
26
+ *
27
+ * After augmentation, `request.meta.traceId` is typed everywhere — per-call
28
+ * options, hooks, and adapters.
29
+ */
30
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
31
+ export interface RequestMeta {}
32
+
1
33
  // ── Adapter ──────────────────────────────────────────────
2
34
 
3
35
  export interface ClientAdapter {
@@ -11,6 +43,11 @@ export interface AdapterRequest {
11
43
  headers?: Record<string, string>
12
44
  body?: unknown
13
45
  signal?: AbortSignal
46
+ /**
47
+ * Per-request metadata. Augment `RequestMeta` via declaration merging to
48
+ * type your own fields. See {@link RequestMeta}.
49
+ */
50
+ meta?: RequestMeta
14
51
  }
15
52
 
16
53
  export interface AdapterResponse {
@@ -23,6 +60,12 @@ export interface AdapterStreamResponse {
23
60
  status: number
24
61
  headers: Record<string, string>
25
62
  body: AsyncIterable<unknown>
63
+ /**
64
+ * Populated when `status` is non-2xx — the parsed response body. Surfaced so
65
+ * `executeStream` can dispatch typed errors via the error registry instead
66
+ * of always falling back to `ClientRequestError` with `body: null`.
67
+ */
68
+ errorBody?: unknown
26
69
  }
27
70
 
28
71
  // ── Hooks ────────────────────────────────────────────────
@@ -81,14 +124,72 @@ export interface TypedStream<TYield, TReturn = void> extends AsyncIterable<TYiel
81
124
  result: Promise<TReturn>
82
125
  }
83
126
 
84
- // ── Client Instance ──────────────────────────────────────
127
+ // ── Request Options ──────────────────────────────────────
128
+
129
+ /**
130
+ * Request-level configuration that can be set as client-level defaults
131
+ * (via `CreateClientConfig.defaults`) or per-call (via `ProcedureCallOptions`).
132
+ *
133
+ * - `signal`: AbortSignal for cancellation. When both a default and per-call
134
+ * signal are provided, they're combined — whichever aborts first wins.
135
+ * - `timeout`: Timeout in milliseconds. Combined with `signal` the same way.
136
+ * A per-call `timeout: 0` disables an inherited default timeout.
137
+ * - `headers`: Extra headers merged into the request. Per-call keys win over
138
+ * default keys. Still subject to further mutation by `onBeforeRequest` hooks.
139
+ * - `basePath`: Override the base path for this call. Per-call > default > config.
140
+ * - `meta`: Per-request metadata typed via the {@link RequestMeta} interface.
141
+ * Merged shallowly (per-call keys win over default keys).
142
+ */
143
+ export interface ProcedureCallDefaults {
144
+ signal?: AbortSignal
145
+ timeout?: number
146
+ headers?: Record<string, string>
147
+ basePath?: string
148
+ meta?: RequestMeta
149
+ }
150
+
151
+ /**
152
+ * Per-call options. Extends both `ProcedureCallDefaults` (request config) and
153
+ * `ClientHooks` (hooks), so a single options bag covers both concerns.
154
+ */
155
+ export interface ProcedureCallOptions extends ProcedureCallDefaults, ClientHooks {}
156
+
157
+ // ── Error Registry ───────────────────────────────────────
158
+
159
+ /**
160
+ * Metadata attached to a typed error at construction. Supplies the transport
161
+ * context (status, procedure, scope) that isn't part of the response body.
162
+ */
163
+ export interface ErrorResponseMeta {
164
+ status: number
165
+ procedureName: string
166
+ scope: string
167
+ }
168
+
169
+ /**
170
+ * A factory for a typed error class — constructed from the response body plus
171
+ * transport metadata. Generated error classes expose this as a static method.
172
+ */
173
+ export interface ErrorFactory {
174
+ fromResponse(body: unknown, meta: ErrorResponseMeta): Error
175
+ }
85
176
 
86
- export type ProcedureCallOptions = ClientHooks
177
+ /**
178
+ * Maps `body.name` values (taxonomy keys) to error class factories. When the
179
+ * client sees a non-2xx response whose body has a `name` matching a registry
180
+ * entry, it throws the typed error instead of a generic `ClientRequestError`.
181
+ */
182
+ export type ErrorRegistry = Record<string, ErrorFactory>
183
+
184
+ // ── Client Instance ──────────────────────────────────────
87
185
 
88
186
  export interface ClientInstance {
89
187
  basePath: string
90
188
  adapter: ClientAdapter
91
189
  hooks: ClientHooks
190
+ defaults: ProcedureCallDefaults
191
+ /** Optional registry for runtime dispatch of typed errors by `body.name`. */
192
+ errorRegistry?: ErrorRegistry
92
193
  call<TResponse>(descriptor: CallDescriptor, options?: ProcedureCallOptions): Promise<TResponse>
93
194
  stream<TYield, TReturn>(descriptor: StreamDescriptor, options?: ProcedureCallOptions): TypedStream<TYield, TReturn>
94
195
  }
@@ -100,4 +201,17 @@ export interface CreateClientConfig<TScopes> {
100
201
  basePath: string
101
202
  scopes: (client: ClientInstance) => TScopes
102
203
  hooks?: ClientHooks
204
+ /**
205
+ * Default request options applied to every call. Per-call options override
206
+ * these (except `signal`, which combines via AbortSignal.any — whichever
207
+ * fires first cancels the request).
208
+ */
209
+ defaults?: ProcedureCallDefaults
210
+ /**
211
+ * Optional error-dispatch registry. When a non-2xx response body has a
212
+ * `name` field matching a registry key, the client throws the typed error
213
+ * constructed via that entry's `fromResponse`. When absent or when no key
214
+ * matches, falls back to `ClientRequestError` (transport error shape).
215
+ */
216
+ errorRegistry?: ErrorRegistry
103
217
  }
@@ -339,12 +339,13 @@ describe('E2E: generateClient full pipeline', () => {
339
339
  expect(existsSync(join(tmpDir, '_errors.ts'))).toBe(true)
340
340
  })
341
341
 
342
- it('_errors.ts contains ProcedureError type', async () => {
342
+ it('_errors.ts contains a runtime class for ProcedureError', async () => {
343
343
  tmpDir = makeTmpDir()
344
344
  await generateClient({ envelope, outDir: tmpDir })
345
345
 
346
346
  const content = readFileSync(join(tmpDir, '_errors.ts'), 'utf-8')
347
- expect(content).toContain('export type ProcedureError =')
347
+ expect(content).toContain('export class ProcedureError')
348
+ expect(content).toContain('static fromResponse(')
348
349
  })
349
350
 
350
351
  it('_errors.ts contains the service-prefixed ProcedureErrorUnion', async () => {
@@ -357,12 +358,16 @@ describe('E2E: generateClient full pipeline', () => {
357
358
  expect(content).toContain('ProcedureValidationError')
358
359
  })
359
360
 
360
- it('index.ts does not import _errors when namespaceTypes is off', async () => {
361
+ it('index.ts imports the _errors registry as a runtime value when errors are present', async () => {
362
+ // PR 3 change: the error registry is imported as a value (not `import
363
+ // type`) so `createApiClient` can wire it into `createClient` regardless
364
+ // of `namespaceTypes`.
361
365
  tmpDir = makeTmpDir()
362
366
  await generateClient({ envelope, outDir: tmpDir })
363
367
 
364
368
  const content = readFileSync(join(tmpDir, 'index.ts'), 'utf-8')
365
- expect(content).not.toContain("from './_errors'")
369
+ expect(content).toContain("import * as _errorsModule from './_errors'")
370
+ expect(content).toContain('errorRegistry: _errorsModule.ApiErrorRegistry')
366
371
  })
367
372
 
368
373
  it('index.ts folds errors into the service namespace when namespaceTypes is on', async () => {
@@ -481,6 +486,157 @@ describe('E2E: generateClient full pipeline', () => {
481
486
  execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
482
487
  }).not.toThrow()
483
488
  })
489
+
490
+ it('_types.ts exports RequestMeta (empty interface ready for augmentation)', async () => {
491
+ tmpDir = makeTmpDir()
492
+ await generateClient({ envelope, outDir: tmpDir, selfContained: true })
493
+
494
+ const content = readFileSync(join(tmpDir, '_types.ts'), 'utf-8')
495
+ expect(content).toContain('export interface RequestMeta')
496
+ // meta on AdapterRequest and ProcedureCallDefaults should be typed as RequestMeta
497
+ expect(content).toContain('meta?: RequestMeta')
498
+ })
499
+
500
+ it('developers can augment RequestMeta for typed per-call meta + typed hook/adapter access', async () => {
501
+ tmpDir = makeTmpDir()
502
+ await generateClient({ envelope, outDir: tmpDir, selfContained: true })
503
+
504
+ // Write a consumer file that augments RequestMeta, then uses typed meta
505
+ // end-to-end: per-call options, createClient defaults, onBeforeRequest, and adapter.
506
+ const consumer = `
507
+ import { createClient } from './_client'
508
+ import type { ClientAdapter } from './_types'
509
+ import { createApiBindings } from './index'
510
+
511
+ declare module './_types' {
512
+ interface RequestMeta {
513
+ traceId: string
514
+ priority?: 'high' | 'low'
515
+ }
516
+ }
517
+
518
+ const typedAdapter: ClientAdapter = {
519
+ async request(req) {
520
+ // req.meta is now typed
521
+ const trace: string | undefined = req.meta?.traceId
522
+ const pri: 'high' | 'low' | undefined = req.meta?.priority
523
+ void trace; void pri
524
+ return { status: 200, headers: {}, body: {} }
525
+ },
526
+ async stream(req) {
527
+ const trace: string | undefined = req.meta?.traceId
528
+ void trace
529
+ return { status: 200, headers: {}, body: (async function*() {})() }
530
+ },
531
+ }
532
+
533
+ const client = createClient({
534
+ adapter: typedAdapter,
535
+ basePath: 'https://api.example.com',
536
+ scopes: createApiBindings,
537
+ defaults: {
538
+ meta: { traceId: 'default-trace' }, // typed
539
+ },
540
+ hooks: {
541
+ onBeforeRequest(ctx) {
542
+ // ctx.request.meta is typed via declaration merging
543
+ const trace: string | undefined = ctx.request.meta?.traceId
544
+ void trace
545
+ return ctx
546
+ },
547
+ },
548
+ })
549
+
550
+ async function run(): Promise<void> {
551
+ // Per-call meta is typed — traceId is required, priority is optional
552
+ await client.users.GetUser(
553
+ { id: '1' },
554
+ { meta: { traceId: 'per-call-trace', priority: 'high' } },
555
+ )
556
+ // Timeout + signal + headers typecheck
557
+ await client.users.GetUser(
558
+ { id: '2' },
559
+ { timeout: 5000, headers: { 'X-Request-Id': 'abc' }, basePath: 'https://other' },
560
+ )
561
+ }
562
+ void run
563
+ `
564
+ const { writeFileSync } = await import('node:fs')
565
+ writeFileSync(join(tmpDir, 'consumer.ts'), consumer)
566
+
567
+ const tsconfig = {
568
+ compilerOptions: {
569
+ strict: true,
570
+ target: 'ES2022',
571
+ module: 'ES2022',
572
+ moduleResolution: 'bundler',
573
+ noEmit: true,
574
+ skipLibCheck: true,
575
+ },
576
+ include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', 'events.ts', '_errors.ts', 'consumer.ts'],
577
+ }
578
+ writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
579
+
580
+ const { execSync } = await import('node:child_process')
581
+ const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
582
+ expect(() => {
583
+ execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
584
+ }).not.toThrow()
585
+ })
586
+
587
+ it('augmented RequestMeta rejects wrong types (compile error)', async () => {
588
+ tmpDir = makeTmpDir()
589
+ await generateClient({ envelope, outDir: tmpDir, selfContained: true })
590
+
591
+ // Passing a number for `traceId` (declared as string) should fail tsc
592
+ const consumer = `
593
+ import { createClient, createFetchAdapter } from './_client'
594
+ import { createApiBindings } from './index'
595
+ // RequestMeta is imported only for augmentation below
596
+ import type {} from './_types'
597
+
598
+ declare module './_types' {
599
+ interface RequestMeta {
600
+ traceId: string
601
+ }
602
+ }
603
+
604
+ const client = createClient({
605
+ adapter: createFetchAdapter(),
606
+ basePath: 'https://api.example.com',
607
+ scopes: createApiBindings,
608
+ })
609
+
610
+ async function run(): Promise<void> {
611
+ // @ts-expect-error traceId must be string, not number
612
+ await client.users.GetUser({ id: '1' }, { meta: { traceId: 42 } })
613
+ }
614
+ void run
615
+ `
616
+ const { writeFileSync } = await import('node:fs')
617
+ writeFileSync(join(tmpDir, 'consumer.ts'), consumer)
618
+
619
+ const tsconfig = {
620
+ compilerOptions: {
621
+ strict: true,
622
+ target: 'ES2022',
623
+ module: 'ES2022',
624
+ moduleResolution: 'bundler',
625
+ noEmit: true,
626
+ skipLibCheck: true,
627
+ },
628
+ include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', 'events.ts', '_errors.ts', 'consumer.ts'],
629
+ }
630
+ writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
631
+
632
+ const { execSync } = await import('node:child_process')
633
+ const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
634
+ // With @ts-expect-error in place, tsc should pass; if RequestMeta wasn't
635
+ // enforcing the type, @ts-expect-error would fail because there'd be no error.
636
+ expect(() => {
637
+ execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
638
+ }).not.toThrow()
639
+ })
484
640
  })
485
641
 
486
642
  // ── namespaceTypes mode ───────────────────────────────────────────────────
@@ -16,8 +16,13 @@ const TYPES_IMPORT = `import type {
16
16
  StreamDescriptor,
17
17
  TypedStream,
18
18
  ClientInstance,
19
+ ProcedureCallDefaults,
19
20
  ProcedureCallOptions,
20
21
  CreateClientConfig,
22
+ RequestMeta,
23
+ ErrorRegistry,
24
+ ErrorFactory,
25
+ ErrorResponseMeta,
21
26
  } from './_types'`
22
27
 
23
28
  /**
@@ -26,7 +31,9 @@ const TYPES_IMPORT = `import type {
26
31
  */
27
32
  const SOURCE_FILES = [
28
33
  'errors.ts',
34
+ 'error-dispatch.ts',
29
35
  'request-builder.ts',
36
+ 'resolve-options.ts',
30
37
  'hooks.ts',
31
38
  'call.ts',
32
39
  'stream.ts',