import MagicString from 'magic-string'; import { URL, URLSearchParams } from 'node:url'; import path from 'node:path'; import fs from 'node:fs/promises'; import { createHash } from 'node:crypto'; import type { Plugin, InlineConfig, Rolldown } from 'vite'; import { build as viteBuild, mergeConfig } from 'vite'; function toRelativePath(filename: string, importer: string): string { const relPath = path.posix.relative(path.dirname(importer), filename); return relPath.startsWith('.') ? relPath : `./${relPath}`; } function parseRequest(id: string): Record | null { try { const { search } = new URL(id, 'file:'); if (!search) { return null; } return Object.fromEntries(new URLSearchParams(search)); } catch { return null; } } function getHash(text: string): string { return createHash('sha256').update(text).digest('hex').substring(0, 8); } const queryRE = /\?.*$/s; const hashRE = /#.*$/s; const cleanUrl = (url: string): string => url.replace(hashRE, '').replace(queryRE, ''); const modulePathRE = /__VITE_MODULE_PATH__([\w$]+)__/g; // ESM shim for __dirname const CJSShim = ` import __cjs_url__ from 'node:url'; import __cjs_path__ from 'node:path'; const __filename = __cjs_url__.fileURLToPath(import.meta.url); const __dirname = __cjs_path__.dirname(__filename); `; interface ModulePathPluginOptions { /** * Vite configuration to use for bundling modules */ bundleConfig?: InlineConfig; } interface EmittedChunk { fileName: string; code: string; referenceId: string; } /** * Resolve `?modulePath` import and return the module bundle path. */ export default function modulePathPlugin( options: ModulePathPluginOptions = {} ): Plugin { let sourcemap = false; let viteConfig: InlineConfig; let rootDir: string; let outDir: string; let isDev = false; const assetCache = new Map(); // original path -> hashed fileName const chunksToWrite = new Map(); // fileName -> chunk data const chunkShims = new Set(); const fileNameMap = new Map(); // original fileName -> hashed fileName return { name: 'vite:module-path', enforce: 'pre', configResolved(config) { isDev = config.command === 'serve'; sourcemap = !!config.build.sourcemap; rootDir = config.root; outDir = path.resolve(rootDir, config.build.outDir); if (!isDev) { viteConfig = { root: config.root, mode: config.mode, configFile: false, logLevel: 'warn', ...options.bundleConfig, build: { ...options.bundleConfig?.build, write: false, lib: undefined, rollupOptions: { ...options.bundleConfig?.build?.rollupOptions, output: { format: 'es', entryFileNames: '[name].js', chunkFileNames: '[name]-[hash].js', assetFileNames: '[name]-[hash][extname]', ...(options.bundleConfig?.build?.rollupOptions?.output as any), }, }, }, }; console.log(`[module-path] Output directory: ${outDir}`); } }, buildStart() { if (!isDev) { assetCache.clear(); chunksToWrite.clear(); chunkShims.clear(); fileNameMap.clear(); } }, async resolveId(id, importer) { const query = parseRequest(id); if (query && typeof query.modulePath === 'string') { // dev 모드에서는 절대 경로로 변환 if (isDev) { const cleanPath = cleanUrl(id); // 절대 경로가 아니면 importer 기준으로 resolve let resolvedPath: string; if (path.isAbsolute(cleanPath)) { resolvedPath = cleanPath; } else if (importer) { resolvedPath = path.resolve(path.dirname(importer), cleanPath); } else { resolvedPath = path.resolve(rootDir, cleanPath); } console.log(`[module-path] Resolved: ${id} -> ${resolvedPath}`); // 절대 경로에 플래그 추가 return resolvedPath + '?__modulePath__=true'; } } return null; }, async load(id) { const query = parseRequest(id); // Dev 모드: 파일을 실행하지 않고 경로만 반환 if (isDev && query && query.__modulePath__ === 'true') { const cleanPath = cleanUrl(id); console.log( `[module-path] Dev mode: returning file path for ${cleanPath}` ); // 절대 경로를 그대로 반환 (dev 모드에서는 소스 파일 직접 사용) return `export default ${JSON.stringify(cleanPath)}`; } // Build 모드: 번들링 수행 if (!isDev && query && typeof query.modulePath === 'string') { const cleanPath = cleanUrl(id); // 이미 처리된 파일인지 확인 if (assetCache.has(cleanPath)) { const hashedFileName = assetCache.get(cleanPath)!; const referenceId = chunksToWrite.get(hashedFileName)?.referenceId; if (referenceId) { console.log( `[module-path] Reusing cached bundle: ${cleanPath} -> ${hashedFileName}` ); const refId = `__VITE_MODULE_PATH__${referenceId}__`; return ` import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) export default join(__dirname, ${refId})`; } } console.log(`[module-path] Bundling module: ${cleanPath}`); try { const { outputChunks } = await bundleModule(cleanPath, viteConfig); if (outputChunks.length === 0) { throw new Error(`No output chunks generated for: ${cleanPath}`); } console.log( `[module-path] Generated ${outputChunks.length} chunk(s)` ); // 메인 청크에 대해 고유한 파일명 생성 const mainChunk = outputChunks[0]; const mainChunkHash = getHash(cleanPath + mainChunk.code); const ext = path.extname(mainChunk.fileName); const baseName = path.basename(mainChunk.fileName, ext); const hashedMainFileName = `${baseName}-${mainChunkHash}${ext}`; // 파일명 매핑 저장 fileNameMap.set(mainChunk.fileName, hashedMainFileName); console.log( `[module-path] Main chunk: ${mainChunk.fileName} -> ${hashedMainFileName}` ); let mainReferenceId: string | undefined; // 모든 청크를 저장 for (const chunk of outputChunks) { const originalFileName = chunk.fileName; const isMainChunk = chunk === mainChunk; // 메인 청크는 이미 해시된 이름 사용, 나머지는 원본 이름 또는 이미 해시된 이름 사용 const fileName = isMainChunk ? hashedMainFileName : fileNameMap.get(originalFileName) || originalFileName; // 동일한 파일이 이미 처리되었는지 확인 if (chunksToWrite.has(fileName)) { console.log(`[module-path] Skipping duplicate: ${fileName}`); if (isMainChunk) { mainReferenceId = chunksToWrite.get(fileName)!.referenceId; } continue; } // 청크의 import 경로를 수정 (다른 청크를 참조하는 경우) let code = chunk.type === 'chunk' ? chunk.code : (chunk.source as string); if (chunk.type === 'chunk') { // import 문에서 다른 청크를 참조하는 경우 해시된 이름으로 변경 for (const [original, hashed] of fileNameMap) { if (original !== hashed) { const importRegex = new RegExp( `(from\\s+['"]\\.\\/)(${original.replace('.', '\\.')})(['"])`, 'g' ); code = code.replace(importRegex, `$1${hashed}$3`); } } } // emitFile로 참조 ID 생성 const referenceId = this.emitFile({ type: 'asset', fileName: fileName, source: code, }); if (isMainChunk) { mainReferenceId = referenceId; assetCache.set(cleanPath, fileName); } // 나중에 직접 쓰기 위해 저장 chunksToWrite.set(fileName, { fileName, code, referenceId, }); if (chunk.type === 'chunk') { chunkShims.add(fileName); } console.log(`[module-path] Processed: ${fileName}`); } if (!mainReferenceId) { throw new Error('Failed to process main chunk'); } const refId = `__VITE_MODULE_PATH__${mainReferenceId}__`; return ` import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) export default join(__dirname, ${refId})`; } catch (error) { console.error(`[module-path] Bundle error:`, error); throw error; } } }, renderChunk(code, chunk) { if (isDev) return null; let modified = false; let s: MagicString | undefined; if (code.match(modulePathRE)) { s = new MagicString(code); let match: RegExpExecArray | null; modulePathRE.lastIndex = 0; while ((match = modulePathRE.exec(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, }); modified = true; } } if (chunkShims.has(chunk.fileName) && !code.includes('const __dirname')) { s = s || new MagicString(code); const importMatches = Array.from( code.matchAll(/import\s+.*?from\s+['"][^'"]+['"]\s*;?/g) ); const lastImport = importMatches[importMatches.length - 1]; const insertPos = lastImport ? lastImport.index! + lastImport[0].length : 0; s.appendRight(insertPos, CJSShim); modified = true; } if (modified && s) { return { code: s.toString(), map: sourcemap ? s.generateMap({ hires: 'boundary' }) : null, }; } return null; }, async writeBundle(options, bundle) { if (isDev) return; console.log( `[module-path] Writing ${chunksToWrite.size} additional chunk(s)` ); for (const [fileName, chunkData] of chunksToWrite) { const outputPath = path.resolve(outDir, fileName); try { await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, chunkData.code, 'utf-8'); console.log(`[module-path] Written: ${fileName}`); } catch (error) { console.error(`[module-path] Failed to write ${fileName}:`, error); throw error; } } console.log(`[module-path] All chunks written successfully`); }, }; } async function bundleModule( input: string, baseConfig: InlineConfig ): Promise<{ outputChunks: any[] }> { const entryName = path.basename(input, path.extname(input)); const buildConfig = mergeConfig(baseConfig, { build: { write: false, emptyOutDir: false, rollupOptions: { input: { [entryName]: input, }, preserveEntrySignatures: 'strict', }, }, }) as InlineConfig; try { const bundles = (await viteBuild(buildConfig)) as Rolldown.RolldownOutput; if (!bundles || !bundles.output) { throw new Error('No output from vite build'); } const outputChunks = bundles.output; return { outputChunks }; } catch (error) { console.error('[module-path] Bundle error:', error); throw error; } }