@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.
@@ -531,14 +531,47 @@ function unwrap(res) {
531
531
  return res.data;
532
532
  }
533
533
 
534
+ // src/perf/url-redactor.ts
535
+ 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})$/;
536
+ var EMAIL_SEGMENT = /(?:@|%40)/i;
537
+ function isSensitiveSegment(seg2) {
538
+ return ID_SEGMENT.test(seg2) || EMAIL_SEGMENT.test(seg2);
539
+ }
540
+ function redactUrl(rawUrl) {
541
+ let path = rawUrl;
542
+ try {
543
+ path = new URL(rawUrl).pathname;
544
+ } catch {
545
+ const q = path.indexOf("?");
546
+ if (q >= 0) path = path.slice(0, q);
547
+ }
548
+ return path.split("/").map((seg2) => isSensitiveSegment(seg2) ? ":id" : seg2).join("/");
549
+ }
550
+
534
551
  // src/request.ts
535
552
  var MUTATING = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
553
+ var PERF_EXCLUDED_PREFIX = "/v1/analytics/";
554
+ function isSelfTraced(path) {
555
+ return path.startsWith(PERF_EXCLUDED_PREFIX);
556
+ }
557
+ function nowMs() {
558
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
559
+ }
560
+ function isAbort(e) {
561
+ return e instanceof Error && e.name === "AbortError";
562
+ }
536
563
  async function palbeRequest(rt, method, path, spec = {}) {
537
564
  const headers = { ...spec.headers };
538
565
  const callerHasKey = Object.keys(headers).some((k) => k.toLowerCase() === "idempotency-key");
539
566
  if (MUTATING.has(method) && !callerHasKey) {
540
567
  headers["Idempotency-Key"] = crypto.randomUUID();
541
568
  }
569
+ if (rt.appIdentifier !== "") {
570
+ const callerHasBundle = Object.keys(headers).some(
571
+ (k) => k.toLowerCase() === "x-palbase-bundle"
572
+ );
573
+ if (!callerHasBundle) headers["X-Palbase-Bundle"] = rt.appIdentifier;
574
+ }
542
575
  const attempt = async () => {
543
576
  try {
544
577
  return await rt.http.request(method, path, {
@@ -551,21 +584,38 @@ async function palbeRequest(rt, method, path, spec = {}) {
551
584
  throw pe ? fromPalbaseError(pe) : e;
552
585
  }
553
586
  };
554
- let res = await attempt();
555
- if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
556
- try {
557
- await rt.tokenManager.refreshSession();
558
- } catch (refreshErr) {
559
- const pe = asPalbaseError(refreshErr);
560
- const status = pe?.status ?? 0;
561
- if (status === 400 || status === 401 || status === 403) {
562
- rt.tokenManager.clearSession();
563
- throw fromPalbaseError(res.error);
587
+ const traced = !isSelfTraced(path) && rt.perf !== void 0;
588
+ const startedAt = traced ? nowMs() : 0;
589
+ let recorded = false;
590
+ const record = (status) => {
591
+ if (!traced || recorded) return;
592
+ recorded = true;
593
+ rt.perf.recordNetwork(method, redactUrl(path), status, nowMs() - startedAt);
594
+ };
595
+ let res;
596
+ try {
597
+ res = await attempt();
598
+ if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
599
+ try {
600
+ await rt.tokenManager.refreshSession();
601
+ } catch (refreshErr) {
602
+ const pe = asPalbaseError(refreshErr);
603
+ const status = pe?.status ?? 0;
604
+ if (status === 400 || status === 401 || status === 403) {
605
+ rt.tokenManager.clearSession();
606
+ record(res.error.status);
607
+ throw fromPalbaseError(res.error);
608
+ }
609
+ record(pe?.status ?? 0);
610
+ throw pe ? fromPalbaseError(pe) : refreshErr;
564
611
  }
565
- throw pe ? fromPalbaseError(pe) : refreshErr;
612
+ res = await attempt();
566
613
  }
567
- res = await attempt();
614
+ } catch (e) {
615
+ if (!isAbort(e)) record(asPalbaseError(e)?.status ?? 0);
616
+ throw e;
568
617
  }
618
+ record(res.error?.status ?? 200);
569
619
  return unwrap(res);
570
620
  }
571
621
 
@@ -1415,6 +1465,23 @@ var PalbeAnalytics = class {
1415
1465
  }
1416
1466
  };
1417
1467
 
1468
+ // src/app-config.ts
1469
+ function loadAppConfig(raw) {
1470
+ if (typeof raw !== "object" || raw === null) {
1471
+ throw new Error("app_config_invalid: expected a JSON object");
1472
+ }
1473
+ const r = raw;
1474
+ const str = (k) => typeof r[k] === "string" ? r[k] : "";
1475
+ return { appId: str("app_id"), identifier: str("identifier"), envPreset: str("env_preset") };
1476
+ }
1477
+ function assertOriginMatches(cfg, runtimeOrigin) {
1478
+ if (cfg.identifier === "") return;
1479
+ if (runtimeOrigin === "") return;
1480
+ if (runtimeOrigin !== cfg.identifier) {
1481
+ throw new Error(`app_config_mismatch: expected origin ${cfg.identifier}, got ${runtimeOrigin}`);
1482
+ }
1483
+ }
1484
+
1418
1485
  // src/auth-facade.ts
1419
1486
  function mapWireUser(raw) {
1420
1487
  return {
@@ -2921,6 +2988,10 @@ var MessagingPaths = {
2921
2988
  groupMessages: (displayId) => `${GROUPS}/${seg(displayId)}/messages`,
2922
2989
  groupCommits: (displayId) => `${GROUPS}/${seg(displayId)}/commits`,
2923
2990
  groupRead: (displayId) => `${GROUPS}/${seg(displayId)}/read`,
2991
+ // Server-metadata cluster: the per-(user,group) notify scope (mute toggle, GET/PUT)
2992
+ // + the caller's own unread view (GET). {gid} is the grp_ display_id (M3 #7).
2993
+ groupNotify: (displayId) => `${GROUPS}/${seg(displayId)}/notify`,
2994
+ groupUnread: (displayId) => `${GROUPS}/${seg(displayId)}/unread`,
2924
2995
  deviceWelcomes: (deviceId) => `${DEVICES}/${seg(deviceId)}/welcomes`,
2925
2996
  deviceQueue: (deviceId) => `${DEVICES}/${seg(deviceId)}/queue`,
2926
2997
  deviceQueueAck: (deviceId) => `${DEVICES}/${seg(deviceId)}/queue/ack`,
@@ -4499,6 +4570,18 @@ var Chat = class {
4499
4570
  lastSeenAt: ts ? new Date(ts * 1e3) : null
4500
4571
  });
4501
4572
  this.emit();
4573
+ } else if (event === "delivered" || event === "read") {
4574
+ const seq = typeof payload.up_to_server_seq === "number" ? payload.up_to_server_seq : null;
4575
+ if (seq === null) return;
4576
+ let changed = false;
4577
+ this.messageList = this.messageList.map((m) => {
4578
+ if (m.direction !== "outgoing" || m.serverSeq > seq) return m;
4579
+ const cur = event === "delivered" ? m.deliveredUpTo ?? -1 : m.readUpTo ?? -1;
4580
+ if (seq <= cur) return m;
4581
+ changed = true;
4582
+ return event === "delivered" ? { ...m, deliveredUpTo: seq } : { ...m, readUpTo: seq };
4583
+ });
4584
+ if (changed) this.emit();
4502
4585
  }
4503
4586
  }
4504
4587
  kindOf(incoming) {
@@ -4747,6 +4830,31 @@ var Chat = class {
4747
4830
  this.readWatermark = Math.max(this.readWatermark, message.serverSeq);
4748
4831
  this.emit();
4749
4832
  }
4833
+ // ── Server-metadata cluster: notify scope (mute) + server unread ──
4834
+ /** Set this chat's notify scope (the mute toggle). `'none'` mutes the push wake;
4835
+ * `'all'` unmutes (the default). Materializes a draft first (the scope is a
4836
+ * per-(user,group) server row), PUTs `/notify`, and returns the server-echoed scope.
4837
+ * Mirrors iOS `Chat.setNotifyScope`. */
4838
+ async setNotifyScope(scope) {
4839
+ const group = await this.materializeIfNeeded();
4840
+ return this.backend.setNotifyScope(group, scope);
4841
+ }
4842
+ /** Refresh + return this chat's notify scope from the server (fail-OPEN to `'all'`).
4843
+ * Returns `'all'` for a draft chat (no server row yet). */
4844
+ async getNotifyScope() {
4845
+ if (!this._group) return "all";
4846
+ return this.backend.getNotifyScope(this._group);
4847
+ }
4848
+ /** Fetch the caller's authoritative SERVER unread count (opaque server metadata).
4849
+ * Returns the clamped count (`max(0, …)`); `0` for a draft chat. The local computed
4850
+ * `unreadCount` getter stays the instant, offline best-effort badge — this is the
4851
+ * canonical count on demand. Named distinctly so it does not shadow the observable
4852
+ * `unreadCount` snapshot getter. Mirrors iOS `Chat.refreshUnread`. */
4853
+ async unreadCountFromServer() {
4854
+ if (!this._group) return 0;
4855
+ const v = await this.backend.unread(this._group);
4856
+ return Math.max(0, v.unreadCount);
4857
+ }
4750
4858
  // ── Reactions ──
4751
4859
  /** Add an emoji reaction to a message. No-op if the message isn't reactable
4752
4860
  * (empty clientMsgId — a legacy/system row). The reaction folds locally with
@@ -5185,7 +5293,7 @@ var MessageDeliverySource = class {
5185
5293
  if (this.observed.has(group.displayId)) return;
5186
5294
  try {
5187
5295
  const channel = this.rt.realtime.channel(`messaging:conv:${group.rfcGroupId}`);
5188
- const subs = ["presence", "typing", "read"].map(
5296
+ const subs = ["presence", "typing", "read", "delivered"].map(
5189
5297
  (ev) => channel.on(ev, (payload) => {
5190
5298
  this.hub.emitConv(group.displayId, { event: ev, payload });
5191
5299
  })
@@ -5225,6 +5333,40 @@ var MessageDeliverySource = class {
5225
5333
  body: { read_seq: upToServerSeq, read_epoch: group.currentEpoch, is_private: false }
5226
5334
  });
5227
5335
  }
5336
+ // ── Server-metadata cluster: notify scope (mute) + unread (HTTP) ──
5337
+ /** PUT `/v1/messaging/groups/{gid}/notify` — set the caller's per-(user,group) notify
5338
+ * scope (`'all'`|`'none'`). The server stores the opaque enum verbatim and stays blind.
5339
+ * Returns the server-echoed scope (fail-OPEN to `'all'` on an unknown value). */
5340
+ async setNotifyScope(group, scope) {
5341
+ const res = await palbeRequest(
5342
+ this.rt,
5343
+ "PUT",
5344
+ MessagingPaths.groupNotify(group.displayId),
5345
+ { body: { notify_scope: scope } }
5346
+ );
5347
+ return res.notify_scope === "none" ? "none" : "all";
5348
+ }
5349
+ /** GET `/v1/messaging/groups/{gid}/notify` — the caller's notify scope. An absent
5350
+ * server row / unknown value reads as `'all'` (fail-OPEN — never silently mutes). */
5351
+ async getNotifyScope(group) {
5352
+ const res = await palbeRequest(
5353
+ this.rt,
5354
+ "GET",
5355
+ MessagingPaths.groupNotify(group.displayId)
5356
+ );
5357
+ return res.notify_scope === "none" ? "none" : "all";
5358
+ }
5359
+ /** GET `/v1/messaging/groups/{gid}/unread` — the caller's OWN unread view (opaque
5360
+ * server metadata). Maps the snake_case wire → the camelCase `UnreadView`. */
5361
+ async unread(group) {
5362
+ const res = await palbeRequest(this.rt, "GET", MessagingPaths.groupUnread(group.displayId));
5363
+ return {
5364
+ groupMaxSeq: res.group_max_seq,
5365
+ lastReadSeq: res.last_read_seq,
5366
+ deliveredSeq: res.delivered_seq,
5367
+ unreadCount: res.unread_count
5368
+ };
5369
+ }
5228
5370
  };
5229
5371
  function isOwnEchoOrConsumed(e) {
5230
5372
  const msg = e instanceof Error ? e.message : String(e);
@@ -7382,6 +7524,19 @@ var MessagingCoordinator = class {
7382
7524
  const r = await this.resolve();
7383
7525
  await r.source.markRead(group, upToServerSeq);
7384
7526
  }
7527
+ // ── Server-metadata cluster: notify scope (mute) + unread ──
7528
+ async setNotifyScope(group, scope) {
7529
+ const r = await this.resolve();
7530
+ return r.source.setNotifyScope(group, scope);
7531
+ }
7532
+ async getNotifyScope(group) {
7533
+ const r = await this.resolve();
7534
+ return r.source.getNotifyScope(group);
7535
+ }
7536
+ async unread(group) {
7537
+ const r = await this.resolve();
7538
+ return r.source.unread(group);
7539
+ }
7385
7540
  subscribeLive(group, chat) {
7386
7541
  let offMsg = null;
7387
7542
  let offConv = null;
@@ -7634,6 +7789,469 @@ var PalbeMessaging = class {
7634
7789
  }
7635
7790
  };
7636
7791
 
7792
+ // src/perf/app-start.ts
7793
+ function measureAppStart(nav) {
7794
+ if (!(nav.fcp > 0)) return null;
7795
+ const value = nav.fcp - nav.startTime;
7796
+ if (value <= 0) return null;
7797
+ return {
7798
+ row_id: crypto.randomUUID(),
7799
+ trace_type: "app_start",
7800
+ name: "cold_start",
7801
+ value,
7802
+ timestamp: Date.now()
7803
+ };
7804
+ }
7805
+
7806
+ // src/perf/perf-config-client.ts
7807
+ var PERF_CONFIG_PATH = "/v1/analytics/perf/config";
7808
+ var FNV_OFFSET_BASIS_32 = 2166136261;
7809
+ var FNV_PRIME_32 = 16777619;
7810
+ var utf82 = typeof TextEncoder !== "undefined" ? new TextEncoder() : { encode: (s) => Uint8Array.from(Buffer.from(s, "utf-8")) };
7811
+ function perfSampleBucket(rowId) {
7812
+ let hash = FNV_OFFSET_BASIS_32;
7813
+ for (const byte of utf82.encode(rowId)) {
7814
+ hash ^= byte;
7815
+ hash = Math.imul(hash, FNV_PRIME_32);
7816
+ }
7817
+ return (hash >>> 0) % 100;
7818
+ }
7819
+ function isPerfConfig(value) {
7820
+ if (typeof value !== "object" || value === null) return false;
7821
+ const o = value;
7822
+ return typeof o.sample_pct === "number";
7823
+ }
7824
+ function isRawResponse(value) {
7825
+ return typeof value === "object" && value !== null && "data" in value;
7826
+ }
7827
+ function headerValue(headers, name) {
7828
+ if (!headers) return void 0;
7829
+ const lower = name.toLowerCase();
7830
+ for (const [k, v] of Object.entries(headers)) {
7831
+ if (k.toLowerCase() === lower) return v;
7832
+ }
7833
+ return void 0;
7834
+ }
7835
+ var PerfConfigClient = class {
7836
+ etag;
7837
+ async fetchConfig(rt, perf) {
7838
+ const headers = {};
7839
+ if (this.etag) headers["If-None-Match"] = this.etag;
7840
+ try {
7841
+ const res = await rt.http.request("GET", PERF_CONFIG_PATH, { headers });
7842
+ if (!isRawResponse(res)) return;
7843
+ if (res.status === 304 || res.error && res.error.status === 304) return;
7844
+ if (res.error) return;
7845
+ const newEtag = headerValue(res.headers, "etag");
7846
+ if (newEtag) this.etag = newEtag;
7847
+ if (isPerfConfig(res.data)) {
7848
+ perf.setSamplePct(res.data.sample_pct);
7849
+ }
7850
+ } catch {
7851
+ }
7852
+ }
7853
+ };
7854
+
7855
+ // src/perf/fetch-swizzle.ts
7856
+ var PERF_EXCLUDED_PREFIX2 = "/v1/analytics/";
7857
+ function nowMs2() {
7858
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7859
+ }
7860
+ function describeRequest(input, init) {
7861
+ let url;
7862
+ let method = "GET";
7863
+ if (typeof input === "string") {
7864
+ url = input;
7865
+ } else if (input instanceof URL) {
7866
+ url = input.href;
7867
+ } else {
7868
+ url = input.url;
7869
+ method = input.method;
7870
+ }
7871
+ if (init?.method) method = init.method;
7872
+ return { url, method: method.toUpperCase() };
7873
+ }
7874
+ function pathOf(url) {
7875
+ try {
7876
+ return new URL(url, "http://_local").pathname;
7877
+ } catch {
7878
+ return url;
7879
+ }
7880
+ }
7881
+ function installFetchSwizzle(perf) {
7882
+ if (typeof fetch !== "function") return () => {
7883
+ };
7884
+ const original = fetch;
7885
+ const wrapped = async (input, init) => {
7886
+ const { url, method } = describeRequest(input, init);
7887
+ const traced = !pathOf(url).startsWith(PERF_EXCLUDED_PREFIX2);
7888
+ const startedAt = traced ? nowMs2() : 0;
7889
+ try {
7890
+ const res = await original(input, init);
7891
+ if (traced) perf.recordNetwork(method, redactUrl(url), res.status, nowMs2() - startedAt);
7892
+ return res;
7893
+ } catch (err) {
7894
+ if (traced) perf.recordNetwork(method, redactUrl(url), 0, nowMs2() - startedAt);
7895
+ throw err;
7896
+ }
7897
+ };
7898
+ globalThis.fetch = wrapped;
7899
+ return () => {
7900
+ globalThis.fetch = original;
7901
+ };
7902
+ }
7903
+
7904
+ // src/perf/offline-queue.ts
7905
+ var PERF_QUEUE_KEY = "palbe.perf.queue";
7906
+ var DEFAULT_MAX_ITEMS = 500;
7907
+ function canPersist() {
7908
+ return typeof document !== "undefined" && typeof localStorage !== "undefined";
7909
+ }
7910
+ function isPerfItem(value) {
7911
+ if (typeof value !== "object" || value === null) return false;
7912
+ const o = value;
7913
+ return typeof o.row_id === "string" && typeof o.trace_type === "string" && typeof o.name === "string" && typeof o.value === "number" && typeof o.timestamp === "number";
7914
+ }
7915
+ function readPersisted() {
7916
+ if (!canPersist()) return [];
7917
+ try {
7918
+ const raw = localStorage.getItem(PERF_QUEUE_KEY);
7919
+ if (!raw) return [];
7920
+ const parsed = JSON.parse(raw);
7921
+ if (!Array.isArray(parsed)) return [];
7922
+ return parsed.filter(isPerfItem);
7923
+ } catch {
7924
+ return [];
7925
+ }
7926
+ }
7927
+ var PerfOfflineQueue = class {
7928
+ items;
7929
+ _dropped = 0;
7930
+ maxItems;
7931
+ constructor(maxItems = DEFAULT_MAX_ITEMS) {
7932
+ this.maxItems = Math.max(1, maxItems);
7933
+ this.items = readPersisted();
7934
+ this.trim();
7935
+ }
7936
+ /** Append items; FIFO-evict the oldest when over `maxItems`. */
7937
+ enqueue(items) {
7938
+ if (items.length === 0) return;
7939
+ this.items.push(...items);
7940
+ this.trim();
7941
+ this.persist();
7942
+ }
7943
+ /** Return all queued items (oldest-first) and clear the queue + store. */
7944
+ drainAll() {
7945
+ if (this.items.length === 0) return [];
7946
+ const out = this.items;
7947
+ this.items = [];
7948
+ this.clearStore();
7949
+ return out;
7950
+ }
7951
+ /** Current queue depth. */
7952
+ get count() {
7953
+ return this.items.length;
7954
+ }
7955
+ /** Cumulative count of FIFO-evicted items (a `dropped` metric, not silent). */
7956
+ get dropped() {
7957
+ return this._dropped;
7958
+ }
7959
+ /** Drop the oldest items until at most `maxItems` remain, counting each. */
7960
+ trim() {
7961
+ const overflow = this.items.length - this.maxItems;
7962
+ if (overflow > 0) {
7963
+ this.items.splice(0, overflow);
7964
+ this._dropped += overflow;
7965
+ }
7966
+ }
7967
+ persist() {
7968
+ if (!canPersist()) return;
7969
+ try {
7970
+ localStorage.setItem(PERF_QUEUE_KEY, JSON.stringify(this.items));
7971
+ } catch {
7972
+ }
7973
+ }
7974
+ clearStore() {
7975
+ if (!canPersist()) return;
7976
+ try {
7977
+ localStorage.removeItem(PERF_QUEUE_KEY);
7978
+ } catch {
7979
+ }
7980
+ }
7981
+ };
7982
+
7983
+ // src/perf/perf-state.ts
7984
+ var MAX_PERF_BATCH = 100;
7985
+ var PerfState = class {
7986
+ /** Pending, un-flushed perf items (FIFO). */
7987
+ buffer = [];
7988
+ /** When true, every flush carries `X-Palbase-Test-Device: 1`. */
7989
+ testDevice = false;
7990
+ enqueue(item) {
7991
+ this.buffer.push(item);
7992
+ }
7993
+ /** Detach up to `MAX_PERF_BATCH` items for one POST (FIFO order preserved). */
7994
+ take(limit = MAX_PERF_BATCH) {
7995
+ return this.buffer.splice(0, limit);
7996
+ }
7997
+ get size() {
7998
+ return this.buffer.length;
7999
+ }
8000
+ };
8001
+
8002
+ // src/perf/perf-wire.ts
8003
+ function encodePerfBatch(items) {
8004
+ return { items };
8005
+ }
8006
+
8007
+ // src/perf/perf-facade.ts
8008
+ var FLUSH_AT2 = 20;
8009
+ var FLUSH_INTERVAL_MS2 = 1e4;
8010
+ var TEST_DEVICE_HEADER = "X-Palbase-Test-Device";
8011
+ function nowMs3() {
8012
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
8013
+ }
8014
+ var PerfTrace = class {
8015
+ constructor(name, onStop) {
8016
+ this.name = name;
8017
+ this.onStop = onStop;
8018
+ }
8019
+ name;
8020
+ onStop;
8021
+ attrs = {};
8022
+ counters = {};
8023
+ startedAt = nowMs3();
8024
+ stopped = false;
8025
+ putAttribute(key, value) {
8026
+ this.attrs[key] = value;
8027
+ }
8028
+ incrementMetric(name, by = 1) {
8029
+ this.counters[name] = (this.counters[name] ?? 0) + by;
8030
+ }
8031
+ stop() {
8032
+ if (this.stopped) return;
8033
+ this.stopped = true;
8034
+ const item = {
8035
+ row_id: crypto.randomUUID(),
8036
+ trace_type: "custom",
8037
+ name: this.name,
8038
+ value: Math.max(0, nowMs3() - this.startedAt),
8039
+ timestamp: Date.now()
8040
+ };
8041
+ if (Object.keys(this.attrs).length > 0) item.attrs = this.attrs;
8042
+ if (Object.keys(this.counters).length > 0) item.counters = this.counters;
8043
+ this.onStop(item);
8044
+ }
8045
+ };
8046
+ var PalbePerf = class {
8047
+ constructor(rt, state = new PerfState(), queue = new PerfOfflineQueue()) {
8048
+ this.rt = rt;
8049
+ this.state = state;
8050
+ this.queue = queue;
8051
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
8052
+ window.addEventListener("online", this.onOnline);
8053
+ }
8054
+ }
8055
+ rt;
8056
+ state;
8057
+ queue;
8058
+ flushTimer = null;
8059
+ /** Browser → buffer + size/timer flush. Server (no document) → immediate
8060
+ * per-item flush, zero timers (nothing leaks into RSC/route handlers). */
8061
+ browser = typeof document !== "undefined";
8062
+ /** Bound `online` handler so it can be removed on `dispose()` (no leak). */
8063
+ onOnline = () => {
8064
+ void this.flush();
8065
+ };
8066
+ /** Server-controlled client-side sample rate (0..100). 100 until the config
8067
+ * client fetches `/v1/analytics/perf/config` — the SDK obeys this ceiling and
8068
+ * never raises its own rate (invariant 3). `record` drops an item whose
8069
+ * deterministic `row_id` bucket is >= this pct, mirroring the server's
8070
+ * `SampleDecision` so client + server keep the SAME rows. */
8071
+ samplePct = 100;
8072
+ /** Remove the `online` listener. Called when the runtime is replaced so the
8073
+ * handler does not outlive this facade. No-op outside the browser. */
8074
+ dispose() {
8075
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
8076
+ window.removeEventListener("online", this.onOnline);
8077
+ }
8078
+ }
8079
+ /** Mark (or unmark) this client's traffic as test — the server tags the rows
8080
+ * when the `X-Palbase-Test-Device: 1` header rides along on flush. */
8081
+ setTestDevice(on) {
8082
+ this.state.testDevice = on;
8083
+ }
8084
+ /** Apply the server-resolved client-side sample rate (0..100), clamped. Called
8085
+ * by `PerfConfigClient` after fetching `/v1/analytics/perf/config`. The SDK
8086
+ * OBEYS this value — it is a ceiling, never raised locally. */
8087
+ setSamplePct(pct) {
8088
+ this.samplePct = Math.max(0, Math.min(100, Math.trunc(pct)));
8089
+ }
8090
+ /** Pending (un-flushed) buffer depth. Test-only visibility into the buffer so
8091
+ * the remote-sampling tests can assert how many items were sampled in. */
8092
+ get bufferSizeForTest() {
8093
+ return this.state.size;
8094
+ }
8095
+ /** Opt-in: wrap the global `fetch` so every app-level request is recorded as a
8096
+ * redacted `network` perf item (the `/v1/analytics/*` ingest paths are
8097
+ * self-excluded). OFF by default — swizzling a global is a page-wide side
8098
+ * effect. Returns an `uninstall` that restores the original `fetch`. */
8099
+ enableFetchCapture() {
8100
+ return installFetchSwizzle(this);
8101
+ }
8102
+ /** Start a custom trace; the returned handle records a `custom` item on
8103
+ * `.stop()`. */
8104
+ startTrace(name) {
8105
+ return new PerfTrace(name, (item) => this.record(item));
8106
+ }
8107
+ /** Buffer one perf item. In the browser, flush on size/timer; on the server
8108
+ * flush immediately (no timers). Never throws.
8109
+ *
8110
+ * Client-side remote sampling: an item whose deterministic `row_id` bucket is
8111
+ * NOT below the server-controlled `samplePct` is dropped before buffering —
8112
+ * the SAME FNV-1a-mod-100 decision the server applies at ingest, so the two
8113
+ * keep identical rows and the SDK saves the upload (defense-in-depth: the
8114
+ * server re-samples authoritatively). */
8115
+ record(item) {
8116
+ if (perfSampleBucket(item.row_id) >= this.samplePct) return;
8117
+ this.state.enqueue(item);
8118
+ if (!this.browser) {
8119
+ void this.flush();
8120
+ return;
8121
+ }
8122
+ if (this.state.size >= FLUSH_AT2) void this.flush();
8123
+ else this.startTimer();
8124
+ }
8125
+ /** Buffer a network trace — called by `request.ts` around `rt.http.request`
8126
+ * (the analytics ingest path is excluded by the caller to avoid recursion). */
8127
+ recordNetwork(method, url, status, durationMs, requestId) {
8128
+ const item = {
8129
+ row_id: crypto.randomUUID(),
8130
+ trace_type: "network",
8131
+ name: `${method} ${url}`,
8132
+ value: durationMs,
8133
+ attrs: { status: String(status) },
8134
+ timestamp: Date.now()
8135
+ };
8136
+ if (requestId) item.request_id = requestId;
8137
+ this.record(item);
8138
+ }
8139
+ /** Drain the offline queue (oldest-first) and the live buffer to
8140
+ * `/v1/analytics/perf` (≤100 items per request, sequential). Resolves when
8141
+ * delivery finished; NEVER rejects. A failed slice is NOT dropped — it goes
8142
+ * to the offline queue (persist-on-fail) so the next flush (size/timer or the
8143
+ * `online` reconnect event) retries it. Items keep their original `row_id`,
8144
+ * so a redelivery dedups server-side (ReplacingMergeTree). */
8145
+ async flush() {
8146
+ this.cancelTimer();
8147
+ const pending = this.queue.drainAll();
8148
+ if (pending.length === 0 && this.state.size === 0) return;
8149
+ const headers = this.state.testDevice ? { [TEST_DEVICE_HEADER]: "1" } : void 0;
8150
+ const remaining = [...pending];
8151
+ while (this.state.size > 0) remaining.push(...this.state.take(MAX_PERF_BATCH));
8152
+ for (let i = 0; i < remaining.length; i += MAX_PERF_BATCH) {
8153
+ const slice = remaining.slice(i, i + MAX_PERF_BATCH);
8154
+ try {
8155
+ await palbeRequest(
8156
+ this.rt,
8157
+ "POST",
8158
+ "/v1/analytics/perf",
8159
+ { body: encodePerfBatch(slice), headers }
8160
+ );
8161
+ } catch {
8162
+ this.queue.enqueue(slice);
8163
+ }
8164
+ }
8165
+ }
8166
+ startTimer() {
8167
+ if (this.flushTimer !== null) return;
8168
+ this.flushTimer = setTimeout(() => {
8169
+ this.flushTimer = null;
8170
+ void this.flush();
8171
+ }, FLUSH_INTERVAL_MS2);
8172
+ }
8173
+ cancelTimer() {
8174
+ if (this.flushTimer !== null) {
8175
+ clearTimeout(this.flushTimer);
8176
+ this.flushTimer = null;
8177
+ }
8178
+ }
8179
+ };
8180
+
8181
+ // src/perf/web-vitals.ts
8182
+ function webVitalItem(name, value) {
8183
+ return {
8184
+ row_id: crypto.randomUUID(),
8185
+ trace_type: "web_vital",
8186
+ name,
8187
+ value: Math.max(0, value),
8188
+ timestamp: Date.now()
8189
+ };
8190
+ }
8191
+ function isLayoutShift(e) {
8192
+ return e.entryType === "layout-shift" && "value" in e && "hadRecentInput" in e;
8193
+ }
8194
+ function isEventTiming(e) {
8195
+ return e.entryType === "event" || e.entryType === "first-input";
8196
+ }
8197
+ function safeObserve(type, cb) {
8198
+ if (typeof PerformanceObserver === "undefined") return null;
8199
+ try {
8200
+ const obs = new PerformanceObserver((list) => cb(list.getEntries()));
8201
+ obs.observe({ type, buffered: true });
8202
+ return obs;
8203
+ } catch {
8204
+ return null;
8205
+ }
8206
+ }
8207
+ function observeWebVitals(record) {
8208
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function" || typeof document.removeEventListener !== "function" || typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
8209
+ return () => {
8210
+ };
8211
+ }
8212
+ let cls = 0;
8213
+ let lcp = 0;
8214
+ let inp = 0;
8215
+ const observers = [
8216
+ // LCP: keep the largest/last reported render.
8217
+ safeObserve("largest-contentful-paint", (entries) => {
8218
+ for (const e of entries) lcp = Math.max(lcp, e.startTime);
8219
+ }),
8220
+ // CLS: sum shift values that weren't caused by recent input.
8221
+ safeObserve("layout-shift", (entries) => {
8222
+ for (const e of entries) if (isLayoutShift(e) && !e.hadRecentInput) cls += e.value;
8223
+ }),
8224
+ // INP: approximate as the worst interaction duration observed.
8225
+ safeObserve("event", (entries) => {
8226
+ for (const e of entries) if (isEventTiming(e)) inp = Math.max(inp, e.duration);
8227
+ }),
8228
+ // FCP: one-shot.
8229
+ safeObserve("paint", (entries) => {
8230
+ for (const e of entries)
8231
+ if (e.name === "first-contentful-paint") record(webVitalItem("FCP", e.startTime));
8232
+ })
8233
+ ];
8234
+ const nav = performance.getEntriesByType("navigation")[0];
8235
+ if (nav && nav.responseStart > 0) record(webVitalItem("TTFB", nav.responseStart));
8236
+ let flushed = false;
8237
+ const flush = () => {
8238
+ if (flushed) return;
8239
+ flushed = true;
8240
+ if (lcp > 0) record(webVitalItem("LCP", lcp));
8241
+ record(webVitalItem("CLS", cls));
8242
+ if (inp > 0) record(webVitalItem("INP", inp));
8243
+ };
8244
+ const onHide = () => {
8245
+ if (document.visibilityState === "hidden") flush();
8246
+ };
8247
+ document.addEventListener("visibilitychange", onHide);
8248
+ return () => {
8249
+ flush();
8250
+ document.removeEventListener("visibilitychange", onHide);
8251
+ for (const o of observers) o?.disconnect();
8252
+ };
8253
+ }
8254
+
7637
8255
  // src/realtime/anon-token.ts
7638
8256
  var REFRESH_SKEW_MS = 6e4;
7639
8257
  var AnonTokenProvider = class {
@@ -8321,10 +8939,13 @@ function defaultSessionStorage(key) {
8321
8939
  }
8322
8940
 
8323
8941
  // src/version.ts
8324
- var VERSION = "1.6.2";
8942
+ var VERSION = "1.8.0";
8325
8943
 
8326
8944
  // src/runtime.ts
8327
8945
  function buildRuntime(config) {
8946
+ const appIdentifier = config.identifier ?? "";
8947
+ const runtimeOrigin = typeof window !== "undefined" && typeof window.location !== "undefined" ? window.location.origin : "";
8948
+ assertOriginMatches(loadAppConfig({ identifier: appIdentifier }), runtimeOrigin);
8328
8949
  const http = new HttpClient(config.apiKey, {
8329
8950
  url: config.url,
8330
8951
  headers: { "X-Client-Info": `palbe-web/${VERSION}`, ...config.headers }
@@ -8374,8 +8995,10 @@ function buildRuntime(config) {
8374
8995
  let analytics;
8375
8996
  let calls;
8376
8997
  let messaging;
8998
+ let perf;
8377
8999
  const rt = {
8378
9000
  config,
9001
+ appIdentifier,
8379
9002
  http,
8380
9003
  tokenManager,
8381
9004
  authClient,
@@ -8411,11 +9034,19 @@ function buildRuntime(config) {
8411
9034
  destroyRealtime() {
8412
9035
  realtime?.destroy();
8413
9036
  realtime = void 0;
9037
+ perf?.dispose();
8414
9038
  },
8415
9039
  // The buffering facade is lazy; its identity state is NOT (below).
8416
9040
  get analytics() {
8417
9041
  if (!analytics) analytics = new PalbeAnalytics(rt, analyticsState);
8418
9042
  return analytics;
9043
+ },
9044
+ // PalPerf is constructed up front (touched below): `request.ts` records a
9045
+ // network trace on EVERY fetch, so it can't be lazy. The memo here only
9046
+ // guards against re-construction.
9047
+ get perf() {
9048
+ if (!perf) perf = new PalbePerf(rt);
9049
+ return perf;
8419
9050
  }
8420
9051
  };
8421
9052
  const analyticsState = new AnalyticsState(rt);
@@ -8423,8 +9054,42 @@ function buildRuntime(config) {
8423
9054
  const hasOwn = Object.keys(request.headers).some((k) => k.toLowerCase() === "x-distinct-id");
8424
9055
  if (!hasOwn) request.headers["X-Distinct-Id"] = analyticsState.distinctId();
8425
9056
  });
9057
+ void rt.perf;
9058
+ if (typeof document !== "undefined") {
9059
+ void new PerfConfigClient().fetchConfig(rt, rt.perf);
9060
+ }
9061
+ recordColdStart(rt);
9062
+ observeWebVitals((item) => rt.perf.record(item));
8426
9063
  return rt;
8427
9064
  }
9065
+ function recordColdStart(rt) {
9066
+ if (typeof document === "undefined" || typeof performance === "undefined") return;
9067
+ try {
9068
+ const navEntry = performance.getEntriesByType("navigation")[0];
9069
+ const startTime = navEntry?.startTime ?? 0;
9070
+ const recordFromFcp = (fcp) => {
9071
+ const item = measureAppStart({ startTime, fcp });
9072
+ if (item) rt.perf.record(item);
9073
+ };
9074
+ const existing = performance.getEntriesByName("first-contentful-paint").find((e) => e.startTime > 0);
9075
+ if (existing) {
9076
+ recordFromFcp(existing.startTime);
9077
+ return;
9078
+ }
9079
+ if (typeof PerformanceObserver === "undefined") return;
9080
+ const observer = new PerformanceObserver((list) => {
9081
+ for (const entry of list.getEntries()) {
9082
+ if (entry.name === "first-contentful-paint") {
9083
+ observer.disconnect();
9084
+ recordFromFcp(entry.startTime);
9085
+ return;
9086
+ }
9087
+ }
9088
+ });
9089
+ observer.observe({ type: "paint", buffered: true });
9090
+ } catch {
9091
+ }
9092
+ }
8428
9093
 
8429
9094
  // src/call.ts
8430
9095
  async function callEndpoint(resolveRt, name, input, options) {
@@ -8603,6 +9268,12 @@ function createClientProxy(resolveRt, nsAccessor) {
8603
9268
  },
8604
9269
  get messaging() {
8605
9270
  return resolveRt().messaging;
9271
+ },
9272
+ get perf() {
9273
+ return resolveRt().perf;
9274
+ },
9275
+ setTestDevice(on) {
9276
+ resolveRt().perf.setTestDevice(on);
8606
9277
  }
8607
9278
  };
8608
9279
  return new Proxy(base, {