@zhin.js/runtime 1.0.0

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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/lib/compatibility.d.ts +10 -0
  4. package/lib/compatibility.js +45 -0
  5. package/lib/config-composer.d.ts +27 -0
  6. package/lib/config-composer.js +212 -0
  7. package/lib/config-document.d.ts +19 -0
  8. package/lib/config-document.js +6 -0
  9. package/lib/config-patch-planner.d.ts +25 -0
  10. package/lib/config-patch-planner.js +126 -0
  11. package/lib/environment-store.d.ts +56 -0
  12. package/lib/environment-store.js +300 -0
  13. package/lib/environment.d.ts +8 -0
  14. package/lib/environment.js +13 -0
  15. package/lib/feature-projector.d.ts +15 -0
  16. package/lib/feature-projector.js +57 -0
  17. package/lib/generation-assets.d.ts +9 -0
  18. package/lib/generation-assets.js +67 -0
  19. package/lib/hmr-coordinator.d.ts +23 -0
  20. package/lib/hmr-coordinator.js +110 -0
  21. package/lib/index.d.ts +21 -0
  22. package/lib/index.js +21 -0
  23. package/lib/invalidation-planner.d.ts +29 -0
  24. package/lib/invalidation-planner.js +106 -0
  25. package/lib/isolation.d.ts +28 -0
  26. package/lib/isolation.js +1 -0
  27. package/lib/manifest.d.ts +45 -0
  28. package/lib/manifest.js +195 -0
  29. package/lib/module-runtime.d.ts +17 -0
  30. package/lib/module-runtime.js +7 -0
  31. package/lib/native-development-runtime.d.ts +22 -0
  32. package/lib/native-development-runtime.js +190 -0
  33. package/lib/node-discovery-host.d.ts +10 -0
  34. package/lib/node-discovery-host.js +37 -0
  35. package/lib/package-resolver.d.ts +23 -0
  36. package/lib/package-resolver.js +121 -0
  37. package/lib/plugin-scope-assembler.d.ts +38 -0
  38. package/lib/plugin-scope-assembler.js +167 -0
  39. package/lib/process-restart.d.ts +15 -0
  40. package/lib/process-restart.js +29 -0
  41. package/lib/project-graph.d.ts +30 -0
  42. package/lib/project-graph.js +129 -0
  43. package/lib/restart-boundary.d.ts +6 -0
  44. package/lib/restart-boundary.js +68 -0
  45. package/lib/root-runtime.d.ts +37 -0
  46. package/lib/root-runtime.js +432 -0
  47. package/lib/runtime-generation.d.ts +18 -0
  48. package/lib/runtime-generation.js +1 -0
  49. package/lib/slot-generation-preparer.d.ts +9 -0
  50. package/lib/slot-generation-preparer.js +76 -0
  51. package/lib/source-ownership.d.ts +19 -0
  52. package/lib/source-ownership.js +105 -0
  53. package/lib/subtree-generation-preparer.d.ts +24 -0
  54. package/lib/subtree-generation-preparer.js +152 -0
  55. package/lib/topology-generation-preparer.d.ts +22 -0
  56. package/lib/topology-generation-preparer.js +249 -0
  57. package/lib/topology-transaction.d.ts +26 -0
  58. package/lib/topology-transaction.js +142 -0
  59. package/lib/typescript-specifier-remap.d.ts +23 -0
  60. package/lib/typescript-specifier-remap.js +92 -0
  61. package/package.json +57 -0
@@ -0,0 +1,300 @@
1
+ import { createToken } from '@zhin.js/plugin-runtime';
2
+ export const envStoreToken = createToken('zhin.env', 'Owner-scoped environment variables');
3
+ export class EnvironmentVariableMissingError extends Error {
4
+ owner;
5
+ key;
6
+ constructor(owner, key) {
7
+ super(`Missing environment variable ${key} for Plugin ${owner}`);
8
+ this.owner = owner;
9
+ this.key = key;
10
+ this.name = 'EnvironmentVariableMissingError';
11
+ }
12
+ }
13
+ export class EnvSchemaParseError extends Error {
14
+ owner;
15
+ constructor(owner, message) {
16
+ super(`Invalid environment for Plugin ${owner}: ${message}`);
17
+ this.owner = owner;
18
+ this.name = 'EnvSchemaParseError';
19
+ }
20
+ }
21
+ export function defineEnvSchema(schema) {
22
+ const secretKeys = Object.freeze([...(schema.secretKeys ?? [])]);
23
+ for (const key of secretKeys)
24
+ assertEnvironmentKey(key);
25
+ return Object.freeze({
26
+ secretKeys,
27
+ parse: (source) => schema.parse(source),
28
+ });
29
+ }
30
+ export function defineEnvironmentLayers(layers = {}) {
31
+ const environments = Object.fromEntries(Object.entries(layers.environments ?? {}).map(([name, source]) => {
32
+ if (!/^[a-z0-9][a-z0-9-]*$/u.test(name)) {
33
+ throw new TypeError(`Invalid environment overlay name: ${name}`);
34
+ }
35
+ return [name, copySource(source, `environment ${name}`)];
36
+ }));
37
+ const plugins = Object.fromEntries(Object.entries(layers.plugins ?? {}).map(([owner, source]) => {
38
+ if (!/^root(?:\/[a-z0-9][a-z0-9-]*)*$/u.test(owner)) {
39
+ throw new TypeError(`Invalid Plugin environment overlay owner: ${owner}`);
40
+ }
41
+ return [owner, copySource(source, `Plugin ${owner}`)];
42
+ }));
43
+ return Object.freeze({
44
+ base: copySource(layers.base ?? {}, 'base environment'),
45
+ environments: Object.freeze(environments),
46
+ plugins: Object.freeze(plugins),
47
+ });
48
+ }
49
+ export function createEnvStore(owner, environment, layers = {}) {
50
+ return new EnvStoreFactory(environment, layers).create(owner);
51
+ }
52
+ /** Normalizes layers once, then derives immutable stores for each Plugin owner. */
53
+ export class EnvStoreFactory {
54
+ #environment;
55
+ #layers;
56
+ constructor(environment, layers = {}) {
57
+ this.#environment = environment;
58
+ this.#layers = defineEnvironmentLayers(layers);
59
+ }
60
+ create(owner) {
61
+ const source = {};
62
+ applyLayer(source, this.#layers.base);
63
+ applyLayer(source, this.#layers.environments?.[this.#environment.name]);
64
+ for (const ancestor of pluginAncestors(owner)) {
65
+ applyLayer(source, this.#layers.plugins?.[ancestor]);
66
+ }
67
+ return new OwnerEnvStore(owner, this.#environment, Object.freeze(source));
68
+ }
69
+ }
70
+ class OwnerEnvStore {
71
+ owner;
72
+ environment;
73
+ #source;
74
+ constructor(owner, environment, source) {
75
+ this.owner = owner;
76
+ this.environment = environment;
77
+ this.#source = source;
78
+ }
79
+ has(key) {
80
+ assertEnvironmentKey(key);
81
+ return Object.hasOwn(this.#source, key);
82
+ }
83
+ get(key) {
84
+ assertEnvironmentKey(key);
85
+ return this.#source[key];
86
+ }
87
+ require(key) {
88
+ const value = this.get(key);
89
+ if (value === undefined)
90
+ throw new EnvironmentVariableMissingError(this.owner, key);
91
+ return value;
92
+ }
93
+ parse(schema) {
94
+ const secretKeys = schema.secretKeys ?? [];
95
+ for (const key of secretKeys)
96
+ assertEnvironmentKey(key);
97
+ try {
98
+ return freezeValue(schema.parse(this.#source));
99
+ }
100
+ catch (error) {
101
+ // Do not retain the original cause: validation libraries commonly embed
102
+ // source values in it, which would bypass the redacted public message.
103
+ const message = error instanceof Error ? error.message : String(error);
104
+ throw new EnvSchemaParseError(this.owner, redactString(message, secretValues(this.#source, secretKeys)));
105
+ }
106
+ }
107
+ expand(value) {
108
+ return expandValue(value, (key) => this.get(key), (key) => {
109
+ throw new EnvironmentVariableMissingError(this.owner, key);
110
+ });
111
+ }
112
+ expandMissingAsEmpty(value) {
113
+ return expandValue(value, (key) => this.get(key), () => '');
114
+ }
115
+ redact(value, secretKeys) {
116
+ for (const key of secretKeys)
117
+ assertEnvironmentKey(key);
118
+ return redactValue(value, secretValues(this.#source, secretKeys));
119
+ }
120
+ }
121
+ function copySource(source, label) {
122
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
123
+ throw new TypeError(`${label} must be an object`);
124
+ }
125
+ const result = {};
126
+ for (const [key, value] of Object.entries(source)) {
127
+ assertEnvironmentKey(key);
128
+ if (value !== undefined && typeof value !== 'string') {
129
+ throw new TypeError(`${label}.${key} must be a string or undefined`);
130
+ }
131
+ result[key] = value;
132
+ }
133
+ return Object.freeze(result);
134
+ }
135
+ function applyLayer(target, layer) {
136
+ for (const [key, value] of Object.entries(layer ?? {})) {
137
+ if (value === undefined)
138
+ delete target[key];
139
+ else
140
+ target[key] = value;
141
+ }
142
+ }
143
+ function pluginAncestors(owner) {
144
+ const segments = owner.split('/');
145
+ return Object.freeze(segments.map((_, index) => segments.slice(0, index + 1).join('/')));
146
+ }
147
+ function assertEnvironmentKey(key) {
148
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) {
149
+ throw new TypeError(`Invalid environment variable name: ${key}`);
150
+ }
151
+ }
152
+ /**
153
+ * Standalone deep expansion over an arbitrary config value (e.g. a raw `ai`
154
+ * config document before EnvStore scoping exists). Supports `${VAR}` and
155
+ * `${VAR:-default}` / `${VAR:=default}` (default applies when the variable is
156
+ * unset or empty). Missing plain references resolve via `onMissing`
157
+ * (default: `""`, matching `EnvStore.expandMissingAsEmpty`).
158
+ */
159
+ export function expandEnvironmentValue(value, lookup, onMissing = () => '') {
160
+ return expandValue(value, lookup, onMissing);
161
+ }
162
+ function expandValue(value, lookup, onMissing, seen = new WeakSet()) {
163
+ if (typeof value === 'string') {
164
+ return expandString(value, lookup, onMissing);
165
+ }
166
+ if (!value || typeof value !== 'object')
167
+ return value;
168
+ if (seen.has(value))
169
+ throw new TypeError('Environment expansion input must be acyclic');
170
+ seen.add(value);
171
+ if (Array.isArray(value)) {
172
+ const result = Object.freeze(value.map((item) => expandValue(item, lookup, onMissing, seen)));
173
+ seen.delete(value);
174
+ return result;
175
+ }
176
+ if (!isPlainRecord(value)) {
177
+ seen.delete(value);
178
+ return value;
179
+ }
180
+ const result = Object.freeze(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expandValue(item, lookup, onMissing, seen)])));
181
+ seen.delete(value);
182
+ return result;
183
+ }
184
+ function isEnvKeyStart(ch) {
185
+ return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_';
186
+ }
187
+ function isEnvKeyChar(ch) {
188
+ return isEnvKeyStart(ch) || (ch >= '0' && ch <= '9');
189
+ }
190
+ /**
191
+ * 线性扫描展开 `${VAR}` 与 `${VAR:-default}` / `${VAR:=default}`。
192
+ * 语义与正则 `/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::[-=]([^}]*))?\}/gu` 完全一致
193
+ * (解析失败时从 `${` 之后重新找下一个候选,等价于正则引擎逐位后移),
194
+ * 但全程无回溯(js/polynomial-redos)。
195
+ */
196
+ function expandString(input, lookup, onMissing) {
197
+ let out = '';
198
+ let i = 0;
199
+ while (i < input.length) {
200
+ const start = input.indexOf('${', i);
201
+ if (start < 0)
202
+ break;
203
+ let j = start + 2;
204
+ if (j >= input.length || !isEnvKeyStart(input[j])) {
205
+ out += input.slice(i, start + 1);
206
+ i = start + 1;
207
+ continue;
208
+ }
209
+ j += 1;
210
+ while (j < input.length && isEnvKeyChar(input[j]))
211
+ j += 1;
212
+ const key = input.slice(start + 2, j);
213
+ let fallback;
214
+ if (input[j] === ':' && (input[j + 1] === '-' || input[j + 1] === '=')) {
215
+ const close = input.indexOf('}', j + 2);
216
+ if (close < 0) {
217
+ out += input.slice(i, start + 1);
218
+ i = start + 1;
219
+ continue;
220
+ }
221
+ fallback = input.slice(j + 2, close);
222
+ j = close;
223
+ }
224
+ if (input[j] !== '}') {
225
+ out += input.slice(i, start + 1);
226
+ i = start + 1;
227
+ continue;
228
+ }
229
+ out += input.slice(i, start);
230
+ const resolved = lookup(key);
231
+ if (fallback !== undefined) {
232
+ out += resolved !== undefined && resolved !== '' ? resolved : fallback;
233
+ }
234
+ else if (resolved !== undefined) {
235
+ out += resolved;
236
+ }
237
+ else {
238
+ out += onMissing(key);
239
+ }
240
+ i = j + 1;
241
+ }
242
+ return out + input.slice(i);
243
+ }
244
+ function redactValue(value, secrets, seen = new WeakSet()) {
245
+ if (typeof value === 'string')
246
+ return redactString(value, secrets);
247
+ if (!value || typeof value !== 'object')
248
+ return value;
249
+ if (seen.has(value))
250
+ return '[Circular]';
251
+ seen.add(value);
252
+ if (value instanceof Error) {
253
+ const result = Object.freeze({
254
+ name: value.name,
255
+ message: redactString(value.message, secrets),
256
+ stack: value.stack ? redactString(value.stack, secrets) : undefined,
257
+ cause: value.cause === undefined ? undefined : redactValue(value.cause, secrets, seen),
258
+ });
259
+ seen.delete(value);
260
+ return result;
261
+ }
262
+ if (Array.isArray(value)) {
263
+ const result = Object.freeze(value.map((item) => redactValue(item, secrets, seen)));
264
+ seen.delete(value);
265
+ return result;
266
+ }
267
+ if (!isPlainRecord(value)) {
268
+ seen.delete(value);
269
+ return value;
270
+ }
271
+ const result = Object.freeze(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactValue(item, secrets, seen)])));
272
+ seen.delete(value);
273
+ return result;
274
+ }
275
+ function redactString(value, secrets) {
276
+ return secrets.reduce((result, secret) => result.replaceAll(secret, '[REDACTED]'), value);
277
+ }
278
+ function secretValues(source, keys) {
279
+ return Object.freeze(keys.flatMap((key) => {
280
+ const value = source[key];
281
+ return value ? [value] : [];
282
+ }));
283
+ }
284
+ function freezeValue(value, seen = new WeakSet()) {
285
+ if (!value || typeof value !== 'object' || seen.has(value))
286
+ return value;
287
+ seen.add(value);
288
+ if (Array.isArray(value) || isPlainRecord(value)) {
289
+ for (const item of Object.values(value))
290
+ freezeValue(item, seen);
291
+ Object.freeze(value);
292
+ }
293
+ return value;
294
+ }
295
+ function isPlainRecord(value) {
296
+ if (!value || typeof value !== 'object' || Array.isArray(value))
297
+ return false;
298
+ const prototype = Object.getPrototypeOf(value);
299
+ return prototype === Object.prototype || prototype === null;
300
+ }
@@ -0,0 +1,8 @@
1
+ export type RuntimeMode = 'development' | 'test' | 'production';
2
+ export interface RuntimeEnvironment {
3
+ readonly name: string;
4
+ readonly mode: RuntimeMode;
5
+ readonly platform: string;
6
+ }
7
+ export declare const runtimeEnvironmentToken: import("@zhin.js/plugin-runtime").Token<RuntimeEnvironment>;
8
+ export declare function defineRuntimeEnvironment(environment: RuntimeEnvironment): Readonly<RuntimeEnvironment>;
@@ -0,0 +1,13 @@
1
+ import { createToken } from '@zhin.js/plugin-runtime';
2
+ export const runtimeEnvironmentToken = createToken('zhin.runtime-environment', 'Explicit Root runtime environment');
3
+ export function defineRuntimeEnvironment(environment) {
4
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(environment.name)) {
5
+ throw new TypeError(`Invalid environment name: ${environment.name}`);
6
+ }
7
+ if (!['development', 'test', 'production'].includes(environment.mode)) {
8
+ throw new TypeError(`Invalid runtime mode: ${environment.mode}`);
9
+ }
10
+ if (!environment.platform)
11
+ throw new TypeError('Runtime platform is required');
12
+ return Object.freeze({ ...environment });
13
+ }
@@ -0,0 +1,15 @@
1
+ import { type Dispose, type GenerationHandoff, type SnapshotState } from '@zhin.js/plugin-runtime';
2
+ import type { FeatureProvider } from '@zhin.js/feature-kit';
3
+ export type ProjectionState = Omit<SnapshotState, 'projections'>;
4
+ export interface ProjectedFeatures {
5
+ readonly state: SnapshotState;
6
+ readonly disposers: readonly Dispose[];
7
+ readonly handoff?: GenerationHandoff;
8
+ }
9
+ /** Builds every Feature projection against one coherent candidate snapshot. */
10
+ export declare class FeatureProjector {
11
+ private readonly providers;
12
+ constructor(providers: Iterable<FeatureProvider>);
13
+ project(generation: number, base: ProjectionState): Promise<ProjectedFeatures>;
14
+ }
15
+ export declare function composeGenerationHandoffs(...handoffs: readonly (GenerationHandoff | undefined)[]): GenerationHandoff | undefined;
@@ -0,0 +1,57 @@
1
+ import { DisposeStack, GenerationHandoffStack, createSnapshotView, } from '@zhin.js/plugin-runtime';
2
+ /** Builds every Feature projection against one coherent candidate snapshot. */
3
+ export class FeatureProjector {
4
+ providers;
5
+ constructor(providers) {
6
+ this.providers = providers;
7
+ }
8
+ async project(generation, base) {
9
+ const projections = new Map();
10
+ const disposers = [];
11
+ const handoffs = new GenerationHandoffStack();
12
+ const state = { ...base, projections };
13
+ try {
14
+ // A projection may capture its snapshot. Rebuilding every projection
15
+ // prevents unchanged Features from retaining an older generation.
16
+ for (const provider of this.providers) {
17
+ const slots = [...base.capabilities.values()].filter((slot) => slot.feature === provider.id);
18
+ const projection = await provider.runtime.project(slots, {
19
+ snapshot: createSnapshotView(generation, state),
20
+ });
21
+ projections.set(provider.id, projection.value);
22
+ if (projection.dispose)
23
+ disposers.push(projection.dispose);
24
+ if (projection.handoff)
25
+ handoffs.add(projection.handoff);
26
+ }
27
+ return {
28
+ state,
29
+ disposers: Object.freeze(disposers),
30
+ handoff: handoffs.seal(),
31
+ };
32
+ }
33
+ catch (error) {
34
+ await rollback(disposers, error);
35
+ throw error;
36
+ }
37
+ }
38
+ }
39
+ export function composeGenerationHandoffs(...handoffs) {
40
+ const stack = new GenerationHandoffStack();
41
+ for (const handoff of handoffs) {
42
+ if (handoff)
43
+ stack.add(handoff);
44
+ }
45
+ return stack.seal();
46
+ }
47
+ async function rollback(disposers, prepareError) {
48
+ const stack = new DisposeStack();
49
+ for (const dispose of disposers)
50
+ stack.add(dispose);
51
+ try {
52
+ await stack.dispose();
53
+ }
54
+ catch (disposeError) {
55
+ throw new AggregateError([prepareError, disposeError], 'Feature projection and rollback both failed', { cause: disposeError });
56
+ }
57
+ }
@@ -0,0 +1,9 @@
1
+ import { type Dispose, type PluginId } from '@zhin.js/plugin-runtime';
2
+ export declare class GenerationAssets {
3
+ #private;
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;
8
+ dispose(): Promise<void>;
9
+ }
@@ -0,0 +1,67 @@
1
+ import { DisposeStack, SharedLifetime, } from '@zhin.js/plugin-runtime';
2
+ export class GenerationAssets {
3
+ #scopeLifetimes;
4
+ #disposers = new DisposeStack();
5
+ constructor(scopeOrder, scopeLifetimes, projectionDisposers) {
6
+ this.#scopeLifetimes = scopeLifetimes;
7
+ assertScopeOrder(scopeOrder, scopeLifetimes);
8
+ // Scope order is parent-first. DisposeStack unwinds projections first,
9
+ // then Plugin leases children-first, so no child observes a closed parent.
10
+ for (const owner of scopeOrder) {
11
+ const lifetime = scopeLifetimes.get(owner);
12
+ if (!lifetime)
13
+ throw new Error(`Missing Scope lifetime for ${owner}`);
14
+ const lease = lifetime.acquire();
15
+ this.#disposers.add(() => lease.release());
16
+ }
17
+ for (const dispose of projectionDisposers)
18
+ this.#disposers.add(dispose);
19
+ this.#disposers.seal();
20
+ }
21
+ static create(scopeDisposers, projectionDisposers) {
22
+ const lifetimes = new Map();
23
+ for (const [owner, dispose] of scopeDisposers) {
24
+ if (lifetimes.has(owner))
25
+ throw new Error(`Duplicate Plugin Scope: ${owner}`);
26
+ lifetimes.set(owner, new SharedLifetime(dispose));
27
+ }
28
+ return new GenerationAssets([...lifetimes.keys()], lifetimes, projectionDisposers);
29
+ }
30
+ fork(projectionDisposers) {
31
+ return new GenerationAssets([...this.#scopeLifetimes.keys()], this.#scopeLifetimes, projectionDisposers);
32
+ }
33
+ replaceScopes(scopeOrder, replacements, projectionDisposers) {
34
+ const owners = new Set(scopeOrder);
35
+ for (const owner of replacements.keys()) {
36
+ if (!owners.has(owner))
37
+ throw new Error(`Replacement Scope is not mounted: ${owner}`);
38
+ }
39
+ const lifetimes = new Map();
40
+ for (const owner of scopeOrder) {
41
+ const replacement = replacements.get(owner);
42
+ const lifetime = replacement
43
+ ? new SharedLifetime(replacement)
44
+ : this.#scopeLifetimes.get(owner);
45
+ if (!lifetime)
46
+ throw new Error(`Cannot retain unknown Plugin Scope: ${owner}`);
47
+ lifetimes.set(owner, lifetime);
48
+ }
49
+ return new GenerationAssets(scopeOrder, lifetimes, projectionDisposers);
50
+ }
51
+ dispose() {
52
+ return this.#disposers.dispose();
53
+ }
54
+ }
55
+ function assertScopeOrder(scopeOrder, lifetimes) {
56
+ const owners = new Set();
57
+ for (const owner of scopeOrder) {
58
+ if (owners.has(owner))
59
+ throw new Error(`Duplicate Plugin Scope: ${owner}`);
60
+ if (!lifetimes.has(owner))
61
+ throw new Error(`Missing Scope lifetime for ${owner}`);
62
+ owners.add(owner);
63
+ }
64
+ if (owners.size !== lifetimes.size) {
65
+ throw new Error('Scope order does not include every Plugin Scope lifetime');
66
+ }
67
+ }
@@ -0,0 +1,23 @@
1
+ import type { Dispose } from '@zhin.js/plugin-runtime';
2
+ import { type GenerationInvalidationPlan, type InvalidationPlan, type ProcessInvalidationPlan } from './invalidation-planner.js';
3
+ import type { ModuleRuntime } from './module-runtime.js';
4
+ import type { SourceOwnershipIndex } from './source-ownership.js';
5
+ export interface HmrReloadPort {
6
+ reload(plan: GenerationInvalidationPlan): Promise<ProcessInvalidationPlan | void>;
7
+ }
8
+ export interface HmrCoordinatorOptions {
9
+ readonly modules: ModuleRuntime;
10
+ readonly ownership: () => SourceOwnershipIndex;
11
+ readonly runtime: HmrReloadPort;
12
+ onRestartRequired(plan: ProcessInvalidationPlan): void | Promise<void>;
13
+ onError(error: unknown): void | Promise<void>;
14
+ onPlan?(plan: InvalidationPlan): void | Promise<void>;
15
+ }
16
+ export declare class HmrCoordinator {
17
+ #private;
18
+ private readonly options;
19
+ constructor(options: HmrCoordinatorOptions);
20
+ start(): Dispose;
21
+ stop(): void;
22
+ enqueue(source: string): Promise<void>;
23
+ }
@@ -0,0 +1,110 @@
1
+ import { InvalidationPlanner, } from './invalidation-planner.js';
2
+ export class HmrCoordinator {
3
+ options;
4
+ #pending = new Set();
5
+ #waiters = [];
6
+ #draining;
7
+ #unwatch;
8
+ constructor(options) {
9
+ this.options = options;
10
+ }
11
+ start() {
12
+ if (this.#unwatch)
13
+ throw new Error('HmrCoordinator is already started');
14
+ if (!this.options.modules.watch) {
15
+ throw new Error('ModuleRuntime does not provide a file watcher');
16
+ }
17
+ this.#unwatch = this.options.modules.watch((source) => {
18
+ void this.enqueue(source).catch(() => undefined);
19
+ });
20
+ return () => this.stop();
21
+ }
22
+ stop() {
23
+ this.#unwatch?.();
24
+ this.#unwatch = undefined;
25
+ }
26
+ enqueue(source) {
27
+ this.#pending.add(source);
28
+ const completed = new Promise((resolve, reject) => {
29
+ this.#waiters.push({ resolve, reject });
30
+ });
31
+ // Starting on a microtask batches add/change/unlink events emitted for the
32
+ // same filesystem operation into one generation transaction.
33
+ this.#ensureDrain();
34
+ return completed;
35
+ }
36
+ #ensureDrain() {
37
+ if (this.#draining)
38
+ return;
39
+ // Individual enqueue promises carry failures to callers. Keep the shared
40
+ // scheduler promise handled even when an onError hook itself fails.
41
+ this.#draining = Promise.resolve()
42
+ .then(() => this.#drain())
43
+ .catch(() => undefined);
44
+ }
45
+ async #drain() {
46
+ try {
47
+ while (this.#pending.size > 0) {
48
+ const changed = [...this.#pending];
49
+ this.#pending.clear();
50
+ const forcedRestart = changed.filter((source) => this.options.modules.requiresProcessRestart?.(source));
51
+ if (forcedRestart.length > 0) {
52
+ await this.options.onRestartRequired(Object.freeze({
53
+ kind: 'process',
54
+ changed: Object.freeze(changed),
55
+ reasons: Object.freeze([
56
+ `Module loader cannot safely invalidate: ${forcedRestart.join(', ')}`,
57
+ ]),
58
+ }));
59
+ continue;
60
+ }
61
+ const dependencyPort = this.options.modules.affectedSources
62
+ ? {
63
+ affectedSources: (source) => this.options.modules.affectedSources?.(source) ?? [source],
64
+ }
65
+ : undefined;
66
+ const plan = new InvalidationPlanner(this.options.ownership(), dependencyPort).plan(changed);
67
+ await this.options.onPlan?.(plan);
68
+ if (plan.kind === 'process') {
69
+ await this.options.onRestartRequired(plan);
70
+ continue;
71
+ }
72
+ if (plan.kind === 'none')
73
+ continue;
74
+ for (const source of plan.changed) {
75
+ await this.options.modules.invalidate?.(source);
76
+ }
77
+ const restart = await this.options.runtime.reload(plan);
78
+ if (restart)
79
+ await this.options.onRestartRequired(restart);
80
+ }
81
+ this.#resolveWaiters();
82
+ }
83
+ catch (error) {
84
+ // A failed transaction invalidates the rest of this burst as well. Do
85
+ // not replay queued paths without their callers explicitly retrying.
86
+ this.#pending.clear();
87
+ try {
88
+ await this.options.onError(error);
89
+ }
90
+ finally {
91
+ this.#rejectWaiters(error);
92
+ }
93
+ }
94
+ finally {
95
+ this.#draining = undefined;
96
+ // A source may arrive after the loop observed an empty queue but before
97
+ // this promise settled. Keep its waiter attached to a fresh transaction.
98
+ if (this.#pending.size > 0)
99
+ this.#ensureDrain();
100
+ }
101
+ }
102
+ #resolveWaiters() {
103
+ for (const waiter of this.#waiters.splice(0))
104
+ waiter.resolve();
105
+ }
106
+ #rejectWaiters(error) {
107
+ for (const waiter of this.#waiters.splice(0))
108
+ waiter.reject(error);
109
+ }
110
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export * from './config-composer.js';
2
+ export * from './config-document.js';
3
+ export * from './config-patch-planner.js';
4
+ export * from './compatibility.js';
5
+ export * from './environment.js';
6
+ export * from './environment-store.js';
7
+ export * from './hmr-coordinator.js';
8
+ export * from './invalidation-planner.js';
9
+ export * from './isolation.js';
10
+ export * from './manifest.js';
11
+ export * from './module-runtime.js';
12
+ export * from './native-development-runtime.js';
13
+ export * from './node-discovery-host.js';
14
+ export * from './package-resolver.js';
15
+ export * from './project-graph.js';
16
+ export * from './process-restart.js';
17
+ export * from './restart-boundary.js';
18
+ export * from './root-runtime.js';
19
+ export * from './source-ownership.js';
20
+ export * from './topology-transaction.js';
21
+ export * from './typescript-specifier-remap.js';
package/lib/index.js ADDED
@@ -0,0 +1,21 @@
1
+ export * from './config-composer.js';
2
+ export * from './config-document.js';
3
+ export * from './config-patch-planner.js';
4
+ export * from './compatibility.js';
5
+ export * from './environment.js';
6
+ export * from './environment-store.js';
7
+ export * from './hmr-coordinator.js';
8
+ export * from './invalidation-planner.js';
9
+ export * from './isolation.js';
10
+ export * from './manifest.js';
11
+ export * from './module-runtime.js';
12
+ export * from './native-development-runtime.js';
13
+ export * from './node-discovery-host.js';
14
+ export * from './package-resolver.js';
15
+ export * from './project-graph.js';
16
+ export * from './process-restart.js';
17
+ export * from './restart-boundary.js';
18
+ export * from './root-runtime.js';
19
+ export * from './source-ownership.js';
20
+ export * from './topology-transaction.js';
21
+ export * from './typescript-specifier-remap.js';
@@ -0,0 +1,29 @@
1
+ import { type CapabilityId, type PluginId } from '@zhin.js/plugin-runtime';
2
+ import type { SourceOwnershipIndex } from './source-ownership.js';
3
+ export interface DependencyImpactPort {
4
+ affectedSources(source: string): readonly string[];
5
+ }
6
+ export interface NoInvalidationPlan {
7
+ readonly kind: 'none';
8
+ readonly changed: readonly string[];
9
+ readonly reasons: readonly string[];
10
+ }
11
+ export interface GenerationInvalidationPlan {
12
+ readonly kind: 'generation';
13
+ readonly changed: readonly string[];
14
+ readonly slots: readonly CapabilityId[];
15
+ readonly subtrees: readonly PluginId[];
16
+ readonly reasons: readonly string[];
17
+ }
18
+ export interface ProcessInvalidationPlan {
19
+ readonly kind: 'process';
20
+ readonly changed: readonly string[];
21
+ readonly reasons: readonly string[];
22
+ }
23
+ export type InvalidationPlan = NoInvalidationPlan | GenerationInvalidationPlan | ProcessInvalidationPlan;
24
+ export declare class InvalidationPlanner {
25
+ private readonly ownership;
26
+ private readonly dependencies?;
27
+ constructor(ownership: SourceOwnershipIndex, dependencies?: DependencyImpactPort | undefined);
28
+ plan(sources: readonly string[]): InvalidationPlan;
29
+ }