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:
123
api/routes/ws.ts
123
api/routes/ws.ts
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* The WebSocket route, and the handling of what clients send over it.
|
||||
* Opens the WebSocket that `WSProvider` keeps for each tab, and handles what
|
||||
* the client sends over it. Voting, comment likes and every chat write go
|
||||
* through here, not through HTTP. The messages are typed in `shared/ws.ts`,
|
||||
* and `services/ws-service.ts` sends the broadcasts.
|
||||
*
|
||||
* - `GET /ws?token=` — public; a session `token` signs the socket in, and an
|
||||
* invalid one leaves it anonymous. Opens the socket that carries presence,
|
||||
* live updates, votes, comment likes and the chat (see
|
||||
* `ClientToServerMessage` and `ServerToClientMessage` in shared/ws.ts); 403
|
||||
* from an origin not in {@link ALLOWED_ORIGINS}.
|
||||
* - `GET /ws?token=`, public. A valid session `token` signs the socket in, and
|
||||
* an invalid one leaves it anonymous. Answers 403 to an origin not in
|
||||
* {@link ALLOWED_ORIGINS}, and 426 to a request that isn't an upgrade.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -62,10 +63,17 @@ import type {
|
||||
} from "../../shared/ws.ts";
|
||||
import type { User } from "../model/user.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router();
|
||||
|
||||
/** Checks whether a socket may be opened from `origin`. */
|
||||
/**
|
||||
* Checks whether a socket may be opened from `origin`. Browsers don't apply
|
||||
* CORS to WebSockets, so this is what stops another site from opening one with
|
||||
* its visitors' tokens.
|
||||
*/
|
||||
function isAllowedOrigin(origin: string): boolean {
|
||||
return ALLOWED_ORIGINS.includes(origin);
|
||||
}
|
||||
@@ -82,9 +90,8 @@ router.get("/ws", async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// authenticate() refuses a token whose user is gone or which predates a
|
||||
// password change, and reads the live role from the DB. Such a socket is
|
||||
// simply anonymous; the client's next API call gets the 401.
|
||||
// A revoked token (deleted user, changed password) opens an anonymous
|
||||
// socket. The client signs out on its next API call, which gets a 401.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const authPayload = token ? await authenticate(token) : null;
|
||||
let user: User | undefined;
|
||||
@@ -104,9 +111,11 @@ router.get("/ws", async (ctx) => {
|
||||
avatarMime: user?.avatarMime,
|
||||
};
|
||||
|
||||
// Use addEventListener — more reliable than onopen= with Deno.serve
|
||||
// addEventListener, since `onopen =` proved unreliable under Deno.serve.
|
||||
socket.addEventListener("open", () => {
|
||||
register(client);
|
||||
// The new socket is registered, so it gets this presence_update too,
|
||||
// before its welcome.
|
||||
broadcastPresence();
|
||||
|
||||
try {
|
||||
@@ -141,12 +150,16 @@ router.get("/ws", async (ctx) => {
|
||||
socket.addEventListener("close", () => {
|
||||
unregister(client);
|
||||
broadcastPresence();
|
||||
// If they were mid-compose, drop them from the typing set right away.
|
||||
// The sweep only sees registered clients, so it would never clear this one.
|
||||
if (client.typingAt) broadcastTyping();
|
||||
});
|
||||
});
|
||||
|
||||
/** Parses one message from a client and dispatches it by `type`. */
|
||||
/**
|
||||
* Parses one message from a client and dispatches it by `type`. A message that
|
||||
* isn't JSON, or whose `type` is unknown, is ignored. Its fields are still
|
||||
* untrusted, so each handler checks their types.
|
||||
*/
|
||||
function handleMessage(client: WsClient, data: unknown): void {
|
||||
if (typeof data !== "string") return;
|
||||
let parsed: unknown;
|
||||
@@ -155,8 +168,6 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Anything but an object with a string `type` (`null`, a number, an array)
|
||||
// is ignored rather than read from.
|
||||
if (
|
||||
typeof parsed !== "object" || parsed === null ||
|
||||
typeof (parsed as { type?: unknown }).type !== "string"
|
||||
@@ -187,7 +198,7 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
|
||||
break;
|
||||
case "chat_typing":
|
||||
// Typing is cosmetic: past the limit it's just dropped.
|
||||
// Typing is cosmetic: past the limit it's dropped, with no error.
|
||||
if (
|
||||
client.userId &&
|
||||
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
|
||||
@@ -210,14 +221,15 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a message only while the socket is open: `send` on a closing socket throws. */
|
||||
/** Sends `data` unless the socket is closing, where `send` would throw. */
|
||||
function send(socket: WebSocket, data: ServerToClientMessage): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts or removes the client's upvote, acknowledges it, and broadcasts the new
|
||||
* count.
|
||||
* Handles `vote_cast` and `vote_remove`. It acks the voter with `vote_ack` and
|
||||
* broadcasts `votes_update`. A failure goes back as an `error` naming the dump
|
||||
* and the action, so the client can undo its optimistic vote at once.
|
||||
*/
|
||||
function handleVote(
|
||||
client: WsClient,
|
||||
@@ -244,8 +256,7 @@ function handleVote(
|
||||
voteCount,
|
||||
});
|
||||
|
||||
// Only a private dump's owner can vote on it, so they're its whole
|
||||
// audience.
|
||||
// Only a private dump's owner can see it, and so vote on it.
|
||||
broadcastVoteUpdate(
|
||||
dumpId,
|
||||
voteCount,
|
||||
@@ -260,8 +271,8 @@ function handleVote(
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts or removes the client's like on a comment, acknowledges it, and
|
||||
* broadcasts the new count.
|
||||
* Handles `comment_like_cast` and `comment_like_remove`, as
|
||||
* {@link handleVote} does votes.
|
||||
*/
|
||||
function handleCommentLike(
|
||||
client: WsClient,
|
||||
@@ -302,20 +313,23 @@ function handleCommentLike(
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat writes per user. Sending, editing and deleting share one bucket: a
|
||||
* burst of 8, then one every 1.5s — room for a quick back-and-forth, not for a
|
||||
* flood.
|
||||
* Limits each user's chat writes, sends, edits and deletes together, to a burst
|
||||
* of 8, then one every 1.5 s. The buckets are per user, so more tabs don't buy
|
||||
* more.
|
||||
*/
|
||||
const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 };
|
||||
/** Typing signals per user. */
|
||||
const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
||||
/**
|
||||
* Read markers per user. They are cheap for the client to send often; past
|
||||
* this they're dropped, and the next one catches up.
|
||||
* Limits each user's `chat_read`. One over the limit is dropped, and the next
|
||||
* one catches up, since a marker only moves forward.
|
||||
*/
|
||||
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
||||
|
||||
/** Reports a failed chat write to the client, with the nonce of its pending copy. */
|
||||
/**
|
||||
* Sends a chat-scoped `error`. With the `nonce` of a `chat_send`, the chatbox
|
||||
* marks that pending message failed. Without one, it shows `message` as a
|
||||
* notice.
|
||||
*/
|
||||
function sendChatError(
|
||||
client: WsClient,
|
||||
message: string,
|
||||
@@ -325,8 +339,8 @@ function sendChatError(
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the common preconditions of a chat write: signed in and under the
|
||||
* rate limit. Returns the user id, or undefined after reporting why not.
|
||||
* Returns the id of the user behind a chat write, or undefined after sending
|
||||
* the error when they are signed out or over {@link CHAT_WRITE_LIMIT}.
|
||||
*/
|
||||
function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
if (!client.userId) {
|
||||
@@ -344,7 +358,9 @@ function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
return client.userId;
|
||||
}
|
||||
|
||||
/** Returns `value` when it is a string of at most `maxLength`, else undefined. */
|
||||
/**
|
||||
* Returns `value` when it is a string of at most `maxLength`, else undefined.
|
||||
*/
|
||||
function optionalString(value: unknown, maxLength: number): string | undefined {
|
||||
return typeof value === "string" && value.length <= maxLength
|
||||
? value
|
||||
@@ -352,8 +368,8 @@ function optionalString(value: unknown, maxLength: number): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a chat message, broadcasts it, and notifies the @mentioned users not
|
||||
* already reading the chat.
|
||||
* Handles `chat_send`. It stores the message, broadcasts it, and notifies the
|
||||
* @mentioned users who aren't reading the chat.
|
||||
*/
|
||||
function handleChatSend(
|
||||
client: WsClient,
|
||||
@@ -375,14 +391,10 @@ function handleChatSend(
|
||||
body,
|
||||
optionalString(replyToId, 64),
|
||||
);
|
||||
// Sending a message ends composition — clear the typing flag before the
|
||||
// message broadcast so the indicator doesn't linger next to it.
|
||||
// Before the broadcast, so the typing indicator doesn't linger beside the
|
||||
// message.
|
||||
setClientTyping(client, false);
|
||||
// Broadcast to everyone including the sender, whose pending copy the nonce
|
||||
// retires.
|
||||
broadcastChatMessage(message, nonce);
|
||||
// Skip mention notifications for users who already have the chat open —
|
||||
// they've seen the message live.
|
||||
notifyMentions(
|
||||
userId,
|
||||
message.body,
|
||||
@@ -402,10 +414,9 @@ function handleChatSend(
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the client's user may moderate the chat. Reads the role from
|
||||
* the DB on every check rather than trusting the one captured at connect: a
|
||||
* demoted moderator loses the power right away, not whenever their socket next
|
||||
* reconnects.
|
||||
* Checks whether the client's user may edit and delete others' chat messages.
|
||||
* The role is read from the database, not from the socket's connect, so that a
|
||||
* demoted moderator loses the right at once.
|
||||
*/
|
||||
function canModerateChat(client: WsClient): boolean {
|
||||
if (!client.userId) return false;
|
||||
@@ -417,7 +428,10 @@ function canModerateChat(client: WsClient): boolean {
|
||||
return !!client.role && hasPermission(client.role, "chat:moderate");
|
||||
}
|
||||
|
||||
/** Edits a chat message, broadcasts it, and notifies newly @mentioned users. */
|
||||
/**
|
||||
* Handles `chat_edit`. It stores the new body, broadcasts it, and notifies the
|
||||
* users the edit newly @mentions.
|
||||
*/
|
||||
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
@@ -434,8 +448,9 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
canModerateChat(client),
|
||||
);
|
||||
broadcastChatMessageUpdated(message);
|
||||
// A newly added @mention in an edit should still notify (deduped per
|
||||
// message+user), skipping anyone already reading the chat.
|
||||
// Mentions already notified for this message are deduplicated, so only
|
||||
// the new ones notify. After a moderator's edit, they come from the
|
||||
// moderator.
|
||||
notifyMentions(
|
||||
userId,
|
||||
message.body,
|
||||
@@ -453,7 +468,7 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a chat message and broadcasts it. */
|
||||
/** Handles `chat_delete`. It deletes the message and broadcasts its id. */
|
||||
function handleChatDelete(client: WsClient, id: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
@@ -474,8 +489,9 @@ function handleChatDelete(client: WsClient, id: unknown): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the user's read marker and tells all their sockets, so every tab and
|
||||
* device drops its badge together.
|
||||
* Handles `chat_read`. It moves the user's read marker and sends
|
||||
* `chat_read_update` to all their sockets, so every tab and device updates its
|
||||
* badge together.
|
||||
*/
|
||||
function handleChatRead(client: WsClient, at: unknown): void {
|
||||
if (!client.userId || typeof at !== "string") return;
|
||||
@@ -488,7 +504,8 @@ function handleChatRead(client: WsClient, at: unknown): void {
|
||||
unread: state.unread,
|
||||
});
|
||||
} catch {
|
||||
// A malformed marker changes nothing; there's nothing to report.
|
||||
// A malformed marker changes nothing, and the client doesn't wait for an
|
||||
// answer.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user