@nifrajs/web 5.2.0 → 5.3.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 +72 -0
  2. package/dist/build.d.ts +11 -2
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +182 -11
  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/output-guard.d.ts +2 -0
  26. package/dist/internal/output-guard.d.ts.map +1 -1
  27. package/dist/internal/output-guard.js +51 -9
  28. package/dist/internal/output-guard.js.map +1 -1
  29. package/dist/internal/page-execution.d.ts +1 -0
  30. package/dist/internal/page-execution.d.ts.map +1 -1
  31. package/dist/internal/page-execution.js +53 -43
  32. package/dist/internal/page-execution.js.map +1 -1
  33. package/dist/internal/parity.d.ts.map +1 -1
  34. package/dist/internal/parity.js +3 -1
  35. package/dist/internal/parity.js.map +1 -1
  36. package/dist/internal/prerendered-pages.d.ts +42 -0
  37. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  38. package/dist/internal/prerendered-pages.js +92 -0
  39. package/dist/internal/prerendered-pages.js.map +1 -0
  40. package/dist/internal/public-url.d.ts +7 -0
  41. package/dist/internal/public-url.d.ts.map +1 -1
  42. package/dist/internal/public-url.js +13 -0
  43. package/dist/internal/public-url.js.map +1 -1
  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 +15 -0
  68. package/dist/public-dir.d.ts.map +1 -1
  69. package/dist/public-dir.js +91 -12
  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 +226 -35
  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/output-guard.ts +51 -9
  89. package/src/internal/page-execution.ts +55 -47
  90. package/src/internal/parity.ts +2 -1
  91. package/src/internal/prerendered-pages.ts +124 -0
  92. package/src/internal/public-url.ts +13 -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 +107 -12
  100. package/src/render-seam.ts +5 -2
  101. package/src/search.ts +1 -0
  102. package/src/vite.ts +130 -54
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,
@@ -33,6 +33,7 @@ import {
33
33
  formatIdentityParityFindings,
34
34
  identityParityHeadline,
35
35
  } from "./internal/parity.ts"
36
+ import { isHiddenPublicPath } from "./internal/public-url.ts"
36
37
  import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
37
38
  import { importVite } from "./internal/vite-import.ts"
38
39
  import { normalizeRolldownPlugins } from "./internal/vite-plugin-compat.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 (isHiddenPublicPath(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) && isHiddenPublicPath(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