@nifrajs/web 3.1.0 → 3.3.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/dist/build-plan.d.ts +228 -0
- package/dist/build-plan.d.ts.map +1 -0
- package/dist/build-plan.js +514 -0
- package/dist/build-plan.js.map +1 -0
- package/dist/build-vite.d.ts +2 -1
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +2 -197
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +61 -502
- package/dist/build.js.map +1 -1
- package/dist/chunk-names.d.ts.map +1 -1
- package/dist/chunk-names.js +3 -2
- package/dist/chunk-names.js.map +1 -1
- package/dist/dev-ssr-graph.d.ts.map +1 -1
- package/dist/dev-ssr-graph.js +63 -28
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +9 -2
- package/dist/diagnostic.js.map +1 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js +3 -1
- package/dist/fonts-google.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +2 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +15 -609
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -1935
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +68 -0
- package/dist/internal/codegen.d.ts.map +1 -0
- package/dist/internal/codegen.js +317 -0
- package/dist/internal/codegen.js.map +1 -0
- package/dist/internal/head-merge.d.ts +2 -0
- package/dist/internal/head-merge.d.ts.map +1 -1
- package/dist/internal/head-merge.js +17 -1
- package/dist/internal/head-merge.js.map +1 -1
- package/dist/internal/parity.d.ts +7 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +212 -30
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/render-document.d.ts +372 -0
- package/dist/internal/render-document.d.ts.map +1 -0
- package/dist/internal/render-document.js +744 -0
- package/dist/internal/render-document.js.map +1 -0
- package/dist/internal/runtime-contract.d.ts +48 -0
- package/dist/internal/runtime-contract.d.ts.map +1 -0
- package/dist/internal/runtime-contract.js +27 -0
- package/dist/internal/runtime-contract.js.map +1 -0
- package/dist/internal/web-app.d.ts +171 -0
- package/dist/internal/web-app.d.ts.map +1 -0
- package/dist/internal/web-app.js +844 -0
- package/dist/internal/web-app.js.map +1 -0
- package/dist/islands.d.ts +48 -0
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +79 -0
- package/dist/islands.js.map +1 -1
- package/dist/module-graph.d.ts.map +1 -1
- package/dist/module-graph.js +22 -9
- package/dist/module-graph.js.map +1 -1
- package/dist/nano.d.ts +125 -0
- package/dist/nano.d.ts.map +1 -0
- package/dist/nano.js +190 -0
- package/dist/nano.js.map +1 -0
- package/dist/plugins/css-modules.js +2 -2
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +12 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +62 -12
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/postcss.js +2 -2
- package/dist/plugins/postcss.js.map +1 -1
- package/dist/plugins/scss.js +2 -2
- package/dist/plugins/scss.js.map +1 -1
- package/dist/plugins/stylex.d.ts +70 -0
- package/dist/plugins/stylex.d.ts.map +1 -0
- package/dist/plugins/stylex.js +166 -0
- package/dist/plugins/stylex.js.map +1 -0
- package/dist/plugins/svg.js +2 -2
- package/dist/plugins/svg.js.map +1 -1
- package/dist/router.d.ts +1 -1
- package/dist/router.js +23 -23
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +26 -10
- package/dist/vite.js.map +1 -1
- package/package.json +39 -3
- package/src/build-plan.ts +741 -0
- package/src/build-vite.ts +1 -4
- package/src/build.ts +84 -716
- package/src/chunk-names.ts +3 -2
- package/src/dev-ssr-graph.ts +63 -28
- package/src/diagnostic.ts +14 -2
- package/src/fonts-google.ts +3 -1
- package/src/fs.ts +2 -1
- package/src/index.ts +73 -2705
- package/src/internal/codegen.ts +400 -0
- package/src/internal/head-merge.ts +19 -1
- package/src/internal/parity.ts +215 -35
- package/src/internal/render-document.ts +1116 -0
- package/src/internal/runtime-contract.ts +54 -0
- package/src/internal/web-app.ts +1114 -0
- package/src/islands.ts +85 -0
- package/src/module-graph.ts +23 -9
- package/src/nano.ts +246 -0
- package/src/plugins/css-modules.ts +2 -2
- package/src/plugins/kit.ts +65 -12
- package/src/plugins/postcss.ts +2 -2
- package/src/plugins/scss.ts +2 -2
- package/src/plugins/stylex.ts +298 -0
- package/src/plugins/svg.ts +2 -2
- package/src/router.ts +24 -24
- 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)
|
package/src/plugins/svg.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 = ++
|
|
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 !==
|
|
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
|
|
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 `
|
|
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 = ++
|
|
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 !==
|
|
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 ===
|
|
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
|
|
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 = ++
|
|
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 !==
|
|
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 ===
|
|
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 = ++
|
|
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 !==
|
|
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 ===
|
|
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
|
-
++
|
|
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 = ++
|
|
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 !==
|
|
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 ===
|
|
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 = ++
|
|
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 !==
|
|
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 !==
|
|
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 ===
|
|
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 {
|
|
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 =>
|
|
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 = [
|
|
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
|
-
|
|
556
|
-
|
|
578
|
+
const filePath = normalizeFilePath(path)
|
|
579
|
+
if (pathInside(routesDir, filePath)) writeClientEntry()
|
|
580
|
+
refreshApp(filePath)
|
|
557
581
|
})
|
|
558
582
|
}
|
|
559
583
|
|