@nifrajs/core 5.8.0 → 5.10.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/CHANGELOG.md +118 -0
- package/README.md +9 -4
- package/dist/assurance.d.ts +26 -1
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +43 -11
- package/dist/assurance.js.map +1 -1
- package/dist/canonical-json.d.ts +11 -0
- package/dist/canonical-json.d.ts.map +1 -0
- package/dist/canonical-json.js +46 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +1 -8
- package/dist/capabilities.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +1 -8
- package/dist/data.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +2 -12
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts +2 -0
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +41 -49
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +14 -20
- package/dist/durable-saga.js.map +1 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +14 -68
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +60 -23
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/control-character.d.ts +3 -0
- package/dist/internal/control-character.d.ts.map +1 -0
- package/dist/internal/control-character.js +10 -0
- package/dist/internal/control-character.js.map +1 -0
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +4 -1
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/hex.d.ts +5 -0
- package/dist/internal/hex.d.ts.map +1 -0
- package/dist/internal/hex.js +14 -0
- package/dist/internal/hex.js.map +1 -0
- package/dist/internal/record.d.ts +3 -0
- package/dist/internal/record.d.ts.map +1 -0
- package/dist/internal/record.js +5 -0
- package/dist/internal/record.js.map +1 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +2 -2
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +4 -4
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +4 -0
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +4 -3
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +16 -33
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/schema-parts.d.ts +13 -0
- package/dist/internal/schema-parts.d.ts.map +1 -0
- package/dist/internal/schema-parts.js +15 -0
- package/dist/internal/schema-parts.js.map +1 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +2 -14
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -37
- package/dist/manifest.js.map +1 -1
- package/dist/open-resolved.d.ts +18 -0
- package/dist/open-resolved.d.ts.map +1 -0
- package/dist/open-resolved.js +62 -0
- package/dist/open-resolved.js.map +1 -0
- package/dist/range.js +1 -1
- package/dist/range.js.map +1 -1
- package/dist/router/path-codec.d.ts +22 -0
- package/dist/router/path-codec.d.ts.map +1 -0
- package/dist/router/path-codec.js +87 -0
- package/dist/router/path-codec.js.map +1 -0
- package/dist/router/pattern.d.ts +21 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +117 -0
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/same-origin-path.d.ts +22 -0
- package/dist/router/same-origin-path.d.ts.map +1 -0
- package/dist/router/same-origin-path.js +37 -0
- package/dist/router/same-origin-path.js.map +1 -0
- package/dist/serve-static.d.ts +22 -0
- package/dist/serve-static.d.ts.map +1 -0
- package/dist/serve-static.js +187 -0
- package/dist/serve-static.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +2 -1
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +12 -3
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/base64-answers.d.ts.map +1 -1
- package/dist/server/base64-answers.js +1 -4
- package/dist/server/base64-answers.js.map +1 -1
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +43 -38
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +4 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/bridge-markers.d.ts +1 -1
- package/dist/server/bridge-markers.js +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +4 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/http.d.ts +6 -18
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +14 -33
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +2 -0
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/idle-timeout.d.ts +39 -0
- package/dist/server/idle-timeout.d.ts.map +1 -0
- package/dist/server/idle-timeout.js +95 -0
- package/dist/server/idle-timeout.js.map +1 -0
- package/dist/server/json-shared.d.ts +23 -0
- package/dist/server/json-shared.d.ts.map +1 -0
- package/dist/server/json-shared.js +33 -0
- package/dist/server/json-shared.js.map +1 -0
- package/dist/server/json-text.d.ts +1 -1
- package/dist/server/json-text.js +1 -1
- package/dist/server/node-outcome.js +1 -1
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/request-context.d.ts +22 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +44 -4
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-local.d.ts +44 -0
- package/dist/server/request-local.d.ts.map +1 -0
- package/dist/server/request-local.js +91 -0
- package/dist/server/request-local.js.map +1 -0
- package/dist/server/respond.js +1 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +10 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +93 -64
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/runtime-core.d.ts +7 -4
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +9 -7
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +23 -8
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +58 -59
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +860 -510
- package/dist/server/server.js.map +1 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +2 -0
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.js +1 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-adapter-view.d.ts +54 -0
- package/dist/server/ws-adapter-view.d.ts.map +1 -0
- package/dist/server/ws-adapter-view.js +7 -0
- package/dist/server/ws-adapter-view.js.map +1 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +1 -1
- package/dist/single-copy.js.map +1 -1
- package/dist/static-file.d.ts +156 -4
- package/dist/static-file.d.ts.map +1 -1
- package/dist/static-file.js +198 -4
- package/dist/static-file.js.map +1 -1
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +1 -3
- package/dist/wire.js.map +1 -1
- package/package.json +16 -1
- package/src/assurance.ts +66 -16
- package/src/canonical-json.ts +43 -0
- package/src/capabilities.ts +1 -8
- package/src/data.ts +2 -8
- package/src/diff.ts +2 -12
- package/src/durable-adapters.ts +53 -50
- package/src/durable-saga.ts +30 -23
- package/src/evidence.ts +25 -90
- package/src/idempotency.ts +62 -20
- package/src/index.ts +13 -2
- package/src/internal/control-character.ts +8 -0
- package/src/internal/durable-shared.ts +4 -1
- package/src/internal/hex.ts +14 -0
- package/src/internal/record.ts +4 -0
- package/src/internal/route-compiler.ts +2 -2
- package/src/internal/route-execution.ts +7 -20
- package/src/internal/route-lanes.ts +20 -44
- package/src/internal/schema-parts.ts +19 -0
- package/src/ledger.ts +2 -16
- package/src/manifest.ts +10 -38
- package/src/open-resolved.ts +64 -0
- package/src/range.ts +1 -1
- package/src/router/path-codec.ts +93 -0
- package/src/router/pattern.ts +127 -0
- package/src/router/same-origin-path.ts +36 -0
- package/src/serve-static.ts +227 -0
- package/src/server/bare-error-lane.ts +11 -2
- package/src/server/base64-answers.ts +2 -6
- package/src/server/body-lane.ts +58 -51
- package/src/server/body.ts +4 -1
- package/src/server/bridge-markers.ts +1 -1
- package/src/server/cookies.ts +4 -1
- package/src/server/core-copies.ts +1 -1
- package/src/server/http.ts +13 -33
- package/src/server/idempotency-lane.ts +2 -0
- package/src/server/idle-timeout.ts +107 -0
- package/src/server/json-shared.ts +38 -0
- package/src/server/json-text.ts +1 -1
- package/src/server/node-outcome.ts +1 -1
- package/src/server/request-context.ts +62 -3
- package/src/server/request-local.ts +109 -0
- package/src/server/respond.ts +1 -1
- package/src/server/response-contract-lane.ts +112 -65
- package/src/server/runtime-core.ts +10 -8
- package/src/server/server-types.ts +23 -8
- package/src/server/server.ts +1220 -798
- package/src/server/transport-lane.ts +2 -0
- package/src/server/webhook.ts +1 -1
- package/src/server/websocket.ts +18 -1
- package/src/server/ws-adapter-view.ts +63 -0
- package/src/server.ts +12 -1
- package/src/single-copy.ts +1 -3
- package/src/static-file.ts +350 -4
- package/src/wire.ts +2 -4
package/src/manifest.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Deterministic, signed route manifest: contract × assurance × capabilities × classification. */
|
|
2
2
|
|
|
3
3
|
import type { AssuranceReport } from "./assurance.ts"
|
|
4
|
+
import { canonicalJson } from "./canonical-json.ts"
|
|
4
5
|
import type { CapabilityAssuranceReport } from "./capabilities.ts"
|
|
5
6
|
import {
|
|
6
7
|
type DataClassification,
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
} from "./diff.ts"
|
|
16
17
|
import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "./evidence.ts"
|
|
17
18
|
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
19
|
+
import { hasControlCharacter } from "./internal/control-character.ts"
|
|
20
|
+
import { sha256Hex } from "./internal/hex.ts"
|
|
18
21
|
|
|
19
22
|
export interface NifraManifestAssurance {
|
|
20
23
|
readonly rule?: string
|
|
@@ -70,35 +73,6 @@ export interface BuildNifraManifestInput {
|
|
|
70
73
|
|
|
71
74
|
const encoder = new TextEncoder()
|
|
72
75
|
|
|
73
|
-
function hasControlCharacter(value: string): boolean {
|
|
74
|
-
for (let index = 0; index < value.length; index++) {
|
|
75
|
-
const code = value.charCodeAt(index)
|
|
76
|
-
if (code <= 31 || code === 127) return true
|
|
77
|
-
}
|
|
78
|
-
return false
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function canonicalValue(value: unknown): string {
|
|
82
|
-
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
83
|
-
return JSON.stringify(value)
|
|
84
|
-
}
|
|
85
|
-
if (typeof value === "number") {
|
|
86
|
-
if (!Number.isFinite(value))
|
|
87
|
-
throw new TypeError("nifra manifest cannot encode non-finite numbers")
|
|
88
|
-
return JSON.stringify(value)
|
|
89
|
-
}
|
|
90
|
-
if (Array.isArray(value)) return `[${value.map((item) => canonicalValue(item)).join(",")}]`
|
|
91
|
-
if (typeof value === "object") {
|
|
92
|
-
const record = value as Readonly<Record<string, unknown>>
|
|
93
|
-
return `{${Object.keys(record)
|
|
94
|
-
.filter((key) => record[key] !== undefined)
|
|
95
|
-
.sort()
|
|
96
|
-
.map((key) => `${JSON.stringify(key)}:${canonicalValue(record[key])}`)
|
|
97
|
-
.join(",")}}`
|
|
98
|
-
}
|
|
99
|
-
throw new TypeError(`nifra manifest cannot encode ${typeof value}`)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
76
|
function manifestBody(manifest: Pick<NifraManifest, "manifestVersion" | "routes">): {
|
|
103
77
|
readonly manifestVersion: 1
|
|
104
78
|
readonly routes: readonly NifraManifestRoute[]
|
|
@@ -110,17 +84,17 @@ function manifestBody(manifest: Pick<NifraManifest, "manifestVersion" | "routes"
|
|
|
110
84
|
export function canonicalManifest(
|
|
111
85
|
manifest: Pick<NifraManifest, "manifestVersion" | "routes">,
|
|
112
86
|
): string {
|
|
113
|
-
return
|
|
87
|
+
return canonicalJson(manifestBody(manifest))
|
|
114
88
|
}
|
|
115
89
|
|
|
116
90
|
/** Byte-stable artifact serialization (including `contentHash`). */
|
|
117
91
|
export function serializeNifraManifest(manifest: NifraManifest): string {
|
|
118
|
-
return
|
|
92
|
+
return canonicalJson(manifest)
|
|
119
93
|
}
|
|
120
94
|
|
|
121
95
|
/** Byte-stable serialization for the detached signature sidecar. */
|
|
122
96
|
export function serializeNifraManifestSignature(signature: NifraManifestSignature): string {
|
|
123
|
-
return
|
|
97
|
+
return canonicalJson(signature)
|
|
124
98
|
}
|
|
125
99
|
|
|
126
100
|
/** Parse the detached sidecar before selecting its operator-controlled public key. */
|
|
@@ -163,11 +137,6 @@ export function parseNifraManifestSignature(
|
|
|
163
137
|
return Object.freeze(candidate as NifraManifestSignature)
|
|
164
138
|
}
|
|
165
139
|
|
|
166
|
-
async function sha256Hex(value: string): Promise<string> {
|
|
167
|
-
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)))
|
|
168
|
-
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")
|
|
169
|
-
}
|
|
170
|
-
|
|
171
140
|
const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
|
|
172
141
|
const sortedStrings = (values: Iterable<string>): readonly string[] =>
|
|
173
142
|
Object.freeze([...new Set(values)].sort())
|
|
@@ -255,7 +224,10 @@ export async function buildNifraManifest(input: BuildNifraManifestInput): Promis
|
|
|
255
224
|
function base64Url(bytes: Uint8Array): string {
|
|
256
225
|
let binary = ""
|
|
257
226
|
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
258
|
-
return btoa(binary)
|
|
227
|
+
return btoa(binary)
|
|
228
|
+
.replaceAll("+", "-")
|
|
229
|
+
.replaceAll("/", "_")
|
|
230
|
+
.replace(/={1,2}$/, "")
|
|
259
231
|
}
|
|
260
232
|
|
|
261
233
|
function fromBase64Url(value: string): Uint8Array<ArrayBuffer> {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { constants as FS } from "node:fs"
|
|
2
|
+
import { type FileHandle, open, readlink, realpath, stat } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
// Darwin's open(2) flag refusing a symlink in ANY component; `O_NOFOLLOW` covers only the last one.
|
|
5
|
+
const O_NOFOLLOW_ANY = 0x20000000
|
|
6
|
+
// Deno's node:fs rejects flags it does not know with EINVAL; the first refusal turns the flag off.
|
|
7
|
+
let noFollowAny: boolean | undefined
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Open a name `realpath` already resolved, refusing it when any component became a symlink since.
|
|
11
|
+
* A local writer inside the served tree can swap an intermediate directory for a link after the
|
|
12
|
+
* containment check; `O_NOFOLLOW` alone would follow it. Darwin refuses such a path in the kernel;
|
|
13
|
+
* Linux reads back the name the descriptor holds. Elsewhere, Windows included, no file API can open
|
|
14
|
+
* without following a middle link or name an open descriptor's path, so the check in
|
|
15
|
+
* {@link opensAt} is a platform limit: it narrows the race to repeated swaps but cannot close it.
|
|
16
|
+
*/
|
|
17
|
+
export async function openResolved(realFile: string): Promise<FileHandle> {
|
|
18
|
+
// Read per open, not at load: this module can sit in a client bundle graph, where `process` is
|
|
19
|
+
// undefined.
|
|
20
|
+
if (noFollowAny ?? process.platform === "darwin") {
|
|
21
|
+
try {
|
|
22
|
+
return await open(realFile, FS.O_RDONLY | O_NOFOLLOW_ANY)
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (!(error instanceof Error && "code" in error && error.code === "EINVAL")) throw error
|
|
25
|
+
noFollowAny = false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const handle = await open(realFile, FS.O_RDONLY | FS.O_NOFOLLOW)
|
|
29
|
+
try {
|
|
30
|
+
if (await opensAt(handle, realFile)) return handle
|
|
31
|
+
} catch {}
|
|
32
|
+
await handle.close()
|
|
33
|
+
throw Object.assign(new Error(`${realFile} moved while it was opened`), { code: "ELOOP" })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether `handle` is the file at `realFile` with no symlink in its path. */
|
|
37
|
+
export async function opensAt(
|
|
38
|
+
handle: Pick<FileHandle, "fd" | "stat">,
|
|
39
|
+
realFile: string,
|
|
40
|
+
): Promise<boolean> {
|
|
41
|
+
try {
|
|
42
|
+
return (await readlink(`/proc/self/fd/${handle.fd}`)) === realFile
|
|
43
|
+
} catch {
|
|
44
|
+
return sameFileAt(handle, realFile)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* {@link opensAt} where no descriptor's path can be read. Exported for its test, which runs it on
|
|
50
|
+
* every host.
|
|
51
|
+
*/
|
|
52
|
+
export async function sameFileAt(
|
|
53
|
+
handle: Pick<FileHandle, "stat">,
|
|
54
|
+
realFile: string,
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
// One step after another, so passing takes a swap back and a swap again between steps: the name
|
|
57
|
+
// resolves to itself, then to the opened file, then to itself again. Identities are compared as
|
|
58
|
+
// bigints because a Windows file index can exceed 2^53.
|
|
59
|
+
if ((await realpath(realFile)) !== realFile) return false
|
|
60
|
+
const opened = await handle.stat({ bigint: true })
|
|
61
|
+
const named = await stat(realFile, { bigint: true })
|
|
62
|
+
if (opened.dev !== named.dev || opened.ino !== named.ino) return false
|
|
63
|
+
return (await realpath(realFile)) === realFile
|
|
64
|
+
}
|
package/src/range.ts
CHANGED
|
@@ -104,7 +104,7 @@ export function parseByteRange(value: string | null, size: number): ByteRangeRes
|
|
|
104
104
|
if (value.length > MAX_RANGE_HEADER_LENGTH) return { kind: "none" }
|
|
105
105
|
const single = parseSingleRange(value, size)
|
|
106
106
|
if (single !== undefined) return single
|
|
107
|
-
const match = /^bytes\s*=\s*(
|
|
107
|
+
const match = /^bytes\s*=\s*(\S.*)$/i.exec(value)
|
|
108
108
|
if (match === null) return { kind: "none" }
|
|
109
109
|
const source = match[1]!
|
|
110
110
|
let separators = 0
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// One URL path segment, encoded and decoded. Every place that spells a value into a path calls this
|
|
2
|
+
// module, so a link, a prerendered file, a client request and a public asset agree on one spelling.
|
|
3
|
+
|
|
4
|
+
const HEX = "0123456789ABCDEF"
|
|
5
|
+
|
|
6
|
+
const UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
7
|
+
|
|
8
|
+
function rawTable(chars: string): Uint8Array {
|
|
9
|
+
const table = new Uint8Array(128)
|
|
10
|
+
for (let i = 0; i < chars.length; i++) table[chars.charCodeAt(i)] = 1
|
|
11
|
+
return table
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// RFC 3986 `pchar` minus `;`: the unreserved set, the sub-delimiters, `:` and `@`. `;` delimits path
|
|
15
|
+
// parameters (RFC 3986 section 3.3) and servlet containers strip them; `[`, `]` and `|` are not valid in
|
|
16
|
+
// a path at all and Tomcat rejects them unencoded by default.
|
|
17
|
+
const CANONICAL = /* @__PURE__ */ rawTable(`${UNRESERVED}!$&'()*+,:=@`)
|
|
18
|
+
|
|
19
|
+
// What a browser leaves as written in a path: the canonical set plus `;`, `[`, `]` and `|`.
|
|
20
|
+
const BROWSER = /* @__PURE__ */ rawTable(`${UNRESERVED}!$&'()*+,:=@;[]|`)
|
|
21
|
+
|
|
22
|
+
function encodeNonAscii(run: string): string {
|
|
23
|
+
try {
|
|
24
|
+
return encodeURIComponent(run)
|
|
25
|
+
} catch {
|
|
26
|
+
throw new RangeError("[nifra/core] a path segment cannot hold a lone surrogate")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function encodeWith(raw: Uint8Array, value: string): string {
|
|
31
|
+
if (value === "." || value === "..") {
|
|
32
|
+
throw new RangeError(
|
|
33
|
+
`[nifra/core] a path segment cannot be ${JSON.stringify(value)}: every spelling of it is a dot segment to a URL parser`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
const length = value.length
|
|
37
|
+
let out = ""
|
|
38
|
+
let start = 0
|
|
39
|
+
let i = 0
|
|
40
|
+
while (i < length) {
|
|
41
|
+
const code = value.charCodeAt(i)
|
|
42
|
+
if (code < 128 && raw[code] === 1) {
|
|
43
|
+
i++
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
out += value.slice(start, i)
|
|
47
|
+
if (code < 128) {
|
|
48
|
+
out += `%${HEX[code >> 4]}${HEX[code & 15]}`
|
|
49
|
+
i++
|
|
50
|
+
} else {
|
|
51
|
+
let end = i + 1
|
|
52
|
+
while (end < length && value.charCodeAt(end) >= 128) end++
|
|
53
|
+
out += encodeNonAscii(value.slice(i, end))
|
|
54
|
+
i = end
|
|
55
|
+
}
|
|
56
|
+
start = i
|
|
57
|
+
}
|
|
58
|
+
return start === 0 ? value : out + value.slice(start)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `value` as one path segment. Left as written: letters, digits, the at sign and `- . _ ~ ! $ & ' ( ) * + , : =`.
|
|
63
|
+
* Everything else is percent-encoded, UTF-8 and upper-case hex, including `; [ ] | / ? # % \`.
|
|
64
|
+
*
|
|
65
|
+
* Throws a `RangeError` for a value that no segment can carry: exactly `.` or `..` (a URL parser reads
|
|
66
|
+
* `%2E` and `%2E%2E` as dot segments too) and a lone surrogate (substituting U+FFFD would map distinct
|
|
67
|
+
* values to one URL). The empty string is returned as is; a caller that cannot use it checks for it.
|
|
68
|
+
*/
|
|
69
|
+
export function encodePathSegment(value: string): string {
|
|
70
|
+
return encodeWith(CANONICAL, value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `value` as a browser spells it in a request URL: {@link encodePathSegment} with `; [ ] |` left as
|
|
75
|
+
* written. Used for one thing, registering a public file under the spelling its page links request;
|
|
76
|
+
* it differs from the canonical form on those four characters only.
|
|
77
|
+
*/
|
|
78
|
+
export function browserPathSegment(value: string): string {
|
|
79
|
+
return encodeWith(BROWSER, value)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `raw` with its percent-escapes decoded, or `undefined` when one is malformed. The inverse of
|
|
84
|
+
* {@link encodePathSegment} and of {@link browserPathSegment}; a segment in any other spelling decodes
|
|
85
|
+
* too. What a malformed escape means is the caller's policy.
|
|
86
|
+
*/
|
|
87
|
+
export function decodePathSegment(raw: string): string | undefined {
|
|
88
|
+
try {
|
|
89
|
+
return decodeURIComponent(raw)
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/router/pattern.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { RouteConfigError } from "../errors.ts"
|
|
2
|
+
import { encodePathSegment } from "./path-codec.ts"
|
|
3
|
+
|
|
4
|
+
export { browserPathSegment, decodePathSegment, encodePathSegment } from "./path-codec.ts"
|
|
5
|
+
export { isSameOriginPath } from "./same-origin-path.ts"
|
|
2
6
|
|
|
3
7
|
const PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
4
8
|
const RESERVED_PARAM_NAMES = new Set(["__proto__", "constructor", "prototype"])
|
|
@@ -649,6 +653,129 @@ export function decodeRouteParams(raw: Record<string, string>): Record<string, s
|
|
|
649
653
|
return out ?? raw
|
|
650
654
|
}
|
|
651
655
|
|
|
656
|
+
export type FilledRoutePath =
|
|
657
|
+
| { readonly ok: true; readonly path: string }
|
|
658
|
+
| {
|
|
659
|
+
readonly ok: false
|
|
660
|
+
/** Params the pattern names that `params` leaves out or holds as the empty string. */
|
|
661
|
+
readonly missing: readonly string[]
|
|
662
|
+
/** Params whose value cannot be carried or would be split differently by this pattern. */
|
|
663
|
+
readonly invalid: readonly string[]
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The path `pattern` names for `params`, every value spelled by {@link encodePathSegment}. A wildcard
|
|
668
|
+
* value is split on `/` and each part encoded, empty parts dropped; a `.` or `..` part is invalid, as
|
|
669
|
+
* a whole-segment value is. Nothing is thrown for a bad value: the caller decides what a missing or
|
|
670
|
+
* invalid param means. A param inside a mixed segment is invalid when its value holds the literal that ends it (`name` = `a.b` in
|
|
671
|
+
* `:name.:ext`), or when it touches the next param and encodes to more than one character, because the
|
|
672
|
+
* router would split it differently. A param's constraint (`:id{[0-9]+}`) is not checked here.
|
|
673
|
+
*/
|
|
674
|
+
export function fillRoutePath(
|
|
675
|
+
pattern: string | CompiledRoutePattern,
|
|
676
|
+
params: Readonly<Record<string, string | undefined>>,
|
|
677
|
+
): FilledRoutePath {
|
|
678
|
+
const compiled = typeof pattern === "string" ? compileRoutePattern(pattern) : pattern
|
|
679
|
+
// Built as one string, with the two problem lists made only when a param is bad: a path that fills
|
|
680
|
+
// allocates nothing else.
|
|
681
|
+
let path = ""
|
|
682
|
+
let missing: string[] | undefined
|
|
683
|
+
let invalid: string[] | undefined
|
|
684
|
+
const segments = compiled.segments
|
|
685
|
+
for (let s = 0; s < segments.length; s++) {
|
|
686
|
+
const segment = segments[s]!
|
|
687
|
+
if (segment.kind === "static") {
|
|
688
|
+
path += `/${segment.value}`
|
|
689
|
+
} else if (segment.kind === "param") {
|
|
690
|
+
const value = fillValue(params, segment.name)
|
|
691
|
+
if (value === undefined) {
|
|
692
|
+
missing = noted(missing, segment.name)
|
|
693
|
+
path += "/"
|
|
694
|
+
continue
|
|
695
|
+
}
|
|
696
|
+
const encoded = spell(value)
|
|
697
|
+
if (encoded === undefined) invalid = noted(invalid, segment.name)
|
|
698
|
+
path += `/${encoded ?? ""}`
|
|
699
|
+
} else if (segment.kind === "wildcard") {
|
|
700
|
+
const value = fillValue(params, segment.name)
|
|
701
|
+
let joined = ""
|
|
702
|
+
let any = false
|
|
703
|
+
if (value !== undefined) {
|
|
704
|
+
for (const piece of value.split("/")) {
|
|
705
|
+
if (piece === "") continue
|
|
706
|
+
const encoded = spell(piece)
|
|
707
|
+
if (encoded === undefined) invalid = noted(invalid, segment.name)
|
|
708
|
+
joined += any ? `/${encoded ?? ""}` : (encoded ?? "")
|
|
709
|
+
any = true
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!any) missing = noted(missing, segment.name)
|
|
713
|
+
path += `/${joined}`
|
|
714
|
+
} else {
|
|
715
|
+
let text = ""
|
|
716
|
+
const shape = segment.parts
|
|
717
|
+
for (let i = 0; i < shape.length; i++) {
|
|
718
|
+
const part = shape[i]!
|
|
719
|
+
if (part.t === "lit") {
|
|
720
|
+
text += part.v
|
|
721
|
+
continue
|
|
722
|
+
}
|
|
723
|
+
const value = fillValue(params, part.name)
|
|
724
|
+
if (value === undefined) {
|
|
725
|
+
missing = noted(missing, part.name)
|
|
726
|
+
continue
|
|
727
|
+
}
|
|
728
|
+
const encoded = spell(value)
|
|
729
|
+
if (encoded === undefined) {
|
|
730
|
+
invalid = noted(invalid, part.name)
|
|
731
|
+
continue
|
|
732
|
+
}
|
|
733
|
+
// matchMixedSegment ends a parameter at the first occurrence of the literal after it, one
|
|
734
|
+
// character in or later. A value holding that literal earlier would come back as different
|
|
735
|
+
// params. A literal in the last place is the segment's tail, which is anchored to its end.
|
|
736
|
+
const next = shape[i + 1]
|
|
737
|
+
// With no separating literal the matcher gives this parameter exactly one raw character.
|
|
738
|
+
// Splitting a percent-escape here would also make the captured parameters malformed.
|
|
739
|
+
if (next?.t === "param" && encoded.length !== 1) invalid = noted(invalid, part.name)
|
|
740
|
+
else if (next?.t === "lit" && shape[i + 2] !== undefined) {
|
|
741
|
+
if ((encoded + next.v).indexOf(next.v, 1) !== encoded.length)
|
|
742
|
+
invalid = noted(invalid, part.name)
|
|
743
|
+
}
|
|
744
|
+
text += encoded
|
|
745
|
+
}
|
|
746
|
+
path += `/${text}`
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return missing !== undefined || invalid !== undefined
|
|
750
|
+
? { ok: false, missing: missing ?? [], invalid: invalid ?? [] }
|
|
751
|
+
: { ok: true, path: path === "" ? "/" : path }
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** A param's value when it is a non-empty string the caller gave, not one inherited from the prototype. */
|
|
755
|
+
function fillValue(
|
|
756
|
+
params: Readonly<Record<string, string | undefined>>,
|
|
757
|
+
name: string,
|
|
758
|
+
): string | undefined {
|
|
759
|
+
const value = Object.hasOwn(params, name) ? params[name] : undefined
|
|
760
|
+
return typeof value === "string" && value !== "" ? value : undefined
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** `value` spelled as one path segment, or undefined when no segment can carry it. */
|
|
764
|
+
function spell(value: string): string | undefined {
|
|
765
|
+
try {
|
|
766
|
+
return encodePathSegment(value)
|
|
767
|
+
} catch {
|
|
768
|
+
return undefined
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** `list` with `name` in it once, made when there was none. */
|
|
773
|
+
function noted(list: string[] | undefined, name: string): string[] {
|
|
774
|
+
if (list === undefined) return [name]
|
|
775
|
+
if (!list.includes(name)) list.push(name)
|
|
776
|
+
return list
|
|
777
|
+
}
|
|
778
|
+
|
|
652
779
|
/** Match one compiled pattern and return decoded captures. The caller decides cross-pattern order. */
|
|
653
780
|
export function matchRoutePattern(
|
|
654
781
|
compiled: CompiledRoutePattern,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is `value` a destination that stays on this origin?
|
|
3
|
+
*
|
|
4
|
+
* True only for an absolute path: one leading `/`, never `//` (protocol-relative → another origin),
|
|
5
|
+
* and free of any character a URL parser turns into an origin escape. A leading `/` alone is not
|
|
6
|
+
* enough: under a special scheme `\` parses as `/`, and tab/CR/LF are STRIPPED before parsing, so
|
|
7
|
+
* `/\evil.example` and `/<TAB>/evil.example` both resolve to the external host `evil.example` while
|
|
8
|
+
* passing a `//` test. Rejecting CR/LF here also keeps a validated destination out of the
|
|
9
|
+
* response-splitting sink it usually flows into.
|
|
10
|
+
*
|
|
11
|
+
* One implementation, because every caller is the same open-redirect gate: `redirect()` and
|
|
12
|
+
* `rawHref()` in `@nifrajs/web`, the `redirectTo` of the `@nifrajs/auth` and `@nifrajs/better-auth` guards. Three
|
|
13
|
+
* copies of a security predicate is three chances for one of them to be hardened alone.
|
|
14
|
+
*
|
|
15
|
+
* It answers about a PATH, not a URL: an absolute `https://this.host/x` is false even for the
|
|
16
|
+
* current origin, because the whole point is that the value never got to name a host.
|
|
17
|
+
*
|
|
18
|
+
* It sits in a leaf with no imports, so browser code (`rawHref` in `@nifrajs/web`) can use it without
|
|
19
|
+
* the server entry; `@nifrajs/core/server` and `@nifrajs/core/pattern` both export it.
|
|
20
|
+
*/
|
|
21
|
+
export function isSameOriginPath(value: string): boolean {
|
|
22
|
+
if (!value.startsWith("/") || value.startsWith("//")) return false
|
|
23
|
+
// Scanned by character code rather than matched by regex: no control characters have to be written
|
|
24
|
+
// into a pattern, and the check is a single pass over a short string.
|
|
25
|
+
for (let i = 1; i < value.length; i++) {
|
|
26
|
+
const code = value.charCodeAt(i)
|
|
27
|
+
if (code === 0x5c /* \ */ || code <= 0x1f || code === 0x7f) return false
|
|
28
|
+
}
|
|
29
|
+
// `/.//host` and `/a/..//host` (or their `%2e` forms) resolve to the path `//host`, which any
|
|
30
|
+
// relative reference later built from that path - a client router's redirect - reads as a host.
|
|
31
|
+
// A path-absolute reference against a valid base always parses, so this cannot throw.
|
|
32
|
+
return (
|
|
33
|
+
!(value.includes(".") || value.includes("%")) ||
|
|
34
|
+
!new URL(value, "http://n.invalid").pathname.startsWith("//")
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `serveStatic()` for Bun's `listen({ static })`: files on disk under URL prefixes, with the same
|
|
3
|
+
* mounts `@nifrajs/node` and `@nifrajs/deno` take. Which request a mount takes and how it answers is
|
|
4
|
+
* `./static-file.ts`; this module only reads the file, through node:fs, which Bun runs natively.
|
|
5
|
+
*
|
|
6
|
+
* A subpath rather than part of `listen()`, so an app that serves no files bundles no file reader.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto"
|
|
9
|
+
import { type FileHandle, realpath } from "node:fs/promises"
|
|
10
|
+
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
11
|
+
import { Readable } from "node:stream"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
import { openResolved } from "./open-resolved.ts"
|
|
14
|
+
import { parseByteRange } from "./range.ts"
|
|
15
|
+
import { pathnameOf } from "./server/http.ts"
|
|
16
|
+
import {
|
|
17
|
+
CONTENT_SNAPSHOT_MAX,
|
|
18
|
+
contentTags,
|
|
19
|
+
isHiddenPath,
|
|
20
|
+
matchStatic,
|
|
21
|
+
planStatic,
|
|
22
|
+
type ServeStaticOptions,
|
|
23
|
+
type StaticMount,
|
|
24
|
+
staticMountsOf,
|
|
25
|
+
} from "./static-file.ts"
|
|
26
|
+
|
|
27
|
+
export type { HashedFiles, ServeStaticOptions } from "./static-file.ts"
|
|
28
|
+
export { IMMUTABLE, ONE_DAY } from "./static-file.ts"
|
|
29
|
+
|
|
30
|
+
// Bun.serve drops the declared length of any stream body and sends it chunked, and a Bun.file body
|
|
31
|
+
// would need this descriptor open for a send nothing reports the end of. So on Bun a body up to 1 MiB
|
|
32
|
+
// is read whole: a slow client then holds ~1 MB, against ~0.6 MB through Bun's stream sink.
|
|
33
|
+
const BUFFERED_BODY_MAX = typeof Bun === "undefined" ? CONTENT_SNAPSHOT_MAX : 1024 * 1024
|
|
34
|
+
|
|
35
|
+
// One per process: the tag of a file served from any mount is remembered by its real path.
|
|
36
|
+
const TAGS = contentTags(createHash)
|
|
37
|
+
|
|
38
|
+
function rootOf(dir: string | URL): string {
|
|
39
|
+
return resolve(typeof dir === "string" ? dir : fileURLToPath(dir))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The host of an absolute URL, as `new URL(url).host` would give it, without parsing the rest. */
|
|
43
|
+
function hostOf(url: string): string {
|
|
44
|
+
const start = url.indexOf("//") + 2
|
|
45
|
+
let end = start
|
|
46
|
+
while (end < url.length) {
|
|
47
|
+
const code = url.charCodeAt(end)
|
|
48
|
+
if (code === 47 /* '/' */ || code === 63 /* '?' */ || code === 35 /* '#' */) break
|
|
49
|
+
end++
|
|
50
|
+
}
|
|
51
|
+
return url.slice(start, end)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function refusalOf(status: 400 | 403 | 404): Response {
|
|
55
|
+
return new Response(status === 400 ? "Bad Request" : status === 403 ? "Forbidden" : "Not Found", {
|
|
56
|
+
status,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `length` bytes of `handle` from `start`; fewer when the file shrank since its stat. */
|
|
61
|
+
async function readPinned(
|
|
62
|
+
handle: FileHandle,
|
|
63
|
+
start: number,
|
|
64
|
+
length: number,
|
|
65
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
66
|
+
const bytes = new Uint8Array(length)
|
|
67
|
+
let offset = 0
|
|
68
|
+
while (offset < length) {
|
|
69
|
+
const { bytesRead } = await handle.read(bytes, offset, length - offset, start + offset)
|
|
70
|
+
if (bytesRead === 0) break
|
|
71
|
+
offset += bytesRead
|
|
72
|
+
}
|
|
73
|
+
return offset === length ? bytes : bytes.subarray(0, offset)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface BunMount extends StaticMount {
|
|
77
|
+
/** The directory's real path, resolved on first use and again after a miss, so one made later serves. */
|
|
78
|
+
realRoot: Promise<string | undefined> | undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The answer for `rel` under `mount`, or `undefined` when the mount falls through on a file it cannot
|
|
83
|
+
* serve.
|
|
84
|
+
*/
|
|
85
|
+
async function readStatic(
|
|
86
|
+
mount: BunMount,
|
|
87
|
+
rel: string,
|
|
88
|
+
immutable: boolean,
|
|
89
|
+
request: Request,
|
|
90
|
+
): Promise<Response | undefined> {
|
|
91
|
+
const missing = (status: 403 | 404 = 404): Response | undefined =>
|
|
92
|
+
mount.fallthrough ? undefined : refusalOf(status)
|
|
93
|
+
const file = resolve(mount.root, rel)
|
|
94
|
+
// A drive or root in the decoded name (`C:/x` on Windows) leaves the directory without a `..`.
|
|
95
|
+
const contained = relative(mount.root, file)
|
|
96
|
+
if (contained.startsWith("..") || isAbsolute(contained)) return missing(403)
|
|
97
|
+
// Containment is checked on the RESOLVED name and that name is what opens, so a link swapped inside
|
|
98
|
+
// the tree after the check cannot redirect the read; openResolved refuses a name that gained a link.
|
|
99
|
+
mount.realRoot ??= realpath(mount.root).catch(() => undefined)
|
|
100
|
+
const [realRoot, realFile] = await Promise.all([
|
|
101
|
+
mount.realRoot,
|
|
102
|
+
realpath(file).catch(() => undefined),
|
|
103
|
+
])
|
|
104
|
+
if (realRoot === undefined) mount.realRoot = undefined
|
|
105
|
+
if (
|
|
106
|
+
realRoot === undefined ||
|
|
107
|
+
realFile === undefined ||
|
|
108
|
+
(realFile !== realRoot && !realFile.startsWith(realRoot + sep))
|
|
109
|
+
) {
|
|
110
|
+
return missing()
|
|
111
|
+
}
|
|
112
|
+
// A visible name linking to a dotfile inside the directory answers as a missing file does.
|
|
113
|
+
if (
|
|
114
|
+
mount.denyDotfiles &&
|
|
115
|
+
realFile !== realRoot &&
|
|
116
|
+
isHiddenPath(realFile.slice(realRoot.length + 1))
|
|
117
|
+
) {
|
|
118
|
+
return missing()
|
|
119
|
+
}
|
|
120
|
+
let handle: FileHandle | undefined
|
|
121
|
+
try {
|
|
122
|
+
try {
|
|
123
|
+
handle = await openResolved(realFile)
|
|
124
|
+
} catch {
|
|
125
|
+
return missing()
|
|
126
|
+
}
|
|
127
|
+
const opened = handle
|
|
128
|
+
const stat = await opened.stat()
|
|
129
|
+
if (!stat.isFile()) return missing()
|
|
130
|
+
const method = request.method === "HEAD" ? "HEAD" : "GET"
|
|
131
|
+
let whole: Uint8Array<ArrayBuffer> | undefined
|
|
132
|
+
const plan = await planStatic(
|
|
133
|
+
mount,
|
|
134
|
+
immutable,
|
|
135
|
+
{ method, header: (name) => request.headers.get(name) },
|
|
136
|
+
{
|
|
137
|
+
path: realFile,
|
|
138
|
+
version: stat,
|
|
139
|
+
readWhole: async () => {
|
|
140
|
+
whole = await readPinned(opened, 0, stat.size)
|
|
141
|
+
return whole
|
|
142
|
+
},
|
|
143
|
+
restat: () => opened.stat(),
|
|
144
|
+
tags: TAGS,
|
|
145
|
+
parseRange: parseByteRange,
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
if (!plan.body) return new Response(null, { status: plan.status, headers: plan.headers })
|
|
149
|
+
const { start, length, headers } = plan
|
|
150
|
+
if (length <= BUFFERED_BODY_MAX) {
|
|
151
|
+
const body =
|
|
152
|
+
whole === undefined
|
|
153
|
+
? await readPinned(opened, start, length)
|
|
154
|
+
: whole.subarray(start, start + length)
|
|
155
|
+
headers["content-length"] = String(body.byteLength)
|
|
156
|
+
return new Response(body, { status: plan.status, headers })
|
|
157
|
+
}
|
|
158
|
+
const stream = opened.createReadStream({ start, end: start + length - 1 })
|
|
159
|
+
// createReadStream owns closing the descriptor after EOF or abort.
|
|
160
|
+
handle = undefined
|
|
161
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: node:stream/web's ReadableStream is the global one on Bun; only the declarations differ.
|
|
162
|
+
const body = Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>
|
|
163
|
+
return new Response(body, { status: plan.status, headers })
|
|
164
|
+
} catch {
|
|
165
|
+
// Unreadable under the prefix: a 404 (not an SSR 404 page), unless the mount falls through.
|
|
166
|
+
return missing()
|
|
167
|
+
} finally {
|
|
168
|
+
try {
|
|
169
|
+
await handle?.close()
|
|
170
|
+
} catch {
|
|
171
|
+
// A close that fails changes nothing about the answer already chosen.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function serveFrom(
|
|
177
|
+
mounts: readonly BunMount[],
|
|
178
|
+
from: number,
|
|
179
|
+
request: Request,
|
|
180
|
+
path: string,
|
|
181
|
+
host: () => string,
|
|
182
|
+
): Promise<Response | undefined> | undefined {
|
|
183
|
+
for (let i = from; i < mounts.length; i++) {
|
|
184
|
+
const mount = mounts[i]
|
|
185
|
+
if (mount === undefined) break
|
|
186
|
+
const matched = matchStatic(mount, path, host)
|
|
187
|
+
if (matched === "pass") continue
|
|
188
|
+
// matchStatic passes what a falling-through mount refuses, so a status here is always answered.
|
|
189
|
+
if ("status" in matched) return Promise.resolve(refusalOf(matched.status))
|
|
190
|
+
return readStatic(mount, matched.rel, matched.immutable, request).then(
|
|
191
|
+
(response) => response ?? serveFrom(mounts, i + 1, request, path, host),
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
return undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The `static` handler for files on disk, for Bun's `listen()`: each mount serves a directory under a
|
|
199
|
+
* URL prefix, and the first mount whose prefix (and `files`, when set) takes a GET or HEAD path
|
|
200
|
+
* answers it. A request no mount takes returns `undefined` synchronously and reaches the app. The
|
|
201
|
+
* same mounts work with `@nifrajs/node`'s and `@nifrajs/deno`'s `serveStatic`.
|
|
202
|
+
*
|
|
203
|
+
* Files up to 256 KiB carry a strong `ETag` from the exact bytes served. Larger files stream with no
|
|
204
|
+
* tag and a weak date, so an `If-Range` gets the whole file rather than a range spliced from two
|
|
205
|
+
* versions.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```ts
|
|
209
|
+
* import { serveStatic } from "@nifrajs/core/serve-static"
|
|
210
|
+
*
|
|
211
|
+
* app.listen(3000, { static: serveStatic({ dir: new URL("./assets/", import.meta.url) }) })
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
export function serveStatic(
|
|
215
|
+
mounts: ServeStaticOptions | readonly ServeStaticOptions[],
|
|
216
|
+
): (request: Request) => Promise<Response | undefined> | undefined {
|
|
217
|
+
const states: readonly BunMount[] = staticMountsOf(mounts, rootOf).map((mount) => ({
|
|
218
|
+
...mount,
|
|
219
|
+
realRoot: undefined,
|
|
220
|
+
}))
|
|
221
|
+
return (request) => {
|
|
222
|
+
const method = request.method
|
|
223
|
+
if (method !== "GET" && method !== "HEAD") return undefined
|
|
224
|
+
const url = request.url
|
|
225
|
+
return serveFrom(states, 0, request, pathnameOf(url), () => hostOf(url))
|
|
226
|
+
}
|
|
227
|
+
}
|