@nifrajs/web 2.2.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 (163) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +4 -4
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +20 -74
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +64 -42
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +100 -78
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +1 -1
  11. package/dist/bun-dev-entry.js +1 -1
  12. package/dist/chunk-names.js +1 -1
  13. package/dist/client.d.ts +3 -9
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +201 -29
  16. package/dist/client.js.map +1 -1
  17. package/dist/conformance.d.ts +2 -2
  18. package/dist/conformance.js +3 -3
  19. package/dist/deferred.d.ts +11 -11
  20. package/dist/deferred.d.ts.map +1 -1
  21. package/dist/deferred.js +16 -16
  22. package/dist/dev-error.d.ts +2 -2
  23. package/dist/dev-error.js +3 -3
  24. package/dist/dev-port.d.ts +2 -2
  25. package/dist/dev-port.js +1 -1
  26. package/dist/dev.d.ts +3 -3
  27. package/dist/dev.js +9 -9
  28. package/dist/draft.d.ts +12 -12
  29. package/dist/draft.js +11 -11
  30. package/dist/fn-state.d.ts +56 -0
  31. package/dist/fn-state.d.ts.map +1 -0
  32. package/dist/fn-state.js +77 -0
  33. package/dist/fn-state.js.map +1 -0
  34. package/dist/fn.d.ts +111 -0
  35. package/dist/fn.d.ts.map +1 -0
  36. package/dist/fn.js +125 -0
  37. package/dist/fn.js.map +1 -0
  38. package/dist/fonts-google.d.ts +9 -9
  39. package/dist/fonts-google.js +10 -10
  40. package/dist/fonts.d.ts +7 -7
  41. package/dist/fonts.js +1 -1
  42. package/dist/forms.d.ts +8 -8
  43. package/dist/fs.d.ts +1 -1
  44. package/dist/fs.js +1 -1
  45. package/dist/index.d.ts +151 -70
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +317 -139
  48. package/dist/index.js.map +1 -1
  49. package/dist/internal/head-attributes.d.ts +15 -0
  50. package/dist/internal/head-attributes.d.ts.map +1 -0
  51. package/dist/internal/head-attributes.js +99 -0
  52. package/dist/internal/head-attributes.js.map +1 -0
  53. package/dist/internal/script-types.d.ts +14 -0
  54. package/dist/internal/script-types.d.ts.map +1 -0
  55. package/dist/internal/script-types.js +17 -0
  56. package/dist/internal/script-types.js.map +1 -0
  57. package/dist/internal/server-boundary.d.ts +60 -0
  58. package/dist/internal/server-boundary.d.ts.map +1 -0
  59. package/dist/internal/server-boundary.js +174 -0
  60. package/dist/internal/server-boundary.js.map +1 -0
  61. package/dist/internal/timing-safe-equal.d.ts +1 -1
  62. package/dist/internal/timing-safe-equal.js +1 -1
  63. package/dist/internal/vite-import.d.ts +63 -0
  64. package/dist/internal/vite-import.d.ts.map +1 -0
  65. package/dist/internal/vite-import.js +94 -0
  66. package/dist/internal/vite-import.js.map +1 -0
  67. package/dist/islands.d.ts +5 -5
  68. package/dist/islands.js +5 -5
  69. package/dist/isr.d.ts +15 -15
  70. package/dist/isr.js +7 -7
  71. package/dist/manifest.d.ts +73 -35
  72. package/dist/manifest.d.ts.map +1 -1
  73. package/dist/manifest.js +10 -16
  74. package/dist/manifest.js.map +1 -1
  75. package/dist/module-graph.d.ts +5 -5
  76. package/dist/module-graph.js +4 -4
  77. package/dist/navigation.d.ts +120 -5
  78. package/dist/navigation.d.ts.map +1 -1
  79. package/dist/navigation.js +37 -5
  80. package/dist/navigation.js.map +1 -1
  81. package/dist/plugins/css-modules.d.ts +3 -3
  82. package/dist/plugins/css-modules.js +0 -0
  83. package/dist/plugins/kit.d.ts +8 -8
  84. package/dist/plugins/kit.js +9 -9
  85. package/dist/plugins/scss.js +3 -3
  86. package/dist/plugins/vite-leak-guard.d.ts +1 -1
  87. package/dist/plugins/vite-leak-guard.js +1 -1
  88. package/dist/plugins/vite-server-fn.d.ts +23 -0
  89. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  90. package/dist/plugins/vite-server-fn.js +51 -0
  91. package/dist/plugins/vite-server-fn.js.map +1 -0
  92. package/dist/plugins/vite-server-only.d.ts +49 -0
  93. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  94. package/dist/plugins/vite-server-only.js +57 -0
  95. package/dist/plugins/vite-server-only.js.map +1 -0
  96. package/dist/prerender.d.ts +5 -5
  97. package/dist/prerender.js +7 -7
  98. package/dist/query.d.ts +10 -10
  99. package/dist/query.js +15 -15
  100. package/dist/route-manifest.d.ts +1 -1
  101. package/dist/route-manifest.js +2 -2
  102. package/dist/router.d.ts +30 -21
  103. package/dist/router.d.ts.map +1 -1
  104. package/dist/router.js +57 -28
  105. package/dist/router.js.map +1 -1
  106. package/dist/search.d.ts +94 -0
  107. package/dist/search.d.ts.map +1 -0
  108. package/dist/search.js +232 -0
  109. package/dist/search.js.map +1 -0
  110. package/dist/server-only.d.ts +6 -6
  111. package/dist/server-only.js +7 -7
  112. package/dist/service-worker.d.ts +56 -0
  113. package/dist/service-worker.d.ts.map +1 -0
  114. package/dist/service-worker.js +127 -0
  115. package/dist/service-worker.js.map +1 -0
  116. package/dist/vite.d.ts +15 -13
  117. package/dist/vite.d.ts.map +1 -1
  118. package/dist/vite.js +31 -21
  119. package/dist/vite.js.map +1 -1
  120. package/package.json +33 -4
  121. package/server-manifest.d.ts +31 -0
  122. package/src/build-vite.ts +24 -77
  123. package/src/build.ts +137 -105
  124. package/src/bun-dev-entry.ts +1 -1
  125. package/src/chunk-names.ts +1 -1
  126. package/src/client.ts +215 -32
  127. package/src/conformance.ts +3 -3
  128. package/src/deferred.ts +17 -17
  129. package/src/dev-error.ts +3 -3
  130. package/src/dev-port.ts +2 -2
  131. package/src/dev.ts +12 -12
  132. package/src/draft.ts +15 -15
  133. package/src/fn-state.ts +107 -0
  134. package/src/fn.ts +199 -0
  135. package/src/fonts-google.ts +13 -13
  136. package/src/fonts.ts +7 -7
  137. package/src/forms.ts +8 -8
  138. package/src/fs.ts +2 -2
  139. package/src/index.ts +460 -181
  140. package/src/internal/head-attributes.ts +109 -0
  141. package/src/internal/script-types.ts +18 -0
  142. package/src/internal/server-boundary.ts +187 -0
  143. package/src/internal/timing-safe-equal.ts +1 -1
  144. package/src/internal/vite-import.ts +92 -0
  145. package/src/islands.ts +7 -7
  146. package/src/isr.ts +17 -17
  147. package/src/manifest.ts +83 -42
  148. package/src/module-graph.ts +6 -6
  149. package/src/navigation.ts +151 -5
  150. package/src/plugins/css-modules.ts +0 -0
  151. package/src/plugins/kit.ts +11 -11
  152. package/src/plugins/scss.ts +3 -3
  153. package/src/plugins/vite-leak-guard.ts +2 -2
  154. package/src/plugins/vite-server-fn.ts +67 -0
  155. package/src/plugins/vite-server-only.ts +75 -0
  156. package/src/prerender.ts +10 -10
  157. package/src/query.ts +22 -22
  158. package/src/route-manifest.ts +3 -3
  159. package/src/router.ts +91 -41
  160. package/src/search.ts +297 -0
  161. package/src/server-only.ts +7 -7
  162. package/src/service-worker.ts +165 -0
  163. package/src/vite.ts +41 -29
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 {
@@ -21,8 +21,12 @@ import { renderDevErrorOverlay } from "./dev-error.ts"
21
21
  import { listenOrExplain } from "./dev-port.ts"
22
22
  import { discoverRoutes } from "./fs.ts"
23
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"
24
28
 
25
- /** Minimal app surface — `createWebApp(...)` satisfies it. */
29
+ /** Minimal app surface - `createWebApp(...)` satisfies it. */
26
30
  interface FetchApp {
27
31
  fetch(request: Request): Response | Promise<Response>
28
32
  }
@@ -45,7 +49,7 @@ interface ViteModule {
45
49
  readonly rolldownVersion?: string
46
50
  }
47
51
 
48
- // `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).
49
53
  type NodeHttpServer = ReturnType<typeof createHttpServer>
50
54
 
51
55
  export interface ViteDevServerOptions {
@@ -66,10 +70,10 @@ export interface ViteDevServerOptions {
66
70
  clientEntry: string,
67
71
  load: (absolutePath: string) => Promise<unknown>,
68
72
  ) => FetchApp | Promise<FetchApp>
69
- /** Vite plugins — inject your framework's official plugin, e.g. `[react()]`. */
73
+ /** Vite plugins - inject your framework's official plugin, e.g. `[react()]`. */
70
74
  readonly plugins?: readonly unknown[]
71
75
  /**
72
- * 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
73
77
  * (e.g. Solid's `"solid"` condition routes `solid-js` to its source/JSX-dev build).
74
78
  */
75
79
  readonly conditions?: readonly string[]
@@ -84,12 +88,14 @@ export interface ViteDevServerOptions {
84
88
  readonly port?: number
85
89
  /**
86
90
  * Use polling for the file watcher. Native fs events (fsevents/inotify) are unreliable inside
87
- * 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
88
92
  * `true` (or the env var `CHOKIDAR_USEPOLLING=1`) to poll instead. Default: off (native events).
89
93
  */
90
94
  readonly poll?: boolean
91
95
  /** Vite public directory. Defaults to `<root>/public`; `false` disables it. */
92
96
  readonly publicDir?: string | false
97
+ /** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
98
+ readonly publicEnvPrefix?: string
93
99
  }
94
100
 
95
101
  export interface ViteDevServer {
@@ -128,11 +134,11 @@ function toWebRequest(req: IncomingMessage, body: Buffer | undefined): Request {
128
134
  }
129
135
 
130
136
  // A Vite `config` hook: a plain function, OR the object form `{ handler, order }` Vite accepts for hook
131
- // 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.
132
138
  type ConfigFn = (config: unknown, env: unknown) => unknown
133
139
  type ConfigHook = ConfigFn | { readonly handler: ConfigFn; readonly order?: unknown }
134
140
 
135
- // 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
136
142
  // else on the plugin object is preserved by spread, so wrapping is transparent to Vite.
137
143
  interface VitePluginLike {
138
144
  readonly name?: string
@@ -140,7 +146,7 @@ interface VitePluginLike {
140
146
  readonly [key: string]: unknown
141
147
  }
142
148
 
143
- /** 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. */
144
150
  interface NodeResLike {
145
151
  flushHeaders?(): void
146
152
  on(event: "close", cb: () => void): void
@@ -155,7 +161,7 @@ interface NodeHeaderSink {
155
161
 
156
162
  /**
157
163
  * Copy a Web `Response`'s headers onto a Node response, emitting EACH `Set-Cookie` as its own header. The
158
- * `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.
159
165
  * better-auth's `session_token` + `session_data` collapse into one unparseable cookie and the session is
160
166
  * silently lost. `getSetCookie()` returns them split; Node's `setHeader` emits one header per array element.
161
167
  */
@@ -170,7 +176,7 @@ export function applyResponseHeaders(headers: Headers, res: NodeHeaderSink): voi
170
176
 
171
177
  /**
172
178
  * Stream a Web `Response` body to a Node response chunk-by-chunk. Buffering the whole body (e.g.
173
- * `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
174
180
  * does, so it hung `nifra dev` (the Bun production server streamed it fine). This flushes each chunk as it
175
181
  * arrives and cancels the reader if the client disconnects; a finite body just streams its chunk(s) + ends.
176
182
  */
@@ -192,23 +198,23 @@ export async function pipeWebBodyToNode(
192
198
  res.write(value)
193
199
  }
194
200
  } catch {
195
- // client disconnected mid-stream — the `close` handler already cancelled the reader
201
+ // client disconnected mid-stream - the `close` handler already cancelled the reader
196
202
  }
197
203
  res.end()
198
204
  }
199
205
 
200
206
  /**
201
207
  * Strip `optimizeDeps.rollupOptions.jsx` from a plugin's `config` hook output when running under
202
- * 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
203
209
  * key: Expected never but received "jsx"` on `nifra dev`.
204
210
  *
205
211
  * Why it happens: `@vitejs/plugin-react@4.x` (and peers) target an *older* rolldown-vite optimizeDeps
206
- * 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
207
213
  * 8's rolldown dep-optimizer renamed that surface to `optimizeDeps.rolldownOptions` (and moved jsx under
208
214
  * `transform.jsx`), so the stale `rollupOptions.jsx` is an unrecognized input option → the warning. It's
209
215
  * a version-skew artifact, not a real misconfig: the route source JSX transform runs through the
210
216
  * plugin's own `transform` hook (untouched here), and node_modules deps that get pre-bundled almost
211
- * 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
212
218
  * intact. We *strip* (rather than translate to `rolldownOptions`) so the fix is version-agnostic: a
213
219
  * plugin already emitting the correct `rolldownOptions` is left untouched, and a future plugin bump that
214
220
  * stops emitting `rollupOptions.jsx` makes this a no-op.
@@ -216,12 +222,12 @@ export async function pipeWebBodyToNode(
216
222
  * Scoped narrowly: only the `optimizeDeps.rollupOptions.jsx` key is removed, only under rolldown-vite,
217
223
  * and only from the value a plugin's `config` hook returns. Non-rolldown Vite is passed through verbatim.
218
224
  *
219
- * 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()`
220
226
  * returns `[vite:react-babel, vite:react-refresh]`, and it's `react:react-babel`'s `config` hook that emits
221
227
  * the offending `optimizeDeps.rollupOptions.jsx`. `nifra.config.ts` writes `vitePlugins = [react()]`, so the
222
228
  * plugin list arrives NESTED (`[[babel, refresh]]`). Without flattening, `.map` sees the inner array (which
223
- * has no `config`), leaves it untouched, and Vite — which flattens plugin arrays itself before running them
224
- * — 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
225
231
  * list identically) is what lets the strip reach every real plugin.
226
232
  */
227
233
  export function normalizeRolldownPlugins(
@@ -251,7 +257,7 @@ export function normalizeRolldownPlugins(
251
257
  if (typeof handler !== "function") return plugin
252
258
  const wrappedHandler: ConfigFn = (config, env) => {
253
259
  const returned = handler(config, env)
254
- // The hook may return a promise — normalize both shapes.
260
+ // The hook may return a promise - normalize both shapes.
255
261
  return returned instanceof Promise ? returned.then(stripJsxKey) : stripJsxKey(returned)
256
262
  }
257
263
  // Preserve the ORIGINAL shape: a function stays a function; the object form keeps its `order`
@@ -285,7 +291,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
285
291
  const entryUrl = `/${DEV_ENTRY}`
286
292
 
287
293
  // Create our HTTP server FIRST, then hand it to Vite as the HMR WebSocket host (`hmr.server`). In
288
- // 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
289
295
  // across restarts ("Port undefined is already in use" → the client never connects). Sharing one
290
296
  // port means HMR rides the same origin as the app, robust across restarts. The handler closes over
291
297
  // `vite`, which is assigned just below before the server starts listening.
@@ -302,7 +308,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
302
308
  const contentType = nifraRes.headers.get("content-type") ?? ""
303
309
  res.statusCode = nifraRes.status
304
310
  if (!contentType.includes("text/html")) {
305
- // 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
306
312
  // emitted per-header so multi-cookie responses (e.g. better-auth sessions) aren't collapsed.
307
313
  applyResponseHeaders(nifraRes.headers, res)
308
314
  await pipeWebBodyToNode(nifraRes.body, res)
@@ -314,7 +320,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
314
320
  res.end(html)
315
321
  } catch (err) {
316
322
  // Source-map the stack first (Vite maps the bundled frames back to your `.ts`), then render
317
- // 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`.
318
324
  if (err instanceof Error) vite.ssrFixStacktrace(err)
319
325
  res.statusCode = 500
320
326
  res.setHeader("content-type", "text/html; charset=utf-8")
@@ -325,18 +331,22 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
325
331
  })
326
332
 
327
333
  // `conditions: ["bun"]` makes Vite resolve nifra's workspace packages (`@nifrajs/web-react/client`, …) to
328
- // 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.
329
335
  const usePolling = options.poll ?? process.env.CHOKIDAR_USEPOLLING === "1"
330
- 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>()
331
339
  const { createServer } = viteModule
332
340
  // Under rolldown-vite (Vite 8+), strip the stale `optimizeDeps.rollupOptions.jsx` some framework
333
- // 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.
334
342
  const plugins = normalizeRolldownPlugins(
335
343
  options.plugins ?? [],
336
344
  viteModule.rolldownVersion !== undefined,
337
345
  )
338
346
  vite = await createServer({
339
347
  root,
348
+ // Dev must not inherit Vite's default `VITE_*` exposure beside Nifra's documented boundary.
349
+ envPrefix: vitePublicEnvPrefix(options.publicEnvPrefix),
340
350
  ...(options.publicDir !== undefined ? { publicDir: options.publicDir } : {}),
341
351
  appType: "custom",
342
352
  server: {
@@ -345,13 +355,15 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
345
355
  // Explicit watch config; poll when native fs events aren't delivered (containers/sandboxes).
346
356
  watch: usePolling ? { usePolling: true, interval: 80 } : {},
347
357
  },
348
- 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],
349
361
  resolve: {
350
362
  conditions: [...(options.conditions ?? []), "bun", "module", "browser", "development"],
351
363
  // Dedupe React to ONE copy. In a multi-root workspace a shared package can pull react/react-dom
352
364
  // from a SIBLING app's node_modules, so the dev server loads two React cores → a second hook
353
365
  // dispatcher → `resolveDispatcher().useState` null on any hook-using route (the error points at the
354
- // 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
355
367
  // dev matches prod. No-op for non-React apps (the package simply isn't present to dedupe).
356
368
  dedupe: ["react", "react-dom"],
357
369
  },