@palbase/web 7.3.8 → 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 (48) 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-6DGCUHSO.js → chunk-4WMJTUDN.js} +28 -19
  4. package/dist/chunk-4WMJTUDN.js.map +1 -0
  5. package/dist/{chunk-CFDU23TB.js → chunk-G56VGUYZ.js} +3 -3
  6. package/dist/chunk-G56VGUYZ.js.map +1 -0
  7. package/dist/{chunk-HT5SNA5H.js → chunk-VHUCJBBQ.js} +2 -2
  8. package/dist/chunk-VHUCJBBQ.js.map +1 -0
  9. package/dist/{chunk-A42CPPQ7.js → chunk-WXBOPPHU.js} +502 -38
  10. package/dist/chunk-WXBOPPHU.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 +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 +527 -54
  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 +521 -51
  26. package/dist/next/client.cjs.map +1 -1
  27. package/dist/next/client.js +3 -3
  28. package/dist/next/index.cjs +528 -55
  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 +1 -1
  35. package/dist/next/proxy.cjs.map +1 -1
  36. package/dist/next/proxy.js +3 -3
  37. package/dist/{pb-WAQvwCfM.d.cts → pb-9MMDNQ9p.d.cts} +6 -1
  38. package/dist/{pb-BRlmBIAm.d.ts → pb-DRmdWQzG.d.ts} +6 -1
  39. package/dist/react/index.cjs +22 -19
  40. package/dist/react/index.cjs.map +1 -1
  41. package/dist/react/index.d.cts +1 -1
  42. package/dist/react/index.d.ts +1 -1
  43. package/dist/react/index.js +3 -3
  44. package/package.json +1 -1
  45. package/dist/chunk-6DGCUHSO.js.map +0 -1
  46. package/dist/chunk-A42CPPQ7.js.map +0 -1
  47. package/dist/chunk-CFDU23TB.js.map +0 -1
  48. package/dist/chunk-HT5SNA5H.js.map +0 -1
package/dist/internal.cjs CHANGED
@@ -179,13 +179,22 @@ function wirePlatform() {
179
179
  return host === "browser" ? "web" : host;
180
180
  }
181
181
  var PALBASE_DEFAULT_HOST = "api.palbase.studio";
182
- var API_KEY_RE = /^pb_([a-z0-9]{4,24})_c[A-Za-z0-9]{20}$/;
182
+ var API_KEY_RE = /^pb_([a-z0-9]{4,24})_c[A-Za-z0-9]{20,}$/;
183
183
  function parseEnvironmentRef(apiKey) {
184
184
  return API_KEY_RE.exec(apiKey)?.[1] ?? null;
185
185
  }
186
186
  var MAX_RETRIES = 3;
187
187
  var INITIAL_BACKOFF_MS = 200;
188
188
  var MAX_RETRY_DELAY_MS = 1e4;
189
+ function withRetryHint(body, response) {
190
+ if (response.status !== 429) return body;
191
+ const data = body?.data;
192
+ const alreadyStated = typeof body?.retry_after === "number" || typeof data === "object" && data !== null && "retryAfter" in data;
193
+ if (alreadyStated) return body;
194
+ const seconds = Number.parseInt(response.headers.get("Retry-After") ?? "", 10);
195
+ if (Number.isNaN(seconds) || seconds <= 0) return body;
196
+ return { ...body, retry_after: seconds };
197
+ }
189
198
  var HttpClient = class _HttpClient {
190
199
  apiKey;
191
200
  options;
@@ -255,7 +264,7 @@ var HttpClient = class _HttpClient {
255
264
  if (this.apiKey && parseEnvironmentRef(this.apiKey) === null) {
256
265
  throw new PalbaseError(
257
266
  "invalid_api_key",
258
- 'Invalid API key format. Expected pb_{environment_ref}_c{20_base62_chars}. For dev/staging pass `url: "https://api.dev.palbase.studio"` via options.',
267
+ 'Invalid API key format. Expected pb_{environment_ref}_c{at least 20 base62 chars}. For dev/staging pass `url: "https://api.dev.palbase.studio"` via options.',
259
268
  0
260
269
  );
261
270
  }
@@ -361,7 +370,7 @@ var HttpClient = class _HttpClient {
361
370
  errorBody?.error ?? "unknown_error",
362
371
  errorBody?.error_description ?? response.statusText,
363
372
  response.status,
364
- errorBody
373
+ withRetryHint(errorBody, response)
365
374
  ),
366
375
  status: response.status
367
376
  };
@@ -484,6 +493,15 @@ function isFieldErrorArray(value) {
484
493
  (v) => typeof v === "object" && v !== null && typeof v.field === "string" && typeof v.message === "string"
485
494
  );
486
495
  }
496
+ function fieldErrorsOf(envelope) {
497
+ const scoped = pickField(pickField(envelope, "data"), "fields");
498
+ if (isFieldErrorArray(scoped)) return scoped;
499
+ const flat = pickField(envelope, "fields");
500
+ return isFieldErrorArray(flat) ? flat : void 0;
501
+ }
502
+ function retryAfterOf(envelope) {
503
+ return pickNumber(envelope, "retry_after") ?? pickNumber(pickField(envelope, "data"), "retryAfter");
504
+ }
487
505
  function pickField(value, key) {
488
506
  if (typeof value === "object" && value !== null) {
489
507
  return value[key];
@@ -509,20 +527,15 @@ function fromPalbaseError(err) {
509
527
  if (err.code === "network_error") return new BackendError("network", base);
510
528
  if (err.status === 401) return new BackendError("unauthorized", base);
511
529
  if (err.status === 429)
512
- return new BackendError("rateLimited", {
513
- ...base,
514
- retryAfter: pickNumber(err.details, "retry_after")
515
- });
516
- const nested = pickField(err.details, "details");
517
- if (err.status === 400 && isFieldErrorArray(nested))
518
- return new BackendError("validation", { ...base, fields: nested });
530
+ return new BackendError("rateLimited", { ...base, retryAfter: retryAfterOf(err.details) });
531
+ const fields = fieldErrorsOf(err.details);
532
+ if (err.status === 400 && fields) return new BackendError("validation", { ...base, fields });
519
533
  return new BackendError("server", base);
520
534
  }
521
535
  function fromEnvelope(status, body) {
522
536
  const code = pickString(body, "error") ?? "http_error";
523
537
  const message = pickString(body, "error_description") ?? `HTTP ${status}`;
524
538
  const requestId = pickString(body, "request_id");
525
- const details = pickField(body, "details");
526
539
  const params = {
527
540
  code,
528
541
  message,
@@ -532,13 +545,9 @@ function fromEnvelope(status, body) {
532
545
  };
533
546
  if (status === 401) return new BackendError("unauthorized", params);
534
547
  if (status === 429)
535
- return new BackendError("rateLimited", {
536
- ...params,
537
- // Real 429 wire body has TOP-LEVEL retry_after; nested details is a fallback.
538
- retryAfter: pickNumber(body, "retry_after") ?? pickNumber(details, "retry_after")
539
- });
540
- if (status === 400 && isFieldErrorArray(details))
541
- return new BackendError("validation", { ...params, fields: details });
548
+ return new BackendError("rateLimited", { ...params, retryAfter: retryAfterOf(body) });
549
+ const fields = fieldErrorsOf(body);
550
+ if (status === 400 && fields) return new BackendError("validation", { ...params, fields });
542
551
  return new BackendError("server", params);
543
552
  }
544
553
  function isBackendError(e) {
@@ -1594,7 +1603,7 @@ var PalbeAnalytics = class {
1594
1603
  };
1595
1604
 
1596
1605
  // src/api-key.ts
1597
- var PUBLISHABLE_API_KEY_RE = /^pb_([a-z0-9]{4,24})_c[A-Za-z0-9]{20}$/;
1606
+ var PUBLISHABLE_API_KEY_RE = /^pb_([a-z0-9]{4,24})_c[A-Za-z0-9]{20,}$/;
1598
1607
  function environmentRefFromPublishableApiKey(apiKey) {
1599
1608
  const match = PUBLISHABLE_API_KEY_RE.exec(apiKey);
1600
1609
  return match ? match[1] ?? "" : "";
@@ -1866,24 +1875,29 @@ var PalbeAuth = class {
1866
1875
  * Self-service account erasure ("right to be forgotten"). Calls palauth
1867
1876
  * `DELETE /auth/user` (session-authed + fresh re-auth): password users pass
1868
1877
  * `{ password }`; passwordless/OAuth users rely on a freshly stepped-up
1869
- * session. On 202 the erasure workflow is durably queued AND every session is
1870
- * already revoked server-side — so we tear down local state (mirroring
1871
- * `signOut()`'s clear, minus the pointless `/logout`) and emit
1872
- * `signedOut{reason:'accountDeleted'}`.
1878
+ * session. The handler answers **204 with no body** the erasure has already
1879
+ * finished and every session is already revoked server-side — so we tear down
1880
+ * local state (mirroring `signOut()`'s clear, minus the pointless `/logout`)
1881
+ * and emit `signedOut{reason:'accountDeleted'}`.
1882
+ *
1883
+ * Returns nothing, because there is nothing to return: the handler used to
1884
+ * answer 202 with an `{erasure_id}` naming an asynchronous run, and that id
1885
+ * was removed along with the run (`gdpr_handlers.go:221-229`). Reading it
1886
+ * here threw a `TypeError` AFTER the account was already gone — a success
1887
+ * reported as a failure.
1873
1888
  *
1874
1889
  * On 401 `reauth_required` / 503 `not_configured` (or `erasure_unavailable`)
1875
1890
  * the request throws BEFORE the teardown line, so the local session is left
1876
1891
  * fully intact — the deletion did not happen and the user is still signed in.
1877
1892
  */
1878
1893
  async deleteAccount(params = {}) {
1879
- const res = await palbeRequest(this.rt, "DELETE", "/auth/user", {
1894
+ await palbeRequest(this.rt, "DELETE", "/auth/user", {
1880
1895
  body: params.password ? { password: params.password } : {}
1881
1896
  });
1882
1897
  this.nextSignOutReason = "accountDeleted";
1883
1898
  this.rt.tokenManager.clearSession();
1884
1899
  this.rt.storage.clear();
1885
1900
  this.cachedUser = null;
1886
- return { erasureId: res.erasure_id };
1887
1901
  }
1888
1902
  /**
1889
1903
  * @internal — invoked by the transport choke point (`request.ts`), not app code.
@@ -2370,9 +2384,15 @@ var PalbeCalls = class {
2370
2384
  * @param media - Which media tracks to publish (default: audio + video)
2371
2385
  * @returns A `Call` in `connecting` state; transitions to `active` once the media room connects.
2372
2386
  *
2373
- * @throws BackendError('network', { code: 'call_service_unavailable' }) if the SFU is down (503)
2374
- * @throws BackendError('forbidden', { code: 'not_group_member' }) if the caller is not a member (403)
2375
- * @throws BackendError('conflict', { code: 'call_room_full' }) if the room is full (409)
2387
+ * Every failure below arrives as kind `'server'` this SDK's error kinds are
2388
+ * `notConfigured | validation | unauthorized | rateLimited | server | network
2389
+ * | decode`, and only a 401, a 429 or a 400 carrying a field-error array gets
2390
+ * its own kind. So branch on `err.code`, never on the kind.
2391
+ *
2392
+ * @throws BackendError('server', { code: 'calling_unavailable' }) no SFU configured on this stack (503)
2393
+ * @throws BackendError('server', { code: 'not_group_member' }) the caller is not a member of the group (403)
2394
+ * @throws BackendError('server', { code: 'call_room_full' }) the call is at its participant limit (409)
2395
+ * @throws BackendError('server', { code: 'call_provider_error' }) the media provider failed upstream (502)
2376
2396
  */
2377
2397
  async start(groupId, { media = ["audio", "video"] } = {}) {
2378
2398
  if (!groupId) {
@@ -2393,7 +2413,11 @@ var PalbeCalls = class {
2393
2413
  * Accept an incoming call in a messaging group.
2394
2414
  *
2395
2415
  * @param groupId - Messaging group display-id
2396
- * @param callId - Call identifier (from the push notification payload)
2416
+ * @param callId - Call identifier, carried by the `call_invite` the server
2417
+ * fans onto the group's conversation topic
2418
+ * (`pb.realtime.channel('messaging:conv:<rfcGroupId>').on('call_invite', …)`).
2419
+ * This SDK ships no web push: on the web the invite arrives over realtime or
2420
+ * over whatever signalling the app already runs.
2397
2421
  * @param media - Which media tracks to publish (default: audio + video)
2398
2422
  */
2399
2423
  async accept(groupId, callId, { media = ["audio", "video"] } = {}) {
@@ -4496,14 +4520,11 @@ var Chat = class {
4496
4520
  get title() {
4497
4521
  if (this.titleOverride) return this.titleOverride;
4498
4522
  if (this._group?.name) return this._group.name;
4499
- if (this._draft) {
4500
- const mode = this._draft.mode;
4501
- if (mode.kind === "direct") {
4502
- const peer = this.memberCache.find((m) => m.userId === mode.peerUserId);
4503
- return peer?.displayName ?? "Chat";
4504
- }
4505
- return "Group";
4523
+ if (this.kind === "direct") {
4524
+ const peer = this.memberCache.find((m) => !m.isSelf);
4525
+ return peer?.displayName ?? "Chat";
4506
4526
  }
4527
+ if (this._draft) return "Group";
4507
4528
  const tail = this.id.startsWith("grp_") ? this.id.slice(4) : this.id;
4508
4529
  const short = tail.slice(-4);
4509
4530
  return short ? `Group ${short}` : "Group";
@@ -5477,6 +5498,11 @@ var MessageHub = class {
5477
5498
  emitConv(displayId, e) {
5478
5499
  for (const fn of this.convListeners.get(displayId) ?? []) fn(e);
5479
5500
  }
5501
+ /** How many live conv listeners a group still has — the census the LAST
5502
+ * subscriber reads before tearing the conv topic (and its presence) down. */
5503
+ convListenerCount(displayId) {
5504
+ return this.convListeners.get(displayId)?.size ?? 0;
5505
+ }
5480
5506
  add(map, key, fn) {
5481
5507
  let set = map.get(key);
5482
5508
  if (!set) {
@@ -5514,6 +5540,9 @@ var MessageDeliverySource = class {
5514
5540
  started = false;
5515
5541
  wakeUnsub = null;
5516
5542
  // Per-observed-group conv subscription + heartbeat (presence/typing/read).
5543
+ // `teardown` is the ONE departure path: it stops the heartbeat, broadcasts the
5544
+ // offline frame and drops the subscriptions, so no caller can tear an
5545
+ // observation down without saying goodbye (see observeConversation).
5517
5546
  observed = /* @__PURE__ */ new Map();
5518
5547
  /** Subscribe the device wake topic + run an initial drain. Idempotent. */
5519
5548
  async start() {
@@ -5538,10 +5567,7 @@ var MessageDeliverySource = class {
5538
5567
  stop() {
5539
5568
  this.wakeUnsub?.();
5540
5569
  this.wakeUnsub = null;
5541
- for (const [, o] of this.observed) {
5542
- o.unsub();
5543
- if (o.heartbeat) clearInterval(o.heartbeat);
5544
- }
5570
+ for (const [, o] of this.observed) o.teardown();
5545
5571
  this.observed.clear();
5546
5572
  this.started = false;
5547
5573
  }
@@ -5772,14 +5798,26 @@ var MessageDeliverySource = class {
5772
5798
  emitPresence(true);
5773
5799
  const heartbeat = setInterval(() => emitPresence(true), 25e3);
5774
5800
  this.observed.set(group.displayId, {
5775
- unsub: () => {
5801
+ teardown: () => {
5802
+ clearInterval(heartbeat);
5803
+ try {
5804
+ emitPresence(false);
5805
+ } catch {
5806
+ }
5776
5807
  for (const s of subs) s.cancel();
5777
- },
5778
- heartbeat
5808
+ }
5779
5809
  });
5780
5810
  } catch {
5781
5811
  }
5782
5812
  }
5813
+ /** Stop observing a group's conv topic: announce OFFLINE, stop the heartbeat
5814
+ * and drop the subscriptions. Idempotent — an unobserved group is a no-op. */
5815
+ unobserveConversation(group) {
5816
+ const o = this.observed.get(group.displayId);
5817
+ if (!o) return;
5818
+ this.observed.delete(group.displayId);
5819
+ o.teardown();
5820
+ }
5783
5821
  /** Announce typing on a group's conv topic (the app calls per keystroke). */
5784
5822
  setTyping(group, isTyping) {
5785
5823
  this.observeConversation(group);
@@ -8007,7 +8045,11 @@ var MessagingCoordinator = class {
8007
8045
  subscribeLive(group, chat) {
8008
8046
  let offMsg = null;
8009
8047
  let offConv = null;
8048
+ let resolved = null;
8049
+ let cancelled = false;
8010
8050
  void this.resolve().then((r) => {
8051
+ if (cancelled) return;
8052
+ resolved = r;
8011
8053
  offMsg = r.hub.onMessage(group.displayId, (m) => {
8012
8054
  void chat.ingestLive(m);
8013
8055
  });
@@ -8015,8 +8057,12 @@ var MessagingCoordinator = class {
8015
8057
  offConv = r.hub.onConv(group.displayId, (e) => chat.applyConv(e.event, e.payload));
8016
8058
  });
8017
8059
  return () => {
8060
+ cancelled = true;
8018
8061
  offMsg?.();
8019
8062
  offConv?.();
8063
+ if (resolved && resolved.hub.convListenerCount(group.displayId) === 0) {
8064
+ resolved.source.unobserveConversation(group);
8065
+ }
8020
8066
  };
8021
8067
  }
8022
8068
  async userIdForDevice(group, deviceId) {
@@ -8874,8 +8920,33 @@ function unwrapBroadcast(frame) {
8874
8920
  function stripRealtimePrefix(topic) {
8875
8921
  return topic.startsWith(REALTIME_PREFIX) ? topic.slice(REALTIME_PREFIX.length) : topic;
8876
8922
  }
8923
+ function encodeStateSet(joinRef, ref, topic, key, value, life) {
8924
+ return JSON.stringify([
8925
+ joinRef,
8926
+ String(ref),
8927
+ REALTIME_PREFIX + topic,
8928
+ "state_set",
8929
+ { key, value, life }
8930
+ ]);
8931
+ }
8932
+ function encodeStateDel(joinRef, ref, topic, key) {
8933
+ return JSON.stringify([joinRef, String(ref), REALTIME_PREFIX + topic, "state_del", { key }]);
8934
+ }
8935
+ function encodeStateResync(joinRef, ref, topic) {
8936
+ return JSON.stringify([joinRef, String(ref), REALTIME_PREFIX + topic, "state_resync", {}]);
8937
+ }
8938
+ function encodeAccessToken(joinRef, ref, topic, token) {
8939
+ return JSON.stringify([
8940
+ joinRef,
8941
+ String(ref),
8942
+ REALTIME_PREFIX + topic,
8943
+ "access_token",
8944
+ { access_token: token }
8945
+ ]);
8946
+ }
8877
8947
 
8878
8948
  // src/realtime/connection.ts
8949
+ var RETRYABLE_REFUSALS = /* @__PURE__ */ new Set(["unavailable", "rate_limited", "too_many_channels"]);
8879
8950
  var WS_OPEN = 1;
8880
8951
  var DEFAULT_HEARTBEAT_MS = 25e3;
8881
8952
  var DEFAULT_MAX_BACKOFF_SECONDS = 30;
@@ -9108,6 +9179,31 @@ var RealtimeSocket = class {
9108
9179
  this.handleChannelClose(frame);
9109
9180
  return;
9110
9181
  }
9182
+ if (frame.event === "state_snapshot") {
9183
+ this.handlers?.onStateSnapshot(stripRealtimePrefix(frame.topic), frame.payload);
9184
+ return;
9185
+ }
9186
+ if (frame.event === "state_diff") {
9187
+ this.handlers?.onStateDiff(stripRealtimePrefix(frame.topic), frame.payload);
9188
+ return;
9189
+ }
9190
+ if (frame.event === "phx_reply") {
9191
+ const body = frame.payload;
9192
+ const topic = stripRealtimePrefix(frame.topic);
9193
+ if (body && body.status === "ok" && body.response && typeof body.response === "object") {
9194
+ this.handlers?.onJoinInfo(topic, body.response);
9195
+ return;
9196
+ }
9197
+ if (body && body.status === "error" && String(frame.ref) === this.joinRefs.get(topic)) {
9198
+ const reason = typeof body.response?.["reason"] === "string" ? body.response["reason"] : "unauthorized";
9199
+ if (!RETRYABLE_REFUSALS.has(reason)) {
9200
+ this.joinedTopics.delete(topic);
9201
+ this.joinRefs.delete(topic);
9202
+ }
9203
+ this.handlers?.onChannelRefused(topic, reason);
9204
+ }
9205
+ return;
9206
+ }
9111
9207
  const inbound = unwrapBroadcast(frame);
9112
9208
  if (inbound) {
9113
9209
  this.channelRejoinAttempts.delete(inbound.topic);
@@ -9148,6 +9244,40 @@ var RealtimeSocket = class {
9148
9244
  }, delayMs);
9149
9245
  this.channelRejoinTimers.set(topic, timer);
9150
9246
  }
9247
+ // MARK: state
9248
+ /** Write one entry on a joined channel's state. Dropped when the topic has no
9249
+ * live join: the server refuses state on a channel this socket is not on, and
9250
+ * the reconnect's join brings a fresh snapshot anyway. */
9251
+ sendStateSet(topic, key, value, life) {
9252
+ const joinRef = this.joinRefs.get(topic);
9253
+ if (joinRef === void 0 || !this.isOpen()) return;
9254
+ this.send(encodeStateSet(joinRef, this.nextRef(), topic, key, value, life));
9255
+ }
9256
+ /** Remove one entry. The server refuses a key this connection does not own. */
9257
+ sendStateDel(topic, key) {
9258
+ const joinRef = this.joinRefs.get(topic);
9259
+ if (joinRef === void 0 || !this.isOpen()) return;
9260
+ this.send(encodeStateDel(joinRef, this.nextRef(), topic, key));
9261
+ }
9262
+ /** Ask for a fresh snapshot — the answer to a detected gap. */
9263
+ sendStateResync(topic) {
9264
+ const joinRef = this.joinRefs.get(topic);
9265
+ if (joinRef === void 0 || !this.isOpen()) return;
9266
+ this.send(encodeStateResync(joinRef, this.nextRef(), topic));
9267
+ }
9268
+ /**
9269
+ * Present a fresher credential on every joined channel WITHOUT rejoining.
9270
+ *
9271
+ * The alternative — dropping and rejoining — reaps this connection's
9272
+ * ephemeral entries (its presence) and forces a full resync on every channel.
9273
+ * A token rotation is not a reconnection and must not look like one.
9274
+ */
9275
+ sendAccessToken(token) {
9276
+ if (!this.isOpen()) return;
9277
+ for (const [topic, joinRef] of this.joinRefs) {
9278
+ this.send(encodeAccessToken(joinRef, this.nextRef(), topic, token));
9279
+ }
9280
+ }
9151
9281
  // MARK: helpers
9152
9282
  /** Drop a single topic's token-expiry rejoin state (cancel its pending timer). */
9153
9283
  clearChannelRejoin(topic) {
@@ -9173,6 +9303,60 @@ var RealtimeSocket = class {
9173
9303
  }
9174
9304
  };
9175
9305
 
9306
+ // src/realtime/state.ts
9307
+ var PRESENCE_PREFIX = "$p:";
9308
+ var ChannelState = class {
9309
+ /** The sequence this local copy is current as of. */
9310
+ seq = 0;
9311
+ /** key → value. Lifetimes are the server's business; a reader only needs the
9312
+ * values, and presence is derived from the key shape. */
9313
+ entries = /* @__PURE__ */ new Map();
9314
+ /** Replace everything. A snapshot is the WHOLE picture, never a merge —
9315
+ * merging one would keep entries the server has since removed. */
9316
+ applySnapshot(body) {
9317
+ this.entries.clear();
9318
+ for (const [k, e] of Object.entries(body.entries ?? {})) {
9319
+ this.entries.set(k, e.v);
9320
+ }
9321
+ this.seq = body.seq;
9322
+ }
9323
+ /**
9324
+ * Apply a diff, or report that it cannot be applied.
9325
+ *
9326
+ * `gap` when this copy is behind the diff's starting point: frames were
9327
+ * missed, and applying anyway would produce a state that never existed on the
9328
+ * server. The state is left untouched so the caller can resync from something
9329
+ * consistent.
9330
+ *
9331
+ * A diff at or below the current sequence is already applied — a resend after
9332
+ * a retry — and is silently accepted so duplicates are harmless.
9333
+ */
9334
+ applyDiff(body) {
9335
+ if (this.seq < body.from_seq) return "gap";
9336
+ if (body.seq <= this.seq) return "applied";
9337
+ for (const [k, e] of Object.entries(body.set ?? {})) this.entries.set(k, e.v);
9338
+ for (const k of body.del ?? []) this.entries.delete(k);
9339
+ this.seq = body.seq;
9340
+ return "applied";
9341
+ }
9342
+ /**
9343
+ * Everyone else's presence.
9344
+ *
9345
+ * `selfConn` is this connection's id, as the join reply reported it. Its own
9346
+ * entry is excluded because an app rendering "who else is here" would
9347
+ * otherwise always show one extra face — itself.
9348
+ */
9349
+ presence(selfConn) {
9350
+ const own = selfConn === void 0 ? void 0 : PRESENCE_PREFIX + selfConn;
9351
+ const out = [];
9352
+ for (const [k, v] of this.entries) {
9353
+ if (!k.startsWith(PRESENCE_PREFIX) || k === own) continue;
9354
+ out.push(v);
9355
+ }
9356
+ return out;
9357
+ }
9358
+ };
9359
+
9176
9360
  // src/realtime/facade.ts
9177
9361
  var StatusStore = class {
9178
9362
  currentState = "idle";
@@ -9208,6 +9392,7 @@ var StatusStore = class {
9208
9392
  for (const listener of this.listeners) listener(snapshot);
9209
9393
  }
9210
9394
  };
9395
+ var RETRYABLE_REASONS = /* @__PURE__ */ new Set(["unavailable", "rate_limited", "too_many_channels"]);
9211
9396
  var RealtimeChannel = class {
9212
9397
  /** The app-defined channel name (bare sub-topic, no "realtime:" prefix). */
9213
9398
  name;
@@ -9225,6 +9410,23 @@ var RealtimeChannel = class {
9225
9410
  on(event, handler) {
9226
9411
  return this.owner.subscribe(this.name, event, handler);
9227
9412
  }
9413
+ /**
9414
+ * Called when the server REFUSES this channel.
9415
+ *
9416
+ * A join is an authorization decision, and a decision the app never hears is
9417
+ * the same as no decision at all: silence looks exactly like a slow network.
9418
+ * This is where a refusal arrives, carrying the server's own distinction —
9419
+ * `retryable: false` means "you may not" and asking again will not help;
9420
+ * `retryable: true` means the project could not be asked, and the socket's
9421
+ * next reconnect retries on its own.
9422
+ *
9423
+ * pb.realtime.channel('room:42').onError((err) => {
9424
+ * if (!err.retryable) showJoinDenied()
9425
+ * })
9426
+ */
9427
+ onError(handler) {
9428
+ return this.owner.subscribeError(this.name, handler);
9429
+ }
9228
9430
  /**
9229
9431
  * Broadcast `payload` to the channel's other subscribers (web addition —
9230
9432
  * iOS is receive-only). Joins the channel if it isn't already; queued
@@ -9233,6 +9435,57 @@ var RealtimeChannel = class {
9233
9435
  send(event, payload = {}) {
9234
9436
  this.owner.send(this.name, event, payload);
9235
9437
  }
9438
+ /**
9439
+ * Watch one key of the channel's shared state.
9440
+ *
9441
+ * The handler is called with the CURRENT value straight away when there is
9442
+ * one, and again on every change. That is the difference from `on()`: a
9443
+ * subscriber to an event learns nothing until the next one, while state is
9444
+ * already there when you ask.
9445
+ */
9446
+ onState(key, handler) {
9447
+ return this.owner.subscribeState(this.name, key, handler);
9448
+ }
9449
+ /** Read one key without subscribing. */
9450
+ stateValue(key) {
9451
+ return this.owner.stateOf(this.name).entries.get(key);
9452
+ }
9453
+ /** Write one key. `durable` outlives this connection; `ephemeral` is reaped
9454
+ * when it closes, which is what presence is built on. */
9455
+ setState(key, value, opts = {}) {
9456
+ this.owner.writeState(this.name, key, value, opts.life ?? "durable");
9457
+ }
9458
+ /** Remove a key this connection wrote. */
9459
+ clearState(key) {
9460
+ this.owner.clearState(this.name, key);
9461
+ }
9462
+ /**
9463
+ * Announce this client on the channel. `meta` is whatever other participants
9464
+ * should see — a name, an avatar, a cursor.
9465
+ *
9466
+ * It is ephemeral state keyed by this connection, so it disappears when the
9467
+ * socket does. Nothing has to be un-announced on a crash.
9468
+ */
9469
+ presenceEnter(meta) {
9470
+ this.owner.presenceWrite(this.name, meta);
9471
+ }
9472
+ /** Replace the announced value — a cursor moving is this, at whatever rate
9473
+ * the app likes: the server conflates writes it cannot deliver fast enough. */
9474
+ presenceUpdate(meta) {
9475
+ this.owner.presenceWrite(this.name, meta);
9476
+ }
9477
+ /** Leave early. Closing the socket does this on its own. */
9478
+ presenceLeave() {
9479
+ this.owner.presenceClear(this.name);
9480
+ }
9481
+ /** Everyone else currently announced, this client excluded. */
9482
+ presenceOthers() {
9483
+ return this.owner.presenceOthers(this.name);
9484
+ }
9485
+ /** Watch the announced set. Called on every change. */
9486
+ onPresence(handler) {
9487
+ return this.owner.subscribePresence(this.name, handler);
9488
+ }
9236
9489
  };
9237
9490
  var PalbeRealtime = class {
9238
9491
  rt;
@@ -9242,9 +9495,21 @@ var PalbeRealtime = class {
9242
9495
  // Per-(topic, event) handlers — an event may have multiple handlers
9243
9496
  // (multiple .on calls); entry identity is the unsubscribe token.
9244
9497
  handlers = /* @__PURE__ */ new Set();
9498
+ // Per-topic refusal handlers. Kept apart from `handlers` because they are not
9499
+ // refcounted: registering one must not join a channel, and cancelling the
9500
+ // last one must not leave a channel that still has event handlers on it.
9501
+ errorHandlers = /* @__PURE__ */ new Set();
9245
9502
  // Joins are refcounted per topic so the socket only leaves a topic when
9246
9503
  // its last handler cancels (iOS RealtimeClient parity).
9247
9504
  topicRefcount = /* @__PURE__ */ new Map();
9505
+ // Per-topic shared state, and this connection's identity on each topic (from
9506
+ // the join ack). Presence is keyed by that identity, so a write before the
9507
+ // ack has nowhere to go and is queued until it arrives.
9508
+ states = /* @__PURE__ */ new Map();
9509
+ connIds = /* @__PURE__ */ new Map();
9510
+ pendingPresence = /* @__PURE__ */ new Map();
9511
+ stateWatchers = /* @__PURE__ */ new Set();
9512
+ presenceWatchers = /* @__PURE__ */ new Set();
9248
9513
  constructor(rt) {
9249
9514
  this.rt = rt;
9250
9515
  }
@@ -9287,6 +9552,20 @@ var PalbeRealtime = class {
9287
9552
  get status() {
9288
9553
  return this.statusStore;
9289
9554
  }
9555
+ /**
9556
+ * Hand the socket a fresher credential for the SAME user, without rejoining.
9557
+ *
9558
+ * Called when the app's token rotates. The alternative — letting the channels
9559
+ * die at expiry and rejoining — reaps this connection's ephemeral entries
9560
+ * (its presence, on every channel) and forces a full state resync. A token
9561
+ * getting older is not a disconnection and must not cost one.
9562
+ *
9563
+ * A no-op before a socket exists: the first connect resolves a fresh token
9564
+ * on its own.
9565
+ */
9566
+ refreshToken(token) {
9567
+ this.socket?.sendAccessToken(token);
9568
+ }
9290
9569
  /** @internal */
9291
9570
  subscribe(topic, event, handler) {
9292
9571
  const socket = this.ensureSocket();
@@ -9301,7 +9580,9 @@ var PalbeRealtime = class {
9301
9580
  if (cancelled) return;
9302
9581
  cancelled = true;
9303
9582
  this.handlers.delete(entry);
9304
- const next = (this.topicRefcount.get(topic) ?? 1) - 1;
9583
+ const held = this.topicRefcount.get(topic);
9584
+ if (held === void 0) return;
9585
+ const next = held - 1;
9305
9586
  if (next <= 0) {
9306
9587
  this.topicRefcount.delete(topic);
9307
9588
  socket.leaveTopic(topic);
@@ -9311,6 +9592,25 @@ var PalbeRealtime = class {
9311
9592
  }
9312
9593
  };
9313
9594
  }
9595
+ /**
9596
+ * Register a refusal handler for one topic. Does NOT join: watching for a
9597
+ * refusal is not a subscription, and an app that registers one before its
9598
+ * first `.on()` should not be joining anything yet.
9599
+ *
9600
+ * @internal
9601
+ */
9602
+ subscribeError(topic, handler) {
9603
+ const entry = { topic, handler };
9604
+ this.errorHandlers.add(entry);
9605
+ let cancelled = false;
9606
+ return {
9607
+ cancel: () => {
9608
+ if (cancelled) return;
9609
+ cancelled = true;
9610
+ this.errorHandlers.delete(entry);
9611
+ }
9612
+ };
9613
+ }
9314
9614
  /** @internal */
9315
9615
  send(topic, event, payload) {
9316
9616
  const socket = this.ensureSocket();
@@ -9332,7 +9632,11 @@ var PalbeRealtime = class {
9332
9632
  onReconnect: () => {
9333
9633
  },
9334
9634
  onStateChange: (state) => this.statusStore.setState(state),
9335
- onChannelError: (topic) => this.handleChannelError(topic)
9635
+ onChannelError: (topic) => this.handleChannelError(topic),
9636
+ onChannelRefused: (topic, reason) => this.handleChannelRefused(topic, reason),
9637
+ onStateSnapshot: (topic, body) => this.applySnapshot(topic, body),
9638
+ onStateDiff: (topic, body) => this.applyDiff(topic, body),
9639
+ onJoinInfo: (topic, info) => this.applyJoinInfo(topic, info)
9336
9640
  });
9337
9641
  }
9338
9642
  return this.socket;
@@ -9346,19 +9650,185 @@ var PalbeRealtime = class {
9346
9650
  }
9347
9651
  }
9348
9652
  /**
9349
- * A channel died for good (token-expiry rejoin exhausted N attempts). Drop its
9350
- * handlers + refcount so the app stops expecting events on it, and flip the
9351
- * status observable to `'error'` (the React `useChannel` hook reports this).
9352
- * A later `.on()` for the same topic re-joins from scratch (refcount 1).
9653
+ /**
9654
+ * The server refused a channel. Tell whoever asked, and when the answer is
9655
+ * final stop pretending the app is subscribed to it.
9656
+ *
9657
+ * `unavailable` leaves everything in place: the project could not be reached
9658
+ * to decide, the socket rejoins on its next reconnect, and dropping the app's
9659
+ * handlers would make a momentary outage look like a permanent denial.
9660
+ */
9661
+ handleChannelRefused(topic, reason) {
9662
+ const retryable = RETRYABLE_REASONS.has(reason);
9663
+ for (const entry of this.errorHandlers) {
9664
+ if (entry.topic === topic) entry.handler({ topic, reason, retryable });
9665
+ }
9666
+ if (retryable) return;
9667
+ this.forgetTopic(topic);
9668
+ }
9669
+ /**
9670
+ * A channel died for good — a token-expiry rejoin that exhausted its attempts.
9671
+ * The app must stop expecting anything on it, and the status observable flips
9672
+ * to `'error'` (the React `useChannel` hook reports that).
9673
+ *
9674
+ * It clears the SAME registries a final refusal clears, and used to clear only
9675
+ * the event handlers. A state or presence watcher left behind here waits
9676
+ * forever for a snapshot that cannot arrive, and fires twice if the app ever
9677
+ * re-subscribes — the identical failure its sibling three functions up
9678
+ * describes, on a channel that was given up on rather than refused.
9353
9679
  */
9354
9680
  handleChannelError(topic) {
9681
+ this.forgetTopic(topic);
9682
+ this.statusStore.setState("error");
9683
+ }
9684
+ /** Drop every registration this topic holds. One place, because "which
9685
+ * registries" is a question two callers must never answer differently. */
9686
+ forgetTopic(topic) {
9355
9687
  for (const entry of this.handlers) {
9356
9688
  if (entry.topic === topic) this.handlers.delete(entry);
9357
9689
  }
9690
+ for (const entry of this.stateWatchers) {
9691
+ if (entry.topic === topic) this.stateWatchers.delete(entry);
9692
+ }
9693
+ for (const entry of this.presenceWatchers) {
9694
+ if (entry.topic === topic) this.presenceWatchers.delete(entry);
9695
+ }
9696
+ this.pendingPresence.delete(topic);
9697
+ this.states.delete(topic);
9698
+ this.connIds.delete(topic);
9358
9699
  this.topicRefcount.delete(topic);
9359
- this.statusStore.setState("error");
9700
+ }
9701
+ // ── state ──────────────────────────────────────────────────────────────────
9702
+ /** @internal */
9703
+ stateOf(topic) {
9704
+ let st = this.states.get(topic);
9705
+ if (!st) {
9706
+ st = new ChannelState();
9707
+ this.states.set(topic, st);
9708
+ }
9709
+ return st;
9710
+ }
9711
+ /** @internal */
9712
+ subscribeState(topic, key, handler) {
9713
+ const socket = this.ensureSocket();
9714
+ const entry = { topic, key, handler };
9715
+ this.stateWatchers.add(entry);
9716
+ this.retainTopic(topic, socket);
9717
+ const known = this.stateOf(topic).entries.get(key);
9718
+ if (known !== void 0) handler(known);
9719
+ let cancelled = false;
9720
+ return {
9721
+ cancel: () => {
9722
+ if (cancelled) return;
9723
+ cancelled = true;
9724
+ this.stateWatchers.delete(entry);
9725
+ this.releaseTopic(topic, socket);
9726
+ }
9727
+ };
9728
+ }
9729
+ /** @internal */
9730
+ subscribePresence(topic, handler) {
9731
+ const socket = this.ensureSocket();
9732
+ const entry = { topic, handler };
9733
+ this.presenceWatchers.add(entry);
9734
+ this.retainTopic(topic, socket);
9735
+ handler(this.presenceOthers(topic));
9736
+ let cancelled = false;
9737
+ return {
9738
+ cancel: () => {
9739
+ if (cancelled) return;
9740
+ cancelled = true;
9741
+ this.presenceWatchers.delete(entry);
9742
+ this.releaseTopic(topic, socket);
9743
+ }
9744
+ };
9745
+ }
9746
+ /** @internal */
9747
+ writeState(topic, key, value, life) {
9748
+ const socket = this.ensureSocket();
9749
+ socket.joinTopic(topic);
9750
+ socket.sendStateSet(topic, key, value, life);
9751
+ }
9752
+ /** @internal */
9753
+ clearState(topic, key) {
9754
+ this.ensureSocket().sendStateDel(topic, key);
9755
+ }
9756
+ /** @internal */
9757
+ presenceWrite(topic, meta) {
9758
+ const socket = this.ensureSocket();
9759
+ socket.joinTopic(topic);
9760
+ const conn = this.connIds.get(topic);
9761
+ if (conn === void 0) {
9762
+ this.pendingPresence.set(topic, meta);
9763
+ return;
9764
+ }
9765
+ socket.sendStateSet(topic, PRESENCE_KEY(conn), meta, "ephemeral");
9766
+ }
9767
+ /** @internal */
9768
+ presenceClear(topic) {
9769
+ this.pendingPresence.delete(topic);
9770
+ const conn = this.connIds.get(topic);
9771
+ if (conn === void 0) return;
9772
+ this.ensureSocket().sendStateDel(topic, PRESENCE_KEY(conn));
9773
+ }
9774
+ /** @internal */
9775
+ presenceOthers(topic) {
9776
+ return this.stateOf(topic).presence(this.connIds.get(topic));
9777
+ }
9778
+ retainTopic(topic, socket) {
9779
+ const count = (this.topicRefcount.get(topic) ?? 0) + 1;
9780
+ this.topicRefcount.set(topic, count);
9781
+ if (count === 1) socket.joinTopic(topic);
9782
+ }
9783
+ releaseTopic(topic, socket) {
9784
+ const next = (this.topicRefcount.get(topic) ?? 1) - 1;
9785
+ if (next <= 0) {
9786
+ this.topicRefcount.delete(topic);
9787
+ socket.leaveTopic(topic);
9788
+ return;
9789
+ }
9790
+ this.topicRefcount.set(topic, next);
9791
+ }
9792
+ applySnapshot(topic, body) {
9793
+ this.stateOf(topic).applySnapshot(body);
9794
+ this.notifyState(topic);
9795
+ }
9796
+ applyDiff(topic, body) {
9797
+ if (this.stateOf(topic).applyDiff(body) === "gap") {
9798
+ this.socket?.sendStateResync(topic);
9799
+ return;
9800
+ }
9801
+ this.notifyState(topic);
9802
+ }
9803
+ applyJoinInfo(topic, info) {
9804
+ if (typeof info.conn !== "string" || info.conn === "") return;
9805
+ this.connIds.set(topic, info.conn);
9806
+ const seq = info.state_seq;
9807
+ if (typeof seq === "number" && seq < this.stateOf(topic).seq) {
9808
+ this.stateOf(topic).applySnapshot({ seq, entries: {} });
9809
+ }
9810
+ const held = this.pendingPresence.get(topic);
9811
+ if (held !== void 0) {
9812
+ this.pendingPresence.delete(topic);
9813
+ this.socket?.sendStateSet(topic, PRESENCE_KEY(info.conn), held, "ephemeral");
9814
+ }
9815
+ this.notifyState(topic);
9816
+ }
9817
+ notifyState(topic) {
9818
+ this.statusStore.recordEvent(/* @__PURE__ */ new Date());
9819
+ const st = this.stateOf(topic);
9820
+ for (const w of this.stateWatchers) {
9821
+ if (w.topic === topic) w.handler(st.entries.get(w.key));
9822
+ }
9823
+ if (this.presenceWatchers.size > 0) {
9824
+ const others = this.presenceOthers(topic);
9825
+ for (const w of this.presenceWatchers) {
9826
+ if (w.topic === topic) w.handler(others);
9827
+ }
9828
+ }
9360
9829
  }
9361
9830
  };
9831
+ var PRESENCE_KEY = (conn) => `$p:${conn}`;
9362
9832
 
9363
9833
  // src/storage.ts
9364
9834
  function memorySessionStorage() {
@@ -9419,7 +9889,7 @@ function defaultSessionStorage(key) {
9419
9889
  }
9420
9890
 
9421
9891
  // src/version.ts
9422
- var VERSION = "7.3.8";
9892
+ var VERSION = "7.4.0";
9423
9893
 
9424
9894
  // src/runtime.ts
9425
9895
  function buildRuntime(config) {
@@ -9597,6 +10067,7 @@ async function callEndpoint(resolveRt, name, input, options) {
9597
10067
  }
9598
10068
 
9599
10069
  // src/upload.ts
10070
+ var UPLOAD_PATH = "/v1/storage/upload";
9600
10071
  function abortError() {
9601
10072
  return new BackendError("network", { code: "aborted", message: "Upload aborted" });
9602
10073
  }
@@ -9650,7 +10121,9 @@ async function buildHeaders(rt, extra) {
9650
10121
  }
9651
10122
  function buildForm(options) {
9652
10123
  const form = new FormData();
9653
- for (const [k, v] of Object.entries(options.fields ?? {})) form.append(k, v);
10124
+ const { body, ...loose } = options.fields ?? {};
10125
+ if (body !== void 0) form.append("body", body);
10126
+ else if (Object.keys(loose).length > 0) form.append("body", JSON.stringify(loose));
9654
10127
  const file = options.contentType && options.file.type !== options.contentType ? new Blob([options.file], { type: options.contentType }) : options.file;
9655
10128
  const filename = options.filename ?? (typeof File !== "undefined" && options.file instanceof File ? options.file.name : "file");
9656
10129
  form.append("file", file, filename);
@@ -9724,8 +10197,8 @@ async function uploadViaFetch(url, form, headers, options) {
9724
10197
  async function uploadEndpoint(resolveRt, name, options) {
9725
10198
  const rt = resolveRt();
9726
10199
  checkConstraints(options);
9727
- const path = name.startsWith("/") ? name : `/${name}`;
9728
- const url = `${rt.config.url}${path}`;
10200
+ const route = name.startsWith("/") ? name : `/${name}`;
10201
+ const url = `${rt.config.url}${UPLOAD_PATH}${route}`;
9729
10202
  const headers = await buildHeaders(rt, options.headers);
9730
10203
  const form = buildForm(options);
9731
10204
  const useXHR = options.onProgress !== void 0 && typeof XMLHttpRequest !== "undefined";