feat: add native module realtime transport
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
export * from "./configuration.js";
|
||||
export * from "./configuration-schema.js";
|
||||
export * from "./configuration-store.js";
|
||||
export * from "./configuration.js";
|
||||
export * from "./database.js";
|
||||
export * from "./discovery.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./lifecycle-schema.js";
|
||||
export * from "./lifecycle-store.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./realtime.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./schema-discovery.js";
|
||||
export * from "./ui-publication.js";
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import {
|
||||
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
|
||||
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
|
||||
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
|
||||
type ModuleId,
|
||||
type ModuleRealtimeChannel,
|
||||
type ModuleRealtimeChannelDefinitionInput,
|
||||
type ModuleRealtimeChannelRegistrar,
|
||||
ModuleRealtimeError,
|
||||
type ModuleRealtimePublisher,
|
||||
moduleIdSchema,
|
||||
moduleRealtimeChannelSchema,
|
||||
type PermissionId,
|
||||
safeParseModuleRealtimeChannelDefinition,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
export type ModuleRealtimeRegistrationIssueCode =
|
||||
| "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID"
|
||||
| "MODULE_REALTIME_CHANNEL_INVALID"
|
||||
| "MODULE_REALTIME_PERMISSION_INVALID"
|
||||
| "MODULE_REALTIME_PERMISSION_UNDECLARED"
|
||||
| "MODULE_REALTIME_CHANNEL_DUPLICATE"
|
||||
| "MODULE_REALTIME_CHANNEL_LIMIT";
|
||||
|
||||
/** An internal fixed-code signal. The runtime converts it to its ordinary structured issue shape. */
|
||||
export class ModuleRealtimeRegistrationError extends Error {
|
||||
readonly code: ModuleRealtimeRegistrationIssueCode;
|
||||
|
||||
constructor(code: ModuleRealtimeRegistrationIssueCode) {
|
||||
super("Module realtime channel registration failed.");
|
||||
this.name = "ModuleRealtimeRegistrationError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegisteredModuleRealtimeChannel {
|
||||
readonly moduleId: ModuleId;
|
||||
readonly channel: ModuleRealtimeChannel;
|
||||
readonly permission: PermissionId;
|
||||
}
|
||||
|
||||
/** A host-facing opaque publication. `serializedPayload` is the exact JSON snapshot serialized once
|
||||
* at publish time; hosts forward it without inspecting, mutating, or re-signing values inside it. */
|
||||
export interface ModuleRealtimePublication {
|
||||
readonly sequence: number;
|
||||
readonly serializedPayload: string;
|
||||
}
|
||||
|
||||
/** `snapshot` is captured atomically with installing this subscription. `read` then yields ordered
|
||||
* later publications. A slow reader retains only the newest pending publication. */
|
||||
export interface ModuleRealtimeSubscription {
|
||||
readonly snapshot: ModuleRealtimePublication;
|
||||
read(): Promise<ModuleRealtimePublication | undefined>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface ModuleRealtimeRegistry {
|
||||
readonly channels: readonly RegisteredModuleRealtimeChannel[];
|
||||
/** Refuses new subscriptions and closes existing streams while publishers remain usable for
|
||||
* ordinary HTTP requests already draining. Publisher lifetime remains Core runtime-owned. */
|
||||
closeSubscriptions(): void;
|
||||
publisherFor(moduleId: ModuleId): ModuleRealtimePublisher;
|
||||
subscribe(moduleId: ModuleId, channel: string): ModuleRealtimeSubscription;
|
||||
}
|
||||
|
||||
const emptyRealtimePublisher: ModuleRealtimePublisher = Object.freeze({
|
||||
publish: (): never => {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
},
|
||||
});
|
||||
|
||||
/** Explicit no-channel host composition for tests and hosts that start no modules. */
|
||||
export const EMPTY_MODULE_REALTIME_REGISTRY: ModuleRealtimeRegistry = Object.freeze({
|
||||
channels: Object.freeze([]),
|
||||
closeSubscriptions: (): void => undefined,
|
||||
publisherFor: (): ModuleRealtimePublisher => emptyRealtimePublisher,
|
||||
subscribe: (): never => {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
},
|
||||
});
|
||||
|
||||
interface ChannelState {
|
||||
readonly definition: RegisteredModuleRealtimeChannel;
|
||||
readonly subscriptions: Set<BoundedModuleRealtimeSubscription>;
|
||||
publication?: ModuleRealtimePublication;
|
||||
}
|
||||
|
||||
interface RealtimeRegistrationStage {
|
||||
readonly realtime: ModuleRealtimeChannelRegistrar;
|
||||
commit(): void;
|
||||
}
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
type JsonWriteResult = "invalid" | "success" | "too-large";
|
||||
|
||||
/** Accumulates at most the public payload ceiling. Each encoded fragment is itself bounded, so an
|
||||
* oversized value cannot create an oversized temporary serialization before being refused. */
|
||||
class BoundedJsonWriter {
|
||||
readonly #chunks: string[] = [];
|
||||
#bytes = 0;
|
||||
|
||||
get remainingBytes(): number {
|
||||
return MODULE_REALTIME_MAX_PAYLOAD_BYTES - this.#bytes;
|
||||
}
|
||||
|
||||
append(fragment: string): boolean {
|
||||
const bytes = textEncoder.encode(fragment).byteLength;
|
||||
if (bytes > this.remainingBytes) {
|
||||
return false;
|
||||
}
|
||||
this.#chunks.push(fragment);
|
||||
this.#bytes += bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
finish(): string {
|
||||
return this.#chunks.join("");
|
||||
}
|
||||
}
|
||||
|
||||
function appendRawString(
|
||||
writer: BoundedJsonWriter,
|
||||
value: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): boolean {
|
||||
let offset = start;
|
||||
while (offset < end) {
|
||||
let chunkEnd = Math.min(offset + 1_024, end);
|
||||
const finalCodeUnit = value.charCodeAt(chunkEnd - 1);
|
||||
if (
|
||||
chunkEnd < end &&
|
||||
finalCodeUnit >= 0xd800 &&
|
||||
finalCodeUnit <= 0xdbff &&
|
||||
value.charCodeAt(chunkEnd) >= 0xdc00 &&
|
||||
value.charCodeAt(chunkEnd) <= 0xdfff
|
||||
) {
|
||||
chunkEnd -= 1;
|
||||
}
|
||||
if (!writer.append(value.slice(offset, chunkEnd))) {
|
||||
return false;
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapedCodeUnit(codeUnit: number): string | undefined {
|
||||
switch (codeUnit) {
|
||||
case 0x08:
|
||||
return "\\b";
|
||||
case 0x09:
|
||||
return "\\t";
|
||||
case 0x0a:
|
||||
return "\\n";
|
||||
case 0x0c:
|
||||
return "\\f";
|
||||
case 0x0d:
|
||||
return "\\r";
|
||||
case 0x22:
|
||||
return '\\"';
|
||||
case 0x5c:
|
||||
return "\\\\";
|
||||
default:
|
||||
return codeUnit <= 0x1f ? `\\u${codeUnit.toString(16).padStart(4, "0")}` : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the same JSON string representation as `JSON.stringify`, including escaping lone UTF-16
|
||||
* surrogates, but stops the moment the output budget cannot be met. */
|
||||
function writeJsonString(writer: BoundedJsonWriter, value: string): JsonWriteResult {
|
||||
// Every UTF-16 code unit contributes at least one UTF-8 byte to JSON. This rejects an enormous
|
||||
// string without first copying or stringifying it.
|
||||
if (value.length + 2 > writer.remainingBytes) {
|
||||
return "too-large";
|
||||
}
|
||||
if (!writer.append('"')) {
|
||||
return "too-large";
|
||||
}
|
||||
let rawStart = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index);
|
||||
let escaped = escapedCodeUnit(codeUnit);
|
||||
if (escaped === undefined && codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const following = value.charCodeAt(index + 1);
|
||||
if (following >= 0xdc00 && following <= 0xdfff) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
escaped = `\\u${codeUnit.toString(16)}`;
|
||||
} else if (escaped === undefined && codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
escaped = `\\u${codeUnit.toString(16)}`;
|
||||
}
|
||||
if (escaped === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!appendRawString(writer, value, rawStart, index) || !writer.append(escaped)) {
|
||||
return "too-large";
|
||||
}
|
||||
rawStart = index + 1;
|
||||
}
|
||||
if (!appendRawString(writer, value, rawStart, value.length) || !writer.append('"')) {
|
||||
return "too-large";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
|
||||
function writeJsonValue(
|
||||
writer: BoundedJsonWriter,
|
||||
value: unknown,
|
||||
ancestors: Set<object>,
|
||||
depth: number,
|
||||
): JsonWriteResult {
|
||||
if (value === null) {
|
||||
return writer.append("null") ? "success" : "too-large";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return writeJsonString(writer, value);
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return writer.append(value ? "true" : "false") ? "success" : "too-large";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "invalid";
|
||||
}
|
||||
// A finite JavaScript number's JSON spelling is tiny and cannot be an unbounded allocation.
|
||||
const serializedNumber = JSON.stringify(value);
|
||||
return writer.append(serializedNumber) ? "success" : "too-large";
|
||||
}
|
||||
if (typeof value !== "object" || depth > 64 || ancestors.has(value)) {
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
try {
|
||||
// Never execute user-defined serialization hooks. A non-enumerable `toJSON` still changes
|
||||
// `JSON.stringify` output, so silently ignoring it would make validation and wire semantics
|
||||
// disagree. Reject the hook itself without reading or invoking its value.
|
||||
if (Object.hasOwn(value, "toJSON")) {
|
||||
return "invalid";
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getPrototypeOf(value) !== Array.prototype) {
|
||||
return "invalid";
|
||||
}
|
||||
// Brackets, one single-byte value per element, and separators are the smallest possible
|
||||
// representation. If even that cannot fit, do not enumerate a potentially enormous array.
|
||||
const minimumBytes = value.length === 0 ? 2 : value.length * 2 + 1;
|
||||
if (minimumBytes > writer.remainingBytes) {
|
||||
return "too-large";
|
||||
}
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
continue;
|
||||
}
|
||||
if (!/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length) {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
if (!writer.append("[")) {
|
||||
return "too-large";
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
return "invalid";
|
||||
}
|
||||
if (index > 0 && !writer.append(",")) {
|
||||
return "too-large";
|
||||
}
|
||||
const result = writeJsonValue(writer, descriptor.value, ancestors, depth + 1);
|
||||
if (result !== "success") {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return writer.append("]") ? "success" : "too-large";
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
return "invalid";
|
||||
}
|
||||
if (!writer.append("{")) {
|
||||
return "too-large";
|
||||
}
|
||||
let entryCount = 0;
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
continue;
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
return "invalid";
|
||||
}
|
||||
if (entryCount > 0 && !writer.append(",")) {
|
||||
return "too-large";
|
||||
}
|
||||
const keyResult = writeJsonString(writer, key);
|
||||
if (keyResult !== "success") {
|
||||
return keyResult;
|
||||
}
|
||||
if (!writer.append(":")) {
|
||||
return "too-large";
|
||||
}
|
||||
const valueResult = writeJsonValue(writer, descriptor.value, ancestors, depth + 1);
|
||||
if (valueResult !== "success") {
|
||||
return valueResult;
|
||||
}
|
||||
entryCount += 1;
|
||||
}
|
||||
return writer.append("}") ? "success" : "too-large";
|
||||
} catch {
|
||||
return "invalid";
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
export type ModuleRealtimePayloadSerializationResult =
|
||||
| { readonly success: true; readonly serializedPayload: string }
|
||||
| { readonly success: false; readonly code: "REALTIME_PAYLOAD_INVALID" }
|
||||
| { readonly success: false; readonly code: "REALTIME_PAYLOAD_TOO_LARGE" };
|
||||
|
||||
/** Serializes once and reports only a fixed code. It never returns or embeds rejected input. */
|
||||
export function serializeModuleRealtimePayload(
|
||||
payload: unknown,
|
||||
): ModuleRealtimePayloadSerializationResult {
|
||||
const writer = new BoundedJsonWriter();
|
||||
const result = writeJsonValue(writer, payload, new Set<object>(), 0);
|
||||
if (result === "invalid") {
|
||||
return { success: false, code: "REALTIME_PAYLOAD_INVALID" };
|
||||
}
|
||||
if (result === "too-large") {
|
||||
return { success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" };
|
||||
}
|
||||
return { success: true, serializedPayload: writer.finish() };
|
||||
}
|
||||
|
||||
class BoundedModuleRealtimeSubscription implements ModuleRealtimeSubscription {
|
||||
readonly snapshot: ModuleRealtimePublication;
|
||||
#closed = false;
|
||||
#pending: ModuleRealtimePublication | undefined;
|
||||
#reader: ((publication: ModuleRealtimePublication | undefined) => void) | undefined;
|
||||
readonly #onClose: () => void;
|
||||
|
||||
constructor(snapshot: ModuleRealtimePublication, onClose: () => void) {
|
||||
this.snapshot = snapshot;
|
||||
this.#onClose = onClose;
|
||||
}
|
||||
|
||||
push(publication: ModuleRealtimePublication): void {
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
const reader = this.#reader;
|
||||
if (reader) {
|
||||
this.#reader = undefined;
|
||||
reader(publication);
|
||||
return;
|
||||
}
|
||||
// One slot, overwritten by the newest publication. A publisher never waits for a browser.
|
||||
this.#pending = publication;
|
||||
}
|
||||
|
||||
read(): Promise<ModuleRealtimePublication | undefined> {
|
||||
if (this.#closed) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
if (this.#pending) {
|
||||
const publication = this.#pending;
|
||||
this.#pending = undefined;
|
||||
return Promise.resolve(publication);
|
||||
}
|
||||
if (this.#reader) {
|
||||
return Promise.reject(new ModuleRealtimeError("REALTIME_UNAVAILABLE"));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.#reader = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
this.#closed = true;
|
||||
this.#pending = undefined;
|
||||
const reader = this.#reader;
|
||||
this.#reader = undefined;
|
||||
this.#onClose();
|
||||
reader?.(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function stateKey(moduleId: string, channel: string): string {
|
||||
return `${moduleId}\0${channel}`;
|
||||
}
|
||||
|
||||
/** Registration is staged per module, while state and subscriptions stay private and bounded. */
|
||||
export class StagedModuleRealtimeRegistry {
|
||||
readonly #channels: RegisteredModuleRealtimeChannel[] = [];
|
||||
readonly #publishers = new Map<ModuleId, ModuleRealtimePublisher>();
|
||||
readonly #states = new Map<string, ChannelState>();
|
||||
readonly #activeModules = new Set<ModuleId>();
|
||||
#acceptingSubscriptions = true;
|
||||
|
||||
createRegistrationStage(
|
||||
moduleId: ModuleId,
|
||||
ownedPermissionIds: ReadonlySet<PermissionId>,
|
||||
): RealtimeRegistrationStage {
|
||||
const local: RegisteredModuleRealtimeChannel[] = [];
|
||||
const localNames = new Set<ModuleRealtimeChannel>();
|
||||
const realtime: ModuleRealtimeChannelRegistrar = Object.freeze({
|
||||
channel: (definition: ModuleRealtimeChannelDefinitionInput): void => {
|
||||
const parsed = safeParseModuleRealtimeChannelDefinition(definition);
|
||||
if (!parsed.success) {
|
||||
throw new ModuleRealtimeRegistrationError(parsed.code);
|
||||
}
|
||||
if (!ownedPermissionIds.has(parsed.data.permission)) {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_PERMISSION_UNDECLARED");
|
||||
}
|
||||
if (localNames.has(parsed.data.channel)) {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_DUPLICATE");
|
||||
}
|
||||
if (local.length >= MODULE_REALTIME_MAX_CHANNELS_PER_MODULE) {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_LIMIT");
|
||||
}
|
||||
localNames.add(parsed.data.channel);
|
||||
local.push(
|
||||
Object.freeze({
|
||||
channel: parsed.data.channel,
|
||||
moduleId,
|
||||
permission: parsed.data.permission,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
realtime,
|
||||
commit: (): void => {
|
||||
for (const definition of local) {
|
||||
this.#channels.push(definition);
|
||||
this.#states.set(stateKey(moduleId, definition.channel), {
|
||||
definition,
|
||||
subscriptions: new Set(),
|
||||
});
|
||||
}
|
||||
this.#activeModules.add(moduleId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
publisherFor(moduleId: ModuleId): ModuleRealtimePublisher {
|
||||
let publisher = this.#publishers.get(moduleId);
|
||||
if (!publisher) {
|
||||
publisher = Object.freeze({
|
||||
publish: (channel: string, payload: unknown): void => {
|
||||
this.#publish(moduleId, channel, payload);
|
||||
},
|
||||
});
|
||||
this.#publishers.set(moduleId, publisher);
|
||||
}
|
||||
return publisher;
|
||||
}
|
||||
|
||||
missingSnapshots(moduleId: ModuleId): readonly ModuleRealtimeChannel[] {
|
||||
return Object.freeze(
|
||||
this.#channels
|
||||
.filter((definition) => definition.moduleId === moduleId)
|
||||
.filter(
|
||||
(definition) => !this.#states.get(stateKey(moduleId, definition.channel))?.publication,
|
||||
)
|
||||
.map((definition) => definition.channel),
|
||||
);
|
||||
}
|
||||
|
||||
closeSubscriptions(): void {
|
||||
this.#acceptingSubscriptions = false;
|
||||
for (const state of this.#states.values()) {
|
||||
for (const subscription of [...state.subscriptions]) {
|
||||
subscription.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes one module's captured publisher inert immediately before that module's stop hook. Other
|
||||
* dependency modules remain live until reverse-order shutdown reaches their own hook. */
|
||||
deactivateModule(moduleId: ModuleId): void {
|
||||
this.#activeModules.delete(moduleId);
|
||||
for (const state of this.#states.values()) {
|
||||
if (state.definition.moduleId !== moduleId) {
|
||||
continue;
|
||||
}
|
||||
for (const subscription of [...state.subscriptions]) {
|
||||
subscription.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deactivateAll(): void {
|
||||
this.closeSubscriptions();
|
||||
this.#activeModules.clear();
|
||||
}
|
||||
|
||||
publish(): ModuleRealtimeRegistry {
|
||||
const channels = Object.freeze([...this.#channels]);
|
||||
return Object.freeze({
|
||||
channels,
|
||||
closeSubscriptions: (): void => this.closeSubscriptions(),
|
||||
publisherFor: (moduleId: ModuleId): ModuleRealtimePublisher => this.publisherFor(moduleId),
|
||||
subscribe: (moduleId: ModuleId, channel: string): ModuleRealtimeSubscription =>
|
||||
this.subscribe(moduleId, channel),
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(moduleId: ModuleId, channel: string): ModuleRealtimeSubscription {
|
||||
if (!this.#acceptingSubscriptions) {
|
||||
throw new ModuleRealtimeError("REALTIME_UNAVAILABLE");
|
||||
}
|
||||
if (
|
||||
!moduleIdSchema.safeParse(moduleId).success ||
|
||||
!moduleRealtimeChannelSchema.safeParse(channel).success
|
||||
) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_INVALID");
|
||||
}
|
||||
const state = this.#states.get(stateKey(moduleId, channel));
|
||||
if (!state) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
}
|
||||
if (!state.publication) {
|
||||
throw new ModuleRealtimeError("REALTIME_SNAPSHOT_UNAVAILABLE");
|
||||
}
|
||||
if (state.subscriptions.size >= MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL) {
|
||||
throw new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT");
|
||||
}
|
||||
const subscription = new BoundedModuleRealtimeSubscription(state.publication, () => {
|
||||
state.subscriptions.delete(subscription);
|
||||
});
|
||||
// JavaScript runs this capture and insertion without interleaving. A later publish therefore
|
||||
// either becomes `snapshot` or is observed by `read`, never lost between those two operations.
|
||||
state.subscriptions.add(subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
#publish(moduleId: ModuleId, channel: string, payload: unknown): void {
|
||||
if (!this.#activeModules.has(moduleId)) {
|
||||
throw new ModuleRealtimeError("REALTIME_PUBLISHER_STOPPED");
|
||||
}
|
||||
const parsedChannel = moduleRealtimeChannelSchema.safeParse(channel);
|
||||
if (!parsedChannel.success) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_INVALID");
|
||||
}
|
||||
const state = this.#states.get(stateKey(moduleId, parsedChannel.data));
|
||||
if (!state) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
}
|
||||
const serialized = serializeModuleRealtimePayload(payload);
|
||||
if (!serialized.success) {
|
||||
throw new ModuleRealtimeError(serialized.code);
|
||||
}
|
||||
const previousSequence = state.publication?.sequence ?? 0;
|
||||
if (previousSequence >= Number.MAX_SAFE_INTEGER) {
|
||||
throw new ModuleRealtimeError("REALTIME_UNAVAILABLE");
|
||||
}
|
||||
const publication = Object.freeze({
|
||||
sequence: previousSequence + 1,
|
||||
serializedPayload: serialized.serializedPayload,
|
||||
});
|
||||
state.publication = publication;
|
||||
for (const subscription of state.subscriptions) {
|
||||
subscription.push(publication);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ModuleDatabase,
|
||||
ModuleRealtimePublisher,
|
||||
ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const globalSymbols: symbol[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
const globals = globalThis as unknown as Record<PropertyKey, unknown>;
|
||||
for (const symbol of globalSymbols.splice(0)) {
|
||||
delete globals[symbol];
|
||||
}
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
function fakeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||
}
|
||||
|
||||
function manifest(
|
||||
id: string,
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
permissions,
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
};
|
||||
}
|
||||
|
||||
async function createRuntimeFixture(
|
||||
id: string,
|
||||
serverSource: string,
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Promise<string> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-realtime-runtime-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
name: `@test/module-${id}`,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify(manifest(id, permissions)),
|
||||
),
|
||||
writeFile(path.join(moduleDirectory, "dist/server/index.js"), serverSource),
|
||||
]);
|
||||
return modulesDirectory;
|
||||
}
|
||||
|
||||
function runtimeOptions(modulesDirectory: string) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
services: fakeServices(),
|
||||
};
|
||||
}
|
||||
|
||||
function recorder(): {
|
||||
readonly expression: string;
|
||||
readonly value: { events: string[]; publishers: ModuleRealtimePublisher[] };
|
||||
} {
|
||||
const symbol = Symbol.for(`ekklesia.realtime.${Date.now()}.${Math.random()}`);
|
||||
const value = { events: [] as string[], publishers: [] as ModuleRealtimePublisher[] };
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = value;
|
||||
globalSymbols.push(symbol);
|
||||
return { expression: `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`, value };
|
||||
}
|
||||
|
||||
describe("module runtime realtime lifecycle", () => {
|
||||
it("stages declarations, requires startup state, delivers live state, and closes before stop", async () => {
|
||||
const recorded = recorder();
|
||||
const objectUrl = "https://delivery.invalid/slides/welcome.png?signature=unchanged";
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"announcements",
|
||||
`
|
||||
export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
${recorded.expression}.events.push("register");
|
||||
},
|
||||
start({ realtime }) {
|
||||
${recorded.expression}.publishers.push(realtime);
|
||||
realtime.publish("state", {
|
||||
currentSlide: 0,
|
||||
previousSlide: -1,
|
||||
slides: [${JSON.stringify(objectUrl)}],
|
||||
slideSeconds: 7,
|
||||
mode: "on"
|
||||
});
|
||||
${recorded.expression}.events.push("start");
|
||||
},
|
||||
stop({ realtime }) {
|
||||
try { realtime.publish("state", { mode: "off" }); }
|
||||
catch (error) { ${recorded.expression}.events.push("stop:" + error.code); }
|
||||
}
|
||||
};
|
||||
`,
|
||||
[
|
||||
{
|
||||
id: "announcements.slideshow.view",
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
expect(recorded.value.events).toEqual(["register", "start"]);
|
||||
expect(runtime.realtime.channels).toEqual([
|
||||
{
|
||||
moduleId: "announcements",
|
||||
channel: "state",
|
||||
permission: "announcements.slideshow.view",
|
||||
},
|
||||
]);
|
||||
const subscription = runtime.realtime.subscribe("announcements", "state");
|
||||
expect(subscription.snapshot.sequence).toBe(1);
|
||||
expect(subscription.snapshot.serializedPayload).toContain(objectUrl);
|
||||
|
||||
const next = subscription.read();
|
||||
recorded.value.publishers[0]?.publish("state", {
|
||||
currentSlide: 1,
|
||||
previousSlide: 0,
|
||||
slides: [objectUrl],
|
||||
slideSeconds: 7,
|
||||
mode: "on",
|
||||
});
|
||||
await expect(next).resolves.toMatchObject({ sequence: 2 });
|
||||
const pendingClose = subscription.read();
|
||||
await runtime.stop();
|
||||
await expect(pendingClose).resolves.toBeUndefined();
|
||||
expect(recorded.value.events).toEqual(["register", "start", "stop:REALTIME_PUBLISHER_STOPPED"]);
|
||||
expect(() => recorded.value.publishers[0]?.publish("state", null)).toThrowError(
|
||||
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails atomically and rolls back when a declared channel has no initial snapshot", async () => {
|
||||
const recorded = recorder();
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"announcements",
|
||||
`
|
||||
export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
},
|
||||
start() { ${recorded.expression}.events.push("start"); },
|
||||
stop() { ${recorded.expression}.events.push("rollback"); }
|
||||
};
|
||||
`,
|
||||
[
|
||||
{
|
||||
id: "announcements.slideshow.view",
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await expect(startModuleRuntime(runtimeOptions(modulesDirectory))).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_REALTIME_SNAPSHOT_MISSING" })],
|
||||
});
|
||||
expect(recorded.value.events).toEqual(["start", "rollback"]);
|
||||
});
|
||||
|
||||
it("reports invalid owned-permission registration with a deterministic sanitized issue", async () => {
|
||||
const secret = "/machine/private/permission-token";
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"announcements",
|
||||
`
|
||||
export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.not-owned" });
|
||||
}
|
||||
};
|
||||
// ${secret}
|
||||
`,
|
||||
);
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(ModuleRuntimeError);
|
||||
expect(failure).toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_REALTIME_PERMISSION_UNDECLARED" })],
|
||||
});
|
||||
expect(JSON.stringify(failure)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("starts and stops modules that declare no realtime channels unchanged", async () => {
|
||||
const recorded = recorder();
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"calendar",
|
||||
`
|
||||
export default {
|
||||
id: "calendar",
|
||||
version: "0.1.0",
|
||||
start() { ${recorded.expression}.events.push("start"); },
|
||||
stop() { ${recorded.expression}.events.push("stop"); }
|
||||
};
|
||||
`,
|
||||
);
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
expect(runtime.realtime.channels).toEqual([]);
|
||||
await runtime.stop();
|
||||
expect(recorded.value.events).toEqual(["start", "stop"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
|
||||
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
|
||||
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
|
||||
ModuleRealtimeError,
|
||||
moduleIdSchema,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ModuleRealtimeRegistrationError,
|
||||
StagedModuleRealtimeRegistry,
|
||||
serializeModuleRealtimePayload,
|
||||
} from "../src/modules/realtime.js";
|
||||
|
||||
const permission = parsePermissionId("announcements.slideshow.view");
|
||||
|
||||
function registeredRegistry(
|
||||
modules: Readonly<Record<string, readonly string[]>>,
|
||||
): StagedModuleRealtimeRegistry {
|
||||
const registry = new StagedModuleRealtimeRegistry();
|
||||
for (const [rawModuleId, channels] of Object.entries(modules)) {
|
||||
const moduleId = moduleIdSchema.parse(rawModuleId);
|
||||
const stage = registry.createRegistrationStage(moduleId, new Set([permission]));
|
||||
for (const channel of channels) {
|
||||
stage.realtime.channel({ channel, permission });
|
||||
}
|
||||
stage.commit();
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
function expectRealtimeCode(callback: () => unknown, code: string): void {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleRealtimeError);
|
||||
expect(error).toMatchObject({ code });
|
||||
return;
|
||||
}
|
||||
throw new Error(`Expected realtime error ${code}.`);
|
||||
}
|
||||
|
||||
describe("module realtime registry", () => {
|
||||
it("publishes immutable module-scoped channel inventory", () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"], calendar: ["schedule"] });
|
||||
const registry = staged.publish();
|
||||
|
||||
expect(registry.channels).toEqual([
|
||||
{ moduleId: "announcements", channel: "state", permission },
|
||||
{ moduleId: "calendar", channel: "schedule", permission },
|
||||
]);
|
||||
expect(Object.isFrozen(registry.channels)).toBe(true);
|
||||
expect(Object.isFrozen(registry.channels[0])).toBe(true);
|
||||
|
||||
staged.publisherFor(moduleIdSchema.parse("announcements")).publish("state", { mode: "on" });
|
||||
staged.publisherFor(moduleIdSchema.parse("calendar")).publish("schedule", { mode: "off" });
|
||||
expect(
|
||||
JSON.parse(
|
||||
registry.subscribe(moduleIdSchema.parse("announcements"), "state").snapshot
|
||||
.serializedPayload,
|
||||
),
|
||||
).toEqual({ mode: "on" });
|
||||
expect(
|
||||
JSON.parse(
|
||||
registry.subscribe(moduleIdSchema.parse("calendar"), "schedule").snapshot.serializedPayload,
|
||||
),
|
||||
).toEqual({ mode: "off" });
|
||||
expectRealtimeCode(
|
||||
() =>
|
||||
staged
|
||||
.publisherFor(moduleIdSchema.parse("announcements"))
|
||||
.publish("schedule", { mode: "off" }),
|
||||
"REALTIME_CHANNEL_UNDECLARED",
|
||||
);
|
||||
expectRealtimeCode(
|
||||
() => staged.publisherFor(moduleIdSchema.parse("calendar")).publish("state", { mode: "on" }),
|
||||
"REALTIME_CHANNEL_UNDECLARED",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures an immediate snapshot, preserves ordered live updates, and reconnects at latest", async () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
const registry = staged.publish();
|
||||
publisher.publish("state", { currentSlide: 0 });
|
||||
|
||||
const first = registry.subscribe(moduleId, "state");
|
||||
expect(first.snapshot).toEqual({ sequence: 1, serializedPayload: '{"currentSlide":0}' });
|
||||
|
||||
const secondPublication = first.read();
|
||||
publisher.publish("state", { currentSlide: 1 });
|
||||
await expect(secondPublication).resolves.toEqual({
|
||||
sequence: 2,
|
||||
serializedPayload: '{"currentSlide":1}',
|
||||
});
|
||||
const thirdPublication = first.read();
|
||||
publisher.publish("state", { currentSlide: 2 });
|
||||
await expect(thirdPublication).resolves.toEqual({
|
||||
sequence: 3,
|
||||
serializedPayload: '{"currentSlide":2}',
|
||||
});
|
||||
|
||||
const reconnected = registry.subscribe(moduleId, "state");
|
||||
expect(reconnected.snapshot).toEqual({
|
||||
sequence: 3,
|
||||
serializedPayload: '{"currentSlide":2}',
|
||||
});
|
||||
first.close();
|
||||
reconnected.close();
|
||||
});
|
||||
|
||||
it("coalesces a slow subscriber to its newest single pending update", async () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
publisher.publish("state", { value: 0 });
|
||||
const subscription = staged.publish().subscribe(moduleId, "state");
|
||||
|
||||
for (let value = 1; value <= 1_000; value += 1) {
|
||||
publisher.publish("state", { value });
|
||||
}
|
||||
await expect(subscription.read()).resolves.toEqual({
|
||||
sequence: 1_001,
|
||||
serializedPayload: '{"value":1000}',
|
||||
});
|
||||
subscription.close();
|
||||
});
|
||||
|
||||
it("bounds subscribers and releases every slot on close", () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
staged.publisherFor(moduleId).publish("state", null);
|
||||
const registry = staged.publish();
|
||||
const subscriptions = Array.from({ length: MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL }, () =>
|
||||
registry.subscribe(moduleId, "state"),
|
||||
);
|
||||
expectRealtimeCode(() => registry.subscribe(moduleId, "state"), "REALTIME_SUBSCRIBER_LIMIT");
|
||||
subscriptions[0]?.close();
|
||||
expect(() => registry.subscribe(moduleId, "state")).not.toThrow();
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes pending readers and permanently deactivates publishers during shutdown", async () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
publisher.publish("state", { mode: "on" });
|
||||
const registry = staged.publish();
|
||||
const subscription = registry.subscribe(moduleId, "state");
|
||||
const pending = subscription.read();
|
||||
|
||||
staged.deactivateAll();
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expectRealtimeCode(
|
||||
() => publisher.publish("state", { mode: "off" }),
|
||||
"REALTIME_PUBLISHER_STOPPED",
|
||||
);
|
||||
expectRealtimeCode(() => registry.subscribe(moduleId, "state"), "REALTIME_UNAVAILABLE");
|
||||
});
|
||||
|
||||
it("can close transport subscriptions while an in-flight HTTP publisher finishes", async () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
publisher.publish("state", { mode: "on" });
|
||||
const registry = staged.publish();
|
||||
const pending = registry.subscribe(moduleId, "state").read();
|
||||
|
||||
registry.closeSubscriptions();
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expect(() => publisher.publish("state", { mode: "off" })).not.toThrow();
|
||||
expectRealtimeCode(() => registry.subscribe(moduleId, "state"), "REALTIME_UNAVAILABLE");
|
||||
|
||||
staged.deactivateAll();
|
||||
expectRealtimeCode(
|
||||
() => publisher.publish("state", { mode: "on" }),
|
||||
"REALTIME_PUBLISHER_STOPPED",
|
||||
);
|
||||
});
|
||||
|
||||
it("deactivates only the module whose reverse-order stop is beginning", () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"], calendar: ["schedule"] });
|
||||
const announcementsId = moduleIdSchema.parse("announcements");
|
||||
const calendarId = moduleIdSchema.parse("calendar");
|
||||
const announcements = staged.publisherFor(announcementsId);
|
||||
const calendar = staged.publisherFor(calendarId);
|
||||
announcements.publish("state", { mode: "on" });
|
||||
calendar.publish("schedule", { date: "2026-08-23" });
|
||||
|
||||
staged.deactivateModule(announcementsId);
|
||||
|
||||
expectRealtimeCode(
|
||||
() => announcements.publish("state", { mode: "off" }),
|
||||
"REALTIME_PUBLISHER_STOPPED",
|
||||
);
|
||||
expect(() => calendar.publish("schedule", { date: "2026-08-24" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("serializes opaque state once so later source mutation cannot change it", () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const slides = ["https://delivery.invalid/object?signature=stable"];
|
||||
staged.publisherFor(moduleId).publish("state", { slides });
|
||||
slides[0] = "https://delivery.invalid/object?signature=changed";
|
||||
|
||||
const snapshot = staged.publish().subscribe(moduleId, "state").snapshot;
|
||||
expect(snapshot.serializedPayload).toContain("signature=stable");
|
||||
expect(snapshot.serializedPayload).not.toContain("signature=changed");
|
||||
});
|
||||
|
||||
it("accepts a named module state interface while runtime validation remains authoritative", () => {
|
||||
interface SlideshowState {
|
||||
readonly currentSlide: number;
|
||||
readonly mode: "on" | "off";
|
||||
readonly slides: readonly string[];
|
||||
}
|
||||
const state: SlideshowState = {
|
||||
currentSlide: 4,
|
||||
mode: "on",
|
||||
slides: ["https://delivery.invalid/welcome.png"],
|
||||
};
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
|
||||
staged.publisherFor(moduleId).publish("state", state);
|
||||
|
||||
expect(
|
||||
JSON.parse(staged.publish().subscribe(moduleId, "state").snapshot.serializedPayload),
|
||||
).toEqual(state);
|
||||
});
|
||||
|
||||
it("rejects non-JSON values and payloads above the UTF-8 limit with fixed codes", () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const publisher = staged.publisherFor(moduleIdSchema.parse("announcements"));
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
const sparse = Array.from({ length: 2 }) as unknown[];
|
||||
sparse[0] = "present";
|
||||
const toJson = vi.fn(() => ({ mode: "rewritten" }));
|
||||
const hooked = { mode: "original" };
|
||||
Object.defineProperty(hooked, "toJSON", { value: toJson });
|
||||
|
||||
for (const payload of [
|
||||
undefined,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
1n,
|
||||
new Date(),
|
||||
cyclic,
|
||||
sparse,
|
||||
hooked,
|
||||
]) {
|
||||
expectRealtimeCode(() => publisher.publish("state", payload), "REALTIME_PAYLOAD_INVALID");
|
||||
}
|
||||
expect(toJson).not.toHaveBeenCalled();
|
||||
expectRealtimeCode(
|
||||
() => publisher.publish("state", "x".repeat(MODULE_REALTIME_MAX_PAYLOAD_BYTES)),
|
||||
"REALTIME_PAYLOAD_TOO_LARGE",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces the serialized UTF-8 boundary including JSON escaping", () => {
|
||||
const exactlyAtLimit = serializeModuleRealtimePayload(
|
||||
"x".repeat(MODULE_REALTIME_MAX_PAYLOAD_BYTES - 2),
|
||||
);
|
||||
expect(exactlyAtLimit).toMatchObject({ success: true });
|
||||
if (exactlyAtLimit.success) {
|
||||
expect(new TextEncoder().encode(exactlyAtLimit.serializedPayload)).toHaveLength(
|
||||
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
serializeModuleRealtimePayload("x".repeat(MODULE_REALTIME_MAX_PAYLOAD_BYTES - 1)),
|
||||
).toEqual({ success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" });
|
||||
expect(serializeModuleRealtimePayload("\0".repeat(11_000))).toEqual({
|
||||
success: false,
|
||||
code: "REALTIME_PAYLOAD_TOO_LARGE",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects enormous strings and containers before attempting an unbounded stringify", () => {
|
||||
const hugeString = "x".repeat(1_000_000);
|
||||
const hugeArray = new Array<unknown>(1_000_000).fill(0);
|
||||
const stringify = vi.spyOn(JSON, "stringify");
|
||||
const stringResult = serializeModuleRealtimePayload(hugeString);
|
||||
const arrayResult = serializeModuleRealtimePayload(hugeArray);
|
||||
const calls = [...stringify.mock.calls];
|
||||
stringify.mockRestore();
|
||||
|
||||
expect(stringResult).toEqual({ success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" });
|
||||
expect(arrayResult).toEqual({ success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches canonical JSON escaping without converting invalid values", () => {
|
||||
const payload = {
|
||||
controls: '\b\t\n\f\r\0"\\',
|
||||
loneSurrogate: "\ud800",
|
||||
unicode: "Welcome 👋 — καλημέρα",
|
||||
};
|
||||
expect(serializeModuleRealtimePayload(payload)).toEqual({
|
||||
success: true,
|
||||
serializedPayload: JSON.stringify(payload),
|
||||
});
|
||||
expect(serializeModuleRealtimePayload({ invalid: undefined })).toEqual({
|
||||
success: false,
|
||||
code: "REALTIME_PAYLOAD_INVALID",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates duplicate, undeclared-permission, and finite channel registration", () => {
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const duplicateRegistry = new StagedModuleRealtimeRegistry();
|
||||
const duplicate = duplicateRegistry.createRegistrationStage(moduleId, new Set([permission]));
|
||||
duplicate.realtime.channel({ channel: "state", permission });
|
||||
expect(() => duplicate.realtime.channel({ channel: "state", permission })).toThrowError(
|
||||
expect.objectContaining({ code: "MODULE_REALTIME_CHANNEL_DUPLICATE" }),
|
||||
);
|
||||
|
||||
const foreign = new StagedModuleRealtimeRegistry().createRegistrationStage(moduleId, new Set());
|
||||
expect(() => foreign.realtime.channel({ channel: "state", permission })).toThrowError(
|
||||
expect.objectContaining({ code: "MODULE_REALTIME_PERMISSION_UNDECLARED" }),
|
||||
);
|
||||
|
||||
const limited = new StagedModuleRealtimeRegistry().createRegistrationStage(
|
||||
moduleId,
|
||||
new Set([permission]),
|
||||
);
|
||||
for (let index = 0; index < MODULE_REALTIME_MAX_CHANNELS_PER_MODULE; index += 1) {
|
||||
limited.realtime.channel({ channel: `channel-${index}`, permission });
|
||||
}
|
||||
expect(() => limited.realtime.channel({ channel: "one-too-many", permission })).toThrowError(
|
||||
expect.objectContaining({ code: "MODULE_REALTIME_CHANNEL_LIMIT" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps modules without realtime declarations fully compatible", () => {
|
||||
const staged = registeredRegistry({ calendar: [] });
|
||||
const moduleId = moduleIdSchema.parse("calendar");
|
||||
expect(staged.publish().channels).toEqual([]);
|
||||
expectRealtimeCode(
|
||||
() => staged.publisherFor(moduleId).publish("state", null),
|
||||
"REALTIME_CHANNEL_UNDECLARED",
|
||||
);
|
||||
expect(staged.missingSnapshots(moduleId)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports payload validation without reflecting contents", () => {
|
||||
const secret = "/machine/private/secret?token=do-not-leak";
|
||||
const result = serializeModuleRealtimePayload({ secret, invalid: undefined });
|
||||
expect(result).toEqual({ success: false, code: "REALTIME_PAYLOAD_INVALID" });
|
||||
expect(JSON.stringify(result)).not.toContain(secret);
|
||||
expect(() => {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_INVALID");
|
||||
}).toThrow("Module realtime channel registration failed.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user