@lakutata/builder 3.0.0-beta.0

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.
@@ -0,0 +1,533 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
4
+ import { createRequire, isBuiltin } from 'node:module';
5
+ import path from 'node:path';
6
+ import { gzipSync } from 'node:zlib';
7
+ /**
8
+ * The shared libraries: the addons, and the libraries they are linked with (sharp and libvips)
9
+ */
10
+ const ADDON = /\.node$/;
11
+ const LIBRARY = /\.(node|dylib|dll|so)$|\.so\.\d+(\.\d+)*$/;
12
+ const RESOLVING = 'lakutataNativeResolving';
13
+ /**
14
+ * The files of the packages node never loads: their types, their source maps, their sources in TypeScript, their
15
+ * documentation
16
+ */
17
+ const UNUSED = /\.(d\.[cm]?ts|[cm]?ts|map|md|markdown)$/i;
18
+ /**
19
+ * The JavaScript files, which a package keeps when they are loaded (traced)
20
+ */
21
+ const SCRIPT = /\.[cm]?js$/;
22
+ /**
23
+ * A require esbuild cannot follow loading a file of its package by its path (require(path.join(__dirname, name)))
24
+ */
25
+ const OWN_FILE = /__dirname|__filename|import\.meta|['"`]\.\.?\//;
26
+ /**
27
+ * The require of an ES module, createRequire(import.meta.url): in the bundle, it is the require of the bundle
28
+ */
29
+ const MODULE_REQUIRE = /\b(?:[A-Za-z_$][\w$]*\.)?createRequire\s*\(\s*import\.meta\.url\s*\)/g;
30
+ /**
31
+ * The name of the package of a module specifier ("@scope/name/sub/path" → "@scope/name"), undefined for a path
32
+ * @param specifier
33
+ */
34
+ export function specifierPackage(specifier) {
35
+ if (specifier.startsWith('.') || path.isAbsolute(specifier))
36
+ return undefined;
37
+ const parts = specifier.split('/');
38
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
39
+ }
40
+ /**
41
+ * The files of a package directory, without the packages it holds (node_modules)
42
+ * @param directory
43
+ */
44
+ function packageFiles(directory) {
45
+ const files = [];
46
+ const walk = (current) => {
47
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
48
+ if (entry.name === 'node_modules')
49
+ continue;
50
+ const file = path.join(current, entry.name);
51
+ let stats;
52
+ try {
53
+ stats = statSync(file);
54
+ }
55
+ catch {
56
+ //A broken link
57
+ continue;
58
+ }
59
+ if (stats.isDirectory())
60
+ walk(file);
61
+ else if (stats.isFile())
62
+ files.push(file);
63
+ }
64
+ };
65
+ walk(directory);
66
+ return files;
67
+ }
68
+ /**
69
+ * The directories node looks a package up in from a directory of the tree, the nearest first
70
+ * @param directory
71
+ * @param name
72
+ */
73
+ function lookupDirectories(directory, name) {
74
+ const parts = directory.split('/');
75
+ const lookups = [];
76
+ for (let index = parts.length; index >= 0; index--) {
77
+ const prefix = parts.slice(0, index);
78
+ if (prefix.at(-1) === 'node_modules')
79
+ continue;
80
+ lookups.push([...prefix, 'node_modules', name].join('/'));
81
+ }
82
+ return lookups;
83
+ }
84
+ /**
85
+ * Find the native addons of an application while it is bundled: the packages holding addons (and the packages loading
86
+ * them with computed paths, such as sharp) are left out of the bundle and gathered, with the packages they load, into
87
+ * a native tree; the addons of the application itself are loaded from the tree
88
+ */
89
+ export class NativeCollector {
90
+ /**
91
+ * @param project the directory of the project, whose files are bundled
92
+ * @param runtime the runtime loading the tree: Bun resolves the packages with its condition (bun) first
93
+ */
94
+ constructor(project, runtime = 'node') {
95
+ this.project = project;
96
+ this.runtime = runtime;
97
+ this.packages = new Map();
98
+ this.roots = new Map();
99
+ /**
100
+ * The packages left out of the bundle, by the name the application loads them with, and the files it loads
101
+ */
102
+ this.externals = new Map();
103
+ /**
104
+ * The addons of the application itself, by their key in the tree
105
+ */
106
+ this.addons = new Map();
107
+ /**
108
+ * The files of the packages of the tree which are loaded, by package (traced)
109
+ */
110
+ this.loaded = new Map();
111
+ /**
112
+ * The packages of the tree kept as a whole: the ones which are not traced, the ones loading their files by paths
113
+ * esbuild cannot follow
114
+ */
115
+ this.whole = new Set();
116
+ /**
117
+ * The packages each package of the tree requires, by their names (traced)
118
+ */
119
+ this.required = new Map();
120
+ this.project = realpathSync(project);
121
+ }
122
+ /**
123
+ * The directory of the package a file belongs to: the nearest directory with a named package.json
124
+ * @param file
125
+ */
126
+ packageRoot(file) {
127
+ let directory = path.dirname(file);
128
+ const visited = [];
129
+ let found;
130
+ while (true) {
131
+ if (this.roots.has(directory)) {
132
+ found = this.roots.get(directory);
133
+ break;
134
+ }
135
+ visited.push(directory);
136
+ const manifest = path.join(directory, 'package.json');
137
+ if (existsSync(manifest)) {
138
+ try {
139
+ if (JSON.parse(readFileSync(manifest, 'utf8')).name) {
140
+ found = directory;
141
+ break;
142
+ }
143
+ }
144
+ catch {
145
+ //Not a package.json of a package
146
+ }
147
+ }
148
+ const parent = path.dirname(directory);
149
+ if (parent === directory)
150
+ break;
151
+ directory = parent;
152
+ }
153
+ for (const entry of visited)
154
+ this.roots.set(entry, found);
155
+ return found;
156
+ }
157
+ /**
158
+ * A package: its manifest and whether it holds addons or shared libraries
159
+ * @param root
160
+ */
161
+ package(root) {
162
+ if (!this.packages.has(root)) {
163
+ let info;
164
+ try {
165
+ const manifest = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
166
+ const files = packageFiles(root);
167
+ info = {
168
+ root: root,
169
+ name: manifest.name,
170
+ manifest: manifest,
171
+ addons: files.some((file) => ADDON.test(file)),
172
+ libraries: files.some((file) => LIBRARY.test(file))
173
+ };
174
+ }
175
+ catch {
176
+ info = undefined;
177
+ }
178
+ this.packages.set(root, info);
179
+ }
180
+ return this.packages.get(root);
181
+ }
182
+ /**
183
+ * The dependencies of a package, as node finds them from it: the path looked up (a link with pnpm) and the package
184
+ * @param root
185
+ */
186
+ dependencies(root) {
187
+ const manifest = this.package(root)?.manifest ?? {};
188
+ const names = new Set(Object.keys({ ...manifest.dependencies, ...manifest.optionalDependencies, ...manifest.peerDependencies }));
189
+ const found = [];
190
+ for (const name of names) {
191
+ let directory = root;
192
+ while (true) {
193
+ const lookup = path.join(directory, 'node_modules', name);
194
+ if (existsSync(path.join(lookup, 'package.json'))) {
195
+ found.push({ name: name, lookup: lookup, root: realpathSync(lookup) });
196
+ break;
197
+ }
198
+ const parent = path.dirname(directory);
199
+ if (parent === directory)
200
+ break;
201
+ directory = parent;
202
+ }
203
+ }
204
+ return found;
205
+ }
206
+ /**
207
+ * Whether a package is left out of the bundle: it holds addons, or one of its dependencies does (it loads them,
208
+ * with computed paths often), and it is not the project
209
+ * @param root
210
+ */
211
+ isNative(root) {
212
+ if (!root || root === this.project)
213
+ return false;
214
+ const info = this.package(root);
215
+ if (!info)
216
+ return false;
217
+ return info.addons || this.dependencies(root).some(({ root: dependency }) => !!this.package(dependency)?.addons);
218
+ }
219
+ /**
220
+ * Resolve an import as esbuild does
221
+ * @param build
222
+ * @param args
223
+ */
224
+ async resolve(build, args) {
225
+ const result = await build.resolve(args.path, { kind: args.kind, resolveDir: args.resolveDir, importer: args.importer, pluginData: { [RESOLVING]: true } });
226
+ return result.errors.length || result.external || !result.path ? undefined : realpathSync(result.path);
227
+ }
228
+ /**
229
+ * The plugin of the bundle: the native packages are external, the addons of the application are loaded from the tree
230
+ */
231
+ plugin() {
232
+ return {
233
+ name: 'lakutata-native',
234
+ setup: (build) => {
235
+ build.onResolve({ filter: /.*/ }, async (args) => {
236
+ if (args.pluginData?.[RESOLVING] || isBuiltin(args.path) || args.kind === 'entry-point')
237
+ return undefined;
238
+ const file = await this.resolve(build, args);
239
+ if (!file)
240
+ return undefined;
241
+ const root = this.packageRoot(file);
242
+ if (this.isNative(root)) {
243
+ const name = specifierPackage(args.path);
244
+ if (!name)
245
+ throw new Error(`${args.importer} loads ${args.path} of the native package ${this.package(root).name} by its path: load it by its name`);
246
+ const external = this.externals.get(name) ?? { root: root, entries: new Set() };
247
+ external.entries.add(file);
248
+ //The bundle is CommonJS: the require of the run loads the file of the require condition
249
+ if (args.kind !== 'require-call') {
250
+ const required = await this.resolve(build, { ...args, kind: 'require-call' });
251
+ if (required)
252
+ external.entries.add(required);
253
+ }
254
+ this.externals.set(name, external);
255
+ return { path: args.path, external: true };
256
+ }
257
+ if (ADDON.test(file)) {
258
+ const key = `.lakutata/addons/${createHash('sha256').update(file).digest('hex').slice(0, 16)}-${path.basename(file)}`;
259
+ this.addons.set(key, file);
260
+ return { path: key, namespace: 'lakutata-native' };
261
+ }
262
+ return undefined;
263
+ });
264
+ //The requires of the ES modules are the one of the bundle: esbuild follows what they load (the addons)
265
+ build.onLoad({ filter: /\.(c|m)?js$/ }, async (args) => {
266
+ const source = await readFile(args.path, 'utf8');
267
+ if (!MODULE_REQUIRE.test(source))
268
+ return undefined;
269
+ MODULE_REQUIRE.lastIndex = 0;
270
+ return { contents: source.replace(MODULE_REQUIRE, 'require'), loader: 'js', resolveDir: path.dirname(args.path) };
271
+ });
272
+ //The addons of the application are loaded by the require of the bundle, from the extracted tree
273
+ build.onLoad({ filter: /.*/, namespace: 'lakutata-native' }, (args) => ({
274
+ contents: `module.exports = require.lakutataNative(${JSON.stringify(args.path)})`,
275
+ loader: 'js'
276
+ }));
277
+ }
278
+ };
279
+ }
280
+ /**
281
+ * The packages a native package loads at run time: the JavaScript it requires (followed by esbuild), the packages of
282
+ * shared libraries it depends on (the libraries linked with its addons), and the native packages it loads
283
+ * @param build the build function of esbuild
284
+ * @param root
285
+ * @param entries the files of the package which are loaded
286
+ * @param included the packages of the tree
287
+ */
288
+ async trace(build, root, entries, included) {
289
+ const pending = [{ root: root, entries: entries }];
290
+ const traced = new Set();
291
+ while (pending.length) {
292
+ const current = pending.shift();
293
+ const key = `${current.root}\0${[...current.entries].sort().join('\0')}`;
294
+ if (traced.has(key))
295
+ continue;
296
+ traced.add(key);
297
+ included.add(current.root);
298
+ //A package esbuild cannot trace (a glob of its requires matching nothing) is kept as a whole
299
+ const result = await build({
300
+ entryPoints: [...current.entries],
301
+ bundle: true,
302
+ write: false,
303
+ //Not written: a package loaded by several of its files is several entries, which need a directory
304
+ outdir: path.join(current.root, '.lakutata-trace'),
305
+ metafile: true,
306
+ platform: 'node',
307
+ format: 'cjs',
308
+ conditions: this.runtime === 'bun' ? ['bun'] : [],
309
+ logLevel: 'silent',
310
+ //The addons are loaded at run time (the paths of their globs may match nothing: sharp built from its sources)
311
+ external: ['*.node'],
312
+ //Reported in the node_modules too: the requires it cannot follow
313
+ logOverride: { 'unsupported-require-call': 'warning' },
314
+ plugins: [{
315
+ name: 'lakutata-native-trace',
316
+ setup: (trace) => {
317
+ trace.onResolve({ filter: /.*/ }, async (args) => {
318
+ if (args.pluginData?.[RESOLVING] || isBuiltin(args.path) || args.kind === 'entry-point')
319
+ return undefined;
320
+ const file = await this.resolve(trace, args);
321
+ //The requires which cannot be followed (computed paths) are left to the run time
322
+ if (!file || ADDON.test(file))
323
+ return { path: args.path, external: true };
324
+ const dependency = this.packageRoot(file);
325
+ //The package it requires by this name (the ones it does not declare too)
326
+ const name = specifierPackage(args.path);
327
+ const importer = args.importer ? this.packageRoot(realpathSync(args.importer)) : undefined;
328
+ if (name && importer && dependency && importer !== dependency)
329
+ this.requires(importer).set(name, dependency);
330
+ if (dependency !== current.root && this.isNative(dependency)) {
331
+ pending.push({ root: dependency, entries: new Set([file]) });
332
+ return { path: args.path, external: true };
333
+ }
334
+ return undefined;
335
+ });
336
+ }
337
+ }]
338
+ }).catch(() => undefined);
339
+ if (!result)
340
+ this.whole.add(current.root);
341
+ for (const input of Object.keys(result?.metafile.inputs ?? {})) {
342
+ const file = realpathSync(path.resolve(input));
343
+ const dependency = this.packageRoot(file);
344
+ if (!dependency || dependency === this.project)
345
+ continue;
346
+ included.add(dependency);
347
+ const files = this.loaded.get(dependency) ?? new Set();
348
+ this.loaded.set(dependency, files.add(file));
349
+ }
350
+ //The packages loading their files by paths esbuild cannot follow are kept as a whole
351
+ for (const warning of result?.warnings ?? []) {
352
+ if (!warning.location || !OWN_FILE.test(warning.location.lineText.slice(warning.location.column, warning.location.column + 200)))
353
+ continue;
354
+ const dependency = this.packageRoot(realpathSync(path.resolve(warning.location.file)));
355
+ if (dependency)
356
+ this.whole.add(dependency);
357
+ }
358
+ //The packages it declares which its code names: the ones it loads by requires esbuild does not follow
359
+ //(createRequire(import.meta.url)('node-gyp-build')), not the ones installing it (node-gyp, prebuild-install)
360
+ const sources = (result ? Object.keys(result.metafile.inputs).map((input) => realpathSync(path.resolve(input))) : packageFiles(current.root))
361
+ .filter((file) => this.packageRoot(file) === current.root && /\.[cm]?[jt]s$/.test(file))
362
+ .map((file) => readFileSync(file, 'utf8'))
363
+ .join('\n');
364
+ for (const dependency of this.dependencies(current.root)) {
365
+ if (dependency.root === this.project || included.has(dependency.root))
366
+ continue;
367
+ if (!new RegExp(`(['"\`])${dependency.name.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}\\1`).test(sources))
368
+ continue;
369
+ let entry;
370
+ try {
371
+ entry = createRequire(path.join(current.root, 'package.json')).resolve(dependency.name);
372
+ }
373
+ catch {
374
+ //A package without a CommonJS entry: kept as a whole
375
+ }
376
+ if (entry && !isBuiltin(entry))
377
+ pending.push({ root: dependency.root, entries: new Set([realpathSync(entry)]) });
378
+ else {
379
+ included.add(dependency.root);
380
+ this.whole.add(dependency.root);
381
+ }
382
+ }
383
+ }
384
+ //The packages of the shared libraries (the addons of the platform packages, the libraries they are linked with)
385
+ const libraries = [...included];
386
+ while (libraries.length) {
387
+ for (const { root: dependency } of this.dependencies(libraries.pop())) {
388
+ if (!included.has(dependency) && this.package(dependency)?.libraries) {
389
+ included.add(dependency);
390
+ libraries.push(dependency);
391
+ }
392
+ }
393
+ }
394
+ }
395
+ /**
396
+ * The packages a package requires, by their names
397
+ * @param root
398
+ */
399
+ requires(root) {
400
+ const required = this.required.get(root) ?? new Map();
401
+ this.required.set(root, required);
402
+ return required;
403
+ }
404
+ /**
405
+ * The layout of the packages of the tree, without links (as npm installs them, the tree is moved, copied, archived
406
+ * and installed as it is): the packages the application loads in node_modules, the packages each package requires
407
+ * (traced) or declares where node finds them from it: shared when node finds the same package, at the top when its
408
+ * name is free there, else in the node_modules of the package
409
+ * @param included the packages of the tree
410
+ * @return the packages by their directories in the tree
411
+ */
412
+ layout(included) {
413
+ const placed = new Map();
414
+ const queue = [];
415
+ const place = (directory, root) => queue.push(placed.set(directory, root) && directory);
416
+ const drain = () => {
417
+ while (queue.length) {
418
+ const directory = queue.shift();
419
+ const root = placed.get(directory);
420
+ const needed = new Map(this.required.get(root));
421
+ for (const dependency of this.dependencies(root)) {
422
+ if (included.has(dependency.root) && !needed.has(dependency.name))
423
+ needed.set(dependency.name, dependency.root);
424
+ }
425
+ for (const [name, dependency] of [...needed].sort(([a], [b]) => a.localeCompare(b))) {
426
+ if (dependency === root || !included.has(dependency))
427
+ continue;
428
+ const found = lookupDirectories(directory, name).find((lookup) => placed.has(lookup));
429
+ if (found !== undefined && placed.get(found) === dependency)
430
+ continue;
431
+ place(found === undefined ? `node_modules/${name}` : `${directory}/node_modules/${name}`, dependency);
432
+ }
433
+ }
434
+ };
435
+ for (const [name, { root }] of [...this.externals].sort(([a], [b]) => a.localeCompare(b)))
436
+ place(`node_modules/${name}`, root);
437
+ drain();
438
+ //The packages reached otherwise (by the libraries of a package): at the top
439
+ const reached = new Set(placed.values());
440
+ for (const root of [...included].sort()) {
441
+ const name = this.package(root)?.name;
442
+ if (reached.has(root) || !name || placed.has(`node_modules/${name}`))
443
+ continue;
444
+ place(`node_modules/${name}`, root);
445
+ }
446
+ drain();
447
+ return placed;
448
+ }
449
+ /**
450
+ * The files of a package of the tree which are loaded at run time, without the ones node never loads (UNUSED): the
451
+ * packages of shared libraries (which find their addons by computed paths), and the ones kept as a whole, have all
452
+ * their files; the others have the JavaScript files they load (traced) and all their other files (the data they read)
453
+ * @param root
454
+ */
455
+ packageFiles(root) {
456
+ const files = packageFiles(root).filter((file) => !UNUSED.test(file));
457
+ const loaded = this.loaded.get(root);
458
+ if (!loaded || this.whole.has(root) || this.package(root)?.libraries)
459
+ return files;
460
+ return files.filter((file) => !SCRIPT.test(file) || loaded.has(file));
461
+ }
462
+ /**
463
+ * The native tree of the application, once it is bundled; undefined when it has no native addon
464
+ */
465
+ async collect() {
466
+ if (!this.externals.size && !this.addons.size)
467
+ return undefined;
468
+ const { build } = await import('esbuild');
469
+ const included = new Set();
470
+ for (const { root, entries } of this.externals.values())
471
+ await this.trace(build, root, entries, included);
472
+ const files = [];
473
+ for (const [directory, root] of [...this.layout(included)].sort(([a], [b]) => a.localeCompare(b))) {
474
+ for (const file of this.packageFiles(root))
475
+ files.push({ path: `${directory}/${path.relative(root, file).split(path.sep).join('/')}`, source: file, mode: statSync(file).mode & 0o777 });
476
+ }
477
+ for (const [key, file] of this.addons)
478
+ files.push({ path: key, source: file, mode: statSync(file).mode & 0o777 });
479
+ return { modules: [...this.externals.keys()].sort(), files: files, links: [], hash: hashNativeTree(files, []) };
480
+ }
481
+ }
482
+ /**
483
+ * The hash of a native tree, which names the directory it is extracted into: its files and their content, its links
484
+ * @param files
485
+ * @param links
486
+ */
487
+ export function hashNativeTree(files, links) {
488
+ const hash = createHash('sha256');
489
+ for (const file of files)
490
+ hash.update(`${file.path}\0${file.mode}\0`).update(readFileSync(file.source));
491
+ for (const link of links)
492
+ hash.update(`${link.path}\0${link.target}\0`);
493
+ return hash.digest('hex').slice(0, 16);
494
+ }
495
+ /**
496
+ * Write a native tree into a directory (next to a bundle)
497
+ * @param tree
498
+ * @param directory
499
+ */
500
+ export async function writeNativeTree(tree, directory) {
501
+ await rm(directory, { recursive: true, force: true });
502
+ for (const file of tree.files) {
503
+ const target = path.join(directory, ...file.path.split('/'));
504
+ await mkdir(path.dirname(target), { recursive: true });
505
+ await writeFile(target, readFileSync(file.source), { mode: file.mode });
506
+ }
507
+ for (const link of tree.links) {
508
+ const at = path.join(directory, ...link.path.split('/'));
509
+ const target = path.join(directory, ...link.target.split('/'));
510
+ await mkdir(path.dirname(at), { recursive: true });
511
+ //The junctions of Windows need no privilege
512
+ if (process.platform === 'win32')
513
+ await symlink(target, at, 'junction');
514
+ else
515
+ await symlink(path.relative(path.dirname(at), target), at, 'dir');
516
+ }
517
+ }
518
+ /**
519
+ * Pack a native tree into the assets of an executable: its gzipped files and their index
520
+ * @param tree
521
+ */
522
+ export function packNativeTree(tree) {
523
+ const chunks = [];
524
+ const files = [];
525
+ let offset = 0;
526
+ for (const file of tree.files) {
527
+ const content = readFileSync(file.source);
528
+ files.push({ path: file.path, offset: offset, size: content.length, mode: file.mode });
529
+ chunks.push(content);
530
+ offset += content.length;
531
+ }
532
+ return { index: { hash: tree.hash, files: files, links: tree.links }, data: gzipSync(Buffer.concat(chunks)) };
533
+ }