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:
parent
0a0eb43d78
commit
753da6d3d4
18 changed files with 326 additions and 132 deletions
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
11
server/api/last-event-id.ts
Normal file
11
server/api/last-event-id.ts
Normal 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;
|
||||
});
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue