@nifrajs/web 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.
- package/CHANGELOG.md +86 -0
- package/dist/build-plan.d.ts +6 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +25 -0
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +2 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +2 -0
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +17 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +174 -54
- package/dist/build.js.map +1 -1
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +20 -6
- package/dist/deferred.js.map +1 -1
- package/dist/dev.d.ts +5 -0
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +7 -6
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic.d.ts +6 -0
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +41 -0
- package/dist/diagnostic.js.map +1 -1
- package/dist/host-policy.d.ts +72 -0
- package/dist/host-policy.d.ts.map +1 -0
- package/dist/host-policy.js +149 -0
- package/dist/host-policy.js.map +1 -0
- package/dist/host-reach.d.ts +49 -0
- package/dist/host-reach.d.ts.map +1 -0
- package/dist/host-reach.js +144 -0
- package/dist/host-reach.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/certified-components.d.ts +8 -0
- package/dist/internal/certified-components.d.ts.map +1 -0
- package/dist/internal/certified-components.js +8 -0
- package/dist/internal/certified-components.js.map +1 -0
- package/dist/internal/codegen.d.ts +8 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +15 -4
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/node-adapter.d.ts +19 -0
- package/dist/internal/node-adapter.d.ts.map +1 -0
- package/dist/internal/node-adapter.js +48 -0
- package/dist/internal/node-adapter.js.map +1 -0
- package/dist/internal/output-guard.d.ts +6 -0
- package/dist/internal/output-guard.d.ts.map +1 -1
- package/dist/internal/output-guard.js +118 -16
- package/dist/internal/output-guard.js.map +1 -1
- package/dist/internal/page-execution.d.ts +2 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +16 -11
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +3 -2
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/public-url.d.ts +0 -7
- package/dist/internal/public-url.d.ts.map +1 -1
- package/dist/internal/public-url.js +0 -13
- package/dist/internal/public-url.js.map +1 -1
- package/dist/internal/render-document.d.ts +3 -1
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +10 -4
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/web-app.d.ts +15 -1
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +8 -0
- package/dist/internal/web-app.js.map +1 -1
- package/dist/public-dir.d.ts +5 -7
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +54 -96
- package/dist/public-dir.js.map +1 -1
- package/dist/router.d.ts +2 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +9 -8
- package/dist/router.js.map +1 -1
- package/dist/server-components/vite.d.ts.map +1 -1
- package/dist/server-components/vite.js +8 -6
- package/dist/server-components/vite.js.map +1 -1
- package/dist/service-worker.d.ts +3 -1
- package/dist/service-worker.d.ts.map +1 -1
- package/dist/service-worker.js +29 -8
- package/dist/service-worker.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +3 -3
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +5 -1
- package/dist/zones.d.ts.map +1 -1
- package/dist/zones.js +17 -4
- package/dist/zones.js.map +1 -1
- package/package.json +4 -4
- package/src/build-plan.ts +26 -0
- package/src/build-vite.ts +4 -0
- package/src/build.ts +228 -68
- package/src/deferred.ts +20 -6
- package/src/dev.ts +7 -6
- package/src/diagnostic.ts +43 -0
- package/src/host-policy.ts +219 -0
- package/src/host-reach.ts +177 -0
- package/src/index.ts +10 -2
- package/src/internal/certified-components.ts +7 -0
- package/src/internal/codegen.ts +24 -5
- package/src/internal/node-adapter.ts +57 -0
- package/src/internal/output-guard.ts +166 -15
- package/src/internal/page-execution.ts +23 -9
- package/src/internal/parity.ts +3 -2
- package/src/internal/public-url.ts +0 -13
- package/src/internal/render-document.ts +11 -4
- package/src/internal/web-app.ts +27 -1
- package/src/public-dir.ts +70 -97
- package/src/router.ts +9 -8
- package/src/server-components/vite.ts +11 -6
- package/src/service-worker.ts +32 -9
- package/src/vite.ts +3 -3
- package/src/zones.ts +27 -5
package/src/public-dir.ts
CHANGED
|
@@ -12,12 +12,22 @@
|
|
|
12
12
|
* Distinct from `publicPath` in `build.ts`, which is the URL prefix for content-hashed bundle chunks.
|
|
13
13
|
* The names collide and the concepts do not: `publicPath` never covers user-authored files.
|
|
14
14
|
*/
|
|
15
|
+
import { createHash } from "node:crypto"
|
|
15
16
|
import { type FileHandle, realpath } from "node:fs/promises"
|
|
16
|
-
import {
|
|
17
|
+
import { normalize, resolve, sep } from "node:path"
|
|
17
18
|
import { Readable } from "node:stream"
|
|
18
19
|
import { parseByteRange } from "@nifrajs/core/range"
|
|
19
20
|
import { pathnameOf } from "@nifrajs/core/server"
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
CONTENT_SNAPSHOT_MAX,
|
|
23
|
+
contentTags,
|
|
24
|
+
contentTypeOf,
|
|
25
|
+
ifRangeAllowsPartial,
|
|
26
|
+
isHiddenPath,
|
|
27
|
+
isNotModified,
|
|
28
|
+
lastModifiedOf,
|
|
29
|
+
streamedLastModifiedOf,
|
|
30
|
+
} from "@nifrajs/core/static-file"
|
|
21
31
|
import { openResolved } from "./open-resolved.ts"
|
|
22
32
|
|
|
23
33
|
/** How long each subtree may be cached. Content-hashed bundle output can be immutable; a
|
|
@@ -50,65 +60,25 @@ export const IMMUTABLE = "public, max-age=31536000, immutable"
|
|
|
50
60
|
export const ONE_DAY = "public, max-age=86400"
|
|
51
61
|
|
|
52
62
|
/**
|
|
53
|
-
*
|
|
54
|
-
* Node and Deno adapters, where `Bun` does not exist and every hit would throw. Unknown extensions
|
|
55
|
-
* (and extensionless files such as ACME tokens) are served as `application/octet-stream`, which
|
|
56
|
-
* together with `nosniff` keeps the browser from guessing a more dangerous type.
|
|
57
|
-
*/
|
|
58
|
-
export const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
59
|
-
".html": "text/html; charset=utf-8",
|
|
60
|
-
".htm": "text/html; charset=utf-8",
|
|
61
|
-
".css": "text/css; charset=utf-8",
|
|
62
|
-
".js": "text/javascript; charset=utf-8",
|
|
63
|
-
".mjs": "text/javascript; charset=utf-8",
|
|
64
|
-
".cjs": "text/javascript; charset=utf-8",
|
|
65
|
-
".json": "application/json; charset=utf-8",
|
|
66
|
-
".map": "application/json; charset=utf-8",
|
|
67
|
-
".webmanifest": "application/manifest+json",
|
|
68
|
-
".txt": "text/plain; charset=utf-8",
|
|
69
|
-
".md": "text/markdown; charset=utf-8",
|
|
70
|
-
".csv": "text/csv; charset=utf-8",
|
|
71
|
-
".xml": "application/xml",
|
|
72
|
-
".rss": "application/rss+xml",
|
|
73
|
-
".atom": "application/atom+xml",
|
|
74
|
-
".svg": "image/svg+xml",
|
|
75
|
-
".png": "image/png",
|
|
76
|
-
".jpg": "image/jpeg",
|
|
77
|
-
".jpeg": "image/jpeg",
|
|
78
|
-
".gif": "image/gif",
|
|
79
|
-
".webp": "image/webp",
|
|
80
|
-
".avif": "image/avif",
|
|
81
|
-
".ico": "image/x-icon",
|
|
82
|
-
".bmp": "image/bmp",
|
|
83
|
-
".woff": "font/woff",
|
|
84
|
-
".woff2": "font/woff2",
|
|
85
|
-
".ttf": "font/ttf",
|
|
86
|
-
".otf": "font/otf",
|
|
87
|
-
".wasm": "application/wasm",
|
|
88
|
-
".pdf": "application/pdf",
|
|
89
|
-
".zip": "application/zip",
|
|
90
|
-
".mp4": "video/mp4",
|
|
91
|
-
".webm": "video/webm",
|
|
92
|
-
".ogg": "audio/ogg",
|
|
93
|
-
".mp3": "audio/mpeg",
|
|
94
|
-
".wav": "audio/wav",
|
|
95
|
-
".m4a": "audio/mp4",
|
|
96
|
-
".vtt": "text/vtt; charset=utf-8",
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Whether a confined path crosses a hidden segment ({@link isHiddenPublicPath}). Checked on the
|
|
63
|
+
* Whether a confined path crosses a hidden segment ({@link isHiddenPath}). Checked on the
|
|
101
64
|
* resolved path, so `%2e` encodings are covered, and again on the real target, so a visible name
|
|
102
65
|
* linking to `.env` is refused too.
|
|
103
66
|
*/
|
|
104
|
-
function
|
|
105
|
-
return abs !== root &&
|
|
67
|
+
function hiddenUnder(root: string, abs: string): boolean {
|
|
68
|
+
return abs !== root && isHiddenPath(abs.slice(root.length + 1))
|
|
106
69
|
}
|
|
107
70
|
|
|
108
71
|
// Up to this many bytes are read whole through the pinned descriptor, which then closes before the
|
|
109
72
|
// response leaves: on Bun a 200 KB asset is served in 0.17 ms against 0.28 ms through a node:fs
|
|
110
|
-
// stream. A larger body streams, so a video holds
|
|
111
|
-
const BUFFERED_BODY_MAX =
|
|
73
|
+
// stream. A larger body streams, so a video holds a stream's buffer per request rather than the file.
|
|
74
|
+
const BUFFERED_BODY_MAX = CONTENT_SNAPSHOT_MAX
|
|
75
|
+
// Bun.serve drops the declared length of any stream body and sends it chunked, and a Bun.file body
|
|
76
|
+
// would need this descriptor open for a send nothing reports the end of. So on Bun a body up to 1 MiB
|
|
77
|
+
// is read whole as well: a slow client then holds ~1 MB, against ~0.6 MB through Bun's stream sink.
|
|
78
|
+
const BUN_BUFFERED_BODY_MAX = 1024 * 1024
|
|
79
|
+
|
|
80
|
+
// One per process: the tag of a file served by any handler is remembered by its real path.
|
|
81
|
+
const TAGS = contentTags(createHash)
|
|
112
82
|
|
|
113
83
|
/** `length` bytes of `handle` from `start`; fewer when the file shrank since its stat. */
|
|
114
84
|
async function readPinned(
|
|
@@ -126,35 +96,6 @@ async function readPinned(
|
|
|
126
96
|
return offset === length ? bytes : bytes.subarray(0, offset)
|
|
127
97
|
}
|
|
128
98
|
|
|
129
|
-
/** HTTP dates carry second precision; comparing raw milliseconds makes every file look stale. */
|
|
130
|
-
function seconds(time: number): number {
|
|
131
|
-
return Math.floor(time / 1000)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* `If-Range` decides whether a range request is still safe to answer partially. The only validator
|
|
136
|
-
* this handler publishes is `last-modified`, so an entity-tag form can never match and the whole
|
|
137
|
-
* representation is sent instead - which is the conformant outcome, not a fallback. Dates use strong
|
|
138
|
-
* comparison (RFC 9110 13.1.5): a file modified since the client's copy invalidates its byte offsets.
|
|
139
|
-
*/
|
|
140
|
-
function ifRangeMatches(value: string | null, lastModified: number | undefined): boolean {
|
|
141
|
-
if (value === null) return true
|
|
142
|
-
if (lastModified === undefined) return false
|
|
143
|
-
const item = value.trim()
|
|
144
|
-
if (item.startsWith('"') || item.startsWith("W/")) return false
|
|
145
|
-
const parsed = Date.parse(item)
|
|
146
|
-
return Number.isFinite(parsed) && seconds(parsed) === seconds(lastModified)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** `If-Modified-Since` freshness. No ETag is published, so there is no `If-None-Match` precedence. */
|
|
150
|
-
function isNotModified(request: Request, lastModified: number | undefined): boolean {
|
|
151
|
-
if (lastModified === undefined) return false
|
|
152
|
-
const since = request.headers.get("if-modified-since")
|
|
153
|
-
if (since === null) return false
|
|
154
|
-
const parsed = Date.parse(since)
|
|
155
|
-
return Number.isFinite(parsed) && seconds(parsed) >= seconds(lastModified)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
99
|
/**
|
|
159
100
|
* Resolve a URL pathname to an absolute path **confined** to `root`, or `undefined` if it escapes.
|
|
160
101
|
*
|
|
@@ -185,6 +126,11 @@ export function resolvePublicPath(root: string, pathname: string): string | unde
|
|
|
185
126
|
* never shadow a route. That ordering is also why a `routes/robots.txt.tsx` beats a
|
|
186
127
|
* `public/robots.txt` only if the caller checks routes first; the documented precedence is that the
|
|
187
128
|
* static probe runs first, so `public/` wins, and an app wanting the route should not ship both.
|
|
129
|
+
*
|
|
130
|
+
* Files up to 256 KiB carry a strong `ETag` from the exact snapshot served, matching the Node and
|
|
131
|
+
* Deno readers. Those snapshots carry `Last-Modified` only while mtime reflects their own last change.
|
|
132
|
+
* Larger files stream without an `ETag`: an open inode can change after a separate hash pass. They
|
|
133
|
+
* are dated once settled, but an `If-Range` request gets the whole file rather than spliced versions.
|
|
188
134
|
*/
|
|
189
135
|
export function servePublicDir(
|
|
190
136
|
options: ServePublicDirOptions,
|
|
@@ -195,6 +141,7 @@ export function servePublicDir(
|
|
|
195
141
|
const hashedFiles = options.hashedFiles
|
|
196
142
|
const hashed = options.cache?.hashed ?? IMMUTABLE
|
|
197
143
|
const assets = options.cache?.assets ?? ONE_DAY
|
|
144
|
+
const bufferedMax = typeof Bun === "undefined" ? BUFFERED_BODY_MAX : BUN_BUFFERED_BODY_MAX
|
|
198
145
|
|
|
199
146
|
return async (request: Request): Promise<Response | undefined> => {
|
|
200
147
|
if (request.method !== "GET" && request.method !== "HEAD") return undefined
|
|
@@ -205,7 +152,7 @@ export function servePublicDir(
|
|
|
205
152
|
// A production manifest can reject page routes without touching the filesystem.
|
|
206
153
|
if (options.files !== undefined && !options.files.has(pathname)) return undefined
|
|
207
154
|
const abs = resolvePublicPath(root, pathname)
|
|
208
|
-
if (abs === undefined ||
|
|
155
|
+
if (abs === undefined || hiddenUnder(root, abs)) return undefined
|
|
209
156
|
const [resolvedRoot, resolvedFile] = await Promise.all([
|
|
210
157
|
rootReal,
|
|
211
158
|
realpath(abs).catch(() => undefined),
|
|
@@ -214,7 +161,7 @@ export function servePublicDir(
|
|
|
214
161
|
resolvedRoot === undefined ||
|
|
215
162
|
resolvedFile === undefined ||
|
|
216
163
|
(resolvedFile !== resolvedRoot && !resolvedFile.startsWith(resolvedRoot + sep)) ||
|
|
217
|
-
|
|
164
|
+
hiddenUnder(resolvedRoot, resolvedFile)
|
|
218
165
|
) {
|
|
219
166
|
return undefined
|
|
220
167
|
}
|
|
@@ -224,13 +171,15 @@ export function servePublicDir(
|
|
|
224
171
|
// symlink; the descriptor then pins the bytes for the lifetime of the response.
|
|
225
172
|
let handle: Awaited<ReturnType<typeof openResolved>> | undefined
|
|
226
173
|
try {
|
|
174
|
+
let file: FileHandle
|
|
227
175
|
try {
|
|
228
|
-
|
|
176
|
+
file = await openResolved(resolvedFile)
|
|
229
177
|
} catch {
|
|
230
178
|
// A name refused or gone since realpath falls through, as a failed realpath does.
|
|
231
179
|
return undefined
|
|
232
180
|
}
|
|
233
|
-
|
|
181
|
+
handle = file
|
|
182
|
+
const stat = await file.stat()
|
|
234
183
|
if (!stat.isFile()) return undefined
|
|
235
184
|
const size = stat.size
|
|
236
185
|
const headers = new Headers({
|
|
@@ -248,25 +197,46 @@ export function servePublicDir(
|
|
|
248
197
|
// rendered as HTML, say). The declared type below is authoritative.
|
|
249
198
|
"x-content-type-options": "nosniff",
|
|
250
199
|
// Set explicitly rather than inferred from the body, so HEAD (a null body) carries it too.
|
|
251
|
-
"content-type":
|
|
252
|
-
CONTENT_TYPES[extname(resolvedFile).toLowerCase()] ?? "application/octet-stream",
|
|
200
|
+
"content-type": contentTypeOf(resolvedFile),
|
|
253
201
|
})
|
|
254
|
-
const
|
|
255
|
-
|
|
202
|
+
const snapshot = size <= CONTENT_SNAPSHOT_MAX
|
|
203
|
+
const lastModified = snapshot ? lastModifiedOf(stat) : streamedLastModifiedOf(stat)
|
|
256
204
|
if (lastModified !== undefined) {
|
|
257
205
|
headers.set("last-modified", new Date(lastModified).toUTCString())
|
|
258
206
|
}
|
|
259
207
|
const head = request.method === "HEAD"
|
|
208
|
+
const ifNoneMatch = request.headers.get("if-none-match")
|
|
209
|
+
const ifModifiedSince = request.headers.get("if-modified-since")
|
|
210
|
+
// The whole file, when tagging it had to read it: the response is then served from these bytes.
|
|
211
|
+
let whole: Uint8Array<ArrayBuffer> | undefined
|
|
212
|
+
// A HEAD or a 304 sends none of the file, so the tag remembered for this settled version
|
|
213
|
+
// answers it from the stat alone: a write since that tag would show in the stat.
|
|
214
|
+
let etag = snapshot ? TAGS.cached(resolvedFile, stat) : undefined
|
|
215
|
+
if (
|
|
216
|
+
snapshot &&
|
|
217
|
+
(etag === undefined ||
|
|
218
|
+
!(head || isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)))
|
|
219
|
+
) {
|
|
220
|
+
whole = await readPinned(file, 0, size)
|
|
221
|
+
etag = await TAGS.ofSnapshot(resolvedFile, stat, whole, () => file.stat())
|
|
222
|
+
}
|
|
223
|
+
if (etag !== undefined) headers.set("etag", etag)
|
|
260
224
|
|
|
261
|
-
if (isNotModified(
|
|
225
|
+
if (isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)) {
|
|
262
226
|
headers.delete("content-type")
|
|
263
227
|
return new Response(null, { status: 304, headers })
|
|
264
228
|
}
|
|
265
229
|
|
|
266
230
|
// Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
|
|
267
231
|
const rangeHeader = head ? null : request.headers.get("range")
|
|
232
|
+
// A streamed body's date is weak: a write during the stream must never splice two versions.
|
|
268
233
|
const range =
|
|
269
|
-
rangeHeader !== null &&
|
|
234
|
+
rangeHeader !== null &&
|
|
235
|
+
ifRangeAllowsPartial(
|
|
236
|
+
request.headers.get("if-range"),
|
|
237
|
+
etag,
|
|
238
|
+
snapshot ? lastModified : undefined,
|
|
239
|
+
)
|
|
270
240
|
? parseByteRange(rangeHeader, size)
|
|
271
241
|
: ({ kind: "none" } as const)
|
|
272
242
|
|
|
@@ -281,12 +251,15 @@ export function servePublicDir(
|
|
|
281
251
|
end: number,
|
|
282
252
|
): Promise<Uint8Array<ArrayBuffer> | ReadableStream<Uint8Array>> => {
|
|
283
253
|
const length = end - start + 1
|
|
284
|
-
if (length <=
|
|
285
|
-
const bytes =
|
|
254
|
+
if (length <= bufferedMax) {
|
|
255
|
+
const bytes =
|
|
256
|
+
whole === undefined
|
|
257
|
+
? await readPinned(file, start, length)
|
|
258
|
+
: whole.subarray(start, start + length)
|
|
286
259
|
headers.set("content-length", String(bytes.byteLength))
|
|
287
260
|
return bytes
|
|
288
261
|
}
|
|
289
|
-
const stream =
|
|
262
|
+
const stream = file.createReadStream({ start, end })
|
|
290
263
|
// FileHandle.createReadStream owns closing the descriptor after EOF/abort.
|
|
291
264
|
handle = undefined
|
|
292
265
|
return Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>
|
|
@@ -346,7 +319,7 @@ export function prerenderedPageReader(
|
|
|
346
319
|
const rel = files.get(pathname)
|
|
347
320
|
if (rel === undefined) return undefined
|
|
348
321
|
const abs = resolve(root, rel)
|
|
349
|
-
if (!abs.startsWith(root + sep) ||
|
|
322
|
+
if (!abs.startsWith(root + sep) || hiddenUnder(root, abs)) return undefined
|
|
350
323
|
const [resolvedRoot, resolvedFile] = await Promise.all([
|
|
351
324
|
rootReal,
|
|
352
325
|
realpath(abs).catch(() => undefined),
|
package/src/router.ts
CHANGED
|
@@ -24,7 +24,8 @@ export const DATA_HEADER = "x-nifra-data"
|
|
|
24
24
|
/**
|
|
25
25
|
* Global the server injects (`createWebApp({ prerenderedPaths })`) listing the SSG-prerendered paths.
|
|
26
26
|
* The client's default data fetch reads it: a soft-nav INTO a prerendered route fetches its static
|
|
27
|
-
* `<path>/_data.json` (a CDN file - no worker round-trip) instead of the dynamic header-GET
|
|
27
|
+
* `<path>/_data.json` (a CDN file - no worker round-trip) instead of the dynamic header-GET, unless
|
|
28
|
+
* the target carries a query string.
|
|
28
29
|
*/
|
|
29
30
|
export const PRERENDERED_GLOBAL = "__NIFRA_PRERENDERED__"
|
|
30
31
|
|
|
@@ -422,13 +423,13 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
|
|
|
422
423
|
// worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
|
|
423
424
|
// or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
|
|
424
425
|
const prerendered = await prerenderedOf()
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const pathname =
|
|
426
|
+
// `indexOf` rather than a regex: no allocation or backtracking on a caller-controlled path.
|
|
427
|
+
const query = path.indexOf("?")
|
|
428
|
+
const hash = path.indexOf("#")
|
|
429
|
+
// The file was rendered without a query, so a query-bearing path renders on demand, exactly as
|
|
430
|
+
// `servePrerenderedPages` answers the same URL on a hard request.
|
|
431
|
+
if (Array.isArray(prerendered) && (query === -1 || (hash !== -1 && hash < query))) {
|
|
432
|
+
const pathname = hash === -1 ? path : path.slice(0, hash)
|
|
432
433
|
if (prerenderedSetOf(prerendered).has(pathname)) {
|
|
433
434
|
const staticRes = await fetch(appUrl(dataUrlFor(pathname)), { signal: signal ?? null })
|
|
434
435
|
if (staticRes.ok) return readResponseData(staticRes, signal)
|
|
@@ -3,6 +3,10 @@ import { createRequire } from "node:module"
|
|
|
3
3
|
import { resolve } from "node:path"
|
|
4
4
|
import type * as TS from "typescript"
|
|
5
5
|
import { type Plugin, version as viteVersion } from "vite"
|
|
6
|
+
import {
|
|
7
|
+
CERTIFIED_COMPONENT_REACT,
|
|
8
|
+
CERTIFIED_COMPONENT_VITE,
|
|
9
|
+
} from "../internal/certified-components.ts"
|
|
6
10
|
import {
|
|
7
11
|
compileComponentModule,
|
|
8
12
|
componentReference,
|
|
@@ -102,25 +106,26 @@ export function serverComponentsPlugin(
|
|
|
102
106
|
name: "nifra:server-components",
|
|
103
107
|
enforce: "pre",
|
|
104
108
|
configResolved(config) {
|
|
105
|
-
if (viteVersion !==
|
|
109
|
+
if (viteVersion !== CERTIFIED_COMPONENT_VITE)
|
|
106
110
|
throw new Error(
|
|
107
|
-
|
|
111
|
+
`[nifra/components] Use the certified Vite ${CERTIFIED_COMPONENT_VITE} component pipeline, not ${viteVersion}; Bun remains a supported server runtime`,
|
|
108
112
|
)
|
|
109
113
|
if (options.adapter === "react") {
|
|
110
114
|
const require = createRequire(resolve(config.root, "package.json"))
|
|
111
|
-
const
|
|
115
|
+
const packages = ["react", "react-dom", "react-server-dom-webpack"]
|
|
116
|
+
const versions = packages.map((name) => {
|
|
112
117
|
try {
|
|
113
118
|
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: The compiler inspects trusted AST/package metadata and its own populated stub map; application data never supplies these values.
|
|
114
119
|
return (require(`${name}/package.json`) as { version: string }).version
|
|
115
120
|
} catch {
|
|
116
121
|
throw new Error(
|
|
117
|
-
`[nifra/components] Install ${name}
|
|
122
|
+
`[nifra/components] Install ${name}@${CERTIFIED_COMPONENT_REACT} for the React component pipeline`,
|
|
118
123
|
)
|
|
119
124
|
}
|
|
120
125
|
})
|
|
121
|
-
if (versions.some((version) => version !==
|
|
126
|
+
if (versions.some((version) => version !== CERTIFIED_COMPONENT_REACT))
|
|
122
127
|
throw new Error(
|
|
123
|
-
|
|
128
|
+
`[nifra/components] React, React DOM, and Flight must all use the matching ${CERTIFIED_COMPONENT_REACT} version, not ${packages.map((name, index) => `${name}@${versions[index]}`).join(", ")}`,
|
|
124
129
|
)
|
|
125
130
|
}
|
|
126
131
|
},
|
package/src/service-worker.ts
CHANGED
|
@@ -37,7 +37,9 @@ export interface ServiceWorkerOptions {
|
|
|
37
37
|
/**
|
|
38
38
|
* URL of a page to serve when a navigation fails and the network is unreachable. It must be a
|
|
39
39
|
* static, user-independent document (a prerendered `/offline`), because every visitor gets the same
|
|
40
|
-
* bytes.
|
|
40
|
+
* bytes. Install fails rather than store it, or any precached URL, when the response is an error, a
|
|
41
|
+
* redirect, or marked `private` or `no-store`. Omit it and failed navigations simply fail, which is
|
|
42
|
+
* the honest default.
|
|
41
43
|
*/
|
|
42
44
|
readonly offlineUrl?: string
|
|
43
45
|
/**
|
|
@@ -115,10 +117,35 @@ const OWN_URLS = new Set(PRECACHE)
|
|
|
115
117
|
const OWN = { cacheName: CACHE }
|
|
116
118
|
const OFFLINE = ${offline === undefined ? "null" : JSON.stringify(offline)}
|
|
117
119
|
|
|
120
|
+
// Whether every visitor may be served these bytes from this cache. CacheStorage itself ignores
|
|
121
|
+
// Cache-Control, and a redirect can land one visitor's page under a shared URL.
|
|
122
|
+
const shareable = (response) =>
|
|
123
|
+
response.ok &&
|
|
124
|
+
response.type === "basic" &&
|
|
125
|
+
!response.redirected &&
|
|
126
|
+
!(response.headers.get("cache-control") ?? "")
|
|
127
|
+
.split(",")
|
|
128
|
+
.some((directive) => /^\\s*(?:no-store|private)\\s*(?:=|$)/i.test(directive))
|
|
129
|
+
|
|
118
130
|
self.addEventListener("install", (event) => {
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
131
|
+
// Every response is checked before any is stored, so a refused one leaves this build's cache
|
|
132
|
+
// empty and the install failed, as addAll would on an error status. Take over as soon as the new
|
|
133
|
+
// assets are stored; the activate handler drops the old cache, so waiting would only serve stale
|
|
134
|
+
// bytes for longer.
|
|
135
|
+
event.waitUntil(
|
|
136
|
+
Promise.all(
|
|
137
|
+
PRECACHE.map((url) =>
|
|
138
|
+
fetch(url).then((response) => {
|
|
139
|
+
if (shareable(response)) return response
|
|
140
|
+
throw new TypeError("[nifra/web] not precached, the response is not shareable: " + url)
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
.then((responses) =>
|
|
145
|
+
caches.open(CACHE).then((c) => Promise.all(responses.map((r, i) => c.put(PRECACHE[i], r)))),
|
|
146
|
+
)
|
|
147
|
+
.then(() => self.skipWaiting()),
|
|
148
|
+
)
|
|
122
149
|
})
|
|
123
150
|
|
|
124
151
|
self.addEventListener("activate", (event) => {
|
|
@@ -156,11 +183,7 @@ self.addEventListener("fetch", (event) => {
|
|
|
156
183
|
caches.match(request, OWN).then((hit) => {
|
|
157
184
|
if (hit !== undefined) return hit
|
|
158
185
|
return fetch(request).then((response) => {
|
|
159
|
-
|
|
160
|
-
response.ok &&
|
|
161
|
-
response.type === "basic" &&
|
|
162
|
-
!(response.headers.get("cache-control") ?? "").includes("no-store")
|
|
163
|
-
if (cacheable) {
|
|
186
|
+
if (shareable(response)) {
|
|
164
187
|
const copy = response.clone()
|
|
165
188
|
void caches.open(CACHE).then((c) => c.put(request, copy))
|
|
166
189
|
}
|
package/src/vite.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "node:http"
|
|
20
20
|
import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"
|
|
21
21
|
import { singleCopyPackageNames } from "@nifrajs/core/single-copy"
|
|
22
|
+
import { isHiddenPath } from "@nifrajs/core/static-file"
|
|
22
23
|
import { CLIENT_BATCH_MAX_BYTES } from "./dev-client.ts"
|
|
23
24
|
import { DEV_REQUEST_ID_HEADER, DEV_SERVER_FILES } from "./dev-feed.ts"
|
|
24
25
|
import { listenOrExplain } from "./dev-port.ts"
|
|
@@ -33,7 +34,6 @@ import {
|
|
|
33
34
|
formatIdentityParityFindings,
|
|
34
35
|
identityParityHeadline,
|
|
35
36
|
} from "./internal/parity.ts"
|
|
36
|
-
import { isHiddenPublicPath } from "./internal/public-url.ts"
|
|
37
37
|
import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
|
|
38
38
|
import { importVite } from "./internal/vite-import.ts"
|
|
39
39
|
import { normalizeRolldownPlugins } from "./internal/vite-plugin-compat.ts"
|
|
@@ -473,12 +473,12 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
|
|
|
473
473
|
}
|
|
474
474
|
const file = resolvePath(publicDir, rel)
|
|
475
475
|
if (!file.startsWith(publicDir + sep) || !existsSync(file)) return false
|
|
476
|
-
if (
|
|
476
|
+
if (isHiddenPath(rel)) return true
|
|
477
477
|
// A visible name linking to a hidden file is refused too, as `servePublicDir` refuses it.
|
|
478
478
|
try {
|
|
479
479
|
const real = realpathSync(file)
|
|
480
480
|
const realRoot = realpathSync(publicDir)
|
|
481
|
-
return real.startsWith(realRoot + sep) &&
|
|
481
|
+
return real.startsWith(realRoot + sep) && isHiddenPath(real.slice(realRoot.length + 1))
|
|
482
482
|
} catch {
|
|
483
483
|
return false
|
|
484
484
|
}
|
package/src/zones.ts
CHANGED
|
@@ -170,7 +170,11 @@ export interface ZoneConfig {
|
|
|
170
170
|
readonly backend?: readonly string[]
|
|
171
171
|
/** App-relative folders or files holding frontend code, beside `frontend/`. */
|
|
172
172
|
readonly frontend?: readonly string[]
|
|
173
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* App-relative folders or files holding code both sides import, beside `shared/`. Unlike the other
|
|
175
|
+
* sides, an entry may sit inside `frontend/` or `backend/`: a module there that both sides import
|
|
176
|
+
* stays where it is, held to the shared rules (it may import only shared code and packages).
|
|
177
|
+
*/
|
|
174
178
|
readonly shared?: readonly string[]
|
|
175
179
|
}
|
|
176
180
|
|
|
@@ -258,8 +262,15 @@ export function parseZoneConfig(value: unknown, source: string): ZoneConfig {
|
|
|
258
262
|
if (folder.split("/").includes("..")) fail(`"${side}" entry "${entry}" may not contain ".."`)
|
|
259
263
|
if (/[*?[\]{}!]/.test(folder))
|
|
260
264
|
fail(`"${side}" entry "${entry}" must be a plain path (a trailing "/**" is allowed)`)
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
const sharedInSide =
|
|
266
|
+
side === "shared" && (top === "frontend" || top === "backend") && folder !== top
|
|
267
|
+
if ((RESERVED_FOLDERS.has(top) && !sharedInSide) || top.startsWith(".nifra"))
|
|
268
|
+
fail(
|
|
269
|
+
`"${side}" entry "${entry}" is inside ${top}/, which already has its own rules` +
|
|
270
|
+
(top === "frontend" || top === "backend"
|
|
271
|
+
? `; only a "shared" entry may name a path inside ${top}/`
|
|
272
|
+
: ""),
|
|
273
|
+
)
|
|
263
274
|
const other = owner.get(folder)
|
|
264
275
|
if (other !== undefined && other !== side)
|
|
265
276
|
fail(`"${folder}" is listed as both ${other} and ${side}; a path has one side`)
|
|
@@ -587,11 +598,22 @@ export function createZoneClassifier(options: ZoneClassifierOptions): ZoneClassi
|
|
|
587
598
|
if (suffix === "shared") return { zone: "shared" }
|
|
588
599
|
return { zone: "route-frontend" }
|
|
589
600
|
}
|
|
590
|
-
|
|
591
|
-
const
|
|
601
|
+
// Inside a reserved folder only a "shared" entry can match: the parser refuses the others there.
|
|
602
|
+
const configured = configuredPath(rel)
|
|
603
|
+
const folder = configured?.[1] ?? ZONE_FOLDERS[top]
|
|
592
604
|
if (folder !== undefined) {
|
|
593
605
|
if (suffix === undefined || suffix === folder) return { zone: folder }
|
|
594
606
|
if (suffix === "fn" && folder === "backend") return { zone: "fn" }
|
|
607
|
+
// Code both sides import may live beside the side that wrote it. Shared code may import only
|
|
608
|
+
// shared code and packages, so the declaration narrows what the file may reach, never widens it.
|
|
609
|
+
// A file "nifra.zones" lists by name has that side; its suffix cannot contradict it.
|
|
610
|
+
if (
|
|
611
|
+
suffix === "shared" &&
|
|
612
|
+
(folder === "frontend" || folder === "backend") &&
|
|
613
|
+
top !== "public" &&
|
|
614
|
+
configured?.[0] !== rel
|
|
615
|
+
)
|
|
616
|
+
return { zone: "shared" }
|
|
595
617
|
const where =
|
|
596
618
|
configured === undefined
|
|
597
619
|
? `sits in ${top}/`
|