@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
@@ -10,10 +10,10 @@ import {
10
10
  isBackendError,
11
11
  solvePowChallenge,
12
12
  unwrap
13
- } from "./chunk-EAOROUSW.js";
13
+ } from "./chunk-4WMJTUDN.js";
14
14
  import {
15
15
  environmentRefFromPublishableApiKey
16
- } from "./chunk-Q5DUDL77.js";
16
+ } from "./chunk-G56VGUYZ.js";
17
17
  import {
18
18
  __export
19
19
  } from "./chunk-PZ5AY32C.js";
@@ -1323,24 +1323,29 @@ var PalbeAuth = class {
1323
1323
  * Self-service account erasure ("right to be forgotten"). Calls palauth
1324
1324
  * `DELETE /auth/user` (session-authed + fresh re-auth): password users pass
1325
1325
  * `{ password }`; passwordless/OAuth users rely on a freshly stepped-up
1326
- * session. On 202 the erasure workflow is durably queued AND every session is
1327
- * already revoked server-side — so we tear down local state (mirroring
1328
- * `signOut()`'s clear, minus the pointless `/logout`) and emit
1329
- * `signedOut{reason:'accountDeleted'}`.
1326
+ * session. The handler answers **204 with no body** the erasure has already
1327
+ * finished and every session is already revoked server-side — so we tear down
1328
+ * local state (mirroring `signOut()`'s clear, minus the pointless `/logout`)
1329
+ * and emit `signedOut{reason:'accountDeleted'}`.
1330
+ *
1331
+ * Returns nothing, because there is nothing to return: the handler used to
1332
+ * answer 202 with an `{erasure_id}` naming an asynchronous run, and that id
1333
+ * was removed along with the run (`gdpr_handlers.go:221-229`). Reading it
1334
+ * here threw a `TypeError` AFTER the account was already gone — a success
1335
+ * reported as a failure.
1330
1336
  *
1331
1337
  * On 401 `reauth_required` / 503 `not_configured` (or `erasure_unavailable`)
1332
1338
  * the request throws BEFORE the teardown line, so the local session is left
1333
1339
  * fully intact — the deletion did not happen and the user is still signed in.
1334
1340
  */
1335
1341
  async deleteAccount(params = {}) {
1336
- const res = await palbeRequest(this.rt, "DELETE", "/auth/user", {
1342
+ await palbeRequest(this.rt, "DELETE", "/auth/user", {
1337
1343
  body: params.password ? { password: params.password } : {}
1338
1344
  });
1339
1345
  this.nextSignOutReason = "accountDeleted";
1340
1346
  this.rt.tokenManager.clearSession();
1341
1347
  this.rt.storage.clear();
1342
1348
  this.cachedUser = null;
1343
- return { erasureId: res.erasure_id };
1344
1349
  }
1345
1350
  /**
1346
1351
  * @internal — invoked by the transport choke point (`request.ts`), not app code.
@@ -1832,9 +1837,15 @@ var PalbeCalls = class {
1832
1837
  * @param media - Which media tracks to publish (default: audio + video)
1833
1838
  * @returns A `Call` in `connecting` state; transitions to `active` once the media room connects.
1834
1839
  *
1835
- * @throws BackendError('network', { code: 'call_service_unavailable' }) if the SFU is down (503)
1836
- * @throws BackendError('forbidden', { code: 'not_group_member' }) if the caller is not a member (403)
1837
- * @throws BackendError('conflict', { code: 'call_room_full' }) if the room is full (409)
1840
+ * Every failure below arrives as kind `'server'` this SDK's error kinds are
1841
+ * `notConfigured | validation | unauthorized | rateLimited | server | network
1842
+ * | decode`, and only a 401, a 429 or a 400 carrying a field-error array gets
1843
+ * its own kind. So branch on `err.code`, never on the kind.
1844
+ *
1845
+ * @throws BackendError('server', { code: 'calling_unavailable' }) no SFU configured on this stack (503)
1846
+ * @throws BackendError('server', { code: 'not_group_member' }) the caller is not a member of the group (403)
1847
+ * @throws BackendError('server', { code: 'call_room_full' }) the call is at its participant limit (409)
1848
+ * @throws BackendError('server', { code: 'call_provider_error' }) the media provider failed upstream (502)
1838
1849
  */
1839
1850
  async start(groupId, { media = ["audio", "video"] } = {}) {
1840
1851
  if (!groupId) {
@@ -1855,7 +1866,11 @@ var PalbeCalls = class {
1855
1866
  * Accept an incoming call in a messaging group.
1856
1867
  *
1857
1868
  * @param groupId - Messaging group display-id
1858
- * @param callId - Call identifier (from the push notification payload)
1869
+ * @param callId - Call identifier, carried by the `call_invite` the server
1870
+ * fans onto the group's conversation topic
1871
+ * (`pb.realtime.channel('messaging:conv:<rfcGroupId>').on('call_invite', …)`).
1872
+ * This SDK ships no web push: on the web the invite arrives over realtime or
1873
+ * over whatever signalling the app already runs.
1859
1874
  * @param media - Which media tracks to publish (default: audio + video)
1860
1875
  */
1861
1876
  async accept(groupId, callId, { media = ["audio", "video"] } = {}) {
@@ -3958,14 +3973,11 @@ var Chat = class {
3958
3973
  get title() {
3959
3974
  if (this.titleOverride) return this.titleOverride;
3960
3975
  if (this._group?.name) return this._group.name;
3961
- if (this._draft) {
3962
- const mode = this._draft.mode;
3963
- if (mode.kind === "direct") {
3964
- const peer = this.memberCache.find((m) => m.userId === mode.peerUserId);
3965
- return peer?.displayName ?? "Chat";
3966
- }
3967
- return "Group";
3976
+ if (this.kind === "direct") {
3977
+ const peer = this.memberCache.find((m) => !m.isSelf);
3978
+ return peer?.displayName ?? "Chat";
3968
3979
  }
3980
+ if (this._draft) return "Group";
3969
3981
  const tail = this.id.startsWith("grp_") ? this.id.slice(4) : this.id;
3970
3982
  const short = tail.slice(-4);
3971
3983
  return short ? `Group ${short}` : "Group";
@@ -4939,6 +4951,11 @@ var MessageHub = class {
4939
4951
  emitConv(displayId, e) {
4940
4952
  for (const fn of this.convListeners.get(displayId) ?? []) fn(e);
4941
4953
  }
4954
+ /** How many live conv listeners a group still has — the census the LAST
4955
+ * subscriber reads before tearing the conv topic (and its presence) down. */
4956
+ convListenerCount(displayId) {
4957
+ return this.convListeners.get(displayId)?.size ?? 0;
4958
+ }
4942
4959
  add(map, key, fn) {
4943
4960
  let set = map.get(key);
4944
4961
  if (!set) {
@@ -4976,6 +4993,9 @@ var MessageDeliverySource = class {
4976
4993
  started = false;
4977
4994
  wakeUnsub = null;
4978
4995
  // Per-observed-group conv subscription + heartbeat (presence/typing/read).
4996
+ // `teardown` is the ONE departure path: it stops the heartbeat, broadcasts the
4997
+ // offline frame and drops the subscriptions, so no caller can tear an
4998
+ // observation down without saying goodbye (see observeConversation).
4979
4999
  observed = /* @__PURE__ */ new Map();
4980
5000
  /** Subscribe the device wake topic + run an initial drain. Idempotent. */
4981
5001
  async start() {
@@ -5000,10 +5020,7 @@ var MessageDeliverySource = class {
5000
5020
  stop() {
5001
5021
  this.wakeUnsub?.();
5002
5022
  this.wakeUnsub = null;
5003
- for (const [, o] of this.observed) {
5004
- o.unsub();
5005
- if (o.heartbeat) clearInterval(o.heartbeat);
5006
- }
5023
+ for (const [, o] of this.observed) o.teardown();
5007
5024
  this.observed.clear();
5008
5025
  this.started = false;
5009
5026
  }
@@ -5234,14 +5251,26 @@ var MessageDeliverySource = class {
5234
5251
  emitPresence(true);
5235
5252
  const heartbeat = setInterval(() => emitPresence(true), 25e3);
5236
5253
  this.observed.set(group.displayId, {
5237
- unsub: () => {
5254
+ teardown: () => {
5255
+ clearInterval(heartbeat);
5256
+ try {
5257
+ emitPresence(false);
5258
+ } catch {
5259
+ }
5238
5260
  for (const s of subs) s.cancel();
5239
- },
5240
- heartbeat
5261
+ }
5241
5262
  });
5242
5263
  } catch {
5243
5264
  }
5244
5265
  }
5266
+ /** Stop observing a group's conv topic: announce OFFLINE, stop the heartbeat
5267
+ * and drop the subscriptions. Idempotent — an unobserved group is a no-op. */
5268
+ unobserveConversation(group) {
5269
+ const o = this.observed.get(group.displayId);
5270
+ if (!o) return;
5271
+ this.observed.delete(group.displayId);
5272
+ o.teardown();
5273
+ }
5245
5274
  /** Announce typing on a group's conv topic (the app calls per keystroke). */
5246
5275
  setTyping(group, isTyping) {
5247
5276
  this.observeConversation(group);
@@ -7469,7 +7498,11 @@ var MessagingCoordinator = class {
7469
7498
  subscribeLive(group, chat) {
7470
7499
  let offMsg = null;
7471
7500
  let offConv = null;
7501
+ let resolved = null;
7502
+ let cancelled = false;
7472
7503
  void this.resolve().then((r) => {
7504
+ if (cancelled) return;
7505
+ resolved = r;
7473
7506
  offMsg = r.hub.onMessage(group.displayId, (m) => {
7474
7507
  void chat.ingestLive(m);
7475
7508
  });
@@ -7477,8 +7510,12 @@ var MessagingCoordinator = class {
7477
7510
  offConv = r.hub.onConv(group.displayId, (e) => chat.applyConv(e.event, e.payload));
7478
7511
  });
7479
7512
  return () => {
7513
+ cancelled = true;
7480
7514
  offMsg?.();
7481
7515
  offConv?.();
7516
+ if (resolved && resolved.hub.convListenerCount(group.displayId) === 0) {
7517
+ resolved.source.unobserveConversation(group);
7518
+ }
7482
7519
  };
7483
7520
  }
7484
7521
  async userIdForDevice(group, deviceId) {
@@ -8336,8 +8373,33 @@ function unwrapBroadcast(frame) {
8336
8373
  function stripRealtimePrefix(topic) {
8337
8374
  return topic.startsWith(REALTIME_PREFIX) ? topic.slice(REALTIME_PREFIX.length) : topic;
8338
8375
  }
8376
+ function encodeStateSet(joinRef, ref, topic, key, value, life) {
8377
+ return JSON.stringify([
8378
+ joinRef,
8379
+ String(ref),
8380
+ REALTIME_PREFIX + topic,
8381
+ "state_set",
8382
+ { key, value, life }
8383
+ ]);
8384
+ }
8385
+ function encodeStateDel(joinRef, ref, topic, key) {
8386
+ return JSON.stringify([joinRef, String(ref), REALTIME_PREFIX + topic, "state_del", { key }]);
8387
+ }
8388
+ function encodeStateResync(joinRef, ref, topic) {
8389
+ return JSON.stringify([joinRef, String(ref), REALTIME_PREFIX + topic, "state_resync", {}]);
8390
+ }
8391
+ function encodeAccessToken(joinRef, ref, topic, token) {
8392
+ return JSON.stringify([
8393
+ joinRef,
8394
+ String(ref),
8395
+ REALTIME_PREFIX + topic,
8396
+ "access_token",
8397
+ { access_token: token }
8398
+ ]);
8399
+ }
8339
8400
 
8340
8401
  // src/realtime/connection.ts
8402
+ var RETRYABLE_REFUSALS = /* @__PURE__ */ new Set(["unavailable", "rate_limited", "too_many_channels"]);
8341
8403
  var WS_OPEN = 1;
8342
8404
  var DEFAULT_HEARTBEAT_MS = 25e3;
8343
8405
  var DEFAULT_MAX_BACKOFF_SECONDS = 30;
@@ -8570,6 +8632,31 @@ var RealtimeSocket = class {
8570
8632
  this.handleChannelClose(frame);
8571
8633
  return;
8572
8634
  }
8635
+ if (frame.event === "state_snapshot") {
8636
+ this.handlers?.onStateSnapshot(stripRealtimePrefix(frame.topic), frame.payload);
8637
+ return;
8638
+ }
8639
+ if (frame.event === "state_diff") {
8640
+ this.handlers?.onStateDiff(stripRealtimePrefix(frame.topic), frame.payload);
8641
+ return;
8642
+ }
8643
+ if (frame.event === "phx_reply") {
8644
+ const body = frame.payload;
8645
+ const topic = stripRealtimePrefix(frame.topic);
8646
+ if (body && body.status === "ok" && body.response && typeof body.response === "object") {
8647
+ this.handlers?.onJoinInfo(topic, body.response);
8648
+ return;
8649
+ }
8650
+ if (body && body.status === "error" && String(frame.ref) === this.joinRefs.get(topic)) {
8651
+ const reason = typeof body.response?.["reason"] === "string" ? body.response["reason"] : "unauthorized";
8652
+ if (!RETRYABLE_REFUSALS.has(reason)) {
8653
+ this.joinedTopics.delete(topic);
8654
+ this.joinRefs.delete(topic);
8655
+ }
8656
+ this.handlers?.onChannelRefused(topic, reason);
8657
+ }
8658
+ return;
8659
+ }
8573
8660
  const inbound = unwrapBroadcast(frame);
8574
8661
  if (inbound) {
8575
8662
  this.channelRejoinAttempts.delete(inbound.topic);
@@ -8610,6 +8697,40 @@ var RealtimeSocket = class {
8610
8697
  }, delayMs);
8611
8698
  this.channelRejoinTimers.set(topic, timer);
8612
8699
  }
8700
+ // MARK: state
8701
+ /** Write one entry on a joined channel's state. Dropped when the topic has no
8702
+ * live join: the server refuses state on a channel this socket is not on, and
8703
+ * the reconnect's join brings a fresh snapshot anyway. */
8704
+ sendStateSet(topic, key, value, life) {
8705
+ const joinRef = this.joinRefs.get(topic);
8706
+ if (joinRef === void 0 || !this.isOpen()) return;
8707
+ this.send(encodeStateSet(joinRef, this.nextRef(), topic, key, value, life));
8708
+ }
8709
+ /** Remove one entry. The server refuses a key this connection does not own. */
8710
+ sendStateDel(topic, key) {
8711
+ const joinRef = this.joinRefs.get(topic);
8712
+ if (joinRef === void 0 || !this.isOpen()) return;
8713
+ this.send(encodeStateDel(joinRef, this.nextRef(), topic, key));
8714
+ }
8715
+ /** Ask for a fresh snapshot — the answer to a detected gap. */
8716
+ sendStateResync(topic) {
8717
+ const joinRef = this.joinRefs.get(topic);
8718
+ if (joinRef === void 0 || !this.isOpen()) return;
8719
+ this.send(encodeStateResync(joinRef, this.nextRef(), topic));
8720
+ }
8721
+ /**
8722
+ * Present a fresher credential on every joined channel WITHOUT rejoining.
8723
+ *
8724
+ * The alternative — dropping and rejoining — reaps this connection's
8725
+ * ephemeral entries (its presence) and forces a full resync on every channel.
8726
+ * A token rotation is not a reconnection and must not look like one.
8727
+ */
8728
+ sendAccessToken(token) {
8729
+ if (!this.isOpen()) return;
8730
+ for (const [topic, joinRef] of this.joinRefs) {
8731
+ this.send(encodeAccessToken(joinRef, this.nextRef(), topic, token));
8732
+ }
8733
+ }
8613
8734
  // MARK: helpers
8614
8735
  /** Drop a single topic's token-expiry rejoin state (cancel its pending timer). */
8615
8736
  clearChannelRejoin(topic) {
@@ -8635,6 +8756,60 @@ var RealtimeSocket = class {
8635
8756
  }
8636
8757
  };
8637
8758
 
8759
+ // src/realtime/state.ts
8760
+ var PRESENCE_PREFIX = "$p:";
8761
+ var ChannelState = class {
8762
+ /** The sequence this local copy is current as of. */
8763
+ seq = 0;
8764
+ /** key → value. Lifetimes are the server's business; a reader only needs the
8765
+ * values, and presence is derived from the key shape. */
8766
+ entries = /* @__PURE__ */ new Map();
8767
+ /** Replace everything. A snapshot is the WHOLE picture, never a merge —
8768
+ * merging one would keep entries the server has since removed. */
8769
+ applySnapshot(body) {
8770
+ this.entries.clear();
8771
+ for (const [k, e] of Object.entries(body.entries ?? {})) {
8772
+ this.entries.set(k, e.v);
8773
+ }
8774
+ this.seq = body.seq;
8775
+ }
8776
+ /**
8777
+ * Apply a diff, or report that it cannot be applied.
8778
+ *
8779
+ * `gap` when this copy is behind the diff's starting point: frames were
8780
+ * missed, and applying anyway would produce a state that never existed on the
8781
+ * server. The state is left untouched so the caller can resync from something
8782
+ * consistent.
8783
+ *
8784
+ * A diff at or below the current sequence is already applied — a resend after
8785
+ * a retry — and is silently accepted so duplicates are harmless.
8786
+ */
8787
+ applyDiff(body) {
8788
+ if (this.seq < body.from_seq) return "gap";
8789
+ if (body.seq <= this.seq) return "applied";
8790
+ for (const [k, e] of Object.entries(body.set ?? {})) this.entries.set(k, e.v);
8791
+ for (const k of body.del ?? []) this.entries.delete(k);
8792
+ this.seq = body.seq;
8793
+ return "applied";
8794
+ }
8795
+ /**
8796
+ * Everyone else's presence.
8797
+ *
8798
+ * `selfConn` is this connection's id, as the join reply reported it. Its own
8799
+ * entry is excluded because an app rendering "who else is here" would
8800
+ * otherwise always show one extra face — itself.
8801
+ */
8802
+ presence(selfConn) {
8803
+ const own = selfConn === void 0 ? void 0 : PRESENCE_PREFIX + selfConn;
8804
+ const out = [];
8805
+ for (const [k, v] of this.entries) {
8806
+ if (!k.startsWith(PRESENCE_PREFIX) || k === own) continue;
8807
+ out.push(v);
8808
+ }
8809
+ return out;
8810
+ }
8811
+ };
8812
+
8638
8813
  // src/realtime/facade.ts
8639
8814
  var StatusStore = class {
8640
8815
  currentState = "idle";
@@ -8670,6 +8845,7 @@ var StatusStore = class {
8670
8845
  for (const listener of this.listeners) listener(snapshot);
8671
8846
  }
8672
8847
  };
8848
+ var RETRYABLE_REASONS = /* @__PURE__ */ new Set(["unavailable", "rate_limited", "too_many_channels"]);
8673
8849
  var RealtimeChannel = class {
8674
8850
  /** The app-defined channel name (bare sub-topic, no "realtime:" prefix). */
8675
8851
  name;
@@ -8687,6 +8863,23 @@ var RealtimeChannel = class {
8687
8863
  on(event, handler) {
8688
8864
  return this.owner.subscribe(this.name, event, handler);
8689
8865
  }
8866
+ /**
8867
+ * Called when the server REFUSES this channel.
8868
+ *
8869
+ * A join is an authorization decision, and a decision the app never hears is
8870
+ * the same as no decision at all: silence looks exactly like a slow network.
8871
+ * This is where a refusal arrives, carrying the server's own distinction —
8872
+ * `retryable: false` means "you may not" and asking again will not help;
8873
+ * `retryable: true` means the project could not be asked, and the socket's
8874
+ * next reconnect retries on its own.
8875
+ *
8876
+ * pb.realtime.channel('room:42').onError((err) => {
8877
+ * if (!err.retryable) showJoinDenied()
8878
+ * })
8879
+ */
8880
+ onError(handler) {
8881
+ return this.owner.subscribeError(this.name, handler);
8882
+ }
8690
8883
  /**
8691
8884
  * Broadcast `payload` to the channel's other subscribers (web addition —
8692
8885
  * iOS is receive-only). Joins the channel if it isn't already; queued
@@ -8695,6 +8888,57 @@ var RealtimeChannel = class {
8695
8888
  send(event, payload = {}) {
8696
8889
  this.owner.send(this.name, event, payload);
8697
8890
  }
8891
+ /**
8892
+ * Watch one key of the channel's shared state.
8893
+ *
8894
+ * The handler is called with the CURRENT value straight away when there is
8895
+ * one, and again on every change. That is the difference from `on()`: a
8896
+ * subscriber to an event learns nothing until the next one, while state is
8897
+ * already there when you ask.
8898
+ */
8899
+ onState(key, handler) {
8900
+ return this.owner.subscribeState(this.name, key, handler);
8901
+ }
8902
+ /** Read one key without subscribing. */
8903
+ stateValue(key) {
8904
+ return this.owner.stateOf(this.name).entries.get(key);
8905
+ }
8906
+ /** Write one key. `durable` outlives this connection; `ephemeral` is reaped
8907
+ * when it closes, which is what presence is built on. */
8908
+ setState(key, value, opts = {}) {
8909
+ this.owner.writeState(this.name, key, value, opts.life ?? "durable");
8910
+ }
8911
+ /** Remove a key this connection wrote. */
8912
+ clearState(key) {
8913
+ this.owner.clearState(this.name, key);
8914
+ }
8915
+ /**
8916
+ * Announce this client on the channel. `meta` is whatever other participants
8917
+ * should see — a name, an avatar, a cursor.
8918
+ *
8919
+ * It is ephemeral state keyed by this connection, so it disappears when the
8920
+ * socket does. Nothing has to be un-announced on a crash.
8921
+ */
8922
+ presenceEnter(meta) {
8923
+ this.owner.presenceWrite(this.name, meta);
8924
+ }
8925
+ /** Replace the announced value — a cursor moving is this, at whatever rate
8926
+ * the app likes: the server conflates writes it cannot deliver fast enough. */
8927
+ presenceUpdate(meta) {
8928
+ this.owner.presenceWrite(this.name, meta);
8929
+ }
8930
+ /** Leave early. Closing the socket does this on its own. */
8931
+ presenceLeave() {
8932
+ this.owner.presenceClear(this.name);
8933
+ }
8934
+ /** Everyone else currently announced, this client excluded. */
8935
+ presenceOthers() {
8936
+ return this.owner.presenceOthers(this.name);
8937
+ }
8938
+ /** Watch the announced set. Called on every change. */
8939
+ onPresence(handler) {
8940
+ return this.owner.subscribePresence(this.name, handler);
8941
+ }
8698
8942
  };
8699
8943
  var PalbeRealtime = class {
8700
8944
  rt;
@@ -8704,9 +8948,21 @@ var PalbeRealtime = class {
8704
8948
  // Per-(topic, event) handlers — an event may have multiple handlers
8705
8949
  // (multiple .on calls); entry identity is the unsubscribe token.
8706
8950
  handlers = /* @__PURE__ */ new Set();
8951
+ // Per-topic refusal handlers. Kept apart from `handlers` because they are not
8952
+ // refcounted: registering one must not join a channel, and cancelling the
8953
+ // last one must not leave a channel that still has event handlers on it.
8954
+ errorHandlers = /* @__PURE__ */ new Set();
8707
8955
  // Joins are refcounted per topic so the socket only leaves a topic when
8708
8956
  // its last handler cancels (iOS RealtimeClient parity).
8709
8957
  topicRefcount = /* @__PURE__ */ new Map();
8958
+ // Per-topic shared state, and this connection's identity on each topic (from
8959
+ // the join ack). Presence is keyed by that identity, so a write before the
8960
+ // ack has nowhere to go and is queued until it arrives.
8961
+ states = /* @__PURE__ */ new Map();
8962
+ connIds = /* @__PURE__ */ new Map();
8963
+ pendingPresence = /* @__PURE__ */ new Map();
8964
+ stateWatchers = /* @__PURE__ */ new Set();
8965
+ presenceWatchers = /* @__PURE__ */ new Set();
8710
8966
  constructor(rt) {
8711
8967
  this.rt = rt;
8712
8968
  }
@@ -8749,6 +9005,20 @@ var PalbeRealtime = class {
8749
9005
  get status() {
8750
9006
  return this.statusStore;
8751
9007
  }
9008
+ /**
9009
+ * Hand the socket a fresher credential for the SAME user, without rejoining.
9010
+ *
9011
+ * Called when the app's token rotates. The alternative — letting the channels
9012
+ * die at expiry and rejoining — reaps this connection's ephemeral entries
9013
+ * (its presence, on every channel) and forces a full state resync. A token
9014
+ * getting older is not a disconnection and must not cost one.
9015
+ *
9016
+ * A no-op before a socket exists: the first connect resolves a fresh token
9017
+ * on its own.
9018
+ */
9019
+ refreshToken(token) {
9020
+ this.socket?.sendAccessToken(token);
9021
+ }
8752
9022
  /** @internal */
8753
9023
  subscribe(topic, event, handler) {
8754
9024
  const socket = this.ensureSocket();
@@ -8763,7 +9033,9 @@ var PalbeRealtime = class {
8763
9033
  if (cancelled) return;
8764
9034
  cancelled = true;
8765
9035
  this.handlers.delete(entry);
8766
- const next = (this.topicRefcount.get(topic) ?? 1) - 1;
9036
+ const held = this.topicRefcount.get(topic);
9037
+ if (held === void 0) return;
9038
+ const next = held - 1;
8767
9039
  if (next <= 0) {
8768
9040
  this.topicRefcount.delete(topic);
8769
9041
  socket.leaveTopic(topic);
@@ -8773,6 +9045,25 @@ var PalbeRealtime = class {
8773
9045
  }
8774
9046
  };
8775
9047
  }
9048
+ /**
9049
+ * Register a refusal handler for one topic. Does NOT join: watching for a
9050
+ * refusal is not a subscription, and an app that registers one before its
9051
+ * first `.on()` should not be joining anything yet.
9052
+ *
9053
+ * @internal
9054
+ */
9055
+ subscribeError(topic, handler) {
9056
+ const entry = { topic, handler };
9057
+ this.errorHandlers.add(entry);
9058
+ let cancelled = false;
9059
+ return {
9060
+ cancel: () => {
9061
+ if (cancelled) return;
9062
+ cancelled = true;
9063
+ this.errorHandlers.delete(entry);
9064
+ }
9065
+ };
9066
+ }
8776
9067
  /** @internal */
8777
9068
  send(topic, event, payload) {
8778
9069
  const socket = this.ensureSocket();
@@ -8794,7 +9085,11 @@ var PalbeRealtime = class {
8794
9085
  onReconnect: () => {
8795
9086
  },
8796
9087
  onStateChange: (state) => this.statusStore.setState(state),
8797
- onChannelError: (topic) => this.handleChannelError(topic)
9088
+ onChannelError: (topic) => this.handleChannelError(topic),
9089
+ onChannelRefused: (topic, reason) => this.handleChannelRefused(topic, reason),
9090
+ onStateSnapshot: (topic, body) => this.applySnapshot(topic, body),
9091
+ onStateDiff: (topic, body) => this.applyDiff(topic, body),
9092
+ onJoinInfo: (topic, info) => this.applyJoinInfo(topic, info)
8798
9093
  });
8799
9094
  }
8800
9095
  return this.socket;
@@ -8808,19 +9103,185 @@ var PalbeRealtime = class {
8808
9103
  }
8809
9104
  }
8810
9105
  /**
8811
- * A channel died for good (token-expiry rejoin exhausted N attempts). Drop its
8812
- * handlers + refcount so the app stops expecting events on it, and flip the
8813
- * status observable to `'error'` (the React `useChannel` hook reports this).
8814
- * A later `.on()` for the same topic re-joins from scratch (refcount 1).
9106
+ /**
9107
+ * The server refused a channel. Tell whoever asked, and when the answer is
9108
+ * final stop pretending the app is subscribed to it.
9109
+ *
9110
+ * `unavailable` leaves everything in place: the project could not be reached
9111
+ * to decide, the socket rejoins on its next reconnect, and dropping the app's
9112
+ * handlers would make a momentary outage look like a permanent denial.
9113
+ */
9114
+ handleChannelRefused(topic, reason) {
9115
+ const retryable = RETRYABLE_REASONS.has(reason);
9116
+ for (const entry of this.errorHandlers) {
9117
+ if (entry.topic === topic) entry.handler({ topic, reason, retryable });
9118
+ }
9119
+ if (retryable) return;
9120
+ this.forgetTopic(topic);
9121
+ }
9122
+ /**
9123
+ * A channel died for good — a token-expiry rejoin that exhausted its attempts.
9124
+ * The app must stop expecting anything on it, and the status observable flips
9125
+ * to `'error'` (the React `useChannel` hook reports that).
9126
+ *
9127
+ * It clears the SAME registries a final refusal clears, and used to clear only
9128
+ * the event handlers. A state or presence watcher left behind here waits
9129
+ * forever for a snapshot that cannot arrive, and fires twice if the app ever
9130
+ * re-subscribes — the identical failure its sibling three functions up
9131
+ * describes, on a channel that was given up on rather than refused.
8815
9132
  */
8816
9133
  handleChannelError(topic) {
9134
+ this.forgetTopic(topic);
9135
+ this.statusStore.setState("error");
9136
+ }
9137
+ /** Drop every registration this topic holds. One place, because "which
9138
+ * registries" is a question two callers must never answer differently. */
9139
+ forgetTopic(topic) {
8817
9140
  for (const entry of this.handlers) {
8818
9141
  if (entry.topic === topic) this.handlers.delete(entry);
8819
9142
  }
9143
+ for (const entry of this.stateWatchers) {
9144
+ if (entry.topic === topic) this.stateWatchers.delete(entry);
9145
+ }
9146
+ for (const entry of this.presenceWatchers) {
9147
+ if (entry.topic === topic) this.presenceWatchers.delete(entry);
9148
+ }
9149
+ this.pendingPresence.delete(topic);
9150
+ this.states.delete(topic);
9151
+ this.connIds.delete(topic);
8820
9152
  this.topicRefcount.delete(topic);
8821
- this.statusStore.setState("error");
9153
+ }
9154
+ // ── state ──────────────────────────────────────────────────────────────────
9155
+ /** @internal */
9156
+ stateOf(topic) {
9157
+ let st = this.states.get(topic);
9158
+ if (!st) {
9159
+ st = new ChannelState();
9160
+ this.states.set(topic, st);
9161
+ }
9162
+ return st;
9163
+ }
9164
+ /** @internal */
9165
+ subscribeState(topic, key, handler) {
9166
+ const socket = this.ensureSocket();
9167
+ const entry = { topic, key, handler };
9168
+ this.stateWatchers.add(entry);
9169
+ this.retainTopic(topic, socket);
9170
+ const known = this.stateOf(topic).entries.get(key);
9171
+ if (known !== void 0) handler(known);
9172
+ let cancelled = false;
9173
+ return {
9174
+ cancel: () => {
9175
+ if (cancelled) return;
9176
+ cancelled = true;
9177
+ this.stateWatchers.delete(entry);
9178
+ this.releaseTopic(topic, socket);
9179
+ }
9180
+ };
9181
+ }
9182
+ /** @internal */
9183
+ subscribePresence(topic, handler) {
9184
+ const socket = this.ensureSocket();
9185
+ const entry = { topic, handler };
9186
+ this.presenceWatchers.add(entry);
9187
+ this.retainTopic(topic, socket);
9188
+ handler(this.presenceOthers(topic));
9189
+ let cancelled = false;
9190
+ return {
9191
+ cancel: () => {
9192
+ if (cancelled) return;
9193
+ cancelled = true;
9194
+ this.presenceWatchers.delete(entry);
9195
+ this.releaseTopic(topic, socket);
9196
+ }
9197
+ };
9198
+ }
9199
+ /** @internal */
9200
+ writeState(topic, key, value, life) {
9201
+ const socket = this.ensureSocket();
9202
+ socket.joinTopic(topic);
9203
+ socket.sendStateSet(topic, key, value, life);
9204
+ }
9205
+ /** @internal */
9206
+ clearState(topic, key) {
9207
+ this.ensureSocket().sendStateDel(topic, key);
9208
+ }
9209
+ /** @internal */
9210
+ presenceWrite(topic, meta) {
9211
+ const socket = this.ensureSocket();
9212
+ socket.joinTopic(topic);
9213
+ const conn = this.connIds.get(topic);
9214
+ if (conn === void 0) {
9215
+ this.pendingPresence.set(topic, meta);
9216
+ return;
9217
+ }
9218
+ socket.sendStateSet(topic, PRESENCE_KEY(conn), meta, "ephemeral");
9219
+ }
9220
+ /** @internal */
9221
+ presenceClear(topic) {
9222
+ this.pendingPresence.delete(topic);
9223
+ const conn = this.connIds.get(topic);
9224
+ if (conn === void 0) return;
9225
+ this.ensureSocket().sendStateDel(topic, PRESENCE_KEY(conn));
9226
+ }
9227
+ /** @internal */
9228
+ presenceOthers(topic) {
9229
+ return this.stateOf(topic).presence(this.connIds.get(topic));
9230
+ }
9231
+ retainTopic(topic, socket) {
9232
+ const count = (this.topicRefcount.get(topic) ?? 0) + 1;
9233
+ this.topicRefcount.set(topic, count);
9234
+ if (count === 1) socket.joinTopic(topic);
9235
+ }
9236
+ releaseTopic(topic, socket) {
9237
+ const next = (this.topicRefcount.get(topic) ?? 1) - 1;
9238
+ if (next <= 0) {
9239
+ this.topicRefcount.delete(topic);
9240
+ socket.leaveTopic(topic);
9241
+ return;
9242
+ }
9243
+ this.topicRefcount.set(topic, next);
9244
+ }
9245
+ applySnapshot(topic, body) {
9246
+ this.stateOf(topic).applySnapshot(body);
9247
+ this.notifyState(topic);
9248
+ }
9249
+ applyDiff(topic, body) {
9250
+ if (this.stateOf(topic).applyDiff(body) === "gap") {
9251
+ this.socket?.sendStateResync(topic);
9252
+ return;
9253
+ }
9254
+ this.notifyState(topic);
9255
+ }
9256
+ applyJoinInfo(topic, info) {
9257
+ if (typeof info.conn !== "string" || info.conn === "") return;
9258
+ this.connIds.set(topic, info.conn);
9259
+ const seq = info.state_seq;
9260
+ if (typeof seq === "number" && seq < this.stateOf(topic).seq) {
9261
+ this.stateOf(topic).applySnapshot({ seq, entries: {} });
9262
+ }
9263
+ const held = this.pendingPresence.get(topic);
9264
+ if (held !== void 0) {
9265
+ this.pendingPresence.delete(topic);
9266
+ this.socket?.sendStateSet(topic, PRESENCE_KEY(info.conn), held, "ephemeral");
9267
+ }
9268
+ this.notifyState(topic);
9269
+ }
9270
+ notifyState(topic) {
9271
+ this.statusStore.recordEvent(/* @__PURE__ */ new Date());
9272
+ const st = this.stateOf(topic);
9273
+ for (const w of this.stateWatchers) {
9274
+ if (w.topic === topic) w.handler(st.entries.get(w.key));
9275
+ }
9276
+ if (this.presenceWatchers.size > 0) {
9277
+ const others = this.presenceOthers(topic);
9278
+ for (const w of this.presenceWatchers) {
9279
+ if (w.topic === topic) w.handler(others);
9280
+ }
9281
+ }
8822
9282
  }
8823
9283
  };
9284
+ var PRESENCE_KEY = (conn) => `$p:${conn}`;
8824
9285
 
8825
9286
  // src/storage.ts
8826
9287
  function memorySessionStorage() {
@@ -8881,7 +9342,7 @@ function defaultSessionStorage(key) {
8881
9342
  }
8882
9343
 
8883
9344
  // src/version.ts
8884
- var VERSION = "7.3.9";
9345
+ var VERSION = "7.4.1";
8885
9346
 
8886
9347
  // src/runtime.ts
8887
9348
  function buildRuntime(config) {
@@ -9059,6 +9520,7 @@ async function callEndpoint(resolveRt, name, input, options) {
9059
9520
  }
9060
9521
 
9061
9522
  // src/upload.ts
9523
+ var UPLOAD_PATH = "/v1/storage/upload";
9062
9524
  function abortError() {
9063
9525
  return new BackendError("network", { code: "aborted", message: "Upload aborted" });
9064
9526
  }
@@ -9112,7 +9574,9 @@ async function buildHeaders(rt, extra) {
9112
9574
  }
9113
9575
  function buildForm(options) {
9114
9576
  const form = new FormData();
9115
- for (const [k, v] of Object.entries(options.fields ?? {})) form.append(k, v);
9577
+ const { body, ...loose } = options.fields ?? {};
9578
+ if (body !== void 0) form.append("body", body);
9579
+ else if (Object.keys(loose).length > 0) form.append("body", JSON.stringify(loose));
9116
9580
  const file = options.contentType && options.file.type !== options.contentType ? new Blob([options.file], { type: options.contentType }) : options.file;
9117
9581
  const filename = options.filename ?? (typeof File !== "undefined" && options.file instanceof File ? options.file.name : "file");
9118
9582
  form.append("file", file, filename);
@@ -9186,8 +9650,8 @@ async function uploadViaFetch(url, form, headers, options) {
9186
9650
  async function uploadEndpoint(resolveRt, name, options) {
9187
9651
  const rt = resolveRt();
9188
9652
  checkConstraints(options);
9189
- const path = name.startsWith("/") ? name : `/${name}`;
9190
- const url = `${rt.config.url}${path}`;
9653
+ const route = name.startsWith("/") ? name : `/${name}`;
9654
+ const url = `${rt.config.url}${UPLOAD_PATH}${route}`;
9191
9655
  const headers = await buildHeaders(rt, options.headers);
9192
9656
  const form = buildForm(options);
9193
9657
  const useXHR = options.onProgress !== void 0 && typeof XMLHttpRequest !== "undefined";
@@ -9301,4 +9765,4 @@ export {
9301
9765
  pb,
9302
9766
  createBoundClient
9303
9767
  };
9304
- //# sourceMappingURL=chunk-H6YSCGQC.js.map
9768
+ //# sourceMappingURL=chunk-XJBMBUHU.js.map