Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

Comments

Comment threads kept by the room beside the document, read and written over HTTP or RPC, sent live to the sockets that ask, with a hook for notifications.

The room keeps a document’s comment threads, as in Notion: a thread starts on some text, collects replies, and is resolved or reopened. Thread bodies live in the room’s SQLite storage, beside the document and never in it; the document holds only each thread’s anchor, a comment:<id> mark over the text it was made on. The browser side is the comments plugin.

Comments are on in every room. Nothing is stored until the first thread, and nothing reaches a client until it asks: the threads are read and written over HTTP (routeDocumentComments) or RPC, and each change is sent to the sockets that subscribed.

Over HTTP

routeDocumentComments is the HTTP door to a room’s threads, authorized as routeDocumentSocket authorizes a dial (the same authorize, the same refusals, as HTTP statuses). Mount it at a path of your own:

import {
  DocumentRoom,
  closedSocket,
  routeDocumentComments,
  routeDocumentSocket
} from 'edytor/cloudflare';

export { DocumentRoom };

type Env = { ROOMS: DurableObjectNamespace<DocumentRoom> };

const authorize = async (request: Request) => {
  const userId = new URL(request.url).searchParams.get('user'); // check a session instead
  return userId ? { userId } : null;
};

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const { pathname } = new URL(request.url);
    const match = /^\/(?:rooms|comments)\/([^/]+)$/.exec(pathname);
    if (!match) return new Response('not found', { status: 404 });
    let documentId: string; // percent-encoded, as the clients send it
    try {
      documentId = decodeURIComponent(match[1]);
    } catch {
      return closedSocket(4400, 'invalid document id');
    }
    if (pathname.startsWith('/comments/')) {
      return routeDocumentComments(request, env.ROOMS, documentId, authorize);
    }
    return routeDocumentSocket(request, env.ROOMS, documentId, authorize);
  }
} satisfies ExportedHandler<Env>;
Request Answer
GET <url> Every thread, oldest first, and the sequence number of the last change: { seq, threads }.
POST <url> One request, as JSON (below), made as the verified user: { status: 'applied', change }, or { status: 'noop', thread } when it changed nothing.

The requests a POST carries:

Body Does
{ op: 'add', thread, body, quote?, block?, comment? } Starts thread thread (an id the client picks: its anchor mark names it) with its first comment. quote is the text it was made on (kept as one line, at most 300 characters) and block the block it starts in, both for display.
{ op: 'reply', thread, body, comment? } Adds a comment to the thread.
{ op: 'resolve', thread }, { op: 'reopen', thread } Resolves the thread, or opens it again. Resolving a resolved thread is a noop.
{ op: 'delete', thread, comment? } Deletes one comment. Without comment, or with the thread’s first one, deletes the whole thread.

A refusal is a status: 400 for an invalid document id, request or id, an empty body or one over 10,000 characters; 401 when authorize returns { expired: true }; 403 when it denies the request, when a read-only identity writes, or when a user deletes another user’s comment; 404 for a thread or comment the room does not hold, or a room with comments off; 405 for another method; 409 for a thread or comment id already taken; 413 for a request body over 64 KiB (read no further, with or without a Content-Length), a thread of 500 comments, a document of 5,000 threads, or a thread or reply past the room’s comment bytes; 429 when the user is past the room’s comment rate; 503 when the room cannot serve it.

The rules

One rule decides every request, in the room and in the clients’ memory store alike (decideComment, exported for a store of your own):

  • Who writes. Anyone with write access adds threads, replies, resolves and reopens. A read-only identity lists the threads and changes none (403).
  • Who deletes. A comment’s author deletes it. The first comment stands for its thread: deleting it, or the thread, is its creator’s, and removes the replies too. Over RPC, a moderator deletes anyone’s.
  • Ids. A thread or comment id is 1 to 64 of A-Z a-z 0-9 _ -. The client picks the thread’s id; a comment’s id is the room’s unless the request names one.
  • Bodies. Plain text, line breaks kept, trailing white space dropped, at most 10,000 characters (maxLength lowers it).
  • Sequence. Each change takes the document’s next sequence number (seq), and the thread it changes carries it as rev.

Over RPC

The room’s methods, for your own routes, bots and moderation:

import type { DocumentRoom } from 'edytor/cloudflare';

export const removeThread = async (
  rooms: DurableObjectNamespace<DocumentRoom>,
  documentId: string,
  thread: string
) => {
  const room = rooms.getByName(documentId);
  const { threads } = await room.listComments();
  if (!threads.some((t) => t.id === thread)) return null;
  return room.comment({ op: 'delete', thread }, { user: 'moderation', moderator: true });
};
  • listComments() answers { seq, threads }.
  • comment(request, { user, readOnly?, moderator? }) decides one request under the rules above and answers { status: 'applied', change }, { status: 'noop', thread } or { status: 'refused', reason, message } (reason: invalid, read-only, forbidden, missing, exists, full).

What a change does

An applied change is stored (the thread, its comments and the sequence number, in one SQLite transaction), then sent to every socket that subscribed, then passed to onComment:

type CommentChange = {
  type: 'added' | 'replied' | 'resolved' | 'reopened' | 'deleted' | 'removed';
  thread: CommentThread; // after the change; for `removed`, as it was
  comment: ThreadComment | null; // added or deleted, else null
  user: string; // who made it
  at: number;
  seq: number;
};

A removed thread takes its anchor with it: the room removes its comment:<id> marks from the document as one room transaction, stored and sent to every client like any edit, so no client shows a highlight with no thread behind it. A resolved thread keeps its anchor (reopening it shows the highlight again). A thread whose text was deleted keeps its comments: it has no anchor until an undo brings the text back.

Live

A socket asks for the changes with a messageComments subscribe; the room answers every thread ({ seq, threads }) at once, then sends each change. The subscription lives in the socket’s attachment, so it survives hibernation, and a reconnected socket subscribes again, which answers what it missed. Sockets that do not ask receive nothing. A subscribe on a socket already subscribed answers nothing, and a socket’s comment messages draw from the comment rate (a bucket per socket): past it, a message is dropped and the subscription stays as it was. The frames are in the protocol. createCommentsClient subscribes on the socket the page’s websocket sync already holds.

Notifications

Override onComment to send an email, a push notification or a webhook when a thread changes. It runs after the change is stored and sent, and the request is answered once it settles; a throw is logged (fault), and the change stands:

import { DocumentRoom, type CommentChange } from 'edytor/cloudflare';

type Env = { NOTIFY: Queue<{ to: string[]; change: CommentChange }> };

export class Room extends DocumentRoom<Env> {
  protected override async onComment(change: CommentChange) {
    if (change.type !== 'added' && change.type !== 'replied') return;
    // Everyone in the thread but the writer.
    const to = [...new Set(change.thread.comments.map((c) => c.author))].filter(
      (user) => user !== change.user
    );
    if (to.length > 0) await this.env.NOTIFY.send({ to, change });
  }
}

With attachRoom, pass comments: { onComment }. onComment hears every change, the room’s own over RPC included; to notify of mentions, read them from change.comment.body.

Options

Option Default
comments (attachRoom) on { onComment, maxLength, maxBytes, maxRequestsPerSecond }, or false to keep no comments: every comments request answers 404 and a subscribe receives an empty snapshot.
EDYTOR_COMMENTS (DocumentRoom) unset off keeps no comments.
maxBytes, EDYTOR_MAX_COMMENT_BYTES 4 MiB (DEFAULT_MAX_COMMENT_BYTES) The bytes the room’s comments hold at most. See Quotas.
maxRequestsPerSecond, EDYTOR_MAX_COMMENT_REQUESTS_PER_SECOND 2 (DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND) Comment requests per second, a ten-second burst. See Quotas.
onComment (DocumentRoom, overridable) nothing See Notifications.

Quotas

Comments are on by default, so the room bounds what anyone allowed in can make it hold and send:

  • Bytes. The comments of a room hold at most maxBytes (4 MiB by default): each comment counts its body in UTF-8, plus its thread’s quote with the first comment, plus 128 bytes (commentBytes, COMMENT_ROW_BYTES). A thread or reply past it is refused 413 (full); a delete frees its bytes. Resolving, reopening and deleting are never refused for size.
  • Rate. Over HTTP, each verified user, read-only ones included, has a bucket of maxRequestsPerSecond requests a second (2 by default, a ten-second burst: 20 at once), lists and posts alike: past it, 429. Each socket has a bucket of its own for its comment messages: past it, a subscribe or unsubscribe is dropped, the socket stays. A subscribe while subscribed sends no second snapshot. RPC calls (comment, listComments) are the host’s own and are not counted.
  • Body. routeDocumentComments reads a POST body no further than 64 KiB, whether or not it declares a Content-Length.

Each refusal is logged comments ({ user, quota: 'bytes' | 'rate', limit }) and counted in refusalCounts.comments. The buckets live in the room’s memory: a wake refills them.

Storage

Threads live in two tables of the room’s SQLite storage, threads (one row per thread: its id, block, quote, creator, time and resolution) and comments (one row per comment), created at the first request; the meta table keeps the last sequence number under comments. See Internals. They are not the document: compaction, the history’s versions, a restore and the purge leave them as they are, onSave does not receive them (read them with listComments()), and a generation cutover keeps them. Their size is not counted in the document quota; their own byte quota bounds it, besides the limits above (500 comments of 10,000 characters per thread, 5,000 threads).

Was this page helpful?