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