@lovrozagar/honey 0.6.8 → 0.7.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 (674) hide show
  1. package/README.md +514 -192
  2. package/bin/honey.js +25 -0
  3. package/dist/accepts.d.ts +7 -1
  4. package/dist/accepts.d.ts.map +1 -1
  5. package/dist/accepts.js +126 -0
  6. package/dist/accepts.js.map +1 -0
  7. package/dist/body-kind.d.ts +26 -0
  8. package/dist/body-kind.d.ts.map +1 -0
  9. package/dist/body-kind.js +70 -0
  10. package/dist/body-kind.js.map +1 -0
  11. package/dist/body-limit.d.ts +5 -1
  12. package/dist/body-limit.d.ts.map +1 -1
  13. package/dist/body-limit.js +126 -0
  14. package/dist/body-limit.js.map +1 -0
  15. package/dist/build/index.d.ts +19 -3
  16. package/dist/build/index.d.ts.map +1 -1
  17. package/dist/build/index.js +173 -0
  18. package/dist/build/index.js.map +1 -0
  19. package/dist/cli-args.d.ts +33 -0
  20. package/dist/cli-args.d.ts.map +1 -0
  21. package/dist/cli-args.js +102 -0
  22. package/dist/cli-args.js.map +1 -0
  23. package/dist/cli-go/auth.go +26 -0
  24. package/dist/cli-go/config.go +192 -0
  25. package/dist/cli-go/errors.go +75 -0
  26. package/dist/cli-go/multipart.go +79 -0
  27. package/dist/cli-go/output.go +163 -0
  28. package/dist/cli-go/root.go +30 -0
  29. package/dist/cli-go/stream.go +88 -0
  30. package/dist/cli-go/version.go +5 -0
  31. package/dist/cli.d.ts +1 -1
  32. package/dist/cli.js +209 -0
  33. package/dist/cli.js.map +1 -0
  34. package/dist/client/defaults.js +48 -0
  35. package/dist/client/defaults.js.map +1 -0
  36. package/dist/client/error.d.ts +35 -0
  37. package/dist/client/error.d.ts.map +1 -1
  38. package/dist/client/error.js +71 -0
  39. package/dist/client/error.js.map +1 -0
  40. package/dist/client/http.d.ts +67 -8
  41. package/dist/client/http.d.ts.map +1 -1
  42. package/dist/client/http.js +571 -0
  43. package/dist/client/http.js.map +1 -0
  44. package/dist/client/index.d.ts +3 -2
  45. package/dist/client/index.d.ts.map +1 -1
  46. package/dist/client/index.js +95 -0
  47. package/dist/client/index.js.map +1 -0
  48. package/dist/client/path.d.ts +26 -0
  49. package/dist/client/path.d.ts.map +1 -0
  50. package/dist/client/path.js +163 -0
  51. package/dist/client/path.js.map +1 -0
  52. package/dist/client/redirect-policy.d.ts +12 -0
  53. package/dist/client/redirect-policy.d.ts.map +1 -0
  54. package/dist/client/redirect-policy.js +20 -0
  55. package/dist/client/redirect-policy.js.map +1 -0
  56. package/dist/client/sdk.d.ts +36 -0
  57. package/dist/client/sdk.d.ts.map +1 -1
  58. package/dist/client/sdk.js +292 -0
  59. package/dist/client/sdk.js.map +1 -0
  60. package/dist/client/sse.d.ts +13 -0
  61. package/dist/client/sse.d.ts.map +1 -1
  62. package/dist/client/sse.js +129 -0
  63. package/dist/client/sse.js.map +1 -0
  64. package/dist/client/types.js +2 -0
  65. package/dist/client/types.js.map +1 -0
  66. package/dist/client/ws.d.ts +9 -2
  67. package/dist/client/ws.d.ts.map +1 -1
  68. package/dist/client/ws.js +112 -0
  69. package/dist/client/ws.js.map +1 -0
  70. package/dist/client-go/errors.go +236 -0
  71. package/dist/client-go/export.go +44 -0
  72. package/dist/client-go/invalidation.go +371 -0
  73. package/dist/client-go/nullable.go +62 -0
  74. package/dist/client-go/realtime.go +354 -0
  75. package/dist/client-go/result.go +20 -0
  76. package/dist/client-go/runtime.go +975 -0
  77. package/dist/client-go/sse.go +159 -0
  78. package/dist/client-go/transport.go +495 -0
  79. package/dist/client-go/ws.go +128 -0
  80. package/dist/client-info.d.ts +36 -0
  81. package/dist/client-info.d.ts.map +1 -0
  82. package/dist/client-info.js +53 -0
  83. package/dist/client-info.js.map +1 -0
  84. package/dist/client-mcp/README.md +10 -0
  85. package/dist/client-mcp/server.ts +221 -0
  86. package/dist/client-mcp/types.ts +19 -0
  87. package/dist/client-python/_errors.py +115 -0
  88. package/dist/client-python/_invalidation.py +494 -0
  89. package/dist/client-python/_realtime.py +179 -0
  90. package/dist/client-python/_runtime.py +860 -0
  91. package/dist/client-python/_sse.py +155 -0
  92. package/dist/client-python/_transport.py +366 -0
  93. package/dist/client-python/_ws.py +159 -0
  94. package/dist/client-python/py.typed +0 -0
  95. package/dist/client-rust/errors.rs +220 -0
  96. package/dist/client-rust/invalidation.rs +412 -0
  97. package/dist/client-rust/invalidation_sync.rs +93 -0
  98. package/dist/client-rust/nullable.rs +17 -0
  99. package/dist/client-rust/realtime.rs +702 -0
  100. package/dist/client-rust/result.rs +22 -0
  101. package/dist/client-rust/runtime.rs +980 -0
  102. package/dist/client-rust/runtime_sync.rs +484 -0
  103. package/dist/client-rust/sse.rs +188 -0
  104. package/dist/client-rust/ws.rs +167 -0
  105. package/dist/codegen-go-cli.d.ts.map +1 -1
  106. package/dist/codegen-go-cli.js +964 -0
  107. package/dist/codegen-go-cli.js.map +1 -0
  108. package/dist/codegen-go.d.ts +15 -3
  109. package/dist/codegen-go.d.ts.map +1 -1
  110. package/dist/codegen-go.js +872 -0
  111. package/dist/codegen-go.js.map +1 -0
  112. package/dist/codegen-hash.js +9 -0
  113. package/dist/codegen-hash.js.map +1 -0
  114. package/dist/codegen-ir.d.ts +69 -2
  115. package/dist/codegen-ir.d.ts.map +1 -1
  116. package/dist/codegen-ir.js +661 -0
  117. package/dist/codegen-ir.js.map +1 -0
  118. package/dist/codegen-lang.d.ts +65 -0
  119. package/dist/codegen-lang.d.ts.map +1 -0
  120. package/dist/codegen-lang.js +526 -0
  121. package/dist/codegen-lang.js.map +1 -0
  122. package/dist/codegen-load.d.ts +31 -0
  123. package/dist/codegen-load.d.ts.map +1 -0
  124. package/dist/codegen-load.js +138 -0
  125. package/dist/codegen-load.js.map +1 -0
  126. package/dist/codegen-loaders.js +55 -0
  127. package/dist/codegen-loaders.js.map +1 -0
  128. package/dist/codegen-mcp.d.ts.map +1 -1
  129. package/dist/codegen-mcp.js +368 -0
  130. package/dist/codegen-mcp.js.map +1 -0
  131. package/dist/codegen-python.d.ts +13 -1
  132. package/dist/codegen-python.d.ts.map +1 -1
  133. package/dist/codegen-python.js +828 -0
  134. package/dist/codegen-python.js.map +1 -0
  135. package/dist/codegen-route-tree-intern.d.ts +15 -5
  136. package/dist/codegen-route-tree-intern.d.ts.map +1 -1
  137. package/dist/codegen-route-tree-intern.js +221 -0
  138. package/dist/codegen-route-tree-intern.js.map +1 -0
  139. package/dist/codegen-rust.d.ts.map +1 -1
  140. package/dist/codegen-rust.js +1293 -0
  141. package/dist/codegen-rust.js.map +1 -0
  142. package/dist/codegen-sanitize.d.ts.map +1 -1
  143. package/dist/codegen-sanitize.js +56 -0
  144. package/dist/codegen-sanitize.js.map +1 -0
  145. package/dist/codegen-schema-naming.d.ts +5 -0
  146. package/dist/codegen-schema-naming.d.ts.map +1 -1
  147. package/dist/codegen-schema-naming.js +150 -0
  148. package/dist/codegen-schema-naming.js.map +1 -0
  149. package/dist/codegen-sdk-model.d.ts +71 -0
  150. package/dist/codegen-sdk-model.d.ts.map +1 -0
  151. package/dist/codegen-sdk-model.js +179 -0
  152. package/dist/codegen-sdk-model.js.map +1 -0
  153. package/dist/codegen.d.ts +41 -23
  154. package/dist/codegen.d.ts.map +1 -1
  155. package/dist/codegen.js +4610 -0
  156. package/dist/codegen.js.map +1 -0
  157. package/dist/context.d.ts +36 -6
  158. package/dist/context.d.ts.map +1 -1
  159. package/dist/context.js +242 -0
  160. package/dist/context.js.map +1 -0
  161. package/dist/cookie-sign.d.ts +16 -2
  162. package/dist/cookie-sign.d.ts.map +1 -1
  163. package/dist/cookie-sign.js +104 -0
  164. package/dist/cookie-sign.js.map +1 -0
  165. package/dist/cookie.d.ts +12 -0
  166. package/dist/cookie.d.ts.map +1 -1
  167. package/dist/cookie.js +138 -0
  168. package/dist/cookie.js.map +1 -0
  169. package/dist/cors.d.ts +1 -0
  170. package/dist/cors.d.ts.map +1 -1
  171. package/dist/cors.js +87 -0
  172. package/dist/cors.js.map +1 -0
  173. package/dist/crypto.d.ts +6 -1
  174. package/dist/crypto.d.ts.map +1 -1
  175. package/dist/crypto.js +38 -0
  176. package/dist/crypto.js.map +1 -0
  177. package/dist/csrf.d.ts +19 -0
  178. package/dist/csrf.d.ts.map +1 -1
  179. package/dist/csrf.js +79 -0
  180. package/dist/csrf.js.map +1 -0
  181. package/dist/curl-logger.d.ts +14 -3
  182. package/dist/curl-logger.d.ts.map +1 -1
  183. package/dist/curl-logger.js +199 -0
  184. package/dist/curl-logger.js.map +1 -0
  185. package/dist/detect-runtime.js +13 -0
  186. package/dist/detect-runtime.js.map +1 -0
  187. package/dist/dict.d.ts +11 -0
  188. package/dist/dict.d.ts.map +1 -0
  189. package/dist/dict.js +13 -0
  190. package/dist/dict.js.map +1 -0
  191. package/dist/error.d.ts.map +1 -1
  192. package/dist/error.js +44 -0
  193. package/dist/error.js.map +1 -0
  194. package/dist/errors.js +39 -0
  195. package/dist/errors.js.map +1 -0
  196. package/dist/etag.d.ts +2 -0
  197. package/dist/etag.d.ts.map +1 -1
  198. package/dist/etag.js +82 -0
  199. package/dist/etag.js.map +1 -0
  200. package/dist/feature-detect.d.ts +25 -0
  201. package/dist/feature-detect.d.ts.map +1 -0
  202. package/dist/feature-detect.js +144 -0
  203. package/dist/feature-detect.js.map +1 -0
  204. package/dist/feature-load.js +36 -0
  205. package/dist/feature-load.js.map +1 -0
  206. package/dist/feature-slots.d.ts +44 -0
  207. package/dist/feature-slots.d.ts.map +1 -0
  208. package/dist/feature-slots.js +48 -0
  209. package/dist/feature-slots.js.map +1 -0
  210. package/dist/fetch-request.d.ts +10 -0
  211. package/dist/fetch-request.d.ts.map +1 -0
  212. package/dist/fetch-request.js +13 -0
  213. package/dist/fetch-request.js.map +1 -0
  214. package/dist/gen-process.d.ts +15 -0
  215. package/dist/gen-process.d.ts.map +1 -0
  216. package/dist/gen-process.js +43 -0
  217. package/dist/gen-process.js.map +1 -0
  218. package/dist/gen-write.d.ts +19 -0
  219. package/dist/gen-write.d.ts.map +1 -0
  220. package/dist/gen-write.js +120 -0
  221. package/dist/gen-write.js.map +1 -0
  222. package/dist/glob.d.ts +21 -0
  223. package/dist/glob.d.ts.map +1 -0
  224. package/dist/glob.js +115 -0
  225. package/dist/glob.js.map +1 -0
  226. package/dist/go-type-emitter.d.ts +18 -4
  227. package/dist/go-type-emitter.d.ts.map +1 -1
  228. package/dist/go-type-emitter.js +594 -0
  229. package/dist/go-type-emitter.js.map +1 -0
  230. package/dist/honey-response.d.ts.map +1 -1
  231. package/dist/honey-response.js +338 -0
  232. package/dist/honey-response.js.map +1 -0
  233. package/dist/i18n-slot.d.ts +8 -0
  234. package/dist/i18n-slot.d.ts.map +1 -1
  235. package/dist/i18n-slot.js +14 -0
  236. package/dist/i18n-slot.js.map +1 -0
  237. package/dist/i18n.d.ts +3 -0
  238. package/dist/i18n.d.ts.map +1 -1
  239. package/dist/i18n.js +250 -0
  240. package/dist/i18n.js.map +1 -0
  241. package/dist/index.d.ts +408 -148
  242. package/dist/index.d.ts.map +1 -1
  243. package/dist/index.js +2324 -0
  244. package/dist/index.js.map +1 -0
  245. package/dist/init.d.ts +3 -5
  246. package/dist/init.d.ts.map +1 -1
  247. package/dist/init.js +140 -0
  248. package/dist/init.js.map +1 -0
  249. package/dist/input.js +4 -0
  250. package/dist/input.js.map +1 -0
  251. package/dist/invalidate-check.d.ts +11 -0
  252. package/dist/invalidate-check.d.ts.map +1 -1
  253. package/dist/invalidate-check.js +163 -0
  254. package/dist/invalidate-check.js.map +1 -0
  255. package/dist/invoke-user.d.ts +35 -0
  256. package/dist/invoke-user.d.ts.map +1 -0
  257. package/dist/invoke-user.js +73 -0
  258. package/dist/invoke-user.js.map +1 -0
  259. package/dist/ip-restrict.d.ts +13 -1
  260. package/dist/ip-restrict.d.ts.map +1 -1
  261. package/dist/ip-restrict.js +60 -0
  262. package/dist/ip-restrict.js.map +1 -0
  263. package/dist/ip.d.ts +39 -0
  264. package/dist/ip.d.ts.map +1 -0
  265. package/dist/ip.js +209 -0
  266. package/dist/ip.js.map +1 -0
  267. package/dist/logger.d.ts.map +1 -1
  268. package/dist/logger.js +113 -0
  269. package/dist/logger.js.map +1 -0
  270. package/dist/media-type.d.ts +30 -0
  271. package/dist/media-type.d.ts.map +1 -0
  272. package/dist/media-type.js +97 -0
  273. package/dist/media-type.js.map +1 -0
  274. package/dist/meta-spec-merge.d.ts +32 -0
  275. package/dist/meta-spec-merge.d.ts.map +1 -0
  276. package/dist/meta-spec-merge.js +68 -0
  277. package/dist/meta-spec-merge.js.map +1 -0
  278. package/dist/meta-spec.d.ts +16 -3
  279. package/dist/meta-spec.d.ts.map +1 -1
  280. package/dist/meta-spec.js +535 -0
  281. package/dist/meta-spec.js.map +1 -0
  282. package/dist/middleware.d.ts +23 -6
  283. package/dist/middleware.d.ts.map +1 -1
  284. package/dist/middleware.js +253 -0
  285. package/dist/middleware.js.map +1 -0
  286. package/dist/node-request.d.ts +57 -12
  287. package/dist/node-request.d.ts.map +1 -1
  288. package/dist/node-request.js +407 -0
  289. package/dist/node-request.js.map +1 -0
  290. package/dist/node.d.ts +20 -3
  291. package/dist/node.d.ts.map +1 -1
  292. package/dist/node.js +396 -0
  293. package/dist/node.js.map +1 -0
  294. package/dist/openapi/collect.d.ts +6 -3
  295. package/dist/openapi/collect.d.ts.map +1 -1
  296. package/dist/openapi/collect.js +30 -0
  297. package/dist/openapi/collect.js.map +1 -0
  298. package/dist/openapi/docs-page.d.ts +42 -0
  299. package/dist/openapi/docs-page.d.ts.map +1 -0
  300. package/dist/openapi/docs-page.js +80 -0
  301. package/dist/openapi/docs-page.js.map +1 -0
  302. package/dist/openapi/document.d.ts +4 -0
  303. package/dist/openapi/document.d.ts.map +1 -1
  304. package/dist/openapi/document.js +785 -0
  305. package/dist/openapi/document.js.map +1 -0
  306. package/dist/openapi/json-schema-slot.d.ts +7 -0
  307. package/dist/openapi/json-schema-slot.d.ts.map +1 -1
  308. package/dist/openapi/json-schema-slot.js +38 -0
  309. package/dist/openapi/json-schema-slot.js.map +1 -0
  310. package/dist/openapi/register.d.ts.map +1 -1
  311. package/dist/openapi/register.js +20 -0
  312. package/dist/openapi/register.js.map +1 -0
  313. package/dist/openapi/scalar.d.ts +3 -1
  314. package/dist/openapi/scalar.d.ts.map +1 -1
  315. package/dist/openapi/scalar.js +21 -0
  316. package/dist/openapi/scalar.js.map +1 -0
  317. package/dist/openapi/serve.d.ts +9 -0
  318. package/dist/openapi/serve.d.ts.map +1 -0
  319. package/dist/openapi/serve.js +36 -0
  320. package/dist/openapi/serve.js.map +1 -0
  321. package/dist/openapi/spec-factory.d.ts +30 -1
  322. package/dist/openapi/spec-factory.d.ts.map +1 -1
  323. package/dist/openapi/spec-factory.js +52 -0
  324. package/dist/openapi/spec-factory.js.map +1 -0
  325. package/dist/openapi/spec.d.ts +14 -5
  326. package/dist/openapi/spec.d.ts.map +1 -1
  327. package/dist/openapi/spec.js +50 -0
  328. package/dist/openapi/spec.js.map +1 -0
  329. package/dist/openapi/swagger.d.ts +3 -1
  330. package/dist/openapi/swagger.d.ts.map +1 -1
  331. package/dist/openapi/swagger.js +22 -0
  332. package/dist/openapi/swagger.js.map +1 -0
  333. package/dist/pattern.d.ts +71 -0
  334. package/dist/pattern.d.ts.map +1 -0
  335. package/dist/pattern.js +239 -0
  336. package/dist/pattern.js.map +1 -0
  337. package/dist/peer.d.ts +17 -0
  338. package/dist/peer.d.ts.map +1 -0
  339. package/dist/peer.js +50 -0
  340. package/dist/peer.js.map +1 -0
  341. package/dist/plugin.d.ts +67 -16
  342. package/dist/plugin.d.ts.map +1 -1
  343. package/dist/plugin.js +649 -0
  344. package/dist/plugin.js.map +1 -0
  345. package/dist/powered-by.d.ts.map +1 -1
  346. package/dist/powered-by.js +11 -0
  347. package/dist/powered-by.js.map +1 -0
  348. package/dist/pretty-json.d.ts.map +1 -1
  349. package/dist/pretty-json.js +47 -0
  350. package/dist/pretty-json.js.map +1 -0
  351. package/dist/producer-stream.d.ts +47 -0
  352. package/dist/producer-stream.d.ts.map +1 -0
  353. package/dist/producer-stream.js +267 -0
  354. package/dist/producer-stream.js.map +1 -0
  355. package/dist/proxy.d.ts +47 -8
  356. package/dist/proxy.d.ts.map +1 -1
  357. package/dist/proxy.js +274 -0
  358. package/dist/proxy.js.map +1 -0
  359. package/dist/python-type-emitter.d.ts +14 -1
  360. package/dist/python-type-emitter.d.ts.map +1 -1
  361. package/dist/python-type-emitter.js +157 -0
  362. package/dist/python-type-emitter.js.map +1 -0
  363. package/dist/realtime/bus.d.ts +17 -1
  364. package/dist/realtime/bus.d.ts.map +1 -1
  365. package/dist/realtime/bus.js +112 -0
  366. package/dist/realtime/bus.js.map +1 -0
  367. package/dist/realtime/register.d.ts +3 -0
  368. package/dist/realtime/register.d.ts.map +1 -0
  369. package/dist/realtime/register.js +10 -0
  370. package/dist/realtime/register.js.map +1 -0
  371. package/dist/realtime/route.d.ts +65 -26
  372. package/dist/realtime/route.d.ts.map +1 -1
  373. package/dist/realtime/route.js +72 -0
  374. package/dist/realtime/route.js.map +1 -0
  375. package/dist/realtime/server.d.ts +43 -0
  376. package/dist/realtime/server.d.ts.map +1 -0
  377. package/dist/realtime/server.js +255 -0
  378. package/dist/realtime/server.js.map +1 -0
  379. package/dist/request-id.d.ts +7 -0
  380. package/dist/request-id.d.ts.map +1 -1
  381. package/dist/request-id.js +16 -0
  382. package/dist/request-id.js.map +1 -0
  383. package/dist/request-limits.d.ts +3 -0
  384. package/dist/request-limits.d.ts.map +1 -0
  385. package/dist/request-limits.js +3 -0
  386. package/dist/request-limits.js.map +1 -0
  387. package/dist/request-path.d.ts +33 -0
  388. package/dist/request-path.d.ts.map +1 -0
  389. package/dist/request-path.js +178 -0
  390. package/dist/request-path.js.map +1 -0
  391. package/dist/request-to-curl.d.ts +7 -1
  392. package/dist/request-to-curl.d.ts.map +1 -1
  393. package/dist/request-to-curl.js +48 -0
  394. package/dist/request-to-curl.js.map +1 -0
  395. package/dist/response.d.ts +25 -2
  396. package/dist/response.d.ts.map +1 -1
  397. package/dist/response.js +313 -0
  398. package/dist/response.js.map +1 -0
  399. package/dist/rust-type-emitter.d.ts +14 -2
  400. package/dist/rust-type-emitter.d.ts.map +1 -1
  401. package/dist/rust-type-emitter.js +463 -0
  402. package/dist/rust-type-emitter.js.map +1 -0
  403. package/dist/secure-headers.d.ts.map +1 -1
  404. package/dist/secure-headers.js +53 -0
  405. package/dist/secure-headers.js.map +1 -0
  406. package/dist/serve-register.d.ts +4 -0
  407. package/dist/serve-register.d.ts.map +1 -1
  408. package/dist/serve-register.js +9 -0
  409. package/dist/serve-register.js.map +1 -0
  410. package/dist/serve-slot.d.ts +3 -0
  411. package/dist/serve-slot.d.ts.map +1 -1
  412. package/dist/serve-slot.js +52 -0
  413. package/dist/serve-slot.js.map +1 -0
  414. package/dist/serve.d.ts +18 -1
  415. package/dist/serve.d.ts.map +1 -1
  416. package/dist/serve.js +157 -0
  417. package/dist/serve.js.map +1 -0
  418. package/dist/server-timing.d.ts.map +1 -1
  419. package/dist/server-timing.js +51 -0
  420. package/dist/server-timing.js.map +1 -0
  421. package/dist/static.d.ts +9 -0
  422. package/dist/static.d.ts.map +1 -1
  423. package/dist/static.js +102 -0
  424. package/dist/static.js.map +1 -0
  425. package/dist/telemetry/otel.d.ts +2 -0
  426. package/dist/telemetry/otel.d.ts.map +1 -1
  427. package/dist/telemetry/otel.js +135 -0
  428. package/dist/telemetry/otel.js.map +1 -0
  429. package/dist/testing.d.ts +12 -0
  430. package/dist/testing.d.ts.map +1 -1
  431. package/dist/testing.js +124 -0
  432. package/dist/testing.js.map +1 -0
  433. package/dist/timeout.d.ts +6 -0
  434. package/dist/timeout.d.ts.map +1 -1
  435. package/dist/timeout.js +36 -0
  436. package/dist/timeout.js.map +1 -0
  437. package/dist/tree.d.ts +133 -36
  438. package/dist/tree.d.ts.map +1 -1
  439. package/dist/tree.js +437 -0
  440. package/dist/tree.js.map +1 -0
  441. package/dist/trust.d.ts +21 -0
  442. package/dist/trust.d.ts.map +1 -0
  443. package/dist/trust.js +100 -0
  444. package/dist/trust.js.map +1 -0
  445. package/dist/ts-type-emitter.d.ts.map +1 -1
  446. package/dist/ts-type-emitter.js +150 -0
  447. package/dist/ts-type-emitter.js.map +1 -0
  448. package/dist/type-emitter.d.ts +16 -2
  449. package/dist/type-emitter.d.ts.map +1 -1
  450. package/dist/type-emitter.js +729 -0
  451. package/dist/type-emitter.js.map +1 -0
  452. package/dist/type-extractor.d.ts +8 -4
  453. package/dist/type-extractor.d.ts.map +1 -1
  454. package/dist/type-extractor.js +776 -0
  455. package/dist/type-extractor.js.map +1 -0
  456. package/dist/types.d.ts +7 -0
  457. package/dist/types.d.ts.map +1 -1
  458. package/dist/types.js +96 -0
  459. package/dist/types.js.map +1 -0
  460. package/dist/validation.d.ts +14 -2
  461. package/dist/validation.d.ts.map +1 -1
  462. package/dist/validation.js +335 -0
  463. package/dist/validation.js.map +1 -0
  464. package/dist/with-headers.d.ts +15 -0
  465. package/dist/with-headers.d.ts.map +1 -0
  466. package/dist/with-headers.js +45 -0
  467. package/dist/with-headers.js.map +1 -0
  468. package/dist/ws/bun.d.ts +11 -5
  469. package/dist/ws/bun.d.ts.map +1 -1
  470. package/dist/ws/bun.js +82 -0
  471. package/dist/ws/bun.js.map +1 -0
  472. package/dist/ws/cloudflare.d.ts +25 -3
  473. package/dist/ws/cloudflare.d.ts.map +1 -1
  474. package/dist/ws/cloudflare.js +117 -0
  475. package/dist/ws/cloudflare.js.map +1 -0
  476. package/dist/ws/deno.d.ts +4 -4
  477. package/dist/ws/deno.d.ts.map +1 -1
  478. package/dist/ws/deno.js +133 -0
  479. package/dist/ws/deno.js.map +1 -0
  480. package/dist/ws/node.d.ts +20 -3
  481. package/dist/ws/node.d.ts.map +1 -1
  482. package/dist/ws/node.js +173 -0
  483. package/dist/ws/node.js.map +1 -0
  484. package/dist/ws/session.d.ts +11 -0
  485. package/dist/ws/session.d.ts.map +1 -0
  486. package/dist/ws/session.js +60 -0
  487. package/dist/ws/session.js.map +1 -0
  488. package/dist/ws/shared.d.ts +65 -0
  489. package/dist/ws/shared.d.ts.map +1 -0
  490. package/dist/ws/shared.js +103 -0
  491. package/dist/ws/shared.js.map +1 -0
  492. package/dist/ws-origin.d.ts +20 -0
  493. package/dist/ws-origin.d.ts.map +1 -0
  494. package/dist/ws-origin.js +41 -0
  495. package/dist/ws-origin.js.map +1 -0
  496. package/dist/yaml.d.ts +15 -1
  497. package/dist/yaml.d.ts.map +1 -1
  498. package/dist/yaml.js +101 -0
  499. package/dist/yaml.js.map +1 -0
  500. package/docs/invalidate-spec.md +13 -1
  501. package/docs/meta-spec.md +36 -12
  502. package/docs/packaging.md +43 -24
  503. package/docs/regression-matrix/README.md +32 -0
  504. package/docs/regression-matrix/review.md +44 -0
  505. package/docs/regression-matrix/ws1-3.md +97 -0
  506. package/docs/regression-matrix/ws4-8.md +107 -0
  507. package/docs/regression-matrix/ws9-12.md +266 -0
  508. package/docs/sdk.md +102 -52
  509. package/package.json +183 -52
  510. package/src/accepts.ts +91 -33
  511. package/src/body-kind.ts +67 -0
  512. package/src/body-limit.ts +102 -87
  513. package/src/build/index.ts +104 -54
  514. package/src/cli-args.ts +123 -0
  515. package/src/cli-go/auth.go +12 -1
  516. package/src/cli-go/config.go +29 -2
  517. package/src/cli-go/errors.go +28 -10
  518. package/src/cli-go/multipart.go +51 -27
  519. package/src/cli-go/output.go +58 -4
  520. package/src/cli-go/root.go +1 -1
  521. package/src/cli-go/version.go +2 -2
  522. package/src/cli.ts +154 -148
  523. package/src/client/error.ts +56 -1
  524. package/src/client/http.ts +479 -256
  525. package/src/client/index.ts +55 -17
  526. package/src/client/path.ts +169 -0
  527. package/src/client/redirect-policy.ts +19 -0
  528. package/src/client/sdk.ts +180 -101
  529. package/src/client/sse.ts +92 -66
  530. package/src/client/ws.ts +67 -31
  531. package/src/client-go/errors.go +41 -12
  532. package/src/client-go/export.go +44 -0
  533. package/src/client-go/invalidation.go +52 -54
  534. package/src/client-go/nullable.go +62 -0
  535. package/src/client-go/realtime.go +147 -110
  536. package/src/client-go/runtime.go +646 -88
  537. package/src/client-go/sse.go +104 -94
  538. package/src/client-go/transport.go +67 -27
  539. package/src/client-go/ws.go +49 -25
  540. package/src/client-info.ts +79 -0
  541. package/src/client-mcp/server.ts +133 -3
  542. package/src/client-python/_invalidation.py +207 -291
  543. package/src/client-python/_realtime.py +63 -40
  544. package/src/client-python/_runtime.py +501 -119
  545. package/src/client-python/_sse.py +130 -74
  546. package/src/client-python/_transport.py +37 -9
  547. package/src/client-python/_ws.py +50 -22
  548. package/src/client-rust/errors.rs +21 -1
  549. package/src/client-rust/invalidation.rs +202 -231
  550. package/src/client-rust/invalidation_sync.rs +16 -229
  551. package/src/client-rust/nullable.rs +17 -0
  552. package/src/client-rust/realtime.rs +217 -217
  553. package/src/client-rust/runtime.rs +602 -177
  554. package/src/client-rust/runtime_sync.rs +169 -145
  555. package/src/client-rust/sse.rs +142 -121
  556. package/src/client-rust/ws.rs +61 -37
  557. package/src/codegen-go-cli.ts +607 -728
  558. package/src/codegen-go.ts +710 -770
  559. package/src/codegen-ir.ts +381 -75
  560. package/src/codegen-lang.ts +517 -0
  561. package/src/codegen-load.ts +150 -0
  562. package/src/codegen-mcp.ts +207 -108
  563. package/src/codegen-python.ts +684 -816
  564. package/src/codegen-route-tree-intern.ts +51 -25
  565. package/src/codegen-rust.ts +946 -1240
  566. package/src/codegen-sanitize.ts +0 -4
  567. package/src/codegen-schema-naming.ts +19 -6
  568. package/src/codegen-sdk-model.ts +228 -0
  569. package/src/codegen.ts +1488 -1140
  570. package/src/context.ts +130 -11
  571. package/src/cookie-sign.ts +85 -16
  572. package/src/cookie.ts +66 -11
  573. package/src/cors.ts +55 -54
  574. package/src/crypto.ts +27 -5
  575. package/src/csrf.ts +57 -25
  576. package/src/curl-logger.ts +155 -114
  577. package/src/dict.ts +12 -0
  578. package/src/error.ts +4 -2
  579. package/src/etag.ts +58 -34
  580. package/src/feature-detect.ts +147 -0
  581. package/src/feature-slots.ts +70 -0
  582. package/src/fetch-request.ts +13 -0
  583. package/src/gen-process.ts +42 -0
  584. package/src/gen-write.ts +127 -0
  585. package/src/glob.ts +109 -0
  586. package/src/go-type-emitter.ts +406 -155
  587. package/src/honey-response.ts +36 -9
  588. package/src/i18n-slot.ts +10 -0
  589. package/src/i18n.ts +98 -23
  590. package/src/index.ts +1887 -1756
  591. package/src/init.ts +40 -28
  592. package/src/invalidate-check.ts +42 -4
  593. package/src/invoke-user.ts +95 -0
  594. package/src/ip-restrict.ts +47 -161
  595. package/src/ip.ts +194 -0
  596. package/src/logger.ts +65 -36
  597. package/src/media-type.ts +104 -0
  598. package/src/meta-spec-merge.ts +80 -0
  599. package/src/meta-spec.ts +0 -0
  600. package/src/middleware.ts +112 -12
  601. package/src/node-request.ts +242 -52
  602. package/src/node.ts +318 -75
  603. package/src/openapi/collect.ts +19 -44
  604. package/src/openapi/docs-page.ts +89 -0
  605. package/src/openapi/document.ts +503 -224
  606. package/src/openapi/json-schema-slot.ts +40 -0
  607. package/src/openapi/register.ts +7 -2
  608. package/src/openapi/scalar.ts +11 -10
  609. package/src/openapi/serve.ts +39 -0
  610. package/src/openapi/spec-factory.ts +50 -1
  611. package/src/openapi/spec.ts +53 -30
  612. package/src/openapi/swagger.ts +12 -11
  613. package/src/pattern.ts +245 -0
  614. package/src/peer.ts +53 -0
  615. package/src/plugin.ts +270 -185
  616. package/src/powered-by.ts +2 -2
  617. package/src/pretty-json.ts +25 -9
  618. package/src/producer-stream.ts +296 -0
  619. package/src/proxy.ts +253 -55
  620. package/src/python-type-emitter.ts +68 -56
  621. package/src/realtime/bus.ts +56 -9
  622. package/src/realtime/register.ts +13 -0
  623. package/src/realtime/route.ts +129 -80
  624. package/src/realtime/server.ts +298 -0
  625. package/src/request-id.ts +13 -3
  626. package/src/request-limits.ts +2 -0
  627. package/src/request-path.ts +165 -0
  628. package/src/request-to-curl.ts +28 -7
  629. package/src/response.ts +143 -103
  630. package/src/rust-type-emitter.ts +265 -170
  631. package/src/secure-headers.ts +8 -4
  632. package/src/serve-register.ts +4 -0
  633. package/src/serve-slot.ts +38 -0
  634. package/src/serve.ts +113 -25
  635. package/src/server-timing.ts +11 -7
  636. package/src/static.ts +85 -32
  637. package/src/telemetry/otel.ts +29 -7
  638. package/src/testing.ts +76 -14
  639. package/src/timeout.ts +15 -7
  640. package/src/tree.ts +405 -333
  641. package/src/trust.ts +117 -0
  642. package/src/ts-type-emitter.ts +18 -12
  643. package/src/type-emitter.ts +227 -89
  644. package/src/type-extractor.ts +562 -424
  645. package/src/types.ts +8 -0
  646. package/src/validation.ts +221 -96
  647. package/src/with-headers.ts +45 -0
  648. package/src/ws/bun.ts +53 -15
  649. package/src/ws/cloudflare.ts +81 -19
  650. package/src/ws/deno.ts +124 -36
  651. package/src/ws/node.ts +173 -79
  652. package/src/ws/session.ts +73 -0
  653. package/src/ws/shared.ts +140 -0
  654. package/src/ws-origin.ts +53 -0
  655. package/src/yaml.ts +45 -22
  656. package/dist/client-mcp/server.d.ts +0 -10
  657. package/dist/client-mcp/server.d.ts.map +0 -1
  658. package/dist/client-mcp/types.d.ts +0 -16
  659. package/dist/client-mcp/types.d.ts.map +0 -1
  660. package/dist/cloudflare/realtime.d.ts +0 -30
  661. package/dist/cloudflare/realtime.d.ts.map +0 -1
  662. package/dist/realtime/buffer.d.ts +0 -18
  663. package/dist/realtime/buffer.d.ts.map +0 -1
  664. package/dist/realtime/longpoll.d.ts +0 -23
  665. package/dist/realtime/longpoll.d.ts.map +0 -1
  666. package/dist/realtime/protocol.d.ts +0 -50
  667. package/dist/realtime/protocol.d.ts.map +0 -1
  668. package/dist/realtime/runtime.d.ts +0 -74
  669. package/dist/realtime/runtime.d.ts.map +0 -1
  670. package/src/cloudflare/realtime.ts +0 -234
  671. package/src/realtime/buffer.ts +0 -101
  672. package/src/realtime/longpoll.ts +0 -148
  673. package/src/realtime/protocol.ts +0 -128
  674. package/src/realtime/runtime.ts +0 -427
package/src/codegen.ts CHANGED
@@ -1,14 +1,32 @@
1
1
  import { InternPool } from "./codegen-route-tree-intern.ts"
2
- import { deriveErrorEnvelopeName, deriveSchemaName, isErrorEnvelope, shortHash } from "./codegen-schema-naming.ts"
2
+ import {
3
+ deriveErrorEnvelopeName,
4
+ deriveSchemaName,
5
+ isErrorEnvelope,
6
+ sanitizeComponentName,
7
+ shortHash,
8
+ } from "./codegen-schema-naming.ts"
3
9
  import type { SchemaNameContext } from "./codegen-schema-naming.ts"
4
10
  import { effectJsonSchemaFn, loadEffectJsonSchema, loadToJSONSchema, toJSONSchemaFn } from "./codegen-loaders.ts"
5
11
  import { sanitizeZodJsonSchema } from "./codegen-sanitize.ts"
6
- import { methodsOf, namespacesOf, schemaToIR, toIR } from "./codegen-ir.ts"
7
- import type { IRNamespace } from "./codegen-ir.ts"
12
+ import {
13
+ bodiesOf,
14
+ irErrorEnvelope,
15
+ irResolver,
16
+ methodsOf,
17
+ namespacesOf,
18
+ openApiPathParams,
19
+ schemaToIR,
20
+ toIR,
21
+ } from "./codegen-ir.ts"
22
+ import type { IR, IRBody, IRNamespace, IROperation, IRSchema } from "./codegen-ir.ts"
23
+ import { isJsonMedia, isTextMedia, mediaEssence } from "./codegen-sdk-model.ts"
8
24
  import type { HoneyError } from "./error.ts"
9
25
  import { ERROR_META } from "./errors.ts"
10
26
  import type { ErrorMetaEntry } from "./errors.ts"
11
27
  import type { InvalidateCheckConfig } from "./invalidate-check.ts"
28
+ import { publishableMetaKeys } from "./meta-spec.ts"
29
+ import { metaSpecOf } from "./meta-spec-merge.ts"
12
30
  import type { Honey } from "./index.ts"
13
31
  import {
14
32
  generateOpenApiFromTree,
@@ -17,12 +35,16 @@ import {
17
35
  type OpenApiSpec,
18
36
  } from "./openapi/document.ts"
19
37
  import { getJsonSchemaConverter, setJsonSchemaConverter } from "./openapi/json-schema-slot.ts"
20
- import type { RouteHandler, TreeNode } from "./tree.ts"
38
+ import { collectRoutes, extractParams } from "./openapi/collect.ts"
39
+ import { parsePattern, patternParams } from "./pattern.ts"
40
+ import type { RouteHandler, RouteTree } from "./tree.ts"
41
+ import { forEachLeaf, ROUTE_TREE_VERSION } from "./tree.ts"
21
42
  import { irToTs } from "./ts-type-emitter.ts"
22
- import { createTypeEmitState, emitSchemaType } from "./type-emitter.ts"
43
+ import { createTypeEmitState, emitSchemaType, quoteKey } from "./type-emitter.ts"
23
44
  import type { TypeEmitState } from "./type-emitter.ts"
24
- import type { InputSchemaEntry, InputSchemasDef, OutputSchemaDef, StandardSchemaLike } from "./types.ts"
45
+ import type { InputSchemaEntry, InputSchemasDef, MetaSpecConfig, OutputSchemaDef, StandardSchemaLike } from "./types.ts"
25
46
  import { EMPTY_OBJ, statusKeyToCode } from "./types.ts"
47
+ import { CROSS_ORIGIN_SAFE_HEADERS } from "./client/redirect-policy.ts"
26
48
 
27
49
  export { prepareCodegen } from "./codegen-loaders.ts"
28
50
  export type { InvalidateCheckConfig, InvalidateCheckLevel } from "./invalidate-check.ts"
@@ -57,21 +79,80 @@ const ERROR_EXPORT_NAMES: ReadonlyArray<string> = [
57
79
 
58
80
  type ValibotSchema = Record<string, unknown> & { type: string }
59
81
 
60
- function valibotToJsonSchema(schema: unknown): unknown {
82
+ /** Wrappers that make an object entry optional. */
83
+ const VALIBOT_OPTIONAL = new Set(["exact_optional", "nullish", "optional", "undefinedable"])
84
+
85
+ /** Pipe actions with a JSON Schema equivalent. Anything else constrains nothing in the document. */
86
+ function applyValibotPipe(schema: ValibotSchema, json: Record<string, unknown>): Record<string, unknown> {
87
+ const pipe = schema.pipe as Array<Record<string, unknown>> | undefined
88
+ if (!Array.isArray(pipe)) return json
89
+ const formats: Record<string, string> = {
90
+ email: "email",
91
+ ipv4: "ipv4",
92
+ ipv6: "ipv6",
93
+ iso_date: "date",
94
+ iso_date_time: "date-time",
95
+ iso_timestamp: "date-time",
96
+ url: "uri",
97
+ uuid: "uuid",
98
+ }
99
+ for (const action of pipe.slice(1)) {
100
+ const type = action.type as string
101
+ const req = action.requirement
102
+ if (type in formats) json.format = formats[type]
103
+ else if (type === "integer") json.type = "integer"
104
+ else if (type === "regex" && req instanceof RegExp) json.pattern = req.source
105
+ else if (type === "min_length" && typeof req === "number")
106
+ json[json.type === "array" ? "minItems" : "minLength"] = req
107
+ else if (type === "max_length" && typeof req === "number")
108
+ json[json.type === "array" ? "maxItems" : "maxLength"] = req
109
+ else if (type === "length" && typeof req === "number") {
110
+ json[json.type === "array" ? "minItems" : "minLength"] = req
111
+ json[json.type === "array" ? "maxItems" : "maxLength"] = req
112
+ } else if (type === "min_value" && typeof req === "number") json.minimum = req
113
+ else if (type === "max_value" && typeof req === "number") json.maximum = req
114
+ else if (type === "description" && typeof action.description === "string") json.description = action.description
115
+ }
116
+ return json
117
+ }
118
+
119
+ function valibotObject(s: ValibotSchema, depth: number): Record<string, unknown> {
120
+ const entries = s.entries as Record<string, ValibotSchema>
121
+ if (Object.keys(entries).length === 0 && s.type === "object") return { type: "object" }
122
+ const properties: Record<string, unknown> = {}
123
+ const required: string[] = []
124
+ for (const k of Object.keys(entries)) {
125
+ const entry = entries[k]
126
+ properties[k] = valibotToJsonSchema(entry, depth + 1)
127
+ if (!VALIBOT_OPTIONAL.has(entry.type)) required.push(k)
128
+ }
129
+ const result: Record<string, unknown> = { properties, type: "object" }
130
+ if (required.length > 0) result.required = required
131
+ if (s.type === "strict_object") result.additionalProperties = false
132
+ else if (s.type === "object_with_rest") result.additionalProperties = valibotToJsonSchema(s.rest, depth + 1)
133
+ return result
134
+ }
135
+
136
+ function valibotToJsonSchema(schema: unknown, depth = 0): unknown {
137
+ /* a lazy schema can recurse without end; past this depth the node is unconstrained */
138
+ if (depth > 32) return {}
61
139
  const s = schema as ValibotSchema
62
- const t = s.type
140
+ const walk = (child: unknown): unknown => valibotToJsonSchema(child, depth + 1)
63
141
 
64
- switch (t) {
142
+ switch (s.type) {
65
143
  case "string":
66
- return { type: "string" }
144
+ return applyValibotPipe(s, { type: "string" })
67
145
  case "number":
68
- return { type: "number" }
146
+ return applyValibotPipe(s, { type: "number" })
69
147
  case "boolean":
70
148
  return { type: "boolean" }
71
149
  case "bigint":
72
- return { type: "integer" }
150
+ return { format: "int64", type: "integer" }
73
151
  case "date":
74
152
  return { format: "date-time", type: "string" }
153
+ case "file":
154
+ case "blob":
155
+ return { contentEncoding: "binary", format: "binary", type: "string" }
75
156
  case "undefined":
76
157
  case "void":
77
158
  return {}
@@ -80,69 +161,62 @@ function valibotToJsonSchema(schema: unknown): unknown {
80
161
  case "any":
81
162
  case "unknown":
82
163
  return {}
83
- case "literal": {
84
- const val = s.literal
85
- return { const: val }
86
- }
87
- case "object": {
88
- const entries = s.entries as Record<string, unknown>
89
- const keys = Object.keys(entries)
90
- if (keys.length === 0) return { type: "object" }
91
- const properties: Record<string, unknown> = {}
92
- const required: string[] = []
93
- for (const k of keys) {
94
- const entry = entries[k] as ValibotSchema
95
- if (entry.type === "optional") {
96
- properties[k] = valibotToJsonSchema(entry.wrapped as unknown)
97
- } else {
98
- properties[k] = valibotToJsonSchema(entry)
99
- required.push(k)
100
- }
101
- }
102
- const result: Record<string, unknown> = { properties, type: "object" }
103
- if (required.length > 0) result.required = required
104
- return result
105
- }
164
+ case "literal":
165
+ return { const: s.literal }
166
+ case "object":
167
+ case "strict_object":
168
+ case "loose_object":
169
+ case "object_with_rest":
170
+ return valibotObject(s, depth)
106
171
  case "array":
107
- return { items: valibotToJsonSchema(s.item as unknown), type: "array" }
172
+ return applyValibotPipe(s, { items: walk(s.item), type: "array" })
108
173
  case "optional":
109
- return valibotToJsonSchema(s.wrapped as unknown)
110
- case "nullable": {
111
- const inner = valibotToJsonSchema(s.wrapped as unknown) as Record<string, unknown>
112
- return { anyOf: [inner, { type: "null" }] }
113
- }
114
- case "nullish": {
115
- const inner = valibotToJsonSchema(s.wrapped as unknown) as Record<string, unknown>
116
- return { anyOf: [inner, { type: "null" }] }
174
+ case "exact_optional":
175
+ case "undefinedable":
176
+ case "non_nullable":
177
+ case "non_nullish":
178
+ case "non_optional": {
179
+ const inner = walk(s.wrapped) as Record<string, unknown>
180
+ const fallback = s.default
181
+ return fallback === undefined || typeof fallback === "function" ? inner : { ...inner, default: fallback }
117
182
  }
183
+ case "nullable":
184
+ case "nullish":
185
+ return { anyOf: [walk(s.wrapped), { type: "null" }] }
118
186
  case "union":
119
- return {
120
- anyOf: (s.options as unknown[]).map((o) => valibotToJsonSchema(o)),
121
- }
187
+ case "variant":
188
+ return { anyOf: (s.options as unknown[]).map(walk) }
122
189
  case "intersect":
123
- return {
124
- allOf: (s.options as unknown[]).map((o) => valibotToJsonSchema(o)),
125
- }
190
+ return { allOf: (s.options as unknown[]).map(walk) }
126
191
  case "picklist":
127
192
  return { enum: s.options }
128
193
  case "enum": {
129
- const enumObj = s.enum as Record<string, string>
130
- return { enum: Object.values(enumObj) }
131
- }
132
- case "record": {
194
+ const enumObj = s.enum as Record<string, string | number>
195
+ /* a TS numeric enum carries reverse mappings — keep the values */
133
196
  return {
134
- additionalProperties: valibotToJsonSchema(s.value as unknown),
135
- type: "object",
197
+ enum: Object.keys(enumObj)
198
+ .filter((k) => typeof enumObj[enumObj[k] as string] !== "number")
199
+ .map((k) => enumObj[k]),
136
200
  }
137
201
  }
138
- case "tuple": {
139
- const items = (s.items as unknown[]).map((i) => valibotToJsonSchema(i))
140
- return {
141
- items,
142
- maxItems: items.length,
143
- minItems: items.length,
144
- type: "array",
202
+ case "record":
203
+ return { additionalProperties: walk(s.value), type: "object" }
204
+ case "tuple":
205
+ case "strict_tuple":
206
+ case "loose_tuple":
207
+ case "tuple_with_rest": {
208
+ const items = (s.items as unknown[]).map(walk)
209
+ const result: Record<string, unknown> = { minItems: items.length, prefixItems: items, type: "array" }
210
+ if (s.type === "tuple_with_rest") result.items = walk(s.rest)
211
+ else if (s.type !== "loose_tuple") {
212
+ result.items = false
213
+ result.maxItems = items.length
145
214
  }
215
+ return result
216
+ }
217
+ case "lazy": {
218
+ const getter = s.getter as ((input: unknown) => unknown) | undefined
219
+ return typeof getter === "function" ? walk(getter(undefined)) : {}
146
220
  }
147
221
  default:
148
222
  return {}
@@ -222,9 +296,10 @@ function yupDescBase(desc: YupJsonDesc): unknown {
222
296
  if (!Array.isArray(desc.innerType)) return { type: "array" }
223
297
  const items = desc.innerType.map((i) => yupDescToJsonSchema(i))
224
298
  return {
225
- items,
299
+ items: false,
226
300
  maxItems: items.length,
227
301
  minItems: items.length,
302
+ prefixItems: items,
228
303
  type: "array",
229
304
  }
230
305
  }
@@ -272,16 +347,6 @@ type RouteManifest = {
272
347
  routes: RouteManifestEntry[]
273
348
  }
274
349
 
275
- function extractParams(path: string): string[] {
276
- const params: string[] = []
277
- for (const seg of path.split("/")) {
278
- if (seg.startsWith(":")) {
279
- params.push(seg.endsWith("?") ? seg.slice(1, -1) : seg.slice(1))
280
- }
281
- }
282
- return params
283
- }
284
-
285
350
  type CollectedRoute = {
286
351
  handler: RouteHandler
287
352
  method: string
@@ -292,30 +357,6 @@ function isMetaInternal(handler: RouteHandler): boolean {
292
357
  return (handler.mt as { internal?: boolean } | null)?.internal === true
293
358
  }
294
359
 
295
- function walkTree(node: TreeNode, currentPath: string, routes: CollectedRoute[], includeSkipped?: boolean): void {
296
- if (node.m !== null) {
297
- for (const [method, handler] of Object.entries(node.m)) {
298
- if (handler._skip && !includeSkipped) continue
299
- routes.push({ handler, method, path: currentPath || "/" })
300
- }
301
- }
302
-
303
- for (const [seg, child] of Object.entries(node.s)) {
304
- walkTree(child, `${currentPath}/${seg}`, routes, includeSkipped)
305
- }
306
-
307
- if (node.d !== null) {
308
- walkTree(node.d.c, `${currentPath}/:${node.d.n}`, routes, includeSkipped)
309
- }
310
-
311
- if (node.w !== null) {
312
- for (const [method, handler] of Object.entries(node.w.m)) {
313
- if (handler._skip && !includeSkipped) continue
314
- routes.push({ handler, method, path: `${currentPath}/*${node.w.n}` })
315
- }
316
- }
317
- }
318
-
319
360
  type ErrorInfo = { errorKey: string; status: number; statusKey: string }
320
361
 
321
362
  /* Longest suffix first so `*_not_found` wins over `*_found` (302). */
@@ -384,10 +425,18 @@ function introspectSchema(schema: StandardSchemaLike): unknown {
384
425
 
385
426
  export { normalizeSecurity } from "./meta-spec.ts"
386
427
 
428
+ /** Locale-independent string order (UTF-16 code units), so output is the same on every machine. */
429
+ export function compareCodeUnits(a: string, b: string): number {
430
+ if (a === b) return 0
431
+ return a < b ? -1 : 1
432
+ }
433
+
387
434
  export function canonicalizeSchema(schema: Record<string, unknown>): string {
388
435
  return JSON.stringify(schema, (_, value) => {
389
436
  if (value && typeof value === "object" && !Array.isArray(value)) {
390
- return Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)))
437
+ return Object.fromEntries(
438
+ Object.entries(value as Record<string, unknown>).sort(([a], [b]) => compareCodeUnits(a, b)),
439
+ )
391
440
  }
392
441
  return value
393
442
  })
@@ -395,6 +444,7 @@ export function canonicalizeSchema(schema: Record<string, unknown>): string {
395
444
 
396
445
  type SchemaSlot = {
397
446
  canonical: string
447
+ contentType: string
398
448
  context: SchemaNameContext
399
449
  /* path key from the spec — used for context-based ref rewriting */
400
450
  pathKey: string
@@ -408,6 +458,16 @@ function isObjectSchema(schema: unknown): schema is Record<string, unknown> {
408
458
  return s.type === "object" || (typeof s.properties === "object" && s.properties !== null)
409
459
  }
410
460
 
461
+ /**
462
+ * A response key as a number: `"200"` → 200, `"default"` → 0, `"4XX"` → 400. Never `NaN`, which
463
+ * would name a schema `…ResponseNaN` and make every `default` response collide.
464
+ */
465
+ function statusNumber(key: string): number {
466
+ if (/^[1-5][0-9][0-9]$/.test(key)) return Number(key)
467
+ if (/^[1-5]XX$/i.test(key)) return Number(key[0]) * 100
468
+ return 0
469
+ }
470
+
411
471
  function collectSchemaSlots(paths: Record<string, Record<string, Record<string, unknown>>>): SchemaSlot[] {
412
472
  const slots: SchemaSlot[] = []
413
473
 
@@ -420,11 +480,11 @@ function collectSchemaSlots(paths: Record<string, Record<string, Record<string,
420
480
  if (requestBody) {
421
481
  const content = requestBody.content as Record<string, Record<string, unknown>> | undefined
422
482
  if (content) {
423
- for (const mediaType of Object.values(content)) {
483
+ for (const [contentType, mediaType] of Object.entries(content)) {
424
484
  const schema = mediaType.schema as Record<string, unknown> | undefined
425
485
  if (schema && !schema.$ref) {
426
486
  const context: SchemaNameContext = { method: m, path: pathKey, role: "request" }
427
- slots.push({ canonical: canonicalizeSchema(schema), context, pathKey, schema })
487
+ slots.push({ canonical: canonicalizeSchema(schema), contentType, context, pathKey, schema })
428
488
  }
429
489
  }
430
490
  }
@@ -437,17 +497,17 @@ function collectSchemaSlots(paths: Record<string, Record<string, Record<string,
437
497
  | Record<string, Record<string, unknown>>
438
498
  | undefined
439
499
  if (content) {
440
- for (const mediaType of Object.values(content)) {
500
+ for (const [contentType, mediaType] of Object.entries(content)) {
441
501
  const schema = mediaType.schema as Record<string, unknown> | undefined
442
502
  if (schema && !schema.$ref) {
443
- const statusNum = Number(status)
503
+ const statusNum = statusNumber(status)
444
504
  const context: SchemaNameContext = {
445
505
  method: m,
446
506
  path: pathKey,
447
507
  role: "response",
448
508
  status: statusNum,
449
509
  }
450
- slots.push({ canonical: canonicalizeSchema(schema), context, pathKey, schema })
510
+ slots.push({ canonical: canonicalizeSchema(schema), contentType, context, pathKey, schema })
451
511
  }
452
512
  }
453
513
  }
@@ -473,6 +533,20 @@ type HoistedResult = {
473
533
  schemas: Record<string, Record<string, unknown>>
474
534
  }
475
535
 
536
+ /**
537
+ * A component name nobody holds yet. A clash gets the content hash, and a clash on the
538
+ * hashed name (24 bits collide) a counter — a name is never handed out twice.
539
+ */
540
+ function uniqueName(base: string, canonical: string, taken: (name: string) => boolean): string {
541
+ if (!taken(base)) return base
542
+ const hashed = `${base}_${shortHash(canonical)}`
543
+ if (!taken(hashed)) return hashed
544
+ for (let n = 2; ; n++) {
545
+ const candidate = `${hashed}_${n}`
546
+ if (!taken(candidate)) return candidate
547
+ }
548
+ }
549
+
476
550
  function walkNestedObjects(
477
551
  schema: Record<string, unknown>,
478
552
  slotName: string,
@@ -482,7 +556,8 @@ function walkNestedObjects(
482
556
  ): void {
483
557
  const props = schema.properties as Record<string, unknown> | undefined
484
558
  if (!props) return
485
- for (const [key, val] of Object.entries(props)) {
559
+ for (const key of Object.keys(props)) {
560
+ const val = props[key]
486
561
  if (!isObjectSchema(val)) continue
487
562
  const child = val as Record<string, unknown>
488
563
  const childPath = [...fieldPath, key]
@@ -505,7 +580,7 @@ function walkNestedObjects(
505
580
  function collectHoistedSchemas(
506
581
  slots: SchemaSlot[],
507
582
  slotNames: Map<SchemaSlot, string>,
508
- reservedNames: Set<string>,
583
+ taken: (name: string) => boolean,
509
584
  ): HoistedResult {
510
585
  const nestedMap = new Map<string, NestedEntry>()
511
586
  for (const slot of slots) {
@@ -529,19 +604,25 @@ function collectHoistedSchemas(
529
604
  const owner = entry.samples.slice().sort((a, b) => {
530
605
  const na = a.slotName + a.fieldPath.join("")
531
606
  const nb = b.slotName + b.fieldPath.join("")
532
- return na.localeCompare(nb)
607
+ return compareCodeUnits(na, nb)
533
608
  })[0]
534
609
 
535
- const fieldPart = owner.fieldPath.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join("")
536
- let name = `${owner.slotName}${fieldPart}`
537
- if (reservedNames.has(name)) name = `${name}_${shortHash(canonical)}`
538
- if (schemas[name]) continue
539
-
610
+ const fieldPart = owner.fieldPath
611
+ .map((f) => sanitizeComponentName(f))
612
+ .map((f) => f.charAt(0).toUpperCase() + f.slice(1))
613
+ .join("")
614
+ const name = uniqueName(
615
+ sanitizeComponentName(`${owner.slotName}${fieldPart}`),
616
+ canonical,
617
+ (n) => taken(n) || Object.hasOwn(schemas, n),
618
+ )
540
619
  schemas[name] = entry.schema
541
620
  canonicalToName.set(canonical, name)
542
- reservedNames.add(name)
543
621
  }
544
622
 
623
+ /* nested hoists inside a hoisted schema point at their component too */
624
+ for (const name of Object.keys(schemas)) schemas[name] = rewriteNestedRefs(schemas[name], canonicalToName)
625
+
545
626
  return { canonicalToName, schemas }
546
627
  }
547
628
 
@@ -549,7 +630,8 @@ function collectHoistedSchemas(
549
630
  * Builds a new paths object by shallow-copying only the containers that need schema
550
631
  * replacement. Avoids structuredClone (which fails on arktype schemas containing
551
632
  * native functions). Original schema objects are dropped; only $ref objects are new.
552
- * Also rewrites nested inline object properties that were hoisted (canonicalToName).
633
+ * Also rewrites nested inline object properties that were hoisted (canonicalToName),
634
+ * at any depth — a hoisted name nothing points at would be an orphan component.
553
635
  */
554
636
 
555
637
  function rewriteNestedRefs(
@@ -559,40 +641,72 @@ function rewriteNestedRefs(
559
641
  const props = schema.properties as Record<string, unknown> | undefined
560
642
  if (!props) return schema
561
643
  let newProps: Record<string, unknown> | undefined
562
- for (const [key, val] of Object.entries(props)) {
644
+ for (const key of Object.keys(props)) {
645
+ const val = props[key]
563
646
  if (!isObjectSchema(val)) continue
564
647
  const child = val as Record<string, unknown>
565
648
  const name = canonicalToName.get(canonicalizeSchema(child))
566
- if (!name) continue
649
+ const next = name ? { $ref: `#/components/schemas/${name}` } : rewriteNestedRefs(child, canonicalToName)
650
+ if (next === child) continue
567
651
  if (!newProps) newProps = { ...props }
568
- newProps[key] = { $ref: `#/components/schemas/${name}` }
652
+ newProps[key] = next
569
653
  }
570
654
  if (!newProps) return schema
571
655
  return { ...schema, properties: newProps }
572
656
  }
573
657
 
658
+ function slotKey(
659
+ pathKey: string,
660
+ method: string,
661
+ role: string,
662
+ status: number | undefined,
663
+ contentType: string,
664
+ ): string {
665
+ return `${pathKey}::${method}::${role}::${status ?? ""}::${contentType}`
666
+ }
667
+
574
668
  function rewritePathsToRefs(
575
669
  paths: Record<string, Record<string, Record<string, unknown>>>,
576
670
  slots: SchemaSlot[],
577
671
  slotNames: Map<SchemaSlot, string>,
578
672
  hoistedCanonicalToName: Map<string, string>,
579
673
  ): Record<string, Record<string, Record<string, unknown>>> {
580
- const ctxKey = (pathKey: string, method: string, role: string, status?: number): string =>
581
- `${pathKey}::${method}::${role}::${status ?? ""}`
582
-
674
+ /* keyed by operation + status + content type: JSON and XML bodies of one status are two slots */
583
675
  const lookup = new Map<string, string>()
584
676
  for (const slot of slots) {
585
677
  const name = slotNames.get(slot)
586
- if (name)
587
- lookup.set(
588
- ctxKey(
589
- slot.pathKey,
590
- slot.context.method,
591
- slot.context.role,
592
- slot.context.role === "response" ? slot.context.status : undefined,
593
- ),
594
- name,
595
- )
678
+ if (!name) continue
679
+ const status = slot.context.role === "response" ? slot.context.status : undefined
680
+ lookup.set(slotKey(slot.pathKey, slot.context.method, slot.context.role, status, slot.contentType), name)
681
+ }
682
+
683
+ const rewriteContent = (
684
+ content: Record<string, Record<string, unknown>>,
685
+ keyFor: (ct: string) => string,
686
+ ): Record<string, Record<string, unknown>> | null => {
687
+ const newContent: Record<string, Record<string, unknown>> = {}
688
+ let changed = false
689
+ for (const [ct, mediaType] of Object.entries(content)) {
690
+ const schema = mediaType.schema as Record<string, unknown> | undefined
691
+ if (!schema || schema.$ref) {
692
+ newContent[ct] = mediaType
693
+ continue
694
+ }
695
+ const name = lookup.get(keyFor(ct))
696
+ if (name) {
697
+ newContent[ct] = { ...mediaType, schema: { $ref: `#/components/schemas/${name}` } }
698
+ changed = true
699
+ continue
700
+ }
701
+ const rewritten = hoistedCanonicalToName.size > 0 ? rewriteNestedRefs(schema, hoistedCanonicalToName) : schema
702
+ if (rewritten !== schema) {
703
+ newContent[ct] = { ...mediaType, schema: rewritten }
704
+ changed = true
705
+ } else {
706
+ newContent[ct] = mediaType
707
+ }
708
+ }
709
+ return changed ? newContent : null
596
710
  }
597
711
 
598
712
  const newPaths: Record<string, Record<string, Record<string, unknown>>> = {}
@@ -606,76 +720,23 @@ function rewritePathsToRefs(
606
720
  let newOp = op
607
721
 
608
722
  const requestBody = op.requestBody as Record<string, unknown> | undefined
609
- if (requestBody) {
610
- const content = requestBody.content as Record<string, Record<string, unknown>> | undefined
611
- if (content) {
612
- const reqName = lookup.get(ctxKey(pathKey, m, "request"))
613
- const newContent: Record<string, Record<string, unknown>> = {}
614
- for (const [ct, mediaType] of Object.entries(content)) {
615
- const schema = mediaType.schema as Record<string, unknown> | undefined
616
- if (schema && !schema.$ref) {
617
- if (reqName) {
618
- newContent[ct] = {
619
- ...mediaType,
620
- schema: { $ref: `#/components/schemas/${reqName}` },
621
- }
622
- } else if (hoistedCanonicalToName.size > 0) {
623
- newContent[ct] = {
624
- ...mediaType,
625
- schema: rewriteNestedRefs(schema, hoistedCanonicalToName),
626
- }
627
- } else {
628
- newContent[ct] = mediaType
629
- }
630
- } else {
631
- newContent[ct] = mediaType
632
- }
633
- }
634
- if (Object.keys(newContent).length > 0) {
635
- newOp = { ...op, requestBody: { ...requestBody, content: newContent } }
636
- }
637
- }
723
+ const reqContent = requestBody?.content as Record<string, Record<string, unknown>> | undefined
724
+ if (requestBody && reqContent) {
725
+ const next = rewriteContent(reqContent, (ct) => slotKey(pathKey, m, "request", undefined, ct))
726
+ if (next) newOp = { ...op, requestBody: { ...requestBody, content: next } }
638
727
  }
639
728
 
640
- const responses = (newOp as Record<string, unknown>).responses as
641
- | Record<string, Record<string, unknown>>
642
- | undefined
729
+ const responses = newOp.responses as Record<string, Record<string, unknown>> | undefined
643
730
  if (responses) {
644
731
  let newResponses: Record<string, Record<string, unknown>> | undefined
645
732
  for (const [status, response] of Object.entries(responses)) {
646
- const content = (response as Record<string, unknown>).content as
647
- | Record<string, Record<string, unknown>>
648
- | undefined
733
+ const content = response.content as Record<string, Record<string, unknown>> | undefined
649
734
  if (!content) continue
650
- const statusNum = Number(status)
651
- const respName = lookup.get(ctxKey(pathKey, m, "response", statusNum))
652
- const newContent: Record<string, Record<string, unknown>> = {}
653
- let changed = false
654
- for (const [ct, mediaType] of Object.entries(content)) {
655
- const schema = mediaType.schema as Record<string, unknown> | undefined
656
- if (schema && !schema.$ref) {
657
- if (respName) {
658
- newContent[ct] = {
659
- ...mediaType,
660
- schema: { $ref: `#/components/schemas/${respName}` },
661
- }
662
- changed = true
663
- } else if (hoistedCanonicalToName.size > 0) {
664
- const rewritten = rewriteNestedRefs(schema, hoistedCanonicalToName)
665
- newContent[ct] = { ...mediaType, schema: rewritten }
666
- if (rewritten !== schema) changed = true
667
- else newContent[ct] = mediaType
668
- } else {
669
- newContent[ct] = mediaType
670
- }
671
- } else {
672
- newContent[ct] = mediaType
673
- }
674
- }
675
- if (changed) {
676
- if (!newResponses) newResponses = { ...responses }
677
- newResponses[status] = { ...(response as Record<string, unknown>), content: newContent }
678
- }
735
+ const statusNum = statusNumber(status)
736
+ const next = rewriteContent(content, (ct) => slotKey(pathKey, m, "response", statusNum, ct))
737
+ if (!next) continue
738
+ if (!newResponses) newResponses = { ...responses }
739
+ newResponses[status] = { ...response, content: next }
679
740
  }
680
741
  if (newResponses) newOp = { ...newOp, responses: newResponses }
681
742
  }
@@ -689,18 +750,35 @@ function rewritePathsToRefs(
689
750
  return newPaths
690
751
  }
691
752
 
753
+ /** Every `#/components/schemas/<name>` a value refers to. */
754
+ function collectComponentRefs(value: unknown, out: Set<string>): void {
755
+ if (Array.isArray(value)) {
756
+ for (const item of value) collectComponentRefs(item, out)
757
+ return
758
+ }
759
+ if (value === null || typeof value !== "object") return
760
+ for (const [key, child] of Object.entries(value)) {
761
+ if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) {
762
+ out.add(child.slice("#/components/schemas/".length))
763
+ } else {
764
+ collectComponentRefs(child, out)
765
+ }
766
+ }
767
+ }
768
+
692
769
  export function deduplicateSchemas(spec: OpenApiSpec): OpenApiSpec {
693
770
  /* Sort paths lexicographically for determinism — input order must not affect output. */
694
771
  const sortedPaths: Record<string, Record<string, Record<string, unknown>>> = {}
695
772
  for (const key of Object.keys(spec.paths).sort()) sortedPaths[key] = spec.paths[key]
696
773
 
697
774
  const slots = collectSchemaSlots(sortedPaths)
775
+ const existing = spec.components?.schemas ?? {}
698
776
 
699
777
  /* Group slots by canonical shape — same shape = one component. */
700
778
  const groups = new Map<string, SchemaSlot[]>()
701
779
  for (const slot of slots) {
702
- const existing = groups.get(slot.canonical)
703
- if (existing) existing.push(slot)
780
+ const group = groups.get(slot.canonical)
781
+ if (group) group.push(slot)
704
782
  else groups.set(slot.canonical, [slot])
705
783
  }
706
784
 
@@ -709,29 +787,28 @@ export function deduplicateSchemas(spec: OpenApiSpec): OpenApiSpec {
709
787
  * Tier 1: unique shape (group.length === 1) → operation-derived name
710
788
  * Tier 2: shared error envelope → Err{status}* name
711
789
  * Tier 3: shared non-error → first-slot operation-derived name
712
- * Collision (two canonicals → same tier name) → hash suffix on second.
790
+ * Collision (two canonicals → same tier name, or a name an existing component holds) →
791
+ * hash suffix, re-checked until free.
713
792
  */
714
793
  const canonicalToName = new Map<string, string>()
715
794
  const nameToCanonical = new Map<string, string>()
716
795
 
717
796
  for (const [canonical, groupSlots] of groups) {
718
797
  const firstSlot = groupSlots[0]
719
- let name: string
798
+ let base: string
720
799
 
721
- if (groupSlots.length === 1) {
722
- name = deriveSchemaName(firstSlot.context)
723
- } else if (isErrorEnvelope(firstSlot.schema)) {
800
+ if (groupSlots.length > 1 && isErrorEnvelope(firstSlot.schema)) {
724
801
  const fallbackStatus = firstSlot.context.role === "response" ? firstSlot.context.status : undefined
725
- name = deriveErrorEnvelopeName(firstSlot.schema, fallbackStatus)
802
+ base = deriveErrorEnvelopeName(firstSlot.schema, fallbackStatus)
726
803
  } else {
727
- name = deriveSchemaName(firstSlot.context)
804
+ base = deriveSchemaName(firstSlot.context)
728
805
  }
729
806
 
730
- /* Collision: different canonical claims same name → suffix second with hash. */
731
- const claimed = nameToCanonical.get(name)
732
- if (claimed !== undefined && claimed !== canonical) {
733
- name = `${name}_${shortHash(canonical)}`
734
- }
807
+ const name = uniqueName(
808
+ base,
809
+ canonical,
810
+ (n) => Object.hasOwn(existing, n) || (nameToCanonical.has(n) && nameToCanonical.get(n) !== canonical),
811
+ )
735
812
  nameToCanonical.set(name, canonical)
736
813
  canonicalToName.set(canonical, name)
737
814
  }
@@ -746,27 +823,23 @@ export function deduplicateSchemas(spec: OpenApiSpec): OpenApiSpec {
746
823
  /* Nested hoisting: collect shared nested objects, then rewrite inline → $ref.
747
824
  * Error envelope slots (any tier) mark their nested schemas as envelope-owned so
748
825
  * envelope-exclusive nesting (e.g. `fields`) is not hoisted under a per-spec name. */
749
- const allTopLevelNames = new Set(slotNames.values())
750
826
  const { schemas: hoistedSchemas, canonicalToName: hoistedCanonicalToName } = collectHoistedSchemas(
751
827
  slots,
752
828
  slotNames,
753
- allTopLevelNames,
829
+ (n) => Object.hasOwn(existing, n) || nameToCanonical.has(n),
754
830
  )
755
831
 
756
832
  /* Build components map, rewriting nested fields → $refs where hoisted */
757
833
  const extractedSchemas: Record<string, Record<string, unknown>> = {}
758
- const seenNames = new Set<string>()
759
834
  for (const slot of slots) {
760
835
  const name = slotNames.get(slot)
761
- if (name && !seenNames.has(name)) {
762
- seenNames.add(name)
836
+ if (name && !Object.hasOwn(extractedSchemas, name)) {
763
837
  extractedSchemas[name] =
764
838
  hoistedCanonicalToName.size > 0 ? rewriteNestedRefs(slot.schema, hoistedCanonicalToName) : slot.schema
765
839
  }
766
840
  }
767
- Object.assign(extractedSchemas, hoistedSchemas)
768
841
 
769
- const hasNewSchemas = Object.keys(extractedSchemas).length > 0
842
+ const hasNewSchemas = Object.keys(extractedSchemas).length > 0 || Object.keys(hoistedSchemas).length > 0
770
843
  /* Nothing to extract and no existing components — return unchanged */
771
844
  if (!hasNewSchemas && !spec.components) return spec
772
845
 
@@ -775,7 +848,17 @@ export function deduplicateSchemas(spec: OpenApiSpec): OpenApiSpec {
775
848
  ? rewritePathsToRefs(sortedPaths, slots, slotNames, hoistedCanonicalToName)
776
849
  : sortedPaths
777
850
 
778
- const mergedSchemas = { ...spec.components?.schemas, ...extractedSchemas }
851
+ const mergedSchemas: Record<string, Record<string, unknown>> = { ...existing, ...extractedSchemas }
852
+
853
+ /* a hoisted nested schema whose every user became a component $ref is still referenced from
854
+ that component; one referenced from nowhere is dropped */
855
+ const referenced = new Set<string>()
856
+ collectComponentRefs(paths, referenced)
857
+ collectComponentRefs(mergedSchemas, referenced)
858
+ for (const [name, schema] of Object.entries(hoistedSchemas)) {
859
+ if (referenced.has(name)) mergedSchemas[name] = schema
860
+ }
861
+
779
862
  const components: OpenApiSpec["components"] = {
780
863
  ...spec.components,
781
864
  ...(Object.keys(mergedSchemas).length > 0 ? { schemas: mergedSchemas } : {}),
@@ -834,35 +917,53 @@ function cloneJson<T>(value: T): T {
834
917
  }
835
918
  }
836
919
 
920
+ /* Inlining expands each $ref; a graph of shared refs can grow exponentially (`L30` with two
921
+ fields that both point at `L29`, …), so the output is bounded and generation fails loudly. */
922
+ const MAX_INLINED_SCHEMA_NODES = 200_000
923
+
924
+ type InlineBudget = { nodes: number }
925
+
926
+ function chargeInline(budget: InlineBudget): void {
927
+ if (++budget.nodes > MAX_INLINED_SCHEMA_NODES) {
928
+ throw new Error(
929
+ `SDK codegen: inlined schemas exceed ${MAX_INLINED_SCHEMA_NODES} nodes (deeply shared $refs); ` +
930
+ "flatten the shared component chain",
931
+ )
932
+ }
933
+ }
934
+
837
935
  function resolveSchema(
838
936
  schema: Record<string, unknown>,
839
937
  schemas: Record<string, Record<string, unknown>>,
938
+ budget: InlineBudget,
840
939
  visited = new Set<string>(),
841
940
  ): Record<string, unknown> {
842
941
  if (typeof schema.$ref === "string" && schema.$ref.startsWith("#/components/schemas/")) {
843
942
  const name = schema.$ref.slice("#/components/schemas/".length)
844
943
  /* circular ref guard — return empty schema which maps to unknown in IR */
845
944
  if (visited.has(name)) return {}
846
- const resolved = schemas[name]
945
+ const resolved = Object.hasOwn(schemas, name) ? schemas[name] : undefined
847
946
  if (!resolved) throw new Error(`$ref points to nonexistent component: ${schema.$ref}`)
848
947
  const next = new Set(visited)
849
948
  next.add(name)
850
- return resolveSchemaDeep(cloneJson(resolved), schemas, next)
949
+ return resolveSchemaDeep(cloneJson(resolved), schemas, budget, next)
851
950
  }
852
- return resolveSchemaDeep(schema, schemas, visited)
951
+ return resolveSchemaDeep(schema, schemas, budget, visited)
853
952
  }
854
953
 
855
954
  function resolveSchemaDeep(
856
955
  schema: Record<string, unknown>,
857
956
  schemas: Record<string, Record<string, unknown>>,
957
+ budget: InlineBudget,
858
958
  visited: Set<string>,
859
959
  ): Record<string, unknown> {
960
+ chargeInline(budget)
860
961
  for (const key of ["oneOf", "anyOf", "allOf"] as const) {
861
962
  const arr = schema[key]
862
963
  if (Array.isArray(arr)) {
863
964
  schema[key] = arr.map((item: unknown) => {
864
965
  if (item && typeof item === "object" && !Array.isArray(item)) {
865
- return resolveSchema(item as Record<string, unknown>, schemas, visited)
966
+ return resolveSchema(item as Record<string, unknown>, schemas, budget, visited)
866
967
  }
867
968
  return item
868
969
  })
@@ -870,14 +971,14 @@ function resolveSchemaDeep(
870
971
  }
871
972
  /* resolve array items */
872
973
  if (schema.items && typeof schema.items === "object" && !Array.isArray(schema.items)) {
873
- schema.items = resolveSchema(schema.items as Record<string, unknown>, schemas, visited)
974
+ schema.items = resolveSchema(schema.items as Record<string, unknown>, schemas, budget, visited)
874
975
  }
875
976
  /* resolve object properties */
876
977
  if (schema.properties && typeof schema.properties === "object") {
877
978
  const props = schema.properties as Record<string, unknown>
878
979
  for (const [k, v] of Object.entries(props)) {
879
980
  if (v && typeof v === "object" && !Array.isArray(v)) {
880
- props[k] = resolveSchema(v as Record<string, unknown>, schemas, visited)
981
+ props[k] = resolveSchema(v as Record<string, unknown>, schemas, budget, visited)
881
982
  }
882
983
  }
883
984
  }
@@ -890,6 +991,7 @@ function resolveSchemaDeep(
890
991
  schema.additionalProperties = resolveSchema(
891
992
  schema.additionalProperties as Record<string, unknown>,
892
993
  schemas,
994
+ budget,
893
995
  visited,
894
996
  )
895
997
  }
@@ -901,6 +1003,7 @@ export function resolveRefs(spec: OpenApiSpecInput): OpenApiSpecInput {
901
1003
  if (!schemas || Object.keys(schemas).length === 0) return spec
902
1004
 
903
1005
  const paths = cloneJson(spec.paths)
1006
+ const budget: InlineBudget = { nodes: 0 }
904
1007
 
905
1008
  for (const methods of Object.values(paths)) {
906
1009
  for (const operation of Object.values(methods)) {
@@ -912,7 +1015,7 @@ export function resolveRefs(spec: OpenApiSpecInput): OpenApiSpecInput {
912
1015
  if (content) {
913
1016
  for (const [ct, mediaType] of Object.entries(content)) {
914
1017
  const schema = mediaType.schema as Record<string, unknown> | undefined
915
- if (schema) content[ct] = { ...mediaType, schema: resolveSchema(schema, schemas) }
1018
+ if (schema) content[ct] = { ...mediaType, schema: resolveSchema(schema, schemas, budget) }
916
1019
  }
917
1020
  }
918
1021
  }
@@ -921,20 +1024,14 @@ export function resolveRefs(spec: OpenApiSpecInput): OpenApiSpecInput {
921
1024
  if (responses) {
922
1025
  for (const [status, response] of Object.entries(responses)) {
923
1026
  const content = response.content as Record<string, Record<string, unknown>> | undefined
924
- if (content) {
925
- for (const [ct, mediaType] of Object.entries(content)) {
926
- const schema = mediaType.schema as Record<string, unknown> | undefined
927
- if (schema) {
928
- responses[status] = {
929
- ...response,
930
- content: {
931
- ...content,
932
- [ct]: { ...mediaType, schema: resolveSchema(schema, schemas) },
933
- },
934
- }
935
- }
936
- }
1027
+ if (!content) continue
1028
+ /* every content type resolved, not only the last one written */
1029
+ const resolved: Record<string, Record<string, unknown>> = {}
1030
+ for (const [ct, mediaType] of Object.entries(content)) {
1031
+ const schema = mediaType.schema as Record<string, unknown> | undefined
1032
+ resolved[ct] = schema ? { ...mediaType, schema: resolveSchema(schema, schemas, budget) } : mediaType
937
1033
  }
1034
+ responses[status] = { ...response, content: resolved }
938
1035
  }
939
1036
  }
940
1037
  }
@@ -946,25 +1043,60 @@ export function resolveRefs(spec: OpenApiSpecInput): OpenApiSpecInput {
946
1043
  return { ...spec, components, paths }
947
1044
  }
948
1045
 
1046
+ /**
1047
+ * Zod node kinds JSON Schema has no word for. Zod emits `{}` for each of them under
1048
+ * `unrepresentable: "any"`; the degrade is per node, and the rest of the schema survives.
1049
+ */
1050
+ const ZOD_UNREPRESENTABLE = new Set(["custom", "function", "map", "nan", "promise", "set", "symbol", "transform"])
1051
+
1052
+ type ZodOverrideCtx = { jsonSchema: Record<string, unknown>; zodSchema: unknown }
1053
+
1054
+ /** `override` for Zod's converter: give `Date` and `BigInt` their wire shape, record what degraded. */
1055
+ function zodOverride(degraded: Set<string>): (ctx: ZodOverrideCtx) => void {
1056
+ return (ctx) => {
1057
+ const def = zodDefOf(ctx.zodSchema)
1058
+ const kind = String(def?.type ?? "")
1059
+ if (kind === "date") {
1060
+ /* JSON.stringify writes a Date as an ISO string */
1061
+ ctx.jsonSchema.format = "date-time"
1062
+ ctx.jsonSchema.type = "string"
1063
+ return
1064
+ }
1065
+ if (kind === "bigint") {
1066
+ ctx.jsonSchema.format = "int64"
1067
+ ctx.jsonSchema.type = "integer"
1068
+ return
1069
+ }
1070
+ if (ZOD_UNREPRESENTABLE.has(kind) && Object.keys(ctx.jsonSchema).length === 0) degraded.add(kind)
1071
+ }
1072
+ }
1073
+
949
1074
  /** Zod 4 puts converters on the schema instance. Use those first so Workers
950
1075
  * (where `import("zod")` does not resolve) still emit real JSON Schema. */
951
1076
  function zodInstanceToJsonSchema(
952
1077
  schema: StandardSchemaLike,
953
1078
  io: "input" | "output",
1079
+ degraded: Set<string>,
954
1080
  ): Record<string, unknown> | undefined {
1081
+ const libraryOptions = { override: zodOverride(degraded), unrepresentable: "any" as const }
955
1082
  const jsonSchema = (
956
1083
  schema["~standard"] as {
957
- jsonSchema?: { input?: () => unknown; output?: () => unknown }
1084
+ jsonSchema?: Record<"input" | "output", ((params?: unknown) => unknown) | undefined>
958
1085
  }
959
1086
  ).jsonSchema
960
1087
  const fromStandard = jsonSchema?.[io]
961
1088
  if (typeof fromStandard === "function") {
962
- const result = fromStandard()
1089
+ const result = fromStandard({ libraryOptions, target: "draft-2020-12" })
963
1090
  if (result && typeof result === "object") return result as Record<string, unknown>
964
1091
  }
965
- const inst = (schema as { toJSONSchema?: (opts?: { io?: "input" | "output" }) => unknown }).toJSONSchema
1092
+ const inst = (schema as { toJSONSchema?: (opts?: Record<string, unknown>) => unknown }).toJSONSchema
966
1093
  if (typeof inst === "function") {
967
- const result = inst.call(schema, { io })
1094
+ const result = inst.call(schema, { ...libraryOptions, io })
1095
+ if (result && typeof result === "object") return result as Record<string, unknown>
1096
+ }
1097
+ if (toJSONSchemaFn) {
1098
+ const convert = toJSONSchemaFn as (schema: unknown, opts: Record<string, unknown>) => unknown
1099
+ const result = convert(schema, { ...libraryOptions, io })
968
1100
  if (result && typeof result === "object") return result as Record<string, unknown>
969
1101
  }
970
1102
  return undefined
@@ -997,32 +1129,47 @@ function zodDefOf(schema: unknown): Record<string, unknown> | undefined {
997
1129
  return s._def ?? s.def ?? s._zod?.def
998
1130
  }
999
1131
 
1000
- function zodDefIsOptional(def: Record<string, unknown>): boolean {
1132
+ function zodDefIsOptional(def: Record<string, unknown>, io: "input" | "output"): boolean {
1001
1133
  const t = String(def.typeName ?? def.type ?? "")
1002
1134
  if (t === "ZodOptional" || t === "optional") return true
1135
+ /* a default fills the value in: optional on the way in, always present on the way out */
1136
+ if ((t === "ZodDefault" || t === "default" || t === "prefault") && io === "input") return true
1003
1137
  if (
1004
1138
  t === "ZodNullable" ||
1005
1139
  t === "nullable" ||
1006
1140
  t === "ZodDefault" ||
1007
1141
  t === "default" ||
1142
+ t === "prefault" ||
1008
1143
  t === "ZodCatch" ||
1009
- t === "catch"
1144
+ t === "catch" ||
1145
+ t === "ZodReadonly" ||
1146
+ t === "readonly"
1010
1147
  ) {
1011
1148
  const inner = def.innerType
1012
1149
  if (inner) {
1013
1150
  const innerDef = zodDefOf(inner)
1014
- if (innerDef) return zodDefIsOptional(innerDef)
1151
+ if (innerDef) return zodDefIsOptional(innerDef, io)
1015
1152
  }
1016
1153
  }
1017
1154
  return false
1018
1155
  }
1019
1156
 
1157
+ function literalSchema(values: readonly unknown[]): Record<string, unknown> {
1158
+ if (values.length === 1) return { const: values[0] }
1159
+ return { enum: [...values] }
1160
+ }
1161
+
1020
1162
  /** Walk Zod 3/4 internals. Survives Workers bundles that drop `toJSONSchema`. */
1021
- function zodDefToJsonSchema(schema: unknown, depth = 0): Record<string, unknown> | undefined {
1163
+ function zodDefToJsonSchema(
1164
+ schema: unknown,
1165
+ io: "input" | "output" = "output",
1166
+ depth = 0,
1167
+ ): Record<string, unknown> | undefined {
1022
1168
  if (depth > 24) return {}
1023
1169
  const def = zodDefOf(schema)
1024
1170
  if (!def) return undefined
1025
1171
  const typeName = String(def.typeName ?? def.type ?? "")
1172
+ const walk = (child: unknown): Record<string, unknown> => zodDefToJsonSchema(child, io, depth + 1) ?? {}
1026
1173
 
1027
1174
  switch (typeName) {
1028
1175
  case "ZodString":
@@ -1036,7 +1183,7 @@ function zodDefToJsonSchema(schema: unknown, depth = 0): Record<string, unknown>
1036
1183
  return { type: "boolean" }
1037
1184
  case "ZodBigInt":
1038
1185
  case "bigint":
1039
- return { type: "integer" }
1186
+ return { format: "int64", type: "integer" }
1040
1187
  case "ZodDate":
1041
1188
  case "date":
1042
1189
  return { format: "date-time", type: "string" }
@@ -1058,14 +1205,25 @@ function zodDefToJsonSchema(schema: unknown, depth = 0): Record<string, unknown>
1058
1205
  return {}
1059
1206
  case "ZodLiteral":
1060
1207
  case "literal": {
1061
- const val = def.value !== undefined ? def.value : (def.values as unknown[])?.[0]
1062
- return { const: val }
1208
+ const values = Array.isArray(def.values) ? (def.values as unknown[]) : [def.value]
1209
+ return literalSchema(values.map((v) => (typeof v === "bigint" ? Number(v) : v)))
1063
1210
  }
1064
1211
  case "ZodEnum":
1065
1212
  case "enum": {
1066
- const vals = (def.values ?? def.entries) as unknown
1067
- const list = Array.isArray(vals) ? vals : Object.values(vals as Record<string, string>)
1068
- return { enum: list, type: "string" }
1213
+ const raw = (def.values ?? def.entries) as unknown
1214
+ let list: unknown[]
1215
+ if (Array.isArray(raw)) list = raw
1216
+ else {
1217
+ /* a TS numeric enum carries reverse mappings: { A: 0, "0": "A" } — keep the values */
1218
+ const entries = raw as Record<string, unknown>
1219
+ list = Object.keys(entries)
1220
+ .filter((k) => typeof entries[entries[k] as string] !== "number")
1221
+ .map((k) => entries[k])
1222
+ }
1223
+ const types = new Set(list.map((v) => typeof v))
1224
+ if (types.size === 1 && types.has("string")) return { enum: list, type: "string" }
1225
+ if (types.size === 1 && types.has("number")) return { enum: list, type: "number" }
1226
+ return { enum: list }
1069
1227
  }
1070
1228
  case "ZodObject":
1071
1229
  case "object": {
@@ -1074,60 +1232,88 @@ function zodDefToJsonSchema(schema: unknown, depth = 0): Record<string, unknown>
1074
1232
  if (!shape) return { type: "object" }
1075
1233
  const properties: Record<string, unknown> = {}
1076
1234
  const required: string[] = []
1077
- for (const [key, prop] of Object.entries(shape)) {
1078
- const converted = zodDefToJsonSchema(prop, depth + 1) ?? {}
1079
- properties[key] = converted
1235
+ for (const key of Object.keys(shape)) {
1236
+ const prop = shape[key]
1237
+ properties[key] = walk(prop)
1080
1238
  const propDef = zodDefOf(prop)
1081
- if (!propDef || !zodDefIsOptional(propDef)) required.push(key)
1239
+ if (!propDef || !zodDefIsOptional(propDef, io)) required.push(key)
1082
1240
  }
1083
- const result: Record<string, unknown> = { additionalProperties: false, properties, type: "object" }
1241
+ const result: Record<string, unknown> = { properties, type: "object" }
1242
+ if (io === "output") result.additionalProperties = false
1084
1243
  if (required.length > 0) result.required = required
1085
1244
  return result
1086
1245
  }
1087
1246
  case "ZodArray":
1088
1247
  case "array":
1089
- return { items: zodDefToJsonSchema(def.element ?? def.type, depth + 1) ?? {}, type: "array" }
1248
+ return { items: walk(def.element ?? def.type), type: "array" }
1090
1249
  case "ZodOptional":
1091
1250
  case "optional":
1092
- return zodDefToJsonSchema(def.innerType, depth + 1)
1251
+ return zodDefToJsonSchema(def.innerType, io, depth + 1)
1093
1252
  case "ZodNullable":
1094
- case "nullable": {
1095
- const inner = zodDefToJsonSchema(def.innerType, depth + 1) ?? {}
1096
- return { anyOf: [inner, { type: "null" }] }
1097
- }
1253
+ case "nullable":
1254
+ return { anyOf: [walk(def.innerType), { type: "null" }] }
1098
1255
  case "ZodUnion":
1099
1256
  case "ZodDiscriminatedUnion":
1100
1257
  case "union":
1101
- return { anyOf: ((def.options as unknown[]) ?? []).map((o) => zodDefToJsonSchema(o, depth + 1) ?? {}) }
1258
+ return { anyOf: ((def.options as unknown[]) ?? []).map(walk) }
1259
+ case "ZodIntersection":
1260
+ case "intersection":
1261
+ return { allOf: [walk(def.left), walk(def.right)] }
1102
1262
  case "ZodRecord":
1103
1263
  case "record":
1104
- return {
1105
- additionalProperties: zodDefToJsonSchema(def.valueType, depth + 1) ?? {},
1106
- type: "object",
1107
- }
1264
+ return { additionalProperties: walk(def.valueType), type: "object" }
1108
1265
  case "ZodTuple":
1109
- case "tuple":
1110
- return {
1111
- items: ((def.items as unknown[]) ?? []).map((i) => zodDefToJsonSchema(i, depth + 1) ?? {}),
1112
- type: "array",
1266
+ case "tuple": {
1267
+ const items = ((def.items as unknown[]) ?? []).map(walk)
1268
+ const out: Record<string, unknown> = { prefixItems: items, type: "array" }
1269
+ if (def.rest) out.items = walk(def.rest)
1270
+ else {
1271
+ out.items = false
1272
+ out.maxItems = items.length
1113
1273
  }
1274
+ out.minItems = items.length
1275
+ return out
1276
+ }
1114
1277
  case "ZodDefault":
1115
- case "ZodCatch":
1116
1278
  case "default":
1279
+ case "prefault": {
1280
+ const inner = walk(def.innerType)
1281
+ const value = (def as { defaultValue?: unknown }).defaultValue
1282
+ const resolved = typeof value === "function" ? undefined : value
1283
+ return resolved === undefined ? inner : { ...inner, default: resolved }
1284
+ }
1285
+ case "ZodCatch":
1117
1286
  case "catch":
1118
1287
  case "ZodReadonly":
1119
1288
  case "readonly":
1120
- return zodDefToJsonSchema(def.innerType, depth + 1)
1289
+ return zodDefToJsonSchema(def.innerType, io, depth + 1)
1121
1290
  case "ZodPipeline":
1122
1291
  case "pipe":
1123
- return zodDefToJsonSchema(def.out, depth + 1)
1292
+ /* a pipe validates `in` and returns `out` */
1293
+ return zodDefToJsonSchema(io === "input" ? def.in : def.out, io, depth + 1)
1124
1294
  case "ZodBranded":
1125
- return zodDefToJsonSchema(def.type, depth + 1)
1295
+ return zodDefToJsonSchema(def.type, io, depth + 1)
1296
+ case "ZodLazy":
1297
+ case "lazy": {
1298
+ const getter = def.getter as (() => unknown) | undefined
1299
+ return typeof getter === "function" ? zodDefToJsonSchema(getter(), io, depth + 1) : {}
1300
+ }
1126
1301
  default:
1127
1302
  return undefined
1128
1303
  }
1129
1304
  }
1130
1305
 
1306
+ /** How a schema that cannot be converted at all is handled. Set per `generateOpenApi` call. */
1307
+ let schemaFailureMode: "throw" | "warn" = "throw"
1308
+
1309
+ function schemaConversionFailed(what: string, io: "input" | "output", err: unknown): Record<string, unknown> {
1310
+ const msg = err instanceof Error ? err.message : String(err)
1311
+ const text = `[honey:codegen] schemaToJsonSchema could not convert ${what} (io=${io}): ${msg}`
1312
+ if (schemaFailureMode === "throw") throw new Error(text)
1313
+ console.warn(`${text}. Documented as an unconstrained schema.`)
1314
+ return {}
1315
+ }
1316
+
1131
1317
  function schemaToJsonSchema(schema: StandardSchemaLike, io: "input" | "output" = "output"): unknown {
1132
1318
  /* Pre-serialized JSON-schema blob or non-schema value — no ~standard marker.
1133
1319
  * Arktype schemas are functions but still carry the marker, so pass those through too. */
@@ -1141,66 +1327,86 @@ function schemaToJsonSchema(schema: StandardSchemaLike, io: "input" | "output" =
1141
1327
  const vendor = schema["~standard"].vendor
1142
1328
 
1143
1329
  if (vendor === "zod") {
1330
+ const kind = (schema as { _zod?: { def?: { type?: string } } })?._zod?.def?.type ?? "unknown"
1331
+ const degraded = new Set<string>()
1332
+ let result: Record<string, unknown> | undefined
1144
1333
  try {
1145
- const result =
1146
- zodInstanceToJsonSchema(schema, io) ??
1147
- (toJSONSchemaFn?.(schema, { io }) as Record<string, unknown> | undefined) ??
1148
- zodDefToJsonSchema(schema)
1149
- if (result) {
1150
- return sanitizeZodJsonSchema(result)
1151
- }
1334
+ result = zodInstanceToJsonSchema(schema, io, degraded) ?? zodDefToJsonSchema(schema, io)
1152
1335
  } catch (err) {
1153
- const msg = err instanceof Error ? err.message : String(err)
1154
- const defType = (schema as { _zod?: { def?: { type?: string } } })?._zod?.def?.type ?? "unknown"
1336
+ return schemaConversionFailed(`zod/${kind}`, io, err)
1337
+ }
1338
+ if (!result) return schemaConversionFailed(`zod/${kind}`, io, new Error("no converter available"))
1339
+ if (degraded.size > 0) {
1155
1340
  console.warn(
1156
- `[honey:codegen] schemaToJsonSchema failed for zod/${defType} (io=${io}): ${msg}. Falling back to metadata-only introspection.`,
1341
+ `[honey:codegen] schemaToJsonSchema: zod/${kind} (io=${io}) has ${[...degraded].sort().join(", ")} ` +
1342
+ "node(s) JSON Schema cannot express; each is documented as an unconstrained schema. " +
1343
+ 'Describe them with .meta({ ... }) or z.toJSONSchema\'s "override".',
1157
1344
  )
1158
1345
  }
1346
+ return sanitizeZodJsonSchema(result)
1159
1347
  }
1160
1348
 
1161
- if (vendor === "valibot") {
1162
- return valibotToJsonSchema(schema)
1163
- }
1164
-
1165
- if (vendor === "arktype") {
1166
- return arkTypeToJsonSchema(schema)
1167
- }
1168
-
1169
- if (vendor === "yup") {
1170
- return yupToJsonSchema(schema)
1171
- }
1172
-
1173
- if (vendor === "effect") {
1174
- return effectToJsonSchema(schema)
1349
+ try {
1350
+ if (vendor === "valibot") return valibotToJsonSchema(schema)
1351
+ if (vendor === "arktype") return arkTypeToJsonSchema(schema)
1352
+ if (vendor === "yup") return yupToJsonSchema(schema)
1353
+ if (vendor === "effect") return effectToJsonSchema(schema)
1354
+ } catch (err) {
1355
+ return schemaConversionFailed(vendor, io, err)
1175
1356
  }
1176
1357
 
1177
- return introspectSchema(schema)
1358
+ /* no converter for this vendor: say so once per schema, never emit the introspection object */
1359
+ console.warn(
1360
+ `[honey:codegen] schemaToJsonSchema: no JSON Schema converter for vendor "${vendor}"; ` +
1361
+ "documented as an unconstrained schema.",
1362
+ )
1363
+ return {}
1178
1364
  }
1179
1365
 
1180
1366
  /* ---- Manifest generation ---- */
1181
1367
 
1368
+ export type GenerateManifestOptions = {
1369
+ /** Drop routes the predicate rejects, as `openapi({ filterRoutes })` does. */
1370
+ filterRoutes?: (route: OpenApiRouteInfo) => boolean
1371
+ /**
1372
+ * `"all"` (default, the `honey generate` artifact): every route and meta key.
1373
+ * `"published"` (a served `/manifest.json`): the OpenAPI document's visibility policy —
1374
+ * `meta.internal` routes are left out, and meta carries only keys the app's `metaSpec`
1375
+ * maps (built-ins included) and does not hide.
1376
+ */
1377
+ visibility?: "all" | "published"
1378
+ }
1379
+
1182
1380
  export function generateManifest<TEnv, TCtx>(
1183
1381
  app: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>,
1382
+ options: GenerateManifestOptions = {},
1184
1383
  ): RouteManifest {
1185
- const tree = (app as unknown as { _tree: TreeNode })._tree
1186
1384
  const factory = getErrorFactory(app)
1187
- const collected: CollectedRoute[] = []
1188
- walkTree(tree, "", collected)
1385
+ const published = options.visibility === "published"
1386
+ const publishable = published ? publishableMetaKeys(metaSpecOf(app)) : null
1387
+ const collected: CollectedRoute[] = collectRoutes(app).filter(({ handler, method, path }) => {
1388
+ if (published && isMetaInternal(handler)) return false
1389
+ return !options.filterRoutes || options.filterRoutes({ meta: handler.mt ?? EMPTY_OBJ, method, path })
1390
+ })
1189
1391
 
1190
- const allErrorKeys = new Set<string>()
1392
+ /* each key resolved against the factory of a route that declares it — a mounted sub-app keeps its own */
1393
+ const allErrorKeys = new Map<string, Record<string, () => HoneyError> | null>()
1191
1394
 
1192
1395
  const routes: RouteManifestEntry[] = collected.map(({ handler, method, path }) => {
1193
1396
  const entry: RouteManifestEntry = {
1194
1397
  errors: Array.from(handler.ek),
1195
- meta: handler.mt ?? EMPTY_OBJ,
1398
+ meta: publishable
1399
+ ? Object.fromEntries(Object.entries(handler.mt ?? EMPTY_OBJ).filter(([key]) => publishable(key)))
1400
+ : (handler.mt ?? EMPTY_OBJ),
1196
1401
  method,
1197
1402
  middleware: handler.mw.map((mw) => mw.name || "anonymous"),
1198
1403
  params: extractParams(path),
1199
1404
  path,
1200
1405
  }
1201
1406
 
1407
+ const routeFactory = (handler.fac as Record<string, () => HoneyError> | null | undefined) ?? factory
1202
1408
  for (const ek of handler.ek) {
1203
- allErrorKeys.add(ek)
1409
+ if (!allErrorKeys.has(ek)) allErrorKeys.set(ek, routeFactory)
1204
1410
  }
1205
1411
 
1206
1412
  if (handler.iv) {
@@ -1235,7 +1441,7 @@ export function generateManifest<TEnv, TCtx>(
1235
1441
  })
1236
1442
 
1237
1443
  return {
1238
- errors: Array.from(allErrorKeys).map((key) => resolveErrorInfo(key, factory)),
1444
+ errors: Array.from(allErrorKeys).map(([key, keyFactory]) => resolveErrorInfo(key, keyFactory)),
1239
1445
  routes,
1240
1446
  }
1241
1447
  }
@@ -1253,6 +1459,13 @@ export async function generateOpenApi<TEnv, TCtx, TMeta = unknown>(
1253
1459
  * nothing. `"off"` for a document served at runtime.
1254
1460
  */
1255
1461
  invalidate?: InvalidateCheckConfig
1462
+ /**
1463
+ * A schema that cannot be converted at all: `"throw"` (default, `honey generate`) fails
1464
+ * the build; `"warn"` (a served document) logs it and documents an unconstrained schema.
1465
+ * A single node JSON Schema cannot express (`z.date()`, `z.custom()`) never fails either
1466
+ * way — only that node degrades.
1467
+ */
1468
+ onSchemaError?: "throw" | "warn"
1256
1469
  /** Named metaSpec profile — selects which emitted keys this document carries */
1257
1470
  profile?: string
1258
1471
  securitySchemes?: Record<string, unknown>
@@ -1260,21 +1473,23 @@ export async function generateOpenApi<TEnv, TCtx, TMeta = unknown>(
1260
1473
  ): Promise<OpenApiSpec> {
1261
1474
  await Promise.all([loadToJSONSchema(), loadEffectJsonSchema()])
1262
1475
  const prev = getJsonSchemaConverter()
1476
+ const prevMode = schemaFailureMode
1477
+ schemaFailureMode = options.onSchemaError ?? "throw"
1263
1478
  setJsonSchemaConverter((schema, io) => schemaToJsonSchema(schema, io) as Record<string, unknown>)
1264
1479
  try {
1265
- const spec = generateOpenApiFromTree(app, options)
1480
+ const { onSchemaError: _mode, ...rest } = options
1481
+ const spec = generateOpenApiFromTree(app, rest)
1266
1482
  return deduplicateSchemas(spec)
1267
1483
  } finally {
1268
1484
  setJsonSchemaConverter(prev)
1485
+ schemaFailureMode = prevMode
1269
1486
  }
1270
1487
  }
1271
1488
 
1272
1489
  export function extractSchemas<TEnv, TCtx>(
1273
1490
  app: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>,
1274
1491
  ): Record<string, Record<string, unknown>> {
1275
- const tree = (app as unknown as { _tree: TreeNode })._tree
1276
- const collected: CollectedRoute[] = []
1277
- walkTree(tree, "", collected)
1492
+ const collected: CollectedRoute[] = collectRoutes(app)
1278
1493
 
1279
1494
  const result: Record<string, Record<string, unknown>> = {}
1280
1495
 
@@ -1311,213 +1526,72 @@ export function extractSchemas<TEnv, TCtx>(
1311
1526
 
1312
1527
  /* ---- Static route tree codegen ---- */
1313
1528
 
1314
- type RouteConfig = {
1315
- boundaryErrorKey: string | null
1316
- errorKeys: string[]
1317
- /** JSON Schema per input source — { json?, form?, params?, search?, headers?, cookies? }; null = no input validation */
1318
- inputSchemas: Record<string, Record<string, unknown>> | null
1319
- meta: Record<string, unknown> | null
1320
- method: string
1321
- middlewareNames: string[]
1322
- /**
1323
- * JSON Schema per (contentType, statusKey). Special case: `redirect` contentType holds
1324
- * `Record<statusKey, true>` with no schema (declaration-only, no body to validate).
1325
- * null = no output validation declared.
1326
- */
1327
- outputSchemas: Record<string, Record<string, unknown>> | null
1328
- path: string
1329
- }
1330
-
1331
- function serializeInputSchemas(iv: InputSchemasDef | null): Record<string, Record<string, unknown>> | null {
1332
- if (!iv) return null
1333
- const result: Record<string, Record<string, unknown>> = {}
1334
- for (const [source, entry] of Object.entries(iv)) {
1335
- if (entry === undefined) continue
1336
- const unwrapped = unwrapEntry(entry as InputSchemaEntry)
1337
- result[source] = schemaToJsonSchema(unwrapped, "input") as Record<string, unknown>
1338
- }
1339
- return Object.keys(result).length > 0 ? result : null
1340
- }
1341
-
1342
- function serializeOutputSchemas(os: OutputSchemaDef | null): Record<string, Record<string, unknown>> | null {
1343
- if (!os) return null
1344
- const result: Record<string, Record<string, unknown>> = {}
1345
- for (const [contentType, schemas] of Object.entries(os)) {
1346
- if (schemas === undefined) continue
1347
- if (contentType === "redirect") {
1348
- /* redirect map is Record<statusKey, true> — copy as-is, no schema conversion */
1349
- result[contentType] = { ...(schemas as Record<string, true>) }
1350
- continue
1351
- }
1352
- const converted: Record<string, unknown> = {}
1353
- for (const [statusKey, schema] of Object.entries(schemas)) {
1354
- if (schema === undefined) continue
1355
- converted[statusKey] = schemaToJsonSchema(schema as StandardSchemaLike)
1356
- }
1357
- if (Object.keys(converted).length > 0) {
1358
- result[contentType] = converted
1359
- }
1529
+ /**
1530
+ * Reject meta a generated file cannot reproduce. A route tree is JSON plus topology: a value
1531
+ * JSON cannot round-trip (`Date`, `RegExp`, `NaN`, `BigInt`, class instances) would be
1532
+ * emitted as something else, or interned together with a different value.
1533
+ */
1534
+ function assertJsonValue(value: unknown, where: string, path: string): void {
1535
+ const fail = (what: string): never => {
1536
+ throw new Error(`${where}: ${path} is ${what} — route trees carry JSON values only`)
1360
1537
  }
1361
- return Object.keys(result).length > 0 ? result : null
1362
- }
1363
-
1364
- function buildRouteConfig(handler: RouteHandler, method: string, path: string): RouteConfig {
1365
- return {
1366
- boundaryErrorKey: handler.bek,
1367
- errorKeys: Array.from(handler.ek),
1368
- inputSchemas: serializeInputSchemas(handler.iv as InputSchemasDef | null),
1369
- meta: handler.mt,
1370
- method,
1371
- middlewareNames: handler.mw.map((mw) => mw.name || "anonymous"),
1372
- outputSchemas: serializeOutputSchemas(handler.os as OutputSchemaDef | null),
1373
- path,
1538
+ if (value === null || typeof value === "string" || typeof value === "boolean") return
1539
+ if (typeof value === "number") {
1540
+ if (!Number.isFinite(value)) fail(String(value))
1541
+ return
1374
1542
  }
1375
- }
1376
-
1377
- function collectRoutesForTree(node: TreeNode, currentPath: string, routes: RouteConfig[]): void {
1378
- if (node.m !== null) {
1379
- for (const [method, handler] of Object.entries(node.m)) {
1380
- routes.push(buildRouteConfig(handler, method, currentPath || "/"))
1381
- }
1543
+ if (value === undefined) return
1544
+ if (typeof value !== "object") fail(`a ${typeof value}`)
1545
+ if (Array.isArray(value)) {
1546
+ for (let k = 0; k < value.length; k++) assertJsonValue(value[k], where, `${path}[${k}]`)
1547
+ return
1382
1548
  }
1383
-
1384
- for (const [seg, child] of Object.entries(node.s)) {
1385
- collectRoutesForTree(child, `${currentPath}/${seg}`, routes)
1549
+ const proto = Object.getPrototypeOf(value) as unknown
1550
+ if (proto !== Object.prototype && proto !== null) {
1551
+ const name = (value as { constructor?: { name?: string } }).constructor?.name ?? "an object"
1552
+ fail(`a ${name}`)
1386
1553
  }
1387
-
1388
- if (node.d !== null) {
1389
- collectRoutesForTree(node.d.c, `${currentPath}/:${node.d.n}`, routes)
1554
+ for (const key of Object.keys(value as Record<string, unknown>)) {
1555
+ assertJsonValue((value as Record<string, unknown>)[key], where, `${path}.${key}`)
1390
1556
  }
1557
+ }
1391
1558
 
1392
- if (node.w !== null) {
1393
- for (const [method, handler] of Object.entries(node.w.m)) {
1394
- routes.push(buildRouteConfig(handler, method, `${currentPath}/*${node.w.n}`))
1395
- }
1396
- }
1559
+ /** Object-literal key that defines an own property even for `__proto__`. */
1560
+ function keyExpr(key: string): string {
1561
+ return key === "__proto__" ? '["__proto__"]' : JSON.stringify(key)
1397
1562
  }
1398
1563
 
1399
- type TreeBuild = {
1400
- children: Record<string, TreeBuild>
1401
- dynamic: { child: TreeBuild; name: string } | null
1402
- handlers: Array<{ handler: string; method: string }>
1403
- wildcard: {
1404
- handlers: Array<{ handler: string; method: string }>
1405
- name: string
1406
- } | null
1564
+ type TreeNodeLike = {
1565
+ d: { c: TreeNodeLike; n: string } | null
1566
+ m: Record<string, string> | null
1567
+ s: Record<string, TreeNodeLike>
1568
+ w: { m: Record<string, string>; n: string } | null
1569
+ ws: string | null
1407
1570
  }
1408
1571
 
1409
- function createTreeBuild(): TreeBuild {
1410
- return { children: {}, dynamic: null, handlers: [], wildcard: null }
1572
+ function methodMapExpr(m: Record<string, string>, intern: InternPool): string {
1573
+ const parts = Object.keys(m).map((method) => `${keyExpr(method)}: ${intern.expr(m[method])}`)
1574
+ return `S({ ${parts.join(", ")} })`
1411
1575
  }
1412
1576
 
1413
- function serializeNode(node: TreeBuild): string {
1414
- const staticEntries = Object.entries(node.children)
1577
+ function serializeNode(node: TreeNodeLike, intern: InternPool): string {
1578
+ const statics = Object.keys(node.s)
1415
1579
  const sExpr =
1416
- staticEntries.length > 0
1417
- ? `S({ ${staticEntries.map(([k, v]) => `${JSON.stringify(k)}: ${serializeNode(v)}`).join(", ")} })`
1418
- : "E"
1419
-
1420
- const dExpr = node.dynamic
1421
- ? `{ n: ${JSON.stringify(node.dynamic.name)}, c: ${serializeNode(node.dynamic.child)} }`
1422
- : "null"
1423
-
1424
- const wExpr = node.wildcard
1425
- ? `{ n: ${JSON.stringify(node.wildcard.name)}, m: { ${node.wildcard.handlers.map((h) => `${h.method}: ${h.handler}`).join(", ")} } }`
1426
- : "null"
1427
-
1428
- const mExpr =
1429
- node.handlers.length > 0 ? `{ ${node.handlers.map((h) => `${h.method}: ${h.handler}`).join(", ")} }` : "null"
1430
-
1431
- const args = [sExpr]
1432
- if (mExpr !== "null" || dExpr !== "null" || wExpr !== "null") {
1433
- args.push(mExpr)
1434
- if (dExpr !== "null" || wExpr !== "null") {
1435
- args.push(dExpr)
1436
- if (wExpr !== "null") args.push(wExpr)
1437
- }
1438
- }
1580
+ statics.length > 0
1581
+ ? `S({ ${statics.map((k) => `${keyExpr(k)}: ${serializeNode(node.s[k], intern)}`).join(", ")} })`
1582
+ : "undefined"
1583
+ const args = [
1584
+ sExpr,
1585
+ node.m !== null ? methodMapExpr(node.m, intern) : "null",
1586
+ node.d !== null ? `{ n: ${JSON.stringify(node.d.n)}, c: ${serializeNode(node.d.c, intern)} }` : "null",
1587
+ node.w !== null ? `{ n: ${JSON.stringify(node.w.n)}, m: ${methodMapExpr(node.w.m, intern)} }` : "null",
1588
+ node.ws !== null ? intern.expr(node.ws) : "null",
1589
+ ]
1590
+ while (args.length > 0 && (args[args.length - 1] === "null" || args[args.length - 1] === "undefined")) args.pop()
1439
1591
  return `N(${args.join(", ")})`
1440
1592
  }
1441
1593
 
1442
- function nodeHasStatic(node: TreeBuild): boolean {
1443
- if (Object.keys(node.children).length > 0) return true
1444
- if (node.dynamic !== null) return nodeHasStatic(node.dynamic.child)
1445
- return false
1446
- }
1447
-
1448
- function selectorOf(route: RouteConfig): string {
1449
- return `${route.method} ${route.path}`
1450
- }
1451
-
1452
- function mapKeyExpr(intern: InternPool, selector: string): string {
1453
- const id = intern.id(selector)
1454
- return id !== undefined ? `[${id}]` : JSON.stringify(selector)
1455
- }
1456
-
1457
- function buildInternPool(routes: RouteConfig[]): InternPool {
1458
- const intern = new InternPool()
1459
- for (const route of routes) {
1460
- intern.count(route.boundaryErrorKey)
1461
- intern.count(route.errorKeys)
1462
- intern.count(route.meta)
1463
- intern.count(selectorOf(route))
1464
- }
1465
- for (const route of routes) {
1466
- intern.force(route.meta, "M")
1467
- intern.force(selectorOf(route), "P")
1468
- }
1469
- intern.seal()
1470
- return intern
1471
- }
1472
-
1473
- function insertHandler(rootBuild: TreeBuild, route: RouteConfig, hName: string): void {
1474
- const segments = route.path.split("/").filter((s) => s.length > 0)
1475
- let node = rootBuild
1476
-
1477
- for (let i = 0; i < segments.length; i++) {
1478
- const seg = segments[i]
1479
- if (seg.startsWith("*")) {
1480
- const name = seg.length > 1 ? seg.slice(1) : "*"
1481
- if (node.wildcard === null) {
1482
- node.wildcard = { handlers: [], name }
1483
- }
1484
- node.wildcard.handlers.push({ handler: hName, method: route.method })
1485
- return
1486
- }
1487
- if (seg.startsWith(":")) {
1488
- const name = seg.endsWith("?") ? seg.slice(1, -1) : seg.slice(1)
1489
- if (node.dynamic === null) {
1490
- node.dynamic = { child: createTreeBuild(), name }
1491
- }
1492
- if (i === segments.length - 1) {
1493
- node.dynamic.child.handlers.push({
1494
- handler: hName,
1495
- method: route.method,
1496
- })
1497
- } else {
1498
- node = node.dynamic.child
1499
- }
1500
- continue
1501
- }
1502
- if (node.children[seg] === undefined) {
1503
- node.children[seg] = createTreeBuild()
1504
- }
1505
- if (i === segments.length - 1) {
1506
- node.children[seg].handlers.push({
1507
- handler: hName,
1508
- method: route.method,
1509
- })
1510
- } else {
1511
- node = node.children[seg]
1512
- }
1513
- }
1514
-
1515
- if (segments.length === 0) {
1516
- node.handlers.push({ handler: hName, method: route.method })
1517
- }
1518
- }
1519
-
1520
- function collectMetaShape(collected: CollectedRoute[]): {
1594
+ function collectMetaShape(entries: Array<{ mt: Record<string, unknown> | null | undefined }>): {
1521
1595
  allKeys: Map<string, Set<string>>
1522
1596
  keyCount: Map<string, number>
1523
1597
  metaHandlerCount: number
@@ -1525,10 +1599,10 @@ function collectMetaShape(collected: CollectedRoute[]): {
1525
1599
  const allKeys = new Map<string, Set<string>>()
1526
1600
  const keyCount = new Map<string, number>()
1527
1601
  let metaHandlerCount = 0
1528
- for (const { handler } of collected) {
1529
- if (handler.mt === null) continue
1602
+ for (const { mt } of entries) {
1603
+ if (mt === null || mt === undefined) continue
1530
1604
  metaHandlerCount++
1531
- for (const [k, v] of Object.entries(handler.mt)) {
1605
+ for (const [k, v] of Object.entries(mt)) {
1532
1606
  if (!allKeys.has(k)) allKeys.set(k, new Set())
1533
1607
  allKeys.get(k)?.add(emitLiteral(v))
1534
1608
  keyCount.set(k, (keyCount.get(k) ?? 0) + 1)
@@ -1540,137 +1614,144 @@ function collectMetaShape(collected: CollectedRoute[]): {
1540
1614
  function emitMetaShapeType(shape: ReturnType<typeof collectMetaShape>): string {
1541
1615
  if (shape.allKeys.size === 0) return ""
1542
1616
  const props = [...shape.allKeys.entries()]
1543
- .sort(([a], [b]) => a.localeCompare(b))
1617
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
1544
1618
  .map(([key, types]) => {
1545
1619
  const optional = (shape.keyCount.get(key) ?? 0) < shape.metaHandlerCount ? "?" : ""
1546
- const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `"${key}"`
1620
+ const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
1547
1621
  return `\t${safeKey}${optional}: ${[...types].join(" | ")}`
1548
1622
  })
1549
1623
  return `export type MetaShape = {\n${props.join("\n")}\n} & Record<string, unknown>\n`
1550
1624
  }
1551
1625
 
1552
- function emitRouteTree(routes: RouteConfig[], collected: CollectedRoute[] | null): string {
1553
- const intern = buildInternPool(routes)
1554
- const metaShape = collected !== null ? collectMetaShape(collected) : null
1555
- const hasMetaShape = metaShape !== null && metaShape.allKeys.size > 0
1556
- const lines: string[] = []
1557
- lines.push('import type { TreeNode, RouteHandler, RouteTree } from "@lovrozagar/honey/tree"')
1558
- lines.push("")
1559
- lines.push("const E = Object.create(null) as Record<string, TreeNode>")
1560
- lines.push(
1561
- 'function N(s: Record<string, TreeNode>, m: Record<string, RouteHandler> | null = null, d: TreeNode["d"] = null, w: { n: string; m: Record<string, RouteHandler> } | null = null): TreeNode {',
1562
- )
1563
- lines.push('\treturn { s, d, w: w as TreeNode["w"], m: m as TreeNode["m"], ws: null }')
1564
- lines.push("}")
1626
+ type EmitEntry = {
1627
+ bek: string | null
1628
+ ek: string[]
1629
+ id: string
1630
+ internalMeta: boolean
1631
+ mt: Record<string, unknown> | null
1632
+ }
1565
1633
 
1566
- const rootBuild = createTreeBuild()
1567
- for (let i = 0; i < routes.length; i++) {
1568
- insertHandler(rootBuild, routes[i], `H${i}`)
1634
+ /**
1635
+ * Emit a route tree module: topology with `RouteId` leaves plus per-route data (meta, error
1636
+ * keys, boundary key). No handler fields — the app that loads the tree registers its routes,
1637
+ * which bind to these ids; leaves it does not register are delegated to its catch-all.
1638
+ */
1639
+ function emitRouteTree(tree: RouteTree): string {
1640
+ const order: string[] = []
1641
+ const seen = new Set<string>()
1642
+ forEachLeaf(tree.root, (_method, _path, id) => {
1643
+ if (!seen.has(id)) {
1644
+ seen.add(id)
1645
+ order.push(id)
1646
+ }
1647
+ })
1648
+ const entries: EmitEntry[] = []
1649
+ for (const id of order) {
1650
+ const entry = tree.routes[id]
1651
+ if (entry === undefined) throw new Error(`route tree leaf ${id} has no route entry`)
1652
+ const mt = entry.mt ?? null
1653
+ if (mt !== null) assertJsonValue(mt, `Route meta for ${id}`, "meta")
1654
+ entries.push({
1655
+ bek: entry.bek ?? null,
1656
+ ek: [...(entry.ek ?? [])],
1657
+ id,
1658
+ internalMeta: (mt as { internal?: unknown } | null)?.internal === true,
1659
+ mt,
1660
+ })
1569
1661
  }
1570
1662
 
1571
- if (nodeHasStatic(rootBuild)) {
1572
- lines.push("function S(o: Record<string, TreeNode>): Record<string, TreeNode> {")
1573
- lines.push("\treturn Object.assign(Object.create(null), o) as Record<string, TreeNode>")
1574
- lines.push("}")
1663
+ const intern = new InternPool()
1664
+ for (const e of entries) {
1665
+ intern.count(e.bek)
1666
+ if (e.ek.length > 0) intern.count(e.ek)
1667
+ intern.count(e.mt)
1668
+ intern.count(e.id)
1575
1669
  }
1576
-
1577
- if (routes.length > 0) {
1578
- lines.push('const FN = null as unknown as RouteHandler["fn"]')
1670
+ for (const e of entries) {
1671
+ intern.force(e.mt, "M")
1672
+ intern.force(e.id, "P")
1579
1673
  }
1674
+ intern.seal()
1675
+
1676
+ const metaShape = collectMetaShape(entries)
1677
+ const hasMetaShape = metaShape.allKeys.size > 0
1678
+ const lines: string[] = []
1679
+ lines.push('import type { RouteTree, TreeNode } from "@lovrozagar/honey/tree"')
1680
+ lines.push("")
1681
+ lines.push("function S<T>(o: Record<string, T>): Record<string, T> {")
1682
+ lines.push("\treturn Object.assign(Object.create(null) as Record<string, T>, o)")
1683
+ lines.push("}")
1684
+ lines.push(
1685
+ 'function N(s: Record<string, TreeNode> = S({}), m: Record<string, string> | null = null, d: TreeNode["d"] = null, w: TreeNode["w"] = null, ws: string | null = null): TreeNode {',
1686
+ )
1687
+ lines.push("\treturn { d, m, s, w, ws }")
1688
+ lines.push("}")
1580
1689
 
1581
1690
  const consts = intern.emitConstLines()
1582
1691
  if (consts.length > 0) {
1583
1692
  lines.push("")
1584
1693
  for (const line of consts) {
1585
1694
  if (hasMetaShape && /^const M\d+ = /.test(line)) lines.push(`${line} as unknown as MetaShape`)
1586
- else lines.push(line)
1587
- }
1588
- }
1589
-
1590
- if (routes.length > 0) {
1591
- lines.push("")
1592
- /* unique handler per route — sharing H objects would cross-wire .routeTree() patches */
1593
- for (let i = 0; i < routes.length; i++) {
1594
- const route = routes[i]
1595
- const bekExpr = route.boundaryErrorKey !== null ? intern.expr(route.boundaryErrorKey) : "null"
1596
- const ekSet = route.errorKeys.length > 0 ? `new Set(${intern.expr(route.errorKeys)})` : "new Set()"
1597
- const mtExpr = route.meta !== null ? intern.expr(route.meta) : "null"
1598
- lines.push(`const H${i}: RouteHandler = { bek: ${bekExpr}, fn: FN, mw: [], ek: ${ekSet}, mt: ${mtExpr} }`)
1695
+ else lines.push(line)
1599
1696
  }
1600
1697
  }
1601
1698
 
1602
1699
  lines.push("")
1603
- lines.push(`export const tree: TreeNode = ${serializeNode(rootBuild)}`)
1700
+ lines.push(`export const tree: TreeNode = ${serializeNode(tree.root as TreeNodeLike, intern)}`)
1604
1701
  lines.push("")
1605
-
1606
- const handlerMapEntries = routes.map((route, index) => `\t${mapKeyExpr(intern, selectorOf(route))}: H${index}`)
1607
- lines.push(`export const handlers: Record<string, RouteHandler> = {\n${handlerMapEntries.join(",\n")}\n}`)
1702
+ const routeLines = entries.map((e) => {
1703
+ const parts: string[] = []
1704
+ if (e.bek !== null) parts.push(`bek: ${intern.expr(e.bek)}`)
1705
+ if (e.ek.length > 0) parts.push(`ek: ${intern.expr(e.ek)}`)
1706
+ if (e.mt !== null) parts.push(`mt: ${intern.expr(e.mt)}`)
1707
+ return `\t[${intern.expr(e.id)}]: ${parts.length > 0 ? `{ ${parts.join(", ")} }` : "{}"}`
1708
+ })
1709
+ lines.push(`export const routes: RouteTree["routes"] = S({\n${routeLines.join(",\n")}\n})`)
1608
1710
  lines.push("")
1609
1711
 
1610
1712
  let code = lines.join("\n")
1611
- if (collected === null) return code
1612
-
1613
- const metaEntries: string[] = []
1614
- for (const { handler, method, path } of collected) {
1615
- if (handler.mt === null || Object.keys(handler.mt).length === 0) continue
1616
- const selector = `${method} ${path}`
1617
- metaEntries.push(`\t${mapKeyExpr(intern, selector)}: ${intern.expr(handler.mt)}`)
1618
- }
1619
1713
 
1620
- if (hasMetaShape && metaShape !== null) {
1621
- code += `\n${emitMetaShapeType(metaShape)}\n`
1622
- }
1714
+ if (hasMetaShape) code += `\n${emitMetaShapeType(metaShape)}\n`
1623
1715
 
1624
- const selectors = new Set<string>()
1625
- for (const { handler, method, path } of collected) {
1626
- if (handler._skip || isMetaInternal(handler)) continue
1627
- selectors.add(`${method} ${path}`)
1628
- }
1629
- if (selectors.size > 0) {
1630
- const sorted = [...selectors].sort()
1631
- const parts = sorted.map((s) => {
1632
- const id = intern.id(s)
1633
- return id !== undefined ? `typeof ${id}` : JSON.stringify(s)
1716
+ const selectors = entries.filter((e) => !e.id.startsWith("WS ") && !e.internalMeta).map((e) => e.id)
1717
+ if (selectors.length > 0) {
1718
+ const sorted = [...new Set(selectors)].sort()
1719
+ const parts = sorted.map((sel) => {
1720
+ const ref = intern.id(sel)
1721
+ return ref !== undefined ? `typeof ${ref}` : JSON.stringify(sel)
1634
1722
  })
1635
1723
  code += `\nexport type RouteSelector = ${parts.join(" | ")}\n`
1636
1724
  }
1637
1725
 
1726
+ const metaEntries = entries
1727
+ .filter((e) => e.mt !== null && Object.keys(e.mt).length > 0)
1728
+ .map((e) => `\t[${intern.expr(e.id)}]: ${intern.expr(e.mt)}`)
1729
+ const metaType = hasMetaShape ? "Record<string, MetaShape>" : "Record<string, Record<string, unknown>>"
1638
1730
  if (metaEntries.length > 0) {
1639
- const metaType = hasMetaShape ? "Record<string, MetaShape>" : "Record<string, Record<string, unknown>>"
1640
- code += `export const meta: ${metaType} = {\n${metaEntries.join(",\n")}\n}\n`
1731
+ code += `export const meta: ${metaType} = S({\n${metaEntries.join(",\n")}\n})\n`
1641
1732
  }
1642
-
1643
- const hasMetaExport = metaEntries.length > 0
1644
- code += `export const routeTree: RouteTree = { root: tree, ${hasMetaExport ? "meta, " : "meta: {}, "}handlers }\n`
1733
+ code += `export const routeTree: RouteTree = { v: ${ROUTE_TREE_VERSION}, root: tree, routes, ${metaEntries.length > 0 ? "meta" : "meta: {}"} }\n`
1645
1734
 
1646
1735
  return code
1647
1736
  }
1648
1737
 
1649
- export function generateRouteTree(routes: RouteConfig[]): string {
1650
- return emitRouteTree(routes, null)
1651
- }
1652
-
1653
- function generateFromTreeRoot(root: TreeNode): string {
1654
- const routes: RouteConfig[] = []
1655
- collectRoutesForTree(root, "", routes)
1656
- const collected: CollectedRoute[] = []
1657
- walkTree(root, "", collected)
1658
- return emitRouteTree(routes, collected)
1738
+ /** Emit a route tree module from a tree (a `mergeTree` result or an `app.toRouteTree()` snapshot). */
1739
+ export function generateRouteTree(tree: RouteTree): string {
1740
+ return emitRouteTree(tree)
1659
1741
  }
1660
1742
 
1661
1743
  export function generateRouteTreeFromApp<TEnv, TCtx>(
1662
1744
  app: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>,
1663
1745
  ): string {
1664
- const root = (app as unknown as { _tree: TreeNode })._tree
1665
- return generateFromTreeRoot(root)
1746
+ return emitRouteTree(app.toRouteTree())
1666
1747
  }
1667
1748
 
1668
1749
  /**
1669
1750
  * Generate static route tree code from a pre-built RouteTree.
1670
1751
  * Supports gateway patterns: import service trees → enrich meta → mergeTree → generate.
1671
1752
  */
1672
- export function generateRouteTreeFromRouteTree(rt: { root: TreeNode }): string {
1673
- return generateFromTreeRoot(rt.root)
1753
+ export function generateRouteTreeFromRouteTree(rt: RouteTree): string {
1754
+ return emitRouteTree(rt)
1674
1755
  }
1675
1756
 
1676
1757
  /* ---- Type codegen ---- */
@@ -1707,14 +1788,14 @@ function emitOutputType(handler: RouteHandler, state: TypeEmitState): string {
1707
1788
  if (contentType === "redirect") {
1708
1789
  const keys = Object.keys(schemas).filter((k) => schemas[k as keyof typeof schemas])
1709
1790
  if (keys.length > 0) {
1710
- ctEntries.push(`"redirect": { ${keys.map((k) => `${k}: true`).join("; ")} }`)
1791
+ ctEntries.push(`"redirect": { ${keys.map((k) => `${quoteKey(k)}: true`).join("; ")} }`)
1711
1792
  }
1712
1793
  continue
1713
1794
  }
1714
1795
  const statusEntries: string[] = []
1715
1796
  for (const [statusKey, schema] of Object.entries(schemas)) {
1716
1797
  if (schema === undefined) continue
1717
- statusEntries.push(`${statusKey}: ${emitSchemaType(schema as StandardSchemaLike, state)}`)
1798
+ statusEntries.push(`${quoteKey(statusKey)}: ${emitSchemaType(schema as StandardSchemaLike, state)}`)
1718
1799
  }
1719
1800
  if (statusEntries.length > 0) {
1720
1801
  ctEntries.push(`${JSON.stringify(contentType)}: { ${statusEntries.join("; ")} }`)
@@ -1728,8 +1809,7 @@ function emitMetaType(handler: RouteHandler): string {
1728
1809
  if (!handler.mt) return "{}"
1729
1810
  const entries: string[] = []
1730
1811
  for (const [k, v] of Object.entries(handler.mt)) {
1731
- const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) ? k : JSON.stringify(k)
1732
- entries.push(`${key}: ${emitLiteral(v)}`)
1812
+ entries.push(`${quoteKey(k)}: ${emitLiteral(v)}`)
1733
1813
  }
1734
1814
  if (entries.length === 0) return "{}"
1735
1815
  return `{ ${entries.join("; ")} }`
@@ -1739,7 +1819,9 @@ function emitLiteral(value: unknown): string {
1739
1819
  if (value === null) return "null"
1740
1820
  if (value === undefined) return "undefined"
1741
1821
  if (typeof value === "string") return JSON.stringify(value)
1742
- if (typeof value === "number" || typeof value === "boolean") return String(value)
1822
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : "number"
1823
+ if (typeof value === "boolean") return String(value)
1824
+ if (typeof value === "bigint") return `${value}n`
1743
1825
  if (Array.isArray(value)) {
1744
1826
  if (value.length === 0) return "[]"
1745
1827
  return `[${value.map((v) => emitLiteral(v)).join(", ")}]`
@@ -1772,9 +1854,9 @@ function emitErrorShapes(
1772
1854
  const entry = meta?.[key]
1773
1855
  if (entry?.schema) {
1774
1856
  const schemaType = emitSchemaType(entry.schema as StandardSchemaLike, state)
1775
- entries.push(`${key}: ${schemaType}`)
1857
+ entries.push(`${quoteKey(key)}: ${schemaType}`)
1776
1858
  } else {
1777
- entries.push(`${key}: null`)
1859
+ entries.push(`${quoteKey(key)}: null`)
1778
1860
  }
1779
1861
  }
1780
1862
  return `{ ${entries.join("; ")} }`
@@ -1824,16 +1906,15 @@ function emitErrorsCtxType(handler: RouteHandler): string {
1824
1906
  .sort()
1825
1907
  .map(
1826
1908
  (k) =>
1827
- `${k}: (opts?: { cause?: unknown; fields?: Record<string, { error_key: string; message: string; path: string }[]>; headers?: Record<string, string>; vars?: Record<string, string | number> }) => HoneyError`,
1909
+ `${quoteKey(k)}: (opts?: { cause?: unknown; fields?: Record<string, { error_key: string; message: string; path: string }[]>; headers?: Record<string, string>; vars?: Record<string, string | number> }) => HoneyError`,
1828
1910
  )
1829
1911
  return `readonly errors: { ${entries.join("; ")} }`
1830
1912
  }
1831
1913
 
1832
1914
  /**
1833
- * Look up per-route middleware type additions.
1834
- * The type extractor stores keys as `"method /route-path"` (no basePath prefix),
1835
- * but the codegen resolves routes to full paths (with basePath).
1836
- * Try exact match first, then strip leading segments until a match is found.
1915
+ * Per-route middleware type additions. The type extractor keys routes by their full path —
1916
+ * basePath included — exactly as the router registers them, so the lookup is exact: a route
1917
+ * with no entry gets the base ctx, never another route's additions.
1837
1918
  */
1838
1919
  function resolveRouteMiddleware(
1839
1920
  routeMiddleware: Record<string, string> | undefined,
@@ -1841,27 +1922,15 @@ function resolveRouteMiddleware(
1841
1922
  fullPath: string,
1842
1923
  ): string | undefined {
1843
1924
  if (!routeMiddleware) return undefined
1844
- const exact = routeMiddleware[`${method} ${fullPath}`]
1845
- if (exact) return exact
1846
-
1847
- /* strip leading path segments (basePath) until match */
1848
- let p = fullPath
1849
- while (p.includes("/", 1)) {
1850
- const nextSlash = p.indexOf("/", 1)
1851
- p = p.slice(nextSlash)
1852
- const key = `${method} ${p}`
1853
- if (routeMiddleware[key]) return routeMiddleware[key]
1854
- }
1855
- return undefined
1925
+ const key = `${method} ${fullPath}`
1926
+ return Object.hasOwn(routeMiddleware, key) ? routeMiddleware[key] : undefined
1856
1927
  }
1857
1928
 
1858
1929
  export function generateTypes<TEnv, TCtx>(
1859
1930
  app: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>,
1860
1931
  options: GenerateTypesOptions,
1861
1932
  ): string {
1862
- const tree = (app as unknown as { _tree: TreeNode })._tree
1863
- const collected: CollectedRoute[] = []
1864
- walkTree(tree, "", collected, true)
1933
+ const collected: CollectedRoute[] = collectRoutes(app, true)
1865
1934
 
1866
1935
  const factory = getErrorFactory(app)
1867
1936
  const errorMeta = getErrorMeta(factory)
@@ -1906,7 +1975,8 @@ export function generateTypes<TEnv, TCtx>(
1906
1975
  const sorted = [...selectors].sort()
1907
1976
  lines.push(`export type RouteSelector = ${sorted.map((s) => JSON.stringify(s)).join(" | ")}`)
1908
1977
  lines.push("")
1909
- lines.push('declare module "honey" {')
1978
+ /* augment the module consumers import — an augmentation of any other name silently applies to nothing */
1979
+ lines.push('declare module "@lovrozagar/honey" {')
1910
1980
  lines.push("\tinterface HoneyCodegen {")
1911
1981
  lines.push(`\t\trouteSelector: RouteSelector`)
1912
1982
  lines.push("\t}")
@@ -1946,7 +2016,7 @@ export function generateTypes<TEnv, TCtx>(
1946
2016
 
1947
2017
  function buildMwType(method: string, path: string): string | undefined {
1948
2018
  const key = `${method} ${path}`
1949
- const props = routeProps?.[key]
2019
+ const props = routeProps && Object.hasOwn(routeProps, key) ? routeProps[key] : undefined
1950
2020
  if (props?.length) {
1951
2021
  const entries = props.map((p) => {
1952
2022
  const typeStr = subTypeMap.get(p.type) ?? p.type
@@ -1983,7 +2053,7 @@ export function generateTypes<TEnv, TCtx>(
1983
2053
  routesLines.push("export type Routes = {")
1984
2054
 
1985
2055
  for (const [path, methods] of byPath) {
1986
- routesLines.push(`\t"${path}": {`)
2056
+ routesLines.push(`\t${JSON.stringify(path)}: {`)
1987
2057
  for (const { handler, method } of methods) {
1988
2058
  const mwType = buildMwType(method, path)
1989
2059
  const mwAlias = mwType ? mwTypeMap.get(mwType) : undefined
@@ -1991,9 +2061,10 @@ export function generateTypes<TEnv, TCtx>(
1991
2061
  const additions: string[] = []
1992
2062
  const inputType = emitInputType(handler, emitState)
1993
2063
  if (inputType !== "{}") additions.push(`input: ${inputType}`)
1994
- const params = extractParams(path)
2064
+ /* the names ctx.params carries at runtime — an unnamed wildcard is "*" */
2065
+ const params = patternParams(parsePattern(path))
1995
2066
  if (params.length > 0) {
1996
- const paramEntries = params.map((p) => `${p}: string`).join("; ")
2067
+ const paramEntries = params.map((p) => `${quoteKey(p)}: string`).join("; ")
1997
2068
  additions.push(`readonly params: { ${paramEntries} }`)
1998
2069
  }
1999
2070
  const errorsCtx = emitErrorsCtxType(handler)
@@ -2002,13 +2073,16 @@ export function generateTypes<TEnv, TCtx>(
2002
2073
  const errorType = emitErrorType(handler)
2003
2074
  const metaType = emitMetaType(handler)
2004
2075
  const outputType = emitOutputType(handler, emitState)
2005
- routesLines.push(`\t\t${method}: {`)
2076
+ routesLines.push(`\t\t${quoteKey(method)}: {`)
2006
2077
  routesLines.push(`\t\t\tctx: WithOutput<${ctxType}, ${outputType}>`)
2007
2078
  routesLines.push(`\t\t\terrors: ${errorType}`)
2008
- const shapesType = emitErrorShapes(handler, errorMeta, emitState)
2079
+ /* a mounted sub-app's route keeps its own error factory */
2080
+ const routeFactory = (handler.fac as Record<string, () => HoneyError> | null | undefined) ?? factory
2081
+ const routeErrorMeta = routeFactory === factory ? errorMeta : getErrorMeta(routeFactory)
2082
+ const shapesType = emitErrorShapes(handler, routeErrorMeta, emitState)
2009
2083
  if (shapesType) {
2010
2084
  routesLines.push(`\t\t\terrorShapes: ${shapesType}`)
2011
- const byStatusType = emitErrorsByStatus(handler, errorMeta, factory, emitState)
2085
+ const byStatusType = emitErrorsByStatus(handler, routeErrorMeta, routeFactory, emitState)
2012
2086
  if (byStatusType) {
2013
2087
  routesLines.push(`\t\t\terrorsByStatus: ${byStatusType}`)
2014
2088
  }
@@ -2194,6 +2268,8 @@ type ServiceEntry = {
2194
2268
  realtime?: boolean
2195
2269
  ws?: boolean
2196
2270
  sse?: boolean
2271
+ /** The path param that is a wildcard: its value may span segments */
2272
+ wildcard?: string
2197
2273
  }
2198
2274
 
2199
2275
  type NestedServiceNode =
@@ -2214,6 +2290,8 @@ type GeneratedSDK = {
2214
2290
  type SDKMethod = {
2215
2291
  action: string
2216
2292
  errorsByStatusType: string | null
2293
+ /** Full operationId (explicit or derived) */
2294
+ id: string
2217
2295
  inputHasMandatory: boolean
2218
2296
  inputType: string
2219
2297
  realtime: boolean
@@ -2225,7 +2303,8 @@ type SDKMethod = {
2225
2303
 
2226
2304
  export function extractOpenApiPathParams(path: string): string[] {
2227
2305
  const params: string[] = []
2228
- const re = /\{(\w+)\}/g
2306
+ /* any name between braces — `{user-id}` is one parameter, not `{user}` and a literal `-id` */
2307
+ const re = /\{([^{}/]+)\}/g
2229
2308
  let match: RegExpExecArray | null = re.exec(path)
2230
2309
  while (match !== null) {
2231
2310
  params.push(match[1])
@@ -2253,87 +2332,76 @@ export function jsonSchemaToTS(schema: Record<string, unknown> | undefined, dept
2253
2332
  /* a file part of a multipart body is sent as a Blob/File; the runtime appends it to FormData as is */
2254
2333
  const FORM_BINARY_TS = "Blob"
2255
2334
 
2335
+ /* the TS SDK input field a request body fills, by its media type */
2336
+ function sdkBodyField(body: IRBody): "body" | "form" | "json" | null {
2337
+ if (body.kind === "stream") return "body"
2338
+ if (body.kind === "multipart") return "form"
2339
+ const essence = mediaEssence(body.contentType)
2340
+ if (isJsonMedia(body.contentType)) return "json"
2341
+ if (essence === "multipart/form-data" || essence === "application/x-www-form-urlencoded") return "form"
2342
+ return null
2343
+ }
2344
+
2256
2345
  /* extract input type for an operation */
2257
- function emitSDKInputType(op: Record<string, unknown>, path: string): { hasMandatory: boolean; type: string } {
2346
+ function emitSDKInputType(op: IROperation): { hasMandatory: boolean; type: string } {
2258
2347
  const mandatoryParts: string[] = []
2259
2348
  const optionalParts: string[] = []
2260
2349
 
2261
2350
  /* path params — always required */
2262
- const pathParamNames = extractOpenApiPathParams(path)
2263
- if (pathParamNames.length > 0) {
2264
- const allParameters = op.parameters as Array<Record<string, unknown>> | undefined
2265
- const entries = pathParamNames
2266
- .map((name) => {
2267
- const paramDef = allParameters?.find((p) => p.name === name && p.in === "path")
2268
- const schema = paramDef?.schema as Record<string, unknown> | undefined
2269
- const tsType = schema ? jsonSchemaToTS(schema) : "string"
2270
- const safeParamName = sdkSafeName(name)
2271
- return `${safeParamName}: ${tsType}`
2272
- })
2273
- .join("; ")
2351
+ if (op.params.path.length > 0) {
2352
+ const entries = op.params.path.map((p) => `${sdkSafeName(p.name)}: ${irToTs(p.schema)}`).join("; ")
2274
2353
  mandatoryParts.push(`params: { ${entries} }`)
2275
2354
  }
2276
2355
 
2277
2356
  /* query params */
2278
- const parameters = op.parameters as Array<Record<string, unknown>> | undefined
2279
- if (parameters) {
2280
- const queryParams = parameters.filter((p) => p.in === "query")
2281
- if (queryParams.length > 0) {
2282
- const hasRequired = queryParams.some((p) => p.required === true)
2283
- const entries = queryParams
2284
- .sort((a, b) => String(a.name).localeCompare(String(b.name)))
2285
- .map((p) => {
2286
- const name = String(p.name)
2287
- const required = p.required === true
2288
- const schema = p.schema as Record<string, unknown> | undefined
2289
- const tsType = jsonSchemaToTS(schema)
2290
- const safeQueryName = sdkSafeName(name)
2291
- return `${safeQueryName}${required ? "" : "?"}: ${tsType}`
2292
- })
2293
- const searchEntry = `search: { ${entries.join("; ")} }`
2294
- if (hasRequired) {
2295
- mandatoryParts.push(searchEntry)
2296
- } else {
2297
- optionalParts.push(searchEntry)
2298
- }
2299
- }
2357
+ if (op.params.query.length > 0) {
2358
+ const entries = op.params.query
2359
+ .slice()
2360
+ .sort((a, b) => compareCodeUnits(a.name, b.name))
2361
+ .map((p) => `${sdkSafeName(p.name)}${p.required === true ? "" : "?"}: ${irToTs(p.schema)}`)
2362
+ const searchEntry = `search: { ${entries.join("; ")} }`
2363
+ if (op.params.query.some((p) => p.required === true)) mandatoryParts.push(searchEntry)
2364
+ else optionalParts.push(searchEntry)
2300
2365
  }
2301
2366
 
2302
- /* request body */
2303
- const requestBody = op.requestBody as Record<string, unknown> | undefined
2304
- if (requestBody) {
2305
- const required = (requestBody.required as boolean | undefined) === true
2306
- const content = requestBody.content as Record<string, Record<string, unknown>> | undefined
2307
- if (content) {
2308
- /* application/octet-stream: raw byte stream — emit `body:` field,
2309
- * mutually exclusive with json/form for this operation */
2310
- if ("application/octet-stream" in content) {
2311
- const entry = "body: ReadableStream<Uint8Array> | Blob | ArrayBuffer | Uint8Array"
2312
- if (required) {
2313
- mandatoryParts.push(entry)
2314
- } else {
2315
- optionalParts.push(entry)
2316
- }
2317
- }
2318
- const jsonContent = content["application/json"]
2319
- if (jsonContent?.schema) {
2320
- const entry = `json: ${jsonSchemaToTS(jsonContent.schema as Record<string, unknown>)}`
2321
- if (required) {
2322
- mandatoryParts.push(entry)
2323
- } else {
2324
- optionalParts.push(entry)
2325
- }
2326
- }
2327
- const formContent = content["multipart/form-data"] ?? content["application/x-www-form-urlencoded"]
2328
- if (formContent?.schema) {
2329
- const entry = `form: ${jsonSchemaToTS(formContent.schema as Record<string, unknown>, 0, FORM_BINARY_TS)}`
2330
- if (required) {
2331
- mandatoryParts.push(entry)
2332
- } else {
2333
- optionalParts.push(entry)
2334
- }
2335
- }
2367
+ /* header and cookie params — typed on top of the free-form per-call `headers` / `cookies` */
2368
+ for (const [declared, field] of [
2369
+ [op.params.header, "headers"],
2370
+ [op.params.cookie ?? [], "cookies"],
2371
+ ] as const) {
2372
+ if (declared.length === 0) continue
2373
+ const entries = declared
2374
+ .slice()
2375
+ .sort((a, b) => compareCodeUnits(a.name, b.name))
2376
+ .map((p) => {
2377
+ /* sent as text: a header or cookie value is a string on the wire */
2378
+ const tsType = p.schema.kind === "scalar" && p.schema.enum ? irToTs(p.schema) : "string"
2379
+ return `${quoteKey(p.name)}${p.required === true ? "" : "?"}: ${tsType}`
2380
+ })
2381
+ const entry = `${field}: { ${entries.join("; ")} }`
2382
+ if (declared.some((p) => p.required === true)) mandatoryParts.push(entry)
2383
+ else optionalParts.push(entry)
2384
+ }
2385
+
2386
+ /* request body: one input field per kind; the preferred content type of each kind wins */
2387
+ const byField = new Map<string, IRBody>()
2388
+ for (const body of bodiesOf(op)) {
2389
+ const field = sdkBodyField(body)
2390
+ if (field && !byField.has(field)) byField.set(field, body)
2391
+ }
2392
+ /* raw bytes first, then json, then form: the order the fields always had */
2393
+ for (const field of ["body", "json", "form"] as const) {
2394
+ const body = byField.get(field)
2395
+ if (!body) continue
2396
+ let entry: string
2397
+ if (field === "body") entry = "body: ReadableStream<Uint8Array> | Blob | ArrayBuffer | Uint8Array"
2398
+ else if (field === "json") entry = `json: ${body.kind === "raw" ? irToTs(body.schema) : "unknown"}`
2399
+ else {
2400
+ const schema = body.kind === "raw" ? body.schema : body.kind === "multipart" ? body.schema : undefined
2401
+ entry = `form: ${schema ? irToTs(schema, 0, FORM_BINARY_TS) : "Record<string, unknown>"}`
2336
2402
  }
2403
+ if (body.required) mandatoryParts.push(entry)
2404
+ else optionalParts.push(entry)
2337
2405
  }
2338
2406
 
2339
2407
  const allParts = [...mandatoryParts, ...optionalParts]
@@ -2342,49 +2410,31 @@ function emitSDKInputType(op: Record<string, unknown>, path: string): { hasManda
2342
2410
  }
2343
2411
 
2344
2412
  /* extract response type for an operation */
2345
- function emitSDKResponseType(op: Record<string, unknown>): string {
2346
- const responses = op.responses as Record<string, Record<string, unknown>> | undefined
2347
- if (!responses) return "void"
2348
-
2349
- /* check for SSE — shape mirrors runtime _SSEEvent (event/id/retry all optional) */
2350
- for (const response of Object.values(responses)) {
2351
- const content = response.content as Record<string, unknown> | undefined
2352
- if (content && "text/event-stream" in content)
2353
- return "AsyncIterable<{ data: string; event?: string; id?: string; retry?: number }>"
2354
- }
2413
+ function emitSDKResponseType(op: IROperation): string {
2414
+ /* SSE — shape mirrors runtime _SSEEvent (event/id/retry all optional) */
2415
+ if (op.extensions.sse) return "AsyncIterable<{ data: string; event?: string; id?: string; retry?: number }>"
2355
2416
 
2356
- /* collect success response types (2xx) */
2417
+ /* collect success response types (2xx) — `default` and `4XX`-style keys are never success */
2357
2418
  const successTypes: string[] = []
2358
- for (const [status, response] of Object.entries(responses)) {
2359
- const code = Number.parseInt(status, 10)
2360
- if (code < 200 || code >= 300) continue
2361
- if (code === 204) {
2419
+ for (const [status, response] of Object.entries(op.responses)) {
2420
+ if (!/^2(?:[0-9][0-9]|XX)$/i.test(status)) continue
2421
+ const code = /^2XX$/i.test(status) ? 200 : Number(status)
2422
+ if (code === 204 || code === 205 || !response.contentType) {
2362
2423
  successTypes.push("null")
2363
2424
  continue
2364
2425
  }
2365
- const content = response.content as Record<string, Record<string, unknown>> | undefined
2366
- if (!content) {
2367
- successTypes.push("null")
2368
- continue
2369
- }
2370
- if (content["application/json"]?.schema) {
2371
- successTypes.push(jsonSchemaToTS(content["application/json"].schema as Record<string, unknown>))
2372
- } else if ("application/octet-stream" in content) {
2373
- successTypes.push("ArrayBuffer")
2426
+ /* the same classes the runtime's body parser reads: JSON, text, otherwise bytes */
2427
+ if (isJsonMedia(response.contentType)) {
2428
+ successTypes.push(response.schema ? irToTs(response.schema) : "unknown")
2429
+ } else if (isTextMedia(response.contentType)) {
2430
+ successTypes.push("string")
2374
2431
  } else {
2375
- const keys = Object.keys(content)
2376
- const isText = keys.some((k) => k.startsWith("text/") || k === "application/xml")
2377
- if (isText) {
2378
- successTypes.push("string")
2379
- } else if (keys.length > 0) {
2380
- successTypes.push("ArrayBuffer")
2381
- }
2432
+ successTypes.push("ArrayBuffer")
2382
2433
  }
2383
2434
  }
2384
2435
 
2385
2436
  if (successTypes.length === 0) return "void"
2386
- if (successTypes.length === 1) return successTypes[0]
2387
- return successTypes.join(" | ")
2437
+ return [...new Set(successTypes)].join(" | ")
2388
2438
  }
2389
2439
 
2390
2440
  /* detect whether a JSON schema matches the standard error envelope shape */
@@ -2424,25 +2474,19 @@ export function isStandardErrEnvelope(schema: Record<string, unknown>): { keys:
2424
2474
  }
2425
2475
 
2426
2476
  /* extract error types by status code for an operation (non-2xx responses) */
2427
- function emitSDKErrorsByStatusType(op: Record<string, unknown>): string | null {
2428
- const responses = op.responses as Record<string, Record<string, unknown>> | undefined
2429
- if (!responses) return null
2430
-
2477
+ function emitSDKErrorsByStatusType(op: IROperation, resolve: (schema: IRSchema) => IRSchema): string | null {
2431
2478
  const entries: string[] = []
2432
- for (const [status, response] of Object.entries(responses)) {
2433
- const code = Number.parseInt(status, 10)
2434
- if (code < 400) continue /* only error responses */
2435
-
2436
- const content = response.content as Record<string, Record<string, unknown>> | undefined
2437
- const schema = content?.["application/json"]?.schema as Record<string, unknown> | undefined
2438
- if (!schema) continue
2439
-
2440
- const envelope = isStandardErrEnvelope(schema)
2479
+ for (const [status, response] of Object.entries(op.responses)) {
2480
+ /* only concrete error statuses: `default` and `4XX` have no single status to key on */
2481
+ if (!/^[45][0-9][0-9]$/.test(status)) continue
2482
+ if (!response.schema || !response.contentType || !isJsonMedia(response.contentType)) continue
2483
+ const code = Number(status)
2484
+ const envelope = irErrorEnvelope(response.schema, resolve)
2441
2485
  if (envelope) {
2442
2486
  const keyUnion = envelope.keys.map((k) => JSON.stringify(k)).join(" | ")
2443
2487
  entries.push(`${code}: _ErrEnvelope<${envelope.status}, ${keyUnion}>`)
2444
2488
  } else {
2445
- entries.push(`${code}: ${jsonSchemaToTS(schema)}`)
2489
+ entries.push(`${code}: ${irToTs(response.schema)}`)
2446
2490
  }
2447
2491
  }
2448
2492
 
@@ -2502,7 +2546,7 @@ function buildTypeAliases(
2502
2546
  const aliases = new Map<string, string>()
2503
2547
  const lines: string[] = []
2504
2548
  let idx = 0
2505
- for (const [typeStr, count] of [...counts.entries()].sort(([a], [b]) => a.localeCompare(b))) {
2549
+ for (const [typeStr, count] of [...counts.entries()].sort(([a], [b]) => compareCodeUnits(a, b))) {
2506
2550
  if (count >= minCount || typeStr.length > 200) {
2507
2551
  const alias = `${prefix}${idx++}`
2508
2552
  aliases.set(typeStr, alias)
@@ -2703,6 +2747,7 @@ function buildSDKTypes(
2703
2747
  l.push("\tauthHeaderName?: string")
2704
2748
  l.push("\tauthHeaderPrefix?: string")
2705
2749
  l.push("\tonLog?: (entry: _LogEntry) => void")
2750
+ l.push('\tredirect?: "error" | "follow" | "manual" | "same-origin"')
2706
2751
  l.push("\tsortSearchParams?: boolean")
2707
2752
  l.push("\tsseMaxBufferChars?: number")
2708
2753
  l.push("\tstate?: Record<string, unknown>")
@@ -2770,13 +2815,14 @@ function emitNestedMapNode(node: NestedServiceNode, indent: string): string[] {
2770
2815
  const parts = [`method: ${JSON.stringify(entry.method)}`, `path: ${JSON.stringify(entry.path)}`]
2771
2816
  if (entry.params) parts.push(`params: ${JSON.stringify(entry.params)}`)
2772
2817
  if (entry.sse) parts.push("sse: true")
2818
+ if (entry.wildcard) parts.push(`wildcard: ${JSON.stringify(entry.wildcard)}`)
2773
2819
  if (entry.ws) parts.push("ws: true")
2774
2820
  if (entry.idempotent) parts.push("idempotent: true")
2775
2821
  if (entry.invalidate) parts.push(`invalidate: ${JSON.stringify(entry.invalidate)}`)
2776
2822
  return [`{ ${parts.join(", ")} }`]
2777
2823
  }
2778
2824
  const lines: string[] = ["{"]
2779
- for (const [key, child] of [...node.children.entries()].sort(([a], [b]) => a.localeCompare(b))) {
2825
+ for (const [key, child] of [...node.children.entries()].sort(([a], [b]) => compareCodeUnits(a, b))) {
2780
2826
  const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
2781
2827
  const childLines = emitNestedMapNode(child, `${indent}\t`)
2782
2828
  if (childLines.length === 1) {
@@ -2795,7 +2841,7 @@ function buildSDKMap(nestedMap: Map<string, NestedServiceNode>): string {
2795
2841
  const l: string[] = []
2796
2842
 
2797
2843
  l.push("export const serviceMap = {")
2798
- for (const [key, node] of [...nestedMap.entries()].sort(([a], [b]) => a.localeCompare(b))) {
2844
+ for (const [key, node] of [...nestedMap.entries()].sort(([a], [b]) => compareCodeUnits(a, b))) {
2799
2845
  const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
2800
2846
  const childLines = emitNestedMapNode(node, "\t")
2801
2847
  if (childLines.length === 1) {
@@ -3483,6 +3529,7 @@ type _ServiceEntry = {
3483
3529
  \tparams?: readonly string[]
3484
3530
  \tpath: string
3485
3531
  \tsse?: boolean
3532
+ \twildcard?: string
3486
3533
  \tws?: boolean
3487
3534
  }
3488
3535
 
@@ -3511,6 +3558,16 @@ type _RequestMeta = {
3511
3558
  \tselector: string
3512
3559
  \tseqSnapshot: number
3513
3560
  }
3561
+
3562
+ type _StaleEntry = { by: string[]; refreshed?: Set<string>; seq: number; until: number }
3563
+
3564
+ /* a path parameter value that would change the request's path once a URL parser normalizes it */
3565
+ class _PathParamError extends Error {
3566
+ \tconstructor(message: string) {
3567
+ \t\tsuper(message)
3568
+ \t\tthis.name = "PathParamError"
3569
+ \t}
3570
+ }
3514
3571
  `
3515
3572
  }
3516
3573
 
@@ -3555,7 +3612,8 @@ export class ${n}<TThrow extends boolean = false> {
3555
3612
  \t#resourceCache = new Map<string, Record<string, unknown>>()
3556
3613
  \t#searchSerializer: (query: Record<string, unknown>) => URLSearchParams
3557
3614
  \t#staleTime: number
3558
- \t#staleUntil: Map<string, { by: string[]; seq: number; until: number }> | null
3615
+ \t#staleUntil: Map<string, _StaleEntry> | null
3616
+ \t#stalePatterns = new Map<string, Map<string, _StaleEntry>>()
3559
3617
  \t#patternRegexCache = new Map<string, RegExp>()
3560
3618
  \t#invalidationSeq = 0
3561
3619
  \t#staleMaxEntries: number
@@ -3564,6 +3622,8 @@ export class ${n}<TThrow extends boolean = false> {
3564
3622
  \t#sseMaxBufferChars: number
3565
3623
  \t#disposeCtrl = new AbortController()
3566
3624
  \t#disposed = false
3625
+ \t#refreshing: Promise<string | null> | null = null
3626
+ \t#token: string | null = null
3567
3627
  `
3568
3628
  }
3569
3629
 
@@ -3576,9 +3636,7 @@ function sdkClientConstructor(n: string): string {
3576
3636
  \t\tthis.#fetchFn = config.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : globalThis.fetch)
3577
3637
  \t\tthis.#searchSerializer = config.buildSearchParams ?? ((q: Record<string, unknown>) => this.#serializeSearch(q))
3578
3638
  \t\tthis.#staleTime = config.invalidation?.staleTime ?? 0
3579
- \t\tthis.#staleUntil = this.#staleTime > 0
3580
- \t\t\t? new Map<string, { by: string[]; seq: number; until: number }>()
3581
- \t\t\t: null
3639
+ \t\tthis.#staleUntil = this.#staleTime > 0 ? new Map<string, _StaleEntry>() : null
3582
3640
  \t\tthis.#staleMaxEntries = config.invalidation?.staleMaxEntries ?? 1000
3583
3641
  \t\tthis.#maxSourcesPerTarget = Math.max(config.invalidation?.maxSourcesPerTarget ?? 16, 1)
3584
3642
  \t\tthis.#maxErrorMessageChars = config.maxErrorMessageChars ?? 512
@@ -3589,41 +3647,45 @@ function sdkClientConstructor(n: string): string {
3589
3647
  function sdkClientProxy(n: string): string {
3590
3648
  return `
3591
3649
  \t\tconst self = this
3650
+ \t\tconst callable = (entry: _ServiceEntry, name: string): ((input?: Record<string, unknown>) => unknown) => {
3651
+ \t\t\tconst entryPath = self.#toColonParams(entry.path, entry.wildcard)
3652
+ \t\t\tlet fn: (input?: Record<string, unknown>) => unknown
3653
+ \t\t\tif (entry.ws) {
3654
+ \t\t\t\tfn = (input?: Record<string, unknown>) => self.#connectWS(entry, entryPath, (input ?? {}) as _RequestOptions)
3655
+ \t\t\t} else if (entry.sse) {
3656
+ \t\t\t\tfn = (input?: Record<string, unknown>) => self.#requestSSE(entry, entryPath, (input ?? {}) as _RequestOptions)
3657
+ \t\t\t} else {
3658
+ \t\t\t\tfn = (input?: Record<string, unknown>) => self.#request(entry, input ?? {})
3659
+ \t\t\t}
3660
+ \t\t\tObject.defineProperty(fn, "name", { value: name })
3661
+ \t\t\treturn fn
3662
+ \t\t}
3663
+ \t\tconst isEntry = (node: unknown): node is _ServiceEntry =>
3664
+ \t\t\ttypeof node === "object" && node !== null && Object.hasOwn(node, "method") && typeof (node as Record<string, unknown>)["method"] === "string"
3665
+ \t\t/* only the map's own keys are operations — never toString, constructor or then */
3666
+ \t\tconst childOf = (node: _ServiceMapNode, key: string): _ServiceMapNode | undefined =>
3667
+ \t\t\tObject.hasOwn(node, key) ? (node as Record<string, _ServiceMapNode>)[key] : undefined
3668
+
3592
3669
  \t\tfunction makeNodeProxy(node: _ServiceMapNode, path: string[]): object {
3593
3670
  \t\t\tconst actionCache = new Map<string, (input?: Record<string, unknown>) => unknown>()
3594
3671
  \t\t\tconst childCache = new Map<string, object>()
3595
3672
  \t\t\treturn new Proxy({} as Record<string, unknown>, {
3596
- \t\t\t\tget: (_, key: string | symbol) => {
3597
- \t\t\t\t\tif (typeof key === "symbol") return undefined
3598
- \t\t\t\t\tconst child = (node as Record<string, unknown>)[key]
3599
- \t\t\t\t\tif (child === undefined) return undefined
3673
+ \t\t\t\tget: (target, key: string | symbol) => {
3674
+ \t\t\t\t\tif (typeof key === "symbol") return Reflect.get(target, key)
3675
+ \t\t\t\t\tconst child = childOf(node, key)
3676
+ \t\t\t\t\tif (child === undefined) return Reflect.get(target, key)
3600
3677
 
3601
- \t\t\t\t\t/* leaf: entry has a "method" string field */
3602
- \t\t\t\t\tif (typeof (child as Record<string, unknown>)["method"] === "string") {
3678
+ \t\t\t\t\tif (isEntry(child)) {
3603
3679
  \t\t\t\t\t\tconst cached = actionCache.get(key)
3604
3680
  \t\t\t\t\t\tif (cached) return cached
3605
- \t\t\t\t\t\tconst entry = child as _ServiceEntry
3606
- \t\t\t\t\t\tconst entryPath = self.#toColonParams(entry.path)
3607
- \t\t\t\t\t\tlet fn: (input?: Record<string, unknown>) => unknown
3608
- \t\t\t\t\t\tif (entry.ws) {
3609
- \t\t\t\t\t\t\tfn = (input?: Record<string, unknown>) =>
3610
- \t\t\t\t\t\t\t\tself.#connectWS(entry, entryPath, (input ?? {}) as _RequestOptions)
3611
- \t\t\t\t\t\t} else if (entry.sse) {
3612
- \t\t\t\t\t\t\tfn = (input?: Record<string, unknown>) =>
3613
- \t\t\t\t\t\t\t\tself.#requestSSE(entry, entryPath, (input ?? {}) as _RequestOptions)
3614
- \t\t\t\t\t\t} else {
3615
- \t\t\t\t\t\t\tfn = (input?: Record<string, unknown>) =>
3616
- \t\t\t\t\t\t\t\tself.#request(entry, input ?? {})
3617
- \t\t\t\t\t\t}
3618
- \t\t\t\t\t\tObject.defineProperty(fn, "name", { value: [...path, key].join(".") })
3681
+ \t\t\t\t\t\tconst fn = callable(child, [...path, key].join("."))
3619
3682
  \t\t\t\t\t\tactionCache.set(key, fn)
3620
3683
  \t\t\t\t\t\treturn fn
3621
3684
  \t\t\t\t\t}
3622
3685
 
3623
- \t\t\t\t\t/* namespace: recurse */
3624
3686
  \t\t\t\t\tconst cachedChild = childCache.get(key)
3625
3687
  \t\t\t\t\tif (cachedChild) return cachedChild
3626
- \t\t\t\t\tconst childProxy = makeNodeProxy(child as _ServiceMapNode, [...path, key])
3688
+ \t\t\t\t\tconst childProxy = makeNodeProxy(child, [...path, key])
3627
3689
  \t\t\t\t\tchildCache.set(key, childProxy)
3628
3690
  \t\t\t\t\treturn childProxy
3629
3691
  \t\t\t\t},
@@ -3632,57 +3694,32 @@ function sdkClientProxy(n: string): string {
3632
3694
 
3633
3695
  \t\treturn new Proxy(this, {
3634
3696
  \t\t\tget: (target, key: string | symbol) => {
3635
- \t\t\t\tif (typeof key === "symbol") return Reflect.get(target, key)
3697
+ \t\t\t\t/* the client's own members (state, dispose, toString, ...) always win */
3698
+ \t\t\t\tif (typeof key === "symbol" || key in target) return Reflect.get(target, key)
3636
3699
 
3637
3700
  \t\t\t\tconst cached = target.#resourceCache.get(key)
3638
3701
  \t\t\t\tif (cached) return cached
3639
3702
 
3640
- \t\t\t\tconst node = (serviceMap as _ServiceMap)[key]
3641
- \t\t\t\tif (node === undefined) return Reflect.get(target, key)
3703
+ \t\t\t\tconst node = childOf(serviceMap as _ServiceMap, key)
3704
+ \t\t\t\tif (node === undefined) return undefined
3642
3705
 
3643
3706
  \t\t\t\t/* root-level leaf (single-segment operationId) */
3644
- \t\t\t\tif (typeof (node as Record<string, unknown>)["method"] === "string") {
3645
- \t\t\t\t\tconst entry = node as _ServiceEntry
3646
- \t\t\t\t\tconst entryPath = target.#toColonParams(entry.path)
3647
- \t\t\t\t\tlet fn: (input?: Record<string, unknown>) => unknown
3648
- \t\t\t\t\tif (entry.ws) {
3649
- \t\t\t\t\t\tfn = (input?: Record<string, unknown>) =>
3650
- \t\t\t\t\t\t\ttarget.#connectWS(entry, entryPath, (input ?? {}) as _RequestOptions)
3651
- \t\t\t\t\t} else if (entry.sse) {
3652
- \t\t\t\t\t\tfn = (input?: Record<string, unknown>) =>
3653
- \t\t\t\t\t\t\ttarget.#requestSSE(entry, entryPath, (input ?? {}) as _RequestOptions)
3654
- \t\t\t\t\t} else {
3655
- \t\t\t\t\t\tfn = (input?: Record<string, unknown>) =>
3656
- \t\t\t\t\t\t\ttarget.#request(entry, input ?? {})
3657
- \t\t\t\t\t}
3658
- \t\t\t\t\tObject.defineProperty(fn, "name", { value: key })
3707
+ \t\t\t\tif (isEntry(node)) {
3708
+ \t\t\t\t\tconst fn = callable(node, key)
3659
3709
  \t\t\t\t\ttarget.#resourceCache.set(key, fn as unknown as Record<string, unknown>)
3660
3710
  \t\t\t\t\treturn fn
3661
3711
  \t\t\t\t}
3662
3712
 
3663
3713
  \t\t\t\t/* namespace node — check for single _call promotion */
3664
- \t\t\t\tconst nodeRecord = node as Record<string, unknown>
3665
- \t\t\t\tconst nodeKeys = Object.keys(nodeRecord)
3666
- \t\t\t\tif (nodeKeys.length === 1 && nodeKeys[0] === "_call") {
3667
- \t\t\t\t\tconst entry = nodeRecord["_call"] as _ServiceEntry
3668
- \t\t\t\t\tconst entryPath = target.#toColonParams(entry.path)
3669
- \t\t\t\t\tlet fn: (input?: Record<string, unknown>) => unknown
3670
- \t\t\t\t\tif (entry.ws) {
3671
- \t\t\t\t\t\tfn = (input?: Record<string, unknown>) =>
3672
- \t\t\t\t\t\t\ttarget.#connectWS(entry, entryPath, (input ?? {}) as _RequestOptions)
3673
- \t\t\t\t\t} else if (entry.sse) {
3674
- \t\t\t\t\t\tfn = (input?: Record<string, unknown>) =>
3675
- \t\t\t\t\t\t\ttarget.#requestSSE(entry, entryPath, (input ?? {}) as _RequestOptions)
3676
- \t\t\t\t\t} else {
3677
- \t\t\t\t\t\tfn = (input?: Record<string, unknown>) =>
3678
- \t\t\t\t\t\t\ttarget.#request(entry, input ?? {})
3679
- \t\t\t\t\t}
3680
- \t\t\t\t\tObject.defineProperty(fn, "name", { value: key })
3714
+ \t\t\t\tconst nodeKeys = Object.keys(node)
3715
+ \t\t\t\tconst only = childOf(node, "_call")
3716
+ \t\t\t\tif (nodeKeys.length === 1 && only !== undefined && isEntry(only)) {
3717
+ \t\t\t\t\tconst fn = callable(only, key)
3681
3718
  \t\t\t\t\ttarget.#resourceCache.set(key, fn as unknown as Record<string, unknown>)
3682
3719
  \t\t\t\t\treturn fn
3683
3720
  \t\t\t\t}
3684
3721
 
3685
- \t\t\t\tconst proxy = makeNodeProxy(node as _ServiceMapNode, [key])
3722
+ \t\t\t\tconst proxy = makeNodeProxy(node, [key])
3686
3723
  \t\t\t\ttarget.#resourceCache.set(key, proxy as Record<string, unknown>)
3687
3724
  \t\t\t\treturn proxy
3688
3725
  \t\t\t},
@@ -3693,44 +3730,113 @@ function sdkClientProxy(n: string): string {
3693
3730
 
3694
3731
  function sdkClientInterpolatePath(): string {
3695
3732
  return `
3696
- \t#interpolatePath(path: string, params: Record<string, string>): string {
3697
- \t\treturn path.replace(/:(\\w+)/g, (_, key: string) => {
3698
- \t\t\tconst val = params[key]
3699
- \t\t\tif (val === undefined) throw new Error(\`Missing path param: \${key}\`)
3700
- \t\t\treturn encodeURIComponent(val)
3701
- \t\t})
3733
+ \t/* Keep in sync with client/path.ts — :name (whole segment), *name (rest), {name} (inside a segment). */
3734
+ \t#encodeSegmentValue(key: string, value: string): string {
3735
+ \t\tif (value === "" || value === "." || value === "..") {
3736
+ \t\t\tthrow new _PathParamError(\`Invalid path param \${JSON.stringify(key)}: \${JSON.stringify(value)} is not a path segment\`)
3737
+ \t\t}
3738
+ \t\treturn encodeURIComponent(value)
3739
+ \t}
3740
+
3741
+ \t#encodeWildcardValue(key: string, value: string): string {
3742
+ \t\tif (value === "") return ""
3743
+ \t\treturn value.split("/").map((part) => this.#encodeSegmentValue(key, part)).join("/")
3744
+ \t}
3745
+
3746
+ \t#paramValue(params: Record<string, string> | undefined, key: string): string | undefined {
3747
+ \t\tif (!params || !Object.hasOwn(params, key)) return undefined
3748
+ \t\tconst raw = params[key] as unknown
3749
+ \t\tif (raw === undefined || raw === null) return undefined
3750
+ \t\treturn typeof raw === "string" ? raw : String(raw)
3751
+ \t}
3752
+
3753
+ \t#interpolatePath(path: string, params: Record<string, string> | undefined, partial = false): string {
3754
+ \t\tconst segments = path.split("/")
3755
+ \t\tconst out: string[] = []
3756
+ \t\tfor (let i = 0; i < segments.length; i++) {
3757
+ \t\t\tconst seg = segments[i] ?? ""
3758
+ \t\t\tif (seg.length > 1 && seg.charCodeAt(0) === 58) {
3759
+ \t\t\t\tconst optional = seg.endsWith("?")
3760
+ \t\t\t\tconst name = optional ? seg.slice(1, -1) : seg.slice(1)
3761
+ \t\t\t\tconst value = this.#paramValue(params, name)
3762
+ \t\t\t\tif (value === undefined) {
3763
+ \t\t\t\t\tif (partial) { out.push(seg); continue }
3764
+ \t\t\t\t\tif (optional) continue
3765
+ \t\t\t\t\tthrow new _PathParamError(\`Missing path param: \${name}\`)
3766
+ \t\t\t\t}
3767
+ \t\t\t\tout.push(this.#encodeSegmentValue(name, value))
3768
+ \t\t\t\tcontinue
3769
+ \t\t\t}
3770
+ \t\t\tif (seg.charCodeAt(0) === 42 && i === segments.length - 1) {
3771
+ \t\t\t\tconst name = seg.length > 1 ? seg.slice(1) : "*"
3772
+ \t\t\t\tconst value = this.#paramValue(params, name)
3773
+ \t\t\t\tif (value === undefined) {
3774
+ \t\t\t\t\tif (partial) { out.push(seg); continue }
3775
+ \t\t\t\t\tif (seg.length === 1) { out.push(""); continue }
3776
+ \t\t\t\t\tthrow new _PathParamError(\`Missing path param: \${name}\`)
3777
+ \t\t\t\t}
3778
+ \t\t\t\tout.push(this.#encodeWildcardValue(name, value))
3779
+ \t\t\t\tcontinue
3780
+ \t\t\t}
3781
+ \t\t\tout.push(
3782
+ \t\t\t\tseg.replace(/\\{([^{}/]+)\\}/g, (match: string, name: string) => {
3783
+ \t\t\t\t\tconst value = this.#paramValue(params, name)
3784
+ \t\t\t\t\tif (value === undefined) {
3785
+ \t\t\t\t\t\tif (partial) return match
3786
+ \t\t\t\t\t\tthrow new _PathParamError(\`Missing path param: \${name}\`)
3787
+ \t\t\t\t\t}
3788
+ \t\t\t\t\treturn this.#encodeSegmentValue(name, value)
3789
+ \t\t\t\t}),
3790
+ \t\t\t)
3791
+ \t\t}
3792
+ \t\treturn out.join("/")
3793
+ \t}
3794
+
3795
+ \t#hasPlaceholder(path: string): boolean {
3796
+ \t\tfor (const seg of path.split("/")) {
3797
+ \t\t\tif (seg.length > 1 && seg.charCodeAt(0) === 58) return true
3798
+ \t\t\tif (seg.charCodeAt(0) === 42) return true
3799
+ \t\t\tif (/\\{[^{}/]+\\}/.test(seg)) return true
3800
+ \t\t}
3801
+ \t\treturn false
3702
3802
  \t}
3703
3803
  `
3704
3804
  }
3705
3805
 
3706
3806
  function sdkClientToColonParams(): string {
3707
3807
  return `
3708
- \t#toColonParams(path: string): string {
3709
- \t\treturn path.replace(/\\{(\\w+)\\}/g, ":$1")
3808
+ \t/* OpenAPI template → route pattern: a whole-segment {name} is :name, the wildcard *name; {name} inside a segment stays */
3809
+ \t#toColonParams(path: string, wildcard?: string): string {
3810
+ \t\treturn path
3811
+ \t\t\t.split("/")
3812
+ \t\t\t.map((seg) => {
3813
+ \t\t\t\tconst m = /^\\{([^{}/]+)\\}$/.exec(seg)
3814
+ \t\t\t\tif (!m) return seg
3815
+ \t\t\t\treturn m[1] === wildcard ? \`*\${m[1]}\` : \`:\${m[1]}\`
3816
+ \t\t\t})
3817
+ \t\t\t.join("/")
3710
3818
  \t}
3711
3819
  `
3712
3820
  }
3713
3821
 
3714
3822
  function sdkClientResolveInvalidationTargets(): string {
3715
3823
  return `
3824
+ \t/* params that are present are substituted; a target left partly unresolved stays a narrower pattern */
3716
3825
  \t#resolveInvalidationTargets(
3717
3826
  \t\ttargets: readonly string[],
3718
3827
  \t\tparams: Record<string, string> | undefined,
3719
3828
  \t): string[] {
3720
3829
  \t\tconst resolved: string[] = []
3721
3830
  \t\tfor (const target of targets) {
3722
- \t\t\tif (!params || !target.includes(":")) { resolved.push(target); continue }
3723
3831
  \t\t\tconst spaceIdx = target.indexOf(" ")
3832
+ \t\t\tif (spaceIdx <= 0) continue
3724
3833
  \t\t\tconst targetMethod = target.slice(0, spaceIdx)
3725
3834
  \t\t\tconst targetPath = target.slice(spaceIdx + 1)
3726
- \t\t\tlet hasUnresolved = false
3727
- \t\t\tconst replaced = targetPath.replace(/:(\\w+)/g, (match, key: string) => {
3728
- \t\t\t\tconst val = params[key]
3729
- \t\t\t\tif (val === undefined) { hasUnresolved = true; return match }
3730
- \t\t\t\treturn encodeURIComponent(val)
3731
- \t\t\t})
3732
- \t\t\tif (hasUnresolved) continue
3733
- \t\t\tresolved.push(\`\${targetMethod} \${replaced}\`)
3835
+ \t\t\ttry {
3836
+ \t\t\t\tresolved.push(\`\${targetMethod} \${this.#interpolatePath(targetPath, params, true)}\`)
3837
+ \t\t\t} catch {
3838
+ \t\t\t\t/* a param value that is not a path segment marks nothing */
3839
+ \t\t\t}
3734
3840
  \t\t}
3735
3841
  \t\treturn resolved
3736
3842
  \t}
@@ -3742,11 +3848,27 @@ function sdkClientPathMatchesPattern(): string {
3742
3848
  \t#pathMatchesPattern(concretePath: string, pattern: string): boolean {
3743
3849
  \t\tlet re = this.#patternRegexCache.get(pattern)
3744
3850
  \t\tif (!re) {
3745
- \t\t\tconst escaped = pattern
3746
- \t\t\t\t.replace(/:[^\\/]+/g, "\\x00")
3747
- \t\t\t\t.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")
3748
- \t\t\t\t.replace(/\\x00/g, "[^/]+")
3749
- \t\t\tre = new RegExp(\`^\${escaped}$\`)
3851
+ \t\t\tconst esc = (text: string) => text.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")
3852
+ \t\t\tconst segments = pattern.split("/")
3853
+ \t\t\tlet source = ""
3854
+ \t\t\tfor (let i = 0; i < segments.length; i++) {
3855
+ \t\t\t\tconst seg = segments[i] ?? ""
3856
+ \t\t\t\tconst sep = i === 0 ? "" : "/"
3857
+ \t\t\t\tif (seg.length > 1 && seg.charCodeAt(0) === 58) {
3858
+ \t\t\t\t\tsource += seg.endsWith("?") ? \`(?:\${sep}[^/]+)?\` : \`\${sep}[^/]+\`
3859
+ \t\t\t\t\tcontinue
3860
+ \t\t\t\t}
3861
+ \t\t\t\tif (seg.charCodeAt(0) === 42 && i === segments.length - 1) {
3862
+ \t\t\t\t\tsource += \`(?:\${sep}.*)?\`
3863
+ \t\t\t\t\tcontinue
3864
+ \t\t\t\t}
3865
+ \t\t\t\tsource += sep + seg.split(/\\{[^{}/]+\\}/).map(esc).join("[^/]+")
3866
+ \t\t\t}
3867
+ \t\t\tre = new RegExp(\`^\${source}$\`)
3868
+ \t\t\tif (this.#patternRegexCache.size >= 1024) {
3869
+ \t\t\t\tconst oldest = this.#patternRegexCache.keys().next().value
3870
+ \t\t\t\tif (oldest !== undefined) this.#patternRegexCache.delete(oldest)
3871
+ \t\t\t}
3750
3872
  \t\t\tthis.#patternRegexCache.set(pattern, re)
3751
3873
  \t\t}
3752
3874
  \t\treturn re.test(concretePath)
@@ -3756,35 +3878,31 @@ function sdkClientPathMatchesPattern(): string {
3756
3878
 
3757
3879
  function sdkClientLookupStale(): string {
3758
3880
  return `
3881
+ \t/* Keep in sync with client/sdk.ts StaleIndex — exact keys and pattern keys are kept apart, so a
3882
+ \t concrete path containing ":" is never read as a pattern, and a lookup scans only its method. */
3759
3883
  \t#lookupStale(
3760
3884
  \t\tconcreteSelector: string,
3761
3885
  \t\tconcretePath: string,
3762
3886
  \t\tmethod: string,
3763
3887
  \t\tnow: number,
3764
3888
  \t): { by: string[]; isStale: boolean } {
3765
- \t\tconst allBy: string[] = []
3766
-
3889
+ \t\tconst by = new Set<string>()
3767
3890
  \t\tconst exact = this.#staleUntil?.get(concreteSelector)
3768
- \t\tif (exact && exact.until > now) allBy.push(...exact.by)
3769
- \t\telse if (exact) this.#staleUntil?.delete(concreteSelector)
3770
-
3771
- \t\tconst expired: string[] = []
3772
- \t\tif (this.#staleUntil) {
3773
- \t\t\tfor (const [key, entry] of this.#staleUntil) {
3774
- \t\t\t\tif (entry.until <= now) { expired.push(key); continue }
3775
- \t\t\t\tif (!key.includes(":")) continue
3776
- \t\t\t\tconst spaceIdx = key.indexOf(" ")
3777
- \t\t\t\tconst keyMethod = key.slice(0, spaceIdx)
3778
- \t\t\t\tconst keyPattern = key.slice(spaceIdx + 1)
3779
- \t\t\t\tif (keyMethod !== method) continue
3780
- \t\t\t\tif (this.#pathMatchesPattern(concretePath, keyPattern)) {
3781
- \t\t\t\t\tallBy.push(...entry.by)
3891
+ \t\tif (exact) {
3892
+ \t\t\tif (exact.until > now) for (const m of exact.by) by.add(m)
3893
+ \t\t\telse this.#staleUntil?.delete(concreteSelector)
3894
+ \t\t}
3895
+ \t\tconst table = this.#stalePatterns.get(method)
3896
+ \t\tif (table) {
3897
+ \t\t\tfor (const [key, entry] of table) {
3898
+ \t\t\t\tif (entry.until <= now) { table.delete(key); continue }
3899
+ \t\t\t\tif (entry.refreshed?.has(concretePath)) continue
3900
+ \t\t\t\tif (this.#pathMatchesPattern(concretePath, key.slice(key.indexOf(" ") + 1))) {
3901
+ \t\t\t\t\tfor (const m of entry.by) by.add(m)
3782
3902
  \t\t\t\t}
3783
3903
  \t\t\t}
3784
- \t\t\tfor (const key of expired) this.#staleUntil.delete(key)
3785
3904
  \t\t}
3786
-
3787
- \t\treturn { by: [...new Set(allBy)], isStale: allBy.length > 0 }
3905
+ \t\treturn { by: [...by], isStale: by.size > 0 }
3788
3906
  \t}
3789
3907
  `
3790
3908
  }
@@ -3793,7 +3911,8 @@ function sdkClientCreateTypedWebSocket(): string {
3793
3911
  return `
3794
3912
  \t#createTypedWebSocket(url: string, protocols?: string | string[]): _TypedWebSocket {
3795
3913
  \t\tconst ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url)
3796
- \t\tconst listenerMap = new WeakMap<(...args: never[]) => void, EventListener>()
3914
+ \t\t/* keyed by event and handler: one function may listen to several events */
3915
+ \t\tconst listenerMap = new Map<string, WeakMap<(...args: never[]) => void, EventListener>>()
3797
3916
  \t\tconst sendBuffer: Array<ArrayBuffer | ArrayBufferView | string> = []
3798
3917
  \t\tlet buffering = true
3799
3918
 
@@ -3802,10 +3921,16 @@ function sdkClientCreateTypedWebSocket(): string {
3802
3921
  \t\t\tfor (const msg of sendBuffer) ws.send(msg as Parameters<WebSocket["send"]>[0])
3803
3922
  \t\t\tsendBuffer.length = 0
3804
3923
  \t\t})
3805
-
3806
- \t\tfunction close(code?: number, reason?: string) {
3924
+ \t\t/* a socket that never opens must not hold messages forever */
3925
+ \t\tconst drop = () => {
3807
3926
  \t\t\tbuffering = false
3808
3927
  \t\t\tsendBuffer.length = 0
3928
+ \t\t}
3929
+ \t\tws.addEventListener("close", drop)
3930
+ \t\tws.addEventListener("error", drop)
3931
+
3932
+ \t\tfunction close(code?: number, reason?: string) {
3933
+ \t\t\tdrop()
3809
3934
  \t\t\tws.close(code, reason)
3810
3935
  \t\t}
3811
3936
 
@@ -3813,7 +3938,7 @@ function sdkClientCreateTypedWebSocket(): string {
3813
3938
  \t\t\tlet wrapped: EventListener
3814
3939
  \t\t\tswitch (event) {
3815
3940
  \t\t\t\tcase "message":
3816
- \t\t\t\t\twrapped = (e: Event) => (handler as (data: string) => void)((e as MessageEvent).data)
3941
+ \t\t\t\t\twrapped = (e: Event) => (handler as (data: unknown) => void)((e as MessageEvent).data)
3817
3942
  \t\t\t\t\tbreak
3818
3943
  \t\t\t\tcase "open":
3819
3944
  \t\t\t\t\twrapped = () => (handler as () => void)()
@@ -3827,28 +3952,36 @@ function sdkClientCreateTypedWebSocket(): string {
3827
3952
  \t\t\t\tdefault:
3828
3953
  \t\t\t\t\treturn
3829
3954
  \t\t\t}
3830
- \t\t\tlistenerMap.set(handler, wrapped)
3955
+ \t\t\tlet byHandler = listenerMap.get(event)
3956
+ \t\t\tif (!byHandler) {
3957
+ \t\t\t\tbyHandler = new WeakMap()
3958
+ \t\t\t\tlistenerMap.set(event, byHandler)
3959
+ \t\t\t}
3960
+ \t\t\tbyHandler.set(handler, wrapped)
3831
3961
  \t\t\tws.addEventListener(event, wrapped)
3832
3962
  \t\t}
3833
3963
 
3834
3964
  \t\tfunction off(event: string, handler: (...args: never[]) => void): void {
3835
- \t\t\tconst wrapped = listenerMap.get(handler)
3965
+ \t\t\tconst byHandler = listenerMap.get(event)
3966
+ \t\t\tconst wrapped = byHandler?.get(handler)
3836
3967
  \t\t\tif (wrapped) {
3837
3968
  \t\t\t\tws.removeEventListener(event, wrapped)
3838
- \t\t\t\tlistenerMap.delete(handler)
3969
+ \t\t\t\tbyHandler?.delete(handler)
3839
3970
  \t\t\t}
3840
3971
  \t\t}
3841
3972
 
3842
3973
  \t\tfunction send(data: ArrayBuffer | ArrayBufferView | object | string) {
3843
- \t\t\tlet payload: ArrayBuffer | ArrayBufferView | string
3974
+ \t\t\tlet payload: ArrayBuffer | ArrayBufferView | string | Blob
3844
3975
  \t\t\tif (typeof data === "string") {
3845
3976
  \t\t\t\tpayload = data
3846
3977
  \t\t\t} else if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
3847
3978
  \t\t\t\tpayload = data
3979
+ \t\t\t} else if (typeof Blob !== "undefined" && data instanceof Blob) {
3980
+ \t\t\t\tpayload = data
3848
3981
  \t\t\t} else {
3849
3982
  \t\t\t\tpayload = JSON.stringify(data)
3850
3983
  \t\t\t}
3851
- \t\t\tif (buffering) { sendBuffer.push(payload) } else { ws.send(payload as Parameters<WebSocket["send"]>[0]) }
3984
+ \t\t\tif (buffering) { sendBuffer.push(payload as ArrayBuffer | ArrayBufferView | string) } else if (ws.readyState === 1) { ws.send(payload as Parameters<WebSocket["send"]>[0]) }
3852
3985
  \t\t}
3853
3986
 
3854
3987
  \t\tconst typed: _TypedWebSocket = { close, off, on, get readyState() { return ws.readyState }, send }
@@ -3906,12 +4039,15 @@ function sdkClientResolveBaseURL(): string {
3906
4039
  function sdkClientBuildURL(): string {
3907
4040
  return `
3908
4041
  \t#buildURL(path: string, opts: _RequestOptions): string {
3909
- \t\tlet resolvedPath = path
3910
- \t\tif (opts.params) resolvedPath = this.#interpolatePath(path, opts.params)
4042
+ \t\tconst resolvedPath = this.#interpolatePath(path, opts.params)
3911
4043
  \t\tconst baseUrl = this.#resolveBaseURL(this.#config.baseURL)
3912
4044
  \t\tconst basePath = baseUrl.pathname.endsWith("/") ? baseUrl.pathname : \`\${baseUrl.pathname}/\`
3913
4045
  \t\tconst relative = resolvedPath.startsWith("/") ? resolvedPath.slice(1) : resolvedPath
3914
4046
  \t\tconst url = new URL(\`\${basePath}\${relative}\`, baseUrl)
4047
+ \t\t/* a request (and its credentials) never leaves the configured origin */
4048
+ \t\tif (url.origin !== baseUrl.origin) {
4049
+ \t\t\tthrow new _PathParamError(\`Invalid path param: \${JSON.stringify(path)} resolves outside \${baseUrl.origin}\`)
4050
+ \t\t}
3915
4051
  \t\tfor (const [k, v] of baseUrl.searchParams.entries()) url.searchParams.append(k, v)
3916
4052
  \t\tif (opts.search) {
3917
4053
  \t\t\tconst sp = this.#searchSerializer(opts.search)
@@ -3963,9 +4099,14 @@ function sdkClientBuildHeaders(): string {
3963
4099
  \t\t\t}
3964
4100
  \t\t}
3965
4101
 
4102
+ \t\t/* a token from onAuthExpired replaces the configured one until the next refresh */
4103
+ \t\tif (this.#token !== null) {
4104
+ \t\t\theaders.set(this.#config.authHeaderName ?? "Authorization", \`\${this.#config.authHeaderPrefix ?? "Bearer "}\${this.#token}\`)
4105
+ \t\t}
4106
+
3966
4107
  \t\tif (opts.headers) {
3967
4108
  \t\t\tfor (const [k, v] of Object.entries(opts.headers)) {
3968
- \t\t\t\theaders.set(k, v)
4109
+ \t\t\t\tif (v !== undefined) headers.set(k, v)
3969
4110
  \t\t\t}
3970
4111
  \t\t}
3971
4112
 
@@ -3990,24 +4131,16 @@ function sdkClientBuildHeaders(): string {
3990
4131
 
3991
4132
  function sdkClientDoRequest(): string {
3992
4133
  return `
3993
- \tasync #doRequest(
3994
- \t\tmethod: string,
3995
- \t\tpath: string,
3996
- \t\topts: _RequestOptions,
3997
- \t\tisRetry: boolean,
3998
- \t\trequestMeta?: _RequestMeta,
3999
- \t): Promise<{ response: Response }> {
4000
- \t\tconst url = this.#buildURL(path, opts)
4001
- \t\tconst headers = await this.#buildHeaders(opts, { method, path })
4002
- \t\tlet body: BodyInit | undefined
4003
-
4134
+ \t/* the body of a request, and the content-type it needs — shared by plain requests and SSE */
4135
+ \t#buildBody(opts: _RequestOptions, headers: Headers): BodyInit | undefined {
4004
4136
  \t\tif (opts.body !== undefined) {
4005
- \t\t\theaders.set("content-type", "application/octet-stream")
4006
- \t\t\tbody = opts.body as BodyInit
4007
- \t\t} else if (opts.json !== undefined) {
4137
+ \t\t\tif (!headers.has("content-type")) headers.set("content-type", "application/octet-stream")
4138
+ \t\t\treturn opts.body as BodyInit
4139
+ \t\t}
4140
+ \t\tif (opts.json !== undefined) {
4008
4141
  \t\t\theaders.set("content-type", "application/json")
4009
4142
  \t\t\ttry {
4010
- \t\t\t\tbody = JSON.stringify(opts.json)
4143
+ \t\t\t\treturn JSON.stringify(opts.json)
4011
4144
  \t\t\t} catch (e) {
4012
4145
  \t\t\t\tthrow new _ClientError({
4013
4146
  \t\t\t\t\tbody: opts.json,
@@ -4017,50 +4150,75 @@ function sdkClientDoRequest(): string {
4017
4150
  \t\t\t\t\tstatus: 0,
4018
4151
  \t\t\t\t})
4019
4152
  \t\t\t}
4020
- \t\t} else if (opts.form !== undefined) {
4021
- \t\t\tconst hasFiles = Object.values(opts.form).some(
4022
- \t\t\t\t(v) =>
4023
- \t\t\t\t\t(typeof File !== "undefined" && v instanceof File) ||
4024
- \t\t\t\t\t(typeof Blob !== "undefined" && v instanceof Blob) ||
4025
- \t\t\t\t\t(typeof FileList !== "undefined" && v instanceof FileList) ||
4026
- \t\t\t\t\t(Array.isArray(v) &&
4027
- \t\t\t\t\t\tv.some(
4028
- \t\t\t\t\t\t\t(item) =>
4029
- \t\t\t\t\t\t\t\t(typeof File !== "undefined" && item instanceof File) ||
4030
- \t\t\t\t\t\t\t\t(typeof Blob !== "undefined" && item instanceof Blob),
4031
- \t\t\t\t\t\t)),
4032
- \t\t\t)
4033
-
4034
- \t\t\tif (hasFiles) {
4035
- \t\t\t\tconst fd = new FormData()
4036
- \t\t\t\tfor (const [k, v] of Object.entries(opts.form)) {
4037
- \t\t\t\t\tif (v === undefined || v === null) continue
4038
- \t\t\t\t\tif (typeof FileList !== "undefined" && v instanceof FileList) {
4039
- \t\t\t\t\t\tfor (let i = 0; i < v.length; i++) { const f = v[i]; if (f) fd.append(k, f) }
4040
- \t\t\t\t\t} else if (Array.isArray(v)) {
4041
- \t\t\t\t\t\tfor (const item of v) {
4042
- \t\t\t\t\t\t\tif (item instanceof File || item instanceof Blob) fd.append(k, item)
4043
- \t\t\t\t\t\t\telse fd.append(k, String(item))
4044
- \t\t\t\t\t\t}
4045
- \t\t\t\t\t} else if (v instanceof File || v instanceof Blob) {
4046
- \t\t\t\t\t\tfd.append(k, v)
4047
- \t\t\t\t\t} else {
4048
- \t\t\t\t\t\tfd.append(k, String(v))
4049
- \t\t\t\t\t}
4153
+ \t\t}
4154
+ \t\tif (opts.form === undefined) return undefined
4155
+ \t\tconst isFile = (v: unknown) =>
4156
+ \t\t\t(typeof File !== "undefined" && v instanceof File) || (typeof Blob !== "undefined" && v instanceof Blob)
4157
+ \t\tconst hasFiles = Object.values(opts.form).some(
4158
+ \t\t\t(v) =>
4159
+ \t\t\t\tisFile(v) ||
4160
+ \t\t\t\t(typeof FileList !== "undefined" && v instanceof FileList) ||
4161
+ \t\t\t\t(Array.isArray(v) && v.some(isFile)),
4162
+ \t\t)
4163
+ \t\tconst text = (v: unknown): string | null => {
4164
+ \t\t\tif (v === undefined || v === null || typeof v === "symbol") return null
4165
+ \t\t\tif (v instanceof Date) return v.toISOString()
4166
+ \t\t\tif (typeof v === "object") throw new TypeError("form: nested objects cannot be form-encoded; send json instead")
4167
+ \t\t\treturn String(v)
4168
+ \t\t}
4169
+ \t\tif (hasFiles) {
4170
+ \t\t\tconst fd = new FormData()
4171
+ \t\t\tfor (const [k, v] of Object.entries(opts.form)) {
4172
+ \t\t\t\tif (typeof FileList !== "undefined" && v instanceof FileList) {
4173
+ \t\t\t\t\tfor (let i = 0; i < v.length; i++) { const f = v[i]; if (f) fd.append(k, f) }
4174
+ \t\t\t\t\tcontinue
4050
4175
  \t\t\t\t}
4051
- \t\t\t\tbody = fd
4052
- \t\t\t} else {
4053
- \t\t\t\theaders.set("content-type", "application/x-www-form-urlencoded")
4054
- \t\t\t\tconst sp = new URLSearchParams()
4055
- \t\t\t\tfor (const [k, v] of Object.entries(opts.form)) {
4056
- \t\t\t\t\tif (v === undefined || v === null) continue
4057
- \t\t\t\t\tif (v instanceof Date) { sp.set(k, v.toISOString()); continue }
4058
- \t\t\t\t\tif (typeof v === "symbol") continue
4059
- \t\t\t\t\tsp.set(k, String(v))
4176
+ \t\t\t\tfor (const item of Array.isArray(v) ? v : [v]) {
4177
+ \t\t\t\t\tif (isFile(item)) fd.append(k, item as Blob)
4178
+ \t\t\t\t\telse {
4179
+ \t\t\t\t\t\tconst s = text(item)
4180
+ \t\t\t\t\t\tif (s !== null) fd.append(k, s)
4181
+ \t\t\t\t\t}
4060
4182
  \t\t\t\t}
4061
- \t\t\t\tbody = sp.toString()
4062
4183
  \t\t\t}
4184
+ \t\t\treturn fd
4185
+ \t\t}
4186
+ \t\theaders.set("content-type", "application/x-www-form-urlencoded")
4187
+ \t\tconst sp = new URLSearchParams()
4188
+ \t\t/* arrays repeat the key, as they do in multipart and in the query */
4189
+ \t\tfor (const [k, v] of Object.entries(opts.form)) {
4190
+ \t\t\tfor (const item of Array.isArray(v) ? v : [v]) {
4191
+ \t\t\t\tconst s = text(item)
4192
+ \t\t\t\tif (s !== null) sp.append(k, s)
4193
+ \t\t\t}
4194
+ \t\t}
4195
+ \t\treturn sp.toString()
4196
+ \t}
4197
+
4198
+ \t/* a stream body is sent once and needs duplex: "half" (Node's fetch throws without it) */
4199
+ \t#requestInit(init: RequestInit): RequestInit {
4200
+ \t\tif (typeof ReadableStream !== "undefined" && init.body instanceof ReadableStream) {
4201
+ \t\t\t;(init as RequestInit & { duplex?: string }).duplex = "half"
4063
4202
  \t\t}
4203
+ \t\treturn init
4204
+ \t}
4205
+
4206
+ \t/* the Request a response hook sees; a stream body was consumed by the send, so it is left off */
4207
+ \t#hookRequest(url: string, init: RequestInit): Request {
4208
+ \t\tconst consumed = typeof ReadableStream !== "undefined" && init.body instanceof ReadableStream
4209
+ \t\treturn new Request(url, consumed ? { ...init, body: null } : init)
4210
+ \t}
4211
+
4212
+ \tasync #doRequest(
4213
+ \t\tmethod: string,
4214
+ \t\tpath: string,
4215
+ \t\topts: _RequestOptions,
4216
+ \t\tisRetry: boolean,
4217
+ \t\trequestMeta?: _RequestMeta,
4218
+ \t): Promise<{ done: () => void; response: Response }> {
4219
+ \t\tconst url = this.#buildURL(path, opts)
4220
+ \t\tconst headers = await this.#buildHeaders(opts, { method, path })
4221
+ \t\tlet body = this.#buildBody(opts, headers)
4064
4222
 
4065
4223
  \t\tif (this.#config.onRequest) {
4066
4224
  \t\t\tconst reqCtx: { body?: BodyInit; headers: Headers; invalidatedBy?: string[]; isStale?: boolean; method: string; path: string; selector?: string; state: Record<string, unknown>; url: string } = { body, headers, method, path, state: this.#config.state ?? {}, url }
@@ -4076,7 +4234,7 @@ function sdkClientDoRequest(): string {
4076
4234
  \t\t}
4077
4235
 
4078
4236
  \t\tconst { signal, cleanup } = this.#buildSignal(opts)
4079
- \t\tconst init: RequestInit = { body, headers, method, signal }
4237
+ \t\tconst init = this.#requestInit({ body, headers, method, signal })
4080
4238
  \t\tif (this.#config.credentials) init.credentials = this.#config.credentials
4081
4239
  \t\tif (this.#config.mode) init.mode = this.#config.mode
4082
4240
 
@@ -4086,7 +4244,7 @@ function sdkClientDoRequest(): string {
4086
4244
 
4087
4245
  \t\ttry {
4088
4246
  \t\t\tsignal?.throwIfAborted()
4089
- \t\t\tlet response = await this.#fetchFn(url, init)
4247
+ \t\t\tlet response = await this.#send(url, init)
4090
4248
 
4091
4249
  \t\t\t/* FormData, Blob, and strings can be sent again; a stream was consumed by the first attempt */
4092
4250
  \t\t\tif (response.status === 401 && this.#config.onAuthExpired && !isRetry && !(body instanceof ReadableStream)) {
@@ -4094,11 +4252,12 @@ function sdkClientDoRequest(): string {
4094
4252
  \t\t\t\tconst authPrefix = this.#config.authHeaderPrefix ?? "Bearer "
4095
4253
  \t\t\t\tconst sent = new Headers(headers).get(authName)
4096
4254
  \t\t\t\tconst rejectedToken = sent?.startsWith(authPrefix) ? sent.slice(authPrefix.length) : (sent ?? null)
4097
- \t\t\t\tconst newToken = await this.#config.onAuthExpired({ rejectedToken })
4098
- \t\t\t\tif (newToken !== null && newToken !== undefined) {
4255
+ \t\t\t\tconst newToken = await this.#refreshToken(rejectedToken)
4256
+ \t\t\t\tif (newToken !== null) {
4099
4257
  \t\t\t\t\tconst retryHeaders = new Headers(headers)
4100
4258
  \t\t\t\t\tretryHeaders.set(authName, \`\${authPrefix}\${newToken}\`)
4101
- \t\t\t\t\tresponse = await this.#fetchFn(url, { ...init, headers: retryHeaders })
4259
+ \t\t\t\t\tawait response.body?.cancel().catch(() => {})
4260
+ \t\t\t\t\tresponse = await this.#send(url, { ...init, headers: retryHeaders })
4102
4261
  \t\t\t\t}
4103
4262
  \t\t\t}
4104
4263
 
@@ -4115,12 +4274,14 @@ function sdkClientDoRequest(): string {
4115
4274
  \t\t\t\t\tisRetry,
4116
4275
  \t\t\t\t\tmethod,
4117
4276
  \t\t\t\t\tpath,
4118
- \t\t\t\t\trequest: new Request(url, init),
4277
+ \t\t\t\t\trequest: this.#hookRequest(url, init),
4119
4278
  \t\t\t\t\tresponse,
4120
4279
  \t\t\t\t\tretry: () => {
4121
4280
  \t\t\t\t\t\tif (isRetry) throw new Error("Max 1 retry per request")
4281
+ \t\t\t\t\t\tif (body instanceof ReadableStream) throw new Error("A streamed request body cannot be sent again")
4122
4282
  \t\t\t\t\t\treturn this.#doRequest(method, path, opts, true, requestMeta)
4123
4283
  \t\t\t\t\t\t\t.then(async (r) => {
4284
+ \t\t\t\t\t\t\t\tr.done()
4124
4285
  \t\t\t\t\t\t\t\tif (!r.response.ok) throw await this.#parseAsClientError(r.response)
4125
4286
  \t\t\t\t\t\t\t\treturn r.response
4126
4287
  \t\t\t\t\t\t\t})
@@ -4142,8 +4303,10 @@ function sdkClientDoRequest(): string {
4142
4303
  \t\t\t\t}
4143
4304
  \t\t\t}
4144
4305
 
4145
- \t\t\treturn { response }
4306
+ \t\t\t/* the caller releases the timer after reading the body: the timeout covers the whole exchange */
4307
+ \t\t\treturn { done: cleanup, response }
4146
4308
  \t\t} catch (err) {
4309
+ \t\t\tcleanup()
4147
4310
  \t\t\tconst _errStatus = typeof (err as { status?: unknown })?.status === "number" ? (err as { status: number }).status : undefined
4148
4311
  \t\t\tthis.#config.onLog?.({
4149
4312
  \t\t\t\tduration_ms: Date.now() - _logStart,
@@ -4154,8 +4317,82 @@ function sdkClientDoRequest(): string {
4154
4317
  \t\t\t\t...(_errStatus !== undefined ? { status: _errStatus } : {}),
4155
4318
  \t\t\t})
4156
4319
  \t\t\tthrow err
4157
- \t\t} finally {
4158
- \t\t\tcleanup()
4320
+ \t\t}
4321
+ \t}
4322
+
4323
+ \t/* one refresh for every 401 in flight; the new token is kept for later requests */
4324
+ \t#refreshToken(rejectedToken: string | null): Promise<string | null> {
4325
+ \t\tconst hook = this.#config.onAuthExpired
4326
+ \t\tif (!hook) return Promise.resolve(null)
4327
+ \t\tif (rejectedToken !== null && this.#token !== null && rejectedToken !== this.#token) {
4328
+ \t\t\t/* another request already refreshed past the rejected token */
4329
+ \t\t\treturn Promise.resolve(this.#token)
4330
+ \t\t}
4331
+ \t\tthis.#refreshing ??= Promise.resolve()
4332
+ \t\t\t.then(() => hook({ rejectedToken }))
4333
+ \t\t\t.then((token) => {
4334
+ \t\t\t\tthis.#token = typeof token === "string" && token.length > 0 ? token : null
4335
+ \t\t\t\treturn this.#token
4336
+ \t\t\t})
4337
+ \t\t\t.finally(() => {
4338
+ \t\t\t\tthis.#refreshing = null
4339
+ \t\t\t})
4340
+ \t\treturn this.#refreshing
4341
+ \t}
4342
+
4343
+ \t/* Same rules as client/http.ts; the cross-origin header allowlist comes from
4344
+ \t client/redirect-policy.ts. "same-origin" (default) follows a redirect only while it stays on
4345
+ \t the base URL's origin; "follow" also follows cross-origin ones, carrying only allowlisted
4346
+ \t headers and never a replayed body.
4347
+ \t Browsers hide redirect targets from script, so there the platform follows. */
4348
+ \tasync #send(url: string, init: RequestInit): Promise<Response> {
4349
+ \t\tconst policy = this.#config.redirect ?? "same-origin"
4350
+ \t\tconst g = globalThis as { document?: unknown; WorkerGlobalScope?: unknown }
4351
+ \t\tconst platformFollows = typeof g.document !== "undefined" || typeof g.WorkerGlobalScope !== "undefined"
4352
+ \t\tif (policy === "manual" || policy === "error" || platformFollows) {
4353
+ \t\t\treturn this.#fetchFn(url, policy === "same-origin" ? init : { ...init, redirect: policy })
4354
+ \t\t}
4355
+ \t\tconst origin = new URL(url).origin
4356
+ \t\tlet current = new URL(url)
4357
+ \t\tlet currentInit: RequestInit = { ...init, redirect: "manual" }
4358
+ \t\tconst replayable = (b: unknown) =>
4359
+ \t\t\tb == null || typeof b === "string" || (typeof Blob !== "undefined" && b instanceof Blob) || (typeof FormData !== "undefined" && b instanceof FormData) || b instanceof URLSearchParams || b instanceof ArrayBuffer || ArrayBuffer.isView(b)
4360
+ \t\tfor (let hop = 0; ; hop++) {
4361
+ \t\t\tconst response = await this.#fetchFn(current.toString(), currentInit)
4362
+ \t\t\tconst location = response.headers.get("location")
4363
+ \t\t\tif (![301, 302, 303, 307, 308].includes(response.status) || location === null || hop >= 20) return response
4364
+ \t\t\tlet target: URL
4365
+ \t\t\ttry {
4366
+ \t\t\t\ttarget = new URL(location, current)
4367
+ \t\t\t} catch {
4368
+ \t\t\t\treturn response
4369
+ \t\t\t}
4370
+ \t\t\tif (target.protocol !== "http:" && target.protocol !== "https:") return response
4371
+ \t\t\tconst crossOrigin = target.origin !== origin
4372
+ \t\t\tif (crossOrigin && policy !== "follow") return response
4373
+ \t\t\tconst nextInit: RequestInit = { ...currentInit, headers: new Headers(currentInit.headers) }
4374
+ \t\t\tif (response.status === 307 || response.status === 308) {
4375
+ \t\t\t\tif (currentInit.body != null && (crossOrigin || !replayable(currentInit.body))) return response
4376
+ \t\t\t} else {
4377
+ \t\t\t\tconst method = (currentInit.method ?? "GET").toUpperCase()
4378
+ \t\t\t\tif (response.status === 303 ? method !== "HEAD" : method === "POST") {
4379
+ \t\t\t\t\tnextInit.method = "GET"
4380
+ \t\t\t\t\tnextInit.body = undefined
4381
+ \t\t\t\t\t;(nextInit.headers as Headers).delete("content-type")
4382
+ \t\t\t\t}
4383
+ \t\t\t}
4384
+ \t\t\tif (crossOrigin) {
4385
+ \t\t\t\t/* only allowlisted headers cross: no credential, configured, per-call or hook header */
4386
+ \t\t\t\tconst safe = new Headers()
4387
+ \t\t\t\tfor (const name of [${CROSS_ORIGIN_SAFE_HEADERS.map((h) => JSON.stringify(h)).join(", ")}]) {
4388
+ \t\t\t\t\tconst value = (nextInit.headers as Headers).get(name)
4389
+ \t\t\t\t\tif (value !== null) safe.set(name, value)
4390
+ \t\t\t\t}
4391
+ \t\t\t\tnextInit.headers = safe
4392
+ \t\t\t}
4393
+ \t\t\tawait response.body?.cancel().catch(() => {})
4394
+ \t\t\tcurrent = target
4395
+ \t\t\tcurrentInit = nextInit
4159
4396
  \t\t}
4160
4397
  \t}
4161
4398
  `
@@ -4174,7 +4411,8 @@ function sdkClientParseBody(): string {
4174
4411
  \t\tif (ct === "application/octet-stream" || ct === "application/pdf") {
4175
4412
  \t\t\treturn response.arrayBuffer()
4176
4413
  \t\t}
4177
- \t\tif (ct.startsWith("text/")) return response.text()
4414
+ \t\t/* the classes the generated types use: text/*, XML, otherwise bytes */
4415
+ \t\tif (ct.startsWith("text/") || ct === "application/xml" || ct.endsWith("+xml")) return response.text()
4178
4416
  \t\t/* unknown content type \u2014 binary-safe fallback */
4179
4417
  \t\treturn response.arrayBuffer()
4180
4418
  \t}
@@ -4258,11 +4496,13 @@ function sdkClientRequestThrow(): string {
4258
4496
  \t\topts: _RequestOptions,
4259
4497
  \t\trequestMeta?: _RequestMeta,
4260
4498
  \t): Promise<unknown> {
4261
- \t\tconst { response } = await this.#doRequest(method, path, opts, false, requestMeta)
4262
- \t\tif (!response.ok) {
4263
- \t\t\tthrow await this.#parseAsClientError(response)
4499
+ \t\tconst { done, response } = await this.#doRequest(method, path, opts, false, requestMeta)
4500
+ \t\ttry {
4501
+ \t\t\tif (!response.ok) throw await this.#parseAsClientError(response)
4502
+ \t\t\treturn await this.#parseBody(response)
4503
+ \t\t} finally {
4504
+ \t\t\tdone()
4264
4505
  \t\t}
4265
- \t\treturn this.#parseBody(response)
4266
4506
  \t}
4267
4507
  `
4268
4508
  }
@@ -4280,33 +4520,34 @@ function sdkClientRequestSafe(): string {
4280
4520
  \t\tresponse: Response
4281
4521
  \t\tstatus: number
4282
4522
  \t}> {
4283
- \t\tlet doResponse: { response: Response }
4523
+ \t\tlet doResponse: { done: () => void; response: Response }
4284
4524
  \t\ttry {
4285
4525
  \t\t\tdoResponse = await this.#doRequest(method, path, opts, false, requestMeta)
4286
4526
  \t\t} catch (e) {
4287
4527
  \t\t\tif (e instanceof _ClientError) return { data: null, error: e, response: e.response, status: e.status }
4288
4528
  \t\t\tthrow e
4289
4529
  \t\t}
4290
- \t\tconst { response } = doResponse
4291
-
4292
- \t\tif (!response.ok) {
4293
- \t\t\tconst preserved = response.clone()
4294
- \t\t\tconst error = await this.#parseErrorBody(response)
4295
- \t\t\treturn {
4296
- \t\t\t\tdata: null,
4297
- \t\t\t\terror,
4298
- \t\t\t\tresponse: preserved,
4299
- \t\t\t\tstatus: response.status,
4300
- \t\t\t}
4301
- \t\t}
4302
-
4303
- \t\tlet data: unknown
4530
+ \t\tconst { done, response } = doResponse
4304
4531
  \t\ttry {
4305
- \t\t\tdata = await this.#parseBody(response)
4306
- \t\t} catch (e) {
4307
- \t\t\treturn { data: null, error: e, response, status: response.status }
4532
+ \t\t\tif (!response.ok) {
4533
+ \t\t\t\tconst preserved = response.clone()
4534
+ \t\t\t\tconst parsed = await this.#parseErrorBody(response)
4535
+ \t\t\t\t/* a failed request always has a truthy error: the JSON body, or a ClientError for any other body */
4536
+ \t\t\t\tconst error = parsed !== undefined && parsed !== null && parsed !== false && parsed !== "" && parsed !== 0
4537
+ \t\t\t\t\t? parsed
4538
+ \t\t\t\t\t: await this.#parseAsClientError(preserved.clone())
4539
+ \t\t\t\treturn { data: null, error, response: preserved, status: response.status }
4540
+ \t\t\t}
4541
+ \t\t\tlet data: unknown
4542
+ \t\t\ttry {
4543
+ \t\t\t\tdata = await this.#parseBody(response)
4544
+ \t\t\t} catch (e) {
4545
+ \t\t\t\treturn { data: null, error: e, response, status: response.status }
4546
+ \t\t\t}
4547
+ \t\t\treturn { data, error: null, response, status: response.status }
4548
+ \t\t} finally {
4549
+ \t\t\tdone()
4308
4550
  \t\t}
4309
- \t\treturn { data, error: null, response, status: response.status }
4310
4551
  \t}
4311
4552
  `
4312
4553
  }
@@ -4314,33 +4555,33 @@ function sdkClientRequestSafe(): string {
4314
4555
  function sdkClientRequest(): string {
4315
4556
  return `
4316
4557
  \tasync #request(entry: _ServiceEntry, input: Record<string, unknown>): Promise<unknown> {
4317
- \t\tconst path = this.#toColonParams(entry.path)
4558
+ \t\tconst path = this.#toColonParams(entry.path, entry.wildcard)
4318
4559
  \t\tconst method = entry.method
4319
- \t\tconst opts = input as _RequestOptions
4560
+ \t\t/* a copy: the caller's input object is never written to, so reusing it never reuses a key */
4561
+ \t\tconst opts = { ...input } as _RequestOptions
4320
4562
  \t\tif (entry.idempotent) {
4321
4563
  \t\t\tconst existing = opts.headers?.["Idempotency-Key"] ?? opts.headers?.["idempotency-key"]
4322
4564
  \t\t\tif (existing === undefined) {
4323
- \t\t\t\tconst key = opts.idempotencyKey ?? crypto.randomUUID()
4565
+ \t\t\t\tconst key = opts.idempotencyKey ?? this.#newRequestId()
4324
4566
  \t\t\t\topts.headers = { ...(opts.headers ?? {}), "Idempotency-Key": key }
4325
4567
  \t\t\t}
4326
4568
  \t\t}
4327
4569
  \t\tconst params = opts.params
4328
- \t\tif (entry.params) {
4329
- \t\t\tfor (const p of entry.params) {
4330
- \t\t\t\tif (!params || params[p] === undefined) {
4331
- \t\t\t\t\tconst err = new _ClientError({
4332
- \t\t\t\t\t\tbody: null,
4333
- \t\t\t\t\t\tdata: null,
4334
- \t\t\t\t\t\tmessage: \`Missing required path param \\\`\${p}\\\` for \${method} \${entry.path}\`,
4335
- \t\t\t\t\t\tresponse: new Response(null, { status: 0 }),
4336
- \t\t\t\t\t\tstatus: 0,
4337
- \t\t\t\t\t})
4338
- \t\t\t\t\tif (!this.#config.throwOnError) return { data: null, error: err, response: err.response, status: 0 }
4339
- \t\t\t\t\tthrow err
4340
- \t\t\t\t}
4341
- \t\t\t}
4570
+ \t\tlet cp: string
4571
+ \t\ttry {
4572
+ \t\t\tcp = this.#interpolatePath(path, params)
4573
+ \t\t} catch (e) {
4574
+ \t\t\tif (!(e instanceof _PathParamError)) throw e
4575
+ \t\t\tconst err = new _ClientError({
4576
+ \t\t\t\tbody: null,
4577
+ \t\t\t\tdata: null,
4578
+ \t\t\t\tmessage: \`\${e.message} for \${method} \${entry.path}\`,
4579
+ \t\t\t\tresponse: Response.error(),
4580
+ \t\t\t\tstatus: 0,
4581
+ \t\t\t})
4582
+ \t\t\tif (!this.#config.throwOnError) return { data: null, error: err, response: err.response, status: 0 }
4583
+ \t\t\tthrow err
4342
4584
  \t\t}
4343
- \t\tconst cp = params ? this.#interpolatePath(path, params) : path
4344
4585
  \t\tconst cs = \`\${method} \${cp}\`
4345
4586
  \t\tconst requestMeta = this.#buildRequestMeta(cs, cp, method)
4346
4587
  \t\tif (!this.#config.throwOnError) {
@@ -4370,16 +4611,16 @@ function sdkClientRequestSSE(): string {
4370
4611
  function sdkClientConnectWS(): string {
4371
4612
  return `
4372
4613
  \t#connectWS(entry: _ServiceEntry, path: string, opts: _RequestOptions): _TypedWebSocket {
4373
- \t\tlet url = this.#buildURL(path, opts).replace(/^https:\\/\\//, "wss://").replace(/^http:\\/\\//, "ws://")
4374
- \t\tif (opts.reconnectToken) {
4375
- \t\t\tconst sep = url.includes("?") ? "&" : "?"
4376
- \t\t\turl = \`\${url}\${sep}reconnect_token=\${encodeURIComponent(opts.reconnectToken)}\`
4377
- \t\t}
4378
- \t\tconst ws = this.#createTypedWebSocket(url, opts.protocols)
4614
+ \t\tconst built = new URL(this.#buildURL(path, opts))
4615
+ \t\t/* only the scheme changes: an https URL elsewhere in the query is left alone */
4616
+ \t\tif (built.protocol === "https:") built.protocol = "wss:"
4617
+ \t\telse if (built.protocol === "http:") built.protocol = "ws:"
4618
+ \t\tif (opts.reconnectToken) built.searchParams.append("reconnect_token", opts.reconnectToken)
4619
+ \t\tconst ws = this.#createTypedWebSocket(built.toString(), opts.protocols)
4379
4620
  \t\tif (entry.invalidate && entry.invalidate.length > 0) {
4380
4621
  \t\t\tconst invalidate = entry.invalidate
4381
4622
  \t\t\tconst params = opts.params
4382
- \t\t\tconst cs = \`WS \${params ? this.#interpolatePath(path, params) : path}\`
4623
+ \t\t\tconst cs = \`WS \${this.#interpolatePath(path, params)}\`
4383
4624
  \t\t\tws.on("open", () => this.#markStale(invalidate, params, cs))
4384
4625
  \t\t}
4385
4626
  \t\treturn ws
@@ -4401,43 +4642,66 @@ function sdkClientBuildRequestMeta(): string {
4401
4642
  function sdkClientMarkStale(): string {
4402
4643
  return `
4403
4644
  \t#markStale(invalidate: readonly string[], params: Record<string, string> | undefined, mutationSelector: string): void {
4404
- \t\tif (!this.#staleUntil || invalidate.length === 0) return
4645
+ \t\tconst exactTable = this.#staleUntil
4646
+ \t\tif (!exactTable || invalidate.length === 0) return
4405
4647
  \t\tconst seq = ++this.#invalidationSeq
4406
4648
  \t\tconst until = Date.now() + this.#staleTime
4407
- \t\tconst resolved = this.#resolveInvalidationTargets(invalidate, params)
4408
- \t\tfor (const target of resolved) {
4409
- \t\t\tconst existing = this.#staleUntil.get(target)
4649
+ \t\tfor (const target of this.#resolveInvalidationTargets(invalidate, params)) {
4650
+ \t\t\tconst spaceIdx = target.indexOf(" ")
4651
+ \t\t\tconst method = target.slice(0, spaceIdx)
4652
+ \t\t\tconst isPattern = this.#hasPlaceholder(target.slice(spaceIdx + 1))
4653
+ \t\t\tlet table: Map<string, _StaleEntry>
4654
+ \t\t\tif (isPattern) {
4655
+ \t\t\t\ttable = this.#stalePatterns.get(method) ?? new Map<string, _StaleEntry>()
4656
+ \t\t\t\tthis.#stalePatterns.set(method, table)
4657
+ \t\t\t} else {
4658
+ \t\t\t\ttable = exactTable
4659
+ \t\t\t}
4660
+ \t\t\tconst existing = table.get(target)
4410
4661
  \t\t\tif (existing) {
4411
- \t\t\t\tif (!existing.by.includes(mutationSelector) && existing.by.length < this.#maxSourcesPerTarget) existing.by.push(mutationSelector)
4662
+ \t\t\t\tif (!existing.by.includes(mutationSelector)) {
4663
+ \t\t\t\t\texisting.by.push(mutationSelector)
4664
+ \t\t\t\t\tif (existing.by.length > this.#maxSourcesPerTarget) existing.by.shift()
4665
+ \t\t\t\t}
4412
4666
  \t\t\t\texisting.until = until
4413
4667
  \t\t\t\texisting.seq = seq
4414
- \t\t\t} else { this.#staleUntil.set(target, { by: [mutationSelector], seq, until }) }
4668
+ \t\t\t\t/* a new mutation re-marks every instance */
4669
+ \t\t\t\texisting.refreshed?.clear()
4670
+ \t\t\t} else {
4671
+ \t\t\t\ttable.set(target, { by: [mutationSelector], refreshed: isPattern ? new Set<string>() : undefined, seq, until })
4672
+ \t\t\t}
4673
+ \t\t}
4674
+ \t\tif (exactTable.size > this.#staleMaxEntries) {
4675
+ \t\t\tconst now = Date.now()
4676
+ \t\t\tfor (const [k, entry] of exactTable) if (entry.until <= now) exactTable.delete(k)
4677
+ \t\t\t/* still over: drop the oldest marks */
4678
+ \t\t\tfor (const k of exactTable.keys()) {
4679
+ \t\t\t\tif (exactTable.size <= this.#staleMaxEntries) break
4680
+ \t\t\t\texactTable.delete(k)
4681
+ \t\t\t}
4415
4682
  \t\t}
4416
- \t\tif (this.#staleUntil.size > this.#staleMaxEntries)
4417
- \t\t\tfor (const [k, entry] of this.#staleUntil)
4418
- \t\t\t\tif (entry.until <= Date.now()) this.#staleUntil.delete(k)
4419
4683
  \t}
4420
4684
  `
4421
4685
  }
4422
4686
 
4423
4687
  function sdkClientClearStale(): string {
4424
4688
  return `
4689
+ \t/* a successful stale read clears that resource only: its exact mark, and this path's share of a pattern mark */
4425
4690
  \t#clearStale(concreteSelector: string, concretePath: string, method: string, seqSnapshot: number): void {
4426
4691
  \t\tif (!this.#staleUntil) return
4427
4692
  \t\tconst exact = this.#staleUntil.get(concreteSelector)
4428
4693
  \t\tif (exact && exact.seq <= seqSnapshot) this.#staleUntil.delete(concreteSelector)
4429
- \t\tconst toDelete: string[] = []
4430
- \t\tfor (const [key, entry] of this.#staleUntil) {
4431
- \t\t\tif (entry.seq > seqSnapshot) continue
4432
- \t\t\tif (!key.includes(":")) continue
4433
- \t\t\tconst spaceIdx = key.indexOf(" ")
4434
- \t\t\tconst keyMethod = key.slice(0, spaceIdx)
4435
- \t\t\tconst keyPattern = key.slice(spaceIdx + 1)
4436
- \t\t\tif (keyMethod === method && this.#pathMatchesPattern(concretePath, keyPattern)) {
4437
- \t\t\t\ttoDelete.push(key)
4694
+ \t\tconst table = this.#stalePatterns.get(method)
4695
+ \t\tif (!table) return
4696
+ \t\tfor (const [key, entry] of table) {
4697
+ \t\t\tif (entry.seq > seqSnapshot || !entry.refreshed) continue
4698
+ \t\t\tif (!this.#pathMatchesPattern(concretePath, key.slice(key.indexOf(" ") + 1))) continue
4699
+ \t\t\tif (entry.refreshed.size >= 1024) {
4700
+ \t\t\t\tconst oldest = entry.refreshed.values().next().value
4701
+ \t\t\t\tif (oldest !== undefined) entry.refreshed.delete(oldest)
4438
4702
  \t\t\t}
4703
+ \t\t\tentry.refreshed.add(concretePath)
4439
4704
  \t\t}
4440
- \t\tfor (const key of toDelete) this.#staleUntil.delete(key)
4441
4705
  \t}
4442
4706
  `
4443
4707
  }
@@ -4449,6 +4713,7 @@ function sdkClientDispose(): string {
4449
4713
  \t\tthis.#disposed = true
4450
4714
  \t\tthis.#disposeCtrl.abort()
4451
4715
  \t\tthis.#staleUntil?.clear()
4716
+ \t\tthis.#stalePatterns.clear()
4452
4717
  \t\tthis.#resourceCache.clear()
4453
4718
  \t\tthis.#patternRegexCache.clear()
4454
4719
  \t}
@@ -4465,23 +4730,25 @@ function sdkClientDoSSE(): string {
4465
4730
  \t\tif (opts.lastEventId) {
4466
4731
  \t\t\theaders.set("last-event-id", opts.lastEventId)
4467
4732
  \t\t}
4733
+ \t\t/* a streamed operation carries its body like any other request */
4734
+ \t\tlet body = this.#buildBody(opts, headers)
4468
4735
  \t\tconst { signal, cleanup } = this.#buildSignal(opts, true)
4469
- \t\tlet reader: ReadableStreamDefaultReader<Uint8Array> | undefined
4470
4736
  \t\ttry {
4471
4737
  \t\t\tif (this.#config.onRequest) {
4472
- \t\t\t\tconst reqCtx: { headers: Headers; invalidatedBy?: string[]; isStale?: boolean; method: string; path: string; selector?: string; state: Record<string, unknown>; url: string } = { headers, method, path, state: this.#config.state ?? {}, url }
4738
+ \t\t\t\tconst reqCtx: { body?: BodyInit; headers: Headers; invalidatedBy?: string[]; isStale?: boolean; method: string; path: string; selector?: string; state: Record<string, unknown>; url: string } = { body, headers, method, path, state: this.#config.state ?? {}, url }
4473
4739
  \t\t\t\tfor (const hook of this.#config.onRequest) {
4474
4740
  \t\t\t\t\tawait hook(reqCtx)
4475
4741
  \t\t\t\t}
4742
+ \t\t\t\tif (reqCtx.body !== body) body = reqCtx.body
4476
4743
  \t\t\t}
4477
- \t\t\tconst sseInit: RequestInit = { headers, method, signal }
4744
+ \t\t\tconst sseInit = this.#requestInit({ body, headers, method, signal })
4478
4745
  \t\t\tif (this.#config.credentials) sseInit.credentials = this.#config.credentials
4479
4746
  \t\t\tif (this.#config.mode) sseInit.mode = this.#config.mode
4480
4747
  \t\t\tsignal?.throwIfAborted()
4481
4748
  \t\t\tlet response = await this.#fetchFn(url, sseInit)
4482
4749
  \t\t\tif (this.#config.onResponse) {
4483
4750
  \t\t\t\tconst resCtx: { invalidatedBy?: string[]; isRetry: boolean; isStale?: boolean; method: string; path: string; request: Request; response: Response; retry: () => Promise<Response>; selector?: string; state: Record<string, unknown>; url: string } = {
4484
- \t\t\t\t\tisRetry: false, method, path, request: new Request(url, sseInit), response,
4751
+ \t\t\t\t\tisRetry: false, method, path, request: this.#hookRequest(url, sseInit), response,
4485
4752
  \t\t\t\t\tretry: () => { throw new Error("SSE streams do not support retry") },
4486
4753
  \t\t\t\t\tstate: this.#config.state ?? {}, url,
4487
4754
  \t\t\t\t}
@@ -4494,36 +4761,12 @@ function sdkClientDoSSE(): string {
4494
4761
  \t\t\t\tthrow await this.#parseAsClientError(response)
4495
4762
  \t\t\t}
4496
4763
  \t\t\tif (entry.invalidate && entry.invalidate.length > 0) {
4497
- \t\t\t\tconst cs = \`\${method} \${opts.params ? this.#interpolatePath(path, opts.params) : path}\`
4764
+ \t\t\t\tconst cs = \`\${method} \${this.#interpolatePath(path, opts.params)}\`
4498
4765
  \t\t\t\tthis.#markStale(entry.invalidate, opts.params, cs)
4499
4766
  \t\t\t}
4500
4767
  \t\t\tif (!response.body) return
4501
- \t\t\tconst decoder = new TextDecoder()
4502
- \t\t\treader = response.body.getReader()
4503
- \t\t\tlet buffer = ""
4504
- \t\t\tconst maxBuffer = this.#sseMaxBufferChars
4505
- \t\t\twhile (true) {
4506
- \t\t\t\tconst { done, value } = await reader.read()
4507
- \t\t\t\tif (done) break
4508
- \t\t\t\tbuffer += decoder.decode(value, { stream: true })
4509
- \t\t\t\tif (buffer.length > maxBuffer) throw new Error(\`SSE buffer exceeded \${maxBuffer} characters\`)
4510
- \t\t\t\tconst blocks = buffer.split(/\\r\\n\\r\\n|\\r\\n\\r|\\r\\n\\n|\\r\\r\\n|\\n\\r\\n|\\n\\r|\\r\\r|\\n\\n/)
4511
- \t\t\t\tbuffer = blocks.pop() ?? ""
4512
- \t\t\t\tfor (const block of blocks) {
4513
- \t\t\t\t\tif (block.trim() === "") continue
4514
- \t\t\t\t\tconst event = this.#parseSSEBlock(block)
4515
- \t\t\t\t\tif (event) yield event
4516
- \t\t\t\t}
4517
- \t\t\t}
4518
- \t\t\tif (buffer.trim() !== "") {
4519
- \t\t\t\tconst event = this.#parseSSEBlock(buffer)
4520
- \t\t\t\tif (event) yield event
4521
- \t\t\t}
4768
+ \t\t\tyield* this.#parseSSE(response.body)
4522
4769
  \t\t} finally {
4523
- \t\t\tif (reader) {
4524
- \t\t\t\ttry { await reader.cancel() } catch {}
4525
- \t\t\t\treader.releaseLock()
4526
- \t\t\t}
4527
4770
  \t\t\tcleanup()
4528
4771
  \t\t}
4529
4772
  \t}
@@ -4532,50 +4775,101 @@ function sdkClientDoSSE(): string {
4532
4775
 
4533
4776
  function sdkClientParseSSEBlock(): string {
4534
4777
  return `
4535
- \t#parseSSEBlock(block: string): _SSEEvent | undefined {
4536
- \t\tconst lines = block.split(/\\r\\n|\\r|\\n/)
4537
- \t\tlet isComment = true
4538
- \t\tlet data: string | undefined
4778
+ \t/* Keep in sync with client/sse.ts — the WHATWG event-stream rules: lines end at CRLF, LF or CR;
4779
+ \t a leading BOM is skipped; id is sticky and ignored if it holds NUL; retry is digits only; an
4780
+ \t event still open when the stream ends is discarded, never dispatched. */
4781
+ \tasync *#parseSSE(stream: ReadableStream<Uint8Array>): AsyncGenerator<_SSEEvent> {
4782
+ \t\tconst decoder = new TextDecoder()
4783
+ \t\tconst reader = stream.getReader()
4784
+ \t\tconst maxBuffer = this.#sseMaxBufferChars
4785
+ \t\tlet pending = ""
4786
+ \t\tlet first = true
4787
+ \t\tlet data: string[] = []
4788
+ \t\tlet dataSize = 0
4789
+ \t\tlet hasData = false
4539
4790
  \t\tlet event: string | undefined
4540
- \t\tlet id: string | undefined
4791
+ \t\tlet lastId = ""
4541
4792
  \t\tlet retry: number | undefined
4542
- \t\tfor (const line of lines) {
4543
- \t\t\tif (line.startsWith(":")) continue
4544
- \t\t\tisComment = false
4545
- \t\t\tconst colonIdx = line.indexOf(":")
4546
- \t\t\tlet field: string
4547
- \t\t\tlet val: string
4548
- \t\t\tif (colonIdx === -1) {
4549
- \t\t\t\tfield = line
4550
- \t\t\t\tval = ""
4551
- \t\t\t} else {
4552
- \t\t\t\tfield = line.slice(0, colonIdx)
4553
- \t\t\t\tval = line.slice(colonIdx + 1)
4554
- \t\t\t\tif (val.startsWith(" ")) val = val.slice(1)
4793
+ \t\tconst dispatch = (): _SSEEvent | undefined => {
4794
+ \t\t\tconst out: _SSEEvent | undefined = hasData ? { data: data.join("\\n") } : undefined
4795
+ \t\t\tif (out) {
4796
+ \t\t\t\tif (event !== undefined && event !== "") out.event = event
4797
+ \t\t\t\tif (lastId !== "") out.id = lastId
4798
+ \t\t\t\tif (retry !== undefined) out.retry = retry
4799
+ \t\t\t\tretry = undefined
4555
4800
  \t\t\t}
4801
+ \t\t\tdata = []
4802
+ \t\t\tdataSize = 0
4803
+ \t\t\thasData = false
4804
+ \t\t\tevent = undefined
4805
+ \t\t\treturn out
4806
+ \t\t}
4807
+ \t\tconst processLine = (line: string): void => {
4808
+ \t\t\tif (line.charCodeAt(0) === 58) return
4809
+ \t\t\tconst colon = line.indexOf(":")
4810
+ \t\t\tconst field = colon === -1 ? line : line.slice(0, colon)
4811
+ \t\t\tlet value = colon === -1 ? "" : line.slice(colon + 1)
4812
+ \t\t\tif (value.charCodeAt(0) === 32) value = value.slice(1)
4556
4813
  \t\t\tswitch (field) {
4557
4814
  \t\t\t\tcase "data":
4558
- \t\t\t\t\tdata = data === undefined ? val : \`\${data}\\n\${val}\`
4815
+ \t\t\t\t\tdataSize += value.length + 1
4816
+ \t\t\t\t\tif (dataSize > maxBuffer) throw new Error(\`SSE event exceeded \${maxBuffer} characters\`)
4817
+ \t\t\t\t\tdata.push(value)
4818
+ \t\t\t\t\thasData = true
4559
4819
  \t\t\t\t\tbreak
4560
4820
  \t\t\t\tcase "event":
4561
- \t\t\t\t\tevent = val
4821
+ \t\t\t\t\tevent = value
4562
4822
  \t\t\t\t\tbreak
4563
4823
  \t\t\t\tcase "id":
4564
- \t\t\t\t\tif (!val.includes("\\0")) id = val
4824
+ \t\t\t\t\tif (!value.includes("\\0")) lastId = value
4565
4825
  \t\t\t\t\tbreak
4566
- \t\t\t\tcase "retry": {
4567
- \t\t\t\t\tconst nr = Number(val)
4568
- \t\t\t\t\tif (Number.isFinite(nr) && nr >= 0) retry = nr
4826
+ \t\t\t\tcase "retry":
4827
+ \t\t\t\t\tif (/^\\d+$/.test(value)) retry = Number(value)
4569
4828
  \t\t\t\t\tbreak
4829
+ \t\t\t}
4830
+ \t\t}
4831
+ \t\ttry {
4832
+ \t\t\twhile (true) {
4833
+ \t\t\t\tconst { done, value } = await reader.read()
4834
+ \t\t\t\tif (done) break
4835
+ \t\t\t\tpending += decoder.decode(value, { stream: true })
4836
+ \t\t\t\tif (first && pending.length > 0) {
4837
+ \t\t\t\t\tif (pending.charCodeAt(0) === 0xfeff) pending = pending.slice(1)
4838
+ \t\t\t\t\tfirst = false
4839
+ \t\t\t\t}
4840
+ \t\t\t\tlet start = 0
4841
+ \t\t\t\twhile (start < pending.length) {
4842
+ \t\t\t\t\tlet end = start
4843
+ \t\t\t\t\twhile (end < pending.length) {
4844
+ \t\t\t\t\t\tconst c = pending.charCodeAt(end)
4845
+ \t\t\t\t\t\tif (c === 10 || c === 13) break
4846
+ \t\t\t\t\t\tend++
4847
+ \t\t\t\t\t}
4848
+ \t\t\t\t\tif (end === pending.length) break
4849
+ \t\t\t\t\t/* a CR as the last character may be the first half of CRLF */
4850
+ \t\t\t\t\tif (pending.charCodeAt(end) === 13 && end === pending.length - 1) break
4851
+ \t\t\t\t\tconst line = pending.slice(start, end)
4852
+ \t\t\t\t\tstart = end + (pending.charCodeAt(end) === 13 && pending.charCodeAt(end + 1) === 10 ? 2 : 1)
4853
+ \t\t\t\t\tif (line === "") {
4854
+ \t\t\t\t\t\tconst out = dispatch()
4855
+ \t\t\t\t\t\tif (out) yield out
4856
+ \t\t\t\t\t} else {
4857
+ \t\t\t\t\t\tprocessLine(line)
4858
+ \t\t\t\t\t}
4570
4859
  \t\t\t\t}
4860
+ \t\t\t\tpending = pending.slice(start)
4861
+ \t\t\t\tif (pending.length > maxBuffer) throw new Error(\`SSE buffer exceeded \${maxBuffer} characters\`)
4862
+ \t\t\t}
4863
+ \t\t\t/* a lone CR held back at the end of the stream still terminates its line */
4864
+ \t\t\tif (pending === "\\r") {
4865
+ \t\t\t\tconst out = dispatch()
4866
+ \t\t\t\tif (out) yield out
4571
4867
  \t\t\t}
4868
+ \t\t\t/* anything else still open is an incomplete event: discard it */
4869
+ \t\t} finally {
4870
+ \t\t\tawait reader.cancel().catch(() => {})
4871
+ \t\t\treader.releaseLock()
4572
4872
  \t\t}
4573
- \t\tif (isComment || data === undefined) return undefined
4574
- \t\tconst evt: _SSEEvent = { data }
4575
- \t\tif (event !== undefined) evt.event = event
4576
- \t\tif (id !== undefined) evt.id = id
4577
- \t\tif (retry !== undefined) evt.retry = retry
4578
- \t\treturn evt
4579
4873
  \t}
4580
4874
  `
4581
4875
  }
@@ -4596,108 +4890,152 @@ export function isClientError(e: unknown): e is _ClientError {
4596
4890
  `
4597
4891
  }
4598
4892
 
4599
- export function collectSDKMethods(spec: OpenApiSpecInput): {
4893
+ /** The IR every TypeScript SDK emitter reads: refs inlined, derived ids, duplicate ids refused. */
4894
+ export function sdkIR(spec: OpenApiSpecInput): IR {
4895
+ return toIR(resolveRefs(spec), { deriveOperationIds: true, duplicateOperationIds: "throw" })
4896
+ }
4897
+
4898
+ /** Flat `resource → action → entry` map of every operation, for SDK result objects. */
4899
+ export function serviceMapOf(ir: IR): Record<string, Record<string, ServiceEntry>> {
4900
+ /* null-prototype: an operationId of "__proto__.x" must not reach Object.prototype */
4901
+ const serviceMap: Record<string, Record<string, ServiceEntry>> = Object.create(null)
4902
+ for (const op of ir.operations) {
4903
+ const segments = op.id.split(".")
4904
+ const resource = segments.length === 1 ? op.id : (segments[0] ?? op.id)
4905
+ const action = segments.length === 1 ? "_call" : segments.slice(1).join(".")
4906
+ if (!Object.hasOwn(serviceMap, resource)) serviceMap[resource] = Object.create(null)
4907
+ serviceMap[resource][action] = buildServiceEntryForOp(op)
4908
+ }
4909
+ return serviceMap
4910
+ }
4911
+
4912
+ export function collectSDKMethods(
4913
+ spec: OpenApiSpecInput,
4914
+ /* toIR validates namespace collisions; an operation without an operationId gets a derived
4915
+ one instead of silently vanishing from the SDK */
4916
+ ir = sdkIR(spec),
4917
+ ): {
4600
4918
  serviceMap: Record<string, Record<string, ServiceEntry>>
4601
4919
  nestedMap: Map<string, NestedServiceNode>
4602
4920
  methods: SDKMethod[]
4603
4921
  } {
4604
- /* toIR validates duplicates + collision; throws before any codegen runs */
4605
- const ir = toIR(spec)
4606
- const resolved = resolveRefs(spec)
4607
- const serviceMap: Record<string, Record<string, ServiceEntry>> = {}
4922
+ const resolve = irResolver(ir.schemas)
4608
4923
  const methods: SDKMethod[] = []
4609
4924
 
4610
- for (const [path, pathMethods] of Object.entries(resolved.paths)) {
4611
- for (const [method, operation] of Object.entries(pathMethods)) {
4612
- const op = operation as Record<string, unknown>
4613
- const operationId = op.operationId as string | undefined
4614
- if (!operationId) continue
4615
-
4616
- const segments = operationId.split(".")
4617
- const isTopLevel = segments.length === 1
4618
- const resource = isTopLevel ? operationId : (segments[0] ?? operationId)
4619
- const action = isTopLevel ? "_call" : segments.slice(1).join(".")
4620
-
4621
- if (serviceMap[resource] === undefined) {
4622
- serviceMap[resource] = {}
4623
- }
4624
-
4625
- const params = extractOpenApiPathParams(path)
4626
- const entry: ServiceEntry = {
4627
- method: method.toUpperCase(),
4628
- path,
4629
- }
4630
- if (params.length > 0) entry.params = params
4631
- const sse = isSSEOperation(op)
4632
- if (sse) entry.sse = true
4633
- if (op["x-websocket"] === true) entry.ws = true
4634
- if (op["x-realtime"] === true) entry.realtime = true
4635
- if (op["x-idempotency-key"] === true) entry.idempotent = true
4636
- const xinv = op["x-invalidate"]
4637
- if (Array.isArray(xinv) && xinv.length > 0) entry.invalidate = xinv as string[]
4638
-
4639
- serviceMap[resource][action] = entry
4640
- const isWs = op["x-websocket"] === true
4641
- const isRealtime = op["x-realtime"] === true
4642
- const inputResult = emitSDKInputType(op, path)
4643
- methods.push({
4644
- action,
4645
- errorsByStatusType: emitSDKErrorsByStatusType(op),
4646
- inputHasMandatory: inputResult.hasMandatory,
4647
- inputType: inputResult.type,
4648
- realtime: isRealtime,
4649
- resource,
4650
- responseType: emitSDKResponseType(op),
4651
- sse,
4652
- ws: isWs,
4653
- })
4654
- }
4925
+ for (const op of ir.operations) {
4926
+ const operationId = op.id
4927
+ const segments = operationId.split(".")
4928
+ const isTopLevel = segments.length === 1
4929
+ const resource = isTopLevel ? operationId : (segments[0] ?? operationId)
4930
+ const action = isTopLevel ? "_call" : segments.slice(1).join(".")
4931
+
4932
+ const inputResult = emitSDKInputType(op)
4933
+ methods.push({
4934
+ action,
4935
+ errorsByStatusType: emitSDKErrorsByStatusType(op, resolve),
4936
+ id: operationId,
4937
+ inputHasMandatory: inputResult.hasMandatory,
4938
+ inputType: inputResult.type,
4939
+ realtime: op.extensions.realtime === true,
4940
+ resource,
4941
+ responseType: emitSDKResponseType(op),
4942
+ sse: op.extensions.sse === true,
4943
+ ws: op.extensions.websocket === true,
4944
+ })
4655
4945
  }
4656
4946
 
4657
- const nestedMap = buildNestedServiceMap(ir.tree, resolved, spec)
4658
- return { methods, nestedMap, serviceMap }
4947
+ const nestedMap = buildNestedServiceMap(safeMemberTree(ir.tree, true))
4948
+ return { methods, nestedMap, serviceMap: serviceMapOf(ir) }
4659
4949
  }
4660
4950
 
4661
- function buildServiceEntryForOp(op: Record<string, unknown>, path: string, method: string): ServiceEntry {
4662
- const params = extractOpenApiPathParams(path)
4663
- const entry: ServiceEntry = { method: method.toUpperCase(), path }
4951
+ function buildServiceEntryForOp(op: IROperation): ServiceEntry {
4952
+ const params = openApiPathParams(op.path)
4953
+ const entry: ServiceEntry = { method: op.method, path: op.path }
4664
4954
  if (params.length > 0) entry.params = params
4665
- const sse = isSSEOperation(op)
4666
- if (sse) entry.sse = true
4667
- if (op["x-websocket"] === true) entry.ws = true
4668
- if (op["x-realtime"] === true) entry.realtime = true
4669
- if (op["x-idempotency-key"] === true) entry.idempotent = true
4670
- const xinv = op["x-invalidate"]
4671
- if (Array.isArray(xinv) && xinv.length > 0) entry.invalidate = xinv as string[]
4955
+ const wildcard = op.params.path.find((p) => p.wildcard === true)
4956
+ if (wildcard) entry.wildcard = wildcard.name
4957
+ if (op.extensions.sse) entry.sse = true
4958
+ if (op.extensions.websocket) entry.ws = true
4959
+ if (op.extensions.realtime) entry.realtime = true
4960
+ if (op.extensions.idempotencyKey) entry.idempotent = true
4961
+ const inv = op.extensions.invalidates
4962
+ if (inv && inv.length > 0) entry.invalidate = inv
4672
4963
  return entry
4673
4964
  }
4674
4965
 
4675
- function buildNestedServiceMap(
4676
- ns: IRNamespace,
4677
- resolved: ReturnType<typeof resolveRefs>,
4678
- _spec: OpenApiSpecInput,
4679
- ): Map<string, NestedServiceNode> {
4680
- /* build a lookup from operationId → (op record, path, method) for leaf nodes */
4681
- const opLookup = new Map<string, { op: Record<string, unknown>; path: string; method: string }>()
4682
- for (const [path, pathMethods] of Object.entries(resolved.paths)) {
4683
- for (const [method, operation] of Object.entries(pathMethods)) {
4684
- const op = operation as Record<string, unknown>
4685
- const operationId = op.operationId as string | undefined
4686
- if (operationId) opLookup.set(operationId, { method, op, path })
4966
+ /** Members a generated client already has; a resource of the same name would hide them. */
4967
+ const SDK_ROOT_MEMBERS = new Set(["dispose", "state"])
4968
+
4969
+ /**
4970
+ * Names JavaScript machinery reads off any object: a `then` member makes the client a
4971
+ * thenable, `toString` breaks `String(sdk)`, `__proto__` sets the prototype.
4972
+ */
4973
+ const SDK_OBJECT_MEMBERS = new Set([
4974
+ "__defineGetter__",
4975
+ "__defineSetter__",
4976
+ "__lookupGetter__",
4977
+ "__lookupSetter__",
4978
+ "__proto__",
4979
+ "constructor",
4980
+ "hasOwnProperty",
4981
+ "isPrototypeOf",
4982
+ "propertyIsEnumerable",
4983
+ "then",
4984
+ "toJSON",
4985
+ "toLocaleString",
4986
+ "toString",
4987
+ "valueOf",
4988
+ ])
4989
+
4990
+ /**
4991
+ * The resource tree with every member name a client can expose. A name that would shadow a
4992
+ * client member or an `Object.prototype` name gets a trailing `_` (`state` → `state_`), the
4993
+ * same rename in the interface and the service map, so types and runtime agree.
4994
+ */
4995
+ function safeMemberTree(ns: IRNamespace, root: boolean): IRNamespace {
4996
+ const entries = new Map<string, IRNamespace["entries"] extends Map<string, infer E> ? E : never>()
4997
+ const sorted = [...ns.entries.entries()].sort(([a], [b]) => compareCodeUnits(a, b))
4998
+ for (const [key, entry] of sorted) {
4999
+ let name = key
5000
+ if ((root && SDK_ROOT_MEMBERS.has(key)) || SDK_OBJECT_MEMBERS.has(key)) {
5001
+ name = `${key}_`
5002
+ while (ns.entries.has(name) || entries.has(name)) name += "_"
5003
+ }
5004
+ entries.set(name, entry.kind === "namespace" ? { kind: "namespace", ns: safeMemberTree(entry.ns, false) } : entry)
5005
+ }
5006
+ return { entries }
5007
+ }
5008
+
5009
+ /**
5010
+ * The member path of every operation on a generated TypeScript client, after the renames
5011
+ * `safeMemberTree` applies: `["state_", "get"]` for an operation `state.get`. A namespace whose
5012
+ * only method is `_call` is callable itself, so its path ends at the namespace.
5013
+ */
5014
+ export function sdkMemberPaths(ir: IR): Map<string, string[]> {
5015
+ const out = new Map<string, string[]>()
5016
+ const walk = (ns: IRNamespace, prefix: string[]): void => {
5017
+ const methods = methodsOf(ns)
5018
+ for (const [key, entry] of ns.entries) {
5019
+ if (entry.kind === "namespace") {
5020
+ walk(entry.ns, [...prefix, key])
5021
+ } else if (key !== "_call") {
5022
+ out.set(entry.op.id, [...prefix, key])
5023
+ } else if (methods.length === 1) {
5024
+ /* the interface promotes a lone `_call` to the namespace itself and drops any other */
5025
+ out.set(entry.op.id, prefix)
5026
+ }
4687
5027
  }
4688
5028
  }
5029
+ walk(safeMemberTree(ir.tree, true), [])
5030
+ return out
5031
+ }
4689
5032
 
5033
+ function buildNestedServiceMap(ns: IRNamespace): Map<string, NestedServiceNode> {
4690
5034
  function walkNs(namespace: IRNamespace): Map<string, NestedServiceNode> {
4691
5035
  const map = new Map<string, NestedServiceNode>()
4692
- for (const [key, entry] of [...namespace.entries.entries()].sort(([a], [b]) => a.localeCompare(b))) {
4693
- if (entry.kind === "method") {
4694
- const found = opLookup.get(entry.op.id)
4695
- if (!found) continue
4696
- const serviceEntry = buildServiceEntryForOp(found.op, found.path, found.method)
4697
- map.set(key, { entry: serviceEntry, kind: "leaf" })
4698
- } else {
4699
- map.set(key, { children: walkNs(entry.ns), kind: "ns" })
4700
- }
5036
+ for (const [key, entry] of [...namespace.entries.entries()].sort(([a], [b]) => compareCodeUnits(a, b))) {
5037
+ if (entry.kind === "method") map.set(key, { entry: buildServiceEntryForOp(entry.op), kind: "leaf" })
5038
+ else map.set(key, { children: walkNs(entry.ns), kind: "ns" })
4701
5039
  }
4702
5040
  return map
4703
5041
  }
@@ -4705,19 +5043,29 @@ function buildNestedServiceMap(
4705
5043
  return walkNs(ns)
4706
5044
  }
4707
5045
 
5046
+ const TS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
5047
+
5048
+ /** The SDK class name lands in source, the stem in import specifiers and file names. */
5049
+ function validateSdkNames(name: string, stem: string): void {
5050
+ if (!TS_IDENTIFIER.test(name) || SDK_TS_RESERVED.has(name)) {
5051
+ throw new Error(`generateSDK: name ${JSON.stringify(name)} is not a TypeScript identifier`)
5052
+ }
5053
+ if (!/^[A-Za-z0-9_-][A-Za-z0-9._-]*$/.test(stem)) {
5054
+ throw new Error(
5055
+ `generateSDK: stem ${JSON.stringify(stem)} must be a plain file name (letters, digits, ".", "_", "-")`,
5056
+ )
5057
+ }
5058
+ }
5059
+
4708
5060
  export function generateSDK(spec: OpenApiSpecInput, options?: { name?: string; stem?: string }): GeneratedSDK {
4709
5061
  const sdkName = options?.name ?? "SDK"
4710
5062
  const stem = options?.stem ?? "sdk"
4711
- const { methods: sdkMethods, nestedMap, serviceMap } = collectSDKMethods(spec)
5063
+ validateSdkNames(sdkName, stem)
5064
+ const ir = sdkIR(spec)
5065
+ const { methods: sdkMethods, nestedMap, serviceMap } = collectSDKMethods(spec, ir)
4712
5066
  const hasRealtime = sdkMethods.some((m) => m.realtime)
4713
-
4714
- const ir = toIR(spec)
4715
- /* lookup by full operationId — action is already the rest after first segment, joined with "." */
4716
5067
  const fullMethodLookup = new Map<string, SDKMethod>()
4717
- for (const m of sdkMethods) {
4718
- const opId = m.action === "_call" ? m.resource : `${m.resource}.${m.action}`
4719
- fullMethodLookup.set(opId, m)
4720
- }
5068
+ for (const m of sdkMethods) fullMethodLookup.set(m.id, m)
4721
5069
 
4722
5070
  return {
4723
5071
  files: {
@@ -4725,7 +5073,7 @@ export function generateSDK(spec: OpenApiSpecInput, options?: { name?: string; s
4725
5073
  index: buildSDKIndex(sdkName, stem),
4726
5074
  map: buildSDKMap(nestedMap),
4727
5075
  runtime: hasRealtime ? buildSDKRuntime() : null,
4728
- types: buildSDKTypes(sdkName, sdkMethods, ir.tree, fullMethodLookup),
5076
+ types: buildSDKTypes(sdkName, sdkMethods, safeMemberTree(ir.tree, true), fullMethodLookup),
4729
5077
  },
4730
5078
  serviceMap,
4731
5079
  }