@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.
@@ -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 {
@@ -1460,6 +1527,8 @@ var PalbeAuth = class {
1460
1527
  // suppresses AuthClient's TOKEN_REFRESHED during re-signIn
1461
1528
  signedInState = false;
1462
1529
  // dedupes AuthClient's repeated SIGNED_OUT events
1530
+ hydratingUser = false;
1531
+ // guards hydrateUser() to a single boot fetch
1463
1532
  stateListeners = /* @__PURE__ */ new Set();
1464
1533
  eventListeners = /* @__PURE__ */ new Set();
1465
1534
  userListeners = /* @__PURE__ */ new Set();
@@ -1512,13 +1581,37 @@ var PalbeAuth = class {
1512
1581
  if (changed) for (const cb of this.userListeners) this.safeInvoke(() => cb(user));
1513
1582
  return user;
1514
1583
  }
1584
+ /**
1585
+ * Restore the user after a session was rehydrated from storage (page reload).
1586
+ * Hydration in buildRuntime restores the TOKENS synchronously, but the access
1587
+ * token JWT does not carry emailVerified/createdAt, so the full AuthUser can't
1588
+ * be reconstructed offline — this fetches GET /auth/user once and announces
1589
+ * `signedIn`, so the app no longer has to `await pb.auth.refreshUser()` itself
1590
+ * on boot. Idempotent (runs once), browser-safe (no-op when not signed in or
1591
+ * a user is already cached), and never throws (a boot-time network failure
1592
+ * must not break app startup — isSignedIn stays true, the app can retry).
1593
+ */
1594
+ hydrateUser() {
1595
+ if (this.hydratingUser || this.cachedUser || !this.isSignedIn) return;
1596
+ this.hydratingUser = true;
1597
+ void this.refreshUser().then((user) => {
1598
+ if (this.isSignedIn) {
1599
+ this.signedInState = true;
1600
+ this.emitState({ status: "signedIn", user });
1601
+ }
1602
+ }).catch(() => {
1603
+ }).finally(() => {
1604
+ this.hydratingUser = false;
1605
+ });
1606
+ }
1515
1607
  // ── listeners ──────────────────────────────────────────
1516
1608
  /**
1517
1609
  * Subscribe to signed-in/signed-out state. Fires immediately with the
1518
- * current snapshot (iOS parity). NOTE: a restored session (page reload) has
1519
- * no cached user yet, so the immediate snapshot reports signedOut even when
1520
- * `isSignedIn` is true call `refreshUser()` on boot to populate the user
1521
- * and rely on `isSignedIn` for the session truth.
1610
+ * current snapshot (iOS parity). A restored session (page reload) hydrates
1611
+ * the user asynchronously via `hydrateUser()` (fired once at boot), so the
1612
+ * FIRST snapshot may report signedOut for a tick even when `isSignedIn` is
1613
+ * true; the listener then fires again with `signedIn` once the user lands.
1614
+ * Rely on `isSignedIn` for the session truth if you need it synchronously.
1522
1615
  */
1523
1616
  onAuthStateChange(callback) {
1524
1617
  this.stateListeners.add(callback);
@@ -7722,6 +7815,469 @@ var PalbeMessaging = class {
7722
7815
  }
7723
7816
  };
7724
7817
 
7818
+ // src/perf/app-start.ts
7819
+ function measureAppStart(nav) {
7820
+ if (!(nav.fcp > 0)) return null;
7821
+ const value = nav.fcp - nav.startTime;
7822
+ if (value <= 0) return null;
7823
+ return {
7824
+ row_id: crypto.randomUUID(),
7825
+ trace_type: "app_start",
7826
+ name: "cold_start",
7827
+ value,
7828
+ timestamp: Date.now()
7829
+ };
7830
+ }
7831
+
7832
+ // src/perf/perf-config-client.ts
7833
+ var PERF_CONFIG_PATH = "/v1/analytics/perf/config";
7834
+ var FNV_OFFSET_BASIS_32 = 2166136261;
7835
+ var FNV_PRIME_32 = 16777619;
7836
+ var utf82 = typeof TextEncoder !== "undefined" ? new TextEncoder() : { encode: (s) => Uint8Array.from(Buffer.from(s, "utf-8")) };
7837
+ function perfSampleBucket(rowId) {
7838
+ let hash = FNV_OFFSET_BASIS_32;
7839
+ for (const byte of utf82.encode(rowId)) {
7840
+ hash ^= byte;
7841
+ hash = Math.imul(hash, FNV_PRIME_32);
7842
+ }
7843
+ return (hash >>> 0) % 100;
7844
+ }
7845
+ function isPerfConfig(value) {
7846
+ if (typeof value !== "object" || value === null) return false;
7847
+ const o = value;
7848
+ return typeof o.sample_pct === "number";
7849
+ }
7850
+ function isRawResponse(value) {
7851
+ return typeof value === "object" && value !== null && "data" in value;
7852
+ }
7853
+ function headerValue(headers, name) {
7854
+ if (!headers) return void 0;
7855
+ const lower = name.toLowerCase();
7856
+ for (const [k, v] of Object.entries(headers)) {
7857
+ if (k.toLowerCase() === lower) return v;
7858
+ }
7859
+ return void 0;
7860
+ }
7861
+ var PerfConfigClient = class {
7862
+ etag;
7863
+ async fetchConfig(rt, perf) {
7864
+ const headers = {};
7865
+ if (this.etag) headers["If-None-Match"] = this.etag;
7866
+ try {
7867
+ const res = await rt.http.request("GET", PERF_CONFIG_PATH, { headers });
7868
+ if (!isRawResponse(res)) return;
7869
+ if (res.status === 304 || res.error && res.error.status === 304) return;
7870
+ if (res.error) return;
7871
+ const newEtag = headerValue(res.headers, "etag");
7872
+ if (newEtag) this.etag = newEtag;
7873
+ if (isPerfConfig(res.data)) {
7874
+ perf.setSamplePct(res.data.sample_pct);
7875
+ }
7876
+ } catch {
7877
+ }
7878
+ }
7879
+ };
7880
+
7881
+ // src/perf/fetch-swizzle.ts
7882
+ var PERF_EXCLUDED_PREFIX2 = "/v1/analytics/";
7883
+ function nowMs2() {
7884
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7885
+ }
7886
+ function describeRequest(input, init) {
7887
+ let url;
7888
+ let method = "GET";
7889
+ if (typeof input === "string") {
7890
+ url = input;
7891
+ } else if (input instanceof URL) {
7892
+ url = input.href;
7893
+ } else {
7894
+ url = input.url;
7895
+ method = input.method;
7896
+ }
7897
+ if (init?.method) method = init.method;
7898
+ return { url, method: method.toUpperCase() };
7899
+ }
7900
+ function pathOf(url) {
7901
+ try {
7902
+ return new URL(url, "http://_local").pathname;
7903
+ } catch {
7904
+ return url;
7905
+ }
7906
+ }
7907
+ function installFetchSwizzle(perf) {
7908
+ if (typeof fetch !== "function") return () => {
7909
+ };
7910
+ const original = fetch;
7911
+ const wrapped = async (input, init) => {
7912
+ const { url, method } = describeRequest(input, init);
7913
+ const traced = !pathOf(url).startsWith(PERF_EXCLUDED_PREFIX2);
7914
+ const startedAt = traced ? nowMs2() : 0;
7915
+ try {
7916
+ const res = await original(input, init);
7917
+ if (traced) perf.recordNetwork(method, redactUrl(url), res.status, nowMs2() - startedAt);
7918
+ return res;
7919
+ } catch (err) {
7920
+ if (traced) perf.recordNetwork(method, redactUrl(url), 0, nowMs2() - startedAt);
7921
+ throw err;
7922
+ }
7923
+ };
7924
+ globalThis.fetch = wrapped;
7925
+ return () => {
7926
+ globalThis.fetch = original;
7927
+ };
7928
+ }
7929
+
7930
+ // src/perf/offline-queue.ts
7931
+ var PERF_QUEUE_KEY = "palbe.perf.queue";
7932
+ var DEFAULT_MAX_ITEMS = 500;
7933
+ function canPersist() {
7934
+ return typeof document !== "undefined" && typeof localStorage !== "undefined";
7935
+ }
7936
+ function isPerfItem(value) {
7937
+ if (typeof value !== "object" || value === null) return false;
7938
+ const o = value;
7939
+ return typeof o.row_id === "string" && typeof o.trace_type === "string" && typeof o.name === "string" && typeof o.value === "number" && typeof o.timestamp === "number";
7940
+ }
7941
+ function readPersisted() {
7942
+ if (!canPersist()) return [];
7943
+ try {
7944
+ const raw = localStorage.getItem(PERF_QUEUE_KEY);
7945
+ if (!raw) return [];
7946
+ const parsed = JSON.parse(raw);
7947
+ if (!Array.isArray(parsed)) return [];
7948
+ return parsed.filter(isPerfItem);
7949
+ } catch {
7950
+ return [];
7951
+ }
7952
+ }
7953
+ var PerfOfflineQueue = class {
7954
+ items;
7955
+ _dropped = 0;
7956
+ maxItems;
7957
+ constructor(maxItems = DEFAULT_MAX_ITEMS) {
7958
+ this.maxItems = Math.max(1, maxItems);
7959
+ this.items = readPersisted();
7960
+ this.trim();
7961
+ }
7962
+ /** Append items; FIFO-evict the oldest when over `maxItems`. */
7963
+ enqueue(items) {
7964
+ if (items.length === 0) return;
7965
+ this.items.push(...items);
7966
+ this.trim();
7967
+ this.persist();
7968
+ }
7969
+ /** Return all queued items (oldest-first) and clear the queue + store. */
7970
+ drainAll() {
7971
+ if (this.items.length === 0) return [];
7972
+ const out = this.items;
7973
+ this.items = [];
7974
+ this.clearStore();
7975
+ return out;
7976
+ }
7977
+ /** Current queue depth. */
7978
+ get count() {
7979
+ return this.items.length;
7980
+ }
7981
+ /** Cumulative count of FIFO-evicted items (a `dropped` metric, not silent). */
7982
+ get dropped() {
7983
+ return this._dropped;
7984
+ }
7985
+ /** Drop the oldest items until at most `maxItems` remain, counting each. */
7986
+ trim() {
7987
+ const overflow = this.items.length - this.maxItems;
7988
+ if (overflow > 0) {
7989
+ this.items.splice(0, overflow);
7990
+ this._dropped += overflow;
7991
+ }
7992
+ }
7993
+ persist() {
7994
+ if (!canPersist()) return;
7995
+ try {
7996
+ localStorage.setItem(PERF_QUEUE_KEY, JSON.stringify(this.items));
7997
+ } catch {
7998
+ }
7999
+ }
8000
+ clearStore() {
8001
+ if (!canPersist()) return;
8002
+ try {
8003
+ localStorage.removeItem(PERF_QUEUE_KEY);
8004
+ } catch {
8005
+ }
8006
+ }
8007
+ };
8008
+
8009
+ // src/perf/perf-state.ts
8010
+ var MAX_PERF_BATCH = 100;
8011
+ var PerfState = class {
8012
+ /** Pending, un-flushed perf items (FIFO). */
8013
+ buffer = [];
8014
+ /** When true, every flush carries `X-Palbase-Test-Device: 1`. */
8015
+ testDevice = false;
8016
+ enqueue(item) {
8017
+ this.buffer.push(item);
8018
+ }
8019
+ /** Detach up to `MAX_PERF_BATCH` items for one POST (FIFO order preserved). */
8020
+ take(limit = MAX_PERF_BATCH) {
8021
+ return this.buffer.splice(0, limit);
8022
+ }
8023
+ get size() {
8024
+ return this.buffer.length;
8025
+ }
8026
+ };
8027
+
8028
+ // src/perf/perf-wire.ts
8029
+ function encodePerfBatch(items) {
8030
+ return { items };
8031
+ }
8032
+
8033
+ // src/perf/perf-facade.ts
8034
+ var FLUSH_AT2 = 20;
8035
+ var FLUSH_INTERVAL_MS2 = 1e4;
8036
+ var TEST_DEVICE_HEADER = "X-Palbase-Test-Device";
8037
+ function nowMs3() {
8038
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
8039
+ }
8040
+ var PerfTrace = class {
8041
+ constructor(name, onStop) {
8042
+ this.name = name;
8043
+ this.onStop = onStop;
8044
+ }
8045
+ name;
8046
+ onStop;
8047
+ attrs = {};
8048
+ counters = {};
8049
+ startedAt = nowMs3();
8050
+ stopped = false;
8051
+ putAttribute(key, value) {
8052
+ this.attrs[key] = value;
8053
+ }
8054
+ incrementMetric(name, by = 1) {
8055
+ this.counters[name] = (this.counters[name] ?? 0) + by;
8056
+ }
8057
+ stop() {
8058
+ if (this.stopped) return;
8059
+ this.stopped = true;
8060
+ const item = {
8061
+ row_id: crypto.randomUUID(),
8062
+ trace_type: "custom",
8063
+ name: this.name,
8064
+ value: Math.max(0, nowMs3() - this.startedAt),
8065
+ timestamp: Date.now()
8066
+ };
8067
+ if (Object.keys(this.attrs).length > 0) item.attrs = this.attrs;
8068
+ if (Object.keys(this.counters).length > 0) item.counters = this.counters;
8069
+ this.onStop(item);
8070
+ }
8071
+ };
8072
+ var PalbePerf = class {
8073
+ constructor(rt, state = new PerfState(), queue = new PerfOfflineQueue()) {
8074
+ this.rt = rt;
8075
+ this.state = state;
8076
+ this.queue = queue;
8077
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
8078
+ window.addEventListener("online", this.onOnline);
8079
+ }
8080
+ }
8081
+ rt;
8082
+ state;
8083
+ queue;
8084
+ flushTimer = null;
8085
+ /** Browser → buffer + size/timer flush. Server (no document) → immediate
8086
+ * per-item flush, zero timers (nothing leaks into RSC/route handlers). */
8087
+ browser = typeof document !== "undefined";
8088
+ /** Bound `online` handler so it can be removed on `dispose()` (no leak). */
8089
+ onOnline = () => {
8090
+ void this.flush();
8091
+ };
8092
+ /** Server-controlled client-side sample rate (0..100). 100 until the config
8093
+ * client fetches `/v1/analytics/perf/config` — the SDK obeys this ceiling and
8094
+ * never raises its own rate (invariant 3). `record` drops an item whose
8095
+ * deterministic `row_id` bucket is >= this pct, mirroring the server's
8096
+ * `SampleDecision` so client + server keep the SAME rows. */
8097
+ samplePct = 100;
8098
+ /** Remove the `online` listener. Called when the runtime is replaced so the
8099
+ * handler does not outlive this facade. No-op outside the browser. */
8100
+ dispose() {
8101
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
8102
+ window.removeEventListener("online", this.onOnline);
8103
+ }
8104
+ }
8105
+ /** Mark (or unmark) this client's traffic as test — the server tags the rows
8106
+ * when the `X-Palbase-Test-Device: 1` header rides along on flush. */
8107
+ setTestDevice(on) {
8108
+ this.state.testDevice = on;
8109
+ }
8110
+ /** Apply the server-resolved client-side sample rate (0..100), clamped. Called
8111
+ * by `PerfConfigClient` after fetching `/v1/analytics/perf/config`. The SDK
8112
+ * OBEYS this value — it is a ceiling, never raised locally. */
8113
+ setSamplePct(pct) {
8114
+ this.samplePct = Math.max(0, Math.min(100, Math.trunc(pct)));
8115
+ }
8116
+ /** Pending (un-flushed) buffer depth. Test-only visibility into the buffer so
8117
+ * the remote-sampling tests can assert how many items were sampled in. */
8118
+ get bufferSizeForTest() {
8119
+ return this.state.size;
8120
+ }
8121
+ /** Opt-in: wrap the global `fetch` so every app-level request is recorded as a
8122
+ * redacted `network` perf item (the `/v1/analytics/*` ingest paths are
8123
+ * self-excluded). OFF by default — swizzling a global is a page-wide side
8124
+ * effect. Returns an `uninstall` that restores the original `fetch`. */
8125
+ enableFetchCapture() {
8126
+ return installFetchSwizzle(this);
8127
+ }
8128
+ /** Start a custom trace; the returned handle records a `custom` item on
8129
+ * `.stop()`. */
8130
+ startTrace(name) {
8131
+ return new PerfTrace(name, (item) => this.record(item));
8132
+ }
8133
+ /** Buffer one perf item. In the browser, flush on size/timer; on the server
8134
+ * flush immediately (no timers). Never throws.
8135
+ *
8136
+ * Client-side remote sampling: an item whose deterministic `row_id` bucket is
8137
+ * NOT below the server-controlled `samplePct` is dropped before buffering —
8138
+ * the SAME FNV-1a-mod-100 decision the server applies at ingest, so the two
8139
+ * keep identical rows and the SDK saves the upload (defense-in-depth: the
8140
+ * server re-samples authoritatively). */
8141
+ record(item) {
8142
+ if (perfSampleBucket(item.row_id) >= this.samplePct) return;
8143
+ this.state.enqueue(item);
8144
+ if (!this.browser) {
8145
+ void this.flush();
8146
+ return;
8147
+ }
8148
+ if (this.state.size >= FLUSH_AT2) void this.flush();
8149
+ else this.startTimer();
8150
+ }
8151
+ /** Buffer a network trace — called by `request.ts` around `rt.http.request`
8152
+ * (the analytics ingest path is excluded by the caller to avoid recursion). */
8153
+ recordNetwork(method, url, status, durationMs, requestId) {
8154
+ const item = {
8155
+ row_id: crypto.randomUUID(),
8156
+ trace_type: "network",
8157
+ name: `${method} ${url}`,
8158
+ value: durationMs,
8159
+ attrs: { status: String(status) },
8160
+ timestamp: Date.now()
8161
+ };
8162
+ if (requestId) item.request_id = requestId;
8163
+ this.record(item);
8164
+ }
8165
+ /** Drain the offline queue (oldest-first) and the live buffer to
8166
+ * `/v1/analytics/perf` (≤100 items per request, sequential). Resolves when
8167
+ * delivery finished; NEVER rejects. A failed slice is NOT dropped — it goes
8168
+ * to the offline queue (persist-on-fail) so the next flush (size/timer or the
8169
+ * `online` reconnect event) retries it. Items keep their original `row_id`,
8170
+ * so a redelivery dedups server-side (ReplacingMergeTree). */
8171
+ async flush() {
8172
+ this.cancelTimer();
8173
+ const pending = this.queue.drainAll();
8174
+ if (pending.length === 0 && this.state.size === 0) return;
8175
+ const headers = this.state.testDevice ? { [TEST_DEVICE_HEADER]: "1" } : void 0;
8176
+ const remaining = [...pending];
8177
+ while (this.state.size > 0) remaining.push(...this.state.take(MAX_PERF_BATCH));
8178
+ for (let i = 0; i < remaining.length; i += MAX_PERF_BATCH) {
8179
+ const slice = remaining.slice(i, i + MAX_PERF_BATCH);
8180
+ try {
8181
+ await palbeRequest(
8182
+ this.rt,
8183
+ "POST",
8184
+ "/v1/analytics/perf",
8185
+ { body: encodePerfBatch(slice), headers }
8186
+ );
8187
+ } catch {
8188
+ this.queue.enqueue(slice);
8189
+ }
8190
+ }
8191
+ }
8192
+ startTimer() {
8193
+ if (this.flushTimer !== null) return;
8194
+ this.flushTimer = setTimeout(() => {
8195
+ this.flushTimer = null;
8196
+ void this.flush();
8197
+ }, FLUSH_INTERVAL_MS2);
8198
+ }
8199
+ cancelTimer() {
8200
+ if (this.flushTimer !== null) {
8201
+ clearTimeout(this.flushTimer);
8202
+ this.flushTimer = null;
8203
+ }
8204
+ }
8205
+ };
8206
+
8207
+ // src/perf/web-vitals.ts
8208
+ function webVitalItem(name, value) {
8209
+ return {
8210
+ row_id: crypto.randomUUID(),
8211
+ trace_type: "web_vital",
8212
+ name,
8213
+ value: Math.max(0, value),
8214
+ timestamp: Date.now()
8215
+ };
8216
+ }
8217
+ function isLayoutShift(e) {
8218
+ return e.entryType === "layout-shift" && "value" in e && "hadRecentInput" in e;
8219
+ }
8220
+ function isEventTiming(e) {
8221
+ return e.entryType === "event" || e.entryType === "first-input";
8222
+ }
8223
+ function safeObserve(type, cb) {
8224
+ if (typeof PerformanceObserver === "undefined") return null;
8225
+ try {
8226
+ const obs = new PerformanceObserver((list) => cb(list.getEntries()));
8227
+ obs.observe({ type, buffered: true });
8228
+ return obs;
8229
+ } catch {
8230
+ return null;
8231
+ }
8232
+ }
8233
+ function observeWebVitals(record) {
8234
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function" || typeof document.removeEventListener !== "function" || typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
8235
+ return () => {
8236
+ };
8237
+ }
8238
+ let cls = 0;
8239
+ let lcp = 0;
8240
+ let inp = 0;
8241
+ const observers = [
8242
+ // LCP: keep the largest/last reported render.
8243
+ safeObserve("largest-contentful-paint", (entries) => {
8244
+ for (const e of entries) lcp = Math.max(lcp, e.startTime);
8245
+ }),
8246
+ // CLS: sum shift values that weren't caused by recent input.
8247
+ safeObserve("layout-shift", (entries) => {
8248
+ for (const e of entries) if (isLayoutShift(e) && !e.hadRecentInput) cls += e.value;
8249
+ }),
8250
+ // INP: approximate as the worst interaction duration observed.
8251
+ safeObserve("event", (entries) => {
8252
+ for (const e of entries) if (isEventTiming(e)) inp = Math.max(inp, e.duration);
8253
+ }),
8254
+ // FCP: one-shot.
8255
+ safeObserve("paint", (entries) => {
8256
+ for (const e of entries)
8257
+ if (e.name === "first-contentful-paint") record(webVitalItem("FCP", e.startTime));
8258
+ })
8259
+ ];
8260
+ const nav = performance.getEntriesByType("navigation")[0];
8261
+ if (nav && nav.responseStart > 0) record(webVitalItem("TTFB", nav.responseStart));
8262
+ let flushed = false;
8263
+ const flush = () => {
8264
+ if (flushed) return;
8265
+ flushed = true;
8266
+ if (lcp > 0) record(webVitalItem("LCP", lcp));
8267
+ record(webVitalItem("CLS", cls));
8268
+ if (inp > 0) record(webVitalItem("INP", inp));
8269
+ };
8270
+ const onHide = () => {
8271
+ if (document.visibilityState === "hidden") flush();
8272
+ };
8273
+ document.addEventListener("visibilitychange", onHide);
8274
+ return () => {
8275
+ flush();
8276
+ document.removeEventListener("visibilitychange", onHide);
8277
+ for (const o of observers) o?.disconnect();
8278
+ };
8279
+ }
8280
+
7725
8281
  // src/realtime/anon-token.ts
7726
8282
  var REFRESH_SKEW_MS = 6e4;
7727
8283
  var AnonTokenProvider = class {
@@ -8409,10 +8965,13 @@ function defaultSessionStorage(key) {
8409
8965
  }
8410
8966
 
8411
8967
  // src/version.ts
8412
- var VERSION = "1.7.0";
8968
+ var VERSION = "1.9.0";
8413
8969
 
8414
8970
  // src/runtime.ts
8415
8971
  function buildRuntime(config) {
8972
+ const appIdentifier = config.identifier ?? "";
8973
+ const runtimeOrigin = typeof window !== "undefined" && typeof window.location !== "undefined" ? window.location.origin : "";
8974
+ assertOriginMatches(loadAppConfig({ identifier: appIdentifier }), runtimeOrigin);
8416
8975
  const http = new HttpClient(config.apiKey, {
8417
8976
  url: config.url,
8418
8977
  headers: { "X-Client-Info": `palbe-web/${VERSION}`, ...config.headers }
@@ -8462,8 +9021,10 @@ function buildRuntime(config) {
8462
9021
  let analytics;
8463
9022
  let calls;
8464
9023
  let messaging;
9024
+ let perf;
8465
9025
  const rt = {
8466
9026
  config,
9027
+ appIdentifier,
8467
9028
  http,
8468
9029
  tokenManager,
8469
9030
  authClient,
@@ -8499,11 +9060,19 @@ function buildRuntime(config) {
8499
9060
  destroyRealtime() {
8500
9061
  realtime?.destroy();
8501
9062
  realtime = void 0;
9063
+ perf?.dispose();
8502
9064
  },
8503
9065
  // The buffering facade is lazy; its identity state is NOT (below).
8504
9066
  get analytics() {
8505
9067
  if (!analytics) analytics = new PalbeAnalytics(rt, analyticsState);
8506
9068
  return analytics;
9069
+ },
9070
+ // PalPerf is constructed up front (touched below): `request.ts` records a
9071
+ // network trace on EVERY fetch, so it can't be lazy. The memo here only
9072
+ // guards against re-construction.
9073
+ get perf() {
9074
+ if (!perf) perf = new PalbePerf(rt);
9075
+ return perf;
8507
9076
  }
8508
9077
  };
8509
9078
  const analyticsState = new AnalyticsState(rt);
@@ -8511,8 +9080,43 @@ function buildRuntime(config) {
8511
9080
  const hasOwn = Object.keys(request.headers).some((k) => k.toLowerCase() === "x-distinct-id");
8512
9081
  if (!hasOwn) request.headers["X-Distinct-Id"] = analyticsState.distinctId();
8513
9082
  });
9083
+ void rt.perf;
9084
+ if (typeof document !== "undefined") {
9085
+ void new PerfConfigClient().fetchConfig(rt, rt.perf);
9086
+ rt.auth.hydrateUser();
9087
+ }
9088
+ recordColdStart(rt);
9089
+ observeWebVitals((item) => rt.perf.record(item));
8514
9090
  return rt;
8515
9091
  }
9092
+ function recordColdStart(rt) {
9093
+ if (typeof document === "undefined" || typeof performance === "undefined") return;
9094
+ try {
9095
+ const navEntry = performance.getEntriesByType("navigation")[0];
9096
+ const startTime = navEntry?.startTime ?? 0;
9097
+ const recordFromFcp = (fcp) => {
9098
+ const item = measureAppStart({ startTime, fcp });
9099
+ if (item) rt.perf.record(item);
9100
+ };
9101
+ const existing = performance.getEntriesByName("first-contentful-paint").find((e) => e.startTime > 0);
9102
+ if (existing) {
9103
+ recordFromFcp(existing.startTime);
9104
+ return;
9105
+ }
9106
+ if (typeof PerformanceObserver === "undefined") return;
9107
+ const observer = new PerformanceObserver((list) => {
9108
+ for (const entry of list.getEntries()) {
9109
+ if (entry.name === "first-contentful-paint") {
9110
+ observer.disconnect();
9111
+ recordFromFcp(entry.startTime);
9112
+ return;
9113
+ }
9114
+ }
9115
+ });
9116
+ observer.observe({ type: "paint", buffered: true });
9117
+ } catch {
9118
+ }
9119
+ }
8516
9120
 
8517
9121
  // src/call.ts
8518
9122
  async function callEndpoint(resolveRt, name, input, options) {
@@ -8691,6 +9295,12 @@ function createClientProxy(resolveRt, nsAccessor) {
8691
9295
  },
8692
9296
  get messaging() {
8693
9297
  return resolveRt().messaging;
9298
+ },
9299
+ get perf() {
9300
+ return resolveRt().perf;
9301
+ },
9302
+ setTestDevice(on) {
9303
+ resolveRt().perf.setTestDevice(on);
8694
9304
  }
8695
9305
  };
8696
9306
  return new Proxy(base, {