686 lines
25 KiB
TypeScript
686 lines
25 KiB
TypeScript
|
|
import * as fs from 'node:fs';
|
||
|
|
import { pathToFileURL } from 'node:url';
|
||
|
|
|
||
|
|
import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js';
|
||
|
|
import { buildMountedInstanceBootstrapProps } from '@open-matrix/core/runtime/InstanceBootstrapContext.js';
|
||
|
|
import {
|
||
|
|
buildMatrixHttpAssetMount,
|
||
|
|
MatrixHttpAssetEndpointActor,
|
||
|
|
} from '@open-matrix/system-gateway-http';
|
||
|
|
import { HostAuthStateStore, HostCredentialStore } from '@open-matrix/system-auth';
|
||
|
|
import {
|
||
|
|
loadBootstrapJsonObject,
|
||
|
|
resolveBootstrapRefPath,
|
||
|
|
loadBootstrapJsonValue,
|
||
|
|
} from '@open-matrix/core/runtime/InstanceBootstrapRefs.js';
|
||
|
|
import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js';
|
||
|
|
import { loadMatrixServiceManifest } from '../utils/service-manifest.js';
|
||
|
|
import {
|
||
|
|
loadPackageEnvironment,
|
||
|
|
loadPackageEnvironmentFromPath,
|
||
|
|
type IMatrixPackageEnvironment,
|
||
|
|
} from '../utils/package-environment.js';
|
||
|
|
import {
|
||
|
|
deregisterRunnerBindings,
|
||
|
|
deregisterRunnerRuntime,
|
||
|
|
heartbeatRunnerRuntime,
|
||
|
|
type IRunnerWebappRouteMetadata,
|
||
|
|
registerRunnerBindings,
|
||
|
|
type IRunnerBindingDeregistration,
|
||
|
|
type IRunnerBindingRegistration,
|
||
|
|
registerRunnerRuntime,
|
||
|
|
renewRunnerBindings,
|
||
|
|
resolveRunnerRuntimeId,
|
||
|
|
type IRunnerRuntimeDeregistration,
|
||
|
|
type IRunnerRuntimeRegistration,
|
||
|
|
} from '../utils/runner-control-plane.js';
|
||
|
|
import {
|
||
|
|
defaultRunnerRuntimeMount,
|
||
|
|
mountRunnerRuntimeControl,
|
||
|
|
type RunnerRuntimeLifecycleState,
|
||
|
|
} from '../utils/runner-runtime-control.js';
|
||
|
|
import { resolveRunnerTransportPlan, type IResolvedRunnerTransportPlan } from '../utils/runner-transport.js';
|
||
|
|
import {
|
||
|
|
createRunnerRuntimeHandle,
|
||
|
|
formatRunnerRuntimeClosed,
|
||
|
|
type IRunnerRuntimeClosed,
|
||
|
|
type IRunnerRuntimeHandle,
|
||
|
|
} from '../utils/runner-runtime.js';
|
||
|
|
import {
|
||
|
|
loadMatrixWebappManifest,
|
||
|
|
type ILoadedMatrixWebappManifest,
|
||
|
|
} from '../utils/webapp-manifest.js';
|
||
|
|
import { installRunnerPlacementPolicy } from '../utils/runner-placement-policy.js';
|
||
|
|
|
||
|
|
export interface IServiceCommandOptions {
|
||
|
|
readonly check?: boolean;
|
||
|
|
readonly json?: boolean;
|
||
|
|
readonly env?: string;
|
||
|
|
readonly envFile?: string;
|
||
|
|
readonly overrides?: string;
|
||
|
|
readonly entry?: string;
|
||
|
|
readonly export?: string;
|
||
|
|
readonly root?: string;
|
||
|
|
readonly mount?: string;
|
||
|
|
readonly config?: string;
|
||
|
|
readonly secrets?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IServiceCommandResult {
|
||
|
|
readonly packageName: string;
|
||
|
|
readonly packageDir: string;
|
||
|
|
readonly manifestPath: string;
|
||
|
|
readonly entryPath: string;
|
||
|
|
readonly exportName: string;
|
||
|
|
readonly checked: boolean;
|
||
|
|
readonly environmentName?: string;
|
||
|
|
readonly environmentPath?: string;
|
||
|
|
readonly runnerTransport?: IResolvedRunnerTransportPlan;
|
||
|
|
readonly overrides: Record<string, unknown>;
|
||
|
|
readonly root?: string;
|
||
|
|
readonly mount?: string;
|
||
|
|
readonly configRef?: string;
|
||
|
|
readonly secretsRef?: string;
|
||
|
|
readonly instance: Record<string, unknown>;
|
||
|
|
readonly serviceInstance: Record<string, unknown>;
|
||
|
|
readonly deploymentInstance: Record<string, unknown>;
|
||
|
|
readonly runtimeRegistration?: IRunnerRuntimeRegistration;
|
||
|
|
readonly bindingRegistration?: IRunnerBindingRegistration;
|
||
|
|
readonly runtimeDeregistration?: IRunnerRuntimeDeregistration;
|
||
|
|
readonly bindingDeregistration?: IRunnerBindingDeregistration;
|
||
|
|
readonly result?: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IStartedServiceCommand {
|
||
|
|
readonly result: IServiceCommandResult;
|
||
|
|
readonly shutdownRequested: Promise<string>;
|
||
|
|
readonly transportClosed?: Promise<IRunnerRuntimeClosed>;
|
||
|
|
shutdown(): Promise<IServiceCommandResult>;
|
||
|
|
}
|
||
|
|
|
||
|
|
type BootstrapResult = {
|
||
|
|
runtime?: {
|
||
|
|
shutdown?: () => Promise<void> | void;
|
||
|
|
};
|
||
|
|
} & Record<string, unknown>;
|
||
|
|
|
||
|
|
interface IStandaloneRunnerServices {
|
||
|
|
readonly runtime: MatrixRuntime;
|
||
|
|
readonly environment?: IMatrixPackageEnvironment;
|
||
|
|
readonly runnerTransport?: IResolvedRunnerTransportPlan;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function serviceCommand(
|
||
|
|
packageDir: string,
|
||
|
|
options: IServiceCommandOptions = {},
|
||
|
|
): Promise<IServiceCommandResult> {
|
||
|
|
const started = await startServiceCommand(packageDir, options);
|
||
|
|
if (options.check) {
|
||
|
|
return await started.shutdown();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options.json) {
|
||
|
|
console.log(JSON.stringify(started.result, null, 2));
|
||
|
|
} else {
|
||
|
|
console.log(`[mx service] started ${started.result.packageName} via ${started.result.exportName}`);
|
||
|
|
console.log(`[mx service] manifest: ${started.result.manifestPath}`);
|
||
|
|
console.log(`[mx service] entry: ${started.result.entryPath}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
let finalResult = started.result;
|
||
|
|
const shutdownRequested = started.transportClosed
|
||
|
|
? Promise.race([
|
||
|
|
started.shutdownRequested,
|
||
|
|
started.transportClosed.then((closed) => {
|
||
|
|
throw new Error(formatRunnerRuntimeClosed(closed));
|
||
|
|
}),
|
||
|
|
])
|
||
|
|
: started.shutdownRequested;
|
||
|
|
|
||
|
|
await waitForShutdownSignal(async () => {
|
||
|
|
finalResult = await started.shutdown();
|
||
|
|
}, shutdownRequested);
|
||
|
|
|
||
|
|
return finalResult;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function startServiceCommand(
|
||
|
|
packageDir: string,
|
||
|
|
options: IServiceCommandOptions = {},
|
||
|
|
): Promise<IStartedServiceCommand> {
|
||
|
|
const loaded = loadMatrixServiceManifest(packageDir);
|
||
|
|
const webapp = loadMatrixWebappManifest(loaded.packageDir);
|
||
|
|
const envFile = options.envFile?.trim();
|
||
|
|
const selectedEnvironment = envFile
|
||
|
|
? loadPackageEnvironmentFromPath(packageDir, envFile, options.env)
|
||
|
|
: options.env?.trim()
|
||
|
|
? loadPackageEnvironment(packageDir, options.env)
|
||
|
|
: undefined;
|
||
|
|
const entryPath = options.entry
|
||
|
|
? loadBootstrapEntryPath(loaded.packageDir, options.entry, 'service entry override')
|
||
|
|
: loaded.entryPath;
|
||
|
|
const exportName = options.export?.trim() || loaded.exportName;
|
||
|
|
const root = options.root?.trim()
|
||
|
|
|| selectedEnvironment?.environment.runtime.root
|
||
|
|
|| loaded.root;
|
||
|
|
const runnerTransport = selectedEnvironment
|
||
|
|
? resolveRunnerTransportPlan(loaded.packageDir, selectedEnvironment, root)
|
||
|
|
: undefined;
|
||
|
|
const mount = options.mount?.trim() || loaded.mount;
|
||
|
|
const configRef = options.config?.trim() || loaded.configRef;
|
||
|
|
const config = configRef ? loadBootstrapJsonObject(loaded.packageDir, configRef, 'service config') : {};
|
||
|
|
const manifestOverrides = deepMerge(config, loaded.overrides);
|
||
|
|
const cliOverrides = parseOverrides(options.overrides);
|
||
|
|
const effectiveOverrides = deepMerge(manifestOverrides, cliOverrides);
|
||
|
|
const secretsRef = options.secrets?.trim() || undefined;
|
||
|
|
const secrets = secretsRef ? loadBootstrapJsonValue(loaded.packageDir, secretsRef, 'service secrets') : undefined;
|
||
|
|
const mountedInstanceBootstrap = buildMountedInstanceBootstrapProps({
|
||
|
|
...loaded.serviceInstance,
|
||
|
|
...(mount ? { mount } : {}),
|
||
|
|
...(configRef ? { configRef } : {}),
|
||
|
|
...(Array.isArray(secrets)
|
||
|
|
? { secretRefs: structuredClone(secrets) }
|
||
|
|
: loaded.serviceInstance.secretRefs
|
||
|
|
? { secretRefs: structuredClone(loaded.serviceInstance.secretRefs) }
|
||
|
|
: {}),
|
||
|
|
}, effectiveOverrides);
|
||
|
|
const { instance, serviceInstance, deploymentInstance } = mountedInstanceBootstrap;
|
||
|
|
const bootstrapContext = {
|
||
|
|
packageName: loaded.packageName,
|
||
|
|
packageDir: loaded.packageDir,
|
||
|
|
manifestPath: loaded.manifestPath,
|
||
|
|
entryPath,
|
||
|
|
exportName,
|
||
|
|
...(root ? { root } : {}),
|
||
|
|
...(mount ? { mount } : {}),
|
||
|
|
...(configRef ? { configRef } : {}),
|
||
|
|
...(secretsRef ? { secretsRef } : {}),
|
||
|
|
...(secrets !== undefined ? { secrets } : {}),
|
||
|
|
...(loaded.transport ? { transport: loaded.transport } : {}),
|
||
|
|
...(loaded.auth ? { auth: loaded.auth } : {}),
|
||
|
|
...(loaded.http ? { http: loaded.http } : {}),
|
||
|
|
...(selectedEnvironment ? {
|
||
|
|
environmentName: selectedEnvironment.envName,
|
||
|
|
environmentPath: selectedEnvironment.environmentPath,
|
||
|
|
environment: selectedEnvironment.environment,
|
||
|
|
} : {}),
|
||
|
|
...(runnerTransport ? { runnerTransport } : {}),
|
||
|
|
...mountedInstanceBootstrap,
|
||
|
|
};
|
||
|
|
const runnerRuntimeHandle = await createRunnerRuntimeHandle(root, runnerTransport);
|
||
|
|
const runnerRuntime = runnerRuntimeHandle.runtime;
|
||
|
|
registerHostHomeService(runnerRuntime, selectedEnvironment?.environment.host?.matrixDir);
|
||
|
|
const runtimeId = resolveRunnerRuntimeId(loaded, selectedEnvironment);
|
||
|
|
const runtimeMount = selectedEnvironment?.environment.runtime.runtimeMount
|
||
|
|
?? process.env.MATRIX_RUNTIME_MOUNT
|
||
|
|
?? defaultRunnerRuntimeMount(runtimeId);
|
||
|
|
// Slice 1b (control-actor-nav): control authority is the canonical
|
||
|
|
// per-runtime mount, not the legacy `.control` sibling.
|
||
|
|
const controlMount = selectedEnvironment?.environment.runtime.controlMount
|
||
|
|
?? process.env.MATRIX_RUNTIME_CONTROL_MOUNT
|
||
|
|
?? runtimeMount;
|
||
|
|
let lifecycleState: RunnerRuntimeLifecycleState = 'starting';
|
||
|
|
let requestRuntimeShutdown: (reason: string) => void = () => undefined;
|
||
|
|
const shutdownRequested = new Promise<string>((resolve) => {
|
||
|
|
requestRuntimeShutdown = resolve;
|
||
|
|
});
|
||
|
|
const runnerServices: IStandaloneRunnerServices = {
|
||
|
|
runtime: runnerRuntime,
|
||
|
|
...(selectedEnvironment ? { environment: selectedEnvironment.environment } : {}),
|
||
|
|
...(runnerTransport ? { runnerTransport } : {}),
|
||
|
|
};
|
||
|
|
installRunnerPlacementPolicy({
|
||
|
|
runtime: runnerRuntime,
|
||
|
|
manifest: loaded,
|
||
|
|
environment: selectedEnvironment,
|
||
|
|
runtimeId,
|
||
|
|
authorityRoot: root,
|
||
|
|
});
|
||
|
|
|
||
|
|
const moduleUrl = `${pathToFileURL(entryPath).href}?mx_service=${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 runtime factory export "${exportName}" was not found in ${entryPath}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const bootstrapResult = await (bootstrap as (...args: unknown[]) => Promise<unknown> | unknown)(
|
||
|
|
effectiveOverrides,
|
||
|
|
runnerServices,
|
||
|
|
undefined,
|
||
|
|
bootstrapContext,
|
||
|
|
);
|
||
|
|
const activeRuntime = resolveBootstrapRuntime(bootstrapResult) ?? runnerRuntime;
|
||
|
|
const webappAssetMount = webapp
|
||
|
|
? await mountMatrixHttpAssetEndpoint(activeRuntime, webapp, runtimeId)
|
||
|
|
: undefined;
|
||
|
|
const mounted = await mountRunnerRuntimeControl({
|
||
|
|
runtime: activeRuntime,
|
||
|
|
runtimeId,
|
||
|
|
runtimeMount,
|
||
|
|
controlMount,
|
||
|
|
startedAt: new Date().toISOString(),
|
||
|
|
getState: () => lifecycleState,
|
||
|
|
requestShutdown: (reason) => requestRuntimeShutdown(reason),
|
||
|
|
loadedPackage: {
|
||
|
|
packageRef: loaded.packageName,
|
||
|
|
manifest: {
|
||
|
|
packageRef: loaded.packageName,
|
||
|
|
...(loaded.mount ? { mount: loaded.mount } : {}),
|
||
|
|
packageDir: loaded.packageDir,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const mountedControlMount = mounted.controlMount;
|
||
|
|
lifecycleState = 'ready';
|
||
|
|
const runtimeRegistration = await registerRunnerRuntime(
|
||
|
|
activeRuntime,
|
||
|
|
loaded,
|
||
|
|
selectedEnvironment,
|
||
|
|
runnerTransport,
|
||
|
|
mountedInstanceBootstrap.serviceInstance.mount,
|
||
|
|
runtimeMount,
|
||
|
|
mountedControlMount,
|
||
|
|
webapp && webappAssetMount ? buildRunnerWebappRouteMetadata(webapp, webappAssetMount) : undefined,
|
||
|
|
);
|
||
|
|
const bindingRegistration = await registerRunnerBindings(
|
||
|
|
activeRuntime,
|
||
|
|
loaded,
|
||
|
|
selectedEnvironment,
|
||
|
|
runtimeRegistration.runtimeId,
|
||
|
|
mountedInstanceBootstrap.serviceInstance.mount,
|
||
|
|
);
|
||
|
|
lifecycleState = 'live';
|
||
|
|
const serializedInstance = sanitizeForJson(instance) as Record<string, unknown>;
|
||
|
|
let runtimeDeregistration: IRunnerRuntimeDeregistration | undefined;
|
||
|
|
let bindingDeregistration: IRunnerBindingDeregistration | undefined;
|
||
|
|
// Heartbeat tick + self-healing reclaim.
|
||
|
|
//
|
||
|
|
// registry.claim.renew (inside heartbeatRunnerRuntime / renewRunnerBindings)
|
||
|
|
// returns the count of claims the server-side registry actually refreshed.
|
||
|
|
// 0 means "the registry has no record of your claim" — typically because
|
||
|
|
// the registry actor's in-memory _claims map was wiped when the SYSTEM
|
||
|
|
// runtime restarted. Without reclaim the original claim's TTL (45s)
|
||
|
|
// expires, the runtime vanishes from registry.query, the gateway can't
|
||
|
|
// route to it, and apps appear "not registered" until manual intervention.
|
||
|
|
//
|
||
|
|
// We re-issue exactly what we issued at registration time. Server-side ops
|
||
|
|
// are idempotent (onRegistryClaim preserves registeredAt/claimedAt for
|
||
|
|
// existing entries; bindings.register is keyed by provider+match), so
|
||
|
|
// reclaiming against a healthy registry is harmless.
|
||
|
|
//
|
||
|
|
// Log discipline: log STATE TRANSITIONS, not every tick. First failure
|
||
|
|
// logs; subsequent consecutive failures at the same attempt count stay
|
||
|
|
// silent; every 10th attempt logs that we're still trying; recovery from
|
||
|
|
// a degraded state logs the duration. Healthy ticks log nothing.
|
||
|
|
let runtimeReclaimAttempts = 0;
|
||
|
|
let bindingReclaimAttempts = 0;
|
||
|
|
|
||
|
|
const logIfTransitionOrMilestone = (
|
||
|
|
attempts: number,
|
||
|
|
onFirst: () => void,
|
||
|
|
onMilestone: () => void,
|
||
|
|
): void => {
|
||
|
|
if (attempts === 1) {
|
||
|
|
onFirst();
|
||
|
|
} else if (attempts % 10 === 0) {
|
||
|
|
onMilestone();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const runtimePresenceTimer = options.check === true
|
||
|
|
? undefined
|
||
|
|
: setInterval(() => {
|
||
|
|
void (async () => {
|
||
|
|
try {
|
||
|
|
const [heartbeat, renewal] = await Promise.all([
|
||
|
|
heartbeatRunnerRuntime(activeRuntime, runtimeRegistration),
|
||
|
|
renewRunnerBindings(activeRuntime, bindingRegistration),
|
||
|
|
]);
|
||
|
|
|
||
|
|
if (heartbeat && !heartbeat.known) {
|
||
|
|
runtimeReclaimAttempts += 1;
|
||
|
|
logIfTransitionOrMilestone(
|
||
|
|
runtimeReclaimAttempts,
|
||
|
|
() => console.error(
|
||
|
|
`[mx service] registry forgot runtime claim for ${heartbeat.runtimeId} (renewed=0); reclaiming`,
|
||
|
|
),
|
||
|
|
() => console.error(
|
||
|
|
`[mx service] still reclaiming runtime ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempts`,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
try {
|
||
|
|
const reclaimed = await runtimeRegistration.reclaim();
|
||
|
|
if (runtimeReclaimAttempts > 0) {
|
||
|
|
console.error(
|
||
|
|
`[mx service] runtime reclaim recovered for ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempt(s): ${reclaimed.inventoryClaimCount} inventory claims re-issued`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
runtimeReclaimAttempts = 0;
|
||
|
|
} catch (reclaimErr) {
|
||
|
|
logIfTransitionOrMilestone(
|
||
|
|
runtimeReclaimAttempts,
|
||
|
|
() => console.error(
|
||
|
|
`[mx service] runtime reclaim failed for ${heartbeat.runtimeId}: ${reclaimErr instanceof Error ? reclaimErr.message : String(reclaimErr)} (will retry next tick)`,
|
||
|
|
),
|
||
|
|
() => console.error(
|
||
|
|
`[mx service] runtime reclaim still failing for ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempts: ${reclaimErr instanceof Error ? reclaimErr.message : String(reclaimErr)}`,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} else if (heartbeat && runtimeReclaimAttempts > 0) {
|
||
|
|
// Registry started answering renew=N>0 without us reclaiming
|
||
|
|
// first — extremely rare (the registry would need to repopulate
|
||
|
|
// claims from somewhere) but log the recovery so operators see
|
||
|
|
// it. Reset the counter.
|
||
|
|
console.error(
|
||
|
|
`[mx service] runtime registry recovered without explicit reclaim for ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempt(s)`,
|
||
|
|
);
|
||
|
|
runtimeReclaimAttempts = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (renewal && renewal.refreshed === 0 && (bindingRegistration?.bindings.length ?? 0) > 0) {
|
||
|
|
bindingReclaimAttempts += 1;
|
||
|
|
logIfTransitionOrMilestone(
|
||
|
|
bindingReclaimAttempts,
|
||
|
|
() => console.error(
|
||
|
|
`[mx service] registry forgot binding claims for ${renewal.runtimeId} (refreshed=0); reclaiming`,
|
||
|
|
),
|
||
|
|
() => console.error(
|
||
|
|
`[mx service] still reclaiming bindings for ${renewal.runtimeId} after ${bindingReclaimAttempts} attempts`,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
try {
|
||
|
|
const reclaimed = await bindingRegistration?.reclaim();
|
||
|
|
if (reclaimed && bindingReclaimAttempts > 0) {
|
||
|
|
console.error(
|
||
|
|
`[mx service] binding reclaim recovered for ${renewal.runtimeId} after ${bindingReclaimAttempts} attempt(s): ${reclaimed.registryClaimCount} registry claims + ${reclaimed.bindingsRegisteredCount} binding forwards re-issued`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
bindingReclaimAttempts = 0;
|
||
|
|
} catch (reclaimErr) {
|
||
|
|
logIfTransitionOrMilestone(
|
||
|
|
bindingReclaimAttempts,
|
||
|
|
() => console.error(
|
||
|
|
`[mx service] binding reclaim failed for ${renewal.runtimeId}: ${reclaimErr instanceof Error ? reclaimErr.message : String(reclaimErr)} (will retry next tick)`,
|
||
|
|
),
|
||
|
|
() => console.error(
|
||
|
|
`[mx service] binding reclaim still failing for ${renewal.runtimeId} after ${bindingReclaimAttempts} attempts: ${reclaimErr instanceof Error ? reclaimErr.message : String(reclaimErr)}`,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} else if (renewal && bindingReclaimAttempts > 0) {
|
||
|
|
console.error(
|
||
|
|
`[mx service] binding registry recovered without explicit reclaim for ${renewal.runtimeId} after ${bindingReclaimAttempts} attempt(s)`,
|
||
|
|
);
|
||
|
|
bindingReclaimAttempts = 0;
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
console.error(
|
||
|
|
`[mx service] runtime presence tick failed: ${err instanceof Error ? err.message : String(err)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
}, 10_000);
|
||
|
|
const buildResult = (): IServiceCommandResult => ({
|
||
|
|
packageName: loaded.packageName,
|
||
|
|
packageDir: loaded.packageDir,
|
||
|
|
manifestPath: loaded.manifestPath,
|
||
|
|
entryPath,
|
||
|
|
exportName,
|
||
|
|
checked: options.check === true,
|
||
|
|
...(selectedEnvironment ? {
|
||
|
|
environmentName: selectedEnvironment.envName,
|
||
|
|
environmentPath: selectedEnvironment.environmentPath,
|
||
|
|
} : {}),
|
||
|
|
...(runnerTransport ? { runnerTransport } : {}),
|
||
|
|
overrides: effectiveOverrides,
|
||
|
|
...(root ? { root } : {}),
|
||
|
|
...(mount ? { mount } : {}),
|
||
|
|
...(configRef ? { configRef } : {}),
|
||
|
|
...(secretsRef ? { secretsRef } : {}),
|
||
|
|
instance: serializedInstance,
|
||
|
|
serviceInstance: structuredClone(serializedInstance),
|
||
|
|
deploymentInstance: structuredClone(serializedInstance),
|
||
|
|
runtimeRegistration,
|
||
|
|
bindingRegistration,
|
||
|
|
...(runtimeDeregistration ? { runtimeDeregistration } : {}),
|
||
|
|
...(bindingDeregistration ? { bindingDeregistration } : {}),
|
||
|
|
result: sanitizeForJson(bootstrapResult),
|
||
|
|
});
|
||
|
|
let shutdownPromise: Promise<IServiceCommandResult> | null = null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
result: buildResult(),
|
||
|
|
...(runnerRuntimeHandle.closed ? { transportClosed: runnerRuntimeHandle.closed } : {}),
|
||
|
|
async shutdown(): Promise<IServiceCommandResult> {
|
||
|
|
if (!shutdownPromise) {
|
||
|
|
shutdownPromise = (async () => {
|
||
|
|
lifecycleState = 'stopping';
|
||
|
|
if (runtimePresenceTimer) {
|
||
|
|
clearInterval(runtimePresenceTimer);
|
||
|
|
}
|
||
|
|
({ runtimeDeregistration, bindingDeregistration } = await shutdownBootstrapRuntime(
|
||
|
|
bootstrapResult,
|
||
|
|
activeRuntime,
|
||
|
|
runtimeRegistration,
|
||
|
|
bindingRegistration,
|
||
|
|
runnerRuntimeHandle,
|
||
|
|
));
|
||
|
|
lifecycleState = 'stopped';
|
||
|
|
return buildResult();
|
||
|
|
})();
|
||
|
|
}
|
||
|
|
return await shutdownPromise;
|
||
|
|
},
|
||
|
|
shutdownRequested,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function registerHostHomeService(
|
||
|
|
runtime: MatrixRuntime,
|
||
|
|
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: MatrixRuntime,
|
||
|
|
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,
|
||
|
|
): 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,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseOverrides(raw: string | undefined): Record<string, unknown> {
|
||
|
|
if (!raw) return {};
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(raw) as unknown;
|
||
|
|
if (!isRecord(parsed)) {
|
||
|
|
throw new Error('overrides must be a JSON object');
|
||
|
|
}
|
||
|
|
return parsed;
|
||
|
|
} catch (err) {
|
||
|
|
throw new Error(`Failed to parse service overrides: ${err instanceof Error ? err.message : String(err)}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function deepMerge(
|
||
|
|
base: Record<string, unknown>,
|
||
|
|
patch: Record<string, unknown>,
|
||
|
|
): Record<string, unknown> {
|
||
|
|
const output = structuredClone(base);
|
||
|
|
for (const [key, value] of Object.entries(patch)) {
|
||
|
|
const current = output[key];
|
||
|
|
if (isRecord(current) && isRecord(value)) {
|
||
|
|
output[key] = deepMerge(current, value);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
output[key] = value;
|
||
|
|
}
|
||
|
|
return output;
|
||
|
|
}
|
||
|
|
|
||
|
|
function loadBootstrapEntryPath(packageDir: string, ref: string, label: string): string {
|
||
|
|
const entryPath = ref.trim();
|
||
|
|
if (entryPath.length === 0) {
|
||
|
|
throw new Error(`${label} must be a non-empty path`);
|
||
|
|
}
|
||
|
|
const filePath = resolveBootstrapRefPath(packageDir, entryPath);
|
||
|
|
if (!fs.existsSync(filePath)) {
|
||
|
|
throw new Error(`${label} not found at ${filePath}`);
|
||
|
|
}
|
||
|
|
return filePath;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function shutdownBootstrapRuntime(
|
||
|
|
result: unknown,
|
||
|
|
activeRuntime?: MatrixRuntime,
|
||
|
|
registration?: IRunnerRuntimeRegistration,
|
||
|
|
bindingRegistration?: IRunnerBindingRegistration,
|
||
|
|
runnerRuntimeHandle?: IRunnerRuntimeHandle,
|
||
|
|
): Promise<{
|
||
|
|
readonly runtimeDeregistration?: IRunnerRuntimeDeregistration;
|
||
|
|
readonly bindingDeregistration?: IRunnerBindingDeregistration;
|
||
|
|
}> {
|
||
|
|
let shutdownHandled = false;
|
||
|
|
let runtimeDeregistration: IRunnerRuntimeDeregistration | undefined;
|
||
|
|
let bindingDeregistration: IRunnerBindingDeregistration | undefined;
|
||
|
|
if (isRecord(result)) {
|
||
|
|
const runtime: unknown = result.runtime;
|
||
|
|
if (isRecord(runtime)) {
|
||
|
|
const shutdown = runtime.shutdown;
|
||
|
|
if (typeof shutdown === 'function') {
|
||
|
|
if (activeRuntime) {
|
||
|
|
bindingDeregistration = await deregisterRunnerBindings(activeRuntime, bindingRegistration);
|
||
|
|
runtimeDeregistration = await deregisterRunnerRuntime(activeRuntime, registration);
|
||
|
|
}
|
||
|
|
await shutdown.call(runtime);
|
||
|
|
shutdownHandled = Object.is(runtime, runnerRuntimeHandle?.runtime);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (runnerRuntimeHandle && !shutdownHandled) {
|
||
|
|
if (!runtimeDeregistration && activeRuntime) {
|
||
|
|
bindingDeregistration = await deregisterRunnerBindings(activeRuntime, bindingRegistration);
|
||
|
|
runtimeDeregistration = await deregisterRunnerRuntime(activeRuntime, registration);
|
||
|
|
}
|
||
|
|
await runnerRuntimeHandle.shutdown();
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...(runtimeDeregistration ? { runtimeDeregistration } : {}),
|
||
|
|
...(bindingDeregistration ? { bindingDeregistration } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveBootstrapRuntime(result: unknown): MatrixRuntime | undefined {
|
||
|
|
if (!isRecord(result)) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
const runtime = result.runtime;
|
||
|
|
return runtime instanceof MatrixRuntime ? runtime : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function sanitizeForJson(value: unknown, depth = 8, seen = new WeakSet<object>()): unknown {
|
||
|
|
if (value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
if (typeof value === 'bigint') {
|
||
|
|
return value.toString();
|
||
|
|
}
|
||
|
|
if (typeof value === 'function' || typeof value === 'symbol') {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
if (Array.isArray(value)) {
|
||
|
|
if (depth <= 0) return `[array:${value.length}]`;
|
||
|
|
return value
|
||
|
|
.map((entry) => sanitizeForJson(entry, depth - 1, seen))
|
||
|
|
.filter((entry) => entry !== undefined);
|
||
|
|
}
|
||
|
|
if (!isRecord(value)) {
|
||
|
|
return String(value);
|
||
|
|
}
|
||
|
|
if (seen.has(value)) {
|
||
|
|
return '[circular]';
|
||
|
|
}
|
||
|
|
seen.add(value);
|
||
|
|
if (depth <= 0) {
|
||
|
|
return '[object]';
|
||
|
|
}
|
||
|
|
const output: Record<string, unknown> = {};
|
||
|
|
for (const [key, entry] of Object.entries(value)) {
|
||
|
|
const sanitized = sanitizeForJson(entry, depth - 1, seen);
|
||
|
|
if (sanitized !== undefined) {
|
||
|
|
output[key] = sanitized;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return output;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForShutdownSignal(
|
||
|
|
onShutdown: () => Promise<void>,
|
||
|
|
shutdownRequested?: Promise<string>,
|
||
|
|
): Promise<void> {
|
||
|
|
await new Promise<void>((resolve, reject) => {
|
||
|
|
let settled = false;
|
||
|
|
const complete = (err?: unknown) => {
|
||
|
|
if (settled) return;
|
||
|
|
settled = true;
|
||
|
|
process.off('SIGINT', handleSignal);
|
||
|
|
process.off('SIGTERM', handleSignal);
|
||
|
|
if (err) {
|
||
|
|
reject(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
resolve();
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSignal = () => {
|
||
|
|
void onShutdown()
|
||
|
|
.then(() => complete())
|
||
|
|
.catch((err) => complete(err));
|
||
|
|
};
|
||
|
|
|
||
|
|
process.on('SIGINT', handleSignal);
|
||
|
|
process.on('SIGTERM', handleSignal);
|
||
|
|
shutdownRequested
|
||
|
|
?.then(() => handleSignal())
|
||
|
|
.catch((err) => complete(err));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
|
|
}
|