@q9labsai/chalk-client 5.2.5 → 5.2.6

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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.2.6
4
+
5
+ - Reuse microphone and camera senders after short browser capture interruptions, keeping capture disabled until a fresh user enable. Republish after long interruptions before trusting an expired provider track, and detach again if capture is interrupted during reattachment. Report interrupted owned capture as unavailable to other participants through the existing Sync pause command. Include a bounded replacement cause on the first SFU track request after a media connection is replaced.
6
+
3
7
  ## 5.2.0
4
8
 
5
9
  ### board-draw-policy
@@ -1109,7 +1109,25 @@ var AddCloudflareSFUTracksPathParamsSchema = Schema.Struct({
1109
1109
  tenant_id: TenantIdSchema
1110
1110
  });
1111
1111
  var AddCloudflareSFUTracksQueryParamsSchema = Schema.Struct({
1112
- allow_partial_remote_tracks: Schema.optional(Schema.Boolean)
1112
+ allow_partial_remote_tracks: Schema.optional(Schema.Boolean),
1113
+ replacement_cause: Schema.optional(
1114
+ Schema.Literals([
1115
+ "user_pause",
1116
+ "moderation_pause",
1117
+ "sync_recovery_loss",
1118
+ "discovery_absent",
1119
+ "capture_cleanup",
1120
+ "consent_revoked",
1121
+ "publish_failure",
1122
+ "enable_failure",
1123
+ "projection_mismatch",
1124
+ "forced_pause",
1125
+ "local_signaling_failure",
1126
+ "remote_signaling_failure",
1127
+ "dormant_connection",
1128
+ "access_refresh"
1129
+ ])
1130
+ )
1113
1131
  });
1114
1132
  var AddCloudflareSFUTracksRequestBodySchema = CloudflareSFUTracksRequestSchema;
1115
1133
  var AddCloudflareSFUTracksResponseSchema = CloudflareSFUTracksAPIResponseSchema;
@@ -6,11 +6,11 @@ import {
6
6
  describeMediaCaptureError,
7
7
  grantedMediaFromStream,
8
8
  markMediaFailure
9
- } from "./chunk-FD25AMLG.js";
9
+ } from "./chunk-YIDKETOX.js";
10
10
  import {
11
11
  AccessGrantError,
12
12
  requireParsedAccessGrant
13
- } from "./chunk-UMAE5GCV.js";
13
+ } from "./chunk-CJVLTSIO.js";
14
14
  import {
15
15
  ChatAttachmentMimeTypeSchema,
16
16
  ChatReactionSetFrameSchema,
@@ -2699,6 +2699,7 @@ function createSFUConnectionMediaClient(client) {
2699
2699
  function bindConnectionMediaClient(client, restart) {
2700
2700
  return {
2701
2701
  setLocalPublicationTarget: client.setLocalPublicationTarget.bind(client),
2702
+ ...client.pauseLocalCapture ? { pauseLocalCapture: client.pauseLocalCapture.bind(client) } : {},
2702
2703
  ...client.setLocalSourceIntent ? { setLocalSourceIntent: client.setLocalSourceIntent.bind(client) } : {},
2703
2704
  ...client.closeForcedLocalPublication ? { closeForcedLocalPublication: client.closeForcedLocalPublication.bind(client) } : {},
2704
2705
  observeLocalPublications: client.observeLocalPublications.bind(client),
@@ -2998,7 +2999,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2998
2999
  yield* publish();
2999
3000
  });
3000
3001
  const enqueue = (effect, join = false) => Effect2.gen(function* () {
3001
- if (model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed"));
3002
+ if (model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed. Rejoin the call."));
3002
3003
  const deferred = yield* Deferred2.make();
3003
3004
  yield* Queue.offer(queue, { effect, deferred, join });
3004
3005
  return yield* Deferred2.await(deferred);
@@ -3328,7 +3329,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3328
3329
  yield* scheduleRefresh();
3329
3330
  })
3330
3331
  );
3331
- const requireOpen = Effect2.suspend(() => model.closed ? Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed")) : Effect2.void);
3332
+ const requireOpen = Effect2.suspend(() => model.closed ? Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed. Rejoin the call.")) : Effect2.void);
3332
3333
  const withFreshAccess = (operation) => Effect2.gen(function* () {
3333
3334
  yield* requireOpen;
3334
3335
  if (yield* access.requiresRefresh) {
@@ -4949,7 +4950,7 @@ var MediaProjectionMonitor = class {
4949
4950
  var MEDIA_SOURCES = ["microphone", "camera", "screen"];
4950
4951
  var MEDIA_READY_TIMEOUT_MS = 1e4;
4951
4952
  function sourceWorker(enabled) {
4952
- return { desired: { enabled, revision: 0, controlRevision: -1, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" }, initialRevision: 0, localEnabled: enabled, pause: null, serverSequence: -1, forcedRevision: null, pausedPublicationId: null };
4953
+ return { desired: { enabled, revision: 0, controlRevision: -1, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" }, initialRevision: 0, localEnabled: enabled, pause: null, serverSequence: -1, forcedRevision: null, pausedPublicationId: null, capturePause: "none" };
4953
4954
  }
4954
4955
  var MediaControllerService = class extends Context6.Service()("@chalk/client/MediaController") {
4955
4956
  };
@@ -4992,6 +4993,7 @@ var MediaControllerRuntime = class {
4992
4993
  #tracks = /* @__PURE__ */ new Map();
4993
4994
  #sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
4994
4995
  #syncLive = false;
4996
+ #disposed = false;
4995
4997
  #recovering = null;
4996
4998
  #pendingSources = /* @__PURE__ */ new Map();
4997
4999
  #pendingCaptures = /* @__PURE__ */ new Set();
@@ -5024,7 +5026,7 @@ var MediaControllerRuntime = class {
5024
5026
  changed: () => this.#publish(),
5025
5027
  report: (event) => connection.recordTransportDiagnostic(event),
5026
5028
  pause: (source, revision) => {
5027
- if (this.#ports) this.#pausePublication(this.#ports, source, revision);
5029
+ if (this.#ports) this.#pausePublication(this.#ports, source, revision, "projection_mismatch");
5028
5030
  }
5029
5031
  });
5030
5032
  this.#unsubscribeConnection = connection.subscribe(() => this.#handleConnectionChange());
@@ -5130,8 +5132,9 @@ var MediaControllerRuntime = class {
5130
5132
  this.#fork(this.#refreshDevices().pipe(Effect5.ignore));
5131
5133
  }
5132
5134
  dispose() {
5135
+ this.#disposed = true;
5133
5136
  this.#projection.reset();
5134
- for (const check of this.#readinessChecks) check(normalizeMediaError(cancelledCaptureError()));
5137
+ for (const check of this.#readinessChecks) check(normalizeMediaError(this.#cancelledCaptureError()));
5135
5138
  this.#cancelCaptures();
5136
5139
  for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
5137
5140
  this.#unsubscribeConnection?.();
@@ -5161,7 +5164,7 @@ var MediaControllerRuntime = class {
5161
5164
  const check = (failure2) => {
5162
5165
  if (failure2) return resume(Effect5.fail(failure2));
5163
5166
  if (this.#sources[source].desired.revision !== revision) return resume(Effect5.void);
5164
- if (ports && !this.#canUsePorts(ports)) return resume(Effect5.fail(normalizeMediaError(cancelledCaptureError())));
5167
+ if (ports && !this.#canUsePorts(ports)) return resume(Effect5.fail(normalizeMediaError(this.#cancelledCaptureError())));
5165
5168
  const state = this.#connection.getSnapshot().state;
5166
5169
  const phases = [state, this.#ports?.sync.getSnapshot().connection.phase, this.#store.getSnapshot().connection.status];
5167
5170
  if (phases.every((phase) => phase === "live")) return resume(Effect5.void);
@@ -5210,6 +5213,7 @@ var MediaControllerRuntime = class {
5210
5213
  const controlRevision = Math.max(state.serverSequence, this.#ports?.sync.getSnapshot().control?.revision ?? -1);
5211
5214
  const desired = { enabled, revision: state.desired.revision + 1, controlRevision, options, previousEnabled: state.localEnabled };
5212
5215
  state.desired = desired;
5216
+ state.capturePause = "none";
5213
5217
  if (!enabled) {
5214
5218
  state.localEnabled = false;
5215
5219
  this.#pauseTracks(source);
@@ -5219,8 +5223,8 @@ var MediaControllerRuntime = class {
5219
5223
  }
5220
5224
  const ports = this.#ports;
5221
5225
  if (ports) {
5222
- ports.media.setLocalSourceIntent?.(source, false);
5223
- this.#retainPause(state, this.#beginLocalPause(ports, source));
5226
+ ports.media.setLocalSourceIntent?.(source, false, "user_pause");
5227
+ this.#retainPause(state, this.#beginLocalPause(ports, source, "user_pause"));
5224
5228
  }
5225
5229
  }
5226
5230
  for (const check of this.#readinessChecks) check();
@@ -5316,11 +5320,11 @@ var MediaControllerRuntime = class {
5316
5320
  if (state.desired.revision !== intent.revision) return;
5317
5321
  state.result = { revision: intent.revision, status: "failed", cause, cleanup: !intent.enabled || !previous };
5318
5322
  if (state.result.cleanup) {
5319
- if (intent.enabled && source !== "screen") this.#pausePublication(ports, source, intent.revision);
5323
+ if (intent.enabled && source !== "screen") this.#pausePublication(ports, source, intent.revision, "enable_failure");
5320
5324
  else {
5321
5325
  state.localEnabled = false;
5322
5326
  this.#pauseTracks(source);
5323
- ports.media.setLocalSourceIntent?.(source, false);
5327
+ ports.media.setLocalSourceIntent?.(source, false, "enable_failure");
5324
5328
  }
5325
5329
  }
5326
5330
  });
@@ -5434,7 +5438,7 @@ var MediaControllerRuntime = class {
5434
5438
  }
5435
5439
  #assertCurrentIntent(source, revision, enabling = true) {
5436
5440
  const state = this.#sources[source];
5437
- if (state.desired.revision !== revision || enabling && state.forcedRevision === revision) throw cancelledCaptureError();
5441
+ if (state.desired.revision !== revision || enabling && state.forcedRevision === revision) throw this.#cancelledCaptureError();
5438
5442
  }
5439
5443
  #hasConsent(source) {
5440
5444
  const state = this.#sources[source];
@@ -5519,9 +5523,9 @@ var MediaControllerRuntime = class {
5519
5523
  )
5520
5524
  );
5521
5525
  }
5522
- #beginLocalPause(ports, source) {
5526
+ #beginLocalPause(ports, source, cause) {
5523
5527
  this.#sources[source].pausedPublicationId = ports.media.getSnapshot().localTracks.find((item) => item.source === source)?.publicationId ?? null;
5524
- const pause = ports.media.closeForcedLocalPublication ? Promise.resolve().then(() => ports.media.closeForcedLocalPublication(source)) : Effect5.runPromise(this.#localTarget(ports, source, false));
5528
+ const pause = ports.media.closeForcedLocalPublication ? Promise.resolve().then(() => ports.media.closeForcedLocalPublication(source, cause)) : Effect5.runPromise(this.#localTarget(ports, source, false));
5525
5529
  void pause.catch(() => void 0);
5526
5530
  return pause;
5527
5531
  }
@@ -5594,15 +5598,15 @@ var MediaControllerRuntime = class {
5594
5598
  return;
5595
5599
  }
5596
5600
  const state = this.#camera;
5597
- if (!("owner" in state)) throw cancelledCaptureError();
5598
- if (state.status === "live" && state.track !== track) throw cancelledCaptureError();
5601
+ if (!("owner" in state)) throw this.#cancelledCaptureError();
5602
+ if (state.status === "live" && state.track !== track) throw this.#cancelledCaptureError();
5599
5603
  const onEnded = () => this.#handleCameraEnded(track);
5600
5604
  this.#transitionCamera({ status: "live", owner: state.owner, track, publication: this.#ports?.media ?? "initial", onEnded });
5601
5605
  }
5602
5606
  #handleCameraEnded(track) {
5603
5607
  const state = this.#camera;
5604
5608
  if (state.status !== "live" || state.track !== track) return;
5605
- if (this.#ports && state.publication === this.#ports.media) this.#pausePublication(this.#ports, "camera", this.#sources.camera.desired.revision);
5609
+ if (this.#ports && state.publication === this.#ports.media) this.#pausePublication(this.#ports, "camera", this.#sources.camera.desired.revision, "capture_cleanup");
5606
5610
  this.#transitionCamera({ status: "ended" });
5607
5611
  }
5608
5612
  #watchMicrophone(track) {
@@ -5615,7 +5619,7 @@ var MediaControllerRuntime = class {
5615
5619
  if (track.muted || track.readyState === "ended") {
5616
5620
  track.enabled = false;
5617
5621
  const state = this.#sources.microphone;
5618
- if (this.#ports) this.#pausePublication(this.#ports, "microphone", state.desired.revision);
5622
+ if (this.#ports) this.#pausePublication(this.#ports, "microphone", state.desired.revision, "capture_cleanup");
5619
5623
  else {
5620
5624
  state.forcedRevision = state.desired.revision;
5621
5625
  state.localEnabled = false;
@@ -5694,7 +5698,7 @@ var MediaControllerRuntime = class {
5694
5698
  references: 0,
5695
5699
  cancel: () => {
5696
5700
  abort.abort();
5697
- Deferred3.doneUnsafe(owner.result, Effect5.fail(cancelledCaptureError()));
5701
+ Deferred3.doneUnsafe(owner.result, Effect5.fail(this.#cancelledCaptureError()));
5698
5702
  }
5699
5703
  };
5700
5704
  this.#transitionCamera({ status: "capturing", owner });
@@ -5706,13 +5710,13 @@ var MediaControllerRuntime = class {
5706
5710
  const state = this.#camera;
5707
5711
  if (abort.signal.aborted || !("owner" in state) || state.owner !== owner) {
5708
5712
  track.stop();
5709
- throw cancelledCaptureError();
5713
+ throw this.#cancelledCaptureError();
5710
5714
  }
5711
5715
  const onEnded = () => this.#handleCameraEnded(track);
5712
5716
  this.#transitionCamera({ status: "live", owner, track, publication: state.status === "live" ? state.publication : null, onEnded });
5713
5717
  if (track.readyState !== "live") {
5714
5718
  this.#transitionCamera({ status: "ended" });
5715
- throw cancelledCaptureError();
5719
+ throw this.#cancelledCaptureError();
5716
5720
  }
5717
5721
  },
5718
5722
  catch: (cause) => cause
@@ -5735,7 +5739,7 @@ var MediaControllerRuntime = class {
5735
5739
  if (owner.references === 0 && "owner" in current && current.owner === owner && (current.status !== "live" || current.publication === null)) this.#transitionCamera({ status: "ended" });
5736
5740
  };
5737
5741
  return Deferred3.await(owner.result).pipe(
5738
- Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(cancelledCaptureError())),
5742
+ Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(this.#cancelledCaptureError())),
5739
5743
  Effect5.onExit(
5740
5744
  (exit) => Effect5.sync(() => {
5741
5745
  if (Exit2.isFailure(exit)) release();
@@ -5768,25 +5772,25 @@ var MediaControllerRuntime = class {
5768
5772
  const cancel = () => {
5769
5773
  cancelled = true;
5770
5774
  stopStream(captured);
5771
- Deferred3.doneUnsafe(cancellation, Effect5.fail(cancelledCaptureError()));
5775
+ Deferred3.doneUnsafe(cancellation, Effect5.fail(this.#cancelledCaptureError()));
5772
5776
  };
5773
5777
  this.#pendingCaptures.add(cancel);
5774
5778
  signal?.addEventListener("abort", cancel, { once: true });
5775
5779
  if (signal?.aborted) cancel();
5776
5780
  return foreign2(async () => {
5777
- if (cancelled) throw cancelledCaptureError();
5781
+ if (cancelled) throw this.#cancelledCaptureError();
5778
5782
  const stream = await operation();
5779
5783
  captured = stream;
5780
5784
  if (cancelled || this.#ports?.media.getSnapshot().connection.phase === "stopped") {
5781
5785
  stopStream(stream);
5782
- throw cancelledCaptureError();
5786
+ throw this.#cancelledCaptureError();
5783
5787
  }
5784
5788
  return stream;
5785
5789
  }).pipe(
5786
5790
  Effect5.mapError(onCaptureError),
5787
5791
  Effect5.flatMap(use),
5788
5792
  Effect5.raceFirst(Deferred3.await(cancellation)),
5789
- Effect5.tap(() => cancelled ? Effect5.fail(cancelledCaptureError()) : Effect5.void),
5793
+ Effect5.tap(() => cancelled ? Effect5.fail(this.#cancelledCaptureError()) : Effect5.void),
5790
5794
  Effect5.onExit(
5791
5795
  (exit) => Effect5.sync(() => {
5792
5796
  this.#pendingCaptures.delete(cancel);
@@ -5860,23 +5864,48 @@ var MediaControllerRuntime = class {
5860
5864
  const failure2 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
5861
5865
  return rollback.pipe(Effect5.andThen(Effect5.sync(() => this.#publish())), Effect5.andThen(Effect5.fail(failure2)));
5862
5866
  }
5867
+ #cancelledCaptureError() {
5868
+ return new ConnectionError({ code: "invalid_state", action: null, recoverable: !this.#disposed, message: this.#disposed ? "This media client has been disposed. Rejoin the call." : "Media capture was cancelled before its result could be used" });
5869
+ }
5863
5870
  #canUsePorts(ports) {
5864
5871
  return this.#ports?.sync === ports.sync && this.#ports.media === ports.media && ports.media.getSnapshot().connection.phase !== "stopped";
5865
5872
  }
5866
5873
  #assertActivePorts(ports, action) {
5867
5874
  if (this.#connection.getSnapshot().state === "live" && this.#canUsePorts(ports)) return;
5868
- throw new ConnectionError({ code: "invalid_state", action, recoverable: false, message: `${action} belongs to an inactive connection` });
5875
+ throw new ConnectionError({ code: "invalid_state", action, recoverable: !this.#disposed, message: this.#disposed ? "This media client has been disposed. Rejoin the call." : `${action} belongs to an inactive connection` });
5869
5876
  }
5870
- #pausePublication(ports, source, revision) {
5877
+ #pausePublication(ports, source, revision, cause) {
5871
5878
  const state = this.#sources[source];
5872
5879
  if (state.desired.revision !== revision || state.forcedRevision === revision || ports.sync.getSnapshot().control?.status === "ended") return;
5873
5880
  state.forcedRevision = revision;
5874
5881
  state.localEnabled = false;
5875
5882
  this.#pauseTracks(source);
5876
- ports.media.setLocalSourceIntent?.(source, false);
5877
- this.#retainPause(state, this.#beginLocalPause(ports, source));
5883
+ if (cause === "capture_cleanup" && source !== "screen" && ports.media.pauseLocalCapture) {
5884
+ state.capturePause = "pending";
5885
+ this.#retainPause(state, ports.media.pauseLocalCapture(source));
5886
+ this.#notifyCapturePauses(ports);
5887
+ } else {
5888
+ ports.media.setLocalSourceIntent?.(source, false, cause);
5889
+ this.#retainPause(state, this.#beginLocalPause(ports, source, cause));
5890
+ }
5878
5891
  this.#publish();
5879
5892
  }
5893
+ #notifyCapturePauses(ports) {
5894
+ for (const source of ["microphone", "camera"]) {
5895
+ const state = this.#sources[source];
5896
+ if (state.capturePause === "pending") this.#retainPause(state, this.#announceCapturePause(ports, source));
5897
+ }
5898
+ }
5899
+ async #announceCapturePause(ports, source) {
5900
+ const snapshot = ports.sync.getSnapshot();
5901
+ if (snapshot.connection.phase !== "live") return;
5902
+ const owned = ports.media.getSnapshot().localTracks.find((item) => item.source === source);
5903
+ const projected = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
5904
+ if (!owned?.publicationId || !projected?.enabled || projected.publicationId !== owned.publicationId) return;
5905
+ this.#sources[source].capturePause = "announced";
5906
+ const send = source === "microphone" ? ports.sync.setMicrophoneEnabled : ports.sync.setCameraEnabled;
5907
+ await send.call(ports.sync, false);
5908
+ }
5880
5909
  #bind(ports) {
5881
5910
  if (ports?.media.getSnapshot().connection.phase === "stopped") this.#cancelCaptures();
5882
5911
  if (ports && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
@@ -5914,10 +5943,11 @@ var MediaControllerRuntime = class {
5914
5943
  const state = this.#sources[pause.source];
5915
5944
  if (pause.revision <= state.serverSequence) return;
5916
5945
  state.serverSequence = pause.revision;
5917
- if (pause.revision > state.desired.controlRevision) this.#pausePublication(ports, pause.source, state.desired.revision);
5946
+ if (pause.revision > state.desired.controlRevision) this.#pausePublication(ports, pause.source, state.desired.revision, "moderation_pause");
5918
5947
  }) ?? null;
5919
5948
  }
5920
5949
  #rememberRecoveryIntents() {
5950
+ for (const state of Object.values(this.#sources)) if (state.capturePause === "announced") state.capturePause = "pending";
5921
5951
  if (!this.#ports) return;
5922
5952
  this.#recovering ??= new Map(MEDIA_SOURCES.filter((source) => this.#sources[source].desired.enabled).map((source) => [source, this.#sources[source].desired.revision]));
5923
5953
  }
@@ -5926,7 +5956,7 @@ var MediaControllerRuntime = class {
5926
5956
  this.#recovering = null;
5927
5957
  for (const [source, revision] of recovering ?? []) {
5928
5958
  const current = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
5929
- if (!current?.enabled) this.#pausePublication(ports, source, revision);
5959
+ if (!current?.enabled) this.#pausePublication(ports, source, revision, "sync_recovery_loss");
5930
5960
  }
5931
5961
  }
5932
5962
  #subscribeSync(ports) {
@@ -5934,6 +5964,7 @@ var MediaControllerRuntime = class {
5934
5964
  this.#removeRequestsFromMissingParticipants(snapshot);
5935
5965
  this.#applyRemoteMediaState(ports, snapshot);
5936
5966
  this.#reconcileIntents();
5967
+ this.#notifyCapturePauses(ports);
5937
5968
  this.#publish();
5938
5969
  });
5939
5970
  }
@@ -6029,8 +6060,8 @@ var MediaControllerRuntime = class {
6029
6060
  const state = this.#sources[source];
6030
6061
  if (!this.#hasConsent(source) && publication?.enabled && state.pausedPublicationId !== publication.publicationId) {
6031
6062
  this.#pauseTracks(source);
6032
- ports.media.setLocalSourceIntent?.(source, false);
6033
- this.#retainPause(state, this.#beginLocalPause(ports, source));
6063
+ ports.media.setLocalSourceIntent?.(source, false, "consent_revoked");
6064
+ this.#retainPause(state, this.#beginLocalPause(ports, source, "consent_revoked"));
6034
6065
  }
6035
6066
  }
6036
6067
  #observeProjection(ports, source) {
@@ -6094,9 +6125,6 @@ function confirmedLocalMediaState(track, desired, pending, confirmed, live, conv
6094
6125
  if (!live && track && liveTrack(track)) return "requesting";
6095
6126
  return confirmed ? "enabled" : pending ? "requesting" : "disabled";
6096
6127
  }
6097
- function cancelledCaptureError() {
6098
- return new ConnectionError({ code: "invalid_state", action: null, recoverable: false, message: "Media capture was cancelled before its result could be used" });
6099
- }
6100
6128
  function foreign2(operation) {
6101
6129
  return Effect5.tryPromise({ try: operation, catch: (cause) => cause });
6102
6130
  }
@@ -7193,7 +7221,7 @@ var SpaceClientCore = class {
7193
7221
  }
7194
7222
  }
7195
7223
  #requireActive() {
7196
- if (this.#disposed) throw new SpaceClientError({ code: "connection.invalid_state", recoverable: false, message: "The SpaceClient has been disposed" });
7224
+ if (this.#disposed) throw new SpaceClientError({ code: "connection.invalid_state", recoverable: false, message: "The SpaceClient has been disposed. Rejoin the call." });
7197
7225
  }
7198
7226
  };
7199
7227
  function liveClock(dependencies) {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createChalkEffectClient,
3
3
  parseAccessGrant
4
- } from "./chunk-UMAE5GCV.js";
4
+ } from "./chunk-CJVLTSIO.js";
5
5
 
6
6
  // src/public-invites/client.ts
7
7
  import { Effect } from "effect";
@@ -238,6 +238,7 @@ var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
238
238
  var CONNECTION_TIMEOUT_MS = 8e3;
239
239
  var NEGOTIATION_TIMEOUT_MS = 35e3;
240
240
  var PEER_OPERATION_TIMEOUT_MS = 8e3;
241
+ var CAPTURE_REUSE_WINDOW_MS = 2e4;
241
242
  var CloudflareSFUClient = class {
242
243
  #localListeners = /* @__PURE__ */ new Set();
243
244
  #onError;
@@ -250,7 +251,7 @@ var CloudflareSFUClient = class {
250
251
  #remoteListeners = /* @__PURE__ */ new Set();
251
252
  #snapshotListeners = /* @__PURE__ */ new Set();
252
253
  #failureOwners = /* @__PURE__ */ new WeakMap();
253
- #locallyPausedSources = /* @__PURE__ */ new Set();
254
+ #locallyPausedSources = /* @__PURE__ */ new Map();
254
255
  #localTracks = /* @__PURE__ */ new Map();
255
256
  #ownedPublications = /* @__PURE__ */ new Set();
256
257
  #remoteTracks = /* @__PURE__ */ new Map();
@@ -259,9 +260,11 @@ var CloudflareSFUClient = class {
259
260
  #disposeConnectionObservation;
260
261
  #connectionEpoch = 0;
261
262
  #retiredLocalConnection = null;
263
+ #retiredLocalCause = "forced_pause";
264
+ #replacementCause;
262
265
  #cursor = null;
263
266
  #remotePullIncomplete = false;
264
- #remotePullRetryAfter = /* @__PURE__ */ new Map();
267
+ #remotePullRetries = /* @__PURE__ */ new Map();
265
268
  #remoteParticipantIds = null;
266
269
  #remoteParticipantRevision = 0;
267
270
  #subscribedPublicationIds = /* @__PURE__ */ new Set();
@@ -308,7 +311,7 @@ var CloudflareSFUClient = class {
308
311
  return subscribeSnapshot(this.#snapshotListeners, listener);
309
312
  }
310
313
  remotePublicationResumed(publicationId) {
311
- this.#remotePullRetryAfter.delete(publicationId);
314
+ this.#remotePullRetries.delete(publicationId);
312
315
  this.remotePublicationsChanged();
313
316
  }
314
317
  remoteParticipantsChanged(participantIds) {
@@ -363,9 +366,9 @@ var CloudflareSFUClient = class {
363
366
  }
364
367
  async #clearPreparedTrack(state, stopTrack = true) {
365
368
  const source = state.source;
366
- if (source === "screen" || source === "screen_audio" || state.track.readyState === "ended") {
369
+ if (source === "screen" || source === "screen_audio") {
367
370
  try {
368
- this.#pauseLocalSource(source);
371
+ this.#pauseLocalSource(source, "capture_cleanup");
369
372
  await this.#retireOwnedPublications();
370
373
  } finally {
371
374
  this.#removeOwnedLocalTrack(state, stopTrack);
@@ -431,6 +434,10 @@ var CloudflareSFUClient = class {
431
434
  const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
432
435
  if (resolved.kind === "result" && resolved.result.outcome !== "satisfied") return resolved.result;
433
436
  const outcome = resolved.kind === "state" && resolved.value.enabled ? "confirmed" : "satisfied";
437
+ if (target.source !== "screen" && this.#locallyPausedSources.get(target.source) === "capture_cleanup") {
438
+ await this.pauseLocalCapture(target.source);
439
+ return { outcome, errorCode: null };
440
+ }
434
441
  this.#pauseLocalSource(target.source);
435
442
  try {
436
443
  await this.#retireOwnedPublications();
@@ -449,26 +456,31 @@ var CloudflareSFUClient = class {
449
456
  await this.#retireUnavailableLocalPublication(state);
450
457
  if (this.#locallyPausedSources.has(target.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
451
458
  if (state.enabled) return { outcome: "satisfied", errorCode: null };
452
- await this.#enablePreparedTrack(state);
459
+ if (!await this.#enablePreparedTrack(state)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
453
460
  return { outcome: "confirmed", errorCode: null };
454
461
  } catch (error) {
455
462
  if (generation === this.#generation && !this.#stopped) this.#reportError(error);
456
463
  return mediaTargetFailure(error);
457
464
  }
458
465
  }
459
- setLocalSourceIntent(source, enabled) {
466
+ async pauseLocalCapture(source) {
467
+ this.#locallyPausedSources.set(source, "capture_cleanup");
468
+ const state = this.#localTracks.get(source);
469
+ if (state) await this.#disablePreparedTrack(state);
470
+ }
471
+ setLocalSourceIntent(source, enabled, cause = "forced_pause") {
460
472
  const sources = localSourceGroup(source);
461
473
  for (const item of sources) {
462
474
  if (enabled) this.#locallyPausedSources.delete(item);
463
- else this.#locallyPausedSources.add(item);
475
+ else this.#locallyPausedSources.set(item, cause);
464
476
  }
465
- if (!enabled) this.#pauseLocalSource(source);
477
+ if (!enabled) this.#pauseLocalSource(source, cause);
466
478
  return enabled ? Promise.resolve() : this.#retireOwnedPublications();
467
479
  }
468
- async closeForcedLocalPublication(source) {
480
+ async closeForcedLocalPublication(source, cause = "forced_pause") {
469
481
  const sources = localSourceGroup(source);
470
482
  const ownsPublication = [...this.#ownedPublications].some((item) => sources.includes(item.state.source));
471
- await this.setLocalSourceIntent(source, false);
483
+ await this.setLocalSourceIntent(source, false, cause);
472
484
  if (!ownsPublication || !this.#replaceMediaConnection) return;
473
485
  const generation = this.#generation;
474
486
  await this.#serializeSDP(async () => {
@@ -491,16 +503,15 @@ var CloudflareSFUClient = class {
491
503
  const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
492
504
  if (authoritative.publications.some((publication) => publication.publicationId === publicationId)) return;
493
505
  if (state.providerPublicationId === publicationId) {
494
- this.#pauseLocalSource(state.source);
506
+ this.#pauseLocalSource(state.source, "discovery_absent");
495
507
  await this.#retireOwnedPublications();
496
508
  }
497
509
  }
498
- #pauseLocalSource(source) {
510
+ #pauseLocalSource(source, cause = "forced_pause") {
499
511
  const sources = localSourceGroup(source);
500
512
  for (const publication of this.#ownedPublications) {
501
513
  if (!sources.includes(publication.state.source)) continue;
502
- this.#markPublicationRetiring(publication);
503
- this.#retiredLocalConnection = this.#connection;
514
+ this.#markPublicationRetiring(publication, cause);
504
515
  }
505
516
  for (const state of this.#localTracks.values()) {
506
517
  if (!sources.includes(state.source)) continue;
@@ -510,7 +521,7 @@ var CloudflareSFUClient = class {
510
521
  this.#publishSnapshot();
511
522
  this.#emitLocal();
512
523
  }
513
- #retireOwnedPublications() {
524
+ #retireOwnedPublications(reportFailure = this.#reportError.bind(this)) {
514
525
  const publications = [...this.#ownedPublications].filter((item) => item.lifecycle.kind === "retiring" && item.lifecycle.handle);
515
526
  const retirement = (async () => {
516
527
  const errors = [];
@@ -519,7 +530,7 @@ var CloudflareSFUClient = class {
519
530
  await this.#serializeSDP(() => this.#closeOwnedPublication(publication), true);
520
531
  } catch (error) {
521
532
  errors.push(error);
522
- if (!this.#stopped) this.#reportError(error);
533
+ if (!this.#stopped) reportFailure(error);
523
534
  }
524
535
  }
525
536
  if (this.#stopped && this.#ownedPublications.size === 0) this.#transport = void 0;
@@ -529,15 +540,24 @@ var CloudflareSFUClient = class {
529
540
  });
530
541
  return this.#retirement = retirement;
531
542
  }
532
- #markPublicationRetiring(publication) {
543
+ #markPublicationRetiring(publication, cause = "publish_failure") {
533
544
  const phase = publication.lifecycle;
534
- if (phase.kind === "pending" || phase.kind === "confirmed") publication.lifecycle = { kind: "retiring", handle: phase.kind === "confirmed" ? phase.handle : null, detached: false };
545
+ if (phase.kind === "pending" || phase.kind === "confirmed") publication.lifecycle = { kind: "retiring", handle: phase.kind === "confirmed" ? phase.handle : null, detached: false, cause };
546
+ }
547
+ #markBundledTransportRetiring(publication) {
548
+ const bundleTag = /(?:^|\r?\n)a=group:BUNDLE[ \t]+(\S+)/.exec(publication.connection.localDescription?.sdp ?? "")?.[1];
549
+ if (!bundleTag || publication.transceiver.mid !== null && publication.transceiver.mid === bundleTag) {
550
+ if (this.#retiredLocalConnection === publication.connection) return;
551
+ this.#retiredLocalConnection = publication.connection;
552
+ if (publication.lifecycle.kind === "retiring") this.#retiredLocalCause = publication.lifecycle.cause;
553
+ }
535
554
  }
536
555
  async #closeOwnedPublication(publication) {
537
556
  const phase = publication.lifecycle;
538
557
  if (phase.kind !== "retiring" || !phase.handle) return;
539
558
  const handle = phase.handle;
540
559
  const { connection, connectionId, transceiver, state } = publication;
560
+ this.#markBundledTransportRetiring(publication);
541
561
  let closed = connection.connectionState === "closed";
542
562
  try {
543
563
  if (!phase.detached && !closed) {
@@ -574,13 +594,16 @@ var CloudflareSFUClient = class {
574
594
  this.#requireActive();
575
595
  const options = "connectionId" in input ? { bootstrap: input } : input;
576
596
  validateBootstrap(options.bootstrap);
577
- if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) this.#fenceLocalPublications();
597
+ if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) {
598
+ this.#fenceLocalPublications();
599
+ this.#replacementCause = "access_refresh";
600
+ }
578
601
  const generation = ++this.#generation;
579
602
  const connectionEpoch = ++this.#connectionEpoch;
580
603
  this.#polling = false;
581
604
  this.#pollAfterCurrent = false;
582
605
  this.#clearPoll();
583
- this.#remotePullRetryAfter.clear();
606
+ this.#remotePullRetries.clear();
584
607
  this.#disposeConnection(false);
585
608
  for (const publication of this.#ownedPublications) this.#markPublicationRetiring(publication);
586
609
  void this.#retireOwnedPublications();
@@ -622,7 +645,7 @@ var CloudflareSFUClient = class {
622
645
  this.#generation++;
623
646
  this.#connectionEpoch++;
624
647
  this.#clearPoll();
625
- this.#remotePullRetryAfter.clear();
648
+ this.#remotePullRetries.clear();
626
649
  this.#polling = false;
627
650
  this.#pollAfterCurrent = false;
628
651
  this.#disposeConnection(true);
@@ -645,35 +668,64 @@ var CloudflareSFUClient = class {
645
668
  }
646
669
  async #enablePreparedTrack(state) {
647
670
  const generation = this.#generation;
648
- state.desiredEnabled = true;
649
- state.track.enabled = true;
650
671
  try {
651
- if (state.providerPublicationId && state.transceiver) {
652
- await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(state.track));
653
- state.enabled = !this.#locallyPausedSources.has(state.source);
654
- state.desiredEnabled = state.enabled;
655
- state.track.enabled = state.enabled;
656
- if (!state.enabled) {
657
- this.#pauseLocalSource(state.source);
658
- await this.#retireOwnedPublications();
659
- }
660
- this.#publishSnapshot();
661
- this.#emitLocal();
662
- return;
663
- }
672
+ await this.#retireExpiredCapture(state);
673
+ if (this.#locallyPausedSources.has(state.source)) return false;
674
+ state.desiredEnabled = true;
675
+ state.track.enabled = true;
676
+ if (state.providerPublicationId && state.transceiver) return await this.#reattachLocalTrack(state, state.transceiver);
664
677
  await this.#publishPreparedTracks([state], generation);
665
678
  if (this.#remoteRecoveryGeneration !== generation) this.#replacementAttemptedGeneration = null;
679
+ return state.enabled;
666
680
  } catch (error) {
667
681
  if (generation === this.#generation) {
668
- this.#pauseLocalSource(state.source);
682
+ this.#pauseLocalSource(state.source, "enable_failure");
669
683
  await this.#retireOwnedPublications().catch(() => {
670
684
  });
671
685
  }
672
686
  throw error;
673
687
  }
674
688
  }
689
+ async #retireExpiredCapture(state) {
690
+ const publication = [...this.#ownedPublications].find((item) => item.transceiver === state.transceiver && item.lifecycle.kind === "confirmed");
691
+ if (!publication || publication.detachedAt === null || Date.now() - publication.detachedAt < CAPTURE_REUSE_WINDOW_MS) return;
692
+ this.#pauseLocalSource(state.source, "capture_cleanup");
693
+ try {
694
+ await this.#retireOwnedPublications((error) => {
695
+ if (!this.#isExpiredRetiredConnection(error)) this.#reportError(error);
696
+ });
697
+ } catch (error) {
698
+ if (!this.#isExpiredRetiredConnection(error)) throw error;
699
+ this.#fenceLocalPublications();
700
+ }
701
+ }
702
+ #isExpiredRetiredConnection(error) {
703
+ if (!this.#replaceMediaConnection || this.#retiredLocalConnection !== this.#connection) return false;
704
+ if (error instanceof AggregateError) return error.errors.every((cause) => this.#isRetryableConnectionFailure(cause));
705
+ return this.#isRetryableConnectionFailure(error);
706
+ }
707
+ async #reattachLocalTrack(state, transceiver) {
708
+ await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
709
+ const cause = this.#locallyPausedSources.get(state.source);
710
+ state.enabled = cause === void 0;
711
+ state.desiredEnabled = state.enabled;
712
+ state.track.enabled = state.enabled;
713
+ if (cause === "capture_cleanup") await this.#disablePreparedTrack(state);
714
+ else if (cause) {
715
+ this.#pauseLocalSource(state.source, cause);
716
+ await this.#retireOwnedPublications();
717
+ } else {
718
+ for (const publication of this.#ownedPublications) if (publication.transceiver === transceiver) publication.detachedAt = null;
719
+ }
720
+ this.#publishSnapshot();
721
+ this.#emitLocal();
722
+ return state.enabled;
723
+ }
675
724
  async #disablePreparedTrack(state) {
676
725
  const transceiver = state.transceiver;
726
+ for (const publication of this.#ownedPublications) {
727
+ if (publication.transceiver === transceiver && publication.lifecycle.kind === "confirmed") publication.detachedAt ??= Date.now();
728
+ }
677
729
  state.desiredEnabled = false;
678
730
  state.track.enabled = false;
679
731
  const detach = transceiver ? this.#boundPeerOperation(transceiver.sender.replaceTrack(null)) : Promise.resolve();
@@ -691,7 +743,7 @@ var CloudflareSFUClient = class {
691
743
  await this.#publishPreparedTracksSerialized(pending, generation, this.#connection, this.#bootstrap.connectionId);
692
744
  } catch (error) {
693
745
  if (!this.#replaceMediaConnection || !this.#isRetryableConnectionFailure(error) || this.#replacementAttemptedGeneration === generation) throw error;
694
- await this.#replaceMediaConnectionForRecovery(generation);
746
+ await this.#replaceMediaConnectionForRecovery(generation, "local_signaling_failure");
695
747
  await this.#publishPreparedTracksSerialized(
696
748
  [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended"),
697
749
  generation,
@@ -713,14 +765,14 @@ var CloudflareSFUClient = class {
713
765
  const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
714
766
  this.#pauseUnavailableOtherSources(authoritative.publications, states);
715
767
  this.#replacementAttemptedGeneration = null;
716
- await this.#replaceMediaConnectionForRecovery(generation);
768
+ await this.#replaceMediaConnectionForRecovery(generation, this.#retiredLocalCause);
717
769
  return [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended");
718
770
  }
719
771
  #pauseUnavailableOtherSources(publications, states) {
720
772
  for (const state of this.#localTracks.values()) {
721
773
  if (states.includes(state) || !state.providerPublicationId) continue;
722
774
  if (publications.some((publication) => publication.publicationId === state.providerPublicationId)) continue;
723
- this.#pauseLocalSource(state.source);
775
+ this.#pauseLocalSource(state.source, "discovery_absent");
724
776
  }
725
777
  void this.#retireOwnedPublications();
726
778
  }
@@ -750,7 +802,7 @@ var CloudflareSFUClient = class {
750
802
  }
751
803
  async #prepareLocalPublication(connection, state, generation) {
752
804
  const transceiver = state.transceiver ?? this.#addLocalTransceiver(connection, state);
753
- const publication = { state, transceiver, trackName: `${state.source}-${globalThis.crypto.randomUUID()}`, connection, connectionId: this.#bootstrap.connectionId, lifecycle: { kind: "pending" } };
805
+ const publication = { state, transceiver, trackName: `${state.source}-${globalThis.crypto.randomUUID()}`, connection, connectionId: this.#bootstrap.connectionId, detachedAt: null, lifecycle: { kind: "pending" } };
754
806
  this.#ownedPublications.add(publication);
755
807
  if (state.transceiver) await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
756
808
  this.#requireGeneration(generation);
@@ -798,7 +850,7 @@ var CloudflareSFUClient = class {
798
850
  source: state.source
799
851
  })
800
852
  );
801
- const response = await this.#signal(() => this.#requireTransport().addTracks({ connectionId, sessionDescription, tracks }), { generation, connectionId });
853
+ const response = await this.#signal(() => this.#addTracks({ connectionId, sessionDescription, tracks }), { generation, connectionId });
802
854
  this.#recordLocalPublicationHandles(publications, response.tracks);
803
855
  this.#requireGeneration(generation);
804
856
  await this.#applyProviderDescription(response, connection);
@@ -824,7 +876,7 @@ var CloudflareSFUClient = class {
824
876
  state.providerPublicationId = lifecycle.handle.publicationId;
825
877
  continue;
826
878
  }
827
- this.#markPublicationRetiring(publication);
879
+ this.#markPublicationRetiring(publication, this.#locallyPausedSources.get(state.source) ?? "publish_failure");
828
880
  if (state.transceiver === transceiver) {
829
881
  this.#clearLocalPublication(state);
830
882
  }
@@ -871,8 +923,8 @@ var CloudflareSFUClient = class {
871
923
  }
872
924
  #pendingRemotePulls(desired) {
873
925
  const desiredIds = new Set([...desired.values()].map((publication) => publication.publicationId));
874
- for (const publicationId of this.#remotePullRetryAfter.keys()) {
875
- if (!desiredIds.has(publicationId)) this.#remotePullRetryAfter.delete(publicationId);
926
+ for (const publicationId of this.#remotePullRetries.keys()) {
927
+ if (!desiredIds.has(publicationId)) this.#remotePullRetries.delete(publicationId);
876
928
  }
877
929
  for (const publicationId of this.#subscribedPublicationIds) {
878
930
  if (!desiredIds.has(publicationId)) this.#subscribedPublicationIds.delete(publicationId);
@@ -880,7 +932,7 @@ var CloudflareSFUClient = class {
880
932
  const now = Date.now();
881
933
  return [...desired].filter(([key, publication]) => {
882
934
  if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) return false;
883
- const remaining = (this.#remotePullRetryAfter.get(publication.publicationId) ?? 0) - now;
935
+ const remaining = (this.#remotePullRetries.get(publication.publicationId)?.retryAfter ?? 0) - now;
884
936
  return remaining <= 0;
885
937
  }).map(([, publication]) => publication);
886
938
  }
@@ -921,7 +973,8 @@ var CloudflareSFUClient = class {
921
973
  }
922
974
  }
923
975
  #canReplaceConnectionAfterRemotePull(error, generation) {
924
- return this.#isRetryableConnectionFailure(error) && !this.#hasLiveRemoteTracks() && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
976
+ const gone = error instanceof CloudflareSFUError && (error.options.status === 404 || error.options.status === 410);
977
+ return this.#isRetryableConnectionFailure(error) && (gone || !this.#hasLiveRemoteTracks()) && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
925
978
  }
926
979
  #hasLiveRemoteTracks() {
927
980
  return [...this.#remoteTracks.values()].some((publication) => publication.track.readyState === "live");
@@ -929,7 +982,7 @@ var CloudflareSFUClient = class {
929
982
  async #retryRemotePullOnReplacement(publications, cursor, generation) {
930
983
  this.#remoteRecoveryGeneration = generation;
931
984
  try {
932
- await this.#replaceMediaConnectionForRecovery(generation);
985
+ await this.#replaceMediaConnectionForRecovery(generation, "remote_signaling_failure");
933
986
  await this.#republishPreparedTracksAfterRecovery(generation);
934
987
  return await this.#pull(publications, generation);
935
988
  } catch (error) {
@@ -974,7 +1027,10 @@ var CloudflareSFUClient = class {
974
1027
  }
975
1028
  #deferRemotePulls(publications, error) {
976
1029
  const retryAfter = retryableRemotePull(error) ? Date.now() + 15e3 : Number.POSITIVE_INFINITY;
977
- for (const publication of publications) this.#remotePullRetryAfter.set(publication.publicationId, retryAfter);
1030
+ for (const publication of publications) {
1031
+ const failures = this.#remotePullRetries.get(publication.publicationId)?.failures ?? 0;
1032
+ this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures });
1033
+ }
978
1034
  }
979
1035
  async #pullBatch(publications, generation) {
980
1036
  if (publications.length === 0) return [];
@@ -1000,12 +1056,12 @@ var CloudflareSFUClient = class {
1000
1056
  connection.addEventListener("track", onTrack);
1001
1057
  try {
1002
1058
  this.#requireGeneration(generation);
1003
- const response = await this.#signal(() => this.#requireTransport().addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), { generation, connectionId });
1059
+ const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), { generation, connectionId });
1004
1060
  this.#requireGeneration(generation);
1005
1061
  this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
1006
1062
  const responseTracks = response.tracks ?? [];
1007
1063
  const matched = matchRemotePullTracks(publications, responseTracks);
1008
- for (const { publication } of matched) this.#remotePullRetryAfter.delete(publication.publicationId);
1064
+ for (const { publication } of matched) this.#remotePullRetries.delete(publication.publicationId);
1009
1065
  await this.#completeRenegotiation(response, connection, connectionId, generation);
1010
1066
  if (matched.length === 0) return [];
1011
1067
  await this.#waitForConnection(connection, generation);
@@ -1035,8 +1091,10 @@ var CloudflareSFUClient = class {
1035
1091
  for (const failure of failures) {
1036
1092
  const publication = requested.get(`${failure.connectionId}\0${failure.trackName}`);
1037
1093
  if (!publication) throw new CloudflareSFUError("Cloudflare SFU returned an unrequested remote track error", "invalid_publication");
1038
- const delay = 15e3;
1039
- this.#remotePullRetryAfter.set(publication.publicationId, Date.now() + delay);
1094
+ const previous = this.#remotePullRetries.get(publication.publicationId) ?? { retryAfter: 0, failures: 0 };
1095
+ const failureCount = Math.min(previous.failures + 1, 6);
1096
+ const delay = Math.min(15e3, 750 * 2 ** (failureCount - 1));
1097
+ this.#remotePullRetries.set(publication.publicationId, { retryAfter: Date.now() + delay, failures: failureCount });
1040
1098
  this.#reportError(new CloudflareSFUError(`Cloudflare SFU remote track failed: ${failure.code}`, "media_failed", { providerCode: failure.code }));
1041
1099
  }
1042
1100
  }
@@ -1187,9 +1245,14 @@ var CloudflareSFUClient = class {
1187
1245
  }
1188
1246
  async #replaceDormantConnectionBeforeNegotiation(generation) {
1189
1247
  if (!this.#started || this.#negotiatedGeneration === generation || this.#replacementAttemptedGeneration === generation || !this.#replaceMediaConnection) return;
1190
- await this.#replaceMediaConnectionForRecovery(generation);
1248
+ await this.#replaceMediaConnectionForRecovery(generation, "dormant_connection");
1249
+ }
1250
+ #addTracks(input) {
1251
+ const replacementCause = this.#replacementCause;
1252
+ this.#replacementCause = void 0;
1253
+ return this.#requireTransport().addTracks({ ...input, replacementCause });
1191
1254
  }
1192
- async #replaceMediaConnectionForRecovery(generation) {
1255
+ async #replaceMediaConnectionForRecovery(generation, cause) {
1193
1256
  this.#requireGeneration(generation);
1194
1257
  if (!this.#replaceMediaConnection || this.#replacementAttemptedGeneration === generation) {
1195
1258
  throw new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed");
@@ -1209,6 +1272,7 @@ var CloudflareSFUClient = class {
1209
1272
  this.#cursor = null;
1210
1273
  this.#bootstrap = bootstrap;
1211
1274
  this.#rebuildPeerConnection(bootstrap, generation, connectionEpoch);
1275
+ this.#replacementCause = cause;
1212
1276
  this.#retiredLocalConnection = null;
1213
1277
  this.#negotiatedGeneration = null;
1214
1278
  for (const state of this.#localTracks.values()) {
@@ -1557,7 +1621,10 @@ function createCloudflareSFUHTTPTransport(options) {
1557
1621
  };
1558
1622
  return {
1559
1623
  addTracks: async (input) => {
1560
- const path = input.allowPartialRemoteTracks ? "tracks?allow_partial_remote_tracks=true" : "tracks";
1624
+ const query = new URLSearchParams();
1625
+ if (input.allowPartialRemoteTracks) query.set("allow_partial_remote_tracks", "true");
1626
+ if (input.replacementCause) query.set("replacement_cause", input.replacementCause);
1627
+ const path = query.size ? `tracks?${query}` : "tracks";
1561
1628
  const response = await request(path, {
1562
1629
  method: "POST",
1563
1630
  body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription, tracks: input.tracks })
package/dist/client.d.ts CHANGED
@@ -23,6 +23,7 @@ export declare const createChalkEffectClient: (options: ChalkEffectClientOptions
23
23
  readonly tenant_id: import("effect/Schema").brand<import("effect/Schema").String, "TenantId">;
24
24
  }>>, import("effect/Schema").toCodecStringTree<import("effect/Schema").Struct<{
25
25
  readonly allow_partial_remote_tracks: import("effect/Schema").optional<import("effect/Schema").Boolean>;
26
+ readonly replacement_cause: import("effect/Schema").optional<import("effect/Schema").Literals<readonly ["user_pause", "moderation_pause", "sync_recovery_loss", "discovery_absent", "capture_cleanup", "consent_revoked", "publish_failure", "enable_failure", "projection_mismatch", "forced_pause", "local_signaling_failure", "remote_signaling_failure", "dormant_connection", "access_refresh"]>>;
26
27
  }>>, import("effect/Schema").toCodecJson<import("effect/Schema").Struct<{
27
28
  readonly connection_id: import("effect/Schema").String;
28
29
  readonly session_description: import("effect/Schema").optional<import("effect/Schema").NullOr<import("effect/Schema").Struct<{
@@ -10,6 +10,6 @@ export declare const makeProductionConnectionPlatformLayer: (options: {
10
10
  readonly syncURL: string;
11
11
  readonly whiteboardURL?: string | null;
12
12
  }) => import("effect/Layer").Layer<import("./dependencies").ConnectionPlatformService, never, never>;
13
- type BoundMediaClient = Pick<ConnectionMediaClient, "setLocalPublicationTarget" | "setLocalSourceIntent" | "closeForcedLocalPublication" | "observeLocalPublications" | "observeRemotePublications" | "remotePublicationsChanged" | "remoteParticipantsChanged" | "remotePublicationResumed" | "start" | "stop" | "prepareLocalTrack" | "clearPreparedLocalTrack" | "getSnapshot" | "subscribe">;
13
+ type BoundMediaClient = Pick<ConnectionMediaClient, "setLocalPublicationTarget" | "pauseLocalCapture" | "setLocalSourceIntent" | "closeForcedLocalPublication" | "observeLocalPublications" | "observeRemotePublications" | "remotePublicationsChanged" | "remoteParticipantsChanged" | "remotePublicationResumed" | "start" | "stop" | "prepareLocalTrack" | "clearPreparedLocalTrack" | "getSnapshot" | "subscribe">;
14
14
  export declare function bindConnectionMediaClient(client: BoundMediaClient, restart: ConnectionMediaClient["restart"]): ConnectionMediaClient;
15
15
  export {};
package/dist/effect.js CHANGED
@@ -17,8 +17,8 @@ import {
17
17
  makeSpaceClientCoreLayer,
18
18
  makeSpaceClientCoreLayerFromServices,
19
19
  makeSpaceStoreLayer
20
- } from "./chunk-BUNSEDXL.js";
21
- import "./chunk-FD25AMLG.js";
20
+ } from "./chunk-I7Y4HR2P.js";
21
+ import "./chunk-YIDKETOX.js";
22
22
  import {
23
23
  APIKeyListSchema,
24
24
  APIKeyWithSecretSchema,
@@ -841,7 +841,7 @@ import {
841
841
  WhiteboardStorageUnavailableErrorSchema,
842
842
  WhiteboardStorageUnavailableErrorWireSchema,
843
843
  createChalkEffectClient
844
- } from "./chunk-UMAE5GCV.js";
844
+ } from "./chunk-CJVLTSIO.js";
845
845
  import "./chunk-5YPQNMJE.js";
846
846
 
847
847
  // src/space-client/effect.ts
@@ -7,6 +7,7 @@ declare const ChalkApi_base: HttpApi.HttpApi<"ChalkApi", HttpApiGroup.HttpApiGro
7
7
  readonly tenant_id: import("effect/Schema").brand<import("effect/Schema").String, "TenantId">;
8
8
  }>>, import("effect/Schema").toCodecStringTree<import("effect/Schema").Struct<{
9
9
  readonly allow_partial_remote_tracks: import("effect/Schema").optional<import("effect/Schema").Boolean>;
10
+ readonly replacement_cause: import("effect/Schema").optional<import("effect/Schema").Literals<readonly ["user_pause", "moderation_pause", "sync_recovery_loss", "discovery_absent", "capture_cleanup", "consent_revoked", "publish_failure", "enable_failure", "projection_mismatch", "forced_pause", "local_signaling_failure", "remote_signaling_failure", "dormant_connection", "access_refresh"]>>;
10
11
  }>>, import("effect/Schema").toCodecJson<import("effect/Schema").Struct<{
11
12
  readonly connection_id: import("effect/Schema").String;
12
13
  readonly session_description: import("effect/Schema").optional<import("effect/Schema").NullOr<import("effect/Schema").Struct<{
@@ -1358,6 +1358,7 @@ export declare const AddCloudflareSFUTracksPathParamsSchema: Schema.Struct<{
1358
1358
  export type AddCloudflareSFUTracksPathParams = typeof AddCloudflareSFUTracksPathParamsSchema.Type;
1359
1359
  export declare const AddCloudflareSFUTracksQueryParamsSchema: Schema.Struct<{
1360
1360
  readonly allow_partial_remote_tracks: Schema.optional<Schema.Boolean>;
1361
+ readonly replacement_cause: Schema.optional<Schema.Literals<readonly ["user_pause", "moderation_pause", "sync_recovery_loss", "discovery_absent", "capture_cleanup", "consent_revoked", "publish_failure", "enable_failure", "projection_mismatch", "forced_pause", "local_signaling_failure", "remote_signaling_failure", "dormant_connection", "access_refresh"]>>;
1361
1362
  }>;
1362
1363
  export type AddCloudflareSFUTracksQueryParams = typeof AddCloudflareSFUTracksQueryParamsSchema.Type;
1363
1364
  export declare const AddCloudflareSFUTracksRequestBodySchema: Schema.Struct<{
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  createSpaceClient,
16
16
  decodeWhiteboardV1ServerFrame,
17
17
  encodeWhiteboardV1ClientFrame
18
- } from "./chunk-BUNSEDXL.js";
18
+ } from "./chunk-I7Y4HR2P.js";
19
19
  import {
20
20
  CloudflareSFUClient,
21
21
  CloudflareSFUError,
@@ -28,19 +28,19 @@ import {
28
28
  requireSFUDescription,
29
29
  validatePublicationSnapshot,
30
30
  waitFor
31
- } from "./chunk-FD25AMLG.js";
31
+ } from "./chunk-YIDKETOX.js";
32
32
  import {
33
33
  createChalkPublicClient,
34
34
  createChalkPublicInviteClient,
35
35
  parseInviteCode
36
- } from "./chunk-LKNT6XFD.js";
36
+ } from "./chunk-WFRTUBN6.js";
37
37
  import {
38
38
  ArrivalInvalidHandleError,
39
39
  ArrivalUnavailableError,
40
40
  SpacePublicInviteUnavailableError,
41
41
  parseParsedAccessGrant,
42
42
  requireAccessGrant
43
- } from "./chunk-UMAE5GCV.js";
43
+ } from "./chunk-CJVLTSIO.js";
44
44
  import {
45
45
  AsyncStorageV1PendingTargetStore,
46
46
  InMemoryV1PendingTargetStore,
@@ -1,4 +1,4 @@
1
- import type { ClientMediaPlane, MediaPlaneResult, MediaPlaneTarget, MediaPublication, MediaPublicationSource } from "./plane";
1
+ import type { ClientMediaPlane, LocalPublicationRetirementCause, MediaPlaneResult, MediaPlaneTarget, MediaPublication, MediaPublicationSource } from "./plane";
2
2
  import type { CloudflareSFUBootstrap, CloudflareSFUClientOptions, CloudflareSFURestartOptions, CloudflareSFUSnapshot } from "./types";
3
3
  export declare class CloudflareSFUClient implements ClientMediaPlane {
4
4
  #private;
@@ -15,8 +15,9 @@ export declare class CloudflareSFUClient implements ClientMediaPlane {
15
15
  start(localMedia: MediaStream): Promise<void>;
16
16
  refreshRemotePublications(): Promise<void>;
17
17
  setLocalPublicationTarget(target: MediaPlaneTarget): Promise<MediaPlaneResult>;
18
- setLocalSourceIntent(source: MediaPublicationSource, enabled: boolean): Promise<void>;
19
- closeForcedLocalPublication(source: MediaPublicationSource): Promise<void>;
18
+ pauseLocalCapture(source: "microphone" | "camera"): Promise<void>;
19
+ setLocalSourceIntent(source: MediaPublicationSource, enabled: boolean, cause?: LocalPublicationRetirementCause): Promise<void>;
20
+ closeForcedLocalPublication(source: MediaPublicationSource, cause?: LocalPublicationRetirementCause): Promise<void>;
20
21
  observeLocalPublications(listener: (publications: readonly MediaPublication[]) => void): () => void;
21
22
  observeRemotePublications(listener: (publications: readonly MediaPublication[]) => void): () => void;
22
23
  restart(input: CloudflareSFUBootstrap | CloudflareSFURestartOptions): Promise<void>;
@@ -10,7 +10,7 @@ import {
10
10
  requireSFUDescription,
11
11
  validatePublicationSnapshot,
12
12
  waitFor
13
- } from "../chunk-FD25AMLG.js";
13
+ } from "../chunk-YIDKETOX.js";
14
14
  export {
15
15
  CloudflareSFUClient,
16
16
  CloudflareSFUError,
@@ -1,3 +1,6 @@
1
+ import type { operations } from "../generated/openapi-types";
2
+ export type MediaConnectionReplacementCause = NonNullable<NonNullable<operations["addCloudflareSFUTracks"]["parameters"]["query"]>["replacement_cause"]>;
3
+ export type LocalPublicationRetirementCause = Exclude<MediaConnectionReplacementCause, "local_signaling_failure" | "remote_signaling_failure" | "dormant_connection" | "access_refresh">;
1
4
  export type ScreenShareSurface = "monitor" | "window" | "browser";
2
5
  export type ScreenShareOptions = {
3
6
  readonly surface?: ScreenShareSurface;
@@ -54,8 +57,9 @@ export type MediaPlaneResult = {
54
57
  };
55
58
  export type ClientMediaPlane = {
56
59
  readonly setLocalPublicationTarget: (target: MediaPlaneTarget) => Promise<MediaPlaneResult>;
57
- readonly setLocalSourceIntent?: (source: MediaSource, enabled: boolean) => void;
58
- readonly closeForcedLocalPublication?: (source: MediaSource) => Promise<void>;
60
+ readonly pauseLocalCapture?: (source: "microphone" | "camera") => Promise<void>;
61
+ readonly setLocalSourceIntent?: (source: MediaSource, enabled: boolean, cause?: LocalPublicationRetirementCause) => void;
62
+ readonly closeForcedLocalPublication?: (source: MediaSource, cause?: LocalPublicationRetirementCause) => Promise<void>;
59
63
  readonly observeLocalPublications: (listener: (publications: readonly MediaPublication[]) => void) => () => void;
60
64
  readonly observeRemotePublications: (listener: (publications: readonly MediaPublication[]) => void) => () => void;
61
65
  readonly remotePublicationsChanged?: () => void;
@@ -1,5 +1,5 @@
1
1
  import type { ConnectionReportTransportEvent } from "../transport-diagnostics";
2
- import type { MediaPublication, MediaPublicationSource } from "./plane";
2
+ import type { MediaConnectionReplacementCause, MediaPublication, MediaPublicationSource } from "./plane";
3
3
  export type CloudflareSFUBootstrap = {
4
4
  readonly connectionId: string;
5
5
  readonly stunServer: string;
@@ -52,6 +52,7 @@ export type CloudflareSFUSignalingTransport = {
52
52
  readonly sessionDescription?: CloudflareSFUSessionDescription;
53
53
  readonly tracks: readonly CloudflareSFUTrackRequest[];
54
54
  readonly allowPartialRemoteTracks?: boolean;
55
+ readonly replacementCause?: MediaConnectionReplacementCause;
55
56
  }) => Promise<CloudflareSFUTracksResponse>;
56
57
  readonly closeTracks: (input: {
57
58
  readonly connectionId: string;
@@ -2,12 +2,12 @@ import {
2
2
  createChalkPublicClient,
3
3
  createChalkPublicInviteClient,
4
4
  parseInviteCode
5
- } from "../chunk-LKNT6XFD.js";
5
+ } from "../chunk-WFRTUBN6.js";
6
6
  import {
7
7
  ArrivalInvalidHandleError,
8
8
  ArrivalUnavailableError,
9
9
  SpacePublicInviteUnavailableError
10
- } from "../chunk-UMAE5GCV.js";
10
+ } from "../chunk-CJVLTSIO.js";
11
11
  export {
12
12
  ArrivalInvalidHandleError,
13
13
  ArrivalUnavailableError,
@@ -1,11 +1,11 @@
1
1
  import { Effect } from "effect";
2
- type ControllerCommand = (...args: any[]) => unknown;
3
- type PromiseRuntime = {
4
- readonly runPromise: (effect: Effect.Effect<any, any, any>) => Promise<any>;
2
+ type ControllerCommand = (...args: never[]) => unknown;
3
+ type PromiseRuntime<Services> = {
4
+ readonly runPromise: (effect: Effect.Effect<unknown, unknown, Services>) => Promise<unknown>;
5
5
  };
6
- export type PromiseCommand<Command> = Command extends (...args: infer Arguments) => infer Result ? (Result extends Effect.Effect<infer Success, any, any> ? (...args: Arguments) => Promise<Success> : (...args: Arguments) => Result) : never;
6
+ export type PromiseCommand<Command> = Command extends (...args: infer Arguments) => infer Result ? (Result extends Effect.Effect<infer Success, unknown, unknown> ? (...args: Arguments) => Promise<Success> : (...args: Arguments) => Result) : never;
7
7
  export type PromiseController<Controller extends Record<string, ControllerCommand>> = {
8
8
  readonly [Key in keyof Controller]: PromiseCommand<Controller[Key]>;
9
9
  };
10
- export declare function toPromiseController<Controller extends Record<string, ControllerCommand>>(runtime: PromiseRuntime, controller: Controller): PromiseController<Omit<Controller, "configure" | "dispose">>;
10
+ export declare function toPromiseController<Services, Controller extends Record<string, ControllerCommand>>(runtime: PromiseRuntime<Services>, controller: Controller): PromiseController<Omit<Controller, "configure" | "dispose">>;
11
11
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q9labsai/chalk-client",
3
- "version": "5.2.5",
3
+ "version": "5.2.6",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",