148 lines
5.8 KiB
TypeScript
148 lines
5.8 KiB
TypeScript
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);
|
|
}
|
|
}
|