@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,106 @@
1
+ import { basename, resolve } from 'node:path';
2
+ import { rootPluginId } from '@zhin.js/plugin-runtime';
3
+ const processFiles = new Set([
4
+ 'pnpm-lock.yaml',
5
+ 'pnpm-workspace.yaml',
6
+ 'package-lock.json',
7
+ 'yarn.lock',
8
+ ]);
9
+ export class InvalidationPlanner {
10
+ ownership;
11
+ dependencies;
12
+ constructor(ownership, dependencies) {
13
+ this.ownership = ownership;
14
+ this.dependencies = dependencies;
15
+ }
16
+ plan(sources) {
17
+ const changed = unique(sources.map((source) => resolve(source)));
18
+ if (changed.some((source) => processFiles.has(basename(source)))) {
19
+ return Object.freeze({
20
+ kind: 'process',
21
+ changed,
22
+ reasons: Object.freeze(['workspace dependency state changed']),
23
+ });
24
+ }
25
+ const slots = new Map();
26
+ const subtrees = new Set();
27
+ const reasons = new Set();
28
+ const processReasons = new Set();
29
+ for (const source of changed) {
30
+ const affected = unique([source, ...(this.dependencies?.affectedSources(source) ?? [])].map((item) => resolve(item)));
31
+ let matched = false;
32
+ for (const item of affected) {
33
+ const records = this.ownership.recordsFor(item);
34
+ if (records.length > 0)
35
+ matched = true;
36
+ for (const record of records) {
37
+ if (requiresProcessRestart(record)) {
38
+ processReasons.add(`Root ${record.role} source changed`);
39
+ }
40
+ else {
41
+ applyRecord(record, slots, subtrees, reasons);
42
+ }
43
+ }
44
+ }
45
+ // An untracked support module still belongs to the nearest mounted
46
+ // package. Without an owned importer, subtree replacement is safest.
47
+ if (!matched) {
48
+ for (const owner of this.ownership.ownersForPath(source)) {
49
+ subtrees.add(owner);
50
+ reasons.add(`untracked support source changed in ${owner}`);
51
+ }
52
+ }
53
+ }
54
+ if (processReasons.size > 0) {
55
+ return Object.freeze({
56
+ kind: 'process',
57
+ changed,
58
+ reasons: Object.freeze([...processReasons]),
59
+ });
60
+ }
61
+ const roots = collapseSubtrees(subtrees);
62
+ const retainedSlots = [...slots].flatMap(([capability, owner]) => roots.some((root) => isWithin(owner, root)) ? [] : [capability]);
63
+ if (roots.length === 0 && retainedSlots.length === 0) {
64
+ return Object.freeze({
65
+ kind: 'none',
66
+ changed,
67
+ reasons: Object.freeze([...reasons]),
68
+ });
69
+ }
70
+ return Object.freeze({
71
+ kind: 'generation',
72
+ changed,
73
+ slots: Object.freeze(retainedSlots),
74
+ subtrees: Object.freeze(roots),
75
+ reasons: Object.freeze([...reasons]),
76
+ });
77
+ }
78
+ }
79
+ function requiresProcessRestart(record) {
80
+ return (record.owner === rootPluginId()
81
+ && (record.role === 'plugin' || record.role === 'schema'));
82
+ }
83
+ function applyRecord(record, slots, subtrees, reasons) {
84
+ if (record.role === 'capability' && record.capability) {
85
+ slots.set(record.capability, record.owner);
86
+ reasons.add(`Capability source changed: ${record.capability}`);
87
+ return;
88
+ }
89
+ subtrees.add(record.owner);
90
+ reasons.add(`${record.role} source changed in ${record.owner}`);
91
+ }
92
+ function collapseSubtrees(values) {
93
+ const sorted = [...values].sort((left, right) => left.length - right.length);
94
+ const result = [];
95
+ for (const candidate of sorted) {
96
+ if (!result.some((root) => isWithin(candidate, root)))
97
+ result.push(candidate);
98
+ }
99
+ return result;
100
+ }
101
+ function isWithin(plugin, root) {
102
+ return plugin === root || plugin.startsWith(`${root}/`);
103
+ }
104
+ function unique(values) {
105
+ return Object.freeze([...new Set(values)]);
106
+ }
@@ -0,0 +1,28 @@
1
+ import type { Dispose, GenerationHandoffParticipant, PluginId, PluginMetadata, Token } from '@zhin.js/plugin-runtime';
2
+ import type { RuntimeEnvironment } from './environment.js';
3
+ export interface IsolatedPluginPrepareRequest {
4
+ readonly owner: PluginId;
5
+ readonly parent: PluginId;
6
+ readonly packageName: string;
7
+ readonly entry: string;
8
+ readonly config: unknown;
9
+ readonly environment: RuntimeEnvironment;
10
+ }
11
+ export interface IsolatedPluginDescriptor {
12
+ readonly name: string;
13
+ readonly metadata?: PluginMetadata;
14
+ }
15
+ export interface IsolatedResourceBinding<T = unknown> {
16
+ readonly token: Token<T>;
17
+ readonly value: T;
18
+ }
19
+ export interface PreparedIsolatedPlugin {
20
+ readonly descriptor: IsolatedPluginDescriptor;
21
+ readonly resources?: readonly IsolatedResourceBinding[];
22
+ readonly handoff?: GenerationHandoffParticipant;
23
+ readonly dispose: Dispose;
24
+ }
25
+ /** Adapter seam for child Plugin lifecycle that must not execute in the Host realm. */
26
+ export interface IsolatedPluginRuntimePort {
27
+ prepare(request: IsolatedPluginPrepareRequest): Promise<PreparedIsolatedPlugin>;
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ export interface PackageReference {
2
+ readonly package: string;
3
+ readonly optional?: boolean;
4
+ readonly api?: string;
5
+ }
6
+ export interface ChildPluginReference extends PackageReference {
7
+ readonly instanceKey: string;
8
+ }
9
+ export interface ZhinPluginManifest {
10
+ readonly protocol: 1;
11
+ readonly type: 'plugin';
12
+ readonly entry: string;
13
+ readonly engine?: string;
14
+ readonly runtime?: 'trusted' | 'isolated';
15
+ readonly features: readonly PackageReference[];
16
+ readonly plugins: readonly ChildPluginReference[];
17
+ }
18
+ export interface ZhinFeatureManifest {
19
+ readonly protocol: 1;
20
+ readonly type: 'feature';
21
+ readonly entry: string;
22
+ readonly engine?: string;
23
+ readonly featureApi?: string;
24
+ }
25
+ export type ZhinPackageManifest = ZhinPluginManifest | ZhinFeatureManifest;
26
+ export interface PackageJson {
27
+ readonly name: string;
28
+ readonly version?: string;
29
+ readonly type?: 'module' | 'commonjs';
30
+ readonly main?: string;
31
+ readonly exports?: unknown;
32
+ readonly imports?: unknown;
33
+ readonly private?: boolean;
34
+ readonly dependencies?: Readonly<Record<string, string>>;
35
+ readonly optionalDependencies?: Readonly<Record<string, string>>;
36
+ readonly peerDependencies?: Readonly<Record<string, string>>;
37
+ readonly devDependencies?: Readonly<Record<string, string>>;
38
+ readonly scripts?: Readonly<Record<string, string>>;
39
+ readonly zhin: ZhinPackageManifest;
40
+ }
41
+ export declare class ManifestValidationError extends Error {
42
+ readonly issues: readonly string[];
43
+ constructor(issues: readonly string[]);
44
+ }
45
+ export declare function parsePackageJson(value: unknown, source: string): PackageJson;
@@ -0,0 +1,195 @@
1
+ export class ManifestValidationError extends Error {
2
+ issues;
3
+ constructor(issues) {
4
+ super(`Invalid Zhin package manifest:\n${issues.map((issue) => `- ${issue}`).join('\n')}`);
5
+ this.issues = issues;
6
+ this.name = 'ManifestValidationError';
7
+ }
8
+ }
9
+ export function parsePackageJson(value, source) {
10
+ const issues = [];
11
+ const record = asRecord(value, source, issues);
12
+ const name = readString(record, 'name', source, issues);
13
+ if (name && !isPackageName(name))
14
+ issues.push(`${source}#name is not a valid package name`);
15
+ const zhin = parseZhinManifest(record.zhin, `${source}#zhin`, issues);
16
+ const version = optionalString(record.version, `${source}#version`, issues);
17
+ const moduleType = optionalModuleType(record.type, `${source}#type`, issues);
18
+ const main = optionalString(record.main, `${source}#main`, issues);
19
+ const packageExports = optionalPackageMap(record.exports, `${source}#exports`, issues);
20
+ const packageImports = optionalPackageMap(record.imports, `${source}#imports`, issues);
21
+ const isPrivate = optionalBoolean(record.private, `${source}#private`, issues);
22
+ const dependencies = stringRecord(record.dependencies, `${source}#dependencies`, issues);
23
+ const optionalDependencies = stringRecord(record.optionalDependencies, `${source}#optionalDependencies`, issues);
24
+ const peerDependencies = stringRecord(record.peerDependencies, `${source}#peerDependencies`, issues);
25
+ const devDependencies = stringRecord(record.devDependencies, `${source}#devDependencies`, issues);
26
+ const scripts = stringRecord(record.scripts, `${source}#scripts`, issues);
27
+ if (issues.length > 0 || !name || !zhin)
28
+ throw new ManifestValidationError(issues);
29
+ return Object.freeze({
30
+ name,
31
+ version,
32
+ type: moduleType,
33
+ main,
34
+ exports: packageExports,
35
+ imports: packageImports,
36
+ private: isPrivate,
37
+ dependencies,
38
+ optionalDependencies,
39
+ peerDependencies,
40
+ devDependencies,
41
+ scripts,
42
+ zhin,
43
+ });
44
+ }
45
+ function parseZhinManifest(value, source, issues) {
46
+ const record = asRecord(value, source, issues);
47
+ if (record.protocol !== 1)
48
+ issues.push(`${source}.protocol must be 1`);
49
+ const type = readString(record, 'type', source, issues);
50
+ const entry = readRelativeEntry(record.entry, `${source}.entry`, issues);
51
+ const engine = optionalString(record.engine, `${source}.engine`, issues);
52
+ if (!entry || (type !== 'plugin' && type !== 'feature')) {
53
+ if (type && type !== 'plugin' && type !== 'feature') {
54
+ issues.push(`${source}.type must be "plugin" or "feature"`);
55
+ }
56
+ return undefined;
57
+ }
58
+ if (type === 'feature') {
59
+ return Object.freeze({
60
+ protocol: 1,
61
+ type,
62
+ entry,
63
+ engine,
64
+ featureApi: optionalString(record.featureApi, `${source}.featureApi`, issues),
65
+ });
66
+ }
67
+ const runtime = record.runtime;
68
+ if (runtime !== undefined && runtime !== 'trusted' && runtime !== 'isolated') {
69
+ issues.push(`${source}.runtime must be "trusted" or "isolated"`);
70
+ }
71
+ return Object.freeze({
72
+ protocol: 1,
73
+ type,
74
+ entry,
75
+ engine,
76
+ runtime: runtime,
77
+ features: parseReferences(record.features, `${source}.features`, issues, false),
78
+ plugins: parseReferences(record.plugins, `${source}.plugins`, issues, true),
79
+ });
80
+ }
81
+ function parseReferences(value, source, issues, child) {
82
+ if (value === undefined)
83
+ return [];
84
+ if (!Array.isArray(value)) {
85
+ issues.push(`${source} must be an array`);
86
+ return [];
87
+ }
88
+ return value.flatMap((item, index) => {
89
+ const itemSource = `${source}[${index}]`;
90
+ const record = asRecord(item, itemSource, issues);
91
+ const packageName = readString(record, 'package', itemSource, issues);
92
+ const instanceKey = child
93
+ ? readString(record, 'instanceKey', itemSource, issues)
94
+ : undefined;
95
+ if (!packageName || (child && !instanceKey))
96
+ return [];
97
+ if (!isPackageName(packageName)) {
98
+ issues.push(`${itemSource}.package is not a valid package name`);
99
+ return [];
100
+ }
101
+ if (instanceKey && !/^[a-z0-9][a-z0-9-]*$/.test(instanceKey)) {
102
+ issues.push(`${itemSource}.instanceKey is invalid`);
103
+ return [];
104
+ }
105
+ return [{
106
+ package: packageName,
107
+ instanceKey: instanceKey ?? packageName,
108
+ optional: optionalBoolean(record.optional, `${itemSource}.optional`, issues),
109
+ api: optionalString(record.api, `${itemSource}.api`, issues),
110
+ }];
111
+ });
112
+ }
113
+ function readRelativeEntry(value, source, issues) {
114
+ if (typeof value !== 'string' || !value.startsWith('./')) {
115
+ issues.push(`${source} must be a package-relative path starting with ./`);
116
+ return undefined;
117
+ }
118
+ if (value.split('/').includes('..')) {
119
+ issues.push(`${source} must not escape the package root`);
120
+ return undefined;
121
+ }
122
+ return value;
123
+ }
124
+ function asRecord(value, source, issues) {
125
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
126
+ issues.push(`${source} must be an object`);
127
+ return {};
128
+ }
129
+ return value;
130
+ }
131
+ function readString(record, key, source, issues) {
132
+ const value = record[key];
133
+ if (typeof value !== 'string' || value.length === 0) {
134
+ issues.push(`${source}.${key} must be a non-empty string`);
135
+ return undefined;
136
+ }
137
+ return value;
138
+ }
139
+ function optionalString(value, source, issues) {
140
+ if (value === undefined)
141
+ return undefined;
142
+ if (typeof value !== 'string')
143
+ issues.push(`${source} must be a string`);
144
+ return typeof value === 'string' ? value : undefined;
145
+ }
146
+ function optionalBoolean(value, source, issues) {
147
+ if (value === undefined)
148
+ return undefined;
149
+ if (typeof value !== 'boolean')
150
+ issues.push(`${source} must be a boolean`);
151
+ return typeof value === 'boolean' ? value : undefined;
152
+ }
153
+ function optionalModuleType(value, source, issues) {
154
+ if (value === undefined)
155
+ return undefined;
156
+ if (value !== 'module' && value !== 'commonjs') {
157
+ issues.push(`${source} must be "module" or "commonjs"`);
158
+ return undefined;
159
+ }
160
+ return value;
161
+ }
162
+ function optionalPackageMap(value, source, issues) {
163
+ if (value === undefined)
164
+ return undefined;
165
+ if (value === null || typeof value === 'string')
166
+ return value;
167
+ if (Array.isArray(value)) {
168
+ return Object.freeze(value.map((item, index) => optionalPackageMap(item, `${source}[${index}]`, issues)));
169
+ }
170
+ if (!value || typeof value !== 'object') {
171
+ issues.push(`${source} must be a string, null, array, or object`);
172
+ return undefined;
173
+ }
174
+ const entries = Object.entries(value).map(([key, item]) => [
175
+ key,
176
+ optionalPackageMap(item, `${source}.${key}`, issues),
177
+ ]);
178
+ return Object.freeze(Object.fromEntries(entries));
179
+ }
180
+ function stringRecord(value, source, issues) {
181
+ if (value === undefined)
182
+ return undefined;
183
+ const record = asRecord(value, source, issues);
184
+ const result = {};
185
+ for (const [key, item] of Object.entries(record)) {
186
+ if (typeof item !== 'string')
187
+ issues.push(`${source}.${key} must be a string`);
188
+ else
189
+ result[key] = item;
190
+ }
191
+ return Object.freeze(result);
192
+ }
193
+ function isPackageName(value) {
194
+ return /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
195
+ }
@@ -0,0 +1,17 @@
1
+ import type { Dispose } from '@zhin.js/plugin-runtime';
2
+ import type { ClientModuleRequest } from '@zhin.js/feature-kit';
3
+ export interface ModuleRuntime {
4
+ load<T = unknown>(source: string): Promise<T>;
5
+ /** Optional compiler/manifest adapter for browser modules such as Page and Layout. */
6
+ loadClientModule?<T = unknown>(source: string, request: ClientModuleRequest): Promise<T>;
7
+ invalidate?(source: string): Promise<void> | void;
8
+ affectedSources?(source: string): readonly string[];
9
+ /** True when this adapter cannot invalidate the complete importer closure safely. */
10
+ requiresProcessRestart?(source: string): boolean;
11
+ watch?(listener: (source: string) => void): Dispose;
12
+ close(): Promise<void>;
13
+ }
14
+ export declare class EsmModuleRuntime implements ModuleRuntime {
15
+ load<T>(source: string): Promise<T>;
16
+ close(): Promise<void>;
17
+ }
@@ -0,0 +1,7 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ export class EsmModuleRuntime {
3
+ async load(source) {
4
+ return import(pathToFileURL(source).href);
5
+ }
6
+ async close() { }
7
+ }
@@ -0,0 +1,22 @@
1
+ import type { Dispose } from '@zhin.js/plugin-runtime';
2
+ import type { ModuleRuntime } from './module-runtime.js';
3
+ export interface NativeDevelopmentModuleRuntimeOptions {
4
+ readonly projectRoot: string;
5
+ readonly watch?: boolean;
6
+ }
7
+ /**
8
+ * Uses Node's native ESM/TypeScript loader and adds only cache busting and watch.
9
+ * It deliberately requests a process restart for support modules whose cached
10
+ * relative import closure cannot be invalidated without a custom loader.
11
+ */
12
+ export declare class NativeDevelopmentModuleRuntime implements ModuleRuntime {
13
+ #private;
14
+ constructor(options: NativeDevelopmentModuleRuntimeOptions);
15
+ load<T = unknown>(source: string): Promise<T>;
16
+ invalidate(source: string): void;
17
+ requiresProcessRestart(source: string): boolean;
18
+ watch(listener: (source: string) => void): Dispose;
19
+ close(): Promise<void>;
20
+ }
21
+ export declare function supportsNativeTypeScript(version?: string, execArguments?: readonly string[], nodeOptions?: string): boolean;
22
+ export declare function assertNativeTypeScriptSupport(): void;
@@ -0,0 +1,190 @@
1
+ import { readdirSync, statSync, watch as watchDirectory, } from 'node:fs';
2
+ import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ const ignoredDirectories = new Set([
5
+ '.git', '.zhin', 'coverage', 'dist', 'lib', 'node_modules',
6
+ ]);
7
+ const watchedExtensions = new Set([
8
+ '.cjs', '.js', '.json', '.md', '.mjs', '.ts', '.tsx', '.yaml', '.yml',
9
+ ]);
10
+ const capabilityRoots = new Set([
11
+ 'adapters', 'agents', 'commands', 'components', 'mcp', 'middlewares', 'pages', 'skills', 'tools',
12
+ ]);
13
+ /**
14
+ * Uses Node's native ESM/TypeScript loader and adds only cache busting and watch.
15
+ * It deliberately requests a process restart for support modules whose cached
16
+ * relative import closure cannot be invalidated without a custom loader.
17
+ */
18
+ export class NativeDevelopmentModuleRuntime {
19
+ #projectRoot;
20
+ #watchEnabled;
21
+ #revisions = new Map();
22
+ #watchers = new Set();
23
+ #closed = false;
24
+ constructor(options) {
25
+ this.#projectRoot = resolve(options.projectRoot);
26
+ this.#watchEnabled = options.watch ?? true;
27
+ }
28
+ async load(source) {
29
+ this.#assertOpen();
30
+ const normalized = resolve(source);
31
+ if (normalized.endsWith('.tsx')) {
32
+ throw new Error(`Node native TypeScript does not support TSX: ${normalized}`);
33
+ }
34
+ if (normalized.endsWith('.ts'))
35
+ assertNativeTypeScriptSupport();
36
+ const url = pathToFileURL(normalized);
37
+ url.searchParams.set('zhin-generation', String(this.#revisions.get(normalized) ?? 0));
38
+ return import(url.href);
39
+ }
40
+ invalidate(source) {
41
+ const normalized = resolve(source);
42
+ this.#revisions.set(normalized, (this.#revisions.get(normalized) ?? 0) + 1);
43
+ }
44
+ requiresProcessRestart(source) {
45
+ const normalized = resolve(source);
46
+ if (!isWithin(this.#projectRoot, normalized))
47
+ return true;
48
+ const parts = relative(this.#projectRoot, normalized).split(sep);
49
+ const capability = parts.findIndex((part) => capabilityRoots.has(part));
50
+ if (capability < 0)
51
+ return isExecutableSource(normalized);
52
+ const root = parts[capability];
53
+ if (root === 'pages')
54
+ return false;
55
+ if (root === 'skills' || root === 'agents')
56
+ return extname(normalized) !== '.md';
57
+ if (root === 'tools' || root === 'mcp')
58
+ return parts.length !== capability + 2;
59
+ return false;
60
+ }
61
+ watch(listener) {
62
+ this.#assertOpen();
63
+ if (!this.#watchEnabled)
64
+ return () => undefined;
65
+ const watcher = new PortableSourceWatcher(this.#projectRoot, listener);
66
+ this.#watchers.add(watcher);
67
+ return () => {
68
+ watcher.close();
69
+ this.#watchers.delete(watcher);
70
+ };
71
+ }
72
+ async close() {
73
+ if (this.#closed)
74
+ return;
75
+ this.#closed = true;
76
+ for (const watcher of this.#watchers)
77
+ watcher.close();
78
+ this.#watchers.clear();
79
+ }
80
+ #assertOpen() {
81
+ if (this.#closed)
82
+ throw new Error('NativeDevelopmentModuleRuntime is closed');
83
+ }
84
+ }
85
+ export function supportsNativeTypeScript(version = process.versions.node, execArguments = process.execArgv, nodeOptions = process.env.NODE_OPTIONS ?? '') {
86
+ if (execArguments.includes('--experimental-strip-types')
87
+ || /(?:^|\s)--experimental-strip-types(?:\s|$)/u.test(nodeOptions))
88
+ return true;
89
+ const [major = 0, minor = 0] = version.split('.').map(Number);
90
+ return major > 23 || (major === 23 && minor >= 6) || (major === 22 && minor >= 18);
91
+ }
92
+ export function assertNativeTypeScriptSupport() {
93
+ if (supportsNativeTypeScript())
94
+ return;
95
+ throw new Error([
96
+ `Node ${process.versions.node} does not enable native TypeScript by default.`,
97
+ 'Use Node >=22.18.0 or start Node with --experimental-strip-types.',
98
+ ].join(' '));
99
+ }
100
+ class PortableSourceWatcher {
101
+ root;
102
+ listener;
103
+ #watcher;
104
+ #pollTimer;
105
+ #snapshot;
106
+ #closed = false;
107
+ constructor(root, listener) {
108
+ this.root = root;
109
+ this.listener = listener;
110
+ this.#snapshot = sourceSnapshot(root);
111
+ this.#startNativeWatcher();
112
+ }
113
+ close() {
114
+ if (this.#closed)
115
+ return;
116
+ this.#closed = true;
117
+ this.#watcher?.close();
118
+ if (this.#pollTimer)
119
+ clearInterval(this.#pollTimer);
120
+ }
121
+ #startNativeWatcher() {
122
+ try {
123
+ this.#watcher = watchDirectory(this.root, { recursive: true }, (_event, name) => {
124
+ if (!name)
125
+ return;
126
+ const source = resolve(this.root, name.toString());
127
+ if (isWatchedSource(source))
128
+ this.listener(source);
129
+ });
130
+ this.#watcher.on('error', () => this.#startPolling());
131
+ }
132
+ catch {
133
+ this.#startPolling();
134
+ }
135
+ }
136
+ #startPolling() {
137
+ if (this.#closed || this.#pollTimer)
138
+ return;
139
+ this.#watcher?.close();
140
+ this.#watcher = undefined;
141
+ this.#pollTimer = setInterval(() => {
142
+ const next = sourceSnapshot(this.root);
143
+ const sources = new Set([...this.#snapshot.keys(), ...next.keys()]);
144
+ for (const source of sources) {
145
+ if (this.#snapshot.get(source) !== next.get(source))
146
+ this.listener(source);
147
+ }
148
+ this.#snapshot = next;
149
+ }, 100);
150
+ }
151
+ }
152
+ function sourceSnapshot(root) {
153
+ const result = new Map();
154
+ const visit = (directory) => {
155
+ let entries;
156
+ try {
157
+ entries = readdirSync(directory, { withFileTypes: true });
158
+ }
159
+ catch {
160
+ return;
161
+ }
162
+ for (const entry of entries) {
163
+ if (entry.isDirectory() && !ignoredDirectories.has(entry.name)) {
164
+ visit(resolve(directory, entry.name));
165
+ }
166
+ else if (entry.isFile()) {
167
+ const source = resolve(directory, entry.name);
168
+ if (!isWatchedSource(source))
169
+ continue;
170
+ try {
171
+ result.set(source, statSync(source).mtimeMs);
172
+ }
173
+ catch { /* The next poll reports a concurrent unlink. */ }
174
+ }
175
+ }
176
+ };
177
+ visit(root);
178
+ return result;
179
+ }
180
+ function isWatchedSource(source) {
181
+ const name = source.slice(source.lastIndexOf(sep) + 1);
182
+ return watchedExtensions.has(extname(source)) || name.startsWith('.env');
183
+ }
184
+ function isExecutableSource(source) {
185
+ return ['.cjs', '.js', '.mjs', '.ts', '.tsx'].includes(extname(source));
186
+ }
187
+ function isWithin(root, source) {
188
+ const child = relative(root, source);
189
+ return child === '' || (!child.startsWith('..') && !isAbsolute(child));
190
+ }
@@ -0,0 +1,10 @@
1
+ import type { ClientModuleRequest, DirectoryEntry, DiscoveryHost } from '@zhin.js/feature-kit';
2
+ import type { ModuleRuntime } from './module-runtime.js';
3
+ export declare class NodeDiscoveryHost implements DiscoveryHost {
4
+ private readonly modules;
5
+ constructor(modules: ModuleRuntime);
6
+ list(directory: string): Promise<readonly DirectoryEntry[]>;
7
+ loadModule<T = unknown>(source: string): Promise<T>;
8
+ loadClientModule<T = unknown>(source: string, request: ClientModuleRequest): Promise<T>;
9
+ readText(source: string): Promise<string>;
10
+ }
@@ -0,0 +1,37 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ export class NodeDiscoveryHost {
3
+ modules;
4
+ constructor(modules) {
5
+ this.modules = modules;
6
+ }
7
+ async list(directory) {
8
+ try {
9
+ const entries = await readdir(directory, { withFileTypes: true });
10
+ const result = [];
11
+ for (const entry of entries) {
12
+ if (entry.isFile())
13
+ result.push({ name: entry.name, kind: 'file' });
14
+ if (entry.isDirectory())
15
+ result.push({ name: entry.name, kind: 'directory' });
16
+ }
17
+ return result;
18
+ }
19
+ catch (error) {
20
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
21
+ return [];
22
+ throw error;
23
+ }
24
+ }
25
+ loadModule(source) {
26
+ return this.modules.load(source);
27
+ }
28
+ loadClientModule(source, request) {
29
+ if (!this.modules.loadClientModule) {
30
+ throw new Error(`Client Module adapter is required to load ${request.feature}:${request.localName}`);
31
+ }
32
+ return this.modules.loadClientModule(source, request);
33
+ }
34
+ readText(source) {
35
+ return readFile(source, 'utf8');
36
+ }
37
+ }