refactor(bytecodePlugin): improved bytecode bundle generation and made a new string protection plugin

This commit is contained in:
alex8088 2025-10-18 16:33:49 +08:00
parent 4a6aea3704
commit 327adc23df
3 changed files with 172 additions and 130 deletions

View File

@ -74,6 +74,7 @@
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-typescript": "^12.1.4",
"@swc/core": "^1.13.5",
"@types/babel__core": "^7.20.5",
"@types/node": "^22.18.11",
"eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8",

36
pnpm-lock.yaml generated
View File

@ -42,6 +42,9 @@ importers:
'@swc/core':
specifier: ^1.13.5
version: 1.13.5
'@types/babel__core':
specifier: ^7.20.5
version: 7.20.5
'@types/node':
specifier: ^22.18.11
version: 22.18.11
@ -634,6 +637,18 @@ packages:
'@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
'@types/babel__generator@7.27.0':
resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
'@types/babel__template@7.4.4':
resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -1855,6 +1870,27 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
'@babel/types': 7.28.4
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@types/babel__traverse@7.28.0':
dependencies:
'@babel/types': 7.28.4
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}

View File

@ -1,12 +1,11 @@
import path from 'node:path'
import fs from 'node:fs'
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import colors from 'picocolors'
import { type Plugin, type ResolvedConfig, normalizePath, createFilter } from 'vite'
import { type Plugin, type Logger, type LibraryOptions, normalizePath } from 'vite'
import * as babel from '@babel/core'
import MagicString from 'magic-string'
import type { SourceMapInput, OutputChunk } from 'rollup'
import type { SourceMapInput, OutputChunk, OutputOptions } from 'rollup'
import { getElectronPath } from '../electron'
import { toRelativePath } from '../utils'
@ -159,137 +158,100 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
const { chunkAlias = [], transformArrowFunctions = true, removeBundleJS = true, protectedStrings = [] } = options
const _chunkAlias = Array.isArray(chunkAlias) ? chunkAlias : [chunkAlias]
const filter = createFilter(/\.(m?[jt]s|[jt]sx)$/)
const escapeRegExpString = (str: string): string => {
return str
.replace(/\\/g, '\\\\\\\\')
.replace(/[|{}()[\]^$+*?.]/g, '\\$&')
.replace(/-/g, '\\u002d')
}
const transformAllChunks = _chunkAlias.length === 0
const isBytecodeChunk = (chunkName: string): boolean => {
return transformAllChunks || _chunkAlias.some(alias => alias === chunkName)
}
const _transform = (code: string): string => {
const re = babel.transform(code, {
plugins: ['@babel/plugin-transform-arrow-functions']
})
return re.code || ''
const plugins: babel.PluginItem[] = []
if (transformArrowFunctions) {
plugins.push('@babel/plugin-transform-arrow-functions')
}
if (protectedStrings.length > 0) {
plugins.push([protectStringsPlugin, { protectedStrings: new Set(protectedStrings) }])
}
const shouldTransformBytecodeChunk = plugins.length !== 0
const _transform = (code: string, sourceMaps: boolean = false): { code: string; map?: SourceMapInput } | null => {
const re = babel.transform(code, { plugins, sourceMaps })
return re ? { code: re.code || '', map: re.map } : null
}
let bytecodeChunkCount = 0
const useStrict = '"use strict";'
const bytecodeModuleLoader = 'bytecode-loader.cjs'
let config: ResolvedConfig
let useInRenderer = false
let bytecodeRequired = false
let bytecodeFiles: { name: string; size: number }[] = []
let logger: Logger
let sourcemap: boolean | 'inline' | 'hidden' = false
let supported = false
return {
name: 'vite:bytecode',
apply: 'build',
enforce: 'post',
configResolved(resolvedConfig): void {
config = resolvedConfig
useInRenderer = config.plugins.some(p => p.name === 'vite:electron-renderer-preset-config')
configResolved(config): void {
if (supported) {
return
}
logger = config.logger
sourcemap = config.build.sourcemap
const useInRenderer = config.plugins.some(p => p.name === 'vite:electron-renderer-preset-config')
if (useInRenderer) {
config.logger.warn(colors.yellow('bytecodePlugin does not support renderer.'))
return
}
if (resolvedConfig.build.minify && protectedStrings.length > 0) {
const build = config.build
const resolvedOutputs = resolveBuildOutputs(build.rollupOptions.output, build.lib)
if (resolvedOutputs) {
const outputs = Array.isArray(resolvedOutputs) ? resolvedOutputs : [resolvedOutputs]
const output = outputs[0]
if (output.format === 'es') {
config.logger.warn(
colors.yellow(
'bytecodePlugin does not support ES module, please remove "type": "module" ' +
'in package.json or set the "build.rollupOptions.output.format" option to "cjs".'
)
)
}
supported = output.format === 'cjs' && !useInRenderer
}
if (supported && config.build.minify && protectedStrings.length > 0) {
config.logger.warn(colors.yellow('Strings cannot be protected when minification is enabled.'))
}
},
transform(code, id): void | { code: string; map: SourceMapInput } {
if (config.build.minify || protectedStrings.length === 0 || !filter(id)) return
let match: RegExpExecArray | null
let s: MagicString | undefined
protectedStrings.forEach(str => {
const escapedStr = escapeRegExpString(str)
const re = new RegExp(`\\u0027${escapedStr}\\u0027|\\u0022${escapedStr}\\u0022`, 'g')
const charCodes = Array.from(str).map(s => s.charCodeAt(0))
const replacement = `String.fromCharCode(${charCodes.toString()})`
while ((match = re.exec(code))) {
s ||= new MagicString(code)
const [full] = match
s.overwrite(match.index, match.index + full.length, replacement, {
contentOnly: true
})
}
})
if (s) {
return {
code: s.toString(),
map: config.build.sourcemap ? s.generateMap({ hires: 'boundary' }) : null
}
}
},
renderChunk(code, chunk, options): { code: string } | null {
if (options.format === 'es') {
config.logger.warn(
colors.yellow(
'bytecodePlugin does not support ES module, please remove "type": "module" ' +
'in package.json or set the "build.rollupOptions.output.format" option to "cjs".'
)
)
return null
}
if (useInRenderer) {
return null
}
if (chunk.type === 'chunk' && isBytecodeChunk(chunk.name)) {
bytecodeRequired = true
if (transformArrowFunctions) {
return {
code: _transform(code)
}
}
renderChunk(code, chunk): { code: string; map?: SourceMapInput } | null {
if (supported && isBytecodeChunk(chunk.name) && shouldTransformBytecodeChunk) {
return _transform(code, !!sourcemap)
}
return null
},
generateBundle(options): void {
if (options.format !== 'es' && !useInRenderer && bytecodeRequired) {
this.emitFile({
type: 'asset',
source: bytecodeModuleLoaderCode.join('\n') + '\n',
name: 'Bytecode Loader File',
fileName: bytecodeModuleLoader
})
async generateBundle(_, output): Promise<void> {
if (!supported) {
return
}
},
async writeBundle(options, output): Promise<void> {
if (options.format === 'es' || useInRenderer || !bytecodeRequired) {
const _chunks = Object.values(output)
const chunks = _chunks.filter(chunk => chunk.type === 'chunk' && isBytecodeChunk(chunk.name)) as OutputChunk[]
if (chunks.length === 0) {
return
}
const outDir = options.dir!
bytecodeFiles = []
const bundles = Object.keys(output)
const chunks = Object.values(output).filter(
chunk => chunk.type === 'chunk' && isBytecodeChunk(chunk.name) && chunk.fileName !== bytecodeModuleLoader
) as OutputChunk[]
const bytecodeChunks = chunks.map(chunk => chunk.fileName)
const nonEntryChunks = chunks.filter(chunk => !chunk.isEntry).map(chunk => path.basename(chunk.fileName))
const pattern = nonEntryChunks.map(chunk => `(${chunk})`).join('|')
const bytecodeRE = pattern ? new RegExp(`require\\(\\S*(?=(${pattern})\\S*\\))`, 'g') : null
const keepBundle = (chunkFileName: string): void => {
const newFileName = path.resolve(path.dirname(chunkFileName), `_${path.basename(chunkFileName)}`)
fs.renameSync(chunkFileName, newFileName)
}
const getBytecodeLoaderBlock = (chunkFileName: string): string => {
return `require("${toRelativePath(bytecodeModuleLoader, normalizePath(chunkFileName))}");`
}
const bundles = Object.keys(output)
await Promise.all(
bundles.map(async name => {
const chunk = output[name]
@ -307,26 +269,29 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
}
_code = s.toString()
}
const chunkFileName = path.resolve(outDir, name)
if (bytecodeChunks.includes(name)) {
const bytecodeBuffer = await compileToBytecode(_code)
fs.writeFileSync(path.resolve(outDir, name + 'c'), bytecodeBuffer as unknown as Uint8Array)
this.emitFile({
type: 'asset',
fileName: name + 'c',
source: bytecodeBuffer
})
if (!removeBundleJS) {
this.emitFile({
type: 'asset',
fileName: '_' + chunk.fileName,
source: chunk.code
})
}
if (chunk.isEntry) {
if (!removeBundleJS) {
keepBundle(chunkFileName)
}
const bytecodeLoaderBlock = getBytecodeLoaderBlock(chunk.fileName)
const bytecodeModuleBlock = `require("./${path.basename(name) + 'c'}");`
const code = `${useStrict}\n${bytecodeLoaderBlock}\n${bytecodeModuleBlock}\n`
fs.writeFileSync(chunkFileName, code)
chunk.code = code
} else {
if (removeBundleJS) {
fs.unlinkSync(chunkFileName)
} else {
keepBundle(chunkFileName)
}
delete output[chunk.fileName]
}
bytecodeFiles.push({ name: name + 'c', size: bytecodeBuffer.length })
bytecodeChunkCount += 1
} else {
if (chunk.isEntry) {
let hasBytecodeMoudle = false
@ -343,36 +308,76 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
for (const importerId of dynamicImporters) idsToHandle.add(importerId)
}
}
const bytecodeLoaderBlock = getBytecodeLoaderBlock(chunk.fileName)
_code = hasBytecodeMoudle
? _code.replace(/("use strict";)|('use strict';)/, `${useStrict}\n${bytecodeLoaderBlock}`)
? _code.replace(
/("use strict";)|('use strict';)/,
`${useStrict}\n${getBytecodeLoaderBlock(chunk.fileName)}`
)
: _code
}
fs.writeFileSync(chunkFileName, _code)
chunk.code = _code
}
}
})
)
if (bytecodeChunkCount && !_chunks.some(ass => ass.type === 'asset' && ass.fileName === bytecodeModuleLoader)) {
this.emitFile({
type: 'asset',
source: bytecodeModuleLoaderCode.join('\n') + '\n',
name: 'Bytecode Loader File',
fileName: bytecodeModuleLoader
})
}
},
closeBundle(): void {
if (!useInRenderer) {
const chunkLimit = config.build.chunkSizeWarningLimit
const outDir = normalizePath(path.relative(config.root, path.resolve(config.root, config.build.outDir))) + '/'
config.logger.info(`${colors.green(``)} ${bytecodeFiles.length} bundles compiled into bytecode.`)
let longest = 0
bytecodeFiles.forEach(file => {
const len = file.name.length
if (len > longest) longest = len
})
bytecodeFiles.forEach(file => {
const kbs = file.size / 1000
config.logger.info(
`${colors.gray(colors.white(colors.dim(outDir)))}${colors.green(file.name.padEnd(longest + 2))} ${
kbs > chunkLimit ? colors.yellow(`${kbs.toFixed(2)} kB`) : colors.dim(`${kbs.toFixed(2)} kB`)
}`
)
})
bytecodeFiles = []
writeBundle(): void {
if (supported) {
logger.info(`${colors.green(``)} ${bytecodeChunkCount} chunks compiled into bytecode.`)
}
}
}
}
function resolveBuildOutputs(
outputs: OutputOptions | OutputOptions[] | undefined,
libOptions: LibraryOptions | false
): OutputOptions | OutputOptions[] | undefined {
if (libOptions && !Array.isArray(outputs)) {
const libFormats = libOptions.formats || []
return libFormats.map(format => ({ ...outputs, format }))
}
return outputs
}
interface ProtectStringsPluginState extends babel.PluginPass {
opts: { protectedStrings: Set<string> }
}
function protectStringsPlugin(api: typeof babel & babel.ConfigAPI): babel.PluginObj<ProtectStringsPluginState> {
const { types: t } = api
return {
name: 'protect-strings-plugin',
visitor: {
StringLiteral(path, state) {
if (
path.parentPath.isImportDeclaration() || // import x from 'module'
path.parentPath.isExportNamedDeclaration() || // export { x } from 'module'
path.parentPath.isExportAllDeclaration() || // export * from 'module'
path.parentPath.isObjectProperty({ key: path.node, computed: false }) // { 'key': 'value' }
) {
return
}
const { value } = path.node
if (state.opts.protectedStrings.has(value)) {
const charCodes = Array.from(value).map(s => s.charCodeAt(0))
const charCodeLiterals = charCodes.map(code => t.numericLiteral(code))
const replacement = t.callExpression(
t.memberExpression(t.identifier('String'), t.identifier('fromCharCode')),
charCodeLiterals
)
path.replaceWith(replacement)
}
}
}
}