@zhin.js/runtime 1.0.1 → 1.0.3

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.
@@ -1,25 +1,30 @@
1
1
  import { resolve } from 'node:path';
2
- import { GenerationHandoffStack, Scope, rootPluginId, } from '@zhin.js/plugin-runtime';
2
+ import { GenerationHandoffStack, Scope, capabilityId, featureId, rootPluginId, } from '@zhin.js/plugin-runtime';
3
3
  import { runtimeEnvironmentToken } from './environment.js';
4
4
  import { EnvStoreFactory, envStoreToken, } from './environment-store.js';
5
+ import { createPrimaryConfig, primaryConfigToken, } from './primary-config.js';
5
6
  /** Assembles Plugin setup into mutable shadow maps without publishing them. */
6
7
  export class PluginScopeAssembler {
7
8
  modules;
8
9
  configResolver;
9
10
  environment;
11
+ primaryConfigDocument;
10
12
  installResources;
11
13
  isolation;
12
14
  scopes;
13
15
  tree;
14
16
  config;
15
17
  resources;
18
+ #setupCapabilities = new Map();
16
19
  #created = [];
17
20
  #handoffs = new GenerationHandoffStack();
18
21
  #envStores;
19
- constructor(modules, configResolver, environment, installResources, environmentLayers = {}, seed, isolation) {
22
+ #setupFeatureAliases = new Map();
23
+ constructor(modules, configResolver, environment, primaryConfigDocument, installResources, environmentLayers = {}, seed, isolation) {
20
24
  this.modules = modules;
21
25
  this.configResolver = configResolver;
22
26
  this.environment = environment;
27
+ this.primaryConfigDocument = primaryConfigDocument;
23
28
  this.installResources = installResources;
24
29
  this.isolation = isolation;
25
30
  this.#envStores = new EnvStoreFactory(environment, environmentLayers);
@@ -40,6 +45,9 @@ export class PluginScopeAssembler {
40
45
  this.resources.delete(owner);
41
46
  }
42
47
  }
48
+ installSetupFeatureAliases(aliases) {
49
+ this.#setupFeatureAliases = new Map(aliases);
50
+ }
43
51
  async setupTree(node) {
44
52
  const manifest = node.package.packageJson.zhin;
45
53
  const parentScope = node.parent ? this.scopes.get(node.parent) : undefined;
@@ -49,13 +57,17 @@ export class PluginScopeAssembler {
49
57
  this.scopes.set(node.id, scope);
50
58
  this.#created.push(node.id);
51
59
  // Every owner shadows the inherited EnvStore with its exact overlay view.
52
- scope.provide(envStoreToken, this.#envStores.create(node.id));
60
+ const environment = this.#envStores.create(node.id);
61
+ scope.provide(envStoreToken, environment);
53
62
  if (!node.parent) {
54
63
  scope.provide(runtimeEnvironmentToken, this.environment);
64
+ const config = createPrimaryConfig(this.primaryConfigDocument, environment);
65
+ scope.provide(primaryConfigToken, config);
55
66
  await this.installResources?.({
56
67
  resources: scope,
57
68
  lifecycle: scope.disposers,
58
69
  handoff: this.#handoffs,
70
+ config,
59
71
  });
60
72
  }
61
73
  const config = Object.freeze(this.configResolver(node) ?? {});
@@ -109,13 +121,33 @@ export class PluginScopeAssembler {
109
121
  throw new Error(`Missing resource ${token.id} for Plugin ${node.id}`);
110
122
  }
111
123
  }
112
- const returned = await definition.setup?.({
124
+ const register = (feature, localName, capabilityDefinition) => {
125
+ const featureName = featureId(String(feature));
126
+ const id = capabilityId(node.id, featureName, localName);
127
+ if (this.#setupCapabilities.has(id)) {
128
+ throw new Error(`Duplicate setup Capability: ${id}`);
129
+ }
130
+ this.#setupCapabilities.set(id, Object.freeze({
131
+ id,
132
+ owner: node.id,
133
+ feature: featureName,
134
+ localName,
135
+ source: resolve(node.package.root, manifest.entry),
136
+ definition: capabilityDefinition,
137
+ }));
138
+ };
139
+ const setupContext = {
113
140
  plugin,
114
141
  config: view,
115
142
  resources: scope,
116
143
  lifecycle: scope.disposers,
117
144
  handoff: this.#handoffs,
118
- });
145
+ addFeature: register,
146
+ };
147
+ for (const [method, feature] of this.#setupFeatureAliases) {
148
+ setupContext[method] = (name, value) => register(feature, name, value);
149
+ }
150
+ const returned = await definition.setup?.(Object.freeze(setupContext));
119
151
  if (returned)
120
152
  scope.disposers.add(returned);
121
153
  metadata = definition.metadata;
@@ -161,6 +193,9 @@ export class PluginScopeAssembler {
161
193
  generationHandoff() {
162
194
  return this.#handoffs.seal();
163
195
  }
196
+ setupCapabilities() {
197
+ return Object.freeze([...this.#setupCapabilities.values()]);
198
+ }
164
199
  }
165
200
  function isWithin(plugin, root) {
166
201
  return plugin === root || plugin.startsWith(`${root}/`);
@@ -0,0 +1,16 @@
1
+ import type { EnvStore } from './environment-store.js';
2
+ import type { RuntimeConfigDocument } from './config-composer.js';
3
+ /**
4
+ * Generation-owned, validated Root configuration.
5
+ *
6
+ * `document` deliberately retains environment placeholders for safe display and
7
+ * persistence. Runtime consumers use `expanded`, which is derived exclusively
8
+ * through the Root EnvStore instead of reading process.env themselves.
9
+ */
10
+ export interface PrimaryConfig {
11
+ readonly document: RuntimeConfigDocument;
12
+ readonly expanded: RuntimeConfigDocument;
13
+ get<T = unknown>(key: string): T | undefined;
14
+ }
15
+ export declare const primaryConfigToken: import("@zhin.js/plugin-runtime").Token<PrimaryConfig>;
16
+ export declare function createPrimaryConfig(document: RuntimeConfigDocument, environment: EnvStore): PrimaryConfig;
@@ -0,0 +1,20 @@
1
+ import { createToken } from '@zhin.js/plugin-runtime';
2
+ export const primaryConfigToken = createToken('zhin.primary-config', 'Validated Root configuration for the current generation');
3
+ export function createPrimaryConfig(document, environment) {
4
+ const safeDocument = freezeConfig(structuredClone(document));
5
+ const expanded = freezeConfig(environment.expandMissingAsEmpty(safeDocument));
6
+ return Object.freeze({
7
+ document: safeDocument,
8
+ expanded,
9
+ get(key) {
10
+ return expanded[key];
11
+ },
12
+ });
13
+ }
14
+ function freezeConfig(value) {
15
+ if (!value || typeof value !== 'object' || Object.isFrozen(value))
16
+ return value;
17
+ for (const child of Object.values(value))
18
+ freezeConfig(child);
19
+ return Object.freeze(value);
20
+ }
@@ -18,13 +18,14 @@ export interface ProjectGraph {
18
18
  readonly packages: ReadonlyMap<string, ResolvedPackage>;
19
19
  readonly buildOrder: readonly ResolvedPackage[];
20
20
  }
21
+ export interface ProjectGraphServiceOptions {
22
+ readonly engineVersion?: string;
23
+ }
21
24
  export declare class ProjectGraphError extends Error {
22
25
  constructor(message: string);
23
26
  }
24
27
  export declare class ProjectGraphService {
25
28
  #private;
26
- private readonly resolver;
27
- private readonly engineVersion;
28
- constructor(resolver: PackageResolver, engineVersion?: string);
29
+ constructor(resolver: PackageResolver, engineVersionOrOptions?: string | ProjectGraphServiceOptions);
29
30
  inspect(projectRoot: string): Promise<ProjectGraph>;
30
31
  }
@@ -1,6 +1,7 @@
1
1
  import { childPluginId, rootPluginId } from '@zhin.js/plugin-runtime';
2
2
  import { assertFeatureApi, assertPackageEngine, runtimeEngineVersion, } from './compatibility.js';
3
3
  import { PackageResolutionError, } from './package-resolver.js';
4
+ import { PLATFORM_FEATURE_CARRIER, PLATFORM_FEATURE_FACADE, declaredPackageDependency, mergeChildPluginReferences, mergeFeatureReferences, } from './platform-features.js';
4
5
  export class ProjectGraphError extends Error {
5
6
  constructor(message) {
6
7
  super(message);
@@ -8,18 +9,20 @@ export class ProjectGraphError extends Error {
8
9
  }
9
10
  }
10
11
  export class ProjectGraphService {
11
- resolver;
12
- engineVersion;
13
- constructor(resolver, engineVersion = runtimeEngineVersion) {
14
- this.resolver = resolver;
15
- this.engineVersion = engineVersion;
12
+ #resolver;
13
+ #engineVersion;
14
+ constructor(resolver, engineVersionOrOptions = runtimeEngineVersion) {
15
+ this.#resolver = resolver;
16
+ this.#engineVersion = typeof engineVersionOrOptions === 'string'
17
+ ? engineVersionOrOptions
18
+ : (engineVersionOrOptions.engineVersion ?? runtimeEngineVersion);
16
19
  }
17
20
  async inspect(projectRoot) {
18
- const rootPackage = await this.resolver.root(projectRoot);
21
+ const rootPackage = await this.#resolver.root(projectRoot);
19
22
  assertPackageType(rootPackage, 'plugin');
20
23
  const packages = new Map();
21
- const root = await this.#visitPlugin(rootPackage, rootPluginId(), 'root', undefined, [], packages);
22
- for (const pkg of this.resolver.workspacePackages())
24
+ const root = await this.#visitPlugin(rootPackage, rootPluginId(), 'root', undefined, [], packages, true);
25
+ for (const pkg of this.#resolver.workspacePackages())
23
26
  addPackage(packages, pkg);
24
27
  return Object.freeze({
25
28
  root,
@@ -27,39 +30,62 @@ export class ProjectGraphService {
27
30
  buildOrder: topologicalBuildOrder(packages),
28
31
  });
29
32
  }
30
- async #visitPlugin(pkg, id, instanceKey, parent, ancestors, packages) {
33
+ async #visitPlugin(pkg, id, instanceKey, parent, ancestors, packages, isRoot) {
31
34
  if (ancestors.includes(pkg.root)) {
32
35
  throw new ProjectGraphError(`Plugin cycle detected: ${[...ancestors, pkg.root].join(' -> ')}`);
33
36
  }
34
37
  const manifest = assertPackageType(pkg, 'plugin');
35
- assertPackageEngine(pkg, this.engineVersion);
38
+ assertPackageEngine(pkg, this.#engineVersion);
36
39
  addPackage(packages, pkg);
40
+ let featureCarrier;
41
+ let pluginFacade;
42
+ let featureRefs = manifest.features;
43
+ let pluginRefs = manifest.plugins;
44
+ if (isRoot && manifest.platformFeatures !== false) {
45
+ const platform = await tryResolvePlatform(this.#resolver, pkg);
46
+ featureCarrier = platform.featureCarrier;
47
+ pluginFacade = platform.pluginFacade;
48
+ if (featureCarrier) {
49
+ addPackage(packages, featureCarrier);
50
+ const carrierManifest = featureCarrier.packageJson.zhin;
51
+ const inherited = carrierManifest.type === 'plugin' ? carrierManifest.features : [];
52
+ featureRefs = mergeFeatureReferences(manifest.features, inherited);
53
+ }
54
+ if (pluginFacade) {
55
+ addPackage(packages, pluginFacade);
56
+ const facadeManifest = pluginFacade.packageJson.zhin;
57
+ const inheritedPlugins = facadeManifest.type === 'plugin' ? facadeManifest.plugins : [];
58
+ pluginRefs = mergeChildPluginReferences(manifest.plugins, inheritedPlugins);
59
+ }
60
+ }
37
61
  const featurePackages = new Set();
38
- const features = await Promise.all(manifest.features.map(async (reference) => {
62
+ const features = await Promise.all(featureRefs.map(async (reference) => {
39
63
  if (featurePackages.has(reference.package)) {
40
64
  throw new ProjectGraphError(`Duplicate Feature requirement ${reference.package} in ${pkg.name}`);
41
65
  }
42
66
  featurePackages.add(reference.package);
43
- const resolved = await resolveReference(this.resolver, pkg, reference);
67
+ const resolved = await this.#resolveFeatureReference(pkg, reference, featureCarrier);
44
68
  if (!resolved)
45
69
  return undefined;
46
70
  assertPackageType(resolved, 'feature');
47
- assertPackageEngine(resolved, this.engineVersion);
71
+ assertPackageEngine(resolved, this.#engineVersion);
48
72
  assertFeatureApi(pkg, reference, resolved);
49
73
  addPackage(packages, resolved);
50
74
  return Object.freeze({ reference, package: resolved });
51
75
  }));
76
+ const ownPluginKeys = new Set(manifest.plugins.map((item) => item.instanceKey));
52
77
  const instanceKeys = new Set();
53
- const children = await Promise.all(manifest.plugins.map(async (reference) => {
78
+ const children = await Promise.all(pluginRefs.map(async (reference) => {
54
79
  if (instanceKeys.has(reference.instanceKey)) {
55
80
  throw new ProjectGraphError(`Duplicate child instanceKey ${reference.instanceKey} in ${pkg.name}`);
56
81
  }
57
82
  instanceKeys.add(reference.instanceKey);
58
- const resolved = await resolveReference(this.resolver, pkg, reference);
83
+ const resolveFrom = ownPluginKeys.has(reference.instanceKey) ? pkg : (pluginFacade ?? pkg);
84
+ const resolved = await resolveReference(this.#resolver, resolveFrom, reference);
59
85
  if (!resolved)
60
86
  return undefined;
61
87
  assertPackageType(resolved, 'plugin');
62
- return this.#visitPlugin(resolved, childPluginId(id, reference.instanceKey), reference.instanceKey, id, [...ancestors, pkg.root], packages);
88
+ return this.#visitPlugin(resolved, childPluginId(id, reference.instanceKey), reference.instanceKey, id, [...ancestors, pkg.root], packages, false);
63
89
  }));
64
90
  return Object.freeze({
65
91
  id,
@@ -70,15 +96,62 @@ export class ProjectGraphService {
70
96
  children: Object.freeze(children.filter(isDefined)),
71
97
  });
72
98
  }
99
+ async #resolveFeatureReference(pkg, reference, featureCarrier) {
100
+ try {
101
+ return await this.#resolver.resolve(reference.package, pkg);
102
+ }
103
+ catch (error) {
104
+ // 非解析类错误(损坏的 package.json 等)不回退、不容错,直接上抛。
105
+ if (!(error instanceof PackageResolutionError))
106
+ throw error;
107
+ // carrier 回退:继承自 @zhin.js/core 的 Feature 引用声明在 carrier 上,
108
+ // 从引用包解析失败(未声明 / 未安装 / workspace 链接缺失)时改从 carrier 解析。
109
+ if (featureCarrier)
110
+ return resolveReference(this.#resolver, featureCarrier, reference);
111
+ // 无 carrier 时按统一规则处理:optional 引用容错,其余上抛。
112
+ if (reference.optional)
113
+ return undefined;
114
+ throw error;
115
+ }
116
+ }
117
+ }
118
+ async function tryResolvePlatform(resolver, from) {
119
+ let featureCarrier;
120
+ let pluginFacade;
121
+ const directCore = declaredPackageDependency(from.packageJson.dependencies, from.packageJson.optionalDependencies, PLATFORM_FEATURE_CARRIER);
122
+ if (directCore) {
123
+ try {
124
+ featureCarrier = await resolver.resolve(PLATFORM_FEATURE_CARRIER, from);
125
+ }
126
+ catch (error) {
127
+ if (!(error instanceof PackageResolutionError))
128
+ throw error;
129
+ }
130
+ }
131
+ const facadeDecl = declaredPackageDependency(from.packageJson.dependencies, from.packageJson.optionalDependencies, PLATFORM_FEATURE_FACADE);
132
+ if (facadeDecl) {
133
+ try {
134
+ pluginFacade = await resolver.resolve(PLATFORM_FEATURE_FACADE, from);
135
+ if (!featureCarrier) {
136
+ featureCarrier = await resolver.resolve(PLATFORM_FEATURE_CARRIER, pluginFacade);
137
+ }
138
+ }
139
+ catch (error) {
140
+ if (!(error instanceof PackageResolutionError))
141
+ throw error;
142
+ pluginFacade = undefined;
143
+ }
144
+ }
145
+ return { featureCarrier, pluginFacade };
73
146
  }
74
147
  async function resolveReference(resolver, from, reference) {
75
148
  try {
76
149
  return await resolver.resolve(reference.package, from);
77
150
  }
78
151
  catch (error) {
79
- if (reference.optional
80
- && error instanceof PackageResolutionError
81
- && error.message.startsWith('Cannot resolve'))
152
+ // optional 引用对所有 PackageResolutionError 统一容错(未声明 / 未安装 /
153
+ // workspace 链接缺失);非解析类错误(损坏的 package.json 等)继续上抛。
154
+ if (reference.optional && error instanceof PackageResolutionError)
82
155
  return undefined;
83
156
  throw error;
84
157
  }
@@ -12,11 +12,12 @@ import { GenerationAssets } from './generation-assets.js';
12
12
  import { NodeDiscoveryHost } from './node-discovery-host.js';
13
13
  import { NodePackageResolver } from './package-resolver.js';
14
14
  import { PluginScopeAssembler, } from './plugin-scope-assembler.js';
15
- import { ProjectGraphService } from './project-graph.js';
15
+ 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
19
  import { SourceOwnershipIndex } from './source-ownership.js';
20
+ import { addCapabilitySlot, featureSetupAliases, mergeSetupCapabilities, } from './setup-capabilities.js';
20
21
  import { SubtreeGenerationPreparer, SubtreeTopologyChangedError, } from './subtree-generation-preparer.js';
21
22
  import { TopologyGenerationPreparer } from './topology-generation-preparer.js';
22
23
  import { RestartBoundaryPlanner } from './restart-boundary.js';
@@ -118,7 +119,7 @@ export class RootRuntime {
118
119
  restart = new RestartBoundaryPlanner().plan(this.#model.graph, inspected.graph, plan.changed);
119
120
  if (restart)
120
121
  return undefined;
121
- prepared = await new TopologyGenerationPreparer(this.#modules, this.#model, inspected.graph, inspected.configResolver, this.#environment, this.#installResources, this.#environmentLayers, this.#isolation).prepare(current);
122
+ prepared = await new TopologyGenerationPreparer(this.#modules, this.#model, inspected.graph, inspected.configResolver, inspected.primaryConfigDocument, this.#environment, this.#installResources, this.#environmentLayers, this.#isolation).prepare(current);
122
123
  }
123
124
  else if (plan.subtrees.length === 0 && plan.slots.length > 0 && this.#model) {
124
125
  prepared = await new SlotGenerationPreparer(this.#modules, this.#model)
@@ -168,12 +169,36 @@ export class RootRuntime {
168
169
  async #inspectProject() {
169
170
  const resolver = await NodePackageResolver.create(this.#projectRoot);
170
171
  const graph = await new ProjectGraphService(resolver).inspect(this.#projectRoot);
171
- const configResolver = this.#configResolver
172
- ? this.#configResolver
173
- : await new ConfigComposer()
174
- .compose(graph, this.#configDocument)
175
- .then((composed) => this.#configViewResolver(composed.views));
176
- return { graph, configResolver };
172
+ await this.#refreshConfigDocument();
173
+ if (this.#configResolver) {
174
+ return {
175
+ graph,
176
+ configResolver: this.#configResolver,
177
+ primaryConfigDocument: Object.freeze({}),
178
+ };
179
+ }
180
+ const composed = await new ConfigComposer().compose(graph, this.#configDocument);
181
+ return {
182
+ graph,
183
+ configResolver: this.#configViewResolver(composed.views),
184
+ primaryConfigDocument: composed.document,
185
+ };
186
+ }
187
+ /**
188
+ * The config file itself is watched, so an external edit triggers a full
189
+ * reload. Re-read through the port before composing: without this the reload
190
+ * would rebuild the whole generation from the stale in-memory document read
191
+ * at start, the edit would silently not apply, and the next patchConfig
192
+ * would hit a revision conflict. On drift the file is authoritative.
193
+ */
194
+ async #refreshConfigDocument() {
195
+ if (!this.#configPort)
196
+ return;
197
+ const snapshot = await this.#configPort.read();
198
+ if (snapshot.revision === this.#configSnapshot?.revision)
199
+ return;
200
+ this.#configSnapshot = snapshot;
201
+ this.#configDocument = structuredClone(snapshot.document);
177
202
  }
178
203
  #configViewResolver(views) {
179
204
  const env = createEnvStore(rootPluginId(), this.#environment, this.#environmentLayers);
@@ -188,7 +213,10 @@ export class RootRuntime {
188
213
  if (!this.#configDocument) {
189
214
  throw new Error('Config patches require a document-backed RootRuntime config');
190
215
  }
191
- const currentDocument = this.#configDocument;
216
+ // Adopt any external edit before planning so the port's revision check
217
+ // cannot conflict and the patch applies on top of the on-disk document.
218
+ await this.#refreshConfigDocument();
219
+ const currentDocument = requireConfigDocument(this.#configDocument);
192
220
  let plan;
193
221
  let prepared;
194
222
  let documentTransaction;
@@ -200,33 +228,51 @@ export class RootRuntime {
200
228
  plan = planned;
201
229
  if (!planned.documentChanged)
202
230
  return undefined;
203
- if (this.#configPort) {
204
- const currentSnapshot = requireConfigDocumentSnapshot(this.#configSnapshot);
205
- // Port preparation must remain inert; validation and shadow setup can
206
- // still reject this candidate without touching the backing document.
207
- documentTransaction = await this.#configPort.prepare(currentSnapshot, patches);
208
- if (!isDeepStrictEqual(documentTransaction.document, planned.candidate)) {
209
- throw new ConfigDocumentDivergenceError();
231
+ try {
232
+ if (this.#configPort) {
233
+ const currentSnapshot = requireConfigDocumentSnapshot(this.#configSnapshot);
234
+ // Port preparation must remain inert; validation and shadow setup can
235
+ // still reject this candidate without touching the backing document.
236
+ documentTransaction = await this.#configPort.prepare(currentSnapshot, patches);
237
+ if (!isDeepStrictEqual(documentTransaction.document, planned.candidate)) {
238
+ throw new ConfigDocumentDivergenceError();
239
+ }
240
+ }
241
+ // Host-level sections (http/database/ai/...) never land in a Plugin
242
+ // view, so their patches plan zero roots. With a Root Resource
243
+ // installer the whole generation must still be rebuilt; only an
244
+ // installer-less runtime may take the commit-only shortcut.
245
+ if (!this.#installResources && planned.roots.length === 0) {
246
+ if (documentTransaction)
247
+ committedDocument = await documentTransaction.commit();
248
+ return undefined;
249
+ }
250
+ const inspected = {
251
+ graph,
252
+ configResolver: this.#configViewResolver(planned.views),
253
+ primaryConfigDocument: planned.document,
254
+ };
255
+ // Root resources may consume any Primary Config section. Reinstall them
256
+ // when present so a committed patch cannot leave Host services on the
257
+ // previous generation's document.
258
+ if (!this.#installResources && this.#model && !planned.roots.includes(rootPluginId())) {
259
+ prepared = await this.#prepareSubtrees(current, inspected, planned.roots);
210
260
  }
261
+ else {
262
+ prepared = await this.#prepareInspected(current, inspected);
263
+ }
264
+ return documentTransaction
265
+ ? withConfigDocumentHandoff(prepared.generation, documentTransaction, (committed) => { committedDocument = committed; })
266
+ : prepared.generation;
211
267
  }
212
- if (planned.roots.length === 0) {
268
+ catch (error) {
269
+ // A prepared document transaction is inert until handoff; roll it back
270
+ // before any shadow-phase failure escapes so the port never leaks a
271
+ // pending write.
213
272
  if (documentTransaction)
214
- committedDocument = await documentTransaction.commit();
215
- return undefined;
216
- }
217
- const inspected = {
218
- graph,
219
- configResolver: this.#configViewResolver(planned.views),
220
- };
221
- if (this.#model && !planned.roots.includes(rootPluginId())) {
222
- prepared = await this.#prepareSubtrees(current, inspected, planned.roots);
223
- }
224
- else {
225
- prepared = await this.#prepareInspected(current, inspected);
273
+ await documentTransaction.rollback().catch(() => undefined);
274
+ throw error;
226
275
  }
227
- return documentTransaction
228
- ? withConfigDocumentHandoff(prepared.generation, documentTransaction, (committed) => { committedDocument = committed; })
229
- : prepared.generation;
230
276
  });
231
277
  const completed = requireConfigPatchPlan(plan);
232
278
  if (prepared)
@@ -237,14 +283,14 @@ export class RootRuntime {
237
283
  return snapshot;
238
284
  }
239
285
  #prepareInspected(current, inspected) {
240
- const assembler = new GenerationAssembler(inspected.graph, this.#modules, inspected.configResolver, current.generation + 1, this.#environment, this.#installResources, this.#environmentLayers, this.#isolation);
286
+ const assembler = new GenerationAssembler(inspected.graph, this.#modules, inspected.configResolver, inspected.primaryConfigDocument, current.generation + 1, this.#environment, this.#installResources, this.#environmentLayers, this.#isolation);
241
287
  return assembler.prepare();
242
288
  }
243
289
  async #prepareSubtrees(current, inspected, roots) {
244
290
  if (!this.#model)
245
291
  return this.#prepareInspected(current, inspected);
246
292
  try {
247
- return await new SubtreeGenerationPreparer(this.#modules, this.#model, inspected.graph, inspected.configResolver, this.#environment, this.#installResources, this.#environmentLayers, this.#isolation).prepare(current, roots);
293
+ return await new SubtreeGenerationPreparer(this.#modules, this.#model, inspected.graph, inspected.configResolver, inspected.primaryConfigDocument, this.#environment, this.#installResources, this.#environmentLayers, this.#isolation).prepare(current, roots);
248
294
  }
249
295
  catch (error) {
250
296
  if (!(error instanceof SubtreeTopologyChangedError))
@@ -278,6 +324,11 @@ function requireConfigDocumentSnapshot(snapshot) {
278
324
  throw new Error('ConfigDocumentPort has not been read');
279
325
  return snapshot;
280
326
  }
327
+ function requireConfigDocument(document) {
328
+ if (!document)
329
+ throw new Error('Config patches require a document-backed RootRuntime config');
330
+ return document;
331
+ }
281
332
  function cloneConfigPatches(patches) {
282
333
  return Object.freeze(patches.map((patch) => Object.freeze(patch.op === 'set'
283
334
  ? { ...patch, path: Object.freeze([...patch.path]), value: structuredClone(patch.value) }
@@ -292,6 +343,7 @@ class GenerationAssembler {
292
343
  graph;
293
344
  modules;
294
345
  configResolver;
346
+ primaryConfigDocument;
295
347
  generation;
296
348
  environment;
297
349
  installResources;
@@ -301,31 +353,35 @@ class GenerationAssembler {
301
353
  #catalog = new FeatureCatalog();
302
354
  #rootsByFeature = new Map();
303
355
  #featureIdsByPackageRoot = new Map();
304
- #projectionDisposers = [];
356
+ #projectionDisposers = new Map();
305
357
  #host;
306
358
  #plugins;
307
- constructor(graph, modules, configResolver, generation, environment, installResources, environmentLayers = {}, isolation) {
359
+ constructor(graph, modules, configResolver, primaryConfigDocument, generation, environment, installResources, environmentLayers = {}, isolation) {
308
360
  this.graph = graph;
309
361
  this.modules = modules;
310
362
  this.configResolver = configResolver;
363
+ this.primaryConfigDocument = primaryConfigDocument;
311
364
  this.generation = generation;
312
365
  this.environment = environment;
313
366
  this.installResources = installResources;
314
367
  this.environmentLayers = environmentLayers;
315
368
  this.isolation = isolation;
316
369
  this.#host = new NodeDiscoveryHost(modules);
317
- this.#plugins = new PluginScopeAssembler(modules, configResolver, environment, installResources, environmentLayers, undefined, isolation);
370
+ this.#plugins = new PluginScopeAssembler(modules, configResolver, environment, primaryConfigDocument, installResources, environmentLayers, undefined, isolation);
318
371
  }
319
372
  async prepare() {
320
373
  try {
321
374
  // Prepare is deliberately ordered: providers define discovery, setup
322
375
  // creates owner scopes, then definitions can be projected against both.
323
376
  await this.#loadProviders(this.graph.root);
377
+ this.#plugins.installSetupFeatureAliases(featureSetupAliases(this.#catalog.values()));
324
378
  await this.#plugins.setupTree(this.graph.root);
325
379
  await this.#discover();
326
380
  const projected = await new FeatureProjector(this.#catalog.values())
327
381
  .project(this.generation, this.#projectionState());
328
- this.#projectionDisposers.push(...projected.disposers);
382
+ for (const [feature, dispose] of projected.disposers) {
383
+ this.#projectionDisposers.set(feature, dispose);
384
+ }
329
385
  const state = projected.state;
330
386
  const snapshot = createSnapshotView(this.generation, state);
331
387
  const ownership = SourceOwnershipIndex.fromGeneration(this.graph, snapshot, this.#featureIdsByPackageRoot);
@@ -351,7 +407,7 @@ class GenerationAssembler {
351
407
  };
352
408
  }
353
409
  catch (error) {
354
- await disposePreparedParts(this.#plugins.createdScopeDisposers().map(([, dispose]) => dispose), this.#projectionDisposers, error);
410
+ await disposePreparedParts(this.#plugins.createdScopeDisposers().map(([, dispose]) => dispose), [...this.#projectionDisposers.values()], error);
355
411
  throw error;
356
412
  }
357
413
  }
@@ -378,12 +434,13 @@ class GenerationAssembler {
378
434
  await this.#loadProviders(child);
379
435
  }
380
436
  async #discover() {
437
+ mergeSetupCapabilities(this.#capabilities, this.#plugins.setupCapabilities(), new Map(this.#catalog.values().map((provider) => [provider.id, provider])), this.#rootsByFeature);
381
438
  const discovery = new FeatureDiscovery(this.#host);
382
439
  for (const provider of this.#catalog.values()) {
383
440
  const roots = this.#rootsByFeature.get(provider.id) ?? [];
384
441
  const slots = await discovery.discover(provider, roots);
385
442
  for (const slot of slots)
386
- this.#capabilities.set(slot.id, slot);
443
+ addCapabilitySlot(this.#capabilities, slot);
387
444
  }
388
445
  }
389
446
  #projectionState() {
@@ -0,0 +1,5 @@
1
+ import { type CapabilityId, type CapabilitySlot, type FeatureId, type SetupCapabilityRegistration } from '@zhin.js/plugin-runtime';
2
+ import type { CapabilityRoot, FeatureProvider } from '@zhin.js/feature-kit';
3
+ export declare function mergeSetupCapabilities(capabilities: Map<CapabilityId, CapabilitySlot>, registrations: readonly Readonly<SetupCapabilityRegistration>[], providers: ReadonlyMap<FeatureId, FeatureProvider>, rootsByFeature: ReadonlyMap<FeatureId, readonly CapabilityRoot[]>): void;
4
+ export declare function addCapabilitySlot(capabilities: Map<CapabilityId, CapabilitySlot>, slot: Readonly<CapabilitySlot>): void;
5
+ export declare function featureSetupAliases(providers: Iterable<FeatureProvider>): ReadonlyMap<string, FeatureId>;
@@ -0,0 +1,44 @@
1
+ import { createCapabilitySlot, } from '@zhin.js/plugin-runtime';
2
+ export function mergeSetupCapabilities(capabilities, registrations, providers, rootsByFeature) {
3
+ for (const registration of registrations) {
4
+ const provider = providers.get(registration.feature);
5
+ const mounted = rootsByFeature.get(registration.feature)?.some((root) => root.owner === registration.owner);
6
+ if (!provider || !mounted) {
7
+ throw new Error(`Feature ${registration.feature} is not mounted for Plugin ${registration.owner}`);
8
+ }
9
+ const definition = provider.authoring.validate(registration.definition, {
10
+ owner: registration.owner,
11
+ feature: registration.feature,
12
+ localName: registration.localName,
13
+ source: registration.source,
14
+ });
15
+ addCapabilitySlot(capabilities, createCapabilitySlot({
16
+ owner: registration.owner,
17
+ feature: registration.feature,
18
+ localName: registration.localName,
19
+ source: registration.source,
20
+ definition,
21
+ origin: 'setup',
22
+ }));
23
+ }
24
+ }
25
+ export function addCapabilitySlot(capabilities, slot) {
26
+ if (capabilities.has(slot.id)) {
27
+ throw new Error(`Duplicate Capability Slot: ${slot.id}`);
28
+ }
29
+ capabilities.set(slot.id, slot);
30
+ }
31
+ export function featureSetupAliases(providers) {
32
+ const aliases = new Map();
33
+ for (const provider of providers) {
34
+ const method = provider.authoring.setupMethod;
35
+ if (!method)
36
+ continue;
37
+ const existing = aliases.get(method);
38
+ if (existing && existing !== provider.id) {
39
+ throw new Error(`Duplicate Feature setup method ${method}: ${existing}, ${provider.id}`);
40
+ }
41
+ aliases.set(method, provider.id);
42
+ }
43
+ return aliases;
44
+ }