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/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, moduleShape } from './jsinfo.js';
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 = obfuscateSafe(out, { isModule: false, options: ctx.obfuscatorOptions, id, globals: info.top });
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 = obfuscateSafe(out, { isModule: true, options: ctx.obfuscatorOptions, id });
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 = obfuscateSafe(code, { isModule: true, options: ctx.obfuscatorOptions, id });
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
- const ecma = Math.min(2020, Math.max(5, level ?? 2020));
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) };
@@ -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
- const after = analyzeJs(afterCode, { module: false });
253
- if (!after.ok) return { ok: false, reason: `el resultado no es JS válido (${after.error})` };
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
- return {
11
- size,
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
- compressed++;
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
- export const JS_EXT = new Set(['.js', '.mjs', '.cjs']);
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']);