@nifrajs/web 3.3.0 → 3.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 (77) hide show
  1. package/dist/build-plan.d.ts +17 -0
  2. package/dist/build-plan.d.ts.map +1 -1
  3. package/dist/build-plan.js +7 -1
  4. package/dist/build-plan.js.map +1 -1
  5. package/dist/build-vite.d.ts +14 -0
  6. package/dist/build-vite.d.ts.map +1 -1
  7. package/dist/build-vite.js +29 -4
  8. package/dist/build-vite.js.map +1 -1
  9. package/dist/build.d.ts +15 -0
  10. package/dist/build.d.ts.map +1 -1
  11. package/dist/build.js +57 -15
  12. package/dist/build.js.map +1 -1
  13. package/dist/client.d.ts +18 -0
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +115 -0
  16. package/dist/client.js.map +1 -1
  17. package/dist/css-contract.d.ts +21 -0
  18. package/dist/css-contract.d.ts.map +1 -0
  19. package/dist/css-contract.js +28 -0
  20. package/dist/css-contract.js.map +1 -0
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/internal/codegen.d.ts +3 -0
  26. package/dist/internal/codegen.d.ts.map +1 -1
  27. package/dist/internal/codegen.js +11 -6
  28. package/dist/internal/codegen.js.map +1 -1
  29. package/dist/internal/page-execution.d.ts +57 -0
  30. package/dist/internal/page-execution.d.ts.map +1 -0
  31. package/dist/internal/page-execution.js +555 -0
  32. package/dist/internal/page-execution.js.map +1 -0
  33. package/dist/internal/render-document.d.ts +12 -2
  34. package/dist/internal/render-document.d.ts.map +1 -1
  35. package/dist/internal/render-document.js +28 -5
  36. package/dist/internal/render-document.js.map +1 -1
  37. package/dist/internal/request-url.d.ts +3 -0
  38. package/dist/internal/request-url.d.ts.map +1 -0
  39. package/dist/internal/request-url.js +14 -0
  40. package/dist/internal/request-url.js.map +1 -0
  41. package/dist/internal/web-app.d.ts +20 -1
  42. package/dist/internal/web-app.d.ts.map +1 -1
  43. package/dist/internal/web-app.js +37 -734
  44. package/dist/internal/web-app.js.map +1 -1
  45. package/dist/isr.d.ts.map +1 -1
  46. package/dist/isr.js +39 -4
  47. package/dist/isr.js.map +1 -1
  48. package/dist/llms-txt.d.ts +3 -0
  49. package/dist/llms-txt.d.ts.map +1 -1
  50. package/dist/llms-txt.js +6 -3
  51. package/dist/llms-txt.js.map +1 -1
  52. package/dist/manifest.d.ts +1 -1
  53. package/dist/manifest.d.ts.map +1 -1
  54. package/dist/public-dir.d.ts.map +1 -1
  55. package/dist/public-dir.js +69 -44
  56. package/dist/public-dir.js.map +1 -1
  57. package/dist/trusted-html.d.ts +34 -0
  58. package/dist/trusted-html.d.ts.map +1 -0
  59. package/dist/trusted-html.js +35 -0
  60. package/dist/trusted-html.js.map +1 -0
  61. package/package.json +3 -3
  62. package/src/build-plan.ts +23 -1
  63. package/src/build-vite.ts +47 -4
  64. package/src/build.ts +69 -6
  65. package/src/client.ts +140 -0
  66. package/src/css-contract.ts +44 -0
  67. package/src/index.ts +15 -0
  68. package/src/internal/codegen.ts +20 -6
  69. package/src/internal/page-execution.ts +761 -0
  70. package/src/internal/render-document.ts +39 -6
  71. package/src/internal/request-url.ts +14 -0
  72. package/src/internal/web-app.ts +68 -835
  73. package/src/isr.ts +42 -3
  74. package/src/llms-txt.ts +16 -4
  75. package/src/manifest.ts +1 -1
  76. package/src/public-dir.ts +70 -45
  77. package/src/trusted-html.ts +44 -0
@@ -1,5 +1,6 @@
1
1
  import { isSameOriginPath, type ResponseResult, status as statusResult } from "@nifrajs/core/server"
2
2
  import type { BoundaryRegistration, BoundaryStates } from "../boundary.ts"
3
+ import { type CssLoadingMode, DEFAULT_CSS_LOADING, normalizeCssLoading } from "../css-contract.ts"
3
4
  import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, prepareDeferred } from "../deferred.ts"
4
5
  import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "../isr.ts"
5
6
  import type {
@@ -97,6 +98,13 @@ export interface RenderPageOptions {
97
98
  * `BuildManifest.css`) - injected as `<link rel="stylesheet">` in `<head>` so styles arrive with the
98
99
  * first paint (no FOUC). Rendered even on non-hydrated pages. Empty/omitted ⇒ none (unchanged). */
99
100
  readonly styles?: readonly string[]
101
+ /**
102
+ * Activation policy for these framework-owned stylesheet links (default `"blocking"`). In
103
+ * `"deferred"` mode, hydrating pages fetch with `media="print"` and the generated client promotes
104
+ * them to `media="all"` after load/error/timeout. Non-hydrated pages remain blocking so JS-off pages
105
+ * and terminal documents are styled.
106
+ */
107
+ readonly cssLoading?: CssLoadingMode
100
108
  /** SSG: the prerendered-path set, serialized to `window.__NIFRA_PRERENDERED__` so the client fetches
101
109
  * a static `_data.json` on soft-nav into a prerendered route. Empty/omitted ⇒ not injected. */
102
110
  readonly prerenderedPaths?: readonly string[]
@@ -157,7 +165,8 @@ export interface RenderPageOptions {
157
165
  * tail - emitted **regardless of `hydrate`**, so a static (`hydrate: false`) page can still mount
158
166
  * no-framework islands. URLs are attribute-escaped. Empty/omitted ⇒ none (unchanged output). */
159
167
  readonly islandScripts?: readonly string[]
160
- /** CSP nonce applied to every framework-owned executable script in this document. */
168
+ /** CSP nonce applied to every framework-owned executable script in this document. Nonce-bearing
169
+ * documents are marked `private, no-store` so a request-specific nonce is never replayed. */
161
170
  readonly nonce?: string
162
171
  /**
163
172
  * Advanced: a per-route slot the renderer fills with the request-invariant document pieces (shell
@@ -165,7 +174,8 @@ export interface RenderPageOptions {
165
174
  *
166
175
  * Only pass a cache when every shell-shaping input is IDENTICAL across the requests sharing it:
167
176
  * same `head` content (a static meta chain - never a `meta(data)` function), `title`, `styles`,
168
- * `preload`, `islandScripts`, `clientEntry`, `rootId`, `hydrate`, and `prerenderedPaths`. Per-request
177
+ * `cssLoading`, `preload`, `islandScripts`, `clientEntry`, `rootId`, `hydrate`, and `prerenderedPaths`.
178
+ * Per-request
169
179
  * values (`data`, `params`, `search`, `actionData`, `layoutData`, deferred state) are always assembled
170
180
  * fresh and safe. A per-request `nonce` disables the cache automatically. `createWebApp` wires this
171
181
  * per route, gated on the route + layout metas being static.
@@ -219,6 +229,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
219
229
  clientEntry,
220
230
  preload = [],
221
231
  styles = [],
232
+ cssLoading: requestedCssLoading,
222
233
  prerenderedPaths = [],
223
234
  revalidate,
224
235
  revalidateTags = [],
@@ -233,6 +244,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
233
244
  boundaries,
234
245
  headers: extraHeaders,
235
246
  } = options
247
+ const cssLoading = normalizeCssLoading(requestedCssLoading ?? DEFAULT_CSS_LOADING)
236
248
  if (nonce !== undefined && nonce.trim() === "") {
237
249
  throw new TypeError("[nifra/web] renderPage nonce must be non-empty when provided")
238
250
  }
@@ -317,10 +329,25 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
317
329
  preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`
318
330
  }
319
331
  // The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
320
- // first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static
321
- // or `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead.
332
+ // first paint (no FOUC). Deferred mode is deliberately opt-in and only applies to hydrating pages:
333
+ // a JS-off page must retain a normal stylesheet link, and a terminal/static page has no generated
334
+ // client to promote `media="print"` to `media="all"`.
322
335
  let styleLinks = ""
323
- for (const url of styles) styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`
336
+ const deferStyles = hydrate && cssLoading === "deferred"
337
+ const styleUrls = [...new Set(styles)]
338
+ for (const url of styleUrls) {
339
+ const href = escapeAttr(url)
340
+ if (!deferStyles) {
341
+ styleLinks += `<link rel="stylesheet" href="${href}">`
342
+ continue
343
+ }
344
+ // `media="print"` starts the fetch without making the CSS render-blocking. The marker is scoped
345
+ // to framework-owned links so the generated client never rewrites an author's head link. The
346
+ // noscript copy preserves progressive enhancement when JavaScript is disabled.
347
+ styleLinks +=
348
+ `<link rel="stylesheet" href="${href}" media="print" data-nifra-css="deferred">` +
349
+ `<noscript><link rel="stylesheet" href="${href}"></noscript>`
350
+ }
324
351
  // Island bundles are referenced only by a `<script type="module">` at the END of `<body>`, so the
325
352
  // browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
326
353
  // so the fetch starts immediately, in parallel with parsing - regardless of `hydrate`.
@@ -395,6 +422,10 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
395
422
  if (revalidate !== undefined) headers[ISR_REVALIDATE_HEADER] = String(revalidate)
396
423
  const serializedTags = serializeISRTags(revalidateTags)
397
424
  if (serializedTags !== undefined) headers[ISR_REVALIDATE_TAGS_HEADER] = serializedTags
425
+ // A per-request nonce must not be replayed from a browser, CDN, or ISR cache. The same policy
426
+ // also keeps `withISR` from storing a document whose executable scripts carry request-specific
427
+ // authorization to run under the caller's CSP.
428
+ if (nonce !== undefined) headers["cache-control"] = "private, no-store"
398
429
  const renderProps: RenderProps = {
399
430
  data: forComponent,
400
431
  actionData: actionSplit?.forComponent,
@@ -554,8 +585,10 @@ function streamDocument(
554
585
  deferred.map(async (d) => {
555
586
  try {
556
587
  const value = serializeData(await d.promise)
588
+ // `serializeData` JSON-serializes the value and escapes `<`, `>`, and Unicode line
589
+ // separators before this framework-owned script is emitted.
557
590
  controller.enqueue(
558
- enc.encode(`<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>`),
591
+ enc.encode(`<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>`), // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag
559
592
  )
560
593
  } catch (err) {
561
594
  // A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
@@ -0,0 +1,14 @@
1
+ import { type UrlParts, urlPartsOf } from "@nifrajs/core/server"
2
+
3
+ // A page request reaches the same Request object through mounts, fallback checks, search parsing,
4
+ // and rendering. Cache the allocation-light URL split once for that request. WeakMap keeps the cache
5
+ // bounded to requests that are still alive and makes the helper safe for every runtime adapter.
6
+ const REQUEST_URL_PARTS = new WeakMap<Request, UrlParts>()
7
+
8
+ export function urlPartsFor(request: Request): UrlParts {
9
+ const cached = REQUEST_URL_PARTS.get(request)
10
+ if (cached !== undefined) return cached
11
+ const parts = urlPartsOf(request.url)
12
+ REQUEST_URL_PARTS.set(request, parts)
13
+ return parts
14
+ }