@nifrajs/core 5.3.0 → 5.5.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/internal/route-lanes.d.ts.map +1 -1
  5. package/dist/internal/route-lanes.js +2 -1
  6. package/dist/internal/route-lanes.js.map +1 -1
  7. package/dist/internal/route-program.d.ts +5 -0
  8. package/dist/internal/route-program.d.ts.map +1 -1
  9. package/dist/internal/route-program.js +36 -15
  10. package/dist/internal/route-program.js.map +1 -1
  11. package/dist/range.d.ts.map +1 -1
  12. package/dist/range.js +62 -2
  13. package/dist/range.js.map +1 -1
  14. package/dist/router/router.d.ts +3 -2
  15. package/dist/router/router.d.ts.map +1 -1
  16. package/dist/router/router.js +22 -6
  17. package/dist/router/router.js.map +1 -1
  18. package/dist/security/baseline.d.ts.map +1 -1
  19. package/dist/security/baseline.js +4 -3
  20. package/dist/security/baseline.js.map +1 -1
  21. package/dist/server/body-lane.d.ts.map +1 -1
  22. package/dist/server/body-lane.js +4 -3
  23. package/dist/server/body-lane.js.map +1 -1
  24. package/dist/server/body.d.ts +2 -0
  25. package/dist/server/body.d.ts.map +1 -1
  26. package/dist/server/body.js +5 -0
  27. package/dist/server/body.js.map +1 -1
  28. package/dist/server/cookies.d.ts.map +1 -1
  29. package/dist/server/cookies.js +9 -2
  30. package/dist/server/cookies.js.map +1 -1
  31. package/dist/server/core-copies.js +1 -1
  32. package/dist/server/idempotency-lane.d.ts +11 -12
  33. package/dist/server/idempotency-lane.d.ts.map +1 -1
  34. package/dist/server/idempotency-lane.js +301 -151
  35. package/dist/server/idempotency-lane.js.map +1 -1
  36. package/dist/server/methods.d.ts +3 -2
  37. package/dist/server/methods.d.ts.map +1 -1
  38. package/dist/server/methods.js.map +1 -1
  39. package/dist/server/multipart.d.ts.map +1 -1
  40. package/dist/server/multipart.js +42 -13
  41. package/dist/server/multipart.js.map +1 -1
  42. package/dist/server/node-outcome.d.ts.map +1 -1
  43. package/dist/server/node-outcome.js +2 -2
  44. package/dist/server/node-outcome.js.map +1 -1
  45. package/dist/server/query.d.ts +1 -1
  46. package/dist/server/query.d.ts.map +1 -1
  47. package/dist/server/query.js +13 -5
  48. package/dist/server/query.js.map +1 -1
  49. package/dist/server/request-context.d.ts.map +1 -1
  50. package/dist/server/request-context.js +3 -3
  51. package/dist/server/request-context.js.map +1 -1
  52. package/dist/server/respond.d.ts +17 -0
  53. package/dist/server/respond.d.ts.map +1 -1
  54. package/dist/server/respond.js +81 -3
  55. package/dist/server/respond.js.map +1 -1
  56. package/dist/server/runtime-core.d.ts +15 -0
  57. package/dist/server/runtime-core.d.ts.map +1 -1
  58. package/dist/server/runtime-core.js +41 -1
  59. package/dist/server/runtime-core.js.map +1 -1
  60. package/dist/server/server.d.ts +14 -5
  61. package/dist/server/server.d.ts.map +1 -1
  62. package/dist/server/server.js +187 -62
  63. package/dist/server/server.js.map +1 -1
  64. package/dist/server/transport-lane.d.ts +2 -2
  65. package/dist/server/transport-lane.d.ts.map +1 -1
  66. package/dist/server/transport-lane.js +20 -20
  67. package/dist/server/transport-lane.js.map +1 -1
  68. package/dist/static-file.d.ts +127 -0
  69. package/dist/static-file.d.ts.map +1 -0
  70. package/dist/static-file.js +269 -0
  71. package/dist/static-file.js.map +1 -0
  72. package/package.json +6 -1
  73. package/src/index.ts +1 -1
  74. package/src/internal/route-lanes.ts +18 -16
  75. package/src/internal/route-program.ts +76 -12
  76. package/src/range.ts +61 -2
  77. package/src/router/router.ts +26 -5
  78. package/src/security/baseline.ts +4 -3
  79. package/src/server/body-lane.ts +4 -4
  80. package/src/server/body.ts +6 -0
  81. package/src/server/cookies.ts +7 -2
  82. package/src/server/core-copies.ts +1 -1
  83. package/src/server/idempotency-lane.ts +343 -209
  84. package/src/server/methods.ts +3 -2
  85. package/src/server/multipart.ts +62 -14
  86. package/src/server/node-outcome.ts +2 -1
  87. package/src/server/query.ts +12 -5
  88. package/src/server/request-context.ts +6 -3
  89. package/src/server/respond.ts +108 -6
  90. package/src/server/runtime-core.ts +54 -1
  91. package/src/server/server.ts +257 -102
  92. package/src/server/transport-lane.ts +28 -22
  93. package/src/static-file.ts +362 -0
@@ -1,5 +1,9 @@
1
1
  /** Opt-in versioned transport integration. Kept outside the server kernel's bare dependency graph. */
2
- import type { TransportCodec, TransportCodecRegistry } from "../transport-codec.ts"
2
+ import {
3
+ encodeTransportResponse,
4
+ type TransportCodec,
5
+ type TransportCodecRegistry,
6
+ } from "../transport-codec.ts"
3
7
  import { readBoundedBytes } from "./body.ts"
4
8
  import { jsonError } from "./http.ts"
5
9
  import { guardDecodedValue, type ProtoPoisoning } from "./proto-guard.ts"
@@ -14,7 +18,7 @@ interface TransportBodySource {
14
18
 
15
19
  export type TransportDecodeResult =
16
20
  | { readonly matched: false }
17
- | { readonly matched: true; readonly value: unknown; readonly byteLength: number }
21
+ | { readonly matched: true; readonly value: unknown; readonly bytes: Uint8Array }
18
22
  | { readonly matched: true; readonly response: Response }
19
23
 
20
24
  export interface TransportRuntime {
@@ -99,7 +103,7 @@ export function transportCodecs(
99
103
  return {
100
104
  matched: true,
101
105
  value: guardDecodedValue(codec.decode(text), protoPoisoning, acceptRegExp),
102
- byteLength: read.bytes.byteLength,
106
+ bytes: read.bytes,
103
107
  }
104
108
  } catch {
105
109
  return {
@@ -129,36 +133,38 @@ export function transportCodecs(
129
133
  if (!decoded.matched) return undefined
130
134
  if ("response" in decoded) return decoded.response
131
135
 
132
- // Re-frame as a plain JSON request whose already-decoded value rides the pre-decoded stash;
133
- // the body lane takes the stash verbatim (this lane owns the cap and poisoning policy), so
134
- // the placeholder body is never parsed and codec machinery stays out of the kernel.
135
- const headers: Record<string, string> = {}
136
- request.headers.forEach((value, name) => {
137
- headers[name] = value
138
- })
139
- headers["content-type"] = "application/json"
140
- headers["content-length"] = "2"
141
- delete headers["transfer-encoding"]
136
+ // The decoded value rides the pre-decoded stash, which the body lane takes verbatim (this lane
137
+ // owns the cap and poisoning policy), so codec machinery stays out of the kernel. The request
138
+ // keeps the bytes and media type it was sent with: a route without a body schema, or a
139
+ // signature check, reads the payload itself, not a stand-in.
140
+ const headers = new Headers(request.headers)
141
+ headers.set("content-length", String(decoded.bytes.byteLength))
142
+ headers.delete("transfer-encoding")
142
143
  const normalized = new Request(request.url, {
143
144
  method: request.method,
144
145
  headers,
145
- body: "{}",
146
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: every reader behind readBoundedBytes yields an unshared buffer, which a Request body accepts.
147
+ body: decoded.bytes as Uint8Array<ArrayBuffer>,
146
148
  signal: request.signal as never,
147
149
  })
148
- const stash: PreDecodedBody = { value: decoded.value, byteLength: decoded.byteLength }
150
+ const stash: PreDecodedBody = {
151
+ value: decoded.value,
152
+ byteLength: decoded.bytes.byteLength,
153
+ encoded: decoded.bytes,
154
+ contentType,
155
+ }
149
156
  Object.defineProperty(normalized, PRE_DECODED_BODY, { value: stash })
150
157
  return normalized
151
158
  })
152
159
  app.afterHandle((result, context) => {
153
160
  if (result === undefined || result instanceof Response || isResponseResult(result))
154
161
  return result
155
- const codec = runtime.responseCodec(context.req.headers.get("accept"))
156
- const headers = new Headers(context.set.headers)
157
- headers.set("content-type", codec.mediaType)
158
- return new Response(codec.encode(result), {
159
- status: context.set.status ?? 200,
160
- headers,
161
- })
162
+ // The representation depends on `Accept`, so the response says so to every shared cache.
163
+ return encodeTransportResponse(
164
+ result,
165
+ runtime.responseCodec(context.req.headers.get("accept")),
166
+ { status: context.set.status ?? 200, headers: context.set.headers },
167
+ )
162
168
  })
163
169
  return app
164
170
  }
@@ -0,0 +1,362 @@
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
+ /**
112
+ * {@link lastModifiedOf} for a body streamed after its stat, where a write can still reach the bytes
113
+ * sent. Dated only once its change is older than the racy window, so such a write falls in a later
114
+ * HTTP-date second and the next `If-Modified-Since` fetches it. A weak validator: never pass it to
115
+ * {@link ifRangeAllowsPartial}, which would resume a range across two versions.
116
+ */
117
+ export function streamedLastModifiedOf(
118
+ version: FileVersion,
119
+ now: number = Date.now(),
120
+ ): number | undefined {
121
+ return now - version.ctimeMs >= RACY_MS ? lastModifiedOf(version) : undefined
122
+ }
123
+
124
+ /** HTTP dates carry whole seconds; comparing raw milliseconds makes every file look stale. */
125
+ function seconds(time: number): number {
126
+ return Math.floor(time / 1000)
127
+ }
128
+
129
+ function opaqueTag(tag: string): string {
130
+ return tag.startsWith("W/") ? tag.slice(2) : tag
131
+ }
132
+
133
+ /**
134
+ * Whether the client's copy is current, so the answer is a bodyless 304. `If-None-Match` (the header,
135
+ * or `null`) decides whenever it is sent (RFC 9110 section 13.2.2), by weak comparison with `etag`, and
136
+ * `*` matches any file. Otherwise an `If-Modified-Since` at or after `lastModified` (epoch
137
+ * milliseconds), compared at the whole seconds an HTTP date carries; an unparseable date, or a file
138
+ * with no date, is never current.
139
+ */
140
+ export function isNotModified(
141
+ ifNoneMatch: string | null,
142
+ ifModifiedSince: string | null,
143
+ etag: string | undefined,
144
+ lastModified: number | undefined,
145
+ ): boolean {
146
+ if (ifNoneMatch !== null) {
147
+ const current = etag === undefined ? undefined : opaqueTag(etag)
148
+ for (const raw of ifNoneMatch.split(",")) {
149
+ const candidate = raw.trim()
150
+ if (candidate === "*" || (current !== undefined && opaqueTag(candidate) === current)) {
151
+ return true
152
+ }
153
+ }
154
+ return false
155
+ }
156
+ if (ifModifiedSince === null || lastModified === undefined) return false
157
+ const since = Date.parse(ifModifiedSince)
158
+ return Number.isFinite(since) && seconds(since) >= seconds(lastModified)
159
+ }
160
+
161
+ /**
162
+ * Whether `If-Range` (the header, or `null`) lets a range request be answered partially; otherwise the
163
+ * whole file is the conformant answer, not a fallback. An entity tag must equal the file's strong
164
+ * `etag` (RFC 9110 section 13.1.5). A date must equal `lastModified` to the second, and counts only
165
+ * for a file with no strong tag: a client holding the tag sends it, and a date cannot tell apart two
166
+ * versions dated in the same second - here or on another instance - so resuming by one could splice
167
+ * them.
168
+ */
169
+ export function ifRangeAllowsPartial(
170
+ ifRange: string | null,
171
+ etag: string | undefined,
172
+ lastModified: number | undefined,
173
+ ): boolean {
174
+ if (ifRange === null) return true
175
+ const item = ifRange.trim()
176
+ const strong = etag !== undefined && !etag.startsWith("W/")
177
+ if (item.startsWith('"') || item.startsWith("W/")) return strong && item === etag
178
+ if (strong || lastModified === undefined) return false
179
+ const date = Date.parse(item)
180
+ return Number.isFinite(date) && seconds(date) === seconds(lastModified)
181
+ }
182
+
183
+ /** What node:crypto's `createHash` returns; Bun, Node and Deno all provide it. */
184
+ export interface ContentHash {
185
+ update(bytes: Uint8Array): unknown
186
+ digest(encoding: "base64url"): string
187
+ }
188
+
189
+ /** Reads into `bytes` from byte `position` of a file, resolving to the count read (0 at its end). */
190
+ export type ReadAt = (bytes: Uint8Array, position: number) => Promise<number>
191
+
192
+ /**
193
+ * Entity tags from file bytes, consistent across instances and runtimes. Files beyond the content
194
+ * hash limit instead use a per-instance identity tag. Callers must serve the same immutable bytes
195
+ * they hash; static HTTP readers use bounded snapshots for that guarantee. A tag is remembered per
196
+ * real path for the version it was computed from.
197
+ */
198
+ export interface ContentTags {
199
+ /** The tag remembered for this version of the file at `path`, if any. */
200
+ cached(path: string, version: FileVersion): string | undefined
201
+ /** The tag of `bytes`, read whole from this version of the file at `path`. */
202
+ ofBytes(path: string, version: FileVersion, bytes: Uint8Array): string
203
+ /**
204
+ * The tag of `bytes`, the whole file at `path` read after a stat that gave `version`. A small
205
+ * snapshot is hashed; a larger one takes the tag remembered for `version` when `restat`, called
206
+ * after the read, still reports that version, since no write reached the file in between.
207
+ */
208
+ ofSnapshot(
209
+ path: string,
210
+ version: FileVersion,
211
+ bytes: Uint8Array,
212
+ restat: () => Promise<FileVersion>,
213
+ ): Promise<string>
214
+ /**
215
+ * The tag of this version of the file at `path`, read whole through `read`; requests for a version
216
+ * already being read wait for that read. A file over {@link CONTENT_TAG_MAX} is not read: its tag
217
+ * names its identity within this tagger instance instead, or is `undefined` without a stable identity.
218
+ * Reading a mutable inode and later streaming it does not preserve this tag's byte identity.
219
+ */
220
+ ofFile(path: string, version: FileVersion, read: ReadAt): Promise<string | undefined>
221
+ }
222
+
223
+ export interface ContentTagOptions {
224
+ /** Most tags remembered at once (default 8192); past it the earliest is forgotten. */
225
+ readonly limit?: number
226
+ /** The clock compared with a file's ctime (default `Date.now`). */
227
+ readonly now?: () => number
228
+ /**
229
+ * Largest snapshot {@link ContentTags.ofSnapshot} hashes on every request (default 4 KiB). A larger
230
+ * one takes the tag remembered for its version once a restat shows that version. Raise it where a
231
+ * stat costs more than hashing, as on Deno.
232
+ */
233
+ readonly rehashMax?: number
234
+ }
235
+
236
+ /**
237
+ * Largest file tagged by its content. Its first request waits for one read of the whole file, ~0.1 s
238
+ * per 256 MiB with hardware SHA-256 and several times that on a slow disk or CPU, so a larger file -
239
+ * a long video that is only ever fetched by ranges, say - is tagged by its device, inode, size and
240
+ * times plus a random instance salt. Identity tags require immutable file versions and intentionally
241
+ * differ between tagger instances so local stat identities cannot collide across hosts.
242
+ */
243
+ export const CONTENT_TAG_MAX = 256 * 1024 * 1024
244
+
245
+ /** Static HTTP readers tag only a whole, bounded snapshot; streamed inodes can change after hashing. */
246
+ export const CONTENT_SNAPSHOT_MAX = 256 * 1024
247
+
248
+ // Past this, a restat proves a remembered version unchanged faster than hashing it again: served over
249
+ // HTTP on Node and Bun, an 8 KiB file is 1.02-1.05x faster that way, a 4 KiB one no faster.
250
+ const SNAPSHOT_REHASH_MAX = 4 * 1024
251
+
252
+ const READ_CHUNK = 1024 * 1024
253
+ // A file changed this recently can change again within its ctime's granularity (a jiffy on ext4, a
254
+ // second on HFS+ and as Deno reports it) without its stat showing it, so it is not tagged by version.
255
+ const RACY_MS = 2000
256
+
257
+ function sameVersion(a: FileVersion, b: FileVersion): boolean {
258
+ return (
259
+ a.ctimeMs === b.ctimeMs &&
260
+ a.mtimeMs === b.mtimeMs &&
261
+ a.size === b.size &&
262
+ a.ino === b.ino &&
263
+ a.dev === b.dev
264
+ )
265
+ }
266
+
267
+ function quoted(hash: ContentHash): string {
268
+ return `"${hash.digest("base64url").slice(0, 22)}"`
269
+ }
270
+
271
+ /** Tags from `createHash` - node:crypto's, which every runtime nifra serves files on provides. */
272
+ export function contentTags(
273
+ createHash: (algorithm: "sha256") => ContentHash,
274
+ options: ContentTagOptions = {},
275
+ ): ContentTags {
276
+ const limit = options.limit ?? 8192
277
+ const now = options.now ?? Date.now
278
+ const rehashMax = options.rehashMax ?? SNAPSHOT_REHASH_MAX
279
+ let instance: Uint8Array | undefined
280
+ const known = new Map<string, FileVersion & { readonly tag: string }>()
281
+ const reading = new Map<
282
+ string,
283
+ { readonly version: FileVersion; readonly tag: Promise<string> }
284
+ >()
285
+
286
+ const remember = (path: string, version: FileVersion, tag: string): void => {
287
+ if (!(now() - version.ctimeMs >= RACY_MS)) return
288
+ const { dev, ino, size, mtimeMs, ctimeMs } = version
289
+ known.delete(path)
290
+ known.set(path, { dev, ino, size, mtimeMs, ctimeMs, tag })
291
+ for (const earliest of known.keys()) {
292
+ if (known.size <= limit) break
293
+ known.delete(earliest)
294
+ }
295
+ }
296
+
297
+ const readWhole = async (path: string, version: FileVersion, read: ReadAt): Promise<string> => {
298
+ const hash = createHash("sha256")
299
+ const chunk = new Uint8Array(Math.min(version.size, READ_CHUNK))
300
+ let position = 0
301
+ while (position < version.size) {
302
+ const count = await read(
303
+ chunk.subarray(0, Math.min(chunk.length, version.size - position)),
304
+ position,
305
+ )
306
+ if (count === 0) break
307
+ hash.update(chunk.subarray(0, count))
308
+ position += count
309
+ }
310
+ const tag = quoted(hash)
311
+ if (position === version.size) remember(path, version, tag)
312
+ return tag
313
+ }
314
+
315
+ const cached = (path: string, version: FileVersion): string | undefined => {
316
+ const entry = known.get(path)
317
+ return entry !== undefined && sameVersion(entry, version) ? entry.tag : undefined
318
+ }
319
+
320
+ const ofBytes = (path: string, version: FileVersion, bytes: Uint8Array): string => {
321
+ const hash = createHash("sha256")
322
+ hash.update(bytes)
323
+ const tag = quoted(hash)
324
+ if (bytes.byteLength === version.size) remember(path, version, tag)
325
+ return tag
326
+ }
327
+
328
+ return {
329
+ cached,
330
+ ofBytes,
331
+ async ofSnapshot(path, version, bytes, restat) {
332
+ if (bytes.byteLength > rehashMax) {
333
+ const tag = cached(path, version)
334
+ // A remembered version is settled, so any write since its stat would show in the restat.
335
+ if (tag !== undefined && cached(path, await restat()) === tag) return tag
336
+ }
337
+ return ofBytes(path, version, bytes)
338
+ },
339
+ async ofFile(path, version, read) {
340
+ if (version.size > CONTENT_TAG_MAX) {
341
+ const { dev, ino, size, mtimeMs, ctimeMs } = version
342
+ if (!Number.isFinite(ino) || !(now() - ctimeMs >= RACY_MS)) return undefined
343
+ const hash = createHash("sha256")
344
+ // Filesystem identities are local: another host may reuse every one of these stat fields.
345
+ instance ??= crypto.getRandomValues(new Uint8Array(16))
346
+ hash.update(instance)
347
+ hash.update(new TextEncoder().encode(`${dev}:${ino}:${size}:${mtimeMs}:${ctimeMs}`))
348
+ return quoted(hash)
349
+ }
350
+ const pending = reading.get(path)
351
+ if (pending !== undefined && sameVersion(pending.version, version)) return pending.tag
352
+ const entry = { version, tag: readWhole(path, version, read) }
353
+ reading.set(path, entry)
354
+ try {
355
+ return await entry.tag
356
+ } finally {
357
+ // A read of a newer version may have replaced this entry; leave that one in place.
358
+ if (reading.get(path) === entry) reading.delete(path)
359
+ }
360
+ },
361
+ }
362
+ }