← Files RivetARCHIVED FILE

node_modules/rivet-design/dist/services/DevServerRuntimeService.js

21.8 KB · Nov 7, 2026 · 13:16 UTC

↓ Download file

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createDevServerRuntimeService = exports.DevServerRuntimeService = exports.parsePortFromDevServerOutput = void 0;
const child_process_1 = require("child_process");
const fs_1 = __importDefault(require("fs"));
const net_1 = __importDefault(require("net"));
const path_1 = __importDefault(require("path"));
const util_1 = require("util");
const logger_1 = require("../utils/logger");
const devServerCommand_1 = require("../utils/devServerCommand");
const OperatingSystemServices_1 = require("./OperatingSystemServices");
const errorMessage_1 = require("../utils/errorMessage");
const log = (0, logger_1.createLogger)('DevServerRuntimeService');
const execAsync = (0, util_1.promisify)(child_process_1.exec);
const COMMON_DEV_SERVER_PORTS = [
    3000, 3001, 3002, 5173, 5174, 5175, 8080, 4200, 6006, 6007,
];
const DEFAULT_START_TIMEOUT_MS = 30_000;
const DEFAULT_PORT_CHECK_INTERVAL_MS = 500;
const DEFAULT_STABILITY_ATTEMPTS = 4;
const DEFAULT_STABILITY_INTERVAL_MS = 500;
const STARTUP_ERROR_OUTPUT_TAIL_CHARS = 300;
const MAX_EXCLUSIVE_VALID_TCP_PORT = 65_536;
const MIN_VALID_TCP_PORT = 1;
const SAFE_SCRIPT_NAME_PATTERN = '[A-Za-z0-9:_-]+';
const SAFE_START_COMMAND_PATTERN = new RegExp(`^(?:npm run ${SAFE_SCRIPT_NAME_PATTERN}(?: -- --port \\d+)?|yarn ${SAFE_SCRIPT_NAME_PATTERN}(?: --port \\d+)?|pnpm (?:run )?${SAFE_SCRIPT_NAME_PATTERN}(?: --port \\d+)?)$`);
const SAFE_INSTALL_COMMAND_PATTERN = /^(?:npm install|yarn install|pnpm install)$/;
const PORT_PLACEHOLDER_PATTERN = /\$\{?RIVET_PORT\}?|__RIVET_PORT__|\{\{RIVET_PORT\}\}/;
const PORT_PLACEHOLDER_REPLACE_PATTERN = /\$\{?RIVET_PORT\}?|__RIVET_PORT__|\{\{RIVET_PORT\}\}/g;
const DEV_SERVER_ENV_ALLOWLIST = [
    'APPDATA',
    'CI',
    'HOME',
    'LANG',
    'LC_ALL',
    'LOCALAPPDATA',
    'LOGNAME',
    'PATH',
    'SHELL',
    'SystemRoot',
    'TEMP',
    'TMP',
    'TMPDIR',
    'USER',
    'USERPROFILE',
];
const parsePortFromDevServerOutput = (text) => {
    const matches = [];
    const patterns = [
        /Local:\s*https?:\/\/[^\s]*:(\d+)/gi,
        /(?:localhost|127\.0\.0\.1):(\d+)/gi,
        /(?:on port|listening on port)\s+(\d+)/gi,
    ];
    for (const pattern of patterns) {
        for (const match of text.matchAll(pattern)) {
            const port = Number(match[1]);
            if (Number.isInteger(port) &&
                port >= MIN_VALID_TCP_PORT &&
                port < MAX_EXCLUSIVE_VALID_TCP_PORT) {
                matches.push({ index: match.index ?? 0, port });
            }
        }
    }
    matches.sort((a, b) => a.index - b.index);
    return matches[matches.length - 1]?.port ?? null;
};
exports.parsePortFromDevServerOutput = parsePortFromDevServerOutput;
const inferMissingToolsFromError = (text) => {
    const lower = text.toLowerCase();
    const missingTools = [];
    if (lower.includes('yarn: command not found') ||
        lower.includes('missing yarn')) {
        missingTools.push('yarn');
    }
    if (lower.includes('node: command not found') ||
        lower.includes('missing node')) {
        missingTools.push('node');
    }
    if (lower.includes('git: command not found') ||
        lower.includes('missing git')) {
        missingTools.push('git');
    }
    return missingTools;
};
const buildStartFailureGuidance = (options) => {
    const installCommand = options.installCommand ?? 'npm install';
    return [
        options.summary ??
            `Start command \`${options.command}\` (run in ${options.projectPath}) did not produce a running dev server within ${DEFAULT_START_TIMEOUT_MS / 1000}s.`,
        options.outputTail
            ? `Last output: ${options.outputTail}`
            : 'No output was captured.',
        `Rivet never installs dependencies or modifies the project. Diagnose the output yourself: if dependencies are missing, run \`${installCommand}\` in the project and retry; otherwise start the dev server manually and re-run \`rivet open\` with \`--user-port <port>\` to attach to it.`,
    ].join(' ');
};
const defaultIsPortResponding = (port) => new Promise((resolve) => {
    const socket = net_1.default.connect(port, 'localhost');
    socket.on('connect', () => {
        socket.destroy();
        resolve(true);
    });
    socket.on('error', () => {
        socket.destroy();
        resolve(false);
    });
    socket.setTimeout(DEFAULT_PORT_CHECK_INTERVAL_MS);
    socket.on('timeout', () => {
        socket.destroy();
        resolve(false);
    });
});
const defaultFindAvailablePort = async (startPort) => {
    for (let candidate = startPort; candidate < MAX_EXCLUSIVE_VALID_TCP_PORT; candidate++) {
        if (!(await defaultIsPortResponding(candidate))) {
            return candidate;
        }
    }
    throw new Error(`No available port found from ${startPort}`);
};
const discoverStartupCommandFromProjectScripts = async (options) => {
    const packageJsonPath = path_1.default.join(options.projectPath, 'package.json');
    try {
        const parsed = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf8'));
        const scriptName = devServerCommand_1.DEV_SCRIPT_CANDIDATES.find((candidate) => typeof parsed.scripts?.[candidate] === 'string');
        if (!scriptName) {
            return null;
        }
        const startCommand = options.packageManager === 'npm'
            ? `npm run ${scriptName}`
            : `${options.packageManager} ${scriptName}`;
        return {
            startCommand,
            installCommand: buildInstallCommand(options.packageManager),
        };
    }
    catch {
        return null;
    }
};
const normalizeProjectPath = (projectPath) => {
    const resolved = path_1.default.resolve(projectPath);
    try {
        return fs_1.default.realpathSync(resolved);
    }
    catch {
        return resolved;
    }
};
const isSafeCachedStartupCommand = (entry) => {
    if (entry.lastFailureAt) {
        return false;
    }
    if (!SAFE_START_COMMAND_PATTERN.test(entry.startCommand.trim())) {
        return false;
    }
    if (!entry.installCommand) {
        return true;
    }
    return SAFE_INSTALL_COMMAND_PATTERN.test(entry.installCommand.trim());
};
const buildDevServerProcessEnv = (extraEnv = {}) => {
    const env = {};
    for (const key of DEV_SERVER_ENV_ALLOWLIST) {
        const value = process.env[key];
        if (value !== undefined) {
            env[key] = value;
        }
    }
    return { ...env, ...extraEnv };
};
class ProjectStartupCommandCache {
    async read(projectPath) {
        const cachePath = this.resolveCachePath(projectPath);
        try {
            if (!fs_1.default.existsSync(cachePath)) {
                return null;
            }
            const parsed = JSON.parse(fs_1.default.readFileSync(cachePath, 'utf8'));
            if (typeof parsed.startCommand !== 'string') {
                return null;
            }
            const entry = {
                startCommand: parsed.startCommand,
                installCommand: typeof parsed.installCommand === 'string'
                    ? parsed.installCommand
                    : undefined,
                lastFailureAt: typeof parsed.lastFailureAt === 'string'
                    ? parsed.lastFailureAt
                    : undefined,
            };
            if (!isSafeCachedStartupCommand(entry)) {
                return null;
            }
            return entry;
        }
        catch (error) {
            log.debug(`Failed to read startup command cache: ${(0, errorMessage_1.errorMessage)(error)}`);
            return null;
        }
    }
    async write(projectPath, entry) {
        const cachePath = this.resolveCachePath(projectPath);
        try {
            fs_1.default.mkdirSync(path_1.default.dirname(cachePath), { recursive: true, mode: 0o700 });
            fs_1.default.writeFileSync(cachePath, JSON.stringify({
                ...entry,
                lastUsedAt: new Date().toISOString(),
            }, null, 2), { mode: 0o600 });
        }
        catch (error) {
            log.debug(`Failed to write startup command cache: ${(0, errorMessage_1.errorMessage)(error)}`);
        }
    }
    async markFailed(projectPath, reason) {
        const cachePath = this.resolveCachePath(projectPath);
        try {
            const existing = (await this.read(projectPath)) ?? { startCommand: '' };
            fs_1.default.mkdirSync(path_1.default.dirname(cachePath), { recursive: true, mode: 0o700 });
            fs_1.default.writeFileSync(cachePath, JSON.stringify({
                ...existing,
                lastFailureAt: new Date().toISOString(),
                lastFailureReason: reason,
            }, null, 2), { mode: 0o600 });
        }
        catch {
        }
    }
    resolveCachePath(projectPath) {
        return path_1.default.join(projectPath, '.rivet', 'agent-dev-server-cache.json');
    }
}
const defaultRuntimeDeps = () => ({
    exec: execAsync,
    isPortResponding: defaultIsPortResponding,
    findAvailablePort: defaultFindAvailablePort,
    sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
    operatingSystemServices: OperatingSystemServices_1.selectedOperatingSystemServices,
    cache: new ProjectStartupCommandCache(),
    discoverStartupCommand: discoverStartupCommandFromProjectScripts,
});
const buildInstallCommand = (packageManager) => {
    if (packageManager === 'yarn') {
        return 'yarn install';
    }
    if (packageManager === 'pnpm') {
        return 'pnpm install';
    }
    return 'npm install';
};
const bindPortToStartCommand = (options) => {
    const { framework, selectedPort } = options;
    const trimmedCommand = options.startCommand.trim();
    if (!trimmedCommand || framework === 'static') {
        return { command: trimmedCommand, mode: 'none' };
    }
    if (PORT_PLACEHOLDER_PATTERN.test(trimmedCommand)) {
        return {
            command: trimmedCommand.replace(PORT_PLACEHOLDER_REPLACE_PATTERN, String(selectedPort)),
            env: { PORT: String(selectedPort) },
            mode: 'placeholder',
        };
    }
    const withoutExplicitPort = trimmedCommand
        .replace(/\s--\s--port\s+\d+\b/g, '')
        .replace(/\s--\s-p\s+\d+\b/g, '')
        .replace(/\s--port\s+\d+\b/g, '')
        .replace(/\s--port=\d+\b/g, '')
        .replace(/\s-p\s+\d+\b/g, '')
        .replace(/\s-p=\d+\b/g, '')
        .replace(/\s-p\d+\b/g, '')
        .trim();
    if (framework === 'cra') {
        return {
            command: withoutExplicitPort.replace(/^PORT=\d+\s+/, ''),
            env: { PORT: String(selectedPort) },
            mode: 'cra_env',
        };
    }
    if (/^npm\s+run\s+\S+/.test(withoutExplicitPort)) {
        return {
            command: `${withoutExplicitPort} -- --port ${selectedPort}`,
            env: { PORT: String(selectedPort) },
            mode: 'npm_flag',
        };
    }
    if (/^yarn\s+\S+/.test(withoutExplicitPort)) {
        return {
            command: `${withoutExplicitPort} --port ${selectedPort}`,
            env: { PORT: String(selectedPort) },
            mode: 'yarn_flag',
        };
    }
    if (/^pnpm\s+(run\s+)?\S+/.test(withoutExplicitPort)) {
        return {
            command: `${withoutExplicitPort} --port ${selectedPort}`,
            env: { PORT: String(selectedPort) },
            mode: 'pnpm_flag',
        };
    }
    return {
        command: `${withoutExplicitPort} --port ${selectedPort}`,
        env: { PORT: String(selectedPort) },
        mode: 'fallback_flag',
    };
};
class DevServerRuntimeService {
    deps;
    process = null;
    port = null;
    projectPath = null;
    constructor(deps = defaultRuntimeDeps()) {
        this.deps = deps;
    }
    async resolveStartupCommand(options) {
        const cached = await this.deps.cache.read(options.projectPath);
        if (cached?.startCommand.trim() && isSafeCachedStartupCommand(cached)) {
            return { ...cached, source: 'cache' };
        }
        const discovered = await this.deps.discoverStartupCommand?.({
            projectPath: options.projectPath,
            framework: options.framework,
            packageManager: options.packageManager,
        });
        if (discovered?.startCommand.trim()) {
            return { ...discovered, source: 'discovered' };
        }
        const devConfig = devServerCommand_1.FRAMEWORK_DEV_CONFIG[options.framework] ?? {
            devCommand: 'dev',
            defaultPort: 3000,
        };
        const command = (0, devServerCommand_1.buildDevServerCommand)(options.framework, options.packageManager, devConfig.devCommand, options.preferredPort);
        return {
            startCommand: [command.cmd, ...command.args].join(' '),
            installCommand: buildInstallCommand(options.packageManager),
            source: 'deterministic',
        };
    }
    async start(options) {
        await this.stop();
        const selectedPort = await this.deps.findAvailablePort(options.preferredPort);
        const commandResolution = options.startCommand
            ? {
                startCommand: options.startCommand,
                installCommand: options.installCommand,
                source: 'deterministic',
            }
            : await this.resolveStartupCommand({
                projectPath: options.projectPath,
                framework: options.framework,
                packageManager: options.packageManager,
                preferredPort: selectedPort,
            });
        const boundCommand = bindPortToStartCommand({
            startCommand: commandResolution.startCommand,
            framework: options.framework,
            selectedPort,
        });
        const preExistingPorts = await this.collectPreExistingPorts(selectedPort);
        let output = '';
        let portFromOutput = null;
        this.projectPath = options.projectPath;
        this.process = this.deps.operatingSystemServices.projectProcesses.start({ shellCommand: boundCommand.command }, {
            cwd: options.projectPath,
            env: buildDevServerProcessEnv(boundCommand.env),
            stdio: ['ignore', 'pipe', 'pipe'],
            detached: false,
        });
        const child = this.process;
        const pid = child.pid;
        const recordOutput = (text) => {
            output += text;
            portFromOutput = (0, exports.parsePortFromDevServerOutput)(output);
        };
        child.stdout?.on('data', (data) => recordOutput(data.toString()));
        child.stderr?.on('data', (data) => recordOutput(data.toString()));
        child.on('exit', () => {
            if (this.process === child) {
                this.process = null;
            }
            options.onExit?.();
        });
        child.on('error', (error) => {
            log.warn(`Dev server process error: ${(0, errorMessage_1.errorMessage)(error)}`);
        });
        const detectedPort = await this.waitForServerAndDetectPort({
            expectedPort: selectedPort,
            preExistingPorts,
            getPortFromOutput: () => portFromOutput,
        });
        if (!detectedPort) {
            const outputTail = output.trim().slice(-STARTUP_ERROR_OUTPUT_TAIL_CHARS);
            await this.stop();
            const missingTools = inferMissingToolsFromError(output);
            const error = buildStartFailureGuidance({
                command: boundCommand.command,
                projectPath: options.projectPath,
                outputTail,
                installCommand: commandResolution.installCommand,
            });
            if (commandResolution.source === 'cache') {
                await this.deps.cache.markFailed(options.projectPath, error);
            }
            return {
                success: false,
                error,
                ...(missingTools.length > 0 && { missingTools }),
            };
        }
        this.port = detectedPort;
        const stability = await this.waitForStableServer(detectedPort);
        if (!stability.success) {
            const outputTail = output.trim().slice(-STARTUP_ERROR_OUTPUT_TAIL_CHARS);
            await this.stop();
            const error = buildStartFailureGuidance({
                command: boundCommand.command,
                projectPath: options.projectPath,
                outputTail,
                installCommand: commandResolution.installCommand,
                summary: `Start command \`${boundCommand.command}\` (run in ${options.projectPath}) started but failed: ${stability.error}`,
            });
            if (commandResolution.source === 'cache') {
                await this.deps.cache.markFailed(options.projectPath, error);
            }
            return {
                success: false,
                error,
            };
        }
        await this.deps.cache.write(options.projectPath, {
            startCommand: commandResolution.startCommand,
            installCommand: commandResolution.installCommand,
        });
        return {
            success: true,
            host: 'localhost',
            port: detectedPort,
            pid,
            ownership: 'rivet_owned',
            startedByRivet: true,
            stop: () => this.stop(),
            process: child,
            commandSource: commandResolution.source,
            portBindingMode: boundCommand.mode,
        };
    }
    async stop() {
        const ownedProcess = this.process;
        const ownedPort = this.port;
        const ownedProjectPath = this.projectPath;
        if (ownedProcess && ownedProcess.exitCode === null) {
            let hasExited = false;
            const recordExit = () => {
                hasExited = true;
            };
            ownedProcess.once('exit', recordExit);
            try {
                await this.deps.operatingSystemServices.projectProcesses.terminate(ownedProcess, { isForceful: false });
            }
            catch {
            }
            await this.deps.sleep(1_000);
            if (!hasExited) {
                try {
                    await this.deps.operatingSystemServices.projectProcesses.terminate(ownedProcess, { isForceful: true });
                }
                catch {
                }
            }
            ownedProcess.off('exit', recordExit);
        }
        if (ownedPort && ownedProjectPath) {
            await this.killProjectListenerOnPort(ownedPort, ownedProjectPath);
        }
        this.process = null;
        this.port = null;
        this.projectPath = null;
    }
    async collectPreExistingPorts(preferredPort) {
        const ports = [
            preferredPort,
            ...COMMON_DEV_SERVER_PORTS.filter((port) => port !== preferredPort),
        ];
        const preExistingPorts = new Set();
        for (const port of ports) {
            if (await this.deps.isPortResponding(port)) {
                preExistingPorts.add(port);
            }
        }
        return preExistingPorts;
    }
    async waitForServerAndDetectPort(options) {
        const maxAttempts = Math.ceil(DEFAULT_START_TIMEOUT_MS / DEFAULT_PORT_CHECK_INTERVAL_MS) + 1;
        for (let attempt = 0; attempt < maxAttempts; attempt++) {
            const outputPort = options.getPortFromOutput();
            if (outputPort &&
                !options.preExistingPorts.has(outputPort) &&
                (await this.deps.isPortResponding(outputPort))) {
                return outputPort;
            }
            if (!this.process || this.process.killed) {
                return null;
            }
            if (!options.preExistingPorts.has(options.expectedPort) &&
                (await this.deps.isPortResponding(options.expectedPort))) {
                return options.expectedPort;
            }
            await this.deps.sleep(DEFAULT_PORT_CHECK_INTERVAL_MS);
        }
        return null;
    }
    async waitForStableServer(port) {
        for (let attempt = 0; attempt < DEFAULT_STABILITY_ATTEMPTS; attempt++) {
            if (!this.process || this.process.killed) {
                return {
                    success: false,
                    error: 'Dev server process exited shortly after startup.',
                };
            }
            if (!(await this.deps.isPortResponding(port))) {
                return {
                    success: false,
                    error: `Dev server on localhost:${port} stopped responding during startup.`,
                };
            }
            if (attempt < DEFAULT_STABILITY_ATTEMPTS - 1) {
                await this.deps.sleep(DEFAULT_STABILITY_INTERVAL_MS);
            }
        }
        return { success: true };
    }
    async killProjectListenerOnPort(port, projectPath) {
        try {
            const { stdout } = await this.deps.exec(`lsof -nP -iTCP:${port} -sTCP:LISTEN -Fp`);
            const pids = stdout
                .split('\n')
                .map((line) => line.trim())
                .filter((line) => line.startsWith('p'))
                .map((line) => Number(line.slice(1)))
                .filter((pid) => Number.isInteger(pid) && pid > 0);
            const expectedPath = normalizeProjectPath(projectPath);
            for (const pid of [...new Set(pids)]) {
                const cwd = await this.getProcessCwd(pid);
                if (!cwd || normalizeProjectPath(cwd) !== expectedPath) {
                    continue;
                }
                await this.deps.exec(`kill -9 ${pid}`);
            }
        }
        catch {
        }
    }
    async getProcessCwd(pid) {
        try {
            const { stdout } = await this.deps.exec(`lsof -a -p ${pid} -d cwd -Fn`);
            const cwdLine = stdout
                .split('\n')
                .map((line) => line.trim())
                .find((line) => line.startsWith('n'));
            return cwdLine ? cwdLine.slice(1) : null;
        }
        catch {
            return null;
        }
    }
}
exports.DevServerRuntimeService = DevServerRuntimeService;
const createDevServerRuntimeService = () => new DevServerRuntimeService();
exports.createDevServerRuntimeService = createDevServerRuntimeService;
//# sourceMappingURL=DevServerRuntimeService.js.map

SHA-256: c43103b6288ac567d8236455e9f209507e41ba6f1c719ecbc07c2362e43abc08