@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
package/src/vite.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * `@nifrajs/web/vite` — a **dev-only** server with true HMR, backed by Vite in middleware mode. nifra's
2
+ * `@nifrajs/web/vite` - a **dev-only** server with true HMR, backed by Vite in middleware mode. nifra's
3
3
  * production pipeline stays Bun-native (`@nifrajs/web/build` → `Bun.build`); this is purely for the dev
4
4
  * loop. Bun's own HMR is dev-server-only and DCE's `import.meta.hot` under `Bun.build` (nifra's bundler),
5
5
  * so HMR comes from Vite + the framework's official Vite plugin (React Fast Refresh, Vue/Svelte/Solid
6
- * HMR) — which you inject via `plugins` (the same structural-injection idiom as the Bun/codec plugins).
6
+ * HMR) - which you inject via `plugins` (the same structural-injection idiom as the Bun/codec plugins).
7
7
  *
8
8
  * Flow: Vite serves + HMR-swaps the client modules (nifra's codegen'd entry + the route files); nifra
9
9
  * still **SSRs** each request, and the rendered HTML is run through `vite.transformIndexHtml` so Vite
10
10
  * injects its HMR client + the framework's refresh preamble. Node `http` (not `Bun.serve`) because
11
- * Vite's `middlewares` are Connect-style — it runs fine under Bun.
11
+ * Vite's `middlewares` are Connect-style - it runs fine under Bun.
12
12
  */
13
13
  import { writeFileSync } from "node:fs"
14
14
  import {
@@ -18,20 +18,29 @@ import {
18
18
  } from "node:http"
19
19
  import { relative, resolve as resolvePath } from "node:path"
20
20
  import { renderDevErrorOverlay } from "./dev-error.ts"
21
+ import { listenOrExplain } from "./dev-port.ts"
21
22
  import { discoverRoutes } from "./fs.ts"
22
23
  import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
24
+ import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
25
+ import { importVite } from "./internal/vite-import.ts"
26
+ import { viteServerFnStub } from "./plugins/vite-server-fn.ts"
27
+ import { viteServerOnlyEmpty } from "./plugins/vite-server-only.ts"
23
28
 
24
- /** Minimal app surface — `createWebApp(...)` satisfies it. */
29
+ /** Minimal app surface - `createWebApp(...)` satisfies it. */
25
30
  interface FetchApp {
26
31
  fetch(request: Request): Response | Promise<Response>
27
32
  }
28
33
 
29
34
  // Structural slice of the Vite dev server this module drives (avoids a hard type dep on `vite`).
30
35
  interface ViteLike {
36
+ /** Load a module through VITE's graph - the seam that makes the Vite pipeline own SSR too. */
37
+ ssrLoadModule(url: string): Promise<Record<string, unknown>>
31
38
  readonly middlewares: (req: IncomingMessage, res: ServerResponse, next: () => void) => void
32
39
  transformIndexHtml(url: string, html: string): Promise<string>
33
40
  ssrFixStacktrace(err: Error): void
34
- readonly watcher: { on(event: "change", cb: (path: string) => void): void }
41
+ readonly watcher: {
42
+ on(event: "change" | "add" | "unlink", cb: (path: string) => void): void
43
+ }
35
44
  close(): Promise<void>
36
45
  }
37
46
  interface ViteModule {
@@ -40,7 +49,7 @@ interface ViteModule {
40
49
  readonly rolldownVersion?: string
41
50
  }
42
51
 
43
- // `node:http` server type the request handler runs on (also the HMR WebSocket host — see below).
52
+ // `node:http` server type the request handler runs on (also the HMR WebSocket host - see below).
44
53
  type NodeHttpServer = ReturnType<typeof createHttpServer>
45
54
 
46
55
  export interface ViteDevServerOptions {
@@ -49,15 +58,22 @@ export interface ViteDevServerOptions {
49
58
  /** Client runtime module providing `mountRouter` (e.g. `"@nifrajs/web-react/client"`). */
50
59
  readonly clientModule: string
51
60
  /**
52
- * Build the nifra app for the given dev client-entry URL. `importQuery` changes when a route file
53
- * changes — thread it into `discoverRoutes(routesDir, { importQuery })` so a hard reload SSRs edited
54
- * modules (HMR handles the live client update; this keeps server-render fresh).
61
+ * Build the nifra app for the given dev client-entry URL.
62
+ *
63
+ * `load` resolves a route module through **Vite's** graph (`ssrLoadModule`). Pass it to
64
+ * `discoverRoutes(routesDir, { load })` so SSR and the client are resolved by the SAME toolchain.
65
+ * Without it SSR resolves through Bun while the client resolves through Vite - two resolvers, one
66
+ * process - which is what makes `resolve.dedupe` fail to reach SSR and produces the dual-React
67
+ * crash. Vite re-evaluates on change, so no `importQuery` cache-buster is needed alongside it.
55
68
  */
56
- readonly createApp: (clientEntry: string, importQuery: string) => FetchApp | Promise<FetchApp>
57
- /** Vite plugins — inject your framework's official plugin, e.g. `[react()]`. */
69
+ readonly createApp: (
70
+ clientEntry: string,
71
+ load: (absolutePath: string) => Promise<unknown>,
72
+ ) => FetchApp | Promise<FetchApp>
73
+ /** Vite plugins - inject your framework's official plugin, e.g. `[react()]`. */
58
74
  readonly plugins?: readonly unknown[]
59
75
  /**
60
- * Extra `resolve.conditions` prepended ahead of nifra's defaults — some frameworks need their own
76
+ * Extra `resolve.conditions` prepended ahead of nifra's defaults - some frameworks need their own
61
77
  * (e.g. Solid's `"solid"` condition routes `solid-js` to its source/JSX-dev build).
62
78
  */
63
79
  readonly conditions?: readonly string[]
@@ -72,10 +88,14 @@ export interface ViteDevServerOptions {
72
88
  readonly port?: number
73
89
  /**
74
90
  * Use polling for the file watcher. Native fs events (fsevents/inotify) are unreliable inside
75
- * containers, networked filesystems, and some sandboxes — there, HMR silently never fires. Set
91
+ * containers, networked filesystems, and some sandboxes - there, HMR silently never fires. Set
76
92
  * `true` (or the env var `CHOKIDAR_USEPOLLING=1`) to poll instead. Default: off (native events).
77
93
  */
78
94
  readonly poll?: boolean
95
+ /** Vite public directory. Defaults to `<root>/public`; `false` disables it. */
96
+ readonly publicDir?: string | false
97
+ /** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
98
+ readonly publicEnvPrefix?: string
79
99
  }
80
100
 
81
101
  export interface ViteDevServer {
@@ -114,11 +134,11 @@ function toWebRequest(req: IncomingMessage, body: Buffer | undefined): Request {
114
134
  }
115
135
 
116
136
  // A Vite `config` hook: a plain function, OR the object form `{ handler, order }` Vite accepts for hook
117
- // ordering (`order: "pre" | "post"`). We wrap the handler in either shape — see normalizeRolldownPlugins.
137
+ // ordering (`order: "pre" | "post"`). We wrap the handler in either shape - see normalizeRolldownPlugins.
118
138
  type ConfigFn = (config: unknown, env: unknown) => unknown
119
139
  type ConfigHook = ConfigFn | { readonly handler: ConfigFn; readonly order?: unknown }
120
140
 
121
- // A Vite plugin — the only hook we wrap is `config`. Typed structurally (no `vite` type dep). Everything
141
+ // A Vite plugin - the only hook we wrap is `config`. Typed structurally (no `vite` type dep). Everything
122
142
  // else on the plugin object is preserved by spread, so wrapping is transparent to Vite.
123
143
  interface VitePluginLike {
124
144
  readonly name?: string
@@ -126,7 +146,7 @@ interface VitePluginLike {
126
146
  readonly [key: string]: unknown
127
147
  }
128
148
 
129
- /** The bits of a Node ServerResponse `pipeWebBodyToNode` touches — structural, to avoid a node:http dep here. */
149
+ /** The bits of a Node ServerResponse `pipeWebBodyToNode` touches - structural, to avoid a node:http dep here. */
130
150
  interface NodeResLike {
131
151
  flushHeaders?(): void
132
152
  on(event: "close", cb: () => void): void
@@ -141,7 +161,7 @@ interface NodeHeaderSink {
141
161
 
142
162
  /**
143
163
  * Copy a Web `Response`'s headers onto a Node response, emitting EACH `Set-Cookie` as its own header. The
144
- * `Headers` iterator (and `.get`) join multiple set-cookie values with ", ", which corrupts cookies — e.g.
164
+ * `Headers` iterator (and `.get`) join multiple set-cookie values with ", ", which corrupts cookies - e.g.
145
165
  * better-auth's `session_token` + `session_data` collapse into one unparseable cookie and the session is
146
166
  * silently lost. `getSetCookie()` returns them split; Node's `setHeader` emits one header per array element.
147
167
  */
@@ -156,7 +176,7 @@ export function applyResponseHeaders(headers: Headers, res: NodeHeaderSink): voi
156
176
 
157
177
  /**
158
178
  * Stream a Web `Response` body to a Node response chunk-by-chunk. Buffering the whole body (e.g.
159
- * `arrayBuffer()`) waits for the stream to END — which an open-ended SSE (`text/event-stream`) body never
179
+ * `arrayBuffer()`) waits for the stream to END - which an open-ended SSE (`text/event-stream`) body never
160
180
  * does, so it hung `nifra dev` (the Bun production server streamed it fine). This flushes each chunk as it
161
181
  * arrives and cancels the reader if the client disconnects; a finite body just streams its chunk(s) + ends.
162
182
  */
@@ -178,23 +198,23 @@ export async function pipeWebBodyToNode(
178
198
  res.write(value)
179
199
  }
180
200
  } catch {
181
- // client disconnected mid-stream — the `close` handler already cancelled the reader
201
+ // client disconnected mid-stream - the `close` handler already cancelled the reader
182
202
  }
183
203
  res.end()
184
204
  }
185
205
 
186
206
  /**
187
207
  * Strip `optimizeDeps.rollupOptions.jsx` from a plugin's `config` hook output when running under
188
- * rolldown-vite — the source of the scary, harmless `Warning: Invalid input options … "jsx" Invalid
208
+ * rolldown-vite - the source of the scary, harmless `Warning: Invalid input options … "jsx" Invalid
189
209
  * key: Expected never but received "jsx"` on `nifra dev`.
190
210
  *
191
211
  * Why it happens: `@vitejs/plugin-react@4.x` (and peers) target an *older* rolldown-vite optimizeDeps
192
- * API — they inject `optimizeDeps.rollupOptions.jsx` to tell the dep pre-bundler to transform JSX. Vite
212
+ * API - they inject `optimizeDeps.rollupOptions.jsx` to tell the dep pre-bundler to transform JSX. Vite
193
213
  * 8's rolldown dep-optimizer renamed that surface to `optimizeDeps.rolldownOptions` (and moved jsx under
194
214
  * `transform.jsx`), so the stale `rollupOptions.jsx` is an unrecognized input option → the warning. It's
195
215
  * a version-skew artifact, not a real misconfig: the route source JSX transform runs through the
196
216
  * plugin's own `transform` hook (untouched here), and node_modules deps that get pre-bundled almost
197
- * never contain raw JSX — so dropping the dead key changes no behavior and keeps HMR/Fast Refresh
217
+ * never contain raw JSX - so dropping the dead key changes no behavior and keeps HMR/Fast Refresh
198
218
  * intact. We *strip* (rather than translate to `rolldownOptions`) so the fix is version-agnostic: a
199
219
  * plugin already emitting the correct `rolldownOptions` is left untouched, and a future plugin bump that
200
220
  * stops emitting `rollupOptions.jsx` makes this a no-op.
@@ -202,12 +222,12 @@ export async function pipeWebBodyToNode(
202
222
  * Scoped narrowly: only the `optimizeDeps.rollupOptions.jsx` key is removed, only under rolldown-vite,
203
223
  * and only from the value a plugin's `config` hook returns. Non-rolldown Vite is passed through verbatim.
204
224
  *
205
- * FLATTEN FIRST: a Vite plugin factory may return an ARRAY of plugins — `@vitejs/plugin-react`'s `react()`
225
+ * FLATTEN FIRST: a Vite plugin factory may return an ARRAY of plugins - `@vitejs/plugin-react`'s `react()`
206
226
  * returns `[vite:react-babel, vite:react-refresh]`, and it's `react:react-babel`'s `config` hook that emits
207
227
  * the offending `optimizeDeps.rollupOptions.jsx`. `nifra.config.ts` writes `vitePlugins = [react()]`, so the
208
228
  * plugin list arrives NESTED (`[[babel, refresh]]`). Without flattening, `.map` sees the inner array (which
209
- * has no `config`), leaves it untouched, and Vite — which flattens plugin arrays itself before running them
210
- * — then executes the un-stripped babel hook, so the warning survives. Flattening here (Vite accepts a flat
229
+ * has no `config`), leaves it untouched, and Vite - which flattens plugin arrays itself before running them
230
+ * - then executes the un-stripped babel hook, so the warning survives. Flattening here (Vite accepts a flat
211
231
  * list identically) is what lets the strip reach every real plugin.
212
232
  */
213
233
  export function normalizeRolldownPlugins(
@@ -237,7 +257,7 @@ export function normalizeRolldownPlugins(
237
257
  if (typeof handler !== "function") return plugin
238
258
  const wrappedHandler: ConfigFn = (config, env) => {
239
259
  const returned = handler(config, env)
240
- // The hook may return a promise — normalize both shapes.
260
+ // The hook may return a promise - normalize both shapes.
241
261
  return returned instanceof Promise ? returned.then(stripJsxKey) : stripJsxKey(returned)
242
262
  }
243
263
  // Preserve the ORIGINAL shape: a function stays a function; the object form keeps its `order`
@@ -259,21 +279,23 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
259
279
  const port = options.port ?? DEFAULT_DEV_PORT
260
280
 
261
281
  // Codegen the client entry with Vite-servable, root-relative specifiers (e.g. `/routes/index.tsx`).
262
- const manifest = discoverRoutes(routesDir)
263
282
  const toUrl = (file: string): string => `/${relative(root, `${routesDir}/${file}`)}`
264
- writeFileSync(
265
- resolvePath(root, DEV_ENTRY),
266
- generateClientEntry(manifest, { clientModule: options.clientModule, resolve: toUrl }),
267
- )
283
+ const writeClientEntry = (): void => {
284
+ const manifest = discoverRoutes(routesDir)
285
+ writeFileSync(
286
+ resolvePath(root, DEV_ENTRY),
287
+ generateClientEntry(manifest, { clientModule: options.clientModule, resolve: toUrl }),
288
+ )
289
+ }
290
+ writeClientEntry()
268
291
  const entryUrl = `/${DEV_ENTRY}`
269
292
 
270
293
  // Create our HTTP server FIRST, then hand it to Vite as the HMR WebSocket host (`hmr.server`). In
271
- // middleware mode Vite would otherwise open a *separate* ws port (24678) — fragile: it conflicts
294
+ // middleware mode Vite would otherwise open a *separate* ws port (24678) - fragile: it conflicts
272
295
  // across restarts ("Port undefined is already in use" → the client never connects). Sharing one
273
296
  // port means HMR rides the same origin as the app, robust across restarts. The handler closes over
274
297
  // `vite`, which is assigned just below before the server starts listening.
275
298
  let vite!: ViteLike
276
- let version = 0
277
299
  let app: FetchApp
278
300
 
279
301
  const server: NodeHttpServer = createHttpServer((req, res) => {
@@ -286,7 +308,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
286
308
  const contentType = nifraRes.headers.get("content-type") ?? ""
287
309
  res.statusCode = nifraRes.status
288
310
  if (!contentType.includes("text/html")) {
289
- // Data / redirect / asset response — pass through untouched, streamed (SSE-safe). Set-Cookie is
311
+ // Data / redirect / asset response - pass through untouched, streamed (SSE-safe). Set-Cookie is
290
312
  // emitted per-header so multi-cookie responses (e.g. better-auth sessions) aren't collapsed.
291
313
  applyResponseHeaders(nifraRes.headers, res)
292
314
  await pipeWebBodyToNode(nifraRes.body, res)
@@ -298,7 +320,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
298
320
  res.end(html)
299
321
  } catch (err) {
300
322
  // Source-map the stack first (Vite maps the bundled frames back to your `.ts`), then render
301
- // the readable dev overlay instead of a bare text dump. Dev-only — production maps to `_error`.
323
+ // the readable dev overlay instead of a bare text dump. Dev-only - production maps to `_error`.
302
324
  if (err instanceof Error) vite.ssrFixStacktrace(err)
303
325
  res.statusCode = 500
304
326
  res.setHeader("content-type", "text/html; charset=utf-8")
@@ -309,18 +331,23 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
309
331
  })
310
332
 
311
333
  // `conditions: ["bun"]` makes Vite resolve nifra's workspace packages (`@nifrajs/web-react/client`, …) to
312
- // their TS **source** — so the dev server needs no prior `dist` build of the adapter packages.
334
+ // their TS **source** - so the dev server needs no prior `dist` build of the adapter packages.
313
335
  const usePolling = options.poll ?? process.env.CHOKIDAR_USEPOLLING === "1"
314
- const viteModule = (await import("vite")) as unknown as ViteModule
336
+ // Never `import("vite")` directly here: the guard in `importVite` has to run first, and the dev
337
+ // server importing vite unguarded is precisely what poisoned the module for the whole process.
338
+ const viteModule = await importVite<ViteModule>()
315
339
  const { createServer } = viteModule
316
340
  // Under rolldown-vite (Vite 8+), strip the stale `optimizeDeps.rollupOptions.jsx` some framework
317
- // plugins still emit — it triggers a noisy "Invalid key … jsx" warning but does nothing useful here.
341
+ // plugins still emit - it triggers a noisy "Invalid key … jsx" warning but does nothing useful here.
318
342
  const plugins = normalizeRolldownPlugins(
319
343
  options.plugins ?? [],
320
344
  viteModule.rolldownVersion !== undefined,
321
345
  )
322
346
  vite = await createServer({
323
347
  root,
348
+ // Dev must not inherit Vite's default `VITE_*` exposure beside Nifra's documented boundary.
349
+ envPrefix: vitePublicEnvPrefix(options.publicEnvPrefix),
350
+ ...(options.publicDir !== undefined ? { publicDir: options.publicDir } : {}),
324
351
  appType: "custom",
325
352
  server: {
326
353
  middlewareMode: true,
@@ -328,34 +355,61 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
328
355
  // Explicit watch config; poll when native fs events aren't delivered (containers/sandboxes).
329
356
  watch: usePolling ? { usePolling: true, interval: 80 } : {},
330
357
  },
331
- plugins: [...plugins],
358
+ // Ahead of the user's plugins: a `*.fn` module must be replaced before anything else
359
+ // reads it, and the dev server is a client bundler like any other.
360
+ plugins: [viteServerFnStub(), viteServerOnlyEmpty(), ...plugins],
332
361
  resolve: {
333
362
  conditions: [...(options.conditions ?? []), "bun", "module", "browser", "development"],
334
363
  // Dedupe React to ONE copy. In a multi-root workspace a shared package can pull react/react-dom
335
364
  // from a SIBLING app's node_modules, so the dev server loads two React cores → a second hook
336
365
  // dispatcher → `resolveDispatcher().useState` null on any hook-using route (the error points at the
337
- // component, not the resolution — brutal to diagnose). Mirrors the build-time reactDedupePlugin so
366
+ // component, not the resolution - brutal to diagnose). Mirrors the build-time reactDedupePlugin so
338
367
  // dev matches prod. No-op for non-React apps (the package simply isn't present to dedupe).
339
368
  dedupe: ["react", "react-dom"],
340
369
  },
341
370
  ...(options.define ? { define: options.define } : {}),
342
371
  })
343
372
 
344
- app = await options.createApp(entryUrl, `v=${version}`)
373
+ const ssrLoad = (absolutePath: string): Promise<unknown> => vite.ssrLoadModule(absolutePath)
374
+ app = await options.createApp(entryUrl, ssrLoad)
345
375
 
346
- // Keep server-render fresh on edits (Vite already HMRs the client live; this is for a hard reload).
347
- vite.watcher.on("change", () => {
348
- version += 1
349
- Promise.resolve(options.createApp(entryUrl, `v=${version}`))
376
+ // Re-create the app on change so a hard reload picks up a route ADD/REMOVE (the manifest comes
377
+ // from a directory scan, which `ssrLoadModule` cannot invalidate). Module CONTENT no longer needs a
378
+ // version counter: Vite owns SSR resolution now and re-evaluates changed modules itself, which is
379
+ // the cache-busting the old `importQuery` was emulating against Bun's import cache.
380
+ let refreshVersion = 0
381
+ const refreshApp = (): void => {
382
+ const version = ++refreshVersion
383
+ Promise.resolve(options.createApp(entryUrl, ssrLoad))
350
384
  .then((next) => {
351
- app = next
385
+ if (version === refreshVersion) app = next
352
386
  })
353
387
  .catch((err) => console.error("[nifra/web/vite] app re-create failed:", err))
354
- })
388
+ }
389
+ vite.watcher.on("change", refreshApp)
390
+ for (const event of ["add", "unlink"] as const) {
391
+ vite.watcher.on(event, (path) => {
392
+ if (path.startsWith(`${routesDir}/`) || path.startsWith(`${routesDir}\\`)) writeClientEntry()
393
+ refreshApp()
394
+ })
395
+ }
355
396
 
356
- await new Promise<void>((done) => server.listen(port, done))
397
+ try {
398
+ await listenOrExplain(server, port)
399
+ } catch (err) {
400
+ // Nothing is listening, but Vite is fully up by now - watchers, the dep optimizer, its own sockets -
401
+ // and every one of those keeps the event loop alive. Left open, the process prints the diagnosis and
402
+ // then HANGS on it, which is worse than the raw crash this guard replaced: the user sees a dev server
403
+ // that appears to be starting. Tear Vite down so the failure is terminal.
404
+ await vite.close().catch(() => {})
405
+ throw err
406
+ }
407
+ // Report the port actually BOUND, not the one requested. They differ for `port: 0`, which is how you
408
+ // ask the OS for a free one - the correct thing to do in a test or when running several apps at once.
409
+ // Echoing the request back would return a literal 0, which connects to nothing.
410
+ const address = server.address()
357
411
  return {
358
- port,
412
+ port: typeof address === "object" && address !== null ? address.port : port,
359
413
  stop: async () => {
360
414
  server.close()
361
415
  await vite.close()