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