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/builder.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // Orquestador: descubre dependencias, procesa cada tipo de archivo, verifica y publica la salida.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import zlib from 'node:zlib';
4
+ import { createHeavy, defaultCacheDir, autoWorkers } from './pool.js';
5
+ import { gzipOf, brotliOf, mapLimit } from './compress.js';
5
6
  import { scanProject, matchFiles, globMatcher } from './files.js';
6
7
  import { resolveRef, classifyUrl, parseSrcset } from './urls.js';
7
8
  import { analyzeJs } from './jsinfo.js';
@@ -18,9 +19,10 @@ import {
18
19
  levelOf,
19
20
  } from './js.js';
20
21
  import { resolveObfuscatorOptions, ensureObfuscator } from './obfuscate.js';
21
- import { detectDependency } from './deps.js';
22
+ import { detectDependency, dependencyPragma, packageNames } from './deps.js';
22
23
  import { assertSafeOutDir, writeStaging, swapIntoPlace, discardStaging, writeManifest, MANIFEST } from './output.js';
23
24
  import { verifyOutput } from './verify.js';
25
+ import { rewriteTsImports } from './jsimports.js';
24
26
  import { buildReport } from './report.js';
25
27
  import {
26
28
  BuildError,
@@ -29,6 +31,8 @@ import {
29
31
  BOM,
30
32
  extOf,
31
33
  JS_EXT,
34
+ TS_EXT,
35
+ jsOutPath,
32
36
  CSS_EXT,
33
37
  HTML_EXT,
34
38
  shortHash,
@@ -40,7 +44,7 @@ import {
40
44
 
41
45
  export const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
42
46
 
43
- const MERGE_ATTRS = new Set(['src', 'type', 'defer', 'charset', 'language']);
47
+ const MERGE_ATTRS = new Set(['src', 'type', 'defer', 'charset', 'language', 'data-builder']);
44
48
  const typeOfExt = (ext) =>
45
49
  HTML_EXT.has(ext)
46
50
  ? 'html'
@@ -88,6 +92,9 @@ class Context {
88
92
  this.depIncluded = globMatcher(cfg.dependencies.include);
89
93
  this.depExcluded = globMatcher(cfg.dependencies.exclude);
90
94
  this.depCache = new Map();
95
+ this.depMarks = new Map();
96
+ this.tsCode = new Map();
97
+ this.pkgNames = null;
91
98
  this.vendorPackages = [];
92
99
  this.chunkDir = cfg.js.chunkDir.replace(/\/+$/, '');
93
100
  }
@@ -122,6 +129,8 @@ class Context {
122
129
  }
123
130
 
124
131
  readText(rel, { kind = 'text', charset } = {}) {
132
+ // TypeScript: como JS se lee la versión ya transpilada (sin tipos).
133
+ if (kind === 'js' && this.tsCode.has(rel)) return { text: this.tsCode.get(rel), bom: false, encoding: 'utf8' };
125
134
  const key = `${rel}\0${kind}`;
126
135
  if (this.textCache.has(key)) return this.textCache.get(key);
127
136
  const buf = fs.readFileSync(this.abs(rel));
@@ -186,7 +195,7 @@ class Context {
186
195
  if (JS_EXT.has(ext) || CSS_EXT.has(ext)) {
187
196
  if (!this.workers.has(rel)) this.requireStandalone(rel, `new URL() en ${from}`);
188
197
  } else this.registerAsset(rel, from);
189
- return rel;
198
+ return jsOutPath(rel);
190
199
  }
191
200
 
192
201
  requireStandalone(rel, reason, extra = {}) {
@@ -235,23 +244,52 @@ class Context {
235
244
  }
236
245
 
237
246
  hasOwnOutput(rel) {
238
- const o = this.outputs.get(rel);
247
+ const o = this.outputs.get(rel) ?? (TS_EXT.has(extOf(rel)) ? this.outputs.get(jsOutPath(rel)) : undefined);
239
248
  return !!o && o.sources?.includes(rel);
240
249
  }
241
250
 
242
251
  /** ¿Es una librería de terceros? (a mano en dependencies.include/exclude, o detectada). */
252
+ // Prioridad: configuración (include/exclude) > marca en el HTML o en el archivo > detección automática.
243
253
  dependencyReason(rel) {
244
254
  if (this.depCache.has(rel)) return this.depCache.get(rel);
245
255
  const deps = this.cfg.dependencies;
256
+ const isJs = JS_EXT.has(extOf(rel)) && this.files.has(rel);
257
+ const text = isJs ? this.readText(rel, { kind: 'js' }).text : '';
258
+ const pragma = isJs ? dependencyPragma(text) : null;
259
+ const mark = this.depMarks.get(rel);
246
260
  let reason = null;
247
261
  if (this.depExcluded(rel)) reason = null;
248
262
  else if (this.depIncluded(rel)) reason = 'marcado a mano (dependencies.include)';
263
+ else if (mark === 'own' || pragma === 'own') reason = null;
264
+ else if (mark === 'dependency') reason = 'marcado en el HTML (data-builder="dependency")';
265
+ else if (pragma === 'dependency') reason = 'marcado en el propio archivo (@builder-dependency)';
249
266
  else if (/(^|\/)node_modules\//.test(rel)) reason = 'paquete de npm';
250
- else if (deps.detect && JS_EXT.has(extOf(rel)) && this.files.has(rel)) reason = detectDependency(rel, this.readText(rel, { kind: 'js' }).text);
267
+ else if (deps.detect && isJs) reason = detectDependency(rel, text, { packages: this.packageHints() });
251
268
  this.depCache.set(rel, reason);
252
269
  return reason;
253
270
  }
254
271
 
272
+ /** Paquetes del package.json más cercano (para reconocer librerías copiadas a mano). */
273
+ packageHints() {
274
+ if (this.pkgNames) return this.pkgNames;
275
+ this.pkgNames = new Map();
276
+ for (let dir = this.root, i = 0; i < 10; i++) {
277
+ const file = path.join(dir, 'package.json');
278
+ if (fs.existsSync(file)) {
279
+ try {
280
+ this.pkgNames = packageNames(JSON.parse(fs.readFileSync(file, 'utf8')));
281
+ } catch {
282
+ // package.json ilegible: no aporta pistas
283
+ }
284
+ break;
285
+ }
286
+ const parent = path.dirname(dir);
287
+ if (parent === dir) break;
288
+ dir = parent;
289
+ }
290
+ return this.pkgNames;
291
+ }
292
+
255
293
  shouldObfuscate(rels) {
256
294
  if (!this.obfuscatorOptions) return false;
257
295
  const list = rels.filter(Boolean);
@@ -372,6 +410,70 @@ function loadPages(ctx) {
372
410
  }
373
411
 
374
412
  /** Decide qué páginas se procesan completas (entradas + enlazadas) y cuáles en modo conservador. */
413
+ /**
414
+ * Transpila cada archivo TypeScript a JavaScript (solo quita los tipos: no baja el nivel de sintaxis).
415
+ * Usa el tsconfig.json del proyecto si existe. Lo que falle se avisa y sigue el camino normal
416
+ * (se copia sin cambios), así que nunca se pierde nada.
417
+ */
418
+ async function transpileTypeScript(ctx) {
419
+ const list = [...ctx.files.keys()].filter((rel) => TS_EXT.has(extOf(rel)));
420
+ if (!list.length) return;
421
+ let tsconfigRaw;
422
+ const tsconfig = path.join(ctx.root, 'tsconfig.json');
423
+ if (fs.existsSync(tsconfig)) tsconfigRaw = fs.readFileSync(tsconfig, 'utf8');
424
+ const esbuild = await import('esbuild');
425
+ await mapLimit(list, 32, async (rel) => {
426
+ const source = ctx.readText(rel, { kind: 'ts' }).text;
427
+ try {
428
+ const r = await esbuild.transform(source, {
429
+ loader: 'ts',
430
+ sourcefile: rel,
431
+ target: 'esnext',
432
+ tsconfigRaw,
433
+ logLevel: 'silent',
434
+ supported: { 'import-attributes': true },
435
+ });
436
+ const dir = path.posix.dirname(rel);
437
+ // "./x" → la ruta que existirá en la salida (x.ts → x.js, x/index.ts → x/index.js…).
438
+ const complete = (spec) => {
439
+ const base = path.posix.normalize(path.posix.join(dir, spec));
440
+ for (const cand of ['.ts', '.mts', '.js', '.mjs', '/index.ts', '/index.js'].map((s) => base + s)) {
441
+ if (ctx.files.has(cand)) return spec + jsOutPath(cand).slice(base.length);
442
+ }
443
+ return null;
444
+ };
445
+ ctx.tsCode.set(rel, rewriteTsImports(r.code, complete));
446
+ } catch (err) {
447
+ const msg = String(err.errors?.[0]?.text ?? err.message).split('\n')[0];
448
+ ctx.warn(`TypeScript no válido (${msg}); se copia sin transpilar`, rel);
449
+ }
450
+ });
451
+ }
452
+
453
+ /** <script src="..." data-builder="dependency|own">: el usuario marca librerías (o código propio) en el HTML. */
454
+ const MARK_VALUES = { dependency: 'dependency', dependencia: 'dependency', dep: 'dependency', own: 'own', propio: 'own' };
455
+ function markDependencies(ctx, pages) {
456
+ for (const page of pages.values()) {
457
+ for (const s of page.model.scripts) {
458
+ if (!s.src || !s.attrs.has('data-builder')) continue;
459
+ const raw = String(s.attrs.get('data-builder') ?? '')
460
+ .trim()
461
+ .toLowerCase();
462
+ const value = MARK_VALUES[raw];
463
+ if (!value) {
464
+ ctx.warn(`data-builder="${raw}" no se reconoce; usa "dependency" o "own"`, page.rel);
465
+ continue;
466
+ }
467
+ const res = ctx.resolve(s.src.value, page.baseDir);
468
+ if (res.status !== 'ok') continue;
469
+ const prev = ctx.depMarks.get(res.rel);
470
+ if (prev && prev !== value)
471
+ ctx.warn(`"${res.rel}" está marcado como "${prev}" y como "${value}" en distintas páginas; se usa "${prev}"`, page.rel);
472
+ else ctx.depMarks.set(res.rel, value);
473
+ }
474
+ }
475
+ }
476
+
375
477
  function selectEntries(ctx, pages) {
376
478
  const cfg = ctx.cfg;
377
479
  const extraEntries = { modules: [], classic: [], css: [] };
@@ -534,12 +636,35 @@ export async function runPipeline(cfg, opts = {}) {
534
636
  const t0 = Date.now();
535
637
  if (mode === 'build') assertSafeOutDir(cfg, { force: opts.force });
536
638
  const ctx = new Context(cfg, { ...opts, mode });
639
+ const workers = cfg.workers === 'auto' ? autoWorkers() : cfg.workers;
640
+ ctx.heavy = createHeavy({ root: cfg.root, workers, cacheDir: cfg.cache && mode === 'build' ? defaultCacheDir(cfg.root) : null });
641
+ try {
642
+ return await pipeline(ctx, cfg, opts, mode, t0);
643
+ } finally {
644
+ await ctx.heavy.close();
645
+ }
646
+ }
647
+
648
+ async function pipeline(ctx, cfg, opts, mode, t0) {
537
649
  const log = opts.log ?? (() => {});
650
+ // Tiempo de cada fase (perf.phases): para saber dónde se va el tiempo en proyectos reales.
651
+ const phases = [];
652
+ let phaseStart = Date.now();
653
+ let phaseName = 'preparación';
654
+ const mark = (next) => {
655
+ const now = Date.now();
656
+ phases.push([phaseName, now - phaseStart]);
657
+ phaseName = next;
658
+ phaseStart = now;
659
+ };
538
660
  if (ctx.obfuscatorOptions) await ensureObfuscator(cfg.root);
539
661
  if (cfg.js.minifier === 'terser' && cfg.js.minify) await ensureTerser(cfg.root);
540
662
 
663
+ mark('páginas');
541
664
  // 1. Páginas y entradas
665
+ await transpileTypeScript(ctx);
542
666
  const pages = loadPages(ctx);
667
+ markDependencies(ctx, pages);
543
668
  const extra = selectEntries(ctx, pages);
544
669
  const fullPages = [...pages.values()].filter((p) => p.full);
545
670
  const partialPages = [...pages.values()].filter((p) => !p.full);
@@ -548,6 +673,7 @@ export async function runPipeline(cfg, opts = {}) {
548
673
  for (const p of fullPages) collectRefs(ctx, p);
549
674
  for (const p of partialPages) collectRefs(ctx, p);
550
675
 
676
+ mark('módulos');
551
677
  // 2. Módulos ES agrupados por import map
552
678
  const groups = new Map();
553
679
  const groupFor = (page, importMap) => {
@@ -619,7 +745,7 @@ export async function runPipeline(cfg, opts = {}) {
619
745
  if (res.mode === 'per-file') {
620
746
  ctx.note(`módulos sin empaquetar (cada archivo minificado en su sitio): ${res.reason}`, [...g.pages].join(', ') || 'entries');
621
747
  for (const rel of res.inputs) ctx.requireStandalone(rel, 'módulo (modo sin empaquetar)', { module: true });
622
- for (const rel of g.entries) entryOut.set(`${g.key}\0${rel}`, rel);
748
+ for (const rel of g.entries) entryOut.set(`${g.key}\0${rel}`, jsOutPath(rel));
623
749
  for (const [id, v] of g.virtual) {
624
750
  const info = analyzeJs(v.code, { module: true });
625
751
  if (!info.ok) continue;
@@ -632,25 +758,20 @@ export async function runPipeline(cfg, opts = {}) {
632
758
  continue;
633
759
  }
634
760
  log(`Módulos: ${g.entries.size} entradas, ${res.inputs.length} archivos, target ${res.target}`);
635
- for (const o of res.outputs) {
636
- const sources = o.inputs.length ? o.inputs : o.entry ? [o.entry] : [];
637
- let code = o.code;
638
- let map = o.map;
761
+ // Ofuscar/comprimir cada salida en paralelo; registrar después en el orden original (salida determinista).
762
+ const finals = await mapLimit(res.outputs, 64, async (o) => {
639
763
  const obfRels = o.virtualId ? [g.virtual.get(o.virtualId).page.rel] : o.isChunk ? o.inputs : [o.entry];
640
764
  if (!o.isVendor && ctx.shouldObfuscate(obfRels)) {
641
- const r = obfuscateModule(ctx, code, o.outRel);
642
- if (r.note) ctx.warn(r.note, o.outRel);
643
- else {
644
- code = r.code;
645
- map = null;
646
- }
647
- } else {
648
- const t = await terserPass(ctx, code, { isModule: true, level: levelOf(res.target) });
649
- if (t !== code) {
650
- code = t;
651
- map = null;
652
- }
765
+ const r = await obfuscateModule(ctx, o.code, o.outRel);
766
+ return r.note ? { code: o.code, map: o.map, warn: r.note } : { code: r.code, map: null };
653
767
  }
768
+ const t = await terserPass(ctx, o.code, { isModule: true, level: levelOf(res.target) });
769
+ return t !== o.code ? { code: t, map: null } : { code: o.code, map: o.map };
770
+ });
771
+ for (const [i, o] of res.outputs.entries()) {
772
+ const sources = o.inputs.length ? o.inputs : o.entry ? [o.entry] : [];
773
+ const { code, map, warn } = finals[i];
774
+ if (warn) ctx.warn(warn, o.outRel);
654
775
  if (o.virtualId) {
655
776
  const v = g.virtual.get(o.virtualId);
656
777
  inlineModuleCode.set(o.virtualId, code.replace(/\n$/, ''));
@@ -669,6 +790,7 @@ export async function runPipeline(cfg, opts = {}) {
669
790
  }
670
791
  }
671
792
 
793
+ mark('css');
672
794
  // 3. CSS enlazado
673
795
  const cssOut = new Map();
674
796
  const cssEntries = new Set(extra.css);
@@ -679,10 +801,14 @@ export async function runPipeline(cfg, opts = {}) {
679
801
  if (res.status === 'ok') cssEntries.add(res.rel);
680
802
  else if (res.status !== 'skip') ctx.reportBroken(page.rel, l.href.value, res);
681
803
  }
682
- for (const rel of cssEntries) {
804
+ const cssList = [...cssEntries];
805
+ const cssBuilt = await mapLimit(cssList, 16, (rel) => {
806
+ const hashed = cfg.hash && !extra.css.includes(rel);
807
+ return buildCss(ctx, { entry: rel, outRel: hashed ? `${ctx.chunkDir}/${path.posix.basename(rel)}` : rel });
808
+ });
809
+ for (const [ci, rel] of cssList.entries()) {
683
810
  const hashed = cfg.hash && !extra.css.includes(rel);
684
- const provisional = hashed ? `${ctx.chunkDir}/${path.posix.basename(rel)}` : rel;
685
- const r = await buildCss(ctx, { entry: rel, outRel: provisional });
811
+ const r = cssBuilt[ci];
686
812
  if (r.error) {
687
813
  ctx.warn(`no se pudo procesar el CSS (${r.error}); se copia tal cual`, rel);
688
814
  cssOut.set(rel, ctx.emitCopy(rel, rel, 'copy-fallback'));
@@ -695,6 +821,7 @@ export async function runPipeline(cfg, opts = {}) {
695
821
  for (const i of r.inputs) ctx.cover(i, i === rel ? 'procesado' : 'empaquetado', outRel);
696
822
  }
697
823
 
824
+ mark('white-space');
698
825
  // 4. Detección de white-space: pre (para no colapsar espacios del HTML)
699
826
  let collapse = cfg.html.collapseWhitespace;
700
827
  if (collapse === 'auto') {
@@ -708,15 +835,28 @@ export async function runPipeline(cfg, opts = {}) {
708
835
  );
709
836
  }
710
837
 
838
+ mark('scripts clásicos');
711
839
  // 5. Scripts clásicos por página
712
- const classicCache = new Map();
840
+ // Primero se planifican todas las páginas, luego se minifica/ofusca cada grupo distinto en paralelo
841
+ // y por último se registra todo en el orden original.
842
+ const pageRuns = [];
843
+ const uniqueRuns = new Map();
713
844
  for (const page of fullPages) {
714
845
  for (const run of classicRuns(ctx, page)) {
715
- const files = run.items.map((c) => c.rel);
716
- const key = `${files.join('|')}\0${run.items[0].key}`;
846
+ const key = `${run.items.map((c) => c.rel).join('|')}\0${run.items[0].key}`;
847
+ pageRuns.push({ page, run, key });
848
+ if (!uniqueRuns.has(key)) uniqueRuns.set(key, run);
849
+ }
850
+ }
851
+ const runKeys = [...uniqueRuns.keys()];
852
+ const runResults = await mapLimit(runKeys, 64, (key) => computeClassicRun(ctx, uniqueRuns.get(key)));
853
+ const computedRuns = new Map(runKeys.map((k, i) => [k, runResults[i]]));
854
+ const classicCache = new Map();
855
+ {
856
+ for (const { page, run, key } of pageRuns) {
717
857
  let out = classicCache.get(key);
718
858
  if (!out) {
719
- out = await processClassicRun(ctx, run, page);
859
+ out = commitClassicRun(ctx, run, page, computedRuns.get(key));
720
860
  classicCache.set(key, out);
721
861
  }
722
862
  if (out.merged) {
@@ -736,6 +876,7 @@ export async function runPipeline(cfg, opts = {}) {
736
876
  }
737
877
  }
738
878
 
879
+ mark('edición html');
739
880
  // 6. Edición de cada página: módulos, CSS, inline, preload
740
881
  for (const page of fullPages) {
741
882
  for (const m of moduleRefs.filter((x) => x.page === page)) {
@@ -761,9 +902,11 @@ export async function runPipeline(cfg, opts = {}) {
761
902
  }
762
903
  for (const page of pages.values()) await processInline(ctx, page, collapse);
763
904
 
905
+ mark('workers y sueltos');
764
906
  // 7. Workers, archivos sueltos y rutas mencionadas (hasta que no aparezca nada nuevo)
765
907
  await drainQueues(ctx, cssOut);
766
908
 
909
+ mark('no referenciados');
767
910
  // 8. Archivos no referenciados
768
911
  const unref = [];
769
912
  for (const [rel, f] of ctx.files) {
@@ -781,6 +924,7 @@ export async function runPipeline(cfg, opts = {}) {
781
924
  }
782
925
  await drainQueues(ctx, cssOut);
783
926
 
927
+ mark('html final');
784
928
  // 9. Preloads y HTML final
785
929
  for (const { page, l } of preloadLinks) {
786
930
  const res = ctx.resolve(l.href.value, page.baseDir);
@@ -811,6 +955,7 @@ export async function runPipeline(cfg, opts = {}) {
811
955
  await finalizePage(ctx, page, collapse);
812
956
  }
813
957
 
958
+ mark('recursos');
814
959
  // 10. Recursos: todo lo demás se copia (sin perder nada)
815
960
  for (const [rel, f] of ctx.files) {
816
961
  if (ctx.hasOwnOutput(rel)) {
@@ -838,6 +983,7 @@ export async function runPipeline(cfg, opts = {}) {
838
983
  ctx.cover(rel, 'copiado', rel);
839
984
  }
840
985
 
986
+ mark('avisos');
841
987
  // 11. Avisos de rutas mencionadas en el código que ya no existirán
842
988
  for (const [rel, from] of ctx.mentions) {
843
989
  if (!ctx.outputs.has(rel) && ctx.coverage.get(rel)?.every((s) => s.startsWith('empaquetado'))) {
@@ -851,9 +997,12 @@ export async function runPipeline(cfg, opts = {}) {
851
997
  }
852
998
  }
853
999
 
1000
+ mark('precompresión');
854
1001
  // 12. Precompresión .gz / .br para servidores que sirven archivos comprimidos (nginx gzip_static, etc.)
855
- if (cfg.compress && mode === 'build') precompress(ctx, cfg.compress === true ? ['gzip', 'brotli'] : [cfg.compress]);
1002
+ if (cfg.compress && mode === 'build') await precompress(ctx, cfg.compress === true ? ['gzip', 'brotli'] : [cfg.compress]);
856
1003
 
1004
+ mark('informe de tamaños');
1005
+ const report = await buildReport(ctx.outputs, ctx.heavy);
857
1006
  const result = {
858
1007
  version: VERSION,
859
1008
  root: cfg.root,
@@ -873,15 +1022,18 @@ export async function runPipeline(cfg, opts = {}) {
873
1022
  .map((rel) => ({ rel, reason: ctx.dependencyReason(rel) })),
874
1023
  vendor: ctx.vendorPackages,
875
1024
  obfuscated: !!ctx.obfuscatorOptions,
876
- report: buildReport(ctx.outputs),
1025
+ report,
1026
+ perf: Object.assign(ctx.heavy.stats, { phases }),
877
1027
  verify: null,
878
1028
  durationMs: 0,
879
1029
  };
880
1030
  if (mode === 'analyze') {
1031
+ mark('fin');
881
1032
  result.durationMs = Date.now() - t0;
882
1033
  return result;
883
1034
  }
884
1035
 
1036
+ mark('escritura y verificación');
885
1037
  // 12. Escritura, verificación y publicación
886
1038
  let staging;
887
1039
  try {
@@ -900,6 +1052,7 @@ export async function runPipeline(cfg, opts = {}) {
900
1052
  files: [...ctx.outputs.keys()].sort(),
901
1053
  });
902
1054
  await swapIntoPlace(cfg, staging);
1055
+ mark('fin');
903
1056
  } catch (err) {
904
1057
  if (staging && !opts.keepFailed) await discardStaging(staging);
905
1058
  throw err;
@@ -910,26 +1063,21 @@ export async function runPipeline(cfg, opts = {}) {
910
1063
 
911
1064
  const COMPRESS_TYPES = new Set(['html', 'css', 'js', 'json', 'svg', 'text', 'map']);
912
1065
 
913
- function precompress(ctx, kinds) {
914
- for (const [rel, o] of [...ctx.outputs]) {
915
- if (!COMPRESS_TYPES.has(o.type)) continue;
1066
+ async function precompress(ctx, kinds) {
1067
+ const items = [...ctx.outputs].filter(([, o]) => COMPRESS_TYPES.has(o.type));
1068
+ // En paralelo (pool de hilos de libuv); se registra en orden. El informe reutiliza estos mismos resultados.
1069
+ const done = await mapLimit(items, 64, async ([, o]) => {
916
1070
  const buf = o.copyFrom ? fs.readFileSync(o.copyFrom) : o.data;
917
- if (buf.length < 1024) continue; // no compensa
918
- if (kinds.includes('gzip')) {
919
- const gz = zlib.gzipSync(buf, { level: 9 });
920
- if (gz.length < buf.length) ctx.emit(`${rel}.gz`, gz, { type: 'compressed', action: 'gzip', sources: o.sources });
921
- }
922
- if (kinds.includes('brotli')) {
923
- const br = zlib.brotliCompressSync(buf, {
924
- params: {
925
- [zlib.constants.BROTLI_PARAM_QUALITY]: 11,
926
- [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
927
- [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buf.length,
928
- },
929
- });
930
- if (br.length < buf.length) ctx.emit(`${rel}.br`, br, { type: 'compressed', action: 'brotli', sources: o.sources });
931
- }
932
- }
1071
+ if (buf.length < 1024) return null; // no compensa
1072
+ const [gz, br] = await Promise.all([kinds.includes('gzip') ? gzipOf(buf) : null, kinds.includes('brotli') ? brotliOf(buf) : null]);
1073
+ return { buf, gz, br };
1074
+ });
1075
+ items.forEach(([rel, o], i) => {
1076
+ const d = done[i];
1077
+ if (!d) return;
1078
+ if (d.gz && d.gz.length < d.buf.length) ctx.emit(`${rel}.gz`, d.gz, { type: 'compressed', action: 'gzip', sources: o.sources });
1079
+ if (d.br && d.br.length < d.buf.length) ctx.emit(`${rel}.br`, d.br, { type: 'compressed', action: 'brotli', sources: o.sources });
1080
+ });
933
1081
  }
934
1082
 
935
1083
  function sumSizes(ctx, rels) {
@@ -957,21 +1105,41 @@ function markModuleImport(ctx, spec, baseDir, from, importMap) {
957
1105
  else ctx.reportBroken(from, spec, r);
958
1106
  }
959
1107
 
960
- async function processClassicRun(ctx, run, page) {
1108
+ /** Parte costosa y sin efectos de un grupo de scripts clásicos: unir, minificar y ofuscar. */
1109
+ async function computeClassicRun(ctx, run) {
1110
+ const items = run.items;
1111
+ const first = items[0];
1112
+ if (items.length === 1 && !first.info.ok) return { copy: true };
1113
+ let mergeError = null;
1114
+ if (items.length > 1) {
1115
+ // Separadores con ; y salto de línea: evitan que un archivo sin ; final se "pegue" al siguiente.
1116
+ const code = items.map((c) => ctx.readText(c.rel, { kind: 'js', charset: c.s.attrs.get('charset') }).text.replace(/\s*$/, '\n')).join(';\n');
1117
+ const info = analyzeJs(code, { module: false });
1118
+ if (info.ok) return { merged: await processClassic(ctx, { code, id: items.map((c) => c.rel).join('+'), info, obfuscate: first.obf }) };
1119
+ mergeError = info.error;
1120
+ }
1121
+ const separate = await Promise.all(
1122
+ items.map((c) => {
1123
+ const { text } = ctx.readText(c.rel, { kind: 'js', charset: c.s.attrs.get('charset') });
1124
+ return processClassic(ctx, { code: text, id: c.rel, info: c.info, obfuscate: c.obf, sourcemap: !!ctx.cfg.sourcemap });
1125
+ }),
1126
+ );
1127
+ return { separate, mergeError };
1128
+ }
1129
+
1130
+ /** Registra el resultado de computeClassicRun (salidas, cobertura y notas) en orden. */
1131
+ function commitClassicRun(ctx, run, page, comp) {
961
1132
  const items = run.items;
962
1133
  const first = items[0];
963
- if (items.length === 1 && !first.info.ok) {
1134
+ if (comp.copy) {
964
1135
  ctx.warn(`no se pudo analizar (${first.info.error}); se copia sin cambios`, first.rel);
965
1136
  const outRel = ctx.emitCopy(first.rel, first.rel, 'copy-fallback');
966
1137
  ctx.cover(first.rel, 'copiado sin cambios (no se pudo analizar)', outRel);
967
1138
  return { merged: false, outs: [{ outRel, changed: false }] };
968
1139
  }
969
- if (items.length > 1) {
970
- // Separadores con ; y salto de línea: evitan que un archivo sin ; final se "pegue" al siguiente.
971
- const code = items.map((c) => ctx.readText(c.rel, { kind: 'js', charset: c.s.attrs.get('charset') }).text.replace(/\s*$/, '\n')).join(';\n');
972
- const info = analyzeJs(code, { module: false });
973
- if (info.ok) {
974
- const r = await processClassic(ctx, { code, id: items.map((c) => c.rel).join('+'), info, obfuscate: first.obf });
1140
+ {
1141
+ if (comp.merged) {
1142
+ const r = comp.merged;
975
1143
  for (const n of r.notes) ctx.note(n, `scripts unidos (${items.map((c) => c.rel).join(', ')})`);
976
1144
  const outRel = ctx.emitCode(`${ctx.chunkDir}/scripts-${shortHash(r.code)}.js`, r.code, null, {
977
1145
  type: 'js',
@@ -985,16 +1153,15 @@ async function processClassicRun(ctx, run, page) {
985
1153
  for (const c of items) ctx.cover(c.rel, 'unido', outRel);
986
1154
  return { merged: true, outRel };
987
1155
  }
988
- ctx.warn(`no se pudieron unir (${info.error}); se procesan por separado`, page.rel);
1156
+ if (comp.mergeError) ctx.warn(`no se pudieron unir (${comp.mergeError}); se procesan por separado`, page.rel);
989
1157
  }
990
1158
  // Un solo archivo, o una unión fallida: cada uno se procesa en su sitio.
991
1159
  const outs = [];
992
- for (const c of items) {
993
- const { text } = ctx.readText(c.rel, { kind: 'js', charset: c.s.attrs.get('charset') });
994
- const r = await processClassic(ctx, { code: text, id: c.rel, info: c.info, obfuscate: c.obf, sourcemap: !!ctx.cfg.sourcemap });
1160
+ for (const [i, c] of items.entries()) {
1161
+ const r = comp.separate[i];
995
1162
  for (const n of r.notes) ctx.note(n, c.rel);
996
1163
  if (c.reasons.length && c.reasons[0] !== 'unión desactivada') ctx.note(`no se une con otros scripts: ${c.reasons.join(', ')}`, c.rel);
997
- const outRel = ctx.cfg.hash ? ctx.hashedPath(c.rel, r.code, '.js') : c.rel;
1164
+ const outRel = ctx.cfg.hash ? ctx.hashedPath(c.rel, r.code, '.js') : jsOutPath(c.rel);
998
1165
  ctx.emitCode(outRel, r.code, r.map, { type: 'js', action: 'minify', sources: [c.rel], before: ctx.files.get(c.rel).size });
999
1166
  ctx.cover(c.rel, 'procesado', outRel);
1000
1167
  outs.push({ outRel, changed: true });
@@ -1125,52 +1292,65 @@ async function drainQueues(ctx, cssOut) {
1125
1292
  }
1126
1293
  let code = r.code;
1127
1294
  if (ctx.shouldObfuscate([rel])) {
1128
- const o = obfuscateModule(ctx, code, rel);
1295
+ const o = await obfuscateModule(ctx, code, rel);
1129
1296
  if (o.note) ctx.warn(o.note, rel);
1130
1297
  code = o.code;
1131
1298
  } else code = await terserPass(ctx, code, { isModule: true, level: 2020 });
1132
1299
  const sources = [...r.consumed];
1133
- ctx.emitCode(rel, code, code === r.code ? r.map : null, {
1300
+ ctx.emitCode(jsOutPath(rel), code, code === r.code ? r.map : null, {
1134
1301
  type: 'js',
1135
1302
  module: true,
1136
1303
  action: 'worker',
1137
1304
  sources,
1138
1305
  before: sumSizes(ctx, sources),
1139
1306
  });
1140
- for (const s of sources) ctx.cover(s, s === rel ? 'procesado (worker)' : 'empaquetado', rel);
1307
+ for (const s of sources) ctx.cover(s, s === rel ? 'procesado (worker)' : 'empaquetado', jsOutPath(rel));
1141
1308
  } else ctx.requireStandalone(rel, `worker clásico (${w.kind})`, { classic: true });
1142
1309
  }
1310
+ // Análisis en orden, trabajo pesado en paralelo, registro otra vez en orden.
1311
+ const jobs = [];
1143
1312
  for (const [rel, s] of ctx.standalone) {
1144
1313
  if (ctx.done.has(`s:${rel}`)) continue;
1145
1314
  ctx.done.add(`s:${rel}`);
1146
1315
  progressed = true;
1147
1316
  if (ctx.hasOwnOutput(rel) || !ctx.files.has(rel)) continue;
1148
- await processStandalone(ctx, rel, s, cssOut);
1317
+ const job = planStandalone(ctx, rel, s, cssOut);
1318
+ if (job) jobs.push(job);
1149
1319
  }
1320
+ const results = await mapLimit(jobs, 64, (j) => j.run());
1321
+ jobs.forEach((j, i) => j.commit(results[i]));
1150
1322
  if (!progressed) return;
1151
1323
  }
1152
1324
  throw new BuildError('Demasiadas rondas al resolver dependencias (¿ciclo inesperado?).');
1153
1325
  }
1154
1326
 
1155
- async function processStandalone(ctx, rel, s, cssOut) {
1327
+ /**
1328
+ * Prepara un archivo suelto: el análisis (que descubre más dependencias) se hace ya, en orden;
1329
+ * devuelve {run, commit}: run() es la parte costosa y sin efectos, commit() registra el resultado.
1330
+ */
1331
+ function planStandalone(ctx, rel, s, cssOut) {
1156
1332
  const ext = extOf(rel);
1157
1333
  const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
1158
1334
  if (CSS_EXT.has(ext)) {
1159
- const r = await buildCss(ctx, { entry: rel, outRel: rel });
1160
- if (r.error) {
1161
- ctx.warn(`no se pudo procesar el CSS (${r.error.split('\n')[0]}); se copia tal cual`, rel);
1162
- ctx.emitCopy(rel, rel, 'copy-fallback');
1163
- ctx.cover(rel, 'copiado sin cambios (error al procesar)', rel);
1164
- return;
1165
- }
1166
- ctx.emitCode(rel, r.code, r.map, { type: 'css', action: 'standalone', sources: [rel], before: ctx.files.get(rel).size });
1167
- for (const i of r.inputs) ctx.cover(i, i === rel ? 'procesado (suelto)' : 'empaquetado', rel);
1168
- if (!cssOut.has(rel)) cssOut.set(rel, rel);
1169
- return;
1335
+ // buildCss registra los recursos de url() mientras compila: eso solo añade trabajo a la siguiente ronda.
1336
+ return {
1337
+ run: () => buildCss(ctx, { entry: rel, outRel: rel }),
1338
+ commit: (r) => {
1339
+ if (r.error) {
1340
+ ctx.warn(`no se pudo procesar el CSS (${r.error.split('\n')[0]}); se copia tal cual`, rel);
1341
+ ctx.emitCopy(rel, rel, 'copy-fallback');
1342
+ ctx.cover(rel, 'copiado sin cambios (error al procesar)', rel);
1343
+ return;
1344
+ }
1345
+ ctx.emitCode(rel, r.code, r.map, { type: 'css', action: 'standalone', sources: [rel], before: ctx.files.get(rel).size });
1346
+ for (const i of r.inputs) ctx.cover(i, i === rel ? 'procesado (suelto)' : 'empaquetado', rel);
1347
+ if (!cssOut.has(rel)) cssOut.set(rel, rel);
1348
+ },
1349
+ };
1170
1350
  }
1171
1351
  if (!JS_EXT.has(ext)) {
1172
1352
  ctx.registerAsset(rel, s.reason);
1173
- return;
1353
+ return null;
1174
1354
  }
1175
1355
  const auto = ctx.jsInfo(rel, 'auto');
1176
1356
  const isModule = !s.classic && (s.module || (auto.ok && (auto.hasModuleSyntax || auto.hasImportMeta)) || (!auto.ok && auto.isModule));
@@ -1179,12 +1359,11 @@ async function processStandalone(ctx, rel, s, cssOut) {
1179
1359
  ctx.warn(`no se pudo analizar (${info.error}); se copia sin cambios`, rel);
1180
1360
  ctx.emitCopy(rel, rel, 'copy-fallback');
1181
1361
  ctx.cover(rel, 'copiado sin cambios (no se pudo analizar)', rel);
1182
- return;
1362
+ return null;
1183
1363
  }
1184
1364
  ctx.noteJsInfo(rel, info, dir);
1185
1365
  const { text } = ctx.readText(rel, { kind: 'js' });
1186
1366
  const obfuscate = ctx.shouldObfuscate([rel]);
1187
- let r;
1188
1367
  if (isModule) {
1189
1368
  for (const imp of info.imports) if (imp.spec) markModuleImport(ctx, imp.spec, dir, rel, null);
1190
1369
  for (const u of info.urls) {
@@ -1192,11 +1371,15 @@ async function processStandalone(ctx, rel, s, cssOut) {
1192
1371
  if (res.status === 'ok') ctx.urlTarget(res.rel, rel);
1193
1372
  else ctx.reportBroken(rel, u.spec, res);
1194
1373
  }
1195
- r = await processModuleStandalone(ctx, { code: text, id: rel, info, obfuscate, sourcemap: !!ctx.cfg.sourcemap });
1196
- } else {
1197
- r = await processClassic(ctx, { code: text, id: rel, info, obfuscate, sourcemap: !!ctx.cfg.sourcemap });
1198
1374
  }
1199
- for (const n of r.notes) ctx.note(n, rel);
1200
- ctx.emitCode(rel, r.code, r.map, { type: 'js', module: isModule, action: 'standalone', sources: [rel], before: ctx.files.get(rel).size });
1201
- ctx.cover(rel, `procesado suelto (${s.reason})`, rel);
1375
+ const job = { code: text, id: rel, info, obfuscate, sourcemap: !!ctx.cfg.sourcemap };
1376
+ return {
1377
+ run: () => (isModule ? processModuleStandalone(ctx, job) : processClassic(ctx, job)),
1378
+ commit: (r) => {
1379
+ for (const n of r.notes) ctx.note(n, rel);
1380
+ const out = jsOutPath(rel);
1381
+ ctx.emitCode(out, r.code, r.map, { type: 'js', module: isModule, action: 'standalone', sources: [rel], before: ctx.files.get(rel).size });
1382
+ ctx.cover(rel, `procesado suelto (${s.reason})`, out);
1383
+ },
1384
+ };
1202
1385
  }