diff --git a/scripts/dev.ts b/scripts/dev.ts index 4eabe2b10..e0163dab2 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -1,549 +1,194 @@ #!/usr/bin/env bun -import type { Subprocess, Terminal } from 'bun'; - -interface TaskConfig { - name: string; - command: string[]; - cwd?: string; - port?: number; - description?: string; - autoStart?: boolean; -} - -interface TaskState { - config: TaskConfig; - process: Subprocess | null; - terminal: Terminal | null; - output: string[]; - status: 'stopped' | 'starting' | 'running' | 'error'; - stoppedByUser: boolean; -} - -const TASKS: TaskConfig[] = [ - { name: 'vite', command: ['pnpm', 'dev:vite'], port: 5173, description: 'Vite dev server', autoStart: true }, - { - name: 'tsc', - command: ['pnpm', 'tsc', '--watch', '--noEmit', '--preserveWatchOutput'], - description: 'TypeScript watch', - autoStart: true, - }, - { name: 'i18n', command: ['bun', 'scripts/i18n-types-watch.ts'], description: 'i18n types watcher', autoStart: true }, - { name: 'test', command: ['pnpm', 'vitest', '--watch'], description: 'Vitest watch', autoStart: false }, - { name: 'storybook', command: ['pnpm', 'storybook'], port: 6006, description: 'Storybook dev', autoStart: false }, - { name: 'docs', command: ['pnpm', 'docs:dev'], port: 5174, description: 'VitePress docs', autoStart: false }, - { name: 'mock', command: ['pnpm', 'dev:mock'], port: 5174, description: 'Vite mock mode', autoStart: false }, - { name: 'lint', command: ['pnpm', 'lint'], description: 'Oxlint check', autoStart: false }, - { name: 'i18n:check', command: ['pnpm', 'i18n:run'], description: 'i18n validation', autoStart: false }, - { name: 'theme:check', command: ['pnpm', 'theme:run'], description: 'Theme check', autoStart: false }, - { name: 'build', command: ['pnpm', 'build'], description: 'Vite build', autoStart: false }, - { name: 'e2e:audit', command: ['pnpm', 'e2e:audit:run'], description: 'E2E audit', autoStart: false }, -]; - -const MAX_OUTPUT_LINES = 500; - -const CURSOR_CONTROL_REGEX = - /\x1b\[[0-9;]*[HJKfsu]|\x1b\[[\?]?[0-9;]*[hl]|\x1b\[\d*[ABCD]|\x1b\[s|\x1b\[u|\x1b7|\x1b8|\r/g; - -function stripCursorControls(text: string): string { - return text.replace(CURSOR_CONTROL_REGEX, ''); -} - -class TaskManager { - private tasks: Map = new Map(); - private currentTab = 0; - private selectedIndex = 0; - private rootDir: string; - private scrollOffset = 0; - private focusMode = false; - private inputBuffer = ''; - - constructor() { - this.rootDir = process.cwd(); - for (const config of TASKS) { - this.tasks.set(config.name, { - config, - process: null, - terminal: null, - output: [], - status: 'stopped', - stoppedByUser: false, - }); - } - } - - private getTaskList(): TaskState[] { - return Array.from(this.tasks.values()); - } - - async startTask(name: string): Promise { - const task = this.tasks.get(name); - if (!task || task.process) return; - - task.status = 'starting'; - task.output = []; - task.stoppedByUser = false; - this.render(); - - const cwd = task.config.cwd ? `${this.rootDir}/${task.config.cwd}` : this.rootDir; - const { columns, rows } = process.stdout; - - try { - const terminal = new Bun.Terminal({ - cols: columns, - rows: rows - 6, - data: (_term, data) => { - const text = new TextDecoder().decode(data); - - if (this.focusMode && this.currentTab === this.getTaskIndex(task) + 1) { - process.stdout.write(data); - } - - this.appendOutput(task, text); - - if (!this.focusMode) { - this.render(); - } - }, - }); +import { commands } from './dev/commands'; +import { ProcessManager } from './dev/process-manager'; +import { ControlPanel } from './dev/control-panel'; +import type { TabState } from './dev/types'; - const proc = Bun.spawn(task.config.command, { - cwd, - terminal, - env: { ...process.env, FORCE_COLOR: '1', TERM: 'xterm-256color' }, - }); +const processManager = new ProcessManager(); +processManager.setCommands(commands); - task.process = proc; - task.terminal = terminal; - task.status = 'running'; - - proc.exited.then((code) => { - if (!task.stoppedByUser) { - task.status = code === 0 ? 'stopped' : 'error'; - this.appendOutput(task, `\n[Process exited with code ${code}]`); - } - task.process = null; - task.terminal?.close(); - task.terminal = null; - - if (this.focusMode && this.currentTab === this.getTaskIndex(task) + 1) { - this.focusMode = false; - process.stdout.write('\x1b[?25l'); - } - this.render(); - }); - } catch (e) { - task.status = 'error'; - this.appendOutput(task, `[Error starting process: ${e}]`); - } - this.render(); - } +const tabState: TabState = { activeTab: 0, selectedCommand: 0 }; - private getTaskIndex(task: TaskState): number { - return this.getTaskList().indexOf(task); - } +const controlPanel = new ControlPanel(commands, (id) => processManager.getState(id)); - private appendOutput(task: TaskState, text: string): void { - const cleanText = stripCursorControls(text); - const lines = cleanText.split('\n'); - for (const line of lines) { - if (line || task.output.length === 0 || task.output[task.output.length - 1] !== '') { - task.output.push(line); - } - } - while (task.output.length > MAX_OUTPUT_LINES) { - task.output.shift(); - } - } +function getRunningCommandAtTabIndex(tabIndex: number) { + const runningCommands = controlPanel.getRunningCommands(); + return runningCommands[tabIndex - 1]; +} - async stopTask(name: string): Promise { - const task = this.tasks.get(name); - if (!task?.process) return; - - task.stoppedByUser = true; - task.process.kill(); - task.process = null; - task.terminal?.close(); - task.terminal = null; - task.status = 'stopped'; - this.appendOutput(task, '\n[Process stopped by user]'); - this.render(); - } +function getMaxTabIndex(): number { + return controlPanel.getRunningCommands().length; +} - async restartTask(name: string): Promise { - await this.stopTask(name); - await new Promise((r) => setTimeout(r, 500)); - await this.startTask(name); +function render() { + if (tabState.activeTab === 0) { + console.clear(); + console.log(controlPanel.render(tabState)); } +} - private clearScreen(): void { - process.stdout.write('\x1b[2J\x1b[H'); +function renderProcessOutput(commandId: string) { + const state = processManager.getState(commandId); + if (state) { + console.clear(); + console.log(state.buffer.join('')); } +} - private moveCursor(row: number, col: number): void { - process.stdout.write(`\x1b[${row};${col}H`); +processManager.onOutput = (commandId, text) => { + const runningCommands = controlPanel.getRunningCommands(); + const cmdIndex = runningCommands.findIndex((c) => c.id === commandId); + if (tabState.activeTab === cmdIndex + 1) { + process.stdout.write(text); } +}; - private getStatusIcon(status: TaskState['status']): string { - switch (status) { - case 'stopped': - return '⚫'; - case 'starting': - return '🟡'; - case 'running': - return '🟢'; - case 'error': - return '🔴'; +async function handleKeypress(key: string): Promise { + if (key === '\x1b[1;3D') { + tabState.activeTab = Math.max(0, tabState.activeTab - 1); + if (tabState.activeTab === 0) { + render(); + } else { + const cmd = getRunningCommandAtTabIndex(tabState.activeTab); + if (cmd) renderProcessOutput(cmd.id); } + return true; } - private getTabName(task: TaskState): string { - if (task.process) { - return `${task.process.pid}:${task.config.name}`; + if (key === '\x1b[1;3C') { + const maxTab = getMaxTabIndex(); + tabState.activeTab = Math.min(maxTab, tabState.activeTab + 1); + if (tabState.activeTab === 0) { + render(); + } else { + const cmd = getRunningCommandAtTabIndex(tabState.activeTab); + if (cmd) renderProcessOutput(cmd.id); } - return task.config.name; + return true; } - private getHelpLine(): string { - const taskList = this.getTaskList(); - - if (this.focusMode) { - return '\x1b[90mCtrl+] 退出聚焦模式\x1b[0m'; + if (tabState.activeTab === 0) { + if (key === '\x1b[A') { + tabState.selectedCommand = Math.max(0, tabState.selectedCommand - 1); + render(); + return true; } - if (this.currentTab === 0) { - const task = taskList[this.selectedIndex]; - if (task?.process) { - return '\x1b[90mOption+←/→ 切换Tab | ↑/↓ 选择 | S 停止 | R 重启 | A 全部启动 | X 全部停止 | Q 退出\x1b[0m'; - } - return '\x1b[90mOption+←/→ 切换Tab | ↑/↓ 选择 | Enter 启动 | A 全部启动 | Q 退出\x1b[0m'; + if (key === '\x1b[B') { + tabState.selectedCommand = Math.min(commands.length - 1, tabState.selectedCommand + 1); + render(); + return true; } - const task = taskList[this.currentTab - 1]; - if (task?.process) { - return '\x1b[90mOption+←/→ 切换Tab | ↑/↓/PgUp/PgDn 滚动 | Enter 聚焦 | S 停止 | R 重启 | Q 退出\x1b[0m'; + if (key === '\r') { + const cmd = commands[tabState.selectedCommand]; + await processManager.start(cmd); + render(); + return true; } - return '\x1b[90mOption+←/→ 切换Tab | Enter 启动 | Q 退出\x1b[0m'; - } - render(): void { - if (this.focusMode) { - return; + if (key === 's' || key === 'S') { + const cmd = commands[tabState.selectedCommand]; + await processManager.stop(cmd.id); + render(); + return true; } - const { columns, rows } = process.stdout; - this.clearScreen(); - - this.moveCursor(1, 1); - const taskList = this.getTaskList(); - let tabLine = '\x1b[48;5;236m'; - - if (this.currentTab === 0) { - tabLine += '\x1b[1;37;44m [Control] \x1b[0;48;5;236m'; - } else { - tabLine += '\x1b[37m [Control] '; + if (key === 'r' || key === 'R') { + const cmd = commands[tabState.selectedCommand]; + await processManager.restart(cmd); + render(); + return true; } - for (let i = 0; i < taskList.length; i++) { - const task = taskList[i]; - const tabName = this.getTabName(task); - const icon = this.getStatusIcon(task.status); - - if (this.currentTab === i + 1) { - tabLine += `\x1b[1;37;44m ${icon} ${tabName} \x1b[0;48;5;236m`; - } else { - tabLine += `\x1b[37m ${icon} ${tabName} `; - } + if (key === 'q' || key === 'Q' || key === '\x03') { + return false; + } + } else { + if (key === 'q' || key === 'Q') { + tabState.activeTab = 0; + render(); + return true; } - tabLine += '\x1b[0m'; - process.stdout.write(tabLine.slice(0, columns * 2) + '\n'); - - this.moveCursor(2, 1); - process.stdout.write(this.getHelpLine() + '\n'); - process.stdout.write('─'.repeat(columns) + '\n'); - - const contentRows = rows - 4; - if (this.currentTab === 0) { - this.renderControlPanel(contentRows); - } else { - const task = taskList[this.currentTab - 1]; - this.renderTaskOutput(task, contentRows); + if (key === '\x03') { + return false; } } - private renderControlPanel(availableRows: number): void { - const taskList = this.getTaskList(); - - for (let i = 0; i < Math.min(taskList.length, availableRows); i++) { - const task = taskList[i]; - const icon = this.getStatusIcon(task.status); - const selected = i === this.selectedIndex; - const prefix = selected ? '\x1b[1;36m▸ ' : ' '; - const suffix = selected ? '\x1b[0m' : ''; - - const port = task.config.port ? `http://localhost:${task.config.port}/` : ''; - const pid = task.process ? `[PID: ${task.process.pid}]` : ''; - const desc = task.config.description || ''; + return true; +} - process.stdout.write( - `${prefix}${icon} ${task.config.name.padEnd(15)} ${task.status.padEnd(10)} ${port.padEnd(28)} ${pid.padEnd(14)} ${desc}${suffix}\n`, - ); - } +async function cleanup() { + console.log('\n\x1b[33mStopping all processes...\x1b[0m'); + await processManager.stopAll(); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); } +} - private renderTaskOutput(task: TaskState, availableRows: number): void { - const { columns } = process.stdout; - const totalLines = task.output.length; - const maxScroll = Math.max(0, totalLines - availableRows); +async function main() { + const isHeadless = !process.stdin.isTTY || process.env.CI === 'true'; - if (this.scrollOffset > maxScroll) { - this.scrollOffset = maxScroll; - } + console.clear(); + console.log('\x1b[36mStarting Dev Runner...\\x1b[0m\n'); - const startLine = Math.max(0, totalLines - availableRows - this.scrollOffset); - const endLine = startLine + availableRows; - const lines = task.output.slice(startLine, endLine); - - const scrollbarWidth = 1; - const contentWidth = columns - scrollbarWidth - 1; - - for (let i = 0; i < availableRows; i++) { - const line = lines[i] || ''; - const truncatedLine = line.slice(0, contentWidth); - - let scrollChar = ' '; - if (totalLines > availableRows) { - const scrollbarHeight = Math.max(1, Math.floor((availableRows * availableRows) / totalLines)); - const scrollbarPos = Math.floor( - ((totalLines - availableRows - this.scrollOffset) / maxScroll) * (availableRows - scrollbarHeight), - ); - if (i >= scrollbarPos && i < scrollbarPos + scrollbarHeight) { - scrollChar = '█'; - } else { - scrollChar = '░'; - } - } - - process.stdout.write(truncatedLine + '\x1b[' + columns + 'G\x1b[90m' + scrollChar + '\x1b[0m\n'); + for (const cmd of commands) { + if (cmd.autoStart) { + console.log(`Starting ${cmd.name}...`); + await processManager.start(cmd); } } - private handleKeypress(key: Buffer): void { - const keyStr = key.toString(); - const taskList = this.getTaskList(); - const { rows } = process.stdout; - const inputHeight = this.focusMode ? 2 : 0; - const availableRows = rows - 4 - inputHeight; - - if (this.focusMode) { - const task = taskList[this.currentTab - 1]; - - // Escape sequence: Ctrl+] to exit focus mode - if (keyStr === '\x1d') { - this.focusMode = false; - process.stdout.write('\x1b[?25l'); - this.render(); - return; - } - - // Pass all input directly to terminal - if (task?.terminal) { - task.terminal.write(key); - } - return; - } + if (isHeadless) { + console.log('\x1b[33mRunning in headless mode (no TTY). Press Ctrl+C to stop.\x1b[0m\n'); - if (keyStr === '\x1b[1;3D' || keyStr === '\x1bb') { - this.currentTab = Math.max(0, this.currentTab - 1); - this.scrollOffset = 0; - this.render(); - return; - } - if (keyStr === '\x1b[1;3C' || keyStr === '\x1bf') { - this.currentTab = Math.min(taskList.length, this.currentTab + 1); - this.scrollOffset = 0; - this.render(); - return; - } + processManager.onOutput = (_commandId, text) => { + process.stdout.write(text); + }; - if (this.currentTab > 0) { - const task = taskList[this.currentTab - 1]; - const maxScroll = Math.max(0, task.output.length - availableRows); - - if (keyStr === '\x1b[5~') { - this.scrollOffset = Math.min(maxScroll, this.scrollOffset + availableRows); - this.render(); - return; - } - if (keyStr === '\x1b[6~') { - this.scrollOffset = Math.max(0, this.scrollOffset - availableRows); - this.render(); - return; - } - if (keyStr === '\x1b[H' || keyStr === '\x1b[1~') { - this.scrollOffset = maxScroll; - this.render(); - return; - } - if (keyStr === '\x1b[F' || keyStr === '\x1b[4~') { - this.scrollOffset = 0; - this.render(); - return; - } - if (keyStr === '\x1b[A') { - this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1); - this.render(); - return; - } - if (keyStr === '\x1b[B') { - this.scrollOffset = Math.max(0, this.scrollOffset - 1); - this.render(); - return; - } - - if (keyStr === '\r' || keyStr === '\n') { - if (task.process) { - this.focusMode = true; - this.inputBuffer = ''; - this.render(); - } else { - this.startTask(task.config.name); - } - return; - } - - if (keyStr.toLowerCase() === 's' && task.process) { - this.stopTask(task.config.name); - return; - } - if (keyStr.toLowerCase() === 'r' && task.process) { - this.restartTask(task.config.name); - return; - } - if (keyStr.toLowerCase() === 'q' || keyStr === '\x03') { - this.shutdown(); - return; - } - return; - } - - if (keyStr === '\x1b[A') { - if (this.currentTab === 0) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - } - this.render(); - return; - } - if (keyStr === '\x1b[B') { - if (this.currentTab === 0) { - this.selectedIndex = Math.min(taskList.length - 1, this.selectedIndex + 1); - } - this.render(); - return; - } - - const selectedTask = taskList[this.selectedIndex]; - - switch (keyStr.toLowerCase()) { - case '\r': - case '\n': - if (this.currentTab === 0 && selectedTask) { - this.startTask(selectedTask.config.name); - } - break; - case 's': - if (this.currentTab === 0 && selectedTask) { - this.stopTask(selectedTask.config.name); - } - break; - case 'r': - if (this.currentTab === 0 && selectedTask) { - this.restartTask(selectedTask.config.name); - } - break; - case 'q': - case '\x03': - this.shutdown(); - break; - case 'a': - if (this.currentTab === 0) { - for (const task of taskList) { - if (!task.process) { - this.startTask(task.config.name); - } - } - } - break; - case 'x': - if (this.currentTab === 0) { - for (const task of taskList) { - if (task.process) { - this.stopTask(task.config.name); - } - } - } - break; - } - } + await new Promise((resolve) => { + process.on('SIGINT', async () => { + await cleanup(); + resolve(); + }); + process.on('SIGTERM', async () => { + await cleanup(); + resolve(); + }); + }); - private shutdown(): void { - for (const task of this.tasks.values()) { - if (task.process) { - task.process.kill(); - } - } - process.stdout.write('\x1b[?25h'); process.exit(0); } - async run(): Promise { - if (!process.stdin.isTTY) { - this.printHelp(); - process.exit(0); - } - - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdout.write('\x1b[?25l'); - - process.stdout.on('resize', () => this.render()); + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding('utf8'); - process.stdin.on('data', (key: Buffer) => { - this.handleKeypress(key); - }); + render(); - process.on('SIGINT', () => this.shutdown()); - process.on('SIGTERM', () => this.shutdown()); - - for (const task of this.tasks.values()) { - if (task.config.autoStart) { - await this.startTask(task.config.name); - } + process.stdin.on('data', async (data: string) => { + const shouldContinue = await handleKeypress(data); + if (!shouldContinue) { + await cleanup(); + process.exit(0); } + }); - this.render(); - } + process.on('SIGINT', async () => { + await cleanup(); + process.exit(0); + }); - private printHelp(): void { - console.log(` -\x1b[36mDev Runner\x1b[0m - Development process manager - -\x1b[33mUSAGE:\x1b[0m - pnpm dev Interactive mode (requires TTY) - -\x1b[33mKEYS:\x1b[0m - Option+←/→ Switch tabs - ↑/↓ Navigate commands - Enter Start selected command - s Stop selected command - r Restart selected command - a Start all - x Stop all - q/Ctrl+C Quit -`); - } + process.on('SIGTERM', async () => { + await cleanup(); + process.exit(0); + }); } -const manager = new TaskManager(); -manager.run().catch(console.error); +main().catch(async (err) => { + console.error('Fatal error:', err); + await cleanup(); + process.exit(1); +}); diff --git a/scripts/vite-plugin-miniapps.ts b/scripts/vite-plugin-miniapps.ts index 7285b79bb..d95a39bbd 100644 --- a/scripts/vite-plugin-miniapps.ts +++ b/scripts/vite-plugin-miniapps.ts @@ -12,14 +12,24 @@ import { readdirSync, existsSync, readFileSync, writeFileSync, mkdirSync, cpSync import detectPort from 'detect-port'; import https from 'node:https'; import { getRemoteMiniappsForEcosystem } from './vite-plugin-remote-miniapps'; +import type { WujieRuntimeConfig } from '../src/services/ecosystem/types'; // ==================== Types ==================== type MiniappRuntime = 'iframe' | 'wujie'; interface MiniappRuntimeConfig { - server?: MiniappRuntime; - build?: MiniappRuntime; + server?: MiniappRuntime | { runtime: MiniappRuntime; wujieConfig?: WujieRuntimeConfig }; + build?: MiniappRuntime | { runtime: MiniappRuntime; wujieConfig?: WujieRuntimeConfig }; +} + +function parseRuntimeConfig(config?: MiniappRuntime | { runtime: MiniappRuntime; wujieConfig?: WujieRuntimeConfig }): { + runtime: MiniappRuntime; + wujieConfig?: WujieRuntimeConfig; +} { + if (!config) return { runtime: 'iframe' }; + if (typeof config === 'string') return { runtime: config }; + return { runtime: config.runtime, wujieConfig: config.wujieConfig }; } interface MiniappManifest { @@ -133,7 +143,7 @@ export function miniappsPlugin(options: MiniappsPluginOptions = {}): Plugin { try { const manifest = await fetchManifest(s.port); const appConfig = apps[manifest.id]; - const runtime = appConfig?.server ?? 'iframe'; + const { runtime, wujieConfig } = parseRuntimeConfig(appConfig?.server); return { ...manifest, dirName: s.dirName, @@ -141,6 +151,7 @@ export function miniappsPlugin(options: MiniappsPluginOptions = {}): Plugin { url: new URL('/', s.baseUrl).href, screenshots: manifest.screenshots.map((sc) => new URL(sc, s.baseUrl).href), runtime, + wujieConfig, }; } catch (e) { console.error(`[miniapps] Failed to fetch manifest for ${s.id}:`, e); @@ -280,7 +291,7 @@ function generateEcosystemDataForBuild( const shortId = manifest.id.split('.').pop() || ''; const screenshots = scanScreenshots(root, shortId); const appConfig = apps[manifest.id]; - const runtime = appConfig?.build ?? 'iframe'; + const { runtime, wujieConfig } = parseRuntimeConfig(appConfig?.build); const { dirName, ...rest } = manifest; return { @@ -290,6 +301,7 @@ function generateEcosystemDataForBuild( icon: `./${dirName}/icon.svg`, screenshots: screenshots.map((s) => `./${dirName}/${s}`), runtime, + wujieConfig, }; }); diff --git a/scripts/vite-plugin-remote-miniapps.ts b/scripts/vite-plugin-remote-miniapps.ts index 5ad40ff8f..995b1624a 100644 --- a/scripts/vite-plugin-remote-miniapps.ts +++ b/scripts/vite-plugin-remote-miniapps.ts @@ -14,6 +14,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, rmSync } fr import { createServer } from 'node:http'; import type JSZipType from 'jszip'; import { fetchWithEtag, type FetchWithEtagOptions } from './utils/fetch-with-etag'; +import type { WujieRuntimeConfig } from '../src/services/ecosystem/types'; // ==================== Types ==================== @@ -21,17 +22,20 @@ type MiniappRuntime = 'iframe' | 'wujie'; interface MiniappServerConfig { runtime?: MiniappRuntime; + wujieConfig?: WujieRuntimeConfig; } interface MiniappBuildConfig { runtime?: MiniappRuntime; + wujieConfig?: WujieRuntimeConfig; /** - * 重写 index.html 的 标签 + * 插入 标签重写 HTML 路径 + * 仅在 runtime: 'iframe' 时有意义 * - true: 自动推断为 '/miniapps/{dirName}/' * - string: 自定义路径 - * - undefined/false: 不重写 + * - undefined/false: 不插入 */ - rewriteBase?: boolean | string; + injectBaseTag?: boolean | string; } interface RemoteMiniappConfig { @@ -148,11 +152,22 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug cpSync(srcDir, destDir, { recursive: true }); console.log(`[remote-miniapps] ✅ Copied ${config.dirName} to dist`); - if (config.build?.rewriteBase) { + if (config.build?.injectBaseTag) { const basePath = - typeof config.build.rewriteBase === 'string' ? config.build.rewriteBase : `/miniapps/${config.dirName}/`; + typeof config.build.injectBaseTag === 'string' + ? config.build.injectBaseTag + : `/miniapps/${config.dirName}/`; rewriteHtmlBase(destDir, basePath); } + + if (config.build?.wujieConfig) { + const manifestPath = join(destDir, 'manifest.json'); + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); + manifest.wujieConfig = config.build.wujieConfig; + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + } + } } else { missing.push(config.dirName); } @@ -391,7 +406,7 @@ export function getRemoteMiniappServers(): RemoteMiniappServer[] { * 获取远程 miniapps 用于 ecosystem.json 的数据 */ export function getRemoteMiniappsForEcosystem(): Array< - MiniappManifest & { url: string; runtime?: 'iframe' | 'wujie' } + MiniappManifest & { url: string; runtime?: 'iframe' | 'wujie'; wujieConfig?: WujieRuntimeConfig } > { return globalRemoteServers.map((s) => ({ ...s.manifest, @@ -400,6 +415,7 @@ export function getRemoteMiniappsForEcosystem(): Array< url: new URL('/', s.baseUrl).href, screenshots: s.manifest.screenshots?.map((sc) => new URL(sc, s.baseUrl).href) ?? [], runtime: s.config.server?.runtime ?? 'iframe', + wujieConfig: s.config.server?.wujieConfig, })); } diff --git a/src/services/ecosystem/types.ts b/src/services/ecosystem/types.ts index 25062f965..1102adbff 100644 --- a/src/services/ecosystem/types.ts +++ b/src/services/ecosystem/types.ts @@ -227,6 +227,15 @@ export interface MiniappManifest { sourceName?: string; /** 运行时容器类型(由宿主注入,默认 'iframe') */ runtime?: 'iframe' | 'wujie'; + /** wujie 运行时配置 (仅当 runtime: 'wujie' 时有效) */ + wujieConfig?: WujieRuntimeConfig; +} + +export interface WujieRuntimeConfig { + rewriteAbsolutePaths?: boolean; + alive?: boolean; + fiber?: boolean; + sync?: boolean; } /** Ecosystem source - JSON 文件格式 */ diff --git a/src/services/miniapp-runtime/container/types.ts b/src/services/miniapp-runtime/container/types.ts index d52d33655..9bba289b7 100644 --- a/src/services/miniapp-runtime/container/types.ts +++ b/src/services/miniapp-runtime/container/types.ts @@ -1,3 +1,5 @@ +import type { WujieRuntimeConfig } from '@/services/ecosystem/types'; + export type ContainerType = 'iframe' | 'wujie'; export interface ContainerHandle { @@ -16,6 +18,7 @@ export interface ContainerCreateOptions { mountTarget: HTMLElement; contextParams?: Record; onLoad?: () => void; + wujieConfig?: WujieRuntimeConfig; } export interface ContainerManager { diff --git a/src/services/miniapp-runtime/container/wujie-container.ts b/src/services/miniapp-runtime/container/wujie-container.ts index 52a809067..063055466 100644 --- a/src/services/miniapp-runtime/container/wujie-container.ts +++ b/src/services/miniapp-runtime/container/wujie-container.ts @@ -1,11 +1,6 @@ import { startApp, destroyApp, bus } from 'wujie'; import type { ContainerManager, ContainerHandle, ContainerCreateOptions } from './types'; -/** - * Patch document.adoptedStyleSheets to proxy to shadowRoot.adoptedStyleSheets - * Must be called after shadowRoot is created (e.g., in afterMount) - * TODO: Submit PR to wujie upstream and remove this workaround - */ function patchAdoptedStyleSheets(appId: string) { const iframe = document.querySelector(`iframe[name="${appId}"]`); if (!iframe?.contentWindow) return; @@ -43,6 +38,52 @@ function patchAdoptedStyleSheets(appId: string) { } } +function rewriteHtmlAbsolutePaths(html: string, baseUrl: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + const elements = doc.querySelectorAll('[href], [src]'); + elements.forEach((el) => { + if (el.hasAttribute('href')) { + const original = el.getAttribute('href')!; + if (original.startsWith('/') && !original.startsWith('//')) { + el.setAttribute('href', new URL(original.slice(1), baseUrl).href); + } + } + if (el.hasAttribute('src')) { + const original = el.getAttribute('src')!; + if (original.startsWith('/') && !original.startsWith('//')) { + el.setAttribute('src', new URL(original.slice(1), baseUrl).href); + } + } + }); + + return doc.documentElement.outerHTML; +} + +function createAbsolutePathRewriter(baseUrl: string) { + const parsedUrl = new URL(baseUrl); + const origin = parsedUrl.origin + parsedUrl.pathname.replace(/\/$/, ''); + + return { + fetch: (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const parsedReqUrl = new URL(req.url); + + if (parsedReqUrl.origin === window.location.origin) { + const rewrittenUrl = `${origin}${parsedReqUrl.pathname}${parsedReqUrl.search}${parsedReqUrl.hash}`; + return window.fetch(rewrittenUrl, init); + } + return window.fetch(req); + }, + plugins: [ + { + htmlLoader: (html: string) => rewriteHtmlAbsolutePaths(html, baseUrl), + }, + ], + }; +} + class WujieContainerHandle implements ContainerHandle { readonly type = 'wujie' as const; readonly element: HTMLElement; @@ -87,11 +128,10 @@ export class WujieContainerManager implements ContainerManager { readonly type = 'wujie' as const; async create(options: ContainerCreateOptions): Promise { - const { appId, url, mountTarget, contextParams, onLoad } = options; + const { appId, url, mountTarget, contextParams, onLoad, wujieConfig } = options; const container = document.createElement('div'); container.id = `miniapp-wujie-${appId}`; - // container.style.cssText = 'width: 100%; height: 100%;'; container.className = 'size-full *:size-full *:block *:overflow-auto'; mountTarget.appendChild(container); @@ -103,17 +143,25 @@ export class WujieContainerManager implements ContainerManager { }); } - await startApp({ + const startAppOptions: Parameters[0] = { name: appId, url: urlWithParams.toString(), el: container, - alive: true, - fiber: true, - sync: false, + alive: wujieConfig?.alive ?? true, + fiber: wujieConfig?.fiber ?? true, + sync: wujieConfig?.sync ?? false, afterMount: () => { onLoad?.(); }, - }); + }; + + if (wujieConfig?.rewriteAbsolutePaths) { + const rewriter = createAbsolutePathRewriter(urlWithParams.toString()); + startAppOptions.fetch = rewriter.fetch; + startAppOptions.plugins = rewriter.plugins; + } + + await startApp(startAppOptions); patchAdoptedStyleSheets(appId); diff --git a/src/services/miniapp-runtime/index.ts b/src/services/miniapp-runtime/index.ts index 1cd4d6b41..e76896736 100644 --- a/src/services/miniapp-runtime/index.ts +++ b/src/services/miniapp-runtime/index.ts @@ -668,6 +668,7 @@ export function launchApp( url: manifest.url, mountTarget: document.body, contextParams, + wujieConfig: manifest.wujieConfig, onLoad: () => { updateAppProcessStatus(appId, 'loaded'); if (!manifest.splashScreen) { diff --git a/vite.config.ts b/vite.config.ts index c2a544097..83e15e2f7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -97,10 +97,13 @@ export default defineConfig(({ mode }) => { { metadataUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/metadata.json', dirName: 'rwa-hub', - server: { runtime: 'wujie' }, + server: { + runtime: 'wujie', + wujieConfig: { rewriteAbsolutePaths: true }, + }, build: { runtime: 'wujie', - rewriteBase: true, + wujieConfig: { rewriteAbsolutePaths: true }, }, }, ],