@alchemy.run/node-utils 2.0.0-beta.76 → 2.0.0-beta.77
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.
- package/THIRD_PARTY_LICENSES.md +11 -11
- package/lib/dependency-watcher.d.ts +28 -0
- package/lib/dependency-watcher.d.ts.map +1 -0
- package/lib/dependency-watcher.js +114 -0
- package/lib/dependency-watcher.js.map +1 -0
- package/lib/import-loader.d.ts +54 -0
- package/lib/import-loader.d.ts.map +1 -0
- package/lib/import-loader.js +9 -0
- package/lib/import-loader.js.map +1 -0
- package/lib/register-oxc.d.ts +16 -0
- package/lib/register-oxc.d.ts.map +1 -0
- package/lib/register-oxc.js +257 -0
- package/lib/register-oxc.js.map +1 -0
- package/lib/resolve-specifier.d.ts +52 -0
- package/lib/resolve-specifier.d.ts.map +1 -0
- package/lib/resolve-specifier.js +139 -0
- package/lib/resolve-specifier.js.map +1 -0
- package/lib/transform-source.d.ts +19 -0
- package/lib/transform-source.d.ts.map +1 -0
- package/lib/transform-source.js +140 -0
- package/lib/transform-source.js.map +1 -0
- package/lib/watch-import-bun.d.ts +30 -0
- package/lib/watch-import-bun.d.ts.map +1 -0
- package/lib/watch-import-bun.js +68 -0
- package/lib/watch-import-bun.js.map +1 -0
- package/lib/watch-import.d.ts +27 -0
- package/lib/watch-import.d.ts.map +1 -0
- package/lib/watch-import.js +77 -0
- package/lib/watch-import.js.map +1 -0
- package/package.json +26 -2
- package/src/dependency-watcher.ts +130 -0
- package/src/import-loader.ts +75 -0
- package/src/register-oxc.ts +342 -0
- package/src/resolve-specifier.ts +179 -0
- package/src/transform-source.ts +170 -0
- package/src/watch-import-bun.ts +94 -0
- package/src/watch-import.ts +107 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { ResolverFactory, type ResolveOptions } from "rolldown/experimental";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TypeScript-aware specifier resolution on top of Oxc's resolver, mirroring
|
|
7
|
+
* what tsx layers over Node's resolver:
|
|
8
|
+
*
|
|
9
|
+
* - `tsconfig.json` `paths` / `baseUrl` aliases, discovered per importing file
|
|
10
|
+
* - emitted-extension substitution (`./x.js` → `x.ts`/`x.tsx`, `.mjs` → `.mts`,
|
|
11
|
+
* `.cjs` → `.cts`, `.jsx` → `.tsx`), TypeScript source winning over an
|
|
12
|
+
* emitted sibling
|
|
13
|
+
* - extensionless specifiers and directory indexes, TypeScript first
|
|
14
|
+
*
|
|
15
|
+
* Node's own resolver stays the authority: every candidate this class
|
|
16
|
+
* produces is handed back to Node (`nextResolve`) so it decides the format,
|
|
17
|
+
* validates package exports, and reports the canonical error.
|
|
18
|
+
*/
|
|
19
|
+
export interface SpecifierResolverOptions {
|
|
20
|
+
/** Discover `tsconfig.json` upward from each importing file. */
|
|
21
|
+
readonly tsconfig: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const nodeModulesSegment = `${path.sep}node_modules${path.sep}`;
|
|
25
|
+
|
|
26
|
+
/** Local project code: a file path outside every `node_modules` directory. */
|
|
27
|
+
export const isProjectPath = (filePath: string) =>
|
|
28
|
+
!filePath.includes(nodeModulesSegment);
|
|
29
|
+
|
|
30
|
+
const typeScriptExtensions = /\.(?:[cm]?ts|[tj]sx)$/;
|
|
31
|
+
|
|
32
|
+
/** Whether TypeScript's extension substitution applies to this importer. */
|
|
33
|
+
export const isTypeScriptPath = (filePath: string) =>
|
|
34
|
+
typeScriptExtensions.test(filePath);
|
|
35
|
+
|
|
36
|
+
export const isFileLikeSpecifier = (specifier: string) =>
|
|
37
|
+
specifier.startsWith("./") ||
|
|
38
|
+
specifier.startsWith("../") ||
|
|
39
|
+
specifier === "." ||
|
|
40
|
+
specifier === ".." ||
|
|
41
|
+
specifier.startsWith("file:") ||
|
|
42
|
+
path.isAbsolute(specifier);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Splits `?query#fragment` metadata off a specifier. Bare specifiers never
|
|
46
|
+
* carry fragments in Node, so only `?` is honoured there.
|
|
47
|
+
*/
|
|
48
|
+
export const splitSpecifierMetadata = (specifier: string) => {
|
|
49
|
+
const index = isFileLikeSpecifier(specifier)
|
|
50
|
+
? specifier.search(/[?#]/)
|
|
51
|
+
: specifier.indexOf("?");
|
|
52
|
+
return index === -1
|
|
53
|
+
? { specifier, metadata: "" }
|
|
54
|
+
: {
|
|
55
|
+
specifier: specifier.slice(0, index),
|
|
56
|
+
metadata: specifier.slice(index),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const filePathOfUrl = (url: string | undefined) => {
|
|
61
|
+
if (url === undefined || !url.startsWith("file:")) return undefined;
|
|
62
|
+
try {
|
|
63
|
+
return fileURLToPath(new URL(url));
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export class SpecifierResolver {
|
|
70
|
+
readonly #options: ResolveOptions;
|
|
71
|
+
readonly #base: ResolverFactory;
|
|
72
|
+
readonly #byConditions = new Map<string, ResolverFactory>();
|
|
73
|
+
|
|
74
|
+
constructor(options: SpecifierResolverOptions) {
|
|
75
|
+
this.#options = {
|
|
76
|
+
tsconfig: options.tsconfig ? "auto" : undefined,
|
|
77
|
+
// TypeScript source first, then Node's implicit extensions.
|
|
78
|
+
extensions: [
|
|
79
|
+
".ts",
|
|
80
|
+
".tsx",
|
|
81
|
+
".mts",
|
|
82
|
+
".cts",
|
|
83
|
+
".jsx",
|
|
84
|
+
".js",
|
|
85
|
+
".mjs",
|
|
86
|
+
".cjs",
|
|
87
|
+
".json",
|
|
88
|
+
".node",
|
|
89
|
+
],
|
|
90
|
+
// TypeScript's emitted-extension substitution: `./x.js` may point at
|
|
91
|
+
// `x.ts` (source) or `x.js` (emitted); source wins when both exist.
|
|
92
|
+
extensionAlias: {
|
|
93
|
+
".js": [".ts", ".tsx", ".js"],
|
|
94
|
+
".jsx": [".tsx", ".jsx"],
|
|
95
|
+
".mjs": [".mts", ".mjs"],
|
|
96
|
+
".cjs": [".cts", ".cjs"],
|
|
97
|
+
},
|
|
98
|
+
mainFiles: ["index"],
|
|
99
|
+
builtinModules: true,
|
|
100
|
+
moduleType: true,
|
|
101
|
+
};
|
|
102
|
+
this.#base = new ResolverFactory(this.#options);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Bare specifiers are probed without following symlinks: a workspace
|
|
107
|
+
* package linked into `node_modules` must still read as a package (and be
|
|
108
|
+
* left to Node), not as the project file its real path points at.
|
|
109
|
+
*/
|
|
110
|
+
#resolver(conditions: ReadonlyArray<string>, symlinks: boolean) {
|
|
111
|
+
const key = `${symlinks} ${conditions.join(" ")}`;
|
|
112
|
+
let resolver = this.#byConditions.get(key);
|
|
113
|
+
if (resolver === undefined) {
|
|
114
|
+
// `cloneWithOptions` replaces the option set (sharing only the cache),
|
|
115
|
+
// so each conditions variant restates the base options.
|
|
116
|
+
resolver = this.#base.cloneWithOptions({
|
|
117
|
+
...this.#options,
|
|
118
|
+
symlinks,
|
|
119
|
+
conditionNames: [...conditions],
|
|
120
|
+
});
|
|
121
|
+
this.#byConditions.set(key, resolver);
|
|
122
|
+
}
|
|
123
|
+
return resolver;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolves `specifier` as imported from `parentPath` to an absolute file
|
|
128
|
+
* path, or `undefined` when Oxc cannot resolve it (Node then reports the
|
|
129
|
+
* error), when it is a builtin, or when a bare specifier lands inside
|
|
130
|
+
* `node_modules` — packages are Node's business, only `paths` aliases
|
|
131
|
+
* that map onto project files are ours.
|
|
132
|
+
*/
|
|
133
|
+
resolve(
|
|
134
|
+
parentPath: string,
|
|
135
|
+
specifier: string,
|
|
136
|
+
conditions: ReadonlyArray<string>,
|
|
137
|
+
): string | undefined {
|
|
138
|
+
const request = specifier.startsWith("file:")
|
|
139
|
+
? filePathOfUrl(specifier)
|
|
140
|
+
: specifier;
|
|
141
|
+
if (request === undefined) return undefined;
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = this.#resolver(
|
|
145
|
+
conditions,
|
|
146
|
+
isFileLikeSpecifier(request),
|
|
147
|
+
).resolveFileSync(parentPath, request);
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
if (result.path === undefined) return undefined;
|
|
152
|
+
if (!isFileLikeSpecifier(request) && !isProjectPath(result.path)) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
return result.path;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Given a file path Node failed to find (typically an `exports`/`main`
|
|
160
|
+
* target that names emitted JavaScript that was never built), finds the
|
|
161
|
+
* TypeScript source it was emitted from via extension substitution.
|
|
162
|
+
*/
|
|
163
|
+
resolveMissing(
|
|
164
|
+
missingPath: string,
|
|
165
|
+
conditions: ReadonlyArray<string>,
|
|
166
|
+
): string | undefined {
|
|
167
|
+
const directory = path.dirname(missingPath);
|
|
168
|
+
const base = path.basename(missingPath);
|
|
169
|
+
try {
|
|
170
|
+
const result = this.#resolver(conditions, true).sync(
|
|
171
|
+
directory,
|
|
172
|
+
`./${base}`,
|
|
173
|
+
);
|
|
174
|
+
return result.path;
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
parseSync,
|
|
5
|
+
transformSync,
|
|
6
|
+
TsconfigCache,
|
|
7
|
+
type TransformOptions,
|
|
8
|
+
} from "rolldown/utils";
|
|
9
|
+
import type { ImportLoaderOptions, TransformContext } from "./import-loader.ts";
|
|
10
|
+
|
|
11
|
+
/** Extensions Oxc transpiles; everything else is JavaScript Node can run. */
|
|
12
|
+
export const transformExtensions = new Set([
|
|
13
|
+
".ts",
|
|
14
|
+
".tsx",
|
|
15
|
+
".mts",
|
|
16
|
+
".cts",
|
|
17
|
+
".jsx",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export type ModuleFormat = "module" | "commonjs";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Module format from Node's `load` hook context. Node derives these from the
|
|
24
|
+
* extension and the nearest `package.json#type`; `*-typescript` variants are
|
|
25
|
+
* its TypeScript-aware spellings and mean the same thing.
|
|
26
|
+
*/
|
|
27
|
+
const nodeFormat = (
|
|
28
|
+
format: string | null | undefined,
|
|
29
|
+
): ModuleFormat | undefined => {
|
|
30
|
+
switch (format) {
|
|
31
|
+
case "module":
|
|
32
|
+
case "module-typescript":
|
|
33
|
+
return "module";
|
|
34
|
+
case "commonjs":
|
|
35
|
+
case "commonjs-typescript":
|
|
36
|
+
return "commonjs";
|
|
37
|
+
default:
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Fallback for older Nodes that pass no format: extension, then package type. */
|
|
43
|
+
const inferFormat = (filePath: string): ModuleFormat => {
|
|
44
|
+
const extension = path.extname(filePath);
|
|
45
|
+
if (extension === ".mts" || extension === ".mjs") return "module";
|
|
46
|
+
if (extension === ".cts" || extension === ".cjs") return "commonjs";
|
|
47
|
+
let directory = path.dirname(filePath);
|
|
48
|
+
while (true) {
|
|
49
|
+
const packageJson = path.join(directory, "package.json");
|
|
50
|
+
if (existsSync(packageJson)) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(packageJson, "utf8")).type === "module"
|
|
53
|
+
? "module"
|
|
54
|
+
: "commonjs";
|
|
55
|
+
} catch {
|
|
56
|
+
return "commonjs";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const parent = path.dirname(directory);
|
|
60
|
+
if (parent === directory) return "commonjs";
|
|
61
|
+
directory = parent;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const language = (filePath: string): TransformOptions["lang"] => {
|
|
66
|
+
switch (path.extname(filePath)) {
|
|
67
|
+
case ".tsx":
|
|
68
|
+
return "tsx";
|
|
69
|
+
case ".ts":
|
|
70
|
+
case ".mts":
|
|
71
|
+
case ".cts":
|
|
72
|
+
return "ts";
|
|
73
|
+
case ".jsx":
|
|
74
|
+
return "jsx";
|
|
75
|
+
default:
|
|
76
|
+
return "js";
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const sourceMapComment = (map: string | object) => {
|
|
81
|
+
const json = typeof map === "string" ? map : JSON.stringify(map);
|
|
82
|
+
return `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(json).toString("base64")}`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export interface TransformedSource {
|
|
86
|
+
readonly format: ModuleFormat;
|
|
87
|
+
readonly source: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class SourceTransformer {
|
|
91
|
+
readonly #options: ImportLoaderOptions;
|
|
92
|
+
readonly #tsconfigCache = new TsconfigCache();
|
|
93
|
+
|
|
94
|
+
constructor(options: ImportLoaderOptions) {
|
|
95
|
+
this.#options = options;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Transpiles `filePath` for Node, or returns `undefined` when the file is
|
|
100
|
+
* JavaScript that needs no work. `format` is what Node's `load` hook was
|
|
101
|
+
* told; it decides `sourceType` and the format handed back.
|
|
102
|
+
*/
|
|
103
|
+
transform(
|
|
104
|
+
filePath: string,
|
|
105
|
+
url: string,
|
|
106
|
+
format: string | null | undefined,
|
|
107
|
+
): TransformedSource | undefined {
|
|
108
|
+
const extension = path.extname(filePath);
|
|
109
|
+
const transpile = transformExtensions.has(extension);
|
|
110
|
+
if (!transpile && this.#options.transforms === undefined) return undefined;
|
|
111
|
+
|
|
112
|
+
let moduleFormat = nodeFormat(format) ?? inferFormat(filePath);
|
|
113
|
+
let source = readFileSync(filePath, "utf8");
|
|
114
|
+
let map: string | object | undefined;
|
|
115
|
+
if (transpile) {
|
|
116
|
+
const lang = this.#options.transform?.lang ?? language(filePath);
|
|
117
|
+
// A `.ts` file in a CommonJS package that uses `import`/`export` runs
|
|
118
|
+
// as ESM — the same call Node's own module-syntax detection makes for
|
|
119
|
+
// `.js`. Explicit `.cts` stays CommonJS regardless.
|
|
120
|
+
if (
|
|
121
|
+
moduleFormat === "commonjs" &&
|
|
122
|
+
extension !== ".cts" &&
|
|
123
|
+
parseSync(filePath, source, { lang, sourceType: "unambiguous" }).module
|
|
124
|
+
.hasModuleSyntax
|
|
125
|
+
) {
|
|
126
|
+
moduleFormat = "module";
|
|
127
|
+
}
|
|
128
|
+
const transformed = transformSync(
|
|
129
|
+
filePath,
|
|
130
|
+
source,
|
|
131
|
+
{
|
|
132
|
+
tsconfig: this.#options.tsconfig ?? true,
|
|
133
|
+
sourcemap: true,
|
|
134
|
+
...this.#options.transform,
|
|
135
|
+
lang,
|
|
136
|
+
sourceType: this.#options.transform?.sourceType ?? moduleFormat,
|
|
137
|
+
},
|
|
138
|
+
this.#tsconfigCache,
|
|
139
|
+
);
|
|
140
|
+
if (transformed.errors.length > 0) {
|
|
141
|
+
const [error] = transformed.errors;
|
|
142
|
+
throw error instanceof Error
|
|
143
|
+
? error
|
|
144
|
+
: new SyntaxError(
|
|
145
|
+
`${filePath}: ${(error as { message?: string }).message ?? String(error)}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
source = transformed.code;
|
|
149
|
+
map = transformed.map;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const context: TransformContext = {
|
|
153
|
+
url,
|
|
154
|
+
path: filePath,
|
|
155
|
+
format: moduleFormat,
|
|
156
|
+
};
|
|
157
|
+
for (const transform of this.#options.transforms ?? []) {
|
|
158
|
+
const result = transform(source, context);
|
|
159
|
+
if (typeof result === "string") {
|
|
160
|
+
source = result;
|
|
161
|
+
map = undefined;
|
|
162
|
+
} else if (result !== undefined) {
|
|
163
|
+
source = result.code;
|
|
164
|
+
map = result.map;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (map !== undefined) source += sourceMapComment(map);
|
|
168
|
+
return { format: moduleFormat, source };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
DependencyWatcher,
|
|
5
|
+
type DependencyChangeListener,
|
|
6
|
+
type DependencyWatcherOptions,
|
|
7
|
+
} from "./dependency-watcher.ts";
|
|
8
|
+
|
|
9
|
+
export interface BunImportTrackerOptions extends DependencyWatcherOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Directory whose modules belong to the tracked graph. Files outside it and
|
|
12
|
+
* anything under a `node_modules` directory load untouched.
|
|
13
|
+
*/
|
|
14
|
+
readonly root: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const loaders: Record<string, "js" | "jsx" | "ts" | "tsx"> = {
|
|
18
|
+
".js": "js",
|
|
19
|
+
".mjs": "js",
|
|
20
|
+
".cjs": "js",
|
|
21
|
+
".jsx": "jsx",
|
|
22
|
+
".ts": "ts",
|
|
23
|
+
".mts": "ts",
|
|
24
|
+
".cts": "ts",
|
|
25
|
+
".tsx": "tsx",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const escapeRegExp = (value: string) =>
|
|
29
|
+
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Records every project-local module Bun loads after registration and watches
|
|
33
|
+
* those files for changes.
|
|
34
|
+
*
|
|
35
|
+
* Bun has no loader hooks that can evict or re-namespace an evaluated module,
|
|
36
|
+
* so unlike Node's {@link ImportWatcher} this cannot import a fresh
|
|
37
|
+
* generation in-process. A runtime `Bun.plugin` `onLoad` hook is used purely
|
|
38
|
+
* as a dependency probe: it hands the source back unchanged with the loader
|
|
39
|
+
* Bun would have picked itself. Callers react to a change by exiting so a
|
|
40
|
+
* supervisor can start a fresh process.
|
|
41
|
+
*/
|
|
42
|
+
export class BunImportTracker {
|
|
43
|
+
readonly #watcher: DependencyWatcher;
|
|
44
|
+
readonly #dependencies = new Set<string>();
|
|
45
|
+
|
|
46
|
+
constructor(options: BunImportTrackerOptions) {
|
|
47
|
+
if (process.versions.bun === undefined) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"BunImportTracker requires Bun; Node callers should use watchImport.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
this.#watcher = new DependencyWatcher(options);
|
|
53
|
+
// Bun reports real paths (`/private/tmp/...` for `/tmp/...` on macOS);
|
|
54
|
+
// match them against the root's real path too.
|
|
55
|
+
const root = realpathSync.native(path.resolve(options.root)) + path.sep;
|
|
56
|
+
const nodeModules = `${path.sep}node_modules${path.sep}`;
|
|
57
|
+
const filter = new RegExp(
|
|
58
|
+
`^${escapeRegExp(root)}(?!.*${escapeRegExp(nodeModules)}).*\\.[cm]?[jt]sx?$`,
|
|
59
|
+
);
|
|
60
|
+
Bun.plugin({
|
|
61
|
+
name: "@alchemy.run/node-utils/watch-import-bun",
|
|
62
|
+
setup: (build) => {
|
|
63
|
+
build.onLoad({ filter }, async (args) => {
|
|
64
|
+
this.#dependencies.add(args.path);
|
|
65
|
+
this.#watcher.set(new Set(this.#dependencies));
|
|
66
|
+
return {
|
|
67
|
+
contents: await Bun.file(args.path).text(),
|
|
68
|
+
loader: loaders[path.extname(args.path)] ?? "js",
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get dependencies(): ReadonlySet<string> {
|
|
76
|
+
return this.#watcher.dependencies;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
subscribe(listener: DependencyChangeListener): () => void {
|
|
80
|
+
return this.#watcher.subscribe(listener);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Stops watching. The load hook stays registered but only echoes sources. */
|
|
84
|
+
close(): Promise<void> {
|
|
85
|
+
return this.#watcher.close();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
89
|
+
await this.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const trackBunImports = (options: BunImportTrackerOptions) =>
|
|
94
|
+
new BunImportTracker(options);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import {
|
|
4
|
+
DependencyWatcher,
|
|
5
|
+
type DependencyChangeListener,
|
|
6
|
+
type DependencyWatcherOptions,
|
|
7
|
+
} from "./dependency-watcher.ts";
|
|
8
|
+
import {
|
|
9
|
+
createImportLoader,
|
|
10
|
+
type ImportLoader,
|
|
11
|
+
type ImportLoaderOptions,
|
|
12
|
+
} from "./import-loader.ts";
|
|
13
|
+
|
|
14
|
+
export interface ImportGeneration<T> {
|
|
15
|
+
readonly value: T;
|
|
16
|
+
readonly namespace: string;
|
|
17
|
+
readonly dependencies: ReadonlySet<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ImportWatcherOptions
|
|
21
|
+
extends ImportLoaderOptions, DependencyWatcherOptions {
|
|
22
|
+
readonly parentURL: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Imports fresh Node module generations and watches the exact files loaded by
|
|
27
|
+
* the current generation. Bun callers should use `BunImportTracker` from
|
|
28
|
+
* `./watch-import-bun.ts`: Bun cannot evict evaluated modules, so a change
|
|
29
|
+
* there restarts the process instead of importing a new generation.
|
|
30
|
+
*/
|
|
31
|
+
export class ImportWatcher<T = unknown> {
|
|
32
|
+
readonly #specifier: string;
|
|
33
|
+
readonly #options: ImportWatcherOptions;
|
|
34
|
+
readonly #watcher: DependencyWatcher;
|
|
35
|
+
#registration: ImportLoader | undefined;
|
|
36
|
+
#dependencies = new Set<string>();
|
|
37
|
+
#closed = false;
|
|
38
|
+
|
|
39
|
+
constructor(specifier: string, options: ImportWatcherOptions) {
|
|
40
|
+
this.#specifier = specifier;
|
|
41
|
+
this.#options = options;
|
|
42
|
+
this.#watcher = new DependencyWatcher(options);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get dependencies(): ReadonlySet<string> {
|
|
46
|
+
return this.#dependencies;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
subscribe(listener: DependencyChangeListener): () => void {
|
|
50
|
+
return this.#watcher.subscribe(listener);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async import(): Promise<ImportGeneration<T>> {
|
|
54
|
+
if (this.#closed) throw new Error("ImportWatcher is closed");
|
|
55
|
+
const namespace = randomUUID();
|
|
56
|
+
const dependencies = new Set<string>();
|
|
57
|
+
const {
|
|
58
|
+
debounceMs: _,
|
|
59
|
+
parentURL,
|
|
60
|
+
watch: _watch,
|
|
61
|
+
...registerOptions
|
|
62
|
+
} = this.#options;
|
|
63
|
+
const registration = await createImportLoader({
|
|
64
|
+
...registerOptions,
|
|
65
|
+
namespace,
|
|
66
|
+
onImport: (url) => {
|
|
67
|
+
if (!url.startsWith("file:")) return;
|
|
68
|
+
dependencies.add(fileURLToPath(url));
|
|
69
|
+
// A lazy import evaluated after this generation became current
|
|
70
|
+
// extends the watched set immediately.
|
|
71
|
+
if (this.#dependencies === dependencies)
|
|
72
|
+
this.#watcher.set(dependencies);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
const value = await registration.import<T>(this.#specifier, parentURL);
|
|
77
|
+
await this.#registration?.unregister();
|
|
78
|
+
this.#registration = registration;
|
|
79
|
+
this.#dependencies = dependencies;
|
|
80
|
+
this.#watcher.set(dependencies);
|
|
81
|
+
return { value, namespace, dependencies };
|
|
82
|
+
} catch (error) {
|
|
83
|
+
await registration.unregister();
|
|
84
|
+
// Keep watching everything the failed import touched so the next save
|
|
85
|
+
// of any of those files retries.
|
|
86
|
+
this.#dependencies = new Set([...this.#dependencies, ...dependencies]);
|
|
87
|
+
this.#watcher.set(this.#dependencies);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async close(): Promise<void> {
|
|
93
|
+
if (this.#closed) return;
|
|
94
|
+
this.#closed = true;
|
|
95
|
+
await this.#registration?.unregister();
|
|
96
|
+
await this.#watcher.close();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
100
|
+
await this.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const watchImport = <T = unknown>(
|
|
105
|
+
specifier: string,
|
|
106
|
+
options: ImportWatcherOptions,
|
|
107
|
+
) => new ImportWatcher<T>(specifier, options);
|