@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/edge-kit.d.ts +1 -1
  3. package/dist/edge-kit.d.ts.map +1 -1
  4. package/dist/edge-kit.js +1 -1
  5. package/dist/edge-kit.js.map +1 -1
  6. package/dist/idempotency.d.ts.map +1 -1
  7. package/dist/idempotency.js +2 -0
  8. package/dist/idempotency.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/mount.d.ts +12 -0
  12. package/dist/mount.d.ts.map +1 -1
  13. package/dist/mount.js +20 -0
  14. package/dist/mount.js.map +1 -1
  15. package/dist/range.d.ts.map +1 -1
  16. package/dist/range.js +62 -2
  17. package/dist/range.js.map +1 -1
  18. package/dist/server/core-copies.js +1 -1
  19. package/dist/server/registry.d.ts +8 -10
  20. package/dist/server/registry.d.ts.map +1 -1
  21. package/dist/server/request-context.d.ts.map +1 -1
  22. package/dist/server/request-context.js +4 -3
  23. package/dist/server/request-context.js.map +1 -1
  24. package/dist/server/request-target.d.ts +9 -7
  25. package/dist/server/request-target.d.ts.map +1 -1
  26. package/dist/server/request-target.js +21 -12
  27. package/dist/server/request-target.js.map +1 -1
  28. package/dist/server/respond.d.ts.map +1 -1
  29. package/dist/server/respond.js +10 -7
  30. package/dist/server/respond.js.map +1 -1
  31. package/dist/server/server.d.ts.map +1 -1
  32. package/dist/server/server.js +41 -13
  33. package/dist/server/server.js.map +1 -1
  34. package/dist/server/websocket.d.ts.map +1 -1
  35. package/dist/server/websocket.js +4 -0
  36. package/dist/server/websocket.js.map +1 -1
  37. package/dist/static-file.d.ts +104 -0
  38. package/dist/static-file.d.ts.map +1 -0
  39. package/dist/static-file.js +238 -0
  40. package/dist/static-file.js.map +1 -0
  41. package/dist/tool-contract.d.ts.map +1 -1
  42. package/dist/tool-contract.js +8 -4
  43. package/dist/tool-contract.js.map +1 -1
  44. package/dist/transport-codec.d.ts.map +1 -1
  45. package/dist/transport-codec.js +4 -1
  46. package/dist/transport-codec.js.map +1 -1
  47. package/package.json +6 -1
  48. package/src/edge-kit.ts +1 -1
  49. package/src/idempotency.ts +2 -0
  50. package/src/index.ts +1 -1
  51. package/src/mount.ts +25 -0
  52. package/src/range.ts +61 -2
  53. package/src/server/core-copies.ts +1 -1
  54. package/src/server/registry.ts +18 -19
  55. package/src/server/request-context.ts +6 -3
  56. package/src/server/request-target.ts +21 -12
  57. package/src/server/respond.ts +10 -7
  58. package/src/server/server.ts +41 -11
  59. package/src/server/websocket.ts +3 -0
  60. package/src/static-file.ts +306 -0
  61. package/src/tool-contract.ts +7 -4
  62. package/src/transport-codec.ts +4 -1
@@ -113,7 +113,7 @@ import {
113
113
  readBodyFramed,
114
114
  } from "./request-context.ts"
115
115
  import { recordRequestReplacement } from "./request-lineage.ts"
116
- import { hasDotSegment, resolveDotSegments } from "./request-target.ts"
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), or a backslash, which the parsed URL reads as `/`.
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's dot segments resolved. */
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 && hasDotSegment(req.url)) req = withResolvedTarget(req)
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 a single boolean read - the per-request cost the option promises to keep at zero.
3177
- 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")
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 (hookAuditRuntime && process.env.NODE_ENV !== "production")
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 && hasDotSegment(req.url)) req = withResolvedTarget(req)
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
 
@@ -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
+ }
@@ -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 run, the reservation is never released: a retry would repeat a side effect
473
- // that may have committed. A store that throws on completion counts as one that refused it.
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
- if (!executed) await abandon()
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" } })
@@ -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[] = []