@rivmux/runtime-worker 0.5.0 → 1.0.0-rc.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.
@@ -141,151 +141,44 @@ const PLAYBACK_RATE_RESTORE_THRESHOLD_SECONDS = .1;
141
141
  const SEEK_COOLDOWN_MS = 1e3;
142
142
  const SEEK_MIN_DELTA_SECONDS = .1;
143
143
  //#endregion
144
- //#region src/runtime/lifecycle.ts
145
- function raceLifecycleOperation(operation, signal, onLateValue) {
146
- if (signal.aborted) {
147
- operation.then(onLateValue, () => void 0);
148
- return Promise.resolve({ cancelled: true });
149
- }
150
- return new Promise((resolve, reject) => {
151
- let settled = false;
152
- const onAbort = () => {
153
- if (settled) return;
154
- settled = true;
155
- signal.removeEventListener("abort", onAbort);
156
- operation.then(onLateValue, () => void 0);
157
- resolve({ cancelled: true });
158
- };
159
- signal.addEventListener("abort", onAbort, { once: true });
160
- operation.then((value) => {
161
- if (settled) return;
162
- settled = true;
163
- signal.removeEventListener("abort", onAbort);
164
- resolve({
165
- cancelled: false,
166
- value
167
- });
168
- }, (error) => {
169
- if (settled) return;
170
- settled = true;
171
- signal.removeEventListener("abort", onAbort);
172
- reject(error);
173
- });
174
- });
175
- }
176
- //#endregion
177
- //#region src/runtime/options.ts
178
- function mergeOptions(current, updates) {
179
- return {
180
- playback: {
181
- ...current.playback,
182
- ...updates.playback
183
- },
184
- latency: {
185
- ...current.latency,
186
- ...updates.latency
187
- },
188
- network: {
189
- ...current.network,
190
- ...updates.network,
191
- headers: {
192
- ...current.network.headers,
193
- ...updates.network?.headers
194
- },
195
- retry: {
196
- ...current.network.retry,
197
- ...updates.network?.retry
198
- }
199
- },
200
- runtime: {
201
- ...current.runtime,
202
- ...updates.runtime
203
- },
204
- diagnostics: {
205
- ...current.diagnostics,
206
- ...updates.diagnostics
207
- }
208
- };
209
- }
210
- //#endregion
211
- //#region src/runtime/stats.ts
212
- function updateAppendQueueHighWaterMark(current, mseStats) {
213
- return {
214
- length: Math.max(current.length, mseStats.appendQueueLength),
215
- bytes: Math.max(current.bytes, mseStats.appendQueueBytes)
216
- };
217
- }
218
- function getNetworkIdleMs(stats, nowMs) {
219
- const markerMs = stats?.lastChunkAtMs ?? stats?.startedAtMs;
220
- if (markerMs === void 0) return;
221
- return Math.max(nowMs - markerMs, 0);
222
- }
223
- function createPlayerStats(snapshot) {
224
- const { loaderStats, mseStats, latencyMetrics } = snapshot;
225
- return {
226
- bytesReceived: loaderStats?.bytesReceived ?? 0,
227
- currentNetworkSpeed: loaderStats?.currentNetworkSpeed ?? 0,
228
- networkIdleMs: getNetworkIdleMs(loaderStats, snapshot.nowMs),
229
- outputBytes: snapshot.outputBytes,
230
- appendQueueLength: mseStats.appendQueueLength,
231
- appendQueueBytes: mseStats.appendQueueBytes,
232
- appendQueueMaxLength: snapshot.appendQueueMaxLength,
233
- appendQueueMaxBytes: snapshot.appendQueueMaxBytes,
234
- loaderPaused: snapshot.loaderPaused,
235
- sourceBufferUpdating: mseStats.sourceBufferUpdating,
236
- sourceBufferCount: mseStats.sourceBufferCount,
237
- bufferedStart: latencyMetrics.bufferedStart ?? mseStats.bufferedStart,
238
- bufferedEnd: latencyMetrics.bufferedEnd ?? mseStats.bufferedEnd,
239
- bufferedDuration: latencyMetrics.bufferedDuration ?? mseStats.bufferedDuration,
240
- bufferedRangeCount: mseStats.bufferedRangeCount,
241
- currentTime: latencyMetrics.currentTime,
242
- liveLatency: latencyMetrics.liveLatency,
243
- playbackRate: latencyMetrics.playbackRate,
244
- readyState: latencyMetrics.readyState,
245
- droppedFrames: latencyMetrics.droppedFrames
246
- };
247
- }
248
- //#endregion
249
- //#region src/loader/retry-policy.ts
250
- function createRetryPolicy(input) {
251
- return {
252
- maxAttempts: clampInteger(input?.maxAttempts, 1),
253
- backoffMs: clampInteger(input?.backoffMs, 0)
254
- };
255
- }
256
- function getRetryDelayMs(policy, attempt) {
257
- if (policy.backoffMs === 0) return 0;
258
- return policy.backoffMs * Math.max(1, attempt);
259
- }
260
- function clampInteger(value, minimum) {
261
- if (value === void 0 || !Number.isFinite(value)) return minimum;
262
- return Math.max(minimum, Math.trunc(value));
263
- }
264
- //#endregion
265
144
  //#region src/loader/http-flv-loader.ts
145
+ /** Structured failure raised by one HTTP-FLV connection. */
266
146
  var HttpFlvLoaderError = class extends Error {
267
147
  code;
148
+ phase;
149
+ reason;
268
150
  status;
269
- constructor(code, message, status) {
270
- super(message);
151
+ cause;
152
+ constructor(code, message, options) {
153
+ const normalizedOptions = typeof options === "number" ? {
154
+ phase: "open",
155
+ reason: "http-status",
156
+ status: options
157
+ } : options ?? { phase: "open" };
158
+ super(message, normalizedOptions.cause === void 0 ? void 0 : { cause: normalizedOptions.cause });
271
159
  this.name = "HttpFlvLoaderError";
272
160
  this.code = code;
273
- this.status = status;
161
+ this.phase = normalizedOptions.phase;
162
+ this.reason = normalizedOptions.reason;
163
+ this.status = normalizedOptions.status;
164
+ this.cause = normalizedOptions.cause;
274
165
  }
275
166
  };
276
167
  var HttpFlvLoader = class {
277
168
  url;
278
169
  headers;
279
170
  credentials;
280
- retry = createRetryPolicy(void 0);
171
+ readIdleTimeoutMs;
281
172
  fetchImpl;
282
173
  now;
283
- sleep;
174
+ setTimer;
175
+ clearTimer;
284
176
  abortController;
285
177
  reader;
286
178
  state = "idle";
287
179
  pausedState = false;
288
180
  resumeWaiters = [];
181
+ activeReadTimeout;
289
182
  mutableStats = {
290
183
  bytesReceived: 0,
291
184
  currentNetworkSpeed: 0
@@ -294,10 +187,11 @@ var HttpFlvLoader = class {
294
187
  this.url = config.url;
295
188
  this.headers = config.network.headers;
296
189
  this.credentials = config.network.credentials;
297
- this.retry = createRetryPolicy(config.network.retry);
190
+ this.readIdleTimeoutMs = config.network.readIdleTimeoutMs;
298
191
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
299
192
  this.now = config.now ?? (() => performance.now());
300
- this.sleep = config.sleep ?? wait;
193
+ this.setTimer = config.setTimeout ?? ((callback, ms) => setTimeout(callback, ms));
194
+ this.clearTimer = config.clearTimeout ?? ((timer) => clearTimeout(timer));
301
195
  }
302
196
  get closed() {
303
197
  return this.state === "closed";
@@ -309,33 +203,101 @@ var HttpFlvLoader = class {
309
203
  return { ...this.mutableStats };
310
204
  }
311
205
  async open() {
312
- if (this.state !== "idle") throw new HttpFlvLoaderError("RIVMUX_LOADER_INVALID_STATE", "HTTP Fetch loader can only be opened once.");
206
+ if (this.state !== "idle") throw new HttpFlvLoaderError("RIVMUX_LOADER_INVALID_STATE", "HTTP Fetch loader can only be opened once.", { phase: "open" });
313
207
  this.state = "opening";
314
208
  this.mutableStats.startedAtMs = this.now();
315
- for (let attempt = 1; attempt <= this.retry.maxAttempts; attempt += 1) {
316
- this.abortController = new AbortController();
317
- try {
318
- await this.openAttempt();
319
- return;
320
- } catch (cause) {
321
- if (this.closed || isAbortLikeError(cause) || attempt >= this.retry.maxAttempts) throw cause;
322
- await this.sleep(getRetryDelayMs(this.retry, attempt), this.abortController.signal);
323
- }
209
+ const abortController = new AbortController();
210
+ this.abortController = abortController;
211
+ let response;
212
+ try {
213
+ response = await raceWithAbort(Promise.resolve(this.fetchImpl(this.url, {
214
+ method: "GET",
215
+ headers: createHeaders(this.headers),
216
+ credentials: this.credentials,
217
+ signal: abortController.signal
218
+ })), abortController.signal, (lateResponse) => void cancelResponseBody(lateResponse));
219
+ } catch (cause) {
220
+ if (this.closed || isAbortLikeError(cause)) throw cause;
221
+ throw new HttpFlvLoaderError("RIVMUX_HTTP_NETWORK_ERROR", "HTTP Fetch loader failed to open the stream.", {
222
+ phase: "open",
223
+ reason: "network-error",
224
+ cause
225
+ });
226
+ }
227
+ if (this.closed) {
228
+ await cancelResponseBody(response);
229
+ return;
230
+ }
231
+ if (!response.ok) {
232
+ await cancelResponseBody(response);
233
+ throw new HttpFlvLoaderError("RIVMUX_HTTP_STATUS", `HTTP Fetch loader received status ${response.status} ${response.statusText}.`, {
234
+ phase: "open",
235
+ reason: "http-status",
236
+ status: response.status
237
+ });
238
+ }
239
+ if (response.body === null) throw new HttpFlvLoaderError("RIVMUX_HTTP_BODY_UNAVAILABLE", "HTTP Fetch loader response body is unavailable.", { phase: "open" });
240
+ const contentLength = response.headers.get("Content-Length");
241
+ if (contentLength !== null) {
242
+ const parsedContentLength = Number.parseInt(contentLength, 10);
243
+ if (Number.isFinite(parsedContentLength) && parsedContentLength >= 0) this.mutableStats.contentLength = parsedContentLength;
324
244
  }
245
+ try {
246
+ this.reader = response.body.getReader();
247
+ } catch (cause) {
248
+ await cancelResponseBody(response);
249
+ throw new HttpFlvLoaderError("RIVMUX_HTTP_NETWORK_ERROR", "HTTP Fetch loader could not acquire a stream reader.", {
250
+ phase: "open",
251
+ reason: "network-error",
252
+ cause
253
+ });
254
+ }
255
+ this.state = "open";
325
256
  }
326
257
  async read() {
327
258
  await this.waitUntilResumed();
328
259
  if (this.closed) return null;
329
260
  const reader = this.reader;
330
- if (reader === void 0) {
261
+ const abortController = this.abortController;
262
+ if (reader === void 0 || abortController === void 0) {
331
263
  if (this.closed) return null;
332
- throw new HttpFlvLoaderError("RIVMUX_LOADER_NOT_OPEN", "HTTP Fetch loader must be opened before read().");
264
+ throw new HttpFlvLoaderError("RIVMUX_LOADER_NOT_OPEN", "HTTP Fetch loader must be opened before read().", { phase: "read" });
265
+ }
266
+ const timeout = new PausableTimeout({
267
+ durationMs: this.readIdleTimeoutMs,
268
+ now: this.now,
269
+ setTimeout: this.setTimer,
270
+ clearTimeout: this.clearTimer
271
+ });
272
+ this.activeReadTimeout = timeout;
273
+ if (this.pausedState) timeout.pause();
274
+ let result;
275
+ try {
276
+ result = await raceWithAbort(Promise.race([reader.read(), timeout.promise]), abortController.signal);
277
+ } catch (cause) {
278
+ if (this.closed || isAbortLikeError(cause)) return null;
279
+ if (cause instanceof ReadIdleTimeoutError) throw new HttpFlvLoaderError("RIVMUX_HTTP_READ_TIMEOUT", `HTTP Fetch loader received no data for ${this.readIdleTimeoutMs} ms.`, {
280
+ phase: "read",
281
+ reason: "read-timeout",
282
+ cause
283
+ });
284
+ throw new HttpFlvLoaderError("RIVMUX_HTTP_READ_FAILED", "HTTP Fetch loader failed while reading the stream.", {
285
+ phase: "read",
286
+ reason: "read-error",
287
+ cause
288
+ });
289
+ } finally {
290
+ timeout.cancel();
291
+ if (this.activeReadTimeout === timeout) this.activeReadTimeout = void 0;
333
292
  }
334
- const result = await reader.read();
335
293
  if (result.done) {
336
294
  releaseReader(reader);
337
295
  if (this.reader === reader) this.reader = void 0;
338
- return null;
296
+ if (this.closed) return null;
297
+ throw new HttpFlvLoaderError("RIVMUX_HTTP_UNEXPECTED_EOF", "HTTP Fetch loader reached an unexpected end of the live stream.", {
298
+ phase: "read",
299
+ reason: "unexpected-eof"
300
+ });
339
301
  }
340
302
  const bytes = result.value;
341
303
  const receivedAtMs = this.now();
@@ -350,12 +312,14 @@ var HttpFlvLoader = class {
350
312
  };
351
313
  }
352
314
  pause() {
353
- if (this.closed) return;
315
+ if (this.closed || this.pausedState) return;
354
316
  this.pausedState = true;
317
+ this.activeReadTimeout?.pause();
355
318
  }
356
319
  resume() {
357
320
  if (!this.pausedState) return;
358
321
  this.pausedState = false;
322
+ this.activeReadTimeout?.resume();
359
323
  this.resolveResumeWaiters();
360
324
  }
361
325
  async close() {
@@ -363,6 +327,7 @@ var HttpFlvLoader = class {
363
327
  this.state = "closed";
364
328
  this.pausedState = false;
365
329
  this.resolveResumeWaiters();
330
+ this.activeReadTimeout?.cancel();
366
331
  this.abortController?.abort();
367
332
  const reader = this.reader;
368
333
  this.reader = void 0;
@@ -373,32 +338,6 @@ var HttpFlvLoader = class {
373
338
  releaseReader(reader);
374
339
  }
375
340
  }
376
- async openAttempt() {
377
- const abortController = this.abortController;
378
- if (abortController === void 0) throw new HttpFlvLoaderError("RIVMUX_LOADER_INVALID_STATE", "HTTP Fetch loader abort controller is missing.");
379
- const response = await this.fetchImpl(this.url, {
380
- method: "GET",
381
- headers: createHeaders(this.headers),
382
- credentials: this.credentials,
383
- signal: abortController.signal
384
- });
385
- if (this.closed) {
386
- await response.body?.cancel();
387
- return;
388
- }
389
- if (!response.ok) {
390
- await response.body?.cancel();
391
- throw new HttpFlvLoaderError("RIVMUX_HTTP_STATUS", `HTTP Fetch loader received status ${response.status} ${response.statusText}.`, response.status);
392
- }
393
- if (response.body === null) throw new HttpFlvLoaderError("RIVMUX_HTTP_BODY_UNAVAILABLE", "HTTP Fetch loader response body is unavailable.");
394
- const contentLength = response.headers.get("Content-Length");
395
- if (contentLength !== null) {
396
- const parsedContentLength = Number.parseInt(contentLength, 10);
397
- if (Number.isFinite(parsedContentLength) && parsedContentLength >= 0) this.mutableStats.contentLength = parsedContentLength;
398
- }
399
- this.reader = response.body.getReader();
400
- this.state = "open";
401
- }
402
341
  waitUntilResumed() {
403
342
  if (!this.pausedState || this.closed) return Promise.resolve();
404
343
  return new Promise((resolve) => {
@@ -419,32 +358,198 @@ function createHeaders(headers) {
419
358
  for (const [key, value] of Object.entries(headers)) result.append(key, value);
420
359
  return result;
421
360
  }
361
+ async function cancelResponseBody(response) {
362
+ try {
363
+ await response.body?.cancel();
364
+ } catch {}
365
+ }
422
366
  function releaseReader(reader) {
423
367
  try {
424
368
  reader.releaseLock();
425
369
  } catch {}
426
370
  }
427
- function wait(ms, signal) {
428
- if (ms <= 0) return Promise.resolve();
429
- if (signal.aborted) return Promise.reject(createAbortError());
371
+ function createAbortError() {
372
+ return new DOMException("HTTP Fetch loader was aborted.", "AbortError");
373
+ }
374
+ function raceWithAbort(operation, signal, onLateValue) {
375
+ if (signal.aborted) {
376
+ operation.then(onLateValue, () => void 0);
377
+ return Promise.reject(createAbortError());
378
+ }
430
379
  return new Promise((resolve, reject) => {
431
- const cleanup = () => {
432
- clearTimeout(timer);
380
+ let settled = false;
381
+ const onAbort = () => {
382
+ if (settled) return;
383
+ settled = true;
433
384
  signal.removeEventListener("abort", onAbort);
385
+ operation.then(onLateValue, () => void 0);
386
+ reject(createAbortError());
434
387
  };
388
+ signal.addEventListener("abort", onAbort, { once: true });
389
+ operation.then((value) => {
390
+ if (settled) return;
391
+ settled = true;
392
+ signal.removeEventListener("abort", onAbort);
393
+ resolve(value);
394
+ }, (error) => {
395
+ if (settled) return;
396
+ settled = true;
397
+ signal.removeEventListener("abort", onAbort);
398
+ reject(error);
399
+ });
400
+ });
401
+ }
402
+ var ReadIdleTimeoutError = class extends Error {
403
+ constructor() {
404
+ super("HTTP Fetch loader read timed out.");
405
+ this.name = "ReadIdleTimeoutError";
406
+ }
407
+ };
408
+ var PausableTimeout = class {
409
+ promise;
410
+ now;
411
+ setTimer;
412
+ clearTimer;
413
+ reject;
414
+ timer;
415
+ remainingMs;
416
+ startedAtMs = 0;
417
+ settled = false;
418
+ constructor(options) {
419
+ this.now = options.now;
420
+ this.setTimer = options.setTimeout;
421
+ this.clearTimer = options.clearTimeout;
422
+ this.remainingMs = options.durationMs;
423
+ this.promise = new Promise((_, reject) => {
424
+ this.reject = reject;
425
+ });
426
+ this.start();
427
+ }
428
+ pause() {
429
+ if (this.settled || this.timer === void 0) return;
430
+ this.remainingMs = Math.max(0, this.remainingMs - Math.max(0, this.now() - this.startedAtMs));
431
+ this.clearTimer(this.timer);
432
+ this.timer = void 0;
433
+ }
434
+ resume() {
435
+ if (this.settled || this.timer !== void 0) return;
436
+ this.start();
437
+ }
438
+ cancel() {
439
+ if (this.settled) return;
440
+ this.settled = true;
441
+ if (this.timer !== void 0) {
442
+ this.clearTimer(this.timer);
443
+ this.timer = void 0;
444
+ }
445
+ this.reject = void 0;
446
+ }
447
+ start() {
448
+ this.startedAtMs = this.now();
449
+ this.timer = this.setTimer(() => {
450
+ if (this.settled) return;
451
+ this.settled = true;
452
+ this.timer = void 0;
453
+ this.reject?.(new ReadIdleTimeoutError());
454
+ this.reject = void 0;
455
+ }, this.remainingMs);
456
+ }
457
+ };
458
+ //#endregion
459
+ //#region src/loader/retry-policy.ts
460
+ /**
461
+ * Calculates the delay after a failed one-based connection attempt.
462
+ * Attempt 1 is the initial connection, so its retry uses the base delay.
463
+ */
464
+ function getRetryDelayMs(policy, failedAttempt, random = Math.random) {
465
+ if (policy.backoffMs === 0 || policy.maxBackoffMs === 0) return 0;
466
+ const exponent = Math.max(0, Math.trunc(failedAttempt) - 1);
467
+ const exponentialDelay = Math.min(policy.maxBackoffMs, policy.backoffMs * 2 ** exponent);
468
+ const jitterMultiplier = 1 + (normalizeRandom(random()) * 2 - 1) * policy.jitterRatio;
469
+ return Math.min(policy.maxBackoffMs, Math.max(0, Math.round(exponentialDelay * jitterMultiplier)));
470
+ }
471
+ function isRecoverableHttpStatus(status) {
472
+ return status === 408 || status === 429 || status >= 500 && status <= 599;
473
+ }
474
+ function isRecoverableLoaderError(cause) {
475
+ if (!(cause instanceof HttpFlvLoaderError) || cause.reason === void 0) return false;
476
+ if (cause.reason === "http-status") return cause.status !== void 0 && isRecoverableHttpStatus(cause.status);
477
+ return cause.reason === "network-error" || cause.reason === "read-error" || cause.reason === "read-timeout" || cause.reason === "unexpected-eof";
478
+ }
479
+ function normalizeRandom(value) {
480
+ if (!Number.isFinite(value)) return .5;
481
+ return Math.min(1, Math.max(0, value));
482
+ }
483
+ //#endregion
484
+ //#region src/runtime/lifecycle.ts
485
+ function raceLifecycleOperation(operation, signal, onLateValue) {
486
+ if (signal.aborted) {
487
+ operation.then(onLateValue, () => void 0);
488
+ return Promise.resolve({ cancelled: true });
489
+ }
490
+ return new Promise((resolve, reject) => {
491
+ let settled = false;
435
492
  const onAbort = () => {
436
- cleanup();
437
- reject(createAbortError());
493
+ if (settled) return;
494
+ settled = true;
495
+ signal.removeEventListener("abort", onAbort);
496
+ operation.then(onLateValue, () => void 0);
497
+ resolve({ cancelled: true });
438
498
  };
439
- const timer = setTimeout(() => {
440
- cleanup();
441
- resolve();
442
- }, ms);
443
499
  signal.addEventListener("abort", onAbort, { once: true });
500
+ operation.then((value) => {
501
+ if (settled) return;
502
+ settled = true;
503
+ signal.removeEventListener("abort", onAbort);
504
+ resolve({
505
+ cancelled: false,
506
+ value
507
+ });
508
+ }, (error) => {
509
+ if (settled) return;
510
+ settled = true;
511
+ signal.removeEventListener("abort", onAbort);
512
+ reject(error);
513
+ });
444
514
  });
445
515
  }
446
- function createAbortError() {
447
- return new DOMException("HTTP Fetch loader was aborted.", "AbortError");
516
+ //#endregion
517
+ //#region src/runtime/stats.ts
518
+ function updateAppendQueueHighWaterMark(current, mseStats) {
519
+ return {
520
+ length: Math.max(current.length, mseStats.appendQueueLength),
521
+ bytes: Math.max(current.bytes, mseStats.appendQueueBytes)
522
+ };
523
+ }
524
+ function getNetworkIdleMs(stats, nowMs) {
525
+ const markerMs = stats?.lastChunkAtMs ?? stats?.startedAtMs;
526
+ if (markerMs === void 0) return;
527
+ return Math.max(nowMs - markerMs, 0);
528
+ }
529
+ function createPlayerStats(snapshot) {
530
+ const { loaderStats, mseStats, latencyMetrics } = snapshot;
531
+ return {
532
+ bytesReceived: loaderStats?.bytesReceived ?? 0,
533
+ currentNetworkSpeed: loaderStats?.currentNetworkSpeed ?? 0,
534
+ networkIdleMs: getNetworkIdleMs(loaderStats, snapshot.nowMs),
535
+ outputBytes: snapshot.outputBytes,
536
+ appendQueueLength: mseStats.appendQueueLength,
537
+ appendQueueBytes: mseStats.appendQueueBytes,
538
+ appendQueueMaxLength: snapshot.appendQueueMaxLength,
539
+ appendQueueMaxBytes: snapshot.appendQueueMaxBytes,
540
+ loaderPaused: snapshot.loaderPaused,
541
+ sourceBufferUpdating: mseStats.sourceBufferUpdating,
542
+ sourceBufferCount: mseStats.sourceBufferCount,
543
+ bufferedStart: latencyMetrics.bufferedStart ?? mseStats.bufferedStart,
544
+ bufferedEnd: latencyMetrics.bufferedEnd ?? mseStats.bufferedEnd,
545
+ bufferedDuration: latencyMetrics.bufferedDuration ?? mseStats.bufferedDuration,
546
+ bufferedRangeCount: mseStats.bufferedRangeCount,
547
+ currentTime: latencyMetrics.currentTime,
548
+ liveLatency: latencyMetrics.liveLatency,
549
+ playbackRate: latencyMetrics.playbackRate,
550
+ readyState: latencyMetrics.readyState,
551
+ droppedFrames: latencyMetrics.droppedFrames
552
+ };
448
553
  }
449
554
  //#endregion
450
555
  //#region src/mse/mime.ts
@@ -974,10 +1079,10 @@ function __wbg_get_imports() {
974
1079
  __proto__: null,
975
1080
  "./rivmux_transmux_core_bg.js": {
976
1081
  __proto__: null,
977
- __wbg_Error_fdd633d4bb5dd76a: function(arg0, arg1) {
1082
+ __wbg_Error_67e7344beaa85059: function(arg0, arg1) {
978
1083
  return Error(getStringFromWasm0(arg0, arg1));
979
1084
  },
980
- __wbg_Number_c4bdf66bb78f7977: function(arg0) {
1085
+ __wbg_Number_c54e7112a3fa7e3e: function(arg0) {
981
1086
  return Number(arg0);
982
1087
  },
983
1088
  __wbg_String_8564e559799eccda: function(arg0, arg1) {
@@ -986,28 +1091,28 @@ function __wbg_get_imports() {
986
1091
  getDataViewMemory0().setInt32(arg0 + 4, len1, true);
987
1092
  getDataViewMemory0().setInt32(arg0 + 0, ptr1, true);
988
1093
  },
989
- __wbg___wbindgen_throw_ea4887a5f8f9a9db: function(arg0, arg1) {
1094
+ __wbg___wbindgen_throw_5d9e815e6fdf150f: function(arg0, arg1) {
990
1095
  throw new Error(getStringFromWasm0(arg0, arg1));
991
1096
  },
992
- __wbg_new_2e117a478906f062: function() {
1097
+ __wbg_new_bebc3f4757acf305: function() {
993
1098
  return /* @__PURE__ */ new Object();
994
1099
  },
995
- __wbg_new_36e147a8ced3c6e0: function() {
1100
+ __wbg_new_ffa92086ea89f79c: function() {
996
1101
  return new Array();
997
1102
  },
1103
+ __wbg_set_13d25b81ab403f5e: function(arg0, arg1, arg2) {
1104
+ arg0[arg1 >>> 0] = arg2;
1105
+ },
998
1106
  __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
999
1107
  arg0[arg1] = arg2;
1000
1108
  },
1001
- __wbg_set_dc601f4a69da0bc2: function(arg0, arg1, arg2) {
1002
- arg0[arg1 >>> 0] = arg2;
1003
- },
1004
- __wbindgen_cast_0000000000000001: function(arg0) {
1109
+ __wbindgen_generic_0000000000000001: function(arg0) {
1005
1110
  return arg0;
1006
1111
  },
1007
- __wbindgen_cast_0000000000000002: function(arg0) {
1112
+ __wbindgen_generic_0000000000000002: function(arg0) {
1008
1113
  return arg0;
1009
1114
  },
1010
- __wbindgen_cast_0000000000000003: function(arg0, arg1) {
1115
+ __wbindgen_generic_0000000000000003: function(arg0, arg1) {
1011
1116
  return getStringFromWasm0(arg0, arg1);
1012
1117
  },
1013
1118
  __wbindgen_init_externref_table: function() {
@@ -1117,10 +1222,11 @@ function __wbg_finalize_init(instance, module) {
1117
1222
  }
1118
1223
  async function __wbg_load(module, imports) {
1119
1224
  if (typeof Response === "function" && module instanceof Response) {
1225
+ if (!module.ok) throw new Error(`failed to fetch Wasm: ${module.status} ${module.statusText} fetching '${module.url}'`);
1120
1226
  if (typeof WebAssembly.instantiateStreaming === "function") try {
1121
1227
  return await WebAssembly.instantiateStreaming(module, imports);
1122
1228
  } catch (e) {
1123
- if (module.ok && expectedResponseType(module.type) && module.headers.get("Content-Type") !== "application/wasm") console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
1229
+ if (expectedResponseType(module.type) && module.headers.get("Content-Type") !== "application/wasm") console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
1124
1230
  else throw e;
1125
1231
  }
1126
1232
  const bytes = await module.arrayBuffer();
@@ -1144,8 +1250,10 @@ async function __wbg_load(module, imports) {
1144
1250
  }
1145
1251
  async function __wbg_init(module_or_path) {
1146
1252
  if (wasm !== void 0) return wasm;
1147
- if (module_or_path !== void 0) if (Object.getPrototypeOf(module_or_path) === Object.prototype) ({module_or_path} = module_or_path);
1148
- else console.warn("using deprecated parameters for the initialization function; pass a single object instead");
1253
+ if (module_or_path !== void 0) {
1254
+ if (Object.getPrototypeOf(module_or_path) === Object.prototype) ({module_or_path} = module_or_path);
1255
+ else console.warn("using deprecated parameters for the initialization function; pass a single object instead");
1256
+ }
1149
1257
  if (module_or_path === void 0) module_or_path = new URL("rivmux_transmux_core_bg.wasm", import.meta.url);
1150
1258
  const imports = __wbg_get_imports();
1151
1259
  if (typeof module_or_path === "string" || typeof Request === "function" && module_or_path instanceof Request || typeof URL === "function" && module_or_path instanceof URL) module_or_path = fetch(module_or_path);
@@ -1470,7 +1578,14 @@ var RuntimeSession = class {
1470
1578
  await this.dependencies.applyLatencyPolicy(context);
1471
1579
  if (!this.isCurrentLoader(loader, runId) || !this.isLifecycleContextCurrent(context)) return;
1472
1580
  const chunk = await loader.read();
1473
- if (chunk === null || !this.isCurrentLoader(loader, runId) || !this.isLifecycleContextCurrent(context)) break;
1581
+ if (chunk === null) {
1582
+ if (this.isCurrentLoader(loader, runId) && this.isLifecycleContextCurrent(context)) {
1583
+ if (!await this.appendController.flush(context)) return;
1584
+ this.dependencies.onStats(loader.stats);
1585
+ }
1586
+ return;
1587
+ }
1588
+ if (!this.isCurrentLoader(loader, runId) || !this.isLifecycleContextCurrent(context)) return;
1474
1589
  this.dependencies.onStats(loader.stats);
1475
1590
  if (!await this.processEvents(this.pushChunk(chunk.bytes), context)) {
1476
1591
  await this.closeCurrentLoader(loader, runId);
@@ -1480,16 +1595,16 @@ var RuntimeSession = class {
1480
1595
  if (!this.isCurrentLoader(loader, runId) || !this.isLifecycleContextCurrent(context)) return;
1481
1596
  this.dependencies.onStats(loader.stats);
1482
1597
  }
1483
- if (this.isCurrentLoader(loader, runId) && this.isLifecycleContextCurrent(context)) {
1484
- if (!await this.appendController.flush(context)) return;
1485
- this.dependencies.onStats(loader.stats);
1486
- }
1487
1598
  } catch (cause) {
1488
1599
  if (!this.isCurrentLoader(loader, runId) || isAbortLikeError(cause)) return;
1489
1600
  try {
1490
1601
  await this.closeCurrentLoader(loader, runId);
1491
1602
  } catch {}
1492
1603
  if (!this.isLifecycleContextCurrent(context)) return;
1604
+ if (isRecoverableLoaderError(cause)) {
1605
+ this.dependencies.onRecoverableFailure(cause, context);
1606
+ return;
1607
+ }
1493
1608
  const code = cause instanceof HttpFlvLoaderError ? cause.code : "RIVMUX_HTTP_LOADER_FAILED";
1494
1609
  this.dependencies.onFailure("network", code, "HTTP Fetch loader failed.", cause);
1495
1610
  } finally {
@@ -1531,11 +1646,6 @@ var RuntimeSession = class {
1531
1646
  if (append !== void 0 && !await append) return false;
1532
1647
  break;
1533
1648
  }
1534
- case "probeResult":
1535
- case "trackConfig":
1536
- case "sample":
1537
- case "metadata":
1538
- case "discontinuity": break;
1539
1649
  }
1540
1650
  }
1541
1651
  return this.appendController.waitForTail();
@@ -1583,6 +1693,8 @@ var RuntimeWorker = class {
1583
1693
  port;
1584
1694
  detectRuntime;
1585
1695
  now;
1696
+ sleep;
1697
+ random;
1586
1698
  state = "idle";
1587
1699
  url;
1588
1700
  options;
@@ -1598,21 +1710,31 @@ var RuntimeWorker = class {
1598
1710
  lifecycleGeneration = 0;
1599
1711
  lifecycleAbortController = new AbortController();
1600
1712
  fatalCleanupPromise = Promise.resolve();
1713
+ recoveryPromise;
1714
+ connectionAttempt = 1;
1715
+ recoveryStartedAt;
1716
+ pendingRecovery;
1601
1717
  constructor(port, dependencies = {}) {
1602
1718
  this.port = port;
1603
1719
  this.detectRuntime = dependencies.detectRuntime ?? detectWorkerRuntime;
1604
1720
  this.now = dependencies.now ?? (() => performance.now());
1721
+ this.sleep = dependencies.sleep ?? waitForDelay;
1722
+ this.random = dependencies.random ?? Math.random;
1605
1723
  this.session = new RuntimeSession({
1606
1724
  ...dependencies,
1607
1725
  isStarted: () => this.state === "started",
1608
1726
  isLifecycleContextCurrent: (context) => this.isLifecycleContextCurrent(context),
1609
- onMediaAppended: (context) => this.applyLatencyPolicy(context),
1727
+ onMediaAppended: async (context) => {
1728
+ this.markRecovered(context);
1729
+ await this.applyLatencyPolicy(context);
1730
+ },
1610
1731
  onAppendError: (cause, context) => {
1611
1732
  if (this.isLifecycleContextCurrent(context)) this.fail("mse", "RIVMUX_MSE_APPEND_FAILED", "MSE append failed.", true, cause);
1612
1733
  },
1613
1734
  onLoaderClosing: () => this.stopStatsTimer(),
1614
1735
  onStats: (stats) => this.postStats(stats),
1615
1736
  onMessage: (message) => this.post(message),
1737
+ onRecoverableFailure: (cause, context) => this.scheduleRecovery(cause, context),
1616
1738
  onFailure: (kind, code, message, cause) => this.fail(kind, code, message, true, cause),
1617
1739
  onPlayerError: (error) => this.failWithError(error),
1618
1740
  applyLatencyPolicy: (context) => this.applyLatencyPolicy(context),
@@ -1661,10 +1783,6 @@ var RuntimeWorker = class {
1661
1783
  case "stop":
1662
1784
  await this.stop();
1663
1785
  return;
1664
- case "update-options":
1665
- this.options = this.options === void 0 ? void 0 : mergeOptions(this.options, command.options);
1666
- if (this.options !== void 0) this.latencyController = createLatencyController(this.options);
1667
- return;
1668
1786
  case "video-state":
1669
1787
  this.videoState = command.state;
1670
1788
  if (!(await raceLifecycleOperation(this.applyLatencyPolicy(context), context.signal)).cancelled && this.isLifecycleContextCurrent(context) && this.state === "started") this.postStats();
@@ -1704,7 +1822,10 @@ var RuntimeWorker = class {
1704
1822
  this.fail("runtime", "RIVMUX_WORKER_START_REQUIRES_ATTACH", "Worker start requires an attached MediaSource.", true);
1705
1823
  return;
1706
1824
  }
1707
- if (this.state === "started") return;
1825
+ if (this.state === "started") {
1826
+ this.post({ type: "started" });
1827
+ return;
1828
+ }
1708
1829
  try {
1709
1830
  const transmuxCore = await this.session.createCore(options, context);
1710
1831
  if (context.generation !== this.lifecycleGeneration) return;
@@ -1724,24 +1845,30 @@ var RuntimeWorker = class {
1724
1845
  if ((await raceLifecycleOperation(this.applyLatencyPolicy(context), context.signal)).cancelled || !this.isLifecycleContextCurrent(context)) return;
1725
1846
  this.startStatsTimer();
1726
1847
  this.postStats();
1727
- this.startLoader(context);
1848
+ if (!this.startLoader(context)) return;
1849
+ if (!this.isLifecycleContextCurrent(context) || this.state !== "started") return;
1850
+ this.post({ type: "started" });
1728
1851
  }
1729
1852
  async stop() {
1853
+ await this.waitForRecovery();
1730
1854
  await this.closeLoader();
1731
1855
  this.session.destroyMse();
1732
1856
  this.latencyController?.reset();
1733
1857
  this.videoState = void 0;
1734
1858
  this.lastLatencyMetrics = {};
1859
+ this.resetRecoveryCycle();
1735
1860
  this.state = "stopped";
1736
1861
  this.post({ type: "stopped" });
1737
1862
  }
1738
1863
  async destroy() {
1739
1864
  await this.fatalCleanupPromise;
1865
+ await this.waitForRecovery();
1740
1866
  await this.closeLoader();
1741
1867
  this.session.destroyMse();
1742
1868
  this.latencyController?.reset();
1743
1869
  this.videoState = void 0;
1744
1870
  this.lastLatencyMetrics = {};
1871
+ this.resetRecoveryCycle();
1745
1872
  this.state = "destroyed";
1746
1873
  this.post({ type: "destroyed" });
1747
1874
  this.port.close();
@@ -1751,17 +1878,117 @@ var RuntimeWorker = class {
1751
1878
  const url = this.url;
1752
1879
  if (options === void 0 || url === void 0) {
1753
1880
  this.fail("runtime", "RIVMUX_WORKER_NOT_INITIALIZED", "Worker must be initialized before loader start.", true);
1754
- return;
1881
+ return false;
1755
1882
  }
1756
1883
  this.session.runLoader({
1757
1884
  url,
1758
1885
  network: options.network
1759
1886
  }, context);
1887
+ return true;
1760
1888
  }
1761
1889
  async closeLoader() {
1762
1890
  this.stopStatsTimer();
1763
1891
  await this.session.close();
1764
1892
  }
1893
+ scheduleRecovery(cause, context) {
1894
+ if (!this.isLifecycleContextCurrent(context) || this.state !== "started" || this.recoveryPromise !== void 0) return;
1895
+ const recovery = this.recover(cause, context).finally(() => {
1896
+ if (this.recoveryPromise === recovery) this.recoveryPromise = void 0;
1897
+ });
1898
+ this.recoveryPromise = recovery;
1899
+ }
1900
+ async recover(cause, context) {
1901
+ const options = this.options;
1902
+ if (options === void 0 || !this.isLifecycleContextCurrent(context) || this.state !== "started") return;
1903
+ this.recoveryStartedAt ??= this.now();
1904
+ if (this.connectionAttempt >= options.network.retry.maxAttempts) {
1905
+ this.fail("network", "RIVMUX_RECONNECT_EXHAUSTED", "Live stream reconnect attempts were exhausted.", true, cause);
1906
+ return;
1907
+ }
1908
+ const failedAttempt = this.connectionAttempt;
1909
+ const attempt = failedAttempt + 1;
1910
+ this.connectionAttempt = attempt;
1911
+ const delayMs = getRetryDelayMs(options.network.retry, failedAttempt, this.random);
1912
+ this.post({
1913
+ type: "reconnecting",
1914
+ info: {
1915
+ attempt,
1916
+ maxAttempts: options.network.retry.maxAttempts,
1917
+ delayMs,
1918
+ reason: cause.reason ?? "network-error"
1919
+ }
1920
+ });
1921
+ await this.closeLoader();
1922
+ this.session.destroyMse();
1923
+ this.latencyController?.reset();
1924
+ this.lastLatencyMetrics = {};
1925
+ if (!this.isLifecycleContextCurrent(context) || this.state !== "started") return;
1926
+ try {
1927
+ if ((await raceLifecycleOperation(this.sleep(delayMs, context.signal), context.signal)).cancelled || !this.isLifecycleContextCurrent(context) || this.state !== "started") return;
1928
+ } catch (delayError) {
1929
+ if (this.isLifecycleContextCurrent(context)) this.fail("network", "RIVMUX_RECONNECT_DELAY_FAILED", "Live stream reconnect delay failed.", true, delayError);
1930
+ return;
1931
+ }
1932
+ let handle;
1933
+ try {
1934
+ handle = await this.session.attach(context);
1935
+ } catch (attachError) {
1936
+ if (this.isLifecycleContextCurrent(context)) this.fail("mse", "RIVMUX_MSE_ATTACH_FAILED", "MSE media source attachment failed during reconnect.", true, attachError);
1937
+ return;
1938
+ }
1939
+ if (handle === void 0 || !this.isLifecycleContextCurrent(context) || this.state !== "started") return;
1940
+ this.post({
1941
+ type: "media-source-handle",
1942
+ handle
1943
+ }, [handle]);
1944
+ try {
1945
+ const core = await this.session.createCore(options, context);
1946
+ if (!this.isLifecycleContextCurrent(context) || this.state !== "started") return;
1947
+ if (core === void 0) {
1948
+ this.fail("runtime", "RIVMUX_TRANSMUX_CORE_UNAVAILABLE", "Transmux core is not available during reconnect.", true);
1949
+ return;
1950
+ }
1951
+ this.session.start(core, context);
1952
+ } catch (coreError) {
1953
+ if (this.isLifecycleContextCurrent(context)) this.fail("runtime", "RIVMUX_TRANSMUX_CORE_UNAVAILABLE", "Transmux core is not available during reconnect.", true, coreError);
1954
+ return;
1955
+ }
1956
+ this.pendingRecovery = {
1957
+ attempt,
1958
+ downtimeMs: 0
1959
+ };
1960
+ this.appendQueueMaxLength = 0;
1961
+ this.appendQueueMaxBytes = 0;
1962
+ this.startStatsTimer();
1963
+ this.postStats();
1964
+ this.recoveryPromise = void 0;
1965
+ this.startLoader(context);
1966
+ }
1967
+ markRecovered(context) {
1968
+ const recovery = this.pendingRecovery;
1969
+ const startedAt = this.recoveryStartedAt;
1970
+ if (recovery === void 0 || startedAt === void 0 || !this.isLifecycleContextCurrent(context) || this.state !== "started") return;
1971
+ this.pendingRecovery = void 0;
1972
+ this.recoveryStartedAt = void 0;
1973
+ this.connectionAttempt = 1;
1974
+ this.post({
1975
+ type: "recovered",
1976
+ info: {
1977
+ attempt: recovery.attempt,
1978
+ downtimeMs: Math.max(0, this.now() - startedAt)
1979
+ }
1980
+ });
1981
+ }
1982
+ async waitForRecovery() {
1983
+ try {
1984
+ await this.recoveryPromise;
1985
+ } catch {}
1986
+ }
1987
+ resetRecoveryCycle() {
1988
+ this.connectionAttempt = 1;
1989
+ this.recoveryStartedAt = void 0;
1990
+ this.pendingRecovery = void 0;
1991
+ }
1765
1992
  postStats(loaderStats) {
1766
1993
  const mseStats = this.collectMseStats();
1767
1994
  const loaderSnapshot = loaderStats ?? this.session.loaderStats;
@@ -1884,6 +2111,7 @@ var RuntimeWorker = class {
1884
2111
  this.latencyController?.reset();
1885
2112
  this.videoState = void 0;
1886
2113
  this.lastLatencyMetrics = {};
2114
+ this.resetRecoveryCycle();
1887
2115
  }
1888
2116
  post(message, transfer) {
1889
2117
  this.port.postMessage(message, transfer);
@@ -1894,12 +2122,41 @@ var RuntimeWorker = class {
1894
2122
  this.lifecycleAbortController = new AbortController();
1895
2123
  }
1896
2124
  };
2125
+ function waitForDelay(delayMs, signal) {
2126
+ if (delayMs <= 0) return Promise.resolve();
2127
+ if (signal.aborted) return Promise.reject(new DOMException("Reconnect delay was aborted.", "AbortError"));
2128
+ return new Promise((resolve, reject) => {
2129
+ const cleanup = () => {
2130
+ clearTimeout(timer);
2131
+ signal.removeEventListener("abort", onAbort);
2132
+ };
2133
+ const onAbort = () => {
2134
+ cleanup();
2135
+ reject(new DOMException("Reconnect delay was aborted.", "AbortError"));
2136
+ };
2137
+ const timer = setTimeout(() => {
2138
+ cleanup();
2139
+ resolve();
2140
+ }, delayMs);
2141
+ signal.addEventListener("abort", onAbort, { once: true });
2142
+ });
2143
+ }
1897
2144
  function serializeCause(cause) {
1898
2145
  if (cause instanceof Error) return {
1899
2146
  name: cause.name,
1900
2147
  message: cause.message
1901
2148
  };
1902
- return cause;
2149
+ if (typeof cause === "object" && cause !== null) {
2150
+ const value = cause;
2151
+ return {
2152
+ name: typeof value.name === "string" ? value.name : "Error",
2153
+ message: typeof value.message === "string" ? value.message : String(cause)
2154
+ };
2155
+ }
2156
+ return {
2157
+ name: "Error",
2158
+ message: String(cause)
2159
+ };
1903
2160
  }
1904
2161
  function detectWorkerRuntime() {
1905
2162
  if (typeof fetch !== "function") return createUnsupportedRuntimeError("RIVMUX_UNSUPPORTED_FETCH", "Fetch is not available in this worker runtime.");