@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.
- package/README.md +514 -192
- package/bin/honey.js +25 -0
- package/dist/accepts.d.ts +7 -1
- package/dist/accepts.d.ts.map +1 -1
- package/dist/accepts.js +126 -0
- package/dist/accepts.js.map +1 -0
- package/dist/body-kind.d.ts +26 -0
- package/dist/body-kind.d.ts.map +1 -0
- package/dist/body-kind.js +70 -0
- package/dist/body-kind.js.map +1 -0
- package/dist/body-limit.d.ts +5 -1
- package/dist/body-limit.d.ts.map +1 -1
- package/dist/body-limit.js +126 -0
- package/dist/body-limit.js.map +1 -0
- package/dist/build/index.d.ts +19 -3
- package/dist/build/index.d.ts.map +1 -1
- package/dist/build/index.js +173 -0
- package/dist/build/index.js.map +1 -0
- package/dist/cli-args.d.ts +33 -0
- package/dist/cli-args.d.ts.map +1 -0
- package/dist/cli-args.js +102 -0
- package/dist/cli-args.js.map +1 -0
- package/dist/cli-go/auth.go +26 -0
- package/dist/cli-go/config.go +192 -0
- package/dist/cli-go/errors.go +75 -0
- package/dist/cli-go/multipart.go +79 -0
- package/dist/cli-go/output.go +163 -0
- package/dist/cli-go/root.go +30 -0
- package/dist/cli-go/stream.go +88 -0
- package/dist/cli-go/version.go +5 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +209 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/defaults.js +48 -0
- package/dist/client/defaults.js.map +1 -0
- package/dist/client/error.d.ts +35 -0
- package/dist/client/error.d.ts.map +1 -1
- package/dist/client/error.js +71 -0
- package/dist/client/error.js.map +1 -0
- package/dist/client/http.d.ts +67 -8
- package/dist/client/http.d.ts.map +1 -1
- package/dist/client/http.js +571 -0
- package/dist/client/http.js.map +1 -0
- package/dist/client/index.d.ts +3 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +95 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/path.d.ts +26 -0
- package/dist/client/path.d.ts.map +1 -0
- package/dist/client/path.js +163 -0
- package/dist/client/path.js.map +1 -0
- package/dist/client/redirect-policy.d.ts +12 -0
- package/dist/client/redirect-policy.d.ts.map +1 -0
- package/dist/client/redirect-policy.js +20 -0
- package/dist/client/redirect-policy.js.map +1 -0
- package/dist/client/sdk.d.ts +36 -0
- package/dist/client/sdk.d.ts.map +1 -1
- package/dist/client/sdk.js +292 -0
- package/dist/client/sdk.js.map +1 -0
- package/dist/client/sse.d.ts +13 -0
- package/dist/client/sse.d.ts.map +1 -1
- package/dist/client/sse.js +129 -0
- package/dist/client/sse.js.map +1 -0
- package/dist/client/types.js +2 -0
- package/dist/client/types.js.map +1 -0
- package/dist/client/ws.d.ts +9 -2
- package/dist/client/ws.d.ts.map +1 -1
- package/dist/client/ws.js +112 -0
- package/dist/client/ws.js.map +1 -0
- package/dist/client-go/errors.go +236 -0
- package/dist/client-go/export.go +44 -0
- package/dist/client-go/invalidation.go +371 -0
- package/dist/client-go/nullable.go +62 -0
- package/dist/client-go/realtime.go +354 -0
- package/dist/client-go/result.go +20 -0
- package/dist/client-go/runtime.go +975 -0
- package/dist/client-go/sse.go +159 -0
- package/dist/client-go/transport.go +495 -0
- package/dist/client-go/ws.go +128 -0
- package/dist/client-info.d.ts +36 -0
- package/dist/client-info.d.ts.map +1 -0
- package/dist/client-info.js +53 -0
- package/dist/client-info.js.map +1 -0
- package/dist/client-mcp/README.md +10 -0
- package/dist/client-mcp/server.ts +221 -0
- package/dist/client-mcp/types.ts +19 -0
- package/dist/client-python/_errors.py +115 -0
- package/dist/client-python/_invalidation.py +494 -0
- package/dist/client-python/_realtime.py +179 -0
- package/dist/client-python/_runtime.py +860 -0
- package/dist/client-python/_sse.py +155 -0
- package/dist/client-python/_transport.py +366 -0
- package/dist/client-python/_ws.py +159 -0
- package/dist/client-python/py.typed +0 -0
- package/dist/client-rust/errors.rs +220 -0
- package/dist/client-rust/invalidation.rs +412 -0
- package/dist/client-rust/invalidation_sync.rs +93 -0
- package/dist/client-rust/nullable.rs +17 -0
- package/dist/client-rust/realtime.rs +702 -0
- package/dist/client-rust/result.rs +22 -0
- package/dist/client-rust/runtime.rs +980 -0
- package/dist/client-rust/runtime_sync.rs +484 -0
- package/dist/client-rust/sse.rs +188 -0
- package/dist/client-rust/ws.rs +167 -0
- package/dist/codegen-go-cli.d.ts.map +1 -1
- package/dist/codegen-go-cli.js +964 -0
- package/dist/codegen-go-cli.js.map +1 -0
- package/dist/codegen-go.d.ts +15 -3
- package/dist/codegen-go.d.ts.map +1 -1
- package/dist/codegen-go.js +872 -0
- package/dist/codegen-go.js.map +1 -0
- package/dist/codegen-hash.js +9 -0
- package/dist/codegen-hash.js.map +1 -0
- package/dist/codegen-ir.d.ts +69 -2
- package/dist/codegen-ir.d.ts.map +1 -1
- package/dist/codegen-ir.js +661 -0
- package/dist/codegen-ir.js.map +1 -0
- package/dist/codegen-lang.d.ts +65 -0
- package/dist/codegen-lang.d.ts.map +1 -0
- package/dist/codegen-lang.js +526 -0
- package/dist/codegen-lang.js.map +1 -0
- package/dist/codegen-load.d.ts +31 -0
- package/dist/codegen-load.d.ts.map +1 -0
- package/dist/codegen-load.js +138 -0
- package/dist/codegen-load.js.map +1 -0
- package/dist/codegen-loaders.js +55 -0
- package/dist/codegen-loaders.js.map +1 -0
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-mcp.js +368 -0
- package/dist/codegen-mcp.js.map +1 -0
- package/dist/codegen-python.d.ts +13 -1
- package/dist/codegen-python.d.ts.map +1 -1
- package/dist/codegen-python.js +828 -0
- package/dist/codegen-python.js.map +1 -0
- package/dist/codegen-route-tree-intern.d.ts +15 -5
- package/dist/codegen-route-tree-intern.d.ts.map +1 -1
- package/dist/codegen-route-tree-intern.js +221 -0
- package/dist/codegen-route-tree-intern.js.map +1 -0
- package/dist/codegen-rust.d.ts.map +1 -1
- package/dist/codegen-rust.js +1293 -0
- package/dist/codegen-rust.js.map +1 -0
- package/dist/codegen-sanitize.d.ts.map +1 -1
- package/dist/codegen-sanitize.js +56 -0
- package/dist/codegen-sanitize.js.map +1 -0
- package/dist/codegen-schema-naming.d.ts +5 -0
- package/dist/codegen-schema-naming.d.ts.map +1 -1
- package/dist/codegen-schema-naming.js +150 -0
- package/dist/codegen-schema-naming.js.map +1 -0
- package/dist/codegen-sdk-model.d.ts +71 -0
- package/dist/codegen-sdk-model.d.ts.map +1 -0
- package/dist/codegen-sdk-model.js +179 -0
- package/dist/codegen-sdk-model.js.map +1 -0
- package/dist/codegen.d.ts +41 -23
- package/dist/codegen.d.ts.map +1 -1
- package/dist/codegen.js +4610 -0
- package/dist/codegen.js.map +1 -0
- package/dist/context.d.ts +36 -6
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +242 -0
- package/dist/context.js.map +1 -0
- package/dist/cookie-sign.d.ts +16 -2
- package/dist/cookie-sign.d.ts.map +1 -1
- package/dist/cookie-sign.js +104 -0
- package/dist/cookie-sign.js.map +1 -0
- package/dist/cookie.d.ts +12 -0
- package/dist/cookie.d.ts.map +1 -1
- package/dist/cookie.js +138 -0
- package/dist/cookie.js.map +1 -0
- package/dist/cors.d.ts +1 -0
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +87 -0
- package/dist/cors.js.map +1 -0
- package/dist/crypto.d.ts +6 -1
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +38 -0
- package/dist/crypto.js.map +1 -0
- package/dist/csrf.d.ts +19 -0
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +79 -0
- package/dist/csrf.js.map +1 -0
- package/dist/curl-logger.d.ts +14 -3
- package/dist/curl-logger.d.ts.map +1 -1
- package/dist/curl-logger.js +199 -0
- package/dist/curl-logger.js.map +1 -0
- package/dist/detect-runtime.js +13 -0
- package/dist/detect-runtime.js.map +1 -0
- package/dist/dict.d.ts +11 -0
- package/dist/dict.d.ts.map +1 -0
- package/dist/dict.js +13 -0
- package/dist/dict.js.map +1 -0
- package/dist/error.d.ts.map +1 -1
- package/dist/error.js +44 -0
- package/dist/error.js.map +1 -0
- package/dist/errors.js +39 -0
- package/dist/errors.js.map +1 -0
- package/dist/etag.d.ts +2 -0
- package/dist/etag.d.ts.map +1 -1
- package/dist/etag.js +82 -0
- package/dist/etag.js.map +1 -0
- package/dist/feature-detect.d.ts +25 -0
- package/dist/feature-detect.d.ts.map +1 -0
- package/dist/feature-detect.js +144 -0
- package/dist/feature-detect.js.map +1 -0
- package/dist/feature-load.js +36 -0
- package/dist/feature-load.js.map +1 -0
- package/dist/feature-slots.d.ts +44 -0
- package/dist/feature-slots.d.ts.map +1 -0
- package/dist/feature-slots.js +48 -0
- package/dist/feature-slots.js.map +1 -0
- package/dist/fetch-request.d.ts +10 -0
- package/dist/fetch-request.d.ts.map +1 -0
- package/dist/fetch-request.js +13 -0
- package/dist/fetch-request.js.map +1 -0
- package/dist/gen-process.d.ts +15 -0
- package/dist/gen-process.d.ts.map +1 -0
- package/dist/gen-process.js +43 -0
- package/dist/gen-process.js.map +1 -0
- package/dist/gen-write.d.ts +19 -0
- package/dist/gen-write.d.ts.map +1 -0
- package/dist/gen-write.js +120 -0
- package/dist/gen-write.js.map +1 -0
- package/dist/glob.d.ts +21 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +115 -0
- package/dist/glob.js.map +1 -0
- package/dist/go-type-emitter.d.ts +18 -4
- package/dist/go-type-emitter.d.ts.map +1 -1
- package/dist/go-type-emitter.js +594 -0
- package/dist/go-type-emitter.js.map +1 -0
- package/dist/honey-response.d.ts.map +1 -1
- package/dist/honey-response.js +338 -0
- package/dist/honey-response.js.map +1 -0
- package/dist/i18n-slot.d.ts +8 -0
- package/dist/i18n-slot.d.ts.map +1 -1
- package/dist/i18n-slot.js +14 -0
- package/dist/i18n-slot.js.map +1 -0
- package/dist/i18n.d.ts +3 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +250 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +408 -148
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2324 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +3 -5
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +140 -0
- package/dist/init.js.map +1 -0
- package/dist/input.js +4 -0
- package/dist/input.js.map +1 -0
- package/dist/invalidate-check.d.ts +11 -0
- package/dist/invalidate-check.d.ts.map +1 -1
- package/dist/invalidate-check.js +163 -0
- package/dist/invalidate-check.js.map +1 -0
- package/dist/invoke-user.d.ts +35 -0
- package/dist/invoke-user.d.ts.map +1 -0
- package/dist/invoke-user.js +73 -0
- package/dist/invoke-user.js.map +1 -0
- package/dist/ip-restrict.d.ts +13 -1
- package/dist/ip-restrict.d.ts.map +1 -1
- package/dist/ip-restrict.js +60 -0
- package/dist/ip-restrict.js.map +1 -0
- package/dist/ip.d.ts +39 -0
- package/dist/ip.d.ts.map +1 -0
- package/dist/ip.js +209 -0
- package/dist/ip.js.map +1 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +113 -0
- package/dist/logger.js.map +1 -0
- package/dist/media-type.d.ts +30 -0
- package/dist/media-type.d.ts.map +1 -0
- package/dist/media-type.js +97 -0
- package/dist/media-type.js.map +1 -0
- package/dist/meta-spec-merge.d.ts +32 -0
- package/dist/meta-spec-merge.d.ts.map +1 -0
- package/dist/meta-spec-merge.js +68 -0
- package/dist/meta-spec-merge.js.map +1 -0
- package/dist/meta-spec.d.ts +16 -3
- package/dist/meta-spec.d.ts.map +1 -1
- package/dist/meta-spec.js +535 -0
- package/dist/meta-spec.js.map +1 -0
- package/dist/middleware.d.ts +23 -6
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +253 -0
- package/dist/middleware.js.map +1 -0
- package/dist/node-request.d.ts +57 -12
- package/dist/node-request.d.ts.map +1 -1
- package/dist/node-request.js +407 -0
- package/dist/node-request.js.map +1 -0
- package/dist/node.d.ts +20 -3
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +396 -0
- package/dist/node.js.map +1 -0
- package/dist/openapi/collect.d.ts +6 -3
- package/dist/openapi/collect.d.ts.map +1 -1
- package/dist/openapi/collect.js +30 -0
- package/dist/openapi/collect.js.map +1 -0
- package/dist/openapi/docs-page.d.ts +42 -0
- package/dist/openapi/docs-page.d.ts.map +1 -0
- package/dist/openapi/docs-page.js +80 -0
- package/dist/openapi/docs-page.js.map +1 -0
- package/dist/openapi/document.d.ts +4 -0
- package/dist/openapi/document.d.ts.map +1 -1
- package/dist/openapi/document.js +785 -0
- package/dist/openapi/document.js.map +1 -0
- package/dist/openapi/json-schema-slot.d.ts +7 -0
- package/dist/openapi/json-schema-slot.d.ts.map +1 -1
- package/dist/openapi/json-schema-slot.js +38 -0
- package/dist/openapi/json-schema-slot.js.map +1 -0
- package/dist/openapi/register.d.ts.map +1 -1
- package/dist/openapi/register.js +20 -0
- package/dist/openapi/register.js.map +1 -0
- package/dist/openapi/scalar.d.ts +3 -1
- package/dist/openapi/scalar.d.ts.map +1 -1
- package/dist/openapi/scalar.js +21 -0
- package/dist/openapi/scalar.js.map +1 -0
- package/dist/openapi/serve.d.ts +9 -0
- package/dist/openapi/serve.d.ts.map +1 -0
- package/dist/openapi/serve.js +36 -0
- package/dist/openapi/serve.js.map +1 -0
- package/dist/openapi/spec-factory.d.ts +30 -1
- package/dist/openapi/spec-factory.d.ts.map +1 -1
- package/dist/openapi/spec-factory.js +52 -0
- package/dist/openapi/spec-factory.js.map +1 -0
- package/dist/openapi/spec.d.ts +14 -5
- package/dist/openapi/spec.d.ts.map +1 -1
- package/dist/openapi/spec.js +50 -0
- package/dist/openapi/spec.js.map +1 -0
- package/dist/openapi/swagger.d.ts +3 -1
- package/dist/openapi/swagger.d.ts.map +1 -1
- package/dist/openapi/swagger.js +22 -0
- package/dist/openapi/swagger.js.map +1 -0
- package/dist/pattern.d.ts +71 -0
- package/dist/pattern.d.ts.map +1 -0
- package/dist/pattern.js +239 -0
- package/dist/pattern.js.map +1 -0
- package/dist/peer.d.ts +17 -0
- package/dist/peer.d.ts.map +1 -0
- package/dist/peer.js +50 -0
- package/dist/peer.js.map +1 -0
- package/dist/plugin.d.ts +67 -16
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +649 -0
- package/dist/plugin.js.map +1 -0
- package/dist/powered-by.d.ts.map +1 -1
- package/dist/powered-by.js +11 -0
- package/dist/powered-by.js.map +1 -0
- package/dist/pretty-json.d.ts.map +1 -1
- package/dist/pretty-json.js +47 -0
- package/dist/pretty-json.js.map +1 -0
- package/dist/producer-stream.d.ts +47 -0
- package/dist/producer-stream.d.ts.map +1 -0
- package/dist/producer-stream.js +267 -0
- package/dist/producer-stream.js.map +1 -0
- package/dist/proxy.d.ts +47 -8
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +274 -0
- package/dist/proxy.js.map +1 -0
- package/dist/python-type-emitter.d.ts +14 -1
- package/dist/python-type-emitter.d.ts.map +1 -1
- package/dist/python-type-emitter.js +157 -0
- package/dist/python-type-emitter.js.map +1 -0
- package/dist/realtime/bus.d.ts +17 -1
- package/dist/realtime/bus.d.ts.map +1 -1
- package/dist/realtime/bus.js +112 -0
- package/dist/realtime/bus.js.map +1 -0
- package/dist/realtime/register.d.ts +3 -0
- package/dist/realtime/register.d.ts.map +1 -0
- package/dist/realtime/register.js +10 -0
- package/dist/realtime/register.js.map +1 -0
- package/dist/realtime/route.d.ts +65 -26
- package/dist/realtime/route.d.ts.map +1 -1
- package/dist/realtime/route.js +72 -0
- package/dist/realtime/route.js.map +1 -0
- package/dist/realtime/server.d.ts +43 -0
- package/dist/realtime/server.d.ts.map +1 -0
- package/dist/realtime/server.js +255 -0
- package/dist/realtime/server.js.map +1 -0
- package/dist/request-id.d.ts +7 -0
- package/dist/request-id.d.ts.map +1 -1
- package/dist/request-id.js +16 -0
- package/dist/request-id.js.map +1 -0
- package/dist/request-limits.d.ts +3 -0
- package/dist/request-limits.d.ts.map +1 -0
- package/dist/request-limits.js +3 -0
- package/dist/request-limits.js.map +1 -0
- package/dist/request-path.d.ts +33 -0
- package/dist/request-path.d.ts.map +1 -0
- package/dist/request-path.js +178 -0
- package/dist/request-path.js.map +1 -0
- package/dist/request-to-curl.d.ts +7 -1
- package/dist/request-to-curl.d.ts.map +1 -1
- package/dist/request-to-curl.js +48 -0
- package/dist/request-to-curl.js.map +1 -0
- package/dist/response.d.ts +25 -2
- package/dist/response.d.ts.map +1 -1
- package/dist/response.js +313 -0
- package/dist/response.js.map +1 -0
- package/dist/rust-type-emitter.d.ts +14 -2
- package/dist/rust-type-emitter.d.ts.map +1 -1
- package/dist/rust-type-emitter.js +463 -0
- package/dist/rust-type-emitter.js.map +1 -0
- package/dist/secure-headers.d.ts.map +1 -1
- package/dist/secure-headers.js +53 -0
- package/dist/secure-headers.js.map +1 -0
- package/dist/serve-register.d.ts +4 -0
- package/dist/serve-register.d.ts.map +1 -1
- package/dist/serve-register.js +9 -0
- package/dist/serve-register.js.map +1 -0
- package/dist/serve-slot.d.ts +3 -0
- package/dist/serve-slot.d.ts.map +1 -1
- package/dist/serve-slot.js +52 -0
- package/dist/serve-slot.js.map +1 -0
- package/dist/serve.d.ts +18 -1
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +157 -0
- package/dist/serve.js.map +1 -0
- package/dist/server-timing.d.ts.map +1 -1
- package/dist/server-timing.js +51 -0
- package/dist/server-timing.js.map +1 -0
- package/dist/static.d.ts +9 -0
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +102 -0
- package/dist/static.js.map +1 -0
- package/dist/telemetry/otel.d.ts +2 -0
- package/dist/telemetry/otel.d.ts.map +1 -1
- package/dist/telemetry/otel.js +135 -0
- package/dist/telemetry/otel.js.map +1 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +124 -0
- package/dist/testing.js.map +1 -0
- package/dist/timeout.d.ts +6 -0
- package/dist/timeout.d.ts.map +1 -1
- package/dist/timeout.js +36 -0
- package/dist/timeout.js.map +1 -0
- package/dist/tree.d.ts +133 -36
- package/dist/tree.d.ts.map +1 -1
- package/dist/tree.js +437 -0
- package/dist/tree.js.map +1 -0
- package/dist/trust.d.ts +21 -0
- package/dist/trust.d.ts.map +1 -0
- package/dist/trust.js +100 -0
- package/dist/trust.js.map +1 -0
- package/dist/ts-type-emitter.d.ts.map +1 -1
- package/dist/ts-type-emitter.js +150 -0
- package/dist/ts-type-emitter.js.map +1 -0
- package/dist/type-emitter.d.ts +16 -2
- package/dist/type-emitter.d.ts.map +1 -1
- package/dist/type-emitter.js +729 -0
- package/dist/type-emitter.js.map +1 -0
- package/dist/type-extractor.d.ts +8 -4
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +776 -0
- package/dist/type-extractor.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +96 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +14 -2
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +335 -0
- package/dist/validation.js.map +1 -0
- package/dist/with-headers.d.ts +15 -0
- package/dist/with-headers.d.ts.map +1 -0
- package/dist/with-headers.js +45 -0
- package/dist/with-headers.js.map +1 -0
- package/dist/ws/bun.d.ts +11 -5
- package/dist/ws/bun.d.ts.map +1 -1
- package/dist/ws/bun.js +82 -0
- package/dist/ws/bun.js.map +1 -0
- package/dist/ws/cloudflare.d.ts +25 -3
- package/dist/ws/cloudflare.d.ts.map +1 -1
- package/dist/ws/cloudflare.js +117 -0
- package/dist/ws/cloudflare.js.map +1 -0
- package/dist/ws/deno.d.ts +4 -4
- package/dist/ws/deno.d.ts.map +1 -1
- package/dist/ws/deno.js +133 -0
- package/dist/ws/deno.js.map +1 -0
- package/dist/ws/node.d.ts +20 -3
- package/dist/ws/node.d.ts.map +1 -1
- package/dist/ws/node.js +173 -0
- package/dist/ws/node.js.map +1 -0
- package/dist/ws/session.d.ts +11 -0
- package/dist/ws/session.d.ts.map +1 -0
- package/dist/ws/session.js +60 -0
- package/dist/ws/session.js.map +1 -0
- package/dist/ws/shared.d.ts +65 -0
- package/dist/ws/shared.d.ts.map +1 -0
- package/dist/ws/shared.js +103 -0
- package/dist/ws/shared.js.map +1 -0
- package/dist/ws-origin.d.ts +20 -0
- package/dist/ws-origin.d.ts.map +1 -0
- package/dist/ws-origin.js +41 -0
- package/dist/ws-origin.js.map +1 -0
- package/dist/yaml.d.ts +15 -1
- package/dist/yaml.d.ts.map +1 -1
- package/dist/yaml.js +101 -0
- package/dist/yaml.js.map +1 -0
- package/docs/invalidate-spec.md +13 -1
- package/docs/meta-spec.md +36 -12
- package/docs/packaging.md +43 -24
- package/docs/regression-matrix/README.md +32 -0
- package/docs/regression-matrix/review.md +44 -0
- package/docs/regression-matrix/ws1-3.md +97 -0
- package/docs/regression-matrix/ws4-8.md +107 -0
- package/docs/regression-matrix/ws9-12.md +266 -0
- package/docs/sdk.md +102 -52
- package/package.json +183 -52
- package/src/accepts.ts +91 -33
- package/src/body-kind.ts +67 -0
- package/src/body-limit.ts +102 -87
- package/src/build/index.ts +104 -54
- package/src/cli-args.ts +123 -0
- package/src/cli-go/auth.go +12 -1
- package/src/cli-go/config.go +29 -2
- package/src/cli-go/errors.go +28 -10
- package/src/cli-go/multipart.go +51 -27
- package/src/cli-go/output.go +58 -4
- package/src/cli-go/root.go +1 -1
- package/src/cli-go/version.go +2 -2
- package/src/cli.ts +154 -148
- package/src/client/error.ts +56 -1
- package/src/client/http.ts +479 -256
- package/src/client/index.ts +55 -17
- package/src/client/path.ts +169 -0
- package/src/client/redirect-policy.ts +19 -0
- package/src/client/sdk.ts +180 -101
- package/src/client/sse.ts +92 -66
- package/src/client/ws.ts +67 -31
- package/src/client-go/errors.go +41 -12
- package/src/client-go/export.go +44 -0
- package/src/client-go/invalidation.go +52 -54
- package/src/client-go/nullable.go +62 -0
- package/src/client-go/realtime.go +147 -110
- package/src/client-go/runtime.go +646 -88
- package/src/client-go/sse.go +104 -94
- package/src/client-go/transport.go +67 -27
- package/src/client-go/ws.go +49 -25
- package/src/client-info.ts +79 -0
- package/src/client-mcp/server.ts +133 -3
- package/src/client-python/_invalidation.py +207 -291
- package/src/client-python/_realtime.py +63 -40
- package/src/client-python/_runtime.py +501 -119
- package/src/client-python/_sse.py +130 -74
- package/src/client-python/_transport.py +37 -9
- package/src/client-python/_ws.py +50 -22
- package/src/client-rust/errors.rs +21 -1
- package/src/client-rust/invalidation.rs +202 -231
- package/src/client-rust/invalidation_sync.rs +16 -229
- package/src/client-rust/nullable.rs +17 -0
- package/src/client-rust/realtime.rs +217 -217
- package/src/client-rust/runtime.rs +602 -177
- package/src/client-rust/runtime_sync.rs +169 -145
- package/src/client-rust/sse.rs +142 -121
- package/src/client-rust/ws.rs +61 -37
- package/src/codegen-go-cli.ts +607 -728
- package/src/codegen-go.ts +710 -770
- package/src/codegen-ir.ts +381 -75
- package/src/codegen-lang.ts +517 -0
- package/src/codegen-load.ts +150 -0
- package/src/codegen-mcp.ts +207 -108
- package/src/codegen-python.ts +684 -816
- package/src/codegen-route-tree-intern.ts +51 -25
- package/src/codegen-rust.ts +946 -1240
- package/src/codegen-sanitize.ts +0 -4
- package/src/codegen-schema-naming.ts +19 -6
- package/src/codegen-sdk-model.ts +228 -0
- package/src/codegen.ts +1488 -1140
- package/src/context.ts +130 -11
- package/src/cookie-sign.ts +85 -16
- package/src/cookie.ts +66 -11
- package/src/cors.ts +55 -54
- package/src/crypto.ts +27 -5
- package/src/csrf.ts +57 -25
- package/src/curl-logger.ts +155 -114
- package/src/dict.ts +12 -0
- package/src/error.ts +4 -2
- package/src/etag.ts +58 -34
- package/src/feature-detect.ts +147 -0
- package/src/feature-slots.ts +70 -0
- package/src/fetch-request.ts +13 -0
- package/src/gen-process.ts +42 -0
- package/src/gen-write.ts +127 -0
- package/src/glob.ts +109 -0
- package/src/go-type-emitter.ts +406 -155
- package/src/honey-response.ts +36 -9
- package/src/i18n-slot.ts +10 -0
- package/src/i18n.ts +98 -23
- package/src/index.ts +1887 -1756
- package/src/init.ts +40 -28
- package/src/invalidate-check.ts +42 -4
- package/src/invoke-user.ts +95 -0
- package/src/ip-restrict.ts +47 -161
- package/src/ip.ts +194 -0
- package/src/logger.ts +65 -36
- package/src/media-type.ts +104 -0
- package/src/meta-spec-merge.ts +80 -0
- package/src/meta-spec.ts +0 -0
- package/src/middleware.ts +112 -12
- package/src/node-request.ts +242 -52
- package/src/node.ts +318 -75
- package/src/openapi/collect.ts +19 -44
- package/src/openapi/docs-page.ts +89 -0
- package/src/openapi/document.ts +503 -224
- package/src/openapi/json-schema-slot.ts +40 -0
- package/src/openapi/register.ts +7 -2
- package/src/openapi/scalar.ts +11 -10
- package/src/openapi/serve.ts +39 -0
- package/src/openapi/spec-factory.ts +50 -1
- package/src/openapi/spec.ts +53 -30
- package/src/openapi/swagger.ts +12 -11
- package/src/pattern.ts +245 -0
- package/src/peer.ts +53 -0
- package/src/plugin.ts +270 -185
- package/src/powered-by.ts +2 -2
- package/src/pretty-json.ts +25 -9
- package/src/producer-stream.ts +296 -0
- package/src/proxy.ts +253 -55
- package/src/python-type-emitter.ts +68 -56
- package/src/realtime/bus.ts +56 -9
- package/src/realtime/register.ts +13 -0
- package/src/realtime/route.ts +129 -80
- package/src/realtime/server.ts +298 -0
- package/src/request-id.ts +13 -3
- package/src/request-limits.ts +2 -0
- package/src/request-path.ts +165 -0
- package/src/request-to-curl.ts +28 -7
- package/src/response.ts +143 -103
- package/src/rust-type-emitter.ts +265 -170
- package/src/secure-headers.ts +8 -4
- package/src/serve-register.ts +4 -0
- package/src/serve-slot.ts +38 -0
- package/src/serve.ts +113 -25
- package/src/server-timing.ts +11 -7
- package/src/static.ts +85 -32
- package/src/telemetry/otel.ts +29 -7
- package/src/testing.ts +76 -14
- package/src/timeout.ts +15 -7
- package/src/tree.ts +405 -333
- package/src/trust.ts +117 -0
- package/src/ts-type-emitter.ts +18 -12
- package/src/type-emitter.ts +227 -89
- package/src/type-extractor.ts +562 -424
- package/src/types.ts +8 -0
- package/src/validation.ts +221 -96
- package/src/with-headers.ts +45 -0
- package/src/ws/bun.ts +53 -15
- package/src/ws/cloudflare.ts +81 -19
- package/src/ws/deno.ts +124 -36
- package/src/ws/node.ts +173 -79
- package/src/ws/session.ts +73 -0
- package/src/ws/shared.ts +140 -0
- package/src/ws-origin.ts +53 -0
- package/src/yaml.ts +45 -22
- package/dist/client-mcp/server.d.ts +0 -10
- package/dist/client-mcp/server.d.ts.map +0 -1
- package/dist/client-mcp/types.d.ts +0 -16
- package/dist/client-mcp/types.d.ts.map +0 -1
- package/dist/cloudflare/realtime.d.ts +0 -30
- package/dist/cloudflare/realtime.d.ts.map +0 -1
- package/dist/realtime/buffer.d.ts +0 -18
- package/dist/realtime/buffer.d.ts.map +0 -1
- package/dist/realtime/longpoll.d.ts +0 -23
- package/dist/realtime/longpoll.d.ts.map +0 -1
- package/dist/realtime/protocol.d.ts +0 -50
- package/dist/realtime/protocol.d.ts.map +0 -1
- package/dist/realtime/runtime.d.ts +0 -74
- package/dist/realtime/runtime.d.ts.map +0 -1
- package/src/cloudflare/realtime.ts +0 -234
- package/src/realtime/buffer.ts +0 -101
- package/src/realtime/longpoll.ts +0 -148
- package/src/realtime/protocol.ts +0 -128
- package/src/realtime/runtime.ts +0 -427
package/src/index.ts
CHANGED
|
@@ -1,23 +1,52 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
|
|
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 {
|
|
16
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
/**
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (
|
|
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
|
-
/**
|
|
223
|
-
function
|
|
224
|
-
|
|
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
|
-
/**
|
|
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<
|
|
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
|
|
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
|
-
|
|
337
|
-
private
|
|
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.
|
|
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.
|
|
397
|
-
this.
|
|
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
|
|
445
|
-
return this._graph.
|
|
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
|
-
|
|
448
|
-
|
|
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 —
|
|
452
|
-
|
|
453
|
-
return this.
|
|
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
|
-
/**
|
|
464
|
-
|
|
465
|
-
this.
|
|
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
|
-
/**
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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.
|
|
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.
|
|
705
|
+
return this._graph.settings.defaultBoundaryKey
|
|
484
706
|
}
|
|
485
707
|
|
|
486
|
-
/** Convert unknown thrown value to error Response —
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
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<
|
|
606
|
-
|
|
762
|
+
): Promise<HoneyError> {
|
|
763
|
+
const i18n = this._s.errorI18n
|
|
764
|
+
if (!i18n) return honeyError
|
|
607
765
|
try {
|
|
608
|
-
const locale = await
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
*
|
|
651
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
697
|
-
if (this._onError) {
|
|
853
|
+
if (s.onError) {
|
|
698
854
|
try {
|
|
699
|
-
const customResult = await
|
|
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
|
|
703
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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.
|
|
926
|
+
this._s.logger = logger
|
|
883
927
|
return this
|
|
884
928
|
}
|
|
885
929
|
|
|
886
|
-
outputValidation(mode:
|
|
887
|
-
this.
|
|
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.
|
|
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.
|
|
979
|
+
this._s.stripPrefix = normalized === "" || normalized === "/" ? null : normalized
|
|
903
980
|
return this
|
|
904
981
|
}
|
|
905
982
|
|
|
906
983
|
wsAdapter(adapter: WSAdapter): this {
|
|
907
|
-
this.
|
|
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
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
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
|
-
|
|
979
|
-
const candidates = options.docsPath ? [
|
|
980
|
-
let mounted =
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
}
|
|
1053
|
+
tried = p
|
|
1054
|
+
mounted = this._mountInternalGet(p, ui)
|
|
1055
|
+
if (mounted !== "user") break
|
|
986
1056
|
}
|
|
987
|
-
if (
|
|
988
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
|
1032
|
-
|
|
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:
|
|
1036
|
-
|
|
1037
|
-
|
|
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: [
|
|
1135
|
+
mw: [],
|
|
1042
1136
|
os: null,
|
|
1043
1137
|
ov: null,
|
|
1044
|
-
|
|
1138
|
+
own: g.settings,
|
|
1139
|
+
rb: null,
|
|
1140
|
+
rm: [],
|
|
1141
|
+
xm: null,
|
|
1045
1142
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
1565
|
+
s.errorSchema = null
|
|
1566
|
+
s.errorFormatter = schemaOrFn
|
|
1065
1567
|
} else {
|
|
1066
|
-
|
|
1568
|
+
s.errorSchema = schemaOrFn
|
|
1067
1569
|
const mapper = maybeFn as (error: HoneyError) => Record<string, unknown>
|
|
1068
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1586
|
+
s.customErrorSchema = null
|
|
1587
|
+
s.customErrorFormatter = schemaOrFn
|
|
1085
1588
|
} else {
|
|
1086
|
-
|
|
1589
|
+
s.customErrorSchema = schemaOrFn
|
|
1087
1590
|
const mapper = maybeFn as (error: HoneyError, data: Record<string, unknown>) => Record<string, unknown>
|
|
1088
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
this.
|
|
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.
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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.
|
|
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.
|
|
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
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
|
1438
|
-
|
|
1439
|
-
errors:
|
|
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
|
|
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
|
-
|
|
1452
|
-
|
|
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<
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
-
|
|
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 [
|
|
1501
|
-
|
|
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
|
-
|
|
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 (!
|
|
1507
|
-
this.
|
|
2111
|
+
if (!g.realtimeBus && sub._graph.realtimeBus) {
|
|
2112
|
+
this._setBus(sub._graph.realtimeBus)
|
|
1508
2113
|
}
|
|
1509
|
-
if (
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
1516
|
-
this._applyAllScopedErrors()
|
|
1517
|
-
for (const entry of this._scopedMiddlewares) this._applyScopedEntryMeta(entry)
|
|
2121
|
+
this._bumpEpoch()
|
|
1518
2122
|
}
|
|
1519
|
-
return this
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
|
1557
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2311
|
+
origins: null,
|
|
1693
2312
|
parent: this,
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
root: this._root,
|
|
2313
|
+
path: fullPath,
|
|
2314
|
+
pending,
|
|
1697
2315
|
})
|
|
1698
2316
|
}
|
|
1699
2317
|
|
|
1700
|
-
realtime
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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: [
|
|
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.
|
|
1749
|
-
|
|
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
|
-
|
|
2381
|
+
wsAdapter?.preUpgrade !== undefined &&
|
|
1757
2382
|
!this.trailingSlashRedirects(path) &&
|
|
1758
|
-
|
|
1759
|
-
|
|
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
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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
|
-
|
|
1807
|
-
if (
|
|
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
|
-
|
|
1813
|
-
if (
|
|
1814
|
-
if (path
|
|
1815
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
1863
|
-
|
|
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
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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 =
|
|
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
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
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
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
if (
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
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
|
|
2547
|
+
return new Response("Upgrade Required", {
|
|
2548
|
+
headers: { connection: "Upgrade", upgrade: "websocket" },
|
|
2549
|
+
status: 426,
|
|
2550
|
+
})
|
|
1991
2551
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
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,
|
|
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
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
-
|
|
2068
|
-
|
|
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 (
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
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
|
|
2635
|
+
private _handleWs(
|
|
2136
2636
|
fc: FetchCtx<TEnv>,
|
|
2137
2637
|
wsMatch: { handler: WSRouteHandler; params: Record<string, string> },
|
|
2138
|
-
|
|
2139
|
-
|
|
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
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
const
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
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
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
const
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
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
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
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
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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
|
|
2297
|
-
|
|
2298
|
-
if (
|
|
2299
|
-
|
|
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
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
allowed
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2399
|
-
path: string,
|
|
2400
|
-
handler: RouteHandler,
|
|
2775
|
+
plan: Plan,
|
|
2401
2776
|
params: Record<string, string>,
|
|
2402
|
-
|
|
2777
|
+
allowed: string[],
|
|
2403
2778
|
): Response | Promise<Response> {
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
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
|
|
2641
|
-
|
|
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
|
-
|
|
2843
|
+
return forReturn
|
|
2648
2844
|
}
|
|
2649
2845
|
}
|
|
2650
|
-
return
|
|
2846
|
+
return response
|
|
2651
2847
|
}
|
|
2652
2848
|
|
|
2653
|
-
private _finishMatched(
|
|
2654
|
-
|
|
2655
|
-
|
|
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 (
|
|
2663
|
-
const 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
|
|
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
|
|
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 (
|
|
2887
|
+
if (s.telemetry !== null) {
|
|
2697
2888
|
try {
|
|
2698
2889
|
const duration = performance.now() - fc.startTime
|
|
2699
|
-
|
|
2700
|
-
duration,
|
|
2701
|
-
method,
|
|
2702
|
-
path,
|
|
2703
|
-
status: response.status,
|
|
2704
|
-
})
|
|
2705
|
-
this._telemetry.onResponse?.({
|
|
2890
|
+
s.telemetry.onHandler?.({
|
|
2706
2891
|
duration,
|
|
2707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2865
|
-
|
|
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
|
-
|
|
3055
|
+
/** canonical full pattern */
|
|
2882
3056
|
path: string
|
|
2883
|
-
|
|
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
|
-
|
|
3229
|
-
|
|
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
|
|
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:
|
|
3235
|
-
|
|
3236
|
-
|
|
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.
|
|
3240
|
-
mw: [
|
|
3418
|
+
mt: mergeContributedMeta(collectMiddlewareMeta([view.chain, this._s.middlewares]), this._s.meta),
|
|
3419
|
+
mw: [],
|
|
3241
3420
|
os: this._s.outputSchemas,
|
|
3242
3421
|
ov,
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
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
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
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
|
-
|
|
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:
|
|
3614
|
-
|
|
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:
|
|
3618
|
-
mw: [
|
|
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
|
-
|
|
3622
|
-
|
|
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
|
}
|