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