vanilla-builder 0.0.0-stage → 0.1.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 +21 -0
- package/LICENSE +21 -0
- package/README.es.md +162 -0
- package/README.md +163 -2
- package/bin/builder.js +367 -0
- package/package.json +79 -4
- package/src/builder.js +1202 -0
- package/src/config.js +190 -0
- package/src/css.js +166 -0
- package/src/deps.js +140 -0
- package/src/files.js +152 -0
- package/src/html.js +446 -0
- package/src/index.js +22 -0
- package/src/js.js +592 -0
- package/src/jsinfo.js +290 -0
- package/src/obfuscate.js +160 -0
- package/src/optional.js +38 -0
- package/src/output.js +100 -0
- package/src/report.js +79 -0
- package/src/server.js +124 -0
- package/src/sourcemap.js +77 -0
- package/src/urls.js +105 -0
- package/src/util.js +151 -0
- package/src/verify.js +148 -0
- package/src/watch.js +60 -0
package/src/jsinfo.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Análisis estático de JavaScript con acorn: nivel de sintaxis, globales, workers y rutas.
|
|
2
|
+
import * as acorn from 'acorn';
|
|
3
|
+
import * as walk from 'acorn-walk';
|
|
4
|
+
|
|
5
|
+
const VERSIONS = [5, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026];
|
|
6
|
+
const LATEST = 'latest';
|
|
7
|
+
|
|
8
|
+
function parse(code, sourceType, ecmaVersion) {
|
|
9
|
+
return acorn.parse(code, { ecmaVersion, sourceType, allowHashBang: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function tryParse(code, sourceType, ecmaVersion) {
|
|
13
|
+
try {
|
|
14
|
+
return parse(code, sourceType, ecmaVersion);
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Nivel ES mínimo con el que el código se puede leer (búsqueda binaria). */
|
|
21
|
+
function detectLevel(code, sourceType) {
|
|
22
|
+
const list = sourceType === 'module' ? VERSIONS.slice(1) : VERSIONS;
|
|
23
|
+
let lo = 0;
|
|
24
|
+
let hi = list.length - 1;
|
|
25
|
+
if (!tryParse(code, sourceType, list[hi])) return null;
|
|
26
|
+
while (lo < hi) {
|
|
27
|
+
const mid = (lo + hi) >> 1;
|
|
28
|
+
if (tryParse(code, sourceType, list[mid])) hi = mid;
|
|
29
|
+
else lo = mid + 1;
|
|
30
|
+
}
|
|
31
|
+
return list[lo];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isStr = (n) => n && ((n.type === 'Literal' && typeof n.value === 'string') || (n.type === 'TemplateLiteral' && n.expressions.length === 0));
|
|
35
|
+
const strVal = (n) => (n.type === 'Literal' ? n.value : n.quasis[0].value.cooked);
|
|
36
|
+
const isImportMetaUrl = (n) =>
|
|
37
|
+
n?.type === 'MemberExpression' && n.object.type === 'MetaProperty' && n.object.meta.name === 'import' && !n.computed && n.property.name === 'url';
|
|
38
|
+
const propName = (n) => (n.type === 'MemberExpression' && !n.computed ? n.property.name : n.type === 'Identifier' ? n.name : null);
|
|
39
|
+
const PATHLIKE = /^[^\s<>"'`]{1,260}\.[a-z0-9]{1,10}(?:[?#][^\s]*)?$/i;
|
|
40
|
+
|
|
41
|
+
/** Texto fijo con el que empieza una expresión de ruta ('./a/' + x → './a/'). */
|
|
42
|
+
function fixedPrefix(n) {
|
|
43
|
+
if (isStr(n)) return strVal(n);
|
|
44
|
+
if (n.type === 'TemplateLiteral') return n.quasis[0].value.cooked ?? '';
|
|
45
|
+
if (n.type === 'BinaryExpression' && n.operator === '+') return fixedPrefix(n.left);
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function patternNames(node, out) {
|
|
50
|
+
if (!node) return;
|
|
51
|
+
switch (node.type) {
|
|
52
|
+
case 'Identifier':
|
|
53
|
+
out.push(node.name);
|
|
54
|
+
break;
|
|
55
|
+
case 'ObjectPattern':
|
|
56
|
+
for (const p of node.properties) patternNames(p.type === 'RestElement' ? p.argument : p.value, out);
|
|
57
|
+
break;
|
|
58
|
+
case 'ArrayPattern':
|
|
59
|
+
for (const e of node.elements) patternNames(e, out);
|
|
60
|
+
break;
|
|
61
|
+
case 'RestElement':
|
|
62
|
+
patternNames(node.argument, out);
|
|
63
|
+
break;
|
|
64
|
+
case 'AssignmentPattern':
|
|
65
|
+
patternNames(node.left, out);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Nombres que un script clásico crea en el ámbito global, con su tipo (var | lex). */
|
|
71
|
+
function globalBindings(ast, strict) {
|
|
72
|
+
const top = new Map();
|
|
73
|
+
for (const stmt of ast.body) {
|
|
74
|
+
if (stmt.type === 'VariableDeclaration' && stmt.kind !== 'var') {
|
|
75
|
+
const names = [];
|
|
76
|
+
for (const d of stmt.declarations) patternNames(d.id, names);
|
|
77
|
+
for (const n of names) top.set(n, 'lex');
|
|
78
|
+
} else if (stmt.type === 'ClassDeclaration') top.set(stmt.id.name, 'lex');
|
|
79
|
+
}
|
|
80
|
+
// var y function se elevan al ámbito global desde cualquier bloque (sin cruzar funciones).
|
|
81
|
+
walk.recursive(ast, null, {
|
|
82
|
+
Function() {},
|
|
83
|
+
StaticBlock() {},
|
|
84
|
+
FunctionDeclaration(node) {
|
|
85
|
+
// En modo estricto las funciones dentro de bloques no son globales.
|
|
86
|
+
if (!top.has(node.id.name) && (!strict || ast.body.includes(node))) top.set(node.id.name, 'var');
|
|
87
|
+
},
|
|
88
|
+
VariableDeclaration(node, st, c) {
|
|
89
|
+
if (node.kind === 'var') {
|
|
90
|
+
const names = [];
|
|
91
|
+
for (const d of node.declarations) patternNames(d.id, names);
|
|
92
|
+
for (const n of names) if (!top.has(n)) top.set(n, 'var');
|
|
93
|
+
}
|
|
94
|
+
for (const d of node.declarations) if (d.init) c(d.init, st);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return top;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Analiza un archivo JS.
|
|
102
|
+
* @param {string} code
|
|
103
|
+
* @param {{module?: boolean|'auto'}} [opts]
|
|
104
|
+
*/
|
|
105
|
+
export function analyzeJs(code, opts = {}) {
|
|
106
|
+
const want = opts.module ?? 'auto';
|
|
107
|
+
let sourceType = want === true ? 'module' : 'script';
|
|
108
|
+
let ast = tryParse(code, sourceType, LATEST);
|
|
109
|
+
if (!ast && want === 'auto') {
|
|
110
|
+
sourceType = 'module';
|
|
111
|
+
ast = tryParse(code, 'module', LATEST);
|
|
112
|
+
}
|
|
113
|
+
if (!ast) {
|
|
114
|
+
let error;
|
|
115
|
+
try {
|
|
116
|
+
parse(code, sourceType, LATEST);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
error = err.message;
|
|
119
|
+
}
|
|
120
|
+
return { ok: false, error, isModule: sourceType === 'module' };
|
|
121
|
+
}
|
|
122
|
+
const isModule = sourceType === 'module';
|
|
123
|
+
const strict = isModule || ast.body.some((s) => s.directive === 'use strict');
|
|
124
|
+
const info = {
|
|
125
|
+
ok: true,
|
|
126
|
+
isModule,
|
|
127
|
+
level: detectLevel(code, sourceType),
|
|
128
|
+
strict,
|
|
129
|
+
top: isModule ? new Map() : globalBindings(ast, strict),
|
|
130
|
+
imports: [],
|
|
131
|
+
workers: [],
|
|
132
|
+
urls: [],
|
|
133
|
+
unsafeUrls: [],
|
|
134
|
+
unsafeImports: [],
|
|
135
|
+
dynamicImports: 0,
|
|
136
|
+
strings: new Set(),
|
|
137
|
+
usesCurrentScript: false,
|
|
138
|
+
usesDocumentWrite: false,
|
|
139
|
+
hasImportMeta: false,
|
|
140
|
+
hasModuleSyntax: false,
|
|
141
|
+
};
|
|
142
|
+
const addWorker = (arg, kind, optsNode) => {
|
|
143
|
+
if (!arg) return;
|
|
144
|
+
let spec = null;
|
|
145
|
+
let viaUrl = false;
|
|
146
|
+
handled.add(arg);
|
|
147
|
+
if (isStr(arg)) spec = strVal(arg);
|
|
148
|
+
else if (arg.type === 'NewExpression' && propName(arg.callee) === 'URL' && isStr(arg.arguments[0]) && isImportMetaUrl(arg.arguments[1])) {
|
|
149
|
+
spec = strVal(arg.arguments[0]);
|
|
150
|
+
viaUrl = true;
|
|
151
|
+
}
|
|
152
|
+
const module =
|
|
153
|
+
optsNode?.type === 'ObjectExpression' &&
|
|
154
|
+
optsNode.properties.some(
|
|
155
|
+
(p) => p.type === 'Property' && (p.key.name === 'type' || p.key.value === 'type') && isStr(p.value) && strVal(p.value) === 'module',
|
|
156
|
+
);
|
|
157
|
+
info.workers.push({ spec, kind, module: !!module, viaUrl });
|
|
158
|
+
};
|
|
159
|
+
// Textos que ya son rutas de import/worker/new URL: no cuentan como "mencionados".
|
|
160
|
+
const handled = new Set();
|
|
161
|
+
const candidates = [];
|
|
162
|
+
walk.full(ast, (node) => {
|
|
163
|
+
switch (node.type) {
|
|
164
|
+
case 'ImportDeclaration':
|
|
165
|
+
case 'ExportAllDeclaration':
|
|
166
|
+
case 'ExportNamedDeclaration':
|
|
167
|
+
info.hasModuleSyntax = true;
|
|
168
|
+
if (node.source) {
|
|
169
|
+
handled.add(node.source);
|
|
170
|
+
info.imports.push({ spec: node.source.value, kind: 'static' });
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
case 'ExportDefaultDeclaration':
|
|
174
|
+
info.hasModuleSyntax = true;
|
|
175
|
+
break;
|
|
176
|
+
case 'ImportExpression':
|
|
177
|
+
info.dynamicImports++;
|
|
178
|
+
handled.add(node.source);
|
|
179
|
+
if (isStr(node.source)) info.imports.push({ spec: strVal(node.source), kind: 'dynamic' });
|
|
180
|
+
else {
|
|
181
|
+
info.imports.push({ spec: null, kind: 'dynamic' });
|
|
182
|
+
// esbuild sabe seguir `./pag/${x}.js` o './pag/' + x (prefijo fijo); una ruta calculada sin prefijo, no.
|
|
183
|
+
if (!/^\.\.?\//.test(fixedPrefix(node.source))) info.unsafeImports.push(code.slice(node.start, Math.min(node.end, node.start + 80)));
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
case 'MetaProperty':
|
|
187
|
+
if (node.meta.name === 'import') info.hasImportMeta = true;
|
|
188
|
+
break;
|
|
189
|
+
case 'MemberExpression':
|
|
190
|
+
if (node.object.type === 'Identifier' && node.object.name === 'document' && !node.computed) {
|
|
191
|
+
if (node.property.name === 'currentScript') info.usesCurrentScript = true;
|
|
192
|
+
if (node.property.name === 'write' || node.property.name === 'writeln') info.usesDocumentWrite = true;
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
case 'NewExpression': {
|
|
196
|
+
const name = propName(node.callee);
|
|
197
|
+
if (name === 'Worker' || name === 'SharedWorker')
|
|
198
|
+
addWorker(node.arguments[0], name === 'Worker' ? 'worker' : 'sharedworker', node.arguments[1]);
|
|
199
|
+
else if (name === 'URL' && isImportMetaUrl(node.arguments[1])) {
|
|
200
|
+
const a = node.arguments[0];
|
|
201
|
+
handled.add(a);
|
|
202
|
+
if (isStr(a)) info.urls.push({ spec: strVal(a), start: a.start, end: a.end });
|
|
203
|
+
else info.unsafeUrls.push(code.slice(node.start, Math.min(node.end, node.start + 80)));
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case 'CallExpression': {
|
|
208
|
+
const callee = node.callee;
|
|
209
|
+
if (callee.type === 'Identifier' && callee.name === 'importScripts') {
|
|
210
|
+
for (const a of node.arguments) {
|
|
211
|
+
handled.add(a);
|
|
212
|
+
if (isStr(a)) info.workers.push({ spec: strVal(a), kind: 'importScripts', module: false, viaUrl: false });
|
|
213
|
+
}
|
|
214
|
+
} else if (
|
|
215
|
+
callee.type === 'MemberExpression' &&
|
|
216
|
+
propName(callee) === 'register' &&
|
|
217
|
+
callee.object.type === 'MemberExpression' &&
|
|
218
|
+
propName(callee.object) === 'serviceWorker'
|
|
219
|
+
) {
|
|
220
|
+
addWorker(node.arguments[0], 'serviceworker', node.arguments[1]);
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'Literal':
|
|
225
|
+
if (typeof node.value === 'string' && PATHLIKE.test(node.value)) candidates.push([node, node.value]);
|
|
226
|
+
break;
|
|
227
|
+
case 'TemplateLiteral':
|
|
228
|
+
if (node.expressions.length === 0 && PATHLIKE.test(node.quasis[0].value.cooked ?? '')) candidates.push([node, node.quasis[0].value.cooked]);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
for (const [node, value] of candidates) if (!handled.has(node)) info.strings.add(value);
|
|
233
|
+
if (!info.usesCurrentScript && /document\s*(?:\.\s*currentScript|\[\s*['"`]currentScript)/.test(code)) info.usesCurrentScript = true;
|
|
234
|
+
return info;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Comprueba que el JS de salida sea sintácticamente válido. */
|
|
238
|
+
export function checkSyntax(code, isModule) {
|
|
239
|
+
try {
|
|
240
|
+
parse(code, isModule ? 'module' : 'script', LATEST);
|
|
241
|
+
return null;
|
|
242
|
+
} catch (err) {
|
|
243
|
+
return err.message;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Compara las globales de un script antes y después de transformarlo.
|
|
249
|
+
* `allowPrefix` permite nombres nuevos con ese prefijo (los que añade el ofuscador).
|
|
250
|
+
*/
|
|
251
|
+
export function compareGlobals(before, afterCode, allowPrefix = null) {
|
|
252
|
+
const after = analyzeJs(afterCode, { module: false });
|
|
253
|
+
if (!after.ok) return { ok: false, reason: `el resultado no es JS válido (${after.error})` };
|
|
254
|
+
for (const [name, kind] of before) {
|
|
255
|
+
if (!after.top.has(name)) return { ok: false, reason: `desaparece la global "${name}"` };
|
|
256
|
+
if (after.top.get(name) !== kind) return { ok: false, reason: `la global "${name}" cambia de tipo (${kind} → ${after.top.get(name)})` };
|
|
257
|
+
}
|
|
258
|
+
for (const name of after.top.keys()) {
|
|
259
|
+
if (!before.has(name) && !(allowPrefix && name.startsWith(allowPrefix)))
|
|
260
|
+
return { ok: false, reason: `aparece una global nueva "${name}" que podría chocar con otros scripts` };
|
|
261
|
+
}
|
|
262
|
+
return { ok: true };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Exportaciones e importaciones de un módulo, para comparar antes/después de ofuscar. */
|
|
266
|
+
export function moduleShape(code) {
|
|
267
|
+
const ast = tryParse(code, 'module', LATEST);
|
|
268
|
+
if (!ast) return null;
|
|
269
|
+
const exports = [];
|
|
270
|
+
const imports = [];
|
|
271
|
+
// Los atributos (with { type: 'css' }) forman parte del import: perderlos cambia cómo se carga.
|
|
272
|
+
const src = (s) => `${s.source.value}${(s.attributes ?? []).map((a) => `|${a.key.name ?? a.key.value}=${a.value.value}`).join('')}`;
|
|
273
|
+
for (const s of ast.body) {
|
|
274
|
+
if (s.type === 'ExportNamedDeclaration') {
|
|
275
|
+
for (const sp of s.specifiers) exports.push(sp.exported.name ?? sp.exported.value);
|
|
276
|
+
if (s.declaration) {
|
|
277
|
+
if (s.declaration.id) exports.push(s.declaration.id.name);
|
|
278
|
+
else if (s.declaration.declarations) for (const d of s.declaration.declarations) patternNames(d.id, exports);
|
|
279
|
+
}
|
|
280
|
+
if (s.source) imports.push(src(s));
|
|
281
|
+
} else if (s.type === 'ExportDefaultDeclaration') exports.push('default');
|
|
282
|
+
else if (s.type === 'ExportAllDeclaration') {
|
|
283
|
+
exports.push(`*${s.exported ? `:${s.exported.name ?? s.exported.value}` : ''}`);
|
|
284
|
+
imports.push(src(s));
|
|
285
|
+
} else if (s.type === 'ImportDeclaration') imports.push(src(s));
|
|
286
|
+
}
|
|
287
|
+
return { exports: exports.sort().join(','), imports: imports.sort().join(',') };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export { VERSIONS };
|
package/src/obfuscate.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Ofuscación con javascript-obfuscator (dependencia opcional), con ajustes seguros y verificación del resultado.
|
|
2
|
+
import * as acorn from 'acorn';
|
|
3
|
+
import { shortHash } from './util.js';
|
|
4
|
+
import { compareGlobals, moduleShape, checkSyntax } from './jsinfo.js';
|
|
5
|
+
import { importOptional } from './optional.js';
|
|
6
|
+
|
|
7
|
+
// Nunca se activan solos: cambian el comportamiento o rompen código que comparte globales.
|
|
8
|
+
const FORCED_SAFE = { renameGlobals: false, renameProperties: false, sourceMap: false, target: 'browser' };
|
|
9
|
+
|
|
10
|
+
const BASE = {
|
|
11
|
+
compact: true,
|
|
12
|
+
simplify: true,
|
|
13
|
+
identifierNamesGenerator: 'hexadecimal',
|
|
14
|
+
stringArray: true,
|
|
15
|
+
stringArrayRotate: true,
|
|
16
|
+
stringArrayShuffle: true,
|
|
17
|
+
stringArrayIndexShift: true,
|
|
18
|
+
stringArrayWrappersCount: 1,
|
|
19
|
+
stringArrayWrappersType: 'variable',
|
|
20
|
+
stringArrayThreshold: 0.75,
|
|
21
|
+
stringArrayEncoding: [],
|
|
22
|
+
controlFlowFlattening: false,
|
|
23
|
+
deadCodeInjection: false,
|
|
24
|
+
numbersToExpressions: false,
|
|
25
|
+
splitStrings: false,
|
|
26
|
+
transformObjectKeys: false,
|
|
27
|
+
selfDefending: false,
|
|
28
|
+
debugProtection: false,
|
|
29
|
+
disableConsoleOutput: false,
|
|
30
|
+
unicodeEscapeSequence: false,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const PRESETS = {
|
|
34
|
+
// Rápido y casi sin coste de rendimiento.
|
|
35
|
+
low: { ...BASE },
|
|
36
|
+
// Equilibrio recomendado.
|
|
37
|
+
medium: {
|
|
38
|
+
...BASE,
|
|
39
|
+
controlFlowFlattening: true,
|
|
40
|
+
controlFlowFlatteningThreshold: 0.5,
|
|
41
|
+
deadCodeInjection: true,
|
|
42
|
+
deadCodeInjectionThreshold: 0.2,
|
|
43
|
+
numbersToExpressions: true,
|
|
44
|
+
splitStrings: true,
|
|
45
|
+
splitStringsChunkLength: 10,
|
|
46
|
+
stringArrayEncoding: ['base64'],
|
|
47
|
+
stringArrayWrappersCount: 2,
|
|
48
|
+
stringArrayThreshold: 0.8,
|
|
49
|
+
},
|
|
50
|
+
high: {
|
|
51
|
+
...BASE,
|
|
52
|
+
controlFlowFlattening: true,
|
|
53
|
+
controlFlowFlatteningThreshold: 0.75,
|
|
54
|
+
deadCodeInjection: true,
|
|
55
|
+
deadCodeInjectionThreshold: 0.4,
|
|
56
|
+
numbersToExpressions: true,
|
|
57
|
+
splitStrings: true,
|
|
58
|
+
splitStringsChunkLength: 5,
|
|
59
|
+
stringArrayEncoding: ['rc4'],
|
|
60
|
+
stringArrayWrappersCount: 5,
|
|
61
|
+
stringArrayWrappersChainedCalls: true,
|
|
62
|
+
stringArrayWrappersParametersMaxCount: 5,
|
|
63
|
+
stringArrayWrappersType: 'function',
|
|
64
|
+
stringArrayThreshold: 1,
|
|
65
|
+
transformObjectKeys: true,
|
|
66
|
+
},
|
|
67
|
+
// Máxima protección: además se defiende de ser formateado o modificado. Más lento y pesado.
|
|
68
|
+
max: {
|
|
69
|
+
...BASE,
|
|
70
|
+
controlFlowFlattening: true,
|
|
71
|
+
controlFlowFlatteningThreshold: 1,
|
|
72
|
+
deadCodeInjection: true,
|
|
73
|
+
deadCodeInjectionThreshold: 0.5,
|
|
74
|
+
numbersToExpressions: true,
|
|
75
|
+
splitStrings: true,
|
|
76
|
+
splitStringsChunkLength: 3,
|
|
77
|
+
stringArrayEncoding: ['rc4'],
|
|
78
|
+
stringArrayWrappersCount: 5,
|
|
79
|
+
stringArrayWrappersChainedCalls: true,
|
|
80
|
+
stringArrayWrappersParametersMaxCount: 5,
|
|
81
|
+
stringArrayWrappersType: 'function',
|
|
82
|
+
stringArrayCallsTransform: true,
|
|
83
|
+
stringArrayCallsTransformThreshold: 1,
|
|
84
|
+
stringArrayThreshold: 1,
|
|
85
|
+
transformObjectKeys: true,
|
|
86
|
+
selfDefending: true,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Opciones finales: preset, o preset "low" + opciones del usuario. */
|
|
91
|
+
export function resolveObfuscatorOptions(setting) {
|
|
92
|
+
if (!setting) return null;
|
|
93
|
+
if (typeof setting === 'string') return { ...PRESETS[setting], ...FORCED_SAFE };
|
|
94
|
+
const { preset = 'low', ...custom } = setting;
|
|
95
|
+
// El usuario puede activar renameGlobals para módulos bajo su responsabilidad; nunca se aplica a scripts clásicos.
|
|
96
|
+
return { ...(PRESETS[preset] ?? PRESETS.low), ...FORCED_SAFE, ...custom, sourceMap: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let engine = null;
|
|
100
|
+
|
|
101
|
+
/** Carga javascript-obfuscator (instalado junto a Builder o en el proyecto). */
|
|
102
|
+
export async function ensureObfuscator(projectRoot) {
|
|
103
|
+
if (!engine) {
|
|
104
|
+
const mod = await importOptional('javascript-obfuscator', projectRoot, 'para ofuscar el JavaScript');
|
|
105
|
+
engine = mod.default ?? mod;
|
|
106
|
+
}
|
|
107
|
+
return engine;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Comentarios legales (/*! ... *\/, @license, @preserve): el ofuscador los borra y se reponen al final. */
|
|
111
|
+
function legalComments(code, isModule) {
|
|
112
|
+
const found = [];
|
|
113
|
+
try {
|
|
114
|
+
acorn.parse(code, {
|
|
115
|
+
ecmaVersion: 'latest',
|
|
116
|
+
sourceType: isModule ? 'module' : 'script',
|
|
117
|
+
allowHashBang: true,
|
|
118
|
+
onComment: (block, text) => {
|
|
119
|
+
if (/^!|@license|@preserve/.test(text)) found.push(block ? `/*${text}*/` : `//${text}`);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
return [...new Set(found)].join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Ofusca un archivo y verifica que siga siendo equivalente en lo que se puede comprobar:
|
|
130
|
+
* - scripts clásicos: mismas globales (más las del ofuscador, con prefijo propio);
|
|
131
|
+
* - módulos: mismas exportaciones, importaciones y atributos de import.
|
|
132
|
+
* Si algo no cuadra devuelve {code: null, reason} y se conserva la versión sin ofuscar.
|
|
133
|
+
*/
|
|
134
|
+
export function obfuscateSafe(code, { isModule, options, id, globals }) {
|
|
135
|
+
if (!engine) throw new Error('javascript-obfuscator no está cargado (llama antes a ensureObfuscator)');
|
|
136
|
+
const body = code;
|
|
137
|
+
const legal = legalComments(code, isModule);
|
|
138
|
+
const prefix = `_${shortHash(id, 6)}`;
|
|
139
|
+
// Semilla derivada del contenido: la misma entrada produce siempre la misma salida (caché y diffs estables).
|
|
140
|
+
const opts = { ...options, seed: shortHash(`${id}\0${body}`, 12), identifiersPrefix: prefix };
|
|
141
|
+
if (!isModule) opts.renameGlobals = false;
|
|
142
|
+
let out;
|
|
143
|
+
try {
|
|
144
|
+
out = engine.obfuscate(body, opts).getObfuscatedCode();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return { code: null, reason: `el ofuscador falló (${String(err.message).split('\n')[0]})` };
|
|
147
|
+
}
|
|
148
|
+
if (isModule) {
|
|
149
|
+
const err = checkSyntax(out, true);
|
|
150
|
+
if (err) return { code: null, reason: `resultado inválido (${err})` };
|
|
151
|
+
const a = moduleShape(body);
|
|
152
|
+
const b = moduleShape(out);
|
|
153
|
+
if (!a || !b || a.exports !== b.exports || a.imports !== b.imports)
|
|
154
|
+
return { code: null, reason: 'cambiaron las exportaciones o importaciones del módulo' };
|
|
155
|
+
} else {
|
|
156
|
+
const cmp = compareGlobals(globals, out, prefix);
|
|
157
|
+
if (!cmp.ok) return { code: null, reason: cmp.reason };
|
|
158
|
+
}
|
|
159
|
+
return { code: legal ? `${out}\n${legal}\n` : out };
|
|
160
|
+
}
|
package/src/optional.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Carga de dependencias opcionales (ofuscador, terser): solo se instalan si se usan.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { BuildError } from './util.js';
|
|
6
|
+
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Importa un paquete opcional buscándolo primero junto a Builder y después en el proyecto
|
|
11
|
+
* (útil si Builder está instalado de forma global).
|
|
12
|
+
*/
|
|
13
|
+
export async function importOptional(name, projectRoot, purpose) {
|
|
14
|
+
if (cache.has(name)) return cache.get(name);
|
|
15
|
+
let mod = null;
|
|
16
|
+
try {
|
|
17
|
+
mod = await import(name);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (!isNotFound(err, name)) throw err;
|
|
20
|
+
try {
|
|
21
|
+
const req = createRequire(path.join(projectRoot ?? process.cwd(), 'package.json'));
|
|
22
|
+
mod = await import(pathToFileURL(req.resolve(name)).href);
|
|
23
|
+
} catch (err2) {
|
|
24
|
+
if (!isNotFound(err2, name)) throw err2;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (!mod)
|
|
28
|
+
throw new BuildError(`Falta el paquete opcional "${name}" ${purpose}.`, [
|
|
29
|
+
`Instálalo una vez con: npm install --save-dev ${name}`,
|
|
30
|
+
'Builder lo carga solo cuando hace falta, por eso no viene incluido (así la instalación base es ligera).',
|
|
31
|
+
]);
|
|
32
|
+
cache.set(name, mod);
|
|
33
|
+
return mod;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isNotFound(err, name) {
|
|
37
|
+
return (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.code === 'MODULE_NOT_FOUND') && String(err.message).includes(name);
|
|
38
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Salida: se escribe primero en una carpeta temporal y solo se reemplaza dist/ si todo salió bien.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { BuildError, isInside, retryFs, copyDir } from './util.js';
|
|
6
|
+
|
|
7
|
+
export const MANIFEST = '.builder-manifest.json';
|
|
8
|
+
|
|
9
|
+
/** Comprueba que outDir sea un destino seguro antes de tocar nada. */
|
|
10
|
+
export function assertSafeOutDir(cfg, { force = false } = {}) {
|
|
11
|
+
const out = path.resolve(cfg.outDir);
|
|
12
|
+
const root = path.resolve(cfg.root);
|
|
13
|
+
const parsed = path.parse(out);
|
|
14
|
+
const same = (a, b) => path.relative(a, b) === '';
|
|
15
|
+
if (same(out, root)) throw new BuildError('"outDir" no puede ser la propia carpeta del proyecto: se borraría el código fuente.');
|
|
16
|
+
if (isInside(out, root)) throw new BuildError(`"outDir" (${out}) contiene al proyecto; se borraría el código fuente.`);
|
|
17
|
+
if (same(out, parsed.root)) throw new BuildError('"outDir" no puede ser la raíz de una unidad.');
|
|
18
|
+
if (same(out, os.homedir())) throw new BuildError('"outDir" no puede ser tu carpeta de usuario.');
|
|
19
|
+
if (!cfg.clean || force) return;
|
|
20
|
+
if (fs.existsSync(out)) {
|
|
21
|
+
if (!fs.statSync(out).isDirectory()) throw new BuildError(`"outDir" existe y no es una carpeta: ${out}`);
|
|
22
|
+
const entries = fs.readdirSync(out);
|
|
23
|
+
if (entries.length && !entries.includes(MANIFEST))
|
|
24
|
+
throw new BuildError(`La carpeta de salida ${out} ya tiene archivos y no la creó Builder. No la borro para no perder datos.`, [
|
|
25
|
+
'Elige otra carpeta con --out, vacíala tú mismo o usa --force si estás seguro.',
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Escribe todos los archivos en una carpeta temporal junto a outDir. */
|
|
31
|
+
export async function writeStaging(cfg, outputs) {
|
|
32
|
+
const out = path.resolve(cfg.outDir);
|
|
33
|
+
const staging = path.join(path.dirname(out), `.${path.basename(out)}.builder-tmp-${process.pid}-${Date.now().toString(36)}`);
|
|
34
|
+
await fs.promises.mkdir(staging, { recursive: true });
|
|
35
|
+
const dirs = new Set();
|
|
36
|
+
for (const rel of outputs.keys()) dirs.add(path.dirname(path.join(staging, rel)));
|
|
37
|
+
for (const d of [...dirs].sort()) await fs.promises.mkdir(d, { recursive: true });
|
|
38
|
+
const list = [...outputs.entries()];
|
|
39
|
+
const CONCURRENCY = 16;
|
|
40
|
+
for (let i = 0; i < list.length; i += CONCURRENCY) {
|
|
41
|
+
await Promise.all(
|
|
42
|
+
list.slice(i, i + CONCURRENCY).map(async ([rel, o]) => {
|
|
43
|
+
const dest = path.join(staging, rel);
|
|
44
|
+
if (o.copyFrom) await retryFs(() => fs.promises.copyFile(o.copyFrom, dest));
|
|
45
|
+
else await retryFs(() => fs.promises.writeFile(dest, o.data));
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return staging;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function writeManifest(dir, manifest) {
|
|
53
|
+
await fs.promises.writeFile(path.join(dir, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function rmrf(dir) {
|
|
57
|
+
await retryFs(() => fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sustituye outDir por la carpeta temporal. Si outDir no se puede renombrar (bloqueos de OneDrive/antivirus),
|
|
62
|
+
* se vacía su contenido y se copia el nuevo.
|
|
63
|
+
*/
|
|
64
|
+
export async function swapIntoPlace(cfg, staging) {
|
|
65
|
+
const out = path.resolve(cfg.outDir);
|
|
66
|
+
const old = path.join(path.dirname(out), `.${path.basename(out)}.builder-old-${process.pid}-${Date.now().toString(36)}`);
|
|
67
|
+
const exists = fs.existsSync(out);
|
|
68
|
+
if (exists && !cfg.clean) {
|
|
69
|
+
// Sin limpiar: se copian encima los archivos nuevos y se conserva lo demás.
|
|
70
|
+
await copyDir(staging, out);
|
|
71
|
+
await rmrf(staging);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (exists) {
|
|
75
|
+
try {
|
|
76
|
+
await retryFs(() => fs.promises.rename(out, old));
|
|
77
|
+
} catch {
|
|
78
|
+
for (const name of await fs.promises.readdir(out)) await rmrf(path.join(out, name));
|
|
79
|
+
await copyDir(staging, out);
|
|
80
|
+
await rmrf(staging);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await retryFs(() => fs.promises.rename(staging, out));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
try {
|
|
88
|
+
await copyDir(staging, out);
|
|
89
|
+
await rmrf(staging);
|
|
90
|
+
} catch (copyErr) {
|
|
91
|
+
if (exists) await retryFs(() => fs.promises.rename(old, out)).catch(() => {});
|
|
92
|
+
throw new BuildError(`No pude colocar la salida en ${out}: ${copyErr.message ?? err.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (exists) await rmrf(old).catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function discardStaging(staging) {
|
|
99
|
+
if (staging) await rmrf(staging).catch(() => {});
|
|
100
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Informe de tamaños (original, final, gzip y brotli) por archivo y totales.
|
|
2
|
+
import zlib from 'node:zlib';
|
|
3
|
+
import { formatBytes } from './util.js';
|
|
4
|
+
|
|
5
|
+
const COMPRESSIBLE = new Set(['html', 'css', 'js', 'json', 'svg', 'text']);
|
|
6
|
+
|
|
7
|
+
export function sizesOf(buf, type) {
|
|
8
|
+
const size = buf.length;
|
|
9
|
+
if (!COMPRESSIBLE.has(type) || size === 0) return { size, gzip: null, brotli: null };
|
|
10
|
+
return {
|
|
11
|
+
size,
|
|
12
|
+
gzip: zlib.gzipSync(buf, { level: 9 }).length,
|
|
13
|
+
brotli: zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Filas del informe a partir de las salidas. */
|
|
18
|
+
export function buildReport(outputs) {
|
|
19
|
+
const rows = [];
|
|
20
|
+
let compressed = 0;
|
|
21
|
+
for (const [rel, o] of outputs) {
|
|
22
|
+
if (o.type === 'compressed') {
|
|
23
|
+
compressed++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (o.copyFrom) {
|
|
27
|
+
rows.push({ file: rel, type: o.type, action: o.action, sources: o.sources, before: o.size, size: o.size, gzip: null, brotli: null });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const s = sizesOf(o.data, o.type);
|
|
31
|
+
rows.push({ file: rel, type: o.type, action: o.action, sources: o.sources, before: o.before ?? s.size, ...s });
|
|
32
|
+
}
|
|
33
|
+
rows.sort((a, b) => (a.type === b.type ? a.file.localeCompare(b.file) : order(a.type) - order(b.type)));
|
|
34
|
+
const totals = { before: 0, size: 0, gzip: 0, brotli: 0, files: rows.length };
|
|
35
|
+
const byType = {};
|
|
36
|
+
for (const r of rows) {
|
|
37
|
+
totals.before += r.before;
|
|
38
|
+
totals.size += r.size;
|
|
39
|
+
totals.gzip += r.gzip ?? r.size;
|
|
40
|
+
totals.brotli += r.brotli ?? r.size;
|
|
41
|
+
const t = (byType[r.type] ??= { files: 0, before: 0, size: 0 });
|
|
42
|
+
t.files++;
|
|
43
|
+
t.before += r.before;
|
|
44
|
+
t.size += r.size;
|
|
45
|
+
}
|
|
46
|
+
return { rows, totals, byType, compressed };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ORDER = ['html', 'js', 'css', 'json', 'svg', 'text', 'asset'];
|
|
50
|
+
const order = (t) => (ORDER.includes(t) ? ORDER.indexOf(t) : ORDER.length);
|
|
51
|
+
|
|
52
|
+
const pct = (before, after) => (before > 0 ? `${(((before - after) / before) * 100).toFixed(1)}%` : '—');
|
|
53
|
+
|
|
54
|
+
/** Tabla para la terminal. Los recursos copiados sin cambios se resumen en una línea. */
|
|
55
|
+
export function renderReport(report, C, { verbose = false } = {}) {
|
|
56
|
+
const lines = [];
|
|
57
|
+
const shown = report.rows.filter((r) => verbose || r.action !== 'copy');
|
|
58
|
+
const hidden = report.rows.filter((r) => !verbose && r.action === 'copy');
|
|
59
|
+
const hiddenLabel = `+ ${hidden.length} recursos copiados sin cambios`;
|
|
60
|
+
const w = Math.min(60, Math.max(20, ...shown.map((r) => r.file.length), hidden.length ? hiddenLabel.length : 0));
|
|
61
|
+
const pad = (s, n) => (s.length > n ? `…${s.slice(-(n - 1))}` : s.padEnd(n));
|
|
62
|
+
const num = (n) => (n == null ? '—' : formatBytes(n)).padStart(10);
|
|
63
|
+
lines.push(
|
|
64
|
+
C.gray(`${'Archivo'.padEnd(w)} ${'Antes'.padStart(10)} ${'Después'.padStart(10)} ${'gzip'.padStart(10)} ${'brotli'.padStart(10)} Ahorro`),
|
|
65
|
+
);
|
|
66
|
+
for (const r of shown) {
|
|
67
|
+
const src = r.sources && r.sources.length > 1 ? C.gray(` (${r.sources.length} archivos)`) : '';
|
|
68
|
+
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
|
+
if (hidden.length) {
|
|
71
|
+
const size = hidden.reduce((a, r) => a + r.size, 0);
|
|
72
|
+
lines.push(C.gray(`${pad(hiddenLabel, w)} ${num(size)} ${num(size)}`));
|
|
73
|
+
}
|
|
74
|
+
const t = report.totals;
|
|
75
|
+
lines.push(
|
|
76
|
+
C.bold(`${'Total'.padEnd(w)} ${num(t.before)} ${num(t.size)} ${num(t.gzip)} ${num(t.brotli)} ${pct(t.before, t.size).padStart(6)}`),
|
|
77
|
+
);
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|