Compare commits

...

6 Commits

8 changed files with 955 additions and 873 deletions

View File

@ -1,11 +1,11 @@
// ts-check
import { defineConfig } from 'eslint/config'
import eslint from '@eslint/js'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
export default defineConfig(
{ ignores: ['**/node_modules', '**/dist', '**/bin'] },
eslint.configs.recommended,
tseslint.configs.recommended,
@ -29,6 +29,7 @@ export default tseslint.config(
},
rules: {
'prettier/prettier': 'warn',
'no-empty': ['warn', { allowEmptyCatch: true }],
'@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': 'allow-with-description' }],
'@typescript-eslint/explicit-function-return-type': [
'error',
@ -52,6 +53,10 @@ export default tseslint.config(
allowTaggedTemplates: true,
allowTernary: true
}
],
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', disallowTypeAnnotations: false }
]
}
},

2
node.d.ts vendored
View File

@ -1,6 +1,6 @@
// node worker
declare module '*?nodeWorker' {
import { Worker, WorkerOptions } from 'node:worker_threads'
import type { Worker, WorkerOptions } from 'node:worker_threads'
export default function (options: WorkerOptions): Worker
}

View File

@ -69,33 +69,34 @@
}
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@eslint/js": "^9.37.0",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.3",
"@swc/core": "^1.12.7",
"@types/node": "^22.15.33",
"eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.1",
"globals": "^16.2.0",
"lint-staged": "^16.1.2",
"@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",
"eslint-plugin-prettier": "^5.5.4",
"globals": "^16.4.0",
"lint-staged": "^16.2.4",
"prettier": "^3.6.2",
"rollup": "^4.44.1",
"rollup-plugin-dts": "^6.2.1",
"rollup": "^4.52.4",
"rollup-plugin-dts": "^6.2.3",
"rollup-plugin-rm": "^1.0.2",
"simple-git-hooks": "^2.13.0",
"simple-git-hooks": "^2.13.1",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"typescript-eslint": "^8.35.0",
"vite": "^7.0.0"
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"vite": "^7.1.10"
},
"dependencies": {
"@babel/core": "^7.27.7",
"@babel/core": "^7.28.4",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"cac": "^6.7.14",
"esbuild": "^0.25.5",
"magic-string": "^0.30.17",
"esbuild": "^0.25.11",
"magic-string": "^0.30.19",
"picocolors": "^1.1.1"
},
"pnpm": {

1450
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@ import rm from 'rollup-plugin-rm'
const require = createRequire(import.meta.url)
const pkg = require('./package.json')
const external = ['esbuild', ...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})]
const external = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})]
export default defineConfig([
{

View File

@ -150,7 +150,14 @@ export async function resolveConfig(
...electronMainVitePlugin({ root }),
assetPlugin(),
workerPlugin(),
modulePathPlugin(),
modulePathPlugin(
mergeConfig(
{
plugins: [electronMainVitePlugin({ root })[0], assetPlugin(), importMetaPlugin(), esmShimPlugin()]
},
mainViteConfig
)
),
importMetaPlugin(),
esmShimPlugin()
])
@ -417,7 +424,6 @@ async function loadConfigFormBundledFile(
} finally {
try {
fs.unlinkSync(fileNameTmp)
// eslint-disable-next-line no-empty
} catch {}
}
} else {

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)
}
}
}
}

View File

@ -1,5 +1,6 @@
import type { Plugin } from 'vite'
import type { SourceMapInput } from 'rollup'
import path from 'node:path'
import { type Plugin, type InlineConfig, build as viteBuild, mergeConfig } from 'vite'
import type { SourceMapInput, RollupOutput, OutputOptions } from 'rollup'
import MagicString from 'magic-string'
import { cleanUrl, parseRequest, toRelativePath } from '../utils'
@ -8,7 +9,7 @@ const modulePathRE = /__VITE_MODULE_PATH__([\w$]+)__/g
/**
* Resolve `?modulePath` import and return the module bundle path.
*/
export default function modulePathPlugin(): Plugin {
export default function modulePathPlugin(config: InlineConfig): Plugin {
let sourcemap: boolean | 'inline' | 'hidden' = false
return {
name: 'vite:module-path',
@ -23,14 +24,23 @@ export default function modulePathPlugin(): Plugin {
return id + `&importer=${importer}`
}
},
load(id): string | void {
async load(id): Promise<string | void> {
const query = parseRequest(id)
if (query && typeof query.modulePath === 'string' && typeof query.importer === 'string') {
const cleanPath = cleanUrl(id)
const entry = path.resolve(path.dirname(query.importer), cleanUrl(id))
const bundle = await bundleEntryFile(entry, config)
const [outputChunk, ...outputChunks] = bundle.output
const hash = this.emitFile({
type: 'chunk',
id: cleanPath,
importer: query.importer
type: 'asset',
fileName: outputChunk.fileName,
source: outputChunk.code
})
outputChunks.forEach(chunk => {
this.emitFile({
type: 'asset',
fileName: chunk.fileName,
source: chunk.type === 'chunk' ? chunk.code : chunk.source
})
})
const refId = `__VITE_MODULE_PATH__${hash}__`
return `
@ -63,3 +73,28 @@ export default function modulePathPlugin(): Plugin {
}
}
}
async function bundleEntryFile(input: string, config: InlineConfig): Promise<RollupOutput> {
const viteConfig = mergeConfig(config, {
build: {
rollupOptions: { input },
write: false,
watch: false
},
plugins: [
{
name: 'vite:entry-file-name',
outputOptions(output): OutputOptions {
if (typeof output.entryFileNames !== 'function' && output.entryFileNames) {
output.entryFileNames = '[name]-[hash]' + path.extname(output.entryFileNames)
}
return output
}
}
],
logLevel: 'warn',
configFile: false
})
const bundles = await viteBuild(viteConfig)
return bundles as RollupOutput
}