@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
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A framework-neutral trust boundary for the small set of adapters that intentionally inject HTML.
3
+ *
4
+ * `TrustedHtml` is a type-level brand, not a sanitizer. Callers must sanitize untrusted input with a
5
+ * well-reviewed allowlist sanitizer before calling {@link trustHtml}; build-time markdown and
6
+ * application-owned templates may use it directly after review. Keeping the constructor explicit
7
+ * makes every raw-HTML sink audit-greppable and prevents an ordinary string from type-checking by
8
+ * accident.
9
+ */
10
+ declare const TRUSTED_HTML_BRAND: unique symbol;
11
+ declare const SANITIZED_HTML_BRAND: unique symbol;
12
+ export type TrustedHtml = string & {
13
+ readonly [TRUSTED_HTML_BRAND]: "trusted-html";
14
+ };
15
+ /** HTML returned by an explicit, caller-supplied sanitizer. It is also trusted by the adapters. */
16
+ export type SanitizedHtml = TrustedHtml & {
17
+ readonly [SANITIZED_HTML_BRAND]: "sanitized-html";
18
+ };
19
+ /** The only contract a project-specific, allowlist-based HTML sanitizer must satisfy. */
20
+ export type HtmlSanitizer = (value: string) => string;
21
+ /** Mark already-sanitized or application-owned HTML for an intentional raw-HTML adapter sink. */
22
+ export declare function trustHtml(value: string): TrustedHtml;
23
+ /**
24
+ * Run untrusted markup through an explicit sanitizer before it reaches a raw-HTML adapter.
25
+ *
26
+ * Nifra deliberately does not bundle a sanitizer: applications choose the maintained allowlist
27
+ * appropriate to their runtime and content policy. Requiring the sanitizer as an argument prevents
28
+ * `sanitizedHtml(value)` from silently becoming an alias for the unsafe trust operation.
29
+ */
30
+ export declare function sanitizeHtml(value: string, sanitizer: HtmlSanitizer): SanitizedHtml;
31
+ /** Explicit sanitized-content vocabulary for callers and security audits. */
32
+ export declare const sanitizedHtml: typeof sanitizeHtml;
33
+ export {};
34
+ //# sourceMappingURL=trusted-html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trusted-html.d.ts","sourceRoot":"","sources":["../src/trusted-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,CAAC,MAAM,kBAAkB,EAAE,OAAO,MAAM,CAAA;AAC/C,OAAO,CAAC,MAAM,oBAAoB,EAAE,OAAO,MAAM,CAAA;AAEjD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG;IAAE,QAAQ,CAAC,CAAC,kBAAkB,CAAC,EAAE,cAAc,CAAA;CAAE,CAAA;AAEpF,mGAAmG;AACnG,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG;IAAE,QAAQ,CAAC,CAAC,oBAAoB,CAAC,EAAE,gBAAgB,CAAA;CAAE,CAAA;AAE/F,yFAAyF;AACzF,MAAM,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAA;AAErD,iGAAiG;AACjG,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,CAGpD;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,GAAG,aAAa,CAMnF;AAED,6EAA6E;AAC7E,eAAO,MAAM,aAAa,qBAAe,CAAA"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A framework-neutral trust boundary for the small set of adapters that intentionally inject HTML.
3
+ *
4
+ * `TrustedHtml` is a type-level brand, not a sanitizer. Callers must sanitize untrusted input with a
5
+ * well-reviewed allowlist sanitizer before calling {@link trustHtml}; build-time markdown and
6
+ * application-owned templates may use it directly after review. Keeping the constructor explicit
7
+ * makes every raw-HTML sink audit-greppable and prevents an ordinary string from type-checking by
8
+ * accident.
9
+ */
10
+ /** Mark already-sanitized or application-owned HTML for an intentional raw-HTML adapter sink. */
11
+ export function trustHtml(value) {
12
+ if (typeof value !== "string")
13
+ throw new TypeError("trusted HTML must be a string");
14
+ return value;
15
+ }
16
+ /**
17
+ * Run untrusted markup through an explicit sanitizer before it reaches a raw-HTML adapter.
18
+ *
19
+ * Nifra deliberately does not bundle a sanitizer: applications choose the maintained allowlist
20
+ * appropriate to their runtime and content policy. Requiring the sanitizer as an argument prevents
21
+ * `sanitizedHtml(value)` from silently becoming an alias for the unsafe trust operation.
22
+ */
23
+ export function sanitizeHtml(value, sanitizer) {
24
+ if (typeof value !== "string")
25
+ throw new TypeError("HTML to sanitize must be a string");
26
+ if (typeof sanitizer !== "function")
27
+ throw new TypeError("an HTML sanitizer is required");
28
+ const sanitized = sanitizer(value);
29
+ if (typeof sanitized !== "string")
30
+ throw new TypeError("HTML sanitizer must return a string");
31
+ return sanitized;
32
+ }
33
+ /** Explicit sanitized-content vocabulary for callers and security audits. */
34
+ export const sanitizedHtml = sanitizeHtml;
35
+ //# sourceMappingURL=trusted-html.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trusted-html.js","sourceRoot":"","sources":["../src/trusted-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,iGAAiG;AACjG,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,MAAM,IAAI,SAAS,CAAC,+BAA+B,CAAC,CAAA;IACnF,OAAO,KAAoB,CAAA;AAC7B,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,KAAa,EAAE,SAAwB;IAClE,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,MAAM,IAAI,SAAS,CAAC,mCAAmC,CAAC,CAAA;IACvF,IAAI,OAAO,SAAS,KAAK,UAAU;QAAE,MAAM,IAAI,SAAS,CAAC,+BAA+B,CAAC,CAAA;IACzF,MAAM,SAAS,GAAG,SAAS,CAAC,KAAK,CAAC,CAAA;IAClC,IAAI,OAAO,SAAS,KAAK,QAAQ;QAAE,MAAM,IAAI,SAAS,CAAC,qCAAqC,CAAC,CAAA;IAC7F,OAAO,SAA0B,CAAA;AACnC,CAAC;AAED,6EAA6E;AAC7E,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/web",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "description": "Framework-agnostic SSR core for nifra - the render seam + HTML document orchestration.",
5
5
  "keywords": [
6
6
  "bun",
@@ -196,8 +196,8 @@
196
196
  "access": "public"
197
197
  },
198
198
  "dependencies": {
199
- "@nifrajs/core": "3.3.0",
200
- "@nifrajs/island-trigger": "3.3.0"
199
+ "@nifrajs/core": "3.5.0",
200
+ "@nifrajs/island-trigger": "3.5.0"
201
201
  },
202
202
  "peerDependencies": {
203
203
  "@babel/core": "^7.26.0",
package/src/build-plan.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { basename as pathBasename } from "node:path"
2
+ import type { CssLoadingMode } from "./css-contract.ts"
2
3
  import type { ClientModuleGraph } from "./module-graph.ts"
3
4
 
4
5
  export interface BuildManifest {
@@ -19,6 +20,10 @@ export interface BuildManifest {
19
20
  * imported the CSS; the always-safe fallback `createWebApp` links when a route has no per-route entry
20
21
  * in {@link routeStyles}. Omitted when the app imports no CSS. */
21
22
  readonly css?: readonly string[]
23
+ /** Whether the Vite client build kept CSS split by entry/chunk. Absent for the native Bun build. */
24
+ readonly cssCodeSplit?: boolean
25
+ /** How framework-owned SSR stylesheet links should become active. Defaults to `blocking`. */
26
+ readonly cssLoading?: CssLoadingMode
22
27
  /** `routeId → [chain CSS URLs]` - only the stylesheets the matched route's layout chain + own file
23
28
  * actually use (Bun emits a per-entrypoint CSS bundle per route/layout, with shared-component CSS
24
29
  * inlined into each consumer). `createWebApp` links these instead of the aggregate, so a page ships
@@ -150,6 +155,10 @@ export interface Bundler {
150
155
  readonly define?: Readonly<Record<string, string>>
151
156
  readonly publicDir?: string | false
152
157
  readonly publicEnvPrefix?: string
158
+ /** Vite CSS splitting policy; the Bun strategy rejects `false` and otherwise preserves its native behavior. */
159
+ readonly cssCodeSplit?: boolean
160
+ /** Framework-owned stylesheet activation policy; supported by both server strategies. */
161
+ readonly cssLoading?: CssLoadingMode
153
162
  /** Project root (Vite needs it; the Bun strategy ignores it). */
154
163
  readonly root?: string
155
164
  }): Promise<BuildManifest>
@@ -160,8 +169,14 @@ export interface Bundler {
160
169
  readonly outDir: string
161
170
  readonly clientEntry: string
162
171
  readonly target: "browser" | "node" | "bun"
172
+ /** The client build's aggregate stylesheet URLs to bake into the server manifest. */
173
+ readonly styles?: readonly string[]
174
+ /** The client build's per-route stylesheet map to bake into the server manifest. */
175
+ readonly routeStyles?: Readonly<Record<string, readonly string[]>>
163
176
  readonly plugins?: readonly unknown[]
164
177
  readonly define?: Readonly<Record<string, string>>
178
+ /** CSS activation policy to bake into the generated server manifest. */
179
+ readonly cssLoading?: CssLoadingMode
165
180
  readonly root?: string
166
181
  }): Promise<ServerBuild>
167
182
  }
@@ -618,6 +633,8 @@ export interface ManifestDrift {
618
633
  const MANIFEST_ROUTE_ENTRY = /^[ \t]+(["'])([^"'\n]+)\1\s*:\s*(?:m\d+\b|\(\s*\)\s*=>\s*import\b)/gm
619
634
  // The baked client-entry line: `export const clientEntry = "…"`.
620
635
  const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["']/
636
+ // The baked stylesheet activation line: `generateServerManifest` emits this after the style maps.
637
+ const MANIFEST_CSS_LOADING = /export\s+const\s+cssLoading\s*=\s*["'](blocking|deferred)["']/
621
638
 
622
639
  /**
623
640
  * Extract the route-relative file list a committed server-manifest declares, as the same
@@ -642,6 +659,11 @@ export function parseManifestClientEntry(source: string): string | undefined {
642
659
  return MANIFEST_CLIENT_ENTRY.exec(source)?.[1]
643
660
  }
644
661
 
662
+ /** The baked framework stylesheet activation mode, or `undefined` for an older manifest. Pure. */
663
+ export function parseManifestCssLoading(source: string): CssLoadingMode | undefined {
664
+ return MANIFEST_CSS_LOADING.exec(source)?.[1] as CssLoadingMode | undefined
665
+ }
666
+
645
667
  // The baked asset lines `generateServerManifest` emits: `export const styles = […]` then
646
668
  // `export const routeStyles = {…}`, each a JSON literal running to the next `export const`. Extracted
647
669
  // with `indexOf` slicing rather than a lazy `[\s\S]*?` regex: the slice is multi-line by nature (a
@@ -653,7 +675,7 @@ function bakedManifestLiteral(source: string, name: string): string | undefined
653
675
  if (start === -1) return undefined
654
676
  const from = start + opener.length
655
677
  const end = source.indexOf("\nexport const ", from)
656
- return end === -1 ? undefined : source.slice(from, end)
678
+ return source.slice(from, end === -1 ? source.length : end).trim()
657
679
  }
658
680
 
659
681
  /** Parse a baked JSON literal captured from a committed manifest, tolerating a formatter's TRAILING COMMAS
package/src/build-vite.ts CHANGED
@@ -28,6 +28,13 @@ import {
28
28
  publicEnvDefines,
29
29
  } from "./build.ts"
30
30
  import type { BuildManifest, BuildTarget, Bundler, ServerBuild } from "./build-plan.ts"
31
+ import {
32
+ assertCssLoadingCompatible,
33
+ type CssLoadingMode,
34
+ DEFAULT_CSS_LOADING,
35
+ normalizeCssCodeSplit,
36
+ normalizeCssLoading,
37
+ } from "./css-contract.ts"
31
38
  import { discoverRoutes } from "./fs.ts"
32
39
  import { generateClientEntry, generateServerManifest } from "./index.ts"
33
40
  import { viteDedupePackages } from "./internal/identity-policy.ts"
@@ -117,6 +124,17 @@ export interface BuildClientViteOptions extends Omit<BuildClientOptions, "plugin
117
124
  readonly vitePlugins?: readonly unknown[]
118
125
  /** Vite project root (default: the parent of `routesDir`). Manifest keys are relative to it. */
119
126
  readonly root?: string
127
+ /**
128
+ * Vite's CSS output strategy (default `true`). Set to `false` to emit one aggregate stylesheet for
129
+ * the whole client, preventing lazy-route prefetch from attaching additional stylesheets.
130
+ */
131
+ readonly cssCodeSplit?: boolean
132
+ /**
133
+ * Activation policy for framework-owned SSR stylesheet links (default `"blocking"`). `"deferred"`
134
+ * fetches with `media="print"` and the generated client promotes the links before mounting. It must
135
+ * be paired with `cssCodeSplit: false`; split lazy chunks can otherwise attach CSS during prefetch.
136
+ */
137
+ readonly cssLoading?: CssLoadingMode
120
138
  }
121
139
 
122
140
  /**
@@ -130,6 +148,9 @@ export interface BuildClientViteOptions extends Omit<BuildClientOptions, "plugin
130
148
  */
131
149
  export async function buildClientVite(options: BuildClientViteOptions): Promise<BuildManifest> {
132
150
  const { routesDir, outDir, clientModule } = options
151
+ const cssCodeSplit = normalizeCssCodeSplit(options.cssCodeSplit)
152
+ const cssLoading = normalizeCssLoading(options.cssLoading ?? DEFAULT_CSS_LOADING)
153
+ assertCssLoadingCompatible(cssCodeSplit, cssLoading)
133
154
  const root = resolvePath(options.root ?? dirname(routesDir))
134
155
  await assertIdentityParity(root)
135
156
  const resolve = options.resolve ?? ((file: string) => `${routesDir}/${file}`)
@@ -219,6 +240,7 @@ export async function buildClientVite(options: BuildClientViteOptions): Promise<
219
240
  outDir,
220
241
  emptyOutDir: false, // buildTargetWith owns outDir lifecycle; never let Vite wipe sibling files
221
242
  manifest: true,
243
+ cssCodeSplit,
222
244
  minify: options.minify !== false,
223
245
  rollupOptions: {
224
246
  // Keep `node:` builtins as external specifiers so the leak guard sees `node:crypto` and can
@@ -298,14 +320,20 @@ export async function buildClientVite(options: BuildClientViteOptions): Promise<
298
320
  }
299
321
  return [...urls]
300
322
  }
301
- // CSS aggregate - every stylesheet the app emits, the always-safe fallback.
323
+ // CSS aggregate - every stylesheet the app emits, the always-safe fallback. With native Vite
324
+ // `cssCodeSplit: false`, the aggregate is a standalone manifest asset and is not listed in any
325
+ // JavaScript entry's `css` array. Collect both shapes so the framework manifest never loses the only
326
+ // stylesheet in a single-file build.
302
327
  const allCss = new Set<string>()
303
328
  for (const entry of Object.values(viteManifest)) {
329
+ if (entry.file.endsWith(".css")) allCss.add(url(entry.file))
304
330
  for (const css of entry.css ?? []) allCss.add(url(css))
305
331
  }
306
332
  const css = [...allCss]
307
333
  const routeStyles: Record<string, readonly string[]> = {}
308
- if (css.length > 0) {
334
+ // A single aggregate stylesheet is deliberately the route fallback. A per-route `[]` map would
335
+ // suppress it in `createWebApp` and produce unstyled SSR on every route.
336
+ if (css.length > 0 && cssCodeSplit) {
309
337
  for (const route of routeManifest.routes) routeStyles[route.id] = stylesFor(chainFiles(route))
310
338
  if (routeManifest.notFound) routeStyles._404 = stylesFor([routeManifest.notFound.file])
311
339
  }
@@ -326,6 +354,8 @@ export async function buildClientVite(options: BuildClientViteOptions): Promise<
326
354
  entry: url(bootstrap.file),
327
355
  assets: [...assets],
328
356
  routes,
357
+ cssCodeSplit,
358
+ cssLoading,
329
359
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
330
360
  ...(css.length > 0 ? { css } : {}),
331
361
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
@@ -343,6 +373,8 @@ export interface BuildServerViteOptions {
343
373
  readonly clientEntry: string
344
374
  readonly styles?: readonly string[] | undefined
345
375
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
376
+ /** How framework-owned stylesheet links should activate before hydration (default `"blocking"`). */
377
+ readonly cssLoading?: CssLoadingMode
346
378
  readonly resolve?: (file: string) => string
347
379
  readonly manifestFile?: string
348
380
  readonly vitePlugins?: readonly unknown[]
@@ -434,7 +466,7 @@ function edgeBuiltinGuard(): EdgeGuardPlugin {
434
466
  * The client-leak guards are deliberately NOT run here: a server bundle's `node:` imports are legitimate.
435
467
  */
436
468
  export async function buildServerVite(options: BuildServerViteOptions): Promise<ServerBuild> {
437
- const { routesDir, serverEntry, outDir, clientEntry, styles, routeStyles } = options
469
+ const { routesDir, serverEntry, outDir, clientEntry, styles, routeStyles, cssLoading } = options
438
470
  const root = resolvePath(options.root ?? dirname(routesDir))
439
471
  const entryDir = dirname(serverEntry)
440
472
  const manifestFile = options.manifestFile ?? "server-manifest.ts"
@@ -451,7 +483,13 @@ export async function buildServerVite(options: BuildServerViteOptions): Promise<
451
483
  const routeManifest = discoverRoutes(routesDir)
452
484
  writeFileSync(
453
485
  `${entryDir}/${manifestFile}`,
454
- generateServerManifest(routeManifest, { resolve, clientEntry, styles, routeStyles }),
486
+ generateServerManifest(routeManifest, {
487
+ resolve,
488
+ clientEntry,
489
+ styles,
490
+ routeStyles,
491
+ ...(cssLoading !== undefined ? { cssLoading } : {}),
492
+ }),
455
493
  )
456
494
 
457
495
  // Pin the mode to match NODE_ENV - the react plugin's JSX runtime (jsx vs jsxDEV) follows it, and a
@@ -536,6 +574,8 @@ export const viteBundler: Bundler = {
536
574
  ...(input.plugins ? { vitePlugins: input.plugins } : {}),
537
575
  ...(input.conditions ? { conditions: input.conditions } : {}),
538
576
  ...(input.define ? { define: input.define } : {}),
577
+ ...(input.cssCodeSplit !== undefined ? { cssCodeSplit: input.cssCodeSplit } : {}),
578
+ ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
539
579
  ...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
540
580
  ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
541
581
  ...(input.root ? { root: input.root } : {}),
@@ -547,8 +587,11 @@ export const viteBundler: Bundler = {
547
587
  outDir: input.outDir,
548
588
  clientEntry: input.clientEntry,
549
589
  target: input.target,
590
+ ...(input.styles !== undefined ? { styles: input.styles } : {}),
591
+ ...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
550
592
  ...(input.plugins ? { vitePlugins: input.plugins } : {}),
551
593
  ...(input.define ? { define: input.define } : {}),
594
+ ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
552
595
  ...(input.root ? { root: input.root } : {}),
553
596
  }),
554
597
  }
package/src/build.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  formatNodeBuiltinLeak,
32
32
  formatServerOnlyLeak,
33
33
  parseManifestClientEntry,
34
+ parseManifestCssLoading,
34
35
  parseManifestRouteStyles,
35
36
  parseManifestStyles,
36
37
  planBuildTarget,
@@ -38,6 +39,7 @@ import {
38
39
  type SizeReport,
39
40
  } from "./build-plan.ts"
40
41
  import { sanitizeOutputNames } from "./chunk-names.ts"
42
+ import { type CssLoadingMode, normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.ts"
41
43
  import { discoverRoutes } from "./fs.ts"
42
44
  import { generateClientEntry, generateServerManifest } from "./index.ts"
43
45
  import { dedupePolicyFor } from "./internal/identity-policy.ts"
@@ -350,11 +352,13 @@ export function resyncServerManifestSource(
350
352
  manifest: Parameters<typeof generateServerManifest>[0],
351
353
  routesPrefix: string,
352
354
  ): string {
355
+ const cssLoading = parseManifestCssLoading(source)
353
356
  return generateServerManifest(manifest, {
354
357
  resolve: (file) => `${routesPrefix}${file}`,
355
358
  clientEntry: parseManifestClientEntry(source) ?? "",
356
359
  styles: parseManifestStyles(source),
357
360
  routeStyles: parseManifestRouteStyles(source),
361
+ ...(cssLoading !== undefined ? { cssLoading } : {}),
358
362
  lazy: MANIFEST_IS_LAZY.test(source),
359
363
  })
360
364
  }
@@ -626,6 +630,8 @@ export interface BuildServerOptions {
626
630
  readonly styles?: readonly string[] | undefined
627
631
  /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked alongside `styles`. */
628
632
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
633
+ /** How framework-owned stylesheet links should activate before hydration (default `"blocking"`). */
634
+ readonly cssLoading?: CssLoadingMode
629
635
  /** Route/layout file → import specifier in the generated manifest (default: a relative path from the
630
636
  * manifest's location - written next to `serverEntry` - to `routesDir`). */
631
637
  readonly resolve?: (file: string) => string
@@ -847,7 +853,7 @@ const solidWebServerPlugin = (from: string): BunPlugin => ({
847
853
  * silently ships a broken worker.
848
854
  */
849
855
  export async function buildServer(options: BuildServerOptions): Promise<ServerBuild> {
850
- const { routesDir, serverEntry, outDir, clientEntry, styles, routeStyles } = options
856
+ const { routesDir, serverEntry, outDir, clientEntry, styles, routeStyles, cssLoading } = options
851
857
  const entryDir = dirname(serverEntry)
852
858
  const manifestFile = options.manifestFile ?? "server-manifest.ts"
853
859
  // Default: import routes relative from the generated manifest (next to serverEntry) to routesDir.
@@ -866,7 +872,14 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
866
872
  const manifest = discoverRoutes(routesDir)
867
873
  writeFileSync(
868
874
  `${entryDir}/${manifestFile}`,
869
- generateServerManifest(manifest, { resolve, clientEntry, styles, routeStyles, lazy }),
875
+ generateServerManifest(manifest, {
876
+ resolve,
877
+ clientEntry,
878
+ styles,
879
+ routeStyles,
880
+ ...(cssLoading !== undefined ? { cssLoading } : {}),
881
+ lazy,
882
+ }),
870
883
  )
871
884
 
872
885
  const result = await Bun.build({
@@ -963,7 +976,9 @@ export function generateServerEntry(options: {
963
976
  if (backendImport !== undefined) {
964
977
  lines.push(`import { backend } from ${JSON.stringify(backendImport)}`)
965
978
  }
966
- lines.push('import { clientEntry, manifest, styles, routeStyles } from "./server-manifest"')
979
+ lines.push(
980
+ 'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"',
981
+ )
967
982
  // cf-pages/vercel/deno need the fetch-handler shape; bun/node call app.fetch directly.
968
983
  const usesToFetch = target === "cf-pages" || target === "vercel" || target === "deno"
969
984
  if (usesToFetch) lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
@@ -977,6 +992,7 @@ export function generateServerEntry(options: {
977
992
  " clientEntry,",
978
993
  " styles,",
979
994
  " routeStyles,",
995
+ " cssLoading,",
980
996
  ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []),
981
997
  ` title: ${JSON.stringify(title)},`,
982
998
  "})",
@@ -1157,6 +1173,18 @@ export interface BuildTargetOptions {
1157
1173
  readonly publicDir?: string | false
1158
1174
  /** Prefix of environment variables allowed into the client bundle (default `"PUBLIC_"`). */
1159
1175
  readonly publicEnvPrefix?: string
1176
+ /**
1177
+ * Vite-only CSS output policy. `false` emits one aggregate stylesheet. The native Bun build does not
1178
+ * support this switch and fails closed if it is supplied instead of silently ignoring it.
1179
+ */
1180
+ readonly cssCodeSplit?: boolean
1181
+ /**
1182
+ * Activation policy for framework-owned SSR stylesheet links. Defaults to `"blocking"`; `"deferred"`
1183
+ * fetches with `media="print"` and promotes before hydration. With Vite, pair it with
1184
+ * `cssCodeSplit: false`; the native Bun build supports the activation policy but keeps its existing
1185
+ * CSS output strategy.
1186
+ */
1187
+ readonly cssLoading?: CssLoadingMode
1160
1188
  /** Document `<title>` for the generated server entry. */
1161
1189
  readonly title?: string
1162
1190
  }
@@ -1198,17 +1226,25 @@ export interface BuildTargetResult {
1198
1226
  */
1199
1227
  /** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
1200
1228
  export const bunBundler: Bundler = {
1201
- buildClient: (input) =>
1202
- buildClient({
1229
+ buildClient: (input) => {
1230
+ const cssCodeSplit = normalizeCssCodeSplit(input.cssCodeSplit)
1231
+ if (cssCodeSplit === false) {
1232
+ throw new Error(
1233
+ "[nifra/web] cssCodeSplit: false is a Vite-only production option. Use buildTargetVite or remove it for the native Bun build.",
1234
+ )
1235
+ }
1236
+ return buildClient({
1203
1237
  routesDir: input.routesDir,
1204
1238
  outDir: input.outDir,
1205
1239
  clientModule: input.clientModule,
1206
1240
  ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1207
1241
  ...(input.conditions ? { conditions: input.conditions } : {}),
1208
1242
  ...(input.define ? { define: input.define } : {}),
1243
+ ...(input.cssCodeSplit !== undefined ? { cssCodeSplit } : {}),
1209
1244
  ...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
1210
1245
  ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
1211
- }),
1246
+ })
1247
+ },
1212
1248
  buildServer: (input) =>
1213
1249
  buildServer({
1214
1250
  routesDir: input.routesDir,
@@ -1216,8 +1252,11 @@ export const bunBundler: Bundler = {
1216
1252
  outDir: input.outDir,
1217
1253
  clientEntry: input.clientEntry,
1218
1254
  target: input.target,
1255
+ ...(input.styles !== undefined ? { styles: input.styles } : {}),
1256
+ ...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
1219
1257
  ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1220
1258
  ...(input.define ? { define: input.define } : {}),
1259
+ ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
1221
1260
  }),
1222
1261
  }
1223
1262
 
@@ -1241,6 +1280,10 @@ export async function buildTargetWith(
1241
1280
  bundler: Bundler,
1242
1281
  ): Promise<BuildTargetResult> {
1243
1282
  const { routesDir, outDir, workDir } = options
1283
+ const requestedCssCodeSplit =
1284
+ options.cssCodeSplit === undefined ? undefined : normalizeCssCodeSplit(options.cssCodeSplit)
1285
+ const requestedCssLoading =
1286
+ options.cssLoading === undefined ? undefined : normalizeCssLoading(options.cssLoading)
1244
1287
  const targetPlan = planBuildTarget(target, outDir)
1245
1288
  const { rmSync } = await import("node:fs")
1246
1289
  rmSync(outDir, { recursive: true, force: true })
@@ -1259,8 +1302,16 @@ export async function buildTargetWith(
1259
1302
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1260
1303
  publicDir: false,
1261
1304
  ...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
1305
+ ...(requestedCssCodeSplit !== undefined ? { cssCodeSplit: requestedCssCodeSplit } : {}),
1306
+ ...(requestedCssLoading !== undefined ? { cssLoading: requestedCssLoading } : {}),
1262
1307
  root: resolvePath(dirname(routesDir)),
1263
1308
  })
1309
+ // Bun's client builder has no CSS-loading field, but the server-side document policy is bundler
1310
+ // independent. Carry the requested policy into the result so static prerender callbacks and the
1311
+ // generated server entry see the same choice on both build strategies.
1312
+ if (requestedCssLoading !== undefined && client.cssLoading !== requestedCssLoading) {
1313
+ client = { ...client, cssLoading: requestedCssLoading }
1314
+ }
1264
1315
  const publicDir =
1265
1316
  options.publicDir === false
1266
1317
  ? undefined
@@ -1269,6 +1320,11 @@ export async function buildTargetWith(
1269
1320
  publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
1270
1321
  if (publicFiles.length > 0) {
1271
1322
  client = { ...client, publicFiles }
1323
+ }
1324
+ // `buildClient` writes its first manifest before the target orchestration knows the activation policy
1325
+ // (the native Bun client builder has no CSS-loading option). Persist the target-level additions even
1326
+ // when the app has no public/ files; otherwise the returned manifest and the deploy artifact disagree.
1327
+ if (requestedCssLoading !== undefined || publicFiles.length > 0) {
1272
1328
  writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
1273
1329
  }
1274
1330
 
@@ -1330,8 +1386,15 @@ export async function buildTargetWith(
1330
1386
  outDir: `${workDir}/server`,
1331
1387
  clientEntry: client.entry,
1332
1388
  target: targetPlan.serverTarget,
1389
+ ...(client.css !== undefined ? { styles: client.css } : {}),
1390
+ ...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
1333
1391
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1334
1392
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1393
+ ...(client.cssLoading !== undefined
1394
+ ? { cssLoading: client.cssLoading }
1395
+ : requestedCssLoading !== undefined
1396
+ ? { cssLoading: requestedCssLoading }
1397
+ : {}),
1335
1398
  root: resolvePath(dirname(routesDir)),
1336
1399
  })
1337
1400
 
package/src/client.ts CHANGED
@@ -53,6 +53,146 @@ export interface InstallHistoryOptions {
53
53
  readonly fallback?: (path: string) => void
54
54
  }
55
55
 
56
+ /** Options for {@link waitForStyles}. */
57
+ export interface WaitForStylesOptions {
58
+ /**
59
+ * Maximum time to wait for one framework-owned deferred stylesheet before hydrating anyway.
60
+ * Defaults to 4000 ms and is capped at 30000 ms so a bad value cannot leave the app inert forever.
61
+ */
62
+ readonly timeoutMs?: number
63
+ }
64
+
65
+ const DEFAULT_CSS_WAIT_MS = 4_000
66
+ const MAX_CSS_WAIT_MS = 30_000
67
+ type CssLinkState = "loaded" | "error" | "timeout"
68
+
69
+ // Per-link promises keep repeated calls idempotent without retaining an entire document after a hard
70
+ // navigation. A WeakMap also lets tests and embedded documents replace `globalThis.document` safely.
71
+ const stylesheetWaits = new WeakMap<HTMLLinkElement, Promise<void>>()
72
+
73
+ const cssLinkStateOf = (link: HTMLLinkElement): CssLinkState | undefined => {
74
+ const state = link.getAttribute("data-nifra-css-state")
75
+ return state === "loaded" || state === "error" || state === "timeout" ? state : undefined
76
+ }
77
+
78
+ const hasLoadedStylesheet = (link: HTMLLinkElement): boolean => {
79
+ // Accessing `sheet` is safe for cross-origin stylesheets, but a browser may still throw while it is
80
+ // tearing down a document. Treat that as "not observed yet"; the load/error listener or the bounded
81
+ // timer is the authoritative fallback.
82
+ try {
83
+ return link.sheet !== null
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ const stylesheetLabel = (link: HTMLLinkElement): string =>
90
+ link.getAttribute("href") ?? link.href ?? "<unknown>"
91
+
92
+ /**
93
+ * Wait for one server-emitted deferred stylesheet. The promise ALWAYS settles: a failed or slow CSS
94
+ * request must not leave the server-rendered page's loader visible forever. `media="all"` is applied on
95
+ * every terminal state so a late network completion cannot leave a link permanently in print media.
96
+ */
97
+ const waitForStylesheet = (link: HTMLLinkElement, timeoutMs: number): Promise<void> =>
98
+ new Promise<void>((resolve) => {
99
+ let settled = false
100
+ let timer: ReturnType<typeof setTimeout> | undefined
101
+
102
+ const cleanup = (): void => {
103
+ link.removeEventListener("load", onLoad)
104
+ link.removeEventListener("error", onError)
105
+ if (timer !== undefined) clearTimeout(timer)
106
+ }
107
+ const finish = (state: CssLinkState): void => {
108
+ if (settled) return
109
+ settled = true
110
+ cleanup()
111
+ link.setAttribute("data-nifra-css-state", state)
112
+ // Only the coordinator promotes the framework-owned link, after the resource has settled.
113
+ link.media = "all"
114
+ resolve()
115
+ }
116
+ const onLoad = (): void => finish("loaded")
117
+ const onError = (): void => {
118
+ console.error(`[nifra/web] stylesheet failed to load: ${stylesheetLabel(link)}`)
119
+ finish("error")
120
+ }
121
+
122
+ const prior = cssLinkStateOf(link)
123
+ if (prior !== undefined) {
124
+ finish(prior)
125
+ return
126
+ }
127
+ if (hasLoadedStylesheet(link)) {
128
+ finish("loaded")
129
+ return
130
+ }
131
+
132
+ // Attach listeners before the second loaded-sheet check: a cached resource can transition between
133
+ // the first check and listener registration, and the second check closes that small race.
134
+ link.addEventListener("load", onLoad, { once: true })
135
+ link.addEventListener("error", onError, { once: true })
136
+ timer = setTimeout(() => {
137
+ console.warn(
138
+ `[nifra/web] stylesheet load timed out after ${timeoutMs}ms: ${stylesheetLabel(link)}`,
139
+ )
140
+ finish("timeout")
141
+ }, timeoutMs)
142
+ if (hasLoadedStylesheet(link)) finish("loaded")
143
+ })
144
+
145
+ const validateCssWaitMs = (value: number): number => {
146
+ if (!Number.isFinite(value) || value <= 0) {
147
+ throw new RangeError(
148
+ `[nifra/web] css wait timeout must be a finite positive number, received ${value}`,
149
+ )
150
+ }
151
+ return Math.min(value, MAX_CSS_WAIT_MS)
152
+ }
153
+
154
+ /**
155
+ * Wait for the framework-owned deferred stylesheet links in the initial document, then promote them to
156
+ * `media="all"`. It is safe to call more than once: repeated calls for one document share the same
157
+ * per-link waits, and duplicate framework links are removed before waiting. Blocking links are not
158
+ * touched.
159
+ *
160
+ * This function lives in the browser-only `@nifrajs/web/client` entry. It is nevertheless defensive
161
+ * when imported in a non-DOM environment so an accidental import cannot crash a server process.
162
+ */
163
+ export function waitForStyles(options: WaitForStylesOptions = {}): Promise<void> {
164
+ if (typeof document === "undefined") return Promise.resolve()
165
+ const timeoutMs = validateCssWaitMs(options.timeoutMs ?? DEFAULT_CSS_WAIT_MS)
166
+ const links = Array.from(
167
+ document.querySelectorAll<HTMLLinkElement>('link[data-nifra-css="deferred"]'),
168
+ ).filter(
169
+ (link) =>
170
+ link.getAttribute("data-nifra-css") === "deferred" && link.rel.toLowerCase() === "stylesheet",
171
+ )
172
+ const unique: HTMLLinkElement[] = []
173
+ const seen = new Set<string>()
174
+ for (const link of links) {
175
+ const key = stylesheetLabel(link)
176
+ if (seen.has(key)) {
177
+ // Only remove a duplicate carrying the framework marker; author-owned links are outside this
178
+ // selector and cannot be affected. Keeping the first link preserves its event state and cache.
179
+ link.remove()
180
+ continue
181
+ }
182
+ seen.add(key)
183
+ unique.push(link)
184
+ }
185
+ if (unique.length === 0) return Promise.resolve()
186
+ const promises = unique.map((link) => {
187
+ const existing = stylesheetWaits.get(link)
188
+ if (existing !== undefined) return existing
189
+ const promise = waitForStylesheet(link, timeoutMs)
190
+ stylesheetWaits.set(link, promise)
191
+ return promise
192
+ })
193
+ return Promise.all(promises).then(() => undefined)
194
+ }
195
+
56
196
  /**
57
197
  * Attach history + link interception to a router. Returns a teardown function that removes the
58
198
  * listeners. A data-fetch failure during a client navigation falls back to a full-page load, so