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.
Files changed (305) hide show
  1. package/README.md +1 -1
  2. package/agent_config/bin/postinstall.mjs +3 -3
  3. package/agent_config/bin/setup.mjs +22 -11
  4. package/agent_config/claude-code/agents/ts-procedures-architect.md +46 -101
  5. package/agent_config/claude-code/skills/{guide → ts-procedures}/SKILL.md +50 -35
  6. package/agent_config/claude-code/skills/{guide → ts-procedures}/anti-patterns.md +6 -5
  7. package/agent_config/claude-code/skills/{guide → ts-procedures}/api-reference.md +60 -49
  8. package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +48 -0
  9. package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/SKILL.md +19 -24
  10. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +115 -0
  11. package/agent_config/lib/install-claude.mjs +35 -87
  12. package/build/src/client/call.d.ts +14 -0
  13. package/build/src/client/call.js +47 -0
  14. package/build/src/client/call.js.map +1 -0
  15. package/build/src/client/call.test.d.ts +1 -0
  16. package/build/src/client/call.test.js +124 -0
  17. package/build/src/client/call.test.js.map +1 -0
  18. package/build/src/client/errors.d.ts +25 -0
  19. package/build/src/client/errors.js +33 -0
  20. package/build/src/client/errors.js.map +1 -0
  21. package/build/src/client/errors.test.d.ts +1 -0
  22. package/build/src/client/errors.test.js +41 -0
  23. package/build/src/client/errors.test.js.map +1 -0
  24. package/build/src/client/fetch-adapter.d.ts +12 -0
  25. package/build/src/client/fetch-adapter.js +156 -0
  26. package/build/src/client/fetch-adapter.js.map +1 -0
  27. package/build/src/client/fetch-adapter.test.d.ts +1 -0
  28. package/build/src/client/fetch-adapter.test.js +271 -0
  29. package/build/src/client/fetch-adapter.test.js.map +1 -0
  30. package/build/src/client/hooks.d.ts +17 -0
  31. package/build/src/client/hooks.js +40 -0
  32. package/build/src/client/hooks.js.map +1 -0
  33. package/build/src/client/hooks.test.d.ts +1 -0
  34. package/build/src/client/hooks.test.js +163 -0
  35. package/build/src/client/hooks.test.js.map +1 -0
  36. package/build/src/client/index.d.ts +22 -0
  37. package/build/src/client/index.js +67 -0
  38. package/build/src/client/index.js.map +1 -0
  39. package/build/src/client/index.test.d.ts +1 -0
  40. package/build/src/client/index.test.js +231 -0
  41. package/build/src/client/index.test.js.map +1 -0
  42. package/build/src/client/request-builder.d.ts +13 -0
  43. package/build/src/client/request-builder.js +53 -0
  44. package/build/src/client/request-builder.js.map +1 -0
  45. package/build/src/client/request-builder.test.d.ts +1 -0
  46. package/build/src/client/request-builder.test.js +160 -0
  47. package/build/src/client/request-builder.test.js.map +1 -0
  48. package/build/src/client/stream.d.ts +27 -0
  49. package/build/src/client/stream.js +118 -0
  50. package/build/src/client/stream.js.map +1 -0
  51. package/build/src/client/stream.test.d.ts +1 -0
  52. package/build/src/client/stream.test.js +228 -0
  53. package/build/src/client/stream.test.js.map +1 -0
  54. package/build/src/client/types.d.ts +78 -0
  55. package/build/src/client/types.js +3 -0
  56. package/build/src/client/types.js.map +1 -0
  57. package/build/src/codegen/bin/cli.d.ts +45 -0
  58. package/build/src/codegen/bin/cli.js +246 -0
  59. package/build/src/codegen/bin/cli.js.map +1 -0
  60. package/build/src/codegen/bin/cli.test.d.ts +1 -0
  61. package/build/src/codegen/bin/cli.test.js +220 -0
  62. package/build/src/codegen/bin/cli.test.js.map +1 -0
  63. package/build/src/codegen/constants.d.ts +1 -0
  64. package/build/src/codegen/constants.js +2 -0
  65. package/build/src/codegen/constants.js.map +1 -0
  66. package/build/src/codegen/e2e.test.d.ts +1 -0
  67. package/build/src/codegen/e2e.test.js +464 -0
  68. package/build/src/codegen/e2e.test.js.map +1 -0
  69. package/build/src/codegen/emit-client-runtime.d.ts +9 -0
  70. package/build/src/codegen/emit-client-runtime.js +99 -0
  71. package/build/src/codegen/emit-client-runtime.js.map +1 -0
  72. package/build/src/codegen/emit-client-runtime.test.d.ts +1 -0
  73. package/build/src/codegen/emit-client-runtime.test.js +78 -0
  74. package/build/src/codegen/emit-client-runtime.test.js.map +1 -0
  75. package/build/src/codegen/emit-client-types.d.ts +8 -0
  76. package/build/src/codegen/emit-client-types.js +25 -0
  77. package/build/src/codegen/emit-client-types.js.map +1 -0
  78. package/build/src/codegen/emit-client-types.test.d.ts +1 -0
  79. package/build/src/codegen/emit-client-types.test.js +33 -0
  80. package/build/src/codegen/emit-client-types.test.js.map +1 -0
  81. package/build/src/codegen/emit-errors.d.ts +19 -0
  82. package/build/src/codegen/emit-errors.js +59 -0
  83. package/build/src/codegen/emit-errors.js.map +1 -0
  84. package/build/src/codegen/emit-errors.test.d.ts +1 -0
  85. package/build/src/codegen/emit-errors.test.js +175 -0
  86. package/build/src/codegen/emit-errors.test.js.map +1 -0
  87. package/build/src/codegen/emit-index.d.ts +12 -0
  88. package/build/src/codegen/emit-index.js +41 -0
  89. package/build/src/codegen/emit-index.js.map +1 -0
  90. package/build/src/codegen/emit-index.test.d.ts +1 -0
  91. package/build/src/codegen/emit-index.test.js +106 -0
  92. package/build/src/codegen/emit-index.test.js.map +1 -0
  93. package/build/src/codegen/emit-scope.d.ts +15 -0
  94. package/build/src/codegen/emit-scope.js +299 -0
  95. package/build/src/codegen/emit-scope.js.map +1 -0
  96. package/build/src/codegen/emit-scope.test.d.ts +1 -0
  97. package/build/src/codegen/emit-scope.test.js +559 -0
  98. package/build/src/codegen/emit-scope.test.js.map +1 -0
  99. package/build/src/codegen/emit-types.d.ts +43 -0
  100. package/build/src/codegen/emit-types.js +111 -0
  101. package/build/src/codegen/emit-types.js.map +1 -0
  102. package/build/src/codegen/emit-types.test.d.ts +1 -0
  103. package/build/src/codegen/emit-types.test.js +184 -0
  104. package/build/src/codegen/emit-types.test.js.map +1 -0
  105. package/build/src/codegen/group-routes.d.ts +23 -0
  106. package/build/src/codegen/group-routes.js +46 -0
  107. package/build/src/codegen/group-routes.js.map +1 -0
  108. package/build/src/codegen/group-routes.test.d.ts +1 -0
  109. package/build/src/codegen/group-routes.test.js +131 -0
  110. package/build/src/codegen/group-routes.test.js.map +1 -0
  111. package/build/src/codegen/index.d.ts +15 -0
  112. package/build/src/codegen/index.js +16 -0
  113. package/build/src/codegen/index.js.map +1 -0
  114. package/build/src/codegen/naming.d.ts +7 -0
  115. package/build/src/codegen/naming.js +21 -0
  116. package/build/src/codegen/naming.js.map +1 -0
  117. package/build/src/codegen/naming.test.d.ts +1 -0
  118. package/build/src/codegen/naming.test.js +40 -0
  119. package/build/src/codegen/naming.test.js.map +1 -0
  120. package/build/src/codegen/pipeline.d.ts +17 -0
  121. package/build/src/codegen/pipeline.js +78 -0
  122. package/build/src/codegen/pipeline.js.map +1 -0
  123. package/build/src/codegen/pipeline.test.d.ts +1 -0
  124. package/build/src/codegen/pipeline.test.js +269 -0
  125. package/build/src/codegen/pipeline.test.js.map +1 -0
  126. package/build/src/codegen/resolve-envelope.d.ts +7 -0
  127. package/build/src/codegen/resolve-envelope.js +46 -0
  128. package/build/src/codegen/resolve-envelope.js.map +1 -0
  129. package/build/src/codegen/resolve-envelope.test.d.ts +1 -0
  130. package/build/src/codegen/resolve-envelope.test.js +69 -0
  131. package/build/src/codegen/resolve-envelope.test.js.map +1 -0
  132. package/build/src/errors.d.ts +33 -0
  133. package/build/src/errors.js +91 -0
  134. package/build/src/errors.js.map +1 -0
  135. package/build/src/errors.test.d.ts +1 -0
  136. package/build/src/errors.test.js +122 -0
  137. package/build/src/errors.test.js.map +1 -0
  138. package/build/src/exports.d.ts +7 -0
  139. package/build/src/exports.js +8 -0
  140. package/build/src/exports.js.map +1 -0
  141. package/build/src/implementations/http/doc-registry.d.ts +12 -0
  142. package/build/src/implementations/http/doc-registry.js +114 -0
  143. package/build/src/implementations/http/doc-registry.js.map +1 -0
  144. package/build/src/implementations/http/doc-registry.test.d.ts +1 -0
  145. package/build/src/implementations/http/doc-registry.test.js +347 -0
  146. package/build/src/implementations/http/doc-registry.test.js.map +1 -0
  147. package/build/src/implementations/http/express-rpc/index.d.ts +94 -0
  148. package/build/src/implementations/http/express-rpc/index.js +185 -0
  149. package/build/src/implementations/http/express-rpc/index.js.map +1 -0
  150. package/build/src/implementations/http/express-rpc/index.test.d.ts +1 -0
  151. package/build/src/implementations/http/express-rpc/index.test.js +684 -0
  152. package/build/src/implementations/http/express-rpc/index.test.js.map +1 -0
  153. package/build/src/implementations/http/express-rpc/types.d.ts +11 -0
  154. package/build/src/implementations/http/express-rpc/types.js +2 -0
  155. package/build/src/implementations/http/express-rpc/types.js.map +1 -0
  156. package/build/src/implementations/http/hono-api/index.d.ts +102 -0
  157. package/build/src/implementations/http/hono-api/index.js +341 -0
  158. package/build/src/implementations/http/hono-api/index.js.map +1 -0
  159. package/build/src/implementations/http/hono-api/index.test.d.ts +1 -0
  160. package/build/src/implementations/http/hono-api/index.test.js +992 -0
  161. package/build/src/implementations/http/hono-api/index.test.js.map +1 -0
  162. package/build/src/implementations/http/hono-api/types.d.ts +13 -0
  163. package/build/src/implementations/http/hono-api/types.js +2 -0
  164. package/build/src/implementations/http/hono-api/types.js.map +1 -0
  165. package/build/src/implementations/http/hono-rpc/index.d.ts +92 -0
  166. package/build/src/implementations/http/hono-rpc/index.js +161 -0
  167. package/build/src/implementations/http/hono-rpc/index.js.map +1 -0
  168. package/build/src/implementations/http/hono-rpc/index.test.d.ts +1 -0
  169. package/build/src/implementations/http/hono-rpc/index.test.js +803 -0
  170. package/build/src/implementations/http/hono-rpc/index.test.js.map +1 -0
  171. package/build/src/implementations/http/hono-rpc/types.d.ts +11 -0
  172. package/build/src/implementations/http/hono-rpc/types.js +2 -0
  173. package/build/src/implementations/http/hono-rpc/types.js.map +1 -0
  174. package/build/src/implementations/http/hono-stream/index.d.ts +120 -0
  175. package/build/src/implementations/http/hono-stream/index.js +309 -0
  176. package/build/src/implementations/http/hono-stream/index.js.map +1 -0
  177. package/build/src/implementations/http/hono-stream/index.test.d.ts +1 -0
  178. package/build/src/implementations/http/hono-stream/index.test.js +1356 -0
  179. package/build/src/implementations/http/hono-stream/index.test.js.map +1 -0
  180. package/build/src/implementations/http/hono-stream/types.d.ts +15 -0
  181. package/build/src/implementations/http/hono-stream/types.js +2 -0
  182. package/build/src/implementations/http/hono-stream/types.js.map +1 -0
  183. package/build/src/implementations/types.d.ts +142 -0
  184. package/build/src/implementations/types.js +2 -0
  185. package/build/src/implementations/types.js.map +1 -0
  186. package/build/src/index.d.ts +165 -0
  187. package/build/src/index.js +253 -0
  188. package/build/src/index.js.map +1 -0
  189. package/build/src/index.test.d.ts +1 -0
  190. package/build/src/index.test.js +890 -0
  191. package/build/src/index.test.js.map +1 -0
  192. package/build/src/schema/compute-schema.d.ts +35 -0
  193. package/build/src/schema/compute-schema.js +41 -0
  194. package/build/src/schema/compute-schema.js.map +1 -0
  195. package/build/src/schema/compute-schema.test.d.ts +1 -0
  196. package/build/src/schema/compute-schema.test.js +107 -0
  197. package/build/src/schema/compute-schema.test.js.map +1 -0
  198. package/build/src/schema/extract-json-schema.d.ts +2 -0
  199. package/build/src/schema/extract-json-schema.js +12 -0
  200. package/build/src/schema/extract-json-schema.js.map +1 -0
  201. package/build/src/schema/extract-json-schema.test.d.ts +1 -0
  202. package/build/src/schema/extract-json-schema.test.js +23 -0
  203. package/build/src/schema/extract-json-schema.test.js.map +1 -0
  204. package/build/src/schema/parser.d.ts +28 -0
  205. package/build/src/schema/parser.js +170 -0
  206. package/build/src/schema/parser.js.map +1 -0
  207. package/build/src/schema/parser.test.d.ts +1 -0
  208. package/build/src/schema/parser.test.js +120 -0
  209. package/build/src/schema/parser.test.js.map +1 -0
  210. package/build/src/schema/resolve-schema-lib.d.ts +12 -0
  211. package/build/src/schema/resolve-schema-lib.js +11 -0
  212. package/build/src/schema/resolve-schema-lib.js.map +1 -0
  213. package/build/src/schema/resolve-schema-lib.test.d.ts +1 -0
  214. package/build/src/schema/resolve-schema-lib.test.js +17 -0
  215. package/build/src/schema/resolve-schema-lib.test.js.map +1 -0
  216. package/build/src/schema/types.d.ts +8 -0
  217. package/build/src/schema/types.js +2 -0
  218. package/build/src/schema/types.js.map +1 -0
  219. package/build/src/stack-utils.d.ts +25 -0
  220. package/build/src/stack-utils.js +95 -0
  221. package/build/src/stack-utils.js.map +1 -0
  222. package/build/src/stack-utils.test.d.ts +1 -0
  223. package/build/src/stack-utils.test.js +80 -0
  224. package/build/src/stack-utils.test.js.map +1 -0
  225. package/docs/ai-agent-setup.md +7 -6
  226. package/docs/core.md +5 -9
  227. package/docs/streaming.md +9 -9
  228. package/package.json +2 -13
  229. package/src/client/call.test.ts +162 -0
  230. package/src/client/errors.test.ts +43 -0
  231. package/src/client/fetch-adapter.test.ts +340 -0
  232. package/src/client/hooks.test.ts +191 -0
  233. package/src/client/index.test.ts +290 -0
  234. package/src/client/request-builder.test.ts +184 -0
  235. package/src/client/stream.test.ts +331 -0
  236. package/src/codegen/bin/cli.test.ts +260 -0
  237. package/src/codegen/bin/cli.ts +282 -0
  238. package/src/codegen/constants.ts +1 -0
  239. package/src/codegen/e2e.test.ts +565 -0
  240. package/src/codegen/emit-client-runtime.test.ts +93 -0
  241. package/src/codegen/emit-client-runtime.ts +114 -0
  242. package/src/codegen/emit-client-types.test.ts +39 -0
  243. package/src/codegen/emit-client-types.ts +27 -0
  244. package/src/codegen/emit-errors.test.ts +202 -0
  245. package/src/codegen/emit-errors.ts +80 -0
  246. package/src/codegen/emit-index.test.ts +127 -0
  247. package/src/codegen/emit-index.ts +58 -0
  248. package/src/codegen/emit-scope.test.ts +624 -0
  249. package/src/codegen/emit-scope.ts +389 -0
  250. package/src/codegen/emit-types.test.ts +205 -0
  251. package/src/codegen/emit-types.ts +158 -0
  252. package/src/codegen/group-routes.test.ts +159 -0
  253. package/src/codegen/group-routes.ts +61 -0
  254. package/src/codegen/index.ts +30 -0
  255. package/src/codegen/naming.test.ts +50 -0
  256. package/src/codegen/naming.ts +25 -0
  257. package/src/codegen/pipeline.test.ts +316 -0
  258. package/src/codegen/pipeline.ts +108 -0
  259. package/src/codegen/resolve-envelope.test.ts +76 -0
  260. package/src/codegen/resolve-envelope.ts +61 -0
  261. package/src/errors.test.ts +163 -0
  262. package/src/errors.ts +107 -0
  263. package/src/exports.ts +7 -0
  264. package/src/implementations/http/doc-registry.test.ts +415 -0
  265. package/src/implementations/http/doc-registry.ts +143 -0
  266. package/src/implementations/http/express-rpc/README.md +6 -6
  267. package/src/implementations/http/express-rpc/index.test.ts +957 -0
  268. package/src/implementations/http/express-rpc/index.ts +266 -0
  269. package/src/implementations/http/express-rpc/types.ts +16 -0
  270. package/src/implementations/http/hono-api/index.test.ts +1341 -0
  271. package/src/implementations/http/hono-api/index.ts +463 -0
  272. package/src/implementations/http/hono-api/types.ts +16 -0
  273. package/src/implementations/http/hono-rpc/README.md +6 -6
  274. package/src/implementations/http/hono-rpc/index.test.ts +1075 -0
  275. package/src/implementations/http/hono-rpc/index.ts +238 -0
  276. package/src/implementations/http/hono-rpc/types.ts +16 -0
  277. package/src/implementations/http/hono-stream/README.md +12 -12
  278. package/src/implementations/http/hono-stream/index.test.ts +1768 -0
  279. package/src/implementations/http/hono-stream/index.ts +456 -0
  280. package/src/implementations/http/hono-stream/types.ts +20 -0
  281. package/src/implementations/types.ts +174 -0
  282. package/src/index.test.ts +1185 -0
  283. package/src/index.ts +522 -0
  284. package/src/schema/compute-schema.test.ts +128 -0
  285. package/src/schema/compute-schema.ts +88 -0
  286. package/src/schema/extract-json-schema.test.ts +25 -0
  287. package/src/schema/extract-json-schema.ts +15 -0
  288. package/src/schema/parser.test.ts +182 -0
  289. package/src/schema/parser.ts +215 -0
  290. package/src/schema/resolve-schema-lib.test.ts +19 -0
  291. package/src/schema/resolve-schema-lib.ts +29 -0
  292. package/src/schema/types.ts +20 -0
  293. package/src/stack-utils.test.ts +94 -0
  294. package/src/stack-utils.ts +129 -0
  295. package/agent_config/claude-code/skills/review/SKILL.md +0 -53
  296. package/docs/superpowers/plans/2026-03-30-client-codegen.md +0 -2833
  297. package/docs/superpowers/specs/2026-03-30-client-codegen-design.md +0 -632
  298. /package/agent_config/claude-code/skills/{guide → ts-procedures}/patterns.md +0 -0
  299. /package/agent_config/claude-code/skills/{review → ts-procedures-review}/checklist.md +0 -0
  300. /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/express-rpc.md +0 -0
  301. /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/hono-api.md +0 -0
  302. /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/hono-rpc.md +0 -0
  303. /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/hono-stream.md +0 -0
  304. /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/procedure.md +0 -0
  305. /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