@nifrajs/web 2.9.1 → 2.11.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 (62) hide show
  1. package/dist/client.d.ts +12 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +16 -0
  4. package/dist/client.js.map +1 -1
  5. package/dist/dev-ssr-graph.d.ts +33 -0
  6. package/dist/dev-ssr-graph.d.ts.map +1 -0
  7. package/dist/dev-ssr-graph.js +285 -0
  8. package/dist/dev-ssr-graph.js.map +1 -0
  9. package/dist/dev.d.ts.map +1 -1
  10. package/dist/dev.js +76 -18
  11. package/dist/dev.js.map +1 -1
  12. package/dist/index.d.ts +28 -25
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +53 -79
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/head-merge.d.ts +27 -0
  17. package/dist/internal/head-merge.d.ts.map +1 -0
  18. package/dist/internal/head-merge.js +65 -0
  19. package/dist/internal/head-merge.js.map +1 -0
  20. package/dist/internal/server-boundary.d.ts +1 -2
  21. package/dist/internal/server-boundary.d.ts.map +1 -1
  22. package/dist/internal/server-boundary.js +1 -2
  23. package/dist/internal/server-boundary.js.map +1 -1
  24. package/dist/isr.d.ts +32 -3
  25. package/dist/isr.d.ts.map +1 -1
  26. package/dist/isr.js +181 -16
  27. package/dist/isr.js.map +1 -1
  28. package/dist/manifest.d.ts +2 -0
  29. package/dist/manifest.d.ts.map +1 -1
  30. package/dist/manifest.js.map +1 -1
  31. package/dist/plugins/css-modules.d.ts +12 -0
  32. package/dist/plugins/css-modules.d.ts.map +1 -1
  33. package/dist/plugins/css-modules.js +0 -0
  34. package/dist/plugins/css-modules.js.map +1 -1
  35. package/dist/plugins/kit.d.ts +54 -0
  36. package/dist/plugins/kit.d.ts.map +1 -1
  37. package/dist/plugins/kit.js +66 -0
  38. package/dist/plugins/kit.js.map +1 -1
  39. package/dist/public-dir.d.ts.map +1 -1
  40. package/dist/public-dir.js +71 -4
  41. package/dist/public-dir.js.map +1 -1
  42. package/dist/route-manifest.d.ts +3 -1
  43. package/dist/route-manifest.d.ts.map +1 -1
  44. package/dist/route-manifest.js +3 -0
  45. package/dist/route-manifest.js.map +1 -1
  46. package/dist/vite.d.ts.map +1 -1
  47. package/dist/vite.js +27 -1
  48. package/dist/vite.js.map +1 -1
  49. package/package.json +3 -3
  50. package/src/client.ts +28 -0
  51. package/src/dev-ssr-graph.ts +311 -0
  52. package/src/dev.ts +81 -20
  53. package/src/index.ts +63 -71
  54. package/src/internal/head-merge.ts +80 -0
  55. package/src/internal/server-boundary.ts +1 -2
  56. package/src/isr.ts +216 -19
  57. package/src/manifest.ts +2 -0
  58. package/src/plugins/css-modules.ts +0 -0
  59. package/src/plugins/kit.ts +71 -0
  60. package/src/public-dir.ts +74 -4
  61. package/src/route-manifest.ts +10 -1
  62. package/src/vite.ts +29 -1
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Per-module SSR invalidation for the **Bun pipeline's** dev server.
3
+ *
4
+ * Bun's runtime caches every module it has evaluated, and nothing invalidates that cache on a file
5
+ * change. The dev server's answer used to stop at the route module: `discoverRoutes({ importQuery })`
6
+ * appends a changing query to each route's own dynamic import, so the route re-evaluates. Its imports
7
+ * do not - a component, a helper, a `*.server` module all keep resolving to the same cached specifier,
8
+ * so SSR kept rendering the code that was on disk when the server started while the client, which Bun
9
+ * rebuilds properly, rendered the edit. Every framework on this pipeline had it; the Vite pipeline does
10
+ * not, because Vite owns its own SSR module graph.
11
+ *
12
+ * The fix is that module graph, in the small: watch what the server actually imported, notice which
13
+ * files changed, and give exactly those - and everything that imports them - a fresh cache key.
14
+ *
15
+ * ## Why a version per module, and why version 0 means no query
16
+ *
17
+ * A cache key is a `?v=<n>` query on the resolved absolute path. Bun treats a distinct query as a
18
+ * distinct registry entry and re-reads the file from disk (nifra's own Bun plugins already tolerate the
19
+ * suffix - see the `(\?|$)` in their filters), so a bump is a reload of that module and nothing else.
20
+ *
21
+ * That "nothing else" is the reason versions start at 0 and 0 rewrites nothing at all. A module that has
22
+ * never been edited keeps its bare path, which is the same registry entry the CLI already loaded when it
23
+ * read the app's config and backend - so a database client, a queue handle, any module-scope singleton
24
+ * shared between the backend and a route stays ONE instance. Versioning everything up front would have
25
+ * forked those on the first request. Editing such a module does fork it, deliberately: the routes get
26
+ * the new code and the already-running backend keeps the old, which is the same deal every dev server
27
+ * makes, and the same one a restart resolves.
28
+ *
29
+ * ## Why importers have to be bumped too
30
+ *
31
+ * Re-evaluating a module re-runs its imports through resolution, but a module that is NOT re-evaluated
32
+ * never re-resolves anything. With `route -> Layout -> Button` and an edit to `Button`, the route
33
+ * re-evaluates (its own query moved), asks for `Layout` under the key it always used, and gets the
34
+ * cached `Layout` back - which never mentions `Button` again. So a change propagates UP: the edited file
35
+ * and every transitive importer of it are bumped together, and the chain re-evaluates from the route
36
+ * down.
37
+ *
38
+ * ## Why the key is written into the source, not returned from `onResolve`
39
+ *
40
+ * Rewriting a resolution is the obvious mechanism and it only covers half of the imports people write.
41
+ * Bun's runtime consults resolver plugins for a specifier that carries a file extension
42
+ * (`"./Counter.tsx"`) and not for one that does not (`"./Counter"`, `"./components"`) - the extensionless
43
+ * form is resolved on an internal fast path that no plugin, in any namespace, is offered. Measured on
44
+ * Bun 1.3.14; the bundler has no such gap. Since extensionless is how most of this ecosystem's code is
45
+ * written, an `onResolve`-only fix would have silently covered a minority of real apps.
46
+ *
47
+ * So the version is applied one level earlier, in the importer's own source: on load, each app-owned
48
+ * module has its relative specifiers rewritten to absolute paths carrying the current version of the
49
+ * file they point at. `onResolve` stays as well, because it is the only hook that sees modules loaded by
50
+ * a third-party plugin this one does not wrap.
51
+ *
52
+ * Old versions stay in Bun's registry - a bounded dev-only leak, one entry per edit, which is what the
53
+ * route-level query already did.
54
+ *
55
+ * Bun-only + dev-only; never imported by the edge runtime or by a production build.
56
+ */
57
+ import { statSync } from "node:fs"
58
+ import { dirname } from "node:path"
59
+ import type { BunPlugin } from "bun"
60
+
61
+ /**
62
+ * Extensions worth a version. Source and data only: an asset (`.css`, `.svg`, an image) either has no
63
+ * server-side meaning or, like CSS Modules, compiles to a pure function of its path, so re-reading it
64
+ * would change nothing about the rendered HTML.
65
+ */
66
+ const VERSIONED = /\.(?:mts|cts|mjs|cjs|tsx|jsx|ts|js|svelte|vue|mdx|md|json)$/
67
+
68
+ /** Source this module can load and rewrite itself, when no framework plugin claims the file first. */
69
+ const REWRITABLE = "mts|cts|mjs|cjs|tsx|jsx|ts|js"
70
+
71
+ /**
72
+ * The `onLoad` filter, which has to do the whole job of selecting app source: a **runtime** `onLoad`
73
+ * handler may not decline a file it was offered (Bun raises `onLoad() expects an object returned`), so
74
+ * anything this matches is loaded here - and a dependency's `.js` loaded as ESM when the package meant
75
+ * CJS would break the app. Hence the shape: under the app root, no `node_modules` segment at any depth,
76
+ * no dot-directory (`.nifra-bun/`, `.git/`), and a source extension, with an optional cache-busting
77
+ * query. It mirrors {@link SsrGraph}'s ownership test; keep the two in step.
78
+ */
79
+ const appSourceFilter = (root: string): RegExp =>
80
+ new RegExp(
81
+ `^${root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/` +
82
+ `(?!.*/node_modules/)(?!\\.)(?!.*/\\.)[^?]*\\.(?:${REWRITABLE})(?:\\?|$)`,
83
+ )
84
+
85
+ /**
86
+ * `from "x"`, `import "x"`, `import("x")`, `require("x")` - every form that carries a static specifier,
87
+ * including `export ... from`. What it matches is only a candidate: a hit counts as an import only if the
88
+ * transpiler's own scan reported the same specifier, which is what keeps a lookalike string literal out.
89
+ */
90
+ const SPECIFIER = /\b(from|import|require)(\s*\(?\s*)(["'])([^"'\n]+)\3/g
91
+
92
+ export interface SsrGraphOptions {
93
+ /** The app root. Only files under it (and outside `node_modules`) are tracked. */
94
+ readonly root: string
95
+ }
96
+
97
+ export interface SsrGraph {
98
+ /**
99
+ * The Bun **runtime** plugin that records the graph and re-keys imports. Register it before the first
100
+ * route module is imported; a runtime plugin only affects modules loaded after it.
101
+ */
102
+ readonly plugin: BunPlugin
103
+ /**
104
+ * Re-stat every tracked module and bump the versions of the changed ones and their importers.
105
+ * Returns `true` when anything moved, i.e. when the app has to be rebuilt.
106
+ */
107
+ sweep(): boolean
108
+ /** Bumped by each {@link sweep} that found a change. Belongs in the key the built app is cached under. */
109
+ generation(): number
110
+ /** Release the process-wide hook {@link rewriteSsrImports} reads. */
111
+ dispose(): void
112
+ }
113
+
114
+ /** `/abs/path.ts?v=2` and `file:///abs/path.ts` both name `/abs/path.ts`. */
115
+ function plainPath(path: string): string {
116
+ const withoutQuery = path.split("?")[0] ?? path
117
+ return withoutQuery.startsWith("file://")
118
+ ? decodeURIComponent(withoutQuery.slice("file://".length))
119
+ : withoutQuery
120
+ }
121
+
122
+ const mtimeOf = (path: string): number =>
123
+ statSync(path, { throwIfNoEntry: false })?.mtimeMs ?? Number.NaN
124
+
125
+ /** What Bun should compile a file this module loaded itself as - the file's own extension decides. */
126
+ const loaderFor = (path: string): "tsx" | "ts" | "jsx" | "js" =>
127
+ path.endsWith(".tsx")
128
+ ? "tsx"
129
+ : path.endsWith(".jsx")
130
+ ? "jsx"
131
+ : /\.[cm]?ts$/.test(path)
132
+ ? "ts"
133
+ : "js"
134
+
135
+ /**
136
+ * What to PARSE a module as when scanning it for imports. `ts` is the safe default even for plain JS and
137
+ * for a plugin's compiled output (TS ⊃ JS), and the one thing it cannot also parse is JSX - which is why
138
+ * the JSX extensions are called out. Nothing here is emitted; the scan only reads specifiers.
139
+ */
140
+ const scanLoaderFor = (path: string): "tsx" | "ts" =>
141
+ path.endsWith(".tsx") || path.endsWith(".jsx") ? "tsx" : "ts"
142
+
143
+ /**
144
+ * The active graph, for the framework plugins that own a file extension this module cannot load itself
145
+ * (`.vue`, `.svelte`, `.tsx` under Solid, `.mdx`). Only one dev server runs per process.
146
+ */
147
+ let active: { rewrite(contents: string, path: string): string } | undefined
148
+
149
+ /**
150
+ * Re-key the app-owned imports of a module a **server-side** plugin just compiled. A plugin that claims a
151
+ * file extension is the only code that sees that file's source, so it is the only place its imports can
152
+ * be versioned; without this call every component in that language stays on the code it had at startup.
153
+ *
154
+ * A no-op outside a Bun dev server, and on the client half of a plugin - the client is bundled, and a
155
+ * bundle has no import cache to bust.
156
+ */
157
+ export function rewriteSsrImports(contents: string, path: string, generate: "dom" | "ssr"): string {
158
+ if (generate !== "ssr" || active === undefined) return contents
159
+ return active.rewrite(contents, plainPath(path))
160
+ }
161
+
162
+ /** Track and version the app's own server-side module graph. See the module doc for the whole design. */
163
+ export function createSsrGraph(options: SsrGraphOptions): SsrGraph {
164
+ const root = options.root.endsWith("/") ? options.root.slice(0, -1) : options.root
165
+ const prefix = `${root}/`
166
+ /** Every tracked module, by absolute path: its current cache version and the mtime it was read at. */
167
+ const nodes = new Map<string, { version: number; mtime: number }>()
168
+ /** Reverse edges - who imports this file - which is the direction invalidation travels. */
169
+ const importers = new Map<string, Set<string>>()
170
+ let generation = 0
171
+
172
+ /** Inside the app, outside `node_modules`, outside dot-directories, and a file worth re-reading. */
173
+ const owned = (abs: string): boolean => {
174
+ if (!abs.startsWith(prefix)) return false
175
+ if (abs.includes("/node_modules/")) return false
176
+ // Relative to the app, so a project that itself lives under a dotted directory is not excluded
177
+ // wholesale - this is about the app's own generated trees (`.nifra/`, `.nifra-bun/`, `.git/`).
178
+ const rel = abs.slice(prefix.length)
179
+ if (rel.startsWith(".") || rel.includes("/.")) return false
180
+ return VERSIONED.test(abs)
181
+ }
182
+
183
+ const track = (abs: string, importer: string): void => {
184
+ if (!nodes.has(abs)) nodes.set(abs, { version: 0, mtime: mtimeOf(abs) })
185
+ const parents = importers.get(abs)
186
+ if (parents === undefined) importers.set(abs, new Set([importer]))
187
+ else parents.add(importer)
188
+ }
189
+
190
+ // `Bun.resolveSync` re-enters this plugin's own `onResolve`, which is an unbounded recursion rather
191
+ // than a slow path. The flag is safe because the handler and `resolveSync` are both synchronous.
192
+ let resolving = false
193
+
194
+ const resolveFrom = (specifier: string, dir: string): string | undefined => {
195
+ resolving = true
196
+ try {
197
+ return Bun.resolveSync(specifier, dir)
198
+ } catch {
199
+ // Unresolvable here is not this module's error to report - leave it to Bun, which raises the
200
+ // diagnostic the app author can act on.
201
+ return undefined
202
+ } finally {
203
+ resolving = false
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Record `specifier` as an edge out of `importer` and return what it should be imported as, or
209
+ * `undefined` to leave the specifier exactly as the author wrote it.
210
+ */
211
+ const keyFor = (specifier: string, importer: string): string | undefined => {
212
+ const abs = resolveFrom(specifier, dirname(importer))
213
+ if (abs === undefined || !owned(abs)) return undefined
214
+ track(abs, importer)
215
+ const version = nodes.get(abs)?.version ?? 0
216
+ // Version 0 is the shared, unversioned instance - see the module doc.
217
+ return version === 0 ? undefined : `${abs}?v=${version}`
218
+ }
219
+
220
+ const rewrite = (contents: string, importer: string): string => {
221
+ if (!importer.startsWith(prefix) || importer.includes("/node_modules/")) return contents
222
+ let scanned: readonly { readonly path: string }[]
223
+ try {
224
+ scanned = new Bun.Transpiler({ loader: scanLoaderFor(importer) }).scanImports(contents)
225
+ } catch {
226
+ // Source Bun cannot parse is source Bun is about to reject with a real error message. Hand it
227
+ // through untouched rather than adding a second, worse one.
228
+ return contents
229
+ }
230
+ const candidates = new Set(scanned.map((entry) => entry.path).filter((p) => p.startsWith(".")))
231
+ if (candidates.size === 0) return contents
232
+ const keys = new Map<string, string>()
233
+ for (const specifier of candidates) {
234
+ const key = keyFor(specifier, importer)
235
+ if (key !== undefined) keys.set(specifier, key)
236
+ }
237
+ if (keys.size === 0) return contents
238
+ // Same length in lines and columns up to the specifier itself, so a stack trace still points at the
239
+ // line the author wrote.
240
+ return contents.replace(SPECIFIER, (whole, keyword, gap, quote, specifier) =>
241
+ keys.has(specifier) ? `${keyword}${gap}${quote}${keys.get(specifier)}${quote}` : whole,
242
+ )
243
+ }
244
+
245
+ const plugin: BunPlugin = {
246
+ name: "nifra-dev-ssr-graph",
247
+ setup(build) {
248
+ // Every app-owned module that no other plugin claims: read it, re-key its imports, hand it back.
249
+ // Loading it here is what makes the extensionless specifiers reachable at all (module doc).
250
+ build.onLoad({ filter: appSourceFilter(root) }, async (args) => {
251
+ const path = plainPath(args.path)
252
+ const contents = await Bun.file(path).text()
253
+ return { contents: rewrite(contents, path), loader: loaderFor(path) }
254
+ })
255
+ // The resolver half covers what the loader half cannot see: a module compiled by a plugin that
256
+ // does not call `rewriteSsrImports`. Extension-bearing specifiers only, which is all Bun offers.
257
+ build.onResolve({ filter: /^[./]/ }, (args) => {
258
+ if (resolving) return undefined
259
+ // A specifier that already carries a query is either this module's own rewrite or a plugin's
260
+ // virtual sub-request (`?vue-css` and friends) - both are already keyed.
261
+ if (args.path.includes("?")) return undefined
262
+ const importer = plainPath(args.importer)
263
+ if (!importer.startsWith(prefix) || importer.includes("/node_modules/")) return undefined
264
+ const key = keyFor(args.path, importer)
265
+ return key === undefined ? undefined : { path: key }
266
+ })
267
+ },
268
+ }
269
+
270
+ const sweep = (): boolean => {
271
+ const dirty = new Set<string>()
272
+ for (const [abs, node] of nodes) {
273
+ const mtime = mtimeOf(abs)
274
+ // NaN (deleted, or unreadable mid-save) never equals the recorded value, so it counts as changed
275
+ // and the next successful stat settles it.
276
+ if (mtime === node.mtime) continue
277
+ node.mtime = mtime
278
+ dirty.add(abs)
279
+ }
280
+ if (dirty.size === 0) return false
281
+ // Breadth-first up the reverse edges: an importer of a changed module is itself stale, because it
282
+ // holds the old binding and would not re-resolve on its own.
283
+ const queue = [...dirty]
284
+ for (let i = 0; i < queue.length; i++) {
285
+ const child = queue[i]
286
+ if (child === undefined) continue
287
+ for (const parent of importers.get(child) ?? []) {
288
+ if (dirty.has(parent)) continue
289
+ dirty.add(parent)
290
+ queue.push(parent)
291
+ }
292
+ }
293
+ for (const abs of dirty) {
294
+ const node = nodes.get(abs)
295
+ if (node !== undefined) node.version += 1
296
+ }
297
+ generation += 1
298
+ return true
299
+ }
300
+
301
+ const hook = { rewrite }
302
+ active = hook
303
+ return {
304
+ plugin,
305
+ sweep,
306
+ generation: () => generation,
307
+ dispose: () => {
308
+ if (active === hook) active = undefined
309
+ },
310
+ }
311
+ }
package/src/dev.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  * `@nifrajs/web/dev` - the **Bun pipeline's** dev server: one toolchain, no Vite in the process.
3
3
  *
4
4
  * nifra ships two dev pipelines and the rule between them is that a pipeline owns a whole phase. The
5
- * Vite server (`@nifrajs/web/vite`) is the default - mature framework plugins, and it resolves SSR as
6
- * well as the client so both halves agree on every specifier. This is the other one: `Bun.serve` bundles
7
- * and hot-reloads the client while Bun's runtime resolves SSR. Only one toolchain is present, so the two
5
+ * Vite server (`@nifrajs/web/vite`) is the Vite-only escape hatch - mature framework plugins, and it
6
+ * resolves SSR as well as the client so both halves agree on every specifier. This is the other one:
7
+ * `Bun.serve` bundles and hot-reloads the client while Bun's runtime resolves SSR. Only one toolchain is present, so the two
8
8
  * cannot disagree.
9
9
  *
10
10
  * What you get: React Fast Refresh WITH state preserved - Bun's dev server applies it natively, no plugin
@@ -14,9 +14,9 @@
14
14
  * not, and saving it does a clean full reload. Plus no Vite dependency and ONE bundler across dev and
15
15
  * production, which is the real prize - the dev/prod seam disappears.
16
16
  *
17
- * What you give up: `*.module.css`. Bun's DEV-server bundler has no CSS-Modules transform (its production
18
- * `Bun.build` does), so the import compiles to a dangling reference. Plain CSS and Tailwind are fine. The
19
- * CLI refuses `nifra dev --bun` for a CSS-Modules app rather than serving a broken client.
17
+ * CSS Modules, server functions, and `*.server` modules use the same production transforms through the
18
+ * generated Bun config. The CLI owns that config because Bun's HTML dev server accepts plugins only from
19
+ * `[serve.static] plugins`; direct callers should pass the equivalent plugin through their Bun config.
20
20
  *
21
21
  * ## How the two halves meet
22
22
  *
@@ -38,7 +38,10 @@
38
38
  * production, which reads CSS from the build manifest, is perfectly fine.
39
39
  *
40
40
  * SSR invalidation is Bun's import cache rather than Vite's module graph, so route modules are re-imported
41
- * under a changing query on each change - which is what `discoverRoutes({ importQuery })` exists for.
41
+ * under a changing query on each change - which is what `discoverRoutes({ importQuery })` exists for. That
42
+ * query stops at the route file, so everything BELOW it - components, helpers, `*.server` modules - is
43
+ * tracked and re-keyed per module by `./dev-ssr-graph.ts`, or SSR would render the code that was on disk
44
+ * when the server started.
42
45
  *
43
46
  * Bun-only + build-time; never imported by the edge runtime.
44
47
  */
@@ -56,8 +59,10 @@ import { type BuildClientOptions, buildClient } from "./build.ts"
56
59
  import { type DevEntryMatch, resolveDevEntry } from "./bun-dev-entry.ts"
57
60
  import { createDevDiagnostics } from "./dev-diagnostics.ts"
58
61
  import { explainBindFailure } from "./dev-port.ts"
62
+ import { createSsrGraph, type SsrGraph } from "./dev-ssr-graph.ts"
59
63
  import { discoverRoutes } from "./fs.ts"
60
64
  import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
65
+ import { DEV_HMR_ENV, DEV_ROOT_ENV, DEV_ROUTES_ENV } from "./plugins/kit.ts"
61
66
  import { servePublicDir } from "./public-dir.ts"
62
67
 
63
68
  export { LAST_ERROR_PATH } from "./diagnostic.ts"
@@ -244,13 +249,54 @@ export function injectStyles(html: string, styles: readonly string[]): string {
244
249
  export async function createDevServer(options: DevServerOptions): Promise<DevServer> {
245
250
  const { createApp, port = DEFAULT_DEV_PORT, routesDir, clientModule } = options
246
251
  const serve = bunServe()
252
+ // Tell the framework compiler plugins they are compiling for a DEV SERVER, so they may emit HMR
253
+ // wiring. It cannot be a plugin constructor argument: an app builds its `clientPlugins` once in
254
+ // `nifra.config.ts` and the same objects serve dev and `nifra build`. Guarding the emitted code with
255
+ // `if (import.meta.hot)` is not sufficient either - `Bun.build` keeps the branch, so the HMR calls
256
+ // ship to production. This flag is the phase signal; `nifra build` never sets it.
257
+ process.env[DEV_HMR_ENV] = "1"
258
+ // Dev-shaped runtimes for SSR, matching what the client bundle gets: a library ships its dev build
259
+ // behind the `development` export condition with a `NODE_ENV` fallback for resolvers that set no
260
+ // conditions, and Bun's runtime (where SSR runs) is one of those. `nifra dev` sets this on the server
261
+ // process before it reads the app config, which is earlier than this and is the point that matters -
262
+ // a framework runtime imported through the config has already read the flag by the time this line
263
+ // runs. This is the floor for a dev server started programmatically. `??=`, so a pinned value stands.
264
+ process.env.NODE_ENV ??= "development"
265
+ // `conditions` governs the production client bundle but CANNOT govern the one served here: Bun's
266
+ // dev-server bundler takes no resolve conditions, from `bunfig.toml` or anywhere else, so a package
267
+ // with an `exports` map can resolve to a different file in dev than in `nifra build`. SSR is fine -
268
+ // `nifra dev` passes these to the runtime as `--conditions` when it re-execs - which is exactly why
269
+ // this is worth a line: the halves of the dev process no longer agree, and the drift is invisible
270
+ // until production picks the other file. Said once at startup rather than swallowed.
271
+ if (options.conditions !== undefined && options.conditions.length > 0) {
272
+ console.warn(
273
+ `\n[nifra/web/dev] \`conditions: [${options.conditions.map((c) => `"${c}"`).join(", ")}]\` ` +
274
+ "does not reach the client bundle on the Bun dev pipeline.\n" +
275
+ " Bun's dev-server bundler accepts no resolve conditions, so a package with an `exports` map\n" +
276
+ " may resolve differently here than in `nifra build`. SSR does honour them.\n" +
277
+ " Run `nifra dev --vite` if this app needs exact dev/prod client resolution.\n",
278
+ )
279
+ }
247
280
  // The app root. `routesDir` is `<app>/routes` by convention, so its parent is the project - the one
248
281
  // place a generated entry can sit and still resolve the app's imports.
249
282
  const root = resolve(routesDir, "..")
283
+ // Which files are the app's own components, for the plugins that hot-patch at component granularity
284
+ // (`devHotComponent`). Announced here rather than passed, for the same reason the flag above is.
285
+ process.env[DEV_ROOT_ENV] = root
286
+ process.env[DEV_ROUTES_ENV] = resolve(routesDir)
250
287
  // The most recent SSR failure as a structured Diagnostic, scoped to THIS server. Served at
251
288
  // LAST_ERROR_PATH so an agent driving the dev server reads the exact failure (code, codeframe, fix) as
252
289
  // JSON instead of scraping the overlay. Shared with the Vite adapter so the endpoint can't drift.
253
290
  const devDiagnostics = createDevDiagnostics(root)
291
+ // SSR freshness BELOW the route module. The route-level `importQuery` only ever reloaded the route
292
+ // itself; everything it imports is tracked here and re-keyed when it changes. Registered now because a
293
+ // Bun runtime plugin only affects modules loaded after it, and the first route import is `appFor`
294
+ // below - the CLI's own plugins (CSS Modules, the app's `serverPlugins`) register earlier still, and
295
+ // being earlier is what gives them first refusal on the specifiers and file types they own. That
296
+ // ordering is deliberate: a framework plugin compiling `.vue`/`.svelte`/`.tsx` keeps its loader, and
297
+ // re-keys the imports of what it compiled by handing its output through `rewriteSsrImports`.
298
+ const ssrGraph = createSsrGraph({ root })
299
+ ;(await import("bun")).plugin(ssrGraph.plugin)
254
300
  const devDir = resolve(root, DEV_DIR)
255
301
  const entryPath = resolve(devDir, "entry.tsx")
256
302
  const htmlPath = resolve(devDir, "entry.html")
@@ -271,9 +317,9 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
271
317
  let server: BunServerHandle
272
318
  // The last resolved entry, with the time it was resolved. Deliberately short-lived - see `currentEntry`.
273
319
  let cache: { readonly entry: DevEntryMatch; readonly at: number } | undefined
274
- // The app, tagged with the entry hash it was built against - see `appFor`.
275
- let built: { readonly entry: string; readonly app: FetchApp } | undefined
276
- let building: { readonly entry: string; readonly promise: Promise<FetchApp> } | undefined
320
+ // The app, tagged with the build it was made for - see `appFor`.
321
+ let built: { readonly key: string; readonly app: FetchApp } | undefined
322
+ let building: { readonly key: string; readonly promise: Promise<FetchApp> } | undefined
277
323
  let version = 0
278
324
 
279
325
  /**
@@ -321,27 +367,38 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
321
367
  *
322
368
  * The watcher still runs: it regenerates the entry when routes are added or removed, and re-checks for
323
369
  * client leaks. It is no longer what keeps SSR correct.
370
+ *
371
+ * The entry hash is not the whole marker, though, because it only covers the CLIENT graph. A module
372
+ * the browser never receives - a `*.server` file, a loader's helper - can change without moving it, so
373
+ * the key also carries {@link SsrGraph} generation, which counts changes on the SERVER side. Either
374
+ * one moving rebuilds the app, which is what re-imports the route modules under a fresh query.
324
375
  */
325
- const appFor = (entrySrc: string): Promise<FetchApp> => {
326
- if (built?.entry === entrySrc) return Promise.resolve(built.app)
327
- if (building?.entry === entrySrc) return building.promise
376
+ const appFor = (key: string): Promise<FetchApp> => {
377
+ if (built?.key === key) return Promise.resolve(built.app)
378
+ if (building?.key === key) return building.promise
328
379
  version += 1
329
380
  const promise = Promise.resolve(createApp(CLIENT_ENTRY_PATH, `v=${version}`))
330
- building = { entry: entrySrc, promise }
381
+ building = { key, promise }
331
382
  void promise
332
383
  .then((next) => {
333
- if (building?.entry === entrySrc) {
334
- built = { entry: entrySrc, app: next }
384
+ if (building?.key === key) {
385
+ built = { key, app: next }
335
386
  building = undefined
336
387
  }
337
388
  })
338
389
  .catch(() => {
339
390
  // Clear the in-flight marker so the next request retries instead of re-awaiting a failed build.
340
- if (building?.entry === entrySrc) building = undefined
391
+ if (building?.key === key) building = undefined
341
392
  })
342
393
  return promise
343
394
  }
344
395
 
396
+ /** Check the server-side graph for changes, then hand back the app for what is on disk right now. */
397
+ const appForRequest = (entrySrc: string): Promise<FetchApp> => {
398
+ ssrGraph.sweep()
399
+ return appFor(`${entrySrc}#${ssrGraph.generation()}`)
400
+ }
401
+
345
402
  try {
346
403
  server = serve({
347
404
  port,
@@ -367,14 +424,17 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
367
424
  return new Response(body, { headers })
368
425
  }
369
426
  // Static probe before routing; a miss returns undefined and falls through, so no route is
370
- // shadowed and a page render never pays a filesystem stat (extension-less paths skip it).
427
+ // shadowed. Every GET/HEAD pays the probe, including page renders and API routes: the handler
428
+ // serves extension-less files too (an ACME challenge token is the reason), so there is no
429
+ // cheap prefilter to skip. Dev only - production serves `public/` from the CDN, and a caller
430
+ // mounting this handler itself can pass `files` to answer a miss without touching the disk.
371
431
  const staticFile = await servePublic(req)
372
432
  if (staticFile !== undefined) return staticFile
373
433
  try {
374
434
  // One fresh probe per request: it is both the freshness check for SSR and the stylesheet list,
375
435
  // so the page cannot be rendered against a build the browser is not about to load.
376
436
  const entry = await currentEntry(true)
377
- const res = await (await appFor(entry.src)).fetch(req)
437
+ const res = await (await appForRequest(entry.src)).fetch(req)
378
438
  if (!(res.headers.get("content-type") ?? "").includes("text/html")) return res
379
439
  if (entry.styles.length === 0) return res
380
440
  const headers = new Headers(res.headers)
@@ -405,7 +465,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
405
465
  try {
406
466
  // Build once up front so a dev server that cannot find the entry, or whose app fails to construct,
407
467
  // fails at startup with a real diagnosis instead of on the first page request.
408
- await appFor((await currentEntry()).src)
468
+ await appForRequest((await currentEntry()).src)
409
469
  } catch (err) {
410
470
  // Leaving the server up would answer 500s forever, which presents as a running server rather than as
411
471
  // the startup failure it is.
@@ -481,6 +541,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
481
541
  if (timer) clearTimeout(timer)
482
542
  clearInterval(topologyTimer)
483
543
  for (const file of watched) unwatchFile(file)
544
+ ssrGraph.dispose()
484
545
  server.stop(true)
485
546
  rmSync(devDir, { recursive: true, force: true })
486
547
  },