694 lines
24 KiB
TypeScript
694 lines
24 KiB
TypeScript
|
|
import * as fs from 'node:fs';
|
||
|
|
import * as os from 'node:os';
|
||
|
|
import * as path from 'node:path';
|
||
|
|
import { pathToFileURL } from 'node:url';
|
||
|
|
|
||
|
|
import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js';
|
||
|
|
import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js';
|
||
|
|
import { RequestReply } from '@open-matrix/core/engine/remoting/RequestReply.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 key?: string;
|
||
|
|
readonly cloud?: string;
|
||
|
|
readonly space?: string;
|
||
|
|
readonly home?: string;
|
||
|
|
readonly export?: string;
|
||
|
|
readonly mount?: string;
|
||
|
|
readonly json?: boolean;
|
||
|
|
readonly props?: string;
|
||
|
|
readonly ttlSeconds?: number;
|
||
|
|
readonly checkOp?: string;
|
||
|
|
readonly checkPayload?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IActorRunProviderResolution {
|
||
|
|
readonly key: string;
|
||
|
|
readonly cloud: string;
|
||
|
|
readonly space: string;
|
||
|
|
readonly checked: readonly string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IActorCredentialExchange {
|
||
|
|
readonly ok?: unknown;
|
||
|
|
readonly error?: unknown;
|
||
|
|
readonly space?: {
|
||
|
|
readonly authorityRoot?: unknown;
|
||
|
|
readonly spaceId?: unknown;
|
||
|
|
readonly routeKey?: unknown;
|
||
|
|
readonly publicNamespace?: unknown;
|
||
|
|
};
|
||
|
|
readonly transport?: {
|
||
|
|
readonly natsUrl?: unknown;
|
||
|
|
readonly root?: unknown;
|
||
|
|
};
|
||
|
|
readonly mount?: unknown;
|
||
|
|
readonly effectiveMount?: unknown;
|
||
|
|
readonly credentials?: {
|
||
|
|
readonly jwt?: unknown;
|
||
|
|
readonly seed?: unknown;
|
||
|
|
readonly userPublicKey?: unknown;
|
||
|
|
readonly expiresAt?: unknown;
|
||
|
|
};
|
||
|
|
readonly source?: unknown;
|
||
|
|
readonly credentialPipeline?: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 provider = resolveActorRunProvider(options);
|
||
|
|
const exchange = await exchangeActorCredential({
|
||
|
|
provider,
|
||
|
|
mount,
|
||
|
|
actorId: ActorClass.name || mount,
|
||
|
|
packageId: readPackageNameForEntry(entryPath) ?? 'standalone-actor',
|
||
|
|
runtimeId: `standalone-actor:${process.pid}:${mount}`,
|
||
|
|
accepts: ActorClass.accepts ?? {},
|
||
|
|
emits: ActorClass.emits ?? {},
|
||
|
|
subscribes: ActorClass.subscribes ?? {},
|
||
|
|
ttlSeconds: options.ttlSeconds,
|
||
|
|
});
|
||
|
|
const authorityRoot = readRequiredString(exchange.space?.authorityRoot, 'actor credential response space.authorityRoot');
|
||
|
|
const natsUrl = readRequiredString(exchange.transport?.natsUrl, 'actor credential response transport.natsUrl');
|
||
|
|
const jwt = readRequiredString(exchange.credentials?.jwt, 'actor credential response credentials.jwt');
|
||
|
|
const seed = readRequiredString(exchange.credentials?.seed, 'actor credential response credentials.seed');
|
||
|
|
const connection = await connectActorNats({
|
||
|
|
natsUrl,
|
||
|
|
authorityRoot,
|
||
|
|
jwt,
|
||
|
|
seed,
|
||
|
|
});
|
||
|
|
const transport = new NatsTransport(connection, { root: authorityRoot });
|
||
|
|
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,
|
||
|
|
authorityRoot,
|
||
|
|
effectiveMount: resolveEffectiveMount(exchange.effectiveMount, authorityRoot, mount),
|
||
|
|
cloud: provider.cloud,
|
||
|
|
natsUrl,
|
||
|
|
source: readRequiredString(exchange.source, 'actor credential response source'),
|
||
|
|
credentialPipeline: readRequiredString(exchange.credentialPipeline, 'actor credential response credentialPipeline'),
|
||
|
|
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(` authority: ${result.authorityRoot}`);
|
||
|
|
console.log(` effective: ${result.effectiveMount}`);
|
||
|
|
console.log(` cloud: ${result.cloud}`);
|
||
|
|
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 provider = resolveActorRunProvider(options);
|
||
|
|
const exchange = await exchangeActorCredential({
|
||
|
|
provider,
|
||
|
|
mount: spec.mount,
|
||
|
|
actorId: spec.actorId,
|
||
|
|
packageId: spec.packageId,
|
||
|
|
runtimeId: spec.runtimeId,
|
||
|
|
accepts: spec.accepts,
|
||
|
|
emits: spec.emits,
|
||
|
|
subscribes: spec.subscribes,
|
||
|
|
ttlSeconds: options.ttlSeconds,
|
||
|
|
});
|
||
|
|
const authorityRoot = readRequiredString(exchange.space?.authorityRoot, 'actor credential response space.authorityRoot');
|
||
|
|
const natsUrl = readRequiredString(exchange.transport?.natsUrl, 'actor credential response transport.natsUrl');
|
||
|
|
const jwt = readRequiredString(exchange.credentials?.jwt, 'actor credential response credentials.jwt');
|
||
|
|
const seed = readRequiredString(exchange.credentials?.seed, 'actor credential response credentials.seed');
|
||
|
|
const connection = await connectActorNats({
|
||
|
|
natsUrl,
|
||
|
|
authorityRoot,
|
||
|
|
jwt,
|
||
|
|
seed,
|
||
|
|
});
|
||
|
|
const transport = new NatsTransport(connection, { root: authorityRoot });
|
||
|
|
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: authorityRoot,
|
||
|
|
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,
|
||
|
|
authorityRoot,
|
||
|
|
effectiveMount: resolveEffectiveMount(exchange.effectiveMount, authorityRoot, spec.mount),
|
||
|
|
cloud: provider.cloud,
|
||
|
|
natsUrl,
|
||
|
|
source: readRequiredString(exchange.source, 'actor credential response source'),
|
||
|
|
credentialPipeline: readRequiredString(exchange.credentialPipeline, 'actor credential response credentialPipeline'),
|
||
|
|
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(` authority: ${result.authorityRoot}`);
|
||
|
|
console.log(` effective: ${result.effectiveMount}`);
|
||
|
|
console.log(` cloud: ${result.cloud}`);
|
||
|
|
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 resolveActorRunProvider(options: IActorRunCommandOptions): IActorRunProviderResolution {
|
||
|
|
const home = path.resolve(options.home?.trim() || path.join(os.homedir(), '.matrix'));
|
||
|
|
const homeCredential = readApiKeyCredentialFile(path.join(home, 'credentials', 'matrix-api-key.json'));
|
||
|
|
const homeConfig = readApiKeyConfigFile(path.join(home, 'config.json'));
|
||
|
|
const defaultHome = path.resolve(os.homedir(), '.matrix');
|
||
|
|
const defaultCredential = home === defaultHome ? {} : readApiKeyCredentialFile(path.join(defaultHome, 'credentials', 'matrix-api-key.json'));
|
||
|
|
const defaultConfig = home === defaultHome ? {} : readApiKeyConfigFile(path.join(defaultHome, 'config.json'));
|
||
|
|
const hivecastNamedCredential = readApiKeyCredentialFile(path.join(defaultHome, 'credentials', 'hivecast-api-key.json'));
|
||
|
|
const key = readOptionalString(options.key)
|
||
|
|
?? readOptionalString(process.env.MATRIX_API_KEY)
|
||
|
|
?? readOptionalString(process.env.HIVECAST_API_KEY)
|
||
|
|
?? homeCredential.key
|
||
|
|
?? homeConfig.key
|
||
|
|
?? defaultCredential.key
|
||
|
|
?? defaultConfig.key
|
||
|
|
?? hivecastNamedCredential.key;
|
||
|
|
const cloud = readOptionalString(options.cloud)
|
||
|
|
?? readOptionalString(process.env.MATRIX_CLOUD)
|
||
|
|
?? readOptionalString(process.env.HIVECAST_CLOUD)
|
||
|
|
?? homeCredential.cloud
|
||
|
|
?? homeConfig.cloud
|
||
|
|
?? defaultCredential.cloud
|
||
|
|
?? defaultConfig.cloud
|
||
|
|
?? hivecastNamedCredential.cloud;
|
||
|
|
const space = readOptionalString(options.space)
|
||
|
|
?? readOptionalString(process.env.MATRIX_SPACE)
|
||
|
|
?? readOptionalString(process.env.HIVECAST_SPACE)
|
||
|
|
?? homeCredential.space
|
||
|
|
?? homeConfig.space
|
||
|
|
?? defaultCredential.space
|
||
|
|
?? defaultConfig.space
|
||
|
|
?? hivecastNamedCredential.space;
|
||
|
|
|
||
|
|
const checked = [
|
||
|
|
'--key',
|
||
|
|
'MATRIX_API_KEY',
|
||
|
|
'HIVECAST_API_KEY',
|
||
|
|
'<home>/credentials/matrix-api-key.json',
|
||
|
|
'<home>/config.json',
|
||
|
|
'~/.matrix/credentials/matrix-api-key.json',
|
||
|
|
'~/.matrix/config.json',
|
||
|
|
'~/.matrix/credentials/hivecast-api-key.json',
|
||
|
|
];
|
||
|
|
|
||
|
|
if (!key) {
|
||
|
|
throw new Error(`MATRIX_API_KEY_REQUIRED: matrix actor run requires --key or a configured API key. Checked: ${checked.join(', ')}`);
|
||
|
|
}
|
||
|
|
if (!cloud) {
|
||
|
|
throw new Error('MATRIX_CLOUD_REQUIRED: matrix actor run requires --cloud or configured MATRIX_CLOUD/HIVECAST_CLOUD');
|
||
|
|
}
|
||
|
|
if (!space) {
|
||
|
|
throw new Error('MATRIX_SPACE_REQUIRED: matrix actor run requires --space or configured MATRIX_SPACE/HIVECAST_SPACE');
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
key,
|
||
|
|
cloud: normalizeCloudUrl(cloud),
|
||
|
|
space,
|
||
|
|
checked,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function exchangeActorCredential(input: {
|
||
|
|
readonly provider: IActorRunProviderResolution;
|
||
|
|
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 ttlSeconds?: number;
|
||
|
|
readonly fetchImpl?: typeof fetch;
|
||
|
|
}): Promise<IActorCredentialExchange> {
|
||
|
|
const fetchImpl = input.fetchImpl ?? fetch;
|
||
|
|
const response = await fetchImpl(new URL('/api/install/actor-credential', input.provider.cloud), {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
authorization: `Bearer ${input.provider.key}`,
|
||
|
|
'content-type': 'application/json',
|
||
|
|
},
|
||
|
|
body: JSON.stringify({
|
||
|
|
space: input.provider.space,
|
||
|
|
mount: input.mount,
|
||
|
|
actorId: input.actorId,
|
||
|
|
packageId: input.packageId,
|
||
|
|
runtimeId: input.runtimeId,
|
||
|
|
accepts: input.accepts,
|
||
|
|
emits: input.emits,
|
||
|
|
subscribes: input.subscribes,
|
||
|
|
...(input.ttlSeconds ? { ttlSeconds: input.ttlSeconds } : {}),
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
const body = await response.json().catch(() => null) as IActorCredentialExchange | null;
|
||
|
|
if (!response.ok || body?.ok !== true) {
|
||
|
|
const error = readOptionalString(body?.error) ?? `HTTP_${response.status}`;
|
||
|
|
throw new Error(`ACTOR_CREDENTIAL_EXCHANGE_FAILED: ${error}`);
|
||
|
|
}
|
||
|
|
return body;
|
||
|
|
}
|
||
|
|
|
||
|
|
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: {
|
||
|
|
readonly natsUrl: string;
|
||
|
|
readonly authorityRoot: string;
|
||
|
|
readonly jwt: string;
|
||
|
|
readonly seed: string;
|
||
|
|
}): Promise<NatsConnection> {
|
||
|
|
const encoder = new TextEncoder();
|
||
|
|
return await natsConnect({
|
||
|
|
servers: [input.natsUrl],
|
||
|
|
name: `matrix-actor-run-${input.authorityRoot}-${process.pid}`,
|
||
|
|
timeout: 5_000,
|
||
|
|
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 readPackageNameForEntry(entryPath: string): string | undefined {
|
||
|
|
let dir = path.dirname(entryPath);
|
||
|
|
while (dir !== path.dirname(dir)) {
|
||
|
|
const packageJsonPath = path.join(dir, 'package.json');
|
||
|
|
if (fs.existsSync(packageJsonPath)) {
|
||
|
|
const parsed = readJsonFile(packageJsonPath);
|
||
|
|
return readOptionalString(parsed?.name);
|
||
|
|
}
|
||
|
|
dir = path.dirname(dir);
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 readApiKeyCredentialFile(filePath: string): { readonly key?: string; readonly cloud?: string; readonly space?: string } {
|
||
|
|
const value = readJsonFile(filePath);
|
||
|
|
return {
|
||
|
|
...(readOptionalString(value?.apiKey) ?? readOptionalString(value?.key) ? { key: (readOptionalString(value?.apiKey) ?? readOptionalString(value?.key))! } : {}),
|
||
|
|
...(readOptionalString(value?.cloud) ? { cloud: readOptionalString(value?.cloud)! } : {}),
|
||
|
|
...(readOptionalString(value?.space) ?? readOptionalString(value?.authorityRoot) ? { space: (readOptionalString(value?.space) ?? readOptionalString(value?.authorityRoot))! } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function readApiKeyConfigFile(filePath: string): { readonly key?: string; readonly cloud?: string; readonly space?: string } {
|
||
|
|
const value = readJsonFile(filePath);
|
||
|
|
const apiKey = value?.apiKey && typeof value.apiKey === 'object' && !Array.isArray(value.apiKey)
|
||
|
|
? value.apiKey as Record<string, unknown>
|
||
|
|
: {};
|
||
|
|
return {
|
||
|
|
...(readOptionalString(apiKey.apiKey) ?? readOptionalString(apiKey.key) ? { key: (readOptionalString(apiKey.apiKey) ?? readOptionalString(apiKey.key))! } : {}),
|
||
|
|
...(readOptionalString(value?.cloud) ?? readOptionalString(value?.hivecastCloud) ? { cloud: (readOptionalString(value?.cloud) ?? readOptionalString(value?.hivecastCloud))! } : {}),
|
||
|
|
...(readOptionalString(value?.space) ?? readOptionalString(value?.authorityRoot) ? { space: (readOptionalString(value?.space) ?? readOptionalString(value?.authorityRoot))! } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
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 readRequiredString(value: unknown, label: string): string {
|
||
|
|
const result = readOptionalString(value);
|
||
|
|
if (!result) {
|
||
|
|
throw new Error(`Missing ${label}`);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readOptionalString(value: unknown): string | undefined {
|
||
|
|
if (typeof value !== 'string') {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
const trimmed = value.trim();
|
||
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveEffectiveMount(value: unknown, authorityRoot: string, mount: string): string {
|
||
|
|
const fromExchange = readOptionalString(value);
|
||
|
|
if (fromExchange) {
|
||
|
|
return fromExchange;
|
||
|
|
}
|
||
|
|
return `${authorityRoot}.${mount}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeCloudUrl(value: string): string {
|
||
|
|
const parsed = new URL(value);
|
||
|
|
return parsed.toString().replace(/\/$/, '');
|
||
|
|
}
|