ts-procedures 7.2.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/README.md +65 -3
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +6 -8
  3. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +30 -33
  4. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +139 -53
  5. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +208 -231
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +80 -153
  7. package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
  8. package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +4 -5
  9. package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +4 -7
  10. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono.md +223 -0
  11. package/agent_config/copilot/copilot-instructions.md +36 -48
  12. package/agent_config/cursor/cursorrules +36 -48
  13. package/build/client/call.js +4 -1
  14. package/build/client/call.js.map +1 -1
  15. package/build/client/call.test.js +23 -0
  16. package/build/client/call.test.js.map +1 -1
  17. package/build/client/fetch-adapter.js +3 -1
  18. package/build/client/fetch-adapter.js.map +1 -1
  19. package/build/client/fetch-adapter.test.js +11 -1
  20. package/build/client/fetch-adapter.test.js.map +1 -1
  21. package/build/client/index.test.js +7 -7
  22. package/build/client/index.test.js.map +1 -1
  23. package/build/client/request-builder.d.ts +1 -1
  24. package/build/client/request-builder.js +2 -2
  25. package/build/client/request-builder.js.map +1 -1
  26. package/build/client/stream.js +13 -2
  27. package/build/client/stream.js.map +1 -1
  28. package/build/client/stream.test.js +32 -7
  29. package/build/client/stream.test.js.map +1 -1
  30. package/build/client/typed-error-dispatch.test.js +8 -92
  31. package/build/client/typed-error-dispatch.test.js.map +1 -1
  32. package/build/client/types.d.ts +21 -3
  33. package/build/codegen/bin/cli.js +0 -0
  34. package/build/codegen/e2e.test.js +87 -23
  35. package/build/codegen/e2e.test.js.map +1 -1
  36. package/build/codegen/emit-errors.integration.test.js +1 -1
  37. package/build/codegen/emit-errors.integration.test.js.map +1 -1
  38. package/build/codegen/emit-scope.js +308 -47
  39. package/build/codegen/emit-scope.js.map +1 -1
  40. package/build/codegen/emit-scope.test.js +363 -110
  41. package/build/codegen/emit-scope.test.js.map +1 -1
  42. package/build/codegen/pipeline.test.js +7 -7
  43. package/build/codegen/pipeline.test.js.map +1 -1
  44. package/build/codegen/resolve-envelope.js +1 -1
  45. package/build/codegen/resolve-envelope.js.map +1 -1
  46. package/build/codegen/resolve-envelope.test.js +5 -5
  47. package/build/codegen/resolve-envelope.test.js.map +1 -1
  48. package/build/codegen/targets/_shared/route-slots.d.ts +8 -3
  49. package/build/codegen/targets/_shared/route-slots.js +49 -8
  50. package/build/codegen/targets/_shared/route-slots.js.map +1 -1
  51. package/build/codegen/targets/_shared/route-slots.test.js +99 -26
  52. package/build/codegen/targets/_shared/route-slots.test.js.map +1 -1
  53. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +88 -17
  54. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -1
  55. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +9 -6
  56. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -1
  57. package/build/codegen/targets/kotlin/integration.test.js +6 -0
  58. package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
  59. package/build/codegen/targets/swift/access-level.test.js +8 -11
  60. package/build/codegen/targets/swift/access-level.test.js.map +1 -1
  61. package/build/codegen/targets/swift/emit-route-swift.test.js +91 -20
  62. package/build/codegen/targets/swift/emit-route-swift.test.js.map +1 -1
  63. package/build/codegen/targets/swift/emit-scope-swift.test.js +12 -9
  64. package/build/codegen/targets/swift/emit-scope-swift.test.js.map +1 -1
  65. package/build/codegen/targets/swift/integration.test.js +6 -0
  66. package/build/codegen/targets/swift/integration.test.js.map +1 -1
  67. package/build/create-http-stream.d.ts +58 -0
  68. package/build/create-http-stream.js +122 -0
  69. package/build/create-http-stream.js.map +1 -0
  70. package/build/create-http-stream.test.js +88 -0
  71. package/build/create-http-stream.test.js.map +1 -0
  72. package/build/create-http.d.ts +49 -0
  73. package/build/create-http.js +108 -0
  74. package/build/create-http.js.map +1 -0
  75. package/build/create-http.test.js +137 -0
  76. package/build/create-http.test.js.map +1 -0
  77. package/build/create-stream.d.ts +35 -0
  78. package/build/create-stream.js +123 -0
  79. package/build/create-stream.js.map +1 -0
  80. package/build/create-stream.test.js +428 -0
  81. package/build/create-stream.test.js.map +1 -0
  82. package/build/create.d.ts +28 -0
  83. package/build/create.js +82 -0
  84. package/build/create.js.map +1 -0
  85. package/build/create.test.js +483 -0
  86. package/build/create.test.js.map +1 -0
  87. package/build/exports.d.ts +2 -0
  88. package/build/implementations/http/astro/index.test.js +20 -12
  89. package/build/implementations/http/astro/index.test.js.map +1 -1
  90. package/build/implementations/http/doc-registry.js +1 -1
  91. package/build/implementations/http/doc-registry.js.map +1 -1
  92. package/build/implementations/http/doc-registry.test.js +36 -5
  93. package/build/implementations/http/doc-registry.test.js.map +1 -1
  94. package/build/implementations/http/error-dispatch.d.ts +76 -0
  95. package/build/implementations/http/error-dispatch.js +77 -0
  96. package/build/implementations/http/error-dispatch.js.map +1 -0
  97. package/build/implementations/http/error-dispatch.test.js +254 -0
  98. package/build/implementations/http/error-dispatch.test.js.map +1 -0
  99. package/build/implementations/http/error-taxonomy.d.ts +5 -5
  100. package/build/implementations/http/hono/docs/http-doc.d.ts +6 -0
  101. package/build/implementations/http/hono/docs/http-doc.js +42 -0
  102. package/build/implementations/http/hono/docs/http-doc.js.map +1 -0
  103. package/build/implementations/http/hono/docs/http-stream-doc.d.ts +6 -0
  104. package/build/implementations/http/hono/docs/http-stream-doc.js +40 -0
  105. package/build/implementations/http/hono/docs/http-stream-doc.js.map +1 -0
  106. package/build/implementations/http/hono/docs/rpc-doc.d.ts +6 -0
  107. package/build/implementations/http/hono/docs/rpc-doc.js +24 -0
  108. package/build/implementations/http/hono/docs/rpc-doc.js.map +1 -0
  109. package/build/implementations/http/hono/docs/stream-doc.d.ts +6 -0
  110. package/build/implementations/http/hono/docs/stream-doc.js +42 -0
  111. package/build/implementations/http/hono/docs/stream-doc.js.map +1 -0
  112. package/build/implementations/http/hono/handlers/http-stream.d.ts +10 -0
  113. package/build/implementations/http/hono/handlers/http-stream.js +123 -0
  114. package/build/implementations/http/hono/handlers/http-stream.js.map +1 -0
  115. package/build/implementations/http/hono/handlers/http-stream.test.js +128 -0
  116. package/build/implementations/http/hono/handlers/http-stream.test.js.map +1 -0
  117. package/build/implementations/http/hono/handlers/http.d.ts +10 -0
  118. package/build/implementations/http/hono/handlers/http.js +115 -0
  119. package/build/implementations/http/hono/handlers/http.js.map +1 -0
  120. package/build/implementations/http/hono/handlers/http.test.js +118 -0
  121. package/build/implementations/http/hono/handlers/http.test.js.map +1 -0
  122. package/build/implementations/http/hono/handlers/rpc.d.ts +11 -0
  123. package/build/implementations/http/hono/handlers/rpc.js +32 -0
  124. package/build/implementations/http/hono/handlers/rpc.js.map +1 -0
  125. package/build/implementations/http/hono/handlers/rpc.test.js +73 -0
  126. package/build/implementations/http/hono/handlers/rpc.test.js.map +1 -0
  127. package/build/implementations/http/hono/handlers/stream.d.ts +23 -0
  128. package/build/implementations/http/hono/handlers/stream.js +147 -0
  129. package/build/implementations/http/hono/handlers/stream.js.map +1 -0
  130. package/build/implementations/http/hono/handlers/stream.test.d.ts +1 -0
  131. package/build/implementations/http/hono/handlers/stream.test.js +177 -0
  132. package/build/implementations/http/hono/handlers/stream.test.js.map +1 -0
  133. package/build/implementations/http/hono/index.d.ts +57 -0
  134. package/build/implementations/http/hono/index.js +149 -0
  135. package/build/implementations/http/hono/index.js.map +1 -0
  136. package/build/implementations/http/hono/index.test.d.ts +1 -0
  137. package/build/implementations/http/hono/index.test.js +274 -0
  138. package/build/implementations/http/hono/index.test.js.map +1 -0
  139. package/build/implementations/http/hono/path.d.ts +17 -0
  140. package/build/implementations/http/hono/path.js +39 -0
  141. package/build/implementations/http/hono/path.js.map +1 -0
  142. package/build/implementations/http/hono/path.test.d.ts +1 -0
  143. package/build/implementations/http/hono/path.test.js +83 -0
  144. package/build/implementations/http/hono/path.test.js.map +1 -0
  145. package/build/implementations/http/hono/types.d.ts +51 -0
  146. package/build/implementations/http/hono/types.js.map +1 -0
  147. package/build/implementations/http/on-request-error.test.js +6 -96
  148. package/build/implementations/http/on-request-error.test.js.map +1 -1
  149. package/build/implementations/http/route-errors.test.js +11 -59
  150. package/build/implementations/http/route-errors.test.js.map +1 -1
  151. package/build/implementations/types.d.ts +43 -9
  152. package/build/index.d.ts +125 -115
  153. package/build/index.js +10 -222
  154. package/build/index.js.map +1 -1
  155. package/build/index.test.js +30 -822
  156. package/build/index.test.js.map +1 -1
  157. package/build/migration.test.d.ts +1 -0
  158. package/build/migration.test.js +34 -0
  159. package/build/migration.test.js.map +1 -0
  160. package/build/schema/compute-schema.d.ts +11 -3
  161. package/build/schema/compute-schema.js +13 -7
  162. package/build/schema/compute-schema.js.map +1 -1
  163. package/build/schema/parser.d.ts +11 -3
  164. package/build/schema/parser.js +49 -9
  165. package/build/schema/parser.js.map +1 -1
  166. package/build/stack-utils.js +8 -0
  167. package/build/stack-utils.js.map +1 -1
  168. package/build/types.d.ts +142 -0
  169. package/build/types.js.map +1 -0
  170. package/docs/astro-adapter.md +5 -5
  171. package/docs/core.md +34 -17
  172. package/docs/http-integrations.md +83 -170
  173. package/docs/streaming.md +3 -60
  174. package/docs/superpowers/plans/2026-05-07-astro-adapter.md +2 -7
  175. package/docs/superpowers/plans/2026-05-08-create-http.md +3355 -0
  176. package/docs/superpowers/plans/2026-05-08-hono-app-builder-convergence.md +3365 -0
  177. package/docs/superpowers/specs/2026-05-07-astro-adapter-design.md +1 -3
  178. package/docs/superpowers/specs/2026-05-08-create-http-design.md +409 -0
  179. package/docs/superpowers/specs/2026-05-08-hono-app-builder-convergence-design.md +411 -0
  180. package/package.json +4 -22
  181. package/src/client/call.test.ts +26 -0
  182. package/src/client/call.ts +4 -1
  183. package/src/client/fetch-adapter.test.ts +14 -1
  184. package/src/client/fetch-adapter.ts +3 -1
  185. package/src/client/index.test.ts +7 -7
  186. package/src/client/request-builder.ts +2 -2
  187. package/src/client/stream.test.ts +39 -7
  188. package/src/client/stream.ts +16 -2
  189. package/src/client/typed-error-dispatch.test.ts +7 -97
  190. package/src/client/types.ts +21 -3
  191. package/src/codegen/__fixtures__/users-envelope.json +119 -38
  192. package/src/codegen/e2e.test.ts +98 -24
  193. package/src/codegen/emit-errors.integration.test.ts +1 -1
  194. package/src/codegen/emit-scope.test.ts +395 -110
  195. package/src/codegen/emit-scope.ts +350 -55
  196. package/src/codegen/pipeline.test.ts +7 -7
  197. package/src/codegen/resolve-envelope.test.ts +5 -5
  198. package/src/codegen/resolve-envelope.ts +1 -1
  199. package/src/codegen/targets/_shared/route-slots.test.ts +109 -26
  200. package/src/codegen/targets/_shared/route-slots.ts +48 -11
  201. package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +73 -0
  202. package/src/codegen/targets/kotlin/emit-route-kotlin.test.ts +100 -17
  203. package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +9 -6
  204. package/src/codegen/targets/kotlin/integration.test.ts +19 -0
  205. package/src/codegen/targets/swift/__fixtures__/users-golden.swift +79 -0
  206. package/src/codegen/targets/swift/access-level.test.ts +8 -11
  207. package/src/codegen/targets/swift/emit-route-swift.test.ts +103 -20
  208. package/src/codegen/targets/swift/emit-scope-swift.test.ts +12 -9
  209. package/src/codegen/targets/swift/integration.test.ts +17 -0
  210. package/src/create-http-stream.test.ts +97 -0
  211. package/src/create-http-stream.ts +191 -0
  212. package/src/create-http.test.ts +163 -0
  213. package/src/create-http.ts +211 -0
  214. package/src/create-stream.test.ts +565 -0
  215. package/src/create-stream.ts +228 -0
  216. package/src/create.test.ts +658 -0
  217. package/src/create.ts +172 -0
  218. package/src/exports.ts +2 -0
  219. package/src/implementations/http/README.md +135 -95
  220. package/src/implementations/http/astro/README.md +4 -5
  221. package/src/implementations/http/astro/index.test.ts +25 -18
  222. package/src/implementations/http/doc-registry.test.ts +42 -5
  223. package/src/implementations/http/doc-registry.ts +1 -1
  224. package/src/implementations/http/error-dispatch.test.ts +283 -0
  225. package/src/implementations/http/error-dispatch.ts +176 -0
  226. package/src/implementations/http/error-taxonomy.ts +5 -5
  227. package/src/implementations/http/hono/docs/http-doc.ts +43 -0
  228. package/src/implementations/http/hono/docs/http-stream-doc.ts +44 -0
  229. package/src/implementations/http/hono/docs/rpc-doc.ts +34 -0
  230. package/src/implementations/http/hono/docs/stream-doc.ts +53 -0
  231. package/src/implementations/http/hono/handlers/http-stream.test.ts +150 -0
  232. package/src/implementations/http/hono/handlers/http-stream.ts +152 -0
  233. package/src/implementations/http/hono/handlers/http.test.ts +130 -0
  234. package/src/implementations/http/hono/handlers/http.ts +147 -0
  235. package/src/implementations/http/hono/handlers/rpc.test.ts +81 -0
  236. package/src/implementations/http/hono/handlers/rpc.ts +54 -0
  237. package/src/implementations/http/hono/handlers/stream.test.ts +198 -0
  238. package/src/implementations/http/hono/handlers/stream.ts +208 -0
  239. package/src/implementations/http/hono/index.test.ts +329 -0
  240. package/src/implementations/http/hono/index.ts +204 -0
  241. package/src/implementations/http/hono/path.test.ts +96 -0
  242. package/src/implementations/http/hono/path.ts +59 -0
  243. package/src/implementations/http/hono/types.ts +93 -0
  244. package/src/implementations/http/on-request-error.test.ts +10 -116
  245. package/src/implementations/http/route-errors.test.ts +11 -77
  246. package/src/implementations/types.ts +44 -9
  247. package/src/index.test.ts +35 -1091
  248. package/src/index.ts +50 -474
  249. package/src/migration.test.ts +48 -0
  250. package/src/schema/compute-schema.ts +26 -12
  251. package/src/schema/parser.ts +62 -12
  252. package/src/stack-utils.ts +8 -0
  253. package/src/types.ts +133 -0
  254. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +0 -137
  255. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +0 -173
  256. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +0 -142
  257. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +0 -147
  258. package/build/implementations/http/express-rpc/error-taxonomy.test.js +0 -83
  259. package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +0 -1
  260. package/build/implementations/http/express-rpc/index.d.ts +0 -125
  261. package/build/implementations/http/express-rpc/index.js +0 -216
  262. package/build/implementations/http/express-rpc/index.js.map +0 -1
  263. package/build/implementations/http/express-rpc/index.test.js +0 -684
  264. package/build/implementations/http/express-rpc/index.test.js.map +0 -1
  265. package/build/implementations/http/express-rpc/types.d.ts +0 -11
  266. package/build/implementations/http/express-rpc/types.js.map +0 -1
  267. package/build/implementations/http/hono-api/error-taxonomy.test.js +0 -137
  268. package/build/implementations/http/hono-api/error-taxonomy.test.js.map +0 -1
  269. package/build/implementations/http/hono-api/index.d.ts +0 -151
  270. package/build/implementations/http/hono-api/index.js +0 -344
  271. package/build/implementations/http/hono-api/index.js.map +0 -1
  272. package/build/implementations/http/hono-api/index.test.js +0 -992
  273. package/build/implementations/http/hono-api/index.test.js.map +0 -1
  274. package/build/implementations/http/hono-api/types.d.ts +0 -13
  275. package/build/implementations/http/hono-api/types.js.map +0 -1
  276. package/build/implementations/http/hono-rpc/error-taxonomy.test.js +0 -64
  277. package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +0 -1
  278. package/build/implementations/http/hono-rpc/index.d.ts +0 -130
  279. package/build/implementations/http/hono-rpc/index.js +0 -209
  280. package/build/implementations/http/hono-rpc/index.js.map +0 -1
  281. package/build/implementations/http/hono-rpc/index.test.js +0 -828
  282. package/build/implementations/http/hono-rpc/index.test.js.map +0 -1
  283. package/build/implementations/http/hono-rpc/types.d.ts +0 -11
  284. package/build/implementations/http/hono-rpc/types.js +0 -2
  285. package/build/implementations/http/hono-rpc/types.js.map +0 -1
  286. package/build/implementations/http/hono-stream/error-taxonomy.test.js +0 -159
  287. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +0 -1
  288. package/build/implementations/http/hono-stream/index.d.ts +0 -171
  289. package/build/implementations/http/hono-stream/index.js +0 -415
  290. package/build/implementations/http/hono-stream/index.js.map +0 -1
  291. package/build/implementations/http/hono-stream/index.test.js +0 -1383
  292. package/build/implementations/http/hono-stream/index.test.js.map +0 -1
  293. package/build/implementations/http/hono-stream/types.d.ts +0 -15
  294. package/build/implementations/http/hono-stream/types.js +0 -2
  295. package/build/implementations/http/hono-stream/types.js.map +0 -1
  296. package/src/implementations/http/express-rpc/README.md +0 -280
  297. package/src/implementations/http/express-rpc/error-taxonomy.test.ts +0 -103
  298. package/src/implementations/http/express-rpc/index.test.ts +0 -957
  299. package/src/implementations/http/express-rpc/index.ts +0 -327
  300. package/src/implementations/http/express-rpc/types.ts +0 -16
  301. package/src/implementations/http/hono-api/README.md +0 -284
  302. package/src/implementations/http/hono-api/error-taxonomy.test.ts +0 -179
  303. package/src/implementations/http/hono-api/index.test.ts +0 -1341
  304. package/src/implementations/http/hono-api/index.ts +0 -519
  305. package/src/implementations/http/hono-api/types.ts +0 -16
  306. package/src/implementations/http/hono-rpc/README.md +0 -357
  307. package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +0 -82
  308. package/src/implementations/http/hono-rpc/index.test.ts +0 -1107
  309. package/src/implementations/http/hono-rpc/index.ts +0 -320
  310. package/src/implementations/http/hono-rpc/types.ts +0 -16
  311. package/src/implementations/http/hono-stream/README.md +0 -559
  312. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +0 -178
  313. package/src/implementations/http/hono-stream/index.test.ts +0 -1804
  314. package/src/implementations/http/hono-stream/index.ts +0 -622
  315. package/src/implementations/http/hono-stream/types.ts +0 -20
  316. /package/build/{implementations/http/express-rpc/error-taxonomy.test.d.ts → create-http-stream.test.d.ts} +0 -0
  317. /package/build/{implementations/http/express-rpc/index.test.d.ts → create-http.test.d.ts} +0 -0
  318. /package/build/{implementations/http/hono-api/error-taxonomy.test.d.ts → create-stream.test.d.ts} +0 -0
  319. /package/build/{implementations/http/hono-api/index.test.d.ts → create.test.d.ts} +0 -0
  320. /package/build/implementations/http/{hono-rpc/error-taxonomy.test.d.ts → error-dispatch.test.d.ts} +0 -0
  321. /package/build/implementations/http/{hono-rpc/index.test.d.ts → hono/handlers/http-stream.test.d.ts} +0 -0
  322. /package/build/implementations/http/{hono-stream/error-taxonomy.test.d.ts → hono/handlers/http.test.d.ts} +0 -0
  323. /package/build/implementations/http/{hono-stream/index.test.d.ts → hono/handlers/rpc.test.d.ts} +0 -0
  324. /package/build/implementations/http/{express-rpc → hono}/types.js +0 -0
  325. /package/build/{implementations/http/hono-api/types.js → types.js} +0 -0
@@ -0,0 +1,329 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+ import { Hono } from 'hono'
3
+ import { Type } from 'typebox'
4
+ import { Procedures } from '../../../index.js'
5
+ import type { RPCConfig } from '../../types.js'
6
+ import { HonoAppBuilder } from './index.js'
7
+ import { defineErrorTaxonomy } from '../error-taxonomy.js'
8
+ import { DocRegistry } from '../doc-registry.js'
9
+
10
+ describe('HonoAppBuilder — public surface', () => {
11
+ test('constructor with no args', () => {
12
+ const b = new HonoAppBuilder()
13
+ expect(b.app).toBeInstanceOf(Hono)
14
+ expect(b.docs).toEqual([])
15
+ expect(b.skippedProcedures).toEqual([])
16
+ })
17
+
18
+ test('constructor accepts existing Hono app', () => {
19
+ const custom = new Hono()
20
+ const b = new HonoAppBuilder({ app: custom })
21
+ expect(b.app).toBe(custom)
22
+ })
23
+
24
+ test('register is chainable', () => {
25
+ const b = new HonoAppBuilder()
26
+ const P = Procedures<{}, RPCConfig>()
27
+ const result = b.register(P, () => ({}))
28
+ expect(result).toBe(b)
29
+ })
30
+
31
+ test('docs is lazily computed before build()', () => {
32
+ const b = new HonoAppBuilder()
33
+ const P = Procedures<{}, RPCConfig>()
34
+ P.Create('A', { scope: 's', version: 1 }, async () => ({}))
35
+ b.register(P, () => ({}))
36
+
37
+ const docs1 = b.docs
38
+ expect(docs1).toHaveLength(1)
39
+ expect(docs1[0]!.kind).toBe('rpc')
40
+ // Calling docs again returns the same cached array reference
41
+ expect(b.docs).toBe(docs1)
42
+ })
43
+
44
+ test('docs reads after build() return the same content', () => {
45
+ const b = new HonoAppBuilder()
46
+ const P = Procedures<{}, RPCConfig>()
47
+ P.Create('A', { scope: 's', version: 1 }, async () => ({}))
48
+ b.register(P, () => ({}))
49
+ b.build()
50
+ expect(b.docs).toHaveLength(1)
51
+ })
52
+ })
53
+
54
+ describe('HonoAppBuilder — mixed-kind dispatch', () => {
55
+ test('one factory with all four kinds mounts each at the correct path', async () => {
56
+ const P = Procedures<{ uid: string }, RPCConfig>()
57
+ P.Create('Echo', { scope: 'rpc', version: 1 }, async (_ctx, p) => p)
58
+ P.CreateStream('Tail', { scope: 'rpc', version: 1 }, async function* () { yield 1 })
59
+ P.CreateHttp('GetUser', {
60
+ path: '/users/:id',
61
+ method: 'get',
62
+ schema: {
63
+ req: { pathParams: Type.Object({ id: Type.String() }) },
64
+ res: { body: Type.Object({ id: Type.String() }) },
65
+ },
66
+ }, async () => ({ id: '1' }))
67
+ P.CreateHttpStream('Watch', {
68
+ path: '/watch', method: 'get',
69
+ schema: { yield: Type.String() },
70
+ }, (async function* () { yield 'tick' }) as any)
71
+
72
+ const app = new HonoAppBuilder()
73
+ .register(P, () => ({ uid: 'u1' }))
74
+ .build()
75
+
76
+ const rpc = await app.request('/rpc/echo/1', { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } })
77
+ expect(rpc.status).toBe(200)
78
+
79
+ const stream = await app.request('/rpc/tail/1', { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } })
80
+ expect(stream.status).toBe(200)
81
+ await stream.text()
82
+
83
+ const http = await app.request('/users/1')
84
+ expect(http.status).toBe(200)
85
+
86
+ const httpStream = await app.request('/watch')
87
+ expect(httpStream.status).toBe(200)
88
+ await httpStream.text()
89
+ })
90
+
91
+ test('toDocEnvelope produces same shape as DocRegistry.toJSON', () => {
92
+ const P = Procedures<{}, RPCConfig>()
93
+ P.Create('Echo', { scope: 'e', version: 1 }, async () => ({}))
94
+
95
+ const errors = defineErrorTaxonomy({})
96
+ const builder = new HonoAppBuilder({ pathPrefix: '/api', errors })
97
+ builder.register(P, () => ({}))
98
+ builder.build()
99
+
100
+ const builderEnvelope = builder.toDocEnvelope({ basePath: '/api', errors })
101
+ const registryEnvelope = new DocRegistry({ basePath: '/api', errors }).from(builder).toJSON()
102
+
103
+ expect(builderEnvelope.basePath).toBe(registryEnvelope.basePath)
104
+ expect(builderEnvelope.routes).toEqual(registryEnvelope.routes)
105
+ expect(builderEnvelope.errors.map(e => e.name).sort()).toEqual(registryEnvelope.errors.map(e => e.name).sort())
106
+ })
107
+
108
+ test('toDocEnvelope filter and transform options work', () => {
109
+ const P = Procedures<{}, RPCConfig>()
110
+ P.Create('A', { scope: 's', version: 1 }, async () => ({}))
111
+ P.Create('B', { scope: 's', version: 1 }, async () => ({}))
112
+
113
+ const builder = new HonoAppBuilder().register(P, () => ({}))
114
+ const onlyA = builder.toDocEnvelope({ filter: r => r.name === 'A' })
115
+ expect(onlyA.routes).toHaveLength(1)
116
+ expect(onlyA.routes[0]!.name).toBe('A')
117
+
118
+ const tagged = builder.toDocEnvelope({
119
+ transform: env => ({ ...env, tagged: true }) as any,
120
+ }) as any
121
+ expect(tagged.tagged).toBe(true)
122
+ })
123
+
124
+ test('makeRoutePath static is exposed', () => {
125
+ const P = Procedures<{}, RPCConfig>()
126
+ P.Create('GetUser', { scope: 'users', version: 1 }, async () => ({}))
127
+ const proc = P.getProcedure('GetUser')!
128
+ expect(HonoAppBuilder.makeRoutePath({ procedure: proc as any })).toBe('/users/get-user/1')
129
+ })
130
+ })
131
+
132
+ describe('HonoAppBuilder — factoryContext variants', () => {
133
+ test('factoryContext as a static object is forwarded into ctx', async () => {
134
+ const P = Procedures<{ requestId: string }, RPCConfig>()
135
+ P.Create('GetRequestId', { scope: 'get-request-id', version: 1 }, async (ctx) => ({
136
+ id: ctx.requestId,
137
+ }))
138
+
139
+ const app = new HonoAppBuilder()
140
+ .register(P, { requestId: 'req-123' })
141
+ .build()
142
+
143
+ const res = await app.request('/get-request-id/get-request-id/1', {
144
+ method: 'POST',
145
+ headers: { 'Content-Type': 'application/json' },
146
+ body: JSON.stringify({}),
147
+ })
148
+ expect(await res.json()).toEqual({ id: 'req-123' })
149
+ })
150
+
151
+ test('factoryContext as an async function is awaited and result reaches handler', async () => {
152
+ const factoryContext = vi.fn(async () => ({ requestId: 'req-456' }))
153
+ const P = Procedures<{ requestId: string }, RPCConfig>()
154
+ P.Create('GetRequestId', { scope: 'get-request-id', version: 1 }, async (ctx) => ({
155
+ id: ctx.requestId,
156
+ }))
157
+
158
+ const app = new HonoAppBuilder().register(P, factoryContext).build()
159
+
160
+ const res = await app.request('/get-request-id/get-request-id/1', {
161
+ method: 'POST',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({}),
164
+ })
165
+ expect(factoryContext).toHaveBeenCalledTimes(1)
166
+ expect(await res.json()).toEqual({ id: 'req-456' })
167
+ })
168
+
169
+ test('factoryContext rejection flows through dispatchPreStreamError → 500', async () => {
170
+ const P = Procedures<{ requestId: string }, RPCConfig>()
171
+ P.Create('GetRequestId', { scope: 'get-request-id', version: 1 }, async (ctx) => ({
172
+ id: ctx.requestId,
173
+ }))
174
+
175
+ const app = new HonoAppBuilder()
176
+ .register(P, async () => {
177
+ throw new Error('context-load-failed')
178
+ })
179
+ .build()
180
+
181
+ const res = await app.request('/get-request-id/get-request-id/1', {
182
+ method: 'POST',
183
+ headers: { 'Content-Type': 'application/json' },
184
+ body: JSON.stringify({}),
185
+ })
186
+ expect(res.status).toBe(500)
187
+ const body = await res.json()
188
+ expect(body.error).toContain('context-load-failed')
189
+ })
190
+
191
+ test('factoryContext function receives Hono Context with req.header access', async () => {
192
+ const factoryContext = vi.fn((c: any) => ({
193
+ authHeader: c.req.header('authorization'),
194
+ }))
195
+ const P = Procedures<{ authHeader?: string }, RPCConfig>()
196
+ P.Create('GetAuth', { scope: 'get-auth', version: 1 }, async (ctx) => ({
197
+ auth: ctx.authHeader,
198
+ }))
199
+
200
+ const app = new HonoAppBuilder().register(P, factoryContext).build()
201
+
202
+ const res = await app.request('/get-auth/get-auth/1', {
203
+ method: 'POST',
204
+ headers: {
205
+ 'Content-Type': 'application/json',
206
+ Authorization: 'Bearer token123',
207
+ },
208
+ body: JSON.stringify({}),
209
+ })
210
+ expect(factoryContext).toHaveBeenCalledTimes(1)
211
+ expect(factoryContext.mock.calls[0]![0]).toHaveProperty('req')
212
+ expect(await res.json()).toEqual({ auth: 'Bearer token123' })
213
+ })
214
+ })
215
+
216
+ describe('HonoAppBuilder — extendProcedureDoc discriminated union per kind', () => {
217
+ test('rpc: base.kind === "rpc" and returned object is merged into doc', () => {
218
+ const extend = vi.fn(({ base }: { base: { kind: string } }) => ({
219
+ summary: `RPC: ${base.kind}`,
220
+ tags: ['rpc'],
221
+ }))
222
+ const P = Procedures<{}, RPCConfig>()
223
+ P.Create('GetUser', { scope: 'users', version: 1 }, async () => ({}))
224
+
225
+ const builder = new HonoAppBuilder().register(P, () => ({}), { extendProcedureDoc: extend })
226
+ builder.build()
227
+
228
+ expect(extend).toHaveBeenCalledTimes(1)
229
+ expect(extend.mock.calls[0]![0]!.base.kind).toBe('rpc')
230
+ const doc = builder.docs[0] as any
231
+ expect(doc.summary).toBe('RPC: rpc')
232
+ expect(doc.tags).toEqual(['rpc'])
233
+ })
234
+
235
+ test('api (http): base.kind === "api" and returned object is merged into doc', () => {
236
+ const extend = vi.fn(({ base }: { base: { kind: string } }) => ({
237
+ summary: `API: ${base.kind}`,
238
+ tags: ['api'],
239
+ }))
240
+ const P = Procedures<{}>()
241
+ P.CreateHttp(
242
+ 'GetUser',
243
+ {
244
+ path: '/users/:id',
245
+ method: 'get',
246
+ schema: {
247
+ req: { pathParams: Type.Object({ id: Type.String() }) },
248
+ res: { body: Type.Object({ id: Type.String() }) },
249
+ },
250
+ },
251
+ async () => ({ id: '1' }),
252
+ )
253
+
254
+ const builder = new HonoAppBuilder().register(P, () => ({}), { extendProcedureDoc: extend })
255
+ builder.build()
256
+
257
+ expect(extend).toHaveBeenCalledTimes(1)
258
+ expect(extend.mock.calls[0]![0]!.base.kind).toBe('api')
259
+ const doc = builder.docs[0] as any
260
+ expect(doc.summary).toBe('API: api')
261
+ expect(doc.tags).toEqual(['api'])
262
+ })
263
+
264
+ test('rpc-stream: base.kind === "stream" and returned object is merged into doc', () => {
265
+ const extend = vi.fn(({ base }: { base: { kind: string } }) => ({
266
+ summary: `Stream: ${base.kind}`,
267
+ tags: ['stream'],
268
+ }))
269
+ const P = Procedures<{}, RPCConfig>()
270
+ P.CreateStream('Tail', { scope: 'rpc', version: 1 }, async function* () {
271
+ yield 1
272
+ })
273
+
274
+ const builder = new HonoAppBuilder().register(P, () => ({}), { extendProcedureDoc: extend })
275
+ builder.build()
276
+
277
+ expect(extend).toHaveBeenCalledTimes(1)
278
+ expect(extend.mock.calls[0]![0]!.base.kind).toBe('stream')
279
+ const doc = builder.docs[0] as any
280
+ expect(doc.summary).toBe('Stream: stream')
281
+ expect(doc.tags).toEqual(['stream'])
282
+ })
283
+
284
+ test('http-stream: base.kind === "http-stream" and returned object is merged into doc', () => {
285
+ const extend = vi.fn(({ base }: { base: { kind: string } }) => ({
286
+ summary: `HTTP-Stream: ${base.kind}`,
287
+ tags: ['http-stream'],
288
+ }))
289
+ const P = Procedures<{}>()
290
+ P.CreateHttpStream(
291
+ 'Watch',
292
+ { path: '/watch', method: 'get', schema: { yield: Type.String() } },
293
+ (async function* () {
294
+ yield 'tick'
295
+ }) as any,
296
+ )
297
+
298
+ const builder = new HonoAppBuilder().register(P, () => ({}), { extendProcedureDoc: extend })
299
+ builder.build()
300
+
301
+ expect(extend).toHaveBeenCalledTimes(1)
302
+ expect(extend.mock.calls[0]![0]!.base.kind).toBe('http-stream')
303
+ const doc = builder.docs[0] as any
304
+ expect(doc.summary).toBe('HTTP-Stream: http-stream')
305
+ expect(doc.tags).toEqual(['http-stream'])
306
+ })
307
+ })
308
+
309
+ describe('HonoAppBuilder — per-factory streamMode override', () => {
310
+ test('register options.streamMode overrides stream.defaultStreamMode', async () => {
311
+ const P = Procedures<{}, RPCConfig>()
312
+ P.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
313
+ yield { ok: true }
314
+ })
315
+
316
+ const app = new HonoAppBuilder({ stream: { defaultStreamMode: 'sse' } })
317
+ .register(P, () => ({}), { streamMode: 'text' })
318
+ .build()
319
+
320
+ const res = await app.request('/test/test/1', {
321
+ method: 'POST',
322
+ headers: { 'Content-Type': 'application/json' },
323
+ body: '{}',
324
+ })
325
+ expect(res.headers.get('content-type')).toContain('text/plain')
326
+ // Drain the response so the stream lifecycle completes cleanly.
327
+ await res.text()
328
+ })
329
+ })
@@ -0,0 +1,204 @@
1
+ import { Context, Hono } from 'hono'
2
+ import type {
3
+ AnyHttpRouteDoc,
4
+ ProceduresFactory,
5
+ ExtractContext,
6
+ StreamMode,
7
+ DocEnvelope,
8
+ ErrorDoc,
9
+ HeaderDoc,
10
+ } from '../../types.js'
11
+ import { DocRegistry } from '../doc-registry.js'
12
+ import type { ErrorTaxonomy } from '../error-taxonomy.js'
13
+ import { buildRpcRouteDoc } from './docs/rpc-doc.js'
14
+ import { buildStreamRouteDoc } from './docs/stream-doc.js'
15
+ import { buildHttpRouteDoc } from './docs/http-doc.js'
16
+ import { buildHttpStreamRouteDoc } from './docs/http-stream-doc.js'
17
+ import { installRpcRoute } from './handlers/rpc.js'
18
+ import { installRpcStreamRoute } from './handlers/stream.js'
19
+ import { installHttpRoute } from './handlers/http.js'
20
+ import { installHttpStreamRoute } from './handlers/http-stream.js'
21
+ import { makeRoutePath as _makeRoutePath } from './path.js'
22
+ import type {
23
+ HonoAppBuilderConfig,
24
+ HonoFactoryItem,
25
+ ExtendProcedureDoc,
26
+ AnyProcedureRegistration,
27
+ OnRequestErrorContext,
28
+ } from './types.js'
29
+
30
+ export type { HonoAppBuilderConfig, OnRequestErrorContext, ExtendProcedureDoc } from './types.js'
31
+ export type { AnyProcedureRegistration } from './types.js'
32
+ export { sse } from './handlers/stream.js'
33
+ export type { SSEOptions, MidStreamErrorResult } from './handlers/stream.js'
34
+ export { defineErrorTaxonomy } from '../error-taxonomy.js'
35
+ export type { ErrorTaxonomy, ErrorTaxonomyEntry, UnknownErrorConfig } from '../error-taxonomy.js'
36
+ export type { QueryParser } from './types.js'
37
+
38
+ export class HonoAppBuilder<TStreamErrorData = unknown> {
39
+ private readonly _app: Hono
40
+ private readonly factories: HonoFactoryItem<any>[] = []
41
+ private _docs: AnyHttpRouteDoc[] | null = null
42
+ private _skipped: { name: string; reason: string }[] = []
43
+ private _built = false
44
+
45
+ constructor(readonly config?: HonoAppBuilderConfig<TStreamErrorData>) {
46
+ this._app = config?.app ?? new Hono()
47
+
48
+ if (config?.onRequestStart) {
49
+ this._app.use('*', async (c, next) => {
50
+ config.onRequestStart!(c)
51
+ await next()
52
+ })
53
+ }
54
+ if (config?.onRequestEnd) {
55
+ this._app.use('*', async (c, next) => {
56
+ await next()
57
+ config.onRequestEnd!(c)
58
+ })
59
+ }
60
+ }
61
+
62
+ static makeRoutePath = _makeRoutePath
63
+
64
+ get app(): Hono {
65
+ return this._app
66
+ }
67
+
68
+ /**
69
+ * Lazily computed on first read or `build()`. Computing without `build()`
70
+ * lets tests and tooling introspect routes without spinning up handlers.
71
+ */
72
+ get docs(): AnyHttpRouteDoc[] {
73
+ if (this._docs === null) this.computeDocs()
74
+ return this._docs!
75
+ }
76
+
77
+ get skippedProcedures(): { name: string; reason: string }[] {
78
+ return this._skipped
79
+ }
80
+
81
+ register<TFactory extends ProceduresFactory>(
82
+ factory: TFactory,
83
+ factoryContext:
84
+ | ExtractContext<TFactory>
85
+ | ((c: Context) => ExtractContext<TFactory> | Promise<ExtractContext<TFactory>>),
86
+ options?: {
87
+ streamMode?: StreamMode
88
+ extendProcedureDoc?: ExtendProcedureDoc
89
+ },
90
+ ): this {
91
+ this.factories.push({
92
+ factory,
93
+ factoryContext,
94
+ streamMode: options?.streamMode,
95
+ extendProcedureDoc: options?.extendProcedureDoc,
96
+ } as HonoFactoryItem<any>)
97
+ // Invalidate docs cache so the next read recomputes.
98
+ this._docs = null
99
+ return this
100
+ }
101
+
102
+ private computeDocs(): void {
103
+ const docs: AnyHttpRouteDoc[] = []
104
+ const skipped: { name: string; reason: string }[] = []
105
+
106
+ for (const item of this.factories) {
107
+ for (const procedure of item.factory.getProcedures().values() as Iterable<AnyProcedureRegistration>) {
108
+ const prefix = this.config?.pathPrefix
109
+ switch (procedure.kind) {
110
+ case 'rpc':
111
+ docs.push(buildRpcRouteDoc(procedure as any, prefix, item.extendProcedureDoc as any))
112
+ break
113
+ case 'rpc-stream':
114
+ docs.push(buildStreamRouteDoc(
115
+ procedure as any,
116
+ item.streamMode ?? this.config?.stream?.defaultStreamMode ?? 'sse',
117
+ prefix,
118
+ item.extendProcedureDoc as any,
119
+ ))
120
+ break
121
+ case 'http':
122
+ docs.push(buildHttpRouteDoc(procedure as any, prefix, item.extendProcedureDoc as any))
123
+ break
124
+ case 'http-stream':
125
+ docs.push(buildHttpStreamRouteDoc(procedure as any, prefix, item.extendProcedureDoc as any))
126
+ break
127
+ default: {
128
+ const reason = `Unknown procedure kind "${(procedure as any).kind}"`
129
+ skipped.push({ name: (procedure as any).name, reason })
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ this._docs = docs
136
+ this._skipped = skipped
137
+ }
138
+
139
+ /**
140
+ * Mounts every registered procedure on the Hono app and returns it.
141
+ * Idempotent: calling twice is a no-op on the second call.
142
+ */
143
+ build(): Hono {
144
+ if (this._built) return this._app
145
+ this._built = true
146
+
147
+ const docs: AnyHttpRouteDoc[] = []
148
+ const skipped: { name: string; reason: string }[] = []
149
+ const cfg = this.config ?? {}
150
+
151
+ for (const item of this.factories) {
152
+ for (const procedure of item.factory.getProcedures().values() as Iterable<AnyProcedureRegistration>) {
153
+ switch (procedure.kind) {
154
+ case 'rpc':
155
+ installRpcRoute({ app: this._app, procedure: procedure as any, factoryItem: item, cfg, docs })
156
+ break
157
+ case 'rpc-stream': {
158
+ const streamMode = item.streamMode ?? cfg.stream?.defaultStreamMode ?? 'sse'
159
+ installRpcStreamRoute({ app: this._app, procedure: procedure as any, factoryItem: item, cfg, docs, streamMode })
160
+ break
161
+ }
162
+ case 'http':
163
+ installHttpRoute({ app: this._app, procedure: procedure as any, factoryItem: item, cfg, docs })
164
+ break
165
+ case 'http-stream':
166
+ installHttpStreamRoute({ app: this._app, procedure: procedure as any, factoryItem: item, cfg, docs })
167
+ break
168
+ default: {
169
+ const reason = `Unknown procedure kind "${(procedure as any).kind}"`
170
+ skipped.push({ name: (procedure as any).name, reason })
171
+ console.warn(`[ts-procedures hono] Skipping procedure "${(procedure as any).name}": ${reason}`)
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ this._docs = docs
178
+ this._skipped = skipped
179
+ return this._app
180
+ }
181
+
182
+ /**
183
+ * Produces a {@link DocEnvelope} for single-app usage. For multi-app
184
+ * aggregation, use {@link DocRegistry} and `from(builder)` instead.
185
+ *
186
+ * Mirrors `DocRegistry.toJSON()` options so the two paths produce
187
+ * interchangeable envelopes for the codegen pipeline.
188
+ */
189
+ toDocEnvelope<T = DocEnvelope>(options?: {
190
+ basePath?: string
191
+ errors?: ErrorTaxonomy | ErrorDoc[]
192
+ includeDefaults?: boolean
193
+ headers?: HeaderDoc[]
194
+ filter?: (route: AnyHttpRouteDoc) => boolean
195
+ transform?: (envelope: DocEnvelope) => T
196
+ }): T {
197
+ return new DocRegistry({
198
+ basePath: options?.basePath ?? this.config?.pathPrefix,
199
+ errors: options?.errors ?? this.config?.errors,
200
+ includeDefaults: options?.includeDefaults,
201
+ headers: options?.headers,
202
+ }).from(this).toJSON<T>(options)
203
+ }
204
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { makeRoutePath, resolveFullPath } from './path.js'
3
+ import type {
4
+ TProcedureRegistration,
5
+ TStreamProcedureRegistration,
6
+ THttpProcedureRegistration,
7
+ THttpStreamProcedureRegistration,
8
+ } from '../../../types.js'
9
+
10
+ describe('makeRoutePath', () => {
11
+ test('rpc: scope/name/version with kebab-case', () => {
12
+ const proc = {
13
+ name: 'GetUser',
14
+ kind: 'rpc',
15
+ config: { scope: 'users', version: 1 },
16
+ handler: async () => undefined,
17
+ } as unknown as TProcedureRegistration
18
+ expect(makeRoutePath({ procedure: proc })).toBe('/users/get-user/1')
19
+ })
20
+
21
+ test('rpc: array scope joined with /', () => {
22
+ const proc = {
23
+ name: 'List',
24
+ kind: 'rpc',
25
+ config: { scope: ['UserModule', 'admin'], version: 2 },
26
+ handler: async () => undefined,
27
+ } as unknown as TProcedureRegistration
28
+ expect(makeRoutePath({ procedure: proc })).toBe('/user-module/admin/list/2')
29
+ })
30
+
31
+ test('rpc: pathPrefix is normalized (leading slash inserted)', () => {
32
+ const proc = {
33
+ name: 'Echo',
34
+ kind: 'rpc',
35
+ config: { scope: 'echo', version: 1 },
36
+ handler: async () => undefined,
37
+ } as unknown as TProcedureRegistration
38
+ expect(makeRoutePath({ procedure: proc, prefix: 'api/v1' })).toBe('/api/v1/echo/echo/1')
39
+ })
40
+
41
+ test('rpc-stream: same shape as rpc', () => {
42
+ const proc = {
43
+ name: 'Tail',
44
+ kind: 'rpc-stream',
45
+ isStream: true,
46
+ config: { scope: 'logs', version: 1 },
47
+ handler: async function* () {},
48
+ } as unknown as TStreamProcedureRegistration
49
+ expect(makeRoutePath({ procedure: proc, prefix: '/api' })).toBe('/api/logs/tail/1')
50
+ })
51
+
52
+ test('http: prepends prefix to config.path', () => {
53
+ const proc = {
54
+ name: 'GetUser',
55
+ kind: 'http',
56
+ config: { path: '/users/:id', method: 'get' },
57
+ handler: async () => undefined,
58
+ } as unknown as THttpProcedureRegistration
59
+ expect(makeRoutePath({ procedure: proc, prefix: '/api/v1' })).toBe('/api/v1/users/:id')
60
+ })
61
+
62
+ test('http: leading slash on path is preserved (not duplicated)', () => {
63
+ const proc = {
64
+ name: 'Get',
65
+ kind: 'http',
66
+ config: { path: 'users', method: 'get' },
67
+ handler: async () => undefined,
68
+ } as unknown as THttpProcedureRegistration
69
+ expect(makeRoutePath({ procedure: proc, prefix: '/api' })).toBe('/api/users')
70
+ })
71
+
72
+ test('http-stream: same shape as http', () => {
73
+ const proc = {
74
+ name: 'TailLogs',
75
+ kind: 'http-stream',
76
+ config: { path: '/logs/tail', method: 'get' },
77
+ handler: async () => ({ stream: (async function* () {})(), initialHeaders: undefined }),
78
+ } as unknown as THttpStreamProcedureRegistration
79
+ expect(makeRoutePath({ procedure: proc })).toBe('/logs/tail')
80
+ })
81
+ })
82
+
83
+ describe('resolveFullPath', () => {
84
+ test('combines prefix and path', () => {
85
+ expect(resolveFullPath('/users', '/api/v1')).toBe('/api/v1/users')
86
+ })
87
+ test('normalizes prefix without leading slash', () => {
88
+ expect(resolveFullPath('/users', 'api')).toBe('/api/users')
89
+ })
90
+ test('normalizes path without leading slash', () => {
91
+ expect(resolveFullPath('users', '/api')).toBe('/api/users')
92
+ })
93
+ test('no prefix', () => {
94
+ expect(resolveFullPath('/users')).toBe('/users')
95
+ })
96
+ })
@@ -0,0 +1,59 @@
1
+ import { kebabCase } from 'es-toolkit/string'
2
+ import { castArray } from 'es-toolkit/compat'
3
+ import type {
4
+ TProcedureRegistration,
5
+ TStreamProcedureRegistration,
6
+ THttpProcedureRegistration,
7
+ THttpStreamProcedureRegistration,
8
+ } from '../../../types.js'
9
+
10
+ export type AnyProcedureRegistration =
11
+ | TProcedureRegistration
12
+ | TStreamProcedureRegistration
13
+ | THttpProcedureRegistration<any>
14
+ | THttpStreamProcedureRegistration<any>
15
+
16
+ function normalizePrefix(prefix?: string): string {
17
+ if (!prefix) return ''
18
+ return prefix.startsWith('/') ? prefix : `/${prefix}`
19
+ }
20
+
21
+ /**
22
+ * Resolves the full route path for any procedure kind.
23
+ *
24
+ * - `rpc` / `rpc-stream`: `{prefix}/{kebab(scope)}/{kebab(name)}/{version}`
25
+ * - `http` / `http-stream`: `{prefix}{config.path}` (path-as-given, prefix normalized)
26
+ */
27
+ export function makeRoutePath({
28
+ procedure,
29
+ prefix,
30
+ }: {
31
+ procedure: AnyProcedureRegistration
32
+ prefix?: string
33
+ }): string {
34
+ const normalizedPrefix = normalizePrefix(prefix)
35
+
36
+ switch (procedure.kind) {
37
+ case 'rpc':
38
+ case 'rpc-stream': {
39
+ const cfg = procedure.config as { scope: string | string[]; version: number }
40
+ const scopeSegments = castArray(cfg.scope).map(kebabCase).join('/')
41
+ return `${normalizedPrefix}/${scopeSegments}/${kebabCase(procedure.name)}/${String(cfg.version).trim()}`
42
+ }
43
+ case 'http':
44
+ case 'http-stream': {
45
+ const cfg = procedure.config as { path: string }
46
+ return resolveFullPath(cfg.path, prefix)
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Combines a developer-supplied path with an optional prefix. Both are
53
+ * normalized to begin with a single `/`.
54
+ */
55
+ export function resolveFullPath(procedurePath: string, prefix?: string): string {
56
+ const normalizedPrefix = normalizePrefix(prefix)
57
+ const normalizedPath = procedurePath.startsWith('/') ? procedurePath : `/${procedurePath}`
58
+ return `${normalizedPrefix}${normalizedPath}`
59
+ }