297 lines
8.7 KiB
TypeScript
297 lines
8.7 KiB
TypeScript
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as net from 'node:net';
|
|
import * as path from 'node:path';
|
|
|
|
import { connect as natsConnect, type NatsConnection } from 'nats';
|
|
|
|
import {
|
|
DEFAULT_SECURITY_POLICY,
|
|
DEFAULT_SUPERVISION_POLICY,
|
|
SecurityRealm,
|
|
} from '@open-matrix/core';
|
|
import { createPolicyEngine } from '@open-matrix/core/core/security/PolicyPresets';
|
|
import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js';
|
|
import { InMemoryBroker } from '@open-matrix/core/transport/InMemoryBroker.js';
|
|
import { InMemoryTransport } from '@open-matrix/core/transport/InMemoryTransport.js';
|
|
import { NatsTransport } from '@open-matrix/core/transport/NatsTransport.js';
|
|
import { authenticatorFromCredentialsRef } from '@open-matrix/nats-auth';
|
|
import { SqliteRecordStoreProvider } from '@open-matrix/system-sqlite';
|
|
import type { IResolvedRunnerTransportPlan } from './runner-transport.js';
|
|
|
|
export interface IRunnerRuntimeHandle {
|
|
readonly runtime: MatrixRuntime;
|
|
readonly transportMode: 'in-memory' | 'external' | 'embedded';
|
|
readonly root?: string;
|
|
readonly runnerTransport?: IResolvedRunnerTransportPlan;
|
|
readonly closed?: Promise<IRunnerRuntimeClosed>;
|
|
shutdown(): Promise<void>;
|
|
}
|
|
|
|
export interface IRunnerRuntimeClosed {
|
|
readonly error?: string;
|
|
}
|
|
|
|
export function formatRunnerRuntimeClosed(closed: IRunnerRuntimeClosed): string {
|
|
const detail = closed.error?.trim();
|
|
return detail ? `Runner transport closed: ${detail}` : 'Runner transport closed';
|
|
}
|
|
|
|
export function createRunnerSecurityRealm(): SecurityRealm {
|
|
return new SecurityRealm(
|
|
DEFAULT_SUPERVISION_POLICY,
|
|
DEFAULT_SECURITY_POLICY,
|
|
undefined,
|
|
undefined,
|
|
createPolicyEngine({ policyEngine: 'declarative', preset: 'standard' }),
|
|
);
|
|
}
|
|
|
|
export async function createRunnerRuntimeHandle(
|
|
root: string | undefined,
|
|
runnerTransport?: IResolvedRunnerTransportPlan,
|
|
): Promise<IRunnerRuntimeHandle> {
|
|
if (!runnerTransport) {
|
|
const transport = new InMemoryTransport(new InMemoryBroker(), {
|
|
name: 'mx-service-runner',
|
|
...(root ? { root } : {}),
|
|
});
|
|
const runtime = new MatrixRuntime({
|
|
transport,
|
|
logging: false,
|
|
allowDynamicCompilation: false,
|
|
securityRealm: createRunnerSecurityRealm(),
|
|
recordStoreProvider: new SqliteRecordStoreProvider(),
|
|
});
|
|
return {
|
|
runtime,
|
|
transportMode: 'in-memory',
|
|
...(root ? { root } : {}),
|
|
async shutdown(): Promise<void> {
|
|
await runtime.shutdown();
|
|
await transport.disconnect();
|
|
},
|
|
};
|
|
}
|
|
|
|
if (runnerTransport.mode === 'external') {
|
|
const authenticator = await authenticatorFromCredentialsRef(runnerTransport.credentialsRef);
|
|
const connection = await natsConnect({
|
|
servers: [runnerTransport.url],
|
|
name: `mx-runner-${process.pid}`,
|
|
timeout: 5_000,
|
|
...(authenticator ? { authenticator } : {}),
|
|
});
|
|
const closed = watchNatsConnectionClosed(connection);
|
|
const transport = new NatsTransport(connection, { root: runnerTransport.root });
|
|
const runtime = new MatrixRuntime({
|
|
transport,
|
|
logging: false,
|
|
allowDynamicCompilation: false,
|
|
securityRealm: createRunnerSecurityRealm(),
|
|
recordStoreProvider: new SqliteRecordStoreProvider(),
|
|
});
|
|
return {
|
|
runtime,
|
|
transportMode: 'external',
|
|
root: runnerTransport.root,
|
|
runnerTransport,
|
|
closed,
|
|
async shutdown(): Promise<void> {
|
|
await runtime.shutdown();
|
|
await transport.disconnect();
|
|
if (!connection.isClosed()) {
|
|
await connection.close();
|
|
}
|
|
await closed;
|
|
},
|
|
};
|
|
}
|
|
|
|
const child = await startEmbeddedNatsServer(runnerTransport);
|
|
const connection = await natsConnect({
|
|
servers: [runnerTransport.url],
|
|
name: `mx-runner-embedded-${process.pid}`,
|
|
timeout: 5_000,
|
|
});
|
|
const closed = watchNatsConnectionClosed(connection);
|
|
const transport = new NatsTransport(connection, { root: runnerTransport.root });
|
|
writePidFile(runnerTransport.pidFile, child.pid);
|
|
const runtime = new MatrixRuntime({
|
|
transport,
|
|
logging: false,
|
|
allowDynamicCompilation: false,
|
|
securityRealm: createRunnerSecurityRealm(),
|
|
});
|
|
|
|
return {
|
|
runtime,
|
|
transportMode: 'embedded',
|
|
root: runnerTransport.root,
|
|
runnerTransport,
|
|
closed,
|
|
async shutdown(): Promise<void> {
|
|
await runtime.shutdown();
|
|
await transport.disconnect();
|
|
if (!connection.isClosed()) {
|
|
await connection.close();
|
|
}
|
|
await closed;
|
|
await stopChildProcess(child, runnerTransport.pidFile);
|
|
},
|
|
};
|
|
}
|
|
|
|
function watchNatsConnectionClosed(connection: NatsConnection): Promise<IRunnerRuntimeClosed> {
|
|
return connection.closed().then(
|
|
(reason): IRunnerRuntimeClosed => {
|
|
if (reason instanceof Error) {
|
|
return { error: reason.message };
|
|
}
|
|
return {};
|
|
},
|
|
(err: unknown): IRunnerRuntimeClosed => ({
|
|
error: err instanceof Error ? err.message : String(err),
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function startEmbeddedNatsServer(
|
|
runnerTransport: IResolvedRunnerTransportPlan,
|
|
): Promise<ChildProcessWithoutNullStreams> {
|
|
if (!runnerTransport.binaryPath) {
|
|
throw new Error('Embedded runner transport requires binaryPath');
|
|
}
|
|
if (!runnerTransport.port) {
|
|
throw new Error('Embedded runner transport requires port');
|
|
}
|
|
if (!runnerTransport.wsPort) {
|
|
throw new Error('Embedded runner transport requires wsPort');
|
|
}
|
|
if (!runnerTransport.dataDir) {
|
|
throw new Error('Embedded runner transport requires dataDir');
|
|
}
|
|
|
|
fs.mkdirSync(runnerTransport.dataDir, { recursive: true });
|
|
const configPath = path.join(runnerTransport.dataDir, 'nats-server.conf');
|
|
const escapedStoreDir = runnerTransport.dataDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
fs.writeFileSync(configPath, [
|
|
'host: "127.0.0.1"',
|
|
`port: ${runnerTransport.port}`,
|
|
'jetstream {',
|
|
` store_dir: "${escapedStoreDir}"`,
|
|
'}',
|
|
'websocket {',
|
|
' host: "127.0.0.1"',
|
|
` port: ${runnerTransport.wsPort}`,
|
|
' no_tls: true',
|
|
'}',
|
|
'',
|
|
].join('\n'));
|
|
|
|
const child = spawn(runnerTransport.binaryPath, ['-c', configPath], {
|
|
stdio: 'pipe',
|
|
env: process.env,
|
|
windowsHide: true,
|
|
});
|
|
|
|
let stderr = '';
|
|
let spawnError: Error | null = null;
|
|
child.once('error', (error: Error) => {
|
|
spawnError = error;
|
|
});
|
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
stderr += chunk.toString('utf8');
|
|
});
|
|
|
|
await waitForPorts(
|
|
[runnerTransport.port, runnerTransport.wsPort],
|
|
10_000,
|
|
child,
|
|
() => {
|
|
if (spawnError) {
|
|
return `${spawnError.message}${stderr ? ` | ${stderr.trim()}` : ''}`;
|
|
}
|
|
return stderr.trim();
|
|
},
|
|
() => spawnError,
|
|
);
|
|
return child;
|
|
}
|
|
|
|
async function waitForPorts(
|
|
ports: number[],
|
|
timeoutMs: number,
|
|
child: ChildProcessWithoutNullStreams,
|
|
getLogs: () => string,
|
|
getSpawnError?: () => Error | null,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const spawnError = getSpawnError?.();
|
|
if (spawnError) {
|
|
throw new Error(`Failed to spawn nats-server: ${spawnError.message}`);
|
|
}
|
|
if (child.exitCode != null) {
|
|
throw new Error(`nats-server exited early (${child.exitCode}): ${getLogs()}`);
|
|
}
|
|
const allListening = await Promise.all(ports.map((port) => isPortListening(port)));
|
|
if (allListening.every(Boolean)) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error(`Timed out waiting for nats-server on ports ${ports.join(', ')}: ${getLogs()}`);
|
|
}
|
|
|
|
async function isPortListening(port: number): Promise<boolean> {
|
|
return await new Promise((resolve) => {
|
|
const socket = net.connect({ host: '127.0.0.1', port });
|
|
socket.once('connect', () => {
|
|
socket.destroy();
|
|
resolve(true);
|
|
});
|
|
socket.once('error', () => {
|
|
socket.destroy();
|
|
resolve(false);
|
|
});
|
|
socket.setTimeout(500, () => {
|
|
socket.destroy();
|
|
resolve(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function stopChildProcess(
|
|
child: ChildProcessWithoutNullStreams,
|
|
pidFile?: string,
|
|
): Promise<void> {
|
|
if (child.exitCode == null) {
|
|
await new Promise<void>((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
if (child.exitCode == null) {
|
|
child.kill('SIGKILL');
|
|
}
|
|
}, 2_000);
|
|
|
|
child.once('exit', () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
|
|
child.kill('SIGTERM');
|
|
});
|
|
}
|
|
if (pidFile) {
|
|
fs.rmSync(pidFile, { force: true });
|
|
}
|
|
}
|
|
|
|
function writePidFile(pidFile: string | undefined, pid: number | undefined): void {
|
|
if (!pidFile || !pid) {
|
|
return;
|
|
}
|
|
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
fs.writeFileSync(pidFile, `${pid}\n`, 'utf8');
|
|
}
|