@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
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { readBoundedBytes } from "./body.ts"
|
|
8
8
|
import { jsonError } from "./http.ts"
|
|
9
9
|
import { guardDecodedValue, type ProtoPoisoning } from "./proto-guard.ts"
|
|
10
|
+
import { carryToCopy } from "./request-local.ts"
|
|
10
11
|
import { isResponseResult, PRE_DECODED_BODY, type PreDecodedBody } from "./runtime-core.ts"
|
|
11
12
|
import type { AnyServer, IdentityPlugin } from "./server.ts"
|
|
12
13
|
|
|
@@ -154,6 +155,7 @@ export function transportCodecs(
|
|
|
154
155
|
contentType,
|
|
155
156
|
}
|
|
156
157
|
Object.defineProperty(normalized, PRE_DECODED_BODY, { value: stash })
|
|
158
|
+
carryToCopy(request, normalized)
|
|
157
159
|
return normalized
|
|
158
160
|
})
|
|
159
161
|
app.afterHandle((result, context) => {
|
package/src/server/webhook.ts
CHANGED
|
@@ -105,7 +105,7 @@ const BASE64URL = /^[A-Za-z0-9_-]+={0,2}$/
|
|
|
105
105
|
|
|
106
106
|
const fromBase64Url = (value: string): Uint8Array<ArrayBuffer> | null => {
|
|
107
107
|
if (!BASE64URL.test(value)) return null
|
|
108
|
-
const unpadded = value.replace(
|
|
108
|
+
const unpadded = value.replace(/={1,2}$/, "")
|
|
109
109
|
// Padding only ever completes the last quartet; a lone trailing character encodes no byte.
|
|
110
110
|
if (unpadded.length % 4 === 1) return null
|
|
111
111
|
if (unpadded.length !== value.length && value.length % 4 !== 0) return null
|
package/src/server/websocket.ts
CHANGED
|
@@ -388,7 +388,17 @@ export function attachWebSocket<
|
|
|
388
388
|
|
|
389
389
|
const WS_MESSAGE_DECODER = new TextDecoder()
|
|
390
390
|
|
|
391
|
-
|
|
391
|
+
// `@nifrajs/deno` and `@nifrajs/node` run generated copies of `UTF8_BUFFER`, `exceedsPayloadCap`,
|
|
392
|
+
// `utf8ExceedsCap` and `assertCloseArgs` (scripts/gen-vendored.ts), so these reference nothing else
|
|
393
|
+
// in this module.
|
|
394
|
+
/** Bun, Node, Deno and workerd from about 2026 expose `Buffer`, whose native `byteLength` sizes text
|
|
395
|
+
* without copying it; a Worker on an older compatibility date has none. */
|
|
396
|
+
const UTF8_BUFFER: { byteLength(text: string): number } | undefined = Reflect.get(
|
|
397
|
+
globalThis,
|
|
398
|
+
"Buffer",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
/** Whether a message is over `cap` UTF-8 bytes, without copying a text one: each UTF-16 unit takes
|
|
392
402
|
* 1-3 bytes (a surrogate pair 4 for its 2, a lone surrogate the 3 of U+FFFD), so only a length
|
|
393
403
|
* between those bounds is counted. */
|
|
394
404
|
export function exceedsPayloadCap(
|
|
@@ -399,6 +409,13 @@ export function exceedsPayloadCap(
|
|
|
399
409
|
const units = message.length
|
|
400
410
|
if (units > cap) return true
|
|
401
411
|
if (units * 3 <= cap) return false
|
|
412
|
+
if (UTF8_BUFFER === undefined) return utf8ExceedsCap(message, cap)
|
|
413
|
+
return UTF8_BUFFER.byteLength(message) > cap
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** `exceedsPayloadCap`'s count where the runtime has no `Buffer`, one UTF-16 unit at a time. */
|
|
417
|
+
export function utf8ExceedsCap(message: string, cap: number): boolean {
|
|
418
|
+
const units = message.length
|
|
402
419
|
let bytes = 0
|
|
403
420
|
for (let i = 0; i < units; i++) {
|
|
404
421
|
const unit = message.charCodeAt(i)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The narrow WebSocket view the zero-dependency adapters read. `@nifrajs/node` and `@nifrajs/deno` run
|
|
3
|
+
* generated copies of these types (scripts/gen-vendored.ts) instead of importing core's, whose handler
|
|
4
|
+
* and outcome types carry the schemas and codecs the adapters never touch.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { TopicRegistry, WebSocketHandler, WebSocketUpgradeOutcome } from "./websocket.ts"
|
|
8
|
+
|
|
9
|
+
/** A received frame, normalized: text → `string`, binary → `Uint8Array`. */
|
|
10
|
+
export type NifraWsData = string | Uint8Array
|
|
11
|
+
|
|
12
|
+
/** The portable socket a nifra WS handler sees: `NifraWebSocket` with its data unknown. */
|
|
13
|
+
export interface NifraWs {
|
|
14
|
+
send(data: string | ArrayBufferView | ArrayBuffer): void
|
|
15
|
+
close(code?: number, reason?: string): void
|
|
16
|
+
readonly readyState: number
|
|
17
|
+
subscribe(topic: string): void
|
|
18
|
+
unsubscribe(topic: string): void
|
|
19
|
+
data: unknown
|
|
20
|
+
readonly raw: unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The `TopicRegistry` surface the adapter wires `ws.subscribe` to. */
|
|
24
|
+
export interface WsPubSub {
|
|
25
|
+
subscribe(topic: string, ws: NifraWs): void
|
|
26
|
+
unsubscribe(topic: string, ws: NifraWs): void
|
|
27
|
+
unsubscribeAll(ws: NifraWs): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The post-upgrade callbacks of a `WebSocketHandler`. */
|
|
31
|
+
export interface NifraWsHandler {
|
|
32
|
+
open?(ws: NifraWs): void | Promise<void>
|
|
33
|
+
message?(ws: NifraWs, data: NifraWsData): void | Promise<void>
|
|
34
|
+
close?(ws: NifraWs, code: number, reason: string): void | Promise<void>
|
|
35
|
+
error?(ws: NifraWs, error: unknown): void | Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What `resolveWebSocketUpgrade` returns, as the adapters read it. */
|
|
39
|
+
export type WsUpgradeOutcome =
|
|
40
|
+
| { readonly kind: "pass" }
|
|
41
|
+
| { readonly kind: "reject"; readonly response: Response }
|
|
42
|
+
| {
|
|
43
|
+
readonly kind: "upgrade"
|
|
44
|
+
readonly handler: NifraWsHandler
|
|
45
|
+
readonly data: unknown
|
|
46
|
+
readonly pubsub: WsPubSub
|
|
47
|
+
readonly maxPayloadBytes?: number
|
|
48
|
+
/** Builds `ws.send` with the route's outbound contract (`validateSend`). */
|
|
49
|
+
sender?(
|
|
50
|
+
handler: NifraWsHandler,
|
|
51
|
+
send: (payload: string | ArrayBufferView | ArrayBuffer) => void,
|
|
52
|
+
onError: (error: unknown) => void,
|
|
53
|
+
): (payload: string | ArrayBufferView | ArrayBuffer) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type Fits<View, Actual extends View> = Actual
|
|
57
|
+
|
|
58
|
+
// Core's own types must stay assignable to the view, so a change the adapters cannot read fails here.
|
|
59
|
+
export type CoreFitsAdapterView = [
|
|
60
|
+
Fits<WsUpgradeOutcome, WebSocketUpgradeOutcome>,
|
|
61
|
+
Fits<NifraWsHandler, WebSocketHandler>,
|
|
62
|
+
Fits<WsPubSub, TopicRegistry>,
|
|
63
|
+
]
|
package/src/server.ts
CHANGED
|
@@ -73,7 +73,13 @@ export {
|
|
|
73
73
|
type ScheduledHandler,
|
|
74
74
|
toFetchHandler,
|
|
75
75
|
} from "./server/edge.ts"
|
|
76
|
-
export {
|
|
76
|
+
export {
|
|
77
|
+
isSameOriginPath,
|
|
78
|
+
pathnameOf,
|
|
79
|
+
type UrlParts,
|
|
80
|
+
urlPartsOf,
|
|
81
|
+
varyOn,
|
|
82
|
+
} from "./server/http.ts"
|
|
77
83
|
export {
|
|
78
84
|
commonSecretPatterns,
|
|
79
85
|
jsonLogger,
|
|
@@ -107,6 +113,11 @@ export type {
|
|
|
107
113
|
RouteInfoFor,
|
|
108
114
|
} from "./server/registry.ts"
|
|
109
115
|
export { replacedRequestOf } from "./server/request-lineage.ts"
|
|
116
|
+
export {
|
|
117
|
+
carryRequestLocals,
|
|
118
|
+
type RequestLocal,
|
|
119
|
+
requestLocal,
|
|
120
|
+
} from "./server/request-local.ts"
|
|
110
121
|
export { writableResponse } from "./server/respond.ts"
|
|
111
122
|
export { routeTemplateOf } from "./server/route-template.ts"
|
|
112
123
|
export {
|
package/src/single-copy.ts
CHANGED
|
@@ -58,6 +58,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSyn
|
|
|
58
58
|
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path"
|
|
59
59
|
import type { BunPlugin } from "bun"
|
|
60
60
|
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
61
|
+
import { isRecord } from "./internal/record.ts"
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
64
|
* Packages whose duplication is a defect rather than a waste. Each keeps module-scoped state that
|
|
@@ -151,9 +152,6 @@ export interface SingleCopyOptions {
|
|
|
151
152
|
/** Environment switch for strict mode when the declaration does not set it: `1` or `true`. */
|
|
152
153
|
export const SINGLE_COPY_STRICT_ENV = "NIFRA_SINGLE_COPY_STRICT"
|
|
153
154
|
|
|
154
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
155
|
-
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
156
|
-
|
|
157
155
|
const readJsonSync = (path: string): Record<string, unknown> | undefined => {
|
|
158
156
|
try {
|
|
159
157
|
const value: unknown = JSON.parse(readFileSync(path, "utf8"))
|
package/src/static-file.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* What a file served from disk is typed as, which files are never served, and when a client's copy of
|
|
3
3
|
* one is still current - the shared owner for every static-file surface.
|
|
4
4
|
*
|
|
5
|
-
* `@nifrajs/
|
|
6
|
-
* `@nifrajs/node`
|
|
5
|
+
* `serveStatic()` on Bun (`@nifrajs/core/serve-static`, also `nifra dev`), `@nifrajs/deno` and
|
|
6
|
+
* `@nifrajs/node` answer the same deployed files. Copies that drift
|
|
7
7
|
* serve a `.pdf` as a download on one runtime and inline on the other, a `.well-known` file on one and
|
|
8
8
|
* a 404 on the other, or a resumed download spliced from two versions of a file on one, so all three
|
|
9
|
-
* read this module. The Node
|
|
10
|
-
* module imports nothing.
|
|
9
|
+
* read this module. The Node and Deno adapters run a generated copy (scripts/gen-vendored.ts), so
|
|
10
|
+
* this module imports nothing.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -360,3 +360,349 @@ export function contentTags(
|
|
|
360
360
|
},
|
|
361
361
|
}
|
|
362
362
|
}
|
|
363
|
+
|
|
364
|
+
/** `cache-control` for a content-hashed file: its name changes with its bytes. */
|
|
365
|
+
export const IMMUTABLE = "public, max-age=31536000, immutable"
|
|
366
|
+
/** `cache-control` for a file that keeps its name across deploys, as a build's `public/` files do. */
|
|
367
|
+
export const ONE_DAY = "public, max-age=86400"
|
|
368
|
+
|
|
369
|
+
/** Which files {@link ServeStaticOptions.hashedFiles} names: paths, patterns, or a predicate. */
|
|
370
|
+
export type HashedFiles = Iterable<string> | readonly RegExp[] | ((pathname: string) => boolean)
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* One directory `serveStatic()` serves under a URL prefix: the same options on Bun
|
|
374
|
+
* (`@nifrajs/core/serve-static`), `@nifrajs/node` and `@nifrajs/deno`. Each runtime reads the files
|
|
375
|
+
* with its own fastest file API; which request a mount takes, and how it answers, is decided here.
|
|
376
|
+
*/
|
|
377
|
+
export interface ServeStaticOptions {
|
|
378
|
+
/** Directory to read files from - an absolute path or a `file://` URL (`new URL("./assets/", import.meta.url)`). */
|
|
379
|
+
readonly dir: string | URL
|
|
380
|
+
/** URL prefix these files are served under. Default `"/assets"`. Use `"/"` to serve the whole dir. */
|
|
381
|
+
readonly prefix?: string
|
|
382
|
+
/** Emit `cache-control: public, max-age=31536000, immutable` - correct for content-hashed files. Default `true`. */
|
|
383
|
+
readonly immutable?: boolean
|
|
384
|
+
/**
|
|
385
|
+
* The content-hashed files, matched on the URL path as sent. When set, `immutable` applies only to
|
|
386
|
+
* these, and any other file is sent with `cacheControl`, so a file that keeps its name across deploys
|
|
387
|
+
* still revalidates. One of:
|
|
388
|
+
*
|
|
389
|
+
* - paths, such as a build manifest's `assets`;
|
|
390
|
+
* - patterns, for files a pre-build step hashed that no manifest lists:
|
|
391
|
+
* `[/^\/fonts\/[\w-]+-[0-9a-f]{16}\.woff2$/]`;
|
|
392
|
+
* - a predicate, to combine the two: `(path) => assets.has(path) || FONT.test(path)`. It runs as
|
|
393
|
+
* written, so a RegExp it tests should be neither global nor sticky.
|
|
394
|
+
*/
|
|
395
|
+
readonly hashedFiles?: HashedFiles
|
|
396
|
+
/**
|
|
397
|
+
* `cache-control` for a file that is not served immutable (`immutable: false`, or a file outside
|
|
398
|
+
* `hashedFiles`). Unset, such a file carries only its validators.
|
|
399
|
+
*/
|
|
400
|
+
readonly cacheControl?: string
|
|
401
|
+
/**
|
|
402
|
+
* URL paths (as sent, percent-encoded) of the only files this mount serves. Any other path under
|
|
403
|
+
* `prefix` passes to the next mount, then the app, without this mount touching the filesystem - so
|
|
404
|
+
* a mount over a directory that also holds the server bundle can expose just the build's listed
|
|
405
|
+
* files. A later mount over the same paths can still serve the rest.
|
|
406
|
+
*/
|
|
407
|
+
readonly files?: Iterable<string>
|
|
408
|
+
/**
|
|
409
|
+
* Whether this mount may answer `pathname` (as sent, without the query) for a request addressed to
|
|
410
|
+
* `host`. `false` passes the request on, without touching the filesystem, to the next mount and then
|
|
411
|
+
* the app - the seam a host policy uses so a host's refused file meets the app's own refusal instead
|
|
412
|
+
* of being served.
|
|
413
|
+
*/
|
|
414
|
+
readonly allow?: (host: string, pathname: string) => boolean
|
|
415
|
+
/** Extra headers merged onto every served file. */
|
|
416
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
417
|
+
/**
|
|
418
|
+
* Whether a path with a dot-leading segment (`/.env`, `/.git/config`, `/.hidden/app.js`) is
|
|
419
|
+
* served. Default `"deny"`: answered as a missing file is, so probing can't distinguish "hidden" from
|
|
420
|
+
* "absent". `/.well-known` is public by definition (RFC 8615) and always served. The rule applies to
|
|
421
|
+
* the requested path and again to the file it resolves to, so a visible name linking to `.env` is
|
|
422
|
+
* denied too. Set `"allow"` if the directory deliberately holds dotfiles.
|
|
423
|
+
*/
|
|
424
|
+
readonly dotfiles?: "deny" | "allow"
|
|
425
|
+
/**
|
|
426
|
+
* What a path under `prefix` this mount cannot serve does: a missing or hidden file, a link out of
|
|
427
|
+
* the directory, a malformed or traversing path. Default `false`: answered `404` (`400` malformed,
|
|
428
|
+
* `403` traversing), so a missing asset is never rendered as a page. `true` passes it to the next
|
|
429
|
+
* mount, then the app - for a mount at `"/"` beside the app's own routes.
|
|
430
|
+
*/
|
|
431
|
+
readonly fallthrough?: boolean
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** A {@link ServeStaticOptions} checked once, with its directory resolved by the runtime's reader. */
|
|
435
|
+
export interface StaticMount {
|
|
436
|
+
readonly root: string
|
|
437
|
+
/** `"/"`, or the prefix with one leading and no trailing slash. */
|
|
438
|
+
readonly prefix: string
|
|
439
|
+
readonly isImmutable: (path: string) => boolean
|
|
440
|
+
readonly cacheControl: string | undefined
|
|
441
|
+
readonly files: ReadonlySet<string> | undefined
|
|
442
|
+
readonly allow: ((host: string, pathname: string) => boolean) | undefined
|
|
443
|
+
/** Lowercased names. */
|
|
444
|
+
readonly headers: Readonly<Record<string, string>> | undefined
|
|
445
|
+
readonly denyDotfiles: boolean
|
|
446
|
+
readonly fallthrough: boolean
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// `Array.isArray` does not narrow a readonly array out of a union.
|
|
450
|
+
const isPatternList = (value: HashedFiles): value is readonly RegExp[] =>
|
|
451
|
+
Array.isArray(value) && value.length > 0 && value.every((item) => item instanceof RegExp)
|
|
452
|
+
|
|
453
|
+
function hashedTest(hashedFiles: HashedFiles): (path: string) => boolean {
|
|
454
|
+
if (typeof hashedFiles === "function") return hashedFiles
|
|
455
|
+
if (isPatternList(hashedFiles)) {
|
|
456
|
+
// A global or sticky RegExp keeps `lastIndex` between tests, so every other match would miss.
|
|
457
|
+
const patterns = hashedFiles.map((pattern) =>
|
|
458
|
+
pattern.global || pattern.sticky
|
|
459
|
+
? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""))
|
|
460
|
+
: pattern,
|
|
461
|
+
)
|
|
462
|
+
return (path) => patterns.some((pattern) => pattern.test(path))
|
|
463
|
+
}
|
|
464
|
+
const value: unknown = hashedFiles
|
|
465
|
+
// A Map iterates entries and tests keys, so a path it maps to `false` would still be immutable.
|
|
466
|
+
if (
|
|
467
|
+
typeof value !== "object" ||
|
|
468
|
+
value === null ||
|
|
469
|
+
value instanceof Map ||
|
|
470
|
+
!(Symbol.iterator in value)
|
|
471
|
+
) {
|
|
472
|
+
throw new TypeError(
|
|
473
|
+
"[nifra] serveStatic hashedFiles must be paths, an array of RegExps, or a function",
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
const paths = new Set<string>()
|
|
477
|
+
for (const path of hashedFiles) {
|
|
478
|
+
const item: unknown = path
|
|
479
|
+
if (typeof item !== "string") {
|
|
480
|
+
throw new TypeError(
|
|
481
|
+
"[nifra] serveStatic hashedFiles must be paths, an array of RegExps, or a function",
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
paths.add(item)
|
|
485
|
+
}
|
|
486
|
+
return (path) => paths.has(path)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Check `options` once, with `root` the absolute path its runtime resolved `options.dir` to. */
|
|
490
|
+
export function staticMountOf(options: ServeStaticOptions, root: string): StaticMount {
|
|
491
|
+
const raw = options.prefix ?? "/assets"
|
|
492
|
+
// Index scans, not `/^\/+|\/+$/g` - the trailing-run half backtracks quadratically.
|
|
493
|
+
let from = 0
|
|
494
|
+
while (from < raw.length && raw.charCodeAt(from) === 47 /* '/' */) from++
|
|
495
|
+
let to = raw.length
|
|
496
|
+
while (to > from && raw.charCodeAt(to - 1) === 47) to--
|
|
497
|
+
const prefix = from === to ? "/" : `/${raw.slice(from, to)}`
|
|
498
|
+
const immutable = options.immutable !== false
|
|
499
|
+
const hashed = options.hashedFiles === undefined ? undefined : hashedTest(options.hashedFiles)
|
|
500
|
+
// fromEntries defines each name as its own property, so a `__proto__` header stays a header.
|
|
501
|
+
const headers =
|
|
502
|
+
options.headers === undefined
|
|
503
|
+
? undefined
|
|
504
|
+
: Object.fromEntries(
|
|
505
|
+
Object.entries(options.headers).map(([name, value]) => [name.toLowerCase(), value]),
|
|
506
|
+
)
|
|
507
|
+
return {
|
|
508
|
+
root,
|
|
509
|
+
prefix,
|
|
510
|
+
isImmutable: !immutable ? () => false : hashed === undefined ? () => true : hashed,
|
|
511
|
+
cacheControl: options.cacheControl,
|
|
512
|
+
files: options.files === undefined ? undefined : new Set(options.files),
|
|
513
|
+
allow: options.allow,
|
|
514
|
+
headers,
|
|
515
|
+
denyDotfiles: options.dotfiles !== "allow",
|
|
516
|
+
fallthrough: options.fallthrough === true,
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** One mount or several, as `serveStatic()` takes them, each checked by {@link staticMountOf}. */
|
|
521
|
+
export function staticMountsOf(
|
|
522
|
+
mounts: ServeStaticOptions | readonly ServeStaticOptions[],
|
|
523
|
+
rootOf: (dir: string | URL) => string,
|
|
524
|
+
): readonly StaticMount[] {
|
|
525
|
+
const list: readonly ServeStaticOptions[] = "dir" in mounts ? [mounts] : mounts
|
|
526
|
+
return list.map((options) => staticMountOf(options, rootOf(options.dir)))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* What a mount does with a request path: `"pass"` to the next mount and then the app, a status to
|
|
531
|
+
* answer with, or the decoded path relative to the mount's directory (no leading slash, no `.` or `..`
|
|
532
|
+
* segment, no backslash or NUL) to read.
|
|
533
|
+
*/
|
|
534
|
+
export type StaticMatch =
|
|
535
|
+
| "pass"
|
|
536
|
+
| { readonly status: 400 | 403 | 404 }
|
|
537
|
+
| { readonly rel: string; readonly immutable: boolean }
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Match `path` (the URL path as sent, without the query) against `mount`, without touching the
|
|
541
|
+
* filesystem. `host` names the request's host, read only when the mount has an `allow`.
|
|
542
|
+
*/
|
|
543
|
+
export function matchStatic(mount: StaticMount, path: string, host: () => string): StaticMatch {
|
|
544
|
+
const { prefix } = mount
|
|
545
|
+
if (prefix !== "/" && path !== prefix && !path.startsWith(`${prefix}/`)) return "pass"
|
|
546
|
+
if (mount.files !== undefined && !mount.files.has(path)) return "pass"
|
|
547
|
+
if (mount.allow !== undefined && !mount.allow(host(), path)) return "pass"
|
|
548
|
+
let rel: string
|
|
549
|
+
try {
|
|
550
|
+
rel = decodeURIComponent(prefix === "/" ? path : path.slice(prefix.length))
|
|
551
|
+
} catch {
|
|
552
|
+
return mount.fallthrough ? "pass" : { status: 400 }
|
|
553
|
+
}
|
|
554
|
+
let start = 0
|
|
555
|
+
while (start < rel.length && rel.charCodeAt(start) === 47) start++
|
|
556
|
+
rel = rel.slice(start)
|
|
557
|
+
// A directory request is the app's to answer.
|
|
558
|
+
if (rel === "" || rel.charCodeAt(rel.length - 1) === 47) return "pass"
|
|
559
|
+
if (rel.includes("\0")) return mount.fallthrough ? "pass" : { status: 400 }
|
|
560
|
+
// Traversal in the REQUEST form never reaches a path join: a `..` segment, or a backslash Windows
|
|
561
|
+
// would treat as a separator, has no use in a file URL. A name merely CONTAINING `..`
|
|
562
|
+
// (`logo..png`) is legal. The check runs after decoding, so `%2E`-spelled dots are plain here.
|
|
563
|
+
// Traversal answers 403 (an attack shape, nothing behind it to conceal); a hidden file answers as a
|
|
564
|
+
// missing one does.
|
|
565
|
+
const segments = rel.split("/")
|
|
566
|
+
if (rel.includes("\\") || segments.includes("..")) {
|
|
567
|
+
return mount.fallthrough ? "pass" : { status: 403 }
|
|
568
|
+
}
|
|
569
|
+
if (mount.denyDotfiles && isHiddenPath(rel)) return mount.fallthrough ? "pass" : { status: 404 }
|
|
570
|
+
return { rel, immutable: mount.isImmutable(path) }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The `cache-control` a file a mount serves carries, or `undefined` for its validators alone. */
|
|
574
|
+
export function staticCacheControl(mount: StaticMount, immutable: boolean): string | undefined {
|
|
575
|
+
return immutable ? IMMUTABLE : mount.cacheControl
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** A request's part of a static answer: its method and its headers by lowercase name. */
|
|
579
|
+
export interface StaticRequest {
|
|
580
|
+
readonly method: "GET" | "HEAD"
|
|
581
|
+
readonly header: (name: string) => string | null
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** How a reader reaches the opened, pinned file for {@link planStatic}. */
|
|
585
|
+
export interface StaticFileAccess {
|
|
586
|
+
/** The real path the file was opened at: what tags and types it. */
|
|
587
|
+
readonly path: string
|
|
588
|
+
readonly version: FileVersion
|
|
589
|
+
/** Read the whole file, up to {@link CONTENT_SNAPSHOT_MAX} bytes, through the open descriptor. */
|
|
590
|
+
readonly readWhole: () => Promise<Uint8Array>
|
|
591
|
+
/** Stat the open descriptor again. */
|
|
592
|
+
readonly restat: () => Promise<FileVersion>
|
|
593
|
+
readonly tags: ContentTags
|
|
594
|
+
/** `@nifrajs/core/range`'s parser, passed in so this module imports nothing. */
|
|
595
|
+
readonly parseRange: (
|
|
596
|
+
value: string,
|
|
597
|
+
size: number,
|
|
598
|
+
) =>
|
|
599
|
+
| { readonly kind: "none" | "unsatisfiable" }
|
|
600
|
+
| {
|
|
601
|
+
readonly kind: "satisfiable"
|
|
602
|
+
readonly ranges: readonly { readonly start: number; readonly end: number }[]
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* What a reader sends for a file a mount took: a header-only answer, or `length` bytes from `start`.
|
|
608
|
+
* When tagging it called `readWhole`, the body comes from those same bytes.
|
|
609
|
+
*/
|
|
610
|
+
export type StaticPlan =
|
|
611
|
+
| {
|
|
612
|
+
readonly status: 200 | 206 | 304 | 416
|
|
613
|
+
readonly headers: Record<string, string>
|
|
614
|
+
readonly body: false
|
|
615
|
+
}
|
|
616
|
+
| {
|
|
617
|
+
readonly status: 200 | 206
|
|
618
|
+
readonly headers: Record<string, string>
|
|
619
|
+
readonly body: true
|
|
620
|
+
readonly start: number
|
|
621
|
+
readonly length: number
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The answer for an opened file, the one sequence every runtime's reader runs: the mount's headers
|
|
626
|
+
* (a caller's own `cache-control`, `last-modified` or `etag` kept), a strong tag from the snapshot
|
|
627
|
+
* served for a file up to {@link CONTENT_SNAPSHOT_MAX} bytes, a `304` for a current copy, then one
|
|
628
|
+
* byte range or the whole file. A larger file streams with no tag, and its date is weak: an
|
|
629
|
+
* `If-Range` gets the whole file rather than a range spliced from two versions.
|
|
630
|
+
*/
|
|
631
|
+
export async function planStatic(
|
|
632
|
+
mount: StaticMount,
|
|
633
|
+
immutable: boolean,
|
|
634
|
+
request: StaticRequest,
|
|
635
|
+
file: StaticFileAccess,
|
|
636
|
+
): Promise<StaticPlan> {
|
|
637
|
+
const headers: Record<string, string> = { ...mount.headers }
|
|
638
|
+
if (headers["cache-control"] === undefined) {
|
|
639
|
+
const cacheControl = staticCacheControl(mount, immutable)
|
|
640
|
+
if (cacheControl !== undefined) headers["cache-control"] = cacheControl
|
|
641
|
+
}
|
|
642
|
+
const { size } = file.version
|
|
643
|
+
const streamed = size > CONTENT_SNAPSHOT_MAX
|
|
644
|
+
const modified = streamed ? streamedLastModifiedOf(file.version) : lastModifiedOf(file.version)
|
|
645
|
+
// A date this reader chose for a streamed body is weak; a caller's own stays theirs to vouch for.
|
|
646
|
+
const weakDate = streamed && headers["last-modified"] === undefined
|
|
647
|
+
if (modified !== undefined) headers["last-modified"] ??= new Date(modified).toUTCString()
|
|
648
|
+
const parsedLastModified = Date.parse(headers["last-modified"] ?? "")
|
|
649
|
+
const lastModified = Number.isFinite(parsedLastModified) ? parsedLastModified : undefined
|
|
650
|
+
const ifNoneMatch = request.header("if-none-match")
|
|
651
|
+
const ifModifiedSince = request.header("if-modified-since")
|
|
652
|
+
if (headers.etag === undefined && !streamed) {
|
|
653
|
+
// A HEAD or a 304 sends none of the file, so the tag remembered for this settled version
|
|
654
|
+
// answers it from the stat alone: a write since that tag would show in the stat.
|
|
655
|
+
const known = file.tags.cached(file.path, file.version)
|
|
656
|
+
if (
|
|
657
|
+
known !== undefined &&
|
|
658
|
+
(request.method === "HEAD" ||
|
|
659
|
+
isNotModified(ifNoneMatch, ifModifiedSince, known, lastModified))
|
|
660
|
+
) {
|
|
661
|
+
headers.etag = known
|
|
662
|
+
} else {
|
|
663
|
+
const whole = await file.readWhole()
|
|
664
|
+
headers.etag = await file.tags.ofSnapshot(file.path, file.version, whole, file.restat)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// Custom validators are response headers, not decoration: compare the exact values the client saw.
|
|
668
|
+
const etag = headers.etag
|
|
669
|
+
if (isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)) {
|
|
670
|
+
delete headers["content-length"]
|
|
671
|
+
delete headers["content-type"]
|
|
672
|
+
return { status: 304, headers, body: false }
|
|
673
|
+
}
|
|
674
|
+
// The file served, not the name asked for: a link is typed by its target.
|
|
675
|
+
headers["content-type"] = contentTypeOf(file.path)
|
|
676
|
+
// Never let a client sniff a served file into a more dangerous type (an .svg as active content).
|
|
677
|
+
headers["x-content-type-options"] = "nosniff"
|
|
678
|
+
// Advertised unconditionally: a client that never sees it will not seek in a video or audio file.
|
|
679
|
+
headers["accept-ranges"] = "bytes"
|
|
680
|
+
// Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
|
|
681
|
+
const rangeHeader = request.method === "GET" ? request.header("range") : null
|
|
682
|
+
const parsed =
|
|
683
|
+
rangeHeader !== null &&
|
|
684
|
+
ifRangeAllowsPartial(request.header("if-range"), etag, weakDate ? undefined : lastModified)
|
|
685
|
+
? file.parseRange(rangeHeader, size)
|
|
686
|
+
: undefined
|
|
687
|
+
if (parsed?.kind === "unsatisfiable") {
|
|
688
|
+
headers["content-range"] = `bytes */${size}`
|
|
689
|
+
delete headers["content-length"]
|
|
690
|
+
delete headers["content-type"]
|
|
691
|
+
return { status: 416, headers, body: false }
|
|
692
|
+
}
|
|
693
|
+
// Several disjoint ranges would need a `multipart/byteranges` body assembled from disk; RFC 9110
|
|
694
|
+
// lets a server ignore Range, so the whole file answers them.
|
|
695
|
+
const range =
|
|
696
|
+
parsed?.kind === "satisfiable" && parsed.ranges.length === 1 ? parsed.ranges[0] : undefined
|
|
697
|
+
if (range !== undefined) {
|
|
698
|
+
headers["content-range"] = `bytes ${range.start}-${range.end}/${size}`
|
|
699
|
+
headers["content-length"] = String(range.end - range.start + 1)
|
|
700
|
+
} else {
|
|
701
|
+
headers["content-length"] = String(size)
|
|
702
|
+
}
|
|
703
|
+
const status = range === undefined ? 200 : 206
|
|
704
|
+
if (request.method === "HEAD") return { status, headers, body: false }
|
|
705
|
+
const start = range === undefined ? 0 : range.start
|
|
706
|
+
const length = range === undefined ? size : range.end - range.start + 1
|
|
707
|
+
return { status, headers, body: true, start, length }
|
|
708
|
+
}
|
package/src/wire.ts
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* carrying its own enumerable properties), non-enumerable and symbol keys, and getters (read once).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { isRecord } from "./internal/record.ts"
|
|
25
|
+
|
|
24
26
|
/** Discriminator key on a tagged wire node. Never collides with user data: user objects live under a
|
|
25
27
|
* node's `v` field, so their own keys - including one literally named `$w` - are never re-interpreted. */
|
|
26
28
|
const TAG = "$w"
|
|
@@ -74,10 +76,6 @@ export class WireDecodeError extends TypeError {
|
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
78
|
-
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
79
|
function decodeLimit(value: number | undefined, fallback: number, label: string): number {
|
|
82
80
|
const limit = value ?? fallback
|
|
83
81
|
if (!Number.isSafeInteger(limit) || limit < 0) {
|