@q9labsai/chalk-client 5.2.1 → 5.2.3

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.
@@ -18,8 +18,9 @@ import {
18
18
  createBrowserWebSocketFactory,
19
19
  createV1SyncClient,
20
20
  formatSyncConnectDiagnostic,
21
- scheduleSyncDialDeadline
22
- } from "./chunk-TVNI2XUZ.js";
21
+ scheduleSyncDialDeadline,
22
+ syncConnectFailureCode
23
+ } from "./chunk-IMMT22US.js";
23
24
 
24
25
  // src/access/manager.ts
25
26
  import { Clock, Context, Data, Effect, Layer, SynchronizedRef } from "effect";
@@ -332,7 +333,8 @@ var WhiteboardV1ProtocolMetadata = {
332
333
  extensions: {
333
334
  presentation: {
334
335
  name: "presentation_v1",
335
- exactFields: ["name"]
336
+ exactFields: ["name"],
337
+ presenterExactFields: ["name", "presenter"]
336
338
  },
337
339
  view: {
338
340
  name: "view_v1",
@@ -654,7 +656,7 @@ function isExtensions(value) {
654
656
  const drawPolicyLast = value.length > 0 && isExtension(value[value.length - 1], drawPolicy);
655
657
  const base = drawPolicyLast ? value.slice(0, -1) : value;
656
658
  if (base.length === 0) return drawPolicyLast;
657
- return (base.length === 1 || base.length === 2) && isExtension(base[0], presentation) && (base.length === 1 || isExtension(base[1], view) || hasExactKeys(base[1], view.ownerExactFields) && base[1].name === view.name && base[1].origins === true && base[1].owner === true || hasExactKeys(base[1], view.extendedExactFields) && base[1].name === view.name && base[1].origins === true);
659
+ return (base.length === 1 || base.length === 2) && (isExtension(base[0], presentation) || hasExactKeys(base[0], presentation.presenterExactFields) && base[0].name === presentation.name && base[0].presenter === true) && (base.length === 1 || isExtension(base[1], view) || hasExactKeys(base[1], view.ownerExactFields) && base[1].name === view.name && base[1].origins === true && base[1].owner === true || hasExactKeys(base[1], view.extendedExactFields) && base[1].name === view.name && base[1].origins === true);
658
660
  }
659
661
  function isDrawPolicy(value) {
660
662
  return typeof value === "string" && WhiteboardV1ProtocolMetadata.drawPolicies.some((policy) => policy === value);
@@ -663,6 +665,10 @@ function isDrawGrant(value) {
663
665
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.drawAccess.grantExactFields) && isUuid(value.participant_id) && typeof value.can_draw === "boolean";
664
666
  }
665
667
  function isWelcome(value) {
668
+ if ("presenter_id" in value) {
669
+ const { presenter_id, ...welcome } = value;
670
+ return (presenter_id === null || isUuid(presenter_id)) && isWelcome(welcome);
671
+ }
666
672
  if ("view_owner_id" in value) {
667
673
  const { view_owner_id, ...welcome } = value;
668
674
  return isUuid(view_owner_id) && isWelcome(welcome);
@@ -670,6 +676,13 @@ function isWelcome(value) {
670
676
  const exact = hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.welcome.exactFields) || hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.welcome.extendedExactFields) && typeof value.presenting === "boolean";
671
677
  return exact && value.protocol === "whiteboard-v1" && isUuid(value.participant_id) && Number.isSafeInteger(value.participant_generation) && Number(value.participant_generation) > 0 && isCapabilities(value.capabilities) && isCapabilities(value.participant_capabilities) && isUuid(value.scene_id) && isUnsignedDecimal(value.revision) && typeof value.can_draw === "boolean";
672
678
  }
679
+ function isPresentationUpdated(value) {
680
+ if ("presenter_id" in value) {
681
+ const { presenter_id, ...frame } = value;
682
+ return (presenter_id === null || isUuid(presenter_id)) && isPresentationUpdated(frame);
683
+ }
684
+ return hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.presentationUpdated.exactFields) && isUuid(value.scene_id) && isUnsignedDecimal(value.revision) && typeof value.presenting === "boolean";
685
+ }
673
686
  function isServerFrame(value) {
674
687
  if (!isRecord2(value)) return false;
675
688
  switch (value.type) {
@@ -692,7 +705,7 @@ function isServerFrame(value) {
692
705
  case "draw_access":
693
706
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.drawAccess.exactFields) && isDrawPolicy(value.policy) && typeof value.can_draw === "boolean" && isCapabilities(value.capabilities) && Array.isArray(value.grants) && value.grants.length <= WhiteboardV1ProtocolLimits.drawAccessMaxGrants && value.grants.every(isDrawGrant);
694
707
  case "presentation_updated":
695
- return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.presentationUpdated.exactFields) && isUuid(value.scene_id) && isUnsignedDecimal(value.revision) && typeof value.presenting === "boolean";
708
+ return isPresentationUpdated(value);
696
709
  case "reset_required":
697
710
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.resetRequired.exactFields) && isUuid(value.scene_id) && typeof value.reason === "string" && WhiteboardV1ProtocolMetadata.resetReasons.some((reason) => reason === value.reason);
698
711
  case "operation_error":
@@ -1065,7 +1078,8 @@ var ChalkWhiteboardV1Client = class {
1065
1078
  #presentationSupported = false;
1066
1079
  #viewsSupported = false;
1067
1080
  #presenting = false;
1068
- #hello = "owner-view";
1081
+ #presenterId = null;
1082
+ #hello = "presenter-view";
1069
1083
  #viewSequence = 0;
1070
1084
  #viewOwnerId = null;
1071
1085
  #viewVersions = new ChalkWhiteboardViewVersions();
@@ -1118,7 +1132,7 @@ var ChalkWhiteboardV1Client = class {
1118
1132
  if (this.#started && this.#startPromise) return this.#startPromise;
1119
1133
  this.#started = true;
1120
1134
  globalThis.document?.addEventListener("visibilitychange", this.#visibilityChanged);
1121
- this.#hello = "owner-view";
1135
+ this.#hello = "presenter-view";
1122
1136
  this.#rejectedHelloRetries = 0;
1123
1137
  this.#viewsSupported = false;
1124
1138
  this.#phase = "connecting";
@@ -1172,6 +1186,7 @@ var ChalkWhiteboardV1Client = class {
1172
1186
  this.#drawPolicy = null;
1173
1187
  this.#drawPermissions = [];
1174
1188
  this.#presenting = false;
1189
+ this.#presenterId = null;
1175
1190
  this.#publishSummary("unsubscribed", null);
1176
1191
  }
1177
1192
  subscribe(listener) {
@@ -1488,6 +1503,7 @@ var ChalkWhiteboardV1Client = class {
1488
1503
  this.#sceneId = frame.scene_id;
1489
1504
  this.#revision = frame.revision;
1490
1505
  this.#presenting = frame.presenting;
1506
+ this.#presenterId = frame.presenter_id ?? null;
1491
1507
  this.#publishSummary("ready", null);
1492
1508
  return;
1493
1509
  case "reset_required":
@@ -1560,7 +1576,13 @@ var ChalkWhiteboardV1Client = class {
1560
1576
  this.#drawPolicy = null;
1561
1577
  this.#drawPermissions = [];
1562
1578
  this.#presentationSupported = "presenting" in frame;
1563
- this.#presenting = "presenting" in frame ? frame.presenting : false;
1579
+ if ("presenting" in frame) {
1580
+ this.#presenting = frame.presenting;
1581
+ this.#presenterId = frame.presenter_id ?? null;
1582
+ } else {
1583
+ this.#presenting = false;
1584
+ this.#presenterId = null;
1585
+ }
1564
1586
  const refused = !drawPolicyNegotiated(this.#hello) && this.#dropPolicyChanges();
1565
1587
  this.#publishSummary("loading", refused ? policyUnavailable() : null);
1566
1588
  this.#clearHeartbeat();
@@ -1864,7 +1886,7 @@ var ChalkWhiteboardV1Client = class {
1864
1886
  if (step === "fail") return false;
1865
1887
  if (step === "step-down") return this.#stepDownHello();
1866
1888
  if (step === "retry-hello") return this.#retryHello();
1867
- if (phase === "live") this.#hello = "owner-view";
1889
+ if (phase === "live") this.#hello = "presenter-view";
1868
1890
  return true;
1869
1891
  }
1870
1892
  #stepDownHello() {
@@ -1988,6 +2010,7 @@ var ChalkWhiteboardV1Client = class {
1988
2010
  drawPolicy: this.#drawPolicy,
1989
2011
  drawPermissions: this.#drawPermissions,
1990
2012
  presenting: this.#presenting,
2013
+ presenterId: this.#presenterId,
1991
2014
  error: this.#summaryError
1992
2015
  };
1993
2016
  }
@@ -2192,19 +2215,20 @@ function operationName(frame) {
2192
2215
  function retryableOperationCode(code) {
2193
2216
  return code === "unavailable" || code === "overloaded" || code === "rate_limited" || code === "storage_unavailable";
2194
2217
  }
2195
- var HELLO_LEVELS = ["owner-view", "draw-policy", "versioned-view", "view", "presentation", "legacy"];
2218
+ var HELLO_LEVELS = ["presenter-view", "owner-view", "draw-policy", "versioned-view", "view", "presentation", "legacy"];
2196
2219
  function viewsNegotiated(level) {
2197
- return level === "owner-view" || level === "draw-policy" || level === "versioned-view" || level === "view";
2220
+ return level === "presenter-view" || level === "owner-view" || level === "draw-policy" || level === "versioned-view" || level === "view";
2198
2221
  }
2199
2222
  function viewOriginsNegotiated(level) {
2200
- return level === "owner-view" || level === "draw-policy" || level === "versioned-view";
2223
+ return level === "presenter-view" || level === "owner-view" || level === "draw-policy" || level === "versioned-view";
2201
2224
  }
2202
2225
  function drawPolicyNegotiated(level) {
2203
- return level === "owner-view" || level === "draw-policy";
2226
+ return level === "presenter-view" || level === "owner-view" || level === "draw-policy";
2204
2227
  }
2205
2228
  function helloFrame(level, token, cursor) {
2206
2229
  const hello = { type: "hello", protocol: "whiteboard-v1", token, cursor };
2207
2230
  if (level === "legacy") return hello;
2231
+ if (level === "presenter-view") return { ...hello, extensions: [{ name: "presentation_v1", presenter: true }, { name: "view_v1", origins: true, owner: true }, { name: "draw_policy_v1" }] };
2208
2232
  if (level === "presentation") return { ...hello, extensions: [{ name: "presentation_v1" }] };
2209
2233
  if (level === "view") return { ...hello, extensions: [{ name: "presentation_v1" }, { name: "view_v1" }] };
2210
2234
  if (level === "versioned-view") return { ...hello, extensions: [{ name: "presentation_v1" }, { name: "view_v1", origins: true }] };
@@ -2517,6 +2541,22 @@ var ConnectionDiagnostics = class {
2517
2541
  } catch {
2518
2542
  }
2519
2543
  }
2544
+ recordRecoveryExhausted(input) {
2545
+ const { state, epoch, code, attempts, transport } = input;
2546
+ this.record({
2547
+ event: "recovery_exhausted",
2548
+ state,
2549
+ epoch,
2550
+ code,
2551
+ recovery: {
2552
+ reference: `connection.${code}`,
2553
+ attempts,
2554
+ transport,
2555
+ lastHeartbeatReplyAgoMs: transport?.lastHeartbeatReplyAt == null ? null : Math.max(0, this.#now() - transport.lastHeartbeatReplyAt),
2556
+ visibilityState: typeof document === "undefined" ? null : document.visibilityState
2557
+ }
2558
+ });
2559
+ }
2520
2560
  snapshot() {
2521
2561
  return Object.freeze([...this.#events]);
2522
2562
  }
@@ -2551,7 +2591,7 @@ var ConnectionDiagnostics = class {
2551
2591
 
2552
2592
  // src/connection/lifecycle.ts
2553
2593
  var MEDIA_RECOVERY_TIMEOUT_MS = 6e4;
2554
- var START_TIMEOUT_MS = 1e4;
2594
+ var START_TIMEOUT_MS = 3e4;
2555
2595
  var LEAVE_TIMEOUT_MS = 5e3;
2556
2596
  var RECOVERY_BUDGET_MS = 1e4;
2557
2597
  var MAX_RECOVERY_ATTEMPTS = 3;
@@ -2768,7 +2808,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2768
2808
  const failure2 = snapshot.connection.connectFailure;
2769
2809
  if (failure2 && failure2 !== observedConnectFailure) {
2770
2810
  observedConnectFailure = failure2;
2771
- diagnostics.record({ event: "sync_connect_failed", state: model.state, epoch: model.epoch, code: "sync_hello_timeout", sync: failure2 });
2811
+ diagnostics.record({ event: "sync_connect_failed", state: model.state, epoch: model.epoch, code: syncConnectFailureCode(failure2), sync: failure2 });
2772
2812
  }
2773
2813
  const count = snapshot.connection.dialTimeoutCount ?? 0;
2774
2814
  if (count > observedDialTimeouts) {
@@ -2844,7 +2884,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2844
2884
  const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
2845
2885
  return effect.pipe(
2846
2886
  Effect2.tap(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "succeeded" }))),
2847
- Effect2.tapError(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "failed" })))
2887
+ Effect2.tapError((cause) => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failureFromCause(cause, "internal_error", "Connection step failed").code })))
2848
2888
  );
2849
2889
  };
2850
2890
  const confirmSyncEntry = (sync) => {
@@ -2921,7 +2961,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2921
2961
  Effect2.matchEffect({
2922
2962
  onFailure: (cause) => Effect2.gen(function* () {
2923
2963
  stopStream(stream);
2924
- const failure2 = failureFromCause(cause, "sync_start_failed", "The Connection could not start");
2964
+ const failure2 = failureFromCause(cause, "internal_error", "The Connection could not start");
2925
2965
  model.failure = toFailure(failure2);
2926
2966
  if (model.joinCancelled) {
2927
2967
  span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "invalid_state" });
@@ -3048,7 +3088,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3048
3088
  const syncAttempt = { client: null, expiresAt: null, started: true };
3049
3089
  while (model.epoch === epoch && active(model)) {
3050
3090
  const plan = yield* recoveryPlan(model.recoveryQueued ?? kind);
3051
- const outcome = yield* attemptRecovery(plan, mediaAttempt, syncAttempt).pipe(
3091
+ let attempts = 0;
3092
+ const outcome = yield* attemptRecovery(plan, mediaAttempt, syncAttempt, () => attempts++).pipe(
3052
3093
  Effect2.match({
3053
3094
  onSuccess: (attempt) => ({ attempt, failure: null }),
3054
3095
  onFailure: (failure2) => ({ attempt: null, failure: failure2 })
@@ -3066,9 +3107,18 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3066
3107
  yield* enqueue(
3067
3108
  Effect2.gen(function* () {
3068
3109
  if (model.epoch !== epoch || !active(model)) return;
3110
+ if (plan.kind === "sync" && model.sync?.getSnapshot().connection.phase === "live") {
3111
+ model.recoveryQueued = "media";
3112
+ return;
3113
+ }
3069
3114
  const code = plan.kind === "media" ? "media_recovery_exhausted" : "sync_recovery_exhausted";
3115
+ const transport = model.sync?.getSnapshot().connection.recoveryDiagnostic ?? null;
3116
+ diagnostics.recordRecoveryExhausted({ state: model.state, epoch, code, attempts, transport });
3117
+ if (plan.kind === "sync") {
3118
+ yield* failForSnapshot(lifecycleFailure(code, true, "Connection lost. Rejoin to continue."));
3119
+ return;
3120
+ }
3070
3121
  model.failure = toFailure(lifecycleFailure(code, true, "Connection interrupted. Reconnecting in the background."));
3071
- diagnostics.record({ event: "recovery_exhausted", state: model.state, epoch, code });
3072
3122
  yield* publish();
3073
3123
  })
3074
3124
  );
@@ -3089,14 +3139,14 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3089
3139
  delays: options.recovery?.backoffMs?.length ? options.recovery.backoffMs : [100, 250, 500]
3090
3140
  }))
3091
3141
  );
3092
- const attemptRecovery = (plan, mediaAttempt2, syncAttempt) => Effect2.gen(function* () {
3142
+ const attemptRecovery = (plan, mediaAttempt2, syncAttempt, onAttempt) => Effect2.gen(function* () {
3093
3143
  for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
3094
3144
  const remaining = plan.deadline - (yield* Clock2.currentTimeMillis);
3095
3145
  if (remaining <= 0) return null;
3146
+ onAttempt();
3096
3147
  diagnostics.record({ event: "recovery_attempt", state: model.state, epoch: model.epoch, attempt });
3097
3148
  const failure2 = yield* recoveryOperation(mediaAttempt2, syncAttempt).pipe(
3098
- Effect2.timeout(remaining),
3099
- Effect2.catchTag("TimeoutError", () => Effect2.fail(lifecycleFailure("sync_start_failed", true, "Connection recovery is still waiting for the transport"))),
3149
+ (effect) => withinForegroundBudget(effect, remaining, lifecycleFailure("sync_start_failed", true, "Connection recovery is still waiting for the transport")),
3100
3150
  Effect2.match({ onSuccess: () => null, onFailure: (cause) => cause })
3101
3151
  );
3102
3152
  if (failure2 === null) return attempt;
@@ -3584,9 +3634,11 @@ function syncTerminalFailure(snapshot) {
3584
3634
  return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
3585
3635
  }
3586
3636
  function syncStartupFailure(snapshot) {
3587
- const sync = snapshot.connection.connectFailure ?? snapshot.connection.connectAttempt;
3637
+ const evidence = snapshot.connection.connectFailure ?? snapshot.connection.connectAttempt;
3638
+ const attempts = snapshot.connection.connectAttempt?.attempt;
3639
+ const sync = evidence ? { ...evidence, ...attempts === void 0 ? {} : { attempts } } : void 0;
3588
3640
  const message = sync?.opened === false ? "Sync could not reach the call service" : "Sync could not connect";
3589
- return new ConnectionLifecycleFailure({ code: "sync_hello_timeout", recoverable: true, message: `${message}${sync ? ` ${formatSyncConnectDiagnostic(sync)}` : " before the startup deadline"}`, ...sync ? { sync: Object.freeze({ ...sync }) } : {} });
3641
+ return new ConnectionLifecycleFailure({ code: syncConnectFailureCode(sync), recoverable: true, message: `${message}${sync ? ` ${formatSyncConnectDiagnostic(sync)}` : " before the startup deadline"}`, ...sync ? { sync: Object.freeze({ ...sync }) } : {} });
3590
3642
  }
3591
3643
  function syncAutoRecoverable(snapshot, failure2) {
3592
3644
  return failure2.recoverable && snapshot.connection.terminalReason !== "entry_timeout";
@@ -3631,6 +3683,7 @@ var INVALID_PAYLOAD_CODE_BY_ACTION = /* @__PURE__ */ new Map([
3631
3683
  ]);
3632
3684
  var MEDIA_TIMEOUT_FAILURE = { code: "media.timeout", recoverable: true, message: "Media did not respond in time. Try again." };
3633
3685
  var SYNC_FAILURES = /* @__PURE__ */ new Map([
3686
+ ["sync_start_failed", { failure: { code: "connection.sync_start_failed", recoverable: true, message: "A connection to the call service could not be opened. Try again." } }],
3634
3687
  ["sync_hello_timeout", { failure: { code: "connection.sync_hello_timeout", recoverable: true, message: "Sync could not reach the call service. Try again." } }],
3635
3688
  ["not_live", { failure: { code: "connection.invalid_state", recoverable: true, message: "Wait for the call to connect, then try again." } }],
3636
3689
  ["signaling_timeout", { failure: MEDIA_TIMEOUT_FAILURE }],
@@ -6233,6 +6286,7 @@ var WhiteboardControllerRuntime = class {
6233
6286
  drawPolicy: summary.drawPolicy,
6234
6287
  drawPermissions: Object.freeze(summary.drawPermissions.map((permission) => Object.freeze({ ...permission }))),
6235
6288
  presenting: summary.presenting,
6289
+ presenterId: summary.presenterId ?? null,
6236
6290
  error: summary.error ? Object.freeze({ ...summary.error }) : null
6237
6291
  });
6238
6292
  this.#publish();
@@ -6251,7 +6305,7 @@ var WhiteboardControllerRuntime = class {
6251
6305
  #publish() {
6252
6306
  const slice = Object.freeze({
6253
6307
  open: this.#summary.status !== "unsubscribed",
6254
- engine: Object.freeze({ status: this.#summary.status, sceneId: this.#summary.sceneId, revision: this.#summary.revision, presenting: this.#summary.presenting, error: this.#summary.error }),
6308
+ engine: Object.freeze({ status: this.#summary.status, sceneId: this.#summary.sceneId, revision: this.#summary.revision, presenting: this.#summary.presenting, presenterId: this.#summary.presenterId ?? null, error: this.#summary.error }),
6255
6309
  access: Object.freeze({ canDraw: this.#summary.canDraw, policy: this.#summary.drawPolicy, permissions: this.#summary.drawPermissions })
6256
6310
  });
6257
6311
  const current = this.#store.getSnapshot().whiteboard;
@@ -6260,7 +6314,7 @@ var WhiteboardControllerRuntime = class {
6260
6314
  }
6261
6315
  };
6262
6316
  function sameWhiteboard(left, right) {
6263
- return left.open === right.open && left.engine.status === right.engine.status && left.engine.sceneId === right.engine.sceneId && left.engine.revision === right.engine.revision && left.engine.presenting === right.engine.presenting && sameError2(left.engine.error, right.engine.error) && sameAccess(left.access, right.access);
6317
+ return left.open === right.open && left.engine.status === right.engine.status && left.engine.sceneId === right.engine.sceneId && left.engine.revision === right.engine.revision && left.engine.presenting === right.engine.presenting && left.engine.presenterId === right.engine.presenterId && sameError2(left.engine.error, right.engine.error) && sameAccess(left.access, right.access);
6264
6318
  }
6265
6319
  function sameAccess(left, right) {
6266
6320
  return left.canDraw === right.canDraw && left.policy === right.policy && left.permissions.length === right.permissions.length && left.permissions.every((permission, index) => permission.participantId === right.permissions[index]?.participantId && permission.canDraw === right.permissions[index]?.canDraw);
@@ -6355,7 +6409,9 @@ var makeSpaceClientCoreLayer = (options, platform = {}) => {
6355
6409
  diagnostics: {
6356
6410
  onEvent: (event) => {
6357
6411
  platform.onConnectionDiagnostic?.(event);
6358
- options.logger?.debug?.("SpaceClient connection event", { event: event.event, state: event.state, epoch: event.epoch, ...event.code ? { code: event.code } : {}, ...event.sync ? { sync: event.sync } : {} });
6412
+ const attributes = { event: event.event, state: event.state, epoch: event.epoch, ...event.code ? { code: event.code } : {}, ...event.sync ? { sync: event.sync } : {}, ...event.recovery ? { recovery: event.recovery } : {} };
6413
+ if (event.event === "recovery_exhausted") options.logger?.warn?.("SpaceClient connection event", attributes);
6414
+ else options.logger?.debug?.("SpaceClient connection event", attributes);
6359
6415
  }
6360
6416
  }
6361
6417
  });
@@ -120,7 +120,7 @@ var BrowserSyncSocket = class {
120
120
  this.#socket = socket;
121
121
  this.#socket.addEventListener("open", () => this.onopen?.());
122
122
  this.#socket.addEventListener("message", (event) => this.onmessage?.({ data: event.data }));
123
- this.#socket.addEventListener("close", (event) => this.onclose?.({ code: event.code, reason: event.reason }));
123
+ this.#socket.addEventListener("close", (event) => this.onclose?.({ code: event.code, reason: event.reason, ...typeof event.wasClean === "boolean" ? { wasClean: event.wasClean } : {} }));
124
124
  this.#socket.addEventListener("error", () => this.onerror?.());
125
125
  }
126
126
  send(data) {
@@ -178,7 +178,7 @@ var ReactNativeSyncSocket = class {
178
178
  this.#socket = socket;
179
179
  this.#socket.onopen = () => this.onopen?.();
180
180
  this.#socket.onmessage = (event) => this.onmessage?.({ data: event.data });
181
- this.#socket.onclose = (event) => this.onclose?.({ code: typeof event.code === "number" ? event.code : 1006, reason: typeof event.reason === "string" ? event.reason : void 0 });
181
+ this.#socket.onclose = (event) => this.onclose?.({ code: typeof event.code === "number" ? event.code : 1006, reason: typeof event.reason === "string" ? event.reason : void 0, ...typeof event.wasClean === "boolean" ? { wasClean: event.wasClean } : {} });
182
182
  this.#socket.onerror = () => this.onerror?.();
183
183
  }
184
184
  send(data) {
@@ -198,7 +198,7 @@ var BrowserFallbackSyncSocket = class {
198
198
  this.#socket = socket;
199
199
  this.#socket.addEventListener("open", () => this.onopen?.());
200
200
  this.#socket.addEventListener("message", (event) => this.onmessage?.({ data: event.data }));
201
- this.#socket.addEventListener("close", (event) => this.onclose?.({ code: event.code, reason: event.reason }));
201
+ this.#socket.addEventListener("close", (event) => this.onclose?.({ code: event.code, reason: event.reason, ...typeof event.wasClean === "boolean" ? { wasClean: event.wasClean } : {} }));
202
202
  this.#socket.addEventListener("error", () => this.onerror?.());
203
203
  }
204
204
  send(data) {
@@ -249,8 +249,11 @@ var SAFE_REASONS = /* @__PURE__ */ new Set([
249
249
  function safeSyncCloseReason(reason) {
250
250
  return reason ? SAFE_REASONS.has(reason) ? reason : "redacted" : null;
251
251
  }
252
+ function syncConnectFailureCode(diagnostic) {
253
+ return diagnostic?.stage === "upgrade" ? "sync_start_failed" : "sync_hello_timeout";
254
+ }
252
255
  function formatSyncConnectDiagnostic(diagnostic) {
253
- return `[${diagnostic.stage}; open=${diagnostic.opened ? 1 : 0}; close=${diagnostic.closeCode ?? "none"}; reason=${safeSyncCloseReason(diagnostic.closeReason ?? void 0) ?? "none"}; ${Math.round(diagnostic.durationMs)}ms]`;
256
+ return `[${diagnostic.stage}; open=${diagnostic.opened ? 1 : 0}; close=${diagnostic.closeCode ?? "none"}; reason=${safeSyncCloseReason(diagnostic.closeReason ?? void 0) ?? "none"}; ${diagnostic.stage === "upgrade" && diagnostic.closeCode !== null ? "cause=closed_before_open; " : ""}${diagnostic.attempt === void 0 ? "" : `attempt=${diagnostic.attempt}; `}${diagnostic.attempts === void 0 ? "" : `attempts=${diagnostic.attempts}; `}${Math.round(diagnostic.durationMs)}ms]`;
254
257
  }
255
258
 
256
259
  // src/sync/v1-health.ts
@@ -336,7 +339,7 @@ var SyncProtocolMetadata = {
336
339
  accepts: ["entered", "command", "live_target", "directed_request", "delivery_ack", "request_ack", "reaction_send", "chat_send", "chat_page_request", "chat_read_set", "chat_reaction_set", "ping"],
337
340
  heartbeat: {
338
341
  intervalMs: 2e4,
339
- missedDeadlinesBeforeClose: 2
342
+ missedDeadlinesBeforeClose: 5
340
343
  }
341
344
  },
342
345
  {
@@ -3024,10 +3027,14 @@ var V1SyncClient = class {
3024
3027
  #cancelDial;
3025
3028
  #dialTimeoutCount = 0;
3026
3029
  #connectStartedAt = null;
3030
+ #connectAttemptCount = 0;
3031
+ #socketCloses = [];
3032
+ #omittedSocketCloses = 0;
3033
+ #lastHeartbeatReplyAt = null;
3027
3034
  #opened = false;
3028
3035
  #connectFailure;
3029
3036
  #connectDiagnostic() {
3030
- return { stage: this.#opened ? "hello" : "upgrade", opened: this.#opened, closeCode: null, closeReason: null, durationMs: Math.max(0, this.#clock().now() - (this.#connectStartedAt ?? this.#clock().now())) };
3037
+ return { stage: this.#opened ? "hello" : "upgrade", opened: this.#opened, closeCode: null, closeReason: null, durationMs: Math.max(0, this.#clock().now() - (this.#connectStartedAt ?? this.#clock().now())), attempt: this.#connectAttemptCount };
3031
3038
  }
3032
3039
  #reconnectAttempt = 0;
3033
3040
  #episodeEndTimer;
@@ -3090,6 +3097,11 @@ var V1SyncClient = class {
3090
3097
  async start() {
3091
3098
  if (this.#started) return;
3092
3099
  this.#started = true;
3100
+ this.#connectAttemptCount = 0;
3101
+ this.#socketCloses = [];
3102
+ this.#omittedSocketCloses = 0;
3103
+ this.#lastHeartbeatReplyAt = null;
3104
+ this.#connectFailure = void 0;
3093
3105
  const startupGeneration = ++this.#startupGeneration;
3094
3106
  let stored;
3095
3107
  try {
@@ -3144,6 +3156,7 @@ var V1SyncClient = class {
3144
3156
  return {
3145
3157
  connection: {
3146
3158
  ...this.#phase,
3159
+ recoveryDiagnostic: { socketAttempts: this.#connectAttemptCount, socketCloses: this.#socketCloses.slice(), omittedSocketCloses: this.#omittedSocketCloses, lastHeartbeatReplyAt: this.#lastHeartbeatReplyAt },
3147
3160
  ...this.#connectStartedAt !== null && (this.#phase.phase === "connecting" || this.#phase.phase === "recovering") ? { connectAttempt: this.#connectDiagnostic() } : {},
3148
3161
  ...this.#connectFailure && (this.#phase.phase === "connecting" || this.#phase.phase === "recovering") ? { connectFailure: this.#connectFailure } : {},
3149
3162
  ...this.#phase.phase === "live" ? { noticeUnresponsive: this.#health.health === "recovering", health: this.#health.health } : {},
@@ -3288,9 +3301,10 @@ var V1SyncClient = class {
3288
3301
  #connect() {
3289
3302
  if (!this.#started || !this.#transportAvailable || this.#socket || this.#phase.phase === "terminal") return;
3290
3303
  this.#phase = { phase: "connecting" };
3304
+ this.#connectAttemptCount += 1;
3291
3305
  this.#connectStartedAt = this.#clock().now();
3292
3306
  this.#opened = false;
3293
- this.#connectFailure = void 0;
3307
+ if (this.#connectFailure?.opened) this.#connectFailure = void 0;
3294
3308
  this.#emit();
3295
3309
  this.#entryNegotiated = false;
3296
3310
  let socket;
@@ -3299,7 +3313,7 @@ var V1SyncClient = class {
3299
3313
  } catch {
3300
3314
  this.#connectFailure = this.#connectDiagnostic();
3301
3315
  this.#emit();
3302
- throw new V1SyncError("Sync WebSocket could not be created", "sync_hello_timeout");
3316
+ throw new V1SyncError("Sync WebSocket could not be created", "sync_start_failed");
3303
3317
  }
3304
3318
  const connectionGeneration = ++this.#connectionGeneration;
3305
3319
  this.#socket = socket;
@@ -3324,6 +3338,13 @@ var V1SyncClient = class {
3324
3338
  this.#inbound = this.#inbound.then(() => this.#receive(socket, event.data));
3325
3339
  };
3326
3340
  socket.onclose = (event) => {
3341
+ if (this.#started) {
3342
+ this.#socketCloses.push({ code: event.code, wasClean: event.wasClean ?? null });
3343
+ if (this.#socketCloses.length > 50) {
3344
+ this.#socketCloses.shift();
3345
+ this.#omittedSocketCloses += 1;
3346
+ }
3347
+ }
3327
3348
  if (socket === this.#socket && (this.#phase.phase === "connecting" || this.#phase.phase === "recovering")) {
3328
3349
  this.#connectFailure = { ...this.#connectDiagnostic(), closeCode: event.code, closeReason: safeSyncCloseReason(event.reason) };
3329
3350
  this.#emit();
@@ -3341,7 +3362,7 @@ var V1SyncClient = class {
3341
3362
  };
3342
3363
  socket.onerror = () => {
3343
3364
  if (socket === this.#socket && (this.#phase.phase === "connecting" || this.#phase.phase === "recovering")) {
3344
- this.#connectFailure = this.#connectDiagnostic();
3365
+ this.#connectFailure ??= this.#connectDiagnostic();
3345
3366
  this.#emit();
3346
3367
  }
3347
3368
  socket.close(CLIENT_RESTART_CLOSE_CODE, "transport error");
@@ -3494,6 +3515,7 @@ var V1SyncClient = class {
3494
3515
  case "pong": {
3495
3516
  this.#requireLive();
3496
3517
  this.#missedHeartbeats = 0;
3518
+ this.#lastHeartbeatReplyAt = this.#now();
3497
3519
  const previous = this.#health.health;
3498
3520
  this.#health.observePong(this.#now());
3499
3521
  if (previous !== this.#health.health) this.#emit();
@@ -4046,6 +4068,7 @@ function createV1SyncClient(options) {
4046
4068
  }
4047
4069
 
4048
4070
  export {
4071
+ syncConnectFailureCode,
4049
4072
  formatSyncConnectDiagnostic,
4050
4073
  UuidSchema,
4051
4074
  ReactionValues,
@@ -32,6 +32,13 @@ export type ConnectionDiagnostic = {
32
32
  readonly outcome?: ConnectionJoinTraceOutcome;
33
33
  readonly durationMs?: number;
34
34
  readonly sync?: import("../sync/connect-diagnostics").SyncConnectDiagnostic;
35
+ readonly recovery?: {
36
+ readonly reference: "connection.sync_recovery_exhausted" | "connection.media_recovery_exhausted";
37
+ readonly attempts: number;
38
+ readonly transport: import("../sync/connect-diagnostics").SyncRecoveryDiagnostic | null;
39
+ readonly lastHeartbeatReplyAgoMs: number | null;
40
+ readonly visibilityState: DocumentVisibilityState | null;
41
+ };
35
42
  };
36
43
  export declare class ConnectionDiagnostics {
37
44
  #private;
@@ -41,6 +48,13 @@ export declare class ConnectionDiagnostics {
41
48
  readonly onEvent?: (event: ConnectionDiagnostic) => void;
42
49
  });
43
50
  record(input: Omit<ConnectionDiagnostic, "timestamp">): void;
51
+ recordRecoveryExhausted(input: {
52
+ readonly state: ConnectionState;
53
+ readonly epoch: number;
54
+ readonly code: "sync_recovery_exhausted" | "media_recovery_exhausted";
55
+ readonly attempts: number;
56
+ readonly transport: import("../sync/connect-diagnostics").SyncRecoveryDiagnostic | null;
57
+ }): void;
44
58
  snapshot(): readonly ConnectionDiagnostic[];
45
59
  startSpan(input: {
46
60
  readonly step: ConnectionJoinTraceStep;
package/dist/effect.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  makeSpaceClientCoreLayer,
18
18
  makeSpaceClientCoreLayerFromServices,
19
19
  makeSpaceStoreLayer
20
- } from "./chunk-RVUN6LW4.js";
20
+ } from "./chunk-4ZUN55Z2.js";
21
21
  import "./chunk-DWKTGBHR.js";
22
22
  import {
23
23
  APIKeyListSchema,
@@ -829,7 +829,7 @@ import {
829
829
  WhiteboardStorageUnavailableErrorWireSchema,
830
830
  createChalkEffectClient
831
831
  } from "./chunk-GA3JDPFI.js";
832
- import "./chunk-TVNI2XUZ.js";
832
+ import "./chunk-IMMT22US.js";
833
833
 
834
834
  // src/space-client/effect.ts
835
835
  import { Context, Effect, Exit, Layer, Scope } from "effect";
@@ -37,7 +37,7 @@ export declare const SyncProtocolMetadata: {
37
37
  readonly accepts: readonly ["entered", "command", "live_target", "directed_request", "delivery_ack", "request_ack", "reaction_send", "chat_send", "chat_page_request", "chat_read_set", "chat_reaction_set", "ping"];
38
38
  readonly heartbeat: {
39
39
  readonly intervalMs: 20000;
40
- readonly missedDeadlinesBeforeClose: 2;
40
+ readonly missedDeadlinesBeforeClose: 5;
41
41
  };
42
42
  }, {
43
43
  readonly id: "terminal";
@@ -11,6 +11,7 @@ export declare const WhiteboardV1ProtocolMetadata: {
11
11
  readonly presentation: {
12
12
  readonly name: "presentation_v1";
13
13
  readonly exactFields: readonly ["name"];
14
+ readonly presenterExactFields: readonly ["name", "presenter"];
14
15
  };
15
16
  readonly view: {
16
17
  readonly name: "view_v1";
@@ -258,6 +259,7 @@ export type WhiteboardV1SharedAppState = {
258
259
  } | null;
259
260
  export type WhiteboardV1PresentationExtension = {
260
261
  readonly name: "presentation_v1";
262
+ readonly presenter?: true;
261
263
  };
262
264
  export type WhiteboardV1ViewExtension = {
263
265
  readonly name: "view_v1";
@@ -388,6 +390,7 @@ export type WhiteboardV1ServerFrame = {
388
390
  readonly revision: string;
389
391
  readonly can_draw: boolean;
390
392
  readonly presenting: boolean;
393
+ readonly presenter_id?: string | null;
391
394
  readonly view_owner_id?: string;
392
395
  } | {
393
396
  readonly type: "snapshot_page";
@@ -451,6 +454,7 @@ export type WhiteboardV1ServerFrame = {
451
454
  readonly scene_id: string;
452
455
  readonly revision: string;
453
456
  readonly presenting: boolean;
457
+ readonly presenter_id?: string | null;
454
458
  } | {
455
459
  readonly type: "reset_required";
456
460
  readonly scene_id: string;
package/dist/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export * from "./sync";
6
6
  export * from "./whiteboard";
7
7
  export * from "./public-invites";
8
8
  export { formatSyncConnectDiagnostic } from "./sync/connect-diagnostics";
9
+ export { getConnectionFailureDetail } from "./space-client/failure-detail";
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  createSpaceClient,
16
16
  decodeWhiteboardV1ServerFrame,
17
17
  encodeWhiteboardV1ClientFrame
18
- } from "./chunk-RVUN6LW4.js";
18
+ } from "./chunk-4ZUN55Z2.js";
19
19
  import {
20
20
  CloudflareSFUClient,
21
21
  CloudflareSFUError,
@@ -69,7 +69,7 @@ import {
69
69
  optimisticV1Control,
70
70
  reactionFromFrame,
71
71
  restoreV1Snapshot
72
- } from "./chunk-TVNI2XUZ.js";
72
+ } from "./chunk-IMMT22US.js";
73
73
 
74
74
  // src/space-client/reload-rejoin.ts
75
75
  import { Schema } from "effect";
@@ -115,6 +115,23 @@ function reloadRejoinAccess(access) {
115
115
  const parsed = parseParsedAccessGrant(access);
116
116
  return { tenantId: parsed.subject.tenantId, episodeId: parsed.subject.episodeId, participantId: parsed.subject.participantId, participantGeneration: parsed.subject.participantGeneration, mediaProof: parsed.media.token };
117
117
  }
118
+
119
+ // src/space-client/failure-detail.ts
120
+ var DEFAULT_FAILURE_DETAIL = "Something went wrong while joining. Try again.";
121
+ var FAILURE_DETAILS = {
122
+ "access.invalid": "Access to this call was rejected. Refresh the page and try again.",
123
+ "access.unavailable": "Couldn\u2019t get access to the call. Try again.",
124
+ "connection.sync_start_failed": "Couldn\u2019t reach the call service. Try again.",
125
+ "connection.sync_hello_timeout": "The call service did not respond in time. Try again.",
126
+ "connection.sync_recovery_exhausted": "You\u2019re disconnected from the call. Rejoin to continue.",
127
+ "connection.media_start_failed": "Audio and video could not connect. Try again.",
128
+ "connection.media_recovery_exhausted": "Audio and video could not reconnect. Try again.",
129
+ "environment.unsupported": "This device cannot start the call. Use an up-to-date browser over HTTPS, or update the app."
130
+ };
131
+ function getConnectionFailureDetail({ code, sync }) {
132
+ if (sync?.stage === "upgrade") return sync.closeCode !== null ? "The connection closed before the call service could be reached. Try again." : "A connection to the call service could not be opened. Try again.";
133
+ return (code && FAILURE_DETAILS[code]) ?? DEFAULT_FAILURE_DETAIL;
134
+ }
118
135
  export {
119
136
  ArrivalInvalidHandleError,
120
137
  ArrivalUnavailableError,
@@ -166,6 +183,7 @@ export {
166
183
  encodeV1ClientFrame,
167
184
  encodeWhiteboardV1ClientFrame,
168
185
  formatSyncConnectDiagnostic,
186
+ getConnectionFailureDetail,
169
187
  optimisticV1Control,
170
188
  parseCloudflareSFUPublicationID,
171
189
  parseInviteCode,
@@ -0,0 +1,6 @@
1
+ import type { ClientFailure, ErrorCode } from "./types";
2
+ /** Safe, actionable failure copy shared by web and native call screens. */
3
+ export declare function getConnectionFailureDetail({ code, sync }: {
4
+ readonly code?: ErrorCode;
5
+ readonly sync?: ClientFailure["sync"];
6
+ }): string;
@@ -203,6 +203,7 @@ export type WhiteboardSlice = {
203
203
  readonly sceneId: string | null;
204
204
  readonly revision: string | null;
205
205
  readonly presenting: boolean;
206
+ readonly presenterId?: string | null;
206
207
  readonly error: ChalkWhiteboardV1Failure | null;
207
208
  };
208
209
  /** Who can draw, as Sync last told this person. `canDraw` is false until the board subscription is live. */
@@ -1,3 +1,13 @@
1
+ /** Transport facts only. No URLs, close reasons, payloads, or identities. */
2
+ export type SyncRecoveryDiagnostic = {
3
+ readonly socketAttempts: number;
4
+ readonly socketCloses: readonly {
5
+ readonly code: number;
6
+ readonly wasClean: boolean | null;
7
+ }[];
8
+ readonly omittedSocketCloses: number;
9
+ readonly lastHeartbeatReplyAt: number | null;
10
+ };
1
11
  /** Browsers expose only upgrade/open and hello, never DNS/TCP/TLS progress. */
2
12
  export type SyncConnectDiagnostic = {
3
13
  readonly stage: "upgrade" | "hello";
@@ -5,6 +15,12 @@ export type SyncConnectDiagnostic = {
5
15
  readonly closeCode: number | null;
6
16
  readonly closeReason: string | null;
7
17
  readonly durationMs: number;
18
+ /** One-based socket attempt since Sync start; absent in older SDK evidence. */
19
+ readonly attempt?: number;
20
+ /** Total sockets started when the join failed, including a pending replacement. */
21
+ readonly attempts?: number;
8
22
  };
9
23
  export declare function safeSyncCloseReason(reason: string | undefined): string | null;
24
+ /** Keep published error codes stable while separating upgrade from hello failures. */
25
+ export declare function syncConnectFailureCode(diagnostic: SyncConnectDiagnostic | undefined): "sync_start_failed" | "sync_hello_timeout";
10
26
  export declare function formatSyncConnectDiagnostic(diagnostic: SyncConnectDiagnostic): string;
@@ -22,7 +22,7 @@ import {
22
22
  encodeV1ClientFrame,
23
23
  optimisticV1Control,
24
24
  restoreV1Snapshot
25
- } from "../chunk-TVNI2XUZ.js";
25
+ } from "../chunk-IMMT22US.js";
26
26
  export {
27
27
  AsyncStorageV1PendingTargetStore,
28
28
  InMemoryV1PendingTargetStore,
@@ -7,6 +7,7 @@ export type ReactNativeAsyncStorage = {
7
7
  export type ReactNativeWebSocketCloseEvent = {
8
8
  readonly code?: unknown;
9
9
  readonly reason?: unknown;
10
+ readonly wasClean?: unknown;
10
11
  };
11
12
  export type ReactNativeWebSocket = {
12
13
  onopen: ((event: unknown) => void) | null;
@@ -10,6 +10,7 @@ export type SyncSocket = {
10
10
  onclose: ((event: {
11
11
  readonly code: number;
12
12
  readonly reason?: string;
13
+ readonly wasClean?: boolean;
13
14
  }) => void) | null;
14
15
  onerror: (() => void) | null;
15
16
  send(data: string): void;
@@ -81,6 +81,7 @@ export type V1EpisodeSnapshot = {
81
81
  readonly dialTimeoutCount?: number;
82
82
  readonly connectAttempt?: import("./connect-diagnostics").SyncConnectDiagnostic;
83
83
  readonly connectFailure?: import("./connect-diagnostics").SyncConnectDiagnostic;
84
+ readonly recoveryDiagnostic?: import("./connect-diagnostics").SyncRecoveryDiagnostic;
84
85
  };
85
86
  readonly participantId: string | null;
86
87
  readonly participantGeneration: number | null;
@@ -179,6 +179,8 @@ export type ChalkWhiteboardSummary = {
179
179
  readonly drawPolicy: ChalkWhiteboardDrawPolicy | null;
180
180
  /** The host's per-person choices for people in the call. Empty unless this person is a host. */
181
181
  readonly drawPermissions: readonly ChalkWhiteboardDrawPermission[];
182
+ /** The person who opened the current presentation; absent on older servers. */
183
+ readonly presenterId?: string | null;
182
184
  readonly presenting: boolean;
183
185
  readonly error: ChalkWhiteboardV1Failure | null;
184
186
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q9labsai/chalk-client",
3
- "version": "5.2.1",
3
+ "version": "5.2.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",