@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.
@@ -408,14 +408,47 @@ function unwrap(res) {
408
408
  return res.data;
409
409
  }
410
410
 
411
+ // src/perf/url-redactor.ts
412
+ 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})$/;
413
+ var EMAIL_SEGMENT = /(?:@|%40)/i;
414
+ function isSensitiveSegment(seg2) {
415
+ return ID_SEGMENT.test(seg2) || EMAIL_SEGMENT.test(seg2);
416
+ }
417
+ function redactUrl(rawUrl) {
418
+ let path = rawUrl;
419
+ try {
420
+ path = new URL(rawUrl).pathname;
421
+ } catch {
422
+ const q = path.indexOf("?");
423
+ if (q >= 0) path = path.slice(0, q);
424
+ }
425
+ return path.split("/").map((seg2) => isSensitiveSegment(seg2) ? ":id" : seg2).join("/");
426
+ }
427
+
411
428
  // src/request.ts
412
429
  var MUTATING = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
430
+ var PERF_EXCLUDED_PREFIX = "/v1/analytics/";
431
+ function isSelfTraced(path) {
432
+ return path.startsWith(PERF_EXCLUDED_PREFIX);
433
+ }
434
+ function nowMs() {
435
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
436
+ }
437
+ function isAbort(e) {
438
+ return e instanceof Error && e.name === "AbortError";
439
+ }
413
440
  async function palbeRequest(rt, method, path, spec = {}) {
414
441
  const headers = { ...spec.headers };
415
442
  const callerHasKey = Object.keys(headers).some((k) => k.toLowerCase() === "idempotency-key");
416
443
  if (MUTATING.has(method) && !callerHasKey) {
417
444
  headers["Idempotency-Key"] = crypto.randomUUID();
418
445
  }
446
+ if (rt.appIdentifier !== "") {
447
+ const callerHasBundle = Object.keys(headers).some(
448
+ (k) => k.toLowerCase() === "x-palbase-bundle"
449
+ );
450
+ if (!callerHasBundle) headers["X-Palbase-Bundle"] = rt.appIdentifier;
451
+ }
419
452
  const attempt = async () => {
420
453
  try {
421
454
  return await rt.http.request(method, path, {
@@ -428,21 +461,38 @@ async function palbeRequest(rt, method, path, spec = {}) {
428
461
  throw pe ? fromPalbaseError(pe) : e;
429
462
  }
430
463
  };
431
- let res = await attempt();
432
- if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
433
- try {
434
- await rt.tokenManager.refreshSession();
435
- } catch (refreshErr) {
436
- const pe = asPalbaseError(refreshErr);
437
- const status = pe?.status ?? 0;
438
- if (status === 400 || status === 401 || status === 403) {
439
- rt.tokenManager.clearSession();
440
- throw fromPalbaseError(res.error);
464
+ const traced = !isSelfTraced(path) && rt.perf !== void 0;
465
+ const startedAt = traced ? nowMs() : 0;
466
+ let recorded = false;
467
+ const record = (status) => {
468
+ if (!traced || recorded) return;
469
+ recorded = true;
470
+ rt.perf.recordNetwork(method, redactUrl(path), status, nowMs() - startedAt);
471
+ };
472
+ let res;
473
+ try {
474
+ res = await attempt();
475
+ if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
476
+ try {
477
+ await rt.tokenManager.refreshSession();
478
+ } catch (refreshErr) {
479
+ const pe = asPalbaseError(refreshErr);
480
+ const status = pe?.status ?? 0;
481
+ if (status === 400 || status === 401 || status === 403) {
482
+ rt.tokenManager.clearSession();
483
+ record(res.error.status);
484
+ throw fromPalbaseError(res.error);
485
+ }
486
+ record(pe?.status ?? 0);
487
+ throw pe ? fromPalbaseError(pe) : refreshErr;
441
488
  }
442
- throw pe ? fromPalbaseError(pe) : refreshErr;
489
+ res = await attempt();
443
490
  }
444
- res = await attempt();
491
+ } catch (e) {
492
+ if (!isAbort(e)) record(asPalbaseError(e)?.status ?? 0);
493
+ throw e;
445
494
  }
495
+ record(res.error?.status ?? 200);
446
496
  return unwrap(res);
447
497
  }
448
498
 
@@ -1187,6 +1237,23 @@ var PalbeAnalytics = class {
1187
1237
  }
1188
1238
  };
1189
1239
 
1240
+ // src/app-config.ts
1241
+ function loadAppConfig(raw) {
1242
+ if (typeof raw !== "object" || raw === null) {
1243
+ throw new Error("app_config_invalid: expected a JSON object");
1244
+ }
1245
+ const r = raw;
1246
+ const str = (k) => typeof r[k] === "string" ? r[k] : "";
1247
+ return { appId: str("app_id"), identifier: str("identifier"), envPreset: str("env_preset") };
1248
+ }
1249
+ function assertOriginMatches(cfg, runtimeOrigin) {
1250
+ if (cfg.identifier === "") return;
1251
+ if (runtimeOrigin === "") return;
1252
+ if (runtimeOrigin !== cfg.identifier) {
1253
+ throw new Error(`app_config_mismatch: expected origin ${cfg.identifier}, got ${runtimeOrigin}`);
1254
+ }
1255
+ }
1256
+
1190
1257
  // src/auth-wire.ts
1191
1258
  function asWireAuthResult(raw) {
1192
1259
  if (typeof raw !== "object" || raw === null) return null;
@@ -2706,6 +2773,10 @@ var MessagingPaths = {
2706
2773
  groupMessages: (displayId) => `${GROUPS}/${seg(displayId)}/messages`,
2707
2774
  groupCommits: (displayId) => `${GROUPS}/${seg(displayId)}/commits`,
2708
2775
  groupRead: (displayId) => `${GROUPS}/${seg(displayId)}/read`,
2776
+ // Server-metadata cluster: the per-(user,group) notify scope (mute toggle, GET/PUT)
2777
+ // + the caller's own unread view (GET). {gid} is the grp_ display_id (M3 #7).
2778
+ groupNotify: (displayId) => `${GROUPS}/${seg(displayId)}/notify`,
2779
+ groupUnread: (displayId) => `${GROUPS}/${seg(displayId)}/unread`,
2709
2780
  deviceWelcomes: (deviceId) => `${DEVICES}/${seg(deviceId)}/welcomes`,
2710
2781
  deviceQueue: (deviceId) => `${DEVICES}/${seg(deviceId)}/queue`,
2711
2782
  deviceQueueAck: (deviceId) => `${DEVICES}/${seg(deviceId)}/queue/ack`,
@@ -4284,6 +4355,18 @@ var Chat = class {
4284
4355
  lastSeenAt: ts ? new Date(ts * 1e3) : null
4285
4356
  });
4286
4357
  this.emit();
4358
+ } else if (event === "delivered" || event === "read") {
4359
+ const seq = typeof payload.up_to_server_seq === "number" ? payload.up_to_server_seq : null;
4360
+ if (seq === null) return;
4361
+ let changed = false;
4362
+ this.messageList = this.messageList.map((m) => {
4363
+ if (m.direction !== "outgoing" || m.serverSeq > seq) return m;
4364
+ const cur = event === "delivered" ? m.deliveredUpTo ?? -1 : m.readUpTo ?? -1;
4365
+ if (seq <= cur) return m;
4366
+ changed = true;
4367
+ return event === "delivered" ? { ...m, deliveredUpTo: seq } : { ...m, readUpTo: seq };
4368
+ });
4369
+ if (changed) this.emit();
4287
4370
  }
4288
4371
  }
4289
4372
  kindOf(incoming) {
@@ -4532,6 +4615,31 @@ var Chat = class {
4532
4615
  this.readWatermark = Math.max(this.readWatermark, message.serverSeq);
4533
4616
  this.emit();
4534
4617
  }
4618
+ // ── Server-metadata cluster: notify scope (mute) + server unread ──
4619
+ /** Set this chat's notify scope (the mute toggle). `'none'` mutes the push wake;
4620
+ * `'all'` unmutes (the default). Materializes a draft first (the scope is a
4621
+ * per-(user,group) server row), PUTs `/notify`, and returns the server-echoed scope.
4622
+ * Mirrors iOS `Chat.setNotifyScope`. */
4623
+ async setNotifyScope(scope) {
4624
+ const group = await this.materializeIfNeeded();
4625
+ return this.backend.setNotifyScope(group, scope);
4626
+ }
4627
+ /** Refresh + return this chat's notify scope from the server (fail-OPEN to `'all'`).
4628
+ * Returns `'all'` for a draft chat (no server row yet). */
4629
+ async getNotifyScope() {
4630
+ if (!this._group) return "all";
4631
+ return this.backend.getNotifyScope(this._group);
4632
+ }
4633
+ /** Fetch the caller's authoritative SERVER unread count (opaque server metadata).
4634
+ * Returns the clamped count (`max(0, …)`); `0` for a draft chat. The local computed
4635
+ * `unreadCount` getter stays the instant, offline best-effort badge — this is the
4636
+ * canonical count on demand. Named distinctly so it does not shadow the observable
4637
+ * `unreadCount` snapshot getter. Mirrors iOS `Chat.refreshUnread`. */
4638
+ async unreadCountFromServer() {
4639
+ if (!this._group) return 0;
4640
+ const v = await this.backend.unread(this._group);
4641
+ return Math.max(0, v.unreadCount);
4642
+ }
4535
4643
  // ── Reactions ──
4536
4644
  /** Add an emoji reaction to a message. No-op if the message isn't reactable
4537
4645
  * (empty clientMsgId — a legacy/system row). The reaction folds locally with
@@ -4970,7 +5078,7 @@ var MessageDeliverySource = class {
4970
5078
  if (this.observed.has(group.displayId)) return;
4971
5079
  try {
4972
5080
  const channel = this.rt.realtime.channel(`messaging:conv:${group.rfcGroupId}`);
4973
- const subs = ["presence", "typing", "read"].map(
5081
+ const subs = ["presence", "typing", "read", "delivered"].map(
4974
5082
  (ev) => channel.on(ev, (payload) => {
4975
5083
  this.hub.emitConv(group.displayId, { event: ev, payload });
4976
5084
  })
@@ -5010,6 +5118,40 @@ var MessageDeliverySource = class {
5010
5118
  body: { read_seq: upToServerSeq, read_epoch: group.currentEpoch, is_private: false }
5011
5119
  });
5012
5120
  }
5121
+ // ── Server-metadata cluster: notify scope (mute) + unread (HTTP) ──
5122
+ /** PUT `/v1/messaging/groups/{gid}/notify` — set the caller's per-(user,group) notify
5123
+ * scope (`'all'`|`'none'`). The server stores the opaque enum verbatim and stays blind.
5124
+ * Returns the server-echoed scope (fail-OPEN to `'all'` on an unknown value). */
5125
+ async setNotifyScope(group, scope) {
5126
+ const res = await palbeRequest(
5127
+ this.rt,
5128
+ "PUT",
5129
+ MessagingPaths.groupNotify(group.displayId),
5130
+ { body: { notify_scope: scope } }
5131
+ );
5132
+ return res.notify_scope === "none" ? "none" : "all";
5133
+ }
5134
+ /** GET `/v1/messaging/groups/{gid}/notify` — the caller's notify scope. An absent
5135
+ * server row / unknown value reads as `'all'` (fail-OPEN — never silently mutes). */
5136
+ async getNotifyScope(group) {
5137
+ const res = await palbeRequest(
5138
+ this.rt,
5139
+ "GET",
5140
+ MessagingPaths.groupNotify(group.displayId)
5141
+ );
5142
+ return res.notify_scope === "none" ? "none" : "all";
5143
+ }
5144
+ /** GET `/v1/messaging/groups/{gid}/unread` — the caller's OWN unread view (opaque
5145
+ * server metadata). Maps the snake_case wire → the camelCase `UnreadView`. */
5146
+ async unread(group) {
5147
+ const res = await palbeRequest(this.rt, "GET", MessagingPaths.groupUnread(group.displayId));
5148
+ return {
5149
+ groupMaxSeq: res.group_max_seq,
5150
+ lastReadSeq: res.last_read_seq,
5151
+ deliveredSeq: res.delivered_seq,
5152
+ unreadCount: res.unread_count
5153
+ };
5154
+ }
5013
5155
  };
5014
5156
  function isOwnEchoOrConsumed(e) {
5015
5157
  const msg = e instanceof Error ? e.message : String(e);
@@ -7167,6 +7309,19 @@ var MessagingCoordinator = class {
7167
7309
  const r = await this.resolve();
7168
7310
  await r.source.markRead(group, upToServerSeq);
7169
7311
  }
7312
+ // ── Server-metadata cluster: notify scope (mute) + unread ──
7313
+ async setNotifyScope(group, scope) {
7314
+ const r = await this.resolve();
7315
+ return r.source.setNotifyScope(group, scope);
7316
+ }
7317
+ async getNotifyScope(group) {
7318
+ const r = await this.resolve();
7319
+ return r.source.getNotifyScope(group);
7320
+ }
7321
+ async unread(group) {
7322
+ const r = await this.resolve();
7323
+ return r.source.unread(group);
7324
+ }
7170
7325
  subscribeLive(group, chat) {
7171
7326
  let offMsg = null;
7172
7327
  let offConv = null;
@@ -7419,6 +7574,469 @@ var PalbeMessaging = class {
7419
7574
  }
7420
7575
  };
7421
7576
 
7577
+ // src/perf/app-start.ts
7578
+ function measureAppStart(nav) {
7579
+ if (!(nav.fcp > 0)) return null;
7580
+ const value = nav.fcp - nav.startTime;
7581
+ if (value <= 0) return null;
7582
+ return {
7583
+ row_id: crypto.randomUUID(),
7584
+ trace_type: "app_start",
7585
+ name: "cold_start",
7586
+ value,
7587
+ timestamp: Date.now()
7588
+ };
7589
+ }
7590
+
7591
+ // src/perf/perf-config-client.ts
7592
+ var PERF_CONFIG_PATH = "/v1/analytics/perf/config";
7593
+ var FNV_OFFSET_BASIS_32 = 2166136261;
7594
+ var FNV_PRIME_32 = 16777619;
7595
+ var utf82 = typeof TextEncoder !== "undefined" ? new TextEncoder() : { encode: (s) => Uint8Array.from(Buffer.from(s, "utf-8")) };
7596
+ function perfSampleBucket(rowId) {
7597
+ let hash = FNV_OFFSET_BASIS_32;
7598
+ for (const byte of utf82.encode(rowId)) {
7599
+ hash ^= byte;
7600
+ hash = Math.imul(hash, FNV_PRIME_32);
7601
+ }
7602
+ return (hash >>> 0) % 100;
7603
+ }
7604
+ function isPerfConfig(value) {
7605
+ if (typeof value !== "object" || value === null) return false;
7606
+ const o = value;
7607
+ return typeof o.sample_pct === "number";
7608
+ }
7609
+ function isRawResponse(value) {
7610
+ return typeof value === "object" && value !== null && "data" in value;
7611
+ }
7612
+ function headerValue(headers, name) {
7613
+ if (!headers) return void 0;
7614
+ const lower = name.toLowerCase();
7615
+ for (const [k, v] of Object.entries(headers)) {
7616
+ if (k.toLowerCase() === lower) return v;
7617
+ }
7618
+ return void 0;
7619
+ }
7620
+ var PerfConfigClient = class {
7621
+ etag;
7622
+ async fetchConfig(rt, perf) {
7623
+ const headers = {};
7624
+ if (this.etag) headers["If-None-Match"] = this.etag;
7625
+ try {
7626
+ const res = await rt.http.request("GET", PERF_CONFIG_PATH, { headers });
7627
+ if (!isRawResponse(res)) return;
7628
+ if (res.status === 304 || res.error && res.error.status === 304) return;
7629
+ if (res.error) return;
7630
+ const newEtag = headerValue(res.headers, "etag");
7631
+ if (newEtag) this.etag = newEtag;
7632
+ if (isPerfConfig(res.data)) {
7633
+ perf.setSamplePct(res.data.sample_pct);
7634
+ }
7635
+ } catch {
7636
+ }
7637
+ }
7638
+ };
7639
+
7640
+ // src/perf/fetch-swizzle.ts
7641
+ var PERF_EXCLUDED_PREFIX2 = "/v1/analytics/";
7642
+ function nowMs2() {
7643
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7644
+ }
7645
+ function describeRequest(input, init) {
7646
+ let url;
7647
+ let method = "GET";
7648
+ if (typeof input === "string") {
7649
+ url = input;
7650
+ } else if (input instanceof URL) {
7651
+ url = input.href;
7652
+ } else {
7653
+ url = input.url;
7654
+ method = input.method;
7655
+ }
7656
+ if (init?.method) method = init.method;
7657
+ return { url, method: method.toUpperCase() };
7658
+ }
7659
+ function pathOf(url) {
7660
+ try {
7661
+ return new URL(url, "http://_local").pathname;
7662
+ } catch {
7663
+ return url;
7664
+ }
7665
+ }
7666
+ function installFetchSwizzle(perf) {
7667
+ if (typeof fetch !== "function") return () => {
7668
+ };
7669
+ const original = fetch;
7670
+ const wrapped = async (input, init) => {
7671
+ const { url, method } = describeRequest(input, init);
7672
+ const traced = !pathOf(url).startsWith(PERF_EXCLUDED_PREFIX2);
7673
+ const startedAt = traced ? nowMs2() : 0;
7674
+ try {
7675
+ const res = await original(input, init);
7676
+ if (traced) perf.recordNetwork(method, redactUrl(url), res.status, nowMs2() - startedAt);
7677
+ return res;
7678
+ } catch (err) {
7679
+ if (traced) perf.recordNetwork(method, redactUrl(url), 0, nowMs2() - startedAt);
7680
+ throw err;
7681
+ }
7682
+ };
7683
+ globalThis.fetch = wrapped;
7684
+ return () => {
7685
+ globalThis.fetch = original;
7686
+ };
7687
+ }
7688
+
7689
+ // src/perf/offline-queue.ts
7690
+ var PERF_QUEUE_KEY = "palbe.perf.queue";
7691
+ var DEFAULT_MAX_ITEMS = 500;
7692
+ function canPersist() {
7693
+ return typeof document !== "undefined" && typeof localStorage !== "undefined";
7694
+ }
7695
+ function isPerfItem(value) {
7696
+ if (typeof value !== "object" || value === null) return false;
7697
+ const o = value;
7698
+ return typeof o.row_id === "string" && typeof o.trace_type === "string" && typeof o.name === "string" && typeof o.value === "number" && typeof o.timestamp === "number";
7699
+ }
7700
+ function readPersisted() {
7701
+ if (!canPersist()) return [];
7702
+ try {
7703
+ const raw = localStorage.getItem(PERF_QUEUE_KEY);
7704
+ if (!raw) return [];
7705
+ const parsed = JSON.parse(raw);
7706
+ if (!Array.isArray(parsed)) return [];
7707
+ return parsed.filter(isPerfItem);
7708
+ } catch {
7709
+ return [];
7710
+ }
7711
+ }
7712
+ var PerfOfflineQueue = class {
7713
+ items;
7714
+ _dropped = 0;
7715
+ maxItems;
7716
+ constructor(maxItems = DEFAULT_MAX_ITEMS) {
7717
+ this.maxItems = Math.max(1, maxItems);
7718
+ this.items = readPersisted();
7719
+ this.trim();
7720
+ }
7721
+ /** Append items; FIFO-evict the oldest when over `maxItems`. */
7722
+ enqueue(items) {
7723
+ if (items.length === 0) return;
7724
+ this.items.push(...items);
7725
+ this.trim();
7726
+ this.persist();
7727
+ }
7728
+ /** Return all queued items (oldest-first) and clear the queue + store. */
7729
+ drainAll() {
7730
+ if (this.items.length === 0) return [];
7731
+ const out = this.items;
7732
+ this.items = [];
7733
+ this.clearStore();
7734
+ return out;
7735
+ }
7736
+ /** Current queue depth. */
7737
+ get count() {
7738
+ return this.items.length;
7739
+ }
7740
+ /** Cumulative count of FIFO-evicted items (a `dropped` metric, not silent). */
7741
+ get dropped() {
7742
+ return this._dropped;
7743
+ }
7744
+ /** Drop the oldest items until at most `maxItems` remain, counting each. */
7745
+ trim() {
7746
+ const overflow = this.items.length - this.maxItems;
7747
+ if (overflow > 0) {
7748
+ this.items.splice(0, overflow);
7749
+ this._dropped += overflow;
7750
+ }
7751
+ }
7752
+ persist() {
7753
+ if (!canPersist()) return;
7754
+ try {
7755
+ localStorage.setItem(PERF_QUEUE_KEY, JSON.stringify(this.items));
7756
+ } catch {
7757
+ }
7758
+ }
7759
+ clearStore() {
7760
+ if (!canPersist()) return;
7761
+ try {
7762
+ localStorage.removeItem(PERF_QUEUE_KEY);
7763
+ } catch {
7764
+ }
7765
+ }
7766
+ };
7767
+
7768
+ // src/perf/perf-state.ts
7769
+ var MAX_PERF_BATCH = 100;
7770
+ var PerfState = class {
7771
+ /** Pending, un-flushed perf items (FIFO). */
7772
+ buffer = [];
7773
+ /** When true, every flush carries `X-Palbase-Test-Device: 1`. */
7774
+ testDevice = false;
7775
+ enqueue(item) {
7776
+ this.buffer.push(item);
7777
+ }
7778
+ /** Detach up to `MAX_PERF_BATCH` items for one POST (FIFO order preserved). */
7779
+ take(limit = MAX_PERF_BATCH) {
7780
+ return this.buffer.splice(0, limit);
7781
+ }
7782
+ get size() {
7783
+ return this.buffer.length;
7784
+ }
7785
+ };
7786
+
7787
+ // src/perf/perf-wire.ts
7788
+ function encodePerfBatch(items) {
7789
+ return { items };
7790
+ }
7791
+
7792
+ // src/perf/perf-facade.ts
7793
+ var FLUSH_AT2 = 20;
7794
+ var FLUSH_INTERVAL_MS2 = 1e4;
7795
+ var TEST_DEVICE_HEADER = "X-Palbase-Test-Device";
7796
+ function nowMs3() {
7797
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7798
+ }
7799
+ var PerfTrace = class {
7800
+ constructor(name, onStop) {
7801
+ this.name = name;
7802
+ this.onStop = onStop;
7803
+ }
7804
+ name;
7805
+ onStop;
7806
+ attrs = {};
7807
+ counters = {};
7808
+ startedAt = nowMs3();
7809
+ stopped = false;
7810
+ putAttribute(key, value) {
7811
+ this.attrs[key] = value;
7812
+ }
7813
+ incrementMetric(name, by = 1) {
7814
+ this.counters[name] = (this.counters[name] ?? 0) + by;
7815
+ }
7816
+ stop() {
7817
+ if (this.stopped) return;
7818
+ this.stopped = true;
7819
+ const item = {
7820
+ row_id: crypto.randomUUID(),
7821
+ trace_type: "custom",
7822
+ name: this.name,
7823
+ value: Math.max(0, nowMs3() - this.startedAt),
7824
+ timestamp: Date.now()
7825
+ };
7826
+ if (Object.keys(this.attrs).length > 0) item.attrs = this.attrs;
7827
+ if (Object.keys(this.counters).length > 0) item.counters = this.counters;
7828
+ this.onStop(item);
7829
+ }
7830
+ };
7831
+ var PalbePerf = class {
7832
+ constructor(rt, state = new PerfState(), queue = new PerfOfflineQueue()) {
7833
+ this.rt = rt;
7834
+ this.state = state;
7835
+ this.queue = queue;
7836
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
7837
+ window.addEventListener("online", this.onOnline);
7838
+ }
7839
+ }
7840
+ rt;
7841
+ state;
7842
+ queue;
7843
+ flushTimer = null;
7844
+ /** Browser → buffer + size/timer flush. Server (no document) → immediate
7845
+ * per-item flush, zero timers (nothing leaks into RSC/route handlers). */
7846
+ browser = typeof document !== "undefined";
7847
+ /** Bound `online` handler so it can be removed on `dispose()` (no leak). */
7848
+ onOnline = () => {
7849
+ void this.flush();
7850
+ };
7851
+ /** Server-controlled client-side sample rate (0..100). 100 until the config
7852
+ * client fetches `/v1/analytics/perf/config` — the SDK obeys this ceiling and
7853
+ * never raises its own rate (invariant 3). `record` drops an item whose
7854
+ * deterministic `row_id` bucket is >= this pct, mirroring the server's
7855
+ * `SampleDecision` so client + server keep the SAME rows. */
7856
+ samplePct = 100;
7857
+ /** Remove the `online` listener. Called when the runtime is replaced so the
7858
+ * handler does not outlive this facade. No-op outside the browser. */
7859
+ dispose() {
7860
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
7861
+ window.removeEventListener("online", this.onOnline);
7862
+ }
7863
+ }
7864
+ /** Mark (or unmark) this client's traffic as test — the server tags the rows
7865
+ * when the `X-Palbase-Test-Device: 1` header rides along on flush. */
7866
+ setTestDevice(on) {
7867
+ this.state.testDevice = on;
7868
+ }
7869
+ /** Apply the server-resolved client-side sample rate (0..100), clamped. Called
7870
+ * by `PerfConfigClient` after fetching `/v1/analytics/perf/config`. The SDK
7871
+ * OBEYS this value — it is a ceiling, never raised locally. */
7872
+ setSamplePct(pct) {
7873
+ this.samplePct = Math.max(0, Math.min(100, Math.trunc(pct)));
7874
+ }
7875
+ /** Pending (un-flushed) buffer depth. Test-only visibility into the buffer so
7876
+ * the remote-sampling tests can assert how many items were sampled in. */
7877
+ get bufferSizeForTest() {
7878
+ return this.state.size;
7879
+ }
7880
+ /** Opt-in: wrap the global `fetch` so every app-level request is recorded as a
7881
+ * redacted `network` perf item (the `/v1/analytics/*` ingest paths are
7882
+ * self-excluded). OFF by default — swizzling a global is a page-wide side
7883
+ * effect. Returns an `uninstall` that restores the original `fetch`. */
7884
+ enableFetchCapture() {
7885
+ return installFetchSwizzle(this);
7886
+ }
7887
+ /** Start a custom trace; the returned handle records a `custom` item on
7888
+ * `.stop()`. */
7889
+ startTrace(name) {
7890
+ return new PerfTrace(name, (item) => this.record(item));
7891
+ }
7892
+ /** Buffer one perf item. In the browser, flush on size/timer; on the server
7893
+ * flush immediately (no timers). Never throws.
7894
+ *
7895
+ * Client-side remote sampling: an item whose deterministic `row_id` bucket is
7896
+ * NOT below the server-controlled `samplePct` is dropped before buffering —
7897
+ * the SAME FNV-1a-mod-100 decision the server applies at ingest, so the two
7898
+ * keep identical rows and the SDK saves the upload (defense-in-depth: the
7899
+ * server re-samples authoritatively). */
7900
+ record(item) {
7901
+ if (perfSampleBucket(item.row_id) >= this.samplePct) return;
7902
+ this.state.enqueue(item);
7903
+ if (!this.browser) {
7904
+ void this.flush();
7905
+ return;
7906
+ }
7907
+ if (this.state.size >= FLUSH_AT2) void this.flush();
7908
+ else this.startTimer();
7909
+ }
7910
+ /** Buffer a network trace — called by `request.ts` around `rt.http.request`
7911
+ * (the analytics ingest path is excluded by the caller to avoid recursion). */
7912
+ recordNetwork(method, url, status, durationMs, requestId) {
7913
+ const item = {
7914
+ row_id: crypto.randomUUID(),
7915
+ trace_type: "network",
7916
+ name: `${method} ${url}`,
7917
+ value: durationMs,
7918
+ attrs: { status: String(status) },
7919
+ timestamp: Date.now()
7920
+ };
7921
+ if (requestId) item.request_id = requestId;
7922
+ this.record(item);
7923
+ }
7924
+ /** Drain the offline queue (oldest-first) and the live buffer to
7925
+ * `/v1/analytics/perf` (≤100 items per request, sequential). Resolves when
7926
+ * delivery finished; NEVER rejects. A failed slice is NOT dropped — it goes
7927
+ * to the offline queue (persist-on-fail) so the next flush (size/timer or the
7928
+ * `online` reconnect event) retries it. Items keep their original `row_id`,
7929
+ * so a redelivery dedups server-side (ReplacingMergeTree). */
7930
+ async flush() {
7931
+ this.cancelTimer();
7932
+ const pending = this.queue.drainAll();
7933
+ if (pending.length === 0 && this.state.size === 0) return;
7934
+ const headers = this.state.testDevice ? { [TEST_DEVICE_HEADER]: "1" } : void 0;
7935
+ const remaining = [...pending];
7936
+ while (this.state.size > 0) remaining.push(...this.state.take(MAX_PERF_BATCH));
7937
+ for (let i = 0; i < remaining.length; i += MAX_PERF_BATCH) {
7938
+ const slice = remaining.slice(i, i + MAX_PERF_BATCH);
7939
+ try {
7940
+ await palbeRequest(
7941
+ this.rt,
7942
+ "POST",
7943
+ "/v1/analytics/perf",
7944
+ { body: encodePerfBatch(slice), headers }
7945
+ );
7946
+ } catch {
7947
+ this.queue.enqueue(slice);
7948
+ }
7949
+ }
7950
+ }
7951
+ startTimer() {
7952
+ if (this.flushTimer !== null) return;
7953
+ this.flushTimer = setTimeout(() => {
7954
+ this.flushTimer = null;
7955
+ void this.flush();
7956
+ }, FLUSH_INTERVAL_MS2);
7957
+ }
7958
+ cancelTimer() {
7959
+ if (this.flushTimer !== null) {
7960
+ clearTimeout(this.flushTimer);
7961
+ this.flushTimer = null;
7962
+ }
7963
+ }
7964
+ };
7965
+
7966
+ // src/perf/web-vitals.ts
7967
+ function webVitalItem(name, value) {
7968
+ return {
7969
+ row_id: crypto.randomUUID(),
7970
+ trace_type: "web_vital",
7971
+ name,
7972
+ value: Math.max(0, value),
7973
+ timestamp: Date.now()
7974
+ };
7975
+ }
7976
+ function isLayoutShift(e) {
7977
+ return e.entryType === "layout-shift" && "value" in e && "hadRecentInput" in e;
7978
+ }
7979
+ function isEventTiming(e) {
7980
+ return e.entryType === "event" || e.entryType === "first-input";
7981
+ }
7982
+ function safeObserve(type, cb) {
7983
+ if (typeof PerformanceObserver === "undefined") return null;
7984
+ try {
7985
+ const obs = new PerformanceObserver((list) => cb(list.getEntries()));
7986
+ obs.observe({ type, buffered: true });
7987
+ return obs;
7988
+ } catch {
7989
+ return null;
7990
+ }
7991
+ }
7992
+ function observeWebVitals(record) {
7993
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function" || typeof document.removeEventListener !== "function" || typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
7994
+ return () => {
7995
+ };
7996
+ }
7997
+ let cls = 0;
7998
+ let lcp = 0;
7999
+ let inp = 0;
8000
+ const observers = [
8001
+ // LCP: keep the largest/last reported render.
8002
+ safeObserve("largest-contentful-paint", (entries) => {
8003
+ for (const e of entries) lcp = Math.max(lcp, e.startTime);
8004
+ }),
8005
+ // CLS: sum shift values that weren't caused by recent input.
8006
+ safeObserve("layout-shift", (entries) => {
8007
+ for (const e of entries) if (isLayoutShift(e) && !e.hadRecentInput) cls += e.value;
8008
+ }),
8009
+ // INP: approximate as the worst interaction duration observed.
8010
+ safeObserve("event", (entries) => {
8011
+ for (const e of entries) if (isEventTiming(e)) inp = Math.max(inp, e.duration);
8012
+ }),
8013
+ // FCP: one-shot.
8014
+ safeObserve("paint", (entries) => {
8015
+ for (const e of entries)
8016
+ if (e.name === "first-contentful-paint") record(webVitalItem("FCP", e.startTime));
8017
+ })
8018
+ ];
8019
+ const nav = performance.getEntriesByType("navigation")[0];
8020
+ if (nav && nav.responseStart > 0) record(webVitalItem("TTFB", nav.responseStart));
8021
+ let flushed = false;
8022
+ const flush = () => {
8023
+ if (flushed) return;
8024
+ flushed = true;
8025
+ if (lcp > 0) record(webVitalItem("LCP", lcp));
8026
+ record(webVitalItem("CLS", cls));
8027
+ if (inp > 0) record(webVitalItem("INP", inp));
8028
+ };
8029
+ const onHide = () => {
8030
+ if (document.visibilityState === "hidden") flush();
8031
+ };
8032
+ document.addEventListener("visibilitychange", onHide);
8033
+ return () => {
8034
+ flush();
8035
+ document.removeEventListener("visibilitychange", onHide);
8036
+ for (const o of observers) o?.disconnect();
8037
+ };
8038
+ }
8039
+
7422
8040
  // src/realtime/anon-token.ts
7423
8041
  var REFRESH_SKEW_MS = 6e4;
7424
8042
  var AnonTokenProvider = class {
@@ -8106,10 +8724,13 @@ function defaultSessionStorage(key) {
8106
8724
  }
8107
8725
 
8108
8726
  // src/version.ts
8109
- var VERSION = "1.6.2";
8727
+ var VERSION = "1.8.0";
8110
8728
 
8111
8729
  // src/runtime.ts
8112
8730
  function buildRuntime(config) {
8731
+ const appIdentifier = config.identifier ?? "";
8732
+ const runtimeOrigin = typeof window !== "undefined" && typeof window.location !== "undefined" ? window.location.origin : "";
8733
+ assertOriginMatches(loadAppConfig({ identifier: appIdentifier }), runtimeOrigin);
8113
8734
  const http = new HttpClient(config.apiKey, {
8114
8735
  url: config.url,
8115
8736
  headers: { "X-Client-Info": `palbe-web/${VERSION}`, ...config.headers }
@@ -8159,8 +8780,10 @@ function buildRuntime(config) {
8159
8780
  let analytics;
8160
8781
  let calls;
8161
8782
  let messaging;
8783
+ let perf;
8162
8784
  const rt = {
8163
8785
  config,
8786
+ appIdentifier,
8164
8787
  http,
8165
8788
  tokenManager,
8166
8789
  authClient,
@@ -8196,11 +8819,19 @@ function buildRuntime(config) {
8196
8819
  destroyRealtime() {
8197
8820
  realtime?.destroy();
8198
8821
  realtime = void 0;
8822
+ perf?.dispose();
8199
8823
  },
8200
8824
  // The buffering facade is lazy; its identity state is NOT (below).
8201
8825
  get analytics() {
8202
8826
  if (!analytics) analytics = new PalbeAnalytics(rt, analyticsState);
8203
8827
  return analytics;
8828
+ },
8829
+ // PalPerf is constructed up front (touched below): `request.ts` records a
8830
+ // network trace on EVERY fetch, so it can't be lazy. The memo here only
8831
+ // guards against re-construction.
8832
+ get perf() {
8833
+ if (!perf) perf = new PalbePerf(rt);
8834
+ return perf;
8204
8835
  }
8205
8836
  };
8206
8837
  const analyticsState = new AnalyticsState(rt);
@@ -8208,8 +8839,42 @@ function buildRuntime(config) {
8208
8839
  const hasOwn = Object.keys(request.headers).some((k) => k.toLowerCase() === "x-distinct-id");
8209
8840
  if (!hasOwn) request.headers["X-Distinct-Id"] = analyticsState.distinctId();
8210
8841
  });
8842
+ void rt.perf;
8843
+ if (typeof document !== "undefined") {
8844
+ void new PerfConfigClient().fetchConfig(rt, rt.perf);
8845
+ }
8846
+ recordColdStart(rt);
8847
+ observeWebVitals((item) => rt.perf.record(item));
8211
8848
  return rt;
8212
8849
  }
8850
+ function recordColdStart(rt) {
8851
+ if (typeof document === "undefined" || typeof performance === "undefined") return;
8852
+ try {
8853
+ const navEntry = performance.getEntriesByType("navigation")[0];
8854
+ const startTime = navEntry?.startTime ?? 0;
8855
+ const recordFromFcp = (fcp) => {
8856
+ const item = measureAppStart({ startTime, fcp });
8857
+ if (item) rt.perf.record(item);
8858
+ };
8859
+ const existing = performance.getEntriesByName("first-contentful-paint").find((e) => e.startTime > 0);
8860
+ if (existing) {
8861
+ recordFromFcp(existing.startTime);
8862
+ return;
8863
+ }
8864
+ if (typeof PerformanceObserver === "undefined") return;
8865
+ const observer = new PerformanceObserver((list) => {
8866
+ for (const entry of list.getEntries()) {
8867
+ if (entry.name === "first-contentful-paint") {
8868
+ observer.disconnect();
8869
+ recordFromFcp(entry.startTime);
8870
+ return;
8871
+ }
8872
+ }
8873
+ });
8874
+ observer.observe({ type: "paint", buffered: true });
8875
+ } catch {
8876
+ }
8877
+ }
8213
8878
 
8214
8879
  // src/internal.ts
8215
8880
  function __configure(config) {