From 68a9681fae485ebca190472e2219f9b56ae4ad22 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Wed, 21 Jan 2026 00:38:47 +0800 Subject: [PATCH] feat(vite-plugin): add build.rewriteBase for remote miniapps - Add server/build config structure to RemoteMiniappConfig - Implement rewriteHtmlBase function to inject tag in index.html - Apply base rewrite after copying to dist (build mode only) - Configure rwa-hub with build.rewriteBase: true This fixes baseUrl issues for remote miniapps deployed under /miniapps/{dirName}/ --- scripts/vite-plugin-remote-miniapps.ts | 369 ++++++++++++++----------- vite.config.ts | 328 +++++++++++----------- 2 files changed, 374 insertions(+), 323 deletions(-) diff --git a/scripts/vite-plugin-remote-miniapps.ts b/scripts/vite-plugin-remote-miniapps.ts index b3891fdf7..79737c06e 100644 --- a/scripts/vite-plugin-remote-miniapps.ts +++ b/scripts/vite-plugin-remote-miniapps.ts @@ -8,168 +8,193 @@ * 使用 fetchWithEtag 实现基于 ETag 的缓存 */ -import { type Plugin } from 'vite' -import { resolve, join } from 'node:path' -import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, rmSync } from 'node:fs' -import { createServer } from 'node:http' -import type JSZipType from 'jszip' -import { fetchWithEtag, type FetchWithEtagOptions } from './utils/fetch-with-etag' +import { type Plugin } from 'vite'; +import { resolve, join } from 'node:path'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, rmSync } from 'node:fs'; +import { createServer } from 'node:http'; +import type JSZipType from 'jszip'; +import { fetchWithEtag, type FetchWithEtagOptions } from './utils/fetch-with-etag'; // ==================== Types ==================== +type MiniappRuntime = 'iframe' | 'wujie'; + +interface MiniappServerConfig { + runtime?: MiniappRuntime; +} + +interface MiniappBuildConfig { + runtime?: MiniappRuntime; + /** + * 重写 index.html 的 标签 + * - true: 自动推断为 '/miniapps/{dirName}/' + * - string: 自定义路径 + * - undefined/false: 不重写 + */ + rewriteBase?: boolean | string; +} + interface RemoteMiniappConfig { - metadataUrl: string - dirName: string + metadataUrl: string; + dirName: string; + server?: MiniappServerConfig; + build?: MiniappBuildConfig; } interface RemoteMetadata { - id: string - name: string - version: string - zipUrl: string - manifestUrl: string - updatedAt: string + id: string; + name: string; + version: string; + zipUrl: string; + manifestUrl: string; + updatedAt: string; } interface RemoteMiniappsPluginOptions { - miniapps: RemoteMiniappConfig[] - miniappsDir?: string - timeout?: number - retries?: number + miniapps: RemoteMiniappConfig[]; + miniappsDir?: string; + timeout?: number; + retries?: number; } interface MiniappManifest { - id: string - dirName: string - name: string - description: string - longDescription?: string - icon: string - version: string - author: string - website?: string - category: 'tools' | 'exchange' | 'social' | 'games' | 'other' - tags: string[] - permissions: string[] - chains: string[] - screenshots: string[] - publishedAt: string - updatedAt: string - beta: boolean - themeColor: string - officialScore?: number - communityScore?: number + id: string; + dirName: string; + name: string; + description: string; + longDescription?: string; + icon: string; + version: string; + author: string; + website?: string; + category: 'tools' | 'exchange' | 'social' | 'games' | 'other'; + tags: string[]; + permissions: string[]; + chains: string[]; + screenshots: string[]; + publishedAt: string; + updatedAt: string; + beta: boolean; + themeColor: string; + officialScore?: number; + communityScore?: number; } interface RemoteMiniappServer { - id: string - dirName: string - port: number - server: ReturnType - baseUrl: string - manifest: MiniappManifest + id: string; + dirName: string; + port: number; + server: ReturnType; + baseUrl: string; + manifest: MiniappManifest; } // ==================== Plugin ==================== export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plugin { - const { miniapps, miniappsDir = 'miniapps', timeout = 60000, retries = 3 } = options - const fetchOptions: FetchWithEtagOptions = { timeout, retries } + const { miniapps, miniappsDir = 'miniapps', timeout = 60000, retries = 3 } = options; + const fetchOptions: FetchWithEtagOptions = { timeout, retries }; - let root: string - let isBuild = false - const servers: RemoteMiniappServer[] = [] - const downloadFailures: string[] = [] + let root: string; + let isBuild = false; + const servers: RemoteMiniappServer[] = []; + const downloadFailures: string[] = []; return { name: 'vite-plugin-remote-miniapps', configResolved(config) { - root = config.root - isBuild = config.command === 'build' + root = config.root; + isBuild = config.command === 'build'; }, async buildStart() { - if (miniapps.length === 0) return + if (miniapps.length === 0) return; - const miniappsPath = resolve(root, miniappsDir) + const miniappsPath = resolve(root, miniappsDir); for (const config of miniapps) { try { - await downloadAndExtract(config, miniappsPath, fetchOptions) + await downloadAndExtract(config, miniappsPath, fetchOptions); } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err) - console.error(`[remote-miniapps] ❌ Failed to download ${config.dirName}: ${errorMsg}`) - downloadFailures.push(config.dirName) + const errorMsg = err instanceof Error ? err.message : String(err); + console.error(`[remote-miniapps] ❌ Failed to download ${config.dirName}: ${errorMsg}`); + downloadFailures.push(config.dirName); } } if (downloadFailures.length > 0 && isBuild) { throw new Error( `[remote-miniapps] Build aborted: failed to download remote miniapps: ${downloadFailures.join(', ')}. ` + - `Check network connectivity to remote servers.` - ) + `Check network connectivity to remote servers.`, + ); } }, async writeBundle(outputOptions) { - if (!isBuild || !outputOptions.dir) return + if (!isBuild || !outputOptions.dir) return; - const miniappsPath = resolve(root, miniappsDir) - const miniappsOutputDir = resolve(outputOptions.dir, 'miniapps') - const missing: string[] = [] + const miniappsPath = resolve(root, miniappsDir); + const miniappsOutputDir = resolve(outputOptions.dir, 'miniapps'); + const missing: string[] = []; for (const config of miniapps) { - const srcDir = join(miniappsPath, config.dirName) - const destDir = join(miniappsOutputDir, config.dirName) + const srcDir = join(miniappsPath, config.dirName); + const destDir = join(miniappsOutputDir, config.dirName); if (existsSync(srcDir)) { - mkdirSync(destDir, { recursive: true }) - cpSync(srcDir, destDir, { recursive: true }) - console.log(`[remote-miniapps] ✅ Copied ${config.dirName} to dist`) + mkdirSync(destDir, { recursive: true }); + cpSync(srcDir, destDir, { recursive: true }); + console.log(`[remote-miniapps] ✅ Copied ${config.dirName} to dist`); + + if (config.build?.rewriteBase) { + const basePath = + typeof config.build.rewriteBase === 'string' ? config.build.rewriteBase : `/miniapps/${config.dirName}/`; + rewriteHtmlBase(destDir, basePath); + } } else { - missing.push(config.dirName) + missing.push(config.dirName); } } if (missing.length > 0) { throw new Error( `[remote-miniapps] Build failed: missing miniapps in output: ${missing.join(', ')}. ` + - `Remote miniapps were not downloaded successfully.` - ) + `Remote miniapps were not downloaded successfully.`, + ); } }, async configureServer(server) { - if (miniapps.length === 0) return + if (miniapps.length === 0) return; - const miniappsPath = resolve(root, miniappsDir) + const miniappsPath = resolve(root, miniappsDir); for (const config of miniapps) { try { - await downloadAndExtract(config, miniappsPath, fetchOptions) + await downloadAndExtract(config, miniappsPath, fetchOptions); } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err) - console.warn(`[remote-miniapps] ⚠️ Failed to download ${config.dirName} (dev mode): ${errorMsg}`) - continue + const errorMsg = err instanceof Error ? err.message : String(err); + console.warn(`[remote-miniapps] ⚠️ Failed to download ${config.dirName} (dev mode): ${errorMsg}`); + continue; } } // 启动静态服务器为每个远程 miniapp for (const config of miniapps) { - const miniappDir = join(miniappsPath, config.dirName) - const manifestPath = join(miniappDir, 'manifest.json') + const miniappDir = join(miniappsPath, config.dirName); + const manifestPath = join(miniappDir, 'manifest.json'); if (!existsSync(manifestPath)) { - console.warn(`[remote-miniapps] ${config.dirName}: manifest.json not found, skipping`) - continue + console.warn(`[remote-miniapps] ${config.dirName}: manifest.json not found, skipping`); + continue; } - const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as MiniappManifest + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as MiniappManifest; // 启动静态服务器 - const { server: httpServer, port } = await startStaticServer(miniappDir) - const baseUrl = `http://localhost:${port}` + const { server: httpServer, port } = await startStaticServer(miniappDir); + const baseUrl = `http://localhost:${port}`; const serverInfo: RemoteMiniappServer = { id: manifest.id, @@ -178,31 +203,31 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug server: httpServer, baseUrl, manifest, - } + }; - servers.push(serverInfo) - globalRemoteServers.push(serverInfo) + servers.push(serverInfo); + globalRemoteServers.push(serverInfo); - console.log(`[remote-miniapps] ${manifest.name} (${manifest.id}) serving at ${baseUrl}`) + console.log(`[remote-miniapps] ${manifest.name} (${manifest.id}) serving at ${baseUrl}`); } // 清理服务器 const cleanup = async () => { for (const s of servers) { - await new Promise((resolve) => s.server.close(() => resolve())) + await new Promise((resolve) => s.server.close(() => resolve())); } - } + }; - server.httpServer?.on('close', cleanup) + server.httpServer?.on('close', cleanup); }, async closeBundle() { // 关闭所有静态服务器 for (const s of servers) { - await new Promise((resolve) => s.server.close(() => resolve())) + await new Promise((resolve) => s.server.close(() => resolve())); } }, - } + }; } // ==================== Helpers ==================== @@ -210,130 +235,150 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug async function downloadAndExtract( config: RemoteMiniappConfig, miniappsPath: string, - fetchOptions: FetchWithEtagOptions = {} + fetchOptions: FetchWithEtagOptions = {}, ): Promise { - const targetDir = join(miniappsPath, config.dirName) + const targetDir = join(miniappsPath, config.dirName); - console.log(`[remote-miniapps] Syncing ${config.dirName}...`) + console.log(`[remote-miniapps] Syncing ${config.dirName}...`); - const metadataBuffer = await fetchWithEtag(config.metadataUrl, fetchOptions) - const metadata = JSON.parse(metadataBuffer.toString('utf-8')) as RemoteMetadata + const metadataBuffer = await fetchWithEtag(config.metadataUrl, fetchOptions); + const metadata = JSON.parse(metadataBuffer.toString('utf-8')) as RemoteMetadata; - const localManifestPath = join(targetDir, 'manifest.json') + const localManifestPath = join(targetDir, 'manifest.json'); if (existsSync(localManifestPath)) { - const localManifest = JSON.parse(readFileSync(localManifestPath, 'utf-8')) as MiniappManifest & { _zipEtag?: string } + const localManifest = JSON.parse(readFileSync(localManifestPath, 'utf-8')) as MiniappManifest & { + _zipEtag?: string; + }; if (localManifest.version === metadata.version && localManifest._zipEtag) { - const baseUrl = config.metadataUrl.replace(/\/[^/]+$/, '') - const zipUrl = metadata.zipUrl.startsWith('.') - ? `${baseUrl}/${metadata.zipUrl.slice(2)}` - : metadata.zipUrl + const baseUrl = config.metadataUrl.replace(/\/[^/]+$/, ''); + const zipUrl = metadata.zipUrl.startsWith('.') ? `${baseUrl}/${metadata.zipUrl.slice(2)}` : metadata.zipUrl; try { - const headResponse = await fetch(zipUrl, { method: 'HEAD' }) - const remoteEtag = headResponse.headers.get('etag') || '' + const headResponse = await fetch(zipUrl, { method: 'HEAD' }); + const remoteEtag = headResponse.headers.get('etag') || ''; if (remoteEtag === localManifest._zipEtag) { - console.log(`[remote-miniapps] ${config.dirName} is up-to-date (v${metadata.version}, etag match)`) - return + console.log(`[remote-miniapps] ${config.dirName} is up-to-date (v${metadata.version}, etag match)`); + return; } - console.log(`[remote-miniapps] ${config.dirName} zip changed (etag: ${localManifest._zipEtag} -> ${remoteEtag})`) + console.log( + `[remote-miniapps] ${config.dirName} zip changed (etag: ${localManifest._zipEtag} -> ${remoteEtag})`, + ); } catch { // HEAD request failed, continue with download } } } - const baseUrl = config.metadataUrl.replace(/\/[^/]+$/, '') + const baseUrl = config.metadataUrl.replace(/\/[^/]+$/, ''); const manifestUrl = metadata.manifestUrl.startsWith('.') ? `${baseUrl}/${metadata.manifestUrl.slice(2)}` - : metadata.manifestUrl - const zipUrl = metadata.zipUrl.startsWith('.') - ? `${baseUrl}/${metadata.zipUrl.slice(2)}` - : metadata.zipUrl + : metadata.manifestUrl; + const zipUrl = metadata.zipUrl.startsWith('.') ? `${baseUrl}/${metadata.zipUrl.slice(2)}` : metadata.zipUrl; - const manifestBuffer = await fetchWithEtag(manifestUrl, fetchOptions) - const manifest = JSON.parse(manifestBuffer.toString('utf-8')) as MiniappManifest + const manifestBuffer = await fetchWithEtag(manifestUrl, fetchOptions); + const manifest = JSON.parse(manifestBuffer.toString('utf-8')) as MiniappManifest; - const zipHeadResponse = await fetch(zipUrl, { method: 'HEAD' }) - const zipEtag = zipHeadResponse.headers.get('etag') || '' - const zipBuffer = await fetchWithEtag(zipUrl, fetchOptions) + const zipHeadResponse = await fetch(zipUrl, { method: 'HEAD' }); + const zipEtag = zipHeadResponse.headers.get('etag') || ''; + const zipBuffer = await fetchWithEtag(zipUrl, fetchOptions); if (existsSync(targetDir)) { - rmSync(targetDir, { recursive: true }) + rmSync(targetDir, { recursive: true }); } - mkdirSync(targetDir, { recursive: true }) - - const JSZip = (await import('jszip')).default - const zip = await JSZip.loadAsync(zipBuffer) - for (const [relativePath, file] of Object.entries(zip.files) as [ - string, - JSZipType.JSZipObject, - ][]) { + mkdirSync(targetDir, { recursive: true }); + + const JSZip = (await import('jszip')).default; + const zip = await JSZip.loadAsync(zipBuffer); + for (const [relativePath, file] of Object.entries(zip.files) as [string, JSZipType.JSZipObject][]) { if (file.dir) { - mkdirSync(join(targetDir, relativePath), { recursive: true }) + mkdirSync(join(targetDir, relativePath), { recursive: true }); } else { - const content = await file.async('nodebuffer') - const filePath = join(targetDir, relativePath) - mkdirSync(join(targetDir, relativePath, '..'), { recursive: true }) - writeFileSync(filePath, content) + const content = await file.async('nodebuffer'); + const filePath = join(targetDir, relativePath); + mkdirSync(join(targetDir, relativePath, '..'), { recursive: true }); + writeFileSync(filePath, content); } } - const manifestWithDir = { ...manifest, dirName: config.dirName, _zipEtag: zipEtag } - writeFileSync(localManifestPath, JSON.stringify(manifestWithDir, null, 2)) + const manifestWithDir = { ...manifest, dirName: config.dirName, _zipEtag: zipEtag }; + writeFileSync(localManifestPath, JSON.stringify(manifestWithDir, null, 2)); + + console.log(`[remote-miniapps] ${config.dirName} updated to v${manifest.version} (etag: ${zipEtag})`); +} + +function rewriteHtmlBase(targetDir: string, basePath: string): void { + const indexPath = join(targetDir, 'index.html'); + if (!existsSync(indexPath)) { + console.warn(`[remote-miniapps] index.html not found in ${targetDir}, skipping base rewrite`); + return; + } + + let html = readFileSync(indexPath, 'utf-8'); + html = html.replace(/]*>/gi, ''); + + const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/`; + const baseTag = ``; + + if (html.includes('')) { + html = html.replace(//i, `\n ${baseTag}`); + } else if (html.includes('')) { + html = html.replace(//i, `\n ${baseTag}`); + } else { + html = html.replace(/]*>/i, `$&\n \n ${baseTag}\n `); + } - console.log(`[remote-miniapps] ${config.dirName} updated to v${manifest.version} (etag: ${zipEtag})`) + writeFileSync(indexPath, html); + console.log(`[remote-miniapps] Rewrote to "${normalizedBase}" in ${indexPath}`); } /** * 启动简单的静态文件服务器 */ -async function startStaticServer( - root: string -): Promise<{ server: ReturnType; port: number }> { - const sirv = (await import('sirv')).default - const handler = sirv(root, { dev: true, single: true }) +async function startStaticServer(root: string): Promise<{ server: ReturnType; port: number }> { + const sirv = (await import('sirv')).default; + const handler = sirv(root, { dev: true, single: true }); return new Promise((resolve, reject) => { const server = createServer((req, res) => { // CORS headers - res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { - res.writeHead(204) - res.end() - return + res.writeHead(204); + res.end(); + return; } handler(req, res, () => { - res.writeHead(404) - res.end('Not found') - }) - }) + res.writeHead(404); + res.end('Not found'); + }); + }); server.listen(0, () => { - const address = server.address() + const address = server.address(); if (address && typeof address === 'object') { - resolve({ server, port: address.port }) + resolve({ server, port: address.port }); } else { - reject(new Error('Failed to get server address')) + reject(new Error('Failed to get server address')); } - }) + }); - server.on('error', reject) - }) + server.on('error', reject); + }); } // ==================== 共享状态 ==================== /** 全局注册的远程 miniapp 服务器 */ -const globalRemoteServers: RemoteMiniappServer[] = [] +const globalRemoteServers: RemoteMiniappServer[] = []; /** * 获取远程 miniapps 的服务器信息 (供 ecosystem.json 生成使用) */ export function getRemoteMiniappServers(): RemoteMiniappServer[] { - return [...globalRemoteServers] + return [...globalRemoteServers]; } /** @@ -346,7 +391,7 @@ export function getRemoteMiniappsForEcosystem(): Array new URL(sc, s.baseUrl).href) ?? [], - })) + })); } -export default remoteMiniappsPlugin +export default remoteMiniappsPlugin; diff --git a/vite.config.ts b/vite.config.ts index 774538b41..f317b4822 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,43 +1,43 @@ -import { defineConfig, loadEnv } from 'vite' -import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' -import commonjs from 'vite-plugin-commonjs' -import mkcert from 'vite-plugin-mkcert' -import { networkInterfaces } from 'node:os' -import { resolve } from 'node:path' -import { mockDevToolsPlugin } from './scripts/vite-plugin-mock-devtools' -import { miniappsPlugin } from './scripts/vite-plugin-miniapps' -import { remoteMiniappsPlugin } from './scripts/vite-plugin-remote-miniapps' -import { buildCheckPlugin } from './scripts/vite-plugin-build-check' +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import commonjs from 'vite-plugin-commonjs'; +import mkcert from 'vite-plugin-mkcert'; +import { networkInterfaces } from 'node:os'; +import { resolve } from 'node:path'; +import { mockDevToolsPlugin } from './scripts/vite-plugin-mock-devtools'; +import { miniappsPlugin } from './scripts/vite-plugin-miniapps'; +import { remoteMiniappsPlugin } from './scripts/vite-plugin-remote-miniapps'; +import { buildCheckPlugin } from './scripts/vite-plugin-build-check'; function getPreferredLanIPv4(): string | undefined { - const ifaces = networkInterfaces() - const ips: string[] = [] + const ifaces = networkInterfaces(); + const ips: string[] = []; for (const entries of Object.values(ifaces)) { for (const entry of entries ?? []) { - if (entry.family !== 'IPv4' || entry.internal) continue - const ip = entry.address + if (entry.family !== 'IPv4' || entry.internal) continue; + const ip = entry.address; // Filter special/reserved ranges that confuse mobile debugging. - if (ip.startsWith('127.') || ip.startsWith('169.254.') || ip.startsWith('198.18.')) continue - if (ip === '0.0.0.0') continue - ips.push(ip) + if (ip.startsWith('127.') || ip.startsWith('169.254.') || ip.startsWith('198.18.')) continue; + if (ip === '0.0.0.0') continue; + ips.push(ip); } } const score = (ip: string) => { - if (ip.startsWith('192.168.')) return 3 - if (ip.startsWith('10.')) return 2 - if (/^172\.(1[6-9]|2\\d|3[0-1])\\./.test(ip)) return 1 - return 0 - } + if (ip.startsWith('192.168.')) return 3; + if (ip.startsWith('10.')) return 2; + if (/^172\.(1[6-9]|2\\d|3[0-1])\\./.test(ip)) return 1; + return 0; + }; - ips.sort((a, b) => score(b) - score(a)) - return ips[0] + ips.sort((a, b) => score(b) - score(a)); + return ips[0]; } export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), '') + const env = loadEnv(mode, process.cwd(), ''); /** * 服务实现选择(编译时) @@ -45,156 +45,162 @@ export default defineConfig(({ mode }) => { * - dweb: DWEB/Plaoc 平台 * - mock: 测试环境 */ - const SERVICE_IMPL = env.SERVICE_IMPL ?? process.env.SERVICE_IMPL ?? 'web' + const SERVICE_IMPL = env.SERVICE_IMPL ?? process.env.SERVICE_IMPL ?? 'web'; /** * Base URL 配置 * - 使用 './' 允许部署在任意子路径下 * - 例如: https://example.com/ 或 https://example.com/app/ */ - const BASE_URL = env.VITE_BASE_URL ?? process.env.VITE_BASE_URL ?? './' + const BASE_URL = env.VITE_BASE_URL ?? process.env.VITE_BASE_URL ?? './'; - const DEV_HOST = env.VITE_DEV_HOST ?? process.env.VITE_DEV_HOST ?? getPreferredLanIPv4() + const DEV_HOST = env.VITE_DEV_HOST ?? process.env.VITE_DEV_HOST ?? getPreferredLanIPv4(); - const tronGridApiKey = env.TRONGRID_API_KEY ?? process.env.TRONGRID_API_KEY ?? '' - const etherscanApiKey = env.ETHERSCAN_API_KEY ?? process.env.ETHERSCAN_API_KEY ?? '' + const tronGridApiKey = env.TRONGRID_API_KEY ?? process.env.TRONGRID_API_KEY ?? ''; + const etherscanApiKey = env.ETHERSCAN_API_KEY ?? process.env.ETHERSCAN_API_KEY ?? ''; return { - base: BASE_URL, - server: { - host: true, - // 手机上的“每隔几秒自动刷新”通常是 HMR WebSocket 连不上导致的。 - // 明确指定 wss + 局域网 IP,避免客户端默认连到 localhost(在手机上等于连自己)。 - hmr: DEV_HOST - ? { - protocol: 'wss', - host: DEV_HOST, - } - : undefined, - }, - plugins: [ - mkcert({ - // 默认 hosts 会包含 0.0.0.0 / 某些保留网段,iOS 上偶发会导致 wss 不稳定。 - // 这里收敛到“确切可访问”的 host 列表,减少证书/SAN 干扰。 - hosts: DEV_HOST ? ['localhost', '127.0.0.1', DEV_HOST] : undefined, - }), - commonjs({ - filter(id) { - // Transform .cjs files to ESM - if (id.includes('.cjs')) { - console.log('[commonjs] transforming:', id) - return true - } - return false - } - }), - react(), - tailwindcss(), - mockDevToolsPlugin(), - // 远程 miniapps (必须在 miniappsPlugin 之前,以便注册到全局状态) - remoteMiniappsPlugin({ - miniapps: [ - { - metadataUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/metadata.json', - dirName: 'rwa-hub', + base: BASE_URL, + server: { + host: true, + // 手机上的“每隔几秒自动刷新”通常是 HMR WebSocket 连不上导致的。 + // 明确指定 wss + 局域网 IP,避免客户端默认连到 localhost(在手机上等于连自己)。 + hmr: DEV_HOST + ? { + protocol: 'wss', + host: DEV_HOST, + } + : undefined, + }, + plugins: [ + mkcert({ + // 默认 hosts 会包含 0.0.0.0 / 某些保留网段,iOS 上偶发会导致 wss 不稳定。 + // 这里收敛到“确切可访问”的 host 列表,减少证书/SAN 干扰。 + hosts: DEV_HOST ? ['localhost', '127.0.0.1', DEV_HOST] : undefined, + }), + commonjs({ + filter(id) { + // Transform .cjs files to ESM + if (id.includes('.cjs')) { + console.log('[commonjs] transforming:', id); + return true; + } + return false; }, - ], - timeout: 60000, - retries: 3, - }), - miniappsPlugin(), - buildCheckPlugin(), - ], - resolve: { - alias: { - '@': resolve(__dirname, './src'), - - // ==================== Platform Services (编译时替换) ==================== - // 每个服务独立文件夹,通过 SERVICE_IMPL 环境变量选择实现 - '#biometric-impl': resolve(__dirname, `./src/services/biometric/${SERVICE_IMPL}.ts`), - '#clipboard-impl': resolve(__dirname, `./src/services/clipboard/${SERVICE_IMPL}.ts`), - '#toast-impl': resolve(__dirname, `./src/services/toast/${SERVICE_IMPL}.ts`), - '#haptics-impl': resolve(__dirname, `./src/services/haptics/${SERVICE_IMPL}.ts`), - '#storage-impl': resolve(__dirname, `./src/services/storage/${SERVICE_IMPL}.ts`), - '#camera-impl': resolve(__dirname, `./src/services/camera/${SERVICE_IMPL}.ts`), - '#authorize-impl': resolve(__dirname, `./src/services/authorize/${SERVICE_IMPL}.ts`), - '#currency-exchange-impl': resolve(__dirname, `./src/services/currency-exchange/${SERVICE_IMPL === 'dweb' ? 'web' : SERVICE_IMPL}.ts`), - '#staking-impl': resolve(__dirname, `./src/services/staking/${SERVICE_IMPL}.ts`), - '#transaction-impl': resolve(__dirname, `./src/services/transaction/${SERVICE_IMPL}.ts`), + }), + react(), + tailwindcss(), + mockDevToolsPlugin(), + // 远程 miniapps (必须在 miniappsPlugin 之前,以便注册到全局状态) + remoteMiniappsPlugin({ + miniapps: [ + { + metadataUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/metadata.json', + dirName: 'rwa-hub', + build: { + rewriteBase: true, + }, + }, + ], + timeout: 60000, + retries: 3, + }), + miniappsPlugin(), + buildCheckPlugin(), + ], + resolve: { + alias: { + '@': resolve(__dirname, './src'), - // Node.js polyfills - buffer: 'buffer/', - }, - }, - define: { - // 全局 Buffer 支持 - 'global': 'globalThis', - // Mock 模式标识(用于条件加载 MockDevTools) - '__MOCK_MODE__': JSON.stringify(SERVICE_IMPL === 'mock'), - // Dev 模式标识(用于显示开发版水印) - '__DEV_MODE__': JSON.stringify((env.VITE_DEV_MODE ?? process.env.VITE_DEV_MODE) === 'true'), - // API Keys 对象(用于动态读取环境变量) - '__API_KEYS__': JSON.stringify({ - TRONGRID_API_KEY: tronGridApiKey, - ETHERSCAN_API_KEY: etherscanApiKey, - }), - }, - optimizeDeps: { - include: ['buffer'], - // Force Vite to pre-bundle the CJS bundle file - esbuildOptions: { - loader: { - '.bundle.js': 'js', - '.cjs': 'js', + // ==================== Platform Services (编译时替换) ==================== + // 每个服务独立文件夹,通过 SERVICE_IMPL 环境变量选择实现 + '#biometric-impl': resolve(__dirname, `./src/services/biometric/${SERVICE_IMPL}.ts`), + '#clipboard-impl': resolve(__dirname, `./src/services/clipboard/${SERVICE_IMPL}.ts`), + '#toast-impl': resolve(__dirname, `./src/services/toast/${SERVICE_IMPL}.ts`), + '#haptics-impl': resolve(__dirname, `./src/services/haptics/${SERVICE_IMPL}.ts`), + '#storage-impl': resolve(__dirname, `./src/services/storage/${SERVICE_IMPL}.ts`), + '#camera-impl': resolve(__dirname, `./src/services/camera/${SERVICE_IMPL}.ts`), + '#authorize-impl': resolve(__dirname, `./src/services/authorize/${SERVICE_IMPL}.ts`), + '#currency-exchange-impl': resolve( + __dirname, + `./src/services/currency-exchange/${SERVICE_IMPL === 'dweb' ? 'web' : SERVICE_IMPL}.ts`, + ), + '#staking-impl': resolve(__dirname, `./src/services/staking/${SERVICE_IMPL}.ts`), + '#transaction-impl': resolve(__dirname, `./src/services/transaction/${SERVICE_IMPL}.ts`), + + // Node.js polyfills + buffer: 'buffer/', }, }, - }, - build: { - // 确保资源路径使用相对路径 - assetsDir: 'assets', - rollupOptions: { - input: { - main: resolve(__dirname, 'index.html'), - clear: resolve(__dirname, 'clear.html'), + define: { + // 全局 Buffer 支持 + global: 'globalThis', + // Mock 模式标识(用于条件加载 MockDevTools) + __MOCK_MODE__: JSON.stringify(SERVICE_IMPL === 'mock'), + // Dev 模式标识(用于显示开发版水印) + __DEV_MODE__: JSON.stringify((env.VITE_DEV_MODE ?? process.env.VITE_DEV_MODE) === 'true'), + // API Keys 对象(用于动态读取环境变量) + __API_KEYS__: JSON.stringify({ + TRONGRID_API_KEY: tronGridApiKey, + ETHERSCAN_API_KEY: etherscanApiKey, + }), + }, + optimizeDeps: { + include: ['buffer'], + // Force Vite to pre-bundle the CJS bundle file + esbuildOptions: { + loader: { + '.bundle.js': 'js', + '.cjs': 'js', + }, }, - output: { - // 使用 hash 命名避免缓存问题 - entryFileNames: 'assets/[name]-[hash].js', - chunkFileNames: 'assets/[name]-[hash].js', - assetFileNames: 'assets/[name]-[hash].[ext]', - // 手动分块,减少主 chunk 体积 - manualChunks(id) { - // React 核心 - if (id.includes('node_modules/react/') || id.includes('node_modules/react-dom/')) { - return 'react-vendor' - } - // TanStack - if (id.includes('node_modules/@tanstack/')) { - return 'tanstack' - } - // Radix UI - if (id.includes('node_modules/@radix-ui/')) { - return 'radix' - } - // 动画 - if (id.includes('node_modules/motion/') || id.includes('node_modules/framer-motion/')) { - return 'motion' - } - // i18n - if (id.includes('node_modules/i18next') || id.includes('node_modules/react-i18next')) { - return 'i18n' - } - // 加密库 - 最大的依赖 - if (id.includes('node_modules/@noble/') || id.includes('node_modules/@scure/')) { - return 'crypto' - } - // BioForest 链库 - if (id.includes('node_modules/@bnqkl/')) { - return 'bioforest' - } + }, + build: { + // 确保资源路径使用相对路径 + assetsDir: 'assets', + rollupOptions: { + input: { + main: resolve(__dirname, 'index.html'), + clear: resolve(__dirname, 'clear.html'), + }, + output: { + // 使用 hash 命名避免缓存问题 + entryFileNames: 'assets/[name]-[hash].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash].[ext]', + // 手动分块,减少主 chunk 体积 + manualChunks(id) { + // React 核心 + if (id.includes('node_modules/react/') || id.includes('node_modules/react-dom/')) { + return 'react-vendor'; + } + // TanStack + if (id.includes('node_modules/@tanstack/')) { + return 'tanstack'; + } + // Radix UI + if (id.includes('node_modules/@radix-ui/')) { + return 'radix'; + } + // 动画 + if (id.includes('node_modules/motion/') || id.includes('node_modules/framer-motion/')) { + return 'motion'; + } + // i18n + if (id.includes('node_modules/i18next') || id.includes('node_modules/react-i18next')) { + return 'i18n'; + } + // 加密库 - 最大的依赖 + if (id.includes('node_modules/@noble/') || id.includes('node_modules/@scure/')) { + return 'crypto'; + } + // BioForest 链库 + if (id.includes('node_modules/@bnqkl/')) { + return 'bioforest'; + } + }, }, }, }, - }, -} -}) + }; +});