v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ChatMessage } from "../../shared/chat.ts";
|
||||
import type { Comment } from "../../shared/comment.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
@@ -9,31 +17,45 @@ import {
|
||||
type PlaylistMember,
|
||||
} from "../lib/playlist-members.ts";
|
||||
|
||||
/** A connected socket, and what the server knows of its user. */
|
||||
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 TYPING_TTL are treated as stale. */
|
||||
/**
|
||||
* Timestamp (ms) of the client's last "typing" signal; undefined when not
|
||||
* composing. Entries older than {@link TYPING_TTL} are treated as stale.
|
||||
*/
|
||||
typingAt?: number;
|
||||
}
|
||||
|
||||
/** Every connected client. */
|
||||
const clients = new Set<WsClient>();
|
||||
|
||||
/** Adds a connected client. */
|
||||
export function register(client: WsClient): void {
|
||||
clients.add(client);
|
||||
}
|
||||
|
||||
/** Removes a disconnected client. */
|
||||
export function unregister(client: WsClient): void {
|
||||
clients.delete(client);
|
||||
}
|
||||
|
||||
/** Records a user's new avatar on their clients and rebroadcasts presence. */
|
||||
export function updateClientAvatar(userId: string, avatarMime: string): void {
|
||||
const version = Date.now();
|
||||
for (const client of clients) {
|
||||
@@ -45,6 +67,7 @@ export function updateClientAvatar(userId: string, avatarMime: string): void {
|
||||
broadcastPresence();
|
||||
}
|
||||
|
||||
/** Returns each signed-in user with at least one socket, once. */
|
||||
export function getOnlineUsers(): OnlineUser[] {
|
||||
const seen = new Map<string, OnlineUser>();
|
||||
for (const client of clients) {
|
||||
@@ -60,16 +83,20 @@ export function getOnlineUsers(): OnlineUser[] {
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/** Sends an already serialized message, if the socket is open. */
|
||||
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 one message to every client `include` accepts (all by default),
|
||||
* serialized once rather than once per socket.
|
||||
*/
|
||||
function broadcast(
|
||||
data: ServerToClientMessage,
|
||||
include?: (client: WsClient) => boolean,
|
||||
@@ -82,10 +109,12 @@ function broadcast(
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a message to every socket of `userId`. */
|
||||
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. */
|
||||
export function disconnectUser(userId: string): void {
|
||||
for (const client of clients) {
|
||||
if (client.userId === userId) {
|
||||
@@ -107,23 +136,30 @@ export function closeUserSockets(userId: string, reason: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends everyone the list of online users. */
|
||||
export function broadcastPresence(): void {
|
||||
broadcast({ type: "presence_update", users: getOnlineUsers() });
|
||||
}
|
||||
|
||||
/** Tells everyone a dump was posted. */
|
||||
export function broadcastNewDump(dump: Dump): void {
|
||||
broadcast({ type: "dump_created", dump });
|
||||
}
|
||||
|
||||
/** Tells everyone a dump changed. */
|
||||
export function broadcastDumpUpdated(dump: Dump): void {
|
||||
broadcast({ type: "dump_updated", dump });
|
||||
}
|
||||
|
||||
/** Tells everyone a dump was deleted. */
|
||||
export function broadcastDumpDeleted(dumpId: string): void {
|
||||
broadcast({ type: "dump_deleted", dumpId });
|
||||
}
|
||||
|
||||
// `onlyUserId`: a private dump's count goes to that user's sockets alone.
|
||||
/**
|
||||
* Tells everyone a dump's vote count changed. With `onlyUserId`, a private
|
||||
* dump's count goes to that user's sockets alone.
|
||||
*/
|
||||
export function broadcastVoteUpdate(
|
||||
dumpId: string,
|
||||
voteCount: number,
|
||||
@@ -137,7 +173,10 @@ export function broadcastVoteUpdate(
|
||||
);
|
||||
}
|
||||
|
||||
// `onlyUserId`: a like on a private dump's comment goes to that user alone.
|
||||
/**
|
||||
* Tells everyone a comment's like count changed. With `onlyUserId`, a like on
|
||||
* a private dump's comment goes to that user alone.
|
||||
*/
|
||||
export function broadcastCommentLikeUpdate(
|
||||
commentId: string,
|
||||
likeCount: number,
|
||||
@@ -151,6 +190,7 @@ export function broadcastCommentLikeUpdate(
|
||||
);
|
||||
}
|
||||
|
||||
/** Sends a message to everyone for a public playlist, to its owner otherwise. */
|
||||
function sendToPlaylistAudience(
|
||||
playlist: Pick<Playlist, "isPublic" | "userId">,
|
||||
data: ServerToClientMessage,
|
||||
@@ -161,13 +201,17 @@ function sendToPlaylistAudience(
|
||||
);
|
||||
}
|
||||
|
||||
/** Tells a new playlist's audience it was created. */
|
||||
export function broadcastPlaylistCreated(playlist: Playlist): void {
|
||||
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
|
||||
}
|
||||
|
||||
// `wasPublic`: 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.
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function broadcastPlaylistUpdated(
|
||||
playlist: Playlist,
|
||||
wasPublic: boolean,
|
||||
@@ -178,6 +222,7 @@ export function broadcastPlaylistUpdated(
|
||||
);
|
||||
}
|
||||
|
||||
/** Tells a playlist's audience it was deleted. */
|
||||
export function broadcastPlaylistDeleted(
|
||||
playlistId: string,
|
||||
userId: string,
|
||||
@@ -190,8 +235,11 @@ export function broadcastPlaylistDeleted(
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function broadcastPlaylistDumpsUpdated(
|
||||
playlist: Playlist,
|
||||
members: PlaylistMember[],
|
||||
@@ -209,14 +257,17 @@ export function broadcastPlaylistDumpsUpdated(
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells everyone a user's profile changed. */
|
||||
export function broadcastUserUpdated(user: PublicUser): void {
|
||||
broadcast({ type: "user_updated", user });
|
||||
}
|
||||
|
||||
/** Tells everyone a comment was posted. */
|
||||
export function broadcastCommentCreated(comment: Comment): void {
|
||||
broadcast({ type: "comment_created", comment });
|
||||
}
|
||||
|
||||
/** Tells everyone a comment was deleted. */
|
||||
export function broadcastCommentDeleted(
|
||||
commentId: string,
|
||||
dumpId: string,
|
||||
@@ -224,17 +275,23 @@ export function broadcastCommentDeleted(
|
||||
broadcast({ type: "comment_deleted", commentId, dumpId });
|
||||
}
|
||||
|
||||
/** Tells everyone a comment was edited. */
|
||||
export function broadcastCommentUpdated(comment: Comment): void {
|
||||
broadcast({ type: "comment_updated", comment });
|
||||
}
|
||||
|
||||
// Global chat room for members: history is behind auth, so the live stream is
|
||||
// too — an anonymous socket gets nothing chat-related.
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function sendToMembers(data: ServerToClientMessage): void {
|
||||
broadcast(data, (client) => !!client.userId);
|
||||
}
|
||||
|
||||
// `nonce` is the sender's id for its pending copy; harmless to everyone else.
|
||||
/**
|
||||
* Sends a new chat message to the members. `nonce` is the sender's id for its
|
||||
* pending copy; harmless to everyone else.
|
||||
*/
|
||||
export function broadcastChatMessage(
|
||||
message: ChatMessage,
|
||||
nonce?: string,
|
||||
@@ -242,21 +299,28 @@ export function broadcastChatMessage(
|
||||
sendToMembers({ type: "chat_message", message, nonce });
|
||||
}
|
||||
|
||||
/** Tells the members a chat message was edited. */
|
||||
export function broadcastChatMessageUpdated(message: ChatMessage): void {
|
||||
sendToMembers({ type: "chat_message_updated", message });
|
||||
}
|
||||
|
||||
/** Tells the members a chat message was deleted. */
|
||||
export function broadcastChatMessageDeleted(id: string): void {
|
||||
sendToMembers({ type: "chat_message_deleted", id });
|
||||
}
|
||||
|
||||
// Typing indicator: a client's "typing" signal is considered live for this long
|
||||
// after its last refresh. Must exceed the client's typing-ping cadence so a
|
||||
// steady typist never flickers out between pings.
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const TYPING_TTL = 6_000;
|
||||
|
||||
// Deduped set of users currently composing a chat message (typing signal within
|
||||
// the TTL), shaped like OnlineUser so the client renders the same avatars.
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function getTypingUsers(): OnlineUser[] {
|
||||
const now = Date.now();
|
||||
const seen = new Map<string, OnlineUser>();
|
||||
@@ -276,13 +340,17 @@ export function getTypingUsers(): OnlineUser[] {
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/** Sends the members the list of users typing. */
|
||||
export function broadcastTyping(): void {
|
||||
sendToMembers({ type: "chat_typing_update", users: getTypingUsers() });
|
||||
}
|
||||
|
||||
// Record (or clear) a client's typing state and tell everyone. The client also
|
||||
// resends "true" periodically while composing; the sweep below expires it if
|
||||
// they go quiet without a clean "false" (e.g. they closed the tab).
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function setClientTyping(client: WsClient, typing: boolean): void {
|
||||
const wasTyping = !!client.typingAt;
|
||||
client.typingAt = typing ? Date.now() : undefined;
|
||||
@@ -305,8 +373,11 @@ setInterval(() => {
|
||||
if (changed) broadcastTyping();
|
||||
}, 2_000);
|
||||
|
||||
// True if 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 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.
|
||||
*/
|
||||
export function isUserChatOpen(userId: string): boolean {
|
||||
for (const client of clients) {
|
||||
if (client.userId === userId && client.chatOpen) return true;
|
||||
@@ -316,12 +387,17 @@ export function isUserChatOpen(userId: string): boolean {
|
||||
|
||||
export { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
|
||||
/** Records that a client answered the keepalive ping. */
|
||||
export function handleClientPong(client: WsClient): void {
|
||||
client.pongReceived = true;
|
||||
}
|
||||
|
||||
// Keepalive: ping all clients every 30s, disconnect non-responsive ones
|
||||
/**
|
||||
* How often every client is pinged. One that hasn't answered the previous ping
|
||||
* is disconnected.
|
||||
*/
|
||||
const PING_INTERVAL = 30_000;
|
||||
/** The serialized ping. */
|
||||
const PING = JSON.stringify({ type: "ping" } satisfies ServerToClientMessage);
|
||||
|
||||
setInterval(() => {
|
||||
|
||||
Reference in New Issue
Block a user