Restore existing project works independently of model sessions
This commit is contained in:
1 parent
4eb3efa3d1
commit
0f26b6d072
19 files changed
+1977
-68
No files matched your search
@@ -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', '作品没有成功启动,可以帮忙检查启动记录。');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user