1053 lines
35 KiB
TypeScript
1053 lines
35 KiB
TypeScript
/**
|
|
* mx run — package-first standalone runner.
|
|
*
|
|
* Preferred mode:
|
|
* mx run . --env dev
|
|
*
|
|
* Actor-file mode:
|
|
* mx run ./MyActor.ts --mount my-svc --root MY-ROOT
|
|
*/
|
|
|
|
import { pathToFileURL } from 'node:url';
|
|
import * as path from 'node:path';
|
|
import * as fs from 'node:fs';
|
|
import * as net from 'node:net';
|
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
import type { NatsConnection, Subscription } from 'nats';
|
|
import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js';
|
|
import {
|
|
buildMatrixHttpAssetMount,
|
|
MatrixHttpAssetEndpointActor,
|
|
} from '@open-matrix/system-gateway-http';
|
|
import { HostAuthStateStore, HostCredentialStore } from '@open-matrix/system-auth';
|
|
import {
|
|
serviceCommand,
|
|
startServiceCommand,
|
|
type IServiceCommandOptions,
|
|
type IStartedServiceCommand,
|
|
} from './service.js';
|
|
import { loadPackageEnvironment, loadPackageEnvironmentFromPath } from '../utils/package-environment.js';
|
|
import {
|
|
createRunnerRuntimeHandle,
|
|
formatRunnerRuntimeClosed,
|
|
type IRunnerRuntimeHandle,
|
|
} from '../utils/runner-runtime.js';
|
|
import { resolveNatsBinary } from '../utils/runner-transport.js';
|
|
import { resolveRunnerTransportPlan, type IResolvedRunnerTransportPlan } from '../utils/runner-transport.js';
|
|
import { startStandaloneWebappServer, type IStandaloneWebappServerHandle } from '../utils/standalone-webapp-server.js';
|
|
import { loadMatrixWebappManifest } from '../utils/webapp-manifest.js';
|
|
import {
|
|
deregisterRunnerRuntime,
|
|
heartbeatRunnerRuntime,
|
|
registerRunnerRuntime,
|
|
type IRunnerRuntimeRegistration,
|
|
type IRunnerWebappRouteMetadata,
|
|
} from '../utils/runner-control-plane.js';
|
|
import {
|
|
packageDeclaresStandaloneFactory,
|
|
type ILoadedMatrixServiceManifest,
|
|
} from '../utils/service-manifest.js';
|
|
import {
|
|
defaultRunnerControlMount,
|
|
defaultRunnerRuntimeMount,
|
|
mountRunnerRuntimeControl,
|
|
type RunnerRuntimeLifecycleState,
|
|
} from '../utils/runner-runtime-control.js';
|
|
import type { ILoadedMatrixWebappManifest } from '../utils/webapp-manifest.js';
|
|
|
|
export interface IRunCommandOptions {
|
|
readonly check?: boolean;
|
|
readonly json?: boolean;
|
|
readonly env?: string;
|
|
readonly envFile?: string;
|
|
readonly serve?: boolean;
|
|
readonly entry?: string;
|
|
readonly mount?: string;
|
|
readonly root?: string;
|
|
readonly export?: string;
|
|
readonly config?: string;
|
|
readonly secrets?: string;
|
|
readonly overrides?: string;
|
|
readonly broker?: boolean;
|
|
readonly brokerPort?: number;
|
|
readonly federation?: boolean;
|
|
readonly backboneUrl?: string;
|
|
readonly props?: string;
|
|
}
|
|
|
|
type ActorConstructor = {
|
|
new (): MatrixActor;
|
|
readonly accepts?: Record<string, unknown>;
|
|
readonly name: string;
|
|
};
|
|
|
|
type BrokerLike = {
|
|
publish(topic: string, payload: unknown): void;
|
|
};
|
|
|
|
type BrokerConstructor = new () => BrokerLike;
|
|
type TransportConstructor = new (broker: BrokerLike, options: { readonly name: string }) => unknown;
|
|
type RuntimeConstructor = new () => unknown;
|
|
type MatrixContextFactory = {
|
|
create(mount: string, transport: unknown, runtime: unknown): Parameters<MatrixActor['initialize']>[0];
|
|
};
|
|
type FederationPeerConstructor = new (options: {
|
|
readonly localRoot: string;
|
|
readonly localBus: unknown;
|
|
readonly instanceResolver: unknown;
|
|
}) => {
|
|
setBackbone(transport: unknown): Promise<void>;
|
|
start(): Promise<void>;
|
|
};
|
|
type ExactInstanceResolverConstructor = new () => unknown;
|
|
type CreateNatsTransport = (client: NatsConnection) => unknown;
|
|
|
|
interface IActorFileRunCommandOptions {
|
|
readonly mount: string;
|
|
readonly root?: string;
|
|
readonly export?: string;
|
|
readonly broker?: boolean;
|
|
readonly brokerPort?: number;
|
|
readonly federation?: boolean;
|
|
readonly backboneUrl?: string;
|
|
readonly props?: string;
|
|
}
|
|
|
|
interface IRunningNatsServer {
|
|
readonly port: number;
|
|
stop(): Promise<void>;
|
|
}
|
|
|
|
// P1.65 — set process.title for ps/tasklist visibility. Sourcing order:
|
|
// MATRIX_RUNTIME_KEY env → packageRef shortname → packageDir basename
|
|
// → MATRIX_RUNTIME_ID stripped → default 'runtime'.
|
|
function setRuntimeProcessTitle(input: {
|
|
readonly runtimeKey?: string;
|
|
readonly runtimeId?: string;
|
|
readonly packageDir?: string;
|
|
}): void {
|
|
const fromKey = readTrimmed(input.runtimeKey);
|
|
const fromPackageRef = readPackageRefShortName(input.packageDir);
|
|
const fromDir = readPackageDirBasename(input.packageDir);
|
|
const fromRuntimeId = readRuntimeIdShortName(input.runtimeId);
|
|
const name = fromKey ?? fromPackageRef ?? fromDir ?? fromRuntimeId ?? 'runtime';
|
|
process.title = `hivecast-runtime:${normalizeRuntimeTitleName(name)}`;
|
|
}
|
|
|
|
function readTrimmed(value: string | undefined): string | undefined {
|
|
if (typeof value !== 'string') return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
function readPackageRefShortName(packageDir: string | undefined): string | undefined {
|
|
if (!packageDir) return undefined;
|
|
try {
|
|
const manifestPath = path.join(packageDir, 'package.json');
|
|
if (!fs.existsSync(manifestPath)) return undefined;
|
|
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: unknown };
|
|
const name = typeof parsed.name === 'string' ? parsed.name : undefined;
|
|
if (!name) return undefined;
|
|
return name.split('/').pop();
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function readPackageDirBasename(packageDir: string | undefined): string | undefined {
|
|
if (!packageDir) return undefined;
|
|
const base = path.basename(packageDir);
|
|
return base.length > 0 ? base : undefined;
|
|
}
|
|
|
|
function readRuntimeIdShortName(runtimeId: string | undefined): string | undefined {
|
|
const value = readTrimmed(runtimeId);
|
|
if (!value) return undefined;
|
|
return value
|
|
.replace(/^RUNTIME-/i, '')
|
|
.replace(/^HOST-/i, '')
|
|
.replace(/^LOCAL-/i, '');
|
|
}
|
|
|
|
function normalizeRuntimeTitleName(value: string): string {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^@[^/]+\//, '')
|
|
.replace(/@[^@/]+$/, '')
|
|
.replace(/[^a-z0-9_-]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '') || 'runtime';
|
|
}
|
|
|
|
async function waitForPort(
|
|
port: 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 listening = await new Promise<boolean>((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);
|
|
});
|
|
});
|
|
|
|
if (listening) {
|
|
return;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for nats-server on port ${port}: ${getLogs()}`);
|
|
}
|
|
|
|
async function stopChildProcess(child: ChildProcessWithoutNullStreams): Promise<void> {
|
|
if (child.exitCode != null) {
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
if (child.exitCode == null) {
|
|
child.kill('SIGKILL');
|
|
}
|
|
}, 2000);
|
|
|
|
child.once('exit', () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
|
|
child.kill('SIGTERM');
|
|
});
|
|
}
|
|
|
|
async function startNatsServer(port: number, baseDir: string): Promise<IRunningNatsServer> {
|
|
const binaryPath = resolveNatsBinary(baseDir);
|
|
const child = spawn(binaryPath, ['-a', '127.0.0.1', '-p', String(port), '-js'], {
|
|
stdio: 'pipe',
|
|
env: process.env,
|
|
windowsHide: true,
|
|
});
|
|
|
|
let spawnError: Error | null = null;
|
|
let stderr = '';
|
|
child.once('error', (error: Error) => {
|
|
spawnError = error;
|
|
});
|
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
stderr += chunk.toString('utf8');
|
|
});
|
|
|
|
await waitForPort(port, 10_000, child, () => {
|
|
if (spawnError) {
|
|
return `${spawnError.message}${stderr ? ` | ${stderr.trim()}` : ''}`;
|
|
}
|
|
return stderr.trim();
|
|
}, () => spawnError);
|
|
|
|
return {
|
|
port,
|
|
async stop(): Promise<void> {
|
|
await stopChildProcess(child);
|
|
},
|
|
};
|
|
}
|
|
|
|
function localTopicToNatsSubject(topic: string, root: string, mount: string): string | null {
|
|
if (topic === `${mount}/$events`) {
|
|
return `${root}.${mount}.$events`;
|
|
}
|
|
if (topic.startsWith('$replies.')) {
|
|
return `${root}.$reply.${topic.slice('$replies.'.length)}`;
|
|
}
|
|
if (topic.startsWith(`${root}.$reply.`)) {
|
|
return topic;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function decodeNatsPayload(
|
|
data: Uint8Array,
|
|
jsonCodec: { decode(payload: Uint8Array): unknown },
|
|
stringCodec: { decode(payload: Uint8Array): string },
|
|
): unknown {
|
|
try {
|
|
return jsonCodec.decode(data);
|
|
} catch {
|
|
const text = stringCodec.decode(data);
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
}
|
|
|
|
function pumpSubscription(
|
|
subscription: Subscription,
|
|
label: string,
|
|
onMessage: (subject: string, payload: unknown) => void,
|
|
jsonCodec: { decode(payload: Uint8Array): unknown },
|
|
stringCodec: { decode(payload: Uint8Array): string },
|
|
): void {
|
|
void (async () => {
|
|
for await (const message of subscription) {
|
|
const payload = decodeNatsPayload(message.data, jsonCodec, stringCodec);
|
|
onMessage(message.subject, payload);
|
|
}
|
|
})().catch((error) => {
|
|
console.error(`[mx run] ${label} subscription failed: ${error}`);
|
|
});
|
|
}
|
|
|
|
export async function runCommand(
|
|
targetPath: string,
|
|
options: IRunCommandOptions,
|
|
): Promise<void> {
|
|
const resolvedPath = path.resolve(targetPath);
|
|
if (!fs.existsSync(resolvedPath)) {
|
|
throw new Error(`Run target not found: ${resolvedPath}`);
|
|
}
|
|
|
|
const targetStats = fs.statSync(resolvedPath);
|
|
if (targetStats.isDirectory()) {
|
|
await runPackageCommand(resolvedPath, options);
|
|
return;
|
|
}
|
|
|
|
await runActorFileCommand(resolvedPath, assertActorFileRunOptions(options));
|
|
}
|
|
|
|
async function runPackageCommand(
|
|
packageDir: string,
|
|
options: IRunCommandOptions,
|
|
): Promise<void> {
|
|
// P1.65 — name the runtime process for ps/tasklist. Sourcing order:
|
|
// MATRIX_RUNTIME_KEY env → packageRef shortname → packageDir basename
|
|
// → MATRIX_RUNTIME_ID → 'runtime'.
|
|
setRuntimeProcessTitle({
|
|
runtimeKey: process.env.MATRIX_RUNTIME_KEY,
|
|
runtimeId: process.env.MATRIX_RUNTIME_ID,
|
|
packageDir,
|
|
});
|
|
const hasServiceManifest = packageDeclaresStandaloneFactory(packageDir);
|
|
const webapp = loadMatrixWebappManifest(packageDir);
|
|
|
|
if (!options.serve && !hasServiceManifest && !webapp) {
|
|
throw new Error(
|
|
`Package run target ${packageDir} does not declare a matrix.json runtime.factory. `
|
|
+ 'Use a package directory with runtime.factory metadata, or pass an actor file path with --mount.',
|
|
);
|
|
}
|
|
|
|
if (options.serve || (!hasServiceManifest && webapp)) {
|
|
await runStandaloneWebappPackage(packageDir, options, {
|
|
hasServiceManifest,
|
|
webapp,
|
|
serve: options.serve === true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const result = await serviceCommand(packageDir, {
|
|
check: options.check,
|
|
json: options.json,
|
|
env: options.env,
|
|
envFile: options.envFile,
|
|
entry: options.entry,
|
|
export: options.export,
|
|
root: options.root,
|
|
mount: options.mount,
|
|
config: options.config,
|
|
secrets: options.secrets,
|
|
overrides: options.overrides,
|
|
} satisfies IServiceCommandOptions);
|
|
|
|
if (!options.check) {
|
|
return;
|
|
}
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
return;
|
|
}
|
|
|
|
console.log(`Validated run-in-place bootstrap for ${result.packageName}`);
|
|
console.log(`manifest: ${result.manifestPath}`);
|
|
console.log(`entry: ${result.entryPath}`);
|
|
console.log(`export: ${result.exportName}`);
|
|
}
|
|
|
|
async function runStandaloneWebappPackage(
|
|
packageDir: string,
|
|
options: IRunCommandOptions,
|
|
state: {
|
|
readonly hasServiceManifest: boolean;
|
|
readonly webapp: ReturnType<typeof loadMatrixWebappManifest>;
|
|
readonly serve: boolean;
|
|
},
|
|
): Promise<void> {
|
|
if (!state.webapp) {
|
|
throw new Error(`Package run target ${packageDir} does not declare matrix.json webapp metadata`);
|
|
}
|
|
const envName = options.env?.trim();
|
|
const envFile = options.envFile?.trim();
|
|
if (!envName) {
|
|
throw new Error('Package webapp runtime mode requires --env <name>');
|
|
}
|
|
|
|
const loadedEnvironment = envFile
|
|
? loadPackageEnvironmentFromPath(packageDir, envFile, envName)
|
|
: loadPackageEnvironment(packageDir, envName);
|
|
const root = options.root?.trim() || loadedEnvironment.environment.runtime.root;
|
|
const runnerTransport = resolveRunnerTransportPlan(packageDir, loadedEnvironment, root);
|
|
|
|
let serviceHandle: IStartedServiceCommand | undefined;
|
|
let runnerRuntimeHandle: IRunnerRuntimeHandle | undefined;
|
|
let webappServer: IStandaloneWebappServerHandle | undefined;
|
|
let runtimeRegistration: IRunnerRuntimeRegistration | undefined;
|
|
let webappAssetMount: string | undefined;
|
|
let heartbeatTimer: NodeJS.Timeout | undefined;
|
|
let runtimeReclaimAttempts = 0;
|
|
let lifecycleState: RunnerRuntimeLifecycleState = 'starting';
|
|
let requestRuntimeShutdown: (reason: string) => void = () => undefined;
|
|
const shutdownRequested = new Promise<string>((resolve) => {
|
|
requestRuntimeShutdown = resolve;
|
|
});
|
|
|
|
try {
|
|
if (state.hasServiceManifest) {
|
|
serviceHandle = await startServiceCommand(packageDir, {
|
|
env: envName,
|
|
envFile,
|
|
entry: options.entry,
|
|
export: options.export,
|
|
root: options.root,
|
|
mount: options.mount,
|
|
config: options.config,
|
|
secrets: options.secrets,
|
|
overrides: options.overrides,
|
|
} satisfies IServiceCommandOptions);
|
|
} else {
|
|
runnerRuntimeHandle = await createRunnerRuntimeHandle(root, runnerTransport);
|
|
registerHostHomeService(runnerRuntimeHandle.runtime, loadedEnvironment.environment.host?.matrixDir);
|
|
const runtimeId = resolveWebappRuntimeId(state.webapp, loadedEnvironment);
|
|
const runtimeMount = loadedEnvironment.environment.runtime.runtimeMount
|
|
?? process.env.MATRIX_RUNTIME_MOUNT
|
|
?? defaultRunnerRuntimeMount(runtimeId);
|
|
const controlMount = loadedEnvironment.environment.runtime.controlMount
|
|
?? process.env.MATRIX_RUNTIME_CONTROL_MOUNT
|
|
?? defaultRunnerControlMount(runtimeId);
|
|
await mountRunnerRuntimeControl({
|
|
runtime: runnerRuntimeHandle.runtime,
|
|
runtimeId,
|
|
runtimeMount,
|
|
controlMount,
|
|
startedAt: new Date().toISOString(),
|
|
getState: () => lifecycleState,
|
|
requestShutdown: (reason) => requestRuntimeShutdown(reason),
|
|
loadedPackage: {
|
|
packageRef: state.webapp.packageName,
|
|
},
|
|
});
|
|
if (state.serve) {
|
|
webappServer = await startStandaloneWebappServer({
|
|
loadedEnvironment,
|
|
webapp: state.webapp,
|
|
runnerTransport,
|
|
});
|
|
}
|
|
webappAssetMount = await mountMatrixHttpAssetEndpoint(runnerRuntimeHandle.runtime, state.webapp, runtimeId);
|
|
runtimeRegistration = await registerRunnerRuntime(
|
|
runnerRuntimeHandle.runtime,
|
|
buildWebappRuntimeManifest(state.webapp),
|
|
loadedEnvironment,
|
|
runnerTransport,
|
|
undefined,
|
|
runtimeMount,
|
|
controlMount,
|
|
buildRunnerWebappRouteMetadata(state.webapp, webappAssetMount, webappServer),
|
|
);
|
|
if (!options.check) {
|
|
const heartbeatRuntime = runnerRuntimeHandle.runtime;
|
|
heartbeatTimer = setInterval(() => {
|
|
void (async () => {
|
|
const heartbeat = await heartbeatRunnerRuntime(heartbeatRuntime, runtimeRegistration);
|
|
if (!heartbeat || heartbeat.known) {
|
|
runtimeReclaimAttempts = 0;
|
|
return;
|
|
}
|
|
runtimeReclaimAttempts += 1;
|
|
if (runtimeReclaimAttempts === 1 || runtimeReclaimAttempts % 10 === 0) {
|
|
console.error(
|
|
`[mx run] registry forgot runtime claim for ${heartbeat.runtimeId} (renewed=0); reclaiming`,
|
|
);
|
|
}
|
|
const reclaimed = await runtimeRegistration?.reclaim();
|
|
if (reclaimed) {
|
|
console.error(
|
|
`[mx run] runtime reclaim recovered for ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempt(s): ${reclaimed.inventoryClaimCount} inventory claims re-issued`,
|
|
);
|
|
runtimeReclaimAttempts = 0;
|
|
}
|
|
})().catch((error: unknown) => {
|
|
if (runtimeReclaimAttempts === 1 || runtimeReclaimAttempts % 10 === 0) {
|
|
console.error(
|
|
`[mx run] runtime reclaim failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
});
|
|
}, 10_000);
|
|
}
|
|
}
|
|
|
|
if (state.serve && serviceHandle) {
|
|
webappServer = await startStandaloneWebappServer({
|
|
loadedEnvironment,
|
|
webapp: state.webapp,
|
|
runnerTransport: serviceHandle?.result.runnerTransport ?? runnerTransport,
|
|
});
|
|
}
|
|
lifecycleState = 'live';
|
|
|
|
const buildServeResult = () => ({
|
|
packageName: state.webapp?.packageName,
|
|
packageDir,
|
|
environmentName: loadedEnvironment.envName,
|
|
environmentPath: loadedEnvironment.environmentPath,
|
|
root,
|
|
mode: state.serve ? 'standalone-webapp' : 'webapp-assets',
|
|
...(webappAssetMount ? { assetMount: webappAssetMount } : {}),
|
|
...(webappServer ? {
|
|
baseUrl: webappServer.baseUrl,
|
|
httpPort: webappServer.port,
|
|
bootstrapPath: webappServer.bootstrapPath,
|
|
natsWsPath: webappServer.natsWsPath,
|
|
} : {}),
|
|
runnerTransport: serviceHandle?.result.runnerTransport ?? runnerTransport,
|
|
...(serviceHandle ? { service: serviceHandle.result } : {}),
|
|
});
|
|
|
|
const shutdownAll = async (): Promise<void> => {
|
|
lifecycleState = 'stopping';
|
|
if (heartbeatTimer) {
|
|
clearInterval(heartbeatTimer);
|
|
heartbeatTimer = undefined;
|
|
}
|
|
if (webappServer) {
|
|
const current = webappServer;
|
|
webappServer = undefined;
|
|
await current.shutdown();
|
|
}
|
|
if (serviceHandle) {
|
|
const current = serviceHandle;
|
|
serviceHandle = undefined;
|
|
await current.shutdown();
|
|
}
|
|
if (runnerRuntimeHandle) {
|
|
const current = runnerRuntimeHandle;
|
|
runnerRuntimeHandle = undefined;
|
|
await deregisterRunnerRuntime(current.runtime, runtimeRegistration);
|
|
await current.shutdown();
|
|
}
|
|
lifecycleState = 'stopped';
|
|
};
|
|
|
|
if (options.check) {
|
|
const result = buildServeResult();
|
|
await shutdownAll();
|
|
if (options.json) {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} else if (state.serve) {
|
|
console.log(`Validated standalone webapp serve for ${state.webapp.packageName}`);
|
|
console.log(`base: ${result.baseUrl}`);
|
|
} else {
|
|
console.log(`Validated webapp asset runtime for ${state.webapp.packageName}`);
|
|
console.log(`asset mount: ${result.assetMount ?? '(service runtime)'}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify(buildServeResult(), null, 2));
|
|
} else if (state.serve && webappServer) {
|
|
console.log(`[mx run] serving ${state.webapp.packageName} at ${webappServer.baseUrl}`);
|
|
console.log(`[mx run] bootstrap: ${webappServer.baseUrl}${webappServer.bootstrapPath}`);
|
|
console.log(`[mx run] nats-ws: ${webappServer.baseUrl}${webappServer.natsWsPath}`);
|
|
} else {
|
|
console.log(`[mx run] running ${state.webapp.packageName} web assets at ${webappAssetMount ?? '(service runtime)'}`);
|
|
}
|
|
|
|
const stopTriggers: Promise<void>[] = [
|
|
waitForShutdownSignal(shutdownAll),
|
|
shutdownRequested.then(async () => {
|
|
await shutdownAll();
|
|
}),
|
|
];
|
|
if (serviceHandle) {
|
|
stopTriggers.push(serviceHandle.shutdownRequested.then(async () => {
|
|
await shutdownAll();
|
|
}));
|
|
if (serviceHandle.transportClosed) {
|
|
stopTriggers.push(serviceHandle.transportClosed.then((closed) => {
|
|
throw new Error(formatRunnerRuntimeClosed(closed));
|
|
}));
|
|
}
|
|
}
|
|
if (runnerRuntimeHandle?.closed) {
|
|
stopTriggers.push(runnerRuntimeHandle.closed.then((closed) => {
|
|
throw new Error(formatRunnerRuntimeClosed(closed));
|
|
}));
|
|
}
|
|
|
|
await Promise.race(stopTriggers);
|
|
return;
|
|
} catch (error) {
|
|
lifecycleState = 'failed';
|
|
if (webappServer) {
|
|
await webappServer.shutdown().catch(() => undefined);
|
|
}
|
|
if (serviceHandle) {
|
|
await serviceHandle.shutdown().catch(() => undefined);
|
|
}
|
|
if (runnerRuntimeHandle) {
|
|
await deregisterRunnerRuntime(runnerRuntimeHandle.runtime, runtimeRegistration).catch(() => undefined);
|
|
await runnerRuntimeHandle.shutdown().catch(() => undefined);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function registerHostHomeService(
|
|
runtime: IRunnerRuntimeHandle['runtime'],
|
|
matrixDir: string | undefined,
|
|
): void {
|
|
const hostHome = matrixDir?.trim();
|
|
if (hostHome) {
|
|
runtime.registerService('matrix-host-home', hostHome);
|
|
runtime.registerService('credential-store', new HostCredentialStore(new HostAuthStateStore(hostHome)));
|
|
}
|
|
}
|
|
|
|
async function mountMatrixHttpAssetEndpoint(
|
|
runtime: IRunnerRuntimeHandle['runtime'],
|
|
webapp: ILoadedMatrixWebappManifest,
|
|
runtimeId?: string,
|
|
): Promise<string> {
|
|
const assetMount = buildMatrixHttpAssetMount(webapp.appName, runtimeId);
|
|
if (!runtime.hasComponent(assetMount)) {
|
|
await runtime.createSupervised(MatrixHttpAssetEndpointActor as unknown as new () => MatrixActor, assetMount, {
|
|
assetEndpoint: {
|
|
packageName: webapp.packageName,
|
|
appName: webapp.appName,
|
|
distDir: webapp.distDir,
|
|
entryFile: webapp.entryFile,
|
|
},
|
|
});
|
|
}
|
|
return assetMount;
|
|
}
|
|
|
|
function buildRunnerWebappRouteMetadata(
|
|
webapp: ILoadedMatrixWebappManifest,
|
|
assetMount: string,
|
|
webappServer?: IStandaloneWebappServerHandle,
|
|
): IRunnerWebappRouteMetadata {
|
|
return {
|
|
appName: webapp.appName,
|
|
routePrefix: `/apps/${webapp.appName}/`,
|
|
...(webapp.displayName ? { displayName: webapp.displayName } : {}),
|
|
...(webapp.icon ? { icon: webapp.icon } : {}),
|
|
...(typeof webapp.navOrder === 'number' ? { navOrder: webapp.navOrder } : {}),
|
|
...(webapp.description ? { description: webapp.description } : {}),
|
|
...(webapp.shells && webapp.shells.length > 0 ? { shells: webapp.shells } : {}),
|
|
assetMount,
|
|
...(webappServer ? {
|
|
origin: webappServer.baseUrl,
|
|
port: webappServer.port,
|
|
} : {}),
|
|
};
|
|
}
|
|
|
|
function buildWebappRuntimeManifest(
|
|
webapp: NonNullable<ReturnType<typeof loadMatrixWebappManifest>>,
|
|
): ILoadedMatrixServiceManifest {
|
|
return {
|
|
packageDir: webapp.packageDir,
|
|
packageName: webapp.packageName,
|
|
manifestPath: webapp.manifestPath,
|
|
entryPath: path.join(webapp.distDir, webapp.entryFile),
|
|
exportName: 'webapp',
|
|
kind: 'factory',
|
|
overrides: {},
|
|
packageComponents: [],
|
|
serviceInstance: {
|
|
id: webapp.packageName,
|
|
packageName: webapp.packageName,
|
|
class: 'webapp',
|
|
rootKind: 'package-root',
|
|
autoStart: true,
|
|
registrationSource: 'matrix-service',
|
|
},
|
|
};
|
|
}
|
|
|
|
function resolveWebappRuntimeId(
|
|
webapp: NonNullable<ReturnType<typeof loadMatrixWebappManifest>>,
|
|
loadedEnvironment: ReturnType<typeof loadPackageEnvironmentFromPath>,
|
|
): string {
|
|
const runtimeId = loadedEnvironment.environment.runtime.runtimeId?.trim();
|
|
if (runtimeId) {
|
|
return runtimeId;
|
|
}
|
|
return webapp.packageName;
|
|
}
|
|
|
|
function actorConstructorFromExport(value: unknown): ActorConstructor | null {
|
|
if (typeof value !== 'function') {
|
|
return null;
|
|
}
|
|
return value as ActorConstructor;
|
|
}
|
|
|
|
function requireFunctionExport<T>(
|
|
moduleExports: Record<string, unknown>,
|
|
exportName: string,
|
|
): T {
|
|
const value = moduleExports[exportName];
|
|
if (typeof value !== 'function') {
|
|
throw new Error(`Expected function export ${exportName}`);
|
|
}
|
|
return value as T;
|
|
}
|
|
|
|
function requireObjectExport<T extends object>(
|
|
moduleExports: Record<string, unknown>,
|
|
exportName: string,
|
|
): T {
|
|
const value = moduleExports[exportName];
|
|
if (!value || typeof value !== 'object') {
|
|
throw new Error(`Expected object export ${exportName}`);
|
|
}
|
|
return value as T;
|
|
}
|
|
|
|
function assertActorFileRunOptions(options: IRunCommandOptions): IActorFileRunCommandOptions {
|
|
const mount = options.mount?.trim();
|
|
if (!mount) {
|
|
throw new Error(
|
|
'Actor-file mode requires --mount. '
|
|
+ 'For package execution use: mx run . --env <name>',
|
|
);
|
|
}
|
|
return {
|
|
mount,
|
|
root: options.root,
|
|
export: options.export,
|
|
broker: options.broker,
|
|
brokerPort: options.brokerPort,
|
|
federation: options.federation,
|
|
backboneUrl: options.backboneUrl,
|
|
props: options.props,
|
|
};
|
|
}
|
|
|
|
async function waitForShutdownSignal(onShutdown: () => Promise<void>): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
let settled = false;
|
|
const complete = (error?: unknown) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
process.off('SIGINT', handleSignal);
|
|
process.off('SIGTERM', handleSignal);
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve();
|
|
};
|
|
|
|
const handleSignal = () => {
|
|
void onShutdown()
|
|
.then(() => complete())
|
|
.catch((error) => complete(error));
|
|
};
|
|
|
|
process.on('SIGINT', handleSignal);
|
|
process.on('SIGTERM', handleSignal);
|
|
});
|
|
}
|
|
|
|
async function runActorFileCommand(
|
|
actorPath: string,
|
|
options: IActorFileRunCommandOptions,
|
|
): Promise<void> {
|
|
const root = options.root ?? 'standalone';
|
|
const mount = options.mount;
|
|
const exportName = options.export ?? 'default';
|
|
const props = options.props ? JSON.parse(options.props) : {};
|
|
|
|
// ── Step 1: Import actor class ──────────────────────────────────────
|
|
const moduleUrl = pathToFileURL(actorPath).href;
|
|
const mod = await import(moduleUrl) as Record<string, unknown>;
|
|
|
|
// Try named export, then default, then first export that looks like a class
|
|
let ActorClass = actorConstructorFromExport(mod[exportName]);
|
|
if (!ActorClass && exportName === 'default') {
|
|
// Try first exported class
|
|
for (const [key, val] of Object.entries(mod)) {
|
|
const candidate = actorConstructorFromExport(val);
|
|
if (candidate && key !== 'default') {
|
|
ActorClass = candidate;
|
|
console.log(`[mx run] Using export: ${key}`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!ActorClass) {
|
|
const available = Object.keys(mod).filter(k => actorConstructorFromExport(mod[k]));
|
|
throw new Error(
|
|
`No actor class found as export '${exportName}'. Available exports: ${available.join(', ') || '(none)'}`
|
|
);
|
|
}
|
|
|
|
// ── Step 2: Set up transport ────────────────────────────────────────
|
|
//
|
|
// Three modes:
|
|
// 1. InMemory only (default) — local actors talk, no external access
|
|
// 2. +broker — starts embedded NATS NATS on a port, CLI can connect
|
|
// 3. +federation — connects to NATS hub backbone, world can reach us
|
|
|
|
// Dynamic imports — import individual modules to avoid barrel hangs
|
|
const cwd = process.cwd();
|
|
|
|
async function importModule(relativePath: string): Promise<Record<string, unknown>> {
|
|
const full = path.resolve(cwd, relativePath);
|
|
return await import(pathToFileURL(full).href) as Record<string, unknown>;
|
|
}
|
|
|
|
const [brokerMod, transportMod, contextMod, runtimeMod] = await Promise.all([
|
|
importModule('src/transport/InMemoryBroker.ts'),
|
|
importModule('src/transport/InMemoryTransport.ts'),
|
|
importModule('src/engine/core/MatrixContext.ts'),
|
|
importModule('src/core/MockRuntime.ts'),
|
|
]);
|
|
|
|
const InMemoryBroker = requireFunctionExport<BrokerConstructor>(brokerMod, 'InMemoryBroker');
|
|
const InMemoryTransport = requireFunctionExport<TransportConstructor>(transportMod, 'InMemoryTransport');
|
|
const MatrixContext = requireObjectExport<MatrixContextFactory>(contextMod, 'MatrixContext');
|
|
const MockRuntime = requireFunctionExport<RuntimeConstructor>(runtimeMod, 'MockRuntime');
|
|
|
|
const broker = new InMemoryBroker();
|
|
const transport = new InMemoryTransport(broker, { name: 'mx-run' });
|
|
const runtime = new MockRuntime();
|
|
|
|
// ── Step 3: Optional embedded NATS NATS server ──────────────────────────────
|
|
let natsServer: IRunningNatsServer | null = null;
|
|
let natsClient: NatsConnection | null = null;
|
|
|
|
if (options.broker || options.federation) {
|
|
const port = options.brokerPort ?? 4222;
|
|
try {
|
|
const { connect, JSONCodec, StringCodec } = await import('nats');
|
|
const jsonCodec = JSONCodec();
|
|
const stringCodec = StringCodec();
|
|
|
|
natsServer = await startNatsServer(port, cwd);
|
|
|
|
console.log(`[mx run] NATS server listening on nats://127.0.0.1:${port}`);
|
|
|
|
// Bridge InMemoryBroker ↔ local NATS so actors are reachable via NATS
|
|
natsClient = await connect({
|
|
servers: `nats://127.0.0.1:${port}`,
|
|
name: `mx-run-${process.pid}`,
|
|
timeout: 5000,
|
|
});
|
|
|
|
const actorInbox = `${root}.${mount}.$inbox`;
|
|
pumpSubscription(
|
|
natsClient.subscribe(actorInbox),
|
|
actorInbox,
|
|
(_subject, payload) => {
|
|
broker.publish(`${mount}/$inbox`, payload);
|
|
},
|
|
jsonCodec,
|
|
stringCodec,
|
|
);
|
|
|
|
const rootInbox = `${root}.$inbox`;
|
|
pumpSubscription(
|
|
natsClient.subscribe(rootInbox),
|
|
rootInbox,
|
|
(_subject, payload) => {
|
|
broker.publish('$inbox', payload);
|
|
},
|
|
jsonCodec,
|
|
stringCodec,
|
|
);
|
|
|
|
// Hook actor replies — the actor publishes to the replyTo topic
|
|
const origPublish = broker.publish.bind(broker);
|
|
broker.publish = (topic: string, payload: unknown) => {
|
|
origPublish(topic, payload);
|
|
const subject = localTopicToNatsSubject(topic, root, mount);
|
|
if (subject) {
|
|
natsClient?.publish(subject, jsonCodec.encode(payload));
|
|
}
|
|
};
|
|
} catch (err) {
|
|
throw new Error(`Failed to start NATS server for mx run: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
// ── Step 4: Optional Federation ─────────────────────────────────────
|
|
if (options.federation) {
|
|
const backboneUrl = options.backboneUrl ?? 'nats://hub.example.com:4222';
|
|
try {
|
|
const fedPath = path.resolve(cwd, 'packages', 'federation', 'dist', 'src', 'index.js');
|
|
const fedMod = await import(pathToFileURL(fedPath).href) as Record<string, unknown>;
|
|
|
|
const FederationPeer = requireFunctionExport<FederationPeerConstructor>(fedMod, 'FederationPeer');
|
|
const ExactInstanceResolver = requireFunctionExport<ExactInstanceResolverConstructor>(fedMod, 'ExactInstanceResolver');
|
|
const createNatsTransport = requireFunctionExport<CreateNatsTransport>(fedMod, 'createNatsTransport');
|
|
const { connect } = await import('nats');
|
|
|
|
// Connect to backbone
|
|
const backboneClient = await connect({
|
|
servers: backboneUrl,
|
|
name: `mx-run-fed-${root}-${process.pid}`,
|
|
timeout: 10000,
|
|
});
|
|
|
|
console.log(`[mx run] Federation backbone connected: ${backboneUrl}`);
|
|
|
|
const backboneTransport = createNatsTransport(backboneClient);
|
|
|
|
if (!natsServer) {
|
|
throw new Error('Federation mode requires the embedded NATS server to be running');
|
|
}
|
|
const localNats = await connect({
|
|
servers: `nats://127.0.0.1:${options.brokerPort ?? 4222}`,
|
|
name: `mx-run-fed-local-${process.pid}`,
|
|
timeout: 3000,
|
|
});
|
|
const localBus = createNatsTransport(localNats);
|
|
|
|
const peer = new FederationPeer({
|
|
localRoot: root,
|
|
localBus,
|
|
instanceResolver: new ExactInstanceResolver(),
|
|
});
|
|
|
|
await peer.setBackbone(backboneTransport);
|
|
await peer.start();
|
|
|
|
console.log(`[mx run] FederationPeer active for root: ${root}`);
|
|
console.log(`[mx run] Actor reachable at: ${root}/${mount}`);
|
|
} catch (err) {
|
|
throw new Error(`Federation setup failed for mx run: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
// ── Step 5: Boot the actor ──────────────────────────────────────────
|
|
const context = MatrixContext.create(mount, transport, runtime);
|
|
const actor = new ActorClass();
|
|
await actor.initialize(context, ActorClass.name || 'Actor');
|
|
|
|
// Assign actor properties requested by the CLI.
|
|
for (const [key, value] of Object.entries(props)) {
|
|
Object.defineProperty(actor, key, {
|
|
value,
|
|
writable: true,
|
|
enumerable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
const accepts = ActorClass.accepts
|
|
? Object.keys(ActorClass.accepts).join(', ')
|
|
: 'N/A';
|
|
|
|
console.log('');
|
|
console.log('╔════════════════════════════════════════════════╗');
|
|
console.log('║ mx run — Actor Live ║');
|
|
console.log('╠════════════════════════════════════════════════╣');
|
|
console.log(`║ Actor: ${ActorClass.name}`.padEnd(49) + '║');
|
|
console.log(`║ Mount: ${mount}`.padEnd(49) + '║');
|
|
console.log(`║ Root: ${root}`.padEnd(49) + '║');
|
|
console.log(`║ Accepts: ${accepts}`.padEnd(49) + '║');
|
|
if (options.broker || options.federation) {
|
|
console.log(`║ Broker: nats://127.0.0.1:${options.brokerPort ?? 4222}`.padEnd(49) + '║');
|
|
}
|
|
if (options.federation) {
|
|
console.log(`║ Federation: CONNECTED`.padEnd(49) + '║');
|
|
console.log(`║ URI: ${root}/${mount}`.padEnd(49) + '║');
|
|
}
|
|
console.log('╚════════════════════════════════════════════════╝');
|
|
console.log('');
|
|
|
|
if (options.broker) {
|
|
console.log(`Talk to this actor:`);
|
|
console.log(` matrix send ${mount} $introspect -r ${root}`);
|
|
console.log('');
|
|
}
|
|
|
|
if (options.federation) {
|
|
console.log(`From FlowPad or a federated client:`);
|
|
console.log(` flow().fromValue({}).viaRemote("${root}/${mount}", "introspect")`);
|
|
console.log('');
|
|
}
|
|
|
|
console.log('Press Ctrl+C to stop.');
|
|
|
|
// Keep process alive
|
|
await new Promise<void>(() => {
|
|
const shutdown = async () => {
|
|
console.log('\n[mx run] Shutting down...');
|
|
try {
|
|
if (natsClient && !natsClient.isClosed()) {
|
|
await natsClient.drain();
|
|
}
|
|
} catch {
|
|
// Best-effort shutdown only.
|
|
}
|
|
try {
|
|
await natsServer?.stop();
|
|
} catch {
|
|
// Best-effort shutdown only.
|
|
}
|
|
process.exit(0);
|
|
};
|
|
|
|
process.on('SIGINT', () => {
|
|
void shutdown();
|
|
});
|
|
});
|
|
}
|