@lovrozagar/honey 0.6.5 → 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 +410 -149
  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 +1893 -1761
  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 +271 -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/index.ts CHANGED
@@ -1,23 +1,52 @@
1
- import { HoneyContext } from "./context.ts"
1
+ import { bodyKind, headResponse, isProducedStream, rawBodyOf } from "./body-kind.ts"
2
+ import { clientInfo, HoneyContext } from "./context.ts"
3
+ import { dict } from "./dict.ts"
4
+ import { normalizePath, pathOfUrl, searchOfUrl } from "./request-path.ts"
5
+ import { BUN_URL_FROM_HOST, hasValidHost, TRUST_OFF, type TrustSetting } from "./client-info.ts"
6
+ import type { TrustProxy } from "./trust.ts"
7
+ import { loadFeature, requireFeature } from "./feature-slots.ts"
2
8
  import { HoneyError } from "./error.ts"
3
9
  import { ERROR_META } from "./errors.ts"
4
- import type { MiddlewareFn, RuntimeMiddleware } from "./middleware.ts"
5
- import { collectMiddlewareMeta } from "./middleware.ts"
6
- import { compileChain, executeChain } from "./middleware.ts"
10
+ import type { ChainErrorConverter, MiddlewareFn, RuntimeMiddleware } from "./middleware.ts"
11
+ import { collectMiddlewareMeta, compileChain, RESERVED_CTX_KEYS } from "./middleware.ts"
7
12
  import type { ProxyConfig } from "./proxy.ts"
8
- import { createProxyHandler } from "./proxy.ts"
9
13
  import type { CustomErrorFormatter, ErrorFormatter, ResponseOptions, TypedResponse } from "./response.ts"
10
14
  import { createErrorResponse, type HoneyRes } from "./response.ts"
11
- import type { OutputValidator, RouteHandler, RouteTree, TreeNode, WSRouteHandler } from "./tree.ts"
12
- import { createNode, insertRoute, insertWsRoute, matchRoute, matchWsRoute, mergeInto } from "./tree.ts"
13
- import { createBus } from "./realtime/bus.ts"
15
+ import type { RouteId, Segment } from "./pattern.ts"
16
+ import {
17
+ canonical,
18
+ expandOptional,
19
+ isStaticPattern,
20
+ joinPatterns,
21
+ normalizePattern,
22
+ parsePattern,
23
+ pathInScope,
24
+ routeId,
25
+ scopeCoverage,
26
+ splitRouteId,
27
+ } from "./pattern.ts"
28
+ import type { OutputValidator, RouteEntry, RouteHandler, RouteTree, TreeNode, WSRouteHandler } from "./tree.ts"
29
+ import {
30
+ assertTreeFormat,
31
+ cloneTree,
32
+ createNode,
33
+ findLeaf,
34
+ forEachLeaf,
35
+ freezeTree,
36
+ hasWsLeaf,
37
+ insertLeaf,
38
+ matchRoute,
39
+ matchWsRoute,
40
+ ROUTE_TREE_VERSION,
41
+ } from "./tree.ts"
14
42
  import type { RealtimeBus } from "./realtime/bus.ts"
15
- import { createConnContext } from "./realtime/route.ts"
16
- import type { RealtimeRouteOpts } from "./realtime/route.ts"
43
+ import type { RealtimeConfig, RealtimeRouteOpts } from "./realtime/route.ts"
44
+ import type { RealtimePublisher } from "./realtime/server.ts"
17
45
  import type {
18
46
  ComputeErrorsByStatus,
19
47
  DefaultMeta,
20
48
  ExtractSchemas,
49
+ FieldError,
21
50
  HttpMethod,
22
51
  InferInputMap,
23
52
  InferOutput,
@@ -26,34 +55,43 @@ import type {
26
55
  MergePath,
27
56
  MergeRoute,
28
57
  MetaSpecConfig,
29
- MetaSpecEntry,
30
- MetaSpecMergeConflict,
31
58
  OutputSchemaDef,
32
59
  ParamsFromPath,
60
+ PrefixRoutes,
33
61
  StandardSchemaLike,
34
62
  StatusKey,
35
63
  TapContext,
36
64
  } from "./types.ts"
37
65
  import { codeToStatusKey, EK, EMPTY_OBJ, SK } from "./types.ts"
38
- import { assertRequestContentType, validateInput, validateOutput } from "./validation.ts"
66
+ import { assertBodySchemaAllowed, assertRequestContentType, validateInput, validateOutput } from "./validation.ts"
39
67
  import type { WSAdapter, WSContext, WSHandler } from "./ws/cloudflare.ts"
68
+ import type { InvokeLogger } from "./invoke-user.ts"
69
+ import { type WSOriginPolicy, validateOriginPolicy } from "./ws-origin.ts"
40
70
  import { loadHoneyFeature } from "./feature-load.ts"
41
71
  import { getI18nRuntime } from "./i18n-slot.ts"
42
- import { getOpenApiRuntime } from "./openapi/spec-factory.ts"
72
+ import { bindInternalHandler, epochCached, getOpenApiRuntime } from "./openapi/spec-factory.ts"
73
+ import type { ServedArtifact } from "./openapi/spec-factory.ts"
74
+ import type { MetaSpecSource } from "./meta-spec-merge.ts"
43
75
  import { getServeRuntime } from "./serve-slot.ts"
44
76
  import type { HoneyServeOptions, ServeHandle } from "./serve.ts"
45
77
 
46
- export { HoneyContext } from "./context.ts"
78
+ export { clientInfo, HoneyContext } from "./context.ts"
79
+ export { toFetchRequest } from "./fetch-request.ts"
80
+ export type { WSOriginPolicy } from "./ws-origin.ts"
81
+ export type { ClientInfo } from "./client-info.ts"
82
+ export type { TrustProxy } from "./trust.ts"
47
83
  /** HoneyContext without internal backing fields — use this for consumer-facing types */
48
84
  export type HoneyCtx<TEnv = Record<string, unknown>> = Omit<
49
85
  import("./context.ts").HoneyContext<TEnv>,
50
86
  | "_errorToResponse"
51
87
  | "_isErrorResponse"
88
+ | "_lzClient"
52
89
  | "_lzCookies"
53
90
  | "_lzHeaders"
54
91
  | "_lzSearch"
55
92
  | "_lzSearchAll"
56
93
  | "_lzUrlFn"
94
+ | "_rq"
57
95
  | "_setErrors"
58
96
  >
59
97
  export { HoneyError } from "./error.ts"
@@ -97,6 +135,7 @@ export type {
97
135
  InferRoutes,
98
136
  InputSchemasDef,
99
137
  MergePath,
138
+ PrefixRoutes,
100
139
  HoneyMetaSpec,
101
140
  MetaSpecConfig,
102
141
  MetaSpecContext,
@@ -119,29 +158,26 @@ export type {
119
158
  StatusKey,
120
159
  SuccessStatusKey,
121
160
  } from "./types.ts"
122
- export type { WSAdapter, WSContext, WSHandler, WSPreUpgrade } from "./ws/cloudflare.ts"
123
- export type { ConnContext, RealtimeRouteOpts } from "./realtime/route.ts"
161
+ export type { WSAdapter, WSAdapterOptions, WSContext, WSHandler, WSPreUpgrade } from "./ws/cloudflare.ts"
162
+ export type { ConnContext, RealtimeLimits, RealtimeRouteOpts } from "./realtime/route.ts"
163
+ export type { RealtimePublisher } from "./realtime/server.ts"
124
164
  export type { RealtimeBus } from "./realtime/bus.ts"
125
165
  export type { HoneyServeOptions, ServeHandle } from "./serve.ts"
126
166
  export type { ServeRuntime } from "./detect-runtime.ts"
127
167
  export { detectRuntime } from "./detect-runtime.ts"
128
168
 
129
- /** Type predicate for incoming wire-protocol msg frames from the realtime client. */
130
- function isMsgFrame(value: unknown): value is { data: unknown; t: "msg" } {
131
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false
132
- if (!("t" in value) || !("data" in value)) return false
133
- return value.t === "msg"
134
- }
135
-
136
169
  type TelemetryAdapter = {
137
170
  onError?(ctx: { duration: number; error: HoneyError; method: string; path: string }): void
138
- onHandler?(ctx: { duration: number; method: string; path: string; status: number }): void
171
+ /** `path` is the request path; `route` the matched pattern (`/users/:id`). */
172
+ onHandler?(ctx: { duration: number; method: string; path: string; route: string; status: number }): void
139
173
  onMethodNotAllowed?(ctx: { allowed: string[]; method: string; path: string; req: Request }): void
140
174
  onMiddleware?(ctx: { duration: number; error?: unknown; name: string }): void
141
175
  onNotFound?(ctx: { method: string; path: string; req: Request }): void
142
176
  onRequest?(ctx: { env: unknown; req: Request }): void
177
+ /** Fires exactly once per request that fired `onRequest`, whichever way it exits. */
143
178
  onResponse?(ctx: { duration: number; req: Request; status: number }): void
144
- onRoute?(ctx: { method: string; params: Record<string, string>; path: string; req: Request }): void
179
+ /** `path` is the request path; `route` the matched pattern (`/users/:id`). */
180
+ onRoute?(ctx: { method: string; params: Record<string, string>; path: string; req: Request; route: string }): void
145
181
  }
146
182
 
147
183
  type ErrorI18nConfig<TEnv> = {
@@ -157,57 +193,257 @@ type ErrorI18nConfig<TEnv> = {
157
193
  }) => string | Promise<string>
158
194
  }
159
195
 
196
+ /** The warn side of a structured logger (`warn(obj, msg)` or `warn(msg)`), e.g. honey/logger. */
160
197
  type Logger = {
161
- warn?(msg: string, ...args: unknown[]): void
198
+ warn?(objOrMsg: Record<string, unknown> | string, msg?: string): void
199
+ }
200
+
201
+ type ErrorCtx<TEnv> = { env: TEnv; jsonFromError: (err: HoneyError) => Response; req: Request }
202
+
203
+ type OnErrorFn<TEnv> = (
204
+ error: unknown,
205
+ ctx: ErrorCtx<TEnv>,
206
+ ) => HoneyError | Response | Promise<HoneyError | Response | undefined | void> | undefined | void
207
+
208
+ type OnMethodNotAllowedFn<TEnv> = (ctx: ErrorCtx<TEnv> & { allowed: string[] }) => Response | Promise<Response>
209
+
210
+ type OnNotFoundFn<TEnv> = (ctx: ErrorCtx<TEnv>) => Response | Promise<Response>
211
+
212
+ type TapFn<TEnv> = (ctx: TapContext<TEnv>, payload: unknown) => void | Promise<void>
213
+
214
+ type ErrorFactoryRecord = Record<string, (...args: never[]) => unknown>
215
+
216
+ type OutputValidationMode = "always" | "dev" | "off"
217
+
218
+ /**
219
+ * Settings of one app — shared by every handle derived from it (`use()`, `basePath()`,
220
+ * `context()`, `meta()`), so a setter called on any handle applies to all of them and
221
+ * serving any handle behaves the same. Records point at the settings of the app they were
222
+ * registered on, so a mounted sub-app's routes keep their own error factory, default errors,
223
+ * boundary and output validation.
224
+ */
225
+ type AppSettings<TEnv = unknown> = {
226
+ customErrorFormatter: CustomErrorFormatter | null
227
+ customErrorSchema: StandardSchemaLike | null
228
+ defaultBoundaryKey: string | null
229
+ defaultErrorKeys: Set<string>
230
+ errorFactory: ErrorFactoryRecord | null
231
+ errorFormatter: ErrorFormatterFn
232
+ errorI18n: ErrorI18nConfig<TEnv> | null
233
+ errorSchema: StandardSchemaLike | null
234
+ logger: Logger | null
235
+ onError: OnErrorFn<TEnv> | null
236
+ onMethodNotAllowed: OnMethodNotAllowedFn<TEnv> | null
237
+ onNotFound: OnNotFoundFn<TEnv> | null
238
+ /** null = never set: a mounted route follows the mounting app's mode */
239
+ outputValidation: OutputValidationMode | null
240
+ stripPrefix: string | null
241
+ taps: Map<string, TapFn<TEnv>> | null
242
+ telemetry: TelemetryAdapter | null
243
+ trailingSlash: "enforce" | "ignore" | "strip"
244
+ encodedSlashes: "allow" | "reject"
245
+ trust: TrustSetting
246
+ wsAdapter: WSAdapter | null
162
247
  }
163
248
 
249
+ function createSettings<TEnv>(): AppSettings<TEnv> {
250
+ return {
251
+ customErrorFormatter: null,
252
+ customErrorSchema: null,
253
+ defaultBoundaryKey: null,
254
+ defaultErrorKeys: new Set(),
255
+ errorFactory: null,
256
+ errorFormatter: defaultErrorFormatter,
257
+ errorI18n: null,
258
+ errorSchema: null,
259
+ logger: null,
260
+ onError: null,
261
+ onMethodNotAllowed: null,
262
+ onNotFound: null,
263
+ outputValidation: null,
264
+ stripPrefix: null,
265
+ taps: null,
266
+ telemetry: null,
267
+ trailingSlash: "ignore",
268
+ encodedSlashes: "reject",
269
+ trust: TRUST_OFF,
270
+ wsAdapter: null,
271
+ }
272
+ }
273
+
274
+ /** Join a base path and a route or scope path into one canonical pattern. */
164
275
  function mergePath(base: string, path: string): string {
165
- if (base === "/") return path
166
- if (path === "/") return base
167
- return `${base}${path}`
276
+ return joinPatterns(base, path)
277
+ }
278
+
279
+ /**
280
+ * Everything a matched route runs, compiled once at finalize: the record (with its resolved
281
+ * error keys, meta and chain) and one function that runs chain + handler and never rejects.
282
+ */
283
+ type Plan = {
284
+ /** `.context()` values copied onto each request context */
285
+ cv: Record<string, unknown> | null
286
+ /** declared error keys are enforced (the route, its middleware or the app declared any) */
287
+ enf: boolean
288
+ /** preflight runner — chain without input validation or handler; compiled on first preflight */
289
+ pf: CompiledChain | null
290
+ /** what the preflight runner runs */
291
+ pfChain: RuntimeMiddleware[]
292
+ r: RouteHandler | WSRouteHandler
293
+ /** realtime route config when this is a realtime route */
294
+ rt: RealtimeConfig | null
295
+ run: CompiledChain
296
+ /** taps of the serving app, plus those of the app the route came from */
297
+ taps: Map<string, TapFn<unknown>> | null
168
298
  }
169
299
 
170
- /** Normalize a scope prefix: ensure leading slash, strip trailing slash (except "/"). */
171
- function normalizeScopePath(raw: string): string {
172
- let p = raw
173
- if (p.length === 0) return "/"
174
- if (p.charCodeAt(0) !== 47) p = `/${p}`
175
- if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1)
176
- return p
300
+ type CompiledChain = (ctx: object) => Response | Promise<Response>
301
+
302
+ /** What one app graph serves, resolved at finalize from records and the loaded tree. */
303
+ type FinalTable = {
304
+ byId: Map<RouteId, RouteHandler>
305
+ convert: ChainErrorConverter
306
+ epoch: number
307
+ /** 404 / 405: the middleware every route runs, plus the scopes covering the request path */
308
+ miss: Plan
309
+ plans: Map<RouteId, Plan>
310
+ /** `METHOD /path` → plan, for routes without params or wildcards */
311
+ statics: Record<string, Plan>
312
+ wsById: Map<RouteId, WSRouteHandler>
313
+ wsPlans: Map<RouteId, Plan>
177
314
  }
178
315
 
179
- /** Check whether fullPath falls under scope prefix — exact match or next char is '/'. */
180
- function scopeMatches(prefix: string, fullPath: string): boolean {
181
- if (prefix === "/") return true
182
- if (fullPath === prefix) return true
183
- if (fullPath.length <= prefix.length) return false
184
- if (fullPath.charCodeAt(prefix.length) !== 47) return false
185
- return fullPath.startsWith(prefix)
316
+ /** One `app.use(mw)` — the handle it returns has to register a route, or `mw` runs nowhere. */
317
+ type ChainNode = { mw: RuntimeMiddleware; parent: ChainNode | null; used: boolean }
318
+
319
+ /** A route builder still waiting for `.handler()` — reported at finalize. */
320
+ type PendingRoute = { id: string }
321
+
322
+ /** @internal — runtime entry for a scoped middleware */
323
+ type ScopedEntry = {
324
+ /** cached from mw.errors at registration; undefined when none */
325
+ errors: readonly string[] | undefined
326
+ /** the middleware behind a request-path check, for routes the scope covers only partly */
327
+ guard: RuntimeMiddleware
328
+ mw: RuntimeMiddleware
329
+ /** canonical full-path pattern (already rebased against basePath at .use time) */
330
+ prefix: string
331
+ segs: Segment[]
186
332
  }
187
333
 
334
+ /**
335
+ * One app graph — shared by every handle derived with `use()`, `basePath()`, `context()`,
336
+ * `meta()`. Records are per graph, keyed by `RouteId`; the tree holds ids only, so a loaded
337
+ * (shared, frozen) tree never carries another app's handlers.
338
+ */
188
339
  type HoneyGraph = {
189
- handlerMap: Record<string, RouteHandler> | null
190
- hasRouteTree: boolean
191
- /** Codegen-time meta → OpenAPI policy. Never read on the request path */
340
+ /** metaSpec policies of mounted sub-apps, merged under `metaSpec` when codegen reads it */
341
+ absorbedMetaSpecs: MetaSpecSource[]
342
+ /** gateway catch-alls by method — root wildcards registered over a loaded tree that lacks them */
343
+ catchAll: Map<string, RouteHandler>
344
+ /** every `use(mw)` handle — one that never registers a route is reported at finalize */
345
+ chains: ChainNode[]
346
+ /** bumped by every registration; finalize re-runs when it moves */
347
+ epoch: number
348
+ final: FinalTable | null
349
+ /** app-wide middleware that runs before every chain (serve({ cors })), keyed so a re-serve replaces it */
350
+ global: Map<string, RuntimeMiddleware>
351
+ hasWs: boolean
352
+ /** route data of the loaded tree (per-graph copies) — null when no tree was loaded */
353
+ loaded: Map<RouteId, RouteEntry> | null
354
+ /** Codegen-time meta → OpenAPI policy declared on this app. Never read on the request path */
192
355
  metaSpec: MetaSpecConfig | null
356
+ /** route builders still waiting for `.handler()` */
357
+ pending: Set<PendingRoute>
193
358
  realtimeBus: RealtimeBus | null
359
+ /** `ctx.realtime`, shared by every request of this graph */
360
+ realtimeCtx: RealtimePublisher | null
361
+ realtimeRoutes: Map<string, RealtimeConfig>
362
+ records: Map<RouteId, RouteHandler>
194
363
  root: TreeNode
364
+ /** root is a loaded tree (frozen, possibly shared by other apps) — copy before inserting */
365
+ rootShared: boolean
366
+ /** path-scoped middleware, in registration order */
367
+ scoped: ScopedEntry[]
368
+ /** chains of the handles that served a request — decide what 404 and 405 run when there are no routes */
369
+ served: Set<RuntimeMiddleware[]>
370
+ settings: AppSettings<unknown>
371
+ /** registered after routeTree() with no leaf in the loaded tree — stale generated file */
372
+ unexpected: Set<RouteId>
373
+ wsRecords: Map<RouteId, WSRouteHandler>
195
374
  }
196
375
 
197
- function walkTreeHandlers(root: TreeNode, cb: (h: RouteHandler) => void, wsCb?: (h: WSRouteHandler) => void): void {
198
- if (root.m) {
199
- for (const h of Object.values(root.m) as RouteHandler[]) cb(h)
376
+ /** Outcome of mounting an internal route: done, or who already owns the path. */
377
+ type MountResult = "internal" | "mounted" | "user"
378
+
379
+ function createGraph(): HoneyGraph {
380
+ return {
381
+ absorbedMetaSpecs: [],
382
+ catchAll: new Map(),
383
+ chains: [],
384
+ epoch: 0,
385
+ final: null,
386
+ global: new Map(),
387
+ hasWs: false,
388
+ loaded: null,
389
+ metaSpec: null,
390
+ pending: new Set(),
391
+ realtimeBus: null,
392
+ realtimeCtx: null,
393
+ realtimeRoutes: new Map(),
394
+ records: new Map(),
395
+ root: createNode(),
396
+ rootShared: false,
397
+ scoped: [],
398
+ served: new Set(),
399
+ settings: createSettings(),
400
+ unexpected: new Set(),
401
+ wsRecords: new Map(),
200
402
  }
201
- if (root.w) {
202
- for (const h of Object.values(root.w.m) as RouteHandler[]) cb(h)
203
- }
204
- if (root.ws && wsCb) wsCb(root.ws)
205
- for (const child of Object.values(root.s)) walkTreeHandlers(child, cb, wsCb)
206
- if (root.d) walkTreeHandlers(root.d.c, cb, wsCb)
403
+ }
404
+
405
+ /** Fresh copy of a record for another graph — sources are copied, resolved fields recomputed there. */
406
+ function copyRecord<T extends RouteHandler | WSRouteHandler>(r: T): T {
407
+ const out = { ...r, ek: new Set(r.ek) } as T
408
+ if (r.dk !== undefined) out.dk = new Set(r.dk)
409
+ if (r.cm !== undefined) out.cm = [...r.cm]
410
+ if (r.rm !== undefined) out.rm = [...r.rm]
411
+ delete (out as RouteHandler).ca
412
+ return out
413
+ }
414
+
415
+ /** Every leaf a pattern occupies: an optional last param places two. */
416
+ function leafVariants(segments: readonly Segment[]): Segment[][] {
417
+ return expandOptional(segments)
418
+ }
419
+
420
+ /** Record the error behind an error response on `ctx.error` (read-only for user code). */
421
+ function setCtxError(ctx: HoneyContext<unknown>, error: HoneyError): void {
422
+ ;(ctx as unknown as Record<string, unknown>)["error"] = error
423
+ }
424
+
425
+ /** What `ctx.error` holds for a throw the boundary could not resolve: the 500 it became. */
426
+ function asHoneyError(thrown: unknown): HoneyError {
427
+ return thrown instanceof HoneyError
428
+ ? thrown
429
+ : new HoneyError({ cause: thrown, errorKey: EK.internal_server_error, status: SK.internal_server_error })
430
+ }
431
+
432
+ function patternOf(id: RouteId): { method: string; segments: Segment[] } {
433
+ const { method, pattern } = splitRouteId(id)
434
+ return { method, segments: parsePattern(pattern) }
435
+ }
436
+
437
+ /** `fn` of a tree leaf no handler serves — dispatch answers 404, codegen still documents it. */
438
+ const NOT_SERVED: RouteHandler["fn"] = () => {
439
+ throw new Error("honey: unserved route reached its handler")
440
+ }
441
+
442
+ function isRootWildcard(segments: readonly Segment[]): boolean {
443
+ return segments.length === 1 && segments[0].k === "wildcard"
207
444
  }
208
445
 
209
446
  const EMPTY_PARAMS = EMPTY_OBJ as Record<string, string>
210
- const EMPTY_MW: RuntimeMiddleware[] = []
211
447
 
212
448
  function requestIsWsUpgrade(request: Request): boolean {
213
449
  try {
@@ -218,37 +454,43 @@ function requestIsWsUpgrade(request: Request): boolean {
218
454
  }
219
455
  }
220
456
 
221
- /** Find first '?' or '#' in url starting from pos */
222
- function findSearchOrHash(url: string, pos: number): number {
223
- for (let i = pos; i < url.length; i++) {
224
- const c = url.charCodeAt(i)
225
- if (c === 63 || c === 35) return i
226
- }
227
- return -1
457
+ /** The info object `Deno.serve` passes as the second handler argument. */
458
+ function isDenoServeInfo(env: unknown): boolean {
459
+ return env !== null && typeof env === "object" && (env as { remoteAddr?: unknown }).remoteAddr !== undefined
228
460
  }
229
461
 
462
+ /** `obj[key]` for an own key only; `undefined` for inherited names such as `constructor`. */
230
463
  function safeFire(fn: (() => unknown) | undefined, logger?: Logger): void {
231
464
  if (fn === undefined) return
232
465
  try {
233
466
  const result = fn()
234
467
  if (result && typeof result === "object" && "catch" in result) {
235
468
  ;(result as Promise<unknown>).catch((e: unknown) => {
236
- logger?.warn?.("telemetry callback failed", e)
469
+ logger?.warn?.({ err: e }, "telemetry callback failed")
237
470
  })
238
471
  }
239
472
  } catch (e) {
240
- logger?.warn?.("telemetry callback failed", e)
473
+ logger?.warn?.({ err: e }, "telemetry callback failed")
241
474
  }
242
475
  }
243
476
 
244
- /** Internal context shared across extracted fetch sub-methods */
477
+ /** Per-request state, shared by the dispatcher, the error boundary and terminal handlers. */
245
478
  type FetchCtx<TEnv> = {
479
+ /** methods a 405 lists; also marks the miss pipeline as a 405 */
480
+ allowed: string[] | null
246
481
  env: TEnv
247
482
  executionCtx: { waitUntil?: (p: Promise<unknown>) => void } | undefined
248
483
  log: Logger | undefined
484
+ method: string
485
+ path: string
486
+ plan: Plan | null
249
487
  request: Request
250
488
  startTime: number
489
+ /** the app's trustProxy() setting, read by ctx.ip and clientInfo() */
490
+ trust: TrustSetting
251
491
  url: () => URL
492
+ /** matched websocket route, for the upgrade terminal */
493
+ ws: { handler: WSRouteHandler; params: Record<string, string> } | null
252
494
  /** Already known from fetch() before Deno.upgradeWebSocket consumes the request. */
253
495
  wsUpgrade?: boolean
254
496
  /** Headers copied before Deno.upgradeWebSocket closes the Request. */
@@ -281,11 +523,9 @@ function defaultErrorFormatter(_error: HoneyError, defaultShape: Record<string,
281
523
 
282
524
  type ErrorFormatterFn = ErrorFormatter
283
525
 
284
- const STATIC_CTX_RESERVED = new Set(["background", "cookies", "env", "headers", "params", "req", "res", "search"])
285
-
286
526
  /* errorKeys the framework throws on its own behalf — input/output validation, content negotiation,
287
- * routing. Always passes the boundary check; users never declare these via .errors(). */
288
- const FRAMEWORK_EKS = new Set<(typeof EK)[keyof typeof EK]>([
527
+ * routing, and the shipped middleware. Always passes the boundary check; users never declare these via .errors(). */
528
+ const FRAMEWORK_EKS = new Set<string>([
289
529
  EK.validation_failed,
290
530
  EK.output_validation_failed,
291
531
  EK.output_content_type_mismatch,
@@ -297,8 +537,68 @@ const FRAMEWORK_EKS = new Set<(typeof EK)[keyof typeof EK]>([
297
537
  EK.request_timeout,
298
538
  EK.gateway_timeout,
299
539
  EK.bad_gateway,
540
+ EK.forbidden,
541
+ EK.bad_request,
542
+ EK.malformed_body,
300
543
  ])
301
544
 
545
+ function shouldValidateOutput(mode: OutputValidationMode): boolean {
546
+ if (mode === "always") return true
547
+ if (mode === "off") return false
548
+ return (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV !== "production"
549
+ }
550
+
551
+ /** The scoped middleware behind a request-path check: runs only when the path is inside the scope. */
552
+ function scopeGuard(segs: readonly Segment[], mw: RuntimeMiddleware): RuntimeMiddleware {
553
+ const guard: RuntimeMiddleware = (ctx, next) => (pathInScope(ctx["path"] as string, segs) ? mw(ctx, next) : next())
554
+ Object.defineProperty(guard, "name", { configurable: true, value: mw.name })
555
+ const tagged = mw as { errors?: readonly string[]; meta?: Readonly<Record<string, unknown>> }
556
+ if (tagged.errors) Object.defineProperty(guard, "errors", { value: tagged.errors })
557
+ if (tagged.meta) Object.defineProperty(guard, "meta", { value: tagged.meta })
558
+ return guard
559
+ }
560
+
561
+ /** Wrap a middleware so telemetry sees its own duration and its own throw. Built once at finalize. */
562
+ function timedMiddleware(
563
+ mw: RuntimeMiddleware,
564
+ onMw: NonNullable<TelemetryAdapter["onMiddleware"]>,
565
+ log: Logger | undefined,
566
+ ): RuntimeMiddleware {
567
+ const name = mw.name || "anonymous"
568
+ return async (ctx, next) => {
569
+ const start = performance.now()
570
+ try {
571
+ const res = await mw(ctx, next)
572
+ safeFire(() => onMw({ duration: performance.now() - start, name }), log)
573
+ return res
574
+ } catch (error) {
575
+ safeFire(() => onMw({ duration: performance.now() - start, error, name }), log)
576
+ throw error
577
+ }
578
+ }
579
+ }
580
+
581
+ function errorsOf(mw: RuntimeMiddleware): readonly string[] | undefined {
582
+ return (mw as { errors?: readonly string[] }).errors
583
+ }
584
+
585
+ /** Longest run of middleware every record starts its chain with — what 404 and 405 run. */
586
+ function commonPrefix(chains: ReadonlyArray<readonly RuntimeMiddleware[]>): RuntimeMiddleware[] {
587
+ if (chains.length === 0) return []
588
+ let out = [...(chains[0] as readonly RuntimeMiddleware[])]
589
+ for (let i = 1; i < chains.length && out.length > 0; i++) {
590
+ const c = chains[i] as readonly RuntimeMiddleware[]
591
+ let n = 0
592
+ while (n < out.length && n < c.length && out[n] === c[n]) n++
593
+ out = out.slice(0, n)
594
+ }
595
+ return out
596
+ }
597
+
598
+ function describeMw(mw: RuntimeMiddleware): string {
599
+ return mw.name ? `"${mw.name}"` : "an anonymous middleware"
600
+ }
601
+
302
602
  export class Honey<
303
603
  TEnv,
304
604
  TCtx = HoneyContext<TEnv>,
@@ -317,172 +617,95 @@ export class Honey<
317
617
  declare readonly $meta: TMeta
318
618
  declare readonly $routes: TRoutes
319
619
  declare readonly $taps: TTaps
620
+ /* view state — what routes registered through this handle capture */
320
621
  private _basePath: string
321
- private _defaultBoundaryKey: string | null
322
- private _defaultErrorKeys: Set<string>
323
- private _errorFactory: unknown
324
- private _errorSchema: StandardSchemaLike | null
325
- private _customErrorFormatter: CustomErrorFormatter | null
326
- private _customErrorSchema: StandardSchemaLike | null
622
+ private _chain: RuntimeMiddleware[]
327
623
  private _chainMeta: Record<string, unknown> | null
328
- private _chainMiddlewares: RuntimeMiddleware[]
329
- private _scopedMiddlewares: ScopedEntry[]
330
624
  private _contextValues: Record<string, unknown> | null
331
- private _errorFormatter: ErrorFormatterFn
332
- private _errorI18n: ErrorI18nConfig<TEnv> | null
333
- private _globalMiddlewares: RuntimeMiddleware[]
334
625
  private _graph: HoneyGraph
335
- private _hasWsRoutes: boolean
336
- private _logger: Logger | null
337
- private _outputValidation: "always" | "dev" | "off"
338
- private _staticRoutes: { map: Record<string, RouteHandler> | null }
339
- private _stripPrefix: string | null
340
- private _trailingSlash: "enforce" | "ignore" | "strip"
341
- private _wsAdapter: WSAdapter | null
342
- private _openApiCache: { epoch: number; value: Promise<unknown> } | null
343
- private _openApiYamlCache: { epoch: number; value: Promise<string> } | null
344
- private _manifestCache: { epoch: number; value: Promise<unknown> } | null
345
- private _onError:
346
- | ((
347
- error: unknown,
348
- ctx: {
349
- env: TEnv
350
- jsonFromError: (err: HoneyError) => Response
351
- req: Request
352
- },
353
- ) => HoneyError | Response | Promise<HoneyError | Response | undefined | void> | undefined | void)
354
- | null
355
- private _onMethodNotAllowed:
356
- | ((ctx: {
357
- allowed: string[]
358
- env: TEnv
359
- jsonFromError: (err: HoneyError) => Response
360
- req: Request
361
- }) => Response | Promise<Response>)
362
- | null
363
- private _onNotFound:
364
- | ((ctx: { env: TEnv; jsonFromError: (err: HoneyError) => Response; req: Request }) => Response | Promise<Response>)
365
- | null
366
- private _taps: Map<string, (ctx: TapContext<TEnv>, payload: unknown) => void | Promise<void>> | null
367
- private _telemetry: TelemetryAdapter | null
368
- private _realtimeRoutes: Map<
369
- string,
370
- { handler: RealtimeRouteOpts["handler"]; middlewares?: RealtimeRouteOpts["use"]; reconnectBuffer?: number }
371
- >
626
+ /** the `use(mw)` call that created this chain — null on the root handle */
627
+ private _node: ChainNode | null
372
628
 
373
- constructor(opts?: {
374
- chainMiddlewares?: RuntimeMiddleware[]
375
- defaultErrorKeys?: Set<string>
376
- globalMiddlewares?: RuntimeMiddleware[]
377
- graph?: HoneyGraph
378
- handlerMap?: Record<string, RouteHandler> | null
379
- root?: TreeNode
380
- scopedMiddlewares?: ScopedEntry[]
381
- }) {
629
+ constructor(opts?: { graph?: HoneyGraph }) {
382
630
  this._basePath = "/"
383
- this._graph = opts?.graph ?? {
384
- handlerMap: opts?.handlerMap ?? null,
385
- hasRouteTree: false,
386
- metaSpec: null,
387
- realtimeBus: null,
388
- root: opts?.root ?? createNode(),
389
- }
390
- this._globalMiddlewares = opts?.globalMiddlewares ?? []
391
- this._scopedMiddlewares = opts?.scopedMiddlewares ?? []
631
+ this._chain = []
392
632
  this._chainMeta = null
393
- this._chainMiddlewares = opts?.chainMiddlewares ?? []
394
633
  this._contextValues = null
395
- this._defaultBoundaryKey = null
396
- this._defaultErrorKeys = opts?.defaultErrorKeys ?? new Set()
397
- this._errorFactory = null
398
- this._errorSchema = null
399
- this._customErrorFormatter = null
400
- this._customErrorSchema = null
401
- this._errorFormatter = defaultErrorFormatter
402
- this._errorI18n = null
403
- this._hasWsRoutes = false
404
- this._logger = null
405
- this._outputValidation = "off"
406
- this._staticRoutes = { map: null }
407
- this._stripPrefix = null
408
- this._trailingSlash = "ignore"
409
- this._wsAdapter = null
410
- this._openApiCache = null
411
- this._openApiYamlCache = null
412
- this._manifestCache = null
413
- this._onError = null
414
- this._onNotFound = null
415
- this._onMethodNotAllowed = null
416
- this._taps = null
417
- this._telemetry = null
418
- this._realtimeRoutes = new Map()
419
- }
420
-
421
- private get _handlerMap(): Record<string, RouteHandler> | null {
422
- return this._graph.handlerMap
423
- }
424
- private set _handlerMap(value: Record<string, RouteHandler> | null) {
425
- this._graph.handlerMap = value
426
- }
427
-
428
- private get _hasRouteTree(): boolean {
429
- return this._graph.hasRouteTree
430
- }
431
- private set _hasRouteTree(value: boolean) {
432
- this._graph.hasRouteTree = value
433
- }
434
-
435
- /** @internal — read by codegen */
436
- private get _metaSpec(): MetaSpecConfig | null {
437
- return this._graph.metaSpec
438
- }
439
- private set _metaSpec(value: MetaSpecConfig | null) {
440
- this._graph.metaSpec = value
634
+ this._graph = opts?.graph ?? createGraph()
635
+ this._node = null
441
636
  }
442
637
 
443
- private get _root(): TreeNode {
444
- return this._graph.root
638
+ private get _s(): AppSettings<TEnv> {
639
+ return this._graph.settings as AppSettings<TEnv>
640
+ }
641
+
642
+ /**
643
+ * @internal — read by codegen through `metaSpecOf()`: the declared policy and every mounted
644
+ * sub-app's, unmerged. Merged on read, so `metaSpec()` may come before or after `route(sub)`;
645
+ * the merge lives with codegen so a served app never loads it.
646
+ */
647
+ private get _metaSpecSource(): MetaSpecSource {
648
+ return { absorbed: this._graph.absorbedMetaSpecs, declared: this._graph.metaSpec }
445
649
  }
446
- private set _root(value: TreeNode) {
447
- this._graph.root = value
650
+
651
+ /** @internal — read by codegen and OpenAPI */
652
+ get _errorFactory(): ErrorFactoryRecord | null {
653
+ return this._graph.settings.errorFactory
654
+ }
655
+ set _errorFactory(factory: ErrorFactoryRecord | null) {
656
+ this._graph.settings.errorFactory = factory
657
+ this._bumpEpoch()
658
+ }
659
+
660
+ /** @internal — read by OpenAPI */
661
+ get _errorSchema(): StandardSchemaLike | null {
662
+ return this._graph.settings.errorSchema
448
663
  }
449
664
 
450
- /** @internal — used by codegen */
451
- private get _tree(): TreeNode {
452
- return this._root
665
+ /** @internal — read by OpenAPI */
666
+ get _customErrorSchema(): StandardSchemaLike | null {
667
+ return this._graph.settings.customErrorSchema
668
+ }
669
+
670
+ private get _root(): TreeNode {
671
+ return this._graph.root
453
672
  }
454
673
 
455
674
  private get _realtimeBus(): RealtimeBus | null {
456
675
  return this._graph.realtimeBus
457
676
  }
458
- private set _realtimeBus(value: RealtimeBus | null) {
459
- this._graph.realtimeBus = value
460
- }
461
677
 
462
- /** @internal — mark that this app has WS routes */
463
- _markWsRoutes(): void {
464
- this._hasWsRoutes = true
678
+ /** A new handle on the same graph, carrying this handle's view. */
679
+ private _derive(chain: RuntimeMiddleware[] = this._chain): Honey<TEnv> {
680
+ const next = new Honey<TEnv>({ graph: this._graph })
681
+ next._basePath = this._basePath
682
+ next._chain = chain
683
+ next._chainMeta = this._chainMeta
684
+ next._contextValues = this._contextValues
685
+ next._node = this._node
686
+ return next
465
687
  }
466
688
 
467
- /** @internal — register a static route for O(1) lookup */
468
- _registerStatic(key: string, handler: RouteHandler): void {
469
- if (this._staticRoutes.map === null) {
470
- this._staticRoutes.map = Object.create(null) as Record<string, RouteHandler>
689
+ /** This handle registers, mounts or serves — every `use()` on its way here is in use. */
690
+ private _markUsed(): void {
691
+ let node = this._node
692
+ while (node !== null && !node.used) {
693
+ node.used = true
694
+ node = node.parent
471
695
  }
472
- this._staticRoutes.map[key] = handler
473
696
  }
474
697
 
475
698
  /** @internal — used by RouteBuilder for pre-filtered error factory */
476
699
  get _factory(): unknown {
477
- return this._errorFactory
700
+ return this._graph.settings.errorFactory
478
701
  }
479
702
 
480
703
  /** @internal — used by runtime error boundary */
481
704
  get _boundaryKey(): string | null {
482
- return this._defaultBoundaryKey
705
+ return this._graph.settings.defaultBoundaryKey
483
706
  }
484
707
 
485
- /** Convert unknown thrown value to error Response — used in WS, 404, 405 catch blocks */
708
+ /** Convert an unknown thrown value to a plain error Response — no boundary, no onError. */
486
709
  private _toErrorResponse(thrown: unknown): Response {
487
710
  const error =
488
711
  thrown instanceof HoneyError
@@ -492,14 +715,12 @@ export class Honey<
492
715
  errorKey: EK.internal_server_error,
493
716
  status: SK.internal_server_error,
494
717
  })
495
- return createErrorResponse(error, this._errorFormatter, this._customErrorFormatter)
718
+ const s = this._s
719
+ return createErrorResponse(error, s.errorFormatter, s.customErrorFormatter)
496
720
  }
497
721
 
498
- private _createBoundaryError(errorKey: string, cause: unknown): HoneyError {
499
- const factory = this._errorFactory as Record<
500
- string,
501
- ((opts?: { cause?: unknown }) => HoneyError) | undefined
502
- > | null
722
+ private _createBoundaryError(errorKey: string, cause: unknown, fac: ErrorFactoryRecord | null): HoneyError {
723
+ const factory = fac as Record<string, ((opts?: { cause?: unknown }) => HoneyError) | undefined> | null
503
724
  const factoryFn = factory?.[errorKey]
504
725
  if (factoryFn) {
505
726
  /* check if this is a custom schema error via ERROR_META — boundary must use standard errors only */
@@ -518,7 +739,7 @@ export class Honey<
518
739
  }
519
740
 
520
741
  private _createError(errorKey: string, statusKey: StatusKey): HoneyError {
521
- const factory = this._errorFactory as Record<string, (() => HoneyError) | undefined> | null
742
+ const factory = this._s.errorFactory as Record<string, (() => HoneyError) | undefined> | null
522
743
  const factoryFn = factory?.[errorKey]
523
744
  if (factoryFn) {
524
745
  return factoryFn()
@@ -526,85 +747,23 @@ export class Honey<
526
747
  return new HoneyError({ errorKey, status: statusKey })
527
748
  }
528
749
 
529
- /** Apply error keys from a single scoped entry to every matching handler currently in the tree */
530
- private _applyScopedEntryErrors(entry: ScopedEntry): void {
531
- const errors = entry.errors
532
- if (!errors || errors.length === 0) return
533
- walkTreeHandlers(
534
- this._root,
535
- (h) => {
536
- if (scopeMatches(entry.prefix, h.rp ?? "")) {
537
- for (const k of errors) h.ek.add(k)
538
- }
539
- },
540
- (wh) => {
541
- if (scopeMatches(entry.prefix, wh.rp)) {
542
- for (const k of errors) wh.ek.add(k)
543
- }
544
- },
545
- )
546
- }
547
-
548
750
  /**
549
- * Meta contributed by every middleware that will run for `routePath`, in runtime order:
550
- * global, chain, then scoped entries matching the path. Route-level `.use()` is folded in
551
- * later, by the builder. Resolved here — at registration — because the precompiled route
552
- * tree bakes `mt` as a literal, so a per-request derivation would diverge from it.
751
+ * The i18n-resolved copy of `honeyError`: message from the locale's template for its
752
+ * errorKey, field paths from the locale's field names. Returns `honeyError` itself when
753
+ * i18n is not configured or nothing matches. Never mutates it: an error instance can be
754
+ * thrown again, on another request, in another locale.
553
755
  */
554
- private _contributedMetaFor(routePath: string): Record<string, unknown> | null {
555
- const scoped: RuntimeMiddleware[] = []
556
- for (const entry of this._scopedMiddlewares) {
557
- if (scopeMatches(entry.prefix, routePath)) scoped.push(entry.mw)
558
- }
559
- return collectMiddlewareMeta([this._globalMiddlewares, this._chainMiddlewares, scoped])
560
- }
561
-
562
- /**
563
- * Back-fill meta from one scoped entry onto handlers already in the tree. Mirrors
564
- * `_applyScopedEntryErrors` — `.use("/prefix", mw)` may be registered after the routes it
565
- * covers, and a tag missing where enforcement happens is the worst failure direction.
566
- * `mt` is frozen at registration, so this replaces the object rather than mutating it.
567
- */
568
- private _applyScopedEntryMeta(entry: ScopedEntry): void {
569
- const meta = (entry.mw as { meta?: Record<string, unknown> }).meta
570
- if (!meta) return
571
- const apply = (h: { mt: Record<string, unknown> | null; rp?: string }): void => {
572
- if (!scopeMatches(entry.prefix, h.rp ?? "")) return
573
- /* contributed meta never overwrites what the route or chain stated explicitly */
574
- h.mt = Object.freeze(h.mt ? { ...meta, ...h.mt } : { ...meta })
575
- }
576
- walkTreeHandlers(this._root, apply, apply)
577
- }
578
-
579
- /** Apply error keys from every scoped entry on this chain to every matching handler in the tree */
580
- private _applyAllScopedErrors(): void {
581
- for (const entry of this._scopedMiddlewares) {
582
- this._applyScopedEntryErrors(entry)
583
- }
584
- }
585
-
586
- /** Return scoped middleware functions that match the given route path */
587
- private _filterScopedForPath(routePath: string): RuntimeMiddleware[] {
588
- if (this._scopedMiddlewares.length === 0) return EMPTY_MW
589
- const out: RuntimeMiddleware[] = []
590
- for (const s of this._scopedMiddlewares) {
591
- if (scopeMatches(s.prefix, routePath)) out.push(s.mw)
592
- }
593
- return out
594
- }
595
-
596
- /** Mutates `honeyError.message` in-place with the i18n-resolved template for its errorKey.
597
- * No-op when i18n is not configured or when no template matches. */
598
756
  private async _resolveI18n(
599
757
  honeyError: HoneyError,
600
758
  ctx: HoneyContext<TEnv>,
601
759
  env: TEnv,
602
760
  request: Request,
603
761
  log?: Logger,
604
- ): Promise<void> {
605
- if (!this._errorI18n) return
762
+ ): Promise<HoneyError> {
763
+ const i18n = this._s.errorI18n
764
+ if (!i18n) return honeyError
606
765
  try {
607
- const locale = await this._errorI18n.resolveLocale({
766
+ const locale = await i18n.resolveLocale({
608
767
  cookies: ctx.cookies,
609
768
  env,
610
769
  headers: ctx.headers,
@@ -612,195 +771,121 @@ export class Honey<
612
771
  req: request,
613
772
  search: ctx.search,
614
773
  })
615
- const translations = this._errorI18n.errors?.[locale]
616
- if (translations) {
617
- const template = translations[honeyError.errorKey]
618
- if (template) {
619
- await loadHoneyFeature("i18n")
620
- honeyError.message = getI18nRuntime().interpolate(template, honeyError.vars ?? {})
621
- }
622
- }
774
+ await loadHoneyFeature("i18n")
775
+ return getI18nRuntime().translateError(honeyError, i18n, locale)
776
+ } catch (e) {
777
+ log?.warn?.({ err: e }, "i18n resolution failed")
778
+ return honeyError
779
+ }
780
+ }
623
781
 
624
- const fieldTranslations = this._errorI18n.fieldNames?.[locale]
625
- if (fieldTranslations && Object.keys(honeyError.fields).length > 0) {
626
- for (const fieldErrors of Object.values(honeyError.fields)) {
627
- for (const fe of fieldErrors) {
628
- let candidate = fe.path
629
- while (candidate) {
630
- const translated = fieldTranslations[candidate]
631
- if (translated) {
632
- fe.path = translated
633
- break
634
- }
635
- const dotIdx = candidate.indexOf(".")
636
- if (dotIdx === -1) break
637
- candidate = candidate.slice(dotIdx + 1)
638
- }
639
- }
640
- }
641
- }
782
+ /**
783
+ * The error boundary of every `next()`: turns what a handler or middleware threw into the
784
+ * Response that replaces it, so every middleware around it sees a Response. Never rejects.
785
+ */
786
+ private _convertError(thrown: unknown, ctx: HoneyContext<TEnv>): Response | Promise<Response> {
787
+ const fc = ctx._rq as FetchCtx<TEnv> | null
788
+ const plan = fc?.plan
789
+ if (fc === null || fc === undefined || plan === null || plan === undefined) {
790
+ setCtxError(ctx, asHoneyError(thrown))
791
+ return this._safeErrorResponse(thrown)
792
+ }
793
+ const fallback = (e: unknown): Response => {
794
+ fc.log?.warn?.({ err: e }, "error response failed")
795
+ setCtxError(ctx, asHoneyError(thrown))
796
+ return this._safeErrorResponse(thrown)
797
+ }
798
+ try {
799
+ return this._resolveErrorResponse(thrown, plan, fc, ctx).catch(fallback)
642
800
  } catch (e) {
643
- log?.warn?.("i18n resolution failed", e)
801
+ return fallback(e)
802
+ }
803
+ }
804
+
805
+ /** Last resort when even the configured formatter throws. */
806
+ private _safeErrorResponse(thrown: unknown): Response {
807
+ try {
808
+ return this._toErrorResponse(thrown instanceof HoneyError ? thrown : undefined)
809
+ } catch {
810
+ return new Response(JSON.stringify({ error: { errorKey: EK.internal_server_error } }), {
811
+ headers: { "content-type": "application/json" },
812
+ status: 500,
813
+ })
644
814
  }
645
815
  }
646
816
 
647
817
  /**
648
- * Convert thrown value into an error Response — resolves boundary wrapping,
649
- * i18n translation, onError callback, and telemetry.
650
- * Called from the handler wrapper so errors flow back through middleware.
818
+ * Convert thrown value into an error Response — resolves boundary wrapping, i18n
819
+ * translation, the onError callback, and error telemetry. `onResponse` telemetry is not
820
+ * fired here: the request fires it exactly once, on the way out.
651
821
  */
652
822
  private async _resolveErrorResponse(
653
823
  thrown: unknown,
654
- handler: RouteHandler,
824
+ plan: Plan,
655
825
  fc: FetchCtx<TEnv>,
656
- method: string,
657
- path: string,
658
826
  ctx: HoneyContext<TEnv>,
659
827
  ): Promise<Response> {
660
- const { env, log, request, startTime } = fc
828
+ const { env, log, request, startTime, method, path } = fc
829
+ const s = this._s
830
+ const record = plan.r
831
+ const fac = (record.fac ?? null) as ErrorFactoryRecord | null
661
832
  let honeyError: HoneyError
662
- const boundaryKey = handler.bek ?? this._defaultBoundaryKey
833
+ const boundaryKey = record.bek
663
834
 
664
835
  if (thrown instanceof HoneyError) {
665
836
  /* framework-managed errorKeys (input/output validation, content negotiation, etc.) are always allowed
666
- * regardless of handler.ek — users never declare them, the framework owns them. */
667
- const isFrameworkEk = FRAMEWORK_EKS.has(thrown.errorKey as (typeof EK)[keyof typeof EK])
668
- if (!isFrameworkEk && handler.ek.size > 0 && !handler.ek.has(thrown.errorKey)) {
669
- if (boundaryKey) {
670
- honeyError = this._createBoundaryError(boundaryKey, thrown)
671
- } else {
672
- honeyError = new HoneyError({
673
- cause: thrown,
674
- errorKey: EK.internal_server_error,
675
- status: SK.internal_server_error,
676
- })
677
- }
837
+ * regardless of the declared keys — users never declare them, the framework owns them. */
838
+ if (plan.enf && !FRAMEWORK_EKS.has(thrown.errorKey) && !record.ek.has(thrown.errorKey)) {
839
+ honeyError = boundaryKey
840
+ ? this._createBoundaryError(boundaryKey, thrown, fac)
841
+ : new HoneyError({ cause: thrown, errorKey: EK.internal_server_error, status: SK.internal_server_error })
678
842
  } else {
679
843
  honeyError = thrown
680
844
  }
681
845
  } else {
682
- if (boundaryKey) {
683
- honeyError = this._createBoundaryError(boundaryKey, thrown)
684
- } else {
685
- honeyError = new HoneyError({
686
- cause: thrown,
687
- errorKey: EK.internal_server_error,
688
- status: SK.internal_server_error,
689
- })
690
- }
846
+ honeyError = boundaryKey
847
+ ? this._createBoundaryError(boundaryKey, thrown, fac)
848
+ : new HoneyError({ cause: thrown, errorKey: EK.internal_server_error, status: SK.internal_server_error })
691
849
  }
692
850
 
693
- await this._resolveI18n(honeyError, ctx, env, request, log)
851
+ honeyError = await this._resolveI18n(honeyError, ctx, env, request, log)
694
852
 
695
- /* onError handler */
696
- if (this._onError) {
853
+ if (s.onError) {
697
854
  try {
698
- const customResult = await this._onError(thrown, this._makeErrorCtx(fc))
855
+ const customResult = await s.onError(thrown, this._makeErrorCtx(fc))
699
856
  if (customResult instanceof HoneyError) {
700
857
  /* user-mapped boundary error — re-run i18n against new errorKey,
701
- * then fall through to default response path so telemetry +
702
- * jsonFromError run exactly once. */
703
- honeyError = customResult
704
- await this._resolveI18n(honeyError, ctx, env, request, log)
858
+ * then fall through to the default response path */
859
+ honeyError = await this._resolveI18n(customResult, ctx, env, request, log)
705
860
  } else if (customResult) {
706
861
  safeFire(
707
- () =>
708
- this._telemetry?.onError?.({
709
- duration: performance.now() - startTime,
710
- error: honeyError,
711
- method,
712
- path,
713
- }),
714
- log,
715
- )
716
- safeFire(
717
- () =>
718
- this._telemetry?.onResponse?.({
719
- duration: performance.now() - startTime,
720
- req: request,
721
- status: customResult.status,
722
- }),
862
+ () => s.telemetry?.onError?.({ duration: performance.now() - startTime, error: honeyError, method, path }),
723
863
  log,
724
864
  )
865
+ setCtxError(ctx, honeyError)
725
866
  ctx._isErrorResponse = true
726
867
  return customResult
727
868
  }
728
869
  /* customResult === undefined | void → fall through to default path */
729
- } catch {
730
- /* swallow onError errors */
870
+ } catch (e) {
871
+ log?.warn?.({ err: e }, "onError failed")
731
872
  }
732
873
  }
733
874
 
734
- /* default error response */
735
875
  safeFire(
736
- () =>
737
- this._telemetry?.onError?.({
738
- duration: performance.now() - startTime,
739
- error: honeyError,
740
- method,
741
- path,
742
- }),
743
- log,
744
- )
745
- const res = this._makeErrorCtx(fc).jsonFromError(honeyError)
746
- safeFire(
747
- () =>
748
- this._telemetry?.onResponse?.({
749
- duration: performance.now() - startTime,
750
- req: request,
751
- status: res.status,
752
- }),
876
+ () => s.telemetry?.onError?.({ duration: performance.now() - startTime, error: honeyError, method, path }),
753
877
  log,
754
878
  )
879
+ setCtxError(ctx, honeyError)
755
880
  ctx._isErrorResponse = true
756
- return res
881
+ return this._makeErrorCtx(fc).jsonFromError(honeyError)
757
882
  }
758
883
 
759
884
  basePath<P extends string>(
760
885
  prefix: P,
761
886
  ): Honey<TEnv, TCtx, TRoutes, TMeta, TErrorFactory, TDefaultErrors, MergePath<TBasePath, P>, TTaps, TScopedMw> {
762
- const next = new Honey<
763
- TEnv,
764
- TCtx,
765
- TRoutes,
766
- TMeta,
767
- TErrorFactory,
768
- TDefaultErrors,
769
- MergePath<TBasePath, P>,
770
- TTaps,
771
- TScopedMw
772
- >({
773
- chainMiddlewares: this._chainMiddlewares,
774
- defaultErrorKeys: this._defaultErrorKeys,
775
- globalMiddlewares: this._globalMiddlewares,
776
- graph: this._graph,
777
- scopedMiddlewares: this._scopedMiddlewares,
778
- })
887
+ const next = this._derive()
779
888
  next._basePath = mergePath(this._basePath, prefix)
780
- next._defaultBoundaryKey = this._defaultBoundaryKey
781
- next._errorFactory = this._errorFactory
782
- next._errorSchema = this._errorSchema
783
- next._customErrorFormatter = this._customErrorFormatter
784
- next._customErrorSchema = this._customErrorSchema
785
- next._errorFormatter = this._errorFormatter
786
- next._errorI18n = this._errorI18n
787
- next._logger = this._logger
788
- next._outputValidation = this._outputValidation
789
- next._stripPrefix = this._stripPrefix
790
- next._trailingSlash = this._trailingSlash
791
- next._wsAdapter = this._wsAdapter
792
- next._onError = this._onError
793
- next._onNotFound = this._onNotFound
794
- next._onMethodNotAllowed = this._onMethodNotAllowed
795
- next._chainMeta = this._chainMeta
796
- next._contextValues = this._contextValues
797
- next._taps = this._taps
798
- next._telemetry = this._telemetry
799
- next._realtimeBus = this._realtimeBus
800
- next._realtimeRoutes = this._realtimeRoutes
801
- next._staticRoutes = this._staticRoutes
802
- next._hasRouteTree = this._hasRouteTree
803
- next._hasWsRoutes = this._hasWsRoutes
804
889
  return next as unknown as Honey<
805
890
  TEnv,
806
891
  TCtx,
@@ -818,52 +903,12 @@ export class Honey<
818
903
  values: TAdds,
819
904
  ): Honey<TEnv, TCtx & Readonly<TAdds>, TRoutes, TMeta, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw> {
820
905
  for (const key in values) {
821
- if (STATIC_CTX_RESERVED.has(key)) {
906
+ if (RESERVED_CTX_KEYS.has(key)) {
822
907
  throw new Error(`context() cannot set reserved key "${key}"`)
823
908
  }
824
909
  }
825
- const next = new Honey<
826
- TEnv,
827
- TCtx & Readonly<TAdds>,
828
- TRoutes,
829
- TMeta,
830
- TErrorFactory,
831
- TDefaultErrors,
832
- TBasePath,
833
- TTaps,
834
- TScopedMw
835
- >({
836
- chainMiddlewares: this._chainMiddlewares,
837
- defaultErrorKeys: this._defaultErrorKeys,
838
- globalMiddlewares: this._globalMiddlewares,
839
- graph: this._graph,
840
- scopedMiddlewares: this._scopedMiddlewares,
841
- })
842
- next._basePath = this._basePath
843
- next._chainMeta = this._chainMeta
910
+ const next = this._derive()
844
911
  next._contextValues = this._contextValues ? { ...this._contextValues, ...values } : { ...values }
845
- next._defaultBoundaryKey = this._defaultBoundaryKey
846
- next._errorFactory = this._errorFactory
847
- next._errorSchema = this._errorSchema
848
- next._customErrorFormatter = this._customErrorFormatter
849
- next._customErrorSchema = this._customErrorSchema
850
- next._errorFormatter = this._errorFormatter
851
- next._errorI18n = this._errorI18n
852
- next._logger = this._logger
853
- next._outputValidation = this._outputValidation
854
- next._stripPrefix = this._stripPrefix
855
- next._trailingSlash = this._trailingSlash
856
- next._wsAdapter = this._wsAdapter
857
- next._onError = this._onError
858
- next._onNotFound = this._onNotFound
859
- next._onMethodNotAllowed = this._onMethodNotAllowed
860
- next._taps = this._taps
861
- next._telemetry = this._telemetry
862
- next._realtimeBus = this._realtimeBus
863
- next._realtimeRoutes = this._realtimeRoutes
864
- next._staticRoutes = this._staticRoutes
865
- next._hasRouteTree = this._hasRouteTree
866
- next._hasWsRoutes = this._hasWsRoutes
867
912
  return next as unknown as Honey<
868
913
  TEnv,
869
914
  TCtx & Readonly<TAdds>,
@@ -878,17 +923,50 @@ export class Honey<
878
923
  }
879
924
 
880
925
  logger(logger: Logger): this {
881
- this._logger = logger
926
+ this._s.logger = logger
882
927
  return this
883
928
  }
884
929
 
885
- outputValidation(mode: "always" | "dev" | "off"): this {
886
- this._outputValidation = mode
930
+ outputValidation(mode: OutputValidationMode): this {
931
+ this._s.outputValidation = mode
932
+ this._bumpEpoch()
887
933
  return this
888
934
  }
889
935
 
890
936
  trailingSlash(mode: "enforce" | "ignore" | "strip"): this {
891
- this._trailingSlash = mode
937
+ this._s.trailingSlash = mode
938
+ return this
939
+ }
940
+
941
+ /**
942
+ * Request paths containing an encoded `/` or `\` (`%2F`, `%5C`) answer 400 by default.
943
+ * `"allow"` keeps them encoded in the path, so they decode into a param value
944
+ * (`/repos/:id` with `/repos/group%2Fproject` gives `id === "group/project"`). Only allow
945
+ * them when nothing downstream (a `proxy()` upstream, a file resolver) treats a decoded
946
+ * slash as a separator.
947
+ */
948
+ encodedSlashes(mode: "allow" | "reject"): this {
949
+ this._s.encodedSlashes = mode
950
+ return this
951
+ }
952
+
953
+ /**
954
+ * Which reverse proxies in front of the app to believe. Decides `ctx.ip`, `clientInfo()`,
955
+ * `ipRestrict`, and every other feature that needs the client's address, scheme or host.
956
+ *
957
+ * - `false` (default): the peer address the runtime reports is the client; `X-Forwarded-*`
958
+ * headers are ignored, since any client can send them.
959
+ * - a hop count: exactly that many proxies, each appending its peer to `X-Forwarded-For`
960
+ * (nginx `$proxy_add_x_forwarded_for`, most load balancers). The client is the entry the
961
+ * outermost proxy wrote.
962
+ * - addresses and CIDR ranges of your proxies: hops from those addresses are skipped; the
963
+ * first other address is the client.
964
+ *
965
+ * A hop count or list needs `import "@lovrozagar/honey/trust"` in the app entry.
966
+ */
967
+ trustProxy(value: TrustProxy): this {
968
+ this._s.trust =
969
+ value === false || value === 0 ? TRUST_OFF : requireFeature("trust", "app.trustProxy()").compileTrust(value)
892
970
  return this
893
971
  }
894
972
 
@@ -898,19 +976,29 @@ export class Honey<
898
976
  if (normalized.length > 0 && normalized.charCodeAt(0) !== 47) {
899
977
  normalized = `/${normalized}`
900
978
  }
901
- this._stripPrefix = normalized === "" || normalized === "/" ? null : normalized
979
+ this._s.stripPrefix = normalized === "" || normalized === "/" ? null : normalized
902
980
  return this
903
981
  }
904
982
 
905
983
  wsAdapter(adapter: WSAdapter): this {
906
- this._wsAdapter = adapter
984
+ this._s.wsAdapter = adapter
907
985
  return this
908
986
  }
909
987
 
988
+ /**
989
+ * Serve this app's OpenAPI document at `${path}.json`, `.yaml` and `.yml` (default
990
+ * `/openapi`), and optionally a docs UI. Each call serves its own document: two calls with
991
+ * different options (say an internal and a public profile) never share a cache. Documents
992
+ * are generated once per route change, serialized once, and sent with an `ETag` and
993
+ * `Cache-Control: no-cache`. Pass `enabled: false` (for example in production) to mount
994
+ * nothing while keeping the call in place.
995
+ */
910
996
  openapi(options: {
911
997
  description?: string
912
998
  docs?: "scalar" | "swagger"
913
999
  docsPath?: string
1000
+ /** Mount the document and docs routes. Default `true`. */
1001
+ enabled?: boolean
914
1002
  filterRoutes?: (route: { meta: unknown; method: string; path: string }) => boolean
915
1003
  path?: string
916
1004
  /** Named metaSpec profile selecting which emitted keys this document carries */
@@ -919,134 +1007,548 @@ export class Honey<
919
1007
  title: string
920
1008
  version: string
921
1009
  }): this {
1010
+ if (options.enabled === false) return this
922
1011
  const stem = options.path ?? "/openapi"
923
- const yamlHeaders = { headers: { "content-type": "application/yaml; charset=utf-8" } }
924
- const loadJson = (): Promise<unknown> => {
925
- const epoch = this._root.g ?? 0
926
- if (this._openApiCache && this._openApiCache.epoch === epoch) {
927
- return this._openApiCache.value
928
- }
929
- let pending: Promise<unknown>
930
- pending = loadHoneyFeature("openapi")
931
- .then(() =>
932
- getOpenApiRuntime().generateOpenApi(this, {
933
- filterRoutes: options.filterRoutes,
934
- info: {
935
- description: options.description,
936
- title: options.title,
937
- version: options.version,
938
- },
939
- /* a served document is not an authoring moment — the check belongs to `honey generate` */
940
- invalidate: "off",
941
- profile: options.profile,
942
- securitySchemes: options.securitySchemes,
943
- }),
944
- )
945
- .catch((err: unknown) => {
946
- if (this._openApiCache?.value === pending) this._openApiCache = null
947
- throw err
948
- })
949
- this._openApiCache = { epoch, value: pending }
950
- return pending
951
- }
952
- const loadYaml = (): Promise<string> => {
953
- const epoch = this._root.g ?? 0
954
- if (this._openApiYamlCache && this._openApiYamlCache.epoch === epoch) {
955
- return this._openApiYamlCache.value
956
- }
957
- let pending: Promise<string>
958
- pending = Promise.resolve()
959
- .then(async () => getOpenApiRuntime().toYaml(await loadJson()))
960
- .catch((err: unknown) => {
961
- if (this._openApiYamlCache?.value === pending) this._openApiYamlCache = null
962
- throw err
963
- })
964
- this._openApiYamlCache = { epoch, value: pending }
965
- return pending
966
- }
967
- this._mountInternalGet(`${stem}.json`, async (ctx) => ctx.res.json("ok", await loadJson()))
968
- this._mountInternalGet(`${stem}.yml`, async (ctx) => ctx.res.text("ok", await loadYaml(), yamlHeaders))
969
- this._mountInternalGet(`${stem}.yaml`, async (ctx) => ctx.res.text("ok", await loadYaml(), yamlHeaders))
1012
+ const epochOf = (): number => this._graph.epoch
1013
+ /* per call, not per app: each openapi() serves the document its own options describe */
1014
+ const document = epochCached(epochOf, async () => {
1015
+ await loadHoneyFeature("openapi")
1016
+ return getOpenApiRuntime().generateOpenApi(this, {
1017
+ filterRoutes: options.filterRoutes,
1018
+ info: { description: options.description, title: options.title, version: options.version },
1019
+ /* a served document is not an authoring moment — the check belongs to `honey generate` */
1020
+ invalidate: "off",
1021
+ profile: options.profile,
1022
+ securitySchemes: options.securitySchemes,
1023
+ })
1024
+ })
1025
+ /* the document first: it loads the runtime the serializers come from */
1026
+ const json = epochCached(epochOf, async () => {
1027
+ const doc = await document()
1028
+ return getOpenApiRuntime().toServedArtifact(JSON.stringify(doc), "application/json")
1029
+ })
1030
+ const yaml = epochCached(epochOf, async () => {
1031
+ const doc = await document()
1032
+ const runtime = getOpenApiRuntime()
1033
+ return runtime.toServedArtifact(runtime.toYaml(doc), "application/yaml; charset=utf-8")
1034
+ })
1035
+ const jsonPath = `${stem}.json`
1036
+ this._mountArtifact("openapi", jsonPath, json)
1037
+ this._mountArtifact("openapi", `${stem}.yml`, yaml)
1038
+ this._mountArtifact("openapi", `${stem}.yaml`, yaml)
970
1039
  if (options.docs) {
971
- const specUrl = mergePath(this._basePath, `${stem}.json`)
972
1040
  const docs = options.docs
1041
+ const specPath = mergePath(this._basePath, jsonPath)
973
1042
  const ui = async (ctx: { res: HoneyRes }) => {
974
1043
  await loadHoneyFeature("openapi")
975
- return getOpenApiRuntime().docsUi(docs, specUrl)(ctx)
1044
+ /* the browser asks for the URL it sees: a stripped prefix is part of it */
1045
+ const prefix = this._s.stripPrefix ?? ""
1046
+ return getOpenApiRuntime().docsUi(docs, prefix + specPath)(ctx)
976
1047
  }
977
- const preferred = options.docsPath ?? "/docs"
978
- const candidates = options.docsPath ? [preferred] : [preferred, "/reference"]
979
- let mounted = false
1048
+ /* without an explicit docsPath, a user route at /docs moves the UI to /reference */
1049
+ const candidates = options.docsPath ? [options.docsPath] : ["/docs", "/reference"]
1050
+ let mounted: MountResult = "user"
1051
+ let tried = candidates[0]
980
1052
  for (const p of candidates) {
981
- if (this._mountInternalGet(p, ui)) {
982
- mounted = true
983
- break
984
- }
1053
+ tried = p
1054
+ mounted = this._mountInternalGet(p, ui)
1055
+ if (mounted !== "user") break
985
1056
  }
986
- if (!mounted) {
987
- throw new Error(`Honey.openapi({ docs }) cannot mount at ${preferred}: already registered. Pass docsPath.`)
1057
+ if (mounted !== "mounted") {
1058
+ const at = mergePath(this._basePath, tried)
1059
+ throw new Error(
1060
+ mounted === "internal"
1061
+ ? `Honey.openapi({ docs }) cannot mount at ${at}: an earlier openapi() or manifest() serves it. Pass docsPath.`
1062
+ : `Honey.openapi({ docs }) cannot mount at ${at}: a route already owns it. Pass docsPath.`,
1063
+ )
988
1064
  }
989
1065
  }
990
1066
  return this
991
1067
  }
992
1068
 
993
- manifest(options?: { path?: string }): this {
994
- const path = options?.path ?? "/manifest.json"
995
- const load = (): Promise<unknown> => {
996
- const epoch = this._root.g ?? 0
997
- if (this._manifestCache && this._manifestCache.epoch === epoch) {
998
- return this._manifestCache.value
999
- }
1000
- let pending: Promise<unknown>
1001
- pending = loadHoneyFeature("openapi")
1002
- .then(() => getOpenApiRuntime().generateManifest(this))
1003
- .catch((err: unknown) => {
1004
- if (this._manifestCache?.value === pending) this._manifestCache = null
1005
- throw err
1006
- })
1007
- this._manifestCache = { epoch, value: pending }
1008
- return pending
1009
- }
1010
- this._mountInternalGet(path, async (ctx) => ctx.res.json("ok", await load()))
1069
+ /**
1070
+ * Serve the route manifest at `path` (default `/manifest.json`), filtered by the same
1071
+ * visibility policy as the OpenAPI document. Cached per route change, sent with an `ETag`.
1072
+ * `enabled: false` mounts nothing.
1073
+ */
1074
+ manifest(options?: { enabled?: boolean; path?: string }): this {
1075
+ if (options?.enabled === false) return this
1076
+ const epochOf = (): number => this._graph.epoch
1077
+ const artifact = epochCached(epochOf, async () => {
1078
+ await loadHoneyFeature("openapi")
1079
+ const runtime = getOpenApiRuntime()
1080
+ return runtime.toServedArtifact(JSON.stringify(await runtime.generateManifest(this)), "application/json")
1081
+ })
1082
+ this._mountArtifact("manifest", options?.path ?? "/manifest.json", artifact)
1011
1083
  return this
1012
1084
  }
1013
1085
 
1086
+ /** Mount a served document; a path another route or an earlier call owns is an error. */
1087
+ private _mountArtifact(owner: "manifest" | "openapi", path: string, load: () => Promise<ServedArtifact>): void {
1088
+ const result = this._mountInternalGet(path, async (ctx) => {
1089
+ const c = ctx as unknown as { req: Request; res: HoneyRes }
1090
+ const artifact = await load()
1091
+ return c.res.raw(getOpenApiRuntime().artifactResponse(c.req, artifact))
1092
+ })
1093
+ if (result === "mounted") return
1094
+ const at = mergePath(this._basePath, path)
1095
+ throw new Error(
1096
+ result === "internal"
1097
+ ? `Honey.${owner}() cannot mount GET ${at}: an earlier openapi() or manifest() serves it. Pass a different path.`
1098
+ : `Honey.${owner}() cannot mount GET ${at}: a route already owns it. Pass a different path.`,
1099
+ )
1100
+ }
1101
+
1014
1102
  async serve(options?: HoneyServeOptions): Promise<ServeHandle> {
1015
1103
  await loadHoneyFeature("serve")
1016
1104
  return getServeRuntime()(this, options)
1017
1105
  }
1018
1106
 
1019
- private _lookupGet(fullPath: string): RouteHandler | null {
1020
- const fromStatic = this._staticRoutes.map?.[`GET ${fullPath}`]
1021
- if (fromStatic) return fromStatic
1022
- const hit = matchRoute(this._root, "GET", fullPath)
1023
- if (hit !== null && hit.matched) return hit.handler
1024
- return null
1025
- }
1026
-
1027
- /** Mount an internal GET. Returns false when a user route already owns the path. */
1028
- private _mountInternalGet(path: string, fn: (ctx: { res: HoneyRes }) => Response | Promise<Response>): boolean {
1107
+ /**
1108
+ * Mount an internal GET (spec, docs, manifest). Reports who owns the path when it cannot:
1109
+ * a user route (`"user"`) or an earlier internal mount (`"internal"`). Only an exact leaf
1110
+ * counts: a `/:slug` or `/*rest` route does not block a static internal path, which wins
1111
+ * over it on precedence, with backtracking, in both the runtime and a loaded tree.
1112
+ */
1113
+ private _mountInternalGet(path: string, fn: (ctx: { res: HoneyRes }) => Response | Promise<Response>): MountResult {
1029
1114
  const fullPath = mergePath(this._basePath, path)
1030
- const existing = this._lookupGet(fullPath)
1031
- if (existing) return existing._skip === true
1115
+ const segments = parsePattern(fullPath)
1116
+ const id = routeId("GET", fullPath)
1117
+ const g = this._graph
1118
+ const existing = g.records.get(id)
1119
+ if (existing) return existing._skip === true ? "internal" : "user"
1120
+ const leaf = findLeaf(g.root, segments, "GET")
1121
+ if (leaf !== undefined && leaf !== id) return "user"
1122
+ if (findLeaf(g.root, segments, "ALL") !== undefined) return "user"
1123
+ /* a leaf the loaded tree holds for a user route the app has yet to register */
1124
+ if (leaf === id && g.loaded?.has(id)) return "user"
1032
1125
  const routeHandler: RouteHandler = {
1033
1126
  _skip: true,
1034
- bek: this._defaultBoundaryKey,
1035
- ef: null,
1036
- ek: this._defaultErrorKeys,
1127
+ bek: null,
1128
+ cm: [...this._chain],
1129
+ cv: this._contextValues,
1130
+ dk: new Set(),
1131
+ ek: new Set(),
1037
1132
  fn: (ctx) => fn(ctx as { res: HoneyRes }),
1038
1133
  iv: null,
1039
1134
  mt: null,
1040
- mw: [...this._chainMiddlewares],
1135
+ mw: [],
1041
1136
  os: null,
1042
1137
  ov: null,
1043
- rp: fullPath,
1138
+ own: g.settings,
1139
+ rb: null,
1140
+ rm: [],
1141
+ xm: null,
1044
1142
  }
1045
- insertRoute(this._root, "GET", fullPath, routeHandler)
1046
- if (!fullPath.includes(":") && !fullPath.includes("*")) {
1047
- this._registerStatic(`GET ${fullPath}`, routeHandler)
1143
+ this._markUsed()
1144
+ this._addRoute("GET", segments, routeHandler)
1145
+ return "mounted"
1146
+ }
1147
+
1148
+ private _bumpEpoch(): void {
1149
+ this._graph.epoch++
1150
+ }
1151
+
1152
+ /** Place `id` at every leaf of `segments`, copying a shared (loaded) root first. */
1153
+ private _placeLeaves(segments: readonly Segment[], method: string, id: RouteId): void {
1154
+ const g = this._graph
1155
+ const variants = leafVariants(segments)
1156
+ if (variants.every((v) => findLeaf(g.root, v, method) === id)) return
1157
+ if (g.rootShared) {
1158
+ g.root = cloneTree(g.root)
1159
+ g.rootShared = false
1048
1160
  }
1049
- return true
1161
+ for (const v of variants) insertLeaf(g.root, v, method, id)
1162
+ }
1163
+
1164
+ /**
1165
+ * @internal — register one route record. Over a loaded tree the record binds to the leaf
1166
+ * the tree already holds for its id; a root wildcard the tree lacks becomes the gateway
1167
+ * catch-all; any other unknown route marks the tree as stale (reported at finalize).
1168
+ */
1169
+ _addRoute(method: string, segments: readonly Segment[], record: RouteHandler): void {
1170
+ const pattern = canonical(segments)
1171
+ const id = routeId(method, pattern)
1172
+ const g = this._graph
1173
+ if (g.records.has(id) || (isRootWildcard(segments) && g.catchAll.has(method))) {
1174
+ throw new Error(`Duplicate route: ${id}`)
1175
+ }
1176
+ record.id = id
1177
+ record.rp = pattern
1178
+ if (g.loaded !== null && record._skip !== true) {
1179
+ const known = leafVariants(segments).every((v) => findLeaf(g.root, v, method) === id)
1180
+ if (!known) {
1181
+ if (isRootWildcard(segments)) {
1182
+ record.ca = true
1183
+ g.catchAll.set(method, record)
1184
+ this._bumpEpoch()
1185
+ return
1186
+ }
1187
+ g.unexpected.add(id)
1188
+ }
1189
+ }
1190
+ this._placeLeaves(segments, method, id)
1191
+ g.records.set(id, record)
1192
+ this._bumpEpoch()
1193
+ }
1194
+
1195
+ /** @internal — register one websocket (or realtime) route record. */
1196
+ _addWsRoute(segments: readonly Segment[], record: WSRouteHandler): void {
1197
+ const pattern = canonical(segments)
1198
+ const id = routeId("WS", pattern)
1199
+ const g = this._graph
1200
+ if (g.wsRecords.has(id)) throw new Error(`Duplicate WebSocket route: ${pattern}`)
1201
+ record.id = id
1202
+ record.rp = pattern
1203
+ if (g.loaded !== null && leafVariants(segments).some((v) => findLeaf(g.root, v, "WS") !== id)) {
1204
+ g.unexpected.add(id)
1205
+ }
1206
+ this._placeLeaves(segments, "WS", id)
1207
+ g.wsRecords.set(id, record)
1208
+ g.hasWs = true
1209
+ this._bumpEpoch()
1210
+ }
1211
+
1212
+ /**
1213
+ * Resolve what this graph serves: registered records, live records carried by a loaded
1214
+ * snapshot, and delegated leaves the gateway catch-all serves. Then resolve every record —
1215
+ * its full chain (`chain → scoped → route → input validation`), error keys, meta and error
1216
+ * factory — and compile it once. Re-runs whenever the graph's epoch moved. Throws, naming
1217
+ * every route, when a loaded tree and the registered routes disagree (the generated file is
1218
+ * stale), and when a `use(mw)` handle or a route builder was left unused (the middleware or
1219
+ * route would silently be missing).
1220
+ */
1221
+ _finalize(): FinalTable {
1222
+ const g = this._graph
1223
+ if (g.final !== null && g.final.epoch === g.epoch) return g.final
1224
+ this._assertNothingDiscarded()
1225
+ const byId = new Map<RouteId, RouteHandler>()
1226
+ const wsById = new Map<RouteId, WSRouteHandler>()
1227
+ for (const [id, r] of g.records) byId.set(id, r)
1228
+ for (const [id, r] of g.wsRecords) wsById.set(id, r)
1229
+ const problems: string[] = []
1230
+ if (g.loaded !== null) {
1231
+ for (const id of g.unexpected) problems.push(`${id} is registered but missing from the loaded route tree`)
1232
+ let delegated = 0
1233
+ for (const [id, entry] of g.loaded) {
1234
+ const { method, pattern } = splitRouteId(id)
1235
+ if (method === "WS") {
1236
+ if (wsById.has(id)) continue
1237
+ if (entry.h !== undefined) {
1238
+ const live = copyRecord(entry.h as WSRouteHandler)
1239
+ live.id = id
1240
+ live.rp = pattern
1241
+ wsById.set(id, live)
1242
+ }
1243
+ /* a websocket leaf nothing serves is not upgraded: the request falls through to HTTP */
1244
+ continue
1245
+ }
1246
+ if (byId.has(id)) continue
1247
+ if (entry.h !== undefined) {
1248
+ const live = copyRecord(entry.h as RouteHandler)
1249
+ live.id = id
1250
+ live.rp = pattern
1251
+ byId.set(id, live)
1252
+ continue
1253
+ }
1254
+ const ca = g.catchAll.get(method) ?? g.catchAll.get("ALL")
1255
+ if (ca !== undefined) {
1256
+ const dl = copyRecord(ca)
1257
+ dl.dl = true
1258
+ dl.id = id
1259
+ dl.rp = pattern
1260
+ dl.xm = entry.mt ? { ...entry.mt } : null
1261
+ dl.iv = entry.iv ?? null
1262
+ dl.os = entry.os ?? null
1263
+ dl.dk = new Set([...(ca.dk ?? []), ...(entry.ek ?? [])])
1264
+ if (entry.bek !== undefined && entry.bek !== null) dl.rb = entry.bek
1265
+ byId.set(id, dl)
1266
+ delegated++
1267
+ continue
1268
+ }
1269
+ /* no handler anywhere: documented (served specs list it) but answered with 404 */
1270
+ byId.set(id, {
1271
+ bek: null,
1272
+ cm: [],
1273
+ dk: new Set(entry.ek ?? []),
1274
+ dl: true,
1275
+ ek: new Set(),
1276
+ fn: NOT_SERVED,
1277
+ id,
1278
+ iv: entry.iv ?? null,
1279
+ mt: null,
1280
+ mw: [],
1281
+ os: entry.os ?? null,
1282
+ rb: entry.bek ?? null,
1283
+ rm: [],
1284
+ rp: pattern,
1285
+ xm: entry.mt ? { ...entry.mt } : null,
1286
+ })
1287
+ }
1288
+ if (g.catchAll.size > 0 && delegated === 0) {
1289
+ for (const id of g.catchAll.keys()) {
1290
+ problems.push(`ALL root wildcard (${g.catchAll.get(id)?.id}) is missing from the loaded route tree`)
1291
+ }
1292
+ }
1293
+ }
1294
+ if (problems.length > 0) {
1295
+ throw new Error(`Route tree out of date — regenerate it (\`honey generate\`):\n ${problems.join("\n ")}`)
1296
+ }
1297
+
1298
+ const convert: ChainErrorConverter = (thrown, ctx) => this._convertError(thrown, ctx as HoneyContext<TEnv>)
1299
+ const plans = new Map<RouteId, Plan>()
1300
+ const wsPlans = new Map<RouteId, Plan>()
1301
+ const statics = Object.create(null) as Record<string, Plan>
1302
+ const userChains: RuntimeMiddleware[][] = []
1303
+ for (const [id, r] of byId) {
1304
+ const plan = this._planRoute(r, convert)
1305
+ plans.set(id, plan)
1306
+ if (r._skip !== true && r.dl !== true) userChains.push(r.cm ?? [])
1307
+ const { method, segments } = patternOf(id)
1308
+ for (const v of leafVariants(segments)) {
1309
+ if (isStaticPattern(v)) statics[`${method} ${canonical(v)}`] = plan
1310
+ }
1311
+ }
1312
+ for (const [id, r] of wsById) {
1313
+ wsPlans.set(id, this._planWs(r, convert))
1314
+ userChains.push(r.cm ?? [])
1315
+ }
1316
+ /* an app with no routes (static files only) has nothing but the handles that serve it */
1317
+ if (userChains.length === 0) for (const chain of g.served) userChains.push(chain)
1318
+ const miss = this._planMiss(commonPrefix(userChains), convert)
1319
+ g.final = { byId, convert, epoch: g.epoch, miss, plans, statics, wsById, wsPlans }
1320
+ return g.final
1321
+ }
1322
+
1323
+ /** `use(mw)` handles that never registered, mounted or served, and builders without `.handler()`. */
1324
+ private _assertNothingDiscarded(): void {
1325
+ const g = this._graph
1326
+ const problems: string[] = []
1327
+ for (const node of g.chains) {
1328
+ if (node.used) continue
1329
+ problems.push(
1330
+ `app.use(${describeMw(node.mw)}) returned a handle that never registers a route, so the middleware runs nowhere. ` +
1331
+ "use() does not change the handle it is called on: keep the returned value and register routes on it " +
1332
+ "(`const authed = app.use(auth); authed.get(...)`), or chain it (`honey().use(mw).get(...)`).",
1333
+ )
1334
+ }
1335
+ for (const p of g.pending) {
1336
+ problems.push(`${p.id} was declared but never got a .handler() (or .proxy()), so the route is not registered.`)
1337
+ }
1338
+ if (problems.length > 0) throw new Error(`honey: ${problems.join("\n ")}`)
1339
+ }
1340
+
1341
+ /**
1342
+ * Resolve a record against this graph: chain = captured chain, the scopes that cover its
1343
+ * pattern (behind a request-path check where only some of its paths are inside), then its
1344
+ * route middleware. Error keys and contributed meta come from that chain, in run order.
1345
+ * The error factory, default errors and boundary come from the app the route was
1346
+ * registered on when that app has a factory, else from this one.
1347
+ */
1348
+ private _resolve(
1349
+ r: RouteHandler | WSRouteHandler,
1350
+ everyScope = false,
1351
+ ): { enf: boolean; own: AppSettings<unknown>; taps: Map<string, TapFn<unknown>> | null } {
1352
+ const s = this._graph.settings
1353
+ const own = (r.own as AppSettings<unknown> | undefined) ?? s
1354
+ const src = own.errorFactory !== null ? own : s
1355
+ const segments = parsePattern(r.rp ?? "/")
1356
+ const scoped: RuntimeMiddleware[] = []
1357
+ for (const entry of this._graph.scoped) {
1358
+ if (everyScope) {
1359
+ scoped.push(entry.guard)
1360
+ continue
1361
+ }
1362
+ const cov = scopeCoverage(segments, entry.segs)
1363
+ if (cov === "all") scoped.push(entry.mw)
1364
+ else if (cov === "some") scoped.push(entry.guard)
1365
+ }
1366
+ const mw = [...this._graph.global.values(), ...(r.cm ?? []), ...scoped, ...(r.rm ?? [])]
1367
+ /* app defaults, then what the middleware declares in run order, then the route's own */
1368
+ const ek = new Set<string>(src.defaultErrorKeys)
1369
+ for (const m of mw) {
1370
+ const errs = errorsOf(m)
1371
+ if (errs) for (const k of errs) ek.add(k)
1372
+ }
1373
+ if (r.dk !== undefined) for (const k of r.dk) ek.add(k)
1374
+ const bek = r.rb ?? src.defaultBoundaryKey
1375
+ if (bek !== null) ek.add(bek)
1376
+ const enf = ek.size > 0
1377
+ const fac = src.errorFactory
1378
+ let ef: ErrorFactoryRecord | null = fac
1379
+ if (fac !== null && enf) {
1380
+ const subset = Object.create(null) as ErrorFactoryRecord
1381
+ for (const key of ek) {
1382
+ if (key in fac) subset[key] = fac[key] as ErrorFactoryRecord[string]
1383
+ }
1384
+ ef = Object.freeze(subset)
1385
+ }
1386
+ r.mw = mw
1387
+ r.ek = ek
1388
+ r.bek = bek
1389
+ r.fac = fac
1390
+ r.ef = ef
1391
+ r.mt = mergeContributedMeta(collectMiddlewareMeta([mw]), r.xm ? { ...r.xm } : null)
1392
+ /* the serving app's taps win; a route from another app keeps that app's for the rest */
1393
+ const taps =
1394
+ own === s || own.taps === null ? s.taps : s.taps === null ? own.taps : new Map([...own.taps, ...s.taps])
1395
+ return { enf, own, taps }
1396
+ }
1397
+
1398
+ /** Wrap each middleware for `telemetry.onMiddleware`, once per finalize. */
1399
+ private _timed(chain: RuntimeMiddleware[]): RuntimeMiddleware[] {
1400
+ const onMw = this._graph.settings.telemetry?.onMiddleware
1401
+ if (onMw === undefined) return chain
1402
+ const log = this._graph.settings.logger ?? undefined
1403
+ return chain.map((m) => timedMiddleware(m, onMw, log))
1404
+ }
1405
+
1406
+ private _planRoute(r: RouteHandler, convert: ChainErrorConverter): Plan {
1407
+ const { enf, own, taps } = this._resolve(r)
1408
+ const s = this._graph.settings
1409
+ const ovm = own.outputValidation ?? s.outputValidation ?? "off"
1410
+ const base = this._timed(r.mw)
1411
+ const chain = [...base]
1412
+ /* a delegated route's schemas document it; the body is forwarded, never validated here */
1413
+ const iv = r.iv
1414
+ if (iv && r.dl !== true) {
1415
+ chain.push((ctx, next) =>
1416
+ validateInput(
1417
+ iv,
1418
+ ctx["req"] as Request,
1419
+ ctx["params"] as Record<string, string>,
1420
+ ctx as { searchAll?: Record<string, string[]> },
1421
+ ).then((validated) => {
1422
+ ctx["input"] = validated
1423
+ return next()
1424
+ }),
1425
+ )
1426
+ }
1427
+ const fn = r.fn
1428
+ const handler: CompiledChain =
1429
+ r.os && ovm !== "off"
1430
+ ? (ctx) => {
1431
+ const res = fn(ctx)
1432
+ if (!shouldValidateOutput(ovm)) return res
1433
+ return res instanceof Promise
1434
+ ? res.then((v) => this._validateOutput(r, ctx as HoneyContext<TEnv>, v))
1435
+ : this._validateOutput(r, ctx as HoneyContext<TEnv>, res)
1436
+ }
1437
+ : (fn as CompiledChain)
1438
+ return {
1439
+ cv: r.cv ?? null,
1440
+ enf,
1441
+ pf: null,
1442
+ pfChain: base,
1443
+ r,
1444
+ rt: null,
1445
+ run: compileChain(chain, handler, convert),
1446
+ taps,
1447
+ }
1448
+ }
1449
+
1450
+ private _planWs(r: WSRouteHandler, convert: ChainErrorConverter): Plan {
1451
+ const { enf } = this._resolve(r)
1452
+ const rt = this._graph.realtimeRoutes.get(r.rp) ?? null
1453
+ const chain = this._timed(r.mw)
1454
+ const iv = r.iv
1455
+ if (iv) {
1456
+ chain.push((ctx, next) =>
1457
+ validateInput(
1458
+ iv,
1459
+ ctx["req"] as Request,
1460
+ ctx["params"] as Record<string, string>,
1461
+ ctx as { searchAll?: Record<string, string[]> },
1462
+ ).then((validated) => {
1463
+ ctx["input"] = validated
1464
+ return next()
1465
+ }),
1466
+ )
1467
+ }
1468
+ const terminal: CompiledChain =
1469
+ rt !== null
1470
+ ? (ctx) => this._realtimeUpgrade(ctx as HoneyContext<TEnv>, rt)
1471
+ : (ctx) => this._wsUpgrade(ctx as HoneyContext<TEnv>)
1472
+ return {
1473
+ cv: r.cv ?? null,
1474
+ enf,
1475
+ pf: null,
1476
+ pfChain: [],
1477
+ r,
1478
+ rt,
1479
+ run: compileChain(chain, terminal, convert),
1480
+ taps: null,
1481
+ }
1482
+ }
1483
+
1484
+ /**
1485
+ * 404 and 405 run through the same pipeline as a route: the middleware every route of this
1486
+ * graph starts with (with no routes: every handle that served a request), then every scope
1487
+ * (behind its request-path check), then the 404/405 answer. So a logger or CORS policy on the whole app also covers unknown paths, and a
1488
+ * scope's guard answers before "not found" leaks what exists under it.
1489
+ */
1490
+ private _planMiss(prefix: RuntimeMiddleware[], convert: ChainErrorConverter): Plan {
1491
+ const r: RouteHandler = {
1492
+ bek: null,
1493
+ cm: prefix,
1494
+ dk: new Set(),
1495
+ ek: new Set(),
1496
+ fn: (ctx) => {
1497
+ const fc = (ctx as HoneyContext<TEnv>)._rq as FetchCtx<TEnv>
1498
+ return fc.allowed !== null ? this._make405(fc, fc.allowed) : this._make404(fc)
1499
+ },
1500
+ mt: null,
1501
+ mw: [],
1502
+ rm: [],
1503
+ rp: "",
1504
+ xm: null,
1505
+ }
1506
+ /* every scope, each behind its path check — the request path decides */
1507
+ const { enf } = this._resolve(r, true)
1508
+ return {
1509
+ cv: null,
1510
+ enf,
1511
+ pf: null,
1512
+ pfChain: [],
1513
+ r,
1514
+ rt: null,
1515
+ run: compileChain(this._timed(r.mw), r.fn as CompiledChain, convert),
1516
+ taps: null,
1517
+ }
1518
+ }
1519
+
1520
+ /**
1521
+ * @internal — every served route as one entry per leaf (an optional param yields two
1522
+ * paths), in tree order. Codegen and OpenAPI read routes through this.
1523
+ */
1524
+ _collectRoutes(includeSkipped = false): Array<{ handler: RouteHandler; method: string; path: string }> {
1525
+ const final = this._finalize()
1526
+ const out: Array<{ handler: RouteHandler; method: string; path: string }> = []
1527
+ forEachLeaf(this._root, (method, path, id) => {
1528
+ if (method === "WS") return
1529
+ const handler = final.byId.get(id)
1530
+ if (handler === undefined) return
1531
+ if (handler._skip && !includeSkipped) return
1532
+ out.push({ handler, method, path })
1533
+ })
1534
+ return out
1535
+ }
1536
+
1537
+ /** @internal — websocket routes, one entry per leaf. */
1538
+ _collectWsRoutes(): Array<{ handler: WSRouteHandler; path: string }> {
1539
+ const final = this._finalize()
1540
+ const out: Array<{ handler: WSRouteHandler; path: string }> = []
1541
+ forEachLeaf(this._root, (method, path, id) => {
1542
+ if (method !== "WS") return
1543
+ const handler = final.wsById.get(id)
1544
+ if (handler !== undefined) out.push({ handler, path })
1545
+ })
1546
+ return out
1547
+ }
1548
+
1549
+ /** @internal — the route-graph epoch; served documents cache against it. */
1550
+ get _epoch(): number {
1551
+ return this._graph.epoch
1050
1552
  }
1051
1553
 
1052
1554
  defaultErrorFormatter<TSchema extends StandardSchemaLike>(
@@ -1058,13 +1560,14 @@ export class Honey<
1058
1560
  schemaOrFn: ErrorFormatterFn | StandardSchemaLike,
1059
1561
  maybeFn?: (error: HoneyError) => unknown,
1060
1562
  ): this {
1563
+ const s = this._s
1061
1564
  if (typeof schemaOrFn === "function") {
1062
- this._errorSchema = null
1063
- this._errorFormatter = schemaOrFn
1565
+ s.errorSchema = null
1566
+ s.errorFormatter = schemaOrFn
1064
1567
  } else {
1065
- this._errorSchema = schemaOrFn
1568
+ s.errorSchema = schemaOrFn
1066
1569
  const mapper = maybeFn as (error: HoneyError) => Record<string, unknown>
1067
- this._errorFormatter = (error) => mapper(error)
1570
+ s.errorFormatter = (error) => mapper(error)
1068
1571
  }
1069
1572
  return this
1070
1573
  }
@@ -1078,56 +1581,35 @@ export class Honey<
1078
1581
  schemaOrFn: CustomErrorFormatter | StandardSchemaLike,
1079
1582
  maybeFn?: (error: HoneyError, data: Record<string, unknown>) => unknown,
1080
1583
  ): this {
1584
+ const s = this._s
1081
1585
  if (typeof schemaOrFn === "function") {
1082
- this._customErrorSchema = null
1083
- this._customErrorFormatter = schemaOrFn
1586
+ s.customErrorSchema = null
1587
+ s.customErrorFormatter = schemaOrFn
1084
1588
  } else {
1085
- this._customErrorSchema = schemaOrFn
1589
+ s.customErrorSchema = schemaOrFn
1086
1590
  const mapper = maybeFn as (error: HoneyError, data: Record<string, unknown>) => Record<string, unknown>
1087
- this._customErrorFormatter = (error, data) => mapper(error, data)
1591
+ s.customErrorFormatter = (error, data) => mapper(error, data)
1088
1592
  }
1089
1593
  return this
1090
1594
  }
1091
1595
 
1092
1596
  errorI18n(config: ErrorI18nConfig<TEnv>): this {
1093
- this._errorI18n = config
1597
+ this._s.errorI18n = config
1094
1598
  return this
1095
1599
  }
1096
1600
 
1097
- onError(
1098
- handler: (
1099
- error: unknown,
1100
- ctx: {
1101
- env: TEnv
1102
- jsonFromError: (err: HoneyError) => Response
1103
- req: Request
1104
- },
1105
- ) => HoneyError | Response | Promise<HoneyError | Response | undefined | void> | undefined | void,
1106
- ): this {
1107
- this._onError = handler
1601
+ onError(handler: OnErrorFn<TEnv>): this {
1602
+ this._s.onError = handler
1108
1603
  return this
1109
1604
  }
1110
1605
 
1111
- onMethodNotAllowed(
1112
- handler: (ctx: {
1113
- allowed: string[]
1114
- env: TEnv
1115
- jsonFromError: (err: HoneyError) => Response
1116
- req: Request
1117
- }) => Response | Promise<Response>,
1118
- ): this {
1119
- this._onMethodNotAllowed = handler
1606
+ onMethodNotAllowed(handler: OnMethodNotAllowedFn<TEnv>): this {
1607
+ this._s.onMethodNotAllowed = handler
1120
1608
  return this
1121
1609
  }
1122
1610
 
1123
- onNotFound(
1124
- handler: (ctx: {
1125
- env: TEnv
1126
- jsonFromError: (err: HoneyError) => Response
1127
- req: Request
1128
- }) => Response | Promise<Response>,
1129
- ): this {
1130
- this._onNotFound = handler
1611
+ onNotFound(handler: OnNotFoundFn<TEnv>): this {
1612
+ this._s.onNotFound = handler
1131
1613
  return this
1132
1614
  }
1133
1615
 
@@ -1136,51 +1618,227 @@ export class Honey<
1136
1618
  key: K,
1137
1619
  handler: (ctx: TapContext<TEnv>, payload: K extends keyof TTaps ? TTaps[K] : unknown) => void | Promise<void>,
1138
1620
  ): this {
1139
- if (this._taps === null) {
1140
- this._taps = new Map()
1141
- }
1142
- this._taps.set(key, handler as (ctx: TapContext<TEnv>, payload: unknown) => void | Promise<void>)
1621
+ const s = this._s
1622
+ if (s.taps === null) s.taps = new Map()
1623
+ s.taps.set(key, handler as TapFn<TEnv>)
1624
+ this._bumpEpoch()
1143
1625
  return this
1144
1626
  }
1145
1627
 
1146
1628
  telemetry(adapter: TelemetryAdapter): this {
1147
- this._telemetry = adapter
1629
+ this._s.telemetry = adapter
1630
+ /* onMiddleware wraps the compiled chains */
1631
+ this._bumpEpoch()
1148
1632
  return this
1149
1633
  }
1150
1634
 
1635
+ /**
1636
+ * Load a route tree — a generated `routes.gen.ts`, an `app.toRouteTree()` snapshot, or a
1637
+ * `mergeTree()` of either. The tree supplies the router topology; what each route runs
1638
+ * comes from the routes this app registers afterwards (bound by `RouteId`), from live
1639
+ * records in a snapshot, or — for leaves with neither — from a root wildcard the app
1640
+ * registers as its gateway catch-all. Must be called before any route is registered.
1641
+ */
1151
1642
  routeTree(tree: RouteTree): this {
1152
- this._root = tree.root
1153
- this._handlerMap = tree.handlers ?? null
1154
- this._hasRouteTree = true
1643
+ assertTreeFormat(tree)
1644
+ const g = this._graph
1645
+ if (g.loaded !== null) throw new Error("routeTree() was already called on this app")
1646
+ for (const r of g.records.values()) {
1647
+ if (r._skip !== true) throw new Error(`routeTree() must be called before routes are registered (${r.id} is)`)
1648
+ }
1649
+ if (g.wsRecords.size > 0 || g.catchAll.size > 0) {
1650
+ throw new Error("routeTree() must be called before routes are registered")
1651
+ }
1652
+ forEachLeaf(tree.root, (method, path, id) => {
1653
+ if (!(id in tree.routes)) throw new Error(`routeTree(): leaf ${method} ${path} has no route entry (${id})`)
1654
+ })
1655
+ const internals = [...g.records.values()]
1656
+ g.records.clear()
1657
+ g.root = freezeTree(tree.root)
1658
+ g.rootShared = true
1659
+ g.loaded = new Map()
1660
+ for (const id of Object.keys(tree.routes)) g.loaded.set(id, { ...tree.routes[id] })
1661
+ if (hasWsLeaf(g.root)) g.hasWs = true
1662
+ /* internal routes mounted before the tree was loaded move onto the loaded topology */
1663
+ for (const r of internals) {
1664
+ const { method, segments } = patternOf(r.id as RouteId)
1665
+ this._addRoute(method, segments, r)
1666
+ }
1667
+ this._bumpEpoch()
1155
1668
  return this
1156
1669
  }
1157
1670
 
1671
+ /**
1672
+ * Snapshot this app's routes as a tree: a copy of the topology plus, per route, its data
1673
+ * and its live record. Internal routes (spec, docs, manifest) are left out. Loading the
1674
+ * snapshot into another app serves these records; nothing in it is shared with this app.
1675
+ * Each record keeps this app's settings (error factory, defaults, boundary) and context
1676
+ * values, and carries the scoped middleware that covers it here as part of its chain —
1677
+ * scopes themselves do not travel in a tree.
1678
+ */
1158
1679
  toRouteTree(): RouteTree {
1159
- return { meta: {}, root: this._root }
1680
+ this._markUsed()
1681
+ const final = this._finalize()
1682
+ const routes = Object.create(null) as Record<RouteId, RouteEntry>
1683
+ const keep = new Set<RouteId>()
1684
+ for (const [id, r] of final.byId) {
1685
+ if (r._skip) continue
1686
+ keep.add(id)
1687
+ routes[id] = {
1688
+ bek: r.bek,
1689
+ ek: [...r.ek],
1690
+ h: this._bake(r),
1691
+ iv: r.iv ?? null,
1692
+ mt: r.mt,
1693
+ os: r.os ?? null,
1694
+ }
1695
+ }
1696
+ for (const [id, r] of final.wsById) {
1697
+ keep.add(id)
1698
+ routes[id] = { bek: r.bek, ek: [...r.ek], h: this._bake(r), iv: r.iv, mt: r.mt }
1699
+ }
1700
+ const root = createNode()
1701
+ forEachLeaf(this._root, (method, path, id) => {
1702
+ if (keep.has(id)) insertLeaf(root, parsePattern(path), method, id)
1703
+ })
1704
+ return { meta: {}, root, routes, v: ROUTE_TREE_VERSION }
1160
1705
  }
1161
1706
 
1707
+ /** A resolved record as a standalone source: its resolved chain minus route middleware becomes its chain. */
1708
+ private _bake<T extends RouteHandler | WSRouteHandler>(r: T): T {
1709
+ const h = copyRecord(r)
1710
+ const rm = r.rm ?? []
1711
+ /* app-wide middleware stays with the app; scopes travel as part of the chain */
1712
+ h.cm = r.mw.slice(this._graph.global.size, r.mw.length - rm.length)
1713
+ h.rm = [...rm]
1714
+ h.dk = new Set(r.ek)
1715
+ h.rb = r.bek
1716
+ h.xm = r.mt ? { ...r.mt } : null
1717
+ h.own = r.own ?? this._graph.settings
1718
+ return h
1719
+ }
1720
+
1721
+ /**
1722
+ * @internal — generate-time: copy input/output schemas from `source` onto this app's
1723
+ * routes that lack them (a gateway serves its generated tree, which carries none).
1724
+ */
1725
+ _overlaySchemas(source: RouteTree): void {
1726
+ const g = this._graph
1727
+ for (const [id, entry] of Object.entries(source.routes)) {
1728
+ const iv = entry.iv ?? (entry.h as RouteHandler | undefined)?.iv ?? null
1729
+ const os = entry.os ?? (entry.h as RouteHandler | undefined)?.os ?? null
1730
+ if (iv === null && os === null) continue
1731
+ const record = g.records.get(id)
1732
+ if (record !== undefined) {
1733
+ record.iv ??= iv
1734
+ record.os ??= os
1735
+ continue
1736
+ }
1737
+ const loaded = g.loaded?.get(id)
1738
+ if (loaded !== undefined) {
1739
+ loaded.iv ??= iv
1740
+ loaded.os ??= os
1741
+ }
1742
+ }
1743
+ this._bumpEpoch()
1744
+ }
1745
+
1746
+ /**
1747
+ * @internal — generate-time: the route tree a gateway serves. `source` is the merged
1748
+ * downstream tree (`codegen.mergeTree`); the gateway's own registered routes join it as
1749
+ * ordinary leaves, while its root wildcards stay out of the tree — they are the catch-all
1750
+ * the downstream leaves delegate to. An own route that is also a downstream route, or
1751
+ * whose topology conflicts with one, throws here instead of at request time. The app is
1752
+ * rebound to the result, so codegen that runs next (OpenAPI, manifest) sees what it serves.
1753
+ */
1754
+ _gatewayTree(source: RouteTree): RouteTree {
1755
+ assertTreeFormat(source)
1756
+ const g = this._graph
1757
+ const root = cloneTree(source.root)
1758
+ const routes = dict<RouteEntry>()
1759
+ for (const id of Object.keys(source.routes)) {
1760
+ const { bek, ek, i, mt } = source.routes[id]
1761
+ routes[id] = { bek, ek, i, mt }
1762
+ }
1763
+ const own: { method: string; segments: Segment[]; id: RouteId }[] = []
1764
+ /* every route id this app serves, root wildcards included */
1765
+ const served = new Set<string>()
1766
+ for (const r of [...g.records.values(), ...g.catchAll.values(), ...g.wsRecords.values()]) {
1767
+ if ((r as RouteHandler)._skip === true) continue
1768
+ const id = r.id as RouteId
1769
+ served.add(id)
1770
+ const { method, segments } = patternOf(id)
1771
+ if (!isRootWildcard(segments)) own.push({ id, method, segments })
1772
+ }
1773
+ const overlap = own.filter((o) => o.id in routes)
1774
+ /* a merge source built from this app itself (`app.toRouteTree()`) holds exactly the routes
1775
+ this app serves; anything else that overlaps is a gateway route shadowing a downstream
1776
+ one — judged by the route sets, never by how many routes overlap */
1777
+ const sourceIds = Object.keys(routes)
1778
+ const isSelf = own.length > 0 && overlap.length === own.length && sourceIds.every((id) => served.has(id))
1779
+ if (overlap.length > 0 && !isSelf) {
1780
+ throw new Error(
1781
+ `Gateway routes are also downstream routes in the merged tree: ${overlap.map((o) => o.id).join(", ")}`,
1782
+ )
1783
+ }
1784
+ if (isSelf) own.length = 0
1785
+ for (const { id, method, segments } of own) {
1786
+ for (const v of leafVariants(segments)) insertLeaf(root, v, method, id)
1787
+ routes[id] = {}
1788
+ }
1789
+ this._replaceLoadedTree({ meta: { ...source.meta }, root, routes, v: ROUTE_TREE_VERSION })
1790
+ /* own routes resolved over the rebound tree carry their final error keys and meta */
1791
+ const snapshot = this.toRouteTree()
1792
+ for (const { id } of own) {
1793
+ const { bek, ek, mt } = snapshot.routes[id]
1794
+ routes[id] = { bek, ek, mt }
1795
+ }
1796
+ const tree: RouteTree = { meta: { ...source.meta }, root, routes, v: ROUTE_TREE_VERSION }
1797
+ this._replaceLoadedTree(tree)
1798
+ return tree
1799
+ }
1800
+
1801
+ /** @internal — swap the loaded tree, re-binding every registered route to it. */
1802
+ _replaceLoadedTree(tree: RouteTree): void {
1803
+ const g = this._graph
1804
+ const http = [...g.records.values(), ...g.catchAll.values()]
1805
+ const ws = [...g.wsRecords.values()]
1806
+ g.records.clear()
1807
+ g.catchAll.clear()
1808
+ g.wsRecords.clear()
1809
+ g.unexpected.clear()
1810
+ g.loaded = null
1811
+ g.root = createNode()
1812
+ g.rootShared = false
1813
+ g.hasWs = false
1814
+ this.routeTree(tree)
1815
+ for (const r of http) {
1816
+ delete r.ca
1817
+ const { method, segments } = patternOf(r.id as RouteId)
1818
+ this._addRoute(method, segments, r)
1819
+ }
1820
+ for (const r of ws) this._addWsRoute(patternOf(r.id as RouteId).segments, r)
1821
+ }
1822
+
1823
+ /**
1824
+ * Set the app's error factory. Like every app setting it applies to every handle of this
1825
+ * app, whenever it is called; routes mounted from another app keep that app's factory.
1826
+ */
1162
1827
  errorFactory<TFactory extends Record<string, (...args: never[]) => unknown>>(
1163
1828
  factory: TFactory,
1164
1829
  ): Honey<TEnv, TCtx, TRoutes, TMeta, TFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw> {
1165
- const next = this as unknown as Honey<
1166
- TEnv,
1167
- TCtx,
1168
- TRoutes,
1169
- TMeta,
1170
- TFactory,
1171
- TDefaultErrors,
1172
- TBasePath,
1173
- TTaps,
1174
- TScopedMw
1175
- >
1176
- next._errorFactory = factory
1177
- return next
1830
+ this._s.errorFactory = factory
1831
+ this._bumpEpoch()
1832
+ return this as unknown as Honey<TEnv, TCtx, TRoutes, TMeta, TFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw>
1178
1833
  }
1179
1834
 
1835
+ /** Error keys every route of this app declares — applies to routes registered before and after the call. */
1180
1836
  defaultErrors<TKeys extends ([TErrorFactory] extends [never] ? never : keyof TErrorFactory & string)>(
1181
1837
  ...keys: TKeys[]
1182
1838
  ): Honey<TEnv, TCtx, TRoutes, TMeta, TErrorFactory, TDefaultErrors | TKeys, TBasePath, TTaps, TScopedMw> {
1183
- const next = this as unknown as Honey<
1839
+ for (const k of keys) this._s.defaultErrorKeys.add(k)
1840
+ this._bumpEpoch()
1841
+ return this as unknown as Honey<
1184
1842
  TEnv,
1185
1843
  TCtx,
1186
1844
  TRoutes,
@@ -1191,16 +1849,15 @@ export class Honey<
1191
1849
  TTaps,
1192
1850
  TScopedMw
1193
1851
  >
1194
- for (const k of keys) {
1195
- next._defaultErrorKeys.add(k)
1196
- }
1197
- return next
1198
1852
  }
1199
1853
 
1200
1854
  defaultBoundary<TKey extends ([TErrorFactory] extends [never] ? never : keyof TErrorFactory & string)>(
1201
1855
  key: TKey,
1202
1856
  ): Honey<TEnv, TCtx, TRoutes, TMeta, TErrorFactory, TDefaultErrors | TKey, TBasePath, TTaps, TScopedMw> {
1203
- const next = this as unknown as Honey<
1857
+ this._s.defaultBoundaryKey = key
1858
+ this._s.defaultErrorKeys.add(key)
1859
+ this._bumpEpoch()
1860
+ return this as unknown as Honey<
1204
1861
  TEnv,
1205
1862
  TCtx,
1206
1863
  TRoutes,
@@ -1211,9 +1868,6 @@ export class Honey<
1211
1868
  TTaps,
1212
1869
  TScopedMw
1213
1870
  >
1214
- next._defaultBoundaryKey = key
1215
- next._defaultErrorKeys.add(key)
1216
- return next
1217
1871
  }
1218
1872
 
1219
1873
  /** Phantom overload — constrains what route-level .meta() accepts */
@@ -1244,39 +1898,19 @@ export class Honey<
1244
1898
  return this as Honey<TEnv, TCtx, TRoutes, unknown, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw>
1245
1899
  }
1246
1900
  /* chain-level meta — copy-on-write */
1247
- const next = new Honey<TEnv, TCtx, TRoutes, unknown, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw>({
1248
- chainMiddlewares: this._chainMiddlewares,
1249
- defaultErrorKeys: this._defaultErrorKeys,
1250
- globalMiddlewares: this._globalMiddlewares,
1251
- graph: this._graph,
1252
- scopedMiddlewares: this._scopedMiddlewares,
1253
- })
1254
- next._basePath = this._basePath
1901
+ const next = this._derive()
1255
1902
  next._chainMeta = this._chainMeta ? { ...this._chainMeta, ...values } : { ...values }
1256
- next._contextValues = this._contextValues
1257
- next._defaultBoundaryKey = this._defaultBoundaryKey
1258
- next._errorFactory = this._errorFactory
1259
- next._errorSchema = this._errorSchema
1260
- next._customErrorFormatter = this._customErrorFormatter
1261
- next._customErrorSchema = this._customErrorSchema
1262
- next._errorFormatter = this._errorFormatter
1263
- next._errorI18n = this._errorI18n
1264
- next._logger = this._logger
1265
- next._outputValidation = this._outputValidation
1266
- next._stripPrefix = this._stripPrefix
1267
- next._trailingSlash = this._trailingSlash
1268
- next._wsAdapter = this._wsAdapter
1269
- next._onError = this._onError
1270
- next._onNotFound = this._onNotFound
1271
- next._onMethodNotAllowed = this._onMethodNotAllowed
1272
- next._taps = this._taps
1273
- next._telemetry = this._telemetry
1274
- next._realtimeBus = this._realtimeBus
1275
- next._realtimeRoutes = this._realtimeRoutes
1276
- next._staticRoutes = this._staticRoutes
1277
- next._hasRouteTree = this._hasRouteTree
1278
- next._hasWsRoutes = this._hasWsRoutes
1279
- return next
1903
+ return next as unknown as Honey<
1904
+ TEnv,
1905
+ TCtx,
1906
+ TRoutes,
1907
+ unknown,
1908
+ TErrorFactory,
1909
+ TDefaultErrors,
1910
+ TBasePath,
1911
+ TTaps,
1912
+ TScopedMw
1913
+ >
1280
1914
  }
1281
1915
 
1282
1916
  /**
@@ -1284,68 +1918,13 @@ export class Honey<
1284
1918
  * Codegen-time only — never consulted on the request path. See docs/meta-spec.md.
1285
1919
  */
1286
1920
  metaSpec<TSpec extends HoneyMetaSpec<TMeta>>(spec: TSpec): this {
1287
- if (this._metaSpec !== null) {
1921
+ if (this._graph.metaSpec !== null) {
1288
1922
  throw new Error("metaSpec() was already declared on this app — declare the whole policy in one call")
1289
1923
  }
1290
- this._metaSpec = spec as MetaSpecConfig
1924
+ this._graph.metaSpec = spec as MetaSpecConfig
1291
1925
  return this
1292
1926
  }
1293
1927
 
1294
- /**
1295
- * Merge a mounted sub-app's policy into this one.
1296
- *
1297
- * The parent wins on conflict, so one gateway keeps one answer for its aggregate document —
1298
- * with a single exception. A sub entry of `false` wins over anything the parent says, because
1299
- * hiding is a safety claim and the strictest claim has to survive composition: a worker that
1300
- * declares a field unpublishable must not have the gateway publish it. Wrongly hidden is a
1301
- * visible gap; wrongly published is a leak.
1302
- *
1303
- * Every other disagreement is recorded and reported at codegen. Silently dropping a sub's
1304
- * entry is how someone ends up diffing two documents to find out why they differ.
1305
- */
1306
- private _absorbMetaSpec(sub: MetaSpecConfig | null): void {
1307
- if (!sub) return
1308
- const own = this._metaSpec
1309
- if (!own) {
1310
- this._metaSpec = sub
1311
- return
1312
- }
1313
- const conflicts: MetaSpecMergeConflict[] = [...(own.conflicts ?? []), ...(sub.conflicts ?? [])]
1314
-
1315
- const mergeMeta = (): Record<string, MetaSpecEntry> => {
1316
- const merged: Record<string, MetaSpecEntry> = { ...sub.meta, ...own.meta }
1317
- for (const [key, subEntry] of Object.entries(sub.meta ?? {})) {
1318
- const ownEntry = own.meta?.[key]
1319
- if (ownEntry === undefined || ownEntry === subEntry) continue
1320
- if (subEntry === false && ownEntry !== false) {
1321
- merged[key] = false
1322
- conflicts.push({ key, resolution: "hidden", section: "meta" })
1323
- continue
1324
- }
1325
- conflicts.push({ key, resolution: "parent", section: "meta" })
1326
- }
1327
- return merged
1328
- }
1329
-
1330
- const recordOverlap = (section: "profiles" | "schema", subSide: object, ownSide: object): void => {
1331
- for (const [key, subEntry] of Object.entries(subSide)) {
1332
- const ownEntry = (ownSide as Record<string, unknown>)[key]
1333
- if (ownEntry === undefined || ownEntry === subEntry) continue
1334
- conflicts.push({ key, resolution: "parent", section })
1335
- }
1336
- }
1337
- recordOverlap("schema", sub.schema ?? {}, own.schema ?? {})
1338
- recordOverlap("profiles", sub.profiles ?? {}, own.profiles ?? {})
1339
-
1340
- this._metaSpec = {
1341
- conflicts,
1342
- meta: mergeMeta(),
1343
- profiles: { ...sub.profiles, ...own.profiles },
1344
- schema: { ...sub.schema, ...own.schema },
1345
- strict: own.strict ?? sub.strict,
1346
- }
1347
- }
1348
-
1349
1928
  /** Declare tap payload types — auto-extends meta with Partial<T> for meta-driven taps */
1350
1929
  taps<TNewTaps extends Record<string, unknown>>(): Honey<
1351
1930
  TEnv,
@@ -1393,139 +1972,164 @@ export class Honey<
1393
1972
  /* oxlint-disable-next-line typescript/no-explicit-any -- overload impl requires erased types */
1394
1973
  use(pathOrMw: string | MiddlewareFn<any, any>, maybeMw?: MiddlewareFn<any, any>): any {
1395
1974
  if (typeof pathOrMw !== "string") {
1975
+ /*
1976
+ * Chain middleware: a new handle whose routes run `mw`. This handle is unchanged —
1977
+ * a handle that never registers a route is reported at finalize.
1978
+ */
1396
1979
  const mw = pathOrMw as RuntimeMiddleware
1397
- const newChain = new Honey<TEnv>({
1398
- chainMiddlewares: [...this._chainMiddlewares, mw],
1399
- defaultErrorKeys: this._defaultErrorKeys,
1400
- globalMiddlewares: this._globalMiddlewares,
1401
- graph: this._graph,
1402
- scopedMiddlewares: this._scopedMiddlewares,
1403
- })
1404
- newChain._basePath = this._basePath
1405
- newChain._chainMeta = this._chainMeta
1406
- newChain._contextValues = this._contextValues
1407
- newChain._defaultBoundaryKey = this._defaultBoundaryKey
1408
- newChain._errorFactory = this._errorFactory
1409
- newChain._errorSchema = this._errorSchema
1410
- newChain._customErrorFormatter = this._customErrorFormatter
1411
- newChain._customErrorSchema = this._customErrorSchema
1412
- newChain._errorFormatter = this._errorFormatter
1413
- newChain._errorI18n = this._errorI18n
1414
- newChain._logger = this._logger
1415
- newChain._outputValidation = this._outputValidation
1416
- newChain._stripPrefix = this._stripPrefix
1417
- newChain._trailingSlash = this._trailingSlash
1418
- newChain._wsAdapter = this._wsAdapter
1419
- newChain._onError = this._onError
1420
- newChain._onNotFound = this._onNotFound
1421
- newChain._onMethodNotAllowed = this._onMethodNotAllowed
1422
- newChain._taps = this._taps
1423
- newChain._telemetry = this._telemetry
1424
- newChain._realtimeBus = this._realtimeBus
1425
- newChain._realtimeRoutes = this._realtimeRoutes
1426
- newChain._staticRoutes = this._staticRoutes
1427
- newChain._hasRouteTree = this._hasRouteTree
1428
- newChain._hasWsRoutes = this._hasWsRoutes
1429
- return newChain
1430
- }
1431
-
1432
- /* scoped path */
1433
- const normalizedPrefix = normalizeScopePath(pathOrMw)
1434
- const fullPrefix = mergePath(this._basePath, normalizedPrefix)
1980
+ const node: ChainNode = { mw, parent: this._node, used: false }
1981
+ this._graph.chains.push(node)
1982
+ const next = this._derive([...this._chain, mw])
1983
+ next._node = node
1984
+ return next
1985
+ }
1986
+
1987
+ /* scoped: guards every request path under the prefix, whichever handle registered the route */
1988
+ const prefix = mergePath(this._basePath, normalizePattern(pathOrMw))
1435
1989
  const mw = maybeMw as RuntimeMiddleware
1436
- const mwWithErrors = maybeMw as MiddlewareFn<unknown, unknown>
1437
- const entry: ScopedEntry = {
1438
- errors: mwWithErrors.errors ? [...mwWithErrors.errors] : undefined,
1990
+ const segs = parsePattern(prefix)
1991
+ this._graph.scoped.push({
1992
+ errors: errorsOf(mw) ? [...(errorsOf(mw) as readonly string[])] : undefined,
1993
+ guard: scopeGuard(segs, mw),
1439
1994
  mw,
1440
- prefix: fullPrefix,
1441
- }
1442
- this._scopedMiddlewares.push(entry)
1443
- const newChain = new Honey<TEnv>({
1444
- chainMiddlewares: this._chainMiddlewares,
1445
- defaultErrorKeys: this._defaultErrorKeys,
1446
- globalMiddlewares: this._globalMiddlewares,
1447
- graph: this._graph,
1448
- scopedMiddlewares: this._scopedMiddlewares,
1995
+ prefix,
1996
+ segs,
1449
1997
  })
1450
- newChain._basePath = this._basePath
1451
- newChain._chainMeta = this._chainMeta
1452
- newChain._contextValues = this._contextValues
1453
- newChain._defaultBoundaryKey = this._defaultBoundaryKey
1454
- newChain._errorFactory = this._errorFactory
1455
- newChain._errorSchema = this._errorSchema
1456
- newChain._customErrorFormatter = this._customErrorFormatter
1457
- newChain._customErrorSchema = this._customErrorSchema
1458
- newChain._errorFormatter = this._errorFormatter
1459
- newChain._errorI18n = this._errorI18n
1460
- newChain._logger = this._logger
1461
- newChain._outputValidation = this._outputValidation
1462
- newChain._stripPrefix = this._stripPrefix
1463
- newChain._trailingSlash = this._trailingSlash
1464
- newChain._wsAdapter = this._wsAdapter
1465
- newChain._onError = this._onError
1466
- newChain._onNotFound = this._onNotFound
1467
- newChain._onMethodNotAllowed = this._onMethodNotAllowed
1468
- newChain._taps = this._taps
1469
- newChain._telemetry = this._telemetry
1470
- newChain._realtimeBus = this._realtimeBus
1471
- newChain._realtimeRoutes = this._realtimeRoutes
1472
- newChain._staticRoutes = this._staticRoutes
1473
- newChain._hasRouteTree = this._hasRouteTree
1474
- newChain._hasWsRoutes = this._hasWsRoutes
1475
- newChain._applyScopedEntryErrors(entry)
1476
- newChain._applyScopedEntryMeta(entry)
1477
- return newChain
1998
+ this._bumpEpoch()
1999
+ return this._derive()
1478
2000
  }
1479
2001
 
2002
+ /**
2003
+ * Mount a sub-app: its routes are served under this handle's basePath (and under `prefix`
2004
+ * when given — `app.route("/v1", sub)` is `app.basePath("/v1").route(sub)`), and this
2005
+ * handle's chain runs before theirs. The sub's own basePath stays part of each path.
2006
+ */
1480
2007
  route<TSubRoutes, TSubMeta, TSubErrorFactory, TSubDefaultErrors extends string, TSubBasePath extends string>(
1481
2008
  sub: Honey<TEnv, TCtx, TSubRoutes, TSubMeta, TSubErrorFactory, TSubDefaultErrors, TSubBasePath>,
1482
- ): Honey<TEnv, TCtx, TRoutes & TSubRoutes, TMeta, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw> {
1483
- /* skip self-merge: .handler() already inserted into shared _root */
1484
- if (sub._tree !== this._root) {
1485
- mergeInto(this._root, sub._tree)
1486
- this._absorbMetaSpec(sub._metaSpec)
1487
- if (sub._hasWsRoutes) this._hasWsRoutes = true
1488
- if (sub._hasRouteTree) this._hasRouteTree = true
1489
- /* carry sub's scoped mw entries into parent's runtime list (parent scopes run first) */
1490
- for (const entry of sub._scopedMiddlewares) {
1491
- this._scopedMiddlewares.push(entry)
2009
+ ): Honey<
2010
+ TEnv,
2011
+ TCtx,
2012
+ TRoutes & PrefixRoutes<TBasePath, TSubRoutes>,
2013
+ TMeta,
2014
+ TErrorFactory,
2015
+ TDefaultErrors,
2016
+ TBasePath,
2017
+ TTaps,
2018
+ TScopedMw
2019
+ >
2020
+ route<
2021
+ const TPrefix extends string,
2022
+ TSubRoutes,
2023
+ TSubMeta,
2024
+ TSubErrorFactory,
2025
+ TSubDefaultErrors extends string,
2026
+ TSubBasePath extends string,
2027
+ >(
2028
+ prefix: TPrefix,
2029
+ sub: Honey<TEnv, TCtx, TSubRoutes, TSubMeta, TSubErrorFactory, TSubDefaultErrors, TSubBasePath>,
2030
+ ): Honey<
2031
+ TEnv,
2032
+ TCtx,
2033
+ TRoutes & PrefixRoutes<MergePath<TBasePath, TPrefix>, TSubRoutes>,
2034
+ TMeta,
2035
+ TErrorFactory,
2036
+ TDefaultErrors,
2037
+ TBasePath,
2038
+ TTaps,
2039
+ TScopedMw
2040
+ >
2041
+ route(prefixOrSub: unknown, maybeSub?: unknown): unknown {
2042
+ type Sub = Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>
2043
+ if (typeof prefixOrSub === "string") {
2044
+ if (!(maybeSub instanceof Honey)) throw new TypeError("honey: route(prefix, sub) needs the sub-app")
2045
+ this._markUsed()
2046
+ ;(this.basePath(prefixOrSub) as unknown as Honey<TEnv, TCtx>)._mount(maybeSub as Sub)
2047
+ return this
2048
+ }
2049
+ return this._mount(prefixOrSub as Sub)
2050
+ }
2051
+
2052
+ private _mount(sub: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>): this {
2053
+ this._markUsed()
2054
+ /* skip self-merge: .handler() already registered into the shared graph */
2055
+ if (sub._graph !== this._graph) {
2056
+ /*
2057
+ * Re-register the sub's records under this handle: this handle's chain runs first,
2058
+ * its context values and chain meta apply under the sub's own. Nothing is shared by
2059
+ * reference, so routes the sub registers later stay its own, and two parents
2060
+ * mounting one sub each get their own records. Each record keeps the settings of
2061
+ * the sub (error factory, defaults, boundary, output validation). Internal routes
2062
+ * (spec, docs, manifest) never travel.
2063
+ */
2064
+ sub._markUsed()
2065
+ const subFinal = sub._finalize()
2066
+ const subSettings = sub._graph.settings
2067
+ const mount = <T extends RouteHandler | WSRouteHandler>(r: T): T => {
2068
+ const c = copyRecord(r)
2069
+ c.cm = [...this._chain, ...(r.cm ?? [])]
2070
+ if (this._contextValues) c.cv = { ...this._contextValues, ...r.cv }
2071
+ if (this._chainMeta) c.xm = { ...this._chainMeta, ...r.xm }
2072
+ c.own = r.own ?? subSettings
2073
+ return c
2074
+ }
2075
+ /* the sub's paths land under this handle's basePath, like Hono's `route("/v1", sub)` */
2076
+ const base = this._basePath
2077
+ const at = (pattern: string): string => (base === "/" ? pattern : mergePath(base, pattern))
2078
+ for (const [id, r] of subFinal.byId) {
2079
+ if (r._skip) continue
2080
+ const { method, pattern } = splitRouteId(id)
2081
+ this._addRoute(method, parsePattern(at(pattern)), mount(r))
2082
+ }
2083
+ for (const [id, r] of subFinal.wsById) {
2084
+ const { pattern } = splitRouteId(id)
2085
+ this._addWsRoute(parsePattern(at(pattern)), mount(r))
1492
2086
  }
1493
- if (sub._staticRoutes.map !== null) {
1494
- if (this._staticRoutes.map === null) {
1495
- this._staticRoutes.map = Object.create(null) as Record<string, RouteHandler>
2087
+ /* merged when codegen reads the policy (see `metaSpecOf`): the parent wins
2088
+ * conflicts and keeps its resolved strictness, a sub's `false` still hides */
2089
+ const subSource = sub._metaSpecSource
2090
+ this._graph.absorbedMetaSpecs.push({ absorbed: [...subSource.absorbed], declared: subSource.declared })
2091
+ /* the sub's scopes guard the paths it brought — appended after this app's own */
2092
+ const g = this._graph
2093
+ for (const entry of sub._graph.scoped) {
2094
+ if (base === "/") {
2095
+ if (!g.scoped.includes(entry)) g.scoped.push(entry)
2096
+ continue
1496
2097
  }
1497
- Object.assign(this._staticRoutes.map, sub._staticRoutes.map)
2098
+ const prefix = at(entry.prefix)
2099
+ if (g.scoped.some((e) => e.mw === entry.mw && e.prefix === prefix)) continue
2100
+ const segs = parsePattern(prefix)
2101
+ g.scoped.push({ errors: entry.errors, guard: scopeGuard(segs, entry.mw), mw: entry.mw, prefix, segs })
1498
2102
  }
1499
- for (const [path, cfg] of sub._realtimeRoutes) {
1500
- if (this._realtimeRoutes.has(path)) {
2103
+ for (const [subPath, cfg] of sub._graph.realtimeRoutes) {
2104
+ const path = at(subPath)
2105
+ if (g.realtimeRoutes.has(path)) {
1501
2106
  throw new Error(`Duplicate realtime route: ${path}`)
1502
2107
  }
1503
- this._realtimeRoutes.set(path, cfg)
2108
+ /* a default namespace is the route's full path, so it follows the mount; a named one is kept */
2109
+ g.realtimeRoutes.set(path, { ...cfg, namespace: cfg.namespaceExplicit ? cfg.namespace : path, path })
1504
2110
  }
1505
- if (!this._realtimeBus && sub._realtimeBus) {
1506
- this._realtimeBus = sub._realtimeBus
2111
+ if (!g.realtimeBus && sub._graph.realtimeBus) {
2112
+ this._setBus(sub._graph.realtimeBus)
1507
2113
  }
1508
- if (sub._taps !== null) {
1509
- if (this._taps === null) this._taps = new Map()
1510
- for (const [key, fn] of sub._taps) {
1511
- if (!this._taps.has(key)) this._taps.set(key, fn)
2114
+ if (subSettings.taps !== null) {
2115
+ const s = this._graph.settings
2116
+ if (s.taps === null) s.taps = new Map()
2117
+ for (const [key, fn] of subSettings.taps) {
2118
+ if (!s.taps.has(key)) s.taps.set(key, fn)
1512
2119
  }
1513
2120
  }
1514
- /* walk tree and apply all scoped error keys + contributed meta to matching handlers */
1515
- this._applyAllScopedErrors()
1516
- for (const entry of this._scopedMiddlewares) this._applyScopedEntryMeta(entry)
2121
+ this._bumpEpoch()
1517
2122
  }
1518
- return this as unknown as Honey<
1519
- TEnv,
1520
- TCtx,
1521
- TRoutes & TSubRoutes,
1522
- TMeta,
1523
- TErrorFactory,
1524
- TDefaultErrors,
1525
- TBasePath,
1526
- TTaps,
1527
- TScopedMw
1528
- >
2123
+ return this
2124
+ }
2125
+
2126
+ private _setBus(bus: RealtimeBus): RealtimeBus {
2127
+ const g = this._graph
2128
+ g.realtimeBus = bus
2129
+ g.realtimeCtx = requireFeature("realtime", "app.realtime()").createRealtimePublisher(bus, () =>
2130
+ [...g.realtimeRoutes.values()].map((cfg) => cfg.namespace),
2131
+ )
2132
+ return bus
1529
2133
  }
1530
2134
 
1531
2135
  private _registerRoute<TPath extends string, TMethod extends HttpMethod | "ALL">(
@@ -1552,12 +2156,8 @@ export class Honey<
1552
2156
  TScopedMw
1553
2157
  > {
1554
2158
  const fullPath = mergePath(this._basePath, path)
1555
- const errorKeys = new Set(this._defaultErrorKeys)
1556
- for (const entry of this._scopedMiddlewares) {
1557
- if (entry.errors && scopeMatches(entry.prefix, fullPath)) {
1558
- for (const k of entry.errors) errorKeys.add(k)
1559
- }
1560
- }
2159
+ const pending: PendingRoute = { id: `${[method, ...(extraMethods ?? [])].join(",")} ${fullPath}` }
2160
+ this._graph.pending.add(pending)
1561
2161
  return new RouteBuilder<
1562
2162
  TEnv,
1563
2163
  TCtx & ApplyScoped<TScopedMw, MergePath<TBasePath, TPath>>,
@@ -1577,20 +2177,17 @@ export class Honey<
1577
2177
  TTaps,
1578
2178
  TScopedMw
1579
2179
  >({
1580
- boundaryErrorKey: this._defaultBoundaryKey,
1581
- errorKeys,
2180
+ boundaryErrorKey: null,
2181
+ errorKeys: new Set(),
1582
2182
  extraMethods: extraMethods ?? null,
1583
- handlerMap: this._handlerMap,
1584
2183
  inputSchemas: null,
1585
2184
  meta: this._chainMeta ? { ...this._chainMeta } : null,
1586
2185
  method,
1587
2186
  middlewares: [],
1588
- mwMeta: this._contributedMetaFor(fullPath),
1589
2187
  outputSchemas: null,
1590
2188
  parent: this,
1591
- parentMiddlewares: this._chainMiddlewares,
1592
2189
  path: fullPath,
1593
- root: this._root,
2190
+ pending,
1594
2191
  }) as unknown as BuilderChain<
1595
2192
  TEnv,
1596
2193
  TCtx & ApplyScoped<TScopedMw, MergePath<TBasePath, TPath>>,
@@ -1612,6 +2209,27 @@ export class Honey<
1612
2209
  >
1613
2210
  }
1614
2211
 
2212
+ /** @internal — set (or clear, with null) one app-wide middleware; used by `serve()` */
2213
+ _setGlobal(key: string, mw: RuntimeMiddleware | null): void {
2214
+ if (mw === null) {
2215
+ if (!this._graph.global.delete(key)) return
2216
+ } else {
2217
+ this._graph.global.set(key, mw)
2218
+ }
2219
+ this._bumpEpoch()
2220
+ }
2221
+
2222
+ /** @internal — what a route registered through this handle captures */
2223
+ _view(): { chain: RuntimeMiddleware[]; cv: Record<string, unknown> | null; own: AppSettings<unknown> } {
2224
+ return { chain: this._chain, cv: this._contextValues, own: this._graph.settings }
2225
+ }
2226
+
2227
+ /** @internal — a builder got its handler (or was abandoned on purpose) */
2228
+ _settle(pending: PendingRoute): void {
2229
+ this._graph.pending.delete(pending)
2230
+ this._markUsed()
2231
+ }
2232
+
1615
2233
  on<const TPath extends string, const TMethods extends readonly [HttpMethod | "ALL", ...(HttpMethod | "ALL")[]]>(
1616
2234
  methods: TMethods,
1617
2235
  path: TPath,
@@ -1676,6 +2294,9 @@ export class Honey<
1676
2294
  never,
1677
2295
  Honey<TEnv, TCtx, TRoutes, TMeta, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw>
1678
2296
  > {
2297
+ const fullPath = mergePath(this._basePath, path)
2298
+ const pending: PendingRoute = { id: `WS ${fullPath}` }
2299
+ this._graph.pending.add(pending)
1679
2300
  return new WSRouteBuilder<
1680
2301
  TEnv,
1681
2302
  TCtx & ApplyScoped<TScopedMw, MergePath<TBasePath, TPath>>,
@@ -1683,54 +2304,47 @@ export class Honey<
1683
2304
  never,
1684
2305
  Honey<TEnv, TCtx, TRoutes, TMeta, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw>
1685
2306
  >({
1686
- boundaryErrorKey: this._defaultBoundaryKey,
1687
2307
  errorKeys: new Set(),
1688
2308
  inputSchemas: null,
1689
- meta: null,
2309
+ meta: this._chainMeta ? { ...this._chainMeta } : null,
1690
2310
  middlewares: [],
1691
- mwMeta: this._contributedMetaFor(mergePath(this._basePath, path)),
2311
+ origins: null,
1692
2312
  parent: this,
1693
- parentMiddlewares: this._chainMiddlewares,
1694
- path: mergePath(this._basePath, path),
1695
- root: this._root,
2313
+ path: fullPath,
2314
+ pending,
1696
2315
  })
1697
2316
  }
1698
2317
 
1699
- realtime(path: string, opts: RealtimeRouteOpts): this {
1700
- const fullPath = mergePath(this._basePath, path) || "/"
1701
-
1702
- if (!this._realtimeBus) {
1703
- this._realtimeBus = createBus()
1704
- }
1705
-
1706
- if (this._realtimeRoutes.has(fullPath)) {
2318
+ realtime<const TPath extends string>(
2319
+ path: TPath,
2320
+ opts: RealtimeRouteOpts<TCtx & ApplyScoped<TScopedMw, MergePath<TBasePath, TPath>>>,
2321
+ ): this {
2322
+ const fullPath = mergePath(this._basePath, path)
2323
+ const g = this._graph
2324
+ const rt = requireFeature("realtime", "app.realtime()")
2325
+ const config = rt.resolveRealtimeConfig(fullPath, opts)
2326
+ if (g.realtimeRoutes.has(fullPath)) {
1707
2327
  throw new Error(`Duplicate realtime route: ${fullPath}`)
1708
2328
  }
1709
-
1710
- this._realtimeRoutes.set(fullPath, {
1711
- handler: opts.handler,
1712
- middlewares: opts.use,
1713
- reconnectBuffer: opts.reconnectBuffer,
1714
- })
1715
-
1716
- const mw: RuntimeMiddleware[] = []
1717
- if (opts.use) {
1718
- for (const fn of opts.use) {
1719
- mw.push(fn as RuntimeMiddleware)
1720
- }
1721
- }
1722
-
1723
- insertWsRoute(this._root, fullPath, {
1724
- bek: this._defaultBoundaryKey,
2329
+ if (!g.realtimeBus) this._setBus(rt.createBus())
2330
+ g.realtimeRoutes.set(fullPath, config)
2331
+ this._markUsed()
2332
+ this._addWsRoute(parsePattern(fullPath), {
2333
+ bek: null,
2334
+ cm: [...this._chain],
2335
+ cv: this._contextValues,
2336
+ dk: new Set(),
1725
2337
  ek: new Set(),
1726
2338
  fn: Object.create(null),
1727
2339
  iv: null,
1728
2340
  mt: null,
1729
- mw: [...this._chainMiddlewares, ...mw],
2341
+ mw: [],
2342
+ own: g.settings,
2343
+ rb: null,
2344
+ rm: (opts.use ?? []).map((fn) => fn as RuntimeMiddleware),
1730
2345
  rp: fullPath,
2346
+ xm: this._chainMeta ? { ...this._chainMeta } : null,
1731
2347
  })
1732
-
1733
- this._hasWsRoutes = true
1734
2348
  return this
1735
2349
  }
1736
2350
 
@@ -1744,23 +2358,44 @@ export class Honey<
1744
2358
  env: TEnv,
1745
2359
  executionCtx?: { waitUntil?: (p: Promise<unknown>) => void },
1746
2360
  ): Response | Promise<Response> {
1747
- if (this._wsAdapter === null && !this._hasWsRoutes) {
1748
- return this._doFetch(request, env, executionCtx)
2361
+ if (!this._graph.served.has(this._chain)) {
2362
+ this._markUsed()
2363
+ this._graph.served.add(this._chain)
2364
+ this._bumpEpoch()
2365
+ }
2366
+ const wsAdapter = this._graph.settings.wsAdapter
2367
+ const path = normalizePath(pathOfUrl(request.url), this._graph.settings.encodedSlashes)
2368
+ if (path === null) return this._badRequestTarget()
2369
+ /* Deno and Bun before 1.4 build request.url by concatenating Host and the target, so a
2370
+ * `Host: x/admin?` would choose the routed path. `serve()` checks Host on Deno; this covers
2371
+ * `Deno.serve(app.fetch)`, which passes its serve info as env, and every Bun entry point on
2372
+ * an affected version. Node's adapter checks it; Bun 1.4+ routes on the target. */
2373
+ if ((BUN_URL_FROM_HOST || isDenoServeInfo(env)) && !hasValidHost(request)) return this._badRequestTarget()
2374
+ /* an adapter alone (serve() always installs one) does not need the websocket path */
2375
+ if (!this._graph.hasWs) {
2376
+ return this._doFetch(request, env, executionCtx, path)
1749
2377
  }
1750
2378
  const isWsUpgrade = request.headers.get("upgrade")?.toLowerCase() === "websocket"
1751
- const headerSnap = isWsUpgrade ? new Headers(request.headers) : undefined
1752
- const path = this.pathFromRequest(request)
1753
2379
  const canPreUpgrade =
1754
2380
  isWsUpgrade &&
1755
- this._wsAdapter?.preUpgrade !== undefined &&
2381
+ wsAdapter?.preUpgrade !== undefined &&
1756
2382
  !this.trailingSlashRedirects(path) &&
1757
- matchWsRoute(this._root, this.pathAfterPrefix(path)) !== null
1758
- const pre = canPreUpgrade ? this._wsAdapter?.preUpgrade?.(request) : undefined
2383
+ this._matchWs(this._finalize(), this.pathAfterPrefix(path)) !== null
2384
+ /* Deno closes the Request in preUpgrade: snapshot the headers the chain will read first */
2385
+ const headerSnap = canPreUpgrade ? new Headers(request.headers) : undefined
2386
+ let pre: ReturnType<NonNullable<WSAdapter["preUpgrade"]>>
2387
+ try {
2388
+ pre = canPreUpgrade ? wsAdapter?.preUpgrade?.(request) : undefined
2389
+ } catch (err) {
2390
+ /* the runtime refused the handshake: answer through the normal path, which fails the upgrade */
2391
+ this._graph.settings.logger?.warn?.({ err }, "websocket preUpgrade failed")
2392
+ pre = undefined
2393
+ }
1759
2394
  /* After Deno.upgradeWebSocket the Request is closed. A sync throw here
1760
2395
  * used to be boxed by async _doFetch; keep 101 returning either way. */
1761
2396
  let work: Response | Promise<Response>
1762
2397
  try {
1763
- work = this._doFetch(request, env, executionCtx, isWsUpgrade === true, headerSnap)
2398
+ work = this._doFetch(request, env, executionCtx, path, isWsUpgrade === true, headerSnap)
1764
2399
  } catch (err) {
1765
2400
  work = Promise.reject(err)
1766
2401
  }
@@ -1790,28 +2425,25 @@ export class Honey<
1790
2425
  return pre.response
1791
2426
  }
1792
2427
 
1793
- private pathFromRequest(request: Request): string {
1794
- const rawUrl = request.url
1795
- const protoEnd = rawUrl.indexOf("//")
1796
- const pathStart = protoEnd === -1 ? 0 : rawUrl.indexOf("/", protoEnd + 2)
1797
- const searchOrHash = pathStart === -1 ? -1 : findSearchOrHash(rawUrl, pathStart)
1798
- if (pathStart === -1) return "/"
1799
- if (searchOrHash === -1) return rawUrl.substring(pathStart)
1800
- return rawUrl.substring(pathStart, searchOrHash)
2428
+ /** @internal 400 for a request path the normalization policy rejects (see `normalizePath`), or an invalid `Host`. */
2429
+ _badRequestTarget(): Response {
2430
+ return this._toErrorResponse(this._createError(EK.bad_request, SK.bad_request))
1801
2431
  }
1802
2432
 
1803
2433
  private trailingSlashRedirects(path: string): boolean {
1804
2434
  if (path.length <= 1) return false
1805
- if (this._trailingSlash === "strip" && path.endsWith("/")) return true
1806
- if (this._trailingSlash === "enforce" && !path.endsWith("/")) return true
2435
+ const mode = this._graph.settings.trailingSlash
2436
+ if (mode === "strip" && path.endsWith("/")) return true
2437
+ if (mode === "enforce" && !path.endsWith("/")) return true
1807
2438
  return false
1808
2439
  }
1809
2440
 
1810
2441
  private pathAfterPrefix(path: string): string {
1811
- if (this._stripPrefix === null) return path
1812
- if (path === this._stripPrefix) return "/"
1813
- if (path.startsWith(this._stripPrefix) && path.charCodeAt(this._stripPrefix.length) === 47) {
1814
- return path.slice(this._stripPrefix.length)
2442
+ const prefix = this._graph.settings.stripPrefix
2443
+ if (prefix === null) return path
2444
+ if (path === prefix) return "/"
2445
+ if (path.startsWith(prefix) && path.charCodeAt(prefix.length) === 47) {
2446
+ return path.slice(prefix.length)
1815
2447
  }
1816
2448
  return path
1817
2449
  }
@@ -1819,25 +2451,15 @@ export class Honey<
1819
2451
  private _doFetch(
1820
2452
  request: Request,
1821
2453
  env: TEnv,
1822
- executionCtx?: { waitUntil?: (p: Promise<unknown>) => void },
2454
+ executionCtx: { waitUntil?: (p: Promise<unknown>) => void } | undefined,
2455
+ fullPath: string,
1823
2456
  knownWsUpgrade = false,
1824
2457
  headerSnap?: Headers,
1825
2458
  ): Response | Promise<Response> {
1826
2459
  const startTime = performance.now()
1827
-
1828
- /* fast path extraction — avoids expensive new URL() allocation */
2460
+ const final = this._finalize()
2461
+ const s = this._graph.settings
1829
2462
  const rawUrl = request.url
1830
- const protoEnd = rawUrl.indexOf("//")
1831
- const pathStart = protoEnd === -1 ? 0 : rawUrl.indexOf("/", protoEnd + 2)
1832
- const searchOrHash = pathStart === -1 ? -1 : findSearchOrHash(rawUrl, pathStart)
1833
- let path: string
1834
- if (pathStart === -1) {
1835
- path = "/"
1836
- } else if (searchOrHash === -1) {
1837
- path = rawUrl.substring(pathStart)
1838
- } else {
1839
- path = rawUrl.substring(pathStart, searchOrHash)
1840
- }
1841
2463
 
1842
2464
  /* lazily create URL only when actually needed (search params, redirects) */
1843
2465
  let _url: URL | undefined
@@ -1846,296 +2468,176 @@ export class Honey<
1846
2468
  return _url
1847
2469
  }
1848
2470
 
1849
- const log = this._logger ?? undefined
2471
+ const log = s.logger ?? undefined
2472
+ const method = request.method.toUpperCase() as HttpMethod
1850
2473
  const fc: FetchCtx<TEnv> = {
2474
+ allowed: null,
1851
2475
  env,
1852
2476
  executionCtx,
2477
+ headerSnap,
1853
2478
  log,
2479
+ method,
2480
+ path: fullPath,
2481
+ plan: null,
1854
2482
  request,
1855
2483
  startTime,
2484
+ trust: s.trust,
1856
2485
  url: getUrl,
2486
+ ws: null,
1857
2487
  wsUpgrade: knownWsUpgrade,
1858
- headerSnap,
1859
2488
  }
1860
2489
 
1861
- if (this._telemetry !== null) {
1862
- safeFire(() => this._telemetry?.onRequest?.({ env, req: request }), log)
1863
- }
2490
+ if (s.telemetry === null) return this._route(fc, final, fullPath, rawUrl, knownWsUpgrade)
2491
+ safeFire(() => s.telemetry?.onRequest?.({ env, req: request }), log)
2492
+ /* every exit after onRequest — redirects, 426, upgrades, misses, preflights, handlers —
2493
+ * fires onResponse exactly once, here */
2494
+ return this._withOnResponse(fc, this._route(fc, final, fullPath, rawUrl, knownWsUpgrade))
2495
+ }
1864
2496
 
1865
- /* trailing slash handling */
1866
- if (path.length > 1) {
1867
- if (this._trailingSlash === "strip" && path.endsWith("/")) {
1868
- const redirectUrl = getUrl()
1869
- redirectUrl.pathname = path.slice(0, -1)
1870
- return new Response(null, {
1871
- headers: { location: redirectUrl.toString() },
1872
- status: 308,
1873
- })
1874
- }
1875
- if (this._trailingSlash === "enforce" && !path.endsWith("/")) {
1876
- const redirectUrl = getUrl()
1877
- redirectUrl.pathname = `${path}/`
1878
- return new Response(null, {
1879
- headers: { location: redirectUrl.toString() },
1880
- status: 308,
1881
- })
1882
- }
2497
+ private _route(
2498
+ fc: FetchCtx<TEnv>,
2499
+ final: FinalTable,
2500
+ fullPath: string,
2501
+ rawUrl: string,
2502
+ knownWsUpgrade: boolean,
2503
+ ): Response | Promise<Response> {
2504
+ const s = this._graph.settings
2505
+ const { method, request } = fc
2506
+
2507
+ /* trailing slash handling — a relative Location: the scheme, host and port the client
2508
+ * used are the ones it keeps, whatever proxy terminated TLS in front of the app */
2509
+ if (this.trailingSlashRedirects(fullPath)) {
2510
+ const target = s.trailingSlash === "strip" ? fullPath.slice(0, -1) : `${fullPath}/`
2511
+ return new Response(null, {
2512
+ headers: { location: target + searchOfUrl(rawUrl) },
2513
+ status: 308,
2514
+ })
1883
2515
  }
1884
2516
 
1885
2517
  /* prefix stripping — must run AFTER trailing slash so redirects preserve the full prefixed URL */
1886
- if (this._stripPrefix !== null) {
1887
- if (path === this._stripPrefix) {
1888
- path = "/"
1889
- } else if (path.startsWith(this._stripPrefix) && path.charCodeAt(this._stripPrefix.length) === 47) {
1890
- path = path.slice(this._stripPrefix.length)
1891
- }
1892
- }
2518
+ const path = this.pathAfterPrefix(fullPath)
2519
+ fc.path = path
2520
+ const root = this._graph.root
1893
2521
 
1894
2522
  /* WebSocket route check — do not re-read headers after Deno.upgradeWebSocket */
1895
2523
  const isWsUpgrade = knownWsUpgrade || requestIsWsUpgrade(request)
1896
2524
  if (isWsUpgrade) {
1897
- const wsMatch = matchWsRoute(this._root, path)
2525
+ const wsMatch = this._matchWs(final, path)
1898
2526
  if (wsMatch !== null) {
1899
2527
  return this._handleWs(fc, wsMatch)
1900
2528
  }
1901
- } else if (this._realtimeRoutes.size > 0) {
1902
- /* Non-upgrade request hitting a realtime-only path → 426 Upgrade Required */
1903
- const wsMatch = matchWsRoute(this._root, path)
1904
- if (wsMatch !== null && this._realtimeRoutes.has(wsMatch.handler.rp)) {
1905
- return new Response(null, { headers: { upgrade: "websocket" }, status: 426 })
1906
- }
1907
2529
  }
1908
2530
 
1909
- const method = request.method.toUpperCase() as HttpMethod
1910
-
1911
- /* fn:null fallthrough — tree match sets meta/iv, catch-all dispatches */
1912
- let fnNullMeta: Record<string, unknown> | null = null
1913
- let fnNullParams: Record<string, string> | null = null
1914
- let fnNullIv: InputSchemasDef | null = null
1915
- let fnNullHit = false
1916
-
1917
- /* Tier 2: O(1) static route lookup — checks both precompiled and runtime maps */
1918
- const smap = this._staticRoutes.map ?? this._handlerMap
1919
- if (smap !== null) {
1920
- const key = `${method} ${path}`
1921
- const staticHandler = smap[key]
1922
- if (staticHandler) {
1923
- if (staticHandler.fn === null) {
1924
- fnNullMeta = staticHandler.mt
1925
- fnNullParams = EMPTY_PARAMS
1926
- fnNullIv = staticHandler.iv ?? null
1927
- fnNullHit = true
1928
- } else {
1929
- return this._handleMatched(fc, method, path, staticHandler, EMPTY_PARAMS)
1930
- }
1931
- }
1932
- /* HEAD falls back to GET */
1933
- if (!fnNullHit && method === "HEAD") {
1934
- const getHandler = smap[`GET ${path}`]
1935
- if (getHandler) {
1936
- if (getHandler.fn === null) {
1937
- fnNullMeta = getHandler.mt
1938
- fnNullParams = EMPTY_PARAMS
1939
- fnNullIv = getHandler.iv ?? null
1940
- fnNullHit = true
1941
- } else {
1942
- return this._handleMatched(fc, method, path, getHandler, EMPTY_PARAMS)
1943
- }
1944
- }
1945
- }
2531
+ /* O(1) static route lookup — patterns without params or wildcards only */
2532
+ const staticHit =
2533
+ final.statics[`${method} ${path}`] ?? (method === "HEAD" ? final.statics[`GET ${path}`] : undefined)
2534
+ if (staticHit !== undefined) {
2535
+ return this._dispatchRecord(fc, staticHit, EMPTY_PARAMS)
1946
2536
  }
1947
2537
 
1948
- if (!fnNullHit) {
1949
- const result = matchRoute(this._root, method, path)
1950
-
1951
- if (result?.matched) {
1952
- /*
1953
- * routeTree loaded → wildcard matches for unknown paths must 404.
1954
- * Only fn:null fallthroughs (known routes) should reach the catch-all.
1955
- * Detect wildcard: handler has no mt and fn is NOT null (builder-registered catch-all).
1956
- */
1957
- const isWildcardCatchAll =
1958
- this._hasRouteTree &&
1959
- this._root.w !== null &&
1960
- result.handler.fn !== null &&
1961
- (this._root.w.m[method] === result.handler || this._root.w.m["ALL"] === result.handler)
1962
-
1963
- if (isWildcardCatchAll) {
1964
- /* path not in routeTree — 404 */
1965
- return this._handle404(fc, method, path)
1966
- }
1967
-
1968
- if (result.handler.fn === null) {
1969
- fnNullMeta = result.handler.mt
1970
- fnNullParams = result.params
1971
- fnNullIv = result.handler.iv ?? null
1972
- fnNullHit = true
1973
- } else {
1974
- return this._handleMatched(fc, method, path, result.handler, result.params)
1975
- }
1976
- }
1977
-
1978
- if (!fnNullHit) {
1979
- if (result === null) {
1980
- if (!isWsUpgrade) {
1981
- const wsMatch = matchWsRoute(this._root, path)
1982
- if (wsMatch !== null) {
1983
- return new Response("Upgrade Required", {
1984
- headers: { connection: "Upgrade", upgrade: "websocket" },
1985
- status: 426,
1986
- })
1987
- }
2538
+ const result = matchRoute(root, method, path)
2539
+ if (result === null) {
2540
+ /* no HTTP route: a websocket or realtime route on this path asks for an upgrade */
2541
+ if (!isWsUpgrade && this._graph.hasWs) {
2542
+ const wsMatch = this._matchWs(final, path)
2543
+ if (wsMatch !== null) {
2544
+ if (this._graph.realtimeRoutes.has(wsMatch.handler.rp)) {
2545
+ return new Response(null, { headers: { upgrade: "websocket" }, status: 426 })
1988
2546
  }
1989
- return this._handle404(fc, method, path)
2547
+ return new Response("Upgrade Required", {
2548
+ headers: { connection: "Upgrade", upgrade: "websocket" },
2549
+ status: 426,
2550
+ })
1990
2551
  }
1991
-
1992
- if (!result.matched) {
1993
- if (
1994
- method === "OPTIONS" &&
1995
- fc.request.headers.has("access-control-request-method") &&
1996
- result.allowed.length > 0
1997
- ) {
1998
- const fallback = this._preflightFallbackMethod(result.allowed)
1999
- const retry = matchRoute(this._root, fallback, path)
2000
- if (retry?.matched && retry.handler.fn !== null) {
2001
- return this._handleCorsPreflight(fc, path, retry.handler, retry.params, result.allowed)
2002
- }
2552
+ }
2553
+ return this._handleMiss(fc, null)
2554
+ }
2555
+ if (!result.matched) {
2556
+ if (method === "OPTIONS") {
2557
+ const requested = request.headers.get("access-control-request-method")
2558
+ if (requested) {
2559
+ /* a preflight asks about one method: run that route's own chain */
2560
+ const hit = matchRoute(root, requested.trim().toUpperCase(), path)
2561
+ const plan = hit?.matched ? final.plans.get(hit.id) : undefined
2562
+ if (hit?.matched && plan !== undefined && plan.r.fn !== NOT_SERVED) {
2563
+ return this._handlePreflight(fc, plan, hit.params, result.allowed)
2003
2564
  }
2004
- return this._handle405(fc, method, path, result.allowed)
2005
2565
  }
2006
2566
  }
2567
+ return this._handleMiss(fc, result.allowed)
2007
2568
  }
2569
+ const plan = final.plans.get(result.id)
2570
+ if (plan === undefined) return this._handleMiss(fc, null)
2571
+ return this._dispatchRecord(fc, plan, result.params)
2572
+ }
2008
2573
 
2009
- /* fn:null hit — find catch-all/wildcard to dispatch with stashed meta */
2010
- const wildcardResult = matchRoute(this._root, method, "/*")
2011
- if (wildcardResult?.matched && wildcardResult.handler.fn !== null) {
2012
- /* 415 from matched iv before middleware that waits on the body */
2013
- if (fnNullIv !== null) {
2014
- try {
2015
- assertRequestContentType(fnNullIv, fc.request)
2016
- } catch (thrown) {
2017
- return this._toErrorResponse(thrown)
2018
- }
2574
+ private _matchWs(
2575
+ final: FinalTable,
2576
+ path: string,
2577
+ ): { handler: WSRouteHandler; params: Record<string, string> } | null {
2578
+ const hit = matchWsRoute(this._graph.root, path)
2579
+ if (hit === null) return null
2580
+ const handler = final.wsById.get(hit.id)
2581
+ return handler === undefined ? null : { handler, params: hit.params }
2582
+ }
2583
+
2584
+ private _dispatchRecord(
2585
+ fc: FetchCtx<TEnv>,
2586
+ plan: Plan,
2587
+ params: Record<string, string>,
2588
+ ): Response | Promise<Response> {
2589
+ const record = plan.r as RouteHandler
2590
+ if (record.fn === NOT_SERVED) return this._handleMiss(fc, null)
2591
+ /* a delegated route keeps its own input schemas for docs and the 415 check; the body
2592
+ * itself belongs to whatever the catch-all forwards it to */
2593
+ if (record.dl === true && record.iv) {
2594
+ try {
2595
+ assertRequestContentType(record.iv, fc.request)
2596
+ } catch (thrown) {
2597
+ return this._toErrorResponse(thrown)
2019
2598
  }
2020
- return this._handleMatched(fc, method, path, wildcardResult.handler, fnNullParams ?? EMPTY_PARAMS, fnNullMeta)
2021
2599
  }
2022
-
2023
- return this._handle404(fc, method, path)
2600
+ return this._handleMatched(fc, plan, params)
2024
2601
  }
2025
2602
 
2026
2603
  private _makeErrorCtx(fc: FetchCtx<TEnv>, allowed?: string[]) {
2604
+ const s = this._s
2027
2605
  return {
2028
2606
  env: fc.env,
2029
- jsonFromError: (err: HoneyError) => createErrorResponse(err, this._errorFormatter, this._customErrorFormatter),
2607
+ jsonFromError: (err: HoneyError) => createErrorResponse(err, s.errorFormatter, s.customErrorFormatter),
2030
2608
  req: fc.request,
2031
2609
  ...(allowed ? { allowed } : {}),
2032
2610
  }
2033
2611
  }
2034
2612
 
2035
- private async _handleWs(
2036
- fc: FetchCtx<TEnv>,
2037
- wsMatch: { handler: WSRouteHandler; params: Record<string, string> },
2038
- ): Promise<Response> {
2039
- /* Dispatch to realtime handler if this path is registered as a realtime route */
2040
- const realtimeConfig = this._realtimeRoutes.get(wsMatch.handler.rp)
2041
- if (realtimeConfig) {
2042
- return this._handleRealtime(fc, wsMatch, realtimeConfig)
2043
- }
2044
-
2045
- const isUpgrade = fc.wsUpgrade === true || requestIsWsUpgrade(fc.request)
2046
- if (!isUpgrade) {
2047
- return new Response(null, {
2048
- headers: { upgrade: "websocket" },
2049
- status: 426,
2050
- })
2051
- }
2052
-
2053
- const wsAdapter = this._wsAdapter
2054
- if (!wsAdapter) {
2055
- fc.log?.warn?.("WebSocket adapter not configured — call .wsAdapter()")
2056
- return createErrorResponse(
2057
- this._createError(EK.internal_server_error, SK.internal_server_error),
2058
- this._errorFormatter,
2059
- this._customErrorFormatter,
2060
- )
2061
- }
2062
-
2063
- const wsCtx = new HoneyContext({
2613
+ /** A request context for `plan`: route data, context values, `ctx.errors`, `ctx.realtime`. */
2614
+ private _newCtx(fc: FetchCtx<TEnv>, plan: Plan, params: Record<string, string>, req?: Request): HoneyContext<TEnv> {
2615
+ fc.plan = plan
2616
+ const r = plan.r
2617
+ const ctx = new HoneyContext<TEnv>({
2064
2618
  env: fc.env,
2065
2619
  executionCtx: fc.executionCtx,
2066
- params: wsMatch.params,
2067
- req: ctxRequest(fc),
2620
+ meta: r.mt ?? undefined,
2621
+ params,
2622
+ path: fc.path,
2623
+ req: req ?? fc.request,
2624
+ routePattern: r.rp ?? "",
2068
2625
  urlFn: fc.url,
2069
2626
  })
2070
- if (this._contextValues) Object.assign(wsCtx, this._contextValues)
2071
-
2072
- /*
2073
- * WS ordering: [global → scoped → chain+handler-route-specific]
2074
- * WS bakes chain mw into handler.mw at registration, so scoped runs before chain.
2075
- * This is an unavoidable inconsistency vs HTTP (where chain runs before scoped).
2076
- */
2077
- const scopedForPath = this._filterScopedForPath(wsMatch.handler.rp)
2078
- const allWsMw: RuntimeMiddleware[] = [...this._globalMiddlewares, ...scopedForPath, ...wsMatch.handler.mw]
2079
-
2080
- try {
2081
- return await executeChain(allWsMw, wsCtx, async (finalCtx) => {
2082
- const userHandler = wsMatch.handler.fn
2083
- let messageQueue: Promise<void> = Promise.resolve()
2084
-
2085
- const onOpenFn = userHandler.onOpen
2086
- const onMsgFn = userHandler.onMessage
2087
- const onCloseFn = userHandler.onClose
2088
- const onErrorFn = userHandler.onError
2089
- const onReconnectFn = userHandler.onReconnect
2090
- const reconnectToken = fc.url().searchParams.get("reconnect_token")
2091
-
2092
- const wrappedHandler: WSHandler<unknown> = {}
2093
-
2094
- if (reconnectToken && onReconnectFn) {
2095
- wrappedHandler.onOpen = (_ctx, ws) => {
2096
- onReconnectFn(finalCtx, ws, reconnectToken)
2097
- }
2098
- } else if (onOpenFn) {
2099
- wrappedHandler.onOpen = (_ctx, ws) => {
2100
- onOpenFn(finalCtx, ws)
2101
- }
2102
- }
2103
-
2104
- if (onMsgFn) {
2105
- wrappedHandler.onMessage = (_ctx, ws, data) => {
2106
- messageQueue = messageQueue
2107
- .then(() => onMsgFn(finalCtx, ws, data))
2108
- .catch((err: unknown) => {
2109
- onErrorFn?.(finalCtx, ws, err)
2110
- })
2111
- }
2112
- }
2113
-
2114
- if (onCloseFn) {
2115
- wrappedHandler.onClose = (_ctx, ws, code, reason) => {
2116
- onCloseFn(finalCtx, ws, code, reason)
2117
- }
2118
- }
2119
-
2120
- if (onErrorFn) {
2121
- wrappedHandler.onError = (_ctx, ws, error) => {
2122
- onErrorFn(finalCtx, ws, error)
2123
- }
2124
- }
2125
-
2126
- const upgradeResult = await wsAdapter.upgrade(fc.request, fc.env, wrappedHandler)
2127
- return upgradeResult.response
2128
- })
2129
- } catch (thrown) {
2130
- return this._toErrorResponse(thrown)
2131
- }
2627
+ if (plan.cv !== null) Object.assign(ctx, plan.cv)
2628
+ const rt = this._graph.realtimeCtx
2629
+ if (rt !== null) (ctx as { realtime: unknown }).realtime = rt
2630
+ if (r.ef) ctx._setErrors(r.ef)
2631
+ ctx._rq = fc
2632
+ return ctx
2132
2633
  }
2133
2634
 
2134
- private async _handleRealtime(
2635
+ private _handleWs(
2135
2636
  fc: FetchCtx<TEnv>,
2136
2637
  wsMatch: { handler: WSRouteHandler; params: Record<string, string> },
2137
- config: { handler: RealtimeRouteOpts["handler"]; middlewares?: RealtimeRouteOpts["use"]; reconnectBuffer?: number },
2138
- ): Promise<Response> {
2638
+ ): Response | Promise<Response> {
2639
+ const plan = this._finalize().wsPlans.get(wsMatch.handler.id as RouteId)
2640
+ if (plan === undefined) return this._handleMiss(fc, null)
2139
2641
  const isUpgrade = fc.wsUpgrade === true || requestIsWsUpgrade(fc.request)
2140
2642
  if (!isUpgrade) {
2141
2643
  return new Response(null, {
@@ -2143,485 +2645,172 @@ export class Honey<
2143
2645
  status: 426,
2144
2646
  })
2145
2647
  }
2146
-
2147
- const wsAdapter = this._wsAdapter
2148
- if (!wsAdapter) {
2648
+ if (!this._graph.settings.wsAdapter) {
2149
2649
  fc.log?.warn?.("WebSocket adapter not configured — call .wsAdapter()")
2150
- return createErrorResponse(
2151
- this._createError(EK.internal_server_error, SK.internal_server_error),
2152
- this._errorFormatter,
2153
- this._customErrorFormatter,
2154
- )
2155
- }
2156
-
2157
- /* Lazily create bus if not yet initialized (happens when realtime() was called on a child chain) */
2158
- if (!this._realtimeBus) {
2159
- this._realtimeBus = createBus()
2160
- }
2161
- const bus = this._realtimeBus
2162
-
2163
- const ctx = new HoneyContext({
2164
- env: fc.env,
2165
- executionCtx: fc.executionCtx,
2166
- params: wsMatch.params,
2167
- req: ctxRequest(fc),
2168
- urlFn: fc.url,
2650
+ return this._toErrorResponse(this._createError(EK.internal_server_error, SK.internal_server_error))
2651
+ }
2652
+ fc.ws = wsMatch
2653
+ const ctx = this._newCtx(fc, plan, wsMatch.params, ctxRequest(fc))
2654
+ return plan.run(ctx)
2655
+ }
2656
+
2657
+ /** Reject a cross-origin upgrade the route's origin policy does not allow (403). */
2658
+ /** The websocket session runtime; a cross-origin upgrade the route does not allow is a 403. */
2659
+ private async _wsSession(finalCtx: HoneyContext<TEnv>, policy: WSOriginPolicy | null) {
2660
+ const ws = await loadFeature("ws", "a websocket route")
2661
+ const headers = finalCtx.req.headers
2662
+ const origin = headers.get("origin")
2663
+ if (origin === null || policy === "*") return ws
2664
+ const credentialed = headers.has("cookie") || headers.has("authorization")
2665
+ if (!ws.originAllowed(policy, origin, clientInfo(finalCtx as unknown as HoneyContext).host, credentialed)) {
2666
+ throw this._createError(EK.forbidden, SK.forbidden)
2667
+ }
2668
+ return ws
2669
+ }
2670
+
2671
+ private async _wsUpgrade(finalCtx: HoneyContext<TEnv>): Promise<Response> {
2672
+ const fc = finalCtx._rq as FetchCtx<TEnv>
2673
+ const wsMatch = fc.ws as { handler: WSRouteHandler; params: Record<string, string> }
2674
+ const ws = await this._wsSession(finalCtx, wsMatch.handler.og ?? null)
2675
+ const wsAdapter = this._graph.settings.wsAdapter as WSAdapter
2676
+ const handler = ws.createWsSession({
2677
+ callbacks: wsMatch.handler.fn,
2678
+ ctx: finalCtx,
2679
+ log: (this._graph.settings.logger ?? null) as InvokeLogger | null,
2680
+ reconnectToken: fc.url().searchParams.get("reconnect_token"),
2681
+ route: wsMatch.handler.rp,
2169
2682
  })
2170
- if (this._contextValues) Object.assign(ctx, this._contextValues)
2171
- Object.assign(ctx, {
2172
- realtime: { publish: (topic: string, data: unknown) => bus.publish(topic, data) },
2173
- })
2174
-
2175
- const scopedForPath = this._filterScopedForPath(wsMatch.handler.rp)
2176
- const allMw: RuntimeMiddleware[] = [...this._globalMiddlewares, ...scopedForPath, ...wsMatch.handler.mw]
2177
-
2178
- try {
2179
- return await executeChain(allMw, ctx, async (finalCtx) => {
2180
- const connId = crypto.randomUUID()
2181
-
2182
- let socket: WSContext<unknown> | null = null
2183
- let conn: ReturnType<typeof createConnContext> | null = null
2184
-
2185
- /*
2186
- * initConn creates the ConnContext and calls the user handler.
2187
- * Called from onOpen (for Bun where socket arrives later)
2188
- * or inline after upgrade (for Node/CF where socket is immediate).
2189
- */
2190
- const initConn = (ws: WSContext<unknown>) => {
2191
- socket = ws
2192
- conn = createConnContext({
2193
- bus,
2194
- closeFn: (reason) => {
2195
- if (socket) socket.close(1000, reason)
2196
- },
2197
- id: connId,
2198
- sendFn: (payload) => {
2199
- if (socket)
2200
- socket.send(typeof payload === "object" && payload !== null ? JSON.stringify(payload) : String(payload))
2201
- },
2202
- transport: "ws",
2203
- userId: null,
2204
- })
2205
-
2206
- bus.onMessage(connId, (data) => {
2207
- if (socket) {
2208
- socket.send(typeof data === "object" && data !== null ? JSON.stringify(data) : String(data))
2209
- }
2210
- })
2211
-
2212
- config.handler(finalCtx, conn)
2213
- }
2214
-
2215
- const wrappedHandler: WSHandler<unknown> = {
2216
- onClose: (_ctx, _ws, _code, reason) => {
2217
- if (!conn) return
2218
- const handlers = conn._handlers
2219
- if (handlers.close) {
2220
- handlers.close(reason || "normal")
2221
- }
2222
- bus.unsubscribeAll(connId)
2223
- bus.removeHandler(connId)
2224
- },
2225
- onMessage: (_ctx, _ws, data) => {
2226
- if (!conn) return
2227
- const handlers = conn._handlers
2228
- if (handlers.message && typeof data === "string") {
2229
- try {
2230
- const parsed: unknown = JSON.parse(data)
2231
- if (isMsgFrame(parsed)) {
2232
- handlers.message(parsed.data)
2233
- }
2234
- } catch {
2235
- /* ignore malformed frames */
2236
- }
2237
- }
2238
- },
2239
- onOpen: (_ctx, ws) => {
2240
- if (!socket) initConn(ws)
2241
- },
2242
- }
2243
-
2244
- const upgradeResult = await wsAdapter.upgrade(fc.request, fc.env, wrappedHandler)
2245
-
2246
- /* Node/CF adapters return the socket from upgrade(); Bun returns undefined (socket comes via onOpen).
2247
- * Deno pre-upgrade may still be CONNECTING — wait for onOpen so the first send is not dropped. */
2248
- if (upgradeResult.socket && !socket && upgradeResult.socket.readyState === 1) {
2249
- initConn(upgradeResult.socket)
2250
- }
2683
+ const upgradeResult = await wsAdapter.upgrade(fc.request, fc.env, handler)
2684
+ return upgradeResult.response
2685
+ }
2686
+
2687
+ /** Terminal of a realtime route's chain: identify, upgrade, and attach the connection to the bus. */
2688
+ private async _realtimeUpgrade(finalCtx: HoneyContext<TEnv>, config: RealtimeConfig): Promise<Response> {
2689
+ const fc = finalCtx._rq as FetchCtx<TEnv>
2690
+ await this._wsSession(finalCtx, config.allowedOrigins)
2691
+ const wsAdapter = this._graph.settings.wsAdapter as WSAdapter
2692
+ const rt = requireFeature("realtime", "app.realtime()")
2693
+ const bus = this._graph.realtimeBus ?? this._setBus(rt.createBus())
2694
+ /* a throwing identify rejects the upgrade through the chain's error boundary */
2695
+ const userId = config.identify === null ? null : ((await config.identify(finalCtx)) ?? null)
2696
+ const session = rt.createRealtimeSession({ bus, config, ctx: finalCtx, log: fc.log, userId })
2697
+ const upgradeResult = await wsAdapter.upgrade(fc.request, fc.env, session.handler)
2698
+ /* Node/CF return an open socket from upgrade(); Bun and Deno deliver it through onOpen */
2699
+ if (upgradeResult.socket && upgradeResult.socket.readyState === 1) session.attach(upgradeResult.socket)
2700
+ return upgradeResult.response
2701
+ }
2251
2702
 
2252
- return upgradeResult.response
2253
- })
2254
- } catch (thrown) {
2255
- return this._toErrorResponse(thrown)
2703
+ /**
2704
+ * 404 (`allowed === null`) and 405: one pipeline with a synthetic record — full context,
2705
+ * the app-wide middleware and the scopes covering the path run once, telemetry fires once.
2706
+ */
2707
+ private _handleMiss(fc: FetchCtx<TEnv>, allowed: string[] | null): Response | Promise<Response> {
2708
+ const s = this._graph.settings
2709
+ const { method, path, request } = fc
2710
+ if (allowed === null) {
2711
+ safeFire(() => s.telemetry?.onNotFound?.({ method, path, req: request }), fc.log)
2712
+ } else {
2713
+ safeFire(() => s.telemetry?.onMethodNotAllowed?.({ allowed, method, path, req: request }), fc.log)
2256
2714
  }
2715
+ fc.allowed = allowed
2716
+ const plan = this._finalize().miss
2717
+ const ctx = this._newCtx(fc, plan, EMPTY_PARAMS)
2718
+ return plan.run(ctx)
2257
2719
  }
2258
2720
 
2259
- private async _handle404(fc: FetchCtx<TEnv>, method: string, path: string): Promise<Response> {
2260
- safeFire(() => this._telemetry?.onNotFound?.({ method, path, req: fc.request }), fc.log)
2261
- const make404 = () => {
2262
- if (this._onNotFound) {
2263
- return this._onNotFound(this._makeErrorCtx(fc))
2264
- }
2265
- return this._makeErrorCtx(fc).jsonFromError(this._createError(EK.not_found, SK.not_found))
2266
- }
2267
- try {
2268
- const ctx404 = new HoneyContext({
2269
- env: fc.env,
2270
- executionCtx: fc.executionCtx,
2271
- params: {},
2272
- req: fc.request,
2273
- urlFn: fc.url,
2274
- })
2275
- if (this._contextValues) Object.assign(ctx404, this._contextValues)
2276
- const res =
2277
- this._chainMiddlewares.length > 0
2278
- ? await executeChain(this._chainMiddlewares, ctx404, make404)
2279
- : await make404()
2721
+ /** Fire `onResponse` once for the request's one exit and return. A dispatch that rejects
2722
+ * (an adapter failure past every error boundary) still ends the request: it reports 500. */
2723
+ private _withOnResponse(fc: FetchCtx<TEnv>, res: Response | Promise<Response>): Response | Promise<Response> {
2724
+ const s = this._graph.settings
2725
+ if (s.telemetry?.onResponse === undefined) return res
2726
+ const fire = (status: number): void => {
2280
2727
  safeFire(
2281
- () =>
2282
- this._telemetry?.onResponse?.({
2283
- duration: performance.now() - fc.startTime,
2284
- req: fc.request,
2285
- status: res.status,
2286
- }),
2728
+ () => s.telemetry?.onResponse?.({ duration: performance.now() - fc.startTime, req: fc.request, status }),
2287
2729
  fc.log,
2288
2730
  )
2731
+ }
2732
+ if (!(res instanceof Promise)) {
2733
+ fire(res.status)
2289
2734
  return res
2290
- } catch (thrown) {
2291
- return this._toErrorResponse(thrown)
2292
2735
  }
2736
+ return res.then(
2737
+ (r) => {
2738
+ fire(r.status)
2739
+ return r
2740
+ },
2741
+ (error: unknown) => {
2742
+ fire(500)
2743
+ throw error
2744
+ },
2745
+ )
2293
2746
  }
2294
2747
 
2295
- private _preflightFallbackMethod(allowed: string[]): HttpMethod {
2296
- if (allowed.includes("GET")) return "GET"
2297
- if (allowed.includes("HEAD")) return "HEAD"
2298
- if (allowed.includes("POST")) return "POST"
2299
- return allowed[0] as HttpMethod
2748
+ private _make404(fc: FetchCtx<TEnv>): Response | Promise<Response> {
2749
+ const s = this._s
2750
+ if (s.onNotFound) return s.onNotFound(this._makeErrorCtx(fc))
2751
+ return this._makeErrorCtx(fc).jsonFromError(this._createError(EK.not_found, SK.not_found))
2300
2752
  }
2301
2753
 
2302
- /** Run the existing method's middleware for a CORS preflight. Do not invoke the route handler. */
2303
- private async _handleCorsPreflight(
2304
- fc: FetchCtx<TEnv>,
2305
- path: string,
2306
- handler: RouteHandler,
2307
- params: Record<string, string>,
2308
- allowed: string[],
2309
- ): Promise<Response> {
2310
- const ctx = new HoneyContext({
2311
- env: fc.env,
2312
- executionCtx: fc.executionCtx,
2313
- meta: handler.mt ? Object.freeze(handler.mt) : undefined,
2314
- params,
2315
- path,
2316
- req: fc.request,
2317
- routePattern: handler.rp ?? "",
2318
- urlFn: fc.url,
2754
+ private async _make405(fc: FetchCtx<TEnv>, allowed: string[]): Promise<Response> {
2755
+ const s = this._s
2756
+ const res = s.onMethodNotAllowed
2757
+ ? await s.onMethodNotAllowed(this._makeErrorCtx(fc, allowed) as ErrorCtx<TEnv> & { allowed: string[] })
2758
+ : this._makeErrorCtx(fc).jsonFromError(this._createError(EK.method_not_allowed, SK.method_not_allowed))
2759
+ const responseHeaders = new Headers(res.headers)
2760
+ responseHeaders.set("allow", allowed.join(", "))
2761
+ return new Response(res.body, {
2762
+ headers: responseHeaders,
2763
+ status: res.status,
2319
2764
  })
2320
- if (this._contextValues) Object.assign(ctx, this._contextValues)
2321
- try {
2322
- return await executeChain([...this._globalMiddlewares, ...handler.mw], ctx, () =>
2323
- this._handle405(fc, "OPTIONS", path, allowed),
2324
- )
2325
- } catch (thrown) {
2326
- return this._toErrorResponse(thrown)
2327
- }
2328
- }
2329
-
2330
- private async _handle405(fc: FetchCtx<TEnv>, method: string, path: string, allowed: string[]): Promise<Response> {
2331
- safeFire(
2332
- () =>
2333
- this._telemetry?.onMethodNotAllowed?.({
2334
- allowed,
2335
- method,
2336
- path,
2337
- req: fc.request,
2338
- }),
2339
- fc.log,
2340
- )
2341
- const make405 = async () => {
2342
- if (this._onMethodNotAllowed) {
2343
- const res = await this._onMethodNotAllowed(
2344
- this._makeErrorCtx(fc, allowed) as {
2345
- allowed: string[]
2346
- env: TEnv
2347
- jsonFromError: (err: HoneyError) => Response
2348
- req: Request
2349
- },
2350
- )
2351
- const responseHeaders = new Headers(res.headers)
2352
- responseHeaders.set("allow", allowed.join(", "))
2353
- return new Response(res.body, {
2354
- headers: responseHeaders,
2355
- status: res.status,
2356
- })
2357
- }
2358
- const err = this._createError(EK.method_not_allowed, SK.method_not_allowed)
2359
- const res = this._makeErrorCtx(fc).jsonFromError(err)
2360
- const responseHeaders = new Headers(res.headers)
2361
- responseHeaders.set("allow", allowed.join(", "))
2362
- return new Response(res.body, {
2363
- headers: responseHeaders,
2364
- status: res.status,
2365
- })
2366
- }
2367
- try {
2368
- const ctx405 = new HoneyContext({
2369
- env: fc.env,
2370
- executionCtx: fc.executionCtx,
2371
- params: {},
2372
- req: fc.request,
2373
- urlFn: fc.url,
2374
- })
2375
- if (this._contextValues) Object.assign(ctx405, this._contextValues)
2376
- const finalRes =
2377
- this._chainMiddlewares.length > 0
2378
- ? await executeChain(this._chainMiddlewares, ctx405, make405)
2379
- : await make405()
2380
- safeFire(
2381
- () =>
2382
- this._telemetry?.onResponse?.({
2383
- duration: performance.now() - fc.startTime,
2384
- req: fc.request,
2385
- status: finalRes.status,
2386
- }),
2387
- fc.log,
2388
- )
2389
- return finalRes
2390
- } catch (thrown) {
2391
- return this._toErrorResponse(thrown)
2392
- }
2393
2765
  }
2394
2766
 
2395
- private _handleMatched(
2767
+ /**
2768
+ * CORS preflight for a path that has no OPTIONS route: run the chain of the route the
2769
+ * preflight asks about (`Access-Control-Request-Method`) — chain, scoped and route
2770
+ * middleware, never input validation or the handler. Middleware that answers the
2771
+ * preflight (cors) short-circuits; otherwise the answer is the 405 with `Allow`.
2772
+ */
2773
+ private _handlePreflight(
2396
2774
  fc: FetchCtx<TEnv>,
2397
- method: HttpMethod,
2398
- path: string,
2399
- handler: RouteHandler,
2775
+ plan: Plan,
2400
2776
  params: Record<string, string>,
2401
- stashedMeta?: Record<string, unknown> | null,
2777
+ allowed: string[],
2402
2778
  ): Response | Promise<Response> {
2403
- const { env, executionCtx, log, request } = fc
2404
-
2405
- /* resolve error factory — pre-computed ef preferred, else build/use global */
2406
- let errors: Record<string, (...args: never[]) => unknown> | undefined
2407
- if (handler.ef != null) {
2408
- errors = handler.ef
2409
- } else if (this._errorFactory !== null) {
2410
- if (handler.ek.size > 0) {
2411
- const factory = this._errorFactory as Record<string, (...args: never[]) => unknown>
2412
- const subset = Object.create(null) as Record<string, unknown>
2413
- for (const key of handler.ek) {
2414
- if (key in factory) {
2415
- subset[key] = factory[key]
2416
- }
2417
- }
2418
- errors = Object.freeze(subset) as Record<string, (...args: never[]) => unknown>
2419
- } else {
2420
- errors = this._errorFactory as Record<string, (...args: never[]) => unknown>
2421
- }
2422
- }
2423
-
2424
- /* meta: stashed meta from fn:null tree match takes priority over handler meta */
2425
- const resolvedMeta = stashedMeta ?? handler.mt
2426
-
2427
- const ctx = new HoneyContext({
2428
- env,
2429
- executionCtx,
2430
- meta: resolvedMeta ?? undefined,
2431
- params,
2432
- path,
2433
- req: request,
2434
- routePattern: handler.rp ?? "",
2435
- urlFn: fc.url,
2436
- })
2437
- if (this._contextValues) Object.assign(ctx, this._contextValues)
2438
- if (this._realtimeBus) {
2439
- const rtBus = this._realtimeBus
2440
- Object.assign(ctx, {
2441
- realtime: { publish: (topic: string, data: unknown) => rtBus.publish(topic, data) },
2442
- })
2443
- }
2444
- if (errors) {
2445
- ctx._setErrors(errors)
2446
- }
2779
+ fc.allowed = allowed
2780
+ const ctx = this._newCtx(fc, plan, params)
2781
+ const final = this._finalize()
2782
+ plan.pf ??= compileChain(
2783
+ plan.pfChain,
2784
+ (c) => {
2785
+ const rq = (c as HoneyContext<TEnv>)._rq as FetchCtx<TEnv>
2786
+ return this._make405(rq, rq.allowed ?? [])
2787
+ },
2788
+ final.convert,
2789
+ )
2790
+ return plan.pf(ctx)
2791
+ }
2447
2792
 
2448
- if (this._telemetry !== null) {
2793
+ private _handleMatched(fc: FetchCtx<TEnv>, plan: Plan, params: Record<string, string>): Response | Promise<Response> {
2794
+ const ctx = this._newCtx(fc, plan, params)
2795
+ const telemetry = this._graph.settings.telemetry
2796
+ if (telemetry !== null && telemetry.onRoute !== undefined) {
2449
2797
  try {
2450
- this._telemetry.onRoute?.({ method, params, path, req: request })
2798
+ telemetry.onRoute({ method: fc.method, params, path: fc.path, req: fc.request, route: plan.r.rp ?? "" })
2451
2799
  } catch {
2452
2800
  /* telemetry must not crash request */
2453
2801
  }
2454
2802
  }
2455
-
2456
- /*
2457
- * Tier 3: Use pre-compiled chain when possible.
2458
- * Compiled chains are cached on the handler — created once, reused per request.
2459
- * Falls back to dynamic assembly when telemetry wrapping, input validation, or
2460
- * scoped middleware is in play (scoped mw cannot be baked into the compiled cache
2461
- * because each route may match a different subset).
2462
- */
2463
- const hasTelemetryMw = this._telemetry?.onMiddleware !== undefined
2464
- const hasInputValidation = handler.iv != null
2465
-
2466
- /*
2467
- * Error resolver — stored on ctx so the cached handler wrapper can read it.
2468
- * Converts handler errors into error Responses inside the middleware chain,
2469
- * allowing all post-next() middleware code (headers, logging, timing) to run.
2470
- */
2471
- ctx._errorToResponse = (thrown: unknown) => this._resolveErrorResponse(thrown, handler, fc, method, path, ctx)
2472
-
2473
- const onError = (thrown: unknown): Response | Promise<Response> => {
2474
- if (ctx._errorToResponse) return ctx._errorToResponse(thrown)
2475
- return this._toErrorResponse(thrown)
2476
- }
2477
-
2478
- const after = (response: Response): Response | Promise<Response> => {
2479
- try {
2480
- const done = this._afterMatched(fc, method, path, handler, ctx, response)
2481
- if (done instanceof Promise) return done.catch(onError)
2482
- return done
2483
- } catch (thrown) {
2484
- return onError(thrown)
2485
- }
2486
- }
2487
-
2488
- try {
2489
- if (!hasTelemetryMw && !hasInputValidation && this._scopedMiddlewares.length === 0) {
2490
- if (!handler._compiled) {
2491
- const chainMw = this._chainMiddlewares
2492
- const handlerHasChain = chainMw.length > 0 && chainMw.every((mw, i) => handler.mw[i] === mw)
2493
- const allMw = handlerHasChain
2494
- ? [...this._globalMiddlewares, ...handler.mw]
2495
- : [...this._globalMiddlewares, ...chainMw, ...handler.mw]
2496
- handler._compiled = compileChain(allMw, (c) => {
2497
- try {
2498
- const result = handler.fn(c)
2499
- if (result instanceof Promise) {
2500
- return result.catch((thrown: unknown) => {
2501
- const hCtx = c as HoneyContext<TEnv>
2502
- if (hCtx._errorToResponse) return hCtx._errorToResponse(thrown)
2503
- throw thrown
2504
- })
2505
- }
2506
- return result
2507
- } catch (thrown) {
2508
- const hCtx = c as HoneyContext<TEnv>
2509
- if (hCtx._errorToResponse) return hCtx._errorToResponse(thrown)
2510
- throw thrown
2511
- }
2512
- })
2513
- }
2514
- const result = handler._compiled(ctx)
2515
- if (result instanceof Promise) return result.then(after, onError)
2516
- return after(result)
2517
- }
2518
-
2519
- const scopedForPath = this._filterScopedForPath(handler.rp ?? "")
2520
- const chainMw = this._chainMiddlewares
2521
- const handlerHasChain = chainMw.length > 0 && chainMw.every((mw, i) => handler.mw[i] === mw)
2522
- /*
2523
- * Ordering: [global → chain → scoped → handler-route-specific]
2524
- * When handlerHasChain, handler.mw = [chain..., routeSpecific...].
2525
- * Scoped must go after chain but before route-specific, so we split.
2526
- */
2527
- let allMiddlewares: RuntimeMiddleware[] = handlerHasChain
2528
- ? [
2529
- ...this._globalMiddlewares,
2530
- ...handler.mw.slice(0, chainMw.length),
2531
- ...scopedForPath,
2532
- ...handler.mw.slice(chainMw.length),
2533
- ]
2534
- : [...this._globalMiddlewares, ...chainMw, ...scopedForPath, ...handler.mw]
2535
-
2536
- if (hasTelemetryMw) {
2537
- const onMw = this._telemetry?.onMiddleware
2538
- if (onMw) {
2539
- allMiddlewares = allMiddlewares.map((mw) => {
2540
- const name = mw.name || "anonymous"
2541
- const wrapped: RuntimeMiddleware = async (wCtx, wNext) => {
2542
- const mwStart = performance.now()
2543
- try {
2544
- const res = await mw(wCtx, wNext)
2545
- safeFire(() => onMw({ duration: performance.now() - mwStart, name }), log)
2546
- return res
2547
- } catch (error) {
2548
- safeFire(
2549
- () =>
2550
- onMw({
2551
- duration: performance.now() - mwStart,
2552
- error,
2553
- name,
2554
- }),
2555
- log,
2556
- )
2557
- throw error
2558
- }
2559
- }
2560
- return wrapped
2561
- })
2562
- }
2563
- }
2564
-
2565
- if (hasInputValidation) {
2566
- const schemas = handler.iv
2567
- if (schemas) {
2568
- const inputMw: RuntimeMiddleware = async (inputCtx, inputNext) => {
2569
- const validated = await validateInput(schemas, inputCtx["req"] as Request, params)
2570
- return inputNext({ input: validated })
2571
- }
2572
- allMiddlewares.push(inputMw)
2573
- }
2574
- }
2575
-
2576
- return executeChain(allMiddlewares, ctx, (finalCtx) => {
2577
- try {
2578
- const result = handler.fn(finalCtx)
2579
- if (result instanceof Promise) {
2580
- return result.catch((thrown: unknown) => {
2581
- const hCtx = finalCtx as HoneyContext<TEnv>
2582
- if (hCtx._errorToResponse) return hCtx._errorToResponse(thrown)
2583
- throw thrown
2584
- })
2585
- }
2586
- return result
2587
- } catch (thrown) {
2588
- const hCtx = finalCtx as HoneyContext<TEnv>
2589
- if (hCtx._errorToResponse) return hCtx._errorToResponse(thrown)
2590
- throw thrown
2591
- }
2592
- }).then(after, onError)
2593
- } catch (thrown) {
2594
- /* safety net — middleware-level errors (input validation, middleware crash) */
2595
- return onError(thrown)
2596
- }
2597
- }
2598
-
2599
- private _afterMatched(
2600
- fc: FetchCtx<TEnv>,
2601
- method: HttpMethod,
2602
- path: string,
2603
- handler: RouteHandler,
2604
- ctx: HoneyContext<TEnv>,
2605
- response: Response,
2606
- ): Response | Promise<Response> {
2607
- const validateOut =
2608
- this._outputValidation === "always" ||
2609
- (this._outputValidation === "dev" &&
2610
- (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV !== "production")
2611
- if (handler.os && validateOut && response.body !== null && !ctx._isErrorResponse) {
2612
- return this._validateThenFinish(fc, method, path, handler, ctx, response)
2613
- }
2614
- return this._finishMatched(fc, method, path, handler, ctx, response)
2803
+ const res = plan.run(ctx)
2804
+ if (res instanceof Promise) return res.then((r) => this._finishMatched(fc, plan, ctx, r))
2805
+ return this._finishMatched(fc, plan, ctx, res)
2615
2806
  }
2616
2807
 
2617
- private async _validateThenFinish(
2618
- fc: FetchCtx<TEnv>,
2619
- method: HttpMethod,
2620
- path: string,
2621
- handler: RouteHandler,
2622
- ctx: HoneyContext<TEnv>,
2623
- response: Response,
2624
- ): Promise<Response> {
2808
+ /** Output validation, inside the chain: an invalid body becomes an error response the middleware sees. */
2809
+ private async _validateOutput(handler: RouteHandler, ctx: HoneyContext<TEnv>, response: Response): Promise<Response> {
2810
+ if (response === undefined || response === null || ctx._isErrorResponse) return response
2811
+ /* read the creation tag, never `response.body`: on Node that would build a stream per response */
2812
+ const kind = bodyKind(response)
2813
+ if (kind === "empty") return response
2625
2814
  const ct = response.headers.get("content-type")
2626
2815
 
2627
2816
  /* content-type mismatch check */
@@ -2636,30 +2825,33 @@ export class Honey<
2636
2825
  }
2637
2826
  }
2638
2827
 
2639
- /* JSON schema validation — read original, return clone (Bun clone() drains original) */
2640
- if (ct?.startsWith("application/json") && handler.ov) {
2828
+ /* JSON schema validation. A body Honey built from a string is validated from that string
2829
+ * (no stream, no tee); anything else is read from a clone (Bun clone() drains the original).
2830
+ * A stream Honey created (sse, stream, generate) is never buffered: it may never end. */
2831
+ if (ct?.startsWith("application/json") && handler.ov && !isProducedStream(response)) {
2641
2832
  const sk = codeToStatusKey[response.status]
2642
2833
  if (sk) {
2834
+ const raw = rawBodyOf(response)
2835
+ if (raw !== null) {
2836
+ const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw)
2837
+ await handler.ov(sk, text.length === 0 ? null : JSON.parse(text))
2838
+ return response
2839
+ }
2643
2840
  const forReturn = response.clone()
2644
2841
  const data: unknown = await response.json()
2645
2842
  await handler.ov(sk, data)
2646
- response = forReturn
2843
+ return forReturn
2647
2844
  }
2648
2845
  }
2649
- return this._finishMatched(fc, method, path, handler, ctx, response)
2846
+ return response
2650
2847
  }
2651
2848
 
2652
- private _finishMatched(
2653
- fc: FetchCtx<TEnv>,
2654
- method: HttpMethod,
2655
- path: string,
2656
- handler: RouteHandler,
2657
- ctx: HoneyContext<TEnv>,
2658
- response: Response,
2659
- ): Response {
2849
+ private _finishMatched(fc: FetchCtx<TEnv>, plan: Plan, ctx: HoneyContext<TEnv>, response: Response): Response {
2850
+ const s = this._graph.settings
2851
+ const handler = plan.r
2660
2852
  /* taps — fire after successful handler, non-blocking */
2661
- if (this._taps !== null && !ctx._isErrorResponse) {
2662
- const taps = this._taps
2853
+ if (plan.taps !== null && !ctx._isErrorResponse) {
2854
+ const taps = plan.taps
2663
2855
  const log = fc.log
2664
2856
 
2665
2857
  /* meta-driven taps — fire for each registered key found in route meta */
@@ -2669,8 +2861,8 @@ export class Honey<
2669
2861
  if (metaValue !== undefined) {
2670
2862
  ctx.background(
2671
2863
  Promise.resolve()
2672
- .then(() => tapFn(ctx, metaValue))
2673
- .catch((e) => log?.warn?.("tap failed", key, e)),
2864
+ .then(() => tapFn(ctx as unknown as TapContext<unknown>, metaValue))
2865
+ .catch((e) => log?.warn?.({ err: e, tap: key }, "tap failed")),
2674
2866
  )
2675
2867
  }
2676
2868
  }
@@ -2683,8 +2875,8 @@ export class Honey<
2683
2875
  if (tapFn !== undefined) {
2684
2876
  ctx.background(
2685
2877
  Promise.resolve()
2686
- .then(() => tapFn(ctx, pending.payload))
2687
- .catch((e) => log?.warn?.("tap failed", pending.key, e)),
2878
+ .then(() => tapFn(ctx as unknown as TapContext<unknown>, pending.payload))
2879
+ .catch((e) => log?.warn?.({ err: e, tap: pending.key }, "tap failed")),
2688
2880
  )
2689
2881
  }
2690
2882
  }
@@ -2692,31 +2884,21 @@ export class Honey<
2692
2884
  }
2693
2885
  }
2694
2886
 
2695
- if (this._telemetry !== null) {
2887
+ if (s.telemetry !== null) {
2696
2888
  try {
2697
2889
  const duration = performance.now() - fc.startTime
2698
- this._telemetry.onHandler?.({
2699
- duration,
2700
- method,
2701
- path,
2702
- status: response.status,
2703
- })
2704
- this._telemetry.onResponse?.({
2890
+ s.telemetry.onHandler?.({
2705
2891
  duration,
2706
- req: fc.request,
2892
+ method: fc.method,
2893
+ path: fc.path,
2894
+ route: handler.rp ?? "",
2707
2895
  status: response.status,
2708
2896
  })
2709
2897
  } catch {
2710
2898
  /* telemetry must never crash the response path */
2711
2899
  }
2712
2900
  }
2713
- /* HEAD responses must have empty body — preserve headers + status */
2714
- if (method === "HEAD") {
2715
- return new Response(null, {
2716
- headers: response.headers,
2717
- status: response.status,
2718
- })
2719
- }
2901
+ if (fc.method === "HEAD") return headResponse(response)
2720
2902
  return response
2721
2903
  }
2722
2904
  }
@@ -2810,15 +2992,6 @@ type ApplyScoped<TScopedMw extends readonly ScopedMwEntry[], TFullPath extends s
2810
2992
  ApplyScoped<Rest, TFullPath>
2811
2993
  : {}
2812
2994
 
2813
- /** @internal — runtime entry for a scoped middleware */
2814
- type ScopedEntry = {
2815
- /** merged full-path prefix (already rebased against basePath at .use time) */
2816
- prefix: string
2817
- mw: RuntimeMiddleware
2818
- /** cached from mw.errors at registration; undefined when none */
2819
- errors: readonly string[] | undefined
2820
- }
2821
-
2822
2995
  /** Apply typed params — override params with specific keys when route has :param segments */
2823
2996
  type ApplyParams<TCtx, TParams> = [keyof TParams] extends [string]
2824
2997
  ? string extends keyof TParams
@@ -2859,27 +3032,30 @@ type HandlerCtx<
2859
3032
 
2860
3033
  /** @internal — exposes private Honey members for RouteBuilder/WSRouteBuilder access */
2861
3034
  type HoneyInternal = {
3035
+ _addRoute(method: string, segments: readonly Segment[], record: RouteHandler): void
3036
+ _addWsRoute(segments: readonly Segment[], record: WSRouteHandler): void
2862
3037
  _factory: unknown
2863
- _markWsRoutes(): void
2864
- _registerStatic(key: string, handler: RouteHandler): void
3038
+ _settle(pending: PendingRoute): void
3039
+ _view(): { chain: RuntimeMiddleware[]; cv: Record<string, unknown> | null; own: unknown }
2865
3040
  }
2866
3041
 
2867
3042
  type RouteBuilderState<TParent> = {
2868
3043
  boundaryErrorKey: string | null
3044
+ /** keys the route declares itself (`.errors()`, `.boundary()`) */
2869
3045
  errorKeys: Set<string>
2870
3046
  extraMethods: (HttpMethod | "ALL")[] | null
2871
- handlerMap: Record<string, RouteHandler> | null
2872
3047
  inputSchemas: InputSchemasDef | null
3048
+ /** explicit meta — chain `.meta()` overlaid by route `.meta()` */
2873
3049
  meta: Record<string, unknown> | null
2874
- /** meta contributed by middleware — kept apart so explicit .meta() always outranks it */
2875
- mwMeta: Record<string, unknown> | null
2876
3050
  method: HttpMethod | "ALL"
3051
+ /** route-level middleware */
2877
3052
  middlewares: RuntimeMiddleware[]
2878
3053
  outputSchemas: OutputSchemaDef | null
2879
3054
  parent: TParent
2880
- parentMiddlewares: RuntimeMiddleware[]
3055
+ /** canonical full pattern */
2881
3056
  path: string
2882
- root: TreeNode
3057
+ /** reported at finalize until `.handler()` runs */
3058
+ pending: PendingRoute
2883
3059
  }
2884
3060
 
2885
3061
  /**
@@ -3224,84 +3400,38 @@ class RouteBuilder<
3224
3400
  }
3225
3401
 
3226
3402
  const isInternal = Symbol.for("honey.internal") in fn
3227
- if (isInternal) {
3228
- Object.defineProperty(fn, Symbol.for("honey.app"), { value: this._s.parent })
3229
- }
3403
+ /* a bound copy per app: one `spec()` or docs handler may be mounted on several apps */
3404
+ if (isInternal) fn = bindInternalHandler(fn, this._s.parent)
3230
3405
 
3231
- const routeHandler: RouteHandler = {
3406
+ const parent = this._s.parent as unknown as HoneyInternal
3407
+ const view = parent._view()
3408
+ const base: RouteHandler = {
3232
3409
  _skip: isInternal || undefined,
3233
- bek: this._s.boundaryErrorKey,
3234
- ef: null,
3235
- ek: this._s.errorKeys,
3410
+ bek: null,
3411
+ cm: [...view.chain],
3412
+ cv: view.cv,
3413
+ dk: new Set(this._s.errorKeys),
3414
+ /* provisional until finalize resolves scopes and app defaults */
3415
+ ek: new Set(this._s.errorKeys),
3236
3416
  fn: fn as (ctx: unknown) => Response | Promise<Response>,
3237
3417
  iv: this._s.inputSchemas,
3238
- mt: mergeContributedMeta(this._s.mwMeta, this._s.meta),
3239
- mw: [...this._s.parentMiddlewares, ...this._s.middlewares],
3418
+ mt: mergeContributedMeta(collectMiddlewareMeta([view.chain, this._s.middlewares]), this._s.meta),
3419
+ mw: [],
3240
3420
  os: this._s.outputSchemas,
3241
3421
  ov,
3242
- rp: this._s.path,
3243
- }
3244
-
3245
- /* patch mode: if handler map has this route, patch existing handler instead of insertRoute */
3246
- const handlerMap = this._s.handlerMap
3247
- if (handlerMap) {
3248
- const key = `${this._s.method} ${this._s.path}`
3249
- const existing = handlerMap[key]
3250
- if (existing) {
3251
- existing.fn = routeHandler.fn
3252
- existing.mw = routeHandler.mw
3253
- existing.iv = routeHandler.iv
3254
- existing.os = routeHandler.os
3255
- existing.ov = routeHandler.ov
3256
- /* pre-compute filtered error factory */
3257
- const factory = (this._s.parent as unknown as HoneyInternal)._factory
3258
- if (factory !== null && existing.ek.size > 0) {
3259
- const ef = Object.create(null) as Record<string, (...args: never[]) => unknown>
3260
- const fac = factory as Record<string, (...args: never[]) => unknown>
3261
- for (const k of existing.ek) {
3262
- const factoryFn = fac[k]
3263
- if (factoryFn !== undefined) {
3264
- ef[k] = factoryFn
3265
- }
3266
- }
3267
- existing.ef = Object.freeze(ef)
3268
- }
3269
- return this._s.parent as HandlerReturn<
3270
- TEnv,
3271
- TBaseCtx,
3272
- TRoutes,
3273
- TPath,
3274
- TMethod,
3275
- TInput,
3276
- TOutput,
3277
- TCtx,
3278
- TAccMeta,
3279
- TErrorFactory,
3280
- _TErrorKeys,
3281
- TDefaultErrors,
3282
- TMeta,
3283
- TBasePath,
3284
- TTaps,
3285
- TScopedMw
3286
- >
3287
- }
3422
+ own: view.own,
3423
+ rb: this._s.boundaryErrorKey,
3424
+ rm: [...this._s.middlewares],
3425
+ xm: this._s.meta ? { ...this._s.meta } : null,
3288
3426
  }
3289
3427
 
3290
- insertRoute(this._s.root, this._s.method, this._s.path, routeHandler)
3291
-
3292
- /* .on() extra methods — insert same handler for each additional method */
3293
- if (this._s.extraMethods) {
3294
- for (const m of this._s.extraMethods) {
3295
- insertRoute(this._s.root, m, this._s.path, routeHandler)
3296
- if (!this._s.path.includes(":") && !this._s.path.includes("*")) {
3297
- ;(this._s.parent as unknown as HoneyInternal)._registerStatic(`${m} ${this._s.path}`, routeHandler)
3298
- }
3299
- }
3300
- }
3301
-
3302
- /* populate static route map for O(1) lookup on non-parameterized routes */
3303
- if (!this._s.path.includes(":") && !this._s.path.includes("*")) {
3304
- ;(this._s.parent as unknown as HoneyInternal)._registerStatic(`${this._s.method} ${this._s.path}`, routeHandler)
3428
+ /* one record per method — `.on([...])` registers each under its own RouteId */
3429
+ parent._settle(this._s.pending)
3430
+ const segments = parsePattern(this._s.path)
3431
+ const methods = [this._s.method, ...(this._s.extraMethods ?? [])]
3432
+ for (const method of methods) assertBodySchemaAllowed(this._s.inputSchemas, method, this._s.path)
3433
+ for (let i = 0; i < methods.length; i++) {
3434
+ parent._addRoute(methods[i], segments, i === 0 ? base : copyRecord(base))
3305
3435
  }
3306
3436
  return this._s.parent as HandlerReturn<
3307
3437
  TEnv,
@@ -3355,7 +3485,7 @@ class RouteBuilder<
3355
3485
  TTaps,
3356
3486
  TScopedMw
3357
3487
  > {
3358
- const proxyHandler = createProxyHandler(config)
3488
+ const proxyHandler = requireFeature("proxy", ".proxy()").createProxyHandler(config)
3359
3489
  return this.handler(
3360
3490
  proxyHandler as (
3361
3491
  ctx: HandlerCtx<
@@ -3536,11 +3666,6 @@ class RouteBuilder<
3536
3666
  TTaps,
3537
3667
  TScopedMw
3538
3668
  > {
3539
- if (mw.errors) {
3540
- for (const k of mw.errors) {
3541
- this._s.errorKeys.add(k)
3542
- }
3543
- }
3544
3669
  return new RouteBuilder<
3545
3670
  TEnv,
3546
3671
  TCtx & TAdds,
@@ -3562,31 +3687,22 @@ class RouteBuilder<
3562
3687
  >({
3563
3688
  ...this._s,
3564
3689
  middlewares: [...this._s.middlewares, mw as RuntimeMiddleware],
3565
- mwMeta: mw.meta ? { ...this._s.mwMeta, ...mw.meta } : this._s.mwMeta,
3566
3690
  })
3567
3691
  }
3568
3692
  }
3569
3693
 
3570
3694
  type WSRouteBuilderState<TParent> = {
3571
- mwMeta?: Record<string, unknown> | null
3572
- boundaryErrorKey: string | null
3573
3695
  errorKeys: Set<string>
3574
3696
  inputSchemas: InputSchemasDef | null
3697
+ origins: WSOriginPolicy | null
3575
3698
  meta: Record<string, unknown> | null
3576
3699
  middlewares: RuntimeMiddleware[]
3577
3700
  parent: TParent
3578
- parentMiddlewares: RuntimeMiddleware[]
3579
3701
  path: string
3580
- root: TreeNode
3702
+ pending: PendingRoute
3581
3703
  }
3582
3704
 
3583
- class WSRouteBuilder<
3584
- TEnv,
3585
- TCtx,
3586
- TInput = {},
3587
- _TErrorKeys extends string = never,
3588
- TParent extends { _markWsRoutes(): void } = { _markWsRoutes(): void },
3589
- > {
3705
+ class WSRouteBuilder<TEnv, TCtx, TInput = {}, _TErrorKeys extends string = never, TParent = unknown> {
3590
3706
  private _s: WSRouteBuilderState<TParent>
3591
3707
 
3592
3708
  constructor(state: WSRouteBuilderState<TParent>) {
@@ -3608,17 +3724,27 @@ class WSRouteBuilder<
3608
3724
  }
3609
3725
 
3610
3726
  handler(wsHandler: WSHandler<TCtx>): TParent {
3727
+ const parent = this._s.parent as unknown as HoneyInternal
3728
+ const view = parent._view()
3611
3729
  const routeHandler: WSRouteHandler = {
3612
- bek: this._s.boundaryErrorKey,
3613
- ek: this._s.errorKeys,
3730
+ bek: null,
3731
+ cm: [...view.chain],
3732
+ cv: view.cv,
3733
+ dk: new Set(this._s.errorKeys),
3734
+ ek: new Set(),
3614
3735
  fn: wsHandler as WSHandler<unknown>,
3615
3736
  iv: this._s.inputSchemas,
3616
- mt: mergeContributedMeta(this._s.mwMeta, this._s.meta),
3617
- mw: [...this._s.parentMiddlewares, ...this._s.middlewares],
3737
+ mt: null,
3738
+ mw: [],
3739
+ og: this._s.origins,
3740
+ own: view.own,
3741
+ rb: null,
3742
+ rm: [...this._s.middlewares],
3618
3743
  rp: this._s.path,
3744
+ xm: this._s.meta ? { ...this._s.meta } : null,
3619
3745
  }
3620
- insertWsRoute(this._s.root, this._s.path, routeHandler)
3621
- ;(this._s.parent as unknown as HoneyInternal)._markWsRoutes()
3746
+ parent._settle(this._s.pending)
3747
+ parent._addWsRoute(parsePattern(this._s.path), routeHandler)
3622
3748
  return this._s.parent
3623
3749
  }
3624
3750
 
@@ -3632,20 +3758,26 @@ class WSRouteBuilder<
3632
3758
  }
3633
3759
 
3634
3760
  meta(meta: Record<string, unknown>): this {
3635
- this._s.meta = meta
3761
+ this._s.meta = { ...this._s.meta, ...meta }
3636
3762
  return this
3637
3763
  }
3638
3764
 
3765
+ /**
3766
+ * Browser origins allowed to open this socket: `"*"`, a list of origins
3767
+ * (`https://app.example.com`), or a predicate. Same-origin always passes. Without it, a
3768
+ * cross-origin upgrade that carries `Cookie` or `Authorization` gets 403.
3769
+ */
3770
+ origins(policy: WSOriginPolicy): WSRouteBuilder<TEnv, TCtx, TInput, _TErrorKeys, TParent> {
3771
+ return new WSRouteBuilder<TEnv, TCtx, TInput, _TErrorKeys, TParent>({
3772
+ ...this._s,
3773
+ origins: validateOriginPolicy(policy),
3774
+ })
3775
+ }
3776
+
3639
3777
  use<TAdds>(mw: MiddlewareFn<TCtx, TAdds>): WSRouteBuilder<TEnv, TCtx & TAdds, TInput, _TErrorKeys, TParent> {
3640
- if (mw.errors) {
3641
- for (const k of mw.errors) {
3642
- this._s.errorKeys.add(k)
3643
- }
3644
- }
3645
3778
  return new WSRouteBuilder<TEnv, TCtx & TAdds, TInput, _TErrorKeys, TParent>({
3646
3779
  ...this._s,
3647
3780
  middlewares: [...this._s.middlewares, mw as RuntimeMiddleware],
3648
- mwMeta: mw.meta ? { ...this._s.mwMeta, ...mw.meta } : this._s.mwMeta,
3649
3781
  })
3650
3782
  }
3651
3783
  }