ts-procedures 5.9.0 → 5.10.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 (80) hide show
  1. package/README.md +1 -1
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +46 -101
  3. package/agent_config/claude-code/skills/guide/SKILL.md +49 -34
  4. package/agent_config/claude-code/skills/guide/anti-patterns.md +6 -5
  5. package/agent_config/claude-code/skills/guide/api-reference.md +60 -49
  6. package/agent_config/claude-code/skills/review/SKILL.md +12 -17
  7. package/agent_config/claude-code/skills/scaffold/SKILL.md +18 -23
  8. package/agent_config/claude-code/skills/scaffold/templates/client.md +115 -0
  9. package/agent_config/lib/install-claude.mjs +22 -22
  10. package/docs/core.md +5 -9
  11. package/docs/streaming.md +9 -9
  12. package/package.json +3 -14
  13. package/src/client/call.test.ts +162 -0
  14. package/src/client/errors.test.ts +43 -0
  15. package/src/client/fetch-adapter.test.ts +340 -0
  16. package/src/client/hooks.test.ts +191 -0
  17. package/src/client/index.test.ts +290 -0
  18. package/src/client/request-builder.test.ts +184 -0
  19. package/src/client/stream.test.ts +331 -0
  20. package/src/codegen/bin/cli.test.ts +260 -0
  21. package/src/codegen/bin/cli.ts +282 -0
  22. package/src/codegen/constants.ts +1 -0
  23. package/src/codegen/e2e.test.ts +565 -0
  24. package/src/codegen/emit-client-runtime.test.ts +93 -0
  25. package/src/codegen/emit-client-runtime.ts +114 -0
  26. package/src/codegen/emit-client-types.test.ts +39 -0
  27. package/src/codegen/emit-client-types.ts +27 -0
  28. package/src/codegen/emit-errors.test.ts +202 -0
  29. package/src/codegen/emit-errors.ts +80 -0
  30. package/src/codegen/emit-index.test.ts +127 -0
  31. package/src/codegen/emit-index.ts +58 -0
  32. package/src/codegen/emit-scope.test.ts +624 -0
  33. package/src/codegen/emit-scope.ts +389 -0
  34. package/src/codegen/emit-types.test.ts +205 -0
  35. package/src/codegen/emit-types.ts +158 -0
  36. package/src/codegen/group-routes.test.ts +159 -0
  37. package/src/codegen/group-routes.ts +61 -0
  38. package/src/codegen/index.ts +30 -0
  39. package/src/codegen/naming.test.ts +50 -0
  40. package/src/codegen/naming.ts +25 -0
  41. package/src/codegen/pipeline.test.ts +316 -0
  42. package/src/codegen/pipeline.ts +108 -0
  43. package/src/codegen/resolve-envelope.test.ts +76 -0
  44. package/src/codegen/resolve-envelope.ts +61 -0
  45. package/src/errors.test.ts +163 -0
  46. package/src/errors.ts +107 -0
  47. package/src/exports.ts +7 -0
  48. package/src/implementations/http/doc-registry.test.ts +415 -0
  49. package/src/implementations/http/doc-registry.ts +143 -0
  50. package/src/implementations/http/express-rpc/README.md +6 -6
  51. package/src/implementations/http/express-rpc/index.test.ts +957 -0
  52. package/src/implementations/http/express-rpc/index.ts +266 -0
  53. package/src/implementations/http/express-rpc/types.ts +16 -0
  54. package/src/implementations/http/hono-api/index.test.ts +1341 -0
  55. package/src/implementations/http/hono-api/index.ts +463 -0
  56. package/src/implementations/http/hono-api/types.ts +16 -0
  57. package/src/implementations/http/hono-rpc/README.md +6 -6
  58. package/src/implementations/http/hono-rpc/index.test.ts +1075 -0
  59. package/src/implementations/http/hono-rpc/index.ts +238 -0
  60. package/src/implementations/http/hono-rpc/types.ts +16 -0
  61. package/src/implementations/http/hono-stream/README.md +12 -12
  62. package/src/implementations/http/hono-stream/index.test.ts +1768 -0
  63. package/src/implementations/http/hono-stream/index.ts +456 -0
  64. package/src/implementations/http/hono-stream/types.ts +20 -0
  65. package/src/implementations/types.ts +174 -0
  66. package/src/index.test.ts +1185 -0
  67. package/src/index.ts +522 -0
  68. package/src/schema/compute-schema.test.ts +128 -0
  69. package/src/schema/compute-schema.ts +88 -0
  70. package/src/schema/extract-json-schema.test.ts +25 -0
  71. package/src/schema/extract-json-schema.ts +15 -0
  72. package/src/schema/parser.test.ts +182 -0
  73. package/src/schema/parser.ts +215 -0
  74. package/src/schema/resolve-schema-lib.test.ts +19 -0
  75. package/src/schema/resolve-schema-lib.ts +29 -0
  76. package/src/schema/types.ts +20 -0
  77. package/src/stack-utils.test.ts +94 -0
  78. package/src/stack-utils.ts +129 -0
  79. package/docs/superpowers/plans/2026-03-30-client-codegen.md +0 -2833
  80. package/docs/superpowers/specs/2026-03-30-client-codegen-design.md +0 -632
@@ -0,0 +1,115 @@
1
+ # Client Template: {{Name}}
2
+
3
+ ## Implementation — `{{Name}}.client.ts`
4
+
5
+ ```typescript
6
+ import { createClient, createFetchAdapter } from 'ts-procedures/client'
7
+ import { createScopeBindings } from './generated/api'
8
+ // With --service-name: import { create{{Name}}Bindings } from './generated/api'
9
+
10
+ // Create the typed client
11
+ export const {{name}}Client = createClient({
12
+ adapter: createFetchAdapter(),
13
+ basePath: 'http://localhost:3000', // TODO: configure base URL
14
+ scopes: createScopeBindings,
15
+ // With --service-name: scopes: create{{Name}}Bindings,
16
+ hooks: {
17
+ onBeforeRequest(ctx) {
18
+ // TODO: add auth headers, request IDs, etc.
19
+ ctx.request.headers = {
20
+ ...ctx.request.headers,
21
+ // Authorization: `Bearer ${getToken()}`,
22
+ }
23
+ return ctx
24
+ },
25
+ onAfterResponse(ctx) {
26
+ // TODO: handle global response concerns (401 redirect, rate limiting, etc.)
27
+ // if (ctx.response.status === 401) { redirect('/login') }
28
+ },
29
+ },
30
+ })
31
+
32
+ // --- RPC call example ---
33
+ // const user = await {{name}}Client.users.GetUser({ userId: '123' })
34
+
35
+ // --- API call example (with schema.input channels) ---
36
+ // const user = await {{name}}Client.users.GetUser({ pathParams: { id: '123' } })
37
+
38
+ // --- Streaming call example ---
39
+ // const stream = {{name}}Client.events.WatchNotifications({ filter: 'all' })
40
+ // for await (const event of stream) {
41
+ // console.log(event) // Typed from server yieldType schema
42
+ // }
43
+ // const result = await stream.result // Typed from server returnType schema
44
+
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'])
49
+ // },
50
+ // })
51
+ ```
52
+
53
+ ## Test — `{{Name}}.client.test.ts`
54
+
55
+ ```typescript
56
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
57
+ import { {{name}}Client } from './{{Name}}.client'
58
+
59
+ // TODO: start your server before tests, or mock the adapter
60
+ // import { createServer } from './server'
61
+ // let server: ReturnType<typeof createServer>
62
+
63
+ describe('{{Name}} Client', () => {
64
+ // beforeAll(async () => {
65
+ // server = createServer()
66
+ // await server.listen(3000)
67
+ // })
68
+ //
69
+ // afterAll(async () => {
70
+ // await server.close()
71
+ // })
72
+
73
+ test('makes RPC calls with typed params and response', async () => {
74
+ // TODO: replace with an actual procedure call
75
+ // const result = await {{name}}Client.users.GetUser({ userId: 'test-id' })
76
+ // expect(result).toBeDefined()
77
+ // expect(result.id).toBe('test-id')
78
+ })
79
+
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()
85
+ })
86
+
87
+ test('streams data with typed yields', async () => {
88
+ // TODO: replace with an actual stream procedure call
89
+ // const stream = {{name}}Client.events.WatchNotifications({ filter: 'all' })
90
+ // const values = []
91
+ // for await (const event of stream) {
92
+ // values.push(event)
93
+ // if (values.length >= 3) break
94
+ // }
95
+ // expect(values).toHaveLength(3)
96
+ // const result = await stream.result
97
+ // expect(result).toBeDefined()
98
+ })
99
+ })
100
+ ```
101
+
102
+ ## Code Generation Setup
103
+
104
+ Generate the typed client bindings from your running server:
105
+
106
+ ```bash
107
+ # Default (self-contained, namespaced types, JSDoc)
108
+ npx ts-procedures-codegen --url http://localhost:3000/docs --out ./src/generated/api
109
+
110
+ # With service name for multi-service apps
111
+ npx ts-procedures-codegen --url http://localhost:3000/docs --out ./src/generated/api --service-name {{Name}}
112
+
113
+ # Watch mode for development
114
+ npx ts-procedures-codegen --url http://localhost:3000/docs --out ./src/generated/api --watch
115
+ ```
@@ -59,42 +59,42 @@ export function installClaude(projectRoot) {
59
59
 
60
60
  // 1. Rules file — framework reference (always loaded in context)
61
61
  const guideSkill = readFileSync(join(SKILLS_DIR, 'guide', 'SKILL.md'), 'utf-8');
62
+ const guideBasePath = 'node_modules/ts-procedures/agent_config/claude-code/skills/guide';
62
63
  const guideBody = stripFrontmatter(guideSkill)
63
- // Remove the "Supporting Files" section replaced by "Detailed Reference" below
64
- .replace(/\n## Supporting Files[\s\S]*$/, '');
65
-
66
- const rulesContent = autoHeader + guideBody + `
67
-
68
- ## Detailed Reference
69
-
70
- For complete API details, patterns, and anti-patterns, read the files in:
71
- \`node_modules/ts-procedures/agent_config/claude-code/skills/guide/\`
72
-
73
- - \`api-reference.md\` — Full API reference for Procedures, Create, CreateStream, errors, schema, HTTP implementations
74
- - \`patterns.md\` — Prescribed patterns with code examples
75
- - \`anti-patterns.md\` — Common mistakes to avoid with fixes
76
- `;
77
-
78
- writeFileSync(join(rulesDir, 'ts-procedures.md'), rulesContent, 'utf-8');
64
+ // Replace relative markdown links with node_modules paths
65
+ .replace(/\[patterns\.md\]\(patterns\.md\)/g, `\`${guideBasePath}/patterns.md\``)
66
+ .replace(/\[anti-patterns\.md\]\(anti-patterns\.md\)/g, `\`${guideBasePath}/anti-patterns.md\``)
67
+ .replace(/\[api-reference\.md\]\(api-reference\.md\)/g, `\`${guideBasePath}/api-reference.md\``)
68
+ // Replace the Workflow section — skill cross-references don't apply outside the plugin
69
+ .replace(/\n## Workflow[\s\S]*$/, '');
70
+
71
+ writeFileSync(join(rulesDir, 'ts-procedures.md'), autoHeader + guideBody, 'utf-8');
79
72
  files.push('.claude/rules/ts-procedures.md');
80
73
 
81
74
  // 2. Scaffold command
82
75
  const scaffoldSkill = readFileSync(join(SKILLS_DIR, 'scaffold', 'SKILL.md'), 'utf-8');
76
+ const scaffoldBasePath = 'node_modules/ts-procedures/agent_config/claude-code/skills/scaffold';
83
77
  const scaffoldBody = stripFrontmatter(scaffoldSkill)
78
+ // Replace ${CLAUDE_SKILL_DIR} template path with node_modules path
84
79
  .replace(
85
- 'Read the template file from `templates/<type>.md` in this skill directory.',
86
- 'Read the template file from `node_modules/ts-procedures/agent_config/claude-code/skills/scaffold/templates/<type>.md`.'
87
- );
80
+ '`${CLAUDE_SKILL_DIR}/templates/$0.md`',
81
+ `\`${scaffoldBasePath}/templates/$0.md\``
82
+ )
83
+ // Replace Workflow section — skill cross-references don't apply outside the plugin
84
+ .replace(/\n## Workflow[\s\S]*$/, '');
88
85
 
89
86
  writeFileSync(join(commandsDir, 'ts-procedures-scaffold.md'), autoHeader + scaffoldBody, 'utf-8');
90
87
  files.push('.claude/commands/ts-procedures-scaffold.md');
91
88
 
92
89
  // 3. Review command
93
90
  const reviewSkill = readFileSync(join(SKILLS_DIR, 'review', 'SKILL.md'), 'utf-8');
91
+ const reviewBasePath = 'node_modules/ts-procedures/agent_config/claude-code/skills/review';
94
92
  const reviewBody = stripFrontmatter(reviewSkill)
95
- .replaceAll(
96
- '`checklist.md`',
97
- '`node_modules/ts-procedures/agent_config/claude-code/skills/review/checklist.md`'
93
+ // Replace relative markdown links with node_modules paths
94
+ .replace(/\[checklist\.md\]\(checklist\.md\)/g, `\`${reviewBasePath}/checklist.md\``)
95
+ .replace(
96
+ /\[anti-patterns\.md\]\(\.\.\/guide\/anti-patterns\.md\)/g,
97
+ `\`${guideBasePath}/anti-patterns.md\``
98
98
  );
99
99
 
100
100
  writeFileSync(join(commandsDir, 'ts-procedures-review.md'), autoHeader + reviewBody, 'utf-8');
package/docs/core.md CHANGED
@@ -203,18 +203,15 @@ const { CreateUser } = Create(
203
203
 
204
204
  ## Schema Validation
205
205
 
206
- ts-procedures supports two schema libraries — **TypeBox** (recommended) and **Suretype**. The library is auto-detected from the schema object. Both work identically at runtime; choose whichever you prefer:
206
+ ts-procedures uses **TypeBox** for schema definitions:
207
207
 
208
208
  ```typescript
209
- // TypeBox
210
209
  import { Type } from 'typebox'
211
210
  schema: { params: Type.Object({ title: Type.String() }) }
212
-
213
- // Suretype
214
- import { v } from 'suretype'
215
- schema: { params: v.object({ title: v.string().required() }) }
216
211
  ```
217
212
 
213
+ TypeBox schemas are valid JSON Schema and work directly with AJV for runtime validation.
214
+
218
215
  ### Validation Behavior
219
216
 
220
217
  AJV is configured with:
@@ -426,8 +423,8 @@ Defines a procedure.
426
423
  **Parameters:**
427
424
  - `name` - Unique procedure name (becomes named export)
428
425
  - `config.description` - Optional description
429
- - `config.schema.params` - Suretype or TypeBox schema for params (validated at runtime)
430
- - `config.schema.returnType` - Suretype or TypeBox schema for return returnType (documentation only)
426
+ - `config.schema.params` - TypeBox schema for params (validated at runtime)
427
+ - `config.schema.returnType` - TypeBox schema for return type (documentation only)
431
428
  - Additional properties from `TExtendedConfig`
432
429
  - `handler` - Async function `(ctx, params) => Promise<returnType>`
433
430
 
@@ -460,7 +457,6 @@ import {
460
457
  extractJsonSchema,
461
458
  schemaParser,
462
459
  isTypeboxSchema,
463
- isSuretypeSchema,
464
460
 
465
461
  // Schema types
466
462
  TJSONSchema,
package/docs/streaming.md CHANGED
@@ -10,7 +10,7 @@ For the `CreateStream` function signature and config options, see [Core Procedur
10
10
 
11
11
  ```typescript
12
12
  import { Procedures } from 'ts-procedures'
13
- import { v } from 'suretype'
13
+ import { Type } from 'typebox'
14
14
 
15
15
  const { CreateStream } = Procedures<{ userId: string }>()
16
16
 
@@ -19,11 +19,11 @@ const { StreamUpdates } = CreateStream(
19
19
  {
20
20
  description: 'Stream real-time updates',
21
21
  schema: {
22
- params: v.object({ topic: v.string().required() }),
23
- yieldType: v.object({
24
- id: v.string().required(),
25
- message: v.string().required(),
26
- timestamp: v.number().required(),
22
+ params: Type.Object({ topic: Type.String() }),
23
+ yieldType: Type.Object({
24
+ id: Type.String(),
25
+ message: Type.String(),
26
+ timestamp: Type.Number(),
27
27
  }),
28
28
  },
29
29
  },
@@ -60,7 +60,7 @@ const { ValidatedStream } = CreateStream(
60
60
  'ValidatedStream',
61
61
  {
62
62
  schema: {
63
- yieldType: v.object({ count: v.number().required() }),
63
+ yieldType: Type.Object({ count: Type.Number() }),
64
64
  },
65
65
  validateYields: true, // Enable runtime validation of each yield
66
66
  },
@@ -148,8 +148,8 @@ CreateStream(
148
148
  'LiveFeed',
149
149
  {
150
150
  schema: {
151
- params: v.object({ channel: v.string() }),
152
- yieldType: v.object({ event: v.string(), data: v.any() }),
151
+ params: Type.Object({ channel: Type.String() }),
152
+ yieldType: Type.Object({ event: Type.String(), data: Type.Any() }),
153
153
  },
154
154
  },
155
155
  async function* (ctx, params) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-procedures",
3
- "version": "5.9.0",
3
+ "version": "5.10.0",
4
4
  "description": "A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.",
5
5
  "main": "build/exports.js",
6
6
  "types": "build/exports.d.ts",
@@ -23,7 +23,7 @@
23
23
  "import": "./build/exports.js"
24
24
  },
25
25
  "./http": {
26
- "types": "./build/implementations/http/types.d.ts"
26
+ "types": "./build/implementations/types.d.ts"
27
27
  },
28
28
  "./express-rpc": {
29
29
  "types": "./build/implementations/http/express-rpc/index.d.ts",
@@ -61,18 +61,7 @@
61
61
  "build",
62
62
  "docs",
63
63
  "agent_config",
64
- "src/implementations/http/README.md",
65
- "src/implementations/http/express-rpc/README.md",
66
- "src/implementations/http/hono-rpc/README.md",
67
- "src/implementations/http/hono-stream/README.md",
68
- "src/client/types.ts",
69
- "src/client/errors.ts",
70
- "src/client/request-builder.ts",
71
- "src/client/hooks.ts",
72
- "src/client/call.ts",
73
- "src/client/stream.ts",
74
- "src/client/fetch-adapter.ts",
75
- "src/client/index.ts"
64
+ "src"
76
65
  ],
77
66
  "keywords": [
78
67
  "typescript",
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { executeCall } from './call.js'
3
+ import { ClientRequestError } from './errors.js'
4
+ import type {
5
+ ClientAdapter,
6
+ AdapterRequest,
7
+ AdapterResponse,
8
+ ClientHooks,
9
+ CallDescriptor,
10
+ } from './types.js'
11
+
12
+ // ── helpers ───────────────────────────────────────────────
13
+
14
+ function makeDescriptor(overrides?: Partial<CallDescriptor>): CallDescriptor {
15
+ return {
16
+ name: 'GetUser',
17
+ scope: 'users',
18
+ path: '/users',
19
+ method: 'GET',
20
+ kind: 'rpc',
21
+ params: { id: '42' },
22
+ ...overrides,
23
+ }
24
+ }
25
+
26
+ function makeAdapter(response?: Partial<AdapterResponse>): ClientAdapter {
27
+ return {
28
+ request: vi.fn(async (_req: AdapterRequest): Promise<AdapterResponse> => ({
29
+ status: 200,
30
+ headers: {},
31
+ body: { id: '42', name: 'Alice' },
32
+ ...response,
33
+ })),
34
+ stream: vi.fn(async () => {
35
+ throw new Error('stream not expected in call tests')
36
+ }),
37
+ }
38
+ }
39
+
40
+ // ── executeCall ───────────────────────────────────────────
41
+
42
+ describe('executeCall', () => {
43
+ it('calls adapter.request and returns body', async () => {
44
+ const adapter = makeAdapter({ body: { id: '1', name: 'Bob' } })
45
+ const result = await executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
46
+ expect(adapter.request).toHaveBeenCalledOnce()
47
+ expect(result).toEqual({ id: '1', name: 'Bob' })
48
+ })
49
+
50
+ it('throws ClientRequestError on 4xx response', async () => {
51
+ const adapter = makeAdapter({ status: 404, body: { message: 'Not Found' } })
52
+ await expect(
53
+ executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
54
+ ).rejects.toThrow(ClientRequestError)
55
+ })
56
+
57
+ it('throws ClientRequestError on 5xx response', async () => {
58
+ const adapter = makeAdapter({ status: 500, body: { message: 'Server Error' } })
59
+ await expect(
60
+ executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
61
+ ).rejects.toThrow(ClientRequestError)
62
+ })
63
+
64
+ it('throws ClientRequestError on 199 response (below 200)', async () => {
65
+ const adapter = makeAdapter({ status: 199, body: null })
66
+ await expect(
67
+ executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
68
+ ).rejects.toThrow(ClientRequestError)
69
+ })
70
+
71
+ it('does not throw on 2xx boundary responses (200, 201, 299)', async () => {
72
+ for (const status of [200, 201, 204, 299]) {
73
+ const adapter = makeAdapter({ status, body: null })
74
+ await expect(
75
+ executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
76
+ ).resolves.not.toThrow()
77
+ }
78
+ })
79
+
80
+ it('runs onBeforeRequest before calling adapter (headers are modified)', async () => {
81
+ const capturedHeaders: Record<string, string>[] = []
82
+ const adapter: ClientAdapter = {
83
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
84
+ capturedHeaders.push(req.headers ?? {})
85
+ return { status: 200, headers: {}, body: {} }
86
+ }),
87
+ stream: vi.fn(async () => { throw new Error('not expected') }),
88
+ }
89
+
90
+ const globalHooks: ClientHooks = {
91
+ onBeforeRequest: (ctx) => ({
92
+ ...ctx,
93
+ request: {
94
+ ...ctx.request,
95
+ headers: { ...ctx.request.headers, 'x-auth': 'token-123' },
96
+ },
97
+ }),
98
+ }
99
+
100
+ await executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
101
+ expect(capturedHeaders[0]?.['x-auth']).toBe('token-123')
102
+ })
103
+
104
+ it('runs onAfterResponse after adapter returns', async () => {
105
+ const order: string[] = []
106
+ const adapter: ClientAdapter = {
107
+ request: vi.fn(async (): Promise<AdapterResponse> => {
108
+ order.push('adapter')
109
+ return { status: 200, headers: {}, body: {} }
110
+ }),
111
+ stream: vi.fn(async () => { throw new Error('not expected') }),
112
+ }
113
+ const globalHooks: ClientHooks = {
114
+ onAfterResponse: () => { order.push('afterResponse') },
115
+ }
116
+
117
+ await executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
118
+ expect(order).toEqual(['adapter', 'afterResponse'])
119
+ })
120
+
121
+ it('does not throw when onAfterResponse swallows non-2xx by mutating status', async () => {
122
+ const adapter = makeAdapter({ status: 401, body: { message: 'Unauthorized' } })
123
+ const globalHooks: ClientHooks = {
124
+ onAfterResponse: (ctx) => {
125
+ // Swallow the error by setting status to 200
126
+ ctx.response.status = 200
127
+ },
128
+ }
129
+
130
+ await expect(
131
+ executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
132
+ ).resolves.not.toThrow()
133
+ })
134
+
135
+ it('runs onError on adapter failure and re-throws', async () => {
136
+ const adapterError = new Error('Network failure')
137
+ const adapter: ClientAdapter = {
138
+ request: vi.fn(async () => { throw adapterError }),
139
+ stream: vi.fn(async () => { throw new Error('not expected') }),
140
+ }
141
+ const receivedErrors: unknown[] = []
142
+ const globalHooks: ClientHooks = {
143
+ onError: (ctx) => { receivedErrors.push(ctx.error) },
144
+ }
145
+
146
+ await expect(
147
+ executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
148
+ ).rejects.toThrow('Network failure')
149
+ expect(receivedErrors[0]).toBe(adapterError)
150
+ })
151
+
152
+ it('passes per-procedure hooks as local hooks', async () => {
153
+ const adapter = makeAdapter()
154
+ const localOrder: string[] = []
155
+ const localHooks: ClientHooks = {
156
+ onBeforeRequest: (ctx) => { localOrder.push('local-before'); return ctx },
157
+ }
158
+
159
+ await executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, localHooks)
160
+ expect(localOrder).toContain('local-before')
161
+ })
162
+ })
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'
3
+
4
+ describe('ClientRequestError', () => {
5
+ it('includes status, headers, and body', () => {
6
+ const err = new ClientRequestError({
7
+ status: 401,
8
+ headers: { 'x-request-id': 'abc' },
9
+ body: { message: 'Unauthorized' },
10
+ procedureName: 'GetUser',
11
+ scope: 'users',
12
+ })
13
+ expect(err).toBeInstanceOf(Error)
14
+ expect(err.name).toBe('ClientRequestError')
15
+ expect(err.status).toBe(401)
16
+ expect(err.headers['x-request-id']).toBe('abc')
17
+ expect(err.body).toEqual({ message: 'Unauthorized' })
18
+ expect(err.procedureName).toBe('GetUser')
19
+ expect(err.scope).toBe('users')
20
+ expect(err.message).toBe('GetUser (users) failed with status 401')
21
+ })
22
+ })
23
+
24
+ describe('ClientPathParamError', () => {
25
+ it('reports missing param', () => {
26
+ const err = new ClientPathParamError('id', '/users/:id', 'GetUser')
27
+ expect(err).toBeInstanceOf(Error)
28
+ expect(err.name).toBe('ClientPathParamError')
29
+ expect(err.message).toContain('id')
30
+ expect(err.message).toContain('/users/:id')
31
+ })
32
+ })
33
+
34
+ describe('ClientStreamError', () => {
35
+ it('includes procedure context', () => {
36
+ const err = new ClientStreamError('stream interrupted', 'Watch', 'events')
37
+ expect(err).toBeInstanceOf(Error)
38
+ expect(err.name).toBe('ClientStreamError')
39
+ expect(err.procedureName).toBe('Watch')
40
+ expect(err.scope).toBe('events')
41
+ expect(err.message).toBe('stream interrupted')
42
+ })
43
+ })