@palbase/web 7.3.9 → 7.4.1

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 (47) hide show
  1. package/dist/{analytics-facade-BER0EyYT.d.ts → analytics-facade-But6gu2r.d.ts} +135 -16
  2. package/dist/{analytics-facade-CQXCQSoF.d.cts → analytics-facade-f8IVujJW.d.cts} +135 -16
  3. package/dist/{chunk-EAOROUSW.js → chunk-4WMJTUDN.js} +26 -17
  4. package/dist/chunk-4WMJTUDN.js.map +1 -0
  5. package/dist/{chunk-Q5DUDL77.js → chunk-G56VGUYZ.js} +1 -1
  6. package/dist/chunk-G56VGUYZ.js.map +1 -0
  7. package/dist/{chunk-FCIMEU2E.js → chunk-VHUCJBBQ.js} +2 -2
  8. package/dist/chunk-VHUCJBBQ.js.map +1 -0
  9. package/dist/{chunk-H6YSCGQC.js → chunk-XJBMBUHU.js} +502 -38
  10. package/dist/chunk-XJBMBUHU.js.map +1 -0
  11. package/dist/gen/cli.cjs +2 -2
  12. package/dist/gen/cli.cjs.map +1 -1
  13. package/dist/gen/cli.js +3 -3
  14. package/dist/gen/cli.js.map +1 -1
  15. package/dist/index.cjs +514 -50
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +3 -3
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +3 -3
  20. package/dist/internal.cjs +524 -51
  21. package/dist/internal.cjs.map +1 -1
  22. package/dist/internal.d.cts +24 -4
  23. package/dist/internal.d.ts +24 -4
  24. package/dist/internal.js +3 -3
  25. package/dist/next/client.cjs +518 -48
  26. package/dist/next/client.cjs.map +1 -1
  27. package/dist/next/client.js +3 -3
  28. package/dist/next/index.cjs +524 -51
  29. package/dist/next/index.cjs.map +1 -1
  30. package/dist/next/index.d.cts +2 -2
  31. package/dist/next/index.d.ts +2 -2
  32. package/dist/next/index.js +4 -4
  33. package/dist/next/index.js.map +1 -1
  34. package/dist/next/proxy.cjs.map +1 -1
  35. package/dist/next/proxy.js +3 -3
  36. package/dist/{pb-WAQvwCfM.d.cts → pb-9MMDNQ9p.d.cts} +6 -1
  37. package/dist/{pb-BRlmBIAm.d.ts → pb-DRmdWQzG.d.ts} +6 -1
  38. package/dist/react/index.cjs +22 -19
  39. package/dist/react/index.cjs.map +1 -1
  40. package/dist/react/index.d.cts +1 -1
  41. package/dist/react/index.d.ts +1 -1
  42. package/dist/react/index.js +3 -3
  43. package/package.json +1 -1
  44. package/dist/chunk-EAOROUSW.js.map +0 -1
  45. package/dist/chunk-FCIMEU2E.js.map +0 -1
  46. package/dist/chunk-H6YSCGQC.js.map +0 -1
  47. package/dist/chunk-Q5DUDL77.js.map +0 -1
@@ -188,6 +188,15 @@ function parseEnvironmentRef(apiKey) {
188
188
  var MAX_RETRIES = 3;
189
189
  var INITIAL_BACKOFF_MS = 200;
190
190
  var MAX_RETRY_DELAY_MS = 1e4;
191
+ function withRetryHint(body, response) {
192
+ if (response.status !== 429) return body;
193
+ const data = body?.data;
194
+ const alreadyStated = typeof body?.retry_after === "number" || typeof data === "object" && data !== null && "retryAfter" in data;
195
+ if (alreadyStated) return body;
196
+ const seconds = Number.parseInt(response.headers.get("Retry-After") ?? "", 10);
197
+ if (Number.isNaN(seconds) || seconds <= 0) return body;
198
+ return { ...body, retry_after: seconds };
199
+ }
191
200
  var HttpClient = class _HttpClient {
192
201
  apiKey;
193
202
  options;
@@ -363,7 +372,7 @@ var HttpClient = class _HttpClient {
363
372
  errorBody?.error ?? "unknown_error",
364
373
  errorBody?.error_description ?? response.statusText,
365
374
  response.status,
366
- errorBody
375
+ withRetryHint(errorBody, response)
367
376
  ),
368
377
  status: response.status
369
378
  };
@@ -486,6 +495,15 @@ function isFieldErrorArray(value) {
486
495
  (v) => typeof v === "object" && v !== null && typeof v.field === "string" && typeof v.message === "string"
487
496
  );
488
497
  }
498
+ function fieldErrorsOf(envelope) {
499
+ const scoped = pickField(pickField(envelope, "data"), "fields");
500
+ if (isFieldErrorArray(scoped)) return scoped;
501
+ const flat = pickField(envelope, "fields");
502
+ return isFieldErrorArray(flat) ? flat : void 0;
503
+ }
504
+ function retryAfterOf(envelope) {
505
+ return pickNumber(envelope, "retry_after") ?? pickNumber(pickField(envelope, "data"), "retryAfter");
506
+ }
489
507
  function pickField(value, key) {
490
508
  if (typeof value === "object" && value !== null) {
491
509
  return value[key];
@@ -511,20 +529,15 @@ function fromPalbaseError(err) {
511
529
  if (err.code === "network_error") return new BackendError("network", base);
512
530
  if (err.status === 401) return new BackendError("unauthorized", base);
513
531
  if (err.status === 429)
514
- return new BackendError("rateLimited", {
515
- ...base,
516
- retryAfter: pickNumber(err.details, "retry_after")
517
- });
518
- const nested = pickField(err.details, "details");
519
- if (err.status === 400 && isFieldErrorArray(nested))
520
- return new BackendError("validation", { ...base, fields: nested });
532
+ return new BackendError("rateLimited", { ...base, retryAfter: retryAfterOf(err.details) });
533
+ const fields = fieldErrorsOf(err.details);
534
+ if (err.status === 400 && fields) return new BackendError("validation", { ...base, fields });
521
535
  return new BackendError("server", base);
522
536
  }
523
537
  function fromEnvelope(status, body) {
524
538
  const code = pickString(body, "error") ?? "http_error";
525
539
  const message = pickString(body, "error_description") ?? `HTTP ${status}`;
526
540
  const requestId = pickString(body, "request_id");
527
- const details = pickField(body, "details");
528
541
  const params = {
529
542
  code,
530
543
  message,
@@ -534,13 +547,9 @@ function fromEnvelope(status, body) {
534
547
  };
535
548
  if (status === 401) return new BackendError("unauthorized", params);
536
549
  if (status === 429)
537
- return new BackendError("rateLimited", {
538
- ...params,
539
- // Real 429 wire body has TOP-LEVEL retry_after; nested details is a fallback.
540
- retryAfter: pickNumber(body, "retry_after") ?? pickNumber(details, "retry_after")
541
- });
542
- if (status === 400 && isFieldErrorArray(details))
543
- return new BackendError("validation", { ...params, fields: details });
550
+ return new BackendError("rateLimited", { ...params, retryAfter: retryAfterOf(body) });
551
+ const fields = fieldErrorsOf(body);
552
+ if (status === 400 && fields) return new BackendError("validation", { ...params, fields });
544
553
  return new BackendError("server", params);
545
554
  }
546
555
  function isBackendError(e) {
@@ -1666,24 +1675,29 @@ var PalbeAuth = class {
1666
1675
  * Self-service account erasure ("right to be forgotten"). Calls palauth
1667
1676
  * `DELETE /auth/user` (session-authed + fresh re-auth): password users pass
1668
1677
  * `{ password }`; passwordless/OAuth users rely on a freshly stepped-up
1669
- * session. On 202 the erasure workflow is durably queued AND every session is
1670
- * already revoked server-side — so we tear down local state (mirroring
1671
- * `signOut()`'s clear, minus the pointless `/logout`) and emit
1672
- * `signedOut{reason:'accountDeleted'}`.
1678
+ * session. The handler answers **204 with no body** the erasure has already
1679
+ * finished and every session is already revoked server-side — so we tear down
1680
+ * local state (mirroring `signOut()`'s clear, minus the pointless `/logout`)
1681
+ * and emit `signedOut{reason:'accountDeleted'}`.
1682
+ *
1683
+ * Returns nothing, because there is nothing to return: the handler used to
1684
+ * answer 202 with an `{erasure_id}` naming an asynchronous run, and that id
1685
+ * was removed along with the run (`gdpr_handlers.go:221-229`). Reading it
1686
+ * here threw a `TypeError` AFTER the account was already gone — a success
1687
+ * reported as a failure.
1673
1688
  *
1674
1689
  * On 401 `reauth_required` / 503 `not_configured` (or `erasure_unavailable`)
1675
1690
  * the request throws BEFORE the teardown line, so the local session is left
1676
1691
  * fully intact — the deletion did not happen and the user is still signed in.
1677
1692
  */
1678
1693
  async deleteAccount(params = {}) {
1679
- const res = await palbeRequest(this.rt, "DELETE", "/auth/user", {
1694
+ await palbeRequest(this.rt, "DELETE", "/auth/user", {
1680
1695
  body: params.password ? { password: params.password } : {}
1681
1696
  });
1682
1697
  this.nextSignOutReason = "accountDeleted";
1683
1698
  this.rt.tokenManager.clearSession();
1684
1699
  this.rt.storage.clear();
1685
1700
  this.cachedUser = null;
1686
- return { erasureId: res.erasure_id };
1687
1701
  }
1688
1702
  /**
1689
1703
  * @internal — invoked by the transport choke point (`request.ts`), not app code.
@@ -2170,9 +2184,15 @@ var PalbeCalls = class {
2170
2184
  * @param media - Which media tracks to publish (default: audio + video)
2171
2185
  * @returns A `Call` in `connecting` state; transitions to `active` once the media room connects.
2172
2186
  *
2173
- * @throws BackendError('network', { code: 'call_service_unavailable' }) if the SFU is down (503)
2174
- * @throws BackendError('forbidden', { code: 'not_group_member' }) if the caller is not a member (403)
2175
- * @throws BackendError('conflict', { code: 'call_room_full' }) if the room is full (409)
2187
+ * Every failure below arrives as kind `'server'` this SDK's error kinds are
2188
+ * `notConfigured | validation | unauthorized | rateLimited | server | network
2189
+ * | decode`, and only a 401, a 429 or a 400 carrying a field-error array gets
2190
+ * its own kind. So branch on `err.code`, never on the kind.
2191
+ *
2192
+ * @throws BackendError('server', { code: 'calling_unavailable' }) no SFU configured on this stack (503)
2193
+ * @throws BackendError('server', { code: 'not_group_member' }) the caller is not a member of the group (403)
2194
+ * @throws BackendError('server', { code: 'call_room_full' }) the call is at its participant limit (409)
2195
+ * @throws BackendError('server', { code: 'call_provider_error' }) the media provider failed upstream (502)
2176
2196
  */
2177
2197
  async start(groupId, { media = ["audio", "video"] } = {}) {
2178
2198
  if (!groupId) {
@@ -2193,7 +2213,11 @@ var PalbeCalls = class {
2193
2213
  * Accept an incoming call in a messaging group.
2194
2214
  *
2195
2215
  * @param groupId - Messaging group display-id
2196
- * @param callId - Call identifier (from the push notification payload)
2216
+ * @param callId - Call identifier, carried by the `call_invite` the server
2217
+ * fans onto the group's conversation topic
2218
+ * (`pb.realtime.channel('messaging:conv:<rfcGroupId>').on('call_invite', …)`).
2219
+ * This SDK ships no web push: on the web the invite arrives over realtime or
2220
+ * over whatever signalling the app already runs.
2197
2221
  * @param media - Which media tracks to publish (default: audio + video)
2198
2222
  */
2199
2223
  async accept(groupId, callId, { media = ["audio", "video"] } = {}) {
@@ -4296,14 +4320,11 @@ var Chat = class {
4296
4320
  get title() {
4297
4321
  if (this.titleOverride) return this.titleOverride;
4298
4322
  if (this._group?.name) return this._group.name;
4299
- if (this._draft) {
4300
- const mode = this._draft.mode;
4301
- if (mode.kind === "direct") {
4302
- const peer = this.memberCache.find((m) => m.userId === mode.peerUserId);
4303
- return peer?.displayName ?? "Chat";
4304
- }
4305
- return "Group";
4323
+ if (this.kind === "direct") {
4324
+ const peer = this.memberCache.find((m) => !m.isSelf);
4325
+ return peer?.displayName ?? "Chat";
4306
4326
  }
4327
+ if (this._draft) return "Group";
4307
4328
  const tail = this.id.startsWith("grp_") ? this.id.slice(4) : this.id;
4308
4329
  const short = tail.slice(-4);
4309
4330
  return short ? `Group ${short}` : "Group";
@@ -5277,6 +5298,11 @@ var MessageHub = class {
5277
5298
  emitConv(displayId, e) {
5278
5299
  for (const fn of this.convListeners.get(displayId) ?? []) fn(e);
5279
5300
  }
5301
+ /** How many live conv listeners a group still has — the census the LAST
5302
+ * subscriber reads before tearing the conv topic (and its presence) down. */
5303
+ convListenerCount(displayId) {
5304
+ return this.convListeners.get(displayId)?.size ?? 0;
5305
+ }
5280
5306
  add(map, key, fn) {
5281
5307
  let set = map.get(key);
5282
5308
  if (!set) {
@@ -5314,6 +5340,9 @@ var MessageDeliverySource = class {
5314
5340
  started = false;
5315
5341
  wakeUnsub = null;
5316
5342
  // Per-observed-group conv subscription + heartbeat (presence/typing/read).
5343
+ // `teardown` is the ONE departure path: it stops the heartbeat, broadcasts the
5344
+ // offline frame and drops the subscriptions, so no caller can tear an
5345
+ // observation down without saying goodbye (see observeConversation).
5317
5346
  observed = /* @__PURE__ */ new Map();
5318
5347
  /** Subscribe the device wake topic + run an initial drain. Idempotent. */
5319
5348
  async start() {
@@ -5338,10 +5367,7 @@ var MessageDeliverySource = class {
5338
5367
  stop() {
5339
5368
  this.wakeUnsub?.();
5340
5369
  this.wakeUnsub = null;
5341
- for (const [, o] of this.observed) {
5342
- o.unsub();
5343
- if (o.heartbeat) clearInterval(o.heartbeat);
5344
- }
5370
+ for (const [, o] of this.observed) o.teardown();
5345
5371
  this.observed.clear();
5346
5372
  this.started = false;
5347
5373
  }
@@ -5572,14 +5598,26 @@ var MessageDeliverySource = class {
5572
5598
  emitPresence(true);
5573
5599
  const heartbeat = setInterval(() => emitPresence(true), 25e3);
5574
5600
  this.observed.set(group.displayId, {
5575
- unsub: () => {
5601
+ teardown: () => {
5602
+ clearInterval(heartbeat);
5603
+ try {
5604
+ emitPresence(false);
5605
+ } catch {
5606
+ }
5576
5607
  for (const s of subs) s.cancel();
5577
- },
5578
- heartbeat
5608
+ }
5579
5609
  });
5580
5610
  } catch {
5581
5611
  }
5582
5612
  }
5613
+ /** Stop observing a group's conv topic: announce OFFLINE, stop the heartbeat
5614
+ * and drop the subscriptions. Idempotent — an unobserved group is a no-op. */
5615
+ unobserveConversation(group) {
5616
+ const o = this.observed.get(group.displayId);
5617
+ if (!o) return;
5618
+ this.observed.delete(group.displayId);
5619
+ o.teardown();
5620
+ }
5583
5621
  /** Announce typing on a group's conv topic (the app calls per keystroke). */
5584
5622
  setTyping(group, isTyping) {
5585
5623
  this.observeConversation(group);
@@ -7807,7 +7845,11 @@ var MessagingCoordinator = class {
7807
7845
  subscribeLive(group, chat) {
7808
7846
  let offMsg = null;
7809
7847
  let offConv = null;
7848
+ let resolved = null;
7849
+ let cancelled = false;
7810
7850
  void this.resolve().then((r) => {
7851
+ if (cancelled) return;
7852
+ resolved = r;
7811
7853
  offMsg = r.hub.onMessage(group.displayId, (m) => {
7812
7854
  void chat.ingestLive(m);
7813
7855
  });
@@ -7815,8 +7857,12 @@ var MessagingCoordinator = class {
7815
7857
  offConv = r.hub.onConv(group.displayId, (e) => chat.applyConv(e.event, e.payload));
7816
7858
  });
7817
7859
  return () => {
7860
+ cancelled = true;
7818
7861
  offMsg?.();
7819
7862
  offConv?.();
7863
+ if (resolved && resolved.hub.convListenerCount(group.displayId) === 0) {
7864
+ resolved.source.unobserveConversation(group);
7865
+ }
7820
7866
  };
7821
7867
  }
7822
7868
  async userIdForDevice(group, deviceId) {
@@ -8674,8 +8720,33 @@ function unwrapBroadcast(frame) {
8674
8720
  function stripRealtimePrefix(topic) {
8675
8721
  return topic.startsWith(REALTIME_PREFIX) ? topic.slice(REALTIME_PREFIX.length) : topic;
8676
8722
  }
8723
+ function encodeStateSet(joinRef, ref, topic, key, value, life) {
8724
+ return JSON.stringify([
8725
+ joinRef,
8726
+ String(ref),
8727
+ REALTIME_PREFIX + topic,
8728
+ "state_set",
8729
+ { key, value, life }
8730
+ ]);
8731
+ }
8732
+ function encodeStateDel(joinRef, ref, topic, key) {
8733
+ return JSON.stringify([joinRef, String(ref), REALTIME_PREFIX + topic, "state_del", { key }]);
8734
+ }
8735
+ function encodeStateResync(joinRef, ref, topic) {
8736
+ return JSON.stringify([joinRef, String(ref), REALTIME_PREFIX + topic, "state_resync", {}]);
8737
+ }
8738
+ function encodeAccessToken(joinRef, ref, topic, token) {
8739
+ return JSON.stringify([
8740
+ joinRef,
8741
+ String(ref),
8742
+ REALTIME_PREFIX + topic,
8743
+ "access_token",
8744
+ { access_token: token }
8745
+ ]);
8746
+ }
8677
8747
 
8678
8748
  // src/realtime/connection.ts
8749
+ var RETRYABLE_REFUSALS = /* @__PURE__ */ new Set(["unavailable", "rate_limited", "too_many_channels"]);
8679
8750
  var WS_OPEN = 1;
8680
8751
  var DEFAULT_HEARTBEAT_MS = 25e3;
8681
8752
  var DEFAULT_MAX_BACKOFF_SECONDS = 30;
@@ -8908,6 +8979,31 @@ var RealtimeSocket = class {
8908
8979
  this.handleChannelClose(frame);
8909
8980
  return;
8910
8981
  }
8982
+ if (frame.event === "state_snapshot") {
8983
+ this.handlers?.onStateSnapshot(stripRealtimePrefix(frame.topic), frame.payload);
8984
+ return;
8985
+ }
8986
+ if (frame.event === "state_diff") {
8987
+ this.handlers?.onStateDiff(stripRealtimePrefix(frame.topic), frame.payload);
8988
+ return;
8989
+ }
8990
+ if (frame.event === "phx_reply") {
8991
+ const body = frame.payload;
8992
+ const topic = stripRealtimePrefix(frame.topic);
8993
+ if (body && body.status === "ok" && body.response && typeof body.response === "object") {
8994
+ this.handlers?.onJoinInfo(topic, body.response);
8995
+ return;
8996
+ }
8997
+ if (body && body.status === "error" && String(frame.ref) === this.joinRefs.get(topic)) {
8998
+ const reason = typeof body.response?.["reason"] === "string" ? body.response["reason"] : "unauthorized";
8999
+ if (!RETRYABLE_REFUSALS.has(reason)) {
9000
+ this.joinedTopics.delete(topic);
9001
+ this.joinRefs.delete(topic);
9002
+ }
9003
+ this.handlers?.onChannelRefused(topic, reason);
9004
+ }
9005
+ return;
9006
+ }
8911
9007
  const inbound = unwrapBroadcast(frame);
8912
9008
  if (inbound) {
8913
9009
  this.channelRejoinAttempts.delete(inbound.topic);
@@ -8948,6 +9044,40 @@ var RealtimeSocket = class {
8948
9044
  }, delayMs);
8949
9045
  this.channelRejoinTimers.set(topic, timer);
8950
9046
  }
9047
+ // MARK: state
9048
+ /** Write one entry on a joined channel's state. Dropped when the topic has no
9049
+ * live join: the server refuses state on a channel this socket is not on, and
9050
+ * the reconnect's join brings a fresh snapshot anyway. */
9051
+ sendStateSet(topic, key, value, life) {
9052
+ const joinRef = this.joinRefs.get(topic);
9053
+ if (joinRef === void 0 || !this.isOpen()) return;
9054
+ this.send(encodeStateSet(joinRef, this.nextRef(), topic, key, value, life));
9055
+ }
9056
+ /** Remove one entry. The server refuses a key this connection does not own. */
9057
+ sendStateDel(topic, key) {
9058
+ const joinRef = this.joinRefs.get(topic);
9059
+ if (joinRef === void 0 || !this.isOpen()) return;
9060
+ this.send(encodeStateDel(joinRef, this.nextRef(), topic, key));
9061
+ }
9062
+ /** Ask for a fresh snapshot — the answer to a detected gap. */
9063
+ sendStateResync(topic) {
9064
+ const joinRef = this.joinRefs.get(topic);
9065
+ if (joinRef === void 0 || !this.isOpen()) return;
9066
+ this.send(encodeStateResync(joinRef, this.nextRef(), topic));
9067
+ }
9068
+ /**
9069
+ * Present a fresher credential on every joined channel WITHOUT rejoining.
9070
+ *
9071
+ * The alternative — dropping and rejoining — reaps this connection's
9072
+ * ephemeral entries (its presence) and forces a full resync on every channel.
9073
+ * A token rotation is not a reconnection and must not look like one.
9074
+ */
9075
+ sendAccessToken(token) {
9076
+ if (!this.isOpen()) return;
9077
+ for (const [topic, joinRef] of this.joinRefs) {
9078
+ this.send(encodeAccessToken(joinRef, this.nextRef(), topic, token));
9079
+ }
9080
+ }
8951
9081
  // MARK: helpers
8952
9082
  /** Drop a single topic's token-expiry rejoin state (cancel its pending timer). */
8953
9083
  clearChannelRejoin(topic) {
@@ -8973,6 +9103,60 @@ var RealtimeSocket = class {
8973
9103
  }
8974
9104
  };
8975
9105
 
9106
+ // src/realtime/state.ts
9107
+ var PRESENCE_PREFIX = "$p:";
9108
+ var ChannelState = class {
9109
+ /** The sequence this local copy is current as of. */
9110
+ seq = 0;
9111
+ /** key → value. Lifetimes are the server's business; a reader only needs the
9112
+ * values, and presence is derived from the key shape. */
9113
+ entries = /* @__PURE__ */ new Map();
9114
+ /** Replace everything. A snapshot is the WHOLE picture, never a merge —
9115
+ * merging one would keep entries the server has since removed. */
9116
+ applySnapshot(body) {
9117
+ this.entries.clear();
9118
+ for (const [k, e] of Object.entries(body.entries ?? {})) {
9119
+ this.entries.set(k, e.v);
9120
+ }
9121
+ this.seq = body.seq;
9122
+ }
9123
+ /**
9124
+ * Apply a diff, or report that it cannot be applied.
9125
+ *
9126
+ * `gap` when this copy is behind the diff's starting point: frames were
9127
+ * missed, and applying anyway would produce a state that never existed on the
9128
+ * server. The state is left untouched so the caller can resync from something
9129
+ * consistent.
9130
+ *
9131
+ * A diff at or below the current sequence is already applied — a resend after
9132
+ * a retry — and is silently accepted so duplicates are harmless.
9133
+ */
9134
+ applyDiff(body) {
9135
+ if (this.seq < body.from_seq) return "gap";
9136
+ if (body.seq <= this.seq) return "applied";
9137
+ for (const [k, e] of Object.entries(body.set ?? {})) this.entries.set(k, e.v);
9138
+ for (const k of body.del ?? []) this.entries.delete(k);
9139
+ this.seq = body.seq;
9140
+ return "applied";
9141
+ }
9142
+ /**
9143
+ * Everyone else's presence.
9144
+ *
9145
+ * `selfConn` is this connection's id, as the join reply reported it. Its own
9146
+ * entry is excluded because an app rendering "who else is here" would
9147
+ * otherwise always show one extra face — itself.
9148
+ */
9149
+ presence(selfConn) {
9150
+ const own = selfConn === void 0 ? void 0 : PRESENCE_PREFIX + selfConn;
9151
+ const out = [];
9152
+ for (const [k, v] of this.entries) {
9153
+ if (!k.startsWith(PRESENCE_PREFIX) || k === own) continue;
9154
+ out.push(v);
9155
+ }
9156
+ return out;
9157
+ }
9158
+ };
9159
+
8976
9160
  // src/realtime/facade.ts
8977
9161
  var StatusStore = class {
8978
9162
  currentState = "idle";
@@ -9008,6 +9192,7 @@ var StatusStore = class {
9008
9192
  for (const listener of this.listeners) listener(snapshot);
9009
9193
  }
9010
9194
  };
9195
+ var RETRYABLE_REASONS = /* @__PURE__ */ new Set(["unavailable", "rate_limited", "too_many_channels"]);
9011
9196
  var RealtimeChannel = class {
9012
9197
  /** The app-defined channel name (bare sub-topic, no "realtime:" prefix). */
9013
9198
  name;
@@ -9025,6 +9210,23 @@ var RealtimeChannel = class {
9025
9210
  on(event, handler) {
9026
9211
  return this.owner.subscribe(this.name, event, handler);
9027
9212
  }
9213
+ /**
9214
+ * Called when the server REFUSES this channel.
9215
+ *
9216
+ * A join is an authorization decision, and a decision the app never hears is
9217
+ * the same as no decision at all: silence looks exactly like a slow network.
9218
+ * This is where a refusal arrives, carrying the server's own distinction —
9219
+ * `retryable: false` means "you may not" and asking again will not help;
9220
+ * `retryable: true` means the project could not be asked, and the socket's
9221
+ * next reconnect retries on its own.
9222
+ *
9223
+ * pb.realtime.channel('room:42').onError((err) => {
9224
+ * if (!err.retryable) showJoinDenied()
9225
+ * })
9226
+ */
9227
+ onError(handler) {
9228
+ return this.owner.subscribeError(this.name, handler);
9229
+ }
9028
9230
  /**
9029
9231
  * Broadcast `payload` to the channel's other subscribers (web addition —
9030
9232
  * iOS is receive-only). Joins the channel if it isn't already; queued
@@ -9033,6 +9235,57 @@ var RealtimeChannel = class {
9033
9235
  send(event, payload = {}) {
9034
9236
  this.owner.send(this.name, event, payload);
9035
9237
  }
9238
+ /**
9239
+ * Watch one key of the channel's shared state.
9240
+ *
9241
+ * The handler is called with the CURRENT value straight away when there is
9242
+ * one, and again on every change. That is the difference from `on()`: a
9243
+ * subscriber to an event learns nothing until the next one, while state is
9244
+ * already there when you ask.
9245
+ */
9246
+ onState(key, handler) {
9247
+ return this.owner.subscribeState(this.name, key, handler);
9248
+ }
9249
+ /** Read one key without subscribing. */
9250
+ stateValue(key) {
9251
+ return this.owner.stateOf(this.name).entries.get(key);
9252
+ }
9253
+ /** Write one key. `durable` outlives this connection; `ephemeral` is reaped
9254
+ * when it closes, which is what presence is built on. */
9255
+ setState(key, value, opts = {}) {
9256
+ this.owner.writeState(this.name, key, value, opts.life ?? "durable");
9257
+ }
9258
+ /** Remove a key this connection wrote. */
9259
+ clearState(key) {
9260
+ this.owner.clearState(this.name, key);
9261
+ }
9262
+ /**
9263
+ * Announce this client on the channel. `meta` is whatever other participants
9264
+ * should see — a name, an avatar, a cursor.
9265
+ *
9266
+ * It is ephemeral state keyed by this connection, so it disappears when the
9267
+ * socket does. Nothing has to be un-announced on a crash.
9268
+ */
9269
+ presenceEnter(meta) {
9270
+ this.owner.presenceWrite(this.name, meta);
9271
+ }
9272
+ /** Replace the announced value — a cursor moving is this, at whatever rate
9273
+ * the app likes: the server conflates writes it cannot deliver fast enough. */
9274
+ presenceUpdate(meta) {
9275
+ this.owner.presenceWrite(this.name, meta);
9276
+ }
9277
+ /** Leave early. Closing the socket does this on its own. */
9278
+ presenceLeave() {
9279
+ this.owner.presenceClear(this.name);
9280
+ }
9281
+ /** Everyone else currently announced, this client excluded. */
9282
+ presenceOthers() {
9283
+ return this.owner.presenceOthers(this.name);
9284
+ }
9285
+ /** Watch the announced set. Called on every change. */
9286
+ onPresence(handler) {
9287
+ return this.owner.subscribePresence(this.name, handler);
9288
+ }
9036
9289
  };
9037
9290
  var PalbeRealtime = class {
9038
9291
  rt;
@@ -9042,9 +9295,21 @@ var PalbeRealtime = class {
9042
9295
  // Per-(topic, event) handlers — an event may have multiple handlers
9043
9296
  // (multiple .on calls); entry identity is the unsubscribe token.
9044
9297
  handlers = /* @__PURE__ */ new Set();
9298
+ // Per-topic refusal handlers. Kept apart from `handlers` because they are not
9299
+ // refcounted: registering one must not join a channel, and cancelling the
9300
+ // last one must not leave a channel that still has event handlers on it.
9301
+ errorHandlers = /* @__PURE__ */ new Set();
9045
9302
  // Joins are refcounted per topic so the socket only leaves a topic when
9046
9303
  // its last handler cancels (iOS RealtimeClient parity).
9047
9304
  topicRefcount = /* @__PURE__ */ new Map();
9305
+ // Per-topic shared state, and this connection's identity on each topic (from
9306
+ // the join ack). Presence is keyed by that identity, so a write before the
9307
+ // ack has nowhere to go and is queued until it arrives.
9308
+ states = /* @__PURE__ */ new Map();
9309
+ connIds = /* @__PURE__ */ new Map();
9310
+ pendingPresence = /* @__PURE__ */ new Map();
9311
+ stateWatchers = /* @__PURE__ */ new Set();
9312
+ presenceWatchers = /* @__PURE__ */ new Set();
9048
9313
  constructor(rt) {
9049
9314
  this.rt = rt;
9050
9315
  }
@@ -9087,6 +9352,20 @@ var PalbeRealtime = class {
9087
9352
  get status() {
9088
9353
  return this.statusStore;
9089
9354
  }
9355
+ /**
9356
+ * Hand the socket a fresher credential for the SAME user, without rejoining.
9357
+ *
9358
+ * Called when the app's token rotates. The alternative — letting the channels
9359
+ * die at expiry and rejoining — reaps this connection's ephemeral entries
9360
+ * (its presence, on every channel) and forces a full state resync. A token
9361
+ * getting older is not a disconnection and must not cost one.
9362
+ *
9363
+ * A no-op before a socket exists: the first connect resolves a fresh token
9364
+ * on its own.
9365
+ */
9366
+ refreshToken(token) {
9367
+ this.socket?.sendAccessToken(token);
9368
+ }
9090
9369
  /** @internal */
9091
9370
  subscribe(topic, event, handler) {
9092
9371
  const socket = this.ensureSocket();
@@ -9101,7 +9380,9 @@ var PalbeRealtime = class {
9101
9380
  if (cancelled) return;
9102
9381
  cancelled = true;
9103
9382
  this.handlers.delete(entry);
9104
- const next = (this.topicRefcount.get(topic) ?? 1) - 1;
9383
+ const held = this.topicRefcount.get(topic);
9384
+ if (held === void 0) return;
9385
+ const next = held - 1;
9105
9386
  if (next <= 0) {
9106
9387
  this.topicRefcount.delete(topic);
9107
9388
  socket.leaveTopic(topic);
@@ -9111,6 +9392,25 @@ var PalbeRealtime = class {
9111
9392
  }
9112
9393
  };
9113
9394
  }
9395
+ /**
9396
+ * Register a refusal handler for one topic. Does NOT join: watching for a
9397
+ * refusal is not a subscription, and an app that registers one before its
9398
+ * first `.on()` should not be joining anything yet.
9399
+ *
9400
+ * @internal
9401
+ */
9402
+ subscribeError(topic, handler) {
9403
+ const entry = { topic, handler };
9404
+ this.errorHandlers.add(entry);
9405
+ let cancelled = false;
9406
+ return {
9407
+ cancel: () => {
9408
+ if (cancelled) return;
9409
+ cancelled = true;
9410
+ this.errorHandlers.delete(entry);
9411
+ }
9412
+ };
9413
+ }
9114
9414
  /** @internal */
9115
9415
  send(topic, event, payload) {
9116
9416
  const socket = this.ensureSocket();
@@ -9132,7 +9432,11 @@ var PalbeRealtime = class {
9132
9432
  onReconnect: () => {
9133
9433
  },
9134
9434
  onStateChange: (state) => this.statusStore.setState(state),
9135
- onChannelError: (topic) => this.handleChannelError(topic)
9435
+ onChannelError: (topic) => this.handleChannelError(topic),
9436
+ onChannelRefused: (topic, reason) => this.handleChannelRefused(topic, reason),
9437
+ onStateSnapshot: (topic, body) => this.applySnapshot(topic, body),
9438
+ onStateDiff: (topic, body) => this.applyDiff(topic, body),
9439
+ onJoinInfo: (topic, info) => this.applyJoinInfo(topic, info)
9136
9440
  });
9137
9441
  }
9138
9442
  return this.socket;
@@ -9146,19 +9450,185 @@ var PalbeRealtime = class {
9146
9450
  }
9147
9451
  }
9148
9452
  /**
9149
- * A channel died for good (token-expiry rejoin exhausted N attempts). Drop its
9150
- * handlers + refcount so the app stops expecting events on it, and flip the
9151
- * status observable to `'error'` (the React `useChannel` hook reports this).
9152
- * A later `.on()` for the same topic re-joins from scratch (refcount 1).
9453
+ /**
9454
+ * The server refused a channel. Tell whoever asked, and when the answer is
9455
+ * final stop pretending the app is subscribed to it.
9456
+ *
9457
+ * `unavailable` leaves everything in place: the project could not be reached
9458
+ * to decide, the socket rejoins on its next reconnect, and dropping the app's
9459
+ * handlers would make a momentary outage look like a permanent denial.
9460
+ */
9461
+ handleChannelRefused(topic, reason) {
9462
+ const retryable = RETRYABLE_REASONS.has(reason);
9463
+ for (const entry of this.errorHandlers) {
9464
+ if (entry.topic === topic) entry.handler({ topic, reason, retryable });
9465
+ }
9466
+ if (retryable) return;
9467
+ this.forgetTopic(topic);
9468
+ }
9469
+ /**
9470
+ * A channel died for good — a token-expiry rejoin that exhausted its attempts.
9471
+ * The app must stop expecting anything on it, and the status observable flips
9472
+ * to `'error'` (the React `useChannel` hook reports that).
9473
+ *
9474
+ * It clears the SAME registries a final refusal clears, and used to clear only
9475
+ * the event handlers. A state or presence watcher left behind here waits
9476
+ * forever for a snapshot that cannot arrive, and fires twice if the app ever
9477
+ * re-subscribes — the identical failure its sibling three functions up
9478
+ * describes, on a channel that was given up on rather than refused.
9153
9479
  */
9154
9480
  handleChannelError(topic) {
9481
+ this.forgetTopic(topic);
9482
+ this.statusStore.setState("error");
9483
+ }
9484
+ /** Drop every registration this topic holds. One place, because "which
9485
+ * registries" is a question two callers must never answer differently. */
9486
+ forgetTopic(topic) {
9155
9487
  for (const entry of this.handlers) {
9156
9488
  if (entry.topic === topic) this.handlers.delete(entry);
9157
9489
  }
9490
+ for (const entry of this.stateWatchers) {
9491
+ if (entry.topic === topic) this.stateWatchers.delete(entry);
9492
+ }
9493
+ for (const entry of this.presenceWatchers) {
9494
+ if (entry.topic === topic) this.presenceWatchers.delete(entry);
9495
+ }
9496
+ this.pendingPresence.delete(topic);
9497
+ this.states.delete(topic);
9498
+ this.connIds.delete(topic);
9158
9499
  this.topicRefcount.delete(topic);
9159
- this.statusStore.setState("error");
9500
+ }
9501
+ // ── state ──────────────────────────────────────────────────────────────────
9502
+ /** @internal */
9503
+ stateOf(topic) {
9504
+ let st = this.states.get(topic);
9505
+ if (!st) {
9506
+ st = new ChannelState();
9507
+ this.states.set(topic, st);
9508
+ }
9509
+ return st;
9510
+ }
9511
+ /** @internal */
9512
+ subscribeState(topic, key, handler) {
9513
+ const socket = this.ensureSocket();
9514
+ const entry = { topic, key, handler };
9515
+ this.stateWatchers.add(entry);
9516
+ this.retainTopic(topic, socket);
9517
+ const known = this.stateOf(topic).entries.get(key);
9518
+ if (known !== void 0) handler(known);
9519
+ let cancelled = false;
9520
+ return {
9521
+ cancel: () => {
9522
+ if (cancelled) return;
9523
+ cancelled = true;
9524
+ this.stateWatchers.delete(entry);
9525
+ this.releaseTopic(topic, socket);
9526
+ }
9527
+ };
9528
+ }
9529
+ /** @internal */
9530
+ subscribePresence(topic, handler) {
9531
+ const socket = this.ensureSocket();
9532
+ const entry = { topic, handler };
9533
+ this.presenceWatchers.add(entry);
9534
+ this.retainTopic(topic, socket);
9535
+ handler(this.presenceOthers(topic));
9536
+ let cancelled = false;
9537
+ return {
9538
+ cancel: () => {
9539
+ if (cancelled) return;
9540
+ cancelled = true;
9541
+ this.presenceWatchers.delete(entry);
9542
+ this.releaseTopic(topic, socket);
9543
+ }
9544
+ };
9545
+ }
9546
+ /** @internal */
9547
+ writeState(topic, key, value, life) {
9548
+ const socket = this.ensureSocket();
9549
+ socket.joinTopic(topic);
9550
+ socket.sendStateSet(topic, key, value, life);
9551
+ }
9552
+ /** @internal */
9553
+ clearState(topic, key) {
9554
+ this.ensureSocket().sendStateDel(topic, key);
9555
+ }
9556
+ /** @internal */
9557
+ presenceWrite(topic, meta) {
9558
+ const socket = this.ensureSocket();
9559
+ socket.joinTopic(topic);
9560
+ const conn = this.connIds.get(topic);
9561
+ if (conn === void 0) {
9562
+ this.pendingPresence.set(topic, meta);
9563
+ return;
9564
+ }
9565
+ socket.sendStateSet(topic, PRESENCE_KEY(conn), meta, "ephemeral");
9566
+ }
9567
+ /** @internal */
9568
+ presenceClear(topic) {
9569
+ this.pendingPresence.delete(topic);
9570
+ const conn = this.connIds.get(topic);
9571
+ if (conn === void 0) return;
9572
+ this.ensureSocket().sendStateDel(topic, PRESENCE_KEY(conn));
9573
+ }
9574
+ /** @internal */
9575
+ presenceOthers(topic) {
9576
+ return this.stateOf(topic).presence(this.connIds.get(topic));
9577
+ }
9578
+ retainTopic(topic, socket) {
9579
+ const count = (this.topicRefcount.get(topic) ?? 0) + 1;
9580
+ this.topicRefcount.set(topic, count);
9581
+ if (count === 1) socket.joinTopic(topic);
9582
+ }
9583
+ releaseTopic(topic, socket) {
9584
+ const next = (this.topicRefcount.get(topic) ?? 1) - 1;
9585
+ if (next <= 0) {
9586
+ this.topicRefcount.delete(topic);
9587
+ socket.leaveTopic(topic);
9588
+ return;
9589
+ }
9590
+ this.topicRefcount.set(topic, next);
9591
+ }
9592
+ applySnapshot(topic, body) {
9593
+ this.stateOf(topic).applySnapshot(body);
9594
+ this.notifyState(topic);
9595
+ }
9596
+ applyDiff(topic, body) {
9597
+ if (this.stateOf(topic).applyDiff(body) === "gap") {
9598
+ this.socket?.sendStateResync(topic);
9599
+ return;
9600
+ }
9601
+ this.notifyState(topic);
9602
+ }
9603
+ applyJoinInfo(topic, info) {
9604
+ if (typeof info.conn !== "string" || info.conn === "") return;
9605
+ this.connIds.set(topic, info.conn);
9606
+ const seq = info.state_seq;
9607
+ if (typeof seq === "number" && seq < this.stateOf(topic).seq) {
9608
+ this.stateOf(topic).applySnapshot({ seq, entries: {} });
9609
+ }
9610
+ const held = this.pendingPresence.get(topic);
9611
+ if (held !== void 0) {
9612
+ this.pendingPresence.delete(topic);
9613
+ this.socket?.sendStateSet(topic, PRESENCE_KEY(info.conn), held, "ephemeral");
9614
+ }
9615
+ this.notifyState(topic);
9616
+ }
9617
+ notifyState(topic) {
9618
+ this.statusStore.recordEvent(/* @__PURE__ */ new Date());
9619
+ const st = this.stateOf(topic);
9620
+ for (const w of this.stateWatchers) {
9621
+ if (w.topic === topic) w.handler(st.entries.get(w.key));
9622
+ }
9623
+ if (this.presenceWatchers.size > 0) {
9624
+ const others = this.presenceOthers(topic);
9625
+ for (const w of this.presenceWatchers) {
9626
+ if (w.topic === topic) w.handler(others);
9627
+ }
9628
+ }
9160
9629
  }
9161
9630
  };
9631
+ var PRESENCE_KEY = (conn) => `$p:${conn}`;
9162
9632
 
9163
9633
  // src/storage.ts
9164
9634
  function memorySessionStorage() {
@@ -9219,7 +9689,7 @@ function defaultSessionStorage(key) {
9219
9689
  }
9220
9690
 
9221
9691
  // src/version.ts
9222
- var VERSION = "7.3.9";
9692
+ var VERSION = "7.4.1";
9223
9693
 
9224
9694
  // src/runtime.ts
9225
9695
  function buildRuntime(config) {