@nifrajs/web 4.0.0 → 4.0.2

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
@@ -10,7 +10,7 @@
10
10
  * injects its HMR client + the framework's refresh preamble. Node `http` (not `Bun.serve`) because
11
11
  * Vite's `middlewares` are Connect-style - it runs fine under Bun.
12
12
  */
13
- import { writeFileSync } from "node:fs"
13
+ import { type Dirent, readdirSync, writeFileSync } from "node:fs"
14
14
  import {
15
15
  createServer as createHttpServer,
16
16
  type IncomingMessage,
@@ -170,6 +170,7 @@ export { LAST_ERROR_PATH } from "./diagnostic.ts"
170
170
  // The codegen'd client entry is written here (at the Vite root) so Vite serves + HMRs it.
171
171
  const DEV_ENTRY = ".nifra-vite-entry.tsx"
172
172
  const VITE_CLOSE_BOUND_MS = 2000
173
+ const POLL_INTERVAL_MS = 80
173
174
 
174
175
  /** Candidate spellings for Vite's module-graph map. Vite has used native and slash-normalized
175
176
  * absolute Windows paths across versions, while watcher events can additionally arrive as a file URL. */
@@ -184,6 +185,22 @@ const pathInside = (root: string, path: string): boolean => {
184
185
  return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
185
186
  }
186
187
 
188
+ /** Every file below `dir` as an absolute path. A directory removed mid-walk contributes nothing. */
189
+ const filesUnder = (dir: string, into = new Set<string>()): Set<string> => {
190
+ let entries: Dirent[]
191
+ try {
192
+ entries = readdirSync(dir, { withFileTypes: true })
193
+ } catch {
194
+ return into
195
+ }
196
+ for (const entry of entries) {
197
+ const path = join(dir, entry.name)
198
+ if (entry.isDirectory()) filesUnder(path, into)
199
+ else into.add(path)
200
+ }
201
+ return into
202
+ }
203
+
187
204
  const readNodeBody = async (req: IncomingMessage): Promise<Buffer | undefined> => {
188
205
  if (req.method === "GET" || req.method === "HEAD") return undefined
189
206
  const chunks: Buffer[] = []
@@ -415,6 +432,9 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
415
432
  generateClientEntry(manifest, { clientModule: options.clientModule, resolve: toUrl }),
416
433
  )
417
434
  }
435
+ const usePolling = options.poll ?? process.env.CHOKIDAR_USEPOLLING === "1"
436
+ // Listed before the entry is generated, so a file added in between is reconciled again, not lost.
437
+ const routeFiles = usePolling ? filesUnder(routesDir) : new Set<string>()
418
438
  writeClientEntry()
419
439
  const entryUrl = `/${DEV_ENTRY}`
420
440
 
@@ -578,7 +598,6 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
578
598
  // path-shaped `clientModule` (tests) names no package; there is then nothing to externalize.
579
599
  const adapterPackage = packageNameOf(options.clientModule)
580
600
  const ssrExternal = [...(adapterPackage !== undefined ? [adapterPackage] : []), "@nifrajs/web"]
581
- const usePolling = options.poll ?? process.env.CHOKIDAR_USEPOLLING === "1"
582
601
  // The feed writes its record and log under `.nifra/`: watching them would turn every log flush into
583
602
  // a "file change" that re-creates the app and marks every entry stale.
584
603
  const watch: { ignored: RegExp[]; usePolling?: boolean; interval?: number } = {
@@ -587,7 +606,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
587
606
  // Poll when native fs events aren't delivered (containers/sandboxes).
588
607
  if (usePolling) {
589
608
  watch.usePolling = true
590
- watch.interval = 80
609
+ watch.interval = POLL_INTERVAL_MS
591
610
  }
592
611
  // Never `import("vite")` directly here: the guard in `importVite` has to run first, and the dev
593
612
  // server importing vite unguarded is precisely what poisoned the module for the whole process.
@@ -722,20 +741,43 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
722
741
  session.markChange()
723
742
  refreshApp(normalizeFilePath(path))
724
743
  })
744
+ const onAddUnlink = (event: "add" | "unlink", path: string): void => {
745
+ const filePath = normalizeFilePath(path)
746
+ const inRoutes = pathInside(routesDir, filePath)
747
+ if (inRoutes && usePolling) {
748
+ const file = resolvePath(filePath)
749
+ // Whichever of the watcher and the rescan below reports a route file first handles it.
750
+ if (routeFiles.has(file) === (event === "add")) return
751
+ if (event === "add") routeFiles.add(file)
752
+ else routeFiles.delete(file)
753
+ }
754
+ session.markChange()
755
+ if (inRoutes) {
756
+ writeClientEntry()
757
+ // A newly added route is not in Vite's module graph yet, so invalidating only the
758
+ // route path cannot evict the cached generated entry that imports the route. Evict the
759
+ // generated entry after rewriting it before recreating the SSR app; this also prevents
760
+ // an unlink refresh from briefly loading an entry that still references the deleted file.
761
+ invalidateImporterClosure(resolvePath(root, DEV_ENTRY))
762
+ }
763
+ refreshApp(filePath)
764
+ }
725
765
  for (const event of ["add", "unlink"] as const) {
726
- vite.watcher.on(event, (path) => {
727
- session.markChange()
728
- const filePath = normalizeFilePath(path)
729
- if (pathInside(routesDir, filePath)) {
730
- writeClientEntry()
731
- // A newly added route is not in Vite's module graph yet, so invalidating only the
732
- // route path cannot evict the cached generated entry that imports the route. Evict the
733
- // generated entry after rewriting it before recreating the SSR app; this also prevents
734
- // an unlink refresh from briefly loading an entry that still references the deleted file.
735
- invalidateImporterClosure(resolvePath(root, DEV_ENTRY))
736
- }
737
- refreshApp(filePath)
738
- })
766
+ vite.watcher.on(event, (path) => onAddUnlink(event, path))
767
+ }
768
+ // Chokidar reports ready before the poller has taken its baseline stat of a directory (Bun's
769
+ // fs.watchFile takes it asynchronously), and a route added or removed in that gap is folded into the
770
+ // baseline and never reported. The manifest comes from a directory scan, so a scan diffed against the
771
+ // route files already handled recovers those events.
772
+ const reconcileRouteFiles = (): void => {
773
+ const onDisk = filesUnder(routesDir)
774
+ try {
775
+ for (const file of onDisk) if (!routeFiles.has(file)) onAddUnlink("add", file)
776
+ for (const file of [...routeFiles]) if (!onDisk.has(file)) onAddUnlink("unlink", file)
777
+ } catch (err) {
778
+ console.error("[nifra/web/vite] route rescan failed:", err)
779
+ session.buildFailed(err, "route rescan failed")
780
+ }
739
781
  }
740
782
  await watcherReady
741
783
 
@@ -756,9 +798,11 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
756
798
  const address = server.address()
757
799
  const boundPort = typeof address === "object" && address !== null ? address.port : port
758
800
  session.listening(boundPort)
801
+ const routeRescan = usePolling ? setInterval(reconcileRouteFiles, POLL_INTERVAL_MS) : undefined
759
802
  return {
760
803
  port: boundPort,
761
804
  stop: async () => {
805
+ clearInterval(routeRescan)
762
806
  session.stop()
763
807
  // Cleared with the server that owns it: the slot is process-global, so a stopped server leaving
764
808
  // its loader behind would hand the next one's adapter a graph that is closed (tests start and