@nifrajs/core 5.2.0 → 5.4.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 +61 -0
- package/dist/edge-kit.d.ts +1 -1
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +1 -1
- package/dist/edge-kit.js.map +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +2 -0
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mount.d.ts +12 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +20 -0
- package/dist/mount.js.map +1 -1
- package/dist/range.d.ts.map +1 -1
- package/dist/range.js +62 -2
- package/dist/range.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/registry.d.ts +8 -10
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-target.d.ts +9 -7
- package/dist/server/request-target.d.ts.map +1 -1
- package/dist/server/request-target.js +21 -12
- package/dist/server/request-target.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +10 -7
- package/dist/server/respond.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +41 -13
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +4 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/static-file.d.ts +104 -0
- package/dist/static-file.d.ts.map +1 -0
- package/dist/static-file.js +238 -0
- package/dist/static-file.js.map +1 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +8 -4
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +4 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +6 -1
- package/src/edge-kit.ts +1 -1
- package/src/idempotency.ts +2 -0
- package/src/index.ts +1 -1
- package/src/mount.ts +25 -0
- package/src/range.ts +61 -2
- package/src/server/core-copies.ts +1 -1
- package/src/server/registry.ts +18 -19
- package/src/server/request-context.ts +6 -3
- package/src/server/request-target.ts +21 -12
- package/src/server/respond.ts +10 -7
- package/src/server/server.ts +41 -11
- package/src/server/websocket.ts +3 -0
- package/src/static-file.ts +306 -0
- package/src/tool-contract.ts +7 -4
- package/src/transport-codec.ts +4 -1
package/src/server/server.ts
CHANGED
|
@@ -113,7 +113,7 @@ import {
|
|
|
113
113
|
readBodyFramed,
|
|
114
114
|
} from "./request-context.ts"
|
|
115
115
|
import { recordRequestReplacement } from "./request-lineage.ts"
|
|
116
|
-
import {
|
|
116
|
+
import { needsResolving, resolveDotSegments } from "./request-target.ts"
|
|
117
117
|
import {
|
|
118
118
|
applyStaticResponseHeaders,
|
|
119
119
|
buildStaticResponseHeaders,
|
|
@@ -583,14 +583,21 @@ export { plainValidationError } from "./validation.ts"
|
|
|
583
583
|
// importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
|
|
584
584
|
export { type QueryValue, queryObjectOf, searchOf }
|
|
585
585
|
|
|
586
|
+
// Outside the printable ASCII a parsed path keeps as written: a control, space, `"`, `#`, `<`, `>`,
|
|
587
|
+
// `\`, `^`, `` ` ``, `{`, `}`, or past ASCII (U+FFFD, an invalid escape, included). Bun's table reads
|
|
588
|
+
// a raw `#` and what follows as path, where the parsed URL ends the path there.
|
|
589
|
+
const UNROUTED_CHAR = /[^!$%&'()*+,\-./0-9:;=?@A-Z[\]_a-z|~]/
|
|
590
|
+
|
|
586
591
|
/**
|
|
587
592
|
* A param value Bun's native route table matched but the portable router must decide: an invalid
|
|
588
593
|
* escape, or a piece of path Bun matched raw while its own parsed URL says otherwise - a `.` / `..`
|
|
589
|
-
* segment (Bun hands `%2e` over decoded),
|
|
594
|
+
* segment (Bun hands `%2e` over decoded), a backslash, which the parsed URL reads as `/`, a `#`,
|
|
595
|
+
* where the parsed URL's path ends (`/a/..#/b` is `/`), or a character the parsed URL
|
|
596
|
+
* percent-encodes (`/t/<b>` is `/t/%3Cb%3E`, which a static route for that text matches). The value
|
|
597
|
+
* is decoded, so a raw `<` and a sent `%3C` look alike; both are re-routed.
|
|
590
598
|
*/
|
|
591
599
|
const unroutedParam = (value: string | undefined): boolean =>
|
|
592
|
-
value !== undefined &&
|
|
593
|
-
(value.includes("\uFFFD") || value.includes("\\") || value === "." || value === "..")
|
|
600
|
+
value !== undefined && (value === "." || value === ".." || UNROUTED_CHAR.test(value))
|
|
594
601
|
|
|
595
602
|
function hasUnroutedParam(params: Record<string, string>): boolean {
|
|
596
603
|
for (const key in params) {
|
|
@@ -605,9 +612,19 @@ function hasUnroutedParam(params: Record<string, string>): boolean {
|
|
|
605
612
|
*/
|
|
606
613
|
const rawRequestTargets = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
|
|
607
614
|
|
|
608
|
-
/** `req` as Bun or workerd would have delivered it: the same request, its URL
|
|
615
|
+
/** `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it. */
|
|
609
616
|
const withResolvedTarget = (req: Request): Request => new Request(resolveDotSegments(req.url), req)
|
|
610
617
|
|
|
618
|
+
// A Host as RFC 9110 allows it: a reg-name or an IP literal, then an optional port. Deno builds
|
|
619
|
+
// `Request.url` by appending the target to the Host as received, so `Host: x/admin#` turned a
|
|
620
|
+
// request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
|
|
621
|
+
const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::\d{1,5})?$/i
|
|
622
|
+
|
|
623
|
+
const invalidHost = (req: Request): boolean => {
|
|
624
|
+
const host = req.headers.get("host")
|
|
625
|
+
return host !== null && !HOST_AUTHORITY.test(host)
|
|
626
|
+
}
|
|
627
|
+
|
|
611
628
|
function normalizeMountPrefix(path: string): string {
|
|
612
629
|
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
613
630
|
throw new TypeError("mountFetch path must be an absolute pathname without a query or hash")
|
|
@@ -844,6 +861,9 @@ interface HookAuditState {
|
|
|
844
861
|
checked: boolean
|
|
845
862
|
}
|
|
846
863
|
const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
|
|
864
|
+
// Audits begun and not yet sealed. Zero outside development, so a request tests it before the
|
|
865
|
+
// NODE_ENV literal and never reads `process.env` (a native call of ~130 ns on Node).
|
|
866
|
+
let unsealedHookAudits = 0
|
|
847
867
|
|
|
848
868
|
/** Whether `from` reaches `target` through mounts that keep the path and take a request under `path`.
|
|
849
869
|
* Module-level, not a `Server` method, so a production bundle drops it with its dev-only caller. */
|
|
@@ -875,6 +895,7 @@ const beginHookAudit = (
|
|
|
875
895
|
policy: "warn" | "error" | "off",
|
|
876
896
|
hasCustomLogger: boolean,
|
|
877
897
|
): void => {
|
|
898
|
+
unsealedHookAudits++
|
|
878
899
|
hookAudit.set(key, {
|
|
879
900
|
policy,
|
|
880
901
|
hasCustomLogger,
|
|
@@ -990,6 +1011,7 @@ const sealHookAudit = (
|
|
|
990
1011
|
const state = hookAudit.get(key)
|
|
991
1012
|
if (state === undefined || state.checked) return
|
|
992
1013
|
state.checked = true
|
|
1014
|
+
unsealedHookAudits--
|
|
993
1015
|
if (state.policy === "off" || total + sockets === 0) return
|
|
994
1016
|
const own = total - state.merged
|
|
995
1017
|
const dead = state.sites.filter(
|
|
@@ -3159,7 +3181,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3159
3181
|
fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
|
|
3160
3182
|
// Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
|
|
3161
3183
|
// Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
|
|
3162
|
-
if (rawRequestTargets
|
|
3184
|
+
if (rawRequestTargets) {
|
|
3185
|
+
if (invalidHost(req)) return jsonError(400, "invalid_host")
|
|
3186
|
+
if (needsResolving(req.url)) req = withResolvedTarget(req)
|
|
3187
|
+
}
|
|
3163
3188
|
// An edge deployment whose only ingress is this method declares its requests runtime-framed at
|
|
3164
3189
|
// construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
|
|
3165
3190
|
if (this.trustBodyFraming) markTrustedBodyFraming(req)
|
|
@@ -3173,8 +3198,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3173
3198
|
platform?: Platform<EnvOf<Ctx>>,
|
|
3174
3199
|
): MaybePromise<Response> {
|
|
3175
3200
|
// Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
|
|
3176
|
-
// After that it is
|
|
3177
|
-
if (
|
|
3201
|
+
// After that it is one integer compare; the literal stays so a production define drops the branch.
|
|
3202
|
+
if (unsealedHookAudits !== 0 && process.env.NODE_ENV !== "production")
|
|
3178
3203
|
sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
|
|
3179
3204
|
// Off path (default): straight through - one property check, no closure, no promise.
|
|
3180
3205
|
if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
|
|
@@ -3358,7 +3383,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3358
3383
|
suppliedRuntime?: NodeOutcomeRuntime,
|
|
3359
3384
|
): MaybePromise<NodeServeOutcome> {
|
|
3360
3385
|
// Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
|
|
3361
|
-
if (
|
|
3386
|
+
if (unsealedHookAudits !== 0 && process.env.NODE_ENV !== "production")
|
|
3362
3387
|
sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
|
|
3363
3388
|
// A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
|
|
3364
3389
|
// responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
|
|
@@ -3654,8 +3679,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3654
3679
|
if (req.method !== "GET") return WS_PASS
|
|
3655
3680
|
// The adapter answers a pass by handing this same object to `fetch`.
|
|
3656
3681
|
const received = req
|
|
3657
|
-
// The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
|
|
3658
|
-
if (rawRequestTargets
|
|
3682
|
+
// The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
|
|
3683
|
+
if (rawRequestTargets) {
|
|
3684
|
+
if (invalidHost(req)) {
|
|
3685
|
+
return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
|
|
3686
|
+
}
|
|
3687
|
+
if (needsResolving(req.url)) req = withResolvedTarget(req)
|
|
3688
|
+
}
|
|
3659
3689
|
// The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
|
|
3660
3690
|
platform = this.trustedPlatform(req, platform)
|
|
3661
3691
|
|
package/src/server/websocket.ts
CHANGED
|
@@ -60,6 +60,9 @@ export class TopicRegistry {
|
|
|
60
60
|
private readonly memberships = new WeakMap<NifraWebSocket, Set<string>>()
|
|
61
61
|
|
|
62
62
|
subscribe(topic: string, ws: NifraWebSocket): void {
|
|
63
|
+
// CLOSING or CLOSED: close has run, or is about to run, `unsubscribeAll`, so an async handler's
|
|
64
|
+
// late subscribe would hold the dead socket for good.
|
|
65
|
+
if (ws.readyState >= 2) return
|
|
63
66
|
let subs = this.topics.get(topic)
|
|
64
67
|
if (subs === undefined) {
|
|
65
68
|
subs = new Set()
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a file served from disk is typed as, which files are never served, and when a client's copy of
|
|
3
|
+
* one is still current - the shared owner for every static-file surface.
|
|
4
|
+
*
|
|
5
|
+
* `@nifrajs/web`'s `servePublicDir` (dev, the Bun entry), the Deno entry's handler and
|
|
6
|
+
* `@nifrajs/node`'s `static` reader (the Node entry) answer the same deployed files. Copies that drift
|
|
7
|
+
* serve a `.pdf` as a download on one runtime and inline on the other, a `.well-known` file on one and
|
|
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 adapter runs a generated copy (scripts/gen-node-outcome.ts), so this
|
|
10
|
+
* module imports nothing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Media types by extension. A table rather than the runtime's own file typing, which Node and Deno
|
|
15
|
+
* lack. Unknown extensions, and extensionless files such as ACME tokens, are
|
|
16
|
+
* `application/octet-stream`, which together with `nosniff` keeps a browser from guessing a more
|
|
17
|
+
* dangerous type.
|
|
18
|
+
*/
|
|
19
|
+
export const CONTENT_TYPES: Readonly<Record<string, string>> = Object.freeze({
|
|
20
|
+
".html": "text/html; charset=utf-8",
|
|
21
|
+
".htm": "text/html; charset=utf-8",
|
|
22
|
+
".css": "text/css; charset=utf-8",
|
|
23
|
+
".js": "text/javascript; charset=utf-8",
|
|
24
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
25
|
+
".cjs": "text/javascript; charset=utf-8",
|
|
26
|
+
".json": "application/json; charset=utf-8",
|
|
27
|
+
".map": "application/json; charset=utf-8",
|
|
28
|
+
".webmanifest": "application/manifest+json",
|
|
29
|
+
".txt": "text/plain; charset=utf-8",
|
|
30
|
+
".md": "text/markdown; charset=utf-8",
|
|
31
|
+
".csv": "text/csv; charset=utf-8",
|
|
32
|
+
".xml": "application/xml",
|
|
33
|
+
".rss": "application/rss+xml",
|
|
34
|
+
".atom": "application/atom+xml",
|
|
35
|
+
".svg": "image/svg+xml",
|
|
36
|
+
".png": "image/png",
|
|
37
|
+
".jpg": "image/jpeg",
|
|
38
|
+
".jpeg": "image/jpeg",
|
|
39
|
+
".gif": "image/gif",
|
|
40
|
+
".webp": "image/webp",
|
|
41
|
+
".avif": "image/avif",
|
|
42
|
+
".ico": "image/x-icon",
|
|
43
|
+
".bmp": "image/bmp",
|
|
44
|
+
".woff": "font/woff",
|
|
45
|
+
".woff2": "font/woff2",
|
|
46
|
+
".ttf": "font/ttf",
|
|
47
|
+
".otf": "font/otf",
|
|
48
|
+
".wasm": "application/wasm",
|
|
49
|
+
".pdf": "application/pdf",
|
|
50
|
+
".zip": "application/zip",
|
|
51
|
+
".mp4": "video/mp4",
|
|
52
|
+
".webm": "video/webm",
|
|
53
|
+
".ogg": "audio/ogg",
|
|
54
|
+
".mp3": "audio/mpeg",
|
|
55
|
+
".wav": "audio/wav",
|
|
56
|
+
".m4a": "audio/mp4",
|
|
57
|
+
".vtt": "text/vtt; charset=utf-8",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** The media type for a file path or name by its extension, case-insensitively. */
|
|
61
|
+
export function contentTypeOf(path: string): string {
|
|
62
|
+
const dot = path.lastIndexOf(".")
|
|
63
|
+
// A dot before the last separator belongs to a directory; a leading dot names a dotfile, not a type.
|
|
64
|
+
const name = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1
|
|
65
|
+
if (dot <= name) return "application/octet-stream"
|
|
66
|
+
return CONTENT_TYPES[path.slice(dot).toLowerCase()] ?? "application/octet-stream"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether a relative path crosses a dot-segment other than `.well-known` (RFC 8615, public by
|
|
71
|
+
* definition). Serving, the build's `public/` copy and the parity listing all apply it, so an editor
|
|
72
|
+
* or VCS artifact (`.env`, `.git/`, `.DS_Store`) is neither served nor shipped. Both separators split:
|
|
73
|
+
* a request path uses `/`, a relative file path the OS separator.
|
|
74
|
+
*/
|
|
75
|
+
export function isHiddenPath(rel: string): boolean {
|
|
76
|
+
for (const segment of rel.split(/[\\/]/)) {
|
|
77
|
+
if (segment.charCodeAt(0) === 46 /* '.' */ && segment !== ".well-known") return true
|
|
78
|
+
}
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The stat fields that tell one version of a file from another on one host: node:fs `Stats` as it is,
|
|
84
|
+
* or Deno's `FileInfo` with its dates as epoch milliseconds and a field it lacks as `NaN`.
|
|
85
|
+
*/
|
|
86
|
+
export interface FileVersion {
|
|
87
|
+
readonly dev: number
|
|
88
|
+
readonly ino: number
|
|
89
|
+
readonly size: number
|
|
90
|
+
readonly mtimeMs: number
|
|
91
|
+
readonly ctimeMs: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A write sets a file's mtime and ctime together (a chmod or rename right after adds well under a
|
|
95
|
+
// millisecond), and Deno truncates the ctime it reports to the second. No tool can set a ctime, so a
|
|
96
|
+
// larger gap means the mtime came from somewhere else.
|
|
97
|
+
const MODIFIED_LAG_MS = 1000
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The time a file can be dated by in Last-Modified, or `undefined`: its mtime, only while that is
|
|
101
|
+
* the time of its own last change. A copy or build that keeps or normalizes timestamps - `cp -p`,
|
|
102
|
+
* `rsync -a`, tar, a container image layer, SOURCE_DATE_EPOCH, the Nix store's 1 s - gives a
|
|
103
|
+
* same-size replacement the old file's date, so its files are left undated and their content tag
|
|
104
|
+
* validates them alone.
|
|
105
|
+
*/
|
|
106
|
+
export function lastModifiedOf(version: FileVersion): number | undefined {
|
|
107
|
+
const lag = Math.abs(version.ctimeMs - version.mtimeMs)
|
|
108
|
+
return version.mtimeMs > 0 && lag < MODIFIED_LAG_MS ? version.mtimeMs : undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** HTTP dates carry whole seconds; comparing raw milliseconds makes every file look stale. */
|
|
112
|
+
function seconds(time: number): number {
|
|
113
|
+
return Math.floor(time / 1000)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function opaqueTag(tag: string): string {
|
|
117
|
+
return tag.startsWith("W/") ? tag.slice(2) : tag
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether the client's copy is current, so the answer is a bodyless 304. `If-None-Match` (the header,
|
|
122
|
+
* or `null`) decides whenever it is sent (RFC 9110 section 13.2.2), by weak comparison with `etag`, and
|
|
123
|
+
* `*` matches any file. Otherwise an `If-Modified-Since` at or after `lastModified` (epoch
|
|
124
|
+
* milliseconds), compared at the whole seconds an HTTP date carries; an unparseable date, or a file
|
|
125
|
+
* with no date, is never current.
|
|
126
|
+
*/
|
|
127
|
+
export function isNotModified(
|
|
128
|
+
ifNoneMatch: string | null,
|
|
129
|
+
ifModifiedSince: string | null,
|
|
130
|
+
etag: string | undefined,
|
|
131
|
+
lastModified: number | undefined,
|
|
132
|
+
): boolean {
|
|
133
|
+
if (ifNoneMatch !== null) {
|
|
134
|
+
const current = etag === undefined ? undefined : opaqueTag(etag)
|
|
135
|
+
for (const raw of ifNoneMatch.split(",")) {
|
|
136
|
+
const candidate = raw.trim()
|
|
137
|
+
if (candidate === "*" || (current !== undefined && opaqueTag(candidate) === current)) {
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
if (ifModifiedSince === null || lastModified === undefined) return false
|
|
144
|
+
const since = Date.parse(ifModifiedSince)
|
|
145
|
+
return Number.isFinite(since) && seconds(since) >= seconds(lastModified)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether `If-Range` (the header, or `null`) lets a range request be answered partially; otherwise the
|
|
150
|
+
* whole file is the conformant answer, not a fallback. An entity tag must equal the file's strong
|
|
151
|
+
* `etag` (RFC 9110 section 13.1.5). A date must equal `lastModified` to the second, and counts only
|
|
152
|
+
* for a file with no strong tag: a client holding the tag sends it, and a date cannot tell apart two
|
|
153
|
+
* versions dated in the same second - here or on another instance - so resuming by one could splice
|
|
154
|
+
* them.
|
|
155
|
+
*/
|
|
156
|
+
export function ifRangeAllowsPartial(
|
|
157
|
+
ifRange: string | null,
|
|
158
|
+
etag: string | undefined,
|
|
159
|
+
lastModified: number | undefined,
|
|
160
|
+
): boolean {
|
|
161
|
+
if (ifRange === null) return true
|
|
162
|
+
const item = ifRange.trim()
|
|
163
|
+
const strong = etag !== undefined && !etag.startsWith("W/")
|
|
164
|
+
if (item.startsWith('"') || item.startsWith("W/")) return strong && item === etag
|
|
165
|
+
if (strong || lastModified === undefined) return false
|
|
166
|
+
const date = Date.parse(item)
|
|
167
|
+
return Number.isFinite(date) && seconds(date) === seconds(lastModified)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What node:crypto's `createHash` returns; Bun, Node and Deno all provide it. */
|
|
171
|
+
export interface ContentHash {
|
|
172
|
+
update(bytes: Uint8Array): unknown
|
|
173
|
+
digest(encoding: "base64url"): string
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Reads into `bytes` from byte `position` of a file, resolving to the count read (0 at its end). */
|
|
177
|
+
export type ReadAt = (bytes: Uint8Array, position: number) => Promise<number>
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Strong entity tags from what a file holds, the same for the same bytes on every instance and
|
|
181
|
+
* runtime, so a replacement that keeps a file's size and timestamps still changes its tag. A tag is
|
|
182
|
+
* remembered per real path for the version it was computed from, so serving a known file reads no
|
|
183
|
+
* more than before.
|
|
184
|
+
*/
|
|
185
|
+
export interface ContentTags {
|
|
186
|
+
/** The tag remembered for this version of the file at `path`, if any. */
|
|
187
|
+
cached(path: string, version: FileVersion): string | undefined
|
|
188
|
+
/** The tag of `bytes`, read whole from this version of the file at `path`. */
|
|
189
|
+
ofBytes(path: string, version: FileVersion, bytes: Uint8Array): string
|
|
190
|
+
/**
|
|
191
|
+
* The tag of this version of the file at `path`, read whole through `read`; requests for a version
|
|
192
|
+
* already being read wait for that read. A file over {@link CONTENT_TAG_MAX} is not read: its tag
|
|
193
|
+
* names its identity on this host instead, or is `undefined` where the runtime reports none.
|
|
194
|
+
*/
|
|
195
|
+
ofFile(path: string, version: FileVersion, read: ReadAt): Promise<string | undefined>
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ContentTagOptions {
|
|
199
|
+
/** Most tags remembered at once (default 8192); past it the earliest is forgotten. */
|
|
200
|
+
readonly limit?: number
|
|
201
|
+
/** The clock compared with a file's ctime (default `Date.now`). */
|
|
202
|
+
readonly now?: () => number
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Largest file tagged by its content. Its first request waits for one read of the whole file, ~0.1 s
|
|
207
|
+
* per 256 MiB with hardware SHA-256 and several times that on a slow disk or CPU, so a larger file -
|
|
208
|
+
* a long video that is only ever fetched by ranges, say - is tagged by its device, inode, size and
|
|
209
|
+
* times. That tag is still strong and changes with any replacement, but differs between instances.
|
|
210
|
+
*/
|
|
211
|
+
export const CONTENT_TAG_MAX = 256 * 1024 * 1024
|
|
212
|
+
|
|
213
|
+
const READ_CHUNK = 1024 * 1024
|
|
214
|
+
// A file changed this recently can change again within its ctime's granularity (a jiffy on ext4, a
|
|
215
|
+
// second on HFS+ and as Deno reports it) without its stat showing it, so it is not tagged by version.
|
|
216
|
+
const RACY_MS = 2000
|
|
217
|
+
|
|
218
|
+
function sameVersion(a: FileVersion, b: FileVersion): boolean {
|
|
219
|
+
return (
|
|
220
|
+
a.ctimeMs === b.ctimeMs &&
|
|
221
|
+
a.mtimeMs === b.mtimeMs &&
|
|
222
|
+
a.size === b.size &&
|
|
223
|
+
a.ino === b.ino &&
|
|
224
|
+
a.dev === b.dev
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function quoted(hash: ContentHash): string {
|
|
229
|
+
return `"${hash.digest("base64url").slice(0, 22)}"`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Tags from `createHash` - node:crypto's, which every runtime nifra serves files on provides. */
|
|
233
|
+
export function contentTags(
|
|
234
|
+
createHash: (algorithm: "sha256") => ContentHash,
|
|
235
|
+
options: ContentTagOptions = {},
|
|
236
|
+
): ContentTags {
|
|
237
|
+
const limit = options.limit ?? 8192
|
|
238
|
+
const now = options.now ?? Date.now
|
|
239
|
+
const known = new Map<string, FileVersion & { readonly tag: string }>()
|
|
240
|
+
const reading = new Map<
|
|
241
|
+
string,
|
|
242
|
+
{ readonly version: FileVersion; readonly tag: Promise<string> }
|
|
243
|
+
>()
|
|
244
|
+
|
|
245
|
+
const remember = (path: string, version: FileVersion, tag: string): void => {
|
|
246
|
+
if (!(now() - version.ctimeMs >= RACY_MS)) return
|
|
247
|
+
const { dev, ino, size, mtimeMs, ctimeMs } = version
|
|
248
|
+
known.delete(path)
|
|
249
|
+
known.set(path, { dev, ino, size, mtimeMs, ctimeMs, tag })
|
|
250
|
+
for (const earliest of known.keys()) {
|
|
251
|
+
if (known.size <= limit) break
|
|
252
|
+
known.delete(earliest)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const readWhole = async (path: string, version: FileVersion, read: ReadAt): Promise<string> => {
|
|
257
|
+
const hash = createHash("sha256")
|
|
258
|
+
const chunk = new Uint8Array(Math.min(version.size, READ_CHUNK))
|
|
259
|
+
let position = 0
|
|
260
|
+
while (position < version.size) {
|
|
261
|
+
const count = await read(
|
|
262
|
+
chunk.subarray(0, Math.min(chunk.length, version.size - position)),
|
|
263
|
+
position,
|
|
264
|
+
)
|
|
265
|
+
if (count === 0) break
|
|
266
|
+
hash.update(chunk.subarray(0, count))
|
|
267
|
+
position += count
|
|
268
|
+
}
|
|
269
|
+
const tag = quoted(hash)
|
|
270
|
+
if (position === version.size) remember(path, version, tag)
|
|
271
|
+
return tag
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
cached(path, version) {
|
|
276
|
+
const entry = known.get(path)
|
|
277
|
+
return entry !== undefined && sameVersion(entry, version) ? entry.tag : undefined
|
|
278
|
+
},
|
|
279
|
+
ofBytes(path, version, bytes) {
|
|
280
|
+
const hash = createHash("sha256")
|
|
281
|
+
hash.update(bytes)
|
|
282
|
+
const tag = quoted(hash)
|
|
283
|
+
if (bytes.byteLength === version.size) remember(path, version, tag)
|
|
284
|
+
return tag
|
|
285
|
+
},
|
|
286
|
+
async ofFile(path, version, read) {
|
|
287
|
+
if (version.size > CONTENT_TAG_MAX) {
|
|
288
|
+
const { dev, ino, size, mtimeMs, ctimeMs } = version
|
|
289
|
+
if (!Number.isFinite(ino) || !(now() - ctimeMs >= RACY_MS)) return undefined
|
|
290
|
+
const hash = createHash("sha256")
|
|
291
|
+
hash.update(new TextEncoder().encode(`${dev}:${ino}:${size}:${mtimeMs}:${ctimeMs}`))
|
|
292
|
+
return quoted(hash)
|
|
293
|
+
}
|
|
294
|
+
const pending = reading.get(path)
|
|
295
|
+
if (pending !== undefined && sameVersion(pending.version, version)) return pending.tag
|
|
296
|
+
const entry = { version, tag: readWhole(path, version, read) }
|
|
297
|
+
reading.set(path, entry)
|
|
298
|
+
try {
|
|
299
|
+
return await entry.tag
|
|
300
|
+
} finally {
|
|
301
|
+
// A read of a newer version may have replaced this entry; leave that one in place.
|
|
302
|
+
if (reading.get(path) === entry) reading.delete(path)
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
}
|
package/src/tool-contract.ts
CHANGED
|
@@ -469,8 +469,9 @@ export async function executeTool<Input, Output>(
|
|
|
469
469
|
await options.idempotency?.abandon(reservationInput(reservation))
|
|
470
470
|
} catch {}
|
|
471
471
|
}
|
|
472
|
-
// Once the executor has
|
|
473
|
-
// that may have committed. A store that throws on completion counts as
|
|
472
|
+
// Once the executor has started, the reservation is never released, even when it throws: a retry
|
|
473
|
+
// would repeat a side effect that may have committed. A store that throws on completion counts as
|
|
474
|
+
// one that refused it.
|
|
474
475
|
let executed = false
|
|
475
476
|
const complete = async (): Promise<boolean> => {
|
|
476
477
|
if (reservation === undefined) return true
|
|
@@ -660,6 +661,7 @@ export async function executeTool<Input, Output>(
|
|
|
660
661
|
record("execution", "dry-run")
|
|
661
662
|
return finish({ ok: true, dryRun })
|
|
662
663
|
}
|
|
664
|
+
executed = true
|
|
663
665
|
const output = await tool.execute(validatedInput.value, {
|
|
664
666
|
effectId,
|
|
665
667
|
signal,
|
|
@@ -667,7 +669,6 @@ export async function executeTool<Input, Output>(
|
|
|
667
669
|
dryRun,
|
|
668
670
|
...(tool.policy === undefined ? {} : { policy: tool.policy }),
|
|
669
671
|
})
|
|
670
|
-
executed = true
|
|
671
672
|
record("execution", "committed")
|
|
672
673
|
const validatedOutput = await validateStandard(tool.output, output)
|
|
673
674
|
if (!validatedOutput.ok) {
|
|
@@ -699,7 +700,9 @@ export async function executeTool<Input, Output>(
|
|
|
699
700
|
}
|
|
700
701
|
return finish({ ok: true, dryRun, output: validatedOutput.value })
|
|
701
702
|
} catch (error) {
|
|
702
|
-
|
|
703
|
+
// A completion the store refuses still leaves the key pending until its lease lapses.
|
|
704
|
+
if (executed) await complete()
|
|
705
|
+
else await abandon()
|
|
703
706
|
const code = signal.aborted || isAbortError(error) ? "cancelled" : "execution_failed"
|
|
704
707
|
record("execution", "failed", code)
|
|
705
708
|
return finish({ ok: false, dryRun, error: { code, stage: "execution" } })
|
package/src/transport-codec.ts
CHANGED
|
@@ -228,8 +228,11 @@ export async function readBoundedBytes(
|
|
|
228
228
|
): Promise<Uint8Array> {
|
|
229
229
|
const maxBytes = maxBytesOf(options)
|
|
230
230
|
const declared = response.headers.get("content-length")
|
|
231
|
-
if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes)
|
|
231
|
+
if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes) {
|
|
232
|
+
// Refused unread, the body would otherwise hold its connection open until the sender gives up.
|
|
233
|
+
response.body?.cancel().catch(() => {})
|
|
232
234
|
throw new TransportCodecError("transport payload exceeds maxBytes")
|
|
235
|
+
}
|
|
233
236
|
if (response.body === null) return new Uint8Array(0)
|
|
234
237
|
const reader = response.body.getReader()
|
|
235
238
|
const chunks: Uint8Array[] = []
|