@nifrajs/web 2.0.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/deferred.d.ts +3 -2
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +11 -8
- package/dist/deferred.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/deferred.ts +13 -6
- 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/src/build.ts
CHANGED
|
@@ -5,13 +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 {
|
|
10
|
-
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 { type BunPlugin, Glob } from "bun"
|
|
11
12
|
import { sanitizeOutputNames } from "./chunk-names.ts"
|
|
12
13
|
import { discoverRoutes } from "./fs.ts"
|
|
13
14
|
import { generateClientEntry, generateServerManifest } from "./index.ts"
|
|
14
15
|
// `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
|
|
16
|
+
import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
|
|
15
17
|
import { prerenderRoutes } from "./prerender.ts"
|
|
16
18
|
|
|
17
19
|
// Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
|
|
@@ -48,6 +50,15 @@ export interface BuildClientOptions {
|
|
|
48
50
|
readonly minify?: boolean
|
|
49
51
|
/** URL prefix the assets are served under (default `"/assets/"`); also Bun's chunk `publicPath`. */
|
|
50
52
|
readonly publicPath?: string
|
|
53
|
+
/**
|
|
54
|
+
* Directory of user-authored static files copied into the build and served at the root (default
|
|
55
|
+
* `"public"`). Absent directory ⇒ nothing copied, no error.
|
|
56
|
+
*
|
|
57
|
+
* NOT the same thing as {@link publicPath}, despite the names: that is the URL prefix for
|
|
58
|
+
* content-hashed bundle chunks and never covers files an author put on disk. The collision is a
|
|
59
|
+
* real source of confusion, which is why both are spelled out here.
|
|
60
|
+
*/
|
|
61
|
+
readonly publicDir?: string | false
|
|
51
62
|
/**
|
|
52
63
|
* Prefix that opts an environment variable into the **client** bundle (Vite/Next convention; default
|
|
53
64
|
* `"PUBLIC_"`). Every var in the build environment whose name starts with this prefix is baked into
|
|
@@ -82,7 +93,186 @@ export function publicEnvDefines(
|
|
|
82
93
|
return defines
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Percent-encode one path segment exactly the way a browser encodes it into a request URL.
|
|
98
|
+
*
|
|
99
|
+
* NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw -
|
|
100
|
+
* so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives
|
|
101
|
+
* as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI`
|
|
102
|
+
* agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must
|
|
103
|
+
* be escaped explicitly here.
|
|
104
|
+
*/
|
|
105
|
+
function encodePathSegment(segment: string): string {
|
|
106
|
+
return encodeURI(segment).replace(
|
|
107
|
+
/[?#]/g,
|
|
108
|
+
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any
|
|
114
|
+
* single rule longer than 100 characters.
|
|
115
|
+
*/
|
|
116
|
+
const CF_MAX_ROUTE_RULES = 100
|
|
117
|
+
const CF_MAX_RULE_LENGTH = 100
|
|
118
|
+
|
|
119
|
+
export interface CloudflareRouteRules {
|
|
120
|
+
readonly include: readonly string[]
|
|
121
|
+
readonly exclude: readonly string[]
|
|
122
|
+
/** Public files the budget could not name, so the build can say so instead of capping silently. */
|
|
123
|
+
readonly omitted: readonly string[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What a route pattern's first URL segment says about the paths it can reach.
|
|
128
|
+
*
|
|
129
|
+
* The three cases are genuinely different and collapsing them is a fail-open bug: `/` reaches only the
|
|
130
|
+
* root and can never match below a directory, while `/:locale/about` can match below ANY directory, and
|
|
131
|
+
* both would otherwise read as "no literal first segment". `dynamic` is also where unrecognised syntax
|
|
132
|
+
* lands, deliberately - a pattern form this does not understand must never be taken as proof that
|
|
133
|
+
* nothing matches.
|
|
134
|
+
*/
|
|
135
|
+
type FirstSegment =
|
|
136
|
+
| { readonly kind: "root" }
|
|
137
|
+
| { readonly kind: "literal"; readonly value: string }
|
|
138
|
+
| { readonly kind: "dynamic" }
|
|
139
|
+
|
|
140
|
+
function firstSegmentOf(pattern: string): FirstSegment {
|
|
141
|
+
const first = pattern.split("/").find((segment) => segment !== "")
|
|
142
|
+
if (first === undefined) return { kind: "root" }
|
|
143
|
+
return /^[A-Za-z0-9._-]+$/.test(first) ? { kind: "literal", value: first } : { kind: "dynamic" }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Directories under `public/` whose every file can be replaced by one `/<dir>/*` rule.
|
|
148
|
+
*
|
|
149
|
+
* Safe only when no route can ever be served beneath that directory, because the glob does not merely
|
|
150
|
+
* describe today's files - it hands Pages every future path under the prefix, and the worker never sees
|
|
151
|
+
* them. A `public/blog/hero.png` next to a `/blog/:slug` route is the ordinary case that breaks: the
|
|
152
|
+
* glob would send `/blog/my-post` to the CDN, which has no such file, and the page 404s in production
|
|
153
|
+
* only. So a directory collapses only when every route pattern starts with a literal segment that is
|
|
154
|
+
* something else. One dynamic first segment anywhere (`/:locale/…`) disables collapsing entirely, which
|
|
155
|
+
* is correct - such a route can match under any directory name.
|
|
156
|
+
*
|
|
157
|
+
* A collapsed directory also gives up the app's own 404 page for a missing file beneath it, since the
|
|
158
|
+
* request never reaches the worker. That is the right trade for a directory that holds only static
|
|
159
|
+
* files: a missing image should fail as a fast CDN 404, not as an HTML error page.
|
|
160
|
+
*/
|
|
161
|
+
function collapsibleDirs(
|
|
162
|
+
publicFiles: readonly string[],
|
|
163
|
+
routePatterns: readonly string[],
|
|
164
|
+
): ReadonlySet<string> {
|
|
165
|
+
const reserved = new Set<string>()
|
|
166
|
+
for (const pattern of routePatterns) {
|
|
167
|
+
const first = firstSegmentOf(pattern)
|
|
168
|
+
// A dynamic first segment can match anywhere, so nothing is collapsible at all.
|
|
169
|
+
if (first.kind === "dynamic") return new Set()
|
|
170
|
+
// `root` reserves nothing: `/` cannot match a path below a directory.
|
|
171
|
+
if (first.kind === "literal") reserved.add(first.value)
|
|
172
|
+
}
|
|
173
|
+
const dirs = new Set<string>()
|
|
174
|
+
for (const file of publicFiles) {
|
|
175
|
+
const slash = file.indexOf("/", 1)
|
|
176
|
+
if (slash === -1) continue // a root-level file has no directory to collapse into
|
|
177
|
+
const dir = file.slice(1, slash)
|
|
178
|
+
if (!reserved.has(dir)) dirs.add(dir)
|
|
179
|
+
}
|
|
180
|
+
return dirs
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build the cf-pages `_routes.json` rules for a set of copied public files, within Cloudflare's budget.
|
|
185
|
+
*
|
|
186
|
+
* `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every
|
|
187
|
+
* public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images
|
|
188
|
+
* clears 100 easily, and the rejection lands at `wrangler pages deploy`, long after the build reported
|
|
189
|
+
* success.
|
|
190
|
+
*
|
|
191
|
+
* A glob is emitted only where it cannot be wrong. `/assets/*` always is: the build owns that prefix
|
|
192
|
+
* outright, since it is where the hashed bundle is written. A `public/` subdirectory is different - the
|
|
193
|
+
* name is the author's, and a `/blog/*` rule would hand Pages every future path under `/blog/`, so a
|
|
194
|
+
* `/blog/:slug` route would 404 from the CDN in production only. {@link collapsibleDirs} therefore
|
|
195
|
+
* collapses a directory only after checking it against the app's real route patterns, which is what
|
|
196
|
+
* makes the compaction safe rather than merely smaller. Everything else stays an exact path, and a name
|
|
197
|
+
* containing `*` is dropped rather than escaped, since the rule would become a wildcard.
|
|
198
|
+
*
|
|
199
|
+
* What still does not fit is dropped, which is safe because the list is only an optimization: the
|
|
200
|
+
* generated worker serves any allowlisted path it receives through the ASSETS binding, so an omitted
|
|
201
|
+
* file costs one worker invocation, not a 404.
|
|
202
|
+
*/
|
|
203
|
+
export function cloudflareRouteRules(
|
|
204
|
+
publicFiles: readonly string[],
|
|
205
|
+
/**
|
|
206
|
+
* Every route pattern the app serves. Required, not defaulted: an empty list means "this app serves
|
|
207
|
+
* no routes", which makes every directory collapsible - a defensible answer to state deliberately and
|
|
208
|
+
* a dangerous one to arrive at by forgetting the argument.
|
|
209
|
+
*/
|
|
210
|
+
routePatterns: readonly string[],
|
|
211
|
+
): CloudflareRouteRules {
|
|
212
|
+
const include = ["/*"]
|
|
213
|
+
const exclude = ["/assets/*"]
|
|
214
|
+
const omitted: string[] = []
|
|
215
|
+
|
|
216
|
+
// Collapse only where the route table proves nothing can be served beneath the directory, and only
|
|
217
|
+
// when the per-file list would not have fit anyway - a smaller `_routes.json` is worth nothing on its
|
|
218
|
+
// own, and exact paths keep the app's own 404 for a missing file under that directory.
|
|
219
|
+
const perFileFits = publicFiles.length + include.length + exclude.length <= CF_MAX_ROUTE_RULES
|
|
220
|
+
const collapsible = perFileFits ? new Set<string>() : collapsibleDirs(publicFiles, routePatterns)
|
|
221
|
+
const emitted = new Set<string>()
|
|
222
|
+
|
|
223
|
+
for (const file of publicFiles) {
|
|
224
|
+
const slash = file.indexOf("/", 1)
|
|
225
|
+
const dir = slash === -1 ? undefined : file.slice(1, slash)
|
|
226
|
+
const collapsed = dir !== undefined && collapsible.has(dir)
|
|
227
|
+
const rule = collapsed ? `/${dir}/*` : file
|
|
228
|
+
if (emitted.has(rule)) continue // already covered by a rule emitted for an earlier file
|
|
229
|
+
// A `*` in a FILE name would turn that file's own rule into a wildcard. A collapsed rule's `*` is
|
|
230
|
+
// ours and deliberate, and the file's name never appears in it, so the check applies to one case.
|
|
231
|
+
if (!collapsed && file.includes("*")) {
|
|
232
|
+
omitted.push(file)
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
if (include.length + exclude.length >= CF_MAX_ROUTE_RULES || rule.length > CF_MAX_RULE_LENGTH) {
|
|
236
|
+
omitted.push(file)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
exclude.push(rule)
|
|
240
|
+
emitted.add(rule)
|
|
241
|
+
}
|
|
242
|
+
return { include, exclude, omitted }
|
|
243
|
+
}
|
|
244
|
+
|
|
85
245
|
/** The built asset map — the server reads `entry` for the client script + serves `assets`. */
|
|
246
|
+
/**
|
|
247
|
+
* Copy `from` into `to`, returning the URL paths copied (sorted).
|
|
248
|
+
*
|
|
249
|
+
* Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
|
|
250
|
+
* `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
|
|
251
|
+
* entry - a dynamic `import("bun")` there fails the browser build outright.
|
|
252
|
+
*/
|
|
253
|
+
export async function copyPublicDir(from: string, to: string): Promise<string[]> {
|
|
254
|
+
const source = resolvePath(from)
|
|
255
|
+
const sourceReal = await realpath(source)
|
|
256
|
+
const copied: string[] = []
|
|
257
|
+
for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
|
|
258
|
+
const candidate = join(source, rel)
|
|
259
|
+
const candidateStat = await lstat(candidate)
|
|
260
|
+
if (candidateStat.isSymbolicLink()) {
|
|
261
|
+
throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`)
|
|
262
|
+
}
|
|
263
|
+
if (!candidateStat.isFile()) continue
|
|
264
|
+
const candidateReal = await realpath(candidate)
|
|
265
|
+
if (candidateReal !== sourceReal && !candidateReal.startsWith(sourceReal + sep)) {
|
|
266
|
+
throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`)
|
|
267
|
+
}
|
|
268
|
+
const target = join(to, rel)
|
|
269
|
+
await mkdir(join(target, ".."), { recursive: true })
|
|
270
|
+
await cp(candidateReal, target)
|
|
271
|
+
copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`)
|
|
272
|
+
}
|
|
273
|
+
return copied.sort()
|
|
274
|
+
}
|
|
275
|
+
|
|
86
276
|
export interface BuildManifest {
|
|
87
277
|
/** URL of the client entry module (content-hashed). */
|
|
88
278
|
readonly entry: string
|
|
@@ -92,6 +282,10 @@ export interface BuildManifest {
|
|
|
92
282
|
* `routePreload` (`<link rel="modulepreload">` the matched route alongside the entry). Each route +
|
|
93
283
|
* layout is also a build entrypoint, so it gets a named chunk the bootstrap's lazy import dedupes to. */
|
|
94
284
|
readonly routes: Readonly<Record<string, readonly string[]>>
|
|
285
|
+
/** URL paths copied from `publicDir`, sorted. Lets the server entry serve them without scanning a
|
|
286
|
+
* directory per request, and lets an adapter that needs a file list (CDN upload, platform static
|
|
287
|
+
* assets) consume one. Omitted when there is no `public/`. */
|
|
288
|
+
readonly publicFiles?: readonly string[]
|
|
95
289
|
/** The app's bundled, content-hashed stylesheet(s) — the bootstrap's **aggregate** CSS (every
|
|
96
290
|
* `import './x.css'` reachable from the app). The complete stylesheet regardless of which file
|
|
97
291
|
* imported the CSS; the always-safe fallback `createWebApp` links when a route has no per-route entry
|
|
@@ -216,9 +410,9 @@ function shortestBuiltinChain(
|
|
|
216
410
|
* literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
|
|
217
411
|
*/
|
|
218
412
|
export function detectNodeBuiltinsInClient(
|
|
219
|
-
|
|
413
|
+
graph: ClientModuleGraph,
|
|
220
414
|
): ReadonlyArray<NodeBuiltinFinding> {
|
|
221
|
-
const inputs =
|
|
415
|
+
const inputs = graph.modules
|
|
222
416
|
// (1) The builtins a user (non-polyfill) module imports directly — the ones the author controls.
|
|
223
417
|
const userImported = new Set<string>()
|
|
224
418
|
for (const [inputKey, input] of Object.entries(inputs)) {
|
|
@@ -232,7 +426,7 @@ export function detectNodeBuiltinsInClient(
|
|
|
232
426
|
// at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
|
|
233
427
|
const entryInputs = [
|
|
234
428
|
...new Set(
|
|
235
|
-
Object.values(
|
|
429
|
+
Object.values(graph.chunks)
|
|
236
430
|
.map((o) => o.entryPoint)
|
|
237
431
|
.filter((e): e is string => e !== undefined && !e.startsWith("node:")),
|
|
238
432
|
),
|
|
@@ -248,8 +442,8 @@ export function detectNodeBuiltinsInClient(
|
|
|
248
442
|
}
|
|
249
443
|
// (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
|
|
250
444
|
const findings = new Map<string, NodeBuiltinFinding>()
|
|
251
|
-
for (const [outPath, out] of Object.entries(
|
|
252
|
-
for (const inputKey of
|
|
445
|
+
for (const [outPath, out] of Object.entries(graph.chunks)) {
|
|
446
|
+
for (const inputKey of out.modules) {
|
|
253
447
|
if (userImported.has(inputKey)) {
|
|
254
448
|
const chunk = basename(outPath)
|
|
255
449
|
findings.set(`${inputKey}\0${chunk}`, {
|
|
@@ -386,9 +580,9 @@ function inputKeyResolver(
|
|
|
386
580
|
* unit testing. Empty ⇒ clean.
|
|
387
581
|
*/
|
|
388
582
|
export function detectServerOnlyInClient(
|
|
389
|
-
|
|
583
|
+
graph: ClientModuleGraph,
|
|
390
584
|
): ReadonlyArray<ServerOnlyFinding> {
|
|
391
|
-
const inputs =
|
|
585
|
+
const inputs = graph.modules
|
|
392
586
|
// (1) The modules that import the marker — the ones the author opted into the guard. The marker
|
|
393
587
|
// module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
|
|
394
588
|
const marked = new Set<string>()
|
|
@@ -401,7 +595,7 @@ export function detectServerOnlyInClient(
|
|
|
401
595
|
// wrote an `import` in. Same derivation as the node-builtin guard.
|
|
402
596
|
const entryInputs = [
|
|
403
597
|
...new Set(
|
|
404
|
-
Object.values(
|
|
598
|
+
Object.values(graph.chunks)
|
|
405
599
|
.map((o) => o.entryPoint)
|
|
406
600
|
.filter((e): e is string => e !== undefined && !e.startsWith("node:")),
|
|
407
601
|
),
|
|
@@ -417,8 +611,8 @@ export function detectServerOnlyInClient(
|
|
|
417
611
|
}
|
|
418
612
|
// (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
|
|
419
613
|
const findings = new Map<string, ServerOnlyFinding>()
|
|
420
|
-
for (const [outPath, out] of Object.entries(
|
|
421
|
-
for (const inputKey of
|
|
614
|
+
for (const [outPath, out] of Object.entries(graph.chunks)) {
|
|
615
|
+
for (const inputKey of out.modules) {
|
|
422
616
|
if (marked.has(inputKey)) {
|
|
423
617
|
const chunk = basename(outPath)
|
|
424
618
|
findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) })
|
|
@@ -437,6 +631,48 @@ export function detectServerOnlyInClient(
|
|
|
437
631
|
const isServerOnlyMarkerModule = (inputKey: string): boolean =>
|
|
438
632
|
/(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey)
|
|
439
633
|
|
|
634
|
+
// ---------------------------------------------------------------------------------------------------
|
|
635
|
+
// Guard MESSAGES — one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
|
|
636
|
+
// leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
|
|
637
|
+
// bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
|
|
638
|
+
// error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
|
|
639
|
+
// ---------------------------------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
/** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
|
|
642
|
+
export function formatNodeBuiltinLeak(
|
|
643
|
+
findings: ReadonlyArray<NodeBuiltinFinding>,
|
|
644
|
+
): string | undefined {
|
|
645
|
+
if (findings.length === 0) return undefined
|
|
646
|
+
const lines = findings.map((f) =>
|
|
647
|
+
f.chain.length > 1
|
|
648
|
+
? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
649
|
+
: ` - ${f.builtin} reached the client bundle via ${f.chunk}`,
|
|
650
|
+
)
|
|
651
|
+
return (
|
|
652
|
+
`[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
|
|
653
|
+
`(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
|
|
654
|
+
`top level):\n${lines.join("\n")}`
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
|
|
659
|
+
export function formatServerOnlyLeak(
|
|
660
|
+
findings: ReadonlyArray<ServerOnlyFinding>,
|
|
661
|
+
): string | undefined {
|
|
662
|
+
if (findings.length === 0) return undefined
|
|
663
|
+
const lines = findings.map((f) =>
|
|
664
|
+
f.chain.length > 1
|
|
665
|
+
? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
666
|
+
: ` - server-only module reached the client bundle via ${f.chunk}`,
|
|
667
|
+
)
|
|
668
|
+
return (
|
|
669
|
+
`[nifra/web] server-only module(s) in the client bundle — a module marked ` +
|
|
670
|
+
`\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
|
|
671
|
+
`(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
|
|
672
|
+
`to the client:\n${lines.join("\n")}`
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
|
|
440
676
|
const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1)
|
|
441
677
|
/** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
|
|
442
678
|
const entryName = (file: string): string => {
|
|
@@ -784,42 +1020,18 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
784
1020
|
// per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
|
|
785
1021
|
// minification. Only the client build runs this; the server build's `node:` imports are legitimate.
|
|
786
1022
|
const clientMeta = (result as unknown as { metafile?: BunMetafile }).metafile
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
`(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
|
|
800
|
-
`top level):\n${lines.join("\n")}`,
|
|
801
|
-
)
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// §3.3/§5.1: a module that opted into the `server-only` marker (`import "@nifrajs/web/server-only"`)
|
|
805
|
-
// yet reached a CLIENT chunk — fail the build with the same chain-bearing, "reached the client bundle
|
|
806
|
-
// via" message shape as the node-builtin guard above. This catches pure-server logic (a secret, a
|
|
807
|
-
// server-only API call) that carries no `node:` import and isn't named `*.server`, so neither of the
|
|
808
|
-
// other two guards fires. Graph-based (the same metafile), so it survives minification.
|
|
809
|
-
const serverOnly = detectServerOnlyInClient(clientMeta)
|
|
810
|
-
if (serverOnly.length > 0) {
|
|
811
|
-
const lines = serverOnly.map((f) =>
|
|
812
|
-
f.chain.length > 1
|
|
813
|
-
? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
814
|
-
: ` - server-only module reached the client bundle via ${f.chunk}`,
|
|
815
|
-
)
|
|
816
|
-
throw new Error(
|
|
817
|
-
`[nifra/web] server-only module(s) in the client bundle — a module marked ` +
|
|
818
|
-
`\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
|
|
819
|
-
`(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
|
|
820
|
-
`to the client:\n${lines.join("\n")}`,
|
|
821
|
-
)
|
|
822
|
-
}
|
|
1023
|
+
const clientGraph = fromBunMetafile(clientMeta)
|
|
1024
|
+
// #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a
|
|
1025
|
+
// browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the
|
|
1026
|
+
// SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same.
|
|
1027
|
+
const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph))
|
|
1028
|
+
if (nodeBuiltinLeak !== undefined) throw new Error(nodeBuiltinLeak)
|
|
1029
|
+
|
|
1030
|
+
// §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk — catches
|
|
1031
|
+
// pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
|
|
1032
|
+
// `*.server`, so neither other guard fires. Same shared formatter as above.
|
|
1033
|
+
const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph))
|
|
1034
|
+
if (serverOnlyLeak !== undefined) throw new Error(serverOnlyLeak)
|
|
823
1035
|
|
|
824
1036
|
// Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
|
|
825
1037
|
// rewrite the references — otherwise the lazy import 404s and the route silently never hydrates.
|
|
@@ -899,10 +1111,17 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
899
1111
|
if (routeManifest.notFound) routeStyles._404 = stylesFor([routeManifest.notFound.file])
|
|
900
1112
|
}
|
|
901
1113
|
|
|
1114
|
+
// Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
|
|
1115
|
+
// apps have none) and must not fail the build.
|
|
1116
|
+
const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
|
|
1117
|
+
const publicFiles =
|
|
1118
|
+
publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
|
|
1119
|
+
|
|
902
1120
|
const manifest: BuildManifest = {
|
|
903
1121
|
entry: toUrl(bootstrap.path),
|
|
904
1122
|
assets: result.outputs.map((o) => toUrl(o.path)),
|
|
905
1123
|
routes,
|
|
1124
|
+
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
906
1125
|
...(css.length > 0 ? { css } : {}),
|
|
907
1126
|
...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
|
|
908
1127
|
}
|
|
@@ -1158,6 +1377,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
|
|
|
1158
1377
|
// of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
|
|
1159
1378
|
// (nifra dev/start, nifra_render) never define it, so they still re-root — the dev dual-install fix.
|
|
1160
1379
|
"process.env.NIFRA_SSR_BUNDLED": '"1"',
|
|
1380
|
+
"globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
|
|
1161
1381
|
},
|
|
1162
1382
|
minify: options.minify ?? true,
|
|
1163
1383
|
// Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
|
|
@@ -1220,8 +1440,10 @@ export function generateServerEntry(options: {
|
|
|
1220
1440
|
readonly backendImport?: string
|
|
1221
1441
|
/** Document `<title>` passed to `createWebApp`. */
|
|
1222
1442
|
readonly title?: string
|
|
1443
|
+
/** Encoded root-relative public file paths copied into the deploy directory. */
|
|
1444
|
+
readonly publicFiles?: readonly string[]
|
|
1223
1445
|
}): string {
|
|
1224
|
-
const { target, adapterImport, backendImport, title = "nifra" } = options
|
|
1446
|
+
const { target, adapterImport, backendImport, title = "nifra", publicFiles = [] } = options
|
|
1225
1447
|
if (target === "static") {
|
|
1226
1448
|
throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
|
|
1227
1449
|
}
|
|
@@ -1251,9 +1473,28 @@ export function generateServerEntry(options: {
|
|
|
1251
1473
|
)
|
|
1252
1474
|
|
|
1253
1475
|
if (target === "cf-pages") {
|
|
1254
|
-
// Cloudflare Pages advanced mode: `_routes.json`
|
|
1255
|
-
// falls through to this
|
|
1256
|
-
|
|
1476
|
+
// Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and
|
|
1477
|
+
// everything else falls through to this handler (SSR).
|
|
1478
|
+
//
|
|
1479
|
+
// That exclude list cannot always name every public file - Cloudflare caps `_routes.json` at 100
|
|
1480
|
+
// rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting
|
|
1481
|
+
// the router 404 it, which makes correctness independent of how much of the list fit: the exclude
|
|
1482
|
+
// list decides only how many static requests skip the worker, never which files exist.
|
|
1483
|
+
lines.push(
|
|
1484
|
+
`const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`,
|
|
1485
|
+
"const handler = toFetchHandler(app)",
|
|
1486
|
+
"type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }",
|
|
1487
|
+
"export default {",
|
|
1488
|
+
" fetch(request: Request, env: Assets, ctx: ExecutionContext) {",
|
|
1489
|
+
" const { pathname } = new URL(request.url)",
|
|
1490
|
+
" // Only the build's own outputs: the hashed bundle prefix and the files it copied.",
|
|
1491
|
+
' if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))) {',
|
|
1492
|
+
" return env.ASSETS.fetch(request)",
|
|
1493
|
+
" }",
|
|
1494
|
+
" return handler.fetch(request, env as never, ctx)",
|
|
1495
|
+
" },",
|
|
1496
|
+
"}",
|
|
1497
|
+
)
|
|
1257
1498
|
return `${lines.join("\n")}\n`
|
|
1258
1499
|
}
|
|
1259
1500
|
if (target === "vercel") {
|
|
@@ -1267,8 +1508,20 @@ export function generateServerEntry(options: {
|
|
|
1267
1508
|
|
|
1268
1509
|
// bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
|
|
1269
1510
|
lines.push(
|
|
1270
|
-
"// The client bundle
|
|
1271
|
-
'const
|
|
1511
|
+
"// The client bundle and public files live next to this entry; serve only manifest-approved paths.",
|
|
1512
|
+
'const STATIC_ROOT = new URL("./", import.meta.url)',
|
|
1513
|
+
`const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`,
|
|
1514
|
+
"const staticPath = (pathname: string): string | undefined => {",
|
|
1515
|
+
" if (PUBLIC_FILES.has(pathname)) return '.' + pathname",
|
|
1516
|
+
' if (!pathname.startsWith("/assets/")) return undefined',
|
|
1517
|
+
' const segments = pathname.slice(1).split("/")',
|
|
1518
|
+
" // `.` and `..` match the character class, and the path is joined onto a base URL that resolves",
|
|
1519
|
+
" // dot segments - so they are rejected explicitly rather than relying on the request URL having",
|
|
1520
|
+
" // been normalized upstream.",
|
|
1521
|
+
" const safe = (segment: string): boolean =>",
|
|
1522
|
+
' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
|
|
1523
|
+
" return segments.every(safe) ? '.' + pathname : undefined",
|
|
1524
|
+
"}",
|
|
1272
1525
|
'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }',
|
|
1273
1526
|
)
|
|
1274
1527
|
if (target === "bun") {
|
|
@@ -1277,12 +1530,11 @@ export function generateServerEntry(options: {
|
|
|
1277
1530
|
" port: Number(Bun.env.PORT ?? 3000),",
|
|
1278
1531
|
" async fetch(req) {",
|
|
1279
1532
|
" const { pathname } = new URL(req.url)",
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
" const file = Bun.file(new URL(name, ASSETS))",
|
|
1533
|
+
" const filePath = staticPath(pathname)",
|
|
1534
|
+
" if (filePath !== undefined) {",
|
|
1535
|
+
" const file = Bun.file(new URL(filePath, STATIC_ROOT))",
|
|
1284
1536
|
' if (!(await file.exists())) return new Response("not found", { status: 404 })',
|
|
1285
|
-
' const ext =
|
|
1537
|
+
' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
|
|
1286
1538
|
' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
|
|
1287
1539
|
" }",
|
|
1288
1540
|
" return app.fetch(req)",
|
|
@@ -1301,12 +1553,11 @@ export function generateServerEntry(options: {
|
|
|
1301
1553
|
" {",
|
|
1302
1554
|
" async fetch(req) {",
|
|
1303
1555
|
" const { pathname } = new URL(req.url)",
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })',
|
|
1556
|
+
" const filePath = staticPath(pathname)",
|
|
1557
|
+
" if (filePath !== undefined) {",
|
|
1307
1558
|
" try {",
|
|
1308
|
-
" const body = await readFile(new URL(
|
|
1309
|
-
' const ext =
|
|
1559
|
+
" const body = await readFile(new URL(filePath, STATIC_ROOT))",
|
|
1560
|
+
' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
|
|
1310
1561
|
' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
|
|
1311
1562
|
" } catch {",
|
|
1312
1563
|
' return new Response("not found", { status: 404 })',
|
|
@@ -1326,13 +1577,12 @@ export function generateServerEntry(options: {
|
|
|
1326
1577
|
"// @ts-ignore — Deno global is present on the Deno runtime this output targets.",
|
|
1327
1578
|
'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {',
|
|
1328
1579
|
" const { pathname } = new URL(req.url)",
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })',
|
|
1580
|
+
" const filePath = staticPath(pathname)",
|
|
1581
|
+
" if (filePath !== undefined) {",
|
|
1332
1582
|
" try {",
|
|
1333
1583
|
" // @ts-ignore — Deno.readFile is present on the Deno runtime.",
|
|
1334
|
-
" const body = await Deno.readFile(new URL(
|
|
1335
|
-
' const ext =
|
|
1584
|
+
" const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))",
|
|
1585
|
+
' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
|
|
1336
1586
|
' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
|
|
1337
1587
|
" } catch {",
|
|
1338
1588
|
' return new Response("not found", { status: 404 })',
|
|
@@ -1397,6 +1647,10 @@ export interface BuildTargetOptions {
|
|
|
1397
1647
|
readonly conditions?: readonly string[]
|
|
1398
1648
|
/** Compile-time `define` replacements layered onto both builds. */
|
|
1399
1649
|
readonly define?: Readonly<Record<string, string>>
|
|
1650
|
+
/** Project-root static directory copied to deploy root. `false` disables it. */
|
|
1651
|
+
readonly publicDir?: string | false
|
|
1652
|
+
/** Prefix of environment variables allowed into the client bundle (default `"PUBLIC_"`). */
|
|
1653
|
+
readonly publicEnvPrefix?: string
|
|
1400
1654
|
/** Document `<title>` for the generated server entry. */
|
|
1401
1655
|
readonly title?: string
|
|
1402
1656
|
}
|
|
@@ -1436,9 +1690,84 @@ export interface BuildTargetResult {
|
|
|
1436
1690
|
* Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
|
|
1437
1691
|
* remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
|
|
1438
1692
|
*/
|
|
1693
|
+
/**
|
|
1694
|
+
* A build-tool STRATEGY for {@link buildTargetWith} — the two bundling steps, and nothing else. Everything
|
|
1695
|
+
* around them (server-entry codegen, deploy assembly, prerender, size report) is bundler-agnostic and
|
|
1696
|
+
* lives in `buildTargetWith`, so a second bundler (Vite) is this interface, not a second orchestrator.
|
|
1697
|
+
*
|
|
1698
|
+
* Plugin lists are `readonly unknown[]` because a Bun plugin and a Vite plugin are different types; each
|
|
1699
|
+
* strategy casts to its own. `buildTargetWith` only forwards them.
|
|
1700
|
+
*/
|
|
1701
|
+
export interface Bundler {
|
|
1702
|
+
/** Build the client bundle → the shared {@link BuildManifest}. */
|
|
1703
|
+
buildClient(input: {
|
|
1704
|
+
readonly routesDir: string
|
|
1705
|
+
readonly outDir: string
|
|
1706
|
+
readonly clientModule: string
|
|
1707
|
+
readonly plugins?: readonly unknown[]
|
|
1708
|
+
readonly conditions?: readonly string[]
|
|
1709
|
+
readonly define?: Readonly<Record<string, string>>
|
|
1710
|
+
readonly publicDir?: string | false
|
|
1711
|
+
readonly publicEnvPrefix?: string
|
|
1712
|
+
/** Project root (Vite needs it; the Bun strategy ignores it). */
|
|
1713
|
+
readonly root?: string
|
|
1714
|
+
}): Promise<BuildManifest>
|
|
1715
|
+
/** Build the server worker → the shared {@link ServerBuild}. */
|
|
1716
|
+
buildServer(input: {
|
|
1717
|
+
readonly routesDir: string
|
|
1718
|
+
readonly serverEntry: string
|
|
1719
|
+
readonly outDir: string
|
|
1720
|
+
readonly clientEntry: string
|
|
1721
|
+
readonly target: "browser" | "node" | "bun"
|
|
1722
|
+
readonly plugins?: readonly unknown[]
|
|
1723
|
+
readonly define?: Readonly<Record<string, string>>
|
|
1724
|
+
readonly root?: string
|
|
1725
|
+
}): Promise<ServerBuild>
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/** The default (Bun) strategy — `buildClient`/`buildServer` from this module. */
|
|
1729
|
+
export const bunBundler: Bundler = {
|
|
1730
|
+
buildClient: (input) =>
|
|
1731
|
+
buildClient({
|
|
1732
|
+
routesDir: input.routesDir,
|
|
1733
|
+
outDir: input.outDir,
|
|
1734
|
+
clientModule: input.clientModule,
|
|
1735
|
+
...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
|
|
1736
|
+
...(input.conditions ? { conditions: input.conditions } : {}),
|
|
1737
|
+
...(input.define ? { define: input.define } : {}),
|
|
1738
|
+
...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
|
|
1739
|
+
...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
|
|
1740
|
+
}),
|
|
1741
|
+
buildServer: (input) =>
|
|
1742
|
+
buildServer({
|
|
1743
|
+
routesDir: input.routesDir,
|
|
1744
|
+
serverEntry: input.serverEntry,
|
|
1745
|
+
outDir: input.outDir,
|
|
1746
|
+
clientEntry: input.clientEntry,
|
|
1747
|
+
target: input.target,
|
|
1748
|
+
...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
|
|
1749
|
+
...(input.define ? { define: input.define } : {}),
|
|
1750
|
+
}),
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
|
|
1439
1754
|
export async function buildTarget(
|
|
1440
1755
|
target: BuildTarget,
|
|
1441
1756
|
options: BuildTargetOptions,
|
|
1757
|
+
): Promise<BuildTargetResult> {
|
|
1758
|
+
return buildTargetWith(target, options, bunBundler)
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* The bundler-agnostic deploy orchestrator: everything `buildTarget` does EXCEPT the two bundling steps,
|
|
1763
|
+
* which come from `bundler`. `buildTarget` passes {@link bunBundler}; `buildTargetVite`
|
|
1764
|
+
* (`@nifrajs/web/build-vite`) passes the Vite strategy. One orchestrator, so the deploy-dir shape,
|
|
1765
|
+
* server-entry codegen, prerender and size report are identical across pipelines.
|
|
1766
|
+
*/
|
|
1767
|
+
export async function buildTargetWith(
|
|
1768
|
+
target: BuildTarget,
|
|
1769
|
+
options: BuildTargetOptions,
|
|
1770
|
+
bundler: Bundler,
|
|
1442
1771
|
): Promise<BuildTargetResult> {
|
|
1443
1772
|
const { routesDir, outDir, workDir } = options
|
|
1444
1773
|
const { rmSync } = await import("node:fs")
|
|
@@ -1449,14 +1778,27 @@ export async function buildTarget(
|
|
|
1449
1778
|
mkdirSync(workDir, { recursive: true })
|
|
1450
1779
|
|
|
1451
1780
|
// (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
|
|
1452
|
-
|
|
1781
|
+
let client = await bundler.buildClient({
|
|
1453
1782
|
routesDir,
|
|
1454
1783
|
outDir: assetsDir,
|
|
1455
1784
|
clientModule: options.clientModule,
|
|
1456
1785
|
...(options.clientPlugins ? { plugins: options.clientPlugins } : {}),
|
|
1457
1786
|
...(options.conditions ? { conditions: options.conditions } : {}),
|
|
1458
1787
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1788
|
+
publicDir: false,
|
|
1789
|
+
...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
|
|
1790
|
+
root: resolvePath(dirname(routesDir)),
|
|
1459
1791
|
})
|
|
1792
|
+
const publicDir =
|
|
1793
|
+
options.publicDir === false
|
|
1794
|
+
? undefined
|
|
1795
|
+
: resolvePath(options.publicDir ?? join(dirname(routesDir), "public"))
|
|
1796
|
+
const publicFiles =
|
|
1797
|
+
publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
|
|
1798
|
+
if (publicFiles.length > 0) {
|
|
1799
|
+
client = { ...client, publicFiles }
|
|
1800
|
+
writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
|
|
1801
|
+
}
|
|
1460
1802
|
|
|
1461
1803
|
if (target === "static") {
|
|
1462
1804
|
if (options.prerenderApp === undefined) {
|
|
@@ -1506,10 +1848,11 @@ export async function buildTarget(
|
|
|
1506
1848
|
adapterImport: options.adapterImport,
|
|
1507
1849
|
...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
|
|
1508
1850
|
...(options.title !== undefined ? { title: options.title } : {}),
|
|
1851
|
+
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
1509
1852
|
}),
|
|
1510
1853
|
)
|
|
1511
1854
|
const serverTarget = SERVER_BUILD_TARGET[target]
|
|
1512
|
-
const { worker } = await buildServer({
|
|
1855
|
+
const { worker } = await bundler.buildServer({
|
|
1513
1856
|
routesDir,
|
|
1514
1857
|
serverEntry: serverEntryPath,
|
|
1515
1858
|
outDir: `${workDir}/server`,
|
|
@@ -1517,6 +1860,7 @@ export async function buildTarget(
|
|
|
1517
1860
|
target: serverTarget,
|
|
1518
1861
|
...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
|
|
1519
1862
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1863
|
+
root: resolvePath(dirname(routesDir)),
|
|
1520
1864
|
})
|
|
1521
1865
|
|
|
1522
1866
|
// (3) Assemble the deploy dir for the target.
|
|
@@ -1524,10 +1868,25 @@ export async function buildTarget(
|
|
|
1524
1868
|
let run: string
|
|
1525
1869
|
if (target === "cf-pages") {
|
|
1526
1870
|
cpSync(worker, `${outDir}/_worker.js`)
|
|
1871
|
+
// The app's real patterns, so a directory is only collapsed into a glob once the route table
|
|
1872
|
+
// proves nothing can be served beneath it.
|
|
1873
|
+
const rules = cloudflareRouteRules(
|
|
1874
|
+
publicFiles,
|
|
1875
|
+
discoverRoutes(routesDir).routes.map((route) => route.pattern),
|
|
1876
|
+
)
|
|
1527
1877
|
writeFileSync(
|
|
1528
1878
|
`${outDir}/_routes.json`,
|
|
1529
|
-
`${JSON.stringify({ version: 1, include:
|
|
1879
|
+
`${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`,
|
|
1530
1880
|
)
|
|
1881
|
+
// Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a
|
|
1882
|
+
// worker invocation, and that trade should be visible rather than inferred from a latency graph.
|
|
1883
|
+
if (rules.omitted.length > 0) {
|
|
1884
|
+
console.log(
|
|
1885
|
+
`[nifra/web] _routes.json holds ${rules.exclude.length} of ${publicFiles.length + 1} static rules ` +
|
|
1886
|
+
`(Cloudflare allows ${CF_MAX_ROUTE_RULES}). The other ${rules.omitted.length} file(s) are served by the ` +
|
|
1887
|
+
"worker via ASSETS instead of the CDN directly - correct, just one invocation each.",
|
|
1888
|
+
)
|
|
1889
|
+
}
|
|
1531
1890
|
run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`
|
|
1532
1891
|
} else if (target === "vercel") {
|
|
1533
1892
|
cpSync(worker, `${outDir}/index.js`)
|