@nifrajs/core 5.4.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.
- package/CHANGELOG.md +29 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +2 -1
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +5 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +36 -15
- package/dist/internal/route-program.js.map +1 -1
- package/dist/router/router.d.ts +3 -2
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +22 -6
- package/dist/router/router.js.map +1 -1
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +4 -3
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +4 -3
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts +2 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +5 -0
- package/dist/server/body.js.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +9 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/idempotency-lane.d.ts +11 -12
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +301 -151
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/methods.d.ts +3 -2
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js.map +1 -1
- package/dist/server/multipart.d.ts.map +1 -1
- package/dist/server/multipart.js +42 -13
- package/dist/server/multipart.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +2 -2
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/query.d.ts +1 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +13 -5
- package/dist/server/query.js.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +3 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +81 -3
- package/dist/server/respond.js.map +1 -1
- package/dist/server/runtime-core.d.ts +15 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +41 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server.d.ts +14 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +179 -59
- package/dist/server/server.js.map +1 -1
- package/dist/server/transport-lane.d.ts +2 -2
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +20 -20
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/static-file.d.ts +29 -6
- package/dist/static-file.d.ts.map +1 -1
- package/dist/static-file.js +43 -12
- package/dist/static-file.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/internal/route-lanes.ts +18 -16
- package/src/internal/route-program.ts +76 -12
- package/src/router/router.ts +26 -5
- package/src/security/baseline.ts +4 -3
- package/src/server/body-lane.ts +4 -4
- package/src/server/body.ts +6 -0
- package/src/server/cookies.ts +7 -2
- package/src/server/core-copies.ts +1 -1
- package/src/server/idempotency-lane.ts +343 -209
- package/src/server/methods.ts +3 -2
- package/src/server/multipart.ts +62 -14
- package/src/server/node-outcome.ts +2 -1
- package/src/server/query.ts +12 -5
- package/src/server/request-context.ts +6 -3
- package/src/server/respond.ts +108 -6
- package/src/server/runtime-core.ts +54 -1
- package/src/server/server.ts +249 -99
- package/src/server/transport-lane.ts +28 -22
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
133
|
-
// the
|
|
134
|
-
// the
|
|
135
|
-
|
|
136
|
-
request.headers
|
|
137
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
status: context.set.status ?? 200,
|
|
160
|
-
|
|
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
|
}
|
package/src/static-file.ts
CHANGED
|
@@ -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
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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
|
|
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
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
}
|