@nifrajs/core 3.3.0 → 4.0.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 +26 -0
- package/dist/assurance.d.ts +7 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +39 -3
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +20 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -15
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +6 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +36 -2
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +16 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-effect.d.ts.map +1 -1
- package/dist/durable-effect.js +6 -3
- package/dist/durable-effect.js.map +1 -1
- package/dist/edge-kit.d.ts +2 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-lifecycle.d.ts.map +1 -1
- package/dist/effect-lifecycle.js +2 -1
- package/dist/effect-lifecycle.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +1 -1
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +36 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +219 -13
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts +31 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +48 -9
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +7 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +9 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.js +2 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -1
- package/dist/internal/route-catalog.d.ts +6 -6
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +20 -18
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +7 -6
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +36 -4
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +7 -3
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +1 -0
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +34 -29
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +8 -2
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +222 -20
- package/dist/internal/route-program.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +8 -5
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +52 -46
- package/dist/manifest.js.map +1 -1
- package/dist/mount.d.ts +36 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +32 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +26 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +65 -1
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -1
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js.map +1 -1
- package/dist/router/overlap.d.ts +23 -0
- package/dist/router/overlap.d.ts.map +1 -0
- package/dist/router/overlap.js +224 -0
- package/dist/router/overlap.js.map +1 -0
- package/dist/router/pattern.d.ts +117 -2
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +283 -23
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts +18 -4
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +51 -44
- package/dist/router/router.js.map +1 -1
- package/dist/server/auth.d.ts +53 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +9 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +12 -7
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +13 -11
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/body-lane.d.ts +20 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +26 -7
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts +68 -0
- package/dist/server/body-parser.d.ts.map +1 -0
- package/dist/server/body-parser.js +172 -0
- package/dist/server/body-parser.js.map +1 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +62 -7
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +8 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +8 -0
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +71 -6
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -5
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -4
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +4 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +40 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.d.ts +2 -0
- package/dist/server/core-copies.d.ts.map +1 -0
- package/dist/server/core-copies.js +20 -0
- package/dist/server/core-copies.js.map +1 -0
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +36 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +130 -73
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +4 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +4 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/methods.d.ts +45 -0
- package/dist/server/methods.d.ts.map +1 -0
- package/dist/server/methods.js +37 -0
- package/dist/server/methods.js.map +1 -0
- package/dist/server/multipart.d.ts +63 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +374 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +20 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +15 -6
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/not-found-answer.d.ts +40 -0
- package/dist/server/not-found-answer.d.ts.map +1 -0
- package/dist/server/not-found-answer.js +56 -0
- package/dist/server/not-found-answer.js.map +1 -0
- package/dist/server/not-found.d.ts +23 -0
- package/dist/server/not-found.d.ts.map +1 -0
- package/dist/server/not-found.js +81 -0
- package/dist/server/not-found.js.map +1 -0
- package/dist/server/plugin.d.ts +26 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +18 -5
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +114 -9
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts +7 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +47 -0
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +13 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +11 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +33 -19
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-lineage.d.ts +8 -0
- package/dist/server/request-lineage.d.ts.map +1 -0
- package/dist/server/request-lineage.js +14 -0
- package/dist/server/request-lineage.js.map +1 -0
- package/dist/server/request-target.d.ts +31 -0
- package/dist/server/request-target.d.ts.map +1 -0
- package/dist/server/request-target.js +44 -0
- package/dist/server/request-target.js.map +1 -0
- package/dist/server/respond.d.ts +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +19 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +59 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +9 -5
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -6
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +55 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +194 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1342 -274
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +10 -7
- package/dist/server/sse.js.map +1 -1
- package/dist/server/static-headers.d.ts +3 -0
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +19 -1
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +9 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/validation.d.ts.map +1 -1
- package/dist/server/validation.js +5 -1
- package/dist/server/validation.js.map +1 -1
- package/dist/server/websocket.d.ts +10 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +7 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +2 -0
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +6 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +2 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +8 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +5 -2
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -0
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +46 -3
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +125 -12
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +23 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +37 -3
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +27 -2
- package/src/assurance.ts +52 -2
- package/src/capabilities.ts +81 -22
- package/src/diff.ts +54 -7
- package/src/durable-adapters.ts +20 -3
- package/src/durable-effect.ts +10 -3
- package/src/edge-kit.ts +7 -0
- package/src/effect-lifecycle.ts +5 -1
- package/src/effect-scope.ts +2 -1
- package/src/errors.ts +3 -0
- package/src/evidence.ts +302 -12
- package/src/idempotency.ts +80 -9
- package/src/index.ts +41 -3
- package/src/internal/code-unit-order.ts +8 -0
- package/src/internal/durable-saga-protocol.ts +2 -0
- package/src/internal/route-catalog.ts +19 -18
- package/src/internal/route-compiler.ts +62 -13
- package/src/internal/route-execution.ts +13 -3
- package/src/internal/route-lanes.ts +37 -28
- package/src/internal/route-program.ts +434 -20
- package/src/ledger.ts +11 -5
- package/src/manifest.ts +52 -46
- package/src/mount.ts +62 -0
- package/src/reflection.ts +84 -1
- package/src/response-observer.ts +1 -0
- package/src/router/overlap.ts +258 -0
- package/src/router/pattern.ts +349 -25
- package/src/router/router.ts +76 -51
- package/src/server/auth.ts +75 -0
- package/src/server/bare-error-lane.ts +12 -11
- package/src/server/body-lane.ts +46 -6
- package/src/server/body-parser.ts +241 -0
- package/src/server/body.ts +82 -9
- package/src/server/client-ip.ts +9 -0
- package/src/server/context.ts +219 -12
- package/src/server/contract.ts +54 -9
- package/src/server/cookies.ts +34 -2
- package/src/server/core-copies.ts +21 -0
- package/src/server/edge.ts +2 -0
- package/src/server/http.ts +34 -0
- package/src/server/idempotency-lane.ts +147 -72
- package/src/server/install.ts +6 -0
- package/src/server/methods.ts +143 -0
- package/src/server/multipart.ts +424 -0
- package/src/server/node-outcome-hook.ts +40 -7
- package/src/server/not-found-answer.ts +109 -0
- package/src/server/not-found.ts +121 -0
- package/src/server/plugin.ts +45 -1
- package/src/server/proto-guard.ts +128 -9
- package/src/server/query.ts +49 -0
- package/src/server/registry.ts +17 -2
- package/src/server/request-context.ts +44 -16
- package/src/server/request-lineage.ts +15 -0
- package/src/server/request-target.ts +46 -0
- package/src/server/respond.ts +14 -3
- package/src/server/response-contract-lane.ts +97 -16
- package/src/server/response-observer-runtime.ts +37 -8
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +61 -2
- package/src/server/server.ts +1798 -355
- package/src/server/sse.ts +10 -8
- package/src/server/static-headers.ts +22 -1
- package/src/server/transport-lane.ts +17 -9
- package/src/server/validation.ts +7 -1
- package/src/server/websocket.ts +23 -9
- package/src/server/ws-bun.ts +8 -3
- package/src/server/ws-hook.ts +2 -1
- package/src/server.ts +39 -2
- package/src/single-copy-register.ts +5 -0
- package/src/single-copy.ts +160 -12
- package/src/tool-contract.ts +66 -3
- package/src/transport-codec.ts +2 -1
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed `multipart/form-data` request bodies - `multipartBody(schema, limits?)`.
|
|
3
|
+
*
|
|
4
|
+
* A route reads JSON and urlencoded bodies by default and answers `415` to everything else. Wrapping
|
|
5
|
+
* its body schema opts that one route into multipart: the parts are read under the route's
|
|
6
|
+
* `bodyLimit`, bounded by count and size, collected into one record, and handed to the schema.
|
|
7
|
+
*
|
|
8
|
+
* import { multipartBody } from "@nifrajs/core/multipart"
|
|
9
|
+
* import { t } from "@nifrajs/schema"
|
|
10
|
+
*
|
|
11
|
+
* app.post(
|
|
12
|
+
* "/avatars",
|
|
13
|
+
* {
|
|
14
|
+
* body: multipartBody(
|
|
15
|
+
* t.form({ avatar: t.file({ maxBytes: 2_000_000, accept: ["image/png"] }), alt: t.string() }),
|
|
16
|
+
* ),
|
|
17
|
+
* bodyLimit: 2_500_000,
|
|
18
|
+
* },
|
|
19
|
+
* (c) => store(c.body.avatar),
|
|
20
|
+
* )
|
|
21
|
+
*
|
|
22
|
+
* The schema sees a null-prototype record: a text part is a string, a file part is a `File`, and a
|
|
23
|
+
* name that repeats becomes an array in part order. A file part with no bytes is left out: that is
|
|
24
|
+
* what an empty file input submits, so an optional file field validates as absent.
|
|
25
|
+
*
|
|
26
|
+
* What the reader guarantees before the schema runs:
|
|
27
|
+
* - the body is at most `bodyLimit` bytes, measured on the bytes delivered and not on the header
|
|
28
|
+
* - at most `maxFields + maxFiles` parts, counted on the raw bytes before anything is parsed
|
|
29
|
+
* - one boundary, taken from the header by one strict grammar and handed to the parser as-is
|
|
30
|
+
* - every text value is at most `maxFieldBytes` and every file at most `maxFileBytes`
|
|
31
|
+
* - a file name is a bare name: no directory, no control or bidirectional-override characters
|
|
32
|
+
* - a `__proto__` part follows the app's prototype-poisoning policy
|
|
33
|
+
*
|
|
34
|
+
* What it does not: a file's `name` and `type` are still the client's claim. Check content with a
|
|
35
|
+
* schema that reads the bytes, and store uploads under a key you generate.
|
|
36
|
+
*
|
|
37
|
+
* The whole body is buffered, so a request costs about twice its size in memory while it is parsed.
|
|
38
|
+
* Send large uploads straight to storage with a presigned URL instead of raising `bodyLimit`.
|
|
39
|
+
*/
|
|
40
|
+
import type { StandardSchemaV1 } from "../schema/standard.ts"
|
|
41
|
+
import { assertByteLimit, readBoundedBytes, UNLIMITED_BODY_BYTES } from "./body.ts"
|
|
42
|
+
import { SCHEMA_BODY_READER, type SchemaBodyReader } from "./body-lane.ts"
|
|
43
|
+
import { plainError } from "./http.ts"
|
|
44
|
+
import type { ResponseResult } from "./runtime-core.ts"
|
|
45
|
+
|
|
46
|
+
/** Per-route bounds on a multipart body. The total size is the route's `bodyLimit`. */
|
|
47
|
+
export interface MultipartLimits {
|
|
48
|
+
/** Most text parts a request may carry. Default `100`. */
|
|
49
|
+
readonly maxFields?: number
|
|
50
|
+
/** Most file parts a request may carry. Empty files are not counted. Default `10`. */
|
|
51
|
+
readonly maxFiles?: number
|
|
52
|
+
/** Largest text value, in UTF-8 bytes. Default `65536`. */
|
|
53
|
+
readonly maxFieldBytes?: number
|
|
54
|
+
/** Largest single file, in bytes. Defaults to no bound beyond the route's `bodyLimit`. */
|
|
55
|
+
readonly maxFileBytes?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A value of the record handed to the schema: one part, or every part that shared the name. */
|
|
59
|
+
export type MultipartValue = string | File | Array<string | File>
|
|
60
|
+
|
|
61
|
+
const DEFAULT_MAX_FIELDS = 100
|
|
62
|
+
const DEFAULT_MAX_FILES = 10
|
|
63
|
+
const DEFAULT_MAX_FIELD_BYTES = 65_536
|
|
64
|
+
|
|
65
|
+
const MEDIA_TYPE = "multipart/form-data"
|
|
66
|
+
// RFC 2046 `bchars` without the space: what a boundary may be made of.
|
|
67
|
+
const BOUNDARY = /^[0-9A-Za-z'()+_,\-./:=?]{1,70}$/
|
|
68
|
+
// RFC 9110 `token`: a parameter name, and a value that needs no quoting.
|
|
69
|
+
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
70
|
+
const FALLBACK_FILE_NAME = "file"
|
|
71
|
+
const MAX_FILE_NAME_LENGTH = 255
|
|
72
|
+
|
|
73
|
+
const DASH = 45
|
|
74
|
+
const QUOTE = 34
|
|
75
|
+
const BACKSLASH = 92
|
|
76
|
+
const SLASH = 47
|
|
77
|
+
const SEMICOLON = 59
|
|
78
|
+
const SPACE = 32
|
|
79
|
+
const TAB = 9
|
|
80
|
+
|
|
81
|
+
const INVALID = (): ResponseResult => plainError(400, "invalid_multipart")
|
|
82
|
+
|
|
83
|
+
function limitOf(value: number | undefined, fallback: number, name: string): number {
|
|
84
|
+
if (value === undefined) return fallback
|
|
85
|
+
assertByteLimit(value, `multipartBody: ${name}`)
|
|
86
|
+
return value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The boundary of a `multipart/form-data` content type. `undefined` when the media type is another
|
|
91
|
+
* one, `null` when it is multipart but its parameters are malformed, repeat `boundary`, or carry a
|
|
92
|
+
* boundary outside the RFC 2046 alphabet.
|
|
93
|
+
*/
|
|
94
|
+
function boundaryOf(contentType: string): string | null | undefined {
|
|
95
|
+
const semi = contentType.indexOf(";")
|
|
96
|
+
const essence = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
|
|
97
|
+
if (essence !== MEDIA_TYPE) return undefined
|
|
98
|
+
if (semi === -1) return null
|
|
99
|
+
|
|
100
|
+
const end = contentType.length
|
|
101
|
+
let boundary: string | undefined
|
|
102
|
+
let i = semi
|
|
103
|
+
while (i < end) {
|
|
104
|
+
i++ // past the `;`
|
|
105
|
+
while (i < end && isBlank(contentType.charCodeAt(i))) i++
|
|
106
|
+
if (i >= end) break
|
|
107
|
+
const eq = contentType.indexOf("=", i)
|
|
108
|
+
if (eq === -1) return null
|
|
109
|
+
const name = contentType.slice(i, eq)
|
|
110
|
+
if (!TOKEN.test(name)) return null
|
|
111
|
+
i = eq + 1
|
|
112
|
+
let value: string
|
|
113
|
+
if (contentType.charCodeAt(i) === QUOTE) {
|
|
114
|
+
let close = i + 1
|
|
115
|
+
while (close < end && contentType.charCodeAt(close) !== QUOTE) {
|
|
116
|
+
close += contentType.charCodeAt(close) === BACKSLASH ? 2 : 1
|
|
117
|
+
}
|
|
118
|
+
if (close >= end) return null
|
|
119
|
+
// Kept raw: an escaped character is outside the boundary alphabet, so it fails the test below.
|
|
120
|
+
value = contentType.slice(i + 1, close)
|
|
121
|
+
i = close + 1
|
|
122
|
+
while (i < end && isBlank(contentType.charCodeAt(i))) i++
|
|
123
|
+
if (i < end && contentType.charCodeAt(i) !== SEMICOLON) return null
|
|
124
|
+
} else {
|
|
125
|
+
const next = contentType.indexOf(";", i)
|
|
126
|
+
const stop = next === -1 ? end : next
|
|
127
|
+
value = contentType.slice(i, stop).trimEnd()
|
|
128
|
+
i = stop
|
|
129
|
+
}
|
|
130
|
+
if (name.toLowerCase() !== "boundary") continue
|
|
131
|
+
if (boundary !== undefined || !BOUNDARY.test(value)) return null
|
|
132
|
+
boundary = value
|
|
133
|
+
}
|
|
134
|
+
return boundary ?? null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isBlank(code: number): boolean {
|
|
138
|
+
return code === SPACE || code === TAB
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function failureTable(pattern: Uint8Array): Uint8Array {
|
|
142
|
+
const table = new Uint8Array(pattern.length)
|
|
143
|
+
for (let i = 1, k = 0; i < pattern.length; i++) {
|
|
144
|
+
while (k > 0 && pattern[i] !== pattern[k]) k = table[k - 1]!
|
|
145
|
+
if (pattern[i] === pattern[k]) k++
|
|
146
|
+
table[i] = k
|
|
147
|
+
}
|
|
148
|
+
return table
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What a scan for `--boundary` found: how many, and whether the last one closes the body. */
|
|
152
|
+
interface Delimiters {
|
|
153
|
+
readonly count: number
|
|
154
|
+
readonly closed: boolean
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** `end` is the index after the last delimiter, or -1 when there was none. */
|
|
158
|
+
function delimiters(bytes: Uint8Array, count: number, end: number): Delimiters {
|
|
159
|
+
return { count, closed: end !== -1 && bytes[end] === DASH && bytes[end + 1] === DASH }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Knuth-Morris-Pratt from `start`: linear in the input whatever the pattern repeats. */
|
|
163
|
+
function scanFrom(
|
|
164
|
+
bytes: Uint8Array,
|
|
165
|
+
pattern: Uint8Array,
|
|
166
|
+
start: number,
|
|
167
|
+
counted: number,
|
|
168
|
+
lastEnd: number,
|
|
169
|
+
limit: number,
|
|
170
|
+
): Delimiters {
|
|
171
|
+
const table = failureTable(pattern)
|
|
172
|
+
const size = pattern.length
|
|
173
|
+
let count = counted
|
|
174
|
+
let end = lastEnd
|
|
175
|
+
for (let i = start, k = 0; i < bytes.length; i++) {
|
|
176
|
+
const byte = bytes[i]
|
|
177
|
+
while (k > 0 && byte !== pattern[k]) k = table[k - 1]!
|
|
178
|
+
if (byte === pattern[k]) k++
|
|
179
|
+
if (k === size) {
|
|
180
|
+
end = i + 1
|
|
181
|
+
if (++count > limit) break
|
|
182
|
+
k = 0
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return delimiters(bytes, count, end)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Find the non-overlapping occurrences of `pattern` (which starts with a dash), stopping once the
|
|
190
|
+
* count passes `limit`. Every `--boundary` is counted wherever it sits, which is at least what any
|
|
191
|
+
* parser can treat as a delimiter.
|
|
192
|
+
*
|
|
193
|
+
* The native search for the next dash skips binary content at memory speed. A body built to make
|
|
194
|
+
* the verify loop re-read the same bytes (dashes against a boundary of dashes) spends a budget of
|
|
195
|
+
* one pass over the input, then the scan continues with the linear matcher.
|
|
196
|
+
*/
|
|
197
|
+
function scanDelimiters(bytes: Uint8Array, pattern: Uint8Array, limit: number): Delimiters {
|
|
198
|
+
const size = pattern.length
|
|
199
|
+
const last = bytes.length - size
|
|
200
|
+
let count = 0
|
|
201
|
+
let end = -1
|
|
202
|
+
let work = 0
|
|
203
|
+
let i = 0
|
|
204
|
+
while (i <= last) {
|
|
205
|
+
i = bytes.indexOf(DASH, i)
|
|
206
|
+
if (i === -1 || i > last) break
|
|
207
|
+
let k = 1
|
|
208
|
+
while (k < size && bytes[i + k] === pattern[k]) k++
|
|
209
|
+
if (k === size) {
|
|
210
|
+
i += size
|
|
211
|
+
end = i
|
|
212
|
+
if (++count > limit) break
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
work += k
|
|
216
|
+
if (work > bytes.length) return scanFrom(bytes, pattern, i, count, end, limit)
|
|
217
|
+
i++
|
|
218
|
+
}
|
|
219
|
+
return delimiters(bytes, count, end)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** True when `value` takes more than `max` bytes as UTF-8. */
|
|
223
|
+
function exceedsUtf8(value: string, max: number): boolean {
|
|
224
|
+
const length = value.length
|
|
225
|
+
// A UTF-16 code unit is one to three UTF-8 bytes, so most values are decided without a walk.
|
|
226
|
+
if (length > max) return true
|
|
227
|
+
if (length * 3 <= max) return false
|
|
228
|
+
let bytes = 0
|
|
229
|
+
for (let i = 0; i < length; i++) {
|
|
230
|
+
const code = value.charCodeAt(i)
|
|
231
|
+
if (code < 0x80) bytes += 1
|
|
232
|
+
else if (code < 0x800) bytes += 2
|
|
233
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < length) {
|
|
234
|
+
const low = value.charCodeAt(i + 1)
|
|
235
|
+
if (low >= 0xdc00 && low <= 0xdfff) {
|
|
236
|
+
bytes += 4
|
|
237
|
+
i++
|
|
238
|
+
} else bytes += 3
|
|
239
|
+
} else bytes += 3
|
|
240
|
+
if (bytes > max) return true
|
|
241
|
+
}
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** C0 controls, DEL, line separators, and the characters that reorder or hide text in a label. */
|
|
246
|
+
function isUnsafeNameCode(code: number): boolean {
|
|
247
|
+
return (
|
|
248
|
+
code < 0x20 ||
|
|
249
|
+
code === 0x7f ||
|
|
250
|
+
code === 0x200e ||
|
|
251
|
+
code === 0x200f ||
|
|
252
|
+
(code >= 0x2028 && code <= 0x202e) ||
|
|
253
|
+
(code >= 0x2066 && code <= 0x2069)
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A client file name reduced to a bare name: the text after the last `/` or `\`, without control
|
|
259
|
+
* and bidirectional characters, at most 255 code units. A name with nothing left is `"file"`.
|
|
260
|
+
*/
|
|
261
|
+
function safeFileName(raw: string): string {
|
|
262
|
+
let start = 0
|
|
263
|
+
let dirty = false
|
|
264
|
+
for (let i = 0; i < raw.length; i++) {
|
|
265
|
+
const code = raw.charCodeAt(i)
|
|
266
|
+
if (code === SLASH || code === BACKSLASH) {
|
|
267
|
+
start = i + 1
|
|
268
|
+
dirty = false
|
|
269
|
+
} else if (isUnsafeNameCode(code)) dirty = true
|
|
270
|
+
}
|
|
271
|
+
let name = raw
|
|
272
|
+
if (dirty) {
|
|
273
|
+
name = ""
|
|
274
|
+
for (let i = start; i < raw.length; i++) {
|
|
275
|
+
if (!isUnsafeNameCode(raw.charCodeAt(i))) name += raw[i]
|
|
276
|
+
}
|
|
277
|
+
} else if (start > 0) name = raw.slice(start)
|
|
278
|
+
name = name.trim()
|
|
279
|
+
if (name.length > MAX_FILE_NAME_LENGTH) {
|
|
280
|
+
const lastKept = name.charCodeAt(MAX_FILE_NAME_LENGTH - 1)
|
|
281
|
+
// Never end on the first half of a surrogate pair.
|
|
282
|
+
const cut = lastKept >= 0xd800 && lastKept <= 0xdbff ? 1 : 0
|
|
283
|
+
name = name.slice(0, MAX_FILE_NAME_LENGTH - cut).trimEnd()
|
|
284
|
+
}
|
|
285
|
+
return name === "" || name === "." || name === ".." ? FALLBACK_FILE_NAME : name
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** `part` as a `File` called `name`, keeping its bytes, type, and timestamp. */
|
|
289
|
+
function renamedFile(part: Blob, name: string): File {
|
|
290
|
+
const modified = (part as Partial<File>).lastModified
|
|
291
|
+
const options: FilePropertyBag =
|
|
292
|
+
modified === undefined ? { type: part.type } : { type: part.type, lastModified: modified }
|
|
293
|
+
let file = new File([part], name, options)
|
|
294
|
+
// A runtime may hand back the single source part with its own name when nothing else changes;
|
|
295
|
+
// a second, empty part forces a new file.
|
|
296
|
+
if (file.name !== name) file = new File([part, ""], name, options)
|
|
297
|
+
if (file.name !== name || file.size !== part.size) {
|
|
298
|
+
throw new Error("multipartBody: this runtime did not apply a file name")
|
|
299
|
+
}
|
|
300
|
+
return file
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function createReader(
|
|
304
|
+
limits: MultipartLimits,
|
|
305
|
+
inner: SchemaBodyReader | undefined,
|
|
306
|
+
): SchemaBodyReader {
|
|
307
|
+
const maxFields = limitOf(limits.maxFields, DEFAULT_MAX_FIELDS, "maxFields")
|
|
308
|
+
const maxFiles = limitOf(limits.maxFiles, DEFAULT_MAX_FILES, "maxFiles")
|
|
309
|
+
const maxFieldBytes = limitOf(limits.maxFieldBytes, DEFAULT_MAX_FIELD_BYTES, "maxFieldBytes")
|
|
310
|
+
const maxFileBytes = limitOf(limits.maxFileBytes, UNLIMITED_BODY_BYTES, "maxFileBytes")
|
|
311
|
+
// Every part sits between two delimiters, and the last delimiter closes the body.
|
|
312
|
+
const maxDelimiters = maxFields + maxFiles + 1
|
|
313
|
+
|
|
314
|
+
const read: SchemaBodyReader = async (source, contentType, maxBodyBytes, protoPoisoning) => {
|
|
315
|
+
const boundary = boundaryOf(contentType)
|
|
316
|
+
if (boundary === undefined) {
|
|
317
|
+
// Not a multipart body: a reader the schema already carried for another media type takes it.
|
|
318
|
+
return inner === undefined
|
|
319
|
+
? plainError(415, "unsupported_media_type")
|
|
320
|
+
: inner(source, contentType, maxBodyBytes, protoPoisoning)
|
|
321
|
+
}
|
|
322
|
+
if (boundary === null) return INVALID()
|
|
323
|
+
|
|
324
|
+
const read = await readBoundedBytes(source, maxBodyBytes)
|
|
325
|
+
if (!read.ok) {
|
|
326
|
+
return read.status === 413
|
|
327
|
+
? plainError(413, "payload_too_large")
|
|
328
|
+
: plainError(400, "invalid_content_length")
|
|
329
|
+
}
|
|
330
|
+
const bytes = read.bytes
|
|
331
|
+
|
|
332
|
+
// Counted on the raw bytes: the platform parser allocates per part, so a body of many tiny
|
|
333
|
+
// parts must be refused before it is parsed, not after.
|
|
334
|
+
const delimiter = new Uint8Array(boundary.length + 2)
|
|
335
|
+
delimiter[0] = DASH
|
|
336
|
+
delimiter[1] = DASH
|
|
337
|
+
for (let i = 0; i < boundary.length; i++) delimiter[i + 2] = boundary.charCodeAt(i)
|
|
338
|
+
const found = scanDelimiters(bytes, delimiter, maxDelimiters)
|
|
339
|
+
if (found.count > maxDelimiters) return plainError(413, "too_many_parts")
|
|
340
|
+
// A body is over when `--boundary--` says so. One that stops short is refused here, because a
|
|
341
|
+
// parser that tolerates it hands back the parts it saw as if they were the whole form.
|
|
342
|
+
if (!found.closed) return INVALID()
|
|
343
|
+
|
|
344
|
+
// The parser is given the boundary this reader extracted, never the request's own header: two
|
|
345
|
+
// readings of one header (a repeated or oddly quoted parameter) would let a body be counted
|
|
346
|
+
// under one boundary and parsed under another.
|
|
347
|
+
const canonical = `${MEDIA_TYPE}; boundary=${TOKEN.test(boundary) ? boundary : `"${boundary}"`}`
|
|
348
|
+
// Typed by what is read from it: the global `FormData` and `BodyInit` names do not resolve to
|
|
349
|
+
// the same declarations under every lib config.
|
|
350
|
+
let form: Iterable<[string, string | Blob]>
|
|
351
|
+
try {
|
|
352
|
+
form = (await new Response(bytes as ConstructorParameters<typeof Response>[0], {
|
|
353
|
+
headers: { "content-type": canonical },
|
|
354
|
+
}).formData()) as unknown as Iterable<[string, string | Blob]>
|
|
355
|
+
} catch {
|
|
356
|
+
return INVALID()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const record = Object.create(null) as Record<string, MultipartValue>
|
|
360
|
+
let fields = 0
|
|
361
|
+
let files = 0
|
|
362
|
+
for (const [name, part] of form) {
|
|
363
|
+
if (name === "__proto__" && protoPoisoning !== "ignore") {
|
|
364
|
+
if (protoPoisoning === "reject") return INVALID()
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
let value: string | File
|
|
368
|
+
if (typeof part === "string") {
|
|
369
|
+
if (++fields > maxFields) return plainError(413, "too_many_fields")
|
|
370
|
+
if (exceedsUtf8(part, maxFieldBytes)) return plainError(413, "field_too_large")
|
|
371
|
+
value = part
|
|
372
|
+
} else {
|
|
373
|
+
// A file input left empty still submits a part with no bytes. Runtimes disagree on whether
|
|
374
|
+
// a named empty file keeps its name, so every empty file is treated as that input.
|
|
375
|
+
if (part.size === 0) continue
|
|
376
|
+
const rawName = (part as Partial<File>).name
|
|
377
|
+
const clientName = typeof rawName === "string" ? rawName : ""
|
|
378
|
+
if (++files > maxFiles) return plainError(413, "too_many_files")
|
|
379
|
+
if (part.size > maxFileBytes) return plainError(413, "file_too_large")
|
|
380
|
+
const name = safeFileName(clientName)
|
|
381
|
+
value = part instanceof File && part.name === name ? part : renamedFile(part, name)
|
|
382
|
+
}
|
|
383
|
+
const existing = record[name]
|
|
384
|
+
if (existing === undefined) record[name] = value
|
|
385
|
+
else if (Array.isArray(existing)) existing.push(value)
|
|
386
|
+
else record[name] = [existing, value]
|
|
387
|
+
}
|
|
388
|
+
return record
|
|
389
|
+
}
|
|
390
|
+
const mediaTypes = inner?.mediaTypes
|
|
391
|
+
return mediaTypes === undefined
|
|
392
|
+
? read
|
|
393
|
+
: Object.defineProperty(read, "mediaTypes", { value: mediaTypes })
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Opt a route's body schema into `multipart/form-data`. Returns a copy of `schema` that also reads
|
|
398
|
+
* multipart bodies; the schema itself is not changed, and JSON and urlencoded bodies still reach it.
|
|
399
|
+
* Opting in a schema that already was replaces its limits, and a schema that reads another media
|
|
400
|
+
* type through a parser of its own keeps reading it.
|
|
401
|
+
*
|
|
402
|
+
* Throws a `RangeError` for a limit that is not a non-negative safe integer.
|
|
403
|
+
*/
|
|
404
|
+
export function multipartBody<Schema extends StandardSchemaV1>(
|
|
405
|
+
schema: Schema,
|
|
406
|
+
limits: MultipartLimits = {},
|
|
407
|
+
): Schema {
|
|
408
|
+
const carried = (schema as { readonly [SCHEMA_BODY_READER]?: SchemaBodyReader })[
|
|
409
|
+
SCHEMA_BODY_READER
|
|
410
|
+
]
|
|
411
|
+
const reader: PropertyDescriptor = {
|
|
412
|
+
value: createReader(limits, typeof carried === "function" ? carried : undefined),
|
|
413
|
+
}
|
|
414
|
+
if (typeof schema === "function") {
|
|
415
|
+
// A callable schema cannot be copied; an object that delegates to it reads the same.
|
|
416
|
+
return Object.defineProperty(Object.create(schema), SCHEMA_BODY_READER, reader) as Schema
|
|
417
|
+
}
|
|
418
|
+
// The reader goes in with the copied properties rather than after them: a schema that was already
|
|
419
|
+
// opted in carries a reader that cannot be redefined, and the limits given here replace its own.
|
|
420
|
+
return Object.create(Object.getPrototypeOf(schema), {
|
|
421
|
+
...Object.getOwnPropertyDescriptors(schema),
|
|
422
|
+
[SCHEMA_BODY_READER]: reader,
|
|
423
|
+
}) as Schema
|
|
424
|
+
}
|
|
@@ -66,6 +66,8 @@ export interface NodeResponseContext {
|
|
|
66
66
|
/** Mutable ONLY through a body hook's replacement object (e.g. an ETag 304); header hooks read. */
|
|
67
67
|
status: number
|
|
68
68
|
headers: Record<string, string | readonly string[]> | undefined
|
|
69
|
+
/** True when core has proved the record uses lowercase wire names before native hooks run. */
|
|
70
|
+
readonly headersAreLowercase?: boolean
|
|
69
71
|
readonly cookies: readonly string[] | undefined
|
|
70
72
|
/** The framework-serialized body bytes (`null` for a bodiless render). Replaceable by body hooks. */
|
|
71
73
|
body: string | Uint8Array | null
|
|
@@ -92,6 +94,30 @@ export type ResponseBodyHook = (
|
|
|
92
94
|
status: number,
|
|
93
95
|
) => MaybePromise<string | Uint8Array | ResponseBodyReplacement | undefined>
|
|
94
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Native equivalent of a paired `onResponseBody` hook - the body tier's twin, mirroring what
|
|
99
|
+
* `onNodeResponseHeaders` is to `onResponseHeaders`. It receives the same already-serialized bytes
|
|
100
|
+
* the portable hook would (never `null` - bodiless renders skip the twin exactly as they skip the
|
|
101
|
+
* portable hook) plus the outcome record itself, so a twin that only reads the bytes and writes
|
|
102
|
+
* headers pays no header-view allocation. It returns through the same replacement channel
|
|
103
|
+
* (`undefined` keeps the body; bytes or a `{ body, status }` replacement is applied by the shared
|
|
104
|
+
* `applyBodyReplacement`), so a twin and its portable hook are observably identical.
|
|
105
|
+
*
|
|
106
|
+
* Header writes go straight to `response.headers`, whose contract is the lowercase wire spelling
|
|
107
|
+
* (the same contract `setNodeHeader` documents): a plain assignment of a name like `__proto__`
|
|
108
|
+
* hits the inherited setter and is an inert no-op rather than pollution - `c.set.headers` behaves
|
|
109
|
+
* the same way - so a header that must round-trip under that name needs `defineProperty`. The
|
|
110
|
+
* twin must preserve its portable hook's observable header/body/status semantics; direct `body`
|
|
111
|
+
* or `status` writes outside the return channel are still honored deterministically by the finish
|
|
112
|
+
* step, but they have no portable equivalent, so prefer the return.
|
|
113
|
+
*/
|
|
114
|
+
export type NodeResponseBodyHook = (
|
|
115
|
+
body: string | Uint8Array,
|
|
116
|
+
response: NodeResponseContext,
|
|
117
|
+
req: NodeRequestContext,
|
|
118
|
+
status: number,
|
|
119
|
+
) => MaybePromise<string | Uint8Array | ResponseBodyReplacement | undefined>
|
|
120
|
+
|
|
95
121
|
/** Native equivalent of a paired `onResponse` hook. It must preserve the Web hook's header semantics. */
|
|
96
122
|
export type NodeResponseHook = (
|
|
97
123
|
response: NodeResponseContext,
|
|
@@ -160,11 +186,17 @@ class RecordHeadersView implements ResponseHeadersView {
|
|
|
160
186
|
* a raw record write from a custom native twin uses lowercase names (the wire form the record
|
|
161
187
|
* documents), so it stays visible without a scan. */
|
|
162
188
|
#actual(lower: string): string {
|
|
189
|
+
return this.#key(lower) ?? lower
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** As {@link #actual}, but `undefined` when the record holds no such header: only an own property
|
|
193
|
+
* is a header, so an inherited `constructor` or `toString` never reads as one. */
|
|
194
|
+
#key(lower: string): string | undefined {
|
|
163
195
|
const record = this.#target.headers
|
|
164
|
-
if (record
|
|
196
|
+
if (record === undefined) return undefined
|
|
197
|
+
if (Object.hasOwn(record, lower)) return lower
|
|
165
198
|
const known = this.#alias?.get(lower)
|
|
166
|
-
|
|
167
|
-
return lower
|
|
199
|
+
return known !== undefined && Object.hasOwn(record, known) ? known : undefined
|
|
168
200
|
}
|
|
169
201
|
|
|
170
202
|
/** The backing record if the outcome has one, prepared on first touch; `undefined` otherwise. */
|
|
@@ -216,16 +248,17 @@ class RecordHeadersView implements ResponseHeadersView {
|
|
|
216
248
|
get(name: string): string | null {
|
|
217
249
|
const headers = this.#readable()
|
|
218
250
|
if (headers === undefined) return null
|
|
219
|
-
const
|
|
251
|
+
const key = this.#key(RecordHeadersView.#lower(name))
|
|
252
|
+
const value = key === undefined ? undefined : headers[key]
|
|
220
253
|
if (value === undefined) return null
|
|
221
254
|
return typeof value === "string" ? value : (value.join(", ") ?? null)
|
|
222
255
|
}
|
|
223
256
|
|
|
224
257
|
has(name: string): boolean {
|
|
225
258
|
const headers = this.#readable()
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
|
|
259
|
+
if (headers === undefined) return false
|
|
260
|
+
const key = this.#key(RecordHeadersView.#lower(name))
|
|
261
|
+
return key !== undefined && headers[key] !== undefined
|
|
229
262
|
}
|
|
230
263
|
|
|
231
264
|
set(name: string, value: string): void {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** The not-found rules (see `./not-found.ts`), shared by `notFound()` and the edge server. */
|
|
2
|
+
import type { Platform } from "./context.ts"
|
|
3
|
+
import { isRoutableMethod, plainError } from "./http.ts"
|
|
4
|
+
import { getNeverAbortSignal, headerOf, type ResponseResult } from "./runtime-core.ts"
|
|
5
|
+
import type { MaybePromise, RequestSource } from "./server.ts"
|
|
6
|
+
|
|
7
|
+
/** What a {@link NotFoundHandler} is given: the request line and headers of a request no route matched. */
|
|
8
|
+
export interface NotFoundInput<Env = unknown> {
|
|
9
|
+
/** The request method, exactly as received. Always a token a route could be registered under. */
|
|
10
|
+
readonly method: string
|
|
11
|
+
/** The request URL, exactly as received. */
|
|
12
|
+
readonly url: string
|
|
13
|
+
/** The path the router looked up, exactly as received: NOT percent-decoded, so `/a%2Fb` stays
|
|
14
|
+
* `/a%2Fb`. Escape it before writing it into a body. */
|
|
15
|
+
readonly pathname: string
|
|
16
|
+
/** The request headers. */
|
|
17
|
+
readonly headers: Headers
|
|
18
|
+
/** Read one request header (case-insensitive), or `null` when it is absent. */
|
|
19
|
+
header(name: string): string | null
|
|
20
|
+
/** Aborts when the server's `requestTimeoutMs` elapses before the handler settles. Never aborts
|
|
21
|
+
* when no timeout is configured. */
|
|
22
|
+
readonly signal: AbortSignal
|
|
23
|
+
/** The platform inputs (`env`, `waitUntil`, the caller address) the serving adapter supplied. */
|
|
24
|
+
readonly platform: Platform<Env> | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Answers a request no route matched. Return a `Response`, or `undefined` for the default `404`.
|
|
29
|
+
* May be async; a thrown `Response` is treated like a returned one.
|
|
30
|
+
*/
|
|
31
|
+
export type NotFoundHandler<Env = unknown> = (
|
|
32
|
+
input: NotFoundInput<Env>,
|
|
33
|
+
) => MaybePromise<Response | undefined>
|
|
34
|
+
|
|
35
|
+
/** @internal The read-only view a {@link NotFoundHandler} receives. `url` and `headers` stay getters
|
|
36
|
+
* so a source that builds them on demand is not forced to for a handler that never reads them. */
|
|
37
|
+
export function notFoundInput<Env>(
|
|
38
|
+
source: RequestSource,
|
|
39
|
+
pathname: string,
|
|
40
|
+
platform: Platform<Env> | undefined,
|
|
41
|
+
signal: AbortSignal = getNeverAbortSignal(),
|
|
42
|
+
): NotFoundInput<Env> {
|
|
43
|
+
return {
|
|
44
|
+
method: source.method,
|
|
45
|
+
pathname,
|
|
46
|
+
signal,
|
|
47
|
+
platform,
|
|
48
|
+
get url() {
|
|
49
|
+
return source.url
|
|
50
|
+
},
|
|
51
|
+
get headers() {
|
|
52
|
+
return source.headers
|
|
53
|
+
},
|
|
54
|
+
header: (name) => headerOf(source, name),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @internal Run a {@link NotFoundHandler} and turn what it produced into the answer. One function
|
|
60
|
+
* for every server that has a `404` site, so the rules above hold identically on each of them.
|
|
61
|
+
* `wrap` lifts a response into the caller's output type; `failed` renders the fault answer.
|
|
62
|
+
*/
|
|
63
|
+
export function answerNotFound<T, Env>(
|
|
64
|
+
handler: NotFoundHandler<Env>,
|
|
65
|
+
input: NotFoundInput<Env>,
|
|
66
|
+
wrap: (response: Response | ResponseResult) => T,
|
|
67
|
+
failed: (error: unknown) => T,
|
|
68
|
+
): MaybePromise<T> {
|
|
69
|
+
if (!isRoutableMethod(input.method)) return wrap(plainError(404, "not_found"))
|
|
70
|
+
const settle = (value: unknown, thrown: boolean): T => {
|
|
71
|
+
if (value instanceof Response) {
|
|
72
|
+
// A status below 200 is `Response.error()`: a network-error marker, not an answer.
|
|
73
|
+
if (value.bodyUsed || value.status < 200) {
|
|
74
|
+
return failed(new TypeError("notFound handler answered with an unusable Response"))
|
|
75
|
+
}
|
|
76
|
+
// A miss is never a success. The body and headers are the handler's; the status is not.
|
|
77
|
+
return wrap(
|
|
78
|
+
value.status < 300
|
|
79
|
+
? new Response(value.body, { status: 404, headers: value.headers })
|
|
80
|
+
: value,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
if (thrown) return failed(value)
|
|
84
|
+
if (value === undefined) return wrap(plainError(404, "not_found"))
|
|
85
|
+
return failed(new TypeError("notFound handler must return a Response or undefined"))
|
|
86
|
+
}
|
|
87
|
+
let result: MaybePromise<Response | undefined>
|
|
88
|
+
try {
|
|
89
|
+
result = handler(input)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return settle(error, true)
|
|
92
|
+
}
|
|
93
|
+
return result instanceof Promise
|
|
94
|
+
? result.then(
|
|
95
|
+
(value) => settle(value, false),
|
|
96
|
+
(error) => settle(error, true),
|
|
97
|
+
)
|
|
98
|
+
: settle(result, false)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @internal What the kernel calls at its `404` site once a handler is installed. */
|
|
102
|
+
export type NotFoundLane = <T>(
|
|
103
|
+
host: unknown,
|
|
104
|
+
source: RequestSource,
|
|
105
|
+
pathname: string,
|
|
106
|
+
platform: Platform<unknown> | undefined,
|
|
107
|
+
wrap: (response: Response | ResponseResult) => T,
|
|
108
|
+
onTimeout: () => T,
|
|
109
|
+
) => MaybePromise<T>
|