@lovrozagar/honey 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (674) hide show
  1. package/README.md +514 -192
  2. package/bin/honey.js +25 -0
  3. package/dist/accepts.d.ts +7 -1
  4. package/dist/accepts.d.ts.map +1 -1
  5. package/dist/accepts.js +126 -0
  6. package/dist/accepts.js.map +1 -0
  7. package/dist/body-kind.d.ts +26 -0
  8. package/dist/body-kind.d.ts.map +1 -0
  9. package/dist/body-kind.js +70 -0
  10. package/dist/body-kind.js.map +1 -0
  11. package/dist/body-limit.d.ts +5 -1
  12. package/dist/body-limit.d.ts.map +1 -1
  13. package/dist/body-limit.js +126 -0
  14. package/dist/body-limit.js.map +1 -0
  15. package/dist/build/index.d.ts +19 -3
  16. package/dist/build/index.d.ts.map +1 -1
  17. package/dist/build/index.js +173 -0
  18. package/dist/build/index.js.map +1 -0
  19. package/dist/cli-args.d.ts +33 -0
  20. package/dist/cli-args.d.ts.map +1 -0
  21. package/dist/cli-args.js +102 -0
  22. package/dist/cli-args.js.map +1 -0
  23. package/dist/cli-go/auth.go +26 -0
  24. package/dist/cli-go/config.go +192 -0
  25. package/dist/cli-go/errors.go +75 -0
  26. package/dist/cli-go/multipart.go +79 -0
  27. package/dist/cli-go/output.go +163 -0
  28. package/dist/cli-go/root.go +30 -0
  29. package/dist/cli-go/stream.go +88 -0
  30. package/dist/cli-go/version.go +5 -0
  31. package/dist/cli.d.ts +1 -1
  32. package/dist/cli.js +209 -0
  33. package/dist/cli.js.map +1 -0
  34. package/dist/client/defaults.js +48 -0
  35. package/dist/client/defaults.js.map +1 -0
  36. package/dist/client/error.d.ts +35 -0
  37. package/dist/client/error.d.ts.map +1 -1
  38. package/dist/client/error.js +71 -0
  39. package/dist/client/error.js.map +1 -0
  40. package/dist/client/http.d.ts +67 -8
  41. package/dist/client/http.d.ts.map +1 -1
  42. package/dist/client/http.js +571 -0
  43. package/dist/client/http.js.map +1 -0
  44. package/dist/client/index.d.ts +3 -2
  45. package/dist/client/index.d.ts.map +1 -1
  46. package/dist/client/index.js +95 -0
  47. package/dist/client/index.js.map +1 -0
  48. package/dist/client/path.d.ts +26 -0
  49. package/dist/client/path.d.ts.map +1 -0
  50. package/dist/client/path.js +163 -0
  51. package/dist/client/path.js.map +1 -0
  52. package/dist/client/redirect-policy.d.ts +12 -0
  53. package/dist/client/redirect-policy.d.ts.map +1 -0
  54. package/dist/client/redirect-policy.js +20 -0
  55. package/dist/client/redirect-policy.js.map +1 -0
  56. package/dist/client/sdk.d.ts +36 -0
  57. package/dist/client/sdk.d.ts.map +1 -1
  58. package/dist/client/sdk.js +292 -0
  59. package/dist/client/sdk.js.map +1 -0
  60. package/dist/client/sse.d.ts +13 -0
  61. package/dist/client/sse.d.ts.map +1 -1
  62. package/dist/client/sse.js +129 -0
  63. package/dist/client/sse.js.map +1 -0
  64. package/dist/client/types.js +2 -0
  65. package/dist/client/types.js.map +1 -0
  66. package/dist/client/ws.d.ts +9 -2
  67. package/dist/client/ws.d.ts.map +1 -1
  68. package/dist/client/ws.js +112 -0
  69. package/dist/client/ws.js.map +1 -0
  70. package/dist/client-go/errors.go +236 -0
  71. package/dist/client-go/export.go +44 -0
  72. package/dist/client-go/invalidation.go +371 -0
  73. package/dist/client-go/nullable.go +62 -0
  74. package/dist/client-go/realtime.go +354 -0
  75. package/dist/client-go/result.go +20 -0
  76. package/dist/client-go/runtime.go +975 -0
  77. package/dist/client-go/sse.go +159 -0
  78. package/dist/client-go/transport.go +495 -0
  79. package/dist/client-go/ws.go +128 -0
  80. package/dist/client-info.d.ts +36 -0
  81. package/dist/client-info.d.ts.map +1 -0
  82. package/dist/client-info.js +53 -0
  83. package/dist/client-info.js.map +1 -0
  84. package/dist/client-mcp/README.md +10 -0
  85. package/dist/client-mcp/server.ts +221 -0
  86. package/dist/client-mcp/types.ts +19 -0
  87. package/dist/client-python/_errors.py +115 -0
  88. package/dist/client-python/_invalidation.py +494 -0
  89. package/dist/client-python/_realtime.py +179 -0
  90. package/dist/client-python/_runtime.py +860 -0
  91. package/dist/client-python/_sse.py +155 -0
  92. package/dist/client-python/_transport.py +366 -0
  93. package/dist/client-python/_ws.py +159 -0
  94. package/dist/client-python/py.typed +0 -0
  95. package/dist/client-rust/errors.rs +220 -0
  96. package/dist/client-rust/invalidation.rs +412 -0
  97. package/dist/client-rust/invalidation_sync.rs +93 -0
  98. package/dist/client-rust/nullable.rs +17 -0
  99. package/dist/client-rust/realtime.rs +702 -0
  100. package/dist/client-rust/result.rs +22 -0
  101. package/dist/client-rust/runtime.rs +980 -0
  102. package/dist/client-rust/runtime_sync.rs +484 -0
  103. package/dist/client-rust/sse.rs +188 -0
  104. package/dist/client-rust/ws.rs +167 -0
  105. package/dist/codegen-go-cli.d.ts.map +1 -1
  106. package/dist/codegen-go-cli.js +964 -0
  107. package/dist/codegen-go-cli.js.map +1 -0
  108. package/dist/codegen-go.d.ts +15 -3
  109. package/dist/codegen-go.d.ts.map +1 -1
  110. package/dist/codegen-go.js +872 -0
  111. package/dist/codegen-go.js.map +1 -0
  112. package/dist/codegen-hash.js +9 -0
  113. package/dist/codegen-hash.js.map +1 -0
  114. package/dist/codegen-ir.d.ts +69 -2
  115. package/dist/codegen-ir.d.ts.map +1 -1
  116. package/dist/codegen-ir.js +661 -0
  117. package/dist/codegen-ir.js.map +1 -0
  118. package/dist/codegen-lang.d.ts +65 -0
  119. package/dist/codegen-lang.d.ts.map +1 -0
  120. package/dist/codegen-lang.js +526 -0
  121. package/dist/codegen-lang.js.map +1 -0
  122. package/dist/codegen-load.d.ts +31 -0
  123. package/dist/codegen-load.d.ts.map +1 -0
  124. package/dist/codegen-load.js +138 -0
  125. package/dist/codegen-load.js.map +1 -0
  126. package/dist/codegen-loaders.js +55 -0
  127. package/dist/codegen-loaders.js.map +1 -0
  128. package/dist/codegen-mcp.d.ts.map +1 -1
  129. package/dist/codegen-mcp.js +368 -0
  130. package/dist/codegen-mcp.js.map +1 -0
  131. package/dist/codegen-python.d.ts +13 -1
  132. package/dist/codegen-python.d.ts.map +1 -1
  133. package/dist/codegen-python.js +828 -0
  134. package/dist/codegen-python.js.map +1 -0
  135. package/dist/codegen-route-tree-intern.d.ts +15 -5
  136. package/dist/codegen-route-tree-intern.d.ts.map +1 -1
  137. package/dist/codegen-route-tree-intern.js +221 -0
  138. package/dist/codegen-route-tree-intern.js.map +1 -0
  139. package/dist/codegen-rust.d.ts.map +1 -1
  140. package/dist/codegen-rust.js +1293 -0
  141. package/dist/codegen-rust.js.map +1 -0
  142. package/dist/codegen-sanitize.d.ts.map +1 -1
  143. package/dist/codegen-sanitize.js +56 -0
  144. package/dist/codegen-sanitize.js.map +1 -0
  145. package/dist/codegen-schema-naming.d.ts +5 -0
  146. package/dist/codegen-schema-naming.d.ts.map +1 -1
  147. package/dist/codegen-schema-naming.js +150 -0
  148. package/dist/codegen-schema-naming.js.map +1 -0
  149. package/dist/codegen-sdk-model.d.ts +71 -0
  150. package/dist/codegen-sdk-model.d.ts.map +1 -0
  151. package/dist/codegen-sdk-model.js +179 -0
  152. package/dist/codegen-sdk-model.js.map +1 -0
  153. package/dist/codegen.d.ts +41 -23
  154. package/dist/codegen.d.ts.map +1 -1
  155. package/dist/codegen.js +4610 -0
  156. package/dist/codegen.js.map +1 -0
  157. package/dist/context.d.ts +36 -6
  158. package/dist/context.d.ts.map +1 -1
  159. package/dist/context.js +242 -0
  160. package/dist/context.js.map +1 -0
  161. package/dist/cookie-sign.d.ts +16 -2
  162. package/dist/cookie-sign.d.ts.map +1 -1
  163. package/dist/cookie-sign.js +104 -0
  164. package/dist/cookie-sign.js.map +1 -0
  165. package/dist/cookie.d.ts +12 -0
  166. package/dist/cookie.d.ts.map +1 -1
  167. package/dist/cookie.js +138 -0
  168. package/dist/cookie.js.map +1 -0
  169. package/dist/cors.d.ts +1 -0
  170. package/dist/cors.d.ts.map +1 -1
  171. package/dist/cors.js +87 -0
  172. package/dist/cors.js.map +1 -0
  173. package/dist/crypto.d.ts +6 -1
  174. package/dist/crypto.d.ts.map +1 -1
  175. package/dist/crypto.js +38 -0
  176. package/dist/crypto.js.map +1 -0
  177. package/dist/csrf.d.ts +19 -0
  178. package/dist/csrf.d.ts.map +1 -1
  179. package/dist/csrf.js +79 -0
  180. package/dist/csrf.js.map +1 -0
  181. package/dist/curl-logger.d.ts +14 -3
  182. package/dist/curl-logger.d.ts.map +1 -1
  183. package/dist/curl-logger.js +199 -0
  184. package/dist/curl-logger.js.map +1 -0
  185. package/dist/detect-runtime.js +13 -0
  186. package/dist/detect-runtime.js.map +1 -0
  187. package/dist/dict.d.ts +11 -0
  188. package/dist/dict.d.ts.map +1 -0
  189. package/dist/dict.js +13 -0
  190. package/dist/dict.js.map +1 -0
  191. package/dist/error.d.ts.map +1 -1
  192. package/dist/error.js +44 -0
  193. package/dist/error.js.map +1 -0
  194. package/dist/errors.js +39 -0
  195. package/dist/errors.js.map +1 -0
  196. package/dist/etag.d.ts +2 -0
  197. package/dist/etag.d.ts.map +1 -1
  198. package/dist/etag.js +82 -0
  199. package/dist/etag.js.map +1 -0
  200. package/dist/feature-detect.d.ts +25 -0
  201. package/dist/feature-detect.d.ts.map +1 -0
  202. package/dist/feature-detect.js +144 -0
  203. package/dist/feature-detect.js.map +1 -0
  204. package/dist/feature-load.js +36 -0
  205. package/dist/feature-load.js.map +1 -0
  206. package/dist/feature-slots.d.ts +44 -0
  207. package/dist/feature-slots.d.ts.map +1 -0
  208. package/dist/feature-slots.js +48 -0
  209. package/dist/feature-slots.js.map +1 -0
  210. package/dist/fetch-request.d.ts +10 -0
  211. package/dist/fetch-request.d.ts.map +1 -0
  212. package/dist/fetch-request.js +13 -0
  213. package/dist/fetch-request.js.map +1 -0
  214. package/dist/gen-process.d.ts +15 -0
  215. package/dist/gen-process.d.ts.map +1 -0
  216. package/dist/gen-process.js +43 -0
  217. package/dist/gen-process.js.map +1 -0
  218. package/dist/gen-write.d.ts +19 -0
  219. package/dist/gen-write.d.ts.map +1 -0
  220. package/dist/gen-write.js +120 -0
  221. package/dist/gen-write.js.map +1 -0
  222. package/dist/glob.d.ts +21 -0
  223. package/dist/glob.d.ts.map +1 -0
  224. package/dist/glob.js +115 -0
  225. package/dist/glob.js.map +1 -0
  226. package/dist/go-type-emitter.d.ts +18 -4
  227. package/dist/go-type-emitter.d.ts.map +1 -1
  228. package/dist/go-type-emitter.js +594 -0
  229. package/dist/go-type-emitter.js.map +1 -0
  230. package/dist/honey-response.d.ts.map +1 -1
  231. package/dist/honey-response.js +338 -0
  232. package/dist/honey-response.js.map +1 -0
  233. package/dist/i18n-slot.d.ts +8 -0
  234. package/dist/i18n-slot.d.ts.map +1 -1
  235. package/dist/i18n-slot.js +14 -0
  236. package/dist/i18n-slot.js.map +1 -0
  237. package/dist/i18n.d.ts +3 -0
  238. package/dist/i18n.d.ts.map +1 -1
  239. package/dist/i18n.js +250 -0
  240. package/dist/i18n.js.map +1 -0
  241. package/dist/index.d.ts +408 -148
  242. package/dist/index.d.ts.map +1 -1
  243. package/dist/index.js +2324 -0
  244. package/dist/index.js.map +1 -0
  245. package/dist/init.d.ts +3 -5
  246. package/dist/init.d.ts.map +1 -1
  247. package/dist/init.js +140 -0
  248. package/dist/init.js.map +1 -0
  249. package/dist/input.js +4 -0
  250. package/dist/input.js.map +1 -0
  251. package/dist/invalidate-check.d.ts +11 -0
  252. package/dist/invalidate-check.d.ts.map +1 -1
  253. package/dist/invalidate-check.js +163 -0
  254. package/dist/invalidate-check.js.map +1 -0
  255. package/dist/invoke-user.d.ts +35 -0
  256. package/dist/invoke-user.d.ts.map +1 -0
  257. package/dist/invoke-user.js +73 -0
  258. package/dist/invoke-user.js.map +1 -0
  259. package/dist/ip-restrict.d.ts +13 -1
  260. package/dist/ip-restrict.d.ts.map +1 -1
  261. package/dist/ip-restrict.js +60 -0
  262. package/dist/ip-restrict.js.map +1 -0
  263. package/dist/ip.d.ts +39 -0
  264. package/dist/ip.d.ts.map +1 -0
  265. package/dist/ip.js +209 -0
  266. package/dist/ip.js.map +1 -0
  267. package/dist/logger.d.ts.map +1 -1
  268. package/dist/logger.js +113 -0
  269. package/dist/logger.js.map +1 -0
  270. package/dist/media-type.d.ts +30 -0
  271. package/dist/media-type.d.ts.map +1 -0
  272. package/dist/media-type.js +97 -0
  273. package/dist/media-type.js.map +1 -0
  274. package/dist/meta-spec-merge.d.ts +32 -0
  275. package/dist/meta-spec-merge.d.ts.map +1 -0
  276. package/dist/meta-spec-merge.js +68 -0
  277. package/dist/meta-spec-merge.js.map +1 -0
  278. package/dist/meta-spec.d.ts +16 -3
  279. package/dist/meta-spec.d.ts.map +1 -1
  280. package/dist/meta-spec.js +535 -0
  281. package/dist/meta-spec.js.map +1 -0
  282. package/dist/middleware.d.ts +23 -6
  283. package/dist/middleware.d.ts.map +1 -1
  284. package/dist/middleware.js +253 -0
  285. package/dist/middleware.js.map +1 -0
  286. package/dist/node-request.d.ts +57 -12
  287. package/dist/node-request.d.ts.map +1 -1
  288. package/dist/node-request.js +407 -0
  289. package/dist/node-request.js.map +1 -0
  290. package/dist/node.d.ts +20 -3
  291. package/dist/node.d.ts.map +1 -1
  292. package/dist/node.js +396 -0
  293. package/dist/node.js.map +1 -0
  294. package/dist/openapi/collect.d.ts +6 -3
  295. package/dist/openapi/collect.d.ts.map +1 -1
  296. package/dist/openapi/collect.js +30 -0
  297. package/dist/openapi/collect.js.map +1 -0
  298. package/dist/openapi/docs-page.d.ts +42 -0
  299. package/dist/openapi/docs-page.d.ts.map +1 -0
  300. package/dist/openapi/docs-page.js +80 -0
  301. package/dist/openapi/docs-page.js.map +1 -0
  302. package/dist/openapi/document.d.ts +4 -0
  303. package/dist/openapi/document.d.ts.map +1 -1
  304. package/dist/openapi/document.js +785 -0
  305. package/dist/openapi/document.js.map +1 -0
  306. package/dist/openapi/json-schema-slot.d.ts +7 -0
  307. package/dist/openapi/json-schema-slot.d.ts.map +1 -1
  308. package/dist/openapi/json-schema-slot.js +38 -0
  309. package/dist/openapi/json-schema-slot.js.map +1 -0
  310. package/dist/openapi/register.d.ts.map +1 -1
  311. package/dist/openapi/register.js +20 -0
  312. package/dist/openapi/register.js.map +1 -0
  313. package/dist/openapi/scalar.d.ts +3 -1
  314. package/dist/openapi/scalar.d.ts.map +1 -1
  315. package/dist/openapi/scalar.js +21 -0
  316. package/dist/openapi/scalar.js.map +1 -0
  317. package/dist/openapi/serve.d.ts +9 -0
  318. package/dist/openapi/serve.d.ts.map +1 -0
  319. package/dist/openapi/serve.js +36 -0
  320. package/dist/openapi/serve.js.map +1 -0
  321. package/dist/openapi/spec-factory.d.ts +30 -1
  322. package/dist/openapi/spec-factory.d.ts.map +1 -1
  323. package/dist/openapi/spec-factory.js +52 -0
  324. package/dist/openapi/spec-factory.js.map +1 -0
  325. package/dist/openapi/spec.d.ts +14 -5
  326. package/dist/openapi/spec.d.ts.map +1 -1
  327. package/dist/openapi/spec.js +50 -0
  328. package/dist/openapi/spec.js.map +1 -0
  329. package/dist/openapi/swagger.d.ts +3 -1
  330. package/dist/openapi/swagger.d.ts.map +1 -1
  331. package/dist/openapi/swagger.js +22 -0
  332. package/dist/openapi/swagger.js.map +1 -0
  333. package/dist/pattern.d.ts +71 -0
  334. package/dist/pattern.d.ts.map +1 -0
  335. package/dist/pattern.js +239 -0
  336. package/dist/pattern.js.map +1 -0
  337. package/dist/peer.d.ts +17 -0
  338. package/dist/peer.d.ts.map +1 -0
  339. package/dist/peer.js +50 -0
  340. package/dist/peer.js.map +1 -0
  341. package/dist/plugin.d.ts +67 -16
  342. package/dist/plugin.d.ts.map +1 -1
  343. package/dist/plugin.js +649 -0
  344. package/dist/plugin.js.map +1 -0
  345. package/dist/powered-by.d.ts.map +1 -1
  346. package/dist/powered-by.js +11 -0
  347. package/dist/powered-by.js.map +1 -0
  348. package/dist/pretty-json.d.ts.map +1 -1
  349. package/dist/pretty-json.js +47 -0
  350. package/dist/pretty-json.js.map +1 -0
  351. package/dist/producer-stream.d.ts +47 -0
  352. package/dist/producer-stream.d.ts.map +1 -0
  353. package/dist/producer-stream.js +267 -0
  354. package/dist/producer-stream.js.map +1 -0
  355. package/dist/proxy.d.ts +47 -8
  356. package/dist/proxy.d.ts.map +1 -1
  357. package/dist/proxy.js +274 -0
  358. package/dist/proxy.js.map +1 -0
  359. package/dist/python-type-emitter.d.ts +14 -1
  360. package/dist/python-type-emitter.d.ts.map +1 -1
  361. package/dist/python-type-emitter.js +157 -0
  362. package/dist/python-type-emitter.js.map +1 -0
  363. package/dist/realtime/bus.d.ts +17 -1
  364. package/dist/realtime/bus.d.ts.map +1 -1
  365. package/dist/realtime/bus.js +112 -0
  366. package/dist/realtime/bus.js.map +1 -0
  367. package/dist/realtime/register.d.ts +3 -0
  368. package/dist/realtime/register.d.ts.map +1 -0
  369. package/dist/realtime/register.js +10 -0
  370. package/dist/realtime/register.js.map +1 -0
  371. package/dist/realtime/route.d.ts +65 -26
  372. package/dist/realtime/route.d.ts.map +1 -1
  373. package/dist/realtime/route.js +72 -0
  374. package/dist/realtime/route.js.map +1 -0
  375. package/dist/realtime/server.d.ts +43 -0
  376. package/dist/realtime/server.d.ts.map +1 -0
  377. package/dist/realtime/server.js +255 -0
  378. package/dist/realtime/server.js.map +1 -0
  379. package/dist/request-id.d.ts +7 -0
  380. package/dist/request-id.d.ts.map +1 -1
  381. package/dist/request-id.js +16 -0
  382. package/dist/request-id.js.map +1 -0
  383. package/dist/request-limits.d.ts +3 -0
  384. package/dist/request-limits.d.ts.map +1 -0
  385. package/dist/request-limits.js +3 -0
  386. package/dist/request-limits.js.map +1 -0
  387. package/dist/request-path.d.ts +33 -0
  388. package/dist/request-path.d.ts.map +1 -0
  389. package/dist/request-path.js +178 -0
  390. package/dist/request-path.js.map +1 -0
  391. package/dist/request-to-curl.d.ts +7 -1
  392. package/dist/request-to-curl.d.ts.map +1 -1
  393. package/dist/request-to-curl.js +48 -0
  394. package/dist/request-to-curl.js.map +1 -0
  395. package/dist/response.d.ts +25 -2
  396. package/dist/response.d.ts.map +1 -1
  397. package/dist/response.js +313 -0
  398. package/dist/response.js.map +1 -0
  399. package/dist/rust-type-emitter.d.ts +14 -2
  400. package/dist/rust-type-emitter.d.ts.map +1 -1
  401. package/dist/rust-type-emitter.js +463 -0
  402. package/dist/rust-type-emitter.js.map +1 -0
  403. package/dist/secure-headers.d.ts.map +1 -1
  404. package/dist/secure-headers.js +53 -0
  405. package/dist/secure-headers.js.map +1 -0
  406. package/dist/serve-register.d.ts +4 -0
  407. package/dist/serve-register.d.ts.map +1 -1
  408. package/dist/serve-register.js +9 -0
  409. package/dist/serve-register.js.map +1 -0
  410. package/dist/serve-slot.d.ts +3 -0
  411. package/dist/serve-slot.d.ts.map +1 -1
  412. package/dist/serve-slot.js +52 -0
  413. package/dist/serve-slot.js.map +1 -0
  414. package/dist/serve.d.ts +18 -1
  415. package/dist/serve.d.ts.map +1 -1
  416. package/dist/serve.js +157 -0
  417. package/dist/serve.js.map +1 -0
  418. package/dist/server-timing.d.ts.map +1 -1
  419. package/dist/server-timing.js +51 -0
  420. package/dist/server-timing.js.map +1 -0
  421. package/dist/static.d.ts +9 -0
  422. package/dist/static.d.ts.map +1 -1
  423. package/dist/static.js +102 -0
  424. package/dist/static.js.map +1 -0
  425. package/dist/telemetry/otel.d.ts +2 -0
  426. package/dist/telemetry/otel.d.ts.map +1 -1
  427. package/dist/telemetry/otel.js +135 -0
  428. package/dist/telemetry/otel.js.map +1 -0
  429. package/dist/testing.d.ts +12 -0
  430. package/dist/testing.d.ts.map +1 -1
  431. package/dist/testing.js +124 -0
  432. package/dist/testing.js.map +1 -0
  433. package/dist/timeout.d.ts +6 -0
  434. package/dist/timeout.d.ts.map +1 -1
  435. package/dist/timeout.js +36 -0
  436. package/dist/timeout.js.map +1 -0
  437. package/dist/tree.d.ts +133 -36
  438. package/dist/tree.d.ts.map +1 -1
  439. package/dist/tree.js +437 -0
  440. package/dist/tree.js.map +1 -0
  441. package/dist/trust.d.ts +21 -0
  442. package/dist/trust.d.ts.map +1 -0
  443. package/dist/trust.js +100 -0
  444. package/dist/trust.js.map +1 -0
  445. package/dist/ts-type-emitter.d.ts.map +1 -1
  446. package/dist/ts-type-emitter.js +150 -0
  447. package/dist/ts-type-emitter.js.map +1 -0
  448. package/dist/type-emitter.d.ts +16 -2
  449. package/dist/type-emitter.d.ts.map +1 -1
  450. package/dist/type-emitter.js +729 -0
  451. package/dist/type-emitter.js.map +1 -0
  452. package/dist/type-extractor.d.ts +8 -4
  453. package/dist/type-extractor.d.ts.map +1 -1
  454. package/dist/type-extractor.js +776 -0
  455. package/dist/type-extractor.js.map +1 -0
  456. package/dist/types.d.ts +7 -0
  457. package/dist/types.d.ts.map +1 -1
  458. package/dist/types.js +96 -0
  459. package/dist/types.js.map +1 -0
  460. package/dist/validation.d.ts +14 -2
  461. package/dist/validation.d.ts.map +1 -1
  462. package/dist/validation.js +335 -0
  463. package/dist/validation.js.map +1 -0
  464. package/dist/with-headers.d.ts +15 -0
  465. package/dist/with-headers.d.ts.map +1 -0
  466. package/dist/with-headers.js +45 -0
  467. package/dist/with-headers.js.map +1 -0
  468. package/dist/ws/bun.d.ts +11 -5
  469. package/dist/ws/bun.d.ts.map +1 -1
  470. package/dist/ws/bun.js +82 -0
  471. package/dist/ws/bun.js.map +1 -0
  472. package/dist/ws/cloudflare.d.ts +25 -3
  473. package/dist/ws/cloudflare.d.ts.map +1 -1
  474. package/dist/ws/cloudflare.js +117 -0
  475. package/dist/ws/cloudflare.js.map +1 -0
  476. package/dist/ws/deno.d.ts +4 -4
  477. package/dist/ws/deno.d.ts.map +1 -1
  478. package/dist/ws/deno.js +133 -0
  479. package/dist/ws/deno.js.map +1 -0
  480. package/dist/ws/node.d.ts +20 -3
  481. package/dist/ws/node.d.ts.map +1 -1
  482. package/dist/ws/node.js +173 -0
  483. package/dist/ws/node.js.map +1 -0
  484. package/dist/ws/session.d.ts +11 -0
  485. package/dist/ws/session.d.ts.map +1 -0
  486. package/dist/ws/session.js +60 -0
  487. package/dist/ws/session.js.map +1 -0
  488. package/dist/ws/shared.d.ts +65 -0
  489. package/dist/ws/shared.d.ts.map +1 -0
  490. package/dist/ws/shared.js +103 -0
  491. package/dist/ws/shared.js.map +1 -0
  492. package/dist/ws-origin.d.ts +20 -0
  493. package/dist/ws-origin.d.ts.map +1 -0
  494. package/dist/ws-origin.js +41 -0
  495. package/dist/ws-origin.js.map +1 -0
  496. package/dist/yaml.d.ts +15 -1
  497. package/dist/yaml.d.ts.map +1 -1
  498. package/dist/yaml.js +101 -0
  499. package/dist/yaml.js.map +1 -0
  500. package/docs/invalidate-spec.md +13 -1
  501. package/docs/meta-spec.md +36 -12
  502. package/docs/packaging.md +43 -24
  503. package/docs/regression-matrix/README.md +32 -0
  504. package/docs/regression-matrix/review.md +44 -0
  505. package/docs/regression-matrix/ws1-3.md +97 -0
  506. package/docs/regression-matrix/ws4-8.md +107 -0
  507. package/docs/regression-matrix/ws9-12.md +266 -0
  508. package/docs/sdk.md +102 -52
  509. package/package.json +183 -52
  510. package/src/accepts.ts +91 -33
  511. package/src/body-kind.ts +67 -0
  512. package/src/body-limit.ts +102 -87
  513. package/src/build/index.ts +104 -54
  514. package/src/cli-args.ts +123 -0
  515. package/src/cli-go/auth.go +12 -1
  516. package/src/cli-go/config.go +29 -2
  517. package/src/cli-go/errors.go +28 -10
  518. package/src/cli-go/multipart.go +51 -27
  519. package/src/cli-go/output.go +58 -4
  520. package/src/cli-go/root.go +1 -1
  521. package/src/cli-go/version.go +2 -2
  522. package/src/cli.ts +154 -148
  523. package/src/client/error.ts +56 -1
  524. package/src/client/http.ts +479 -256
  525. package/src/client/index.ts +55 -17
  526. package/src/client/path.ts +169 -0
  527. package/src/client/redirect-policy.ts +19 -0
  528. package/src/client/sdk.ts +180 -101
  529. package/src/client/sse.ts +92 -66
  530. package/src/client/ws.ts +67 -31
  531. package/src/client-go/errors.go +41 -12
  532. package/src/client-go/export.go +44 -0
  533. package/src/client-go/invalidation.go +52 -54
  534. package/src/client-go/nullable.go +62 -0
  535. package/src/client-go/realtime.go +147 -110
  536. package/src/client-go/runtime.go +646 -88
  537. package/src/client-go/sse.go +104 -94
  538. package/src/client-go/transport.go +67 -27
  539. package/src/client-go/ws.go +49 -25
  540. package/src/client-info.ts +79 -0
  541. package/src/client-mcp/server.ts +133 -3
  542. package/src/client-python/_invalidation.py +207 -291
  543. package/src/client-python/_realtime.py +63 -40
  544. package/src/client-python/_runtime.py +501 -119
  545. package/src/client-python/_sse.py +130 -74
  546. package/src/client-python/_transport.py +37 -9
  547. package/src/client-python/_ws.py +50 -22
  548. package/src/client-rust/errors.rs +21 -1
  549. package/src/client-rust/invalidation.rs +202 -231
  550. package/src/client-rust/invalidation_sync.rs +16 -229
  551. package/src/client-rust/nullable.rs +17 -0
  552. package/src/client-rust/realtime.rs +217 -217
  553. package/src/client-rust/runtime.rs +602 -177
  554. package/src/client-rust/runtime_sync.rs +169 -145
  555. package/src/client-rust/sse.rs +142 -121
  556. package/src/client-rust/ws.rs +61 -37
  557. package/src/codegen-go-cli.ts +607 -728
  558. package/src/codegen-go.ts +710 -770
  559. package/src/codegen-ir.ts +381 -75
  560. package/src/codegen-lang.ts +517 -0
  561. package/src/codegen-load.ts +150 -0
  562. package/src/codegen-mcp.ts +207 -108
  563. package/src/codegen-python.ts +684 -816
  564. package/src/codegen-route-tree-intern.ts +51 -25
  565. package/src/codegen-rust.ts +946 -1240
  566. package/src/codegen-sanitize.ts +0 -4
  567. package/src/codegen-schema-naming.ts +19 -6
  568. package/src/codegen-sdk-model.ts +228 -0
  569. package/src/codegen.ts +1488 -1140
  570. package/src/context.ts +130 -11
  571. package/src/cookie-sign.ts +85 -16
  572. package/src/cookie.ts +66 -11
  573. package/src/cors.ts +55 -54
  574. package/src/crypto.ts +27 -5
  575. package/src/csrf.ts +57 -25
  576. package/src/curl-logger.ts +155 -114
  577. package/src/dict.ts +12 -0
  578. package/src/error.ts +4 -2
  579. package/src/etag.ts +58 -34
  580. package/src/feature-detect.ts +147 -0
  581. package/src/feature-slots.ts +70 -0
  582. package/src/fetch-request.ts +13 -0
  583. package/src/gen-process.ts +42 -0
  584. package/src/gen-write.ts +127 -0
  585. package/src/glob.ts +109 -0
  586. package/src/go-type-emitter.ts +406 -155
  587. package/src/honey-response.ts +36 -9
  588. package/src/i18n-slot.ts +10 -0
  589. package/src/i18n.ts +98 -23
  590. package/src/index.ts +1887 -1756
  591. package/src/init.ts +40 -28
  592. package/src/invalidate-check.ts +42 -4
  593. package/src/invoke-user.ts +95 -0
  594. package/src/ip-restrict.ts +47 -161
  595. package/src/ip.ts +194 -0
  596. package/src/logger.ts +65 -36
  597. package/src/media-type.ts +104 -0
  598. package/src/meta-spec-merge.ts +80 -0
  599. package/src/meta-spec.ts +0 -0
  600. package/src/middleware.ts +112 -12
  601. package/src/node-request.ts +242 -52
  602. package/src/node.ts +318 -75
  603. package/src/openapi/collect.ts +19 -44
  604. package/src/openapi/docs-page.ts +89 -0
  605. package/src/openapi/document.ts +503 -224
  606. package/src/openapi/json-schema-slot.ts +40 -0
  607. package/src/openapi/register.ts +7 -2
  608. package/src/openapi/scalar.ts +11 -10
  609. package/src/openapi/serve.ts +39 -0
  610. package/src/openapi/spec-factory.ts +50 -1
  611. package/src/openapi/spec.ts +53 -30
  612. package/src/openapi/swagger.ts +12 -11
  613. package/src/pattern.ts +245 -0
  614. package/src/peer.ts +53 -0
  615. package/src/plugin.ts +270 -185
  616. package/src/powered-by.ts +2 -2
  617. package/src/pretty-json.ts +25 -9
  618. package/src/producer-stream.ts +296 -0
  619. package/src/proxy.ts +253 -55
  620. package/src/python-type-emitter.ts +68 -56
  621. package/src/realtime/bus.ts +56 -9
  622. package/src/realtime/register.ts +13 -0
  623. package/src/realtime/route.ts +129 -80
  624. package/src/realtime/server.ts +298 -0
  625. package/src/request-id.ts +13 -3
  626. package/src/request-limits.ts +2 -0
  627. package/src/request-path.ts +165 -0
  628. package/src/request-to-curl.ts +28 -7
  629. package/src/response.ts +143 -103
  630. package/src/rust-type-emitter.ts +265 -170
  631. package/src/secure-headers.ts +8 -4
  632. package/src/serve-register.ts +4 -0
  633. package/src/serve-slot.ts +38 -0
  634. package/src/serve.ts +113 -25
  635. package/src/server-timing.ts +11 -7
  636. package/src/static.ts +85 -32
  637. package/src/telemetry/otel.ts +29 -7
  638. package/src/testing.ts +76 -14
  639. package/src/timeout.ts +15 -7
  640. package/src/tree.ts +405 -333
  641. package/src/trust.ts +117 -0
  642. package/src/ts-type-emitter.ts +18 -12
  643. package/src/type-emitter.ts +227 -89
  644. package/src/type-extractor.ts +562 -424
  645. package/src/types.ts +8 -0
  646. package/src/validation.ts +221 -96
  647. package/src/with-headers.ts +45 -0
  648. package/src/ws/bun.ts +53 -15
  649. package/src/ws/cloudflare.ts +81 -19
  650. package/src/ws/deno.ts +124 -36
  651. package/src/ws/node.ts +173 -79
  652. package/src/ws/session.ts +73 -0
  653. package/src/ws/shared.ts +140 -0
  654. package/src/ws-origin.ts +53 -0
  655. package/src/yaml.ts +45 -22
  656. package/dist/client-mcp/server.d.ts +0 -10
  657. package/dist/client-mcp/server.d.ts.map +0 -1
  658. package/dist/client-mcp/types.d.ts +0 -16
  659. package/dist/client-mcp/types.d.ts.map +0 -1
  660. package/dist/cloudflare/realtime.d.ts +0 -30
  661. package/dist/cloudflare/realtime.d.ts.map +0 -1
  662. package/dist/realtime/buffer.d.ts +0 -18
  663. package/dist/realtime/buffer.d.ts.map +0 -1
  664. package/dist/realtime/longpoll.d.ts +0 -23
  665. package/dist/realtime/longpoll.d.ts.map +0 -1
  666. package/dist/realtime/protocol.d.ts +0 -50
  667. package/dist/realtime/protocol.d.ts.map +0 -1
  668. package/dist/realtime/runtime.d.ts +0 -74
  669. package/dist/realtime/runtime.d.ts.map +0 -1
  670. package/src/cloudflare/realtime.ts +0 -234
  671. package/src/realtime/buffer.ts +0 -101
  672. package/src/realtime/longpoll.ts +0 -148
  673. package/src/realtime/protocol.ts +0 -128
  674. package/src/realtime/runtime.ts +0 -427
package/src/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> = {
@@ -162,53 +198,252 @@ type Logger = {
162
198
  warn?(objOrMsg: Record<string, unknown> | string, msg?: string): void
163
199
  }
164
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
247
+ }
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. */
165
275
  function mergePath(base: string, path: string): string {
166
- if (base === "/") return path
167
- if (path === "/") return base
168
- 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
169
298
  }
170
299
 
171
- /** Normalize a scope prefix: ensure leading slash, strip trailing slash (except "/"). */
172
- function normalizeScopePath(raw: string): string {
173
- let p = raw
174
- if (p.length === 0) return "/"
175
- if (p.charCodeAt(0) !== 47) p = `/${p}`
176
- if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1)
177
- 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>
178
314
  }
179
315
 
180
- /** Check whether fullPath falls under scope prefix — exact match or next char is '/'. */
181
- function scopeMatches(prefix: string, fullPath: string): boolean {
182
- if (prefix === "/") return true
183
- if (fullPath === prefix) return true
184
- if (fullPath.length <= prefix.length) return false
185
- if (fullPath.charCodeAt(prefix.length) !== 47) return false
186
- 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[]
187
332
  }
188
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
+ */
189
339
  type HoneyGraph = {
190
- handlerMap: Record<string, RouteHandler> | null
191
- hasRouteTree: boolean
192
- /** 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 */
193
355
  metaSpec: MetaSpecConfig | null
356
+ /** route builders still waiting for `.handler()` */
357
+ pending: Set<PendingRoute>
194
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>
195
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>
196
374
  }
197
375
 
198
- function walkTreeHandlers(root: TreeNode, cb: (h: RouteHandler) => void, wsCb?: (h: WSRouteHandler) => void): void {
199
- if (root.m) {
200
- 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(),
201
402
  }
202
- if (root.w) {
203
- for (const h of Object.values(root.w.m) as RouteHandler[]) cb(h)
204
- }
205
- if (root.ws && wsCb) wsCb(root.ws)
206
- for (const child of Object.values(root.s)) walkTreeHandlers(child, cb, wsCb)
207
- 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"
208
444
  }
209
445
 
210
446
  const EMPTY_PARAMS = EMPTY_OBJ as Record<string, string>
211
- const EMPTY_MW: RuntimeMiddleware[] = []
212
447
 
213
448
  function requestIsWsUpgrade(request: Request): boolean {
214
449
  try {
@@ -219,15 +454,12 @@ function requestIsWsUpgrade(request: Request): boolean {
219
454
  }
220
455
  }
221
456
 
222
- /** Find first '?' or '#' in url starting from pos */
223
- function findSearchOrHash(url: string, pos: number): number {
224
- for (let i = pos; i < url.length; i++) {
225
- const c = url.charCodeAt(i)
226
- if (c === 63 || c === 35) return i
227
- }
228
- 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
229
460
  }
230
461
 
462
+ /** `obj[key]` for an own key only; `undefined` for inherited names such as `constructor`. */
231
463
  function safeFire(fn: (() => unknown) | undefined, logger?: Logger): void {
232
464
  if (fn === undefined) return
233
465
  try {
@@ -242,14 +474,23 @@ function safeFire(fn: (() => unknown) | undefined, logger?: Logger): void {
242
474
  }
243
475
  }
244
476
 
245
- /** Internal context shared across extracted fetch sub-methods */
477
+ /** Per-request state, shared by the dispatcher, the error boundary and terminal handlers. */
246
478
  type FetchCtx<TEnv> = {
479
+ /** methods a 405 lists; also marks the miss pipeline as a 405 */
480
+ allowed: string[] | null
247
481
  env: TEnv
248
482
  executionCtx: { waitUntil?: (p: Promise<unknown>) => void } | undefined
249
483
  log: Logger | undefined
484
+ method: string
485
+ path: string
486
+ plan: Plan | null
250
487
  request: Request
251
488
  startTime: number
489
+ /** the app's trustProxy() setting, read by ctx.ip and clientInfo() */
490
+ trust: TrustSetting
252
491
  url: () => URL
492
+ /** matched websocket route, for the upgrade terminal */
493
+ ws: { handler: WSRouteHandler; params: Record<string, string> } | null
253
494
  /** Already known from fetch() before Deno.upgradeWebSocket consumes the request. */
254
495
  wsUpgrade?: boolean
255
496
  /** Headers copied before Deno.upgradeWebSocket closes the Request. */
@@ -282,11 +523,9 @@ function defaultErrorFormatter(_error: HoneyError, defaultShape: Record<string,
282
523
 
283
524
  type ErrorFormatterFn = ErrorFormatter
284
525
 
285
- const STATIC_CTX_RESERVED = new Set(["background", "cookies", "env", "headers", "params", "req", "res", "search"])
286
-
287
526
  /* errorKeys the framework throws on its own behalf — input/output validation, content negotiation,
288
- * routing. Always passes the boundary check; users never declare these via .errors(). */
289
- 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>([
290
529
  EK.validation_failed,
291
530
  EK.output_validation_failed,
292
531
  EK.output_content_type_mismatch,
@@ -298,8 +537,68 @@ const FRAMEWORK_EKS = new Set<(typeof EK)[keyof typeof EK]>([
298
537
  EK.request_timeout,
299
538
  EK.gateway_timeout,
300
539
  EK.bad_gateway,
540
+ EK.forbidden,
541
+ EK.bad_request,
542
+ EK.malformed_body,
301
543
  ])
302
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
+
303
602
  export class Honey<
304
603
  TEnv,
305
604
  TCtx = HoneyContext<TEnv>,
@@ -318,172 +617,95 @@ export class Honey<
318
617
  declare readonly $meta: TMeta
319
618
  declare readonly $routes: TRoutes
320
619
  declare readonly $taps: TTaps
620
+ /* view state — what routes registered through this handle capture */
321
621
  private _basePath: string
322
- private _defaultBoundaryKey: string | null
323
- private _defaultErrorKeys: Set<string>
324
- private _errorFactory: unknown
325
- private _errorSchema: StandardSchemaLike | null
326
- private _customErrorFormatter: CustomErrorFormatter | null
327
- private _customErrorSchema: StandardSchemaLike | null
622
+ private _chain: RuntimeMiddleware[]
328
623
  private _chainMeta: Record<string, unknown> | null
329
- private _chainMiddlewares: RuntimeMiddleware[]
330
- private _scopedMiddlewares: ScopedEntry[]
331
624
  private _contextValues: Record<string, unknown> | null
332
- private _errorFormatter: ErrorFormatterFn
333
- private _errorI18n: ErrorI18nConfig<TEnv> | null
334
- private _globalMiddlewares: RuntimeMiddleware[]
335
625
  private _graph: HoneyGraph
336
- private _hasWsRoutes: boolean
337
- private _logger: Logger | null
338
- private _outputValidation: "always" | "dev" | "off"
339
- private _staticRoutes: { map: Record<string, RouteHandler> | null }
340
- private _stripPrefix: string | null
341
- private _trailingSlash: "enforce" | "ignore" | "strip"
342
- private _wsAdapter: WSAdapter | null
343
- private _openApiCache: { epoch: number; value: Promise<unknown> } | null
344
- private _openApiYamlCache: { epoch: number; value: Promise<string> } | null
345
- private _manifestCache: { epoch: number; value: Promise<unknown> } | null
346
- private _onError:
347
- | ((
348
- error: unknown,
349
- ctx: {
350
- env: TEnv
351
- jsonFromError: (err: HoneyError) => Response
352
- req: Request
353
- },
354
- ) => HoneyError | Response | Promise<HoneyError | Response | undefined | void> | undefined | void)
355
- | null
356
- private _onMethodNotAllowed:
357
- | ((ctx: {
358
- allowed: string[]
359
- env: TEnv
360
- jsonFromError: (err: HoneyError) => Response
361
- req: Request
362
- }) => Response | Promise<Response>)
363
- | null
364
- private _onNotFound:
365
- | ((ctx: { env: TEnv; jsonFromError: (err: HoneyError) => Response; req: Request }) => Response | Promise<Response>)
366
- | null
367
- private _taps: Map<string, (ctx: TapContext<TEnv>, payload: unknown) => void | Promise<void>> | null
368
- private _telemetry: TelemetryAdapter | null
369
- private _realtimeRoutes: Map<
370
- string,
371
- { handler: RealtimeRouteOpts["handler"]; middlewares?: RealtimeRouteOpts["use"]; reconnectBuffer?: number }
372
- >
626
+ /** the `use(mw)` call that created this chain — null on the root handle */
627
+ private _node: ChainNode | null
373
628
 
374
- constructor(opts?: {
375
- chainMiddlewares?: RuntimeMiddleware[]
376
- defaultErrorKeys?: Set<string>
377
- globalMiddlewares?: RuntimeMiddleware[]
378
- graph?: HoneyGraph
379
- handlerMap?: Record<string, RouteHandler> | null
380
- root?: TreeNode
381
- scopedMiddlewares?: ScopedEntry[]
382
- }) {
629
+ constructor(opts?: { graph?: HoneyGraph }) {
383
630
  this._basePath = "/"
384
- this._graph = opts?.graph ?? {
385
- handlerMap: opts?.handlerMap ?? null,
386
- hasRouteTree: false,
387
- metaSpec: null,
388
- realtimeBus: null,
389
- root: opts?.root ?? createNode(),
390
- }
391
- this._globalMiddlewares = opts?.globalMiddlewares ?? []
392
- this._scopedMiddlewares = opts?.scopedMiddlewares ?? []
631
+ this._chain = []
393
632
  this._chainMeta = null
394
- this._chainMiddlewares = opts?.chainMiddlewares ?? []
395
633
  this._contextValues = null
396
- this._defaultBoundaryKey = null
397
- this._defaultErrorKeys = opts?.defaultErrorKeys ?? new Set()
398
- this._errorFactory = null
399
- this._errorSchema = null
400
- this._customErrorFormatter = null
401
- this._customErrorSchema = null
402
- this._errorFormatter = defaultErrorFormatter
403
- this._errorI18n = null
404
- this._hasWsRoutes = false
405
- this._logger = null
406
- this._outputValidation = "off"
407
- this._staticRoutes = { map: null }
408
- this._stripPrefix = null
409
- this._trailingSlash = "ignore"
410
- this._wsAdapter = null
411
- this._openApiCache = null
412
- this._openApiYamlCache = null
413
- this._manifestCache = null
414
- this._onError = null
415
- this._onNotFound = null
416
- this._onMethodNotAllowed = null
417
- this._taps = null
418
- this._telemetry = null
419
- this._realtimeRoutes = new Map()
420
- }
421
-
422
- private get _handlerMap(): Record<string, RouteHandler> | null {
423
- return this._graph.handlerMap
424
- }
425
- private set _handlerMap(value: Record<string, RouteHandler> | null) {
426
- this._graph.handlerMap = value
427
- }
428
-
429
- private get _hasRouteTree(): boolean {
430
- return this._graph.hasRouteTree
431
- }
432
- private set _hasRouteTree(value: boolean) {
433
- this._graph.hasRouteTree = value
434
- }
435
-
436
- /** @internal — read by codegen */
437
- private get _metaSpec(): MetaSpecConfig | null {
438
- return this._graph.metaSpec
439
- }
440
- private set _metaSpec(value: MetaSpecConfig | null) {
441
- this._graph.metaSpec = value
634
+ this._graph = opts?.graph ?? createGraph()
635
+ this._node = null
442
636
  }
443
637
 
444
- private get _root(): TreeNode {
445
- 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 }
446
649
  }
447
- private set _root(value: TreeNode) {
448
- 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
449
663
  }
450
664
 
451
- /** @internal — used by codegen */
452
- private get _tree(): TreeNode {
453
- 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
454
672
  }
455
673
 
456
674
  private get _realtimeBus(): RealtimeBus | null {
457
675
  return this._graph.realtimeBus
458
676
  }
459
- private set _realtimeBus(value: RealtimeBus | null) {
460
- this._graph.realtimeBus = value
461
- }
462
677
 
463
- /** @internal — mark that this app has WS routes */
464
- _markWsRoutes(): void {
465
- 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
466
687
  }
467
688
 
468
- /** @internal — register a static route for O(1) lookup */
469
- _registerStatic(key: string, handler: RouteHandler): void {
470
- if (this._staticRoutes.map === null) {
471
- 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
472
695
  }
473
- this._staticRoutes.map[key] = handler
474
696
  }
475
697
 
476
698
  /** @internal — used by RouteBuilder for pre-filtered error factory */
477
699
  get _factory(): unknown {
478
- return this._errorFactory
700
+ return this._graph.settings.errorFactory
479
701
  }
480
702
 
481
703
  /** @internal — used by runtime error boundary */
482
704
  get _boundaryKey(): string | null {
483
- return this._defaultBoundaryKey
705
+ return this._graph.settings.defaultBoundaryKey
484
706
  }
485
707
 
486
- /** 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. */
487
709
  private _toErrorResponse(thrown: unknown): Response {
488
710
  const error =
489
711
  thrown instanceof HoneyError
@@ -493,14 +715,12 @@ export class Honey<
493
715
  errorKey: EK.internal_server_error,
494
716
  status: SK.internal_server_error,
495
717
  })
496
- return createErrorResponse(error, this._errorFormatter, this._customErrorFormatter)
718
+ const s = this._s
719
+ return createErrorResponse(error, s.errorFormatter, s.customErrorFormatter)
497
720
  }
498
721
 
499
- private _createBoundaryError(errorKey: string, cause: unknown): HoneyError {
500
- const factory = this._errorFactory as Record<
501
- string,
502
- ((opts?: { cause?: unknown }) => HoneyError) | undefined
503
- > | 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
504
724
  const factoryFn = factory?.[errorKey]
505
725
  if (factoryFn) {
506
726
  /* check if this is a custom schema error via ERROR_META — boundary must use standard errors only */
@@ -519,7 +739,7 @@ export class Honey<
519
739
  }
520
740
 
521
741
  private _createError(errorKey: string, statusKey: StatusKey): HoneyError {
522
- const factory = this._errorFactory as Record<string, (() => HoneyError) | undefined> | null
742
+ const factory = this._s.errorFactory as Record<string, (() => HoneyError) | undefined> | null
523
743
  const factoryFn = factory?.[errorKey]
524
744
  if (factoryFn) {
525
745
  return factoryFn()
@@ -527,85 +747,23 @@ export class Honey<
527
747
  return new HoneyError({ errorKey, status: statusKey })
528
748
  }
529
749
 
530
- /** Apply error keys from a single scoped entry to every matching handler currently in the tree */
531
- private _applyScopedEntryErrors(entry: ScopedEntry): void {
532
- const errors = entry.errors
533
- if (!errors || errors.length === 0) return
534
- walkTreeHandlers(
535
- this._root,
536
- (h) => {
537
- if (scopeMatches(entry.prefix, h.rp ?? "")) {
538
- for (const k of errors) h.ek.add(k)
539
- }
540
- },
541
- (wh) => {
542
- if (scopeMatches(entry.prefix, wh.rp)) {
543
- for (const k of errors) wh.ek.add(k)
544
- }
545
- },
546
- )
547
- }
548
-
549
750
  /**
550
- * Meta contributed by every middleware that will run for `routePath`, in runtime order:
551
- * global, chain, then scoped entries matching the path. Route-level `.use()` is folded in
552
- * later, by the builder. Resolved here — at registration — because the precompiled route
553
- * 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.
554
755
  */
555
- private _contributedMetaFor(routePath: string): Record<string, unknown> | null {
556
- const scoped: RuntimeMiddleware[] = []
557
- for (const entry of this._scopedMiddlewares) {
558
- if (scopeMatches(entry.prefix, routePath)) scoped.push(entry.mw)
559
- }
560
- return collectMiddlewareMeta([this._globalMiddlewares, this._chainMiddlewares, scoped])
561
- }
562
-
563
- /**
564
- * Back-fill meta from one scoped entry onto handlers already in the tree. Mirrors
565
- * `_applyScopedEntryErrors` — `.use("/prefix", mw)` may be registered after the routes it
566
- * covers, and a tag missing where enforcement happens is the worst failure direction.
567
- * `mt` is frozen at registration, so this replaces the object rather than mutating it.
568
- */
569
- private _applyScopedEntryMeta(entry: ScopedEntry): void {
570
- const meta = (entry.mw as { meta?: Record<string, unknown> }).meta
571
- if (!meta) return
572
- const apply = (h: { mt: Record<string, unknown> | null; rp?: string }): void => {
573
- if (!scopeMatches(entry.prefix, h.rp ?? "")) return
574
- /* contributed meta never overwrites what the route or chain stated explicitly */
575
- h.mt = Object.freeze(h.mt ? { ...meta, ...h.mt } : { ...meta })
576
- }
577
- walkTreeHandlers(this._root, apply, apply)
578
- }
579
-
580
- /** Apply error keys from every scoped entry on this chain to every matching handler in the tree */
581
- private _applyAllScopedErrors(): void {
582
- for (const entry of this._scopedMiddlewares) {
583
- this._applyScopedEntryErrors(entry)
584
- }
585
- }
586
-
587
- /** Return scoped middleware functions that match the given route path */
588
- private _filterScopedForPath(routePath: string): RuntimeMiddleware[] {
589
- if (this._scopedMiddlewares.length === 0) return EMPTY_MW
590
- const out: RuntimeMiddleware[] = []
591
- for (const s of this._scopedMiddlewares) {
592
- if (scopeMatches(s.prefix, routePath)) out.push(s.mw)
593
- }
594
- return out
595
- }
596
-
597
- /** Mutates `honeyError.message` in-place with the i18n-resolved template for its errorKey.
598
- * No-op when i18n is not configured or when no template matches. */
599
756
  private async _resolveI18n(
600
757
  honeyError: HoneyError,
601
758
  ctx: HoneyContext<TEnv>,
602
759
  env: TEnv,
603
760
  request: Request,
604
761
  log?: Logger,
605
- ): Promise<void> {
606
- if (!this._errorI18n) return
762
+ ): Promise<HoneyError> {
763
+ const i18n = this._s.errorI18n
764
+ if (!i18n) return honeyError
607
765
  try {
608
- const locale = await this._errorI18n.resolveLocale({
766
+ const locale = await i18n.resolveLocale({
609
767
  cookies: ctx.cookies,
610
768
  env,
611
769
  headers: ctx.headers,
@@ -613,195 +771,121 @@ export class Honey<
613
771
  req: request,
614
772
  search: ctx.search,
615
773
  })
616
- const translations = this._errorI18n.errors?.[locale]
617
- if (translations) {
618
- const template = translations[honeyError.errorKey]
619
- if (template) {
620
- await loadHoneyFeature("i18n")
621
- honeyError.message = getI18nRuntime().interpolate(template, honeyError.vars ?? {})
622
- }
623
- }
624
-
625
- const fieldTranslations = this._errorI18n.fieldNames?.[locale]
626
- if (fieldTranslations && Object.keys(honeyError.fields).length > 0) {
627
- for (const fieldErrors of Object.values(honeyError.fields)) {
628
- for (const fe of fieldErrors) {
629
- let candidate = fe.path
630
- while (candidate) {
631
- const translated = fieldTranslations[candidate]
632
- if (translated) {
633
- fe.path = translated
634
- break
635
- }
636
- const dotIdx = candidate.indexOf(".")
637
- if (dotIdx === -1) break
638
- candidate = candidate.slice(dotIdx + 1)
639
- }
640
- }
641
- }
642
- }
774
+ await loadHoneyFeature("i18n")
775
+ return getI18nRuntime().translateError(honeyError, i18n, locale)
643
776
  } catch (e) {
644
777
  log?.warn?.({ err: e }, "i18n resolution failed")
778
+ return honeyError
779
+ }
780
+ }
781
+
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)
800
+ } catch (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
+ })
645
814
  }
646
815
  }
647
816
 
648
817
  /**
649
- * Convert thrown value into an error Response — resolves boundary wrapping,
650
- * i18n translation, onError callback, and telemetry.
651
- * 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.
652
821
  */
653
822
  private async _resolveErrorResponse(
654
823
  thrown: unknown,
655
- handler: RouteHandler,
824
+ plan: Plan,
656
825
  fc: FetchCtx<TEnv>,
657
- method: string,
658
- path: string,
659
826
  ctx: HoneyContext<TEnv>,
660
827
  ): Promise<Response> {
661
- 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
662
832
  let honeyError: HoneyError
663
- const boundaryKey = handler.bek ?? this._defaultBoundaryKey
833
+ const boundaryKey = record.bek
664
834
 
665
835
  if (thrown instanceof HoneyError) {
666
836
  /* framework-managed errorKeys (input/output validation, content negotiation, etc.) are always allowed
667
- * regardless of handler.ek — users never declare them, the framework owns them. */
668
- const isFrameworkEk = FRAMEWORK_EKS.has(thrown.errorKey as (typeof EK)[keyof typeof EK])
669
- if (!isFrameworkEk && handler.ek.size > 0 && !handler.ek.has(thrown.errorKey)) {
670
- if (boundaryKey) {
671
- honeyError = this._createBoundaryError(boundaryKey, thrown)
672
- } else {
673
- honeyError = new HoneyError({
674
- cause: thrown,
675
- errorKey: EK.internal_server_error,
676
- status: SK.internal_server_error,
677
- })
678
- }
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 })
679
842
  } else {
680
843
  honeyError = thrown
681
844
  }
682
845
  } else {
683
- if (boundaryKey) {
684
- honeyError = this._createBoundaryError(boundaryKey, thrown)
685
- } else {
686
- honeyError = new HoneyError({
687
- cause: thrown,
688
- errorKey: EK.internal_server_error,
689
- status: SK.internal_server_error,
690
- })
691
- }
846
+ honeyError = boundaryKey
847
+ ? this._createBoundaryError(boundaryKey, thrown, fac)
848
+ : new HoneyError({ cause: thrown, errorKey: EK.internal_server_error, status: SK.internal_server_error })
692
849
  }
693
850
 
694
- await this._resolveI18n(honeyError, ctx, env, request, log)
851
+ honeyError = await this._resolveI18n(honeyError, ctx, env, request, log)
695
852
 
696
- /* onError handler */
697
- if (this._onError) {
853
+ if (s.onError) {
698
854
  try {
699
- const customResult = await this._onError(thrown, this._makeErrorCtx(fc))
855
+ const customResult = await s.onError(thrown, this._makeErrorCtx(fc))
700
856
  if (customResult instanceof HoneyError) {
701
857
  /* user-mapped boundary error — re-run i18n against new errorKey,
702
- * then fall through to default response path so telemetry +
703
- * jsonFromError run exactly once. */
704
- honeyError = customResult
705
- 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)
706
860
  } else if (customResult) {
707
861
  safeFire(
708
- () =>
709
- this._telemetry?.onError?.({
710
- duration: performance.now() - startTime,
711
- error: honeyError,
712
- method,
713
- path,
714
- }),
715
- log,
716
- )
717
- safeFire(
718
- () =>
719
- this._telemetry?.onResponse?.({
720
- duration: performance.now() - startTime,
721
- req: request,
722
- status: customResult.status,
723
- }),
862
+ () => s.telemetry?.onError?.({ duration: performance.now() - startTime, error: honeyError, method, path }),
724
863
  log,
725
864
  )
865
+ setCtxError(ctx, honeyError)
726
866
  ctx._isErrorResponse = true
727
867
  return customResult
728
868
  }
729
869
  /* customResult === undefined | void → fall through to default path */
730
- } catch {
731
- /* swallow onError errors */
870
+ } catch (e) {
871
+ log?.warn?.({ err: e }, "onError failed")
732
872
  }
733
873
  }
734
874
 
735
- /* default error response */
736
875
  safeFire(
737
- () =>
738
- this._telemetry?.onError?.({
739
- duration: performance.now() - startTime,
740
- error: honeyError,
741
- method,
742
- path,
743
- }),
744
- log,
745
- )
746
- const res = this._makeErrorCtx(fc).jsonFromError(honeyError)
747
- safeFire(
748
- () =>
749
- this._telemetry?.onResponse?.({
750
- duration: performance.now() - startTime,
751
- req: request,
752
- status: res.status,
753
- }),
876
+ () => s.telemetry?.onError?.({ duration: performance.now() - startTime, error: honeyError, method, path }),
754
877
  log,
755
878
  )
879
+ setCtxError(ctx, honeyError)
756
880
  ctx._isErrorResponse = true
757
- return res
881
+ return this._makeErrorCtx(fc).jsonFromError(honeyError)
758
882
  }
759
883
 
760
884
  basePath<P extends string>(
761
885
  prefix: P,
762
886
  ): Honey<TEnv, TCtx, TRoutes, TMeta, TErrorFactory, TDefaultErrors, MergePath<TBasePath, P>, TTaps, TScopedMw> {
763
- const next = new Honey<
764
- TEnv,
765
- TCtx,
766
- TRoutes,
767
- TMeta,
768
- TErrorFactory,
769
- TDefaultErrors,
770
- MergePath<TBasePath, P>,
771
- TTaps,
772
- TScopedMw
773
- >({
774
- chainMiddlewares: this._chainMiddlewares,
775
- defaultErrorKeys: this._defaultErrorKeys,
776
- globalMiddlewares: this._globalMiddlewares,
777
- graph: this._graph,
778
- scopedMiddlewares: this._scopedMiddlewares,
779
- })
887
+ const next = this._derive()
780
888
  next._basePath = mergePath(this._basePath, prefix)
781
- next._defaultBoundaryKey = this._defaultBoundaryKey
782
- next._errorFactory = this._errorFactory
783
- next._errorSchema = this._errorSchema
784
- next._customErrorFormatter = this._customErrorFormatter
785
- next._customErrorSchema = this._customErrorSchema
786
- next._errorFormatter = this._errorFormatter
787
- next._errorI18n = this._errorI18n
788
- next._logger = this._logger
789
- next._outputValidation = this._outputValidation
790
- next._stripPrefix = this._stripPrefix
791
- next._trailingSlash = this._trailingSlash
792
- next._wsAdapter = this._wsAdapter
793
- next._onError = this._onError
794
- next._onNotFound = this._onNotFound
795
- next._onMethodNotAllowed = this._onMethodNotAllowed
796
- next._chainMeta = this._chainMeta
797
- next._contextValues = this._contextValues
798
- next._taps = this._taps
799
- next._telemetry = this._telemetry
800
- next._realtimeBus = this._realtimeBus
801
- next._realtimeRoutes = this._realtimeRoutes
802
- next._staticRoutes = this._staticRoutes
803
- next._hasRouteTree = this._hasRouteTree
804
- next._hasWsRoutes = this._hasWsRoutes
805
889
  return next as unknown as Honey<
806
890
  TEnv,
807
891
  TCtx,
@@ -819,52 +903,12 @@ export class Honey<
819
903
  values: TAdds,
820
904
  ): Honey<TEnv, TCtx & Readonly<TAdds>, TRoutes, TMeta, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw> {
821
905
  for (const key in values) {
822
- if (STATIC_CTX_RESERVED.has(key)) {
906
+ if (RESERVED_CTX_KEYS.has(key)) {
823
907
  throw new Error(`context() cannot set reserved key "${key}"`)
824
908
  }
825
909
  }
826
- const next = new Honey<
827
- TEnv,
828
- TCtx & Readonly<TAdds>,
829
- TRoutes,
830
- TMeta,
831
- TErrorFactory,
832
- TDefaultErrors,
833
- TBasePath,
834
- TTaps,
835
- TScopedMw
836
- >({
837
- chainMiddlewares: this._chainMiddlewares,
838
- defaultErrorKeys: this._defaultErrorKeys,
839
- globalMiddlewares: this._globalMiddlewares,
840
- graph: this._graph,
841
- scopedMiddlewares: this._scopedMiddlewares,
842
- })
843
- next._basePath = this._basePath
844
- next._chainMeta = this._chainMeta
910
+ const next = this._derive()
845
911
  next._contextValues = this._contextValues ? { ...this._contextValues, ...values } : { ...values }
846
- next._defaultBoundaryKey = this._defaultBoundaryKey
847
- next._errorFactory = this._errorFactory
848
- next._errorSchema = this._errorSchema
849
- next._customErrorFormatter = this._customErrorFormatter
850
- next._customErrorSchema = this._customErrorSchema
851
- next._errorFormatter = this._errorFormatter
852
- next._errorI18n = this._errorI18n
853
- next._logger = this._logger
854
- next._outputValidation = this._outputValidation
855
- next._stripPrefix = this._stripPrefix
856
- next._trailingSlash = this._trailingSlash
857
- next._wsAdapter = this._wsAdapter
858
- next._onError = this._onError
859
- next._onNotFound = this._onNotFound
860
- next._onMethodNotAllowed = this._onMethodNotAllowed
861
- next._taps = this._taps
862
- next._telemetry = this._telemetry
863
- next._realtimeBus = this._realtimeBus
864
- next._realtimeRoutes = this._realtimeRoutes
865
- next._staticRoutes = this._staticRoutes
866
- next._hasRouteTree = this._hasRouteTree
867
- next._hasWsRoutes = this._hasWsRoutes
868
912
  return next as unknown as Honey<
869
913
  TEnv,
870
914
  TCtx & Readonly<TAdds>,
@@ -879,17 +923,50 @@ export class Honey<
879
923
  }
880
924
 
881
925
  logger(logger: Logger): this {
882
- this._logger = logger
926
+ this._s.logger = logger
883
927
  return this
884
928
  }
885
929
 
886
- outputValidation(mode: "always" | "dev" | "off"): this {
887
- this._outputValidation = mode
930
+ outputValidation(mode: OutputValidationMode): this {
931
+ this._s.outputValidation = mode
932
+ this._bumpEpoch()
888
933
  return this
889
934
  }
890
935
 
891
936
  trailingSlash(mode: "enforce" | "ignore" | "strip"): this {
892
- 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)
893
970
  return this
894
971
  }
895
972
 
@@ -899,19 +976,29 @@ export class Honey<
899
976
  if (normalized.length > 0 && normalized.charCodeAt(0) !== 47) {
900
977
  normalized = `/${normalized}`
901
978
  }
902
- this._stripPrefix = normalized === "" || normalized === "/" ? null : normalized
979
+ this._s.stripPrefix = normalized === "" || normalized === "/" ? null : normalized
903
980
  return this
904
981
  }
905
982
 
906
983
  wsAdapter(adapter: WSAdapter): this {
907
- this._wsAdapter = adapter
984
+ this._s.wsAdapter = adapter
908
985
  return this
909
986
  }
910
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
+ */
911
996
  openapi(options: {
912
997
  description?: string
913
998
  docs?: "scalar" | "swagger"
914
999
  docsPath?: string
1000
+ /** Mount the document and docs routes. Default `true`. */
1001
+ enabled?: boolean
915
1002
  filterRoutes?: (route: { meta: unknown; method: string; path: string }) => boolean
916
1003
  path?: string
917
1004
  /** Named metaSpec profile selecting which emitted keys this document carries */
@@ -920,134 +1007,548 @@ export class Honey<
920
1007
  title: string
921
1008
  version: string
922
1009
  }): this {
1010
+ if (options.enabled === false) return this
923
1011
  const stem = options.path ?? "/openapi"
924
- const yamlHeaders = { headers: { "content-type": "application/yaml; charset=utf-8" } }
925
- const loadJson = (): Promise<unknown> => {
926
- const epoch = this._root.g ?? 0
927
- if (this._openApiCache && this._openApiCache.epoch === epoch) {
928
- return this._openApiCache.value
929
- }
930
- let pending: Promise<unknown>
931
- pending = loadHoneyFeature("openapi")
932
- .then(() =>
933
- getOpenApiRuntime().generateOpenApi(this, {
934
- filterRoutes: options.filterRoutes,
935
- info: {
936
- description: options.description,
937
- title: options.title,
938
- version: options.version,
939
- },
940
- /* a served document is not an authoring moment — the check belongs to `honey generate` */
941
- invalidate: "off",
942
- profile: options.profile,
943
- securitySchemes: options.securitySchemes,
944
- }),
945
- )
946
- .catch((err: unknown) => {
947
- if (this._openApiCache?.value === pending) this._openApiCache = null
948
- throw err
949
- })
950
- this._openApiCache = { epoch, value: pending }
951
- return pending
952
- }
953
- const loadYaml = (): Promise<string> => {
954
- const epoch = this._root.g ?? 0
955
- if (this._openApiYamlCache && this._openApiYamlCache.epoch === epoch) {
956
- return this._openApiYamlCache.value
957
- }
958
- let pending: Promise<string>
959
- pending = Promise.resolve()
960
- .then(async () => getOpenApiRuntime().toYaml(await loadJson()))
961
- .catch((err: unknown) => {
962
- if (this._openApiYamlCache?.value === pending) this._openApiYamlCache = null
963
- throw err
964
- })
965
- this._openApiYamlCache = { epoch, value: pending }
966
- return pending
967
- }
968
- this._mountInternalGet(`${stem}.json`, async (ctx) => ctx.res.json("ok", await loadJson()))
969
- this._mountInternalGet(`${stem}.yml`, async (ctx) => ctx.res.text("ok", await loadYaml(), yamlHeaders))
970
- 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)
971
1039
  if (options.docs) {
972
- const specUrl = mergePath(this._basePath, `${stem}.json`)
973
1040
  const docs = options.docs
1041
+ const specPath = mergePath(this._basePath, jsonPath)
974
1042
  const ui = async (ctx: { res: HoneyRes }) => {
975
1043
  await loadHoneyFeature("openapi")
976
- 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)
977
1047
  }
978
- const preferred = options.docsPath ?? "/docs"
979
- const candidates = options.docsPath ? [preferred] : [preferred, "/reference"]
980
- 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]
981
1052
  for (const p of candidates) {
982
- if (this._mountInternalGet(p, ui)) {
983
- mounted = true
984
- break
985
- }
1053
+ tried = p
1054
+ mounted = this._mountInternalGet(p, ui)
1055
+ if (mounted !== "user") break
986
1056
  }
987
- if (!mounted) {
988
- 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
+ )
989
1064
  }
990
1065
  }
991
1066
  return this
992
1067
  }
993
1068
 
994
- manifest(options?: { path?: string }): this {
995
- const path = options?.path ?? "/manifest.json"
996
- const load = (): Promise<unknown> => {
997
- const epoch = this._root.g ?? 0
998
- if (this._manifestCache && this._manifestCache.epoch === epoch) {
999
- return this._manifestCache.value
1000
- }
1001
- let pending: Promise<unknown>
1002
- pending = loadHoneyFeature("openapi")
1003
- .then(() => getOpenApiRuntime().generateManifest(this))
1004
- .catch((err: unknown) => {
1005
- if (this._manifestCache?.value === pending) this._manifestCache = null
1006
- throw err
1007
- })
1008
- this._manifestCache = { epoch, value: pending }
1009
- return pending
1010
- }
1011
- 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)
1012
1083
  return this
1013
1084
  }
1014
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
+
1015
1102
  async serve(options?: HoneyServeOptions): Promise<ServeHandle> {
1016
1103
  await loadHoneyFeature("serve")
1017
1104
  return getServeRuntime()(this, options)
1018
1105
  }
1019
1106
 
1020
- private _lookupGet(fullPath: string): RouteHandler | null {
1021
- const fromStatic = this._staticRoutes.map?.[`GET ${fullPath}`]
1022
- if (fromStatic) return fromStatic
1023
- const hit = matchRoute(this._root, "GET", fullPath)
1024
- if (hit !== null && hit.matched) return hit.handler
1025
- return null
1026
- }
1027
-
1028
- /** Mount an internal GET. Returns false when a user route already owns the path. */
1029
- 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 {
1030
1114
  const fullPath = mergePath(this._basePath, path)
1031
- const existing = this._lookupGet(fullPath)
1032
- 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"
1033
1125
  const routeHandler: RouteHandler = {
1034
1126
  _skip: true,
1035
- bek: this._defaultBoundaryKey,
1036
- ef: null,
1037
- ek: this._defaultErrorKeys,
1127
+ bek: null,
1128
+ cm: [...this._chain],
1129
+ cv: this._contextValues,
1130
+ dk: new Set(),
1131
+ ek: new Set(),
1038
1132
  fn: (ctx) => fn(ctx as { res: HoneyRes }),
1039
1133
  iv: null,
1040
1134
  mt: null,
1041
- mw: [...this._chainMiddlewares],
1135
+ mw: [],
1042
1136
  os: null,
1043
1137
  ov: null,
1044
- rp: fullPath,
1138
+ own: g.settings,
1139
+ rb: null,
1140
+ rm: [],
1141
+ xm: null,
1045
1142
  }
1046
- insertRoute(this._root, "GET", fullPath, routeHandler)
1047
- if (!fullPath.includes(":") && !fullPath.includes("*")) {
1048
- 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
1049
1160
  }
1050
- 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
1051
1552
  }
1052
1553
 
1053
1554
  defaultErrorFormatter<TSchema extends StandardSchemaLike>(
@@ -1059,13 +1560,14 @@ export class Honey<
1059
1560
  schemaOrFn: ErrorFormatterFn | StandardSchemaLike,
1060
1561
  maybeFn?: (error: HoneyError) => unknown,
1061
1562
  ): this {
1563
+ const s = this._s
1062
1564
  if (typeof schemaOrFn === "function") {
1063
- this._errorSchema = null
1064
- this._errorFormatter = schemaOrFn
1565
+ s.errorSchema = null
1566
+ s.errorFormatter = schemaOrFn
1065
1567
  } else {
1066
- this._errorSchema = schemaOrFn
1568
+ s.errorSchema = schemaOrFn
1067
1569
  const mapper = maybeFn as (error: HoneyError) => Record<string, unknown>
1068
- this._errorFormatter = (error) => mapper(error)
1570
+ s.errorFormatter = (error) => mapper(error)
1069
1571
  }
1070
1572
  return this
1071
1573
  }
@@ -1079,56 +1581,35 @@ export class Honey<
1079
1581
  schemaOrFn: CustomErrorFormatter | StandardSchemaLike,
1080
1582
  maybeFn?: (error: HoneyError, data: Record<string, unknown>) => unknown,
1081
1583
  ): this {
1584
+ const s = this._s
1082
1585
  if (typeof schemaOrFn === "function") {
1083
- this._customErrorSchema = null
1084
- this._customErrorFormatter = schemaOrFn
1586
+ s.customErrorSchema = null
1587
+ s.customErrorFormatter = schemaOrFn
1085
1588
  } else {
1086
- this._customErrorSchema = schemaOrFn
1589
+ s.customErrorSchema = schemaOrFn
1087
1590
  const mapper = maybeFn as (error: HoneyError, data: Record<string, unknown>) => Record<string, unknown>
1088
- this._customErrorFormatter = (error, data) => mapper(error, data)
1591
+ s.customErrorFormatter = (error, data) => mapper(error, data)
1089
1592
  }
1090
1593
  return this
1091
1594
  }
1092
1595
 
1093
1596
  errorI18n(config: ErrorI18nConfig<TEnv>): this {
1094
- this._errorI18n = config
1597
+ this._s.errorI18n = config
1095
1598
  return this
1096
1599
  }
1097
1600
 
1098
- onError(
1099
- handler: (
1100
- error: unknown,
1101
- ctx: {
1102
- env: TEnv
1103
- jsonFromError: (err: HoneyError) => Response
1104
- req: Request
1105
- },
1106
- ) => HoneyError | Response | Promise<HoneyError | Response | undefined | void> | undefined | void,
1107
- ): this {
1108
- this._onError = handler
1601
+ onError(handler: OnErrorFn<TEnv>): this {
1602
+ this._s.onError = handler
1109
1603
  return this
1110
1604
  }
1111
1605
 
1112
- onMethodNotAllowed(
1113
- handler: (ctx: {
1114
- allowed: string[]
1115
- env: TEnv
1116
- jsonFromError: (err: HoneyError) => Response
1117
- req: Request
1118
- }) => Response | Promise<Response>,
1119
- ): this {
1120
- this._onMethodNotAllowed = handler
1606
+ onMethodNotAllowed(handler: OnMethodNotAllowedFn<TEnv>): this {
1607
+ this._s.onMethodNotAllowed = handler
1121
1608
  return this
1122
1609
  }
1123
1610
 
1124
- onNotFound(
1125
- handler: (ctx: {
1126
- env: TEnv
1127
- jsonFromError: (err: HoneyError) => Response
1128
- req: Request
1129
- }) => Response | Promise<Response>,
1130
- ): this {
1131
- this._onNotFound = handler
1611
+ onNotFound(handler: OnNotFoundFn<TEnv>): this {
1612
+ this._s.onNotFound = handler
1132
1613
  return this
1133
1614
  }
1134
1615
 
@@ -1137,51 +1618,227 @@ export class Honey<
1137
1618
  key: K,
1138
1619
  handler: (ctx: TapContext<TEnv>, payload: K extends keyof TTaps ? TTaps[K] : unknown) => void | Promise<void>,
1139
1620
  ): this {
1140
- if (this._taps === null) {
1141
- this._taps = new Map()
1142
- }
1143
- 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()
1144
1625
  return this
1145
1626
  }
1146
1627
 
1147
1628
  telemetry(adapter: TelemetryAdapter): this {
1148
- this._telemetry = adapter
1629
+ this._s.telemetry = adapter
1630
+ /* onMiddleware wraps the compiled chains */
1631
+ this._bumpEpoch()
1149
1632
  return this
1150
1633
  }
1151
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
+ */
1152
1642
  routeTree(tree: RouteTree): this {
1153
- this._root = tree.root
1154
- this._handlerMap = tree.handlers ?? null
1155
- 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()
1156
1668
  return this
1157
1669
  }
1158
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
+ */
1159
1679
  toRouteTree(): RouteTree {
1160
- 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 }
1705
+ }
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
1161
1719
  }
1162
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
+ */
1163
1827
  errorFactory<TFactory extends Record<string, (...args: never[]) => unknown>>(
1164
1828
  factory: TFactory,
1165
1829
  ): Honey<TEnv, TCtx, TRoutes, TMeta, TFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw> {
1166
- const next = this as unknown as Honey<
1167
- TEnv,
1168
- TCtx,
1169
- TRoutes,
1170
- TMeta,
1171
- TFactory,
1172
- TDefaultErrors,
1173
- TBasePath,
1174
- TTaps,
1175
- TScopedMw
1176
- >
1177
- next._errorFactory = factory
1178
- 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>
1179
1833
  }
1180
1834
 
1835
+ /** Error keys every route of this app declares — applies to routes registered before and after the call. */
1181
1836
  defaultErrors<TKeys extends ([TErrorFactory] extends [never] ? never : keyof TErrorFactory & string)>(
1182
1837
  ...keys: TKeys[]
1183
1838
  ): Honey<TEnv, TCtx, TRoutes, TMeta, TErrorFactory, TDefaultErrors | TKeys, TBasePath, TTaps, TScopedMw> {
1184
- 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<
1185
1842
  TEnv,
1186
1843
  TCtx,
1187
1844
  TRoutes,
@@ -1192,16 +1849,15 @@ export class Honey<
1192
1849
  TTaps,
1193
1850
  TScopedMw
1194
1851
  >
1195
- for (const k of keys) {
1196
- next._defaultErrorKeys.add(k)
1197
- }
1198
- return next
1199
1852
  }
1200
1853
 
1201
1854
  defaultBoundary<TKey extends ([TErrorFactory] extends [never] ? never : keyof TErrorFactory & string)>(
1202
1855
  key: TKey,
1203
1856
  ): Honey<TEnv, TCtx, TRoutes, TMeta, TErrorFactory, TDefaultErrors | TKey, TBasePath, TTaps, TScopedMw> {
1204
- 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<
1205
1861
  TEnv,
1206
1862
  TCtx,
1207
1863
  TRoutes,
@@ -1212,9 +1868,6 @@ export class Honey<
1212
1868
  TTaps,
1213
1869
  TScopedMw
1214
1870
  >
1215
- next._defaultBoundaryKey = key
1216
- next._defaultErrorKeys.add(key)
1217
- return next
1218
1871
  }
1219
1872
 
1220
1873
  /** Phantom overload — constrains what route-level .meta() accepts */
@@ -1245,39 +1898,19 @@ export class Honey<
1245
1898
  return this as Honey<TEnv, TCtx, TRoutes, unknown, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw>
1246
1899
  }
1247
1900
  /* chain-level meta — copy-on-write */
1248
- const next = new Honey<TEnv, TCtx, TRoutes, unknown, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw>({
1249
- chainMiddlewares: this._chainMiddlewares,
1250
- defaultErrorKeys: this._defaultErrorKeys,
1251
- globalMiddlewares: this._globalMiddlewares,
1252
- graph: this._graph,
1253
- scopedMiddlewares: this._scopedMiddlewares,
1254
- })
1255
- next._basePath = this._basePath
1901
+ const next = this._derive()
1256
1902
  next._chainMeta = this._chainMeta ? { ...this._chainMeta, ...values } : { ...values }
1257
- next._contextValues = this._contextValues
1258
- next._defaultBoundaryKey = this._defaultBoundaryKey
1259
- next._errorFactory = this._errorFactory
1260
- next._errorSchema = this._errorSchema
1261
- next._customErrorFormatter = this._customErrorFormatter
1262
- next._customErrorSchema = this._customErrorSchema
1263
- next._errorFormatter = this._errorFormatter
1264
- next._errorI18n = this._errorI18n
1265
- next._logger = this._logger
1266
- next._outputValidation = this._outputValidation
1267
- next._stripPrefix = this._stripPrefix
1268
- next._trailingSlash = this._trailingSlash
1269
- next._wsAdapter = this._wsAdapter
1270
- next._onError = this._onError
1271
- next._onNotFound = this._onNotFound
1272
- next._onMethodNotAllowed = this._onMethodNotAllowed
1273
- next._taps = this._taps
1274
- next._telemetry = this._telemetry
1275
- next._realtimeBus = this._realtimeBus
1276
- next._realtimeRoutes = this._realtimeRoutes
1277
- next._staticRoutes = this._staticRoutes
1278
- next._hasRouteTree = this._hasRouteTree
1279
- next._hasWsRoutes = this._hasWsRoutes
1280
- 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
+ >
1281
1914
  }
1282
1915
 
1283
1916
  /**
@@ -1285,68 +1918,13 @@ export class Honey<
1285
1918
  * Codegen-time only — never consulted on the request path. See docs/meta-spec.md.
1286
1919
  */
1287
1920
  metaSpec<TSpec extends HoneyMetaSpec<TMeta>>(spec: TSpec): this {
1288
- if (this._metaSpec !== null) {
1921
+ if (this._graph.metaSpec !== null) {
1289
1922
  throw new Error("metaSpec() was already declared on this app — declare the whole policy in one call")
1290
1923
  }
1291
- this._metaSpec = spec as MetaSpecConfig
1924
+ this._graph.metaSpec = spec as MetaSpecConfig
1292
1925
  return this
1293
1926
  }
1294
1927
 
1295
- /**
1296
- * Merge a mounted sub-app's policy into this one.
1297
- *
1298
- * The parent wins on conflict, so one gateway keeps one answer for its aggregate document —
1299
- * with a single exception. A sub entry of `false` wins over anything the parent says, because
1300
- * hiding is a safety claim and the strictest claim has to survive composition: a worker that
1301
- * declares a field unpublishable must not have the gateway publish it. Wrongly hidden is a
1302
- * visible gap; wrongly published is a leak.
1303
- *
1304
- * Every other disagreement is recorded and reported at codegen. Silently dropping a sub's
1305
- * entry is how someone ends up diffing two documents to find out why they differ.
1306
- */
1307
- private _absorbMetaSpec(sub: MetaSpecConfig | null): void {
1308
- if (!sub) return
1309
- const own = this._metaSpec
1310
- if (!own) {
1311
- this._metaSpec = sub
1312
- return
1313
- }
1314
- const conflicts: MetaSpecMergeConflict[] = [...(own.conflicts ?? []), ...(sub.conflicts ?? [])]
1315
-
1316
- const mergeMeta = (): Record<string, MetaSpecEntry> => {
1317
- const merged: Record<string, MetaSpecEntry> = { ...sub.meta, ...own.meta }
1318
- for (const [key, subEntry] of Object.entries(sub.meta ?? {})) {
1319
- const ownEntry = own.meta?.[key]
1320
- if (ownEntry === undefined || ownEntry === subEntry) continue
1321
- if (subEntry === false && ownEntry !== false) {
1322
- merged[key] = false
1323
- conflicts.push({ key, resolution: "hidden", section: "meta" })
1324
- continue
1325
- }
1326
- conflicts.push({ key, resolution: "parent", section: "meta" })
1327
- }
1328
- return merged
1329
- }
1330
-
1331
- const recordOverlap = (section: "profiles" | "schema", subSide: object, ownSide: object): void => {
1332
- for (const [key, subEntry] of Object.entries(subSide)) {
1333
- const ownEntry = (ownSide as Record<string, unknown>)[key]
1334
- if (ownEntry === undefined || ownEntry === subEntry) continue
1335
- conflicts.push({ key, resolution: "parent", section })
1336
- }
1337
- }
1338
- recordOverlap("schema", sub.schema ?? {}, own.schema ?? {})
1339
- recordOverlap("profiles", sub.profiles ?? {}, own.profiles ?? {})
1340
-
1341
- this._metaSpec = {
1342
- conflicts,
1343
- meta: mergeMeta(),
1344
- profiles: { ...sub.profiles, ...own.profiles },
1345
- schema: { ...sub.schema, ...own.schema },
1346
- strict: own.strict ?? sub.strict,
1347
- }
1348
- }
1349
-
1350
1928
  /** Declare tap payload types — auto-extends meta with Partial<T> for meta-driven taps */
1351
1929
  taps<TNewTaps extends Record<string, unknown>>(): Honey<
1352
1930
  TEnv,
@@ -1394,139 +1972,164 @@ export class Honey<
1394
1972
  /* oxlint-disable-next-line typescript/no-explicit-any -- overload impl requires erased types */
1395
1973
  use(pathOrMw: string | MiddlewareFn<any, any>, maybeMw?: MiddlewareFn<any, any>): any {
1396
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
+ */
1397
1979
  const mw = pathOrMw as RuntimeMiddleware
1398
- const newChain = new Honey<TEnv>({
1399
- chainMiddlewares: [...this._chainMiddlewares, mw],
1400
- defaultErrorKeys: this._defaultErrorKeys,
1401
- globalMiddlewares: this._globalMiddlewares,
1402
- graph: this._graph,
1403
- scopedMiddlewares: this._scopedMiddlewares,
1404
- })
1405
- newChain._basePath = this._basePath
1406
- newChain._chainMeta = this._chainMeta
1407
- newChain._contextValues = this._contextValues
1408
- newChain._defaultBoundaryKey = this._defaultBoundaryKey
1409
- newChain._errorFactory = this._errorFactory
1410
- newChain._errorSchema = this._errorSchema
1411
- newChain._customErrorFormatter = this._customErrorFormatter
1412
- newChain._customErrorSchema = this._customErrorSchema
1413
- newChain._errorFormatter = this._errorFormatter
1414
- newChain._errorI18n = this._errorI18n
1415
- newChain._logger = this._logger
1416
- newChain._outputValidation = this._outputValidation
1417
- newChain._stripPrefix = this._stripPrefix
1418
- newChain._trailingSlash = this._trailingSlash
1419
- newChain._wsAdapter = this._wsAdapter
1420
- newChain._onError = this._onError
1421
- newChain._onNotFound = this._onNotFound
1422
- newChain._onMethodNotAllowed = this._onMethodNotAllowed
1423
- newChain._taps = this._taps
1424
- newChain._telemetry = this._telemetry
1425
- newChain._realtimeBus = this._realtimeBus
1426
- newChain._realtimeRoutes = this._realtimeRoutes
1427
- newChain._staticRoutes = this._staticRoutes
1428
- newChain._hasRouteTree = this._hasRouteTree
1429
- newChain._hasWsRoutes = this._hasWsRoutes
1430
- return newChain
1431
- }
1432
-
1433
- /* scoped path */
1434
- const normalizedPrefix = normalizeScopePath(pathOrMw)
1435
- 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))
1436
1989
  const mw = maybeMw as RuntimeMiddleware
1437
- const mwWithErrors = maybeMw as MiddlewareFn<unknown, unknown>
1438
- const entry: ScopedEntry = {
1439
- 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),
1440
1994
  mw,
1441
- prefix: fullPrefix,
1442
- }
1443
- this._scopedMiddlewares.push(entry)
1444
- const newChain = new Honey<TEnv>({
1445
- chainMiddlewares: this._chainMiddlewares,
1446
- defaultErrorKeys: this._defaultErrorKeys,
1447
- globalMiddlewares: this._globalMiddlewares,
1448
- graph: this._graph,
1449
- scopedMiddlewares: this._scopedMiddlewares,
1995
+ prefix,
1996
+ segs,
1450
1997
  })
1451
- newChain._basePath = this._basePath
1452
- newChain._chainMeta = this._chainMeta
1453
- newChain._contextValues = this._contextValues
1454
- newChain._defaultBoundaryKey = this._defaultBoundaryKey
1455
- newChain._errorFactory = this._errorFactory
1456
- newChain._errorSchema = this._errorSchema
1457
- newChain._customErrorFormatter = this._customErrorFormatter
1458
- newChain._customErrorSchema = this._customErrorSchema
1459
- newChain._errorFormatter = this._errorFormatter
1460
- newChain._errorI18n = this._errorI18n
1461
- newChain._logger = this._logger
1462
- newChain._outputValidation = this._outputValidation
1463
- newChain._stripPrefix = this._stripPrefix
1464
- newChain._trailingSlash = this._trailingSlash
1465
- newChain._wsAdapter = this._wsAdapter
1466
- newChain._onError = this._onError
1467
- newChain._onNotFound = this._onNotFound
1468
- newChain._onMethodNotAllowed = this._onMethodNotAllowed
1469
- newChain._taps = this._taps
1470
- newChain._telemetry = this._telemetry
1471
- newChain._realtimeBus = this._realtimeBus
1472
- newChain._realtimeRoutes = this._realtimeRoutes
1473
- newChain._staticRoutes = this._staticRoutes
1474
- newChain._hasRouteTree = this._hasRouteTree
1475
- newChain._hasWsRoutes = this._hasWsRoutes
1476
- newChain._applyScopedEntryErrors(entry)
1477
- newChain._applyScopedEntryMeta(entry)
1478
- return newChain
1998
+ this._bumpEpoch()
1999
+ return this._derive()
1479
2000
  }
1480
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
+ */
1481
2007
  route<TSubRoutes, TSubMeta, TSubErrorFactory, TSubDefaultErrors extends string, TSubBasePath extends string>(
1482
2008
  sub: Honey<TEnv, TCtx, TSubRoutes, TSubMeta, TSubErrorFactory, TSubDefaultErrors, TSubBasePath>,
1483
- ): Honey<TEnv, TCtx, TRoutes & TSubRoutes, TMeta, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw> {
1484
- /* skip self-merge: .handler() already inserted into shared _root */
1485
- if (sub._tree !== this._root) {
1486
- mergeInto(this._root, sub._tree)
1487
- this._absorbMetaSpec(sub._metaSpec)
1488
- if (sub._hasWsRoutes) this._hasWsRoutes = true
1489
- if (sub._hasRouteTree) this._hasRouteTree = true
1490
- /* carry sub's scoped mw entries into parent's runtime list (parent scopes run first) */
1491
- for (const entry of sub._scopedMiddlewares) {
1492
- 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))
1493
2086
  }
1494
- if (sub._staticRoutes.map !== null) {
1495
- if (this._staticRoutes.map === null) {
1496
- 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
1497
2097
  }
1498
- 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 })
1499
2102
  }
1500
- for (const [path, cfg] of sub._realtimeRoutes) {
1501
- 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)) {
1502
2106
  throw new Error(`Duplicate realtime route: ${path}`)
1503
2107
  }
1504
- 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 })
1505
2110
  }
1506
- if (!this._realtimeBus && sub._realtimeBus) {
1507
- this._realtimeBus = sub._realtimeBus
2111
+ if (!g.realtimeBus && sub._graph.realtimeBus) {
2112
+ this._setBus(sub._graph.realtimeBus)
1508
2113
  }
1509
- if (sub._taps !== null) {
1510
- if (this._taps === null) this._taps = new Map()
1511
- for (const [key, fn] of sub._taps) {
1512
- 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)
1513
2119
  }
1514
2120
  }
1515
- /* walk tree and apply all scoped error keys + contributed meta to matching handlers */
1516
- this._applyAllScopedErrors()
1517
- for (const entry of this._scopedMiddlewares) this._applyScopedEntryMeta(entry)
2121
+ this._bumpEpoch()
1518
2122
  }
1519
- return this as unknown as Honey<
1520
- TEnv,
1521
- TCtx,
1522
- TRoutes & TSubRoutes,
1523
- TMeta,
1524
- TErrorFactory,
1525
- TDefaultErrors,
1526
- TBasePath,
1527
- TTaps,
1528
- TScopedMw
1529
- >
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
1530
2133
  }
1531
2134
 
1532
2135
  private _registerRoute<TPath extends string, TMethod extends HttpMethod | "ALL">(
@@ -1553,12 +2156,8 @@ export class Honey<
1553
2156
  TScopedMw
1554
2157
  > {
1555
2158
  const fullPath = mergePath(this._basePath, path)
1556
- const errorKeys = new Set(this._defaultErrorKeys)
1557
- for (const entry of this._scopedMiddlewares) {
1558
- if (entry.errors && scopeMatches(entry.prefix, fullPath)) {
1559
- for (const k of entry.errors) errorKeys.add(k)
1560
- }
1561
- }
2159
+ const pending: PendingRoute = { id: `${[method, ...(extraMethods ?? [])].join(",")} ${fullPath}` }
2160
+ this._graph.pending.add(pending)
1562
2161
  return new RouteBuilder<
1563
2162
  TEnv,
1564
2163
  TCtx & ApplyScoped<TScopedMw, MergePath<TBasePath, TPath>>,
@@ -1578,20 +2177,17 @@ export class Honey<
1578
2177
  TTaps,
1579
2178
  TScopedMw
1580
2179
  >({
1581
- boundaryErrorKey: this._defaultBoundaryKey,
1582
- errorKeys,
2180
+ boundaryErrorKey: null,
2181
+ errorKeys: new Set(),
1583
2182
  extraMethods: extraMethods ?? null,
1584
- handlerMap: this._handlerMap,
1585
2183
  inputSchemas: null,
1586
2184
  meta: this._chainMeta ? { ...this._chainMeta } : null,
1587
2185
  method,
1588
2186
  middlewares: [],
1589
- mwMeta: this._contributedMetaFor(fullPath),
1590
2187
  outputSchemas: null,
1591
2188
  parent: this,
1592
- parentMiddlewares: this._chainMiddlewares,
1593
2189
  path: fullPath,
1594
- root: this._root,
2190
+ pending,
1595
2191
  }) as unknown as BuilderChain<
1596
2192
  TEnv,
1597
2193
  TCtx & ApplyScoped<TScopedMw, MergePath<TBasePath, TPath>>,
@@ -1613,6 +2209,27 @@ export class Honey<
1613
2209
  >
1614
2210
  }
1615
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
+
1616
2233
  on<const TPath extends string, const TMethods extends readonly [HttpMethod | "ALL", ...(HttpMethod | "ALL")[]]>(
1617
2234
  methods: TMethods,
1618
2235
  path: TPath,
@@ -1677,6 +2294,9 @@ export class Honey<
1677
2294
  never,
1678
2295
  Honey<TEnv, TCtx, TRoutes, TMeta, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw>
1679
2296
  > {
2297
+ const fullPath = mergePath(this._basePath, path)
2298
+ const pending: PendingRoute = { id: `WS ${fullPath}` }
2299
+ this._graph.pending.add(pending)
1680
2300
  return new WSRouteBuilder<
1681
2301
  TEnv,
1682
2302
  TCtx & ApplyScoped<TScopedMw, MergePath<TBasePath, TPath>>,
@@ -1684,54 +2304,47 @@ export class Honey<
1684
2304
  never,
1685
2305
  Honey<TEnv, TCtx, TRoutes, TMeta, TErrorFactory, TDefaultErrors, TBasePath, TTaps, TScopedMw>
1686
2306
  >({
1687
- boundaryErrorKey: this._defaultBoundaryKey,
1688
2307
  errorKeys: new Set(),
1689
2308
  inputSchemas: null,
1690
- meta: null,
2309
+ meta: this._chainMeta ? { ...this._chainMeta } : null,
1691
2310
  middlewares: [],
1692
- mwMeta: this._contributedMetaFor(mergePath(this._basePath, path)),
2311
+ origins: null,
1693
2312
  parent: this,
1694
- parentMiddlewares: this._chainMiddlewares,
1695
- path: mergePath(this._basePath, path),
1696
- root: this._root,
2313
+ path: fullPath,
2314
+ pending,
1697
2315
  })
1698
2316
  }
1699
2317
 
1700
- realtime(path: string, opts: RealtimeRouteOpts): this {
1701
- const fullPath = mergePath(this._basePath, path) || "/"
1702
-
1703
- if (!this._realtimeBus) {
1704
- this._realtimeBus = createBus()
1705
- }
1706
-
1707
- 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)) {
1708
2327
  throw new Error(`Duplicate realtime route: ${fullPath}`)
1709
2328
  }
1710
-
1711
- this._realtimeRoutes.set(fullPath, {
1712
- handler: opts.handler,
1713
- middlewares: opts.use,
1714
- reconnectBuffer: opts.reconnectBuffer,
1715
- })
1716
-
1717
- const mw: RuntimeMiddleware[] = []
1718
- if (opts.use) {
1719
- for (const fn of opts.use) {
1720
- mw.push(fn as RuntimeMiddleware)
1721
- }
1722
- }
1723
-
1724
- insertWsRoute(this._root, fullPath, {
1725
- 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(),
1726
2337
  ek: new Set(),
1727
2338
  fn: Object.create(null),
1728
2339
  iv: null,
1729
2340
  mt: null,
1730
- mw: [...this._chainMiddlewares, ...mw],
2341
+ mw: [],
2342
+ own: g.settings,
2343
+ rb: null,
2344
+ rm: (opts.use ?? []).map((fn) => fn as RuntimeMiddleware),
1731
2345
  rp: fullPath,
2346
+ xm: this._chainMeta ? { ...this._chainMeta } : null,
1732
2347
  })
1733
-
1734
- this._hasWsRoutes = true
1735
2348
  return this
1736
2349
  }
1737
2350
 
@@ -1745,23 +2358,44 @@ export class Honey<
1745
2358
  env: TEnv,
1746
2359
  executionCtx?: { waitUntil?: (p: Promise<unknown>) => void },
1747
2360
  ): Response | Promise<Response> {
1748
- if (this._wsAdapter === null && !this._hasWsRoutes) {
1749
- 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)
1750
2377
  }
1751
2378
  const isWsUpgrade = request.headers.get("upgrade")?.toLowerCase() === "websocket"
1752
- const headerSnap = isWsUpgrade ? new Headers(request.headers) : undefined
1753
- const path = this.pathFromRequest(request)
1754
2379
  const canPreUpgrade =
1755
2380
  isWsUpgrade &&
1756
- this._wsAdapter?.preUpgrade !== undefined &&
2381
+ wsAdapter?.preUpgrade !== undefined &&
1757
2382
  !this.trailingSlashRedirects(path) &&
1758
- matchWsRoute(this._root, this.pathAfterPrefix(path)) !== null
1759
- 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
+ }
1760
2394
  /* After Deno.upgradeWebSocket the Request is closed. A sync throw here
1761
2395
  * used to be boxed by async _doFetch; keep 101 returning either way. */
1762
2396
  let work: Response | Promise<Response>
1763
2397
  try {
1764
- work = this._doFetch(request, env, executionCtx, isWsUpgrade === true, headerSnap)
2398
+ work = this._doFetch(request, env, executionCtx, path, isWsUpgrade === true, headerSnap)
1765
2399
  } catch (err) {
1766
2400
  work = Promise.reject(err)
1767
2401
  }
@@ -1791,28 +2425,25 @@ export class Honey<
1791
2425
  return pre.response
1792
2426
  }
1793
2427
 
1794
- private pathFromRequest(request: Request): string {
1795
- const rawUrl = request.url
1796
- const protoEnd = rawUrl.indexOf("//")
1797
- const pathStart = protoEnd === -1 ? 0 : rawUrl.indexOf("/", protoEnd + 2)
1798
- const searchOrHash = pathStart === -1 ? -1 : findSearchOrHash(rawUrl, pathStart)
1799
- if (pathStart === -1) return "/"
1800
- if (searchOrHash === -1) return rawUrl.substring(pathStart)
1801
- 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))
1802
2431
  }
1803
2432
 
1804
2433
  private trailingSlashRedirects(path: string): boolean {
1805
2434
  if (path.length <= 1) return false
1806
- if (this._trailingSlash === "strip" && path.endsWith("/")) return true
1807
- 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
1808
2438
  return false
1809
2439
  }
1810
2440
 
1811
2441
  private pathAfterPrefix(path: string): string {
1812
- if (this._stripPrefix === null) return path
1813
- if (path === this._stripPrefix) return "/"
1814
- if (path.startsWith(this._stripPrefix) && path.charCodeAt(this._stripPrefix.length) === 47) {
1815
- 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)
1816
2447
  }
1817
2448
  return path
1818
2449
  }
@@ -1820,25 +2451,15 @@ export class Honey<
1820
2451
  private _doFetch(
1821
2452
  request: Request,
1822
2453
  env: TEnv,
1823
- executionCtx?: { waitUntil?: (p: Promise<unknown>) => void },
2454
+ executionCtx: { waitUntil?: (p: Promise<unknown>) => void } | undefined,
2455
+ fullPath: string,
1824
2456
  knownWsUpgrade = false,
1825
2457
  headerSnap?: Headers,
1826
2458
  ): Response | Promise<Response> {
1827
2459
  const startTime = performance.now()
1828
-
1829
- /* fast path extraction — avoids expensive new URL() allocation */
2460
+ const final = this._finalize()
2461
+ const s = this._graph.settings
1830
2462
  const rawUrl = request.url
1831
- const protoEnd = rawUrl.indexOf("//")
1832
- const pathStart = protoEnd === -1 ? 0 : rawUrl.indexOf("/", protoEnd + 2)
1833
- const searchOrHash = pathStart === -1 ? -1 : findSearchOrHash(rawUrl, pathStart)
1834
- let path: string
1835
- if (pathStart === -1) {
1836
- path = "/"
1837
- } else if (searchOrHash === -1) {
1838
- path = rawUrl.substring(pathStart)
1839
- } else {
1840
- path = rawUrl.substring(pathStart, searchOrHash)
1841
- }
1842
2463
 
1843
2464
  /* lazily create URL only when actually needed (search params, redirects) */
1844
2465
  let _url: URL | undefined
@@ -1847,296 +2468,176 @@ export class Honey<
1847
2468
  return _url
1848
2469
  }
1849
2470
 
1850
- const log = this._logger ?? undefined
2471
+ const log = s.logger ?? undefined
2472
+ const method = request.method.toUpperCase() as HttpMethod
1851
2473
  const fc: FetchCtx<TEnv> = {
2474
+ allowed: null,
1852
2475
  env,
1853
2476
  executionCtx,
2477
+ headerSnap,
1854
2478
  log,
2479
+ method,
2480
+ path: fullPath,
2481
+ plan: null,
1855
2482
  request,
1856
2483
  startTime,
2484
+ trust: s.trust,
1857
2485
  url: getUrl,
2486
+ ws: null,
1858
2487
  wsUpgrade: knownWsUpgrade,
1859
- headerSnap,
1860
2488
  }
1861
2489
 
1862
- if (this._telemetry !== null) {
1863
- safeFire(() => this._telemetry?.onRequest?.({ env, req: request }), log)
1864
- }
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
+ }
1865
2496
 
1866
- /* trailing slash handling */
1867
- if (path.length > 1) {
1868
- if (this._trailingSlash === "strip" && path.endsWith("/")) {
1869
- const redirectUrl = getUrl()
1870
- redirectUrl.pathname = path.slice(0, -1)
1871
- return new Response(null, {
1872
- headers: { location: redirectUrl.toString() },
1873
- status: 308,
1874
- })
1875
- }
1876
- if (this._trailingSlash === "enforce" && !path.endsWith("/")) {
1877
- const redirectUrl = getUrl()
1878
- redirectUrl.pathname = `${path}/`
1879
- return new Response(null, {
1880
- headers: { location: redirectUrl.toString() },
1881
- status: 308,
1882
- })
1883
- }
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
+ })
1884
2515
  }
1885
2516
 
1886
2517
  /* prefix stripping — must run AFTER trailing slash so redirects preserve the full prefixed URL */
1887
- if (this._stripPrefix !== null) {
1888
- if (path === this._stripPrefix) {
1889
- path = "/"
1890
- } else if (path.startsWith(this._stripPrefix) && path.charCodeAt(this._stripPrefix.length) === 47) {
1891
- path = path.slice(this._stripPrefix.length)
1892
- }
1893
- }
2518
+ const path = this.pathAfterPrefix(fullPath)
2519
+ fc.path = path
2520
+ const root = this._graph.root
1894
2521
 
1895
2522
  /* WebSocket route check — do not re-read headers after Deno.upgradeWebSocket */
1896
2523
  const isWsUpgrade = knownWsUpgrade || requestIsWsUpgrade(request)
1897
2524
  if (isWsUpgrade) {
1898
- const wsMatch = matchWsRoute(this._root, path)
2525
+ const wsMatch = this._matchWs(final, path)
1899
2526
  if (wsMatch !== null) {
1900
2527
  return this._handleWs(fc, wsMatch)
1901
2528
  }
1902
- } else if (this._realtimeRoutes.size > 0) {
1903
- /* Non-upgrade request hitting a realtime-only path → 426 Upgrade Required */
1904
- const wsMatch = matchWsRoute(this._root, path)
1905
- if (wsMatch !== null && this._realtimeRoutes.has(wsMatch.handler.rp)) {
1906
- return new Response(null, { headers: { upgrade: "websocket" }, status: 426 })
1907
- }
1908
2529
  }
1909
2530
 
1910
- const method = request.method.toUpperCase() as HttpMethod
1911
-
1912
- /* fn:null fallthrough — tree match sets meta/iv, catch-all dispatches */
1913
- let fnNullMeta: Record<string, unknown> | null = null
1914
- let fnNullParams: Record<string, string> | null = null
1915
- let fnNullIv: InputSchemasDef | null = null
1916
- let fnNullHit = false
1917
-
1918
- /* Tier 2: O(1) static route lookup — checks both precompiled and runtime maps */
1919
- const smap = this._staticRoutes.map ?? this._handlerMap
1920
- if (smap !== null) {
1921
- const key = `${method} ${path}`
1922
- const staticHandler = smap[key]
1923
- if (staticHandler) {
1924
- if (staticHandler.fn === null) {
1925
- fnNullMeta = staticHandler.mt
1926
- fnNullParams = EMPTY_PARAMS
1927
- fnNullIv = staticHandler.iv ?? null
1928
- fnNullHit = true
1929
- } else {
1930
- return this._handleMatched(fc, method, path, staticHandler, EMPTY_PARAMS)
1931
- }
1932
- }
1933
- /* HEAD falls back to GET */
1934
- if (!fnNullHit && method === "HEAD") {
1935
- const getHandler = smap[`GET ${path}`]
1936
- if (getHandler) {
1937
- if (getHandler.fn === null) {
1938
- fnNullMeta = getHandler.mt
1939
- fnNullParams = EMPTY_PARAMS
1940
- fnNullIv = getHandler.iv ?? null
1941
- fnNullHit = true
1942
- } else {
1943
- return this._handleMatched(fc, method, path, getHandler, EMPTY_PARAMS)
1944
- }
1945
- }
1946
- }
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)
1947
2536
  }
1948
2537
 
1949
- if (!fnNullHit) {
1950
- const result = matchRoute(this._root, method, path)
1951
-
1952
- if (result?.matched) {
1953
- /*
1954
- * routeTree loaded → wildcard matches for unknown paths must 404.
1955
- * Only fn:null fallthroughs (known routes) should reach the catch-all.
1956
- * Detect wildcard: handler has no mt and fn is NOT null (builder-registered catch-all).
1957
- */
1958
- const isWildcardCatchAll =
1959
- this._hasRouteTree &&
1960
- this._root.w !== null &&
1961
- result.handler.fn !== null &&
1962
- (this._root.w.m[method] === result.handler || this._root.w.m["ALL"] === result.handler)
1963
-
1964
- if (isWildcardCatchAll) {
1965
- /* path not in routeTree — 404 */
1966
- return this._handle404(fc, method, path)
1967
- }
1968
-
1969
- if (result.handler.fn === null) {
1970
- fnNullMeta = result.handler.mt
1971
- fnNullParams = result.params
1972
- fnNullIv = result.handler.iv ?? null
1973
- fnNullHit = true
1974
- } else {
1975
- return this._handleMatched(fc, method, path, result.handler, result.params)
1976
- }
1977
- }
1978
-
1979
- if (!fnNullHit) {
1980
- if (result === null) {
1981
- if (!isWsUpgrade) {
1982
- const wsMatch = matchWsRoute(this._root, path)
1983
- if (wsMatch !== null) {
1984
- return new Response("Upgrade Required", {
1985
- headers: { connection: "Upgrade", upgrade: "websocket" },
1986
- status: 426,
1987
- })
1988
- }
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 })
1989
2546
  }
1990
- return this._handle404(fc, method, path)
2547
+ return new Response("Upgrade Required", {
2548
+ headers: { connection: "Upgrade", upgrade: "websocket" },
2549
+ status: 426,
2550
+ })
1991
2551
  }
1992
-
1993
- if (!result.matched) {
1994
- if (
1995
- method === "OPTIONS" &&
1996
- fc.request.headers.has("access-control-request-method") &&
1997
- result.allowed.length > 0
1998
- ) {
1999
- const fallback = this._preflightFallbackMethod(result.allowed)
2000
- const retry = matchRoute(this._root, fallback, path)
2001
- if (retry?.matched && retry.handler.fn !== null) {
2002
- return this._handleCorsPreflight(fc, path, retry.handler, retry.params, result.allowed)
2003
- }
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)
2004
2564
  }
2005
- return this._handle405(fc, method, path, result.allowed)
2006
2565
  }
2007
2566
  }
2567
+ return this._handleMiss(fc, result.allowed)
2008
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
+ }
2009
2573
 
2010
- /* fn:null hit — find catch-all/wildcard to dispatch with stashed meta */
2011
- const wildcardResult = matchRoute(this._root, method, "/*")
2012
- if (wildcardResult?.matched && wildcardResult.handler.fn !== null) {
2013
- /* 415 from matched iv before middleware that waits on the body */
2014
- if (fnNullIv !== null) {
2015
- try {
2016
- assertRequestContentType(fnNullIv, fc.request)
2017
- } catch (thrown) {
2018
- return this._toErrorResponse(thrown)
2019
- }
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)
2020
2598
  }
2021
- return this._handleMatched(fc, method, path, wildcardResult.handler, fnNullParams ?? EMPTY_PARAMS, fnNullMeta)
2022
2599
  }
2023
-
2024
- return this._handle404(fc, method, path)
2600
+ return this._handleMatched(fc, plan, params)
2025
2601
  }
2026
2602
 
2027
2603
  private _makeErrorCtx(fc: FetchCtx<TEnv>, allowed?: string[]) {
2604
+ const s = this._s
2028
2605
  return {
2029
2606
  env: fc.env,
2030
- jsonFromError: (err: HoneyError) => createErrorResponse(err, this._errorFormatter, this._customErrorFormatter),
2607
+ jsonFromError: (err: HoneyError) => createErrorResponse(err, s.errorFormatter, s.customErrorFormatter),
2031
2608
  req: fc.request,
2032
2609
  ...(allowed ? { allowed } : {}),
2033
2610
  }
2034
2611
  }
2035
2612
 
2036
- private async _handleWs(
2037
- fc: FetchCtx<TEnv>,
2038
- wsMatch: { handler: WSRouteHandler; params: Record<string, string> },
2039
- ): Promise<Response> {
2040
- /* Dispatch to realtime handler if this path is registered as a realtime route */
2041
- const realtimeConfig = this._realtimeRoutes.get(wsMatch.handler.rp)
2042
- if (realtimeConfig) {
2043
- return this._handleRealtime(fc, wsMatch, realtimeConfig)
2044
- }
2045
-
2046
- const isUpgrade = fc.wsUpgrade === true || requestIsWsUpgrade(fc.request)
2047
- if (!isUpgrade) {
2048
- return new Response(null, {
2049
- headers: { upgrade: "websocket" },
2050
- status: 426,
2051
- })
2052
- }
2053
-
2054
- const wsAdapter = this._wsAdapter
2055
- if (!wsAdapter) {
2056
- fc.log?.warn?.("WebSocket adapter not configured — call .wsAdapter()")
2057
- return createErrorResponse(
2058
- this._createError(EK.internal_server_error, SK.internal_server_error),
2059
- this._errorFormatter,
2060
- this._customErrorFormatter,
2061
- )
2062
- }
2063
-
2064
- 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>({
2065
2618
  env: fc.env,
2066
2619
  executionCtx: fc.executionCtx,
2067
- params: wsMatch.params,
2068
- req: ctxRequest(fc),
2620
+ meta: r.mt ?? undefined,
2621
+ params,
2622
+ path: fc.path,
2623
+ req: req ?? fc.request,
2624
+ routePattern: r.rp ?? "",
2069
2625
  urlFn: fc.url,
2070
2626
  })
2071
- if (this._contextValues) Object.assign(wsCtx, this._contextValues)
2072
-
2073
- /*
2074
- * WS ordering: [global → scoped → chain+handler-route-specific]
2075
- * WS bakes chain mw into handler.mw at registration, so scoped runs before chain.
2076
- * This is an unavoidable inconsistency vs HTTP (where chain runs before scoped).
2077
- */
2078
- const scopedForPath = this._filterScopedForPath(wsMatch.handler.rp)
2079
- const allWsMw: RuntimeMiddleware[] = [...this._globalMiddlewares, ...scopedForPath, ...wsMatch.handler.mw]
2080
-
2081
- try {
2082
- return await executeChain(allWsMw, wsCtx, async (finalCtx) => {
2083
- const userHandler = wsMatch.handler.fn
2084
- let messageQueue: Promise<void> = Promise.resolve()
2085
-
2086
- const onOpenFn = userHandler.onOpen
2087
- const onMsgFn = userHandler.onMessage
2088
- const onCloseFn = userHandler.onClose
2089
- const onErrorFn = userHandler.onError
2090
- const onReconnectFn = userHandler.onReconnect
2091
- const reconnectToken = fc.url().searchParams.get("reconnect_token")
2092
-
2093
- const wrappedHandler: WSHandler<unknown> = {}
2094
-
2095
- if (reconnectToken && onReconnectFn) {
2096
- wrappedHandler.onOpen = (_ctx, ws) => {
2097
- onReconnectFn(finalCtx, ws, reconnectToken)
2098
- }
2099
- } else if (onOpenFn) {
2100
- wrappedHandler.onOpen = (_ctx, ws) => {
2101
- onOpenFn(finalCtx, ws)
2102
- }
2103
- }
2104
-
2105
- if (onMsgFn) {
2106
- wrappedHandler.onMessage = (_ctx, ws, data) => {
2107
- messageQueue = messageQueue
2108
- .then(() => onMsgFn(finalCtx, ws, data))
2109
- .catch((err: unknown) => {
2110
- onErrorFn?.(finalCtx, ws, err)
2111
- })
2112
- }
2113
- }
2114
-
2115
- if (onCloseFn) {
2116
- wrappedHandler.onClose = (_ctx, ws, code, reason) => {
2117
- onCloseFn(finalCtx, ws, code, reason)
2118
- }
2119
- }
2120
-
2121
- if (onErrorFn) {
2122
- wrappedHandler.onError = (_ctx, ws, error) => {
2123
- onErrorFn(finalCtx, ws, error)
2124
- }
2125
- }
2126
-
2127
- const upgradeResult = await wsAdapter.upgrade(fc.request, fc.env, wrappedHandler)
2128
- return upgradeResult.response
2129
- })
2130
- } catch (thrown) {
2131
- return this._toErrorResponse(thrown)
2132
- }
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
2133
2633
  }
2134
2634
 
2135
- private async _handleRealtime(
2635
+ private _handleWs(
2136
2636
  fc: FetchCtx<TEnv>,
2137
2637
  wsMatch: { handler: WSRouteHandler; params: Record<string, string> },
2138
- config: { handler: RealtimeRouteOpts["handler"]; middlewares?: RealtimeRouteOpts["use"]; reconnectBuffer?: number },
2139
- ): 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)
2140
2641
  const isUpgrade = fc.wsUpgrade === true || requestIsWsUpgrade(fc.request)
2141
2642
  if (!isUpgrade) {
2142
2643
  return new Response(null, {
@@ -2144,485 +2645,172 @@ export class Honey<
2144
2645
  status: 426,
2145
2646
  })
2146
2647
  }
2147
-
2148
- const wsAdapter = this._wsAdapter
2149
- if (!wsAdapter) {
2648
+ if (!this._graph.settings.wsAdapter) {
2150
2649
  fc.log?.warn?.("WebSocket adapter not configured — call .wsAdapter()")
2151
- return createErrorResponse(
2152
- this._createError(EK.internal_server_error, SK.internal_server_error),
2153
- this._errorFormatter,
2154
- this._customErrorFormatter,
2155
- )
2156
- }
2157
-
2158
- /* Lazily create bus if not yet initialized (happens when realtime() was called on a child chain) */
2159
- if (!this._realtimeBus) {
2160
- this._realtimeBus = createBus()
2161
- }
2162
- const bus = this._realtimeBus
2163
-
2164
- const ctx = new HoneyContext({
2165
- env: fc.env,
2166
- executionCtx: fc.executionCtx,
2167
- params: wsMatch.params,
2168
- req: ctxRequest(fc),
2169
- 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,
2170
2682
  })
2171
- if (this._contextValues) Object.assign(ctx, this._contextValues)
2172
- Object.assign(ctx, {
2173
- realtime: { publish: (topic: string, data: unknown) => bus.publish(topic, data) },
2174
- })
2175
-
2176
- const scopedForPath = this._filterScopedForPath(wsMatch.handler.rp)
2177
- const allMw: RuntimeMiddleware[] = [...this._globalMiddlewares, ...scopedForPath, ...wsMatch.handler.mw]
2178
-
2179
- try {
2180
- return await executeChain(allMw, ctx, async (finalCtx) => {
2181
- const connId = crypto.randomUUID()
2182
-
2183
- let socket: WSContext<unknown> | null = null
2184
- let conn: ReturnType<typeof createConnContext> | null = null
2185
-
2186
- /*
2187
- * initConn creates the ConnContext and calls the user handler.
2188
- * Called from onOpen (for Bun where socket arrives later)
2189
- * or inline after upgrade (for Node/CF where socket is immediate).
2190
- */
2191
- const initConn = (ws: WSContext<unknown>) => {
2192
- socket = ws
2193
- conn = createConnContext({
2194
- bus,
2195
- closeFn: (reason) => {
2196
- if (socket) socket.close(1000, reason)
2197
- },
2198
- id: connId,
2199
- sendFn: (payload) => {
2200
- if (socket)
2201
- socket.send(typeof payload === "object" && payload !== null ? JSON.stringify(payload) : String(payload))
2202
- },
2203
- transport: "ws",
2204
- userId: null,
2205
- })
2206
-
2207
- bus.onMessage(connId, (data) => {
2208
- if (socket) {
2209
- socket.send(typeof data === "object" && data !== null ? JSON.stringify(data) : String(data))
2210
- }
2211
- })
2212
-
2213
- config.handler(finalCtx, conn)
2214
- }
2215
-
2216
- const wrappedHandler: WSHandler<unknown> = {
2217
- onClose: (_ctx, _ws, _code, reason) => {
2218
- if (!conn) return
2219
- const handlers = conn._handlers
2220
- if (handlers.close) {
2221
- handlers.close(reason || "normal")
2222
- }
2223
- bus.unsubscribeAll(connId)
2224
- bus.removeHandler(connId)
2225
- },
2226
- onMessage: (_ctx, _ws, data) => {
2227
- if (!conn) return
2228
- const handlers = conn._handlers
2229
- if (handlers.message && typeof data === "string") {
2230
- try {
2231
- const parsed: unknown = JSON.parse(data)
2232
- if (isMsgFrame(parsed)) {
2233
- handlers.message(parsed.data)
2234
- }
2235
- } catch {
2236
- /* ignore malformed frames */
2237
- }
2238
- }
2239
- },
2240
- onOpen: (_ctx, ws) => {
2241
- if (!socket) initConn(ws)
2242
- },
2243
- }
2244
-
2245
- const upgradeResult = await wsAdapter.upgrade(fc.request, fc.env, wrappedHandler)
2246
-
2247
- /* Node/CF adapters return the socket from upgrade(); Bun returns undefined (socket comes via onOpen).
2248
- * Deno pre-upgrade may still be CONNECTING — wait for onOpen so the first send is not dropped. */
2249
- if (upgradeResult.socket && !socket && upgradeResult.socket.readyState === 1) {
2250
- initConn(upgradeResult.socket)
2251
- }
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
+ }
2252
2702
 
2253
- return upgradeResult.response
2254
- })
2255
- } catch (thrown) {
2256
- 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)
2257
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)
2258
2719
  }
2259
2720
 
2260
- private async _handle404(fc: FetchCtx<TEnv>, method: string, path: string): Promise<Response> {
2261
- safeFire(() => this._telemetry?.onNotFound?.({ method, path, req: fc.request }), fc.log)
2262
- const make404 = () => {
2263
- if (this._onNotFound) {
2264
- return this._onNotFound(this._makeErrorCtx(fc))
2265
- }
2266
- return this._makeErrorCtx(fc).jsonFromError(this._createError(EK.not_found, SK.not_found))
2267
- }
2268
- try {
2269
- const ctx404 = new HoneyContext({
2270
- env: fc.env,
2271
- executionCtx: fc.executionCtx,
2272
- params: {},
2273
- req: fc.request,
2274
- urlFn: fc.url,
2275
- })
2276
- if (this._contextValues) Object.assign(ctx404, this._contextValues)
2277
- const res =
2278
- this._chainMiddlewares.length > 0
2279
- ? await executeChain(this._chainMiddlewares, ctx404, make404)
2280
- : 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 => {
2281
2727
  safeFire(
2282
- () =>
2283
- this._telemetry?.onResponse?.({
2284
- duration: performance.now() - fc.startTime,
2285
- req: fc.request,
2286
- status: res.status,
2287
- }),
2728
+ () => s.telemetry?.onResponse?.({ duration: performance.now() - fc.startTime, req: fc.request, status }),
2288
2729
  fc.log,
2289
2730
  )
2731
+ }
2732
+ if (!(res instanceof Promise)) {
2733
+ fire(res.status)
2290
2734
  return res
2291
- } catch (thrown) {
2292
- return this._toErrorResponse(thrown)
2293
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
+ )
2294
2746
  }
2295
2747
 
2296
- private _preflightFallbackMethod(allowed: string[]): HttpMethod {
2297
- if (allowed.includes("GET")) return "GET"
2298
- if (allowed.includes("HEAD")) return "HEAD"
2299
- if (allowed.includes("POST")) return "POST"
2300
- 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))
2301
2752
  }
2302
2753
 
2303
- /** Run the existing method's middleware for a CORS preflight. Do not invoke the route handler. */
2304
- private async _handleCorsPreflight(
2305
- fc: FetchCtx<TEnv>,
2306
- path: string,
2307
- handler: RouteHandler,
2308
- params: Record<string, string>,
2309
- allowed: string[],
2310
- ): Promise<Response> {
2311
- const ctx = new HoneyContext({
2312
- env: fc.env,
2313
- executionCtx: fc.executionCtx,
2314
- meta: handler.mt ? Object.freeze(handler.mt) : undefined,
2315
- params,
2316
- path,
2317
- req: fc.request,
2318
- routePattern: handler.rp ?? "",
2319
- 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,
2320
2764
  })
2321
- if (this._contextValues) Object.assign(ctx, this._contextValues)
2322
- try {
2323
- return await executeChain([...this._globalMiddlewares, ...handler.mw], ctx, () =>
2324
- this._handle405(fc, "OPTIONS", path, allowed),
2325
- )
2326
- } catch (thrown) {
2327
- return this._toErrorResponse(thrown)
2328
- }
2329
- }
2330
-
2331
- private async _handle405(fc: FetchCtx<TEnv>, method: string, path: string, allowed: string[]): Promise<Response> {
2332
- safeFire(
2333
- () =>
2334
- this._telemetry?.onMethodNotAllowed?.({
2335
- allowed,
2336
- method,
2337
- path,
2338
- req: fc.request,
2339
- }),
2340
- fc.log,
2341
- )
2342
- const make405 = async () => {
2343
- if (this._onMethodNotAllowed) {
2344
- const res = await this._onMethodNotAllowed(
2345
- this._makeErrorCtx(fc, allowed) as {
2346
- allowed: string[]
2347
- env: TEnv
2348
- jsonFromError: (err: HoneyError) => Response
2349
- req: Request
2350
- },
2351
- )
2352
- const responseHeaders = new Headers(res.headers)
2353
- responseHeaders.set("allow", allowed.join(", "))
2354
- return new Response(res.body, {
2355
- headers: responseHeaders,
2356
- status: res.status,
2357
- })
2358
- }
2359
- const err = this._createError(EK.method_not_allowed, SK.method_not_allowed)
2360
- const res = this._makeErrorCtx(fc).jsonFromError(err)
2361
- const responseHeaders = new Headers(res.headers)
2362
- responseHeaders.set("allow", allowed.join(", "))
2363
- return new Response(res.body, {
2364
- headers: responseHeaders,
2365
- status: res.status,
2366
- })
2367
- }
2368
- try {
2369
- const ctx405 = new HoneyContext({
2370
- env: fc.env,
2371
- executionCtx: fc.executionCtx,
2372
- params: {},
2373
- req: fc.request,
2374
- urlFn: fc.url,
2375
- })
2376
- if (this._contextValues) Object.assign(ctx405, this._contextValues)
2377
- const finalRes =
2378
- this._chainMiddlewares.length > 0
2379
- ? await executeChain(this._chainMiddlewares, ctx405, make405)
2380
- : await make405()
2381
- safeFire(
2382
- () =>
2383
- this._telemetry?.onResponse?.({
2384
- duration: performance.now() - fc.startTime,
2385
- req: fc.request,
2386
- status: finalRes.status,
2387
- }),
2388
- fc.log,
2389
- )
2390
- return finalRes
2391
- } catch (thrown) {
2392
- return this._toErrorResponse(thrown)
2393
- }
2394
2765
  }
2395
2766
 
2396
- 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(
2397
2774
  fc: FetchCtx<TEnv>,
2398
- method: HttpMethod,
2399
- path: string,
2400
- handler: RouteHandler,
2775
+ plan: Plan,
2401
2776
  params: Record<string, string>,
2402
- stashedMeta?: Record<string, unknown> | null,
2777
+ allowed: string[],
2403
2778
  ): Response | Promise<Response> {
2404
- const { env, executionCtx, log, request } = fc
2405
-
2406
- /* resolve error factory — pre-computed ef preferred, else build/use global */
2407
- let errors: Record<string, (...args: never[]) => unknown> | undefined
2408
- if (handler.ef != null) {
2409
- errors = handler.ef
2410
- } else if (this._errorFactory !== null) {
2411
- if (handler.ek.size > 0) {
2412
- const factory = this._errorFactory as Record<string, (...args: never[]) => unknown>
2413
- const subset = Object.create(null) as Record<string, unknown>
2414
- for (const key of handler.ek) {
2415
- if (key in factory) {
2416
- subset[key] = factory[key]
2417
- }
2418
- }
2419
- errors = Object.freeze(subset) as Record<string, (...args: never[]) => unknown>
2420
- } else {
2421
- errors = this._errorFactory as Record<string, (...args: never[]) => unknown>
2422
- }
2423
- }
2424
-
2425
- /* meta: stashed meta from fn:null tree match takes priority over handler meta */
2426
- const resolvedMeta = stashedMeta ?? handler.mt
2427
-
2428
- const ctx = new HoneyContext({
2429
- env,
2430
- executionCtx,
2431
- meta: resolvedMeta ?? undefined,
2432
- params,
2433
- path,
2434
- req: request,
2435
- routePattern: handler.rp ?? "",
2436
- urlFn: fc.url,
2437
- })
2438
- if (this._contextValues) Object.assign(ctx, this._contextValues)
2439
- if (this._realtimeBus) {
2440
- const rtBus = this._realtimeBus
2441
- Object.assign(ctx, {
2442
- realtime: { publish: (topic: string, data: unknown) => rtBus.publish(topic, data) },
2443
- })
2444
- }
2445
- if (errors) {
2446
- ctx._setErrors(errors)
2447
- }
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
+ }
2448
2792
 
2449
- 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) {
2450
2797
  try {
2451
- 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 ?? "" })
2452
2799
  } catch {
2453
2800
  /* telemetry must not crash request */
2454
2801
  }
2455
2802
  }
2456
-
2457
- /*
2458
- * Tier 3: Use pre-compiled chain when possible.
2459
- * Compiled chains are cached on the handler — created once, reused per request.
2460
- * Falls back to dynamic assembly when telemetry wrapping, input validation, or
2461
- * scoped middleware is in play (scoped mw cannot be baked into the compiled cache
2462
- * because each route may match a different subset).
2463
- */
2464
- const hasTelemetryMw = this._telemetry?.onMiddleware !== undefined
2465
- const hasInputValidation = handler.iv != null
2466
-
2467
- /*
2468
- * Error resolver — stored on ctx so the cached handler wrapper can read it.
2469
- * Converts handler errors into error Responses inside the middleware chain,
2470
- * allowing all post-next() middleware code (headers, logging, timing) to run.
2471
- */
2472
- ctx._errorToResponse = (thrown: unknown) => this._resolveErrorResponse(thrown, handler, fc, method, path, ctx)
2473
-
2474
- const onError = (thrown: unknown): Response | Promise<Response> => {
2475
- if (ctx._errorToResponse) return ctx._errorToResponse(thrown)
2476
- return this._toErrorResponse(thrown)
2477
- }
2478
-
2479
- const after = (response: Response): Response | Promise<Response> => {
2480
- try {
2481
- const done = this._afterMatched(fc, method, path, handler, ctx, response)
2482
- if (done instanceof Promise) return done.catch(onError)
2483
- return done
2484
- } catch (thrown) {
2485
- return onError(thrown)
2486
- }
2487
- }
2488
-
2489
- try {
2490
- if (!hasTelemetryMw && !hasInputValidation && this._scopedMiddlewares.length === 0) {
2491
- if (!handler._compiled) {
2492
- const chainMw = this._chainMiddlewares
2493
- const handlerHasChain = chainMw.length > 0 && chainMw.every((mw, i) => handler.mw[i] === mw)
2494
- const allMw = handlerHasChain
2495
- ? [...this._globalMiddlewares, ...handler.mw]
2496
- : [...this._globalMiddlewares, ...chainMw, ...handler.mw]
2497
- handler._compiled = compileChain(allMw, (c) => {
2498
- try {
2499
- const result = handler.fn(c)
2500
- if (result instanceof Promise) {
2501
- return result.catch((thrown: unknown) => {
2502
- const hCtx = c as HoneyContext<TEnv>
2503
- if (hCtx._errorToResponse) return hCtx._errorToResponse(thrown)
2504
- throw thrown
2505
- })
2506
- }
2507
- return result
2508
- } catch (thrown) {
2509
- const hCtx = c as HoneyContext<TEnv>
2510
- if (hCtx._errorToResponse) return hCtx._errorToResponse(thrown)
2511
- throw thrown
2512
- }
2513
- })
2514
- }
2515
- const result = handler._compiled(ctx)
2516
- if (result instanceof Promise) return result.then(after, onError)
2517
- return after(result)
2518
- }
2519
-
2520
- const scopedForPath = this._filterScopedForPath(handler.rp ?? "")
2521
- const chainMw = this._chainMiddlewares
2522
- const handlerHasChain = chainMw.length > 0 && chainMw.every((mw, i) => handler.mw[i] === mw)
2523
- /*
2524
- * Ordering: [global → chain → scoped → handler-route-specific]
2525
- * When handlerHasChain, handler.mw = [chain..., routeSpecific...].
2526
- * Scoped must go after chain but before route-specific, so we split.
2527
- */
2528
- let allMiddlewares: RuntimeMiddleware[] = handlerHasChain
2529
- ? [
2530
- ...this._globalMiddlewares,
2531
- ...handler.mw.slice(0, chainMw.length),
2532
- ...scopedForPath,
2533
- ...handler.mw.slice(chainMw.length),
2534
- ]
2535
- : [...this._globalMiddlewares, ...chainMw, ...scopedForPath, ...handler.mw]
2536
-
2537
- if (hasTelemetryMw) {
2538
- const onMw = this._telemetry?.onMiddleware
2539
- if (onMw) {
2540
- allMiddlewares = allMiddlewares.map((mw) => {
2541
- const name = mw.name || "anonymous"
2542
- const wrapped: RuntimeMiddleware = async (wCtx, wNext) => {
2543
- const mwStart = performance.now()
2544
- try {
2545
- const res = await mw(wCtx, wNext)
2546
- safeFire(() => onMw({ duration: performance.now() - mwStart, name }), log)
2547
- return res
2548
- } catch (error) {
2549
- safeFire(
2550
- () =>
2551
- onMw({
2552
- duration: performance.now() - mwStart,
2553
- error,
2554
- name,
2555
- }),
2556
- log,
2557
- )
2558
- throw error
2559
- }
2560
- }
2561
- return wrapped
2562
- })
2563
- }
2564
- }
2565
-
2566
- if (hasInputValidation) {
2567
- const schemas = handler.iv
2568
- if (schemas) {
2569
- const inputMw: RuntimeMiddleware = async (inputCtx, inputNext) => {
2570
- const validated = await validateInput(schemas, inputCtx["req"] as Request, params)
2571
- return inputNext({ input: validated })
2572
- }
2573
- allMiddlewares.push(inputMw)
2574
- }
2575
- }
2576
-
2577
- return executeChain(allMiddlewares, ctx, (finalCtx) => {
2578
- try {
2579
- const result = handler.fn(finalCtx)
2580
- if (result instanceof Promise) {
2581
- return result.catch((thrown: unknown) => {
2582
- const hCtx = finalCtx as HoneyContext<TEnv>
2583
- if (hCtx._errorToResponse) return hCtx._errorToResponse(thrown)
2584
- throw thrown
2585
- })
2586
- }
2587
- return result
2588
- } catch (thrown) {
2589
- const hCtx = finalCtx as HoneyContext<TEnv>
2590
- if (hCtx._errorToResponse) return hCtx._errorToResponse(thrown)
2591
- throw thrown
2592
- }
2593
- }).then(after, onError)
2594
- } catch (thrown) {
2595
- /* safety net — middleware-level errors (input validation, middleware crash) */
2596
- return onError(thrown)
2597
- }
2598
- }
2599
-
2600
- private _afterMatched(
2601
- fc: FetchCtx<TEnv>,
2602
- method: HttpMethod,
2603
- path: string,
2604
- handler: RouteHandler,
2605
- ctx: HoneyContext<TEnv>,
2606
- response: Response,
2607
- ): Response | Promise<Response> {
2608
- const validateOut =
2609
- this._outputValidation === "always" ||
2610
- (this._outputValidation === "dev" &&
2611
- (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV !== "production")
2612
- if (handler.os && validateOut && response.body !== null && !ctx._isErrorResponse) {
2613
- return this._validateThenFinish(fc, method, path, handler, ctx, response)
2614
- }
2615
- 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)
2616
2806
  }
2617
2807
 
2618
- private async _validateThenFinish(
2619
- fc: FetchCtx<TEnv>,
2620
- method: HttpMethod,
2621
- path: string,
2622
- handler: RouteHandler,
2623
- ctx: HoneyContext<TEnv>,
2624
- response: Response,
2625
- ): 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
2626
2814
  const ct = response.headers.get("content-type")
2627
2815
 
2628
2816
  /* content-type mismatch check */
@@ -2637,30 +2825,33 @@ export class Honey<
2637
2825
  }
2638
2826
  }
2639
2827
 
2640
- /* JSON schema validation — read original, return clone (Bun clone() drains original) */
2641
- 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)) {
2642
2832
  const sk = codeToStatusKey[response.status]
2643
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
+ }
2644
2840
  const forReturn = response.clone()
2645
2841
  const data: unknown = await response.json()
2646
2842
  await handler.ov(sk, data)
2647
- response = forReturn
2843
+ return forReturn
2648
2844
  }
2649
2845
  }
2650
- return this._finishMatched(fc, method, path, handler, ctx, response)
2846
+ return response
2651
2847
  }
2652
2848
 
2653
- private _finishMatched(
2654
- fc: FetchCtx<TEnv>,
2655
- method: HttpMethod,
2656
- path: string,
2657
- handler: RouteHandler,
2658
- ctx: HoneyContext<TEnv>,
2659
- response: Response,
2660
- ): 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
2661
2852
  /* taps — fire after successful handler, non-blocking */
2662
- if (this._taps !== null && !ctx._isErrorResponse) {
2663
- const taps = this._taps
2853
+ if (plan.taps !== null && !ctx._isErrorResponse) {
2854
+ const taps = plan.taps
2664
2855
  const log = fc.log
2665
2856
 
2666
2857
  /* meta-driven taps — fire for each registered key found in route meta */
@@ -2670,7 +2861,7 @@ export class Honey<
2670
2861
  if (metaValue !== undefined) {
2671
2862
  ctx.background(
2672
2863
  Promise.resolve()
2673
- .then(() => tapFn(ctx, metaValue))
2864
+ .then(() => tapFn(ctx as unknown as TapContext<unknown>, metaValue))
2674
2865
  .catch((e) => log?.warn?.({ err: e, tap: key }, "tap failed")),
2675
2866
  )
2676
2867
  }
@@ -2684,7 +2875,7 @@ export class Honey<
2684
2875
  if (tapFn !== undefined) {
2685
2876
  ctx.background(
2686
2877
  Promise.resolve()
2687
- .then(() => tapFn(ctx, pending.payload))
2878
+ .then(() => tapFn(ctx as unknown as TapContext<unknown>, pending.payload))
2688
2879
  .catch((e) => log?.warn?.({ err: e, tap: pending.key }, "tap failed")),
2689
2880
  )
2690
2881
  }
@@ -2693,31 +2884,21 @@ export class Honey<
2693
2884
  }
2694
2885
  }
2695
2886
 
2696
- if (this._telemetry !== null) {
2887
+ if (s.telemetry !== null) {
2697
2888
  try {
2698
2889
  const duration = performance.now() - fc.startTime
2699
- this._telemetry.onHandler?.({
2700
- duration,
2701
- method,
2702
- path,
2703
- status: response.status,
2704
- })
2705
- this._telemetry.onResponse?.({
2890
+ s.telemetry.onHandler?.({
2706
2891
  duration,
2707
- req: fc.request,
2892
+ method: fc.method,
2893
+ path: fc.path,
2894
+ route: handler.rp ?? "",
2708
2895
  status: response.status,
2709
2896
  })
2710
2897
  } catch {
2711
2898
  /* telemetry must never crash the response path */
2712
2899
  }
2713
2900
  }
2714
- /* HEAD responses must have empty body — preserve headers + status */
2715
- if (method === "HEAD") {
2716
- return new Response(null, {
2717
- headers: response.headers,
2718
- status: response.status,
2719
- })
2720
- }
2901
+ if (fc.method === "HEAD") return headResponse(response)
2721
2902
  return response
2722
2903
  }
2723
2904
  }
@@ -2811,15 +2992,6 @@ type ApplyScoped<TScopedMw extends readonly ScopedMwEntry[], TFullPath extends s
2811
2992
  ApplyScoped<Rest, TFullPath>
2812
2993
  : {}
2813
2994
 
2814
- /** @internal — runtime entry for a scoped middleware */
2815
- type ScopedEntry = {
2816
- /** merged full-path prefix (already rebased against basePath at .use time) */
2817
- prefix: string
2818
- mw: RuntimeMiddleware
2819
- /** cached from mw.errors at registration; undefined when none */
2820
- errors: readonly string[] | undefined
2821
- }
2822
-
2823
2995
  /** Apply typed params — override params with specific keys when route has :param segments */
2824
2996
  type ApplyParams<TCtx, TParams> = [keyof TParams] extends [string]
2825
2997
  ? string extends keyof TParams
@@ -2860,27 +3032,30 @@ type HandlerCtx<
2860
3032
 
2861
3033
  /** @internal — exposes private Honey members for RouteBuilder/WSRouteBuilder access */
2862
3034
  type HoneyInternal = {
3035
+ _addRoute(method: string, segments: readonly Segment[], record: RouteHandler): void
3036
+ _addWsRoute(segments: readonly Segment[], record: WSRouteHandler): void
2863
3037
  _factory: unknown
2864
- _markWsRoutes(): void
2865
- _registerStatic(key: string, handler: RouteHandler): void
3038
+ _settle(pending: PendingRoute): void
3039
+ _view(): { chain: RuntimeMiddleware[]; cv: Record<string, unknown> | null; own: unknown }
2866
3040
  }
2867
3041
 
2868
3042
  type RouteBuilderState<TParent> = {
2869
3043
  boundaryErrorKey: string | null
3044
+ /** keys the route declares itself (`.errors()`, `.boundary()`) */
2870
3045
  errorKeys: Set<string>
2871
3046
  extraMethods: (HttpMethod | "ALL")[] | null
2872
- handlerMap: Record<string, RouteHandler> | null
2873
3047
  inputSchemas: InputSchemasDef | null
3048
+ /** explicit meta — chain `.meta()` overlaid by route `.meta()` */
2874
3049
  meta: Record<string, unknown> | null
2875
- /** meta contributed by middleware — kept apart so explicit .meta() always outranks it */
2876
- mwMeta: Record<string, unknown> | null
2877
3050
  method: HttpMethod | "ALL"
3051
+ /** route-level middleware */
2878
3052
  middlewares: RuntimeMiddleware[]
2879
3053
  outputSchemas: OutputSchemaDef | null
2880
3054
  parent: TParent
2881
- parentMiddlewares: RuntimeMiddleware[]
3055
+ /** canonical full pattern */
2882
3056
  path: string
2883
- root: TreeNode
3057
+ /** reported at finalize until `.handler()` runs */
3058
+ pending: PendingRoute
2884
3059
  }
2885
3060
 
2886
3061
  /**
@@ -3225,84 +3400,38 @@ class RouteBuilder<
3225
3400
  }
3226
3401
 
3227
3402
  const isInternal = Symbol.for("honey.internal") in fn
3228
- if (isInternal) {
3229
- Object.defineProperty(fn, Symbol.for("honey.app"), { value: this._s.parent })
3230
- }
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)
3231
3405
 
3232
- const routeHandler: RouteHandler = {
3406
+ const parent = this._s.parent as unknown as HoneyInternal
3407
+ const view = parent._view()
3408
+ const base: RouteHandler = {
3233
3409
  _skip: isInternal || undefined,
3234
- bek: this._s.boundaryErrorKey,
3235
- ef: null,
3236
- 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),
3237
3416
  fn: fn as (ctx: unknown) => Response | Promise<Response>,
3238
3417
  iv: this._s.inputSchemas,
3239
- mt: mergeContributedMeta(this._s.mwMeta, this._s.meta),
3240
- mw: [...this._s.parentMiddlewares, ...this._s.middlewares],
3418
+ mt: mergeContributedMeta(collectMiddlewareMeta([view.chain, this._s.middlewares]), this._s.meta),
3419
+ mw: [],
3241
3420
  os: this._s.outputSchemas,
3242
3421
  ov,
3243
- rp: this._s.path,
3244
- }
3245
-
3246
- /* patch mode: if handler map has this route, patch existing handler instead of insertRoute */
3247
- const handlerMap = this._s.handlerMap
3248
- if (handlerMap) {
3249
- const key = `${this._s.method} ${this._s.path}`
3250
- const existing = handlerMap[key]
3251
- if (existing) {
3252
- existing.fn = routeHandler.fn
3253
- existing.mw = routeHandler.mw
3254
- existing.iv = routeHandler.iv
3255
- existing.os = routeHandler.os
3256
- existing.ov = routeHandler.ov
3257
- /* pre-compute filtered error factory */
3258
- const factory = (this._s.parent as unknown as HoneyInternal)._factory
3259
- if (factory !== null && existing.ek.size > 0) {
3260
- const ef = Object.create(null) as Record<string, (...args: never[]) => unknown>
3261
- const fac = factory as Record<string, (...args: never[]) => unknown>
3262
- for (const k of existing.ek) {
3263
- const factoryFn = fac[k]
3264
- if (factoryFn !== undefined) {
3265
- ef[k] = factoryFn
3266
- }
3267
- }
3268
- existing.ef = Object.freeze(ef)
3269
- }
3270
- return this._s.parent as HandlerReturn<
3271
- TEnv,
3272
- TBaseCtx,
3273
- TRoutes,
3274
- TPath,
3275
- TMethod,
3276
- TInput,
3277
- TOutput,
3278
- TCtx,
3279
- TAccMeta,
3280
- TErrorFactory,
3281
- _TErrorKeys,
3282
- TDefaultErrors,
3283
- TMeta,
3284
- TBasePath,
3285
- TTaps,
3286
- TScopedMw
3287
- >
3288
- }
3422
+ own: view.own,
3423
+ rb: this._s.boundaryErrorKey,
3424
+ rm: [...this._s.middlewares],
3425
+ xm: this._s.meta ? { ...this._s.meta } : null,
3289
3426
  }
3290
3427
 
3291
- insertRoute(this._s.root, this._s.method, this._s.path, routeHandler)
3292
-
3293
- /* .on() extra methods — insert same handler for each additional method */
3294
- if (this._s.extraMethods) {
3295
- for (const m of this._s.extraMethods) {
3296
- insertRoute(this._s.root, m, this._s.path, routeHandler)
3297
- if (!this._s.path.includes(":") && !this._s.path.includes("*")) {
3298
- ;(this._s.parent as unknown as HoneyInternal)._registerStatic(`${m} ${this._s.path}`, routeHandler)
3299
- }
3300
- }
3301
- }
3302
-
3303
- /* populate static route map for O(1) lookup on non-parameterized routes */
3304
- if (!this._s.path.includes(":") && !this._s.path.includes("*")) {
3305
- ;(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))
3306
3435
  }
3307
3436
  return this._s.parent as HandlerReturn<
3308
3437
  TEnv,
@@ -3356,7 +3485,7 @@ class RouteBuilder<
3356
3485
  TTaps,
3357
3486
  TScopedMw
3358
3487
  > {
3359
- const proxyHandler = createProxyHandler(config)
3488
+ const proxyHandler = requireFeature("proxy", ".proxy()").createProxyHandler(config)
3360
3489
  return this.handler(
3361
3490
  proxyHandler as (
3362
3491
  ctx: HandlerCtx<
@@ -3537,11 +3666,6 @@ class RouteBuilder<
3537
3666
  TTaps,
3538
3667
  TScopedMw
3539
3668
  > {
3540
- if (mw.errors) {
3541
- for (const k of mw.errors) {
3542
- this._s.errorKeys.add(k)
3543
- }
3544
- }
3545
3669
  return new RouteBuilder<
3546
3670
  TEnv,
3547
3671
  TCtx & TAdds,
@@ -3563,31 +3687,22 @@ class RouteBuilder<
3563
3687
  >({
3564
3688
  ...this._s,
3565
3689
  middlewares: [...this._s.middlewares, mw as RuntimeMiddleware],
3566
- mwMeta: mw.meta ? { ...this._s.mwMeta, ...mw.meta } : this._s.mwMeta,
3567
3690
  })
3568
3691
  }
3569
3692
  }
3570
3693
 
3571
3694
  type WSRouteBuilderState<TParent> = {
3572
- mwMeta?: Record<string, unknown> | null
3573
- boundaryErrorKey: string | null
3574
3695
  errorKeys: Set<string>
3575
3696
  inputSchemas: InputSchemasDef | null
3697
+ origins: WSOriginPolicy | null
3576
3698
  meta: Record<string, unknown> | null
3577
3699
  middlewares: RuntimeMiddleware[]
3578
3700
  parent: TParent
3579
- parentMiddlewares: RuntimeMiddleware[]
3580
3701
  path: string
3581
- root: TreeNode
3702
+ pending: PendingRoute
3582
3703
  }
3583
3704
 
3584
- class WSRouteBuilder<
3585
- TEnv,
3586
- TCtx,
3587
- TInput = {},
3588
- _TErrorKeys extends string = never,
3589
- TParent extends { _markWsRoutes(): void } = { _markWsRoutes(): void },
3590
- > {
3705
+ class WSRouteBuilder<TEnv, TCtx, TInput = {}, _TErrorKeys extends string = never, TParent = unknown> {
3591
3706
  private _s: WSRouteBuilderState<TParent>
3592
3707
 
3593
3708
  constructor(state: WSRouteBuilderState<TParent>) {
@@ -3609,17 +3724,27 @@ class WSRouteBuilder<
3609
3724
  }
3610
3725
 
3611
3726
  handler(wsHandler: WSHandler<TCtx>): TParent {
3727
+ const parent = this._s.parent as unknown as HoneyInternal
3728
+ const view = parent._view()
3612
3729
  const routeHandler: WSRouteHandler = {
3613
- bek: this._s.boundaryErrorKey,
3614
- 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(),
3615
3735
  fn: wsHandler as WSHandler<unknown>,
3616
3736
  iv: this._s.inputSchemas,
3617
- mt: mergeContributedMeta(this._s.mwMeta, this._s.meta),
3618
- 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],
3619
3743
  rp: this._s.path,
3744
+ xm: this._s.meta ? { ...this._s.meta } : null,
3620
3745
  }
3621
- insertWsRoute(this._s.root, this._s.path, routeHandler)
3622
- ;(this._s.parent as unknown as HoneyInternal)._markWsRoutes()
3746
+ parent._settle(this._s.pending)
3747
+ parent._addWsRoute(parsePattern(this._s.path), routeHandler)
3623
3748
  return this._s.parent
3624
3749
  }
3625
3750
 
@@ -3633,20 +3758,26 @@ class WSRouteBuilder<
3633
3758
  }
3634
3759
 
3635
3760
  meta(meta: Record<string, unknown>): this {
3636
- this._s.meta = meta
3761
+ this._s.meta = { ...this._s.meta, ...meta }
3637
3762
  return this
3638
3763
  }
3639
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
+
3640
3777
  use<TAdds>(mw: MiddlewareFn<TCtx, TAdds>): WSRouteBuilder<TEnv, TCtx & TAdds, TInput, _TErrorKeys, TParent> {
3641
- if (mw.errors) {
3642
- for (const k of mw.errors) {
3643
- this._s.errorKeys.add(k)
3644
- }
3645
- }
3646
3778
  return new WSRouteBuilder<TEnv, TCtx & TAdds, TInput, _TErrorKeys, TParent>({
3647
3779
  ...this._s,
3648
3780
  middlewares: [...this._s.middlewares, mw as RuntimeMiddleware],
3649
- mwMeta: mw.meta ? { ...this._s.mwMeta, ...mw.meta } : this._s.mwMeta,
3650
3781
  })
3651
3782
  }
3652
3783
  }