@f8team/reel-core 1.0.3 → 1.0.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @f8team/reel-core
2
2
 
3
+ ## 1.0.4
4
+
5
+ ### Patch Changes
6
+
7
+ - b476560: Harden HLS playback against repeated fragment request storms by bounding HLS load retries, detecting looped fragment loads, aborting stuck loaders, and surfacing retryable player errors instead of leaving playback stuck in loading.
8
+
3
9
  ## 1.0.3
4
10
 
5
11
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -7,6 +7,7 @@ var RETRYABLE_BY_DEFAULT = {
7
7
  decode: false,
8
8
  unauthorized: false,
9
9
  unsupported: false,
10
+ "hls-fragment-loop": true,
10
11
  internal: false
11
12
  };
12
13
  function createPlayerError(input) {
@@ -266,6 +267,54 @@ function resolveWithCredentials(policy, url) {
266
267
  }
267
268
 
268
269
  // src/sources/hls.ts
270
+ var REPEATED_FRAGMENT_LOAD_LIMIT = 4;
271
+ var MEDIA_RECOVERY_LIMIT = 1;
272
+ function makeLoadPolicy(options) {
273
+ return {
274
+ default: {
275
+ maxTimeToFirstByteMs: options.maxTimeToFirstByteMs,
276
+ maxLoadTimeMs: options.maxLoadTimeMs,
277
+ timeoutRetry: options.timeoutRetry,
278
+ errorRetry: options.errorRetry
279
+ }
280
+ };
281
+ }
282
+ function makeLinearRetry(maxNumRetry, retryDelayMs, maxRetryDelayMs) {
283
+ return {
284
+ maxNumRetry,
285
+ retryDelayMs,
286
+ maxRetryDelayMs,
287
+ backoff: "linear"
288
+ };
289
+ }
290
+ function makeHlsLoadPolicies() {
291
+ return {
292
+ manifestLoadPolicy: makeLoadPolicy({
293
+ maxTimeToFirstByteMs: 1e4,
294
+ maxLoadTimeMs: 2e4,
295
+ timeoutRetry: makeLinearRetry(1, 0, 0),
296
+ errorRetry: makeLinearRetry(1, 1e3, 8e3)
297
+ }),
298
+ playlistLoadPolicy: makeLoadPolicy({
299
+ maxTimeToFirstByteMs: 1e4,
300
+ maxLoadTimeMs: 2e4,
301
+ timeoutRetry: makeLinearRetry(1, 0, 0),
302
+ errorRetry: makeLinearRetry(2, 1e3, 8e3)
303
+ }),
304
+ keyLoadPolicy: makeLoadPolicy({
305
+ maxTimeToFirstByteMs: 8e3,
306
+ maxLoadTimeMs: 2e4,
307
+ timeoutRetry: makeLinearRetry(1, 1e3, 8e3),
308
+ errorRetry: makeLinearRetry(2, 1e3, 8e3)
309
+ }),
310
+ fragLoadPolicy: makeLoadPolicy({
311
+ maxTimeToFirstByteMs: 1e4,
312
+ maxLoadTimeMs: 3e4,
313
+ timeoutRetry: makeLinearRetry(1, 0, 0),
314
+ errorRetry: makeLinearRetry(2, 1e3, 4e3)
315
+ })
316
+ };
317
+ }
269
318
  var defaultLoad = () => {
270
319
  return import('hls.js').then((m) => m.default);
271
320
  };
@@ -290,13 +339,17 @@ var HlsLoader = class {
290
339
  this.rejectAttach = null;
291
340
  this.manifestTimeout = null;
292
341
  this.aborted = false;
342
+ this.lastLoadingFragmentKey = null;
343
+ this.repeatedFragmentLoadCount = 0;
344
+ this.repeatedFragmentErrorFired = false;
345
+ this.mediaRecoveryCount = 0;
293
346
  /** `false` idle; `'auto'` → first `LEVEL_SWITCHED` clears; otherwise await this level index. */
294
347
  this.qualityAwait = false;
295
348
  this.qualitySwitchTimer = null;
296
349
  /** Tracks consecutive menu selections so redundant "Auto → Auto" skips the spinner. */
297
350
  this.lastChosenQualityWasAuto = false;
298
351
  /**
299
- * `null` — user chose ABR auto (menu shows "Auto"). Real hls.js still exposes
352
+ * `null` - user chose ABR auto (menu shows "Auto"). Real hls.js still exposes
300
353
  * `currentLevel >= 0` for the playing rendition; we must not mirror that into UI state.
301
354
  */
302
355
  this.manualLevelIndex = null;
@@ -352,6 +405,7 @@ var HlsLoader = class {
352
405
  attachWithHlsJs(Hls, video, source) {
353
406
  const hls = new Hls({
354
407
  enableWorker: true,
408
+ ...makeHlsLoadPolicies(),
355
409
  xhrSetup: (xhr, url) => this.installXhrWatcher(xhr, url, source)
356
410
  });
357
411
  this.hls = hls;
@@ -372,27 +426,44 @@ var HlsLoader = class {
372
426
  this.onLevelEventuallyStabilized(...args);
373
427
  this.refreshActiveQuality();
374
428
  };
429
+ const onFragmentLoading = (...args) => {
430
+ this.onFragmentLoading(...args);
431
+ };
432
+ const onFragmentBuffered = (...args) => {
433
+ this.resetFragmentLoopGuardFromEvent(...args);
434
+ };
375
435
  const onError = (..._args) => {
376
- const data = _args[1] ?? {};
436
+ const data = this.getHlsEventData(_args);
377
437
  const status = data.response?.code;
438
+ if (this.tryRecoverMediaError(Hls, data)) {
439
+ return;
440
+ }
378
441
  const isHttpError = typeof status === "number" && status >= 400;
379
- if (data.fatal || isHttpError) {
380
- const message = data.fatal ? `HLS fatal error: ${data.type ?? "unknown"}` : `HLS load error: HTTP ${status}`;
442
+ const isTerminalHlsError = this.isTerminalHlsError(data);
443
+ if (data.fatal || isHttpError || isTerminalHlsError) {
444
+ const message = this.formatHlsErrorMessage(data, status);
445
+ this.stopHlsLoading();
381
446
  this.options.onError?.({
447
+ code: this.getHlsErrorCode(data, status),
382
448
  message,
383
449
  cause: data,
384
450
  status,
385
- url: data.response?.url ?? data.url
451
+ url: this.getErrorUrl(data),
452
+ retryable: this.isRetryableHlsError(data, status)
386
453
  });
387
454
  settle(() => reject(new Error(message)));
388
455
  }
389
456
  };
390
457
  hls.on(Hls.Events.MANIFEST_PARSED, onParsed);
391
458
  hls.on(Hls.Events.LEVEL_SWITCHED, onSwitched);
459
+ hls.on(Hls.Events.FRAG_LOADING, onFragmentLoading);
460
+ hls.on(Hls.Events.FRAG_BUFFERED, onFragmentBuffered);
392
461
  hls.on(Hls.Events.ERROR, onError);
393
462
  this.detachListeners.push(
394
463
  () => hls.off(Hls.Events.MANIFEST_PARSED, onParsed),
395
464
  () => hls.off(Hls.Events.LEVEL_SWITCHED, onSwitched),
465
+ () => hls.off(Hls.Events.FRAG_LOADING, onFragmentLoading),
466
+ () => hls.off(Hls.Events.FRAG_BUFFERED, onFragmentBuffered),
396
467
  () => hls.off(Hls.Events.ERROR, onError),
397
468
  () => settle(() => reject(makeAbortError()))
398
469
  );
@@ -400,6 +471,7 @@ var HlsLoader = class {
400
471
  if (timeoutMs > 0) {
401
472
  this.manifestTimeout = setTimeout(() => {
402
473
  const message = `HLS manifest timeout after ${timeoutMs}ms`;
474
+ this.stopHlsLoading();
403
475
  this.options.onError?.({ message, cause: { type: "timeout" }, url: source.src });
404
476
  settle(() => reject(new Error(message)));
405
477
  }, timeoutMs);
@@ -414,7 +486,7 @@ var HlsLoader = class {
414
486
  }
415
487
  /**
416
488
  * Wire the readystatechange watcher and remove it as soon as the XHR
417
- * completes — without this cleanup hls.js (which spawns one XHR per segment)
489
+ * completes - without this cleanup hls.js (which spawns one XHR per segment)
418
490
  * leaks a closure per request (A1).
419
491
  */
420
492
  installXhrWatcher(xhr, url, source) {
@@ -440,10 +512,149 @@ var HlsLoader = class {
440
512
  };
441
513
  xhr.addEventListener("readystatechange", watcher);
442
514
  }
443
- abort() {
444
- if (this.aborted) return;
445
- this.aborted = true;
446
- this.clearManifestTimeout();
515
+ getHlsEventData(args) {
516
+ const data = args[1] ?? args[0];
517
+ return data !== null && typeof data === "object" ? data : {};
518
+ }
519
+ getFragmentUrl(data) {
520
+ return data.frag?.url ?? data.frag?.relurl ?? data.response?.url ?? data.url;
521
+ }
522
+ getErrorUrl(data) {
523
+ return this.getFragmentUrl(data) ?? this.currentSource?.src;
524
+ }
525
+ getFragmentKey(data) {
526
+ const url = this.getFragmentUrl(data);
527
+ const frag = data.frag;
528
+ if (!frag) return url ?? null;
529
+ const type = frag.type ?? "main";
530
+ const level = frag.level ?? "unknown-level";
531
+ const sn = frag.sn ?? "unknown-sn";
532
+ const cc = frag.cc ?? "unknown-cc";
533
+ const start = frag.start ?? frag.startPTS ?? "unknown-start";
534
+ return `${url ?? "unknown-url"}:${type}:${level}:${sn}:${cc}:${start}`;
535
+ }
536
+ isTerminalHlsError(data) {
537
+ switch (data.details) {
538
+ case "manifestParsingError":
539
+ case "manifestIncompatibleCodecsError":
540
+ case "levelEmptyError":
541
+ case "levelParsingError":
542
+ case "fragParsingError":
543
+ case "fragDecryptError":
544
+ case "keyLoadError":
545
+ case "keyLoadTimeOut":
546
+ case "bufferFullError":
547
+ return true;
548
+ default:
549
+ return false;
550
+ }
551
+ }
552
+ formatHlsErrorMessage(data, status) {
553
+ if (data.fatal) {
554
+ const type2 = data.type ?? "unknown";
555
+ const details2 = data.details ? ` (${data.details})` : "";
556
+ return `HLS fatal error: ${type2}${details2}`;
557
+ }
558
+ if (typeof status === "number" && status >= 400) {
559
+ return `HLS load error: HTTP ${status}${data.details ? ` (${data.details})` : ""}`;
560
+ }
561
+ const type = data.type ?? "unknown";
562
+ const details = data.details ? ` (${data.details})` : "";
563
+ return `HLS error: ${type}${details}`;
564
+ }
565
+ getHlsErrorCode(data, status) {
566
+ if (status === 401 || status === 403) return "unauthorized";
567
+ switch (data.details) {
568
+ case "manifestParsingError":
569
+ case "manifestIncompatibleCodecsError":
570
+ case "levelParsingError":
571
+ case "fragParsingError":
572
+ case "fragDecryptError":
573
+ return "decode";
574
+ default:
575
+ return "network";
576
+ }
577
+ }
578
+ isRetryableHlsError(data, status) {
579
+ if (status === 401 || status === 403) return false;
580
+ if (typeof status === "number" && status >= 400 && status < 500) return false;
581
+ switch (data.details) {
582
+ case "manifestParsingError":
583
+ case "manifestIncompatibleCodecsError":
584
+ case "levelParsingError":
585
+ case "fragParsingError":
586
+ case "fragDecryptError":
587
+ return false;
588
+ default:
589
+ return true;
590
+ }
591
+ }
592
+ isRecoverableMediaError(Hls, data) {
593
+ if (data.type !== Hls.ErrorTypes.MEDIA_ERROR && data.type !== "mediaError") {
594
+ return false;
595
+ }
596
+ if (!data.fatal) return false;
597
+ if (this.mediaRecoveryCount >= MEDIA_RECOVERY_LIMIT) return false;
598
+ if (typeof this.hls?.recoverMediaError !== "function") return false;
599
+ switch (data.details) {
600
+ case void 0:
601
+ case "bufferAppendError":
602
+ case "bufferAppendingError":
603
+ case "bufferStalledError":
604
+ case "bufferSeekOverHole":
605
+ case "bufferNudgeOnStall":
606
+ case "bufferFullError":
607
+ return true;
608
+ default:
609
+ return false;
610
+ }
611
+ }
612
+ tryRecoverMediaError(Hls, data) {
613
+ if (!this.isRecoverableMediaError(Hls, data)) return false;
614
+ this.mediaRecoveryCount += 1;
615
+ this.resetFragmentLoopGuard();
616
+ this.completeQualityAwaitIfBusy();
617
+ try {
618
+ this.hls?.recoverMediaError?.();
619
+ return true;
620
+ } catch {
621
+ return false;
622
+ }
623
+ }
624
+ onFragmentLoading(...args) {
625
+ if (this.repeatedFragmentErrorFired) return;
626
+ const data = this.getHlsEventData(args);
627
+ const key = this.getFragmentKey(data);
628
+ if (!key) return;
629
+ if (key === this.lastLoadingFragmentKey) {
630
+ this.repeatedFragmentLoadCount += 1;
631
+ } else {
632
+ this.lastLoadingFragmentKey = key;
633
+ this.repeatedFragmentLoadCount = 1;
634
+ }
635
+ if (this.repeatedFragmentLoadCount < REPEATED_FRAGMENT_LOAD_LIMIT) return;
636
+ this.repeatedFragmentErrorFired = true;
637
+ this.stopHlsLoading();
638
+ const url = this.getFragmentUrl(data);
639
+ this.options.onError?.({
640
+ code: "hls-fragment-loop",
641
+ message: `HLS fragment repeatedly loading: ${url ?? key}`,
642
+ cause: data,
643
+ url
644
+ });
645
+ }
646
+ resetFragmentLoopGuardFromEvent(...args) {
647
+ const data = this.getHlsEventData(args);
648
+ if (this.getFragmentKey(data)) {
649
+ this.resetFragmentLoopGuard();
650
+ }
651
+ }
652
+ resetFragmentLoopGuard() {
653
+ this.lastLoadingFragmentKey = null;
654
+ this.repeatedFragmentLoadCount = 0;
655
+ this.repeatedFragmentErrorFired = false;
656
+ }
657
+ abortLiveXhrs() {
447
658
  for (const xhr of [...this.liveXhrs]) {
448
659
  try {
449
660
  xhr.abort();
@@ -451,6 +662,20 @@ var HlsLoader = class {
451
662
  }
452
663
  }
453
664
  this.liveXhrs.clear();
665
+ }
666
+ stopHlsLoading() {
667
+ this.completeQualityAwaitIfBusy();
668
+ try {
669
+ this.hls?.stopLoad?.();
670
+ } catch {
671
+ }
672
+ this.abortLiveXhrs();
673
+ }
674
+ abort() {
675
+ if (this.aborted) return;
676
+ this.aborted = true;
677
+ this.clearManifestTimeout();
678
+ this.abortLiveXhrs();
454
679
  const reject = this.rejectAttach;
455
680
  this.rejectAttach = null;
456
681
  reject?.(makeAbortError());
@@ -486,6 +711,8 @@ var HlsLoader = class {
486
711
  this.aborted = false;
487
712
  this.lastChosenQualityWasAuto = false;
488
713
  this.manualLevelIndex = null;
714
+ this.mediaRecoveryCount = 0;
715
+ this.resetFragmentLoopGuard();
489
716
  }
490
717
  clearQualitySwitchTimerOnly() {
491
718
  if (this.qualitySwitchTimer !== null) {
@@ -607,7 +834,7 @@ var HlsLoader = class {
607
834
  const locked = this.qualities[this.manualLevelIndex] ?? null;
608
835
  this.options.onActiveQuality?.(locked, false);
609
836
  }
610
- /** @internal — for tests. */
837
+ /** @internal - for tests. */
611
838
  _getInternalState() {
612
839
  return { source: this.currentSource, qualities: this.qualities };
613
840
  }
@@ -1248,7 +1475,14 @@ function createPlayer(options = {}, internal = {}) {
1248
1475
  onUnauthorized: (e) => emitUnauthorized(e),
1249
1476
  onQualities: (q) => setQualities(q),
1250
1477
  onActiveQuality: (q, auto) => setActiveQuality(q, auto),
1251
- onError: (err) => applyError(createPlayerError({ ...err, code: "network" })),
1478
+ onError: (err) => {
1479
+ const playerError = createPlayerError({ ...err, code: err.code ?? "network" });
1480
+ if (status === "loading") {
1481
+ dispatch({ type: "loadFailed", error: playerError });
1482
+ return;
1483
+ }
1484
+ dispatch({ type: "runtimeError", error: playerError });
1485
+ },
1252
1486
  onQualitySwitch: (active) => bus.emit("qualityswitch", { active })
1253
1487
  })
1254
1488
  );