@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/dist/build.js
CHANGED
|
@@ -12,22 +12,25 @@ import { cp, lstat, mkdir, realpath } from "node:fs/promises";
|
|
|
12
12
|
import { dirname, isAbsolute, join, basename as pathBasename, posix, relative, resolve as resolvePath, sep, } from "node:path";
|
|
13
13
|
import { singleCopyPlugin as declaredSingleCopyPlugin, readSingleCopyDeclaration, } from "@nifrajs/core/single-copy";
|
|
14
14
|
import { Glob } from "bun";
|
|
15
|
-
import { aggregateSizeReport, detectNodeBuiltinsInClient, detectServerOnlyInClient, formatNodeBuiltinLeak, formatServerOnlyLeak, parseBuildTarget, parseManifestClientEntry, parseManifestCssLoading, parseManifestRouteStyles, parseManifestStyles, planBuildTarget, } from "./build-plan.js";
|
|
15
|
+
import { aggregateSizeReport, detectNodeBuiltinsInClient, detectServerOnlyInClient, formatNodeBuiltinLeak, formatServerOnlyLeak, parseBuildTarget, parseManifestClientEntry, parseManifestCopyChunks, parseManifestCssLoading, parseManifestRouteStyles, parseManifestStyles, planBuildTarget, } from "./build-plan.js";
|
|
16
16
|
import { sanitizeOutputNames } from "./chunk-names.js";
|
|
17
17
|
import { normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.js";
|
|
18
18
|
import { discoverRoutes } from "./fs.js";
|
|
19
19
|
import { generateClientEntry, generateServerManifest } from "./index.js";
|
|
20
|
+
import { loadCopySplit } from "./internal/copy-split.js";
|
|
20
21
|
import { assertDisposableDir } from "./internal/disposable-dir.js";
|
|
21
22
|
import { entryChunks } from "./internal/entry-chunks.js";
|
|
22
23
|
import { dedupePolicyFor } from "./internal/identity-policy.js";
|
|
23
24
|
import { assertDevelopmentProductionParity, assertIdentityParity, collectDevelopmentParityInput, } from "./internal/parity.js";
|
|
25
|
+
import { servablePrerenderedPages, } from "./internal/prerendered-pages.js";
|
|
24
26
|
import { privateEnvCheck } from "./internal/private-env.js";
|
|
25
|
-
import { publicUrlPath } from "./internal/public-url.js";
|
|
26
|
-
import { assertNoSecrets, buildEnvironment, emittedScanFiles, formatSecretFindings, graphScanInput, originName, publicScanFiles, scanForSecrets, textOf, } from "./internal/secret-scan.js";
|
|
27
|
+
import { isHiddenPublicPath, publicUrlPath } from "./internal/public-url.js";
|
|
28
|
+
import { assertNoSecrets, buildEnvironment, emittedScanFiles, formatSecretFindings, graphScanInput, originName, publicScanFiles, SecretScanError, scanForSecrets, textOf, } from "./internal/secret-scan.js";
|
|
27
29
|
import { generateServerFnStub, SERVER_FN_MODULE, serverFnNamespace, } from "./internal/server-boundary.js";
|
|
28
30
|
import { formatUnsupportedBuiltins, unsupportedBuiltins } from "./internal/target-compat.js";
|
|
29
31
|
import { accountEmittedFiles, bunModuleSource, formatClientGraphVerdict, formatServerGraphVerdict, formatUnaccountedOutput, verifyClientGraph, verifyServerGraph, } from "./internal/zone-graph.js";
|
|
30
32
|
import { fromBunMetafile } from "./module-graph.js";
|
|
33
|
+
import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.js";
|
|
31
34
|
import { zoneGuardPlugin } from "./plugins/zone-guard.js";
|
|
32
35
|
// `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
|
|
33
36
|
import { prerenderRoutes } from "./prerender.js";
|
|
@@ -164,7 +167,9 @@ routePatterns) {
|
|
|
164
167
|
}
|
|
165
168
|
/** The built asset map - the server reads `entry` for the client script + serves `assets`. */
|
|
166
169
|
/**
|
|
167
|
-
* Copy `from` into `to`, returning the URL paths copied (sorted).
|
|
170
|
+
* Copy `from` into `to`, returning the URL paths copied (sorted). Hidden entries other than
|
|
171
|
+
* `.well-known` are skipped, as development never serves them: every target ships this list, and a
|
|
172
|
+
* CDN-hosted one serves whatever was copied.
|
|
168
173
|
*
|
|
169
174
|
* Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
|
|
170
175
|
* `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
|
|
@@ -175,6 +180,8 @@ export async function copyPublicDir(from, to) {
|
|
|
175
180
|
const sourceReal = await realpath(source);
|
|
176
181
|
const copied = [];
|
|
177
182
|
for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
|
|
183
|
+
if (isHiddenPublicPath(rel))
|
|
184
|
+
continue;
|
|
178
185
|
const candidate = join(source, rel);
|
|
179
186
|
const candidateStat = await lstat(candidate);
|
|
180
187
|
if (candidateStat.isSymbolicLink()) {
|
|
@@ -203,12 +210,14 @@ export async function copyPublicDir(from, to) {
|
|
|
203
210
|
*/
|
|
204
211
|
export function resyncServerManifestSource(source, manifest, routesPrefix) {
|
|
205
212
|
const cssLoading = parseManifestCssLoading(source);
|
|
213
|
+
const copyChunks = parseManifestCopyChunks(source);
|
|
206
214
|
return generateServerManifest(manifest, {
|
|
207
215
|
resolve: (file) => `${routesPrefix}${file}`,
|
|
208
216
|
clientEntry: parseManifestClientEntry(source) ?? "",
|
|
209
217
|
styles: parseManifestStyles(source),
|
|
210
218
|
routeStyles: parseManifestRouteStyles(source),
|
|
211
219
|
...(cssLoading !== undefined ? { cssLoading } : {}),
|
|
220
|
+
...(copyChunks !== undefined ? { copyChunks } : {}),
|
|
212
221
|
lazy: MANIFEST_IS_LAZY.test(source),
|
|
213
222
|
});
|
|
214
223
|
}
|
|
@@ -249,6 +258,9 @@ export async function buildClient(options) {
|
|
|
249
258
|
// The bootstrap's filename is `_nifra`-namespaced (not `entry.ts`) so its `[name]` can't collide with
|
|
250
259
|
// a user route named `entry.tsx` when the CSS mapping below excludes the bootstrap's aggregate CSS.
|
|
251
260
|
const mode = options.minify === false ? "development" : "production";
|
|
261
|
+
const copySplit = (options.copySplit ?? mode === "production")
|
|
262
|
+
? await loadCopySplit(root, { skip: [outDir] })
|
|
263
|
+
: undefined;
|
|
252
264
|
// PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
|
|
253
265
|
// falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
|
|
254
266
|
// in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
|
|
@@ -263,7 +275,11 @@ export async function buildClient(options) {
|
|
|
263
275
|
const entryFile = `${entryDir}/_nifra-entry.ts`;
|
|
264
276
|
// A client bundle has no Node `process`; provide a minimal one so a stray bare `process` reference in
|
|
265
277
|
// an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
|
|
266
|
-
writeFileSync(entryFile, `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, {
|
|
278
|
+
writeFileSync(entryFile, `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, {
|
|
279
|
+
clientModule,
|
|
280
|
+
resolve,
|
|
281
|
+
copyChunks: copySplit !== undefined,
|
|
282
|
+
})}`);
|
|
267
283
|
// Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
|
|
268
284
|
// named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
|
|
269
285
|
// can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same
|
|
@@ -314,6 +330,10 @@ export async function buildClient(options) {
|
|
|
314
330
|
svelteDedupePlugin(routesDir),
|
|
315
331
|
serverFnStubPlugin(),
|
|
316
332
|
...(options.plugins ?? []),
|
|
333
|
+
// Before the copy split: it claims a module that calls the glob and reads it through the split.
|
|
334
|
+
importGlobPlugin({ root }),
|
|
335
|
+
// Last: it rewrites a module no other plugin claims, and the ones that do read through it.
|
|
336
|
+
...(copySplit === undefined ? [] : [copySplit.plugin]),
|
|
317
337
|
],
|
|
318
338
|
...(options.conditions ? { conditions: [...options.conditions] } : {}),
|
|
319
339
|
// Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
|
|
@@ -364,6 +384,7 @@ export async function buildClient(options) {
|
|
|
364
384
|
}
|
|
365
385
|
return { name: out.path, kind: "code", text: await out.text() };
|
|
366
386
|
}));
|
|
387
|
+
assertNoImportGlob(emitted.flatMap((file) => file.kind === "code" ? [[relative(cwd, file.name), file.text ?? ""]] : []));
|
|
367
388
|
const clientGraph = withEmittedImports(fromBunMetafile(clientMeta), emitted, publicPath);
|
|
368
389
|
const verdict = verifyClientGraph(clientGraph, {
|
|
369
390
|
classifier,
|
|
@@ -405,6 +426,7 @@ export async function buildClient(options) {
|
|
|
405
426
|
if (unaccounted !== undefined)
|
|
406
427
|
throw new Error(unaccounted);
|
|
407
428
|
const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public");
|
|
429
|
+
const copy = copySplit?.chunks(publicPath);
|
|
408
430
|
const { sources, originsOf } = graphScanInput(clientGraph, bunModuleSource(cwd), classifier);
|
|
409
431
|
const secrets = formatSecretFindings(scanForSecrets({
|
|
410
432
|
sources,
|
|
@@ -415,6 +437,7 @@ export async function buildClient(options) {
|
|
|
415
437
|
const name = file.name.replace(/^\.\//, "");
|
|
416
438
|
return emittedScanFiles(name, file.text, originsOf(name));
|
|
417
439
|
}),
|
|
440
|
+
...(copy?.files ?? []).flatMap((file) => emittedScanFiles(file.name, file.text)),
|
|
418
441
|
...(publicDir !== undefined && existsSync(publicDir)
|
|
419
442
|
? publicScanFiles(publicDir, originName(root, resolvePath(publicDir)))
|
|
420
443
|
: []),
|
|
@@ -430,6 +453,8 @@ export async function buildClient(options) {
|
|
|
430
453
|
await Bun.write(path, out);
|
|
431
454
|
return { kind: out.kind, path };
|
|
432
455
|
}));
|
|
456
|
+
for (const file of copy?.files ?? [])
|
|
457
|
+
writeFileSync(join(outDir, file.name), file.text);
|
|
433
458
|
// Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
|
|
434
459
|
// rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
|
|
435
460
|
const renamed = sanitizeOutputNames(outputs);
|
|
@@ -553,13 +578,15 @@ export async function buildClient(options) {
|
|
|
553
578
|
// Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
|
|
554
579
|
// apps have none) and must not fail the build.
|
|
555
580
|
const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [];
|
|
581
|
+
const copyChunks = copy?.urls ?? {};
|
|
556
582
|
const manifest = {
|
|
557
583
|
entry: entryUrl,
|
|
558
|
-
assets: outputs.map((o) => toUrl(o.path)),
|
|
584
|
+
assets: [...outputs.map((o) => toUrl(o.path)), ...Object.values(copyChunks)],
|
|
559
585
|
routes,
|
|
560
586
|
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
561
587
|
...(css.length > 0 ? { css } : {}),
|
|
562
588
|
...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
|
|
589
|
+
...(Object.keys(copyChunks).length > 0 ? { copyChunks } : {}),
|
|
563
590
|
};
|
|
564
591
|
assertDevelopmentProductionParity(collectDevelopmentParityInput(routesDir, publicDir), manifest);
|
|
565
592
|
writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2));
|
|
@@ -788,6 +815,7 @@ function formatRefusedFiles(refused, root) {
|
|
|
788
815
|
});
|
|
789
816
|
return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`;
|
|
790
817
|
}
|
|
818
|
+
export { importGlobPlugin } from "./plugins/import-glob.js";
|
|
791
819
|
export { zoneGuardPlugin } from "./plugins/zone-guard.js";
|
|
792
820
|
/**
|
|
793
821
|
* Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes
|
|
@@ -858,6 +886,7 @@ export async function buildServer(options) {
|
|
|
858
886
|
styles,
|
|
859
887
|
routeStyles,
|
|
860
888
|
...(cssLoading !== undefined ? { cssLoading } : {}),
|
|
889
|
+
...(options.copyChunks !== undefined ? { copyChunks: options.copyChunks } : {}),
|
|
861
890
|
lazy,
|
|
862
891
|
}));
|
|
863
892
|
const result = await Bun.build({
|
|
@@ -887,6 +916,7 @@ export async function buildServer(options) {
|
|
|
887
916
|
reactDedupePlugin(entryDir),
|
|
888
917
|
preactDedupePlugin(entryDir),
|
|
889
918
|
...(options.plugins ?? []),
|
|
919
|
+
importGlobPlugin({ root: resolvePath(options.root ?? dirname(routesDir)) }),
|
|
890
920
|
],
|
|
891
921
|
});
|
|
892
922
|
if (!result.success) {
|
|
@@ -939,7 +969,14 @@ export async function buildServer(options) {
|
|
|
939
969
|
// …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
|
|
940
970
|
// ===================================================================================================
|
|
941
971
|
/** The `createWebApp` options a generated server entry can import from the app's framework module. */
|
|
942
|
-
export const SERVER_ENTRY_OPTIONS = [
|
|
972
|
+
export const SERVER_ENTRY_OPTIONS = [
|
|
973
|
+
"apiPrefix",
|
|
974
|
+
"apiStrip",
|
|
975
|
+
"mounts",
|
|
976
|
+
"csp",
|
|
977
|
+
"nonce",
|
|
978
|
+
"i18n",
|
|
979
|
+
];
|
|
943
980
|
/**
|
|
944
981
|
* The header each edge platform sets to the caller's address, replacing any value the client sent.
|
|
945
982
|
* That replacement is the whole trust: the same bundle served without the platform's edge in front
|
|
@@ -950,6 +987,8 @@ const PLATFORM_CLIENT_IP_HEADERS = {
|
|
|
950
987
|
cloudflare: "cf-connecting-ip",
|
|
951
988
|
vercel: "x-real-ip",
|
|
952
989
|
};
|
|
990
|
+
/** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
|
|
991
|
+
const PRERENDERED_DIR = "_prerendered";
|
|
953
992
|
/**
|
|
954
993
|
* Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
|
|
955
994
|
* the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
|
|
@@ -984,7 +1023,20 @@ export function generateServerEntry(options) {
|
|
|
984
1023
|
if (target === "static") {
|
|
985
1024
|
throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)");
|
|
986
1025
|
}
|
|
987
|
-
|
|
1026
|
+
// Bun and Node serve the client bundle through `servePublicDir`. Deno bundles for the browser
|
|
1027
|
+
// condition, where `node:fs` is a stub, so its entry runs the same checks on Deno's own file API.
|
|
1028
|
+
const nodeFs = target === "bun" || target === "node";
|
|
1029
|
+
const prerendered = Object.entries(options.prerenderedPages ?? {});
|
|
1030
|
+
if (prerendered.length > 0 && !nodeFs) {
|
|
1031
|
+
throw new Error(`[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`);
|
|
1032
|
+
}
|
|
1033
|
+
const lines = [
|
|
1034
|
+
!nodeFs
|
|
1035
|
+
? 'import { createWebApp } from "@nifrajs/web"'
|
|
1036
|
+
: prerendered.length > 0
|
|
1037
|
+
? 'import { createWebApp, prerenderedPageReader, servePublicDir } from "@nifrajs/web"'
|
|
1038
|
+
: 'import { createWebApp, servePublicDir } from "@nifrajs/web"',
|
|
1039
|
+
];
|
|
988
1040
|
if (backendImport !== undefined)
|
|
989
1041
|
lines.push('import { inProcessClient } from "@nifrajs/client"');
|
|
990
1042
|
lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`);
|
|
@@ -996,13 +1048,24 @@ export function generateServerEntry(options) {
|
|
|
996
1048
|
if (backendImport !== undefined) {
|
|
997
1049
|
lines.push(`import { backend } from ${JSON.stringify(backendImport)}`);
|
|
998
1050
|
}
|
|
999
|
-
lines.push(
|
|
1051
|
+
lines.push(options.copyChunks === true
|
|
1052
|
+
? 'import { clientEntry, copyChunks, cssLoading, manifest, styles, routeStyles } from "./server-manifest"'
|
|
1053
|
+
: 'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"');
|
|
1000
1054
|
// Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
|
|
1001
1055
|
if (target === "cloudflare")
|
|
1002
1056
|
lines.push('import { toFetchHandler } from "@nifrajs/core/server"');
|
|
1003
1057
|
if (target === "node")
|
|
1004
1058
|
lines.push('import { serve } from "@nifrajs/node"');
|
|
1005
|
-
|
|
1059
|
+
if (nodeFs)
|
|
1060
|
+
lines.push('import { fileURLToPath } from "node:url"');
|
|
1061
|
+
if (target === "deno")
|
|
1062
|
+
lines.push('import { parseByteRange } from "@nifrajs/core/range"');
|
|
1063
|
+
if (prerendered.length > 0) {
|
|
1064
|
+
const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]));
|
|
1065
|
+
const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]));
|
|
1066
|
+
lines.push("", `// The pages the build prerendered into ${PRERENDERED_DIR}/, answered from their file after \`use\`.`, "const prerenderedPages = {", ` pages: ${JSON.stringify(headers)},`, " read: prerenderedPageReader({", ` dir: fileURLToPath(new URL(${JSON.stringify(`./${PRERENDERED_DIR}/`)}, import.meta.url)),`, ` files: ${JSON.stringify(files)},`, " }),", "}");
|
|
1067
|
+
}
|
|
1068
|
+
lines.push("", "const app = createWebApp({", " adapter,", ...(useImport !== undefined ? [" use,"] : []), ...(prerendered.length > 0 ? [" prerenderedPages,"] : []), ...importedOptions.map((name) => ` ${name},`), " manifest,", " clientEntry,", " styles,", " routeStyles,", " cssLoading,", ...(options.copyChunks === true ? [" copyChunks,"] : []), ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`,
|
|
1006
1069
|
// Core's own `{ header }` trust, so the mounted backend and every loader see the derived caller.
|
|
1007
1070
|
...(clientIpHeader !== undefined
|
|
1008
1071
|
? [` server: { clientIp: { header: ${JSON.stringify(clientIpHeader)} } },`]
|
|
@@ -1026,27 +1089,132 @@ export function generateServerEntry(options) {
|
|
|
1026
1089
|
lines.push("// The client bundle and public files live next to this entry; serve only manifest-approved paths.", 'const STATIC_ROOT = new URL("./", import.meta.url)', `const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, ...(hashedFiles !== undefined
|
|
1027
1090
|
? [`const HASHED_FILES = new Set(${JSON.stringify(hashedFiles)})`]
|
|
1028
1091
|
: []), "const staticPath = (pathname: string): string | undefined => {", " if (PUBLIC_FILES.has(pathname)) return '.' + pathname", ' if (!pathname.startsWith("/assets/")) return undefined', ' const segments = pathname.slice(1).split("/")', " // `.` and `..` match the character class, and the path is joined onto a base URL that resolves", " // dot segments - so they are rejected explicitly rather than relying on the request URL having", " // been normalized upstream.", " const safe = (segment: string): boolean =>", ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)', " return segments.every(safe) ? '.' + pathname : undefined", "}",
|
|
1029
|
-
// The
|
|
1030
|
-
//
|
|
1031
|
-
|
|
1092
|
+
// The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, HEAD
|
|
1093
|
+
// answered without reading the file, byte ranges and conditional requests, and a descriptor opened
|
|
1094
|
+
// only once the real path is inside the root, so a link planted in the deployed tree cannot reach
|
|
1095
|
+
// a file outside it.
|
|
1096
|
+
...(nodeFs
|
|
1097
|
+
? [
|
|
1098
|
+
"const serveStatic = servePublicDir({",
|
|
1099
|
+
" dir: fileURLToPath(STATIC_ROOT),",
|
|
1100
|
+
...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
|
|
1101
|
+
"})",
|
|
1102
|
+
"const staticResponse = async (req: Request): Promise<Response> =>",
|
|
1103
|
+
' (await serveStatic(req)) ?? new Response("not found", { status: 404 })',
|
|
1104
|
+
]
|
|
1105
|
+
: denoStaticResponse(hashedFiles !== undefined)));
|
|
1032
1106
|
// Bun.serve and Deno.serve delimit the body themselves, so its declared length is the transport
|
|
1033
1107
|
// frame - the guarantee core's own listen() and the Deno adapter mark (see markTrustedBodyFraming).
|
|
1034
1108
|
const markFramed = '(req as unknown as Record<symbol, unknown>)[Symbol.for("nifra.body.trustedFraming")] = true';
|
|
1035
1109
|
if (target === "bun") {
|
|
1036
|
-
lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req, server) {", " const { pathname } = new URL(req.url)",
|
|
1110
|
+
lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req, server) {", " const { pathname } = new URL(req.url)",
|
|
1111
|
+
// Braced: the next emitted line opens with `(`, which would otherwise call the returned value.
|
|
1112
|
+
" if (staticPath(pathname) !== undefined) {", " return staticResponse(req)", " }", ` ${markFramed}`, " // The socket peer, looked up only when something reads it (as core's own listen() does).", " return app.fetch(req, { get clientIp() { return server.requestIP(req)?.address } })", " },", "})",
|
|
1037
1113
|
// The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
|
|
1038
1114
|
// source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
|
|
1039
1115
|
`console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
|
|
1040
1116
|
return `${lines.join("\n")}\n`;
|
|
1041
1117
|
}
|
|
1042
1118
|
if (target === "node") {
|
|
1043
|
-
lines.push(
|
|
1119
|
+
lines.push("await serve(", " {", " async fetch(req, platform) {", " const { pathname } = new URL(req.url)", " if (staticPath(pathname) !== undefined) return staticResponse(req)", " return app.fetch(req, platform)", " },", " },", " { port: Number(process.env.PORT ?? 3000) },", ")");
|
|
1044
1120
|
return `${lines.join("\n")}\n`;
|
|
1045
1121
|
}
|
|
1046
1122
|
// deno
|
|
1047
|
-
lines.push("// @ts-ignore - Deno global is present on the Deno runtime this output targets.", 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req, info) => {', " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", "
|
|
1123
|
+
lines.push("// @ts-ignore - Deno global is present on the Deno runtime this output targets.", 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req, info) => {', " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " return staticResponse(req, filePath, pathname)", " }", ` ${markFramed}`, " return app.fetch(req, { clientIp: info.remoteAddr.hostname })", "})");
|
|
1048
1124
|
return `${lines.join("\n")}\n`;
|
|
1049
1125
|
}
|
|
1126
|
+
/**
|
|
1127
|
+
* The Deno entry's static handler: `servePublicDir`'s checks on Deno's file API. The real path must sit
|
|
1128
|
+
* inside the root and cross no hidden segment, and the opened file must still be the one that path
|
|
1129
|
+
* names, so a link planted in the deployed tree cannot reach a file outside it or a `.env` inside it.
|
|
1130
|
+
* HEAD answers from `stat` without reading; one byte range is served from a seek, several as the
|
|
1131
|
+
* whole file.
|
|
1132
|
+
*/
|
|
1133
|
+
function denoStaticResponse(hashed) {
|
|
1134
|
+
return [
|
|
1135
|
+
`const TYPES: Record<string, string> = ${JSON.stringify(CONTENT_TYPES)}`,
|
|
1136
|
+
"const staticHeaders = (pathname: string): Record<string, string> => ({",
|
|
1137
|
+
' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
|
|
1138
|
+
` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
|
|
1139
|
+
' "x-content-type-options": "nosniff",',
|
|
1140
|
+
' "accept-ranges": "bytes",',
|
|
1141
|
+
"})",
|
|
1142
|
+
"// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1143
|
+
"const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
|
|
1144
|
+
"const hiddenSegment = (rel: string): boolean =>",
|
|
1145
|
+
' rel.split(/[\\\\/]/).some((segment) => segment.startsWith(".") && segment !== ".well-known")',
|
|
1146
|
+
"const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
|
|
1147
|
+
' const missing = () => new Response("not found", { status: 404 })',
|
|
1148
|
+
' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
|
|
1149
|
+
" let file, info",
|
|
1150
|
+
" try {",
|
|
1151
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1152
|
+
" const real: string = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
|
|
1153
|
+
' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
|
|
1154
|
+
" if (hiddenSegment(real.slice(STATIC_REAL.length + 1))) return missing()",
|
|
1155
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1156
|
+
" file = await Deno.open(real)",
|
|
1157
|
+
" // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
|
|
1158
|
+
" // The opened file must be the one at `real`, which must still resolve to itself.",
|
|
1159
|
+
" const opened = await file.stat()",
|
|
1160
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1161
|
+
" const named = await Deno.stat(real)",
|
|
1162
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1163
|
+
" if (opened.dev !== named.dev || opened.ino !== named.ino || (await Deno.realPath(real)) !== real) {",
|
|
1164
|
+
" file.close()",
|
|
1165
|
+
" return missing()",
|
|
1166
|
+
" }",
|
|
1167
|
+
" info = opened",
|
|
1168
|
+
" } catch {",
|
|
1169
|
+
" file?.close()",
|
|
1170
|
+
" return missing()",
|
|
1171
|
+
" }",
|
|
1172
|
+
" const size: number = info.size",
|
|
1173
|
+
" const headers = new Headers(staticHeaders(pathname))",
|
|
1174
|
+
' const range = req.method === "GET" ? parseByteRange(req.headers.get("range"), size) : { kind: "none" as const }',
|
|
1175
|
+
' if (!info.isFile || req.method === "HEAD" || range.kind === "unsatisfiable") {',
|
|
1176
|
+
" file.close()",
|
|
1177
|
+
" if (!info.isFile) return missing()",
|
|
1178
|
+
' if (range.kind !== "unsatisfiable") {',
|
|
1179
|
+
' headers.set("content-length", String(size))',
|
|
1180
|
+
" return new Response(null, { headers })",
|
|
1181
|
+
" }",
|
|
1182
|
+
' headers.delete("content-type")',
|
|
1183
|
+
' headers.set("content-range", "bytes */" + size)',
|
|
1184
|
+
" return new Response(null, { status: 416, headers })",
|
|
1185
|
+
" }",
|
|
1186
|
+
' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
|
|
1187
|
+
" const { start, end } = range.ranges[0]!",
|
|
1188
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1189
|
+
" await file.seek(start, Deno.SeekMode.Start)",
|
|
1190
|
+
" let left = end - start + 1",
|
|
1191
|
+
' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
|
|
1192
|
+
' headers.set("content-length", String(left))',
|
|
1193
|
+
" const body = new ReadableStream<Uint8Array>({",
|
|
1194
|
+
" async pull(controller) {",
|
|
1195
|
+
" const chunk = new Uint8Array(Math.min(left, 65_536))",
|
|
1196
|
+
" const read: number | null = await file.read(chunk)",
|
|
1197
|
+
" if (read === null || read === 0) {",
|
|
1198
|
+
" file.close()",
|
|
1199
|
+
" controller.close()",
|
|
1200
|
+
" return",
|
|
1201
|
+
" }",
|
|
1202
|
+
" controller.enqueue(chunk.subarray(0, read))",
|
|
1203
|
+
" left -= read",
|
|
1204
|
+
" if (left <= 0) {",
|
|
1205
|
+
" file.close()",
|
|
1206
|
+
" controller.close()",
|
|
1207
|
+
" }",
|
|
1208
|
+
" },",
|
|
1209
|
+
" cancel: () => file.close(),",
|
|
1210
|
+
" })",
|
|
1211
|
+
" return new Response(body, { status: 206, headers })",
|
|
1212
|
+
" }",
|
|
1213
|
+
' headers.set("content-length", String(size))',
|
|
1214
|
+
" return new Response(file.readable, { headers })",
|
|
1215
|
+
"}",
|
|
1216
|
+
];
|
|
1217
|
+
}
|
|
1050
1218
|
/** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with
|
|
1051
1219
|
* `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the
|
|
1052
1220
|
* pure {@link aggregateSizeReport}. */
|
|
@@ -1103,6 +1271,7 @@ export const bunBundler = {
|
|
|
1103
1271
|
target: input.target,
|
|
1104
1272
|
...(input.styles !== undefined ? { styles: input.styles } : {}),
|
|
1105
1273
|
...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
|
|
1274
|
+
...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
|
|
1106
1275
|
...(input.plugins ? { plugins: input.plugins } : {}),
|
|
1107
1276
|
...(input.define ? { define: input.define } : {}),
|
|
1108
1277
|
...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
|
|
@@ -1126,6 +1295,36 @@ function assertNoPublicCollision(plan, publicFiles, outDir) {
|
|
|
1126
1295
|
`build's own output in ${outDir}. Rename or move them out of the public directory.`);
|
|
1127
1296
|
}
|
|
1128
1297
|
}
|
|
1298
|
+
/** Prerender a self-hosting build's opted-in routes into `dir` and return the pages its server may
|
|
1299
|
+
* answer from their file: those whose render was not specific to its request. */
|
|
1300
|
+
async function prerenderForServer(prerenderApp, client, routesDir, dir, publicFiles, secrets) {
|
|
1301
|
+
const owned = `/${PRERENDERED_DIR}/`;
|
|
1302
|
+
const clashes = publicFiles.filter((file) => file.toLowerCase().startsWith(owned));
|
|
1303
|
+
if (clashes.length > 0) {
|
|
1304
|
+
throw new Error(`[nifra/web] public file(s) ${clashes.join(", ")} sit under ${owned}, where the build writes its ` +
|
|
1305
|
+
"prerendered pages. Rename or move them out of the public directory.");
|
|
1306
|
+
}
|
|
1307
|
+
let result;
|
|
1308
|
+
try {
|
|
1309
|
+
result = await prerenderRoutes({
|
|
1310
|
+
app: await prerenderApp(client),
|
|
1311
|
+
routes: discoverRoutes(routesDir).routes,
|
|
1312
|
+
outDir: dir,
|
|
1313
|
+
secrets,
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
catch (error) {
|
|
1317
|
+
// Prerendering loads every route module at build time, which a server build never needed before:
|
|
1318
|
+
// one that cannot load there (a connection opened on import, an env var only production sets) costs
|
|
1319
|
+
// the speedup, not the build. A secret in a page still fails it.
|
|
1320
|
+
if (error instanceof SecretScanError)
|
|
1321
|
+
throw error;
|
|
1322
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1323
|
+
console.warn(`[nifra/web] prerendering failed, so the server renders every page on demand: ${error instanceof Error ? error.message : String(error)}`);
|
|
1324
|
+
return {};
|
|
1325
|
+
}
|
|
1326
|
+
return servablePrerenderedPages(result.prerendered);
|
|
1327
|
+
}
|
|
1129
1328
|
/** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
|
|
1130
1329
|
export async function buildTarget(target, options) {
|
|
1131
1330
|
return buildTargetWith(target, options, bunBundler);
|
|
@@ -1208,6 +1407,12 @@ export async function buildTargetWith(target, options, bundler) {
|
|
|
1208
1407
|
if (requestedCssLoading !== undefined || publicFiles.length > 0) {
|
|
1209
1408
|
writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2));
|
|
1210
1409
|
}
|
|
1410
|
+
// A self-hosting server answers its prerendered pages from their file. The static target is all
|
|
1411
|
+
// prerendered pages; the edge targets keep rendering them on demand.
|
|
1412
|
+
const prerenderedPages = options.prerenderApp !== undefined &&
|
|
1413
|
+
(targetPlan.target === "bun" || targetPlan.target === "node")
|
|
1414
|
+
? await prerenderForServer(options.prerenderApp, client, routesDir, `${staticRoot}/${PRERENDERED_DIR}`, publicFiles, secretScan)
|
|
1415
|
+
: undefined;
|
|
1211
1416
|
if (targetPlan.kind === "static") {
|
|
1212
1417
|
if (options.prerenderApp === undefined) {
|
|
1213
1418
|
throw new Error("[nifra/web] buildTarget(static) requires `prerenderApp` (a factory `(client) => createWebApp`)");
|
|
@@ -1253,6 +1458,8 @@ export async function buildTargetWith(target, options, bundler) {
|
|
|
1253
1458
|
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
1254
1459
|
hashedFiles: client.assets,
|
|
1255
1460
|
...(options.clientIp !== undefined ? { clientIp: options.clientIp } : {}),
|
|
1461
|
+
...(client.copyChunks !== undefined ? { copyChunks: true } : {}),
|
|
1462
|
+
...(prerenderedPages !== undefined ? { prerenderedPages } : {}),
|
|
1256
1463
|
}));
|
|
1257
1464
|
const { worker } = await bundler.buildServer({
|
|
1258
1465
|
routesDir,
|
|
@@ -1262,6 +1469,7 @@ export async function buildTargetWith(target, options, bundler) {
|
|
|
1262
1469
|
target: targetPlan.serverTarget,
|
|
1263
1470
|
...(client.css !== undefined ? { styles: client.css } : {}),
|
|
1264
1471
|
...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
|
|
1472
|
+
...(client.copyChunks !== undefined ? { copyChunks: client.copyChunks } : {}),
|
|
1265
1473
|
...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
|
|
1266
1474
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1267
1475
|
...(client.cssLoading !== undefined
|