@guardvideo/player-sdk 3.3.0 → 3.5.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/dist/index.js CHANGED
@@ -70,8 +70,19 @@ class WatchChunkAccumulator {
70
70
  }
71
71
  }
72
72
 
73
+ const VALID_EVENT_TYPES = [
74
+ 'watch_chunk',
75
+ 'play',
76
+ 'pause',
77
+ 'seek',
78
+ 'ended',
79
+ 'quality_change',
80
+ 'error',
81
+ 'security_event',
82
+ ];
73
83
  const MAX_BATCH_SIZE = 25;
74
84
  const DEFAULT_FLUSH_INTERVAL = 5000;
85
+ const MAX_FLUSH_BACKOFF = 60000;
75
86
  class EventTracker {
76
87
  constructor(config) {
77
88
  this.buffer = [];
@@ -79,6 +90,8 @@ class EventTracker {
79
90
  this.accumulator = new WatchChunkAccumulator();
80
91
  this.destroyed = false;
81
92
  this._onBeforeUnload = null;
93
+ this.flushBackoff = 0;
94
+ this.flushing = false;
82
95
  this.config = {
83
96
  endpoint: config.endpoint,
84
97
  tokenId: config.tokenId,
@@ -94,6 +107,12 @@ class EventTracker {
94
107
  track(type, positionSeconds, payload) {
95
108
  if (this.destroyed)
96
109
  return;
110
+ if (!VALID_EVENT_TYPES.includes(type)) {
111
+ if (this.config.debug) {
112
+ console.warn('[GuardVideo EventTracker] Skipping unknown event type:', type);
113
+ }
114
+ return;
115
+ }
97
116
  this.buffer.push({
98
117
  event_type: type,
99
118
  event_at: new Date().toISOString(),
@@ -117,7 +136,7 @@ class EventTracker {
117
136
  this.config.sessionId = sessionId;
118
137
  }
119
138
  async flush() {
120
- if (this.destroyed)
139
+ if (this.destroyed || this.flushing)
121
140
  return;
122
141
  const chunks = this.accumulator.drain();
123
142
  for (const c of chunks) {
@@ -125,8 +144,14 @@ class EventTracker {
125
144
  }
126
145
  if (this.buffer.length === 0)
127
146
  return;
128
- const batch = this.buffer.splice(0, MAX_BATCH_SIZE);
129
- await this.sendBatch(batch);
147
+ this.flushing = true;
148
+ try {
149
+ const batch = this.buffer.splice(0, MAX_BATCH_SIZE);
150
+ await this.sendBatch(batch);
151
+ }
152
+ finally {
153
+ this.flushing = false;
154
+ }
130
155
  }
131
156
  destroy() {
132
157
  this.destroyed = true;
@@ -141,6 +166,11 @@ class EventTracker {
141
166
  startAutoFlush() {
142
167
  this.flushTimer = setInterval(() => this.flush(), this.config.flushIntervalMs);
143
168
  }
169
+ restartAutoFlush(intervalMs) {
170
+ if (this.flushTimer)
171
+ clearInterval(this.flushTimer);
172
+ this.flushTimer = setInterval(() => this.flush(), intervalMs);
173
+ }
144
174
  hookPageUnload() {
145
175
  if (typeof window === 'undefined')
146
176
  return;
@@ -186,8 +216,25 @@ class EventTracker {
186
216
  body,
187
217
  credentials: 'omit',
188
218
  });
189
- if (!resp.ok && this.config.debug) {
190
- console.warn('[GuardVideo EventTracker] Flush failed:', resp.status);
219
+ if (!resp.ok) {
220
+ if (resp.status === 429) {
221
+ this.flushBackoff = Math.min((this.flushBackoff || this.config.flushIntervalMs) * 2, MAX_FLUSH_BACKOFF);
222
+ if (this.config.debug) {
223
+ console.warn('[GuardVideo EventTracker] 429 — backing off to', this.flushBackoff, 'ms');
224
+ }
225
+ this.buffer.unshift(...events);
226
+ this.restartAutoFlush(this.flushBackoff);
227
+ return;
228
+ }
229
+ if (this.config.debug) {
230
+ console.warn('[GuardVideo EventTracker] Flush failed:', resp.status);
231
+ }
232
+ }
233
+ else {
234
+ if (this.flushBackoff > 0) {
235
+ this.flushBackoff = 0;
236
+ this.restartAutoFlush(this.config.flushIntervalMs);
237
+ }
191
238
  }
192
239
  }
193
240
  catch (err) {
@@ -403,6 +450,11 @@ class GuardVideoPlayer {
403
450
  this._onSecurityEvent = null;
404
451
  this.playlistNonce = null;
405
452
  this.nonceRefreshInProgress = false;
453
+ this.nonceRefreshTimer = null;
454
+ this.retryBackoff = 1000;
455
+ this.MAX_BACKOFF = 30000;
456
+ this.rateLimitCooldownUntil = 0;
457
+ this.nonceRefreshPromise = null;
406
458
  this._onRateChange = this.enforceMaxRate.bind(this);
407
459
  this.videoElement = videoElement;
408
460
  this.config = {
@@ -613,27 +665,34 @@ class GuardVideoPlayer {
613
665
  async solveChallenge(tokenId) {
614
666
  if (!this.config.apiBaseUrl)
615
667
  return undefined;
668
+ if (Date.now() < this.rateLimitCooldownUntil) {
669
+ this.log('Challenge skipped — rate limit cooldown active');
670
+ return undefined;
671
+ }
616
672
  try {
617
673
  const url = this.config.apiBaseUrl + '/videos/challenge/' + encodeURIComponent(tokenId);
618
674
  const resp = await fetch(url, { credentials: 'omit' });
675
+ if (resp.status === 429) {
676
+ this.enterRateLimitCooldown();
677
+ this.log('Challenge hit 429 — entering cooldown');
678
+ return undefined;
679
+ }
619
680
  if (!resp.ok) {
620
681
  this.log('Challenge fetch failed (challenge may be disabled)', resp.status);
621
682
  return undefined;
622
683
  }
623
684
  const challenge = await resp.json();
624
- this.log('Challenge received, computing PBKDF2 response...');
685
+ this.log('Challenge received, computing PBKDF2 proof-of-work...');
625
686
  const encoder = new TextEncoder();
626
687
  const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(challenge.nonce), 'PBKDF2', false, ['deriveBits']);
627
- const derivedBits = await crypto.subtle.deriveBits({
688
+ await crypto.subtle.deriveBits({
628
689
  name: 'PBKDF2',
629
690
  salt: encoder.encode(challenge.salt),
630
691
  iterations: challenge.iterations,
631
692
  hash: 'SHA-256',
632
693
  }, keyMaterial, challenge.keyLength * 8);
633
- const hashArray = Array.from(new Uint8Array(derivedBits));
634
- const hexResponse = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
635
- this.log('Challenge response computed');
636
- return hexResponse;
694
+ this.log('Challenge proof-of-work completed');
695
+ return { nonce: challenge.nonce };
637
696
  }
638
697
  catch (err) {
639
698
  this.log('Challenge computation failed (non-fatal):', err);
@@ -643,12 +702,16 @@ class GuardVideoPlayer {
643
702
  async acquireNonce(tokenId) {
644
703
  if (!this.config.apiBaseUrl)
645
704
  return null;
705
+ if (Date.now() < this.rateLimitCooldownUntil) {
706
+ this.log('Nonce acquisition skipped — rate limit cooldown active');
707
+ return this.playlistNonce;
708
+ }
646
709
  try {
647
- const challengeResponse = await this.solveChallenge(tokenId);
710
+ const challengeResult = await this.solveChallenge(tokenId);
648
711
  const url = this.config.apiBaseUrl + '/videos/playlist-session';
649
712
  const body = { tokenId };
650
- if (challengeResponse) {
651
- body.challengeResponse = challengeResponse;
713
+ if (challengeResult) {
714
+ body.challengeNonce = challengeResult.nonce;
652
715
  }
653
716
  const resp = await fetch(url, {
654
717
  method: 'POST',
@@ -656,12 +719,19 @@ class GuardVideoPlayer {
656
719
  body: JSON.stringify(body),
657
720
  credentials: 'omit',
658
721
  });
722
+ if (resp.status === 429) {
723
+ this.enterRateLimitCooldown();
724
+ this.log('Nonce acquisition hit 429 — entering cooldown');
725
+ return this.playlistNonce;
726
+ }
659
727
  if (!resp.ok) {
660
728
  this.log('Playlist session nonce acquisition failed', resp.status);
661
729
  return null;
662
730
  }
663
731
  const data = await resp.json();
664
732
  this.log('Playlist nonce acquired, expires in ' + data.expiresIn + 's');
733
+ this.retryBackoff = 1000;
734
+ this.scheduleNonceRefresh(data.expiresIn);
665
735
  return data.nonce;
666
736
  }
667
737
  catch (err) {
@@ -669,19 +739,38 @@ class GuardVideoPlayer {
669
739
  return null;
670
740
  }
671
741
  }
742
+ scheduleNonceRefresh(expiresInSeconds) {
743
+ if (this.nonceRefreshTimer)
744
+ clearTimeout(this.nonceRefreshTimer);
745
+ const refreshMs = Math.max(5000, expiresInSeconds * 750);
746
+ this.nonceRefreshTimer = setTimeout(() => {
747
+ this.refreshNonce().catch(() => {
748
+ this.log('Proactive nonce refresh failed');
749
+ });
750
+ }, refreshMs);
751
+ }
672
752
  async refreshNonce() {
673
- if (this.nonceRefreshInProgress)
674
- return;
753
+ if (this.nonceRefreshInProgress && this.nonceRefreshPromise) {
754
+ return this.nonceRefreshPromise;
755
+ }
675
756
  if (!this.embedToken)
676
757
  return;
677
758
  this.nonceRefreshInProgress = true;
678
- try {
679
- const nonce = await this.acquireNonce(this.embedToken.tokenId);
680
- this.playlistNonce = nonce;
681
- }
682
- finally {
683
- this.nonceRefreshInProgress = false;
684
- }
759
+ this.nonceRefreshPromise = (async () => {
760
+ try {
761
+ const nonce = await this.acquireNonce(this.embedToken.tokenId);
762
+ this.playlistNonce = nonce;
763
+ }
764
+ finally {
765
+ this.nonceRefreshInProgress = false;
766
+ this.nonceRefreshPromise = null;
767
+ }
768
+ })();
769
+ return this.nonceRefreshPromise;
770
+ }
771
+ enterRateLimitCooldown() {
772
+ this.rateLimitCooldownUntil = Date.now() + this.retryBackoff;
773
+ this.retryBackoff = Math.min(this.retryBackoff * 2, this.MAX_BACKOFF);
685
774
  }
686
775
  async initializePlayer() {
687
776
  if (!this.embedToken) {
@@ -721,8 +810,12 @@ class GuardVideoPlayer {
721
810
  enableWorker: true,
722
811
  lowLatencyMode: false,
723
812
  liveSyncDurationCount: 3,
724
- manifestLoadingMaxRetry: 6,
725
- levelLoadingMaxRetry: 6,
813
+ manifestLoadingMaxRetry: 3,
814
+ manifestLoadingRetryDelay: 2000,
815
+ levelLoadingMaxRetry: 3,
816
+ levelLoadingRetryDelay: 2000,
817
+ fragLoadingMaxRetry: 4,
818
+ fragLoadingRetryDelay: 1000,
726
819
  xhrSetup(xhr, url) {
727
820
  if (url.includes('/watermark-stream/') && url.includes('.m3u8')) {
728
821
  if (self.playlistNonce) {
@@ -730,10 +823,6 @@ class GuardVideoPlayer {
730
823
  const nonceUrl = url + separator + 'nonce=' + encodeURIComponent(self.playlistNonce);
731
824
  xhr.open('GET', nonceUrl, true);
732
825
  self.log('Injected nonce into playlist request');
733
- self.playlistNonce = null;
734
- self.refreshNonce().catch(() => {
735
- self.log('Background nonce refresh failed');
736
- });
737
826
  }
738
827
  }
739
828
  },
@@ -745,6 +834,7 @@ class GuardVideoPlayer {
745
834
  this.log('HLS manifest parsed', { levels: data.levels.map((l) => l.height + 'p') });
746
835
  this.setState(exports.PlayerState.READY);
747
836
  this.config.onReady();
837
+ this.retryBackoff = 1000;
748
838
  if (this.config.autoplay)
749
839
  this.play();
750
840
  });
@@ -760,18 +850,52 @@ class GuardVideoPlayer {
760
850
  this.currentQuality = quality;
761
851
  this.log('Quality switched to ' + quality.name);
762
852
  this.config.onQualityChange(quality.name);
853
+ this.eventTracker?.track('quality_change', this.videoElement.currentTime, {
854
+ level: data.level,
855
+ height: level.height,
856
+ bitrate: level.bitrate,
857
+ });
763
858
  });
764
859
  this.hls.on(Hls.Events.ERROR, (_event, data) => {
765
860
  this.error('HLS Error', data);
861
+ this.eventTracker?.track('error', this.videoElement.currentTime, {
862
+ type: data.type,
863
+ details: data.details,
864
+ fatal: data.fatal,
865
+ });
866
+ if (Date.now() < this.rateLimitCooldownUntil) {
867
+ this.log('Suppressing retry — rate limit cooldown active (' +
868
+ Math.ceil((this.rateLimitCooldownUntil - Date.now()) / 1000) + 's remaining)');
869
+ if (data.fatal) {
870
+ const delay = this.rateLimitCooldownUntil - Date.now() + 500;
871
+ setTimeout(() => this.hls?.startLoad(), delay);
872
+ }
873
+ return;
874
+ }
875
+ const httpStatus = data.response?.code;
876
+ if (httpStatus === 429) {
877
+ this.enterRateLimitCooldown();
878
+ this.log('429 detected — entering cooldown for ' + this.retryBackoff + 'ms');
879
+ setTimeout(() => {
880
+ if (this.embedToken?.forensicWatermark) {
881
+ this.refreshNonce().then(() => this.hls?.startLoad()).catch(() => this.hls?.startLoad());
882
+ }
883
+ else {
884
+ this.hls?.startLoad();
885
+ }
886
+ }, this.retryBackoff);
887
+ return;
888
+ }
766
889
  if (data.type === Hls.ErrorTypes.NETWORK_ERROR &&
767
890
  data.details === Hls.ErrorDetails.LEVEL_LOAD_ERROR &&
768
891
  this.embedToken?.forensicWatermark) {
769
892
  this.log('Playlist load failed — refreshing nonce before retry');
770
893
  this.refreshNonce().then(() => {
771
- setTimeout(() => this.hls?.startLoad(), 500);
894
+ setTimeout(() => this.hls?.startLoad(), this.retryBackoff);
772
895
  }).catch(() => {
773
- setTimeout(() => this.hls?.startLoad(), 1000);
896
+ setTimeout(() => this.hls?.startLoad(), this.retryBackoff);
774
897
  });
898
+ this.retryBackoff = Math.min(this.retryBackoff * 1.5, this.MAX_BACKOFF);
775
899
  return;
776
900
  }
777
901
  if (data.fatal) {
@@ -781,7 +905,8 @@ class GuardVideoPlayer {
781
905
  if (this.embedToken?.forensicWatermark) {
782
906
  this.refreshNonce().catch(() => { });
783
907
  }
784
- setTimeout(() => this.hls?.startLoad(), 1000);
908
+ setTimeout(() => this.hls?.startLoad(), this.retryBackoff);
909
+ this.retryBackoff = Math.min(this.retryBackoff * 1.5, this.MAX_BACKOFF);
785
910
  break;
786
911
  case Hls.ErrorTypes.MEDIA_ERROR:
787
912
  this.error('Media error, attempting recovery...');
@@ -859,6 +984,10 @@ class GuardVideoPlayer {
859
984
  getState() { return this.state; }
860
985
  destroy() {
861
986
  this.log('Destroying player');
987
+ if (this.nonceRefreshTimer) {
988
+ clearTimeout(this.nonceRefreshTimer);
989
+ this.nonceRefreshTimer = null;
990
+ }
862
991
  if (this.eventTracker) {
863
992
  this.eventTracker.destroy();
864
993
  this.eventTracker = null;