@palbase/web 1.6.2 → 1.8.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.
@@ -387,14 +387,47 @@ function unwrap(res) {
387
387
  return res.data;
388
388
  }
389
389
 
390
+ // src/perf/url-redactor.ts
391
+ var ID_SEGMENT = /^(?:\d+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
392
+ var EMAIL_SEGMENT = /(?:@|%40)/i;
393
+ function isSensitiveSegment(seg2) {
394
+ return ID_SEGMENT.test(seg2) || EMAIL_SEGMENT.test(seg2);
395
+ }
396
+ function redactUrl(rawUrl) {
397
+ let path = rawUrl;
398
+ try {
399
+ path = new URL(rawUrl).pathname;
400
+ } catch {
401
+ const q = path.indexOf("?");
402
+ if (q >= 0) path = path.slice(0, q);
403
+ }
404
+ return path.split("/").map((seg2) => isSensitiveSegment(seg2) ? ":id" : seg2).join("/");
405
+ }
406
+
390
407
  // src/request.ts
391
408
  var MUTATING = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
409
+ var PERF_EXCLUDED_PREFIX = "/v1/analytics/";
410
+ function isSelfTraced(path) {
411
+ return path.startsWith(PERF_EXCLUDED_PREFIX);
412
+ }
413
+ function nowMs() {
414
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
415
+ }
416
+ function isAbort(e) {
417
+ return e instanceof Error && e.name === "AbortError";
418
+ }
392
419
  async function palbeRequest(rt, method, path, spec = {}) {
393
420
  const headers = { ...spec.headers };
394
421
  const callerHasKey = Object.keys(headers).some((k) => k.toLowerCase() === "idempotency-key");
395
422
  if (MUTATING.has(method) && !callerHasKey) {
396
423
  headers["Idempotency-Key"] = crypto.randomUUID();
397
424
  }
425
+ if (rt.appIdentifier !== "") {
426
+ const callerHasBundle = Object.keys(headers).some(
427
+ (k) => k.toLowerCase() === "x-palbase-bundle"
428
+ );
429
+ if (!callerHasBundle) headers["X-Palbase-Bundle"] = rt.appIdentifier;
430
+ }
398
431
  const attempt = async () => {
399
432
  try {
400
433
  return await rt.http.request(method, path, {
@@ -407,21 +440,38 @@ async function palbeRequest(rt, method, path, spec = {}) {
407
440
  throw pe ? fromPalbaseError(pe) : e;
408
441
  }
409
442
  };
410
- let res = await attempt();
411
- if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
412
- try {
413
- await rt.tokenManager.refreshSession();
414
- } catch (refreshErr) {
415
- const pe = asPalbaseError(refreshErr);
416
- const status = pe?.status ?? 0;
417
- if (status === 400 || status === 401 || status === 403) {
418
- rt.tokenManager.clearSession();
419
- throw fromPalbaseError(res.error);
443
+ const traced = !isSelfTraced(path) && rt.perf !== void 0;
444
+ const startedAt = traced ? nowMs() : 0;
445
+ let recorded = false;
446
+ const record = (status) => {
447
+ if (!traced || recorded) return;
448
+ recorded = true;
449
+ rt.perf.recordNetwork(method, redactUrl(path), status, nowMs() - startedAt);
450
+ };
451
+ let res;
452
+ try {
453
+ res = await attempt();
454
+ if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
455
+ try {
456
+ await rt.tokenManager.refreshSession();
457
+ } catch (refreshErr) {
458
+ const pe = asPalbaseError(refreshErr);
459
+ const status = pe?.status ?? 0;
460
+ if (status === 400 || status === 401 || status === 403) {
461
+ rt.tokenManager.clearSession();
462
+ record(res.error.status);
463
+ throw fromPalbaseError(res.error);
464
+ }
465
+ record(pe?.status ?? 0);
466
+ throw pe ? fromPalbaseError(pe) : refreshErr;
420
467
  }
421
- throw pe ? fromPalbaseError(pe) : refreshErr;
468
+ res = await attempt();
422
469
  }
423
- res = await attempt();
470
+ } catch (e) {
471
+ if (!isAbort(e)) record(asPalbaseError(e)?.status ?? 0);
472
+ throw e;
424
473
  }
474
+ record(res.error?.status ?? 200);
425
475
  return unwrap(res);
426
476
  }
427
477
 
@@ -438,7 +488,7 @@ function palbeState() {
438
488
  }
439
489
 
440
490
  // src/namespaces.ts
441
- var FIXED_SURFACE = /* @__PURE__ */ new Set([
491
+ var RESERVED = /* @__PURE__ */ new Set([
442
492
  "call",
443
493
  "upload",
444
494
  "auth",
@@ -446,7 +496,8 @@ var FIXED_SURFACE = /* @__PURE__ */ new Set([
446
496
  "realtime",
447
497
  "analytics",
448
498
  "calls",
449
- "messaging"
499
+ "messaging",
500
+ "perf"
450
501
  ]);
451
502
  function reservedNamespaceError(key) {
452
503
  return new BackendError("validation", {
@@ -511,7 +562,7 @@ function validateTree(node) {
511
562
  }
512
563
  function __registerNamespaces(tree) {
513
564
  for (const key of Object.keys(tree)) {
514
- if (FIXED_SURFACE.has(key)) throw reservedNamespaceError(key);
565
+ if (RESERVED.has(key)) throw reservedNamespaceError(key);
515
566
  }
516
567
  validateTree(tree);
517
568
  const state = palbeState();
@@ -1360,6 +1411,23 @@ var PalbeAnalytics = class {
1360
1411
  }
1361
1412
  };
1362
1413
 
1414
+ // src/app-config.ts
1415
+ function loadAppConfig(raw) {
1416
+ if (typeof raw !== "object" || raw === null) {
1417
+ throw new Error("app_config_invalid: expected a JSON object");
1418
+ }
1419
+ const r = raw;
1420
+ const str = (k) => typeof r[k] === "string" ? r[k] : "";
1421
+ return { appId: str("app_id"), identifier: str("identifier"), envPreset: str("env_preset") };
1422
+ }
1423
+ function assertOriginMatches(cfg, runtimeOrigin) {
1424
+ if (cfg.identifier === "") return;
1425
+ if (runtimeOrigin === "") return;
1426
+ if (runtimeOrigin !== cfg.identifier) {
1427
+ throw new Error(`app_config_mismatch: expected origin ${cfg.identifier}, got ${runtimeOrigin}`);
1428
+ }
1429
+ }
1430
+
1363
1431
  // src/auth-wire.ts
1364
1432
  function asWireAuthResult(raw) {
1365
1433
  if (typeof raw !== "object" || raw === null) return null;
@@ -2884,6 +2952,10 @@ var MessagingPaths = {
2884
2952
  groupMessages: (displayId) => `${GROUPS}/${seg(displayId)}/messages`,
2885
2953
  groupCommits: (displayId) => `${GROUPS}/${seg(displayId)}/commits`,
2886
2954
  groupRead: (displayId) => `${GROUPS}/${seg(displayId)}/read`,
2955
+ // Server-metadata cluster: the per-(user,group) notify scope (mute toggle, GET/PUT)
2956
+ // + the caller's own unread view (GET). {gid} is the grp_ display_id (M3 #7).
2957
+ groupNotify: (displayId) => `${GROUPS}/${seg(displayId)}/notify`,
2958
+ groupUnread: (displayId) => `${GROUPS}/${seg(displayId)}/unread`,
2887
2959
  deviceWelcomes: (deviceId) => `${DEVICES}/${seg(deviceId)}/welcomes`,
2888
2960
  deviceQueue: (deviceId) => `${DEVICES}/${seg(deviceId)}/queue`,
2889
2961
  deviceQueueAck: (deviceId) => `${DEVICES}/${seg(deviceId)}/queue/ack`,
@@ -4462,6 +4534,18 @@ var Chat = class {
4462
4534
  lastSeenAt: ts ? new Date(ts * 1e3) : null
4463
4535
  });
4464
4536
  this.emit();
4537
+ } else if (event === "delivered" || event === "read") {
4538
+ const seq = typeof payload.up_to_server_seq === "number" ? payload.up_to_server_seq : null;
4539
+ if (seq === null) return;
4540
+ let changed = false;
4541
+ this.messageList = this.messageList.map((m) => {
4542
+ if (m.direction !== "outgoing" || m.serverSeq > seq) return m;
4543
+ const cur = event === "delivered" ? m.deliveredUpTo ?? -1 : m.readUpTo ?? -1;
4544
+ if (seq <= cur) return m;
4545
+ changed = true;
4546
+ return event === "delivered" ? { ...m, deliveredUpTo: seq } : { ...m, readUpTo: seq };
4547
+ });
4548
+ if (changed) this.emit();
4465
4549
  }
4466
4550
  }
4467
4551
  kindOf(incoming) {
@@ -4710,6 +4794,31 @@ var Chat = class {
4710
4794
  this.readWatermark = Math.max(this.readWatermark, message.serverSeq);
4711
4795
  this.emit();
4712
4796
  }
4797
+ // ── Server-metadata cluster: notify scope (mute) + server unread ──
4798
+ /** Set this chat's notify scope (the mute toggle). `'none'` mutes the push wake;
4799
+ * `'all'` unmutes (the default). Materializes a draft first (the scope is a
4800
+ * per-(user,group) server row), PUTs `/notify`, and returns the server-echoed scope.
4801
+ * Mirrors iOS `Chat.setNotifyScope`. */
4802
+ async setNotifyScope(scope) {
4803
+ const group = await this.materializeIfNeeded();
4804
+ return this.backend.setNotifyScope(group, scope);
4805
+ }
4806
+ /** Refresh + return this chat's notify scope from the server (fail-OPEN to `'all'`).
4807
+ * Returns `'all'` for a draft chat (no server row yet). */
4808
+ async getNotifyScope() {
4809
+ if (!this._group) return "all";
4810
+ return this.backend.getNotifyScope(this._group);
4811
+ }
4812
+ /** Fetch the caller's authoritative SERVER unread count (opaque server metadata).
4813
+ * Returns the clamped count (`max(0, …)`); `0` for a draft chat. The local computed
4814
+ * `unreadCount` getter stays the instant, offline best-effort badge — this is the
4815
+ * canonical count on demand. Named distinctly so it does not shadow the observable
4816
+ * `unreadCount` snapshot getter. Mirrors iOS `Chat.refreshUnread`. */
4817
+ async unreadCountFromServer() {
4818
+ if (!this._group) return 0;
4819
+ const v = await this.backend.unread(this._group);
4820
+ return Math.max(0, v.unreadCount);
4821
+ }
4713
4822
  // ── Reactions ──
4714
4823
  /** Add an emoji reaction to a message. No-op if the message isn't reactable
4715
4824
  * (empty clientMsgId — a legacy/system row). The reaction folds locally with
@@ -5148,7 +5257,7 @@ var MessageDeliverySource = class {
5148
5257
  if (this.observed.has(group.displayId)) return;
5149
5258
  try {
5150
5259
  const channel = this.rt.realtime.channel(`messaging:conv:${group.rfcGroupId}`);
5151
- const subs = ["presence", "typing", "read"].map(
5260
+ const subs = ["presence", "typing", "read", "delivered"].map(
5152
5261
  (ev) => channel.on(ev, (payload) => {
5153
5262
  this.hub.emitConv(group.displayId, { event: ev, payload });
5154
5263
  })
@@ -5188,6 +5297,40 @@ var MessageDeliverySource = class {
5188
5297
  body: { read_seq: upToServerSeq, read_epoch: group.currentEpoch, is_private: false }
5189
5298
  });
5190
5299
  }
5300
+ // ── Server-metadata cluster: notify scope (mute) + unread (HTTP) ──
5301
+ /** PUT `/v1/messaging/groups/{gid}/notify` — set the caller's per-(user,group) notify
5302
+ * scope (`'all'`|`'none'`). The server stores the opaque enum verbatim and stays blind.
5303
+ * Returns the server-echoed scope (fail-OPEN to `'all'` on an unknown value). */
5304
+ async setNotifyScope(group, scope) {
5305
+ const res = await palbeRequest(
5306
+ this.rt,
5307
+ "PUT",
5308
+ MessagingPaths.groupNotify(group.displayId),
5309
+ { body: { notify_scope: scope } }
5310
+ );
5311
+ return res.notify_scope === "none" ? "none" : "all";
5312
+ }
5313
+ /** GET `/v1/messaging/groups/{gid}/notify` — the caller's notify scope. An absent
5314
+ * server row / unknown value reads as `'all'` (fail-OPEN — never silently mutes). */
5315
+ async getNotifyScope(group) {
5316
+ const res = await palbeRequest(
5317
+ this.rt,
5318
+ "GET",
5319
+ MessagingPaths.groupNotify(group.displayId)
5320
+ );
5321
+ return res.notify_scope === "none" ? "none" : "all";
5322
+ }
5323
+ /** GET `/v1/messaging/groups/{gid}/unread` — the caller's OWN unread view (opaque
5324
+ * server metadata). Maps the snake_case wire → the camelCase `UnreadView`. */
5325
+ async unread(group) {
5326
+ const res = await palbeRequest(this.rt, "GET", MessagingPaths.groupUnread(group.displayId));
5327
+ return {
5328
+ groupMaxSeq: res.group_max_seq,
5329
+ lastReadSeq: res.last_read_seq,
5330
+ deliveredSeq: res.delivered_seq,
5331
+ unreadCount: res.unread_count
5332
+ };
5333
+ }
5191
5334
  };
5192
5335
  function isOwnEchoOrConsumed(e) {
5193
5336
  const msg = e instanceof Error ? e.message : String(e);
@@ -7344,6 +7487,19 @@ var MessagingCoordinator = class {
7344
7487
  const r = await this.resolve();
7345
7488
  await r.source.markRead(group, upToServerSeq);
7346
7489
  }
7490
+ // ── Server-metadata cluster: notify scope (mute) + unread ──
7491
+ async setNotifyScope(group, scope) {
7492
+ const r = await this.resolve();
7493
+ return r.source.setNotifyScope(group, scope);
7494
+ }
7495
+ async getNotifyScope(group) {
7496
+ const r = await this.resolve();
7497
+ return r.source.getNotifyScope(group);
7498
+ }
7499
+ async unread(group) {
7500
+ const r = await this.resolve();
7501
+ return r.source.unread(group);
7502
+ }
7347
7503
  subscribeLive(group, chat) {
7348
7504
  let offMsg = null;
7349
7505
  let offConv = null;
@@ -7596,6 +7752,469 @@ var PalbeMessaging = class {
7596
7752
  }
7597
7753
  };
7598
7754
 
7755
+ // src/perf/app-start.ts
7756
+ function measureAppStart(nav) {
7757
+ if (!(nav.fcp > 0)) return null;
7758
+ const value = nav.fcp - nav.startTime;
7759
+ if (value <= 0) return null;
7760
+ return {
7761
+ row_id: crypto.randomUUID(),
7762
+ trace_type: "app_start",
7763
+ name: "cold_start",
7764
+ value,
7765
+ timestamp: Date.now()
7766
+ };
7767
+ }
7768
+
7769
+ // src/perf/perf-config-client.ts
7770
+ var PERF_CONFIG_PATH = "/v1/analytics/perf/config";
7771
+ var FNV_OFFSET_BASIS_32 = 2166136261;
7772
+ var FNV_PRIME_32 = 16777619;
7773
+ var utf82 = typeof TextEncoder !== "undefined" ? new TextEncoder() : { encode: (s) => Uint8Array.from(Buffer.from(s, "utf-8")) };
7774
+ function perfSampleBucket(rowId) {
7775
+ let hash = FNV_OFFSET_BASIS_32;
7776
+ for (const byte of utf82.encode(rowId)) {
7777
+ hash ^= byte;
7778
+ hash = Math.imul(hash, FNV_PRIME_32);
7779
+ }
7780
+ return (hash >>> 0) % 100;
7781
+ }
7782
+ function isPerfConfig(value) {
7783
+ if (typeof value !== "object" || value === null) return false;
7784
+ const o = value;
7785
+ return typeof o.sample_pct === "number";
7786
+ }
7787
+ function isRawResponse(value) {
7788
+ return typeof value === "object" && value !== null && "data" in value;
7789
+ }
7790
+ function headerValue(headers, name) {
7791
+ if (!headers) return void 0;
7792
+ const lower = name.toLowerCase();
7793
+ for (const [k, v] of Object.entries(headers)) {
7794
+ if (k.toLowerCase() === lower) return v;
7795
+ }
7796
+ return void 0;
7797
+ }
7798
+ var PerfConfigClient = class {
7799
+ etag;
7800
+ async fetchConfig(rt, perf) {
7801
+ const headers = {};
7802
+ if (this.etag) headers["If-None-Match"] = this.etag;
7803
+ try {
7804
+ const res = await rt.http.request("GET", PERF_CONFIG_PATH, { headers });
7805
+ if (!isRawResponse(res)) return;
7806
+ if (res.status === 304 || res.error && res.error.status === 304) return;
7807
+ if (res.error) return;
7808
+ const newEtag = headerValue(res.headers, "etag");
7809
+ if (newEtag) this.etag = newEtag;
7810
+ if (isPerfConfig(res.data)) {
7811
+ perf.setSamplePct(res.data.sample_pct);
7812
+ }
7813
+ } catch {
7814
+ }
7815
+ }
7816
+ };
7817
+
7818
+ // src/perf/fetch-swizzle.ts
7819
+ var PERF_EXCLUDED_PREFIX2 = "/v1/analytics/";
7820
+ function nowMs2() {
7821
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7822
+ }
7823
+ function describeRequest(input, init) {
7824
+ let url;
7825
+ let method = "GET";
7826
+ if (typeof input === "string") {
7827
+ url = input;
7828
+ } else if (input instanceof URL) {
7829
+ url = input.href;
7830
+ } else {
7831
+ url = input.url;
7832
+ method = input.method;
7833
+ }
7834
+ if (init?.method) method = init.method;
7835
+ return { url, method: method.toUpperCase() };
7836
+ }
7837
+ function pathOf(url) {
7838
+ try {
7839
+ return new URL(url, "http://_local").pathname;
7840
+ } catch {
7841
+ return url;
7842
+ }
7843
+ }
7844
+ function installFetchSwizzle(perf) {
7845
+ if (typeof fetch !== "function") return () => {
7846
+ };
7847
+ const original = fetch;
7848
+ const wrapped = async (input, init) => {
7849
+ const { url, method } = describeRequest(input, init);
7850
+ const traced = !pathOf(url).startsWith(PERF_EXCLUDED_PREFIX2);
7851
+ const startedAt = traced ? nowMs2() : 0;
7852
+ try {
7853
+ const res = await original(input, init);
7854
+ if (traced) perf.recordNetwork(method, redactUrl(url), res.status, nowMs2() - startedAt);
7855
+ return res;
7856
+ } catch (err) {
7857
+ if (traced) perf.recordNetwork(method, redactUrl(url), 0, nowMs2() - startedAt);
7858
+ throw err;
7859
+ }
7860
+ };
7861
+ globalThis.fetch = wrapped;
7862
+ return () => {
7863
+ globalThis.fetch = original;
7864
+ };
7865
+ }
7866
+
7867
+ // src/perf/offline-queue.ts
7868
+ var PERF_QUEUE_KEY = "palbe.perf.queue";
7869
+ var DEFAULT_MAX_ITEMS = 500;
7870
+ function canPersist() {
7871
+ return typeof document !== "undefined" && typeof localStorage !== "undefined";
7872
+ }
7873
+ function isPerfItem(value) {
7874
+ if (typeof value !== "object" || value === null) return false;
7875
+ const o = value;
7876
+ return typeof o.row_id === "string" && typeof o.trace_type === "string" && typeof o.name === "string" && typeof o.value === "number" && typeof o.timestamp === "number";
7877
+ }
7878
+ function readPersisted() {
7879
+ if (!canPersist()) return [];
7880
+ try {
7881
+ const raw = localStorage.getItem(PERF_QUEUE_KEY);
7882
+ if (!raw) return [];
7883
+ const parsed = JSON.parse(raw);
7884
+ if (!Array.isArray(parsed)) return [];
7885
+ return parsed.filter(isPerfItem);
7886
+ } catch {
7887
+ return [];
7888
+ }
7889
+ }
7890
+ var PerfOfflineQueue = class {
7891
+ items;
7892
+ _dropped = 0;
7893
+ maxItems;
7894
+ constructor(maxItems = DEFAULT_MAX_ITEMS) {
7895
+ this.maxItems = Math.max(1, maxItems);
7896
+ this.items = readPersisted();
7897
+ this.trim();
7898
+ }
7899
+ /** Append items; FIFO-evict the oldest when over `maxItems`. */
7900
+ enqueue(items) {
7901
+ if (items.length === 0) return;
7902
+ this.items.push(...items);
7903
+ this.trim();
7904
+ this.persist();
7905
+ }
7906
+ /** Return all queued items (oldest-first) and clear the queue + store. */
7907
+ drainAll() {
7908
+ if (this.items.length === 0) return [];
7909
+ const out = this.items;
7910
+ this.items = [];
7911
+ this.clearStore();
7912
+ return out;
7913
+ }
7914
+ /** Current queue depth. */
7915
+ get count() {
7916
+ return this.items.length;
7917
+ }
7918
+ /** Cumulative count of FIFO-evicted items (a `dropped` metric, not silent). */
7919
+ get dropped() {
7920
+ return this._dropped;
7921
+ }
7922
+ /** Drop the oldest items until at most `maxItems` remain, counting each. */
7923
+ trim() {
7924
+ const overflow = this.items.length - this.maxItems;
7925
+ if (overflow > 0) {
7926
+ this.items.splice(0, overflow);
7927
+ this._dropped += overflow;
7928
+ }
7929
+ }
7930
+ persist() {
7931
+ if (!canPersist()) return;
7932
+ try {
7933
+ localStorage.setItem(PERF_QUEUE_KEY, JSON.stringify(this.items));
7934
+ } catch {
7935
+ }
7936
+ }
7937
+ clearStore() {
7938
+ if (!canPersist()) return;
7939
+ try {
7940
+ localStorage.removeItem(PERF_QUEUE_KEY);
7941
+ } catch {
7942
+ }
7943
+ }
7944
+ };
7945
+
7946
+ // src/perf/perf-state.ts
7947
+ var MAX_PERF_BATCH = 100;
7948
+ var PerfState = class {
7949
+ /** Pending, un-flushed perf items (FIFO). */
7950
+ buffer = [];
7951
+ /** When true, every flush carries `X-Palbase-Test-Device: 1`. */
7952
+ testDevice = false;
7953
+ enqueue(item) {
7954
+ this.buffer.push(item);
7955
+ }
7956
+ /** Detach up to `MAX_PERF_BATCH` items for one POST (FIFO order preserved). */
7957
+ take(limit = MAX_PERF_BATCH) {
7958
+ return this.buffer.splice(0, limit);
7959
+ }
7960
+ get size() {
7961
+ return this.buffer.length;
7962
+ }
7963
+ };
7964
+
7965
+ // src/perf/perf-wire.ts
7966
+ function encodePerfBatch(items) {
7967
+ return { items };
7968
+ }
7969
+
7970
+ // src/perf/perf-facade.ts
7971
+ var FLUSH_AT2 = 20;
7972
+ var FLUSH_INTERVAL_MS2 = 1e4;
7973
+ var TEST_DEVICE_HEADER = "X-Palbase-Test-Device";
7974
+ function nowMs3() {
7975
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7976
+ }
7977
+ var PerfTrace = class {
7978
+ constructor(name, onStop) {
7979
+ this.name = name;
7980
+ this.onStop = onStop;
7981
+ }
7982
+ name;
7983
+ onStop;
7984
+ attrs = {};
7985
+ counters = {};
7986
+ startedAt = nowMs3();
7987
+ stopped = false;
7988
+ putAttribute(key, value) {
7989
+ this.attrs[key] = value;
7990
+ }
7991
+ incrementMetric(name, by = 1) {
7992
+ this.counters[name] = (this.counters[name] ?? 0) + by;
7993
+ }
7994
+ stop() {
7995
+ if (this.stopped) return;
7996
+ this.stopped = true;
7997
+ const item = {
7998
+ row_id: crypto.randomUUID(),
7999
+ trace_type: "custom",
8000
+ name: this.name,
8001
+ value: Math.max(0, nowMs3() - this.startedAt),
8002
+ timestamp: Date.now()
8003
+ };
8004
+ if (Object.keys(this.attrs).length > 0) item.attrs = this.attrs;
8005
+ if (Object.keys(this.counters).length > 0) item.counters = this.counters;
8006
+ this.onStop(item);
8007
+ }
8008
+ };
8009
+ var PalbePerf = class {
8010
+ constructor(rt, state = new PerfState(), queue = new PerfOfflineQueue()) {
8011
+ this.rt = rt;
8012
+ this.state = state;
8013
+ this.queue = queue;
8014
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
8015
+ window.addEventListener("online", this.onOnline);
8016
+ }
8017
+ }
8018
+ rt;
8019
+ state;
8020
+ queue;
8021
+ flushTimer = null;
8022
+ /** Browser → buffer + size/timer flush. Server (no document) → immediate
8023
+ * per-item flush, zero timers (nothing leaks into RSC/route handlers). */
8024
+ browser = typeof document !== "undefined";
8025
+ /** Bound `online` handler so it can be removed on `dispose()` (no leak). */
8026
+ onOnline = () => {
8027
+ void this.flush();
8028
+ };
8029
+ /** Server-controlled client-side sample rate (0..100). 100 until the config
8030
+ * client fetches `/v1/analytics/perf/config` — the SDK obeys this ceiling and
8031
+ * never raises its own rate (invariant 3). `record` drops an item whose
8032
+ * deterministic `row_id` bucket is >= this pct, mirroring the server's
8033
+ * `SampleDecision` so client + server keep the SAME rows. */
8034
+ samplePct = 100;
8035
+ /** Remove the `online` listener. Called when the runtime is replaced so the
8036
+ * handler does not outlive this facade. No-op outside the browser. */
8037
+ dispose() {
8038
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
8039
+ window.removeEventListener("online", this.onOnline);
8040
+ }
8041
+ }
8042
+ /** Mark (or unmark) this client's traffic as test — the server tags the rows
8043
+ * when the `X-Palbase-Test-Device: 1` header rides along on flush. */
8044
+ setTestDevice(on) {
8045
+ this.state.testDevice = on;
8046
+ }
8047
+ /** Apply the server-resolved client-side sample rate (0..100), clamped. Called
8048
+ * by `PerfConfigClient` after fetching `/v1/analytics/perf/config`. The SDK
8049
+ * OBEYS this value — it is a ceiling, never raised locally. */
8050
+ setSamplePct(pct) {
8051
+ this.samplePct = Math.max(0, Math.min(100, Math.trunc(pct)));
8052
+ }
8053
+ /** Pending (un-flushed) buffer depth. Test-only visibility into the buffer so
8054
+ * the remote-sampling tests can assert how many items were sampled in. */
8055
+ get bufferSizeForTest() {
8056
+ return this.state.size;
8057
+ }
8058
+ /** Opt-in: wrap the global `fetch` so every app-level request is recorded as a
8059
+ * redacted `network` perf item (the `/v1/analytics/*` ingest paths are
8060
+ * self-excluded). OFF by default — swizzling a global is a page-wide side
8061
+ * effect. Returns an `uninstall` that restores the original `fetch`. */
8062
+ enableFetchCapture() {
8063
+ return installFetchSwizzle(this);
8064
+ }
8065
+ /** Start a custom trace; the returned handle records a `custom` item on
8066
+ * `.stop()`. */
8067
+ startTrace(name) {
8068
+ return new PerfTrace(name, (item) => this.record(item));
8069
+ }
8070
+ /** Buffer one perf item. In the browser, flush on size/timer; on the server
8071
+ * flush immediately (no timers). Never throws.
8072
+ *
8073
+ * Client-side remote sampling: an item whose deterministic `row_id` bucket is
8074
+ * NOT below the server-controlled `samplePct` is dropped before buffering —
8075
+ * the SAME FNV-1a-mod-100 decision the server applies at ingest, so the two
8076
+ * keep identical rows and the SDK saves the upload (defense-in-depth: the
8077
+ * server re-samples authoritatively). */
8078
+ record(item) {
8079
+ if (perfSampleBucket(item.row_id) >= this.samplePct) return;
8080
+ this.state.enqueue(item);
8081
+ if (!this.browser) {
8082
+ void this.flush();
8083
+ return;
8084
+ }
8085
+ if (this.state.size >= FLUSH_AT2) void this.flush();
8086
+ else this.startTimer();
8087
+ }
8088
+ /** Buffer a network trace — called by `request.ts` around `rt.http.request`
8089
+ * (the analytics ingest path is excluded by the caller to avoid recursion). */
8090
+ recordNetwork(method, url, status, durationMs, requestId) {
8091
+ const item = {
8092
+ row_id: crypto.randomUUID(),
8093
+ trace_type: "network",
8094
+ name: `${method} ${url}`,
8095
+ value: durationMs,
8096
+ attrs: { status: String(status) },
8097
+ timestamp: Date.now()
8098
+ };
8099
+ if (requestId) item.request_id = requestId;
8100
+ this.record(item);
8101
+ }
8102
+ /** Drain the offline queue (oldest-first) and the live buffer to
8103
+ * `/v1/analytics/perf` (≤100 items per request, sequential). Resolves when
8104
+ * delivery finished; NEVER rejects. A failed slice is NOT dropped — it goes
8105
+ * to the offline queue (persist-on-fail) so the next flush (size/timer or the
8106
+ * `online` reconnect event) retries it. Items keep their original `row_id`,
8107
+ * so a redelivery dedups server-side (ReplacingMergeTree). */
8108
+ async flush() {
8109
+ this.cancelTimer();
8110
+ const pending = this.queue.drainAll();
8111
+ if (pending.length === 0 && this.state.size === 0) return;
8112
+ const headers = this.state.testDevice ? { [TEST_DEVICE_HEADER]: "1" } : void 0;
8113
+ const remaining = [...pending];
8114
+ while (this.state.size > 0) remaining.push(...this.state.take(MAX_PERF_BATCH));
8115
+ for (let i = 0; i < remaining.length; i += MAX_PERF_BATCH) {
8116
+ const slice = remaining.slice(i, i + MAX_PERF_BATCH);
8117
+ try {
8118
+ await palbeRequest(
8119
+ this.rt,
8120
+ "POST",
8121
+ "/v1/analytics/perf",
8122
+ { body: encodePerfBatch(slice), headers }
8123
+ );
8124
+ } catch {
8125
+ this.queue.enqueue(slice);
8126
+ }
8127
+ }
8128
+ }
8129
+ startTimer() {
8130
+ if (this.flushTimer !== null) return;
8131
+ this.flushTimer = setTimeout(() => {
8132
+ this.flushTimer = null;
8133
+ void this.flush();
8134
+ }, FLUSH_INTERVAL_MS2);
8135
+ }
8136
+ cancelTimer() {
8137
+ if (this.flushTimer !== null) {
8138
+ clearTimeout(this.flushTimer);
8139
+ this.flushTimer = null;
8140
+ }
8141
+ }
8142
+ };
8143
+
8144
+ // src/perf/web-vitals.ts
8145
+ function webVitalItem(name, value) {
8146
+ return {
8147
+ row_id: crypto.randomUUID(),
8148
+ trace_type: "web_vital",
8149
+ name,
8150
+ value: Math.max(0, value),
8151
+ timestamp: Date.now()
8152
+ };
8153
+ }
8154
+ function isLayoutShift(e) {
8155
+ return e.entryType === "layout-shift" && "value" in e && "hadRecentInput" in e;
8156
+ }
8157
+ function isEventTiming(e) {
8158
+ return e.entryType === "event" || e.entryType === "first-input";
8159
+ }
8160
+ function safeObserve(type, cb) {
8161
+ if (typeof PerformanceObserver === "undefined") return null;
8162
+ try {
8163
+ const obs = new PerformanceObserver((list) => cb(list.getEntries()));
8164
+ obs.observe({ type, buffered: true });
8165
+ return obs;
8166
+ } catch {
8167
+ return null;
8168
+ }
8169
+ }
8170
+ function observeWebVitals(record) {
8171
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function" || typeof document.removeEventListener !== "function" || typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
8172
+ return () => {
8173
+ };
8174
+ }
8175
+ let cls = 0;
8176
+ let lcp = 0;
8177
+ let inp = 0;
8178
+ const observers = [
8179
+ // LCP: keep the largest/last reported render.
8180
+ safeObserve("largest-contentful-paint", (entries) => {
8181
+ for (const e of entries) lcp = Math.max(lcp, e.startTime);
8182
+ }),
8183
+ // CLS: sum shift values that weren't caused by recent input.
8184
+ safeObserve("layout-shift", (entries) => {
8185
+ for (const e of entries) if (isLayoutShift(e) && !e.hadRecentInput) cls += e.value;
8186
+ }),
8187
+ // INP: approximate as the worst interaction duration observed.
8188
+ safeObserve("event", (entries) => {
8189
+ for (const e of entries) if (isEventTiming(e)) inp = Math.max(inp, e.duration);
8190
+ }),
8191
+ // FCP: one-shot.
8192
+ safeObserve("paint", (entries) => {
8193
+ for (const e of entries)
8194
+ if (e.name === "first-contentful-paint") record(webVitalItem("FCP", e.startTime));
8195
+ })
8196
+ ];
8197
+ const nav = performance.getEntriesByType("navigation")[0];
8198
+ if (nav && nav.responseStart > 0) record(webVitalItem("TTFB", nav.responseStart));
8199
+ let flushed = false;
8200
+ const flush = () => {
8201
+ if (flushed) return;
8202
+ flushed = true;
8203
+ if (lcp > 0) record(webVitalItem("LCP", lcp));
8204
+ record(webVitalItem("CLS", cls));
8205
+ if (inp > 0) record(webVitalItem("INP", inp));
8206
+ };
8207
+ const onHide = () => {
8208
+ if (document.visibilityState === "hidden") flush();
8209
+ };
8210
+ document.addEventListener("visibilitychange", onHide);
8211
+ return () => {
8212
+ flush();
8213
+ document.removeEventListener("visibilitychange", onHide);
8214
+ for (const o of observers) o?.disconnect();
8215
+ };
8216
+ }
8217
+
7599
8218
  // src/realtime/anon-token.ts
7600
8219
  var REFRESH_SKEW_MS = 6e4;
7601
8220
  var AnonTokenProvider = class {
@@ -8283,10 +8902,13 @@ function defaultSessionStorage(key) {
8283
8902
  }
8284
8903
 
8285
8904
  // src/version.ts
8286
- var VERSION = "1.6.2";
8905
+ var VERSION = "1.8.0";
8287
8906
 
8288
8907
  // src/runtime.ts
8289
8908
  function buildRuntime(config) {
8909
+ const appIdentifier = config.identifier ?? "";
8910
+ const runtimeOrigin = typeof window !== "undefined" && typeof window.location !== "undefined" ? window.location.origin : "";
8911
+ assertOriginMatches(loadAppConfig({ identifier: appIdentifier }), runtimeOrigin);
8290
8912
  const http = new HttpClient(config.apiKey, {
8291
8913
  url: config.url,
8292
8914
  headers: { "X-Client-Info": `palbe-web/${VERSION}`, ...config.headers }
@@ -8336,8 +8958,10 @@ function buildRuntime(config) {
8336
8958
  let analytics;
8337
8959
  let calls;
8338
8960
  let messaging;
8961
+ let perf;
8339
8962
  const rt = {
8340
8963
  config,
8964
+ appIdentifier,
8341
8965
  http,
8342
8966
  tokenManager,
8343
8967
  authClient,
@@ -8373,11 +8997,19 @@ function buildRuntime(config) {
8373
8997
  destroyRealtime() {
8374
8998
  realtime?.destroy();
8375
8999
  realtime = void 0;
9000
+ perf?.dispose();
8376
9001
  },
8377
9002
  // The buffering facade is lazy; its identity state is NOT (below).
8378
9003
  get analytics() {
8379
9004
  if (!analytics) analytics = new PalbeAnalytics(rt, analyticsState);
8380
9005
  return analytics;
9006
+ },
9007
+ // PalPerf is constructed up front (touched below): `request.ts` records a
9008
+ // network trace on EVERY fetch, so it can't be lazy. The memo here only
9009
+ // guards against re-construction.
9010
+ get perf() {
9011
+ if (!perf) perf = new PalbePerf(rt);
9012
+ return perf;
8381
9013
  }
8382
9014
  };
8383
9015
  const analyticsState = new AnalyticsState(rt);
@@ -8385,8 +9017,42 @@ function buildRuntime(config) {
8385
9017
  const hasOwn = Object.keys(request.headers).some((k) => k.toLowerCase() === "x-distinct-id");
8386
9018
  if (!hasOwn) request.headers["X-Distinct-Id"] = analyticsState.distinctId();
8387
9019
  });
9020
+ void rt.perf;
9021
+ if (typeof document !== "undefined") {
9022
+ void new PerfConfigClient().fetchConfig(rt, rt.perf);
9023
+ }
9024
+ recordColdStart(rt);
9025
+ observeWebVitals((item) => rt.perf.record(item));
8388
9026
  return rt;
8389
9027
  }
9028
+ function recordColdStart(rt) {
9029
+ if (typeof document === "undefined" || typeof performance === "undefined") return;
9030
+ try {
9031
+ const navEntry = performance.getEntriesByType("navigation")[0];
9032
+ const startTime = navEntry?.startTime ?? 0;
9033
+ const recordFromFcp = (fcp) => {
9034
+ const item = measureAppStart({ startTime, fcp });
9035
+ if (item) rt.perf.record(item);
9036
+ };
9037
+ const existing = performance.getEntriesByName("first-contentful-paint").find((e) => e.startTime > 0);
9038
+ if (existing) {
9039
+ recordFromFcp(existing.startTime);
9040
+ return;
9041
+ }
9042
+ if (typeof PerformanceObserver === "undefined") return;
9043
+ const observer = new PerformanceObserver((list) => {
9044
+ for (const entry of list.getEntries()) {
9045
+ if (entry.name === "first-contentful-paint") {
9046
+ observer.disconnect();
9047
+ recordFromFcp(entry.startTime);
9048
+ return;
9049
+ }
9050
+ }
9051
+ });
9052
+ observer.observe({ type: "paint", buffered: true });
9053
+ } catch {
9054
+ }
9055
+ }
8390
9056
 
8391
9057
  // src/call.ts
8392
9058
  async function callEndpoint(resolveRt, name, input, options) {
@@ -8565,6 +9231,12 @@ function createClientProxy(resolveRt, nsAccessor) {
8565
9231
  },
8566
9232
  get messaging() {
8567
9233
  return resolveRt().messaging;
9234
+ },
9235
+ get perf() {
9236
+ return resolveRt().perf;
9237
+ },
9238
+ setTestDevice(on) {
9239
+ resolveRt().perf.setTestDevice(on);
8568
9240
  }
8569
9241
  };
8570
9242
  return new Proxy(base, {
@@ -8643,4 +9315,4 @@ export {
8643
9315
  pb,
8644
9316
  createBoundClient
8645
9317
  };
8646
- //# sourceMappingURL=chunk-XQZ53URR.js.map
9318
+ //# sourceMappingURL=chunk-AWVNDAMG.js.map