feat: add native module realtime transport
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ModuleDatabase,
|
||||
ModuleRealtimePublisher,
|
||||
ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const globalSymbols: symbol[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
const globals = globalThis as unknown as Record<PropertyKey, unknown>;
|
||||
for (const symbol of globalSymbols.splice(0)) {
|
||||
delete globals[symbol];
|
||||
}
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
function fakeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||
}
|
||||
|
||||
function manifest(
|
||||
id: string,
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
permissions,
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
};
|
||||
}
|
||||
|
||||
async function createRuntimeFixture(
|
||||
id: string,
|
||||
serverSource: string,
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Promise<string> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-realtime-runtime-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
name: `@test/module-${id}`,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify(manifest(id, permissions)),
|
||||
),
|
||||
writeFile(path.join(moduleDirectory, "dist/server/index.js"), serverSource),
|
||||
]);
|
||||
return modulesDirectory;
|
||||
}
|
||||
|
||||
function runtimeOptions(modulesDirectory: string) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
services: fakeServices(),
|
||||
};
|
||||
}
|
||||
|
||||
function recorder(): {
|
||||
readonly expression: string;
|
||||
readonly value: { events: string[]; publishers: ModuleRealtimePublisher[] };
|
||||
} {
|
||||
const symbol = Symbol.for(`ekklesia.realtime.${Date.now()}.${Math.random()}`);
|
||||
const value = { events: [] as string[], publishers: [] as ModuleRealtimePublisher[] };
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = value;
|
||||
globalSymbols.push(symbol);
|
||||
return { expression: `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`, value };
|
||||
}
|
||||
|
||||
describe("module runtime realtime lifecycle", () => {
|
||||
it("stages declarations, requires startup state, delivers live state, and closes before stop", async () => {
|
||||
const recorded = recorder();
|
||||
const objectUrl = "https://delivery.invalid/slides/welcome.png?signature=unchanged";
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"announcements",
|
||||
`
|
||||
export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
${recorded.expression}.events.push("register");
|
||||
},
|
||||
start({ realtime }) {
|
||||
${recorded.expression}.publishers.push(realtime);
|
||||
realtime.publish("state", {
|
||||
currentSlide: 0,
|
||||
previousSlide: -1,
|
||||
slides: [${JSON.stringify(objectUrl)}],
|
||||
slideSeconds: 7,
|
||||
mode: "on"
|
||||
});
|
||||
${recorded.expression}.events.push("start");
|
||||
},
|
||||
stop({ realtime }) {
|
||||
try { realtime.publish("state", { mode: "off" }); }
|
||||
catch (error) { ${recorded.expression}.events.push("stop:" + error.code); }
|
||||
}
|
||||
};
|
||||
`,
|
||||
[
|
||||
{
|
||||
id: "announcements.slideshow.view",
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
expect(recorded.value.events).toEqual(["register", "start"]);
|
||||
expect(runtime.realtime.channels).toEqual([
|
||||
{
|
||||
moduleId: "announcements",
|
||||
channel: "state",
|
||||
permission: "announcements.slideshow.view",
|
||||
},
|
||||
]);
|
||||
const subscription = runtime.realtime.subscribe("announcements", "state");
|
||||
expect(subscription.snapshot.sequence).toBe(1);
|
||||
expect(subscription.snapshot.serializedPayload).toContain(objectUrl);
|
||||
|
||||
const next = subscription.read();
|
||||
recorded.value.publishers[0]?.publish("state", {
|
||||
currentSlide: 1,
|
||||
previousSlide: 0,
|
||||
slides: [objectUrl],
|
||||
slideSeconds: 7,
|
||||
mode: "on",
|
||||
});
|
||||
await expect(next).resolves.toMatchObject({ sequence: 2 });
|
||||
const pendingClose = subscription.read();
|
||||
await runtime.stop();
|
||||
await expect(pendingClose).resolves.toBeUndefined();
|
||||
expect(recorded.value.events).toEqual(["register", "start", "stop:REALTIME_PUBLISHER_STOPPED"]);
|
||||
expect(() => recorded.value.publishers[0]?.publish("state", null)).toThrowError(
|
||||
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails atomically and rolls back when a declared channel has no initial snapshot", async () => {
|
||||
const recorded = recorder();
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"announcements",
|
||||
`
|
||||
export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
},
|
||||
start() { ${recorded.expression}.events.push("start"); },
|
||||
stop() { ${recorded.expression}.events.push("rollback"); }
|
||||
};
|
||||
`,
|
||||
[
|
||||
{
|
||||
id: "announcements.slideshow.view",
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await expect(startModuleRuntime(runtimeOptions(modulesDirectory))).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_REALTIME_SNAPSHOT_MISSING" })],
|
||||
});
|
||||
expect(recorded.value.events).toEqual(["start", "rollback"]);
|
||||
});
|
||||
|
||||
it("reports invalid owned-permission registration with a deterministic sanitized issue", async () => {
|
||||
const secret = "/machine/private/permission-token";
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"announcements",
|
||||
`
|
||||
export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.not-owned" });
|
||||
}
|
||||
};
|
||||
// ${secret}
|
||||
`,
|
||||
);
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(ModuleRuntimeError);
|
||||
expect(failure).toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_REALTIME_PERMISSION_UNDECLARED" })],
|
||||
});
|
||||
expect(JSON.stringify(failure)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("starts and stops modules that declare no realtime channels unchanged", async () => {
|
||||
const recorded = recorder();
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"calendar",
|
||||
`
|
||||
export default {
|
||||
id: "calendar",
|
||||
version: "0.1.0",
|
||||
start() { ${recorded.expression}.events.push("start"); },
|
||||
stop() { ${recorded.expression}.events.push("stop"); }
|
||||
};
|
||||
`,
|
||||
);
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
expect(runtime.realtime.channels).toEqual([]);
|
||||
await runtime.stop();
|
||||
expect(recorded.value.events).toEqual(["start", "stop"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user