feat: add native module realtime transport
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MODULE_REALTIME_CHANNEL_MAX_LENGTH,
|
||||
MODULE_REALTIME_ERROR_CODES,
|
||||
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
|
||||
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
|
||||
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
|
||||
ModuleRealtimeError,
|
||||
type ModuleRealtimePublisher,
|
||||
parseModuleRealtimeChannel,
|
||||
parseModuleRealtimeChannelDefinition,
|
||||
safeParseModuleRealtimeChannel,
|
||||
safeParseModuleRealtimeChannelDefinition,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("module realtime contracts", () => {
|
||||
it("accepts an ordinary named state interface without an index signature or cast", () => {
|
||||
interface SlideshowState {
|
||||
readonly currentSlide: number;
|
||||
readonly mode: "on" | "off";
|
||||
readonly slides: readonly string[];
|
||||
}
|
||||
const state: SlideshowState = { currentSlide: 1, mode: "on", slides: ["/welcome.png"] };
|
||||
const received: unknown[] = [];
|
||||
const publisher: ModuleRealtimePublisher = {
|
||||
publish: (_channel, payload) => received.push(payload),
|
||||
};
|
||||
|
||||
publisher.publish("state", state);
|
||||
expect(received).toEqual([state]);
|
||||
});
|
||||
|
||||
it("accepts flat local kebab-case channels and rejects hierarchy and wildcards", () => {
|
||||
expect(parseModuleRealtimeChannel("state")).toBe("state");
|
||||
expect(parseModuleRealtimeChannel("slide-state")).toBe("slide-state");
|
||||
for (const invalid of ["", "State", "slide_state", "slide.state", "slide/state", "*", "a.*"]) {
|
||||
expect(safeParseModuleRealtimeChannel(invalid).success).toBe(false);
|
||||
}
|
||||
expect(
|
||||
safeParseModuleRealtimeChannel("a".repeat(MODULE_REALTIME_CHANNEL_MAX_LENGTH)).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
safeParseModuleRealtimeChannel("a".repeat(MODULE_REALTIME_CHANNEL_MAX_LENGTH + 1)).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("parses only the exact channel and permission definition shape", () => {
|
||||
expect(
|
||||
parseModuleRealtimeChannelDefinition({
|
||||
channel: "state",
|
||||
permission: "announcements.slideshow.view",
|
||||
}),
|
||||
).toEqual({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
expect(
|
||||
safeParseModuleRealtimeChannelDefinition({
|
||||
channel: "state",
|
||||
permission: "announcements.slideshow.view",
|
||||
topic: "legacy",
|
||||
}),
|
||||
).toEqual({ success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" });
|
||||
expect(safeParseModuleRealtimeChannelDefinition({ channel: "state", permission: "*" })).toEqual(
|
||||
{ success: false, code: "MODULE_REALTIME_PERMISSION_INVALID" },
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes finite platform limits as explicit contract constants", () => {
|
||||
expect(MODULE_REALTIME_MAX_CHANNELS_PER_MODULE).toBe(16);
|
||||
expect(MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL).toBe(100);
|
||||
expect(MODULE_REALTIME_MAX_PAYLOAD_BYTES).toBe(65_536);
|
||||
});
|
||||
|
||||
it("uses a closed error-code set with fixed sanitized messages and stacks", () => {
|
||||
expect(MODULE_REALTIME_ERROR_CODES).toContain("REALTIME_PAYLOAD_INVALID");
|
||||
const error = new ModuleRealtimeError("REALTIME_PAYLOAD_INVALID");
|
||||
expect(error).toMatchObject({
|
||||
code: "REALTIME_PAYLOAD_INVALID",
|
||||
message: "The realtime payload is not JSON-compatible.",
|
||||
name: "ModuleRealtimeError",
|
||||
});
|
||||
expect(error.stack).toBe("ModuleRealtimeError: The realtime payload is not JSON-compatible.");
|
||||
expect(Object.isFrozen(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user