@nifrajs/web 5.1.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3042 -0
- package/dist/build-plan.d.ts +7 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +19 -0
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +1 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +51 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +23 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +224 -16
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -7
- package/dist/client.js.map +1 -1
- package/dist/csp.d.ts +2 -0
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +2 -0
- package/dist/csp.js.map +1 -1
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +23 -3
- package/dist/deferred.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +6 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +23 -5
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/copy-split.d.ts +27 -0
- package/dist/internal/copy-split.d.ts.map +1 -0
- package/dist/internal/copy-split.js +173 -0
- package/dist/internal/copy-split.js.map +1 -0
- package/dist/internal/deferred-free.d.ts +3 -0
- package/dist/internal/deferred-free.d.ts.map +1 -0
- package/dist/internal/deferred-free.js +15 -0
- package/dist/internal/deferred-free.js.map +1 -0
- package/dist/internal/module-preparation.d.ts +7 -0
- package/dist/internal/module-preparation.d.ts.map +1 -0
- package/dist/internal/module-preparation.js +24 -0
- package/dist/internal/module-preparation.js.map +1 -0
- package/dist/internal/output-guard.d.ts +2 -0
- package/dist/internal/output-guard.d.ts.map +1 -1
- package/dist/internal/output-guard.js +51 -9
- package/dist/internal/output-guard.js.map +1 -1
- package/dist/internal/page-execution.d.ts +20 -1
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +115 -50
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +3 -1
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/prerendered-pages.d.ts +42 -0
- package/dist/internal/prerendered-pages.d.ts.map +1 -0
- package/dist/internal/prerendered-pages.js +92 -0
- package/dist/internal/prerendered-pages.js.map +1 -0
- package/dist/internal/public-url.d.ts +7 -0
- package/dist/internal/public-url.d.ts.map +1 -1
- package/dist/internal/public-url.js +13 -0
- package/dist/internal/public-url.js.map +1 -1
- package/dist/internal/render-document.d.ts +11 -2
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +131 -41
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +4 -0
- package/dist/internal/request-url.d.ts.map +1 -1
- package/dist/internal/request-url.js +24 -0
- package/dist/internal/request-url.js.map +1 -1
- package/dist/internal/secret-scan.d.ts +4 -0
- package/dist/internal/secret-scan.d.ts.map +1 -1
- package/dist/internal/secret-scan.js +8 -1
- package/dist/internal/secret-scan.js.map +1 -1
- package/dist/internal/typescript-api.d.ts +4 -0
- package/dist/internal/typescript-api.d.ts.map +1 -0
- package/dist/internal/typescript-api.js +8 -0
- package/dist/internal/typescript-api.js.map +1 -0
- package/dist/internal/vite-plugin-compat.d.ts +28 -0
- package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
- package/dist/internal/vite-plugin-compat.js +82 -0
- package/dist/internal/vite-plugin-compat.js.map +1 -0
- package/dist/internal/web-app.d.ts +45 -0
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +32 -1
- package/dist/internal/web-app.js.map +1 -1
- package/dist/isr.d.ts +1 -0
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +7 -4
- package/dist/isr.js.map +1 -1
- package/dist/plugins/import-glob.d.ts +20 -0
- package/dist/plugins/import-glob.d.ts.map +1 -0
- package/dist/plugins/import-glob.js +401 -0
- package/dist/plugins/import-glob.js.map +1 -0
- package/dist/plugins/kit.d.ts +11 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +32 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/stylex.d.ts.map +1 -1
- package/dist/plugins/stylex.js +2 -2
- package/dist/plugins/stylex.js.map +1 -1
- package/dist/prerender.d.ts +4 -0
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +9 -1
- package/dist/prerender.js.map +1 -1
- package/dist/public-dir.d.ts +15 -0
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +91 -12
- package/dist/public-dir.js.map +1 -1
- package/dist/render-seam.d.ts +42 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +33 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +3 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +4 -4
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -1
- package/dist/server-components/typescript.d.ts.map +1 -1
- package/dist/server-components/typescript.js +2 -7
- package/dist/server-components/typescript.js.map +1 -1
- package/dist/vite.d.ts +7 -28
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +130 -123
- package/dist/vite.js.map +1 -1
- package/package.json +5 -4
- package/src/build-plan.ts +21 -0
- package/src/build-vite.ts +60 -3
- package/src/build.ts +283 -39
- package/src/client.ts +15 -7
- package/src/csp.ts +4 -0
- package/src/deferred.ts +20 -3
- package/src/index.ts +9 -0
- package/src/internal/codegen.ts +32 -3
- package/src/internal/copy-split.ts +220 -0
- package/src/internal/deferred-free.ts +15 -0
- package/src/internal/module-preparation.ts +30 -0
- package/src/internal/output-guard.ts +51 -9
- package/src/internal/page-execution.ts +137 -53
- package/src/internal/parity.ts +2 -1
- package/src/internal/prerendered-pages.ts +124 -0
- package/src/internal/public-url.ts +13 -0
- package/src/internal/render-document.ts +179 -58
- package/src/internal/request-url.ts +24 -0
- package/src/internal/secret-scan.ts +9 -1
- package/src/internal/typescript-api.ts +11 -0
- package/src/internal/vite-plugin-compat.ts +81 -0
- package/src/internal/web-app.ts +82 -2
- package/src/isr.ts +7 -4
- package/src/plugins/import-glob.ts +392 -0
- package/src/plugins/kit.ts +36 -0
- package/src/plugins/stylex.ts +2 -1
- package/src/prerender.ts +13 -1
- package/src/public-dir.ts +107 -12
- package/src/render-seam.ts +65 -2
- package/src/router.ts +7 -6
- package/src/search.ts +1 -0
- package/src/server-components/typescript.ts +2 -10
- package/src/vite.ts +133 -135
package/src/build.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
formatServerOnlyLeak,
|
|
40
40
|
parseBuildTarget,
|
|
41
41
|
parseManifestClientEntry,
|
|
42
|
+
parseManifestCopyChunks,
|
|
42
43
|
parseManifestCssLoading,
|
|
43
44
|
parseManifestRouteStyles,
|
|
44
45
|
parseManifestStyles,
|
|
@@ -50,6 +51,7 @@ import { sanitizeOutputNames } from "./chunk-names.ts"
|
|
|
50
51
|
import { type CssLoadingMode, normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.ts"
|
|
51
52
|
import { discoverRoutes } from "./fs.ts"
|
|
52
53
|
import { generateClientEntry, generateServerManifest } from "./index.ts"
|
|
54
|
+
import { loadCopySplit } from "./internal/copy-split.ts"
|
|
53
55
|
import { assertDisposableDir } from "./internal/disposable-dir.ts"
|
|
54
56
|
import { entryChunks } from "./internal/entry-chunks.ts"
|
|
55
57
|
import { dedupePolicyFor } from "./internal/identity-policy.ts"
|
|
@@ -58,8 +60,12 @@ import {
|
|
|
58
60
|
assertIdentityParity,
|
|
59
61
|
collectDevelopmentParityInput,
|
|
60
62
|
} from "./internal/parity.ts"
|
|
63
|
+
import {
|
|
64
|
+
type ServerPrerenderedPage,
|
|
65
|
+
servablePrerenderedPages,
|
|
66
|
+
} from "./internal/prerendered-pages.ts"
|
|
61
67
|
import { privateEnvCheck } from "./internal/private-env.ts"
|
|
62
|
-
import { publicUrlPath } from "./internal/public-url.ts"
|
|
68
|
+
import { isHiddenPublicPath, publicUrlPath } from "./internal/public-url.ts"
|
|
63
69
|
import {
|
|
64
70
|
assertNoSecrets,
|
|
65
71
|
buildEnvironment,
|
|
@@ -69,6 +75,7 @@ import {
|
|
|
69
75
|
originName,
|
|
70
76
|
publicScanFiles,
|
|
71
77
|
type SecretExemption,
|
|
78
|
+
SecretScanError,
|
|
72
79
|
scanForSecrets,
|
|
73
80
|
textOf,
|
|
74
81
|
} from "./internal/secret-scan.ts"
|
|
@@ -89,9 +96,10 @@ import {
|
|
|
89
96
|
verifyServerGraph,
|
|
90
97
|
} from "./internal/zone-graph.ts"
|
|
91
98
|
import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
|
|
99
|
+
import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.ts"
|
|
92
100
|
import { zoneGuardPlugin } from "./plugins/zone-guard.ts"
|
|
93
101
|
// `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
|
|
94
|
-
import { prerenderRoutes } from "./prerender.ts"
|
|
102
|
+
import { type PrerenderOptions, prerenderRoutes } from "./prerender.ts"
|
|
95
103
|
import { CONTENT_TYPES, IMMUTABLE, ONE_DAY } from "./public-dir.ts"
|
|
96
104
|
import { createZoneClassifier } from "./zones.ts"
|
|
97
105
|
|
|
@@ -125,6 +133,8 @@ interface BunMetafile {
|
|
|
125
133
|
>
|
|
126
134
|
}
|
|
127
135
|
|
|
136
|
+
// A page a bun or node build answers from its file (`generateServerEntry`'s `prerenderedPages`).
|
|
137
|
+
export type { ServerPrerenderedPage } from "./internal/prerendered-pages.ts"
|
|
128
138
|
// The secret scan every client build, `public/` copy and prerender runs.
|
|
129
139
|
export type { SecretExemption, SecretRule } from "./internal/secret-scan.ts"
|
|
130
140
|
// Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
|
|
@@ -186,6 +196,12 @@ export interface BuildClientOptions {
|
|
|
186
196
|
* variable. Each names its rule, its file (or, for an environment value, its variable) and a reason.
|
|
187
197
|
*/
|
|
188
198
|
readonly secretExemptions?: readonly SecretExemption[]
|
|
199
|
+
/**
|
|
200
|
+
* Move copy tables (`@nifrajs/i18n/copy`) into one chunk per locale, so a page's JavaScript carries
|
|
201
|
+
* only the copy of the locale it renders in (default: on when minifying). The server bundle keeps
|
|
202
|
+
* every table whole; the server entry passes the manifest's `copyChunks` to `createWebApp`.
|
|
203
|
+
*/
|
|
204
|
+
readonly copySplit?: boolean
|
|
189
205
|
}
|
|
190
206
|
|
|
191
207
|
/**
|
|
@@ -331,7 +347,9 @@ export function cloudflareRouteRules(
|
|
|
331
347
|
|
|
332
348
|
/** The built asset map - the server reads `entry` for the client script + serves `assets`. */
|
|
333
349
|
/**
|
|
334
|
-
* Copy `from` into `to`, returning the URL paths copied (sorted).
|
|
350
|
+
* Copy `from` into `to`, returning the URL paths copied (sorted). Hidden entries other than
|
|
351
|
+
* `.well-known` are skipped, as development never serves them: every target ships this list, and a
|
|
352
|
+
* CDN-hosted one serves whatever was copied.
|
|
335
353
|
*
|
|
336
354
|
* Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
|
|
337
355
|
* `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
|
|
@@ -342,6 +360,7 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
|
|
|
342
360
|
const sourceReal = await realpath(source)
|
|
343
361
|
const copied: string[] = []
|
|
344
362
|
for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
|
|
363
|
+
if (isHiddenPublicPath(rel)) continue
|
|
345
364
|
const candidate = join(source, rel)
|
|
346
365
|
const candidateStat = await lstat(candidate)
|
|
347
366
|
if (candidateStat.isSymbolicLink()) {
|
|
@@ -374,12 +393,14 @@ export function resyncServerManifestSource(
|
|
|
374
393
|
routesPrefix: string,
|
|
375
394
|
): string {
|
|
376
395
|
const cssLoading = parseManifestCssLoading(source)
|
|
396
|
+
const copyChunks = parseManifestCopyChunks(source)
|
|
377
397
|
return generateServerManifest(manifest, {
|
|
378
398
|
resolve: (file) => `${routesPrefix}${file}`,
|
|
379
399
|
clientEntry: parseManifestClientEntry(source) ?? "",
|
|
380
400
|
styles: parseManifestStyles(source),
|
|
381
401
|
routeStyles: parseManifestRouteStyles(source),
|
|
382
402
|
...(cssLoading !== undefined ? { cssLoading } : {}),
|
|
403
|
+
...(copyChunks !== undefined ? { copyChunks } : {}),
|
|
383
404
|
lazy: MANIFEST_IS_LAZY.test(source),
|
|
384
405
|
})
|
|
385
406
|
}
|
|
@@ -426,6 +447,10 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
426
447
|
// The bootstrap's filename is `_nifra`-namespaced (not `entry.ts`) so its `[name]` can't collide with
|
|
427
448
|
// a user route named `entry.tsx` when the CSS mapping below excludes the bootstrap's aggregate CSS.
|
|
428
449
|
const mode = options.minify === false ? "development" : "production"
|
|
450
|
+
const copySplit =
|
|
451
|
+
(options.copySplit ?? mode === "production")
|
|
452
|
+
? await loadCopySplit(root, { skip: [outDir] })
|
|
453
|
+
: undefined
|
|
429
454
|
// PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
|
|
430
455
|
// falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
|
|
431
456
|
// in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
|
|
@@ -442,7 +467,11 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
442
467
|
// an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
|
|
443
468
|
writeFileSync(
|
|
444
469
|
entryFile,
|
|
445
|
-
`globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, {
|
|
470
|
+
`globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, {
|
|
471
|
+
clientModule,
|
|
472
|
+
resolve,
|
|
473
|
+
copyChunks: copySplit !== undefined,
|
|
474
|
+
})}`,
|
|
446
475
|
)
|
|
447
476
|
|
|
448
477
|
// Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
|
|
@@ -496,6 +525,10 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
496
525
|
svelteDedupePlugin(routesDir),
|
|
497
526
|
serverFnStubPlugin(),
|
|
498
527
|
...(options.plugins ?? []),
|
|
528
|
+
// Before the copy split: it claims a module that calls the glob and reads it through the split.
|
|
529
|
+
importGlobPlugin({ root }),
|
|
530
|
+
// Last: it rewrites a module no other plugin claims, and the ones that do read through it.
|
|
531
|
+
...(copySplit === undefined ? [] : [copySplit.plugin]),
|
|
499
532
|
],
|
|
500
533
|
...(options.conditions ? { conditions: [...options.conditions] } : {}),
|
|
501
534
|
// Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
|
|
@@ -550,6 +583,11 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
550
583
|
return { name: out.path, kind: "code", text: await out.text() }
|
|
551
584
|
}),
|
|
552
585
|
)
|
|
586
|
+
assertNoImportGlob(
|
|
587
|
+
emitted.flatMap((file) =>
|
|
588
|
+
file.kind === "code" ? [[relative(cwd, file.name), file.text ?? ""] as const] : [],
|
|
589
|
+
),
|
|
590
|
+
)
|
|
553
591
|
const clientGraph = withEmittedImports(fromBunMetafile(clientMeta), emitted, publicPath)
|
|
554
592
|
const verdict = verifyClientGraph(clientGraph, {
|
|
555
593
|
classifier,
|
|
@@ -596,6 +634,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
596
634
|
if (unaccounted !== undefined) throw new Error(unaccounted)
|
|
597
635
|
|
|
598
636
|
const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
|
|
637
|
+
const copy = copySplit?.chunks(publicPath)
|
|
599
638
|
const { sources, originsOf } = graphScanInput(clientGraph, bunModuleSource(cwd), classifier)
|
|
600
639
|
const secrets = formatSecretFindings(
|
|
601
640
|
scanForSecrets({
|
|
@@ -606,6 +645,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
606
645
|
const name = file.name.replace(/^\.\//, "")
|
|
607
646
|
return emittedScanFiles(name, file.text, originsOf(name))
|
|
608
647
|
}),
|
|
648
|
+
...(copy?.files ?? []).flatMap((file) => emittedScanFiles(file.name, file.text)),
|
|
609
649
|
...(publicDir !== undefined && existsSync(publicDir)
|
|
610
650
|
? publicScanFiles(publicDir, originName(root, resolvePath(publicDir)))
|
|
611
651
|
: []),
|
|
@@ -624,6 +664,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
624
664
|
return { kind: out.kind, path }
|
|
625
665
|
}),
|
|
626
666
|
)
|
|
667
|
+
for (const file of copy?.files ?? []) writeFileSync(join(outDir, file.name), file.text)
|
|
627
668
|
|
|
628
669
|
// Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
|
|
629
670
|
// rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
|
|
@@ -750,13 +791,15 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
750
791
|
const publicFiles =
|
|
751
792
|
publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
|
|
752
793
|
|
|
794
|
+
const copyChunks = copy?.urls ?? {}
|
|
753
795
|
const manifest: BuildManifest = {
|
|
754
796
|
entry: entryUrl,
|
|
755
|
-
assets: outputs.map((o) => toUrl(o.path)),
|
|
797
|
+
assets: [...outputs.map((o) => toUrl(o.path)), ...Object.values(copyChunks)],
|
|
756
798
|
routes,
|
|
757
799
|
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
758
800
|
...(css.length > 0 ? { css } : {}),
|
|
759
801
|
...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
|
|
802
|
+
...(Object.keys(copyChunks).length > 0 ? { copyChunks } : {}),
|
|
760
803
|
}
|
|
761
804
|
assertDevelopmentProductionParity(collectDevelopmentParityInput(routesDir, publicDir), manifest)
|
|
762
805
|
writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2))
|
|
@@ -780,6 +823,8 @@ export interface BuildServerOptions {
|
|
|
780
823
|
readonly styles?: readonly string[] | undefined
|
|
781
824
|
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked alongside `styles`. */
|
|
782
825
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
|
|
826
|
+
/** Copy chunk URLs by locale (`buildClient`'s `BuildManifest.copyChunks`) - baked as `copyChunks`. */
|
|
827
|
+
readonly copyChunks?: Readonly<Record<string, string>> | undefined
|
|
783
828
|
/** How framework-owned stylesheet links should activate before hydration (default `"blocking"`). */
|
|
784
829
|
readonly cssLoading?: CssLoadingMode
|
|
785
830
|
/** Route/layout file → import specifier in the generated manifest (default: a relative path from the
|
|
@@ -1051,6 +1096,7 @@ function formatRefusedFiles(refused: ReadonlyMap<string, string>, root: string):
|
|
|
1051
1096
|
return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`
|
|
1052
1097
|
}
|
|
1053
1098
|
|
|
1099
|
+
export { importGlobPlugin } from "./plugins/import-glob.ts"
|
|
1054
1100
|
export { zoneGuardPlugin } from "./plugins/zone-guard.ts"
|
|
1055
1101
|
|
|
1056
1102
|
/**
|
|
@@ -1130,6 +1176,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
|
|
|
1130
1176
|
styles,
|
|
1131
1177
|
routeStyles,
|
|
1132
1178
|
...(cssLoading !== undefined ? { cssLoading } : {}),
|
|
1179
|
+
...(options.copyChunks !== undefined ? { copyChunks: options.copyChunks } : {}),
|
|
1133
1180
|
lazy,
|
|
1134
1181
|
}),
|
|
1135
1182
|
)
|
|
@@ -1161,6 +1208,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
|
|
|
1161
1208
|
reactDedupePlugin(entryDir),
|
|
1162
1209
|
preactDedupePlugin(entryDir),
|
|
1163
1210
|
...(options.plugins ?? []),
|
|
1211
|
+
importGlobPlugin({ root: resolvePath(options.root ?? dirname(routesDir)) }),
|
|
1164
1212
|
],
|
|
1165
1213
|
})
|
|
1166
1214
|
if (!result.success) {
|
|
@@ -1227,7 +1275,14 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
|
|
|
1227
1275
|
// ===================================================================================================
|
|
1228
1276
|
|
|
1229
1277
|
/** The `createWebApp` options a generated server entry can import from the app's framework module. */
|
|
1230
|
-
export const SERVER_ENTRY_OPTIONS = [
|
|
1278
|
+
export const SERVER_ENTRY_OPTIONS = [
|
|
1279
|
+
"apiPrefix",
|
|
1280
|
+
"apiStrip",
|
|
1281
|
+
"mounts",
|
|
1282
|
+
"csp",
|
|
1283
|
+
"nonce",
|
|
1284
|
+
"i18n",
|
|
1285
|
+
] as const
|
|
1231
1286
|
export type ServerEntryOption = (typeof SERVER_ENTRY_OPTIONS)[number]
|
|
1232
1287
|
/** Each importable option mapped to the specifier of the module that exports it. */
|
|
1233
1288
|
export type ServerEntryOptionImports = Readonly<Partial<Record<ServerEntryOption, string>>>
|
|
@@ -1251,6 +1306,9 @@ const PLATFORM_CLIENT_IP_HEADERS: Readonly<Partial<Record<BuildTarget, string>>>
|
|
|
1251
1306
|
vercel: "x-real-ip",
|
|
1252
1307
|
}
|
|
1253
1308
|
|
|
1309
|
+
/** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
|
|
1310
|
+
const PRERENDERED_DIR = "_prerendered"
|
|
1311
|
+
|
|
1254
1312
|
/**
|
|
1255
1313
|
* Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
|
|
1256
1314
|
* the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
|
|
@@ -1285,6 +1343,11 @@ export function generateServerEntry(options: {
|
|
|
1285
1343
|
readonly hashedFiles?: readonly string[]
|
|
1286
1344
|
/** Trust the edge platform's client-address header for `c.clientIp`. See {@link ServerEntryClientIp}. */
|
|
1287
1345
|
readonly clientIp?: ServerEntryClientIp
|
|
1346
|
+
/** The server manifest exports `copyChunks` (the client build split copy tables by locale). */
|
|
1347
|
+
readonly copyChunks?: boolean
|
|
1348
|
+
/** `bun` / `node` only: the pages the build prerendered into `_prerendered/`, by request path, which
|
|
1349
|
+
* the app answers from their file (see `createWebApp`'s `prerenderedPages`). */
|
|
1350
|
+
readonly prerenderedPages?: Readonly<Record<string, ServerPrerenderedPage>>
|
|
1288
1351
|
}): string {
|
|
1289
1352
|
const {
|
|
1290
1353
|
target,
|
|
@@ -1316,7 +1379,22 @@ export function generateServerEntry(options: {
|
|
|
1316
1379
|
if (target === "static") {
|
|
1317
1380
|
throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
|
|
1318
1381
|
}
|
|
1319
|
-
|
|
1382
|
+
// Bun and Node serve the client bundle through `servePublicDir`. Deno bundles for the browser
|
|
1383
|
+
// condition, where `node:fs` is a stub, so its entry runs the same checks on Deno's own file API.
|
|
1384
|
+
const nodeFs = target === "bun" || target === "node"
|
|
1385
|
+
const prerendered = Object.entries(options.prerenderedPages ?? {})
|
|
1386
|
+
if (prerendered.length > 0 && !nodeFs) {
|
|
1387
|
+
throw new Error(
|
|
1388
|
+
`[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`,
|
|
1389
|
+
)
|
|
1390
|
+
}
|
|
1391
|
+
const lines: string[] = [
|
|
1392
|
+
!nodeFs
|
|
1393
|
+
? 'import { createWebApp } from "@nifrajs/web"'
|
|
1394
|
+
: prerendered.length > 0
|
|
1395
|
+
? 'import { createWebApp, prerenderedPageReader, servePublicDir } from "@nifrajs/web"'
|
|
1396
|
+
: 'import { createWebApp, servePublicDir } from "@nifrajs/web"',
|
|
1397
|
+
]
|
|
1320
1398
|
if (backendImport !== undefined) lines.push('import { inProcessClient } from "@nifrajs/client"')
|
|
1321
1399
|
lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`)
|
|
1322
1400
|
if (useImport !== undefined) lines.push(`import { use } from ${JSON.stringify(useImport)}`)
|
|
@@ -1327,22 +1405,43 @@ export function generateServerEntry(options: {
|
|
|
1327
1405
|
lines.push(`import { backend } from ${JSON.stringify(backendImport)}`)
|
|
1328
1406
|
}
|
|
1329
1407
|
lines.push(
|
|
1330
|
-
|
|
1408
|
+
options.copyChunks === true
|
|
1409
|
+
? 'import { clientEntry, copyChunks, cssLoading, manifest, styles, routeStyles } from "./server-manifest"'
|
|
1410
|
+
: 'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"',
|
|
1331
1411
|
)
|
|
1332
1412
|
// Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
|
|
1333
1413
|
if (target === "cloudflare") lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
|
|
1334
1414
|
if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
|
|
1415
|
+
if (nodeFs) lines.push('import { fileURLToPath } from "node:url"')
|
|
1416
|
+
if (target === "deno") lines.push('import { parseByteRange } from "@nifrajs/core/range"')
|
|
1417
|
+
if (prerendered.length > 0) {
|
|
1418
|
+
const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]))
|
|
1419
|
+
const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]))
|
|
1420
|
+
lines.push(
|
|
1421
|
+
"",
|
|
1422
|
+
`// The pages the build prerendered into ${PRERENDERED_DIR}/, answered from their file after \`use\`.`,
|
|
1423
|
+
"const prerenderedPages = {",
|
|
1424
|
+
` pages: ${JSON.stringify(headers)},`,
|
|
1425
|
+
" read: prerenderedPageReader({",
|
|
1426
|
+
` dir: fileURLToPath(new URL(${JSON.stringify(`./${PRERENDERED_DIR}/`)}, import.meta.url)),`,
|
|
1427
|
+
` files: ${JSON.stringify(files)},`,
|
|
1428
|
+
" }),",
|
|
1429
|
+
"}",
|
|
1430
|
+
)
|
|
1431
|
+
}
|
|
1335
1432
|
lines.push(
|
|
1336
1433
|
"",
|
|
1337
1434
|
"const app = createWebApp({",
|
|
1338
1435
|
" adapter,",
|
|
1339
1436
|
...(useImport !== undefined ? [" use,"] : []),
|
|
1437
|
+
...(prerendered.length > 0 ? [" prerenderedPages,"] : []),
|
|
1340
1438
|
...importedOptions.map((name) => ` ${name},`),
|
|
1341
1439
|
" manifest,",
|
|
1342
1440
|
" clientEntry,",
|
|
1343
1441
|
" styles,",
|
|
1344
1442
|
" routeStyles,",
|
|
1345
1443
|
" cssLoading,",
|
|
1444
|
+
...(options.copyChunks === true ? [" copyChunks,"] : []),
|
|
1346
1445
|
...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []),
|
|
1347
1446
|
` title: ${JSON.stringify(title)},`,
|
|
1348
1447
|
// Core's own `{ header }` trust, so the mounted backend and every loader see the derived caller.
|
|
@@ -1406,14 +1505,20 @@ export function generateServerEntry(options: {
|
|
|
1406
1505
|
' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
|
|
1407
1506
|
" return segments.every(safe) ? '.' + pathname : undefined",
|
|
1408
1507
|
"}",
|
|
1409
|
-
// The
|
|
1410
|
-
//
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1508
|
+
// The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, HEAD
|
|
1509
|
+
// answered without reading the file, byte ranges and conditional requests, and a descriptor opened
|
|
1510
|
+
// only once the real path is inside the root, so a link planted in the deployed tree cannot reach
|
|
1511
|
+
// a file outside it.
|
|
1512
|
+
...(nodeFs
|
|
1513
|
+
? [
|
|
1514
|
+
"const serveStatic = servePublicDir({",
|
|
1515
|
+
" dir: fileURLToPath(STATIC_ROOT),",
|
|
1516
|
+
...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
|
|
1517
|
+
"})",
|
|
1518
|
+
"const staticResponse = async (req: Request): Promise<Response> =>",
|
|
1519
|
+
' (await serveStatic(req)) ?? new Response("not found", { status: 404 })',
|
|
1520
|
+
]
|
|
1521
|
+
: denoStaticResponse(hashedFiles !== undefined)),
|
|
1417
1522
|
)
|
|
1418
1523
|
// Bun.serve and Deno.serve delimit the body themselves, so its declared length is the transport
|
|
1419
1524
|
// frame - the guarantee core's own listen() and the Deno adapter mark (see markTrustedBodyFraming).
|
|
@@ -1425,11 +1530,9 @@ export function generateServerEntry(options: {
|
|
|
1425
1530
|
" port: Number(Bun.env.PORT ?? 3000),",
|
|
1426
1531
|
" async fetch(req, server) {",
|
|
1427
1532
|
" const { pathname } = new URL(req.url)",
|
|
1428
|
-
|
|
1429
|
-
" if (
|
|
1430
|
-
"
|
|
1431
|
-
' if (!(await file.exists())) return new Response("not found", { status: 404 })',
|
|
1432
|
-
" return new Response(file, { headers: staticHeaders(pathname) })",
|
|
1533
|
+
// Braced: the next emitted line opens with `(`, which would otherwise call the returned value.
|
|
1534
|
+
" if (staticPath(pathname) !== undefined) {",
|
|
1535
|
+
" return staticResponse(req)",
|
|
1433
1536
|
" }",
|
|
1434
1537
|
` ${markFramed}`,
|
|
1435
1538
|
" // The socket peer, looked up only when something reads it (as core's own listen() does).",
|
|
@@ -1444,20 +1547,11 @@ export function generateServerEntry(options: {
|
|
|
1444
1547
|
}
|
|
1445
1548
|
if (target === "node") {
|
|
1446
1549
|
lines.push(
|
|
1447
|
-
'import { readFile } from "node:fs/promises"',
|
|
1448
1550
|
"await serve(",
|
|
1449
1551
|
" {",
|
|
1450
1552
|
" async fetch(req, platform) {",
|
|
1451
1553
|
" const { pathname } = new URL(req.url)",
|
|
1452
|
-
"
|
|
1453
|
-
" if (filePath !== undefined) {",
|
|
1454
|
-
" try {",
|
|
1455
|
-
" const body = await readFile(new URL(filePath, STATIC_ROOT))",
|
|
1456
|
-
" return new Response(body, { headers: staticHeaders(pathname) })",
|
|
1457
|
-
" } catch {",
|
|
1458
|
-
' return new Response("not found", { status: 404 })',
|
|
1459
|
-
" }",
|
|
1460
|
-
" }",
|
|
1554
|
+
" if (staticPath(pathname) !== undefined) return staticResponse(req)",
|
|
1461
1555
|
" return app.fetch(req, platform)",
|
|
1462
1556
|
" },",
|
|
1463
1557
|
" },",
|
|
@@ -1473,13 +1567,7 @@ export function generateServerEntry(options: {
|
|
|
1473
1567
|
" const { pathname } = new URL(req.url)",
|
|
1474
1568
|
" const filePath = staticPath(pathname)",
|
|
1475
1569
|
" if (filePath !== undefined) {",
|
|
1476
|
-
"
|
|
1477
|
-
" // @ts-ignore - Deno.readFile is present on the Deno runtime.",
|
|
1478
|
-
" const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))",
|
|
1479
|
-
" return new Response(body, { headers: staticHeaders(pathname) })",
|
|
1480
|
-
" } catch {",
|
|
1481
|
-
' return new Response("not found", { status: 404 })',
|
|
1482
|
-
" }",
|
|
1570
|
+
" return staticResponse(req, filePath, pathname)",
|
|
1483
1571
|
" }",
|
|
1484
1572
|
` ${markFramed}`,
|
|
1485
1573
|
" return app.fetch(req, { clientIp: info.remoteAddr.hostname })",
|
|
@@ -1488,6 +1576,99 @@ export function generateServerEntry(options: {
|
|
|
1488
1576
|
return `${lines.join("\n")}\n`
|
|
1489
1577
|
}
|
|
1490
1578
|
|
|
1579
|
+
/**
|
|
1580
|
+
* The Deno entry's static handler: `servePublicDir`'s checks on Deno's file API. The real path must sit
|
|
1581
|
+
* inside the root and cross no hidden segment, and the opened file must still be the one that path
|
|
1582
|
+
* names, so a link planted in the deployed tree cannot reach a file outside it or a `.env` inside it.
|
|
1583
|
+
* HEAD answers from `stat` without reading; one byte range is served from a seek, several as the
|
|
1584
|
+
* whole file.
|
|
1585
|
+
*/
|
|
1586
|
+
function denoStaticResponse(hashed: boolean): string[] {
|
|
1587
|
+
return [
|
|
1588
|
+
`const TYPES: Record<string, string> = ${JSON.stringify(CONTENT_TYPES)}`,
|
|
1589
|
+
"const staticHeaders = (pathname: string): Record<string, string> => ({",
|
|
1590
|
+
' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
|
|
1591
|
+
` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
|
|
1592
|
+
' "x-content-type-options": "nosniff",',
|
|
1593
|
+
' "accept-ranges": "bytes",',
|
|
1594
|
+
"})",
|
|
1595
|
+
"// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1596
|
+
"const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
|
|
1597
|
+
"const hiddenSegment = (rel: string): boolean =>",
|
|
1598
|
+
' rel.split(/[\\\\/]/).some((segment) => segment.startsWith(".") && segment !== ".well-known")',
|
|
1599
|
+
"const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
|
|
1600
|
+
' const missing = () => new Response("not found", { status: 404 })',
|
|
1601
|
+
' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
|
|
1602
|
+
" let file, info",
|
|
1603
|
+
" try {",
|
|
1604
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1605
|
+
" const real: string = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
|
|
1606
|
+
' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
|
|
1607
|
+
" if (hiddenSegment(real.slice(STATIC_REAL.length + 1))) return missing()",
|
|
1608
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1609
|
+
" file = await Deno.open(real)",
|
|
1610
|
+
" // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
|
|
1611
|
+
" // The opened file must be the one at `real`, which must still resolve to itself.",
|
|
1612
|
+
" const opened = await file.stat()",
|
|
1613
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1614
|
+
" const named = await Deno.stat(real)",
|
|
1615
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1616
|
+
" if (opened.dev !== named.dev || opened.ino !== named.ino || (await Deno.realPath(real)) !== real) {",
|
|
1617
|
+
" file.close()",
|
|
1618
|
+
" return missing()",
|
|
1619
|
+
" }",
|
|
1620
|
+
" info = opened",
|
|
1621
|
+
" } catch {",
|
|
1622
|
+
" file?.close()",
|
|
1623
|
+
" return missing()",
|
|
1624
|
+
" }",
|
|
1625
|
+
" const size: number = info.size",
|
|
1626
|
+
" const headers = new Headers(staticHeaders(pathname))",
|
|
1627
|
+
' const range = req.method === "GET" ? parseByteRange(req.headers.get("range"), size) : { kind: "none" as const }',
|
|
1628
|
+
' if (!info.isFile || req.method === "HEAD" || range.kind === "unsatisfiable") {',
|
|
1629
|
+
" file.close()",
|
|
1630
|
+
" if (!info.isFile) return missing()",
|
|
1631
|
+
' if (range.kind !== "unsatisfiable") {',
|
|
1632
|
+
' headers.set("content-length", String(size))',
|
|
1633
|
+
" return new Response(null, { headers })",
|
|
1634
|
+
" }",
|
|
1635
|
+
' headers.delete("content-type")',
|
|
1636
|
+
' headers.set("content-range", "bytes */" + size)',
|
|
1637
|
+
" return new Response(null, { status: 416, headers })",
|
|
1638
|
+
" }",
|
|
1639
|
+
' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
|
|
1640
|
+
" const { start, end } = range.ranges[0]!",
|
|
1641
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1642
|
+
" await file.seek(start, Deno.SeekMode.Start)",
|
|
1643
|
+
" let left = end - start + 1",
|
|
1644
|
+
' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
|
|
1645
|
+
' headers.set("content-length", String(left))',
|
|
1646
|
+
" const body = new ReadableStream<Uint8Array>({",
|
|
1647
|
+
" async pull(controller) {",
|
|
1648
|
+
" const chunk = new Uint8Array(Math.min(left, 65_536))",
|
|
1649
|
+
" const read: number | null = await file.read(chunk)",
|
|
1650
|
+
" if (read === null || read === 0) {",
|
|
1651
|
+
" file.close()",
|
|
1652
|
+
" controller.close()",
|
|
1653
|
+
" return",
|
|
1654
|
+
" }",
|
|
1655
|
+
" controller.enqueue(chunk.subarray(0, read))",
|
|
1656
|
+
" left -= read",
|
|
1657
|
+
" if (left <= 0) {",
|
|
1658
|
+
" file.close()",
|
|
1659
|
+
" controller.close()",
|
|
1660
|
+
" }",
|
|
1661
|
+
" },",
|
|
1662
|
+
" cancel: () => file.close(),",
|
|
1663
|
+
" })",
|
|
1664
|
+
" return new Response(body, { status: 206, headers })",
|
|
1665
|
+
" }",
|
|
1666
|
+
' headers.set("content-length", String(size))',
|
|
1667
|
+
" return new Response(file.readable, { headers })",
|
|
1668
|
+
"}",
|
|
1669
|
+
]
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1491
1672
|
/** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with
|
|
1492
1673
|
* `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the
|
|
1493
1674
|
* pure {@link aggregateSizeReport}. */
|
|
@@ -1531,7 +1712,9 @@ export interface BuildTargetOptions {
|
|
|
1531
1712
|
* emitted hydration `<script src>` uses the REAL content-hashed entry (`client.entry`) plus the same
|
|
1532
1713
|
* styles/route-preload the server targets use. A pre-built instance can't work here: the hash isn't known
|
|
1533
1714
|
* until `buildClient` runs inside `buildTarget`, so a hardcoded entry 404s → pages render but never
|
|
1534
|
-
* hydrate. Required for `target: "static"` (SSG drives `app.fetch`)
|
|
1715
|
+
* hydrate. Required for `target: "static"` (SSG drives `app.fetch`). For `bun` and `node` it is
|
|
1716
|
+
* optional: given, the opted-in routes are prerendered into `_prerendered/` and the server answers
|
|
1717
|
+
* them from their file; the other targets ignore it. */
|
|
1535
1718
|
readonly prerenderApp?: (client: BuildManifest) => PrerenderAppLike | Promise<PrerenderAppLike>
|
|
1536
1719
|
/** Client-build plugins (e.g. the MDX/Vue/Solid Bun plugins). */
|
|
1537
1720
|
readonly clientPlugins?: readonly BunPlugin[]
|
|
@@ -1630,6 +1813,7 @@ export const bunBundler: Bundler = {
|
|
|
1630
1813
|
target: input.target,
|
|
1631
1814
|
...(input.styles !== undefined ? { styles: input.styles } : {}),
|
|
1632
1815
|
...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
|
|
1816
|
+
...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
|
|
1633
1817
|
...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
|
|
1634
1818
|
...(input.define ? { define: input.define } : {}),
|
|
1635
1819
|
...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
|
|
@@ -1659,6 +1843,48 @@ function assertNoPublicCollision(
|
|
|
1659
1843
|
}
|
|
1660
1844
|
}
|
|
1661
1845
|
|
|
1846
|
+
/** Prerender a self-hosting build's opted-in routes into `dir` and return the pages its server may
|
|
1847
|
+
* answer from their file: those whose render was not specific to its request. */
|
|
1848
|
+
async function prerenderForServer(
|
|
1849
|
+
prerenderApp: NonNullable<BuildTargetOptions["prerenderApp"]>,
|
|
1850
|
+
client: BuildManifest,
|
|
1851
|
+
routesDir: string,
|
|
1852
|
+
dir: string,
|
|
1853
|
+
publicFiles: readonly string[],
|
|
1854
|
+
secrets: NonNullable<PrerenderOptions["secrets"]>,
|
|
1855
|
+
): Promise<Record<string, ServerPrerenderedPage>> {
|
|
1856
|
+
const owned = `/${PRERENDERED_DIR}/`
|
|
1857
|
+
const clashes = publicFiles.filter((file) => file.toLowerCase().startsWith(owned))
|
|
1858
|
+
if (clashes.length > 0) {
|
|
1859
|
+
throw new Error(
|
|
1860
|
+
`[nifra/web] public file(s) ${clashes.join(", ")} sit under ${owned}, where the build writes its ` +
|
|
1861
|
+
"prerendered pages. Rename or move them out of the public directory.",
|
|
1862
|
+
)
|
|
1863
|
+
}
|
|
1864
|
+
let result: Awaited<ReturnType<typeof prerenderRoutes>>
|
|
1865
|
+
try {
|
|
1866
|
+
result = await prerenderRoutes({
|
|
1867
|
+
app: await prerenderApp(client),
|
|
1868
|
+
routes: discoverRoutes(routesDir).routes,
|
|
1869
|
+
outDir: dir,
|
|
1870
|
+
secrets,
|
|
1871
|
+
})
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
// Prerendering loads every route module at build time, which a server build never needed before:
|
|
1874
|
+
// one that cannot load there (a connection opened on import, an env var only production sets) costs
|
|
1875
|
+
// the speedup, not the build. A secret in a page still fails it.
|
|
1876
|
+
if (error instanceof SecretScanError) throw error
|
|
1877
|
+
rmSync(dir, { recursive: true, force: true })
|
|
1878
|
+
console.warn(
|
|
1879
|
+
`[nifra/web] prerendering failed, so the server renders every page on demand: ${
|
|
1880
|
+
error instanceof Error ? error.message : String(error)
|
|
1881
|
+
}`,
|
|
1882
|
+
)
|
|
1883
|
+
return {}
|
|
1884
|
+
}
|
|
1885
|
+
return servablePrerenderedPages(result.prerendered)
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1662
1888
|
/** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
|
|
1663
1889
|
export async function buildTarget(
|
|
1664
1890
|
target: BuildTarget,
|
|
@@ -1759,6 +1985,21 @@ export async function buildTargetWith(
|
|
|
1759
1985
|
writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
|
|
1760
1986
|
}
|
|
1761
1987
|
|
|
1988
|
+
// A self-hosting server answers its prerendered pages from their file. The static target is all
|
|
1989
|
+
// prerendered pages; the edge targets keep rendering them on demand.
|
|
1990
|
+
const prerenderedPages =
|
|
1991
|
+
options.prerenderApp !== undefined &&
|
|
1992
|
+
(targetPlan.target === "bun" || targetPlan.target === "node")
|
|
1993
|
+
? await prerenderForServer(
|
|
1994
|
+
options.prerenderApp,
|
|
1995
|
+
client,
|
|
1996
|
+
routesDir,
|
|
1997
|
+
`${staticRoot}/${PRERENDERED_DIR}`,
|
|
1998
|
+
publicFiles,
|
|
1999
|
+
secretScan,
|
|
2000
|
+
)
|
|
2001
|
+
: undefined
|
|
2002
|
+
|
|
1762
2003
|
if (targetPlan.kind === "static") {
|
|
1763
2004
|
if (options.prerenderApp === undefined) {
|
|
1764
2005
|
throw new Error(
|
|
@@ -1813,6 +2054,8 @@ export async function buildTargetWith(
|
|
|
1813
2054
|
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
1814
2055
|
hashedFiles: client.assets,
|
|
1815
2056
|
...(options.clientIp !== undefined ? { clientIp: options.clientIp } : {}),
|
|
2057
|
+
...(client.copyChunks !== undefined ? { copyChunks: true } : {}),
|
|
2058
|
+
...(prerenderedPages !== undefined ? { prerenderedPages } : {}),
|
|
1816
2059
|
}),
|
|
1817
2060
|
)
|
|
1818
2061
|
const { worker } = await bundler.buildServer({
|
|
@@ -1823,6 +2066,7 @@ export async function buildTargetWith(
|
|
|
1823
2066
|
target: targetPlan.serverTarget,
|
|
1824
2067
|
...(client.css !== undefined ? { styles: client.css } : {}),
|
|
1825
2068
|
...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
|
|
2069
|
+
...(client.copyChunks !== undefined ? { copyChunks: client.copyChunks } : {}),
|
|
1826
2070
|
...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
|
|
1827
2071
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1828
2072
|
...(client.cssLoading !== undefined
|
package/src/client.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
setBlockerController,
|
|
20
20
|
setBrowserNavigate,
|
|
21
21
|
} from "./navigation.ts"
|
|
22
|
+
import { handoverLocale } from "./render-seam.ts"
|
|
22
23
|
import { type ClientRouter, redirectOf } from "./router.ts"
|
|
23
24
|
|
|
24
25
|
/** Read the nonce carried by the current server-rendered document, if any. */
|
|
@@ -49,6 +50,7 @@ export {
|
|
|
49
50
|
resolveNavigate,
|
|
50
51
|
} from "./navigation.ts"
|
|
51
52
|
export { createMutation, createQueryClient } from "./query.ts"
|
|
53
|
+
export { localeOfProps } from "./render-seam.ts"
|
|
52
54
|
export { createClientRouter, createMatcher } from "./router.ts"
|
|
53
55
|
// Same reason, for the ADAPTER packages (`@nifrajs/web-react` and friends). Every one of them reaches
|
|
54
56
|
// for these from the root - `searchOfChain` in its `/client` entry, the navigation and query values in
|
|
@@ -617,17 +619,23 @@ export function installHistory(
|
|
|
617
619
|
* content (charset, hand-written tags) is never touched. SSR injects the same `data-nifra` tags, so
|
|
618
620
|
* the first navigation cleanly takes over from the server-rendered head.
|
|
619
621
|
*/
|
|
620
|
-
export function applyHead(head: Meta): void {
|
|
622
|
+
export function applyHead(head: Meta, path?: string): void {
|
|
621
623
|
const documentNonce = currentDocumentNonce()
|
|
622
624
|
if (head.title !== undefined) document.title = head.title
|
|
623
625
|
// `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
|
|
624
|
-
// route omits it), mirroring the SSR shell's defaulting exactly: `lang`
|
|
625
|
-
// removed when unset. Anything laxer drifts on a
|
|
626
|
-
// `dir="rtl"` behind and lay the English page
|
|
626
|
+
// route omits it), mirroring the SSR shell's defaulting exactly: a meta `lang` wins, then the page's
|
|
627
|
+
// registered locale, then `"en"`; `dir` is removed when unset. Anything laxer drifts on a
|
|
628
|
+
// multilingual site - navigating /ur → /en would leave `dir="rtl"` behind and lay the English page
|
|
629
|
+
// out right-to-left, a bug a hard reload would not show.
|
|
630
|
+
const locale =
|
|
631
|
+
head.lang === undefined
|
|
632
|
+
? handoverLocale(path ?? (typeof location === "undefined" ? "" : location.pathname))
|
|
633
|
+
: undefined
|
|
634
|
+
const dir = head.dir ?? locale?.dir
|
|
627
635
|
const root = document.documentElement
|
|
628
|
-
root.setAttribute("lang", head.lang ?? "en")
|
|
629
|
-
if (
|
|
630
|
-
else root.setAttribute("dir",
|
|
636
|
+
root.setAttribute("lang", head.lang ?? locale?.lang ?? "en")
|
|
637
|
+
if (dir === undefined) root.removeAttribute("dir")
|
|
638
|
+
else root.setAttribute("dir", dir)
|
|
631
639
|
for (const el of document.head.querySelectorAll("[data-nifra]")) el.remove()
|
|
632
640
|
// Values follow the same HTML attribute conventions as the SSR `tagAttrs`: a string sets the value,
|
|
633
641
|
// `true` sets the bare boolean attribute, `false`/`undefined` skip it - so a soft-nav head matches
|