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()` |
|
| `@/…`, `~/…` | первый совпавший файл по `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 обрывает рекурсию.
|
||||||
|
|||||||
@@ -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'
|
|
||||||
|
|||||||
@@ -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
@@ -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 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.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
@@ -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' }) }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 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', () => {
|
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)
|
||||||
|
|||||||
@@ -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[]>
|
||||||
|
|||||||
@@ -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