@nifrajs/web 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/build-vite.d.ts +68 -0
  2. package/dist/build-vite.d.ts.map +1 -0
  3. package/dist/build-vite.js +487 -0
  4. package/dist/build-vite.js.map +1 -0
  5. package/dist/build.d.ts +112 -25
  6. package/dist/build.d.ts.map +1 -1
  7. package/dist/build.js +283 -64
  8. package/dist/build.js.map +1 -1
  9. package/dist/bun-dev-entry.d.ts +83 -0
  10. package/dist/bun-dev-entry.d.ts.map +1 -0
  11. package/dist/bun-dev-entry.js +136 -0
  12. package/dist/bun-dev-entry.js.map +1 -0
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +10 -0
  15. package/dist/client.js.map +1 -1
  16. package/dist/deferred.d.ts +3 -2
  17. package/dist/deferred.d.ts.map +1 -1
  18. package/dist/deferred.js +11 -8
  19. package/dist/deferred.js.map +1 -1
  20. package/dist/dev-port.d.ts +36 -0
  21. package/dist/dev-port.d.ts.map +1 -0
  22. package/dist/dev-port.js +48 -0
  23. package/dist/dev-port.js.map +1 -0
  24. package/dist/dev.d.ts +61 -4
  25. package/dist/dev.d.ts.map +1 -1
  26. package/dist/dev.js +419 -60
  27. package/dist/dev.js.map +1 -1
  28. package/dist/draft.d.ts +37 -0
  29. package/dist/draft.d.ts.map +1 -1
  30. package/dist/draft.js +87 -4
  31. package/dist/draft.js.map +1 -1
  32. package/dist/fs.d.ts +14 -1
  33. package/dist/fs.d.ts.map +1 -1
  34. package/dist/fs.js +7 -1
  35. package/dist/fs.js.map +1 -1
  36. package/dist/index.d.ts +116 -5
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +565 -53
  39. package/dist/index.js.map +1 -1
  40. package/dist/internal/timing-safe-equal.d.ts +16 -0
  41. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  42. package/dist/internal/timing-safe-equal.js +26 -0
  43. package/dist/internal/timing-safe-equal.js.map +1 -0
  44. package/dist/isr.d.ts.map +1 -1
  45. package/dist/isr.js +1 -14
  46. package/dist/isr.js.map +1 -1
  47. package/dist/llms-txt.d.ts.map +1 -1
  48. package/dist/llms-txt.js +12 -6
  49. package/dist/llms-txt.js.map +1 -1
  50. package/dist/manifest.d.ts +51 -8
  51. package/dist/manifest.d.ts.map +1 -1
  52. package/dist/manifest.js +130 -14
  53. package/dist/manifest.js.map +1 -1
  54. package/dist/module-graph.d.ts +91 -0
  55. package/dist/module-graph.d.ts.map +1 -0
  56. package/dist/module-graph.js +92 -0
  57. package/dist/module-graph.js.map +1 -0
  58. package/dist/plugins/css-modules.d.ts.map +1 -1
  59. package/dist/plugins/css-modules.js +0 -0
  60. package/dist/plugins/css-modules.js.map +1 -1
  61. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  62. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  63. package/dist/plugins/vite-leak-guard.js +67 -0
  64. package/dist/plugins/vite-leak-guard.js.map +1 -0
  65. package/dist/public-dir.d.ts +36 -0
  66. package/dist/public-dir.d.ts.map +1 -0
  67. package/dist/public-dir.js +89 -0
  68. package/dist/public-dir.js.map +1 -0
  69. package/dist/route-manifest.d.ts +91 -0
  70. package/dist/route-manifest.d.ts.map +1 -0
  71. package/dist/route-manifest.js +128 -0
  72. package/dist/route-manifest.js.map +1 -0
  73. package/dist/router.d.ts +64 -1
  74. package/dist/router.d.ts.map +1 -1
  75. package/dist/router.js +114 -31
  76. package/dist/router.js.map +1 -1
  77. package/dist/vite.d.ts +10 -4
  78. package/dist/vite.d.ts.map +1 -1
  79. package/dist/vite.js +44 -12
  80. package/dist/vite.js.map +1 -1
  81. package/package.json +17 -2
  82. package/src/build-vite.ts +606 -0
  83. package/src/build.ts +432 -73
  84. package/src/bun-dev-entry.ts +184 -0
  85. package/src/client.ts +8 -0
  86. package/src/deferred.ts +13 -6
  87. package/src/dev-port.ts +63 -0
  88. package/src/dev.ts +467 -65
  89. package/src/draft.ts +116 -4
  90. package/src/fs.ts +22 -4
  91. package/src/index.ts +727 -57
  92. package/src/internal/timing-safe-equal.ts +24 -0
  93. package/src/isr.ts +1 -13
  94. package/src/llms-txt.ts +13 -5
  95. package/src/manifest.ts +188 -13
  96. package/src/module-graph.ts +146 -0
  97. package/src/plugins/css-modules.ts +0 -0
  98. package/src/plugins/vite-leak-guard.ts +110 -0
  99. package/src/public-dir.ts +108 -0
  100. package/src/route-manifest.ts +240 -0
  101. package/src/router.ts +198 -34
  102. package/src/vite.ts +62 -20
package/src/dev.ts CHANGED
@@ -1,13 +1,64 @@
1
1
  /**
2
- * `@nifrajs/web/dev` — a zero-config dev server (Bun-only). Builds the client unminified on boot,
3
- * serves the nifra app + hashed assets, and injects a live-reload client. Watches the routes dir:
4
- * on change it rebuilds the client and re-creates the app with a fresh `importQuery` (so SSR
5
- * picks up edited route modules past Bun's import cache), then tells the browser to reload.
2
+ * `@nifrajs/web/dev` — the **Bun pipeline's** dev server: one toolchain, no Vite in the process.
3
+ *
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
8
+ * cannot disagree.
9
+ *
10
+ * What you get: React Fast Refresh WITH state preserved — Bun's dev server applies it natively, no plugin
11
+ * (verified: editing a component-only module swaps its markup while a `useState` counter keeps its value,
12
+ * no reload). The usual boundary rule still applies, and it is the same rule Vite has: a module whose
13
+ * exports are all components is a refresh boundary, so a ROUTE file that also exports `loader`/`meta` is
14
+ * not, and saving it does a clean full reload. Plus no Vite dependency and ONE bundler across dev and
15
+ * production, which is the real prize — the dev/prod seam disappears.
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.
20
+ *
21
+ * ## How the two halves meet
22
+ *
23
+ * Bun's dev server bundles HTML routes. nifra renders the document itself, so there is no HTML file for
24
+ * Bun to rewrite - pages are produced per request by `createWebApp`. The join is a throwaway HTML route
25
+ * that exists only so Bun bundles the generated client entry and assigns it a hashed URL. nifra reads
26
+ * that URL back out (`./bun-dev-entry.ts`); Bun serves the chunk, and its HMR client - bundled into that
27
+ * same chunk - connects from nifra's pages exactly as it would from Bun's own.
28
+ *
29
+ * What pages actually reference is {@link CLIENT_ENTRY_PATH}, a stable nifra URL that redirects to the
30
+ * current chunk, because Bun's URL is a content hash over the whole client graph and moves on every
31
+ * rebuild. Injecting a remembered one is not a stale-cache annoyance but a hard failure: Bun answers a
32
+ * superseded chunk URL with a `location.reload()` stub, so the page reloads, SSR hands it the same dead
33
+ * URL, and it loops forever with no console output surviving to explain it.
34
+ *
35
+ * The stylesheets come across the same way, and they are the reason that probe page is read for more than
36
+ * a script URL: Bun lifts `import "./app.css"` out of the JS graph and links it from the page IT bundled.
37
+ * That page is the throwaway. Carry the links over or the whole dev session renders unstyled while
38
+ * production, which reads CSS from the build manifest, is perfectly fine.
39
+ *
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.
42
+ *
6
43
  * Bun-only + build-time; never imported by the edge runtime.
7
44
  */
8
- import { readdirSync, unwatchFile, watchFile } from "node:fs"
45
+ import {
46
+ mkdirSync,
47
+ readdirSync,
48
+ readFileSync,
49
+ rmSync,
50
+ unwatchFile,
51
+ watchFile,
52
+ writeFileSync,
53
+ } from "node:fs"
54
+ import { dirname, relative, resolve } from "node:path"
9
55
  import { type BuildClientOptions, buildClient } from "./build.ts"
10
- import { DEFAULT_DEV_PORT } from "./index.ts"
56
+ import { type DevEntryMatch, resolveDevEntry } from "./bun-dev-entry.ts"
57
+ import { renderDevErrorOverlay } from "./dev-error.ts"
58
+ import { explainBindFailure } from "./dev-port.ts"
59
+ import { discoverRoutes } from "./fs.ts"
60
+ import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
61
+ import { servePublicDir } from "./public-dir.ts"
11
62
 
12
63
  /** Minimal app surface the dev server needs — `createWebApp(...)` satisfies it. */
13
64
  interface FetchApp {
@@ -16,94 +67,445 @@ interface FetchApp {
16
67
 
17
68
  export interface DevServerOptions extends Omit<BuildClientOptions, "minify"> {
18
69
  /**
19
- * Build the nifra app for the freshly-built client entry. `importQuery` changes on every
20
- * rebuild — pass it to `discoverRoutes(routesDir, { importQuery })` so SSR re-imports edited
21
- * route modules instead of Bun's cached copies.
70
+ * Build the nifra app for the current client entry. `importQuery` changes on every reload — pass it to
71
+ * `discoverRoutes(routesDir, { importQuery })` so SSR re-imports edited route modules instead of Bun's
72
+ * cached copies.
22
73
  */
23
74
  readonly createApp: (clientEntry: string, importQuery: string) => FetchApp | Promise<FetchApp>
24
75
  /** Directories to watch (default: `[routesDir]`). */
25
76
  readonly watch?: readonly string[]
26
77
  /** Port to listen on (default {@link DEFAULT_DEV_PORT}). */
27
78
  readonly port?: number
79
+ /** Directory of user-authored static files served at the root (default `"public"`). The SAME
80
+ * option the production build copies and serves, so dev and prod cannot drift. */
81
+ readonly publicDir?: string | false
82
+ /**
83
+ * Run the client-leak guards on each change (default `true`).
84
+ *
85
+ * `buildClient` is what runs them - server-only code reaching the browser, `node:` builtins in client
86
+ * code - and Bun's dev server does its own bundling, so nothing would run them otherwise. They are
87
+ * security guards; a dev loop that stops enforcing them is how a leak reaches a deploy unnoticed. The
88
+ * pass runs in the background off the hot path, so HMR is never waiting on it, and only reports.
89
+ */
90
+ readonly guardLeaks?: boolean
28
91
  }
29
92
 
30
93
  export interface DevServer {
31
94
  readonly port: number
95
+ /**
96
+ * The URL pages point their client entry at — always {@link CLIENT_ENTRY_PATH}, never Bun's hashed
97
+ * chunk. Deliberately not the underlying chunk URL: that one moves on every rebuild, and anything
98
+ * holding onto it is holding onto a URL that will stop working.
99
+ */
100
+ readonly clientEntry: string
32
101
  stop(): void
33
102
  }
34
103
 
35
- const RELOAD_PATH = "/__nifra_dev"
36
- const RELOAD_TOPIC = "reload"
37
- // Live-reload client: reloads on a server "reload" push; reconnects if the socket drops.
38
- const reloadClient = `<script>(()=>{const x=()=>{const w=new WebSocket((location.protocol==="https:"?"wss:":"ws:")+"//"+location.host+"${RELOAD_PATH}");w.onmessage=()=>location.reload();w.onclose=()=>setTimeout(x,500)};x()})()</script>`
104
+ /**
105
+ * Where the generated dev files live. A dot-directory inside the project, not a temp dir: Bun's bundler
106
+ * resolves the entry's imports the way the runtime does, so it has to sit where the app's routes and
107
+ * `node_modules` are reachable from.
108
+ */
109
+ const DEV_DIR = ".nifra-bun"
110
+ /**
111
+ * The throwaway route serving the bundled HTML. Namespaced under `/__nifra/` so it cannot collide with an
112
+ * app route: file-based routing does not produce a leading double underscore.
113
+ */
114
+ const PROBE_PATH = "/__nifra/dev-entry"
115
+ /**
116
+ * The stable URL every SSR'd page points its client entry at.
117
+ *
118
+ * It has to be stable, and Bun's own URL is not: that one is a content hash over the entire client graph,
119
+ * so it changes whenever anything the entry imports changes. This path redirects to whichever hashed
120
+ * chunk Bun is serving at the moment the browser asks, which is the only formulation that stays correct
121
+ * across a rebuild.
122
+ */
123
+ export const CLIENT_ENTRY_PATH = "/__nifra/client.js"
124
+
125
+ /** Bun's `HTMLBundle` is opaque at the type level; it is only ever handed straight back to `Bun.serve`. */
126
+ type HtmlBundle = unknown
127
+
128
+ /** The `Bun.serve` surface this uses, typed structurally so the file builds without Bun's ambient types. */
129
+ interface BunServeOptions {
130
+ readonly port: number
131
+ readonly development: { readonly hmr: boolean }
132
+ readonly routes: Record<string, HtmlBundle>
133
+ fetch(request: Request): Promise<Response>
134
+ }
135
+ interface BunServerHandle {
136
+ readonly port: number
137
+ stop(closeActiveConnections?: boolean): void
138
+ }
139
+ type BunServe = (options: BunServeOptions) => BunServerHandle
140
+
141
+ function bunServe(): BunServe {
142
+ const serve = (globalThis as { Bun?: { serve?: unknown } }).Bun?.serve
143
+ if (typeof serve !== "function") {
144
+ throw new Error(
145
+ "[nifra] the Bun dev server needs the Bun runtime (`Bun.serve` is not available here). Run it " +
146
+ "under `bun`, or use the Vite pipeline dev server, which runs on Node's http server.",
147
+ )
148
+ }
149
+ return serve as BunServe
150
+ }
151
+
152
+ /** Write only when the content differs, so an unchanged file's mtime never moves. */
153
+ function writeIfChanged(path: string, content: string): void {
154
+ try {
155
+ if (readFileSync(path, "utf8") === content) return
156
+ } catch {
157
+ // missing or unreadable — fall through and write
158
+ }
159
+ writeFileSync(path, content)
160
+ }
161
+
162
+ /**
163
+ * The throwaway HTML document. Its only job is to make Bun bundle the entry and assign it a URL - it is
164
+ * never shown to a user, so it carries no app markup. The `<div id="root">` is there purely so the
165
+ * document stands on its own if someone opens the probe path directly while debugging.
166
+ */
167
+ export function devHtml(entryHref: string): string {
168
+ return (
169
+ `<!doctype html><html lang="en"><head><meta charset="utf-8">` +
170
+ `<title>nifra dev entry</title></head><body><div id="root"></div>` +
171
+ `<script type="module" src="${entryHref}"></script></body></html>`
172
+ )
173
+ }
174
+
175
+ interface WriteDevFilesOptions {
176
+ readonly routesDir: string
177
+ readonly clientModule: string
178
+ readonly entryPath: string
179
+ readonly htmlPath: string
180
+ }
181
+
182
+ /**
183
+ * Generate the client entry + the HTML route that carries it.
184
+ *
185
+ * Route specifiers are written **relative to the entry file**, not root-relative as the Vite path writes
186
+ * them: Vite resolves `/routes/x.tsx` against its configured root, while Bun's bundler resolves an import
187
+ * the way the runtime does, so a leading slash there would mean the filesystem root.
188
+ */
189
+ export function writeDevFiles(options: WriteDevFilesOptions): void {
190
+ const { routesDir, clientModule, entryPath, htmlPath } = options
191
+ mkdirSync(dirname(entryPath), { recursive: true })
192
+ const manifest = discoverRoutes(routesDir)
193
+ const entryDir = dirname(entryPath)
194
+ const toSpecifier = (file: string): string => {
195
+ const rel = relative(entryDir, `${routesDir}/${file}`).replaceAll("\\", "/")
196
+ // A bare relative path reads as a PACKAGE specifier to a bundler; `./` is what makes it a file.
197
+ return rel.startsWith(".") ? rel : `./${rel}`
198
+ }
199
+ // No `import.meta.hot.accept` here, deliberately. When an edit lands OUTSIDE a Fast Refresh boundary —
200
+ // a route file, which also exports `loader`/`meta` — Bun walks up to this generated entry, finds no
201
+ // `accept`, logs "hot update was not accepted" and does a full reload. That reload is correct: the route
202
+ // module's non-component exports changed, so patching it into the live tree would be wrong. Accepting
203
+ // here is worse than the warning: Bun re-evaluates the module BEFORE the accept callback runs, so the
204
+ // entry re-executes against a container React already mounted ("createRoot() on a container that has
205
+ // already been passed to createRoot()") and only then does the callback get to reload — trading an
206
+ // accurate warning for a real error. Editing a component-only module never reaches this path: Bun
207
+ // applies React Fast Refresh there and state is preserved.
208
+ writeIfChanged(entryPath, generateClientEntry(manifest, { clientModule, resolve: toSpecifier }))
209
+ const entryHref = `./${relative(dirname(htmlPath), entryPath).replaceAll("\\", "/")}`
210
+ writeIfChanged(htmlPath, devHtml(entryHref))
211
+ }
212
+
213
+ /** `<link rel="stylesheet">` tags for Bun's extracted CSS, injected into each SSR'd page's `<head>`. */
214
+ export function styleTags(styles: readonly string[]): string {
215
+ let html = ""
216
+ for (const href of styles) {
217
+ // Bun's asset URLs are hashes it generated, not user input; escaping the quote character is still
218
+ // the correct habit at an HTML boundary and costs nothing.
219
+ html += `<link rel="stylesheet" href="${href.replaceAll('"', "&quot;")}">`
220
+ }
221
+ return html
222
+ }
39
223
 
40
- /** Start the dev server: build → serve → watch → rebuild + reload on change. */
224
+ /**
225
+ * Inject Bun's stylesheet links into an SSR'd document.
226
+ *
227
+ * Prefers `</head>`; falls back to prepending when a document has no head (a bare fragment from a custom
228
+ * renderer). Never appends blindly at the end - a stylesheet after `</body>` still applies but arrives
229
+ * after first paint, so the page flashes unstyled and dev stops resembling production.
230
+ */
231
+ export function injectStyles(html: string, styles: readonly string[]): string {
232
+ if (styles.length === 0) return html
233
+ const tags = styleTags(styles)
234
+ const head = html.indexOf("</head>")
235
+ if (head !== -1) return html.slice(0, head) + tags + html.slice(head)
236
+ return tags + html
237
+ }
238
+
239
+ /** Start the Bun dev server: generate → bundle → serve → watch → hot-reload on change. */
41
240
  export async function createDevServer(options: DevServerOptions): Promise<DevServer> {
42
- const { createApp, port = DEFAULT_DEV_PORT, outDir, routesDir } = options
43
- const publicPath = options.publicPath ?? "/assets/"
241
+ const { createApp, port = DEFAULT_DEV_PORT, routesDir, clientModule } = options
242
+ const serve = bunServe()
243
+ // The app root. `routesDir` is `<app>/routes` by convention, so its parent is the project - the one
244
+ // place a generated entry can sit and still resolve the app's imports.
245
+ const root = resolve(routesDir, "..")
246
+ const devDir = resolve(root, DEV_DIR)
247
+ const entryPath = resolve(devDir, "entry.tsx")
248
+ const htmlPath = resolve(devDir, "entry.html")
249
+ const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
250
+ // Route dev's `public/` through the SAME handler production uses. Dev previously inherited this
251
+ // from Vite implicitly while production had no equivalent, which is the entire bug: two code paths
252
+ // with different defaults, so a file worked in dev and 404'd only once deployed.
253
+ const servePublic =
254
+ publicDir === undefined
255
+ ? async (): Promise<undefined> => undefined
256
+ : servePublicDir({ dir: resolve(publicDir) })
257
+
258
+ writeDevFiles({ routesDir, clientModule, entryPath, htmlPath })
259
+ // Importing the generated HTML is what hands it to Bun's bundler. A runtime-computed path is fine:
260
+ // Bun resolves and bundles the HTML, and everything it references, at import time.
261
+ const htmlModule = (await import(htmlPath)) as { default: HtmlBundle }
262
+
263
+ let server: BunServerHandle
264
+ // The last resolved entry, with the time it was resolved. Deliberately short-lived — see `currentEntry`.
265
+ let cache: { readonly entry: DevEntryMatch; readonly at: number } | undefined
266
+ // The app, tagged with the entry hash it was built against — see `appFor`.
267
+ let built: { readonly entry: string; readonly app: FetchApp } | undefined
268
+ let building: { readonly entry: string; readonly promise: Promise<FetchApp> } | undefined
44
269
  let version = 0
45
- const rebuild = async (): Promise<FetchApp> => {
46
- const manifest = await buildClient({ ...options, minify: false })
47
- return createApp(manifest.entry, `v=${version}`)
270
+
271
+ /**
272
+ * The client entry Bun is serving RIGHT NOW.
273
+ *
274
+ * Bun's entry URL is a content hash over the whole client graph, so any file it reaches re-hashes it.
275
+ * Resolving once at startup and injecting that value forever is the trap this exists to avoid: Bun
276
+ * answers a superseded chunk URL with a `location.reload()` stub, the page reloads, SSR hands it the
277
+ * same dead URL, and it reloads again - an invisible infinite loop, because each reload wipes the
278
+ * console that would have explained it.
279
+ *
280
+ * Re-resolving cannot be driven off the file watcher either. The watcher covers `routesDir`; the client
281
+ * graph includes components, styles and anything else a route imports, so an edit outside the watched
282
+ * tree would re-hash the entry with nothing to notice. Asking the dev server is the only answer that is
283
+ * right by construction.
284
+ *
285
+ * The TTL keeps that honest without making it expensive: a page render probes fresh (`force`), and the
286
+ * client-entry request that follows it milliseconds later reads the same cached answer - so the HTML and
287
+ * the script it points at always come from ONE probe, and cannot describe different builds.
288
+ */
289
+ const ENTRY_TTL_MS = 250
290
+ const currentEntry = async (force = false): Promise<DevEntryMatch> => {
291
+ const now = performance.now()
292
+ if (!force && cache !== undefined && now - cache.at < ENTRY_TTL_MS) return cache.entry
293
+ const entry = await resolveDevEntry(server, { probePath: PROBE_PATH })
294
+ cache = { entry, at: now }
295
+ return entry
48
296
  }
49
- let app = await rebuild()
50
-
51
- const server = Bun.serve({
52
- port,
53
- async fetch(req, srv) {
54
- const url = new URL(req.url)
55
- if (url.pathname === RELOAD_PATH) {
56
- return srv.upgrade(req) ? undefined : new Response("expected websocket", { status: 426 })
57
- }
58
- if (url.pathname.startsWith(publicPath)) {
59
- const file = Bun.file(`${outDir}/${url.pathname.slice(publicPath.length)}`)
60
- return (await file.exists())
61
- ? new Response(file, { headers: { "content-type": "text/javascript; charset=utf-8" } })
62
- : new Response("Not Found", { status: 404 })
63
- }
64
- const res = await app.fetch(req)
65
- if (!(res.headers.get("content-type") ?? "").includes("text/html")) return res
66
- const headers = new Headers(res.headers)
67
- headers.delete("content-length") // the body grows with the injected reload client
68
- const html = (await res.text()).replace("</body>", `${reloadClient}</body>`)
69
- return new Response(html, { status: res.status, headers })
70
- },
71
- websocket: {
72
- open: (ws) => ws.subscribe(RELOAD_TOPIC),
73
- message: () => {},
74
- },
75
- })
76
297
 
77
- // On a route change: rebuild the client, re-create the app with a fresh import cache-buster,
78
- // then push a reload. Debounced so a burst of saves rebuilds once.
298
+ /**
299
+ * The app built against the client Bun is currently serving.
300
+ *
301
+ * SSR freshness cannot be driven off the file watcher, and getting this wrong is subtle enough to be
302
+ * worth stating. Bun rebuilds the client and tells the browser to reload the instant a file is saved;
303
+ * the watcher here is `watchFile` mtime polling, a poll interval plus a debounce behind that. So the
304
+ * browser's reload request lands while SSR is still rendering the previous code - the server sends the
305
+ * old markup, the client boots the new module, and React reports a hydration mismatch and throws the
306
+ * server-rendered tree away. Transient, self-correcting on the next reload, and exactly the kind of
307
+ * dev-only weirdness nobody can reproduce on request.
308
+ *
309
+ * Ordering by request instead of by clock removes the race rather than shrinking it. Bun's entry hash is
310
+ * a content hash over the whole client graph, so it IS the version marker - and it is already being
311
+ * fetched to render the page. Rebuilding when it moves means SSR is, by construction, never behind the
312
+ * client that is about to hydrate it.
313
+ *
314
+ * The watcher still runs: it regenerates the entry when routes are added or removed, and re-checks for
315
+ * client leaks. It is no longer what keeps SSR correct.
316
+ */
317
+ const appFor = (entrySrc: string): Promise<FetchApp> => {
318
+ if (built?.entry === entrySrc) return Promise.resolve(built.app)
319
+ if (building?.entry === entrySrc) return building.promise
320
+ version += 1
321
+ const promise = Promise.resolve(createApp(CLIENT_ENTRY_PATH, `v=${version}`))
322
+ building = { entry: entrySrc, promise }
323
+ void promise
324
+ .then((next) => {
325
+ if (building?.entry === entrySrc) {
326
+ built = { entry: entrySrc, app: next }
327
+ building = undefined
328
+ }
329
+ })
330
+ .catch(() => {
331
+ // Clear the in-flight marker so the next request retries instead of re-awaiting a failed build.
332
+ if (building?.entry === entrySrc) building = undefined
333
+ })
334
+ return promise
335
+ }
336
+
337
+ try {
338
+ server = serve({
339
+ port,
340
+ development: { hmr: true },
341
+ // The probe route is the ONLY path Bun owns; everything else falls through to nifra's SSR.
342
+ routes: { [PROBE_PATH]: htmlModule.default },
343
+ fetch: async (req: Request): Promise<Response> => {
344
+ const url = new URL(req.url)
345
+ // The stable client-entry URL. Pages reference THIS; it redirects to whichever hashed chunk Bun
346
+ // is serving at the moment the browser asks. A redirect rather than a proxy so the module's final
347
+ // URL is Bun's own - its HMR client derives its socket and module identity from `import.meta.url`,
348
+ // and serving the bytes under a nifra path would quietly change both.
349
+ if (url.pathname === CLIENT_ENTRY_PATH) {
350
+ // Not forced: this lands right after the page render that just probed, so it reads that same
351
+ // answer and the document and its script are guaranteed to describe one build.
352
+ const entry = await currentEntry()
353
+ return new Response(null, { status: 307, headers: { location: entry.src } })
354
+ }
355
+ // Static probe before routing; a miss returns undefined and falls through, so no route is
356
+ // shadowed and a page render never pays a filesystem stat (extension-less paths skip it).
357
+ const staticFile = await servePublic(req)
358
+ if (staticFile !== undefined) return staticFile
359
+ try {
360
+ // One fresh probe per request: it is both the freshness check for SSR and the stylesheet list,
361
+ // so the page cannot be rendered against a build the browser is not about to load.
362
+ const entry = await currentEntry(true)
363
+ const res = await (await appFor(entry.src)).fetch(req)
364
+ if (!(res.headers.get("content-type") ?? "").includes("text/html")) return res
365
+ if (entry.styles.length === 0) return res
366
+ const headers = new Headers(res.headers)
367
+ headers.delete("content-length") // the body grows with the injected stylesheet links
368
+ return new Response(injectStyles(await res.text(), entry.styles), {
369
+ status: res.status,
370
+ headers,
371
+ })
372
+ } catch (err) {
373
+ return new Response(
374
+ renderDevErrorOverlay(err, {
375
+ method: req.method,
376
+ url: `${url.pathname}${url.search}`,
377
+ }),
378
+ { status: 500, headers: { "content-type": "text/html; charset=utf-8" } },
379
+ )
380
+ }
381
+ },
382
+ })
383
+ } catch (err) {
384
+ // `Bun.serve` throws synchronously on a bind failure; the Vite path's equivalent arrives as an async
385
+ // `error` event. Same explanation either way - see ./dev-port.ts for why it is worth spelling out.
386
+ throw explainBindFailure(err, port)
387
+ }
388
+
389
+ try {
390
+ // Build once up front so a dev server that cannot find the entry, or whose app fails to construct,
391
+ // fails at startup with a real diagnosis instead of on the first page request.
392
+ await appFor((await currentEntry()).src)
393
+ } catch (err) {
394
+ // Leaving the server up would answer 500s forever, which presents as a running server rather than as
395
+ // the startup failure it is.
396
+ server.stop(true)
397
+ throw err
398
+ }
399
+
400
+ const guard = options.guardLeaks !== false ? leakGuard(options) : undefined
401
+ guard?.()
402
+
403
+ // What is left for the watcher, now that request ordering keeps SSR fresh: regenerate the client entry
404
+ // when routes are ADDED or REMOVED (a scan result the request path never re-derives), and re-run the
405
+ // leak guards. The entry is rewritten only when the generated source actually differs - an edit inside
406
+ // an existing route does not change it, and rewriting unconditionally would touch the file Bun watches
407
+ // on every save and turn every hot update into a full client rebuild.
79
408
  let timer: ReturnType<typeof setTimeout> | undefined
80
409
  const onChange = (): void => {
81
410
  if (timer) clearTimeout(timer)
82
411
  timer = setTimeout(() => {
83
- version += 1
84
- rebuild()
85
- .then((next) => {
86
- app = next
87
- server.publish(RELOAD_TOPIC, "reload")
88
- console.log(`[nifra/web/dev] rebuilt + reloaded (v${version})`)
89
- })
90
- .catch((err) => console.error("[nifra/web/dev] rebuild failed:", err))
412
+ try {
413
+ writeDevFiles({ routesDir, clientModule, entryPath, htmlPath })
414
+ } catch (err) {
415
+ // A half-saved file can fail the scan mid-edit; the next change re-runs this.
416
+ console.error("[nifra/web/dev] client entry regeneration failed:", err)
417
+ }
418
+ guard?.()
91
419
  }, 60)
92
420
  }
93
- // Poll the source files for changes. Bun's fs.watch is unreliable here, so watchFile (mtime
94
- // polling) is used. New files need a restart to be tracked — fine for dev.
95
- const watched = (options.watch ?? [routesDir]).flatMap((dir) =>
96
- (readdirSync(dir, { recursive: true }) as string[])
97
- .filter((file) => /\.(tsx|jsx|ts|js|mdx|svelte|vue)$/.test(file))
98
- .map((file) => `${dir}/${file}`),
99
- )
100
- for (const file of watched) watchFile(file, { interval: 150 }, onChange)
421
+ // Poll file mtimes for content and rescan directory membership for add/remove. `watchFile` cannot
422
+ // discover a path that did not exist at startup, so the bounded topology scan owns that gap.
423
+ const watchRoots = options.watch ?? [routesDir]
424
+ const watched = new Set<string>()
425
+ const scan = (): Set<string> => {
426
+ const next = new Set<string>()
427
+ for (const dir of watchRoots) {
428
+ let files: string[]
429
+ try {
430
+ files = readdirSync(dir, { recursive: true }) as string[]
431
+ } catch {
432
+ continue
433
+ }
434
+ for (const file of files) {
435
+ if (/\.(tsx|jsx|ts|js|mdx|svelte|vue)$/.test(file)) next.add(`${dir}/${file}`)
436
+ }
437
+ }
438
+ return next
439
+ }
440
+ const syncWatchedFiles = (notify: boolean): void => {
441
+ const next = scan()
442
+ let topologyChanged = false
443
+ for (const file of watched) {
444
+ if (next.has(file)) continue
445
+ unwatchFile(file)
446
+ watched.delete(file)
447
+ topologyChanged = true
448
+ }
449
+ for (const file of next) {
450
+ if (watched.has(file)) continue
451
+ watched.add(file)
452
+ watchFile(file, { interval: 150 }, onChange)
453
+ topologyChanged = true
454
+ }
455
+ if (notify && topologyChanged) onChange()
456
+ }
457
+ syncWatchedFiles(false)
458
+ const topologyTimer = setInterval(() => syncWatchedFiles(true), 150)
459
+ topologyTimer.unref?.()
101
460
 
102
461
  return {
103
462
  port: server.port ?? port,
463
+ clientEntry: CLIENT_ENTRY_PATH,
104
464
  stop: () => {
465
+ if (timer) clearTimeout(timer)
466
+ clearInterval(topologyTimer)
105
467
  for (const file of watched) unwatchFile(file)
106
- server.stop()
468
+ server.stop(true)
469
+ rmSync(devDir, { recursive: true, force: true })
107
470
  },
108
471
  }
109
472
  }
473
+
474
+ /**
475
+ * A debounced, background client-leak check.
476
+ *
477
+ * Bun's dev server does its own bundling, so `buildClient` - which is where `detectServerOnlyInClient`
478
+ * and `detectNodeBuiltinsInClient` run - is no longer on the path that serves the app. Running it beside
479
+ * the dev loop keeps the guards enforced without HMR ever waiting on a full bundle. It only reports:
480
+ * failing the dev server on a leak would mean an in-progress edit can take the whole server down, and
481
+ * the build already blocks the actual deploy.
482
+ *
483
+ * Overlapping runs are collapsed - a save during a bundle queues exactly one re-run, so a burst of edits
484
+ * cannot pile up builds behind each other.
485
+ */
486
+ function leakGuard(options: DevServerOptions): () => void {
487
+ let running = false
488
+ let queued = false
489
+ const run = (): void => {
490
+ if (running) {
491
+ queued = true
492
+ return
493
+ }
494
+ running = true
495
+ buildClient({ ...options, minify: false })
496
+ .catch((err: unknown) => {
497
+ console.error(
498
+ `\n[nifra/web/dev] client-leak guard failed:\n${err instanceof Error ? err.message : String(err)}\n` +
499
+ " The dev server is still running. This will fail `nifra build`.\n",
500
+ )
501
+ })
502
+ .finally(() => {
503
+ running = false
504
+ if (queued) {
505
+ queued = false
506
+ run()
507
+ }
508
+ })
509
+ }
510
+ return run
511
+ }