@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
@@ -298,6 +298,15 @@ function parseEnvironmentRef(apiKey) {
298
298
  var MAX_RETRIES = 3;
299
299
  var INITIAL_BACKOFF_MS = 200;
300
300
  var MAX_RETRY_DELAY_MS = 1e4;
301
+ function withRetryHint(body, response) {
302
+ if (response.status !== 429) return body;
303
+ const data = body?.data;
304
+ const alreadyStated = typeof body?.retry_after === "number" || typeof data === "object" && data !== null && "retryAfter" in data;
305
+ if (alreadyStated) return body;
306
+ const seconds = Number.parseInt(response.headers.get("Retry-After") ?? "", 10);
307
+ if (Number.isNaN(seconds) || seconds <= 0) return body;
308
+ return { ...body, retry_after: seconds };
309
+ }
301
310
  var HttpClient = class _HttpClient {
302
311
  apiKey;
303
312
  options;
@@ -473,7 +482,7 @@ var HttpClient = class _HttpClient {
473
482
  errorBody?.error ?? "unknown_error",
474
483
  errorBody?.error_description ?? response.statusText,
475
484
  response.status,
476
- errorBody
485
+ withRetryHint(errorBody, response)
477
486
  ),
478
487
  status: response.status
479
488
  };
@@ -596,6 +605,15 @@ function isFieldErrorArray(value) {
596
605
  (v) => typeof v === "object" && v !== null && typeof v.field === "string" && typeof v.message === "string"
597
606
  );
598
607
  }
608
+ function fieldErrorsOf(envelope) {
609
+ const scoped = pickField(pickField(envelope, "data"), "fields");
610
+ if (isFieldErrorArray(scoped)) return scoped;
611
+ const flat = pickField(envelope, "fields");
612
+ return isFieldErrorArray(flat) ? flat : void 0;
613
+ }
614
+ function retryAfterOf(envelope) {
615
+ return pickNumber(envelope, "retry_after") ?? pickNumber(pickField(envelope, "data"), "retryAfter");
616
+ }
599
617
  function pickField(value, key) {
600
618
  if (typeof value === "object" && value !== null) {
601
619
  return value[key];
@@ -621,20 +639,15 @@ function fromPalbaseError(err) {
621
639
  if (err.code === "network_error") return new BackendError("network", base);
622
640
  if (err.status === 401) return new BackendError("unauthorized", base);
623
641
  if (err.status === 429)
624
- return new BackendError("rateLimited", {
625
- ...base,
626
- retryAfter: pickNumber(err.details, "retry_after")
627
- });
628
- const nested = pickField(err.details, "details");
629
- if (err.status === 400 && isFieldErrorArray(nested))
630
- return new BackendError("validation", { ...base, fields: nested });
642
+ return new BackendError("rateLimited", { ...base, retryAfter: retryAfterOf(err.details) });
643
+ const fields = fieldErrorsOf(err.details);
644
+ if (err.status === 400 && fields) return new BackendError("validation", { ...base, fields });
631
645
  return new BackendError("server", base);
632
646
  }
633
647
  function fromEnvelope(status, body) {
634
648
  const code = pickString(body, "error") ?? "http_error";
635
649
  const message = pickString(body, "error_description") ?? `HTTP ${status}`;
636
650
  const requestId = pickString(body, "request_id");
637
- const details = pickField(body, "details");
638
651
  const params = {
639
652
  code,
640
653
  message,
@@ -644,13 +657,9 @@ function fromEnvelope(status, body) {
644
657
  };
645
658
  if (status === 401) return new BackendError("unauthorized", params);
646
659
  if (status === 429)
647
- return new BackendError("rateLimited", {
648
- ...params,
649
- // Real 429 wire body has TOP-LEVEL retry_after; nested details is a fallback.
650
- retryAfter: pickNumber(body, "retry_after") ?? pickNumber(details, "retry_after")
651
- });
652
- if (status === 400 && isFieldErrorArray(details))
653
- return new BackendError("validation", { ...params, fields: details });
660
+ return new BackendError("rateLimited", { ...params, retryAfter: retryAfterOf(body) });
661
+ const fields = fieldErrorsOf(body);
662
+ if (status === 400 && fields) return new BackendError("validation", { ...params, fields });
654
663
  return new BackendError("server", params);
655
664
  }
656
665
  function isBackendError(e) {
@@ -1875,24 +1884,29 @@ var PalbeAuth = class {
1875
1884
  * Self-service account erasure ("right to be forgotten"). Calls palauth
1876
1885
  * `DELETE /auth/user` (session-authed + fresh re-auth): password users pass
1877
1886
  * `{ password }`; passwordless/OAuth users rely on a freshly stepped-up
1878
- * session. On 202 the erasure workflow is durably queued AND every session is
1879
- * already revoked server-side — so we tear down local state (mirroring
1880
- * `signOut()`'s clear, minus the pointless `/logout`) and emit
1881
- * `signedOut{reason:'accountDeleted'}`.
1887
+ * session. The handler answers **204 with no body** the erasure has already
1888
+ * finished and every session is already revoked server-side — so we tear down
1889
+ * local state (mirroring `signOut()`'s clear, minus the pointless `/logout`)
1890
+ * and emit `signedOut{reason:'accountDeleted'}`.
1891
+ *
1892
+ * Returns nothing, because there is nothing to return: the handler used to
1893
+ * answer 202 with an `{erasure_id}` naming an asynchronous run, and that id
1894
+ * was removed along with the run (`gdpr_handlers.go:221-229`). Reading it
1895
+ * here threw a `TypeError` AFTER the account was already gone — a success
1896
+ * reported as a failure.
1882
1897
  *
1883
1898
  * On 401 `reauth_required` / 503 `not_configured` (or `erasure_unavailable`)
1884
1899
  * the request throws BEFORE the teardown line, so the local session is left
1885
1900
  * fully intact — the deletion did not happen and the user is still signed in.
1886
1901
  */
1887
1902
  async deleteAccount(params = {}) {
1888
- const res = await palbeRequest(this.rt, "DELETE", "/auth/user", {
1903
+ await palbeRequest(this.rt, "DELETE", "/auth/user", {
1889
1904
  body: params.password ? { password: params.password } : {}
1890
1905
  });
1891
1906
  this.nextSignOutReason = "accountDeleted";
1892
1907
  this.rt.tokenManager.clearSession();
1893
1908
  this.rt.storage.clear();
1894
1909
  this.cachedUser = null;
1895
- return { erasureId: res.erasure_id };
1896
1910
  }
1897
1911
  /**
1898
1912
  * @internal — invoked by the transport choke point (`request.ts`), not app code.
@@ -2379,9 +2393,15 @@ var PalbeCalls = class {
2379
2393
  * @param media - Which media tracks to publish (default: audio + video)
2380
2394
  * @returns A `Call` in `connecting` state; transitions to `active` once the media room connects.
2381
2395
  *
2382
- * @throws BackendError('network', { code: 'call_service_unavailable' }) if the SFU is down (503)
2383
- * @throws BackendError('forbidden', { code: 'not_group_member' }) if the caller is not a member (403)
2384
- * @throws BackendError('conflict', { code: 'call_room_full' }) if the room is full (409)
2396
+ * Every failure below arrives as kind `'server'` this SDK's error kinds are
2397
+ * `notConfigured | validation | unauthorized | rateLimited | server | network
2398
+ * | decode`, and only a 401, a 429 or a 400 carrying a field-error array gets
2399
+ * its own kind. So branch on `err.code`, never on the kind.
2400
+ *
2401
+ * @throws BackendError('server', { code: 'calling_unavailable' }) no SFU configured on this stack (503)
2402
+ * @throws BackendError('server', { code: 'not_group_member' }) the caller is not a member of the group (403)
2403
+ * @throws BackendError('server', { code: 'call_room_full' }) the call is at its participant limit (409)
2404
+ * @throws BackendError('server', { code: 'call_provider_error' }) the media provider failed upstream (502)
2385
2405
  */
2386
2406
  async start(groupId, { media = ["audio", "video"] } = {}) {
2387
2407
  if (!groupId) {
@@ -2402,7 +2422,11 @@ var PalbeCalls = class {
2402
2422
  * Accept an incoming call in a messaging group.
2403
2423
  *
2404
2424
  * @param groupId - Messaging group display-id
2405
- * @param callId - Call identifier (from the push notification payload)
2425
+ * @param callId - Call identifier, carried by the `call_invite` the server
2426
+ * fans onto the group's conversation topic
2427
+ * (`pb.realtime.channel('messaging:conv:<rfcGroupId>').on('call_invite', …)`).
2428
+ * This SDK ships no web push: on the web the invite arrives over realtime or
2429
+ * over whatever signalling the app already runs.
2406
2430
  * @param media - Which media tracks to publish (default: audio + video)
2407
2431
  */
2408
2432
  async accept(groupId, callId, { media = ["audio", "video"] } = {}) {
@@ -4505,14 +4529,11 @@ var Chat = class {
4505
4529
  get title() {
4506
4530
  if (this.titleOverride) return this.titleOverride;
4507
4531
  if (this._group?.name) return this._group.name;
4508
- if (this._draft) {
4509
- const mode = this._draft.mode;
4510
- if (mode.kind === "direct") {
4511
- const peer = this.memberCache.find((m) => m.userId === mode.peerUserId);
4512
- return peer?.displayName ?? "Chat";
4513
- }
4514
- return "Group";
4532
+ if (this.kind === "direct") {
4533
+ const peer = this.memberCache.find((m) => !m.isSelf);
4534
+ return peer?.displayName ?? "Chat";
4515
4535
  }
4536
+ if (this._draft) return "Group";
4516
4537
  const tail = this.id.startsWith("grp_") ? this.id.slice(4) : this.id;
4517
4538
  const short = tail.slice(-4);
4518
4539
  return short ? `Group ${short}` : "Group";
@@ -5486,6 +5507,11 @@ var MessageHub = class {
5486
5507
  emitConv(displayId, e) {
5487
5508
  for (const fn of this.convListeners.get(displayId) ?? []) fn(e);
5488
5509
  }
5510
+ /** How many live conv listeners a group still has — the census the LAST
5511
+ * subscriber reads before tearing the conv topic (and its presence) down. */
5512
+ convListenerCount(displayId) {
5513
+ return this.convListeners.get(displayId)?.size ?? 0;
5514
+ }
5489
5515
  add(map, key, fn) {
5490
5516
  let set = map.get(key);
5491
5517
  if (!set) {
@@ -5523,6 +5549,9 @@ var MessageDeliverySource = class {
5523
5549
  started = false;
5524
5550
  wakeUnsub = null;
5525
5551
  // Per-observed-group conv subscription + heartbeat (presence/typing/read).
5552
+ // `teardown` is the ONE departure path: it stops the heartbeat, broadcasts the
5553
+ // offline frame and drops the subscriptions, so no caller can tear an
5554
+ // observation down without saying goodbye (see observeConversation).
5526
5555
  observed = /* @__PURE__ */ new Map();
5527
5556
  /** Subscribe the device wake topic + run an initial drain. Idempotent. */
5528
5557
  async start() {
@@ -5547,10 +5576,7 @@ var MessageDeliverySource = class {
5547
5576
  stop() {
5548
5577
  this.wakeUnsub?.();
5549
5578
  this.wakeUnsub = null;
5550
- for (const [, o] of this.observed) {
5551
- o.unsub();
5552
- if (o.heartbeat) clearInterval(o.heartbeat);
5553
- }
5579
+ for (const [, o] of this.observed) o.teardown();
5554
5580
  this.observed.clear();
5555
5581
  this.started = false;
5556
5582
  }
@@ -5781,14 +5807,26 @@ var MessageDeliverySource = class {
5781
5807
  emitPresence(true);
5782
5808
  const heartbeat = setInterval(() => emitPresence(true), 25e3);
5783
5809
  this.observed.set(group.displayId, {
5784
- unsub: () => {
5810
+ teardown: () => {
5811
+ clearInterval(heartbeat);
5812
+ try {
5813
+ emitPresence(false);
5814
+ } catch {
5815
+ }
5785
5816
  for (const s of subs) s.cancel();
5786
- },
5787
- heartbeat
5817
+ }
5788
5818
  });
5789
5819
  } catch {
5790
5820
  }
5791
5821
  }
5822
+ /** Stop observing a group's conv topic: announce OFFLINE, stop the heartbeat
5823
+ * and drop the subscriptions. Idempotent — an unobserved group is a no-op. */
5824
+ unobserveConversation(group) {
5825
+ const o = this.observed.get(group.displayId);
5826
+ if (!o) return;
5827
+ this.observed.delete(group.displayId);
5828
+ o.teardown();
5829
+ }
5792
5830
  /** Announce typing on a group's conv topic (the app calls per keystroke). */
5793
5831
  setTyping(group, isTyping) {
5794
5832
  this.observeConversation(group);
@@ -8016,7 +8054,11 @@ var MessagingCoordinator = class {
8016
8054
  subscribeLive(group, chat) {
8017
8055
  let offMsg = null;
8018
8056
  let offConv = null;
8057
+ let resolved = null;
8058
+ let cancelled = false;
8019
8059
  void this.resolve().then((r) => {
8060
+ if (cancelled) return;
8061
+ resolved = r;
8020
8062
  offMsg = r.hub.onMessage(group.displayId, (m) => {
8021
8063
  void chat.ingestLive(m);
8022
8064
  });
@@ -8024,8 +8066,12 @@ var MessagingCoordinator = class {
8024
8066
  offConv = r.hub.onConv(group.displayId, (e) => chat.applyConv(e.event, e.payload));
8025
8067
  });
8026
8068
  return () => {
8069
+ cancelled = true;
8027
8070
  offMsg?.();
8028
8071
  offConv?.();
8072
+ if (resolved && resolved.hub.convListenerCount(group.displayId) === 0) {
8073
+ resolved.source.unobserveConversation(group);
8074
+ }
8029
8075
  };
8030
8076
  }
8031
8077
  async userIdForDevice(group, deviceId) {
@@ -8883,8 +8929,33 @@ function unwrapBroadcast(frame) {
8883
8929
  function stripRealtimePrefix(topic) {
8884
8930
  return topic.startsWith(REALTIME_PREFIX) ? topic.slice(REALTIME_PREFIX.length) : topic;
8885
8931
  }
8932
+ function encodeStateSet(joinRef, ref, topic, key, value, life) {
8933
+ return JSON.stringify([
8934
+ joinRef,
8935
+ String(ref),
8936
+ REALTIME_PREFIX + topic,
8937
+ "state_set",
8938
+ { key, value, life }
8939
+ ]);
8940
+ }
8941
+ function encodeStateDel(joinRef, ref, topic, key) {
8942
+ return JSON.stringify([joinRef, String(ref), REALTIME_PREFIX + topic, "state_del", { key }]);
8943
+ }
8944
+ function encodeStateResync(joinRef, ref, topic) {
8945
+ return JSON.stringify([joinRef, String(ref), REALTIME_PREFIX + topic, "state_resync", {}]);
8946
+ }
8947
+ function encodeAccessToken(joinRef, ref, topic, token) {
8948
+ return JSON.stringify([
8949
+ joinRef,
8950
+ String(ref),
8951
+ REALTIME_PREFIX + topic,
8952
+ "access_token",
8953
+ { access_token: token }
8954
+ ]);
8955
+ }
8886
8956
 
8887
8957
  // src/realtime/connection.ts
8958
+ var RETRYABLE_REFUSALS = /* @__PURE__ */ new Set(["unavailable", "rate_limited", "too_many_channels"]);
8888
8959
  var WS_OPEN = 1;
8889
8960
  var DEFAULT_HEARTBEAT_MS = 25e3;
8890
8961
  var DEFAULT_MAX_BACKOFF_SECONDS = 30;
@@ -9117,6 +9188,31 @@ var RealtimeSocket = class {
9117
9188
  this.handleChannelClose(frame);
9118
9189
  return;
9119
9190
  }
9191
+ if (frame.event === "state_snapshot") {
9192
+ this.handlers?.onStateSnapshot(stripRealtimePrefix(frame.topic), frame.payload);
9193
+ return;
9194
+ }
9195
+ if (frame.event === "state_diff") {
9196
+ this.handlers?.onStateDiff(stripRealtimePrefix(frame.topic), frame.payload);
9197
+ return;
9198
+ }
9199
+ if (frame.event === "phx_reply") {
9200
+ const body = frame.payload;
9201
+ const topic = stripRealtimePrefix(frame.topic);
9202
+ if (body && body.status === "ok" && body.response && typeof body.response === "object") {
9203
+ this.handlers?.onJoinInfo(topic, body.response);
9204
+ return;
9205
+ }
9206
+ if (body && body.status === "error" && String(frame.ref) === this.joinRefs.get(topic)) {
9207
+ const reason = typeof body.response?.["reason"] === "string" ? body.response["reason"] : "unauthorized";
9208
+ if (!RETRYABLE_REFUSALS.has(reason)) {
9209
+ this.joinedTopics.delete(topic);
9210
+ this.joinRefs.delete(topic);
9211
+ }
9212
+ this.handlers?.onChannelRefused(topic, reason);
9213
+ }
9214
+ return;
9215
+ }
9120
9216
  const inbound = unwrapBroadcast(frame);
9121
9217
  if (inbound) {
9122
9218
  this.channelRejoinAttempts.delete(inbound.topic);
@@ -9157,6 +9253,40 @@ var RealtimeSocket = class {
9157
9253
  }, delayMs);
9158
9254
  this.channelRejoinTimers.set(topic, timer);
9159
9255
  }
9256
+ // MARK: state
9257
+ /** Write one entry on a joined channel's state. Dropped when the topic has no
9258
+ * live join: the server refuses state on a channel this socket is not on, and
9259
+ * the reconnect's join brings a fresh snapshot anyway. */
9260
+ sendStateSet(topic, key, value, life) {
9261
+ const joinRef = this.joinRefs.get(topic);
9262
+ if (joinRef === void 0 || !this.isOpen()) return;
9263
+ this.send(encodeStateSet(joinRef, this.nextRef(), topic, key, value, life));
9264
+ }
9265
+ /** Remove one entry. The server refuses a key this connection does not own. */
9266
+ sendStateDel(topic, key) {
9267
+ const joinRef = this.joinRefs.get(topic);
9268
+ if (joinRef === void 0 || !this.isOpen()) return;
9269
+ this.send(encodeStateDel(joinRef, this.nextRef(), topic, key));
9270
+ }
9271
+ /** Ask for a fresh snapshot — the answer to a detected gap. */
9272
+ sendStateResync(topic) {
9273
+ const joinRef = this.joinRefs.get(topic);
9274
+ if (joinRef === void 0 || !this.isOpen()) return;
9275
+ this.send(encodeStateResync(joinRef, this.nextRef(), topic));
9276
+ }
9277
+ /**
9278
+ * Present a fresher credential on every joined channel WITHOUT rejoining.
9279
+ *
9280
+ * The alternative — dropping and rejoining — reaps this connection's
9281
+ * ephemeral entries (its presence) and forces a full resync on every channel.
9282
+ * A token rotation is not a reconnection and must not look like one.
9283
+ */
9284
+ sendAccessToken(token) {
9285
+ if (!this.isOpen()) return;
9286
+ for (const [topic, joinRef] of this.joinRefs) {
9287
+ this.send(encodeAccessToken(joinRef, this.nextRef(), topic, token));
9288
+ }
9289
+ }
9160
9290
  // MARK: helpers
9161
9291
  /** Drop a single topic's token-expiry rejoin state (cancel its pending timer). */
9162
9292
  clearChannelRejoin(topic) {
@@ -9182,6 +9312,60 @@ var RealtimeSocket = class {
9182
9312
  }
9183
9313
  };
9184
9314
 
9315
+ // src/realtime/state.ts
9316
+ var PRESENCE_PREFIX = "$p:";
9317
+ var ChannelState = class {
9318
+ /** The sequence this local copy is current as of. */
9319
+ seq = 0;
9320
+ /** key → value. Lifetimes are the server's business; a reader only needs the
9321
+ * values, and presence is derived from the key shape. */
9322
+ entries = /* @__PURE__ */ new Map();
9323
+ /** Replace everything. A snapshot is the WHOLE picture, never a merge —
9324
+ * merging one would keep entries the server has since removed. */
9325
+ applySnapshot(body) {
9326
+ this.entries.clear();
9327
+ for (const [k, e] of Object.entries(body.entries ?? {})) {
9328
+ this.entries.set(k, e.v);
9329
+ }
9330
+ this.seq = body.seq;
9331
+ }
9332
+ /**
9333
+ * Apply a diff, or report that it cannot be applied.
9334
+ *
9335
+ * `gap` when this copy is behind the diff's starting point: frames were
9336
+ * missed, and applying anyway would produce a state that never existed on the
9337
+ * server. The state is left untouched so the caller can resync from something
9338
+ * consistent.
9339
+ *
9340
+ * A diff at or below the current sequence is already applied — a resend after
9341
+ * a retry — and is silently accepted so duplicates are harmless.
9342
+ */
9343
+ applyDiff(body) {
9344
+ if (this.seq < body.from_seq) return "gap";
9345
+ if (body.seq <= this.seq) return "applied";
9346
+ for (const [k, e] of Object.entries(body.set ?? {})) this.entries.set(k, e.v);
9347
+ for (const k of body.del ?? []) this.entries.delete(k);
9348
+ this.seq = body.seq;
9349
+ return "applied";
9350
+ }
9351
+ /**
9352
+ * Everyone else's presence.
9353
+ *
9354
+ * `selfConn` is this connection's id, as the join reply reported it. Its own
9355
+ * entry is excluded because an app rendering "who else is here" would
9356
+ * otherwise always show one extra face — itself.
9357
+ */
9358
+ presence(selfConn) {
9359
+ const own = selfConn === void 0 ? void 0 : PRESENCE_PREFIX + selfConn;
9360
+ const out = [];
9361
+ for (const [k, v] of this.entries) {
9362
+ if (!k.startsWith(PRESENCE_PREFIX) || k === own) continue;
9363
+ out.push(v);
9364
+ }
9365
+ return out;
9366
+ }
9367
+ };
9368
+
9185
9369
  // src/realtime/facade.ts
9186
9370
  var StatusStore = class {
9187
9371
  currentState = "idle";
@@ -9217,6 +9401,7 @@ var StatusStore = class {
9217
9401
  for (const listener of this.listeners) listener(snapshot);
9218
9402
  }
9219
9403
  };
9404
+ var RETRYABLE_REASONS = /* @__PURE__ */ new Set(["unavailable", "rate_limited", "too_many_channels"]);
9220
9405
  var RealtimeChannel = class {
9221
9406
  /** The app-defined channel name (bare sub-topic, no "realtime:" prefix). */
9222
9407
  name;
@@ -9234,6 +9419,23 @@ var RealtimeChannel = class {
9234
9419
  on(event, handler) {
9235
9420
  return this.owner.subscribe(this.name, event, handler);
9236
9421
  }
9422
+ /**
9423
+ * Called when the server REFUSES this channel.
9424
+ *
9425
+ * A join is an authorization decision, and a decision the app never hears is
9426
+ * the same as no decision at all: silence looks exactly like a slow network.
9427
+ * This is where a refusal arrives, carrying the server's own distinction —
9428
+ * `retryable: false` means "you may not" and asking again will not help;
9429
+ * `retryable: true` means the project could not be asked, and the socket's
9430
+ * next reconnect retries on its own.
9431
+ *
9432
+ * pb.realtime.channel('room:42').onError((err) => {
9433
+ * if (!err.retryable) showJoinDenied()
9434
+ * })
9435
+ */
9436
+ onError(handler) {
9437
+ return this.owner.subscribeError(this.name, handler);
9438
+ }
9237
9439
  /**
9238
9440
  * Broadcast `payload` to the channel's other subscribers (web addition —
9239
9441
  * iOS is receive-only). Joins the channel if it isn't already; queued
@@ -9242,6 +9444,57 @@ var RealtimeChannel = class {
9242
9444
  send(event, payload = {}) {
9243
9445
  this.owner.send(this.name, event, payload);
9244
9446
  }
9447
+ /**
9448
+ * Watch one key of the channel's shared state.
9449
+ *
9450
+ * The handler is called with the CURRENT value straight away when there is
9451
+ * one, and again on every change. That is the difference from `on()`: a
9452
+ * subscriber to an event learns nothing until the next one, while state is
9453
+ * already there when you ask.
9454
+ */
9455
+ onState(key, handler) {
9456
+ return this.owner.subscribeState(this.name, key, handler);
9457
+ }
9458
+ /** Read one key without subscribing. */
9459
+ stateValue(key) {
9460
+ return this.owner.stateOf(this.name).entries.get(key);
9461
+ }
9462
+ /** Write one key. `durable` outlives this connection; `ephemeral` is reaped
9463
+ * when it closes, which is what presence is built on. */
9464
+ setState(key, value, opts = {}) {
9465
+ this.owner.writeState(this.name, key, value, opts.life ?? "durable");
9466
+ }
9467
+ /** Remove a key this connection wrote. */
9468
+ clearState(key) {
9469
+ this.owner.clearState(this.name, key);
9470
+ }
9471
+ /**
9472
+ * Announce this client on the channel. `meta` is whatever other participants
9473
+ * should see — a name, an avatar, a cursor.
9474
+ *
9475
+ * It is ephemeral state keyed by this connection, so it disappears when the
9476
+ * socket does. Nothing has to be un-announced on a crash.
9477
+ */
9478
+ presenceEnter(meta) {
9479
+ this.owner.presenceWrite(this.name, meta);
9480
+ }
9481
+ /** Replace the announced value — a cursor moving is this, at whatever rate
9482
+ * the app likes: the server conflates writes it cannot deliver fast enough. */
9483
+ presenceUpdate(meta) {
9484
+ this.owner.presenceWrite(this.name, meta);
9485
+ }
9486
+ /** Leave early. Closing the socket does this on its own. */
9487
+ presenceLeave() {
9488
+ this.owner.presenceClear(this.name);
9489
+ }
9490
+ /** Everyone else currently announced, this client excluded. */
9491
+ presenceOthers() {
9492
+ return this.owner.presenceOthers(this.name);
9493
+ }
9494
+ /** Watch the announced set. Called on every change. */
9495
+ onPresence(handler) {
9496
+ return this.owner.subscribePresence(this.name, handler);
9497
+ }
9245
9498
  };
9246
9499
  var PalbeRealtime = class {
9247
9500
  rt;
@@ -9251,9 +9504,21 @@ var PalbeRealtime = class {
9251
9504
  // Per-(topic, event) handlers — an event may have multiple handlers
9252
9505
  // (multiple .on calls); entry identity is the unsubscribe token.
9253
9506
  handlers = /* @__PURE__ */ new Set();
9507
+ // Per-topic refusal handlers. Kept apart from `handlers` because they are not
9508
+ // refcounted: registering one must not join a channel, and cancelling the
9509
+ // last one must not leave a channel that still has event handlers on it.
9510
+ errorHandlers = /* @__PURE__ */ new Set();
9254
9511
  // Joins are refcounted per topic so the socket only leaves a topic when
9255
9512
  // its last handler cancels (iOS RealtimeClient parity).
9256
9513
  topicRefcount = /* @__PURE__ */ new Map();
9514
+ // Per-topic shared state, and this connection's identity on each topic (from
9515
+ // the join ack). Presence is keyed by that identity, so a write before the
9516
+ // ack has nowhere to go and is queued until it arrives.
9517
+ states = /* @__PURE__ */ new Map();
9518
+ connIds = /* @__PURE__ */ new Map();
9519
+ pendingPresence = /* @__PURE__ */ new Map();
9520
+ stateWatchers = /* @__PURE__ */ new Set();
9521
+ presenceWatchers = /* @__PURE__ */ new Set();
9257
9522
  constructor(rt) {
9258
9523
  this.rt = rt;
9259
9524
  }
@@ -9296,6 +9561,20 @@ var PalbeRealtime = class {
9296
9561
  get status() {
9297
9562
  return this.statusStore;
9298
9563
  }
9564
+ /**
9565
+ * Hand the socket a fresher credential for the SAME user, without rejoining.
9566
+ *
9567
+ * Called when the app's token rotates. The alternative — letting the channels
9568
+ * die at expiry and rejoining — reaps this connection's ephemeral entries
9569
+ * (its presence, on every channel) and forces a full state resync. A token
9570
+ * getting older is not a disconnection and must not cost one.
9571
+ *
9572
+ * A no-op before a socket exists: the first connect resolves a fresh token
9573
+ * on its own.
9574
+ */
9575
+ refreshToken(token) {
9576
+ this.socket?.sendAccessToken(token);
9577
+ }
9299
9578
  /** @internal */
9300
9579
  subscribe(topic, event, handler) {
9301
9580
  const socket = this.ensureSocket();
@@ -9310,7 +9589,9 @@ var PalbeRealtime = class {
9310
9589
  if (cancelled) return;
9311
9590
  cancelled = true;
9312
9591
  this.handlers.delete(entry);
9313
- const next = (this.topicRefcount.get(topic) ?? 1) - 1;
9592
+ const held = this.topicRefcount.get(topic);
9593
+ if (held === void 0) return;
9594
+ const next = held - 1;
9314
9595
  if (next <= 0) {
9315
9596
  this.topicRefcount.delete(topic);
9316
9597
  socket.leaveTopic(topic);
@@ -9320,6 +9601,25 @@ var PalbeRealtime = class {
9320
9601
  }
9321
9602
  };
9322
9603
  }
9604
+ /**
9605
+ * Register a refusal handler for one topic. Does NOT join: watching for a
9606
+ * refusal is not a subscription, and an app that registers one before its
9607
+ * first `.on()` should not be joining anything yet.
9608
+ *
9609
+ * @internal
9610
+ */
9611
+ subscribeError(topic, handler) {
9612
+ const entry = { topic, handler };
9613
+ this.errorHandlers.add(entry);
9614
+ let cancelled = false;
9615
+ return {
9616
+ cancel: () => {
9617
+ if (cancelled) return;
9618
+ cancelled = true;
9619
+ this.errorHandlers.delete(entry);
9620
+ }
9621
+ };
9622
+ }
9323
9623
  /** @internal */
9324
9624
  send(topic, event, payload) {
9325
9625
  const socket = this.ensureSocket();
@@ -9341,7 +9641,11 @@ var PalbeRealtime = class {
9341
9641
  onReconnect: () => {
9342
9642
  },
9343
9643
  onStateChange: (state) => this.statusStore.setState(state),
9344
- onChannelError: (topic) => this.handleChannelError(topic)
9644
+ onChannelError: (topic) => this.handleChannelError(topic),
9645
+ onChannelRefused: (topic, reason) => this.handleChannelRefused(topic, reason),
9646
+ onStateSnapshot: (topic, body) => this.applySnapshot(topic, body),
9647
+ onStateDiff: (topic, body) => this.applyDiff(topic, body),
9648
+ onJoinInfo: (topic, info) => this.applyJoinInfo(topic, info)
9345
9649
  });
9346
9650
  }
9347
9651
  return this.socket;
@@ -9355,19 +9659,185 @@ var PalbeRealtime = class {
9355
9659
  }
9356
9660
  }
9357
9661
  /**
9358
- * A channel died for good (token-expiry rejoin exhausted N attempts). Drop its
9359
- * handlers + refcount so the app stops expecting events on it, and flip the
9360
- * status observable to `'error'` (the React `useChannel` hook reports this).
9361
- * A later `.on()` for the same topic re-joins from scratch (refcount 1).
9662
+ /**
9663
+ * The server refused a channel. Tell whoever asked, and when the answer is
9664
+ * final stop pretending the app is subscribed to it.
9665
+ *
9666
+ * `unavailable` leaves everything in place: the project could not be reached
9667
+ * to decide, the socket rejoins on its next reconnect, and dropping the app's
9668
+ * handlers would make a momentary outage look like a permanent denial.
9669
+ */
9670
+ handleChannelRefused(topic, reason) {
9671
+ const retryable = RETRYABLE_REASONS.has(reason);
9672
+ for (const entry of this.errorHandlers) {
9673
+ if (entry.topic === topic) entry.handler({ topic, reason, retryable });
9674
+ }
9675
+ if (retryable) return;
9676
+ this.forgetTopic(topic);
9677
+ }
9678
+ /**
9679
+ * A channel died for good — a token-expiry rejoin that exhausted its attempts.
9680
+ * The app must stop expecting anything on it, and the status observable flips
9681
+ * to `'error'` (the React `useChannel` hook reports that).
9682
+ *
9683
+ * It clears the SAME registries a final refusal clears, and used to clear only
9684
+ * the event handlers. A state or presence watcher left behind here waits
9685
+ * forever for a snapshot that cannot arrive, and fires twice if the app ever
9686
+ * re-subscribes — the identical failure its sibling three functions up
9687
+ * describes, on a channel that was given up on rather than refused.
9362
9688
  */
9363
9689
  handleChannelError(topic) {
9690
+ this.forgetTopic(topic);
9691
+ this.statusStore.setState("error");
9692
+ }
9693
+ /** Drop every registration this topic holds. One place, because "which
9694
+ * registries" is a question two callers must never answer differently. */
9695
+ forgetTopic(topic) {
9364
9696
  for (const entry of this.handlers) {
9365
9697
  if (entry.topic === topic) this.handlers.delete(entry);
9366
9698
  }
9699
+ for (const entry of this.stateWatchers) {
9700
+ if (entry.topic === topic) this.stateWatchers.delete(entry);
9701
+ }
9702
+ for (const entry of this.presenceWatchers) {
9703
+ if (entry.topic === topic) this.presenceWatchers.delete(entry);
9704
+ }
9705
+ this.pendingPresence.delete(topic);
9706
+ this.states.delete(topic);
9707
+ this.connIds.delete(topic);
9367
9708
  this.topicRefcount.delete(topic);
9368
- this.statusStore.setState("error");
9709
+ }
9710
+ // ── state ──────────────────────────────────────────────────────────────────
9711
+ /** @internal */
9712
+ stateOf(topic) {
9713
+ let st = this.states.get(topic);
9714
+ if (!st) {
9715
+ st = new ChannelState();
9716
+ this.states.set(topic, st);
9717
+ }
9718
+ return st;
9719
+ }
9720
+ /** @internal */
9721
+ subscribeState(topic, key, handler) {
9722
+ const socket = this.ensureSocket();
9723
+ const entry = { topic, key, handler };
9724
+ this.stateWatchers.add(entry);
9725
+ this.retainTopic(topic, socket);
9726
+ const known = this.stateOf(topic).entries.get(key);
9727
+ if (known !== void 0) handler(known);
9728
+ let cancelled = false;
9729
+ return {
9730
+ cancel: () => {
9731
+ if (cancelled) return;
9732
+ cancelled = true;
9733
+ this.stateWatchers.delete(entry);
9734
+ this.releaseTopic(topic, socket);
9735
+ }
9736
+ };
9737
+ }
9738
+ /** @internal */
9739
+ subscribePresence(topic, handler) {
9740
+ const socket = this.ensureSocket();
9741
+ const entry = { topic, handler };
9742
+ this.presenceWatchers.add(entry);
9743
+ this.retainTopic(topic, socket);
9744
+ handler(this.presenceOthers(topic));
9745
+ let cancelled = false;
9746
+ return {
9747
+ cancel: () => {
9748
+ if (cancelled) return;
9749
+ cancelled = true;
9750
+ this.presenceWatchers.delete(entry);
9751
+ this.releaseTopic(topic, socket);
9752
+ }
9753
+ };
9754
+ }
9755
+ /** @internal */
9756
+ writeState(topic, key, value, life) {
9757
+ const socket = this.ensureSocket();
9758
+ socket.joinTopic(topic);
9759
+ socket.sendStateSet(topic, key, value, life);
9760
+ }
9761
+ /** @internal */
9762
+ clearState(topic, key) {
9763
+ this.ensureSocket().sendStateDel(topic, key);
9764
+ }
9765
+ /** @internal */
9766
+ presenceWrite(topic, meta) {
9767
+ const socket = this.ensureSocket();
9768
+ socket.joinTopic(topic);
9769
+ const conn = this.connIds.get(topic);
9770
+ if (conn === void 0) {
9771
+ this.pendingPresence.set(topic, meta);
9772
+ return;
9773
+ }
9774
+ socket.sendStateSet(topic, PRESENCE_KEY(conn), meta, "ephemeral");
9775
+ }
9776
+ /** @internal */
9777
+ presenceClear(topic) {
9778
+ this.pendingPresence.delete(topic);
9779
+ const conn = this.connIds.get(topic);
9780
+ if (conn === void 0) return;
9781
+ this.ensureSocket().sendStateDel(topic, PRESENCE_KEY(conn));
9782
+ }
9783
+ /** @internal */
9784
+ presenceOthers(topic) {
9785
+ return this.stateOf(topic).presence(this.connIds.get(topic));
9786
+ }
9787
+ retainTopic(topic, socket) {
9788
+ const count = (this.topicRefcount.get(topic) ?? 0) + 1;
9789
+ this.topicRefcount.set(topic, count);
9790
+ if (count === 1) socket.joinTopic(topic);
9791
+ }
9792
+ releaseTopic(topic, socket) {
9793
+ const next = (this.topicRefcount.get(topic) ?? 1) - 1;
9794
+ if (next <= 0) {
9795
+ this.topicRefcount.delete(topic);
9796
+ socket.leaveTopic(topic);
9797
+ return;
9798
+ }
9799
+ this.topicRefcount.set(topic, next);
9800
+ }
9801
+ applySnapshot(topic, body) {
9802
+ this.stateOf(topic).applySnapshot(body);
9803
+ this.notifyState(topic);
9804
+ }
9805
+ applyDiff(topic, body) {
9806
+ if (this.stateOf(topic).applyDiff(body) === "gap") {
9807
+ this.socket?.sendStateResync(topic);
9808
+ return;
9809
+ }
9810
+ this.notifyState(topic);
9811
+ }
9812
+ applyJoinInfo(topic, info) {
9813
+ if (typeof info.conn !== "string" || info.conn === "") return;
9814
+ this.connIds.set(topic, info.conn);
9815
+ const seq = info.state_seq;
9816
+ if (typeof seq === "number" && seq < this.stateOf(topic).seq) {
9817
+ this.stateOf(topic).applySnapshot({ seq, entries: {} });
9818
+ }
9819
+ const held = this.pendingPresence.get(topic);
9820
+ if (held !== void 0) {
9821
+ this.pendingPresence.delete(topic);
9822
+ this.socket?.sendStateSet(topic, PRESENCE_KEY(info.conn), held, "ephemeral");
9823
+ }
9824
+ this.notifyState(topic);
9825
+ }
9826
+ notifyState(topic) {
9827
+ this.statusStore.recordEvent(/* @__PURE__ */ new Date());
9828
+ const st = this.stateOf(topic);
9829
+ for (const w of this.stateWatchers) {
9830
+ if (w.topic === topic) w.handler(st.entries.get(w.key));
9831
+ }
9832
+ if (this.presenceWatchers.size > 0) {
9833
+ const others = this.presenceOthers(topic);
9834
+ for (const w of this.presenceWatchers) {
9835
+ if (w.topic === topic) w.handler(others);
9836
+ }
9837
+ }
9369
9838
  }
9370
9839
  };
9840
+ var PRESENCE_KEY = (conn) => `$p:${conn}`;
9371
9841
 
9372
9842
  // src/storage.ts
9373
9843
  function memorySessionStorage() {
@@ -9428,7 +9898,7 @@ function defaultSessionStorage(key) {
9428
9898
  }
9429
9899
 
9430
9900
  // src/version.ts
9431
- var VERSION = "7.3.9";
9901
+ var VERSION = "7.4.0";
9432
9902
 
9433
9903
  // src/runtime.ts
9434
9904
  function buildRuntime(config) {
@@ -9606,6 +10076,7 @@ async function callEndpoint(resolveRt, name, input, options) {
9606
10076
  }
9607
10077
 
9608
10078
  // src/upload.ts
10079
+ var UPLOAD_PATH = "/v1/storage/upload";
9609
10080
  function abortError() {
9610
10081
  return new BackendError("network", { code: "aborted", message: "Upload aborted" });
9611
10082
  }
@@ -9659,7 +10130,9 @@ async function buildHeaders(rt, extra) {
9659
10130
  }
9660
10131
  function buildForm(options) {
9661
10132
  const form = new FormData();
9662
- for (const [k, v] of Object.entries(options.fields ?? {})) form.append(k, v);
10133
+ const { body, ...loose } = options.fields ?? {};
10134
+ if (body !== void 0) form.append("body", body);
10135
+ else if (Object.keys(loose).length > 0) form.append("body", JSON.stringify(loose));
9663
10136
  const file = options.contentType && options.file.type !== options.contentType ? new Blob([options.file], { type: options.contentType }) : options.file;
9664
10137
  const filename = options.filename ?? (typeof File !== "undefined" && options.file instanceof File ? options.file.name : "file");
9665
10138
  form.append("file", file, filename);
@@ -9733,8 +10206,8 @@ async function uploadViaFetch(url, form, headers, options) {
9733
10206
  async function uploadEndpoint(resolveRt, name, options) {
9734
10207
  const rt = resolveRt();
9735
10208
  checkConstraints(options);
9736
- const path = name.startsWith("/") ? name : `/${name}`;
9737
- const url = `${rt.config.url}${path}`;
10209
+ const route = name.startsWith("/") ? name : `/${name}`;
10210
+ const url = `${rt.config.url}${UPLOAD_PATH}${route}`;
9738
10211
  const headers = await buildHeaders(rt, options.headers);
9739
10212
  const form = buildForm(options);
9740
10213
  const useXHR = options.onProgress !== void 0 && typeof XMLHttpRequest !== "undefined";