#!/usr/bin/env node /* eslint-disable @typescript-eslint/no-require-imports */ 'use strict' const fs = require('node:fs') const path = require('node:path') const JavaScriptObfuscator = require('javascript-obfuscator') const remapping = require('@ampproject/remapping') const projectRoot = path.resolve(__dirname, '..') const outDir = path.join(projectRoot, 'out') const baseOptions = { compact: true, controlFlowFlattening: false, deadCodeInjection: false, debugProtection: false, disableConsoleOutput: false, identifierNamesGenerator: 'mangled-shuffled', log: false, numbersToExpressions: true, renameGlobals: false, rotateStringArray: true, selfDefending: false, shuffleStringArray: true, simplify: true, splitStrings: true, splitStringsChunkLength: 10, stringArray: true, stringArrayCallsTransform: true, stringArrayCallsTransformThreshold: 0.5, stringArrayEncoding: ['base64'], stringArrayIndexShift: true, stringArrayRotate: true, stringArrayShuffle: true, stringArrayThreshold: 0.75, transformObjectKeys: false, unicodeEscapeSequence: false, sourceMap: true, sourceMapMode: 'separate', sourceMapSourcesMode: 'sources-content', } const targets = [ { label: 'main', dir: path.join(outDir, 'main'), match: (name) => name.endsWith('.cjs'), options: { ...baseOptions, target: 'node' }, }, { label: 'renderer', dir: path.join(outDir, 'renderer', 'assets'), match: (name) => name.endsWith('.js'), options: { ...baseOptions, target: 'browser' }, }, ] function walk(dir) { const result = [] if (!fs.existsSync(dir)) return result const stack = [dir] while (stack.length > 0) { const current = stack.pop() for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const full = path.join(current, entry.name) if (entry.isDirectory()) { stack.push(full) } else if (entry.isFile()) { result.push(full) } } } return result } function formatBytes(n) { if (n < 1024) return `${n}B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB` return `${(n / 1024 / 1024).toFixed(2)}MB` } function obfuscateFile(file, options) { const original = fs.readFileSync(file, 'utf8') const beforeBytes = Buffer.byteLength(original, 'utf8') const inputFileName = path.basename(file) const sourceMapFileName = `${inputFileName}.map` const viteMapPath = `${file}.map` // vite 以 sourcemap: 'hidden' 输出时会把 .map 写在文件旁边,但不在 bundle 末尾加注释。 // 在混淆前把这份 map 读出来,混淆后用 remapping chain 出 obfuscated → ts。 let viteMap = null if (fs.existsSync(viteMapPath)) { try { viteMap = JSON.parse(fs.readFileSync(viteMapPath, 'utf8')) } catch { viteMap = null } } const result = JavaScriptObfuscator.obfuscate(original, { ...options, inputFileName, sourceMapFileName, }) let code = result.getObfuscatedCode() // javascript-obfuscator 对 sourceMapFileName 做扩展名规整(.cjs → .js), // 会让注释指向 bootstrap.js.map 而真实文件是 bootstrap.cjs.map — 重写注释修正。 code = code.replace( /\/\/# sourceMappingURL=[^\n]*$/, `//# sourceMappingURL=${sourceMapFileName}`, ) fs.writeFileSync(file, code, 'utf8') const afterBytes = Buffer.byteLength(code, 'utf8') let mapBytes = 0 let chained = false if (options.sourceMap) { let mapJson = result.getSourceMap() if (mapJson) { if (viteMap) { try { const obfMap = JSON.parse(mapJson) // remapping 会递归地为 sources 数组里的每一项调 loader。 // 我们只想为 obfMap 自己的 sources 返回 viteMap(chain 一层), // 对 viteMap 内部的 ts 路径返回 null,否则 loader 会无限递归 → stack overflow。 const obfSources = new Set(obfMap.sources || []) const chainedMap = remapping(obfMap, (file) => obfSources.has(file) ? viteMap : null, ) mapJson = chainedMap.toString() chained = true } catch (err) { console.warn( ` ! ${path.relative(projectRoot, file)} chain failed, keeping obfuscator-only map: ${err.message}`, ) } } fs.writeFileSync(viteMapPath, mapJson, 'utf8') mapBytes = Buffer.byteLength(mapJson, 'utf8') } } return { beforeBytes, afterBytes, mapBytes, chained } } function main() { if (!fs.existsSync(outDir)) { console.error(`[obfuscate] missing build output: ${outDir} — run \`pnpm build\` first.`) process.exit(1) } let totalFiles = 0 let totalBefore = 0 let totalAfter = 0 let totalMap = 0 let totalChained = 0 let failed = 0 for (const target of targets) { if (!fs.existsSync(target.dir)) { console.warn(`[obfuscate] skip ${target.label}: directory not found (${target.dir})`) continue } const files = walk(target.dir).filter((f) => target.match(path.basename(f))) if (files.length === 0) { console.warn(`[obfuscate] skip ${target.label}: no matching files in ${target.dir}`) continue } console.log(`[obfuscate] ${target.label}: ${files.length} file(s)`) for (const file of files) { const rel = path.relative(projectRoot, file) try { const { beforeBytes, afterBytes, mapBytes, chained } = obfuscateFile(file, target.options) totalFiles += 1 totalBefore += beforeBytes totalAfter += afterBytes totalMap += mapBytes if (chained) totalChained += 1 const ratio = ((afterBytes / beforeBytes) * 100).toFixed(0) const mapNote = mapBytes > 0 ? ` +map ${formatBytes(mapBytes)}${chained ? ' (chained → ts)' : ''}` : '' console.log( ` ✓ ${rel} ${formatBytes(beforeBytes)} → ${formatBytes(afterBytes)} (${ratio}%)${mapNote}`, ) } catch (err) { failed += 1 console.error(` ✗ ${rel} ${err && err.message ? err.message : err}`) } } } const mapSummary = totalMap > 0 ? `, sourcemaps ${formatBytes(totalMap)} (${totalChained}/${totalFiles} chained → ts, excluded from package)` : '' console.log( `[obfuscate] done: ${totalFiles} file(s), ${formatBytes(totalBefore)} → ${formatBytes(totalAfter)}${mapSummary}` + (failed > 0 ? `, ${failed} failed` : ''), ) if (failed > 0) { process.exit(1) } } main()