vanilla-builder 0.1.0 → 0.3.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 +41 -0
- package/README.es.md +553 -107
- package/README.md +545 -101
- package/bin/builder.js +432 -46
- package/package.json +21 -4
- package/src/browsers.js +128 -0
- package/src/budgets.js +77 -0
- package/src/builder.js +612 -165
- package/src/compress.js +44 -0
- package/src/config.js +280 -190
- package/src/deps.js +70 -6
- package/src/files.js +19 -7
- package/src/html.js +10 -3
- package/src/i18n.js +33 -0
- package/src/images.js +61 -0
- package/src/js.js +123 -74
- package/src/jsimports.js +54 -0
- package/src/jsinfo.js +81 -6
- package/src/obfuscate.js +8 -4
- package/src/optional.js +7 -3
- package/src/output.js +37 -9
- package/src/plugins.js +126 -0
- package/src/pool.js +343 -0
- package/src/report.js +23 -22
- package/src/server.js +8 -4
- package/src/tersercore.js +33 -0
- package/src/urls.js +3 -0
- package/src/util.js +5 -1
- package/src/verify.js +37 -27
- package/src/worker.js +30 -0
package/src/pool.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
import { L, getLang } from './i18n.js';
|
|
15
|
+
|
|
16
|
+
const WORKER_URL = new URL('./worker.js', import.meta.url);
|
|
17
|
+
const OWN_VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
18
|
+
const CACHE_FORMAT = 1;
|
|
19
|
+
const CACHE_MAX_BYTES = 256 * 1024 * 1024;
|
|
20
|
+
const TASKS_PER_NEW_WORKER = 4;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hilos por defecto: aproximadamente los núcleos físicos (la mitad de los lógicos), máximo 8.
|
|
24
|
+
* Medido: más allá de eso el ofuscador apenas escala (memoria y recolector de basura).
|
|
25
|
+
*/
|
|
26
|
+
export function autoWorkers() {
|
|
27
|
+
const cpus = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
|
|
28
|
+
return Math.max(1, Math.min(8, Math.floor(cpus / 2)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Versión instalada de un paquete (para invalidar la caché al actualizarlo). */
|
|
32
|
+
function packageVersion(name, root) {
|
|
33
|
+
for (const base of [new URL('../package.json', import.meta.url), path.join(root ?? process.cwd(), 'package.json')]) {
|
|
34
|
+
try {
|
|
35
|
+
let dir = path.dirname(createRequire(base).resolve(name));
|
|
36
|
+
for (let i = 0; i < 6; i++, dir = path.dirname(dir)) {
|
|
37
|
+
const file = path.join(dir, 'package.json');
|
|
38
|
+
if (fs.existsSync(file)) {
|
|
39
|
+
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
40
|
+
if (pkg.name === name) return pkg.version;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// no instalado aquí: se prueba la siguiente ubicación
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return 'unknown';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Carpeta de caché: node_modules/.cache/vanilla-builder más cercano, o el temporal del sistema. */
|
|
51
|
+
export function defaultCacheDir(root) {
|
|
52
|
+
// BUILDER_CACHE_DIR: para CI (guardarla entre ejecuciones) o para aislar pruebas.
|
|
53
|
+
if (process.env.BUILDER_CACHE_DIR) return path.resolve(process.env.BUILDER_CACHE_DIR);
|
|
54
|
+
for (let dir = root, i = 0; i < 20; i++) {
|
|
55
|
+
const nm = path.join(dir, 'node_modules');
|
|
56
|
+
if (fs.existsSync(nm)) return path.join(nm, '.cache', 'vanilla-builder');
|
|
57
|
+
const parent = path.dirname(dir);
|
|
58
|
+
if (parent === dir) break;
|
|
59
|
+
dir = parent;
|
|
60
|
+
}
|
|
61
|
+
const id = crypto.createHash('sha256').update(root).digest('hex').slice(0, 12);
|
|
62
|
+
return path.join(os.tmpdir(), 'vanilla-builder-cache', id);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const plain = (v) => (v instanceof Map ? [...v.entries()] : v);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{root: string, workers?: number, cacheDir?: string|null}} opts
|
|
69
|
+
*/
|
|
70
|
+
export function createHeavy({ root, workers = autoWorkers(), cacheDir = null }) {
|
|
71
|
+
const stats = { tasks: 0, cacheHits: 0, threads: 0 };
|
|
72
|
+
const versions = new Map();
|
|
73
|
+
const idle = [];
|
|
74
|
+
const all = [];
|
|
75
|
+
const queue = [];
|
|
76
|
+
const pending = new Map();
|
|
77
|
+
let nextId = 1;
|
|
78
|
+
let broken = false;
|
|
79
|
+
let inProcessTerser = null;
|
|
80
|
+
|
|
81
|
+
const versionOf = (name) => {
|
|
82
|
+
if (!versions.has(name)) versions.set(name, packageVersion(name, root));
|
|
83
|
+
return versions.get(name);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function cacheKey(kind, payload) {
|
|
87
|
+
const engine = kind === 'obfuscate' ? 'javascript-obfuscator' : kind === 'terser' ? 'terser' : 'acorn';
|
|
88
|
+
// El idioma entra en la clave: los motivos de rechazo ("no se ofuscó: …") se guardan ya traducidos.
|
|
89
|
+
const meta = JSON.stringify([CACHE_FORMAT, OWN_VERSION, kind, versionOf(engine), getLang(), payload.opts, plain(payload.opts.globals)]);
|
|
90
|
+
return crypto.createHash('sha256').update(meta).update('\0').update(payload.code).digest('hex');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Valores pequeños (importaciones, tamaños, resultados cortos): un único índice JSON en vez de miles
|
|
94
|
+
// de archivos diminutos, que en Windows (antivirus, indexado) cuestan más que calcularlos.
|
|
95
|
+
const SMALL_FILE = cacheDir ? path.join(cacheDir, 'small-v1.json') : null;
|
|
96
|
+
const SMALL_LIMIT = 2048;
|
|
97
|
+
const SMALL_MAX_ENTRIES = 100000;
|
|
98
|
+
let small = null;
|
|
99
|
+
let smallDirty = false;
|
|
100
|
+
function smallStore() {
|
|
101
|
+
if (!small) {
|
|
102
|
+
small = new Map();
|
|
103
|
+
try {
|
|
104
|
+
for (const [k, v] of JSON.parse(fs.readFileSync(SMALL_FILE, 'utf8'))) small.set(k, v);
|
|
105
|
+
} catch {
|
|
106
|
+
// índice ausente o dañado: se empieza vacío
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return small;
|
|
110
|
+
}
|
|
111
|
+
function saveSmall() {
|
|
112
|
+
if (!smallDirty || !SMALL_FILE) return;
|
|
113
|
+
const entries = [...small.entries()].slice(-SMALL_MAX_ENTRIES);
|
|
114
|
+
const tmp = `${SMALL_FILE}.${process.pid}.tmp`;
|
|
115
|
+
try {
|
|
116
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
117
|
+
fs.writeFileSync(tmp, JSON.stringify(entries));
|
|
118
|
+
fs.renameSync(tmp, SMALL_FILE);
|
|
119
|
+
} catch {
|
|
120
|
+
try {
|
|
121
|
+
fs.rmSync(tmp, { force: true });
|
|
122
|
+
} catch {
|
|
123
|
+
// la caché es opcional
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readCache(key) {
|
|
129
|
+
if (!cacheDir) return undefined;
|
|
130
|
+
const s = smallStore();
|
|
131
|
+
if (s.has(key)) {
|
|
132
|
+
// Al final del Map = usado recientemente (se conservan los últimos al recortar).
|
|
133
|
+
const v = s.get(key);
|
|
134
|
+
s.delete(key);
|
|
135
|
+
s.set(key, v);
|
|
136
|
+
smallDirty = true;
|
|
137
|
+
return v;
|
|
138
|
+
}
|
|
139
|
+
const file = path.join(cacheDir, key.slice(0, 2), `${key}.json`);
|
|
140
|
+
try {
|
|
141
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
142
|
+
const now = new Date();
|
|
143
|
+
fs.utimes(file, now, now, () => {});
|
|
144
|
+
return value;
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeCache(key, value) {
|
|
151
|
+
if (!cacheDir) return;
|
|
152
|
+
const json = JSON.stringify(value);
|
|
153
|
+
if (json.length <= SMALL_LIMIT) {
|
|
154
|
+
smallStore().set(key, value);
|
|
155
|
+
smallDirty = true;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const dir = path.join(cacheDir, key.slice(0, 2));
|
|
159
|
+
const file = path.join(dir, `${key}.json`);
|
|
160
|
+
const tmp = `${file}.${process.pid}.${nextId}.tmp`;
|
|
161
|
+
try {
|
|
162
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
163
|
+
fs.writeFileSync(tmp, json);
|
|
164
|
+
fs.renameSync(tmp, file);
|
|
165
|
+
} catch {
|
|
166
|
+
try {
|
|
167
|
+
fs.rmSync(tmp, { force: true });
|
|
168
|
+
} catch {
|
|
169
|
+
// la caché es opcional: un fallo al escribirla no afecta a la compilación
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function runInProcess(kind, payload) {
|
|
175
|
+
if (kind === 'imports') return jsImports(payload.code, payload.opts.isModule);
|
|
176
|
+
if (kind === 'obfuscate') {
|
|
177
|
+
await ensureObfuscator(root);
|
|
178
|
+
return obfuscateSafe(payload.code, payload.opts);
|
|
179
|
+
}
|
|
180
|
+
inProcessTerser ??= await importOptional('terser', root, L('para la compresión máxima', 'for maximum compression'));
|
|
181
|
+
return terserMinify(inProcessTerser, payload.code, payload.opts);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function spawn() {
|
|
185
|
+
const w = new Worker(WORKER_URL, { workerData: { root, lang: getLang() } });
|
|
186
|
+
w.on('message', ({ id, result, error }) => {
|
|
187
|
+
const p = pending.get(id);
|
|
188
|
+
pending.delete(id);
|
|
189
|
+
w.busy = null;
|
|
190
|
+
idle.push(w);
|
|
191
|
+
pump();
|
|
192
|
+
if (!p) return;
|
|
193
|
+
if (error) p.reject(new Error(error));
|
|
194
|
+
else p.resolve(result);
|
|
195
|
+
});
|
|
196
|
+
w.on('error', (err) => {
|
|
197
|
+
// Si un hilo muere, sus tareas (y las siguientes) se hacen en el hilo principal.
|
|
198
|
+
broken = true;
|
|
199
|
+
const id = w.busy;
|
|
200
|
+
if (id != null && pending.has(id)) {
|
|
201
|
+
const p = pending.get(id);
|
|
202
|
+
pending.delete(id);
|
|
203
|
+
runInProcess(p.kind, p.payload).then(p.resolve, p.reject);
|
|
204
|
+
}
|
|
205
|
+
void err;
|
|
206
|
+
});
|
|
207
|
+
all.push(w);
|
|
208
|
+
stats.threads = all.length;
|
|
209
|
+
return w;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// El hilo principal toma la primera tarea libre (no hay que esperar a que arranque nada) y solo se
|
|
213
|
+
// crean hilos cuando hay más trabajo en cola: uno por tarea pendiente, hasta el máximo. Así un proyecto
|
|
214
|
+
// pequeño nunca paga el arranque de los hilos (cargar el ofuscador cuesta ~0,5 s por hilo).
|
|
215
|
+
let mainBusy = false;
|
|
216
|
+
function runOnMain(t) {
|
|
217
|
+
mainBusy = true;
|
|
218
|
+
setImmediate(async () => {
|
|
219
|
+
try {
|
|
220
|
+
t.resolve(await runInProcess(t.kind, t.payload));
|
|
221
|
+
} catch (err) {
|
|
222
|
+
t.reject(err);
|
|
223
|
+
} finally {
|
|
224
|
+
mainBusy = false;
|
|
225
|
+
pump();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function pump() {
|
|
231
|
+
while (queue.length) {
|
|
232
|
+
if (broken) {
|
|
233
|
+
const t = queue.shift();
|
|
234
|
+
runInProcess(t.kind, t.payload).then(t.resolve, t.reject);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
let w = idle.pop();
|
|
238
|
+
if (!w) {
|
|
239
|
+
if (!mainBusy) {
|
|
240
|
+
runOnMain(queue.shift());
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// Un hilo nuevo solo compensa con trabajo de sobra para él: arrancarlo cuesta lo que varias tareas.
|
|
244
|
+
if (all.length >= workers || queue.length < TASKS_PER_NEW_WORKER * (all.length + 1)) return;
|
|
245
|
+
w = spawn();
|
|
246
|
+
}
|
|
247
|
+
const t = queue.shift();
|
|
248
|
+
const id = nextId++;
|
|
249
|
+
w.busy = id;
|
|
250
|
+
pending.set(id, t);
|
|
251
|
+
w.postMessage({ id, kind: t.kind, payload: t.payload });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function run(kind, payload) {
|
|
256
|
+
const counted = kind === 'obfuscate' || kind === 'terser';
|
|
257
|
+
if (counted) stats.tasks++;
|
|
258
|
+
const key = cacheDir ? cacheKey(kind, payload) : null;
|
|
259
|
+
if (key) {
|
|
260
|
+
const hit = readCache(key);
|
|
261
|
+
if (hit !== undefined) {
|
|
262
|
+
if (counted) stats.cacheHits++;
|
|
263
|
+
return hit;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const result =
|
|
267
|
+
workers > 0
|
|
268
|
+
? await new Promise((resolve, reject) => {
|
|
269
|
+
queue.push({ kind, payload, resolve, reject });
|
|
270
|
+
pump();
|
|
271
|
+
})
|
|
272
|
+
: await runInProcess(kind, payload);
|
|
273
|
+
if (key) writeCache(key, result);
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
stats,
|
|
279
|
+
/** Ofusca y verifica (ver obfuscateSafe). */
|
|
280
|
+
obfuscate: (code, opts) => run('obfuscate', { code, opts }),
|
|
281
|
+
/** Importaciones de un JS generado, o {error} si no es válido (verificación final). */
|
|
282
|
+
imports: (code, isModule) => run('imports', { code, opts: { isModule } }),
|
|
283
|
+
/** Valor costoso guardado en la caché por clave (p. ej. tamaños comprimidos de un contenido). */
|
|
284
|
+
async memo(kind, keyData, compute) {
|
|
285
|
+
const key = cacheDir
|
|
286
|
+
? crypto
|
|
287
|
+
.createHash('sha256')
|
|
288
|
+
.update(JSON.stringify([CACHE_FORMAT, OWN_VERSION, getLang(), kind]))
|
|
289
|
+
.update('\0')
|
|
290
|
+
.update(keyData)
|
|
291
|
+
.digest('hex')
|
|
292
|
+
: null;
|
|
293
|
+
if (key) {
|
|
294
|
+
const hit = readCache(key);
|
|
295
|
+
if (hit !== undefined) return hit;
|
|
296
|
+
}
|
|
297
|
+
const value = await compute();
|
|
298
|
+
if (key) writeCache(key, value);
|
|
299
|
+
return value;
|
|
300
|
+
},
|
|
301
|
+
/** Pasada de terser verificada (ver terserMinify). */
|
|
302
|
+
terser: (code, opts) => run('terser', { code, opts }),
|
|
303
|
+
async close() {
|
|
304
|
+
await Promise.all(all.map((w) => w.terminate()));
|
|
305
|
+
all.length = 0;
|
|
306
|
+
idle.length = 0;
|
|
307
|
+
if (cacheDir) {
|
|
308
|
+
saveSmall();
|
|
309
|
+
pruneCache(cacheDir);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Mantiene la caché por debajo del límite borrando lo usado hace más tiempo. */
|
|
316
|
+
function pruneCache(dir) {
|
|
317
|
+
let entries = [];
|
|
318
|
+
try {
|
|
319
|
+
for (const sub of fs.readdirSync(dir)) {
|
|
320
|
+
if (!/^[0-9a-f]{2}$/.test(sub)) continue;
|
|
321
|
+
const d = path.join(dir, sub);
|
|
322
|
+
for (const f of fs.readdirSync(d)) {
|
|
323
|
+
const file = path.join(d, f);
|
|
324
|
+
const st = fs.statSync(file);
|
|
325
|
+
entries.push({ file, size: st.size, time: st.mtimeMs });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
let total = entries.reduce((a, e) => a + e.size, 0);
|
|
332
|
+
if (total <= CACHE_MAX_BYTES) return;
|
|
333
|
+
entries = entries.sort((a, b) => a.time - b.time);
|
|
334
|
+
for (const e of entries) {
|
|
335
|
+
if (total <= CACHE_MAX_BYTES * 0.8) break;
|
|
336
|
+
try {
|
|
337
|
+
fs.rmSync(e.file, { force: true });
|
|
338
|
+
total -= e.size;
|
|
339
|
+
} catch {
|
|
340
|
+
// otro proceso pudo borrarlo
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
package/src/report.js
CHANGED
|
@@ -1,35 +1,31 @@
|
|
|
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
|
+
import { L } from './i18n.js';
|
|
4
5
|
|
|
5
6
|
const COMPRESSIBLE = new Set(['html', 'css', 'js', 'json', 'svg', 'text']);
|
|
6
7
|
|
|
7
|
-
export function sizesOf(buf, type) {
|
|
8
|
+
export async function sizesOf(buf, type) {
|
|
8
9
|
const size = buf.length;
|
|
9
10
|
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
|
-
};
|
|
11
|
+
const [gz, br] = await Promise.all([gzipOf(buf), brotliOf(buf)]);
|
|
12
|
+
return { size, gzip: gz.length, brotli: br.length };
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
/** Filas del informe a partir de las salidas. */
|
|
18
|
-
export function buildReport(outputs) {
|
|
19
|
-
const rows = [];
|
|
16
|
+
export async function buildReport(outputs, heavy = null) {
|
|
20
17
|
let compressed = 0;
|
|
18
|
+
const items = [];
|
|
21
19
|
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 });
|
|
20
|
+
if (o.type === 'compressed') compressed++;
|
|
21
|
+
else items.push([rel, o]);
|
|
32
22
|
}
|
|
23
|
+
const rows = await mapLimit(items, 64, async ([rel, o]) => {
|
|
24
|
+
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 };
|
|
25
|
+
const cached = heavy && COMPRESSIBLE.has(o.type) && o.data.length;
|
|
26
|
+
const s = cached ? await heavy.memo('sizes', o.data, () => sizesOf(o.data, o.type)) : await sizesOf(o.data, o.type);
|
|
27
|
+
return { file: rel, type: o.type, action: o.action, sources: o.sources, before: o.before ?? s.size, ...s };
|
|
28
|
+
});
|
|
33
29
|
rows.sort((a, b) => (a.type === b.type ? a.file.localeCompare(b.file) : order(a.type) - order(b.type)));
|
|
34
30
|
const totals = { before: 0, size: 0, gzip: 0, brotli: 0, files: rows.length };
|
|
35
31
|
const byType = {};
|
|
@@ -56,15 +52,20 @@ export function renderReport(report, C, { verbose = false } = {}) {
|
|
|
56
52
|
const lines = [];
|
|
57
53
|
const shown = report.rows.filter((r) => verbose || r.action !== 'copy');
|
|
58
54
|
const hidden = report.rows.filter((r) => !verbose && r.action === 'copy');
|
|
59
|
-
const hiddenLabel = `+ ${hidden.length} recursos copiados sin cambios
|
|
55
|
+
const hiddenLabel = L(`+ ${hidden.length} recursos copiados sin cambios`, `+ ${hidden.length} assets copied unchanged`);
|
|
60
56
|
const w = Math.min(60, Math.max(20, ...shown.map((r) => r.file.length), hidden.length ? hiddenLabel.length : 0));
|
|
61
57
|
const pad = (s, n) => (s.length > n ? `…${s.slice(-(n - 1))}` : s.padEnd(n));
|
|
62
58
|
const num = (n) => (n == null ? '—' : formatBytes(n)).padStart(10);
|
|
63
59
|
lines.push(
|
|
64
|
-
C.gray(
|
|
60
|
+
C.gray(
|
|
61
|
+
L(
|
|
62
|
+
`${'Archivo'.padEnd(w)} ${'Antes'.padStart(10)} ${'Después'.padStart(10)} ${'gzip'.padStart(10)} ${'brotli'.padStart(10)} Ahorro`,
|
|
63
|
+
`${'File'.padEnd(w)} ${'Before'.padStart(10)} ${'After'.padStart(10)} ${'gzip'.padStart(10)} ${'brotli'.padStart(10)} Saved`,
|
|
64
|
+
),
|
|
65
|
+
),
|
|
65
66
|
);
|
|
66
67
|
for (const r of shown) {
|
|
67
|
-
const src = r.sources && r.sources.length > 1 ? C.gray(` (${r.sources.length} archivos)`) : '';
|
|
68
|
+
const src = r.sources && r.sources.length > 1 ? C.gray(L(` (${r.sources.length} archivos)`, ` (${r.sources.length} files)`)) : '';
|
|
68
69
|
lines.push(`${pad(r.file, w)} ${num(r.before)} ${num(r.size)} ${num(r.gzip)} ${num(r.brotli)} ${pct(r.before, r.size).padStart(6)}${src}`);
|
|
69
70
|
}
|
|
70
71
|
if (hidden.length) {
|
package/src/server.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import http from 'node:http';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { L } from './i18n.js';
|
|
5
6
|
|
|
6
7
|
const MIME = {
|
|
7
8
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -42,7 +43,8 @@ const MIME = {
|
|
|
42
43
|
|
|
43
44
|
const RELOAD_SCRIPT = `<script>(()=>{const s=new EventSource('/__builder/events');let box;
|
|
44
45
|
s.addEventListener('reload',()=>location.reload());
|
|
45
|
-
s.addEventListener('
|
|
46
|
+
s.addEventListener('css',()=>{for(const l of document.querySelectorAll('link[rel~="stylesheet"][href]')){const u=new URL(l.href,location.href);if(u.origin!==location.origin)continue;u.searchParams.set('vb',Date.now());const n=l.cloneNode();n.href=u.href;n.onload=n.onerror=()=>l.remove();l.after(n)}});
|
|
47
|
+
s.addEventListener('error-build',e=>{box??=document.body.appendChild(Object.assign(document.createElement('pre'),{style:'position:fixed;inset:auto 0 0 0;max-height:50vh;overflow:auto;margin:0;padding:12px;background:#300;color:#fdd;font:12px/1.4 monospace;z-index:2147483647;white-space:pre-wrap'}));box.textContent=JSON.parse(e.data)});
|
|
46
48
|
s.addEventListener('ok',()=>{box?.remove();box=null});})();</script>`;
|
|
47
49
|
|
|
48
50
|
/**
|
|
@@ -56,7 +58,7 @@ export function startServer({ dir, port = 5173, host = '127.0.0.1', liveReload =
|
|
|
56
58
|
try {
|
|
57
59
|
pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
|
58
60
|
} catch {
|
|
59
|
-
res.writeHead(400).end('URL inválida');
|
|
61
|
+
res.writeHead(400).end(L('URL inválida', 'Invalid URL'));
|
|
60
62
|
return;
|
|
61
63
|
}
|
|
62
64
|
if (liveReload && pathname === '/__builder/events') {
|
|
@@ -84,7 +86,7 @@ export function startServer({ dir, port = 5173, host = '127.0.0.1', liveReload =
|
|
|
84
86
|
}
|
|
85
87
|
fs.readFile(file, (err, data) => {
|
|
86
88
|
if (err) {
|
|
87
|
-
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end(`No encontrado: ${pathname}`);
|
|
89
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end(L(`No encontrado: ${pathname}`, `Not found: ${pathname}`));
|
|
88
90
|
return;
|
|
89
91
|
}
|
|
90
92
|
const ext = path.extname(file).toLowerCase();
|
|
@@ -111,7 +113,9 @@ export function startServer({ dir, port = 5173, host = '127.0.0.1', liveReload =
|
|
|
111
113
|
url: `http://${host === '0.0.0.0' ? 'localhost' : host}:${addr.port}/`,
|
|
112
114
|
port: addr.port,
|
|
113
115
|
reload: () => send('reload'),
|
|
114
|
-
|
|
116
|
+
// Solo cambió CSS: se recargan las hojas de estilos sin perder el estado de la página.
|
|
117
|
+
reloadCss: () => send('css'),
|
|
118
|
+
buildError: (msg) => send('error-build', `${L('Builder: error al compilar', 'Builder: build failed')}\n\n${msg}`),
|
|
115
119
|
buildOk: () => send('ok'),
|
|
116
120
|
close: () =>
|
|
117
121
|
new Promise((r) => {
|
|
@@ -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, allowPrefix = null, 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, allowPrefix).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']);
|
package/src/verify.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Verificación de la salida antes de publicarla: referencias, sintaxis, escritura y cobertura.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import { jsImports } from './jsimports.js';
|
|
5
|
+
import { mapLimit } from './compress.js';
|
|
6
6
|
import { parseHtml } from './html.js';
|
|
7
7
|
import { classifyUrl, parseSrcset } from './urls.js';
|
|
8
|
+
import { L } from './i18n.js';
|
|
8
9
|
|
|
9
10
|
function unescapeCss(s) {
|
|
10
11
|
return s.replace(/\\([0-9a-f]{1,6}[ \t\n]?|[\s\S])/gi, (_, e) => (/^[0-9a-f]/i.test(e) ? String.fromCodePoint(parseInt(e, 16)) : e));
|
|
@@ -54,17 +55,6 @@ export function cssRefs(css) {
|
|
|
54
55
|
return refs;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function jsImports(code, isModule) {
|
|
58
|
-
const ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: isModule ? 'module' : 'script', allowHashBang: true });
|
|
59
|
-
const out = [];
|
|
60
|
-
walk.full(ast, (n) => {
|
|
61
|
-
if ((n.type === 'ImportDeclaration' || n.type === 'ExportAllDeclaration' || n.type === 'ExportNamedDeclaration') && n.source)
|
|
62
|
-
out.push(n.source.value);
|
|
63
|
-
if (n.type === 'ImportExpression' && n.source.type === 'Literal' && typeof n.source.value === 'string') out.push(n.source.value);
|
|
64
|
-
});
|
|
65
|
-
return out;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
58
|
/**
|
|
69
59
|
* @param {string} staging carpeta temporal escrita
|
|
70
60
|
* @param {Map<string, object>} outputs archivos generados (rel → {data|copyFrom, type, size})
|
|
@@ -93,10 +83,20 @@ export async function verifyOutput(staging, outputs, ctx) {
|
|
|
93
83
|
const candidates = rel === '' || rel.endsWith('/') ? [`${rel}index.html`] : page ? [rel, `${rel}/index.html`, `${rel}.html`] : [rel];
|
|
94
84
|
if (candidates.some(exists)) continue;
|
|
95
85
|
const ci = candidates.map((c) => lower.get(c.toLowerCase())).find(Boolean);
|
|
96
|
-
errors.push(
|
|
86
|
+
errors.push(
|
|
87
|
+
L(
|
|
88
|
+
`${fromRel}: la referencia "${u}" no existe en la salida${ci ? ` (existe "${ci}" con otras mayúsculas)` : ''}.`,
|
|
89
|
+
`${fromRel}: the reference "${u}" does not exist in the output${ci ? ` ("${ci}" exists with different capitalization)` : ''}.`,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
97
92
|
}
|
|
98
93
|
};
|
|
99
94
|
|
|
95
|
+
// Parseo de los JS generados: en paralelo (hilos) y con caché por contenido; el resultado es el mismo.
|
|
96
|
+
const parse = (code, isModule) => (ctx.heavy ? ctx.heavy.imports(code, isModule) : jsImports(code, isModule));
|
|
97
|
+
const jsList = [...outputs].filter(([, o]) => o.type === 'js' && !o.copyFrom);
|
|
98
|
+
const parsed = new Map(await mapLimit(jsList, 64, async ([rel, o]) => [rel, await parse(o.data.toString('utf8'), !!o.module)]));
|
|
99
|
+
|
|
100
100
|
for (const [rel, o] of outputs) {
|
|
101
101
|
stats.files++;
|
|
102
102
|
const dest = path.join(staging, rel);
|
|
@@ -104,11 +104,12 @@ export async function verifyOutput(staging, outputs, ctx) {
|
|
|
104
104
|
try {
|
|
105
105
|
st = fs.statSync(dest);
|
|
106
106
|
} catch {
|
|
107
|
-
errors.push(`${rel}: no se escribió en disco.`);
|
|
107
|
+
errors.push(L(`${rel}: no se escribió en disco.`, `${rel}: was not written to disk.`));
|
|
108
108
|
continue;
|
|
109
109
|
}
|
|
110
110
|
const expected = o.copyFrom ? o.size : Buffer.byteLength(o.data);
|
|
111
|
-
if (st.size !== expected)
|
|
111
|
+
if (st.size !== expected)
|
|
112
|
+
errors.push(L(`${rel}: tamaño en disco ${st.size} ≠ esperado ${expected}.`, `${rel}: size on disk ${st.size} ≠ expected ${expected}.`));
|
|
112
113
|
if (o.copyFrom) continue;
|
|
113
114
|
const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
|
|
114
115
|
if (o.type === 'html') {
|
|
@@ -120,11 +121,15 @@ export async function verifyOutput(staging, outputs, ctx) {
|
|
|
120
121
|
for (const s of model.scripts) {
|
|
121
122
|
if (s.src) check(rel, s.src.value, baseDir);
|
|
122
123
|
if (s.inline && s.kind === 'module') {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
const r = jsImports(s.inline.code, true);
|
|
125
|
+
if (r.error)
|
|
126
|
+
errors.push(
|
|
127
|
+
L(
|
|
128
|
+
`${rel}: un <script type="module"> inline no es JS válido (${r.error}).`,
|
|
129
|
+
`${rel}: an inline <script type="module"> is not valid JS (${r.error}).`,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
else for (const spec of r.imports) if (/^\.{0,2}\//.test(spec)) check(rel, spec, baseDir);
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
for (const l of model.links) if (l.type === 'stylesheet' || l.type === 'asset' || l.type === 'preload') check(rel, l.href.value, baseDir);
|
|
@@ -134,15 +139,20 @@ export async function verifyOutput(staging, outputs, ctx) {
|
|
|
134
139
|
for (const u of cssRefs(o.data.toString('utf8'))) check(rel, u, dir);
|
|
135
140
|
} else if (o.type === 'js') {
|
|
136
141
|
stats.js++;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
+
const r = parsed.get(rel);
|
|
143
|
+
if (r.error)
|
|
144
|
+
errors.push(L(`${rel}: el JavaScript generado no es válido (${r.error}).`, `${rel}: the generated JavaScript is not valid (${r.error}).`));
|
|
145
|
+
else for (const spec of r.imports) if (/^\.{0,2}\//.test(spec)) check(rel, spec, dir);
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
const missing = [...ctx.files.keys()].filter((rel) => !ctx.coverage.has(rel));
|
|
146
|
-
for (const rel of missing)
|
|
150
|
+
for (const rel of missing)
|
|
151
|
+
errors.push(
|
|
152
|
+
L(
|
|
153
|
+
`${rel}: Builder no registró qué hizo con este archivo (no se publicaría).`,
|
|
154
|
+
`${rel}: Builder did not record what it did with this file (it would not be published).`,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
147
157
|
return { errors, warnings, stats };
|
|
148
158
|
}
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Hilo de trabajo: ofusca o comprime con terser fuera del hilo principal.
|
|
2
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
3
|
+
import { ensureObfuscator, obfuscateSafe } from './obfuscate.js';
|
|
4
|
+
import { importOptional } from './optional.js';
|
|
5
|
+
import { terserMinify } from './tersercore.js';
|
|
6
|
+
import { jsImports } from './jsimports.js';
|
|
7
|
+
import { L, setLang } from './i18n.js';
|
|
8
|
+
|
|
9
|
+
let terser = null;
|
|
10
|
+
|
|
11
|
+
// Mismo idioma que el proceso principal (puede venir de --lang).
|
|
12
|
+
setLang(workerData.lang);
|
|
13
|
+
|
|
14
|
+
parentPort.on('message', async ({ id, kind, payload }) => {
|
|
15
|
+
try {
|
|
16
|
+
let result;
|
|
17
|
+
if (kind === 'obfuscate') {
|
|
18
|
+
await ensureObfuscator(workerData.root);
|
|
19
|
+
result = obfuscateSafe(payload.code, payload.opts);
|
|
20
|
+
} else if (kind === 'terser') {
|
|
21
|
+
terser ??= await importOptional('terser', workerData.root, L('para la compresión máxima', 'for maximum compression'));
|
|
22
|
+
result = await terserMinify(terser, payload.code, payload.opts);
|
|
23
|
+
} else if (kind === 'imports') {
|
|
24
|
+
result = jsImports(payload.code, payload.opts.isModule);
|
|
25
|
+
} else throw new Error(`tarea desconocida: ${kind}`);
|
|
26
|
+
parentPort.postMessage({ id, result });
|
|
27
|
+
} catch (err) {
|
|
28
|
+
parentPort.postMessage({ id, error: String(err?.message ?? err) });
|
|
29
|
+
}
|
|
30
|
+
});
|