diff --git a/node.d.ts b/node.d.ts index 1a59335..040579f 100644 --- a/node.d.ts +++ b/node.d.ts @@ -3,3 +3,21 @@ declare module '*?nodeWorker' { import { Worker, WorkerOptions } from 'node:worker_threads' export default function (options: WorkerOptions): Worker } + +// node asset +declare module '*?asset' { + const src: string + export default src +} + +declare module '*?asset&asarUnpack' { + const src: string + export default src +} + +// native node module +declare module '*.node' { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const node: any + export default node +} diff --git a/src/config.ts b/src/config.ts index e1040e9..0456789 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ import { import { build } from 'esbuild' import { electronMainVitePlugin, electronPreloadVitePlugin, electronRendererVitePlugin } from './plugins/electron' +import assetPlugin from './plugins/asset' import workerPlugin from './plugins/worker' import { isObject, dynamicImport } from './utils' @@ -130,7 +131,7 @@ export async function resolveConfig( resetOutDir(mainViteConfig, outDir, 'main') } - mergePlugins(mainViteConfig, [...electronMainVitePlugin({ root }), workerPlugin()]) + mergePlugins(mainViteConfig, [...electronMainVitePlugin({ root }), assetPlugin(), workerPlugin()]) loadResult.config.main = mainViteConfig loadResult.config.main.configFile = false @@ -142,7 +143,7 @@ export async function resolveConfig( if (outDir) { resetOutDir(preloadViteConfig, outDir, 'preload') } - mergePlugins(preloadViteConfig, electronPreloadVitePlugin({ root })) + mergePlugins(preloadViteConfig, [...electronPreloadVitePlugin({ root }), assetPlugin()]) loadResult.config.preload = preloadViteConfig loadResult.config.preload.configFile = false diff --git a/src/plugins/asset.ts b/src/plugins/asset.ts new file mode 100644 index 0000000..3297aec --- /dev/null +++ b/src/plugins/asset.ts @@ -0,0 +1,143 @@ +import path from 'node:path' +import fs from 'node:fs/promises' +import type { SourceMapInput } from 'rollup' +import { type Plugin, normalizePath } from 'vite' +import MagicString from 'magic-string' +import { cleanUrl, parseRequest, getHash, toRelativePath } from '../utils' + +interface AssetResolved { + type: 'asset' | 'native' + file: string + query: Record | null +} + +function resolveAsset(id: string): AssetResolved | null { + const file = cleanUrl(id) + const query = parseRequest(id) + + if (query && typeof query.asset === 'string') { + return { + type: 'asset', + file, + query + } + } + + if (file.endsWith('.node')) { + return { + type: 'native', + file, + query + } + } + + return null +} + +const nodeAssetRE = /__VITE_NODE_ASSET__([a-z\d]{8})__/g +const nodePublicAssetRE = /__VITE_NODE_PUBLIC_ASSET__([a-z\d]{8})__/g + +export default function assetPlugin(): Plugin { + let sourcemap: boolean | 'inline' | 'hidden' = false + let publicDir = '' + let outDir = '' + const publicAssetPathCache = new Map() + const assetCache = new Map() + return { + name: 'vite:node-asset', + apply: 'build', + enforce: 'pre', + configResolved(config): void { + sourcemap = config.build.sourcemap + publicDir = normalizePath(config.publicDir) + outDir = normalizePath(config.build.outDir) + }, + async load(id): Promise { + const assetResolved = resolveAsset(id) + if (!assetResolved) { + return + } + + let referenceId: string + const file = assetResolved.file + if (publicDir && file.startsWith(publicDir)) { + const hash = getHash(file) + if (!publicAssetPathCache.get(hash)) { + publicAssetPathCache.set(hash, file) + } + referenceId = `__VITE_NODE_PUBLIC_ASSET__${hash}__` + } else { + const cached = assetCache.get(file) + if (cached) { + referenceId = cached + } else { + const source = await fs.readFile(file) + const hash = this.emitFile({ + type: 'asset', + name: path.basename(file), + source + }) + referenceId = `__VITE_NODE_ASSET__${hash}__` + assetCache.set(file, referenceId) + } + } + + if (assetResolved.type === 'asset') { + if (assetResolved.query && typeof assetResolved.query.asarUnpack === 'string') { + return ` + import { join } from 'path' + export default join(__dirname, ${referenceId}).replace('app.asar', 'app.asar.unpacked')` + } else { + return ` + import { join } from 'path' + export default join(__dirname, ${referenceId})` + } + } + + if (assetResolved.type === 'native') { + return `export default require(${referenceId})` + } + }, + renderChunk(code, chunk): { code: string; map: SourceMapInput } | null { + let match: RegExpExecArray | null + let s: MagicString | undefined + + nodeAssetRE.lastIndex = 0 + if (code.match(nodeAssetRE)) { + while ((match = nodeAssetRE.exec(code))) { + s ||= new MagicString(code) + const [full, hash] = match + const filename = this.getFileName(hash) + const outputFilepath = toRelativePath(filename, chunk.fileName) + const replacement = JSON.stringify(outputFilepath) + s.overwrite(match.index, match.index + full.length, replacement, { + contentOnly: true + }) + } + } + + nodePublicAssetRE.lastIndex = 0 + if (code.match(nodePublicAssetRE)) { + while ((match = nodePublicAssetRE.exec(code))) { + s ||= new MagicString(code) + const [full, hash] = match + const filename = publicAssetPathCache.get(hash)! + const outputFilepath = toRelativePath(filename, normalizePath(path.join(outDir, chunk.fileName))) + const replacement = JSON.stringify(outputFilepath) + s.overwrite(match.index, match.index + full.length, replacement, { + contentOnly: true + }) + } + } + + if (s) { + return { + code: s.toString(), + map: sourcemap ? s.generateMap({ hires: true }) : null + } + } else { + return null + } + } + } +} diff --git a/src/plugins/electron.ts b/src/plugins/electron.ts index 0d5299e..ecc30ee 100644 --- a/src/plugins/electron.ts +++ b/src/plugins/electron.ts @@ -75,6 +75,11 @@ export function electronMainVitePlugin(options?: ElectronPluginOptions): Plugin[ defaultConfig.build.rollupOptions.output['format'] = 'cjs' } + defaultConfig.build.rollupOptions.output['assetFileNames'] = path.posix.join( + build.assetsDir || defaultConfig.build.assetsDir, + '[name]-[hash].[ext]' + ) + const buildConfig = mergeConfig(defaultConfig.build, build) config.build = buildConfig @@ -82,6 +87,10 @@ export function electronMainVitePlugin(options?: ElectronPluginOptions): Plugin[ config.define = { ...processEnvDefine(), ...config.define } config.envPrefix = config.envPrefix || 'MAIN_VITE_' + + config.publicDir = config.publicDir || 'resources' + // do not copy public dir + config.build.copyPublicDir = false } }, { @@ -166,6 +175,11 @@ export function electronPreloadVitePlugin(options?: ElectronPluginOptions): Plug defaultConfig.build.rollupOptions.output['format'] = 'cjs' } + defaultConfig.build.rollupOptions.output['assetFileNames'] = path.posix.join( + build.assetsDir || defaultConfig.build.assetsDir, + '[name]-[hash].[ext]' + ) + const buildConfig = mergeConfig(defaultConfig.build, build) config.build = buildConfig @@ -173,6 +187,10 @@ export function electronPreloadVitePlugin(options?: ElectronPluginOptions): Plug config.define = { ...processEnvDefine(), ...config.define } config.envPrefix = config.envPrefix || 'PRELOAD_VITE_' + + config.publicDir = config.publicDir || 'resources' + // do not copy public dir + config.build.copyPublicDir = false } }, { diff --git a/src/utils.ts b/src/utils.ts index 41fafc5..2e4d7fd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,6 @@ import { URL, URLSearchParams } from 'node:url' +import path from 'node:path' +import { createHash } from 'node:crypto' import { loadEnv as viteLoadEnv } from 'vite' export function isObject(value: unknown): value is Record { @@ -26,6 +28,15 @@ export function parseRequest(id: string): Record | null { return Object.fromEntries(new URLSearchParams(search)) } +export function getHash(text: Buffer | string): string { + return createHash('sha256').update(text).digest('hex').substring(0, 8) +} + +export function toRelativePath(filename: string, importer: string): string { + const relPath = path.posix.relative(path.dirname(importer), filename) + return relPath.startsWith('.') ? relPath : `./${relPath}` +} + /** * Load `.env` files within the `envDir`(default: `process.cwd()`). * By default, only env variables prefixed with `MAIN_VITE_`, `PRELOAD_VITE_` and