/// import { defineConfig, Plugin } from 'vite'; import react from '@vitejs/plugin-react'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; import type { OutputAsset, OutputBundle, OutputChunk } from 'rollup'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { createRequire } from 'module'; import { visualizer } from 'rollup-plugin-visualizer'; const require = createRequire(import.meta.url); const workspaceRoot = path.resolve(__dirname, '../..'); // Read version from public/version.json const versionPath = path.resolve(__dirname, 'public/version.json'); let appVersion = '0.0.0'; try { if (fs.existsSync(versionPath)) { const versionContent = fs.readFileSync(versionPath, 'utf-8'); const versionJson = JSON.parse(versionContent); appVersion = versionJson.version || '0.0.0'; console.log(`[Vite] Loaded version from version.json: ${appVersion}`); } else { console.warn('[Vite] version.json not found at', versionPath); } } catch (e) { console.error('[Vite] Failed to read version.json', e); } const IDLE_PREFETCH_GROUPS = [ 'ai-chat', 'tool-windows', 'diagram-engines', 'office-data', 'editor-engines', 'media-viewer', 'external-skills', 'runtime-static-assets', 'offline-static-assets', ] as const; const IDLE_PREFETCH_DEFAULTS = [ 'tool-windows', 'runtime-static-assets', ] as const; type IdlePrefetchGroup = (typeof IDLE_PREFETCH_GROUPS)[number]; type ManifestEntry = { url: string; revision: string }; type ViteOutputChunk = OutputChunk & { viteMetadata?: { importedCss?: Set; }; }; const STATIC_SCAN_EXCLUDED_DIRS = new Set([ 'product_showcase', 'help_tooltips', 'sw-debug', ]); const STATIC_SCAN_EXCLUDED_PATTERNS = [ /stats\.html$/, /\.map$/, /precache-manifest\.json$/, /idle-prefetch-manifest\.json$/, /sw\.js$/, /sw-debug\.html$/, /changelog\.json$/, /version\.json$/, ]; const PRECACHE_EXTENSIONS = new Set(['.js', '.css', '.json', '.svg', '.ico']); const POST_BOOT_PREFETCH_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.woff', '.woff2', '.ttf', '.eot', '.wasm', '.txt', '.md', ]); const PRECACHE_ALWAYS_INCLUDE = new Set([ '/index.html', '/manifest.json', '/favicon.ico', ]); function isFileDescriptorLimitError(error: unknown): boolean { return ( typeof error === 'object' && error !== null && 'code' in error && ((error as NodeJS.ErrnoException).code === 'EMFILE' || (error as NodeJS.ErrnoException).code === 'ENFILE') ); } function waitForFileDescriptorRetry(attempt: number): Promise { return new Promise((resolve) => { setTimeout(resolve, 120 * (attempt + 1)); }); } async function readFileWithFdRetry( fullPath: string, encoding?: BufferEncoding ): Promise { let lastError: unknown; for (let attempt = 0; attempt < 6; attempt += 1) { try { return encoding ? await fs.promises.readFile(fullPath, encoding) : await fs.promises.readFile(fullPath); } catch (error) { lastError = error; if (!isFileDescriptorLimitError(error) || attempt === 5) { throw error; } await waitForFileDescriptorRetry(attempt); } } throw lastError; } async function writeFileWithFdRetry( fullPath: string, content: string ): Promise { for (let attempt = 0; attempt < 6; attempt += 1) { try { await fs.promises.writeFile(fullPath, content); return; } catch (error) { if (!isFileDescriptorLimitError(error) || attempt === 5) { throw error; } await waitForFileDescriptorRetry(attempt); } } } function createRevision(fullPath: string): string { const content = fs.readFileSync(fullPath); return crypto .createHash('md5') .update(new Uint8Array(content)) .digest('hex') .substring(0, 8); } async function createRevisionWithFdRetry(fullPath: string): Promise { const content = await readFileWithFdRetry(fullPath); return crypto .createHash('md5') .update(new Uint8Array(content as Buffer)) .digest('hex') .substring(0, 8); } function createContentRevision(content: string | Uint8Array): string { return crypto.createHash('md5').update(content).digest('hex').substring(0, 8); } function createBundleFileRevision(file: OutputAsset | OutputChunk): string { if (file.type === 'asset') { const source = file.source ?? ''; return createContentRevision( typeof source === 'string' || source instanceof Uint8Array ? source : String(source) ); } return createContentRevision(file.code); } function shouldExcludeStaticScanUrl(url: string): boolean { return STATIC_SCAN_EXCLUDED_PATTERNS.some((pattern) => pattern.test(url)); } function isRuntimeStaticAssetUrl(url: string): boolean { // 仅覆盖构建产物中的运行时附属资源,避免把手册截图等大文件带进默认 idle 预取。 return url.startsWith('/assets/'); } function collectStaticEntries( outDir: string, shouldInclude: (url: string, ext: string) => boolean ): ManifestEntry[] { const manifest: ManifestEntry[] = []; function scanDir(dir: string, base = '') { if (!fs.existsSync(dir)) return; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = path.join(base, entry.name).replace(/\\/g, '/'); if (entry.isDirectory()) { if (!STATIC_SCAN_EXCLUDED_DIRS.has(entry.name)) { scanDir(fullPath, relativePath); } continue; } if (!entry.isFile()) { continue; } const ext = path.extname(entry.name).toLowerCase(); const url = '/' + relativePath; if (shouldExcludeStaticScanUrl(url) || !shouldInclude(url, ext)) { continue; } manifest.push({ url, revision: createRevision(fullPath), }); } } scanDir(outDir); manifest.sort((a, b) => a.url.localeCompare(b.url)); return manifest; } function normalizeStaticUrlCandidate(candidate: string): string | null { if (!candidate) { return null; } if ( candidate.startsWith('http://') || candidate.startsWith('https://') || candidate.startsWith('data:') || candidate.startsWith('blob:') ) { return null; } const [withoutQuery] = candidate.split(/[?#]/, 1); if (!withoutQuery) { return null; } if (withoutQuery.startsWith('./')) { return `/${withoutQuery.slice(2)}`; } if (withoutQuery.startsWith('/')) { return withoutQuery; } return `/${withoutQuery}`; } async function collectSelectedEntries( outDir: string, urls: Iterable, shouldInclude: (url: string, ext: string) => boolean ): Promise { const manifest: ManifestEntry[] = []; const normalizedUrls = new Set(); for (const url of urls) { const normalizedUrl = normalizeStaticUrlCandidate(url); if (!normalizedUrl) { continue; } normalizedUrls.add(normalizedUrl); } for (const url of normalizedUrls) { const ext = path.extname(url).toLowerCase(); if (shouldExcludeStaticScanUrl(url) || !shouldInclude(url, ext)) { continue; } const fullPath = path.join(outDir, url.replace(/^\//, '')); if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) { continue; } manifest.push({ url, revision: await createRevisionWithFdRetry(fullPath), }); } manifest.sort((a, b) => a.url.localeCompare(b.url)); return manifest; } async function collectHtmlShellPrecacheUrls( indexHtmlPath: string ): Promise> { const urls = new Set(PRECACHE_ALWAYS_INCLUDE); if (!fs.existsSync(indexHtmlPath)) { return urls; } const html = (await readFileWithFdRetry(indexHtmlPath, 'utf8')) as string; const attrPattern = /\b(?:data-local-(?:src|href)|src|href)="([^"]+)"/g; for (const match of html.matchAll(attrPattern)) { const normalizedUrl = normalizeStaticUrlCandidate(match[1]); if (normalizedUrl) { urls.add(normalizedUrl); } } return urls; } function normalizePathForChunking(id: string): string { return id.replace(/\\/g, '/'); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function findPackageRoot(entryPath: string): string { let current = fs.statSync(entryPath).isDirectory() ? entryPath : path.dirname(entryPath); while (current !== path.dirname(current)) { if (fs.existsSync(path.join(current, 'package.json'))) { return current; } current = path.dirname(current); } throw new Error(`Unable to resolve package root for ${entryPath}`); } function resolvePackageRoot(packageName: string, fromPaths: string[]): string { return findPackageRoot(require.resolve(packageName, { paths: fromPaths })); } function createPackageRootAlias(packageName: string, packageRoot: string) { const escapedName = escapeRegExp(packageName); return [ { find: new RegExp(`^${escapedName}$`), replacement: packageRoot }, { find: new RegExp(`^${escapedName}/(.+)$`), replacement: `${packageRoot}/$1`, }, ]; } const mermaidToDrawnixPackageRoot = resolvePackageRoot( '@plait-board/mermaid-to-drawnix', [workspaceRoot] ); const diagramEngineAliases = [ ...createPackageRootAlias( 'mermaid', resolvePackageRoot('mermaid', [mermaidToDrawnixPackageRoot]) ), ]; function normalizeBundleFileName(fileName: string): string { return normalizePathForChunking(fileName).replace(/^\.\//, ''); } function toManifestUrl(fileName: string): string { const normalizedFileName = normalizeBundleFileName(fileName); return normalizedFileName.startsWith('/') ? normalizedFileName : `/${normalizedFileName}`; } function shouldIncludeIdlePrefetchBundleFile(fileName: string): boolean { const normalizedFileName = normalizeBundleFileName(fileName).toLowerCase(); return ( normalizedFileName.endsWith('.js') || normalizedFileName.endsWith('.css') ); } function isNamedIdlePrefetchGroupFile( fileName: string, group: IdlePrefetchGroup ): boolean { const baseName = path.posix .basename(normalizeBundleFileName(fileName)) .toLowerCase(); return baseName.startsWith(`${group.toLowerCase()}-`); } function getNamedIdlePrefetchGroupForFile( fileName: string ): IdlePrefetchGroup | undefined { return IDLE_PREFETCH_GROUPS.find((group) => isNamedIdlePrefetchGroupFile(fileName, group) ); } function collectNamedGroupEntriesFromOutputDir( assetsDir: string, group: IdlePrefetchGroup ): ManifestEntry[] { if (!fs.existsSync(assetsDir)) { return []; } const files = fs.readdirSync(assetsDir); const entries: ManifestEntry[] = []; for (const file of files) { const normalizedFileName = normalizeBundleFileName(file); if ( !isNamedIdlePrefetchGroupFile(normalizedFileName, group) || !shouldIncludeIdlePrefetchBundleFile(normalizedFileName) ) { continue; } const fullPath = path.join(assetsDir, file); entries.push({ url: toManifestUrl(path.posix.join('assets', normalizedFileName)), revision: createRevision(fullPath), }); } entries.sort((a, b) => a.url.localeCompare(b.url)); return entries; } function chunkBelongsToIdlePrefetchGroup( chunk: ViteOutputChunk, group: IdlePrefetchGroup ): boolean { if (isNamedIdlePrefetchGroupFile(chunk.fileName, group)) { return true; } const moduleIds = [chunk.facadeModuleId, ...Object.keys(chunk.modules)]; return moduleIds.some( (moduleId) => typeof moduleId === 'string' && resolveIdlePrefetchGroup(moduleId) === group ); } function collectIdlePrefetchGroupEntriesFromBundle( bundle: OutputBundle, group: IdlePrefetchGroup ): ManifestEntry[] { if (group === 'runtime-static-assets' || group === 'offline-static-assets') { return []; } const chunksByFileName = new Map(); const assetsByFileName = new Map(); for (const output of Object.values(bundle)) { const normalizedFileName = normalizeBundleFileName(output.fileName); if (output.type === 'chunk') { chunksByFileName.set(normalizedFileName, output as ViteOutputChunk); continue; } assetsByFileName.set(normalizedFileName, output); } const pendingChunkFileNames = Array.from(chunksByFileName.values()) .filter((chunk) => chunkBelongsToIdlePrefetchGroup(chunk, group)) .map((chunk) => normalizeBundleFileName(chunk.fileName)); if (pendingChunkFileNames.length === 0) { return []; } const visitedChunkFileNames = new Set(); const pendingCssFileNames = new Set(); const entriesByUrl = new Map(); const addBundleEntry = (file: OutputAsset | OutputChunk) => { if (!shouldIncludeIdlePrefetchBundleFile(file.fileName)) { return; } const entry = { url: toManifestUrl(file.fileName), revision: createBundleFileRevision(file), }; entriesByUrl.set(entry.url, entry); }; while (pendingChunkFileNames.length > 0) { const currentFileName = pendingChunkFileNames.pop(); if (!currentFileName || visitedChunkFileNames.has(currentFileName)) { continue; } visitedChunkFileNames.add(currentFileName); const chunk = chunksByFileName.get(currentFileName); if (!chunk) { continue; } addBundleEntry(chunk); chunk.viteMetadata?.importedCss?.forEach((cssFileName) => { pendingCssFileNames.add(normalizeBundleFileName(cssFileName)); }); chunk.referencedFiles.forEach((referencedFileName) => { const normalizedReferencedFileName = normalizeBundleFileName(referencedFileName); if (normalizedReferencedFileName.toLowerCase().endsWith('.css')) { pendingCssFileNames.add(normalizedReferencedFileName); } }); for (const importedFileName of [ ...chunk.imports, ...chunk.dynamicImports, ]) { const normalizedImportedFileName = normalizeBundleFileName(importedFileName); const importedGroup = getNamedIdlePrefetchGroupForFile( normalizedImportedFileName ); if (importedGroup && importedGroup !== group) { continue; } if (!visitedChunkFileNames.has(normalizedImportedFileName)) { pendingChunkFileNames.push(normalizedImportedFileName); } } } for (const cssFileName of pendingCssFileNames) { const cssAsset = assetsByFileName.get(cssFileName); if (cssAsset) { addBundleEntry(cssAsset); } } for (const asset of assetsByFileName.values()) { if ( isNamedIdlePrefetchGroupFile(asset.fileName, group) && asset.fileName.toLowerCase().endsWith('.css') ) { addBundleEntry(asset); } } return Array.from(entriesByUrl.values()).sort((a, b) => a.url.localeCompare(b.url) ); } function resolveIdlePrefetchGroup(id: string): IdlePrefetchGroup | undefined { const normalizedId = normalizePathForChunking(id); if ( normalizedId.includes('/packages/drawnix/src/components/chat-drawer/') || normalizedId.includes('/packages/drawnix/src/hooks/useChatHandler') || normalizedId.includes('/packages/drawnix/src/services/chat-service') || normalizedId.includes( '/packages/drawnix/src/services/chat-storage-service' ) || normalizedId.includes('/packages/drawnix/src/types/chat.types') || normalizedId.includes('/packages/drawnix/src/types/chat-ui.types') ) { return 'ai-chat'; } if ( normalizedId.includes('/node_modules/.pnpm/mermaid@') || normalizedId.includes('/node_modules/mermaid/') || normalizedId.includes('/node_modules/.pnpm/elkjs@') || normalizedId.includes('/node_modules/elkjs/') || normalizedId.includes('/node_modules/.pnpm/cytoscape@') || normalizedId.includes('/node_modules/cytoscape/') || normalizedId.includes('/node_modules/.pnpm/layout-base@') || normalizedId.includes('/node_modules/layout-base/') || normalizedId.includes('/node_modules/.pnpm/cose-base@') || normalizedId.includes('/node_modules/cose-base/') || normalizedId.includes('/node_modules/.pnpm/cytoscape-cose-bilkent@') || normalizedId.includes('/node_modules/cytoscape-cose-bilkent/') || normalizedId.includes('/node_modules/.pnpm/dagre-d3-es@') || normalizedId.includes('/node_modules/dagre-d3-es/') || normalizedId.includes('/node_modules/.pnpm/dompurify@') || normalizedId.includes('/node_modules/dompurify/') ) { return 'diagram-engines'; } if ( normalizedId.includes('/node_modules/.pnpm/xlsx@') || normalizedId.includes('/node_modules/xlsx/') || normalizedId.includes('/node_modules/.pnpm/jszip@') || normalizedId.includes('/node_modules/jszip/') || normalizedId.includes('/node_modules/.pnpm/pptxgenjs@') || normalizedId.includes('/node_modules/pptxgenjs/') ) { return 'office-data'; } if ( normalizedId.includes('/node_modules/.pnpm/@milkdown+') || normalizedId.includes('/node_modules/@milkdown/') || normalizedId.includes('/node_modules/.pnpm/@codemirror+') || normalizedId.includes('/node_modules/@codemirror/') || normalizedId.includes('/node_modules/.pnpm/@lezer+') || normalizedId.includes('/node_modules/@lezer/') || normalizedId.includes('/node_modules/.pnpm/prosemirror-') || normalizedId.includes('/node_modules/prosemirror-') || normalizedId.includes('/node_modules/.pnpm/katex@') || normalizedId.includes('/node_modules/katex/') ) { return 'editor-engines'; } if ( normalizedId.includes('/node_modules/.pnpm/viewerjs@') || normalizedId.includes('/node_modules/viewerjs/') ) { return 'media-viewer'; } if (normalizedId.includes('/packages/drawnix/src/tools/tool-ids.ts')) { return undefined; } if ( normalizedId.includes( '/packages/drawnix/src/components/startup/DrawnixDeferredRuntime.tsx' ) || normalizedId.includes( '/packages/drawnix/src/components/startup/DeferredMediaLibraryModal.tsx' ) || normalizedId.includes( '/packages/drawnix/src/components/startup/DeferredSyncSettings.tsx' ) || normalizedId.includes( '/packages/drawnix/src/services/asset-integration-service' ) || normalizedId.includes( '/packages/drawnix/src/services/font-manager-service' ) || normalizedId.includes('/packages/drawnix/src/hooks/useTaskStorage') || normalizedId.includes('/packages/drawnix/src/hooks/useTaskExecutor') || normalizedId.includes( '/packages/drawnix/src/hooks/useAutoInsertToCanvas' ) || normalizedId.includes( '/packages/drawnix/src/hooks/useImageGenerationAnchorSync' ) || normalizedId.includes('/packages/drawnix/src/hooks/useBeforeUnload') || normalizedId.includes('/packages/drawnix/src/hooks/useProviderProfiles') || normalizedId.includes('/packages/drawnix/src/components/toolbox-drawer/') || normalizedId.includes('/packages/drawnix/src/components/project-drawer/') || normalizedId.includes( '/packages/drawnix/src/components/toolbar/minimized-tools-bar/' ) || normalizedId.includes( '/packages/drawnix/src/services/tool-window-service' ) || normalizedId.includes('/packages/drawnix/src/services/toolbox-service') || normalizedId.includes('/packages/drawnix/src/tools/') || normalizedId.includes('/packages/drawnix/src/components/backup-restore/') || normalizedId.includes( '/packages/drawnix/src/components/performance-panel/' ) || normalizedId.includes('/packages/drawnix/src/components/version-update/') || normalizedId.includes( '/packages/drawnix/src/components/command-palette/' ) || normalizedId.includes('/packages/drawnix/src/components/canvas-search/') ) { return 'tool-windows'; } if ( normalizedId.includes( '/packages/drawnix/src/generated/external-skills-bundle.json' ) ) { return 'external-skills'; } return undefined; } function isStartupRuntimeModule(id: string): boolean { const normalizedId = normalizePathForChunking(id); return ( normalizedId.includes('vite/preload-helper') || normalizedId.includes('commonjs-dynamic-modules') || normalizedId.includes('commonjsHelpers') ); } interface ManualChunkModuleInfo { importers: string[]; } interface ManualChunkContext { getModuleInfo: (id: string) => ManualChunkModuleInfo | null; } function isWebMainEntry(id: string): boolean { const normalizedId = normalizePathForChunking(id); return ( normalizedId.endsWith('/apps/web/src/main.tsx') || normalizedId.endsWith('/src/main.tsx') ); } function isStaticallyReachableFromWebMain( id: string, getModuleInfo: ManualChunkContext['getModuleInfo'], visited = new Set() ): boolean { if (visited.has(id)) { return false; } visited.add(id); if (isWebMainEntry(id)) { return true; } const moduleInfo = getModuleInfo(id); if (!moduleInfo) { return false; } return moduleInfo.importers.some((importerId) => isStaticallyReachableFromWebMain(importerId, getModuleInfo, visited) ); } function resolveManualChunk( id: string, context?: ManualChunkContext ): string | undefined { if (isStartupRuntimeModule(id)) { return 'startup-runtime'; } const group = resolveIdlePrefetchGroup(id); if ( (group === undefined || group === 'tool-windows') && context && isStaticallyReachableFromWebMain(id, context.getModuleInfo) ) { return 'startup-app'; } return group; } /** * Vite 插件:生成 precache-manifest.json * 在构建完成后扫描输出目录,生成需要预缓存的静态资源清单 */ function precacheManifestPlugin(): Plugin { return { name: 'precache-manifest', apply: 'build', closeBundle: { sequential: true, order: 'post', async handler() { const outDir = path.resolve(__dirname, '../../dist/apps/web'); const indexHtmlPath = path.join(outDir, 'index.html'); const precacheUrls = await collectHtmlShellPrecacheUrls(indexHtmlPath); const manifest = await collectSelectedEntries( outDir, precacheUrls, (url, ext) => PRECACHE_ALWAYS_INCLUDE.has(url) || (ext !== '.html' && PRECACHE_EXTENSIONS.has(ext)) ); // 写入 manifest 文件 const manifestPath = path.join(outDir, 'precache-manifest.json'); const manifestContent = { version: appVersion, timestamp: new Date().toISOString(), files: manifest, }; await writeFileWithFdRetry( manifestPath, JSON.stringify(manifestContent, null, 2) ); console.log( `[Precache] Generated manifest with ${manifest.length} files` ); }, }, }; } function idlePrefetchManifestPlugin(): Plugin { let bundleGroupEntries = Object.fromEntries( IDLE_PREFETCH_GROUPS.map((group) => [group, [] as ManifestEntry[]]) ) as Record; return { name: 'idle-prefetch-manifest', apply: 'build', generateBundle(_options, bundle) { bundleGroupEntries = Object.fromEntries( IDLE_PREFETCH_GROUPS.map((group) => [ group, collectIdlePrefetchGroupEntriesFromBundle(bundle, group), ]) ) as Record; }, closeBundle: { sequential: true, order: 'post', async handler() { const outDir = path.resolve(__dirname, '../../dist/apps/web'); const assetsDir = path.join(outDir, 'assets'); const precacheManifestPath = path.join( outDir, 'precache-manifest.json' ); const groups = Object.fromEntries( IDLE_PREFETCH_GROUPS.map((group) => [ group, [] as Array<{ url: string; revision: string }>, ]) ) as Record< IdlePrefetchGroup, Array<{ url: string; revision: string }> >; const precachedUrls = new Set(); if (fs.existsSync(precacheManifestPath)) { try { const precacheManifest = JSON.parse( (await readFileWithFdRetry( precacheManifestPath, 'utf8' )) as string ) as { files?: Array<{ url?: string }> }; for (const entry of precacheManifest.files || []) { if (typeof entry.url === 'string' && entry.url.length > 0) { precachedUrls.add(entry.url); } } } catch (error) { console.warn( '[IdlePrefetch] Failed to read precache-manifest.json:', error ); } } if (fs.existsSync(assetsDir)) { for (const group of IDLE_PREFETCH_GROUPS) { if (group === 'offline-static-assets') { continue; } const expandedEntries = bundleGroupEntries[group].length > 0 ? bundleGroupEntries[group] : collectNamedGroupEntriesFromOutputDir(assetsDir, group); groups[group] = expandedEntries.filter((entry) => { const fullPath = path.join(outDir, entry.url.replace(/^\//, '')); return fs.existsSync(fullPath) && !precachedUrls.has(entry.url); }); } } groups['runtime-static-assets'] = collectStaticEntries( outDir, (url, ext) => !precachedUrls.has(url) && ext !== '.html' && POST_BOOT_PREFETCH_EXTENSIONS.has(ext) && isRuntimeStaticAssetUrl(url) ); const runtimeStaticUrls = new Set( groups['runtime-static-assets'].map((entry) => entry.url) ); groups['offline-static-assets'] = collectStaticEntries( outDir, (url, ext) => !precachedUrls.has(url) && !runtimeStaticUrls.has(url) && ext !== '.html' && POST_BOOT_PREFETCH_EXTENSIONS.has(ext) ); const manifestPath = path.join(outDir, 'idle-prefetch-manifest.json'); fs.writeFileSync( manifestPath, JSON.stringify( { version: appVersion, timestamp: new Date().toISOString(), defaults: [...IDLE_PREFETCH_DEFAULTS], groups, }, null, 2 ) ); console.log('[IdlePrefetch] Generated idle-prefetch-manifest.json'); }, }, }; } function deferEntryAssetsPlugin(): Plugin { return { name: 'defer-entry-assets', apply: 'build', closeBundle: { sequential: true, order: 'post', async handler() { const outDir = path.resolve(__dirname, '../../dist/apps/web'); const indexHtmlPath = path.join(outDir, 'index.html'); if (!fs.existsSync(indexHtmlPath)) { return; } const html = (await readFileWithFdRetry( indexHtmlPath, 'utf8' )) as string; const deferredTags: string[] = []; const assetTagPattern = /^[ \t]*(]*type="module"[^>]*src="\.\/assets\/[^"]+"[^>]*><\/script>|]*rel="stylesheet"[^>]*href="\.\/assets\/[^"]+"[^>]*>)\s*$/gm; const strippedHtml = html.replace(assetTagPattern, (match, tag) => { deferredTags.push(tag.trim()); return ''; }); if (deferredTags.length === 0) { return; } const injection = ` ${deferredTags.join('\n ')}\n`; const nextHtml = strippedHtml.replace('', `${injection}`); await writeFileWithFdRetry(indexHtmlPath, nextHtml); console.log( `[EntryAssets] Deferred ${deferredTags.length} entry asset tag(s) to body end` ); }, }, }; } function rewriteEntryAssetsToCDNPlugin(): Plugin { const preferLocalAssets = process.env.TRUEGROWTH_DESKTOP_LOCAL_ASSETS === '1' || process.env.VITE_DESKTOP_LOCAL_ASSETS === '1'; return { name: 'rewrite-entry-assets-to-cdn', apply: 'build', closeBundle: { sequential: true, order: 'post', async handler() { const outDir = path.resolve(__dirname, '../../dist/apps/web'); const indexHtmlPath = path.join(outDir, 'index.html'); if (!fs.existsSync(indexHtmlPath)) { return; } const html = (await readFileWithFdRetry( indexHtmlPath, 'utf8' )) as string; const cdnBaseUrl = `https://cdn.jsdelivr.net/npm/aitu-app@${appVersion}`; let rewrittenCount = 0; const rewriteAssetUrl = (localPath: string) => { if (preferLocalAssets) { return localPath; } const [pathname, suffix = ''] = localPath.split(/([?#].*)/, 2); return `${cdnBaseUrl}/${pathname.replace(/^\.\//, '')}${suffix}`; }; const rewriteManagedLinkTag = ( beforeHref: string, localHref: string, afterHref: string ) => { const hasSelfClosingSlash = /\/\s*$/.test(afterHref); const normalizedAfterHref = afterHref.replace(/\/\s*$/, ''); rewrittenCount += 1; return ``; }; let nextHtml = html.replace( /]*\btype="module"[^>]*)\bsrc="(\.\/assets\/[^"]+)"([^>]*)><\/script>/g, (_match, beforeSrc, localSrc, afterSrc) => { rewrittenCount += 1; return ``; } ); nextHtml = nextHtml.replace( /]*\brel="stylesheet"[^>]*)\bhref="(\.\/assets\/[^"]+)"([^>]*)>/g, (_match, beforeHref, localHref, afterHref) => rewriteManagedLinkTag(beforeHref, localHref, afterHref) ); nextHtml = nextHtml.replace( /]*\brel="(?:manifest|icon|apple-touch-icon)"[^>]*)\bhref="(\.\/[^"]+)"([^>]*)>/g, (_match, beforeHref, localHref, afterHref) => rewriteManagedLinkTag(beforeHref, localHref, afterHref) ); if (rewrittenCount === 0) { return; } await writeFileWithFdRetry(indexHtmlPath, nextHtml); console.log( `[EntryAssets] Rewrote ${rewrittenCount} entry asset tag(s) to prefer ${ preferLocalAssets ? 'local files' : 'CDN' }` ); }, }, }; } function rewriteManifestAssetsToCDNPlugin(): Plugin { return { name: 'rewrite-manifest-assets-to-cdn', apply: 'build', closeBundle: { sequential: true, order: 'post', async handler() { const outDir = path.resolve(__dirname, '../../dist/apps/web'); const manifestPath = path.join(outDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) { return; } const manifest = JSON.parse( (await readFileWithFdRetry(manifestPath, 'utf8')) as string ); const cdnBaseUrl = `https://cdn.jsdelivr.net/npm/aitu-app@${appVersion}`; let rewrittenCount = 0; const rewriteManifestAssetUrl = (assetUrl: string) => { if ( typeof assetUrl !== 'string' || !assetUrl || /^https?:\/\//.test(assetUrl) ) { return assetUrl; } rewrittenCount += 1; return `${cdnBaseUrl}/${assetUrl.replace(/^\.\//, '')}`; }; if (Array.isArray(manifest.icons)) { manifest.icons = manifest.icons.map( (icon: Record) => ({ ...icon, src: rewriteManifestAssetUrl(String(icon.src || '')), }) ); } if (Array.isArray(manifest.shortcuts)) { manifest.shortcuts = manifest.shortcuts.map( (shortcut: Record) => ({ ...shortcut, icons: Array.isArray(shortcut.icons) ? shortcut.icons.map((icon: Record) => ({ ...icon, src: rewriteManifestAssetUrl(String(icon.src || '')), })) : shortcut.icons, }) ); } if (rewrittenCount === 0) { return; } fs.writeFileSync( manifestPath, JSON.stringify(manifest, null, 2) + '\n' ); console.log( `[ManifestAssets] Rewrote ${rewrittenCount} manifest asset url(s) to prefer CDN` ); }, }, }; } // 检测是否在 watch 模式下运行(命令行包含 --watch) const isWatchMode = process.argv.includes('--watch'); const isServeMode = process.argv.includes('serve'); const isVitestMode = process.argv.some((arg) => arg.includes('vitest')); const reactNodeEnv = isWatchMode || isServeMode || isVitestMode ? 'development' : 'production'; const TRUEMODEL_API_PROXY_PREFIX = '/api/truemodel'; const LOCAL_API_PROXY_TARGET = process.env.VITE_LOCAL_API_PROXY_TARGET || `http://127.0.0.1:${process.env.TRUEGROWTH_LOCAL_API_PORT || 48177}`; const appDevProxy = { [TRUEMODEL_API_PROXY_PREFIX]: { target: 'https://truemodel.benchu.cloud', changeOrigin: true, secure: true, rewrite: (requestPath: string) => requestPath.replace(/^\/api\/truemodel(?=\/|$)/, '') || '/', }, '/local-api': { target: LOCAL_API_PROXY_TARGET, changeOrigin: true, secure: false, }, }; export default defineConfig({ root: __dirname, cacheDir: '../../node_modules/.vite/apps/web', // 使用相对路径,源站始终可用,CDN 加速由 SW 层处理 // SW 的 handleStaticRequest: cache → CDN → 源站回退 base: process.env.VITE_BASE_URL || './', define: { 'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion), 'process.env.NODE_ENV': JSON.stringify(reactNodeEnv), __APP_VERSION__: JSON.stringify(appVersion), // Vue feature flags - @milkdown/crepe 内部使用了 Vue,需要定义这些编译时标志 __VUE_OPTIONS_API__: JSON.stringify(false), __VUE_PROD_DEVTOOLS__: JSON.stringify(false), __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(false), }, esbuild: { jsxDev: reactNodeEnv === 'development', }, server: { port: 7200, host: 'localhost', proxy: appDevProxy, headers: { 'Content-Security-Policy': "default-src 'self' https: data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com https://us.i.posthog.com https://us-assets.i.posthog.com https://wiki.tu-zi.com; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: http: https:; media-src 'self' data: blob: http: https:; connect-src 'self' http: https: ws: wss: data:; frame-ancestors 'self' localhost:* 127.0.0.1:* https://truemodel.benchu.cloud;", }, }, preview: { port: 4300, host: 'localhost', proxy: appDevProxy, headers: { 'Content-Security-Policy': "upgrade-insecure-requests; default-src 'self' https: data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com https://us.i.posthog.com https://us-assets.i.posthog.com https://wiki.tu-zi.com; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: http: https:; media-src 'self' data: blob: http: https:; connect-src 'self' http: https: ws: wss: data:; frame-ancestors 'self' localhost:* 127.0.0.1:* https://truemodel.benchu.cloud;", }, }, plugins: [ react(), nxViteTsPaths(), visualizer({ open: false, filename: path.resolve(__dirname, '../../dist/apps/web/stats.html'), gzipSize: true, brotliSize: true, }), deferEntryAssetsPlugin(), rewriteEntryAssetsToCDNPlugin(), rewriteManifestAssetsToCDNPlugin(), precacheManifestPlugin(), idlePrefetchManifestPlugin(), ], resolve: { alias: [ ...diagramEngineAliases, { find: /^tdesign-react$/, replacement: path.resolve( __dirname, '../../packages/drawnix/src/utils/tdesign.ts' ), }, ], dedupe: ['react', 'react-dom'], }, // Uncomment this if you are using workers. // worker: { // plugins: [ nxViteTsPaths() ], // }, build: { outDir: '../../dist/apps/web', // watch 模式下不清空输出目录,避免 index.html 丢失导致 serve 失败 emptyOutDir: !isWatchMode, reportCompressedSize: true, // 首屏只注入壳层资源,懒加载分组改由运行时按需拉取/空闲预取。 modulePreload: false, commonjsOptions: { transformMixedEsModules: true, }, rollupOptions: { output: { manualChunks(id, context) { return resolveManualChunk(id, context); }, }, }, }, });