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,9 @@
|
||||
/**
|
||||
* The WebSocket route, and the handling of what clients send over it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { ALLOWED_ORIGINS } from "../config.ts";
|
||||
import { authenticate } from "../lib/auth.ts";
|
||||
@@ -50,8 +56,18 @@ import type {
|
||||
} from "../../shared/ws.ts";
|
||||
import type { User } from "../model/user.ts";
|
||||
|
||||
const router = new Router();
|
||||
/**
|
||||
* The WebSocket route:
|
||||
*
|
||||
* - `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}.
|
||||
*/
|
||||
const router: Router = new Router();
|
||||
|
||||
/** Checks whether a socket may be opened from `origin`. */
|
||||
function isAllowedOrigin(origin: string): boolean {
|
||||
return ALLOWED_ORIGINS.includes(origin);
|
||||
}
|
||||
@@ -132,6 +148,7 @@ router.get("/ws", async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Parses one message from a client and dispatches it by `type`. */
|
||||
function handleMessage(client: WsClient, data: unknown): void {
|
||||
if (typeof data !== "string") return;
|
||||
let parsed: unknown;
|
||||
@@ -195,11 +212,15 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Sends only while the socket is open: `send` on a closing socket throws.
|
||||
/** Sends a message only while the socket is open: `send` on a closing socket throws. */
|
||||
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.
|
||||
*/
|
||||
function handleVote(
|
||||
client: WsClient,
|
||||
dumpId: unknown,
|
||||
@@ -240,6 +261,10 @@ function handleVote(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts or removes the client's like on a comment, acknowledges it, and
|
||||
* broadcasts the new count.
|
||||
*/
|
||||
function handleCommentLike(
|
||||
client: WsClient,
|
||||
commentId: unknown,
|
||||
@@ -278,14 +303,21 @@ function handleCommentLike(
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 are cheap for the client to send often; past this they're
|
||||
// dropped, and the next one catches up.
|
||||
/**
|
||||
* Read markers per user. They are cheap for the client to send often; past
|
||||
* this they're dropped, and the next one catches up.
|
||||
*/
|
||||
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
||||
|
||||
/** Reports a failed chat write to the client, with the nonce of its pending copy. */
|
||||
function sendChatError(
|
||||
client: WsClient,
|
||||
message: string,
|
||||
@@ -294,8 +326,10 @@ function sendChatError(
|
||||
send(client.socket, { type: "error", scope: "chat", message, nonce });
|
||||
}
|
||||
|
||||
// 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.
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
if (!client.userId) {
|
||||
sendChatError(client, "Authentication required", nonce);
|
||||
@@ -312,12 +346,17 @@ function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
return client.userId;
|
||||
}
|
||||
|
||||
/** 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
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a chat message, broadcasts it, and notifies the @mentioned users not
|
||||
* already reading the chat.
|
||||
*/
|
||||
function handleChatSend(
|
||||
client: WsClient,
|
||||
body: unknown,
|
||||
@@ -364,9 +403,12 @@ function handleChatSend(
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the role from the DB on every moderation 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 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.
|
||||
*/
|
||||
function canModerateChat(client: WsClient): boolean {
|
||||
if (!client.userId) return false;
|
||||
try {
|
||||
@@ -377,6 +419,7 @@ function canModerateChat(client: WsClient): boolean {
|
||||
return !!client.role && hasPermission(client.role, "chat:moderate");
|
||||
}
|
||||
|
||||
/** Edits a chat message, broadcasts it, and notifies newly @mentioned users. */
|
||||
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
@@ -412,6 +455,7 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a chat message and broadcasts it. */
|
||||
function handleChatDelete(client: WsClient, id: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
@@ -431,8 +475,10 @@ 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.
|
||||
/**
|
||||
* Moves the user's read marker and tells all their sockets, so every tab and
|
||||
* device drops its badge together.
|
||||
*/
|
||||
function handleChatRead(client: WsClient, at: unknown): void {
|
||||
if (!client.userId || typeof at !== "string") return;
|
||||
if (!takeToken(`read:${client.userId}`, CHAT_READ_LIMIT)) return;
|
||||
|
||||
Reference in New Issue
Block a user