import type { Plugin } from 'vite'; import fs from 'fs'; import path from 'path'; import { getLocalIP, getRequestPathname } from './utils/httpUtils'; import { MAKE_ENTRIES_RELATIVE_PATH } from './utils/makeConstants'; import { buildDocApiPath } from './utils/docUtils'; function readInjectedHtml(htmlPath: string, injectScript: string) { let html = fs.readFileSync(htmlPath, 'utf8'); html = html.replace('', `${injectScript}\n`); return html; } function setNoStoreHeaders(res: any) { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } function setImmutableAssetHeaders(res: any) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } function setImageCacheHeaders(res: any) { res.setHeader('Cache-Control', 'public, max-age=86400'); } function hasVersionQuery(requestUrl: string) { return /[?&]v=/.test(requestUrl); } function setAdminStaticCacheHeaders(res: any, pathname: string, requestUrl: string) { if (!hasVersionQuery(requestUrl)) { setNoStoreHeaders(res); return; } if (pathname.startsWith('/images/')) { setImageCacheHeaders(res); return; } if (pathname.startsWith('/assets/')) { setImmutableAssetHeaders(res); return; } setNoStoreHeaders(res); } function escapeHtmlAttribute(value: string) { return value .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } function getCanvasDisplayName(canvasName: string) { return path.basename(canvasName, path.extname(canvasName)) || canvasName; } function injectCanvasTemplateHtml(templateHtml: string, canvasName: string, injectScript: string) { const displayName = getCanvasDisplayName(canvasName); const pageTitle = `${displayName} - Canvas`; return templateHtml .replace(/\{\{CANVAS_NAME\}\}/g, escapeHtmlAttribute(canvasName)) .replace(/\{\{CANVAS_TITLE\}\}/g, escapeHtmlAttribute(pageTitle)) .replace('Canvas', `${escapeHtmlAttribute(pageTitle)}`) .replace('', `${injectScript}\n`); } export function serveAdminPlugin(): Plugin { const projectRoot = process.cwd(); const appsMatch = projectRoot.match(/[\/\\]apps[\/\\]([^\/\\]+)/); let projectPrefix = ''; if (appsMatch) { const rootDir = projectRoot.split(/[\/\\]apps[\/\\]/)[0]; const appsDir = path.join(rootDir, 'apps'); if (fs.existsSync(appsDir)) { const appFolders = fs.readdirSync(appsDir); for (const folder of appFolders) { const folderPath = path.join(appsDir, folder); const entriesPath = path.join(folderPath, MAKE_ENTRIES_RELATIVE_PATH); if (fs.existsSync(entriesPath)) { projectPrefix = `apps/${folder}/`; break; } } } } const isMixedProject = Boolean(projectPrefix); return { name: 'serve-admin-plugin', configureServer(server: any) { server.middlewares.use(async (req: any, res: any, next: any) => { try { const adminDir = path.resolve(projectRoot, 'admin'); const pathname = getRequestPathname(req); const requestUrl = String(req.url || pathname || '/'); const localIP = getLocalIP(); const actualPort = server.httpServer?.address()?.port || server.config.server?.port || 5173; const injectScript = ` `; const sendHtml = async (html: string, options?: { transform?: boolean }) => { let responseHtml = html; if (options?.transform) { const htmlUrl = requestUrl === '/' ? '/index.html' : requestUrl; responseHtml = await server.transformIndexHtml(htmlUrl, html, requestUrl); } setNoStoreHeaders(res); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(responseHtml); }; if (pathname === '/' || pathname === '/index.html') { const indexPath = path.join(adminDir, 'index.html'); if (fs.existsSync(indexPath)) { // 首页 admin 壳不参与 src HMR,避免外层页面被 Vite client 带着刷新。 await sendHtml(readInjectedHtml(indexPath, injectScript), { transform: false }); return; } } if (pathname && pathname.match(/^\/[^/]+\.html$/)) { const htmlPath = path.join(adminDir, pathname); if (fs.existsSync(htmlPath)) { // 其他 admin 静态壳页面同样不接入 HMR,只保留 iframe 内 src 页面自己的热更。 await sendHtml(readInjectedHtml(htmlPath, injectScript), { transform: false }); return; } } if (pathname && pathname.startsWith('/assets/')) { const assetPath = path.join(adminDir, pathname); if (fs.existsSync(assetPath)) { const ext = path.extname(assetPath); const contentTypes: Record = { '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.eot': 'application/vnd.ms-fontobject', }; setAdminStaticCacheHeaders(res, pathname, requestUrl); res.setHeader('Content-Type', contentTypes[ext] || 'application/octet-stream'); res.end(fs.readFileSync(assetPath)); return; } } if (pathname && pathname.startsWith('/images/')) { const imagePath = path.join(adminDir, pathname); if (fs.existsSync(imagePath)) { const ext = path.extname(imagePath); const contentTypes: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', }; setAdminStaticCacheHeaders(res, pathname, requestUrl); res.setHeader('Content-Type', contentTypes[ext] || 'image/png'); res.end(fs.readFileSync(imagePath)); return; } } if (pathname && pathname.startsWith('/admin/')) { const adminFilePath = path.join(adminDir, pathname.replace('/admin/', '')); if (fs.existsSync(adminFilePath)) { const ext = path.extname(adminFilePath); const contentTypes: Record = { '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.html': 'text/html; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.eot': 'application/vnd.ms-fontobject', }; const adminPathname = pathname.replace('/admin', '') || '/'; setAdminStaticCacheHeaders(res, adminPathname, requestUrl); res.setHeader('Content-Type', contentTypes[ext] || 'application/octet-stream'); res.end(fs.readFileSync(adminFilePath)); return; } } if (pathname && pathname.match(/^\/[^/]+\.js$/)) { const jsPath = path.join(adminDir, pathname); if (fs.existsSync(jsPath)) { setNoStoreHeaders(res); res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); res.end(fs.readFileSync(jsPath)); return; } } const isDocsAssetRequest = pathname?.startsWith('/docs/') && pathname.includes('/assets/'); const encodedDocName = isDocsAssetRequest ? undefined : pathname?.match(/^\/docs\/(.+?)(?:\/spec\.html)?$/)?.[1]; if (encodedDocName) { const specTemplatePath = path.join(adminDir, 'spec-template.html'); if (fs.existsSync(specTemplatePath)) { let html = fs.readFileSync(specTemplatePath, 'utf8'); const docName = decodeURIComponent(encodedDocName); const docFileName = docName.endsWith('.md') ? docName : `${docName}.md`; const specUrl = buildDocApiPath(docFileName); html = html.replace(/\{\{SPEC_URL\}\}/g, specUrl); html = html.replace(/\{\{TITLE\}\}/g, docName); html = html.replace(/\{\{MULTI_DOC\}\}/g, 'false'); html = html.replace(/\{\{DOCS_CONFIG\}\}/g, '[]'); html = html.replace('', `${injectScript}\n`); // 文档页内容源现在也在 src 下,保留它自己的 Vite 转换与更新能力。 await sendHtml(html, { transform: true }); return; } } const encodedCanvasName = pathname?.match(/^\/canvas\/(.+?)\/?$/)?.[1]; if (encodedCanvasName) { const canvasTemplatePath = path.join(adminDir, 'canvas-template.html'); if (fs.existsSync(canvasTemplatePath)) { const canvasName = decodeURIComponent(encodedCanvasName); const html = injectCanvasTemplateHtml( fs.readFileSync(canvasTemplatePath, 'utf8'), canvasName, injectScript, ); await sendHtml(html, { transform: true }); return; } } next(); } catch (error) { next(error); } }); }, }; }