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
+1
View File
@@ -7,6 +7,7 @@ export * from "./module-http.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
export * from "./module-migration.js";
export * from "./module-realtime.js";
export * from "./module-runtime.js";
export * from "./module-schema.js";
export * from "./module-storage.js";
+9 -4
View File
@@ -1,9 +1,10 @@
import { z } from "zod";
import type { EkklesiaPrincipal } from "./identity.js";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
import type { EkklesiaPrincipal } from "./identity.js";
import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleStorage } from "./module-storage.js";
import type { ModuleRuntimeServices } from "./module-database.js";
import type { ModuleRealtimePublisher } from "./module-realtime.js";
import type { ModuleStorage } from "./module-storage.js";
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
// it would create a source dependency cycle with that module, which also imports from this one for
@@ -188,8 +189,10 @@ export function moduleRoutesOverlap(
/**
* What a module's route handler receives about the caller, the match, and the platform services
* available to it — never an authorization-store, grant-management, storage, jobs, configuration,
* secrets, the Hono `Context`, or a raw Better Auth session. `principal` is already authorized by
* available to it — never an authorization store, grant-management state, jobs, the Hono `Context`,
* or a raw Better Auth session. Its storage, configuration, and realtime publisher are all scoped
* to the owning module; secret values appear only when explicitly declared and supplied through
* that module's configuration contract. `principal` is already authorized by
* the time a handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route
* before ever invoking its handler; this contracts-layer module never invokes a handler itself.
* `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop`
@@ -226,6 +229,8 @@ export interface ModuleRouteHandlerContext {
* into a `Response` without the browser ever learning that object storage exists. */
readonly storage: ModuleStorage;
readonly services: ModuleRuntimeServices;
/** The identical module-scoped realtime publisher used by lifecycle hooks. */
readonly realtime: ModuleRealtimePublisher;
/** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the
* same object, not a re-read — so a handler and a lifecycle hook can never disagree about a value
* (ADR 0029). Never serialized into a response by Core; a module that chooses to expose part of
+147
View File
@@ -0,0 +1,147 @@
import { z } from "zod";
import { type PermissionId, permissionIdSchema } from "./identifiers.js";
/** A module-local channel name. Core supplies the owning module namespace; modules cannot publish
* into another module's channel. The deliberately flat grammar has no hierarchy or wildcards. */
export const MODULE_REALTIME_CHANNEL_MAX_LENGTH = 48;
export const moduleRealtimeChannelSchema = z
.string()
.min(1)
.max(MODULE_REALTIME_CHANNEL_MAX_LENGTH)
.regex(
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u,
"Must be a lowercase kebab-case module realtime channel.",
)
.brand<"ModuleRealtimeChannel">();
export type ModuleRealtimeChannel = z.infer<typeof moduleRealtimeChannelSchema>;
export function parseModuleRealtimeChannel(input: unknown): ModuleRealtimeChannel {
return moduleRealtimeChannelSchema.parse(input);
}
export function safeParseModuleRealtimeChannel(
input: unknown,
): z.ZodSafeParseResult<ModuleRealtimeChannel> {
return moduleRealtimeChannelSchema.safeParse(input);
}
/** Registration and fan-out ceilings are platform policy, identical for every module. */
export const MODULE_REALTIME_MAX_CHANNELS_PER_MODULE = 16;
export const MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL = 100;
export const MODULE_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024;
export type ModuleRealtimeJsonPrimitive = boolean | number | string | null;
export type ModuleRealtimeJsonValue =
| ModuleRealtimeJsonPrimitive
| readonly ModuleRealtimeJsonValue[]
| { readonly [key: string]: ModuleRealtimeJsonValue };
export interface ModuleRealtimeChannelDefinitionInput {
readonly channel: string;
readonly permission: string;
}
export interface ModuleRealtimeChannelDefinition {
readonly channel: ModuleRealtimeChannel;
readonly permission: PermissionId;
}
export interface ModuleRealtimeChannelRegistrar {
channel(definition: ModuleRealtimeChannelDefinitionInput): void;
}
/** The runtime publisher is already scoped to its module. No module id, origin, transport, token,
* room, or topic can be supplied by module code. */
export interface ModuleRealtimePublisher {
/** `unknown` is deliberate: an ordinary named interface is valid input without having to add a
* meaningless string index signature. TypeScript cannot prove recursive JSON compatibility for
* every structural interface, so the host validates the exact runtime value authoritatively. */
publish(channel: string, payload: unknown): void;
}
export type ModuleRealtimeChannelDefinitionIssueCode =
| "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID"
| "MODULE_REALTIME_CHANNEL_INVALID"
| "MODULE_REALTIME_PERMISSION_INVALID";
export type ModuleRealtimeChannelDefinitionParseResult =
| { readonly success: true; readonly data: ModuleRealtimeChannelDefinition }
| { readonly success: false; readonly code: ModuleRealtimeChannelDefinitionIssueCode };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Validates only the definition's transport-neutral shape. Core separately proves that the
* permission belongs to the registering module. Rejected input is never reflected in the result. */
export function safeParseModuleRealtimeChannelDefinition(
value: unknown,
): ModuleRealtimeChannelDefinitionParseResult {
if (!isRecord(value)) {
return { success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" };
}
const keys = Object.keys(value);
if (keys.length !== 2 || !keys.includes("channel") || !keys.includes("permission")) {
return { success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" };
}
const channel = moduleRealtimeChannelSchema.safeParse(value.channel);
if (!channel.success) {
return { success: false, code: "MODULE_REALTIME_CHANNEL_INVALID" };
}
const permission = permissionIdSchema.safeParse(value.permission);
if (!permission.success) {
return { success: false, code: "MODULE_REALTIME_PERMISSION_INVALID" };
}
return {
success: true,
data: Object.freeze({ channel: channel.data, permission: permission.data }),
};
}
export function parseModuleRealtimeChannelDefinition(
value: unknown,
): ModuleRealtimeChannelDefinition {
const result = safeParseModuleRealtimeChannelDefinition(value);
if (!result.success) {
throw new TypeError("Invalid Ekklesia module realtime channel definition.");
}
return result.data;
}
export const MODULE_REALTIME_ERROR_CODES = [
"REALTIME_CHANNEL_INVALID",
"REALTIME_CHANNEL_UNDECLARED",
"REALTIME_PAYLOAD_INVALID",
"REALTIME_PAYLOAD_TOO_LARGE",
"REALTIME_PUBLISHER_STOPPED",
"REALTIME_SNAPSHOT_UNAVAILABLE",
"REALTIME_SUBSCRIBER_LIMIT",
"REALTIME_UNAVAILABLE",
] as const;
export type ModuleRealtimeErrorCode = (typeof MODULE_REALTIME_ERROR_CODES)[number];
const realtimeErrorMessages: Readonly<Record<ModuleRealtimeErrorCode, string>> = Object.freeze({
REALTIME_CHANNEL_INVALID: "The realtime channel is invalid.",
REALTIME_CHANNEL_UNDECLARED: "This module did not declare the realtime channel.",
REALTIME_PAYLOAD_INVALID: "The realtime payload is not JSON-compatible.",
REALTIME_PAYLOAD_TOO_LARGE: "The realtime payload exceeds the platform limit.",
REALTIME_PUBLISHER_STOPPED: "The realtime publisher is no longer running.",
REALTIME_SNAPSHOT_UNAVAILABLE: "The realtime channel has no current snapshot.",
REALTIME_SUBSCRIBER_LIMIT: "The realtime channel subscriber limit has been reached.",
REALTIME_UNAVAILABLE: "Realtime delivery is not available.",
});
/** A fixed-code, fixed-message failure with no cause, payload, URL, path, or transport detail. */
export class ModuleRealtimeError extends Error {
readonly code: ModuleRealtimeErrorCode;
constructor(code: ModuleRealtimeErrorCode) {
super(realtimeErrorMessages[code]);
this.name = "ModuleRealtimeError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
+8 -2
View File
@@ -1,9 +1,10 @@
import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleStorage } from "./module-storage.js";
import type { CapabilityId, ModuleId } from "./identifiers.js";
import { moduleIdSchema } from "./identifiers.js";
import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleRuntimeServices } from "./module-database.js";
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js";
import type { ModuleStorage } from "./module-storage.js";
import { semanticVersionSchema } from "./versions.js";
export type Awaitable<Value> = Value | PromiseLike<Value>;
@@ -29,6 +30,9 @@ export interface ModuleRegistrationContext {
* be declared while Core is still building the immutable startup snapshot, never added or removed
* afterward from `start`, `stop`, or any other post-registration code path. */
readonly http: ModuleHttpRouteRegistrar;
/** Declarative, registration-only channel publication. Live publishing is available only from
* runtime and request-handler contexts. */
readonly realtime: ModuleRealtimeChannelRegistrar;
readonly moduleId: ModuleId;
/**
* This module's own validated configuration, available here as well as at runtime (ADR 0029).
@@ -61,6 +65,8 @@ export interface ModuleRuntimeContext {
* manifest declared, and carries no endpoint, credential, or physical bucket name.
*/
readonly storage: ModuleStorage;
/** This module's own publisher. Channel names are always resolved inside this module. */
readonly realtime: ModuleRealtimePublisher;
/**
* This module's own validated configuration, frozen for the lifetime of the process.
*