@alchemy.run/node-utils 2.0.0-beta.75 → 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.
Files changed (37) hide show
  1. package/THIRD_PARTY_LICENSES.md +11 -11
  2. package/lib/dependency-watcher.d.ts +28 -0
  3. package/lib/dependency-watcher.d.ts.map +1 -0
  4. package/lib/dependency-watcher.js +114 -0
  5. package/lib/dependency-watcher.js.map +1 -0
  6. package/lib/import-loader.d.ts +54 -0
  7. package/lib/import-loader.d.ts.map +1 -0
  8. package/lib/import-loader.js +9 -0
  9. package/lib/import-loader.js.map +1 -0
  10. package/lib/register-oxc.d.ts +16 -0
  11. package/lib/register-oxc.d.ts.map +1 -0
  12. package/lib/register-oxc.js +257 -0
  13. package/lib/register-oxc.js.map +1 -0
  14. package/lib/resolve-specifier.d.ts +52 -0
  15. package/lib/resolve-specifier.d.ts.map +1 -0
  16. package/lib/resolve-specifier.js +139 -0
  17. package/lib/resolve-specifier.js.map +1 -0
  18. package/lib/transform-source.d.ts +19 -0
  19. package/lib/transform-source.d.ts.map +1 -0
  20. package/lib/transform-source.js +140 -0
  21. package/lib/transform-source.js.map +1 -0
  22. package/lib/watch-import-bun.d.ts +30 -0
  23. package/lib/watch-import-bun.d.ts.map +1 -0
  24. package/lib/watch-import-bun.js +68 -0
  25. package/lib/watch-import-bun.js.map +1 -0
  26. package/lib/watch-import.d.ts +27 -0
  27. package/lib/watch-import.d.ts.map +1 -0
  28. package/lib/watch-import.js +77 -0
  29. package/lib/watch-import.js.map +1 -0
  30. package/package.json +26 -2
  31. package/src/dependency-watcher.ts +130 -0
  32. package/src/import-loader.ts +75 -0
  33. package/src/register-oxc.ts +342 -0
  34. package/src/resolve-specifier.ts +179 -0
  35. package/src/transform-source.ts +170 -0
  36. package/src/watch-import-bun.ts +94 -0
  37. 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);