@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 +3 -2
- package/src/collaboration/protocol.ts +609 -0
- package/src/collaboration/runtime-events.ts +94 -250
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
|
+
"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.
|
|
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:
|
|
375
|
-
stage:
|
|
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<
|
|
397
|
-
|
|
398
|
-
|
|
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:
|
|
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
|
|
529
|
-
|
|
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
|
+
}
|