@nifrajs/web 5.3.0 → 5.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/CHANGELOG.md +86 -0
- package/dist/build-plan.d.ts +6 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +25 -0
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +2 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +2 -0
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +17 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +174 -54
- package/dist/build.js.map +1 -1
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +20 -6
- package/dist/deferred.js.map +1 -1
- package/dist/dev.d.ts +5 -0
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +7 -6
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic.d.ts +6 -0
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +41 -0
- package/dist/diagnostic.js.map +1 -1
- package/dist/host-policy.d.ts +72 -0
- package/dist/host-policy.d.ts.map +1 -0
- package/dist/host-policy.js +149 -0
- package/dist/host-policy.js.map +1 -0
- package/dist/host-reach.d.ts +49 -0
- package/dist/host-reach.d.ts.map +1 -0
- package/dist/host-reach.js +144 -0
- package/dist/host-reach.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/certified-components.d.ts +8 -0
- package/dist/internal/certified-components.d.ts.map +1 -0
- package/dist/internal/certified-components.js +8 -0
- package/dist/internal/certified-components.js.map +1 -0
- package/dist/internal/codegen.d.ts +8 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +15 -4
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/node-adapter.d.ts +19 -0
- package/dist/internal/node-adapter.d.ts.map +1 -0
- package/dist/internal/node-adapter.js +48 -0
- package/dist/internal/node-adapter.js.map +1 -0
- package/dist/internal/output-guard.d.ts +6 -0
- package/dist/internal/output-guard.d.ts.map +1 -1
- package/dist/internal/output-guard.js +118 -16
- package/dist/internal/output-guard.js.map +1 -1
- package/dist/internal/page-execution.d.ts +2 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +16 -11
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +3 -2
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/public-url.d.ts +0 -7
- package/dist/internal/public-url.d.ts.map +1 -1
- package/dist/internal/public-url.js +0 -13
- package/dist/internal/public-url.js.map +1 -1
- package/dist/internal/render-document.d.ts +3 -1
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +10 -4
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/web-app.d.ts +15 -1
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +8 -0
- package/dist/internal/web-app.js.map +1 -1
- package/dist/public-dir.d.ts +5 -7
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +54 -96
- package/dist/public-dir.js.map +1 -1
- package/dist/router.d.ts +2 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +9 -8
- package/dist/router.js.map +1 -1
- package/dist/server-components/vite.d.ts.map +1 -1
- package/dist/server-components/vite.js +8 -6
- package/dist/server-components/vite.js.map +1 -1
- package/dist/service-worker.d.ts +3 -1
- package/dist/service-worker.d.ts.map +1 -1
- package/dist/service-worker.js +29 -8
- package/dist/service-worker.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +3 -3
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +5 -1
- package/dist/zones.d.ts.map +1 -1
- package/dist/zones.js +17 -4
- package/dist/zones.js.map +1 -1
- package/package.json +4 -4
- package/src/build-plan.ts +26 -0
- package/src/build-vite.ts +4 -0
- package/src/build.ts +228 -68
- package/src/deferred.ts +20 -6
- package/src/dev.ts +7 -6
- package/src/diagnostic.ts +43 -0
- package/src/host-policy.ts +219 -0
- package/src/host-reach.ts +177 -0
- package/src/index.ts +10 -2
- package/src/internal/certified-components.ts +7 -0
- package/src/internal/codegen.ts +24 -5
- package/src/internal/node-adapter.ts +57 -0
- package/src/internal/output-guard.ts +166 -15
- package/src/internal/page-execution.ts +23 -9
- package/src/internal/parity.ts +3 -2
- package/src/internal/public-url.ts +0 -13
- package/src/internal/render-document.ts +11 -4
- package/src/internal/web-app.ts +27 -1
- package/src/public-dir.ts +70 -97
- package/src/router.ts +9 -8
- package/src/server-components/vite.ts +11 -6
- package/src/service-worker.ts +32 -9
- package/src/vite.ts +3 -3
- package/src/zones.ts +27 -5
package/dist/build.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatBuildLog } from "./diagnostic.js";
|
|
1
2
|
import { publicEnvDefines } from "./internal/public-env.js";
|
|
2
3
|
export { publicEnvDefines } from "./internal/public-env.js";
|
|
3
4
|
/**
|
|
@@ -11,20 +12,23 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
11
12
|
import { cp, lstat, mkdir, realpath } from "node:fs/promises";
|
|
12
13
|
import { dirname, isAbsolute, join, basename as pathBasename, posix, relative, resolve as resolvePath, sep, } from "node:path";
|
|
13
14
|
import { singleCopyPlugin as declaredSingleCopyPlugin, readSingleCopyDeclaration, } from "@nifrajs/core/single-copy";
|
|
15
|
+
import { isHiddenPath } from "@nifrajs/core/static-file";
|
|
14
16
|
import { Glob } from "bun";
|
|
15
|
-
import { aggregateSizeReport, detectNodeBuiltinsInClient, detectServerOnlyInClient, formatNodeBuiltinLeak, formatServerOnlyLeak, parseBuildTarget, parseManifestClientEntry, parseManifestCopyChunks, parseManifestCssLoading, parseManifestRouteStyles, parseManifestStyles, planBuildTarget, } from "./build-plan.js";
|
|
17
|
+
import { aggregateSizeReport, buildAssetPaths, detectNodeBuiltinsInClient, detectServerOnlyInClient, formatNodeBuiltinLeak, formatServerOnlyLeak, parseBuildTarget, parseManifestBuildAssets, parseManifestClientEntry, parseManifestCopyChunks, parseManifestCssLoading, parseManifestRouteStyles, parseManifestStyles, planBuildTarget, } from "./build-plan.js";
|
|
16
18
|
import { sanitizeOutputNames } from "./chunk-names.js";
|
|
17
19
|
import { normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.js";
|
|
18
20
|
import { discoverRoutes } from "./fs.js";
|
|
19
21
|
import { generateClientEntry, generateServerManifest } from "./index.js";
|
|
22
|
+
import { missingClientEntry } from "./internal/codegen.js";
|
|
20
23
|
import { loadCopySplit } from "./internal/copy-split.js";
|
|
21
24
|
import { assertDisposableDir } from "./internal/disposable-dir.js";
|
|
22
25
|
import { entryChunks } from "./internal/entry-chunks.js";
|
|
23
26
|
import { dedupePolicyFor } from "./internal/identity-policy.js";
|
|
27
|
+
import { nodeAdapterRefusal } from "./internal/node-adapter.js";
|
|
24
28
|
import { assertDevelopmentProductionParity, assertIdentityParity, collectDevelopmentParityInput, } from "./internal/parity.js";
|
|
25
29
|
import { servablePrerenderedPages, } from "./internal/prerendered-pages.js";
|
|
26
30
|
import { privateEnvCheck } from "./internal/private-env.js";
|
|
27
|
-
import {
|
|
31
|
+
import { publicUrlPath } from "./internal/public-url.js";
|
|
28
32
|
import { assertNoSecrets, buildEnvironment, emittedScanFiles, formatSecretFindings, graphScanInput, originName, publicScanFiles, SecretScanError, scanForSecrets, textOf, } from "./internal/secret-scan.js";
|
|
29
33
|
import { generateServerFnStub, SERVER_FN_MODULE, serverFnNamespace, } from "./internal/server-boundary.js";
|
|
30
34
|
import { formatUnsupportedBuiltins, unsupportedBuiltins } from "./internal/target-compat.js";
|
|
@@ -34,7 +38,7 @@ import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.js";
|
|
|
34
38
|
import { zoneGuardPlugin } from "./plugins/zone-guard.js";
|
|
35
39
|
// `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
|
|
36
40
|
import { prerenderRoutes } from "./prerender.js";
|
|
37
|
-
import {
|
|
41
|
+
import { IMMUTABLE, ONE_DAY } from "./public-dir.js";
|
|
38
42
|
import { createZoneClassifier } from "./zones.js";
|
|
39
43
|
export * from "./build-plan.js";
|
|
40
44
|
const basename = (path) => pathBasename(path);
|
|
@@ -180,7 +184,7 @@ export async function copyPublicDir(from, to) {
|
|
|
180
184
|
const sourceReal = await realpath(source);
|
|
181
185
|
const copied = [];
|
|
182
186
|
for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
|
|
183
|
-
if (
|
|
187
|
+
if (isHiddenPath(rel))
|
|
184
188
|
continue;
|
|
185
189
|
const candidate = join(source, rel);
|
|
186
190
|
const candidateStat = await lstat(candidate);
|
|
@@ -211,6 +215,7 @@ export async function copyPublicDir(from, to) {
|
|
|
211
215
|
export function resyncServerManifestSource(source, manifest, routesPrefix) {
|
|
212
216
|
const cssLoading = parseManifestCssLoading(source);
|
|
213
217
|
const copyChunks = parseManifestCopyChunks(source);
|
|
218
|
+
const buildAssets = parseManifestBuildAssets(source);
|
|
214
219
|
return generateServerManifest(manifest, {
|
|
215
220
|
resolve: (file) => `${routesPrefix}${file}`,
|
|
216
221
|
clientEntry: parseManifestClientEntry(source) ?? "",
|
|
@@ -218,6 +223,7 @@ export function resyncServerManifestSource(source, manifest, routesPrefix) {
|
|
|
218
223
|
routeStyles: parseManifestRouteStyles(source),
|
|
219
224
|
...(cssLoading !== undefined ? { cssLoading } : {}),
|
|
220
225
|
...(copyChunks !== undefined ? { copyChunks } : {}),
|
|
226
|
+
...(buildAssets !== undefined ? { buildAssets } : {}),
|
|
221
227
|
lazy: MANIFEST_IS_LAZY.test(source),
|
|
222
228
|
});
|
|
223
229
|
}
|
|
@@ -371,7 +377,7 @@ export async function buildClient(options) {
|
|
|
371
377
|
throw new Error(verdict);
|
|
372
378
|
if (refused.size > 0)
|
|
373
379
|
throw new Error(formatRefusedFiles(refused, root));
|
|
374
|
-
throw new Error(`[nifra/web] client build failed:\n${result.logs.map((l) =>
|
|
380
|
+
throw new Error(`[nifra/web] client build failed:\n${result.logs.map((l) => formatBuildLog(l, root)).join("\n")}`);
|
|
375
381
|
}
|
|
376
382
|
const emitted = await Promise.all(result.outputs.map(async (out) => {
|
|
377
383
|
if (out.kind === "sourcemap")
|
|
@@ -815,6 +821,7 @@ function formatRefusedFiles(refused, root) {
|
|
|
815
821
|
});
|
|
816
822
|
return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`;
|
|
817
823
|
}
|
|
824
|
+
export { checkHostReach, } from "./host-reach.js";
|
|
818
825
|
export { importGlobPlugin } from "./plugins/import-glob.js";
|
|
819
826
|
export { zoneGuardPlugin } from "./plugins/zone-guard.js";
|
|
820
827
|
/**
|
|
@@ -864,7 +871,22 @@ const solidWebServerPlugin = (from) => ({
|
|
|
864
871
|
* silently ships a broken worker.
|
|
865
872
|
*/
|
|
866
873
|
export async function buildServer(options) {
|
|
867
|
-
const { routesDir, serverEntry, outDir,
|
|
874
|
+
const { routesDir, serverEntry, outDir, client } = options;
|
|
875
|
+
if (client !== undefined &&
|
|
876
|
+
(typeof client !== "object" ||
|
|
877
|
+
client === null ||
|
|
878
|
+
typeof client.entry !== "string" ||
|
|
879
|
+
!Array.isArray(client.assets))) {
|
|
880
|
+
throw new TypeError("[nifra/web] buildServer `client` must be the manifest buildClient returned ({ entry, assets, ... })");
|
|
881
|
+
}
|
|
882
|
+
const clientEntry = options.clientEntry ?? client?.entry;
|
|
883
|
+
if (clientEntry === undefined)
|
|
884
|
+
throw missingClientEntry(clientEntry);
|
|
885
|
+
const styles = options.styles ?? client?.css;
|
|
886
|
+
const routeStyles = options.routeStyles ?? client?.routeStyles;
|
|
887
|
+
const copyChunks = options.copyChunks ?? client?.copyChunks;
|
|
888
|
+
const cssLoading = options.cssLoading ?? client?.cssLoading;
|
|
889
|
+
const buildAssets = options.buildAssets ?? (client === undefined ? undefined : buildAssetPaths(client));
|
|
868
890
|
const entryDir = dirname(serverEntry);
|
|
869
891
|
const manifestFile = options.manifestFile ?? "server-manifest.ts";
|
|
870
892
|
// Default: import routes relative from the generated manifest (next to serverEntry) to routesDir.
|
|
@@ -886,7 +908,8 @@ export async function buildServer(options) {
|
|
|
886
908
|
styles,
|
|
887
909
|
routeStyles,
|
|
888
910
|
...(cssLoading !== undefined ? { cssLoading } : {}),
|
|
889
|
-
...(
|
|
911
|
+
...(copyChunks !== undefined ? { copyChunks } : {}),
|
|
912
|
+
...(buildAssets !== undefined ? { buildAssets } : {}),
|
|
890
913
|
lazy,
|
|
891
914
|
}));
|
|
892
915
|
const result = await Bun.build({
|
|
@@ -920,7 +943,8 @@ export async function buildServer(options) {
|
|
|
920
943
|
],
|
|
921
944
|
});
|
|
922
945
|
if (!result.success) {
|
|
923
|
-
|
|
946
|
+
const root = resolvePath(options.root ?? dirname(routesDir));
|
|
947
|
+
throw new Error(`[nifra/web] server build failed:\n${result.logs.map((l) => formatBuildLog(l, root)).join("\n")}`);
|
|
924
948
|
}
|
|
925
949
|
// Named as the metafile names outputs: relative to `outdir`.
|
|
926
950
|
const nameOf = (path) => relative(outDir, path).replaceAll("\\", "/");
|
|
@@ -976,6 +1000,7 @@ export const SERVER_ENTRY_OPTIONS = [
|
|
|
976
1000
|
"csp",
|
|
977
1001
|
"nonce",
|
|
978
1002
|
"i18n",
|
|
1003
|
+
"hostPolicy",
|
|
979
1004
|
];
|
|
980
1005
|
/**
|
|
981
1006
|
* The header each edge platform sets to the caller's address, replacing any value the client sent.
|
|
@@ -987,6 +1012,10 @@ const PLATFORM_CLIENT_IP_HEADERS = {
|
|
|
987
1012
|
cloudflare: "cf-connecting-ip",
|
|
988
1013
|
vercel: "x-real-ip",
|
|
989
1014
|
};
|
|
1015
|
+
// A static path a host's policy refuses is not answered from disk: it falls through to the app, whose
|
|
1016
|
+
// own copy of the policy refuses it the same way it refuses a page.
|
|
1017
|
+
const HOST_ALLOWS_STATIC = ' && hostPolicy.allows(request.headers.get("host") ?? "", pathname, buildAssets)';
|
|
1018
|
+
const NODE_STATIC_ALLOW = " allow: (host: string, pathname: string) => hostPolicy.allows(host, pathname, buildAssets),";
|
|
990
1019
|
/** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
|
|
991
1020
|
const PRERENDERED_DIR = "_prerendered";
|
|
992
1021
|
/**
|
|
@@ -1023,20 +1052,20 @@ export function generateServerEntry(options) {
|
|
|
1023
1052
|
if (target === "static") {
|
|
1024
1053
|
throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)");
|
|
1025
1054
|
}
|
|
1026
|
-
// Bun
|
|
1027
|
-
//
|
|
1055
|
+
// Bun serves the client bundle through `servePublicDir` and Node through @nifrajs/node's own reader,
|
|
1056
|
+
// both on core's static-file policy. Deno bundles for the browser condition, where `node:fs` is a
|
|
1057
|
+
// stub, so its entry runs the same checks on Deno's own file API.
|
|
1028
1058
|
const nodeFs = target === "bun" || target === "node";
|
|
1029
1059
|
const prerendered = Object.entries(options.prerenderedPages ?? {});
|
|
1030
1060
|
if (prerendered.length > 0 && !nodeFs) {
|
|
1031
1061
|
throw new Error(`[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`);
|
|
1032
1062
|
}
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
];
|
|
1063
|
+
const webImports = ["createWebApp"];
|
|
1064
|
+
if (prerendered.length > 0)
|
|
1065
|
+
webImports.push("prerenderedPageReader");
|
|
1066
|
+
if (target === "bun")
|
|
1067
|
+
webImports.push("servePublicDir");
|
|
1068
|
+
const lines = [`import { ${webImports.join(", ")} } from "@nifrajs/web"`];
|
|
1040
1069
|
if (backendImport !== undefined)
|
|
1041
1070
|
lines.push('import { inProcessClient } from "@nifrajs/client"');
|
|
1042
1071
|
lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`);
|
|
@@ -1048,9 +1077,18 @@ export function generateServerEntry(options) {
|
|
|
1048
1077
|
if (backendImport !== undefined) {
|
|
1049
1078
|
lines.push(`import { backend } from ${JSON.stringify(backendImport)}`);
|
|
1050
1079
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1080
|
+
// A host policy is checked before the static files too, which takes the build's own file set.
|
|
1081
|
+
const hostPolicy = importedOptions.includes("hostPolicy");
|
|
1082
|
+
const manifestImports = [
|
|
1083
|
+
...(hostPolicy ? ["buildAssets"] : []),
|
|
1084
|
+
"clientEntry",
|
|
1085
|
+
...(options.copyChunks === true ? ["copyChunks"] : []),
|
|
1086
|
+
"cssLoading",
|
|
1087
|
+
"manifest",
|
|
1088
|
+
"styles",
|
|
1089
|
+
"routeStyles",
|
|
1090
|
+
];
|
|
1091
|
+
lines.push(`import { ${manifestImports.join(", ")} } from "./server-manifest"`);
|
|
1054
1092
|
// Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
|
|
1055
1093
|
if (target === "cloudflare")
|
|
1056
1094
|
lines.push('import { toFetchHandler } from "@nifrajs/core/server"');
|
|
@@ -1058,14 +1096,15 @@ export function generateServerEntry(options) {
|
|
|
1058
1096
|
lines.push('import { serve } from "@nifrajs/node"');
|
|
1059
1097
|
if (nodeFs)
|
|
1060
1098
|
lines.push('import { fileURLToPath } from "node:url"');
|
|
1061
|
-
if (target === "deno")
|
|
1062
|
-
lines.push('import { parseByteRange } from "@nifrajs/core/range"');
|
|
1099
|
+
if (target === "deno") {
|
|
1100
|
+
lines.push('import { parseByteRange } from "@nifrajs/core/range"', 'import { CONTENT_SNAPSHOT_MAX, contentTags, contentTypeOf, ifRangeAllowsPartial, isHiddenPath, isNotModified, lastModifiedOf, streamedLastModifiedOf } from "@nifrajs/core/static-file"', 'import { createHash } from "node:crypto"');
|
|
1101
|
+
}
|
|
1063
1102
|
if (prerendered.length > 0) {
|
|
1064
1103
|
const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]));
|
|
1065
1104
|
const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]));
|
|
1066
1105
|
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
1106
|
}
|
|
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)},`,
|
|
1107
|
+
lines.push("", "const app = createWebApp({", " adapter,", ...(useImport !== undefined ? [" use,"] : []), ...(prerendered.length > 0 ? [" prerenderedPages,"] : []), ...importedOptions.map((name) => ` ${name},`), ...(hostPolicy ? [" buildAssets,"] : []), " manifest,", " clientEntry,", " styles,", " routeStyles,", " cssLoading,", ...(options.copyChunks === true ? [" copyChunks,"] : []), ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`,
|
|
1069
1108
|
// Core's own `{ header }` trust, so the mounted backend and every loader see the derived caller.
|
|
1070
1109
|
...(clientIpHeader !== undefined
|
|
1071
1110
|
? [` server: { clientIp: { header: ${JSON.stringify(clientIpHeader)} } },`]
|
|
@@ -1078,7 +1117,7 @@ export function generateServerEntry(options) {
|
|
|
1078
1117
|
// rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting
|
|
1079
1118
|
// the router 404 it, which makes correctness independent of how much of the list fit: the exclude
|
|
1080
1119
|
// list decides only how many static requests skip the worker, never which files exist.
|
|
1081
|
-
lines.push(`const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, "const handler = toFetchHandler(app)", "type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }", "export default {", " fetch(request: Request, env: Assets, ctx: ExecutionContext) {", " const { pathname } = new URL(request.url)", " // Only the build's own outputs: the hashed bundle prefix and the files it copied.",
|
|
1120
|
+
lines.push(`const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, "const handler = toFetchHandler(app)", "type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }", "export default {", " fetch(request: Request, env: Assets, ctx: ExecutionContext) {", " const { pathname } = new URL(request.url)", " // Only the build's own outputs: the hashed bundle prefix and the files it copied.", ` if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))${hostPolicy ? HOST_ALLOWS_STATIC : ""}) {`, " return env.ASSETS.fetch(request)", " }", " return handler.fetch(request, env as never, ctx)", " },", "}");
|
|
1082
1121
|
return `${lines.join("\n")}\n`;
|
|
1083
1122
|
}
|
|
1084
1123
|
if (target === "vercel") {
|
|
@@ -1088,12 +1127,19 @@ export function generateServerEntry(options) {
|
|
|
1088
1127
|
// bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
|
|
1089
1128
|
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
|
|
1090
1129
|
? [`const HASHED_FILES = new Set(${JSON.stringify(hashedFiles)})`]
|
|
1091
|
-
: [])
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
...(
|
|
1130
|
+
: []));
|
|
1131
|
+
if (target === "node") {
|
|
1132
|
+
// The app itself with the adapter's own reader, not a `{ fetch }` wrapper around app.fetch: the
|
|
1133
|
+
// wrapper hid the node-direct lane, so every page built an undici Request and drained a Response,
|
|
1134
|
+
// and every file went through one too. The root mount serves only the files the build listed.
|
|
1135
|
+
lines.push("await serve(app, {", " port: Number(process.env.PORT ?? 3000),", " static: [", " {", ' dir: fileURLToPath(new URL("./assets/", import.meta.url)),', ' prefix: "/assets",', ...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []), ` cacheControl: ${JSON.stringify(ONE_DAY)},`, ...(hostPolicy ? [NODE_STATIC_ALLOW] : []), " },", " {", " dir: fileURLToPath(STATIC_ROOT),", ' prefix: "/",', " files: PUBLIC_FILES,", " immutable: false,", ` cacheControl: ${JSON.stringify(ONE_DAY)},`, ...(hostPolicy ? [NODE_STATIC_ALLOW] : []), " },", " ],", "})");
|
|
1136
|
+
return `${lines.join("\n")}\n`;
|
|
1137
|
+
}
|
|
1138
|
+
lines.push("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", "}",
|
|
1139
|
+
// The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, content
|
|
1140
|
+
// tags, byte ranges and conditional requests, and a descriptor opened only once the real path is
|
|
1141
|
+
// inside the root, so a link planted in the deployed tree cannot reach a file outside it.
|
|
1142
|
+
...(target === "bun"
|
|
1097
1143
|
? [
|
|
1098
1144
|
"const serveStatic = servePublicDir({",
|
|
1099
1145
|
" dir: fileURLToPath(STATIC_ROOT),",
|
|
@@ -1109,49 +1155,51 @@ export function generateServerEntry(options) {
|
|
|
1109
1155
|
if (target === "bun") {
|
|
1110
1156
|
lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req, server) {", " const { pathname } = new URL(req.url)",
|
|
1111
1157
|
// Braced: the next emitted line opens with `(`, which would otherwise call the returned value.
|
|
1112
|
-
|
|
1158
|
+
` if (staticPath(pathname) !== undefined${hostPolicy ? HOST_ALLOWS_STATIC.replaceAll("request", "req") : ""}) {`, " 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 } })", " },", "})",
|
|
1113
1159
|
// The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
|
|
1114
1160
|
// source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
|
|
1115
1161
|
`console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
|
|
1116
1162
|
return `${lines.join("\n")}\n`;
|
|
1117
1163
|
}
|
|
1118
|
-
if (target === "node") {
|
|
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) },", ")");
|
|
1120
|
-
return `${lines.join("\n")}\n`;
|
|
1121
|
-
}
|
|
1122
1164
|
// deno
|
|
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)",
|
|
1165
|
+
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${hostPolicy ? HOST_ALLOWS_STATIC.replaceAll("request", "req") : ""}) {`, " return staticResponse(req, filePath, pathname)", " }", ` ${markFramed}`, " return app.fetch(req, { clientIp: info.remoteAddr.hostname })", "})");
|
|
1124
1166
|
return `${lines.join("\n")}\n`;
|
|
1125
1167
|
}
|
|
1126
1168
|
/**
|
|
1127
|
-
* The Deno entry's static handler: `servePublicDir`'s checks on Deno's file API. The real
|
|
1128
|
-
* inside the root and cross no hidden segment, and the opened file must still be the one
|
|
1129
|
-
* names, so a link planted in the deployed tree cannot reach a file outside it or a `.env`
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1169
|
+
* The Deno entry's static handler: `servePublicDir`'s checks and answers on Deno's file API. The real
|
|
1170
|
+
* path must sit inside the root and cross no hidden segment, and the opened file must still be the one
|
|
1171
|
+
* that path names, so a link planted in the deployed tree cannot reach a file outside it or a `.env`
|
|
1172
|
+
* inside it. The file is typed by its real path, tagged by its content and dated only by an mtime its
|
|
1173
|
+
* own last write set, through the same policy as servePublicDir: a current cached copy is a 304, and a
|
|
1174
|
+
* range survives only an `If-Range` naming that tag. A file is read once to tag it, and one small
|
|
1175
|
+
* enough is then served from those bytes; otherwise one byte range is served from a seek, several as
|
|
1176
|
+
* the whole file.
|
|
1132
1177
|
*/
|
|
1133
1178
|
function denoStaticResponse(hashed) {
|
|
1134
1179
|
return [
|
|
1135
|
-
|
|
1136
|
-
"
|
|
1137
|
-
' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
|
|
1180
|
+
"const staticHeaders = (pathname: string, file: string): Record<string, string> => ({",
|
|
1181
|
+
' "content-type": contentTypeOf(file),',
|
|
1138
1182
|
` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
|
|
1139
1183
|
' "x-content-type-options": "nosniff",',
|
|
1140
1184
|
' "accept-ranges": "bytes",',
|
|
1141
1185
|
"})",
|
|
1142
1186
|
"// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1143
1187
|
"const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
|
|
1144
|
-
"
|
|
1145
|
-
|
|
1188
|
+
"// One per process: the tag of a file is remembered by its real path.",
|
|
1189
|
+
"// A stat costs Deno more than hashing a snapshot of up to 24 KiB, so only a larger one is restatted.",
|
|
1190
|
+
"const STATIC_TAGS = contentTags(createHash, { rehashMax: 24 * 1024 })",
|
|
1191
|
+
"// Up to this many bytes are read whole to tag a file, and the response is then served from them.",
|
|
1192
|
+
"const STATIC_INLINE_MAX = CONTENT_SNAPSHOT_MAX",
|
|
1146
1193
|
"const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
|
|
1147
1194
|
' const missing = () => new Response("not found", { status: 404 })',
|
|
1148
1195
|
' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
|
|
1149
1196
|
" let file, info",
|
|
1197
|
+
' let real = ""',
|
|
1150
1198
|
" try {",
|
|
1151
1199
|
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1152
|
-
"
|
|
1200
|
+
" real = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
|
|
1153
1201
|
' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
|
|
1154
|
-
" if (
|
|
1202
|
+
" if (isHiddenPath(real.slice(STATIC_REAL.length + 1))) return missing()",
|
|
1155
1203
|
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1156
1204
|
" file = await Deno.open(real)",
|
|
1157
1205
|
" // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
|
|
@@ -1169,12 +1217,60 @@ function denoStaticResponse(hashed) {
|
|
|
1169
1217
|
" file?.close()",
|
|
1170
1218
|
" return missing()",
|
|
1171
1219
|
" }",
|
|
1220
|
+
" if (!info.isFile) {",
|
|
1221
|
+
" file.close()",
|
|
1222
|
+
" return missing()",
|
|
1223
|
+
" }",
|
|
1172
1224
|
" const size: number = info.size",
|
|
1173
|
-
" const headers = new Headers(staticHeaders(pathname))",
|
|
1174
|
-
|
|
1175
|
-
|
|
1225
|
+
" const headers = new Headers(staticHeaders(pathname, real))",
|
|
1226
|
+
" // Deno reports a field the platform does not keep as null; NaN never matches a remembered version.",
|
|
1227
|
+
" const versionOf = (stat: { dev: number; ino: number | null; size: number; mtime: Date | null; ctime: Date | null }) => ({",
|
|
1228
|
+
" dev: stat.dev,",
|
|
1229
|
+
" ino: stat.ino ?? Number.NaN,",
|
|
1230
|
+
" size: stat.size,",
|
|
1231
|
+
" mtimeMs: stat.mtime?.getTime() ?? Number.NaN,",
|
|
1232
|
+
" ctimeMs: stat.ctime?.getTime() ?? Number.NaN,",
|
|
1233
|
+
" })",
|
|
1234
|
+
" const version = versionOf(info)",
|
|
1235
|
+
" const snapshot = size <= STATIC_INLINE_MAX",
|
|
1236
|
+
" const lastModified = snapshot ? lastModifiedOf(version) : streamedLastModifiedOf(version)",
|
|
1237
|
+
' const ifNoneMatch = req.headers.get("if-none-match")',
|
|
1238
|
+
' const ifModifiedSince = req.headers.get("if-modified-since")',
|
|
1239
|
+
" let whole: Uint8Array<ArrayBuffer> | undefined",
|
|
1240
|
+
" // A HEAD or a 304 sends none of the file, so the tag remembered for this settled version",
|
|
1241
|
+
" // answers it from the stat alone: a write since that tag would show in the stat.",
|
|
1242
|
+
" let etag: string | undefined = snapshot ? STATIC_TAGS.cached(real, version) : undefined",
|
|
1243
|
+
" try {",
|
|
1244
|
+
' if (snapshot && (etag === undefined || !(req.method === "HEAD" || isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)))) {',
|
|
1245
|
+
" whole = new Uint8Array(size)",
|
|
1246
|
+
" let filled = 0",
|
|
1247
|
+
" while (filled < size) {",
|
|
1248
|
+
" const read: number | null = await file.read(whole.subarray(filled))",
|
|
1249
|
+
" if (read === null || read === 0) break",
|
|
1250
|
+
" filled += read",
|
|
1251
|
+
" }",
|
|
1252
|
+
" if (filled < size) whole = whole.subarray(0, filled)",
|
|
1253
|
+
" etag = await STATIC_TAGS.ofSnapshot(real, version, whole, async () => versionOf(await file.stat()))",
|
|
1254
|
+
" }",
|
|
1255
|
+
" } catch {",
|
|
1256
|
+
" file.close()",
|
|
1257
|
+
" return missing()",
|
|
1258
|
+
" }",
|
|
1259
|
+
' if (etag !== undefined) headers.set("etag", etag)',
|
|
1260
|
+
' if (lastModified !== undefined) headers.set("last-modified", new Date(lastModified).toUTCString())',
|
|
1261
|
+
" if (isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)) {",
|
|
1262
|
+
" file.close()",
|
|
1263
|
+
' headers.delete("content-type")',
|
|
1264
|
+
" return new Response(null, { status: 304, headers })",
|
|
1265
|
+
" }",
|
|
1266
|
+
' const rangeHeader = req.method === "GET" ? req.headers.get("range") : null',
|
|
1267
|
+
" const range =",
|
|
1268
|
+
" // A streamed body's date is weak: a write during the stream must never splice two versions.",
|
|
1269
|
+
' rangeHeader !== null && ifRangeAllowsPartial(req.headers.get("if-range"), etag, snapshot ? lastModified : undefined)',
|
|
1270
|
+
" ? parseByteRange(rangeHeader, size)",
|
|
1271
|
+
' : { kind: "none" as const }',
|
|
1272
|
+
' if (req.method === "HEAD" || range.kind === "unsatisfiable") {',
|
|
1176
1273
|
" file.close()",
|
|
1177
|
-
" if (!info.isFile) return missing()",
|
|
1178
1274
|
' if (range.kind !== "unsatisfiable") {',
|
|
1179
1275
|
' headers.set("content-length", String(size))',
|
|
1180
1276
|
" return new Response(null, { headers })",
|
|
@@ -1185,10 +1281,16 @@ function denoStaticResponse(hashed) {
|
|
|
1185
1281
|
" }",
|
|
1186
1282
|
' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
|
|
1187
1283
|
" const { start, end } = range.ranges[0]!",
|
|
1284
|
+
' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
|
|
1285
|
+
" if (whole !== undefined) {",
|
|
1286
|
+
" file.close()",
|
|
1287
|
+
" const part = whole.subarray(start, end + 1)",
|
|
1288
|
+
' headers.set("content-length", String(part.byteLength))',
|
|
1289
|
+
" return new Response(part, { status: 206, headers })",
|
|
1290
|
+
" }",
|
|
1188
1291
|
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1189
1292
|
" await file.seek(start, Deno.SeekMode.Start)",
|
|
1190
1293
|
" let left = end - start + 1",
|
|
1191
|
-
' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
|
|
1192
1294
|
' headers.set("content-length", String(left))',
|
|
1193
1295
|
" const body = new ReadableStream<Uint8Array>({",
|
|
1194
1296
|
" async pull(controller) {",
|
|
@@ -1210,6 +1312,11 @@ function denoStaticResponse(hashed) {
|
|
|
1210
1312
|
" })",
|
|
1211
1313
|
" return new Response(body, { status: 206, headers })",
|
|
1212
1314
|
" }",
|
|
1315
|
+
" if (whole !== undefined) {",
|
|
1316
|
+
" file.close()",
|
|
1317
|
+
' headers.set("content-length", String(whole.byteLength))',
|
|
1318
|
+
" return new Response(whole, { headers })",
|
|
1319
|
+
" }",
|
|
1213
1320
|
' headers.set("content-length", String(size))',
|
|
1214
1321
|
" return new Response(file.readable, { headers })",
|
|
1215
1322
|
"}",
|
|
@@ -1272,6 +1379,7 @@ export const bunBundler = {
|
|
|
1272
1379
|
...(input.styles !== undefined ? { styles: input.styles } : {}),
|
|
1273
1380
|
...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
|
|
1274
1381
|
...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
|
|
1382
|
+
...(input.buildAssets !== undefined ? { buildAssets: input.buildAssets } : {}),
|
|
1275
1383
|
...(input.plugins ? { plugins: input.plugins } : {}),
|
|
1276
1384
|
...(input.define ? { define: input.define } : {}),
|
|
1277
1385
|
...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
|
|
@@ -1350,6 +1458,12 @@ export async function buildTargetWith(target, options, bundler) {
|
|
|
1350
1458
|
];
|
|
1351
1459
|
assertDisposableDir("outDir", outDir, sources);
|
|
1352
1460
|
assertDisposableDir("workDir", workDir, sources);
|
|
1461
|
+
// Before anything is cleared, so a refused build leaves the last deploy dir in place.
|
|
1462
|
+
if (targetPlan.target === "node") {
|
|
1463
|
+
const refusal = nodeAdapterRefusal(workDir);
|
|
1464
|
+
if (refusal !== undefined)
|
|
1465
|
+
throw new Error(refusal);
|
|
1466
|
+
}
|
|
1353
1467
|
const { rmSync } = await import("node:fs");
|
|
1354
1468
|
rmSync(outDir, { recursive: true, force: true });
|
|
1355
1469
|
rmSync(workDir, { recursive: true, force: true });
|
|
@@ -1470,6 +1584,7 @@ export async function buildTargetWith(target, options, bundler) {
|
|
|
1470
1584
|
...(client.css !== undefined ? { styles: client.css } : {}),
|
|
1471
1585
|
...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
|
|
1472
1586
|
...(client.copyChunks !== undefined ? { copyChunks: client.copyChunks } : {}),
|
|
1587
|
+
buildAssets: buildAssetPaths(client),
|
|
1473
1588
|
...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
|
|
1474
1589
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1475
1590
|
...(client.cssLoading !== undefined
|
|
@@ -1487,7 +1602,12 @@ export async function buildTargetWith(target, options, bundler) {
|
|
|
1487
1602
|
cpSync(worker, `${outDir}/${targetPlan.outputFile}`);
|
|
1488
1603
|
// The app's real patterns, so a directory is only collapsed into a glob once the route table
|
|
1489
1604
|
// proves nothing can be served beneath it.
|
|
1490
|
-
|
|
1605
|
+
// A host policy must see every file request, which Pages answers from the CDN before the worker
|
|
1606
|
+
// when the path is excluded, so with one nothing is: each file costs an invocation, and the
|
|
1607
|
+
// worker serves the allowed ones from ASSETS.
|
|
1608
|
+
const rules = options.optionImports?.hostPolicy === undefined
|
|
1609
|
+
? cloudflareRouteRules(publicFiles, discoverRoutes(routesDir).routes.map((route) => route.pattern))
|
|
1610
|
+
: { include: ["/*"], exclude: [], omitted: [] };
|
|
1491
1611
|
writeFileSync(`${outDir}/_routes.json`, `${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`);
|
|
1492
1612
|
// Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a
|
|
1493
1613
|
// worker invocation, and that trade should be visible rather than inferred from a latency graph.
|