Restore existing project works independently of model sessions

This commit is contained in:
鲨鱼辣椒 committed 2026-10-14 11:15:17 +08:00
1 parent 4eb3efa3d1
commit 0f26b6d072
19 files changed
+1977 -68

No files matched your search

+551
View File
@@ -0,0 +1,551 @@
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<string, ChildProcess>();
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<string> {
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<string> {
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<Record<string, unknown> | 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<string, unknown>;
} catch {
throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。');
}
}
function devArgs(pkg: Record<string, unknown>): string[] | null {
const scripts = pkg.scripts;
return viteArgs(scripts && typeof scripts === 'object' && !Array.isArray(scripts)
? (scripts as Record<string, unknown>).dev
: undefined);
}
export async function discoverWorkLaunch(projectPath: string): Promise<WorkLaunchSpec | null> {
const root = await projectRoot(projectPath);
const candidates: WorkLaunchSpec[] = [];
let inspected = 0;
let needsHelp = false;
const visit = async (directory: string, depth: number): Promise<void> => {
if (++inspected > MAX_DIRECTORIES) {
needsHelp = true;
return;
}
const relative = path.relative(root, directory).split(path.sep).join('/') || '.';
let pkg: Record<string, unknown> | 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<string> {
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<number> {
const reserve = (port: number) => new Promise<number>((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<PreviewPage | null> {
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<string>,
depth: number,
): Promise<boolean> {
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<string>): Promise<boolean> {
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<boolean> {
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(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
const actualTitle = page.body.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
const modules = [...source.matchAll(/<script\b([^>]*)>/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<string>();
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<void> {
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<string> {
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<void>((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', '作品没有成功启动,可以帮忙检查启动记录。');
}
}