@mebius-io/web 0.6.2 → 0.7.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.
@@ -42840,10 +42840,21 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42840
42840
  }
42841
42841
 
42842
42842
  // src/internal/ll-view-transport.ts
42843
+ var DEFAULT_REALTIME_TARGET_MS = 300;
42844
+ function holdBuffer(receiver, targetMs) {
42845
+ const r = receiver;
42846
+ try {
42847
+ if ("jitterBufferTarget" in r) r.jitterBufferTarget = targetMs;
42848
+ if ("playoutDelayHint" in r) r.playoutDelayHint = targetMs / 1e3;
42849
+ } catch {
42850
+ }
42851
+ }
42843
42852
  var WhepViewTransport = class {
42844
- constructor(signaling) {
42853
+ constructor(signaling, targetLatencyMs = DEFAULT_REALTIME_TARGET_MS) {
42845
42854
  this.signaling = signaling;
42855
+ this.targetLatencyMs = targetLatencyMs;
42846
42856
  this.kind = "whep";
42857
+ this.cursor = null;
42847
42858
  this.pc = null;
42848
42859
  this.resourceUrl = null;
42849
42860
  this.endedCb = null;
@@ -42867,6 +42878,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42867
42878
  pc.addTransceiver("video", { direction: "recvonly" });
42868
42879
  pc.addTransceiver("audio", { direction: "recvonly" });
42869
42880
  pc.ontrack = (ev) => {
42881
+ holdBuffer(ev.receiver, this.targetLatencyMs);
42870
42882
  remote.addTrack(ev.track);
42871
42883
  video.srcObject = remote;
42872
42884
  void playWithAutoplayFallback(video).then((o) => {
@@ -42894,6 +42906,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42894
42906
  await pc.setRemoteDescription({ type: "answer", sdp: answer });
42895
42907
  }
42896
42908
  async stop() {
42909
+ this.cursor = null;
42897
42910
  await this.signaling.deleteResource(this.resourceUrl);
42898
42911
  this.resourceUrl = null;
42899
42912
  this.pc?.close();
@@ -42901,24 +42914,59 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42901
42914
  if (this.video) resetVideoElement(this.video);
42902
42915
  this.video = null;
42903
42916
  }
42917
+ /**
42918
+ * Read what the connection actually did since the last reading.
42919
+ *
42920
+ * This route needs to measure its own freezes, because nothing else can. A
42921
+ * frozen real-time picture still reports a healthy connection and raises no
42922
+ * event on the video element — so from the outside the session looks flawless
42923
+ * while the viewer stares at a still frame. Every freeze on this route used to
42924
+ * be recorded as zero, which is why the problem could be felt and never seen.
42925
+ */
42904
42926
  async getStats() {
42905
42927
  if (!this.pc) return null;
42906
42928
  const report = await this.pc.getStats();
42907
- let bitrateKbps = 0;
42908
- let framesPerSecond = 0;
42909
- let latencyMs;
42929
+ let framesPerSecond;
42930
+ let freezeS = 0;
42931
+ let packetsLost = 0;
42932
+ let packetsReceived = 0;
42933
+ let bytesReceived = 0;
42934
+ let bufferDelayS = 0;
42935
+ let bufferEmitted = 0;
42936
+ let rttMs;
42910
42937
  report.forEach((stat) => {
42911
- if (stat.type === "inbound-rtp") {
42912
- if (typeof stat.framesPerSecond === "number") framesPerSecond = stat.framesPerSecond;
42913
- if (typeof stat.jitter === "number") latencyMs = Math.round(stat.jitter * 1e3);
42938
+ if (stat.type === "inbound-rtp" && stat.kind === "video") {
42939
+ const v = stat;
42940
+ if (typeof v.framesPerSecond === "number") framesPerSecond = v.framesPerSecond;
42941
+ if (typeof v.totalFreezesDuration === "number") freezeS = v.totalFreezesDuration;
42942
+ if (typeof v.packetsLost === "number") packetsLost = v.packetsLost;
42943
+ if (typeof v.packetsReceived === "number") packetsReceived = v.packetsReceived;
42944
+ if (typeof v.bytesReceived === "number") bytesReceived = v.bytesReceived;
42945
+ if (typeof v.jitterBufferDelay === "number") bufferDelayS = v.jitterBufferDelay;
42946
+ if (typeof v.jitterBufferEmittedCount === "number") bufferEmitted = v.jitterBufferEmittedCount;
42914
42947
  }
42915
42948
  if (stat.type === "candidate-pair" && stat.state === "succeeded") {
42916
- if (typeof stat.availableIncomingBitrate === "number") {
42917
- bitrateKbps = Math.round(stat.availableIncomingBitrate / 1e3);
42918
- }
42949
+ const p = stat;
42950
+ if (typeof p.currentRoundTripTime === "number") rttMs = Math.round(p.currentRoundTripTime * 1e3);
42919
42951
  }
42920
42952
  });
42921
- return { bitrateKbps, framesPerSecond, latencyMs };
42953
+ const atMs = Date.now();
42954
+ const previous = this.cursor;
42955
+ this.cursor = { atMs, freezeS, packetsLost, packetsReceived, bytesReceived, bufferDelayS, bufferEmitted };
42956
+ if (!previous) return { framesPerSecond, rttMs };
42957
+ const elapsedS = Math.max(1e-3, (atMs - previous.atMs) / 1e3);
42958
+ const deltaLost = Math.max(0, packetsLost - previous.packetsLost);
42959
+ const deltaReceived = Math.max(0, packetsReceived - previous.packetsReceived);
42960
+ const deltaEmitted = bufferEmitted - previous.bufferEmitted;
42961
+ const heldMs = deltaEmitted > 0 ? (bufferDelayS - previous.bufferDelayS) / deltaEmitted * 1e3 : void 0;
42962
+ return {
42963
+ bitrateKbps: Math.round((bytesReceived - previous.bytesReceived) * 8 / elapsedS / 1e3),
42964
+ framesPerSecond,
42965
+ latencyMs: heldMs === void 0 ? void 0 : Math.round(heldMs + (rttMs !== void 0 ? rttMs / 2 : 0)),
42966
+ rttMs,
42967
+ packetLossPct: deltaLost + deltaReceived > 0 ? Number((deltaLost / (deltaLost + deltaReceived) * 100).toFixed(2)) : 0,
42968
+ freezeMs: Math.max(0, Math.round((freezeS - previous.freezeS) * 1e3))
42969
+ };
42922
42970
  }
42923
42971
  };
42924
42972
 
@@ -42926,6 +42974,19 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42926
42974
  function retryWarmupNotFound(cfg, retryCount, res, retry) {
42927
42975
  return retry || retryCount < (cfg?.maxNumRetry ?? 0) && res?.code === 404;
42928
42976
  }
42977
+ var TOKEN_PARAM = /([?&]token=)[^&#]*/;
42978
+ function withCurrentToken(url, token) {
42979
+ return url.replace(TOKEN_PARAM, (_match, prefix) => prefix + encodeURIComponent(token));
42980
+ }
42981
+ function tokenRestampingLoader(Hls2, currentToken) {
42982
+ const Base = Hls2.DefaultConfig.loader;
42983
+ return class TokenRestampingLoader extends Base {
42984
+ load(context, config2, callbacks) {
42985
+ context.url = withCurrentToken(context.url, currentToken());
42986
+ super.load(context, config2, callbacks);
42987
+ }
42988
+ };
42989
+ }
42929
42990
  var HlsViewTransport = class {
42930
42991
  /**
42931
42992
  * deliveryPath, when given, is a gateway-relative path from the gateway's own
@@ -42933,9 +42994,10 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42933
42994
  * origin one. Without it this falls back to the origin playlist, which is
42934
42995
  * still correct, just served from our own bandwidth.
42935
42996
  */
42936
- constructor(signaling, deliveryPath) {
42997
+ constructor(signaling, deliveryPath, targetS) {
42937
42998
  this.signaling = signaling;
42938
42999
  this.deliveryPath = deliveryPath;
43000
+ this.targetS = targetS;
42939
43001
  this.kind = "hls";
42940
43002
  this.hls = null;
42941
43003
  this.video = null;
@@ -42975,6 +43037,11 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42975
43037
  }
42976
43038
  const hls = new Hls2({
42977
43039
  maxLiveSyncPlaybackRate: 1.1,
43040
+ loader: tokenRestampingLoader(Hls2, () => this.signaling.accessToken()),
43041
+ // Only when the app actually asked. Left alone, the library follows the
43042
+ // playlist's own HOLD-BACK, which the server measured from the segments it
43043
+ // is producing — a number guessed here would only override a real one.
43044
+ ...this.targetS === void 0 ? {} : { liveSyncDuration: this.targetS },
42978
43045
  manifestLoadPolicy: {
42979
43046
  default: {
42980
43047
  maxTimeToFirstByteMs: 1e4,
@@ -43044,8 +43111,12 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43044
43111
  autoCleanupMinBackwardDuration: 10,
43045
43112
  reuseRedirectedURL: true
43046
43113
  };
43047
- var MAX_DRIFT_S = 2;
43048
- var EDGE_MARGIN_S = 0.4;
43114
+ var DEFAULT_BALANCED_TARGET_S = 2;
43115
+ var CATCH_UP_RATE = 1.05;
43116
+ var BUILD_UP_RATE = 0.98;
43117
+ var SEEK_AT = 4;
43118
+ var CATCH_UP_ABOVE = 1.5;
43119
+ var BUILD_UP_BELOW = 0.9;
43049
43120
  var AUDIO_RETRY_MS = 2500;
43050
43121
  var UPSTREAM_LATENCY_MS = 800;
43051
43122
  function stalledWithData(video, ms) {
@@ -43063,17 +43134,31 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43063
43134
  video.addEventListener("timeupdate", onTime);
43064
43135
  });
43065
43136
  }
43066
- function chaseLiveEdge(video) {
43137
+ function syncLiveEdge(video, targetS = DEFAULT_BALANCED_TARGET_S) {
43067
43138
  const ranges = video.buffered;
43068
43139
  if (ranges.length === 0) return;
43069
43140
  const edge = ranges.end(ranges.length - 1);
43070
- if (edge - video.currentTime <= MAX_DRIFT_S) return;
43071
- video.currentTime = edge - EDGE_MARGIN_S;
43141
+ const drift = edge - video.currentTime;
43142
+ if (drift > targetS * SEEK_AT) {
43143
+ video.currentTime = edge - targetS;
43144
+ video.playbackRate = 1;
43145
+ return;
43146
+ }
43147
+ if (drift > targetS * CATCH_UP_ABOVE) {
43148
+ video.playbackRate = CATCH_UP_RATE;
43149
+ return;
43150
+ }
43151
+ if (drift < targetS * BUILD_UP_BELOW) {
43152
+ video.playbackRate = BUILD_UP_RATE;
43153
+ return;
43154
+ }
43155
+ video.playbackRate = 1;
43072
43156
  }
43073
43157
  var FlvViewTransport = class {
43074
- constructor(signaling, deliveryPath) {
43158
+ constructor(signaling, deliveryPath, targetS = DEFAULT_BALANCED_TARGET_S) {
43075
43159
  this.signaling = signaling;
43076
43160
  this.deliveryPath = deliveryPath;
43161
+ this.targetS = targetS;
43077
43162
  this.kind = "flv_js";
43078
43163
  this.player = null;
43079
43164
  this.video = null;
@@ -43099,7 +43184,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43099
43184
  const { signal } = this.listeners;
43100
43185
  video.addEventListener("ended", () => this.endedCb?.(), { signal });
43101
43186
  video.addEventListener("waiting", () => this.bufferingCb?.(), { signal });
43102
- video.addEventListener("timeupdate", () => chaseLiveEdge(video), { signal });
43187
+ video.addEventListener("timeupdate", () => syncLiveEdge(video, this.targetS), { signal });
43103
43188
  let mod;
43104
43189
  try {
43105
43190
  mod = await Promise.resolve().then(() => __toESM(require_flv(), 1));
@@ -43143,6 +43228,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43143
43228
  this.listeners = null;
43144
43229
  this.teardownPlayer();
43145
43230
  if (this.video) {
43231
+ this.video.playbackRate = 1;
43146
43232
  this.video.removeAttribute("src");
43147
43233
  this.video.load();
43148
43234
  }
@@ -43208,18 +43294,29 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43208
43294
  function canPlayBuffered() {
43209
43295
  return typeof MediaSource !== "undefined";
43210
43296
  }
43211
- function transportFor(kind, path, signaling) {
43212
- if (kind === KIND_FAST) return canPlayBuffered() ? new FlvViewTransport(signaling, path) : null;
43213
- if (kind === KIND_WIDE || kind === KIND_LOCAL) return new HlsViewTransport(signaling, path);
43297
+ function transportFor(kind, path, signaling, targetLatencyMs) {
43298
+ const targetS = targetLatencyMs === void 0 ? void 0 : targetLatencyMs / 1e3;
43299
+ if (kind === KIND_FAST)
43300
+ return canPlayBuffered() ? new FlvViewTransport(signaling, path, targetS) : null;
43301
+ if (kind === KIND_WIDE || kind === KIND_LOCAL)
43302
+ return new HlsViewTransport(signaling, path, targetS);
43214
43303
  return null;
43215
43304
  }
43216
- function createViewCandidates(mode, signaling, deliveries = []) {
43217
- const fromGateway = (kinds) => deliveries.filter((d) => kinds.includes(d.kind)).map((d) => transportFor(d.kind, d.path, signaling)).filter((t) => t !== null);
43218
- const originFallback = new HlsViewTransport(signaling);
43305
+ function createViewCandidates(mode, signaling, deliveries = [], targetLatencyMs) {
43306
+ const fromGateway = (kinds) => deliveries.filter((d) => kinds.includes(d.kind)).map((d) => transportFor(d.kind, d.path, signaling, targetLatencyMs)).filter((t) => t !== null);
43307
+ const originFallback = new HlsViewTransport(
43308
+ signaling,
43309
+ void 0,
43310
+ targetLatencyMs === void 0 ? void 0 : targetLatencyMs / 1e3
43311
+ );
43219
43312
  const allKinds = [KIND_FAST, KIND_WIDE, KIND_LOCAL];
43220
43313
  switch (mode) {
43221
43314
  case "low-latency":
43222
- return [new WhepViewTransport(signaling), ...fromGateway(allKinds), originFallback];
43315
+ return [
43316
+ new WhepViewTransport(signaling, targetLatencyMs ?? DEFAULT_REALTIME_TARGET_MS),
43317
+ ...fromGateway(allKinds),
43318
+ originFallback
43319
+ ];
43223
43320
  case "balanced":
43224
43321
  return [...fromGateway(allKinds), originFallback];
43225
43322
  case "scale":
@@ -43230,7 +43327,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43230
43327
  }
43231
43328
 
43232
43329
  // src/internal/telemetry.ts
43233
- var SDK_VERSION = "web/0.4.8";
43330
+ var SDK_VERSION = true ? `web/${"0.7.0"}` : "web/dev";
43234
43331
  var FLUSH_INTERVAL_MS = 15e3;
43235
43332
  var MAX_BATCH = 64;
43236
43333
  function describeDevice() {
@@ -43638,7 +43735,12 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43638
43735
  this.freeze = new FreezeClock();
43639
43736
  /** Cancels element listeners bound for the lifetime of one play(). */
43640
43737
  this.elementListeners = null;
43641
- this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
43738
+ this.candidates = createViewCandidates(
43739
+ options.mode ?? "auto",
43740
+ signaling,
43741
+ deliveries,
43742
+ options.targetLatencyMs
43743
+ );
43642
43744
  }
43643
43745
  /** Start playing `streamId` into the given video element or selector. */
43644
43746
  async play(streamId, viewTarget) {
@@ -43735,13 +43837,15 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43735
43837
  }
43736
43838
  /**
43737
43839
  * Wall-clock time (Unix ms) currently on screen, or `null` when the active
43738
- * route cannot produce one (HTTP-FLV, WHEP see {@link ViewTransport}).
43840
+ * route cannot produce one. A real-time route carries no wall clock at all,
43841
+ * and a segmented route has none until its first timestamped segment arrives
43842
+ * (see {@link ViewTransport}).
43739
43843
  *
43740
43844
  * This is what {@link MebiusClient.createCaptions} compares against a
43741
43845
  * segment's `epochMs` to know when it is due. Delegating to the transport
43742
43846
  * rather than reading the element directly is what keeps this correct across
43743
- * a route failover: the player may switch from HLS to FLV mid-session, and
43744
- * the clock source has to follow.
43847
+ * a route failover: the player may change route mid-session, and the clock
43848
+ * source has to follow.
43745
43849
  */
43746
43850
  currentEpochMs() {
43747
43851
  return this.transport?.playheadEpochMs?.() ?? null;
@@ -43765,9 +43869,11 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43765
43869
  startStats() {
43766
43870
  this.statsTimer = setInterval(async () => {
43767
43871
  const stats = await this.transport?.getStats();
43768
- const freezeMs = this.freeze.take();
43872
+ const elementFreezeMs = this.freeze.take();
43769
43873
  if (!stats) {
43770
- if (freezeMs > 0) this.reporter?.add({ ts: Math.floor(Date.now() / 1e3), freezeMs });
43874
+ if (elementFreezeMs > 0) {
43875
+ this.reporter?.add({ ts: Math.floor(Date.now() / 1e3), freezeMs: elementFreezeMs });
43876
+ }
43771
43877
  return;
43772
43878
  }
43773
43879
  this.emit("stats", stats);
@@ -43775,7 +43881,9 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43775
43881
  ts: Math.floor(Date.now() / 1e3),
43776
43882
  bitrateKbps: stats.bitrateKbps,
43777
43883
  fps: stats.framesPerSecond,
43778
- freezeMs
43884
+ rttMs: stats.rttMs,
43885
+ packetLossPct: stats.packetLossPct,
43886
+ freezeMs: elementFreezeMs + (stats.freezeMs ?? 0)
43779
43887
  });
43780
43888
  }, STATS_INTERVAL_MS2);
43781
43889
  }
@@ -43806,6 +43914,21 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43806
43914
  this.gateway = gateway;
43807
43915
  this.token = token;
43808
43916
  }
43917
+ /**
43918
+ * Swap in a freshly-minted access token.
43919
+ *
43920
+ * Every URL this class builds is built at call time, so a session that starts
43921
+ * a new request after this point uses the new token with no further wiring.
43922
+ * What it does NOT reach is a request already in flight or a media URL another
43923
+ * library has memorised — see the scale route's loader for that half.
43924
+ */
43925
+ setToken(token) {
43926
+ this.token = token;
43927
+ }
43928
+ /** The current access token, for transports that must re-stamp their own URLs. */
43929
+ accessToken() {
43930
+ return this.token;
43931
+ }
43809
43932
  base() {
43810
43933
  return this.gateway.replace(/\/+$/, "");
43811
43934
  }
@@ -43926,16 +44049,21 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43926
44049
  }
43927
44050
 
43928
44051
  // src/client.ts
44052
+ var REFRESH_MARGIN_MS = 6e4;
44053
+ var REFRESH_RETRY_BASE_MS = 2e3;
44054
+ var REFRESH_RETRY_MAX_MS = 3e4;
43929
44055
  var MebiusClient = class extends TypedEmitter {
43930
44056
  /** @internal */
43931
- constructor(config2, token, deliveries = [], telemetry = null, userId) {
44057
+ constructor(config2, token, deliveries = [], telemetry = null, userId, getToken) {
43932
44058
  super();
43933
44059
  this.token = token;
43934
44060
  this.deliveries = deliveries;
43935
44061
  this.telemetry = telemetry;
43936
44062
  this.userId = userId;
44063
+ this.getToken = getToken;
43937
44064
  this.expiryTimer = null;
43938
44065
  this.connected = false;
44066
+ this.refreshFailures = 0;
43939
44067
  this.signaling = new SignalingClient(config2.gateway, token);
43940
44068
  }
43941
44069
  /** @internal Called by {@link Mebius.connect}. */
@@ -43947,13 +44075,82 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43947
44075
  return;
43948
44076
  }
43949
44077
  this.connected = true;
43950
- if (expiresAtMs !== null) {
44078
+ this.scheduleTokenWork(expiresAtMs);
44079
+ queueMicrotask(() => this.emit("connected", void 0));
44080
+ }
44081
+ /**
44082
+ * Arm whatever has to happen as this token approaches its expiry: renew it if
44083
+ * the app gave us a way to, otherwise report that the session is over.
44084
+ *
44085
+ * Renewing is what makes an unattended session possible at all. The gateway
44086
+ * checks the token on every media request, so without a fresh one playback
44087
+ * stops the moment it expires — no matter how healthy the stream is.
44088
+ */
44089
+ scheduleTokenWork(expiresAtMs) {
44090
+ this.clearTimer();
44091
+ if (expiresAtMs === null) return;
44092
+ const remaining = expiresAtMs - Date.now();
44093
+ if (!this.getToken) {
43951
44094
  this.expiryTimer = setTimeout(
43952
44095
  () => this.emit("error", mebiusError("TOKEN_EXPIRED")),
43953
- Math.max(0, expiresAtMs - now2)
44096
+ Math.max(0, remaining)
43954
44097
  );
44098
+ return;
43955
44099
  }
43956
- queueMicrotask(() => this.emit("connected", void 0));
44100
+ this.expiryTimer = setTimeout(
44101
+ () => void this.refreshToken(expiresAtMs),
44102
+ Math.max(0, remaining - REFRESH_MARGIN_MS)
44103
+ );
44104
+ }
44105
+ async refreshToken(previousExpiryMs) {
44106
+ if (!this.connected || !this.getToken) return;
44107
+ let next;
44108
+ try {
44109
+ next = await this.getToken();
44110
+ } catch (cause) {
44111
+ this.onRefreshFailed(previousExpiryMs, cause);
44112
+ return;
44113
+ }
44114
+ if (!this.connected) return;
44115
+ const { expiresAtMs } = readToken(next);
44116
+ if (expiresAtMs !== null && expiresAtMs <= previousExpiryMs) {
44117
+ this.emit(
44118
+ "error",
44119
+ mebiusError("TOKEN_EXPIRED", "Mebius token refresh returned a token that is not newer.")
44120
+ );
44121
+ return;
44122
+ }
44123
+ this.refreshFailures = 0;
44124
+ this.token = next;
44125
+ this.signaling.setToken(next);
44126
+ this.emit("token-refreshed", void 0);
44127
+ this.scheduleTokenWork(expiresAtMs);
44128
+ }
44129
+ /**
44130
+ * A failed refresh is not a dead session: the current token is still valid
44131
+ * until `expiryMs`, and the viewer is still watching. Retry inside that window
44132
+ * and only report expiry once it has actually run out.
44133
+ */
44134
+ onRefreshFailed(expiryMs, cause) {
44135
+ const remaining = expiryMs - Date.now();
44136
+ if (remaining <= 0) {
44137
+ this.emit("error", mebiusError("TOKEN_EXPIRED", void 0, cause));
44138
+ return;
44139
+ }
44140
+ this.refreshFailures += 1;
44141
+ const backoff = Math.min(
44142
+ REFRESH_RETRY_MAX_MS,
44143
+ REFRESH_RETRY_BASE_MS * 2 ** (this.refreshFailures - 1)
44144
+ );
44145
+ this.clearTimer();
44146
+ this.expiryTimer = setTimeout(
44147
+ () => void this.refreshToken(expiryMs),
44148
+ Math.min(backoff, remaining)
44149
+ );
44150
+ }
44151
+ clearTimer() {
44152
+ if (this.expiryTimer) clearTimeout(this.expiryTimer);
44153
+ this.expiryTimer = null;
43957
44154
  }
43958
44155
  /** Create a broadcaster bound to this connection. */
43959
44156
  createBroadcaster(options = {}) {
@@ -43998,8 +44195,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43998
44195
  }
43999
44196
  /** Close the connection and release resources. */
44000
44197
  disconnect(reason) {
44001
- if (this.expiryTimer) clearTimeout(this.expiryTimer);
44002
- this.expiryTimer = null;
44198
+ this.clearTimer();
44003
44199
  this.connected = false;
44004
44200
  this.emit("disconnected", { reason });
44005
44201
  this.removeAllListeners();
@@ -44028,7 +44224,14 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
44028
44224
  }
44029
44225
  if (!options.token) throw mebiusError("UNKNOWN", "Mebius.connect requires a token.");
44030
44226
  const telemetry = options.beaconToken && options.beaconUrl ? { token: options.beaconToken, url: options.beaconUrl } : null;
44031
- const client = new MebiusClient(config, options.token, options.deliveries ?? [], telemetry, options.userId);
44227
+ const client = new MebiusClient(
44228
+ config,
44229
+ options.token,
44230
+ options.deliveries ?? [],
44231
+ telemetry,
44232
+ options.userId,
44233
+ options.getToken
44234
+ );
44032
44235
  client.open();
44033
44236
  return client;
44034
44237
  },