ts-procedures 5.9.1 → 5.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/agent_config/bin/postinstall.mjs +3 -3
- package/agent_config/bin/setup.mjs +22 -11
- package/agent_config/claude-code/agents/ts-procedures-architect.md +46 -101
- package/agent_config/claude-code/skills/{guide → ts-procedures}/SKILL.md +50 -35
- package/agent_config/claude-code/skills/{guide → ts-procedures}/anti-patterns.md +6 -5
- package/agent_config/claude-code/skills/{guide → ts-procedures}/api-reference.md +60 -49
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +48 -0
- package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/SKILL.md +19 -24
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +115 -0
- package/agent_config/lib/install-claude.mjs +35 -87
- package/build/src/client/call.d.ts +14 -0
- package/build/src/client/call.js +47 -0
- package/build/src/client/call.js.map +1 -0
- package/build/src/client/call.test.d.ts +1 -0
- package/build/src/client/call.test.js +124 -0
- package/build/src/client/call.test.js.map +1 -0
- package/build/src/client/errors.d.ts +25 -0
- package/build/src/client/errors.js +33 -0
- package/build/src/client/errors.js.map +1 -0
- package/build/src/client/errors.test.d.ts +1 -0
- package/build/src/client/errors.test.js +41 -0
- package/build/src/client/errors.test.js.map +1 -0
- package/build/src/client/fetch-adapter.d.ts +12 -0
- package/build/src/client/fetch-adapter.js +156 -0
- package/build/src/client/fetch-adapter.js.map +1 -0
- package/build/src/client/fetch-adapter.test.d.ts +1 -0
- package/build/src/client/fetch-adapter.test.js +271 -0
- package/build/src/client/fetch-adapter.test.js.map +1 -0
- package/build/src/client/hooks.d.ts +17 -0
- package/build/src/client/hooks.js +40 -0
- package/build/src/client/hooks.js.map +1 -0
- package/build/src/client/hooks.test.d.ts +1 -0
- package/build/src/client/hooks.test.js +163 -0
- package/build/src/client/hooks.test.js.map +1 -0
- package/build/src/client/index.d.ts +22 -0
- package/build/src/client/index.js +67 -0
- package/build/src/client/index.js.map +1 -0
- package/build/src/client/index.test.d.ts +1 -0
- package/build/src/client/index.test.js +231 -0
- package/build/src/client/index.test.js.map +1 -0
- package/build/src/client/request-builder.d.ts +13 -0
- package/build/src/client/request-builder.js +53 -0
- package/build/src/client/request-builder.js.map +1 -0
- package/build/src/client/request-builder.test.d.ts +1 -0
- package/build/src/client/request-builder.test.js +160 -0
- package/build/src/client/request-builder.test.js.map +1 -0
- package/build/src/client/stream.d.ts +27 -0
- package/build/src/client/stream.js +118 -0
- package/build/src/client/stream.js.map +1 -0
- package/build/src/client/stream.test.d.ts +1 -0
- package/build/src/client/stream.test.js +228 -0
- package/build/src/client/stream.test.js.map +1 -0
- package/build/src/client/types.d.ts +78 -0
- package/build/src/client/types.js +3 -0
- package/build/src/client/types.js.map +1 -0
- package/build/src/codegen/bin/cli.d.ts +45 -0
- package/build/src/codegen/bin/cli.js +246 -0
- package/build/src/codegen/bin/cli.js.map +1 -0
- package/build/src/codegen/bin/cli.test.d.ts +1 -0
- package/build/src/codegen/bin/cli.test.js +220 -0
- package/build/src/codegen/bin/cli.test.js.map +1 -0
- package/build/src/codegen/constants.d.ts +1 -0
- package/build/src/codegen/constants.js +2 -0
- package/build/src/codegen/constants.js.map +1 -0
- package/build/src/codegen/e2e.test.d.ts +1 -0
- package/build/src/codegen/e2e.test.js +464 -0
- package/build/src/codegen/e2e.test.js.map +1 -0
- package/build/src/codegen/emit-client-runtime.d.ts +9 -0
- package/build/src/codegen/emit-client-runtime.js +99 -0
- package/build/src/codegen/emit-client-runtime.js.map +1 -0
- package/build/src/codegen/emit-client-runtime.test.d.ts +1 -0
- package/build/src/codegen/emit-client-runtime.test.js +78 -0
- package/build/src/codegen/emit-client-runtime.test.js.map +1 -0
- package/build/src/codegen/emit-client-types.d.ts +8 -0
- package/build/src/codegen/emit-client-types.js +25 -0
- package/build/src/codegen/emit-client-types.js.map +1 -0
- package/build/src/codegen/emit-client-types.test.d.ts +1 -0
- package/build/src/codegen/emit-client-types.test.js +33 -0
- package/build/src/codegen/emit-client-types.test.js.map +1 -0
- package/build/src/codegen/emit-errors.d.ts +19 -0
- package/build/src/codegen/emit-errors.js +59 -0
- package/build/src/codegen/emit-errors.js.map +1 -0
- package/build/src/codegen/emit-errors.test.d.ts +1 -0
- package/build/src/codegen/emit-errors.test.js +175 -0
- package/build/src/codegen/emit-errors.test.js.map +1 -0
- package/build/src/codegen/emit-index.d.ts +12 -0
- package/build/src/codegen/emit-index.js +41 -0
- package/build/src/codegen/emit-index.js.map +1 -0
- package/build/src/codegen/emit-index.test.d.ts +1 -0
- package/build/src/codegen/emit-index.test.js +106 -0
- package/build/src/codegen/emit-index.test.js.map +1 -0
- package/build/src/codegen/emit-scope.d.ts +15 -0
- package/build/src/codegen/emit-scope.js +299 -0
- package/build/src/codegen/emit-scope.js.map +1 -0
- package/build/src/codegen/emit-scope.test.d.ts +1 -0
- package/build/src/codegen/emit-scope.test.js +559 -0
- package/build/src/codegen/emit-scope.test.js.map +1 -0
- package/build/src/codegen/emit-types.d.ts +43 -0
- package/build/src/codegen/emit-types.js +111 -0
- package/build/src/codegen/emit-types.js.map +1 -0
- package/build/src/codegen/emit-types.test.d.ts +1 -0
- package/build/src/codegen/emit-types.test.js +184 -0
- package/build/src/codegen/emit-types.test.js.map +1 -0
- package/build/src/codegen/group-routes.d.ts +23 -0
- package/build/src/codegen/group-routes.js +46 -0
- package/build/src/codegen/group-routes.js.map +1 -0
- package/build/src/codegen/group-routes.test.d.ts +1 -0
- package/build/src/codegen/group-routes.test.js +131 -0
- package/build/src/codegen/group-routes.test.js.map +1 -0
- package/build/src/codegen/index.d.ts +15 -0
- package/build/src/codegen/index.js +16 -0
- package/build/src/codegen/index.js.map +1 -0
- package/build/src/codegen/naming.d.ts +7 -0
- package/build/src/codegen/naming.js +21 -0
- package/build/src/codegen/naming.js.map +1 -0
- package/build/src/codegen/naming.test.d.ts +1 -0
- package/build/src/codegen/naming.test.js +40 -0
- package/build/src/codegen/naming.test.js.map +1 -0
- package/build/src/codegen/pipeline.d.ts +17 -0
- package/build/src/codegen/pipeline.js +78 -0
- package/build/src/codegen/pipeline.js.map +1 -0
- package/build/src/codegen/pipeline.test.d.ts +1 -0
- package/build/src/codegen/pipeline.test.js +269 -0
- package/build/src/codegen/pipeline.test.js.map +1 -0
- package/build/src/codegen/resolve-envelope.d.ts +7 -0
- package/build/src/codegen/resolve-envelope.js +46 -0
- package/build/src/codegen/resolve-envelope.js.map +1 -0
- package/build/src/codegen/resolve-envelope.test.d.ts +1 -0
- package/build/src/codegen/resolve-envelope.test.js +69 -0
- package/build/src/codegen/resolve-envelope.test.js.map +1 -0
- package/build/src/errors.d.ts +33 -0
- package/build/src/errors.js +91 -0
- package/build/src/errors.js.map +1 -0
- package/build/src/errors.test.d.ts +1 -0
- package/build/src/errors.test.js +122 -0
- package/build/src/errors.test.js.map +1 -0
- package/build/src/exports.d.ts +7 -0
- package/build/src/exports.js +8 -0
- package/build/src/exports.js.map +1 -0
- package/build/src/implementations/http/doc-registry.d.ts +12 -0
- package/build/src/implementations/http/doc-registry.js +114 -0
- package/build/src/implementations/http/doc-registry.js.map +1 -0
- package/build/src/implementations/http/doc-registry.test.d.ts +1 -0
- package/build/src/implementations/http/doc-registry.test.js +347 -0
- package/build/src/implementations/http/doc-registry.test.js.map +1 -0
- package/build/src/implementations/http/express-rpc/index.d.ts +94 -0
- package/build/src/implementations/http/express-rpc/index.js +185 -0
- package/build/src/implementations/http/express-rpc/index.js.map +1 -0
- package/build/src/implementations/http/express-rpc/index.test.d.ts +1 -0
- package/build/src/implementations/http/express-rpc/index.test.js +684 -0
- package/build/src/implementations/http/express-rpc/index.test.js.map +1 -0
- package/build/src/implementations/http/express-rpc/types.d.ts +11 -0
- package/build/src/implementations/http/express-rpc/types.js +2 -0
- package/build/src/implementations/http/express-rpc/types.js.map +1 -0
- package/build/src/implementations/http/hono-api/index.d.ts +102 -0
- package/build/src/implementations/http/hono-api/index.js +341 -0
- package/build/src/implementations/http/hono-api/index.js.map +1 -0
- package/build/src/implementations/http/hono-api/index.test.d.ts +1 -0
- package/build/src/implementations/http/hono-api/index.test.js +992 -0
- package/build/src/implementations/http/hono-api/index.test.js.map +1 -0
- package/build/src/implementations/http/hono-api/types.d.ts +13 -0
- package/build/src/implementations/http/hono-api/types.js +2 -0
- package/build/src/implementations/http/hono-api/types.js.map +1 -0
- package/build/src/implementations/http/hono-rpc/index.d.ts +92 -0
- package/build/src/implementations/http/hono-rpc/index.js +161 -0
- package/build/src/implementations/http/hono-rpc/index.js.map +1 -0
- package/build/src/implementations/http/hono-rpc/index.test.d.ts +1 -0
- package/build/src/implementations/http/hono-rpc/index.test.js +803 -0
- package/build/src/implementations/http/hono-rpc/index.test.js.map +1 -0
- package/build/src/implementations/http/hono-rpc/types.d.ts +11 -0
- package/build/src/implementations/http/hono-rpc/types.js +2 -0
- package/build/src/implementations/http/hono-rpc/types.js.map +1 -0
- package/build/src/implementations/http/hono-stream/index.d.ts +120 -0
- package/build/src/implementations/http/hono-stream/index.js +309 -0
- package/build/src/implementations/http/hono-stream/index.js.map +1 -0
- package/build/src/implementations/http/hono-stream/index.test.d.ts +1 -0
- package/build/src/implementations/http/hono-stream/index.test.js +1356 -0
- package/build/src/implementations/http/hono-stream/index.test.js.map +1 -0
- package/build/src/implementations/http/hono-stream/types.d.ts +15 -0
- package/build/src/implementations/http/hono-stream/types.js +2 -0
- package/build/src/implementations/http/hono-stream/types.js.map +1 -0
- package/build/src/implementations/types.d.ts +142 -0
- package/build/src/implementations/types.js +2 -0
- package/build/src/implementations/types.js.map +1 -0
- package/build/src/index.d.ts +165 -0
- package/build/src/index.js +253 -0
- package/build/src/index.js.map +1 -0
- package/build/src/index.test.d.ts +1 -0
- package/build/src/index.test.js +890 -0
- package/build/src/index.test.js.map +1 -0
- package/build/src/schema/compute-schema.d.ts +35 -0
- package/build/src/schema/compute-schema.js +41 -0
- package/build/src/schema/compute-schema.js.map +1 -0
- package/build/src/schema/compute-schema.test.d.ts +1 -0
- package/build/src/schema/compute-schema.test.js +107 -0
- package/build/src/schema/compute-schema.test.js.map +1 -0
- package/build/src/schema/extract-json-schema.d.ts +2 -0
- package/build/src/schema/extract-json-schema.js +12 -0
- package/build/src/schema/extract-json-schema.js.map +1 -0
- package/build/src/schema/extract-json-schema.test.d.ts +1 -0
- package/build/src/schema/extract-json-schema.test.js +23 -0
- package/build/src/schema/extract-json-schema.test.js.map +1 -0
- package/build/src/schema/parser.d.ts +28 -0
- package/build/src/schema/parser.js +170 -0
- package/build/src/schema/parser.js.map +1 -0
- package/build/src/schema/parser.test.d.ts +1 -0
- package/build/src/schema/parser.test.js +120 -0
- package/build/src/schema/parser.test.js.map +1 -0
- package/build/src/schema/resolve-schema-lib.d.ts +12 -0
- package/build/src/schema/resolve-schema-lib.js +11 -0
- package/build/src/schema/resolve-schema-lib.js.map +1 -0
- package/build/src/schema/resolve-schema-lib.test.d.ts +1 -0
- package/build/src/schema/resolve-schema-lib.test.js +17 -0
- package/build/src/schema/resolve-schema-lib.test.js.map +1 -0
- package/build/src/schema/types.d.ts +8 -0
- package/build/src/schema/types.js +2 -0
- package/build/src/schema/types.js.map +1 -0
- package/build/src/stack-utils.d.ts +25 -0
- package/build/src/stack-utils.js +95 -0
- package/build/src/stack-utils.js.map +1 -0
- package/build/src/stack-utils.test.d.ts +1 -0
- package/build/src/stack-utils.test.js +80 -0
- package/build/src/stack-utils.test.js.map +1 -0
- package/docs/ai-agent-setup.md +7 -6
- package/docs/core.md +5 -9
- package/docs/streaming.md +9 -9
- package/package.json +2 -13
- package/src/client/call.test.ts +162 -0
- package/src/client/errors.test.ts +43 -0
- package/src/client/fetch-adapter.test.ts +340 -0
- package/src/client/hooks.test.ts +191 -0
- package/src/client/index.test.ts +290 -0
- package/src/client/request-builder.test.ts +184 -0
- package/src/client/stream.test.ts +331 -0
- package/src/codegen/bin/cli.test.ts +260 -0
- package/src/codegen/bin/cli.ts +282 -0
- package/src/codegen/constants.ts +1 -0
- package/src/codegen/e2e.test.ts +565 -0
- package/src/codegen/emit-client-runtime.test.ts +93 -0
- package/src/codegen/emit-client-runtime.ts +114 -0
- package/src/codegen/emit-client-types.test.ts +39 -0
- package/src/codegen/emit-client-types.ts +27 -0
- package/src/codegen/emit-errors.test.ts +202 -0
- package/src/codegen/emit-errors.ts +80 -0
- package/src/codegen/emit-index.test.ts +127 -0
- package/src/codegen/emit-index.ts +58 -0
- package/src/codegen/emit-scope.test.ts +624 -0
- package/src/codegen/emit-scope.ts +389 -0
- package/src/codegen/emit-types.test.ts +205 -0
- package/src/codegen/emit-types.ts +158 -0
- package/src/codegen/group-routes.test.ts +159 -0
- package/src/codegen/group-routes.ts +61 -0
- package/src/codegen/index.ts +30 -0
- package/src/codegen/naming.test.ts +50 -0
- package/src/codegen/naming.ts +25 -0
- package/src/codegen/pipeline.test.ts +316 -0
- package/src/codegen/pipeline.ts +108 -0
- package/src/codegen/resolve-envelope.test.ts +76 -0
- package/src/codegen/resolve-envelope.ts +61 -0
- package/src/errors.test.ts +163 -0
- package/src/errors.ts +107 -0
- package/src/exports.ts +7 -0
- package/src/implementations/http/doc-registry.test.ts +415 -0
- package/src/implementations/http/doc-registry.ts +143 -0
- package/src/implementations/http/express-rpc/README.md +6 -6
- package/src/implementations/http/express-rpc/index.test.ts +957 -0
- package/src/implementations/http/express-rpc/index.ts +266 -0
- package/src/implementations/http/express-rpc/types.ts +16 -0
- package/src/implementations/http/hono-api/index.test.ts +1341 -0
- package/src/implementations/http/hono-api/index.ts +463 -0
- package/src/implementations/http/hono-api/types.ts +16 -0
- package/src/implementations/http/hono-rpc/README.md +6 -6
- package/src/implementations/http/hono-rpc/index.test.ts +1075 -0
- package/src/implementations/http/hono-rpc/index.ts +238 -0
- package/src/implementations/http/hono-rpc/types.ts +16 -0
- package/src/implementations/http/hono-stream/README.md +12 -12
- package/src/implementations/http/hono-stream/index.test.ts +1768 -0
- package/src/implementations/http/hono-stream/index.ts +456 -0
- package/src/implementations/http/hono-stream/types.ts +20 -0
- package/src/implementations/types.ts +174 -0
- package/src/index.test.ts +1185 -0
- package/src/index.ts +522 -0
- package/src/schema/compute-schema.test.ts +128 -0
- package/src/schema/compute-schema.ts +88 -0
- package/src/schema/extract-json-schema.test.ts +25 -0
- package/src/schema/extract-json-schema.ts +15 -0
- package/src/schema/parser.test.ts +182 -0
- package/src/schema/parser.ts +215 -0
- package/src/schema/resolve-schema-lib.test.ts +19 -0
- package/src/schema/resolve-schema-lib.ts +29 -0
- package/src/schema/types.ts +20 -0
- package/src/stack-utils.test.ts +94 -0
- package/src/stack-utils.ts +129 -0
- package/agent_config/claude-code/skills/review/SKILL.md +0 -53
- package/docs/superpowers/plans/2026-03-30-client-codegen.md +0 -2833
- package/docs/superpowers/specs/2026-03-30-client-codegen-design.md +0 -632
- /package/agent_config/claude-code/skills/{guide → ts-procedures}/patterns.md +0 -0
- /package/agent_config/claude-code/skills/{review → ts-procedures-review}/checklist.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/express-rpc.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/hono-api.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/hono-rpc.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/hono-stream.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/procedure.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/stream-procedure.md +0 -0
|
@@ -1,2833 +0,0 @@
|
|
|
1
|
-
# Client Code Generation Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** Add two new package exports to ts-procedures — `ts-procedures/client` (runtime) and `ts-procedures/codegen` (code generation) — that together produce type-safe, per-scope TypeScript client files from the DocRegistry's JSON output.
|
|
6
|
-
|
|
7
|
-
**Architecture:** Three layers: (1) additive changes to existing route doc types (`kind` discriminant, optional `scope` on API routes), (2) a zero-dependency runtime client with adapter interface, hook pipeline, and streaming support, (3) a code generation engine using `ajsc` that reads a `DocEnvelope` and emits per-scope `.ts` files with types and callables. CLI wraps the programmatic API.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** TypeScript, ajsc (JSON Schema → TS types), vitest (testing), Node.js fs/fetch (codegen I/O)
|
|
10
|
-
|
|
11
|
-
**Spec:** `docs/superpowers/specs/2026-03-30-client-codegen-design.md`
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
## Phase 1: Existing Codebase Changes
|
|
16
|
-
|
|
17
|
-
These tasks add the `kind` discriminant and optional `scope` to existing types and builders. All changes are additive — no breaking changes.
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
### Task 1: Add `kind` and `scope` to Route Doc Types
|
|
22
|
-
|
|
23
|
-
**Files:**
|
|
24
|
-
- Modify: `src/implementations/types.ts:14-52,78-88`
|
|
25
|
-
- Modify: `src/implementations/http/hono-stream/types.ts:7-17`
|
|
26
|
-
- Test: `src/implementations/http/doc-registry.test.ts`
|
|
27
|
-
|
|
28
|
-
- [ ] **Step 1: Write failing test — `kind` field required on doc type assignments**
|
|
29
|
-
|
|
30
|
-
In `src/implementations/http/doc-registry.test.ts`, update the existing fixture declarations (lines ~20-45) to require `kind`. The test will fail because the type doesn't have `kind` yet:
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
describe('kind discriminant', () => {
|
|
34
|
-
it('rpcDoc fixture requires kind field', () => {
|
|
35
|
-
// This access will fail TypeScript compilation until kind is on the type
|
|
36
|
-
const doc = rpcDoc
|
|
37
|
-
expect(doc.kind).toBe('rpc')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('apiDoc fixture requires kind field', () => {
|
|
41
|
-
const doc = apiDoc
|
|
42
|
-
expect(doc.kind).toBe('api')
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('streamDoc fixture requires kind field', () => {
|
|
46
|
-
const doc = streamDoc
|
|
47
|
-
expect(doc.kind).toBe('stream')
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('narrows AnyHttpRouteDoc union via kind', () => {
|
|
51
|
-
const routes: AnyHttpRouteDoc[] = [rpcDoc, apiDoc, streamDoc]
|
|
52
|
-
for (const route of routes) {
|
|
53
|
-
// This property access fails compilation until kind exists on the union
|
|
54
|
-
expect(route.kind).toBeDefined()
|
|
55
|
-
}
|
|
56
|
-
})
|
|
57
|
-
})
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
61
|
-
|
|
62
|
-
Run: `npx vitest run src/implementations/http/doc-registry.test.ts`
|
|
63
|
-
Expected: TypeScript error — Property `kind` does not exist on type `RPCHttpRouteDoc`, runtime test fails with `undefined`
|
|
64
|
-
|
|
65
|
-
- [ ] **Step 3: Add `kind` to RPCHttpRouteDoc, APIHttpRouteDoc, StreamHttpRouteDoc in types.ts**
|
|
66
|
-
|
|
67
|
-
In `src/implementations/types.ts`, add `kind` to each interface:
|
|
68
|
-
|
|
69
|
-
```ts
|
|
70
|
-
// Line 14 — RPCHttpRouteDoc
|
|
71
|
-
export interface RPCHttpRouteDoc extends RPCConfig {
|
|
72
|
-
kind: 'rpc'
|
|
73
|
-
name: string
|
|
74
|
-
path: string
|
|
75
|
-
method: 'post'
|
|
76
|
-
jsonSchema: {
|
|
77
|
-
body?: Record<string, unknown>
|
|
78
|
-
response?: Record<string, unknown>
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Line 32 — APIConfig: add optional scope
|
|
83
|
-
export interface APIConfig {
|
|
84
|
-
path: string
|
|
85
|
-
method: HttpMethod
|
|
86
|
-
scope?: string
|
|
87
|
-
successStatus?: number
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Line 41 — APIHttpRouteDoc
|
|
91
|
-
export interface APIHttpRouteDoc extends APIConfig {
|
|
92
|
-
kind: 'api'
|
|
93
|
-
name: string
|
|
94
|
-
fullPath: string
|
|
95
|
-
jsonSchema: {
|
|
96
|
-
pathParams?: Record<string, unknown>
|
|
97
|
-
query?: Record<string, unknown>
|
|
98
|
-
body?: Record<string, unknown>
|
|
99
|
-
headers?: Record<string, unknown>
|
|
100
|
-
response?: Record<string, unknown>
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Line 78 — StreamHttpRouteDoc
|
|
105
|
-
export interface StreamHttpRouteDoc extends RPCConfig {
|
|
106
|
-
kind: 'stream'
|
|
107
|
-
name: string
|
|
108
|
-
path: string
|
|
109
|
-
methods: ('get' | 'post')[]
|
|
110
|
-
streamMode: StreamMode
|
|
111
|
-
jsonSchema: {
|
|
112
|
-
params?: Record<string, unknown>
|
|
113
|
-
yieldType?: Record<string, unknown>
|
|
114
|
-
returnType?: Record<string, unknown>
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
Also consolidate the duplicate `StreamHttpRouteDoc`: remove the definition from `src/implementations/http/hono-stream/types.ts` and replace it with a re-export from the canonical source:
|
|
120
|
-
|
|
121
|
-
```ts
|
|
122
|
-
// src/implementations/http/hono-stream/types.ts — replace local definition with:
|
|
123
|
-
export type { StreamHttpRouteDoc } from '../../types.js'
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
This eliminates the duplication the spec flagged. The hono-stream module still exports the type for its consumers.
|
|
127
|
-
|
|
128
|
-
- [ ] **Step 4: Update test fixtures to include `kind`**
|
|
129
|
-
|
|
130
|
-
In `src/implementations/http/doc-registry.test.ts`, update the existing fixtures (lines 20-45) to include the `kind` field:
|
|
131
|
-
|
|
132
|
-
```ts
|
|
133
|
-
const rpcDoc: RPCHttpRouteDoc = {
|
|
134
|
-
kind: 'rpc',
|
|
135
|
-
// ... existing fields
|
|
136
|
-
}
|
|
137
|
-
const apiDoc: APIHttpRouteDoc = {
|
|
138
|
-
kind: 'api',
|
|
139
|
-
// ... existing fields
|
|
140
|
-
}
|
|
141
|
-
const streamDoc: StreamHttpRouteDoc = {
|
|
142
|
-
kind: 'stream',
|
|
143
|
-
// ... existing fields
|
|
144
|
-
}
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
- [ ] **Step 5: Run tests to verify they pass**
|
|
148
|
-
|
|
149
|
-
Run: `npx vitest run src/implementations/http/doc-registry.test.ts`
|
|
150
|
-
Expected: All tests pass including new `kind` tests
|
|
151
|
-
|
|
152
|
-
- [ ] **Step 6: Commit**
|
|
153
|
-
|
|
154
|
-
```bash
|
|
155
|
-
git add src/implementations/types.ts src/implementations/http/hono-stream/types.ts src/implementations/http/doc-registry.test.ts
|
|
156
|
-
git commit -m "feat: add kind discriminant and optional scope to route doc types"
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
---
|
|
160
|
-
|
|
161
|
-
### Task 2: Update Builders to Set `kind`
|
|
162
|
-
|
|
163
|
-
**Files:**
|
|
164
|
-
- Modify: `src/implementations/http/hono-rpc/index.ts:218-225`
|
|
165
|
-
- Modify: `src/implementations/http/hono-api/index.ts:438-448`
|
|
166
|
-
- Modify: `src/implementations/http/hono-stream/index.ts:414-422`
|
|
167
|
-
- Modify: `src/implementations/http/express-rpc/index.ts:246-253`
|
|
168
|
-
- Test: `src/implementations/http/hono-rpc/index.test.ts`
|
|
169
|
-
- Test: `src/implementations/http/hono-api/index.test.ts`
|
|
170
|
-
- Test: `src/implementations/http/hono-stream/index.test.ts`
|
|
171
|
-
- Test: `src/implementations/http/express-rpc/index.test.ts`
|
|
172
|
-
|
|
173
|
-
- [ ] **Step 1: Write failing test — HonoRPC builder sets `kind: 'rpc'`**
|
|
174
|
-
|
|
175
|
-
In `src/implementations/http/hono-rpc/index.test.ts`, add a test that checks the `kind` field in the builder's docs output:
|
|
176
|
-
|
|
177
|
-
```ts
|
|
178
|
-
it('sets kind: rpc on route docs', () => {
|
|
179
|
-
// Use the existing builder setup from the test file
|
|
180
|
-
const docs = builder.docs
|
|
181
|
-
expect(docs[0].kind).toBe('rpc')
|
|
182
|
-
})
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
Add similar tests in each builder's test file.
|
|
186
|
-
|
|
187
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
188
|
-
|
|
189
|
-
Run: `npx vitest run src/implementations/http/hono-rpc/index.test.ts`
|
|
190
|
-
Expected: FAIL — `kind` is undefined
|
|
191
|
-
|
|
192
|
-
- [ ] **Step 3: Add `kind` to each builder's doc construction**
|
|
193
|
-
|
|
194
|
-
In `src/implementations/http/hono-rpc/index.ts`, in `buildRpcHttpRouteDoc()` (around line 218), add `kind: 'rpc'` to the base object:
|
|
195
|
-
|
|
196
|
-
```ts
|
|
197
|
-
const base: RPCHttpRouteDoc = {
|
|
198
|
-
kind: 'rpc',
|
|
199
|
-
name: procedure.name,
|
|
200
|
-
// ... rest unchanged
|
|
201
|
-
}
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
In `src/implementations/http/hono-api/index.ts`, in `buildApiHttpRouteDoc()` (around line 438), add `kind: 'api'` and pass through `scope`:
|
|
205
|
-
|
|
206
|
-
```ts
|
|
207
|
-
const base: APIHttpRouteDoc = {
|
|
208
|
-
kind: 'api',
|
|
209
|
-
name: procedure.name,
|
|
210
|
-
// ... rest unchanged
|
|
211
|
-
}
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
Note: For the API builder, also pass `scope` from the procedure config if present. The `scope` field on `APIConfig` is optional so existing usage without scope still works.
|
|
215
|
-
|
|
216
|
-
In `src/implementations/http/hono-stream/index.ts`, in `buildStreamHttpRouteDoc()` (around line 414), add `kind: 'stream'`:
|
|
217
|
-
|
|
218
|
-
```ts
|
|
219
|
-
const base: StreamHttpRouteDoc = {
|
|
220
|
-
kind: 'stream',
|
|
221
|
-
name: procedure.name,
|
|
222
|
-
// ... rest unchanged
|
|
223
|
-
}
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
In `src/implementations/http/express-rpc/index.ts`, in `buildRpcHttpRouteDoc()` (around line 246), add `kind: 'rpc'`:
|
|
227
|
-
|
|
228
|
-
```ts
|
|
229
|
-
const base: RPCHttpRouteDoc = {
|
|
230
|
-
kind: 'rpc',
|
|
231
|
-
name: procedure.name,
|
|
232
|
-
// ... rest unchanged
|
|
233
|
-
}
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
- [ ] **Step 4: Run all builder tests**
|
|
237
|
-
|
|
238
|
-
Run: `npx vitest run src/implementations/http/`
|
|
239
|
-
Expected: All tests pass
|
|
240
|
-
|
|
241
|
-
- [ ] **Step 5: Run full test suite to verify no regressions**
|
|
242
|
-
|
|
243
|
-
Run: `npm run test`
|
|
244
|
-
Expected: All tests pass
|
|
245
|
-
|
|
246
|
-
- [ ] **Step 6: Commit**
|
|
247
|
-
|
|
248
|
-
```bash
|
|
249
|
-
git add src/implementations/http/hono-rpc/index.ts src/implementations/http/hono-api/index.ts src/implementations/http/hono-stream/index.ts src/implementations/http/express-rpc/index.ts src/implementations/http/hono-rpc/index.test.ts src/implementations/http/hono-api/index.test.ts src/implementations/http/hono-stream/index.test.ts src/implementations/http/express-rpc/index.test.ts
|
|
250
|
-
git commit -m "feat: set kind field in all HTTP builders"
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
---
|
|
254
|
-
|
|
255
|
-
## Phase 2: Runtime Client (`ts-procedures/client`)
|
|
256
|
-
|
|
257
|
-
The client runtime is a zero-dependency module that the generated code imports. Built bottom-up: types → errors → utilities → call/stream → factory.
|
|
258
|
-
|
|
259
|
-
---
|
|
260
|
-
|
|
261
|
-
### Task 3: Client Types
|
|
262
|
-
|
|
263
|
-
**Files:**
|
|
264
|
-
- Create: `src/client/types.ts`
|
|
265
|
-
|
|
266
|
-
- [ ] **Step 1: Create types file with all client interfaces**
|
|
267
|
-
|
|
268
|
-
Create `src/client/types.ts` with all the types from the spec:
|
|
269
|
-
|
|
270
|
-
```ts
|
|
271
|
-
// ── Adapter ──────────────────────────────────────────────
|
|
272
|
-
|
|
273
|
-
export interface ClientAdapter {
|
|
274
|
-
request(config: AdapterRequest): Promise<AdapterResponse>
|
|
275
|
-
stream(config: AdapterRequest): Promise<AdapterStreamResponse>
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
export interface AdapterRequest {
|
|
279
|
-
url: string
|
|
280
|
-
method: string
|
|
281
|
-
headers?: Record<string, string>
|
|
282
|
-
body?: unknown
|
|
283
|
-
signal?: AbortSignal
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
export interface AdapterResponse {
|
|
287
|
-
status: number
|
|
288
|
-
headers: Record<string, string>
|
|
289
|
-
body: unknown
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
export interface AdapterStreamResponse {
|
|
293
|
-
status: number
|
|
294
|
-
headers: Record<string, string>
|
|
295
|
-
body: AsyncIterable<unknown>
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ── Hooks ────────────────────────────────────────────────
|
|
299
|
-
|
|
300
|
-
export interface ClientHooks {
|
|
301
|
-
onBeforeRequest?(context: BeforeRequestContext): BeforeRequestContext | Promise<BeforeRequestContext>
|
|
302
|
-
onAfterResponse?(context: AfterResponseContext): void | Promise<void>
|
|
303
|
-
onError?(context: ErrorContext): void | Promise<void>
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
export interface BeforeRequestContext {
|
|
307
|
-
procedureName: string
|
|
308
|
-
scope: string
|
|
309
|
-
request: AdapterRequest
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
export interface AfterResponseContext {
|
|
313
|
-
procedureName: string
|
|
314
|
-
scope: string
|
|
315
|
-
request: AdapterRequest
|
|
316
|
-
response: AdapterResponse
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export interface ErrorContext {
|
|
320
|
-
procedureName: string
|
|
321
|
-
scope: string
|
|
322
|
-
request: AdapterRequest
|
|
323
|
-
error: unknown
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// ── Descriptors ──────────────────────────────────────────
|
|
327
|
-
|
|
328
|
-
export interface CallDescriptor {
|
|
329
|
-
name: string
|
|
330
|
-
scope: string
|
|
331
|
-
path: string
|
|
332
|
-
method: string
|
|
333
|
-
kind: 'rpc' | 'api' | 'stream'
|
|
334
|
-
params: unknown
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
export interface StreamDescriptor extends CallDescriptor {
|
|
338
|
-
kind: 'stream'
|
|
339
|
-
streamMode: 'sse' | 'text'
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// ── TypedStream ──────────────────────────────────────────
|
|
343
|
-
|
|
344
|
-
export interface TypedStream<TYield, TReturn = void> extends AsyncIterable<TYield> {
|
|
345
|
-
result: Promise<TReturn>
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// ── Client Instance ──────────────────────────────────────
|
|
349
|
-
|
|
350
|
-
export type ProcedureCallOptions = ClientHooks
|
|
351
|
-
|
|
352
|
-
export interface ClientInstance {
|
|
353
|
-
basePath: string
|
|
354
|
-
adapter: ClientAdapter
|
|
355
|
-
hooks: ClientHooks
|
|
356
|
-
call<TResponse>(descriptor: CallDescriptor, options?: ProcedureCallOptions): Promise<TResponse>
|
|
357
|
-
stream<TYield, TReturn>(descriptor: StreamDescriptor, options?: ProcedureCallOptions): TypedStream<TYield, TReturn>
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// ── createClient Config ──────────────────────────────────
|
|
361
|
-
|
|
362
|
-
export interface CreateClientConfig<TScopes> {
|
|
363
|
-
adapter: ClientAdapter
|
|
364
|
-
basePath: string
|
|
365
|
-
scopes: (client: ClientInstance) => TScopes
|
|
366
|
-
hooks?: ClientHooks
|
|
367
|
-
}
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
- [ ] **Step 2: Verify the file compiles**
|
|
371
|
-
|
|
372
|
-
Run: `npx tsc --noEmit src/client/types.ts`
|
|
373
|
-
Expected: No errors
|
|
374
|
-
|
|
375
|
-
- [ ] **Step 3: Commit**
|
|
376
|
-
|
|
377
|
-
```bash
|
|
378
|
-
git add src/client/types.ts
|
|
379
|
-
git commit -m "feat: add client runtime type definitions"
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
---
|
|
383
|
-
|
|
384
|
-
### Task 4: Client Errors
|
|
385
|
-
|
|
386
|
-
**Files:**
|
|
387
|
-
- Create: `src/client/errors.ts`
|
|
388
|
-
- Test: `src/client/errors.test.ts`
|
|
389
|
-
|
|
390
|
-
- [ ] **Step 1: Write failing test**
|
|
391
|
-
|
|
392
|
-
Create `src/client/errors.test.ts`:
|
|
393
|
-
|
|
394
|
-
```ts
|
|
395
|
-
import { describe, it, expect } from 'vitest'
|
|
396
|
-
import { ClientRequestError, ClientPathParamError } from './errors.js'
|
|
397
|
-
|
|
398
|
-
describe('ClientRequestError', () => {
|
|
399
|
-
it('includes status, headers, and body', () => {
|
|
400
|
-
const err = new ClientRequestError({
|
|
401
|
-
status: 401,
|
|
402
|
-
headers: { 'x-request-id': 'abc' },
|
|
403
|
-
body: { message: 'Unauthorized' },
|
|
404
|
-
procedureName: 'GetUser',
|
|
405
|
-
scope: 'users',
|
|
406
|
-
})
|
|
407
|
-
expect(err).toBeInstanceOf(Error)
|
|
408
|
-
expect(err.name).toBe('ClientRequestError')
|
|
409
|
-
expect(err.status).toBe(401)
|
|
410
|
-
expect(err.headers['x-request-id']).toBe('abc')
|
|
411
|
-
expect(err.body).toEqual({ message: 'Unauthorized' })
|
|
412
|
-
expect(err.procedureName).toBe('GetUser')
|
|
413
|
-
expect(err.scope).toBe('users')
|
|
414
|
-
expect(err.message).toBe('GetUser (users) failed with status 401')
|
|
415
|
-
})
|
|
416
|
-
})
|
|
417
|
-
|
|
418
|
-
describe('ClientPathParamError', () => {
|
|
419
|
-
it('reports missing param', () => {
|
|
420
|
-
const err = new ClientPathParamError('id', '/users/:id', 'GetUser')
|
|
421
|
-
expect(err).toBeInstanceOf(Error)
|
|
422
|
-
expect(err.name).toBe('ClientPathParamError')
|
|
423
|
-
expect(err.message).toContain('id')
|
|
424
|
-
expect(err.message).toContain('/users/:id')
|
|
425
|
-
})
|
|
426
|
-
})
|
|
427
|
-
```
|
|
428
|
-
|
|
429
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
430
|
-
|
|
431
|
-
Run: `npx vitest run src/client/errors.test.ts`
|
|
432
|
-
Expected: FAIL — module not found
|
|
433
|
-
|
|
434
|
-
- [ ] **Step 3: Implement errors**
|
|
435
|
-
|
|
436
|
-
Create `src/client/errors.ts`:
|
|
437
|
-
|
|
438
|
-
```ts
|
|
439
|
-
export class ClientRequestError extends Error {
|
|
440
|
-
readonly name = 'ClientRequestError'
|
|
441
|
-
readonly status: number
|
|
442
|
-
readonly headers: Record<string, string>
|
|
443
|
-
readonly body: unknown
|
|
444
|
-
readonly procedureName: string
|
|
445
|
-
readonly scope: string
|
|
446
|
-
|
|
447
|
-
constructor(opts: {
|
|
448
|
-
status: number
|
|
449
|
-
headers: Record<string, string>
|
|
450
|
-
body: unknown
|
|
451
|
-
procedureName: string
|
|
452
|
-
scope: string
|
|
453
|
-
}) {
|
|
454
|
-
super(`${opts.procedureName} (${opts.scope}) failed with status ${opts.status}`)
|
|
455
|
-
this.status = opts.status
|
|
456
|
-
this.headers = opts.headers
|
|
457
|
-
this.body = opts.body
|
|
458
|
-
this.procedureName = opts.procedureName
|
|
459
|
-
this.scope = opts.scope
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
export class ClientPathParamError extends Error {
|
|
464
|
-
readonly name = 'ClientPathParamError'
|
|
465
|
-
|
|
466
|
-
constructor(param: string, path: string, procedureName: string) {
|
|
467
|
-
super(`Missing path parameter "${param}" in "${path}" for procedure ${procedureName}`)
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
export class ClientStreamError extends Error {
|
|
472
|
-
readonly name = 'ClientStreamError'
|
|
473
|
-
readonly procedureName: string
|
|
474
|
-
readonly scope: string
|
|
475
|
-
|
|
476
|
-
constructor(message: string, procedureName: string, scope: string) {
|
|
477
|
-
super(message)
|
|
478
|
-
this.procedureName = procedureName
|
|
479
|
-
this.scope = scope
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
```
|
|
483
|
-
|
|
484
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
485
|
-
|
|
486
|
-
Run: `npx vitest run src/client/errors.test.ts`
|
|
487
|
-
Expected: PASS
|
|
488
|
-
|
|
489
|
-
- [ ] **Step 5: Commit**
|
|
490
|
-
|
|
491
|
-
```bash
|
|
492
|
-
git add src/client/errors.ts src/client/errors.test.ts
|
|
493
|
-
git commit -m "feat: add client error classes"
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
---
|
|
497
|
-
|
|
498
|
-
### Task 5: Path Interpolation and Request Building
|
|
499
|
-
|
|
500
|
-
**Files:**
|
|
501
|
-
- Create: `src/client/request-builder.ts`
|
|
502
|
-
- Test: `src/client/request-builder.test.ts`
|
|
503
|
-
|
|
504
|
-
- [ ] **Step 1: Write failing tests**
|
|
505
|
-
|
|
506
|
-
Create `src/client/request-builder.test.ts`:
|
|
507
|
-
|
|
508
|
-
```ts
|
|
509
|
-
import { describe, it, expect } from 'vitest'
|
|
510
|
-
import { interpolatePath, buildAdapterRequest } from './request-builder.js'
|
|
511
|
-
|
|
512
|
-
describe('interpolatePath', () => {
|
|
513
|
-
it('replaces :param with values', () => {
|
|
514
|
-
expect(interpolatePath('/users/:id', { id: '123' }, 'GetUser')).toBe('/users/123')
|
|
515
|
-
})
|
|
516
|
-
|
|
517
|
-
it('handles multiple params', () => {
|
|
518
|
-
expect(interpolatePath('/users/:userId/posts/:postId', { userId: 'a', postId: 'b' }, 'GetPost')).toBe('/users/a/posts/b')
|
|
519
|
-
})
|
|
520
|
-
|
|
521
|
-
it('URI-encodes values', () => {
|
|
522
|
-
expect(interpolatePath('/search/:q', { q: 'hello world' }, 'Search')).toBe('/search/hello%20world')
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
it('throws on missing param with procedure name', () => {
|
|
526
|
-
expect(() => interpolatePath('/users/:id', {}, 'GetUser')).toThrow('id')
|
|
527
|
-
expect(() => interpolatePath('/users/:id', {}, 'GetUser')).toThrow('GetUser')
|
|
528
|
-
})
|
|
529
|
-
|
|
530
|
-
it('returns path unchanged when no params needed', () => {
|
|
531
|
-
expect(interpolatePath('/health', {}, 'Health')).toBe('/health')
|
|
532
|
-
})
|
|
533
|
-
})
|
|
534
|
-
|
|
535
|
-
describe('buildAdapterRequest', () => {
|
|
536
|
-
it('builds RPC request — body params, POST', () => {
|
|
537
|
-
const req = buildAdapterRequest('http://api.com', {
|
|
538
|
-
name: 'CreateUser',
|
|
539
|
-
scope: 'users',
|
|
540
|
-
path: '/users/CreateUser/1',
|
|
541
|
-
method: 'post',
|
|
542
|
-
kind: 'rpc',
|
|
543
|
-
params: { name: 'Alice' },
|
|
544
|
-
})
|
|
545
|
-
expect(req.url).toBe('http://api.com/users/CreateUser/1')
|
|
546
|
-
expect(req.method).toBe('post')
|
|
547
|
-
expect(req.body).toEqual({ name: 'Alice' })
|
|
548
|
-
expect(req.headers?.['content-type']).toBe('application/json')
|
|
549
|
-
})
|
|
550
|
-
|
|
551
|
-
it('builds API request — structured params with pathParams, query, body, headers', () => {
|
|
552
|
-
const req = buildAdapterRequest('http://api.com', {
|
|
553
|
-
name: 'UpdateUser',
|
|
554
|
-
scope: 'users',
|
|
555
|
-
path: '/users/:id',
|
|
556
|
-
method: 'put',
|
|
557
|
-
kind: 'api',
|
|
558
|
-
params: {
|
|
559
|
-
pathParams: { id: '123' },
|
|
560
|
-
query: { notify: 'true' },
|
|
561
|
-
body: { name: 'Bob' },
|
|
562
|
-
headers: { 'x-custom': 'val' },
|
|
563
|
-
},
|
|
564
|
-
})
|
|
565
|
-
expect(req.url).toBe('http://api.com/users/123?notify=true')
|
|
566
|
-
expect(req.method).toBe('put')
|
|
567
|
-
expect(req.body).toEqual({ name: 'Bob' })
|
|
568
|
-
expect(req.headers?.['x-custom']).toBe('val')
|
|
569
|
-
expect(req.headers?.['content-type']).toBe('application/json')
|
|
570
|
-
})
|
|
571
|
-
|
|
572
|
-
it('builds API request — GET with query only, no body', () => {
|
|
573
|
-
const req = buildAdapterRequest('http://api.com', {
|
|
574
|
-
name: 'GetUser',
|
|
575
|
-
scope: 'users',
|
|
576
|
-
path: '/users/:id',
|
|
577
|
-
method: 'get',
|
|
578
|
-
kind: 'api',
|
|
579
|
-
params: {
|
|
580
|
-
pathParams: { id: '42' },
|
|
581
|
-
query: { include: 'posts' },
|
|
582
|
-
},
|
|
583
|
-
})
|
|
584
|
-
expect(req.url).toBe('http://api.com/users/42?include=posts')
|
|
585
|
-
expect(req.method).toBe('get')
|
|
586
|
-
expect(req.body).toBeUndefined()
|
|
587
|
-
})
|
|
588
|
-
|
|
589
|
-
it('treats RPC params with body/query keys as flat params, not structured', () => {
|
|
590
|
-
const req = buildAdapterRequest('http://api.com', {
|
|
591
|
-
name: 'Simple',
|
|
592
|
-
scope: 'test',
|
|
593
|
-
path: '/test/Simple/1',
|
|
594
|
-
method: 'post',
|
|
595
|
-
kind: 'rpc',
|
|
596
|
-
params: { body: 'some content', query: 'search term' },
|
|
597
|
-
})
|
|
598
|
-
// kind: 'rpc' → always flat body params regardless of key names
|
|
599
|
-
expect(req.body).toEqual({ body: 'some content', query: 'search term' })
|
|
600
|
-
})
|
|
601
|
-
})
|
|
602
|
-
```
|
|
603
|
-
|
|
604
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
605
|
-
|
|
606
|
-
Run: `npx vitest run src/client/request-builder.test.ts`
|
|
607
|
-
Expected: FAIL — module not found
|
|
608
|
-
|
|
609
|
-
- [ ] **Step 3: Implement request builder**
|
|
610
|
-
|
|
611
|
-
Create `src/client/request-builder.ts`:
|
|
612
|
-
|
|
613
|
-
```ts
|
|
614
|
-
import { ClientPathParamError } from './errors.js'
|
|
615
|
-
import type { AdapterRequest, CallDescriptor } from './types.js'
|
|
616
|
-
|
|
617
|
-
export function interpolatePath(path: string, params: Record<string, string>, procedureName: string): string {
|
|
618
|
-
return path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, name) => {
|
|
619
|
-
if (!(name in params)) {
|
|
620
|
-
throw new ClientPathParamError(name, path, procedureName)
|
|
621
|
-
}
|
|
622
|
-
return encodeURIComponent(params[name])
|
|
623
|
-
})
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
export function buildAdapterRequest(basePath: string, descriptor: CallDescriptor): AdapterRequest {
|
|
627
|
-
const { path, method, params, kind, name } = descriptor
|
|
628
|
-
|
|
629
|
-
if (kind === 'api') {
|
|
630
|
-
const structured = params as {
|
|
631
|
-
pathParams?: Record<string, string>
|
|
632
|
-
query?: Record<string, string>
|
|
633
|
-
body?: unknown
|
|
634
|
-
headers?: Record<string, string>
|
|
635
|
-
} | undefined
|
|
636
|
-
const resolvedPath = structured?.pathParams
|
|
637
|
-
? interpolatePath(path, structured.pathParams, name)
|
|
638
|
-
: path
|
|
639
|
-
|
|
640
|
-
const queryString = structured?.query
|
|
641
|
-
? '?' + new URLSearchParams(structured.query as Record<string, string>).toString()
|
|
642
|
-
: ''
|
|
643
|
-
|
|
644
|
-
const headers: Record<string, string> = {
|
|
645
|
-
...(structured?.headers ?? {}),
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
let body: unknown = undefined
|
|
649
|
-
if (structured?.body !== undefined) {
|
|
650
|
-
headers['content-type'] = 'application/json'
|
|
651
|
-
body = structured.body
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
return {
|
|
655
|
-
url: `${basePath}${resolvedPath}${queryString}`,
|
|
656
|
-
method,
|
|
657
|
-
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
658
|
-
body,
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// Flat params → body (RPC/Stream style)
|
|
663
|
-
return {
|
|
664
|
-
url: `${basePath}${path}`,
|
|
665
|
-
method,
|
|
666
|
-
headers: params !== undefined ? { 'content-type': 'application/json' } : undefined,
|
|
667
|
-
body: params ?? undefined,
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
```
|
|
671
|
-
|
|
672
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
673
|
-
|
|
674
|
-
Run: `npx vitest run src/client/request-builder.test.ts`
|
|
675
|
-
Expected: PASS
|
|
676
|
-
|
|
677
|
-
- [ ] **Step 5: Commit**
|
|
678
|
-
|
|
679
|
-
```bash
|
|
680
|
-
git add src/client/request-builder.ts src/client/request-builder.test.ts
|
|
681
|
-
git commit -m "feat: add path interpolation and request builder"
|
|
682
|
-
```
|
|
683
|
-
|
|
684
|
-
---
|
|
685
|
-
|
|
686
|
-
### Task 6: Hook Pipeline
|
|
687
|
-
|
|
688
|
-
**Files:**
|
|
689
|
-
- Create: `src/client/hooks.ts`
|
|
690
|
-
- Test: `src/client/hooks.test.ts`
|
|
691
|
-
|
|
692
|
-
- [ ] **Step 1: Write failing tests**
|
|
693
|
-
|
|
694
|
-
Create `src/client/hooks.test.ts`:
|
|
695
|
-
|
|
696
|
-
```ts
|
|
697
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
698
|
-
import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
|
|
699
|
-
import type { BeforeRequestContext, AfterResponseContext, ClientHooks } from './types.js'
|
|
700
|
-
|
|
701
|
-
describe('runBeforeRequest', () => {
|
|
702
|
-
it('runs global then per-procedure hooks in order', async () => {
|
|
703
|
-
const order: string[] = []
|
|
704
|
-
const global: ClientHooks = {
|
|
705
|
-
onBeforeRequest(ctx) { order.push('global'); return ctx },
|
|
706
|
-
}
|
|
707
|
-
const local: ClientHooks = {
|
|
708
|
-
onBeforeRequest(ctx) { order.push('local'); return ctx },
|
|
709
|
-
}
|
|
710
|
-
const ctx: BeforeRequestContext = {
|
|
711
|
-
procedureName: 'Test',
|
|
712
|
-
scope: 'test',
|
|
713
|
-
request: { url: '/test', method: 'get' },
|
|
714
|
-
}
|
|
715
|
-
await runBeforeRequest(ctx, global, local)
|
|
716
|
-
expect(order).toEqual(['global', 'local'])
|
|
717
|
-
})
|
|
718
|
-
|
|
719
|
-
it('passes mutated context from global to local', async () => {
|
|
720
|
-
const global: ClientHooks = {
|
|
721
|
-
onBeforeRequest(ctx) {
|
|
722
|
-
ctx.request.headers = { Authorization: 'Bearer token' }
|
|
723
|
-
return ctx
|
|
724
|
-
},
|
|
725
|
-
}
|
|
726
|
-
const local: ClientHooks = {
|
|
727
|
-
onBeforeRequest(ctx) {
|
|
728
|
-
expect(ctx.request.headers?.Authorization).toBe('Bearer token')
|
|
729
|
-
return ctx
|
|
730
|
-
},
|
|
731
|
-
}
|
|
732
|
-
const ctx: BeforeRequestContext = {
|
|
733
|
-
procedureName: 'Test',
|
|
734
|
-
scope: 'test',
|
|
735
|
-
request: { url: '/test', method: 'get' },
|
|
736
|
-
}
|
|
737
|
-
const result = await runBeforeRequest(ctx, global, local)
|
|
738
|
-
expect(result.request.headers?.Authorization).toBe('Bearer token')
|
|
739
|
-
})
|
|
740
|
-
|
|
741
|
-
it('handles async hooks', async () => {
|
|
742
|
-
const global: ClientHooks = {
|
|
743
|
-
async onBeforeRequest(ctx) {
|
|
744
|
-
return ctx
|
|
745
|
-
},
|
|
746
|
-
}
|
|
747
|
-
const ctx: BeforeRequestContext = {
|
|
748
|
-
procedureName: 'Test',
|
|
749
|
-
scope: 'test',
|
|
750
|
-
request: { url: '/test', method: 'get' },
|
|
751
|
-
}
|
|
752
|
-
const result = await runBeforeRequest(ctx, global, undefined)
|
|
753
|
-
expect(result).toBe(ctx)
|
|
754
|
-
})
|
|
755
|
-
|
|
756
|
-
it('skips undefined hooks gracefully', async () => {
|
|
757
|
-
const ctx: BeforeRequestContext = {
|
|
758
|
-
procedureName: 'Test',
|
|
759
|
-
scope: 'test',
|
|
760
|
-
request: { url: '/test', method: 'get' },
|
|
761
|
-
}
|
|
762
|
-
const result = await runBeforeRequest(ctx, {}, undefined)
|
|
763
|
-
expect(result).toBe(ctx)
|
|
764
|
-
})
|
|
765
|
-
})
|
|
766
|
-
|
|
767
|
-
describe('runAfterResponse', () => {
|
|
768
|
-
it('runs global then per-procedure hooks', async () => {
|
|
769
|
-
const order: string[] = []
|
|
770
|
-
const global: ClientHooks = {
|
|
771
|
-
onAfterResponse() { order.push('global') },
|
|
772
|
-
}
|
|
773
|
-
const local: ClientHooks = {
|
|
774
|
-
onAfterResponse() { order.push('local') },
|
|
775
|
-
}
|
|
776
|
-
const ctx: AfterResponseContext = {
|
|
777
|
-
procedureName: 'Test',
|
|
778
|
-
scope: 'test',
|
|
779
|
-
request: { url: '/test', method: 'get' },
|
|
780
|
-
response: { status: 200, headers: {}, body: {} },
|
|
781
|
-
}
|
|
782
|
-
await runAfterResponse(ctx, global, local)
|
|
783
|
-
expect(order).toEqual(['global', 'local'])
|
|
784
|
-
})
|
|
785
|
-
})
|
|
786
|
-
|
|
787
|
-
describe('runOnError', () => {
|
|
788
|
-
it('calls global then local onError', async () => {
|
|
789
|
-
const order: string[] = []
|
|
790
|
-
const global: ClientHooks = {
|
|
791
|
-
onError() { order.push('global') },
|
|
792
|
-
}
|
|
793
|
-
const local: ClientHooks = {
|
|
794
|
-
onError() { order.push('local') },
|
|
795
|
-
}
|
|
796
|
-
await runOnError(
|
|
797
|
-
{ procedureName: 'Test', scope: 'test', request: { url: '/test', method: 'get' }, error: new Error('fail') },
|
|
798
|
-
global,
|
|
799
|
-
local,
|
|
800
|
-
)
|
|
801
|
-
expect(order).toEqual(['global', 'local'])
|
|
802
|
-
})
|
|
803
|
-
})
|
|
804
|
-
```
|
|
805
|
-
|
|
806
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
807
|
-
|
|
808
|
-
Run: `npx vitest run src/client/hooks.test.ts`
|
|
809
|
-
Expected: FAIL — module not found
|
|
810
|
-
|
|
811
|
-
- [ ] **Step 3: Implement hook pipeline**
|
|
812
|
-
|
|
813
|
-
Create `src/client/hooks.ts`:
|
|
814
|
-
|
|
815
|
-
```ts
|
|
816
|
-
import type { BeforeRequestContext, AfterResponseContext, ErrorContext, ClientHooks } from './types.js'
|
|
817
|
-
|
|
818
|
-
export async function runBeforeRequest(
|
|
819
|
-
ctx: BeforeRequestContext,
|
|
820
|
-
global: ClientHooks,
|
|
821
|
-
local: ClientHooks | undefined,
|
|
822
|
-
): Promise<BeforeRequestContext> {
|
|
823
|
-
let result = ctx
|
|
824
|
-
if (global.onBeforeRequest) {
|
|
825
|
-
result = await global.onBeforeRequest(result)
|
|
826
|
-
}
|
|
827
|
-
if (local?.onBeforeRequest) {
|
|
828
|
-
result = await local.onBeforeRequest(result)
|
|
829
|
-
}
|
|
830
|
-
return result
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
export async function runAfterResponse(
|
|
834
|
-
ctx: AfterResponseContext,
|
|
835
|
-
global: ClientHooks,
|
|
836
|
-
local: ClientHooks | undefined,
|
|
837
|
-
): Promise<void> {
|
|
838
|
-
if (global.onAfterResponse) {
|
|
839
|
-
await global.onAfterResponse(ctx)
|
|
840
|
-
}
|
|
841
|
-
if (local?.onAfterResponse) {
|
|
842
|
-
await local.onAfterResponse(ctx)
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
export async function runOnError(
|
|
847
|
-
ctx: ErrorContext,
|
|
848
|
-
global: ClientHooks,
|
|
849
|
-
local: ClientHooks | undefined,
|
|
850
|
-
): Promise<void> {
|
|
851
|
-
if (global.onError) {
|
|
852
|
-
await global.onError(ctx)
|
|
853
|
-
}
|
|
854
|
-
if (local?.onError) {
|
|
855
|
-
await local.onError(ctx)
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
```
|
|
859
|
-
|
|
860
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
861
|
-
|
|
862
|
-
Run: `npx vitest run src/client/hooks.test.ts`
|
|
863
|
-
Expected: PASS
|
|
864
|
-
|
|
865
|
-
- [ ] **Step 5: Commit**
|
|
866
|
-
|
|
867
|
-
```bash
|
|
868
|
-
git add src/client/hooks.ts src/client/hooks.test.ts
|
|
869
|
-
git commit -m "feat: add client hook pipeline"
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
---
|
|
873
|
-
|
|
874
|
-
### Task 7: call() Implementation
|
|
875
|
-
|
|
876
|
-
**Files:**
|
|
877
|
-
- Create: `src/client/call.ts`
|
|
878
|
-
- Test: `src/client/call.test.ts`
|
|
879
|
-
|
|
880
|
-
- [ ] **Step 1: Write failing tests**
|
|
881
|
-
|
|
882
|
-
Create `src/client/call.test.ts`:
|
|
883
|
-
|
|
884
|
-
```ts
|
|
885
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
886
|
-
import { executeCall } from './call.js'
|
|
887
|
-
import type { ClientAdapter, ClientHooks, CallDescriptor } from './types.js'
|
|
888
|
-
|
|
889
|
-
function makeAdapter(response = { status: 200, headers: {}, body: { id: '1' } }): ClientAdapter {
|
|
890
|
-
return {
|
|
891
|
-
request: vi.fn().mockResolvedValue(response),
|
|
892
|
-
stream: vi.fn(),
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
const descriptor: CallDescriptor = {
|
|
897
|
-
name: 'GetUser',
|
|
898
|
-
scope: 'users',
|
|
899
|
-
path: '/users/GetUser/1',
|
|
900
|
-
method: 'post',
|
|
901
|
-
kind: 'rpc',
|
|
902
|
-
params: { id: '1' },
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
describe('executeCall', () => {
|
|
906
|
-
it('calls adapter.request and returns body', async () => {
|
|
907
|
-
const adapter = makeAdapter()
|
|
908
|
-
const result = await executeCall('http://api.com', adapter, {}, descriptor, undefined)
|
|
909
|
-
expect(result).toEqual({ id: '1' })
|
|
910
|
-
expect(adapter.request).toHaveBeenCalledOnce()
|
|
911
|
-
})
|
|
912
|
-
|
|
913
|
-
it('throws ClientRequestError on non-2xx', async () => {
|
|
914
|
-
const adapter = makeAdapter({ status: 404, headers: {}, body: { error: 'not found' } })
|
|
915
|
-
await expect(executeCall('http://api.com', adapter, {}, descriptor, undefined))
|
|
916
|
-
.rejects.toThrow('GetUser (users) failed with status 404')
|
|
917
|
-
})
|
|
918
|
-
|
|
919
|
-
it('runs onBeforeRequest hook before calling adapter', async () => {
|
|
920
|
-
const adapter = makeAdapter()
|
|
921
|
-
const hooks: ClientHooks = {
|
|
922
|
-
onBeforeRequest(ctx) {
|
|
923
|
-
ctx.request.headers = { ...ctx.request.headers, Authorization: 'Bearer test' }
|
|
924
|
-
return ctx
|
|
925
|
-
},
|
|
926
|
-
}
|
|
927
|
-
await executeCall('http://api.com', adapter, hooks, descriptor, undefined)
|
|
928
|
-
const calledWith = (adapter.request as ReturnType<typeof vi.fn>).mock.calls[0][0]
|
|
929
|
-
expect(calledWith.headers?.Authorization).toBe('Bearer test')
|
|
930
|
-
})
|
|
931
|
-
|
|
932
|
-
it('runs onAfterResponse hook after receiving response', async () => {
|
|
933
|
-
const adapter = makeAdapter()
|
|
934
|
-
const captured: unknown[] = []
|
|
935
|
-
const hooks: ClientHooks = {
|
|
936
|
-
onAfterResponse(ctx) { captured.push(ctx.response.body) },
|
|
937
|
-
}
|
|
938
|
-
await executeCall('http://api.com', adapter, hooks, descriptor, undefined)
|
|
939
|
-
expect(captured).toEqual([{ id: '1' }])
|
|
940
|
-
})
|
|
941
|
-
|
|
942
|
-
it('does not throw when onAfterResponse hook handles non-2xx', async () => {
|
|
943
|
-
const adapter = makeAdapter({ status: 500, headers: {}, body: { error: 'internal' } })
|
|
944
|
-
const hooks: ClientHooks = {
|
|
945
|
-
onAfterResponse(ctx) {
|
|
946
|
-
// Hook swallows the error by setting status to 200
|
|
947
|
-
ctx.response.status = 200
|
|
948
|
-
},
|
|
949
|
-
}
|
|
950
|
-
const result = await executeCall('http://api.com', adapter, hooks, descriptor, undefined)
|
|
951
|
-
expect(result).toEqual({ error: 'internal' })
|
|
952
|
-
})
|
|
953
|
-
})
|
|
954
|
-
```
|
|
955
|
-
|
|
956
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
957
|
-
|
|
958
|
-
Run: `npx vitest run src/client/call.test.ts`
|
|
959
|
-
Expected: FAIL — module not found
|
|
960
|
-
|
|
961
|
-
- [ ] **Step 3: Implement executeCall**
|
|
962
|
-
|
|
963
|
-
Create `src/client/call.ts`:
|
|
964
|
-
|
|
965
|
-
```ts
|
|
966
|
-
import { ClientRequestError } from './errors.js'
|
|
967
|
-
import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
|
|
968
|
-
import { buildAdapterRequest } from './request-builder.js'
|
|
969
|
-
import type { ClientAdapter, ClientHooks, CallDescriptor, ProcedureCallOptions } from './types.js'
|
|
970
|
-
|
|
971
|
-
export async function executeCall<TResponse>(
|
|
972
|
-
basePath: string,
|
|
973
|
-
adapter: ClientAdapter,
|
|
974
|
-
globalHooks: ClientHooks,
|
|
975
|
-
descriptor: CallDescriptor,
|
|
976
|
-
options: ProcedureCallOptions | undefined,
|
|
977
|
-
): Promise<TResponse> {
|
|
978
|
-
const initialRequest = buildAdapterRequest(basePath, descriptor)
|
|
979
|
-
|
|
980
|
-
const { request } = await runBeforeRequest(
|
|
981
|
-
{ procedureName: descriptor.name, scope: descriptor.scope, request: initialRequest },
|
|
982
|
-
globalHooks,
|
|
983
|
-
options,
|
|
984
|
-
)
|
|
985
|
-
|
|
986
|
-
let response
|
|
987
|
-
try {
|
|
988
|
-
response = await adapter.request(request)
|
|
989
|
-
} catch (error) {
|
|
990
|
-
await runOnError(
|
|
991
|
-
{ procedureName: descriptor.name, scope: descriptor.scope, request, error },
|
|
992
|
-
globalHooks,
|
|
993
|
-
options,
|
|
994
|
-
)
|
|
995
|
-
throw error
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
await runAfterResponse(
|
|
999
|
-
{ procedureName: descriptor.name, scope: descriptor.scope, request, response },
|
|
1000
|
-
globalHooks,
|
|
1001
|
-
options,
|
|
1002
|
-
)
|
|
1003
|
-
|
|
1004
|
-
if (response.status < 200 || response.status >= 300) {
|
|
1005
|
-
throw new ClientRequestError({
|
|
1006
|
-
status: response.status,
|
|
1007
|
-
headers: response.headers,
|
|
1008
|
-
body: response.body,
|
|
1009
|
-
procedureName: descriptor.name,
|
|
1010
|
-
scope: descriptor.scope,
|
|
1011
|
-
})
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
return response.body as TResponse
|
|
1015
|
-
}
|
|
1016
|
-
```
|
|
1017
|
-
|
|
1018
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
1019
|
-
|
|
1020
|
-
Run: `npx vitest run src/client/call.test.ts`
|
|
1021
|
-
Expected: PASS
|
|
1022
|
-
|
|
1023
|
-
- [ ] **Step 5: Commit**
|
|
1024
|
-
|
|
1025
|
-
```bash
|
|
1026
|
-
git add src/client/call.ts src/client/call.test.ts
|
|
1027
|
-
git commit -m "feat: add client call execution with hook pipeline"
|
|
1028
|
-
```
|
|
1029
|
-
|
|
1030
|
-
---
|
|
1031
|
-
|
|
1032
|
-
### Task 8: stream() Implementation
|
|
1033
|
-
|
|
1034
|
-
**Files:**
|
|
1035
|
-
- Create: `src/client/stream.ts`
|
|
1036
|
-
- Test: `src/client/stream.test.ts`
|
|
1037
|
-
|
|
1038
|
-
- [ ] **Step 1: Write failing tests**
|
|
1039
|
-
|
|
1040
|
-
Create `src/client/stream.test.ts`:
|
|
1041
|
-
|
|
1042
|
-
```ts
|
|
1043
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
1044
|
-
import { executeStream, createTypedStream } from './stream.js'
|
|
1045
|
-
import type { ClientAdapter, ClientHooks, StreamDescriptor } from './types.js'
|
|
1046
|
-
|
|
1047
|
-
async function* mockSSEStream() {
|
|
1048
|
-
yield { data: { id: '1', message: 'hello' }, event: 'message', id: '1' }
|
|
1049
|
-
yield { data: { id: '2', message: 'world' }, event: 'message', id: '2' }
|
|
1050
|
-
yield { data: { total: 2 }, event: 'return', id: '3' }
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
async function* mockTextStream() {
|
|
1054
|
-
yield 'chunk1'
|
|
1055
|
-
yield 'chunk2'
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
function makeStreamAdapter(body: AsyncIterable<unknown>): ClientAdapter {
|
|
1059
|
-
return {
|
|
1060
|
-
request: vi.fn(),
|
|
1061
|
-
stream: vi.fn().mockResolvedValue({ status: 200, headers: {}, body }),
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
const sseDescriptor: StreamDescriptor = {
|
|
1066
|
-
name: 'WatchNotifications',
|
|
1067
|
-
scope: 'notifications',
|
|
1068
|
-
path: '/notifications/WatchNotifications/1',
|
|
1069
|
-
method: 'post',
|
|
1070
|
-
kind: 'stream',
|
|
1071
|
-
params: { accountId: 'abc' },
|
|
1072
|
-
streamMode: 'sse',
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
describe('createTypedStream (SSE)', () => {
|
|
1076
|
-
it('yields data payloads, captures return', async () => {
|
|
1077
|
-
const stream = createTypedStream(mockSSEStream(), 'sse')
|
|
1078
|
-
const yields: unknown[] = []
|
|
1079
|
-
for await (const item of stream) {
|
|
1080
|
-
yields.push(item)
|
|
1081
|
-
}
|
|
1082
|
-
expect(yields).toEqual([
|
|
1083
|
-
{ id: '1', message: 'hello' },
|
|
1084
|
-
{ id: '2', message: 'world' },
|
|
1085
|
-
])
|
|
1086
|
-
expect(await stream.result).toEqual({ total: 2 })
|
|
1087
|
-
})
|
|
1088
|
-
})
|
|
1089
|
-
|
|
1090
|
-
describe('createTypedStream (text)', () => {
|
|
1091
|
-
it('yields text chunks, result is void', async () => {
|
|
1092
|
-
const stream = createTypedStream(mockTextStream(), 'text')
|
|
1093
|
-
const yields: unknown[] = []
|
|
1094
|
-
for await (const item of stream) {
|
|
1095
|
-
yields.push(item)
|
|
1096
|
-
}
|
|
1097
|
-
expect(yields).toEqual(['chunk1', 'chunk2'])
|
|
1098
|
-
expect(await stream.result).toBeUndefined()
|
|
1099
|
-
})
|
|
1100
|
-
})
|
|
1101
|
-
|
|
1102
|
-
describe('executeStream', () => {
|
|
1103
|
-
it('calls adapter.stream and returns TypedStream', async () => {
|
|
1104
|
-
const adapter = makeStreamAdapter(mockSSEStream())
|
|
1105
|
-
const stream = await executeStream('http://api.com', adapter, {}, sseDescriptor, undefined)
|
|
1106
|
-
const yields: unknown[] = []
|
|
1107
|
-
for await (const item of stream) {
|
|
1108
|
-
yields.push(item)
|
|
1109
|
-
}
|
|
1110
|
-
expect(yields).toHaveLength(2)
|
|
1111
|
-
expect(adapter.stream).toHaveBeenCalledOnce()
|
|
1112
|
-
})
|
|
1113
|
-
|
|
1114
|
-
it('runs onBeforeRequest and onAfterResponse hooks', async () => {
|
|
1115
|
-
const adapter = makeStreamAdapter(mockSSEStream())
|
|
1116
|
-
const order: string[] = []
|
|
1117
|
-
const hooks: ClientHooks = {
|
|
1118
|
-
onBeforeRequest(ctx) { order.push('before'); return ctx },
|
|
1119
|
-
onAfterResponse() { order.push('after') },
|
|
1120
|
-
}
|
|
1121
|
-
const stream = await executeStream('http://api.com', adapter, hooks, sseDescriptor, undefined)
|
|
1122
|
-
// Consume the stream to avoid hanging
|
|
1123
|
-
for await (const _ of stream) { /* noop */ }
|
|
1124
|
-
expect(order).toEqual(['before', 'after'])
|
|
1125
|
-
})
|
|
1126
|
-
|
|
1127
|
-
it('throws on non-2xx status', async () => {
|
|
1128
|
-
const adapter: ClientAdapter = {
|
|
1129
|
-
request: vi.fn(),
|
|
1130
|
-
stream: vi.fn().mockResolvedValue({
|
|
1131
|
-
status: 401,
|
|
1132
|
-
headers: {},
|
|
1133
|
-
body: (async function* () {})(),
|
|
1134
|
-
}),
|
|
1135
|
-
}
|
|
1136
|
-
await expect(executeStream('http://api.com', adapter, {}, sseDescriptor, undefined))
|
|
1137
|
-
.rejects.toThrow('WatchNotifications (notifications) failed with status 401')
|
|
1138
|
-
})
|
|
1139
|
-
})
|
|
1140
|
-
```
|
|
1141
|
-
|
|
1142
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1143
|
-
|
|
1144
|
-
Run: `npx vitest run src/client/stream.test.ts`
|
|
1145
|
-
Expected: FAIL — module not found
|
|
1146
|
-
|
|
1147
|
-
- [ ] **Step 3: Implement stream execution**
|
|
1148
|
-
|
|
1149
|
-
Create `src/client/stream.ts`:
|
|
1150
|
-
|
|
1151
|
-
```ts
|
|
1152
|
-
import { ClientRequestError } from './errors.js'
|
|
1153
|
-
import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
|
|
1154
|
-
import { buildAdapterRequest } from './request-builder.js'
|
|
1155
|
-
import type { ClientAdapter, ClientHooks, StreamDescriptor, ProcedureCallOptions, TypedStream } from './types.js'
|
|
1156
|
-
|
|
1157
|
-
export function createTypedStream<TYield, TReturn>(
|
|
1158
|
-
source: AsyncIterable<unknown>,
|
|
1159
|
-
streamMode: 'sse' | 'text',
|
|
1160
|
-
): TypedStream<TYield, TReturn> {
|
|
1161
|
-
let resolveResult: (value: TReturn) => void
|
|
1162
|
-
let rejectResult: (error: unknown) => void
|
|
1163
|
-
const resultPromise = new Promise<TReturn>((resolve, reject) => {
|
|
1164
|
-
resolveResult = resolve
|
|
1165
|
-
rejectResult = reject
|
|
1166
|
-
})
|
|
1167
|
-
|
|
1168
|
-
async function* iterate(): AsyncGenerator<TYield> {
|
|
1169
|
-
try {
|
|
1170
|
-
for await (const event of source) {
|
|
1171
|
-
if (streamMode === 'sse') {
|
|
1172
|
-
const sseEvent = event as { data: unknown; event?: string; id?: string }
|
|
1173
|
-
if (sseEvent.event === 'return') {
|
|
1174
|
-
resolveResult!(sseEvent.data as TReturn)
|
|
1175
|
-
continue
|
|
1176
|
-
}
|
|
1177
|
-
yield sseEvent.data as TYield
|
|
1178
|
-
} else {
|
|
1179
|
-
yield event as TYield
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
// Stream completed without a return event
|
|
1183
|
-
if (streamMode === 'text') {
|
|
1184
|
-
resolveResult!(undefined as TReturn)
|
|
1185
|
-
} else {
|
|
1186
|
-
// SSE without return event — resolve with undefined
|
|
1187
|
-
resolveResult!(undefined as TReturn)
|
|
1188
|
-
}
|
|
1189
|
-
} catch (error) {
|
|
1190
|
-
rejectResult!(error)
|
|
1191
|
-
throw error
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
const iterable = iterate()
|
|
1196
|
-
|
|
1197
|
-
return {
|
|
1198
|
-
[Symbol.asyncIterator]() { return iterable },
|
|
1199
|
-
result: resultPromise,
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
export async function executeStream<TYield, TReturn>(
|
|
1204
|
-
basePath: string,
|
|
1205
|
-
adapter: ClientAdapter,
|
|
1206
|
-
globalHooks: ClientHooks,
|
|
1207
|
-
descriptor: StreamDescriptor,
|
|
1208
|
-
options: ProcedureCallOptions | undefined,
|
|
1209
|
-
): Promise<TypedStream<TYield, TReturn>> {
|
|
1210
|
-
const initialRequest = buildAdapterRequest(basePath, descriptor)
|
|
1211
|
-
|
|
1212
|
-
const { request } = await runBeforeRequest(
|
|
1213
|
-
{ procedureName: descriptor.name, scope: descriptor.scope, request: initialRequest },
|
|
1214
|
-
globalHooks,
|
|
1215
|
-
options,
|
|
1216
|
-
)
|
|
1217
|
-
|
|
1218
|
-
let streamResponse
|
|
1219
|
-
try {
|
|
1220
|
-
streamResponse = await adapter.stream(request)
|
|
1221
|
-
} catch (error) {
|
|
1222
|
-
await runOnError(
|
|
1223
|
-
{ procedureName: descriptor.name, scope: descriptor.scope, request, error },
|
|
1224
|
-
globalHooks,
|
|
1225
|
-
options,
|
|
1226
|
-
)
|
|
1227
|
-
throw error
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
// Fire onAfterResponse immediately with initial HTTP response
|
|
1231
|
-
await runAfterResponse(
|
|
1232
|
-
{
|
|
1233
|
-
procedureName: descriptor.name,
|
|
1234
|
-
scope: descriptor.scope,
|
|
1235
|
-
request,
|
|
1236
|
-
response: { status: streamResponse.status, headers: streamResponse.headers, body: null },
|
|
1237
|
-
},
|
|
1238
|
-
globalHooks,
|
|
1239
|
-
options,
|
|
1240
|
-
)
|
|
1241
|
-
|
|
1242
|
-
if (streamResponse.status < 200 || streamResponse.status >= 300) {
|
|
1243
|
-
throw new ClientRequestError({
|
|
1244
|
-
status: streamResponse.status,
|
|
1245
|
-
headers: streamResponse.headers,
|
|
1246
|
-
body: null,
|
|
1247
|
-
procedureName: descriptor.name,
|
|
1248
|
-
scope: descriptor.scope,
|
|
1249
|
-
})
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
return createTypedStream<TYield, TReturn>(streamResponse.body, descriptor.streamMode)
|
|
1253
|
-
}
|
|
1254
|
-
```
|
|
1255
|
-
|
|
1256
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
1257
|
-
|
|
1258
|
-
Run: `npx vitest run src/client/stream.test.ts`
|
|
1259
|
-
Expected: PASS
|
|
1260
|
-
|
|
1261
|
-
- [ ] **Step 5: Commit**
|
|
1262
|
-
|
|
1263
|
-
```bash
|
|
1264
|
-
git add src/client/stream.ts src/client/stream.test.ts
|
|
1265
|
-
git commit -m "feat: add client stream execution with SSE parsing"
|
|
1266
|
-
```
|
|
1267
|
-
|
|
1268
|
-
---
|
|
1269
|
-
|
|
1270
|
-
### Task 9: createClient Factory and Client Index
|
|
1271
|
-
|
|
1272
|
-
**Files:**
|
|
1273
|
-
- Create: `src/client/index.ts`
|
|
1274
|
-
- Test: `src/client/index.test.ts`
|
|
1275
|
-
|
|
1276
|
-
- [ ] **Step 1: Write failing tests**
|
|
1277
|
-
|
|
1278
|
-
Create `src/client/index.test.ts`:
|
|
1279
|
-
|
|
1280
|
-
```ts
|
|
1281
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
1282
|
-
import { createClient } from './index.js'
|
|
1283
|
-
import type { ClientAdapter, ClientInstance } from './types.js'
|
|
1284
|
-
|
|
1285
|
-
function makeAdapter(): ClientAdapter {
|
|
1286
|
-
return {
|
|
1287
|
-
request: vi.fn().mockResolvedValue({ status: 200, headers: {}, body: { ok: true } }),
|
|
1288
|
-
stream: vi.fn(),
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
function mockScopes(client: ClientInstance) {
|
|
1293
|
-
return {
|
|
1294
|
-
users: {
|
|
1295
|
-
GetUser(params: { id: string }) {
|
|
1296
|
-
return client.call({ name: 'GetUser', scope: 'users', path: '/users/GetUser/1', method: 'post', kind: 'rpc', params })
|
|
1297
|
-
},
|
|
1298
|
-
},
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
describe('createClient', () => {
|
|
1303
|
-
it('creates a client with typed scope callables', async () => {
|
|
1304
|
-
const client = createClient({
|
|
1305
|
-
adapter: makeAdapter(),
|
|
1306
|
-
basePath: 'http://localhost:3000/api',
|
|
1307
|
-
scopes: mockScopes,
|
|
1308
|
-
})
|
|
1309
|
-
expect(client.users).toBeDefined()
|
|
1310
|
-
expect(typeof client.users.GetUser).toBe('function')
|
|
1311
|
-
})
|
|
1312
|
-
|
|
1313
|
-
it('executes procedure calls through the adapter', async () => {
|
|
1314
|
-
const adapter = makeAdapter()
|
|
1315
|
-
const client = createClient({
|
|
1316
|
-
adapter,
|
|
1317
|
-
basePath: 'http://localhost:3000/api',
|
|
1318
|
-
scopes: mockScopes,
|
|
1319
|
-
})
|
|
1320
|
-
const result = await client.users.GetUser({ id: '1' })
|
|
1321
|
-
expect(result).toEqual({ ok: true })
|
|
1322
|
-
expect(adapter.request).toHaveBeenCalledOnce()
|
|
1323
|
-
})
|
|
1324
|
-
|
|
1325
|
-
it('supports streaming via client.stream()', async () => {
|
|
1326
|
-
const adapter: ClientAdapter = {
|
|
1327
|
-
request: vi.fn(),
|
|
1328
|
-
stream: vi.fn().mockResolvedValue({
|
|
1329
|
-
status: 200,
|
|
1330
|
-
headers: {},
|
|
1331
|
-
body: (async function* () {
|
|
1332
|
-
yield { data: { msg: 'hi' }, event: 'message', id: '1' }
|
|
1333
|
-
yield { data: { total: 1 }, event: 'return', id: '2' }
|
|
1334
|
-
})(),
|
|
1335
|
-
}),
|
|
1336
|
-
}
|
|
1337
|
-
function streamScopes(client: ClientInstance) {
|
|
1338
|
-
return {
|
|
1339
|
-
events: {
|
|
1340
|
-
Watch(params: { filter: string }) {
|
|
1341
|
-
return client.stream({ name: 'Watch', scope: 'events', path: '/events/Watch/1', method: 'post', kind: 'stream', streamMode: 'sse', params })
|
|
1342
|
-
},
|
|
1343
|
-
},
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
const client = createClient({ adapter, basePath: 'http://localhost:3000/api', scopes: streamScopes })
|
|
1347
|
-
const stream = client.events.Watch({ filter: 'all' })
|
|
1348
|
-
const yields: unknown[] = []
|
|
1349
|
-
for await (const item of stream) { yields.push(item) }
|
|
1350
|
-
expect(yields).toEqual([{ msg: 'hi' }])
|
|
1351
|
-
expect(await stream.result).toEqual({ total: 1 })
|
|
1352
|
-
})
|
|
1353
|
-
|
|
1354
|
-
it('applies global hooks to all calls', async () => {
|
|
1355
|
-
const adapter = makeAdapter()
|
|
1356
|
-
const captured: string[] = []
|
|
1357
|
-
const client = createClient({
|
|
1358
|
-
adapter,
|
|
1359
|
-
basePath: 'http://localhost:3000/api',
|
|
1360
|
-
scopes: mockScopes,
|
|
1361
|
-
hooks: {
|
|
1362
|
-
onBeforeRequest(ctx) {
|
|
1363
|
-
captured.push(ctx.procedureName)
|
|
1364
|
-
return ctx
|
|
1365
|
-
},
|
|
1366
|
-
},
|
|
1367
|
-
})
|
|
1368
|
-
await client.users.GetUser({ id: '1' })
|
|
1369
|
-
expect(captured).toEqual(['GetUser'])
|
|
1370
|
-
})
|
|
1371
|
-
})
|
|
1372
|
-
```
|
|
1373
|
-
|
|
1374
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1375
|
-
|
|
1376
|
-
Run: `npx vitest run src/client/index.test.ts`
|
|
1377
|
-
Expected: FAIL — module not found
|
|
1378
|
-
|
|
1379
|
-
- [ ] **Step 3: Implement createClient and barrel exports**
|
|
1380
|
-
|
|
1381
|
-
Create `src/client/index.ts`:
|
|
1382
|
-
|
|
1383
|
-
```ts
|
|
1384
|
-
import { executeCall } from './call.js'
|
|
1385
|
-
import { executeStream } from './stream.js'
|
|
1386
|
-
import type {
|
|
1387
|
-
CreateClientConfig,
|
|
1388
|
-
ClientInstance,
|
|
1389
|
-
CallDescriptor,
|
|
1390
|
-
StreamDescriptor,
|
|
1391
|
-
ProcedureCallOptions,
|
|
1392
|
-
TypedStream,
|
|
1393
|
-
} from './types.js'
|
|
1394
|
-
|
|
1395
|
-
export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TScopes {
|
|
1396
|
-
const { adapter, basePath, hooks = {}, scopes } = config
|
|
1397
|
-
|
|
1398
|
-
const instance: ClientInstance = {
|
|
1399
|
-
basePath,
|
|
1400
|
-
adapter,
|
|
1401
|
-
hooks,
|
|
1402
|
-
call<TResponse>(descriptor: CallDescriptor, options?: ProcedureCallOptions): Promise<TResponse> {
|
|
1403
|
-
return executeCall<TResponse>(basePath, adapter, hooks, descriptor, options)
|
|
1404
|
-
},
|
|
1405
|
-
stream<TYield, TReturn>(descriptor: StreamDescriptor, options?: ProcedureCallOptions): TypedStream<TYield, TReturn> {
|
|
1406
|
-
// executeStream is async but we need to return TypedStream synchronously-ish
|
|
1407
|
-
// We create a deferred stream that starts executing immediately
|
|
1408
|
-
let streamPromise = executeStream<TYield, TReturn>(basePath, adapter, hooks, descriptor, options)
|
|
1409
|
-
|
|
1410
|
-
let resolveResult: (value: TReturn) => void
|
|
1411
|
-
let rejectResult: (error: unknown) => void
|
|
1412
|
-
const resultPromise = new Promise<TReturn>((resolve, reject) => {
|
|
1413
|
-
resolveResult = resolve
|
|
1414
|
-
rejectResult = reject
|
|
1415
|
-
})
|
|
1416
|
-
|
|
1417
|
-
async function* iterate(): AsyncGenerator<TYield> {
|
|
1418
|
-
const typedStream = await streamPromise
|
|
1419
|
-
typedStream.result.then(resolveResult!, rejectResult!)
|
|
1420
|
-
yield* typedStream
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
const iterable = iterate()
|
|
1424
|
-
return {
|
|
1425
|
-
[Symbol.asyncIterator]() { return iterable },
|
|
1426
|
-
result: resultPromise,
|
|
1427
|
-
}
|
|
1428
|
-
},
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
return scopes(instance)
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
// Re-export all public types
|
|
1435
|
-
export type {
|
|
1436
|
-
ClientAdapter,
|
|
1437
|
-
AdapterRequest,
|
|
1438
|
-
AdapterResponse,
|
|
1439
|
-
AdapterStreamResponse,
|
|
1440
|
-
ClientHooks,
|
|
1441
|
-
BeforeRequestContext,
|
|
1442
|
-
AfterResponseContext,
|
|
1443
|
-
ErrorContext,
|
|
1444
|
-
CallDescriptor,
|
|
1445
|
-
StreamDescriptor,
|
|
1446
|
-
TypedStream,
|
|
1447
|
-
ProcedureCallOptions,
|
|
1448
|
-
ClientInstance,
|
|
1449
|
-
CreateClientConfig,
|
|
1450
|
-
} from './types.js'
|
|
1451
|
-
|
|
1452
|
-
// Re-export errors
|
|
1453
|
-
export { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'
|
|
1454
|
-
```
|
|
1455
|
-
|
|
1456
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
1457
|
-
|
|
1458
|
-
Run: `npx vitest run src/client/index.test.ts`
|
|
1459
|
-
Expected: PASS
|
|
1460
|
-
|
|
1461
|
-
- [ ] **Step 5: Run all client tests**
|
|
1462
|
-
|
|
1463
|
-
Run: `npx vitest run src/client/`
|
|
1464
|
-
Expected: All pass
|
|
1465
|
-
|
|
1466
|
-
- [ ] **Step 6: Commit**
|
|
1467
|
-
|
|
1468
|
-
```bash
|
|
1469
|
-
git add src/client/index.ts src/client/index.test.ts
|
|
1470
|
-
git commit -m "feat: add createClient factory and client barrel exports"
|
|
1471
|
-
```
|
|
1472
|
-
|
|
1473
|
-
---
|
|
1474
|
-
|
|
1475
|
-
## Phase 3: Code Generation Engine (`ts-procedures/codegen`)
|
|
1476
|
-
|
|
1477
|
-
Built bottom-up: envelope resolution → scope grouping → type emission → scope file emission → index emission → pipeline → public API → CLI.
|
|
1478
|
-
|
|
1479
|
-
---
|
|
1480
|
-
|
|
1481
|
-
### Task 10: Resolve Envelope
|
|
1482
|
-
|
|
1483
|
-
**Files:**
|
|
1484
|
-
- Create: `src/codegen/resolve-envelope.ts`
|
|
1485
|
-
- Test: `src/codegen/resolve-envelope.test.ts`
|
|
1486
|
-
|
|
1487
|
-
- [ ] **Step 1: Write failing tests**
|
|
1488
|
-
|
|
1489
|
-
Create `src/codegen/resolve-envelope.test.ts`:
|
|
1490
|
-
|
|
1491
|
-
```ts
|
|
1492
|
-
import { describe, it, expect } from 'vitest'
|
|
1493
|
-
import { resolveEnvelope } from './resolve-envelope.js'
|
|
1494
|
-
import type { DocEnvelope } from '../implementations/types.js'
|
|
1495
|
-
import { writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
1496
|
-
import { join } from 'node:path'
|
|
1497
|
-
import { tmpdir } from 'node:os'
|
|
1498
|
-
|
|
1499
|
-
const fixture: DocEnvelope = {
|
|
1500
|
-
basePath: '/api',
|
|
1501
|
-
headers: [],
|
|
1502
|
-
errors: [],
|
|
1503
|
-
routes: [
|
|
1504
|
-
{ kind: 'rpc', name: 'GetUser', path: '/users/GetUser/1', method: 'post', scope: 'users', version: 1, jsonSchema: {} },
|
|
1505
|
-
],
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
describe('resolveEnvelope', () => {
|
|
1509
|
-
it('accepts a DocEnvelope object directly', async () => {
|
|
1510
|
-
const result = await resolveEnvelope({ envelope: fixture })
|
|
1511
|
-
expect(result.basePath).toBe('/api')
|
|
1512
|
-
expect(result.routes).toHaveLength(1)
|
|
1513
|
-
})
|
|
1514
|
-
|
|
1515
|
-
it('reads from a JSON file', async () => {
|
|
1516
|
-
const dir = join(tmpdir(), 'ts-proc-test-' + Date.now())
|
|
1517
|
-
mkdirSync(dir, { recursive: true })
|
|
1518
|
-
const file = join(dir, 'docs.json')
|
|
1519
|
-
writeFileSync(file, JSON.stringify(fixture))
|
|
1520
|
-
try {
|
|
1521
|
-
const result = await resolveEnvelope({ file })
|
|
1522
|
-
expect(result.basePath).toBe('/api')
|
|
1523
|
-
expect(result.routes).toHaveLength(1)
|
|
1524
|
-
} finally {
|
|
1525
|
-
rmSync(dir, { recursive: true })
|
|
1526
|
-
}
|
|
1527
|
-
})
|
|
1528
|
-
|
|
1529
|
-
it('throws on empty routes', async () => {
|
|
1530
|
-
const empty: DocEnvelope = { basePath: '', headers: [], errors: [], routes: [] }
|
|
1531
|
-
await expect(resolveEnvelope({ envelope: empty })).rejects.toThrow('No routes found')
|
|
1532
|
-
})
|
|
1533
|
-
|
|
1534
|
-
it('throws when no input source provided', async () => {
|
|
1535
|
-
await expect(resolveEnvelope({})).rejects.toThrow('Provide one of: url, file, or envelope')
|
|
1536
|
-
})
|
|
1537
|
-
})
|
|
1538
|
-
```
|
|
1539
|
-
|
|
1540
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1541
|
-
|
|
1542
|
-
Run: `npx vitest run src/codegen/resolve-envelope.test.ts`
|
|
1543
|
-
Expected: FAIL — module not found
|
|
1544
|
-
|
|
1545
|
-
- [ ] **Step 3: Implement resolve-envelope**
|
|
1546
|
-
|
|
1547
|
-
Create `src/codegen/resolve-envelope.ts`:
|
|
1548
|
-
|
|
1549
|
-
```ts
|
|
1550
|
-
import { readFile } from 'node:fs/promises'
|
|
1551
|
-
import type { DocEnvelope } from '../implementations/types.js'
|
|
1552
|
-
|
|
1553
|
-
export interface ResolveInput {
|
|
1554
|
-
url?: string
|
|
1555
|
-
file?: string
|
|
1556
|
-
envelope?: DocEnvelope
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
export async function resolveEnvelope(input: ResolveInput): Promise<DocEnvelope> {
|
|
1560
|
-
let envelope: DocEnvelope
|
|
1561
|
-
|
|
1562
|
-
if (input.envelope) {
|
|
1563
|
-
envelope = input.envelope
|
|
1564
|
-
} else if (input.file) {
|
|
1565
|
-
const content = await readFile(input.file, 'utf-8')
|
|
1566
|
-
envelope = JSON.parse(content) as DocEnvelope
|
|
1567
|
-
} else if (input.url) {
|
|
1568
|
-
const response = await fetch(input.url)
|
|
1569
|
-
if (!response.ok) {
|
|
1570
|
-
throw new Error(`Failed to fetch docs from ${input.url}: ${response.status} ${response.statusText}`)
|
|
1571
|
-
}
|
|
1572
|
-
envelope = (await response.json()) as DocEnvelope
|
|
1573
|
-
} else {
|
|
1574
|
-
throw new Error('Provide one of: url, file, or envelope')
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
if (!envelope.routes || envelope.routes.length === 0) {
|
|
1578
|
-
throw new Error('No routes found in DocEnvelope')
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
return envelope
|
|
1582
|
-
}
|
|
1583
|
-
```
|
|
1584
|
-
|
|
1585
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
1586
|
-
|
|
1587
|
-
Run: `npx vitest run src/codegen/resolve-envelope.test.ts`
|
|
1588
|
-
Expected: PASS
|
|
1589
|
-
|
|
1590
|
-
- [ ] **Step 5: Commit**
|
|
1591
|
-
|
|
1592
|
-
```bash
|
|
1593
|
-
git add src/codegen/resolve-envelope.ts src/codegen/resolve-envelope.test.ts
|
|
1594
|
-
git commit -m "feat: add DocEnvelope resolution (URL, file, object)"
|
|
1595
|
-
```
|
|
1596
|
-
|
|
1597
|
-
---
|
|
1598
|
-
|
|
1599
|
-
### Task 11: Group Routes by Scope
|
|
1600
|
-
|
|
1601
|
-
**Files:**
|
|
1602
|
-
- Create: `src/codegen/group-routes.ts`
|
|
1603
|
-
- Test: `src/codegen/group-routes.test.ts`
|
|
1604
|
-
|
|
1605
|
-
- [ ] **Step 1: Write failing tests**
|
|
1606
|
-
|
|
1607
|
-
Create `src/codegen/group-routes.test.ts`:
|
|
1608
|
-
|
|
1609
|
-
```ts
|
|
1610
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
1611
|
-
import { groupRoutesByScope, normalizeScope, scopeToCamelCase } from './group-routes.js'
|
|
1612
|
-
|
|
1613
|
-
describe('normalizeScope', () => {
|
|
1614
|
-
it('returns string scope as-is', () => {
|
|
1615
|
-
expect(normalizeScope('users')).toBe('users')
|
|
1616
|
-
})
|
|
1617
|
-
|
|
1618
|
-
it('joins array scope with -', () => {
|
|
1619
|
-
expect(normalizeScope(['admin', 'users'])).toBe('admin-users')
|
|
1620
|
-
})
|
|
1621
|
-
|
|
1622
|
-
it('returns "default" for undefined', () => {
|
|
1623
|
-
expect(normalizeScope(undefined)).toBe('default')
|
|
1624
|
-
})
|
|
1625
|
-
})
|
|
1626
|
-
|
|
1627
|
-
describe('scopeToCamelCase', () => {
|
|
1628
|
-
it('converts hyphenated to camelCase', () => {
|
|
1629
|
-
expect(scopeToCamelCase('admin-users')).toBe('adminUsers')
|
|
1630
|
-
})
|
|
1631
|
-
|
|
1632
|
-
it('leaves single word unchanged', () => {
|
|
1633
|
-
expect(scopeToCamelCase('users')).toBe('users')
|
|
1634
|
-
})
|
|
1635
|
-
|
|
1636
|
-
it('handles default', () => {
|
|
1637
|
-
expect(scopeToCamelCase('default')).toBe('default')
|
|
1638
|
-
})
|
|
1639
|
-
})
|
|
1640
|
-
|
|
1641
|
-
describe('groupRoutesByScope', () => {
|
|
1642
|
-
it('groups routes by normalized scope', () => {
|
|
1643
|
-
const routes = [
|
|
1644
|
-
{ kind: 'rpc' as const, name: 'GetUser', path: '/users/GetUser/1', method: 'post' as const, scope: 'users', version: 1, jsonSchema: {} },
|
|
1645
|
-
{ kind: 'rpc' as const, name: 'CreateUser', path: '/users/CreateUser/1', method: 'post' as const, scope: 'users', version: 1, jsonSchema: {} },
|
|
1646
|
-
{ kind: 'rpc' as const, name: 'GetInvoice', path: '/billing/GetInvoice/1', method: 'post' as const, scope: 'billing', version: 1, jsonSchema: {} },
|
|
1647
|
-
]
|
|
1648
|
-
const groups = groupRoutesByScope(routes)
|
|
1649
|
-
expect(groups.size).toBe(2)
|
|
1650
|
-
expect(groups.get('users')!.routes).toHaveLength(2)
|
|
1651
|
-
expect(groups.get('billing')!.routes).toHaveLength(1)
|
|
1652
|
-
expect(groups.get('users')!.camelCase).toBe('users')
|
|
1653
|
-
})
|
|
1654
|
-
|
|
1655
|
-
it('groups array scopes by joined key', () => {
|
|
1656
|
-
const routes = [
|
|
1657
|
-
{ kind: 'rpc' as const, name: 'AdminGetUser', path: '/admin/users/AdminGetUser/1', method: 'post' as const, scope: ['admin', 'users'], version: 1, jsonSchema: {} },
|
|
1658
|
-
]
|
|
1659
|
-
const groups = groupRoutesByScope(routes)
|
|
1660
|
-
expect(groups.has('admin-users')).toBe(true)
|
|
1661
|
-
expect(groups.get('admin-users')!.camelCase).toBe('adminUsers')
|
|
1662
|
-
})
|
|
1663
|
-
|
|
1664
|
-
it('groups API routes without scope into default with warning', () => {
|
|
1665
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
1666
|
-
const routes = [
|
|
1667
|
-
{ kind: 'api' as const, name: 'GetHealth', path: '/health', method: 'get' as const, fullPath: '/health', jsonSchema: {} },
|
|
1668
|
-
]
|
|
1669
|
-
const groups = groupRoutesByScope(routes as any)
|
|
1670
|
-
expect(groups.has('default')).toBe(true)
|
|
1671
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining('GetHealth'))
|
|
1672
|
-
warn.mockRestore()
|
|
1673
|
-
})
|
|
1674
|
-
})
|
|
1675
|
-
```
|
|
1676
|
-
|
|
1677
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1678
|
-
|
|
1679
|
-
Run: `npx vitest run src/codegen/group-routes.test.ts`
|
|
1680
|
-
Expected: FAIL — module not found
|
|
1681
|
-
|
|
1682
|
-
- [ ] **Step 3: Implement group-routes**
|
|
1683
|
-
|
|
1684
|
-
Create `src/codegen/group-routes.ts`:
|
|
1685
|
-
|
|
1686
|
-
```ts
|
|
1687
|
-
import type { AnyHttpRouteDoc } from '../implementations/types.js'
|
|
1688
|
-
|
|
1689
|
-
export interface ScopeGroup {
|
|
1690
|
-
scopeKey: string
|
|
1691
|
-
camelCase: string
|
|
1692
|
-
routes: AnyHttpRouteDoc[]
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
export function normalizeScope(scope: string | string[] | undefined): string {
|
|
1696
|
-
if (scope === undefined) return 'default'
|
|
1697
|
-
if (Array.isArray(scope)) return scope.join('-')
|
|
1698
|
-
return scope
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
export function scopeToCamelCase(scope: string): string {
|
|
1702
|
-
return scope.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
export function groupRoutesByScope(routes: AnyHttpRouteDoc[]): Map<string, ScopeGroup> {
|
|
1706
|
-
const groups = new Map<string, ScopeGroup>()
|
|
1707
|
-
|
|
1708
|
-
for (const route of routes) {
|
|
1709
|
-
const rawScope = 'scope' in route ? (route as any).scope : undefined
|
|
1710
|
-
const scopeKey = normalizeScope(rawScope)
|
|
1711
|
-
|
|
1712
|
-
if (scopeKey === 'default' && rawScope === undefined) {
|
|
1713
|
-
console.warn(
|
|
1714
|
-
`ts-procedures-codegen: Route "${route.name}" has no scope — grouping into "default". ` +
|
|
1715
|
-
`Add scope to your builder config for explicit grouping.`
|
|
1716
|
-
)
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
let group = groups.get(scopeKey)
|
|
1720
|
-
if (!group) {
|
|
1721
|
-
group = { scopeKey, camelCase: scopeToCamelCase(scopeKey), routes: [] }
|
|
1722
|
-
groups.set(scopeKey, group)
|
|
1723
|
-
}
|
|
1724
|
-
group.routes.push(route)
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
return groups
|
|
1728
|
-
}
|
|
1729
|
-
```
|
|
1730
|
-
|
|
1731
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
1732
|
-
|
|
1733
|
-
Run: `npx vitest run src/codegen/group-routes.test.ts`
|
|
1734
|
-
Expected: PASS
|
|
1735
|
-
|
|
1736
|
-
- [ ] **Step 5: Commit**
|
|
1737
|
-
|
|
1738
|
-
```bash
|
|
1739
|
-
git add src/codegen/group-routes.ts src/codegen/group-routes.test.ts
|
|
1740
|
-
git commit -m "feat: add scope normalization and route grouping"
|
|
1741
|
-
```
|
|
1742
|
-
|
|
1743
|
-
---
|
|
1744
|
-
|
|
1745
|
-
### Task 12: Type Emission (ajsc Integration)
|
|
1746
|
-
|
|
1747
|
-
**Files:**
|
|
1748
|
-
- Create: `src/codegen/emit-types.ts`
|
|
1749
|
-
- Test: `src/codegen/emit-types.test.ts`
|
|
1750
|
-
|
|
1751
|
-
- [ ] **Step 1: Write failing tests**
|
|
1752
|
-
|
|
1753
|
-
Create `src/codegen/emit-types.test.ts`:
|
|
1754
|
-
|
|
1755
|
-
```ts
|
|
1756
|
-
import { describe, it, expect } from 'vitest'
|
|
1757
|
-
import { jsonSchemaToTypeString } from './emit-types.js'
|
|
1758
|
-
|
|
1759
|
-
describe('jsonSchemaToTypeString', () => {
|
|
1760
|
-
it('converts a simple object schema to a TypeScript type', async () => {
|
|
1761
|
-
const schema = {
|
|
1762
|
-
type: 'object',
|
|
1763
|
-
properties: {
|
|
1764
|
-
id: { type: 'string' },
|
|
1765
|
-
name: { type: 'string' },
|
|
1766
|
-
},
|
|
1767
|
-
required: ['id', 'name'],
|
|
1768
|
-
}
|
|
1769
|
-
const result = await jsonSchemaToTypeString('GetUserResponse', schema)
|
|
1770
|
-
expect(result).toContain('id:')
|
|
1771
|
-
expect(result).toContain('string')
|
|
1772
|
-
expect(result).toContain('name:')
|
|
1773
|
-
})
|
|
1774
|
-
|
|
1775
|
-
it('converts a schema with optional fields', async () => {
|
|
1776
|
-
const schema = {
|
|
1777
|
-
type: 'object',
|
|
1778
|
-
properties: {
|
|
1779
|
-
id: { type: 'string' },
|
|
1780
|
-
nickname: { type: 'string' },
|
|
1781
|
-
},
|
|
1782
|
-
required: ['id'],
|
|
1783
|
-
}
|
|
1784
|
-
const result = await jsonSchemaToTypeString('UserParams', schema)
|
|
1785
|
-
expect(result).toContain('id:')
|
|
1786
|
-
// nickname should be optional (not in required)
|
|
1787
|
-
expect(result).toBeDefined()
|
|
1788
|
-
})
|
|
1789
|
-
|
|
1790
|
-
it('returns undefined for undefined schema', async () => {
|
|
1791
|
-
const result = await jsonSchemaToTypeString('Empty', undefined)
|
|
1792
|
-
expect(result).toBeUndefined()
|
|
1793
|
-
})
|
|
1794
|
-
|
|
1795
|
-
it('handles enum schemas', async () => {
|
|
1796
|
-
const schema = {
|
|
1797
|
-
type: 'string',
|
|
1798
|
-
enum: ['admin', 'member', 'viewer'],
|
|
1799
|
-
}
|
|
1800
|
-
const result = await jsonSchemaToTypeString('Role', schema)
|
|
1801
|
-
expect(result).toBeDefined()
|
|
1802
|
-
// Should contain the enum values in some form
|
|
1803
|
-
expect(result).toContain('admin')
|
|
1804
|
-
})
|
|
1805
|
-
})
|
|
1806
|
-
```
|
|
1807
|
-
|
|
1808
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1809
|
-
|
|
1810
|
-
Run: `npx vitest run src/codegen/emit-types.test.ts`
|
|
1811
|
-
Expected: FAIL — module not found
|
|
1812
|
-
|
|
1813
|
-
- [ ] **Step 3: Implement emit-types with dynamic ajsc import**
|
|
1814
|
-
|
|
1815
|
-
Create `src/codegen/emit-types.ts`:
|
|
1816
|
-
|
|
1817
|
-
```ts
|
|
1818
|
-
export interface AjscOptions {
|
|
1819
|
-
enumStyle?: 'union' | 'enum'
|
|
1820
|
-
depluralize?: boolean
|
|
1821
|
-
inlineTypes?: boolean
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
let TypescriptConverter: any = null
|
|
1825
|
-
|
|
1826
|
-
async function loadAjsc() {
|
|
1827
|
-
if (TypescriptConverter) return
|
|
1828
|
-
try {
|
|
1829
|
-
const ajsc = await import('ajsc')
|
|
1830
|
-
TypescriptConverter = ajsc.TypescriptConverter
|
|
1831
|
-
} catch {
|
|
1832
|
-
throw new Error(
|
|
1833
|
-
'ajsc is required for code generation but is not installed. ' +
|
|
1834
|
-
'Install it with: npm install ajsc'
|
|
1835
|
-
)
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
|
|
1839
|
-
export async function jsonSchemaToTypeString(
|
|
1840
|
-
typeName: string,
|
|
1841
|
-
schema: Record<string, unknown> | undefined,
|
|
1842
|
-
options?: AjscOptions,
|
|
1843
|
-
): Promise<string | undefined> {
|
|
1844
|
-
if (!schema) return undefined
|
|
1845
|
-
|
|
1846
|
-
await loadAjsc()
|
|
1847
|
-
|
|
1848
|
-
const converter = new TypescriptConverter(schema, {
|
|
1849
|
-
inlineTypes: options?.inlineTypes ?? true,
|
|
1850
|
-
depluralize: options?.depluralize ?? true,
|
|
1851
|
-
enumStyle: options?.enumStyle ?? 'union',
|
|
1852
|
-
})
|
|
1853
|
-
|
|
1854
|
-
const code = converter.code as string
|
|
1855
|
-
|
|
1856
|
-
// ajsc produces a root type — wrap it with our name
|
|
1857
|
-
// The output from ajsc with inlineTypes is a type literal we can assign
|
|
1858
|
-
return `export type ${typeName} = ${code}`
|
|
1859
|
-
}
|
|
1860
|
-
```
|
|
1861
|
-
|
|
1862
|
-
**Note:** The exact shape of ajsc's output may vary. The implementer should install `ajsc` (`npm install ajsc`) and verify what `TypescriptConverter.code` returns for various schemas. The wrapping logic above may need adjustment based on actual output format — for example, if ajsc already emits `type X = ...` then we should extract just the type body.
|
|
1863
|
-
|
|
1864
|
-
- [ ] **Step 4: Install ajsc and run test to verify it passes**
|
|
1865
|
-
|
|
1866
|
-
Run: `npm install ajsc && npx vitest run src/codegen/emit-types.test.ts`
|
|
1867
|
-
Expected: PASS (or adjust wrapping logic based on actual ajsc output)
|
|
1868
|
-
|
|
1869
|
-
- [ ] **Step 5: Commit**
|
|
1870
|
-
|
|
1871
|
-
```bash
|
|
1872
|
-
git add src/codegen/emit-types.ts src/codegen/emit-types.test.ts
|
|
1873
|
-
git commit -m "feat: add ajsc-based JSON Schema to TypeScript type emission"
|
|
1874
|
-
```
|
|
1875
|
-
|
|
1876
|
-
---
|
|
1877
|
-
|
|
1878
|
-
### Task 13: Scope File Emission
|
|
1879
|
-
|
|
1880
|
-
**Files:**
|
|
1881
|
-
- Create: `src/codegen/emit-scope.ts`
|
|
1882
|
-
- Test: `src/codegen/emit-scope.test.ts`
|
|
1883
|
-
|
|
1884
|
-
- [ ] **Step 1: Write failing tests**
|
|
1885
|
-
|
|
1886
|
-
Create `src/codegen/emit-scope.test.ts`:
|
|
1887
|
-
|
|
1888
|
-
```ts
|
|
1889
|
-
import { describe, it, expect } from 'vitest'
|
|
1890
|
-
import { emitScopeFile } from './emit-scope.js'
|
|
1891
|
-
import type { ScopeGroup } from './group-routes.js'
|
|
1892
|
-
import type { RPCHttpRouteDoc, APIHttpRouteDoc, StreamHttpRouteDoc } from '../implementations/types.js'
|
|
1893
|
-
|
|
1894
|
-
describe('emitScopeFile', () => {
|
|
1895
|
-
it('generates scope file for RPC routes', async () => {
|
|
1896
|
-
const group: ScopeGroup = {
|
|
1897
|
-
scopeKey: 'users',
|
|
1898
|
-
camelCase: 'users',
|
|
1899
|
-
routes: [
|
|
1900
|
-
{
|
|
1901
|
-
kind: 'rpc',
|
|
1902
|
-
name: 'GetUser',
|
|
1903
|
-
path: '/users/GetUser/1',
|
|
1904
|
-
method: 'post',
|
|
1905
|
-
scope: 'users',
|
|
1906
|
-
version: 1,
|
|
1907
|
-
jsonSchema: {
|
|
1908
|
-
body: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
1909
|
-
response: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
1910
|
-
},
|
|
1911
|
-
} satisfies RPCHttpRouteDoc,
|
|
1912
|
-
],
|
|
1913
|
-
}
|
|
1914
|
-
const code = await emitScopeFile(group)
|
|
1915
|
-
expect(code).toContain('ts-procedures-codegen')
|
|
1916
|
-
expect(code).toContain("from 'ts-procedures/client'")
|
|
1917
|
-
expect(code).toContain('GetUserParams')
|
|
1918
|
-
expect(code).toContain('GetUserResponse')
|
|
1919
|
-
expect(code).toContain('bindUsersScope')
|
|
1920
|
-
expect(code).toContain("name: 'GetUser'")
|
|
1921
|
-
expect(code).toContain("scope: 'users'")
|
|
1922
|
-
expect(code).toContain("method: 'post'")
|
|
1923
|
-
expect(code).toContain('client.call')
|
|
1924
|
-
})
|
|
1925
|
-
|
|
1926
|
-
it('generates scope file for API routes with structured params', async () => {
|
|
1927
|
-
const group: ScopeGroup = {
|
|
1928
|
-
scopeKey: 'users',
|
|
1929
|
-
camelCase: 'users',
|
|
1930
|
-
routes: [
|
|
1931
|
-
{
|
|
1932
|
-
kind: 'api',
|
|
1933
|
-
name: 'UpdateUser',
|
|
1934
|
-
path: '/users/:id',
|
|
1935
|
-
method: 'put',
|
|
1936
|
-
scope: 'users',
|
|
1937
|
-
fullPath: '/api/users/:id',
|
|
1938
|
-
jsonSchema: {
|
|
1939
|
-
pathParams: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
1940
|
-
body: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
1941
|
-
response: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] },
|
|
1942
|
-
},
|
|
1943
|
-
} satisfies APIHttpRouteDoc,
|
|
1944
|
-
],
|
|
1945
|
-
}
|
|
1946
|
-
const code = await emitScopeFile(group)
|
|
1947
|
-
expect(code).toContain('UpdateUserParams')
|
|
1948
|
-
expect(code).toContain('UpdateUserResponse')
|
|
1949
|
-
expect(code).toContain("method: 'put'")
|
|
1950
|
-
})
|
|
1951
|
-
|
|
1952
|
-
it('generates scope file for stream routes', async () => {
|
|
1953
|
-
const group: ScopeGroup = {
|
|
1954
|
-
scopeKey: 'notifications',
|
|
1955
|
-
camelCase: 'notifications',
|
|
1956
|
-
routes: [
|
|
1957
|
-
{
|
|
1958
|
-
kind: 'stream',
|
|
1959
|
-
name: 'Watch',
|
|
1960
|
-
path: '/notifications/Watch/1',
|
|
1961
|
-
methods: ['get', 'post'],
|
|
1962
|
-
streamMode: 'sse',
|
|
1963
|
-
scope: 'notifications',
|
|
1964
|
-
version: 1,
|
|
1965
|
-
jsonSchema: {
|
|
1966
|
-
params: { type: 'object', properties: { accountId: { type: 'string' } } },
|
|
1967
|
-
yieldType: {
|
|
1968
|
-
type: 'object',
|
|
1969
|
-
description: 'SSE message envelope.',
|
|
1970
|
-
required: ['data', 'event', 'id'],
|
|
1971
|
-
properties: {
|
|
1972
|
-
data: { type: 'object', properties: { msg: { type: 'string' } } },
|
|
1973
|
-
event: { type: 'string' },
|
|
1974
|
-
id: { type: 'string' },
|
|
1975
|
-
retry: { type: 'number' },
|
|
1976
|
-
},
|
|
1977
|
-
},
|
|
1978
|
-
returnType: { type: 'object', properties: { total: { type: 'number' } } },
|
|
1979
|
-
},
|
|
1980
|
-
} satisfies StreamHttpRouteDoc,
|
|
1981
|
-
],
|
|
1982
|
-
}
|
|
1983
|
-
const code = await emitScopeFile(group)
|
|
1984
|
-
expect(code).toContain('WatchParams')
|
|
1985
|
-
expect(code).toContain('WatchYield')
|
|
1986
|
-
expect(code).toContain('WatchReturn')
|
|
1987
|
-
expect(code).toContain('TypedStream')
|
|
1988
|
-
expect(code).toContain('client.stream')
|
|
1989
|
-
expect(code).toContain("streamMode: 'sse'")
|
|
1990
|
-
})
|
|
1991
|
-
|
|
1992
|
-
it('unwraps SSE envelope for yieldType — uses inner data schema', async () => {
|
|
1993
|
-
const group: ScopeGroup = {
|
|
1994
|
-
scopeKey: 'events',
|
|
1995
|
-
camelCase: 'events',
|
|
1996
|
-
routes: [
|
|
1997
|
-
{
|
|
1998
|
-
kind: 'stream',
|
|
1999
|
-
name: 'StreamEvents',
|
|
2000
|
-
path: '/events/StreamEvents/1',
|
|
2001
|
-
methods: ['get', 'post'],
|
|
2002
|
-
streamMode: 'sse',
|
|
2003
|
-
scope: 'events',
|
|
2004
|
-
version: 1,
|
|
2005
|
-
jsonSchema: {
|
|
2006
|
-
yieldType: {
|
|
2007
|
-
type: 'object',
|
|
2008
|
-
required: ['data', 'event', 'id'],
|
|
2009
|
-
properties: {
|
|
2010
|
-
data: { type: 'object', properties: { value: { type: 'number' } } },
|
|
2011
|
-
event: { type: 'string' },
|
|
2012
|
-
id: { type: 'string' },
|
|
2013
|
-
},
|
|
2014
|
-
},
|
|
2015
|
-
},
|
|
2016
|
-
} satisfies StreamHttpRouteDoc,
|
|
2017
|
-
],
|
|
2018
|
-
}
|
|
2019
|
-
const code = await emitScopeFile(group)
|
|
2020
|
-
// The yield type should be based on the inner data schema, not the full envelope
|
|
2021
|
-
expect(code).toContain('StreamEventsYield')
|
|
2022
|
-
// Should NOT contain the SSE envelope fields in the yield type
|
|
2023
|
-
expect(code).not.toContain("event: string")
|
|
2024
|
-
})
|
|
2025
|
-
})
|
|
2026
|
-
```
|
|
2027
|
-
|
|
2028
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
2029
|
-
|
|
2030
|
-
Run: `npx vitest run src/codegen/emit-scope.test.ts`
|
|
2031
|
-
Expected: FAIL — module not found
|
|
2032
|
-
|
|
2033
|
-
- [ ] **Step 3: Implement emit-scope**
|
|
2034
|
-
|
|
2035
|
-
Create `src/codegen/emit-scope.ts`:
|
|
2036
|
-
|
|
2037
|
-
```ts
|
|
2038
|
-
import type { AnyHttpRouteDoc, RPCHttpRouteDoc, APIHttpRouteDoc, StreamHttpRouteDoc } from '../implementations/types.js'
|
|
2039
|
-
import type { ScopeGroup } from './group-routes.js'
|
|
2040
|
-
import type { AjscOptions } from './emit-types.js'
|
|
2041
|
-
import { jsonSchemaToTypeString } from './emit-types.js'
|
|
2042
|
-
import { scopeToCamelCase } from './group-routes.js'
|
|
2043
|
-
|
|
2044
|
-
function isSSEEnvelope(schema: Record<string, unknown> | undefined): boolean {
|
|
2045
|
-
if (!schema) return false
|
|
2046
|
-
const props = schema.properties as Record<string, unknown> | undefined
|
|
2047
|
-
return !!props?.data && !!props?.event && !!props?.id
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
function unwrapSSEYieldType(schema: Record<string, unknown>): Record<string, unknown> {
|
|
2051
|
-
const props = schema.properties as Record<string, any>
|
|
2052
|
-
return props.data ?? schema
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
async function emitRpcRoute(route: RPCHttpRouteDoc, ajscOpts?: AjscOptions): Promise<{ types: string; callable: string }> {
|
|
2056
|
-
const types: string[] = []
|
|
2057
|
-
const paramsType = await jsonSchemaToTypeString(`${route.name}Params`, route.jsonSchema.body, ajscOpts)
|
|
2058
|
-
const responseType = await jsonSchemaToTypeString(`${route.name}Response`, route.jsonSchema.response, ajscOpts)
|
|
2059
|
-
if (paramsType) types.push(paramsType)
|
|
2060
|
-
if (responseType) types.push(responseType)
|
|
2061
|
-
|
|
2062
|
-
const paramsArg = paramsType ? `params: ${route.name}Params` : ''
|
|
2063
|
-
const returnType = responseType ? `Promise<${route.name}Response>` : 'Promise<unknown>'
|
|
2064
|
-
|
|
2065
|
-
const callable = ` /** POST ${route.path} */
|
|
2066
|
-
${route.name}(${paramsArg}${paramsArg ? ', ' : ''}options?: ProcedureCallOptions): ${returnType} {
|
|
2067
|
-
return client.call({
|
|
2068
|
-
name: '${route.name}',
|
|
2069
|
-
scope: '${typeof route.scope === 'string' ? route.scope : route.scope.join('-')}',
|
|
2070
|
-
path: '${route.path}',
|
|
2071
|
-
method: '${route.method}',
|
|
2072
|
-
kind: 'rpc',
|
|
2073
|
-
params${paramsArg ? '' : ': undefined'},
|
|
2074
|
-
}, options)
|
|
2075
|
-
}`
|
|
2076
|
-
|
|
2077
|
-
return { types: types.join('\n\n'), callable }
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
|
-
async function emitApiRoute(route: APIHttpRouteDoc, ajscOpts?: AjscOptions): Promise<{ types: string; callable: string }> {
|
|
2081
|
-
const types: string[] = []
|
|
2082
|
-
const channels: string[] = []
|
|
2083
|
-
|
|
2084
|
-
// Build structured params type with PascalCase channel names
|
|
2085
|
-
for (const channel of ['pathParams', 'query', 'body', 'headers'] as const) {
|
|
2086
|
-
const schema = route.jsonSchema[channel]
|
|
2087
|
-
if (schema) {
|
|
2088
|
-
const channelPascal = channel.charAt(0).toUpperCase() + channel.slice(1)
|
|
2089
|
-
const channelType = await jsonSchemaToTypeString(`${route.name}${channelPascal}`, schema, ajscOpts)
|
|
2090
|
-
if (channelType) {
|
|
2091
|
-
types.push(channelType)
|
|
2092
|
-
channels.push(` ${channel}: ${route.name}${channelPascal}`)
|
|
2093
|
-
}
|
|
2094
|
-
}
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
if (channels.length > 0) {
|
|
2098
|
-
types.push(`export type ${route.name}Params = {\n${channels.join('\n')}\n}`)
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
const responseType = await jsonSchemaToTypeString(`${route.name}Response`, route.jsonSchema.response, ajscOpts)
|
|
2102
|
-
if (responseType) types.push(responseType)
|
|
2103
|
-
|
|
2104
|
-
const paramsArg = channels.length > 0 ? `params: ${route.name}Params` : ''
|
|
2105
|
-
const returnType = responseType ? `Promise<${route.name}Response>` : 'Promise<unknown>'
|
|
2106
|
-
const scope = route.scope ?? 'default'
|
|
2107
|
-
|
|
2108
|
-
const callable = ` /** ${route.method.toUpperCase()} ${route.fullPath || route.path} */
|
|
2109
|
-
${route.name}(${paramsArg}${paramsArg ? ', ' : ''}options?: ProcedureCallOptions): ${returnType} {
|
|
2110
|
-
return client.call({
|
|
2111
|
-
name: '${route.name}',
|
|
2112
|
-
scope: '${scope}',
|
|
2113
|
-
path: '${route.path}',
|
|
2114
|
-
method: '${route.method}',
|
|
2115
|
-
kind: 'api',
|
|
2116
|
-
params${paramsArg ? '' : ': undefined'},
|
|
2117
|
-
}, options)
|
|
2118
|
-
}`
|
|
2119
|
-
|
|
2120
|
-
return { types: types.join('\n\n'), callable }
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
|
-
async function emitStreamRoute(route: StreamHttpRouteDoc, ajscOpts?: AjscOptions): Promise<{ types: string; callable: string }> {
|
|
2124
|
-
const types: string[] = []
|
|
2125
|
-
|
|
2126
|
-
const paramsType = await jsonSchemaToTypeString(`${route.name}Params`, route.jsonSchema.params, ajscOpts)
|
|
2127
|
-
if (paramsType) types.push(paramsType)
|
|
2128
|
-
|
|
2129
|
-
// Unwrap SSE envelope for yieldType
|
|
2130
|
-
let yieldSchema = route.jsonSchema.yieldType
|
|
2131
|
-
if (route.streamMode === 'sse' && yieldSchema && isSSEEnvelope(yieldSchema)) {
|
|
2132
|
-
yieldSchema = unwrapSSEYieldType(yieldSchema)
|
|
2133
|
-
}
|
|
2134
|
-
const yieldType = await jsonSchemaToTypeString(`${route.name}Yield`, yieldSchema, ajscOpts)
|
|
2135
|
-
if (yieldType) types.push(yieldType)
|
|
2136
|
-
|
|
2137
|
-
const returnType = await jsonSchemaToTypeString(`${route.name}Return`, route.jsonSchema.returnType, ajscOpts)
|
|
2138
|
-
if (returnType) types.push(returnType)
|
|
2139
|
-
|
|
2140
|
-
const paramsArg = paramsType ? `params: ${route.name}Params` : ''
|
|
2141
|
-
const yieldTypeRef = yieldType ? `${route.name}Yield` : 'unknown'
|
|
2142
|
-
const returnTypeRef = returnType ? `${route.name}Return` : 'void'
|
|
2143
|
-
const scope = typeof route.scope === 'string' ? route.scope : route.scope.join('-')
|
|
2144
|
-
|
|
2145
|
-
const callable = ` /** ${route.streamMode.toUpperCase()} ${route.path} */
|
|
2146
|
-
${route.name}(${paramsArg}${paramsArg ? ', ' : ''}options?: ProcedureCallOptions): TypedStream<${yieldTypeRef}, ${returnTypeRef}> {
|
|
2147
|
-
return client.stream({
|
|
2148
|
-
name: '${route.name}',
|
|
2149
|
-
scope: '${scope}',
|
|
2150
|
-
path: '${route.path}',
|
|
2151
|
-
method: 'post',
|
|
2152
|
-
kind: 'stream',
|
|
2153
|
-
streamMode: '${route.streamMode}',
|
|
2154
|
-
params${paramsArg ? '' : ': undefined'},
|
|
2155
|
-
}, options)
|
|
2156
|
-
}`
|
|
2157
|
-
|
|
2158
|
-
return { types: types.join('\n\n'), callable }
|
|
2159
|
-
}
|
|
2160
|
-
|
|
2161
|
-
export async function emitScopeFile(group: ScopeGroup, ajscOpts?: AjscOptions): Promise<string> {
|
|
2162
|
-
const allTypes: string[] = []
|
|
2163
|
-
const allCallables: string[] = []
|
|
2164
|
-
let needsTypedStream = false
|
|
2165
|
-
|
|
2166
|
-
for (const route of group.routes) {
|
|
2167
|
-
let result: { types: string; callable: string }
|
|
2168
|
-
|
|
2169
|
-
if (route.kind === 'rpc') {
|
|
2170
|
-
result = await emitRpcRoute(route as RPCHttpRouteDoc, ajscOpts)
|
|
2171
|
-
} else if (route.kind === 'api') {
|
|
2172
|
-
result = await emitApiRoute(route as APIHttpRouteDoc, ajscOpts)
|
|
2173
|
-
} else if (route.kind === 'stream') {
|
|
2174
|
-
result = await emitStreamRoute(route as StreamHttpRouteDoc, ajscOpts)
|
|
2175
|
-
needsTypedStream = true
|
|
2176
|
-
} else {
|
|
2177
|
-
throw new Error(`Unknown route kind on route "${route.name}"`)
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
if (result.types) allTypes.push(result.types)
|
|
2181
|
-
allCallables.push(result.callable)
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
const pascalScope = group.camelCase.charAt(0).toUpperCase() + group.camelCase.slice(1)
|
|
2185
|
-
const typedStreamImport = needsTypedStream ? ', TypedStream' : ''
|
|
2186
|
-
|
|
2187
|
-
return `// Auto-generated by ts-procedures-codegen — do not edit
|
|
2188
|
-
import type { ClientInstance, ProcedureCallOptions${typedStreamImport} } from 'ts-procedures/client'
|
|
2189
|
-
|
|
2190
|
-
// ── Types ────────────────────────────────────────────────────
|
|
2191
|
-
|
|
2192
|
-
${allTypes.join('\n\n')}
|
|
2193
|
-
|
|
2194
|
-
// ── Callables ────────────────────────────────────────────────
|
|
2195
|
-
|
|
2196
|
-
export function bind${pascalScope}Scope(client: ClientInstance) {
|
|
2197
|
-
return {
|
|
2198
|
-
${allCallables.join(',\n\n')},
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
`
|
|
2202
|
-
}
|
|
2203
|
-
```
|
|
2204
|
-
|
|
2205
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
2206
|
-
|
|
2207
|
-
Run: `npx vitest run src/codegen/emit-scope.test.ts`
|
|
2208
|
-
Expected: PASS
|
|
2209
|
-
|
|
2210
|
-
- [ ] **Step 5: Commit**
|
|
2211
|
-
|
|
2212
|
-
```bash
|
|
2213
|
-
git add src/codegen/emit-scope.ts src/codegen/emit-scope.test.ts
|
|
2214
|
-
git commit -m "feat: add per-scope file emission for RPC, API, and stream routes"
|
|
2215
|
-
```
|
|
2216
|
-
|
|
2217
|
-
---
|
|
2218
|
-
|
|
2219
|
-
### Task 14: Index Emission
|
|
2220
|
-
|
|
2221
|
-
**Files:**
|
|
2222
|
-
- Create: `src/codegen/emit-index.ts`
|
|
2223
|
-
- Test: `src/codegen/emit-index.test.ts`
|
|
2224
|
-
|
|
2225
|
-
- [ ] **Step 1: Write failing tests**
|
|
2226
|
-
|
|
2227
|
-
Create `src/codegen/emit-index.test.ts`:
|
|
2228
|
-
|
|
2229
|
-
```ts
|
|
2230
|
-
import { describe, it, expect } from 'vitest'
|
|
2231
|
-
import { emitIndexFile } from './emit-index.js'
|
|
2232
|
-
import type { ScopeGroup } from './group-routes.js'
|
|
2233
|
-
|
|
2234
|
-
describe('emitIndexFile', () => {
|
|
2235
|
-
it('generates barrel with scope bindings', () => {
|
|
2236
|
-
const groups: ScopeGroup[] = [
|
|
2237
|
-
{ scopeKey: 'users', camelCase: 'users', routes: [] },
|
|
2238
|
-
{ scopeKey: 'billing', camelCase: 'billing', routes: [] },
|
|
2239
|
-
{ scopeKey: 'admin-users', camelCase: 'adminUsers', routes: [] },
|
|
2240
|
-
]
|
|
2241
|
-
const code = emitIndexFile(groups)
|
|
2242
|
-
expect(code).toContain("from './users'")
|
|
2243
|
-
expect(code).toContain("from './billing'")
|
|
2244
|
-
expect(code).toContain("from './admin-users'")
|
|
2245
|
-
expect(code).toContain("export * from './users'")
|
|
2246
|
-
expect(code).toContain('bindUsersScope')
|
|
2247
|
-
expect(code).toContain('bindBillingScope')
|
|
2248
|
-
expect(code).toContain('bindAdminUsersScope')
|
|
2249
|
-
expect(code).toContain('createScopeBindings')
|
|
2250
|
-
expect(code).toContain('users: bindUsersScope(client)')
|
|
2251
|
-
expect(code).toContain('adminUsers: bindAdminUsersScope(client)')
|
|
2252
|
-
})
|
|
2253
|
-
})
|
|
2254
|
-
```
|
|
2255
|
-
|
|
2256
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
2257
|
-
|
|
2258
|
-
Run: `npx vitest run src/codegen/emit-index.test.ts`
|
|
2259
|
-
Expected: FAIL — module not found
|
|
2260
|
-
|
|
2261
|
-
- [ ] **Step 3: Implement emit-index**
|
|
2262
|
-
|
|
2263
|
-
Create `src/codegen/emit-index.ts`:
|
|
2264
|
-
|
|
2265
|
-
```ts
|
|
2266
|
-
import type { ScopeGroup } from './group-routes.js'
|
|
2267
|
-
|
|
2268
|
-
export function emitIndexFile(groups: ScopeGroup[]): string {
|
|
2269
|
-
const imports: string[] = []
|
|
2270
|
-
const reExports: string[] = []
|
|
2271
|
-
const bindings: string[] = []
|
|
2272
|
-
|
|
2273
|
-
for (const group of groups) {
|
|
2274
|
-
const pascal = group.camelCase.charAt(0).toUpperCase() + group.camelCase.slice(1)
|
|
2275
|
-
const bindFn = `bind${pascal}Scope`
|
|
2276
|
-
imports.push(`import { ${bindFn} } from './${group.scopeKey}'`)
|
|
2277
|
-
reExports.push(`export * from './${group.scopeKey}'`)
|
|
2278
|
-
bindings.push(` ${group.camelCase}: ${bindFn}(client)`)
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
|
-
return `// Auto-generated by ts-procedures-codegen — do not edit
|
|
2282
|
-
import type { ClientInstance } from 'ts-procedures/client'
|
|
2283
|
-
${imports.join('\n')}
|
|
2284
|
-
|
|
2285
|
-
${reExports.join('\n')}
|
|
2286
|
-
|
|
2287
|
-
export function createScopeBindings(client: ClientInstance) {
|
|
2288
|
-
return {
|
|
2289
|
-
${bindings.join(',\n')},
|
|
2290
|
-
}
|
|
2291
|
-
}
|
|
2292
|
-
`
|
|
2293
|
-
}
|
|
2294
|
-
```
|
|
2295
|
-
|
|
2296
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
2297
|
-
|
|
2298
|
-
Run: `npx vitest run src/codegen/emit-index.test.ts`
|
|
2299
|
-
Expected: PASS
|
|
2300
|
-
|
|
2301
|
-
- [ ] **Step 5: Commit**
|
|
2302
|
-
|
|
2303
|
-
```bash
|
|
2304
|
-
git add src/codegen/emit-index.ts src/codegen/emit-index.test.ts
|
|
2305
|
-
git commit -m "feat: add barrel index file emission"
|
|
2306
|
-
```
|
|
2307
|
-
|
|
2308
|
-
---
|
|
2309
|
-
|
|
2310
|
-
### Task 15: Pipeline and generateClient API
|
|
2311
|
-
|
|
2312
|
-
**Files:**
|
|
2313
|
-
- Create: `src/codegen/pipeline.ts`
|
|
2314
|
-
- Create: `src/codegen/index.ts`
|
|
2315
|
-
- Test: `src/codegen/pipeline.test.ts`
|
|
2316
|
-
|
|
2317
|
-
- [ ] **Step 1: Write failing tests**
|
|
2318
|
-
|
|
2319
|
-
Create `src/codegen/pipeline.test.ts`:
|
|
2320
|
-
|
|
2321
|
-
```ts
|
|
2322
|
-
import { describe, it, expect } from 'vitest'
|
|
2323
|
-
import { generateClient } from './index.js'
|
|
2324
|
-
import { mkdirSync, rmSync, readFileSync, existsSync } from 'node:fs'
|
|
2325
|
-
import { join } from 'node:path'
|
|
2326
|
-
import { tmpdir } from 'node:os'
|
|
2327
|
-
import type { DocEnvelope } from '../implementations/types.js'
|
|
2328
|
-
|
|
2329
|
-
const fixture: DocEnvelope = {
|
|
2330
|
-
basePath: '/api',
|
|
2331
|
-
headers: [],
|
|
2332
|
-
errors: [],
|
|
2333
|
-
routes: [
|
|
2334
|
-
{
|
|
2335
|
-
kind: 'rpc',
|
|
2336
|
-
name: 'GetUser',
|
|
2337
|
-
path: '/users/GetUser/1',
|
|
2338
|
-
method: 'post',
|
|
2339
|
-
scope: 'users',
|
|
2340
|
-
version: 1,
|
|
2341
|
-
jsonSchema: {
|
|
2342
|
-
body: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
2343
|
-
response: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
2344
|
-
},
|
|
2345
|
-
},
|
|
2346
|
-
{
|
|
2347
|
-
kind: 'rpc',
|
|
2348
|
-
name: 'ListInvoices',
|
|
2349
|
-
path: '/billing/ListInvoices/1',
|
|
2350
|
-
method: 'post',
|
|
2351
|
-
scope: 'billing',
|
|
2352
|
-
version: 1,
|
|
2353
|
-
jsonSchema: {
|
|
2354
|
-
response: { type: 'object', properties: { items: { type: 'array' } } },
|
|
2355
|
-
},
|
|
2356
|
-
},
|
|
2357
|
-
],
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2360
|
-
describe('generateClient', () => {
|
|
2361
|
-
let outDir: string
|
|
2362
|
-
|
|
2363
|
-
it('generates per-scope files and an index', async () => {
|
|
2364
|
-
outDir = join(tmpdir(), 'ts-proc-codegen-test-' + Date.now())
|
|
2365
|
-
mkdirSync(outDir, { recursive: true })
|
|
2366
|
-
|
|
2367
|
-
await generateClient({
|
|
2368
|
-
envelope: fixture as any,
|
|
2369
|
-
outDir,
|
|
2370
|
-
})
|
|
2371
|
-
|
|
2372
|
-
expect(existsSync(join(outDir, 'users.ts'))).toBe(true)
|
|
2373
|
-
expect(existsSync(join(outDir, 'billing.ts'))).toBe(true)
|
|
2374
|
-
expect(existsSync(join(outDir, 'index.ts'))).toBe(true)
|
|
2375
|
-
|
|
2376
|
-
const usersContent = readFileSync(join(outDir, 'users.ts'), 'utf-8')
|
|
2377
|
-
expect(usersContent).toContain('GetUserParams')
|
|
2378
|
-
expect(usersContent).toContain('GetUserResponse')
|
|
2379
|
-
expect(usersContent).toContain('bindUsersScope')
|
|
2380
|
-
|
|
2381
|
-
const indexContent = readFileSync(join(outDir, 'index.ts'), 'utf-8')
|
|
2382
|
-
expect(indexContent).toContain('createScopeBindings')
|
|
2383
|
-
expect(indexContent).toContain("from './users'")
|
|
2384
|
-
expect(indexContent).toContain("from './billing'")
|
|
2385
|
-
|
|
2386
|
-
rmSync(outDir, { recursive: true })
|
|
2387
|
-
})
|
|
2388
|
-
})
|
|
2389
|
-
```
|
|
2390
|
-
|
|
2391
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
2392
|
-
|
|
2393
|
-
Run: `npx vitest run src/codegen/pipeline.test.ts`
|
|
2394
|
-
Expected: FAIL — module not found
|
|
2395
|
-
|
|
2396
|
-
- [ ] **Step 3: Implement pipeline and index**
|
|
2397
|
-
|
|
2398
|
-
Create `src/codegen/pipeline.ts`:
|
|
2399
|
-
|
|
2400
|
-
```ts
|
|
2401
|
-
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2402
|
-
import { join } from 'node:path'
|
|
2403
|
-
import type { DocEnvelope } from '../implementations/types.js'
|
|
2404
|
-
import type { AjscOptions } from './emit-types.js'
|
|
2405
|
-
import { groupRoutesByScope } from './group-routes.js'
|
|
2406
|
-
import { emitScopeFile } from './emit-scope.js'
|
|
2407
|
-
import { emitIndexFile } from './emit-index.js'
|
|
2408
|
-
|
|
2409
|
-
export interface PipelineOptions {
|
|
2410
|
-
envelope: DocEnvelope
|
|
2411
|
-
outDir: string
|
|
2412
|
-
ajsc?: AjscOptions
|
|
2413
|
-
}
|
|
2414
|
-
|
|
2415
|
-
export async function runPipeline(options: PipelineOptions): Promise<void> {
|
|
2416
|
-
const { envelope, outDir, ajsc: ajscOpts } = options
|
|
2417
|
-
|
|
2418
|
-
await mkdir(outDir, { recursive: true })
|
|
2419
|
-
|
|
2420
|
-
const groups = groupRoutesByScope(envelope.routes)
|
|
2421
|
-
const groupArray = Array.from(groups.values())
|
|
2422
|
-
|
|
2423
|
-
// Emit each scope file
|
|
2424
|
-
for (const group of groupArray) {
|
|
2425
|
-
const code = await emitScopeFile(group, ajscOpts)
|
|
2426
|
-
await writeFile(join(outDir, `${group.scopeKey}.ts`), code, 'utf-8')
|
|
2427
|
-
}
|
|
2428
|
-
|
|
2429
|
-
// Emit index
|
|
2430
|
-
const indexCode = emitIndexFile(groupArray)
|
|
2431
|
-
await writeFile(join(outDir, 'index.ts'), indexCode, 'utf-8')
|
|
2432
|
-
}
|
|
2433
|
-
```
|
|
2434
|
-
|
|
2435
|
-
Create `src/codegen/index.ts`:
|
|
2436
|
-
|
|
2437
|
-
```ts
|
|
2438
|
-
import { resolveEnvelope, type ResolveInput } from './resolve-envelope.js'
|
|
2439
|
-
import { runPipeline } from './pipeline.js'
|
|
2440
|
-
import type { AjscOptions } from './emit-types.js'
|
|
2441
|
-
|
|
2442
|
-
export interface GenerateClientOptions extends ResolveInput {
|
|
2443
|
-
outDir: string
|
|
2444
|
-
ajsc?: AjscOptions
|
|
2445
|
-
}
|
|
2446
|
-
|
|
2447
|
-
export async function generateClient(options: GenerateClientOptions): Promise<void> {
|
|
2448
|
-
const envelope = await resolveEnvelope(options)
|
|
2449
|
-
await runPipeline({ envelope, outDir: options.outDir, ajsc: options.ajsc })
|
|
2450
|
-
}
|
|
2451
|
-
|
|
2452
|
-
export type { AjscOptions } from './emit-types.js'
|
|
2453
|
-
export type { ResolveInput } from './resolve-envelope.js'
|
|
2454
|
-
```
|
|
2455
|
-
|
|
2456
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
2457
|
-
|
|
2458
|
-
Run: `npx vitest run src/codegen/pipeline.test.ts`
|
|
2459
|
-
Expected: PASS
|
|
2460
|
-
|
|
2461
|
-
- [ ] **Step 5: Commit**
|
|
2462
|
-
|
|
2463
|
-
```bash
|
|
2464
|
-
git add src/codegen/pipeline.ts src/codegen/index.ts src/codegen/pipeline.test.ts
|
|
2465
|
-
git commit -m "feat: add codegen pipeline and generateClient public API"
|
|
2466
|
-
```
|
|
2467
|
-
|
|
2468
|
-
---
|
|
2469
|
-
|
|
2470
|
-
### Task 16: CLI
|
|
2471
|
-
|
|
2472
|
-
**Files:**
|
|
2473
|
-
- Create: `src/codegen/bin/cli.ts`
|
|
2474
|
-
- Test: `src/codegen/bin/cli.test.ts`
|
|
2475
|
-
|
|
2476
|
-
- [ ] **Step 1: Write failing tests**
|
|
2477
|
-
|
|
2478
|
-
Create `src/codegen/bin/cli.test.ts`:
|
|
2479
|
-
|
|
2480
|
-
```ts
|
|
2481
|
-
import { describe, it, expect } from 'vitest'
|
|
2482
|
-
import { parseArgs } from './cli.js'
|
|
2483
|
-
|
|
2484
|
-
describe('parseArgs', () => {
|
|
2485
|
-
it('parses --url and --out', () => {
|
|
2486
|
-
const args = parseArgs(['--url', 'http://localhost:3000/docs', '--out', './generated'])
|
|
2487
|
-
expect(args.url).toBe('http://localhost:3000/docs')
|
|
2488
|
-
expect(args.outDir).toBe('./generated')
|
|
2489
|
-
})
|
|
2490
|
-
|
|
2491
|
-
it('parses --file and --out', () => {
|
|
2492
|
-
const args = parseArgs(['--file', './docs.json', '--out', './generated'])
|
|
2493
|
-
expect(args.file).toBe('./docs.json')
|
|
2494
|
-
expect(args.outDir).toBe('./generated')
|
|
2495
|
-
})
|
|
2496
|
-
|
|
2497
|
-
it('parses --enum-style', () => {
|
|
2498
|
-
const args = parseArgs(['--url', 'http://x.com/docs', '--out', './gen', '--enum-style', 'enum'])
|
|
2499
|
-
expect(args.ajsc?.enumStyle).toBe('enum')
|
|
2500
|
-
})
|
|
2501
|
-
|
|
2502
|
-
it('throws when --out is missing', () => {
|
|
2503
|
-
expect(() => parseArgs(['--url', 'http://x.com/docs'])).toThrow('--out is required')
|
|
2504
|
-
})
|
|
2505
|
-
|
|
2506
|
-
it('throws when no input source provided', () => {
|
|
2507
|
-
expect(() => parseArgs(['--out', './gen'])).toThrow('Provide --url or --file')
|
|
2508
|
-
})
|
|
2509
|
-
})
|
|
2510
|
-
```
|
|
2511
|
-
|
|
2512
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
2513
|
-
|
|
2514
|
-
Run: `npx vitest run src/codegen/bin/cli.test.ts`
|
|
2515
|
-
Expected: FAIL — module not found
|
|
2516
|
-
|
|
2517
|
-
- [ ] **Step 3: Implement CLI**
|
|
2518
|
-
|
|
2519
|
-
Create `src/codegen/bin/cli.ts`:
|
|
2520
|
-
|
|
2521
|
-
```ts
|
|
2522
|
-
#!/usr/bin/env node
|
|
2523
|
-
import { generateClient, type GenerateClientOptions } from '../index.js'
|
|
2524
|
-
|
|
2525
|
-
export interface ParsedArgs {
|
|
2526
|
-
url?: string
|
|
2527
|
-
file?: string
|
|
2528
|
-
outDir: string
|
|
2529
|
-
watch?: boolean
|
|
2530
|
-
interval?: number
|
|
2531
|
-
ajsc?: {
|
|
2532
|
-
enumStyle?: 'union' | 'enum'
|
|
2533
|
-
depluralize?: boolean
|
|
2534
|
-
}
|
|
2535
|
-
}
|
|
2536
|
-
|
|
2537
|
-
export function parseArgs(argv: string[]): ParsedArgs {
|
|
2538
|
-
const result: Partial<ParsedArgs> = { ajsc: {} }
|
|
2539
|
-
|
|
2540
|
-
for (let i = 0; i < argv.length; i++) {
|
|
2541
|
-
switch (argv[i]) {
|
|
2542
|
-
case '--url': result.url = argv[++i]; break
|
|
2543
|
-
case '--file': result.file = argv[++i]; break
|
|
2544
|
-
case '--out': result.outDir = argv[++i]; break
|
|
2545
|
-
case '--watch': result.watch = true; break
|
|
2546
|
-
case '--interval': result.interval = parseInt(argv[++i], 10); break
|
|
2547
|
-
case '--enum-style': result.ajsc!.enumStyle = argv[++i] as 'union' | 'enum'; break
|
|
2548
|
-
case '--depluralize': result.ajsc!.depluralize = argv[++i] !== 'false'; break
|
|
2549
|
-
}
|
|
2550
|
-
}
|
|
2551
|
-
|
|
2552
|
-
if (!result.outDir) throw new Error('--out is required')
|
|
2553
|
-
if (!result.url && !result.file) throw new Error('Provide --url or --file')
|
|
2554
|
-
|
|
2555
|
-
return result as ParsedArgs
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
async function run() {
|
|
2559
|
-
const args = parseArgs(process.argv.slice(2))
|
|
2560
|
-
|
|
2561
|
-
const options: GenerateClientOptions = {
|
|
2562
|
-
url: args.url,
|
|
2563
|
-
file: args.file,
|
|
2564
|
-
outDir: args.outDir,
|
|
2565
|
-
ajsc: args.ajsc,
|
|
2566
|
-
}
|
|
2567
|
-
|
|
2568
|
-
if (args.watch) {
|
|
2569
|
-
const interval = args.interval ?? 3000
|
|
2570
|
-
let lastHash = ''
|
|
2571
|
-
console.log(`Watching ${args.url || args.file} every ${interval}ms...`)
|
|
2572
|
-
|
|
2573
|
-
const poll = async () => {
|
|
2574
|
-
try {
|
|
2575
|
-
const { createHash } = await import('node:crypto')
|
|
2576
|
-
// Re-resolve each time to detect changes
|
|
2577
|
-
const { resolveEnvelope } = await import('../resolve-envelope.js')
|
|
2578
|
-
const envelope = await resolveEnvelope(options)
|
|
2579
|
-
const hash = createHash('md5').update(JSON.stringify(envelope)).digest('hex')
|
|
2580
|
-
|
|
2581
|
-
if (hash !== lastHash) {
|
|
2582
|
-
lastHash = hash
|
|
2583
|
-
await generateClient({ ...options, envelope })
|
|
2584
|
-
console.log(`Generated client files in ${args.outDir}`)
|
|
2585
|
-
}
|
|
2586
|
-
} catch (err) {
|
|
2587
|
-
console.error('Error:', (err as Error).message)
|
|
2588
|
-
}
|
|
2589
|
-
}
|
|
2590
|
-
|
|
2591
|
-
await poll()
|
|
2592
|
-
setInterval(poll, interval)
|
|
2593
|
-
} else {
|
|
2594
|
-
await generateClient(options)
|
|
2595
|
-
console.log(`Generated client files in ${args.outDir}`)
|
|
2596
|
-
}
|
|
2597
|
-
}
|
|
2598
|
-
|
|
2599
|
-
// Only run if this is the entry point
|
|
2600
|
-
const isMain = process.argv[1]?.endsWith('cli.js') || process.argv[1]?.endsWith('cli.ts')
|
|
2601
|
-
if (isMain) {
|
|
2602
|
-
run().catch((err) => {
|
|
2603
|
-
console.error('Error:', err.message)
|
|
2604
|
-
process.exit(1)
|
|
2605
|
-
})
|
|
2606
|
-
}
|
|
2607
|
-
```
|
|
2608
|
-
|
|
2609
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
2610
|
-
|
|
2611
|
-
Run: `npx vitest run src/codegen/bin/cli.test.ts`
|
|
2612
|
-
Expected: PASS
|
|
2613
|
-
|
|
2614
|
-
- [ ] **Step 5: Commit**
|
|
2615
|
-
|
|
2616
|
-
```bash
|
|
2617
|
-
git add src/codegen/bin/cli.ts src/codegen/bin/cli.test.ts
|
|
2618
|
-
git commit -m "feat: add ts-procedures-codegen CLI"
|
|
2619
|
-
```
|
|
2620
|
-
|
|
2621
|
-
---
|
|
2622
|
-
|
|
2623
|
-
## Phase 4: Package Integration
|
|
2624
|
-
|
|
2625
|
-
### Task 17: Update package.json
|
|
2626
|
-
|
|
2627
|
-
**Files:**
|
|
2628
|
-
- Modify: `package.json`
|
|
2629
|
-
|
|
2630
|
-
- [ ] **Step 1: Add new exports, bin entry, and optionalDependency**
|
|
2631
|
-
|
|
2632
|
-
In `package.json`, add the following:
|
|
2633
|
-
|
|
2634
|
-
Under `"exports"`, add:
|
|
2635
|
-
```json
|
|
2636
|
-
"./client": {
|
|
2637
|
-
"types": "./build/client/index.d.ts",
|
|
2638
|
-
"import": "./build/client/index.js"
|
|
2639
|
-
},
|
|
2640
|
-
"./codegen": {
|
|
2641
|
-
"types": "./build/codegen/index.d.ts",
|
|
2642
|
-
"import": "./build/codegen/index.js"
|
|
2643
|
-
}
|
|
2644
|
-
```
|
|
2645
|
-
|
|
2646
|
-
Under `"bin"`, add:
|
|
2647
|
-
```json
|
|
2648
|
-
"ts-procedures-codegen": "./build/codegen/bin/cli.js"
|
|
2649
|
-
```
|
|
2650
|
-
|
|
2651
|
-
Under `"optionalDependencies"`, add:
|
|
2652
|
-
```json
|
|
2653
|
-
"ajsc": "^5.2.4"
|
|
2654
|
-
```
|
|
2655
|
-
|
|
2656
|
-
- [ ] **Step 2: Verify build**
|
|
2657
|
-
|
|
2658
|
-
Run: `npm run build`
|
|
2659
|
-
Expected: Compiles successfully with new client/ and codegen/ files in build/
|
|
2660
|
-
|
|
2661
|
-
- [ ] **Step 3: Run lint**
|
|
2662
|
-
|
|
2663
|
-
Run: `npm run lint`
|
|
2664
|
-
Expected: No errors. Fix any lint issues in new code before proceeding.
|
|
2665
|
-
|
|
2666
|
-
- [ ] **Step 4: Run full test suite**
|
|
2667
|
-
|
|
2668
|
-
Run: `npm run test`
|
|
2669
|
-
Expected: All tests pass (existing + new)
|
|
2670
|
-
|
|
2671
|
-
- [ ] **Step 5: Commit**
|
|
2672
|
-
|
|
2673
|
-
```bash
|
|
2674
|
-
git add package.json
|
|
2675
|
-
git commit -m "feat: add client and codegen package exports, CLI bin entry, ajsc optionalDep"
|
|
2676
|
-
```
|
|
2677
|
-
|
|
2678
|
-
---
|
|
2679
|
-
|
|
2680
|
-
### Task 18: End-to-End Integration Test
|
|
2681
|
-
|
|
2682
|
-
**Files:**
|
|
2683
|
-
- Create: `src/codegen/e2e.test.ts`
|
|
2684
|
-
|
|
2685
|
-
- [ ] **Step 1: Write E2E test**
|
|
2686
|
-
|
|
2687
|
-
Create `src/codegen/e2e.test.ts` that exercises the full pipeline from DocEnvelope → generated files → verify generated code structure:
|
|
2688
|
-
|
|
2689
|
-
```ts
|
|
2690
|
-
import { describe, it, expect } from 'vitest'
|
|
2691
|
-
import { generateClient } from './index.js'
|
|
2692
|
-
import { mkdirSync, rmSync, readFileSync, existsSync } from 'node:fs'
|
|
2693
|
-
import { join } from 'node:path'
|
|
2694
|
-
import { tmpdir } from 'node:os'
|
|
2695
|
-
import type { DocEnvelope, RPCHttpRouteDoc, APIHttpRouteDoc, StreamHttpRouteDoc } from '../implementations/types.js'
|
|
2696
|
-
|
|
2697
|
-
describe('E2E: generateClient', () => {
|
|
2698
|
-
const outDir = join(tmpdir(), 'ts-proc-e2e-' + Date.now())
|
|
2699
|
-
|
|
2700
|
-
const envelope: DocEnvelope = {
|
|
2701
|
-
basePath: '/api/v1',
|
|
2702
|
-
headers: [{ name: 'Authorization', required: true }],
|
|
2703
|
-
errors: [],
|
|
2704
|
-
routes: [
|
|
2705
|
-
{
|
|
2706
|
-
kind: 'rpc',
|
|
2707
|
-
name: 'GetUser',
|
|
2708
|
-
path: '/users/GetUser/1',
|
|
2709
|
-
method: 'post',
|
|
2710
|
-
scope: 'users',
|
|
2711
|
-
version: 1,
|
|
2712
|
-
jsonSchema: {
|
|
2713
|
-
body: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
2714
|
-
response: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' } }, required: ['id', 'name'] },
|
|
2715
|
-
},
|
|
2716
|
-
} satisfies RPCHttpRouteDoc,
|
|
2717
|
-
{
|
|
2718
|
-
kind: 'api',
|
|
2719
|
-
name: 'UpdateUser',
|
|
2720
|
-
path: '/users/:id',
|
|
2721
|
-
method: 'put',
|
|
2722
|
-
scope: 'users',
|
|
2723
|
-
fullPath: '/api/v1/users/:id',
|
|
2724
|
-
jsonSchema: {
|
|
2725
|
-
pathParams: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
2726
|
-
body: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
2727
|
-
response: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] },
|
|
2728
|
-
},
|
|
2729
|
-
} satisfies APIHttpRouteDoc,
|
|
2730
|
-
{
|
|
2731
|
-
kind: 'stream',
|
|
2732
|
-
name: 'WatchEvents',
|
|
2733
|
-
path: '/events/WatchEvents/1',
|
|
2734
|
-
methods: ['get', 'post'],
|
|
2735
|
-
streamMode: 'sse',
|
|
2736
|
-
scope: 'events',
|
|
2737
|
-
version: 1,
|
|
2738
|
-
jsonSchema: {
|
|
2739
|
-
params: { type: 'object', properties: { filter: { type: 'string' } } },
|
|
2740
|
-
yieldType: {
|
|
2741
|
-
type: 'object',
|
|
2742
|
-
required: ['data', 'event', 'id'],
|
|
2743
|
-
properties: {
|
|
2744
|
-
data: { type: 'object', properties: { msg: { type: 'string' } } },
|
|
2745
|
-
event: { type: 'string' },
|
|
2746
|
-
id: { type: 'string' },
|
|
2747
|
-
},
|
|
2748
|
-
},
|
|
2749
|
-
returnType: { type: 'object', properties: { count: { type: 'number' } } },
|
|
2750
|
-
},
|
|
2751
|
-
} satisfies StreamHttpRouteDoc,
|
|
2752
|
-
],
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
|
-
it('generates all expected files', async () => {
|
|
2756
|
-
mkdirSync(outDir, { recursive: true })
|
|
2757
|
-
await generateClient({ envelope: envelope as any, outDir })
|
|
2758
|
-
|
|
2759
|
-
// Check files exist
|
|
2760
|
-
expect(existsSync(join(outDir, 'users.ts'))).toBe(true)
|
|
2761
|
-
expect(existsSync(join(outDir, 'events.ts'))).toBe(true)
|
|
2762
|
-
expect(existsSync(join(outDir, 'index.ts'))).toBe(true)
|
|
2763
|
-
|
|
2764
|
-
// Verify users scope has both RPC and API routes
|
|
2765
|
-
const users = readFileSync(join(outDir, 'users.ts'), 'utf-8')
|
|
2766
|
-
expect(users).toContain('GetUserParams')
|
|
2767
|
-
expect(users).toContain('GetUserResponse')
|
|
2768
|
-
expect(users).toContain('UpdateUserParams')
|
|
2769
|
-
expect(users).toContain('UpdateUserResponse')
|
|
2770
|
-
expect(users).toContain('bindUsersScope')
|
|
2771
|
-
expect(users).toContain('client.call')
|
|
2772
|
-
|
|
2773
|
-
// Verify events scope has stream route
|
|
2774
|
-
const events = readFileSync(join(outDir, 'events.ts'), 'utf-8')
|
|
2775
|
-
expect(events).toContain('WatchEventsYield')
|
|
2776
|
-
expect(events).toContain('WatchEventsReturn')
|
|
2777
|
-
expect(events).toContain('TypedStream')
|
|
2778
|
-
expect(events).toContain('client.stream')
|
|
2779
|
-
expect(events).toContain("streamMode: 'sse'")
|
|
2780
|
-
|
|
2781
|
-
// Verify index barrel
|
|
2782
|
-
const index = readFileSync(join(outDir, 'index.ts'), 'utf-8')
|
|
2783
|
-
expect(index).toContain('createScopeBindings')
|
|
2784
|
-
expect(index).toContain('bindUsersScope')
|
|
2785
|
-
expect(index).toContain('bindEventsScope')
|
|
2786
|
-
|
|
2787
|
-
rmSync(outDir, { recursive: true })
|
|
2788
|
-
})
|
|
2789
|
-
})
|
|
2790
|
-
```
|
|
2791
|
-
|
|
2792
|
-
- [ ] **Step 2: Run E2E test**
|
|
2793
|
-
|
|
2794
|
-
Run: `npx vitest run src/codegen/e2e.test.ts`
|
|
2795
|
-
Expected: PASS
|
|
2796
|
-
|
|
2797
|
-
- [ ] **Step 3: Run full test suite one final time**
|
|
2798
|
-
|
|
2799
|
-
Run: `npm run test`
|
|
2800
|
-
Expected: All tests pass
|
|
2801
|
-
|
|
2802
|
-
- [ ] **Step 4: Run build to confirm everything compiles**
|
|
2803
|
-
|
|
2804
|
-
Run: `npm run build`
|
|
2805
|
-
Expected: Clean compilation
|
|
2806
|
-
|
|
2807
|
-
- [ ] **Step 5: Commit**
|
|
2808
|
-
|
|
2809
|
-
```bash
|
|
2810
|
-
git add src/codegen/e2e.test.ts
|
|
2811
|
-
git commit -m "test: add end-to-end integration test for client codegen"
|
|
2812
|
-
```
|
|
2813
|
-
|
|
2814
|
-
---
|
|
2815
|
-
|
|
2816
|
-
## Task Dependency Graph
|
|
2817
|
-
|
|
2818
|
-
```
|
|
2819
|
-
Task 1 (types) → Task 2 (builders)
|
|
2820
|
-
↓
|
|
2821
|
-
Task 3 (client types) → Task 4 (errors) → Task 5 (request builder) → Task 6 (hooks)
|
|
2822
|
-
↓
|
|
2823
|
-
Task 7 (call) → Task 8 (stream) → Task 9 (createClient)
|
|
2824
|
-
↓
|
|
2825
|
-
Task 10 (resolve) → Task 11 (group) → Task 12 (emit types) → Task 13 (emit scope) → Task 14 (emit index) → Task 15 (pipeline)
|
|
2826
|
-
↓
|
|
2827
|
-
Task 16 (CLI) → Task 17 (package.json) → Task 18 (E2E)
|
|
2828
|
-
```
|
|
2829
|
-
|
|
2830
|
-
**Independent parallel tracks:**
|
|
2831
|
-
- Phase 1 (Tasks 1-2) and Phase 2 (Tasks 3-9) can run in parallel
|
|
2832
|
-
- Phase 3 (Tasks 10-16) depends on Phase 1 (for route types with `kind`) but NOT on Phase 2
|
|
2833
|
-
- Phase 4 (Tasks 17-18) depends on everything
|