606 lines
20 KiB
TypeScript
Raw Normal View History

import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js';
import { RequestReply } from '@open-matrix/core/engine/remoting/RequestReply.js';
import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js';
import { NatsTransport } from '@open-matrix/core/transport/NatsTransport.js';
import { connect as natsConnect, jwtAuthenticator, type NatsConnection } from 'nats';
import { loadMatrixServiceManifest, type ILoadedMatrixServiceManifest } from '../utils/service-manifest.js';
import { createRunnerSecurityRealm } from '../utils/runner-security.js';
export interface IActorRunCommandOptions {
readonly natsUrl?: string;
readonly root?: string;
readonly space?: string;
readonly jwt?: string;
readonly seed?: string;
readonly home?: string;
readonly export?: string;
readonly mount?: string;
readonly json?: boolean;
readonly props?: string;
readonly checkOp?: string;
readonly checkPayload?: string;
}
export interface IBrokerRunConfig {
readonly natsUrl: string;
readonly root: string;
readonly jwt?: string;
readonly seed?: string;
readonly checked: readonly string[];
}
type ActorConstructor = {
new (): MatrixActor;
readonly accepts?: Record<string, unknown>;
readonly emits?: Record<string, unknown>;
readonly subscribes?: Record<string, unknown>;
readonly name: string;
};
type PackageBootstrapFunction = (...args: readonly unknown[]) => Promise<unknown> | unknown;
export interface IPackageRunCommandOptions extends IActorRunCommandOptions {
readonly entry?: string;
readonly overrides?: string;
}
export interface IPackageRunSpec {
readonly packageDir: string;
readonly packageName: string;
readonly manifestPath: string;
readonly entryPath: string;
readonly exportName: string;
readonly mount: string;
readonly actorId: string;
readonly packageId: string;
readonly runtimeId: string;
readonly accepts: Record<string, unknown>;
readonly emits: Record<string, unknown>;
readonly subscribes: Record<string, unknown>;
readonly overrides: Record<string, unknown>;
readonly serviceInstance: ILoadedMatrixServiceManifest['serviceInstance'];
}
export async function actorRunCommand(
entry: string,
options: IActorRunCommandOptions,
): Promise<void> {
const entryPath = path.resolve(entry);
if (!fs.existsSync(entryPath)) {
throw new Error(`Actor entry not found: ${entryPath}`);
}
const exportName = options.export?.trim() || 'default';
const ActorClass = await loadActorConstructor(entryPath, exportName);
const mount = options.mount?.trim() || inferMountFromEntry(entryPath);
if (!mount) {
throw new Error('matrix actor run requires --mount <mount> when it cannot infer a mount from the entry file');
}
const broker = resolveActorRunBroker(options);
const connection = await connectActorNats(broker);
const transport = new NatsTransport(connection, { root: broker.root });
const runtime = new MatrixRuntime({
transport,
logging: false,
allowDynamicCompilation: false,
securityRealm: createRunnerSecurityRealm(),
});
const props = parseProps(options.props);
await runtime.createSupervised(ActorClass, mount, props);
await transport.flush();
const check = await runOptionalActorCheck(runtime, mount, options);
const result = {
ok: true,
mode: 'standalone-actor-runner',
entry: entryPath,
export: exportName,
actor: ActorClass.name,
mount,
root: broker.root,
effectiveMount: `${broker.root}.${mount}`,
natsUrl: broker.natsUrl,
authMode: broker.jwt ? 'jwt' : 'none',
hostStateWritten: false,
...(check ? { check } : {}),
};
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log('Actor mounted:');
console.log(` logical: ${result.mount}`);
console.log(` root: ${result.root}`);
console.log(` effective: ${result.effectiveMount}`);
console.log(` broker: ${result.natsUrl}`);
console.log('Press Ctrl+C to stop.');
}
if (check) {
await runtime.shutdown();
await transport.disconnect();
if (!connection.isClosed()) {
await connection.close();
}
return;
}
await waitForActorShutdown(async () => {
await runtime.shutdown();
await transport.disconnect();
if (!connection.isClosed()) {
await connection.close();
}
});
}
export async function packageRunCommand(
packageDir: string,
options: IPackageRunCommandOptions,
): Promise<void> {
const spec = resolvePackageRunSpec(packageDir, options);
const broker = resolveActorRunBroker(options);
const connection = await connectActorNats(broker);
const transport = new NatsTransport(connection, { root: broker.root });
const runtime = new MatrixRuntime({
transport,
logging: false,
allowDynamicCompilation: false,
securityRealm: createRunnerSecurityRealm(),
});
const bootstrap = await loadPackageBootstrap(spec.entryPath, spec.exportName);
const bootstrapResult = await bootstrap(
spec.overrides,
{ runtime },
undefined,
{
packageName: spec.packageName,
packageDir: spec.packageDir,
manifestPath: spec.manifestPath,
entryPath: spec.entryPath,
exportName: spec.exportName,
root: broker.root,
mount: spec.mount,
instance: spec.serviceInstance,
serviceInstance: spec.serviceInstance,
},
);
await transport.flush();
const check = await runOptionalActorCheck(runtime, spec.mount, options);
const result = {
ok: true,
mode: 'standalone-package-runner',
packageDir: spec.packageDir,
packageName: spec.packageName,
manifest: spec.manifestPath,
entry: spec.entryPath,
export: spec.exportName,
mount: spec.mount,
root: broker.root,
effectiveMount: `${broker.root}.${spec.mount}`,
natsUrl: broker.natsUrl,
authMode: broker.jwt ? 'jwt' : 'none',
hostStateWritten: false,
...(check ? { check } : {}),
};
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log('Package actor mounted:');
console.log(` package: ${result.packageName}`);
console.log(` logical: ${result.mount}`);
console.log(` root: ${result.root}`);
console.log(` effective: ${result.effectiveMount}`);
console.log(` broker: ${result.natsUrl}`);
console.log('Press Ctrl+C to stop.');
}
if (check) {
await shutdownPackageBootstrapResult(bootstrapResult, runtime);
await runtime.shutdown();
await transport.disconnect();
if (!connection.isClosed()) {
await connection.close();
}
return;
}
await waitForActorShutdown(async () => {
await shutdownPackageBootstrapResult(bootstrapResult, runtime);
await runtime.shutdown();
await transport.disconnect();
if (!connection.isClosed()) {
await connection.close();
}
});
}
export function resolveActorRunBroker(options: IActorRunCommandOptions): IBrokerRunConfig {
const candidateHomes = resolveCandidateHomes(options.home);
const files = candidateHomes.flatMap((home) => [
{
label: `${formatHomeLabel(home)}/credentials/matrix-broker.json`,
value: readBrokerCredentialFile(path.join(home, 'credentials', 'matrix-broker.json')),
},
{
label: `${formatHomeLabel(home)}/credentials/matrix-nats.json`,
value: readBrokerCredentialFile(path.join(home, 'credentials', 'matrix-nats.json')),
},
{
label: `${formatHomeLabel(home)}/config.json`,
value: readBrokerConfigFile(path.join(home, 'config.json')),
},
]);
const natsUrl = readOptionalString(options.natsUrl)
?? readOptionalString(process.env.MATRIX_NATS_URL)
?? readOptionalString(process.env.NATS_URL)
?? firstConfigured(files, 'natsUrl');
const root = readOptionalString(options.root)
?? readOptionalString(options.space)
?? readOptionalString(process.env.MATRIX_ROOT)
?? readOptionalString(process.env.MATRIX_SPACE)
?? firstConfigured(files, 'root')
?? firstConfigured(files, 'space');
const jwt = readOptionalString(options.jwt)
?? readOptionalString(process.env.MATRIX_NATS_JWT)
?? readOptionalString(process.env.NATS_JWT)
?? firstConfigured(files, 'jwt');
const seed = readOptionalString(options.seed)
?? readOptionalString(process.env.MATRIX_NATS_SEED)
?? readOptionalString(process.env.NATS_SEED)
?? firstConfigured(files, 'seed');
const checked = [
'--nats-url',
'--root',
'--space',
'--jwt',
'--seed',
'MATRIX_NATS_URL',
'MATRIX_ROOT',
'MATRIX_SPACE',
'MATRIX_NATS_JWT',
'MATRIX_NATS_SEED',
'NATS_URL',
'NATS_JWT',
'NATS_SEED',
...files.map((entry) => entry.label),
];
if (!natsUrl) {
throw new Error(`MATRIX_NATS_URL_REQUIRED: matrix actor/package run requires --nats-url or configured broker URL. Checked: ${checked.join(', ')}`);
}
if (!root) {
throw new Error('MATRIX_ROOT_REQUIRED: matrix actor/package run requires --root, --space, MATRIX_ROOT, MATRIX_SPACE, or configured root');
}
if ((jwt && !seed) || (!jwt && seed)) {
throw new Error('MATRIX_NATS_JWT_SEED_PAIR_REQUIRED: provide both JWT and seed, or neither for an unauthenticated local broker');
}
return {
natsUrl,
root,
...(jwt ? { jwt } : {}),
...(seed ? { seed } : {}),
checked,
};
}
export function resolvePackageRunSpec(packageDir: string, options: IPackageRunCommandOptions): IPackageRunSpec {
const loaded = loadMatrixServiceManifest(packageDir);
const entryPath = options.entry
? resolvePackageEntryPath(loaded.packageDir, options.entry)
: loaded.entryPath;
const exportName = options.export?.trim() || loaded.exportName;
const mount = options.mount?.trim() || loaded.mount;
if (!mount) {
throw new Error('matrix package run requires --mount <mount> when the package descriptor does not declare one');
}
const binding = resolvePackageBindingForMount(loaded, mount);
const componentType = binding?.type ?? loaded.serviceInstance.componentType ?? loaded.serviceInstance.class;
const actorId = componentType || mount;
const runtimeId = `standalone-package:${process.pid}:${sanitizeRuntimeIdPart(loaded.packageName)}:${sanitizeRuntimeIdPart(mount)}`;
return {
packageDir: loaded.packageDir,
packageName: loaded.packageName,
manifestPath: loaded.manifestPath,
entryPath,
exportName,
mount,
actorId,
packageId: loaded.packageName,
runtimeId,
accepts: acceptsArrayToRecord(binding?.accepts ?? []),
emits: {},
subscribes: {},
overrides: parseOverrides(options.overrides),
serviceInstance: loaded.serviceInstance,
};
}
async function connectActorNats(input: IBrokerRunConfig): Promise<NatsConnection> {
const encoder = new TextEncoder();
return await natsConnect({
servers: [input.natsUrl],
name: `matrix-actor-run-${input.root}-${process.pid}`,
timeout: 5_000,
...(input.jwt && input.seed
? {
authenticator: jwtAuthenticator(
() => input.jwt!,
() => encoder.encode(input.seed!),
),
}
: {}),
});
}
export async function loadActorConstructor(entryPath: string, exportName: string): Promise<ActorConstructor> {
const moduleUrl = `${pathToFileURL(entryPath).href}?matrix_actor_run=${Date.now()}-${Math.random().toString(16).slice(2)}`;
const mod = await import(moduleUrl) as Record<string, unknown>;
const requested = actorConstructorFromExport(mod[exportName]);
if (requested) {
return requested;
}
const available = Object.entries(mod)
.filter(([, value]) => actorConstructorFromExport(value))
.map(([name]) => name);
throw new Error(`No actor class found as export "${exportName}". Available exports: ${available.join(', ') || '(none)'}`);
}
async function loadPackageBootstrap(entryPath: string, exportName: string): Promise<PackageBootstrapFunction> {
const moduleUrl = `${pathToFileURL(entryPath).href}?matrix_package_run=${Date.now()}-${Math.random().toString(16).slice(2)}`;
const mod = await import(moduleUrl) as Record<string, unknown>;
const bootstrap = mod[exportName];
if (typeof bootstrap !== 'function') {
throw new Error(`Package run export "${exportName}" was not found in ${entryPath}`);
}
return bootstrap as PackageBootstrapFunction;
}
function actorConstructorFromExport(value: unknown): ActorConstructor | null {
if (typeof value !== 'function') {
return null;
}
const candidate = value as ActorConstructor;
return candidate.prototype ? candidate : null;
}
function inferMountFromEntry(entryPath: string): string {
return path.basename(entryPath, path.extname(entryPath))
.replace(/Actor$/i, '')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function parseProps(value: string | undefined): Record<string, unknown> | undefined {
const text = value?.trim();
if (!text) {
return undefined;
}
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('matrix actor run --props must be a JSON object');
}
return parsed as Record<string, unknown>;
}
function parseOverrides(value: string | undefined): Record<string, unknown> {
const text = value?.trim();
if (!text) {
return {};
}
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('matrix package run --overrides must be a JSON object');
}
return parsed as Record<string, unknown>;
}
async function runOptionalActorCheck(
runtime: MatrixRuntime,
mount: string,
options: IActorRunCommandOptions,
): Promise<{ readonly op: string; readonly result: unknown } | null> {
const op = options.checkOp?.trim();
if (!op) {
return null;
}
const payload = parseCheckPayload(options.checkPayload);
const result = await RequestReply.execute<Record<string, unknown>, unknown>(
runtime.getRootContext(),
mount,
op,
payload,
{ timeoutMs: 5_000 },
);
return { op, result };
}
function parseCheckPayload(value: string | undefined): Record<string, unknown> {
const text = value?.trim();
if (!text) {
return {};
}
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('matrix actor/package run --check-payload must be a JSON object');
}
return parsed as Record<string, unknown>;
}
function resolvePackageEntryPath(packageDir: string, entry: string): string {
const trimmed = entry.trim();
if (!trimmed) {
throw new Error('matrix package run --entry must be a non-empty path');
}
const entryPath = path.resolve(packageDir, trimmed);
if (!fs.existsSync(entryPath)) {
throw new Error(`matrix package run entry does not exist: ${trimmed}`);
}
return entryPath;
}
function resolvePackageBindingForMount(
loaded: ILoadedMatrixServiceManifest,
mount: string,
): ILoadedMatrixServiceManifest['packageComponents'][number] | ILoadedMatrixServiceManifest['packageRoot'] | undefined {
if (loaded.packageRoot?.mount === mount) {
return loaded.packageRoot;
}
return loaded.packageComponents.find((entry) => entry.mount === mount);
}
function acceptsArrayToRecord(accepts: readonly string[]): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const op of accepts) {
result[op] = {};
}
return result;
}
function sanitizeRuntimeIdPart(value: string): string {
return value
.replace(/^@/u, '')
.replace(/[^a-zA-Z0-9._-]+/gu, '-')
.replace(/-+/gu, '-')
.replace(/^-|-$/gu, '')
|| 'package';
}
async function shutdownPackageBootstrapResult(value: unknown, ownedRuntime: MatrixRuntime): Promise<void> {
if (!value || typeof value !== 'object') {
return;
}
const runtime = (value as { readonly runtime?: unknown }).runtime;
if (!runtime || typeof runtime !== 'object') {
return;
}
if (runtime === ownedRuntime) {
return;
}
const shutdown = (runtime as { readonly shutdown?: unknown }).shutdown;
if (typeof shutdown === 'function') {
await shutdown.call(runtime);
}
}
function waitForActorShutdown(onShutdown: () => Promise<void>): Promise<void> {
return new Promise<void>((resolve, reject) => {
let settled = false;
const finish = (error?: unknown): void => {
if (settled) return;
settled = true;
process.off('SIGINT', shutdown);
process.off('SIGTERM', shutdown);
if (error) {
reject(error);
return;
}
resolve();
};
const shutdown = (): void => {
void onShutdown().then(() => finish()).catch((error) => finish(error));
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
});
}
function resolveCandidateHomes(homeOption: string | undefined): readonly string[] {
const homes = [
homeOption ? path.resolve(homeOption) : undefined,
path.resolve(process.cwd(), '.matrix'),
path.resolve(os.homedir(), '.matrix'),
].filter((value): value is string => Boolean(value));
return [...new Set(homes)];
}
function formatHomeLabel(home: string): string {
const defaultHome = path.resolve(os.homedir(), '.matrix');
const cwdHome = path.resolve(process.cwd(), '.matrix');
if (home === defaultHome) return '~/.matrix';
if (home === cwdHome) return './.matrix';
return home;
}
function readBrokerCredentialFile(filePath: string): Record<string, string> {
const value = readJsonFile(filePath);
return normalizeBrokerConfig(value);
}
function readBrokerConfigFile(filePath: string): Record<string, string> {
const value = readJsonFile(filePath);
const nested = [
value,
value?.broker,
value?.nats,
value?.transport,
value?.credentials,
].filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === 'object' && !Array.isArray(entry)));
return nested.reduce<Record<string, string>>((result, entry) => ({
...result,
...normalizeBrokerConfig(entry),
}), {});
}
function normalizeBrokerConfig(value: Record<string, unknown> | null): Record<string, string> {
if (!value) {
return {};
}
return {
...(readOptionalString(value.natsUrl) ?? readOptionalString(value.url) ?? readOptionalString(value.brokerUrl) ? { natsUrl: (readOptionalString(value.natsUrl) ?? readOptionalString(value.url) ?? readOptionalString(value.brokerUrl))! } : {}),
...(readOptionalString(value.root) ?? readOptionalString(value.space) ?? readOptionalString(value.authorityRoot) ? { root: (readOptionalString(value.root) ?? readOptionalString(value.space) ?? readOptionalString(value.authorityRoot))! } : {}),
...(readOptionalString(value.space) ? { space: readOptionalString(value.space)! } : {}),
...(readOptionalString(value.jwt) ? { jwt: readOptionalString(value.jwt)! } : {}),
...(readOptionalString(value.seed) ? { seed: readOptionalString(value.seed)! } : {}),
};
}
function firstConfigured(
files: readonly { readonly value: Record<string, string> }[],
key: 'natsUrl' | 'root' | 'space' | 'jwt' | 'seed',
): string | undefined {
for (const file of files) {
const value = file.value[key];
if (value) {
return value;
}
}
return undefined;
}
function readJsonFile(filePath: string): Record<string, unknown> | null {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid JSON in ${filePath}: ${message}`);
}
}
function readOptionalString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}