@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/build-plan.d.ts +6 -0
  3. package/dist/build-plan.d.ts.map +1 -1
  4. package/dist/build-plan.js +25 -0
  5. package/dist/build-plan.js.map +1 -1
  6. package/dist/build-vite.d.ts +2 -0
  7. package/dist/build-vite.d.ts.map +1 -1
  8. package/dist/build-vite.js +2 -0
  9. package/dist/build-vite.js.map +1 -1
  10. package/dist/build.d.ts +17 -3
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +174 -54
  13. package/dist/build.js.map +1 -1
  14. package/dist/deferred.d.ts.map +1 -1
  15. package/dist/deferred.js +20 -6
  16. package/dist/deferred.js.map +1 -1
  17. package/dist/dev.d.ts +5 -0
  18. package/dist/dev.d.ts.map +1 -1
  19. package/dist/dev.js +7 -6
  20. package/dist/dev.js.map +1 -1
  21. package/dist/diagnostic.d.ts +6 -0
  22. package/dist/diagnostic.d.ts.map +1 -1
  23. package/dist/diagnostic.js +41 -0
  24. package/dist/diagnostic.js.map +1 -1
  25. package/dist/host-policy.d.ts +72 -0
  26. package/dist/host-policy.d.ts.map +1 -0
  27. package/dist/host-policy.js +149 -0
  28. package/dist/host-policy.js.map +1 -0
  29. package/dist/host-reach.d.ts +49 -0
  30. package/dist/host-reach.d.ts.map +1 -0
  31. package/dist/host-reach.js +144 -0
  32. package/dist/host-reach.js.map +1 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/internal/certified-components.d.ts +8 -0
  38. package/dist/internal/certified-components.d.ts.map +1 -0
  39. package/dist/internal/certified-components.js +8 -0
  40. package/dist/internal/certified-components.js.map +1 -0
  41. package/dist/internal/codegen.d.ts +8 -0
  42. package/dist/internal/codegen.d.ts.map +1 -1
  43. package/dist/internal/codegen.js +15 -4
  44. package/dist/internal/codegen.js.map +1 -1
  45. package/dist/internal/node-adapter.d.ts +19 -0
  46. package/dist/internal/node-adapter.d.ts.map +1 -0
  47. package/dist/internal/node-adapter.js +48 -0
  48. package/dist/internal/node-adapter.js.map +1 -0
  49. package/dist/internal/output-guard.d.ts +6 -0
  50. package/dist/internal/output-guard.d.ts.map +1 -1
  51. package/dist/internal/output-guard.js +118 -16
  52. package/dist/internal/output-guard.js.map +1 -1
  53. package/dist/internal/page-execution.d.ts +2 -0
  54. package/dist/internal/page-execution.d.ts.map +1 -1
  55. package/dist/internal/page-execution.js +16 -11
  56. package/dist/internal/page-execution.js.map +1 -1
  57. package/dist/internal/parity.d.ts.map +1 -1
  58. package/dist/internal/parity.js +3 -2
  59. package/dist/internal/parity.js.map +1 -1
  60. package/dist/internal/public-url.d.ts +0 -7
  61. package/dist/internal/public-url.d.ts.map +1 -1
  62. package/dist/internal/public-url.js +0 -13
  63. package/dist/internal/public-url.js.map +1 -1
  64. package/dist/internal/render-document.d.ts +3 -1
  65. package/dist/internal/render-document.d.ts.map +1 -1
  66. package/dist/internal/render-document.js +10 -4
  67. package/dist/internal/render-document.js.map +1 -1
  68. package/dist/internal/web-app.d.ts +15 -1
  69. package/dist/internal/web-app.d.ts.map +1 -1
  70. package/dist/internal/web-app.js +8 -0
  71. package/dist/internal/web-app.js.map +1 -1
  72. package/dist/public-dir.d.ts +5 -7
  73. package/dist/public-dir.d.ts.map +1 -1
  74. package/dist/public-dir.js +54 -96
  75. package/dist/public-dir.js.map +1 -1
  76. package/dist/router.d.ts +2 -1
  77. package/dist/router.d.ts.map +1 -1
  78. package/dist/router.js +9 -8
  79. package/dist/router.js.map +1 -1
  80. package/dist/server-components/vite.d.ts.map +1 -1
  81. package/dist/server-components/vite.js +8 -6
  82. package/dist/server-components/vite.js.map +1 -1
  83. package/dist/service-worker.d.ts +3 -1
  84. package/dist/service-worker.d.ts.map +1 -1
  85. package/dist/service-worker.js +29 -8
  86. package/dist/service-worker.js.map +1 -1
  87. package/dist/vite.d.ts.map +1 -1
  88. package/dist/vite.js +3 -3
  89. package/dist/vite.js.map +1 -1
  90. package/dist/zones.d.ts +5 -1
  91. package/dist/zones.d.ts.map +1 -1
  92. package/dist/zones.js +17 -4
  93. package/dist/zones.js.map +1 -1
  94. package/package.json +4 -4
  95. package/src/build-plan.ts +26 -0
  96. package/src/build-vite.ts +4 -0
  97. package/src/build.ts +228 -68
  98. package/src/deferred.ts +20 -6
  99. package/src/dev.ts +7 -6
  100. package/src/diagnostic.ts +43 -0
  101. package/src/host-policy.ts +219 -0
  102. package/src/host-reach.ts +177 -0
  103. package/src/index.ts +10 -2
  104. package/src/internal/certified-components.ts +7 -0
  105. package/src/internal/codegen.ts +24 -5
  106. package/src/internal/node-adapter.ts +57 -0
  107. package/src/internal/output-guard.ts +166 -15
  108. package/src/internal/page-execution.ts +23 -9
  109. package/src/internal/parity.ts +3 -2
  110. package/src/internal/public-url.ts +0 -13
  111. package/src/internal/render-document.ts +11 -4
  112. package/src/internal/web-app.ts +27 -1
  113. package/src/public-dir.ts +70 -97
  114. package/src/router.ts +9 -8
  115. package/src/server-components/vite.ts +11 -6
  116. package/src/service-worker.ts +32 -9
  117. package/src/vite.ts +3 -3
  118. 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 { extname, normalize, resolve, sep } from "node:path"
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 { isHiddenPublicPath } from "./internal/public-url.ts"
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
- * Media types by extension. A table rather than `Bun.file(path).type`: this handler also runs on the
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 isHiddenPath(root: string, abs: string): boolean {
105
- return abs !== root && isHiddenPublicPath(abs.slice(root.length + 1))
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 64 KiB per request rather than the file.
111
- const BUFFERED_BODY_MAX = 256 * 1024
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 || isHiddenPath(root, abs)) return 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
- isHiddenPath(resolvedRoot, resolvedFile)
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
- handle = await openResolved(resolvedFile)
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
- const stat = await handle.stat()
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 lastModified =
255
- Number.isFinite(stat.mtimeMs) && stat.mtimeMs > 0 ? stat.mtimeMs : undefined
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(request, lastModified)) {
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 && ifRangeMatches(request.headers.get("if-range"), lastModified)
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 <= BUFFERED_BODY_MAX) {
285
- const bytes = await readPinned(handle!, start, length)
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 = handle!.createReadStream({ start, end })
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) || isHiddenPath(root, abs)) return undefined
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
- if (Array.isArray(prerendered)) {
426
- const query = path.indexOf("?")
427
- const hash = path.indexOf("#")
428
- // `indexOf` rather than `replace(/[?#].*$/)`: no regex allocation or backtracking on a
429
- // caller-controlled navigation path.
430
- const cut = query === -1 ? hash : hash === -1 ? query : Math.min(query, hash)
431
- const pathname = cut === -1 ? path : path.slice(0, cut)
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 !== "8.2.1")
109
+ if (viteVersion !== CERTIFIED_COMPONENT_VITE)
106
110
  throw new Error(
107
- "[nifra/components] Use the certified Vite 8.2.1 component pipeline; Bun remains a supported server runtime",
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 versions = ["react", "react-dom", "react-server-dom-webpack"].map((name) => {
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}@19.2.8 for the React component pipeline`,
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 !== "19.2.8"))
126
+ if (versions.some((version) => version !== CERTIFIED_COMPONENT_REACT))
122
127
  throw new Error(
123
- "[nifra/components] React, React DOM, and Flight must all use the matching 19.2.8 version",
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
  },
@@ -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. Omit it and failed navigations simply fail, which is the honest default.
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
- // Take over as soon as the new assets are stored; the activate handler drops the old cache, so
120
- // waiting would only serve stale bytes for longer.
121
- event.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting()))
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
- const cacheable =
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 (isHiddenPublicPath(rel)) return true
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) && isHiddenPublicPath(real.slice(realRoot.length + 1))
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
- /** App-relative folders or files holding code both sides import, beside `shared/`. */
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
- if (RESERVED_FOLDERS.has(top) || top.startsWith(".nifra"))
262
- fail(`"${side}" entry "${entry}" is inside ${top}/, which already has its own rules`)
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
- const configured = ZONE_FOLDERS[top] === undefined ? configuredPath(rel) : undefined
591
- const folder = ZONE_FOLDERS[top] ?? configured?.[1]
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}/`