vanilla-builder 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.es.md +231 -0
- package/README.md +232 -2
- package/bin/builder.js +471 -0
- package/package.json +83 -4
- package/src/builder.js +1385 -0
- package/src/compress.js +44 -0
- package/src/config.js +197 -0
- package/src/css.js +166 -0
- package/src/deps.js +199 -0
- package/src/files.js +159 -0
- package/src/html.js +446 -0
- package/src/index.js +22 -0
- package/src/js.js +584 -0
- package/src/jsimports.js +54 -0
- package/src/jsinfo.js +306 -0
- package/src/obfuscate.js +160 -0
- package/src/optional.js +38 -0
- package/src/output.js +100 -0
- package/src/pool.js +341 -0
- package/src/report.js +74 -0
- package/src/server.js +124 -0
- package/src/sourcemap.js +77 -0
- package/src/tersercore.js +33 -0
- package/src/urls.js +108 -0
- package/src/util.js +155 -0
- package/src/verify.js +138 -0
- package/src/watch.js +60 -0
- package/src/worker.js +26 -0
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
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Informe de tamaños (original, final, gzip y brotli) por archivo y totales.
|
|
2
|
+
import { formatBytes } from './util.js';
|
|
3
|
+
import { gzipOf, brotliOf, mapLimit } from './compress.js';
|
|
4
|
+
|
|
5
|
+
const COMPRESSIBLE = new Set(['html', 'css', 'js', 'json', 'svg', 'text']);
|
|
6
|
+
|
|
7
|
+
export async function sizesOf(buf, type) {
|
|
8
|
+
const size = buf.length;
|
|
9
|
+
if (!COMPRESSIBLE.has(type) || size === 0) return { size, gzip: null, brotli: null };
|
|
10
|
+
const [gz, br] = await Promise.all([gzipOf(buf), brotliOf(buf)]);
|
|
11
|
+
return { size, gzip: gz.length, brotli: br.length };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Filas del informe a partir de las salidas. */
|
|
15
|
+
export async function buildReport(outputs, heavy = null) {
|
|
16
|
+
let compressed = 0;
|
|
17
|
+
const items = [];
|
|
18
|
+
for (const [rel, o] of outputs) {
|
|
19
|
+
if (o.type === 'compressed') compressed++;
|
|
20
|
+
else items.push([rel, o]);
|
|
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
|
+
});
|
|
28
|
+
rows.sort((a, b) => (a.type === b.type ? a.file.localeCompare(b.file) : order(a.type) - order(b.type)));
|
|
29
|
+
const totals = { before: 0, size: 0, gzip: 0, brotli: 0, files: rows.length };
|
|
30
|
+
const byType = {};
|
|
31
|
+
for (const r of rows) {
|
|
32
|
+
totals.before += r.before;
|
|
33
|
+
totals.size += r.size;
|
|
34
|
+
totals.gzip += r.gzip ?? r.size;
|
|
35
|
+
totals.brotli += r.brotli ?? r.size;
|
|
36
|
+
const t = (byType[r.type] ??= { files: 0, before: 0, size: 0 });
|
|
37
|
+
t.files++;
|
|
38
|
+
t.before += r.before;
|
|
39
|
+
t.size += r.size;
|
|
40
|
+
}
|
|
41
|
+
return { rows, totals, byType, compressed };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ORDER = ['html', 'js', 'css', 'json', 'svg', 'text', 'asset'];
|
|
45
|
+
const order = (t) => (ORDER.includes(t) ? ORDER.indexOf(t) : ORDER.length);
|
|
46
|
+
|
|
47
|
+
const pct = (before, after) => (before > 0 ? `${(((before - after) / before) * 100).toFixed(1)}%` : '—');
|
|
48
|
+
|
|
49
|
+
/** Tabla para la terminal. Los recursos copiados sin cambios se resumen en una línea. */
|
|
50
|
+
export function renderReport(report, C, { verbose = false } = {}) {
|
|
51
|
+
const lines = [];
|
|
52
|
+
const shown = report.rows.filter((r) => verbose || r.action !== 'copy');
|
|
53
|
+
const hidden = report.rows.filter((r) => !verbose && r.action === 'copy');
|
|
54
|
+
const hiddenLabel = `+ ${hidden.length} recursos copiados sin cambios`;
|
|
55
|
+
const w = Math.min(60, Math.max(20, ...shown.map((r) => r.file.length), hidden.length ? hiddenLabel.length : 0));
|
|
56
|
+
const pad = (s, n) => (s.length > n ? `…${s.slice(-(n - 1))}` : s.padEnd(n));
|
|
57
|
+
const num = (n) => (n == null ? '—' : formatBytes(n)).padStart(10);
|
|
58
|
+
lines.push(
|
|
59
|
+
C.gray(`${'Archivo'.padEnd(w)} ${'Antes'.padStart(10)} ${'Después'.padStart(10)} ${'gzip'.padStart(10)} ${'brotli'.padStart(10)} Ahorro`),
|
|
60
|
+
);
|
|
61
|
+
for (const r of shown) {
|
|
62
|
+
const src = r.sources && r.sources.length > 1 ? C.gray(` (${r.sources.length} archivos)`) : '';
|
|
63
|
+
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}`);
|
|
64
|
+
}
|
|
65
|
+
if (hidden.length) {
|
|
66
|
+
const size = hidden.reduce((a, r) => a + r.size, 0);
|
|
67
|
+
lines.push(C.gray(`${pad(hiddenLabel, w)} ${num(size)} ${num(size)}`));
|
|
68
|
+
}
|
|
69
|
+
const t = report.totals;
|
|
70
|
+
lines.push(
|
|
71
|
+
C.bold(`${'Total'.padEnd(w)} ${num(t.before)} ${num(t.size)} ${num(t.gzip)} ${num(t.brotli)} ${pct(t.before, t.size).padStart(6)}`),
|
|
72
|
+
);
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Servidor estático para probar la salida, con recarga automática opcional (modo dev).
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const MIME = {
|
|
7
|
+
'.html': 'text/html; charset=utf-8',
|
|
8
|
+
'.htm': 'text/html; charset=utf-8',
|
|
9
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
10
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
11
|
+
'.cjs': 'text/javascript; charset=utf-8',
|
|
12
|
+
'.css': 'text/css; charset=utf-8',
|
|
13
|
+
'.json': 'application/json; charset=utf-8',
|
|
14
|
+
'.map': 'application/json; charset=utf-8',
|
|
15
|
+
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
16
|
+
'.svg': 'image/svg+xml',
|
|
17
|
+
'.png': 'image/png',
|
|
18
|
+
'.jpg': 'image/jpeg',
|
|
19
|
+
'.jpeg': 'image/jpeg',
|
|
20
|
+
'.gif': 'image/gif',
|
|
21
|
+
'.webp': 'image/webp',
|
|
22
|
+
'.avif': 'image/avif',
|
|
23
|
+
'.ico': 'image/x-icon',
|
|
24
|
+
'.bmp': 'image/bmp',
|
|
25
|
+
'.woff': 'font/woff',
|
|
26
|
+
'.woff2': 'font/woff2',
|
|
27
|
+
'.ttf': 'font/ttf',
|
|
28
|
+
'.otf': 'font/otf',
|
|
29
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
30
|
+
'.mp4': 'video/mp4',
|
|
31
|
+
'.webm': 'video/webm',
|
|
32
|
+
'.mp3': 'audio/mpeg',
|
|
33
|
+
'.wav': 'audio/wav',
|
|
34
|
+
'.ogg': 'audio/ogg',
|
|
35
|
+
'.vtt': 'text/vtt; charset=utf-8',
|
|
36
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
37
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
38
|
+
'.pdf': 'application/pdf',
|
|
39
|
+
'.wasm': 'application/wasm',
|
|
40
|
+
'.zip': 'application/zip',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const RELOAD_SCRIPT = `<script>(()=>{const s=new EventSource('/__builder/events');let box;
|
|
44
|
+
s.addEventListener('reload',()=>location.reload());
|
|
45
|
+
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='Builder: error al compilar\\n\\n'+JSON.parse(e.data)});
|
|
46
|
+
s.addEventListener('ok',()=>{box?.remove();box=null});})();</script>`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {{dir: string, port?: number, host?: string, liveReload?: boolean}} opts
|
|
50
|
+
*/
|
|
51
|
+
export function startServer({ dir, port = 5173, host = '127.0.0.1', liveReload = false }) {
|
|
52
|
+
const clients = new Set();
|
|
53
|
+
const root = path.resolve(dir);
|
|
54
|
+
const server = http.createServer((req, res) => {
|
|
55
|
+
let pathname;
|
|
56
|
+
try {
|
|
57
|
+
pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
|
58
|
+
} catch {
|
|
59
|
+
res.writeHead(400).end('URL inválida');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (liveReload && pathname === '/__builder/events') {
|
|
63
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
64
|
+
res.write(': ok\n\n');
|
|
65
|
+
clients.add(res);
|
|
66
|
+
req.on('close', () => clients.delete(res));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let file = path.join(root, pathname);
|
|
70
|
+
if (path.relative(root, file).startsWith('..')) {
|
|
71
|
+
res.writeHead(403).end('Prohibido');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
if (fs.statSync(file).isDirectory()) {
|
|
76
|
+
if (!pathname.endsWith('/')) {
|
|
77
|
+
res.writeHead(301, { Location: `${pathname}/` }).end();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
file = path.join(file, 'index.html');
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
/* se maneja abajo */
|
|
84
|
+
}
|
|
85
|
+
fs.readFile(file, (err, data) => {
|
|
86
|
+
if (err) {
|
|
87
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end(`No encontrado: ${pathname}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const ext = path.extname(file).toLowerCase();
|
|
91
|
+
const type = MIME[ext] ?? 'application/octet-stream';
|
|
92
|
+
let body = data;
|
|
93
|
+
if (liveReload && ext.startsWith('.htm')) {
|
|
94
|
+
const html = data.toString('utf8');
|
|
95
|
+
const i = html.toLowerCase().lastIndexOf('</body>');
|
|
96
|
+
body = Buffer.from(i === -1 ? html + RELOAD_SCRIPT : html.slice(0, i) + RELOAD_SCRIPT + html.slice(i));
|
|
97
|
+
}
|
|
98
|
+
const headers = { 'Content-Type': type, 'Cache-Control': 'no-store' };
|
|
99
|
+
if (path.basename(file).match(/^sw\.js$|service-?worker/i)) headers['Service-Worker-Allowed'] = '/';
|
|
100
|
+
res.writeHead(200, headers).end(body);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
const send = (event, data = '') => {
|
|
104
|
+
for (const c of clients) c.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
105
|
+
};
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
server.once('error', reject);
|
|
108
|
+
server.listen(port, host, () => {
|
|
109
|
+
const addr = server.address();
|
|
110
|
+
resolve({
|
|
111
|
+
url: `http://${host === '0.0.0.0' ? 'localhost' : host}:${addr.port}/`,
|
|
112
|
+
port: addr.port,
|
|
113
|
+
reload: () => send('reload'),
|
|
114
|
+
buildError: (msg) => send('error-build', msg),
|
|
115
|
+
buildOk: () => send('ok'),
|
|
116
|
+
close: () =>
|
|
117
|
+
new Promise((r) => {
|
|
118
|
+
for (const c of clients) c.end();
|
|
119
|
+
server.close(() => r());
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
package/src/sourcemap.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Ajuste mínimo de source maps v3 cuando se reemplaza texto en el código generado.
|
|
2
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
3
|
+
const B64_INDEX = Object.fromEntries([...B64].map((c, i) => [c, i]));
|
|
4
|
+
|
|
5
|
+
function decodeVlq(str) {
|
|
6
|
+
const out = [];
|
|
7
|
+
let value = 0;
|
|
8
|
+
let shift = 0;
|
|
9
|
+
for (const ch of str) {
|
|
10
|
+
const digit = B64_INDEX[ch];
|
|
11
|
+
value += (digit & 31) << shift;
|
|
12
|
+
if (digit & 32) shift += 5;
|
|
13
|
+
else {
|
|
14
|
+
out.push(value & 1 ? -(value >>> 1) : value >>> 1);
|
|
15
|
+
value = 0;
|
|
16
|
+
shift = 0;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function encodeVlq(n) {
|
|
23
|
+
let v = n < 0 ? (-n << 1) | 1 : n << 1;
|
|
24
|
+
let out = '';
|
|
25
|
+
do {
|
|
26
|
+
let digit = v & 31;
|
|
27
|
+
v >>>= 5;
|
|
28
|
+
if (v > 0) digit |= 32;
|
|
29
|
+
out += B64[digit];
|
|
30
|
+
} while (v > 0);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reemplaza todas las apariciones de `search` por `replacement` y desplaza las columnas
|
|
36
|
+
* del source map para que sigan apuntando al mismo código.
|
|
37
|
+
*/
|
|
38
|
+
export function replaceWithMap(code, map, search, replacement) {
|
|
39
|
+
if (!code.includes(search)) return { code, map };
|
|
40
|
+
const delta = replacement.length - search.length;
|
|
41
|
+
// columnas (por línea) donde hubo reemplazos
|
|
42
|
+
const edits = new Map();
|
|
43
|
+
const lines = code.split('\n');
|
|
44
|
+
const newLines = lines.map((line, i) => {
|
|
45
|
+
if (!line.includes(search)) return line;
|
|
46
|
+
const cols = [];
|
|
47
|
+
let idx = line.indexOf(search);
|
|
48
|
+
while (idx !== -1) {
|
|
49
|
+
cols.push(idx);
|
|
50
|
+
idx = line.indexOf(search, idx + search.length);
|
|
51
|
+
}
|
|
52
|
+
edits.set(i, cols);
|
|
53
|
+
return line.split(search).join(replacement);
|
|
54
|
+
});
|
|
55
|
+
const newCode = newLines.join('\n');
|
|
56
|
+
if (!map || delta === 0) return { code: newCode, map };
|
|
57
|
+
const json = JSON.parse(map);
|
|
58
|
+
const mapLines = json.mappings.split(';');
|
|
59
|
+
for (const [lineNo, cols] of edits) {
|
|
60
|
+
if (lineNo >= mapLines.length || !mapLines[lineNo]) continue;
|
|
61
|
+
let prevIn = 0;
|
|
62
|
+
let prevOut = 0;
|
|
63
|
+
const segs = mapLines[lineNo].split(',').map((seg) => {
|
|
64
|
+
const fields = decodeVlq(seg);
|
|
65
|
+
const col = prevIn + fields[0];
|
|
66
|
+
prevIn = col;
|
|
67
|
+
const shift = cols.filter((c) => c < col).length * delta;
|
|
68
|
+
const newCol = col + shift;
|
|
69
|
+
fields[0] = newCol - prevOut;
|
|
70
|
+
prevOut = newCol;
|
|
71
|
+
return fields.map(encodeVlq).join('');
|
|
72
|
+
});
|
|
73
|
+
mapLines[lineNo] = segs.join(',');
|
|
74
|
+
}
|
|
75
|
+
json.mappings = mapLines.join(';');
|
|
76
|
+
return { code: newCode, map: JSON.stringify(json) };
|
|
77
|
+
}
|
|
@@ -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
|
+
}
|