feat: add native module realtime transport
This commit is contained in:
@@ -52,6 +52,26 @@ function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
async function withTransportDeadline<Value>(
|
||||
operation: Promise<Value>,
|
||||
onTimeout: () => void,
|
||||
): Promise<Value> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
onTimeout();
|
||||
reject(harnessError("An integration transport operation timed out."));
|
||||
}, 5_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
@@ -313,22 +333,25 @@ async function createGatedRouteModule(
|
||||
`export default {
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
register({ http }) {
|
||||
register({ http, realtime }) {
|
||||
realtime.channel({ channel: "state", permission: ${JSON.stringify(permissionId)} });
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/gated",
|
||||
permission: ${JSON.stringify(permissionId)},
|
||||
maxBodyBytes: 0,
|
||||
async handler() {
|
||||
async handler(_request, context) {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:handler-entered`)});
|
||||
${gate.expression}.enter();
|
||||
await ${gate.expression}.released;
|
||||
context.realtime.publish("state", { status: "finished" });
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:handler-published`)});
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:handler-finished`)});
|
||||
return Response.json({ gated: true });
|
||||
},
|
||||
});
|
||||
},
|
||||
start() {},
|
||||
start({ realtime }) { realtime.publish("state", { status: "waiting" }); },
|
||||
stop() { ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); },
|
||||
};`,
|
||||
),
|
||||
@@ -355,6 +378,9 @@ async function createFixtureModule(
|
||||
const declaresCalendarRead = (manifest.permissions as ReadonlyArray<{ id: string }>).some(
|
||||
(declaration) => declaration.id === "calendar.events.read",
|
||||
);
|
||||
const realtimePermission = (manifest.permissions as ReadonlyArray<{ id: string }>).find(
|
||||
(declaration) => declaration.id === `${id}.state.view`,
|
||||
)?.id;
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
@@ -372,8 +398,13 @@ async function createFixtureModule(
|
||||
`export default {
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
register({ http }) {
|
||||
register({ http, realtime }) {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
|
||||
${
|
||||
realtimePermission
|
||||
? `realtime.channel({ channel: "state", permission: ${JSON.stringify(realtimePermission)} });`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
declaresCalendarRead
|
||||
? `http.route({
|
||||
@@ -397,7 +428,12 @@ async function createFixtureModule(
|
||||
: ""
|
||||
}
|
||||
},
|
||||
start() {
|
||||
start({ realtime }) {
|
||||
${
|
||||
realtimePermission
|
||||
? `realtime.publish("state", { module: ${JSON.stringify(id)}, status: "running" });`
|
||||
: ""
|
||||
}
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
|
||||
},
|
||||
stop() {
|
||||
@@ -3037,6 +3073,7 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
||||
permissions: [
|
||||
{ baseline: "public", description: "Read calendar events.", id: "calendar.events.read" },
|
||||
{ baseline: "public", description: "View calendar state.", id: "calendar.state.view" },
|
||||
],
|
||||
});
|
||||
await createFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||
@@ -3046,6 +3083,11 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
description: "Read announcements.",
|
||||
id: "announcements.events.read",
|
||||
},
|
||||
{
|
||||
baseline: "public",
|
||||
description: "View announcement state.",
|
||||
id: "announcements.state.view",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -3056,6 +3098,8 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
const running = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
const realtimeAbort = new AbortController();
|
||||
let calendarReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
|
||||
@@ -3064,6 +3108,18 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
expect(recorder.events).toContain("calendar:start");
|
||||
const calendarRoute = await fetch(`${origin}/api/v1/modules/calendar/events/abc`);
|
||||
expect(calendarRoute.status).toBe(200);
|
||||
await withTransportDeadline(
|
||||
(async () => {
|
||||
const calendarRealtime = await fetch(`${origin}/api/v1/module-realtime/calendar/state`, {
|
||||
signal: realtimeAbort.signal,
|
||||
});
|
||||
expect(calendarRealtime.status).toBe(200);
|
||||
calendarReader = calendarRealtime.body?.getReader();
|
||||
const calendarSnapshot = await calendarReader?.read();
|
||||
expect(new TextDecoder().decode(calendarSnapshot?.value)).toContain("event: snapshot");
|
||||
})(),
|
||||
() => realtimeAbort.abort(),
|
||||
);
|
||||
|
||||
// The disabled module ran nothing at all. Not "registered but skipped", not "started and
|
||||
// gated per request" — its compiled entrypoint was never imported, so no hook could run.
|
||||
@@ -3076,12 +3132,25 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
const disabledRoute = await fetch(`${origin}/api/v1/modules/announcements/events/abc`);
|
||||
expect(disabledRoute.status).toBe(404);
|
||||
expect(await disabledRoute.json()).toEqual({ code: "NOT_FOUND" });
|
||||
const disabledRealtime = await fetch(`${origin}/api/v1/module-realtime/announcements/state`);
|
||||
expect(disabledRealtime.status).toBe(404);
|
||||
expect(await disabledRealtime.json()).toEqual({ code: "NOT_FOUND" });
|
||||
|
||||
// Shutdown never invokes a stop hook for a module that never started.
|
||||
await running.shutdown();
|
||||
const streamClosed = calendarReader?.read();
|
||||
const shutdown = running.shutdown();
|
||||
await expect(
|
||||
withTransportDeadline(
|
||||
streamClosed ?? Promise.reject(harnessError("The realtime stream was unavailable.")),
|
||||
() => realtimeAbort.abort(),
|
||||
),
|
||||
).resolves.toEqual({ done: true, value: undefined });
|
||||
await shutdown;
|
||||
expect(recorder.events).toContain("calendar:stop");
|
||||
expect(recorder.events).not.toContain("announcements:stop");
|
||||
} finally {
|
||||
realtimeAbort.abort();
|
||||
await calendarReader?.cancel().catch(() => undefined);
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
@@ -3093,6 +3162,13 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
await createFixtureModule(modulesDirectory, "storage", recorder);
|
||||
await createFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
||||
permissions: [
|
||||
{
|
||||
baseline: "public",
|
||||
description: "View announcement state.",
|
||||
id: "announcements.state.view",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_block");
|
||||
@@ -3151,6 +3227,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
const raw = JSON.stringify(body);
|
||||
expect(raw).not.toContain(modulesDirectory);
|
||||
expect(raw).not.toContain("dist/");
|
||||
const blockedRealtime = await fetch(`${origin}/api/v1/module-realtime/announcements/state`);
|
||||
expect(blockedRealtime.status).toBe(404);
|
||||
expect(await blockedRealtime.json()).toEqual({ code: "NOT_FOUND" });
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
@@ -3384,11 +3463,15 @@ describe.sequential("live API — bounded HTTP drain and shutdown", () => {
|
||||
expect(raw).toContain("HTTP/1.1 200");
|
||||
expect(raw).toContain('"gated":true');
|
||||
|
||||
// Explicit ordering: the handler finished and the module stopped, in that order.
|
||||
// Explicit ordering: the already-running command can publish its committed state while HTTP
|
||||
// drains, then the handler finishes and only afterward does its module stop.
|
||||
const published = recorder.events.indexOf("drainorder:handler-published");
|
||||
const finished = recorder.events.indexOf("drainorder:handler-finished");
|
||||
const stopped = recorder.events.indexOf("drainorder:stop");
|
||||
expect(published).toBeGreaterThanOrEqual(0);
|
||||
expect(finished).toBeGreaterThanOrEqual(0);
|
||||
expect(stopped).toBeGreaterThanOrEqual(0);
|
||||
expect(published).toBeLessThan(finished);
|
||||
expect(finished).toBeLessThan(stopped);
|
||||
} finally {
|
||||
gate.release();
|
||||
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
||||
import {
|
||||
createApiApp as createApiAppWithModules,
|
||||
type CreateApiAppAuthorizationOptions,
|
||||
type CreateApiAppOptions,
|
||||
} from "../src/app.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
@@ -58,6 +63,12 @@ function emptyModuleUiPublication(): ModuleUiPublicationRegistry {
|
||||
return createModuleUiPublicationRegistry([]);
|
||||
}
|
||||
|
||||
/** Most application-wiring cases exercise no modules. Keep that fact explicit in one test helper
|
||||
* while production composition still requires the realtime registry with no implicit fallback. */
|
||||
function createApiApp(options: Omit<CreateApiAppOptions, "moduleRealtime">) {
|
||||
return createApiAppWithModules({ ...options, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY });
|
||||
}
|
||||
|
||||
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
|
||||
* specific decision or store behavior override the relevant method explicitly. The registry
|
||||
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -139,6 +140,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
@@ -270,6 +272,7 @@ describe("Dashboard navigation — reading", () => {
|
||||
api: { baseUrl: "/api/v1/modules/livestream" },
|
||||
id: "livestream",
|
||||
permissions: [],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
|
||||
ui: { url: "/api/v1/module-ui/livestream/1.0.0" },
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type ModuleConfigurationRecord,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
@@ -205,6 +206,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store: lifecycleStore,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
|
||||
@@ -509,7 +509,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
||||
expect(signedUp.userId).toBeDefined();
|
||||
});
|
||||
|
||||
it("authorized read reflects real PostgreSQL state, exposes exactly moduleId/principal/params/services, and the handler's database is reference-equal to start's", async () => {
|
||||
it("authorized read reflects real PostgreSQL state, exposes the exact scoped handler context, and the handler's database is reference-equal to start's", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||
@@ -520,15 +520,16 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
||||
.reverse()
|
||||
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
||||
// The complete handler-context surface, asserted exactly so a new field is a deliberate change
|
||||
// here rather than an unnoticed one: `config` (ADR 0029) and `storage` (ADR 0030) are the two
|
||||
// scoped, per-module additions; everything else a module might want — a Hono Context, the
|
||||
// authorization store, raw PostgreSQL, the storage provider, an endpoint or credential — remains
|
||||
// unreachable.
|
||||
// here rather than an unnoticed one: `config` (ADR 0029), `storage` (ADR 0030), and `realtime`
|
||||
// (ADR 0032) are scoped, per-module additions; everything else a module might want — a Hono
|
||||
// Context, the authorization store, raw PostgreSQL, the storage provider, an endpoint or
|
||||
// credential — remains unreachable.
|
||||
expect(readHandlerEvent?.contextKeys).toEqual([
|
||||
"config",
|
||||
"moduleId",
|
||||
"params",
|
||||
"principal",
|
||||
"realtime",
|
||||
"services",
|
||||
"storage",
|
||||
]);
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { createModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
@@ -110,6 +113,7 @@ function buildModuleApp(options: {
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
||||
moduleRoutes: options.routes,
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
@@ -352,6 +356,7 @@ describe("module route handler adaptation", () => {
|
||||
"moduleId",
|
||||
"params",
|
||||
"principal",
|
||||
"realtime",
|
||||
"services",
|
||||
"storage",
|
||||
]);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
discoverModules,
|
||||
type ModuleLifecycleStore,
|
||||
ModuleLifecycleStoreError,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
@@ -181,6 +182,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRealtimePublisher,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
type AuthorizationEvaluator,
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
discoverModules,
|
||||
type ModuleRealtimeRegistry,
|
||||
type ModuleRealtimeSubscription,
|
||||
startDiscoveredModuleRuntime,
|
||||
type StartedModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { type ServerType, serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mountModuleRealtimeRoutes } from "../src/module-realtime-routes.js";
|
||||
|
||||
const allowedUserId = parseEkklesiaUserId("99999999-9999-4999-8999-999999999999");
|
||||
const deniedUserId = parseEkklesiaUserId("77777777-7777-4777-8777-777777777777");
|
||||
const stableDeliveryUrl =
|
||||
"https://objects.example.test/announcements/slides/welcome.png?X-Amz-Signature=stable";
|
||||
const TRANSPORT_DEADLINE_MS = 2_000;
|
||||
|
||||
async function withinTransportDeadline<Value>(
|
||||
operation: Promise<Value>,
|
||||
message: string,
|
||||
onTimeout: () => void = () => undefined,
|
||||
): Promise<Value> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
onTimeout();
|
||||
reject(new Error(message));
|
||||
}, TRANSPORT_DEADLINE_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTransportCondition(predicate: () => boolean, message: string): Promise<void> {
|
||||
let cancelled = false;
|
||||
await withinTransportDeadline(
|
||||
new Promise<void>((resolve) => {
|
||||
const inspect = (): void => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (predicate()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(inspect, 5);
|
||||
};
|
||||
inspect();
|
||||
}),
|
||||
message,
|
||||
() => {
|
||||
cancelled = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface FixtureControl {
|
||||
readonly events: string[];
|
||||
publisher?: ModuleRealtimePublisher;
|
||||
}
|
||||
|
||||
interface LiveEvent {
|
||||
readonly event: "snapshot" | "update";
|
||||
readonly payload: unknown;
|
||||
readonly sequence: number;
|
||||
}
|
||||
|
||||
class LiveSseConnection {
|
||||
readonly #abort: AbortController;
|
||||
readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
#buffer = "";
|
||||
|
||||
constructor(response: Response, abort: AbortController) {
|
||||
if (!response.body) {
|
||||
throw new Error("Realtime integration response had no body.");
|
||||
}
|
||||
this.#abort = abort;
|
||||
this.#reader = response.body.getReader();
|
||||
}
|
||||
|
||||
async readEvent(): Promise<LiveEvent> {
|
||||
for (;;) {
|
||||
const boundary = this.#buffer.indexOf("\n\n");
|
||||
if (boundary >= 0) {
|
||||
const block = this.#buffer.slice(0, boundary);
|
||||
this.#buffer = this.#buffer.slice(boundary + 2);
|
||||
if (block.startsWith(":")) {
|
||||
continue;
|
||||
}
|
||||
const event = block
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("event: "))
|
||||
?.slice("event: ".length);
|
||||
const data = block
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data: "))
|
||||
?.slice("data: ".length);
|
||||
if ((event !== "snapshot" && event !== "update") || data === undefined) {
|
||||
throw new Error("Realtime integration received an invalid event.");
|
||||
}
|
||||
const envelope = JSON.parse(data) as { payload?: unknown; sequence?: unknown };
|
||||
if (!Number.isSafeInteger(envelope.sequence) || (envelope.sequence as number) <= 0) {
|
||||
throw new Error("Realtime integration received an invalid sequence.");
|
||||
}
|
||||
return { event, payload: envelope.payload, sequence: envelope.sequence as number };
|
||||
}
|
||||
|
||||
const result = await this.#readWithinDeadline();
|
||||
if (result.done) {
|
||||
throw new Error("Realtime integration stream closed before the expected event.");
|
||||
}
|
||||
this.#buffer += new TextDecoder().decode(result.value, { stream: true });
|
||||
}
|
||||
}
|
||||
|
||||
async expectClosed(): Promise<void> {
|
||||
const result = await this.#readWithinDeadline();
|
||||
expect(result.done).toBe(true);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#abort.abort();
|
||||
await withinTransportDeadline(
|
||||
// Abort and cancel race by design; a rejection means one of them already released the
|
||||
// transport. A deadline still propagates and fails the test.
|
||||
this.#reader.cancel().catch(() => undefined),
|
||||
"Realtime integration client cancellation timed out.",
|
||||
);
|
||||
}
|
||||
|
||||
async #readWithinDeadline() {
|
||||
return withinTransportDeadline(
|
||||
this.#reader.read(),
|
||||
"Realtime integration read timed out.",
|
||||
() => this.#abort.abort(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createFixtureModule(control: FixtureControl): Promise<{
|
||||
readonly directory: string;
|
||||
readonly symbol: symbol;
|
||||
}> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-native-realtime-"));
|
||||
const moduleDirectory = path.join(directory, "announcements");
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
const symbol = Symbol.for(`ekklesia.native-realtime.${Date.now()}.${Math.random()}`);
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = control;
|
||||
const expression = `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`;
|
||||
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: "@test/module-announcements",
|
||||
type: "module",
|
||||
version: "0.1.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: "announcements",
|
||||
manifestVersion: 5,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: "announcements.slideshow.view",
|
||||
},
|
||||
],
|
||||
version: "0.1.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
${expression}.events.push("register");
|
||||
},
|
||||
start({ realtime }) {
|
||||
${expression}.publisher = realtime;
|
||||
realtime.publish("state", {
|
||||
mode: "on",
|
||||
currentSlide: 0,
|
||||
previousSlide: -1,
|
||||
slides: [${JSON.stringify(stableDeliveryUrl)}]
|
||||
});
|
||||
${expression}.events.push("start");
|
||||
},
|
||||
stop() { ${expression}.events.push("stop"); }
|
||||
};\n`,
|
||||
),
|
||||
]);
|
||||
return { directory, symbol };
|
||||
}
|
||||
|
||||
function services(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||
}
|
||||
|
||||
function identityRuntime(): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: async (headers) => {
|
||||
const cookie = headers.get("cookie");
|
||||
if (cookie === "ekklesia-session=allowed") {
|
||||
return createAuthenticatedPrincipal(allowedUserId);
|
||||
}
|
||||
if (cookie === "ekklesia-session=denied") {
|
||||
return createAuthenticatedPrincipal(deniedUserId);
|
||||
}
|
||||
return ANONYMOUS_PRINCIPAL;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authorization(): AuthorizationEvaluator {
|
||||
return createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async ({ userId }) => userId === allowedUserId },
|
||||
platformOwners: { isPlatformOwner: async () => false },
|
||||
});
|
||||
}
|
||||
|
||||
async function bindLiveServer(
|
||||
runtime: StartedModuleRuntime,
|
||||
registry: PermissionRegistry,
|
||||
): Promise<{
|
||||
readonly activeSubscriptions: () => number;
|
||||
readonly origin: string;
|
||||
readonly server: ServerType;
|
||||
}> {
|
||||
const app = new Hono();
|
||||
let activeSubscriptions = 0;
|
||||
const observedRealtime: ModuleRealtimeRegistry = {
|
||||
channels: runtime.realtime.channels,
|
||||
closeSubscriptions: () => runtime.realtime.closeSubscriptions(),
|
||||
publisherFor: (moduleId) => runtime.realtime.publisherFor(moduleId),
|
||||
subscribe: (moduleId, channel): ModuleRealtimeSubscription => {
|
||||
const subscription = runtime.realtime.subscribe(moduleId, channel);
|
||||
activeSubscriptions += 1;
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
close: (): void => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
activeSubscriptions -= 1;
|
||||
}
|
||||
subscription.close();
|
||||
},
|
||||
read: () => subscription.read(),
|
||||
snapshot: subscription.snapshot,
|
||||
});
|
||||
},
|
||||
};
|
||||
mountModuleRealtimeRoutes(app, {
|
||||
evaluator: authorization(),
|
||||
realtime: observedRealtime,
|
||||
registry,
|
||||
runtime: identityRuntime(),
|
||||
});
|
||||
app.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
|
||||
|
||||
let pendingServer: ServerType | undefined;
|
||||
const server = await withinTransportDeadline(
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
pendingServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, () =>
|
||||
resolve(pendingServer as ServerType),
|
||||
);
|
||||
pendingServer.once("error", reject);
|
||||
}),
|
||||
"Realtime integration server bind timed out.",
|
||||
() => pendingServer?.close(),
|
||||
);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Realtime integration server did not bind a TCP port.");
|
||||
}
|
||||
return {
|
||||
activeSubscriptions: () => activeSubscriptions,
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
server,
|
||||
};
|
||||
}
|
||||
|
||||
async function connect(
|
||||
origin: string,
|
||||
cookie: string,
|
||||
): Promise<{
|
||||
readonly connection: LiveSseConnection;
|
||||
readonly response: Response;
|
||||
}> {
|
||||
const abort = new AbortController();
|
||||
const response = await withinTransportDeadline(
|
||||
fetch(`${origin}/api/v1/module-realtime/announcements/state`, {
|
||||
headers: { cookie },
|
||||
signal: abort.signal,
|
||||
}),
|
||||
"Realtime integration connection timed out.",
|
||||
() => abort.abort(),
|
||||
);
|
||||
return { connection: new LiveSseConnection(response, abort), response };
|
||||
}
|
||||
|
||||
async function closeServer(server: ServerType): Promise<void> {
|
||||
await withinTransportDeadline(
|
||||
new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
"Realtime integration server close timed out.",
|
||||
() =>
|
||||
(
|
||||
server as ServerType & {
|
||||
closeAllConnections?: () => void;
|
||||
}
|
||||
).closeAllConnections?.(),
|
||||
);
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
): Promise<{ readonly body: unknown; readonly response: Response }> {
|
||||
const abort = new AbortController();
|
||||
const operation = (async (): Promise<{ readonly body: unknown; readonly response: Response }> => {
|
||||
const response = await fetch(url, { ...init, signal: abort.signal });
|
||||
const body = (await response.json()) as unknown;
|
||||
return { body, response };
|
||||
})();
|
||||
return withinTransportDeadline(operation, "Realtime integration finite request timed out.", () =>
|
||||
abort.abort(),
|
||||
);
|
||||
}
|
||||
|
||||
describe("native realtime over a live Node HTTP server", () => {
|
||||
it("loads a real module and proves auth, snapshot, fanout, update, reconnect, and shutdown", async () => {
|
||||
const control: FixtureControl = { events: [] };
|
||||
const fixture = await createFixtureModule(control);
|
||||
let runtime: StartedModuleRuntime | undefined;
|
||||
let server: ServerType | undefined;
|
||||
const connections: LiveSseConnection[] = [];
|
||||
const failures: unknown[] = [];
|
||||
try {
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: fixture.directory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
runtime = await startDiscoveredModuleRuntime(modules, services());
|
||||
expect(control.events).toEqual(["register", "start"]);
|
||||
const live = await bindLiveServer(runtime, composePlatformPermissionRegistry(modules));
|
||||
server = live.server;
|
||||
|
||||
const anonymous = await requestJson(
|
||||
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||
);
|
||||
expect(anonymous.response.status).toBe(403);
|
||||
expect(anonymous.body).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
const missingPermission = await requestJson(
|
||||
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||
{ headers: { cookie: "ekklesia-session=denied" } },
|
||||
);
|
||||
expect(missingPermission.response.status).toBe(403);
|
||||
|
||||
const first = await connect(live.origin, "ekklesia-session=allowed");
|
||||
const second = await connect(live.origin, "ekklesia-session=allowed");
|
||||
connections.push(first.connection, second.connection);
|
||||
expect(first.response.status).toBe(200);
|
||||
expect(first.response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||
|
||||
const expectedInitial = {
|
||||
currentSlide: 0,
|
||||
mode: "on",
|
||||
previousSlide: -1,
|
||||
slides: [stableDeliveryUrl],
|
||||
};
|
||||
expect(await first.connection.readEvent()).toEqual({
|
||||
event: "snapshot",
|
||||
payload: expectedInitial,
|
||||
sequence: 1,
|
||||
});
|
||||
expect(await second.connection.readEvent()).toEqual({
|
||||
event: "snapshot",
|
||||
payload: expectedInitial,
|
||||
sequence: 1,
|
||||
});
|
||||
expect(live.activeSubscriptions()).toBe(2);
|
||||
|
||||
control.publisher?.publish("state", {
|
||||
currentSlide: 1,
|
||||
mode: "on",
|
||||
previousSlide: 0,
|
||||
slides: [stableDeliveryUrl],
|
||||
});
|
||||
const expectedUpdate = {
|
||||
event: "update",
|
||||
payload: {
|
||||
currentSlide: 1,
|
||||
mode: "on",
|
||||
previousSlide: 0,
|
||||
slides: [stableDeliveryUrl],
|
||||
},
|
||||
sequence: 2,
|
||||
};
|
||||
expect(await first.connection.readEvent()).toEqual(expectedUpdate);
|
||||
expect(await second.connection.readEvent()).toEqual(expectedUpdate);
|
||||
|
||||
await first.connection.close();
|
||||
await waitForTransportCondition(
|
||||
() => live.activeSubscriptions() === 1,
|
||||
"Realtime integration subscriber cleanup timed out.",
|
||||
);
|
||||
control.publisher?.publish("state", {
|
||||
currentSlide: 2,
|
||||
mode: "off",
|
||||
previousSlide: 1,
|
||||
slides: [stableDeliveryUrl],
|
||||
});
|
||||
expect(await second.connection.readEvent()).toMatchObject({ event: "update", sequence: 3 });
|
||||
|
||||
const reconnected = await connect(live.origin, "ekklesia-session=allowed");
|
||||
connections.push(reconnected.connection);
|
||||
expect(await reconnected.connection.readEvent()).toEqual({
|
||||
event: "snapshot",
|
||||
payload: {
|
||||
currentSlide: 2,
|
||||
mode: "off",
|
||||
previousSlide: 1,
|
||||
slides: [stableDeliveryUrl],
|
||||
},
|
||||
sequence: 3,
|
||||
});
|
||||
expect(live.activeSubscriptions()).toBe(2);
|
||||
|
||||
const secondClosed = second.connection.expectClosed();
|
||||
const reconnectedClosed = reconnected.connection.expectClosed();
|
||||
runtime.realtime.closeSubscriptions();
|
||||
await Promise.all([secondClosed, reconnectedClosed]);
|
||||
await waitForTransportCondition(
|
||||
() => live.activeSubscriptions() === 0,
|
||||
"Realtime integration shutdown cleanup timed out.",
|
||||
);
|
||||
// Stream shutdown happens before ordinary HTTP drain in production. A publisher must remain
|
||||
// usable until that drain completes so an already-running command can publish its result.
|
||||
expect(() => control.publisher?.publish("state", null)).not.toThrow();
|
||||
|
||||
const afterShutdown = await requestJson(
|
||||
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||
{ headers: { cookie: "ekklesia-session=allowed" } },
|
||||
);
|
||||
expect(afterShutdown.response.status).toBe(503);
|
||||
expect(afterShutdown.body).toEqual({ code: "REALTIME_UNAVAILABLE" });
|
||||
await runtime.stop();
|
||||
expect(() => control.publisher?.publish("state", null)).toThrowError(
|
||||
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
|
||||
);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
} finally {
|
||||
try {
|
||||
runtime?.realtime.closeSubscriptions();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
const connectionCleanup = await Promise.allSettled(
|
||||
connections.map((connection) => connection.close()),
|
||||
);
|
||||
for (const result of connectionCleanup) {
|
||||
if (result.status === "rejected") {
|
||||
failures.push(result.reason);
|
||||
}
|
||||
}
|
||||
if (server) {
|
||||
try {
|
||||
await closeServer(server);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (runtime) {
|
||||
try {
|
||||
await runtime.stop();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
delete (globalThis as unknown as Record<PropertyKey, unknown>)[fixture.symbol];
|
||||
try {
|
||||
await rm(fixture.directory, { force: true, recursive: true });
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw failures[0];
|
||||
}
|
||||
expect(control.events).toEqual(["register", "start", "stop"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
ModuleRealtimeError,
|
||||
moduleIdSchema,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { createPermissionRegistry, type PermissionRegistry } from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { type ModuleRealtimeRegistry, StagedModuleRealtimeRegistry } from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MODULE_REALTIME_KEEPALIVE_MS,
|
||||
MODULE_REALTIME_MAX_CONNECTION_AGE_MS,
|
||||
ModuleRealtimeConfigurationError,
|
||||
moduleRealtimeBaseUrl,
|
||||
mountModuleRealtimeRoutes,
|
||||
} from "../src/module-realtime-routes.js";
|
||||
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const permission = parsePermissionId("announcements.slideshow.view");
|
||||
const userId = parseEkklesiaUserId("88888888-8888-4888-8888-888888888888");
|
||||
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||
|
||||
function permissionRegistry(): PermissionRegistry {
|
||||
return createPermissionRegistry([
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: permission,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function realtimeFixture(initialPayload: unknown = { mode: "on" }): {
|
||||
readonly publisher: ReturnType<StagedModuleRealtimeRegistry["publisherFor"]>;
|
||||
readonly realtime: ModuleRealtimeRegistry;
|
||||
} {
|
||||
const staged = new StagedModuleRealtimeRegistry();
|
||||
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||
registration.realtime.channel({ channel: "state", permission });
|
||||
registration.commit();
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
publisher.publish("state", initialPayload as never);
|
||||
return { publisher, realtime: staged.publish() };
|
||||
}
|
||||
|
||||
function appFor(
|
||||
realtime: ModuleRealtimeRegistry,
|
||||
options: {
|
||||
readonly allowed?: boolean;
|
||||
readonly keepaliveMs?: number;
|
||||
readonly maxConnectionAgeMs?: number;
|
||||
readonly signedIn?: boolean;
|
||||
} = {},
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
options.signedIn === false ? ANONYMOUS_PRINCIPAL : createAuthenticatedPrincipal(userId),
|
||||
),
|
||||
};
|
||||
mountModuleRealtimeRoutes(app, {
|
||||
evaluator: {
|
||||
authorize: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: options.signedIn !== false && options.allowed !== false }),
|
||||
},
|
||||
...(options.keepaliveMs === undefined ? {} : { keepaliveMs: options.keepaliveMs }),
|
||||
...(options.maxConnectionAgeMs === undefined
|
||||
? {}
|
||||
: { maxConnectionAgeMs: options.maxConnectionAgeMs }),
|
||||
realtime,
|
||||
registry: permissionRegistry(),
|
||||
runtime,
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Realtime test read timed out.")), 1_000);
|
||||
}),
|
||||
]);
|
||||
return result.done ? "" : new TextDecoder().decode(result.value);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
describe("module realtime HTTP transport", () => {
|
||||
it("builds one server-owned root and rejects an unenforceable channel snapshot", () => {
|
||||
expect(moduleRealtimeBaseUrl("announcements")).toBe("/api/v1/module-realtime/announcements");
|
||||
expect(MODULE_REALTIME_KEEPALIVE_MS).toBe(15_000);
|
||||
expect(MODULE_REALTIME_MAX_CONNECTION_AGE_MS).toBe(300_000);
|
||||
|
||||
const { realtime } = realtimeFixture();
|
||||
expect(() =>
|
||||
mountModuleRealtimeRoutes(new Hono(), {
|
||||
evaluator: { authorize: vi.fn() },
|
||||
realtime,
|
||||
registry: createPermissionRegistry([]),
|
||||
runtime: { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn() },
|
||||
}),
|
||||
).toThrow(ModuleRealtimeConfigurationError);
|
||||
});
|
||||
|
||||
it("denies anonymous and unauthorized callers before allocating a subscription", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
|
||||
const observed: ModuleRealtimeRegistry = {
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe,
|
||||
};
|
||||
|
||||
const anonymous = await appFor(observed, { signedIn: false }).request(endpoint);
|
||||
expect(anonymous.status).toBe(403);
|
||||
expect(await anonymous.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
|
||||
const unauthorized = await appFor(observed, { allowed: false }).request(endpoint);
|
||||
expect(unauthorized.status).toBe(403);
|
||||
expect(await unauthorized.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses Hono's implicit HEAD handling without allocating a stream subscription", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
|
||||
const observed: ModuleRealtimeRegistry = {
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe,
|
||||
};
|
||||
|
||||
const response = await appFor(observed).request(endpoint, { method: "HEAD" });
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens an authorized snapshot-first stream with proxy-safe headers and unchanged URLs", async () => {
|
||||
const deliveryUrl =
|
||||
"https://objects.example.test/slides/welcome.png?X-Amz-Signature=byte-identical";
|
||||
const { realtime } = realtimeFixture({
|
||||
currentSlide: 0,
|
||||
mode: "on",
|
||||
previousSlide: -1,
|
||||
slides: [deliveryUrl],
|
||||
});
|
||||
const response = await appFor(realtime).request(endpoint);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("x-accel-buffering")).toBe("no");
|
||||
expect(response.headers.get("vary")).toBe("Origin, Cookie");
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
expect(reader).toBeDefined();
|
||||
const snapshot = await readChunk(reader as ReadableStreamDefaultReader<Uint8Array>);
|
||||
expect(snapshot).toContain("event: snapshot\n");
|
||||
expect(snapshot).toContain('"sequence":1');
|
||||
expect(snapshot).toContain(deliveryUrl);
|
||||
expect(snapshot).not.toContain("\nid:");
|
||||
await reader?.cancel();
|
||||
});
|
||||
|
||||
it("streams ordered updates, emits transport-only keepalives, and closes on shutdown", async () => {
|
||||
const { publisher, realtime } = realtimeFixture({ currentSlide: 0 });
|
||||
const response = await appFor(realtime, { keepaliveMs: 5 }).request(endpoint);
|
||||
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
expect(await readChunk(reader)).toContain("event: snapshot");
|
||||
publisher.publish("state", { currentSlide: 1 });
|
||||
const update = await readChunk(reader);
|
||||
expect(update).toContain("event: update");
|
||||
expect(update).toContain('"sequence":2');
|
||||
expect(update).toContain('"currentSlide":1');
|
||||
expect(await readChunk(reader)).toBe(": keepalive\n\n");
|
||||
|
||||
const closed = reader.read();
|
||||
realtime.closeSubscriptions();
|
||||
await expect(closed).resolves.toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
it("closes a stream at the host-owned maximum age so reconnect reauthorizes", async () => {
|
||||
const { realtime } = realtimeFixture({ currentSlide: 0 });
|
||||
let activeSubscriptions = 0;
|
||||
const observed: ModuleRealtimeRegistry = {
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe: (owner, channel) => {
|
||||
const subscription = realtime.subscribe(owner, channel);
|
||||
activeSubscriptions += 1;
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
close: () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
activeSubscriptions -= 1;
|
||||
}
|
||||
subscription.close();
|
||||
},
|
||||
read: () => subscription.read(),
|
||||
snapshot: subscription.snapshot,
|
||||
});
|
||||
},
|
||||
};
|
||||
const response = await appFor(observed, {
|
||||
keepaliveMs: 1_000,
|
||||
maxConnectionAgeMs: 25,
|
||||
}).request(endpoint);
|
||||
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
expect(await readChunk(reader)).toContain("event: snapshot");
|
||||
expect(activeSubscriptions).toBe(1);
|
||||
expect(await readChunk(reader)).toBe("");
|
||||
expect(activeSubscriptions).toBe(0);
|
||||
const replacement = realtime.subscribe(moduleId, "state");
|
||||
replacement.close();
|
||||
});
|
||||
|
||||
it("does not expose unknown module/channel combinations", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const app = appFor(realtime);
|
||||
expect((await app.request("/api/v1/module-realtime/calendar/state")).status).toBe(404);
|
||||
expect((await app.request("/api/v1/module-realtime/announcements/other")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("maps subscriber-capacity and unexpected allocation failures to fixed non-secret responses", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const observed = (failure: unknown): ModuleRealtimeRegistry => ({
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe: () => {
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
|
||||
const atCapacity = await appFor(
|
||||
observed(new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT")),
|
||||
).request(endpoint);
|
||||
expect(atCapacity.status).toBe(503);
|
||||
expect(await atCapacity.json()).toEqual({ code: "REALTIME_SUBSCRIBER_LIMIT" });
|
||||
|
||||
const detail = "secret-token-and-machine-path";
|
||||
const unavailable = await appFor(observed(new Error(detail))).request(endpoint);
|
||||
expect(unavailable.status).toBe(503);
|
||||
const body = await unavailable.text();
|
||||
expect(body).toBe('{"code":"REALTIME_UNAVAILABLE"}');
|
||||
expect(body).not.toContain(detail);
|
||||
});
|
||||
});
|
||||
@@ -217,6 +217,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleRealtime: moduleRuntime.realtime,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
@@ -289,6 +290,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
uiUrl: "/api/v1/module-ui/example/0.1.0",
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||
import {
|
||||
@@ -173,6 +175,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: permissionRegistry, store },
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
@@ -219,13 +222,14 @@ describe("module UI inventory — what it lists", () => {
|
||||
api: { baseUrl: "/api/v1/modules/example" },
|
||||
id: "example",
|
||||
permissions: [],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
||||
ui: { url: "/api/v1/module-ui/example/0.1.0" },
|
||||
version: "0.1.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds both URLs with the server's own canonical helpers", async () => {
|
||||
it("builds all host URLs with the server's own canonical helpers", async () => {
|
||||
const { app, modules: discovered } = await install({
|
||||
fixtures: [{ id: "example", version: "2.4.1", withUi: true }],
|
||||
principal: signedIn(),
|
||||
@@ -240,6 +244,9 @@ describe("module UI inventory — what it lists", () => {
|
||||
url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }),
|
||||
});
|
||||
expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) });
|
||||
expect(modules[0]?.realtime).toEqual({
|
||||
baseUrl: moduleRealtimeBaseUrl(module.manifest.id),
|
||||
});
|
||||
});
|
||||
|
||||
it("omits a module that declares no UI entrypoint", async () => {
|
||||
@@ -310,6 +317,7 @@ describe("module UI inventory — what it lists", () => {
|
||||
"api",
|
||||
"id",
|
||||
"permissions",
|
||||
"realtime",
|
||||
"ui",
|
||||
"version",
|
||||
]);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
import type { Hono } from "hono";
|
||||
@@ -173,6 +174,7 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store },
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
@@ -708,6 +710,7 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
moduleId: "announcements",
|
||||
permissions: { has: () => false },
|
||||
principal: { kind: "anonymous" as const },
|
||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user