601 lines
17 KiB
TypeScript
601 lines
17 KiB
TypeScript
import { spawn, type ChildProcess } from 'child_process';
|
|
import fs from 'node:fs';
|
|
import { createServer } from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {
|
|
appendTail as appendStartupTail,
|
|
createGooseServeStartupDiagnostics,
|
|
type GooseServeStartupDiagnostics,
|
|
} from './startupDiagnostics';
|
|
|
|
export interface Logger {
|
|
info: (...args: unknown[]) => void;
|
|
error: (...args: unknown[]) => void;
|
|
}
|
|
|
|
export const defaultLogger: Logger = {
|
|
info: (...args) => console.log('[goose-serve]', ...args),
|
|
error: (...args) => console.error('[goose-serve]', ...args),
|
|
};
|
|
|
|
export interface FindGooseBinaryOptions {
|
|
isPackaged?: boolean;
|
|
resourcesPath?: string;
|
|
}
|
|
|
|
type ReadinessFetchInit = Parameters<typeof globalThis.fetch>[1];
|
|
export type GooseServeExitSignal = ChildProcess['signalCode'];
|
|
type ReadinessFetch = (input: string, init?: ReadinessFetchInit) => Promise<Response>;
|
|
|
|
export interface StartGooseServeOptions extends FindGooseBinaryOptions {
|
|
dir?: string;
|
|
serverSecret: string;
|
|
tls?: boolean;
|
|
env?: Record<string, string | undefined>;
|
|
logger?: Logger;
|
|
diagnosticsDir?: string;
|
|
readinessFetch?: ReadinessFetch;
|
|
}
|
|
|
|
export interface GooseServeResult {
|
|
acpUrl: string;
|
|
workingDir: string;
|
|
process: ChildProcess;
|
|
errorLog: string[];
|
|
certFingerprint: string | null;
|
|
cleanup: () => Promise<void>;
|
|
hasExited: () => boolean;
|
|
getExitDetails: () => { code: number | null; signal: GooseServeExitSignal };
|
|
startupDiagnosticsPath: string | null;
|
|
getStartupDiagnostics: () => GooseServeStartupDiagnostics | null;
|
|
recordStartupEvent: (name: string, details?: Record<string, unknown>) => void;
|
|
}
|
|
|
|
const existingFile = (candidate: string): boolean => {
|
|
try {
|
|
return fs.existsSync(candidate) && fs.statSync(candidate).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const findGooseBinaryPath = (options: FindGooseBinaryOptions = {}): string => {
|
|
const { isPackaged = false, resourcesPath } = options;
|
|
const pathFromEnv = process.env.GOOSE_BINARY;
|
|
if (pathFromEnv) {
|
|
if (isPackaged) {
|
|
throw new Error('GOOSE_BINARY is only supported in development builds');
|
|
}
|
|
|
|
const resolvedPath = path.resolve(pathFromEnv);
|
|
if (existingFile(resolvedPath)) {
|
|
return resolvedPath;
|
|
}
|
|
throw new Error(`Invalid GOOSE_BINARY path: ${pathFromEnv} (pwd is ${process.cwd()})`);
|
|
}
|
|
|
|
const binaryName = process.platform === 'win32' ? 'goose.exe' : 'goose';
|
|
const possiblePaths: string[] = [];
|
|
|
|
if (isPackaged && resourcesPath) {
|
|
possiblePaths.push(path.join(resourcesPath, 'bin', binaryName));
|
|
possiblePaths.push(path.join(resourcesPath, binaryName));
|
|
} else {
|
|
possiblePaths.push(
|
|
path.join(process.cwd(), 'src', 'bin', binaryName),
|
|
path.join(process.cwd(), '..', '..', 'target', 'release', binaryName),
|
|
path.join(process.cwd(), '..', '..', 'target', 'debug', binaryName)
|
|
);
|
|
}
|
|
|
|
for (const candidate of possiblePaths) {
|
|
if (existingFile(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`Goose binary not found in any of the possible paths: ${possiblePaths.join(', ')}`
|
|
);
|
|
};
|
|
|
|
const findAvailablePort = (): Promise<number> => {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createServer();
|
|
|
|
server.on('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const { port } = server.address() as { port: number };
|
|
server.close(() => {
|
|
resolve(port);
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const isFatalError = (line: string): boolean => {
|
|
const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i];
|
|
return fatalPatterns.some((pattern) => pattern.test(line));
|
|
};
|
|
|
|
const appendErrorTail = (target: string[], lines: string[], maxLines = 100): void => {
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
target.push(line);
|
|
}
|
|
}
|
|
if (target.length > maxLines) {
|
|
target.splice(0, target.length - maxLines);
|
|
}
|
|
};
|
|
|
|
const CERT_FINGERPRINT_PREFIX = 'GOOSED_CERT_FINGERPRINT=';
|
|
const TLS_FINGERPRINT_TIMEOUT_MS = 5000;
|
|
|
|
const fetchStatus = async (
|
|
statusUrl: string,
|
|
readinessFetch: ReadinessFetch
|
|
): Promise<boolean> => {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 1000);
|
|
|
|
try {
|
|
const response = await readinessFetch(statusUrl, { signal: controller.signal });
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
};
|
|
|
|
const waitForFingerprint = async (
|
|
fingerprintReady: Promise<string | null>,
|
|
timeoutMs: number
|
|
): Promise<string | null> => {
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
const timeoutPromise = new Promise<null>((resolve) => {
|
|
timeout = setTimeout(() => resolve(null), timeoutMs);
|
|
});
|
|
|
|
try {
|
|
return await Promise.race([fingerprintReady, timeoutPromise]);
|
|
} finally {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
};
|
|
|
|
const waitForGooseServeReady = async (
|
|
statusUrl: string,
|
|
errorLog: string[],
|
|
shouldStopWaiting: () => boolean,
|
|
options: {
|
|
healthUrl: string;
|
|
readinessFetch: ReadinessFetch;
|
|
onEvent?: (name: string, details?: Record<string, unknown>) => void;
|
|
}
|
|
): Promise<boolean> => {
|
|
const timeout = 30000;
|
|
const interval = 100;
|
|
const deadline = Date.now() + timeout;
|
|
const probeDetails = {
|
|
transport: statusUrl.startsWith('https:') ? 'https' : 'plain-http',
|
|
method: 'GET',
|
|
path: '/status',
|
|
url: statusUrl,
|
|
statusUrl,
|
|
healthUrl: options.healthUrl,
|
|
};
|
|
options.onEvent?.('healthcheck_start', {
|
|
...probeDetails,
|
|
timeoutMs: timeout,
|
|
intervalMs: interval,
|
|
});
|
|
|
|
let attempt = 1;
|
|
while (Date.now() < deadline) {
|
|
if (shouldStopWaiting()) {
|
|
options.onEvent?.('healthcheck_fatal_error', {
|
|
...probeDetails,
|
|
attempt,
|
|
reason: 'process_unavailable',
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (errorLog.some(isFatalError)) {
|
|
options.onEvent?.('healthcheck_fatal_error', {
|
|
...probeDetails,
|
|
attempt,
|
|
reason: 'fatal_stderr',
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (await fetchStatus(statusUrl, options.readinessFetch)) {
|
|
options.onEvent?.('healthcheck_success', {
|
|
...probeDetails,
|
|
attempt,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
await delay(interval);
|
|
attempt += 1;
|
|
}
|
|
|
|
options.onEvent?.('healthcheck_timeout', { ...probeDetails, timeoutMs: timeout });
|
|
return false;
|
|
};
|
|
|
|
export type LocalServeScheme = 'http' | 'https';
|
|
|
|
export interface LocalServeUrls {
|
|
httpBaseUrl: string;
|
|
statusUrl: string;
|
|
healthUrl: string;
|
|
acpUrl: string;
|
|
redactedAcpUrl: string;
|
|
}
|
|
|
|
export const buildLocalServeUrls = (
|
|
port: number,
|
|
token: string,
|
|
scheme: LocalServeScheme
|
|
): LocalServeUrls => {
|
|
const httpBaseUrl = `${scheme}://127.0.0.1:${port}`;
|
|
const websocketProtocol = scheme === 'https' ? 'wss:' : 'ws:';
|
|
|
|
const acpUrl = new URL(`${httpBaseUrl}/acp`);
|
|
acpUrl.protocol = websocketProtocol;
|
|
acpUrl.searchParams.set('token', token);
|
|
|
|
const redactedAcpUrl = new URL(`${httpBaseUrl}/acp`);
|
|
redactedAcpUrl.protocol = websocketProtocol;
|
|
redactedAcpUrl.searchParams.set('token', 'REDACTED');
|
|
|
|
return {
|
|
httpBaseUrl,
|
|
statusUrl: `${httpBaseUrl}/status`,
|
|
healthUrl: `${httpBaseUrl}/health`,
|
|
acpUrl: acpUrl.toString(),
|
|
redactedAcpUrl: redactedAcpUrl.toString(),
|
|
};
|
|
};
|
|
|
|
const errorMessage = (error: unknown): string => {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
return String(error);
|
|
};
|
|
|
|
const withStartupDiagnosticsPath = (
|
|
message: string,
|
|
startupDiagnosticsPath: string | null
|
|
): string => {
|
|
if (!startupDiagnosticsPath) {
|
|
return message;
|
|
}
|
|
return `${message} Startup diagnostics: ${startupDiagnosticsPath}`;
|
|
};
|
|
|
|
const buildGooseServeEnv = (
|
|
serverSecret: string,
|
|
binaryPath: string,
|
|
additionalEnv: Record<string, string | undefined>
|
|
): Record<string, string | undefined> => {
|
|
const homeDir = process.env.HOME || os.homedir();
|
|
const pathKey = process.platform === 'win32' ? 'Path' : 'PATH';
|
|
const currentPath = process.env[pathKey] || '';
|
|
|
|
const env: Record<string, string | undefined> = {
|
|
...process.env,
|
|
HOME: homeDir,
|
|
[pathKey]: `${path.dirname(binaryPath)}${path.delimiter}${currentPath}`,
|
|
};
|
|
|
|
if (process.platform === 'win32') {
|
|
env.USERPROFILE = homeDir;
|
|
env.APPDATA = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
|
|
env.LOCALAPPDATA = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(additionalEnv)) {
|
|
if (value !== undefined) {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
|
|
env.GOOSE_SERVER__SECRET_KEY = serverSecret;
|
|
|
|
return env;
|
|
};
|
|
|
|
export const startGooseServe = async ({
|
|
dir,
|
|
serverSecret,
|
|
tls = false,
|
|
env: additionalEnv = {},
|
|
isPackaged,
|
|
resourcesPath,
|
|
logger = defaultLogger,
|
|
diagnosticsDir,
|
|
readinessFetch = fetch,
|
|
}: StartGooseServeOptions): Promise<GooseServeResult> => {
|
|
const workingDir = dir || process.cwd();
|
|
const startupTrace = createGooseServeStartupDiagnostics(diagnosticsDir, workingDir);
|
|
const startupDiagnosticsPath = startupTrace?.diagnosticsPath ?? null;
|
|
const secretKey = serverSecret.trim();
|
|
if (!secretKey) {
|
|
const message = 'GOOSE_SERVER__SECRET_KEY is required for goose serve';
|
|
startupTrace?.record('configuration_error', { message });
|
|
throw new Error(withStartupDiagnosticsPath(message, startupDiagnosticsPath));
|
|
}
|
|
|
|
let goosePath: string;
|
|
try {
|
|
goosePath = findGooseBinaryPath({ isPackaged, resourcesPath });
|
|
} catch (error) {
|
|
const message = errorMessage(error);
|
|
startupTrace?.record('binary_resolve_error', { message });
|
|
throw new Error(withStartupDiagnosticsPath(message, startupDiagnosticsPath));
|
|
}
|
|
|
|
const port = await findAvailablePort();
|
|
const localServeScheme: LocalServeScheme = tls ? 'https' : 'http';
|
|
const { httpBaseUrl, statusUrl, healthUrl, acpUrl, redactedAcpUrl } = buildLocalServeUrls(
|
|
port,
|
|
secretKey,
|
|
localServeScheme
|
|
);
|
|
const errorLog: string[] = [];
|
|
const args = [
|
|
'serve',
|
|
...(tls ? ['--tls'] : []),
|
|
'--platform',
|
|
'desktop',
|
|
'--host',
|
|
'127.0.0.1',
|
|
'--port',
|
|
String(port),
|
|
];
|
|
|
|
logger.info(`Starting goose serve from: ${goosePath} on port ${port} in dir ${workingDir}`);
|
|
if (startupTrace) {
|
|
startupTrace.diagnostics.binaryPath = goosePath;
|
|
startupTrace.diagnostics.httpBaseUrl = httpBaseUrl;
|
|
startupTrace.diagnostics.readinessUrl = statusUrl;
|
|
startupTrace.diagnostics.statusUrl = statusUrl;
|
|
startupTrace.diagnostics.healthUrl = healthUrl;
|
|
startupTrace.diagnostics.acpUrl = redactedAcpUrl;
|
|
startupTrace.record('spawn_start', {
|
|
binaryPath: goosePath,
|
|
port,
|
|
tls,
|
|
workingDir,
|
|
args,
|
|
});
|
|
}
|
|
|
|
const spawnOptions = {
|
|
env: buildGooseServeEnv(secretKey, goosePath, additionalEnv),
|
|
cwd: workingDir,
|
|
windowsHide: true,
|
|
detached: process.platform === 'win32',
|
|
shell: false as const,
|
|
stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
|
|
};
|
|
|
|
const gooseProcess = spawn(goosePath, args, spawnOptions);
|
|
if (startupTrace) {
|
|
startupTrace.diagnostics.pid = gooseProcess.pid ?? null;
|
|
startupTrace.record('spawn_success', { pid: gooseProcess.pid ?? null });
|
|
}
|
|
|
|
let exited = false;
|
|
let spawnFailed = false;
|
|
let exitCode: number | null = null;
|
|
let exitSignal: GooseServeExitSignal = null;
|
|
let certFingerprint: string | null = null;
|
|
let stdoutBuffer = '';
|
|
let stdoutCollectionStopped = false;
|
|
let fingerprintReadyResolved = false;
|
|
let resolveFingerprintReady: (fingerprint: string | null) => void = () => {};
|
|
const fingerprintReady = new Promise<string | null>((resolve) => {
|
|
resolveFingerprintReady = resolve;
|
|
});
|
|
|
|
const resolveFingerprint = (fingerprint: string | null) => {
|
|
if (fingerprintReadyResolved) {
|
|
return;
|
|
}
|
|
fingerprintReadyResolved = true;
|
|
resolveFingerprintReady(fingerprint);
|
|
};
|
|
|
|
const stopStdoutCollection = () => {
|
|
if (stdoutCollectionStopped) {
|
|
return;
|
|
}
|
|
stdoutCollectionStopped = true;
|
|
gooseProcess.stdout?.off('data', onStdoutData);
|
|
gooseProcess.stdout?.resume();
|
|
};
|
|
|
|
const recordCertFingerprint = (fingerprint: string) => {
|
|
if (!fingerprint) {
|
|
return;
|
|
}
|
|
certFingerprint = fingerprint;
|
|
logger.info(`Pinned cert fingerprint: ${certFingerprint}`);
|
|
startupTrace?.record('fingerprint_received', { certFingerprint });
|
|
resolveFingerprint(certFingerprint);
|
|
stopStdoutCollection();
|
|
};
|
|
|
|
const onStdoutData = (data: Buffer) => {
|
|
stdoutBuffer += data.toString();
|
|
const lines = stdoutBuffer.split(/\r?\n/);
|
|
stdoutBuffer = lines.pop() ?? '';
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith(CERT_FINGERPRINT_PREFIX)) {
|
|
recordCertFingerprint(line.slice(CERT_FINGERPRINT_PREFIX.length).trim());
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
gooseProcess.stdout?.on('data', onStdoutData);
|
|
|
|
const onStderrData = (data: Buffer) => {
|
|
const lines = data.toString().split('\n');
|
|
appendErrorTail(errorLog, lines);
|
|
if (startupTrace) {
|
|
appendStartupTail(startupTrace.diagnostics.stderrTail, lines);
|
|
}
|
|
for (const line of lines) {
|
|
if (line.trim() && isFatalError(line)) {
|
|
logger.error(`goose serve stderr for port ${port} and dir ${workingDir}: ${line}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
gooseProcess.stderr?.on('data', onStderrData);
|
|
|
|
gooseProcess.on('exit', (code, signal) => {
|
|
exited = true;
|
|
exitCode = code;
|
|
exitSignal = signal;
|
|
logger.info(
|
|
`goose serve process exited with code ${code} and signal ${signal} for port ${port} and dir ${workingDir}`
|
|
);
|
|
if (startupTrace) {
|
|
startupTrace.diagnostics.childExitCode = code;
|
|
startupTrace.diagnostics.childExitSignal = signal;
|
|
startupTrace.record('child_exit', { code, signal });
|
|
}
|
|
resolveFingerprint(null);
|
|
});
|
|
|
|
gooseProcess.on('error', (error) => {
|
|
spawnFailed = true;
|
|
errorLog.push(error.message);
|
|
logger.error(`Failed to start goose serve on port ${port} and dir ${workingDir}`, error);
|
|
startupTrace?.record('spawn_error', { message: error.message, name: error.name });
|
|
});
|
|
|
|
const cleanup = async (): Promise<void> => {
|
|
return new Promise<void>((resolve) => {
|
|
if (exited || gooseProcess.killed) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
let resolved = false;
|
|
const finish = () => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
gooseProcess.once('close', finish);
|
|
|
|
logger.info('Terminating goose serve');
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
if (gooseProcess.pid) {
|
|
spawn('taskkill', ['/pid', gooseProcess.pid.toString(), '/f', '/t']);
|
|
}
|
|
} else {
|
|
gooseProcess.kill('SIGTERM');
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error while terminating goose serve process:', error);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (!exited && !gooseProcess.killed && process.platform !== 'win32') {
|
|
gooseProcess.kill('SIGKILL');
|
|
}
|
|
finish();
|
|
}, 5000);
|
|
});
|
|
};
|
|
|
|
const ready = await waitForGooseServeReady(statusUrl, errorLog, () => exited || spawnFailed, {
|
|
healthUrl,
|
|
readinessFetch,
|
|
onEvent: startupTrace?.record,
|
|
});
|
|
|
|
const stopOutputCollection = () => {
|
|
stopStdoutCollection();
|
|
gooseProcess.stderr?.off('data', onStderrData);
|
|
gooseProcess.stderr?.resume();
|
|
};
|
|
|
|
if (!ready) {
|
|
stopOutputCollection();
|
|
await cleanup();
|
|
const exitDetails = exited
|
|
? ` Process exited with code ${exitCode} and signal ${exitSignal}.`
|
|
: '';
|
|
const stderrDetails = errorLog.length ? ` Stderr: ${errorLog.join('\n')}` : '';
|
|
throw new Error(
|
|
withStartupDiagnosticsPath(
|
|
`goose serve did not become ready on ${statusUrl}.${exitDetails}${stderrDetails}`,
|
|
startupDiagnosticsPath
|
|
)
|
|
);
|
|
}
|
|
|
|
if (tls) {
|
|
startupTrace?.record('fingerprint_wait_start', { timeoutMs: TLS_FINGERPRINT_TIMEOUT_MS });
|
|
const fingerprint = await waitForFingerprint(fingerprintReady, TLS_FINGERPRINT_TIMEOUT_MS);
|
|
if (!fingerprint) {
|
|
stopOutputCollection();
|
|
await cleanup();
|
|
const exitDetails = exited
|
|
? ` Process exited with code ${exitCode} and signal ${exitSignal}.`
|
|
: '';
|
|
const stderrDetails = errorLog.length ? ` Stderr: ${errorLog.join('\n')}` : '';
|
|
startupTrace?.record('fingerprint_missing', {
|
|
timeoutMs: TLS_FINGERPRINT_TIMEOUT_MS,
|
|
exited,
|
|
exitCode,
|
|
exitSignal,
|
|
});
|
|
throw new Error(
|
|
withStartupDiagnosticsPath(
|
|
`goose serve did not emit TLS certificate fingerprint on ${statusUrl}.${exitDetails}${stderrDetails}`,
|
|
startupDiagnosticsPath
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
stopOutputCollection();
|
|
|
|
return {
|
|
acpUrl,
|
|
workingDir,
|
|
process: gooseProcess,
|
|
errorLog,
|
|
certFingerprint,
|
|
cleanup,
|
|
hasExited: () => exited,
|
|
getExitDetails: () => ({ code: exitCode, signal: exitSignal }),
|
|
startupDiagnosticsPath,
|
|
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
|
|
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
|
|
};
|
|
};
|