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();
|
||||
|
||||
Reference in New Issue
Block a user