@nifrajs/web 5.2.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/build.d.ts +11 -2
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +265 -15
  5. package/dist/build.js.map +1 -1
  6. package/dist/csp.d.ts +2 -0
  7. package/dist/csp.d.ts.map +1 -1
  8. package/dist/csp.js +2 -0
  9. package/dist/csp.js.map +1 -1
  10. package/dist/deferred.d.ts.map +1 -1
  11. package/dist/deferred.js +23 -3
  12. package/dist/deferred.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/deferred-free.d.ts +3 -0
  18. package/dist/internal/deferred-free.d.ts.map +1 -0
  19. package/dist/internal/deferred-free.js +15 -0
  20. package/dist/internal/deferred-free.js.map +1 -0
  21. package/dist/internal/module-preparation.d.ts +7 -0
  22. package/dist/internal/module-preparation.d.ts.map +1 -0
  23. package/dist/internal/module-preparation.js +24 -0
  24. package/dist/internal/module-preparation.js.map +1 -0
  25. package/dist/internal/node-adapter.d.ts +19 -0
  26. package/dist/internal/node-adapter.d.ts.map +1 -0
  27. package/dist/internal/node-adapter.js +48 -0
  28. package/dist/internal/node-adapter.js.map +1 -0
  29. package/dist/internal/output-guard.d.ts +2 -0
  30. package/dist/internal/output-guard.d.ts.map +1 -1
  31. package/dist/internal/output-guard.js +51 -9
  32. package/dist/internal/output-guard.js.map +1 -1
  33. package/dist/internal/page-execution.d.ts +1 -0
  34. package/dist/internal/page-execution.d.ts.map +1 -1
  35. package/dist/internal/page-execution.js +53 -43
  36. package/dist/internal/page-execution.js.map +1 -1
  37. package/dist/internal/parity.d.ts.map +1 -1
  38. package/dist/internal/parity.js +3 -0
  39. package/dist/internal/parity.js.map +1 -1
  40. package/dist/internal/prerendered-pages.d.ts +42 -0
  41. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  42. package/dist/internal/prerendered-pages.js +92 -0
  43. package/dist/internal/prerendered-pages.js.map +1 -0
  44. package/dist/internal/render-document.d.ts.map +1 -1
  45. package/dist/internal/render-document.js +93 -35
  46. package/dist/internal/render-document.js.map +1 -1
  47. package/dist/internal/request-url.d.ts +4 -0
  48. package/dist/internal/request-url.d.ts.map +1 -1
  49. package/dist/internal/request-url.js +24 -0
  50. package/dist/internal/request-url.js.map +1 -1
  51. package/dist/internal/secret-scan.d.ts +4 -0
  52. package/dist/internal/secret-scan.d.ts.map +1 -1
  53. package/dist/internal/secret-scan.js +8 -1
  54. package/dist/internal/secret-scan.js.map +1 -1
  55. package/dist/internal/web-app.d.ts +11 -0
  56. package/dist/internal/web-app.d.ts.map +1 -1
  57. package/dist/internal/web-app.js +10 -1
  58. package/dist/internal/web-app.js.map +1 -1
  59. package/dist/isr.d.ts +1 -0
  60. package/dist/isr.d.ts.map +1 -1
  61. package/dist/isr.js +7 -4
  62. package/dist/isr.js.map +1 -1
  63. package/dist/prerender.d.ts +4 -0
  64. package/dist/prerender.d.ts.map +1 -1
  65. package/dist/prerender.js +9 -1
  66. package/dist/prerender.js.map +1 -1
  67. package/dist/public-dir.d.ts +19 -7
  68. package/dist/public-dir.d.ts.map +1 -1
  69. package/dist/public-dir.js +130 -99
  70. package/dist/public-dir.js.map +1 -1
  71. package/dist/render-seam.d.ts +5 -2
  72. package/dist/render-seam.d.ts.map +1 -1
  73. package/dist/render-seam.js.map +1 -1
  74. package/dist/search.d.ts.map +1 -1
  75. package/dist/search.js +2 -0
  76. package/dist/search.js.map +1 -1
  77. package/dist/vite.d.ts +6 -0
  78. package/dist/vite.d.ts.map +1 -1
  79. package/dist/vite.js +127 -54
  80. package/dist/vite.js.map +1 -1
  81. package/package.json +3 -3
  82. package/src/build.ts +330 -49
  83. package/src/csp.ts +4 -0
  84. package/src/deferred.ts +20 -3
  85. package/src/index.ts +3 -0
  86. package/src/internal/deferred-free.ts +15 -0
  87. package/src/internal/module-preparation.ts +30 -0
  88. package/src/internal/node-adapter.ts +57 -0
  89. package/src/internal/output-guard.ts +51 -9
  90. package/src/internal/page-execution.ts +55 -47
  91. package/src/internal/parity.ts +2 -0
  92. package/src/internal/prerendered-pages.ts +124 -0
  93. package/src/internal/render-document.ts +116 -52
  94. package/src/internal/request-url.ts +24 -0
  95. package/src/internal/secret-scan.ts +9 -1
  96. package/src/internal/web-app.ts +21 -1
  97. package/src/isr.ts +7 -4
  98. package/src/prerender.ts +13 -1
  99. package/src/public-dir.ts +158 -97
  100. package/src/render-seam.ts +5 -2
  101. package/src/search.ts +1 -0
  102. package/src/vite.ts +130 -54
package/src/public-dir.ts CHANGED
@@ -12,11 +12,20 @@
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 { realpath } from "node:fs/promises"
16
- import { extname, normalize, resolve, sep } from "node:path"
15
+ import { createHash } from "node:crypto"
16
+ import { type FileHandle, realpath } from "node:fs/promises"
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"
21
+ import {
22
+ contentTags,
23
+ contentTypeOf,
24
+ ifRangeAllowsPartial,
25
+ isHiddenPath,
26
+ isNotModified,
27
+ lastModifiedOf,
28
+ } from "@nifrajs/core/static-file"
20
29
  import { openResolved } from "./open-resolved.ts"
21
30
 
22
31
  /** How long each subtree may be cached. Content-hashed bundle output can be immutable; a
@@ -49,91 +58,40 @@ export const IMMUTABLE = "public, max-age=31536000, immutable"
49
58
  export const ONE_DAY = "public, max-age=86400"
50
59
 
51
60
  /**
52
- * Media types by extension. A table rather than `Bun.file(path).type`: this handler also runs on the
53
- * Node and Deno adapters, where `Bun` does not exist and every hit would throw. Unknown extensions
54
- * (and extensionless files such as ACME tokens) are served as `application/octet-stream`, which
55
- * together with `nosniff` keeps the browser from guessing a more dangerous type.
61
+ * Whether a confined path crosses a hidden segment ({@link isHiddenPath}). Checked on the
62
+ * resolved path, so `%2e` encodings are covered, and again on the real target, so a visible name
63
+ * linking to `.env` is refused too.
56
64
  */
57
- export const CONTENT_TYPES: Readonly<Record<string, string>> = {
58
- ".html": "text/html; charset=utf-8",
59
- ".htm": "text/html; charset=utf-8",
60
- ".css": "text/css; charset=utf-8",
61
- ".js": "text/javascript; charset=utf-8",
62
- ".mjs": "text/javascript; charset=utf-8",
63
- ".cjs": "text/javascript; charset=utf-8",
64
- ".json": "application/json; charset=utf-8",
65
- ".map": "application/json; charset=utf-8",
66
- ".webmanifest": "application/manifest+json",
67
- ".txt": "text/plain; charset=utf-8",
68
- ".md": "text/markdown; charset=utf-8",
69
- ".csv": "text/csv; charset=utf-8",
70
- ".xml": "application/xml",
71
- ".rss": "application/rss+xml",
72
- ".atom": "application/atom+xml",
73
- ".svg": "image/svg+xml",
74
- ".png": "image/png",
75
- ".jpg": "image/jpeg",
76
- ".jpeg": "image/jpeg",
77
- ".gif": "image/gif",
78
- ".webp": "image/webp",
79
- ".avif": "image/avif",
80
- ".ico": "image/x-icon",
81
- ".bmp": "image/bmp",
82
- ".woff": "font/woff",
83
- ".woff2": "font/woff2",
84
- ".ttf": "font/ttf",
85
- ".otf": "font/otf",
86
- ".wasm": "application/wasm",
87
- ".pdf": "application/pdf",
88
- ".zip": "application/zip",
89
- ".mp4": "video/mp4",
90
- ".webm": "video/webm",
91
- ".ogg": "audio/ogg",
92
- ".mp3": "audio/mpeg",
93
- ".wav": "audio/wav",
94
- ".m4a": "audio/mp4",
95
- ".vtt": "text/vtt; charset=utf-8",
65
+ function hiddenUnder(root: string, abs: string): boolean {
66
+ return abs !== root && isHiddenPath(abs.slice(root.length + 1))
96
67
  }
97
68
 
98
- /**
99
- * Whether a confined path crosses a dot-segment other than `.well-known`. The build copies `public/`
100
- * with dotfiles included, so an editor or VCS artifact (`.env`, `.git/`, `.DS_Store`) dropped there
101
- * would otherwise be downloadable. Checked on the resolved path, so `%2e` encodings are covered.
102
- */
103
- function isHiddenPath(root: string, abs: string): boolean {
104
- for (const segment of abs.slice(root.length + 1).split(sep)) {
105
- if (segment.startsWith(".") && segment !== ".well-known") return true
106
- }
107
- return false
108
- }
69
+ // Up to this many bytes are read whole through the pinned descriptor, which then closes before the
70
+ // response leaves: on Bun a 200 KB asset is served in 0.17 ms against 0.28 ms through a node:fs
71
+ // stream. A larger body streams, so a video holds a stream's buffer per request rather than the file.
72
+ const BUFFERED_BODY_MAX = 256 * 1024
73
+ // Bun.serve drops the declared length of any stream body and sends it chunked, and a Bun.file body
74
+ // would need this descriptor open for a send nothing reports the end of. So on Bun a body up to 1 MiB
75
+ // is read whole as well: a slow client then holds ~1 MB, against ~0.6 MB through Bun's stream sink.
76
+ const BUN_BUFFERED_BODY_MAX = 1024 * 1024
109
77
 
110
- /** HTTP dates carry second precision; comparing raw milliseconds makes every file look stale. */
111
- function seconds(time: number): number {
112
- return Math.floor(time / 1000)
113
- }
78
+ // One per process: the tag of a file served by any handler is remembered by its real path.
79
+ const TAGS = contentTags(createHash)
114
80
 
115
- /**
116
- * `If-Range` decides whether a range request is still safe to answer partially. The only validator
117
- * this handler publishes is `last-modified`, so an entity-tag form can never match and the whole
118
- * representation is sent instead - which is the conformant outcome, not a fallback. Dates use strong
119
- * comparison (RFC 9110 13.1.5): a file modified since the client's copy invalidates its byte offsets.
120
- */
121
- function ifRangeMatches(value: string | null, lastModified: number | undefined): boolean {
122
- if (value === null) return true
123
- if (lastModified === undefined) return false
124
- const item = value.trim()
125
- if (item.startsWith('"') || item.startsWith("W/")) return false
126
- const parsed = Date.parse(item)
127
- return Number.isFinite(parsed) && seconds(parsed) === seconds(lastModified)
128
- }
129
-
130
- /** `If-Modified-Since` freshness. No ETag is published, so there is no `If-None-Match` precedence. */
131
- function isNotModified(request: Request, lastModified: number | undefined): boolean {
132
- if (lastModified === undefined) return false
133
- const since = request.headers.get("if-modified-since")
134
- if (since === null) return false
135
- const parsed = Date.parse(since)
136
- return Number.isFinite(parsed) && seconds(parsed) >= seconds(lastModified)
81
+ /** `length` bytes of `handle` from `start`; fewer when the file shrank since its stat. */
82
+ async function readPinned(
83
+ handle: FileHandle,
84
+ start: number,
85
+ length: number,
86
+ ): Promise<Uint8Array<ArrayBuffer>> {
87
+ const bytes = new Uint8Array(length)
88
+ let offset = 0
89
+ while (offset < length) {
90
+ const { bytesRead } = await handle.read(bytes, offset, length - offset, start + offset)
91
+ if (bytesRead === 0) break
92
+ offset += bytesRead
93
+ }
94
+ return offset === length ? bytes : bytes.subarray(0, offset)
137
95
  }
138
96
 
139
97
  /**
@@ -166,6 +124,10 @@ export function resolvePublicPath(root: string, pathname: string): string | unde
166
124
  * never shadow a route. That ordering is also why a `routes/robots.txt.tsx` beats a
167
125
  * `public/robots.txt` only if the caller checks routes first; the documented precedence is that the
168
126
  * static probe runs first, so `public/` wins, and an app wanting the route should not ship both.
127
+ *
128
+ * A file is sent with a strong `ETag` from its bytes, the tag `@nifrajs/node`'s `static` reader and
129
+ * the Deno entry send, and with `Last-Modified` only while its mtime is that of its own last change,
130
+ * so a replacement that keeps a file's size and timestamps is never judged current or resumed into.
169
131
  */
170
132
  export function servePublicDir(
171
133
  options: ServePublicDirOptions,
@@ -176,6 +138,7 @@ export function servePublicDir(
176
138
  const hashedFiles = options.hashedFiles
177
139
  const hashed = options.cache?.hashed ?? IMMUTABLE
178
140
  const assets = options.cache?.assets ?? ONE_DAY
141
+ const bufferedMax = typeof Bun === "undefined" ? BUFFERED_BODY_MAX : BUN_BUFFERED_BODY_MAX
179
142
 
180
143
  return async (request: Request): Promise<Response | undefined> => {
181
144
  if (request.method !== "GET" && request.method !== "HEAD") return undefined
@@ -186,7 +149,7 @@ export function servePublicDir(
186
149
  // A production manifest can reject page routes without touching the filesystem.
187
150
  if (options.files !== undefined && !options.files.has(pathname)) return undefined
188
151
  const abs = resolvePublicPath(root, pathname)
189
- if (abs === undefined || isHiddenPath(root, abs)) return undefined
152
+ if (abs === undefined || hiddenUnder(root, abs)) return undefined
190
153
  const [resolvedRoot, resolvedFile] = await Promise.all([
191
154
  rootReal,
192
155
  realpath(abs).catch(() => undefined),
@@ -194,7 +157,8 @@ export function servePublicDir(
194
157
  if (
195
158
  resolvedRoot === undefined ||
196
159
  resolvedFile === undefined ||
197
- (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(resolvedRoot + sep))
160
+ (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(resolvedRoot + sep)) ||
161
+ hiddenUnder(resolvedRoot, resolvedFile)
198
162
  ) {
199
163
  return undefined
200
164
  }
@@ -204,13 +168,15 @@ export function servePublicDir(
204
168
  // symlink; the descriptor then pins the bytes for the lifetime of the response.
205
169
  let handle: Awaited<ReturnType<typeof openResolved>> | undefined
206
170
  try {
171
+ let file: FileHandle
207
172
  try {
208
- handle = await openResolved(resolvedFile)
173
+ file = await openResolved(resolvedFile)
209
174
  } catch {
210
175
  // A name refused or gone since realpath falls through, as a failed realpath does.
211
176
  return undefined
212
177
  }
213
- const stat = await handle.stat()
178
+ handle = file
179
+ const stat = await file.stat()
214
180
  if (!stat.isFile()) return undefined
215
181
  const size = stat.size
216
182
  const headers = new Headers({
@@ -228,24 +194,42 @@ export function servePublicDir(
228
194
  // rendered as HTML, say). The declared type below is authoritative.
229
195
  "x-content-type-options": "nosniff",
230
196
  // Set explicitly rather than inferred from the body, so HEAD (a null body) carries it too.
231
- "content-type":
232
- CONTENT_TYPES[extname(resolvedFile).toLowerCase()] ?? "application/octet-stream",
197
+ "content-type": contentTypeOf(resolvedFile),
233
198
  })
234
- const lastModified =
235
- Number.isFinite(stat.mtimeMs) && stat.mtimeMs > 0 ? stat.mtimeMs : undefined
199
+ // The whole file, when tagging it had to read it: the response is then served from these bytes.
200
+ let whole: Uint8Array<ArrayBuffer> | undefined
201
+ let etag = TAGS.cached(resolvedFile, stat)
202
+ if (etag === undefined && size <= bufferedMax) {
203
+ whole = await readPinned(file, 0, size)
204
+ etag = TAGS.ofBytes(resolvedFile, stat, whole)
205
+ }
206
+ etag ??= await TAGS.ofFile(resolvedFile, stat, async (bytes, position) => {
207
+ return (await file.read(bytes, 0, bytes.byteLength, position)).bytesRead
208
+ })
209
+ if (etag !== undefined) headers.set("etag", etag)
210
+ const lastModified = lastModifiedOf(stat)
236
211
  if (lastModified !== undefined) {
237
212
  headers.set("last-modified", new Date(lastModified).toUTCString())
238
213
  }
239
214
  const head = request.method === "HEAD"
240
215
 
241
- if (isNotModified(request, lastModified)) {
216
+ if (
217
+ isNotModified(
218
+ request.headers.get("if-none-match"),
219
+ request.headers.get("if-modified-since"),
220
+ etag,
221
+ lastModified,
222
+ )
223
+ ) {
242
224
  headers.delete("content-type")
243
225
  return new Response(null, { status: 304, headers })
244
226
  }
245
227
 
246
- const rangeHeader = request.headers.get("range")
228
+ // Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
229
+ const rangeHeader = head ? null : request.headers.get("range")
247
230
  const range =
248
- rangeHeader !== null && ifRangeMatches(request.headers.get("if-range"), lastModified)
231
+ rangeHeader !== null &&
232
+ ifRangeAllowsPartial(request.headers.get("if-range"), etag, lastModified)
249
233
  ? parseByteRange(rangeHeader, size)
250
234
  : ({ kind: "none" } as const)
251
235
 
@@ -255,8 +239,20 @@ export function servePublicDir(
255
239
  return new Response(null, { status: 416, headers })
256
240
  }
257
241
 
258
- const body = (start?: number, end?: number): ReadableStream<Uint8Array> => {
259
- const stream = handle!.createReadStream({ start, end })
242
+ const body = async (
243
+ start: number,
244
+ end: number,
245
+ ): Promise<Uint8Array<ArrayBuffer> | ReadableStream<Uint8Array>> => {
246
+ const length = end - start + 1
247
+ if (length <= bufferedMax) {
248
+ const bytes =
249
+ whole === undefined
250
+ ? await readPinned(file, start, length)
251
+ : whole.subarray(start, start + length)
252
+ headers.set("content-length", String(bytes.byteLength))
253
+ return bytes
254
+ }
255
+ const stream = file.createReadStream({ start, end })
260
256
  // FileHandle.createReadStream owns closing the descriptor after EOF/abort.
261
257
  handle = undefined
262
258
  return Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>
@@ -266,14 +262,14 @@ export function servePublicDir(
266
262
  const { start, end } = range.ranges[0]!
267
263
  headers.set("content-range", `bytes ${start}-${end}/${size}`)
268
264
  headers.set("content-length", String(end - start + 1))
269
- return new Response(head ? null : body(start, end), { status: 206, headers })
265
+ return new Response(head ? null : await body(start, end), { status: 206, headers })
270
266
  }
271
267
 
272
268
  // Multiple ranges would require assembling `multipart/byteranges`, and doing that for a file on
273
269
  // disk means buffering the whole representation to serve a request that asked for less of it.
274
270
  // RFC 9110 lets a server ignore Range entirely, so the full body is the conformant answer here.
275
271
  headers.set("content-length", String(size))
276
- return new Response(head ? null : body(), { headers })
272
+ return new Response(head || size === 0 ? null : await body(0, size - 1), { headers })
277
273
  } finally {
278
274
  if (handle !== undefined) {
279
275
  try {
@@ -285,3 +281,68 @@ export function servePublicDir(
285
281
  }
286
282
  }
287
283
  }
284
+
285
+ export interface PrerenderedPageReaderOptions {
286
+ /** Absolute path of the directory the build wrote the prerendered pages into. */
287
+ readonly dir: string
288
+ /** Each page path mapped to its file, relative to `dir`. */
289
+ readonly files: Readonly<Record<string, string>>
290
+ /** Bytes of HTML kept in memory once read (default 32 MiB); a page past the budget is read per request. */
291
+ readonly cacheBytes?: number
292
+ }
293
+
294
+ /**
295
+ * Read a self-hosted build's prerendered pages for `createWebApp`'s `prerenderedPages`. Only the build's
296
+ * own files are reachable, each opened only once its real path is inside `dir`, so a link planted in the
297
+ * deployed tree cannot point a page elsewhere. A deploy's files do not change while it runs, so a page
298
+ * is kept after its first read; `undefined` (a missing or refused file) renders the page on demand.
299
+ */
300
+ export function prerenderedPageReader(
301
+ options: PrerenderedPageReaderOptions,
302
+ ): (pathname: string) => Promise<string | undefined> {
303
+ const root = resolve(options.dir)
304
+ const rootReal = realpath(root).catch(() => undefined)
305
+ const files = new Map(Object.entries(options.files))
306
+ const budget = options.cacheBytes ?? 32 * 1024 * 1024
307
+ const cache = new Map<string, string>()
308
+ let cachedBytes = 0
309
+ return async (pathname) => {
310
+ const cached = cache.get(pathname)
311
+ if (cached !== undefined) return cached
312
+ const rel = files.get(pathname)
313
+ if (rel === undefined) return undefined
314
+ const abs = resolve(root, rel)
315
+ if (!abs.startsWith(root + sep) || hiddenUnder(root, abs)) return undefined
316
+ const [resolvedRoot, resolvedFile] = await Promise.all([
317
+ rootReal,
318
+ realpath(abs).catch(() => undefined),
319
+ ])
320
+ if (
321
+ resolvedRoot === undefined ||
322
+ resolvedFile === undefined ||
323
+ !resolvedFile.startsWith(resolvedRoot + sep)
324
+ ) {
325
+ return undefined
326
+ }
327
+ let handle: FileHandle
328
+ try {
329
+ handle = await openResolved(resolvedFile)
330
+ } catch {
331
+ return undefined
332
+ }
333
+ try {
334
+ if (!(await handle.stat()).isFile()) return undefined
335
+ const bytes = await handle.readFile()
336
+ const html = bytes.toString("utf8")
337
+ if (!cache.has(pathname) && cachedBytes + bytes.byteLength <= budget) {
338
+ cache.set(pathname, html)
339
+ cachedBytes += bytes.byteLength
340
+ }
341
+ return html
342
+ } catch {
343
+ return undefined
344
+ } finally {
345
+ await handle.close().catch(() => undefined)
346
+ }
347
+ }
348
+ }
@@ -155,13 +155,16 @@ export interface RenderAdapter {
155
155
  ): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>
156
156
  /**
157
157
  * Render the chain to a complete HTML string when no content is deferred. `options.nonce` is the
158
- * document's CSP nonce, for any inline script the renderer emits (Solid's hydration data).
158
+ * document's CSP nonce, for any inline script the renderer emits (Solid's hydration data); a
159
+ * document that sends a policy keeps the nonce only when the returned HTML carries it. Return
160
+ * `undefined` when the render suspended on something only `renderToStream` waits for (React's
161
+ * `use` or `lazy` inside `<Suspense>`): the page is then streamed instead.
159
162
  */
160
163
  renderToString?(
161
164
  chain: readonly unknown[],
162
165
  props: RenderProps,
163
166
  options?: RenderStreamOptions,
164
- ): string | Promise<string>
167
+ ): string | undefined | Promise<string | undefined>
165
168
  /** Per-document bootstrap markup injected into `<head>` for client hydration. */
166
169
  hydrationHead(nonce?: string): string
167
170
  /**
package/src/search.ts CHANGED
@@ -107,6 +107,7 @@ function encodeValue(value: unknown): string {
107
107
  /** The default JSON-first codec. Repeated keys (`?t=a&t=b`) decode to an array; single keys to a value. */
108
108
  const jsonCodec: SearchCodec = {
109
109
  parse(raw, limits) {
110
+ if (raw === "" || raw === "?") return {}
110
111
  const usp = new URLSearchParams(raw.startsWith("?") ? raw.slice(1) : raw)
111
112
  // Two passes instead of the old `Set(keys) + getAll(key)` shape, which rescanned the complete
112
113
  // URLSearchParams list once per distinct key (O(keys²)). Admission must be decided BEFORE any
package/src/vite.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * Vite's `middlewares` are Connect-style - it runs fine under Bun.
12
12
  */
13
13
 
14
- import { type Dirent, readdirSync, writeFileSync } from "node:fs"
14
+ import { type Dirent, existsSync, readdirSync, realpathSync, writeFileSync } from "node:fs"
15
15
  import {
16
16
  createServer as createHttpServer,
17
17
  type IncomingMessage,
@@ -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"
@@ -77,6 +78,8 @@ interface ViteLike {
77
78
  ssrFixStacktrace(err: Error): void
78
79
  /** The module graph, used to invalidate a changed file AND its transitive importers before reload. */
79
80
  readonly moduleGraph?: ViteModuleGraph
81
+ /** The resolved config: `publicDir` is absolute, or `""` when the public directory is off. */
82
+ readonly config?: { readonly publicDir?: string }
80
83
  readonly watcher: {
81
84
  on(event: "change" | "add" | "unlink", cb: (path: string) => void): void
82
85
  on(event: "ready", cb: () => void): void
@@ -251,8 +254,13 @@ function toWebRequest(req: IncomingMessage, body: Buffer | undefined): Request {
251
254
 
252
255
  /** The bits of a Node ServerResponse `pipeWebBodyToNode` touches - structural, to avoid a node:http dep here. */
253
256
  interface NodeResLike {
257
+ /** Set once the socket is gone: `close` has fired, and a write returns `false` for good. */
258
+ readonly destroyed?: boolean
254
259
  flushHeaders?(): void
255
260
  on(event: "close", cb: () => void): void
261
+ // Optional so a bare sink still fits; without them a full buffer is not waited out.
262
+ once?(event: "drain", cb: () => void): void
263
+ off?(event: "close" | "drain", cb: () => void): void
256
264
  write(chunk: Uint8Array): boolean
257
265
  end(): void
258
266
  }
@@ -282,6 +290,8 @@ export function applyResponseHeaders(headers: Headers, res: NodeHeaderSink): voi
282
290
  * `arrayBuffer()`) waits for the stream to END - which an open-ended SSE (`text/event-stream`) body never
283
291
  * does, so it hung `nifra dev` (the Bun production server streamed it fine). This flushes each chunk as it
284
292
  * arrives and cancels the reader if the client disconnects; a finite body just streams its chunk(s) + ends.
293
+ * A write the socket can't take yet is waited out (`drain`) before the next read, so a slow client
294
+ * holds one chunk rather than the whole body.
285
295
  */
286
296
  export async function pipeWebBodyToNode(
287
297
  body: ReadableStream<Uint8Array> | null,
@@ -291,17 +301,48 @@ export async function pipeWebBodyToNode(
291
301
  res.end()
292
302
  return
293
303
  }
304
+ // A call, so the check after an await is not narrowed by the one before it.
305
+ const destroyed = (): boolean => res.destroyed === true
306
+ // The client left while the app ran: `close` fired before anyone listened, and no `drain` comes.
307
+ if (destroyed()) {
308
+ await body.cancel().catch(() => {})
309
+ return
310
+ }
294
311
  const reader = body.getReader()
295
312
  res.flushHeaders?.()
296
- res.on("close", () => void reader.cancel().catch(() => {}))
313
+ let closed = false
314
+ let wake: (() => void) | undefined
315
+ const onClose = (): void => {
316
+ closed = true
317
+ void reader.cancel().catch(() => {})
318
+ wake?.()
319
+ }
320
+ res.on("close", onClose)
297
321
  try {
298
322
  for (;;) {
299
323
  const { done, value } = await reader.read()
300
324
  if (done) break
301
- res.write(value)
325
+ if (!res.write(value) && !closed && res.once !== undefined) {
326
+ if (destroyed()) {
327
+ onClose()
328
+ break
329
+ }
330
+ let drain!: () => void
331
+ const drained = new Promise<void>((resolve) => {
332
+ drain = resolve
333
+ })
334
+ wake = drain
335
+ res.once("drain", drain)
336
+ await drained
337
+ res.off?.("drain", drain)
338
+ wake = undefined
339
+ }
302
340
  }
303
341
  } catch {
304
342
  // client disconnected mid-stream - the `close` handler already cancelled the reader
343
+ } finally {
344
+ res.off?.("close", onClose)
345
+ reader.releaseLock()
305
346
  }
306
347
  res.end()
307
348
  }
@@ -416,7 +457,38 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
416
457
  handleWithVite(req, res)
417
458
  })
418
459
 
460
+ // Vite's public middleware serves every file under the public directory, dotfiles included. A
461
+ // build ships no hidden file and the Bun pipeline's `servePublicDir` refuses one, so a request for
462
+ // `/.env` or `/.git/config` goes to the app here too, as it does in production.
463
+ function isHiddenPublicFile(url: string): boolean {
464
+ const configured = vite.config?.publicDir
465
+ if (configured === undefined || configured === "") return false
466
+ // Vite writes the path with `/`; resolved, it uses the platform's separator like `file`.
467
+ const publicDir = resolvePath(configured)
468
+ let rel: string
469
+ try {
470
+ rel = decodeURIComponent(new URL(url, "http://localhost").pathname).slice(1)
471
+ } catch {
472
+ return false
473
+ }
474
+ const file = resolvePath(publicDir, rel)
475
+ if (!file.startsWith(publicDir + sep) || !existsSync(file)) return false
476
+ if (isHiddenPath(rel)) return true
477
+ // A visible name linking to a hidden file is refused too, as `servePublicDir` refuses it.
478
+ try {
479
+ const real = realpathSync(file)
480
+ const realRoot = realpathSync(publicDir)
481
+ return real.startsWith(realRoot + sep) && isHiddenPath(real.slice(realRoot.length + 1))
482
+ } catch {
483
+ return false
484
+ }
485
+ }
486
+
419
487
  function handleWithVite(req: IncomingMessage, res: ServerResponse): void {
488
+ if (isHiddenPublicFile(req.url ?? "/")) {
489
+ handleWithApp(req, res)
490
+ return
491
+ }
420
492
  vite.middlewares(req, res, (error) => {
421
493
  // In middleware mode Vite reports a module's transform error to the overlay and passes the
422
494
  // request on. A request naming a source file was for a module, never a page: it gets the error,
@@ -437,60 +509,64 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
437
509
  return
438
510
  }
439
511
  // Not a Vite asset → nifra SSR. (`next` runs after Vite declines, so the body is still readable.)
440
- void (async () => {
441
- try {
442
- const body = await readNodeBody(req)
443
- const webRequest = toWebRequest(req, body)
444
- let overlay = false
445
- const nifraRes = await session.track(webRequest, async () => {
446
- try {
447
- return await app.fetch(webRequest)
448
- } catch (err) {
449
- // Source-map the stack first (Vite maps the bundled frames back to your `.ts`), then render
450
- // the readable dev overlay instead of a bare text dump. Dev-only - production maps to `_error`.
451
- if (err instanceof Error) vite.ssrFixStacktrace(err)
452
- overlay = true
453
- return new Response(
454
- session.failure(err, { method: req.method ?? "GET", url: req.url ?? "/" }),
455
- { status: 500, headers: { "content-type": "text/html; charset=utf-8" } },
456
- )
457
- }
458
- })
459
- const contentType = nifraRes.headers.get("content-type") ?? ""
460
- res.statusCode = nifraRes.status
461
- const requestId = nifraRes.headers.get(DEV_REQUEST_ID_HEADER)
462
- if (requestId !== null) res.setHeader(DEV_REQUEST_ID_HEADER, requestId)
463
- if (overlay || session.isOverlay(nifraRes)) {
464
- res.setHeader("content-type", "text/html; charset=utf-8")
465
- res.end(await nifraRes.text())
466
- return
467
- }
468
- if (!contentType.includes("text/html")) {
469
- // Data / redirect / asset response - pass through untouched, streamed (SSE-safe). Set-Cookie is
470
- // emitted per-header so multi-cookie responses (e.g. better-auth sessions) aren't collapsed.
471
- applyResponseHeaders(nifraRes.headers, res)
472
- await pipeWebBodyToNode(nifraRes.body, res)
473
- return
512
+ handleWithApp(req, res)
513
+ })
514
+ }
515
+
516
+ function handleWithApp(req: IncomingMessage, res: ServerResponse): void {
517
+ void (async () => {
518
+ try {
519
+ const body = await readNodeBody(req)
520
+ const webRequest = toWebRequest(req, body)
521
+ let overlay = false
522
+ const nifraRes = await session.track(webRequest, async () => {
523
+ try {
524
+ return await app.fetch(webRequest)
525
+ } catch (err) {
526
+ // Source-map the stack first (Vite maps the bundled frames back to your `.ts`), then render
527
+ // the readable dev overlay instead of a bare text dump. Dev-only - production maps to `_error`.
528
+ if (err instanceof Error) vite.ssrFixStacktrace(err)
529
+ overlay = true
530
+ return new Response(
531
+ session.failure(err, { method: req.method ?? "GET", url: req.url ?? "/" }),
532
+ { status: 500, headers: { "content-type": "text/html; charset=utf-8" } },
533
+ )
474
534
  }
475
- // Inject Vite's HMR client + the framework's refresh preamble into the SSR'd HTML, keeping the
476
- // app's headers (cookies, CSP, cache-control) as the Bun pipeline does.
477
- const headers = new Headers(nifraRes.headers)
478
- headers.delete("content-length") // the body grows with Vite's tags
479
- const page = session.decorateHtml(webRequest, await nifraRes.text(), headers, requestId)
480
- const html = admitViteTags(await vite.transformIndexHtml(req.url ?? "/", page), headers)
481
- applyResponseHeaders(headers, res)
535
+ })
536
+ const contentType = nifraRes.headers.get("content-type") ?? ""
537
+ res.statusCode = nifraRes.status
538
+ const requestId = nifraRes.headers.get(DEV_REQUEST_ID_HEADER)
539
+ if (requestId !== null) res.setHeader(DEV_REQUEST_ID_HEADER, requestId)
540
+ if (overlay || session.isOverlay(nifraRes)) {
482
541
  res.setHeader("content-type", "text/html; charset=utf-8")
483
- res.end(html)
484
- } catch (err) {
485
- // A failure past the app (reading the body, Vite's HTML transform): same overlay, same feed.
486
- if (err instanceof Error) vite.ssrFixStacktrace(err)
487
- const html = session.failure(err, { method: req.method ?? "GET", url: req.url ?? "/" })
488
- res.statusCode = 500
489
- res.setHeader("content-type", "text/html; charset=utf-8")
490
- res.end(html)
542
+ res.end(await nifraRes.text())
543
+ return
491
544
  }
492
- })()
493
- })
545
+ if (!contentType.includes("text/html")) {
546
+ // Data / redirect / asset response - pass through untouched, streamed (SSE-safe). Set-Cookie is
547
+ // emitted per-header so multi-cookie responses (e.g. better-auth sessions) aren't collapsed.
548
+ applyResponseHeaders(nifraRes.headers, res)
549
+ await pipeWebBodyToNode(nifraRes.body, res)
550
+ return
551
+ }
552
+ // Inject Vite's HMR client + the framework's refresh preamble into the SSR'd HTML, keeping the
553
+ // app's headers (cookies, CSP, cache-control) as the Bun pipeline does.
554
+ const headers = new Headers(nifraRes.headers)
555
+ headers.delete("content-length") // the body grows with Vite's tags
556
+ const page = session.decorateHtml(webRequest, await nifraRes.text(), headers, requestId)
557
+ const html = admitViteTags(await vite.transformIndexHtml(req.url ?? "/", page), headers)
558
+ applyResponseHeaders(headers, res)
559
+ res.setHeader("content-type", "text/html; charset=utf-8")
560
+ res.end(html)
561
+ } catch (err) {
562
+ // A failure past the app (reading the body, Vite's HTML transform): same overlay, same feed.
563
+ if (err instanceof Error) vite.ssrFixStacktrace(err)
564
+ const html = session.failure(err, { method: req.method ?? "GET", url: req.url ?? "/" })
565
+ res.statusCode = 500
566
+ res.setHeader("content-type", "text/html; charset=utf-8")
567
+ res.end(html)
568
+ }
569
+ })()
494
570
  }
495
571
 
496
572
  // `conditions: ["bun"]` makes Vite resolve nifra's workspace packages (`@nifrajs/web-react/client`, …) to