import { spawn, type ChildProcess } from 'node:child_process'; import { constants } from 'node:fs'; import { lstat, mkdir, open, readFile, readdir, realpath } from 'node:fs/promises'; import { get, type IncomingHttpHeaders } from 'node:http'; import { createServer } from 'node:net'; import path from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; import type { WorkEntryProject, WorkLaunchSpec } from './work-entry-contracts'; export type WorkLaunchErrorCode = | 'WORK_PREVIEW_DEPENDENCIES_MISSING' | 'WORK_PREVIEW_START_FAILED' | 'WORK_PREVIEW_ENTRY_MISSING' | 'WORK_PREVIEW_NEEDS_HELP'; export class WorkLaunchError extends Error { constructor(readonly code: WorkLaunchErrorCode, message: string) { super(message); this.name = 'WorkLaunchError'; } } const MAX_DEPTH = 2; const MAX_DIRECTORIES = 160; const START_TIMEOUT_MS = 15_000; const ownedLaunches = new Map(); const SKIP_DIRECTORIES = new Set(['node_modules', '.git', '.makelore', '.codex']); const LOCAL_BASE = /^(?:\/(?!\/)[A-Za-z0-9_./-]*|\.\/?[A-Za-z0-9_./-]*)$/; /** Only a small Vite CLI vocabulary is replayable; package scripts never enter a shell. */ function viteArgs(script: unknown): string[] | null { if (typeof script !== 'string' || script.length > 1_000) return null; const tokens = script.trim().split(/\s+/); if (tokens.shift() !== 'vite') return null; const args: string[] = []; for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]; const equal = token.indexOf('='); const flag = equal < 0 ? token : token.slice(0, equal); const inline = equal < 0 ? undefined : token.slice(equal + 1); if (flag === '--strictPort' || flag === '--force') { if (inline !== undefined) return null; args.push(flag); continue; } if (flag === '--host') { const host = inline ?? (!tokens[index + 1]?.startsWith('--') ? tokens[++index] : undefined); if (host !== undefined && !['127.0.0.1', 'localhost', '0.0.0.0'].includes(host)) return null; continue; // The app supplies the loopback binding. } if (!['--port', '--base', '--mode'].includes(flag)) return null; const value = inline ?? tokens[++index]; if (!value) return null; if (flag === '--port' && (!/^\d{1,5}$/.test(value) || Number(value) < 1 || Number(value) > 65_535)) return null; if (flag === '--mode' && !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value)) return null; if (flag === '--base' && (!LOCAL_BASE.test(value) || value.split('/').includes('..'))) return null; if (args.includes(flag)) return null; args.push(flag, flag === '--port' ? String(Number(value)) : value); } return args; } function within(root: string, target: string): boolean { const relative = path.relative(root, target); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); } function relativeParts(value: string): string[] { if (typeof value !== 'string' || !value || value.includes('\\') || value.includes('\0') || path.isAbsolute(value)) { throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的启动位置已改变,请重新检查作品。'); } if (value === '.') return []; const parts = value.split('/'); if (parts.some((part) => !part || part === '.' || part === '..' || part.startsWith('.'))) { throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的启动位置不在项目中。'); } return parts; } async function safeExistingPath(root: string, relative: string, kind: 'directory' | 'file'): Promise { const parts = relativeParts(relative); let current = root; try { for (const part of parts) { current = path.join(current, part); if ((await lstat(current)).isSymbolicLink()) throw new Error('symlink'); } const stat = await lstat(current); const resolved = await realpath(current); if (!within(root, resolved) || (kind === 'file' ? !stat.isFile() : !stat.isDirectory())) throw new Error('outside project'); return resolved; } catch { throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的文件或启动位置找不到了。'); } } async function projectRoot(projectPath: string): Promise { try { const root = await realpath(projectPath); if (!(await lstat(root)).isDirectory()) throw new Error('not a directory'); return root; } catch { throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '项目文件夹找不到了。'); } } async function packageRecord(directory: string): Promise | null> { const file = path.join(directory, 'package.json'); const stat = await lstat(file).catch(() => null); if (!stat) return null; if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_000_000) { throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。'); } try { const value: unknown = JSON.parse(await readFile(file, 'utf8')); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid package'); return value as Record; } catch { throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。'); } } function devArgs(pkg: Record): string[] | null { const scripts = pkg.scripts; return viteArgs(scripts && typeof scripts === 'object' && !Array.isArray(scripts) ? (scripts as Record).dev : undefined); } export async function discoverWorkLaunch(projectPath: string): Promise { const root = await projectRoot(projectPath); const candidates: WorkLaunchSpec[] = []; let inspected = 0; let needsHelp = false; const visit = async (directory: string, depth: number): Promise => { if (++inspected > MAX_DIRECTORIES) { needsHelp = true; return; } const relative = path.relative(root, directory).split(path.sep).join('/') || '.'; let pkg: Record | null; try { pkg = await packageRecord(directory); } catch { needsHelp = true; return; } if (pkg) { const args = devArgs(pkg); if (args) candidates.push({ kind: 'vite', directory: relative, args }); else needsHelp = true; // Do not mistake a framework's public/dist/template files for a plain website. return; } const index = await lstat(path.join(directory, 'index.html')).catch(() => null); if (index?.isFile() && !index.isSymbolicLink()) { candidates.push({ kind: 'static', directory: relative, entry: 'index.html' }); return; } if (depth >= MAX_DEPTH || candidates.length > 1) return; const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith('.') || SKIP_DIRECTORIES.has(entry.name)) continue; await visit(path.join(directory, entry.name), depth + 1); if (candidates.length > 1 || inspected > MAX_DIRECTORIES) break; } }; await visit(root, 0); if (candidates.length > 1 || needsHelp) { throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '需要帮忙确认打开哪个作品,以及怎样启动它。'); } return candidates[0] ?? null; } async function viteCli(root: string, directory: string): Promise { for (let current = directory; within(root, current); current = path.dirname(current)) { const packagePath = path.join(current, 'node_modules', 'vite', 'package.json'); try { const pkg = JSON.parse(await readFile(packagePath, 'utf8')) as { name?: string }; const cli = await realpath(path.join(current, 'node_modules', 'vite', 'bin', 'vite.js')); const api = path.resolve(path.dirname(cli), '../dist/node/index.js'); if (pkg.name === 'vite' && (await lstat(cli)).isFile() && (await lstat(api)).isFile()) return cli; } catch { /* Try installed dependencies at a containing project directory. */ } if (current === root) break; } throw new WorkLaunchError('WORK_PREVIEW_DEPENDENCIES_MISSING', '作品的启动工具还没准备好,需要帮忙检查。'); } function argumentValue(args: string[], flag: string): string | undefined { const index = args.indexOf(flag); return index < 0 ? undefined : args[index + 1]; } function previousPort(previousUrl: string | undefined): number | undefined { try { const url = new URL(previousUrl ?? ''); if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.username || url.password || !url.port) return undefined; return Number(url.port); } catch { return undefined; } } async function availablePort(preferred?: number): Promise { const reserve = (port: number) => new Promise((resolve, reject) => { const server = createServer(); server.once('error', reject); server.listen(port, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { server.close(); reject(new Error('port unavailable')); return; } server.close((error) => error ? reject(error) : resolve(address.port)); }); }); if (preferred) { try { return await reserve(preferred); } catch { /* Never kill whoever already owns the old port. */ } } return await reserve(0); } // App-owned code, passed as a Node argument. No project script or shell is evaluated. const STATIC_RUNNER = String.raw` const http = require('node:http'); const fs = require('node:fs/promises'); const path = require('node:path'); const [root, entry, port, token, identity] = process.argv.slice(1); const mime = {'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.mjs':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.gif':'image/gif','.ico':'image/x-icon','.mp3':'audio/mpeg','.wav':'audio/wav','.ogg':'audio/ogg','.mp4':'video/mp4','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf','.wasm':'application/wasm','.txt':'text/plain; charset=utf-8'}; async function safeFile(parts) { let file = root; for (const part of parts) { file = path.join(file, part); if ((await fs.lstat(file)).isSymbolicLink()) throw new Error('forbidden'); } const resolved = await fs.realpath(file); const relative = path.relative(root, resolved); if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new Error('forbidden'); if (!(await fs.stat(resolved)).isFile()) throw new Error('missing'); return resolved; } http.createServer(async (req, res) => { try { if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); res.end(); return; } const pathname = decodeURIComponent((req.url || '/').split('?')[0]); if (!pathname.startsWith('/') || pathname.includes('\\') || pathname.includes('\0')) throw new Error('forbidden'); let parts = pathname.slice(1).split('/').filter(Boolean); if (parts.some(part => part === '..' || part.startsWith('.'))) throw new Error('forbidden'); if (parts.length === 0) parts = entry.split('/'); else if (pathname.endsWith('/')) parts.push('index.html'); const file = await safeFile(parts); const bytes = await fs.readFile(file); res.writeHead(200, {'content-type':mime[path.extname(file).toLowerCase()] || 'application/octet-stream','content-length':bytes.length,'cache-control':'no-store','x-makelore-work':token,'x-makelore-work-project':identity,'x-content-type-options':'nosniff'}); res.end(req.method === 'HEAD' ? undefined : bytes); } catch (error) { res.writeHead(error.message === 'forbidden' ? 403 : 404); res.end(); } }).listen(Number(port), '127.0.0.1', () => console.log('Makelore preview pid=' + process.pid)); `; // Use the installed Vite runtime/config with app-owned options and report its resolved base. const VITE_RUNNER = String.raw` const http = require('node:http'); const {pathToFileURL} = require('node:url'); const path = require('node:path'); const fs = require('node:fs/promises'); const [token, identity, cli, projectRoot, ...args] = process.argv.slice(1); const emit = http.Server.prototype.emit; http.Server.prototype.emit = function(event, ...values) { if (event === 'request') { values[1].setHeader('x-makelore-work', token); values[1].setHeader('x-makelore-work-project', identity); } return emit.call(this, event, ...values); }; const value = flag => args[args.indexOf(flag) + 1]; (async () => { const vite = await import(pathToFileURL(path.resolve(path.dirname(cli), '../dist/node/index.js')).href); const options = {clearScreen:false,server:{host:'127.0.0.1',port:Number(value('--port')),strictPort:true,open:false},optimizeDeps:{force:args.includes('--force')}}; if (args.includes('--base')) options.base = value('--base'); if (args.includes('--mode')) options.mode = value('--mode'); const server = await vite.createServer(options); const root = await fs.realpath(server.config.root); const relative = path.relative(projectRoot, root); if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new Error('Vite root is outside this project'); await server.listen(); const origin = 'http://127.0.0.1:' + value('--port'); const url = new URL(server.config.base || '/', origin + '/'); if (url.origin !== origin || url.username || url.password) throw new Error('Vite base is outside this local server'); console.log('Makelore ready pid=' + process.pid + ' url=' + url.href); if (!process.send) throw new Error('Missing launch readiness channel'); process.send({token,url:url.href}, () => process.disconnect()); })().catch(error => { console.error(error); process.exit(1); }); `; async function previewLog(root: string, kind: WorkLaunchSpec['kind']) { let directory = root; for (const part of ['.makelore', 'logs']) { directory = path.join(directory, part); await mkdir(directory).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error; }); const stat = await lstat(directory); if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('unsafe log directory'); } const logPath = path.join(directory, `${kind}-preview.log`); const previous = await lstat(logPath).catch(() => null); if (previous && (previous.isSymbolicLink() || !previous.isFile())) throw new Error('unsafe log file'); return await open(logPath, constants.O_CREAT | constants.O_APPEND | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), 0o600); } function childEnvironment(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1', CI: '1', NO_COLOR: '1' }; for (const key of ['PATH', 'HOME', 'USERPROFILE', 'SYSTEMROOT', 'COMSPEC', 'TEMP', 'TMP', 'APPDATA', 'LOCALAPPDATA']) { if (process.env[key] !== undefined) env[key] = process.env[key]; } return env; } type PreviewPage = { status?: number; headers: IncomingHttpHeaders; body: string }; function readPage(url: string, maxBytes = 256 * 1024): Promise { return new Promise((resolve) => { let settled = false; const finish = (page: PreviewPage | null) => { if (settled) return; settled = true; clearTimeout(timer); resolve(page); }; const timer = setTimeout(() => { request.destroy(); finish(null); }, 800); const request = get(url, (response) => { let body = ''; let receivedBytes = 0; response.setEncoding('utf8'); response.on('data', (chunk: string) => { receivedBytes += Buffer.byteLength(chunk, 'utf8'); if (receivedBytes > maxBytes) { request.destroy(); finish(null); return; } body += chunk; }); response.once('error', () => finish(null)); response.once('end', () => finish({ status: response.statusCode, headers: response.headers, body })); }); request.once('error', () => finish(null)); }); } function workIdentity(root: string, directory: string): string { return createHash('sha256').update(`${root}\0${directory}`).digest('hex'); } function hasExactModuleSource(body: string, source: string): boolean { const fullSource = source.trim(); if (!fullSource) return false; if (body.includes(fullSource)) return true; const maps = [...body.matchAll(/\/\/[#@]\s*sourceMappingURL=data:application\/json(?:;charset=[\w-]+)?;base64,([A-Za-z0-9+/=]+)/g)]; const lastMap = maps.at(-1)?.[1]; if (!lastMap || lastMap.length > 2 * 1024 * 1024) return false; try { const map = JSON.parse(Buffer.from(lastMap, 'base64').toString('utf8')) as { sourcesContent?: unknown }; return Array.isArray(map.sourcesContent) && map.sourcesContent.some((content) => content === source); } catch { return false; } } function exactRawModule(body: string, source: string): boolean { const exported = body.match(/^\s*export\s+default\s+("(?:[^"\\]|\\.)*")\s*;?\s*(?:\/\/[#@]\s*sourceMappingURL=[^\r\n]*\s*)?$/); if (!exported) return false; try { return JSON.parse(exported[1]) === source; } catch { return false; } } const TEXT_MODULE_EXTENSION = /\.(?:[cm]?js|jsx|[cm]?ts|tsx|css|json|svg|glsl|html?|txt)$/i; async function matchesLocalModule( directory: string, relative: string, address: URL, visited: Set, depth: number, ): Promise { if (visited.has(relative)) return true; if (depth > 4 || visited.size >= 20) return false; visited.add(relative); try { const file = await safeExistingPath(directory, relative, 'file'); if ((await lstat(file)).size > 512 * 1024) return false; const source = await readFile(file, 'utf8'); const rawAddress = new URL(address); rawAddress.search = '?raw'; const raw = await readPage(rawAddress.href, 2 * 1024 * 1024); if (!raw || raw.status !== 200 || !exactRawModule(raw.body, source)) { const response = await readPage(address.href, 2 * 1024 * 1024); if (!response || response.status !== 200 || !hasExactModuleSource(response.body, source)) return false; } // Exact entry text alone is insufficient when another local module contains the game. const specifiers = [...source.matchAll(/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)["']([^"']+)["']/g)].map((match) => match[1]); if (specifiers.some((specifier) => /^(?:@\/|~\/|\/)/.test(specifier)) || /\bimport\s*\(\s*([^'"\s])/.test(source)) return false; const imports = specifiers.filter((specifier) => /^\.{1,2}\//.test(specifier)); const matches = await Promise.all(imports.map(async (specifier) => { const local = decodeURIComponent(specifier.split(/[?#]/)[0]); let imported = path.posix.normalize(path.posix.join(path.posix.dirname(relative), local)); const extension = path.posix.extname(imported); if (extension && !TEXT_MODULE_EXTENSION.test(imported)) return true; if (!extension) { const candidates = [imported, ...['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'].flatMap((suffix) => [imported + suffix, `${imported}/index${suffix}`])]; const present: string[] = []; for (const candidate of candidates) { try { await safeExistingPath(directory, candidate, 'file'); present.push(candidate); } catch { /* Unknown imports cannot establish identity. */ } } if (present.length !== 1) return false; imported = present[0]; } const importedAddress = new URL(local, address); if (!extension) { const original = path.posix.normalize(path.posix.join(path.posix.dirname(relative), local)); importedAddress.pathname += imported.slice(original.length); } return await matchesLocalModule(directory, imported, importedAddress, visited, depth + 1); })); return matches.every(Boolean); } catch { return false; } } async function matchesEntryModule(directory: string, module: string, pageUrl: string, spec: WorkLaunchSpec, visited: Set): Promise { try { const address = new URL(module, pageUrl); if (address.origin !== new URL(pageUrl).origin || address.username || address.password) return false; let relative = decodeURIComponent(module.split(/[?#]/)[0]); if (relative.startsWith('/')) { const base = spec.kind === 'vite' ? argumentValue(spec.args, '--base') : undefined; if (base?.startsWith('/') && relative.startsWith(base)) relative = relative.slice(base.length); else relative = relative.slice(1); } if (relative.startsWith('./')) relative = relative.slice(2); if (!/\.(?:[cm]?js|jsx|ts|tsx)$/.test(relative)) return false; return await matchesLocalModule(directory, relative, address, visited, 0); } catch { return false; } } /** Check a saved address against this project's files before reusing its local service. */ export async function workLaunchMatches(project: WorkEntryProject, spec: WorkLaunchSpec, url: string): Promise { if (previousPort(url) === undefined) return false; try { const root = await projectRoot(project.path); const directory = await safeExistingPath(root, spec.directory, 'directory'); const entry = await safeExistingPath(directory, spec.kind === 'static' ? spec.entry : 'index.html', 'file'); const page = await readPage(url); if (!page || page.status !== 200 || !String(page.headers['content-type']).includes('text/html')) return false; if (page.headers['x-makelore-work-project'] === workIdentity(root, directory)) return true; const source = await readFile(entry, 'utf8'); if (spec.kind === 'static') return page.body === source; // Older, manually opened Vite services do not yet have the app's ownership header. const title = source.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim(); const actualTitle = page.body.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim(); const modules = [...source.matchAll(/]*)>/gi)].flatMap((match) => { if (!/\btype\s*=\s*["']module["']/i.test(match[1])) return []; const module = match[1].match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1]; return module ? [module] : []; }); if (!title || title !== actualTitle || modules.length === 0 || modules.length > 8 || !page.body.includes('/@vite/client')) return false; const visited = new Set(); const matches = await Promise.all(modules.map((module) => matchesEntryModule(directory, module, url, spec, visited))); return matches.every(Boolean); } catch { return false; } } function terminateOwnedProcess(child: ChildProcess): void { if (!child.pid || child.exitCode !== null) return; if (process.platform !== 'win32') { try { process.kill(-child.pid, 'SIGTERM'); return; } catch { /* The child may already have exited. */ } } child.kill(); } /** Cancel only a launch owned by this Main process, never an arbitrary saved address. */ export async function stopWorkLaunch(project: WorkEntryProject, url: string): Promise { const root = await projectRoot(project.path).catch(() => null); if (!root) return; const key = `${root}\0${url}`; const child = ownedLaunches.get(key); if (!child) return; ownedLaunches.delete(key); terminateOwnedProcess(child); } export async function startWorkLaunch(project: WorkEntryProject, spec: WorkLaunchSpec, previousUrl?: string): Promise { const root = await projectRoot(project.path); const directory = await safeExistingPath(root, spec.directory, 'directory'); let args: string[]; let base = '/'; const token = randomUUID(); const identity = workIdentity(root, directory); if (spec.kind === 'vite') { const pkg = await packageRecord(directory); const recognized = pkg && devArgs(pkg); if (!recognized || JSON.stringify(recognized) !== JSON.stringify(spec.args)) { throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '作品的启动方式变了,需要帮忙检查。'); } await safeExistingPath(directory, 'index.html', 'file'); const cli = await viteCli(root, directory); args = ['-e', VITE_RUNNER, token, identity, cli, root, ...spec.args.filter((value, index) => value !== '--port' && spec.args[index - 1] !== '--port' && value !== '--strictPort')]; const configuredBase = argumentValue(spec.args, '--base'); if (configuredBase?.startsWith('/')) base = configuredBase.endsWith('/') ? configuredBase : `${configuredBase}/`; } else if (spec.kind === 'static') { if (await packageRecord(directory)) throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品需要先确认启动方式。'); await safeExistingPath(directory, spec.entry, 'file'); if (!/\.html?$/i.test(spec.entry)) throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的网页入口找不到了。'); args = ['-e', STATIC_RUNNER, directory, spec.entry]; } else { throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。'); } let child: ChildProcess | undefined; try { const port = await availablePort(previousPort(previousUrl) ?? (spec.kind === 'vite' ? Number(argumentValue(spec.args, '--port')) || undefined : undefined)); let url = `http://127.0.0.1:${port}${base}`; let readyUrl = spec.kind === 'static' ? url : undefined; args.push(...(spec.kind === 'vite' ? ['--host', '127.0.0.1', '--port', String(port), '--strictPort'] : [String(port), token, identity])); const log = await previewLog(root, spec.kind); try { child = spawn(process.execPath, args, { cwd: directory, env: childEnvironment(), shell: false, detached: true, windowsHide: true, stdio: spec.kind === 'vite' ? ['ignore', log.fd, log.fd, 'ipc'] : ['ignore', log.fd, log.fd], }); child.on('message', (message: unknown) => { if (!message || typeof message !== 'object') return; const value = message as { token?: unknown; url?: unknown }; if (value.token !== token || typeof value.url !== 'string' || value.url.length > 4_096) return; try { const address = new URL(value.url); if (address.origin === `http://127.0.0.1:${port}` && !address.username && !address.password) readyUrl = address.href; } catch { /* Ignore untrusted or malformed child messages. */ } }); await new Promise((resolve, reject) => { child!.once('spawn', resolve); child!.once('error', reject); }); await log.write(`\nMakelore launch pid=${child.pid} url=${url}\n`); } finally { await log.close(); } let failed = false; child.once('error', () => { failed = true; }); const deadline = Date.now() + START_TIMEOUT_MS; while (Date.now() < deadline) { if (failed || child.exitCode !== null || child.signalCode !== null) break; if (readyUrl) url = readyUrl; const page = readyUrl ? await readPage(url) : null; if (page?.status === 200 && String(page.headers['content-type']).includes('text/html') && page.headers['x-makelore-work'] === token) { const key = `${root}\0${url}`; const ownedChild = child; ownedLaunches.set(key, ownedChild); ownedChild.once('exit', () => { if (ownedLaunches.get(key) === ownedChild) ownedLaunches.delete(key); }); child.unref(); return url; } await new Promise((resolve) => setTimeout(resolve, 80)); } throw new Error('preview did not become ready'); } catch { if (child) terminateOwnedProcess(child); throw new WorkLaunchError('WORK_PREVIEW_START_FAILED', '作品没有成功启动,可以帮忙检查启动记录。'); } }