@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.
- package/lib/register-oxc.d.ts +54 -9
- package/lib/register-oxc.d.ts.map +1 -1
- package/lib/register-oxc.js +49 -86
- package/lib/register-oxc.js.map +1 -1
- package/lib/resolve-specifier.d.ts +0 -6
- package/lib/resolve-specifier.d.ts.map +1 -1
- package/lib/resolve-specifier.js +0 -16
- package/lib/resolve-specifier.js.map +1 -1
- package/lib/transform-cache.d.ts +63 -0
- package/lib/transform-cache.d.ts.map +1 -0
- package/lib/transform-cache.js +193 -0
- package/lib/transform-cache.js.map +1 -0
- package/lib/transform-source.d.ts +3 -3
- package/lib/transform-source.d.ts.map +1 -1
- package/lib/transform-source.js +123 -50
- package/lib/transform-source.js.map +1 -1
- package/lib/watch-import-bun.d.ts.map +1 -1
- package/lib/watch-import-bun.js +7 -2
- package/lib/watch-import-bun.js.map +1 -1
- package/lib/watch-import.d.ts +2 -2
- package/lib/watch-import.d.ts.map +1 -1
- package/lib/watch-import.js +4 -2
- package/lib/watch-import.js.map +1 -1
- package/package.json +1 -6
- package/src/register-oxc.ts +117 -122
- package/src/resolve-specifier.ts +0 -22
- package/src/transform-cache.ts +226 -0
- package/src/transform-source.ts +145 -61
- package/src/watch-import-bun.ts +7 -2
- package/src/watch-import.ts +7 -8
- package/lib/import-loader.d.ts +0 -54
- package/lib/import-loader.d.ts.map +0 -1
- package/lib/import-loader.js +0 -9
- package/lib/import-loader.js.map +0 -1
- package/src/import-loader.ts +0 -75
package/src/register-oxc.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as NodeModule from "node:module";
|
|
2
2
|
import {
|
|
3
3
|
registerHooks,
|
|
4
4
|
type LoadFnOutput,
|
|
@@ -7,10 +7,6 @@ import {
|
|
|
7
7
|
type ResolveHookContext,
|
|
8
8
|
} from "node:module";
|
|
9
9
|
import { pathToFileURL } from "node:url";
|
|
10
|
-
import type {
|
|
11
|
-
ImportLoader,
|
|
12
|
-
ImportLoaderRegistrationOptions,
|
|
13
|
-
} from "./import-loader.ts";
|
|
14
10
|
import {
|
|
15
11
|
filePathOfUrl,
|
|
16
12
|
isFileLikeSpecifier,
|
|
@@ -20,26 +16,69 @@ import {
|
|
|
20
16
|
} from "./resolve-specifier.ts";
|
|
21
17
|
import { SourceTransformer } from "./transform-source.ts";
|
|
22
18
|
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
export interface OxcLoaderOptions {
|
|
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
|
+
* 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?:
|
|
34
|
+
| ((url: string, parentURL: string | undefined) => boolean)
|
|
35
|
+
| undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Limits transformation to matching absolute file paths; everything else
|
|
38
|
+
* loads through Node untouched. Lets a published install transpile only
|
|
39
|
+
* the user's own TypeScript while alchemy and its dependencies run their
|
|
40
|
+
* built JavaScript.
|
|
41
|
+
*/
|
|
42
|
+
readonly filter?: ((path: string) => boolean) | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* On-disk cache of Oxc output shared by every process on the machine, so
|
|
45
|
+
* the CLI, its dev exec child and the local-provider sidecars transpile
|
|
46
|
+
* each source file once between them rather than once each. `false`
|
|
47
|
+
* disables it, a string names the directory.
|
|
48
|
+
* @default `$ALCHEMY_TRANSFORM_CACHE` (`0` disables), else a per-user
|
|
49
|
+
* directory under the OS temp directory
|
|
50
|
+
*/
|
|
51
|
+
readonly cache?: boolean | string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RegisterOxcOptions extends OxcLoaderOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Isolates one import graph in the runtime's module cache: every file
|
|
57
|
+
* URL the graph resolves carries this namespace as a query parameter, so
|
|
58
|
+
* the same files import again as fresh modules under a new namespace.
|
|
59
|
+
* This is how `alchemy dev` reloads the user's stack (see
|
|
60
|
+
* `watch-import.ts`); an un-namespaced registration is the process-wide
|
|
61
|
+
* TypeScript loader.
|
|
62
|
+
*/
|
|
63
|
+
readonly namespace?: string | undefined;
|
|
64
|
+
/** Called once the runtime loads a file in this registration's graph. */
|
|
65
|
+
readonly onImport?: ((url: string) => void) | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface OxcLoader {
|
|
69
|
+
/**
|
|
70
|
+
* Imports a file under this registration's namespace. `specifier` is a
|
|
71
|
+
* file URL, an absolute path, or a path relative to `parentURL`.
|
|
72
|
+
*/
|
|
73
|
+
import<T = unknown>(specifier: string, parentURL: string): Promise<T>;
|
|
74
|
+
unregister(): void;
|
|
75
|
+
}
|
|
30
76
|
|
|
31
|
-
const protocol = "alchemy-import:";
|
|
32
77
|
const namespaceParameter = "alchemy-import-namespace";
|
|
33
78
|
const globalRegistrationKey = Symbol.for(
|
|
34
79
|
"@alchemy.run/node-utils/register-oxc",
|
|
35
80
|
);
|
|
36
81
|
|
|
37
|
-
interface ImportRequest {
|
|
38
|
-
readonly namespace?: string | undefined;
|
|
39
|
-
readonly parentURL: string;
|
|
40
|
-
readonly specifier: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
82
|
type NextResolve = (
|
|
44
83
|
specifier: string,
|
|
45
84
|
context?: Partial<ResolveHookContext>,
|
|
@@ -63,45 +102,34 @@ const withNamespace = (url: string, namespace: string) => {
|
|
|
63
102
|
return parsed.href;
|
|
64
103
|
};
|
|
65
104
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Node's module compile cache (`module.enableCompileCache`) keeps V8 code
|
|
107
|
+
* cache for compiled modules — transformed TypeScript included, since it is
|
|
108
|
+
* keyed by the compiled source — but Node only persists it once after the
|
|
109
|
+
* entry module evaluated and again on a clean exit. Alchemy processes load
|
|
110
|
+
* most of their graph lazily after that point (commands, the user's stack,
|
|
111
|
+
* provider layers) and usually stop on a signal, so without an explicit
|
|
112
|
+
* flush that code never reaches the cache. Flush once module loading has
|
|
113
|
+
* gone quiet; a no-op when the cache is off or this Node predates it.
|
|
114
|
+
*/
|
|
115
|
+
const scheduleCompileCacheFlush = (() => {
|
|
116
|
+
let timer: NodeJS.Timeout | undefined;
|
|
117
|
+
return () => {
|
|
118
|
+
if (NodeModule.getCompileCacheDir?.() === undefined) return;
|
|
119
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
120
|
+
timer = setTimeout(() => {
|
|
121
|
+
timer = undefined;
|
|
122
|
+
NodeModule.flushCompileCache?.();
|
|
123
|
+
}, 1000);
|
|
124
|
+
timer.unref();
|
|
125
|
+
};
|
|
126
|
+
})();
|
|
70
127
|
|
|
71
128
|
/** Specifiers Node owns outright: builtins, data URLs, remote schemes. */
|
|
72
129
|
const isForeignSpecifier = (specifier: string) =>
|
|
73
130
|
/^(?:node:|data:|[a-z][a-z\d+.-]*:\/\/)/i.test(specifier) &&
|
|
74
131
|
!specifier.startsWith("file:");
|
|
75
132
|
|
|
76
|
-
const notFoundCodes = new Set([
|
|
77
|
-
"ERR_MODULE_NOT_FOUND",
|
|
78
|
-
"MODULE_NOT_FOUND",
|
|
79
|
-
"ERR_UNSUPPORTED_DIR_IMPORT",
|
|
80
|
-
"ERR_PACKAGE_PATH_NOT_EXPORTED",
|
|
81
|
-
]);
|
|
82
|
-
|
|
83
|
-
const isNotFound = (error: unknown): error is Error & { url?: string } =>
|
|
84
|
-
error instanceof Error &&
|
|
85
|
-
notFoundCodes.has((error as { code?: string }).code ?? "");
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* The file Node could not find, from the error it raised. Node names the
|
|
89
|
-
* resolved target (`url` on ESM errors, the message on CommonJS ones) —
|
|
90
|
-
* for a package `exports` entry pointing at emitted JavaScript that was
|
|
91
|
-
* never built, that is the `.js` path whose `.ts` source we can substitute.
|
|
92
|
-
*/
|
|
93
|
-
const missingPathOf = (error: Error & { url?: string }) => {
|
|
94
|
-
if (error.url !== undefined) return filePathOfUrl(error.url);
|
|
95
|
-
const match = error.message.match(/^Cannot find module '([^']+)'/);
|
|
96
|
-
if (match === null) return undefined;
|
|
97
|
-
const [, target] = match;
|
|
98
|
-
if (target === undefined) return undefined;
|
|
99
|
-
if (target.startsWith("file:")) return filePathOfUrl(target);
|
|
100
|
-
return target.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(target)
|
|
101
|
-
? target
|
|
102
|
-
: undefined;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
133
|
/** A `require()` reaching the hooks: Node's CommonJS resolver wants paths, not URLs. */
|
|
106
134
|
const isRequireContext = (context: ResolveHookContext) =>
|
|
107
135
|
context.conditions?.includes("require") === true &&
|
|
@@ -156,27 +184,7 @@ const resolveSpecifier = (
|
|
|
156
184
|
}
|
|
157
185
|
}
|
|
158
186
|
|
|
159
|
-
|
|
160
|
-
return nextResolve(specifier, context);
|
|
161
|
-
} catch (error) {
|
|
162
|
-
if (!isNotFound(error)) throw error;
|
|
163
|
-
// A package `exports`/`main` target naming emitted JavaScript that only
|
|
164
|
-
// exists as TypeScript source (workspace packages in a checkout).
|
|
165
|
-
const missing = missingPathOf(error);
|
|
166
|
-
if (missing !== undefined && isFileLikeSpecifier(missing)) {
|
|
167
|
-
const candidate = resolver.resolveMissing(missing, conditions);
|
|
168
|
-
if (candidate !== undefined && candidate !== missing) {
|
|
169
|
-
const resolved = resolveWithCandidate(
|
|
170
|
-
candidate,
|
|
171
|
-
metadata,
|
|
172
|
-
context,
|
|
173
|
-
nextResolve,
|
|
174
|
-
);
|
|
175
|
-
if (resolved !== undefined) return resolved;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
throw error;
|
|
179
|
-
}
|
|
187
|
+
return nextResolve(specifier, context);
|
|
180
188
|
};
|
|
181
189
|
|
|
182
190
|
/**
|
|
@@ -200,16 +208,14 @@ const withJsonAttribute = (url: string, context: LoadHookContext) => {
|
|
|
200
208
|
* does. A namespaced registration also provides a scoped import whose
|
|
201
209
|
* namespace propagates through the complete ESM graph.
|
|
202
210
|
*/
|
|
203
|
-
export const registerOxc = (
|
|
204
|
-
options: ImportLoaderRegistrationOptions = {},
|
|
205
|
-
): ImportLoader => {
|
|
211
|
+
export const registerOxc = (options: RegisterOxcOptions = {}): OxcLoader => {
|
|
206
212
|
// One global (un-namespaced) registration per process. Alchemy starts every
|
|
207
213
|
// Node process with `--import` of a file that calls this, and in-process
|
|
208
214
|
// callers (the dev exec child, tests) may call it again; a second copy of
|
|
209
215
|
// the hooks would only re-run the resolve chain. The marker lives on
|
|
210
216
|
// globalThis because a checkout can load this module twice (src/ and lib/).
|
|
211
217
|
const globalRegistration = globalThis as typeof globalThis & {
|
|
212
|
-
[globalRegistrationKey]?:
|
|
218
|
+
[globalRegistrationKey]?: OxcLoader;
|
|
213
219
|
};
|
|
214
220
|
if (options.namespace === undefined) {
|
|
215
221
|
const existing = globalRegistration[globalRegistrationKey];
|
|
@@ -221,19 +227,20 @@ export const registerOxc = (
|
|
|
221
227
|
});
|
|
222
228
|
const shouldInvalidate = options.shouldInvalidate ?? (() => true);
|
|
223
229
|
|
|
224
|
-
// Transformed sources
|
|
225
|
-
// stack traces once source-map support
|
|
230
|
+
// Transformed sources reference their source maps (see transform-source);
|
|
231
|
+
// Node only reads and applies them to stack traces once source-map support
|
|
232
|
+
// is on.
|
|
226
233
|
const sourceMapsWereEnabled = process.sourceMapsEnabled;
|
|
227
234
|
process.setSourceMapsEnabled(true);
|
|
228
235
|
|
|
229
236
|
const hooks = registerHooks({
|
|
230
237
|
resolve(specifier, context, nextResolve) {
|
|
231
|
-
|
|
232
|
-
|
|
238
|
+
// A graph's entry carries the namespace itself (see `import` below);
|
|
239
|
+
// everything it imports inherits it from the importing module's URL.
|
|
233
240
|
const namespace =
|
|
234
241
|
options.namespace === undefined
|
|
235
242
|
? undefined
|
|
236
|
-
: (
|
|
243
|
+
: (namespaceOf(specifier) ?? namespaceOf(context.parentURL));
|
|
237
244
|
|
|
238
245
|
if (options.namespace !== undefined && namespace !== options.namespace) {
|
|
239
246
|
return nextResolve(specifier, context);
|
|
@@ -248,14 +255,12 @@ export const registerOxc = (
|
|
|
248
255
|
...new Set([...options.conditions, ...context.conditions]),
|
|
249
256
|
],
|
|
250
257
|
};
|
|
251
|
-
const resolved =
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
)
|
|
258
|
-
: resolveSpecifier(resolver, specifier, resolutionContext, nextResolve);
|
|
258
|
+
const resolved = resolveSpecifier(
|
|
259
|
+
resolver,
|
|
260
|
+
specifier,
|
|
261
|
+
resolutionContext,
|
|
262
|
+
nextResolve,
|
|
263
|
+
);
|
|
259
264
|
if (
|
|
260
265
|
namespace !== undefined &&
|
|
261
266
|
resolved.url.startsWith("file:") &&
|
|
@@ -271,6 +276,7 @@ export const registerOxc = (
|
|
|
271
276
|
return resolved;
|
|
272
277
|
},
|
|
273
278
|
load(url, context, nextLoad): LoadFnOutput {
|
|
279
|
+
scheduleCompileCacheFlush();
|
|
274
280
|
const namespace = namespaceOf(url);
|
|
275
281
|
if (options.namespace !== undefined && namespace !== options.namespace) {
|
|
276
282
|
return nextLoad(url, context);
|
|
@@ -284,11 +290,7 @@ export const registerOxc = (
|
|
|
284
290
|
if (options.filter !== undefined && !options.filter(filePath)) {
|
|
285
291
|
return nextLoad(cleanUrl, withJsonAttribute(cleanUrl, context));
|
|
286
292
|
}
|
|
287
|
-
const transformed = transformer.transform(
|
|
288
|
-
filePath,
|
|
289
|
-
cleanUrl,
|
|
290
|
-
context.format,
|
|
291
|
-
);
|
|
293
|
+
const transformed = transformer.transform(filePath, context.format);
|
|
292
294
|
if (transformed === undefined) {
|
|
293
295
|
return nextLoad(cleanUrl, withJsonAttribute(cleanUrl, context));
|
|
294
296
|
}
|
|
@@ -296,17 +298,28 @@ export const registerOxc = (
|
|
|
296
298
|
},
|
|
297
299
|
});
|
|
298
300
|
|
|
299
|
-
const loader:
|
|
301
|
+
const loader: OxcLoader = {
|
|
300
302
|
import<T>(specifier: string, parentURL: string) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
303
|
+
if (!isFileLikeSpecifier(specifier)) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Cannot import '${specifier}': expected a file URL or path.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const base = parentURL.startsWith("file:")
|
|
309
|
+
? parentURL
|
|
310
|
+
: pathToFileURL(parentURL).href;
|
|
311
|
+
const url = specifier.startsWith("file:")
|
|
312
|
+
? specifier
|
|
313
|
+
: new URL(
|
|
314
|
+
specifier.startsWith(".")
|
|
315
|
+
? specifier
|
|
316
|
+
: pathToFileURL(specifier).href,
|
|
317
|
+
base,
|
|
318
|
+
).href;
|
|
308
319
|
return import(
|
|
309
|
-
|
|
320
|
+
options.namespace === undefined
|
|
321
|
+
? url
|
|
322
|
+
: withNamespace(url, options.namespace)
|
|
310
323
|
) as Promise<T>;
|
|
311
324
|
},
|
|
312
325
|
unregister() {
|
|
@@ -322,21 +335,3 @@ export const registerOxc = (
|
|
|
322
335
|
}
|
|
323
336
|
return loader;
|
|
324
337
|
};
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* One-shot TypeScript import that leaves the rest of the runtime untouched:
|
|
328
|
-
* a private namespace is registered for the call, so nothing is shared with
|
|
329
|
-
* other imports of the same files. Mirrors tsx's `tsImport`.
|
|
330
|
-
*/
|
|
331
|
-
export const tsImport = async <T = unknown>(
|
|
332
|
-
specifier: string,
|
|
333
|
-
parentURL: string,
|
|
334
|
-
options: Omit<ImportLoaderRegistrationOptions, "namespace"> = {},
|
|
335
|
-
): Promise<T> => {
|
|
336
|
-
const loader = registerOxc({ ...options, namespace: randomUUID() });
|
|
337
|
-
try {
|
|
338
|
-
return await loader.import<T>(specifier, parentURL);
|
|
339
|
-
} finally {
|
|
340
|
-
await loader.unregister();
|
|
341
|
-
}
|
|
342
|
-
};
|
package/src/resolve-specifier.ts
CHANGED
|
@@ -154,26 +154,4 @@ export class SpecifierResolver {
|
|
|
154
154
|
}
|
|
155
155
|
return result.path;
|
|
156
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
157
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
promises as fs,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import rolldown from "rolldown/package.json" with { type: "json" };
|
|
14
|
+
import self from "../package.json" with { type: "json" };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Anything that changes Oxc's output for identical input invalidates every
|
|
18
|
+
* entry: the transformer itself (rolldown), this package's transform
|
|
19
|
+
* pipeline, and the on-disk entry layout.
|
|
20
|
+
*/
|
|
21
|
+
const CACHE_VERSION = ["1", self.version, rolldown.version].join("-");
|
|
22
|
+
|
|
23
|
+
/** Entries untouched for this long are swept on the first disk access. */
|
|
24
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
export const TRANSFORM_CACHE_ENV = "ALCHEMY_TRANSFORM_CACHE";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Per-user directory, like tsx's `tsx-<uid>`: `tmpdir()` is shared on
|
|
30
|
+
* multi-user machines, and entries are written with the owner's umask.
|
|
31
|
+
*/
|
|
32
|
+
const defaultDirectory = () => {
|
|
33
|
+
const user = process.geteuid?.() ?? os.userInfo().username;
|
|
34
|
+
return path.join(os.tmpdir(), `alchemy-oxc-${user}`);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The directory to use for the given option, or `undefined` when caching is
|
|
39
|
+
* off. `cache: false` and `ALCHEMY_TRANSFORM_CACHE=0` disable it; a string
|
|
40
|
+
* (option or env) names the directory; otherwise the per-user default.
|
|
41
|
+
*/
|
|
42
|
+
export const resolveCacheDirectory = (
|
|
43
|
+
option: boolean | string | undefined,
|
|
44
|
+
): string | undefined => {
|
|
45
|
+
if (option === false) return undefined;
|
|
46
|
+
if (typeof option === "string") return option;
|
|
47
|
+
const env = process.env[TRANSFORM_CACHE_ENV];
|
|
48
|
+
if (env === "0" || env === "false") return undefined;
|
|
49
|
+
if (env !== undefined && env !== "") return env;
|
|
50
|
+
return defaultDirectory();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** One transform's output as handed to {@link TransformCache.set}. */
|
|
54
|
+
export interface TransformCacheEntry {
|
|
55
|
+
readonly format: "module" | "commonjs";
|
|
56
|
+
readonly code: string;
|
|
57
|
+
/** Source map JSON, or `undefined` when the transform produced none. */
|
|
58
|
+
readonly map: string | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A cache hit: the code, and where its source map lives on disk. */
|
|
62
|
+
export interface CachedTransform {
|
|
63
|
+
readonly format: "module" | "commonjs";
|
|
64
|
+
readonly code: string;
|
|
65
|
+
/** Absolute path of the entry's `.map` file, present when it has one. */
|
|
66
|
+
readonly mapFile: string | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* On-disk cache of Oxc transform output shared by every process on the
|
|
71
|
+
* machine — the `alchemy` CLI, its dev exec child, the local-provider
|
|
72
|
+
* sidecars and dev-server runners all load the same source files, and
|
|
73
|
+
* without this each of them transpiles the whole graph again.
|
|
74
|
+
*
|
|
75
|
+
* Modelled on tsx's file cache: entries are keyed by a hash of the source
|
|
76
|
+
* file's path, size and mtime, the transform options and the resolved
|
|
77
|
+
* tsconfig, so a change to any input is simply a different key; nothing is
|
|
78
|
+
* ever invalidated in place. Size plus nanosecond mtime stands in for the
|
|
79
|
+
* contents so a hit never reads the source.
|
|
80
|
+
*
|
|
81
|
+
* An entry is two files: `<key>.json` with the code, and `<key>.map` with
|
|
82
|
+
* the source map. The map stays on disk and is referenced from the module
|
|
83
|
+
* by path rather than inlined — an inline `data:` map is part of the
|
|
84
|
+
* script's source text, which V8 keeps for the process lifetime; for a
|
|
85
|
+
* graph the size of alchemy's that is hundreds of megabytes per process.
|
|
86
|
+
* Node reads the referenced file synchronously as it compiles the module,
|
|
87
|
+
* so the map is written (atomically) before `set` returns; the code entry
|
|
88
|
+
* is a fire-and-forget write, because a missing one is only a slower
|
|
89
|
+
* cache. Reads are synchronous (the loader hook is). Stale entries are
|
|
90
|
+
* swept by age once per process.
|
|
91
|
+
*/
|
|
92
|
+
export class TransformCache {
|
|
93
|
+
readonly #directory: string;
|
|
94
|
+
#ready = false;
|
|
95
|
+
#sequence = 0;
|
|
96
|
+
|
|
97
|
+
constructor(directory: string) {
|
|
98
|
+
this.#directory = directory;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The entry key for these transform inputs. */
|
|
102
|
+
key(parts: ReadonlyArray<string>): string {
|
|
103
|
+
const hash = createHash("sha1");
|
|
104
|
+
hash.update(CACHE_VERSION);
|
|
105
|
+
for (const part of parts) {
|
|
106
|
+
// Length-prefixed so adjacent parts cannot run into each other.
|
|
107
|
+
hash.update(`\0${part.length}\0`);
|
|
108
|
+
hash.update(part);
|
|
109
|
+
}
|
|
110
|
+
return hash.digest("hex");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The cached transform, or `undefined` on a miss. An entry whose map file
|
|
115
|
+
* has gone (swept, or never landed) is a miss too: the module would
|
|
116
|
+
* otherwise reference a map that is not there.
|
|
117
|
+
*/
|
|
118
|
+
get(key: string): CachedTransform | undefined {
|
|
119
|
+
this.#prepare();
|
|
120
|
+
let raw: string;
|
|
121
|
+
try {
|
|
122
|
+
raw = readFileSync(this.#file(key, "json"), "utf8");
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
let entry: { format?: unknown; code?: unknown; map?: unknown };
|
|
127
|
+
try {
|
|
128
|
+
entry = JSON.parse(raw);
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
(entry.format !== "module" && entry.format !== "commonjs") ||
|
|
134
|
+
typeof entry.code !== "string" ||
|
|
135
|
+
typeof entry.map !== "boolean"
|
|
136
|
+
) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const mapFile = this.#file(key, "map");
|
|
140
|
+
if (entry.map && !existsSync(mapFile)) return undefined;
|
|
141
|
+
return {
|
|
142
|
+
format: entry.format,
|
|
143
|
+
code: entry.code,
|
|
144
|
+
mapFile: entry.map ? mapFile : undefined,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Stores one transform. Returns the map file's path once it is on disk,
|
|
150
|
+
* or `undefined` when there is no map or it could not be written — the
|
|
151
|
+
* caller then falls back to inlining it.
|
|
152
|
+
*/
|
|
153
|
+
set(key: string, entry: TransformCacheEntry): string | undefined {
|
|
154
|
+
this.#prepare();
|
|
155
|
+
let mapFile: string | undefined;
|
|
156
|
+
if (entry.map !== undefined) {
|
|
157
|
+
mapFile = this.#file(key, "map");
|
|
158
|
+
if (!this.#writeAtomically(mapFile, entry.map)) return undefined;
|
|
159
|
+
}
|
|
160
|
+
const file = this.#file(key, "json");
|
|
161
|
+
const temporary = this.#temporary(file);
|
|
162
|
+
// Best effort: a cache that cannot be written is only a slower cache.
|
|
163
|
+
fs.writeFile(
|
|
164
|
+
temporary,
|
|
165
|
+
JSON.stringify({
|
|
166
|
+
format: entry.format,
|
|
167
|
+
code: entry.code,
|
|
168
|
+
map: mapFile !== undefined,
|
|
169
|
+
}),
|
|
170
|
+
)
|
|
171
|
+
.then(() => fs.rename(temporary, file))
|
|
172
|
+
.catch(() => fs.unlink(temporary).catch(() => {}));
|
|
173
|
+
return mapFile;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#file(key: string, extension: "json" | "map") {
|
|
177
|
+
return path.join(this.#directory, `${key}.${extension}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#temporary(file: string) {
|
|
181
|
+
return `${file}.${process.pid}.${this.#sequence++}.tmp`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Temp file plus rename: concurrent readers never see a partial file. */
|
|
185
|
+
#writeAtomically(file: string, content: string): boolean {
|
|
186
|
+
const temporary = this.#temporary(file);
|
|
187
|
+
try {
|
|
188
|
+
writeFileSync(temporary, content);
|
|
189
|
+
renameSync(temporary, file);
|
|
190
|
+
return true;
|
|
191
|
+
} catch {
|
|
192
|
+
try {
|
|
193
|
+
unlinkSync(temporary);
|
|
194
|
+
} catch {}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#prepare() {
|
|
200
|
+
if (this.#ready) return;
|
|
201
|
+
this.#ready = true;
|
|
202
|
+
try {
|
|
203
|
+
mkdirSync(this.#directory, { recursive: true });
|
|
204
|
+
} catch {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// Off the hot path: the loader hook that got us here is synchronous.
|
|
208
|
+
setImmediate(() => {
|
|
209
|
+
this.#sweep().catch(() => {});
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async #sweep() {
|
|
214
|
+
const cutoff = Date.now() - MAX_AGE_MS;
|
|
215
|
+
const names = await fs.readdir(this.#directory);
|
|
216
|
+
await Promise.all(
|
|
217
|
+
names.map(async (name) => {
|
|
218
|
+
const file = path.join(this.#directory, name);
|
|
219
|
+
try {
|
|
220
|
+
const { mtimeMs } = await fs.stat(file);
|
|
221
|
+
if (mtimeMs < cutoff) await fs.unlink(file);
|
|
222
|
+
} catch {}
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|