@nifrajs/web 2.1.0 → 2.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/README.md +3 -3
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +433 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +174 -65
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +376 -135
- 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/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +209 -27
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- 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 +62 -5
- 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 +44 -7
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +92 -9
- package/dist/draft.js.map +1 -1
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +15 -2
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +8 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +266 -74
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +869 -179
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- 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/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +8 -21
- 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 +121 -40
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +136 -26
- 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/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- 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/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- 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/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- 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/query.d.ts +10 -10
- package/dist/query.js +15 -15
- 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 +94 -22
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +170 -58
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +25 -17
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -33
- package/dist/vite.js.map +1 -1
- package/package.json +48 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +553 -0
- package/src/build.ts +563 -172
- package/src/bun-dev-entry.ts +184 -0
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +221 -30
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +468 -66
- package/src/draft.ts +124 -12
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +24 -6
- package/src/index.ts +1174 -225
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +18 -30
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +264 -48
- package/src/module-graph.ts +146 -0
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/public-dir.ts +108 -0
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +288 -74
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +103 -49
package/dist/build.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web/build`
|
|
2
|
+
* `@nifrajs/web/build` - the production build (Bun-only, build-time). `buildClient` codegens + bundles
|
|
3
3
|
* the client entry (content-hashed, code-split); `buildServer` codegens the static-import server
|
|
4
4
|
* manifest + bundles a self-contained **worker** for the disk-less edge (Cloudflare Workers). Both
|
|
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";
|
|
15
|
+
import { generateServerFnStub, SERVER_FN_MODULE, SERVER_ONLY_MODULE, SERVER_ONLY_REPLACEMENT, serverFnNamespace, } from "./internal/server-boundary.js";
|
|
13
16
|
// `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
|
|
17
|
+
import { fromBunMetafile } from "./module-graph.js";
|
|
14
18
|
import { prerenderRoutes } from "./prerender.js";
|
|
15
19
|
// Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
|
|
16
20
|
// run after `buildClient`.
|
|
@@ -34,6 +38,156 @@ export function publicEnvDefines(prefix, env) {
|
|
|
34
38
|
}
|
|
35
39
|
return defines;
|
|
36
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Percent-encode one path segment exactly the way a browser encodes it into a request URL.
|
|
43
|
+
*
|
|
44
|
+
* NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw -
|
|
45
|
+
* so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives
|
|
46
|
+
* as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI`
|
|
47
|
+
* agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must
|
|
48
|
+
* be escaped explicitly here.
|
|
49
|
+
*/
|
|
50
|
+
function encodePathSegment(segment) {
|
|
51
|
+
return encodeURI(segment).replace(/[?#]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any
|
|
55
|
+
* single rule longer than 100 characters.
|
|
56
|
+
*/
|
|
57
|
+
const CF_MAX_ROUTE_RULES = 100;
|
|
58
|
+
const CF_MAX_RULE_LENGTH = 100;
|
|
59
|
+
function firstSegmentOf(pattern) {
|
|
60
|
+
const first = pattern.split("/").find((segment) => segment !== "");
|
|
61
|
+
if (first === undefined)
|
|
62
|
+
return { kind: "root" };
|
|
63
|
+
return /^[A-Za-z0-9._-]+$/.test(first) ? { kind: "literal", value: first } : { kind: "dynamic" };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Directories under `public/` whose every file can be replaced by one `/<dir>/*` rule.
|
|
67
|
+
*
|
|
68
|
+
* Safe only when no route can ever be served beneath that directory, because the glob does not merely
|
|
69
|
+
* describe today's files - it hands Pages every future path under the prefix, and the worker never sees
|
|
70
|
+
* them. A `public/blog/hero.png` next to a `/blog/:slug` route is the ordinary case that breaks: the
|
|
71
|
+
* glob would send `/blog/my-post` to the CDN, which has no such file, and the page 404s in production
|
|
72
|
+
* only. So a directory collapses only when every route pattern starts with a literal segment that is
|
|
73
|
+
* something else. One dynamic first segment anywhere (`/:locale/…`) disables collapsing entirely, which
|
|
74
|
+
* is correct - such a route can match under any directory name.
|
|
75
|
+
*
|
|
76
|
+
* A collapsed directory also gives up the app's own 404 page for a missing file beneath it, since the
|
|
77
|
+
* request never reaches the worker. That is the right trade for a directory that holds only static
|
|
78
|
+
* files: a missing image should fail as a fast CDN 404, not as an HTML error page.
|
|
79
|
+
*/
|
|
80
|
+
function collapsibleDirs(publicFiles, routePatterns) {
|
|
81
|
+
const reserved = new Set();
|
|
82
|
+
for (const pattern of routePatterns) {
|
|
83
|
+
const first = firstSegmentOf(pattern);
|
|
84
|
+
// A dynamic first segment can match anywhere, so nothing is collapsible at all.
|
|
85
|
+
if (first.kind === "dynamic")
|
|
86
|
+
return new Set();
|
|
87
|
+
// `root` reserves nothing: `/` cannot match a path below a directory.
|
|
88
|
+
if (first.kind === "literal")
|
|
89
|
+
reserved.add(first.value);
|
|
90
|
+
}
|
|
91
|
+
const dirs = new Set();
|
|
92
|
+
for (const file of publicFiles) {
|
|
93
|
+
const slash = file.indexOf("/", 1);
|
|
94
|
+
if (slash === -1)
|
|
95
|
+
continue; // a root-level file has no directory to collapse into
|
|
96
|
+
const dir = file.slice(1, slash);
|
|
97
|
+
if (!reserved.has(dir))
|
|
98
|
+
dirs.add(dir);
|
|
99
|
+
}
|
|
100
|
+
return dirs;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Build the cf-pages `_routes.json` rules for a set of copied public files, within Cloudflare's budget.
|
|
104
|
+
*
|
|
105
|
+
* `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every
|
|
106
|
+
* public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images
|
|
107
|
+
* clears 100 easily, and the rejection lands at `wrangler pages deploy`, long after the build reported
|
|
108
|
+
* success.
|
|
109
|
+
*
|
|
110
|
+
* A glob is emitted only where it cannot be wrong. `/assets/*` always is: the build owns that prefix
|
|
111
|
+
* outright, since it is where the hashed bundle is written. A `public/` subdirectory is different - the
|
|
112
|
+
* name is the author's, and a `/blog/*` rule would hand Pages every future path under `/blog/`, so a
|
|
113
|
+
* `/blog/:slug` route would 404 from the CDN in production only. {@link collapsibleDirs} therefore
|
|
114
|
+
* collapses a directory only after checking it against the app's real route patterns, which is what
|
|
115
|
+
* makes the compaction safe rather than merely smaller. Everything else stays an exact path, and a name
|
|
116
|
+
* containing `*` is dropped rather than escaped, since the rule would become a wildcard.
|
|
117
|
+
*
|
|
118
|
+
* What still does not fit is dropped, which is safe because the list is only an optimization: the
|
|
119
|
+
* generated worker serves any allowlisted path it receives through the ASSETS binding, so an omitted
|
|
120
|
+
* file costs one worker invocation, not a 404.
|
|
121
|
+
*/
|
|
122
|
+
export function cloudflareRouteRules(publicFiles,
|
|
123
|
+
/**
|
|
124
|
+
* Every route pattern the app serves. Required, not defaulted: an empty list means "this app serves
|
|
125
|
+
* no routes", which makes every directory collapsible - a defensible answer to state deliberately and
|
|
126
|
+
* a dangerous one to arrive at by forgetting the argument.
|
|
127
|
+
*/
|
|
128
|
+
routePatterns) {
|
|
129
|
+
const include = ["/*"];
|
|
130
|
+
const exclude = ["/assets/*"];
|
|
131
|
+
const omitted = [];
|
|
132
|
+
// Collapse only where the route table proves nothing can be served beneath the directory, and only
|
|
133
|
+
// when the per-file list would not have fit anyway - a smaller `_routes.json` is worth nothing on its
|
|
134
|
+
// own, and exact paths keep the app's own 404 for a missing file under that directory.
|
|
135
|
+
const perFileFits = publicFiles.length + include.length + exclude.length <= CF_MAX_ROUTE_RULES;
|
|
136
|
+
const collapsible = perFileFits ? new Set() : collapsibleDirs(publicFiles, routePatterns);
|
|
137
|
+
const emitted = new Set();
|
|
138
|
+
for (const file of publicFiles) {
|
|
139
|
+
const slash = file.indexOf("/", 1);
|
|
140
|
+
const dir = slash === -1 ? undefined : file.slice(1, slash);
|
|
141
|
+
const collapsed = dir !== undefined && collapsible.has(dir);
|
|
142
|
+
const rule = collapsed ? `/${dir}/*` : file;
|
|
143
|
+
if (emitted.has(rule))
|
|
144
|
+
continue; // already covered by a rule emitted for an earlier file
|
|
145
|
+
// A `*` in a FILE name would turn that file's own rule into a wildcard. A collapsed rule's `*` is
|
|
146
|
+
// ours and deliberate, and the file's name never appears in it, so the check applies to one case.
|
|
147
|
+
if (!collapsed && file.includes("*")) {
|
|
148
|
+
omitted.push(file);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (include.length + exclude.length >= CF_MAX_ROUTE_RULES || rule.length > CF_MAX_RULE_LENGTH) {
|
|
152
|
+
omitted.push(file);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
exclude.push(rule);
|
|
156
|
+
emitted.add(rule);
|
|
157
|
+
}
|
|
158
|
+
return { include, exclude, omitted };
|
|
159
|
+
}
|
|
160
|
+
/** The built asset map - the server reads `entry` for the client script + serves `assets`. */
|
|
161
|
+
/**
|
|
162
|
+
* Copy `from` into `to`, returning the URL paths copied (sorted).
|
|
163
|
+
*
|
|
164
|
+
* Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
|
|
165
|
+
* `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
|
|
166
|
+
* entry - a dynamic `import("bun")` there fails the browser build outright.
|
|
167
|
+
*/
|
|
168
|
+
export async function copyPublicDir(from, to) {
|
|
169
|
+
const source = resolvePath(from);
|
|
170
|
+
const sourceReal = await realpath(source);
|
|
171
|
+
const copied = [];
|
|
172
|
+
for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
|
|
173
|
+
const candidate = join(source, rel);
|
|
174
|
+
const candidateStat = await lstat(candidate);
|
|
175
|
+
if (candidateStat.isSymbolicLink()) {
|
|
176
|
+
throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`);
|
|
177
|
+
}
|
|
178
|
+
if (!candidateStat.isFile())
|
|
179
|
+
continue;
|
|
180
|
+
const candidateReal = await realpath(candidate);
|
|
181
|
+
if (candidateReal !== sourceReal && !candidateReal.startsWith(sourceReal + sep)) {
|
|
182
|
+
throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`);
|
|
183
|
+
}
|
|
184
|
+
const target = join(to, rel);
|
|
185
|
+
await mkdir(join(target, ".."), { recursive: true });
|
|
186
|
+
await cp(candidateReal, target);
|
|
187
|
+
copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`);
|
|
188
|
+
}
|
|
189
|
+
return copied.sort();
|
|
190
|
+
}
|
|
37
191
|
/** The `node:` specifier of an import edge, or undefined if it isn't a Node built-in. Reads the
|
|
38
192
|
* `original` (as-written) specifier first, falling back to the resolved `path` (an `external` node:
|
|
39
193
|
* import keeps `node:` in its path). */
|
|
@@ -48,10 +202,10 @@ const nodeBuiltinOf = (im) => {
|
|
|
48
202
|
* BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
|
|
49
203
|
* bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
|
|
50
204
|
* starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
|
|
51
|
-
* actually wrote `import`
|
|
205
|
+
* actually wrote `import` - the actionable root, not an arbitrary internal module. Traversal crosses
|
|
52
206
|
* only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
|
|
53
207
|
* whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
|
|
54
|
-
* is reachable by construction). Pure
|
|
208
|
+
* is reachable by construction). Pure - operates on the graph, never the emitted text.
|
|
55
209
|
*/
|
|
56
210
|
function shortestBuiltinChain(inputs, entryInputs, builtin) {
|
|
57
211
|
// Each queue item carries the resolved-path node + the display chain to reach it (entry + the
|
|
@@ -72,7 +226,7 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
|
|
|
72
226
|
return [...chain, builtin];
|
|
73
227
|
const target = im.path;
|
|
74
228
|
// Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
|
|
75
|
-
// been visited
|
|
229
|
+
// been visited - so the polyfill subtree can't lengthen the path and there are no cycles.
|
|
76
230
|
if (target === undefined ||
|
|
77
231
|
target.startsWith("node:") ||
|
|
78
232
|
inputs[target] === undefined ||
|
|
@@ -81,45 +235,45 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
|
|
|
81
235
|
}
|
|
82
236
|
seen.add(target);
|
|
83
237
|
// Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
|
|
84
|
-
// the resolved path
|
|
238
|
+
// the resolved path - that's what the dev recognizes in their source, not the resolved path.
|
|
85
239
|
next.push({ node: target, chain: [...chain, im.original ?? target] });
|
|
86
240
|
}
|
|
87
241
|
}
|
|
88
242
|
frontier = next;
|
|
89
243
|
}
|
|
90
|
-
return [builtin]; // unreachable from any entry
|
|
244
|
+
return [builtin]; // unreachable from any entry - degrade to just the builtin (shouldn't happen)
|
|
91
245
|
}
|
|
92
246
|
/**
|
|
93
247
|
* Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
|
|
94
248
|
* chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
|
|
95
249
|
* the report is precise AND actionable:
|
|
96
|
-
* 1. **What the user wrote**
|
|
250
|
+
* 1. **What the user wrote** - only builtins imported by a NON-`node:` input count, so Bun's own
|
|
97
251
|
* polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
|
|
98
|
-
* 2. **Where it landed**
|
|
252
|
+
* 2. **Where it landed** - the chunk is read from the per-output `inputs`, so the error names the
|
|
99
253
|
* emitted file to look at.
|
|
100
|
-
* 3. **How it got there**
|
|
254
|
+
* 3. **How it got there** - the shortest import chain from a user entry to the builtin
|
|
101
255
|
* (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
|
|
102
256
|
* leaving the dev to grep the dependency tree (the DX gap this closes).
|
|
103
257
|
* Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
|
|
104
258
|
* literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
|
|
105
259
|
*/
|
|
106
|
-
export function detectNodeBuiltinsInClient(
|
|
107
|
-
const inputs =
|
|
108
|
-
// (1) The builtins a user (non-polyfill) module imports directly
|
|
260
|
+
export function detectNodeBuiltinsInClient(graph) {
|
|
261
|
+
const inputs = graph.modules;
|
|
262
|
+
// (1) The builtins a user (non-polyfill) module imports directly - the ones the author controls.
|
|
109
263
|
const userImported = new Set();
|
|
110
264
|
for (const [inputKey, input] of Object.entries(inputs)) {
|
|
111
265
|
if (inputKey.startsWith("node:"))
|
|
112
|
-
continue; // a polyfill importing another builtin
|
|
266
|
+
continue; // a polyfill importing another builtin - not the cause
|
|
113
267
|
for (const im of input.imports ?? []) {
|
|
114
268
|
const builtin = nodeBuiltinOf(im);
|
|
115
269
|
if (builtin !== undefined)
|
|
116
270
|
userImported.add(builtin);
|
|
117
271
|
}
|
|
118
272
|
}
|
|
119
|
-
// The entry inputs (route/user entrypoints)
|
|
273
|
+
// The entry inputs (route/user entrypoints) - the chain BFS starts here so the reported path begins
|
|
120
274
|
// at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
|
|
121
275
|
const entryInputs = [
|
|
122
|
-
...new Set(Object.values(
|
|
276
|
+
...new Set(Object.values(graph.chunks)
|
|
123
277
|
.map((o) => o.entryPoint)
|
|
124
278
|
.filter((e) => e !== undefined && !e.startsWith("node:"))),
|
|
125
279
|
];
|
|
@@ -135,8 +289,8 @@ export function detectNodeBuiltinsInClient(meta) {
|
|
|
135
289
|
};
|
|
136
290
|
// (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
|
|
137
291
|
const findings = new Map();
|
|
138
|
-
for (const [outPath, out] of Object.entries(
|
|
139
|
-
for (const inputKey of
|
|
292
|
+
for (const [outPath, out] of Object.entries(graph.chunks)) {
|
|
293
|
+
for (const inputKey of out.modules) {
|
|
140
294
|
if (userImported.has(inputKey)) {
|
|
141
295
|
const chunk = basename(outPath);
|
|
142
296
|
findings.set(`${inputKey}\0${chunk}`, {
|
|
@@ -154,8 +308,8 @@ export function detectNodeBuiltinsInClient(meta) {
|
|
|
154
308
|
// node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
|
|
155
309
|
// a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
|
|
156
310
|
// side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
|
|
157
|
-
// the marker is an empty no-op; the CLIENT build detects
|
|
158
|
-
// node-builtin guard walks
|
|
311
|
+
// the marker is an empty no-op; the CLIENT build detects - via the SAME Bun metafile graph the
|
|
312
|
+
// node-builtin guard walks - any module that imports the marker AND lands in a client chunk, and fails
|
|
159
313
|
// the build with the import chain. Graph-based (never the emitted text), so it survives minification.
|
|
160
314
|
// ---------------------------------------------------------------------------------------------------
|
|
161
315
|
/** The marker specifier an author imports to opt a module into the client-leak guard. Matched on the
|
|
@@ -164,7 +318,7 @@ export function detectNodeBuiltinsInClient(meta) {
|
|
|
164
318
|
export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only";
|
|
165
319
|
/** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
|
|
166
320
|
* specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
|
|
167
|
-
* (no `original`)
|
|
321
|
+
* (no `original`) - or a workspace-relative resolution - is still recognised. */
|
|
168
322
|
const isServerOnlyMarkerImport = (im) => {
|
|
169
323
|
if (im.original === SERVER_ONLY_MARKER)
|
|
170
324
|
return true;
|
|
@@ -179,14 +333,14 @@ const isServerOnlyMarkerImport = (im) => {
|
|
|
179
333
|
* "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
|
|
180
334
|
* every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
|
|
181
335
|
* only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
|
|
182
|
-
* contains the marker (the marked module
|
|
183
|
-
* specifier the previous hop used to reach it. Pure
|
|
336
|
+
* contains the marker (the marked module - the actionable tail), labelling that node by the as-written
|
|
337
|
+
* specifier the previous hop used to reach it. Pure - operates on the graph, never the emitted text.
|
|
184
338
|
*/
|
|
185
339
|
function shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarget) {
|
|
186
340
|
// The label for the marked module's tail: its as-written specifier (filled when we cross the edge
|
|
187
341
|
// that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
|
|
188
342
|
const tail = (label) => `${label} (marked server-only)`;
|
|
189
|
-
// An entry that is ITSELF the marked module
|
|
343
|
+
// An entry that is ITSELF the marked module - the chain is just that one node.
|
|
190
344
|
if (entryInputs.includes(markedModule))
|
|
191
345
|
return [tail(markedModule)];
|
|
192
346
|
const seen = new Set(entryInputs);
|
|
@@ -198,7 +352,7 @@ function shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarge
|
|
|
198
352
|
const next = [];
|
|
199
353
|
for (const { node, chain } of frontier) {
|
|
200
354
|
for (const im of inputs[node]?.imports ?? []) {
|
|
201
|
-
// Resolve the edge's `path` to the matching INPUT-GRAPH KEY
|
|
355
|
+
// Resolve the edge's `path` to the matching INPUT-GRAPH KEY - in a real build the edge `path`
|
|
202
356
|
// is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
|
|
203
357
|
// multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
|
|
204
358
|
const target = resolveTarget(im);
|
|
@@ -247,14 +401,14 @@ function inputKeyResolver(inputs) {
|
|
|
247
401
|
* Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
|
|
248
402
|
* `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
|
|
249
403
|
* list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
|
|
250
|
-
* graph facts
|
|
404
|
+
* graph facts - which inputs import the marker (the "marked" modules), which chunk each landed in (the
|
|
251
405
|
* per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
|
|
252
|
-
* (which imports nothing) is excluded
|
|
406
|
+
* (which imports nothing) is excluded - only the modules that *opt in* are flagged. Pure + exported for
|
|
253
407
|
* unit testing. Empty ⇒ clean.
|
|
254
408
|
*/
|
|
255
|
-
export function detectServerOnlyInClient(
|
|
256
|
-
const inputs =
|
|
257
|
-
// (1) The modules that import the marker
|
|
409
|
+
export function detectServerOnlyInClient(graph) {
|
|
410
|
+
const inputs = graph.modules;
|
|
411
|
+
// (1) The modules that import the marker - the ones the author opted into the guard. The marker
|
|
258
412
|
// module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
|
|
259
413
|
const marked = new Set();
|
|
260
414
|
for (const [inputKey, input] of Object.entries(inputs)) {
|
|
@@ -265,10 +419,10 @@ export function detectServerOnlyInClient(meta) {
|
|
|
265
419
|
}
|
|
266
420
|
if (marked.size === 0)
|
|
267
421
|
return [];
|
|
268
|
-
// The entry inputs
|
|
422
|
+
// The entry inputs - the chain BFS starts here, so the reported path begins at the file the dev
|
|
269
423
|
// wrote an `import` in. Same derivation as the node-builtin guard.
|
|
270
424
|
const entryInputs = [
|
|
271
|
-
...new Set(Object.values(
|
|
425
|
+
...new Set(Object.values(graph.chunks)
|
|
272
426
|
.map((o) => o.entryPoint)
|
|
273
427
|
.filter((e) => e !== undefined && !e.startsWith("node:"))),
|
|
274
428
|
];
|
|
@@ -284,8 +438,8 @@ export function detectServerOnlyInClient(meta) {
|
|
|
284
438
|
};
|
|
285
439
|
// (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
|
|
286
440
|
const findings = new Map();
|
|
287
|
-
for (const [outPath, out] of Object.entries(
|
|
288
|
-
for (const inputKey of
|
|
441
|
+
for (const [outPath, out] of Object.entries(graph.chunks)) {
|
|
442
|
+
for (const inputKey of out.modules) {
|
|
289
443
|
if (marked.has(inputKey)) {
|
|
290
444
|
const chunk = basename(outPath);
|
|
291
445
|
findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) });
|
|
@@ -299,8 +453,37 @@ export function detectServerOnlyInClient(meta) {
|
|
|
299
453
|
});
|
|
300
454
|
}
|
|
301
455
|
/** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
|
|
302
|
-
* Excluded from the "marked" set
|
|
456
|
+
* Excluded from the "marked" set - the marker is the import target, not an opt-in module. */
|
|
303
457
|
const isServerOnlyMarkerModule = (inputKey) => /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey);
|
|
458
|
+
// ---------------------------------------------------------------------------------------------------
|
|
459
|
+
// Guard MESSAGES - one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
|
|
460
|
+
// leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
|
|
461
|
+
// bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
|
|
462
|
+
// error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
|
|
463
|
+
// ---------------------------------------------------------------------------------------------------
|
|
464
|
+
/** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
|
|
465
|
+
export function formatNodeBuiltinLeak(findings) {
|
|
466
|
+
if (findings.length === 0)
|
|
467
|
+
return undefined;
|
|
468
|
+
const lines = findings.map((f) => f.chain.length > 1
|
|
469
|
+
? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
470
|
+
: ` - ${f.builtin} reached the client bundle via ${f.chunk}`);
|
|
471
|
+
return (`[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` +
|
|
472
|
+
`(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
|
|
473
|
+
`top level):\n${lines.join("\n")}`);
|
|
474
|
+
}
|
|
475
|
+
/** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
|
|
476
|
+
export function formatServerOnlyLeak(findings) {
|
|
477
|
+
if (findings.length === 0)
|
|
478
|
+
return undefined;
|
|
479
|
+
const lines = findings.map((f) => f.chain.length > 1
|
|
480
|
+
? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
481
|
+
: ` - server-only module reached the client bundle via ${f.chunk}`);
|
|
482
|
+
return (`[nifra/web] server-only module(s) in the client bundle - a module marked ` +
|
|
483
|
+
`\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
|
|
484
|
+
`(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
|
|
485
|
+
`to the client:\n${lines.join("\n")}`);
|
|
486
|
+
}
|
|
304
487
|
const basename = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
305
488
|
/** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
|
|
306
489
|
const entryName = (file) => {
|
|
@@ -310,7 +493,7 @@ const entryName = (file) => {
|
|
|
310
493
|
};
|
|
311
494
|
/**
|
|
312
495
|
* Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
|
|
313
|
-
* by raw bytes, then name for stable output) and sum the totals. Pure
|
|
496
|
+
* by raw bytes, then name for stable output) and sum the totals. Pure - the measurement (reading the
|
|
314
497
|
* file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
|
|
315
498
|
*/
|
|
316
499
|
export function aggregateSizeReport(chunks) {
|
|
@@ -332,7 +515,7 @@ export function formatBytes(bytes) {
|
|
|
332
515
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
333
516
|
}
|
|
334
517
|
/**
|
|
335
|
-
* Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row
|
|
518
|
+
* Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row - the text
|
|
336
519
|
* `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
|
|
337
520
|
*/
|
|
338
521
|
export function renderSizeReport(report) {
|
|
@@ -367,7 +550,7 @@ const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["
|
|
|
367
550
|
* `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
|
|
368
551
|
* specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
|
|
369
552
|
* default `resolve` emits). Only import specifiers under that prefix are route files; the
|
|
370
|
-
* `@nifrajs/web` import (and any other bare specifier) is ignored. Pure
|
|
553
|
+
* `@nifrajs/web` import (and any other bare specifier) is ignored. Pure - operates on source text.
|
|
371
554
|
*/
|
|
372
555
|
export function parseManifestRouteFiles(source, routesPrefix = "./routes/") {
|
|
373
556
|
const files = new Set();
|
|
@@ -443,8 +626,8 @@ export function resyncServerManifestSource(source, manifest, routesPrefix) {
|
|
|
443
626
|
}
|
|
444
627
|
/**
|
|
445
628
|
* Diff the route files a committed server-manifest imports against the files freshly discovered in
|
|
446
|
-
* `routes/`. Returns the `missing` (in routes/, not in manifest
|
|
447
|
-
* manifest, gone from routes/
|
|
629
|
+
* `routes/`. Returns the `missing` (in routes/, not in manifest - stale manifest) and `extra` (in
|
|
630
|
+
* manifest, gone from routes/ - dangling import) sets. Empty arrays ⇒ in sync. Pure - the caller
|
|
448
631
|
* supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
|
|
449
632
|
* fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
|
|
450
633
|
*/
|
|
@@ -468,20 +651,20 @@ export function formatManifestDrift(drift, manifestPath = "server-manifest.ts")
|
|
|
468
651
|
if (isManifestInSync(drift))
|
|
469
652
|
return undefined;
|
|
470
653
|
const lines = [
|
|
471
|
-
`[nifra/web] server-manifest drift
|
|
654
|
+
`[nifra/web] server-manifest drift - \`${manifestPath}\` is out of sync with routes/.`,
|
|
472
655
|
];
|
|
473
656
|
if (drift.missing.length > 0) {
|
|
474
|
-
lines.push(` Missing (in routes/, not in the manifest
|
|
657
|
+
lines.push(` Missing (in routes/, not in the manifest - these routes won't be served): ${drift.missing.join(", ")}`);
|
|
475
658
|
}
|
|
476
659
|
if (drift.extra.length > 0) {
|
|
477
|
-
lines.push(` Extra (imported by the manifest, gone from routes/
|
|
660
|
+
lines.push(` Extra (imported by the manifest, gone from routes/ - a dangling import): ${drift.extra.join(", ")}`);
|
|
478
661
|
}
|
|
479
662
|
lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.");
|
|
480
663
|
return lines.join("\n");
|
|
481
664
|
}
|
|
482
665
|
/**
|
|
483
666
|
* Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
|
|
484
|
-
* `outDir` and returns the manifest. Throws (with the bundler logs) on build failure
|
|
667
|
+
* `outDir` and returns the manifest. Throws (with the bundler logs) on build failure - never
|
|
485
668
|
* silently ships a broken bundle.
|
|
486
669
|
*/
|
|
487
670
|
export async function buildClient(options) {
|
|
@@ -496,7 +679,7 @@ export async function buildClient(options) {
|
|
|
496
679
|
// PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
|
|
497
680
|
// falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
|
|
498
681
|
// in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
|
|
499
|
-
// layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck
|
|
682
|
+
// layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck - guard it.
|
|
500
683
|
const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env;
|
|
501
684
|
const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv);
|
|
502
685
|
// Keep the generated source beside the project, not inside `outDir`: module resolution starts at the
|
|
@@ -507,7 +690,7 @@ export async function buildClient(options) {
|
|
|
507
690
|
// A client bundle has no Node `process`; provide a minimal one so a stray bare `process` reference in
|
|
508
691
|
// an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
|
|
509
692
|
writeFileSync(entryFile, `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`);
|
|
510
|
-
// Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints
|
|
693
|
+
// Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
|
|
511
694
|
// named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
|
|
512
695
|
// can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same
|
|
513
696
|
// specifier the bootstrap imports, so the entrypoint + lazy import are the same module (dedup).
|
|
@@ -518,7 +701,7 @@ export async function buildClient(options) {
|
|
|
518
701
|
...(routeManifest.notFound ? [routeManifest.notFound.file] : []),
|
|
519
702
|
]),
|
|
520
703
|
].sort();
|
|
521
|
-
// `metafile: true` asks Bun for the input/output graph
|
|
704
|
+
// `metafile: true` asks Bun for the input/output graph - specifically `outputs[js].entryPoint`
|
|
522
705
|
// (the source file) + `outputs[js].cssBundle` (that entry's emitted stylesheet). It's the robust
|
|
523
706
|
// entry→CSS link for per-route splitting: keyed by the unique source path, so it survives
|
|
524
707
|
// same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in
|
|
@@ -535,7 +718,7 @@ export async function buildClient(options) {
|
|
|
535
718
|
splitting: true, // one chunk per lazily-imported route; shared deps deduped into shared chunks
|
|
536
719
|
// `import "./x.css"` in a route/component → bundled, minified, content-hashed `.css` asset (Bun
|
|
537
720
|
// strips the import from the JS; CSS bundling is on by default since Bun 1.2). Mapped to routes
|
|
538
|
-
// below
|
|
721
|
+
// below - both the aggregate and per-route - via the metafile, for `<link>` injection.
|
|
539
722
|
...buildExtras,
|
|
540
723
|
minify: options.minify ?? true,
|
|
541
724
|
plugins: [
|
|
@@ -543,13 +726,14 @@ export async function buildClient(options) {
|
|
|
543
726
|
preactDedupePlugin(routesDir),
|
|
544
727
|
svelteDedupePlugin(routesDir),
|
|
545
728
|
serverOnlyEmptyPlugin(),
|
|
729
|
+
serverFnStubPlugin(),
|
|
546
730
|
...(options.plugins ?? []),
|
|
547
731
|
],
|
|
548
732
|
...(options.conditions ? { conditions: [...options.conditions] } : {}),
|
|
549
733
|
// Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
|
|
550
734
|
// hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
|
|
551
735
|
// the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
|
|
552
|
-
// other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback
|
|
736
|
+
// other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback - so secrets
|
|
553
737
|
// never leak). Callers can override any of these via `options.define` (layered last).
|
|
554
738
|
define: {
|
|
555
739
|
"process.env": "({})",
|
|
@@ -568,39 +752,25 @@ export async function buildClient(options) {
|
|
|
568
752
|
}
|
|
569
753
|
// #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
|
|
570
754
|
// a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
|
|
571
|
-
// instead
|
|
755
|
+
// instead - caught at build time, not by a confused user in the browser. Graph-based (the metafile's
|
|
572
756
|
// per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
|
|
573
757
|
// minification. Only the client build runs this; the server build's `node:` imports are legitimate.
|
|
574
758
|
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
|
-
}
|
|
759
|
+
const clientGraph = fromBunMetafile(clientMeta);
|
|
760
|
+
// #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a
|
|
761
|
+
// browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the
|
|
762
|
+
// SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same.
|
|
763
|
+
const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph));
|
|
764
|
+
if (nodeBuiltinLeak !== undefined)
|
|
765
|
+
throw new Error(nodeBuiltinLeak);
|
|
766
|
+
// §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk - catches
|
|
767
|
+
// pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
|
|
768
|
+
// `*.server`, so neither other guard fires. Same shared formatter as above.
|
|
769
|
+
const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph));
|
|
770
|
+
if (serverOnlyLeak !== undefined)
|
|
771
|
+
throw new Error(serverOnlyLeak);
|
|
602
772
|
// Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
|
|
603
|
-
// rewrite the references
|
|
773
|
+
// rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
|
|
604
774
|
const renamed = sanitizeOutputNames(result.outputs);
|
|
605
775
|
const toUrl = (path) => `${publicPath}${renamed.get(basename(path)) ?? basename(path)}`;
|
|
606
776
|
// Entry-point outputs come back in entrypoint order: [bootstrap, ...routeFiles]. Map each route file
|
|
@@ -615,7 +785,7 @@ export async function buildClient(options) {
|
|
|
615
785
|
}
|
|
616
786
|
const fileToChunk = new Map();
|
|
617
787
|
routeFiles.forEach((file, i) => {
|
|
618
|
-
const out = entryPoints[i + 1]; // in range
|
|
788
|
+
const out = entryPoints[i + 1]; // in range - length checked above
|
|
619
789
|
if (out !== undefined)
|
|
620
790
|
fileToChunk.set(file, toUrl(out.path));
|
|
621
791
|
});
|
|
@@ -629,9 +799,9 @@ export async function buildClient(options) {
|
|
|
629
799
|
}
|
|
630
800
|
if (routeManifest.notFound)
|
|
631
801
|
routes._404 = chunksFor([routeManifest.notFound.file]);
|
|
632
|
-
// CSS
|
|
802
|
+
// CSS - aggregate: an `import "./x.css"` anywhere → a content-hashed `.css` asset (Bun strips the
|
|
633
803
|
// import from the JS). The bootstrap lazily imports every route, so its **aggregate** stylesheet is
|
|
634
|
-
// the whole app's CSS
|
|
804
|
+
// the whole app's CSS - the always-safe fallback `createWebApp` links when a route has no per-route
|
|
635
805
|
// entry below. Fallback to all CSS assets if Bun emitted no distinct aggregate.
|
|
636
806
|
const bootstrapName = entryName(entryFile); // `_nifra-entry`
|
|
637
807
|
const cssNameOf = (path) => {
|
|
@@ -643,12 +813,12 @@ export async function buildClient(options) {
|
|
|
643
813
|
.filter((o) => cssNameOf(o.path) === bootstrapName)
|
|
644
814
|
.map((o) => toUrl(o.path));
|
|
645
815
|
const css = aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path));
|
|
646
|
-
// CSS
|
|
647
|
-
// `cssBundle`
|
|
816
|
+
// CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its
|
|
817
|
+
// `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
|
|
648
818
|
// consumer; verified). Keyed by the metafile's unique source `entryPoint`, so it survives
|
|
649
819
|
// same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
|
|
650
820
|
// links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
|
|
651
|
-
// Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle
|
|
821
|
+
// Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle - never silently incomplete.
|
|
652
822
|
const cwd = process.cwd();
|
|
653
823
|
const cssByEntry = new Map();
|
|
654
824
|
for (const out of Object.values(clientMeta?.outputs ?? {})) {
|
|
@@ -673,10 +843,15 @@ export async function buildClient(options) {
|
|
|
673
843
|
if (routeManifest.notFound)
|
|
674
844
|
routeStyles._404 = stylesFor([routeManifest.notFound.file]);
|
|
675
845
|
}
|
|
846
|
+
// Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
|
|
847
|
+
// apps have none) and must not fail the build.
|
|
848
|
+
const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public");
|
|
849
|
+
const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [];
|
|
676
850
|
const manifest = {
|
|
677
851
|
entry: toUrl(bootstrap.path),
|
|
678
852
|
assets: result.outputs.map((o) => toUrl(o.path)),
|
|
679
853
|
routes,
|
|
854
|
+
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
680
855
|
...(css.length > 0 ? { css } : {}),
|
|
681
856
|
...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
|
|
682
857
|
};
|
|
@@ -688,7 +863,7 @@ export async function buildClient(options) {
|
|
|
688
863
|
* on workerd, and `Bun.build` always applies the `bun` condition (it wins over `workerd`/`edge-light`),
|
|
689
864
|
* so conditions alone can't select the edge build. This shim pins `react-dom/server` to its edge build
|
|
690
865
|
* (`server.edge.js`, which exports `renderToReadableStream`). A no-op when nothing imports react-dom
|
|
691
|
-
* (e.g. a Solid worker)
|
|
866
|
+
* (e.g. a Solid worker) - the resolver only runs on a match.
|
|
692
867
|
*/
|
|
693
868
|
const reactDomEdgePlugin = (from) => ({
|
|
694
869
|
name: "nifra-react-dom-edge",
|
|
@@ -700,11 +875,11 @@ const reactDomEdgePlugin = (from) => ({
|
|
|
700
875
|
});
|
|
701
876
|
/**
|
|
702
877
|
* Dedupe React to a single copy. A `file:`-linked package can ship its OWN `react` under its own
|
|
703
|
-
* node_modules, so the bundle ends up with two React cores
|
|
878
|
+
* node_modules, so the bundle ends up with two React cores - each with its own hook dispatcher - and SSR
|
|
704
879
|
* throws the cryptic `null is not an object (evaluating '…H.useState')` (the second renderer's dispatcher
|
|
705
880
|
* is null). This was the #1 time-sink in app builds. Pinning `react` + its JSX runtimes to ONE resolved
|
|
706
881
|
* copy (the app's, from `from`) guarantees a single dispatcher; `react-dom`, which imports `react`, then
|
|
707
|
-
* shares it
|
|
882
|
+
* shares it - so the class can't occur rather than needing a named error. No-op when React isn't used
|
|
708
883
|
* (an unresolvable spec is skipped; the resolver only fires on an exact match). React core is
|
|
709
884
|
* condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
|
|
710
885
|
* react-dom's build.
|
|
@@ -719,7 +894,7 @@ export const reactDedupePlugin = (from) => ({
|
|
|
719
894
|
resolved = Bun.resolveSync(spec, from);
|
|
720
895
|
}
|
|
721
896
|
catch {
|
|
722
|
-
continue; // React (or this subpath) isn't resolvable here
|
|
897
|
+
continue; // React (or this subpath) isn't resolvable here - nothing to dedupe
|
|
723
898
|
}
|
|
724
899
|
const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
725
900
|
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }));
|
|
@@ -727,14 +902,14 @@ export const reactDedupePlugin = (from) => ({
|
|
|
727
902
|
},
|
|
728
903
|
});
|
|
729
904
|
/**
|
|
730
|
-
* Dedupe Preact to a single copy
|
|
905
|
+
* Dedupe Preact to a single copy - the Preact analogue of `reactDedupePlugin`, closing the same class of
|
|
731
906
|
* bug for the Preact framework (which had NO build-time dedup before). A `file:`-linked package can ship
|
|
732
907
|
* its OWN `preact`, so the bundle ends up with two Preact cores; since `preact-render-to-string` mutates
|
|
733
908
|
* `preact`'s shared `options` global and `preact/hooks` writes the SAME global, two copies → two `options`
|
|
734
909
|
* → SSR throws `undefined is not an object (… __H)` (the vnode's hook state was set up on the other copy).
|
|
735
910
|
* Pinning `preact` + its hooks/compat/jsx subpaths to ONE resolved copy (the app's, from `from`) makes the
|
|
736
911
|
* renderer and the components share one core. No-op when Preact isn't used (an unresolvable spec is
|
|
737
|
-
* skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection
|
|
912
|
+
* skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and
|
|
738
913
|
* unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
|
|
739
914
|
*/
|
|
740
915
|
const PREACT_DEDUPE_SPECS = [
|
|
@@ -753,7 +928,7 @@ export const preactDedupePlugin = (from) => ({
|
|
|
753
928
|
resolved = Bun.resolveSync(spec, from);
|
|
754
929
|
}
|
|
755
930
|
catch {
|
|
756
|
-
continue; // Preact (or this subpath) isn't resolvable here
|
|
931
|
+
continue; // Preact (or this subpath) isn't resolvable here - nothing to dedupe
|
|
757
932
|
}
|
|
758
933
|
const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
759
934
|
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }));
|
|
@@ -761,10 +936,10 @@ export const preactDedupePlugin = (from) => ({
|
|
|
761
936
|
},
|
|
762
937
|
});
|
|
763
938
|
/**
|
|
764
|
-
* Dedupe Svelte to a single copy
|
|
939
|
+
* Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
|
|
765
940
|
* the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
|
|
766
941
|
* `@nifrajs/web-svelte` can resolve its OWN `svelte` (e.g. a sibling repo's install store) while the app's
|
|
767
|
-
* components resolve another
|
|
942
|
+
* components resolve another - SAME version, two physical copies. Svelte 5's client runtime
|
|
768
943
|
* (`svelte/internal/client`) holds module-level component-context state, so two copies means the compiled
|
|
769
944
|
* components register on one runtime while `hydrate` runs on the other → hydration throws
|
|
770
945
|
* `Cannot read properties of undefined (reading 'call')` and the server-rendered markup is wiped.
|
|
@@ -783,26 +958,46 @@ export const svelteDedupePlugin = (from) => ({
|
|
|
783
958
|
return { path: Bun.resolveSync(args.path, from) };
|
|
784
959
|
}
|
|
785
960
|
catch {
|
|
786
|
-
return undefined; // not resolvable from the app root
|
|
961
|
+
return undefined; // not resolvable from the app root - leave Bun's default resolution
|
|
787
962
|
}
|
|
788
963
|
});
|
|
789
964
|
},
|
|
790
965
|
});
|
|
791
966
|
/**
|
|
792
967
|
* Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
|
|
793
|
-
* `auth.server.ts`, …) is server-only
|
|
968
|
+
* `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
|
|
794
969
|
* Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
|
|
795
970
|
* import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
|
|
796
971
|
* import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
|
|
797
972
|
* in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
|
|
798
|
-
* module is the fix. CLIENT-only
|
|
973
|
+
* module is the fix. CLIENT-only - buildServer keeps the real module, which runs server-side.
|
|
799
974
|
*/
|
|
800
|
-
const SERVER_ONLY_MODULE = /\.server(\.[cm]?[jt]sx?)?$/;
|
|
801
975
|
export const serverOnlyEmptyPlugin = () => ({
|
|
802
976
|
name: "nifra-server-only-empty",
|
|
803
977
|
setup(build) {
|
|
804
978
|
build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
|
|
805
|
-
contents:
|
|
979
|
+
contents: SERVER_ONLY_REPLACEMENT,
|
|
980
|
+
loader: "js",
|
|
981
|
+
}));
|
|
982
|
+
},
|
|
983
|
+
});
|
|
984
|
+
/**
|
|
985
|
+
* Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes
|
|
986
|
+
* the server mounted, so the function bodies - and everything they import - never reach a browser.
|
|
987
|
+
*
|
|
988
|
+
* The sibling of {@link serverOnlyEmptyPlugin}, and a deliberate contrast: a `*.server` module is
|
|
989
|
+
* EMPTIED because nothing may call it from the client, while a `*.fn` module is REPLACED because the
|
|
990
|
+
* client is supposed to call it, just over HTTP. The generation itself is in
|
|
991
|
+
* `internal/server-boundary.ts` so the Vite pipeline emits identical stubs from the same code; two
|
|
992
|
+
* hand-written copies would be a client that works in dev and 404s in production.
|
|
993
|
+
*
|
|
994
|
+
* CLIENT-only. The server build keeps the real module, which is what `serverFunctions()` mounts.
|
|
995
|
+
*/
|
|
996
|
+
export const serverFnStubPlugin = () => ({
|
|
997
|
+
name: "nifra-server-fn-stub",
|
|
998
|
+
setup(build) {
|
|
999
|
+
build.onLoad({ filter: SERVER_FN_MODULE }, async (args) => ({
|
|
1000
|
+
contents: generateServerFnStub(await Bun.file(args.path).text(), serverFnNamespace(args.path)),
|
|
806
1001
|
loader: "js",
|
|
807
1002
|
}));
|
|
808
1003
|
},
|
|
@@ -831,7 +1026,7 @@ const solidWebServerPlugin = (from) => ({
|
|
|
831
1026
|
* (`generateServerManifest`, written next to `serverEntry`), then bundles `serverEntry` with
|
|
832
1027
|
* `Bun.build` using **edge conditions** + the adapter's SSR plugins. The output imports no `node:fs`
|
|
833
1028
|
* and does no dynamic-path import, so it runs on workerd: point `wrangler.toml`'s `main` at it and
|
|
834
|
-
* serve the client assets via Workers Assets. Throws (with the bundler logs) on failure
|
|
1029
|
+
* serve the client assets via Workers Assets. Throws (with the bundler logs) on failure - never
|
|
835
1030
|
* silently ships a broken worker.
|
|
836
1031
|
*/
|
|
837
1032
|
export async function buildServer(options) {
|
|
@@ -847,7 +1042,7 @@ export async function buildServer(options) {
|
|
|
847
1042
|
// Edge (browser) target: Bun's `bun` condition contaminates react-dom's server build + the `worker`
|
|
848
1043
|
// condition segfaults Bun.build on solid, so force the edge/server builds via shims. The `node`/`bun`
|
|
849
1044
|
// targets resolve those correctly via their own condition, so the shims (and edge conditions) don't
|
|
850
|
-
// apply
|
|
1045
|
+
// apply - defaults become `[target]` (e.g. react-dom → server.node.js under `node`).
|
|
851
1046
|
const edge = target === "browser";
|
|
852
1047
|
const conditions = options.conditions ?? (edge ? ["workerd", "edge-light", "browser"] : [target]);
|
|
853
1048
|
const manifest = discoverRoutes(routesDir);
|
|
@@ -862,11 +1057,12 @@ export async function buildServer(options) {
|
|
|
862
1057
|
// Tag every BUNDLED SSR output so @nifrajs/web-react's react-dom adapter takes the static
|
|
863
1058
|
// (bundled, deduped) react-dom instead of re-rooting react-dom/server to a DISK copy at runtime.
|
|
864
1059
|
// A `target:"bun"` bundle still has `Bun.resolveSync` under the Bun runtime, so without this tag the
|
|
865
|
-
// adapter re-imports a SECOND react-dom from node_modules
|
|
1060
|
+
// adapter re-imports a SECOND react-dom from node_modules - a second React core whose hook dispatcher
|
|
866
1061
|
// is null for the bundled components → SSR throws `…H.useRef of null`. Always set (a structural fact
|
|
867
1062
|
// of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
|
|
868
|
-
// (nifra dev/start, nifra_render) never define it, so they still re-root
|
|
1063
|
+
// (nifra dev/start, nifra_render) never define it, so they still re-root - the dev dual-install fix.
|
|
869
1064
|
"process.env.NIFRA_SSR_BUNDLED": '"1"',
|
|
1065
|
+
"globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
|
|
870
1066
|
},
|
|
871
1067
|
minify: options.minify ?? true,
|
|
872
1068
|
// Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
|
|
@@ -888,12 +1084,12 @@ export async function buildServer(options) {
|
|
|
888
1084
|
return { worker: entryOutput.path, outputs: result.outputs.map((o) => o.path) };
|
|
889
1085
|
}
|
|
890
1086
|
// ===================================================================================================
|
|
891
|
-
// `nifra build --target`
|
|
1087
|
+
// `nifra build --target` - package the engine above into one command that emits a full deploy dir.
|
|
892
1088
|
//
|
|
893
1089
|
// An app already declares everything the build needs through nifra's conventions: `adapter` +
|
|
894
1090
|
// `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`.
|
|
895
1091
|
// The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
|
|
896
|
-
// …)
|
|
1092
|
+
// …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
|
|
897
1093
|
// ===================================================================================================
|
|
898
1094
|
/** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
|
|
899
1095
|
export const BUILD_TARGETS = ["bun", "node", "deno", "cf-pages", "vercel", "static"];
|
|
@@ -910,12 +1106,12 @@ export function isBuildTarget(value) {
|
|
|
910
1106
|
* - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
|
|
911
1107
|
* - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
|
|
912
1108
|
* runtimes have a filesystem; the static `/assets/*` sit next to the entry).
|
|
913
|
-
* `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written)
|
|
1109
|
+
* `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) -
|
|
914
1110
|
* `buildServer` writes the entry next to `serverEntry`, so they're resolved from there. Pure (string in,
|
|
915
1111
|
* string out) so the generation is unit-testable without a real build.
|
|
916
1112
|
*/
|
|
917
1113
|
export function generateServerEntry(options) {
|
|
918
|
-
const { target, adapterImport, backendImport, title = "nifra" } = options;
|
|
1114
|
+
const { target, adapterImport, backendImport, title = "nifra", publicFiles = [] } = options;
|
|
919
1115
|
if (target === "static") {
|
|
920
1116
|
throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)");
|
|
921
1117
|
}
|
|
@@ -935,30 +1131,35 @@ export function generateServerEntry(options) {
|
|
|
935
1131
|
lines.push('import { serve } from "@nifrajs/node"');
|
|
936
1132
|
lines.push("", "const app = createWebApp({", " adapter,", " manifest,", " clientEntry,", " styles,", " routeStyles,", ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`, "})", "");
|
|
937
1133
|
if (target === "cf-pages") {
|
|
938
|
-
// Cloudflare Pages advanced mode: `_routes.json`
|
|
939
|
-
// falls through to this
|
|
940
|
-
|
|
1134
|
+
// Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and
|
|
1135
|
+
// everything else falls through to this handler (SSR).
|
|
1136
|
+
//
|
|
1137
|
+
// That exclude list cannot always name every public file - Cloudflare caps `_routes.json` at 100
|
|
1138
|
+
// rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting
|
|
1139
|
+
// the router 404 it, which makes correctness independent of how much of the list fit: the exclude
|
|
1140
|
+
// list decides only how many static requests skip the worker, never which files exist.
|
|
1141
|
+
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
1142
|
return `${lines.join("\n")}\n`;
|
|
942
1143
|
}
|
|
943
1144
|
if (target === "vercel") {
|
|
944
|
-
lines.push("// Vercel Edge Function
|
|
1145
|
+
lines.push("// Vercel Edge Function - Vercel serves /assets/* from its CDN; this only SSRs page routes.", 'export const config = { runtime: "edge" }', "export default (req: Request): Response | Promise<Response> => app.fetch(req)");
|
|
945
1146
|
return `${lines.join("\n")}\n`;
|
|
946
1147
|
}
|
|
947
1148
|
// bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
|
|
948
|
-
lines.push("// The client bundle
|
|
1149
|
+
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
1150
|
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)",
|
|
1151
|
+
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
1152
|
// The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
|
|
952
|
-
// source
|
|
1153
|
+
// source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
|
|
953
1154
|
`console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
|
|
954
1155
|
return `${lines.join("\n")}\n`;
|
|
955
1156
|
}
|
|
956
1157
|
if (target === "node") {
|
|
957
|
-
lines.push('import { readFile } from "node:fs/promises"', "await serve(", " {", " async fetch(req) {", " const { pathname } = new URL(req.url)",
|
|
1158
|
+
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
1159
|
return `${lines.join("\n")}\n`;
|
|
959
1160
|
}
|
|
960
1161
|
// deno
|
|
961
|
-
lines.push("const handler = toFetchHandler(app)", "// @ts-ignore
|
|
1162
|
+
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
1163
|
return `${lines.join("\n")}\n`;
|
|
963
1164
|
}
|
|
964
1165
|
/** The Bun.build `target` each deploy target compiles its server with (mirrors `buildServer`'s docs). */
|
|
@@ -984,22 +1185,39 @@ async function measureOutputs(paths) {
|
|
|
984
1185
|
}
|
|
985
1186
|
return chunks;
|
|
986
1187
|
}
|
|
1188
|
+
/** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
|
|
1189
|
+
export const bunBundler = {
|
|
1190
|
+
buildClient: (input) => buildClient({
|
|
1191
|
+
routesDir: input.routesDir,
|
|
1192
|
+
outDir: input.outDir,
|
|
1193
|
+
clientModule: input.clientModule,
|
|
1194
|
+
...(input.plugins ? { plugins: input.plugins } : {}),
|
|
1195
|
+
...(input.conditions ? { conditions: input.conditions } : {}),
|
|
1196
|
+
...(input.define ? { define: input.define } : {}),
|
|
1197
|
+
...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
|
|
1198
|
+
...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
|
|
1199
|
+
}),
|
|
1200
|
+
buildServer: (input) => buildServer({
|
|
1201
|
+
routesDir: input.routesDir,
|
|
1202
|
+
serverEntry: input.serverEntry,
|
|
1203
|
+
outDir: input.outDir,
|
|
1204
|
+
clientEntry: input.clientEntry,
|
|
1205
|
+
target: input.target,
|
|
1206
|
+
...(input.plugins ? { plugins: input.plugins } : {}),
|
|
1207
|
+
...(input.define ? { define: input.define } : {}),
|
|
1208
|
+
}),
|
|
1209
|
+
};
|
|
1210
|
+
/** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
|
|
1211
|
+
export async function buildTarget(target, options) {
|
|
1212
|
+
return buildTargetWith(target, options, bunBundler);
|
|
1213
|
+
}
|
|
987
1214
|
/**
|
|
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.
|
|
1215
|
+
* The bundler-agnostic deploy orchestrator: everything `buildTarget` does EXCEPT the two bundling steps,
|
|
1216
|
+
* which come from `bundler`. `buildTarget` passes {@link bunBundler}; `buildTargetVite`
|
|
1217
|
+
* (`@nifrajs/web/build-vite`) passes the Vite strategy. One orchestrator, so the deploy-dir shape,
|
|
1218
|
+
* server-entry codegen, prerender and size report are identical across pipelines.
|
|
1001
1219
|
*/
|
|
1002
|
-
export async function
|
|
1220
|
+
export async function buildTargetWith(target, options, bundler) {
|
|
1003
1221
|
const { routesDir, outDir, workDir } = options;
|
|
1004
1222
|
const { rmSync } = await import("node:fs");
|
|
1005
1223
|
rmSync(outDir, { recursive: true, force: true });
|
|
@@ -1008,14 +1226,25 @@ export async function buildTarget(target, options) {
|
|
|
1008
1226
|
mkdirSync(assetsDir, { recursive: true });
|
|
1009
1227
|
mkdirSync(workDir, { recursive: true });
|
|
1010
1228
|
// (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
|
|
1011
|
-
|
|
1229
|
+
let client = await bundler.buildClient({
|
|
1012
1230
|
routesDir,
|
|
1013
1231
|
outDir: assetsDir,
|
|
1014
1232
|
clientModule: options.clientModule,
|
|
1015
1233
|
...(options.clientPlugins ? { plugins: options.clientPlugins } : {}),
|
|
1016
1234
|
...(options.conditions ? { conditions: options.conditions } : {}),
|
|
1017
1235
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1236
|
+
publicDir: false,
|
|
1237
|
+
...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
|
|
1238
|
+
root: resolvePath(dirname(routesDir)),
|
|
1018
1239
|
});
|
|
1240
|
+
const publicDir = options.publicDir === false
|
|
1241
|
+
? undefined
|
|
1242
|
+
: resolvePath(options.publicDir ?? join(dirname(routesDir), "public"));
|
|
1243
|
+
const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [];
|
|
1244
|
+
if (publicFiles.length > 0) {
|
|
1245
|
+
client = { ...client, publicFiles };
|
|
1246
|
+
writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2));
|
|
1247
|
+
}
|
|
1019
1248
|
if (target === "static") {
|
|
1020
1249
|
if (options.prerenderApp === undefined) {
|
|
1021
1250
|
throw new Error("[nifra/web] buildTarget(static) requires `prerenderApp` (a factory `(client) => createWebApp`)");
|
|
@@ -1032,8 +1261,8 @@ export async function buildTarget(target, options) {
|
|
|
1032
1261
|
});
|
|
1033
1262
|
if (result.prerendered.length === 0) {
|
|
1034
1263
|
// A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
|
|
1035
|
-
// getStaticPaths)
|
|
1036
|
-
throw new Error("[nifra/web] buildTarget(static): no routes were prerendered
|
|
1264
|
+
// getStaticPaths) - fail loudly rather than ship an empty dir the dev thinks is their site.
|
|
1265
|
+
throw new Error("[nifra/web] buildTarget(static): no routes were prerendered - opt routes in with " +
|
|
1037
1266
|
"`export const prerender = true` (static) or `getStaticPaths` (dynamic).");
|
|
1038
1267
|
}
|
|
1039
1268
|
rmSync(workDir, { recursive: true, force: true });
|
|
@@ -1055,9 +1284,10 @@ export async function buildTarget(target, options) {
|
|
|
1055
1284
|
adapterImport: options.adapterImport,
|
|
1056
1285
|
...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
|
|
1057
1286
|
...(options.title !== undefined ? { title: options.title } : {}),
|
|
1287
|
+
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
1058
1288
|
}));
|
|
1059
1289
|
const serverTarget = SERVER_BUILD_TARGET[target];
|
|
1060
|
-
const { worker } = await buildServer({
|
|
1290
|
+
const { worker } = await bundler.buildServer({
|
|
1061
1291
|
routesDir,
|
|
1062
1292
|
serverEntry: serverEntryPath,
|
|
1063
1293
|
outDir: `${workDir}/server`,
|
|
@@ -1065,13 +1295,24 @@ export async function buildTarget(target, options) {
|
|
|
1065
1295
|
target: serverTarget,
|
|
1066
1296
|
...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
|
|
1067
1297
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1298
|
+
root: resolvePath(dirname(routesDir)),
|
|
1068
1299
|
});
|
|
1069
1300
|
// (3) Assemble the deploy dir for the target.
|
|
1070
1301
|
const { cpSync } = await import("node:fs");
|
|
1071
1302
|
let run;
|
|
1072
1303
|
if (target === "cf-pages") {
|
|
1073
1304
|
cpSync(worker, `${outDir}/_worker.js`);
|
|
1074
|
-
|
|
1305
|
+
// The app's real patterns, so a directory is only collapsed into a glob once the route table
|
|
1306
|
+
// proves nothing can be served beneath it.
|
|
1307
|
+
const rules = cloudflareRouteRules(publicFiles, discoverRoutes(routesDir).routes.map((route) => route.pattern));
|
|
1308
|
+
writeFileSync(`${outDir}/_routes.json`, `${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`);
|
|
1309
|
+
// Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a
|
|
1310
|
+
// worker invocation, and that trade should be visible rather than inferred from a latency graph.
|
|
1311
|
+
if (rules.omitted.length > 0) {
|
|
1312
|
+
console.log(`[nifra/web] _routes.json holds ${rules.exclude.length} of ${publicFiles.length + 1} static rules ` +
|
|
1313
|
+
`(Cloudflare allows ${CF_MAX_ROUTE_RULES}). The other ${rules.omitted.length} file(s) are served by the ` +
|
|
1314
|
+
"worker via ASSETS instead of the CDN directly - correct, just one invocation each.");
|
|
1315
|
+
}
|
|
1075
1316
|
run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`;
|
|
1076
1317
|
}
|
|
1077
1318
|
else if (target === "vercel") {
|