@echovisionlab/geul-common 0.4.0 → 0.6.0

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.
@@ -0,0 +1,609 @@
1
+ /**
2
+ * Collaboration control-plane protocol v3: the single definition of every Hocuspocus stateless
3
+ * message exchanged by the editor (geul-web) and the collaboration server (geul-editor-collab).
4
+ * Specification: docs/collaboration-protocol.md. Both sides parse with these schemas; neither
5
+ * defines its own message shapes.
6
+ *
7
+ * Typed bodies (`document`, `presentLocaleValues`, metadata payload values, form schemas) are
8
+ * protobuf or domain JSON validated by their owning modules after the envelope is accepted.
9
+ */
10
+ import * as z from "zod/mini";
11
+ import { editorRuntimeEventSchema } from "./runtime-events.ts";
12
+
13
+ export const COLLABORATION_PROTOCOL_VERSION = 3 as const;
14
+ /** Upper bound for a client control frame; bootstrap is the only large message and is server-sent. */
15
+ export const MAX_CLIENT_CONTROL_MESSAGE_BYTES = 16 * 1024;
16
+ /** `form.schema.patch` carries the previous and next full schemas, so it gets its own bound. */
17
+ export const MAX_FORM_SCHEMA_PATCH_MESSAGE_BYTES = 1024 * 1024;
18
+
19
+ /**
20
+ * Connection parameters naming the typed body a client already holds (the SSR read). Collab omits
21
+ * `room.bootstrap.document` only when both the revision and the body digest match its own body.
22
+ */
23
+ export const KNOWN_DOCUMENT_REVISION_PARAM = "knownDocumentRevision";
24
+ export const KNOWN_DOCUMENT_DIGEST_PARAM = "knownDocumentDigest";
25
+
26
+ /**
27
+ * 64-bit non-cryptographic digest (cyrb64) of a typed body's protobuf binary. It only decides
28
+ * whether a body may be omitted; the client still verifies the Yjs update against the body it
29
+ * holds, so a collision costs a reconnect, never correctness.
30
+ */
31
+ export function documentBodyDigest(bytes: Uint8Array): string {
32
+ let h1 = 0xdeadbeef ^ bytes.length;
33
+ let h2 = 0x41c6ce57 ^ bytes.length;
34
+ for (const byte of bytes) {
35
+ h1 = Math.imul(h1 ^ byte, 2654435761);
36
+ h2 = Math.imul(h2 ^ byte, 1597334677);
37
+ }
38
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
39
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
40
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
41
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
42
+ return (
43
+ (h2 >>> 0).toString(16).padStart(8, "0") +
44
+ (h1 >>> 0).toString(16).padStart(8, "0")
45
+ );
46
+ }
47
+
48
+ const v = z.literal(COLLABORATION_PROTOCOL_VERSION);
49
+ const nonEmpty = z.string().check(z.trim(), z.minLength(1));
50
+ const uuid = z.string().check(z.uuid());
51
+ const base64 = z
52
+ .string()
53
+ .check(
54
+ z.regex(
55
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u,
56
+ ),
57
+ );
58
+ /** Opaque target-locale revision token issued by the API. */
59
+ export const targetRevisionSchema = z
60
+ .string()
61
+ .check(
62
+ z.refine(
63
+ (value) => value.trim().length > 0,
64
+ "Target revision must be a nonblank opaque token.",
65
+ ),
66
+ );
67
+ const json = z.unknown();
68
+
69
+ export const blockRoomDocumentTypeSchema = z.enum([
70
+ "post",
71
+ "page",
72
+ "work",
73
+ "program-event",
74
+ "artist",
75
+ "label",
76
+ "release",
77
+ "campaign",
78
+ "email-template",
79
+ "terms-history",
80
+ "privacy-history",
81
+ ]);
82
+
83
+ export const reloadReasonSchema = z.enum([
84
+ "deploy_or_restart",
85
+ "epoch_mismatch",
86
+ "external_update",
87
+ "conflict",
88
+ "permission",
89
+ "state_mismatch",
90
+ ]);
91
+ export type ReloadReason = z.infer<typeof reloadReasonSchema>;
92
+
93
+ export const accessEndedReasonSchema = z.enum([
94
+ "permission_revoked",
95
+ "session_expired",
96
+ ]);
97
+ export type AccessEndedReason = z.infer<typeof accessEndedReasonSchema>;
98
+
99
+ /**
100
+ * Reasons a refused admission is temporary (the Hocuspocus permission-denied frame carries the
101
+ * reason): the collab host is stopping or could not reach authorization. The client keeps its
102
+ * state and retries with backoff; any other unknown reason is not retried.
103
+ */
104
+ export const admissionRetryReasonSchema = z.enum([
105
+ "service_stopping",
106
+ "authorization_unavailable",
107
+ ]);
108
+ export type AdmissionRetryReason = z.infer<typeof admissionRetryReasonSchema>;
109
+
110
+ export function isAdmissionRetryReason(
111
+ reason: string,
112
+ ): reason is AdmissionRetryReason {
113
+ return admissionRetryReasonSchema.safeParse(reason).success;
114
+ }
115
+
116
+ export const localeMetadataSchema = z.strictObject({
117
+ locale: nonEmpty,
118
+ title: z.optional(z.string()),
119
+ summary: z.optional(z.string()),
120
+ subject: z.optional(z.string()),
121
+ creditNotes: z.optional(
122
+ z.array(z.strictObject({ creditId: nonEmpty, note: z.string() })),
123
+ ),
124
+ });
125
+ export type LocaleMetadata = z.infer<typeof localeMetadataSchema>;
126
+
127
+ export const metadataOperationSchema = z.enum([
128
+ "locale",
129
+ "document",
130
+ "page_layout",
131
+ ]);
132
+ export type MetadataOperation = z.infer<typeof metadataOperationSchema>;
133
+
134
+ export const metadataUpdateSchema = z.strictObject({
135
+ operation: metadataOperationSchema,
136
+ values: z.record(z.string(), json),
137
+ sequence: z.number().check(z.int(), z.positive()),
138
+ });
139
+ export type MetadataUpdate = z.infer<typeof metadataUpdateSchema>;
140
+
141
+ export const metadataAckSchema = z.strictObject({
142
+ documentRevision: uuid,
143
+ targetRevision: z.optional(targetRevisionSchema),
144
+ changed: z.boolean(),
145
+ sourceChanged: z.boolean(),
146
+ changedLocales: z.array(z.string()),
147
+ locale: nonEmpty,
148
+ metadataUpdate: metadataUpdateSchema,
149
+ });
150
+ export type MetadataAck = z.infer<typeof metadataAckSchema>;
151
+
152
+ /** Yjs coverage a durable save acknowledges: client clocks plus deleted ranges. */
153
+ export const durabilityCoverageSchema = z.strictObject({
154
+ stateVector: base64,
155
+ deleted: z.record(
156
+ z.string().check(z.regex(/^\d+$/u)),
157
+ z.array(
158
+ z.strictObject({
159
+ clock: z.number().check(z.int(), z.nonnegative()),
160
+ len: z.number().check(z.int(), z.positive()),
161
+ }),
162
+ ),
163
+ ),
164
+ });
165
+ export type DurabilityCoverage = z.infer<typeof durabilityCoverageSchema>;
166
+
167
+ export const roomRevisionSchema = z.strictObject({
168
+ documentRevision: uuid,
169
+ targetRevision: z.optional(targetRevisionSchema),
170
+ sourceLocale: nonEmpty,
171
+ locale: nonEmpty,
172
+ localeExists: z.boolean(),
173
+ });
174
+ export type RoomRevision = z.infer<typeof roomRevisionSchema>;
175
+
176
+ export const protocolErrorSchema = z.strictObject({
177
+ code: nonEmpty,
178
+ message: z.optional(z.string()),
179
+ reloadReason: z.optional(reloadReasonSchema),
180
+ /**
181
+ * false: repeating the same request cannot succeed (permission, validation, missing entity), so
182
+ * the client stops retrying and keeps the intent as a recoverable backup. Absent means retryable.
183
+ */
184
+ retryable: z.optional(z.boolean()),
185
+ });
186
+ export type ProtocolError = z.infer<typeof protocolErrorSchema>;
187
+
188
+ function result<
189
+ Kind extends string,
190
+ Value extends Record<string, z.core.$ZodType>,
191
+ >(kind: Kind, value: Value) {
192
+ return z.discriminatedUnion("ok", [
193
+ z.strictObject({
194
+ v,
195
+ kind: z.literal(kind),
196
+ requestId: uuid,
197
+ ok: z.literal(true),
198
+ ...value,
199
+ }),
200
+ z.strictObject({
201
+ v,
202
+ kind: z.literal(kind),
203
+ requestId: uuid,
204
+ ok: z.literal(false),
205
+ error: protocolErrorSchema,
206
+ }),
207
+ ]);
208
+ }
209
+
210
+ // ---- Client → server -------------------------------------------------------------------------
211
+
212
+ export const bootstrapAckSchema = z.strictObject({
213
+ v,
214
+ kind: z.literal("room.bootstrap_ack"),
215
+ challenge: nonEmpty,
216
+ stateVector: base64,
217
+ });
218
+ export const metadataRequestSchema = z.strictObject({
219
+ v,
220
+ kind: z.literal("room.metadata"),
221
+ requestId: uuid,
222
+ operation: metadataOperationSchema,
223
+ payload: json,
224
+ });
225
+ export const revisionRequestSchema = z.strictObject({
226
+ v,
227
+ kind: z.literal("room.revision"),
228
+ requestId: uuid,
229
+ });
230
+ export const flushRequestSchema = z.strictObject({
231
+ v,
232
+ kind: z.literal("room.flush"),
233
+ requestId: uuid,
234
+ });
235
+ export const formSchemaPatchRequestSchema = z.strictObject({
236
+ v,
237
+ kind: z.literal("form.schema.patch"),
238
+ requestId: uuid,
239
+ previousSchema: json,
240
+ nextSchema: json,
241
+ });
242
+
243
+ export const clientMessageSchema = z.discriminatedUnion("kind", [
244
+ bootstrapAckSchema,
245
+ metadataRequestSchema,
246
+ revisionRequestSchema,
247
+ flushRequestSchema,
248
+ formSchemaPatchRequestSchema,
249
+ ]);
250
+ export type ClientMessage = z.infer<typeof clientMessageSchema>;
251
+
252
+ // ---- Server → client -------------------------------------------------------------------------
253
+
254
+ export const bootstrapSchema = z.strictObject({
255
+ v,
256
+ kind: z.literal("room.bootstrap"),
257
+ bootstrapChallenge: nonEmpty,
258
+ documentName: nonEmpty,
259
+ documentType: blockRoomDocumentTypeSchema,
260
+ /** Omitted when the connection's known revision and body digest match this body. */
261
+ document: z.optional(json),
262
+ documentRevision: uuid,
263
+ sourceLocale: nonEmpty,
264
+ locale: nonEmpty,
265
+ localeExists: z.boolean(),
266
+ targetRevision: z.optional(targetRevisionSchema),
267
+ presentLocaleValues: z.array(json),
268
+ sourceMetadata: localeMetadataSchema,
269
+ localeMetadata: z.optional(localeMetadataSchema),
270
+ documentMetadata: z.record(z.string(), json),
271
+ metadataSequence: z.number().check(z.int(), z.nonnegative()),
272
+ blockCatalogFingerprint: nonEmpty,
273
+ serverInstanceId: nonEmpty,
274
+ roomEpoch: uuid,
275
+ yjsBootstrapUpdate: base64,
276
+ });
277
+ export type BootstrapMessage = z.infer<typeof bootstrapSchema>;
278
+
279
+ export const readySchema = z.strictObject({
280
+ v,
281
+ kind: z.literal("room.ready"),
282
+ bootstrapChallenge: nonEmpty,
283
+ });
284
+ export const metadataResultSchema = result("room.metadata_result", {
285
+ ack: metadataAckSchema,
286
+ });
287
+ export const revisionResultSchema = result("room.revision_result", {
288
+ revision: roomRevisionSchema,
289
+ });
290
+ export const flushResultSchema = result("room.flush_result", {
291
+ persisted: durabilityCoverageSchema,
292
+ });
293
+ export const formSchemaPatchResultSchema = result("form.schema.patch_result", {
294
+ canonicalSchema: json,
295
+ });
296
+ export const persistedSchema = z.strictObject({
297
+ v,
298
+ kind: z.literal("room.persisted"),
299
+ documentName: nonEmpty,
300
+ ...durabilityCoverageSchema.shape,
301
+ });
302
+ export const metadataChangedSchema = z.strictObject({
303
+ v,
304
+ kind: z.literal("room.metadata_changed"),
305
+ documentName: nonEmpty,
306
+ ack: metadataAckSchema,
307
+ });
308
+ export const formSchemaChangedSchema = z.strictObject({
309
+ v,
310
+ kind: z.literal("form.schema.changed"),
311
+ documentName: nonEmpty,
312
+ canonicalSchema: json,
313
+ });
314
+ export const reloadSchema = z.strictObject({
315
+ v,
316
+ kind: z.literal("room.reload"),
317
+ reloadReason: reloadReasonSchema,
318
+ });
319
+ /**
320
+ * The connection's access level changed while it stays open: `view` makes it read-only (edits are
321
+ * no longer accepted), `edit` restores editing. Access that ends entirely is `room.access_ended`.
322
+ */
323
+ export const accessLevelSchema = z.enum(["view", "edit"]);
324
+ export type AccessLevel = z.infer<typeof accessLevelSchema>;
325
+ export const accessChangedSchema = z.strictObject({
326
+ v,
327
+ kind: z.literal("room.access_changed"),
328
+ access: accessLevelSchema,
329
+ });
330
+ export const accessEndedSchema = z.strictObject({
331
+ v,
332
+ kind: z.literal("room.access_ended"),
333
+ reason: accessEndedReasonSchema,
334
+ });
335
+ export const entityChangedSchema = z.strictObject({
336
+ v,
337
+ kind: z.literal("entity.changed"),
338
+ /** `{type}:{entityUUID}` of the resource. */
339
+ entity: nonEmpty,
340
+ scope: z.enum(["metadata", "document", "locale"]),
341
+ /** The authoritative document revision after the change. */
342
+ revision: z.optional(uuid),
343
+ locale: z.optional(nonEmpty),
344
+ /** Opaque per-tab write origin; never an authentication identity. */
345
+ origin: z.optional(nonEmpty),
346
+ });
347
+ export type EntityChangedMessage = z.infer<typeof entityChangedSchema>;
348
+ export const interactiveMutationBeginSchema = z.strictObject({
349
+ v,
350
+ kind: z.literal("interactive_mutation.begin"),
351
+ mutationId: uuid,
352
+ });
353
+ export const interactiveMutationEndSchema = z.strictObject({
354
+ v,
355
+ kind: z.literal("interactive_mutation.end"),
356
+ mutationId: uuid,
357
+ outcome: z.enum(["accepted", "aborted"]),
358
+ });
359
+
360
+ /** Translation, OG, media and ingest status, carried unchanged inside the v3 envelope. */
361
+ export const runtimeEventMessageSchema = z.strictObject({
362
+ v,
363
+ kind: z.literal("runtime.event"),
364
+ event: editorRuntimeEventSchema,
365
+ });
366
+
367
+ // The two-branch result unions are matched by kind before their `ok` discriminator.
368
+ const serverSingleSchemas = [
369
+ bootstrapSchema,
370
+ readySchema,
371
+ persistedSchema,
372
+ metadataChangedSchema,
373
+ formSchemaChangedSchema,
374
+ reloadSchema,
375
+ accessChangedSchema,
376
+ accessEndedSchema,
377
+ entityChangedSchema,
378
+ interactiveMutationBeginSchema,
379
+ interactiveMutationEndSchema,
380
+ runtimeEventMessageSchema,
381
+ ] as const;
382
+ const serverResultSchemas = {
383
+ "room.metadata_result": metadataResultSchema,
384
+ "room.revision_result": revisionResultSchema,
385
+ "room.flush_result": flushResultSchema,
386
+ "form.schema.patch_result": formSchemaPatchResultSchema,
387
+ } as const;
388
+
389
+ /** The result kind answering each client request kind. */
390
+ const resultKindByRequestKind = new Map<
391
+ string,
392
+ keyof typeof serverResultSchemas
393
+ >([
394
+ ["room.metadata", "room.metadata_result"],
395
+ ["room.revision", "room.revision_result"],
396
+ ["room.flush", "room.flush_result"],
397
+ ["form.schema.patch", "form.schema.patch_result"],
398
+ ]);
399
+
400
+ export type ServerMessage =
401
+ | z.infer<(typeof serverSingleSchemas)[number]>
402
+ | z.infer<(typeof serverResultSchemas)[keyof typeof serverResultSchemas]>;
403
+ export type ServerMessageKind = ServerMessage["kind"];
404
+ export type ServerMessageOf<Kind extends ServerMessageKind> = Extract<
405
+ ServerMessage,
406
+ { kind: Kind }
407
+ >;
408
+ export type ClientMessageOf<Kind extends ClientMessage["kind"]> = Extract<
409
+ ClientMessage,
410
+ { kind: Kind }
411
+ >;
412
+
413
+ function kindOf(schema: { shape: { kind: z.ZodMiniLiteral<string> } }): string {
414
+ return schema.shape.kind.def.values[0] as string;
415
+ }
416
+ // Maps, not object literals: an inherited key such as "constructor" must not resolve to a schema.
417
+ const serverSchemaByKind = new Map<string, z.ZodMiniType>([
418
+ ...serverSingleSchemas.map(
419
+ (schema) => [kindOf(schema), schema] as [string, z.ZodMiniType],
420
+ ),
421
+ ...Object.entries(serverResultSchemas),
422
+ ]);
423
+ const clientMaxBytesByKind = new Map<string, number>([
424
+ [kindOf(bootstrapAckSchema), MAX_CLIENT_CONTROL_MESSAGE_BYTES],
425
+ [kindOf(metadataRequestSchema), MAX_CLIENT_CONTROL_MESSAGE_BYTES],
426
+ [kindOf(revisionRequestSchema), MAX_CLIENT_CONTROL_MESSAGE_BYTES],
427
+ [kindOf(flushRequestSchema), MAX_CLIENT_CONTROL_MESSAGE_BYTES],
428
+ [kindOf(formSchemaPatchRequestSchema), MAX_FORM_SCHEMA_PATCH_MESSAGE_BYTES],
429
+ ]);
430
+ const MAX_ANY_CLIENT_CONTROL_MESSAGE_BYTES = Math.max(
431
+ ...clientMaxBytesByKind.values(),
432
+ );
433
+
434
+ /**
435
+ * `requestId` is present when a recognized request was rejected, so the receiver can answer it
436
+ * with a failed result (see `encodeRequestRejection`) instead of leaving the sender waiting.
437
+ */
438
+ export type ParseFailure =
439
+ | { ok: false; reason: "invalid_json" | "not_an_object" }
440
+ | { ok: false; reason: "too_large"; kind?: string; requestId?: string }
441
+ | { ok: false; reason: "unsupported_version"; version: unknown }
442
+ | { ok: false; reason: "unknown_kind"; kind: unknown }
443
+ | {
444
+ ok: false;
445
+ reason: "invalid_shape";
446
+ kind: string;
447
+ issues: string;
448
+ requestId?: string;
449
+ };
450
+
451
+ /** UTF-8 byte length, computed only when the UTF-16 length cannot already decide the bound. */
452
+ function exceedsBytes(payload: string, maxBytes: number): boolean {
453
+ if (payload.length * 3 <= maxBytes) return false;
454
+ if (payload.length > maxBytes) return true;
455
+ return new TextEncoder().encode(payload).byteLength > maxBytes;
456
+ }
457
+
458
+ function requestIdOf(record: Record<string, unknown>): string | undefined {
459
+ return typeof record.requestId === "string" ? record.requestId : undefined;
460
+ }
461
+
462
+ function readEnvelope(
463
+ payload: string,
464
+ maxBytes?: number,
465
+ ): { ok: true; record: Record<string, unknown>; kind: unknown } | ParseFailure {
466
+ if (maxBytes !== undefined && exceedsBytes(payload, maxBytes)) {
467
+ return { ok: false, reason: "too_large" };
468
+ }
469
+ let value: unknown;
470
+ try {
471
+ value = JSON.parse(payload);
472
+ } catch {
473
+ return { ok: false, reason: "invalid_json" };
474
+ }
475
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
476
+ return { ok: false, reason: "not_an_object" };
477
+ }
478
+ const record = value as Record<string, unknown>;
479
+ if (record.v !== COLLABORATION_PROTOCOL_VERSION) {
480
+ return { ok: false, reason: "unsupported_version", version: record.v };
481
+ }
482
+ return { ok: true, record, kind: record.kind };
483
+ }
484
+
485
+ function issues(error: z.core.$ZodError): string {
486
+ return error.issues
487
+ .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
488
+ .join("; ");
489
+ }
490
+
491
+ type ParseResult<Message> = { ok: true; message: Message } | ParseFailure;
492
+
493
+ /**
494
+ * Several independent listeners receive every stateless frame (save tracking, runtime events,
495
+ * entity changes, ...). Remembering the last frame lets them share one parse of the same payload.
496
+ * Parsed messages are shared between listeners and must be treated as read-only.
497
+ */
498
+ function lastPayloadMemo<Message>(
499
+ parse: (payload: string) => ParseResult<Message>,
500
+ ): (payload: string) => ParseResult<Message> {
501
+ let lastPayload: string | undefined;
502
+ let lastResult: ParseResult<Message> | undefined;
503
+ return (payload) => {
504
+ if (payload !== lastPayload || !lastResult) {
505
+ lastResult = parse(payload);
506
+ lastPayload = payload;
507
+ }
508
+ return lastResult;
509
+ };
510
+ }
511
+
512
+ /** Parse a client→server control frame. Never throws. */
513
+ export const parseClientMessage = lastPayloadMemo(
514
+ (payload): ParseResult<ClientMessage> => {
515
+ const envelope = readEnvelope(
516
+ payload,
517
+ MAX_ANY_CLIENT_CONTROL_MESSAGE_BYTES,
518
+ );
519
+ if (!envelope.ok) return envelope;
520
+ const kind = envelope.kind;
521
+ const maxBytes =
522
+ typeof kind === "string" ? clientMaxBytesByKind.get(kind) : undefined;
523
+ if (maxBytes === undefined)
524
+ return { ok: false, reason: "unknown_kind", kind };
525
+ const requestId = requestIdOf(envelope.record);
526
+ if (exceedsBytes(payload, maxBytes)) {
527
+ return {
528
+ ok: false,
529
+ reason: "too_large",
530
+ kind: kind as string,
531
+ requestId,
532
+ };
533
+ }
534
+ const parsed = clientMessageSchema.safeParse(envelope.record);
535
+ if (parsed.success) return { ok: true, message: parsed.data };
536
+ return {
537
+ ok: false,
538
+ reason: "invalid_shape",
539
+ kind: kind as string,
540
+ issues: issues(parsed.error),
541
+ requestId,
542
+ };
543
+ },
544
+ );
545
+
546
+ /** Parse a server→client control frame. Never throws. */
547
+ export const parseServerMessage = lastPayloadMemo(
548
+ (payload): ParseResult<ServerMessage> => {
549
+ const envelope = readEnvelope(payload);
550
+ if (!envelope.ok) return envelope;
551
+ const kind = envelope.kind;
552
+ const schema =
553
+ typeof kind === "string" ? serverSchemaByKind.get(kind) : undefined;
554
+ if (!schema) return { ok: false, reason: "unknown_kind", kind };
555
+ const parsed = schema.safeParse(envelope.record);
556
+ return parsed.success
557
+ ? { ok: true, message: parsed.data as ServerMessage }
558
+ : {
559
+ ok: false,
560
+ reason: "invalid_shape",
561
+ kind: kind as string,
562
+ issues: issues(parsed.error),
563
+ };
564
+ },
565
+ );
566
+
567
+ type Unversioned<Message> = Message extends unknown
568
+ ? Omit<Message, "v">
569
+ : never;
570
+
571
+ /** Serialize a control frame, stamping the protocol version. */
572
+ export function encodeMessage(
573
+ message: Unversioned<ClientMessage> | Unversioned<ServerMessage>,
574
+ ): string {
575
+ return JSON.stringify({ v: COLLABORATION_PROTOCOL_VERSION, ...message });
576
+ }
577
+
578
+ /**
579
+ * The failed result answering a rejected request, or undefined when the frame was not a
580
+ * recognizable request. The error code is the parse failure reason.
581
+ */
582
+ export function encodeRequestRejection(
583
+ failure: ParseFailure,
584
+ ): string | undefined {
585
+ if (failure.reason !== "too_large" && failure.reason !== "invalid_shape")
586
+ return undefined;
587
+ const resultKind =
588
+ failure.kind === undefined
589
+ ? undefined
590
+ : resultKindByRequestKind.get(failure.kind);
591
+ const requestId = failure.requestId;
592
+ if (!resultKind || !requestId || !uuid.safeParse(requestId).success)
593
+ return undefined;
594
+ return encodeMessage({
595
+ kind: resultKind,
596
+ requestId,
597
+ ok: false,
598
+ error: { code: failure.reason, retryable: false },
599
+ });
600
+ }
601
+
602
+ /** Whether a client frame fits the bound the receiver enforces for its kind. */
603
+ export function fitsClientMessageBound(
604
+ kind: ClientMessage["kind"],
605
+ payload: string,
606
+ ): boolean {
607
+ const maxBytes = clientMaxBytesByKind.get(kind);
608
+ return maxBytes !== undefined && !exceedsBytes(payload, maxBytes);
609
+ }