@alchemy.run/node-utils 2.0.0-beta.77 → 2.0.0-beta.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,15 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { resolveTsconfig } from "rolldown/experimental";
3
5
  import {
4
6
  parseSync,
5
7
  transformSync,
6
8
  TsconfigCache,
7
9
  type TransformOptions,
8
10
  } from "rolldown/utils";
9
- import type { ImportLoaderOptions, TransformContext } from "./import-loader.ts";
11
+ import type { OxcLoaderOptions } from "./register-oxc.ts";
12
+ import { resolveCacheDirectory, TransformCache } from "./transform-cache.ts";
10
13
 
11
14
  /** Extensions Oxc transpiles; everything else is JavaScript Node can run. */
12
15
  export const transformExtensions = new Set([
@@ -77,22 +80,93 @@ const language = (filePath: string): TransformOptions["lang"] => {
77
80
  }
78
81
  };
79
82
 
80
- const sourceMapComment = (map: string | object) => {
83
+ /**
84
+ * Inline map, for when there is no cache file to point at. Only the
85
+ * fallback: the base64 becomes part of the script source V8 retains, and
86
+ * for a non-ASCII source it is stored two bytes per character on top.
87
+ */
88
+ const inlineSourceMapComment = (map: string | object) => {
81
89
  const json = typeof map === "string" ? map : JSON.stringify(map);
82
90
  return `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(json).toString("base64")}`;
83
91
  };
84
92
 
93
+ /**
94
+ * Map by reference. Node's source-map support only understands `data:`
95
+ * URLs and scheme-less paths (it resolves the latter against the module
96
+ * URL and reads the file), so this is the file URL's path component —
97
+ * `/var/…/x.map` on POSIX, `/C:/…/x.map` on Windows — never a `file:` URL.
98
+ */
99
+ const fileSourceMapComment = (mapFile: string) =>
100
+ `\n//# sourceMappingURL=${pathToFileURL(mapFile).pathname}`;
101
+
102
+ /**
103
+ * The transform's map without `sourcesContent`. Every source is a file on
104
+ * this machine, named by the map's `sources`, so embedding its text only
105
+ * makes the map larger than the code it describes and every process that
106
+ * loads the module pay for it.
107
+ */
108
+ const withoutSourcesContent = ({
109
+ sourcesContent: _,
110
+ ...map
111
+ }: NonNullable<ReturnType<typeof transformSync>["map"]>) => map;
112
+
85
113
  export interface TransformedSource {
86
114
  readonly format: ModuleFormat;
87
115
  readonly source: string;
88
116
  }
89
117
 
90
118
  export class SourceTransformer {
91
- readonly #options: ImportLoaderOptions;
119
+ readonly #options: OxcLoaderOptions;
92
120
  readonly #tsconfigCache = new TsconfigCache();
121
+ readonly #cache: TransformCache | undefined;
93
122
 
94
- constructor(options: ImportLoaderOptions) {
123
+ constructor(options: OxcLoaderOptions) {
95
124
  this.#options = options;
125
+ const directory = resolveCacheDirectory(options.cache);
126
+ this.#cache =
127
+ directory === undefined ? undefined : new TransformCache(directory);
128
+ }
129
+
130
+ /**
131
+ * Cache key for one transform, or `undefined` when the result must not be
132
+ * cached. Every input Oxc's output depends on is part of it: the file's
133
+ * path (source maps name it), its size and mtime standing in for its
134
+ * contents — a stat instead of a read plus a hash per module on the warm
135
+ * path — the effective transform options, Node's module format, and the
136
+ * tsconfig that would be discovered for the file, resolved through the
137
+ * same cache `transformSync` uses with the `extends` chain already
138
+ * merged, so editing any tsconfig in the chain is a new key.
139
+ */
140
+ #cacheKey(
141
+ filePath: string,
142
+ options: TransformOptions,
143
+ format: ModuleFormat,
144
+ ): string | undefined {
145
+ if (this.#cache === undefined) return undefined;
146
+ let stat: { size: bigint; mtimeNs: bigint };
147
+ try {
148
+ stat = statSync(filePath, { bigint: true });
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ let tsconfig: unknown = null;
153
+ try {
154
+ if (options.tsconfig === true) {
155
+ tsconfig = resolveTsconfig(filePath, this.#tsconfigCache)?.tsconfig;
156
+ } else if (typeof options.tsconfig === "string") {
157
+ tsconfig = readFileSync(options.tsconfig, "utf8");
158
+ }
159
+ } catch {
160
+ // A broken tsconfig is the transform's error to report; don't cache.
161
+ return undefined;
162
+ }
163
+ return this.#cache.key([
164
+ filePath,
165
+ `${stat.size}:${stat.mtimeNs}`,
166
+ JSON.stringify(options),
167
+ JSON.stringify(tsconfig ?? null),
168
+ format,
169
+ ]);
96
170
  }
97
171
 
98
172
  /**
@@ -102,69 +176,79 @@ export class SourceTransformer {
102
176
  */
103
177
  transform(
104
178
  filePath: string,
105
- url: string,
106
179
  format: string | null | undefined,
107
180
  ): TransformedSource | undefined {
108
181
  const extension = path.extname(filePath);
109
- const transpile = transformExtensions.has(extension);
110
- if (!transpile && this.#options.transforms === undefined) return undefined;
182
+ if (!transformExtensions.has(extension)) return undefined;
111
183
 
112
184
  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;
185
+ const lang = language(filePath);
186
+ const options: TransformOptions = {
187
+ tsconfig: this.#options.tsconfig ?? true,
188
+ sourcemap: true,
189
+ lang,
190
+ };
191
+ // The key is taken before the source is read so a hit costs one stat
192
+ // and one cache read, never the source itself.
193
+ const key = this.#cacheKey(filePath, options, moduleFormat);
194
+ const cached = key === undefined ? undefined : this.#cache?.get(key);
195
+ if (cached !== undefined) {
196
+ return {
197
+ format: cached.format,
198
+ source:
199
+ cached.mapFile === undefined
200
+ ? cached.code
201
+ : cached.code + fileSourceMapComment(cached.mapFile),
202
+ };
150
203
  }
151
-
152
- const context: TransformContext = {
153
- url,
154
- path: filePath,
204
+ const source = readFileSync(filePath, "utf8");
205
+ // A `.ts` file in a CommonJS package that uses `import`/`export` runs
206
+ // as ESM — the same call Node's own module-syntax detection makes for
207
+ // `.js`. Explicit `.cts` stays CommonJS regardless.
208
+ if (
209
+ moduleFormat === "commonjs" &&
210
+ extension !== ".cts" &&
211
+ parseSync(filePath, source, { lang, sourceType: "unambiguous" }).module
212
+ .hasModuleSyntax
213
+ ) {
214
+ moduleFormat = "module";
215
+ }
216
+ const transformed = transformSync(
217
+ filePath,
218
+ source,
219
+ { ...options, sourceType: moduleFormat },
220
+ this.#tsconfigCache,
221
+ );
222
+ if (transformed.errors.length > 0) {
223
+ const [error] = transformed.errors;
224
+ throw error instanceof Error
225
+ ? error
226
+ : new SyntaxError(
227
+ `${filePath}: ${(error as { message?: string }).message ?? String(error)}`,
228
+ );
229
+ }
230
+ const map =
231
+ transformed.map === undefined
232
+ ? undefined
233
+ : JSON.stringify(withoutSourcesContent(transformed.map));
234
+ // The map ends up in the module exactly one way: on disk next to the
235
+ // cache entry and referenced by path, or (cache off) inlined.
236
+ const mapFile =
237
+ key === undefined || map === undefined
238
+ ? undefined
239
+ : this.#cache?.set(key, {
240
+ format: moduleFormat,
241
+ code: transformed.code,
242
+ map,
243
+ });
244
+ return {
155
245
  format: moduleFormat,
246
+ source:
247
+ mapFile !== undefined
248
+ ? transformed.code + fileSourceMapComment(mapFile)
249
+ : map !== undefined
250
+ ? transformed.code + inlineSourceMapComment(map)
251
+ : transformed.code,
156
252
  };
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
253
  }
170
254
  }
@@ -53,9 +53,14 @@ export class BunImportTracker {
53
53
  // Bun reports real paths (`/private/tmp/...` for `/tmp/...` on macOS);
54
54
  // match them against the root's real path too.
55
55
  const root = realpathSync.native(path.resolve(options.root)) + path.sep;
56
- const nodeModules = `${path.sep}node_modules${path.sep}`;
56
+ const nodeModules = `node_modules${path.sep}`;
57
+ // `root` already ends in a separator, so the segment right after it has no
58
+ // leading one: a `.*${sep}node_modules${sep}` lookahead alone would miss
59
+ // `<root>node_modules/...` and intercept every dependency. Bun cannot
60
+ // return CommonJS source from `onLoad` (oven-sh/bun#19279), so a CJS
61
+ // dependency that reaches this probe loses its exports.
57
62
  const filter = new RegExp(
58
- `^${escapeRegExp(root)}(?!.*${escapeRegExp(nodeModules)}).*\\.[cm]?[jt]sx?$`,
63
+ `^${escapeRegExp(root)}(?!(?:.*${escapeRegExp(path.sep)})?${escapeRegExp(nodeModules)}).*\\.[cm]?[jt]sx?$`,
59
64
  );
60
65
  Bun.plugin({
61
66
  name: "@alchemy.run/node-utils/watch-import-bun",
@@ -5,11 +5,7 @@ import {
5
5
  type DependencyChangeListener,
6
6
  type DependencyWatcherOptions,
7
7
  } from "./dependency-watcher.ts";
8
- import {
9
- createImportLoader,
10
- type ImportLoader,
11
- type ImportLoaderOptions,
12
- } from "./import-loader.ts";
8
+ import type { OxcLoader, OxcLoaderOptions } from "./register-oxc.ts";
13
9
 
14
10
  export interface ImportGeneration<T> {
15
11
  readonly value: T;
@@ -18,7 +14,7 @@ export interface ImportGeneration<T> {
18
14
  }
19
15
 
20
16
  export interface ImportWatcherOptions
21
- extends ImportLoaderOptions, DependencyWatcherOptions {
17
+ extends OxcLoaderOptions, DependencyWatcherOptions {
22
18
  readonly parentURL: string;
23
19
  }
24
20
 
@@ -32,7 +28,7 @@ export class ImportWatcher<T = unknown> {
32
28
  readonly #specifier: string;
33
29
  readonly #options: ImportWatcherOptions;
34
30
  readonly #watcher: DependencyWatcher;
35
- #registration: ImportLoader | undefined;
31
+ #registration: OxcLoader | undefined;
36
32
  #dependencies = new Set<string>();
37
33
  #closed = false;
38
34
 
@@ -60,7 +56,10 @@ export class ImportWatcher<T = unknown> {
60
56
  watch: _watch,
61
57
  ...registerOptions
62
58
  } = this.#options;
63
- const registration = await createImportLoader({
59
+ // Loaded here, not at module scope: the exec child imports this file on
60
+ // both runtimes, and the loader's Node hooks do not exist under Bun.
61
+ const { registerOxc } = await import("./register-oxc.ts");
62
+ const registration = registerOxc({
64
63
  ...registerOptions,
65
64
  namespace,
66
65
  onImport: (url) => {
@@ -1,54 +0,0 @@
1
- import type { TransformOptions } from "rolldown/utils";
2
- export interface TransformContext {
3
- readonly url: string;
4
- readonly path: string;
5
- readonly format: "module" | "commonjs";
6
- }
7
- export interface SourceTransformResult {
8
- readonly code: string;
9
- readonly map?: string | object | undefined;
10
- }
11
- export type SourceTransform = (code: string, context: TransformContext) => string | SourceTransformResult | undefined;
12
- export interface ImportLoaderOptions {
13
- /**
14
- * Additional package export conditions used during module resolution.
15
- * They are made available alongside Node's ambient conditions to both the
16
- * TypeScript-aware resolver and Node's package exports resolver.
17
- */
18
- readonly conditions?: ReadonlyArray<string> | undefined;
19
- /**
20
- * Oxc transform configuration, layered over the nearest `tsconfig.json`
21
- * of each transformed file (`jsx`, decorators, …).
22
- */
23
- readonly transform?: TransformOptions | undefined;
24
- /** Additional synchronous source transforms, applied after Oxc. */
25
- readonly transforms?: ReadonlyArray<SourceTransform> | undefined;
26
- /**
27
- * Honour `tsconfig.json` discovered upward from each file: compiler
28
- * options for the transform, `paths`/`baseUrl` aliases for resolution.
29
- * @default true
30
- */
31
- readonly tsconfig?: boolean | undefined;
32
- /** Controls which file URLs belong to the fresh import graph. */
33
- readonly shouldInvalidate?: ((url: string, parentURL: string | undefined) => boolean) | undefined;
34
- /**
35
- * Limits transformation to matching absolute file paths; everything else
36
- * loads through Node untouched. Lets a published install transpile only
37
- * the user's own TypeScript while alchemy and its dependencies run their
38
- * built JavaScript.
39
- */
40
- readonly filter?: ((path: string) => boolean) | undefined;
41
- }
42
- export interface ImportLoaderRegistrationOptions extends ImportLoaderOptions {
43
- /** Isolates one import graph in the runtime's module cache. */
44
- readonly namespace?: string | undefined;
45
- /** Called once the runtime loads a file in this registration's graph. */
46
- readonly onImport?: ((url: string) => void) | undefined;
47
- }
48
- export interface ImportLoader {
49
- import<T = unknown>(specifier: string, parentURL: string): Promise<T>;
50
- unregister(): void | Promise<void>;
51
- }
52
- /** Creates a Node import loader using synchronous module hooks backed by Oxc. */
53
- export declare const createImportLoader: (options?: ImportLoaderRegistrationOptions) => Promise<ImportLoader>;
54
- //# sourceMappingURL=import-loader.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"import-loader.d.ts","sourceRoot":"","sources":["../src/import-loader.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEvD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,UAAU,CAAC;CACxC;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAC5C;AAED,MAAM,MAAM,eAAe,GAAG,CAC5B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,gBAAgB,KACtB,MAAM,GAAG,qBAAqB,GAAG,SAAS,CAAC;AAEhD,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC;IACxD;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAClD,mEAAmE;IACnE,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAC,eAAe,CAAC,GAAG,SAAS,CAAC;IACjE;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACxC,iEAAiE;IACjE,QAAQ,CAAC,gBAAgB,CAAC,EACtB,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,SAAS,KAAK,OAAO,CAAC,GACzD,SAAS,CAAC;IACd;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,SAAS,CAAC;CAC3D;AAED,MAAM,WAAW,+BAAgC,SAAQ,mBAAmB;IAC1E,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,SAAS,CAAC;CACzD;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,CAAC,GAAG,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACtE,UAAU,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAED,iFAAiF;AACjF,eAAO,MAAM,kBAAkB,aACpB,+BAA+B,KACvC,OAAO,CAAC,YAAY,CAQtB,CAAC"}
@@ -1,9 +0,0 @@
1
- /** Creates a Node import loader using synchronous module hooks backed by Oxc. */
2
- export const createImportLoader = async (options = {}) => {
3
- if (process.versions.bun !== undefined) {
4
- throw new Error("The import-aware loader is only available in Node; use Bun's process-level watcher instead.");
5
- }
6
- const { registerOxc } = await import("./register-oxc.js");
7
- return registerOxc(options);
8
- };
9
- //# sourceMappingURL=import-loader.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"import-loader.js","sourceRoot":"","sources":["../src/import-loader.ts"],"names":[],"mappings":"AA+DA,iFAAiF;AACjF,MAAM,CAAC,MAAM,kBAAkB,GAAG,KAAK,EACrC,OAAO,GAAoC,EAAE,EACtB,EAAE;IACzB,IAAI,OAAO,CAAC,QAAQ,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;QACvC,MAAM,IAAI,KAAK,CACb,6FAA6F,CAC9F,CAAC;IACJ,CAAC;IACD,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,MAAM,CAAC,mBAAmB,CAAC,CAAC;IAC1D,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;AAC9B,CAAC,CAAC"}
@@ -1,75 +0,0 @@
1
- import type { TransformOptions } from "rolldown/utils";
2
-
3
- export interface TransformContext {
4
- readonly url: string;
5
- readonly path: string;
6
- readonly format: "module" | "commonjs";
7
- }
8
-
9
- export interface SourceTransformResult {
10
- readonly code: string;
11
- readonly map?: string | object | undefined;
12
- }
13
-
14
- export type SourceTransform = (
15
- code: string,
16
- context: TransformContext,
17
- ) => string | SourceTransformResult | undefined;
18
-
19
- export interface ImportLoaderOptions {
20
- /**
21
- * Additional package export conditions used during module resolution.
22
- * They are made available alongside Node's ambient conditions to both the
23
- * TypeScript-aware resolver and Node's package exports resolver.
24
- */
25
- readonly conditions?: ReadonlyArray<string> | undefined;
26
- /**
27
- * Oxc transform configuration, layered over the nearest `tsconfig.json`
28
- * of each transformed file (`jsx`, decorators, …).
29
- */
30
- readonly transform?: TransformOptions | undefined;
31
- /** Additional synchronous source transforms, applied after Oxc. */
32
- readonly transforms?: ReadonlyArray<SourceTransform> | undefined;
33
- /**
34
- * Honour `tsconfig.json` discovered upward from each file: compiler
35
- * options for the transform, `paths`/`baseUrl` aliases for resolution.
36
- * @default true
37
- */
38
- readonly tsconfig?: boolean | undefined;
39
- /** Controls which file URLs belong to the fresh import graph. */
40
- readonly shouldInvalidate?:
41
- | ((url: string, parentURL: string | undefined) => boolean)
42
- | undefined;
43
- /**
44
- * Limits transformation to matching absolute file paths; everything else
45
- * loads through Node untouched. Lets a published install transpile only
46
- * the user's own TypeScript while alchemy and its dependencies run their
47
- * built JavaScript.
48
- */
49
- readonly filter?: ((path: string) => boolean) | undefined;
50
- }
51
-
52
- export interface ImportLoaderRegistrationOptions extends ImportLoaderOptions {
53
- /** Isolates one import graph in the runtime's module cache. */
54
- readonly namespace?: string | undefined;
55
- /** Called once the runtime loads a file in this registration's graph. */
56
- readonly onImport?: ((url: string) => void) | undefined;
57
- }
58
-
59
- export interface ImportLoader {
60
- import<T = unknown>(specifier: string, parentURL: string): Promise<T>;
61
- unregister(): void | Promise<void>;
62
- }
63
-
64
- /** Creates a Node import loader using synchronous module hooks backed by Oxc. */
65
- export const createImportLoader = async (
66
- options: ImportLoaderRegistrationOptions = {},
67
- ): Promise<ImportLoader> => {
68
- if (process.versions.bun !== undefined) {
69
- throw new Error(
70
- "The import-aware loader is only available in Node; use Bun's process-level watcher instead.",
71
- );
72
- }
73
- const { registerOxc } = await import("./register-oxc.ts");
74
- return registerOxc(options);
75
- };