2025-06-30 18:58:24 +02:00
|
|
|
/*
|
|
|
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
*/
|
2025-09-20 20:11:58 +02:00
|
|
|
import { readEvents, writeEvents, readUsers, type ServerSession } from "~/server/database";
|
|
|
|
import type { ApiAccount, ApiDisconnected, ApiEvent, ApiEventStreamMessage, ApiUserType } from "~/shared/types/api";
|
2025-07-08 15:43:14 +02:00
|
|
|
import { serverSessionToApi } from "./utils/session";
|
|
|
|
import { H3Event } from "h3";
|
2025-02-27 18:39:04 +01:00
|
|
|
|
2025-09-20 20:11:58 +02:00
|
|
|
const keepaliveTimeoutMs = 45e3;
|
|
|
|
const eventUpdateTimeMs = 1e3;
|
2025-02-27 18:39:04 +01:00
|
|
|
|
2025-09-20 20:11:58 +02:00
|
|
|
class EventStream {
|
|
|
|
write!: (data: string) => void;
|
|
|
|
close!: (reason?: string) => void;
|
2025-03-10 16:26:52 +01:00
|
|
|
|
2025-09-20 20:11:58 +02:00
|
|
|
constructor(
|
|
|
|
public sessionId: number | undefined,
|
|
|
|
public accountId: number | undefined,
|
|
|
|
public userType: ApiUserType | undefined,
|
|
|
|
public rotatesAtMs: number ,
|
|
|
|
public lastKeepAliveMs: number,
|
|
|
|
public lastEventId: number,
|
|
|
|
) {
|
|
|
|
}
|
|
|
|
}
|
2025-02-27 18:39:04 +01:00
|
|
|
|
2025-09-20 20:11:58 +02:00
|
|
|
export async function createEventStream(
|
2025-07-08 15:43:14 +02:00
|
|
|
event: H3Event,
|
2025-09-20 20:11:58 +02:00
|
|
|
source: string,
|
|
|
|
lastEventId: number,
|
2025-07-08 15:43:14 +02:00
|
|
|
session?: ServerSession,
|
|
|
|
) {
|
|
|
|
const runtimeConfig = useRuntimeConfig(event);
|
2025-09-20 20:11:58 +02:00
|
|
|
const now = Date.now();
|
2025-09-20 23:04:16 +02:00
|
|
|
const events = (readEvents()).filter(e => e.id > lastEventId);
|
|
|
|
const users = readUsers();
|
2025-09-20 20:11:58 +02:00
|
|
|
const apiSession = session ? await serverSessionToApi(event, session) : undefined;
|
|
|
|
let userType: ApiAccount["type"] | undefined;
|
|
|
|
if (session?.accountId !== undefined) {
|
|
|
|
userType = users.find(a => !a.deleted && a.id === session.accountId)?.type
|
|
|
|
}
|
|
|
|
const stream = new EventStream(
|
|
|
|
session?.id,
|
|
|
|
session?.accountId,
|
|
|
|
userType,
|
|
|
|
session?.rotatesAtMs ?? now + runtimeConfig.sessionRotatesTimeout * 1000,
|
|
|
|
now,
|
|
|
|
events[events.length - 1]?.id ?? lastEventId,
|
|
|
|
);
|
|
|
|
|
|
|
|
const readableStream = new ReadableStream<Uint8Array>({
|
|
|
|
start(controller) {
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
stream.write = (data: string) => {
|
|
|
|
controller.enqueue(encoder.encode(data));
|
|
|
|
}
|
|
|
|
stream.close = (reason?: string) => {
|
|
|
|
const data: ApiDisconnected = {
|
|
|
|
type: "disconnect",
|
|
|
|
reason,
|
|
|
|
};
|
|
|
|
stream.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
|
|
controller.close();
|
|
|
|
deleteStream(stream);
|
|
|
|
},
|
|
|
|
console.log(`Starting event stream for ${source}`)
|
|
|
|
addStream(stream);
|
|
|
|
},
|
|
|
|
cancel(reason) {
|
|
|
|
console.log(`Cancelled event stream for ${source}:`, reason);
|
|
|
|
deleteStream(stream);
|
|
|
|
}
|
2025-07-08 15:43:14 +02:00
|
|
|
});
|
|
|
|
// Produce a response immediately to avoid the reply waiting for content.
|
2025-09-20 20:11:58 +02:00
|
|
|
const update: ApiEventStreamMessage = {
|
2025-07-08 15:43:14 +02:00
|
|
|
type: "connected",
|
2025-09-20 20:11:58 +02:00
|
|
|
session: apiSession,
|
2025-07-08 15:43:14 +02:00
|
|
|
};
|
2025-09-20 20:11:58 +02:00
|
|
|
stream.write(`data: ${JSON.stringify(update)}\n\n`);
|
|
|
|
|
|
|
|
/*
|
|
|
|
Send events since the provided lastEventId
|
|
|
|
|
|
|
|
Warning: This have to happen either before addStream(stream) is
|
|
|
|
called, or as done here synchronously after it. Otherwise there's a
|
|
|
|
possibility of events being delivered out of order, which will break
|
|
|
|
the assumption made by the schedule updating logic.
|
|
|
|
*/
|
|
|
|
if (events.length)
|
|
|
|
console.log(`Sending ${events.length} event(s) to ${source}`);
|
|
|
|
for (const event of events) {
|
|
|
|
if (!sendEventToStream(stream, event)) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return readableStream;
|
2025-02-27 18:39:04 +01:00
|
|
|
}
|
2025-09-20 20:11:58 +02:00
|
|
|
|
|
|
|
let updateInterval: ReturnType<typeof setInterval> | null = null
|
|
|
|
const streams = new Set<EventStream>();
|
|
|
|
function addStream(
|
|
|
|
stream: EventStream,
|
|
|
|
) {
|
|
|
|
if (streams.size === 0) {
|
|
|
|
console.log("Starting event updates")
|
|
|
|
updateInterval = setInterval(sendEventUpdates, eventUpdateTimeMs)
|
|
|
|
}
|
|
|
|
streams.add(stream);
|
|
|
|
}
|
|
|
|
function deleteStream(stream: EventStream) {
|
2025-02-27 18:39:04 +01:00
|
|
|
streams.delete(stream);
|
|
|
|
if (streams.size === 0) {
|
2025-09-20 20:11:58 +02:00
|
|
|
console.log("Ending event updates")
|
|
|
|
clearInterval(updateInterval!);
|
|
|
|
updateInterval = null;
|
2025-02-27 18:39:04 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-03-10 16:26:52 +01:00
|
|
|
export function cancelAccountStreams(accountId: number) {
|
2025-09-20 20:11:58 +02:00
|
|
|
for (const stream of streams.values()) {
|
|
|
|
if (stream.accountId === accountId) {
|
|
|
|
stream.close("cancelled");
|
2025-03-10 16:26:52 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export function cancelSessionStreams(sessionId: number) {
|
2025-09-20 20:11:58 +02:00
|
|
|
for (const stream of streams.values()) {
|
|
|
|
if (stream.sessionId === sessionId) {
|
|
|
|
stream.close("cancelled");
|
2025-03-10 16:26:52 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
const encodeEventCache = new WeakMap<ApiEvent, Map<ApiAccount["type"] | undefined, string>>();
|
2025-06-23 00:17:22 +02:00
|
|
|
function encodeEvent(event: ApiEvent, userType: ApiAccount["type"] | undefined) {
|
2025-06-11 21:05:17 +02:00
|
|
|
const cache = encodeEventCache.get(event);
|
2025-06-23 00:17:22 +02:00
|
|
|
const cacheEntry = cache?.get(userType);
|
2025-06-11 21:05:17 +02:00
|
|
|
if (cacheEntry) {
|
|
|
|
return cacheEntry;
|
|
|
|
}
|
|
|
|
|
|
|
|
let data: string;
|
|
|
|
if (event.type === "schedule-update") {
|
2025-06-23 00:17:22 +02:00
|
|
|
if (!canSeeCrew(userType)) {
|
2025-06-11 21:05:17 +02:00
|
|
|
event = {
|
2025-09-20 20:11:58 +02:00
|
|
|
id: event.id,
|
2025-06-11 21:05:17 +02:00
|
|
|
type: event.type,
|
|
|
|
updatedFrom: event.updatedFrom,
|
|
|
|
data: filterSchedule(event.data),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
data = JSON.stringify(event);
|
2025-06-23 00:17:22 +02:00
|
|
|
} else if (event.type === "user-update") {
|
|
|
|
if (
|
|
|
|
!canSeeCrew(userType)
|
|
|
|
|| !event.data.deleted && event.data.type === "anonymous" && !canSeeAnonymous(userType)
|
|
|
|
) {
|
|
|
|
event = {
|
2025-09-20 20:11:58 +02:00
|
|
|
id: event.id,
|
2025-06-23 00:17:22 +02:00
|
|
|
type: event.type,
|
|
|
|
data: {
|
|
|
|
id: event.data.id,
|
|
|
|
updatedAt: event.data.updatedAt,
|
|
|
|
deleted: true,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
data = JSON.stringify(event);
|
2025-06-11 21:05:17 +02:00
|
|
|
} else {
|
|
|
|
throw Error(`encodeEvent cannot encode ${event.type} event`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (cache) {
|
2025-06-23 00:17:22 +02:00
|
|
|
cache.set(userType, data);
|
2025-06-11 21:05:17 +02:00
|
|
|
} else {
|
2025-06-23 00:17:22 +02:00
|
|
|
encodeEventCache.set(event, new Map([[userType, data]]));
|
2025-06-11 21:05:17 +02:00
|
|
|
}
|
|
|
|
return data;
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function broadcastEvent(event: ApiEvent) {
|
2025-09-20 23:04:16 +02:00
|
|
|
const events = readEvents();
|
2025-09-20 20:11:58 +02:00
|
|
|
events.push(event);
|
2025-09-20 23:04:16 +02:00
|
|
|
writeEvents(events);
|
2025-09-20 20:11:58 +02:00
|
|
|
}
|
2025-07-08 15:43:14 +02:00
|
|
|
|
2025-09-20 20:11:58 +02:00
|
|
|
function sendEventToStream(stream: EventStream, event: ApiEvent) {
|
2025-07-08 15:43:14 +02:00
|
|
|
// Session expiry events cause the streams belonging to that session to be terminated
|
|
|
|
if (event.type === "session-expired") {
|
2025-09-20 20:11:58 +02:00
|
|
|
if (stream.sessionId === event.sessionId) {
|
|
|
|
stream.close("session expired");
|
2025-09-21 22:15:11 +02:00
|
|
|
return false;
|
2025-09-20 20:11:58 +02:00
|
|
|
}
|
2025-09-21 22:15:11 +02:00
|
|
|
return true;
|
2025-07-08 15:43:14 +02:00
|
|
|
}
|
|
|
|
|
2025-09-20 20:11:58 +02:00
|
|
|
// Account events are specially handled and only sent to the user they belong to.
|
|
|
|
if (event.type === "account-update") {
|
|
|
|
if (stream.accountId === event.data.id) {
|
|
|
|
stream.write(`id: ${event.id}\nevent: event\ndata: ${JSON.stringify(event)}\n\n`);
|
2025-03-10 16:26:52 +01:00
|
|
|
}
|
2025-09-20 20:11:58 +02:00
|
|
|
return true;
|
2025-02-27 18:39:04 +01:00
|
|
|
}
|
2025-09-20 20:11:58 +02:00
|
|
|
|
|
|
|
// All other events are encoded according to the user access level seeing it.
|
|
|
|
const data = encodeEvent(event, stream.userType)
|
|
|
|
stream.write(`id: ${event.id}\nevent: event\ndata: ${data}\n\n`);
|
|
|
|
return true;
|
2025-02-27 18:39:04 +01:00
|
|
|
}
|
|
|
|
|
2025-09-20 20:11:58 +02:00
|
|
|
async function sendEventUpdates() {
|
|
|
|
// Cancel streams that need to be rotated.
|
2025-07-08 15:43:14 +02:00
|
|
|
const now = Date.now();
|
2025-09-20 20:11:58 +02:00
|
|
|
for (const stream of streams.values()) {
|
|
|
|
if (stream.rotatesAtMs < now) {
|
|
|
|
stream.close("session rotation");
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Send events.
|
|
|
|
const skipEventId = Math.min(...[...streams.values()].map(s => s.lastEventId));
|
2025-09-20 23:04:16 +02:00
|
|
|
const events = (readEvents()).filter(e => e.id > skipEventId);
|
2025-09-20 20:11:58 +02:00
|
|
|
if (events.length)
|
|
|
|
console.log(`broadcasting ${events.length} event(s) to ${streams.size} client(s)`);
|
|
|
|
for (const stream of streams.values()) {
|
|
|
|
for (const event of events) {
|
|
|
|
if (event.id > stream.lastEventId) {
|
|
|
|
stream.lastEventId = event.id;
|
|
|
|
stream.lastKeepAliveMs = now;
|
|
|
|
|
|
|
|
if (!sendEventToStream(stream, event)) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Send Keepalives to streams with no activity.
|
|
|
|
for (const stream of streams.values()) {
|
|
|
|
if (stream.lastKeepAliveMs + keepaliveTimeoutMs < now) {
|
|
|
|
stream.write(": keepalive\n");
|
|
|
|
stream.lastKeepAliveMs = now;
|
2025-07-08 15:43:14 +02:00
|
|
|
}
|
2025-02-27 18:39:04 +01:00
|
|
|
}
|
|
|
|
}
|