feat: add support for #super imports in vite-layers

This commit is contained in:
2026-08-13 02:29:44 +07:00
parent 7d98bb00fd
commit 660989085c
14 changed files with 778 additions and 144 deletions
+27 -2
View File
@@ -77,10 +77,34 @@ c12, вне графа Vite — сам он не следит), подхваты
| Префикс | Куда резолвится | Примечания | | Префикс | Куда резолвится | Примечания |
|---|---|---| |---|---|---|
| `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` | | `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` |
| `#super`, `#super/…` | первый совпавший файл **строго ниже** слоя импортёра | явный `super()` — предпочтительная форма, см. ниже |
| `~~/…`, `@@/…` | `rootDir` проекта | обычный alias | | `~~/…`, `@@/…` | `rootDir` проекта | обычный alias |
| `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени | | `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени |
| `#feature` | entry макроса `feature('key')` | алиас регистрируется автоматически; вызовы сворачиваются в литералы | | `#feature` | entry макроса `feature('key')` | алиас регистрируется автоматически; вызовы сворачиваются в литералы |
## `super()`: доступ к затенённому файлу
Оверрайд часто хочет не заменить базовый файл целиком, а обернуть его. Для этого есть `#super`:
```ts
// apps/brand/src/main.ts — бренд без собственного bootstrap: реиспользует entry базы
import '#super' // мой же путь (main.ts), слоем ниже → apps/main/src/main.ts
// apps/brand/src/components/Header.vue — оборачиваем базовый компонент
import BaseHeader from '#super/components/Header.vue'
```
- `#super/<path>` — резолвит `<path>` начиная со слоя **строго ниже** слоя импортёра
(работает из любого файла слоя, не только из одноимённого оверрайда);
- голый `#super` — сахар для «мой собственный путь, слоем ниже».
`#super/*` типизируется в сгенерированном tsconfig (`paths` → слои ниже проектного), так что
go-to-definition ведёт в правильный файл; голая форма покрыта ambient-декларацией
(`.vite-layers/super.d.ts`) — типов не даёт, но и ошибок не создаёт (подходит для side-effect
импортов). Legacy-форма — self-import собственного пути (`import '@/main.ts'` изнутри `main.ts`,
Nuxt-парити) — продолжает работать, но не грепается, меняет смысл при копировании в другой файл и
резолвится TypeScript'ом в самого себя; в новом коде используйте `#super`.
## Модель приоритета (из Nuxt) ## Модель приоритета (из Nuxt)
`layers[0]` — это сам проект (высший приоритет); далее `extends` слева-направо, в глубину; `layers[0]` — это сам проект (высший приоритет); далее `extends` слева-направо, в глубину;
@@ -187,8 +211,9 @@ UI рисуется целиком на сервере (json-render спеки `
## Улучшения над Nuxt/c12 ## Улучшения над Nuxt/c12
1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`), 1. **`super()`** — явный `#super`/`#super/<path>` (типизированный, greppable) плюс self-skip
чтобы дотянуться до базового файла. В Nuxt такого механизма нет. (оверрайд может импортировать собственный путь `@/components/X`, чтобы дотянуться до базового
файла). В Nuxt такого механизма нет.
2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt 2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt
срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в
`resolve`-хуке c12 обрывает рекурсию. `resolve`-хуке c12 обрывает рекурсию.
+3 -4
View File
@@ -1,4 +1,3 @@
// Aurora has no bootstrap logic of its own — it reuses the base layer's entry. // Aurora has no bootstrap logic of its own — `#super` re-resolves this file's own path
// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip // (main.ts) from the next-lower layer, i.e. main/src/main.ts.
// (super() semantics) falls through to the next layer, i.e. main/src/main.ts. import '#super'
import '@/main.ts'
+3 -4
View File
@@ -1,4 +1,3 @@
// Brand has no bootstrap logic of its own — it reuses the base layer's entry. // Brand has no bootstrap logic of its own — `#super` re-resolves this file's own path
// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip // (main.ts) from the next-lower layer, i.e. main/src/main.ts.
// (super() semantics) falls through to the next layer, i.e. main/src/main.ts. import '#super'
import '@/main.ts'
+12 -39
View File
@@ -1,5 +1,5 @@
import { readdirSync, statSync } from 'node:fs' import { statSync } from 'node:fs'
import { join, relative, resolve } from 'node:path' import { relative, resolve } from 'node:path'
import type { ConfigEnv } from 'vite' import type { ConfigEnv } from 'vite'
import type { import type {
DevToolsServerCommandInput, DevToolsServerCommandInput,
@@ -14,7 +14,7 @@ import { flattenFeatures } from './features'
import type { LayeredResolution, ResolveRecord } from './resolve' import type { LayeredResolution, ResolveRecord } from './resolve'
import { generateTsConfig, type GenerateTsConfigOptions } from './tsconfig' import { generateTsConfig, type GenerateTsConfigOptions } from './tsconfig'
import type { LayerStack } from './types' import type { LayerStack } from './types'
import { toPosix } from './util' import { toPosix, walkFiles } from './util'
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
// This module imports **only types** from `@vitejs/devtools-kit` — they are erased at emit, so the // This module imports **only types** from `@vitejs/devtools-kit` — they are erased at emit, so the
@@ -100,11 +100,10 @@ interface HookRow {
} }
type TsconfigInfo = type TsconfigInfo =
| { enabled: false } | { enabled: false }
| { enabled: true; paths: Record<string, string[]>; appJson: string; nodeJson: string; dts: string } | { enabled: true; paths: Record<string, string[]>; appJson: string; nodeJson: string; dts: string; superDts: string }
interface Snapshot { interface Snapshot {
projectName: string projectName: string
appDir: string
mode: string mode: string
command: string command: string
layers: LayerRow[] layers: LayerRow[]
@@ -120,30 +119,6 @@ interface Snapshot {
inheritanceTree: string inheritanceTree: string
} }
/** Recursively list files under a directory (absolute paths); `[]` if it isn't a directory. */
function walk(dir: string, out: string[] = []): string[] {
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
return out
}
for (const name of entries) {
const abs = join(dir, name)
// Guard each stat: a broken symlink or a file unlinked between readdir and stat (a real TOCTOU
// window under the dev watcher) throws ENOENT — skip it instead of failing the whole snapshot.
let isDir: boolean
try {
isDir = statSync(abs).isDirectory()
} catch {
continue
}
if (isDir) walk(abs, out)
else out.push(abs)
}
return out
}
const asArray = (v: unknown): string[] => const asArray = (v: unknown): string[] =>
v == null ? [] : (Array.isArray(v) ? v : [v]).map(String) v == null ? [] : (Array.isArray(v) ? v : [v]).map(String)
@@ -247,7 +222,7 @@ async function collectSnapshot(data: LayersDevtoolsData): Promise<Snapshot> {
}) })
const byPath = new Map<string, string[]>() const byPath = new Map<string, string[]>()
for (const { name, dir } of publicLayers) { for (const { name, dir } of publicLayers) {
for (const abs of walk(dir)) { for (const abs of walkFiles(dir)) {
const rel = toPosix(relative(dir, abs)) const rel = toPosix(relative(dir, abs))
;(byPath.get(rel) ?? byPath.set(rel, []).get(rel)!).push(name) ;(byPath.get(rel) ?? byPath.set(rel, []).get(rel)!).push(name)
} }
@@ -288,12 +263,12 @@ async function collectSnapshot(data: LayersDevtoolsData): Promise<Snapshot> {
appJson: JSON.stringify(gen.tsconfig, null, 2), appJson: JSON.stringify(gen.tsconfig, null, 2),
nodeJson: JSON.stringify(gen.nodeTsconfig, null, 2), nodeJson: JSON.stringify(gen.nodeTsconfig, null, 2),
dts: gen.dts, dts: gen.dts,
superDts: gen.superDts,
} }
} }
return { return {
projectName: layers[0]?.name ?? 'app', projectName: layers[0]?.name ?? 'app',
appDir: data.appDir,
mode: data.env.mode, mode: data.env.mode,
command: data.env.command, command: data.env.command,
layers: layerRows, layers: layerRows,
@@ -538,8 +513,6 @@ function buildFeaturesSpec(snap: Snapshot): JsonRenderSpec {
interface ResolveResult { interface ResolveResult {
id: string id: string
sub: string
query: string
candidates: string[] candidates: string[]
error?: string error?: string
} }
@@ -588,9 +561,10 @@ function buildResolverSpec(data: LayersDevtoolsData, query: string, result: Reso
return s.build(s.vstack(sections, 14, 12)) return s.build(s.vstack(sections, 14, 12))
} }
/** Describe how a record resolved: a normal import, a `super()` self-import, or unresolved. */ /** Describe how a record resolved: a normal import, `#super`, a `super()` self-import, or unresolved. */
function recordVia(r: ResolveRecord): string { function recordVia(r: ResolveRecord): string {
if (r.resolved === null) return 'unresolved' if (r.resolved === null) return 'unresolved'
if (r.id.startsWith('#super')) return '#super'
if (r.selfIndex < 0) return 'top match' if (r.selfIndex < 0) return 'top match'
return `super() #${r.selfIndex + 1}` return `super() #${r.selfIndex + 1}`
} }
@@ -619,7 +593,7 @@ function resolveResultEls(s: Spec, data: LayersDevtoolsData, result: ResolveResu
})), })),
'240px', '240px',
), ),
s.text('A self-import (an override importing its own path) would super()-skip to the next row down.', 'caption'), s.text('#super (or a self-import of an overrides own path) skips to the next row down.', 'caption'),
] ]
} }
@@ -662,6 +636,7 @@ function buildAssetsSpec(snap: Snapshot): JsonRenderSpec {
sections.push(s.card('.vite-layers/tsconfig.json', [s.code(ts.appJson, 'tsconfig.json')], true)) sections.push(s.card('.vite-layers/tsconfig.json', [s.code(ts.appJson, 'tsconfig.json')], true))
sections.push(s.card('.vite-layers/tsconfig.node.json', [s.code(ts.nodeJson, 'tsconfig.node.json')], true)) sections.push(s.card('.vite-layers/tsconfig.node.json', [s.code(ts.nodeJson, 'tsconfig.node.json')], true))
sections.push(s.card('.vite-layers/features.d.ts', [s.code(ts.dts, 'features.d.ts')], true)) sections.push(s.card('.vite-layers/features.d.ts', [s.code(ts.dts, 'features.d.ts')], true))
sections.push(s.card('.vite-layers/super.d.ts', [s.code(ts.superDts, 'super.d.ts')], true))
} else { } else {
sections.push(s.card('TypeScript', [s.text('tsconfig autogeneration is disabled (tsconfig: false).', 'caption')])) sections.push(s.card('TypeScript', [s.text('tsconfig autogeneration is disabled (tsconfig: false).', 'caption')]))
} }
@@ -693,18 +668,16 @@ const action = (name: string, handler: (params?: Record<string, unknown>) => voi
*/ */
function runResolve(data: LayersDevtoolsData, rawId: unknown): ResolveResult { function runResolve(data: LayersDevtoolsData, rawId: unknown): ResolveResult {
const id = String(rawId ?? '').trim() const id = String(rawId ?? '').trim()
if (!id) return { id, candidates: [], error: 'Enter an import id.' }
const parsed = data.resolution.parse(id) const parsed = data.resolution.parse(id)
if (!id) return { id, sub: '', query: '', candidates: [], error: 'Enter an import id.' }
if (!parsed) { if (!parsed) {
return { return {
id, id,
sub: '',
query: '',
candidates: [], candidates: [],
error: `Not a layered id — must start with one of: ${data.resolution.prefixes.join(', ')}`, error: `Not a layered id — must start with one of: ${data.resolution.prefixes.join(', ')}`,
} }
} }
return { id, sub: parsed.sub, query: parsed.query, candidates: data.resolution.candidates(parsed.sub) } return { id, candidates: data.resolution.candidates(parsed.sub) }
} }
/** /**
+34 -32
View File
@@ -353,29 +353,29 @@ export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
if (id.includes('/node_modules/')) return null if (id.includes('/node_modules/')) return null
if (!code.includes(FEATURE_MODULE) && !code.includes('vite-layers/feature')) return null if (!code.includes(FEATURE_MODULE) && !code.includes('vite-layers/feature')) return null
// A real macro module that fails to parse must NEVER be skipped silently — its feature() calls // A real macro module that fails to parse must NEVER be skipped silently — its feature() calls
// would ship uncompiled. oxc reports syntax errors in `errors` (it does not throw) and yields an // would ship uncompiled. oxc reports syntax errors in `errors` (it does not throw) and yields an
// empty/partial body, which otherwise looks like "no macro here". So: when the module references // empty/partial body, which otherwise looks like "no macro here". So: when the module references
// `#feature` in a from-clause, any parse failure is a hard build error; if `#feature` only shows // `#feature` in a from-clause, any parse failure is a hard build error; if `#feature` only shows
// up in a string/comment, stay out of the way and let the rest of the pipeline proceed. // up in a string/comment, stay out of the way and let the rest of the pipeline proceed.
let result: ReturnType<typeof parseSync> let result: ReturnType<typeof parseSync>
try { try {
result = parseSync(id.split('?', 1)[0]!, code, { sourceType: 'module', lang: langFromId(id) }) result = parseSync(id.split('?', 1)[0]!, code, { sourceType: 'module', lang: langFromId(id) })
} catch (err) { } catch (err) {
if (FEATURE_FROM_RE.test(code)) { if (FEATURE_FROM_RE.test(code)) {
this.error(`vite-layers: could not parse ${id} to compile its feature() calls — ${(err as Error)?.message ?? err}`) this.error(`vite-layers: could not parse ${id} to compile its feature() calls — ${(err as Error)?.message ?? err}`)
} }
return null return null
} }
if (result.errors?.length && FEATURE_FROM_RE.test(code)) { if (result.errors?.length && FEATURE_FROM_RE.test(code)) {
this.error(`vite-layers: ${id} has syntax errors; cannot safely compile its feature() calls — ${result.errors[0]?.message ?? ''}`) this.error(`vite-layers: ${id} has syntax errors; cannot safely compile its feature() calls — ${result.errors[0]?.message ?? ''}`)
} }
const program = result.program as unknown as AnyNode const program = result.program as unknown as AnyNode
// Pass 1: collect the local binding name(s) imported from our module, and the import nodes. // Pass 1: collect the local binding name(s) imported from our module, and the import nodes.
const importDecls: AnyNode[] = [] const importDecls: AnyNode[] = []
const locals = new Set<string>() const locals = new Set<string>()
for (const node of program.body as AnyNode[]) { for (const node of program.body as AnyNode[]) {
if (node.type === 'ImportDeclaration' && isImportSource(node, FEATURE_SPECIFIERS)) { if (node.type === 'ImportDeclaration' && isImportSource(node, FEATURE_SPECIFIERS)) {
if (node.importKind === 'type') continue // `import type { feature }` — fully erased, ignore if (node.importKind === 'type') continue // `import type { feature }` — fully erased, ignore
importDecls.push(node) importDecls.push(node)
@@ -400,15 +400,15 @@ export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
) { ) {
this.error('vite-layers: re-exporting the `feature` macro is not supported — import and call it directly.', node.start) this.error('vite-layers: re-exporting the `feature` macro is not supported — import and call it directly.', node.start)
} }
} }
if (locals.size === 0) return null if (locals.size === 0) return null
// Pass 2: every reference to the binding (that isn't shadowed by a local of the same name) // Pass 2: every reference to the binding (that isn't shadowed by a local of the same name)
// must be a direct `feature('known-key')` call; anything else is a hard error. // must be a direct `feature('known-key')` call; anything else is a hard error.
const s = new MagicString(code) const s = new MagicString(code)
const edits: Array<[number, number, string]> = [] const edits: Array<[number, number, string]> = []
const handleRef = (node: AnyNode, parent: AnyNode | null) => { const handleRef = (node: AnyNode, parent: AnyNode | null) => {
if (parent) { if (parent) {
// Binding/declaration positions and non-reference uses of the name — not macro calls. // Binding/declaration positions and non-reference uses of the name — not macro calls.
if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier' || parent.type === 'ImportNamespaceSpecifier') return if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier' || parent.type === 'ImportNamespaceSpecifier') return
@@ -439,11 +439,11 @@ export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
node.start, node.start,
) )
} }
} }
// Scope-aware descent: a reference is the macro only if no enclosing scope re-binds its name // Scope-aware descent: a reference is the macro only if no enclosing scope re-binds its name
// (so an unrelated local `feature` param/const/catch/… is left untouched, not falsely rejected). // (so an unrelated local `feature` param/const/catch/… is left untouched, not falsely rejected).
const descend = (node: AnyNode, parent: AnyNode | null, shadow: Set<string>) => { const descend = (node: AnyNode, parent: AnyNode | null, shadow: Set<string>) => {
let childShadow = shadow let childShadow = shadow
if (SCOPE_NODES.has(node.type)) { if (SCOPE_NODES.has(node.type)) {
const bound = scopeBindings(node, locals) const bound = scopeBindings(node, locals)
@@ -466,13 +466,15 @@ export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
descend(child as AnyNode, node, childShadow) descend(child as AnyNode, node, childShadow)
} }
} }
} }
descend(program, null, new Set()) descend(program, null, new Set())
for (const [start, end, text] of edits) s.overwrite(start, end, text) for (const [start, end, text] of edits) s.overwrite(start, end, text)
for (const decl of importDecls) s.remove(decl.start, decl.end) for (const decl of importDecls) s.remove(decl.start, decl.end)
return { code: s.toString(), map: s.generateMap({ source: id, hires: true }) } // 'boundary' maps token boundaries instead of every character — far cheaper than `true`,
// accurate enough to step over a substituted feature() call.
return { code: s.toString(), map: s.generateMap({ source: id, hires: 'boundary' }) }
}, },
}, },
} }
+1
View File
@@ -17,6 +17,7 @@ export {
createLayeredResolution, createLayeredResolution,
DEFAULT_EXTENSIONS, DEFAULT_EXTENSIONS,
layersResolver, layersResolver,
SUPER_MODULE,
type LayeredResolution, type LayeredResolution,
type LayersResolverOptions, type LayersResolverOptions,
type ParsedLayeredId, type ParsedLayeredId,
+5 -4
View File
@@ -104,16 +104,17 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions
'@@': project.rootDir, '@@': project.rootDir,
[FEATURE_MODULE]: FEATURE_FILE, // `#feature` → the macro entry (compiled away by featurePlugin) [FEATURE_MODULE]: FEATURE_FILE, // `#feature` → the macro entry (compiled away by featurePlugin)
} }
// `#layers/<name>` → layer rootDir. Iterate low→high so the highest-priority layer wins (first-wins). // Low→high iteration order: for aliases the highest-priority layer overwrites (first-wins by
for (const l of [...layers].reverse()) alias[`#layers/${l.name}`] = l.rootDir // name), for config fragments it merges last (higher layers override).
const lowToHigh = [...layers].reverse()
for (const l of lowToHigh) alias[`#layers/${l.name}`] = l.rootDir
let vite: UserConfig = { let vite: UserConfig = {
resolve: { alias }, resolve: { alias },
build: { outDir: options.outDir ?? `dist/${basename(appDir)}` }, build: { outDir: options.outDir ?? `dist/${basename(appDir)}` },
} }
// Layer fragments: low → high so higher-priority layers override. for (const l of lowToHigh) {
for (const l of [...layers].reverse()) {
const frag = typeof l.config.vite === 'function' ? l.config.vite(env) : l.config.vite const frag = typeof l.config.vite === 'function' ? l.config.vite(env) : l.config.vite
if (frag) vite = mergeConfig(vite, frag) if (frag) vite = mergeConfig(vite, frag)
} }
+3 -20
View File
@@ -1,25 +1,8 @@
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs' import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path' import { dirname, join, relative, resolve } from 'node:path'
import sirv from 'sirv' import sirv from 'sirv'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
import { toPosix } from './util' import { toPosix, walkFiles } from './util'
/** Recursively list files under a directory (absolute paths). */
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const abs = join(dir, name)
// Skip a broken symlink / file removed mid-walk (ENOENT) rather than aborting the public copy.
let isDir: boolean
try {
isDir = statSync(abs).isDirectory()
} catch {
continue
}
if (isDir) walk(abs, out)
else out.push(abs)
}
return out
}
/** /**
* Merged `relativePath → sourceAbs` map across all layers. Walked low→high so the higher-priority * Merged `relativePath → sourceAbs` map across all layers. Walked low→high so the higher-priority
@@ -28,7 +11,7 @@ function walk(dir: string, out: string[] = []): string[] {
function mergePublic(dirs: string[]): Map<string, string> { function mergePublic(dirs: string[]): Map<string, string> {
const assets = new Map<string, string>() const assets = new Map<string, string>()
for (const dir of [...dirs].reverse()) { for (const dir of [...dirs].reverse()) {
for (const abs of walk(dir)) assets.set(toPosix(relative(dir, abs)), abs) for (const abs of walkFiles(dir)) assets.set(toPosix(relative(dir, abs)), abs)
} }
return assets return assets
} }
+104 -33
View File
@@ -6,6 +6,22 @@ import { toPosix } from './util'
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */ /** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue'] export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
/**
* The explicit `super()` import specifier: `#super/<path>` resolves `<path>` from the layer
* **strictly below** the importer's; bare `#super` is sugar for "my own layer-relative path, one
* layer down". Unlike the implicit self-import form it is greppable, survives copy-paste, and
* `#super/*` is typed in the generated tsconfig.
*/
export const SUPER_MODULE = '#super'
const SUPER_PREFIX = `${SUPER_MODULE}/`
const SUPER_QUERY = `${SUPER_MODULE}?`
/** Strip a `?query` suffix (allocation-free `split('?')[0]` — resolveId is per-import hot). */
const stripQuery = (s: string): string => {
const q = s.indexOf('?')
return q < 0 ? s : s.slice(0, q)
}
export interface LayersResolverOptions { export interface LayersResolverOptions {
/** Source roots ordered high→low priority (typically `layers.map(l => l.srcDir)`). */ /** Source roots ordered high→low priority (typically `layers.map(l => l.srcDir)`). */
roots: string[] roots: string[]
@@ -52,14 +68,18 @@ export interface LayeredResolution {
readonly roots: string[] readonly roots: string[]
readonly prefixes: string[] readonly prefixes: string[]
readonly extensions: string[] readonly extensions: string[]
/** Split a layered id into prefix/sub/query, or `null` if no prefix matches. */ /** Split a layered id into prefix/sub/query (`#super/` counts as a prefix), or `null`. */
parse: (id: string) => ParsedLayeredId | null parse: (id: string) => ParsedLayeredId | null
/** Ordered candidate files for a prefix-stripped sub-path, high→low priority. Cached. */ /** Ordered candidate files for a prefix-stripped sub-path, high→low priority. Cached. */
candidates: (sub: string) => string[] candidates: (sub: string) => string[]
/** Resolve a layered id (super()/self-skip + query preservation). `null` if not layered / no match. */ /** Resolve a layered id (`#super` + self-skip `super()` + query preservation). `null` if not layered / no match. */
resolveId: (id: string, importer?: string) => string | null resolveId: (id: string, importer?: string) => string | null
/** Drop the candidate cache (call when files are added/removed — which layer wins can change). */ /** Drop the whole candidate cache. Prefer the targeted invalidate* methods in dev. */
clear: () => void clear: () => void
/** Drop only the cache entries one added/removed file can affect (its sub + ext/index probe subs). */
invalidateFile: (file: string) => void
/** Drop the cache entries under (or probing into) a removed directory. */
invalidateDir: (dir: string) => void
/** Recorded resolutions, newest first (empty unless `record` was enabled). */ /** Recorded resolutions, newest first (empty unless `record` was enabled). */
records: () => ResolveRecord[] records: () => ResolveRecord[]
/** Clear the resolution log (the candidate cache is untouched). */ /** Clear the resolution log (the candidate cache is untouched). */
@@ -86,6 +106,10 @@ const isFile = (p: string): boolean => {
export function createLayeredResolution(options: LayersResolverOptions): LayeredResolution { export function createLayeredResolution(options: LayersResolverOptions): LayeredResolution {
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS, record = 0 } = options const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS, record = 0 } = options
// Layer index of a (posix, query-stripped) file, or -1 if outside every root.
const posixRoots = roots.map(r => toPosix(r))
const layerOf = (file: string): number => posixRoots.findIndex(r => file.startsWith(`${r}/`))
const probe = (root: string, sub: string): string | null => { const probe = (root: string, sub: string): string | null => {
const direct = resolve(root, sub) const direct = resolve(root, sub)
if (isFile(direct)) return direct if (isFile(direct)) return direct
@@ -117,7 +141,13 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered
} }
const parse = (id: string): ParsedLayeredId | null => { const parse = (id: string): ParsedLayeredId | null => {
const prefix = prefixes.find(p => id.startsWith(p)) // `#super/…` and bare `#super` always parse (bare → prefix `#super`, sub ''), so the devtools
// playground can list their candidates like any layered id.
const prefix = id.startsWith(SUPER_PREFIX)
? SUPER_PREFIX
: id === SUPER_MODULE || id.startsWith(SUPER_QUERY)
? SUPER_MODULE
: prefixes.find(p => id.startsWith(p))
if (!prefix) return null if (!prefix) return null
const q = id.indexOf('?') const q = id.indexOf('?')
const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes
@@ -125,13 +155,11 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered
return { prefix, sub, query } return { prefix, sub, query }
} }
// Bounded, de-duplicated resolution log (devtools). Keyed by a JSON-encoded `[id, importer]` pair // Bounded, de-duplicated resolution log (devtools). NUL-joined key: collision-proof (paths can't
// (collision-proof, unlike a delimiter string) so repeated resolves of the same import (HMR re-runs) // contain NUL), cheaper than JSON.stringify. Re-inserting moves an entry to the end (most-recent).
// update one entry instead of flooding the log; a Map preserves insertion order, and re-inserting
// moves the entry to the end (most-recent-last).
const log = new Map<string, ResolveRecord>() const log = new Map<string, ResolveRecord>()
const remember = (rec: ResolveRecord) => { const remember = (rec: ResolveRecord) => {
const key = JSON.stringify([rec.id, rec.importer ?? null]) // collision-proof composite key const key = `${rec.id}\0${rec.importer ?? '\0'}`
if (log.has(key)) log.delete(key) if (log.has(key)) log.delete(key)
log.set(key, rec) log.set(key, rec)
while (log.size > record) log.delete(log.keys().next().value!) while (log.size > record) log.delete(log.keys().next().value!)
@@ -147,24 +175,66 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered
const parsed = parse(id) const parsed = parse(id)
if (!parsed) return null if (!parsed) return null
const self = importer ? toPosix(importer.split('?')[0]!) : undefined const self = importer ? toPosix(stripQuery(importer)) : undefined
let list: string[]
let next: string | undefined
let selfIndex: number
if (parsed.prefix === SUPER_PREFIX || parsed.prefix === SUPER_MODULE) {
// #super — explicit super(): first candidate in a layer STRICTLY BELOW the importer's, so it
// works from any file, not just an override of the same path.
const myLayer = self ? layerOf(self) : -1
if (self === undefined || myLayer < 0) return null // importer outside the stack
// bare `#super` (sub === '') → the importer's own sub-path
list = candidates(parsed.sub || self.slice(posixRoots[myLayer]!.length + 1))
next = list.find(f => layerOf(f) > myLayer)
selfIndex = list.indexOf(self)
} else {
// super() via self-skip: if the importer is one of the candidates (an override importing its
// own layered path), resolve to the NEXT-LOWER layer; a normal importer isn't in the list, so
// it resolves to the highest-priority match (index 0). Note: "first candidate that isn't me"
// would be wrong — for a shadowed middle layer it jumps UP to a higher override, and a
// top↔mid self-import chain would cycle. Position-aware skip stays correct at any depth.
list = candidates(parsed.sub)
selfIndex = self ? list.indexOf(self) : -1
next = list[selfIndex + 1]
}
// super(): if the importer is one of the candidates (an override importing its own layered
// path), resolve to the NEXT-LOWER layer; a normal importer isn't in the list, so it resolves to
// the highest-priority match (index 0). Note: "first candidate that isn't me" would be wrong —
// for a shadowed middle layer it jumps UP to a higher override, and a top↔mid self-import chain
// would cycle. Position-aware skip makes super() correct through a deep extends chain.
const list = candidates(parsed.sub)
const selfIndex = self ? list.indexOf(self) : -1
const next = list[selfIndex + 1]
const resolved = next ? next + parsed.query : null const resolved = next ? next + parsed.query : null
if (record > 0) remember({ id, importer: self, resolved, candidates: list, selfIndex }) if (record > 0) remember({ id, importer: self, resolved, candidates: list, selfIndex })
return resolved return resolved
}, },
clear() { clear() {
cache.clear() cache.clear()
}, },
invalidateFile(file) {
const f = toPosix(file)
for (const root of posixRoots) {
// every root, not just the first — with nested roots a file has a different sub per root
if (!f.startsWith(`${root}/`)) continue
const sub = f.slice(root.length + 1)
cache.delete(sub)
for (const ext of extensions) {
if (!sub.endsWith(ext)) continue
const bare = sub.slice(0, -ext.length) // extension probe: `@/foo` ← foo.ts
cache.delete(bare)
if (bare.endsWith('/index')) cache.delete(bare.slice(0, -'/index'.length)) // `@/dir` ← dir/index.ts
break
}
}
},
invalidateDir(dir) {
const d = toPosix(dir)
for (const root of posixRoots) {
if (!d.startsWith(`${root}/`)) continue
const sub = d.slice(root.length + 1)
const prefix = `${sub}/`
for (const key of cache.keys()) {
// `key === sub`: `@/dir` may have resolved via dir/index.*
if (key === sub || key.startsWith(prefix)) cache.delete(key)
}
}
},
records() { records() {
return [...log.values()].reverse() return [...log.values()].reverse()
}, },
@@ -186,11 +256,10 @@ const isResolution = (v: LayersResolverOptions | LayeredResolution): v is Layere
* Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then `<path><ext>`, * Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then `<path><ext>`,
* then `<path>/index<ext>`. * then `<path>/index<ext>`.
* *
* Improvement over Nuxt: **self-skip** gives `super()` semantics at any depth. When the importer is * Improvement over Nuxt: `super()` semantics at any depth, in two forms — the explicit
* itself one of the matches (an override importing its own layered path), resolution continues to the * `#super`/`#super/<path>` (preferred: greppable and typed, see {@link SUPER_MODULE}) and the
* **next-lower** layer — so an override at `@/components/Foo.vue` can import `@/components/Foo.vue` to * implicit self-skip (Nuxt-parity: an override importing its own layered path resolves to the
* reach the layer beneath it. This composes through a deep `extends` chain: top→mid→base each resolve * next-lower layer). Both compose through a deep `extends` chain, one step down per layer.
* one step down, so multi-level overrides can each call `super()`.
* *
* Accepts either {@link LayersResolverOptions} (builds its own {@link LayeredResolution}) or a * Accepts either {@link LayersResolverOptions} (builds its own {@link LayeredResolution}) or a
* pre-built resolution — `buildViteConfig` passes a shared instance so the devtools panel introspects * pre-built resolution — `buildViteConfig` passes a shared instance so the devtools panel introspects
@@ -198,20 +267,22 @@ const isResolution = (v: LayersResolverOptions | LayeredResolution): v is Layere
*/ */
export function layersResolver(source: LayersResolverOptions | LayeredResolution): Plugin { export function layersResolver(source: LayersResolverOptions | LayeredResolution): Plugin {
const resolution = isResolution(source) ? source : createLayeredResolution(source) const resolution = isResolution(source) ? source : createLayeredResolution(source)
// Hook filter (rolldown): a RegExp matching the layered prefixes, so the bundler only invokes // Hook filter (rolldown): only layered prefixes + `#super` reach the JS handler; every other
// resolveId for `@/`/`~/` ids — every other specifier skips the JS round-trip. (resolveId filters // specifier skips the round-trip. https://rolldown.rs/in-depth/why-plugin-hook-filter
// accept only RegExp ids, not string globs.) https://rolldown.rs/in-depth/why-plugin-hook-filter const idFilter = new RegExp(
const idFilter = new RegExp(`^(?:${resolution.prefixes.map(escapeRegExp).join('|')})`) `^(?:${resolution.prefixes.map(escapeRegExp).join('|')}|${SUPER_MODULE}(?:/|\\?|$))`,
)
return { return {
name: 'vite-layers:resolve', name: 'vite-layers:resolve',
enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases
configureServer(server) { configureServer(server) {
// A new/removed file can change which layer wins → drop the cache in dev. // A new/removed file can change which layer wins. Targeted invalidation, not a full clear —
const clear = () => resolution.clear() // dev codegen (typed-router, dts emitters) would otherwise wipe the cache on every emit.
server.watcher.on('add', clear) const invalidateFile = (file: string) => resolution.invalidateFile(file)
server.watcher.on('unlink', clear) server.watcher.on('add', invalidateFile)
server.watcher.on('unlinkDir', clear) server.watcher.on('unlink', invalidateFile)
server.watcher.on('unlinkDir', dir => resolution.invalidateDir(dir))
}, },
resolveId: { resolveId: {
filter: { id: idFilter }, filter: { id: idFilter },
+26 -4
View File
@@ -30,6 +30,20 @@ export interface GenerateTsConfigOptions {
hooks?: LayerHookable hooks?: LayerHookable
} }
/**
* Ambient declaration for bare `#super` — importer-relative, so `paths` cannot express it; an empty
* ambient module at least makes side-effect imports type-check. MUST stay a **script** d.ts (no
* top-level import/export): inside a module file `declare module` becomes an augmentation of an
* unresolvable specifier (TS2664).
*/
const SUPER_DTS = [
'// AUTO-GENERATED by vite-layers — do not edit.',
"// Bare `#super` re-resolves the importer's own path one layer down; it is importer-relative, so",
"// it cannot be typed. Use `#super/<path>` for typed super() imports (see generated `paths`).",
"declare module '#super' {}",
'',
].join('\n')
/** A path not already starting with `.` — {@link rel} prefixes it with `./`. */ /** A path not already starting with `.` — {@link rel} prefixes it with `./`. */
const LEADING_NON_DOT_RE = /^([^.])/ const LEADING_NON_DOT_RE = /^([^.])/
/** Port of Nuxt's `relativeWithDot`: guarantees a leading `./`, returns `.` for the self case. */ /** Port of Nuxt's `relativeWithDot`: guarantees a leading `./`, returns `.` for the self case. */
@@ -95,6 +109,10 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
// the generated `features.d.ts` augmentation. Matches the alias buildViteConfig registers. // the generated `features.d.ts` augmentation. Matches the alias buildViteConfig registers.
[FEATURE_MODULE]: [rel(genDir, FEATURE_FILE)], [FEATURE_MODULE]: [rel(genDir, FEATURE_FILE)],
} }
// `#super/*` → the layers BELOW the project layer: exact for project-layer overrides (the common
// case), one layer too high for a middle-layer file — still better than the self-import form,
// which TS resolves to the importer itself. Bare `#super` is covered by SUPER_DTS instead.
if (layers.length > 1) paths['#super/*'] = layers.slice(1).map(l => `${rel(genDir, l.srcDir)}/*`)
for (const l of layers) { for (const l of layers) {
// first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig. // first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig.
const star = `#layers/${l.name}/*` const star = `#layers/${l.name}/*`
@@ -109,7 +127,7 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
// are NOT here — they belong to the node config below. // are NOT here — they belong to the node config below.
const base: TSConfig = { const base: TSConfig = {
compilerOptions: { ...DEFAULT_COMPILER_OPTIONS }, compilerOptions: { ...DEFAULT_COMPILER_OPTIONS },
include: ['./features.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)], include: ['./features.d.ts', './super.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)],
exclude, exclude,
} }
@@ -141,20 +159,24 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
genDir, genDir,
dts: featuresDts(merged.features), dts: featuresDts(merged.features),
dtsFile: resolve(genDir, 'features.d.ts'), dtsFile: resolve(genDir, 'features.d.ts'),
superDts: SUPER_DTS,
superDtsFile: resolve(genDir, 'super.d.ts'),
} }
} }
/** /**
* Generate and write `<appDir>/.vite-layers/{tsconfig.json,features.d.ts}` (tsconfig via pkg-types * Generate and write `<appDir>/.vite-layers/{tsconfig.json,features.d.ts,super.d.ts}` (tsconfig via
* `writeTSConfig`). Returns the tsconfig path. * pkg-types `writeTSConfig`). Returns the tsconfig path.
*/ */
export async function writeTsConfig(appDir: string, opts?: GenerateTsConfigOptions): Promise<string> { export async function writeTsConfig(appDir: string, opts?: GenerateTsConfigOptions): Promise<string> {
const { tsconfig, file, nodeTsconfig, nodeFile, genDir, dts, dtsFile } = await generateTsConfig(appDir, opts) const { tsconfig, file, nodeTsconfig, nodeFile, genDir, dts, dtsFile, superDts, superDtsFile } =
await generateTsConfig(appDir, opts)
await mkdir(genDir, { recursive: true }) await mkdir(genDir, { recursive: true })
await Promise.all([ await Promise.all([
writeTSConfig(file, tsconfig), writeTSConfig(file, tsconfig),
writeTSConfig(nodeFile, nodeTsconfig), writeTSConfig(nodeFile, nodeTsconfig),
writeFile(dtsFile, dts), writeFile(dtsFile, dts),
writeFile(superDtsFile, superDts),
]) ])
return file return file
} }
+31
View File
@@ -1,3 +1,6 @@
import { readdirSync, statSync, type Dirent } from 'node:fs'
import { join } from 'node:path'
/** /**
* Normalize a path to forward slashes (POSIX-style). c12 returns posix-style `cwd`s while Node's * Normalize a path to forward slashes (POSIX-style). c12 returns posix-style `cwd`s while Node's
* `path` helpers are OS-native (backslashes on Windows); paths must be canonicalized to forward * `path` helpers are OS-native (backslashes on Windows); paths must be canonicalized to forward
@@ -6,3 +9,31 @@
*/ */
const SEPARATOR_RE = /\\/g const SEPARATOR_RE = /\\/g
export const toPosix = (p: string): string => p.replace(SEPARATOR_RE, '/') export const toPosix = (p: string): string => p.replace(SEPARATOR_RE, '/')
/**
* Recursively list files under a directory (absolute paths); `[]` if it isn't a directory.
* withFileTypes: half the syscalls of a per-entry statSync walk; only symlinks still need a stat
* (to follow them), and a broken one / a file unlinked mid-walk (ENOENT) is skipped, not fatal.
*/
export function walkFiles(dir: string, out: string[] = []): string[] {
let entries: Dirent[]
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
const abs = join(dir, entry.name)
let isDir = entry.isDirectory()
if (entry.isSymbolicLink()) {
try {
isDir = statSync(abs).isDirectory()
} catch {
continue
}
}
if (isDir) walkFiles(abs, out)
else out.push(abs)
}
return out
}
+143 -2
View File
@@ -1,6 +1,8 @@
import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path' import { dirname, join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
import { createLayeredResolution, layersResolver } from '../src/resolve' import { createLayeredResolution, layersResolver } from '../src/resolve'
@@ -70,6 +72,55 @@ describe('layersResolver', () => {
}) })
}) })
describe('#super — the explicit super() specifier', () => {
const brandHeader = fixture('brand/src/components/Header.vue')
const baseHeader = fixture('base/src/components/Header.vue')
it('bare #super re-resolves the importers own path from the next-lower layer', () => {
expect(resolveId('#super', brandHeader)).toBe(baseHeader)
})
it('#super/<path> resolves any path from strictly below the importers layer', () => {
// the importer is NOT an override of Header — the implicit self-skip can't express this
expect(resolveId('#super/components/Header.vue', fixture('brand/src/main.ts'))).toBe(baseHeader)
})
it('never resolves to the importers own layer (strictly below, not first match)', () => {
expect(resolveId('#super/components/Header.vue', brandHeader)).toBe(baseHeader)
})
it('returns null from the lowest layer / for a path absent below', () => {
expect(resolveId('#super', baseHeader)).toBeNull()
// Footer.vue exists only in base — from a brand importer it resolves DOWN to base's copy…
expect(resolveId('#super/components/Footer.vue', brandHeader)).toBe(fixture('base/src/components/Footer.vue'))
// …but from base itself there is nothing beneath.
expect(resolveId('#super/components/Footer.vue', fixture('base/src/components/Footer.vue'))).toBeNull()
})
it('returns null when the importer is outside the layer stack (or absent)', () => {
expect(resolveId('#super')).toBeNull()
expect(resolveId('#super/components/Header.vue', '/somewhere/else/file.ts')).toBeNull()
})
it('preserves query suffixes on both forms', () => {
expect(resolveId('#super?raw', brandHeader)).toBe(`${baseHeader}?raw`)
expect(resolveId('#super/components/Header.vue?vue&type=style', brandHeader)).toBe(`${baseHeader}?vue&type=style`)
})
it('probes extensions/index like any layered id', () => {
expect(resolveId('#super/widgets/Card', brandHeader)).toBe(fixture('base/src/widgets/Card/index.ts'))
})
it('composes through a deep (3-layer) chain, one step down per layer', () => {
const deepRoots = [fixture('deep/top/src'), fixture('deep/mid/src'), fixture('deep/base/src')]
const dp = layersResolver({ roots: deepRoots })
const W = (layer: string) => fixture(`deep/${layer}/src/components/Widget.vue`)
expect(callResolveId(dp, '#super', W('top'))).toBe(W('mid'))
expect(callResolveId(dp, '#super', W('mid'))).toBe(W('base'))
expect(callResolveId(dp, '#super', W('base'))).toBeNull()
})
})
it('returns null when nothing matches across layers', () => { it('returns null when nothing matches across layers', () => {
expect(resolveId('@/components/Missing.vue')).toBeNull() expect(resolveId('@/components/Missing.vue')).toBeNull()
}) })
@@ -87,6 +138,15 @@ describe('layersResolver', () => {
expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here
}) })
it('the hook filter matches #super ids but not lookalikes', () => {
const h = plugin.resolveId as { filter: { id: RegExp } }
expect(h.filter.id.test('#super')).toBe(true)
expect(h.filter.id.test('#super/components/Header.vue')).toBe(true)
expect(h.filter.id.test('#super?raw')).toBe(true)
expect(h.filter.id.test('#superstition')).toBe(false)
expect(h.filter.id.test('@/components/Header.vue')).toBe(true)
})
it('caches candidates (repeated resolveId is stable, served from cache)', () => { it('caches candidates (repeated resolveId is stable, served from cache)', () => {
const p = layersResolver({ roots }) const p = layersResolver({ roots })
const rid = (id: string) => callResolveId(p, id) const rid = (id: string) => callResolveId(p, id)
@@ -103,6 +163,15 @@ describe('createLayeredResolution (introspection core)', () => {
expect(r.parse('#layers/base/x')).toBeNull() expect(r.parse('#layers/base/x')).toBeNull()
}) })
it('parse() accepts #super/ as a pseudo-prefix (so devtools can show its candidate stack)', () => {
const r = createLayeredResolution({ roots })
expect(r.parse('#super/components/Header.vue?raw')).toEqual({
prefix: '#super/',
sub: 'components/Header.vue',
query: '?raw',
})
})
it('candidates() lists every matching file across layers, high→low', () => { it('candidates() lists every matching file across layers, high→low', () => {
const r = createLayeredResolution({ roots }) const r = createLayeredResolution({ roots })
expect(r.candidates('components/Header.vue')).toEqual([ expect(r.candidates('components/Header.vue')).toEqual([
@@ -151,6 +220,78 @@ describe('createLayeredResolution (introspection core)', () => {
expect(r.records().map(x => x.id)).toEqual(['@/components/Missing.vue', '@/components/Footer.vue']) expect(r.records().map(x => x.id)).toEqual(['@/components/Missing.vue', '@/components/Footer.vue'])
}) })
describe('targeted invalidation (invalidateFile / invalidateDir)', () => {
// Real temp layers — invalidation is about reacting to FS changes, fixtures can't change.
let tmp: string
const setup = () => {
tmp = mkdtempSync(join(tmpdir(), 'vite-layers-inv-'))
const brandSrc = join(tmp, 'brand/src')
const baseSrc = join(tmp, 'base/src')
mkdirSync(join(baseSrc, 'components'), { recursive: true })
mkdirSync(join(brandSrc, 'components'), { recursive: true })
writeFileSync(join(baseSrc, 'components/Button.ts'), 'export default 1')
return { brandSrc, baseSrc, r: createLayeredResolution({ roots: [brandSrc, baseSrc] }) }
}
afterEach(() => rmSync(tmp, { recursive: true, force: true }))
it('a new file in a higher layer changes the winner after invalidateFile', () => {
const { brandSrc, baseSrc, r } = setup()
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts'))) // cached
const override = join(brandSrc, 'components/Button.ts')
writeFileSync(override, 'export default 2')
// control: the stale cache still serves the old winner…
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
r.invalidateFile(override)
// …and the targeted invalidation flips it (extension-probe sub `components/Button` was dropped)
expect(r.resolveId('@/components/Button')).toBe(toPosix(override))
})
it('leaves unrelated cache entries warm and ignores files outside every root', () => {
const { brandSrc, baseSrc, r } = setup()
writeFileSync(join(baseSrc, 'components/Other.ts'), 'export default 3')
r.resolveId('@/components/Other')
const before = r.candidates('components/Other')
r.invalidateFile(join(brandSrc, 'components/Button.ts')) // different sub
r.invalidateFile(join(tmp, 'elsewhere/file.ts')) // outside every root — no-op
expect(r.candidates('components/Other')).toBe(before) // same array identity → still cached
})
it('a deleted file falls back to the lower layer after invalidateFile', () => {
const { brandSrc, baseSrc, r } = setup()
const override = join(brandSrc, 'components/Button.ts')
writeFileSync(override, 'export default 2')
expect(r.resolveId('@/components/Button')).toBe(toPosix(override))
unlinkSync(override)
r.invalidateFile(override)
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
})
it('invalidateFile on an index file also drops the bare-dir and dir/index subs', () => {
const { brandSrc, baseSrc, r } = setup()
mkdirSync(join(baseSrc, 'widgets/Card'), { recursive: true })
writeFileSync(join(baseSrc, 'widgets/Card/index.ts'), 'export default 1')
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(join(baseSrc, 'widgets/Card/index.ts')))
const override = join(brandSrc, 'widgets/Card/index.ts')
mkdirSync(join(brandSrc, 'widgets/Card'), { recursive: true })
writeFileSync(override, 'export default 2')
r.invalidateFile(override)
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(override))
})
it('invalidateDir drops everything under the dir including its own index sub', () => {
const { brandSrc, baseSrc, r } = setup()
mkdirSync(join(brandSrc, 'widgets/Card'), { recursive: true })
writeFileSync(join(brandSrc, 'widgets/Card/index.ts'), 'export default 2')
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(join(brandSrc, 'widgets/Card/index.ts')))
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
rmSync(join(brandSrc, 'widgets'), { recursive: true })
r.invalidateDir(join(brandSrc, 'widgets'))
expect(r.resolveId('@/widgets/Card')).toBeNull() // dir gone, nothing below
const warm = r.candidates('components/Button')
expect(r.candidates('components/Button')).toBe(warm) // unrelated entry untouched
})
})
it('the plugin and a shared resolution stay in sync', () => { it('the plugin and a shared resolution stay in sync', () => {
const shared = createLayeredResolution({ roots, record: 10 }) const shared = createLayeredResolution({ roots, record: 10 })
const plugin = layersResolver(shared) const plugin = layersResolver(shared)
+26
View File
@@ -107,6 +107,32 @@ describe('generateTsConfig', () => {
expect(r.dts).toContain(`declare module '#feature'`) expect(r.dts).toContain(`declare module '#feature'`)
}) })
it('maps #super/* to the layers BELOW the project layer (explicit super() is typed)', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
// project layer (app) excluded — super() never resolves to the importer's own layer
expect(paths['#super/*']).toEqual(['../../base/src/*', '../../core/src/*'])
})
it('omits #super/* for a single-layer stack (paths forbids empty substitution arrays)', async () => {
const stack = {
merged: {},
layers: [{ rootDir: fixture('stack/app'), srcDir: resolve(fixture('stack/app'), 'src'), name: 'app', config: {} }],
}
const { tsconfig } = await generateTsConfig(fixture('stack/app'), { stack: stack as never })
expect(tsconfig.compilerOptions!.paths).not.toHaveProperty('#super/*')
})
it('emits an ambient super.d.ts so a bare `import "#super"` type-checks', async () => {
const r = await generateTsConfig(fixture('stack/app'))
expect(r.tsconfig.include).toContain('./super.d.ts')
expect(r.superDtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/super\.d\.ts$/)
expect(r.superDts).toContain(`declare module '#super' {}`)
// must stay a SCRIPT file: a top-level import would turn `declare module` into an augmentation
// of an unresolvable specifier (TS2664)
expect(r.superDts).not.toMatch(/^\s*(import|export)\b/m)
})
it('maps #feature to the macro entry so tsc resolves the feature() import', async () => { it('maps #feature to the macro entry so tsc resolves the feature() import', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app')) const { tsconfig } = await generateTsConfig(fixture('stack/app'))
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]> const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
+360
View File
@@ -0,0 +1,360 @@
# Как устроен vue-sync-engine
Это объяснение «на пальцах»: что происходит внутри библиотеки от вызова
`useQuery()` до перерисовки компонента. Для справочника по API см.
[README.md](./README.md) — здесь фокус на механике и картинках.
## Зачем он вообще нужен
В обычном SPA каждый компонент сам решает, откуда брать данные: сам
дёргает `fetch`, сам хранит результат в `ref`, сам решает, когда обновить.
Если один и тот же пост показан в двух местах экрана — либо оба компонента
независимо грузят его заново, либо после мутации один обновился, а второй
остался со старыми данными.
vue-sync-engine убирает эту проблему через одну идею: **все данные живут
в одном месте, компоненты только на них подписываются.** Само место —
не «дерево ответов API», а плоский нормализованный кэш сущностей, как
таблицы в базе данных (в духе Apollo / RTK Query), а не как в наивном
`fetch`-кэше, где один и тот же пользователь может быть продублирован
внутри трёх разных ответов запросов.
## Главная идея одной картинкой
Библиотека всегда состоит из двух половин, даже если физически они
работают в одном JS-потоке: **вкладка** (то, что видит пользователь) и
**QueryGraph** — «мини-сервер», который решает, что и когда фетчить.
Между ними — заменяемый транспорт.
```mermaid
flowchart TB
subgraph TAB["📄 Вкладка браузера"]
direction TB
UI["Vue-компонент"]
HOOKS["useQuery / useMutation /\nuseInfiniteQuery / useEntity"]
RUNTIME["TabRuntime"]
MIRROR["Mirror\n(entities + query-state, ShallowRef)"]
UI --> HOOKS --> RUNTIME
RUNTIME <--> MIRROR
MIRROR -.-> UI
end
TRANSPORT{{"Transport\nInline (queueMicrotask) или\nSharedWorker (MessagePort)"}}
subgraph GRAPH["⚙️ QueryGraph — «мини-сервер» (тот же поток или SharedWorker)"]
direction TB
NODES["QueryNode-ы:\nдедуп fetch-ей, staleTime/gcTime,\nentityRefs"]
QUEUE["Очередь мутаций:\noptimistic → persist → retry/rollback"]
STORE[("StorageAdapter\nIndexedDB / память")]
NODES --> STORE
QUEUE --> STORE
end
RUNTIME -->|"Subscribe / Unsubscribe\nMutate / FetchNextPage"| TRANSPORT
TRANSPORT -->|"QueryPatch / EntityPatch\nMutateResult"| RUNTIME
TRANSPORT --> NODES
TRANSPORT --> QUEUE
```
Ключевая мысль: **вкладка никогда не фетчит данные сама.** Она только
посылает «хочу подписаться на такой-то запрос» и получает в ответ поток
патчей. Реальный `fetch()` живёт только в QueryGraph.
## Действующие лица
| Кто | Что это простыми словами |
|---|---|
| **Entity** | Тип сущности в кэше — «таблица» (`post`, `user`). Описывает только, как достать `id` у объекта, и опционально — где его персистить. |
| **Query / InfiniteQuery** | Описание запроса: как построить ключ кэша из аргументов, как зафетчить, как разложить ответ на сущности (`normalize`). |
| **Mutation** | Запись: `fetch` + опциональные `optimistic` (мгновенная правка) и `onSuccess` (правка после ответа) + `invalidate` (что перефетчить). |
| **Mirror** | Реактивный «слепок» на стороне вкладки: сущности по типам + состояния запросов. Единственное, что реально читают компоненты. |
| **TabRuntime** | Клиентская логика вкладки: подписки (с дедупом по хэшу ключа), их GC, отправка мутаций, разбор входящих патчей. |
| **QueryGraph** | Серверная логика: хранит `QueryNode` на каждый уникальный запрос, дедуплицирует fetch, гидрирует из storage, рассылает патчи всем подписчикам. |
| **Transport** | Канал сообщений между вкладкой и QueryGraph. Две реализации: `Inline` (тот же поток, батчинг через `queueMicrotask`) и `SharedWorker` (через `MessagePort`). |
| **StorageAdapter / KeyedStore** | Персистентность. Два независимых уровня — см. [раздел ниже](#persistence-два-независимых-уровня). |
## Что происходит по шагам: от `useQuery()` до рендера
```mermaid
sequenceDiagram
autonumber
participant C as Компонент
participant TR as TabRuntime
participant M as Mirror
participant QG as QueryGraph
participant S as Storage
C->>TR: useQuery(usersQuery, args)
TR->>M: ensureQuery(subId) → status: idle
TR->>QG: Subscribe(subId, defName, args)
Note over QG: ensureNode() — находит или создаёт QueryNode по hash(key(args))
alt узел новый и в storage есть валидный снапшот
QG->>S: queries.read(key) + entities.readMany()
S-->>QG: QuerySnapshot + сущности
QG->>TR: EntityPatch (восстановленные сущности)
QG->>TR: QueryPatch(status: success, cached result)
end
opt данных нет или они устарели (age > staleTime)
QG->>TR: QueryPatch(status: pending)
TR->>M: applyQueryPatch → status: pending
M-->>C: isLoading = true
QG->>QG: fetch(args) → normalize(response)
QG->>S: сохранить QuerySnapshot + сущности
QG->>TR: EntityPatch (новые/обновлённые сущности)
QG->>TR: QueryPatch(status: success, result)
end
TR->>M: applyEntityPatches + applyQueryPatch
M-->>C: data / status обновились → компонент перерисовался
```
Важные детали, которые не видны в коде компонента:
- **Дедупликация по ключу.** `subscribeQuery` хэширует `key(args)`
(`hashKey`, стабильная сериализация — порядок полей объекта не важен) и
ищет уже существующую подписку. Если два компонента одновременно
вызвали `useQuery(usersQuery, ...)` с одинаковыми аргументами — будет
один `QueryNode` и один fetch на двоих.
- **`isLoading` мигает и при фоновом рефетче.** Если данные уже есть, но
протухли (`age > staleTime`), QueryGraph сперва отдаёт кэш мгновенно, а
затем всё равно переводит статус в `pending` на время рефетча. Отдельного
флага `isFetching`/`isRefetching` в библиотеке нет — `isLoading` покрывает
оба случая: и первую загрузку, и фоновое обновление устаревших данных.
- **Протухание проверяется только при новой подписке.** Нет ни `setInterval`,
ни `visibilitychange`/`focus`-слушателей, которые бы сами дёргали рефетч
фонового запроса. Пока подписчик один и не размонтировался — застоявшиеся
данные просто лежат в кэше, пока кто-то не подпишется заново (например,
при возврате на страницу) или пока их явно не инвалидирует мутация.
- **GC-окно на отписку.** `onScopeDispose` вызывает `release()`, но реальная
отписка (`Unsubscribe` в QueryGraph) откладывается на `staleSubGcMs`
(по умолчанию 5 c). Это защита от «мигания»: быстрый переход между
вкладками/роутами не должен рвать подписку и гнать повторный fetch.
## Нормализация: почему кэш плоский
`normalize()` в определении запроса разбирает ответ API на **сущности**
(что идёт в общий кэш) и **result** (тонкая структура из id, которая
хранится именно в этом запросе).
```mermaid
flowchart LR
RESP["Ответ API:\nPost {id:1, title, userId:5,\nauthor: {id:5, name...}}"] --> NORM["normalize(response)"]
NORM --> EPOST[("entities.post\n{1: {...}}")]
NORM --> EUSER[("entities.user\n{5: {...}}")]
NORM --> RES["result запроса\n{ids: [1]}"]
```
Почему это важнее, чем кажется: если пользователь `5` встречается ещё в
десяти других постах или в отдельном запросе `users.list`, это **один и тот
же объект в `entities.user`**, а не десять копий. `useEntity(UserEntity, 5)`
в любом компоненте — включая совсем не связанные с исходным запросом —
всегда прочитает актуальную версию. Оптимистичная мутация, которая
поправила имя пользователя, мгновенно видна везде, где он упомянут, без
ручной инвалидации каждого места.
## Кэш и время жизни: `staleTime` и `gcTime`
У каждого `QueryNode` есть простой жизненный цикл:
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Pending: первая подписка
Pending --> Success: fetch выполнен успешно
Pending --> Error: fetch завершился ошибкой
Success --> Pending: новая подписка застала данные протухшими (age > staleTime)
Success --> NoSubscribers: отписался последний подписчик
NoSubscribers --> Success: подписались снова до истечения gcTime
NoSubscribers --> [*]: gcTime истёк — узел и запись в storage удалены
```
Аналогия — молоко на полке магазина:
- **`staleTime`** — «срок годности для доверия». Пока не истёк, новый
покупатель (подписчик) берёт с полки без вопросов, fetch не идёт.
По умолчанию 30 с.
- **`gcTime`** — «через сколько выбросить, если никто не берёт». Отсчёт
идёт с момента, когда отписался последний подписчик. Если до истечения
подписался кто-то новый — таймер просто отменяется. Если нет — узел и
соответствующая запись в `storage.queries` удаляются насовсем.
По умолчанию 5 мин.
Оба значения задаются дефолтами при бутстрапе (`createEngine({ defaultStaleTime, defaultGcTime })`) и переопределяются на уровне конкретного `defineQuery(...)`.
### Инвалидация
Мутация может явно перевести чужие узлы обратно в `Pending`, указав теги
или сами деф-объекты в `invalidate`. Узлы без активных подписчиков в этот
момент просто помечаются протухшими — рефетч случится при следующей
подписке, а не сразу.
## Мутации: мгновенный UI + автоматический откат
Самая интересная часть. Когда вы вызываете `mutate(input)`, происходит
следующее:
```mermaid
sequenceDiagram
autonumber
participant C as Компонент
participant TR as TabRuntime
participant QG as QueryGraph
participant Q as Очередь мутаций
participant API as Сервер
participant M as Mirror
C->>TR: mutate({id, title})
TR->>QG: Mutate(mutId, input)
QG->>Q: enqueue(mutId, input)
Q->>Q: optimistic(input, ctx) считает forward- и inverse-патчи
Q->>TR: EntityPatch (forward) — мгновенно
TR->>M: применить патч
M-->>C: UI обновился ДО ответа сервера
Q->>Q: persist в storage (переживёт перезагрузку страницы)
Q->>API: fetch(input)
alt успех
API-->>Q: ответ сервера
Q->>Q: onSuccess(ctx) + invalidate(tags)
Q->>TR: MutateResult(ok: true)
TR-->>C: mutateAsync() resolve
else сетевая ошибка, есть ещё попытки
API-->>Q: ошибка сети
Q->>Q: остаётся pending, drain() повторит попытку позже
else ошибка, попытки исчерпаны (или ошибка не сетевая)
Q->>Q: rollback — применить inverse-патчи в обратном порядке
Q->>TR: EntityPatch (inverse)
TR->>M: откатить патч
M-->>C: UI вернулся к прежнему состоянию
Q->>TR: MutateResult(ok: false)
TR-->>C: mutateAsync() reject
end
```
Что стоит понимать про `optimistic`:
```ts
optimistic: (input, ctx) => ctx.patchEntity(PostEntity, input.id, { title: input.title })
```
Вызывая `patchEntity` / `upsertEntity` / `removeEntity`, вы не пишете
rollback руками. Движок сам на лету считает **инверсный патч** (было —
стало наоборот) и применяет его автоматически, если мутация в итоге
провалилась.
### Очередь мутаций — она же офлайн-режим
`QueuedMutation` пишется в `storage.mutations` **до** отправки запроса.
Это значит:
- если вкладку закрыть/обновить посреди мутации — при следующем старте
движок подхватит незавершённые мутации из storage и продолжит попытки;
- retry идёт, только пока `navigator.onLine` и `attempts < maxRetries`
(по умолчанию 5); при возврате сети (`online`-событие) очередь сама
запускает `drain()`;
- сущности, тронутые ещё не завершённой мутацией, «запиниваются»
(`pinEntities`) — фоновая сборка мусора сущностей (`entityGc`) их не
тронет, пока мутация не разрешится.
## Два режима движка
Один и тот же `QueryGraph` можно поднять либо в том же потоке, что и UI,
либо в `SharedWorker`, общем на все вкладки одного origin'а.
```mermaid
flowchart TB
subgraph INLINE["Inline — createEngine()"]
direction LR
T1["Вкладка"] --- QG1["QueryGraph\n(тот же JS-поток)"]
end
subgraph SHARED["SharedWorker — createTabEngine()"]
direction LR
T2["Вкладка 1"] -->|MessagePort| SW["SharedWorker\nодин QueryGraph на все вкладки"]
T3["Вкладка 2"] -->|MessagePort| SW
T4["Вкладка 3"] -->|MessagePort| SW
end
```
| | Inline (`createEngine`) | SharedWorker (`createTabEngine`) |
|---|---|---|
| Кросс-таб синхронизация | нет | да, мгновенно |
| Дедупликация fetch | в пределах одной вкладки | глобально на все вкладки |
| IndexedDB | каждая вкладка открывает свою | один общий instance |
| Сложность подключения | минимальная | нужен отдельный worker-файл |
Код компонентов и определения (`defineQuery` и т.д.) не меняются вообще —
разница только в том, как собран `TabRuntime` на старте приложения.
## Persistence: два независимых уровня
```mermaid
flowchart TB
QG["QueryGraph"] --> SA["StorageAdapter (уровень движка)"]
SA --> QS[("queries: QuerySnapshot\nрезультат + entityRefs")]
SA --> MQ[("mutations: QueuedMutation\nнезавершённые мутации")]
QG --> KS["KeyedStore (уровень сущности, опционально)"]
KS --> E1[("PostEntity → idbStore")]
KS --> E2["UserEntity → без storage, только память"]
```
1. **Уровень движка** (`StorageAdapter`, `memoryAdapter()` или
`indexedDBAdapter({ dbName })`) — хранит снапшоты результатов запросов
и очередь мутаций. Без него движок работает так же, но всё исчезает
при перезагрузке страницы.
2. **Уровень сущности** (`KeyedStore` в `defineEntity({ storage })`) —
каждый тип сущности сам решает, персистится ли он, независимо от
остальных. В демо `PostEntity` живёт в IndexedDB, а `UserEntity`
только в памяти, специально для контраста.
Оба уровня работают вместе: снапшот запроса хранит только `entityRefs`
(ссылки `{type, id}`), а сами данные сущностей при гидрации подтягиваются
из своего `KeyedStore`. Если у типа сущности нет `storage` и её нет в
памяти воркера — гидрация признаётся неудачной, снапшот выбрасывается, и
при следующей подписке всё просто перефетчится заново.
## Автодискавери определений через Vite-плагин
Вместо того чтобы руками собирать массивы `entities`/`queries`/`mutations`,
можно раскидать `defineEntity`/`defineQuery`/`defineMutation` по файлам
`*.defs.ts` и один раз подключить плагин:
```ts
syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] })
```
Плагин сканирует файлы по glob-маске и собирает всё найденное в один
виртуальный модуль `virtual:sync-engine-registry`. Дедуп — по `name`: если
один и тот же деф случайно экспортирован из двух мест, плагин молча
оставит первый найденный.
## Vue DevTools
`installEngine(app, runtime)` в dev-режиме сама подключает кастомную
панель «Sync Engine» с пятью узлами: **Engine** (дефолты, счётчики),
**Queries** (статус/tags/cache-метаданные по каждой подписке), **Entities**
(персистентные vs in-memory, список инстансов), **Mutations** (кольцевой
буфер последних 50) и **Tabs** (обнаружение других вкладок через отдельный
`BroadcastChannel`). В продакшене весь код вырезается через константу
`__SYNC_ENGINE_DEV__`.
## Шпаргалка: что за что отвечает в коде
| Файл | Отвечает за |
|---|---|
| [`createEngine.ts`](./lib/src/createEngine.ts) | Точки входа: `createEngine` / `createTabEngine` / `bootstrapWorker` / `installEngine` |
| [`define.ts`](./lib/src/define.ts) | Фабрики `defineEntity` / `defineQuery` / `defineInfiniteQuery` / `defineMutation`, `Object.freeze` |
| [`tab/mirror.ts`](./lib/src/tab/mirror.ts) | Реактивный кэш вкладки: сущности по типам + состояния запросов, `ShallowRef` на каждую сущность отдельно |
| [`tab/runtime.ts`](./lib/src/tab/runtime.ts) | `TabRuntime`: дедуп подписок по хэшу ключа, GC-таймер отписки, `mutate()` |
| [`worker/queryGraph.ts`](./lib/src/worker/queryGraph.ts) | «Сервер»: `QueryNode`-ы, дедуп fetch-ей, гидрация из storage, инвалидация, entity-refcounting |
| [`worker/mutationQueue.ts`](./lib/src/worker/mutationQueue.ts) | Очередь мутаций: optimistic → persist → retry → rollback |
| [`core/patches.ts`](./lib/src/core/patches.ts) | `applyPatch` + автогенерация инверсных патчей для rollback |
| [`core/queryKey.ts`](./lib/src/core/queryKey.ts) | `hashKey()` — стабильная сериализация ключа (порядок полей объекта не важен) |
| [`transport/InlineTransport.ts`](./lib/src/transport/InlineTransport.ts) | Транспорт в одном потоке, батчинг через `queueMicrotask` |
| [`transport/SharedWorkerTransport.ts`](./lib/src/transport/SharedWorkerTransport.ts) | Транспорт через `MessagePort` поверх `SharedWorker` |
| [`adapters/`](./lib/src/adapters/) | `memoryAdapter` / `indexedDBAdapter` (движок), `idbStore` / `memoryStore` / `noopStore` (сущности) |
| [`composables/`](./lib/src/composables/) | Vue-обвязка: `useQuery` / `useMutation` / `useInfiniteQuery` / `useEntity` / `useEngine` |