feat: add native module realtime transport
This commit is contained in:
+111
-12
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user