@zhin.js/runtime 1.1.8 → 1.1.10

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,6 +1,7 @@
1
1
  import Ajv2020 from 'ajv/dist/2020.js';
2
2
  import hostConfigSchema from './host-config-schema.json' with { type: 'json' };
3
3
  import { readPluginConfigSchema } from './plugin-config-schema.js';
4
+ import { readApplicationPluginDeclaration } from './application-plugin-declaration.js';
4
5
  function deepFreeze(value) {
5
6
  if (value && typeof value === 'object' && !Object.isFrozen(value)) {
6
7
  for (const nested of Object.values(value))
@@ -12,6 +13,9 @@ function deepFreeze(value) {
12
13
  export const HOST_CONFIG_SCHEMA = deepFreeze(hostConfigSchema);
13
14
  /** Host configuration keys consumed by Runtime composition and Host installers. */
14
15
  export const HOST_CONFIG_KEYS = Object.freeze(Object.keys(HOST_CONFIG_SCHEMA.properties));
16
+ const ROOT_FEATURE_CONFIG_KEYS = ['menuKeyword', 'menuStyle'];
17
+ /** Command routing keys the framework injects into every Plugin config schema. */
18
+ const COMMAND_CONFIG_KEYS = ['commandNamespace', 'disabledCommands'];
15
19
  export class ConfigSchemaCollisionError extends Error {
16
20
  plugin;
17
21
  instanceKey;
@@ -37,22 +41,36 @@ export class ConfigValidationError extends Error {
37
41
  export class ConfigComposer {
38
42
  inactivePluginInstanceKeys;
39
43
  modules;
40
- constructor(inactivePluginInstanceKeys = [], modules) {
44
+ validatedSchemas;
45
+ constructor(inactivePluginInstanceKeys = [], modules,
46
+ /** Previously validated generation declarations; never import retired owners just to compare inputs. */
47
+ validatedSchemas) {
41
48
  this.inactivePluginInstanceKeys = inactivePluginInstanceKeys;
42
49
  this.modules = modules;
50
+ this.validatedSchemas = validatedSchemas;
43
51
  }
44
52
  async compose(graph, input = {},
45
53
  /** Source config file name, annotated on ConfigValidationError. */
46
54
  source) {
55
+ const declaration = readApplicationPluginDeclaration(input, source);
56
+ if (this.inactivePluginInstanceKeys.length > 0) {
57
+ throw new TypeError('Legacy disabled Plugin state must move to disable_plugins; see docs/migrations/plugin-instance-declarations.md');
58
+ }
59
+ const enabledKeys = new Set(declaration.instances.filter(instance => instance.enabled).map(instance => instance.instanceKey));
60
+ if (enabledKeys.size !== graph.root.children.length
61
+ || graph.root.children.some(child => !enabledKeys.has(child.instanceKey))) {
62
+ throw new TypeError('Compose configuration against its candidate application graph before loading Plugin schemas');
63
+ }
47
64
  // Effective schemas include child namespaces for whole-tree validation;
48
65
  // ownSchemas retain each package's private configuration contract.
49
66
  const ownSchemas = new Map();
50
- const rootOwn = await readOwnSchema(graph.root, this.modules);
67
+ const rootOwn = await readOwnSchema(graph.root, this.modules, this.validatedSchemas);
51
68
  ownSchemas.set(graph.root.id, rootOwn);
52
69
  const childSchemas = await Promise.all(graph.root.children.map(async (child) => [
53
70
  child.instanceKey,
54
- await composeNode(child, ownSchemas, this.modules),
71
+ await composeNode(child, ownSchemas, this.modules, this.validatedSchemas),
55
72
  ]));
73
+ const childSchemaByKey = new Map(childSchemas);
56
74
  // Host-level keys (`http`, `database`, `ai`, `mcp`, `a2a`, `speech`,
57
75
  // `htmlRenderer`, `assistant`, `log_level`) are consumed by CLI Root installers /
58
76
  // start-command, not Plugin ConfigViews.
@@ -63,15 +81,15 @@ export class ConfigComposer {
63
81
  properties: {
64
82
  ...HOST_CONFIG_SCHEMA.properties,
65
83
  plugin: withDefault(rootOwn),
66
- plugins: {
67
- type: 'object',
68
- additionalProperties: false,
69
- default: {},
70
- properties: Object.fromEntries([
71
- ...this.inactivePluginInstanceKeys.map((key) => [key, {}]),
72
- ...childSchemas.map(([key, schema]) => [key, withDefault(schema)]),
73
- ]),
74
- },
84
+ configVersion: { const: 2 },
85
+ plugins: { type: 'object', additionalProperties: { type: 'string' }, default: {} },
86
+ disable_plugins: { type: 'array', items: { type: 'string' }, default: [] },
87
+ ...Object.fromEntries(declaration.instances.map(instance => {
88
+ const schema = childSchemaByKey.get(instance.instanceKey);
89
+ if (instance.enabled && !schema)
90
+ throw new TypeError(`Candidate graph is missing enabled Plugin ${instance.instanceKey}`);
91
+ return [instance.instanceKey, instance.enabled && schema ? withDefault(schema) : { type: 'object' }];
92
+ })),
75
93
  },
76
94
  });
77
95
  const document = structuredClone(input);
@@ -83,8 +101,17 @@ export class ConfigComposer {
83
101
  const views = new Map();
84
102
  // A Plugin never receives its effective node object because that object
85
103
  // also contains descendants. Re-pick fields from the owner's own schema.
86
- views.set(graph.root.id, pickOwnFields(document.plugin, requireOwnSchema(ownSchemas, graph.root.id)));
87
- collectChildViews(graph.root, document.plugins, ownSchemas, views);
104
+ views.set(graph.root.id,
105
+ // Built-in menu settings belong to the application, but Command Feature
106
+ // consumes the root's generation view. Project only these reserved fields;
107
+ // Host credentials and other instances never enter a Plugin ConfigView.
108
+ Object.freeze({
109
+ ...pickOwnFields(document.plugin, requireOwnSchema(ownSchemas, graph.root.id)),
110
+ ...Object.fromEntries(ROOT_FEATURE_CONFIG_KEYS
111
+ .filter(key => Object.hasOwn(document, key))
112
+ .map(key => [key, deepFreeze(structuredClone(document[key]))])),
113
+ }));
114
+ collectChildViews(graph.root, document, ownSchemas, views);
88
115
  return Object.freeze({
89
116
  effectiveSchema,
90
117
  document: Object.freeze(document),
@@ -145,8 +172,8 @@ function injectFrameworkRoleSchema(properties) {
145
172
  }),
146
173
  });
147
174
  }
148
- async function composeNode(node, ownSchemas, modules) {
149
- const own = await readOwnSchema(node, modules);
175
+ async function composeNode(node, ownSchemas, modules, validatedSchemas) {
176
+ const own = await readOwnSchema(node, modules, validatedSchemas);
150
177
  ownSchemas.set(node.id, own);
151
178
  const properties = { ...schemaProperties(own) };
152
179
  // Adapter plugins (schemas declaring array-typed `endpoints`) get
@@ -159,11 +186,17 @@ async function composeNode(node, ownSchemas, modules) {
159
186
  if (Object.hasOwn(properties, child.instanceKey)) {
160
187
  throw new ConfigSchemaCollisionError(node.id, child.instanceKey);
161
188
  }
162
- properties[child.instanceKey] = withDefault(await composeNode(child, ownSchemas, modules));
189
+ properties[child.instanceKey] = withDefault(await composeNode(child, ownSchemas, modules, validatedSchemas));
163
190
  }
164
191
  return Object.freeze({ ...own, properties });
165
192
  }
166
- async function readOwnSchema(node, modules) {
193
+ async function readOwnSchema(node, modules, validatedSchemas) {
194
+ if (validatedSchemas) {
195
+ const schema = validatedSchemas.get(node.id);
196
+ if (!schema)
197
+ throw new TypeError(`Missing committed configuration schema for ${node.id}`);
198
+ return schema;
199
+ }
167
200
  const file = `${node.package.name} config`;
168
201
  const value = await readPluginConfigSchema(node.package, modules)
169
202
  ?? { type: 'object', additionalProperties: false, properties: {} };
@@ -183,8 +216,9 @@ async function readOwnSchema(node, modules) {
183
216
  throw new TypeError(`${file} root schema must declare properties; composition keywords (anyOf/oneOf/allOf/$ref) are not supported`);
184
217
  }
185
218
  const properties = schemaProperties(schema);
186
- if (Object.hasOwn(properties, 'commandNamespace')) {
187
- throw new ConfigSchemaCollisionError(node.id, 'commandNamespace');
219
+ for (const key of [...COMMAND_CONFIG_KEYS, ...(!node.parent ? ROOT_FEATURE_CONFIG_KEYS : [])]) {
220
+ if (Object.hasOwn(properties, key))
221
+ throw new ConfigSchemaCollisionError(node.id, key);
188
222
  }
189
223
  return deepFreeze({
190
224
  $id: `urn:zhin:plugin-config:${encodeURIComponent(String(node.id))}`,
@@ -199,6 +233,13 @@ async function readOwnSchema(node, modules) {
199
233
  description: 'Explicit command namespace prepended after the Endpoint commandPrefix.',
200
234
  'x-descriptionZh': '显式指令命名空间;在 Endpoint commandPrefix 之后参与路由。',
201
235
  },
236
+ disabledCommands: {
237
+ type: 'array',
238
+ uniqueItems: true,
239
+ items: { type: 'string', minLength: 1, pattern: '^\\S+(?: \\S+)*$' },
240
+ description: 'Commands of this Plugin instance to leave out of routing, by local path without commandNamespace or parameters (e.g. "fanyi", "netease login"). A parent path also disables its subcommands.',
241
+ 'x-descriptionZh': '不注册的指令:写插件内的指令路径(不含 commandNamespace 和参数,如 fanyi、netease login),写上级路径会连同子命令一起禁用。',
242
+ },
202
243
  ...properties,
203
244
  },
204
245
  });
@@ -9,6 +9,7 @@ export interface ConfigPatchPlan extends ComposedConfig {
9
9
  /** Validates a candidate document before deriving its minimal replacement forest. */
10
10
  export declare class ConfigPatchPlanner {
11
11
  private readonly composer;
12
- constructor(composer?: ConfigComposer);
13
- plan(graph: ProjectGraph, current: RuntimeConfigDocument, patches: readonly ConfigPatch[]): Promise<ConfigPatchPlan>;
12
+ private readonly previousComposer;
13
+ constructor(composer?: ConfigComposer, previousComposer?: ConfigComposer);
14
+ plan(graph: ProjectGraph, current: RuntimeConfigDocument, patches: readonly ConfigPatch[], candidateGraph?: ProjectGraph, previousDocument?: RuntimeConfigDocument): Promise<ConfigPatchPlan>;
14
15
  }
@@ -4,14 +4,16 @@ import { ConfigComposer } from './config-composer.js';
4
4
  /** Validates a candidate document before deriving its minimal replacement forest. */
5
5
  export class ConfigPatchPlanner {
6
6
  composer;
7
- constructor(composer = new ConfigComposer()) {
7
+ previousComposer;
8
+ constructor(composer = new ConfigComposer(), previousComposer = composer) {
8
9
  this.composer = composer;
10
+ this.previousComposer = previousComposer;
9
11
  }
10
- async plan(graph, current, patches) {
11
- const previous = await this.composer.compose(graph, current);
12
+ async plan(graph, current, patches, candidateGraph = graph, previousDocument = current) {
13
+ const previous = await this.previousComposer.compose(graph, previousDocument);
12
14
  const candidate = applyConfigPatches(current, patches);
13
- const next = await this.composer.compose(graph, candidate);
14
- const changed = indexGraph(graph)
15
+ const next = await this.composer.compose(candidateGraph, candidate);
16
+ const changed = indexGraph(candidateGraph)
15
17
  .filter((node) => !isDeepStrictEqual(previous.views.get(node.id), next.views.get(node.id)))
16
18
  .map((node) => node.id);
17
19
  return Object.freeze({
@@ -0,0 +1,12 @@
1
+ interface EditorTempPattern {
2
+ readonly editor: string;
3
+ readonly test: (name: string) => boolean;
4
+ }
5
+ /** Ordered, documented list; each entry is covered by a unit test. */
6
+ export declare const EDITOR_TEMP_FILE_PATTERNS: readonly EditorTempPattern[];
7
+ /**
8
+ * Returns true when `source` (a path or a bare file name) is an editor temp or
9
+ * backup file. Only the basename is inspected.
10
+ */
11
+ export declare function isEditorTempFile(source: string): boolean;
12
+ export {};
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Editor temp, swap, lock, and backup files that must never trigger an HMR
3
+ * reload (#857 P2-11).
4
+ *
5
+ * Editors write these next to the real source while saving. Several of them
6
+ * keep the source extension (for example `.!97910!index.ts`), so an
7
+ * extension allow-list alone is not enough.
8
+ *
9
+ * Matching uses the basename only, so a directory such as `tmp/` or a file
10
+ * such as `tmp.ts` or `foo.tmp.ts` is still watched: those are ordinary
11
+ * sources a plugin may really import. Only the exact shapes below are skipped.
12
+ */
13
+ import { basename } from 'node:path';
14
+ /** Ordered, documented list; each entry is covered by a unit test. */
15
+ export const EDITOR_TEMP_FILE_PATTERNS = Object.freeze([
16
+ // `.!<digits>!<name>`: atomic-save temp file seen in practice (`.!97910!index.ts`).
17
+ { editor: 'atomic save (.!<pid>!name)', test: (name) => /^\.!\d+!/.test(name) },
18
+ // Vim swap files: `.index.ts.swp`, then `.swo`, `.swx` when the first one is taken.
19
+ { editor: 'vim swap (*.swp, *.swo, *.swx)', test: (name) => /\.sw[pox]$/.test(name) },
20
+ // Vim `writebackup` probe: Vim creates and deletes `4913` to test the directory.
21
+ { editor: 'vim probe (4913)', test: (name) => name === '4913' },
22
+ // Backup copies from Vim, Emacs, nano and others: `index.ts~`.
23
+ { editor: 'backup (*~)', test: (name) => name.endsWith('~') },
24
+ // Emacs lock files: `.#index.ts` (usually a dangling symlink).
25
+ { editor: 'emacs lock (.#*)', test: (name) => name.startsWith('.#') },
26
+ // Generic temp files: `index.ts.tmp`. `foo.tmp.ts` is a real source and still reloads.
27
+ { editor: 'temp (*.tmp)', test: (name) => name.endsWith('.tmp') },
28
+ // JetBrains "safe write": `index.ts___jb_tmp___` and `index.ts___jb_old___`.
29
+ {
30
+ editor: 'JetBrains safe write (*___jb_tmp___, *___jb_old___)',
31
+ test: (name) => name.endsWith('___jb_tmp___') || name.endsWith('___jb_old___'),
32
+ },
33
+ ]);
34
+ /**
35
+ * Returns true when `source` (a path or a bare file name) is an editor temp or
36
+ * backup file. Only the basename is inspected.
37
+ */
38
+ export function isEditorTempFile(source) {
39
+ // Accept both separators so Windows paths behave the same on every host.
40
+ const name = basename(source.replaceAll('\\', '/'));
41
+ if (!name)
42
+ return false;
43
+ return EDITOR_TEMP_FILE_PATTERNS.some((pattern) => pattern.test(name));
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { normalizeSourcePath } from './source-path.js';
2
2
  import { InvalidationPlanner, } from './invalidation-planner.js';
3
+ import { isEditorTempFile } from './editor-temp-file.js';
3
4
  export class HmrCoordinator {
4
5
  options;
5
6
  #pending = new Set();
@@ -24,6 +25,9 @@ export class HmrCoordinator {
24
25
  }
25
26
  this.#syncModuleState();
26
27
  this.#unwatch = this.options.modules.watch((source) => {
28
+ // Any ModuleRuntime watcher may report editor temp/backup files; they never reload (#857).
29
+ if (isEditorTempFile(source))
30
+ return;
27
31
  void this.enqueue(source).catch(() => undefined);
28
32
  });
29
33
  return () => this.stop();