@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.
package/dist/internal.cjs CHANGED
@@ -413,14 +413,47 @@ function unwrap(res) {
413
413
  return res.data;
414
414
  }
415
415
 
416
+ // src/perf/url-redactor.ts
417
+ 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})$/;
418
+ var EMAIL_SEGMENT = /(?:@|%40)/i;
419
+ function isSensitiveSegment(seg2) {
420
+ return ID_SEGMENT.test(seg2) || EMAIL_SEGMENT.test(seg2);
421
+ }
422
+ function redactUrl(rawUrl) {
423
+ let path = rawUrl;
424
+ try {
425
+ path = new URL(rawUrl).pathname;
426
+ } catch {
427
+ const q = path.indexOf("?");
428
+ if (q >= 0) path = path.slice(0, q);
429
+ }
430
+ return path.split("/").map((seg2) => isSensitiveSegment(seg2) ? ":id" : seg2).join("/");
431
+ }
432
+
416
433
  // src/request.ts
417
434
  var MUTATING = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
435
+ var PERF_EXCLUDED_PREFIX = "/v1/analytics/";
436
+ function isSelfTraced(path) {
437
+ return path.startsWith(PERF_EXCLUDED_PREFIX);
438
+ }
439
+ function nowMs() {
440
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
441
+ }
442
+ function isAbort(e) {
443
+ return e instanceof Error && e.name === "AbortError";
444
+ }
418
445
  async function palbeRequest(rt, method, path, spec = {}) {
419
446
  const headers = { ...spec.headers };
420
447
  const callerHasKey = Object.keys(headers).some((k) => k.toLowerCase() === "idempotency-key");
421
448
  if (MUTATING.has(method) && !callerHasKey) {
422
449
  headers["Idempotency-Key"] = crypto.randomUUID();
423
450
  }
451
+ if (rt.appIdentifier !== "") {
452
+ const callerHasBundle = Object.keys(headers).some(
453
+ (k) => k.toLowerCase() === "x-palbase-bundle"
454
+ );
455
+ if (!callerHasBundle) headers["X-Palbase-Bundle"] = rt.appIdentifier;
456
+ }
424
457
  const attempt = async () => {
425
458
  try {
426
459
  return await rt.http.request(method, path, {
@@ -433,21 +466,38 @@ async function palbeRequest(rt, method, path, spec = {}) {
433
466
  throw pe ? fromPalbaseError(pe) : e;
434
467
  }
435
468
  };
436
- let res = await attempt();
437
- if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
438
- try {
439
- await rt.tokenManager.refreshSession();
440
- } catch (refreshErr) {
441
- const pe = asPalbaseError(refreshErr);
442
- const status = pe?.status ?? 0;
443
- if (status === 400 || status === 401 || status === 403) {
444
- rt.tokenManager.clearSession();
445
- throw fromPalbaseError(res.error);
469
+ const traced = !isSelfTraced(path) && rt.perf !== void 0;
470
+ const startedAt = traced ? nowMs() : 0;
471
+ let recorded = false;
472
+ const record = (status) => {
473
+ if (!traced || recorded) return;
474
+ recorded = true;
475
+ rt.perf.recordNetwork(method, redactUrl(path), status, nowMs() - startedAt);
476
+ };
477
+ let res;
478
+ try {
479
+ res = await attempt();
480
+ if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
481
+ try {
482
+ await rt.tokenManager.refreshSession();
483
+ } catch (refreshErr) {
484
+ const pe = asPalbaseError(refreshErr);
485
+ const status = pe?.status ?? 0;
486
+ if (status === 400 || status === 401 || status === 403) {
487
+ rt.tokenManager.clearSession();
488
+ record(res.error.status);
489
+ throw fromPalbaseError(res.error);
490
+ }
491
+ record(pe?.status ?? 0);
492
+ throw pe ? fromPalbaseError(pe) : refreshErr;
446
493
  }
447
- throw pe ? fromPalbaseError(pe) : refreshErr;
494
+ res = await attempt();
448
495
  }
449
- res = await attempt();
496
+ } catch (e) {
497
+ if (!isAbort(e)) record(asPalbaseError(e)?.status ?? 0);
498
+ throw e;
450
499
  }
500
+ record(res.error?.status ?? 200);
451
501
  return unwrap(res);
452
502
  }
453
503
 
@@ -464,7 +514,7 @@ function palbeState() {
464
514
  }
465
515
 
466
516
  // src/namespaces.ts
467
- var FIXED_SURFACE = /* @__PURE__ */ new Set([
517
+ var RESERVED = /* @__PURE__ */ new Set([
468
518
  "call",
469
519
  "upload",
470
520
  "auth",
@@ -472,7 +522,8 @@ var FIXED_SURFACE = /* @__PURE__ */ new Set([
472
522
  "realtime",
473
523
  "analytics",
474
524
  "calls",
475
- "messaging"
525
+ "messaging",
526
+ "perf"
476
527
  ]);
477
528
  function reservedNamespaceError(key) {
478
529
  return new BackendError("validation", {
@@ -537,7 +588,7 @@ function validateTree(node) {
537
588
  }
538
589
  function __registerNamespaces(tree) {
539
590
  for (const key of Object.keys(tree)) {
540
- if (FIXED_SURFACE.has(key)) throw reservedNamespaceError(key);
591
+ if (RESERVED.has(key)) throw reservedNamespaceError(key);
541
592
  }
542
593
  validateTree(tree);
543
594
  const state = palbeState();
@@ -1386,6 +1437,23 @@ var PalbeAnalytics = class {
1386
1437
  }
1387
1438
  };
1388
1439
 
1440
+ // src/app-config.ts
1441
+ function loadAppConfig(raw) {
1442
+ if (typeof raw !== "object" || raw === null) {
1443
+ throw new Error("app_config_invalid: expected a JSON object");
1444
+ }
1445
+ const r = raw;
1446
+ const str = (k) => typeof r[k] === "string" ? r[k] : "";
1447
+ return { appId: str("app_id"), identifier: str("identifier"), envPreset: str("env_preset") };
1448
+ }
1449
+ function assertOriginMatches(cfg, runtimeOrigin) {
1450
+ if (cfg.identifier === "") return;
1451
+ if (runtimeOrigin === "") return;
1452
+ if (runtimeOrigin !== cfg.identifier) {
1453
+ throw new Error(`app_config_mismatch: expected origin ${cfg.identifier}, got ${runtimeOrigin}`);
1454
+ }
1455
+ }
1456
+
1389
1457
  // src/auth-wire.ts
1390
1458
  function asWireAuthResult(raw) {
1391
1459
  if (typeof raw !== "object" || raw === null) return null;
@@ -2905,6 +2973,10 @@ var MessagingPaths = {
2905
2973
  groupMessages: (displayId) => `${GROUPS}/${seg(displayId)}/messages`,
2906
2974
  groupCommits: (displayId) => `${GROUPS}/${seg(displayId)}/commits`,
2907
2975
  groupRead: (displayId) => `${GROUPS}/${seg(displayId)}/read`,
2976
+ // Server-metadata cluster: the per-(user,group) notify scope (mute toggle, GET/PUT)
2977
+ // + the caller's own unread view (GET). {gid} is the grp_ display_id (M3 #7).
2978
+ groupNotify: (displayId) => `${GROUPS}/${seg(displayId)}/notify`,
2979
+ groupUnread: (displayId) => `${GROUPS}/${seg(displayId)}/unread`,
2908
2980
  deviceWelcomes: (deviceId) => `${DEVICES}/${seg(deviceId)}/welcomes`,
2909
2981
  deviceQueue: (deviceId) => `${DEVICES}/${seg(deviceId)}/queue`,
2910
2982
  deviceQueueAck: (deviceId) => `${DEVICES}/${seg(deviceId)}/queue/ack`,
@@ -4483,6 +4555,18 @@ var Chat = class {
4483
4555
  lastSeenAt: ts ? new Date(ts * 1e3) : null
4484
4556
  });
4485
4557
  this.emit();
4558
+ } else if (event === "delivered" || event === "read") {
4559
+ const seq = typeof payload.up_to_server_seq === "number" ? payload.up_to_server_seq : null;
4560
+ if (seq === null) return;
4561
+ let changed = false;
4562
+ this.messageList = this.messageList.map((m) => {
4563
+ if (m.direction !== "outgoing" || m.serverSeq > seq) return m;
4564
+ const cur = event === "delivered" ? m.deliveredUpTo ?? -1 : m.readUpTo ?? -1;
4565
+ if (seq <= cur) return m;
4566
+ changed = true;
4567
+ return event === "delivered" ? { ...m, deliveredUpTo: seq } : { ...m, readUpTo: seq };
4568
+ });
4569
+ if (changed) this.emit();
4486
4570
  }
4487
4571
  }
4488
4572
  kindOf(incoming) {
@@ -4731,6 +4815,31 @@ var Chat = class {
4731
4815
  this.readWatermark = Math.max(this.readWatermark, message.serverSeq);
4732
4816
  this.emit();
4733
4817
  }
4818
+ // ── Server-metadata cluster: notify scope (mute) + server unread ──
4819
+ /** Set this chat's notify scope (the mute toggle). `'none'` mutes the push wake;
4820
+ * `'all'` unmutes (the default). Materializes a draft first (the scope is a
4821
+ * per-(user,group) server row), PUTs `/notify`, and returns the server-echoed scope.
4822
+ * Mirrors iOS `Chat.setNotifyScope`. */
4823
+ async setNotifyScope(scope) {
4824
+ const group = await this.materializeIfNeeded();
4825
+ return this.backend.setNotifyScope(group, scope);
4826
+ }
4827
+ /** Refresh + return this chat's notify scope from the server (fail-OPEN to `'all'`).
4828
+ * Returns `'all'` for a draft chat (no server row yet). */
4829
+ async getNotifyScope() {
4830
+ if (!this._group) return "all";
4831
+ return this.backend.getNotifyScope(this._group);
4832
+ }
4833
+ /** Fetch the caller's authoritative SERVER unread count (opaque server metadata).
4834
+ * Returns the clamped count (`max(0, …)`); `0` for a draft chat. The local computed
4835
+ * `unreadCount` getter stays the instant, offline best-effort badge — this is the
4836
+ * canonical count on demand. Named distinctly so it does not shadow the observable
4837
+ * `unreadCount` snapshot getter. Mirrors iOS `Chat.refreshUnread`. */
4838
+ async unreadCountFromServer() {
4839
+ if (!this._group) return 0;
4840
+ const v = await this.backend.unread(this._group);
4841
+ return Math.max(0, v.unreadCount);
4842
+ }
4734
4843
  // ── Reactions ──
4735
4844
  /** Add an emoji reaction to a message. No-op if the message isn't reactable
4736
4845
  * (empty clientMsgId — a legacy/system row). The reaction folds locally with
@@ -5169,7 +5278,7 @@ var MessageDeliverySource = class {
5169
5278
  if (this.observed.has(group.displayId)) return;
5170
5279
  try {
5171
5280
  const channel = this.rt.realtime.channel(`messaging:conv:${group.rfcGroupId}`);
5172
- const subs = ["presence", "typing", "read"].map(
5281
+ const subs = ["presence", "typing", "read", "delivered"].map(
5173
5282
  (ev) => channel.on(ev, (payload) => {
5174
5283
  this.hub.emitConv(group.displayId, { event: ev, payload });
5175
5284
  })
@@ -5209,6 +5318,40 @@ var MessageDeliverySource = class {
5209
5318
  body: { read_seq: upToServerSeq, read_epoch: group.currentEpoch, is_private: false }
5210
5319
  });
5211
5320
  }
5321
+ // ── Server-metadata cluster: notify scope (mute) + unread (HTTP) ──
5322
+ /** PUT `/v1/messaging/groups/{gid}/notify` — set the caller's per-(user,group) notify
5323
+ * scope (`'all'`|`'none'`). The server stores the opaque enum verbatim and stays blind.
5324
+ * Returns the server-echoed scope (fail-OPEN to `'all'` on an unknown value). */
5325
+ async setNotifyScope(group, scope) {
5326
+ const res = await palbeRequest(
5327
+ this.rt,
5328
+ "PUT",
5329
+ MessagingPaths.groupNotify(group.displayId),
5330
+ { body: { notify_scope: scope } }
5331
+ );
5332
+ return res.notify_scope === "none" ? "none" : "all";
5333
+ }
5334
+ /** GET `/v1/messaging/groups/{gid}/notify` — the caller's notify scope. An absent
5335
+ * server row / unknown value reads as `'all'` (fail-OPEN — never silently mutes). */
5336
+ async getNotifyScope(group) {
5337
+ const res = await palbeRequest(
5338
+ this.rt,
5339
+ "GET",
5340
+ MessagingPaths.groupNotify(group.displayId)
5341
+ );
5342
+ return res.notify_scope === "none" ? "none" : "all";
5343
+ }
5344
+ /** GET `/v1/messaging/groups/{gid}/unread` — the caller's OWN unread view (opaque
5345
+ * server metadata). Maps the snake_case wire → the camelCase `UnreadView`. */
5346
+ async unread(group) {
5347
+ const res = await palbeRequest(this.rt, "GET", MessagingPaths.groupUnread(group.displayId));
5348
+ return {
5349
+ groupMaxSeq: res.group_max_seq,
5350
+ lastReadSeq: res.last_read_seq,
5351
+ deliveredSeq: res.delivered_seq,
5352
+ unreadCount: res.unread_count
5353
+ };
5354
+ }
5212
5355
  };
5213
5356
  function isOwnEchoOrConsumed(e) {
5214
5357
  const msg = e instanceof Error ? e.message : String(e);
@@ -7366,6 +7509,19 @@ var MessagingCoordinator = class {
7366
7509
  const r = await this.resolve();
7367
7510
  await r.source.markRead(group, upToServerSeq);
7368
7511
  }
7512
+ // ── Server-metadata cluster: notify scope (mute) + unread ──
7513
+ async setNotifyScope(group, scope) {
7514
+ const r = await this.resolve();
7515
+ return r.source.setNotifyScope(group, scope);
7516
+ }
7517
+ async getNotifyScope(group) {
7518
+ const r = await this.resolve();
7519
+ return r.source.getNotifyScope(group);
7520
+ }
7521
+ async unread(group) {
7522
+ const r = await this.resolve();
7523
+ return r.source.unread(group);
7524
+ }
7369
7525
  subscribeLive(group, chat) {
7370
7526
  let offMsg = null;
7371
7527
  let offConv = null;
@@ -7618,6 +7774,469 @@ var PalbeMessaging = class {
7618
7774
  }
7619
7775
  };
7620
7776
 
7777
+ // src/perf/app-start.ts
7778
+ function measureAppStart(nav) {
7779
+ if (!(nav.fcp > 0)) return null;
7780
+ const value = nav.fcp - nav.startTime;
7781
+ if (value <= 0) return null;
7782
+ return {
7783
+ row_id: crypto.randomUUID(),
7784
+ trace_type: "app_start",
7785
+ name: "cold_start",
7786
+ value,
7787
+ timestamp: Date.now()
7788
+ };
7789
+ }
7790
+
7791
+ // src/perf/perf-config-client.ts
7792
+ var PERF_CONFIG_PATH = "/v1/analytics/perf/config";
7793
+ var FNV_OFFSET_BASIS_32 = 2166136261;
7794
+ var FNV_PRIME_32 = 16777619;
7795
+ var utf82 = typeof TextEncoder !== "undefined" ? new TextEncoder() : { encode: (s) => Uint8Array.from(Buffer.from(s, "utf-8")) };
7796
+ function perfSampleBucket(rowId) {
7797
+ let hash = FNV_OFFSET_BASIS_32;
7798
+ for (const byte of utf82.encode(rowId)) {
7799
+ hash ^= byte;
7800
+ hash = Math.imul(hash, FNV_PRIME_32);
7801
+ }
7802
+ return (hash >>> 0) % 100;
7803
+ }
7804
+ function isPerfConfig(value) {
7805
+ if (typeof value !== "object" || value === null) return false;
7806
+ const o = value;
7807
+ return typeof o.sample_pct === "number";
7808
+ }
7809
+ function isRawResponse(value) {
7810
+ return typeof value === "object" && value !== null && "data" in value;
7811
+ }
7812
+ function headerValue(headers, name) {
7813
+ if (!headers) return void 0;
7814
+ const lower = name.toLowerCase();
7815
+ for (const [k, v] of Object.entries(headers)) {
7816
+ if (k.toLowerCase() === lower) return v;
7817
+ }
7818
+ return void 0;
7819
+ }
7820
+ var PerfConfigClient = class {
7821
+ etag;
7822
+ async fetchConfig(rt, perf) {
7823
+ const headers = {};
7824
+ if (this.etag) headers["If-None-Match"] = this.etag;
7825
+ try {
7826
+ const res = await rt.http.request("GET", PERF_CONFIG_PATH, { headers });
7827
+ if (!isRawResponse(res)) return;
7828
+ if (res.status === 304 || res.error && res.error.status === 304) return;
7829
+ if (res.error) return;
7830
+ const newEtag = headerValue(res.headers, "etag");
7831
+ if (newEtag) this.etag = newEtag;
7832
+ if (isPerfConfig(res.data)) {
7833
+ perf.setSamplePct(res.data.sample_pct);
7834
+ }
7835
+ } catch {
7836
+ }
7837
+ }
7838
+ };
7839
+
7840
+ // src/perf/fetch-swizzle.ts
7841
+ var PERF_EXCLUDED_PREFIX2 = "/v1/analytics/";
7842
+ function nowMs2() {
7843
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7844
+ }
7845
+ function describeRequest(input, init) {
7846
+ let url;
7847
+ let method = "GET";
7848
+ if (typeof input === "string") {
7849
+ url = input;
7850
+ } else if (input instanceof URL) {
7851
+ url = input.href;
7852
+ } else {
7853
+ url = input.url;
7854
+ method = input.method;
7855
+ }
7856
+ if (init?.method) method = init.method;
7857
+ return { url, method: method.toUpperCase() };
7858
+ }
7859
+ function pathOf(url) {
7860
+ try {
7861
+ return new URL(url, "http://_local").pathname;
7862
+ } catch {
7863
+ return url;
7864
+ }
7865
+ }
7866
+ function installFetchSwizzle(perf) {
7867
+ if (typeof fetch !== "function") return () => {
7868
+ };
7869
+ const original = fetch;
7870
+ const wrapped = async (input, init) => {
7871
+ const { url, method } = describeRequest(input, init);
7872
+ const traced = !pathOf(url).startsWith(PERF_EXCLUDED_PREFIX2);
7873
+ const startedAt = traced ? nowMs2() : 0;
7874
+ try {
7875
+ const res = await original(input, init);
7876
+ if (traced) perf.recordNetwork(method, redactUrl(url), res.status, nowMs2() - startedAt);
7877
+ return res;
7878
+ } catch (err) {
7879
+ if (traced) perf.recordNetwork(method, redactUrl(url), 0, nowMs2() - startedAt);
7880
+ throw err;
7881
+ }
7882
+ };
7883
+ globalThis.fetch = wrapped;
7884
+ return () => {
7885
+ globalThis.fetch = original;
7886
+ };
7887
+ }
7888
+
7889
+ // src/perf/offline-queue.ts
7890
+ var PERF_QUEUE_KEY = "palbe.perf.queue";
7891
+ var DEFAULT_MAX_ITEMS = 500;
7892
+ function canPersist() {
7893
+ return typeof document !== "undefined" && typeof localStorage !== "undefined";
7894
+ }
7895
+ function isPerfItem(value) {
7896
+ if (typeof value !== "object" || value === null) return false;
7897
+ const o = value;
7898
+ return typeof o.row_id === "string" && typeof o.trace_type === "string" && typeof o.name === "string" && typeof o.value === "number" && typeof o.timestamp === "number";
7899
+ }
7900
+ function readPersisted() {
7901
+ if (!canPersist()) return [];
7902
+ try {
7903
+ const raw = localStorage.getItem(PERF_QUEUE_KEY);
7904
+ if (!raw) return [];
7905
+ const parsed = JSON.parse(raw);
7906
+ if (!Array.isArray(parsed)) return [];
7907
+ return parsed.filter(isPerfItem);
7908
+ } catch {
7909
+ return [];
7910
+ }
7911
+ }
7912
+ var PerfOfflineQueue = class {
7913
+ items;
7914
+ _dropped = 0;
7915
+ maxItems;
7916
+ constructor(maxItems = DEFAULT_MAX_ITEMS) {
7917
+ this.maxItems = Math.max(1, maxItems);
7918
+ this.items = readPersisted();
7919
+ this.trim();
7920
+ }
7921
+ /** Append items; FIFO-evict the oldest when over `maxItems`. */
7922
+ enqueue(items) {
7923
+ if (items.length === 0) return;
7924
+ this.items.push(...items);
7925
+ this.trim();
7926
+ this.persist();
7927
+ }
7928
+ /** Return all queued items (oldest-first) and clear the queue + store. */
7929
+ drainAll() {
7930
+ if (this.items.length === 0) return [];
7931
+ const out = this.items;
7932
+ this.items = [];
7933
+ this.clearStore();
7934
+ return out;
7935
+ }
7936
+ /** Current queue depth. */
7937
+ get count() {
7938
+ return this.items.length;
7939
+ }
7940
+ /** Cumulative count of FIFO-evicted items (a `dropped` metric, not silent). */
7941
+ get dropped() {
7942
+ return this._dropped;
7943
+ }
7944
+ /** Drop the oldest items until at most `maxItems` remain, counting each. */
7945
+ trim() {
7946
+ const overflow = this.items.length - this.maxItems;
7947
+ if (overflow > 0) {
7948
+ this.items.splice(0, overflow);
7949
+ this._dropped += overflow;
7950
+ }
7951
+ }
7952
+ persist() {
7953
+ if (!canPersist()) return;
7954
+ try {
7955
+ localStorage.setItem(PERF_QUEUE_KEY, JSON.stringify(this.items));
7956
+ } catch {
7957
+ }
7958
+ }
7959
+ clearStore() {
7960
+ if (!canPersist()) return;
7961
+ try {
7962
+ localStorage.removeItem(PERF_QUEUE_KEY);
7963
+ } catch {
7964
+ }
7965
+ }
7966
+ };
7967
+
7968
+ // src/perf/perf-state.ts
7969
+ var MAX_PERF_BATCH = 100;
7970
+ var PerfState = class {
7971
+ /** Pending, un-flushed perf items (FIFO). */
7972
+ buffer = [];
7973
+ /** When true, every flush carries `X-Palbase-Test-Device: 1`. */
7974
+ testDevice = false;
7975
+ enqueue(item) {
7976
+ this.buffer.push(item);
7977
+ }
7978
+ /** Detach up to `MAX_PERF_BATCH` items for one POST (FIFO order preserved). */
7979
+ take(limit = MAX_PERF_BATCH) {
7980
+ return this.buffer.splice(0, limit);
7981
+ }
7982
+ get size() {
7983
+ return this.buffer.length;
7984
+ }
7985
+ };
7986
+
7987
+ // src/perf/perf-wire.ts
7988
+ function encodePerfBatch(items) {
7989
+ return { items };
7990
+ }
7991
+
7992
+ // src/perf/perf-facade.ts
7993
+ var FLUSH_AT2 = 20;
7994
+ var FLUSH_INTERVAL_MS2 = 1e4;
7995
+ var TEST_DEVICE_HEADER = "X-Palbase-Test-Device";
7996
+ function nowMs3() {
7997
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7998
+ }
7999
+ var PerfTrace = class {
8000
+ constructor(name, onStop) {
8001
+ this.name = name;
8002
+ this.onStop = onStop;
8003
+ }
8004
+ name;
8005
+ onStop;
8006
+ attrs = {};
8007
+ counters = {};
8008
+ startedAt = nowMs3();
8009
+ stopped = false;
8010
+ putAttribute(key, value) {
8011
+ this.attrs[key] = value;
8012
+ }
8013
+ incrementMetric(name, by = 1) {
8014
+ this.counters[name] = (this.counters[name] ?? 0) + by;
8015
+ }
8016
+ stop() {
8017
+ if (this.stopped) return;
8018
+ this.stopped = true;
8019
+ const item = {
8020
+ row_id: crypto.randomUUID(),
8021
+ trace_type: "custom",
8022
+ name: this.name,
8023
+ value: Math.max(0, nowMs3() - this.startedAt),
8024
+ timestamp: Date.now()
8025
+ };
8026
+ if (Object.keys(this.attrs).length > 0) item.attrs = this.attrs;
8027
+ if (Object.keys(this.counters).length > 0) item.counters = this.counters;
8028
+ this.onStop(item);
8029
+ }
8030
+ };
8031
+ var PalbePerf = class {
8032
+ constructor(rt, state = new PerfState(), queue = new PerfOfflineQueue()) {
8033
+ this.rt = rt;
8034
+ this.state = state;
8035
+ this.queue = queue;
8036
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
8037
+ window.addEventListener("online", this.onOnline);
8038
+ }
8039
+ }
8040
+ rt;
8041
+ state;
8042
+ queue;
8043
+ flushTimer = null;
8044
+ /** Browser → buffer + size/timer flush. Server (no document) → immediate
8045
+ * per-item flush, zero timers (nothing leaks into RSC/route handlers). */
8046
+ browser = typeof document !== "undefined";
8047
+ /** Bound `online` handler so it can be removed on `dispose()` (no leak). */
8048
+ onOnline = () => {
8049
+ void this.flush();
8050
+ };
8051
+ /** Server-controlled client-side sample rate (0..100). 100 until the config
8052
+ * client fetches `/v1/analytics/perf/config` — the SDK obeys this ceiling and
8053
+ * never raises its own rate (invariant 3). `record` drops an item whose
8054
+ * deterministic `row_id` bucket is >= this pct, mirroring the server's
8055
+ * `SampleDecision` so client + server keep the SAME rows. */
8056
+ samplePct = 100;
8057
+ /** Remove the `online` listener. Called when the runtime is replaced so the
8058
+ * handler does not outlive this facade. No-op outside the browser. */
8059
+ dispose() {
8060
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
8061
+ window.removeEventListener("online", this.onOnline);
8062
+ }
8063
+ }
8064
+ /** Mark (or unmark) this client's traffic as test — the server tags the rows
8065
+ * when the `X-Palbase-Test-Device: 1` header rides along on flush. */
8066
+ setTestDevice(on) {
8067
+ this.state.testDevice = on;
8068
+ }
8069
+ /** Apply the server-resolved client-side sample rate (0..100), clamped. Called
8070
+ * by `PerfConfigClient` after fetching `/v1/analytics/perf/config`. The SDK
8071
+ * OBEYS this value — it is a ceiling, never raised locally. */
8072
+ setSamplePct(pct) {
8073
+ this.samplePct = Math.max(0, Math.min(100, Math.trunc(pct)));
8074
+ }
8075
+ /** Pending (un-flushed) buffer depth. Test-only visibility into the buffer so
8076
+ * the remote-sampling tests can assert how many items were sampled in. */
8077
+ get bufferSizeForTest() {
8078
+ return this.state.size;
8079
+ }
8080
+ /** Opt-in: wrap the global `fetch` so every app-level request is recorded as a
8081
+ * redacted `network` perf item (the `/v1/analytics/*` ingest paths are
8082
+ * self-excluded). OFF by default — swizzling a global is a page-wide side
8083
+ * effect. Returns an `uninstall` that restores the original `fetch`. */
8084
+ enableFetchCapture() {
8085
+ return installFetchSwizzle(this);
8086
+ }
8087
+ /** Start a custom trace; the returned handle records a `custom` item on
8088
+ * `.stop()`. */
8089
+ startTrace(name) {
8090
+ return new PerfTrace(name, (item) => this.record(item));
8091
+ }
8092
+ /** Buffer one perf item. In the browser, flush on size/timer; on the server
8093
+ * flush immediately (no timers). Never throws.
8094
+ *
8095
+ * Client-side remote sampling: an item whose deterministic `row_id` bucket is
8096
+ * NOT below the server-controlled `samplePct` is dropped before buffering —
8097
+ * the SAME FNV-1a-mod-100 decision the server applies at ingest, so the two
8098
+ * keep identical rows and the SDK saves the upload (defense-in-depth: the
8099
+ * server re-samples authoritatively). */
8100
+ record(item) {
8101
+ if (perfSampleBucket(item.row_id) >= this.samplePct) return;
8102
+ this.state.enqueue(item);
8103
+ if (!this.browser) {
8104
+ void this.flush();
8105
+ return;
8106
+ }
8107
+ if (this.state.size >= FLUSH_AT2) void this.flush();
8108
+ else this.startTimer();
8109
+ }
8110
+ /** Buffer a network trace — called by `request.ts` around `rt.http.request`
8111
+ * (the analytics ingest path is excluded by the caller to avoid recursion). */
8112
+ recordNetwork(method, url, status, durationMs, requestId) {
8113
+ const item = {
8114
+ row_id: crypto.randomUUID(),
8115
+ trace_type: "network",
8116
+ name: `${method} ${url}`,
8117
+ value: durationMs,
8118
+ attrs: { status: String(status) },
8119
+ timestamp: Date.now()
8120
+ };
8121
+ if (requestId) item.request_id = requestId;
8122
+ this.record(item);
8123
+ }
8124
+ /** Drain the offline queue (oldest-first) and the live buffer to
8125
+ * `/v1/analytics/perf` (≤100 items per request, sequential). Resolves when
8126
+ * delivery finished; NEVER rejects. A failed slice is NOT dropped — it goes
8127
+ * to the offline queue (persist-on-fail) so the next flush (size/timer or the
8128
+ * `online` reconnect event) retries it. Items keep their original `row_id`,
8129
+ * so a redelivery dedups server-side (ReplacingMergeTree). */
8130
+ async flush() {
8131
+ this.cancelTimer();
8132
+ const pending = this.queue.drainAll();
8133
+ if (pending.length === 0 && this.state.size === 0) return;
8134
+ const headers = this.state.testDevice ? { [TEST_DEVICE_HEADER]: "1" } : void 0;
8135
+ const remaining = [...pending];
8136
+ while (this.state.size > 0) remaining.push(...this.state.take(MAX_PERF_BATCH));
8137
+ for (let i = 0; i < remaining.length; i += MAX_PERF_BATCH) {
8138
+ const slice = remaining.slice(i, i + MAX_PERF_BATCH);
8139
+ try {
8140
+ await palbeRequest(
8141
+ this.rt,
8142
+ "POST",
8143
+ "/v1/analytics/perf",
8144
+ { body: encodePerfBatch(slice), headers }
8145
+ );
8146
+ } catch {
8147
+ this.queue.enqueue(slice);
8148
+ }
8149
+ }
8150
+ }
8151
+ startTimer() {
8152
+ if (this.flushTimer !== null) return;
8153
+ this.flushTimer = setTimeout(() => {
8154
+ this.flushTimer = null;
8155
+ void this.flush();
8156
+ }, FLUSH_INTERVAL_MS2);
8157
+ }
8158
+ cancelTimer() {
8159
+ if (this.flushTimer !== null) {
8160
+ clearTimeout(this.flushTimer);
8161
+ this.flushTimer = null;
8162
+ }
8163
+ }
8164
+ };
8165
+
8166
+ // src/perf/web-vitals.ts
8167
+ function webVitalItem(name, value) {
8168
+ return {
8169
+ row_id: crypto.randomUUID(),
8170
+ trace_type: "web_vital",
8171
+ name,
8172
+ value: Math.max(0, value),
8173
+ timestamp: Date.now()
8174
+ };
8175
+ }
8176
+ function isLayoutShift(e) {
8177
+ return e.entryType === "layout-shift" && "value" in e && "hadRecentInput" in e;
8178
+ }
8179
+ function isEventTiming(e) {
8180
+ return e.entryType === "event" || e.entryType === "first-input";
8181
+ }
8182
+ function safeObserve(type, cb) {
8183
+ if (typeof PerformanceObserver === "undefined") return null;
8184
+ try {
8185
+ const obs = new PerformanceObserver((list) => cb(list.getEntries()));
8186
+ obs.observe({ type, buffered: true });
8187
+ return obs;
8188
+ } catch {
8189
+ return null;
8190
+ }
8191
+ }
8192
+ function observeWebVitals(record) {
8193
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function" || typeof document.removeEventListener !== "function" || typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
8194
+ return () => {
8195
+ };
8196
+ }
8197
+ let cls = 0;
8198
+ let lcp = 0;
8199
+ let inp = 0;
8200
+ const observers = [
8201
+ // LCP: keep the largest/last reported render.
8202
+ safeObserve("largest-contentful-paint", (entries) => {
8203
+ for (const e of entries) lcp = Math.max(lcp, e.startTime);
8204
+ }),
8205
+ // CLS: sum shift values that weren't caused by recent input.
8206
+ safeObserve("layout-shift", (entries) => {
8207
+ for (const e of entries) if (isLayoutShift(e) && !e.hadRecentInput) cls += e.value;
8208
+ }),
8209
+ // INP: approximate as the worst interaction duration observed.
8210
+ safeObserve("event", (entries) => {
8211
+ for (const e of entries) if (isEventTiming(e)) inp = Math.max(inp, e.duration);
8212
+ }),
8213
+ // FCP: one-shot.
8214
+ safeObserve("paint", (entries) => {
8215
+ for (const e of entries)
8216
+ if (e.name === "first-contentful-paint") record(webVitalItem("FCP", e.startTime));
8217
+ })
8218
+ ];
8219
+ const nav = performance.getEntriesByType("navigation")[0];
8220
+ if (nav && nav.responseStart > 0) record(webVitalItem("TTFB", nav.responseStart));
8221
+ let flushed = false;
8222
+ const flush = () => {
8223
+ if (flushed) return;
8224
+ flushed = true;
8225
+ if (lcp > 0) record(webVitalItem("LCP", lcp));
8226
+ record(webVitalItem("CLS", cls));
8227
+ if (inp > 0) record(webVitalItem("INP", inp));
8228
+ };
8229
+ const onHide = () => {
8230
+ if (document.visibilityState === "hidden") flush();
8231
+ };
8232
+ document.addEventListener("visibilitychange", onHide);
8233
+ return () => {
8234
+ flush();
8235
+ document.removeEventListener("visibilitychange", onHide);
8236
+ for (const o of observers) o?.disconnect();
8237
+ };
8238
+ }
8239
+
7621
8240
  // src/realtime/anon-token.ts
7622
8241
  var REFRESH_SKEW_MS = 6e4;
7623
8242
  var AnonTokenProvider = class {
@@ -8305,10 +8924,13 @@ function defaultSessionStorage(key) {
8305
8924
  }
8306
8925
 
8307
8926
  // src/version.ts
8308
- var VERSION = "1.6.2";
8927
+ var VERSION = "1.8.0";
8309
8928
 
8310
8929
  // src/runtime.ts
8311
8930
  function buildRuntime(config) {
8931
+ const appIdentifier = config.identifier ?? "";
8932
+ const runtimeOrigin = typeof window !== "undefined" && typeof window.location !== "undefined" ? window.location.origin : "";
8933
+ assertOriginMatches(loadAppConfig({ identifier: appIdentifier }), runtimeOrigin);
8312
8934
  const http = new HttpClient(config.apiKey, {
8313
8935
  url: config.url,
8314
8936
  headers: { "X-Client-Info": `palbe-web/${VERSION}`, ...config.headers }
@@ -8358,8 +8980,10 @@ function buildRuntime(config) {
8358
8980
  let analytics;
8359
8981
  let calls;
8360
8982
  let messaging;
8983
+ let perf;
8361
8984
  const rt = {
8362
8985
  config,
8986
+ appIdentifier,
8363
8987
  http,
8364
8988
  tokenManager,
8365
8989
  authClient,
@@ -8395,11 +9019,19 @@ function buildRuntime(config) {
8395
9019
  destroyRealtime() {
8396
9020
  realtime?.destroy();
8397
9021
  realtime = void 0;
9022
+ perf?.dispose();
8398
9023
  },
8399
9024
  // The buffering facade is lazy; its identity state is NOT (below).
8400
9025
  get analytics() {
8401
9026
  if (!analytics) analytics = new PalbeAnalytics(rt, analyticsState);
8402
9027
  return analytics;
9028
+ },
9029
+ // PalPerf is constructed up front (touched below): `request.ts` records a
9030
+ // network trace on EVERY fetch, so it can't be lazy. The memo here only
9031
+ // guards against re-construction.
9032
+ get perf() {
9033
+ if (!perf) perf = new PalbePerf(rt);
9034
+ return perf;
8403
9035
  }
8404
9036
  };
8405
9037
  const analyticsState = new AnalyticsState(rt);
@@ -8407,8 +9039,42 @@ function buildRuntime(config) {
8407
9039
  const hasOwn = Object.keys(request.headers).some((k) => k.toLowerCase() === "x-distinct-id");
8408
9040
  if (!hasOwn) request.headers["X-Distinct-Id"] = analyticsState.distinctId();
8409
9041
  });
9042
+ void rt.perf;
9043
+ if (typeof document !== "undefined") {
9044
+ void new PerfConfigClient().fetchConfig(rt, rt.perf);
9045
+ }
9046
+ recordColdStart(rt);
9047
+ observeWebVitals((item) => rt.perf.record(item));
8410
9048
  return rt;
8411
9049
  }
9050
+ function recordColdStart(rt) {
9051
+ if (typeof document === "undefined" || typeof performance === "undefined") return;
9052
+ try {
9053
+ const navEntry = performance.getEntriesByType("navigation")[0];
9054
+ const startTime = navEntry?.startTime ?? 0;
9055
+ const recordFromFcp = (fcp) => {
9056
+ const item = measureAppStart({ startTime, fcp });
9057
+ if (item) rt.perf.record(item);
9058
+ };
9059
+ const existing = performance.getEntriesByName("first-contentful-paint").find((e) => e.startTime > 0);
9060
+ if (existing) {
9061
+ recordFromFcp(existing.startTime);
9062
+ return;
9063
+ }
9064
+ if (typeof PerformanceObserver === "undefined") return;
9065
+ const observer = new PerformanceObserver((list) => {
9066
+ for (const entry of list.getEntries()) {
9067
+ if (entry.name === "first-contentful-paint") {
9068
+ observer.disconnect();
9069
+ recordFromFcp(entry.startTime);
9070
+ return;
9071
+ }
9072
+ }
9073
+ });
9074
+ observer.observe({ type: "paint", buffered: true });
9075
+ } catch {
9076
+ }
9077
+ }
8412
9078
 
8413
9079
  // src/call.ts
8414
9080
  async function callEndpoint(resolveRt, name, input, options) {
@@ -8587,6 +9253,12 @@ function createClientProxy(resolveRt, nsAccessor) {
8587
9253
  },
8588
9254
  get messaging() {
8589
9255
  return resolveRt().messaging;
9256
+ },
9257
+ get perf() {
9258
+ return resolveRt().perf;
9259
+ },
9260
+ setTestDevice(on) {
9261
+ resolveRt().perf.setTestDevice(on);
8590
9262
  }
8591
9263
  };
8592
9264
  return new Proxy(base, {