@zhin.js/runtime 1.1.2 → 1.1.4

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,4 +1,4 @@
1
- import { DisposeStack, Scope, type Dispose, type FeatureId, type GenerationHandoff, type GenerationAdmissionGate, type GenerationHandoffRegistry, type PluginId, type PluginNodeSnapshot, type SetupCapabilityRegistration, type TokenId } from '@zhin.js/plugin-runtime';
1
+ import { DisposeStack, Scope, type Dispose, type FeatureId, type GenerationHandoff, type GenerationAdmissionGate, type GenerationHandoffRegistry, type RuntimeSnapshot, type PluginId, type PluginNodeSnapshot, type SetupCapabilityRegistration, type TokenId } 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 { ModuleRuntime } from './module-runtime.js';
@@ -10,6 +10,8 @@ export type PluginConfigResolver = (node: PluginGraphNode) => unknown;
10
10
  export interface RootResourceContext {
11
11
  /** Exact shadow generation being assembled; never inferred from a latest snapshot. */
12
12
  readonly generation: number;
13
+ /** Exact projected candidate, available during handoff activation, never the committed previous generation. */
14
+ readonly readCandidateSnapshot?: () => RuntimeSnapshot;
13
15
  readonly signal: AbortSignal;
14
16
  readonly resources: Scope;
15
17
  readonly lifecycle: DisposeStack;
@@ -51,6 +53,7 @@ export declare class PluginScopeAssembler {
51
53
  setupTree(node: PluginGraphNode, signal: AbortSignal): Promise<void>;
52
54
  synchronizeTree(node: PluginGraphNode): void;
53
55
  createdScopeDisposers(): readonly (readonly [PluginId, Dispose])[];
56
+ bindCandidateSnapshot(snapshot: RuntimeSnapshot): void;
54
57
  generationHandoff(): GenerationHandoff | undefined;
55
58
  setupCapabilities(): readonly Readonly<SetupCapabilityRegistration>[];
56
59
  /** Root resources are process-owned; every Plugin Scope receives an owner-bound facade. */
@@ -21,6 +21,7 @@ export class PluginScopeAssembler {
21
21
  #handoffs = new GenerationHandoffStack();
22
22
  #admission = createGenerationAdmissionGate();
23
23
  #envStores;
24
+ #candidateSnapshot;
24
25
  #setupFeatureAliases = new Map();
25
26
  constructor(modules, configResolver, environment, primaryConfigDocument, generation, installResources, environmentLayers = {}, seed, isolation) {
26
27
  this.modules = modules;
@@ -71,6 +72,11 @@ export class PluginScopeAssembler {
71
72
  scope.provide(primaryConfigToken, config);
72
73
  await this.installResources?.({
73
74
  generation: this.generation,
75
+ readCandidateSnapshot: () => {
76
+ if (!this.#candidateSnapshot)
77
+ throw new Error('Candidate snapshot is not projected yet');
78
+ return this.#candidateSnapshot;
79
+ },
74
80
  signal,
75
81
  resources: scope,
76
82
  lifecycle: scope.disposers,
@@ -206,6 +212,12 @@ export class PluginScopeAssembler {
206
212
  return [owner, () => scope.disposers.dispose()];
207
213
  });
208
214
  }
215
+ bindCandidateSnapshot(snapshot) {
216
+ if (snapshot.generation !== this.generation || this.#candidateSnapshot) {
217
+ throw new Error('Candidate snapshot does not match this Plugin assembly');
218
+ }
219
+ this.#candidateSnapshot = snapshot;
220
+ }
209
221
  generationHandoff() {
210
222
  return this.#handoffs.seal();
211
223
  }
@@ -1,3 +1,4 @@
1
+ import { normalizeSourcePath } from './source-path.js';
1
2
  import { resolve } from 'node:path';
2
3
  import { isDeepStrictEqual } from 'node:util';
3
4
  import { DisposeStack, GenerationHandoffStack, RootController, ConfigDocumentDivergenceError, createSnapshotView, rootPluginId, } from '@zhin.js/plugin-runtime';
@@ -41,7 +42,7 @@ export class RootRuntime {
41
42
  #controller;
42
43
  #disabledPluginInstanceKeys;
43
44
  constructor(options) {
44
- this.#projectRoot = resolve(options.projectRoot);
45
+ this.#projectRoot = normalizeSourcePath(options.projectRoot);
45
46
  this.#modules = options.modules;
46
47
  this.#environment = defineRuntimeEnvironment(options.environment);
47
48
  this.#environmentLayers = defineEnvironmentLayers(options.environmentVariables);
@@ -55,7 +56,7 @@ export class RootRuntime {
55
56
  this.#runtimeInputSources = new Set([
56
57
  ...(this.#configPort?.sources ?? []),
57
58
  ...(this.#environmentSource?.sources ?? []),
58
- ].map((source) => resolve(source)));
59
+ ].map((source) => normalizeSourcePath(source)));
59
60
  this.#installResources = options.installResources;
60
61
  this.#isolation = options.isolation;
61
62
  this.#disabledPluginInstanceKeys = Object.freeze([...(options.disabledPluginInstanceKeys ?? [])]);
@@ -105,7 +106,7 @@ export class RootRuntime {
105
106
  modules: this.#modules,
106
107
  ownership: () => this.sourceOwnership,
107
108
  runtime: {
108
- handlesSource: (source) => this.#runtimeInputSources.has(resolve(source)),
109
+ handlesSource: (source) => this.#runtimeInputSources.has(normalizeSourcePath(source)),
109
110
  reload: async (plan) => {
110
111
  const result = await this.#reloadPlan(plan);
111
112
  return isProcessPlan(result) ? result : undefined;
@@ -139,7 +140,7 @@ export class RootRuntime {
139
140
  ? Object.freeze({
140
141
  ...plan,
141
142
  subtrees: collapseInvalidationSubtrees([...plan.subtrees, ...inputs.roots]),
142
- fallbacks: Object.freeze(plan.fallbacks.filter((fallback) => !inputs.inputSources.has(resolve(fallback.source)))),
143
+ fallbacks: Object.freeze(plan.fallbacks.filter((fallback) => !inputs.inputSources.has(normalizeSourcePath(fallback.source)))),
143
144
  })
144
145
  : plan;
145
146
  let restart;
@@ -189,11 +190,11 @@ export class RootRuntime {
189
190
  return this.#controller.committed.state.model;
190
191
  }
191
192
  async #planRuntimeInputs(changed) {
192
- const inputSources = new Set(changed.map((source) => resolve(source)).filter((source) => this.#runtimeInputSources.has(source)));
193
+ const inputSources = new Set(changed.map((source) => normalizeSourcePath(source)).filter((source) => this.#runtimeInputSources.has(source)));
193
194
  if (inputSources.size === 0 || !this.#model)
194
195
  return undefined;
195
- const configChanged = this.#configPort?.sources?.some((source) => inputSources.has(resolve(source))) ?? false;
196
- const environmentChanged = this.#environmentSource?.sources.some((source) => inputSources.has(resolve(source))) ?? false;
196
+ const configChanged = this.#configPort?.sources?.some((source) => inputSources.has(normalizeSourcePath(source))) ?? false;
197
+ const environmentChanged = this.#environmentSource?.sources.some((source) => inputSources.has(normalizeSourcePath(source))) ?? false;
197
198
  const configSnapshot = configChanged ? await this.#configPort?.read() : undefined;
198
199
  const document = configSnapshot?.document ?? requireConfigDocument(this.#configDocument);
199
200
  const environmentLayers = environmentChanged && this.#environmentSource
@@ -210,7 +211,7 @@ export class RootRuntime {
210
211
  if (inputPlan.hostKeys.length > 0) {
211
212
  return Object.freeze({
212
213
  kind: 'process',
213
- changed: Object.freeze([...changed].map((source) => resolve(source))),
214
+ changed: Object.freeze([...changed].map((source) => normalizeSourcePath(source))),
214
215
  reasons: Object.freeze(inputPlan.hostKeys.map((key) => `Host configuration changed: ${key}`)),
215
216
  });
216
217
  }
@@ -263,7 +264,7 @@ export class RootRuntime {
263
264
  if (plan.subtrees.length === 0 || plan.subtrees.includes(rootPluginId()))
264
265
  return false;
265
266
  return plan.changed.every((source) => {
266
- if (this.#runtimeInputSources.has(resolve(source)))
267
+ if (this.#runtimeInputSources.has(normalizeSourcePath(source)))
267
268
  return true;
268
269
  const records = this.sourceOwnership.recordsFor(source);
269
270
  return records.length > 0 && records.every((record) => record.role === 'plugin' || record.role === 'schema');
@@ -519,6 +520,7 @@ class GenerationAssembler {
519
520
  }
520
521
  const state = projected.state;
521
522
  const snapshot = createSnapshotView(this.generation, state);
523
+ this.#plugins.bindCandidateSnapshot(snapshot);
522
524
  const ownership = SourceOwnershipIndex.fromGeneration(this.graph, snapshot, this.#featureIdsByPackageRoot);
523
525
  const assets = GenerationAssets.create(this.#plugins.createdScopeDisposers(), this.#projectionDisposers);
524
526
  return prepareRuntimeGeneration({
@@ -17,6 +17,7 @@ export declare class SourceOwnershipIndex {
17
17
  addPackageRoot(packageRoot: string, owner: PluginId): void;
18
18
  recordsFor(source: string): readonly SourceOwnershipRecord[];
19
19
  ownersForPath(source: string): readonly PluginId[];
20
+ moduleSources(): readonly string[];
20
21
  watchRoots(): readonly ModuleWatchRoot[];
21
22
  addWatchRoot(root: ModuleWatchRoot): void;
22
23
  }
@@ -1,3 +1,4 @@
1
+ import { normalizeSourcePath } from './source-path.js';
1
2
  import { isAbsolute, relative, resolve } from 'node:path';
2
3
  export class SourceOwnershipIndex {
3
4
  #records = new Map();
@@ -92,11 +93,17 @@ export class SourceOwnershipIndex {
92
93
  }
93
94
  return Object.freeze([...owners]);
94
95
  }
96
+ moduleSources() {
97
+ return Object.freeze([...this.#records.entries()]
98
+ .filter(([, records]) => records.some((record) => (record.role === 'plugin' || record.role === 'feature' || record.role === 'capability')))
99
+ .map(([source]) => source)
100
+ .sort());
101
+ }
95
102
  watchRoots() {
96
103
  return Object.freeze([...this.#watchRoots.values()]);
97
104
  }
98
105
  addWatchRoot(root) {
99
- const normalized = resolve(root.root);
106
+ const normalized = normalizeSourcePath(root.root);
100
107
  this.#watchRoots.set(normalized, Object.freeze({ ...root, root: normalized }));
101
108
  }
102
109
  }
@@ -106,7 +113,7 @@ function visitPlugin(node, visit) {
106
113
  visitPlugin(child, visit);
107
114
  }
108
115
  function normalizeSource(source) {
109
- return resolve(source);
116
+ return normalizeSourcePath(source);
110
117
  }
111
118
  function contains(packageRoot, source) {
112
119
  const path = relative(packageRoot, source);
@@ -0,0 +1,4 @@
1
+ /** Keep file events and scanned sources in the same physical path namespace.
2
+ * Resolve the nearest existing parent for unlink events and newly added files.
3
+ */
4
+ export declare function normalizeSourcePath(source: string): string;
@@ -0,0 +1,25 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ /** Keep file events and scanned sources in the same physical path namespace.
4
+ * Resolve the nearest existing parent for unlink events and newly added files.
5
+ */
6
+ export function normalizeSourcePath(source) {
7
+ const absolute = resolve(source);
8
+ let parent = absolute;
9
+ const suffix = [];
10
+ for (;;) {
11
+ try {
12
+ return join(realpathSync(parent), ...suffix);
13
+ }
14
+ catch (error) {
15
+ const code = error.code;
16
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
17
+ throw error;
18
+ const next = dirname(parent);
19
+ if (next === parent)
20
+ return absolute;
21
+ suffix.unshift(basename(parent));
22
+ parent = next;
23
+ }
24
+ }
25
+ }
@@ -82,6 +82,7 @@ export class SubtreeGenerationPreparer {
82
82
  projectionDisposers.set(feature, dispose);
83
83
  }
84
84
  const snapshot = createSnapshotView(current.generation + 1, projected.state);
85
+ plugins.bindCandidateSnapshot(snapshot);
85
86
  const ownership = SourceOwnershipIndex.fromGeneration(this.graph, snapshot, this.model.featureIdsByPackageRoot);
86
87
  const replacements = new Map(plugins.createdScopeDisposers());
87
88
  // Local prepare still commits a complete immutable generation. The
@@ -89,6 +89,7 @@ export class TopologyGenerationPreparer {
89
89
  projectionDisposers.set(feature, dispose);
90
90
  }
91
91
  const snapshot = createSnapshotView(current.generation + 1, projected.state);
92
+ plugins.bindCandidateSnapshot(snapshot);
92
93
  const ownership = SourceOwnershipIndex.fromGeneration(this.graph, snapshot, featureTopology.featureIdsByPackageRoot);
93
94
  const replacements = new Map(plugins.createdScopeDisposers());
94
95
  const assets = this.model.assets.replaceScopes(graphOrder(this.graph), replacements, projectionDisposers);
@@ -1,3 +1,8 @@
1
+ interface ResolveResult {
2
+ readonly url: string;
3
+ readonly shortCircuit?: boolean;
4
+ readonly format?: string;
5
+ }
1
6
  /**
2
7
  * ESM resolve hook: when a relative/absolute `.js` specifier fails and a
3
8
  * sibling `.ts` / `.tsx` exists, rewrite to that source file.
@@ -10,14 +15,7 @@ export declare function resolve(specifier: string, context: {
10
15
  parentURL?: string;
11
16
  }, nextResolve: (specifier: string, context: {
12
17
  parentURL?: string;
13
- }) => Promise<{
14
- url: string;
15
- shortCircuit?: boolean;
16
- format?: string;
17
- }>): Promise<{
18
- url: string;
19
- shortCircuit?: boolean;
20
- format?: string;
21
- }>;
18
+ }) => Promise<ResolveResult>): Promise<ResolveResult>;
22
19
  /** Register once for the current process (safe to call from CLI / RootHost). */
23
20
  export declare function ensureTypeScriptSpecifierRemap(): void;
21
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import { register } from 'node:module';
3
- import { sep } from 'node:path';
3
+ import { isAbsolute, sep } from 'node:path';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  /**
6
6
  * ESM resolve hook: when a relative/absolute `.js` specifier fails and a
@@ -11,8 +11,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
11
11
  * remap missing `.js` → `.ts` the way `tsc`/tsx do.
12
12
  */
13
13
  export async function resolve(specifier, context, nextResolve) {
14
+ let resolved;
14
15
  try {
15
- return await nextResolve(specifier, context);
16
+ resolved = await nextResolve(specifier, context);
16
17
  }
17
18
  catch (error) {
18
19
  if (!shouldTryTypeScript(specifier, error))
@@ -20,8 +21,9 @@ export async function resolve(specifier, context, nextResolve) {
20
21
  const candidate = await firstExistingTypeScript(specifier, context.parentURL);
21
22
  if (!candidate)
22
23
  throw error;
23
- return nextResolve(candidate, context);
24
+ resolved = await nextResolve(candidate, context);
24
25
  }
26
+ return inheritGenerationRevision(specifier, context.parentURL, resolved);
25
27
  }
26
28
  let registered = false;
27
29
  /** Register once for the current process (safe to call from CLI / RootHost). */
@@ -39,6 +41,29 @@ function shouldTryTypeScript(specifier, error) {
39
41
  return false;
40
42
  return /\.[cm]?js$/u.test(specifier) || specifier.endsWith('.jsx');
41
43
  }
44
+ function inheritGenerationRevision(specifier, parentURL, resolved) {
45
+ if (!parentURL || !isLocalSpecifier(specifier) || !resolved.url.startsWith('file:')) {
46
+ return resolved;
47
+ }
48
+ let revision;
49
+ try {
50
+ revision = new URL(parentURL).searchParams.get('zhin-generation');
51
+ }
52
+ catch {
53
+ return resolved;
54
+ }
55
+ if (revision === null)
56
+ return resolved;
57
+ const url = new URL(resolved.url);
58
+ url.searchParams.set('zhin-generation', revision);
59
+ return { ...resolved, url: url.href };
60
+ }
61
+ function isLocalSpecifier(specifier) {
62
+ return specifier.startsWith('.')
63
+ || specifier.startsWith('#')
64
+ || specifier.startsWith('file:')
65
+ || isAbsolute(specifier);
66
+ }
42
67
  async function firstExistingTypeScript(specifier, parentURL) {
43
68
  const resolvedJs = resolveAgainstParent(specifier, parentURL);
44
69
  if (!resolvedJs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/runtime",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
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",
@@ -17,25 +17,27 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "ajv": "8.18.0",
20
+ "es-module-lexer": "2.3.1",
21
+ "import-meta-resolve": "4.2.0",
20
22
  "semver": "7.8.5",
21
- "@zhin.js/feature-kit": "1.1.2",
22
- "@zhin.js/plugin-runtime": "1.1.11"
23
+ "@zhin.js/plugin-runtime": "1.1.11",
24
+ "@zhin.js/feature-kit": "1.1.2"
23
25
  },
24
26
  "devDependencies": {
25
27
  "@types/node": "^26.1.2",
26
28
  "tsx": "^4.23.1",
27
29
  "typescript": "^6.0.3",
28
- "@zhin.js/adapter": "1.1.15",
29
- "@zhin.js/agent-feature": "1.1.2",
30
- "@zhin.js/command": "1.1.2",
31
- "@zhin.js/component": "1.1.2",
32
- "@zhin.js/layout": "1.1.2",
33
- "@zhin.js/mcp-feature": "1.1.2",
34
- "@zhin.js/middleware": "1.1.2",
35
- "@zhin.js/page": "1.1.2",
30
+ "@zhin.js/adapter": "1.1.16",
31
+ "@zhin.js/agent-feature": "1.1.3",
32
+ "@zhin.js/command": "1.1.4",
33
+ "@zhin.js/component": "1.1.3",
34
+ "@zhin.js/mcp-feature": "1.1.3",
35
+ "@zhin.js/layout": "1.1.3",
36
+ "@zhin.js/page": "1.1.3",
37
+ "@zhin.js/middleware": "1.1.3",
36
38
  "@zhin.js/prompt-section": "1.1.2",
37
- "@zhin.js/skill": "1.1.2",
38
- "@zhin.js/tool": "1.1.2"
39
+ "@zhin.js/skill": "1.1.3",
40
+ "@zhin.js/tool": "1.1.3"
39
41
  },
40
42
  "repository": {
41
43
  "type": "git",