@nifrajs/web 2.10.0 → 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 (44) 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 +72 -17
  11. package/dist/dev.js.map +1 -1
  12. package/dist/index.d.ts +25 -24
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +46 -76
  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/plugins/css-modules.d.ts +12 -0
  25. package/dist/plugins/css-modules.d.ts.map +1 -1
  26. package/dist/plugins/css-modules.js +0 -0
  27. package/dist/plugins/css-modules.js.map +1 -1
  28. package/dist/plugins/kit.d.ts +54 -0
  29. package/dist/plugins/kit.d.ts.map +1 -1
  30. package/dist/plugins/kit.js +66 -0
  31. package/dist/plugins/kit.js.map +1 -1
  32. package/dist/vite.d.ts.map +1 -1
  33. package/dist/vite.js +27 -1
  34. package/dist/vite.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/client.ts +28 -0
  37. package/src/dev-ssr-graph.ts +311 -0
  38. package/src/dev.ts +77 -19
  39. package/src/index.ts +55 -70
  40. package/src/internal/head-merge.ts +80 -0
  41. package/src/internal/server-boundary.ts +1 -2
  42. package/src/plugins/css-modules.ts +0 -0
  43. package/src/plugins/kit.ts +71 -0
  44. package/src/vite.ts +29 -1
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,
@@ -377,7 +434,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
377
434
  // One fresh probe per request: it is both the freshness check for SSR and the stylesheet list,
378
435
  // so the page cannot be rendered against a build the browser is not about to load.
379
436
  const entry = await currentEntry(true)
380
- const res = await (await appFor(entry.src)).fetch(req)
437
+ const res = await (await appForRequest(entry.src)).fetch(req)
381
438
  if (!(res.headers.get("content-type") ?? "").includes("text/html")) return res
382
439
  if (entry.styles.length === 0) return res
383
440
  const headers = new Headers(res.headers)
@@ -408,7 +465,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
408
465
  try {
409
466
  // Build once up front so a dev server that cannot find the entry, or whose app fails to construct,
410
467
  // fails at startup with a real diagnosis instead of on the first page request.
411
- await appFor((await currentEntry()).src)
468
+ await appForRequest((await currentEntry()).src)
412
469
  } catch (err) {
413
470
  // Leaving the server up would answer 500s forever, which presents as a running server rather than as
414
471
  // the startup failure it is.
@@ -484,6 +541,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
484
541
  if (timer) clearTimeout(timer)
485
542
  clearInterval(topologyTimer)
486
543
  for (const file of watched) unwatchFile(file)
544
+ ssrGraph.dispose()
487
545
  server.stop(true)
488
546
  rmSync(devDir, { recursive: true, force: true })
489
547
  },
package/src/index.ts CHANGED
@@ -25,6 +25,10 @@ import {
25
25
  } from "./deferred.ts"
26
26
  import { isDraftEnabled } from "./draft.ts"
27
27
  import { trustedHeadAttributes } from "./internal/head-attributes.ts"
28
+ // Head resolution/merging. Defined in an internal module so `@nifrajs/web/client` can export the same
29
+ // two functions to the generated client entry without dragging THIS module (and its server code) into
30
+ // the browser graph - see internal/head-merge.ts. Re-exported here so the public API is unchanged.
31
+ import { mergeHeads, resolveMeta } from "./internal/head-merge.ts"
28
32
  import { jsStringLiteral } from "./internal/js-string.ts"
29
33
  import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
30
34
  import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "./isr.ts"
@@ -195,7 +199,7 @@ export {
195
199
  type SubmitOptions,
196
200
  } from "./router.ts"
197
201
  // Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
198
- export { type Deferred, defer }
202
+ export { type Deferred, defer, mergeHeads, resolveMeta }
199
203
 
200
204
  /** Phantom brand key for {@link ServerOnly}. A unique symbol so the brand can't be forged by a plain
201
205
  * object literal; `unique symbol` keys never appear in the emitted JS, so the brand is purely
@@ -311,6 +315,43 @@ export interface RenderAdapter {
311
315
  hydrationHead(nonce?: string): string
312
316
  }
313
317
 
318
+ /**
319
+ * Loads a module through the DEV SERVER'S module graph rather than the runtime's. Takes an absolute
320
+ * file path or a bare specifier; resolution, compilation and caching are the dev server's.
321
+ */
322
+ export type SsrModuleLoader = (id: string) => Promise<unknown>
323
+
324
+ /* A `globalThis` singleton for the same reason `@nifrajs/web-react`'s router context is one: in dev
325
+ * this module is evaluated twice in one process (the CLI's copy under Bun, and the app's copy), and a
326
+ * module-level `let` would leave the adapter reading a slot the dev server set on the other copy. */
327
+ const SSR_MODULE_LOADER_SLOT = Symbol.for("nifra.web.ssr-module-loader")
328
+ /* Explicitly `| undefined`: clearing the slot is part of the contract (a dev server clears it on
329
+ * stop), and under `exactOptionalPropertyTypes` "absent" and "set to undefined" are separate types. */
330
+ const loaderSlot = globalThis as { [SSR_MODULE_LOADER_SLOT]?: SsrModuleLoader | undefined }
331
+
332
+ /**
333
+ * Publishes the dev server's SSR module loader. Called by a dev server that owns SSR resolution
334
+ * itself; pass `undefined` to clear it on shutdown.
335
+ */
336
+ export function setSsrModuleLoader(load: SsrModuleLoader | undefined): void {
337
+ loaderSlot[SSR_MODULE_LOADER_SLOT] = load
338
+ }
339
+
340
+ /**
341
+ * The dev server's SSR module loader, or `undefined` when nothing owns SSR resolution but the runtime
342
+ * - the Bun dev pipeline, and every production build, where the adapter's assets are compiled ahead
343
+ * of time and a plain `import` is already the right thing.
344
+ *
345
+ * An adapter that must load a COMPILED asset at runtime (a `.svelte` component, say) has to prefer
346
+ * this over a bare `import`, and has to take the framework's SERVER RENDERER from it as well, not
347
+ * just the component. A component compiled by the dev server renders through the renderer the dev
348
+ * server resolved; mixing the two puts two copies of the framework runtime in one component tree,
349
+ * where context written by one half is invisible to the other.
350
+ */
351
+ export function ssrModuleLoader(): SsrModuleLoader | undefined {
352
+ return loaderSlot[SSR_MODULE_LOADER_SLOT]
353
+ }
354
+
314
355
  /** Global the server serializes loader data into; the client reads it to hydrate. */
315
356
  export const DATA_GLOBAL = "__NIFRA_DATA__"
316
357
  /** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
@@ -1287,12 +1328,6 @@ function headTags(head: Meta | undefined): string {
1287
1328
  return out
1288
1329
  }
1289
1330
 
1290
- /** Resolve a route's `meta` (static or a function of the loader data + params) to a {@link Meta}. */
1291
- export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
1292
- if (meta === undefined) return {}
1293
- return typeof meta === "function" ? meta(args) : meta
1294
- }
1295
-
1296
1331
  /**
1297
1332
  * A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
1298
1333
  * engines which URL is authoritative for a page (deduping query-string / tracking variants).
@@ -1379,68 +1414,6 @@ export function unsafeInlineScript(
1379
1414
  }
1380
1415
  }
1381
1416
 
1382
- /**
1383
- * Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
1384
- *
1385
- * The head contract (see {@link CreateWebAppOptions} and `LayoutEntry`): a `_layout.tsx` may export
1386
- * `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
1387
- * it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
1388
- * render chain), and merges:
1389
- * - **`title`** - *nearest-wins*: the last defined value across the list, so the page overrides an
1390
- * inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
1391
- * page `title` keeps it.
1392
- * - **`meta` / `link`** - *concatenated* in list order (outermost first, page last). Duplicate-tag
1393
- * de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
1394
- * can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
1395
- *
1396
- * Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
1397
- * static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
1398
- * fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
1399
- * content can vary with loader data anyway).
1400
- */
1401
- export function mergeHeads(heads: readonly Meta[]): Meta {
1402
- // Single-head fast path (a route with no layout `meta`, by far the common case) - return the
1403
- // resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
1404
- if (heads.length === 1) return heads[0] as Meta
1405
- let title: string | undefined
1406
- let lang: string | undefined
1407
- let dir: Meta["dir"]
1408
- const meta: MetaDescriptor[] = []
1409
- const link: LinkDescriptor[] = []
1410
- const script: ScriptDescriptor[] = []
1411
- const unsafeScript: UnsafeScriptDescriptor[] = []
1412
- for (const h of heads) {
1413
- if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
1414
- if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
1415
- if (h.dir !== undefined) dir = h.dir
1416
- if (h.meta !== undefined) meta.push(...h.meta)
1417
- if (h.link !== undefined) link.push(...h.link)
1418
- if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
1419
- if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
1420
- }
1421
- // Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
1422
- // `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
1423
- // is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
1424
- // `undefined`).
1425
- const merged: {
1426
- title?: string
1427
- meta?: Meta["meta"]
1428
- link?: Meta["link"]
1429
- script?: Meta["script"]
1430
- unsafeScript?: Meta["unsafeScript"]
1431
- lang?: string
1432
- dir?: Meta["dir"]
1433
- } = {}
1434
- if (title !== undefined) merged.title = title
1435
- if (meta.length > 0) merged.meta = meta
1436
- if (link.length > 0) merged.link = link
1437
- if (script.length > 0) merged.script = script
1438
- if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
1439
- if (lang !== undefined) merged.lang = lang
1440
- if (dir !== undefined) merged.dir = dir
1441
- return merged as Meta
1442
- }
1443
-
1444
1417
  export interface CreateWebAppOptions {
1445
1418
  readonly adapter: RenderAdapter
1446
1419
  readonly manifest: Manifest
@@ -2447,7 +2420,9 @@ export function generateClientEntry(
2447
2420
  }
2448
2421
 
2449
2422
  return `${[
2450
- 'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web"',
2423
+ // `/client`, never the root: the root's graph carries the server (renderPage, the static-file
2424
+ // server), and Vite's dev server evaluates what it is given instead of tree-shaking it.
2425
+ 'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"',
2451
2426
  'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
2452
2427
  // Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
2453
2428
  // access yields `undefined` if absent - unlike a named import, which would be a link error.
@@ -2464,6 +2439,16 @@ export function generateClientEntry(
2464
2439
  '`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
2465
2440
  )})`,
2466
2441
  "const errorBoundary = __adapter.errorBoundary",
2442
+ // A hot update that the framework cannot actually apply must end in a reload, not in nothing.
2443
+ //
2444
+ // Bun's dev server applies React Fast Refresh to JSX modules. For React that patches the component
2445
+ // and preserves state. For Preact it does not - the module still LOOKS Fast-Refresh-able, so the
2446
+ // update is accepted and then swallowed: `bun:beforeUpdate`/`bun:afterUpdate` both fire, the server
2447
+ // logs a rebuild, and the page silently keeps rendering the old code. An edit that appears to do
2448
+ // nothing is the worst failure mode in a dev loop - worse than a reload, because there is no signal
2449
+ // to act on. An adapter that knows its framework is in that position says so, and the entry turns
2450
+ // the swallowed update into an honest full reload.
2451
+ 'if (import.meta.hot && __adapter.hotUpdateNeedsReload) import.meta.hot.on("bun:afterUpdate", () => location.reload())',
2467
2452
  `const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
2468
2453
  // Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
2469
2454
  // route, shared layouts/deps deduped into shared chunks, so a route's code loads only when
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Head resolution + merging, in its own module so the CLIENT can have it without the server.
3
+ *
4
+ * These two are pure functions over `Meta`, but they used to live in `index.ts` - the module that also
5
+ * carries `renderPage`, `createWebApp` and the static-file server. The generated client entry imported
6
+ * them from `@nifrajs/web`, which under a bundler was harmless (server code tree-shakes away) and under
7
+ * Vite's dev server was fatal: it serves each module as-is, so pulling `index.ts` into the browser graph
8
+ * executed `public-dir.ts` and the page died on `Module "node:fs/promises" has been externalized`
9
+ * before hydrating. Splitting them out means the client entry never names the server module at all,
10
+ * rather than relying on a bundler to remove it afterwards.
11
+ *
12
+ * `index.ts` and `client.ts` both re-export from here, so the public API is unchanged in both places.
13
+ */
14
+ import type {
15
+ LinkDescriptor,
16
+ Meta,
17
+ MetaArgs,
18
+ MetaDescriptor,
19
+ MetaInput,
20
+ ScriptDescriptor,
21
+ UnsafeScriptDescriptor,
22
+ } from "../manifest.ts"
23
+
24
+ /** Resolve a route's `meta` export - a static object, or a function of the route's data/params. */
25
+ export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
26
+ if (meta === undefined) return {}
27
+ return typeof meta === "function" ? meta(args) : meta
28
+ }
29
+
30
+ /**
31
+ * Merge a layout chain's heads, outermost first: `title`/`lang`/`dir` are nearest-wins, and
32
+ * `meta`/`link`/`script` concatenate in chain order.
33
+ *
34
+ * Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
35
+ * static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
36
+ * fine: `headTags`'s memo is keyed on identity, so a per-request merge simply recomputes (its
37
+ * content can vary with loader data anyway).
38
+ */
39
+ export function mergeHeads(heads: readonly Meta[]): Meta {
40
+ // Single-head fast path (a route with no layout `meta`, by far the common case) - return the
41
+ // resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
42
+ if (heads.length === 1) return heads[0] as Meta
43
+ let title: string | undefined
44
+ let lang: string | undefined
45
+ let dir: Meta["dir"]
46
+ const meta: MetaDescriptor[] = []
47
+ const link: LinkDescriptor[] = []
48
+ const script: ScriptDescriptor[] = []
49
+ const unsafeScript: UnsafeScriptDescriptor[] = []
50
+ for (const h of heads) {
51
+ if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
52
+ if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
53
+ if (h.dir !== undefined) dir = h.dir
54
+ if (h.meta !== undefined) meta.push(...h.meta)
55
+ if (h.link !== undefined) link.push(...h.link)
56
+ if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
57
+ if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
58
+ }
59
+ // Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
60
+ // `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
61
+ // is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
62
+ // `undefined`).
63
+ const merged: {
64
+ title?: string
65
+ meta?: Meta["meta"]
66
+ link?: Meta["link"]
67
+ script?: Meta["script"]
68
+ unsafeScript?: Meta["unsafeScript"]
69
+ lang?: string
70
+ dir?: Meta["dir"]
71
+ } = {}
72
+ if (title !== undefined) merged.title = title
73
+ if (meta.length > 0) merged.meta = meta
74
+ if (link.length > 0) merged.link = link
75
+ if (script.length > 0) merged.script = script
76
+ if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
77
+ if (lang !== undefined) merged.lang = lang
78
+ if (dir !== undefined) merged.dir = dir
79
+ return merged as Meta
80
+ }
@@ -3,8 +3,7 @@
3
3
  *
4
4
  * The client build must never ship server-only code, or server-only config, to a browser. Three
5
5
  * conventions enforce that, and each has to mean the SAME thing in every pipeline that builds the client
6
- * - the Bun client build, the Vite dev server, the Vite production build, and `nifra dev --bun`'s refusal
7
- * (which cannot transform, so it refuses to start instead):
6
+ * - the Bun client build, the Vite dev server, the Vite production build, and `nifra dev --bun`:
8
7
  *
9
8
  * - `*.server` modules - emptied, so a secret / `node:` / native import subtree never reaches the client.
10
9
  * - `*.fn` modules - replaced with HTTP stubs, so the function bodies (and their imports) stay server-side.
Binary file
@@ -11,6 +11,16 @@ import type { BunPlugin } from "bun"
11
11
  /** The argument Bun passes to a plugin's `setup` - Bun doesn't export the type, so derive it. */
12
12
  export type PluginBuilder = Parameters<BunPlugin["setup"]>[0]
13
13
 
14
+ /**
15
+ * Re-keys the app-owned imports of a module the SERVER half of a plugin just compiled, so an edit below
16
+ * a route reaches SSR instead of being served from Bun's import cache. A plugin that claims a file
17
+ * extension is the only code that ever sees that file's source, so it is the only place its imports can
18
+ * be versioned - every `generate: "ssr"` loader should return its output through this.
19
+ *
20
+ * A no-op on the client half and outside a dev server. See `../dev-ssr-graph.ts` for the whole design.
21
+ */
22
+ export { rewriteSsrImports } from "../dev-ssr-graph.ts"
23
+
14
24
  const packageRootCache = new Map<string, string>()
15
25
 
16
26
  /** The nearest ancestor directory of `startDir` that holds a `package.json` (the file's package root),
@@ -57,6 +67,67 @@ export function hash8(input: string): string {
57
67
  return h.toString(16).padStart(8, "0")
58
68
  }
59
69
 
70
+ /**
71
+ * Env flag a nifra dev server sets for its own lifetime, so a framework compiler plugin can tell a dev
72
+ * compile from a `nifra build` one. Exported for the dev server that sets it; plugins ask
73
+ * {@link devServerCompile} instead of reading it.
74
+ */
75
+ export const DEV_HMR_ENV = "NIFRA_DEV_HMR"
76
+
77
+ /**
78
+ * True while a nifra dev server is running, i.e. the compile may emit HMR wiring (and, where the
79
+ * framework couples the two, dev-mode runtime output).
80
+ *
81
+ * The phase cannot be a plugin constructor argument: an app builds its `clientPlugins` once in
82
+ * `nifra.config.ts` and the same plugin objects serve both `nifra dev` and `nifra build`. Guarding the
83
+ * emitted code with `if (import.meta.hot)` is not sufficient either - `Bun.build` keeps the branch, so
84
+ * the HMR calls were measured shipping in production client chunks. The dev server announces itself in
85
+ * the environment instead, and `nifra build` never sets it. Read per compile (not captured at
86
+ * registration) so the flag is honoured however late the server sets it.
87
+ */
88
+ export function devServerCompile(): boolean {
89
+ return process.env[DEV_HMR_ENV] === "1"
90
+ }
91
+
92
+ /**
93
+ * The app root and the routes directory of the running dev server, for plugins that need to tell one of
94
+ * the app's own components from a dependency or a route module. Set alongside {@link DEV_HMR_ENV}; read
95
+ * through {@link devHotComponent}.
96
+ */
97
+ export const DEV_ROOT_ENV = "NIFRA_DEV_ROOT"
98
+ export const DEV_ROUTES_ENV = "NIFRA_DEV_ROUTES"
99
+
100
+ const under = (path: string, dir: string | undefined): boolean =>
101
+ dir !== undefined &&
102
+ dir !== "" &&
103
+ (path === dir || path.startsWith(dir.endsWith("/") ? dir : `${dir}/`))
104
+
105
+ /**
106
+ * Whether a dev compile of `path` may wrap the module in component-level hot-patching - i.e. whether it
107
+ * is one of the app's OWN components rather than a dependency or a route module.
108
+ *
109
+ * The exclusions are not caution, they are two things such a wrapper cannot survive:
110
+ *
111
+ * - a **dependency**, whose components are the framework's own composition machinery. An adapter folds
112
+ * a layout chain through them, so they are entered from a snippet or as a dynamic component - and a
113
+ * wrapper there sits between the SSR markup and the component that is supposed to claim it.
114
+ * - a **route module**, for the same reason one level down: a route or layout IS a chain member, so it
115
+ * is always the dynamic child. It is also the wrong boundary to patch - a route module carries the
116
+ * loader and meta the server ran, and re-running those is a navigation, not a patch. Editing one
117
+ * finds no accepting module and reloads the page, which is the correct outcome.
118
+ *
119
+ * Both cost nothing in practice: the components an edit loop actually touches are the views, and a view
120
+ * that wants to hot-patch lives outside the routes directory. Where the split is unknown (no dev server,
121
+ * or a root that was never announced), the answer is `false` - a full reload is always correct, a
122
+ * mispatched tree is not.
123
+ */
124
+ export function devHotComponent(path: string): boolean {
125
+ const root = process.env[DEV_ROOT_ENV]
126
+ if (!under(path, root)) return false
127
+ if (path.includes("/node_modules/")) return false
128
+ return !under(path, process.env[DEV_ROUTES_ENV])
129
+ }
130
+
60
131
  /**
61
132
  * Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module -
62
133
  * the idiom the Vue plugin established (`?vue-css`). Register one per plugin `setup`; call `emit` per
package/src/vite.ts CHANGED
@@ -20,9 +20,11 @@ import { relative, resolve as resolvePath } from "node:path"
20
20
  import { createDevDiagnostics } from "./dev-diagnostics.ts"
21
21
  import { listenOrExplain } from "./dev-port.ts"
22
22
  import { discoverRoutes } from "./fs.ts"
23
- import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
23
+ import { DEFAULT_DEV_PORT, generateClientEntry, setSsrModuleLoader } from "./index.ts"
24
24
  import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
25
25
  import { importVite } from "./internal/vite-import.ts"
26
+ import { scopedName } from "./plugins/css-modules.ts"
27
+ import { DEV_ROOT_ENV, DEV_ROUTES_ENV, reproduciblePath } from "./plugins/kit.ts"
26
28
  import { viteServerFnStub } from "./plugins/vite-server-fn.ts"
27
29
  import { viteServerOnlyEmpty } from "./plugins/vite-server-only.ts"
28
30
 
@@ -296,6 +298,12 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
296
298
  const routesDir = resolvePath(options.routesDir)
297
299
  const port = options.port ?? DEFAULT_DEV_PORT
298
300
 
301
+ // Which files are the app's own components, for framework plugins that hot-patch at component
302
+ // granularity (`devHotComponent`). Announced in the environment rather than passed, because the
303
+ // plugin that reads it is constructed by the app, in its own config, before this server exists.
304
+ process.env[DEV_ROOT_ENV] = root
305
+ process.env[DEV_ROUTES_ENV] = routesDir
306
+
299
307
  // Codegen the client entry with Vite-servable, root-relative specifiers (e.g. `/routes/index.tsx`).
300
308
  const toUrl = (file: string): string => `/${relative(root, `${routesDir}/${file}`)}`
301
309
  const writeClientEntry = (): void => {
@@ -442,10 +450,26 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
442
450
  externalConditions: ssrConditions,
443
451
  },
444
452
  },
453
+ css: {
454
+ modules: {
455
+ // Vite ships its own CSS-Modules naming (`_name_<base64>`); the Bun pipeline and every
456
+ // `nifra build` use `scopedName`. Left alone, one class is called two different things
457
+ // depending on which pipeline served it, so a selector written against a generated name works
458
+ // in dev and vanishes in production - and the two dev pipelines disagree with each other. The
459
+ // name is defined once, in the plugin, and Vite is handed that definition.
460
+ generateScopedName: (name: string, filename: string): string =>
461
+ scopedName(reproduciblePath(filename), name),
462
+ },
463
+ },
445
464
  ...(options.define ? { define: options.define } : {}),
446
465
  })
447
466
 
448
467
  const ssrLoad = (absolutePath: string): Promise<unknown> => vite.ssrLoadModule(absolutePath)
468
+ // Published so an ADAPTER can reach this graph too. Route modules already load through it; an
469
+ // adapter that has to load a compiled asset of its own on the server (`@nifrajs/web-svelte` and its
470
+ // `Chain.svelte`) would otherwise take it from the runtime, which has no compiler for it and no
471
+ // reason to agree with Vite about which copy of the framework runtime it renders through.
472
+ setSsrModuleLoader(ssrLoad)
449
473
  app = await options.createApp(entryUrl, ssrLoad)
450
474
 
451
475
  // Re-create the app on change so a hard reload picks up a route ADD/REMOVE (the manifest comes
@@ -486,6 +510,10 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
486
510
  return {
487
511
  port: typeof address === "object" && address !== null ? address.port : port,
488
512
  stop: async () => {
513
+ // Cleared with the server that owns it: the slot is process-global, so a stopped server leaving
514
+ // its loader behind would hand the next one's adapter a graph that is closed (tests start and
515
+ // stop several dev servers in one process).
516
+ setSsrModuleLoader(undefined)
489
517
  server.close()
490
518
  await vite.close()
491
519
  },