feat: add native module realtime transport
CI / verify (pull_request) Successful in 37s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m7s

This commit is contained in:
ekklesia
2026-08-23 18:47:18 -05:00
parent fcc531a39c
commit fc6c8045c4
53 changed files with 3959 additions and 108 deletions
+90 -7
View File
@@ -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();