v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* The connected WebSocket clients, and everything pushed to them: presence,
|
||||
* live updates to dumps, comments, playlists and chat, typing indicators, and
|
||||
* the keepalive that drops dead sockets.
|
||||
* Keeps the set of open sockets and sends them the server's messages.
|
||||
*
|
||||
* The WebSocket route (`routes/ws.ts`) registers each socket and handles what
|
||||
* it sends. The services and routes that change dumps, comments, playlists and
|
||||
* users call the `broadcast*` functions here after the change is stored. A
|
||||
* `broadcast*` function sends to everyone unless its doc names an audience, so
|
||||
* its caller must not pass it anything private. The module also pings every
|
||||
* socket and closes the ones that stop answering.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,45 +22,56 @@ import {
|
||||
type PlaylistMember,
|
||||
} from "../lib/playlist-members.ts";
|
||||
|
||||
/** A connected socket, and what the server knows of its user. */
|
||||
/**
|
||||
* Created by the WebSocket route for each socket it opens, and kept here until
|
||||
* the socket closes. The user fields are read at connect and left undefined for
|
||||
* an anonymous socket.
|
||||
*/
|
||||
export interface WsClient {
|
||||
/** The socket. */
|
||||
socket: WebSocket;
|
||||
/** The user's id; undefined for an anonymous socket. */
|
||||
userId?: string;
|
||||
/** The user's name. */
|
||||
username?: string;
|
||||
/** The user's role. */
|
||||
role?: Role;
|
||||
/** The user's avatar type, undefined without one. */
|
||||
avatarMime?: string;
|
||||
/** When the avatar last changed, to bust the client's cache. */
|
||||
avatarVersion?: number;
|
||||
/** Whether the client answered the last ping; undefined before the first. */
|
||||
pongReceived?: boolean;
|
||||
/** Whether this client currently has the chatbox open (is reading chat). */
|
||||
chatOpen?: boolean;
|
||||
/**
|
||||
* Timestamp (ms) of the client's last "typing" signal; undefined when not
|
||||
* composing. Entries older than {@link TYPING_TTL} are treated as stale.
|
||||
* Read again from the database on each chat edit or delete, so a demoted
|
||||
* moderator loses the right at once.
|
||||
*/
|
||||
role?: Role;
|
||||
avatarMime?: string;
|
||||
/** Set by {@link updateClientAvatar}, for the client's avatar URLs. */
|
||||
avatarVersion?: number;
|
||||
/** Undefined until the first ping, so a new socket isn't closed at once. */
|
||||
pongReceived?: boolean;
|
||||
/**
|
||||
* Set by `chat_focus`: the chatbox is open and the tab visible, so the user
|
||||
* is reading the chat and gets no notification for a mention in it.
|
||||
*/
|
||||
chatOpen?: boolean;
|
||||
/** When the last `chat_typing` true arrived, in ms. */
|
||||
typingAt?: number;
|
||||
}
|
||||
|
||||
/** Every connected client. */
|
||||
const clients = new Set<WsClient>();
|
||||
|
||||
/** Adds a connected client. */
|
||||
/**
|
||||
* Adds a client once its socket opens. From then on, it receives the
|
||||
* broadcasts, and is shown online if it is signed in.
|
||||
*/
|
||||
export function register(client: WsClient): void {
|
||||
clients.add(client);
|
||||
}
|
||||
|
||||
/** Removes a disconnected client. */
|
||||
/**
|
||||
* Removes a client when its socket closes. The caller rebroadcasts presence.
|
||||
*/
|
||||
export function unregister(client: WsClient): void {
|
||||
clients.delete(client);
|
||||
}
|
||||
|
||||
/** Records a user's new avatar on their clients and rebroadcasts presence. */
|
||||
/**
|
||||
* Records `userId`'s new avatar on their clients, so presence and the typing
|
||||
* list show it, and rebroadcasts presence. `routes/avatars.ts` calls it on an
|
||||
* upload.
|
||||
*/
|
||||
export function updateClientAvatar(userId: string, avatarMime: string): void {
|
||||
const version = Date.now();
|
||||
for (const client of clients) {
|
||||
@@ -67,7 +83,10 @@ export function updateClientAvatar(userId: string, avatarMime: string): void {
|
||||
broadcastPresence();
|
||||
}
|
||||
|
||||
/** Returns each signed-in user with at least one socket, once. */
|
||||
/**
|
||||
* Returns each signed-in user with a socket open, once. The route sends it in
|
||||
* `welcome`, and {@link broadcastPresence} in `presence_update`.
|
||||
*/
|
||||
export function getOnlineUsers(): OnlineUser[] {
|
||||
const seen = new Map<string, OnlineUser>();
|
||||
for (const client of clients) {
|
||||
@@ -83,19 +102,18 @@ export function getOnlineUsers(): OnlineUser[] {
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/** Sends an already serialized message, if the socket is open. */
|
||||
/** Sends `payload` unless the socket is closing, where `send` would throw. */
|
||||
function sendRaw(socket: WebSocket, payload: string): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(payload);
|
||||
}
|
||||
|
||||
/** Sends one message to one socket. */
|
||||
function send(socket: WebSocket, data: ServerToClientMessage): void {
|
||||
sendRaw(socket, JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one message to every client `include` accepts (all by default),
|
||||
* serialized once rather than once per socket.
|
||||
* Sends `data` to every client `include` accepts, or to all of them. It is
|
||||
* serialized once, not once per socket.
|
||||
*/
|
||||
function broadcast(
|
||||
data: ServerToClientMessage,
|
||||
@@ -109,12 +127,18 @@ function broadcast(
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a message to every socket of `userId`. */
|
||||
/**
|
||||
* Sends `data` to every socket of `userId`. The notification service sends
|
||||
* `notification_created` with it, and the route `chat_read_update`.
|
||||
*/
|
||||
export function sendToUser(userId: string, data: ServerToClientMessage): void {
|
||||
broadcast(data, (client) => client.userId === userId);
|
||||
}
|
||||
|
||||
/** Logs a deleted user out of every socket and closes them. */
|
||||
/**
|
||||
* Sends `force_logout` to every socket of `userId`, then closes them. The user
|
||||
* service calls it before deleting the account, so every tab signs out.
|
||||
*/
|
||||
export function disconnectUser(userId: string): void {
|
||||
for (const client of clients) {
|
||||
if (client.userId === userId) {
|
||||
@@ -125,10 +149,10 @@ export function disconnectUser(userId: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the user's sockets without logging anyone out: each reconnects with
|
||||
* whatever token it holds, and /ws turns a revoked one into a force_logout.
|
||||
* Used on a password change, where the tab that made the change has already
|
||||
* swapped in a fresh token and must stay signed in.
|
||||
* Closes every socket of `userId` without signing anyone out. The user service
|
||||
* calls it on a password change. Each tab reconnects with the token it holds:
|
||||
* the tab that made the change has a fresh one and stays signed in, while the
|
||||
* others connect anonymously and sign out on their next 401.
|
||||
*/
|
||||
export function closeUserSockets(userId: string, reason: string): void {
|
||||
for (const client of clients) {
|
||||
@@ -136,29 +160,41 @@ export function closeUserSockets(userId: string, reason: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends everyone the list of online users. */
|
||||
/**
|
||||
* Sends everyone `presence_update`. The route calls it whenever a socket opens
|
||||
* or closes.
|
||||
*/
|
||||
export function broadcastPresence(): void {
|
||||
broadcast({ type: "presence_update", users: getOnlineUsers() });
|
||||
}
|
||||
|
||||
/** Tells everyone a dump was posted. */
|
||||
/**
|
||||
* Sends everyone `dump_created`. The dump service also sends it when a private
|
||||
* dump goes public, since to everyone else that dump is new.
|
||||
*/
|
||||
export function broadcastNewDump(dump: Dump): void {
|
||||
broadcast({ type: "dump_created", dump });
|
||||
}
|
||||
|
||||
/** Tells everyone a dump changed. */
|
||||
/**
|
||||
* Sends everyone `dump_updated`, for a public dump that changed. The dump
|
||||
* service calls it.
|
||||
*/
|
||||
export function broadcastDumpUpdated(dump: Dump): void {
|
||||
broadcast({ type: "dump_updated", dump });
|
||||
}
|
||||
|
||||
/** Tells everyone a dump was deleted. */
|
||||
/**
|
||||
* Sends everyone `dump_deleted`. The dump service also sends it when a public
|
||||
* dump goes private, so that everyone else drops it.
|
||||
*/
|
||||
export function broadcastDumpDeleted(dumpId: string): void {
|
||||
broadcast({ type: "dump_deleted", dumpId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells everyone a dump's vote count changed. With `onlyUserId`, a private
|
||||
* dump's count goes to that user's sockets alone.
|
||||
* Sends `votes_update` after the route stored a vote, to everyone, or with
|
||||
* `onlyUserId` to that user's sockets alone (for a private dump).
|
||||
*/
|
||||
export function broadcastVoteUpdate(
|
||||
dumpId: string,
|
||||
@@ -174,8 +210,9 @@ export function broadcastVoteUpdate(
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells everyone a comment's like count changed. With `onlyUserId`, a like on
|
||||
* a private dump's comment goes to that user alone.
|
||||
* Sends `comment_likes_update` after the route stored a like, to everyone, or
|
||||
* with `onlyUserId` to that user's sockets alone (for a comment on a private
|
||||
* dump).
|
||||
*/
|
||||
export function broadcastCommentLikeUpdate(
|
||||
commentId: string,
|
||||
@@ -190,7 +227,7 @@ export function broadcastCommentLikeUpdate(
|
||||
);
|
||||
}
|
||||
|
||||
/** Sends a message to everyone for a public playlist, to its owner otherwise. */
|
||||
/** Sends `data` to everyone for a public playlist, else to its owner alone. */
|
||||
function sendToPlaylistAudience(
|
||||
playlist: Pick<Playlist, "isPublic" | "userId">,
|
||||
data: ServerToClientMessage,
|
||||
@@ -201,16 +238,18 @@ function sendToPlaylistAudience(
|
||||
);
|
||||
}
|
||||
|
||||
/** Tells a new playlist's audience it was created. */
|
||||
/**
|
||||
* Sends `playlist_created` to the new playlist's audience. The playlist service
|
||||
* calls it.
|
||||
*/
|
||||
export function broadcastPlaylistCreated(playlist: Playlist): void {
|
||||
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells a playlist's audience it changed. `wasPublic` is the visibility before
|
||||
* this change: a playlist that just went private still goes to everyone, so
|
||||
* non-owners can drop it from their views; one that was and stays private goes
|
||||
* to its owner alone.
|
||||
* Sends `playlist_updated` to the playlist's audience. `wasPublic` is its
|
||||
* visibility before the change: a playlist that just went private still goes
|
||||
* to everyone, so that the others can drop it.
|
||||
*/
|
||||
export function broadcastPlaylistUpdated(
|
||||
playlist: Playlist,
|
||||
@@ -222,7 +261,7 @@ export function broadcastPlaylistUpdated(
|
||||
);
|
||||
}
|
||||
|
||||
/** Tells a playlist's audience it was deleted. */
|
||||
/** Sends `playlist_deleted` to the audience the playlist had. */
|
||||
export function broadcastPlaylistDeleted(
|
||||
playlistId: string,
|
||||
userId: string,
|
||||
@@ -236,9 +275,9 @@ export function broadcastPlaylistDeleted(
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells a playlist's audience its dumps changed. Each viewer gets the members
|
||||
* they can see, as GET would list them: the ids of private dumps don't go out
|
||||
* to the whole audience of a public playlist.
|
||||
* Sends `playlist_dumps_updated` to the playlist's audience, each socket with
|
||||
* only the dumps its user may see, as `GET` would list them. The private dumps
|
||||
* in a public playlist stay hidden from everyone but their posters.
|
||||
*/
|
||||
export function broadcastPlaylistDumpsUpdated(
|
||||
playlist: Playlist,
|
||||
@@ -257,17 +296,23 @@ export function broadcastPlaylistDumpsUpdated(
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells everyone a user's profile changed. */
|
||||
/**
|
||||
* Sends everyone `user_updated`, after a profile edit, a role change or an
|
||||
* avatar upload.
|
||||
*/
|
||||
export function broadcastUserUpdated(user: PublicUser): void {
|
||||
broadcast({ type: "user_updated", user });
|
||||
}
|
||||
|
||||
/** Tells everyone a comment was posted. */
|
||||
/**
|
||||
* Sends everyone `comment_created`. The comments route calls it only for a
|
||||
* comment on a public dump, as it does the two below.
|
||||
*/
|
||||
export function broadcastCommentCreated(comment: Comment): void {
|
||||
broadcast({ type: "comment_created", comment });
|
||||
}
|
||||
|
||||
/** Tells everyone a comment was deleted. */
|
||||
/** Sends everyone `comment_deleted`. */
|
||||
export function broadcastCommentDeleted(
|
||||
commentId: string,
|
||||
dumpId: string,
|
||||
@@ -275,22 +320,23 @@ export function broadcastCommentDeleted(
|
||||
broadcast({ type: "comment_deleted", commentId, dumpId });
|
||||
}
|
||||
|
||||
/** Tells everyone a comment was edited. */
|
||||
/** Sends everyone `comment_updated`. */
|
||||
export function broadcastCommentUpdated(comment: Comment): void {
|
||||
broadcast({ type: "comment_updated", comment });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a chat message to signed-in sockets only: history is behind auth, so
|
||||
* the live stream is too — an anonymous socket gets nothing chat-related.
|
||||
* Sends `data` to signed-in sockets only. The chat's history needs a session,
|
||||
* so its live messages do too.
|
||||
*/
|
||||
function sendToMembers(data: ServerToClientMessage): void {
|
||||
broadcast(data, (client) => !!client.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new chat message to the members. `nonce` is the sender's id for its
|
||||
* pending copy; harmless to everyone else.
|
||||
* Sends `chat_message` to every signed-in socket, the sender's included.
|
||||
* `nonce` comes from the sender's `chat_send`, and lets it swap its pending
|
||||
* copy for the message.
|
||||
*/
|
||||
export function broadcastChatMessage(
|
||||
message: ChatMessage,
|
||||
@@ -299,27 +345,25 @@ export function broadcastChatMessage(
|
||||
sendToMembers({ type: "chat_message", message, nonce });
|
||||
}
|
||||
|
||||
/** Tells the members a chat message was edited. */
|
||||
/** Sends `chat_message_updated` to every signed-in socket. */
|
||||
export function broadcastChatMessageUpdated(message: ChatMessage): void {
|
||||
sendToMembers({ type: "chat_message_updated", message });
|
||||
}
|
||||
|
||||
/** Tells the members a chat message was deleted. */
|
||||
/** Sends `chat_message_deleted` to every signed-in socket. */
|
||||
export function broadcastChatMessageDeleted(id: string): void {
|
||||
sendToMembers({ type: "chat_message_deleted", id });
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a client's "typing" signal is considered live after its last
|
||||
* refresh. Must exceed the client's typing-ping cadence so a steady typist
|
||||
* never flickers out between pings.
|
||||
* How long a `chat_typing` true lasts. It must exceed how often the chatbox
|
||||
* resends it (`TYPING_REFRESH`, 3 s), or a steady typist flickers out.
|
||||
*/
|
||||
const TYPING_TTL = 6_000;
|
||||
|
||||
/**
|
||||
* Returns each user currently composing a chat message (typing signal within
|
||||
* {@link TYPING_TTL}) once, shaped like the online users so the client renders
|
||||
* the same avatars.
|
||||
* Returns each user who sent `chat_typing` true within the last
|
||||
* {@link TYPING_TTL}, once, for {@link broadcastTyping}.
|
||||
*/
|
||||
export function getTypingUsers(): OnlineUser[] {
|
||||
const now = Date.now();
|
||||
@@ -340,27 +384,29 @@ export function getTypingUsers(): OnlineUser[] {
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/** Sends the members the list of users typing. */
|
||||
/**
|
||||
* Sends `chat_typing_update` to every signed-in socket. The route also calls
|
||||
* it when a typing user's socket closes.
|
||||
*/
|
||||
export function broadcastTyping(): void {
|
||||
sendToMembers({ type: "chat_typing_update", users: getTypingUsers() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Records (or clears) a client's typing state and tells everyone when it
|
||||
* changed. The client also resends "true" periodically while composing; a
|
||||
* sweep expires it if they go quiet without a clean "false" (e.g. they closed
|
||||
* the tab).
|
||||
* Records a client's `chat_typing`, and broadcasts the typing list when the
|
||||
* client starts or stops. The route also clears it when the client sends a
|
||||
* message. A client that stops without saying so is expired after
|
||||
* {@link TYPING_TTL}.
|
||||
*/
|
||||
export function setClientTyping(client: WsClient, typing: boolean): void {
|
||||
const wasTyping = !!client.typingAt;
|
||||
client.typingAt = typing ? Date.now() : undefined;
|
||||
// Only rebroadcast on an actual edge to avoid a flood of identical updates
|
||||
// from the periodic typing pings.
|
||||
// The chatbox resends true every 3 s while typing, which changes nothing.
|
||||
if (typing !== wasTyping) broadcastTyping();
|
||||
}
|
||||
|
||||
// Sweep stale typing entries (clients that stopped pinging without a clean
|
||||
// stop) and rebroadcast when the visible set shrinks.
|
||||
// Expires the typing users who went quiet without a false, such as by closing
|
||||
// the tab.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
@@ -374,9 +420,9 @@ setInterval(() => {
|
||||
}, 2_000);
|
||||
|
||||
/**
|
||||
* Checks whether the user has the chatbox open on any of their connected
|
||||
* clients — i.e. they're already reading chat, so a mention notification would
|
||||
* be redundant.
|
||||
* Checks whether `userId` is reading the chat on any of their sockets. The
|
||||
* route passes it to `notifyMentions`, which skips those users: they saw the
|
||||
* mention live, and their client chimes for it.
|
||||
*/
|
||||
export function isUserChatOpen(userId: string): boolean {
|
||||
for (const client of clients) {
|
||||
@@ -387,17 +433,16 @@ export function isUserChatOpen(userId: string): boolean {
|
||||
|
||||
export { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
|
||||
/** Records that a client answered the keepalive ping. */
|
||||
/** Records a client's `pong`, which keeps its socket open. */
|
||||
export function handleClientPong(client: WsClient): void {
|
||||
client.pongReceived = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* How often every client is pinged. One that hasn't answered the previous ping
|
||||
* is disconnected.
|
||||
* is closed.
|
||||
*/
|
||||
const PING_INTERVAL = 30_000;
|
||||
/** The serialized ping. */
|
||||
const PING = JSON.stringify({ type: "ping" } satisfies ServerToClientMessage);
|
||||
|
||||
setInterval(() => {
|
||||
@@ -406,7 +451,7 @@ setInterval(() => {
|
||||
clients.delete(client);
|
||||
continue;
|
||||
}
|
||||
// Disconnect if no pong since last ping (pongReceived starts undefined, skip first cycle)
|
||||
// Undefined, not false, before the first ping.
|
||||
if (client.pongReceived === false) {
|
||||
client.socket.close(1001, "Ping timeout");
|
||||
clients.delete(client);
|
||||
|
||||
Reference in New Issue
Block a user