@nifrajs/web 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/build-vite.d.ts +68 -0
  2. package/dist/build-vite.d.ts.map +1 -0
  3. package/dist/build-vite.js +487 -0
  4. package/dist/build-vite.js.map +1 -0
  5. package/dist/build.d.ts +112 -25
  6. package/dist/build.d.ts.map +1 -1
  7. package/dist/build.js +283 -64
  8. package/dist/build.js.map +1 -1
  9. package/dist/bun-dev-entry.d.ts +83 -0
  10. package/dist/bun-dev-entry.d.ts.map +1 -0
  11. package/dist/bun-dev-entry.js +136 -0
  12. package/dist/bun-dev-entry.js.map +1 -0
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +10 -0
  15. package/dist/client.js.map +1 -1
  16. package/dist/deferred.d.ts +3 -2
  17. package/dist/deferred.d.ts.map +1 -1
  18. package/dist/deferred.js +11 -8
  19. package/dist/deferred.js.map +1 -1
  20. package/dist/dev-port.d.ts +36 -0
  21. package/dist/dev-port.d.ts.map +1 -0
  22. package/dist/dev-port.js +48 -0
  23. package/dist/dev-port.js.map +1 -0
  24. package/dist/dev.d.ts +61 -4
  25. package/dist/dev.d.ts.map +1 -1
  26. package/dist/dev.js +419 -60
  27. package/dist/dev.js.map +1 -1
  28. package/dist/draft.d.ts +37 -0
  29. package/dist/draft.d.ts.map +1 -1
  30. package/dist/draft.js +87 -4
  31. package/dist/draft.js.map +1 -1
  32. package/dist/fs.d.ts +14 -1
  33. package/dist/fs.d.ts.map +1 -1
  34. package/dist/fs.js +7 -1
  35. package/dist/fs.js.map +1 -1
  36. package/dist/index.d.ts +116 -5
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +565 -53
  39. package/dist/index.js.map +1 -1
  40. package/dist/internal/timing-safe-equal.d.ts +16 -0
  41. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  42. package/dist/internal/timing-safe-equal.js +26 -0
  43. package/dist/internal/timing-safe-equal.js.map +1 -0
  44. package/dist/isr.d.ts.map +1 -1
  45. package/dist/isr.js +1 -14
  46. package/dist/isr.js.map +1 -1
  47. package/dist/llms-txt.d.ts.map +1 -1
  48. package/dist/llms-txt.js +12 -6
  49. package/dist/llms-txt.js.map +1 -1
  50. package/dist/manifest.d.ts +51 -8
  51. package/dist/manifest.d.ts.map +1 -1
  52. package/dist/manifest.js +130 -14
  53. package/dist/manifest.js.map +1 -1
  54. package/dist/module-graph.d.ts +91 -0
  55. package/dist/module-graph.d.ts.map +1 -0
  56. package/dist/module-graph.js +92 -0
  57. package/dist/module-graph.js.map +1 -0
  58. package/dist/plugins/css-modules.d.ts.map +1 -1
  59. package/dist/plugins/css-modules.js +0 -0
  60. package/dist/plugins/css-modules.js.map +1 -1
  61. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  62. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  63. package/dist/plugins/vite-leak-guard.js +67 -0
  64. package/dist/plugins/vite-leak-guard.js.map +1 -0
  65. package/dist/public-dir.d.ts +36 -0
  66. package/dist/public-dir.d.ts.map +1 -0
  67. package/dist/public-dir.js +89 -0
  68. package/dist/public-dir.js.map +1 -0
  69. package/dist/route-manifest.d.ts +91 -0
  70. package/dist/route-manifest.d.ts.map +1 -0
  71. package/dist/route-manifest.js +128 -0
  72. package/dist/route-manifest.js.map +1 -0
  73. package/dist/router.d.ts +64 -1
  74. package/dist/router.d.ts.map +1 -1
  75. package/dist/router.js +114 -31
  76. package/dist/router.js.map +1 -1
  77. package/dist/vite.d.ts +10 -4
  78. package/dist/vite.d.ts.map +1 -1
  79. package/dist/vite.js +44 -12
  80. package/dist/vite.js.map +1 -1
  81. package/package.json +17 -2
  82. package/src/build-vite.ts +606 -0
  83. package/src/build.ts +432 -73
  84. package/src/bun-dev-entry.ts +184 -0
  85. package/src/client.ts +8 -0
  86. package/src/deferred.ts +13 -6
  87. package/src/dev-port.ts +63 -0
  88. package/src/dev.ts +467 -65
  89. package/src/draft.ts +116 -4
  90. package/src/fs.ts +22 -4
  91. package/src/index.ts +727 -57
  92. package/src/internal/timing-safe-equal.ts +24 -0
  93. package/src/isr.ts +1 -13
  94. package/src/llms-txt.ts +13 -5
  95. package/src/manifest.ts +188 -13
  96. package/src/module-graph.ts +146 -0
  97. package/src/plugins/css-modules.ts +0 -0
  98. package/src/plugins/vite-leak-guard.ts +110 -0
  99. package/src/public-dir.ts +108 -0
  100. package/src/route-manifest.ts +240 -0
  101. package/src/router.ts +198 -34
  102. package/src/vite.ts +62 -20
@@ -0,0 +1,606 @@
1
+ /**
2
+ * `@nifrajs/web/build-vite` — the **Vite/Rollup production build**: the escape hatch, not the default.
3
+ *
4
+ * nifra's production default is Bun (`@nifrajs/web/build`), and stays so - it is faster and Bun-native,
5
+ * which is what nifra competes on. This exists for the one case that default cannot serve: an app whose
6
+ * client depends on a Vite-only transform with no Bun equivalent. For those, `buildClientVite` /
7
+ * `buildServerVite` produce the SAME artifacts as the Bun build - the same `BuildManifest`, the same
8
+ * `ServerBuild` - through Vite instead, and `buildTargetVite` assembles the same per-target deploy dir.
9
+ *
10
+ * Crucially this is NOT a second orchestrator. `buildTarget`'s deploy assembly, server-entry codegen,
11
+ * prerender and size report are bundler-agnostic; they live in `build.ts` behind `buildTargetWith`, which
12
+ * takes a {@link Bundler} strategy. This file only supplies the Vite strategy - the two bundling steps -
13
+ * so a change to how a deploy dir is shaped happens in one place for both pipelines.
14
+ *
15
+ * And it does not arrive without the client-leak guards: `buildClientVite` wires `viteLeakGuard()`, so
16
+ * server-only code or a `node:` builtin reaching the browser fails the Vite build with the identical
17
+ * error the Bun build raises. A second production pipeline without those guards is exactly the hole the
18
+ * bundler-neutral module graph was built to close.
19
+ */
20
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
21
+ import { dirname, join, relative, resolve as resolvePath } from "node:path"
22
+ import {
23
+ type BuildClientOptions,
24
+ type BuildManifest,
25
+ type BuildTarget,
26
+ type BuildTargetOptions,
27
+ type BuildTargetResult,
28
+ type Bundler,
29
+ buildTargetWith,
30
+ copyPublicDir,
31
+ publicEnvDefines,
32
+ type ServerBuild,
33
+ } from "./build.ts"
34
+ import { discoverRoutes } from "./fs.ts"
35
+ import { generateClientEntry, generateServerManifest } from "./index.ts"
36
+ import { viteLeakGuard } from "./plugins/vite-leak-guard.ts"
37
+
38
+ // ---------------------------------------------------------------------------------------------------
39
+ // Structural Vite typings — no hard `vite` dependency (mirrors vite.ts). Only the build API is used.
40
+ // ---------------------------------------------------------------------------------------------------
41
+
42
+ /** One entry in Vite's `.vite/manifest.json`: its emitted file + transitive CSS + imported chunks. */
43
+ interface ViteManifestEntry {
44
+ readonly file: string
45
+ readonly isEntry?: boolean
46
+ readonly css?: readonly string[]
47
+ readonly assets?: readonly string[]
48
+ readonly imports?: readonly string[]
49
+ }
50
+ type ViteBuildManifest = Readonly<Record<string, ViteManifestEntry>>
51
+
52
+ interface ViteModule {
53
+ build(config: Record<string, unknown>): Promise<unknown>
54
+ }
55
+
56
+ /**
57
+ * Restore V8's `Error.captureStackTrace` contract on a runtime that is stricter than it.
58
+ *
59
+ * `captureStackTrace` is a V8 API, and V8 decorates ANY object handed to it. Some runtimes instead
60
+ * require a real Error - one with the internal slot, which `Object.create(Error.prototype)` and
61
+ * `new SubclassWithErrorPrototype()` do NOT have - and throw "First argument must be an Error object".
62
+ *
63
+ * That breaks importing vite outright. Vite bundles `follow-redirects`, which builds its error types the
64
+ * pre-class way:
65
+ *
66
+ * function CustomError(props) { Error.captureStackTrace(this, this.constructor); … }
67
+ * CustomError.prototype = new (baseClass || Error)()
68
+ *
69
+ * The last line CONSTRUCTS `baseClass` while defining the subclass, so `this` is an object that inherits
70
+ * from Error but was never built by it - and the throw happens at module evaluation, before vite exports
71
+ * anything. Every Vite build then fails with a message about stack traces, naming nothing about vite.
72
+ *
73
+ * Decorating a stack is best-effort by definition, so swallowing the rejection loses nothing: the shim
74
+ * delegates, and only suppresses the refusal. Installed once, and deliberately not restored - the strict
75
+ * behaviour is the deviation, and a library evaluated later would hit the same wall.
76
+ */
77
+ function relaxCaptureStackTrace(): void {
78
+ const strict = Error.captureStackTrace
79
+ if (
80
+ typeof strict !== "function" ||
81
+ Object.prototype.toString.call(new Error()) !== "[object Error]"
82
+ ) {
83
+ return
84
+ }
85
+ // Probe rather than sniff a version: construct exactly what follow-redirects does and see if it is
86
+ // refused. A runtime that already follows V8 keeps its own implementation untouched.
87
+ const inherited = Object.create(Error.prototype)
88
+ try {
89
+ strict.call(Error, inherited)
90
+ return // permissive already - nothing to fix
91
+ } catch {
92
+ // stricter than V8; fall through and wrap
93
+ }
94
+ try {
95
+ Error.captureStackTrace = ((target: object, constructorOpt?: unknown) => {
96
+ try {
97
+ ;(strict as (t: object, c?: unknown) => void).call(Error, target, constructorOpt)
98
+ } catch {
99
+ // The runtime refused to decorate this object. A missing `.stack` is not worth failing over.
100
+ }
101
+ }) as typeof Error.captureStackTrace
102
+ } catch {
103
+ // The property is frozen or non-writable. Nothing can be done about the import that follows, but
104
+ // failing HERE would replace vite's own error with an assignment error - strictly less informative
105
+ // than letting the import fail and be reported by `loadVite`.
106
+ }
107
+ }
108
+
109
+ /** Load the app's Vite. Resolved from `root` so it's the project's copy, not this package's. */
110
+ async function loadVite(): Promise<ViteModule> {
111
+ relaxCaptureStackTrace()
112
+ try {
113
+ return (await import("vite")) as unknown as ViteModule
114
+ } catch (cause) {
115
+ // Two different failures arrive here and must not be conflated. `vite` is an OPTIONAL peer, so a
116
+ // project without it fails to RESOLVE - the common case, and the one worth naming. But vite can
117
+ // also resolve and fail while EVALUATING, which is not a missing dependency at all: vite 8 loads
118
+ // rolldown's native binding on import, and a binding that will not load surfaces here too. Telling
119
+ // that user to install what they already installed sends them the wrong way, so only a resolution
120
+ // failure claims a missing package; anything else is reported as what it is.
121
+ const message = cause instanceof Error ? cause.message : String(cause)
122
+ const unresolved = /cannot find (?:package|module)|ERR_MODULE_NOT_FOUND/i.test(message)
123
+ throw new Error(
124
+ unresolved
125
+ ? "[nifra/web] the Vite production build needs `vite` installed in this project (it is an " +
126
+ "optional peer dependency, so it is not installed for you). Add it, or use the default Bun " +
127
+ `build. Underlying error: ${message}`
128
+ : `[nifra/web] \`vite\` resolved but failed to load, so the Vite production build cannot run: ${message}. ` +
129
+ "This is vite (or a native dependency of it) failing on this runtime, not a nifra build error - " +
130
+ "check that the installed vite works here, or use the default Bun build.",
131
+ { cause },
132
+ )
133
+ }
134
+ }
135
+
136
+ // Vite derives `isProduction` from the process-global NODE_ENV even when an explicit `mode` is supplied.
137
+ // Programmatic builds therefore have to pin it, but two overlapping calls must never observe each other's
138
+ // mode. Serialize only this unavoidable Vite window; the previous value is captured after acquiring the
139
+ // lock and restored before the next build starts. Rejections release the lock too.
140
+ let nodeEnvBuildTail: Promise<void> = Promise.resolve()
141
+
142
+ async function withSerializedNodeEnv<T>(mode: string, run: () => Promise<T>): Promise<T> {
143
+ const previousBuild = nodeEnvBuildTail
144
+ let release!: () => void
145
+ nodeEnvBuildTail = new Promise<void>((resolve) => {
146
+ release = resolve
147
+ })
148
+ await previousBuild
149
+
150
+ const previous = process.env.NODE_ENV
151
+ process.env.NODE_ENV = mode
152
+ try {
153
+ return await run()
154
+ } finally {
155
+ // An env value is always a string when set; `undefined` means the key was absent, so remove it rather
156
+ // than assign `undefined` (which `exactOptionalPropertyTypes` rejects and which would stringify anyway).
157
+ if (previous === undefined) delete process.env.NODE_ENV
158
+ else process.env.NODE_ENV = previous
159
+ release()
160
+ }
161
+ }
162
+
163
+ /** Options for {@link buildClientVite} — the Bun {@link BuildClientOptions} minus Bun-plugin specifics,
164
+ * plus the Vite plugin list the app injects for its transforms. */
165
+ export interface BuildClientViteOptions extends Omit<BuildClientOptions, "plugins"> {
166
+ /** Vite plugins for the app's client transforms (e.g. `react()`, `vue()`). */
167
+ readonly vitePlugins?: readonly unknown[]
168
+ /** Vite project root (default: the parent of `routesDir`). Manifest keys are relative to it. */
169
+ readonly root?: string
170
+ }
171
+
172
+ /**
173
+ * Build the client bundle with Vite, emitting the SAME {@link BuildManifest} `buildClient` (Bun) does:
174
+ * a content-hashed entry, per-route chunk lists, aggregate + per-route CSS, and copied `public/` files.
175
+ *
176
+ * The route→chunk and route→CSS mappings come from Vite's own `.vite/manifest.json` (`build.manifest`),
177
+ * which records each entry's emitted `file` and its transitive `css` - exactly the two facts the Bun path
178
+ * reconstructs from Bun's metafile. `viteLeakGuard()` runs during the build, so a client leak fails here
179
+ * with the same error as the Bun build rather than shipping.
180
+ */
181
+ export async function buildClientVite(options: BuildClientViteOptions): Promise<BuildManifest> {
182
+ const { routesDir, outDir, clientModule } = options
183
+ const root = resolvePath(options.root ?? dirname(routesDir))
184
+ const resolve = options.resolve ?? ((file: string) => `${routesDir}/${file}`)
185
+ const publicPath = options.publicPath ?? "/assets/"
186
+ mkdirSync(outDir, { recursive: true })
187
+
188
+ const routeManifest = discoverRoutes(routesDir)
189
+ // The generated bootstrap, written beside the project so its imports resolve the app's deps (same
190
+ // reasoning as buildClient). A unique dir avoids clobbering a user file / colliding with parallel builds.
191
+ const entryDir = mkdtempSync(resolvePath(dirname(routesDir), ".nifra-vite-client-"))
192
+ const entryFile = `${entryDir}/_nifra-entry.ts`
193
+ writeFileSync(
194
+ entryFile,
195
+ `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`,
196
+ )
197
+
198
+ // Every unique route/layout/_404 file — each an additional entry so Vite emits a named chunk per file
199
+ // the bootstrap's lazy import dedupes to, giving the per-route chunk + CSS mapping below.
200
+ const routeFiles = [
201
+ ...new Set([
202
+ ...routeManifest.routes.map((r) => r.file),
203
+ ...Object.values(routeManifest.layouts).map((l) => l.file),
204
+ ...(routeManifest.notFound ? [routeManifest.notFound.file] : []),
205
+ ]),
206
+ ].sort()
207
+
208
+ // Vite manifest keys are the input path RELATIVE TO ROOT — precompute them so the lookup below is exact
209
+ // (survives same-basename collisions like `index.tsx` + `blog/index.tsx`).
210
+ const keyOf = (absPath: string): string => relative(root, absPath).replaceAll("\\", "/")
211
+ const entryKey = keyOf(entryFile)
212
+ const input: Record<string, string> = { _nifra: entryFile }
213
+ const fileKey = new Map<string, string>() // route/layout file → its manifest key
214
+ for (const file of routeFiles) {
215
+ const abs = resolvePath(resolve(file))
216
+ // FLAT input names (no `/`), so Vite emits `routes_index_tsx-HASH.js` in `outDir` directly, not a
217
+ // nested `routes/…` dir — the deploy assembly + size report map asset URLs by basename and expect a
218
+ // flat layout. The manifest LOOKUP below is keyed by the src path (`keyOf`), independent of this name.
219
+ input[keyOf(abs).replace(/[^\w]/g, "_")] = abs
220
+ fileKey.set(file, keyOf(abs))
221
+ }
222
+
223
+ // Pin Vite's MODE to match the NODE_ENV we define. @vitejs/plugin-react picks the JSX runtime by mode -
224
+ // `jsx` (production) vs `jsxDEV` (development) - so if mode and NODE_ENV disagree, the transform emits
225
+ // `jsxDEV` calls while `react/jsx-dev-runtime` guards `jsxDEV` behind `NODE_ENV !== "production"` and
226
+ // leaves it undefined → `jsxDEV is not a function` at SSR. Vite's default mode follows the ambient
227
+ // NODE_ENV (e.g. "test" under `bun test`), so it MUST be set explicitly, not left to default.
228
+ const mode = options.minify === false ? "development" : "production"
229
+ const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
230
+ const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv)
231
+ const vite = await loadVite()
232
+ const leakGuard = viteLeakGuard()
233
+ try {
234
+ await withSerializedNodeEnv(mode, () =>
235
+ vite.build({
236
+ root,
237
+ base: publicPath,
238
+ mode,
239
+ logLevel: "silent",
240
+ // Nifra owns publicDir copying so direct and target builds share symlink confinement and
241
+ // deploy-root placement. Vite's automatic copy would put root files inside /assets.
242
+ publicDir: false,
243
+ define: {
244
+ ...publicDefines,
245
+ "process.env.NODE_ENV": JSON.stringify(mode),
246
+ ...(options.define ?? {}),
247
+ },
248
+ resolve: {
249
+ // Mirror the Bun build's reactDedupePlugin: one physical react/react-dom, or a hook-using route
250
+ // gets a second dispatcher. No-op when the app isn't React.
251
+ dedupe: ["react", "react-dom"],
252
+ ...(options.conditions ? { conditions: [...options.conditions] } : {}),
253
+ },
254
+ plugins: [...(options.vitePlugins ?? [])],
255
+ build: {
256
+ outDir,
257
+ emptyOutDir: false, // buildTargetWith owns outDir lifecycle; never let Vite wipe sibling files
258
+ manifest: true,
259
+ minify: options.minify !== false,
260
+ rollupOptions: {
261
+ // Keep `node:` builtins as external specifiers so the leak guard sees `node:crypto` and can
262
+ // name it. Left to itself, rolldown-vite SILENTLY rewrites a `node:` import to a
263
+ // `__vite-browser-external` stub for "browser compatibility" - the code builds, ships, and the
264
+ // builtin is a no-op at runtime, which is a worse footgun than Bun's polyfill and invisible
265
+ // without this. External here means the guard fails the build (below) with the exact builtin.
266
+ external: [/^node:/],
267
+ input,
268
+ // The leak guard is a Rollup plugin — last, so it sees the final graph.
269
+ plugins: [leakGuard],
270
+ output: {
271
+ entryFileNames: "[name]-[hash].js",
272
+ chunkFileNames: "[name]-[hash].js",
273
+ assetFileNames: "[name]-[hash][extname]",
274
+ },
275
+ },
276
+ },
277
+ }),
278
+ )
279
+ } catch (error) {
280
+ // Re-raise the guard's own message. The plugin already threw it, but a plugin error is reported
281
+ // through the bundler, which builds its own error to do so - and on some Bun + rolldown pairings
282
+ // that construction fails, replacing a precise "node:crypto reached the client bundle" with an
283
+ // internal complaint naming nothing. Raising it here puts the message beyond anything that can
284
+ // rewrite it; the bundler's error is kept as `cause` for the rest of the context.
285
+ if (leakGuard.leak !== undefined) throw new Error(leakGuard.leak, { cause: error })
286
+ throw error
287
+ } finally {
288
+ rmSync(entryDir, { recursive: true, force: true })
289
+ }
290
+
291
+ const viteManifestDir = join(outDir, ".vite")
292
+ const viteManifest = JSON.parse(
293
+ readFileSync(join(viteManifestDir, "manifest.json"), "utf8"),
294
+ ) as ViteBuildManifest
295
+ // Vite's source-keyed build manifest is an internal mapping input, not a deploy artifact.
296
+ rmSync(viteManifestDir, { recursive: true, force: true })
297
+ const url = (file: string): string => `${publicPath}${file}`
298
+
299
+ const bootstrap = viteManifest[entryKey]
300
+ if (bootstrap === undefined) {
301
+ throw new Error(
302
+ `[nifra/web] Vite build produced no manifest entry for the bootstrap (${entryKey}). ` +
303
+ "The client build did not emit the generated entry.",
304
+ )
305
+ }
306
+
307
+ // routes: each route id → its layout chain + own chunk URLs (per-file entry chunks, like the Bun path).
308
+ const chunkFor = (file: string): string | undefined => {
309
+ const key = fileKey.get(file)
310
+ const entry = key !== undefined ? viteManifest[key] : undefined
311
+ return entry !== undefined ? url(entry.file) : undefined
312
+ }
313
+ const chainFiles = (route: (typeof routeManifest.routes)[number]): string[] => [
314
+ ...route.layoutIds.map((id) => routeManifest.layouts[id]?.file ?? ""),
315
+ route.file,
316
+ ]
317
+ const routes: Record<string, string[]> = {}
318
+ for (const route of routeManifest.routes) {
319
+ routes[route.id] = chainFiles(route)
320
+ .map(chunkFor)
321
+ .filter((u): u is string => u !== undefined)
322
+ }
323
+ if (routeManifest.notFound) {
324
+ const c = chunkFor(routeManifest.notFound.file)
325
+ routes._404 = c !== undefined ? [c] : []
326
+ }
327
+
328
+ // CSS per route — Vite's manifest `css` is the transitive stylesheet set for that entry.
329
+ const stylesFor = (files: readonly string[]): readonly string[] => {
330
+ const urls = new Set<string>()
331
+ for (const file of files) {
332
+ const key = fileKey.get(file)
333
+ const entry = key !== undefined ? viteManifest[key] : undefined
334
+ for (const css of entry?.css ?? []) urls.add(url(css))
335
+ }
336
+ return [...urls]
337
+ }
338
+ // CSS aggregate — every stylesheet the app emits, the always-safe fallback.
339
+ const allCss = new Set<string>()
340
+ for (const entry of Object.values(viteManifest)) {
341
+ for (const css of entry.css ?? []) allCss.add(url(css))
342
+ }
343
+ const css = [...allCss]
344
+ const routeStyles: Record<string, readonly string[]> = {}
345
+ if (css.length > 0) {
346
+ for (const route of routeManifest.routes) routeStyles[route.id] = stylesFor(chainFiles(route))
347
+ if (routeManifest.notFound) routeStyles._404 = stylesFor([routeManifest.notFound.file])
348
+ }
349
+
350
+ // assets — every emitted file + stylesheet across the manifest (chunks, entries, css).
351
+ const assets = new Set<string>()
352
+ for (const entry of Object.values(viteManifest)) {
353
+ assets.add(url(entry.file))
354
+ for (const css of entry.css ?? []) assets.add(url(css))
355
+ for (const asset of entry.assets ?? []) assets.add(url(asset))
356
+ }
357
+
358
+ const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
359
+ const publicFiles =
360
+ publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
361
+
362
+ const manifest: BuildManifest = {
363
+ entry: url(bootstrap.file),
364
+ assets: [...assets],
365
+ routes,
366
+ ...(publicFiles.length > 0 ? { publicFiles } : {}),
367
+ ...(css.length > 0 ? { css } : {}),
368
+ ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
369
+ }
370
+ writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2))
371
+ return manifest
372
+ }
373
+
374
+ /** Options for {@link buildServerVite} — the same surface as `buildServer` minus Bun-plugin specifics. */
375
+ export interface BuildServerViteOptions {
376
+ readonly routesDir: string
377
+ readonly serverEntry: string
378
+ readonly outDir: string
379
+ readonly clientEntry: string
380
+ readonly styles?: readonly string[] | undefined
381
+ readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
382
+ readonly resolve?: (file: string) => string
383
+ readonly manifestFile?: string
384
+ readonly vitePlugins?: readonly unknown[]
385
+ readonly conditions?: readonly string[]
386
+ readonly define?: Readonly<Record<string, string>>
387
+ readonly minify?: boolean
388
+ /** The runtime the worker targets — mirrors `buildServer`. `browser` = the edge (workerd/edge-light). */
389
+ readonly target?: "browser" | "node" | "bun"
390
+ /** Vite project root (default: the parent of `routesDir`). */
391
+ readonly root?: string
392
+ }
393
+
394
+ interface EdgeBundleChunk {
395
+ readonly type?: string
396
+ readonly imports?: readonly string[]
397
+ readonly dynamicImports?: readonly string[]
398
+ readonly moduleIds?: readonly string[]
399
+ }
400
+
401
+ interface EdgeBuiltinGuardContext {
402
+ getModuleInfo(id: string): {
403
+ readonly importedIds?: readonly string[]
404
+ readonly dynamicallyImportedIds?: readonly string[]
405
+ } | null
406
+ /** Takes an `Error`, never a string - see {@link EdgeGuardPlugin.leak}. */
407
+ error(error: Error): never
408
+ }
409
+
410
+ interface EdgeGuardPlugin {
411
+ readonly name: string
412
+ /**
413
+ * The finding that failed the build, or `undefined` when the bundle was clean.
414
+ *
415
+ * Recorded as well as thrown, for the same reason as the client leak guard: a plugin error is reported
416
+ * through the bundler, which builds its own error to do so, and on some Bun + rolldown pairings that
417
+ * construction fails and replaces the message with an internal one naming nothing. The caller owns
418
+ * this build, so it re-raises this from its own code where nothing can rewrite it.
419
+ */
420
+ leak?: string
421
+ generateBundle(
422
+ this: EdgeBuiltinGuardContext,
423
+ options: unknown,
424
+ bundle: Readonly<Record<string, EdgeBundleChunk>>,
425
+ ): void
426
+ }
427
+
428
+ /** Fail closed if Rollup would leave a Node builtin import in a workerd/edge worker. */
429
+ function edgeBuiltinGuard(): EdgeGuardPlugin {
430
+ const plugin: EdgeGuardPlugin = {
431
+ name: "nifra:edge-node-builtin-guard",
432
+ generateBundle(_options, bundle) {
433
+ const builtins = new Set<string>()
434
+ const importers = new Set<string>()
435
+ for (const output of Object.values(bundle)) {
436
+ if (output.type !== "chunk") continue
437
+ for (const specifier of [...(output.imports ?? []), ...(output.dynamicImports ?? [])]) {
438
+ if (specifier.startsWith("node:")) builtins.add(specifier)
439
+ }
440
+ for (const id of output.moduleIds ?? []) {
441
+ const info = this.getModuleInfo(id)
442
+ const imports = [...(info?.importedIds ?? []), ...(info?.dynamicallyImportedIds ?? [])]
443
+ if (imports.some((specifier) => specifier.startsWith("node:"))) importers.add(id)
444
+ }
445
+ }
446
+ if (builtins.size === 0) return
447
+ const leak =
448
+ `[nifra/web] Node built-in(s) reached an edge server bundle: ${[...builtins].sort().join(", ")}. ` +
449
+ `Imported by: ${[...importers].sort().join(", ") || "unknown module"}. ` +
450
+ "Move the import behind a Node/Bun target or replace it with an edge-compatible API."
451
+ // Record, then throw - see `leak`.
452
+ plugin.leak = leak
453
+ this.error(new Error(leak))
454
+ },
455
+ }
456
+ return plugin
457
+ }
458
+
459
+ /**
460
+ * Build the SSR worker with Vite, emitting the SAME {@link ServerBuild} `buildServer` (Bun) does: a
461
+ * self-contained worker bundle whose path is returned as `worker`.
462
+ *
463
+ * Like `buildServer`, it codegens the statically-analyzable `server-manifest` next to the entry (so no
464
+ * `node:fs` route scan runs on a disk-less edge), then bundles. `ssr.noExternal` bundles the app + adapter
465
+ * + `@nifrajs/*` into ONE file (the self-contained worker), while `node:` builtins stay external on the
466
+ * `node`/`bun` targets that provide them. `NIFRA_SSR_BUNDLED` is defined so the web-react adapter uses the
467
+ * bundled, deduped react-dom rather than re-rooting to a disk copy at runtime - the same tag `buildServer`
468
+ * sets, and the reason a bundled SSR output does not hit the dual-React crash.
469
+ *
470
+ * The client-leak guards are deliberately NOT run here: a server bundle's `node:` imports are legitimate.
471
+ */
472
+ export async function buildServerVite(options: BuildServerViteOptions): Promise<ServerBuild> {
473
+ const { routesDir, serverEntry, outDir, clientEntry, styles, routeStyles } = options
474
+ const root = resolvePath(options.root ?? dirname(routesDir))
475
+ const entryDir = dirname(serverEntry)
476
+ const manifestFile = options.manifestFile ?? "server-manifest.ts"
477
+ const rel = relative(entryDir, routesDir).replaceAll("\\", "/")
478
+ const resolve = options.resolve ?? ((file: string) => `./${rel}/${file}`)
479
+ mkdirSync(outDir, { recursive: true })
480
+
481
+ const target = options.target ?? "browser"
482
+ const edge = target === "browser"
483
+ // Same conditions as buildServer: the edge target needs workerd/edge-light so react-dom resolves its
484
+ // edge server build; node/bun resolve their own via the target condition.
485
+ const conditions = options.conditions ?? (edge ? ["workerd", "edge-light", "browser"] : [target])
486
+
487
+ const routeManifest = discoverRoutes(routesDir)
488
+ writeFileSync(
489
+ `${entryDir}/${manifestFile}`,
490
+ generateServerManifest(routeManifest, { resolve, clientEntry, styles, routeStyles }),
491
+ )
492
+
493
+ // Pin the mode to match NODE_ENV — the react plugin's JSX runtime (jsx vs jsxDEV) follows it, and a
494
+ // mismatch throws `jsxDEV is not a function` at SSR (see buildClientVite for the full reasoning).
495
+ const mode = options.minify === false ? "development" : "production"
496
+ const vite = await loadVite()
497
+ const edgeGuard = edgeBuiltinGuard()
498
+ try {
499
+ await withSerializedNodeEnv(mode, () =>
500
+ vite.build({
501
+ root,
502
+ mode,
503
+ logLevel: "silent",
504
+ define: {
505
+ "process.env.NODE_ENV": JSON.stringify(mode),
506
+ // Tag the bundle so @nifrajs/web-react uses the bundled+deduped react-dom (no runtime re-root →
507
+ // no second React core). Layered after any caller define so it cannot be overridden.
508
+ ...(options.define ?? {}),
509
+ "process.env.NIFRA_SSR_BUNDLED": '"1"',
510
+ "globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
511
+ },
512
+ resolve: {
513
+ dedupe: ["react", "react-dom"],
514
+ conditions: [...conditions],
515
+ },
516
+ plugins: [...(options.vitePlugins ?? [])],
517
+ build: {
518
+ outDir,
519
+ emptyOutDir: false,
520
+ minify: options.minify !== false,
521
+ ssr: serverEntry,
522
+ rollupOptions: {
523
+ // Keep builtins external so Node/Bun use their native implementations. Edge builds add a
524
+ // generateBundle guard below, so an external specifier can never silently ship to workerd.
525
+ external: [/^node:/],
526
+ ...(edge ? { plugins: [edgeGuard] } : {}),
527
+ // ONE self-contained `server.js`. `inlineDynamicImports` forces the ENTRY chunk to absorb
528
+ // every module - the app, the adapter, react/react-dom, @nifrajs/* - so no second chunk is
529
+ // emitted. It is NOT redundant with `ssr.noExternal`: `noExternal` decides what gets bundled,
530
+ // this decides how many files it lands in. Without it the `node` SSR target splits vendor deps
531
+ // (verified: a React app emits `assets/react-<hash>.js`), and the deploy assembly copies only
532
+ // the entry, so the server dies at boot with ERR_MODULE_NOT_FOUND on a path that was never
533
+ // written. bun/deno/edge happen not to split today, which is exactly why the node regression
534
+ // went unnoticed - relying on a bundler's default chunking is not a contract.
535
+ // (Under rolldown-vite this option logs a cosmetic deprecation, silenced by `logLevel: silent`.)
536
+ output: { entryFileNames: "server.js", inlineDynamicImports: true },
537
+ },
538
+ // Bundle the app + adapter + @nifrajs/* into one self-contained worker (like Bun's eager output),
539
+ // rather than externalizing node_modules the way a default Vite SSR build does.
540
+ ssrEmitAssets: false,
541
+ },
542
+ ssr: {
543
+ noExternal: true,
544
+ ...(edge ? { target: "webworker" } : {}),
545
+ },
546
+ }),
547
+ )
548
+ } catch (error) {
549
+ // Re-raise the guard's own message from here, where no bundler error-reporting path can rewrite it
550
+ // (see the same handling in buildClientVite).
551
+ if (edgeGuard.leak !== undefined) throw new Error(edgeGuard.leak, { cause: error })
552
+ throw error
553
+ }
554
+
555
+ const worker = join(outDir, "server.js")
556
+ if (!existsSync(worker)) {
557
+ throw new Error(`[nifra/web] Vite SSR build produced no ${worker}`)
558
+ }
559
+ return { worker, outputs: [worker] }
560
+ }
561
+
562
+ /**
563
+ * The Vite build STRATEGY — plugged into `buildTargetWith`. `plugins` arriving through the shared
564
+ * orchestrator are the app's Vite plugins (the escape hatch's whole reason), cast to Vite's plugin type.
565
+ */
566
+ export const viteBundler: Bundler = {
567
+ buildClient: (input) =>
568
+ buildClientVite({
569
+ routesDir: input.routesDir,
570
+ outDir: input.outDir,
571
+ clientModule: input.clientModule,
572
+ ...(input.plugins ? { vitePlugins: input.plugins } : {}),
573
+ ...(input.conditions ? { conditions: input.conditions } : {}),
574
+ ...(input.define ? { define: input.define } : {}),
575
+ ...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
576
+ ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
577
+ ...(input.root ? { root: input.root } : {}),
578
+ }),
579
+ buildServer: (input) =>
580
+ buildServerVite({
581
+ routesDir: input.routesDir,
582
+ serverEntry: input.serverEntry,
583
+ outDir: input.outDir,
584
+ clientEntry: input.clientEntry,
585
+ target: input.target,
586
+ ...(input.plugins ? { vitePlugins: input.plugins } : {}),
587
+ ...(input.define ? { define: input.define } : {}),
588
+ ...(input.root ? { root: input.root } : {}),
589
+ }),
590
+ }
591
+
592
+ /**
593
+ * Build a full deploy dir for `target` using the Vite/Rollup pipeline - the escape hatch for apps that
594
+ * need a Vite-only transform in production. Identical output shape to {@link import("./build.ts").buildTarget}
595
+ * (same deploy dir, same server entry, same prerender + size report), because both delegate to the ONE
596
+ * `buildTargetWith` orchestrator; only the two bundling steps differ.
597
+ *
598
+ * Pass the app's Vite plugins via `clientPlugins` / `serverPlugins` (the shared option names) - here they
599
+ * are Vite plugins, not Bun plugins.
600
+ */
601
+ export function buildTargetVite(
602
+ target: BuildTarget,
603
+ options: BuildTargetOptions,
604
+ ): Promise<BuildTargetResult> {
605
+ return buildTargetWith(target, options, viteBundler)
606
+ }