@nifrajs/web 3.1.0 → 3.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 (114) hide show
  1. package/dist/build-plan.d.ts +228 -0
  2. package/dist/build-plan.d.ts.map +1 -0
  3. package/dist/build-plan.js +514 -0
  4. package/dist/build-plan.js.map +1 -0
  5. package/dist/build-vite.d.ts +2 -1
  6. package/dist/build-vite.d.ts.map +1 -1
  7. package/dist/build-vite.js.map +1 -1
  8. package/dist/build.d.ts +2 -197
  9. package/dist/build.d.ts.map +1 -1
  10. package/dist/build.js +61 -502
  11. package/dist/build.js.map +1 -1
  12. package/dist/chunk-names.d.ts.map +1 -1
  13. package/dist/chunk-names.js +3 -2
  14. package/dist/chunk-names.js.map +1 -1
  15. package/dist/dev-ssr-graph.d.ts.map +1 -1
  16. package/dist/dev-ssr-graph.js +63 -28
  17. package/dist/dev-ssr-graph.js.map +1 -1
  18. package/dist/diagnostic.d.ts.map +1 -1
  19. package/dist/diagnostic.js +9 -2
  20. package/dist/diagnostic.js.map +1 -1
  21. package/dist/fonts-google.d.ts.map +1 -1
  22. package/dist/fonts-google.js +3 -1
  23. package/dist/fonts-google.js.map +1 -1
  24. package/dist/fs.d.ts.map +1 -1
  25. package/dist/fs.js +2 -1
  26. package/dist/fs.js.map +1 -1
  27. package/dist/index.d.ts +15 -609
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +22 -1935
  30. package/dist/index.js.map +1 -1
  31. package/dist/internal/codegen.d.ts +68 -0
  32. package/dist/internal/codegen.d.ts.map +1 -0
  33. package/dist/internal/codegen.js +317 -0
  34. package/dist/internal/codegen.js.map +1 -0
  35. package/dist/internal/head-merge.d.ts +2 -0
  36. package/dist/internal/head-merge.d.ts.map +1 -1
  37. package/dist/internal/head-merge.js +17 -1
  38. package/dist/internal/head-merge.js.map +1 -1
  39. package/dist/internal/parity.d.ts +7 -0
  40. package/dist/internal/parity.d.ts.map +1 -1
  41. package/dist/internal/parity.js +212 -30
  42. package/dist/internal/parity.js.map +1 -1
  43. package/dist/internal/render-document.d.ts +372 -0
  44. package/dist/internal/render-document.d.ts.map +1 -0
  45. package/dist/internal/render-document.js +744 -0
  46. package/dist/internal/render-document.js.map +1 -0
  47. package/dist/internal/runtime-contract.d.ts +48 -0
  48. package/dist/internal/runtime-contract.d.ts.map +1 -0
  49. package/dist/internal/runtime-contract.js +27 -0
  50. package/dist/internal/runtime-contract.js.map +1 -0
  51. package/dist/internal/web-app.d.ts +171 -0
  52. package/dist/internal/web-app.d.ts.map +1 -0
  53. package/dist/internal/web-app.js +844 -0
  54. package/dist/internal/web-app.js.map +1 -0
  55. package/dist/islands.d.ts +48 -0
  56. package/dist/islands.d.ts.map +1 -1
  57. package/dist/islands.js +79 -0
  58. package/dist/islands.js.map +1 -1
  59. package/dist/module-graph.d.ts.map +1 -1
  60. package/dist/module-graph.js +22 -9
  61. package/dist/module-graph.js.map +1 -1
  62. package/dist/nano.d.ts +125 -0
  63. package/dist/nano.d.ts.map +1 -0
  64. package/dist/nano.js +190 -0
  65. package/dist/nano.js.map +1 -0
  66. package/dist/plugins/css-modules.js +2 -2
  67. package/dist/plugins/css-modules.js.map +1 -1
  68. package/dist/plugins/kit.d.ts +12 -0
  69. package/dist/plugins/kit.d.ts.map +1 -1
  70. package/dist/plugins/kit.js +62 -12
  71. package/dist/plugins/kit.js.map +1 -1
  72. package/dist/plugins/postcss.js +2 -2
  73. package/dist/plugins/postcss.js.map +1 -1
  74. package/dist/plugins/scss.js +2 -2
  75. package/dist/plugins/scss.js.map +1 -1
  76. package/dist/plugins/stylex.d.ts +70 -0
  77. package/dist/plugins/stylex.d.ts.map +1 -0
  78. package/dist/plugins/stylex.js +166 -0
  79. package/dist/plugins/stylex.js.map +1 -0
  80. package/dist/plugins/svg.js +2 -2
  81. package/dist/plugins/svg.js.map +1 -1
  82. package/dist/router.d.ts +1 -1
  83. package/dist/router.js +23 -23
  84. package/dist/router.js.map +1 -1
  85. package/dist/vite.d.ts.map +1 -1
  86. package/dist/vite.js +26 -10
  87. package/dist/vite.js.map +1 -1
  88. package/package.json +39 -3
  89. package/src/build-plan.ts +741 -0
  90. package/src/build-vite.ts +1 -4
  91. package/src/build.ts +84 -716
  92. package/src/chunk-names.ts +3 -2
  93. package/src/dev-ssr-graph.ts +63 -28
  94. package/src/diagnostic.ts +14 -2
  95. package/src/fonts-google.ts +3 -1
  96. package/src/fs.ts +2 -1
  97. package/src/index.ts +73 -2705
  98. package/src/internal/codegen.ts +400 -0
  99. package/src/internal/head-merge.ts +19 -1
  100. package/src/internal/parity.ts +215 -35
  101. package/src/internal/render-document.ts +1116 -0
  102. package/src/internal/runtime-contract.ts +54 -0
  103. package/src/internal/web-app.ts +1114 -0
  104. package/src/islands.ts +85 -0
  105. package/src/module-graph.ts +23 -9
  106. package/src/nano.ts +246 -0
  107. package/src/plugins/css-modules.ts +2 -2
  108. package/src/plugins/kit.ts +65 -12
  109. package/src/plugins/postcss.ts +2 -2
  110. package/src/plugins/scss.ts +2 -2
  111. package/src/plugins/stylex.ts +298 -0
  112. package/src/plugins/svg.ts +2 -2
  113. package/src/router.ts +24 -24
  114. package/src/vite.ts +33 -9
@@ -0,0 +1,298 @@
1
+ /**
2
+ * StyleX integration for Nifra's Bun and Vite pipelines.
3
+ *
4
+ * The StyleX compiler stays an optional peer owned by the StyleX project. This module owns only the
5
+ * Nifra boundary: transform route/component modules in both pipelines, emit client CSS through the
6
+ * existing virtual stylesheet seam, and run the same transform without CSS for SSR.
7
+ */
8
+ import type { BunPlugin } from "bun"
9
+ import {
10
+ createStylesheetEmitter,
11
+ devServerCompile,
12
+ hash8,
13
+ normalizeFilePath,
14
+ reproduciblePath,
15
+ requirePeer,
16
+ } from "./kit.ts"
17
+
18
+ const STYLE_NS = "nifra-stylex"
19
+ const JS_FILTER = /\.[cm]?[jt]sx?(\?|$)/
20
+
21
+ /** StyleX's module resolution option, kept structural so the compiler remains an optional peer. */
22
+ export interface StylexModuleResolution {
23
+ readonly type: "commonJS" | "haste"
24
+ readonly rootDir: string
25
+ }
26
+
27
+ /** Structural slice of `@babel/core` used by the adapter. */
28
+ export interface StylexBabelCompiler {
29
+ transformAsync(
30
+ source: string,
31
+ options: Readonly<Record<string, unknown>>,
32
+ ): Promise<StylexBabelResult | null | undefined>
33
+ }
34
+
35
+ /** Structural slice of the StyleX Babel plugin used by the adapter. */
36
+ export interface StylexCompiler {
37
+ withOptions(options: Readonly<Record<string, unknown>>): unknown
38
+ processStylexRules(rules: readonly unknown[], options: Readonly<Record<string, unknown>>): string
39
+ }
40
+
41
+ /** Optional peer injection and compiler configuration shared by Bun and Vite adapters. */
42
+ export interface StylexPluginOptions {
43
+ /** Inject `@babel/core` in tests or advanced integrations. Defaults to the optional peer. */
44
+ readonly babel?: StylexBabelCompiler
45
+ /** Inject `@stylexjs/babel-plugin` in tests or advanced integrations. */
46
+ readonly compiler?: StylexCompiler
47
+ /** Inject Babel syntax plugins. Defaults to the three official syntax peers. */
48
+ readonly syntaxPlugins?: {
49
+ readonly flow: unknown
50
+ readonly jsx: unknown
51
+ readonly typescript: unknown
52
+ }
53
+ /** StyleX import sources to transform. Defaults to `stylex` and `@stylexjs/stylex`. */
54
+ readonly importSources?: readonly string[]
55
+ /** StyleX development transform mode. Defaults to Nifra's dev-server signal. */
56
+ readonly dev?: boolean
57
+ /** StyleX CSS layer output. Defaults to `true` for deterministic precedence. */
58
+ readonly useCSSLayers?: boolean
59
+ /** StyleX's module resolution policy. */
60
+ readonly unstable_moduleResolution?: StylexModuleResolution
61
+ /** Extra Babel plugins and presets merged before the StyleX plugin. */
62
+ readonly babelConfig?: {
63
+ readonly plugins?: readonly unknown[]
64
+ readonly presets?: readonly unknown[]
65
+ }
66
+ }
67
+
68
+ interface StylexBabelResult {
69
+ readonly code?: unknown
70
+ readonly metadata?: unknown
71
+ }
72
+
73
+ interface StylexTransformedModule {
74
+ readonly code: string
75
+ readonly css: string
76
+ }
77
+
78
+ interface StylexDependencies {
79
+ readonly babel: StylexBabelCompiler
80
+ readonly compiler: StylexCompiler
81
+ readonly syntax: Required<StylexPluginOptions>["syntaxPlugins"]
82
+ }
83
+
84
+ interface RecordLike {
85
+ readonly [key: string]: unknown
86
+ }
87
+
88
+ const isRecord = (value: unknown): value is RecordLike =>
89
+ (typeof value === "object" && value !== null) || typeof value === "function"
90
+
91
+ const unwrapDefault = (value: unknown): unknown => {
92
+ if (!isRecord(value)) return value
93
+ return value.default ?? value
94
+ }
95
+
96
+ const isBabelCompiler = (value: unknown): value is StylexBabelCompiler =>
97
+ isRecord(value) && typeof value.transformAsync === "function"
98
+
99
+ const isStylexCompiler = (value: unknown): value is StylexCompiler =>
100
+ isRecord(value) &&
101
+ typeof value.withOptions === "function" &&
102
+ typeof value.processStylexRules === "function"
103
+
104
+ const syntaxPeer = async (specifier: string, feature: string): Promise<unknown> =>
105
+ unwrapDefault(
106
+ await requirePeer<unknown>(specifier, {
107
+ feature,
108
+ install: `bun add -d ${specifier}`,
109
+ }),
110
+ )
111
+
112
+ const loadBabelCompiler = async (options: StylexPluginOptions): Promise<StylexBabelCompiler> => {
113
+ if (options.babel !== undefined) return options.babel
114
+ const loaded = await requirePeer<unknown>("@babel/core", {
115
+ feature: "StyleX compilation",
116
+ install: "bun add -d @babel/core",
117
+ })
118
+ const compiler = unwrapDefault(loaded)
119
+ if (!isBabelCompiler(compiler)) {
120
+ throw new Error("[nifra/web] the installed @babel/core does not expose transformAsync")
121
+ }
122
+ return compiler
123
+ }
124
+
125
+ const loadStylexCompiler = async (options: StylexPluginOptions): Promise<StylexCompiler> => {
126
+ if (options.compiler !== undefined) return options.compiler
127
+ const loaded = await requirePeer<unknown>("@stylexjs/babel-plugin", {
128
+ feature: "StyleX compilation",
129
+ install: "bun add -d @stylexjs/babel-plugin",
130
+ })
131
+ const compiler = unwrapDefault(loaded)
132
+ if (!isStylexCompiler(compiler)) {
133
+ throw new Error(
134
+ "[nifra/web] the installed @stylexjs/babel-plugin does not expose the StyleX compiler API",
135
+ )
136
+ }
137
+ return compiler
138
+ }
139
+
140
+ const loadSyntaxPlugins = async (
141
+ options: StylexPluginOptions,
142
+ ): Promise<Required<StylexPluginOptions>["syntaxPlugins"]> => {
143
+ if (options.syntaxPlugins !== undefined) return options.syntaxPlugins
144
+ const [flow, jsx, typescript] = await Promise.all([
145
+ syntaxPeer("@babel/plugin-syntax-flow", "StyleX Flow syntax parsing"),
146
+ syntaxPeer("@babel/plugin-syntax-jsx", "StyleX JSX syntax parsing"),
147
+ syntaxPeer("@babel/plugin-syntax-typescript", "StyleX TypeScript syntax parsing"),
148
+ ])
149
+ return { flow, jsx, typescript }
150
+ }
151
+
152
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
153
+
154
+ const containsStylexImport = (source: string, importSources: readonly string[]): boolean =>
155
+ importSources.some((specifier) => {
156
+ const escaped = escapeRegExp(specifier)
157
+ return new RegExp(
158
+ `(?:from\\s*['"]${escaped}['"]|import\\s*\\(\\s*['"]${escaped}['"]\\s*\\)|require\\s*\\(\\s*['"]${escaped}['"]\\s*\\))`,
159
+ "m",
160
+ ).test(source)
161
+ })
162
+
163
+ const loaderFor = (path: string): "js" | "jsx" | "ts" | "tsx" => {
164
+ const clean = path.split("?", 1)[0] ?? path
165
+ if (clean.endsWith(".tsx")) return "tsx"
166
+ if (clean.endsWith(".jsx")) return "jsx"
167
+ if (clean.endsWith(".ts") || clean.endsWith(".mts") || clean.endsWith(".cts")) return "ts"
168
+ return "js"
169
+ }
170
+
171
+ const moduleResolution = (options: StylexPluginOptions): StylexModuleResolution =>
172
+ options.unstable_moduleResolution ?? { type: "commonJS", rootDir: process.cwd() }
173
+
174
+ const transformModule = async (
175
+ source: string,
176
+ filename: string,
177
+ options: StylexPluginOptions,
178
+ dependencies: Promise<StylexDependencies>,
179
+ ): Promise<StylexTransformedModule | undefined> => {
180
+ if (!containsStylexImport(source, options.importSources ?? ["stylex", "@stylexjs/stylex"])) {
181
+ return undefined
182
+ }
183
+
184
+ const { babel, compiler, syntax } = await dependencies
185
+ const result = await babel.transformAsync(source, {
186
+ babelrc: false,
187
+ filename,
188
+ presets: [...(options.babelConfig?.presets ?? [])],
189
+ plugins: [
190
+ ...((options.babelConfig?.plugins ?? []) as readonly unknown[]),
191
+ /\.jsx?$/.test(filename) ? syntax.flow : [syntax.typescript, { isTSX: true }],
192
+ syntax.jsx,
193
+ compiler.withOptions({
194
+ dev: options.dev ?? devServerCompile(),
195
+ importSources: options.importSources ?? ["stylex", "@stylexjs/stylex"],
196
+ treeshakeCompensation: true,
197
+ unstable_moduleResolution: moduleResolution(options),
198
+ }),
199
+ ],
200
+ })
201
+ const code = typeof result?.code === "string" ? result.code : undefined
202
+ if (code === undefined) return undefined
203
+
204
+ const metadata = isRecord(result?.metadata) ? result.metadata : undefined
205
+ const rules = Array.isArray(metadata?.stylex) ? metadata.stylex : []
206
+ const css =
207
+ rules.length === 0
208
+ ? ""
209
+ : compiler.processStylexRules(rules, {
210
+ useCSSLayers: options.useCSSLayers ?? true,
211
+ })
212
+ return { code, css }
213
+ }
214
+
215
+ const dependenciesFor = (options: StylexPluginOptions): (() => Promise<StylexDependencies>) => {
216
+ let dependencies: Promise<StylexDependencies> | undefined
217
+ return (): Promise<StylexDependencies> => {
218
+ dependencies ??= Promise.all([
219
+ loadBabelCompiler(options),
220
+ loadStylexCompiler(options),
221
+ loadSyntaxPlugins(options),
222
+ ]).then(([babel, compiler, syntax]) => ({ babel, compiler, syntax }))
223
+ return dependencies
224
+ }
225
+ }
226
+
227
+ /** Build a Bun plugin that transforms StyleX and emits CSS for the browser or class maps for SSR. */
228
+ export function stylexBunPlugin(
229
+ generate: "dom" | "ssr",
230
+ options: StylexPluginOptions = {},
231
+ ): BunPlugin {
232
+ return {
233
+ name: `nifra-stylex-${generate}`,
234
+ setup(build) {
235
+ const stylesheet = generate === "dom" ? createStylesheetEmitter(build, STYLE_NS) : undefined
236
+ const dependencies = dependenciesFor(options)
237
+ build.onLoad({ filter: JS_FILTER }, async (args) => {
238
+ const path = normalizeFilePath(args.path)
239
+ const source = await Bun.file(path).text()
240
+ const transformed = await transformModule(source, path, options, dependencies())
241
+ if (transformed === undefined) return
242
+ const contents =
243
+ generate === "dom" && stylesheet !== undefined && transformed.css !== ""
244
+ ? `${transformed.code}\n${stylesheet.emit(path, transformed.css)}`
245
+ : transformed.code
246
+ return { contents, loader: loaderFor(path) }
247
+ })
248
+ },
249
+ }
250
+ }
251
+
252
+ /** Structural Vite plugin return type. Vite remains an optional peer of `@nifrajs/web`. */
253
+ export interface StylexVitePlugin {
254
+ readonly name: string
255
+ transform(
256
+ source: string,
257
+ id: string,
258
+ ): Promise<{ readonly code: string; readonly map?: null } | undefined>
259
+ resolveId(id: string): string | undefined
260
+ load(id: string): string | undefined
261
+ }
262
+
263
+ /** Build a Vite plugin that maps each transformed StyleX module to a virtual CSS asset. */
264
+ export function stylexVitePlugin(
265
+ generate: "dom" | "ssr",
266
+ options: StylexPluginOptions = {},
267
+ ): StylexVitePlugin {
268
+ const cssById = new Map<string, string>()
269
+ const dependencies = dependenciesFor(options)
270
+ const virtualIdFor = (id: string): string =>
271
+ `virtual:nifra-stylex/${hash8(reproduciblePath(normalizeFilePath(id)))}.css`
272
+
273
+ return {
274
+ name: `nifra-stylex-${generate}`,
275
+ async transform(source, id) {
276
+ const path = normalizeFilePath(id)
277
+ const transformed = await transformModule(source, path, options, dependencies())
278
+ if (transformed === undefined) return undefined
279
+ if (generate === "ssr" || transformed.css === "") return { code: transformed.code, map: null }
280
+ const cssId = virtualIdFor(path)
281
+ cssById.set(cssId, transformed.css)
282
+ return {
283
+ code: `${transformed.code}\nimport ${JSON.stringify(cssId)}`,
284
+ map: null,
285
+ }
286
+ },
287
+ resolveId(id) {
288
+ return cssById.has(id) ? id : undefined
289
+ },
290
+ load(id) {
291
+ return cssById.get(id)
292
+ },
293
+ }
294
+ }
295
+
296
+ /** Convenience alias for apps that configure only a browser Vite build. */
297
+ export const stylexVite = (options: StylexPluginOptions = {}): StylexVitePlugin =>
298
+ stylexVitePlugin("dom", options)
@@ -16,7 +16,7 @@
16
16
  * `style="..."` into an object, and drops XML declarations/comments so the result is valid JSX.
17
17
  */
18
18
  import type { BunPlugin } from "bun"
19
- import { requirePeer } from "./kit.ts"
19
+ import { normalizeFilePath, requirePeer } from "./kit.ts"
20
20
 
21
21
  /** The subset of the `svgo` API this plugin uses (structural, so no hard dependency on its types). */
22
22
  export interface SvgOptimizer {
@@ -168,7 +168,7 @@ export function svgComponentBunPlugin(
168
168
  }
169
169
 
170
170
  build.onLoad({ filter: SVG_FILTER }, async (args) => {
171
- const path = args.path.split("?")[0] ?? args.path
171
+ const path = normalizeFilePath(args.path)
172
172
  let xml = await Bun.file(path).text()
173
173
  const svgo = await getOptimizer()
174
174
  if (svgo !== undefined) {
package/src/router.ts CHANGED
@@ -215,7 +215,7 @@ export interface FetcherState {
215
215
  /**
216
216
  * An independent load/submit state machine, retrieved by `router.fetcher(key)`. Runs **concurrently**
217
217
  * with the main router and with other fetchers - each is single-flight against *itself* (its own
218
- * monotonic token), so N row-level mutations / side-channel loads can be in flight at once without
218
+ * monotonic generation), so N row-level mutations / side-channel loads can be in flight at once without
219
219
  * disturbing the active view. Loads/submits write the shared cache and honor `X-Nifra-Revalidate`.
220
220
  */
221
221
  export interface Fetcher {
@@ -388,7 +388,7 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
388
388
  }
389
389
 
390
390
  /**
391
- * Create the agnostic router store. `navigate` is guarded by a monotonic token so that when
391
+ * Create the agnostic router store. `navigate` is guarded by a monotonic generation so that when
392
392
  * navigations overlap, only the latest result is applied (rapid clicks don't flash stale data).
393
393
  * A failed fetch clears `pending` and rethrows so the caller can fall back to a full-page load.
394
394
  */
@@ -581,7 +581,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
581
581
  hydrated = true
582
582
  const route = { routeId: state.routeId, params: state.params }
583
583
  if (routeHooks[route.routeId]?.clientLoader === undefined) return
584
- const mine = ++token
584
+ const mine = ++generation
585
585
  navAbort?.abort()
586
586
  const ac = new AbortController()
587
587
  navAbort = ac
@@ -597,7 +597,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
597
597
  () => Promise.resolve(initial),
598
598
  initial,
599
599
  )
600
- if (mine !== token) return
600
+ if (mine !== generation) return
601
601
  if (loaded.terminalRouteId !== undefined) {
602
602
  await loadModule?.(loaded.terminalRouteId)
603
603
  state = { ...state, routeId: loaded.terminalRouteId, params: {}, data: null }
@@ -613,7 +613,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
613
613
  }
614
614
  let state = options.initial
615
615
  const listeners = new Set<() => void>()
616
- let token = 0
616
+ let generation = 0
617
617
  // Snapshot listeners to defend against accidental un/subscribe during notification. The adapters
618
618
  // don't do this (un/subscribe runs in effect/cleanup ticks), so the overhead is negligible
619
619
  // (5-20 listeners typically), and the defensiveness is valuable.
@@ -684,10 +684,10 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
684
684
  // (so a superseded route's NDJSON stream stops reading instead of draining in the background).
685
685
  let navAbort: AbortController | undefined
686
686
 
687
- // Refetch the active route + republish, single-flight via the shared `token`. Shared by `invalidate`
687
+ // Refetch the active route + republish, single-flight via the shared `generation`. Shared by `invalidate`
688
688
  // and targeted revalidation. Rejects if the fetch fails (clearing its own `pending` first).
689
689
  const refetchActive = async (): Promise<void> => {
690
- const mine = ++token
690
+ const mine = ++generation
691
691
  navAbort?.abort()
692
692
  const ac = new AbortController()
693
693
  navAbort = ac
@@ -698,7 +698,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
698
698
  const loaded = await applyClientLoader(state.path, active, ac.signal, () =>
699
699
  loadRouteData(state.path, active, ac.signal),
700
700
  )
701
- if (mine !== token) return // superseded - drop the stale result
701
+ if (mine !== generation) return // superseded - drop the stale result
702
702
  cachePut(state.path, loaded)
703
703
  state = {
704
704
  ...state,
@@ -709,7 +709,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
709
709
  }
710
710
  emit()
711
711
  } catch (err) {
712
- if (mine === token) {
712
+ if (mine === generation) {
713
713
  state = { ...state, pending: false }
714
714
  emit()
715
715
  }
@@ -748,7 +748,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
748
748
 
749
749
  const createFetcher = (): FetcherEntry => {
750
750
  let fState: FetcherState = { pending: false, data: undefined }
751
- let fToken = 0
751
+ let fGeneration = 0
752
752
  let fAbort: AbortController | undefined
753
753
  let loadedPath: string | undefined
754
754
  const fListeners = new Set<() => void>()
@@ -759,7 +759,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
759
759
  const runLoad = async (path: string): Promise<void> => {
760
760
  const matched = match(path)
761
761
  if (matched === null) return // unmatched path → no-op
762
- const mine = ++fToken
762
+ const mine = ++fGeneration
763
763
  fAbort?.abort()
764
764
  const ac = new AbortController()
765
765
  fAbort = ac
@@ -770,7 +770,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
770
770
  loadModule?.(matched.routeId),
771
771
  loadRouteData(path, matched, ac.signal),
772
772
  ])
773
- if (mine !== fToken) return // a newer load/submit on THIS fetcher superseded us
773
+ if (mine !== fGeneration) return // a newer load/submit on THIS fetcher superseded us
774
774
  // Record the loaded path only on SUCCESS - a thrown or superseded load must not
775
775
  // leave `loadedPath` pointing at a path this fetcher never actually showed, or a later
776
776
  // `X-Nifra-Revalidate` for it would spuriously refetch onto unexpected data.
@@ -779,7 +779,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
779
779
  fState = { ...fState, data: loaded.data, pending: false }
780
780
  fEmit()
781
781
  } catch (err) {
782
- if (mine === fToken) {
782
+ if (mine === fGeneration) {
783
783
  fState = { ...fState, pending: false }
784
784
  fEmit()
785
785
  }
@@ -796,7 +796,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
796
796
  },
797
797
  load: runLoad,
798
798
  submit: async (action, body) => {
799
- const mine = ++fToken
799
+ const mine = ++fGeneration
800
800
  // Abort any prior in-flight load/submit on THIS fetcher (its fetch + NDJSON drain) - like
801
801
  // `runLoad` does for a superseding load. The mutation POST is left to complete; the
802
802
  // signal cancels the follow-up data drain if a newer op supersedes this one.
@@ -817,7 +817,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
817
817
  throw new Error(`[nifra/web] fetcher action failed (${res.status}): ${action}`)
818
818
  const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal)
819
819
  const changed = parseRevalidate(res.headers.get(REVALIDATE_HEADER))
820
- if (mine !== fToken) return
820
+ if (mine !== fGeneration) return
821
821
  // Publish the fetcher's actionData; clear `submission` (the optimistic window is over).
822
822
  fState = { pending: false, data: fState.data, actionData }
823
823
  fEmit()
@@ -828,7 +828,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
828
828
  await refreshMounted(changed, false)
829
829
  }
830
830
  } catch (err) {
831
- if (mine === fToken) {
831
+ if (mine === fGeneration) {
832
832
  fState = { pending: false, data: fState.data }
833
833
  fEmit()
834
834
  }
@@ -872,7 +872,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
872
872
  searchClientKeys[state.routeId] ?? [],
873
873
  )
874
874
  ) {
875
- ++token // supersede any in-flight navigation so its late result is dropped
875
+ ++generation // supersede any in-flight navigation so its late result is dropped
876
876
  navAbort?.abort()
877
877
  cachePut(path, {
878
878
  data: state.data,
@@ -883,7 +883,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
883
883
  emit()
884
884
  return
885
885
  }
886
- const mine = ++token
886
+ const mine = ++generation
887
887
  navAbort?.abort() // abandon any in-flight navigation's stream
888
888
  const ac = new AbortController()
889
889
  navAbort = ac
@@ -914,7 +914,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
914
914
  prefetchedData,
915
915
  )
916
916
  if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
917
- if (mine !== token) return // a newer navigation superseded this one - drop the stale result
917
+ if (mine !== generation) return // a newer navigation superseded this one - drop the stale result
918
918
  cachePut(path, loaded) // keep the keyed cache coherent with what we publish
919
919
  const interception =
920
920
  loaded.terminalRouteId === undefined
@@ -959,7 +959,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
959
959
  } catch (err) {
960
960
  // Clear our pending flag + target (only if still current) and rethrow - the caller decides how
961
961
  // to recover (the history layer falls back to a full-page navigation).
962
- if (mine === token) {
962
+ if (mine === generation) {
963
963
  state = { ...state, pending: false, pendingPath: undefined }
964
964
  emit()
965
965
  }
@@ -967,7 +967,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
967
967
  }
968
968
  },
969
969
  submit: async (action, body, opts) => {
970
- const mine = ++token
970
+ const mine = ++generation
971
971
  const previousActionData = state.actionData
972
972
  // A superseding navigation/submit aborts this submit's FOLLOW-UP reads (revalidation / redirect
973
973
  // fetch + their NDJSON drains) - not the mutation POST itself, which should complete.
@@ -1013,7 +1013,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1013
1013
  ),
1014
1014
  )
1015
1015
  if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
1016
- if (mine !== token) return
1016
+ if (mine !== generation) return
1017
1017
  cachePut(redirectTo, loaded)
1018
1018
  state = {
1019
1019
  routeId: loaded.terminalRouteId ?? target.routeId,
@@ -1049,7 +1049,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1049
1049
  : await applyClientLoader(state.path, active, ac.signal, () =>
1050
1050
  loadRouteData(state.path, active, ac.signal),
1051
1051
  )
1052
- if (mine !== token) return
1052
+ if (mine !== generation) return
1053
1053
  cachePut(state.path, loaded) // the revalidated (or kept) data is now the cache's truth
1054
1054
  // Mark the OTHER changed routes stale so the next access refetches.
1055
1055
  markStale(changed.filter((p) => p !== state.path))
@@ -1070,7 +1070,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1070
1070
  // revalidated inline above, so skip it here). Best-effort - never rejects the submit.
1071
1071
  await refreshMounted(changed, true)
1072
1072
  } catch (err) {
1073
- if (mine === token) {
1073
+ if (mine === generation) {
1074
1074
  // Revert: clear `submission` (the optimistic view vanishes) leaving `data` untouched, so the
1075
1075
  // pre-submit data shows through.
1076
1076
  state = {
package/src/vite.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  type IncomingMessage,
17
17
  type ServerResponse,
18
18
  } from "node:http"
19
- import { relative, resolve as resolvePath } from "node:path"
19
+ import { isAbsolute, join, relative, resolve as resolvePath, sep } 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"
@@ -31,7 +31,13 @@ import {
31
31
  import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
32
32
  import { importVite } from "./internal/vite-import.ts"
33
33
  import { scopedName } from "./plugins/css-modules.ts"
34
- import { DEV_ROOT_ENV, DEV_ROUTES_ENV, reproduciblePath } from "./plugins/kit.ts"
34
+ import {
35
+ DEV_ROOT_ENV,
36
+ DEV_ROUTES_ENV,
37
+ normalizeFilePath,
38
+ portablePath,
39
+ reproduciblePath,
40
+ } from "./plugins/kit.ts"
35
41
  import { viteServerFnStub } from "./plugins/vite-server-fn.ts"
36
42
  import { viteServerOnlyEmpty } from "./plugins/vite-server-only.ts"
37
43
 
@@ -139,6 +145,19 @@ export { LAST_ERROR_PATH } from "./diagnostic.ts"
139
145
  // The codegen'd client entry is written here (at the Vite root) so Vite serves + HMRs it.
140
146
  const DEV_ENTRY = ".nifra-vite-entry.tsx"
141
147
 
148
+ /** Candidate spellings for Vite's module-graph map. Vite has used native and slash-normalized
149
+ * absolute Windows paths across versions, while watcher events can additionally arrive as a file URL. */
150
+ const modulePathCandidates = (path: string): string[] => {
151
+ const native = normalizeFilePath(path)
152
+ const portable = portablePath(path)
153
+ return [...new Set([path, native, portable])]
154
+ }
155
+
156
+ const pathInside = (root: string, path: string): boolean => {
157
+ const rel = relative(resolvePath(root), resolvePath(normalizeFilePath(path)))
158
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
159
+ }
160
+
142
161
  const readNodeBody = async (req: IncomingMessage): Promise<Buffer | undefined> => {
143
162
  if (req.method === "GET" || req.method === "HEAD") return undefined
144
163
  const chunks: Buffer[] = []
@@ -344,7 +363,8 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
344
363
  process.env[DEV_ROUTES_ENV] = routesDir
345
364
 
346
365
  // Codegen the client entry with Vite-servable, root-relative specifiers (e.g. `/routes/index.tsx`).
347
- const toUrl = (file: string): string => `/${relative(root, `${routesDir}/${file}`)}`
366
+ const toUrl = (file: string): string =>
367
+ `/${relative(root, join(routesDir, file)).replaceAll("\\", "/")}`
348
368
  const writeClientEntry = (): void => {
349
369
  const manifest = discoverRoutes(routesDir)
350
370
  writeFileSync(
@@ -523,10 +543,13 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
523
543
  const invalidateImporterClosure = (path: string): void => {
524
544
  const graph = vite.moduleGraph
525
545
  if (graph === undefined) return
526
- const roots = graph.getModulesByFile(path)
527
- if (roots === undefined) return
528
546
  const seen = new Set<ViteModuleNode>()
529
- const stack = [...roots]
547
+ const stack: ViteModuleNode[] = []
548
+ for (const candidate of modulePathCandidates(path)) {
549
+ const roots = graph.getModulesByFile(candidate)
550
+ if (roots !== undefined) stack.push(...roots)
551
+ }
552
+ if (stack.length === 0) return
530
553
  for (let mod = stack.pop(); mod !== undefined; mod = stack.pop()) {
531
554
  if (seen.has(mod)) continue
532
555
  seen.add(mod)
@@ -549,11 +572,12 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
549
572
  })
550
573
  .catch((err) => console.error("[nifra/web/vite] app re-create failed:", err))
551
574
  }
552
- vite.watcher.on("change", refreshApp)
575
+ vite.watcher.on("change", (path) => refreshApp(normalizeFilePath(path)))
553
576
  for (const event of ["add", "unlink"] as const) {
554
577
  vite.watcher.on(event, (path) => {
555
- if (path.startsWith(`${routesDir}/`) || path.startsWith(`${routesDir}\\`)) writeClientEntry()
556
- refreshApp(path)
578
+ const filePath = normalizeFilePath(path)
579
+ if (pathInside(routesDir, filePath)) writeClientEntry()
580
+ refreshApp(filePath)
557
581
  })
558
582
  }
559
583