@procedures/node 0.25.0 → 0.26.1
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/CHANGELOG.md +22 -0
- package/README.md +49 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/fresh-bundle.d.ts +47 -0
- package/dist/fresh-bundle.d.ts.map +1 -0
- package/dist/fresh-bundle.js +292 -0
- package/dist/fresh-bundle.js.map +1 -0
- package/package.json +22 -4
- package/src/fresh-bundle.ts +335 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fresh bundle: a launcher that starts a TypeScript entry from a bundle of the current files on disk.
|
|
3
|
+
*
|
|
4
|
+
* The launcher keeps its builds in a cache folder, for example `node_modules/.cache/<name>`. It starts
|
|
5
|
+
* the last build only when each file of that build has the content hash of the build. Otherwise it
|
|
6
|
+
* makes a new build in a child process and starts it. When the build fails, it starts the source
|
|
7
|
+
* through `tsx`. Thus it does not start code that is older than the source. An edit with the same size
|
|
8
|
+
* and the same modification time also makes the build stale.
|
|
9
|
+
*
|
|
10
|
+
* Import this module from `@procedures/node/fresh-bundle`. The root of the package does not load it.
|
|
11
|
+
* The module needs the optional peer dependencies `esbuild` (for the build) and `tsx` (for the fallback).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
statSync,
|
|
23
|
+
utimesSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { basename, dirname, extname, join, sep } from "node:path";
|
|
27
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
28
|
+
import type { Loader, OnLoadArgs, OnLoadResult, OnResolveArgs, OnResolveResult, Plugin } from "esbuild";
|
|
29
|
+
|
|
30
|
+
/** The format of the builds. A build of another format is stale. */
|
|
31
|
+
const FORMAT = 1;
|
|
32
|
+
|
|
33
|
+
/** The time after which the next build removes a build that no launch used: 6 hours. */
|
|
34
|
+
const UNUSED_MS = 6 * 60 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
/** The time limit of a build. A build takes approximately one second. A build that hangs does not stop the call. */
|
|
37
|
+
const BUILD_TIMEOUT_MS = 60_000;
|
|
38
|
+
|
|
39
|
+
/** The file of this module. Each build records its hash, so a new version of the launcher makes a new build. */
|
|
40
|
+
const self = fileURLToPath(import.meta.url);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The script of the child process that builds. It imports this module, reads the spec from stdin
|
|
44
|
+
* and starts {@link build}. The module itself has no code that starts when it loads.
|
|
45
|
+
*/
|
|
46
|
+
const BUILD_SCRIPT = [
|
|
47
|
+
`import { build } from ${JSON.stringify(import.meta.url)};`,
|
|
48
|
+
"const chunks = [];",
|
|
49
|
+
"for await (const chunk of process.stdin) chunks.push(chunk);",
|
|
50
|
+
'await build(JSON.parse(Buffer.concat(chunks).toString("utf8")));',
|
|
51
|
+
].join("\n");
|
|
52
|
+
|
|
53
|
+
/** The identifiers that replace `import.meta.url`, `import.meta.dirname` and `import.meta.filename` in a build. */
|
|
54
|
+
const META: Readonly<Record<string, string>> = {
|
|
55
|
+
"import.meta.url": "__freshBundleImportMetaUrl",
|
|
56
|
+
"import.meta.dirname": "__freshBundleImportMetaDirname",
|
|
57
|
+
"import.meta.filename": "__freshBundleImportMetaFilename",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The loader of esbuild for each file extension that is not the name of a loader. */
|
|
61
|
+
const LOADERS: Readonly<Record<string, Loader>> = { mts: "ts", cts: "ts", mjs: "js", cjs: "js" };
|
|
62
|
+
|
|
63
|
+
/** The entry, the cache folder and the inputs of a fresh bundle. */
|
|
64
|
+
export interface FreshBundleSpec {
|
|
65
|
+
/** The absolute path of the TypeScript entry. */
|
|
66
|
+
readonly entry: string;
|
|
67
|
+
/** The absolute path of the folder of the builds. Use a folder that Git ignores, for example `node_modules/.cache/<name>`. */
|
|
68
|
+
readonly outDir: string;
|
|
69
|
+
/**
|
|
70
|
+
* The absolute paths of the files that decide a build in addition to the sources: the lockfile,
|
|
71
|
+
* each `package.json` and each `tsconfig.json`. The record of a build marks a missing file as missing.
|
|
72
|
+
*/
|
|
73
|
+
readonly inputs: readonly string[];
|
|
74
|
+
/** The module that the build imports esbuild from, as a package name or a file URL. The default is `esbuild`. */
|
|
75
|
+
readonly esbuild?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The record of the last build, in `current.json` of the cache folder. */
|
|
79
|
+
interface CurrentBuild {
|
|
80
|
+
readonly version: number;
|
|
81
|
+
readonly entry: string;
|
|
82
|
+
/** The file of the build that runs, relative to the cache folder. */
|
|
83
|
+
readonly main: string;
|
|
84
|
+
/** The SHA-256 hash of each file of the build, or null for a file that did not exist. */
|
|
85
|
+
readonly inputs: Readonly<Record<string, string | null>>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** This function gives the SHA-256 hash of bytes or of a text, in hexadecimal. */
|
|
89
|
+
const sha256 = (bytes: Uint8Array | string): string => createHash("sha256").update(bytes).digest("hex");
|
|
90
|
+
|
|
91
|
+
/** This function gives the SHA-256 hash of the content of a file, or null when it cannot read the file. */
|
|
92
|
+
const hashOf = (file: string): string | null => {
|
|
93
|
+
try {
|
|
94
|
+
return sha256(readFileSync(file));
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** This function gives the inputs of the spec and the file of this module. */
|
|
101
|
+
const allInputs = (spec: FreshBundleSpec): string[] => [...spec.inputs, self];
|
|
102
|
+
|
|
103
|
+
/** This function reads `current.json` of the cache folder, or gives undefined when the record is missing or not valid. */
|
|
104
|
+
const readCurrent = (outDir: string): CurrentBuild | undefined => {
|
|
105
|
+
try {
|
|
106
|
+
const current: unknown = JSON.parse(readFileSync(join(outDir, "current.json"), "utf8"));
|
|
107
|
+
if (typeof current !== "object" || current === null) return undefined;
|
|
108
|
+
const record = current as Partial<CurrentBuild>;
|
|
109
|
+
if (typeof record.main !== "string" || typeof record.inputs !== "object" || record.inputs === null) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return record as CurrentBuild;
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* This function gives the entry file of a build that has the content of the current files on disk.
|
|
120
|
+
* It gives undefined when there is no build, or when a file changed after the build.
|
|
121
|
+
* @param spec - The entry, the cache folder and the inputs.
|
|
122
|
+
* @returns The absolute path of the entry file of the build, or undefined.
|
|
123
|
+
*/
|
|
124
|
+
export function freshEntry(spec: FreshBundleSpec): string | undefined {
|
|
125
|
+
const current = readCurrent(spec.outDir);
|
|
126
|
+
if (current === undefined || current.version !== FORMAT || current.entry !== spec.entry) return undefined;
|
|
127
|
+
// A file that the spec names now (the package.json of a new package) is an input that the build must have.
|
|
128
|
+
const files = new Set([...Object.keys(current.inputs), ...allInputs(spec)]);
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
if (!Object.hasOwn(current.inputs, file) || hashOf(file) !== current.inputs[file]) return undefined;
|
|
131
|
+
}
|
|
132
|
+
const main = join(spec.outDir, current.main);
|
|
133
|
+
// A build that a person removed is not a build.
|
|
134
|
+
return statSync(main, { throwIfNoEntry: false })?.isFile() === true ? main : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* This function gives the URL of the file that Node.js resolves a specifier to from an importer.
|
|
139
|
+
* The parent argument of `import.meta.resolve` needs the flag `--experimental-import-meta-resolve`.
|
|
140
|
+
*/
|
|
141
|
+
const nodeResolve = (specifier: string, importer: string): string | undefined => {
|
|
142
|
+
try {
|
|
143
|
+
return import.meta.resolve(specifier, pathToFileURL(importer).href);
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* This function makes the esbuild plugin of the build. The plugin keeps each package of
|
|
151
|
+
* `node_modules` external, at the file that Node.js resolves it to from the importer. Thus each
|
|
152
|
+
* package loads at the same version as under `tsx`. The plugin records the hash of each file that
|
|
153
|
+
* esbuild compiles, from the bytes that esbuild compiles. Each file keeps its own `import.meta.url`
|
|
154
|
+
* through a declaration on its first line.
|
|
155
|
+
* @param sources - The map that gets the hash of each compiled file.
|
|
156
|
+
*/
|
|
157
|
+
const plugin = (sources: Map<string, string>): Plugin => ({
|
|
158
|
+
name: "fresh-bundle",
|
|
159
|
+
setup(builder) {
|
|
160
|
+
builder.onResolve({ filter: /^[^./\\]/ }, (args: OnResolveArgs): OnResolveResult | undefined => {
|
|
161
|
+
if (args.kind === "entry-point") return undefined;
|
|
162
|
+
if (args.path.startsWith("node:")) return { path: args.path, external: true };
|
|
163
|
+
const url = nodeResolve(args.path, args.importer);
|
|
164
|
+
// A specifier that does not resolve here fails where the code imports it, when that code runs, as under tsx.
|
|
165
|
+
if (url === undefined) return { path: args.path, external: true };
|
|
166
|
+
if (!url.startsWith("file:")) return { path: url, external: true };
|
|
167
|
+
const file = fileURLToPath(url);
|
|
168
|
+
const packaged = file.includes(`${sep}node_modules${sep}`) && !/\.[cm]?tsx?$/.test(file);
|
|
169
|
+
return packaged ? { path: url, external: true } : { path: file };
|
|
170
|
+
});
|
|
171
|
+
builder.onLoad({ filter: /.*/ }, (args: OnLoadArgs): OnLoadResult => {
|
|
172
|
+
const bytes = readFileSync(args.path);
|
|
173
|
+
sources.set(args.path, sha256(bytes));
|
|
174
|
+
let text = bytes.toString("utf8");
|
|
175
|
+
if (text.includes("import.meta")) {
|
|
176
|
+
const other = /\bimport\.meta\b(?!\.(?:url|dirname|filename)\b)/.exec(text);
|
|
177
|
+
if (other !== null) {
|
|
178
|
+
throw new Error(`${args.path}: import.meta other than url, dirname or filename`);
|
|
179
|
+
}
|
|
180
|
+
const declared = [
|
|
181
|
+
`${META["import.meta.url"]} = ${JSON.stringify(pathToFileURL(args.path).href)}`,
|
|
182
|
+
`${META["import.meta.dirname"]} = ${JSON.stringify(dirname(args.path))}`,
|
|
183
|
+
`${META["import.meta.filename"]} = ${JSON.stringify(args.path)}`,
|
|
184
|
+
];
|
|
185
|
+
const line = `var ${declared.join(", ")};`;
|
|
186
|
+
// The declaration goes on the first line (after a hashbang), so the line numbers stay those of the source.
|
|
187
|
+
text = text.startsWith("#!") ? text.replace(/^(#![^\n]*\n)/, `$1${line}`) : line + text;
|
|
188
|
+
}
|
|
189
|
+
const ext = extname(args.path).slice(1);
|
|
190
|
+
return { contents: text, loader: LOADERS[ext] ?? (ext as Loader) };
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/** This function waits for some milliseconds. It blocks the thread. */
|
|
196
|
+
const sleepSync = (ms: number): void => {
|
|
197
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** This function writes a file through a temporary file and a rename, so a reader does not see a part of the file. */
|
|
201
|
+
const writeAtomic = (file: string, text: string): void => {
|
|
202
|
+
const tmp = `${file}.${String(process.pid)}.tmp`;
|
|
203
|
+
writeFileSync(tmp, text);
|
|
204
|
+
// On Windows, the rename fails while a reader has the file open. Thus the function tries again.
|
|
205
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
206
|
+
try {
|
|
207
|
+
renameSync(tmp, file);
|
|
208
|
+
return;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (attempt >= 20) {
|
|
211
|
+
rmSync(tmp, { force: true });
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
sleepSync(25);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** This function removes the builds that no launch used recently, and the remains of a build that crashed. */
|
|
220
|
+
const prune = (outDir: string, keep: string): void => {
|
|
221
|
+
for (const entry of readdirSync(outDir, { withFileTypes: true })) {
|
|
222
|
+
if (!entry.isDirectory() || entry.name === keep) continue;
|
|
223
|
+
const path = join(outDir, entry.name);
|
|
224
|
+
try {
|
|
225
|
+
if (Date.now() - statSync(path).mtimeMs > UNUSED_MS) {
|
|
226
|
+
rmSync(path, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// The folder is in use or gone. The next build tries again.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* This function builds the entry into a new folder of the cache folder, and points `current.json` at it.
|
|
236
|
+
* Start it in a process with the flag `--experimental-import-meta-resolve`. {@link launch} does this.
|
|
237
|
+
* @param spec - The entry, the cache folder and the inputs.
|
|
238
|
+
*/
|
|
239
|
+
export async function build(spec: FreshBundleSpec): Promise<void> {
|
|
240
|
+
const esbuild = (await import(spec.esbuild ?? "esbuild")) as typeof import("esbuild");
|
|
241
|
+
const sources = new Map<string, string>();
|
|
242
|
+
// The hashes come before esbuild reads the files. Thus a change during the build makes the build stale.
|
|
243
|
+
const inputs: Record<string, string | null> = Object.fromEntries(
|
|
244
|
+
allInputs(spec).map((file) => [file, hashOf(file)]),
|
|
245
|
+
);
|
|
246
|
+
mkdirSync(spec.outDir, { recursive: true });
|
|
247
|
+
const tmp = join(spec.outDir, `tmp-${String(process.pid)}-${randomBytes(4).toString("hex")}`);
|
|
248
|
+
try {
|
|
249
|
+
await esbuild.build({
|
|
250
|
+
entryPoints: [spec.entry],
|
|
251
|
+
outdir: tmp,
|
|
252
|
+
bundle: true,
|
|
253
|
+
// The imports of a code path load when that path runs.
|
|
254
|
+
splitting: true,
|
|
255
|
+
format: "esm",
|
|
256
|
+
platform: "node",
|
|
257
|
+
target: "node22",
|
|
258
|
+
outExtension: { ".js": ".mjs" },
|
|
259
|
+
chunkNames: "chunk-[hash]",
|
|
260
|
+
// A bundle renames the names that clash. A class or a function keeps the name that it has under tsx.
|
|
261
|
+
keepNames: true,
|
|
262
|
+
define: { ...META },
|
|
263
|
+
logLevel: "silent",
|
|
264
|
+
plugins: [plugin(sources)],
|
|
265
|
+
});
|
|
266
|
+
Object.assign(inputs, Object.fromEntries(sources));
|
|
267
|
+
const id = sha256(JSON.stringify([FORMAT, Object.entries(inputs).sort()])).slice(0, 16);
|
|
268
|
+
const dir = join(spec.outDir, id);
|
|
269
|
+
try {
|
|
270
|
+
renameSync(tmp, dir);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// Another launch made the same build first. Its build is this build.
|
|
273
|
+
if (statSync(dir, { throwIfNoEntry: false })?.isDirectory() !== true) throw error;
|
|
274
|
+
}
|
|
275
|
+
const main = join(id, basename(spec.entry).replace(/\.[cm]?tsx?$/, ".mjs"));
|
|
276
|
+
const current: CurrentBuild = { version: FORMAT, entry: spec.entry, main, inputs };
|
|
277
|
+
writeAtomic(join(spec.outDir, "current.json"), JSON.stringify(current));
|
|
278
|
+
prune(spec.outDir, id);
|
|
279
|
+
} finally {
|
|
280
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** This function writes the error output of a failed build to `build-error.txt` of the cache folder. */
|
|
285
|
+
const recordFailure = (outDir: string, text: string): void => {
|
|
286
|
+
try {
|
|
287
|
+
mkdirSync(outDir, { recursive: true });
|
|
288
|
+
writeFileSync(join(outDir, "build-error.txt"), text);
|
|
289
|
+
} catch {
|
|
290
|
+
// There is no place for the record. The call continues through tsx.
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* This function starts the entry: its fresh build, else a new build, else the source through `tsx`.
|
|
296
|
+
* The entry gets the `process.argv` that it gets under `tsx`: `process.argv[1]` is the entry. An
|
|
297
|
+
* error of the entry is its own. After the bundle starts, the function does not start the entry again.
|
|
298
|
+
* @param spec - The entry, the cache folder and the inputs.
|
|
299
|
+
*/
|
|
300
|
+
export async function launch(spec: FreshBundleSpec): Promise<void> {
|
|
301
|
+
let main = freshEntry(spec);
|
|
302
|
+
if (main === undefined) {
|
|
303
|
+
const child = spawnSync(
|
|
304
|
+
process.execPath,
|
|
305
|
+
["--experimental-import-meta-resolve", "--input-type=module", "--eval", BUILD_SCRIPT],
|
|
306
|
+
{
|
|
307
|
+
input: JSON.stringify(spec),
|
|
308
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
309
|
+
windowsHide: true,
|
|
310
|
+
timeout: BUILD_TIMEOUT_MS,
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
if (child.status !== 0) {
|
|
314
|
+
// The record of the last failure tells why the start is slow. The call continues through tsx.
|
|
315
|
+
const stderr = child.stderr?.toString() ?? "";
|
|
316
|
+
recordFailure(spec.outDir, child.error === undefined ? stderr : `${child.error.message}\n${stderr}`);
|
|
317
|
+
}
|
|
318
|
+
main = freshEntry(spec);
|
|
319
|
+
}
|
|
320
|
+
process.argv[1] = spec.entry;
|
|
321
|
+
if (main !== undefined) {
|
|
322
|
+
try {
|
|
323
|
+
// The time of the last use. The next build does not remove a build that is in use.
|
|
324
|
+
const now = new Date();
|
|
325
|
+
utimesSync(dirname(main), now, now);
|
|
326
|
+
} catch {
|
|
327
|
+
// The build is gone or read-only. It runs all the same.
|
|
328
|
+
}
|
|
329
|
+
await import(pathToFileURL(main).href);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const { register } = await import("tsx/esm/api");
|
|
333
|
+
register();
|
|
334
|
+
await import(pathToFileURL(spec.entry).href);
|
|
335
|
+
}
|