@zhin.js/runtime 1.0.2 → 1.0.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.
@@ -149,6 +149,14 @@ async function readOwnSchema(node) {
149
149
  if (schema.type !== undefined && schema.type !== 'object') {
150
150
  throw new TypeError(`${file} root schema type must be object`);
151
151
  }
152
+ // A compositional root (anyOf/oneOf/allOf/$ref without properties) would
153
+ // validate but pickOwnFields only copies top-level properties, silently
154
+ // projecting an empty ConfigView. Reject it explicitly instead.
155
+ if (schema.properties === undefined
156
+ && (schema.anyOf !== undefined || schema.oneOf !== undefined
157
+ || schema.allOf !== undefined || schema.$ref !== undefined)) {
158
+ throw new TypeError(`${file} root schema must declare properties; composition keywords (anyOf/oneOf/allOf/$ref) are not supported`);
159
+ }
152
160
  return Object.freeze({
153
161
  type: 'object',
154
162
  additionalProperties: false,
@@ -47,31 +47,67 @@ function applyPatch(document, patch) {
47
47
  function setValue(document, path, value) {
48
48
  let target = document;
49
49
  for (const [index, segment] of path.slice(0, -1).entries()) {
50
- const existing = target[segment];
50
+ const existing = readChild(target, segment, path.slice(0, index + 1));
51
51
  if (existing === undefined) {
52
+ // Missing intermediates are always created as records; arrays only ever
53
+ // come from the existing document (numeric segments index into them).
52
54
  const created = {};
53
55
  target[segment] = created;
54
56
  target = created;
55
57
  }
56
58
  else {
57
- target = requireRecord(existing, path.slice(0, index + 1));
59
+ target = requireContainer(existing, path.slice(0, index + 1));
58
60
  }
59
61
  }
60
- target[lastSegment(path)] = value;
62
+ writeChild(target, lastSegment(path), value, path);
61
63
  }
62
64
  function removeValue(document, path) {
63
65
  let target = document;
64
66
  for (const [index, segment] of path.slice(0, -1).entries()) {
65
- const existing = target[segment];
67
+ const existing = readChild(target, segment, path.slice(0, index + 1));
66
68
  if (existing === undefined)
67
69
  return;
68
- target = requireRecord(existing, path.slice(0, index + 1));
70
+ target = requireContainer(existing, path.slice(0, index + 1));
69
71
  }
70
- delete target[lastSegment(path)];
72
+ const segment = lastSegment(path);
73
+ if (Array.isArray(target)) {
74
+ target.splice(arrayIndex(segment, target, path), 1);
75
+ return;
76
+ }
77
+ delete target[segment];
71
78
  }
72
79
  function cloneDocument(value) {
73
80
  return requireRecord(structuredClone(value), []);
74
81
  }
82
+ function readChild(container, segment, path) {
83
+ if (Array.isArray(container))
84
+ return container[arrayIndex(segment, container, path)];
85
+ return container[segment];
86
+ }
87
+ function writeChild(container, segment, value, path) {
88
+ if (Array.isArray(container)) {
89
+ container[arrayIndex(segment, container, path)] = value;
90
+ return;
91
+ }
92
+ container[segment] = value;
93
+ }
94
+ /** Resolves a numeric path segment against an array, rejecting non-numeric segments and out-of-bounds indexes. */
95
+ function arrayIndex(segment, container, path) {
96
+ if (!/^(0|[1-9]\d*)$/.test(segment)) {
97
+ throw new ConfigPatchPathError(`Config path ${pointer(path)} requires an array index, got "${segment}"`);
98
+ }
99
+ const index = Number(segment);
100
+ if (index >= container.length) {
101
+ throw new ConfigPatchPathError(`Config path ${pointer(path)} is out of bounds (array length ${container.length})`);
102
+ }
103
+ return index;
104
+ }
105
+ function requireContainer(value, path) {
106
+ if (!value || typeof value !== 'object') {
107
+ throw new ConfigPatchPathError(`Config path ${pointer(path)} is not an object`);
108
+ }
109
+ return value;
110
+ }
75
111
  function requireRecord(value, path) {
76
112
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
77
113
  throw new ConfigPatchPathError(`Config path ${pointer(path)} is not an object`);
@@ -1,15 +1,15 @@
1
- import { type Dispose, type GenerationHandoff, type SnapshotState } from '@zhin.js/plugin-runtime';
1
+ import { type Dispose, type FeatureId, type GenerationHandoff, type SnapshotState } from '@zhin.js/plugin-runtime';
2
2
  import type { FeatureProvider } from '@zhin.js/feature-kit';
3
3
  export type ProjectionState = Omit<SnapshotState, 'projections'>;
4
4
  export interface ProjectedFeatures {
5
5
  readonly state: SnapshotState;
6
- readonly disposers: readonly Dispose[];
6
+ readonly disposers: ReadonlyMap<FeatureId, Dispose>;
7
7
  readonly handoff?: GenerationHandoff;
8
8
  }
9
- /** Builds every Feature projection against one coherent candidate snapshot. */
9
+ /** Builds selected Feature projections against one coherent candidate snapshot. */
10
10
  export declare class FeatureProjector {
11
11
  private readonly providers;
12
12
  constructor(providers: Iterable<FeatureProvider>);
13
- project(generation: number, base: ProjectionState): Promise<ProjectedFeatures>;
13
+ project(generation: number, base: ProjectionState, retained?: ReadonlyMap<FeatureId, unknown>): Promise<ProjectedFeatures>;
14
14
  }
15
15
  export declare function composeGenerationHandoffs(...handoffs: readonly (GenerationHandoff | undefined)[]): GenerationHandoff | undefined;
@@ -1,18 +1,19 @@
1
1
  import { DisposeStack, GenerationHandoffStack, createSnapshotView, } from '@zhin.js/plugin-runtime';
2
- /** Builds every Feature projection against one coherent candidate snapshot. */
2
+ /** Builds selected Feature projections against one coherent candidate snapshot. */
3
3
  export class FeatureProjector {
4
4
  providers;
5
5
  constructor(providers) {
6
6
  this.providers = providers;
7
7
  }
8
- async project(generation, base) {
9
- const projections = new Map();
10
- const disposers = [];
8
+ async project(generation, base, retained = new Map()) {
9
+ // Slot HMR seeds this map with the committed projections. Only providers
10
+ // passed to this projector replace their entry; every other Feature keeps
11
+ // its live instance and therefore keeps its external resources running.
12
+ const projections = new Map(retained);
13
+ const disposers = new Map();
11
14
  const handoffs = new GenerationHandoffStack();
12
15
  const state = { ...base, projections };
13
16
  try {
14
- // A projection may capture its snapshot. Rebuilding every projection
15
- // prevents unchanged Features from retaining an older generation.
16
17
  for (const provider of this.providers) {
17
18
  const slots = [...base.capabilities.values()].filter((slot) => slot.feature === provider.id);
18
19
  const projection = await provider.runtime.project(slots, {
@@ -20,18 +21,18 @@ export class FeatureProjector {
20
21
  });
21
22
  projections.set(provider.id, projection.value);
22
23
  if (projection.dispose)
23
- disposers.push(projection.dispose);
24
+ disposers.set(provider.id, projection.dispose);
24
25
  if (projection.handoff)
25
26
  handoffs.add(projection.handoff);
26
27
  }
27
28
  return {
28
29
  state,
29
- disposers: Object.freeze(disposers),
30
+ disposers,
30
31
  handoff: handoffs.seal(),
31
32
  };
32
33
  }
33
34
  catch (error) {
34
- await rollback(disposers, error);
35
+ await rollback(disposers.values(), error);
35
36
  throw error;
36
37
  }
37
38
  }
@@ -1,9 +1,9 @@
1
- import { type Dispose, type PluginId } from '@zhin.js/plugin-runtime';
1
+ import { type Dispose, type FeatureId, type PluginId } from '@zhin.js/plugin-runtime';
2
2
  export declare class GenerationAssets {
3
3
  #private;
4
4
  private constructor();
5
- static create(scopeDisposers: Iterable<readonly [PluginId, Dispose]>, projectionDisposers: Iterable<Dispose>): GenerationAssets;
6
- fork(projectionDisposers: Iterable<Dispose>): GenerationAssets;
7
- replaceScopes(scopeOrder: readonly PluginId[], replacements: ReadonlyMap<PluginId, Dispose>, projectionDisposers: Iterable<Dispose>): GenerationAssets;
5
+ static create(scopeDisposers: Iterable<readonly [PluginId, Dispose]>, projectionDisposers: ReadonlyMap<FeatureId, Dispose>): GenerationAssets;
6
+ replaceProjections(features: Iterable<FeatureId>, projectionDisposers: ReadonlyMap<FeatureId, Dispose>): GenerationAssets;
7
+ replaceScopes(scopeOrder: readonly PluginId[], replacements: ReadonlyMap<PluginId, Dispose>, projectionDisposers: ReadonlyMap<FeatureId, Dispose>): GenerationAssets;
8
8
  dispose(): Promise<void>;
9
9
  }
@@ -1,9 +1,13 @@
1
1
  import { DisposeStack, SharedLifetime, } from '@zhin.js/plugin-runtime';
2
2
  export class GenerationAssets {
3
3
  #scopeLifetimes;
4
+ // Projection ownership is per Feature, so one command transaction can
5
+ // retire CommandIndex without releasing AdapterIndex and its endpoints.
6
+ #projectionLifetimes;
4
7
  #disposers = new DisposeStack();
5
- constructor(scopeOrder, scopeLifetimes, projectionDisposers) {
8
+ constructor(scopeOrder, scopeLifetimes, projectionLifetimes) {
6
9
  this.#scopeLifetimes = scopeLifetimes;
10
+ this.#projectionLifetimes = projectionLifetimes;
7
11
  assertScopeOrder(scopeOrder, scopeLifetimes);
8
12
  // Scope order is parent-first. DisposeStack unwinds projections first,
9
13
  // then Plugin leases children-first, so no child observes a closed parent.
@@ -14,8 +18,10 @@ export class GenerationAssets {
14
18
  const lease = lifetime.acquire();
15
19
  this.#disposers.add(() => lease.release());
16
20
  }
17
- for (const dispose of projectionDisposers)
18
- this.#disposers.add(dispose);
21
+ for (const lifetime of projectionLifetimes.values()) {
22
+ const lease = lifetime.acquire();
23
+ this.#disposers.add(() => lease.release());
24
+ }
19
25
  this.#disposers.seal();
20
26
  }
21
27
  static create(scopeDisposers, projectionDisposers) {
@@ -25,10 +31,22 @@ export class GenerationAssets {
25
31
  throw new Error(`Duplicate Plugin Scope: ${owner}`);
26
32
  lifetimes.set(owner, new SharedLifetime(dispose));
27
33
  }
28
- return new GenerationAssets([...lifetimes.keys()], lifetimes, projectionDisposers);
34
+ const projectionLifetimes = new Map([...projectionDisposers].map(([feature, dispose]) => [
35
+ feature,
36
+ new SharedLifetime(dispose),
37
+ ]));
38
+ return new GenerationAssets([...lifetimes.keys()], lifetimes, projectionLifetimes);
29
39
  }
30
- fork(projectionDisposers) {
31
- return new GenerationAssets([...this.#scopeLifetimes.keys()], this.#scopeLifetimes, projectionDisposers);
40
+ replaceProjections(features, projectionDisposers) {
41
+ const lifetimes = new Map(this.#projectionLifetimes);
42
+ for (const feature of features) {
43
+ const dispose = projectionDisposers.get(feature);
44
+ if (dispose)
45
+ lifetimes.set(feature, new SharedLifetime(dispose));
46
+ else
47
+ lifetimes.delete(feature);
48
+ }
49
+ return new GenerationAssets([...this.#scopeLifetimes.keys()], this.#scopeLifetimes, lifetimes);
32
50
  }
33
51
  replaceScopes(scopeOrder, replacements, projectionDisposers) {
34
52
  const owners = new Set(scopeOrder);
@@ -46,7 +64,11 @@ export class GenerationAssets {
46
64
  throw new Error(`Cannot retain unknown Plugin Scope: ${owner}`);
47
65
  lifetimes.set(owner, lifetime);
48
66
  }
49
- return new GenerationAssets(scopeOrder, lifetimes, projectionDisposers);
67
+ const projectionLifetimes = new Map([...projectionDisposers].map(([feature, dispose]) => [
68
+ feature,
69
+ new SharedLifetime(dispose),
70
+ ]));
71
+ return new GenerationAssets(scopeOrder, lifetimes, projectionLifetimes);
50
72
  }
51
73
  dispose() {
52
74
  return this.#disposers.dispose();
@@ -12,6 +12,8 @@ export interface HmrCoordinatorOptions {
12
12
  onRestartRequired(plan: ProcessInvalidationPlan): void | Promise<void>;
13
13
  onError(error: unknown): void | Promise<void>;
14
14
  onPlan?(plan: InvalidationPlan): void | Promise<void>;
15
+ /** Fires once after a generation transaction commits successfully. */
16
+ onReload?(plan: GenerationInvalidationPlan, durationMs: number): void | Promise<void>;
15
17
  }
16
18
  export declare class HmrCoordinator {
17
19
  #private;
@@ -71,12 +71,17 @@ export class HmrCoordinator {
71
71
  }
72
72
  if (plan.kind === 'none')
73
73
  continue;
74
+ const startedAt = performance.now();
74
75
  for (const source of plan.changed) {
75
76
  await this.options.modules.invalidate?.(source);
76
77
  }
77
78
  const restart = await this.options.runtime.reload(plan);
78
79
  if (restart)
79
80
  await this.options.onRestartRequired(restart);
81
+ else {
82
+ const durationMs = Number((performance.now() - startedAt).toFixed(1));
83
+ await this.options.onReload?.(plan, durationMs);
84
+ }
80
85
  }
81
86
  this.#resolveWaiters();
82
87
  }
package/lib/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from './module-runtime.js';
12
12
  export * from './native-development-runtime.js';
13
13
  export * from './node-discovery-host.js';
14
14
  export * from './package-resolver.js';
15
+ export * from './platform-features.js';
15
16
  export * from './primary-config.js';
16
17
  export * from './project-graph.js';
17
18
  export * from './process-restart.js';
package/lib/index.js CHANGED
@@ -12,6 +12,7 @@ export * from './module-runtime.js';
12
12
  export * from './native-development-runtime.js';
13
13
  export * from './node-discovery-host.js';
14
14
  export * from './package-resolver.js';
15
+ export * from './platform-features.js';
15
16
  export * from './primary-config.js';
16
17
  export * from './project-graph.js';
17
18
  export * from './process-restart.js';
package/lib/manifest.d.ts CHANGED
@@ -12,6 +12,12 @@ export interface ZhinPluginManifest {
12
12
  readonly entry: string;
13
13
  readonly engine?: string;
14
14
  readonly runtime?: 'trusted' | 'isolated';
15
+ /**
16
+ * When true (default), Root plugins receive official Stable Features
17
+ * (`@zhin.js/adapter|command|component`) from the platform/CLI even if they
18
+ * are omitted from `features` / project dependencies. Set false to opt out.
19
+ */
20
+ readonly platformFeatures?: boolean;
15
21
  readonly features: readonly PackageReference[];
16
22
  readonly plugins: readonly ChildPluginReference[];
17
23
  }
package/lib/manifest.js CHANGED
@@ -68,12 +68,14 @@ function parseZhinManifest(value, source, issues) {
68
68
  if (runtime !== undefined && runtime !== 'trusted' && runtime !== 'isolated') {
69
69
  issues.push(`${source}.runtime must be "trusted" or "isolated"`);
70
70
  }
71
+ const platformFeatures = optionalBoolean(record.platformFeatures, `${source}.platformFeatures`, issues);
71
72
  return Object.freeze({
72
73
  protocol: 1,
73
74
  type,
74
75
  entry,
75
76
  engine,
76
77
  runtime: runtime,
78
+ platformFeatures,
77
79
  features: parseReferences(record.features, `${source}.features`, issues, false),
78
80
  plugins: parseReferences(record.plugins, `${source}.plugins`, issues, true),
79
81
  });
@@ -94,10 +96,16 @@ function parseReferences(value, source, issues, child) {
94
96
  : undefined;
95
97
  if (!packageName || (child && !instanceKey))
96
98
  return [];
97
- if (!isPackageName(packageName)) {
99
+ // 支持两种来源:npm 包名(package 依赖)或 ./ 相对路径(monorepo 本地插件目录)
100
+ const isLocalPath = packageName.startsWith('./');
101
+ if (!isLocalPath && !isPackageName(packageName)) {
98
102
  issues.push(`${itemSource}.package is not a valid package name`);
99
103
  return [];
100
104
  }
105
+ if (isLocalPath && packageName.split('/').includes('..')) {
106
+ issues.push(`${itemSource}.package must not escape the package root`);
107
+ return [];
108
+ }
101
109
  if (instanceKey && !/^[a-z0-9][a-z0-9-]*$/.test(instanceKey)) {
102
110
  issues.push(`${itemSource}.instanceKey is invalid`);
103
111
  return [];
@@ -56,7 +56,12 @@ export class NativeDevelopmentModuleRuntime {
56
56
  return extname(normalized) !== '.md';
57
57
  if (root === 'tools' || root === 'mcp')
58
58
  return parts.length !== capability + 2;
59
- return false;
59
+ if (isCapabilityEntry(parts.slice(capability + 1)))
60
+ return false;
61
+ // Support files inside capability directories (e.g. commands/_utils.ts)
62
+ // are not discovery entries: reloading the entry URL only bumps that
63
+ // entry's zhin-generation, so the importer closure keeps the old code.
64
+ return ['.js', '.json', '.ts'].includes(extname(normalized));
60
65
  }
61
66
  watch(listener) {
62
67
  this.#assertOpen();
@@ -124,7 +129,7 @@ class PortableSourceWatcher {
124
129
  if (!name)
125
130
  return;
126
131
  const source = resolve(this.root, name.toString());
127
- if (isWatchedSource(source))
132
+ if (isWatchedSource(source) && !isIgnoredSource(this.root, source))
128
133
  this.listener(source);
129
134
  });
130
135
  this.#watcher.on('error', () => this.#startPolling());
@@ -181,6 +186,25 @@ function isWatchedSource(source) {
181
186
  const name = source.slice(source.lastIndexOf(sep) + 1);
182
187
  return watchedExtensions.has(extname(source)) || name.startsWith('.env');
183
188
  }
189
+ /** Mirrors sourceSnapshot: any path segment matching an ignored directory opts out. */
190
+ function isIgnoredSource(root, source) {
191
+ return relative(root, source).split(sep).some((segment) => ignoredDirectories.has(segment));
192
+ }
193
+ /**
194
+ * Discovery entries follow the typeScriptModules convention
195
+ * (feature-kit typescript-convention.ts): lowercase segment directories and
196
+ * lowercase .ts/.tsx module names only.
197
+ */
198
+ function isCapabilityEntry(segments) {
199
+ const file = segments[segments.length - 1] ?? '';
200
+ return segments.slice(0, -1).every(isCapabilitySegment) && isCapabilityModule(file);
201
+ }
202
+ function isCapabilitySegment(value) {
203
+ return /^[a-z0-9][a-z0-9-]*$/u.test(value);
204
+ }
205
+ function isCapabilityModule(value) {
206
+ return /^[a-z0-9][a-z0-9-]*\.tsx?$/u.test(value);
207
+ }
184
208
  function isExecutableSource(source) {
185
209
  return ['.cjs', '.js', '.mjs', '.ts', '.tsx'].includes(extname(source));
186
210
  }
@@ -3,11 +3,13 @@ export interface ResolvedPackage {
3
3
  readonly name: string;
4
4
  readonly root: string;
5
5
  readonly packageJson: PackageJson;
6
- readonly source: 'workspace' | 'node_modules';
6
+ readonly source: 'workspace' | 'node_modules' | 'local';
7
7
  }
8
8
  export interface PackageResolver {
9
9
  root(root: string): Promise<ResolvedPackage>;
10
10
  resolve(request: string, from: ResolvedPackage): Promise<ResolvedPackage>;
11
+ /** Load a package from an absolute package root (skips dependency declaration checks). */
12
+ loadPackage?(packageRoot: string): Promise<ResolvedPackage>;
11
13
  workspacePackages(): readonly ResolvedPackage[];
12
14
  }
13
15
  export declare class PackageResolutionError extends Error {
@@ -18,6 +20,7 @@ export declare class NodePackageResolver implements PackageResolver {
18
20
  #private;
19
21
  static create(projectRoot: string): Promise<NodePackageResolver>;
20
22
  root(root: string): Promise<ResolvedPackage>;
23
+ loadPackage(packageRoot: string): Promise<ResolvedPackage>;
21
24
  workspacePackages(): readonly ResolvedPackage[];
22
25
  resolve(request: string, from: ResolvedPackage): Promise<ResolvedPackage>;
23
26
  }
@@ -1,5 +1,5 @@
1
1
  import { access, readFile, readdir, realpath } from 'node:fs/promises';
2
- import { dirname, join, resolve } from 'node:path';
2
+ import { dirname, extname, join, relative, resolve } from 'node:path';
3
3
  import { parsePackageJson } from './manifest.js';
4
4
  export class PackageResolutionError extends Error {
5
5
  request;
@@ -23,9 +23,8 @@ export class NodePackageResolver {
23
23
  if (await exists(join(packageRoot, 'pnpm-workspace.yaml'))) {
24
24
  throw new PackageResolutionError(`Nested workspace is not allowed: ${packageRoot}`);
25
25
  }
26
- if (directory === 'plugins' && await exists(join(packageRoot, 'plugins'))) {
27
- throw new PackageResolutionError(`Nested local Plugin directory is not allowed: ${join(packageRoot, 'plugins')}`);
28
- }
26
+ // 注意:plugins/<x>/plugins/ 嵌套目录不再报错——顶层扫描只注册一层
27
+ // workspace 包;更深的本地插件经 manifest './' 相对路径显式引用。
29
28
  if (!await exists(join(packageRoot, 'package.json')))
30
29
  continue;
31
30
  const pkg = await resolver.#readPackage(packageRoot, 'workspace');
@@ -40,21 +39,31 @@ export class NodePackageResolver {
40
39
  async root(root) {
41
40
  return this.#readPackage(root, 'workspace');
42
41
  }
42
+ async loadPackage(packageRoot) {
43
+ return this.#readPackage(packageRoot, 'node_modules');
44
+ }
43
45
  workspacePackages() {
44
46
  return [...this.#workspaceByName.values()];
45
47
  }
46
48
  async resolve(request, from) {
49
+ // 解析管线(按序短路,任一步命中即返回):
50
+ //
51
+ // 1. 本地路径('./' 开头):monorepo 本地插件目录,相对声明包根解析。
52
+ // 目录即声明——跳过依赖声明检查,也不入 node_modules。
53
+ if (request.startsWith('./'))
54
+ return this.#resolveLocal(request, from);
55
+ // 2. 声明检查(仅包名引用),按声明位置分级:
56
+ // - dependencies / optionalDependencies:硬要求,引用必须能在此声明;
57
+ // - peerDependencies:宽松声明——允许未安装,解析失败由引用方 optional 容错;
58
+ // - 三者皆无:拒绝(zhin manifest 引用必须可追溯到包依赖声明)。
47
59
  const specification = declaredDependency(request, from.packageJson);
60
+ // 3. workspace byName:packages/* + plugins/* 顶层扫描结果优先命中。
61
+ // 'workspace:*' 未命中时继续走 node_modules——examples 等在扫描面之外,
62
+ // 但 pnpm 仍会把 workspace:* 链接进 node_modules。
48
63
  const workspace = this.#workspaceByName.get(request);
49
- if (specification.startsWith('workspace:')) {
50
- if (workspace)
51
- return workspace;
52
- // Examples live outside the monorepo packages/plugins scan roots; pnpm still
53
- // links workspace:* into node_modules, so fall through before failing.
54
- }
55
- else if (workspace) {
64
+ if (workspace)
56
65
  return workspace;
57
- }
66
+ // 4. node_modules 上溯:从声明包根逐级向上查找。
58
67
  let current = from.root;
59
68
  while (true) {
60
69
  const packageRoot = join(current, 'node_modules', ...request.split('/'));
@@ -70,6 +79,13 @@ export class NodePackageResolver {
70
79
  ? `Workspace dependency ${request} declared by ${from.name} is missing`
71
80
  : `Cannot resolve ${request} from ${from.name}`, request);
72
81
  }
82
+ async #resolveLocal(request, from) {
83
+ const packageRoot = join(from.root, request);
84
+ if (await exists(join(packageRoot, 'package.json'))) {
85
+ return this.#readPackage(packageRoot, 'local');
86
+ }
87
+ throw new PackageResolutionError(`Cannot resolve ${request} from ${from.name}`, request);
88
+ }
73
89
  async #readPackage(packageRoot, source) {
74
90
  const normalized = await realpath(resolve(packageRoot));
75
91
  const cached = this.#cache.get(normalized);
@@ -77,7 +93,17 @@ export class NodePackageResolver {
77
93
  return cached;
78
94
  const file = join(normalized, 'package.json');
79
95
  const content = await readFile(file, 'utf8');
80
- const packageJson = parsePackageJson(JSON.parse(content), file);
96
+ const parsedPackageJson = parsePackageJson(JSON.parse(content), file);
97
+ const entry = await resolveRuntimeEntry(normalized, parsedPackageJson.zhin.entry, source);
98
+ const packageJson = entry === parsedPackageJson.zhin.entry
99
+ ? parsedPackageJson
100
+ : Object.freeze({
101
+ ...parsedPackageJson,
102
+ zhin: Object.freeze({
103
+ ...parsedPackageJson.zhin,
104
+ entry,
105
+ }),
106
+ });
81
107
  const result = Object.freeze({
82
108
  name: packageJson.name,
83
109
  root: normalized,
@@ -88,9 +114,31 @@ export class NodePackageResolver {
88
114
  return result;
89
115
  }
90
116
  }
117
+ async function resolveRuntimeEntry(packageRoot, declaredEntry, source) {
118
+ const declared = resolve(packageRoot, declaredEntry);
119
+ const extension = extname(declared);
120
+ const base = declared.slice(0, -extension.length);
121
+ const sourceCandidates = extension === '.js'
122
+ ? [`${base}.ts`, `${base}.tsx`]
123
+ : [declared];
124
+ const javascriptCandidates = extension === '.ts' || extension === '.tsx'
125
+ ? [`${base}.js`, join(packageRoot, 'lib', `${base.slice(packageRoot.length + 1)}.js`)]
126
+ : [declared];
127
+ const candidates = source === 'node_modules'
128
+ ? [...javascriptCandidates, ...sourceCandidates]
129
+ : [...sourceCandidates, ...javascriptCandidates];
130
+ for (const candidate of new Set(candidates)) {
131
+ if (await exists(candidate))
132
+ return `./${relative(packageRoot, candidate).replaceAll('\\', '/')}`;
133
+ }
134
+ return declaredEntry;
135
+ }
91
136
  function declaredDependency(request, pkg) {
92
137
  const specification = (pkg.dependencies?.[request]
93
- ?? pkg.optionalDependencies?.[request]);
138
+ ?? pkg.optionalDependencies?.[request]
139
+ // peerDependencies 是宽松声明:允许未安装。未安装时 resolve 抛
140
+ // PackageResolutionError,由引用方的 optional 标记统一容错。
141
+ ?? pkg.peerDependencies?.[request]);
94
142
  if (!specification) {
95
143
  throw new PackageResolutionError(`${pkg.name} references ${request} in zhin manifest but does not declare it as a package dependency`, request);
96
144
  }
@@ -0,0 +1,25 @@
1
+ import type { ChildPluginReference, PackageReference } from './manifest.js';
2
+ /**
3
+ * Package whose `zhin.features` define the Stable Feature composition
4
+ * (adapter / command / component / middleware).
5
+ *
6
+ * Root inherits these when it depends on `@zhin.js/core` directly, or on the
7
+ * `zhin.js` facade (which depends on `@zhin.js/core`).
8
+ */
9
+ export declare const PLATFORM_FEATURE_CARRIER = "@zhin.js/core";
10
+ /**
11
+ * Install facade that may declare default child plugins and re-exports core
12
+ * authoring surfaces.
13
+ */
14
+ export declare const PLATFORM_FEATURE_FACADE = "zhin.js";
15
+ /**
16
+ * Merge user-declared features with inherited platform features.
17
+ * User references for the same package win (pin / override).
18
+ */
19
+ export declare function mergeFeatureReferences(declared: readonly PackageReference[], inherited: readonly PackageReference[]): readonly PackageReference[];
20
+ /**
21
+ * Merge user-declared child plugins with facade defaults.
22
+ * User references for the same instanceKey win (pin / override).
23
+ */
24
+ export declare function mergeChildPluginReferences(declared: readonly ChildPluginReference[], inherited: readonly ChildPluginReference[]): readonly ChildPluginReference[];
25
+ export declare function declaredPackageDependency(dependencies: Readonly<Record<string, string>> | undefined, optionalDependencies: Readonly<Record<string, string>> | undefined, name: string): string | undefined;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Package whose `zhin.features` define the Stable Feature composition
3
+ * (adapter / command / component / middleware).
4
+ *
5
+ * Root inherits these when it depends on `@zhin.js/core` directly, or on the
6
+ * `zhin.js` facade (which depends on `@zhin.js/core`).
7
+ */
8
+ export const PLATFORM_FEATURE_CARRIER = '@zhin.js/core';
9
+ /**
10
+ * Install facade that may declare default child plugins and re-exports core
11
+ * authoring surfaces.
12
+ */
13
+ export const PLATFORM_FEATURE_FACADE = 'zhin.js';
14
+ /**
15
+ * Merge user-declared features with inherited platform features.
16
+ * User references for the same package win (pin / override).
17
+ */
18
+ export function mergeFeatureReferences(declared, inherited) {
19
+ const seen = new Set(declared.map((item) => item.package));
20
+ const extras = inherited.filter((item) => !seen.has(item.package));
21
+ if (extras.length === 0)
22
+ return declared;
23
+ return Object.freeze([...declared, ...extras]);
24
+ }
25
+ /**
26
+ * Merge user-declared child plugins with facade defaults.
27
+ * User references for the same instanceKey win (pin / override).
28
+ */
29
+ export function mergeChildPluginReferences(declared, inherited) {
30
+ const seen = new Set(declared.map((item) => item.instanceKey));
31
+ const extras = inherited.filter((item) => !seen.has(item.instanceKey));
32
+ if (extras.length === 0)
33
+ return declared;
34
+ return Object.freeze([...declared, ...extras]);
35
+ }
36
+ export function declaredPackageDependency(dependencies, optionalDependencies, name) {
37
+ return dependencies?.[name] ?? optionalDependencies?.[name];
38
+ }
@@ -1,4 +1,4 @@
1
- import { DisposeStack, Scope, type Dispose, type GenerationHandoff, type GenerationHandoffRegistry, type PluginId, type PluginNodeSnapshot, type TokenId } from '@zhin.js/plugin-runtime';
1
+ import { DisposeStack, Scope, type Dispose, type FeatureId, type GenerationHandoff, type GenerationHandoffRegistry, 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';
@@ -35,8 +35,10 @@ export declare class PluginScopeAssembler {
35
35
  readonly resources: Map<PluginId, ReadonlyMap<TokenId, unknown>>;
36
36
  constructor(modules: ModuleRuntime, configResolver: PluginConfigResolver, environment: RuntimeEnvironment, primaryConfigDocument: RuntimeConfigDocument, installResources?: RootResourceInstaller | undefined, environmentLayers?: EnvironmentLayers, seed?: PluginAssemblySeed, isolation?: IsolatedPluginRuntimePort | undefined);
37
37
  removeSubtrees(roots: readonly PluginId[]): void;
38
+ installSetupFeatureAliases(aliases: ReadonlyMap<string, FeatureId>): void;
38
39
  setupTree(node: PluginGraphNode): Promise<void>;
39
40
  synchronizeTree(node: PluginGraphNode): void;
40
41
  createdScopeDisposers(): readonly (readonly [PluginId, Dispose])[];
41
42
  generationHandoff(): GenerationHandoff | undefined;
43
+ setupCapabilities(): readonly Readonly<SetupCapabilityRegistration>[];
42
44
  }