@palbase/web 7.3.9 → 7.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 (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-WXBOPPHU.js} +502 -38
  10. package/dist/chunk-WXBOPPHU.js.map +1 -0
  11. package/dist/gen/cli.cjs +1 -1
  12. package/dist/gen/cli.cjs.map +1 -1
  13. package/dist/gen/cli.js +2 -2
  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
package/dist/index.cjs CHANGED
@@ -197,6 +197,15 @@ function isFieldErrorArray(value) {
197
197
  (v) => typeof v === "object" && v !== null && typeof v.field === "string" && typeof v.message === "string"
198
198
  );
199
199
  }
200
+ function fieldErrorsOf(envelope) {
201
+ const scoped = pickField(pickField(envelope, "data"), "fields");
202
+ if (isFieldErrorArray(scoped)) return scoped;
203
+ const flat = pickField(envelope, "fields");
204
+ return isFieldErrorArray(flat) ? flat : void 0;
205
+ }
206
+ function retryAfterOf(envelope) {
207
+ return pickNumber(envelope, "retry_after") ?? pickNumber(pickField(envelope, "data"), "retryAfter");
208
+ }
200
209
  function pickField(value, key) {
201
210
  if (typeof value === "object" && value !== null) {
202
211
  return value[key];
@@ -222,20 +231,15 @@ function fromPalbaseError(err) {
222
231
  if (err.code === "network_error") return new BackendError("network", base);
223
232
  if (err.status === 401) return new BackendError("unauthorized", base);
224
233
  if (err.status === 429)
225
- return new BackendError("rateLimited", {
226
- ...base,
227
- retryAfter: pickNumber(err.details, "retry_after")
228
- });
229
- const nested = pickField(err.details, "details");
230
- if (err.status === 400 && isFieldErrorArray(nested))
231
- return new BackendError("validation", { ...base, fields: nested });
234
+ return new BackendError("rateLimited", { ...base, retryAfter: retryAfterOf(err.details) });
235
+ const fields = fieldErrorsOf(err.details);
236
+ if (err.status === 400 && fields) return new BackendError("validation", { ...base, fields });
232
237
  return new BackendError("server", base);
233
238
  }
234
239
  function fromEnvelope(status, body) {
235
240
  const code = pickString(body, "error") ?? "http_error";
236
241
  const message = pickString(body, "error_description") ?? `HTTP ${status}`;
237
242
  const requestId = pickString(body, "request_id");
238
- const details = pickField(body, "details");
239
243
  const params = {
240
244
  code,
241
245
  message,
@@ -245,13 +249,9 @@ function fromEnvelope(status, body) {
245
249
  };
246
250
  if (status === 401) return new BackendError("unauthorized", params);
247
251
  if (status === 429)
248
- return new BackendError("rateLimited", {
249
- ...params,
250
- // Real 429 wire body has TOP-LEVEL retry_after; nested details is a fallback.
251
- retryAfter: pickNumber(body, "retry_after") ?? pickNumber(details, "retry_after")
252
- });
253
- if (status === 400 && isFieldErrorArray(details))
254
- return new BackendError("validation", { ...params, fields: details });
252
+ return new BackendError("rateLimited", { ...params, retryAfter: retryAfterOf(body) });
253
+ const fields = fieldErrorsOf(body);
254
+ if (status === 400 && fields) return new BackendError("validation", { ...params, fields });
255
255
  return new BackendError("server", params);
256
256
  }
257
257
  function isBackendError(e) {
@@ -811,24 +811,29 @@ var PalbeAuth = class {
811
811
  * Self-service account erasure ("right to be forgotten"). Calls palauth
812
812
  * `DELETE /auth/user` (session-authed + fresh re-auth): password users pass
813
813
  * `{ password }`; passwordless/OAuth users rely on a freshly stepped-up
814
- * session. On 202 the erasure workflow is durably queued AND every session is
815
- * already revoked server-side — so we tear down local state (mirroring
816
- * `signOut()`'s clear, minus the pointless `/logout`) and emit
817
- * `signedOut{reason:'accountDeleted'}`.
814
+ * session. The handler answers **204 with no body** the erasure has already
815
+ * finished and every session is already revoked server-side — so we tear down
816
+ * local state (mirroring `signOut()`'s clear, minus the pointless `/logout`)
817
+ * and emit `signedOut{reason:'accountDeleted'}`.
818
+ *
819
+ * Returns nothing, because there is nothing to return: the handler used to
820
+ * answer 202 with an `{erasure_id}` naming an asynchronous run, and that id
821
+ * was removed along with the run (`gdpr_handlers.go:221-229`). Reading it
822
+ * here threw a `TypeError` AFTER the account was already gone — a success
823
+ * reported as a failure.
818
824
  *
819
825
  * On 401 `reauth_required` / 503 `not_configured` (or `erasure_unavailable`)
820
826
  * the request throws BEFORE the teardown line, so the local session is left
821
827
  * fully intact — the deletion did not happen and the user is still signed in.
822
828
  */
823
829
  async deleteAccount(params = {}) {
824
- const res = await palbeRequest(this.rt, "DELETE", "/auth/user", {
830
+ await palbeRequest(this.rt, "DELETE", "/auth/user", {
825
831
  body: params.password ? { password: params.password } : {}
826
832
  });
827
833
  this.nextSignOutReason = "accountDeleted";
828
834
  this.rt.tokenManager.clearSession();
829
835
  this.rt.storage.clear();
830
836
  this.cachedUser = null;
831
- return { erasureId: res.erasure_id };
832
837
  }
833
838
  /**
834
839
  * @internal — invoked by the transport choke point (`request.ts`), not app code.
@@ -1315,9 +1320,15 @@ var PalbeCalls = class {
1315
1320
  * @param media - Which media tracks to publish (default: audio + video)
1316
1321
  * @returns A `Call` in `connecting` state; transitions to `active` once the media room connects.
1317
1322
  *
1318
- * @throws BackendError('network', { code: 'call_service_unavailable' }) if the SFU is down (503)
1319
- * @throws BackendError('forbidden', { code: 'not_group_member' }) if the caller is not a member (403)
1320
- * @throws BackendError('conflict', { code: 'call_room_full' }) if the room is full (409)
1323
+ * Every failure below arrives as kind `'server'` this SDK's error kinds are
1324
+ * `notConfigured | validation | unauthorized | rateLimited | server | network
1325
+ * | decode`, and only a 401, a 429 or a 400 carrying a field-error array gets
1326
+ * its own kind. So branch on `err.code`, never on the kind.
1327
+ *
1328
+ * @throws BackendError('server', { code: 'calling_unavailable' }) no SFU configured on this stack (503)
1329
+ * @throws BackendError('server', { code: 'not_group_member' }) the caller is not a member of the group (403)
1330
+ * @throws BackendError('server', { code: 'call_room_full' }) the call is at its participant limit (409)
1331
+ * @throws BackendError('server', { code: 'call_provider_error' }) the media provider failed upstream (502)
1321
1332
  */
1322
1333
  async start(groupId, { media = ["audio", "video"] } = {}) {
1323
1334
  if (!groupId) {
@@ -1338,7 +1349,11 @@ var PalbeCalls = class {
1338
1349
  * Accept an incoming call in a messaging group.
1339
1350
  *
1340
1351
  * @param groupId - Messaging group display-id
1341
- * @param callId - Call identifier (from the push notification payload)
1352
+ * @param callId - Call identifier, carried by the `call_invite` the server
1353
+ * fans onto the group's conversation topic
1354
+ * (`pb.realtime.channel('messaging:conv:<rfcGroupId>').on('call_invite', …)`).
1355
+ * This SDK ships no web push: on the web the invite arrives over realtime or
1356
+ * over whatever signalling the app already runs.
1342
1357
  * @param media - Which media tracks to publish (default: audio + video)
1343
1358
  */
1344
1359
  async accept(groupId, callId, { media = ["audio", "video"] } = {}) {
@@ -3441,14 +3456,11 @@ var Chat = class {
3441
3456
  get title() {
3442
3457
  if (this.titleOverride) return this.titleOverride;
3443
3458
  if (this._group?.name) return this._group.name;
3444
- if (this._draft) {
3445
- const mode = this._draft.mode;
3446
- if (mode.kind === "direct") {
3447
- const peer = this.memberCache.find((m) => m.userId === mode.peerUserId);
3448
- return peer?.displayName ?? "Chat";
3449
- }
3450
- return "Group";
3459
+ if (this.kind === "direct") {
3460
+ const peer = this.memberCache.find((m) => !m.isSelf);
3461
+ return peer?.displayName ?? "Chat";
3451
3462
  }
3463
+ if (this._draft) return "Group";
3452
3464
  const tail = this.id.startsWith("grp_") ? this.id.slice(4) : this.id;
3453
3465
  const short = tail.slice(-4);
3454
3466
  return short ? `Group ${short}` : "Group";
@@ -4422,6 +4434,11 @@ var MessageHub = class {
4422
4434
  emitConv(displayId, e) {
4423
4435
  for (const fn of this.convListeners.get(displayId) ?? []) fn(e);
4424
4436
  }
4437
+ /** How many live conv listeners a group still has — the census the LAST
4438
+ * subscriber reads before tearing the conv topic (and its presence) down. */
4439
+ convListenerCount(displayId) {
4440
+ return this.convListeners.get(displayId)?.size ?? 0;
4441
+ }
4425
4442
  add(map, key, fn) {
4426
4443
  let set = map.get(key);
4427
4444
  if (!set) {
@@ -4459,6 +4476,9 @@ var MessageDeliverySource = class {
4459
4476
  started = false;
4460
4477
  wakeUnsub = null;
4461
4478
  // Per-observed-group conv subscription + heartbeat (presence/typing/read).
4479
+ // `teardown` is the ONE departure path: it stops the heartbeat, broadcasts the
4480
+ // offline frame and drops the subscriptions, so no caller can tear an
4481
+ // observation down without saying goodbye (see observeConversation).
4462
4482
  observed = /* @__PURE__ */ new Map();
4463
4483
  /** Subscribe the device wake topic + run an initial drain. Idempotent. */
4464
4484
  async start() {
@@ -4483,10 +4503,7 @@ var MessageDeliverySource = class {
4483
4503
  stop() {
4484
4504
  this.wakeUnsub?.();
4485
4505
  this.wakeUnsub = null;
4486
- for (const [, o] of this.observed) {
4487
- o.unsub();
4488
- if (o.heartbeat) clearInterval(o.heartbeat);
4489
- }
4506
+ for (const [, o] of this.observed) o.teardown();
4490
4507
  this.observed.clear();
4491
4508
  this.started = false;
4492
4509
  }
@@ -4717,14 +4734,26 @@ var MessageDeliverySource = class {
4717
4734
  emitPresence(true);
4718
4735
  const heartbeat = setInterval(() => emitPresence(true), 25e3);
4719
4736
  this.observed.set(group.displayId, {
4720
- unsub: () => {
4737
+ teardown: () => {
4738
+ clearInterval(heartbeat);
4739
+ try {
4740
+ emitPresence(false);
4741
+ } catch {
4742
+ }
4721
4743
  for (const s of subs) s.cancel();
4722
- },
4723
- heartbeat
4744
+ }
4724
4745
  });
4725
4746
  } catch {
4726
4747
  }
4727
4748
  }
4749
+ /** Stop observing a group's conv topic: announce OFFLINE, stop the heartbeat
4750
+ * and drop the subscriptions. Idempotent — an unobserved group is a no-op. */
4751
+ unobserveConversation(group) {
4752
+ const o = this.observed.get(group.displayId);
4753
+ if (!o) return;
4754
+ this.observed.delete(group.displayId);
4755
+ o.teardown();
4756
+ }
4728
4757
  /** Announce typing on a group's conv topic (the app calls per keystroke). */
4729
4758
  setTyping(group, isTyping) {
4730
4759
  this.observeConversation(group);
@@ -6952,7 +6981,11 @@ var MessagingCoordinator = class {
6952
6981
  subscribeLive(group, chat) {
6953
6982
  let offMsg = null;
6954
6983
  let offConv = null;
6984
+ let resolved = null;
6985
+ let cancelled = false;
6955
6986
  void this.resolve().then((r) => {
6987
+ if (cancelled) return;
6988
+ resolved = r;
6956
6989
  offMsg = r.hub.onMessage(group.displayId, (m) => {
6957
6990
  void chat.ingestLive(m);
6958
6991
  });
@@ -6960,8 +6993,12 @@ var MessagingCoordinator = class {
6960
6993
  offConv = r.hub.onConv(group.displayId, (e) => chat.applyConv(e.event, e.payload));
6961
6994
  });
6962
6995
  return () => {
6996
+ cancelled = true;
6963
6997
  offMsg?.();
6964
6998
  offConv?.();
6999
+ if (resolved && resolved.hub.convListenerCount(group.displayId) === 0) {
7000
+ resolved.source.unobserveConversation(group);
7001
+ }
6965
7002
  };
6966
7003
  }
6967
7004
  async userIdForDevice(group, deviceId) {
@@ -7479,8 +7516,33 @@ function unwrapBroadcast(frame) {
7479
7516
  function stripRealtimePrefix(topic) {
7480
7517
  return topic.startsWith(REALTIME_PREFIX) ? topic.slice(REALTIME_PREFIX.length) : topic;
7481
7518
  }
7519
+ function encodeStateSet(joinRef, ref, topic, key, value, life) {
7520
+ return JSON.stringify([
7521
+ joinRef,
7522
+ String(ref),
7523
+ REALTIME_PREFIX + topic,
7524
+ "state_set",
7525
+ { key, value, life }
7526
+ ]);
7527
+ }
7528
+ function encodeStateDel(joinRef, ref, topic, key) {
7529
+ return JSON.stringify([joinRef, String(ref), REALTIME_PREFIX + topic, "state_del", { key }]);
7530
+ }
7531
+ function encodeStateResync(joinRef, ref, topic) {
7532
+ return JSON.stringify([joinRef, String(ref), REALTIME_PREFIX + topic, "state_resync", {}]);
7533
+ }
7534
+ function encodeAccessToken(joinRef, ref, topic, token) {
7535
+ return JSON.stringify([
7536
+ joinRef,
7537
+ String(ref),
7538
+ REALTIME_PREFIX + topic,
7539
+ "access_token",
7540
+ { access_token: token }
7541
+ ]);
7542
+ }
7482
7543
 
7483
7544
  // src/realtime/connection.ts
7545
+ var RETRYABLE_REFUSALS = /* @__PURE__ */ new Set(["unavailable", "rate_limited", "too_many_channels"]);
7484
7546
  var WS_OPEN = 1;
7485
7547
  var DEFAULT_HEARTBEAT_MS = 25e3;
7486
7548
  var DEFAULT_MAX_BACKOFF_SECONDS = 30;
@@ -7713,6 +7775,31 @@ var RealtimeSocket = class {
7713
7775
  this.handleChannelClose(frame);
7714
7776
  return;
7715
7777
  }
7778
+ if (frame.event === "state_snapshot") {
7779
+ this.handlers?.onStateSnapshot(stripRealtimePrefix(frame.topic), frame.payload);
7780
+ return;
7781
+ }
7782
+ if (frame.event === "state_diff") {
7783
+ this.handlers?.onStateDiff(stripRealtimePrefix(frame.topic), frame.payload);
7784
+ return;
7785
+ }
7786
+ if (frame.event === "phx_reply") {
7787
+ const body = frame.payload;
7788
+ const topic = stripRealtimePrefix(frame.topic);
7789
+ if (body && body.status === "ok" && body.response && typeof body.response === "object") {
7790
+ this.handlers?.onJoinInfo(topic, body.response);
7791
+ return;
7792
+ }
7793
+ if (body && body.status === "error" && String(frame.ref) === this.joinRefs.get(topic)) {
7794
+ const reason = typeof body.response?.["reason"] === "string" ? body.response["reason"] : "unauthorized";
7795
+ if (!RETRYABLE_REFUSALS.has(reason)) {
7796
+ this.joinedTopics.delete(topic);
7797
+ this.joinRefs.delete(topic);
7798
+ }
7799
+ this.handlers?.onChannelRefused(topic, reason);
7800
+ }
7801
+ return;
7802
+ }
7716
7803
  const inbound = unwrapBroadcast(frame);
7717
7804
  if (inbound) {
7718
7805
  this.channelRejoinAttempts.delete(inbound.topic);
@@ -7753,6 +7840,40 @@ var RealtimeSocket = class {
7753
7840
  }, delayMs);
7754
7841
  this.channelRejoinTimers.set(topic, timer);
7755
7842
  }
7843
+ // MARK: state
7844
+ /** Write one entry on a joined channel's state. Dropped when the topic has no
7845
+ * live join: the server refuses state on a channel this socket is not on, and
7846
+ * the reconnect's join brings a fresh snapshot anyway. */
7847
+ sendStateSet(topic, key, value, life) {
7848
+ const joinRef = this.joinRefs.get(topic);
7849
+ if (joinRef === void 0 || !this.isOpen()) return;
7850
+ this.send(encodeStateSet(joinRef, this.nextRef(), topic, key, value, life));
7851
+ }
7852
+ /** Remove one entry. The server refuses a key this connection does not own. */
7853
+ sendStateDel(topic, key) {
7854
+ const joinRef = this.joinRefs.get(topic);
7855
+ if (joinRef === void 0 || !this.isOpen()) return;
7856
+ this.send(encodeStateDel(joinRef, this.nextRef(), topic, key));
7857
+ }
7858
+ /** Ask for a fresh snapshot — the answer to a detected gap. */
7859
+ sendStateResync(topic) {
7860
+ const joinRef = this.joinRefs.get(topic);
7861
+ if (joinRef === void 0 || !this.isOpen()) return;
7862
+ this.send(encodeStateResync(joinRef, this.nextRef(), topic));
7863
+ }
7864
+ /**
7865
+ * Present a fresher credential on every joined channel WITHOUT rejoining.
7866
+ *
7867
+ * The alternative — dropping and rejoining — reaps this connection's
7868
+ * ephemeral entries (its presence) and forces a full resync on every channel.
7869
+ * A token rotation is not a reconnection and must not look like one.
7870
+ */
7871
+ sendAccessToken(token) {
7872
+ if (!this.isOpen()) return;
7873
+ for (const [topic, joinRef] of this.joinRefs) {
7874
+ this.send(encodeAccessToken(joinRef, this.nextRef(), topic, token));
7875
+ }
7876
+ }
7756
7877
  // MARK: helpers
7757
7878
  /** Drop a single topic's token-expiry rejoin state (cancel its pending timer). */
7758
7879
  clearChannelRejoin(topic) {
@@ -7778,6 +7899,60 @@ var RealtimeSocket = class {
7778
7899
  }
7779
7900
  };
7780
7901
 
7902
+ // src/realtime/state.ts
7903
+ var PRESENCE_PREFIX = "$p:";
7904
+ var ChannelState = class {
7905
+ /** The sequence this local copy is current as of. */
7906
+ seq = 0;
7907
+ /** key → value. Lifetimes are the server's business; a reader only needs the
7908
+ * values, and presence is derived from the key shape. */
7909
+ entries = /* @__PURE__ */ new Map();
7910
+ /** Replace everything. A snapshot is the WHOLE picture, never a merge —
7911
+ * merging one would keep entries the server has since removed. */
7912
+ applySnapshot(body) {
7913
+ this.entries.clear();
7914
+ for (const [k, e] of Object.entries(body.entries ?? {})) {
7915
+ this.entries.set(k, e.v);
7916
+ }
7917
+ this.seq = body.seq;
7918
+ }
7919
+ /**
7920
+ * Apply a diff, or report that it cannot be applied.
7921
+ *
7922
+ * `gap` when this copy is behind the diff's starting point: frames were
7923
+ * missed, and applying anyway would produce a state that never existed on the
7924
+ * server. The state is left untouched so the caller can resync from something
7925
+ * consistent.
7926
+ *
7927
+ * A diff at or below the current sequence is already applied — a resend after
7928
+ * a retry — and is silently accepted so duplicates are harmless.
7929
+ */
7930
+ applyDiff(body) {
7931
+ if (this.seq < body.from_seq) return "gap";
7932
+ if (body.seq <= this.seq) return "applied";
7933
+ for (const [k, e] of Object.entries(body.set ?? {})) this.entries.set(k, e.v);
7934
+ for (const k of body.del ?? []) this.entries.delete(k);
7935
+ this.seq = body.seq;
7936
+ return "applied";
7937
+ }
7938
+ /**
7939
+ * Everyone else's presence.
7940
+ *
7941
+ * `selfConn` is this connection's id, as the join reply reported it. Its own
7942
+ * entry is excluded because an app rendering "who else is here" would
7943
+ * otherwise always show one extra face — itself.
7944
+ */
7945
+ presence(selfConn) {
7946
+ const own = selfConn === void 0 ? void 0 : PRESENCE_PREFIX + selfConn;
7947
+ const out = [];
7948
+ for (const [k, v] of this.entries) {
7949
+ if (!k.startsWith(PRESENCE_PREFIX) || k === own) continue;
7950
+ out.push(v);
7951
+ }
7952
+ return out;
7953
+ }
7954
+ };
7955
+
7781
7956
  // src/realtime/facade.ts
7782
7957
  var StatusStore = class {
7783
7958
  currentState = "idle";
@@ -7813,6 +7988,7 @@ var StatusStore = class {
7813
7988
  for (const listener of this.listeners) listener(snapshot);
7814
7989
  }
7815
7990
  };
7991
+ var RETRYABLE_REASONS = /* @__PURE__ */ new Set(["unavailable", "rate_limited", "too_many_channels"]);
7816
7992
  var RealtimeChannel = class {
7817
7993
  /** The app-defined channel name (bare sub-topic, no "realtime:" prefix). */
7818
7994
  name;
@@ -7830,6 +8006,23 @@ var RealtimeChannel = class {
7830
8006
  on(event, handler) {
7831
8007
  return this.owner.subscribe(this.name, event, handler);
7832
8008
  }
8009
+ /**
8010
+ * Called when the server REFUSES this channel.
8011
+ *
8012
+ * A join is an authorization decision, and a decision the app never hears is
8013
+ * the same as no decision at all: silence looks exactly like a slow network.
8014
+ * This is where a refusal arrives, carrying the server's own distinction —
8015
+ * `retryable: false` means "you may not" and asking again will not help;
8016
+ * `retryable: true` means the project could not be asked, and the socket's
8017
+ * next reconnect retries on its own.
8018
+ *
8019
+ * pb.realtime.channel('room:42').onError((err) => {
8020
+ * if (!err.retryable) showJoinDenied()
8021
+ * })
8022
+ */
8023
+ onError(handler) {
8024
+ return this.owner.subscribeError(this.name, handler);
8025
+ }
7833
8026
  /**
7834
8027
  * Broadcast `payload` to the channel's other subscribers (web addition —
7835
8028
  * iOS is receive-only). Joins the channel if it isn't already; queued
@@ -7838,6 +8031,57 @@ var RealtimeChannel = class {
7838
8031
  send(event, payload = {}) {
7839
8032
  this.owner.send(this.name, event, payload);
7840
8033
  }
8034
+ /**
8035
+ * Watch one key of the channel's shared state.
8036
+ *
8037
+ * The handler is called with the CURRENT value straight away when there is
8038
+ * one, and again on every change. That is the difference from `on()`: a
8039
+ * subscriber to an event learns nothing until the next one, while state is
8040
+ * already there when you ask.
8041
+ */
8042
+ onState(key, handler) {
8043
+ return this.owner.subscribeState(this.name, key, handler);
8044
+ }
8045
+ /** Read one key without subscribing. */
8046
+ stateValue(key) {
8047
+ return this.owner.stateOf(this.name).entries.get(key);
8048
+ }
8049
+ /** Write one key. `durable` outlives this connection; `ephemeral` is reaped
8050
+ * when it closes, which is what presence is built on. */
8051
+ setState(key, value, opts = {}) {
8052
+ this.owner.writeState(this.name, key, value, opts.life ?? "durable");
8053
+ }
8054
+ /** Remove a key this connection wrote. */
8055
+ clearState(key) {
8056
+ this.owner.clearState(this.name, key);
8057
+ }
8058
+ /**
8059
+ * Announce this client on the channel. `meta` is whatever other participants
8060
+ * should see — a name, an avatar, a cursor.
8061
+ *
8062
+ * It is ephemeral state keyed by this connection, so it disappears when the
8063
+ * socket does. Nothing has to be un-announced on a crash.
8064
+ */
8065
+ presenceEnter(meta) {
8066
+ this.owner.presenceWrite(this.name, meta);
8067
+ }
8068
+ /** Replace the announced value — a cursor moving is this, at whatever rate
8069
+ * the app likes: the server conflates writes it cannot deliver fast enough. */
8070
+ presenceUpdate(meta) {
8071
+ this.owner.presenceWrite(this.name, meta);
8072
+ }
8073
+ /** Leave early. Closing the socket does this on its own. */
8074
+ presenceLeave() {
8075
+ this.owner.presenceClear(this.name);
8076
+ }
8077
+ /** Everyone else currently announced, this client excluded. */
8078
+ presenceOthers() {
8079
+ return this.owner.presenceOthers(this.name);
8080
+ }
8081
+ /** Watch the announced set. Called on every change. */
8082
+ onPresence(handler) {
8083
+ return this.owner.subscribePresence(this.name, handler);
8084
+ }
7841
8085
  };
7842
8086
  var PalbeRealtime = class {
7843
8087
  rt;
@@ -7847,9 +8091,21 @@ var PalbeRealtime = class {
7847
8091
  // Per-(topic, event) handlers — an event may have multiple handlers
7848
8092
  // (multiple .on calls); entry identity is the unsubscribe token.
7849
8093
  handlers = /* @__PURE__ */ new Set();
8094
+ // Per-topic refusal handlers. Kept apart from `handlers` because they are not
8095
+ // refcounted: registering one must not join a channel, and cancelling the
8096
+ // last one must not leave a channel that still has event handlers on it.
8097
+ errorHandlers = /* @__PURE__ */ new Set();
7850
8098
  // Joins are refcounted per topic so the socket only leaves a topic when
7851
8099
  // its last handler cancels (iOS RealtimeClient parity).
7852
8100
  topicRefcount = /* @__PURE__ */ new Map();
8101
+ // Per-topic shared state, and this connection's identity on each topic (from
8102
+ // the join ack). Presence is keyed by that identity, so a write before the
8103
+ // ack has nowhere to go and is queued until it arrives.
8104
+ states = /* @__PURE__ */ new Map();
8105
+ connIds = /* @__PURE__ */ new Map();
8106
+ pendingPresence = /* @__PURE__ */ new Map();
8107
+ stateWatchers = /* @__PURE__ */ new Set();
8108
+ presenceWatchers = /* @__PURE__ */ new Set();
7853
8109
  constructor(rt) {
7854
8110
  this.rt = rt;
7855
8111
  }
@@ -7892,6 +8148,20 @@ var PalbeRealtime = class {
7892
8148
  get status() {
7893
8149
  return this.statusStore;
7894
8150
  }
8151
+ /**
8152
+ * Hand the socket a fresher credential for the SAME user, without rejoining.
8153
+ *
8154
+ * Called when the app's token rotates. The alternative — letting the channels
8155
+ * die at expiry and rejoining — reaps this connection's ephemeral entries
8156
+ * (its presence, on every channel) and forces a full state resync. A token
8157
+ * getting older is not a disconnection and must not cost one.
8158
+ *
8159
+ * A no-op before a socket exists: the first connect resolves a fresh token
8160
+ * on its own.
8161
+ */
8162
+ refreshToken(token) {
8163
+ this.socket?.sendAccessToken(token);
8164
+ }
7895
8165
  /** @internal */
7896
8166
  subscribe(topic, event, handler) {
7897
8167
  const socket = this.ensureSocket();
@@ -7906,7 +8176,9 @@ var PalbeRealtime = class {
7906
8176
  if (cancelled) return;
7907
8177
  cancelled = true;
7908
8178
  this.handlers.delete(entry);
7909
- const next = (this.topicRefcount.get(topic) ?? 1) - 1;
8179
+ const held = this.topicRefcount.get(topic);
8180
+ if (held === void 0) return;
8181
+ const next = held - 1;
7910
8182
  if (next <= 0) {
7911
8183
  this.topicRefcount.delete(topic);
7912
8184
  socket.leaveTopic(topic);
@@ -7916,6 +8188,25 @@ var PalbeRealtime = class {
7916
8188
  }
7917
8189
  };
7918
8190
  }
8191
+ /**
8192
+ * Register a refusal handler for one topic. Does NOT join: watching for a
8193
+ * refusal is not a subscription, and an app that registers one before its
8194
+ * first `.on()` should not be joining anything yet.
8195
+ *
8196
+ * @internal
8197
+ */
8198
+ subscribeError(topic, handler) {
8199
+ const entry = { topic, handler };
8200
+ this.errorHandlers.add(entry);
8201
+ let cancelled = false;
8202
+ return {
8203
+ cancel: () => {
8204
+ if (cancelled) return;
8205
+ cancelled = true;
8206
+ this.errorHandlers.delete(entry);
8207
+ }
8208
+ };
8209
+ }
7919
8210
  /** @internal */
7920
8211
  send(topic, event, payload) {
7921
8212
  const socket = this.ensureSocket();
@@ -7937,7 +8228,11 @@ var PalbeRealtime = class {
7937
8228
  onReconnect: () => {
7938
8229
  },
7939
8230
  onStateChange: (state) => this.statusStore.setState(state),
7940
- onChannelError: (topic) => this.handleChannelError(topic)
8231
+ onChannelError: (topic) => this.handleChannelError(topic),
8232
+ onChannelRefused: (topic, reason) => this.handleChannelRefused(topic, reason),
8233
+ onStateSnapshot: (topic, body) => this.applySnapshot(topic, body),
8234
+ onStateDiff: (topic, body) => this.applyDiff(topic, body),
8235
+ onJoinInfo: (topic, info) => this.applyJoinInfo(topic, info)
7941
8236
  });
7942
8237
  }
7943
8238
  return this.socket;
@@ -7951,19 +8246,185 @@ var PalbeRealtime = class {
7951
8246
  }
7952
8247
  }
7953
8248
  /**
7954
- * A channel died for good (token-expiry rejoin exhausted N attempts). Drop its
7955
- * handlers + refcount so the app stops expecting events on it, and flip the
7956
- * status observable to `'error'` (the React `useChannel` hook reports this).
7957
- * A later `.on()` for the same topic re-joins from scratch (refcount 1).
8249
+ /**
8250
+ * The server refused a channel. Tell whoever asked, and when the answer is
8251
+ * final stop pretending the app is subscribed to it.
8252
+ *
8253
+ * `unavailable` leaves everything in place: the project could not be reached
8254
+ * to decide, the socket rejoins on its next reconnect, and dropping the app's
8255
+ * handlers would make a momentary outage look like a permanent denial.
8256
+ */
8257
+ handleChannelRefused(topic, reason) {
8258
+ const retryable = RETRYABLE_REASONS.has(reason);
8259
+ for (const entry of this.errorHandlers) {
8260
+ if (entry.topic === topic) entry.handler({ topic, reason, retryable });
8261
+ }
8262
+ if (retryable) return;
8263
+ this.forgetTopic(topic);
8264
+ }
8265
+ /**
8266
+ * A channel died for good — a token-expiry rejoin that exhausted its attempts.
8267
+ * The app must stop expecting anything on it, and the status observable flips
8268
+ * to `'error'` (the React `useChannel` hook reports that).
8269
+ *
8270
+ * It clears the SAME registries a final refusal clears, and used to clear only
8271
+ * the event handlers. A state or presence watcher left behind here waits
8272
+ * forever for a snapshot that cannot arrive, and fires twice if the app ever
8273
+ * re-subscribes — the identical failure its sibling three functions up
8274
+ * describes, on a channel that was given up on rather than refused.
7958
8275
  */
7959
8276
  handleChannelError(topic) {
8277
+ this.forgetTopic(topic);
8278
+ this.statusStore.setState("error");
8279
+ }
8280
+ /** Drop every registration this topic holds. One place, because "which
8281
+ * registries" is a question two callers must never answer differently. */
8282
+ forgetTopic(topic) {
7960
8283
  for (const entry of this.handlers) {
7961
8284
  if (entry.topic === topic) this.handlers.delete(entry);
7962
8285
  }
8286
+ for (const entry of this.stateWatchers) {
8287
+ if (entry.topic === topic) this.stateWatchers.delete(entry);
8288
+ }
8289
+ for (const entry of this.presenceWatchers) {
8290
+ if (entry.topic === topic) this.presenceWatchers.delete(entry);
8291
+ }
8292
+ this.pendingPresence.delete(topic);
8293
+ this.states.delete(topic);
8294
+ this.connIds.delete(topic);
7963
8295
  this.topicRefcount.delete(topic);
7964
- this.statusStore.setState("error");
8296
+ }
8297
+ // ── state ──────────────────────────────────────────────────────────────────
8298
+ /** @internal */
8299
+ stateOf(topic) {
8300
+ let st = this.states.get(topic);
8301
+ if (!st) {
8302
+ st = new ChannelState();
8303
+ this.states.set(topic, st);
8304
+ }
8305
+ return st;
8306
+ }
8307
+ /** @internal */
8308
+ subscribeState(topic, key, handler) {
8309
+ const socket = this.ensureSocket();
8310
+ const entry = { topic, key, handler };
8311
+ this.stateWatchers.add(entry);
8312
+ this.retainTopic(topic, socket);
8313
+ const known = this.stateOf(topic).entries.get(key);
8314
+ if (known !== void 0) handler(known);
8315
+ let cancelled = false;
8316
+ return {
8317
+ cancel: () => {
8318
+ if (cancelled) return;
8319
+ cancelled = true;
8320
+ this.stateWatchers.delete(entry);
8321
+ this.releaseTopic(topic, socket);
8322
+ }
8323
+ };
8324
+ }
8325
+ /** @internal */
8326
+ subscribePresence(topic, handler) {
8327
+ const socket = this.ensureSocket();
8328
+ const entry = { topic, handler };
8329
+ this.presenceWatchers.add(entry);
8330
+ this.retainTopic(topic, socket);
8331
+ handler(this.presenceOthers(topic));
8332
+ let cancelled = false;
8333
+ return {
8334
+ cancel: () => {
8335
+ if (cancelled) return;
8336
+ cancelled = true;
8337
+ this.presenceWatchers.delete(entry);
8338
+ this.releaseTopic(topic, socket);
8339
+ }
8340
+ };
8341
+ }
8342
+ /** @internal */
8343
+ writeState(topic, key, value, life) {
8344
+ const socket = this.ensureSocket();
8345
+ socket.joinTopic(topic);
8346
+ socket.sendStateSet(topic, key, value, life);
8347
+ }
8348
+ /** @internal */
8349
+ clearState(topic, key) {
8350
+ this.ensureSocket().sendStateDel(topic, key);
8351
+ }
8352
+ /** @internal */
8353
+ presenceWrite(topic, meta) {
8354
+ const socket = this.ensureSocket();
8355
+ socket.joinTopic(topic);
8356
+ const conn = this.connIds.get(topic);
8357
+ if (conn === void 0) {
8358
+ this.pendingPresence.set(topic, meta);
8359
+ return;
8360
+ }
8361
+ socket.sendStateSet(topic, PRESENCE_KEY(conn), meta, "ephemeral");
8362
+ }
8363
+ /** @internal */
8364
+ presenceClear(topic) {
8365
+ this.pendingPresence.delete(topic);
8366
+ const conn = this.connIds.get(topic);
8367
+ if (conn === void 0) return;
8368
+ this.ensureSocket().sendStateDel(topic, PRESENCE_KEY(conn));
8369
+ }
8370
+ /** @internal */
8371
+ presenceOthers(topic) {
8372
+ return this.stateOf(topic).presence(this.connIds.get(topic));
8373
+ }
8374
+ retainTopic(topic, socket) {
8375
+ const count = (this.topicRefcount.get(topic) ?? 0) + 1;
8376
+ this.topicRefcount.set(topic, count);
8377
+ if (count === 1) socket.joinTopic(topic);
8378
+ }
8379
+ releaseTopic(topic, socket) {
8380
+ const next = (this.topicRefcount.get(topic) ?? 1) - 1;
8381
+ if (next <= 0) {
8382
+ this.topicRefcount.delete(topic);
8383
+ socket.leaveTopic(topic);
8384
+ return;
8385
+ }
8386
+ this.topicRefcount.set(topic, next);
8387
+ }
8388
+ applySnapshot(topic, body) {
8389
+ this.stateOf(topic).applySnapshot(body);
8390
+ this.notifyState(topic);
8391
+ }
8392
+ applyDiff(topic, body) {
8393
+ if (this.stateOf(topic).applyDiff(body) === "gap") {
8394
+ this.socket?.sendStateResync(topic);
8395
+ return;
8396
+ }
8397
+ this.notifyState(topic);
8398
+ }
8399
+ applyJoinInfo(topic, info) {
8400
+ if (typeof info.conn !== "string" || info.conn === "") return;
8401
+ this.connIds.set(topic, info.conn);
8402
+ const seq = info.state_seq;
8403
+ if (typeof seq === "number" && seq < this.stateOf(topic).seq) {
8404
+ this.stateOf(topic).applySnapshot({ seq, entries: {} });
8405
+ }
8406
+ const held = this.pendingPresence.get(topic);
8407
+ if (held !== void 0) {
8408
+ this.pendingPresence.delete(topic);
8409
+ this.socket?.sendStateSet(topic, PRESENCE_KEY(info.conn), held, "ephemeral");
8410
+ }
8411
+ this.notifyState(topic);
8412
+ }
8413
+ notifyState(topic) {
8414
+ this.statusStore.recordEvent(/* @__PURE__ */ new Date());
8415
+ const st = this.stateOf(topic);
8416
+ for (const w of this.stateWatchers) {
8417
+ if (w.topic === topic) w.handler(st.entries.get(w.key));
8418
+ }
8419
+ if (this.presenceWatchers.size > 0) {
8420
+ const others = this.presenceOthers(topic);
8421
+ for (const w of this.presenceWatchers) {
8422
+ if (w.topic === topic) w.handler(others);
8423
+ }
8424
+ }
7965
8425
  }
7966
8426
  };
8427
+ var PRESENCE_KEY = (conn) => `$p:${conn}`;
7967
8428
 
7968
8429
  // src/storage.ts
7969
8430
  function memorySessionStorage() {
@@ -8020,7 +8481,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
8020
8481
  }
8021
8482
 
8022
8483
  // src/version.ts
8023
- var VERSION = "7.3.9";
8484
+ var VERSION = "7.4.0";
8024
8485
 
8025
8486
  // src/internal.ts
8026
8487
  function getRuntime() {
@@ -8030,6 +8491,7 @@ function getRuntime() {
8030
8491
  }
8031
8492
 
8032
8493
  // src/upload.ts
8494
+ var UPLOAD_PATH = "/v1/storage/upload";
8033
8495
  function abortError() {
8034
8496
  return new BackendError("network", { code: "aborted", message: "Upload aborted" });
8035
8497
  }
@@ -8083,7 +8545,9 @@ async function buildHeaders(rt, extra) {
8083
8545
  }
8084
8546
  function buildForm(options) {
8085
8547
  const form = new FormData();
8086
- for (const [k, v] of Object.entries(options.fields ?? {})) form.append(k, v);
8548
+ const { body, ...loose } = options.fields ?? {};
8549
+ if (body !== void 0) form.append("body", body);
8550
+ else if (Object.keys(loose).length > 0) form.append("body", JSON.stringify(loose));
8087
8551
  const file = options.contentType && options.file.type !== options.contentType ? new Blob([options.file], { type: options.contentType }) : options.file;
8088
8552
  const filename = options.filename ?? (typeof File !== "undefined" && options.file instanceof File ? options.file.name : "file");
8089
8553
  form.append("file", file, filename);
@@ -8157,8 +8621,8 @@ async function uploadViaFetch(url, form, headers, options) {
8157
8621
  async function uploadEndpoint(resolveRt, name, options) {
8158
8622
  const rt = resolveRt();
8159
8623
  checkConstraints(options);
8160
- const path = name.startsWith("/") ? name : `/${name}`;
8161
- const url = `${rt.config.url}${path}`;
8624
+ const route = name.startsWith("/") ? name : `/${name}`;
8625
+ const url = `${rt.config.url}${UPLOAD_PATH}${route}`;
8162
8626
  const headers = await buildHeaders(rt, options.headers);
8163
8627
  const form = buildForm(options);
8164
8628
  const useXHR = options.onProgress !== void 0 && typeof XMLHttpRequest !== "undefined";