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.

537 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"]) this.setMeta(field, this.meta<string[]>(field, []).filter(value => value !== key));
126 this.setMeta("editor_names", this.meta<string[]>("editor_names", []).filter(value => value.toLowerCase() !== username.toLowerCase()));
127 }
128
129 async destroy(): Promise<void> {
130 // Synchronous tombstone comes first; no reentrant save can recreate state.
131 this.setMeta("erased", true);
132 // Open tabs hear the folio is gone, as before tombstones (4410, folio.trashed).
133 await this.closeAll("Deleted");
134 await Promise.allSettled([...this.saves]);
135 this.doc?.destroy();
136 this.doc = null;
137 this.awareness.clear();
138 this.clocks.clear();
139 this.ctx.storage.transactionSync(() => {
140 this.ctx.storage.sql.exec("DELETE FROM updates");
141 this.ctx.storage.sql.exec("DELETE FROM snapshot");
142 this.ctx.storage.sql.exec("DELETE FROM meta WHERE key != 'erased'");
143 });
144 await this.ctx.storage.deleteAlarm();
145 }
146
147 private load(): Y.Doc {
148 if (this.meta("erased", false)) throw new Error("Document erased");
149 if (this.doc) return this.doc;
150 const doc = new Y.Doc({ gc: true });
151 const snapshot = this.ctx.storage.sql.exec<{ data: ArrayBuffer }>("SELECT data FROM snapshot WHERE id = 1").toArray()[0];
152 if (snapshot) Y.applyUpdate(doc, new Uint8Array(snapshot.data));
153 for (const row of this.ctx.storage.sql.exec<{ data: ArrayBuffer }>("SELECT data FROM updates ORDER BY seq")) {
154 Y.applyUpdate(doc, new Uint8Array(row.data));
155 }
156 doc.on("update", (update: Uint8Array, origin: unknown) => this.onUpdate(update, origin));
157 this.doc = doc;
158 return doc;
159 }
160
161 private onUpdate(update: Uint8Array, origin: unknown): void {
162 if (this.meta("erased", false)) return;
163 this.ctx.storage.sql.exec("INSERT INTO updates (data) VALUES (?)", update);
164 const count = this.ctx.storage.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM updates").one().n;
165 if (count >= COMPACT_AT) this.compact();
166 const member = origin instanceof WebSocket ? (origin.deserializeAttachment() as Attachment | null) : null;
167 const key = member ? member.key : (origin as FolioOrigin | null)?.key;
168 if (member?.member.kind === "user") {
169 const names = this.meta<string[]>("editor_names", []);
170 const name = member.member.name.toLowerCase();
171 if (!names.includes(name)) this.setMeta("editor_names", [...names, name]);
172 }
173 if (key) {
174 const editors = this.meta<string[]>("editors", []).filter((k) => k !== key);
175 editors.push(key);
176 this.setMeta("editors", editors);
177 const pending = this.meta<string[]>("pending_authors", []);
178 if (!pending.includes(key)) this.setMeta("pending_authors", [...pending, key]);
179 }
180 const encoder = encoding.createEncoder();
181 encoding.writeVarUint(encoder, MESSAGE_SYNC);
182 syncProtocol.writeUpdate(encoder, update);
183 this.send(encoding.toUint8Array(encoder), origin instanceof WebSocket ? origin : null);
184 this.alarmBy(Date.now() + SAVE_AFTER_MS);
185 }
186
187 private compact(): void {
188 const doc = this.load();
189 this.ctx.storage.transactionSync(() => {
190 this.ctx.storage.sql.exec("INSERT INTO snapshot (id, data) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET data = excluded.data", Y.encodeStateAsUpdate(doc));
191 this.ctx.storage.sql.exec("DELETE FROM updates");
192 });
193 }
194
195 private alarmBy(when: number): void {
196 void this.ctx.storage.getAlarm().then((at) => {
197 if (this.meta("erased", false)) return;
198 if (at == null || at > when) return this.ctx.storage.setAlarm(when);
199 });
200 }
201
202 private send(message: Uint8Array | string, except: WebSocket | null = null): void {
203 for (const socket of this.ctx.getWebSockets()) {
204 if (socket === except) continue;
205 try {
206 socket.send(message);
207 } catch {
208 // Closing already.
209 }
210 }
211 }
212
213 /** Saves to D1 now: the rendition, search, links, citations, and a version if one is due or asked for. */
214 private async persist(version: FolioOrigin | null = null): Promise<string | null> {
215 if (this.meta("erased", false)) return null;
216 const saving = this.save(version);
217 this.saves.add(saving);
218 try { return await saving; } finally { this.saves.delete(saving); }
219 }
220
221 private async save(version: FolioOrigin | null = null): Promise<string | null> {
222 const folioId = this.meta<string | null>("folio_id", null);
223 if (!folioId || !kindModel(this.meta<string | null>("kind", null))) return null;
224 const doc = this.load();
225 const pending = this.meta<string[]>("pending_authors", []);
226 const result = await saveFolio(this.env, {
227 folio_id: folioId,
228 rendition: this.model().render(doc),
229 editors: this.meta<string[]>("editors", []),
230 editor_names: this.meta<string[]>("editor_names", []),
231 state: Y.encodeStateAsUpdate(doc),
232 version: version ? { kind: version.kind, note: version.note, authors: version.authors ?? [version.key] } : null,
233 pending_authors: pending,
234 last_version_at: this.meta<number>("last_version_at", 0),
235 workspace_slug: this.meta<string | null>("workspace_slug", null),
236 });
237 this.setMeta("editors", []);
238 this.setMeta("editor_names", []);
239 if (result.changed && this.meta<number | null>("index_at", null) == null) {
240 const when = Date.now() + INDEX_AFTER_MS;
241 this.setMeta("index_at", when);
242 this.alarmBy(when);
243 }
244 if (result.version_id) {
245 this.setMeta("last_version_at", Date.now());
246 this.setMeta("pending_authors", []);
247 }
248 if (result.mentioned.length) {
249 // Told only if they can read it (the service checks), so this waits on nothing.
250 const slug = this.meta<string | null>("workspace_slug", null);
251 if (slug) {
252 this.ctx.waitUntil(notifyFolioMentions(this.env, slug, folioId, result.mentioned, result.last).catch((error: unknown) => console.error("folios could not tell people they were mentioned", String(error))));
253 }
254 }
255 return result.version_id;
256 }
257
258 override async alarm(): Promise<void> {
259 if (this.meta("erased", false)) return;
260 await this.persist();
261 const due = this.meta<number | null>("index_at", null);
262 if (due == null) return;
263 if (Date.now() < due) return this.alarmBy(due);
264 this.setMeta("index_at", null);
265 const folioId = this.meta<string | null>("folio_id", null);
266 if (folioId) {
267 const indexing = indexFolio(this.env, folioId);
268 this.saves.add(indexing);
269 try { await indexing; } finally { this.saves.delete(indexing); }
270 }
271 }
272
273 // ── Calls from the Worker ──────────────────────────────────────────────
274
275 /**
276 * Names the folio and its kind, and fills an empty document: from a
277 * Yjs state (a duplicate, or what D1 saved), or the kind's seed from
278 * text or a spec (a template, an agent's new folio, or blank). Returns
279 * whether the document has content afterwards: called with nothing to
280 * fill it with, an empty room says false and the service reads what
281 * was saved, so the usual open costs one call and no D1 read.
282 */
283 async ensure(init: { folio_id: string; kind: FolioKind; workspace_slug: string; text?: string | null; spec?: unknown; state?: Uint8Array | null }): Promise<boolean> {
284 this.setMeta("folio_id", init.folio_id);
285 this.setMeta("kind", init.kind);
286 if (init.workspace_slug) this.setMeta("workspace_slug", init.workspace_slug);
287 const doc = this.load();
288 const model = this.model();
289 if (!model.isEmpty(doc)) return true;
290 if (init.text === undefined && init.spec === undefined && init.state == null) return false;
291 // No origin: filling a new room is its "created" version (written by the service), nobody's edit.
292 if (init.state) Y.applyUpdate(doc, init.state);
293 else doc.transact(() => model.seed(doc, { text: init.text ?? null, spec: init.spec }));
294 return true;
295 }
296
297 /** The folio in its agent form. */
298 async read(): Promise<AgentForm> {
299 return this.model().read(this.load());
300 }
301
302 /** The text rendition now (unsaved edits included). */
303 async text(): Promise<string> {
304 return this.model().render(this.load()).text;
305 }
306
307 /** The whole document's state, for a duplicate. */
308 async state(): Promise<Uint8Array> {
309 return Y.encodeStateAsUpdate(this.load());
310 }
311
312 /** A doc's target now, or null when it is gone (doc kind only). */
313 async target(target: DocEditTarget): Promise<{ markdown: string; block_ids: string[] } | null> {
314 if (this.model().kind !== "doc") return null;
315 return docTarget(this.load(), target);
316 }
317
318 async targets(targets: DocEditTarget[]): Promise<(string[] | null)[]> {
319 if (this.model().kind !== "doc") return targets.map(() => null);
320 return docTargets(this.load(), targets);
321 }
322
323 /** Applies an edit in the kind's terms and records a version for it. */
324 async edit(edit: FolioAgentEdit, origin: FolioOrigin): Promise<{ applied: boolean; version_id: string | null; summary: string }> {
325 const doc = this.load();
326 const model = this.model();
327 const result = model.applyAgentEdit(doc, edit, origin);
328 if (!result.applied) return { applied: false, version_id: null, summary: result.summary };
329 const version_id = await this.persist({ ...origin, note: origin.note ?? result.summary });
330 return { applied: true, version_id, summary: result.summary };
331 }
332
333 /** Makes the document what a version's was, as a new version. */
334 async restore(input: { state: Uint8Array | null; text: string }, origin: FolioOrigin): Promise<string | null> {
335 const doc = this.load();
336 const model = this.model();
337 if (input.state) {
338 const old = new Y.Doc();
339 Y.applyUpdate(old, input.state);
340 model.restore(doc, old, origin);
341 } else model.restoreText(doc, input.text, origin);
342 return this.persist(origin);
343 }
344
345 /** A comment operation from `actor` with `role`. Anchoring in the text is the doc kind's. */
346 async thread(actor: string, role: DocRole, action: DocThreadAction): Promise<ThreadResult> {
347 const doc = this.load();
348 const isDoc = this.model().kind === "doc";
349 if (action.op === "anchor") {
350 if (!atLeast(role, "comment")) return { ok: false, code: "forbidden", message: "You can read this but not comment on it." };
351 if (!isDoc) return { ok: false, code: "invalid", message: "Comments on this kind of artifact are pinned, not anchored in text." };
352 const thread = doc.getMap<Y.Map<unknown>>("threads").get(action.thread_id);
353 if (!thread) return { ok: false, code: "not_found", message: "That thread is gone." };
354 const quote = anchorThread(doc, docFragment(doc), action.anchor, action.head, action.thread_id);
355 if (quote) setQuote(doc, action.thread_id, quote);
356 return { ok: true, value: { quote } };
357 }
358 const result = applyThreadAction(doc, actor, role, action);
359 if (result.ok && action.op === "delete_thread" && isDoc) unanchorThread(doc, docFragment(doc), action.thread_id);
360 return result;
361 }
362
363 async threads(): Promise<ReturnType<typeof listThreads>> {
364 return listThreads(this.load());
365 }
366
367 /** Tells everyone with the folio open. */
368 async notice(event: FoliosLiveEvent): Promise<void> {
369 this.send(JSON.stringify(event));
370 }
371
372 /** Who has it open: each socket's member key and username, for re-checking access. */
373 async members(): Promise<{ key: string; name: string }[]> {
374 const out = new Map<string, string>();
375 for (const socket of this.ctx.getWebSockets()) {
376 const who = socket.deserializeAttachment() as Attachment | null;
377 if (who) out.set(who.key, who.member.name);
378 }
379 return [...out].map(([key, name]) => ({ key, name }));
380 }
381
382 /** A member's role changed (null: they can no longer read it): their sockets follow, or close with 4403. */
383 async setRole(key: string, role: DocRole | null): Promise<void> {
384 for (const socket of this.ctx.getWebSockets(key)) {
385 const who = socket.deserializeAttachment() as Attachment | null;
386 if (!who) continue;
387 if (!role) {
388 try {
389 socket.send(JSON.stringify({ type: "access", role: null } satisfies FoliosLiveEvent));
390 socket.close(4403, "No longer allowed");
391 } catch {
392 // Already closed.
393 }
394 continue;
395 }
396 if (who.role === role) continue;
397 socket.serializeAttachment({ ...who, role });
398 try {
399 socket.send(JSON.stringify({ type: "access", role } satisfies FoliosLiveEvent));
400 } catch {
401 // Closing.
402 }
403 }
404 }
405
406 /** Saves now, as before a version list, an export or the trash. */
407 async flush(): Promise<void> {
408 if (this.doc) await this.persist();
409 }
410
411 /** Closes every socket: the folio went to the trash. */
412 async closeAll(reason: string): Promise<void> {
413 for (const socket of this.ctx.getWebSockets()) {
414 try {
415 socket.send(JSON.stringify({ type: "folio.trashed", folio_id: this.meta<string>("folio_id", "") } satisfies FoliosLiveEvent));
416 socket.close(4410, reason);
417 } catch {
418 // Already closed.
419 }
420 }
421 }
422
423 /** Forgets everything: the folio was deleted for good. */
424
425
426 // ── Sockets ────────────────────────────────────────────────────────────
427
428 override async fetch(request: Request): Promise<Response> {
429 if (this.meta("erased", false)) return new Response("Document erased", { status: 410 });
430 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 });
431 let who: FolioRoomMember;
432 try {
433 who = JSON.parse(request.headers.get(ROOM_MEMBER_HEADER) ?? "") as FolioRoomMember;
434 } catch {
435 return new Response("Missing member\n", { status: 400 });
436 }
437 if (!kindModel(this.meta<string | null>("kind", null))) return new Response("Not ready\n", { status: 409 });
438 if (this.meta(`erased:${who.key}`, false)) return new Response("Account erased", { status: 410 });
439 if (who.workspace_slug) this.setMeta("workspace_slug", who.workspace_slug);
440 const pair = new WebSocketPair();
441 const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
442 this.ctx.acceptWebSocket(server, [who.key]);
443 server.serializeAttachment({ ...who, clients: [] } satisfies Attachment);
444 const doc = this.load();
445 const encoder = encoding.createEncoder();
446 encoding.writeVarUint(encoder, MESSAGE_SYNC);
447 syncProtocol.writeSyncStep1(encoder, doc);
448 server.send(encoding.toUint8Array(encoder));
449 for (const update of this.awareness.values()) {
450 const e = encoding.createEncoder();
451 encoding.writeVarUint(e, MESSAGE_AWARENESS);
452 encoding.writeVarUint8Array(e, update);
453 server.send(encoding.toUint8Array(e));
454 }
455 return new Response(null, { status: 101, webSocket: client });
456 }
457
458 override async webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise<void> {
459 if (typeof message === "string") return;
460 const who = socket.deserializeAttachment() as Attachment | null;
461 if (!who || this.meta("erased", false) || this.meta(`erased:${who.key}`, false)) return;
462 const data = new Uint8Array(message);
463 const decoder = decoding.createDecoder(data);
464 const type = decoding.readVarUint(decoder);
465 if (type === MESSAGE_SYNC) {
466 // Viewers and commenters may ask for the document (step 1) but never change it.
467 const peek = decoding.createDecoder(data);
468 decoding.readVarUint(peek);
469 const step = decoding.readVarUint(peek);
470 if (step !== syncProtocol.messageYjsSyncStep1 && !atLeast(who.role, "edit")) return;
471 const doc = this.load();
472 const encoder = encoding.createEncoder();
473 encoding.writeVarUint(encoder, MESSAGE_SYNC);
474 syncProtocol.readSyncMessage(decoder, encoder, doc, socket);
475 if (encoding.length(encoder) > 1) socket.send(encoding.toUint8Array(encoder));
476 return;
477 }
478 if (type === MESSAGE_AWARENESS) {
479 const update = decoding.readVarUint8Array(decoder);
480 const entries = awarenessEntries(update);
481 const clients = entries.map((e) => e.id);
482 for (const e of entries) {
483 this.clocks.set(e.id, e.clock);
484 if (e.gone) this.awareness.delete(e.id);
485 else this.awareness.set(e.id, update);
486 }
487 const known = new Set(who.clients);
488 if (clients.some((id) => !known.has(id))) socket.serializeAttachment({ ...who, clients: [...new Set([...who.clients, ...clients])] });
489 const encoder = encoding.createEncoder();
490 encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
491 encoding.writeVarUint8Array(encoder, update);
492 this.send(encoding.toUint8Array(encoder), socket);
493 return;
494 }
495 if (type === MESSAGE_QUERY_AWARENESS) {
496 for (const update of this.awareness.values()) {
497 const encoder = encoding.createEncoder();
498 encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
499 encoding.writeVarUint8Array(encoder, update);
500 socket.send(encoding.toUint8Array(encoder));
501 }
502 }
503 }
504
505 override async webSocketClose(socket: WebSocket, code: number, reason: string): Promise<void> {
506 this.leave(socket);
507 try {
508 socket.close(code, reason);
509 } catch {
510 // Already closed.
511 }
512 }
513
514 override async webSocketError(socket: WebSocket): Promise<void> {
515 this.leave(socket);
516 }
517
518 private leave(socket: WebSocket): void {
519 const who = socket.deserializeAttachment() as Attachment | null;
520 if (!who?.clients.length) return;
521 for (const id of who.clients) this.awareness.delete(id);
522 const gone = who.clients.filter((id) => this.clocks.has(id));
523 if (!gone.length) return;
524 const encoder = encoding.createEncoder();
525 encoding.writeVarUint(encoder, gone.length);
526 for (const id of gone) {
527 encoding.writeVarUint(encoder, id);
528 encoding.writeVarUint(encoder, this.clocks.get(id)! + 1);
529 encoding.writeVarString(encoder, "null");
530 this.clocks.delete(id);
531 }
532 const message = encoding.createEncoder();
533 encoding.writeVarUint(message, MESSAGE_AWARENESS);
534 encoding.writeVarUint8Array(message, encoding.toUint8Array(encoder));
535 this.send(encoding.toUint8Array(message), socket);
536 }
537}