@echovisionlab/geul-common 0.5.0 → 0.6.1

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@echovisionlab/geul-common",
3
3
  "description": "Shared TypeScript contracts and helpers for Geul editors, collaboration, localized content, media blocks, pages, posts, and runtime events.",
4
4
  "author": "state303 <state303@dsub.io>",
5
- "version": "0.5.0",
5
+ "version": "0.6.1",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "repository": {
@@ -49,6 +49,7 @@
49
49
  "./collaboration/page": "./src/collaboration/page.ts",
50
50
  "./collaboration/post": "./src/collaboration/post.ts",
51
51
  "./collaboration/post-series": "./src/collaboration/post-series.ts",
52
+ "./collaboration/protocol": "./src/collaboration/protocol.ts",
52
53
  "./collaboration/release": "./src/collaboration/release.ts",
53
54
  "./collaboration/runtime-events": "./src/collaboration/runtime-events.ts",
54
55
  "./collaboration/work": "./src/collaboration/work.ts",
@@ -64,7 +65,7 @@
64
65
  },
65
66
  "dependencies": {
66
67
  "@bufbuild/protobuf": "^2.16.0",
67
- "@echovisionlab/geul-proto": "^0.5.0",
68
+ "@echovisionlab/geul-proto": "^0.6.0",
68
69
  "diff": "9.0.0",
69
70
  "zod": "^4.6.5"
70
71
  },
@@ -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
+ }
@@ -1,192 +1,5 @@
1
1
  import * as z from "zod/mini";
2
2
 
3
- export type RuntimeEntityType =
4
- | "post"
5
- | "page"
6
- | "work"
7
- | "artist"
8
- | "label"
9
- | "release"
10
- | "series"
11
- | "form"
12
- | "program_event"
13
- | "menu"
14
- | "site"
15
- | "site_setting"
16
- | "email_template"
17
- | "email_layout"
18
- | "campaign"
19
- | "privacy"
20
- | "terms";
21
-
22
- export type TranslationLifecycleRuntimeStatus = "failed";
23
-
24
- export type MediaProcessingRuntimeStatus = "processing" | "ready" | "failed";
25
-
26
- export type OgGenerationRuntimeEntityType =
27
- | "post"
28
- | "page"
29
- | "work"
30
- | "artist"
31
- | "label"
32
- | "release"
33
- | "series"
34
- | "form"
35
- | "site"
36
- | "privacy"
37
- | "terms";
38
-
39
- export type OgGenerationRuntimeStatus =
40
- "queued" | "processing" | "ready" | "failed" | "superseded" | "cancelled";
41
-
42
- export function isOgGenerationRuntimeTerminal(
43
- status: OgGenerationRuntimeStatus,
44
- ): boolean {
45
- return (
46
- status === "ready" ||
47
- status === "failed" ||
48
- status === "superseded" ||
49
- status === "cancelled"
50
- );
51
- }
52
-
53
- export type FileIngestRuntimeSource = "upload" | "embed";
54
-
55
- export type FileIngestRuntimeStage =
56
- | "uploading"
57
- | "downloading"
58
- | "finalized"
59
- | "attached"
60
- | "completed"
61
- | "expired"
62
- | "failed"
63
- | "unknown";
64
-
65
- export interface RuntimeEventEnvelope<K extends string, P> {
66
- version: 1;
67
- kind: K;
68
- entityType: RuntimeEntityType;
69
- entityId: string;
70
- locale?: string;
71
- correlationId?: string;
72
- sequence?: number;
73
- timestampMs: number;
74
- payload: P;
75
- }
76
-
77
- export interface OgGenerationLifecycleRuntimePayload {
78
- generationId: string;
79
- runId: string;
80
- status: OgGenerationRuntimeStatus;
81
- assetId?: string;
82
- assetUrl?: string;
83
- errorCode?: string;
84
- error?: string;
85
- replacementGenerationId?: string;
86
- }
87
-
88
- export interface RenderCompleteRuntimePayload {
89
- contentHash: string;
90
- success: boolean;
91
- error?: string;
92
- }
93
-
94
- export interface MediaProcessingRuntimeOutputs {
95
- spectrogramAssetId?: string;
96
- thumbnailAssetId?: string;
97
- hlsGenerationId?: string;
98
- durationSeconds?: number;
99
- waveformAssetId?: string;
100
- }
101
-
102
- export interface MediaProcessingLifecycleRuntimePayload {
103
- fileId: string;
104
- slotId?: string;
105
- attemptId?: string;
106
- status: MediaProcessingRuntimeStatus;
107
- percentage?: number;
108
- outputs?: MediaProcessingRuntimeOutputs;
109
- error?: string;
110
- trackId?: string;
111
- releaseId?: string;
112
- }
113
-
114
- export function isMediaProcessingRuntimeReady(payload: {
115
- status?: MediaProcessingRuntimeStatus | null;
116
- }): boolean {
117
- return payload.status === "ready";
118
- }
119
-
120
- export interface FileIngestLifecycleRuntimePayload {
121
- fileId: string;
122
- uploadId?: string;
123
- mediaKind?: string;
124
- slotId?: string;
125
- attemptId?: string;
126
- source: FileIngestRuntimeSource;
127
- stage: FileIngestRuntimeStage;
128
- progress: number;
129
- bytesCompleted?: number;
130
- bytesTotal?: number;
131
- fileName?: string;
132
- mimeType?: string;
133
- fileSize?: number;
134
- error?: string;
135
- trackId?: string;
136
- releaseId?: string;
137
- }
138
-
139
- export interface TranslationLifecycleRuntimePayload {
140
- jobId: string;
141
- targetLocale: string;
142
- status: TranslationLifecycleRuntimeStatus;
143
- error?: string;
144
- }
145
-
146
- export interface RuntimeErrorPayload {
147
- code: string;
148
- message: string;
149
- retryable?: boolean;
150
- source?: "backend" | "collab" | "worker";
151
- details?: Record<string, string | number | boolean | null>;
152
- }
153
-
154
- export interface OgGenerationLifecycleRuntimeEvent extends RuntimeEventEnvelope<
155
- "og.lifecycle",
156
- OgGenerationLifecycleRuntimePayload
157
- > {
158
- entityType: OgGenerationRuntimeEntityType;
159
- correlationId: string;
160
- }
161
- export type RenderCompleteRuntimeEvent = RuntimeEventEnvelope<
162
- "render.complete",
163
- RenderCompleteRuntimePayload
164
- >;
165
- export type MediaProcessingLifecycleRuntimeEvent = RuntimeEventEnvelope<
166
- "media.processing.lifecycle",
167
- MediaProcessingLifecycleRuntimePayload
168
- >;
169
- export type FileIngestLifecycleRuntimeEvent = RuntimeEventEnvelope<
170
- "file.ingest.lifecycle",
171
- FileIngestLifecycleRuntimePayload
172
- >;
173
- export type TranslationLifecycleRuntimeEvent = RuntimeEventEnvelope<
174
- "translation.lifecycle",
175
- TranslationLifecycleRuntimePayload
176
- >;
177
- export type RuntimeErrorEvent = RuntimeEventEnvelope<
178
- "runtime.error",
179
- RuntimeErrorPayload
180
- >;
181
-
182
- export type EditorRuntimeEvent =
183
- | OgGenerationLifecycleRuntimeEvent
184
- | RenderCompleteRuntimeEvent
185
- | MediaProcessingLifecycleRuntimeEvent
186
- | FileIngestLifecycleRuntimeEvent
187
- | TranslationLifecycleRuntimeEvent
188
- | RuntimeErrorEvent;
189
-
190
3
  export const runtimeEntityTypeSchema = z.enum([
191
4
  "post",
192
5
  "page",
@@ -365,23 +178,26 @@ export const mediaProcessingLifecycleRuntimePayloadSchema = z
365
178
  }),
366
179
  );
367
180
 
181
+ export const fileIngestRuntimeSourceSchema = z.enum(["upload", "embed"]);
182
+ export const fileIngestRuntimeStageSchema = z.enum([
183
+ "uploading",
184
+ "downloading",
185
+ "finalized",
186
+ "attached",
187
+ "completed",
188
+ "expired",
189
+ "failed",
190
+ "unknown",
191
+ ]);
192
+
368
193
  export const fileIngestLifecycleRuntimePayloadSchema = z.strictObject({
369
194
  fileId: z.string().check(z.minLength(1)),
370
195
  uploadId: z.optional(z.string()),
371
196
  mediaKind: z.optional(z.string()),
372
197
  slotId: z.optional(z.string()),
373
198
  attemptId: z.optional(z.string()),
374
- source: z.enum(["upload", "embed"]),
375
- stage: z.enum([
376
- "uploading",
377
- "downloading",
378
- "finalized",
379
- "attached",
380
- "completed",
381
- "expired",
382
- "failed",
383
- "unknown",
384
- ]),
199
+ source: fileIngestRuntimeSourceSchema,
200
+ stage: fileIngestRuntimeStageSchema,
385
201
  progress: z.int().check(z.minimum(0), z.maximum(100)),
386
202
  bytesCompleted: z.optional(z.int().check(z.nonnegative())),
387
203
  bytesTotal: z.optional(z.int().check(z.nonnegative())),
@@ -393,15 +209,11 @@ export const fileIngestLifecycleRuntimePayloadSchema = z.strictObject({
393
209
  releaseId: z.optional(z.string()),
394
210
  });
395
211
 
396
- const localeScopedOgEntityTypes = new Set<OgGenerationRuntimeEntityType>([
397
- "post",
398
- "page",
399
- "form",
400
- "privacy",
401
- "terms",
402
- ]);
212
+ const localeScopedOgEntityTypes = new Set<
213
+ z.infer<typeof ogGenerationRuntimeEntityTypeSchema>
214
+ >(["post", "page", "form", "privacy", "terms"]);
403
215
 
404
- const ogGenerationLifecycleRuntimeEventSchema = z
216
+ export const ogGenerationLifecycleRuntimeEventSchema = z
405
217
  .strictObject({
406
218
  version: z.literal(1),
407
219
  kind: z.literal("og.lifecycle"),
@@ -440,30 +252,17 @@ const ogGenerationLifecycleRuntimeEventSchema = z
440
252
  }),
441
253
  );
442
254
 
255
+ export const translationLifecycleRuntimeStatusSchema = z.literal("failed");
256
+
443
257
  export const translationLifecycleRuntimePayloadSchema = z.strictObject({
444
258
  jobId: z.string().check(z.minLength(1)),
445
259
  targetLocale: z.string().check(z.minLength(1)),
446
- status: z.literal("failed"),
260
+ status: translationLifecycleRuntimeStatusSchema,
447
261
  error: z.optional(z.string()),
448
262
  });
449
263
 
450
264
  export const editorRuntimeEventSchema = z.discriminatedUnion("kind", [
451
265
  ogGenerationLifecycleRuntimeEventSchema,
452
- z.strictObject({
453
- version: z.literal(1),
454
- kind: z.literal("render.complete"),
455
- entityType: runtimeEntityTypeSchema,
456
- entityId: z.string().check(z.minLength(1)),
457
- locale: z.optional(z.string()),
458
- correlationId: z.optional(z.string()),
459
- sequence: z.optional(z.int()),
460
- timestampMs: z.int(),
461
- payload: z.strictObject({
462
- contentHash: z.string(),
463
- success: z.boolean(),
464
- error: z.optional(z.string()),
465
- }),
466
- }),
467
266
  z.strictObject({
468
267
  version: z.literal(1),
469
268
  kind: z.literal("media.processing.lifecycle"),
@@ -497,41 +296,86 @@ export const editorRuntimeEventSchema = z.discriminatedUnion("kind", [
497
296
  timestampMs: z.int(),
498
297
  payload: translationLifecycleRuntimePayloadSchema,
499
298
  }),
500
- z.strictObject({
501
- version: z.literal(1),
502
- kind: z.literal("runtime.error"),
503
- entityType: runtimeEntityTypeSchema,
504
- entityId: z.string().check(z.minLength(1)),
505
- locale: z.optional(z.string()),
506
- correlationId: z.optional(z.string()),
507
- sequence: z.optional(z.int()),
508
- timestampMs: z.int(),
509
- payload: z.strictObject({
510
- code: z.string().check(z.minLength(1)),
511
- message: z.string(),
512
- retryable: z.optional(z.boolean()),
513
- source: z.optional(z.enum(["backend", "collab", "worker"])),
514
- details: z.optional(
515
- z.record(
516
- z.string(),
517
- z.union([z.string(), z.number(), z.boolean(), z.null()]),
518
- ),
519
- ),
520
- }),
521
- }),
522
299
  ]);
523
300
 
524
301
  export function parseEditorRuntimeEventMessage(
525
302
  payload: string,
526
303
  ): EditorRuntimeEvent | null {
527
304
  try {
528
- const data = JSON.parse(payload) as unknown;
529
- const result = editorRuntimeEventSchema.safeParse(data);
530
- if (!result.success) {
531
- return null;
532
- }
533
- return result.data as EditorRuntimeEvent;
305
+ const result = editorRuntimeEventSchema.safeParse(JSON.parse(payload));
306
+ return result.success ? result.data : null;
534
307
  } catch {
535
308
  return null;
536
309
  }
537
310
  }
311
+
312
+ // ---- Types: inferred from the schemas above, the single definition of every runtime event ----
313
+
314
+ export type RuntimeEntityType = z.infer<typeof runtimeEntityTypeSchema>;
315
+ export type TranslationLifecycleRuntimeStatus = z.infer<
316
+ typeof translationLifecycleRuntimeStatusSchema
317
+ >;
318
+ export type MediaProcessingRuntimeStatus = z.infer<
319
+ typeof mediaProcessingRuntimeStatusSchema
320
+ >;
321
+ export type OgGenerationRuntimeEntityType = z.infer<
322
+ typeof ogGenerationRuntimeEntityTypeSchema
323
+ >;
324
+ export type OgGenerationRuntimeStatus = z.infer<
325
+ typeof ogGenerationRuntimeStatusSchema
326
+ >;
327
+ export type FileIngestRuntimeSource = z.infer<
328
+ typeof fileIngestRuntimeSourceSchema
329
+ >;
330
+ export type FileIngestRuntimeStage = z.infer<
331
+ typeof fileIngestRuntimeStageSchema
332
+ >;
333
+ export type OgGenerationLifecycleRuntimePayload = z.infer<
334
+ typeof ogGenerationLifecycleRuntimePayloadSchema
335
+ >;
336
+ export type MediaProcessingRuntimeOutputs = z.infer<
337
+ typeof mediaProcessingRuntimeOutputsSchema
338
+ >;
339
+ export type MediaProcessingLifecycleRuntimePayload = z.infer<
340
+ typeof mediaProcessingLifecycleRuntimePayloadSchema
341
+ >;
342
+ export type FileIngestLifecycleRuntimePayload = z.infer<
343
+ typeof fileIngestLifecycleRuntimePayloadSchema
344
+ >;
345
+ export type TranslationLifecycleRuntimePayload = z.infer<
346
+ typeof translationLifecycleRuntimePayloadSchema
347
+ >;
348
+ export type EditorRuntimeEvent = z.infer<typeof editorRuntimeEventSchema>;
349
+ export type OgGenerationLifecycleRuntimeEvent = Extract<
350
+ EditorRuntimeEvent,
351
+ { kind: "og.lifecycle" }
352
+ >;
353
+ export type MediaProcessingLifecycleRuntimeEvent = Extract<
354
+ EditorRuntimeEvent,
355
+ { kind: "media.processing.lifecycle" }
356
+ >;
357
+ export type FileIngestLifecycleRuntimeEvent = Extract<
358
+ EditorRuntimeEvent,
359
+ { kind: "file.ingest.lifecycle" }
360
+ >;
361
+ export type TranslationLifecycleRuntimeEvent = Extract<
362
+ EditorRuntimeEvent,
363
+ { kind: "translation.lifecycle" }
364
+ >;
365
+
366
+ export function isMediaProcessingRuntimeReady(payload: {
367
+ status?: MediaProcessingRuntimeStatus | null;
368
+ }): boolean {
369
+ return payload.status === "ready";
370
+ }
371
+
372
+ export function isOgGenerationRuntimeTerminal(
373
+ status: OgGenerationRuntimeStatus,
374
+ ): boolean {
375
+ return (
376
+ status === "ready" ||
377
+ status === "failed" ||
378
+ status === "superseded" ||
379
+ status === "cancelled"
380
+ );
381
+ }