Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

565 linesCodeBlameRaw
1/**
2 * One folio's live room, as a Durable Object named by the folio id: the
3 * same socket protocol, hibernation, roles, save alarm and index alarm as
4 * a Docs page's room (src/room.ts, PageRoom), for every kind of folio
5 * through its kind's model (src/kinds/). The kind is kept in `meta`.
6 *
7 * It owns the folio's Yjs document: every editor's socket syncs with it
8 * (y-protocols sync and awareness, binary frames), and every change made
9 * on the server (an agent's edit, an accepted suggestion, a restore, a
10 * comment) is applied here, so all of them merge as one CRDT. A few
11 * seconds after a burst of edits (an alarm) it saves the kind's text
12 * rendition, card, links, citations and history to D1 (src/persist.ts
13 * `saveFolio`); half a minute after the text first changes, it brings
14 * the folio's passages in the index up to date (src/indexer.ts
15 * `indexFolio`).
16 *
17 * The room authorizes nothing about who may open the folio: the Worker
18 * checks the viewer's role before forwarding a socket
19 * (src/folios/service.ts, `live`) and puts it in ROOM_MEMBER_HEADER. The
20 * room enforces that role: a socket that may only view or comment never
21 * changes the document. Access changes reach open sockets through
22 * `setRole`; a socket whose access ended closes with 4403.
23 */
24import { DurableObject } from "cloudflare:workers";
25
26import type { DocEditTarget, DocRole, DocThreadAction, FolioAgentEdit, FolioKind, FoliosLiveEvent, MemberProfile, ServiceBinding } from "@g1t/contracts";
27import * as decoding from "lib0/decoding";
28import * as encoding from "lib0/encoding";
29import * as syncProtocol from "y-protocols/sync";
30import * as Y from "yjs";
31
32import { atLeast } from "../access.ts";
33import { anchorThread, unanchorThread } from "../edits.ts";
34import type { FileStoreEnv } from "../files.ts";
35import { indexFolio, type IndexEnv } from "../indexer.ts";
36import { docFragment, docTarget, docTargets } from "../kinds/doc/index.ts";
37import { kindModel } from "../kinds/index.ts";
38import type { AgentForm, FolioOrigin, KindModel } from "../kinds/types.ts";
39import { saveFolio } from "../persist.ts";
40import { ROOM_MEMBER_HEADER, awarenessEntries } from "../room.ts";
41import { applyThreadAction, listThreads, setQuote, type ThreadResult } from "../threads.ts";
42import { notifyFolioMentions } from "./service.ts";
43
44export { ROOM_MEMBER_HEADER };
45
46export type FolioRoomMember = {
47 folio_id: string;
48 workspace_slug: string;
49 /** `user:<id>`. */
50 key: string;
51 member: MemberProfile;
52 role: DocRole;
53};
54
55type Attachment = FolioRoomMember & { clients: number[] };
56
57export type FolioRoomEnv = IndexEnv &
58 FileStoreEnv & {
59 DB: D1Database;
60 NOTIFY?: ServiceBinding;
61 EVENTS?: ServiceBinding;
62 IDENTITY: ServiceBinding;
63 REPOS?: ServiceBinding;
64 /** The rooms themselves, as the Worker binds them (mentions are checked through the service). */
65 FOLIOS: DurableObjectNamespace<FolioRoom>;
66 };
67
68const MESSAGE_SYNC = 0;
69const MESSAGE_AWARENESS = 1;
70const MESSAGE_QUERY_AWARENESS = 3;
71/** Save this long after the last change. */
72const SAVE_AFTER_MS = 4_000;
73/** Index this long after the text first changed: at most twice a minute while someone types. */
74const INDEX_AFTER_MS = 30_000;
75/** Compact the stored updates into one snapshot past this many. */
76const COMPACT_AT = 300;
77
78export class FolioRoom extends DurableObject<FolioRoomEnv> {
79 private doc: Y.Doc | null = null;
80 /** Saves and index runs in flight: destroy() waits for them, so none writes after it. */
81 private saves = new Set<Promise<unknown>>();
82 /** The last awareness update each client sent. Lost on hibernation; clients resend every 15 s. */
83 private awareness = new Map<number, Uint8Array>();
84 private clocks = new Map<number, number>();
85
86 constructor(ctx: DurableObjectState, env: FolioRoomEnv) {
87 super(ctx, env);
88 ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
89 ctx.blockConcurrencyWhile(async () => {
90 this.ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS updates (seq INTEGER PRIMARY KEY AUTOINCREMENT, data BLOB NOT NULL)");
91 this.ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS snapshot (id INTEGER PRIMARY KEY CHECK (id = 1), data BLOB NOT NULL)");
92 this.ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
93 });
94 }
95
96 // ── State ──────────────────────────────────────────────────────────────
97
98 private meta<T>(key: string, fallback: T): T {
99 const row = this.ctx.storage.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = ?", key).toArray()[0];
100 if (!row) return fallback;
101 try {
102 return JSON.parse(row.value) as T;
103 } catch {
104 return fallback;
105 }
106 }
107
108 private setMeta(key: string, value: unknown): void {
109 if (key !== "erased" && this.meta("erased", false)) return;
110 this.ctx.storage.sql.exec("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", key, JSON.stringify(value));
111 }
112
113 /** The kind's model. Every room is named and given its kind by `ensure` (or a socket) before anything else. */
114 private model(): KindModel {
115 const model = kindModel(this.meta<string | null>("kind", null));
116 if (!model) throw new Error(`folio room ${this.meta<string>("folio_id", "?")} has no kind it knows`);
117 return model;
118 }
119
120 async forgetPerson(key: string, username: string): Promise<void> {
121 if (this.meta("erased", false)) return;
122 this.setMeta(`erased:${key}`, true);
123 await this.setRole(key, null);
124 await Promise.allSettled([...this.saves]);
125 for (const field of ["editors", "pending_authors"])
126 this.setMeta(
127 field,
128 this.meta<string[]>(field, []).filter((value) => value !== key),
129 );
130 this.setMeta(
131 "editor_names",
132 this.meta<string[]>("editor_names", []).filter((value) => value.toLowerCase() !== username.toLowerCase()),
133 );
134 }
135
136 async destroy(): Promise<void> {
137 // Synchronous tombstone comes first; no reentrant save can recreate state.
138 this.setMeta("erased", true);
139 // Open tabs hear the folio is gone, as before tombstones (4410, folio.trashed).
140 await this.closeAll("Deleted");
141 await Promise.allSettled([...this.saves]);
142 this.doc?.destroy();
143 this.doc = null;
144 this.awareness.clear();
145 this.clocks.clear();
146 this.ctx.storage.transactionSync(() => {
147 this.ctx.storage.sql.exec("DELETE FROM updates");
148 this.ctx.storage.sql.exec("DELETE FROM snapshot");
149 this.ctx.storage.sql.exec("DELETE FROM meta WHERE key != 'erased'");
150 });
151 await this.ctx.storage.deleteAlarm();
152 }
153
154 private load(): Y.Doc {
155 if (this.meta("erased", false)) throw new Error("Document erased");
156 if (this.doc) return this.doc;
157 const doc = new Y.Doc({ gc: true });
158 const snapshot = this.ctx.storage.sql.exec<{ data: ArrayBuffer }>("SELECT data FROM snapshot WHERE id = 1").toArray()[0];
159 if (snapshot) Y.applyUpdate(doc, new Uint8Array(snapshot.data));
160 for (const row of this.ctx.storage.sql.exec<{ data: ArrayBuffer }>("SELECT data FROM updates ORDER BY seq")) {
161 Y.applyUpdate(doc, new Uint8Array(row.data));
162 }
163 doc.on("update", (update: Uint8Array, origin: unknown) => this.onUpdate(update, origin));
164 this.doc = doc;
165 return doc;
166 }
167
168 private onUpdate(update: Uint8Array, origin: unknown): void {
169 if (this.meta("erased", false)) return;
170 this.ctx.storage.sql.exec("INSERT INTO updates (data) VALUES (?)", update);
171 const count = this.ctx.storage.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM updates").one().n;
172 if (count >= COMPACT_AT) this.compact();
173 const member = origin instanceof WebSocket ? (origin.deserializeAttachment() as Attachment | null) : null;
174 const key = member ? member.key : (origin as FolioOrigin | null)?.key;
175 if (member?.member.kind === "user") {
176 const names = this.meta<string[]>("editor_names", []);
177 const name = member.member.name.toLowerCase();
178 if (!names.includes(name)) this.setMeta("editor_names", [...names, name]);
179 }
180 if (key) {
181 const editors = this.meta<string[]>("editors", []).filter((k) => k !== key);
182 editors.push(key);
183 this.setMeta("editors", editors);
184 const pending = this.meta<string[]>("pending_authors", []);
185 if (!pending.includes(key)) this.setMeta("pending_authors", [...pending, key]);
186 }
187 const encoder = encoding.createEncoder();
188 encoding.writeVarUint(encoder, MESSAGE_SYNC);
189 syncProtocol.writeUpdate(encoder, update);
190 this.send(encoding.toUint8Array(encoder), origin instanceof WebSocket ? origin : null);
191 this.alarmBy(Date.now() + SAVE_AFTER_MS);
192 }
193
194 private compact(): void {
195 const doc = this.load();
196 this.ctx.storage.transactionSync(() => {
197 this.ctx.storage.sql.exec(
198 "INSERT INTO snapshot (id, data) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET data = excluded.data",
199 Y.encodeStateAsUpdate(doc),
200 );
201 this.ctx.storage.sql.exec("DELETE FROM updates");
202 });
203 }
204
205 private alarmBy(when: number): void {
206 void this.ctx.storage.getAlarm().then((at) => {
207 if (this.meta("erased", false)) return;
208 if (at == null || at > when) return this.ctx.storage.setAlarm(when);
209 });
210 }
211
212 private send(message: Uint8Array | string, except: WebSocket | null = null): void {
213 for (const socket of this.ctx.getWebSockets()) {
214 if (socket === except) continue;
215 try {
216 socket.send(message);
217 } catch {
218 // Closing already.
219 }
220 }
221 }
222
223 /** Saves to D1 now: the rendition, search, links, citations, and a version if one is due or asked for. */
224 private async persist(version: FolioOrigin | null = null): Promise<string | null> {
225 if (this.meta("erased", false)) return null;
226 const saving = this.save(version);
227 this.saves.add(saving);
228 try {
229 return await saving;
230 } finally {
231 this.saves.delete(saving);
232 }
233 }
234
235 private async save(version: FolioOrigin | null = null): Promise<string | null> {
236 const folioId = this.meta<string | null>("folio_id", null);
237 if (!folioId || !kindModel(this.meta<string | null>("kind", null))) return null;
238 const doc = this.load();
239 const pending = this.meta<string[]>("pending_authors", []);
240 const result = await saveFolio(this.env, {
241 folio_id: folioId,
242 rendition: this.model().render(doc),
243 editors: this.meta<string[]>("editors", []),
244 editor_names: this.meta<string[]>("editor_names", []),
245 state: Y.encodeStateAsUpdate(doc),
246 version: version ? { kind: version.kind, note: version.note, authors: version.authors ?? [version.key] } : null,
247 pending_authors: pending,
248 last_version_at: this.meta<number>("last_version_at", 0),
249 workspace_slug: this.meta<string | null>("workspace_slug", null),
250 });
251 this.setMeta("editors", []);
252 this.setMeta("editor_names", []);
253 if (result.changed && this.meta<number | null>("index_at", null) == null) {
254 const when = Date.now() + INDEX_AFTER_MS;
255 this.setMeta("index_at", when);
256 this.alarmBy(when);
257 }
258 if (result.version_id) {
259 this.setMeta("last_version_at", Date.now());
260 this.setMeta("pending_authors", []);
261 }
262 if (result.mentioned.length) {
263 // Told only if they can read it (the service checks), so this waits on nothing.
264 const slug = this.meta<string | null>("workspace_slug", null);
265 if (slug) {
266 this.ctx.waitUntil(
267 notifyFolioMentions(this.env, slug, folioId, result.mentioned, result.last).catch((error: unknown) =>
268 console.error("folios could not tell people they were mentioned", String(error)),
269 ),
270 );
271 }
272 }
273 return result.version_id;
274 }
275
276 override async alarm(): Promise<void> {
277 if (this.meta("erased", false)) return;
278 await this.persist();
279 const due = this.meta<number | null>("index_at", null);
280 if (due == null) return;
281 if (Date.now() < due) return this.alarmBy(due);
282 this.setMeta("index_at", null);
283 const folioId = this.meta<string | null>("folio_id", null);
284 if (folioId) {
285 const indexing = indexFolio(this.env, folioId);
286 this.saves.add(indexing);
287 try {
288 await indexing;
289 } finally {
290 this.saves.delete(indexing);
291 }
292 }
293 }
294
295 // ── Calls from the Worker ──────────────────────────────────────────────
296
297 /**
298 * Names the folio and its kind, and fills an empty document: from a
299 * Yjs state (a duplicate, or what D1 saved), or the kind's seed from
300 * text or a spec (a template, an agent's new folio, or blank). Returns
301 * whether the document has content afterwards: called with nothing to
302 * fill it with, an empty room says false and the service reads what
303 * was saved, so the usual open costs one call and no D1 read.
304 */
305 async ensure(init: {
306 folio_id: string;
307 kind: FolioKind;
308 workspace_slug: string;
309 text?: string | null;
310 spec?: unknown;
311 state?: Uint8Array | null;
312 }): Promise<boolean> {
313 this.setMeta("folio_id", init.folio_id);
314 this.setMeta("kind", init.kind);
315 if (init.workspace_slug) this.setMeta("workspace_slug", init.workspace_slug);
316 const doc = this.load();
317 const model = this.model();
318 if (!model.isEmpty(doc)) return true;
319 if (init.text === undefined && init.spec === undefined && init.state == null) return false;
320 // No origin: filling a new room is its "created" version (written by the service), nobody's edit.
321 if (init.state) Y.applyUpdate(doc, init.state);
322 else doc.transact(() => model.seed(doc, { text: init.text ?? null, spec: init.spec }));
323 return true;
324 }
325
326 /** The folio in its agent form. */
327 async read(): Promise<AgentForm> {
328 return this.model().read(this.load());
329 }
330
331 /** The text rendition now (unsaved edits included). */
332 async text(): Promise<string> {
333 return this.model().render(this.load()).text;
334 }
335
336 /** The whole document's state, for a duplicate. */
337 async state(): Promise<Uint8Array> {
338 return Y.encodeStateAsUpdate(this.load());
339 }
340
341 /** A doc's target now, or null when it is gone (doc kind only). */
342 async target(target: DocEditTarget): Promise<{ markdown: string; block_ids: string[] } | null> {
343 if (this.model().kind !== "doc") return null;
344 return docTarget(this.load(), target);
345 }
346
347 async targets(targets: DocEditTarget[]): Promise<(string[] | null)[]> {
348 if (this.model().kind !== "doc") return targets.map(() => null);
349 return docTargets(this.load(), targets);
350 }
351
352 /** Applies an edit in the kind's terms and records a version for it. */
353 async edit(edit: FolioAgentEdit, origin: FolioOrigin): Promise<{ applied: boolean; version_id: string | null; summary: string }> {
354 const doc = this.load();
355 const model = this.model();
356 const result = model.applyAgentEdit(doc, edit, origin);
357 if (!result.applied) return { applied: false, version_id: null, summary: result.summary };
358 const version_id = await this.persist({ ...origin, note: origin.note ?? result.summary });
359 return { applied: true, version_id, summary: result.summary };
360 }
361
362 /** Makes the document what a version's was, as a new version. */
363 async restore(input: { state: Uint8Array | null; text: string }, origin: FolioOrigin): Promise<string | null> {
364 const doc = this.load();
365 const model = this.model();
366 if (input.state) {
367 const old = new Y.Doc();
368 Y.applyUpdate(old, input.state);
369 model.restore(doc, old, origin);
370 } else model.restoreText(doc, input.text, origin);
371 return this.persist(origin);
372 }
373
374 /** A comment operation from `actor` with `role`. Anchoring in the text is the doc kind's. */
375 async thread(actor: string, role: DocRole, action: DocThreadAction): Promise<ThreadResult> {
376 const doc = this.load();
377 const isDoc = this.model().kind === "doc";
378 if (action.op === "anchor") {
379 if (!atLeast(role, "comment")) return { ok: false, code: "forbidden", message: "You can read this but not comment on it." };
380 if (!isDoc) return { ok: false, code: "invalid", message: "Comments on this kind of artifact are pinned, not anchored in text." };
381 const thread = doc.getMap<Y.Map<unknown>>("threads").get(action.thread_id);
382 if (!thread) return { ok: false, code: "not_found", message: "That thread is gone." };
383 const quote = anchorThread(doc, docFragment(doc), action.anchor, action.head, action.thread_id);
384 if (quote) setQuote(doc, action.thread_id, quote);
385 return { ok: true, value: { quote } };
386 }
387 const result = applyThreadAction(doc, actor, role, action);
388 if (result.ok && action.op === "delete_thread" && isDoc) unanchorThread(doc, docFragment(doc), action.thread_id);
389 return result;
390 }
391
392 async threads(): Promise<ReturnType<typeof listThreads>> {
393 return listThreads(this.load());
394 }
395
396 /** Tells everyone with the folio open. */
397 async notice(event: FoliosLiveEvent): Promise<void> {
398 this.send(JSON.stringify(event));
399 }
400
401 /** Who has it open: each socket's member key and username, for re-checking access. */
402 async members(): Promise<{ key: string; name: string }[]> {
403 const out = new Map<string, string>();
404 for (const socket of this.ctx.getWebSockets()) {
405 const who = socket.deserializeAttachment() as Attachment | null;
406 if (who) out.set(who.key, who.member.name);
407 }
408 return [...out].map(([key, name]) => ({ key, name }));
409 }
410
411 /** A member's role changed (null: they can no longer read it): their sockets follow, or close with 4403. */
412 async setRole(key: string, role: DocRole | null): Promise<void> {
413 for (const socket of this.ctx.getWebSockets(key)) {
414 const who = socket.deserializeAttachment() as Attachment | null;
415 if (!who) continue;
416 if (!role) {
417 try {
418 socket.send(JSON.stringify({ type: "access", role: null } satisfies FoliosLiveEvent));
419 socket.close(4403, "No longer allowed");
420 } catch {
421 // Already closed.
422 }
423 continue;
424 }
425 if (who.role === role) continue;
426 socket.serializeAttachment({ ...who, role });
427 try {
428 socket.send(JSON.stringify({ type: "access", role } satisfies FoliosLiveEvent));
429 } catch {
430 // Closing.
431 }
432 }
433 }
434
435 /** Saves now, as before a version list, an export or the trash. */
436 async flush(): Promise<void> {
437 if (this.doc) await this.persist();
438 }
439
440 /** Closes every socket: the folio went to the trash. */
441 async closeAll(reason: string): Promise<void> {
442 for (const socket of this.ctx.getWebSockets()) {
443 try {
444 socket.send(JSON.stringify({ type: "folio.trashed", folio_id: this.meta<string>("folio_id", "") } satisfies FoliosLiveEvent));
445 socket.close(4410, reason);
446 } catch {
447 // Already closed.
448 }
449 }
450 }
451
452 /** Forgets everything: the folio was deleted for good. */
453
454 // ── Sockets ────────────────────────────────────────────────────────────
455
456 override async fetch(request: Request): Promise<Response> {
457 if (this.meta("erased", false)) return new Response("Document erased", { status: 410 });
458 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 });
459 let who: FolioRoomMember;
460 try {
461 who = JSON.parse(request.headers.get(ROOM_MEMBER_HEADER) ?? "") as FolioRoomMember;
462 } catch {
463 return new Response("Missing member\n", { status: 400 });
464 }
465 if (!kindModel(this.meta<string | null>("kind", null))) return new Response("Not ready\n", { status: 409 });
466 if (this.meta(`erased:${who.key}`, false)) return new Response("Account erased", { status: 410 });
467 if (who.workspace_slug) this.setMeta("workspace_slug", who.workspace_slug);
468 const pair = new WebSocketPair();
469 const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
470 this.ctx.acceptWebSocket(server, [who.key]);
471 server.serializeAttachment({ ...who, clients: [] } satisfies Attachment);
472 const doc = this.load();
473 const encoder = encoding.createEncoder();
474 encoding.writeVarUint(encoder, MESSAGE_SYNC);
475 syncProtocol.writeSyncStep1(encoder, doc);
476 server.send(encoding.toUint8Array(encoder));
477 for (const update of this.awareness.values()) {
478 const e = encoding.createEncoder();
479 encoding.writeVarUint(e, MESSAGE_AWARENESS);
480 encoding.writeVarUint8Array(e, update);
481 server.send(encoding.toUint8Array(e));
482 }
483 return new Response(null, { status: 101, webSocket: client });
484 }
485
486 override async webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise<void> {
487 if (typeof message === "string") return;
488 const who = socket.deserializeAttachment() as Attachment | null;
489 if (!who || this.meta("erased", false) || this.meta(`erased:${who.key}`, false)) return;
490 const data = new Uint8Array(message);
491 const decoder = decoding.createDecoder(data);
492 const type = decoding.readVarUint(decoder);
493 if (type === MESSAGE_SYNC) {
494 // Viewers and commenters may ask for the document (step 1) but never change it.
495 const peek = decoding.createDecoder(data);
496 decoding.readVarUint(peek);
497 const step = decoding.readVarUint(peek);
498 if (step !== syncProtocol.messageYjsSyncStep1 && !atLeast(who.role, "edit")) return;
499 const doc = this.load();
500 const encoder = encoding.createEncoder();
501 encoding.writeVarUint(encoder, MESSAGE_SYNC);
502 syncProtocol.readSyncMessage(decoder, encoder, doc, socket);
503 if (encoding.length(encoder) > 1) socket.send(encoding.toUint8Array(encoder));
504 return;
505 }
506 if (type === MESSAGE_AWARENESS) {
507 const update = decoding.readVarUint8Array(decoder);
508 const entries = awarenessEntries(update);
509 const clients = entries.map((e) => e.id);
510 for (const e of entries) {
511 this.clocks.set(e.id, e.clock);
512 if (e.gone) this.awareness.delete(e.id);
513 else this.awareness.set(e.id, update);
514 }
515 const known = new Set(who.clients);
516 if (clients.some((id) => !known.has(id))) socket.serializeAttachment({ ...who, clients: [...new Set([...who.clients, ...clients])] });
517 const encoder = encoding.createEncoder();
518 encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
519 encoding.writeVarUint8Array(encoder, update);
520 this.send(encoding.toUint8Array(encoder), socket);
521 return;
522 }
523 if (type === MESSAGE_QUERY_AWARENESS) {
524 for (const update of this.awareness.values()) {
525 const encoder = encoding.createEncoder();
526 encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
527 encoding.writeVarUint8Array(encoder, update);
528 socket.send(encoding.toUint8Array(encoder));
529 }
530 }
531 }
532
533 override async webSocketClose(socket: WebSocket, code: number, reason: string): Promise<void> {
534 this.leave(socket);
535 try {
536 socket.close(code, reason);
537 } catch {
538 // Already closed.
539 }
540 }
541
542 override async webSocketError(socket: WebSocket): Promise<void> {
543 this.leave(socket);
544 }
545
546 private leave(socket: WebSocket): void {
547 const who = socket.deserializeAttachment() as Attachment | null;
548 if (!who?.clients.length) return;
549 for (const id of who.clients) this.awareness.delete(id);
550 const gone = who.clients.filter((id) => this.clocks.has(id));
551 if (!gone.length) return;
552 const encoder = encoding.createEncoder();
553 encoding.writeVarUint(encoder, gone.length);
554 for (const id of gone) {
555 encoding.writeVarUint(encoder, id);
556 encoding.writeVarUint(encoder, this.clocks.get(id)! + 1);
557 encoding.writeVarString(encoder, "null");
558 this.clocks.delete(id);
559 }
560 const message = encoding.createEncoder();
561 encoding.writeVarUint(message, MESSAGE_AWARENESS);
562 encoding.writeVarUint8Array(message, encoding.toUint8Array(encoder));
563 this.send(encoding.toUint8Array(message), socket);
564 }
565}