@q9labsai/chalk-client 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/access/grant.d.ts +2 -0
  2. package/dist/{chunk-GOSWSXTH.js → chunk-MHJ4UPM3.js} +162 -5
  3. package/dist/chunk-N4IPKRS6.js +76 -0
  4. package/dist/{chunk-DHUHTIU7.js → chunk-TA2BJXI7.js} +243 -45
  5. package/dist/{chunk-3TGVTP6Z.js → chunk-UVWRTXNO.js} +894 -72
  6. package/dist/{chunk-O26BHHMI.js → chunk-VAJ6XNAA.js} +1 -1
  7. package/dist/{chunk-XSB2GDRW.js → chunk-XF567GPH.js} +198 -26
  8. package/dist/client.d.ts +180 -0
  9. package/dist/connection/dependencies.d.ts +5 -0
  10. package/dist/connection/production.d.ts +1 -1
  11. package/dist/effect.js +29 -4
  12. package/dist/generated/http-api.d.ts +178 -0
  13. package/dist/generated/schemas.d.ts +287 -0
  14. package/dist/generated/sync.d.ts +53 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +33 -15
  17. package/dist/media/call-stats.d.ts +27 -0
  18. package/dist/media/client.d.ts +1 -0
  19. package/dist/media/ice-servers.d.ts +3 -0
  20. package/dist/media/index.d.ts +2 -0
  21. package/dist/media/index.js +4 -1
  22. package/dist/media/plane.d.ts +1 -0
  23. package/dist/media/receive-stall.d.ts +11 -2
  24. package/dist/media/types.d.ts +19 -0
  25. package/dist/media/video-render-size.d.ts +7 -0
  26. package/dist/media/viewer-layer.d.ts +48 -0
  27. package/dist/public-invites/index.js +3 -2
  28. package/dist/server/index.cjs +18 -4
  29. package/dist/server/index.js +18 -4
  30. package/dist/session/index.d.ts +18 -0
  31. package/dist/sync/index.js +2 -1
  32. package/dist/sync/link-watch.d.ts +67 -0
  33. package/dist/sync/v1-client.d.ts +5 -0
  34. package/dist/sync/v1-command-scheduler.d.ts +4 -0
  35. package/dist/sync/v1-types.d.ts +12 -1
  36. package/package.json +2 -1
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createChalkEffectClient,
3
3
  parseAccessGrant
4
- } from "./chunk-GOSWSXTH.js";
4
+ } from "./chunk-MHJ4UPM3.js";
5
5
 
6
6
  // src/public-invites/client.ts
7
7
  import { Effect } from "effect";
@@ -1,3 +1,7 @@
1
+ import {
2
+ sessionBaggage
3
+ } from "./chunk-N4IPKRS6.js";
4
+
1
5
  // src/sync/canonical.ts
2
6
  var encoder = new TextEncoder();
3
7
  function canonicalJson(value) {
@@ -297,6 +301,73 @@ function scheduleSyncDialDeadline(clock, onTimeout, timeoutMs = SYNC_DIAL_TIMEOU
297
301
  return () => clock.clearTimeout(timer);
298
302
  }
299
303
 
304
+ // src/sync/link-watch.ts
305
+ var LONG_DROP_MS = 6e4;
306
+ var ROUND_TRIP_MS = 5e3;
307
+ var UNANSWERED_LIMIT_MS = 6e4;
308
+ var LinkWatch = class {
309
+ #clock;
310
+ #page;
311
+ #suspends = 0;
312
+ #pings = [];
313
+ #proof = null;
314
+ #suspended = () => {
315
+ this.#suspends += 1;
316
+ };
317
+ constructor(clock, page) {
318
+ this.#clock = clock;
319
+ this.#page = page;
320
+ }
321
+ /** Listens for page suspension. Idempotent, and paired with `close`. */
322
+ open() {
323
+ this.close();
324
+ this.#page?.addEventListener("freeze", this.#suspended);
325
+ this.#page?.addEventListener("resume", this.#suspended);
326
+ }
327
+ close() {
328
+ this.#page?.removeEventListener("freeze", this.#suspended);
329
+ this.#page?.removeEventListener("resume", this.#suspended);
330
+ }
331
+ /** Call when a connection goes live: the previous socket's pings will not be answered. */
332
+ restart() {
333
+ this.#pings = [];
334
+ }
335
+ /**
336
+ * Call as a ping goes out, in the same order as the pings. False means the oldest ping has gone unanswered for over
337
+ * `UNANSWERED_LIMIT_MS`: the socket is dead and this ping must not be sent on it.
338
+ */
339
+ pinged() {
340
+ const now = this.#read();
341
+ const oldest = this.#pings[0];
342
+ if (oldest !== void 0 && (now.wall - oldest.wall > UNANSWERED_LIMIT_MS || now.mono - oldest.mono > UNANSWERED_LIMIT_MS)) return false;
343
+ this.#pings.push(now);
344
+ return true;
345
+ }
346
+ /** Call as a pong is received. It answers the oldest ping in flight, and proves contact only if the round trip was short. */
347
+ ponged() {
348
+ const sent = this.#pings.shift();
349
+ if (sent === void 0) return;
350
+ const now = this.#read();
351
+ const wall = now.wall - sent.wall;
352
+ const mono = now.mono - sent.mono;
353
+ if (wall < 0 || wall > ROUND_TRIP_MS || mono < 0 || mono > ROUND_TRIP_MS || now.suspends !== sent.suspends) return;
354
+ this.#proof = sent;
355
+ }
356
+ proof() {
357
+ return this.#proof;
358
+ }
359
+ longSince(proof) {
360
+ if (proof === null) return true;
361
+ const now = this.#read();
362
+ const wall = now.wall - proof.wall;
363
+ const mono = now.mono - proof.mono;
364
+ return wall < 0 || mono < 0 || wall >= LONG_DROP_MS || mono >= LONG_DROP_MS || now.suspends !== proof.suspends;
365
+ }
366
+ #read() {
367
+ return { wall: this.#clock.wall(), mono: this.#clock.mono(), suspends: this.#suspends + (this.#page?.wasDiscarded === true ? 1 : 0) };
368
+ }
369
+ };
370
+
300
371
  // src/generated/sync.ts
301
372
  import { Schema } from "effect";
302
373
  var SyncProtocolMetadata = {
@@ -405,11 +476,20 @@ var SyncProtocolMetadata = {
405
476
  pattern: "^[a-z][a-z0-9_*\\-/]{0,255}=[\\x21-\\x2b\\x2d-\\x3c\\x3e-\\x7e]{1,256}(,[a-z][a-z0-9_*\\-/]{0,255}=[\\x21-\\x2b\\x2d-\\x3c\\x3e-\\x7e]{1,256})*$"
406
477
  }
407
478
  },
479
+ helloOnlyFields: {
480
+ baggage: {
481
+ kind: "string",
482
+ minBytes: 47,
483
+ maxBytes: 47,
484
+ format: "w3c-baggage-session-id",
485
+ pattern: "^session\\.id=[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
486
+ }
487
+ },
408
488
  acceptedOn: {
409
489
  hello: "client-to-server",
410
490
  serverFrames: "server-to-client"
411
491
  },
412
- upgradeHeaders: ["x-chalk-journey-id", "traceparent", "tracestate"],
492
+ upgradeHeaders: ["x-chalk-journey-id", "traceparent", "tracestate", "baggage"],
413
493
  rule: "propagate_from_first_observed_layer_to_every_downstream_frame"
414
494
  },
415
495
  externalIntents: [
@@ -712,7 +792,9 @@ function strictCommand(value) {
712
792
  function strictCollaborationsHelloExtension(value) {
713
793
  return exactObject(value, ["name", "chat_cursor"]) && ["collaboration_v1", "collaboration_v2"].includes(String(value.name)) && exactObject(value.chat_cursor, ["after_sequence", "retained_floor_sequence"]);
714
794
  }
715
- function strictHello(value) {
795
+ function strictHello(hello) {
796
+ const { baggage, ...value } = hello;
797
+ if (baggage !== void 0 && !(typeof baggage === "string" && boundedCorrelationString(baggage, 47, 47) && /^session\.id=[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(baggage))) return false;
716
798
  const base = exactObjectWithCorrelation(value, ["type", "protocol", "token", "streams"]);
717
799
  const extended = exactObjectWithCorrelation(value, ["type", "protocol", "token", "streams", "extensions"]) && Array.isArray(value.extensions) && value.extensions.length === 1 && strictCollaborationsHelloExtension(value.extensions[0]);
718
800
  if (!base && !extended || !exactObject(value.streams, ["control", "media", "presence", "requests"])) return false;
@@ -859,8 +941,13 @@ function strictEvent(value) {
859
941
  return false;
860
942
  }
861
943
  }
944
+ function validMediaOffReason(item) {
945
+ if (!Object.hasOwn(item, "off_reason")) return !Object.hasOwn(item, "off_revision");
946
+ if (item.off_reason === "host") return Number.isInteger(item.off_revision) && item.off_revision > 0;
947
+ return item.enabled === false && (item.off_reason === "self" || item.off_reason === "dead") && !Object.hasOwn(item, "off_revision");
948
+ }
862
949
  function strictProjectionItem(value, stream) {
863
- if (stream === "media") return exactObject(value, ["participant_id", "source", "enabled", "publication_id"]);
950
+ if (stream === "media") return isObject(value) && exactObject(value, [...["participant_id", "source", "enabled", "publication_id"], ...Object.hasOwn(value, "off_reason") ? ["off_reason"] : [], ...Object.hasOwn(value, "off_revision") ? ["off_revision"] : []]) && validMediaOffReason(value);
864
951
  if (stream === "presence")
865
952
  return isObject(value) && exactObject(value, [...["participant_id", "state", "speaking", "active_speaker"], ...Object.hasOwn(value, "identity_id") ? ["identity_id"] : [], ...Object.hasOwn(value, "device_type") ? ["device_type"] : [], ...Object.hasOwn(value, "admission_revision") ? ["admission_revision"] : []]);
866
953
  return false;
@@ -874,10 +961,12 @@ function strictWelcome(value) {
874
961
  const extension = Array.isArray(value.extensions) && value.extensions.length === 1 ? value.extensions[0] : void 0;
875
962
  const extensionValid = isObject(extension) && ["collaboration_v1", "collaboration_v2"].includes(String(extension.name)) && exactObject(extension, ["name", "capabilities", "participant_capabilities", "chat_head_sequence", "retained_floor_sequence", "read_receipts"]) && Array.isArray(extension.read_receipts);
876
963
  if (Object.hasOwn(value, "entry_confirmation") && value.entry_confirmation !== true) return false;
964
+ if (Object.hasOwn(value, "media_off_reason") && value.media_off_reason !== true) return false;
877
965
  const fields = [
878
966
  ...base,
879
967
  ...extensionValid ? ["extensions"] : [],
880
968
  ...Object.hasOwn(value, "entry_confirmation") ? ["entry_confirmation"] : [],
969
+ ...Object.hasOwn(value, "media_off_reason") ? ["media_off_reason"] : [],
881
970
  ...Object.hasOwn(value, "entry_generation") ? ["entry_generation"] : [],
882
971
  ...Object.hasOwn(value, "participant_identity_id") ? ["participant_identity_id"] : []
883
972
  ];
@@ -957,9 +1046,10 @@ var TraceparentSchema = boundedUtf8String(55, 55).check(
957
1046
  var TracestateSchema = boundedUtf8String(512, 1).check(
958
1047
  Schema.makeFilter((value) => new RegExp("^[a-z][a-z0-9_*\\-/]{0,255}=[\\x21-\\x2b\\x2d-\\x3c\\x3e-\\x7e]{1,256}(,[a-z][a-z0-9_*\\-/]{0,255}=[\\x21-\\x2b\\x2d-\\x3c\\x3e-\\x7e]{1,256})*$", "u").test(value) ? void 0 : { path: [], issue: "must be a valid bounded W3C tracestate" })
959
1048
  );
1049
+ var BaggageSchema = boundedUtf8String(47, 47).check(Schema.makeFilter((value) => /^session\.id=[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(value) ? void 0 : { path: [], issue: "must be the single W3C baggage member session.id=<lowercase UUID>" }));
960
1050
  var SyncCorrelationFieldsSchema = Schema.Struct({ journey_id: Schema.optional(JourneyIdSchema), traceparent: Schema.optional(TraceparentSchema), tracestate: Schema.optional(TracestateSchema) });
961
- var HelloCorrelationFields = { journey_id: Schema.optional(JourneyIdSchema), traceparent: Schema.optional(TraceparentSchema), tracestate: Schema.optional(TracestateSchema) };
962
- var ServerCorrelationFields = { ...HelloCorrelationFields };
1051
+ var ServerCorrelationFields = { journey_id: Schema.optional(JourneyIdSchema), traceparent: Schema.optional(TraceparentSchema), tracestate: Schema.optional(TracestateSchema) };
1052
+ var HelloCorrelationFields = { ...ServerCorrelationFields, baggage: Schema.optional(BaggageSchema) };
963
1053
  var StateDigestSchema = Schema.String.check(Schema.makeFilter((value) => /^[0-9a-f]{64}$/u.test(value) ? void 0 : { path: [], issue: "must be a lowercase hexadecimal SHA-256 digest" }));
964
1054
  var UnsignedDecimalSchema = Schema.String.check(Schema.makeFilter((value) => /^(?:0|[1-9][0-9]*)$/u.test(value) ? void 0 : { path: [], issue: "must be an unsigned decimal integer" }));
965
1055
  var CommandIdSchema = boundedUtf8String(64).check(Schema.makeFilter((value) => textEncoder.encode(value).byteLength >= 16 ? void 0 : { path: [], issue: "must meet the command ID minimum byte length" }));
@@ -1490,6 +1580,7 @@ var WelcomeBase = {
1490
1580
  recovery_id: UuidSchema,
1491
1581
  head: ControlCursorSchema,
1492
1582
  entry_confirmation: Schema.optionalKey(Schema.Literal(true)),
1583
+ media_off_reason: Schema.optionalKey(Schema.Literal(true)),
1493
1584
  entry_generation: Schema.optionalKey(PositiveIntegerSchema.check(Schema.isLessThanOrEqualTo(9007199254740991)))
1494
1585
  };
1495
1586
  var ExtendedWelcomeBase = { ...WelcomeBase, extensions: Schema.Tuple([CollaborationWelcomeExtensionSchema]) };
@@ -1532,9 +1623,14 @@ var CommittedAckFrameSchema = Schema.Struct({ type: Schema.Literal("ack"), comma
1532
1623
  var SatisfiedAckFrameSchema = Schema.Struct({ type: Schema.Literal("ack"), command_id: CommandIdSchema, delivery: AckDeliverySchema, outcome: Schema.Literal("satisfied"), revision: NonNegativeIntegerSchema, state_digest: StateDigestSchema, ...ServerCorrelationFields });
1533
1624
  var RejectedAckFrameSchema = Schema.Struct({ type: Schema.Literal("ack"), command_id: CommandIdSchema, delivery: AckDeliverySchema, outcome: Schema.Literal("rejected"), reason: RejectionReasonSchema, ...ServerCorrelationFields });
1534
1625
  var CommandIdConflictAckFrameSchema = Schema.Struct({ type: Schema.Literal("ack"), command_id: CommandIdSchema, delivery: AckDeliverySchema, outcome: Schema.Literal("command_id_conflict"), reason: Schema.Literal("command_id_conflict"), ...ServerCorrelationFields });
1535
- var MediaProjectionItemSchema = Schema.Struct({ participant_id: UuidSchema, source: Schema.Union([Schema.Literal("microphone"), Schema.Literal("camera"), Schema.Literal("screen")]), enabled: Schema.Boolean, publication_id: Schema.NullOr(boundedUtf8String(256, 1)) }).check(
1536
- Schema.makeFilter((item) => !item.enabled || item.publication_id !== null ? void 0 : { path: [], issue: "enabled requires a publication_id" })
1537
- );
1626
+ var MediaProjectionItemSchema = Schema.Struct({
1627
+ participant_id: UuidSchema,
1628
+ source: Schema.Union([Schema.Literal("microphone"), Schema.Literal("camera"), Schema.Literal("screen")]),
1629
+ enabled: Schema.Boolean,
1630
+ publication_id: Schema.NullOr(boundedUtf8String(256, 1)),
1631
+ off_reason: Schema.optional(Schema.Literals(["host", "self", "dead"])),
1632
+ off_revision: Schema.optional(PositiveIntegerSchema)
1633
+ }).check(Schema.makeFilter((item) => !item.enabled || item.publication_id !== null ? void 0 : { path: [], issue: "enabled requires a publication_id" }));
1538
1634
  var PresenceProjectionItemSchema = Schema.Struct({
1539
1635
  participant_id: UuidSchema,
1540
1636
  state: Schema.Union([Schema.Literal("connected"), Schema.Literal("disconnected")]),
@@ -2528,6 +2624,11 @@ var V1CommandScheduler = class {
2528
2624
  async acknowledge(frame) {
2529
2625
  const deferred = this.#commands.get(frame.command_id);
2530
2626
  if (!deferred) return;
2627
+ if (isSatisfiedByInactiveParticipant(frame, deferred.frame)) {
2628
+ const head = this.#options.controlHead();
2629
+ await this.#finishCommand(frame.command_id, { type: "ack", command_id: frame.command_id, delivery: "duplicate", outcome: "satisfied", revision: head.revision, state_digest: head.state_digest });
2630
+ return;
2631
+ }
2531
2632
  if (frame.outcome === "rejected" || frame.outcome === "command_id_conflict") {
2532
2633
  await this.#finishCommand(frame.command_id, frame);
2533
2634
  return;
@@ -2566,15 +2667,12 @@ var V1CommandScheduler = class {
2566
2667
  }
2567
2668
  }
2568
2669
  async settleTerminalRecovery(frame) {
2569
- const satisfiedOperations = /* @__PURE__ */ new Set();
2570
- if (frame.reason === "episode_ended") {
2571
- satisfiedOperations.add("end_episode");
2572
- satisfiedOperations.add("participant_leave");
2573
- } else if (frame.reason === "participant_inactive") {
2574
- satisfiedOperations.add("participant_leave");
2575
- }
2670
+ const satisfies = (operation) => {
2671
+ if (frame.reason === "episode_ended") return operation === "end_episode" || operation === "participant_leave";
2672
+ return frame.reason === "participant_inactive" && inactiveParticipantSatisfies(operation);
2673
+ };
2576
2674
  for (const [commandId, deferred] of this.#commands) {
2577
- if (deferred.frame.type !== "operation" || !satisfiedOperations.has(deferred.frame.name)) continue;
2675
+ if (deferred.frame.type !== "operation" || !satisfies(deferred.frame.name)) continue;
2578
2676
  await this.#finishCommand(commandId, {
2579
2677
  type: "ack",
2580
2678
  command_id: commandId,
@@ -2744,6 +2842,12 @@ function targetFrame(commandId, command) {
2744
2842
  return { type: "command", command_id: commandId, name: command.name, payload: command.payload };
2745
2843
  }
2746
2844
  }
2845
+ function inactiveParticipantSatisfies(operation) {
2846
+ return operation === "participant_leave";
2847
+ }
2848
+ function isSatisfiedByInactiveParticipant(result, command) {
2849
+ return result.outcome === "rejected" && result.reason === "participant_inactive" && command.type === "operation" && inactiveParticipantSatisfies(command.name);
2850
+ }
2747
2851
  function isTerminalLifecycleOperation(frame) {
2748
2852
  return frame.type === "operation" && (frame.name === "participant_leave" || frame.name === "end_episode");
2749
2853
  }
@@ -3002,6 +3106,7 @@ var MAX_REPLAY_EVENTS = SyncProtocolLimits.completeReplayMaxEvents;
3002
3106
  var MAX_REPLAY_BYTES = SyncProtocolLimits.completeReplayEncodedBytes;
3003
3107
  var MAX_PROJECTION_EVENT_EVIDENCE = 256;
3004
3108
  var CLIENT_RESTART_CLOSE_CODE = 4e3;
3109
+ var legacyHelloServers = /* @__PURE__ */ new Set();
3005
3110
  var DEFAULT_RECONNECT_DELAY_MS = 250;
3006
3111
  var MAX_RECONNECT_DELAY_MS = 5e3;
3007
3112
  var FAST_RECONNECT_ATTEMPTS = 8;
@@ -3046,12 +3151,19 @@ var V1SyncClient = class {
3046
3151
  #socketCloses = [];
3047
3152
  #omittedSocketCloses = 0;
3048
3153
  #lastHeartbeatReplyAt = null;
3154
+ /** Whether this connection's welcome advertised `media_off_reason`; reset for each connection. */
3155
+ #offReasonNegotiated = false;
3156
+ #watch;
3157
+ /** Tells a recovery whether the drop was proven short. */
3158
+ link;
3049
3159
  #opened = false;
3050
3160
  #connectFailure;
3051
3161
  #connectDiagnostic() {
3052
3162
  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 };
3053
3163
  }
3054
3164
  #reconnectAttempt = 0;
3165
+ /** The socket whose hello carried baggage and has not been answered by a welcome yet. */
3166
+ #baggageHelloSocket = null;
3055
3167
  #episodeEndTimer;
3056
3168
  #heartbeatTimer;
3057
3169
  #missedHeartbeats = 0;
@@ -3070,6 +3182,8 @@ var V1SyncClient = class {
3070
3182
  constructor(options) {
3071
3183
  assertV1Url(options.url);
3072
3184
  this.#options = options;
3185
+ this.#watch = new LinkWatch({ wall: () => this.#now(), mono: options.monotonic ?? (() => performance.now()) }, options.page === void 0 ? typeof document === "object" ? document : null : options.page);
3186
+ this.link = this.#watch;
3073
3187
  this.#commandScheduler = new V1CommandScheduler({
3074
3188
  store: options.pendingStore ?? new InMemoryV1PendingTargetStore(),
3075
3189
  ids: options.ids,
@@ -3082,7 +3196,11 @@ var V1SyncClient = class {
3082
3196
  isStarted: () => this.#started,
3083
3197
  sendIfLive: (frame) => this.#sendIfLive(frame),
3084
3198
  stateChanged: () => this.#emit(),
3085
- ackHeadIsProven: (ack) => this.#ackHeadIsProven(ack)
3199
+ ackHeadIsProven: (ack) => this.#ackHeadIsProven(ack),
3200
+ controlHead: () => {
3201
+ const control = this.#requireControl();
3202
+ return { revision: control.revision, state_digest: control.stateDigest };
3203
+ }
3086
3204
  });
3087
3205
  this.#liveTargets = new V1LiveTargetCoordinator({
3088
3206
  mediaPlane: options.mediaPlane,
@@ -3112,6 +3230,7 @@ var V1SyncClient = class {
3112
3230
  async start() {
3113
3231
  if (this.#started) return;
3114
3232
  this.#started = true;
3233
+ this.#watch.open();
3115
3234
  this.#connectAttemptCount = 0;
3116
3235
  this.#socketCloses = [];
3117
3236
  this.#omittedSocketCloses = 0;
@@ -3124,6 +3243,7 @@ var V1SyncClient = class {
3124
3243
  } catch (error) {
3125
3244
  if (startupGeneration === this.#startupGeneration) {
3126
3245
  this.#started = false;
3246
+ this.#watch.close();
3127
3247
  }
3128
3248
  throw error;
3129
3249
  }
@@ -3135,6 +3255,7 @@ var V1SyncClient = class {
3135
3255
  }
3136
3256
  stop() {
3137
3257
  this.#started = false;
3258
+ this.#watch.close();
3138
3259
  this.#entryPending = false;
3139
3260
  this.#entryGeneration = null;
3140
3261
  this.#startupGeneration += 1;
@@ -3166,6 +3287,10 @@ var V1SyncClient = class {
3166
3287
  if (!this.#entryNegotiated) return;
3167
3288
  this.#send({ type: "entered" });
3168
3289
  }
3290
+ /** True when the connected Sync advertised `media_off_reason` in its welcome, so media items may carry `off_reason`. */
3291
+ offReasonNegotiated() {
3292
+ return this.#offReasonNegotiated;
3293
+ }
3169
3294
  getSnapshot() {
3170
3295
  const pendingCommands = this.#commandScheduler.pendingCommands;
3171
3296
  const optimisticControl = this.#control && this.#participantId ? optimisticV1Control(this.#control, this.#participantId, pendingCommands) : this.#control;
@@ -3326,6 +3451,7 @@ var V1SyncClient = class {
3326
3451
  if (this.#connectFailure?.opened) this.#connectFailure = void 0;
3327
3452
  this.#emit();
3328
3453
  this.#entryNegotiated = false;
3454
+ this.#offReasonNegotiated = false;
3329
3455
  let socket;
3330
3456
  try {
3331
3457
  socket = this.#options.webSocket.connect(entryUrl(this.#options.url, this.#entryGeneration, this.#options.entryConfirmation === true));
@@ -3373,11 +3499,12 @@ var V1SyncClient = class {
3373
3499
  this.#clock().setTimeout(() => {
3374
3500
  if (connectionGeneration !== this.#connectionGeneration) return;
3375
3501
  this.#inbound = this.#inbound.then(() => {
3502
+ const legacyHello = event.code === 1009 && this.#legacyHelloRejected(socket);
3376
3503
  if (socket === this.#socket && event.code === 1008 && (event.reason === "" || event.reason === "invalid token" || event.reason === "policy violation" || event.reason === "entry timeout")) {
3377
3504
  this.#phase = { phase: "terminal", terminalReason: event.reason === "entry timeout" ? "entry_timeout" : "access_rejected" };
3378
3505
  this.#emit();
3379
3506
  }
3380
- this.#disconnected(socket);
3507
+ this.#disconnected(socket, legacyHello);
3381
3508
  });
3382
3509
  }, 0);
3383
3510
  };
@@ -3394,10 +3521,12 @@ var V1SyncClient = class {
3394
3521
  const token = await this.#options.token();
3395
3522
  if (socket !== this.#socket) return;
3396
3523
  this.#phase = { phase: "recovering" };
3524
+ const baggage = legacyHelloServers.has(this.#options.url) ? void 0 : sessionBaggage();
3397
3525
  const hello = {
3398
3526
  type: "hello",
3399
3527
  protocol: 1,
3400
3528
  ...this.#options.traceparent ? { traceparent: this.#options.traceparent } : {},
3529
+ ...baggage ? { baggage } : {},
3401
3530
  token,
3402
3531
  streams: {
3403
3532
  control: { cursor: this.#control && { revision: this.#control.revision, state_schema_version: this.#control.stateSchemaVersion, state_digest: this.#control.stateDigest } },
@@ -3407,6 +3536,7 @@ var V1SyncClient = class {
3407
3536
  }
3408
3537
  };
3409
3538
  const collaborationExtension = this.#collaborationState.helloExtension;
3539
+ this.#baggageHelloSocket = baggage ? socket : null;
3410
3540
  if (!collaborationExtension) {
3411
3541
  this.#send(hello);
3412
3542
  } else {
@@ -3426,7 +3556,7 @@ var V1SyncClient = class {
3426
3556
  } catch (error) {
3427
3557
  if (this.#snapshotRecoveryRequested) this.#failDigestRecovery();
3428
3558
  else if (error instanceof V1StateDigestMismatchError) this.#recoverDigestMismatch();
3429
- else this.#recover("invalid_frame");
3559
+ else this.#recover("invalid_frame", error instanceof V1ReplicaError && error.message === "invalid_frame" && this.#legacyHelloRejected(socket));
3430
3560
  }
3431
3561
  }
3432
3562
  async #handleFrame(frame) {
@@ -3537,6 +3667,7 @@ var V1SyncClient = class {
3537
3667
  return;
3538
3668
  case "pong": {
3539
3669
  this.#requireLive();
3670
+ this.#watch.ponged();
3540
3671
  this.#missedHeartbeats = 0;
3541
3672
  this.#lastHeartbeatReplyAt = this.#now();
3542
3673
  const previous = this.#health.health;
@@ -3548,8 +3679,10 @@ var V1SyncClient = class {
3548
3679
  }
3549
3680
  async #welcome(frame) {
3550
3681
  if (this.#phase.phase !== "recovering" || this.#recovery) throw new V1ReplicaError("unexpected welcome");
3682
+ this.#baggageHelloSocket = null;
3551
3683
  this.#collaborationState.acceptWelcome(frame);
3552
3684
  this.#entryNegotiated = frame.entry_confirmation === true;
3685
+ this.#offReasonNegotiated = frame.media_off_reason === true;
3553
3686
  this.#entryGeneration = frame.entry_generation ?? this.#entryGeneration;
3554
3687
  this.#participantId = frame.participant_id;
3555
3688
  this.#participantGeneration = frame.participant_generation;
@@ -3631,6 +3764,9 @@ var V1SyncClient = class {
3631
3764
  this.#media = updateProjection(this.#media, frame.projection_id, frame.sequence, item, mediaKey);
3632
3765
  rememberBoundedEvidence(this.#mediaEventEvidence, frame.sequence, frameSignature(frame), MAX_PROJECTION_EVENT_EVIDENCE);
3633
3766
  this.#notifyRemotePublicationResumed(previous, item);
3767
+ if (!this.#mediaEnded() && item.participantId === this.#participantId && previous?.publicationId && previous.publicationId !== item.publicationId) {
3768
+ this.#options.mediaPlane?.localPublicationWithdrawn?.(previous.publicationId);
3769
+ }
3634
3770
  if (!this.#mediaEnded() && item.participantId !== this.#participantId && (previous?.enabled !== item.enabled || previous?.publicationId !== item.publicationId)) {
3635
3771
  this.#options.mediaPlane?.remotePublicationsChanged?.(this.#media?.items ?? []);
3636
3772
  }
@@ -3704,7 +3840,7 @@ var V1SyncClient = class {
3704
3840
  if (evidence.get(frame.sequence) !== frameSignature(frame)) throw new V1ReplicaError("conflicting or unprovable duplicate projection event");
3705
3841
  return true;
3706
3842
  }
3707
- #disconnected(socket) {
3843
+ #disconnected(socket, retryNow = false) {
3708
3844
  if (socket !== this.#socket) return;
3709
3845
  this.#socket = null;
3710
3846
  this.#clearDial();
@@ -3723,6 +3859,13 @@ var V1SyncClient = class {
3723
3859
  this.#emit();
3724
3860
  if (!this.#transportAvailable) return;
3725
3861
  this.#clearReconnect();
3862
+ if (retryNow) {
3863
+ this.#reconnectTimer = this.#clock().setTimeout(() => {
3864
+ this.#reconnectTimer = void 0;
3865
+ this.#connect();
3866
+ }, 0);
3867
+ return;
3868
+ }
3726
3869
  const configuredDelay = this.#options.reconnectDelayMs;
3727
3870
  const exponentialAttempt = configuredDelay === void 0 ? Math.max(0, this.#reconnectAttempt - FAST_RECONNECT_ATTEMPTS + 1) : this.#reconnectAttempt;
3728
3871
  const baseDelay = configuredDelay === 0 ? 0 : Math.min(MAX_RECONNECT_DELAY_MS, (configuredDelay ?? DEFAULT_RECONNECT_DELAY_MS) * 2 ** Math.min(exponentialAttempt, 5));
@@ -3733,11 +3876,21 @@ var V1SyncClient = class {
3733
3876
  this.#connect();
3734
3877
  }, delay);
3735
3878
  }
3736
- #recover(reason) {
3879
+ #recover(reason, retryNow = false) {
3737
3880
  const socket = this.#socket;
3738
3881
  if (!socket) return;
3739
3882
  socket.close(CLIENT_RESTART_CLOSE_CODE, reason);
3740
- this.#disconnected(socket);
3883
+ this.#disconnected(socket, retryNow);
3884
+ }
3885
+ /**
3886
+ * A Sync that predates the session ID rejects any hello with an unknown `baggage` field before welcome. The first
3887
+ * rejection marks that server as legacy, so the retry and every later reconnect send the hello without it.
3888
+ */
3889
+ #legacyHelloRejected(socket) {
3890
+ if (this.#baggageHelloSocket !== socket) return false;
3891
+ this.#baggageHelloSocket = null;
3892
+ legacyHelloServers.add(this.#options.url);
3893
+ return true;
3741
3894
  }
3742
3895
  #recoverDigestMismatch() {
3743
3896
  const now = this.#clock().now();
@@ -3872,7 +4025,7 @@ var V1SyncClient = class {
3872
4025
  this.#socket?.close(CLIENT_RESTART_CLOSE_CODE, "heartbeat timeout");
3873
4026
  return;
3874
4027
  }
3875
- this.#send({ type: "ping" });
4028
+ if (!this.#sendPing()) return;
3876
4029
  this.#startHeartbeat();
3877
4030
  }, 2e4);
3878
4031
  }
@@ -3883,7 +4036,8 @@ var V1SyncClient = class {
3883
4036
  }
3884
4037
  #startNoticeProbe() {
3885
4038
  this.#clearNoticeProbe();
3886
- this.#send({ type: "ping" });
4039
+ this.#watch.restart();
4040
+ this.#sendPing();
3887
4041
  this.#health = new V1SyncHealth(this.#now());
3888
4042
  const tick = () => {
3889
4043
  this.#noticeProbeTimer = void 0;
@@ -3891,7 +4045,7 @@ var V1SyncClient = class {
3891
4045
  const previous = this.#health.health;
3892
4046
  this.#health.check(this.#now());
3893
4047
  if (previous !== this.#health.health) this.#emit();
3894
- this.#send({ type: "ping" });
4048
+ if (!this.#sendPing()) return;
3895
4049
  this.#noticeProbeTimer = this.#clock().setTimeout(tick, SYNC_HEALTH_BUDGET.probeIntervalMs);
3896
4050
  };
3897
4051
  this.#noticeProbeTimer = this.#clock().setTimeout(tick, SYNC_HEALTH_BUDGET.probeIntervalMs);
@@ -3901,6 +4055,16 @@ var V1SyncClient = class {
3901
4055
  this.#emit();
3902
4056
  }, SYNC_HEALTH_BUDGET.degradedAfterMs);
3903
4057
  }
4058
+ // The link watch times each ping, so every ping goes through here. When the oldest ping has gone unanswered for over 60 s, the
4059
+ // old heartbeat timeout, the socket is dead: it is replaced through the normal reconnect and this ping is not sent.
4060
+ #sendPing() {
4061
+ if (!this.#watch.pinged()) {
4062
+ this.#recover("ping unanswered for 60 s");
4063
+ return false;
4064
+ }
4065
+ this.#send({ type: "ping" });
4066
+ return true;
4067
+ }
3904
4068
  #clearNoticeProbe() {
3905
4069
  if (this.#noticeTimer !== void 0) this.#clock().clearTimeout(this.#noticeTimer);
3906
4070
  if (this.#noticeProbeTimer !== void 0) this.#clock().clearTimeout(this.#noticeProbeTimer);
@@ -3919,7 +4083,14 @@ function updateProjection(projection, projectionId, sequence, item, key) {
3919
4083
  return { projectionId, sequence, items: [...projection.items.filter((candidate) => key(candidate) !== itemKey), item] };
3920
4084
  }
3921
4085
  function mediaItem(item) {
3922
- return { participantId: item.participant_id, source: item.source, enabled: item.enabled, publicationId: item.publication_id };
4086
+ return {
4087
+ participantId: item.participant_id,
4088
+ source: item.source,
4089
+ enabled: item.enabled,
4090
+ publicationId: item.publication_id,
4091
+ ...item.off_reason === void 0 ? {} : { offReason: item.off_reason },
4092
+ ...item.off_revision === void 0 ? {} : { offRevision: item.off_revision }
4093
+ };
3923
4094
  }
3924
4095
  function presenceItem(item) {
3925
4096
  return { participantId: item.participant_id, admissionRevision: item.admission_revision, identityId: item.identity_id, deviceType: item.device_type, state: item.state, speaking: item.speaking, activeSpeaker: item.active_speaker };
@@ -3952,6 +4123,7 @@ function entryUrl(value, generation, confirmation) {
3952
4123
  const url = new URL(value);
3953
4124
  url.searchParams.set("participant_devices", "1");
3954
4125
  url.searchParams.set("participant_device_admissions", "2");
4126
+ url.searchParams.set("media_off_reason", "1");
3955
4127
  url.searchParams.set("entry_generation", generation === null ? "new" : String(generation));
3956
4128
  if (confirmation) url.searchParams.set("entry_confirmation", "1");
3957
4129
  return url.toString();