@nifrajs/core 5.4.0 → 5.5.1

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 (96) hide show
  1. package/CHANGELOG.md +35 -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/router/router.d.ts +3 -2
  12. package/dist/router/router.d.ts.map +1 -1
  13. package/dist/router/router.js +22 -6
  14. package/dist/router/router.js.map +1 -1
  15. package/dist/security/baseline.d.ts.map +1 -1
  16. package/dist/security/baseline.js +4 -3
  17. package/dist/security/baseline.js.map +1 -1
  18. package/dist/server/body-lane.d.ts.map +1 -1
  19. package/dist/server/body-lane.js +4 -3
  20. package/dist/server/body-lane.js.map +1 -1
  21. package/dist/server/body.d.ts +2 -0
  22. package/dist/server/body.d.ts.map +1 -1
  23. package/dist/server/body.js +5 -0
  24. package/dist/server/body.js.map +1 -1
  25. package/dist/server/cookies.d.ts.map +1 -1
  26. package/dist/server/cookies.js +9 -2
  27. package/dist/server/cookies.js.map +1 -1
  28. package/dist/server/core-copies.js +1 -1
  29. package/dist/server/idempotency-lane.d.ts +11 -12
  30. package/dist/server/idempotency-lane.d.ts.map +1 -1
  31. package/dist/server/idempotency-lane.js +301 -151
  32. package/dist/server/idempotency-lane.js.map +1 -1
  33. package/dist/server/methods.d.ts +3 -2
  34. package/dist/server/methods.d.ts.map +1 -1
  35. package/dist/server/methods.js.map +1 -1
  36. package/dist/server/multipart.d.ts.map +1 -1
  37. package/dist/server/multipart.js +42 -13
  38. package/dist/server/multipart.js.map +1 -1
  39. package/dist/server/node-outcome.d.ts.map +1 -1
  40. package/dist/server/node-outcome.js +2 -2
  41. package/dist/server/node-outcome.js.map +1 -1
  42. package/dist/server/query.d.ts +1 -1
  43. package/dist/server/query.d.ts.map +1 -1
  44. package/dist/server/query.js +13 -5
  45. package/dist/server/query.js.map +1 -1
  46. package/dist/server/request-context.d.ts.map +1 -1
  47. package/dist/server/request-context.js +3 -3
  48. package/dist/server/request-context.js.map +1 -1
  49. package/dist/server/respond.d.ts +17 -0
  50. package/dist/server/respond.d.ts.map +1 -1
  51. package/dist/server/respond.js +81 -3
  52. package/dist/server/respond.js.map +1 -1
  53. package/dist/server/runtime-core.d.ts +15 -0
  54. package/dist/server/runtime-core.d.ts.map +1 -1
  55. package/dist/server/runtime-core.js +41 -1
  56. package/dist/server/runtime-core.js.map +1 -1
  57. package/dist/server/server-types.d.ts +5 -3
  58. package/dist/server/server-types.d.ts.map +1 -1
  59. package/dist/server/server.d.ts +14 -5
  60. package/dist/server/server.d.ts.map +1 -1
  61. package/dist/server/server.js +179 -59
  62. package/dist/server/server.js.map +1 -1
  63. package/dist/server/transport-lane.d.ts +2 -2
  64. package/dist/server/transport-lane.d.ts.map +1 -1
  65. package/dist/server/transport-lane.js +20 -20
  66. package/dist/server/transport-lane.js.map +1 -1
  67. package/dist/server/ws-bun.d.ts.map +1 -1
  68. package/dist/server/ws-bun.js +7 -1
  69. package/dist/server/ws-bun.js.map +1 -1
  70. package/dist/static-file.d.ts +29 -6
  71. package/dist/static-file.d.ts.map +1 -1
  72. package/dist/static-file.js +43 -12
  73. package/dist/static-file.js.map +1 -1
  74. package/package.json +1 -1
  75. package/src/index.ts +1 -1
  76. package/src/internal/route-lanes.ts +18 -16
  77. package/src/internal/route-program.ts +76 -12
  78. package/src/router/router.ts +26 -5
  79. package/src/security/baseline.ts +4 -3
  80. package/src/server/body-lane.ts +4 -4
  81. package/src/server/body.ts +6 -0
  82. package/src/server/cookies.ts +7 -2
  83. package/src/server/core-copies.ts +1 -1
  84. package/src/server/idempotency-lane.ts +343 -209
  85. package/src/server/methods.ts +3 -2
  86. package/src/server/multipart.ts +62 -14
  87. package/src/server/node-outcome.ts +2 -1
  88. package/src/server/query.ts +12 -5
  89. package/src/server/request-context.ts +6 -3
  90. package/src/server/respond.ts +108 -6
  91. package/src/server/runtime-core.ts +54 -1
  92. package/src/server/server-types.ts +5 -3
  93. package/src/server/server.ts +249 -99
  94. package/src/server/transport-lane.ts +28 -22
  95. package/src/server/ws-bun.ts +12 -1
  96. package/src/static-file.ts +72 -16
@@ -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
  }
@@ -149,6 +149,8 @@ function dispatchWsCallback(
149
149
  }
150
150
  }
151
151
 
152
+ const BUN_TOO_BIG_REASON = "Received too big message"
153
+
152
154
  /**
153
155
  * The shared Bun `websocket` dispatcher config for one app - each connection's `ws.data.handler` is
154
156
  * the matched route's handler, set by `server.upgrade`.
@@ -204,7 +206,16 @@ export function createBunWsHandlers(
204
206
  // Registry mode: drop topic subscriptions so it never holds a dead socket. Native mode: Bun
205
207
  // unsubscribes the socket from every topic on close, so there is nothing to sweep here.
206
208
  if (!nativePubsub) (ws.data.pubsub ?? topics).unsubscribeAll(nifra)
207
- dispatchWsCallback(() => ws.data.handler.close?.(nifra, code, reason), nifra, ws.data.handler)
209
+ // Bun's own maxPayloadLength drops the connection without a close frame, so the peer sees 1006;
210
+ // the handler gets the 1009 every other payload cap reports.
211
+ const oversize = code === 1006 && reason === BUN_TOO_BIG_REASON
212
+ const closeCode = oversize ? 1009 : code
213
+ const closeReason = oversize ? "message too large" : reason
214
+ dispatchWsCallback(
215
+ () => ws.data.handler.close?.(nifra, closeCode, closeReason),
216
+ nifra,
217
+ ws.data.handler,
218
+ )
208
219
  },
209
220
  }
210
221
  }
@@ -108,6 +108,19 @@ export function lastModifiedOf(version: FileVersion): number | undefined {
108
108
  return version.mtimeMs > 0 && lag < MODIFIED_LAG_MS ? version.mtimeMs : undefined
109
109
  }
110
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
+
111
124
  /** HTTP dates carry whole seconds; comparing raw milliseconds makes every file look stale. */
112
125
  function seconds(time: number): number {
113
126
  return Math.floor(time / 1000)
@@ -177,20 +190,32 @@ export interface ContentHash {
177
190
  export type ReadAt = (bytes: Uint8Array, position: number) => Promise<number>
178
191
 
179
192
  /**
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.
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.
184
197
  */
185
198
  export interface ContentTags {
186
199
  /** The tag remembered for this version of the file at `path`, if any. */
187
200
  cached(path: string, version: FileVersion): string | undefined
188
201
  /** The tag of `bytes`, read whole from this version of the file at `path`. */
189
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>
190
214
  /**
191
215
  * The tag of this version of the file at `path`, read whole through `read`; requests for a version
192
216
  * 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.
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.
194
219
  */
195
220
  ofFile(path: string, version: FileVersion, read: ReadAt): Promise<string | undefined>
196
221
  }
@@ -200,16 +225,30 @@ export interface ContentTagOptions {
200
225
  readonly limit?: number
201
226
  /** The clock compared with a file's ctime (default `Date.now`). */
202
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
203
234
  }
204
235
 
205
236
  /**
206
237
  * Largest file tagged by its content. Its first request waits for one read of the whole file, ~0.1 s
207
238
  * per 256 MiB with hardware SHA-256 and several times that on a slow disk or CPU, so a larger file -
208
239
  * 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.
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.
210
242
  */
211
243
  export const CONTENT_TAG_MAX = 256 * 1024 * 1024
212
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
+
213
252
  const READ_CHUNK = 1024 * 1024
214
253
  // A file changed this recently can change again within its ctime's granularity (a jiffy on ext4, a
215
254
  // second on HFS+ and as Deno reports it) without its stat showing it, so it is not tagged by version.
@@ -236,6 +275,8 @@ export function contentTags(
236
275
  ): ContentTags {
237
276
  const limit = options.limit ?? 8192
238
277
  const now = options.now ?? Date.now
278
+ const rehashMax = options.rehashMax ?? SNAPSHOT_REHASH_MAX
279
+ let instance: Uint8Array | undefined
239
280
  const known = new Map<string, FileVersion & { readonly tag: string }>()
240
281
  const reading = new Map<
241
282
  string,
@@ -271,23 +312,38 @@ export function contentTags(
271
312
  return tag
272
313
  }
273
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
+
274
328
  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
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)
285
338
  },
286
339
  async ofFile(path, version, read) {
287
340
  if (version.size > CONTENT_TAG_MAX) {
288
341
  const { dev, ino, size, mtimeMs, ctimeMs } = version
289
342
  if (!Number.isFinite(ino) || !(now() - ctimeMs >= RACY_MS)) return undefined
290
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)
291
347
  hash.update(new TextEncoder().encode(`${dev}:${ino}:${size}:${mtimeMs}:${ctimeMs}`))
292
348
  return quoted(hash)
293
349
  }