@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.
@@ -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;
@@ -1245,6 +1312,8 @@ var PalbeAuth = class {
1245
1312
  // suppresses AuthClient's TOKEN_REFRESHED during re-signIn
1246
1313
  signedInState = false;
1247
1314
  // dedupes AuthClient's repeated SIGNED_OUT events
1315
+ hydratingUser = false;
1316
+ // guards hydrateUser() to a single boot fetch
1248
1317
  stateListeners = /* @__PURE__ */ new Set();
1249
1318
  eventListeners = /* @__PURE__ */ new Set();
1250
1319
  userListeners = /* @__PURE__ */ new Set();
@@ -1297,13 +1366,37 @@ var PalbeAuth = class {
1297
1366
  if (changed) for (const cb of this.userListeners) this.safeInvoke(() => cb(user));
1298
1367
  return user;
1299
1368
  }
1369
+ /**
1370
+ * Restore the user after a session was rehydrated from storage (page reload).
1371
+ * Hydration in buildRuntime restores the TOKENS synchronously, but the access
1372
+ * token JWT does not carry emailVerified/createdAt, so the full AuthUser can't
1373
+ * be reconstructed offline — this fetches GET /auth/user once and announces
1374
+ * `signedIn`, so the app no longer has to `await pb.auth.refreshUser()` itself
1375
+ * on boot. Idempotent (runs once), browser-safe (no-op when not signed in or
1376
+ * a user is already cached), and never throws (a boot-time network failure
1377
+ * must not break app startup — isSignedIn stays true, the app can retry).
1378
+ */
1379
+ hydrateUser() {
1380
+ if (this.hydratingUser || this.cachedUser || !this.isSignedIn) return;
1381
+ this.hydratingUser = true;
1382
+ void this.refreshUser().then((user) => {
1383
+ if (this.isSignedIn) {
1384
+ this.signedInState = true;
1385
+ this.emitState({ status: "signedIn", user });
1386
+ }
1387
+ }).catch(() => {
1388
+ }).finally(() => {
1389
+ this.hydratingUser = false;
1390
+ });
1391
+ }
1300
1392
  // ── listeners ──────────────────────────────────────────
1301
1393
  /**
1302
1394
  * Subscribe to signed-in/signed-out state. Fires immediately with the
1303
- * current snapshot (iOS parity). NOTE: a restored session (page reload) has
1304
- * no cached user yet, so the immediate snapshot reports signedOut even when
1305
- * `isSignedIn` is true call `refreshUser()` on boot to populate the user
1306
- * and rely on `isSignedIn` for the session truth.
1395
+ * current snapshot (iOS parity). A restored session (page reload) hydrates
1396
+ * the user asynchronously via `hydrateUser()` (fired once at boot), so the
1397
+ * FIRST snapshot may report signedOut for a tick even when `isSignedIn` is
1398
+ * true; the listener then fires again with `signedIn` once the user lands.
1399
+ * Rely on `isSignedIn` for the session truth if you need it synchronously.
1307
1400
  */
1308
1401
  onAuthStateChange(callback) {
1309
1402
  this.stateListeners.add(callback);
@@ -7507,6 +7600,469 @@ var PalbeMessaging = class {
7507
7600
  }
7508
7601
  };
7509
7602
 
7603
+ // src/perf/app-start.ts
7604
+ function measureAppStart(nav) {
7605
+ if (!(nav.fcp > 0)) return null;
7606
+ const value = nav.fcp - nav.startTime;
7607
+ if (value <= 0) return null;
7608
+ return {
7609
+ row_id: crypto.randomUUID(),
7610
+ trace_type: "app_start",
7611
+ name: "cold_start",
7612
+ value,
7613
+ timestamp: Date.now()
7614
+ };
7615
+ }
7616
+
7617
+ // src/perf/perf-config-client.ts
7618
+ var PERF_CONFIG_PATH = "/v1/analytics/perf/config";
7619
+ var FNV_OFFSET_BASIS_32 = 2166136261;
7620
+ var FNV_PRIME_32 = 16777619;
7621
+ var utf82 = typeof TextEncoder !== "undefined" ? new TextEncoder() : { encode: (s) => Uint8Array.from(Buffer.from(s, "utf-8")) };
7622
+ function perfSampleBucket(rowId) {
7623
+ let hash = FNV_OFFSET_BASIS_32;
7624
+ for (const byte of utf82.encode(rowId)) {
7625
+ hash ^= byte;
7626
+ hash = Math.imul(hash, FNV_PRIME_32);
7627
+ }
7628
+ return (hash >>> 0) % 100;
7629
+ }
7630
+ function isPerfConfig(value) {
7631
+ if (typeof value !== "object" || value === null) return false;
7632
+ const o = value;
7633
+ return typeof o.sample_pct === "number";
7634
+ }
7635
+ function isRawResponse(value) {
7636
+ return typeof value === "object" && value !== null && "data" in value;
7637
+ }
7638
+ function headerValue(headers, name) {
7639
+ if (!headers) return void 0;
7640
+ const lower = name.toLowerCase();
7641
+ for (const [k, v] of Object.entries(headers)) {
7642
+ if (k.toLowerCase() === lower) return v;
7643
+ }
7644
+ return void 0;
7645
+ }
7646
+ var PerfConfigClient = class {
7647
+ etag;
7648
+ async fetchConfig(rt, perf) {
7649
+ const headers = {};
7650
+ if (this.etag) headers["If-None-Match"] = this.etag;
7651
+ try {
7652
+ const res = await rt.http.request("GET", PERF_CONFIG_PATH, { headers });
7653
+ if (!isRawResponse(res)) return;
7654
+ if (res.status === 304 || res.error && res.error.status === 304) return;
7655
+ if (res.error) return;
7656
+ const newEtag = headerValue(res.headers, "etag");
7657
+ if (newEtag) this.etag = newEtag;
7658
+ if (isPerfConfig(res.data)) {
7659
+ perf.setSamplePct(res.data.sample_pct);
7660
+ }
7661
+ } catch {
7662
+ }
7663
+ }
7664
+ };
7665
+
7666
+ // src/perf/fetch-swizzle.ts
7667
+ var PERF_EXCLUDED_PREFIX2 = "/v1/analytics/";
7668
+ function nowMs2() {
7669
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7670
+ }
7671
+ function describeRequest(input, init) {
7672
+ let url;
7673
+ let method = "GET";
7674
+ if (typeof input === "string") {
7675
+ url = input;
7676
+ } else if (input instanceof URL) {
7677
+ url = input.href;
7678
+ } else {
7679
+ url = input.url;
7680
+ method = input.method;
7681
+ }
7682
+ if (init?.method) method = init.method;
7683
+ return { url, method: method.toUpperCase() };
7684
+ }
7685
+ function pathOf(url) {
7686
+ try {
7687
+ return new URL(url, "http://_local").pathname;
7688
+ } catch {
7689
+ return url;
7690
+ }
7691
+ }
7692
+ function installFetchSwizzle(perf) {
7693
+ if (typeof fetch !== "function") return () => {
7694
+ };
7695
+ const original = fetch;
7696
+ const wrapped = async (input, init) => {
7697
+ const { url, method } = describeRequest(input, init);
7698
+ const traced = !pathOf(url).startsWith(PERF_EXCLUDED_PREFIX2);
7699
+ const startedAt = traced ? nowMs2() : 0;
7700
+ try {
7701
+ const res = await original(input, init);
7702
+ if (traced) perf.recordNetwork(method, redactUrl(url), res.status, nowMs2() - startedAt);
7703
+ return res;
7704
+ } catch (err) {
7705
+ if (traced) perf.recordNetwork(method, redactUrl(url), 0, nowMs2() - startedAt);
7706
+ throw err;
7707
+ }
7708
+ };
7709
+ globalThis.fetch = wrapped;
7710
+ return () => {
7711
+ globalThis.fetch = original;
7712
+ };
7713
+ }
7714
+
7715
+ // src/perf/offline-queue.ts
7716
+ var PERF_QUEUE_KEY = "palbe.perf.queue";
7717
+ var DEFAULT_MAX_ITEMS = 500;
7718
+ function canPersist() {
7719
+ return typeof document !== "undefined" && typeof localStorage !== "undefined";
7720
+ }
7721
+ function isPerfItem(value) {
7722
+ if (typeof value !== "object" || value === null) return false;
7723
+ const o = value;
7724
+ return typeof o.row_id === "string" && typeof o.trace_type === "string" && typeof o.name === "string" && typeof o.value === "number" && typeof o.timestamp === "number";
7725
+ }
7726
+ function readPersisted() {
7727
+ if (!canPersist()) return [];
7728
+ try {
7729
+ const raw = localStorage.getItem(PERF_QUEUE_KEY);
7730
+ if (!raw) return [];
7731
+ const parsed = JSON.parse(raw);
7732
+ if (!Array.isArray(parsed)) return [];
7733
+ return parsed.filter(isPerfItem);
7734
+ } catch {
7735
+ return [];
7736
+ }
7737
+ }
7738
+ var PerfOfflineQueue = class {
7739
+ items;
7740
+ _dropped = 0;
7741
+ maxItems;
7742
+ constructor(maxItems = DEFAULT_MAX_ITEMS) {
7743
+ this.maxItems = Math.max(1, maxItems);
7744
+ this.items = readPersisted();
7745
+ this.trim();
7746
+ }
7747
+ /** Append items; FIFO-evict the oldest when over `maxItems`. */
7748
+ enqueue(items) {
7749
+ if (items.length === 0) return;
7750
+ this.items.push(...items);
7751
+ this.trim();
7752
+ this.persist();
7753
+ }
7754
+ /** Return all queued items (oldest-first) and clear the queue + store. */
7755
+ drainAll() {
7756
+ if (this.items.length === 0) return [];
7757
+ const out = this.items;
7758
+ this.items = [];
7759
+ this.clearStore();
7760
+ return out;
7761
+ }
7762
+ /** Current queue depth. */
7763
+ get count() {
7764
+ return this.items.length;
7765
+ }
7766
+ /** Cumulative count of FIFO-evicted items (a `dropped` metric, not silent). */
7767
+ get dropped() {
7768
+ return this._dropped;
7769
+ }
7770
+ /** Drop the oldest items until at most `maxItems` remain, counting each. */
7771
+ trim() {
7772
+ const overflow = this.items.length - this.maxItems;
7773
+ if (overflow > 0) {
7774
+ this.items.splice(0, overflow);
7775
+ this._dropped += overflow;
7776
+ }
7777
+ }
7778
+ persist() {
7779
+ if (!canPersist()) return;
7780
+ try {
7781
+ localStorage.setItem(PERF_QUEUE_KEY, JSON.stringify(this.items));
7782
+ } catch {
7783
+ }
7784
+ }
7785
+ clearStore() {
7786
+ if (!canPersist()) return;
7787
+ try {
7788
+ localStorage.removeItem(PERF_QUEUE_KEY);
7789
+ } catch {
7790
+ }
7791
+ }
7792
+ };
7793
+
7794
+ // src/perf/perf-state.ts
7795
+ var MAX_PERF_BATCH = 100;
7796
+ var PerfState = class {
7797
+ /** Pending, un-flushed perf items (FIFO). */
7798
+ buffer = [];
7799
+ /** When true, every flush carries `X-Palbase-Test-Device: 1`. */
7800
+ testDevice = false;
7801
+ enqueue(item) {
7802
+ this.buffer.push(item);
7803
+ }
7804
+ /** Detach up to `MAX_PERF_BATCH` items for one POST (FIFO order preserved). */
7805
+ take(limit = MAX_PERF_BATCH) {
7806
+ return this.buffer.splice(0, limit);
7807
+ }
7808
+ get size() {
7809
+ return this.buffer.length;
7810
+ }
7811
+ };
7812
+
7813
+ // src/perf/perf-wire.ts
7814
+ function encodePerfBatch(items) {
7815
+ return { items };
7816
+ }
7817
+
7818
+ // src/perf/perf-facade.ts
7819
+ var FLUSH_AT2 = 20;
7820
+ var FLUSH_INTERVAL_MS2 = 1e4;
7821
+ var TEST_DEVICE_HEADER = "X-Palbase-Test-Device";
7822
+ function nowMs3() {
7823
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
7824
+ }
7825
+ var PerfTrace = class {
7826
+ constructor(name, onStop) {
7827
+ this.name = name;
7828
+ this.onStop = onStop;
7829
+ }
7830
+ name;
7831
+ onStop;
7832
+ attrs = {};
7833
+ counters = {};
7834
+ startedAt = nowMs3();
7835
+ stopped = false;
7836
+ putAttribute(key, value) {
7837
+ this.attrs[key] = value;
7838
+ }
7839
+ incrementMetric(name, by = 1) {
7840
+ this.counters[name] = (this.counters[name] ?? 0) + by;
7841
+ }
7842
+ stop() {
7843
+ if (this.stopped) return;
7844
+ this.stopped = true;
7845
+ const item = {
7846
+ row_id: crypto.randomUUID(),
7847
+ trace_type: "custom",
7848
+ name: this.name,
7849
+ value: Math.max(0, nowMs3() - this.startedAt),
7850
+ timestamp: Date.now()
7851
+ };
7852
+ if (Object.keys(this.attrs).length > 0) item.attrs = this.attrs;
7853
+ if (Object.keys(this.counters).length > 0) item.counters = this.counters;
7854
+ this.onStop(item);
7855
+ }
7856
+ };
7857
+ var PalbePerf = class {
7858
+ constructor(rt, state = new PerfState(), queue = new PerfOfflineQueue()) {
7859
+ this.rt = rt;
7860
+ this.state = state;
7861
+ this.queue = queue;
7862
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
7863
+ window.addEventListener("online", this.onOnline);
7864
+ }
7865
+ }
7866
+ rt;
7867
+ state;
7868
+ queue;
7869
+ flushTimer = null;
7870
+ /** Browser → buffer + size/timer flush. Server (no document) → immediate
7871
+ * per-item flush, zero timers (nothing leaks into RSC/route handlers). */
7872
+ browser = typeof document !== "undefined";
7873
+ /** Bound `online` handler so it can be removed on `dispose()` (no leak). */
7874
+ onOnline = () => {
7875
+ void this.flush();
7876
+ };
7877
+ /** Server-controlled client-side sample rate (0..100). 100 until the config
7878
+ * client fetches `/v1/analytics/perf/config` — the SDK obeys this ceiling and
7879
+ * never raises its own rate (invariant 3). `record` drops an item whose
7880
+ * deterministic `row_id` bucket is >= this pct, mirroring the server's
7881
+ * `SampleDecision` so client + server keep the SAME rows. */
7882
+ samplePct = 100;
7883
+ /** Remove the `online` listener. Called when the runtime is replaced so the
7884
+ * handler does not outlive this facade. No-op outside the browser. */
7885
+ dispose() {
7886
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
7887
+ window.removeEventListener("online", this.onOnline);
7888
+ }
7889
+ }
7890
+ /** Mark (or unmark) this client's traffic as test — the server tags the rows
7891
+ * when the `X-Palbase-Test-Device: 1` header rides along on flush. */
7892
+ setTestDevice(on) {
7893
+ this.state.testDevice = on;
7894
+ }
7895
+ /** Apply the server-resolved client-side sample rate (0..100), clamped. Called
7896
+ * by `PerfConfigClient` after fetching `/v1/analytics/perf/config`. The SDK
7897
+ * OBEYS this value — it is a ceiling, never raised locally. */
7898
+ setSamplePct(pct) {
7899
+ this.samplePct = Math.max(0, Math.min(100, Math.trunc(pct)));
7900
+ }
7901
+ /** Pending (un-flushed) buffer depth. Test-only visibility into the buffer so
7902
+ * the remote-sampling tests can assert how many items were sampled in. */
7903
+ get bufferSizeForTest() {
7904
+ return this.state.size;
7905
+ }
7906
+ /** Opt-in: wrap the global `fetch` so every app-level request is recorded as a
7907
+ * redacted `network` perf item (the `/v1/analytics/*` ingest paths are
7908
+ * self-excluded). OFF by default — swizzling a global is a page-wide side
7909
+ * effect. Returns an `uninstall` that restores the original `fetch`. */
7910
+ enableFetchCapture() {
7911
+ return installFetchSwizzle(this);
7912
+ }
7913
+ /** Start a custom trace; the returned handle records a `custom` item on
7914
+ * `.stop()`. */
7915
+ startTrace(name) {
7916
+ return new PerfTrace(name, (item) => this.record(item));
7917
+ }
7918
+ /** Buffer one perf item. In the browser, flush on size/timer; on the server
7919
+ * flush immediately (no timers). Never throws.
7920
+ *
7921
+ * Client-side remote sampling: an item whose deterministic `row_id` bucket is
7922
+ * NOT below the server-controlled `samplePct` is dropped before buffering —
7923
+ * the SAME FNV-1a-mod-100 decision the server applies at ingest, so the two
7924
+ * keep identical rows and the SDK saves the upload (defense-in-depth: the
7925
+ * server re-samples authoritatively). */
7926
+ record(item) {
7927
+ if (perfSampleBucket(item.row_id) >= this.samplePct) return;
7928
+ this.state.enqueue(item);
7929
+ if (!this.browser) {
7930
+ void this.flush();
7931
+ return;
7932
+ }
7933
+ if (this.state.size >= FLUSH_AT2) void this.flush();
7934
+ else this.startTimer();
7935
+ }
7936
+ /** Buffer a network trace — called by `request.ts` around `rt.http.request`
7937
+ * (the analytics ingest path is excluded by the caller to avoid recursion). */
7938
+ recordNetwork(method, url, status, durationMs, requestId) {
7939
+ const item = {
7940
+ row_id: crypto.randomUUID(),
7941
+ trace_type: "network",
7942
+ name: `${method} ${url}`,
7943
+ value: durationMs,
7944
+ attrs: { status: String(status) },
7945
+ timestamp: Date.now()
7946
+ };
7947
+ if (requestId) item.request_id = requestId;
7948
+ this.record(item);
7949
+ }
7950
+ /** Drain the offline queue (oldest-first) and the live buffer to
7951
+ * `/v1/analytics/perf` (≤100 items per request, sequential). Resolves when
7952
+ * delivery finished; NEVER rejects. A failed slice is NOT dropped — it goes
7953
+ * to the offline queue (persist-on-fail) so the next flush (size/timer or the
7954
+ * `online` reconnect event) retries it. Items keep their original `row_id`,
7955
+ * so a redelivery dedups server-side (ReplacingMergeTree). */
7956
+ async flush() {
7957
+ this.cancelTimer();
7958
+ const pending = this.queue.drainAll();
7959
+ if (pending.length === 0 && this.state.size === 0) return;
7960
+ const headers = this.state.testDevice ? { [TEST_DEVICE_HEADER]: "1" } : void 0;
7961
+ const remaining = [...pending];
7962
+ while (this.state.size > 0) remaining.push(...this.state.take(MAX_PERF_BATCH));
7963
+ for (let i = 0; i < remaining.length; i += MAX_PERF_BATCH) {
7964
+ const slice = remaining.slice(i, i + MAX_PERF_BATCH);
7965
+ try {
7966
+ await palbeRequest(
7967
+ this.rt,
7968
+ "POST",
7969
+ "/v1/analytics/perf",
7970
+ { body: encodePerfBatch(slice), headers }
7971
+ );
7972
+ } catch {
7973
+ this.queue.enqueue(slice);
7974
+ }
7975
+ }
7976
+ }
7977
+ startTimer() {
7978
+ if (this.flushTimer !== null) return;
7979
+ this.flushTimer = setTimeout(() => {
7980
+ this.flushTimer = null;
7981
+ void this.flush();
7982
+ }, FLUSH_INTERVAL_MS2);
7983
+ }
7984
+ cancelTimer() {
7985
+ if (this.flushTimer !== null) {
7986
+ clearTimeout(this.flushTimer);
7987
+ this.flushTimer = null;
7988
+ }
7989
+ }
7990
+ };
7991
+
7992
+ // src/perf/web-vitals.ts
7993
+ function webVitalItem(name, value) {
7994
+ return {
7995
+ row_id: crypto.randomUUID(),
7996
+ trace_type: "web_vital",
7997
+ name,
7998
+ value: Math.max(0, value),
7999
+ timestamp: Date.now()
8000
+ };
8001
+ }
8002
+ function isLayoutShift(e) {
8003
+ return e.entryType === "layout-shift" && "value" in e && "hadRecentInput" in e;
8004
+ }
8005
+ function isEventTiming(e) {
8006
+ return e.entryType === "event" || e.entryType === "first-input";
8007
+ }
8008
+ function safeObserve(type, cb) {
8009
+ if (typeof PerformanceObserver === "undefined") return null;
8010
+ try {
8011
+ const obs = new PerformanceObserver((list) => cb(list.getEntries()));
8012
+ obs.observe({ type, buffered: true });
8013
+ return obs;
8014
+ } catch {
8015
+ return null;
8016
+ }
8017
+ }
8018
+ function observeWebVitals(record) {
8019
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function" || typeof document.removeEventListener !== "function" || typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
8020
+ return () => {
8021
+ };
8022
+ }
8023
+ let cls = 0;
8024
+ let lcp = 0;
8025
+ let inp = 0;
8026
+ const observers = [
8027
+ // LCP: keep the largest/last reported render.
8028
+ safeObserve("largest-contentful-paint", (entries) => {
8029
+ for (const e of entries) lcp = Math.max(lcp, e.startTime);
8030
+ }),
8031
+ // CLS: sum shift values that weren't caused by recent input.
8032
+ safeObserve("layout-shift", (entries) => {
8033
+ for (const e of entries) if (isLayoutShift(e) && !e.hadRecentInput) cls += e.value;
8034
+ }),
8035
+ // INP: approximate as the worst interaction duration observed.
8036
+ safeObserve("event", (entries) => {
8037
+ for (const e of entries) if (isEventTiming(e)) inp = Math.max(inp, e.duration);
8038
+ }),
8039
+ // FCP: one-shot.
8040
+ safeObserve("paint", (entries) => {
8041
+ for (const e of entries)
8042
+ if (e.name === "first-contentful-paint") record(webVitalItem("FCP", e.startTime));
8043
+ })
8044
+ ];
8045
+ const nav = performance.getEntriesByType("navigation")[0];
8046
+ if (nav && nav.responseStart > 0) record(webVitalItem("TTFB", nav.responseStart));
8047
+ let flushed = false;
8048
+ const flush = () => {
8049
+ if (flushed) return;
8050
+ flushed = true;
8051
+ if (lcp > 0) record(webVitalItem("LCP", lcp));
8052
+ record(webVitalItem("CLS", cls));
8053
+ if (inp > 0) record(webVitalItem("INP", inp));
8054
+ };
8055
+ const onHide = () => {
8056
+ if (document.visibilityState === "hidden") flush();
8057
+ };
8058
+ document.addEventListener("visibilitychange", onHide);
8059
+ return () => {
8060
+ flush();
8061
+ document.removeEventListener("visibilitychange", onHide);
8062
+ for (const o of observers) o?.disconnect();
8063
+ };
8064
+ }
8065
+
7510
8066
  // src/realtime/anon-token.ts
7511
8067
  var REFRESH_SKEW_MS = 6e4;
7512
8068
  var AnonTokenProvider = class {
@@ -8194,10 +8750,13 @@ function defaultSessionStorage(key) {
8194
8750
  }
8195
8751
 
8196
8752
  // src/version.ts
8197
- var VERSION = "1.7.0";
8753
+ var VERSION = "1.9.0";
8198
8754
 
8199
8755
  // src/runtime.ts
8200
8756
  function buildRuntime(config) {
8757
+ const appIdentifier = config.identifier ?? "";
8758
+ const runtimeOrigin = typeof window !== "undefined" && typeof window.location !== "undefined" ? window.location.origin : "";
8759
+ assertOriginMatches(loadAppConfig({ identifier: appIdentifier }), runtimeOrigin);
8201
8760
  const http = new HttpClient(config.apiKey, {
8202
8761
  url: config.url,
8203
8762
  headers: { "X-Client-Info": `palbe-web/${VERSION}`, ...config.headers }
@@ -8247,8 +8806,10 @@ function buildRuntime(config) {
8247
8806
  let analytics;
8248
8807
  let calls;
8249
8808
  let messaging;
8809
+ let perf;
8250
8810
  const rt = {
8251
8811
  config,
8812
+ appIdentifier,
8252
8813
  http,
8253
8814
  tokenManager,
8254
8815
  authClient,
@@ -8284,11 +8845,19 @@ function buildRuntime(config) {
8284
8845
  destroyRealtime() {
8285
8846
  realtime?.destroy();
8286
8847
  realtime = void 0;
8848
+ perf?.dispose();
8287
8849
  },
8288
8850
  // The buffering facade is lazy; its identity state is NOT (below).
8289
8851
  get analytics() {
8290
8852
  if (!analytics) analytics = new PalbeAnalytics(rt, analyticsState);
8291
8853
  return analytics;
8854
+ },
8855
+ // PalPerf is constructed up front (touched below): `request.ts` records a
8856
+ // network trace on EVERY fetch, so it can't be lazy. The memo here only
8857
+ // guards against re-construction.
8858
+ get perf() {
8859
+ if (!perf) perf = new PalbePerf(rt);
8860
+ return perf;
8292
8861
  }
8293
8862
  };
8294
8863
  const analyticsState = new AnalyticsState(rt);
@@ -8296,8 +8865,43 @@ function buildRuntime(config) {
8296
8865
  const hasOwn = Object.keys(request.headers).some((k) => k.toLowerCase() === "x-distinct-id");
8297
8866
  if (!hasOwn) request.headers["X-Distinct-Id"] = analyticsState.distinctId();
8298
8867
  });
8868
+ void rt.perf;
8869
+ if (typeof document !== "undefined") {
8870
+ void new PerfConfigClient().fetchConfig(rt, rt.perf);
8871
+ rt.auth.hydrateUser();
8872
+ }
8873
+ recordColdStart(rt);
8874
+ observeWebVitals((item) => rt.perf.record(item));
8299
8875
  return rt;
8300
8876
  }
8877
+ function recordColdStart(rt) {
8878
+ if (typeof document === "undefined" || typeof performance === "undefined") return;
8879
+ try {
8880
+ const navEntry = performance.getEntriesByType("navigation")[0];
8881
+ const startTime = navEntry?.startTime ?? 0;
8882
+ const recordFromFcp = (fcp) => {
8883
+ const item = measureAppStart({ startTime, fcp });
8884
+ if (item) rt.perf.record(item);
8885
+ };
8886
+ const existing = performance.getEntriesByName("first-contentful-paint").find((e) => e.startTime > 0);
8887
+ if (existing) {
8888
+ recordFromFcp(existing.startTime);
8889
+ return;
8890
+ }
8891
+ if (typeof PerformanceObserver === "undefined") return;
8892
+ const observer = new PerformanceObserver((list) => {
8893
+ for (const entry of list.getEntries()) {
8894
+ if (entry.name === "first-contentful-paint") {
8895
+ observer.disconnect();
8896
+ recordFromFcp(entry.startTime);
8897
+ return;
8898
+ }
8899
+ }
8900
+ });
8901
+ observer.observe({ type: "paint", buffered: true });
8902
+ } catch {
8903
+ }
8904
+ }
8301
8905
 
8302
8906
  // src/internal.ts
8303
8907
  function __configure(config) {