de63d0be5b
Frontend CI / Frontend (push) Successful in 4m56s
Adds a post-build obfuscation step using javascript-obfuscator with hidden vite sourcemaps chained back to TypeScript via @ampproject/remapping, so production packages ship mangled code while debug-time maps still resolve to original sources. Wires obfuscation into all package:* scripts via a new build:obf step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
206 lines
6.3 KiB
JavaScript
206 lines
6.3 KiB
JavaScript
#!/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()
|