@zhin.js/runtime 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/convention-capability-delta.d.ts +26 -0
- package/lib/convention-capability-delta.js +143 -0
- package/lib/environment-store.d.ts +6 -0
- package/lib/environment-store.js +18 -6
- package/lib/hmr-coordinator.js +8 -0
- package/lib/invalidation-planner.d.ts +8 -0
- package/lib/invalidation-planner.js +28 -5
- package/lib/module-runtime.d.ts +6 -0
- package/lib/native-development-runtime.d.ts +2 -1
- package/lib/native-development-runtime.js +85 -24
- package/lib/plugin-scope-assembler.d.ts +6 -0
- package/lib/plugin-scope-assembler.js +26 -1
- package/lib/root-runtime.js +38 -18
- package/lib/slot-generation-preparer.d.ts +2 -1
- package/lib/slot-generation-preparer.js +4 -13
- package/lib/source-ownership.d.ts +3 -0
- package/lib/source-ownership.js +13 -0
- package/lib/topology-generation-preparer.d.ts +9 -2
- package/lib/topology-generation-preparer.js +41 -5
- package/package.json +14 -14
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type CapabilityId, type FeatureId, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import type { FallbackInvalidation } from './invalidation-planner.js';
|
|
3
|
+
import type { ModuleRuntime } from './module-runtime.js';
|
|
4
|
+
import type { RuntimeGenerationModel } from './runtime-generation.js';
|
|
5
|
+
export type CapabilityDelta = ReadonlyMap<FeatureId, ReadonlySet<CapabilityId>>;
|
|
6
|
+
export interface ConventionCapabilityDelta {
|
|
7
|
+
readonly capabilities: CapabilityDelta;
|
|
8
|
+
readonly unresolved: readonly FallbackInvalidation[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolves unowned filesystem paths through mounted Feature conventions.
|
|
12
|
+
* Conventions are asked to enumerate sources only: definition modules stay
|
|
13
|
+
* untouched until SlotGenerationPreparer commits the selected delta.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ConventionCapabilityDeltaResolver {
|
|
16
|
+
#private;
|
|
17
|
+
private readonly model;
|
|
18
|
+
private readonly snapshot;
|
|
19
|
+
constructor(modules: ModuleRuntime, model: RuntimeGenerationModel, snapshot: RuntimeSnapshot);
|
|
20
|
+
resolve(fallbacks: readonly FallbackInvalidation[], selected: readonly CapabilityId[]): Promise<ConventionCapabilityDelta>;
|
|
21
|
+
}
|
|
22
|
+
export declare function capabilityDeltaFromSlots(snapshot: RuntimeSnapshot, selected: readonly CapabilityId[]): CapabilityDelta;
|
|
23
|
+
export declare function mergeCapabilityDeltas(...deltas: readonly CapabilityDelta[]): CapabilityDelta;
|
|
24
|
+
export declare function filterCapabilityDelta(delta: CapabilityDelta, predicate: (owner: PluginId) => boolean): CapabilityDelta;
|
|
25
|
+
export declare function capabilityDeltaIds(delta: CapabilityDelta): readonly CapabilityId[];
|
|
26
|
+
export declare function capabilityOwner(id: CapabilityId): PluginId;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
2
|
+
import { capabilityId } from '@zhin.js/plugin-runtime';
|
|
3
|
+
import { NodeDiscoveryHost } from './node-discovery-host.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves unowned filesystem paths through mounted Feature conventions.
|
|
6
|
+
* Conventions are asked to enumerate sources only: definition modules stay
|
|
7
|
+
* untouched until SlotGenerationPreparer commits the selected delta.
|
|
8
|
+
*/
|
|
9
|
+
export class ConventionCapabilityDeltaResolver {
|
|
10
|
+
model;
|
|
11
|
+
snapshot;
|
|
12
|
+
#host;
|
|
13
|
+
constructor(modules, model, snapshot) {
|
|
14
|
+
this.model = model;
|
|
15
|
+
this.snapshot = snapshot;
|
|
16
|
+
this.#host = new NodeDiscoveryHost(modules);
|
|
17
|
+
}
|
|
18
|
+
async resolve(fallbacks, selected) {
|
|
19
|
+
const capabilities = new Map();
|
|
20
|
+
const unresolved = [];
|
|
21
|
+
const cache = new Map();
|
|
22
|
+
for (const fallback of fallbacks) {
|
|
23
|
+
let claimed = false;
|
|
24
|
+
for (const owner of fallback.owners) {
|
|
25
|
+
for (const [feature, provider] of this.model.providers) {
|
|
26
|
+
const roots = (this.model.rootsByFeature.get(feature) ?? [])
|
|
27
|
+
.filter((root) => root.owner === owner && contains(root.packageRoot, fallback.source));
|
|
28
|
+
for (const root of roots) {
|
|
29
|
+
const discovered = await this.#discover(cache, provider, root);
|
|
30
|
+
for (const candidate of discovered) {
|
|
31
|
+
if (candidate.source !== fallback.source)
|
|
32
|
+
continue;
|
|
33
|
+
const ids = capabilities.get(feature) ?? new Set();
|
|
34
|
+
ids.add(candidate.id);
|
|
35
|
+
capabilities.set(feature, ids);
|
|
36
|
+
claimed = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!claimed)
|
|
42
|
+
unresolved.push(fallback);
|
|
43
|
+
}
|
|
44
|
+
await this.#discoverMoves(cache, capabilities, selected);
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
capabilities: freezeCapabilityDelta(capabilities),
|
|
47
|
+
unresolved: Object.freeze(unresolved),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async #discoverMoves(cache, capabilities, selected) {
|
|
51
|
+
for (const id of selected) {
|
|
52
|
+
const slot = this.snapshot.capabilities.get(id);
|
|
53
|
+
if (!slot || slot.origin === 'setup')
|
|
54
|
+
continue;
|
|
55
|
+
const provider = this.model.providers.get(slot.feature);
|
|
56
|
+
if (!provider)
|
|
57
|
+
continue;
|
|
58
|
+
const roots = (this.model.rootsByFeature.get(slot.feature) ?? [])
|
|
59
|
+
.filter((root) => root.owner === slot.owner && contains(root.packageRoot, slot.source));
|
|
60
|
+
for (const root of roots) {
|
|
61
|
+
const discovered = await this.#discover(cache, provider, root);
|
|
62
|
+
if (discovered.some((candidate) => candidate.source === slot.source))
|
|
63
|
+
continue;
|
|
64
|
+
for (const candidate of discovered) {
|
|
65
|
+
if (this.snapshot.capabilities.has(candidate.id))
|
|
66
|
+
continue;
|
|
67
|
+
const ids = capabilities.get(slot.feature) ?? new Set();
|
|
68
|
+
ids.add(candidate.id);
|
|
69
|
+
capabilities.set(slot.feature, ids);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async #discover(cache, provider, root) {
|
|
75
|
+
const key = `${provider.id}\0${root.owner}\0${root.packageRoot}`;
|
|
76
|
+
const existing = cache.get(key);
|
|
77
|
+
if (existing)
|
|
78
|
+
return existing;
|
|
79
|
+
const discovered = [];
|
|
80
|
+
const context = { ...root, host: this.#host };
|
|
81
|
+
for (const convention of provider.authoring.conventions) {
|
|
82
|
+
for await (const source of convention.discover(context)) {
|
|
83
|
+
discovered.push(Object.freeze({
|
|
84
|
+
source: resolve(source.source),
|
|
85
|
+
id: capabilityId(root.owner, provider.id, source.localName),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const frozen = Object.freeze(discovered);
|
|
90
|
+
cache.set(key, frozen);
|
|
91
|
+
return frozen;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function capabilityDeltaFromSlots(snapshot, selected) {
|
|
95
|
+
const result = new Map();
|
|
96
|
+
for (const id of selected) {
|
|
97
|
+
const slot = snapshot.capabilities.get(id);
|
|
98
|
+
if (!slot)
|
|
99
|
+
continue;
|
|
100
|
+
const ids = result.get(slot.feature) ?? new Set();
|
|
101
|
+
ids.add(id);
|
|
102
|
+
result.set(slot.feature, ids);
|
|
103
|
+
}
|
|
104
|
+
return freezeCapabilityDelta(result);
|
|
105
|
+
}
|
|
106
|
+
export function mergeCapabilityDeltas(...deltas) {
|
|
107
|
+
const result = new Map();
|
|
108
|
+
for (const delta of deltas) {
|
|
109
|
+
for (const [feature, ids] of delta) {
|
|
110
|
+
const merged = result.get(feature) ?? new Set();
|
|
111
|
+
for (const id of ids)
|
|
112
|
+
merged.add(id);
|
|
113
|
+
result.set(feature, merged);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return freezeCapabilityDelta(result);
|
|
117
|
+
}
|
|
118
|
+
export function filterCapabilityDelta(delta, predicate) {
|
|
119
|
+
const result = new Map();
|
|
120
|
+
for (const [feature, ids] of delta) {
|
|
121
|
+
for (const id of ids) {
|
|
122
|
+
if (!predicate(capabilityOwner(id)))
|
|
123
|
+
continue;
|
|
124
|
+
const filtered = result.get(feature) ?? new Set();
|
|
125
|
+
filtered.add(id);
|
|
126
|
+
result.set(feature, filtered);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return freezeCapabilityDelta(result);
|
|
130
|
+
}
|
|
131
|
+
export function capabilityDeltaIds(delta) {
|
|
132
|
+
return Object.freeze([...delta.values()].flatMap((ids) => [...ids]));
|
|
133
|
+
}
|
|
134
|
+
export function capabilityOwner(id) {
|
|
135
|
+
return id.slice(0, id.indexOf('\0'));
|
|
136
|
+
}
|
|
137
|
+
function freezeCapabilityDelta(delta) {
|
|
138
|
+
return new Map([...delta].map(([feature, ids]) => [feature, new Set(ids)]));
|
|
139
|
+
}
|
|
140
|
+
function contains(root, source) {
|
|
141
|
+
const path = relative(root, resolve(source));
|
|
142
|
+
return path === '' || (!path.startsWith('..') && !isAbsolute(path));
|
|
143
|
+
}
|
|
@@ -37,6 +37,12 @@ export declare class EnvSchemaParseError extends Error {
|
|
|
37
37
|
readonly owner: PluginId;
|
|
38
38
|
constructor(owner: PluginId, message: string);
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The PluginGraph owner path is the sole namespace for Plugin overlays.
|
|
42
|
+
* Package names and bare instance keys are deliberately not accepted as
|
|
43
|
+
* aliases: a layer for `root/a` can flow only to `root/a` and descendants.
|
|
44
|
+
*/
|
|
45
|
+
export declare function environmentOwnerPath(owner: PluginId): readonly PluginId[];
|
|
40
46
|
export declare function defineEnvSchema<T>(schema: EnvSchema<T>): Readonly<EnvSchema<T>>;
|
|
41
47
|
export declare function defineEnvironmentLayers(layers?: EnvironmentLayers): Readonly<EnvironmentLayers>;
|
|
42
48
|
export declare function createEnvStore(owner: PluginId, environment: RuntimeEnvironment, layers?: EnvironmentLayers): EnvStore;
|
package/lib/environment-store.js
CHANGED
|
@@ -18,6 +18,19 @@ export class EnvSchemaParseError extends Error {
|
|
|
18
18
|
this.name = 'EnvSchemaParseError';
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The PluginGraph owner path is the sole namespace for Plugin overlays.
|
|
23
|
+
* Package names and bare instance keys are deliberately not accepted as
|
|
24
|
+
* aliases: a layer for `root/a` can flow only to `root/a` and descendants.
|
|
25
|
+
*/
|
|
26
|
+
export function environmentOwnerPath(owner) {
|
|
27
|
+
const value = String(owner);
|
|
28
|
+
if (!/^root(?:\/[a-z0-9][a-z0-9-]*)*$/u.test(value)) {
|
|
29
|
+
throw new TypeError(`Invalid Plugin environment owner: ${owner}`);
|
|
30
|
+
}
|
|
31
|
+
const segments = value.split('/');
|
|
32
|
+
return Object.freeze(segments.map((_, index) => segments.slice(0, index + 1).join('/')));
|
|
33
|
+
}
|
|
21
34
|
export function defineEnvSchema(schema) {
|
|
22
35
|
const secretKeys = Object.freeze([...(schema.secretKeys ?? [])]);
|
|
23
36
|
for (const key of secretKeys)
|
|
@@ -35,7 +48,10 @@ export function defineEnvironmentLayers(layers = {}) {
|
|
|
35
48
|
return [name, copySource(source, `environment ${name}`)];
|
|
36
49
|
}));
|
|
37
50
|
const plugins = Object.fromEntries(Object.entries(layers.plugins ?? {}).map(([owner, source]) => {
|
|
38
|
-
|
|
51
|
+
try {
|
|
52
|
+
environmentOwnerPath(owner);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
39
55
|
throw new TypeError(`Invalid Plugin environment overlay owner: ${owner}`);
|
|
40
56
|
}
|
|
41
57
|
return [owner, copySource(source, `Plugin ${owner}`)];
|
|
@@ -61,7 +77,7 @@ export class EnvStoreFactory {
|
|
|
61
77
|
const source = {};
|
|
62
78
|
applyLayer(source, this.#layers.base);
|
|
63
79
|
applyLayer(source, this.#layers.environments?.[this.#environment.name]);
|
|
64
|
-
for (const ancestor of
|
|
80
|
+
for (const ancestor of environmentOwnerPath(owner)) {
|
|
65
81
|
applyLayer(source, this.#layers.plugins?.[ancestor]);
|
|
66
82
|
}
|
|
67
83
|
return new OwnerEnvStore(owner, this.#environment, Object.freeze(source));
|
|
@@ -140,10 +156,6 @@ function applyLayer(target, layer) {
|
|
|
140
156
|
target[key] = value;
|
|
141
157
|
}
|
|
142
158
|
}
|
|
143
|
-
function pluginAncestors(owner) {
|
|
144
|
-
const segments = owner.split('/');
|
|
145
|
-
return Object.freeze(segments.map((_, index) => segments.slice(0, index + 1).join('/')));
|
|
146
|
-
}
|
|
147
159
|
function assertEnvironmentKey(key) {
|
|
148
160
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) {
|
|
149
161
|
throw new TypeError(`Invalid environment variable name: ${key}`);
|
package/lib/hmr-coordinator.js
CHANGED
|
@@ -14,6 +14,7 @@ export class HmrCoordinator {
|
|
|
14
14
|
if (!this.options.modules.watch) {
|
|
15
15
|
throw new Error('ModuleRuntime does not provide a file watcher');
|
|
16
16
|
}
|
|
17
|
+
this.#syncWatchRoots();
|
|
17
18
|
this.#unwatch = this.options.modules.watch((source) => {
|
|
18
19
|
void this.enqueue(source).catch(() => undefined);
|
|
19
20
|
});
|
|
@@ -79,6 +80,10 @@ export class HmrCoordinator {
|
|
|
79
80
|
if (restart)
|
|
80
81
|
await this.options.onRestartRequired(restart);
|
|
81
82
|
else {
|
|
83
|
+
// reload resolves only after RootController has committed the new
|
|
84
|
+
// generation. Read ownership now so failed transactions never make
|
|
85
|
+
// newly discovered workspace packages observable to the watcher.
|
|
86
|
+
this.#syncWatchRoots();
|
|
82
87
|
const durationMs = Number((performance.now() - startedAt).toFixed(1));
|
|
83
88
|
await this.options.onReload?.(plan, durationMs);
|
|
84
89
|
}
|
|
@@ -112,4 +117,7 @@ export class HmrCoordinator {
|
|
|
112
117
|
for (const waiter of this.#waiters.splice(0))
|
|
113
118
|
waiter.reject(error);
|
|
114
119
|
}
|
|
120
|
+
#syncWatchRoots() {
|
|
121
|
+
this.options.modules.updateWatchRoots?.(this.options.ownership().watchRoots());
|
|
122
|
+
}
|
|
115
123
|
}
|
|
@@ -11,10 +11,18 @@ export interface NoInvalidationPlan {
|
|
|
11
11
|
export interface GenerationInvalidationPlan {
|
|
12
12
|
readonly kind: 'generation';
|
|
13
13
|
readonly changed: readonly string[];
|
|
14
|
+
/** Manifest sources require a graph and process-boundary check before reload. */
|
|
15
|
+
readonly manifestSources: readonly string[];
|
|
14
16
|
readonly slots: readonly CapabilityId[];
|
|
15
17
|
readonly subtrees: readonly PluginId[];
|
|
18
|
+
/** Paths without ownership records; RootRuntime may claim them through a convention. */
|
|
19
|
+
readonly fallbacks: readonly FallbackInvalidation[];
|
|
16
20
|
readonly reasons: readonly string[];
|
|
17
21
|
}
|
|
22
|
+
export interface FallbackInvalidation {
|
|
23
|
+
readonly source: string;
|
|
24
|
+
readonly owners: readonly PluginId[];
|
|
25
|
+
}
|
|
18
26
|
export interface ProcessInvalidationPlan {
|
|
19
27
|
readonly kind: 'process';
|
|
20
28
|
readonly changed: readonly string[];
|
|
@@ -24,9 +24,14 @@ export class InvalidationPlanner {
|
|
|
24
24
|
}
|
|
25
25
|
const slots = new Map();
|
|
26
26
|
const subtrees = new Set();
|
|
27
|
+
const fallbacks = [];
|
|
28
|
+
const manifestSources = new Set();
|
|
27
29
|
const reasons = new Set();
|
|
28
30
|
const processReasons = new Set();
|
|
29
31
|
for (const source of changed) {
|
|
32
|
+
if (basename(source) === 'package.json') {
|
|
33
|
+
addManifestSource(manifestSources, reasons, source);
|
|
34
|
+
}
|
|
30
35
|
const affected = unique([source, ...(this.dependencies?.affectedSources(source) ?? [])].map((item) => resolve(item)));
|
|
31
36
|
let matched = false;
|
|
32
37
|
for (const item of affected) {
|
|
@@ -37,17 +42,25 @@ export class InvalidationPlanner {
|
|
|
37
42
|
if (requiresProcessRestart(record)) {
|
|
38
43
|
processReasons.add(`Root ${record.role} source changed`);
|
|
39
44
|
}
|
|
45
|
+
else if (record.role === 'manifest') {
|
|
46
|
+
// Manifest changes are handled by RootRuntime after it compares
|
|
47
|
+
// the current and inspected graphs. Do not turn them into a
|
|
48
|
+
// subtree reload here: that would hide concurrent Slot changes.
|
|
49
|
+
addManifestSource(manifestSources, reasons, record.source);
|
|
50
|
+
}
|
|
40
51
|
else {
|
|
41
52
|
applyRecord(record, slots, subtrees, reasons);
|
|
42
53
|
}
|
|
43
54
|
}
|
|
44
55
|
}
|
|
45
56
|
// An untracked support module still belongs to the nearest mounted
|
|
46
|
-
// package.
|
|
57
|
+
// package. RootRuntime gets the first chance to claim it through a
|
|
58
|
+
// mounted Feature convention; unresolved paths retain this fallback.
|
|
47
59
|
if (!matched) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
const owners = this.ownership.ownersForPath(source);
|
|
61
|
+
if (owners.length > 0) {
|
|
62
|
+
fallbacks.push(Object.freeze({ source, owners }));
|
|
63
|
+
reasons.add(`untracked source changed in ${owners.join(', ')}`);
|
|
51
64
|
}
|
|
52
65
|
}
|
|
53
66
|
}
|
|
@@ -60,7 +73,10 @@ export class InvalidationPlanner {
|
|
|
60
73
|
}
|
|
61
74
|
const roots = collapseSubtrees(subtrees);
|
|
62
75
|
const retainedSlots = [...slots].flatMap(([capability, owner]) => roots.some((root) => isWithin(owner, root)) ? [] : [capability]);
|
|
63
|
-
if (roots.length === 0
|
|
76
|
+
if (roots.length === 0
|
|
77
|
+
&& retainedSlots.length === 0
|
|
78
|
+
&& manifestSources.size === 0
|
|
79
|
+
&& fallbacks.length === 0) {
|
|
64
80
|
return Object.freeze({
|
|
65
81
|
kind: 'none',
|
|
66
82
|
changed,
|
|
@@ -70,12 +86,19 @@ export class InvalidationPlanner {
|
|
|
70
86
|
return Object.freeze({
|
|
71
87
|
kind: 'generation',
|
|
72
88
|
changed,
|
|
89
|
+
manifestSources: Object.freeze([...manifestSources]),
|
|
73
90
|
slots: Object.freeze(retainedSlots),
|
|
74
91
|
subtrees: Object.freeze(roots),
|
|
92
|
+
fallbacks: Object.freeze(fallbacks),
|
|
75
93
|
reasons: Object.freeze([...reasons]),
|
|
76
94
|
});
|
|
77
95
|
}
|
|
78
96
|
}
|
|
97
|
+
function addManifestSource(manifestSources, reasons, source) {
|
|
98
|
+
const manifest = resolve(source);
|
|
99
|
+
manifestSources.add(manifest);
|
|
100
|
+
reasons.add(`Manifest source changed: ${manifest}`);
|
|
101
|
+
}
|
|
79
102
|
function requiresProcessRestart(record) {
|
|
80
103
|
return (record.owner === rootPluginId()
|
|
81
104
|
&& (record.role === 'plugin' || record.role === 'schema'));
|
package/lib/module-runtime.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Dispose } from '@zhin.js/plugin-runtime';
|
|
2
2
|
import type { ClientModuleRequest } from '@zhin.js/feature-kit';
|
|
3
|
+
export interface ModuleWatchRoot {
|
|
4
|
+
readonly root: string;
|
|
5
|
+
readonly source: 'workspace' | 'local';
|
|
6
|
+
}
|
|
3
7
|
export interface ModuleRuntime {
|
|
4
8
|
load<T = unknown>(source: string): Promise<T>;
|
|
5
9
|
/** Optional compiler/manifest adapter for browser modules such as Page and Layout. */
|
|
@@ -8,6 +12,8 @@ export interface ModuleRuntime {
|
|
|
8
12
|
affectedSources?(source: string): readonly string[];
|
|
9
13
|
/** True when this adapter cannot invalidate the complete importer closure safely. */
|
|
10
14
|
requiresProcessRestart?(source: string): boolean;
|
|
15
|
+
/** Replaces the local package roots after a generation commits. */
|
|
16
|
+
updateWatchRoots?(roots: readonly ModuleWatchRoot[]): void;
|
|
11
17
|
watch?(listener: (source: string) => void): Dispose;
|
|
12
18
|
close(): Promise<void>;
|
|
13
19
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Dispose } from '@zhin.js/plugin-runtime';
|
|
2
|
-
import type { ModuleRuntime } from './module-runtime.js';
|
|
2
|
+
import type { ModuleRuntime, ModuleWatchRoot } from './module-runtime.js';
|
|
3
3
|
export interface NativeDevelopmentModuleRuntimeOptions {
|
|
4
4
|
readonly projectRoot: string;
|
|
5
5
|
readonly watch?: boolean;
|
|
@@ -15,6 +15,7 @@ export declare class NativeDevelopmentModuleRuntime implements ModuleRuntime {
|
|
|
15
15
|
load<T = unknown>(source: string): Promise<T>;
|
|
16
16
|
invalidate(source: string): void;
|
|
17
17
|
requiresProcessRestart(source: string): boolean;
|
|
18
|
+
updateWatchRoots(roots: readonly ModuleWatchRoot[]): void;
|
|
18
19
|
watch(listener: (source: string) => void): Dispose;
|
|
19
20
|
close(): Promise<void>;
|
|
20
21
|
}
|
|
@@ -20,10 +20,12 @@ export class NativeDevelopmentModuleRuntime {
|
|
|
20
20
|
#watchEnabled;
|
|
21
21
|
#revisions = new Map();
|
|
22
22
|
#watchers = new Set();
|
|
23
|
+
#watchRoots;
|
|
23
24
|
#closed = false;
|
|
24
25
|
constructor(options) {
|
|
25
26
|
this.#projectRoot = resolve(options.projectRoot);
|
|
26
27
|
this.#watchEnabled = options.watch ?? true;
|
|
28
|
+
this.#watchRoots = normalizeWatchRoots([this.#projectRoot]);
|
|
27
29
|
}
|
|
28
30
|
async load(source) {
|
|
29
31
|
this.#assertOpen();
|
|
@@ -43,9 +45,12 @@ export class NativeDevelopmentModuleRuntime {
|
|
|
43
45
|
}
|
|
44
46
|
requiresProcessRestart(source) {
|
|
45
47
|
const normalized = resolve(source);
|
|
46
|
-
|
|
48
|
+
const packageRoot = nearestWatchRoot(this.#watchRoots, normalized);
|
|
49
|
+
// Installed packages and external paths are intentionally not watched.
|
|
50
|
+
// The HMR coordinator turns this into a visible process restart reason.
|
|
51
|
+
if (!packageRoot || isNodeModulesSource(packageRoot, normalized))
|
|
47
52
|
return true;
|
|
48
|
-
const parts = relative(
|
|
53
|
+
const parts = relative(packageRoot, normalized).split(sep);
|
|
49
54
|
const capability = parts.findIndex((part) => capabilityRoots.has(part));
|
|
50
55
|
if (capability < 0)
|
|
51
56
|
return isExecutableSource(normalized);
|
|
@@ -63,11 +68,23 @@ export class NativeDevelopmentModuleRuntime {
|
|
|
63
68
|
// entry's zhin-generation, so the importer closure keeps the old code.
|
|
64
69
|
return ['.js', '.json', '.ts'].includes(extname(normalized));
|
|
65
70
|
}
|
|
71
|
+
updateWatchRoots(roots) {
|
|
72
|
+
this.#assertOpen();
|
|
73
|
+
const next = normalizeWatchRoots([
|
|
74
|
+
this.#projectRoot,
|
|
75
|
+
...roots.map((root) => root.root),
|
|
76
|
+
]);
|
|
77
|
+
if (sameRoots(this.#watchRoots, next))
|
|
78
|
+
return;
|
|
79
|
+
this.#watchRoots = next;
|
|
80
|
+
for (const watcher of this.#watchers)
|
|
81
|
+
watcher.replaceRoots(next);
|
|
82
|
+
}
|
|
66
83
|
watch(listener) {
|
|
67
84
|
this.#assertOpen();
|
|
68
85
|
if (!this.#watchEnabled)
|
|
69
86
|
return () => undefined;
|
|
70
|
-
const watcher = new PortableSourceWatcher(this.#
|
|
87
|
+
const watcher = new PortableSourceWatcher(this.#watchRoots, listener);
|
|
71
88
|
this.#watchers.add(watcher);
|
|
72
89
|
return () => {
|
|
73
90
|
watcher.close();
|
|
@@ -103,48 +120,73 @@ export function assertNativeTypeScriptSupport() {
|
|
|
103
120
|
].join(' '));
|
|
104
121
|
}
|
|
105
122
|
class PortableSourceWatcher {
|
|
106
|
-
root;
|
|
107
123
|
listener;
|
|
108
|
-
#
|
|
124
|
+
#watchers = new Set();
|
|
109
125
|
#pollTimer;
|
|
110
126
|
#snapshot;
|
|
111
127
|
#closed = false;
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
#roots;
|
|
129
|
+
constructor(roots, listener) {
|
|
114
130
|
this.listener = listener;
|
|
115
|
-
this.#
|
|
116
|
-
this.#
|
|
131
|
+
this.#roots = normalizeWatchRoots(roots);
|
|
132
|
+
this.#snapshot = sourceSnapshot(this.#roots);
|
|
133
|
+
this.#startNativeWatchers();
|
|
134
|
+
}
|
|
135
|
+
replaceRoots(roots) {
|
|
136
|
+
if (this.#closed)
|
|
137
|
+
return;
|
|
138
|
+
const next = normalizeWatchRoots(roots);
|
|
139
|
+
if (sameRoots(this.#roots, next))
|
|
140
|
+
return;
|
|
141
|
+
// The root set and polling snapshot change together. Native handles are
|
|
142
|
+
// replaced afterwards; stale handles are filtered by the committed set.
|
|
143
|
+
this.#roots = next;
|
|
144
|
+
this.#snapshot = sourceSnapshot(next);
|
|
145
|
+
if (!this.#pollTimer)
|
|
146
|
+
this.#startNativeWatchers();
|
|
117
147
|
}
|
|
118
148
|
close() {
|
|
119
149
|
if (this.#closed)
|
|
120
150
|
return;
|
|
121
151
|
this.#closed = true;
|
|
122
|
-
this.#
|
|
152
|
+
this.#closeNativeWatchers();
|
|
123
153
|
if (this.#pollTimer)
|
|
124
154
|
clearInterval(this.#pollTimer);
|
|
125
155
|
}
|
|
126
|
-
#
|
|
156
|
+
#startNativeWatchers() {
|
|
157
|
+
if (this.#closed || this.#pollTimer)
|
|
158
|
+
return;
|
|
159
|
+
const next = new Set();
|
|
127
160
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
161
|
+
for (const root of this.#roots) {
|
|
162
|
+
const watcher = watchDirectory(root, { recursive: true }, (_event, name) => {
|
|
163
|
+
if (!name || !this.#roots.includes(root))
|
|
164
|
+
return;
|
|
165
|
+
const source = resolve(root, name.toString());
|
|
166
|
+
if (isWatchedSource(source) && !isIgnoredSource(root, source))
|
|
167
|
+
this.listener(source);
|
|
168
|
+
});
|
|
169
|
+
watcher.on('error', () => this.#startPolling());
|
|
170
|
+
next.add(watcher);
|
|
171
|
+
}
|
|
136
172
|
}
|
|
137
173
|
catch {
|
|
174
|
+
for (const watcher of next)
|
|
175
|
+
watcher.close();
|
|
138
176
|
this.#startPolling();
|
|
177
|
+
return;
|
|
139
178
|
}
|
|
179
|
+
const previous = this.#watchers;
|
|
180
|
+
this.#watchers = next;
|
|
181
|
+
for (const watcher of previous)
|
|
182
|
+
watcher.close();
|
|
140
183
|
}
|
|
141
184
|
#startPolling() {
|
|
142
185
|
if (this.#closed || this.#pollTimer)
|
|
143
186
|
return;
|
|
144
|
-
this.#
|
|
145
|
-
this.#watcher = undefined;
|
|
187
|
+
this.#closeNativeWatchers();
|
|
146
188
|
this.#pollTimer = setInterval(() => {
|
|
147
|
-
const next = sourceSnapshot(this
|
|
189
|
+
const next = sourceSnapshot(this.#roots);
|
|
148
190
|
const sources = new Set([...this.#snapshot.keys(), ...next.keys()]);
|
|
149
191
|
for (const source of sources) {
|
|
150
192
|
if (this.#snapshot.get(source) !== next.get(source))
|
|
@@ -153,8 +195,13 @@ class PortableSourceWatcher {
|
|
|
153
195
|
this.#snapshot = next;
|
|
154
196
|
}, 100);
|
|
155
197
|
}
|
|
198
|
+
#closeNativeWatchers() {
|
|
199
|
+
for (const watcher of this.#watchers)
|
|
200
|
+
watcher.close();
|
|
201
|
+
this.#watchers.clear();
|
|
202
|
+
}
|
|
156
203
|
}
|
|
157
|
-
function sourceSnapshot(
|
|
204
|
+
function sourceSnapshot(roots) {
|
|
158
205
|
const result = new Map();
|
|
159
206
|
const visit = (directory) => {
|
|
160
207
|
let entries;
|
|
@@ -179,7 +226,8 @@ function sourceSnapshot(root) {
|
|
|
179
226
|
}
|
|
180
227
|
}
|
|
181
228
|
};
|
|
182
|
-
|
|
229
|
+
for (const root of roots)
|
|
230
|
+
visit(root);
|
|
183
231
|
return result;
|
|
184
232
|
}
|
|
185
233
|
function isWatchedSource(source) {
|
|
@@ -212,3 +260,16 @@ function isWithin(root, source) {
|
|
|
212
260
|
const child = relative(root, source);
|
|
213
261
|
return child === '' || (!child.startsWith('..') && !isAbsolute(child));
|
|
214
262
|
}
|
|
263
|
+
function nearestWatchRoot(roots, source) {
|
|
264
|
+
return roots.find((root) => isWithin(root, source));
|
|
265
|
+
}
|
|
266
|
+
function isNodeModulesSource(root, source) {
|
|
267
|
+
return relative(root, source).split(sep).some((part) => part === 'node_modules');
|
|
268
|
+
}
|
|
269
|
+
function normalizeWatchRoots(roots) {
|
|
270
|
+
const sorted = [...new Set(roots.map((root) => resolve(root)))].sort((left, right) => left.length - right.length);
|
|
271
|
+
return Object.freeze(sorted.filter((root, index) => !sorted.slice(0, index).some((parent) => isWithin(parent, root))));
|
|
272
|
+
}
|
|
273
|
+
function sameRoots(left, right) {
|
|
274
|
+
return left.length === right.length && left.every((root, index) => root === right[index]);
|
|
275
|
+
}
|
|
@@ -41,4 +41,10 @@ export declare class PluginScopeAssembler {
|
|
|
41
41
|
createdScopeDisposers(): readonly (readonly [PluginId, Dispose])[];
|
|
42
42
|
generationHandoff(): GenerationHandoff | undefined;
|
|
43
43
|
setupCapabilities(): readonly Readonly<SetupCapabilityRegistration>[];
|
|
44
|
+
/**
|
|
45
|
+
* Root resources are process-owned. A Plugin Scope receives only a facade
|
|
46
|
+
* that translates logical table/job names to its owner namespace. The root
|
|
47
|
+
* owner deliberately keeps bare names for backward-compatible projects.
|
|
48
|
+
*/
|
|
49
|
+
private installOwnerScopedHosts;
|
|
44
50
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
|
-
import { GenerationHandoffStack, Scope, capabilityId, featureId, rootPluginId, } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import { GenerationHandoffStack, Scope, capabilityId, createPluginDatabaseHost, createPluginScheduleHost, databaseHostToken, databaseRootHostToken, featureId, rootPluginId, scheduleHostToken, scheduleRootHostToken, unwrapPluginDatabaseHost, unwrapPluginScheduleHost, } from '@zhin.js/plugin-runtime';
|
|
3
3
|
import { runtimeEnvironmentToken } from './environment.js';
|
|
4
4
|
import { EnvStoreFactory, envStoreToken, } from './environment-store.js';
|
|
5
5
|
import { createPrimaryConfig, primaryConfigToken, } from './primary-config.js';
|
|
@@ -70,6 +70,8 @@ export class PluginScopeAssembler {
|
|
|
70
70
|
config,
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
|
+
if (node.parent)
|
|
74
|
+
this.installOwnerScopedHosts(scope, node.id);
|
|
73
75
|
const config = Object.freeze(this.configResolver(node) ?? {});
|
|
74
76
|
const view = { get: () => config };
|
|
75
77
|
const plugin = Object.freeze({
|
|
@@ -196,6 +198,29 @@ export class PluginScopeAssembler {
|
|
|
196
198
|
setupCapabilities() {
|
|
197
199
|
return Object.freeze([...this.#setupCapabilities.values()]);
|
|
198
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Root resources are process-owned. A Plugin Scope receives only a facade
|
|
203
|
+
* that translates logical table/job names to its owner namespace. The root
|
|
204
|
+
* owner deliberately keeps bare names for backward-compatible projects.
|
|
205
|
+
*/
|
|
206
|
+
installOwnerScopedHosts(scope, owner) {
|
|
207
|
+
const database = scope.has(databaseRootHostToken)
|
|
208
|
+
? scope.use(databaseRootHostToken)
|
|
209
|
+
: scope.has(databaseHostToken)
|
|
210
|
+
? unwrapPluginDatabaseHost(scope.use(databaseHostToken))
|
|
211
|
+
: undefined;
|
|
212
|
+
if (database) {
|
|
213
|
+
scope.provide(databaseHostToken, createPluginDatabaseHost(owner, database));
|
|
214
|
+
}
|
|
215
|
+
const schedule = scope.has(scheduleRootHostToken)
|
|
216
|
+
? scope.use(scheduleRootHostToken)
|
|
217
|
+
: scope.has(scheduleHostToken)
|
|
218
|
+
? unwrapPluginScheduleHost(scope.use(scheduleHostToken))
|
|
219
|
+
: undefined;
|
|
220
|
+
if (schedule) {
|
|
221
|
+
scope.provide(scheduleHostToken, createPluginScheduleHost(owner, schedule));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
199
224
|
}
|
|
200
225
|
function isWithin(plugin, root) {
|
|
201
226
|
return plugin === root || plugin.startsWith(`${root}/`);
|
package/lib/root-runtime.js
CHANGED
|
@@ -16,6 +16,7 @@ import { ProjectGraphService, } from './project-graph.js';
|
|
|
16
16
|
import { HmrCoordinator } from './hmr-coordinator.js';
|
|
17
17
|
import { RootProcessRestartExecutor, } from './process-restart.js';
|
|
18
18
|
import { SlotGenerationPreparer } from './slot-generation-preparer.js';
|
|
19
|
+
import { capabilityDeltaFromSlots, capabilityDeltaIds, ConventionCapabilityDeltaResolver, filterCapabilityDelta, mergeCapabilityDeltas, } from './convention-capability-delta.js';
|
|
19
20
|
import { SourceOwnershipIndex } from './source-ownership.js';
|
|
20
21
|
import { addCapabilitySlot, featureSetupAliases, mergeSetupCapabilities, } from './setup-capabilities.js';
|
|
21
22
|
import { SubtreeGenerationPreparer, SubtreeTopologyChangedError, } from './subtree-generation-preparer.js';
|
|
@@ -114,20 +115,24 @@ export class RootRuntime {
|
|
|
114
115
|
let prepared;
|
|
115
116
|
let restart;
|
|
116
117
|
const snapshot = await this.controller.reload(plan.subtrees[0] ?? plan.slots[0] ?? rootPluginId(), async (current) => {
|
|
117
|
-
|
|
118
|
+
const resolved = await this.#resolveCapabilityDelta(current, plan);
|
|
119
|
+
const effective = resolved.plan;
|
|
120
|
+
if (this.#model && effective.manifestSources.length > 0) {
|
|
118
121
|
const inspected = await this.#inspectProject();
|
|
119
|
-
restart = new RestartBoundaryPlanner().plan(this.#model.graph, inspected.graph,
|
|
122
|
+
restart = new RestartBoundaryPlanner().plan(this.#model.graph, inspected.graph, effective.changed);
|
|
120
123
|
if (restart)
|
|
121
124
|
return undefined;
|
|
122
|
-
prepared =
|
|
125
|
+
prepared = effective.subtrees.includes(rootPluginId())
|
|
126
|
+
? await this.#prepareInspected(current, inspected)
|
|
127
|
+
: await new TopologyGenerationPreparer(this.#modules, this.#model, inspected.graph, inspected.configResolver, inspected.primaryConfigDocument, this.#environment, this.#installResources, this.#environmentLayers, this.#isolation).prepare(current, { ...effective, capabilities: resolved.capabilities });
|
|
123
128
|
}
|
|
124
|
-
else if (
|
|
129
|
+
else if (effective.subtrees.length === 0 && resolved.capabilities.size > 0 && this.#model) {
|
|
125
130
|
prepared = await new SlotGenerationPreparer(this.#modules, this.#model)
|
|
126
|
-
.prepare(current,
|
|
131
|
+
.prepare(current, resolved.capabilities);
|
|
127
132
|
}
|
|
128
|
-
else if (this.#model && this.#canPrepareSubtrees(
|
|
133
|
+
else if (this.#model && this.#canPrepareSubtrees(effective)) {
|
|
129
134
|
const inspected = await this.#inspectProject();
|
|
130
|
-
prepared = await this.#prepareSubtrees(current, inspected,
|
|
135
|
+
prepared = await this.#prepareSubtrees(current, inspected, effective.subtrees);
|
|
131
136
|
}
|
|
132
137
|
else {
|
|
133
138
|
prepared = await this.#prepare(current);
|
|
@@ -140,21 +145,29 @@ export class RootRuntime {
|
|
|
140
145
|
this.#accept(prepared);
|
|
141
146
|
return snapshot;
|
|
142
147
|
}
|
|
143
|
-
#isManifestTopologyPlan(plan) {
|
|
144
|
-
let manifest = false;
|
|
145
|
-
for (const source of plan.changed) {
|
|
146
|
-
const records = this.#ownership.recordsFor(source);
|
|
147
|
-
if (records.some((record) => record.role === 'manifest'))
|
|
148
|
-
manifest = true;
|
|
149
|
-
if (records.some((record) => record.role !== 'manifest'))
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
return manifest;
|
|
153
|
-
}
|
|
154
148
|
#accept(prepared) {
|
|
155
149
|
this.#ownership = prepared.ownership;
|
|
156
150
|
this.#model = prepared.model;
|
|
157
151
|
}
|
|
152
|
+
async #resolveCapabilityDelta(current, plan) {
|
|
153
|
+
const known = capabilityDeltaFromSlots(current, plan.slots);
|
|
154
|
+
if (!this.#model || (plan.fallbacks.length === 0 && plan.slots.length === 0)) {
|
|
155
|
+
return { plan, capabilities: known };
|
|
156
|
+
}
|
|
157
|
+
const discovered = await new ConventionCapabilityDeltaResolver(this.#modules, this.#model, current).resolve(plan.fallbacks, plan.slots);
|
|
158
|
+
const subtrees = collapseInvalidationSubtrees([
|
|
159
|
+
...plan.subtrees,
|
|
160
|
+
...discovered.unresolved.flatMap((fallback) => fallback.owners),
|
|
161
|
+
]);
|
|
162
|
+
const capabilities = filterCapabilityDelta(mergeCapabilityDeltas(known, discovered.capabilities), (owner) => !subtrees.some((root) => isWithinInvalidationRoot(owner, root)));
|
|
163
|
+
const effective = Object.freeze({
|
|
164
|
+
...plan,
|
|
165
|
+
slots: capabilityDeltaIds(capabilities),
|
|
166
|
+
subtrees,
|
|
167
|
+
fallbacks: Object.freeze([]),
|
|
168
|
+
});
|
|
169
|
+
return { plan: effective, capabilities };
|
|
170
|
+
}
|
|
158
171
|
#canPrepareSubtrees(plan) {
|
|
159
172
|
if (plan.subtrees.length === 0 || plan.subtrees.includes(rootPluginId()))
|
|
160
173
|
return false;
|
|
@@ -299,6 +312,13 @@ export class RootRuntime {
|
|
|
299
312
|
}
|
|
300
313
|
}
|
|
301
314
|
}
|
|
315
|
+
function collapseInvalidationSubtrees(values) {
|
|
316
|
+
const sorted = [...new Set(values)].sort((left, right) => left.length - right.length);
|
|
317
|
+
return Object.freeze(sorted.filter((candidate, index) => !sorted.slice(0, index).some((root) => isWithinInvalidationRoot(candidate, root))));
|
|
318
|
+
}
|
|
319
|
+
function isWithinInvalidationRoot(plugin, root) {
|
|
320
|
+
return plugin === root || plugin.startsWith(`${root}/`);
|
|
321
|
+
}
|
|
302
322
|
function withConfigDocumentHandoff(generation, document, committed) {
|
|
303
323
|
const handoffs = new GenerationHandoffStack();
|
|
304
324
|
if (generation.handoff)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type CapabilityId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
2
|
import type { ModuleRuntime } from './module-runtime.js';
|
|
3
3
|
import type { PreparedRuntimeGeneration, RuntimeGenerationModel } from './runtime-generation.js';
|
|
4
|
+
import { type CapabilityDelta } from './convention-capability-delta.js';
|
|
4
5
|
export declare class SlotGenerationPreparer {
|
|
5
6
|
private readonly modules;
|
|
6
7
|
private readonly model;
|
|
7
8
|
constructor(modules: ModuleRuntime, model: RuntimeGenerationModel);
|
|
8
|
-
prepare(current: RuntimeSnapshot, selected: readonly CapabilityId[]): Promise<PreparedRuntimeGeneration>;
|
|
9
|
+
prepare(current: RuntimeSnapshot, selected: readonly CapabilityId[] | CapabilityDelta): Promise<PreparedRuntimeGeneration>;
|
|
9
10
|
}
|
|
@@ -3,6 +3,7 @@ import { FeatureDiscovery } from '@zhin.js/feature-kit';
|
|
|
3
3
|
import { FeatureProjector, composeGenerationHandoffs } from './feature-projector.js';
|
|
4
4
|
import { NodeDiscoveryHost } from './node-discovery-host.js';
|
|
5
5
|
import { SourceOwnershipIndex } from './source-ownership.js';
|
|
6
|
+
import { capabilityDeltaFromSlots, } from './convention-capability-delta.js';
|
|
6
7
|
export class SlotGenerationPreparer {
|
|
7
8
|
modules;
|
|
8
9
|
model;
|
|
@@ -11,7 +12,9 @@ export class SlotGenerationPreparer {
|
|
|
11
12
|
this.model = model;
|
|
12
13
|
}
|
|
13
14
|
async prepare(current, selected) {
|
|
14
|
-
const selectedByFeature =
|
|
15
|
+
const selectedByFeature = Array.isArray(selected)
|
|
16
|
+
? capabilityDeltaFromSlots(current, selected)
|
|
17
|
+
: selected;
|
|
15
18
|
const capabilities = new Map(current.capabilities);
|
|
16
19
|
const discovery = new FeatureDiscovery(new NodeDiscoveryHost(this.modules));
|
|
17
20
|
for (const [feature, ids] of selectedByFeature) {
|
|
@@ -59,18 +62,6 @@ export class SlotGenerationPreparer {
|
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
|
-
function groupByFeature(current, selected) {
|
|
63
|
-
const result = new Map();
|
|
64
|
-
for (const id of selected) {
|
|
65
|
-
const slot = current.capabilities.get(id);
|
|
66
|
-
if (!slot)
|
|
67
|
-
throw new Error(`Cannot reload missing Capability Slot: ${id}`);
|
|
68
|
-
const ids = result.get(slot.feature) ?? new Set();
|
|
69
|
-
ids.add(id);
|
|
70
|
-
result.set(slot.feature, ids);
|
|
71
|
-
}
|
|
72
|
-
return result;
|
|
73
|
-
}
|
|
74
65
|
async function disposeProjections(disposers, prepareError) {
|
|
75
66
|
const rollback = new DisposeStack();
|
|
76
67
|
for (const dispose of disposers)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CapabilityId, FeatureId, PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import type { ModuleWatchRoot } from './module-runtime.js';
|
|
2
3
|
import type { ProjectGraph } from './project-graph.js';
|
|
3
4
|
export type SourceRole = 'plugin' | 'schema' | 'manifest' | 'feature' | 'capability';
|
|
4
5
|
export interface SourceOwnershipRecord {
|
|
@@ -16,4 +17,6 @@ export declare class SourceOwnershipIndex {
|
|
|
16
17
|
addPackageRoot(packageRoot: string, owner: PluginId): void;
|
|
17
18
|
recordsFor(source: string): readonly SourceOwnershipRecord[];
|
|
18
19
|
ownersForPath(source: string): readonly PluginId[];
|
|
20
|
+
watchRoots(): readonly ModuleWatchRoot[];
|
|
21
|
+
addWatchRoot(root: ModuleWatchRoot): void;
|
|
19
22
|
}
|
package/lib/source-ownership.js
CHANGED
|
@@ -2,11 +2,17 @@ import { isAbsolute, relative, resolve } from 'node:path';
|
|
|
2
2
|
export class SourceOwnershipIndex {
|
|
3
3
|
#records = new Map();
|
|
4
4
|
#packages = [];
|
|
5
|
+
#watchRoots = new Map();
|
|
5
6
|
static empty() {
|
|
6
7
|
return new SourceOwnershipIndex();
|
|
7
8
|
}
|
|
8
9
|
static fromGeneration(graph, snapshot, featureIdsByPackageRoot) {
|
|
9
10
|
const index = new SourceOwnershipIndex();
|
|
11
|
+
for (const pkg of graph.packages.values()) {
|
|
12
|
+
if (pkg.source === 'node_modules')
|
|
13
|
+
continue;
|
|
14
|
+
index.addWatchRoot({ root: pkg.root, source: pkg.source });
|
|
15
|
+
}
|
|
10
16
|
visitPlugin(graph.root, (node) => {
|
|
11
17
|
index.addPackageRoot(node.package.root, node.id);
|
|
12
18
|
index.add({
|
|
@@ -86,6 +92,13 @@ export class SourceOwnershipIndex {
|
|
|
86
92
|
}
|
|
87
93
|
return Object.freeze([...owners]);
|
|
88
94
|
}
|
|
95
|
+
watchRoots() {
|
|
96
|
+
return Object.freeze([...this.#watchRoots.values()]);
|
|
97
|
+
}
|
|
98
|
+
addWatchRoot(root) {
|
|
99
|
+
const normalized = resolve(root.root);
|
|
100
|
+
this.#watchRoots.set(normalized, Object.freeze({ ...root, root: normalized }));
|
|
101
|
+
}
|
|
89
102
|
}
|
|
90
103
|
function visitPlugin(node, visit) {
|
|
91
104
|
visit(node);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
1
|
+
import { type CapabilityId, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
2
|
import type { RuntimeEnvironment } from './environment.js';
|
|
3
3
|
import type { EnvironmentLayers } from './environment-store.js';
|
|
4
4
|
import type { RuntimeConfigDocument } from './config-composer.js';
|
|
@@ -7,6 +7,13 @@ import type { ModuleRuntime } from './module-runtime.js';
|
|
|
7
7
|
import { type PluginConfigResolver, type RootResourceInstaller } from './plugin-scope-assembler.js';
|
|
8
8
|
import type { ProjectGraph } from './project-graph.js';
|
|
9
9
|
import type { PreparedRuntimeGeneration, RuntimeGenerationModel } from './runtime-generation.js';
|
|
10
|
+
import { type CapabilityDelta } from './convention-capability-delta.js';
|
|
11
|
+
/** Runtime-local invalidation to commit alongside an ABI-safe manifest change. */
|
|
12
|
+
export interface TopologyRuntimeDelta {
|
|
13
|
+
readonly slots: readonly CapabilityId[];
|
|
14
|
+
readonly subtrees: readonly PluginId[];
|
|
15
|
+
readonly capabilities?: CapabilityDelta;
|
|
16
|
+
}
|
|
10
17
|
/** Prepares manifest topology changes without rebuilding stable Plugin Scopes. */
|
|
11
18
|
export declare class TopologyGenerationPreparer {
|
|
12
19
|
#private;
|
|
@@ -20,5 +27,5 @@ export declare class TopologyGenerationPreparer {
|
|
|
20
27
|
private readonly environmentLayers;
|
|
21
28
|
private readonly isolation?;
|
|
22
29
|
constructor(modules: ModuleRuntime, model: RuntimeGenerationModel, graph: ProjectGraph, configResolver: PluginConfigResolver, primaryConfigDocument: RuntimeConfigDocument, environment: RuntimeEnvironment, installResources?: RootResourceInstaller | undefined, environmentLayers?: EnvironmentLayers, isolation?: IsolatedPluginRuntimePort | undefined);
|
|
23
|
-
prepare(current: RuntimeSnapshot): Promise<PreparedRuntimeGeneration | undefined>;
|
|
30
|
+
prepare(current: RuntimeSnapshot, delta?: TopologyRuntimeDelta): Promise<PreparedRuntimeGeneration | undefined>;
|
|
24
31
|
}
|
|
@@ -6,6 +6,7 @@ import { FeatureProjector, composeGenerationHandoffs } from './feature-projector
|
|
|
6
6
|
import { NodeDiscoveryHost } from './node-discovery-host.js';
|
|
7
7
|
import { PluginScopeAssembler, } from './plugin-scope-assembler.js';
|
|
8
8
|
import { SourceOwnershipIndex } from './source-ownership.js';
|
|
9
|
+
import { capabilityDeltaFromSlots, capabilityDeltaIds, } from './convention-capability-delta.js';
|
|
9
10
|
import { addCapabilitySlot, featureSetupAliases, mergeSetupCapabilities, } from './setup-capabilities.js';
|
|
10
11
|
import { TopologyTransactionPlanner, collapseRoots, graphNodes, graphOrder, isWithin, } from './topology-transaction.js';
|
|
11
12
|
/** Prepares manifest topology changes without rebuilding stable Plugin Scopes. */
|
|
@@ -30,7 +31,7 @@ export class TopologyGenerationPreparer {
|
|
|
30
31
|
this.environmentLayers = environmentLayers;
|
|
31
32
|
this.isolation = isolation;
|
|
32
33
|
}
|
|
33
|
-
async prepare(current) {
|
|
34
|
+
async prepare(current, delta = { slots: [], subtrees: [] }) {
|
|
34
35
|
const planned = new TopologyTransactionPlanner().plan(this.model.graph, this.graph);
|
|
35
36
|
const nextNodes = graphNodes(this.graph);
|
|
36
37
|
const configReplacements = changedConfigRoots(current, nextNodes, this.configResolver);
|
|
@@ -39,7 +40,9 @@ export class TopologyGenerationPreparer {
|
|
|
39
40
|
...configReplacements,
|
|
40
41
|
]);
|
|
41
42
|
const plan = withReplacements(planned, replacedPluginRoots);
|
|
42
|
-
|
|
43
|
+
const subtreeRoots = collapseRoots(delta.subtrees);
|
|
44
|
+
const selected = delta.capabilities ?? capabilityDeltaFromSlots(current, delta.slots);
|
|
45
|
+
if (!plan.changed && subtreeRoots.length === 0 && selected.size === 0)
|
|
43
46
|
return undefined;
|
|
44
47
|
const featureTopology = await this.#loadFeatureTopology(plan);
|
|
45
48
|
const plugins = new PluginScopeAssembler(this.modules, this.configResolver, this.environment, this.primaryConfigDocument, this.installResources, this.environmentLayers, {
|
|
@@ -52,10 +55,12 @@ export class TopologyGenerationPreparer {
|
|
|
52
55
|
const setupRoots = collapseRoots([
|
|
53
56
|
...plan.addedPluginRoots,
|
|
54
57
|
...plan.replacedPluginRoots,
|
|
58
|
+
...subtreeRoots,
|
|
55
59
|
]);
|
|
56
60
|
const removalRoots = collapseRoots([
|
|
57
61
|
...plan.removedPluginRoots,
|
|
58
62
|
...plan.replacedPluginRoots,
|
|
63
|
+
...subtreeRoots,
|
|
59
64
|
]);
|
|
60
65
|
plugins.removeSubtrees(removalRoots);
|
|
61
66
|
const projectionDisposers = new Map();
|
|
@@ -69,7 +74,7 @@ export class TopologyGenerationPreparer {
|
|
|
69
74
|
// Retained parents still need a new immutable children view after add,
|
|
70
75
|
// remove, move, or reorder operations.
|
|
71
76
|
plugins.synchronizeTree(this.graph.root);
|
|
72
|
-
const capabilities = await this.#prepareCapabilities(current, plan, setupRoots, featureTopology);
|
|
77
|
+
const capabilities = await this.#prepareCapabilities(current, plan, setupRoots, featureTopology, selected);
|
|
73
78
|
mergeSetupCapabilities(capabilities, plugins.setupCapabilities(), featureTopology.providers, featureTopology.rootsByFeature);
|
|
74
79
|
const projected = await new FeatureProjector(featureTopology.providers.values()).project(current.generation + 1, {
|
|
75
80
|
root: current.root,
|
|
@@ -140,7 +145,7 @@ export class TopologyGenerationPreparer {
|
|
|
140
145
|
featureIdsByPackageRoot: new Map(featureIdsByPackageRoot),
|
|
141
146
|
};
|
|
142
147
|
}
|
|
143
|
-
async #prepareCapabilities(current, plan, setupRoots, topology) {
|
|
148
|
+
async #prepareCapabilities(current, plan, setupRoots, topology, selected) {
|
|
144
149
|
const nextOwners = new Set(graphNodes(this.graph).keys());
|
|
145
150
|
const mounted = mountedFeatures(topology);
|
|
146
151
|
const refresh = new Set();
|
|
@@ -167,9 +172,13 @@ export class TopologyGenerationPreparer {
|
|
|
167
172
|
refresh.add(ownerFeatureKey(mount.owner, previousFeature));
|
|
168
173
|
}
|
|
169
174
|
const capabilities = new Map(current.capabilities);
|
|
175
|
+
const selectedIds = new Set(capabilityDeltaIds(selected));
|
|
170
176
|
for (const [id, slot] of capabilities) {
|
|
171
177
|
const key = ownerFeatureKey(slot.owner, slot.feature);
|
|
172
|
-
if (!nextOwners.has(slot.owner)
|
|
178
|
+
if (!nextOwners.has(slot.owner)
|
|
179
|
+
|| !mounted.has(key)
|
|
180
|
+
|| refresh.has(key)
|
|
181
|
+
|| selectedIds.has(id)) {
|
|
173
182
|
capabilities.delete(id);
|
|
174
183
|
}
|
|
175
184
|
}
|
|
@@ -185,6 +194,19 @@ export class TopologyGenerationPreparer {
|
|
|
185
194
|
addCapabilitySlot(capabilities, slot);
|
|
186
195
|
}
|
|
187
196
|
}
|
|
197
|
+
// A manifest batch can carry a direct capability change. Rediscover the
|
|
198
|
+
// requested Slots after topology refresh so both observations commit as
|
|
199
|
+
// one immutable generation instead of racing two reload transactions.
|
|
200
|
+
const selectedByFeature = selectedSlotsByFeature(current, selected, refresh);
|
|
201
|
+
for (const [feature, ids] of selectedByFeature) {
|
|
202
|
+
const provider = topology.providers.get(feature);
|
|
203
|
+
if (!provider)
|
|
204
|
+
continue;
|
|
205
|
+
const roots = topology.rootsByFeature.get(feature) ?? [];
|
|
206
|
+
for (const slot of await discovery.discover(provider, roots, { capabilities: ids })) {
|
|
207
|
+
addCapabilitySlot(capabilities, slot);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
188
210
|
return capabilities;
|
|
189
211
|
}
|
|
190
212
|
}
|
|
@@ -238,6 +260,20 @@ function featureMountsForReload(graph, packageRoots) {
|
|
|
238
260
|
function ownerFeatureKey(owner, feature) {
|
|
239
261
|
return `${owner}\0${feature}`;
|
|
240
262
|
}
|
|
263
|
+
function selectedSlotsByFeature(current, selected, refreshed) {
|
|
264
|
+
const result = new Map();
|
|
265
|
+
for (const [feature, selectedIds] of selected) {
|
|
266
|
+
for (const id of selectedIds) {
|
|
267
|
+
const slot = current.capabilities.get(id);
|
|
268
|
+
if (slot && refreshed.has(ownerFeatureKey(slot.owner, slot.feature)))
|
|
269
|
+
continue;
|
|
270
|
+
const ids = result.get(feature) ?? new Set();
|
|
271
|
+
ids.add(id);
|
|
272
|
+
result.set(feature, ids);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
241
277
|
function sameValues(left, right) {
|
|
242
278
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
243
279
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/runtime",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Static Plugin graph, generation transaction and HMR Root runtime for Zhin.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,22 +18,22 @@
|
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"ajv": "8.18.0",
|
|
20
20
|
"semver": "7.8.5",
|
|
21
|
-
"@zhin.js/feature-kit": "1.0.
|
|
22
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
21
|
+
"@zhin.js/feature-kit": "1.0.5",
|
|
22
|
+
"@zhin.js/plugin-runtime": "1.1.2"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@types/node": "^26.1.
|
|
25
|
+
"@types/node": "^26.1.2",
|
|
26
26
|
"typescript": "^6.0.3",
|
|
27
|
-
"@zhin.js/adapter": "1.1.
|
|
28
|
-
"@zhin.js/
|
|
29
|
-
"@zhin.js/
|
|
30
|
-
"@zhin.js/
|
|
31
|
-
"@zhin.js/
|
|
32
|
-
"@zhin.js/mcp-feature": "1.0.
|
|
33
|
-
"@zhin.js/
|
|
34
|
-
"@zhin.js/
|
|
35
|
-
"@zhin.js/skill": "1.0.
|
|
36
|
-
"@zhin.js/tool": "1.0.
|
|
27
|
+
"@zhin.js/adapter": "1.1.4",
|
|
28
|
+
"@zhin.js/command": "1.0.6",
|
|
29
|
+
"@zhin.js/agent-feature": "1.0.5",
|
|
30
|
+
"@zhin.js/layout": "1.0.5",
|
|
31
|
+
"@zhin.js/component": "1.0.5",
|
|
32
|
+
"@zhin.js/mcp-feature": "1.0.5",
|
|
33
|
+
"@zhin.js/page": "1.0.5",
|
|
34
|
+
"@zhin.js/middleware": "1.0.5",
|
|
35
|
+
"@zhin.js/skill": "1.0.5",
|
|
36
|
+
"@zhin.js/tool": "1.0.5"
|
|
37
37
|
},
|
|
38
38
|
"repository": {
|
|
39
39
|
"type": "git",
|