vanilla-builder 0.1.0 → 0.2.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.
- package/CHANGELOG.md +20 -0
- package/README.es.md +76 -7
- package/README.md +76 -7
- package/bin/builder.js +106 -2
- package/package.json +7 -3
- package/src/builder.js +272 -89
- package/src/compress.js +44 -0
- package/src/config.js +197 -190
- package/src/deps.js +60 -1
- package/src/files.js +7 -0
- package/src/js.js +26 -34
- package/src/jsimports.js +54 -0
- package/src/jsinfo.js +19 -3
- package/src/pool.js +341 -0
- package/src/report.js +14 -19
- package/src/tersercore.js +33 -0
- package/src/urls.js +3 -0
- package/src/util.js +5 -1
- package/src/verify.js +13 -23
- package/src/worker.js +26 -0
package/src/js.js
CHANGED
|
@@ -4,11 +4,10 @@ import fs from 'node:fs';
|
|
|
4
4
|
import * as esbuild from 'esbuild';
|
|
5
5
|
import * as acorn from 'acorn';
|
|
6
6
|
import * as walk from 'acorn-walk';
|
|
7
|
-
import { analyzeJs, compareGlobals
|
|
8
|
-
import { obfuscateSafe } from './obfuscate.js';
|
|
7
|
+
import { analyzeJs, compareGlobals } from './jsinfo.js';
|
|
9
8
|
import { importOptional } from './optional.js';
|
|
10
9
|
import { replaceWithMap } from './sourcemap.js';
|
|
11
|
-
import { relFrom, toPosix, BuildError, rootPrefix, hasOwn, shortHash } from './util.js';
|
|
10
|
+
import { relFrom, toPosix, BuildError, rootPrefix, hasOwn, shortHash, jsOutPath } from './util.js';
|
|
12
11
|
import { classifyUrl } from './urls.js';
|
|
13
12
|
import { formatMessage, esbuildErrorText } from './css.js';
|
|
14
13
|
|
|
@@ -93,7 +92,7 @@ export async function processClassic(ctx, { code, id, info, obfuscate, sourcemap
|
|
|
93
92
|
if (!done) notes.push('se deja sin minificar para no romperlo');
|
|
94
93
|
}
|
|
95
94
|
if (obfuscate) {
|
|
96
|
-
const o =
|
|
95
|
+
const o = await ctx.heavy.obfuscate(out, { isModule: false, options: ctx.obfuscatorOptions, id, globals: info.top });
|
|
97
96
|
if (o.code) {
|
|
98
97
|
out = o.code;
|
|
99
98
|
map = null;
|
|
@@ -138,7 +137,7 @@ export async function processModuleStandalone(ctx, { code, id, info, obfuscate,
|
|
|
138
137
|
}
|
|
139
138
|
}
|
|
140
139
|
if (obfuscate) {
|
|
141
|
-
const o =
|
|
140
|
+
const o = await ctx.heavy.obfuscate(out, { isModule: true, options: ctx.obfuscatorOptions, id });
|
|
142
141
|
if (o.code) {
|
|
143
142
|
out = o.code;
|
|
144
143
|
map = null;
|
|
@@ -154,8 +153,8 @@ export async function processModuleStandalone(ctx, { code, id, info, obfuscate,
|
|
|
154
153
|
}
|
|
155
154
|
|
|
156
155
|
/** Ofusca un módulo ya generado (paquete, chunk o worker). */
|
|
157
|
-
export function obfuscateModule(ctx, code, id) {
|
|
158
|
-
const o =
|
|
156
|
+
export async function obfuscateModule(ctx, code, id) {
|
|
157
|
+
const o = await ctx.heavy.obfuscate(code, { isModule: true, options: ctx.obfuscatorOptions, id });
|
|
159
158
|
return o.code ? { code: o.code } : { code, note: `no se ofuscó: ${o.reason}` };
|
|
160
159
|
}
|
|
161
160
|
|
|
@@ -196,6 +195,14 @@ export function mapSpecifier(ctx, importMap, spec, importerDir) {
|
|
|
196
195
|
|
|
197
196
|
const EXTERNAL_SPEC = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
198
197
|
|
|
198
|
+
function isFile(abs) {
|
|
199
|
+
try {
|
|
200
|
+
return fs.statSync(abs).isFile();
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
199
206
|
function modulePlugin(ctx, group, state) {
|
|
200
207
|
const isDepPath = (abs) => {
|
|
201
208
|
if (!abs) return false;
|
|
@@ -245,6 +252,11 @@ function modulePlugin(ctx, group, state) {
|
|
|
245
252
|
if (!state.vendor || a.pluginData?.vbInner || r?.external || r?.errors) return r;
|
|
246
253
|
if (a.namespace !== 'vb-inline' && isDepPath(a.importer)) return r;
|
|
247
254
|
let abs = r?.path;
|
|
255
|
+
// Ruta relativa a un archivo que existe: se resuelve aquí, sin otro viaje de ida y vuelta a esbuild.
|
|
256
|
+
if (!abs && /^\.\.?\//.test(a.path) && a.namespace !== 'vb-inline' && a.resolveDir) {
|
|
257
|
+
const direct = path.resolve(a.resolveDir, a.path);
|
|
258
|
+
if (path.extname(direct) && isFile(direct)) abs = direct;
|
|
259
|
+
}
|
|
248
260
|
if (!abs) {
|
|
249
261
|
const res = await b.resolve(a.path, {
|
|
250
262
|
kind: a.kind,
|
|
@@ -290,9 +302,12 @@ function modulePlugin(ctx, group, state) {
|
|
|
290
302
|
}
|
|
291
303
|
return undefined;
|
|
292
304
|
};
|
|
293
|
-
b.onLoad({ filter: /\.(?:m?js|cjs)$/i }, (a) => {
|
|
305
|
+
b.onLoad({ filter: /\.(?:m?js|cjs|m?ts|cts)$/i }, (a) => {
|
|
294
306
|
const rel = relFrom(ctx.root, a.path);
|
|
295
307
|
const local = rel != null && !/(^|\/)node_modules\//.test(rel) && ctx.files.has(rel);
|
|
308
|
+
// Los .ts propios llegan ya transpilados (readText); los de node_modules los transpila esbuild.
|
|
309
|
+
const isTs = /\.(?:m?ts|cts)$/i.test(a.path);
|
|
310
|
+
if (!local && isTs) return { contents: fs.readFileSync(a.path, 'utf8'), loader: 'ts' };
|
|
296
311
|
const code = local ? ctx.readText(rel, { kind: 'js' }).text : fs.readFileSync(a.path, 'utf8');
|
|
297
312
|
if (local) state.consumed.add(rel);
|
|
298
313
|
return { contents: prepare(code, local ? rel : toPosix(a.path), local ? path.posix.dirname(rel) : '', local), loader: 'js' };
|
|
@@ -319,30 +334,7 @@ export async function ensureTerser(projectRoot) {
|
|
|
319
334
|
export async function terserPass(ctx, code, { isModule, level, globals }) {
|
|
320
335
|
const js = ctx.cfg.js;
|
|
321
336
|
if (!terser || js.minifier !== 'terser' || !js.minify || ctx.cfg.sourcemap) return code;
|
|
322
|
-
|
|
323
|
-
let out;
|
|
324
|
-
try {
|
|
325
|
-
const r = await terser.minify(code, {
|
|
326
|
-
module: isModule,
|
|
327
|
-
toplevel: isModule,
|
|
328
|
-
ecma,
|
|
329
|
-
compress: { passes: 3, ecma, module: isModule, toplevel: isModule },
|
|
330
|
-
mangle: { toplevel: isModule },
|
|
331
|
-
keep_fnames: js.keepNames,
|
|
332
|
-
keep_classnames: js.keepNames,
|
|
333
|
-
format: { comments: /^!|@license|@preserve/i, ascii_only: true, ecma },
|
|
334
|
-
});
|
|
335
|
-
out = r.code;
|
|
336
|
-
} catch {
|
|
337
|
-
return code;
|
|
338
|
-
}
|
|
339
|
-
if (!out || out.length >= code.trimEnd().length) return code;
|
|
340
|
-
if (isModule) {
|
|
341
|
-
const a = moduleShape(code);
|
|
342
|
-
const b = moduleShape(out);
|
|
343
|
-
if (!a || !b || a.exports !== b.exports || a.imports !== b.imports) return code;
|
|
344
|
-
} else if (!globals || !compareGlobals(globals, out).ok) return code;
|
|
345
|
-
return out;
|
|
337
|
+
return ctx.heavy.terser(code, { isModule, level, globals: isModule ? undefined : globals, keepNames: js.keepNames });
|
|
346
338
|
}
|
|
347
339
|
|
|
348
340
|
/** Bytes de origen que aportaron código a una salida (incluye paquetes de node_modules). */
|
|
@@ -552,7 +544,7 @@ export async function buildModuleGroup(ctx, group) {
|
|
|
552
544
|
export async function buildModuleWorker(ctx, rel) {
|
|
553
545
|
const group = { importMap: null, virtual: new Map(), entries: new Set([rel]) };
|
|
554
546
|
const state = newState('scan');
|
|
555
|
-
const outfile = path.join(ctx.root, '__vb_out__', rel);
|
|
547
|
+
const outfile = path.join(ctx.root, '__vb_out__', jsOutPath(rel));
|
|
556
548
|
const run = (st, extra) =>
|
|
557
549
|
esbuild.build({
|
|
558
550
|
absWorkingDir: ctx.root,
|
|
@@ -584,7 +576,7 @@ export async function buildModuleWorker(ctx, rel) {
|
|
|
584
576
|
if (f.path.endsWith('.map')) map = f.text;
|
|
585
577
|
else code = f.text.replace(/\n\/\/# sourceMappingURL=.*\n?$/, '\n');
|
|
586
578
|
}
|
|
587
|
-
({ code, map } = finalizeMarkers(code, map, rel));
|
|
579
|
+
({ code, map } = finalizeMarkers(code, map, jsOutPath(rel)));
|
|
588
580
|
return { code, map, consumed: emit.consumed };
|
|
589
581
|
} catch (err) {
|
|
590
582
|
return { error: esbuildErrorText(err, ctx.root) };
|
package/src/jsimports.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Rutas importadas por un JS ya generado (para verificar que existen en la salida).
|
|
2
|
+
import * as acorn from 'acorn';
|
|
3
|
+
import * as walk from 'acorn-walk';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reescribe los imports relativos de un archivo TypeScript ya transpilado para que funcionen en el navegador:
|
|
7
|
+
* "./x.ts" → "./x.js" (tras compilar existe con extensión .js) y "./x" → "./x.js" o "./x/index.js"
|
|
8
|
+
* según `complete(spec)`, que devuelve la ruta completa o null. Si el código no se puede leer, se devuelve igual.
|
|
9
|
+
*/
|
|
10
|
+
export function rewriteTsImports(code, complete = () => null) {
|
|
11
|
+
let ast;
|
|
12
|
+
try {
|
|
13
|
+
ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module', allowHashBang: true });
|
|
14
|
+
} catch {
|
|
15
|
+
return code;
|
|
16
|
+
}
|
|
17
|
+
const edits = [];
|
|
18
|
+
const consider = (src) => {
|
|
19
|
+
if (src?.type !== 'Literal' || typeof src.value !== 'string' || !/^\.{1,2}\//.test(src.value)) return;
|
|
20
|
+
let spec = src.value;
|
|
21
|
+
if (/\.(m|c)?ts$/i.test(spec)) spec = spec.replace(/\.(m|c)?ts$/i, (_, p) => `.${(p ?? '').toLowerCase()}js`);
|
|
22
|
+
else if (!/\.[a-z0-9]+$/i.test(spec.split('/').pop())) spec = complete(spec) ?? spec;
|
|
23
|
+
if (spec !== src.value) edits.push({ start: src.start, end: src.end, text: JSON.stringify(spec) });
|
|
24
|
+
};
|
|
25
|
+
const isImportMetaUrl = (m) =>
|
|
26
|
+
m?.type === 'MemberExpression' && m.object.type === 'MetaProperty' && m.object.meta.name === 'import' && m.property.name === 'url';
|
|
27
|
+
walk.full(ast, (n) => {
|
|
28
|
+
if (n.type === 'ImportDeclaration' || n.type === 'ExportAllDeclaration' || n.type === 'ExportNamedDeclaration') consider(n.source);
|
|
29
|
+
else if (n.type === 'ImportExpression') consider(n.source);
|
|
30
|
+
// new URL('./worker.ts', import.meta.url): workers y recursos cargados por ruta.
|
|
31
|
+
else if (n.type === 'NewExpression' && n.callee.type === 'Identifier' && n.callee.name === 'URL' && isImportMetaUrl(n.arguments[1]))
|
|
32
|
+
consider(n.arguments[0]);
|
|
33
|
+
});
|
|
34
|
+
let out = code;
|
|
35
|
+
for (const e of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Devuelve {imports} o {error} si el código no es JavaScript válido. */
|
|
40
|
+
export function jsImports(code, isModule) {
|
|
41
|
+
let ast;
|
|
42
|
+
try {
|
|
43
|
+
ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: isModule ? 'module' : 'script', allowHashBang: true });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return { error: err.message };
|
|
46
|
+
}
|
|
47
|
+
const imports = [];
|
|
48
|
+
walk.full(ast, (n) => {
|
|
49
|
+
if ((n.type === 'ImportDeclaration' || n.type === 'ExportAllDeclaration' || n.type === 'ExportNamedDeclaration') && n.source)
|
|
50
|
+
imports.push(n.source.value);
|
|
51
|
+
if (n.type === 'ImportExpression' && n.source.type === 'Literal' && typeof n.source.value === 'string') imports.push(n.source.value);
|
|
52
|
+
});
|
|
53
|
+
return { imports };
|
|
54
|
+
}
|
package/src/jsinfo.js
CHANGED
|
@@ -124,7 +124,6 @@ export function analyzeJs(code, opts = {}) {
|
|
|
124
124
|
const info = {
|
|
125
125
|
ok: true,
|
|
126
126
|
isModule,
|
|
127
|
-
level: detectLevel(code, sourceType),
|
|
128
127
|
strict,
|
|
129
128
|
top: isModule ? new Map() : globalBindings(ast, strict),
|
|
130
129
|
imports: [],
|
|
@@ -139,6 +138,12 @@ export function analyzeJs(code, opts = {}) {
|
|
|
139
138
|
hasImportMeta: false,
|
|
140
139
|
hasModuleSyntax: false,
|
|
141
140
|
};
|
|
141
|
+
// El nivel ES cuesta varios parseos: solo se calcula si alguien lo pide (la mayoría de análisis no lo necesitan).
|
|
142
|
+
let level;
|
|
143
|
+
Object.defineProperty(info, 'level', {
|
|
144
|
+
enumerable: true,
|
|
145
|
+
get: () => (level === undefined ? (level = detectLevel(code, sourceType)) : level),
|
|
146
|
+
});
|
|
142
147
|
const addWorker = (arg, kind, optsNode) => {
|
|
143
148
|
if (!arg) return;
|
|
144
149
|
let spec = null;
|
|
@@ -249,8 +254,19 @@ export function checkSyntax(code, isModule) {
|
|
|
249
254
|
* `allowPrefix` permite nombres nuevos con ese prefijo (los que añade el ofuscador).
|
|
250
255
|
*/
|
|
251
256
|
export function compareGlobals(before, afterCode, allowPrefix = null) {
|
|
252
|
-
|
|
253
|
-
|
|
257
|
+
// Solo hacen falta las globales: un parseo y un recorrido, sin el análisis completo.
|
|
258
|
+
let ast;
|
|
259
|
+
try {
|
|
260
|
+
ast = parse(afterCode, 'script', LATEST);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
return { ok: false, reason: `el resultado no es JS válido (${err.message})` };
|
|
263
|
+
}
|
|
264
|
+
const after = {
|
|
265
|
+
top: globalBindings(
|
|
266
|
+
ast,
|
|
267
|
+
ast.body.some((s) => s.directive === 'use strict'),
|
|
268
|
+
),
|
|
269
|
+
};
|
|
254
270
|
for (const [name, kind] of before) {
|
|
255
271
|
if (!after.top.has(name)) return { ok: false, reason: `desaparece la global "${name}"` };
|
|
256
272
|
if (after.top.get(name) !== kind) return { ok: false, reason: `la global "${name}" cambia de tipo (${kind} → ${after.top.get(name)})` };
|
package/src/pool.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// Trabajo pesado (ofuscación y terser) en hilos paralelos, con caché persistente entre compilaciones.
|
|
2
|
+
// La salida no depende del orden ni del número de hilos: cada tarea es determinista (la semilla del
|
|
3
|
+
// ofuscador sale del contenido), así que reutilizar un resultado guardado da exactamente lo mismo.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { Worker } from 'node:worker_threads';
|
|
10
|
+
import { ensureObfuscator, obfuscateSafe } from './obfuscate.js';
|
|
11
|
+
import { importOptional } from './optional.js';
|
|
12
|
+
import { terserMinify } from './tersercore.js';
|
|
13
|
+
import { jsImports } from './jsimports.js';
|
|
14
|
+
|
|
15
|
+
const WORKER_URL = new URL('./worker.js', import.meta.url);
|
|
16
|
+
const OWN_VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
17
|
+
const CACHE_FORMAT = 1;
|
|
18
|
+
const CACHE_MAX_BYTES = 256 * 1024 * 1024;
|
|
19
|
+
const TASKS_PER_NEW_WORKER = 4;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hilos por defecto: aproximadamente los núcleos físicos (la mitad de los lógicos), máximo 8.
|
|
23
|
+
* Medido: más allá de eso el ofuscador apenas escala (memoria y recolector de basura).
|
|
24
|
+
*/
|
|
25
|
+
export function autoWorkers() {
|
|
26
|
+
const cpus = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
|
|
27
|
+
return Math.max(1, Math.min(8, Math.floor(cpus / 2)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Versión instalada de un paquete (para invalidar la caché al actualizarlo). */
|
|
31
|
+
function packageVersion(name, root) {
|
|
32
|
+
for (const base of [new URL('../package.json', import.meta.url), path.join(root ?? process.cwd(), 'package.json')]) {
|
|
33
|
+
try {
|
|
34
|
+
let dir = path.dirname(createRequire(base).resolve(name));
|
|
35
|
+
for (let i = 0; i < 6; i++, dir = path.dirname(dir)) {
|
|
36
|
+
const file = path.join(dir, 'package.json');
|
|
37
|
+
if (fs.existsSync(file)) {
|
|
38
|
+
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
39
|
+
if (pkg.name === name) return pkg.version;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// no instalado aquí: se prueba la siguiente ubicación
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Carpeta de caché: node_modules/.cache/vanilla-builder más cercano, o el temporal del sistema. */
|
|
50
|
+
export function defaultCacheDir(root) {
|
|
51
|
+
// BUILDER_CACHE_DIR: para CI (guardarla entre ejecuciones) o para aislar pruebas.
|
|
52
|
+
if (process.env.BUILDER_CACHE_DIR) return path.resolve(process.env.BUILDER_CACHE_DIR);
|
|
53
|
+
for (let dir = root, i = 0; i < 20; i++) {
|
|
54
|
+
const nm = path.join(dir, 'node_modules');
|
|
55
|
+
if (fs.existsSync(nm)) return path.join(nm, '.cache', 'vanilla-builder');
|
|
56
|
+
const parent = path.dirname(dir);
|
|
57
|
+
if (parent === dir) break;
|
|
58
|
+
dir = parent;
|
|
59
|
+
}
|
|
60
|
+
const id = crypto.createHash('sha256').update(root).digest('hex').slice(0, 12);
|
|
61
|
+
return path.join(os.tmpdir(), 'vanilla-builder-cache', id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const plain = (v) => (v instanceof Map ? [...v.entries()] : v);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{root: string, workers?: number, cacheDir?: string|null}} opts
|
|
68
|
+
*/
|
|
69
|
+
export function createHeavy({ root, workers = autoWorkers(), cacheDir = null }) {
|
|
70
|
+
const stats = { tasks: 0, cacheHits: 0, threads: 0 };
|
|
71
|
+
const versions = new Map();
|
|
72
|
+
const idle = [];
|
|
73
|
+
const all = [];
|
|
74
|
+
const queue = [];
|
|
75
|
+
const pending = new Map();
|
|
76
|
+
let nextId = 1;
|
|
77
|
+
let broken = false;
|
|
78
|
+
let inProcessTerser = null;
|
|
79
|
+
|
|
80
|
+
const versionOf = (name) => {
|
|
81
|
+
if (!versions.has(name)) versions.set(name, packageVersion(name, root));
|
|
82
|
+
return versions.get(name);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function cacheKey(kind, payload) {
|
|
86
|
+
const engine = kind === 'obfuscate' ? 'javascript-obfuscator' : kind === 'terser' ? 'terser' : 'acorn';
|
|
87
|
+
const meta = JSON.stringify([CACHE_FORMAT, OWN_VERSION, kind, versionOf(engine), payload.opts, plain(payload.opts.globals)]);
|
|
88
|
+
return crypto.createHash('sha256').update(meta).update('\0').update(payload.code).digest('hex');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Valores pequeños (importaciones, tamaños, resultados cortos): un único índice JSON en vez de miles
|
|
92
|
+
// de archivos diminutos, que en Windows (antivirus, indexado) cuestan más que calcularlos.
|
|
93
|
+
const SMALL_FILE = cacheDir ? path.join(cacheDir, 'small-v1.json') : null;
|
|
94
|
+
const SMALL_LIMIT = 2048;
|
|
95
|
+
const SMALL_MAX_ENTRIES = 100000;
|
|
96
|
+
let small = null;
|
|
97
|
+
let smallDirty = false;
|
|
98
|
+
function smallStore() {
|
|
99
|
+
if (!small) {
|
|
100
|
+
small = new Map();
|
|
101
|
+
try {
|
|
102
|
+
for (const [k, v] of JSON.parse(fs.readFileSync(SMALL_FILE, 'utf8'))) small.set(k, v);
|
|
103
|
+
} catch {
|
|
104
|
+
// índice ausente o dañado: se empieza vacío
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return small;
|
|
108
|
+
}
|
|
109
|
+
function saveSmall() {
|
|
110
|
+
if (!smallDirty || !SMALL_FILE) return;
|
|
111
|
+
const entries = [...small.entries()].slice(-SMALL_MAX_ENTRIES);
|
|
112
|
+
const tmp = `${SMALL_FILE}.${process.pid}.tmp`;
|
|
113
|
+
try {
|
|
114
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
115
|
+
fs.writeFileSync(tmp, JSON.stringify(entries));
|
|
116
|
+
fs.renameSync(tmp, SMALL_FILE);
|
|
117
|
+
} catch {
|
|
118
|
+
try {
|
|
119
|
+
fs.rmSync(tmp, { force: true });
|
|
120
|
+
} catch {
|
|
121
|
+
// la caché es opcional
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readCache(key) {
|
|
127
|
+
if (!cacheDir) return undefined;
|
|
128
|
+
const s = smallStore();
|
|
129
|
+
if (s.has(key)) {
|
|
130
|
+
// Al final del Map = usado recientemente (se conservan los últimos al recortar).
|
|
131
|
+
const v = s.get(key);
|
|
132
|
+
s.delete(key);
|
|
133
|
+
s.set(key, v);
|
|
134
|
+
smallDirty = true;
|
|
135
|
+
return v;
|
|
136
|
+
}
|
|
137
|
+
const file = path.join(cacheDir, key.slice(0, 2), `${key}.json`);
|
|
138
|
+
try {
|
|
139
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
140
|
+
const now = new Date();
|
|
141
|
+
fs.utimes(file, now, now, () => {});
|
|
142
|
+
return value;
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function writeCache(key, value) {
|
|
149
|
+
if (!cacheDir) return;
|
|
150
|
+
const json = JSON.stringify(value);
|
|
151
|
+
if (json.length <= SMALL_LIMIT) {
|
|
152
|
+
smallStore().set(key, value);
|
|
153
|
+
smallDirty = true;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const dir = path.join(cacheDir, key.slice(0, 2));
|
|
157
|
+
const file = path.join(dir, `${key}.json`);
|
|
158
|
+
const tmp = `${file}.${process.pid}.${nextId}.tmp`;
|
|
159
|
+
try {
|
|
160
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
161
|
+
fs.writeFileSync(tmp, json);
|
|
162
|
+
fs.renameSync(tmp, file);
|
|
163
|
+
} catch {
|
|
164
|
+
try {
|
|
165
|
+
fs.rmSync(tmp, { force: true });
|
|
166
|
+
} catch {
|
|
167
|
+
// la caché es opcional: un fallo al escribirla no afecta a la compilación
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function runInProcess(kind, payload) {
|
|
173
|
+
if (kind === 'imports') return jsImports(payload.code, payload.opts.isModule);
|
|
174
|
+
if (kind === 'obfuscate') {
|
|
175
|
+
await ensureObfuscator(root);
|
|
176
|
+
return obfuscateSafe(payload.code, payload.opts);
|
|
177
|
+
}
|
|
178
|
+
inProcessTerser ??= await importOptional('terser', root, 'para la compresión máxima');
|
|
179
|
+
return terserMinify(inProcessTerser, payload.code, payload.opts);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function spawn() {
|
|
183
|
+
const w = new Worker(WORKER_URL, { workerData: { root } });
|
|
184
|
+
w.on('message', ({ id, result, error }) => {
|
|
185
|
+
const p = pending.get(id);
|
|
186
|
+
pending.delete(id);
|
|
187
|
+
w.busy = null;
|
|
188
|
+
idle.push(w);
|
|
189
|
+
pump();
|
|
190
|
+
if (!p) return;
|
|
191
|
+
if (error) p.reject(new Error(error));
|
|
192
|
+
else p.resolve(result);
|
|
193
|
+
});
|
|
194
|
+
w.on('error', (err) => {
|
|
195
|
+
// Si un hilo muere, sus tareas (y las siguientes) se hacen en el hilo principal.
|
|
196
|
+
broken = true;
|
|
197
|
+
const id = w.busy;
|
|
198
|
+
if (id != null && pending.has(id)) {
|
|
199
|
+
const p = pending.get(id);
|
|
200
|
+
pending.delete(id);
|
|
201
|
+
runInProcess(p.kind, p.payload).then(p.resolve, p.reject);
|
|
202
|
+
}
|
|
203
|
+
void err;
|
|
204
|
+
});
|
|
205
|
+
all.push(w);
|
|
206
|
+
stats.threads = all.length;
|
|
207
|
+
return w;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// El hilo principal toma la primera tarea libre (no hay que esperar a que arranque nada) y solo se
|
|
211
|
+
// crean hilos cuando hay más trabajo en cola: uno por tarea pendiente, hasta el máximo. Así un proyecto
|
|
212
|
+
// pequeño nunca paga el arranque de los hilos (cargar el ofuscador cuesta ~0,5 s por hilo).
|
|
213
|
+
let mainBusy = false;
|
|
214
|
+
function runOnMain(t) {
|
|
215
|
+
mainBusy = true;
|
|
216
|
+
setImmediate(async () => {
|
|
217
|
+
try {
|
|
218
|
+
t.resolve(await runInProcess(t.kind, t.payload));
|
|
219
|
+
} catch (err) {
|
|
220
|
+
t.reject(err);
|
|
221
|
+
} finally {
|
|
222
|
+
mainBusy = false;
|
|
223
|
+
pump();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function pump() {
|
|
229
|
+
while (queue.length) {
|
|
230
|
+
if (broken) {
|
|
231
|
+
const t = queue.shift();
|
|
232
|
+
runInProcess(t.kind, t.payload).then(t.resolve, t.reject);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
let w = idle.pop();
|
|
236
|
+
if (!w) {
|
|
237
|
+
if (!mainBusy) {
|
|
238
|
+
runOnMain(queue.shift());
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
// Un hilo nuevo solo compensa con trabajo de sobra para él: arrancarlo cuesta lo que varias tareas.
|
|
242
|
+
if (all.length >= workers || queue.length < TASKS_PER_NEW_WORKER * (all.length + 1)) return;
|
|
243
|
+
w = spawn();
|
|
244
|
+
}
|
|
245
|
+
const t = queue.shift();
|
|
246
|
+
const id = nextId++;
|
|
247
|
+
w.busy = id;
|
|
248
|
+
pending.set(id, t);
|
|
249
|
+
w.postMessage({ id, kind: t.kind, payload: t.payload });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function run(kind, payload) {
|
|
254
|
+
const counted = kind === 'obfuscate' || kind === 'terser';
|
|
255
|
+
if (counted) stats.tasks++;
|
|
256
|
+
const key = cacheDir ? cacheKey(kind, payload) : null;
|
|
257
|
+
if (key) {
|
|
258
|
+
const hit = readCache(key);
|
|
259
|
+
if (hit !== undefined) {
|
|
260
|
+
if (counted) stats.cacheHits++;
|
|
261
|
+
return hit;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const result =
|
|
265
|
+
workers > 0
|
|
266
|
+
? await new Promise((resolve, reject) => {
|
|
267
|
+
queue.push({ kind, payload, resolve, reject });
|
|
268
|
+
pump();
|
|
269
|
+
})
|
|
270
|
+
: await runInProcess(kind, payload);
|
|
271
|
+
if (key) writeCache(key, result);
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
stats,
|
|
277
|
+
/** Ofusca y verifica (ver obfuscateSafe). */
|
|
278
|
+
obfuscate: (code, opts) => run('obfuscate', { code, opts }),
|
|
279
|
+
/** Importaciones de un JS generado, o {error} si no es válido (verificación final). */
|
|
280
|
+
imports: (code, isModule) => run('imports', { code, opts: { isModule } }),
|
|
281
|
+
/** Valor costoso guardado en la caché por clave (p. ej. tamaños comprimidos de un contenido). */
|
|
282
|
+
async memo(kind, keyData, compute) {
|
|
283
|
+
const key = cacheDir
|
|
284
|
+
? crypto
|
|
285
|
+
.createHash('sha256')
|
|
286
|
+
.update(JSON.stringify([CACHE_FORMAT, kind]))
|
|
287
|
+
.update('\0')
|
|
288
|
+
.update(keyData)
|
|
289
|
+
.digest('hex')
|
|
290
|
+
: null;
|
|
291
|
+
if (key) {
|
|
292
|
+
const hit = readCache(key);
|
|
293
|
+
if (hit !== undefined) return hit;
|
|
294
|
+
}
|
|
295
|
+
const value = await compute();
|
|
296
|
+
if (key) writeCache(key, value);
|
|
297
|
+
return value;
|
|
298
|
+
},
|
|
299
|
+
/** Pasada de terser verificada (ver terserMinify). */
|
|
300
|
+
terser: (code, opts) => run('terser', { code, opts }),
|
|
301
|
+
async close() {
|
|
302
|
+
await Promise.all(all.map((w) => w.terminate()));
|
|
303
|
+
all.length = 0;
|
|
304
|
+
idle.length = 0;
|
|
305
|
+
if (cacheDir) {
|
|
306
|
+
saveSmall();
|
|
307
|
+
pruneCache(cacheDir);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Mantiene la caché por debajo del límite borrando lo usado hace más tiempo. */
|
|
314
|
+
function pruneCache(dir) {
|
|
315
|
+
let entries = [];
|
|
316
|
+
try {
|
|
317
|
+
for (const sub of fs.readdirSync(dir)) {
|
|
318
|
+
if (!/^[0-9a-f]{2}$/.test(sub)) continue;
|
|
319
|
+
const d = path.join(dir, sub);
|
|
320
|
+
for (const f of fs.readdirSync(d)) {
|
|
321
|
+
const file = path.join(d, f);
|
|
322
|
+
const st = fs.statSync(file);
|
|
323
|
+
entries.push({ file, size: st.size, time: st.mtimeMs });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
let total = entries.reduce((a, e) => a + e.size, 0);
|
|
330
|
+
if (total <= CACHE_MAX_BYTES) return;
|
|
331
|
+
entries = entries.sort((a, b) => a.time - b.time);
|
|
332
|
+
for (const e of entries) {
|
|
333
|
+
if (total <= CACHE_MAX_BYTES * 0.8) break;
|
|
334
|
+
try {
|
|
335
|
+
fs.rmSync(e.file, { force: true });
|
|
336
|
+
total -= e.size;
|
|
337
|
+
} catch {
|
|
338
|
+
// otro proceso pudo borrarlo
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
package/src/report.js
CHANGED
|
@@ -1,35 +1,30 @@
|
|
|
1
1
|
// Informe de tamaños (original, final, gzip y brotli) por archivo y totales.
|
|
2
|
-
import zlib from 'node:zlib';
|
|
3
2
|
import { formatBytes } from './util.js';
|
|
3
|
+
import { gzipOf, brotliOf, mapLimit } from './compress.js';
|
|
4
4
|
|
|
5
5
|
const COMPRESSIBLE = new Set(['html', 'css', 'js', 'json', 'svg', 'text']);
|
|
6
6
|
|
|
7
|
-
export function sizesOf(buf, type) {
|
|
7
|
+
export async function sizesOf(buf, type) {
|
|
8
8
|
const size = buf.length;
|
|
9
9
|
if (!COMPRESSIBLE.has(type) || size === 0) return { size, gzip: null, brotli: null };
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
gzip: zlib.gzipSync(buf, { level: 9 }).length,
|
|
13
|
-
brotli: zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length,
|
|
14
|
-
};
|
|
10
|
+
const [gz, br] = await Promise.all([gzipOf(buf), brotliOf(buf)]);
|
|
11
|
+
return { size, gzip: gz.length, brotli: br.length };
|
|
15
12
|
}
|
|
16
13
|
|
|
17
14
|
/** Filas del informe a partir de las salidas. */
|
|
18
|
-
export function buildReport(outputs) {
|
|
19
|
-
const rows = [];
|
|
15
|
+
export async function buildReport(outputs, heavy = null) {
|
|
20
16
|
let compressed = 0;
|
|
17
|
+
const items = [];
|
|
21
18
|
for (const [rel, o] of outputs) {
|
|
22
|
-
if (o.type === 'compressed')
|
|
23
|
-
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
if (o.copyFrom) {
|
|
27
|
-
rows.push({ file: rel, type: o.type, action: o.action, sources: o.sources, before: o.size, size: o.size, gzip: null, brotli: null });
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
const s = sizesOf(o.data, o.type);
|
|
31
|
-
rows.push({ file: rel, type: o.type, action: o.action, sources: o.sources, before: o.before ?? s.size, ...s });
|
|
19
|
+
if (o.type === 'compressed') compressed++;
|
|
20
|
+
else items.push([rel, o]);
|
|
32
21
|
}
|
|
22
|
+
const rows = await mapLimit(items, 64, async ([rel, o]) => {
|
|
23
|
+
if (o.copyFrom) return { file: rel, type: o.type, action: o.action, sources: o.sources, before: o.size, size: o.size, gzip: null, brotli: null };
|
|
24
|
+
const cached = heavy && COMPRESSIBLE.has(o.type) && o.data.length;
|
|
25
|
+
const s = cached ? await heavy.memo('sizes', o.data, () => sizesOf(o.data, o.type)) : await sizesOf(o.data, o.type);
|
|
26
|
+
return { file: rel, type: o.type, action: o.action, sources: o.sources, before: o.before ?? s.size, ...s };
|
|
27
|
+
});
|
|
33
28
|
rows.sort((a, b) => (a.type === b.type ? a.file.localeCompare(b.file) : order(a.type) - order(b.type)));
|
|
34
29
|
const totals = { before: 0, size: 0, gzip: 0, brotli: 0, files: rows.length };
|
|
35
30
|
const byType = {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Pasada de terser con verificación. Sin estado: se usa igual en el hilo principal y en los hilos de trabajo.
|
|
2
|
+
import { compareGlobals, moduleShape } from './jsinfo.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Comprime con terser y solo acepta el resultado si es más pequeño y conserva las globales (scripts)
|
|
6
|
+
* o las exportaciones/importaciones (módulos). Devuelve el código original si no compensa.
|
|
7
|
+
*/
|
|
8
|
+
export async function terserMinify(terser, code, { isModule, level, globals, keepNames = false }) {
|
|
9
|
+
const ecma = Math.min(2020, Math.max(5, level ?? 2020));
|
|
10
|
+
let out;
|
|
11
|
+
try {
|
|
12
|
+
const r = await terser.minify(code, {
|
|
13
|
+
module: isModule,
|
|
14
|
+
toplevel: isModule,
|
|
15
|
+
ecma,
|
|
16
|
+
compress: { passes: 3, ecma, module: isModule, toplevel: isModule },
|
|
17
|
+
mangle: { toplevel: isModule },
|
|
18
|
+
keep_fnames: keepNames,
|
|
19
|
+
keep_classnames: keepNames,
|
|
20
|
+
format: { comments: /^!|@license|@preserve/i, ascii_only: true, ecma },
|
|
21
|
+
});
|
|
22
|
+
out = r.code;
|
|
23
|
+
} catch {
|
|
24
|
+
return code;
|
|
25
|
+
}
|
|
26
|
+
if (!out || out.length >= code.trimEnd().length) return code;
|
|
27
|
+
if (isModule) {
|
|
28
|
+
const a = moduleShape(code);
|
|
29
|
+
const b = moduleShape(out);
|
|
30
|
+
if (!a || !b || a.exports !== b.exports || a.imports !== b.imports) return code;
|
|
31
|
+
} else if (!globals || !compareGlobals(globals, out).ok) return code;
|
|
32
|
+
return out;
|
|
33
|
+
}
|
package/src/urls.js
CHANGED
|
@@ -61,6 +61,9 @@ export function resolveRef(raw, baseDir, ctx, opts = {}) {
|
|
|
61
61
|
for (const c of candidates) {
|
|
62
62
|
if (ctx.files.has(c)) return { status: 'ok', rel: c, info };
|
|
63
63
|
}
|
|
64
|
+
// Convención de TypeScript: se escribe "./x.js" y el archivo es x.ts (su salida será justo x.js).
|
|
65
|
+
const ts = !isDirLike && /\.(m|c)?js$/i.test(rel) ? rel.replace(/\.(m|c)?js$/i, (_, p) => `.${p ?? ''}ts`) : null;
|
|
66
|
+
if (ts && ctx.files.has(ts)) return { status: 'ok', rel: ts, info };
|
|
64
67
|
for (const c of candidates) {
|
|
65
68
|
if (ctx.ignoredSet?.has(c)) return { status: 'ignored', rel: c, info };
|
|
66
69
|
// Diferencia solo de mayúsculas: en Windows funciona, en un servidor Linux no.
|
package/src/util.js
CHANGED
|
@@ -55,7 +55,11 @@ export function stripBom(text) {
|
|
|
55
55
|
|
|
56
56
|
export const extOf = (p) => path.posix.extname(p).toLowerCase();
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
// TypeScript se transpila al empezar y desde ahí se trata como JavaScript (sale con extensión .js).
|
|
59
|
+
export const TS_EXT = new Set(['.ts', '.mts', '.cts']);
|
|
60
|
+
export const JS_EXT = new Set(['.js', '.mjs', '.cjs', ...TS_EXT]);
|
|
61
|
+
/** Ruta de salida de un script: app.ts → app.js, x.mts → x.mjs, x.cts → x.cjs. */
|
|
62
|
+
export const jsOutPath = (rel) => rel.replace(/\.(m|c)?ts$/i, (_, p) => `.${(p ?? '').toLowerCase()}js`);
|
|
59
63
|
export const CSS_EXT = new Set(['.css']);
|
|
60
64
|
export const HTML_EXT = new Set(['.html', '.htm']);
|
|
61
65
|
export const JSON_EXT = new Set(['.json', '.webmanifest', '.map']);
|