feat: add native module realtime transport
CI / verify (pull_request) Successful in 37s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m7s

This commit is contained in:
ekklesia
2026-08-23 18:47:18 -05:00
parent fcc531a39c
commit fc6c8045c4
53 changed files with 3959 additions and 108 deletions
+111 -12
View File
@@ -1,20 +1,22 @@
import { pathToFileURL } from "node:url";
import {
type CapabilityId,
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleStorage,
type CapabilityRegistrar,
type CapabilityResolver,
capabilityIdSchema,
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleHttpRouteRegistrar,
type ModuleId,
ModuleRealtimeError,
type ModuleRealtimePublisher,
type ModuleRegistrationContext,
type ModuleRouteDefinitionInput,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
type ModuleRuntimeServices,
type ModuleStorage,
moduleRouteShapeKey,
moduleRoutesOverlap,
parseModuleRuntimeDefinition,
@@ -27,6 +29,11 @@ import {
discoverModules,
type ModuleDiscoveryOptions,
} from "./discovery.js";
import {
ModuleRealtimeRegistrationError,
type ModuleRealtimeRegistry,
StagedModuleRealtimeRegistry,
} from "./realtime.js";
export type ModuleRuntimePhase = "load" | "register" | "rollback" | "start" | "stop";
@@ -37,6 +44,18 @@ export type ModuleRuntimeIssueCode =
| "MODULE_CAPABILITY_UNDECLARED"
| "MODULE_CAPABILITY_UNREGISTERED"
| "MODULE_REGISTER_FAILED"
| "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID"
| "MODULE_REALTIME_CHANNEL_DUPLICATE"
| "MODULE_REALTIME_CHANNEL_INVALID"
| "MODULE_REALTIME_CHANNEL_LIMIT"
| "MODULE_REALTIME_CHANNEL_UNDECLARED"
| "MODULE_REALTIME_PAYLOAD_INVALID"
| "MODULE_REALTIME_PAYLOAD_TOO_LARGE"
| "MODULE_REALTIME_PERMISSION_INVALID"
| "MODULE_REALTIME_PERMISSION_UNDECLARED"
| "MODULE_REALTIME_PUBLISH_FAILED"
| "MODULE_REALTIME_PUBLISHER_STOPPED"
| "MODULE_REALTIME_SNAPSHOT_MISSING"
| "MODULE_ROLLBACK_FAILED"
| "MODULE_ROLLBACK_TIMEOUT"
| "MODULE_ROUTE_DEFINITION_INVALID"
@@ -77,6 +96,8 @@ export interface StartedModuleRuntime {
/** One immutable startup snapshot: discovered-module order, then registration order within each
* module. Never mutated after startup — there is no dynamic route addition or removal. */
readonly routes: readonly RegisteredModuleHttpRoute[];
/** Opaque, transport-neutral state and subscriptions for the host. */
readonly realtime: ModuleRealtimeRegistry;
stop(): Promise<void>;
}
@@ -193,6 +214,23 @@ function runtimeIssue(
return { code, message, moduleId, phase };
}
function realtimeRuntimeIssueCode(error: ModuleRealtimeError): ModuleRuntimeIssueCode {
switch (error.code) {
case "REALTIME_CHANNEL_INVALID":
return "MODULE_REALTIME_CHANNEL_INVALID";
case "REALTIME_CHANNEL_UNDECLARED":
return "MODULE_REALTIME_CHANNEL_UNDECLARED";
case "REALTIME_PAYLOAD_INVALID":
return "MODULE_REALTIME_PAYLOAD_INVALID";
case "REALTIME_PAYLOAD_TOO_LARGE":
return "MODULE_REALTIME_PAYLOAD_TOO_LARGE";
case "REALTIME_PUBLISHER_STOPPED":
return "MODULE_REALTIME_PUBLISHER_STOPPED";
default:
return "MODULE_REALTIME_PUBLISH_FAILED";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -280,6 +318,7 @@ class StagedCapabilityRegistry {
services: ModuleRuntimeServices,
config: ModuleConfiguration,
storage: ModuleStorage,
realtime: ModuleRealtimePublisher,
): ModuleRuntimeContext {
const capabilities: CapabilityResolver = Object.freeze({
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
@@ -291,6 +330,7 @@ class StagedCapabilityRegistry {
capabilities,
config,
moduleId: module.manifest.id,
realtime,
services,
storage,
});
@@ -528,16 +568,22 @@ async function registerModules(
modules: readonly LoadedModule[],
capabilityRegistry: StagedCapabilityRegistry,
httpRoutes: StagedModuleHttpRoutes,
realtimeRegistry: StagedModuleRealtimeRegistry,
configurations: ModuleConfigurationLookup,
): Promise<void> {
for (const module of modules) {
const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered);
const routeStage = httpRoutes.createRegistrationStage(module.discovered);
const realtimeStage = realtimeRegistry.createRegistrationStage(
module.discovered.manifest.id,
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
);
const context: ModuleRegistrationContext = Object.freeze({
capabilities: capabilityStage.capabilities,
config: configurationFor(configurations, module.discovered.manifest.id),
http: routeStage.http,
moduleId: module.discovered.manifest.id,
realtime: realtimeStage.realtime,
});
try {
await module.definition.register?.(context);
@@ -545,6 +591,16 @@ async function registerModules(
if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]);
}
if (error instanceof ModuleRealtimeRegistrationError) {
throw new ModuleRuntimeError([
runtimeIssue(
error.code,
"register",
module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} registered an invalid realtime channel.`,
),
]);
}
throw new ModuleRuntimeError([
runtimeIssue(
"MODULE_REGISTER_FAILED",
@@ -563,6 +619,7 @@ async function registerModules(
// a failed registration therefore publishes no routes for this module, matching the same
// all-or-nothing guarantee capabilities already have.
routeStage.commit();
realtimeStage.commit();
}
}
@@ -629,12 +686,16 @@ async function stopModules(
services: ModuleRuntimeServices,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
options: StopModulesOptions = {},
): Promise<readonly ModuleRuntimeIssue[]> {
const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS;
const issues: ModuleRuntimeIssue[] = [];
for (const module of [...modules].reverse()) {
const moduleId = module.discovered.manifest.id;
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
// module whose stop hook is about to run, not every publisher at the beginning of the sequence.
realtimeRegistry.deactivateModule(moduleId);
const stop = module.definition.stop;
if (!stop) {
// No synthetic callback and no timer for a module that declares no stop hook.
@@ -650,6 +711,7 @@ async function stopModules(
services,
configurationFor(configurations, moduleId),
storageFor(storages, moduleId),
realtimeRegistry.publisherFor(moduleId),
),
),
),
@@ -689,6 +751,7 @@ async function startModules(
services: ModuleRuntimeServices,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
options: StopModulesOptions = {},
): Promise<void> {
// A module joins the rollback set the moment its `start` hook is *invoked*, not when it
@@ -708,18 +771,38 @@ async function startModules(
services,
configurationFor(configurations, module.discovered.manifest.id),
storageFor(storages, module.discovered.manifest.id),
realtimeRegistry.publisherFor(module.discovered.manifest.id),
),
);
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
if (missingSnapshots.length > 0) {
throw new ModuleRuntimeSignal(
runtimeIssue(
"MODULE_REALTIME_SNAPSHOT_MISSING",
"start",
module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} did not publish an initial snapshot for every declared realtime channel.`,
),
);
}
} catch (error) {
const startIssue =
error instanceof ModuleRuntimeSignal
? error.issue
: runtimeIssue(
"MODULE_START_FAILED",
"start",
module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} startup failed.`,
);
: error instanceof ModuleRealtimeError
? runtimeIssue(
realtimeRuntimeIssueCode(error),
"start",
module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} realtime publication failed during startup.`,
)
: runtimeIssue(
"MODULE_START_FAILED",
"start",
module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} startup failed.`,
);
realtimeRegistry.closeSubscriptions();
const rollbackIssues = await stopModules(
rollbackCandidates,
registry,
@@ -727,8 +810,10 @@ async function startModules(
services,
configurations,
storages,
realtimeRegistry,
options,
);
realtimeRegistry.deactivateAll();
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
}
}
@@ -750,13 +835,23 @@ export async function startDiscoveredModuleRuntime(
const modules = await loadRuntimeDefinitions(discoveredModules);
const capabilityRegistry = new StagedCapabilityRegistry();
const httpRoutes = new StagedModuleHttpRoutes();
const realtimeRegistry = new StagedModuleRealtimeRegistry();
const configurations = timeouts.configurations ?? new Map<string, ModuleConfiguration>();
const storages = timeouts.storages ?? new Map<string, ModuleStorage>();
await registerModules(modules, capabilityRegistry, httpRoutes, configurations);
await startModules(modules, capabilityRegistry, services, configurations, storages, timeouts);
await registerModules(modules, capabilityRegistry, httpRoutes, realtimeRegistry, configurations);
await startModules(
modules,
capabilityRegistry,
services,
configurations,
storages,
realtimeRegistry,
timeouts,
);
const capabilities = capabilityRegistry.publish();
const routes = httpRoutes.publish();
const realtime = realtimeRegistry.publish();
const runningModules = Object.freeze(
modules.map((module) =>
Object.freeze({
@@ -770,9 +865,11 @@ export async function startDiscoveredModuleRuntime(
return Object.freeze({
capabilities,
modules: runningModules,
realtime,
routes,
stop: (): Promise<void> => {
stopPromise ??= (async () => {
realtime.closeSubscriptions();
const issues = await stopModules(
modules,
capabilityRegistry,
@@ -780,8 +877,10 @@ export async function startDiscoveredModuleRuntime(
services,
configurations,
storages,
realtimeRegistry,
timeouts,
);
realtimeRegistry.deactivateAll();
if (issues.length > 0) {
throw new ModuleRuntimeError(issues);
}