@palbase/web 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -387,14 +387,47 @@ function unwrap(res) {
387
387
  return res.data;
388
388
  }
389
389
 
390
+ // src/perf/url-redactor.ts
391
+ var ID_SEGMENT = /^(?:\d+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
392
+ var EMAIL_SEGMENT = /(?:@|%40)/i;
393
+ function isSensitiveSegment(seg2) {
394
+ return ID_SEGMENT.test(seg2) || EMAIL_SEGMENT.test(seg2);
395
+ }
396
+ function redactUrl(rawUrl) {
397
+ let path = rawUrl;
398
+ try {
399
+ path = new URL(rawUrl).pathname;
400
+ } catch {
401
+ const q = path.indexOf("?");
402
+ if (q >= 0) path = path.slice(0, q);
403
+ }
404
+ return path.split("/").map((seg2) => isSensitiveSegment(seg2) ? ":id" : seg2).join("/");
405
+ }
406
+
390
407
  // src/request.ts
391
408
  var MUTATING = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
409
+ var PERF_EXCLUDED_PREFIX = "/v1/analytics/";
410
+ function isSelfTraced(path) {
411
+ return path.startsWith(PERF_EXCLUDED_PREFIX);
412
+ }
413
+ function nowMs() {
414
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
415
+ }
416
+ function isAbort(e) {
417
+ return e instanceof Error && e.name === "AbortError";
418
+ }
392
419
  async function palbeRequest(rt, method, path, spec = {}) {
393
420
  const headers = { ...spec.headers };
394
421
  const callerHasKey = Object.keys(headers).some((k) => k.toLowerCase() === "idempotency-key");
395
422
  if (MUTATING.has(method) && !callerHasKey) {
396
423
  headers["Idempotency-Key"] = crypto.randomUUID();
397
424
  }
425
+ if (rt.appIdentifier !== "") {
426
+ const callerHasBundle = Object.keys(headers).some(
427
+ (k) => k.toLowerCase() === "x-palbase-bundle"
428
+ );
429
+ if (!callerHasBundle) headers["X-Palbase-Bundle"] = rt.appIdentifier;
430
+ }
398
431
  const attempt = async () => {
399
432
  try {
400
433
  return await rt.http.request(method, path, {
@@ -407,21 +440,38 @@ async function palbeRequest(rt, method, path, spec = {}) {
407
440
  throw pe ? fromPalbaseError(pe) : e;
408
441
  }
409
442
  };
410
- let res = await attempt();
411
- if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
412
- try {
413
- await rt.tokenManager.refreshSession();
414
- } catch (refreshErr) {
415
- const pe = asPalbaseError(refreshErr);
416
- const status = pe?.status ?? 0;
417
- if (status === 400 || status === 401 || status === 403) {
418
- rt.tokenManager.clearSession();
419
- throw fromPalbaseError(res.error);
443
+ const traced = !isSelfTraced(path) && rt.perf !== void 0;
444
+ const startedAt = traced ? nowMs() : 0;
445
+ let recorded = false;
446
+ const record = (status) => {
447
+ if (!traced || recorded) return;
448
+ recorded = true;
449
+ rt.perf.recordNetwork(method, redactUrl(path), status, nowMs() - startedAt);
450
+ };
451
+ let res;
452
+ try {
453
+ res = await attempt();
454
+ if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
455
+ try {
456
+ await rt.tokenManager.refreshSession();
457
+ } catch (refreshErr) {
458
+ const pe = asPalbaseError(refreshErr);
459
+ const status = pe?.status ?? 0;
460
+ if (status === 400 || status === 401 || status === 403) {
461
+ rt.tokenManager.clearSession();
462
+ record(res.error.status);
463
+ throw fromPalbaseError(res.error);
464
+ }
465
+ record(pe?.status ?? 0);
466
+ throw pe ? fromPalbaseError(pe) : refreshErr;
420
467
  }
421
- throw pe ? fromPalbaseError(pe) : refreshErr;
468
+ res = await attempt();
422
469
  }
423
- res = await attempt();
470
+ } catch (e) {
471
+ if (!isAbort(e)) record(asPalbaseError(e)?.status ?? 0);
472
+ throw e;
424
473
  }
474
+ record(res.error?.status ?? 200);
425
475
  return unwrap(res);
426
476
  }
427
477
 
@@ -438,7 +488,7 @@ function palbeState() {
438
488
  }
439
489
 
440
490
  // src/namespaces.ts
441
- var FIXED_SURFACE = /* @__PURE__ */ new Set([
491
+ var RESERVED = /* @__PURE__ */ new Set([
442
492
  "call",
443
493
  "upload",
444
494
  "auth",
@@ -446,7 +496,8 @@ var FIXED_SURFACE = /* @__PURE__ */ new Set([
446
496
  "realtime",
447
497
  "analytics",
448
498
  "calls",
449
- "messaging"
499
+ "messaging",
500
+ "perf"
450
501
  ]);
451
502
  function reservedNamespaceError(key) {
452
503
  return new BackendError("validation", {
@@ -511,7 +562,7 @@ function validateTree(node) {
511
562
  }
512
563
  function __registerNamespaces(tree) {
513
564
  for (const key of Object.keys(tree)) {
514
- if (FIXED_SURFACE.has(key)) throw reservedNamespaceError(key);
565
+ if (RESERVED.has(key)) throw reservedNamespaceError(key);
515
566
  }
516
567
  validateTree(tree);
517
568
  const state = palbeState();
@@ -1360,6 +1411,23 @@ var PalbeAnalytics = class {
1360
1411
  }
1361
1412
  };
1362
1413
 
1414
+ // src/app-config.ts
1415
+ function loadAppConfig(raw) {
1416
+ if (typeof raw !== "object" || raw === null) {
1417
+ throw new Error("app_config_invalid: expected a JSON object");
1418
+ }
1419
+ const r = raw;
1420
+ const str = (k) => typeof r[k] === "string" ? r[k] : "";
1421
+ return { appId: str("app_id"), identifier: str("identifier"), envPreset: str("env_preset") };
1422
+ }
1423
+ function assertOriginMatches(cfg, runtimeOrigin) {
1424
+ if (cfg.identifier === "") return;
1425
+ if (runtimeOrigin === "") return;
1426
+ if (runtimeOrigin !== cfg.identifier) {
1427
+ throw new Error(`app_config_mismatch: expected origin ${cfg.identifier}, got ${runtimeOrigin}`);
1428
+ }
1429
+ }
1430
+
1363
1431
  // src/auth-wire.ts
1364
1432
  function asWireAuthResult(raw) {
1365
1433
  if (typeof raw !== "object" || raw === null) return null;
@@ -1418,6 +1486,8 @@ var PalbeAuth = class {
1418
1486
  // suppresses AuthClient's TOKEN_REFRESHED during re-signIn
1419
1487
  signedInState = false;
1420
1488
  // dedupes AuthClient's repeated SIGNED_OUT events
1489
+ hydratingUser = false;
1490
+ // guards hydrateUser() to a single boot fetch
1421
1491
  stateListeners = /* @__PURE__ */ new Set();
1422
1492
  eventListeners = /* @__PURE__ */ new Set();
1423
1493
  userListeners = /* @__PURE__ */ new Set();
@@ -1470,13 +1540,37 @@ var PalbeAuth = class {
1470
1540
  if (changed) for (const cb of this.userListeners) this.safeInvoke(() => cb(user));
1471
1541
  return user;
1472
1542
  }
1543
+ /**
1544
+ * Restore the user after a session was rehydrated from storage (page reload).
1545
+ * Hydration in buildRuntime restores the TOKENS synchronously, but the access
1546
+ * token JWT does not carry emailVerified/createdAt, so the full AuthUser can't
1547
+ * be reconstructed offline — this fetches GET /auth/user once and announces
1548
+ * `signedIn`, so the app no longer has to `await pb.auth.refreshUser()` itself
1549
+ * on boot. Idempotent (runs once), browser-safe (no-op when not signed in or
1550
+ * a user is already cached), and never throws (a boot-time network failure
1551
+ * must not break app startup — isSignedIn stays true, the app can retry).
1552
+ */
1553
+ hydrateUser() {
1554
+ if (this.hydratingUser || this.cachedUser || !this.isSignedIn) return;
1555
+ this.hydratingUser = true;
1556
+ void this.refreshUser().then((user) => {
1557
+ if (this.isSignedIn) {
1558
+ this.signedInState = true;
1559
+ this.emitState({ status: "signedIn", user });
1560
+ }
1561
+ }).catch(() => {
1562
+ }).finally(() => {
1563
+ this.hydratingUser = false;
1564
+ });
1565
+ }
1473
1566
  // ── listeners ──────────────────────────────────────────
1474
1567
  /**
1475
1568
  * Subscribe to signed-in/signed-out state. Fires immediately with the
1476
- * current snapshot (iOS parity). NOTE: a restored session (page reload) has
1477
- * no cached user yet, so the immediate snapshot reports signedOut even when
1478
- * `isSignedIn` is true call `refreshUser()` on boot to populate the user
1479
- * and rely on `isSignedIn` for the session truth.
1569
+ * current snapshot (iOS parity). A restored session (page reload) hydrates
1570
+ * the user asynchronously via `hydrateUser()` (fired once at boot), so the
1571
+ * FIRST snapshot may report signedOut for a tick even when `isSignedIn` is
1572
+ * true; the listener then fires again with `signedIn` once the user lands.
1573
+ * Rely on `isSignedIn` for the session truth if you need it synchronously.
1480
1574
  */
1481
1575
  onAuthStateChange(callback) {
1482
1576
  this.stateListeners.add(callback);
@@ -7684,6 +7778,469 @@ var PalbeMessaging = class {
7684
7778
  }
7685
7779
  };
7686
7780
 
7781
+ // src/perf/app-start.ts
7782
+ function measureAppStart(nav) {
7783
+ if (!(nav.fcp > 0)) return null;
7784
+ const value = nav.fcp - nav.startTime;
7785
+ if (value <= 0) return null;
7786
+ return {
7787
+ row_id: crypto.randomUUID(),
7788
+ trace_type: "app_start",
7789
+ name: "cold_start",
7790
+ value,
7791
+ timestamp: Date.now()
7792
+ };
7793
+ }
7794
+
7795
+ // src/perf/perf-config-client.ts
7796
+ var PERF_CONFIG_PATH = "/v1/analytics/perf/config";
7797
+ var FNV_OFFSET_BASIS_32 = 2166136261;
7798
+ var FNV_PRIME_32 = 16777619;
7799
+ var utf82 = typeof TextEncoder !== "undefined" ? new TextEncoder() : { encode: (s) => Uint8Array.from(Buffer.from(s, "utf-8")) };
7800
+ function perfSampleBucket(rowId) {
7801
+ let hash = FNV_OFFSET_BASIS_32;
7802
+ for (const byte of utf82.encode(rowId)) {
7803
+ hash ^= byte;
7804
+ hash = Math.imul(hash, FNV_PRIME_32);
7805
+ }
7806
+ return (hash >>> 0) % 100;
7807
+ }
7808
+ function isPerfConfig(value) {
7809
+ if (typeof value !== "object" || value === null) return false;
7810
+ const o = value;
7811
+ return typeof o.sample_pct === "number";
7812
+ }
7813
+ function isRawResponse(value) {
7814
+ return typeof value === "object" && value !== null && "data" in value;
7815
+ }
7816
+ function headerValue(headers, name) {
7817
+ if (!headers) return void 0;
7818
+ const lower = name.toLowerCase();
7819
+ for (const [k, v] of Object.entries(headers)) {
7820
+ if (k.toLowerCase() === lower) return v;
7821
+ }
7822
+ return void 0;
7823
+ }
7824
+ var PerfConfigClient = class {
7825
+ etag;
7826
+ async fetchConfig(rt, perf) {
7827
+ const headers = {};
7828
+ if (this.etag) headers["If-None-Match"] = this.etag;
7829
+ try {
7830
+ const res = await rt.http.request("GET", PERF_CONFIG_PATH, { headers });
7831
+ if (!isRawResponse(res)) return;
7832
+ if (res.status === 304 || res.error && res.error.status === 304) return;
7833
+ if (res.error) return;
7834
+ const newEtag = headerValue(res.headers, "etag");
7835
+ if (newEtag) this.etag = newEtag;
7836
+ if (isPerfConfig(res.data)) {
7837
+ perf.setSamplePct(res.data.sample_pct);
7838
+ }
7839
+ } catch {
7840
+ }
7841
+ }
7842
+ };
7843
+
7844
+ // src/perf/fetch-swizzle.ts
7845
+ var PERF_EXCLUDED_PREFIX2 = "/v1/analytics/";
7846
+ function nowMs2() {
7847
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7848
+ }
7849
+ function describeRequest(input, init) {
7850
+ let url;
7851
+ let method = "GET";
7852
+ if (typeof input === "string") {
7853
+ url = input;
7854
+ } else if (input instanceof URL) {
7855
+ url = input.href;
7856
+ } else {
7857
+ url = input.url;
7858
+ method = input.method;
7859
+ }
7860
+ if (init?.method) method = init.method;
7861
+ return { url, method: method.toUpperCase() };
7862
+ }
7863
+ function pathOf(url) {
7864
+ try {
7865
+ return new URL(url, "http://_local").pathname;
7866
+ } catch {
7867
+ return url;
7868
+ }
7869
+ }
7870
+ function installFetchSwizzle(perf) {
7871
+ if (typeof fetch !== "function") return () => {
7872
+ };
7873
+ const original = fetch;
7874
+ const wrapped = async (input, init) => {
7875
+ const { url, method } = describeRequest(input, init);
7876
+ const traced = !pathOf(url).startsWith(PERF_EXCLUDED_PREFIX2);
7877
+ const startedAt = traced ? nowMs2() : 0;
7878
+ try {
7879
+ const res = await original(input, init);
7880
+ if (traced) perf.recordNetwork(method, redactUrl(url), res.status, nowMs2() - startedAt);
7881
+ return res;
7882
+ } catch (err) {
7883
+ if (traced) perf.recordNetwork(method, redactUrl(url), 0, nowMs2() - startedAt);
7884
+ throw err;
7885
+ }
7886
+ };
7887
+ globalThis.fetch = wrapped;
7888
+ return () => {
7889
+ globalThis.fetch = original;
7890
+ };
7891
+ }
7892
+
7893
+ // src/perf/offline-queue.ts
7894
+ var PERF_QUEUE_KEY = "palbe.perf.queue";
7895
+ var DEFAULT_MAX_ITEMS = 500;
7896
+ function canPersist() {
7897
+ return typeof document !== "undefined" && typeof localStorage !== "undefined";
7898
+ }
7899
+ function isPerfItem(value) {
7900
+ if (typeof value !== "object" || value === null) return false;
7901
+ const o = value;
7902
+ return typeof o.row_id === "string" && typeof o.trace_type === "string" && typeof o.name === "string" && typeof o.value === "number" && typeof o.timestamp === "number";
7903
+ }
7904
+ function readPersisted() {
7905
+ if (!canPersist()) return [];
7906
+ try {
7907
+ const raw = localStorage.getItem(PERF_QUEUE_KEY);
7908
+ if (!raw) return [];
7909
+ const parsed = JSON.parse(raw);
7910
+ if (!Array.isArray(parsed)) return [];
7911
+ return parsed.filter(isPerfItem);
7912
+ } catch {
7913
+ return [];
7914
+ }
7915
+ }
7916
+ var PerfOfflineQueue = class {
7917
+ items;
7918
+ _dropped = 0;
7919
+ maxItems;
7920
+ constructor(maxItems = DEFAULT_MAX_ITEMS) {
7921
+ this.maxItems = Math.max(1, maxItems);
7922
+ this.items = readPersisted();
7923
+ this.trim();
7924
+ }
7925
+ /** Append items; FIFO-evict the oldest when over `maxItems`. */
7926
+ enqueue(items) {
7927
+ if (items.length === 0) return;
7928
+ this.items.push(...items);
7929
+ this.trim();
7930
+ this.persist();
7931
+ }
7932
+ /** Return all queued items (oldest-first) and clear the queue + store. */
7933
+ drainAll() {
7934
+ if (this.items.length === 0) return [];
7935
+ const out = this.items;
7936
+ this.items = [];
7937
+ this.clearStore();
7938
+ return out;
7939
+ }
7940
+ /** Current queue depth. */
7941
+ get count() {
7942
+ return this.items.length;
7943
+ }
7944
+ /** Cumulative count of FIFO-evicted items (a `dropped` metric, not silent). */
7945
+ get dropped() {
7946
+ return this._dropped;
7947
+ }
7948
+ /** Drop the oldest items until at most `maxItems` remain, counting each. */
7949
+ trim() {
7950
+ const overflow = this.items.length - this.maxItems;
7951
+ if (overflow > 0) {
7952
+ this.items.splice(0, overflow);
7953
+ this._dropped += overflow;
7954
+ }
7955
+ }
7956
+ persist() {
7957
+ if (!canPersist()) return;
7958
+ try {
7959
+ localStorage.setItem(PERF_QUEUE_KEY, JSON.stringify(this.items));
7960
+ } catch {
7961
+ }
7962
+ }
7963
+ clearStore() {
7964
+ if (!canPersist()) return;
7965
+ try {
7966
+ localStorage.removeItem(PERF_QUEUE_KEY);
7967
+ } catch {
7968
+ }
7969
+ }
7970
+ };
7971
+
7972
+ // src/perf/perf-state.ts
7973
+ var MAX_PERF_BATCH = 100;
7974
+ var PerfState = class {
7975
+ /** Pending, un-flushed perf items (FIFO). */
7976
+ buffer = [];
7977
+ /** When true, every flush carries `X-Palbase-Test-Device: 1`. */
7978
+ testDevice = false;
7979
+ enqueue(item) {
7980
+ this.buffer.push(item);
7981
+ }
7982
+ /** Detach up to `MAX_PERF_BATCH` items for one POST (FIFO order preserved). */
7983
+ take(limit = MAX_PERF_BATCH) {
7984
+ return this.buffer.splice(0, limit);
7985
+ }
7986
+ get size() {
7987
+ return this.buffer.length;
7988
+ }
7989
+ };
7990
+
7991
+ // src/perf/perf-wire.ts
7992
+ function encodePerfBatch(items) {
7993
+ return { items };
7994
+ }
7995
+
7996
+ // src/perf/perf-facade.ts
7997
+ var FLUSH_AT2 = 20;
7998
+ var FLUSH_INTERVAL_MS2 = 1e4;
7999
+ var TEST_DEVICE_HEADER = "X-Palbase-Test-Device";
8000
+ function nowMs3() {
8001
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
8002
+ }
8003
+ var PerfTrace = class {
8004
+ constructor(name, onStop) {
8005
+ this.name = name;
8006
+ this.onStop = onStop;
8007
+ }
8008
+ name;
8009
+ onStop;
8010
+ attrs = {};
8011
+ counters = {};
8012
+ startedAt = nowMs3();
8013
+ stopped = false;
8014
+ putAttribute(key, value) {
8015
+ this.attrs[key] = value;
8016
+ }
8017
+ incrementMetric(name, by = 1) {
8018
+ this.counters[name] = (this.counters[name] ?? 0) + by;
8019
+ }
8020
+ stop() {
8021
+ if (this.stopped) return;
8022
+ this.stopped = true;
8023
+ const item = {
8024
+ row_id: crypto.randomUUID(),
8025
+ trace_type: "custom",
8026
+ name: this.name,
8027
+ value: Math.max(0, nowMs3() - this.startedAt),
8028
+ timestamp: Date.now()
8029
+ };
8030
+ if (Object.keys(this.attrs).length > 0) item.attrs = this.attrs;
8031
+ if (Object.keys(this.counters).length > 0) item.counters = this.counters;
8032
+ this.onStop(item);
8033
+ }
8034
+ };
8035
+ var PalbePerf = class {
8036
+ constructor(rt, state = new PerfState(), queue = new PerfOfflineQueue()) {
8037
+ this.rt = rt;
8038
+ this.state = state;
8039
+ this.queue = queue;
8040
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
8041
+ window.addEventListener("online", this.onOnline);
8042
+ }
8043
+ }
8044
+ rt;
8045
+ state;
8046
+ queue;
8047
+ flushTimer = null;
8048
+ /** Browser → buffer + size/timer flush. Server (no document) → immediate
8049
+ * per-item flush, zero timers (nothing leaks into RSC/route handlers). */
8050
+ browser = typeof document !== "undefined";
8051
+ /** Bound `online` handler so it can be removed on `dispose()` (no leak). */
8052
+ onOnline = () => {
8053
+ void this.flush();
8054
+ };
8055
+ /** Server-controlled client-side sample rate (0..100). 100 until the config
8056
+ * client fetches `/v1/analytics/perf/config` — the SDK obeys this ceiling and
8057
+ * never raises its own rate (invariant 3). `record` drops an item whose
8058
+ * deterministic `row_id` bucket is >= this pct, mirroring the server's
8059
+ * `SampleDecision` so client + server keep the SAME rows. */
8060
+ samplePct = 100;
8061
+ /** Remove the `online` listener. Called when the runtime is replaced so the
8062
+ * handler does not outlive this facade. No-op outside the browser. */
8063
+ dispose() {
8064
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
8065
+ window.removeEventListener("online", this.onOnline);
8066
+ }
8067
+ }
8068
+ /** Mark (or unmark) this client's traffic as test — the server tags the rows
8069
+ * when the `X-Palbase-Test-Device: 1` header rides along on flush. */
8070
+ setTestDevice(on) {
8071
+ this.state.testDevice = on;
8072
+ }
8073
+ /** Apply the server-resolved client-side sample rate (0..100), clamped. Called
8074
+ * by `PerfConfigClient` after fetching `/v1/analytics/perf/config`. The SDK
8075
+ * OBEYS this value — it is a ceiling, never raised locally. */
8076
+ setSamplePct(pct) {
8077
+ this.samplePct = Math.max(0, Math.min(100, Math.trunc(pct)));
8078
+ }
8079
+ /** Pending (un-flushed) buffer depth. Test-only visibility into the buffer so
8080
+ * the remote-sampling tests can assert how many items were sampled in. */
8081
+ get bufferSizeForTest() {
8082
+ return this.state.size;
8083
+ }
8084
+ /** Opt-in: wrap the global `fetch` so every app-level request is recorded as a
8085
+ * redacted `network` perf item (the `/v1/analytics/*` ingest paths are
8086
+ * self-excluded). OFF by default — swizzling a global is a page-wide side
8087
+ * effect. Returns an `uninstall` that restores the original `fetch`. */
8088
+ enableFetchCapture() {
8089
+ return installFetchSwizzle(this);
8090
+ }
8091
+ /** Start a custom trace; the returned handle records a `custom` item on
8092
+ * `.stop()`. */
8093
+ startTrace(name) {
8094
+ return new PerfTrace(name, (item) => this.record(item));
8095
+ }
8096
+ /** Buffer one perf item. In the browser, flush on size/timer; on the server
8097
+ * flush immediately (no timers). Never throws.
8098
+ *
8099
+ * Client-side remote sampling: an item whose deterministic `row_id` bucket is
8100
+ * NOT below the server-controlled `samplePct` is dropped before buffering —
8101
+ * the SAME FNV-1a-mod-100 decision the server applies at ingest, so the two
8102
+ * keep identical rows and the SDK saves the upload (defense-in-depth: the
8103
+ * server re-samples authoritatively). */
8104
+ record(item) {
8105
+ if (perfSampleBucket(item.row_id) >= this.samplePct) return;
8106
+ this.state.enqueue(item);
8107
+ if (!this.browser) {
8108
+ void this.flush();
8109
+ return;
8110
+ }
8111
+ if (this.state.size >= FLUSH_AT2) void this.flush();
8112
+ else this.startTimer();
8113
+ }
8114
+ /** Buffer a network trace — called by `request.ts` around `rt.http.request`
8115
+ * (the analytics ingest path is excluded by the caller to avoid recursion). */
8116
+ recordNetwork(method, url, status, durationMs, requestId) {
8117
+ const item = {
8118
+ row_id: crypto.randomUUID(),
8119
+ trace_type: "network",
8120
+ name: `${method} ${url}`,
8121
+ value: durationMs,
8122
+ attrs: { status: String(status) },
8123
+ timestamp: Date.now()
8124
+ };
8125
+ if (requestId) item.request_id = requestId;
8126
+ this.record(item);
8127
+ }
8128
+ /** Drain the offline queue (oldest-first) and the live buffer to
8129
+ * `/v1/analytics/perf` (≤100 items per request, sequential). Resolves when
8130
+ * delivery finished; NEVER rejects. A failed slice is NOT dropped — it goes
8131
+ * to the offline queue (persist-on-fail) so the next flush (size/timer or the
8132
+ * `online` reconnect event) retries it. Items keep their original `row_id`,
8133
+ * so a redelivery dedups server-side (ReplacingMergeTree). */
8134
+ async flush() {
8135
+ this.cancelTimer();
8136
+ const pending = this.queue.drainAll();
8137
+ if (pending.length === 0 && this.state.size === 0) return;
8138
+ const headers = this.state.testDevice ? { [TEST_DEVICE_HEADER]: "1" } : void 0;
8139
+ const remaining = [...pending];
8140
+ while (this.state.size > 0) remaining.push(...this.state.take(MAX_PERF_BATCH));
8141
+ for (let i = 0; i < remaining.length; i += MAX_PERF_BATCH) {
8142
+ const slice = remaining.slice(i, i + MAX_PERF_BATCH);
8143
+ try {
8144
+ await palbeRequest(
8145
+ this.rt,
8146
+ "POST",
8147
+ "/v1/analytics/perf",
8148
+ { body: encodePerfBatch(slice), headers }
8149
+ );
8150
+ } catch {
8151
+ this.queue.enqueue(slice);
8152
+ }
8153
+ }
8154
+ }
8155
+ startTimer() {
8156
+ if (this.flushTimer !== null) return;
8157
+ this.flushTimer = setTimeout(() => {
8158
+ this.flushTimer = null;
8159
+ void this.flush();
8160
+ }, FLUSH_INTERVAL_MS2);
8161
+ }
8162
+ cancelTimer() {
8163
+ if (this.flushTimer !== null) {
8164
+ clearTimeout(this.flushTimer);
8165
+ this.flushTimer = null;
8166
+ }
8167
+ }
8168
+ };
8169
+
8170
+ // src/perf/web-vitals.ts
8171
+ function webVitalItem(name, value) {
8172
+ return {
8173
+ row_id: crypto.randomUUID(),
8174
+ trace_type: "web_vital",
8175
+ name,
8176
+ value: Math.max(0, value),
8177
+ timestamp: Date.now()
8178
+ };
8179
+ }
8180
+ function isLayoutShift(e) {
8181
+ return e.entryType === "layout-shift" && "value" in e && "hadRecentInput" in e;
8182
+ }
8183
+ function isEventTiming(e) {
8184
+ return e.entryType === "event" || e.entryType === "first-input";
8185
+ }
8186
+ function safeObserve(type, cb) {
8187
+ if (typeof PerformanceObserver === "undefined") return null;
8188
+ try {
8189
+ const obs = new PerformanceObserver((list) => cb(list.getEntries()));
8190
+ obs.observe({ type, buffered: true });
8191
+ return obs;
8192
+ } catch {
8193
+ return null;
8194
+ }
8195
+ }
8196
+ function observeWebVitals(record) {
8197
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function" || typeof document.removeEventListener !== "function" || typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
8198
+ return () => {
8199
+ };
8200
+ }
8201
+ let cls = 0;
8202
+ let lcp = 0;
8203
+ let inp = 0;
8204
+ const observers = [
8205
+ // LCP: keep the largest/last reported render.
8206
+ safeObserve("largest-contentful-paint", (entries) => {
8207
+ for (const e of entries) lcp = Math.max(lcp, e.startTime);
8208
+ }),
8209
+ // CLS: sum shift values that weren't caused by recent input.
8210
+ safeObserve("layout-shift", (entries) => {
8211
+ for (const e of entries) if (isLayoutShift(e) && !e.hadRecentInput) cls += e.value;
8212
+ }),
8213
+ // INP: approximate as the worst interaction duration observed.
8214
+ safeObserve("event", (entries) => {
8215
+ for (const e of entries) if (isEventTiming(e)) inp = Math.max(inp, e.duration);
8216
+ }),
8217
+ // FCP: one-shot.
8218
+ safeObserve("paint", (entries) => {
8219
+ for (const e of entries)
8220
+ if (e.name === "first-contentful-paint") record(webVitalItem("FCP", e.startTime));
8221
+ })
8222
+ ];
8223
+ const nav = performance.getEntriesByType("navigation")[0];
8224
+ if (nav && nav.responseStart > 0) record(webVitalItem("TTFB", nav.responseStart));
8225
+ let flushed = false;
8226
+ const flush = () => {
8227
+ if (flushed) return;
8228
+ flushed = true;
8229
+ if (lcp > 0) record(webVitalItem("LCP", lcp));
8230
+ record(webVitalItem("CLS", cls));
8231
+ if (inp > 0) record(webVitalItem("INP", inp));
8232
+ };
8233
+ const onHide = () => {
8234
+ if (document.visibilityState === "hidden") flush();
8235
+ };
8236
+ document.addEventListener("visibilitychange", onHide);
8237
+ return () => {
8238
+ flush();
8239
+ document.removeEventListener("visibilitychange", onHide);
8240
+ for (const o of observers) o?.disconnect();
8241
+ };
8242
+ }
8243
+
7687
8244
  // src/realtime/anon-token.ts
7688
8245
  var REFRESH_SKEW_MS = 6e4;
7689
8246
  var AnonTokenProvider = class {
@@ -8371,10 +8928,13 @@ function defaultSessionStorage(key) {
8371
8928
  }
8372
8929
 
8373
8930
  // src/version.ts
8374
- var VERSION = "1.7.0";
8931
+ var VERSION = "1.9.0";
8375
8932
 
8376
8933
  // src/runtime.ts
8377
8934
  function buildRuntime(config) {
8935
+ const appIdentifier = config.identifier ?? "";
8936
+ const runtimeOrigin = typeof window !== "undefined" && typeof window.location !== "undefined" ? window.location.origin : "";
8937
+ assertOriginMatches(loadAppConfig({ identifier: appIdentifier }), runtimeOrigin);
8378
8938
  const http = new HttpClient(config.apiKey, {
8379
8939
  url: config.url,
8380
8940
  headers: { "X-Client-Info": `palbe-web/${VERSION}`, ...config.headers }
@@ -8424,8 +8984,10 @@ function buildRuntime(config) {
8424
8984
  let analytics;
8425
8985
  let calls;
8426
8986
  let messaging;
8987
+ let perf;
8427
8988
  const rt = {
8428
8989
  config,
8990
+ appIdentifier,
8429
8991
  http,
8430
8992
  tokenManager,
8431
8993
  authClient,
@@ -8461,11 +9023,19 @@ function buildRuntime(config) {
8461
9023
  destroyRealtime() {
8462
9024
  realtime?.destroy();
8463
9025
  realtime = void 0;
9026
+ perf?.dispose();
8464
9027
  },
8465
9028
  // The buffering facade is lazy; its identity state is NOT (below).
8466
9029
  get analytics() {
8467
9030
  if (!analytics) analytics = new PalbeAnalytics(rt, analyticsState);
8468
9031
  return analytics;
9032
+ },
9033
+ // PalPerf is constructed up front (touched below): `request.ts` records a
9034
+ // network trace on EVERY fetch, so it can't be lazy. The memo here only
9035
+ // guards against re-construction.
9036
+ get perf() {
9037
+ if (!perf) perf = new PalbePerf(rt);
9038
+ return perf;
8469
9039
  }
8470
9040
  };
8471
9041
  const analyticsState = new AnalyticsState(rt);
@@ -8473,8 +9043,43 @@ function buildRuntime(config) {
8473
9043
  const hasOwn = Object.keys(request.headers).some((k) => k.toLowerCase() === "x-distinct-id");
8474
9044
  if (!hasOwn) request.headers["X-Distinct-Id"] = analyticsState.distinctId();
8475
9045
  });
9046
+ void rt.perf;
9047
+ if (typeof document !== "undefined") {
9048
+ void new PerfConfigClient().fetchConfig(rt, rt.perf);
9049
+ rt.auth.hydrateUser();
9050
+ }
9051
+ recordColdStart(rt);
9052
+ observeWebVitals((item) => rt.perf.record(item));
8476
9053
  return rt;
8477
9054
  }
9055
+ function recordColdStart(rt) {
9056
+ if (typeof document === "undefined" || typeof performance === "undefined") return;
9057
+ try {
9058
+ const navEntry = performance.getEntriesByType("navigation")[0];
9059
+ const startTime = navEntry?.startTime ?? 0;
9060
+ const recordFromFcp = (fcp) => {
9061
+ const item = measureAppStart({ startTime, fcp });
9062
+ if (item) rt.perf.record(item);
9063
+ };
9064
+ const existing = performance.getEntriesByName("first-contentful-paint").find((e) => e.startTime > 0);
9065
+ if (existing) {
9066
+ recordFromFcp(existing.startTime);
9067
+ return;
9068
+ }
9069
+ if (typeof PerformanceObserver === "undefined") return;
9070
+ const observer = new PerformanceObserver((list) => {
9071
+ for (const entry of list.getEntries()) {
9072
+ if (entry.name === "first-contentful-paint") {
9073
+ observer.disconnect();
9074
+ recordFromFcp(entry.startTime);
9075
+ return;
9076
+ }
9077
+ }
9078
+ });
9079
+ observer.observe({ type: "paint", buffered: true });
9080
+ } catch {
9081
+ }
9082
+ }
8478
9083
 
8479
9084
  // src/call.ts
8480
9085
  async function callEndpoint(resolveRt, name, input, options) {
@@ -8653,6 +9258,12 @@ function createClientProxy(resolveRt, nsAccessor) {
8653
9258
  },
8654
9259
  get messaging() {
8655
9260
  return resolveRt().messaging;
9261
+ },
9262
+ get perf() {
9263
+ return resolveRt().perf;
9264
+ },
9265
+ setTestDevice(on) {
9266
+ resolveRt().perf.setTestDevice(on);
8656
9267
  }
8657
9268
  };
8658
9269
  return new Proxy(base, {
@@ -8731,4 +9342,4 @@ export {
8731
9342
  pb,
8732
9343
  createBoundClient
8733
9344
  };
8734
- //# sourceMappingURL=chunk-QRO632M7.js.map
9345
+ //# sourceMappingURL=chunk-I3ZMB7XM.js.map