@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/src/build.ts
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
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 {
|
|
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"
|
|
15
|
+
import {
|
|
16
|
+
generateServerFnStub,
|
|
17
|
+
SERVER_FN_MODULE,
|
|
18
|
+
SERVER_ONLY_MODULE,
|
|
19
|
+
SERVER_ONLY_REPLACEMENT,
|
|
20
|
+
serverFnNamespace,
|
|
21
|
+
} from "./internal/server-boundary.ts"
|
|
14
22
|
// `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
|
|
23
|
+
import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
|
|
15
24
|
import { prerenderRoutes } from "./prerender.ts"
|
|
16
25
|
|
|
17
26
|
// Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
|
|
@@ -48,11 +57,20 @@ export interface BuildClientOptions {
|
|
|
48
57
|
readonly minify?: boolean
|
|
49
58
|
/** URL prefix the assets are served under (default `"/assets/"`); also Bun's chunk `publicPath`. */
|
|
50
59
|
readonly publicPath?: string
|
|
60
|
+
/**
|
|
61
|
+
* Directory of user-authored static files copied into the build and served at the root (default
|
|
62
|
+
* `"public"`). Absent directory ⇒ nothing copied, no error.
|
|
63
|
+
*
|
|
64
|
+
* NOT the same thing as {@link publicPath}, despite the names: that is the URL prefix for
|
|
65
|
+
* content-hashed bundle chunks and never covers files an author put on disk. The collision is a
|
|
66
|
+
* real source of confusion, which is why both are spelled out here.
|
|
67
|
+
*/
|
|
68
|
+
readonly publicDir?: string | false
|
|
51
69
|
/**
|
|
52
70
|
* Prefix that opts an environment variable into the **client** bundle (Vite/Next convention; default
|
|
53
71
|
* `"PUBLIC_"`). Every var in the build environment whose name starts with this prefix is baked into
|
|
54
72
|
* the client `define` as `"process.env.NAME": JSON.stringify(value)`, so `process.env.PUBLIC_API_URL`
|
|
55
|
-
* compiles to its literal value in the browser. Vars WITHOUT the prefix are never exposed
|
|
73
|
+
* compiles to its literal value in the browser. Vars WITHOUT the prefix are never exposed - the bare
|
|
56
74
|
* `process.env` define resolves them to `undefined`, so server secrets can't leak into the client
|
|
57
75
|
* bundle. Set to `""` to disable auto-exposure entirely (no var is baked in). `options.define` still
|
|
58
76
|
* wins over an auto-exposed var (it's layered last). Sourced from `Bun.env` (falls back to
|
|
@@ -82,26 +100,209 @@ export function publicEnvDefines(
|
|
|
82
100
|
return defines
|
|
83
101
|
}
|
|
84
102
|
|
|
85
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Percent-encode one path segment exactly the way a browser encodes it into a request URL.
|
|
105
|
+
*
|
|
106
|
+
* NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw -
|
|
107
|
+
* so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives
|
|
108
|
+
* as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI`
|
|
109
|
+
* agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must
|
|
110
|
+
* be escaped explicitly here.
|
|
111
|
+
*/
|
|
112
|
+
function encodePathSegment(segment: string): string {
|
|
113
|
+
return encodeURI(segment).replace(
|
|
114
|
+
/[?#]/g,
|
|
115
|
+
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any
|
|
121
|
+
* single rule longer than 100 characters.
|
|
122
|
+
*/
|
|
123
|
+
const CF_MAX_ROUTE_RULES = 100
|
|
124
|
+
const CF_MAX_RULE_LENGTH = 100
|
|
125
|
+
|
|
126
|
+
export interface CloudflareRouteRules {
|
|
127
|
+
readonly include: readonly string[]
|
|
128
|
+
readonly exclude: readonly string[]
|
|
129
|
+
/** Public files the budget could not name, so the build can say so instead of capping silently. */
|
|
130
|
+
readonly omitted: readonly string[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What a route pattern's first URL segment says about the paths it can reach.
|
|
135
|
+
*
|
|
136
|
+
* The three cases are genuinely different and collapsing them is a fail-open bug: `/` reaches only the
|
|
137
|
+
* root and can never match below a directory, while `/:locale/about` can match below ANY directory, and
|
|
138
|
+
* both would otherwise read as "no literal first segment". `dynamic` is also where unrecognised syntax
|
|
139
|
+
* lands, deliberately - a pattern form this does not understand must never be taken as proof that
|
|
140
|
+
* nothing matches.
|
|
141
|
+
*/
|
|
142
|
+
type FirstSegment =
|
|
143
|
+
| { readonly kind: "root" }
|
|
144
|
+
| { readonly kind: "literal"; readonly value: string }
|
|
145
|
+
| { readonly kind: "dynamic" }
|
|
146
|
+
|
|
147
|
+
function firstSegmentOf(pattern: string): FirstSegment {
|
|
148
|
+
const first = pattern.split("/").find((segment) => segment !== "")
|
|
149
|
+
if (first === undefined) return { kind: "root" }
|
|
150
|
+
return /^[A-Za-z0-9._-]+$/.test(first) ? { kind: "literal", value: first } : { kind: "dynamic" }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Directories under `public/` whose every file can be replaced by one `/<dir>/*` rule.
|
|
155
|
+
*
|
|
156
|
+
* Safe only when no route can ever be served beneath that directory, because the glob does not merely
|
|
157
|
+
* describe today's files - it hands Pages every future path under the prefix, and the worker never sees
|
|
158
|
+
* them. A `public/blog/hero.png` next to a `/blog/:slug` route is the ordinary case that breaks: the
|
|
159
|
+
* glob would send `/blog/my-post` to the CDN, which has no such file, and the page 404s in production
|
|
160
|
+
* only. So a directory collapses only when every route pattern starts with a literal segment that is
|
|
161
|
+
* something else. One dynamic first segment anywhere (`/:locale/…`) disables collapsing entirely, which
|
|
162
|
+
* is correct - such a route can match under any directory name.
|
|
163
|
+
*
|
|
164
|
+
* A collapsed directory also gives up the app's own 404 page for a missing file beneath it, since the
|
|
165
|
+
* request never reaches the worker. That is the right trade for a directory that holds only static
|
|
166
|
+
* files: a missing image should fail as a fast CDN 404, not as an HTML error page.
|
|
167
|
+
*/
|
|
168
|
+
function collapsibleDirs(
|
|
169
|
+
publicFiles: readonly string[],
|
|
170
|
+
routePatterns: readonly string[],
|
|
171
|
+
): ReadonlySet<string> {
|
|
172
|
+
const reserved = new Set<string>()
|
|
173
|
+
for (const pattern of routePatterns) {
|
|
174
|
+
const first = firstSegmentOf(pattern)
|
|
175
|
+
// A dynamic first segment can match anywhere, so nothing is collapsible at all.
|
|
176
|
+
if (first.kind === "dynamic") return new Set()
|
|
177
|
+
// `root` reserves nothing: `/` cannot match a path below a directory.
|
|
178
|
+
if (first.kind === "literal") reserved.add(first.value)
|
|
179
|
+
}
|
|
180
|
+
const dirs = new Set<string>()
|
|
181
|
+
for (const file of publicFiles) {
|
|
182
|
+
const slash = file.indexOf("/", 1)
|
|
183
|
+
if (slash === -1) continue // a root-level file has no directory to collapse into
|
|
184
|
+
const dir = file.slice(1, slash)
|
|
185
|
+
if (!reserved.has(dir)) dirs.add(dir)
|
|
186
|
+
}
|
|
187
|
+
return dirs
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build the cf-pages `_routes.json` rules for a set of copied public files, within Cloudflare's budget.
|
|
192
|
+
*
|
|
193
|
+
* `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every
|
|
194
|
+
* public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images
|
|
195
|
+
* clears 100 easily, and the rejection lands at `wrangler pages deploy`, long after the build reported
|
|
196
|
+
* success.
|
|
197
|
+
*
|
|
198
|
+
* A glob is emitted only where it cannot be wrong. `/assets/*` always is: the build owns that prefix
|
|
199
|
+
* outright, since it is where the hashed bundle is written. A `public/` subdirectory is different - the
|
|
200
|
+
* name is the author's, and a `/blog/*` rule would hand Pages every future path under `/blog/`, so a
|
|
201
|
+
* `/blog/:slug` route would 404 from the CDN in production only. {@link collapsibleDirs} therefore
|
|
202
|
+
* collapses a directory only after checking it against the app's real route patterns, which is what
|
|
203
|
+
* makes the compaction safe rather than merely smaller. Everything else stays an exact path, and a name
|
|
204
|
+
* containing `*` is dropped rather than escaped, since the rule would become a wildcard.
|
|
205
|
+
*
|
|
206
|
+
* What still does not fit is dropped, which is safe because the list is only an optimization: the
|
|
207
|
+
* generated worker serves any allowlisted path it receives through the ASSETS binding, so an omitted
|
|
208
|
+
* file costs one worker invocation, not a 404.
|
|
209
|
+
*/
|
|
210
|
+
export function cloudflareRouteRules(
|
|
211
|
+
publicFiles: readonly string[],
|
|
212
|
+
/**
|
|
213
|
+
* Every route pattern the app serves. Required, not defaulted: an empty list means "this app serves
|
|
214
|
+
* no routes", which makes every directory collapsible - a defensible answer to state deliberately and
|
|
215
|
+
* a dangerous one to arrive at by forgetting the argument.
|
|
216
|
+
*/
|
|
217
|
+
routePatterns: readonly string[],
|
|
218
|
+
): CloudflareRouteRules {
|
|
219
|
+
const include = ["/*"]
|
|
220
|
+
const exclude = ["/assets/*"]
|
|
221
|
+
const omitted: string[] = []
|
|
222
|
+
|
|
223
|
+
// Collapse only where the route table proves nothing can be served beneath the directory, and only
|
|
224
|
+
// when the per-file list would not have fit anyway - a smaller `_routes.json` is worth nothing on its
|
|
225
|
+
// own, and exact paths keep the app's own 404 for a missing file under that directory.
|
|
226
|
+
const perFileFits = publicFiles.length + include.length + exclude.length <= CF_MAX_ROUTE_RULES
|
|
227
|
+
const collapsible = perFileFits ? new Set<string>() : collapsibleDirs(publicFiles, routePatterns)
|
|
228
|
+
const emitted = new Set<string>()
|
|
229
|
+
|
|
230
|
+
for (const file of publicFiles) {
|
|
231
|
+
const slash = file.indexOf("/", 1)
|
|
232
|
+
const dir = slash === -1 ? undefined : file.slice(1, slash)
|
|
233
|
+
const collapsed = dir !== undefined && collapsible.has(dir)
|
|
234
|
+
const rule = collapsed ? `/${dir}/*` : file
|
|
235
|
+
if (emitted.has(rule)) continue // already covered by a rule emitted for an earlier file
|
|
236
|
+
// A `*` in a FILE name would turn that file's own rule into a wildcard. A collapsed rule's `*` is
|
|
237
|
+
// ours and deliberate, and the file's name never appears in it, so the check applies to one case.
|
|
238
|
+
if (!collapsed && file.includes("*")) {
|
|
239
|
+
omitted.push(file)
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
if (include.length + exclude.length >= CF_MAX_ROUTE_RULES || rule.length > CF_MAX_RULE_LENGTH) {
|
|
243
|
+
omitted.push(file)
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
exclude.push(rule)
|
|
247
|
+
emitted.add(rule)
|
|
248
|
+
}
|
|
249
|
+
return { include, exclude, omitted }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The built asset map - the server reads `entry` for the client script + serves `assets`. */
|
|
253
|
+
/**
|
|
254
|
+
* Copy `from` into `to`, returning the URL paths copied (sorted).
|
|
255
|
+
*
|
|
256
|
+
* Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
|
|
257
|
+
* `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
|
|
258
|
+
* entry - a dynamic `import("bun")` there fails the browser build outright.
|
|
259
|
+
*/
|
|
260
|
+
export async function copyPublicDir(from: string, to: string): Promise<string[]> {
|
|
261
|
+
const source = resolvePath(from)
|
|
262
|
+
const sourceReal = await realpath(source)
|
|
263
|
+
const copied: string[] = []
|
|
264
|
+
for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
|
|
265
|
+
const candidate = join(source, rel)
|
|
266
|
+
const candidateStat = await lstat(candidate)
|
|
267
|
+
if (candidateStat.isSymbolicLink()) {
|
|
268
|
+
throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`)
|
|
269
|
+
}
|
|
270
|
+
if (!candidateStat.isFile()) continue
|
|
271
|
+
const candidateReal = await realpath(candidate)
|
|
272
|
+
if (candidateReal !== sourceReal && !candidateReal.startsWith(sourceReal + sep)) {
|
|
273
|
+
throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`)
|
|
274
|
+
}
|
|
275
|
+
const target = join(to, rel)
|
|
276
|
+
await mkdir(join(target, ".."), { recursive: true })
|
|
277
|
+
await cp(candidateReal, target)
|
|
278
|
+
copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`)
|
|
279
|
+
}
|
|
280
|
+
return copied.sort()
|
|
281
|
+
}
|
|
282
|
+
|
|
86
283
|
export interface BuildManifest {
|
|
87
284
|
/** URL of the client entry module (content-hashed). */
|
|
88
285
|
readonly entry: string
|
|
89
|
-
/** URLs of every emitted asset (entry + chunks)
|
|
286
|
+
/** URLs of every emitted asset (entry + chunks) - for serving + preloading. */
|
|
90
287
|
readonly assets: readonly string[]
|
|
91
|
-
/** `routeId → [layout chunk URLs…, own chunk URL]`
|
|
288
|
+
/** `routeId → [layout chunk URLs…, own chunk URL]` - the chunks a route needs, for `createWebApp`'s
|
|
92
289
|
* `routePreload` (`<link rel="modulepreload">` the matched route alongside the entry). Each route +
|
|
93
290
|
* layout is also a build entrypoint, so it gets a named chunk the bootstrap's lazy import dedupes to. */
|
|
94
291
|
readonly routes: Readonly<Record<string, readonly string[]>>
|
|
95
|
-
/**
|
|
292
|
+
/** URL paths copied from `publicDir`, sorted. Lets the server entry serve them without scanning a
|
|
293
|
+
* directory per request, and lets an adapter that needs a file list (CDN upload, platform static
|
|
294
|
+
* assets) consume one. Omitted when there is no `public/`. */
|
|
295
|
+
readonly publicFiles?: readonly string[]
|
|
296
|
+
/** The app's bundled, content-hashed stylesheet(s) - the bootstrap's **aggregate** CSS (every
|
|
96
297
|
* `import './x.css'` reachable from the app). The complete stylesheet regardless of which file
|
|
97
298
|
* imported the CSS; the always-safe fallback `createWebApp` links when a route has no per-route entry
|
|
98
299
|
* in {@link routeStyles}. Omitted when the app imports no CSS. */
|
|
99
300
|
readonly css?: readonly string[]
|
|
100
|
-
/** `routeId → [chain CSS URLs]`
|
|
301
|
+
/** `routeId → [chain CSS URLs]` - only the stylesheets the matched route's layout chain + own file
|
|
101
302
|
* actually use (Bun emits a per-entrypoint CSS bundle per route/layout, with shared-component CSS
|
|
102
303
|
* inlined into each consumer). `createWebApp` links these instead of the aggregate, so a page ships
|
|
103
304
|
* only its own CSS. A route is omitted (→ aggregate fallback) when its `[name]` collides with another
|
|
104
|
-
* route's basename (ambiguous CSS↔route) or the build emitted orphan shared-chunk CSS
|
|
305
|
+
* route's basename (ambiguous CSS↔route) or the build emitted orphan shared-chunk CSS - correctness
|
|
105
306
|
* over minimality. Absent entirely when the app imports no CSS. */
|
|
106
307
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>>
|
|
107
308
|
}
|
|
@@ -154,10 +355,10 @@ export interface NodeBuiltinFinding {
|
|
|
154
355
|
* BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
|
|
155
356
|
* bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
|
|
156
357
|
* starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
|
|
157
|
-
* actually wrote `import`
|
|
358
|
+
* actually wrote `import` - the actionable root, not an arbitrary internal module. Traversal crosses
|
|
158
359
|
* only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
|
|
159
360
|
* whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
|
|
160
|
-
* is reachable by construction). Pure
|
|
361
|
+
* is reachable by construction). Pure - operates on the graph, never the emitted text.
|
|
161
362
|
*/
|
|
162
363
|
function shortestBuiltinChain(
|
|
163
364
|
inputs: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>,
|
|
@@ -181,7 +382,7 @@ function shortestBuiltinChain(
|
|
|
181
382
|
if (nodeBuiltinOf(im) === builtin) return [...chain, builtin]
|
|
182
383
|
const target = im.path
|
|
183
384
|
// Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
|
|
184
|
-
// been visited
|
|
385
|
+
// been visited - so the polyfill subtree can't lengthen the path and there are no cycles.
|
|
185
386
|
if (
|
|
186
387
|
target === undefined ||
|
|
187
388
|
target.startsWith("node:") ||
|
|
@@ -192,47 +393,47 @@ function shortestBuiltinChain(
|
|
|
192
393
|
}
|
|
193
394
|
seen.add(target)
|
|
194
395
|
// Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
|
|
195
|
-
// the resolved path
|
|
396
|
+
// the resolved path - that's what the dev recognizes in their source, not the resolved path.
|
|
196
397
|
next.push({ node: target, chain: [...chain, im.original ?? target] })
|
|
197
398
|
}
|
|
198
399
|
}
|
|
199
400
|
frontier = next
|
|
200
401
|
}
|
|
201
|
-
return [builtin] // unreachable from any entry
|
|
402
|
+
return [builtin] // unreachable from any entry - degrade to just the builtin (shouldn't happen)
|
|
202
403
|
}
|
|
203
404
|
|
|
204
405
|
/**
|
|
205
406
|
* Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
|
|
206
407
|
* chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
|
|
207
408
|
* the report is precise AND actionable:
|
|
208
|
-
* 1. **What the user wrote**
|
|
409
|
+
* 1. **What the user wrote** - only builtins imported by a NON-`node:` input count, so Bun's own
|
|
209
410
|
* polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
|
|
210
|
-
* 2. **Where it landed**
|
|
411
|
+
* 2. **Where it landed** - the chunk is read from the per-output `inputs`, so the error names the
|
|
211
412
|
* emitted file to look at.
|
|
212
|
-
* 3. **How it got there**
|
|
413
|
+
* 3. **How it got there** - the shortest import chain from a user entry to the builtin
|
|
213
414
|
* (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
|
|
214
415
|
* leaving the dev to grep the dependency tree (the DX gap this closes).
|
|
215
416
|
* Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
|
|
216
417
|
* literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
|
|
217
418
|
*/
|
|
218
419
|
export function detectNodeBuiltinsInClient(
|
|
219
|
-
|
|
420
|
+
graph: ClientModuleGraph,
|
|
220
421
|
): ReadonlyArray<NodeBuiltinFinding> {
|
|
221
|
-
const inputs =
|
|
222
|
-
// (1) The builtins a user (non-polyfill) module imports directly
|
|
422
|
+
const inputs = graph.modules
|
|
423
|
+
// (1) The builtins a user (non-polyfill) module imports directly - the ones the author controls.
|
|
223
424
|
const userImported = new Set<string>()
|
|
224
425
|
for (const [inputKey, input] of Object.entries(inputs)) {
|
|
225
|
-
if (inputKey.startsWith("node:")) continue // a polyfill importing another builtin
|
|
426
|
+
if (inputKey.startsWith("node:")) continue // a polyfill importing another builtin - not the cause
|
|
226
427
|
for (const im of input.imports ?? []) {
|
|
227
428
|
const builtin = nodeBuiltinOf(im)
|
|
228
429
|
if (builtin !== undefined) userImported.add(builtin)
|
|
229
430
|
}
|
|
230
431
|
}
|
|
231
|
-
// The entry inputs (route/user entrypoints)
|
|
432
|
+
// The entry inputs (route/user entrypoints) - the chain BFS starts here so the reported path begins
|
|
232
433
|
// at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
|
|
233
434
|
const entryInputs = [
|
|
234
435
|
...new Set(
|
|
235
|
-
Object.values(
|
|
436
|
+
Object.values(graph.chunks)
|
|
236
437
|
.map((o) => o.entryPoint)
|
|
237
438
|
.filter((e): e is string => e !== undefined && !e.startsWith("node:")),
|
|
238
439
|
),
|
|
@@ -248,8 +449,8 @@ export function detectNodeBuiltinsInClient(
|
|
|
248
449
|
}
|
|
249
450
|
// (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
|
|
250
451
|
const findings = new Map<string, NodeBuiltinFinding>()
|
|
251
|
-
for (const [outPath, out] of Object.entries(
|
|
252
|
-
for (const inputKey of
|
|
452
|
+
for (const [outPath, out] of Object.entries(graph.chunks)) {
|
|
453
|
+
for (const inputKey of out.modules) {
|
|
253
454
|
if (userImported.has(inputKey)) {
|
|
254
455
|
const chunk = basename(outPath)
|
|
255
456
|
findings.set(`${inputKey}\0${chunk}`, {
|
|
@@ -270,8 +471,8 @@ export function detectNodeBuiltinsInClient(
|
|
|
270
471
|
// node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
|
|
271
472
|
// a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
|
|
272
473
|
// side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
|
|
273
|
-
// the marker is an empty no-op; the CLIENT build detects
|
|
274
|
-
// node-builtin guard walks
|
|
474
|
+
// the marker is an empty no-op; the CLIENT build detects - via the SAME Bun metafile graph the
|
|
475
|
+
// node-builtin guard walks - any module that imports the marker AND lands in a client chunk, and fails
|
|
275
476
|
// the build with the import chain. Graph-based (never the emitted text), so it survives minification.
|
|
276
477
|
// ---------------------------------------------------------------------------------------------------
|
|
277
478
|
|
|
@@ -282,7 +483,7 @@ export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only"
|
|
|
282
483
|
|
|
283
484
|
/** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
|
|
284
485
|
* specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
|
|
285
|
-
* (no `original`)
|
|
486
|
+
* (no `original`) - or a workspace-relative resolution - is still recognised. */
|
|
286
487
|
const isServerOnlyMarkerImport = (im: BunMetafileImport): boolean => {
|
|
287
488
|
if (im.original === SERVER_ONLY_MARKER) return true
|
|
288
489
|
// Defensive fallback: an edge that lost its `original` but resolved to the marker module file. The
|
|
@@ -310,8 +511,8 @@ export interface ServerOnlyFinding {
|
|
|
310
511
|
* "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
|
|
311
512
|
* every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
|
|
312
513
|
* only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
|
|
313
|
-
* contains the marker (the marked module
|
|
314
|
-
* specifier the previous hop used to reach it. Pure
|
|
514
|
+
* contains the marker (the marked module - the actionable tail), labelling that node by the as-written
|
|
515
|
+
* specifier the previous hop used to reach it. Pure - operates on the graph, never the emitted text.
|
|
315
516
|
*/
|
|
316
517
|
function shortestServerOnlyChain(
|
|
317
518
|
inputs: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>,
|
|
@@ -322,7 +523,7 @@ function shortestServerOnlyChain(
|
|
|
322
523
|
// The label for the marked module's tail: its as-written specifier (filled when we cross the edge
|
|
323
524
|
// that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
|
|
324
525
|
const tail = (label: string): string => `${label} (marked server-only)`
|
|
325
|
-
// An entry that is ITSELF the marked module
|
|
526
|
+
// An entry that is ITSELF the marked module - the chain is just that one node.
|
|
326
527
|
if (entryInputs.includes(markedModule)) return [tail(markedModule)]
|
|
327
528
|
const seen = new Set<string>(entryInputs)
|
|
328
529
|
let frontier: Array<{ node: string; chain: string[] }> = entryInputs.map((node) => ({
|
|
@@ -333,7 +534,7 @@ function shortestServerOnlyChain(
|
|
|
333
534
|
const next: Array<{ node: string; chain: string[] }> = []
|
|
334
535
|
for (const { node, chain } of frontier) {
|
|
335
536
|
for (const im of inputs[node]?.imports ?? []) {
|
|
336
|
-
// Resolve the edge's `path` to the matching INPUT-GRAPH KEY
|
|
537
|
+
// Resolve the edge's `path` to the matching INPUT-GRAPH KEY - in a real build the edge `path`
|
|
337
538
|
// is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
|
|
338
539
|
// multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
|
|
339
540
|
const target = resolveTarget(im)
|
|
@@ -380,16 +581,16 @@ function inputKeyResolver(
|
|
|
380
581
|
* Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
|
|
381
582
|
* `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
|
|
382
583
|
* list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
|
|
383
|
-
* graph facts
|
|
584
|
+
* graph facts - which inputs import the marker (the "marked" modules), which chunk each landed in (the
|
|
384
585
|
* per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
|
|
385
|
-
* (which imports nothing) is excluded
|
|
586
|
+
* (which imports nothing) is excluded - only the modules that *opt in* are flagged. Pure + exported for
|
|
386
587
|
* unit testing. Empty ⇒ clean.
|
|
387
588
|
*/
|
|
388
589
|
export function detectServerOnlyInClient(
|
|
389
|
-
|
|
590
|
+
graph: ClientModuleGraph,
|
|
390
591
|
): ReadonlyArray<ServerOnlyFinding> {
|
|
391
|
-
const inputs =
|
|
392
|
-
// (1) The modules that import the marker
|
|
592
|
+
const inputs = graph.modules
|
|
593
|
+
// (1) The modules that import the marker - the ones the author opted into the guard. The marker
|
|
393
594
|
// module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
|
|
394
595
|
const marked = new Set<string>()
|
|
395
596
|
for (const [inputKey, input] of Object.entries(inputs)) {
|
|
@@ -397,11 +598,11 @@ export function detectServerOnlyInClient(
|
|
|
397
598
|
if ((input.imports ?? []).some(isServerOnlyMarkerImport)) marked.add(inputKey)
|
|
398
599
|
}
|
|
399
600
|
if (marked.size === 0) return []
|
|
400
|
-
// The entry inputs
|
|
601
|
+
// The entry inputs - the chain BFS starts here, so the reported path begins at the file the dev
|
|
401
602
|
// wrote an `import` in. Same derivation as the node-builtin guard.
|
|
402
603
|
const entryInputs = [
|
|
403
604
|
...new Set(
|
|
404
|
-
Object.values(
|
|
605
|
+
Object.values(graph.chunks)
|
|
405
606
|
.map((o) => o.entryPoint)
|
|
406
607
|
.filter((e): e is string => e !== undefined && !e.startsWith("node:")),
|
|
407
608
|
),
|
|
@@ -417,8 +618,8 @@ export function detectServerOnlyInClient(
|
|
|
417
618
|
}
|
|
418
619
|
// (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
|
|
419
620
|
const findings = new Map<string, ServerOnlyFinding>()
|
|
420
|
-
for (const [outPath, out] of Object.entries(
|
|
421
|
-
for (const inputKey of
|
|
621
|
+
for (const [outPath, out] of Object.entries(graph.chunks)) {
|
|
622
|
+
for (const inputKey of out.modules) {
|
|
422
623
|
if (marked.has(inputKey)) {
|
|
423
624
|
const chunk = basename(outPath)
|
|
424
625
|
findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) })
|
|
@@ -433,10 +634,52 @@ export function detectServerOnlyInClient(
|
|
|
433
634
|
}
|
|
434
635
|
|
|
435
636
|
/** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
|
|
436
|
-
* Excluded from the "marked" set
|
|
637
|
+
* Excluded from the "marked" set - the marker is the import target, not an opt-in module. */
|
|
437
638
|
const isServerOnlyMarkerModule = (inputKey: string): boolean =>
|
|
438
639
|
/(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey)
|
|
439
640
|
|
|
641
|
+
// ---------------------------------------------------------------------------------------------------
|
|
642
|
+
// Guard MESSAGES - one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
|
|
643
|
+
// leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
|
|
644
|
+
// bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
|
|
645
|
+
// error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
|
|
646
|
+
// ---------------------------------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
/** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
|
|
649
|
+
export function formatNodeBuiltinLeak(
|
|
650
|
+
findings: ReadonlyArray<NodeBuiltinFinding>,
|
|
651
|
+
): string | undefined {
|
|
652
|
+
if (findings.length === 0) return undefined
|
|
653
|
+
const lines = findings.map((f) =>
|
|
654
|
+
f.chain.length > 1
|
|
655
|
+
? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
656
|
+
: ` - ${f.builtin} reached the client bundle via ${f.chunk}`,
|
|
657
|
+
)
|
|
658
|
+
return (
|
|
659
|
+
`[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` +
|
|
660
|
+
`(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
|
|
661
|
+
`top level):\n${lines.join("\n")}`
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
|
|
666
|
+
export function formatServerOnlyLeak(
|
|
667
|
+
findings: ReadonlyArray<ServerOnlyFinding>,
|
|
668
|
+
): string | undefined {
|
|
669
|
+
if (findings.length === 0) return undefined
|
|
670
|
+
const lines = findings.map((f) =>
|
|
671
|
+
f.chain.length > 1
|
|
672
|
+
? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
673
|
+
: ` - server-only module reached the client bundle via ${f.chunk}`,
|
|
674
|
+
)
|
|
675
|
+
return (
|
|
676
|
+
`[nifra/web] server-only module(s) in the client bundle - a module marked ` +
|
|
677
|
+
`\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
|
|
678
|
+
`(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
|
|
679
|
+
`to the client:\n${lines.join("\n")}`
|
|
680
|
+
)
|
|
681
|
+
}
|
|
682
|
+
|
|
440
683
|
const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1)
|
|
441
684
|
/** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
|
|
442
685
|
const entryName = (file: string): string => {
|
|
@@ -461,7 +704,7 @@ export interface ChunkSize {
|
|
|
461
704
|
readonly gzip: number
|
|
462
705
|
}
|
|
463
706
|
|
|
464
|
-
/** A whole build's size report
|
|
707
|
+
/** A whole build's size report - every chunk (largest first) + the totals. */
|
|
465
708
|
export interface SizeReport {
|
|
466
709
|
/** Per-chunk sizes, sorted biggest gzip first (the regression you want to see at the top). */
|
|
467
710
|
readonly chunks: readonly ChunkSize[]
|
|
@@ -473,7 +716,7 @@ export interface SizeReport {
|
|
|
473
716
|
|
|
474
717
|
/**
|
|
475
718
|
* Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
|
|
476
|
-
* by raw bytes, then name for stable output) and sum the totals. Pure
|
|
719
|
+
* by raw bytes, then name for stable output) and sum the totals. Pure - the measurement (reading the
|
|
477
720
|
* file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
|
|
478
721
|
*/
|
|
479
722
|
export function aggregateSizeReport(chunks: readonly ChunkSize[]): SizeReport {
|
|
@@ -497,7 +740,7 @@ export function formatBytes(bytes: number): string {
|
|
|
497
740
|
}
|
|
498
741
|
|
|
499
742
|
/**
|
|
500
|
-
* Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row
|
|
743
|
+
* Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row - the text
|
|
501
744
|
* `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
|
|
502
745
|
*/
|
|
503
746
|
export function renderSizeReport(report: SizeReport): string {
|
|
@@ -526,18 +769,18 @@ export function renderSizeReport(report: SizeReport): string {
|
|
|
526
769
|
// ---------------------------------------------------------------------------------------------------
|
|
527
770
|
// Server-manifest drift detection (#7). `server-manifest.ts` is a committed generated file: it bakes
|
|
528
771
|
// the route list + the client-entry hash for a disk-less worker (`generateServerManifest`). If `routes/`
|
|
529
|
-
// changes but the manifest isn't regenerated, the worker serves a stale route table
|
|
772
|
+
// changes but the manifest isn't regenerated, the worker serves a stale route table - a silent edge
|
|
530
773
|
// break. These pure helpers diff the COMMITTED manifest source against the freshly-discovered routes so
|
|
531
774
|
// `nifra check` (and `buildServer`) can fail with a named, actionable error before the drift ships.
|
|
532
775
|
// ---------------------------------------------------------------------------------------------------
|
|
533
776
|
|
|
534
777
|
/** A drift finding between a committed server-manifest and the live `routes/` tree. */
|
|
535
778
|
export interface ManifestDrift {
|
|
536
|
-
/** Route files present in `routes/` but ABSENT from the committed manifest (the manifest is stale
|
|
779
|
+
/** Route files present in `routes/` but ABSENT from the committed manifest (the manifest is stale -
|
|
537
780
|
* the new route won't be served by the worker). */
|
|
538
781
|
readonly missing: readonly string[]
|
|
539
782
|
/** Route files the committed manifest imports that no longer exist in `routes/` (a deleted/renamed
|
|
540
|
-
* route still wired into the worker
|
|
783
|
+
* route still wired into the worker - a build/runtime break). */
|
|
541
784
|
readonly extra: readonly string[]
|
|
542
785
|
}
|
|
543
786
|
|
|
@@ -553,7 +796,7 @@ const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["
|
|
|
553
796
|
* `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
|
|
554
797
|
* specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
|
|
555
798
|
* default `resolve` emits). Only import specifiers under that prefix are route files; the
|
|
556
|
-
* `@nifrajs/web` import (and any other bare specifier) is ignored. Pure
|
|
799
|
+
* `@nifrajs/web` import (and any other bare specifier) is ignored. Pure - operates on source text.
|
|
557
800
|
*/
|
|
558
801
|
export function parseManifestRouteFiles(source: string, routesPrefix = "./routes/"): string[] {
|
|
559
802
|
const files = new Set<string>()
|
|
@@ -635,8 +878,8 @@ export function resyncServerManifestSource(
|
|
|
635
878
|
|
|
636
879
|
/**
|
|
637
880
|
* Diff the route files a committed server-manifest imports against the files freshly discovered in
|
|
638
|
-
* `routes/`. Returns the `missing` (in routes/, not in manifest
|
|
639
|
-
* manifest, gone from routes/
|
|
881
|
+
* `routes/`. Returns the `missing` (in routes/, not in manifest - stale manifest) and `extra` (in
|
|
882
|
+
* manifest, gone from routes/ - dangling import) sets. Empty arrays ⇒ in sync. Pure - the caller
|
|
640
883
|
* supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
|
|
641
884
|
* fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
|
|
642
885
|
*/
|
|
@@ -667,16 +910,16 @@ export function formatManifestDrift(
|
|
|
667
910
|
): string | undefined {
|
|
668
911
|
if (isManifestInSync(drift)) return undefined
|
|
669
912
|
const lines: string[] = [
|
|
670
|
-
`[nifra/web] server-manifest drift
|
|
913
|
+
`[nifra/web] server-manifest drift - \`${manifestPath}\` is out of sync with routes/.`,
|
|
671
914
|
]
|
|
672
915
|
if (drift.missing.length > 0) {
|
|
673
916
|
lines.push(
|
|
674
|
-
` Missing (in routes/, not in the manifest
|
|
917
|
+
` Missing (in routes/, not in the manifest - these routes won't be served): ${drift.missing.join(", ")}`,
|
|
675
918
|
)
|
|
676
919
|
}
|
|
677
920
|
if (drift.extra.length > 0) {
|
|
678
921
|
lines.push(
|
|
679
|
-
` Extra (imported by the manifest, gone from routes/
|
|
922
|
+
` Extra (imported by the manifest, gone from routes/ - a dangling import): ${drift.extra.join(", ")}`,
|
|
680
923
|
)
|
|
681
924
|
}
|
|
682
925
|
lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.")
|
|
@@ -685,7 +928,7 @@ export function formatManifestDrift(
|
|
|
685
928
|
|
|
686
929
|
/**
|
|
687
930
|
* Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
|
|
688
|
-
* `outDir` and returns the manifest. Throws (with the bundler logs) on build failure
|
|
931
|
+
* `outDir` and returns the manifest. Throws (with the bundler logs) on build failure - never
|
|
689
932
|
* silently ships a broken bundle.
|
|
690
933
|
*/
|
|
691
934
|
export async function buildClient(options: BuildClientOptions): Promise<BuildManifest> {
|
|
@@ -701,7 +944,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
701
944
|
// PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
|
|
702
945
|
// falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
|
|
703
946
|
// in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
|
|
704
|
-
// layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck
|
|
947
|
+
// layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck - guard it.
|
|
705
948
|
const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
|
|
706
949
|
const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv)
|
|
707
950
|
// Keep the generated source beside the project, not inside `outDir`: module resolution starts at the
|
|
@@ -716,7 +959,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
716
959
|
`globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`,
|
|
717
960
|
)
|
|
718
961
|
|
|
719
|
-
// Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints
|
|
962
|
+
// Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
|
|
720
963
|
// named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
|
|
721
964
|
// can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same
|
|
722
965
|
// specifier the bootstrap imports, so the entrypoint + lazy import are the same module (dedup).
|
|
@@ -728,7 +971,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
728
971
|
]),
|
|
729
972
|
].sort()
|
|
730
973
|
|
|
731
|
-
// `metafile: true` asks Bun for the input/output graph
|
|
974
|
+
// `metafile: true` asks Bun for the input/output graph - specifically `outputs[js].entryPoint`
|
|
732
975
|
// (the source file) + `outputs[js].cssBundle` (that entry's emitted stylesheet). It's the robust
|
|
733
976
|
// entry→CSS link for per-route splitting: keyed by the unique source path, so it survives
|
|
734
977
|
// same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in
|
|
@@ -745,7 +988,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
745
988
|
splitting: true, // one chunk per lazily-imported route; shared deps deduped into shared chunks
|
|
746
989
|
// `import "./x.css"` in a route/component → bundled, minified, content-hashed `.css` asset (Bun
|
|
747
990
|
// strips the import from the JS; CSS bundling is on by default since Bun 1.2). Mapped to routes
|
|
748
|
-
// below
|
|
991
|
+
// below - both the aggregate and per-route - via the metafile, for `<link>` injection.
|
|
749
992
|
...buildExtras,
|
|
750
993
|
minify: options.minify ?? true,
|
|
751
994
|
plugins: [
|
|
@@ -753,13 +996,14 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
753
996
|
preactDedupePlugin(routesDir),
|
|
754
997
|
svelteDedupePlugin(routesDir),
|
|
755
998
|
serverOnlyEmptyPlugin(),
|
|
999
|
+
serverFnStubPlugin(),
|
|
756
1000
|
...(options.plugins ?? []),
|
|
757
1001
|
],
|
|
758
1002
|
...(options.conditions ? { conditions: [...options.conditions] } : {}),
|
|
759
1003
|
// Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
|
|
760
1004
|
// hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
|
|
761
1005
|
// the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
|
|
762
|
-
// other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback
|
|
1006
|
+
// other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback - so secrets
|
|
763
1007
|
// never leak). Callers can override any of these via `options.define` (layered last).
|
|
764
1008
|
define: {
|
|
765
1009
|
"process.env": "({})",
|
|
@@ -780,49 +1024,25 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
780
1024
|
|
|
781
1025
|
// #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
|
|
782
1026
|
// a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
|
|
783
|
-
// instead
|
|
1027
|
+
// instead - caught at build time, not by a confused user in the browser. Graph-based (the metafile's
|
|
784
1028
|
// per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
|
|
785
1029
|
// minification. Only the client build runs this; the server build's `node:` imports are legitimate.
|
|
786
1030
|
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
|
-
}
|
|
1031
|
+
const clientGraph = fromBunMetafile(clientMeta)
|
|
1032
|
+
// #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a
|
|
1033
|
+
// browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the
|
|
1034
|
+
// SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same.
|
|
1035
|
+
const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph))
|
|
1036
|
+
if (nodeBuiltinLeak !== undefined) throw new Error(nodeBuiltinLeak)
|
|
1037
|
+
|
|
1038
|
+
// §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk - catches
|
|
1039
|
+
// pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
|
|
1040
|
+
// `*.server`, so neither other guard fires. Same shared formatter as above.
|
|
1041
|
+
const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph))
|
|
1042
|
+
if (serverOnlyLeak !== undefined) throw new Error(serverOnlyLeak)
|
|
823
1043
|
|
|
824
1044
|
// Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
|
|
825
|
-
// rewrite the references
|
|
1045
|
+
// rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
|
|
826
1046
|
const renamed = sanitizeOutputNames(result.outputs)
|
|
827
1047
|
const toUrl = (path: string): string =>
|
|
828
1048
|
`${publicPath}${renamed.get(basename(path)) ?? basename(path)}`
|
|
@@ -839,7 +1059,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
839
1059
|
}
|
|
840
1060
|
const fileToChunk = new Map<string, string>()
|
|
841
1061
|
routeFiles.forEach((file, i) => {
|
|
842
|
-
const out = entryPoints[i + 1] // in range
|
|
1062
|
+
const out = entryPoints[i + 1] // in range - length checked above
|
|
843
1063
|
if (out !== undefined) fileToChunk.set(file, toUrl(out.path))
|
|
844
1064
|
})
|
|
845
1065
|
const chunksFor = (chainFiles: readonly string[]): string[] =>
|
|
@@ -853,9 +1073,9 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
853
1073
|
}
|
|
854
1074
|
if (routeManifest.notFound) routes._404 = chunksFor([routeManifest.notFound.file])
|
|
855
1075
|
|
|
856
|
-
// CSS
|
|
1076
|
+
// CSS - aggregate: an `import "./x.css"` anywhere → a content-hashed `.css` asset (Bun strips the
|
|
857
1077
|
// import from the JS). The bootstrap lazily imports every route, so its **aggregate** stylesheet is
|
|
858
|
-
// the whole app's CSS
|
|
1078
|
+
// the whole app's CSS - the always-safe fallback `createWebApp` links when a route has no per-route
|
|
859
1079
|
// entry below. Fallback to all CSS assets if Bun emitted no distinct aggregate.
|
|
860
1080
|
const bootstrapName = entryName(entryFile) // `_nifra-entry`
|
|
861
1081
|
const cssNameOf = (path: string): string => {
|
|
@@ -869,12 +1089,12 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
869
1089
|
const css: readonly string[] =
|
|
870
1090
|
aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path))
|
|
871
1091
|
|
|
872
|
-
// CSS
|
|
873
|
-
// `cssBundle`
|
|
1092
|
+
// CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its
|
|
1093
|
+
// `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
|
|
874
1094
|
// consumer; verified). Keyed by the metafile's unique source `entryPoint`, so it survives
|
|
875
1095
|
// same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
|
|
876
1096
|
// links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
|
|
877
|
-
// Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle
|
|
1097
|
+
// Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle - never silently incomplete.
|
|
878
1098
|
const cwd = process.cwd()
|
|
879
1099
|
const cssByEntry = new Map<string, string>()
|
|
880
1100
|
for (const out of Object.values(clientMeta?.outputs ?? {})) {
|
|
@@ -899,10 +1119,17 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
899
1119
|
if (routeManifest.notFound) routeStyles._404 = stylesFor([routeManifest.notFound.file])
|
|
900
1120
|
}
|
|
901
1121
|
|
|
1122
|
+
// Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
|
|
1123
|
+
// apps have none) and must not fail the build.
|
|
1124
|
+
const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
|
|
1125
|
+
const publicFiles =
|
|
1126
|
+
publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
|
|
1127
|
+
|
|
902
1128
|
const manifest: BuildManifest = {
|
|
903
1129
|
entry: toUrl(bootstrap.path),
|
|
904
1130
|
assets: result.outputs.map((o) => toUrl(o.path)),
|
|
905
1131
|
routes,
|
|
1132
|
+
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
906
1133
|
...(css.length > 0 ? { css } : {}),
|
|
907
1134
|
...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
|
|
908
1135
|
}
|
|
@@ -913,30 +1140,30 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
913
1140
|
export interface BuildServerOptions {
|
|
914
1141
|
/** The `routes/` directory to discover (absolute path). */
|
|
915
1142
|
readonly routesDir: string
|
|
916
|
-
/** The worker entry module (absolute path)
|
|
1143
|
+
/** The worker entry module (absolute path) - your `worker.ts`. It imports `{ manifest, clientEntry }`
|
|
917
1144
|
* from the generated `./server-manifest`, builds `createWebApp`, and `export default toFetchHandler(app)`. */
|
|
918
1145
|
readonly serverEntry: string
|
|
919
1146
|
/** Output directory for the bundled worker (absolute path). */
|
|
920
1147
|
readonly outDir: string
|
|
921
|
-
/** The content-hashed client entry URL (from `buildClient`'s manifest)
|
|
1148
|
+
/** The content-hashed client entry URL (from `buildClient`'s manifest) - **baked** into the generated
|
|
922
1149
|
* server manifest, since a disk-less worker can't read `manifest.json` at runtime. */
|
|
923
1150
|
readonly clientEntry: string
|
|
924
|
-
/** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`)
|
|
1151
|
+
/** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked into the generated
|
|
925
1152
|
* manifest so the server entry hands them to `createWebApp` (→ `<link rel="stylesheet">`). Omit ⇒ no CSS
|
|
926
1153
|
* link (the built SSR page would otherwise render unstyled). */
|
|
927
1154
|
readonly styles?: readonly string[] | undefined
|
|
928
|
-
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`)
|
|
1155
|
+
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked alongside `styles`. */
|
|
929
1156
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
|
|
930
1157
|
/** Route/layout file → import specifier in the generated manifest (default: a relative path from the
|
|
931
|
-
* manifest's location
|
|
1158
|
+
* manifest's location - written next to `serverEntry` - to `routesDir`). */
|
|
932
1159
|
readonly resolve?: (file: string) => string
|
|
933
1160
|
/** Filename for the generated server-manifest module, written next to `serverEntry` (default
|
|
934
1161
|
* `"server-manifest.ts"`); your `serverEntry` imports it as `./server-manifest`. */
|
|
935
1162
|
readonly manifestFile?: string
|
|
936
|
-
/** Adapter build plugins (e.g. `solidBunPlugin("ssr")`
|
|
1163
|
+
/** Adapter build plugins (e.g. `solidBunPlugin("ssr")` - Solid routes need their SSR transform at
|
|
937
1164
|
* build time; React's JSX is Bun-native and needs none). */
|
|
938
1165
|
readonly plugins?: readonly BunPlugin[]
|
|
939
|
-
/** `Bun.build` resolution conditions (default `["workerd", "edge-light", "browser"]`)
|
|
1166
|
+
/** `Bun.build` resolution conditions (default `["workerd", "edge-light", "browser"]`) - selects each
|
|
940
1167
|
* dependency's edge build. */
|
|
941
1168
|
readonly conditions?: readonly string[]
|
|
942
1169
|
/** Compile-time replacements (default `{ "process.env.NODE_ENV": '"production"' }` → production
|
|
@@ -944,7 +1171,7 @@ export interface BuildServerOptions {
|
|
|
944
1171
|
readonly define?: Readonly<Record<string, string>>
|
|
945
1172
|
/** Minify the output (default `true`). */
|
|
946
1173
|
readonly minify?: boolean
|
|
947
|
-
/** `Bun.build` target (default `"browser"`
|
|
1174
|
+
/** `Bun.build` target (default `"browser"` - the right shape for edge runtimes: Cloudflare Workers,
|
|
948
1175
|
* Vercel Edge, Deno, Deno Deploy). Use `"node"` for a `@nifrajs/node` server (so `node:*` built-ins
|
|
949
1176
|
* stay external), or `"bun"` for a Bun server. The default `conditions` + the edge resolve shims
|
|
950
1177
|
* only apply to the `"browser"` target; `"node"`/`"bun"` resolve their own renderer builds via the
|
|
@@ -953,16 +1180,16 @@ export interface BuildServerOptions {
|
|
|
953
1180
|
/** **Lazy/code-split routes** (default `false`): emit `() => import(route)` loaders + bundle with
|
|
954
1181
|
* `splitting`, so each route is its own chunk loaded on first request (smaller cold-start parse)
|
|
955
1182
|
* instead of all parsed at boot. The output becomes the worker entry **+ chunk files** in `outDir`
|
|
956
|
-
*
|
|
1183
|
+
* - on Cloudflare, ship them with wrangler's `no_bundle` + `find_additional_modules` + an ESModule
|
|
957
1184
|
* `rule` (Node/Deno import the chunks natively). Eager (one self-contained file) stays the default. */
|
|
958
1185
|
readonly lazy?: boolean
|
|
959
1186
|
}
|
|
960
1187
|
|
|
961
|
-
/** The built worker bundle
|
|
1188
|
+
/** The built worker bundle - point your `wrangler.toml`'s `main` at `worker`. */
|
|
962
1189
|
export interface ServerBuild {
|
|
963
1190
|
/** Path to the bundled, self-contained worker entry. */
|
|
964
1191
|
readonly worker: string
|
|
965
|
-
/** Paths of every emitted output (entry + any code-split chunks)
|
|
1192
|
+
/** Paths of every emitted output (entry + any code-split chunks) - what to ship to the platform. */
|
|
966
1193
|
readonly outputs: readonly string[]
|
|
967
1194
|
}
|
|
968
1195
|
|
|
@@ -971,7 +1198,7 @@ export interface ServerBuild {
|
|
|
971
1198
|
* on workerd, and `Bun.build` always applies the `bun` condition (it wins over `workerd`/`edge-light`),
|
|
972
1199
|
* so conditions alone can't select the edge build. This shim pins `react-dom/server` to its edge build
|
|
973
1200
|
* (`server.edge.js`, which exports `renderToReadableStream`). A no-op when nothing imports react-dom
|
|
974
|
-
* (e.g. a Solid worker)
|
|
1201
|
+
* (e.g. a Solid worker) - the resolver only runs on a match.
|
|
975
1202
|
*/
|
|
976
1203
|
const reactDomEdgePlugin = (from: string): BunPlugin => ({
|
|
977
1204
|
name: "nifra-react-dom-edge",
|
|
@@ -984,11 +1211,11 @@ const reactDomEdgePlugin = (from: string): BunPlugin => ({
|
|
|
984
1211
|
|
|
985
1212
|
/**
|
|
986
1213
|
* Dedupe React to a single copy. A `file:`-linked package can ship its OWN `react` under its own
|
|
987
|
-
* node_modules, so the bundle ends up with two React cores
|
|
1214
|
+
* node_modules, so the bundle ends up with two React cores - each with its own hook dispatcher - and SSR
|
|
988
1215
|
* throws the cryptic `null is not an object (evaluating '…H.useState')` (the second renderer's dispatcher
|
|
989
1216
|
* is null). This was the #1 time-sink in app builds. Pinning `react` + its JSX runtimes to ONE resolved
|
|
990
1217
|
* copy (the app's, from `from`) guarantees a single dispatcher; `react-dom`, which imports `react`, then
|
|
991
|
-
* shares it
|
|
1218
|
+
* shares it - so the class can't occur rather than needing a named error. No-op when React isn't used
|
|
992
1219
|
* (an unresolvable spec is skipped; the resolver only fires on an exact match). React core is
|
|
993
1220
|
* condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
|
|
994
1221
|
* react-dom's build.
|
|
@@ -1002,7 +1229,7 @@ export const reactDedupePlugin = (from: string): BunPlugin => ({
|
|
|
1002
1229
|
try {
|
|
1003
1230
|
resolved = Bun.resolveSync(spec, from)
|
|
1004
1231
|
} catch {
|
|
1005
|
-
continue // React (or this subpath) isn't resolvable here
|
|
1232
|
+
continue // React (or this subpath) isn't resolvable here - nothing to dedupe
|
|
1006
1233
|
}
|
|
1007
1234
|
const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")
|
|
1008
1235
|
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }))
|
|
@@ -1011,14 +1238,14 @@ export const reactDedupePlugin = (from: string): BunPlugin => ({
|
|
|
1011
1238
|
})
|
|
1012
1239
|
|
|
1013
1240
|
/**
|
|
1014
|
-
* Dedupe Preact to a single copy
|
|
1241
|
+
* Dedupe Preact to a single copy - the Preact analogue of `reactDedupePlugin`, closing the same class of
|
|
1015
1242
|
* bug for the Preact framework (which had NO build-time dedup before). A `file:`-linked package can ship
|
|
1016
1243
|
* its OWN `preact`, so the bundle ends up with two Preact cores; since `preact-render-to-string` mutates
|
|
1017
1244
|
* `preact`'s shared `options` global and `preact/hooks` writes the SAME global, two copies → two `options`
|
|
1018
1245
|
* → SSR throws `undefined is not an object (… __H)` (the vnode's hook state was set up on the other copy).
|
|
1019
1246
|
* Pinning `preact` + its hooks/compat/jsx subpaths to ONE resolved copy (the app's, from `from`) makes the
|
|
1020
1247
|
* renderer and the components share one core. No-op when Preact isn't used (an unresolvable spec is
|
|
1021
|
-
* skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection
|
|
1248
|
+
* skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and
|
|
1022
1249
|
* unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
|
|
1023
1250
|
*/
|
|
1024
1251
|
const PREACT_DEDUPE_SPECS = [
|
|
@@ -1036,7 +1263,7 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
|
|
|
1036
1263
|
try {
|
|
1037
1264
|
resolved = Bun.resolveSync(spec, from)
|
|
1038
1265
|
} catch {
|
|
1039
|
-
continue // Preact (or this subpath) isn't resolvable here
|
|
1266
|
+
continue // Preact (or this subpath) isn't resolvable here - nothing to dedupe
|
|
1040
1267
|
}
|
|
1041
1268
|
const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")
|
|
1042
1269
|
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }))
|
|
@@ -1045,10 +1272,10 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
|
|
|
1045
1272
|
})
|
|
1046
1273
|
|
|
1047
1274
|
/**
|
|
1048
|
-
* Dedupe Svelte to a single copy
|
|
1275
|
+
* Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
|
|
1049
1276
|
* the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
|
|
1050
1277
|
* `@nifrajs/web-svelte` can resolve its OWN `svelte` (e.g. a sibling repo's install store) while the app's
|
|
1051
|
-
* components resolve another
|
|
1278
|
+
* components resolve another - SAME version, two physical copies. Svelte 5's client runtime
|
|
1052
1279
|
* (`svelte/internal/client`) holds module-level component-context state, so two copies means the compiled
|
|
1053
1280
|
* components register on one runtime while `hydrate` runs on the other → hydration throws
|
|
1054
1281
|
* `Cannot read properties of undefined (reading 'call')` and the server-rendered markup is wiped.
|
|
@@ -1066,7 +1293,7 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
|
|
|
1066
1293
|
try {
|
|
1067
1294
|
return { path: Bun.resolveSync(args.path, from) }
|
|
1068
1295
|
} catch {
|
|
1069
|
-
return undefined // not resolvable from the app root
|
|
1296
|
+
return undefined // not resolvable from the app root - leave Bun's default resolution
|
|
1070
1297
|
}
|
|
1071
1298
|
})
|
|
1072
1299
|
},
|
|
@@ -1074,19 +1301,43 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
|
|
|
1074
1301
|
|
|
1075
1302
|
/**
|
|
1076
1303
|
* Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
|
|
1077
|
-
* `auth.server.ts`, …) is server-only
|
|
1304
|
+
* `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
|
|
1078
1305
|
* Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
|
|
1079
1306
|
* import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
|
|
1080
1307
|
* import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
|
|
1081
1308
|
* in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
|
|
1082
|
-
* module is the fix. CLIENT-only
|
|
1309
|
+
* module is the fix. CLIENT-only - buildServer keeps the real module, which runs server-side.
|
|
1083
1310
|
*/
|
|
1084
|
-
const SERVER_ONLY_MODULE = /\.server(\.[cm]?[jt]sx?)?$/
|
|
1085
1311
|
export const serverOnlyEmptyPlugin = (): BunPlugin => ({
|
|
1086
1312
|
name: "nifra-server-only-empty",
|
|
1087
1313
|
setup(build) {
|
|
1088
1314
|
build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
|
|
1089
|
-
contents:
|
|
1315
|
+
contents: SERVER_ONLY_REPLACEMENT,
|
|
1316
|
+
loader: "js",
|
|
1317
|
+
}))
|
|
1318
|
+
},
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes
|
|
1323
|
+
* the server mounted, so the function bodies - and everything they import - never reach a browser.
|
|
1324
|
+
*
|
|
1325
|
+
* The sibling of {@link serverOnlyEmptyPlugin}, and a deliberate contrast: a `*.server` module is
|
|
1326
|
+
* EMPTIED because nothing may call it from the client, while a `*.fn` module is REPLACED because the
|
|
1327
|
+
* client is supposed to call it, just over HTTP. The generation itself is in
|
|
1328
|
+
* `internal/server-boundary.ts` so the Vite pipeline emits identical stubs from the same code; two
|
|
1329
|
+
* hand-written copies would be a client that works in dev and 404s in production.
|
|
1330
|
+
*
|
|
1331
|
+
* CLIENT-only. The server build keeps the real module, which is what `serverFunctions()` mounts.
|
|
1332
|
+
*/
|
|
1333
|
+
export const serverFnStubPlugin = (): BunPlugin => ({
|
|
1334
|
+
name: "nifra-server-fn-stub",
|
|
1335
|
+
setup(build) {
|
|
1336
|
+
build.onLoad({ filter: SERVER_FN_MODULE }, async (args) => ({
|
|
1337
|
+
contents: generateServerFnStub(
|
|
1338
|
+
await Bun.file(args.path).text(),
|
|
1339
|
+
serverFnNamespace(args.path),
|
|
1340
|
+
),
|
|
1090
1341
|
loader: "js",
|
|
1091
1342
|
}))
|
|
1092
1343
|
},
|
|
@@ -1117,7 +1368,7 @@ const solidWebServerPlugin = (from: string): BunPlugin => ({
|
|
|
1117
1368
|
* (`generateServerManifest`, written next to `serverEntry`), then bundles `serverEntry` with
|
|
1118
1369
|
* `Bun.build` using **edge conditions** + the adapter's SSR plugins. The output imports no `node:fs`
|
|
1119
1370
|
* and does no dynamic-path import, so it runs on workerd: point `wrangler.toml`'s `main` at it and
|
|
1120
|
-
* serve the client assets via Workers Assets. Throws (with the bundler logs) on failure
|
|
1371
|
+
* serve the client assets via Workers Assets. Throws (with the bundler logs) on failure - never
|
|
1121
1372
|
* silently ships a broken worker.
|
|
1122
1373
|
*/
|
|
1123
1374
|
export async function buildServer(options: BuildServerOptions): Promise<ServerBuild> {
|
|
@@ -1134,7 +1385,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
|
|
|
1134
1385
|
// Edge (browser) target: Bun's `bun` condition contaminates react-dom's server build + the `worker`
|
|
1135
1386
|
// condition segfaults Bun.build on solid, so force the edge/server builds via shims. The `node`/`bun`
|
|
1136
1387
|
// targets resolve those correctly via their own condition, so the shims (and edge conditions) don't
|
|
1137
|
-
// apply
|
|
1388
|
+
// apply - defaults become `[target]` (e.g. react-dom → server.node.js under `node`).
|
|
1138
1389
|
const edge = target === "browser"
|
|
1139
1390
|
const conditions = options.conditions ?? (edge ? ["workerd", "edge-light", "browser"] : [target])
|
|
1140
1391
|
const manifest = discoverRoutes(routesDir)
|
|
@@ -1153,11 +1404,12 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
|
|
|
1153
1404
|
// Tag every BUNDLED SSR output so @nifrajs/web-react's react-dom adapter takes the static
|
|
1154
1405
|
// (bundled, deduped) react-dom instead of re-rooting react-dom/server to a DISK copy at runtime.
|
|
1155
1406
|
// A `target:"bun"` bundle still has `Bun.resolveSync` under the Bun runtime, so without this tag the
|
|
1156
|
-
// adapter re-imports a SECOND react-dom from node_modules
|
|
1407
|
+
// adapter re-imports a SECOND react-dom from node_modules - a second React core whose hook dispatcher
|
|
1157
1408
|
// is null for the bundled components → SSR throws `…H.useRef of null`. Always set (a structural fact
|
|
1158
1409
|
// of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
|
|
1159
|
-
// (nifra dev/start, nifra_render) never define it, so they still re-root
|
|
1410
|
+
// (nifra dev/start, nifra_render) never define it, so they still re-root - the dev dual-install fix.
|
|
1160
1411
|
"process.env.NIFRA_SSR_BUNDLED": '"1"',
|
|
1412
|
+
"globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
|
|
1161
1413
|
},
|
|
1162
1414
|
minify: options.minify ?? true,
|
|
1163
1415
|
// Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
|
|
@@ -1182,12 +1434,12 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
|
|
|
1182
1434
|
}
|
|
1183
1435
|
|
|
1184
1436
|
// ===================================================================================================
|
|
1185
|
-
// `nifra build --target`
|
|
1437
|
+
// `nifra build --target` - package the engine above into one command that emits a full deploy dir.
|
|
1186
1438
|
//
|
|
1187
1439
|
// An app already declares everything the build needs through nifra's conventions: `adapter` +
|
|
1188
1440
|
// `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`.
|
|
1189
1441
|
// The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
|
|
1190
|
-
// …)
|
|
1442
|
+
// …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
|
|
1191
1443
|
// ===================================================================================================
|
|
1192
1444
|
|
|
1193
1445
|
/** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
|
|
@@ -1208,7 +1460,7 @@ export function isBuildTarget(value: string): value is BuildTarget {
|
|
|
1208
1460
|
* - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
|
|
1209
1461
|
* - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
|
|
1210
1462
|
* runtimes have a filesystem; the static `/assets/*` sit next to the entry).
|
|
1211
|
-
* `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written)
|
|
1463
|
+
* `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) -
|
|
1212
1464
|
* `buildServer` writes the entry next to `serverEntry`, so they're resolved from there. Pure (string in,
|
|
1213
1465
|
* string out) so the generation is unit-testable without a real build.
|
|
1214
1466
|
*/
|
|
@@ -1220,8 +1472,10 @@ export function generateServerEntry(options: {
|
|
|
1220
1472
|
readonly backendImport?: string
|
|
1221
1473
|
/** Document `<title>` passed to `createWebApp`. */
|
|
1222
1474
|
readonly title?: string
|
|
1475
|
+
/** Encoded root-relative public file paths copied into the deploy directory. */
|
|
1476
|
+
readonly publicFiles?: readonly string[]
|
|
1223
1477
|
}): string {
|
|
1224
|
-
const { target, adapterImport, backendImport, title = "nifra" } = options
|
|
1478
|
+
const { target, adapterImport, backendImport, title = "nifra", publicFiles = [] } = options
|
|
1225
1479
|
if (target === "static") {
|
|
1226
1480
|
throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
|
|
1227
1481
|
}
|
|
@@ -1251,14 +1505,33 @@ export function generateServerEntry(options: {
|
|
|
1251
1505
|
)
|
|
1252
1506
|
|
|
1253
1507
|
if (target === "cf-pages") {
|
|
1254
|
-
// Cloudflare Pages advanced mode: `_routes.json`
|
|
1255
|
-
// falls through to this
|
|
1256
|
-
|
|
1508
|
+
// Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and
|
|
1509
|
+
// everything else falls through to this handler (SSR).
|
|
1510
|
+
//
|
|
1511
|
+
// That exclude list cannot always name every public file - Cloudflare caps `_routes.json` at 100
|
|
1512
|
+
// rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting
|
|
1513
|
+
// the router 404 it, which makes correctness independent of how much of the list fit: the exclude
|
|
1514
|
+
// list decides only how many static requests skip the worker, never which files exist.
|
|
1515
|
+
lines.push(
|
|
1516
|
+
`const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`,
|
|
1517
|
+
"const handler = toFetchHandler(app)",
|
|
1518
|
+
"type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }",
|
|
1519
|
+
"export default {",
|
|
1520
|
+
" fetch(request: Request, env: Assets, ctx: ExecutionContext) {",
|
|
1521
|
+
" const { pathname } = new URL(request.url)",
|
|
1522
|
+
" // Only the build's own outputs: the hashed bundle prefix and the files it copied.",
|
|
1523
|
+
' if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))) {',
|
|
1524
|
+
" return env.ASSETS.fetch(request)",
|
|
1525
|
+
" }",
|
|
1526
|
+
" return handler.fetch(request, env as never, ctx)",
|
|
1527
|
+
" },",
|
|
1528
|
+
"}",
|
|
1529
|
+
)
|
|
1257
1530
|
return `${lines.join("\n")}\n`
|
|
1258
1531
|
}
|
|
1259
1532
|
if (target === "vercel") {
|
|
1260
1533
|
lines.push(
|
|
1261
|
-
"// Vercel Edge Function
|
|
1534
|
+
"// Vercel Edge Function - Vercel serves /assets/* from its CDN; this only SSRs page routes.",
|
|
1262
1535
|
'export const config = { runtime: "edge" }',
|
|
1263
1536
|
"export default (req: Request): Response | Promise<Response> => app.fetch(req)",
|
|
1264
1537
|
)
|
|
@@ -1267,8 +1540,20 @@ export function generateServerEntry(options: {
|
|
|
1267
1540
|
|
|
1268
1541
|
// bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
|
|
1269
1542
|
lines.push(
|
|
1270
|
-
"// The client bundle
|
|
1271
|
-
'const
|
|
1543
|
+
"// The client bundle and public files live next to this entry; serve only manifest-approved paths.",
|
|
1544
|
+
'const STATIC_ROOT = new URL("./", import.meta.url)',
|
|
1545
|
+
`const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`,
|
|
1546
|
+
"const staticPath = (pathname: string): string | undefined => {",
|
|
1547
|
+
" if (PUBLIC_FILES.has(pathname)) return '.' + pathname",
|
|
1548
|
+
' if (!pathname.startsWith("/assets/")) return undefined',
|
|
1549
|
+
' const segments = pathname.slice(1).split("/")',
|
|
1550
|
+
" // `.` and `..` match the character class, and the path is joined onto a base URL that resolves",
|
|
1551
|
+
" // dot segments - so they are rejected explicitly rather than relying on the request URL having",
|
|
1552
|
+
" // been normalized upstream.",
|
|
1553
|
+
" const safe = (segment: string): boolean =>",
|
|
1554
|
+
' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
|
|
1555
|
+
" return segments.every(safe) ? '.' + pathname : undefined",
|
|
1556
|
+
"}",
|
|
1272
1557
|
'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }',
|
|
1273
1558
|
)
|
|
1274
1559
|
if (target === "bun") {
|
|
@@ -1277,19 +1562,18 @@ export function generateServerEntry(options: {
|
|
|
1277
1562
|
" port: Number(Bun.env.PORT ?? 3000),",
|
|
1278
1563
|
" async fetch(req) {",
|
|
1279
1564
|
" const { pathname } = new URL(req.url)",
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
" const file = Bun.file(new URL(name, ASSETS))",
|
|
1565
|
+
" const filePath = staticPath(pathname)",
|
|
1566
|
+
" if (filePath !== undefined) {",
|
|
1567
|
+
" const file = Bun.file(new URL(filePath, STATIC_ROOT))",
|
|
1284
1568
|
' if (!(await file.exists())) return new Response("not found", { status: 404 })',
|
|
1285
|
-
' const ext =
|
|
1569
|
+
' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
|
|
1286
1570
|
' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
|
|
1287
1571
|
" }",
|
|
1288
1572
|
" return app.fetch(req)",
|
|
1289
1573
|
" },",
|
|
1290
1574
|
"})",
|
|
1291
1575
|
// The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
|
|
1292
|
-
// source
|
|
1576
|
+
// source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
|
|
1293
1577
|
`console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`,
|
|
1294
1578
|
)
|
|
1295
1579
|
return `${lines.join("\n")}\n`
|
|
@@ -1301,12 +1585,11 @@ export function generateServerEntry(options: {
|
|
|
1301
1585
|
" {",
|
|
1302
1586
|
" async fetch(req) {",
|
|
1303
1587
|
" const { pathname } = new URL(req.url)",
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })',
|
|
1588
|
+
" const filePath = staticPath(pathname)",
|
|
1589
|
+
" if (filePath !== undefined) {",
|
|
1307
1590
|
" try {",
|
|
1308
|
-
" const body = await readFile(new URL(
|
|
1309
|
-
' const ext =
|
|
1591
|
+
" const body = await readFile(new URL(filePath, STATIC_ROOT))",
|
|
1592
|
+
' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
|
|
1310
1593
|
' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
|
|
1311
1594
|
" } catch {",
|
|
1312
1595
|
' return new Response("not found", { status: 404 })',
|
|
@@ -1323,16 +1606,15 @@ export function generateServerEntry(options: {
|
|
|
1323
1606
|
// deno
|
|
1324
1607
|
lines.push(
|
|
1325
1608
|
"const handler = toFetchHandler(app)",
|
|
1326
|
-
"// @ts-ignore
|
|
1609
|
+
"// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1327
1610
|
'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {',
|
|
1328
1611
|
" const { pathname } = new URL(req.url)",
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })',
|
|
1612
|
+
" const filePath = staticPath(pathname)",
|
|
1613
|
+
" if (filePath !== undefined) {",
|
|
1332
1614
|
" try {",
|
|
1333
|
-
" // @ts-ignore
|
|
1334
|
-
" const body = await Deno.readFile(new URL(
|
|
1335
|
-
' const ext =
|
|
1615
|
+
" // @ts-ignore - Deno.readFile is present on the Deno runtime.",
|
|
1616
|
+
" const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))",
|
|
1617
|
+
' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
|
|
1336
1618
|
' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
|
|
1337
1619
|
" } catch {",
|
|
1338
1620
|
' return new Response("not found", { status: 404 })',
|
|
@@ -1383,7 +1665,7 @@ export interface BuildTargetOptions {
|
|
|
1383
1665
|
readonly adapterImport: string
|
|
1384
1666
|
/** Import specifier (resolvable from `workDir`) of the module exporting `backend`, or `undefined`. */
|
|
1385
1667
|
readonly backendImport?: string
|
|
1386
|
-
/** Factory that builds the app for `static` prerendering, GIVEN the client build's manifest
|
|
1668
|
+
/** Factory that builds the app for `static` prerendering, GIVEN the client build's manifest - so the
|
|
1387
1669
|
* emitted hydration `<script src>` uses the REAL content-hashed entry (`client.entry`) plus the same
|
|
1388
1670
|
* styles/route-preload the server targets use. A pre-built instance can't work here: the hash isn't known
|
|
1389
1671
|
* until `buildClient` runs inside `buildTarget`, so a hardcoded entry 404s → pages render but never
|
|
@@ -1397,16 +1679,20 @@ export interface BuildTargetOptions {
|
|
|
1397
1679
|
readonly conditions?: readonly string[]
|
|
1398
1680
|
/** Compile-time `define` replacements layered onto both builds. */
|
|
1399
1681
|
readonly define?: Readonly<Record<string, string>>
|
|
1682
|
+
/** Project-root static directory copied to deploy root. `false` disables it. */
|
|
1683
|
+
readonly publicDir?: string | false
|
|
1684
|
+
/** Prefix of environment variables allowed into the client bundle (default `"PUBLIC_"`). */
|
|
1685
|
+
readonly publicEnvPrefix?: string
|
|
1400
1686
|
/** Document `<title>` for the generated server entry. */
|
|
1401
1687
|
readonly title?: string
|
|
1402
1688
|
}
|
|
1403
1689
|
|
|
1404
|
-
/** Minimal app surface `buildTarget`'s static path needs
|
|
1690
|
+
/** Minimal app surface `buildTarget`'s static path needs - a fetch handler (a built `createWebApp`). */
|
|
1405
1691
|
export interface PrerenderAppLike {
|
|
1406
1692
|
fetch(req: Request): Response | Promise<Response>
|
|
1407
1693
|
}
|
|
1408
1694
|
|
|
1409
|
-
/** The result of a target build
|
|
1695
|
+
/** The result of a target build - the deploy dir + the client manifest + an optional size report. */
|
|
1410
1696
|
export interface BuildTargetResult {
|
|
1411
1697
|
/** The deploy target that was built. */
|
|
1412
1698
|
readonly target: BuildTarget
|
|
@@ -1427,7 +1713,7 @@ export interface BuildTargetResult {
|
|
|
1427
1713
|
* - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+
|
|
1428
1714
|
* `_data.json`); needs `prerenderApp`. No server.
|
|
1429
1715
|
* - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
|
|
1430
|
-
* - `vercel`: a `.vercel/output`-shaped function isn't emitted here
|
|
1716
|
+
* - `vercel`: a `.vercel/output`-shaped function isn't emitted here - `vercel` emits the bundled edge
|
|
1431
1717
|
* entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note]
|
|
1432
1718
|
* - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
|
|
1433
1719
|
* The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
|
|
@@ -1436,9 +1722,84 @@ export interface BuildTargetResult {
|
|
|
1436
1722
|
* Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
|
|
1437
1723
|
* remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
|
|
1438
1724
|
*/
|
|
1725
|
+
/**
|
|
1726
|
+
* A build-tool STRATEGY for {@link buildTargetWith} - the two bundling steps, and nothing else. Everything
|
|
1727
|
+
* around them (server-entry codegen, deploy assembly, prerender, size report) is bundler-agnostic and
|
|
1728
|
+
* lives in `buildTargetWith`, so a second bundler (Vite) is this interface, not a second orchestrator.
|
|
1729
|
+
*
|
|
1730
|
+
* Plugin lists are `readonly unknown[]` because a Bun plugin and a Vite plugin are different types; each
|
|
1731
|
+
* strategy casts to its own. `buildTargetWith` only forwards them.
|
|
1732
|
+
*/
|
|
1733
|
+
export interface Bundler {
|
|
1734
|
+
/** Build the client bundle → the shared {@link BuildManifest}. */
|
|
1735
|
+
buildClient(input: {
|
|
1736
|
+
readonly routesDir: string
|
|
1737
|
+
readonly outDir: string
|
|
1738
|
+
readonly clientModule: string
|
|
1739
|
+
readonly plugins?: readonly unknown[]
|
|
1740
|
+
readonly conditions?: readonly string[]
|
|
1741
|
+
readonly define?: Readonly<Record<string, string>>
|
|
1742
|
+
readonly publicDir?: string | false
|
|
1743
|
+
readonly publicEnvPrefix?: string
|
|
1744
|
+
/** Project root (Vite needs it; the Bun strategy ignores it). */
|
|
1745
|
+
readonly root?: string
|
|
1746
|
+
}): Promise<BuildManifest>
|
|
1747
|
+
/** Build the server worker → the shared {@link ServerBuild}. */
|
|
1748
|
+
buildServer(input: {
|
|
1749
|
+
readonly routesDir: string
|
|
1750
|
+
readonly serverEntry: string
|
|
1751
|
+
readonly outDir: string
|
|
1752
|
+
readonly clientEntry: string
|
|
1753
|
+
readonly target: "browser" | "node" | "bun"
|
|
1754
|
+
readonly plugins?: readonly unknown[]
|
|
1755
|
+
readonly define?: Readonly<Record<string, string>>
|
|
1756
|
+
readonly root?: string
|
|
1757
|
+
}): Promise<ServerBuild>
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
|
|
1761
|
+
export const bunBundler: Bundler = {
|
|
1762
|
+
buildClient: (input) =>
|
|
1763
|
+
buildClient({
|
|
1764
|
+
routesDir: input.routesDir,
|
|
1765
|
+
outDir: input.outDir,
|
|
1766
|
+
clientModule: input.clientModule,
|
|
1767
|
+
...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
|
|
1768
|
+
...(input.conditions ? { conditions: input.conditions } : {}),
|
|
1769
|
+
...(input.define ? { define: input.define } : {}),
|
|
1770
|
+
...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
|
|
1771
|
+
...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
|
|
1772
|
+
}),
|
|
1773
|
+
buildServer: (input) =>
|
|
1774
|
+
buildServer({
|
|
1775
|
+
routesDir: input.routesDir,
|
|
1776
|
+
serverEntry: input.serverEntry,
|
|
1777
|
+
outDir: input.outDir,
|
|
1778
|
+
clientEntry: input.clientEntry,
|
|
1779
|
+
target: input.target,
|
|
1780
|
+
...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
|
|
1781
|
+
...(input.define ? { define: input.define } : {}),
|
|
1782
|
+
}),
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
|
|
1439
1786
|
export async function buildTarget(
|
|
1440
1787
|
target: BuildTarget,
|
|
1441
1788
|
options: BuildTargetOptions,
|
|
1789
|
+
): Promise<BuildTargetResult> {
|
|
1790
|
+
return buildTargetWith(target, options, bunBundler)
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* The bundler-agnostic deploy orchestrator: everything `buildTarget` does EXCEPT the two bundling steps,
|
|
1795
|
+
* which come from `bundler`. `buildTarget` passes {@link bunBundler}; `buildTargetVite`
|
|
1796
|
+
* (`@nifrajs/web/build-vite`) passes the Vite strategy. One orchestrator, so the deploy-dir shape,
|
|
1797
|
+
* server-entry codegen, prerender and size report are identical across pipelines.
|
|
1798
|
+
*/
|
|
1799
|
+
export async function buildTargetWith(
|
|
1800
|
+
target: BuildTarget,
|
|
1801
|
+
options: BuildTargetOptions,
|
|
1802
|
+
bundler: Bundler,
|
|
1442
1803
|
): Promise<BuildTargetResult> {
|
|
1443
1804
|
const { routesDir, outDir, workDir } = options
|
|
1444
1805
|
const { rmSync } = await import("node:fs")
|
|
@@ -1449,14 +1810,27 @@ export async function buildTarget(
|
|
|
1449
1810
|
mkdirSync(workDir, { recursive: true })
|
|
1450
1811
|
|
|
1451
1812
|
// (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
|
|
1452
|
-
|
|
1813
|
+
let client = await bundler.buildClient({
|
|
1453
1814
|
routesDir,
|
|
1454
1815
|
outDir: assetsDir,
|
|
1455
1816
|
clientModule: options.clientModule,
|
|
1456
1817
|
...(options.clientPlugins ? { plugins: options.clientPlugins } : {}),
|
|
1457
1818
|
...(options.conditions ? { conditions: options.conditions } : {}),
|
|
1458
1819
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1820
|
+
publicDir: false,
|
|
1821
|
+
...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
|
|
1822
|
+
root: resolvePath(dirname(routesDir)),
|
|
1459
1823
|
})
|
|
1824
|
+
const publicDir =
|
|
1825
|
+
options.publicDir === false
|
|
1826
|
+
? undefined
|
|
1827
|
+
: resolvePath(options.publicDir ?? join(dirname(routesDir), "public"))
|
|
1828
|
+
const publicFiles =
|
|
1829
|
+
publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
|
|
1830
|
+
if (publicFiles.length > 0) {
|
|
1831
|
+
client = { ...client, publicFiles }
|
|
1832
|
+
writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
|
|
1833
|
+
}
|
|
1460
1834
|
|
|
1461
1835
|
if (target === "static") {
|
|
1462
1836
|
if (options.prerenderApp === undefined) {
|
|
@@ -1476,9 +1850,9 @@ export async function buildTarget(
|
|
|
1476
1850
|
})
|
|
1477
1851
|
if (result.prerendered.length === 0) {
|
|
1478
1852
|
// A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
|
|
1479
|
-
// getStaticPaths)
|
|
1853
|
+
// getStaticPaths) - fail loudly rather than ship an empty dir the dev thinks is their site.
|
|
1480
1854
|
throw new Error(
|
|
1481
|
-
"[nifra/web] buildTarget(static): no routes were prerendered
|
|
1855
|
+
"[nifra/web] buildTarget(static): no routes were prerendered - opt routes in with " +
|
|
1482
1856
|
"`export const prerender = true` (static) or `getStaticPaths` (dynamic).",
|
|
1483
1857
|
)
|
|
1484
1858
|
}
|
|
@@ -1506,10 +1880,11 @@ export async function buildTarget(
|
|
|
1506
1880
|
adapterImport: options.adapterImport,
|
|
1507
1881
|
...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
|
|
1508
1882
|
...(options.title !== undefined ? { title: options.title } : {}),
|
|
1883
|
+
...(publicFiles.length > 0 ? { publicFiles } : {}),
|
|
1509
1884
|
}),
|
|
1510
1885
|
)
|
|
1511
1886
|
const serverTarget = SERVER_BUILD_TARGET[target]
|
|
1512
|
-
const { worker } = await buildServer({
|
|
1887
|
+
const { worker } = await bundler.buildServer({
|
|
1513
1888
|
routesDir,
|
|
1514
1889
|
serverEntry: serverEntryPath,
|
|
1515
1890
|
outDir: `${workDir}/server`,
|
|
@@ -1517,6 +1892,7 @@ export async function buildTarget(
|
|
|
1517
1892
|
target: serverTarget,
|
|
1518
1893
|
...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
|
|
1519
1894
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
1895
|
+
root: resolvePath(dirname(routesDir)),
|
|
1520
1896
|
})
|
|
1521
1897
|
|
|
1522
1898
|
// (3) Assemble the deploy dir for the target.
|
|
@@ -1524,10 +1900,25 @@ export async function buildTarget(
|
|
|
1524
1900
|
let run: string
|
|
1525
1901
|
if (target === "cf-pages") {
|
|
1526
1902
|
cpSync(worker, `${outDir}/_worker.js`)
|
|
1903
|
+
// The app's real patterns, so a directory is only collapsed into a glob once the route table
|
|
1904
|
+
// proves nothing can be served beneath it.
|
|
1905
|
+
const rules = cloudflareRouteRules(
|
|
1906
|
+
publicFiles,
|
|
1907
|
+
discoverRoutes(routesDir).routes.map((route) => route.pattern),
|
|
1908
|
+
)
|
|
1527
1909
|
writeFileSync(
|
|
1528
1910
|
`${outDir}/_routes.json`,
|
|
1529
|
-
`${JSON.stringify({ version: 1, include:
|
|
1911
|
+
`${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`,
|
|
1530
1912
|
)
|
|
1913
|
+
// Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a
|
|
1914
|
+
// worker invocation, and that trade should be visible rather than inferred from a latency graph.
|
|
1915
|
+
if (rules.omitted.length > 0) {
|
|
1916
|
+
console.log(
|
|
1917
|
+
`[nifra/web] _routes.json holds ${rules.exclude.length} of ${publicFiles.length + 1} static rules ` +
|
|
1918
|
+
`(Cloudflare allows ${CF_MAX_ROUTE_RULES}). The other ${rules.omitted.length} file(s) are served by the ` +
|
|
1919
|
+
"worker via ASSETS instead of the CDN directly - correct, just one invocation each.",
|
|
1920
|
+
)
|
|
1921
|
+
}
|
|
1531
1922
|
run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`
|
|
1532
1923
|
} else if (target === "vercel") {
|
|
1533
1924
|
cpSync(worker, `${outDir}/index.js`)
|