@nifrajs/core 5.3.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.
@@ -0,0 +1,104 @@
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
+ * Media types by extension. A table rather than the runtime's own file typing, which Node and Deno
14
+ * lack. Unknown extensions, and extensionless files such as ACME tokens, are
15
+ * `application/octet-stream`, which together with `nosniff` keeps a browser from guessing a more
16
+ * dangerous type.
17
+ */
18
+ export declare const CONTENT_TYPES: Readonly<Record<string, string>>;
19
+ /** The media type for a file path or name by its extension, case-insensitively. */
20
+ export declare function contentTypeOf(path: string): string;
21
+ /**
22
+ * Whether a relative path crosses a dot-segment other than `.well-known` (RFC 8615, public by
23
+ * definition). Serving, the build's `public/` copy and the parity listing all apply it, so an editor
24
+ * or VCS artifact (`.env`, `.git/`, `.DS_Store`) is neither served nor shipped. Both separators split:
25
+ * a request path uses `/`, a relative file path the OS separator.
26
+ */
27
+ export declare function isHiddenPath(rel: string): boolean;
28
+ /**
29
+ * The stat fields that tell one version of a file from another on one host: node:fs `Stats` as it is,
30
+ * or Deno's `FileInfo` with its dates as epoch milliseconds and a field it lacks as `NaN`.
31
+ */
32
+ export interface FileVersion {
33
+ readonly dev: number;
34
+ readonly ino: number;
35
+ readonly size: number;
36
+ readonly mtimeMs: number;
37
+ readonly ctimeMs: number;
38
+ }
39
+ /**
40
+ * The time a file can be dated by in Last-Modified, or `undefined`: its mtime, only while that is
41
+ * the time of its own last change. A copy or build that keeps or normalizes timestamps - `cp -p`,
42
+ * `rsync -a`, tar, a container image layer, SOURCE_DATE_EPOCH, the Nix store's 1 s - gives a
43
+ * same-size replacement the old file's date, so its files are left undated and their content tag
44
+ * validates them alone.
45
+ */
46
+ export declare function lastModifiedOf(version: FileVersion): number | undefined;
47
+ /**
48
+ * Whether the client's copy is current, so the answer is a bodyless 304. `If-None-Match` (the header,
49
+ * or `null`) decides whenever it is sent (RFC 9110 section 13.2.2), by weak comparison with `etag`, and
50
+ * `*` matches any file. Otherwise an `If-Modified-Since` at or after `lastModified` (epoch
51
+ * milliseconds), compared at the whole seconds an HTTP date carries; an unparseable date, or a file
52
+ * with no date, is never current.
53
+ */
54
+ export declare function isNotModified(ifNoneMatch: string | null, ifModifiedSince: string | null, etag: string | undefined, lastModified: number | undefined): boolean;
55
+ /**
56
+ * Whether `If-Range` (the header, or `null`) lets a range request be answered partially; otherwise the
57
+ * whole file is the conformant answer, not a fallback. An entity tag must equal the file's strong
58
+ * `etag` (RFC 9110 section 13.1.5). A date must equal `lastModified` to the second, and counts only
59
+ * for a file with no strong tag: a client holding the tag sends it, and a date cannot tell apart two
60
+ * versions dated in the same second - here or on another instance - so resuming by one could splice
61
+ * them.
62
+ */
63
+ export declare function ifRangeAllowsPartial(ifRange: string | null, etag: string | undefined, lastModified: number | undefined): boolean;
64
+ /** What node:crypto's `createHash` returns; Bun, Node and Deno all provide it. */
65
+ export interface ContentHash {
66
+ update(bytes: Uint8Array): unknown;
67
+ digest(encoding: "base64url"): string;
68
+ }
69
+ /** Reads into `bytes` from byte `position` of a file, resolving to the count read (0 at its end). */
70
+ export type ReadAt = (bytes: Uint8Array, position: number) => Promise<number>;
71
+ /**
72
+ * Strong entity tags from what a file holds, the same for the same bytes on every instance and
73
+ * runtime, so a replacement that keeps a file's size and timestamps still changes its tag. A tag is
74
+ * remembered per real path for the version it was computed from, so serving a known file reads no
75
+ * more than before.
76
+ */
77
+ export interface ContentTags {
78
+ /** The tag remembered for this version of the file at `path`, if any. */
79
+ cached(path: string, version: FileVersion): string | undefined;
80
+ /** The tag of `bytes`, read whole from this version of the file at `path`. */
81
+ ofBytes(path: string, version: FileVersion, bytes: Uint8Array): string;
82
+ /**
83
+ * The tag of this version of the file at `path`, read whole through `read`; requests for a version
84
+ * already being read wait for that read. A file over {@link CONTENT_TAG_MAX} is not read: its tag
85
+ * names its identity on this host instead, or is `undefined` where the runtime reports none.
86
+ */
87
+ ofFile(path: string, version: FileVersion, read: ReadAt): Promise<string | undefined>;
88
+ }
89
+ export interface ContentTagOptions {
90
+ /** Most tags remembered at once (default 8192); past it the earliest is forgotten. */
91
+ readonly limit?: number;
92
+ /** The clock compared with a file's ctime (default `Date.now`). */
93
+ readonly now?: () => number;
94
+ }
95
+ /**
96
+ * Largest file tagged by its content. Its first request waits for one read of the whole file, ~0.1 s
97
+ * per 256 MiB with hardware SHA-256 and several times that on a slow disk or CPU, so a larger file -
98
+ * a long video that is only ever fetched by ranges, say - is tagged by its device, inode, size and
99
+ * times. That tag is still strong and changes with any replacement, but differs between instances.
100
+ */
101
+ export declare const CONTENT_TAG_MAX: number;
102
+ /** Tags from `createHash` - node:crypto's, which every runtime nifra serves files on provides. */
103
+ export declare function contentTags(createHash: (algorithm: "sha256") => ContentHash, options?: ContentTagOptions): ContentTags;
104
+ //# sourceMappingURL=static-file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"static-file.d.ts","sourceRoot":"","sources":["../src/static-file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAuCzD,CAAA;AAEF,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMlD;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAKjD;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAOD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,CAGvE;AAWD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,EAC1B,eAAe,EAAE,MAAM,GAAG,IAAI,EAC9B,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,OAAO,CAcT;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,OAAO,CAQT;AAED,kFAAkF;AAClF,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAA;IAClC,MAAM,CAAC,QAAQ,EAAE,WAAW,GAAG,MAAM,CAAA;CACtC;AAED,qGAAqG;AACrG,MAAM,MAAM,MAAM,GAAG,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;AAE7E;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,yEAAyE;IACzE,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,CAAA;IAC9D,8EAA8E;IAC9E,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,UAAU,GAAG,MAAM,CAAA;IACtE;;;;OAIG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;CACtF;AAED,MAAM,WAAW,iBAAiB;IAChC,sFAAsF;IACtF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,mEAAmE;IACnE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAC5B;AAED;;;;;GAKG;AACH,eAAO,MAAM,eAAe,QAAoB,CAAA;AAqBhD,kGAAkG;AAClG,wBAAgB,WAAW,CACzB,UAAU,EAAE,CAAC,SAAS,EAAE,QAAQ,KAAK,WAAW,EAChD,OAAO,GAAE,iBAAsB,GAC9B,WAAW,CAsEb"}
@@ -0,0 +1,238 @@
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
+ * Media types by extension. A table rather than the runtime's own file typing, which Node and Deno
14
+ * lack. Unknown extensions, and extensionless files such as ACME tokens, are
15
+ * `application/octet-stream`, which together with `nosniff` keeps a browser from guessing a more
16
+ * dangerous type.
17
+ */
18
+ export const CONTENT_TYPES = Object.freeze({
19
+ ".html": "text/html; charset=utf-8",
20
+ ".htm": "text/html; charset=utf-8",
21
+ ".css": "text/css; charset=utf-8",
22
+ ".js": "text/javascript; charset=utf-8",
23
+ ".mjs": "text/javascript; charset=utf-8",
24
+ ".cjs": "text/javascript; charset=utf-8",
25
+ ".json": "application/json; charset=utf-8",
26
+ ".map": "application/json; charset=utf-8",
27
+ ".webmanifest": "application/manifest+json",
28
+ ".txt": "text/plain; charset=utf-8",
29
+ ".md": "text/markdown; charset=utf-8",
30
+ ".csv": "text/csv; charset=utf-8",
31
+ ".xml": "application/xml",
32
+ ".rss": "application/rss+xml",
33
+ ".atom": "application/atom+xml",
34
+ ".svg": "image/svg+xml",
35
+ ".png": "image/png",
36
+ ".jpg": "image/jpeg",
37
+ ".jpeg": "image/jpeg",
38
+ ".gif": "image/gif",
39
+ ".webp": "image/webp",
40
+ ".avif": "image/avif",
41
+ ".ico": "image/x-icon",
42
+ ".bmp": "image/bmp",
43
+ ".woff": "font/woff",
44
+ ".woff2": "font/woff2",
45
+ ".ttf": "font/ttf",
46
+ ".otf": "font/otf",
47
+ ".wasm": "application/wasm",
48
+ ".pdf": "application/pdf",
49
+ ".zip": "application/zip",
50
+ ".mp4": "video/mp4",
51
+ ".webm": "video/webm",
52
+ ".ogg": "audio/ogg",
53
+ ".mp3": "audio/mpeg",
54
+ ".wav": "audio/wav",
55
+ ".m4a": "audio/mp4",
56
+ ".vtt": "text/vtt; charset=utf-8",
57
+ });
58
+ /** The media type for a file path or name by its extension, case-insensitively. */
59
+ export function contentTypeOf(path) {
60
+ const dot = path.lastIndexOf(".");
61
+ // A dot before the last separator belongs to a directory; a leading dot names a dotfile, not a type.
62
+ const name = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1;
63
+ if (dot <= name)
64
+ return "application/octet-stream";
65
+ return CONTENT_TYPES[path.slice(dot).toLowerCase()] ?? "application/octet-stream";
66
+ }
67
+ /**
68
+ * Whether a relative path crosses a dot-segment other than `.well-known` (RFC 8615, public by
69
+ * definition). Serving, the build's `public/` copy and the parity listing all apply it, so an editor
70
+ * or VCS artifact (`.env`, `.git/`, `.DS_Store`) is neither served nor shipped. Both separators split:
71
+ * a request path uses `/`, a relative file path the OS separator.
72
+ */
73
+ export function isHiddenPath(rel) {
74
+ for (const segment of rel.split(/[\\/]/)) {
75
+ if (segment.charCodeAt(0) === 46 /* '.' */ && segment !== ".well-known")
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ // A write sets a file's mtime and ctime together (a chmod or rename right after adds well under a
81
+ // millisecond), and Deno truncates the ctime it reports to the second. No tool can set a ctime, so a
82
+ // larger gap means the mtime came from somewhere else.
83
+ const MODIFIED_LAG_MS = 1000;
84
+ /**
85
+ * The time a file can be dated by in Last-Modified, or `undefined`: its mtime, only while that is
86
+ * the time of its own last change. A copy or build that keeps or normalizes timestamps - `cp -p`,
87
+ * `rsync -a`, tar, a container image layer, SOURCE_DATE_EPOCH, the Nix store's 1 s - gives a
88
+ * same-size replacement the old file's date, so its files are left undated and their content tag
89
+ * validates them alone.
90
+ */
91
+ export function lastModifiedOf(version) {
92
+ const lag = Math.abs(version.ctimeMs - version.mtimeMs);
93
+ return version.mtimeMs > 0 && lag < MODIFIED_LAG_MS ? version.mtimeMs : undefined;
94
+ }
95
+ /** HTTP dates carry whole seconds; comparing raw milliseconds makes every file look stale. */
96
+ function seconds(time) {
97
+ return Math.floor(time / 1000);
98
+ }
99
+ function opaqueTag(tag) {
100
+ return tag.startsWith("W/") ? tag.slice(2) : tag;
101
+ }
102
+ /**
103
+ * Whether the client's copy is current, so the answer is a bodyless 304. `If-None-Match` (the header,
104
+ * or `null`) decides whenever it is sent (RFC 9110 section 13.2.2), by weak comparison with `etag`, and
105
+ * `*` matches any file. Otherwise an `If-Modified-Since` at or after `lastModified` (epoch
106
+ * milliseconds), compared at the whole seconds an HTTP date carries; an unparseable date, or a file
107
+ * with no date, is never current.
108
+ */
109
+ export function isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified) {
110
+ if (ifNoneMatch !== null) {
111
+ const current = etag === undefined ? undefined : opaqueTag(etag);
112
+ for (const raw of ifNoneMatch.split(",")) {
113
+ const candidate = raw.trim();
114
+ if (candidate === "*" || (current !== undefined && opaqueTag(candidate) === current)) {
115
+ return true;
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+ if (ifModifiedSince === null || lastModified === undefined)
121
+ return false;
122
+ const since = Date.parse(ifModifiedSince);
123
+ return Number.isFinite(since) && seconds(since) >= seconds(lastModified);
124
+ }
125
+ /**
126
+ * Whether `If-Range` (the header, or `null`) lets a range request be answered partially; otherwise the
127
+ * whole file is the conformant answer, not a fallback. An entity tag must equal the file's strong
128
+ * `etag` (RFC 9110 section 13.1.5). A date must equal `lastModified` to the second, and counts only
129
+ * for a file with no strong tag: a client holding the tag sends it, and a date cannot tell apart two
130
+ * versions dated in the same second - here or on another instance - so resuming by one could splice
131
+ * them.
132
+ */
133
+ export function ifRangeAllowsPartial(ifRange, etag, lastModified) {
134
+ if (ifRange === null)
135
+ return true;
136
+ const item = ifRange.trim();
137
+ const strong = etag !== undefined && !etag.startsWith("W/");
138
+ if (item.startsWith('"') || item.startsWith("W/"))
139
+ return strong && item === etag;
140
+ if (strong || lastModified === undefined)
141
+ return false;
142
+ const date = Date.parse(item);
143
+ return Number.isFinite(date) && seconds(date) === seconds(lastModified);
144
+ }
145
+ /**
146
+ * Largest file tagged by its content. Its first request waits for one read of the whole file, ~0.1 s
147
+ * per 256 MiB with hardware SHA-256 and several times that on a slow disk or CPU, so a larger file -
148
+ * a long video that is only ever fetched by ranges, say - is tagged by its device, inode, size and
149
+ * times. That tag is still strong and changes with any replacement, but differs between instances.
150
+ */
151
+ export const CONTENT_TAG_MAX = 256 * 1024 * 1024;
152
+ const READ_CHUNK = 1024 * 1024;
153
+ // A file changed this recently can change again within its ctime's granularity (a jiffy on ext4, a
154
+ // second on HFS+ and as Deno reports it) without its stat showing it, so it is not tagged by version.
155
+ const RACY_MS = 2000;
156
+ function sameVersion(a, b) {
157
+ return (a.ctimeMs === b.ctimeMs &&
158
+ a.mtimeMs === b.mtimeMs &&
159
+ a.size === b.size &&
160
+ a.ino === b.ino &&
161
+ a.dev === b.dev);
162
+ }
163
+ function quoted(hash) {
164
+ return `"${hash.digest("base64url").slice(0, 22)}"`;
165
+ }
166
+ /** Tags from `createHash` - node:crypto's, which every runtime nifra serves files on provides. */
167
+ export function contentTags(createHash, options = {}) {
168
+ const limit = options.limit ?? 8192;
169
+ const now = options.now ?? Date.now;
170
+ const known = new Map();
171
+ const reading = new Map();
172
+ const remember = (path, version, tag) => {
173
+ if (!(now() - version.ctimeMs >= RACY_MS))
174
+ return;
175
+ const { dev, ino, size, mtimeMs, ctimeMs } = version;
176
+ known.delete(path);
177
+ known.set(path, { dev, ino, size, mtimeMs, ctimeMs, tag });
178
+ for (const earliest of known.keys()) {
179
+ if (known.size <= limit)
180
+ break;
181
+ known.delete(earliest);
182
+ }
183
+ };
184
+ const readWhole = async (path, version, read) => {
185
+ const hash = createHash("sha256");
186
+ const chunk = new Uint8Array(Math.min(version.size, READ_CHUNK));
187
+ let position = 0;
188
+ while (position < version.size) {
189
+ const count = await read(chunk.subarray(0, Math.min(chunk.length, version.size - position)), position);
190
+ if (count === 0)
191
+ break;
192
+ hash.update(chunk.subarray(0, count));
193
+ position += count;
194
+ }
195
+ const tag = quoted(hash);
196
+ if (position === version.size)
197
+ remember(path, version, tag);
198
+ return tag;
199
+ };
200
+ return {
201
+ cached(path, version) {
202
+ const entry = known.get(path);
203
+ return entry !== undefined && sameVersion(entry, version) ? entry.tag : undefined;
204
+ },
205
+ ofBytes(path, version, bytes) {
206
+ const hash = createHash("sha256");
207
+ hash.update(bytes);
208
+ const tag = quoted(hash);
209
+ if (bytes.byteLength === version.size)
210
+ remember(path, version, tag);
211
+ return tag;
212
+ },
213
+ async ofFile(path, version, read) {
214
+ if (version.size > CONTENT_TAG_MAX) {
215
+ const { dev, ino, size, mtimeMs, ctimeMs } = version;
216
+ if (!Number.isFinite(ino) || !(now() - ctimeMs >= RACY_MS))
217
+ return undefined;
218
+ const hash = createHash("sha256");
219
+ hash.update(new TextEncoder().encode(`${dev}:${ino}:${size}:${mtimeMs}:${ctimeMs}`));
220
+ return quoted(hash);
221
+ }
222
+ const pending = reading.get(path);
223
+ if (pending !== undefined && sameVersion(pending.version, version))
224
+ return pending.tag;
225
+ const entry = { version, tag: readWhole(path, version, read) };
226
+ reading.set(path, entry);
227
+ try {
228
+ return await entry.tag;
229
+ }
230
+ finally {
231
+ // A read of a newer version may have replaced this entry; leave that one in place.
232
+ if (reading.get(path) === entry)
233
+ reading.delete(path);
234
+ }
235
+ },
236
+ };
237
+ }
238
+ //# sourceMappingURL=static-file.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"static-file.js","sourceRoot":"","sources":["../src/static-file.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/core",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "Bun-native, contract-first HTTP framework - the router, server, and route descriptor.",
5
5
  "keywords": [
6
6
  "bun",
@@ -200,6 +200,11 @@
200
200
  "types": "./dist/range.d.ts",
201
201
  "default": "./dist/range.js"
202
202
  },
203
+ "./static-file": {
204
+ "bun": "./src/static-file.ts",
205
+ "types": "./dist/static-file.d.ts",
206
+ "default": "./dist/static-file.js"
207
+ },
203
208
  "./contract": {
204
209
  "bun": "./src/server/contract.ts",
205
210
  "types": "./dist/server/contract.d.ts",
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
17
17
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
18
18
  */
19
- export const VERSION = "5.3.0" as const
19
+ export const VERSION = "5.4.0" as const
20
20
 
21
21
  export type Version = typeof VERSION
22
22
 
package/src/range.ts CHANGED
@@ -32,6 +32,63 @@ function validSize(size: number): void {
32
32
  }
33
33
  }
34
34
 
35
+ /**
36
+ * `bytes=<digits>-<digits>`, the one form clients send, answered with a single scan; `undefined`
37
+ * for anything else, which the general path below answers. The answers must stay the general path's:
38
+ * test/range.test.ts compares both against a frozen copy of it.
39
+ */
40
+ function parseSingleRange(value: string, size: number): ByteRangeResult | undefined {
41
+ const length = value.length
42
+ // `| 0x20` folds only ASCII case, which is all the general path's /bytes/i matches.
43
+ if (
44
+ length < 7 ||
45
+ value.charCodeAt(5) !== 0x3d ||
46
+ (value.charCodeAt(0) | 0x20) !== 0x62 ||
47
+ (value.charCodeAt(1) | 0x20) !== 0x79 ||
48
+ (value.charCodeAt(2) | 0x20) !== 0x74 ||
49
+ (value.charCodeAt(3) | 0x20) !== 0x65 ||
50
+ (value.charCodeAt(4) | 0x20) !== 0x73
51
+ ) {
52
+ return undefined
53
+ }
54
+ let dash = -1
55
+ for (let index = 6; index < length; index++) {
56
+ const code = value.charCodeAt(index)
57
+ if (code === 0x2d) {
58
+ if (dash !== -1) return undefined
59
+ dash = index
60
+ } else if (code < 0x30 || code > 0x39) {
61
+ return undefined
62
+ }
63
+ }
64
+ if (dash === -1) return undefined
65
+ const leftDigits = dash - 6
66
+ const rightDigits = length - dash - 1
67
+ // INTEGER: no leading zero.
68
+ if (
69
+ (leftDigits > 1 && value.charCodeAt(6) === 0x30) ||
70
+ (rightDigits > 1 && value.charCodeAt(dash + 1) === 0x30)
71
+ ) {
72
+ return { kind: "none" }
73
+ }
74
+ let start: number
75
+ let end: number
76
+ if (leftDigits === 0) {
77
+ // A suffix with no length, or a zero one, is ignored.
78
+ if (rightDigits === 0 || (rightDigits === 1 && value.charCodeAt(dash + 1) === 0x30)) {
79
+ return { kind: "none" }
80
+ }
81
+ const suffix = Number(value.slice(dash + 1))
82
+ start = suffix >= size ? 0 : size - suffix
83
+ end = size - 1
84
+ } else {
85
+ start = Number(value.slice(6, dash))
86
+ end = rightDigits === 0 ? size - 1 : Math.min(Number(value.slice(dash + 1)), size - 1)
87
+ }
88
+ if (start > end || start >= size || size === 0) return { kind: "unsatisfiable" }
89
+ return { kind: "satisfiable", ranges: [{ start, end }] }
90
+ }
91
+
35
92
  /**
36
93
  * Parse an HTTP `Range: bytes=...` header against a known representation size.
37
94
  *
@@ -45,12 +102,14 @@ export function parseByteRange(value: string | null, size: number): ByteRangeRes
45
102
  // Bound the input before the regex runs, not after: an oversized header must never reach the
46
103
  // matcher in the first place.
47
104
  if (value.length > MAX_RANGE_HEADER_LENGTH) return { kind: "none" }
105
+ const single = parseSingleRange(value, size)
106
+ if (single !== undefined) return single
48
107
  const match = /^bytes\s*=\s*(.+)$/i.exec(value)
49
108
  if (match === null) return { kind: "none" }
50
109
  const source = match[1]!
51
110
  let separators = 0
52
- for (const character of source) {
53
- if (character === "," && ++separators >= MAX_RANGE_SPECIFIERS) return { kind: "none" }
111
+ for (let comma = source.indexOf(","); comma !== -1; comma = source.indexOf(",", comma + 1)) {
112
+ if (++separators >= MAX_RANGE_SPECIFIERS) return { kind: "none" }
54
113
  }
55
114
 
56
115
  const ranges: ByteRange[] = []
@@ -23,7 +23,7 @@ if (!copies.includes(here) && copies.push(here) > 1) {
23
23
 
24
24
  /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
25
25
  * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
26
- const COPY_VERSION = "5.3.0"
26
+ const COPY_VERSION = "5.4.0"
27
27
 
28
28
  /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
29
29
  * so a production bundle drops it with the version; a copy that built a server has recorded one. */
@@ -861,6 +861,9 @@ interface HookAuditState {
861
861
  checked: boolean
862
862
  }
863
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
864
867
 
865
868
  /** Whether `from` reaches `target` through mounts that keep the path and take a request under `path`.
866
869
  * Module-level, not a `Server` method, so a production bundle drops it with its dev-only caller. */
@@ -892,6 +895,7 @@ const beginHookAudit = (
892
895
  policy: "warn" | "error" | "off",
893
896
  hasCustomLogger: boolean,
894
897
  ): void => {
898
+ unsealedHookAudits++
895
899
  hookAudit.set(key, {
896
900
  policy,
897
901
  hasCustomLogger,
@@ -1007,6 +1011,7 @@ const sealHookAudit = (
1007
1011
  const state = hookAudit.get(key)
1008
1012
  if (state === undefined || state.checked) return
1009
1013
  state.checked = true
1014
+ unsealedHookAudits--
1010
1015
  if (state.policy === "off" || total + sockets === 0) return
1011
1016
  const own = total - state.merged
1012
1017
  const dead = state.sites.filter(
@@ -3193,8 +3198,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3193
3198
  platform?: Platform<EnvOf<Ctx>>,
3194
3199
  ): MaybePromise<Response> {
3195
3200
  // Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
3196
- // After that it is a single boolean read - the per-request cost the option promises to keep at zero.
3197
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
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")
3198
3203
  sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
3199
3204
  // Off path (default): straight through - one property check, no closure, no promise.
3200
3205
  if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
@@ -3378,7 +3383,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3378
3383
  suppliedRuntime?: NodeOutcomeRuntime,
3379
3384
  ): MaybePromise<NodeServeOutcome> {
3380
3385
  // Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
3381
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
3386
+ if (unsealedHookAudits !== 0 && process.env.NODE_ENV !== "production")
3382
3387
  sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
3383
3388
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
3384
3389
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can