feat: add support for #super imports in vite-layers
This commit is contained in:
+27
-2
@@ -77,10 +77,34 @@ c12, вне графа Vite — сам он не следит), подхваты
|
||||
| Префикс | Куда резолвится | Примечания |
|
||||
|---|---|---|
|
||||
| `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` |
|
||||
| `#super`, `#super/…` | первый совпавший файл **строго ниже** слоя импортёра | явный `super()` — предпочтительная форма, см. ниже |
|
||||
| `~~/…`, `@@/…` | `rootDir` проекта | обычный alias |
|
||||
| `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени |
|
||||
| `#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)
|
||||
|
||||
`layers[0]` — это сам проект (высший приоритет); далее `extends` слева-направо, в глубину;
|
||||
@@ -187,8 +211,9 @@ UI рисуется целиком на сервере (json-render спеки `
|
||||
|
||||
## Улучшения над Nuxt/c12
|
||||
|
||||
1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`),
|
||||
чтобы дотянуться до базового файла. В Nuxt такого механизма нет.
|
||||
1. **`super()`** — явный `#super`/`#super/<path>` (типизированный, greppable) плюс self-skip
|
||||
(оверрайд может импортировать собственный путь `@/components/X`, чтобы дотянуться до базового
|
||||
файла). В Nuxt такого механизма нет.
|
||||
2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt
|
||||
срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в
|
||||
`resolve`-хуке c12 обрывает рекурсию.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Aurora has no bootstrap logic of its own — it reuses the base layer's entry.
|
||||
// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip
|
||||
// (super() semantics) falls through to the next layer, i.e. main/src/main.ts.
|
||||
import '@/main.ts'
|
||||
// Aurora has no bootstrap logic of its own — `#super` re-resolves this file's own path
|
||||
// (main.ts) from the next-lower layer, i.e. main/src/main.ts.
|
||||
import '#super'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Brand has no bootstrap logic of its own — it reuses the base layer's entry.
|
||||
// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip
|
||||
// (super() semantics) falls through to the next layer, i.e. main/src/main.ts.
|
||||
import '@/main.ts'
|
||||
// Brand has no bootstrap logic of its own — `#super` re-resolves this file's own path
|
||||
// (main.ts) from the next-lower layer, i.e. main/src/main.ts.
|
||||
import '#super'
|
||||
|
||||
+12
-39
@@ -1,5 +1,5 @@
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { statSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import type { ConfigEnv } from 'vite'
|
||||
import type {
|
||||
DevToolsServerCommandInput,
|
||||
@@ -14,7 +14,7 @@ import { flattenFeatures } from './features'
|
||||
import type { LayeredResolution, ResolveRecord } from './resolve'
|
||||
import { generateTsConfig, type GenerateTsConfigOptions } from './tsconfig'
|
||||
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
|
||||
@@ -100,11 +100,10 @@ interface HookRow {
|
||||
}
|
||||
type TsconfigInfo =
|
||||
| { 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 {
|
||||
projectName: string
|
||||
appDir: string
|
||||
mode: string
|
||||
command: string
|
||||
layers: LayerRow[]
|
||||
@@ -120,30 +119,6 @@ interface Snapshot {
|
||||
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[] =>
|
||||
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[]>()
|
||||
for (const { name, dir } of publicLayers) {
|
||||
for (const abs of walk(dir)) {
|
||||
for (const abs of walkFiles(dir)) {
|
||||
const rel = toPosix(relative(dir, abs))
|
||||
;(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),
|
||||
nodeJson: JSON.stringify(gen.nodeTsconfig, null, 2),
|
||||
dts: gen.dts,
|
||||
superDts: gen.superDts,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectName: layers[0]?.name ?? 'app',
|
||||
appDir: data.appDir,
|
||||
mode: data.env.mode,
|
||||
command: data.env.command,
|
||||
layers: layerRows,
|
||||
@@ -538,8 +513,6 @@ function buildFeaturesSpec(snap: Snapshot): JsonRenderSpec {
|
||||
|
||||
interface ResolveResult {
|
||||
id: string
|
||||
sub: string
|
||||
query: string
|
||||
candidates: string[]
|
||||
error?: string
|
||||
}
|
||||
@@ -588,9 +561,10 @@ function buildResolverSpec(data: LayersDevtoolsData, query: string, result: Reso
|
||||
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 {
|
||||
if (r.resolved === null) return 'unresolved'
|
||||
if (r.id.startsWith('#super')) return '#super'
|
||||
if (r.selfIndex < 0) return 'top match'
|
||||
return `super() #${r.selfIndex + 1}`
|
||||
}
|
||||
@@ -619,7 +593,7 @@ function resolveResultEls(s: Spec, data: LayersDevtoolsData, result: ResolveResu
|
||||
})),
|
||||
'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 override’s 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.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/super.d.ts', [s.code(ts.superDts, 'super.d.ts')], true))
|
||||
} else {
|
||||
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 {
|
||||
const id = String(rawId ?? '').trim()
|
||||
if (!id) return { id, candidates: [], error: 'Enter an import id.' }
|
||||
const parsed = data.resolution.parse(id)
|
||||
if (!id) return { id, sub: '', query: '', candidates: [], error: 'Enter an import id.' }
|
||||
if (!parsed) {
|
||||
return {
|
||||
id,
|
||||
sub: '',
|
||||
query: '',
|
||||
candidates: [],
|
||||
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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -472,7 +472,9 @@ export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
|
||||
for (const [start, end, text] of edits) s.overwrite(start, end, text)
|
||||
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' }) }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
createLayeredResolution,
|
||||
DEFAULT_EXTENSIONS,
|
||||
layersResolver,
|
||||
SUPER_MODULE,
|
||||
type LayeredResolution,
|
||||
type LayersResolverOptions,
|
||||
type ParsedLayeredId,
|
||||
|
||||
@@ -104,16 +104,17 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions
|
||||
'@@': project.rootDir,
|
||||
[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).
|
||||
for (const l of [...layers].reverse()) alias[`#layers/${l.name}`] = l.rootDir
|
||||
// Low→high iteration order: for aliases the highest-priority layer overwrites (first-wins by
|
||||
// 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 = {
|
||||
resolve: { alias },
|
||||
build: { outDir: options.outDir ?? `dist/${basename(appDir)}` },
|
||||
}
|
||||
|
||||
// Layer fragments: low → high so higher-priority layers override.
|
||||
for (const l of [...layers].reverse()) {
|
||||
for (const l of lowToHigh) {
|
||||
const frag = typeof l.config.vite === 'function' ? l.config.vite(env) : l.config.vite
|
||||
if (frag) vite = mergeConfig(vite, frag)
|
||||
}
|
||||
|
||||
@@ -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 sirv from 'sirv'
|
||||
import type { Plugin } from 'vite'
|
||||
import { toPosix } 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
|
||||
}
|
||||
import { toPosix, walkFiles } from './util'
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const assets = new Map<string, string>()
|
||||
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
|
||||
}
|
||||
|
||||
+104
-33
@@ -6,6 +6,22 @@ import { toPosix } from './util'
|
||||
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
|
||||
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 {
|
||||
/** Source roots ordered high→low priority (typically `layers.map(l => l.srcDir)`). */
|
||||
roots: string[]
|
||||
@@ -52,14 +68,18 @@ export interface LayeredResolution {
|
||||
readonly roots: string[]
|
||||
readonly prefixes: 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
|
||||
/** Ordered candidate files for a prefix-stripped sub-path, high→low priority. Cached. */
|
||||
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
|
||||
/** 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
|
||||
/** 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). */
|
||||
records: () => ResolveRecord[]
|
||||
/** Clear the resolution log (the candidate cache is untouched). */
|
||||
@@ -86,6 +106,10 @@ const isFile = (p: string): boolean => {
|
||||
export function createLayeredResolution(options: LayersResolverOptions): LayeredResolution {
|
||||
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 direct = resolve(root, sub)
|
||||
if (isFile(direct)) return direct
|
||||
@@ -117,7 +141,13 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered
|
||||
}
|
||||
|
||||
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
|
||||
const q = id.indexOf('?')
|
||||
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 }
|
||||
}
|
||||
|
||||
// Bounded, de-duplicated resolution log (devtools). Keyed by a JSON-encoded `[id, importer]` pair
|
||||
// (collision-proof, unlike a delimiter string) so repeated resolves of the same import (HMR re-runs)
|
||||
// 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).
|
||||
// Bounded, de-duplicated resolution log (devtools). NUL-joined key: collision-proof (paths can't
|
||||
// contain NUL), cheaper than JSON.stringify. Re-inserting moves an entry to the end (most-recent).
|
||||
const log = new Map<string, 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)
|
||||
log.set(key, rec)
|
||||
while (log.size > record) log.delete(log.keys().next().value!)
|
||||
@@ -147,24 +175,66 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered
|
||||
const parsed = parse(id)
|
||||
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
|
||||
|
||||
if (record > 0) remember({ id, importer: self, resolved, candidates: list, selfIndex })
|
||||
return resolved
|
||||
},
|
||||
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() {
|
||||
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>`,
|
||||
* then `<path>/index<ext>`.
|
||||
*
|
||||
* Improvement over Nuxt: **self-skip** gives `super()` semantics at any depth. When the importer is
|
||||
* itself one of the matches (an override importing its own layered path), resolution continues to the
|
||||
* **next-lower** layer — so an override at `@/components/Foo.vue` can import `@/components/Foo.vue` to
|
||||
* reach the layer beneath it. This composes through a deep `extends` chain: top→mid→base each resolve
|
||||
* one step down, so multi-level overrides can each call `super()`.
|
||||
* Improvement over Nuxt: `super()` semantics at any depth, in two forms — the explicit
|
||||
* `#super`/`#super/<path>` (preferred: greppable and typed, see {@link SUPER_MODULE}) and the
|
||||
* implicit self-skip (Nuxt-parity: an override importing its own layered path resolves to the
|
||||
* next-lower layer). Both compose through a deep `extends` chain, one step down per layer.
|
||||
*
|
||||
* Accepts either {@link LayersResolverOptions} (builds its own {@link LayeredResolution}) or a
|
||||
* 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 {
|
||||
const resolution = isResolution(source) ? source : createLayeredResolution(source)
|
||||
// Hook filter (rolldown): a RegExp matching the layered prefixes, so the bundler only invokes
|
||||
// resolveId for `@/`/`~/` ids — every other specifier skips the JS round-trip. (resolveId filters
|
||||
// accept only RegExp ids, not string globs.) https://rolldown.rs/in-depth/why-plugin-hook-filter
|
||||
const idFilter = new RegExp(`^(?:${resolution.prefixes.map(escapeRegExp).join('|')})`)
|
||||
// Hook filter (rolldown): only layered prefixes + `#super` reach the JS handler; every other
|
||||
// specifier skips the round-trip. https://rolldown.rs/in-depth/why-plugin-hook-filter
|
||||
const idFilter = new RegExp(
|
||||
`^(?:${resolution.prefixes.map(escapeRegExp).join('|')}|${SUPER_MODULE}(?:/|\\?|$))`,
|
||||
)
|
||||
|
||||
return {
|
||||
name: 'vite-layers:resolve',
|
||||
enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases
|
||||
configureServer(server) {
|
||||
// A new/removed file can change which layer wins → drop the cache in dev.
|
||||
const clear = () => resolution.clear()
|
||||
server.watcher.on('add', clear)
|
||||
server.watcher.on('unlink', clear)
|
||||
server.watcher.on('unlinkDir', clear)
|
||||
// A new/removed file can change which layer wins. Targeted invalidation, not a full clear —
|
||||
// dev codegen (typed-router, dts emitters) would otherwise wipe the cache on every emit.
|
||||
const invalidateFile = (file: string) => resolution.invalidateFile(file)
|
||||
server.watcher.on('add', invalidateFile)
|
||||
server.watcher.on('unlink', invalidateFile)
|
||||
server.watcher.on('unlinkDir', dir => resolution.invalidateDir(dir))
|
||||
},
|
||||
resolveId: {
|
||||
filter: { id: idFilter },
|
||||
|
||||
@@ -30,6 +30,20 @@ export interface GenerateTsConfigOptions {
|
||||
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 `./`. */
|
||||
const LEADING_NON_DOT_RE = /^([^.])/
|
||||
/** 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.
|
||||
[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) {
|
||||
// first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig.
|
||||
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.
|
||||
const base: TSConfig = {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -141,20 +159,24 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
|
||||
genDir,
|
||||
dts: featuresDts(merged.features),
|
||||
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
|
||||
* `writeTSConfig`). Returns the tsconfig path.
|
||||
* Generate and write `<appDir>/.vite-layers/{tsconfig.json,features.d.ts,super.d.ts}` (tsconfig via
|
||||
* pkg-types `writeTSConfig`). Returns the tsconfig path.
|
||||
*/
|
||||
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 Promise.all([
|
||||
writeTSConfig(file, tsconfig),
|
||||
writeTSConfig(nodeFile, nodeTsconfig),
|
||||
writeFile(dtsFile, dts),
|
||||
writeFile(superDtsFile, superDts),
|
||||
])
|
||||
return 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
|
||||
* `path` helpers are OS-native (backslashes on Windows); paths must be canonicalized to forward
|
||||
@@ -6,3 +9,31 @@
|
||||
*/
|
||||
const SEPARATOR_RE = /\\/g
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Plugin } from 'vite'
|
||||
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 importer’s own path from the next-lower layer', () => {
|
||||
expect(resolveId('#super', brandHeader)).toBe(baseHeader)
|
||||
})
|
||||
|
||||
it('#super/<path> resolves any path from strictly below the importer’s 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 importer’s 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', () => {
|
||||
expect(resolveId('@/components/Missing.vue')).toBeNull()
|
||||
})
|
||||
@@ -87,6 +138,15 @@ describe('layersResolver', () => {
|
||||
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)', () => {
|
||||
const p = layersResolver({ roots })
|
||||
const rid = (id: string) => callResolveId(p, id)
|
||||
@@ -103,6 +163,15 @@ describe('createLayeredResolution (introspection core)', () => {
|
||||
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', () => {
|
||||
const r = createLayeredResolution({ roots })
|
||||
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'])
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const shared = createLayeredResolution({ roots, record: 10 })
|
||||
const plugin = layersResolver(shared)
|
||||
|
||||
@@ -107,6 +107,32 @@ describe('generateTsConfig', () => {
|
||||
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 () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
|
||||
@@ -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` |
|
||||
Reference in New Issue
Block a user