@nifrajs/web 2.3.0 → 2.5.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.
package/src/vite.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  type ServerResponse,
18
18
  } from "node:http"
19
19
  import { relative, resolve as resolvePath } from "node:path"
20
- import { renderDevErrorOverlay } from "./dev-error.ts"
20
+ import { createDevDiagnostics } from "./dev-diagnostics.ts"
21
21
  import { listenOrExplain } from "./dev-port.ts"
22
22
  import { discoverRoutes } from "./fs.ts"
23
23
  import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
@@ -103,6 +103,8 @@ export interface ViteDevServer {
103
103
  stop(): Promise<void>
104
104
  }
105
105
 
106
+ export { LAST_ERROR_PATH } from "./diagnostic.ts"
107
+
106
108
  // The codegen'd client entry is written here (at the Vite root) so Vite serves + HMRs it.
107
109
  const DEV_ENTRY = ".nifra-vite-entry.tsx"
108
110
 
@@ -269,6 +271,22 @@ export function normalizeRolldownPlugins(
269
271
  })
270
272
  }
271
273
 
274
+ /**
275
+ * The npm package a bare module specifier belongs to (`@nifrajs/web-react/client` →
276
+ * `@nifrajs/web-react`, `some-adapter/client` → `some-adapter`), or `undefined` for a path-shaped
277
+ * specifier (relative/absolute), which names no package.
278
+ */
279
+ function packageNameOf(specifier: string): string | undefined {
280
+ if (specifier.startsWith(".") || specifier.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(specifier))
281
+ return undefined
282
+ const parts = specifier.split("/")
283
+ return specifier.startsWith("@")
284
+ ? parts.length >= 2
285
+ ? `${parts[0]}/${parts[1]}`
286
+ : undefined
287
+ : parts[0]
288
+ }
289
+
272
290
  /**
273
291
  * Start the Vite-backed dev server: Vite serves/HMRs the client; nifra SSRs each request and Vite
274
292
  * injects its HMR client + the framework refresh preamble via `transformIndexHtml`.
@@ -297,8 +315,19 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
297
315
  // `vite`, which is assigned just below before the server starts listening.
298
316
  let vite!: ViteLike
299
317
  let app: FetchApp
318
+ // The most recent SSR failure, served as JSON at LAST_ERROR_PATH. Shared with the Bun adapter so the
319
+ // endpoint and its headers can't drift between the two dev servers.
320
+ const devDiagnostics = createDevDiagnostics(root)
300
321
 
301
322
  const server: NodeHttpServer = createHttpServer((req, res) => {
323
+ // Keep this before Vite's middleware: it is an agent endpoint owned by nifra, not a file or app route.
324
+ if (devDiagnostics.isLastErrorPath((req.url ?? "/").split("?", 1)[0] ?? "/")) {
325
+ const { body, headers } = devDiagnostics.lastError()
326
+ res.statusCode = 200
327
+ for (const [key, value] of Object.entries(headers)) res.setHeader(key, value)
328
+ res.end(body)
329
+ return
330
+ }
302
331
  vite.middlewares(req, res, () => {
303
332
  // Not a Vite asset → nifra SSR. (`next` runs after Vite declines, so the body is still readable.)
304
333
  void (async () => {
@@ -322,9 +351,13 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
322
351
  // Source-map the stack first (Vite maps the bundled frames back to your `.ts`), then render
323
352
  // the readable dev overlay instead of a bare text dump. Dev-only - production maps to `_error`.
324
353
  if (err instanceof Error) vite.ssrFixStacktrace(err)
354
+ const html = devDiagnostics.capture(err, {
355
+ method: req.method ?? "GET",
356
+ url: req.url ?? "/",
357
+ })
325
358
  res.statusCode = 500
326
359
  res.setHeader("content-type", "text/html; charset=utf-8")
327
- res.end(renderDevErrorOverlay(err, { method: req.method ?? "GET", url: req.url ?? "/" }))
360
+ res.end(html)
328
361
  }
329
362
  })()
330
363
  })
@@ -332,6 +365,34 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
332
365
 
333
366
  // `conditions: ["bun"]` makes Vite resolve nifra's workspace packages (`@nifrajs/web-react/client`, …) to
334
367
  // their TS **source** - so the dev server needs no prior `dist` build of the adapter packages.
368
+ //
369
+ // That covers the CLIENT half only: Vite resolves SSR through `ssr.resolve.*`, which ignores
370
+ // `resolve.conditions`. Left at its defaults, SSR resolves a package's `default` export (`dist/…`)
371
+ // while the app's own imports of the same package run under Bun and resolve the `bun` export
372
+ // (`src/…`). Two consequences, both real bugs:
373
+ // 1. The adapter package loads TWICE - the app's Bun import (the adapter passed to `createWebApp`)
374
+ // and the route modules' Vite-SSR import - so `createContext` runs twice and the routing hooks
375
+ // (`useSearch`/`useParams`/`useLocation`) read a context the render never provided: they
376
+ // SSR-render their EMPTY defaults in dev while the loader (same request!) sees the real values.
377
+ // 2. SSR reads `dist`, which nothing in the dev loop rebuilds - a stale `dist` then 500s inside
378
+ // framework code and reads exactly like a framework regression.
379
+ // Fix, below: `ssr.resolve.{conditions,externalConditions}` mirror the client conditions (so SSR
380
+ // resolves the same files Bun does), and the adapter package + `@nifrajs/web` are `ssr.external`
381
+ // (so Bun's OWN module cache serves them to route modules - one instance, one `RouterContext`).
382
+ const resolveConditions = [
383
+ ...(options.conditions ?? []),
384
+ "bun",
385
+ "module",
386
+ "browser",
387
+ "development",
388
+ ]
389
+ // SSR must not pick `browser` builds; otherwise identical to the client list so both halves agree.
390
+ const ssrConditions = [...(options.conditions ?? []), "bun", "module", "node", "development"]
391
+ // The adapter package (from `clientModule`, e.g. `@nifrajs/web-react/client` → `@nifrajs/web-react`)
392
+ // - identity-sensitive (its `RouterContext` must be the ONE the Bun-imported adapter provides). A
393
+ // path-shaped `clientModule` (tests) names no package; there is then nothing to externalize.
394
+ const adapterPackage = packageNameOf(options.clientModule)
395
+ const ssrExternal = [...(adapterPackage !== undefined ? [adapterPackage] : []), "@nifrajs/web"]
335
396
  const usePolling = options.poll ?? process.env.CHOKIDAR_USEPOLLING === "1"
336
397
  // Never `import("vite")` directly here: the guard in `importVite` has to run first, and the dev
337
398
  // server importing vite unguarded is precisely what poisoned the module for the whole process.
@@ -359,7 +420,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
359
420
  // reads it, and the dev server is a client bundler like any other.
360
421
  plugins: [viteServerFnStub(), viteServerOnlyEmpty(), ...plugins],
361
422
  resolve: {
362
- conditions: [...(options.conditions ?? []), "bun", "module", "browser", "development"],
423
+ conditions: resolveConditions,
363
424
  // Dedupe React to ONE copy. In a multi-root workspace a shared package can pull react/react-dom
364
425
  // from a SIBLING app's node_modules, so the dev server loads two React cores → a second hook
365
426
  // dispatcher → `resolveDispatcher().useState` null on any hook-using route (the error points at the
@@ -367,6 +428,20 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
367
428
  // dev matches prod. No-op for non-React apps (the package simply isn't present to dedupe).
368
429
  dedupe: ["react", "react-dom"],
369
430
  },
431
+ ssr: {
432
+ // Explicit listing forces external even for workspace-LINKED packages (Vite's default keeps
433
+ // linked deps internal), so a monorepo dev run gets the same single-instance guarantee as an
434
+ // npm-installed one.
435
+ external: ssrExternal,
436
+ resolve: {
437
+ // Non-externalized deps (a linked `@platform/*`, say): Vite's runner evaluates them, but from
438
+ // the same files Bun would pick - `bun`-conditioned source, never a stale `dist`.
439
+ conditions: ssrConditions,
440
+ // Externalized deps: Vite resolves the specifier, Bun imports the result. `bun` first so the
441
+ // resolved file IS the one already in Bun's module cache (one evaluation, shared context).
442
+ externalConditions: ssrConditions,
443
+ },
444
+ },
370
445
  ...(options.define ? { define: options.define } : {}),
371
446
  })
372
447
 
@@ -395,7 +470,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
395
470
  }
396
471
 
397
472
  try {
398
- await listenOrExplain(server, port)
473
+ await listenOrExplain(server, port, "127.0.0.1")
399
474
  } catch (err) {
400
475
  // Nothing is listening, but Vite is fully up by now - watchers, the dep optimizer, its own sockets -
401
476
  // and every one of those keeps the event loop alive. Left open, the process prints the diagnosis and