Refactor to persist and reliably deliver events

Store events that are to be broadcasted in the database, and fetch
events to serve in the /api/event stream to the client from the
database.  This ensures that events are not lost if the operation to
open the stream takes longer than usual, or the client was not connected
at the time the event was broadcast.

To ensure no events are lost in the transition from server generating
the page to the client hydrating and establishing a connection with the
event stream, the /api/last-event-id endpoint is first queried on the
server before any other entities is fetched from the database.  The
client then passes this id when establishing the event stream, and
receives all events greater than that id.
This commit is contained in:
Hornwitser 2025-09-20 20:11:58 +02:00
parent 0a0eb43d78
commit 753da6d3d4
18 changed files with 326 additions and 132 deletions

View file

@ -2,7 +2,7 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readSessions, readUsers, writeSessions, writeUsers } from "~/server/database";
import { nextEventId, readSessions, readUsers, writeSessions, writeUsers } from "~/server/database";
import { apiUserPatchSchema } from "~/shared/types/api";
import { z } from "zod/v4-mini";
import { broadcastEvent } from "~/server/streams";
@ -54,6 +54,7 @@ export default defineEventHandler(async (event) => {
user.updatedAt = new Date().toISOString();
await writeUsers(users);
broadcastEvent({
id: await nextEventId(),
type: "user-update",
data: serverUserToApi(user),
});
@ -66,6 +67,7 @@ export default defineEventHandler(async (event) => {
if (session.accountId === user.id) {
session.rotatesAtMs = nowMs;
broadcastEvent({
id: await nextEventId(),
type: "session-expired",
sessionId: session.id,
});

View file

@ -5,6 +5,7 @@
import {
readUsers, readSessions, readSubscriptions,
writeUsers, writeSessions, writeSubscriptions,
nextEventId,
} from "~/server/database";
import { broadcastEvent, cancelAccountStreams } from "~/server/streams";
@ -24,6 +25,7 @@ export default defineEventHandler(async (event) => {
) {
session.expiresAtMs = nowMs;
broadcastEvent({
id: await nextEventId(),
type: "session-expired",
sessionId: session.id,
});
@ -48,6 +50,7 @@ export default defineEventHandler(async (event) => {
account.updatedAt = now;
await writeUsers(users);
await broadcastEvent({
id: await nextEventId(),
type: "user-update",
data: {
id: account.id,

View file

@ -2,7 +2,7 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readUsers, writeUsers, nextUserId, type ServerUser, readAuthenticationMethods, nextAuthenticationMethodId, writeAuthenticationMethods } from "~/server/database";
import { readUsers, writeUsers, nextUserId, type ServerUser, readAuthenticationMethods, nextAuthenticationMethodId, writeAuthenticationMethods, nextEventId } from "~/server/database";
import { broadcastEvent } from "~/server/streams";
import type { ApiSession } from "~/shared/types/api";
@ -88,6 +88,7 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
users.push(user);
await writeUsers(users);
await broadcastEvent({
id: await nextEventId(),
type: "user-update",
data: user,
});

View file

@ -3,33 +3,46 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { pipeline } from "node:stream";
import { addStream, deleteStream } from "~/server/streams";
import { createEventStream } from "~/server/streams";
export default defineEventHandler(async (event) => {
const session = await getServerSession(event, false);
const encoder = new TextEncoder();
const source = event.headers.get("x-forwarded-for");
console.log(`starting event stream for ${source}`)
const stream = new TransformStream<string, Uint8Array>({
transform(chunk, controller) {
controller.enqueue(encoder.encode(chunk));
},
flush(controller) {
console.log(`finished event stream for ${source}`);
deleteStream(stream.writable);
},
// @ts-expect-error experimental API
cancel(reason) {
console.log(`cancelled event stream for ${source}`);
deleteStream(stream.writable);
let lastEventId: number | undefined;
const lastEventIdHeader = event.headers.get("Last-Event-ID");
const lastEventIdQuery = getQuery(event)["lastEventId"];
if (lastEventIdHeader) {
if (!/^[0-9]{1,15}$/.test(lastEventIdHeader)) {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "Malformed Last-Event-ID header",
});
}
});
addStream(event, stream.writable, session);
lastEventId = Number.parseInt(lastEventIdHeader, 10);
} else if (lastEventIdQuery) {
if (typeof lastEventIdQuery !== "string" || !/^[0-9]{1,15}$/.test(lastEventIdQuery)) {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "Malformed lastEventId",
});
}
lastEventId = Number.parseInt(lastEventIdQuery, 10);
} else {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "lastEventId is required",
});
}
const source = event.headers.get("x-forwarded-for") ?? "";
const stream = await createEventStream(event, source, lastEventId, session);
// Workaround to properly handle stream errors. See https://github.com/unjs/h3/issues/986
setHeader(event, "Access-Control-Allow-Origin", "*");
setHeader(event, "Content-Type", "text/event-stream");
pipeline(stream.readable as unknown as NodeJS.ReadableStream, event.node.res, (err) => { /* ignore */ });
pipeline(stream as unknown as NodeJS.ReadableStream, event.node.res, (err) => { /* ignore */ });
event._handled = true;
});

View file

@ -0,0 +1,11 @@
/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readEvents } from "../database";
export default defineEventHandler(async (event) => {
const events = await readEvents();
return events[events.length - 1]?. id ?? 0;
});

View file

@ -3,7 +3,7 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from "zod/v4-mini";
import { readSchedule, writeSchedule } from "~/server/database";
import { nextEventId, readSchedule, writeSchedule } from "~/server/database";
import { broadcastEvent } from "~/server/streams";
import { apiScheduleSchema } from "~/shared/types/api";
import { applyUpdatesToArray } from "~/shared/utils/update";
@ -87,6 +87,7 @@ export default defineEventHandler(async (event) => {
await writeSchedule(schedule);
await broadcastEvent({
id: await nextEventId(),
type: "schedule-update",
updatedFrom,
data: update,