@nifrajs/web 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +487 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +112 -25
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +283 -64
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -0
- package/dist/client.js.map +1 -1
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +61 -4
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +37 -0
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +87 -4
- package/dist/draft.js.map +1 -1
- package/dist/fs.d.ts +14 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +116 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +565 -53
- package/dist/index.js.map +1 -1
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +1 -14
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +51 -8
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +130 -14
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +64 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +114 -31
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts +10 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +44 -12
- package/dist/vite.js.map +1 -1
- package/package.json +17 -2
- package/src/build-vite.ts +606 -0
- package/src/build.ts +432 -73
- package/src/bun-dev-entry.ts +184 -0
- package/src/client.ts +8 -0
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +467 -65
- package/src/draft.ts +116 -4
- package/src/fs.ts +22 -4
- package/src/index.ts +727 -57
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/isr.ts +1 -13
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +188 -13
- package/src/module-graph.ts +146 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/public-dir.ts +108 -0
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +198 -34
- package/src/vite.ts +62 -20
package/dist/build.js
CHANGED
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* are Bun-specific and never on the request path (own subpath, like `@nifrajs/web/fs`); the *output*
|
|
6
6
|
* runs on any runtime.
|
|
7
7
|
*/
|
|
8
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
-
import {
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { cp, lstat, mkdir, realpath } from "node:fs/promises";
|
|
10
|
+
import { dirname, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
11
|
+
import { Glob } from "bun";
|
|
10
12
|
import { sanitizeOutputNames } from "./chunk-names.js";
|
|
11
13
|
import { discoverRoutes } from "./fs.js";
|
|
12
14
|
import { generateClientEntry, generateServerManifest } from "./index.js";
|
|
13
15
|
// `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
|
|
16
|
+
import { fromBunMetafile } from "./module-graph.js";
|
|
14
17
|
import { prerenderRoutes } from "./prerender.js";
|
|
15
18
|
// Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
|
|
16
19
|
// run after `buildClient`.
|
|
@@ -34,6 +37,156 @@ export function publicEnvDefines(prefix, env) {
|
|
|
34
37
|
}
|
|
35
38
|
return defines;
|
|
36
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Percent-encode one path segment exactly the way a browser encodes it into a request URL.
|
|
42
|
+
*
|
|
43
|
+
* NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw -
|
|
44
|
+
* so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives
|
|
45
|
+
* as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI`
|
|
46
|
+
* agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must
|
|
47
|
+
* be escaped explicitly here.
|
|
48
|
+
*/
|
|
49
|
+
function encodePathSegment(segment) {
|
|
50
|
+
return encodeURI(segment).replace(/[?#]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any
|
|
54
|
+
* single rule longer than 100 characters.
|
|
55
|
+
*/
|
|
56
|
+
const CF_MAX_ROUTE_RULES = 100;
|
|
57
|
+
const CF_MAX_RULE_LENGTH = 100;
|
|
58
|
+
function firstSegmentOf(pattern) {
|
|
59
|
+
const first = pattern.split("/").find((segment) => segment !== "");
|
|
60
|
+
if (first === undefined)
|
|
61
|
+
return { kind: "root" };
|
|
62
|
+
return /^[A-Za-z0-9._-]+$/.test(first) ? { kind: "literal", value: first } : { kind: "dynamic" };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Directories under `public/` whose every file can be replaced by one `/<dir>/*` rule.
|
|
66
|
+
*
|
|
67
|
+
* Safe only when no route can ever be served beneath that directory, because the glob does not merely
|
|
68
|
+
* describe today's files - it hands Pages every future path under the prefix, and the worker never sees
|
|
69
|
+
* them. A `public/blog/hero.png` next to a `/blog/:slug` route is the ordinary case that breaks: the
|
|
70
|
+
* glob would send `/blog/my-post` to the CDN, which has no such file, and the page 404s in production
|
|
71
|
+
* only. So a directory collapses only when every route pattern starts with a literal segment that is
|
|
72
|
+
* something else. One dynamic first segment anywhere (`/:locale/…`) disables collapsing entirely, which
|
|
73
|
+
* is correct - such a route can match under any directory name.
|
|
74
|
+
*
|
|
75
|
+
* A collapsed directory also gives up the app's own 404 page for a missing file beneath it, since the
|
|
76
|
+
* request never reaches the worker. That is the right trade for a directory that holds only static
|
|
77
|
+
* files: a missing image should fail as a fast CDN 404, not as an HTML error page.
|
|
78
|
+
*/
|
|
79
|
+
function collapsibleDirs(publicFiles, routePatterns) {
|
|
80
|
+
const reserved = new Set();
|
|
81
|
+
for (const pattern of routePatterns) {
|
|
82
|
+
const first = firstSegmentOf(pattern);
|
|
83
|
+
// A dynamic first segment can match anywhere, so nothing is collapsible at all.
|
|
84
|
+
if (first.kind === "dynamic")
|
|
85
|
+
return new Set();
|
|
86
|
+
// `root` reserves nothing: `/` cannot match a path below a directory.
|
|
87
|
+
if (first.kind === "literal")
|
|
88
|
+
reserved.add(first.value);
|
|
89
|
+
}
|
|
90
|
+
const dirs = new Set();
|
|
91
|
+
for (const file of publicFiles) {
|
|
92
|
+
const slash = file.indexOf("/", 1);
|
|
93
|
+
if (slash === -1)
|
|
94
|
+
continue; // a root-level file has no directory to collapse into
|
|
95
|
+
const dir = file.slice(1, slash);
|
|
96
|
+
if (!reserved.has(dir))
|
|
97
|
+
dirs.add(dir);
|
|
98
|
+
}
|
|
99
|
+
return dirs;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Build the cf-pages `_routes.json` rules for a set of copied public files, within Cloudflare's budget.
|
|
103
|
+
*
|
|
104
|
+
* `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every
|
|
105
|
+
* public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images
|
|
106
|
+
* clears 100 easily, and the rejection lands at `wrangler pages deploy`, long after the build reported
|
|
107
|
+
* success.
|
|
108
|
+
*
|
|
109
|
+
* A glob is emitted only where it cannot be wrong. `/assets/*` always is: the build owns that prefix
|
|
110
|
+
* outright, since it is where the hashed bundle is written. A `public/` subdirectory is different - the
|
|
111
|
+
* name is the author's, and a `/blog/*` rule would hand Pages every future path under `/blog/`, so a
|
|
112
|
+
* `/blog/:slug` route would 404 from the CDN in production only. {@link collapsibleDirs} therefore
|
|
113
|
+
* collapses a directory only after checking it against the app's real route patterns, which is what
|
|
114
|
+
* makes the compaction safe rather than merely smaller. Everything else stays an exact path, and a name
|
|
115
|
+
* containing `*` is dropped rather than escaped, since the rule would become a wildcard.
|
|
116
|
+
*
|
|
117
|
+
* What still does not fit is dropped, which is safe because the list is only an optimization: the
|
|
118
|
+
* generated worker serves any allowlisted path it receives through the ASSETS binding, so an omitted
|
|
119
|
+
* file costs one worker invocation, not a 404.
|
|
120
|
+
*/
|
|
121
|
+
export function cloudflareRouteRules(publicFiles,
|
|
122
|
+
/**
|
|
123
|
+
* Every route pattern the app serves. Required, not defaulted: an empty list means "this app serves
|
|
124
|
+
* no routes", which makes every directory collapsible - a defensible answer to state deliberately and
|
|
125
|
+
* a dangerous one to arrive at by forgetting the argument.
|
|
126
|
+
*/
|
|
127
|
+
routePatterns) {
|
|
128
|
+
const include = ["/*"];
|
|
129
|
+
const exclude = ["/assets/*"];
|
|
130
|
+
const omitted = [];
|
|
131
|
+
// Collapse only where the route table proves nothing can be served beneath the directory, and only
|
|
132
|
+
// when the per-file list would not have fit anyway - a smaller `_routes.json` is worth nothing on its
|
|
133
|
+
// own, and exact paths keep the app's own 404 for a missing file under that directory.
|
|
134
|
+
const perFileFits = publicFiles.length + include.length + exclude.length <= CF_MAX_ROUTE_RULES;
|
|
135
|
+
const collapsible = perFileFits ? new Set() : collapsibleDirs(publicFiles, routePatterns);
|
|
136
|
+
const emitted = new Set();
|
|
137
|
+
for (const file of publicFiles) {
|
|
138
|
+
const slash = file.indexOf("/", 1);
|
|
139
|
+
const dir = slash === -1 ? undefined : file.slice(1, slash);
|
|
140
|
+
const collapsed = dir !== undefined && collapsible.has(dir);
|
|
141
|
+
const rule = collapsed ? `/${dir}/*` : file;
|
|
142
|
+
if (emitted.has(rule))
|
|
143
|
+
continue; // already covered by a rule emitted for an earlier file
|
|
144
|
+
// A `*` in a FILE name would turn that file's own rule into a wildcard. A collapsed rule's `*` is
|
|
145
|
+
// ours and deliberate, and the file's name never appears in it, so the check applies to one case.
|
|
146
|
+
if (!collapsed && file.includes("*")) {
|
|
147
|
+
omitted.push(file);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (include.length + exclude.length >= CF_MAX_ROUTE_RULES || rule.length > CF_MAX_RULE_LENGTH) {
|
|
151
|
+
omitted.push(file);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
exclude.push(rule);
|
|
155
|
+
emitted.add(rule);
|
|
156
|
+
}
|
|
157
|
+
return { include, exclude, omitted };
|
|
158
|
+
}
|
|
159
|
+
/** The built asset map — the server reads `entry` for the client script + serves `assets`. */
|
|
160
|
+
/**
|
|
161
|
+
* Copy `from` into `to`, returning the URL paths copied (sorted).
|
|
162
|
+
*
|
|
163
|
+
* Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
|
|
164
|
+
* `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
|
|
165
|
+
* entry - a dynamic `import("bun")` there fails the browser build outright.
|
|
166
|
+
*/
|
|
167
|
+
export async function copyPublicDir(from, to) {
|
|
168
|
+
const source = resolvePath(from);
|
|
169
|
+
const sourceReal = await realpath(source);
|
|
170
|
+
const copied = [];
|
|
171
|
+
for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
|
|
172
|
+
const candidate = join(source, rel);
|
|
173
|
+
const candidateStat = await lstat(candidate);
|
|
174
|
+
if (candidateStat.isSymbolicLink()) {
|
|
175
|
+
throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`);
|
|
176
|
+
}
|
|
177
|
+
if (!candidateStat.isFile())
|
|
178
|
+
continue;
|
|
179
|
+
const candidateReal = await realpath(candidate);
|
|
180
|
+
if (candidateReal !== sourceReal && !candidateReal.startsWith(sourceReal + sep)) {
|
|
181
|
+
throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`);
|
|
182
|
+
}
|
|
183
|
+
const target = join(to, rel);
|
|
184
|
+
await mkdir(join(target, ".."), { recursive: true });
|
|
185
|
+
await cp(candidateReal, target);
|
|
186
|
+
copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`);
|
|
187
|
+
}
|
|
188
|
+
return copied.sort();
|
|
189
|
+
}
|
|
37
190
|
/** The `node:` specifier of an import edge, or undefined if it isn't a Node built-in. Reads the
|
|
38
191
|
* `original` (as-written) specifier first, falling back to the resolved `path` (an `external` node:
|
|
39
192
|
* import keeps `node:` in its path). */
|
|
@@ -103,8 +256,8 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
|
|
|
103
256
|
* Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
|
|
104
257
|
* literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
|
|
105
258
|
*/
|
|
106
|
-
export function detectNodeBuiltinsInClient(
|
|
107
|
-
const inputs =
|
|
259
|
+
export function detectNodeBuiltinsInClient(graph) {
|
|
260
|
+
const inputs = graph.modules;
|
|
108
261
|
// (1) The builtins a user (non-polyfill) module imports directly — the ones the author controls.
|
|
109
262
|
const userImported = new Set();
|
|
110
263
|
for (const [inputKey, input] of Object.entries(inputs)) {
|
|
@@ -119,7 +272,7 @@ export function detectNodeBuiltinsInClient(meta) {
|
|
|
119
272
|
// The entry inputs (route/user entrypoints) — the chain BFS starts here so the reported path begins
|
|
120
273
|
// at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
|
|
121
274
|
const entryInputs = [
|
|
122
|
-
...new Set(Object.values(
|
|
275
|
+
...new Set(Object.values(graph.chunks)
|
|
123
276
|
.map((o) => o.entryPoint)
|
|
124
277
|
.filter((e) => e !== undefined && !e.startsWith("node:"))),
|
|
125
278
|
];
|
|
@@ -135,8 +288,8 @@ export function detectNodeBuiltinsInClient(meta) {
|
|
|
135
288
|
};
|
|
136
289
|
// (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
|
|
137
290
|
const findings = new Map();
|
|
138
|
-
for (const [outPath, out] of Object.entries(
|
|
139
|
-
for (const inputKey of
|
|
291
|
+
for (const [outPath, out] of Object.entries(graph.chunks)) {
|
|
292
|
+
for (const inputKey of out.modules) {
|
|
140
293
|
if (userImported.has(inputKey)) {
|
|
141
294
|
const chunk = basename(outPath);
|
|
142
295
|
findings.set(`${inputKey}\0${chunk}`, {
|
|
@@ -252,8 +405,8 @@ function inputKeyResolver(inputs) {
|
|
|
252
405
|
* (which imports nothing) is excluded — only the modules that *opt in* are flagged. Pure + exported for
|
|
253
406
|
* unit testing. Empty ⇒ clean.
|
|
254
407
|
*/
|
|
255
|
-
export function detectServerOnlyInClient(
|
|
256
|
-
const inputs =
|
|
408
|
+
export function detectServerOnlyInClient(graph) {
|
|
409
|
+
const inputs = graph.modules;
|
|
257
410
|
// (1) The modules that import the marker — the ones the author opted into the guard. The marker
|
|
258
411
|
// module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
|
|
259
412
|
const marked = new Set();
|
|
@@ -268,7 +421,7 @@ export function detectServerOnlyInClient(meta) {
|
|
|
268
421
|
// The entry inputs — the chain BFS starts here, so the reported path begins at the file the dev
|
|
269
422
|
// wrote an `import` in. Same derivation as the node-builtin guard.
|
|
270
423
|
const entryInputs = [
|
|
271
|
-
...new Set(Object.values(
|
|
424
|
+
...new Set(Object.values(graph.chunks)
|
|
272
425
|
.map((o) => o.entryPoint)
|
|
273
426
|
.filter((e) => e !== undefined && !e.startsWith("node:"))),
|
|
274
427
|
];
|
|
@@ -284,8 +437,8 @@ export function detectServerOnlyInClient(meta) {
|
|
|
284
437
|
};
|
|
285
438
|
// (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
|
|
286
439
|
const findings = new Map();
|
|
287
|
-
for (const [outPath, out] of Object.entries(
|
|
288
|
-
for (const inputKey of
|
|
440
|
+
for (const [outPath, out] of Object.entries(graph.chunks)) {
|
|
441
|
+
for (const inputKey of out.modules) {
|
|
289
442
|
if (marked.has(inputKey)) {
|
|
290
443
|
const chunk = basename(outPath);
|
|
291
444
|
findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) });
|
|
@@ -301,6 +454,35 @@ export function detectServerOnlyInClient(meta) {
|
|
|
301
454
|
/** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
|
|
302
455
|
* Excluded from the "marked" set — the marker is the import target, not an opt-in module. */
|
|
303
456
|
const isServerOnlyMarkerModule = (inputKey) => /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey);
|
|
457
|
+
// ---------------------------------------------------------------------------------------------------
|
|
458
|
+
// Guard MESSAGES — one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
|
|
459
|
+
// leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
|
|
460
|
+
// bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
|
|
461
|
+
// error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
|
|
462
|
+
// ---------------------------------------------------------------------------------------------------
|
|
463
|
+
/** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
|
|
464
|
+
export function formatNodeBuiltinLeak(findings) {
|
|
465
|
+
if (findings.length === 0)
|
|
466
|
+
return undefined;
|
|
467
|
+
const lines = findings.map((f) => f.chain.length > 1
|
|
468
|
+
? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
469
|
+
: ` - ${f.builtin} reached the client bundle via ${f.chunk}`);
|
|
470
|
+
return (`[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
|
|
471
|
+
`(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
|
|
472
|
+
`top level):\n${lines.join("\n")}`);
|
|
473
|
+
}
|
|
474
|
+
/** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
|
|
475
|
+
export function formatServerOnlyLeak(findings) {
|
|
476
|
+
if (findings.length === 0)
|
|
477
|
+
return undefined;
|
|
478
|
+
const lines = findings.map((f) => f.chain.length > 1
|
|
479
|
+
? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
480
|
+
: ` - server-only module reached the client bundle via ${f.chunk}`);
|
|
481
|
+
return (`[nifra/web] server-only module(s) in the client bundle — a module marked ` +
|
|
482
|
+
`\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
|
|
483
|
+
`(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
|
|
484
|
+
`to the client:\n${lines.join("\n")}`);
|
|
485
|
+
}
|
|
304
486
|
const basename = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
305
487
|
/** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
|
|
306
488
|
const entryName = (file) => {
|
|
@@ -572,33 +754,19 @@ export async function buildClient(options) {
|
|
|
572
754
|
// per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
|
|
573
755
|
// minification. Only the client build runs this; the server build's `node:` imports are legitimate.
|
|
574
756
|
const clientMeta = result.metafile;
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
// yet reached a CLIENT chunk — fail the build with the same chain-bearing, "reached the client bundle
|
|
589
|
-
// via" message shape as the node-builtin guard above. This catches pure-server logic (a secret, a
|
|
590
|
-
// server-only API call) that carries no `node:` import and isn't named `*.server`, so neither of the
|
|
591
|
-
// other two guards fires. Graph-based (the same metafile), so it survives minification.
|
|
592
|
-
const serverOnly = detectServerOnlyInClient(clientMeta);
|
|
593
|
-
if (serverOnly.length > 0) {
|
|
594
|
-
const lines = serverOnly.map((f) => f.chain.length > 1
|
|
595
|
-
? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
596
|
-
: ` - server-only module reached the client bundle via ${f.chunk}`);
|
|
597
|
-
throw new Error(`[nifra/web] server-only module(s) in the client bundle — a module marked ` +
|
|
598
|
-
`\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
|
|
599
|
-
`(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
|
|
600
|
-
`to the client:\n${lines.join("\n")}`);
|
|
601
|
-
}
|
|
757
|
+
const clientGraph = fromBunMetafile(clientMeta);
|
|
758
|
+
// #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a
|
|
759
|
+
// browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the
|
|
760
|
+
// SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same.
|
|
761
|
+
const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph));
|
|
762
|
+
if (nodeBuiltinLeak !== undefined)
|
|
763
|
+
throw new Error(nodeBuiltinLeak);
|
|
764
|
+
// §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk — catches
|
|
765
|
+
// pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
|
|
766
|
+
// `*.server`, so neither other guard fires. Same shared formatter as above.
|
|
767
|
+
const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph));
|
|
768
|
+
if (serverOnlyLeak !== undefined)
|
|
769
|
+
throw new Error(serverOnlyLeak);
|
|
602
770
|
// Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
|
|
603
771
|
// rewrite the references — otherwise the lazy import 404s and the route silently never hydrates.
|
|
604
772
|
const renamed = sanitizeOutputNames(result.outputs);
|
|
@@ -673,10 +841,15 @@ export async function buildClient(options) {
|
|
|
673
841
|
if (routeManifest.notFound)
|
|
674
842
|
routeStyles._404 = stylesFor([routeManifest.notFound.file]);
|
|
675
843
|
}
|
|
844
|
+
// Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
|
|
845
|
+
// apps have none) and must not fail the build.
|
|
846
|
+
const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public");
|
|
847
|
+
const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [];
|
|
676
848
|
const manifest = {
|
|
677
849
|
entry: toUrl(bootstrap.path),
|
|
678
850
|
assets: result.outputs.map((o) => toUrl(o.path)),
|
|
679
851
|
routes,
|
|
852
|
+
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
680
853
|
...(css.length > 0 ? { css } : {}),
|
|
681
854
|
...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
|
|
682
855
|
};
|
|
@@ -867,6 +1040,7 @@ export async function buildServer(options) {
|
|
|
867
1040
|
// of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
|
|
868
1041
|
// (nifra dev/start, nifra_render) never define it, so they still re-root — the dev dual-install fix.
|
|
869
1042
|
"process.env.NIFRA_SSR_BUNDLED": '"1"',
|
|
1043
|
+
"globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
|
|
870
1044
|
},
|
|
871
1045
|
minify: options.minify ?? true,
|
|
872
1046
|
// Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
|
|
@@ -915,7 +1089,7 @@ export function isBuildTarget(value) {
|
|
|
915
1089
|
* string out) so the generation is unit-testable without a real build.
|
|
916
1090
|
*/
|
|
917
1091
|
export function generateServerEntry(options) {
|
|
918
|
-
const { target, adapterImport, backendImport, title = "nifra" } = options;
|
|
1092
|
+
const { target, adapterImport, backendImport, title = "nifra", publicFiles = [] } = options;
|
|
919
1093
|
if (target === "static") {
|
|
920
1094
|
throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)");
|
|
921
1095
|
}
|
|
@@ -935,9 +1109,14 @@ export function generateServerEntry(options) {
|
|
|
935
1109
|
lines.push('import { serve } from "@nifrajs/node"');
|
|
936
1110
|
lines.push("", "const app = createWebApp({", " adapter,", " manifest,", " clientEntry,", " styles,", " routeStyles,", ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`, "})", "");
|
|
937
1111
|
if (target === "cf-pages") {
|
|
938
|
-
// Cloudflare Pages advanced mode: `_routes.json`
|
|
939
|
-
// falls through to this
|
|
940
|
-
|
|
1112
|
+
// Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and
|
|
1113
|
+
// everything else falls through to this handler (SSR).
|
|
1114
|
+
//
|
|
1115
|
+
// That exclude list cannot always name every public file - Cloudflare caps `_routes.json` at 100
|
|
1116
|
+
// rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting
|
|
1117
|
+
// the router 404 it, which makes correctness independent of how much of the list fit: the exclude
|
|
1118
|
+
// list decides only how many static requests skip the worker, never which files exist.
|
|
1119
|
+
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))) {', " return env.ASSETS.fetch(request)", " }", " return handler.fetch(request, env as never, ctx)", " },", "}");
|
|
941
1120
|
return `${lines.join("\n")}\n`;
|
|
942
1121
|
}
|
|
943
1122
|
if (target === "vercel") {
|
|
@@ -945,20 +1124,20 @@ export function generateServerEntry(options) {
|
|
|
945
1124
|
return `${lines.join("\n")}\n`;
|
|
946
1125
|
}
|
|
947
1126
|
// bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
|
|
948
|
-
lines.push("// The client bundle
|
|
1127
|
+
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)})`, "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", "}", 'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }');
|
|
949
1128
|
if (target === "bun") {
|
|
950
|
-
lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req) {", " const { pathname } = new URL(req.url)",
|
|
1129
|
+
lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req) {", " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " const file = Bun.file(new URL(filePath, STATIC_ROOT))", ' if (!(await file.exists())) return new Response("not found", { status: 404 })', ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " }", " return app.fetch(req)", " },", "})",
|
|
951
1130
|
// The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
|
|
952
1131
|
// source — split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
|
|
953
1132
|
`console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
|
|
954
1133
|
return `${lines.join("\n")}\n`;
|
|
955
1134
|
}
|
|
956
1135
|
if (target === "node") {
|
|
957
|
-
lines.push('import { readFile } from "node:fs/promises"', "await serve(", " {", " async fetch(req) {", " const { pathname } = new URL(req.url)",
|
|
1136
|
+
lines.push('import { readFile } from "node:fs/promises"', "await serve(", " {", " async fetch(req) {", " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " try {", " const body = await readFile(new URL(filePath, STATIC_ROOT))", ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return app.fetch(req)", " },", " },", " { port: Number(process.env.PORT ?? 3000) },", ")");
|
|
958
1137
|
return `${lines.join("\n")}\n`;
|
|
959
1138
|
}
|
|
960
1139
|
// deno
|
|
961
|
-
lines.push("const handler = toFetchHandler(app)", "// @ts-ignore — Deno global is present on the Deno runtime this output targets.", 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {', " const { pathname } = new URL(req.url)",
|
|
1140
|
+
lines.push("const handler = toFetchHandler(app)", "// @ts-ignore — Deno global is present on the Deno runtime this output targets.", 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {', " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " try {", " // @ts-ignore — Deno.readFile is present on the Deno runtime.", " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))", ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return handler.fetch(req)", "})");
|
|
962
1141
|
return `${lines.join("\n")}\n`;
|
|
963
1142
|
}
|
|
964
1143
|
/** The Bun.build `target` each deploy target compiles its server with (mirrors `buildServer`'s docs). */
|
|
@@ -984,22 +1163,39 @@ async function measureOutputs(paths) {
|
|
|
984
1163
|
}
|
|
985
1164
|
return chunks;
|
|
986
1165
|
}
|
|
1166
|
+
/** The default (Bun) strategy — `buildClient`/`buildServer` from this module. */
|
|
1167
|
+
export const bunBundler = {
|
|
1168
|
+
buildClient: (input) => buildClient({
|
|
1169
|
+
routesDir: input.routesDir,
|
|
1170
|
+
outDir: input.outDir,
|
|
1171
|
+
clientModule: input.clientModule,
|
|
1172
|
+
...(input.plugins ? { plugins: input.plugins } : {}),
|
|
1173
|
+
...(input.conditions ? { conditions: input.conditions } : {}),
|
|
1174
|
+
...(input.define ? { define: input.define } : {}),
|
|
1175
|
+
...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
|
|
1176
|
+
...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
|
|
1177
|
+
}),
|
|
1178
|
+
buildServer: (input) => buildServer({
|
|
1179
|
+
routesDir: input.routesDir,
|
|
1180
|
+
serverEntry: input.serverEntry,
|
|
1181
|
+
outDir: input.outDir,
|
|
1182
|
+
clientEntry: input.clientEntry,
|
|
1183
|
+
target: input.target,
|
|
1184
|
+
...(input.plugins ? { plugins: input.plugins } : {}),
|
|
1185
|
+
...(input.define ? { define: input.define } : {}),
|
|
1186
|
+
}),
|
|
1187
|
+
};
|
|
1188
|
+
/** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
|
|
1189
|
+
export async function buildTarget(target, options) {
|
|
1190
|
+
return buildTargetWith(target, options, bunBundler);
|
|
1191
|
+
}
|
|
987
1192
|
/**
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
*
|
|
992
|
-
* - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
|
|
993
|
-
* - `vercel`: a `.vercel/output`-shaped function isn't emitted here — `vercel` emits the bundled edge
|
|
994
|
-
* entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note]
|
|
995
|
-
* - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
|
|
996
|
-
* The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
|
|
997
|
-
* only adapter/backend/routes. Returns the manifest + a size report. Throws on any build failure.
|
|
998
|
-
*
|
|
999
|
-
* Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
|
|
1000
|
-
* remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
|
|
1193
|
+
* The bundler-agnostic deploy orchestrator: everything `buildTarget` does EXCEPT the two bundling steps,
|
|
1194
|
+
* which come from `bundler`. `buildTarget` passes {@link bunBundler}; `buildTargetVite`
|
|
1195
|
+
* (`@nifrajs/web/build-vite`) passes the Vite strategy. One orchestrator, so the deploy-dir shape,
|
|
1196
|
+
* server-entry codegen, prerender and size report are identical across pipelines.
|
|
1001
1197
|
*/
|
|
1002
|
-
export async function
|
|
1198
|
+
export async function buildTargetWith(target, options, bundler) {
|
|
1003
1199
|
const { routesDir, outDir, workDir } = options;
|
|
1004
1200
|
const { rmSync } = await import("node:fs");
|
|
1005
1201
|
rmSync(outDir, { recursive: true, force: true });
|
|
@@ -1008,14 +1204,25 @@ export async function buildTarget(target, options) {
|
|
|
1008
1204
|
mkdirSync(assetsDir, { recursive: true });
|
|
1009
1205
|
mkdirSync(workDir, { recursive: true });
|
|
1010
1206
|
// (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
|
|
1011
|
-
|
|
1207
|
+
let client = await bundler.buildClient({
|
|
1012
1208
|
routesDir,
|
|
1013
1209
|
outDir: assetsDir,
|
|
1014
1210
|
clientModule: options.clientModule,
|
|
1015
1211
|
...(options.clientPlugins ? { plugins: options.clientPlugins } : {}),
|
|
1016
1212
|
...(options.conditions ? { conditions: options.conditions } : {}),
|
|
1017
1213
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1214
|
+
publicDir: false,
|
|
1215
|
+
...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
|
|
1216
|
+
root: resolvePath(dirname(routesDir)),
|
|
1018
1217
|
});
|
|
1218
|
+
const publicDir = options.publicDir === false
|
|
1219
|
+
? undefined
|
|
1220
|
+
: resolvePath(options.publicDir ?? join(dirname(routesDir), "public"));
|
|
1221
|
+
const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [];
|
|
1222
|
+
if (publicFiles.length > 0) {
|
|
1223
|
+
client = { ...client, publicFiles };
|
|
1224
|
+
writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2));
|
|
1225
|
+
}
|
|
1019
1226
|
if (target === "static") {
|
|
1020
1227
|
if (options.prerenderApp === undefined) {
|
|
1021
1228
|
throw new Error("[nifra/web] buildTarget(static) requires `prerenderApp` (a factory `(client) => createWebApp`)");
|
|
@@ -1055,9 +1262,10 @@ export async function buildTarget(target, options) {
|
|
|
1055
1262
|
adapterImport: options.adapterImport,
|
|
1056
1263
|
...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
|
|
1057
1264
|
...(options.title !== undefined ? { title: options.title } : {}),
|
|
1265
|
+
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
1058
1266
|
}));
|
|
1059
1267
|
const serverTarget = SERVER_BUILD_TARGET[target];
|
|
1060
|
-
const { worker } = await buildServer({
|
|
1268
|
+
const { worker } = await bundler.buildServer({
|
|
1061
1269
|
routesDir,
|
|
1062
1270
|
serverEntry: serverEntryPath,
|
|
1063
1271
|
outDir: `${workDir}/server`,
|
|
@@ -1065,13 +1273,24 @@ export async function buildTarget(target, options) {
|
|
|
1065
1273
|
target: serverTarget,
|
|
1066
1274
|
...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
|
|
1067
1275
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1276
|
+
root: resolvePath(dirname(routesDir)),
|
|
1068
1277
|
});
|
|
1069
1278
|
// (3) Assemble the deploy dir for the target.
|
|
1070
1279
|
const { cpSync } = await import("node:fs");
|
|
1071
1280
|
let run;
|
|
1072
1281
|
if (target === "cf-pages") {
|
|
1073
1282
|
cpSync(worker, `${outDir}/_worker.js`);
|
|
1074
|
-
|
|
1283
|
+
// The app's real patterns, so a directory is only collapsed into a glob once the route table
|
|
1284
|
+
// proves nothing can be served beneath it.
|
|
1285
|
+
const rules = cloudflareRouteRules(publicFiles, discoverRoutes(routesDir).routes.map((route) => route.pattern));
|
|
1286
|
+
writeFileSync(`${outDir}/_routes.json`, `${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`);
|
|
1287
|
+
// Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a
|
|
1288
|
+
// worker invocation, and that trade should be visible rather than inferred from a latency graph.
|
|
1289
|
+
if (rules.omitted.length > 0) {
|
|
1290
|
+
console.log(`[nifra/web] _routes.json holds ${rules.exclude.length} of ${publicFiles.length + 1} static rules ` +
|
|
1291
|
+
`(Cloudflare allows ${CF_MAX_ROUTE_RULES}). The other ${rules.omitted.length} file(s) are served by the ` +
|
|
1292
|
+
"worker via ASSETS instead of the CDN directly - correct, just one invocation each.");
|
|
1293
|
+
}
|
|
1075
1294
|
run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`;
|
|
1076
1295
|
}
|
|
1077
1296
|
else if (target === "vercel") {
|