edytor 0.1.0-next.41 → 0.1.0-next.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/clipboard/insertClipboardFragment.d.ts +8 -0
- package/dist/clipboard/insertClipboardFragment.js +23 -0
- package/dist/clipboard/textBlocks.d.ts +2 -0
- package/dist/clipboard/textBlocks.js +4 -0
- package/dist/cloudflare/DocumentRoom.d.ts +24 -1
- package/dist/cloudflare/DocumentRoom.js +9 -1
- package/dist/cloudflare/index.d.ts +2 -2
- package/dist/cloudflare/index.js +2 -2
- package/dist/cloudflare/room/admission.d.ts +11 -6
- package/dist/cloudflare/room/admission.js +7 -8
- package/dist/cloudflare/room/comments.d.ts +18 -2
- package/dist/cloudflare/room/comments.js +65 -7
- package/dist/cloudflare/room/context.d.ts +13 -13
- package/dist/cloudflare/routeDocumentSocket.d.ts +2 -1
- package/dist/cloudflare/routeDocumentSocket.js +33 -3
- package/dist/collaboration/awarenessSelection.d.ts +1 -2
- package/dist/crdt/protocols/comments.d.ts +22 -0
- package/dist/crdt/protocols/comments.js +41 -1
- package/dist/edytor.svelte.d.ts +6 -0
- package/dist/edytor.svelte.js +16 -2
- package/dist/events/onBeforeInput.js +1 -1
- package/dist/events/onKeyDown.js +1 -1
- package/dist/events/onPaste.js +12 -1
- package/dist/index.d.ts +24 -0
- package/dist/labels.d.ts +4 -0
- package/dist/labels.js +2 -0
- package/dist/plugins/blockHandles/BlockHandleController.svelte.js +1 -1
- package/dist/plugins/columns/ColumnsPlugin.svelte +1 -1
- package/dist/plugins/image/ImageEmpty.svelte +4 -4
- package/dist/plugins/image/ImageEmpty.svelte.d.ts +3 -3
- package/dist/plugins/image/ImagePlugin.svelte +30 -29
- package/dist/plugins/image/ImagePlugin.svelte.d.ts +4 -2
- package/dist/plugins/media/AudioPlugin.svelte +5 -1
- package/dist/plugins/media/FilePlugin.svelte +5 -1
- package/dist/plugins/media/MediaEmpty.svelte +42 -11
- package/dist/plugins/media/MediaEmpty.svelte.d.ts +2 -1
- package/dist/plugins/media/VideoPlugin.svelte +5 -1
- package/dist/plugins/media/media.d.ts +17 -2
- package/dist/plugins/media/media.js +34 -0
- package/dist/plugins/table/TablePlugin.svelte +18 -5
- package/dist/plugins/uploads.svelte.d.ts +92 -0
- package/dist/plugins/uploads.svelte.js +160 -0
- package/dist/selection/replaceSelection.js +1 -1
- package/dist/selection/selection.svelte.d.ts +11 -7
- package/dist/selection/selection.svelte.js +22 -8
- package/dist/session/commands.d.ts +2 -2
- package/dist/session/commands.js +20 -8
- package/dist/session/keymap.d.ts +5 -3
- package/dist/session/moves.js +1 -1
- package/package.json +1 -1
- package/dist/plugins/image/uploads.svelte.d.ts +0 -50
- package/dist/plugins/image/uploads.svelte.js +0 -111
package/README.md
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
<p>
|
|
11
11
|
<a href="https://edytor.dev/docs">Documentation</a> •
|
|
12
12
|
<a href="#quick-start">Quick start</a> •
|
|
13
|
-
<a href="https://edytor.dev/docs/reference/migration">Changelog</a>
|
|
13
|
+
<a href="https://edytor.dev/docs/reference/migration">Changelog</a> •
|
|
14
|
+
<a href="SECURITY.md">Security</a>
|
|
14
15
|
</p>
|
|
15
16
|
</div>
|
|
16
17
|
|
|
17
18
|
Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable editor with an API to build any kind of collaborative rich text editor.
|
|
18
19
|
|
|
19
|
-
> **Work in progress.** Edytor is a pre-release (`0.1.0-next.
|
|
20
|
+
> **Work in progress.** Edytor is a pre-release (`0.1.0-next.44`, `edytor@next` on npm) and not ready for production; the API changes between releases without a compatibility layer. The untagged `edytor@0.0.11` on npm predates the current API. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
|
|
20
21
|
|
|
21
22
|
## Features
|
|
22
23
|
|
|
@@ -4,6 +4,14 @@ import { type SelectionInsertionTarget, type SelectionReplacementState } from '.
|
|
|
4
4
|
import { type JSONText } from '../utils/json.js';
|
|
5
5
|
import type { EdytorClipboardFragment } from './types.js';
|
|
6
6
|
export declare const flowOfText: (text: string, marks?: JSONText["marks"]) => Flow;
|
|
7
|
+
/**
|
|
8
|
+
* A pasted `text/plain` as markdown (`paste.markdown`): `null` unless a line
|
|
9
|
+
* holds block markdown (`hasBlockMarkdown`), else the blocks it reads as
|
|
10
|
+
* (`textToBlocks`). A kind the view does not register is a paragraph of its
|
|
11
|
+
* text, its children after it (a divider is dropped); without a paragraph
|
|
12
|
+
* kind, nothing converts.
|
|
13
|
+
*/
|
|
14
|
+
export declare const flowOfMarkdown: (edytor: Pick<Edytor, "blocks">, text: string) => Flow | null;
|
|
7
15
|
export declare const flowOfFragment: (fragment: EdytorClipboardFragment) => Flow;
|
|
8
16
|
/**
|
|
9
17
|
* Place `flow` (`flow.*`) at `at`, over the selected blocks (unless
|
|
@@ -4,6 +4,7 @@ import { cloneJsonSafe, jsonBlockToSpec, jsonContentToItems } from '../utils/jso
|
|
|
4
4
|
import { id } from '../utils.js';
|
|
5
5
|
import { caretAt } from '../session/attempt.js';
|
|
6
6
|
import { isValidEdytorClipboardFragment } from './fragmentData.js';
|
|
7
|
+
import { hasBlockMarkdown, textToBlocks } from './textBlocks.js';
|
|
7
8
|
// Admission (`flow.shape`): every id is minted fresh here, once.
|
|
8
9
|
const run = (content) => ({
|
|
9
10
|
id: id('b'),
|
|
@@ -12,6 +13,28 @@ const run = (content) => ({
|
|
|
12
13
|
export const flowOfText = (text, marks) => ({
|
|
13
14
|
lines: text.split(/\r\n|\r|\n/).map((line) => run(line ? [{ text: line, marks }] : []))
|
|
14
15
|
});
|
|
16
|
+
/**
|
|
17
|
+
* A pasted `text/plain` as markdown (`paste.markdown`): `null` unless a line
|
|
18
|
+
* holds block markdown (`hasBlockMarkdown`), else the blocks it reads as
|
|
19
|
+
* (`textToBlocks`). A kind the view does not register is a paragraph of its
|
|
20
|
+
* text, its children after it (a divider is dropped); without a paragraph
|
|
21
|
+
* kind, nothing converts.
|
|
22
|
+
*/
|
|
23
|
+
export const flowOfMarkdown = (edytor, text) => {
|
|
24
|
+
if (!edytor.blocks.has('paragraph') || !hasBlockMarkdown(text))
|
|
25
|
+
return null;
|
|
26
|
+
const known = (block) => {
|
|
27
|
+
const children = (block.children ?? []).flatMap(known);
|
|
28
|
+
if (edytor.blocks.has(block.type))
|
|
29
|
+
return [{ ...block, ...(block.children ? { children } : {}) }];
|
|
30
|
+
const own = block.type === 'divider' || (!block.content?.length && block.children?.length)
|
|
31
|
+
? []
|
|
32
|
+
: [{ type: 'paragraph', content: block.content ?? [] }];
|
|
33
|
+
return [...own, ...children];
|
|
34
|
+
};
|
|
35
|
+
const blocks = textToBlocks(text).flatMap(known);
|
|
36
|
+
return blocks.length ? { lines: blocks.map((block) => jsonBlockToSpec(block, true)) } : null;
|
|
37
|
+
};
|
|
15
38
|
export const flowOfFragment = (fragment) => fragment.kind === 'content'
|
|
16
39
|
? { lines: [run(fragment.content)] }
|
|
17
40
|
: {
|
|
@@ -19,5 +19,7 @@ export type TextToBlocksOptions = {
|
|
|
19
19
|
/** The kind of a paragraph (default `paragraph`). */
|
|
20
20
|
type?: string;
|
|
21
21
|
};
|
|
22
|
+
/** Whether `text` holds block markdown (one line of it is enough), as a paste reads it. */
|
|
23
|
+
export declare const hasBlockMarkdown: (text: string) => boolean;
|
|
22
24
|
/** `text` as blocks (see the module comment). */
|
|
23
25
|
export declare const textToBlocks: (text: string, { markdown, type }?: TextToBlocksOptions) => JSONBlock[];
|
|
@@ -21,6 +21,10 @@ const runs = (text) => {
|
|
|
21
21
|
out.push({ text: text.slice(at) });
|
|
22
22
|
return out;
|
|
23
23
|
};
|
|
24
|
+
/** A line that opens block markdown: a heading, a list or to-do item, a quote, a fence, a divider. */
|
|
25
|
+
const BLOCK_LINE = /^\s*(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>|```|(?:-{3,}|\*{3,}|_{3,})\s*$)/;
|
|
26
|
+
/** Whether `text` holds block markdown (one line of it is enough), as a paste reads it. */
|
|
27
|
+
export const hasBlockMarkdown = (text) => text.split(/\r?\n/).some((line) => BLOCK_LINE.test(line));
|
|
24
28
|
/** One line's kind, data and text, or `null` for a paragraph line. */
|
|
25
29
|
const lineKind = (line) => {
|
|
26
30
|
let m = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
@@ -278,6 +278,10 @@ export type DocumentRoomEnv = {
|
|
|
278
278
|
EDYTOR_ROOMS?: MoveNamespace;
|
|
279
279
|
/** `off`: the room keeps no comment threads (its comments requests answer `404`). */
|
|
280
280
|
EDYTOR_COMMENTS?: string;
|
|
281
|
+
/** The bytes the room's comments may hold ({@link CommentOptions.maxBytes}). */
|
|
282
|
+
EDYTOR_MAX_COMMENT_BYTES?: string | number;
|
|
283
|
+
/** Comment requests per second ({@link CommentOptions.maxRequestsPerSecond}). */
|
|
284
|
+
EDYTOR_MAX_COMMENT_REQUESTS_PER_SECOND?: string | number;
|
|
281
285
|
};
|
|
282
286
|
/** What a socket is bound to: its verified user, its replica (Yjs client id), its access. */
|
|
283
287
|
export type SocketIdentity = {
|
|
@@ -300,7 +304,7 @@ export type Attachment = SocketIdentity & {
|
|
|
300
304
|
comments?: boolean;
|
|
301
305
|
};
|
|
302
306
|
export type Refusal = {
|
|
303
|
-
reason: 'generation' | 'schema' | 'malformed' | 'container' | 'identity' | 'replica' | 'read-only' | 'waiting' | 'storage' | 'internal' | 'quota' | 'access' | 'presence' | 'mark' | 'forged' | 'denied' | 'orphan' | 'relayed' | 'history';
|
|
307
|
+
reason: 'generation' | 'schema' | 'malformed' | 'container' | 'identity' | 'replica' | 'read-only' | 'waiting' | 'storage' | 'internal' | 'quota' | 'access' | 'presence' | 'comments' | 'mark' | 'forged' | 'denied' | 'orphan' | 'relayed' | 'history';
|
|
304
308
|
detail: unknown;
|
|
305
309
|
};
|
|
306
310
|
/** A duration series: how many, their total and longest (ms; see {@link RoomMetrics}). */
|
|
@@ -490,7 +494,26 @@ export type CommentOptions = {
|
|
|
490
494
|
onComment?: (change: CommentChange) => void | Promise<void>;
|
|
491
495
|
/** The longest comment body (default and ceiling 10,000 characters). */
|
|
492
496
|
maxLength?: number;
|
|
497
|
+
/**
|
|
498
|
+
* The bytes the room's comments may hold (default
|
|
499
|
+
* {@link DEFAULT_MAX_COMMENT_BYTES}, 4 MiB): each body and quote in UTF-8
|
|
500
|
+
* plus 128 a comment. A thread or reply past it is refused `413`; a
|
|
501
|
+
* delete frees its bytes.
|
|
502
|
+
*/
|
|
503
|
+
maxBytes?: number;
|
|
504
|
+
/**
|
|
505
|
+
* Comment requests per second (default
|
|
506
|
+
* {@link DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND}, a ten-second burst):
|
|
507
|
+
* over HTTP per verified user (past it, `429`), and a socket's comment
|
|
508
|
+
* messages per socket (past it, dropped). RPC calls are the host's own and
|
|
509
|
+
* are not counted.
|
|
510
|
+
*/
|
|
511
|
+
maxRequestsPerSecond?: number;
|
|
493
512
|
};
|
|
513
|
+
/** The bytes a room's comments hold at most, by default (4 MiB). */
|
|
514
|
+
export declare const DEFAULT_MAX_COMMENT_BYTES: number;
|
|
515
|
+
/** Comment requests per second and user (or socket), by default. */
|
|
516
|
+
export declare const DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND = 2;
|
|
494
517
|
/** What {@link attachRoom} (and `DocumentRoom`) takes. */
|
|
495
518
|
export type AttachRoomOptions = {
|
|
496
519
|
/**
|
|
@@ -242,6 +242,10 @@ export const noTimers = (fn) => {
|
|
|
242
242
|
[g.setTimeout, g.setInterval] = saved;
|
|
243
243
|
}
|
|
244
244
|
};
|
|
245
|
+
/** The bytes a room's comments hold at most, by default (4 MiB). */
|
|
246
|
+
export const DEFAULT_MAX_COMMENT_BYTES = 4 * 1024 * 1024;
|
|
247
|
+
/** Comment requests per second and user (or socket), by default. */
|
|
248
|
+
export const DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND = 2;
|
|
245
249
|
/** `EDYTOR_PURGE_AFTER_DAYS`: `off` (or `false`) never purges; a number of days; anything else the default. */
|
|
246
250
|
const purgeAfterDays = (value) => {
|
|
247
251
|
if (value === 'off' || value === 'false' || value === false)
|
|
@@ -703,7 +707,11 @@ export class DocumentRoom extends DurableObject {
|
|
|
703
707
|
const locks = () => this.locks();
|
|
704
708
|
const comments = () => String(knobs.EDYTOR_COMMENTS ?? '') === 'off'
|
|
705
709
|
? false
|
|
706
|
-
: {
|
|
710
|
+
: {
|
|
711
|
+
onComment: (change) => this.onComment(change),
|
|
712
|
+
maxBytes: Number(knobs.EDYTOR_MAX_COMMENT_BYTES),
|
|
713
|
+
maxRequestsPerSecond: Number(knobs.EDYTOR_MAX_COMMENT_REQUESTS_PER_SECOND)
|
|
714
|
+
};
|
|
707
715
|
this.room = new AttachedDocument(ctx, {
|
|
708
716
|
maxRowBytes: Number(knobs.EDYTOR_MAX_ROW_BYTES),
|
|
709
717
|
maxFrameBytes: Number(knobs.EDYTOR_MAX_FRAME_BYTES),
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* `cloudflare:workers`); built on the Worker-safe CRDT entry. Documented in
|
|
5
5
|
* the site's `server/` section (room, authorization, extending, protocol).
|
|
6
6
|
*/
|
|
7
|
-
export { DocumentRoom, AttachedDocument, attachRoom, SOCKET_TAG, closedSocket, type AttachRoomOptions, type CommentOptions, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers, type Attachment, type DocumentRoomEnv, type LoadedDocument, type Refusal, type ReplicaOwner, type SavedDocument, type SocketIdentity, type StoredRecord, type FrameValidation, type ValidatedBlock, type RoomMetrics, type RoomLogEntry, type RestoreResult, type Timing } from './DocumentRoom.js';
|
|
7
|
+
export { DocumentRoom, AttachedDocument, attachRoom, SOCKET_TAG, closedSocket, type AttachRoomOptions, type CommentOptions, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers, type Attachment, type DocumentRoomEnv, type LoadedDocument, type Refusal, type ReplicaOwner, type SavedDocument, type SocketIdentity, type StoredRecord, type FrameValidation, type ValidatedBlock, type RoomMetrics, type RoomLogEntry, type RestoreResult, type Timing } from './DocumentRoom.js';
|
|
8
8
|
export { DEFAULT_RETENTION_DAYS, HISTORY_MAX_VALUE_BYTES, R2_HISTORY_MAX_VALUE_BYTES, ROOM_HISTORY_MAX_BYTES, ROOM_HISTORY_MAX_VALUE_BYTES, kvHistory, r2History, roomHistory, type HistoryEntry, type HistoryMetadata, type HistoryOptions, type HistoryRoomStorage, type HistoryStore, type HistoryStoreFactory, type HistoryStoreRecord, type KVLike, type R2BucketLike } from './history.js';
|
|
9
9
|
export { type PurgeReport } from '../crdt/purge.js';
|
|
10
|
-
export { COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, decideComment, type ThreadComment, type CommentActor, type CommentChange, type CommentOutcome, type CommentRefusal, type CommentRequest, type CommentRun, type CommentSnapshot, type CommentThread } from '../crdt/protocols/comments.js';
|
|
10
|
+
export { COMMENT_ROW_BYTES, COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, commentBytes, decideComment, type ThreadComment, type CommentActor, type CommentChange, type CommentOutcome, type CommentRefusal, type CommentRequest, type CommentRun, type CommentSnapshot, type CommentThread } from '../crdt/protocols/comments.js';
|
|
11
11
|
export { lockedBlocks, type LockOptions } from './locks.js';
|
|
12
12
|
export { moveBlocksBetweenRooms, forwardLateEdits, DEFAULT_MOVE_GRACE_DAYS, type CommitResult, type ExportedBlocks, type ImportReceipt, type ImportRequest, type LateEdit, type LateEditBatch, type MoveDestination, type MovedState, type MoveNamespace, type MoveRoom } from './move.js';
|
|
13
13
|
export { routeDocumentSocket, routeDocumentHistory, routeDocumentComments, requestedReplica, type AuthorizeDocumentSocket, type ExpiredCredential, type DocumentNamespace, type DocumentIdentity, type RouteDocumentOptions } from './routeDocumentSocket.js';
|
package/dist/cloudflare/index.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* `cloudflare:workers`); built on the Worker-safe CRDT entry. Documented in
|
|
5
5
|
* the site's `server/` section (room, authorization, extending, protocol).
|
|
6
6
|
*/
|
|
7
|
-
export { DocumentRoom, AttachedDocument, attachRoom, SOCKET_TAG, closedSocket, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers } from './DocumentRoom.js';
|
|
7
|
+
export { DocumentRoom, AttachedDocument, attachRoom, SOCKET_TAG, closedSocket, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers } from './DocumentRoom.js';
|
|
8
8
|
export { DEFAULT_RETENTION_DAYS, HISTORY_MAX_VALUE_BYTES, R2_HISTORY_MAX_VALUE_BYTES, ROOM_HISTORY_MAX_BYTES, ROOM_HISTORY_MAX_VALUE_BYTES, kvHistory, r2History, roomHistory } from './history.js';
|
|
9
9
|
export {} from '../crdt/purge.js';
|
|
10
|
-
export { COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, decideComment } from '../crdt/protocols/comments.js';
|
|
10
|
+
export { COMMENT_ROW_BYTES, COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, commentBytes, decideComment } from '../crdt/protocols/comments.js';
|
|
11
11
|
export { lockedBlocks } from './locks.js';
|
|
12
12
|
export { moveBlocksBetweenRooms, forwardLateEdits, DEFAULT_MOVE_GRACE_DAYS } from './move.js';
|
|
13
13
|
export { routeDocumentSocket, routeDocumentHistory, routeDocumentComments, requestedReplica } from './routeDocumentSocket.js';
|
|
@@ -2,16 +2,21 @@ import { type RoomContext } from './context.js';
|
|
|
2
2
|
/** The provider's keepalive text frame, and the room's answer. */
|
|
3
3
|
export declare const PING = "ping";
|
|
4
4
|
export declare const PONG = "pong";
|
|
5
|
-
/**
|
|
6
|
-
export type
|
|
5
|
+
/** A token bucket: its tokens, refilled since `at` (ms). */
|
|
6
|
+
export type Bucket = {
|
|
7
7
|
tokens: number;
|
|
8
8
|
at: number;
|
|
9
|
-
}
|
|
9
|
+
};
|
|
10
|
+
/** Each socket's allowance (a token bucket; memory: a wake refills it). */
|
|
11
|
+
export type Allowances = WeakMap<WebSocket, Bucket>;
|
|
10
12
|
/**
|
|
11
|
-
* A token bucket per socket, refilled at `perSecond` up to
|
|
12
|
-
* worth: takes one token. `false`: over the rate.
|
|
13
|
+
* A token bucket per key (a socket, a user), refilled at `perSecond` up to
|
|
14
|
+
* ten seconds' worth: takes one token. `false`: over the rate.
|
|
13
15
|
*/
|
|
14
|
-
export declare const allowance: (allowances:
|
|
16
|
+
export declare const allowance: <K>(allowances: {
|
|
17
|
+
get(key: K): Bucket | undefined;
|
|
18
|
+
set(key: K, bucket: Bucket): unknown;
|
|
19
|
+
}, key: K, perSecond: number, now?: number) => boolean;
|
|
15
20
|
export declare class Admission {
|
|
16
21
|
private readonly room;
|
|
17
22
|
/** Each socket's update allowance. */
|
|
@@ -32,19 +32,18 @@ export const PONG = 'pong';
|
|
|
32
32
|
/** Seconds of the update rate a socket may spend at once. */
|
|
33
33
|
const BURST_SECONDS = 10;
|
|
34
34
|
/**
|
|
35
|
-
* A token bucket per socket, refilled at `perSecond` up to
|
|
36
|
-
* worth: takes one token. `false`: over the rate.
|
|
35
|
+
* A token bucket per key (a socket, a user), refilled at `perSecond` up to
|
|
36
|
+
* ten seconds' worth: takes one token. `false`: over the rate.
|
|
37
37
|
*/
|
|
38
|
-
export const allowance = (allowances,
|
|
39
|
-
const now = Date.now();
|
|
38
|
+
export const allowance = (allowances, key, perSecond, now = Date.now()) => {
|
|
40
39
|
const burst = perSecond * BURST_SECONDS;
|
|
41
|
-
const held = allowances.get(
|
|
42
|
-
const tokens = Math.min(burst, held.tokens + ((now - held.at) / 1000) * perSecond);
|
|
40
|
+
const held = allowances.get(key) ?? { tokens: burst, at: now };
|
|
41
|
+
const tokens = Math.min(burst, held.tokens + (Math.max(0, now - held.at) / 1000) * perSecond);
|
|
43
42
|
if (tokens < 1) {
|
|
44
|
-
allowances.set(
|
|
43
|
+
allowances.set(key, { tokens, at: now });
|
|
45
44
|
return false;
|
|
46
45
|
}
|
|
47
|
-
allowances.set(
|
|
46
|
+
allowances.set(key, { tokens: tokens - 1, at: now });
|
|
48
47
|
return true;
|
|
49
48
|
};
|
|
50
49
|
/** A chunk sequence the room does not take at its start: past the rate, or past the room's buffer. */
|
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
import * as E from '../../crdt/protocol.js';
|
|
10
10
|
import { type CommentActor, type CommentOutcome, type CommentRequest, type CommentSnapshot } from '../../crdt/protocols/comments.js';
|
|
11
11
|
import { type Attachment, type SocketIdentity } from '../DocumentRoom.js';
|
|
12
|
-
import type
|
|
12
|
+
import { type RoomContext } from './context.js';
|
|
13
13
|
/** The largest request body `routeDocumentComments` forwards, in bytes. */
|
|
14
14
|
export declare const MAX_COMMENT_REQUEST_BYTES: number;
|
|
15
15
|
export declare class RoomComments {
|
|
16
16
|
private readonly room;
|
|
17
17
|
private ready;
|
|
18
|
+
/** Each verified user's HTTP request allowance (memory: a wake refills it). */
|
|
19
|
+
private readonly users;
|
|
20
|
+
/** Each socket's comment message allowance. */
|
|
21
|
+
private readonly sockets;
|
|
18
22
|
constructor(room: RoomContext);
|
|
19
23
|
/** Comments are on unless the room's `comments` option is `false`. */
|
|
20
24
|
get enabled(): boolean;
|
|
@@ -28,6 +32,16 @@ export declare class RoomComments {
|
|
|
28
32
|
private seq;
|
|
29
33
|
/** One thread as stored, or `null`. */
|
|
30
34
|
private read;
|
|
35
|
+
/**
|
|
36
|
+
* The bytes the comments hold, as the byte quota counts them
|
|
37
|
+
* (`commentBytes`: each body and quote in UTF-8, plus a row's cost).
|
|
38
|
+
*/
|
|
39
|
+
private bytes;
|
|
40
|
+
/**
|
|
41
|
+
* Whether `key` (a user over HTTP, a socket) is within the comment
|
|
42
|
+
* rate; past it the room notes it (`comments`, `quota: 'rate'`).
|
|
43
|
+
*/
|
|
44
|
+
private allow;
|
|
31
45
|
/** Every thread, oldest first, with the last sequence number. */
|
|
32
46
|
list(): CommentSnapshot;
|
|
33
47
|
/** Store `change` (one SQLite transaction with its sequence number). */
|
|
@@ -46,7 +60,9 @@ export declare class RoomComments {
|
|
|
46
60
|
* A socket's comment message (`messageComments`): a subscribe marks its
|
|
47
61
|
* attachment (it survives hibernation) and is answered with every
|
|
48
62
|
* thread; an unsubscribe clears it. Any other comment message from a
|
|
49
|
-
* client is malformed.
|
|
63
|
+
* client is malformed. A subscribe while subscribed answers nothing; a
|
|
64
|
+
* socket's comment messages past the comment rate are dropped (its
|
|
65
|
+
* subscription unchanged), the socket stays.
|
|
50
66
|
*/
|
|
51
67
|
onMessage(ws: WebSocket, attachment: Attachment, decoder: E.Decoder): void;
|
|
52
68
|
/**
|
|
@@ -7,13 +7,17 @@
|
|
|
7
7
|
* may do what) are `crdt/protocols/comments.ts`, shared with the clients.
|
|
8
8
|
*/
|
|
9
9
|
import * as E from '../../crdt/protocol.js';
|
|
10
|
-
import { COMMENT_MARK, COMMENT_STATUS, commentAnchors, decideComment, parseCommentRequest, validCommentId } from '../../crdt/protocols/comments.js';
|
|
11
|
-
import { SOCKET_TAG, noTimers } from '../DocumentRoom.js';
|
|
10
|
+
import { COMMENT_MARK, COMMENT_ROW_BYTES, COMMENT_STATUS, commentAnchors, decideComment, OVER_BYTE_QUOTA, parseCommentRequest, validCommentId } from '../../crdt/protocols/comments.js';
|
|
11
|
+
import { DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, SOCKET_TAG, noTimers } from '../DocumentRoom.js';
|
|
12
|
+
import { allowance } from './admission.js';
|
|
13
|
+
import { knob } from './context.js';
|
|
12
14
|
import { decode } from './frames.js';
|
|
13
15
|
/** The `meta` key holding the comments' last sequence number. */
|
|
14
16
|
const SEQ_KEY = 'comments';
|
|
15
17
|
/** The largest request body `routeDocumentComments` forwards, in bytes. */
|
|
16
18
|
export const MAX_COMMENT_REQUEST_BYTES = 64 * 1024;
|
|
19
|
+
/** The users whose request rate the room tracks at once (the oldest is forgotten past it). */
|
|
20
|
+
const MAX_RATE_USERS = 1_000;
|
|
17
21
|
const threadOf = (row, comments) => ({
|
|
18
22
|
id: row.id,
|
|
19
23
|
block: row.block,
|
|
@@ -35,6 +39,10 @@ const commentOf = (row) => ({
|
|
|
35
39
|
export class RoomComments {
|
|
36
40
|
room;
|
|
37
41
|
ready = false;
|
|
42
|
+
/** Each verified user's HTTP request allowance (memory: a wake refills it). */
|
|
43
|
+
users = new Map();
|
|
44
|
+
/** Each socket's comment message allowance. */
|
|
45
|
+
sockets = new WeakMap();
|
|
38
46
|
constructor(room) {
|
|
39
47
|
this.room = room;
|
|
40
48
|
}
|
|
@@ -95,6 +103,32 @@ export class RoomComments {
|
|
|
95
103
|
.map(commentOf);
|
|
96
104
|
return threadOf(row, comments);
|
|
97
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The bytes the comments hold, as the byte quota counts them
|
|
108
|
+
* (`commentBytes`: each body and quote in UTF-8, plus a row's cost).
|
|
109
|
+
*/
|
|
110
|
+
bytes() {
|
|
111
|
+
const { sql, tables } = this.room;
|
|
112
|
+
const comments = sql
|
|
113
|
+
.exec(`SELECT SUM(LENGTH(CAST(body AS BLOB))) AS bytes, COUNT(*) AS n FROM ${tables.comments}`)
|
|
114
|
+
.one();
|
|
115
|
+
const quotes = sql
|
|
116
|
+
.exec(`SELECT SUM(LENGTH(CAST(quote AS BLOB))) AS bytes FROM ${tables.threads}`)
|
|
117
|
+
.one().bytes ?? 0;
|
|
118
|
+
return (comments.bytes ?? 0) + quotes + comments.n * COMMENT_ROW_BYTES;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether `key` (a user over HTTP, a socket) is within the comment
|
|
122
|
+
* rate; past it the room notes it (`comments`, `quota: 'rate'`).
|
|
123
|
+
*/
|
|
124
|
+
allow(buckets, key, user) {
|
|
125
|
+
const room = this.room;
|
|
126
|
+
const limit = limitsOf(room).maxRequestsPerSecond;
|
|
127
|
+
if (allowance(buckets, key, limit, room.clock()))
|
|
128
|
+
return true;
|
|
129
|
+
room.note({ reason: 'comments', detail: { user, quota: 'rate', limit } });
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
98
132
|
/** Every thread, oldest first, with the last sequence number. */
|
|
99
133
|
list() {
|
|
100
134
|
if (!this.enabled)
|
|
@@ -158,13 +192,21 @@ export class RoomComments {
|
|
|
158
192
|
const threads = request.op === 'add'
|
|
159
193
|
? sql.exec(`SELECT COUNT(*) AS n FROM ${tables.threads}`).one().n
|
|
160
194
|
: 0;
|
|
195
|
+
const maxBytes = limitsOf(room).maxBytes;
|
|
161
196
|
const decided = decideComment(request, actor, {
|
|
162
197
|
thread,
|
|
163
198
|
threads,
|
|
164
199
|
now: room.clock(),
|
|
165
200
|
seq: this.seq() + 1,
|
|
166
|
-
maxLength: commentsOptions(room).maxLength
|
|
201
|
+
maxLength: commentsOptions(room).maxLength,
|
|
202
|
+
bytes: request.op === 'add' || request.op === 'reply' ? this.bytes() : undefined,
|
|
203
|
+
maxBytes
|
|
167
204
|
});
|
|
205
|
+
if (decided.status === 'refused' && decided.message === OVER_BYTE_QUOTA)
|
|
206
|
+
room.note({
|
|
207
|
+
reason: 'comments',
|
|
208
|
+
detail: { user: actor.user, quota: 'bytes', limit: maxBytes }
|
|
209
|
+
});
|
|
168
210
|
if (decided.status !== 'applied')
|
|
169
211
|
return decided;
|
|
170
212
|
this.store(decided.change);
|
|
@@ -232,7 +274,9 @@ export class RoomComments {
|
|
|
232
274
|
* A socket's comment message (`messageComments`): a subscribe marks its
|
|
233
275
|
* attachment (it survives hibernation) and is answered with every
|
|
234
276
|
* thread; an unsubscribe clears it. Any other comment message from a
|
|
235
|
-
* client is malformed.
|
|
277
|
+
* client is malformed. A subscribe while subscribed answers nothing; a
|
|
278
|
+
* socket's comment messages past the comment rate are dropped (its
|
|
279
|
+
* subscription unchanged), the socket stays.
|
|
236
280
|
*/
|
|
237
281
|
onMessage(ws, attachment, decoder) {
|
|
238
282
|
const room = this.room;
|
|
@@ -240,10 +284,11 @@ export class RoomComments {
|
|
|
240
284
|
if (message.type !== 'subscribe') {
|
|
241
285
|
return room.refuse(ws, { reason: 'malformed', detail: `comment message ${message.type}` });
|
|
242
286
|
}
|
|
243
|
-
if (!
|
|
244
|
-
return
|
|
287
|
+
if (!this.allow(this.sockets, ws, attachment.user))
|
|
288
|
+
return;
|
|
289
|
+
const was = attachment.comments === true;
|
|
245
290
|
ws.serializeAttachment({ ...attachment, comments: message.on });
|
|
246
|
-
if (!message.on)
|
|
291
|
+
if (!message.on || was)
|
|
247
292
|
return;
|
|
248
293
|
const snapshot = this.list();
|
|
249
294
|
room.send(ws, E.frame(E.messageComments, (e) => E.writeCommentsSnapshot(e, snapshot)));
|
|
@@ -257,6 +302,11 @@ export class RoomComments {
|
|
|
257
302
|
async request(op, identity, body) {
|
|
258
303
|
if (!this.enabled)
|
|
259
304
|
return new Response('no comments', { status: 404 });
|
|
305
|
+
const users = this.users;
|
|
306
|
+
if (!users.has(identity.user) && users.size >= MAX_RATE_USERS)
|
|
307
|
+
users.delete(users.keys().next().value);
|
|
308
|
+
if (!this.allow(users, identity.user, identity.user))
|
|
309
|
+
return new Response('too many comment requests', { status: 429 });
|
|
260
310
|
try {
|
|
261
311
|
if (op === 'list')
|
|
262
312
|
return Response.json(this.list());
|
|
@@ -291,3 +341,11 @@ const commentsOptions = (room) => {
|
|
|
291
341
|
const options = room.options.comments;
|
|
292
342
|
return options === false || options === undefined ? {} : options;
|
|
293
343
|
};
|
|
344
|
+
/** The room's comment quotas, defaults applied. */
|
|
345
|
+
const limitsOf = (room) => {
|
|
346
|
+
const options = commentsOptions(room);
|
|
347
|
+
return {
|
|
348
|
+
maxBytes: knob(options.maxBytes, DEFAULT_MAX_COMMENT_BYTES, 128 * 1024 * 1024),
|
|
349
|
+
maxRequestsPerSecond: knob(options.maxRequestsPerSecond, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, 1_000)
|
|
350
|
+
};
|
|
351
|
+
};
|
|
@@ -56,7 +56,7 @@ export declare const crdt: {
|
|
|
56
56
|
SchemaMismatchError: typeof E.SchemaMismatchError;
|
|
57
57
|
create: (doc: E.EngineDoc, config?: import("../../crdt/edytor-doc.js").EdytorDocConfig) => {
|
|
58
58
|
prepare: {
|
|
59
|
-
insertFlow: (target: import("../../
|
|
59
|
+
insertFlow: (target: import("../../index.js").FlowTarget, flow: import("../../index.js").Flow, view?: import("../../crdt/flow.js").FlowView) => import("../../index.js").Prepared;
|
|
60
60
|
deleteRange: (from: import("../../crdt/rangeDelete.js").DocPosition, to: import("../../crdt/rangeDelete.js").DocPosition, view?: import("../../crdt/rangeDelete.js").RangeView) => import("../../index.js").Prepared;
|
|
61
61
|
replaceRange: (from: import("../../crdt/rangeDelete.js").DocPosition, to: import("../../crdt/rangeDelete.js").DocPosition, view?: import("../../crdt/rangeDelete.js").RangeView) => import("../../index.js").Prepared;
|
|
62
62
|
insertTableRow: (table: import("../../crdt/index.js").BlockId, index: number) => import("../../index.js").Prepared;
|
|
@@ -93,7 +93,7 @@ export declare const crdt: {
|
|
|
93
93
|
children?: import("../../crdt/index.js").BlockSpec[];
|
|
94
94
|
}) => import("../../index.js").Prepared;
|
|
95
95
|
setBlockType: (id: import("../../crdt/index.js").BlockId, type: string) => import("../../index.js").Prepared;
|
|
96
|
-
patchData: (target: import("../../crdt/edytor-doc.js").DataTarget, patches: readonly import("../../
|
|
96
|
+
patchData: (target: import("../../crdt/edytor-doc.js").DataTarget, patches: readonly import("../../index.js").DataPatch[]) => import("../../index.js").Prepared;
|
|
97
97
|
setBlockData: (id: import("../../crdt/index.js").BlockId, data: Record<string, unknown>) => import("../../index.js").Prepared;
|
|
98
98
|
duplicateBlock: (id: import("../../crdt/index.js").BlockId, freshId: (oldId: string, kind: "block" | "inline") => string) => import("../../index.js").Prepared;
|
|
99
99
|
insertText: (id: import("../../crdt/index.js").BlockId, offset: number, text: string, marks?: Record<string, unknown>) => import("../../index.js").Prepared;
|
|
@@ -180,7 +180,7 @@ export declare const crdt: {
|
|
|
180
180
|
runsView: import("../../crdt/text/runs.js").RunView;
|
|
181
181
|
version: number;
|
|
182
182
|
block: (id: import("../../crdt/index.js").BlockId) => import("../../crdt/nodes.js").DocBlock;
|
|
183
|
-
insertFlow: (target: import("../../
|
|
183
|
+
insertFlow: (target: import("../../index.js").FlowTarget, flow: import("../../index.js").Flow, view?: import("../../crdt/flow.js").FlowView | undefined) => import("../../index.js").OpResult;
|
|
184
184
|
deleteRange: (from: import("../../crdt/rangeDelete.js").DocPosition, to: import("../../crdt/rangeDelete.js").DocPosition, view?: import("../../crdt/rangeDelete.js").RangeView | undefined) => import("../../index.js").OpResult;
|
|
185
185
|
replaceRange: (from: import("../../crdt/rangeDelete.js").DocPosition, to: import("../../crdt/rangeDelete.js").DocPosition, view?: import("../../crdt/rangeDelete.js").RangeView | undefined) => import("../../index.js").OpResult;
|
|
186
186
|
insertTableRow: (table: string, index: number) => import("../../index.js").OpResult;
|
|
@@ -217,7 +217,7 @@ export declare const crdt: {
|
|
|
217
217
|
children?: import("../../crdt/index.js").BlockSpec[];
|
|
218
218
|
}) => import("../../index.js").OpResult;
|
|
219
219
|
setBlockType: (id: string, type: string) => import("../../index.js").OpResult;
|
|
220
|
-
patchData: (target: import("../../crdt/edytor-doc.js").DataTarget, patches: readonly import("../../
|
|
220
|
+
patchData: (target: import("../../crdt/edytor-doc.js").DataTarget, patches: readonly import("../../index.js").DataPatch[]) => import("../../index.js").OpResult;
|
|
221
221
|
setBlockData: (id: string, data: Record<string, unknown>) => import("../../index.js").OpResult;
|
|
222
222
|
duplicateBlock: (id: string, freshId: (oldId: string, kind: "block" | "inline") => string) => import("../../index.js").OpResult;
|
|
223
223
|
insertText: (id: string, offset: number, text: string, marks?: Record<string, unknown> | undefined) => import("../../index.js").OpResult;
|
|
@@ -263,7 +263,7 @@ export declare const crdt: {
|
|
|
263
263
|
blockDataOf: (id: string) => Record<string, unknown> | undefined;
|
|
264
264
|
docData: () => import("../../crdt/doc/types.js").JsonObj;
|
|
265
265
|
dataItemIds: (target: import("../../crdt/edytor-doc.js").DataTarget, path: readonly string[]) => string[];
|
|
266
|
-
blockAttribution: (id: string) => import("../../
|
|
266
|
+
blockAttribution: (id: string) => import("../../index.js").BlockAttribution | undefined;
|
|
267
267
|
crdtId: (id: string) => string | null;
|
|
268
268
|
resolveBlock: (id: string) => E.EngineNode | null;
|
|
269
269
|
displayLength: (id: string) => number;
|
|
@@ -274,12 +274,12 @@ export declare const crdt: {
|
|
|
274
274
|
compare: (a: import("../../crdt/index.js").BlockId, b: import("../../crdt/index.js").BlockId) => number;
|
|
275
275
|
next: (id: string, policy?: import("../../crdt/edytor-doc.js").OrderPolicy | undefined) => string | null;
|
|
276
276
|
previous: (id: string, policy?: import("../../crdt/edytor-doc.js").OrderPolicy | undefined) => string | null;
|
|
277
|
-
anchorAt: (id: string, offset: number, affinity?: import("../../
|
|
278
|
-
resolveAnchor: (anchor: import("../../
|
|
277
|
+
anchorAt: (id: string, offset: number, affinity?: import("../../index.js").AnchorAffinity | undefined) => import("../../index.js").DocAnchor | null;
|
|
278
|
+
resolveAnchor: (anchor: import("../../index.js").DocAnchor) => {
|
|
279
279
|
blockId: import("../../crdt/index.js").BlockId;
|
|
280
280
|
offset: number;
|
|
281
281
|
} | null;
|
|
282
|
-
followUndo: (anchor: import("../../
|
|
282
|
+
followUndo: (anchor: import("../../index.js").DocAnchor) => import("../../index.js").DocAnchor;
|
|
283
283
|
rolesChanged: () => void;
|
|
284
284
|
isVoid: (id: string) => boolean;
|
|
285
285
|
isIsland: (id: string) => boolean;
|
|
@@ -404,9 +404,9 @@ export declare const crdt: {
|
|
|
404
404
|
};
|
|
405
405
|
};
|
|
406
406
|
providers: {
|
|
407
|
-
prefetch: (options: import("../../
|
|
408
|
-
lastUpdated: (options: import("../../
|
|
409
|
-
documentSnapshot: (options: import("../../
|
|
407
|
+
prefetch: (options: import("../../index.js").PrefetchOptions) => Promise<import("../../index.js").PrefetchResult>;
|
|
408
|
+
lastUpdated: (options: import("../../index.js").LastUpdatedOptions) => Promise<number | null>;
|
|
409
|
+
documentSnapshot: (options: import("../../index.js").LastUpdatedOptions) => Promise<JSONDoc | null>;
|
|
410
410
|
IndexeddbPersistence: {
|
|
411
411
|
new (name: string, doc: YDoc, options?: E.IndexeddbPersistenceOptions): {
|
|
412
412
|
doc: YDoc;
|
|
@@ -627,7 +627,7 @@ export declare const crdt: {
|
|
|
627
627
|
};
|
|
628
628
|
attribution: {
|
|
629
629
|
attach: (doc: E.EngineDoc, opts: {
|
|
630
|
-
actor: import("../../
|
|
630
|
+
actor: import("../../index.js").AttributionActor;
|
|
631
631
|
}) => import("../../crdt/index.js").AttributionController;
|
|
632
632
|
};
|
|
633
633
|
};
|
|
@@ -671,7 +671,7 @@ export declare const tally: (t: Timing, ms: number) => void;
|
|
|
671
671
|
export declare const encodeJSON: (value: unknown) => Uint8Array<ArrayBuffer>;
|
|
672
672
|
/** A semantics config as the facade's lookups — own keys only: block types come off the wire. */
|
|
673
673
|
declare const lookups: (semantics: DocumentSemanticsConfig) => {
|
|
674
|
-
roleOf: (type: string) => import("../../
|
|
674
|
+
roleOf: (type: string) => import("../../index.js").BlockRole | undefined;
|
|
675
675
|
kinds: () => string[];
|
|
676
676
|
defaultChildOf: (type: string) => string | undefined;
|
|
677
677
|
rendersContent: (type: string) => boolean;
|
|
@@ -88,7 +88,8 @@ export declare function routeDocumentHistory(request: Request, rooms: DocumentNa
|
|
|
88
88
|
* write, another user's comment, an `Origin` that `options.allowedOrigins`
|
|
89
89
|
* does not list), `404` (no such thread or comment, or the room keeps no
|
|
90
90
|
* comments), `405` (another method), `409` (a thread or comment id taken),
|
|
91
|
-
* `413` (a body over 64 KiB,
|
|
91
|
+
* `413` (a body over 64 KiB, too many threads or comments, or the room's
|
|
92
|
+
* comment bytes full), `429` (the user past the room's comment rate).
|
|
92
93
|
*/
|
|
93
94
|
export declare function routeDocumentComments(request: Request, rooms: DocumentNamespace, documentId: string, authorize: AuthorizeDocumentSocket, options?: RouteDocumentOptions): Promise<Response>;
|
|
94
95
|
export declare function routeDocumentSocket(request: Request, rooms: DocumentNamespace, documentId: string, authorize: AuthorizeDocumentSocket, options?: RouteDocumentOptions): Promise<Response>;
|
|
@@ -141,6 +141,35 @@ export async function routeDocumentHistory(request, rooms, documentId, authorize
|
|
|
141
141
|
}
|
|
142
142
|
return rooms.getByName(documentId).fetch(new Request(request.url, { method: 'GET', headers }));
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* A request's body as text, read no further than `max` bytes (`null`: it
|
|
146
|
+
* holds more, its declared length or not).
|
|
147
|
+
*/
|
|
148
|
+
const bodyWithin = async (request, max) => {
|
|
149
|
+
if (request.body === null)
|
|
150
|
+
return '';
|
|
151
|
+
const reader = request.body.getReader();
|
|
152
|
+
const chunks = [];
|
|
153
|
+
let length = 0;
|
|
154
|
+
for (;;) {
|
|
155
|
+
const { done, value } = await reader.read();
|
|
156
|
+
if (done)
|
|
157
|
+
break;
|
|
158
|
+
length += value.byteLength;
|
|
159
|
+
if (length > max) {
|
|
160
|
+
await reader.cancel().catch(() => { });
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
chunks.push(value);
|
|
164
|
+
}
|
|
165
|
+
const bytes = new Uint8Array(length);
|
|
166
|
+
let at = 0;
|
|
167
|
+
for (const chunk of chunks) {
|
|
168
|
+
bytes.set(chunk, at);
|
|
169
|
+
at += chunk.byteLength;
|
|
170
|
+
}
|
|
171
|
+
return new TextDecoder().decode(bytes);
|
|
172
|
+
};
|
|
144
173
|
/**
|
|
145
174
|
* The host Worker's door to a document's comment threads, authorized as
|
|
146
175
|
* {@link routeDocumentSocket} authorizes a dial, forwarding only the
|
|
@@ -158,7 +187,8 @@ export async function routeDocumentHistory(request, rooms, documentId, authorize
|
|
|
158
187
|
* write, another user's comment, an `Origin` that `options.allowedOrigins`
|
|
159
188
|
* does not list), `404` (no such thread or comment, or the room keeps no
|
|
160
189
|
* comments), `405` (another method), `409` (a thread or comment id taken),
|
|
161
|
-
* `413` (a body over 64 KiB,
|
|
190
|
+
* `413` (a body over 64 KiB, too many threads or comments, or the room's
|
|
191
|
+
* comment bytes full), `429` (the user past the room's comment rate).
|
|
162
192
|
*/
|
|
163
193
|
export async function routeDocumentComments(request, rooms, documentId, authorize, options) {
|
|
164
194
|
const op = request.method === 'GET' ? 'list' : request.method === 'POST' ? 'post' : null;
|
|
@@ -174,8 +204,8 @@ export async function routeDocumentComments(request, rooms, documentId, authoriz
|
|
|
174
204
|
const declared = Number(request.headers.get('Content-Length') ?? 0);
|
|
175
205
|
if (declared > MAX_COMMENT_REQUEST_BYTES)
|
|
176
206
|
return new Response('request too large', { status: 413 });
|
|
177
|
-
body = await request
|
|
178
|
-
if (
|
|
207
|
+
body = await bodyWithin(request, MAX_COMMENT_REQUEST_BYTES);
|
|
208
|
+
if (body === null)
|
|
179
209
|
return new Response('request too large', { status: 413 });
|
|
180
210
|
}
|
|
181
211
|
const headers = identityHeaders(identity);
|
|
@@ -52,7 +52,7 @@ export type EdytorAwarenessState = {
|
|
|
52
52
|
* Structural awareness surface used by `publishPresence` — the real
|
|
53
53
|
* `Awareness` satisfies it; tests may substitute stubs.
|
|
54
54
|
*/
|
|
55
|
-
type AwarenessLike = {
|
|
55
|
+
export type AwarenessLike = {
|
|
56
56
|
getLocalState: () => Record<string, unknown> | null;
|
|
57
57
|
setLocalState: (state: Record<string, unknown> | null) => void;
|
|
58
58
|
};
|
|
@@ -130,4 +130,3 @@ export declare const resolvePeerSelection: (edytor: Edytor, state: unknown) => {
|
|
|
130
130
|
} | {
|
|
131
131
|
blocks: string[];
|
|
132
132
|
} | null;
|
|
133
|
-
export {};
|