@devicerail/live-visualizer 0.1.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.
- package/LICENSE +201 -0
- package/README.md +30 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +10 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/limits.d.ts +4 -0
- package/dist/limits.js +52 -0
- package/dist/live-timeline.d.ts +26 -0
- package/dist/live-timeline.js +440 -0
- package/dist/presentation.d.ts +3 -0
- package/dist/presentation.js +341 -0
- package/dist/sanitize.d.ts +8 -0
- package/dist/sanitize.js +261 -0
- package/dist/types.d.ts +167 -0
- package/dist/types.js +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { LiveTimelineError } from "./errors.js";
|
|
2
|
+
import { LIVE_TIMELINE_MAX_PAGE_SIZE, normalizeLiveTimelineLimits, } from "./limits.js";
|
|
3
|
+
import { presentEvent } from "./presentation.js";
|
|
4
|
+
import { canonicalFingerprint, deepFreeze } from "./sanitize.js";
|
|
5
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
6
|
+
function positiveInteger(value, name, maximum) {
|
|
7
|
+
if (!Number.isSafeInteger(value) || value <= 0 || (maximum !== undefined && value > maximum)) {
|
|
8
|
+
throw new LiveTimelineError("invalid_page", `${name} is outside its supported range`, {
|
|
9
|
+
details: { ...(maximum === undefined ? {} : { maximum }), value },
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function entryMatchesFilter(entry, filter) {
|
|
15
|
+
switch (filter) {
|
|
16
|
+
case "all":
|
|
17
|
+
return true;
|
|
18
|
+
case "observations":
|
|
19
|
+
return (entry.category === "observation" ||
|
|
20
|
+
entry.presentation.type === "mediaFrameCaptured");
|
|
21
|
+
case "actions":
|
|
22
|
+
return entry.category === "action";
|
|
23
|
+
case "errors":
|
|
24
|
+
return entry.category === "error";
|
|
25
|
+
case "verdicts":
|
|
26
|
+
return entry.category === "verdict";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function eventLifecycle(event) {
|
|
30
|
+
const payload = event.payload;
|
|
31
|
+
if (payload.type === "actionStarted") {
|
|
32
|
+
return Object.freeze({ callId: payload.call.id, eventId: event.eventId, type: payload.type });
|
|
33
|
+
}
|
|
34
|
+
if (payload.type === "actionCompleted") {
|
|
35
|
+
if (payload.outcome.outcome === "succeeded" &&
|
|
36
|
+
payload.outcome.result.callId !== payload.callId) {
|
|
37
|
+
throw new LiveTimelineError("invalid_event", "Action result.callId does not match the completed Action callId");
|
|
38
|
+
}
|
|
39
|
+
return Object.freeze({ callId: payload.callId, eventId: event.eventId, type: payload.type });
|
|
40
|
+
}
|
|
41
|
+
if (payload.type === "mediaStreamStarted") {
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
eventId: event.eventId,
|
|
44
|
+
mediaType: payload.stream.mediaType,
|
|
45
|
+
streamId: payload.stream.id,
|
|
46
|
+
type: payload.type,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (payload.type === "mediaFrameCaptured") {
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
eventId: event.eventId,
|
|
52
|
+
frameIndex: payload.frame.frameIndex,
|
|
53
|
+
mediaType: payload.frame.evidence.mediaType,
|
|
54
|
+
streamId: payload.frame.streamId,
|
|
55
|
+
type: payload.type,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (payload.type === "mediaStreamEnded") {
|
|
59
|
+
return Object.freeze({
|
|
60
|
+
eventId: event.eventId,
|
|
61
|
+
frameCount: payload.frameCount,
|
|
62
|
+
streamId: payload.streamId,
|
|
63
|
+
type: payload.type,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return Object.freeze({ eventId: event.eventId, type: payload.type });
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Bounded, protocol-only model for a live Session timeline.
|
|
70
|
+
*
|
|
71
|
+
* `commit` reserves a sanitized presentation but does not publish it. The
|
|
72
|
+
* host must durably confirm the daemon item and then call `confirm`; only that
|
|
73
|
+
* final call advances the public revision and confirmed sequence.
|
|
74
|
+
*/
|
|
75
|
+
export class LiveTimeline {
|
|
76
|
+
#actionEntries = [];
|
|
77
|
+
#commits = new WeakMap();
|
|
78
|
+
#entries = [];
|
|
79
|
+
#errorEntries = [];
|
|
80
|
+
#eventIds = new Set();
|
|
81
|
+
#inFlightActionIds = new Set();
|
|
82
|
+
#mediaStreams = new Map();
|
|
83
|
+
#limits;
|
|
84
|
+
#prepared = new WeakMap();
|
|
85
|
+
#observationEntries = [];
|
|
86
|
+
#seenActionIds = new Set();
|
|
87
|
+
#seenMediaStreamIds = new Set();
|
|
88
|
+
#sessionId;
|
|
89
|
+
#verdictEntries = [];
|
|
90
|
+
#confirmedSequence;
|
|
91
|
+
#generation = 0;
|
|
92
|
+
#pending;
|
|
93
|
+
#revision = 0;
|
|
94
|
+
#sessionStarted = false;
|
|
95
|
+
#status = "active";
|
|
96
|
+
#totalBytes = 0;
|
|
97
|
+
constructor(sessionId, options = {}) {
|
|
98
|
+
if (typeof sessionId !== "string" || !UUID_PATTERN.test(sessionId)) {
|
|
99
|
+
throw new LiveTimelineError("invalid_event", "timeline sessionId is invalid");
|
|
100
|
+
}
|
|
101
|
+
this.#sessionId = sessionId;
|
|
102
|
+
this.#limits = normalizeLiveTimelineLimits(options.limits);
|
|
103
|
+
}
|
|
104
|
+
get limits() {
|
|
105
|
+
return this.#limits;
|
|
106
|
+
}
|
|
107
|
+
get revision() {
|
|
108
|
+
return this.#revision;
|
|
109
|
+
}
|
|
110
|
+
get status() {
|
|
111
|
+
return this.#status;
|
|
112
|
+
}
|
|
113
|
+
prepare(event) {
|
|
114
|
+
if (this.#status !== "active") {
|
|
115
|
+
throw new LiveTimelineError("timeline_closed", `timeline is ${this.#status}`);
|
|
116
|
+
}
|
|
117
|
+
let canonical;
|
|
118
|
+
let fingerprint;
|
|
119
|
+
try {
|
|
120
|
+
({ canonical, fingerprint } = canonicalFingerprint(event, this.#limits));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof LiveTimelineError &&
|
|
124
|
+
error.code === "viewer_capacity_exceeded" &&
|
|
125
|
+
this.#status === "active") {
|
|
126
|
+
this.#status = "viewerCapacityExceeded";
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
// Present the exact canonical snapshot that was fingerprinted. The caller
|
|
131
|
+
// may mutate its input immediately after this method returns, and hostile
|
|
132
|
+
// accessor/proxy state must never make fingerprint and presentation drift.
|
|
133
|
+
const snapshot = JSON.parse(canonical);
|
|
134
|
+
const presentation = presentEvent(snapshot, this.#limits);
|
|
135
|
+
if (presentation.sessionId !== this.#sessionId) {
|
|
136
|
+
throw new LiveTimelineError("invalid_event", "event belongs to another Session");
|
|
137
|
+
}
|
|
138
|
+
const byteSize = Buffer.byteLength(JSON.stringify(presentation));
|
|
139
|
+
const prepared = {
|
|
140
|
+
byteSize,
|
|
141
|
+
fingerprint,
|
|
142
|
+
presentation,
|
|
143
|
+
sequence: presentation.sequence,
|
|
144
|
+
};
|
|
145
|
+
const frozen = deepFreeze(prepared);
|
|
146
|
+
this.#prepared.set(frozen, Object.freeze({
|
|
147
|
+
byteSize,
|
|
148
|
+
entry: presentation,
|
|
149
|
+
fingerprint,
|
|
150
|
+
lifecycle: eventLifecycle(snapshot),
|
|
151
|
+
sequence: presentation.sequence,
|
|
152
|
+
}));
|
|
153
|
+
return frozen;
|
|
154
|
+
}
|
|
155
|
+
commit(prepared) {
|
|
156
|
+
if (prepared === null || typeof prepared !== "object") {
|
|
157
|
+
throw new LiveTimelineError("stale_prepared_event", "prepared event is invalid");
|
|
158
|
+
}
|
|
159
|
+
const internal = this.#prepared.get(prepared);
|
|
160
|
+
if (!internal) {
|
|
161
|
+
throw new LiveTimelineError("stale_prepared_event", "prepared event was not produced by this timeline");
|
|
162
|
+
}
|
|
163
|
+
if (this.#status !== "active") {
|
|
164
|
+
throw new LiveTimelineError("timeline_closed", `timeline is ${this.#status}`);
|
|
165
|
+
}
|
|
166
|
+
if (this.#pending) {
|
|
167
|
+
if (internal.sequence === this.#pending.sequence &&
|
|
168
|
+
internal.fingerprint === this.#pending.fingerprint) {
|
|
169
|
+
return this.#commitToken(this.#pending, "pendingReplay");
|
|
170
|
+
}
|
|
171
|
+
if (internal.sequence === this.#pending.sequence) {
|
|
172
|
+
throw new LiveTimelineError("event_conflict", "an unconfirmed sequence was replayed with different content", { details: { sequence: internal.sequence } });
|
|
173
|
+
}
|
|
174
|
+
throw new LiveTimelineError("pending_confirmation", "the previous committed event must be confirmed before another sequence", {
|
|
175
|
+
details: {
|
|
176
|
+
pendingSequence: this.#pending.sequence,
|
|
177
|
+
receivedSequence: internal.sequence,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const expected = (this.#confirmedSequence ?? 0) + 1;
|
|
182
|
+
if (internal.sequence !== expected) {
|
|
183
|
+
throw new LiveTimelineError("sequence_gap", "event sequence is not contiguous", {
|
|
184
|
+
details: { expected, received: internal.sequence },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
this.#validateLifecycle(internal.lifecycle, internal.sequence);
|
|
188
|
+
const capacityExceeded = internal.byteSize > this.#limits.maxEventBytes ||
|
|
189
|
+
this.#entries.length >= this.#limits.maxEvents ||
|
|
190
|
+
this.#totalBytes + internal.byteSize > this.#limits.maxTotalBytes;
|
|
191
|
+
if (capacityExceeded) {
|
|
192
|
+
this.#status = "viewerCapacityExceeded";
|
|
193
|
+
throw new LiveTimelineError("viewer_capacity_exceeded", "live timeline capacity was reached; use the offline Bundle Viewer", {
|
|
194
|
+
details: {
|
|
195
|
+
eventBytes: internal.byteSize,
|
|
196
|
+
eventCount: this.#entries.length,
|
|
197
|
+
totalBytes: this.#totalBytes,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
this.#generation += 1;
|
|
202
|
+
this.#pending = Object.freeze({
|
|
203
|
+
byteSize: internal.byteSize,
|
|
204
|
+
entry: internal.entry,
|
|
205
|
+
fingerprint: internal.fingerprint,
|
|
206
|
+
generation: this.#generation,
|
|
207
|
+
lifecycle: internal.lifecycle,
|
|
208
|
+
sequence: internal.sequence,
|
|
209
|
+
});
|
|
210
|
+
return this.#commitToken(this.#pending, "committed");
|
|
211
|
+
}
|
|
212
|
+
confirm(commit) {
|
|
213
|
+
if (commit === null || typeof commit !== "object") {
|
|
214
|
+
throw new LiveTimelineError("invalid_confirmation", "commit token is invalid");
|
|
215
|
+
}
|
|
216
|
+
const internal = this.#commits.get(commit);
|
|
217
|
+
if (!internal || !this.#pending) {
|
|
218
|
+
throw new LiveTimelineError("invalid_confirmation", "commit token is not pending here");
|
|
219
|
+
}
|
|
220
|
+
if (internal.generation !== this.#pending.generation ||
|
|
221
|
+
internal.sequence !== this.#pending.sequence ||
|
|
222
|
+
internal.fingerprint !== this.#pending.fingerprint) {
|
|
223
|
+
throw new LiveTimelineError("invalid_confirmation", "commit token is stale or mismatched");
|
|
224
|
+
}
|
|
225
|
+
const pending = this.#pending;
|
|
226
|
+
this.#applyLifecycle(pending.lifecycle);
|
|
227
|
+
this.#entries.push(pending.entry);
|
|
228
|
+
this.#indexEntry(pending.entry);
|
|
229
|
+
this.#totalBytes += pending.byteSize;
|
|
230
|
+
this.#confirmedSequence = pending.sequence;
|
|
231
|
+
this.#pending = undefined;
|
|
232
|
+
this.#commits.delete(commit);
|
|
233
|
+
this.#revision += 1;
|
|
234
|
+
if (pending.entry.presentation.type === "sessionEnded") {
|
|
235
|
+
this.#status = "sessionEnded";
|
|
236
|
+
}
|
|
237
|
+
return Object.freeze({
|
|
238
|
+
revision: this.#revision,
|
|
239
|
+
sequence: pending.sequence,
|
|
240
|
+
status: this.#status,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
fail() {
|
|
244
|
+
if (this.#status === "active") {
|
|
245
|
+
this.#pending = undefined;
|
|
246
|
+
this.#status = "failed";
|
|
247
|
+
}
|
|
248
|
+
return this.state();
|
|
249
|
+
}
|
|
250
|
+
stop() {
|
|
251
|
+
if (this.#status === "active") {
|
|
252
|
+
this.#pending = undefined;
|
|
253
|
+
this.#status = "stopped";
|
|
254
|
+
}
|
|
255
|
+
return this.state();
|
|
256
|
+
}
|
|
257
|
+
state() {
|
|
258
|
+
return deepFreeze({
|
|
259
|
+
...(this.#confirmedSequence === undefined
|
|
260
|
+
? {}
|
|
261
|
+
: { confirmedSequence: this.#confirmedSequence }),
|
|
262
|
+
eventCount: this.#entries.length,
|
|
263
|
+
...(this.#pending
|
|
264
|
+
? {
|
|
265
|
+
pending: {
|
|
266
|
+
fingerprint: this.#pending.fingerprint,
|
|
267
|
+
sequence: this.#pending.sequence,
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
: {}),
|
|
271
|
+
revision: this.#revision,
|
|
272
|
+
sessionId: this.#sessionId,
|
|
273
|
+
status: this.#status,
|
|
274
|
+
totalBytes: this.#totalBytes,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
page(request = {}) {
|
|
278
|
+
const filter = request.filter ?? "all";
|
|
279
|
+
if (filter !== "all" &&
|
|
280
|
+
filter !== "observations" &&
|
|
281
|
+
filter !== "actions" &&
|
|
282
|
+
filter !== "errors" &&
|
|
283
|
+
filter !== "verdicts") {
|
|
284
|
+
throw new LiveTimelineError("invalid_page", "timeline filter is invalid");
|
|
285
|
+
}
|
|
286
|
+
const page = positiveInteger(request.page ?? 1, "page");
|
|
287
|
+
const pageSize = positiveInteger(request.pageSize ?? LIVE_TIMELINE_MAX_PAGE_SIZE, "pageSize", LIVE_TIMELINE_MAX_PAGE_SIZE);
|
|
288
|
+
const entries = this.#entriesForFilter(filter);
|
|
289
|
+
const totalItems = entries.length;
|
|
290
|
+
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
|
291
|
+
if (page > totalPages) {
|
|
292
|
+
throw new LiveTimelineError("invalid_page", "page exceeds the available page count", {
|
|
293
|
+
details: { page, totalPages },
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const start = (page - 1) * pageSize;
|
|
297
|
+
if (!Number.isSafeInteger(start)) {
|
|
298
|
+
throw new LiveTimelineError("invalid_page", "page offset exceeds safe integer range");
|
|
299
|
+
}
|
|
300
|
+
const items = Object.freeze(entries.slice(start, start + pageSize));
|
|
301
|
+
return Object.freeze({
|
|
302
|
+
filter,
|
|
303
|
+
items,
|
|
304
|
+
page,
|
|
305
|
+
pageSize,
|
|
306
|
+
revision: this.#revision,
|
|
307
|
+
status: this.#status,
|
|
308
|
+
totalItems,
|
|
309
|
+
totalPages,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
#indexEntry(entry) {
|
|
313
|
+
if (entryMatchesFilter(entry, "observations")) {
|
|
314
|
+
this.#observationEntries.push(entry);
|
|
315
|
+
}
|
|
316
|
+
else if (entryMatchesFilter(entry, "actions")) {
|
|
317
|
+
this.#actionEntries.push(entry);
|
|
318
|
+
}
|
|
319
|
+
else if (entryMatchesFilter(entry, "errors")) {
|
|
320
|
+
this.#errorEntries.push(entry);
|
|
321
|
+
}
|
|
322
|
+
else if (entryMatchesFilter(entry, "verdicts")) {
|
|
323
|
+
this.#verdictEntries.push(entry);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
#entriesForFilter(filter) {
|
|
327
|
+
switch (filter) {
|
|
328
|
+
case "all":
|
|
329
|
+
return this.#entries;
|
|
330
|
+
case "observations":
|
|
331
|
+
return this.#observationEntries;
|
|
332
|
+
case "actions":
|
|
333
|
+
return this.#actionEntries;
|
|
334
|
+
case "errors":
|
|
335
|
+
return this.#errorEntries;
|
|
336
|
+
case "verdicts":
|
|
337
|
+
return this.#verdictEntries;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
#applyLifecycle(lifecycle) {
|
|
341
|
+
this.#eventIds.add(lifecycle.eventId);
|
|
342
|
+
switch (lifecycle.type) {
|
|
343
|
+
case "sessionStarted":
|
|
344
|
+
this.#sessionStarted = true;
|
|
345
|
+
break;
|
|
346
|
+
case "actionStarted":
|
|
347
|
+
this.#seenActionIds.add(lifecycle.callId);
|
|
348
|
+
this.#inFlightActionIds.add(lifecycle.callId);
|
|
349
|
+
break;
|
|
350
|
+
case "actionCompleted":
|
|
351
|
+
this.#inFlightActionIds.delete(lifecycle.callId);
|
|
352
|
+
break;
|
|
353
|
+
case "mediaStreamStarted":
|
|
354
|
+
this.#seenMediaStreamIds.add(lifecycle.streamId);
|
|
355
|
+
this.#mediaStreams.set(lifecycle.streamId, {
|
|
356
|
+
mediaType: lifecycle.mediaType,
|
|
357
|
+
nextFrameIndex: 1,
|
|
358
|
+
});
|
|
359
|
+
break;
|
|
360
|
+
case "mediaFrameCaptured": {
|
|
361
|
+
const stream = this.#mediaStreams.get(lifecycle.streamId);
|
|
362
|
+
this.#mediaStreams.set(lifecycle.streamId, {
|
|
363
|
+
mediaType: stream.mediaType,
|
|
364
|
+
nextFrameIndex: stream.nextFrameIndex + 1,
|
|
365
|
+
});
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
case "mediaStreamEnded":
|
|
369
|
+
this.#mediaStreams.delete(lifecycle.streamId);
|
|
370
|
+
break;
|
|
371
|
+
case "sessionEnded":
|
|
372
|
+
case "observationCaptured":
|
|
373
|
+
case "error":
|
|
374
|
+
case "verdictRecorded":
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
#commitToken(pending, kind) {
|
|
379
|
+
const token = Object.freeze({
|
|
380
|
+
fingerprint: pending.fingerprint,
|
|
381
|
+
kind,
|
|
382
|
+
sequence: pending.sequence,
|
|
383
|
+
});
|
|
384
|
+
this.#commits.set(token, Object.freeze({
|
|
385
|
+
fingerprint: pending.fingerprint,
|
|
386
|
+
generation: pending.generation,
|
|
387
|
+
sequence: pending.sequence,
|
|
388
|
+
}));
|
|
389
|
+
return token;
|
|
390
|
+
}
|
|
391
|
+
#validateLifecycle(lifecycle, sequence) {
|
|
392
|
+
if (this.#eventIds.has(lifecycle.eventId)) {
|
|
393
|
+
throw new LiveTimelineError("invalid_event", "TestEvent.eventId is duplicated");
|
|
394
|
+
}
|
|
395
|
+
if (sequence === 1 && lifecycle.type !== "sessionStarted") {
|
|
396
|
+
throw new LiveTimelineError("invalid_event", "the first Session event must be sessionStarted");
|
|
397
|
+
}
|
|
398
|
+
if (sequence !== 1 && lifecycle.type === "sessionStarted") {
|
|
399
|
+
throw new LiveTimelineError("invalid_event", "sessionStarted may only be the first event");
|
|
400
|
+
}
|
|
401
|
+
if (!this.#sessionStarted && lifecycle.type !== "sessionStarted") {
|
|
402
|
+
throw new LiveTimelineError("invalid_event", "Session events require a confirmed sessionStarted");
|
|
403
|
+
}
|
|
404
|
+
if (lifecycle.type === "actionStarted") {
|
|
405
|
+
const callId = lifecycle.callId;
|
|
406
|
+
if (this.#seenActionIds.has(callId)) {
|
|
407
|
+
throw new LiveTimelineError("invalid_event", "Action callId is duplicated");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else if (lifecycle.type === "actionCompleted") {
|
|
411
|
+
const callId = lifecycle.callId;
|
|
412
|
+
if (!this.#inFlightActionIds.has(callId)) {
|
|
413
|
+
throw new LiveTimelineError("invalid_event", "Action completion has no confirmed matching Action start");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
else if (lifecycle.type === "mediaStreamStarted") {
|
|
417
|
+
if (this.#seenMediaStreamIds.has(lifecycle.streamId)) {
|
|
418
|
+
throw new LiveTimelineError("invalid_event", "media stream id is duplicated");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
else if (lifecycle.type === "mediaFrameCaptured") {
|
|
422
|
+
const stream = this.#mediaStreams.get(lifecycle.streamId);
|
|
423
|
+
if (!stream
|
|
424
|
+
|| lifecycle.frameIndex !== stream.nextFrameIndex
|
|
425
|
+
|| lifecycle.mediaType !== stream.mediaType) {
|
|
426
|
+
throw new LiveTimelineError("invalid_event", "media frame does not match an active stream");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else if (lifecycle.type === "mediaStreamEnded") {
|
|
430
|
+
const stream = this.#mediaStreams.get(lifecycle.streamId);
|
|
431
|
+
if (!stream || lifecycle.frameCount !== stream.nextFrameIndex - 1) {
|
|
432
|
+
throw new LiveTimelineError("invalid_event", "media stream terminal is inconsistent");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
else if (lifecycle.type === "sessionEnded"
|
|
436
|
+
&& (this.#inFlightActionIds.size > 0 || this.#mediaStreams.size > 0)) {
|
|
437
|
+
throw new LiveTimelineError("invalid_event", "Session cannot end while Actions or media streams remain in flight");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|