@nifrajs/web 3.2.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.
- package/dist/build-plan.d.ts +17 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +7 -1
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +14 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +29 -4
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +15 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +57 -15
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +18 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +115 -0
- package/dist/client.js.map +1 -1
- package/dist/css-contract.d.ts +21 -0
- package/dist/css-contract.d.ts.map +1 -0
- package/dist/css-contract.js +28 -0
- package/dist/css-contract.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +3 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +11 -6
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/page-execution.d.ts +57 -0
- package/dist/internal/page-execution.d.ts.map +1 -0
- package/dist/internal/page-execution.js +555 -0
- package/dist/internal/page-execution.js.map +1 -0
- package/dist/internal/render-document.d.ts +12 -2
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +28 -5
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +3 -0
- package/dist/internal/request-url.d.ts.map +1 -0
- package/dist/internal/request-url.js +14 -0
- package/dist/internal/request-url.js.map +1 -0
- package/dist/internal/web-app.d.ts +20 -1
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +37 -734
- package/dist/internal/web-app.js.map +1 -1
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +39 -4
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts +3 -0
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +6 -3
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +69 -44
- package/dist/public-dir.js.map +1 -1
- package/dist/trusted-html.d.ts +34 -0
- package/dist/trusted-html.d.ts.map +1 -0
- package/dist/trusted-html.js +35 -0
- package/dist/trusted-html.js.map +1 -0
- package/package.json +3 -3
- package/src/build-plan.ts +23 -1
- package/src/build-vite.ts +47 -4
- package/src/build.ts +69 -6
- package/src/client.ts +140 -0
- package/src/css-contract.ts +44 -0
- package/src/index.ts +15 -0
- package/src/internal/codegen.ts +20 -6
- package/src/internal/page-execution.ts +761 -0
- package/src/internal/render-document.ts +39 -6
- package/src/internal/request-url.ts +14 -0
- package/src/internal/web-app.ts +68 -835
- package/src/isr.ts +42 -3
- package/src/llms-txt.ts +16 -4
- package/src/manifest.ts +1 -1
- package/src/public-dir.ts +70 -45
- 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
|
+
"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.
|
|
200
|
-
"@nifrajs/island-trigger": "3.
|
|
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 ?
|
|
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
|
-
|
|
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, {
|
|
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, {
|
|
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(
|
|
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
|
-
|
|
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
|