@cross-deck/node 1.1.0 → 1.2.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.
package/dist/index.mjs CHANGED
@@ -318,7 +318,7 @@ function byteLength(s) {
318
318
 
319
319
  // src/http.ts
320
320
  var SDK_NAME = "@cross-deck/node";
321
- var SDK_VERSION = "1.1.0";
321
+ var SDK_VERSION = "1.2.0";
322
322
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
323
323
  var DEFAULT_TIMEOUT_MS = 15e3;
324
324
  var CROSSDECK_API_VERSION = "2025-01-01";
@@ -1037,13 +1037,21 @@ function isInAppFrame(filename) {
1037
1037
  if (/^internal[\\/]/.test(filename)) return false;
1038
1038
  return true;
1039
1039
  }
1040
- function fingerprintError(message, frames) {
1040
+ function fingerprintError(message, frames, location) {
1041
1041
  const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
1042
- const key = [
1042
+ const parts = [
1043
1043
  (message || "").slice(0, 200),
1044
1044
  ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
1045
- ].join("|");
1046
- return djb2Hex(key);
1045
+ ];
1046
+ if (inAppFrames.length === 0 && location) {
1047
+ const loc = [
1048
+ location.errorType ?? "",
1049
+ location.filename ?? "",
1050
+ location.lineno ?? ""
1051
+ ].join(":");
1052
+ if (loc !== "::") parts.push(loc);
1053
+ }
1054
+ return djb2Hex(parts.join("|"));
1047
1055
  }
1048
1056
  function djb2Hex(input) {
1049
1057
  let h = 5381;
@@ -1274,34 +1282,30 @@ var ErrorTracker = class {
1274
1282
  * runtime-agnostic.
1275
1283
  */
1276
1284
  buildFromUnknown(err, kind, level) {
1277
- if (err instanceof Error) {
1278
- const frames = parseStack(err.stack);
1279
- return {
1280
- timestamp: Date.now(),
1281
- kind,
1282
- level,
1283
- message: String(err.message).slice(0, 1024),
1284
- errorType: err.name,
1285
- frames,
1286
- rawStack: err.stack ?? null,
1287
- fingerprint: fingerprintError(err.message, frames),
1288
- breadcrumbs: this.opts.breadcrumbs.snapshot(),
1289
- context: this.opts.getContext(),
1290
- tags: this.opts.getTags()
1291
- };
1292
- }
1293
- const message = safeStringify3(err).slice(0, 1024);
1285
+ const payload = coerceErrorPayload(err);
1286
+ const message = (payload.message || "Unknown error").slice(0, 1024);
1287
+ const stack = err instanceof Error ? err.stack ?? null : null;
1288
+ const frames = parseStack(stack);
1289
+ const errorType = payload.errorType ?? null;
1290
+ const context = payload.extras ? { ...this.opts.getContext(), __error_extras: payload.extras } : this.opts.getContext();
1294
1291
  return {
1295
1292
  timestamp: Date.now(),
1296
1293
  kind,
1297
1294
  level,
1298
1295
  message,
1299
- errorType: null,
1300
- frames: [],
1301
- rawStack: null,
1302
- fingerprint: fingerprintError(message, []),
1296
+ errorType,
1297
+ frames,
1298
+ rawStack: stack,
1299
+ // Location fallback ensures distinct call sites stay separate
1300
+ // even when the message is generic and there are no parseable
1301
+ // frames (e.g. `throw "boom"` from a middleware).
1302
+ fingerprint: fingerprintError(message, frames, {
1303
+ filename: frames[0]?.filename ?? null,
1304
+ lineno: frames[0]?.lineno ?? null,
1305
+ errorType
1306
+ }),
1303
1307
  breadcrumbs: this.opts.breadcrumbs.snapshot(),
1304
- context: this.opts.getContext(),
1308
+ context,
1305
1309
  tags: this.opts.getTags()
1306
1310
  };
1307
1311
  }
@@ -1316,7 +1320,10 @@ var ErrorTracker = class {
1316
1320
  errorType: "HTTPError",
1317
1321
  frames: [],
1318
1322
  rawStack: null,
1319
- fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
1323
+ fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, [], {
1324
+ filename: info.url,
1325
+ errorType: "HTTPError"
1326
+ }),
1320
1327
  breadcrumbs: this.opts.breadcrumbs.snapshot(),
1321
1328
  context: this.opts.getContext(),
1322
1329
  tags: this.opts.getTags(),
@@ -1419,19 +1426,164 @@ var ErrorTracker = class {
1419
1426
  }
1420
1427
  }
1421
1428
  };
1422
- function safeStringify3(v) {
1423
- if (v == null) return String(v);
1424
- if (typeof v === "string") return v;
1425
- if (typeof v === "number" || typeof v === "boolean") return String(v);
1429
+ function coerceErrorPayload(v) {
1430
+ if (v === null) return { message: "(thrown: null)", errorType: null, extras: null };
1431
+ if (v === void 0) return { message: "(thrown: undefined)", errorType: null, extras: null };
1432
+ if (typeof v === "string") {
1433
+ return { message: v, errorType: null, extras: null };
1434
+ }
1435
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") {
1436
+ return { message: String(v), errorType: typeof v, extras: null };
1437
+ }
1438
+ if (typeof v === "symbol") {
1439
+ return { message: v.toString(), errorType: "symbol", extras: null };
1440
+ }
1441
+ if (typeof v === "function") {
1442
+ return { message: `(thrown function: ${v.name || "anonymous"})`, errorType: "function", extras: null };
1443
+ }
1444
+ if (v instanceof Error) {
1445
+ const errorType = v.name || v.constructor?.name || "Error";
1446
+ const message = typeof v.message === "string" && v.message.length > 0 ? v.message : safeToString(v) || errorType;
1447
+ const extras = {};
1448
+ const causeChain = collectCauseChain(v);
1449
+ if (causeChain.length > 0) extras.cause = causeChain;
1450
+ const aggErrors = v.errors;
1451
+ if (Array.isArray(aggErrors)) {
1452
+ extras.aggregatedErrors = aggErrors.slice(0, 10).map((inner) => {
1453
+ if (inner instanceof Error) {
1454
+ return { name: inner.name || "Error", message: inner.message || "" };
1455
+ }
1456
+ return { name: "non-Error", message: safeToString(inner) };
1457
+ });
1458
+ }
1459
+ for (const key of [
1460
+ "code",
1461
+ "errno",
1462
+ "syscall",
1463
+ "path",
1464
+ "status",
1465
+ "statusCode",
1466
+ "response",
1467
+ "data",
1468
+ "detail",
1469
+ "details"
1470
+ ]) {
1471
+ const val = v[key];
1472
+ if (val !== void 0 && typeof val !== "function") {
1473
+ extras[key] = safeClone(val);
1474
+ }
1475
+ }
1476
+ for (const key of Object.keys(v)) {
1477
+ if (key === "message" || key === "stack" || key === "name" || key === "cause" || key === "errors") continue;
1478
+ if (key in extras) continue;
1479
+ const val = v[key];
1480
+ if (typeof val === "function") continue;
1481
+ extras[key] = safeClone(val);
1482
+ }
1483
+ return {
1484
+ message,
1485
+ errorType,
1486
+ extras: Object.keys(extras).length > 0 ? extras : null
1487
+ };
1488
+ }
1489
+ if (typeof Response !== "undefined" && v instanceof Response) {
1490
+ return {
1491
+ message: `HTTP ${v.status} ${v.statusText || ""}${v.url ? ` ${v.url}` : ""}`.trim(),
1492
+ errorType: "Response",
1493
+ extras: { status: v.status, statusText: v.statusText, url: v.url, type: v.type }
1494
+ };
1495
+ }
1496
+ if (typeof v === "object") {
1497
+ const obj = v;
1498
+ const ctorName = obj.constructor && typeof obj.constructor === "function" && obj.constructor.name || null;
1499
+ const ownMessage = typeof obj.message === "string" && obj.message ? obj.message : null;
1500
+ const ownName = typeof obj.name === "string" && obj.name ? obj.name : null;
1501
+ let jsonForm = null;
1502
+ try {
1503
+ const serialised = JSON.stringify(obj);
1504
+ jsonForm = serialised === "{}" ? null : serialised;
1505
+ } catch {
1506
+ jsonForm = null;
1507
+ }
1508
+ const fallbackString = safeToString(obj);
1509
+ const message = ownMessage ?? jsonForm ?? (fallbackString && fallbackString !== "[object Object]" ? fallbackString : null) ?? (ctorName ? `(thrown ${ctorName} with no message)` : "(thrown object with no message)");
1510
+ const errorType = ownName ?? ctorName ?? null;
1511
+ const extras = {};
1512
+ let count = 0;
1513
+ for (const key of Object.keys(obj)) {
1514
+ if (count >= 20) break;
1515
+ if (key === "message" || key === "name") continue;
1516
+ const val = obj[key];
1517
+ if (typeof val === "function") continue;
1518
+ extras[key] = safeClone(val);
1519
+ count++;
1520
+ }
1521
+ return {
1522
+ message,
1523
+ errorType,
1524
+ extras: Object.keys(extras).length > 0 ? extras : null
1525
+ };
1526
+ }
1527
+ return { message: safeToString(v) || "(unstringifiable thrown value)", errorType: null, extras: null };
1528
+ }
1529
+ function collectCauseChain(err) {
1530
+ const out = [];
1531
+ let cur = err.cause;
1532
+ let depth = 0;
1533
+ while (cur != null && depth < 5) {
1534
+ if (cur instanceof Error) {
1535
+ out.push({ name: cur.name || "Error", message: cur.message || "" });
1536
+ cur = cur.cause;
1537
+ } else {
1538
+ out.push({ name: "non-Error", message: safeToString(cur) });
1539
+ cur = null;
1540
+ }
1541
+ depth++;
1542
+ }
1543
+ return out;
1544
+ }
1545
+ function safeToString(v) {
1426
1546
  try {
1427
- return JSON.stringify(v);
1547
+ const s = Object.prototype.toString.call(v);
1548
+ if (s !== "[object Object]") return s;
1549
+ const own = v?.toString;
1550
+ if (typeof own === "function" && own !== Object.prototype.toString) {
1551
+ const r = own.call(v);
1552
+ if (typeof r === "string") return r;
1553
+ }
1554
+ return s;
1428
1555
  } catch {
1429
- return Object.prototype.toString.call(v);
1556
+ return "(throwing toString)";
1430
1557
  }
1431
1558
  }
1559
+ function safeClone(v) {
1560
+ if (v == null) return v;
1561
+ const t = typeof v;
1562
+ if (t === "string" || t === "number" || t === "boolean") return v;
1563
+ if (t === "bigint") return String(v);
1564
+ try {
1565
+ const s = JSON.stringify(v);
1566
+ return s === void 0 ? safeToString(v) : JSON.parse(s);
1567
+ } catch {
1568
+ return safeToString(v);
1569
+ }
1570
+ }
1571
+ function safeStringify3(v) {
1572
+ return coerceErrorPayload(v).message;
1573
+ }
1432
1574
 
1433
1575
  // src/runtime-info.ts
1434
1576
  import { hostname as osHostname, platform as osPlatform, release as osRelease } from "os";
1577
+ var SERVERLESS_HOSTS = /* @__PURE__ */ new Set([
1578
+ "aws-lambda",
1579
+ "azure-functions",
1580
+ "google-app-engine",
1581
+ "firebase-functions-v1",
1582
+ "firebase-functions-v2",
1583
+ "cloud-run",
1584
+ "vercel",
1585
+ "netlify"
1586
+ ]);
1435
1587
  var cached = null;
1436
1588
  function collectRuntimeInfo(options = {}) {
1437
1589
  if (cached) return cached;
@@ -1447,6 +1599,7 @@ function detect(options) {
1447
1599
  platformRelease: safeRelease(),
1448
1600
  hostname: safeHostname(),
1449
1601
  host: detected.host,
1602
+ isServerless: SERVERLESS_HOSTS.has(detected.host),
1450
1603
  region: detected.region,
1451
1604
  serviceName: options.serviceName ?? detected.serviceName,
1452
1605
  serviceVersion: options.serviceVersion ?? detected.serviceVersion,
@@ -1850,9 +2003,11 @@ var SuperPropertyStore = class {
1850
2003
 
1851
2004
  // src/entitlement-cache.ts
1852
2005
  var DEFAULT_MAX_CUSTOMERS = 1e4;
2006
+ var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
1853
2007
  var EntitlementCache = class {
1854
2008
  ttlMs;
1855
2009
  maxCustomers;
2010
+ staleAfterMs;
1856
2011
  byCustomer = /* @__PURE__ */ new Map();
1857
2012
  listeners = /* @__PURE__ */ new Set();
1858
2013
  listenerErrorCount = 0;
@@ -1860,52 +2015,95 @@ var EntitlementCache = class {
1860
2015
  constructor(options = {}) {
1861
2016
  this.ttlMs = options.ttlMs ?? 6e4;
1862
2017
  this.maxCustomers = options.maxCustomers ?? DEFAULT_MAX_CUSTOMERS;
2018
+ this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
1863
2019
  }
1864
2020
  /**
1865
- * Synchronous lookup. Returns `true` iff the customer has the
1866
- * entitlement AND the cache entry is fresh (within `ttlMs`).
1867
- * Returns `false` otherwise (no entry / expired / key not active).
2021
+ * Synchronous lookup true iff the customer currently has the
2022
+ * entitlement granting access. Pure in-memory `Map` read, ZERO I/O.
2023
+ *
2024
+ * Served from last-known-good: a stale entry (Crossdeck unreachable
2025
+ * since the last successful fetch, or past `ttlMs`) STILL answers true
2026
+ * for a still-valid entitlement. Cache staleness alone never makes
2027
+ * this `false` — the central durability fix. The only things that
2028
+ * turn it false:
2029
+ * - the customer has no cached entry at all (genuine cold miss)
2030
+ * - no matching `key` in the customer's entitlement set
2031
+ * - the matching entitlement is `isActive: false`
2032
+ * - the matching entitlement is past its OWN `validUntil` — a
2033
+ * time-based trial expiry still applies mid-outage (mirrors the
2034
+ * web SDK's `validUntil` check exactly).
2035
+ *
2036
+ * An entry being past `ttlMs`, or marked refresh-failed, does NOT
2037
+ * affect the answer — `getEntitlements()` re-fetches on the TTL hint,
2038
+ * but until it succeeds the customer keeps their access.
1868
2039
  */
1869
2040
  isEntitled(customerId, key) {
1870
2041
  const entry = this.byCustomer.get(customerId);
1871
2042
  if (!entry) return false;
1872
- if (Date.now() > entry.expiresAt) return false;
1873
- return entry.active.has(key);
2043
+ const nowSec = Date.now() / 1e3;
2044
+ return entry.all.some(
2045
+ (e) => e.key === key && e.isActive && (e.validUntil == null || e.validUntil > nowSec)
2046
+ );
1874
2047
  }
1875
2048
  /**
1876
- * Full snapshot for callers that need source / validUntil. Returns
1877
- * `[]` when the customer has no cached entry or the entry has
1878
- * expired.
2049
+ * Full snapshot for callers that need source / validUntil details.
2050
+ * Returns `[]` ONLY when the customer has no cached entry a stale
2051
+ * or past-TTL entry still returns its last-known-good entitlements
2052
+ * (same durability posture as `isEntitled()`; per-entitlement
2053
+ * `validUntil` is the caller's to honour from the returned objects).
1879
2054
  */
1880
2055
  list(customerId) {
1881
2056
  const entry = this.byCustomer.get(customerId);
1882
2057
  if (!entry) return [];
1883
- if (Date.now() > entry.expiresAt) return [];
1884
2058
  return entry.all.slice();
1885
2059
  }
1886
2060
  /**
1887
- * Whether a fresh entry exists for the customer. Useful for
1888
- * deciding whether to warm before a hot path.
2061
+ * Whether the customer's entry is still within `ttlMs` — i.e. a
2062
+ * re-fetch is NOT yet due. Useful for deciding whether to warm before
2063
+ * a hot path. A `false` result does NOT mean the cache is empty or
2064
+ * that `isEntitled()` will return false — it only means the data is
2065
+ * past its refresh hint. See `needsRefresh()` for the inverse.
1889
2066
  */
1890
2067
  isFresh(customerId) {
1891
2068
  const entry = this.byCustomer.get(customerId);
1892
- return Boolean(entry && Date.now() <= entry.expiresAt);
2069
+ return Boolean(entry && Date.now() <= entry.refreshDueAt);
1893
2070
  }
1894
2071
  /**
1895
- * Replace (or insert) the cache entry for a customer. Sets the
1896
- * `expiresAt` to `now + ttlMs`. Re-inserting an existing customerId
1897
- * "touches" it the entry moves to the end of insertion order
1898
- * (Map semantics) so it's treated as most-recently-used for LRU
1899
- * eviction. Fires listeners.
2072
+ * Whether the customer should be re-fetched: either there is no
2073
+ * cached entry, or the entry is past its `ttlMs` refresh hint, or the
2074
+ * most recent refresh attempt for them failed (retry it).
2075
+ *
2076
+ * This is purely advisory — `getEntitlements()` decides when to act
2077
+ * on it. It NEVER gates `isEntitled()`, which serves last-known-good
2078
+ * regardless.
2079
+ */
2080
+ needsRefresh(customerId) {
2081
+ const entry = this.byCustomer.get(customerId);
2082
+ if (!entry) return true;
2083
+ if (entry.refreshFailedAt > 0) return true;
2084
+ return Date.now() > entry.refreshDueAt;
2085
+ }
2086
+ /**
2087
+ * Replace (or insert) the cache entry for a customer with a fresh
2088
+ * server response. Sets `refreshDueAt` to `now + ttlMs` and CLEARS
2089
+ * any failed-refresh marker — a success ends the stale state.
2090
+ *
2091
+ * Called ONLY after a SUCCESSFUL server read — a failed fetch is
2092
+ * routed to `markRefreshFailed` instead and never reaches here, so
2093
+ * last-known-good is preserved through an outage.
2094
+ *
2095
+ * Re-inserting an existing customerId "touches" it — the entry moves
2096
+ * to the end of insertion order (Map semantics) so it's treated as
2097
+ * most-recently-used for LRU eviction. Fires listeners.
1900
2098
  */
1901
2099
  setForCustomer(customerId, entitlements) {
1902
2100
  const now = Date.now();
1903
2101
  this.byCustomer.delete(customerId);
1904
2102
  this.byCustomer.set(customerId, {
1905
2103
  all: entitlements.slice(),
1906
- active: new Set(entitlements.filter((e) => e.isActive).map((e) => e.key)),
1907
- expiresAt: now + this.ttlMs,
1908
- populatedAt: now
2104
+ refreshDueAt: now + this.ttlMs,
2105
+ populatedAt: now,
2106
+ refreshFailedAt: 0
1909
2107
  });
1910
2108
  while (this.byCustomer.size > this.maxCustomers) {
1911
2109
  const oldestKey = this.byCustomer.keys().next().value;
@@ -1915,6 +2113,68 @@ var EntitlementCache = class {
1915
2113
  }
1916
2114
  this.notify(customerId, entitlements);
1917
2115
  }
2116
+ /**
2117
+ * Record that a refresh attempt for a customer FAILED (Crossdeck
2118
+ * unreachable / transient error). `getEntitlements()` calls this in
2119
+ * its catch path.
2120
+ *
2121
+ * It does NOT touch the customer's cached entitlements — last-known-
2122
+ * good keeps serving — it only stamps `refreshFailedAt` so the
2123
+ * customer shows up as stale in `diagnostics()` rather than the
2124
+ * staleness being a silent unbounded window.
2125
+ *
2126
+ * If the customer has no entry yet (a genuine cold miss whose first
2127
+ * fetch failed) a stub entry with no entitlements is created purely
2128
+ * to carry the failed-refresh marker — so "we tried and Crossdeck was
2129
+ * down" is observable even before any successful warm. The stub holds
2130
+ * an empty entitlement set, so `isEntitled()` still correctly returns
2131
+ * false for it; there is genuinely nothing to serve.
2132
+ */
2133
+ markRefreshFailed(customerId) {
2134
+ const now = Date.now();
2135
+ const entry = this.byCustomer.get(customerId);
2136
+ if (entry) {
2137
+ entry.refreshFailedAt = now;
2138
+ return;
2139
+ }
2140
+ this.byCustomer.set(customerId, {
2141
+ all: [],
2142
+ refreshDueAt: now + this.ttlMs,
2143
+ populatedAt: 0,
2144
+ refreshFailedAt: now
2145
+ });
2146
+ while (this.byCustomer.size > this.maxCustomers) {
2147
+ const oldestKey = this.byCustomer.keys().next().value;
2148
+ if (oldestKey === void 0) break;
2149
+ this.byCustomer.delete(oldestKey);
2150
+ this.evicted += 1;
2151
+ }
2152
+ }
2153
+ /**
2154
+ * Whether a customer is knowingly serving older-than-trustworthy
2155
+ * data. True when the most recent refresh ATTEMPT for them failed
2156
+ * (Crossdeck unreachable since the last success — the outage case,
2157
+ * distinct from a benign idle customer simply past `ttlMs`), OR when
2158
+ * their last-known-good has aged past `staleAfterMs`.
2159
+ *
2160
+ * `isStale` NEVER changes what `isEntitled()` returns — the cache
2161
+ * still serves last-known-good. It exists so the staleness is
2162
+ * observable via `diagnostics()` instead of an unbounded silent
2163
+ * window where a revoked customer (event-based revoke, no
2164
+ * `validUntil`) holds access with nobody able to see it.
2165
+ *
2166
+ * Returns false for an unknown customer — nothing cached, nothing
2167
+ * stale.
2168
+ */
2169
+ isStale(customerId) {
2170
+ const entry = this.byCustomer.get(customerId);
2171
+ if (!entry) return false;
2172
+ return this.entryIsStale(entry);
2173
+ }
2174
+ /** Epoch ms of a customer's last failed refresh, or 0 if none / unknown. */
2175
+ refreshFailedAt(customerId) {
2176
+ return this.byCustomer.get(customerId)?.refreshFailedAt ?? 0;
2177
+ }
1918
2178
  /**
1919
2179
  * Drop a single customer's entry. Fires listeners with an empty
1920
2180
  * list so subscribers know that customer's cache is gone.
@@ -1980,7 +2240,57 @@ var EntitlementCache = class {
1980
2240
  get maxSize() {
1981
2241
  return this.maxCustomers;
1982
2242
  }
2243
+ /** Configured staleness window in ms. */
2244
+ get staleWindowMs() {
2245
+ return this.staleAfterMs;
2246
+ }
2247
+ /**
2248
+ * Count of cached customers currently flagged stale — most recent
2249
+ * refresh failed, or data aged past `staleAfterMs`. The cache keeps
2250
+ * serving last-known-good for them; this is the observability number
2251
+ * `diagnostics()` surfaces.
2252
+ */
2253
+ get staleCustomerCount() {
2254
+ let count = 0;
2255
+ for (const entry of this.byCustomer.values()) {
2256
+ if (this.entryIsStale(entry)) count += 1;
2257
+ }
2258
+ return count;
2259
+ }
2260
+ /** Whether ANY cached customer is currently stale. */
2261
+ get isAnyStale() {
2262
+ for (const entry of this.byCustomer.values()) {
2263
+ if (this.entryIsStale(entry)) return true;
2264
+ }
2265
+ return false;
2266
+ }
2267
+ /**
2268
+ * Most recent failed-refresh timestamp across all customers (epoch
2269
+ * ms), or 0 if every cached customer's last refresh succeeded.
2270
+ */
2271
+ get lastRefreshFailedAt() {
2272
+ let max = 0;
2273
+ for (const entry of this.byCustomer.values()) {
2274
+ if (entry.refreshFailedAt > max) max = entry.refreshFailedAt;
2275
+ }
2276
+ return max;
2277
+ }
1983
2278
  // ---------- internals ----------
2279
+ /**
2280
+ * Stale iff the entry's most recent refresh attempt failed, OR its
2281
+ * last-known-good has aged past `staleAfterMs`.
2282
+ *
2283
+ * `refreshFailedAt` is non-zero ONLY between a failed refresh and the
2284
+ * next successful one (`setForCustomer` zeroes it), so `> 0` alone
2285
+ * means "a failure occurred since the last success" — no need to
2286
+ * compare against `populatedAt`, which would mis-fire when a failure
2287
+ * and a populate land in the same millisecond. A marker-only stub
2288
+ * (populatedAt 0, failure stamped) is stale via this first clause.
2289
+ */
2290
+ entryIsStale(entry) {
2291
+ if (entry.refreshFailedAt > 0) return true;
2292
+ return entry.populatedAt > 0 && Date.now() - entry.populatedAt > this.staleAfterMs;
2293
+ }
1984
2294
  notify(customerId, snapshot) {
1985
2295
  if (this.listeners.size === 0) return;
1986
2296
  const snap = snapshot.slice();
@@ -2071,6 +2381,16 @@ var CrossdeckServer = class extends EventEmitter {
2071
2381
  flushOnExit;
2072
2382
  superProps;
2073
2383
  entitlementCache;
2384
+ /**
2385
+ * Optional developer-supplied durable store for last-known-good
2386
+ * entitlements (Redis / their DB / a KV). `undefined` when not
2387
+ * configured — the SDK then has no cold-start durability on
2388
+ * serverless, which it states explicitly at boot.
2389
+ *
2390
+ * Touched ONLY from the async `getEntitlements()` — never from the
2391
+ * synchronous `isEntitled()`.
2392
+ */
2393
+ entitlementStore;
2074
2394
  debug;
2075
2395
  /**
2076
2396
  * Alias map — `developerUserId` / `anonymousId` → canonical
@@ -2124,8 +2444,10 @@ var CrossdeckServer = class extends EventEmitter {
2124
2444
  this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
2125
2445
  this.superProps = new SuperPropertyStore();
2126
2446
  this.entitlementCache = new EntitlementCache({
2127
- ttlMs: options.entitlementCacheTtlMs ?? 6e4
2447
+ ttlMs: options.entitlementCacheTtlMs ?? 6e4,
2448
+ staleAfterMs: options.entitlementStaleAfterMs
2128
2449
  });
2450
+ this.entitlementStore = options.entitlementStore ?? null;
2129
2451
  this.debug = options.debug === true ? new ConsoleDebugLogger() : new NullDebugLogger();
2130
2452
  if (options.debug === true) this.debug.enabled = true;
2131
2453
  this.debug.emit(
@@ -2201,7 +2523,60 @@ var CrossdeckServer = class extends EventEmitter {
2201
2523
  { message: err instanceof Error ? err.message : String(err) }
2202
2524
  );
2203
2525
  });
2526
+ this.emitBootTelemetry();
2527
+ });
2528
+ }
2529
+ }
2530
+ /**
2531
+ * Emit the one-time `sdk.boot` telemetry event and, when the runtime
2532
+ * is serverless with no `entitlementStore`, the honest "no cold-start
2533
+ * durability" warning.
2534
+ *
2535
+ * Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
2536
+ * carries no request body, so it cannot transport a structured
2537
+ * `durability` fact. The event pipeline can — every `track()` event
2538
+ * lands as an aggregatable document the backend can query, so
2539
+ * Crossdeck can compute fleet-wide "% serverless-with-no-durable-
2540
+ * store" from `sdk.boot` events (denominator = all `sdk.boot`,
2541
+ * numerator = those with `durability.coldStartDurable === false`).
2542
+ * The event rides the existing batched + retried + idempotent queue
2543
+ * and is drained by flush-on-exit, so it survives a serverless
2544
+ * teardown — it is NOT a local-only debug log.
2545
+ *
2546
+ * `isServerless` AND no store is the gap: a cold start begins with an
2547
+ * empty in-memory cache and a brief Crossdeck outage in that window
2548
+ * would read a paying customer as un-entitled. That gap is
2549
+ * unavoidable without a store — so the SDK STATES it (a
2550
+ * `sdk.no_durable_store` debug warning) rather than hiding it.
2551
+ *
2552
+ * Called once, from the deferred boot block — so it inherits the
2553
+ * `testMode` / `bootHeartbeat:false` opt-outs and never fires before
2554
+ * the constructor returns.
2555
+ */
2556
+ emitBootTelemetry() {
2557
+ const isServerless = this.runtime.isServerless;
2558
+ const hasStore = this.entitlementStore !== null;
2559
+ const coldStartDurable = hasStore || !isServerless;
2560
+ if (isServerless && !hasStore) {
2561
+ this.debug.emit(
2562
+ "sdk.no_durable_store",
2563
+ `Running on a serverless host (${this.runtime.host}) with no entitlementStore. The entitlement cache is in-memory only, so a cold start begins empty: if Crossdeck is briefly unreachable during that window, isEntitled() can read a paying customer as un-entitled. Wire \`entitlementStore\` (Redis / your DB / a KV) to close this gap.`,
2564
+ { host: this.runtime.host, isServerless, durableStore: false }
2565
+ );
2566
+ }
2567
+ try {
2568
+ this.track({
2569
+ name: "sdk.boot",
2570
+ anonymousId: this.processAnonymousId,
2571
+ properties: {
2572
+ "durability.entitlementStore": hasStore,
2573
+ "durability.coldStartDurable": coldStartDurable,
2574
+ "durability.runtimeIsServerless": isServerless,
2575
+ "durability.runtimeHost": this.runtime.host,
2576
+ "durability.entitlementCacheTtlMs": this.entitlementCache.ttl
2577
+ }
2204
2578
  });
2579
+ } catch {
2205
2580
  }
2206
2581
  }
2207
2582
  // ============================================================
@@ -2259,13 +2634,77 @@ var CrossdeckServer = class extends EventEmitter {
2259
2634
  // alias so a subsequent `isEntitled({ userId }, "pro")` resolves
2260
2635
  // to the same cache entry.
2261
2636
  // ============================================================
2637
+ /**
2638
+ * Fetch a customer's entitlements from Crossdeck and warm the cache.
2639
+ *
2640
+ * Durability — this is where last-known-good lives, NOT in the
2641
+ * synchronous `isEntitled()`:
2642
+ * - On a SUCCESSFUL fetch: the entitlement cache is populated and,
2643
+ * if an `entitlementStore` is configured, the result is persisted
2644
+ * to it (`await store.save(...)`). The cache + store now hold
2645
+ * server-confirmed truth.
2646
+ * - On a network FAILURE: the cache is marked refresh-failed for the
2647
+ * customer (so `diagnostics()` shows the staleness), then — if a
2648
+ * store is configured — last-known-good is loaded back from it
2649
+ * (`await store.load(...)`). If the store yields a snapshot, the
2650
+ * cache is populated from it and that snapshot is RETURNED as a
2651
+ * normal `EntitlementsListResponse` — a cold-start / outage no
2652
+ * longer fails a paying customer. If there is no store, or the
2653
+ * store is empty, the network error is rethrown unchanged so the
2654
+ * caller still sees the failure.
2655
+ *
2656
+ * The store is touched only here, inside the `await` that already
2657
+ * existed. `isEntitled()` remains a pure synchronous `Map` read.
2658
+ */
2262
2659
  async getEntitlements(hints, options) {
2263
- const response = await this.http.request("GET", "/entitlements", {
2264
- query: this.identityPayload(hints),
2265
- signal: options?.signal,
2266
- timeoutMs: options?.timeoutMs
2267
- });
2660
+ let response;
2661
+ try {
2662
+ response = await this.http.request("GET", "/entitlements", {
2663
+ query: this.identityPayload(hints),
2664
+ signal: options?.signal,
2665
+ timeoutMs: options?.timeoutMs
2666
+ });
2667
+ } catch (err) {
2668
+ const failedCustomerId = this.resolveFailedRefreshCustomerId(hints);
2669
+ if (failedCustomerId) {
2670
+ this.entitlementCache.markRefreshFailed(failedCustomerId);
2671
+ }
2672
+ const recovered = await this.loadEntitlementsFromStore(hints);
2673
+ if (recovered) {
2674
+ const recoveredResponse = {
2675
+ object: "list",
2676
+ data: recovered.entitlements,
2677
+ crossdeckCustomerId: recovered.crossdeckCustomerId,
2678
+ env: recovered.env
2679
+ };
2680
+ this.populateEntitlementCache(hints, recoveredResponse);
2681
+ this.entitlementCache.markRefreshFailed(recovered.crossdeckCustomerId);
2682
+ this.debug.emit(
2683
+ "sdk.entitlement_store_recovered",
2684
+ `Crossdeck unreachable \u2014 served ${recovered.crossdeckCustomerId} from the durable store (${recovered.entitlements.length} entitlement(s), last refreshed ${new Date(recovered.savedAt).toISOString()}).`,
2685
+ {
2686
+ customerId: recovered.crossdeckCustomerId,
2687
+ savedAt: recovered.savedAt,
2688
+ error: err instanceof Error ? err.message : String(err)
2689
+ }
2690
+ );
2691
+ return recoveredResponse;
2692
+ }
2693
+ if (failedCustomerId && this.entitlementCache.isStale(failedCustomerId)) {
2694
+ this.debug.emit(
2695
+ "sdk.entitlement_cache_stale",
2696
+ `Crossdeck unreachable \u2014 entitlement cache for ${failedCustomerId} is now stale. ` + (this.entitlementStore ? "No durable snapshot was available to recover from." : "No entitlementStore is configured, so there is no durable fallback.") + " isEntitled() keeps serving last-known-good; staleness is visible in diagnostics().",
2697
+ {
2698
+ customerId: failedCustomerId,
2699
+ durableStore: this.entitlementStore !== null,
2700
+ error: err instanceof Error ? err.message : String(err)
2701
+ }
2702
+ );
2703
+ }
2704
+ throw err;
2705
+ }
2268
2706
  this.populateEntitlementCache(hints, response);
2707
+ await this.saveEntitlementsToStore(hints, response);
2269
2708
  return response;
2270
2709
  }
2271
2710
  async getCustomerEntitlements(customerId, options) {
@@ -2779,7 +3218,14 @@ var CrossdeckServer = class extends EventEmitter {
2779
3218
  count: this.entitlementCache.customerCount,
2780
3219
  lastUpdated: this.entitlementCache.lastUpdated,
2781
3220
  ttlMs: this.entitlementCache.ttl,
2782
- listenerErrors: this.entitlementCache.listenerErrors
3221
+ listenerErrors: this.entitlementCache.listenerErrors,
3222
+ staleCustomers: this.entitlementCache.staleCustomerCount,
3223
+ isStale: this.entitlementCache.isAnyStale,
3224
+ lastRefreshFailedAt: this.entitlementCache.lastRefreshFailedAt,
3225
+ durableStore: this.entitlementStore !== null,
3226
+ // Cold-start durable iff a store is wired, OR the host is
3227
+ // long-lived (the process, hence the in-memory cache, survives).
3228
+ coldStartDurable: this.entitlementStore !== null || !this.runtime.isServerless
2783
3229
  },
2784
3230
  events: this.eventQueue.getStats(),
2785
3231
  errors: {
@@ -2993,6 +3439,90 @@ var CrossdeckServer = class extends EventEmitter {
2993
3439
  } catch {
2994
3440
  }
2995
3441
  }
3442
+ /**
3443
+ * Persist a successful entitlements fetch to the durable store, if
3444
+ * one is configured. No-op when there is no store.
3445
+ *
3446
+ * Saved under EVERY identity the caller might later look up by — the
3447
+ * canonical `crossdeckCustomerId` plus any `userId` / `anonymousId`
3448
+ * hint. The Node cache resolves a hint to a canonical ID via an
3449
+ * in-memory alias map; on a cold start that map is empty, so a
3450
+ * failure-path `load()` must be able to hit the store with the raw
3451
+ * hint the caller passed. Saving under all keys makes that work.
3452
+ *
3453
+ * Best-effort: a store `save()` that throws is swallowed (logged in
3454
+ * debug) — it weakens durability for that customer but must never
3455
+ * fail an otherwise-successful `getEntitlements()`.
3456
+ */
3457
+ async saveEntitlementsToStore(hints, response) {
3458
+ if (!this.entitlementStore) return;
3459
+ const customerId = response.crossdeckCustomerId;
3460
+ if (!customerId) return;
3461
+ const snapshot = {
3462
+ v: 1,
3463
+ crossdeckCustomerId: customerId,
3464
+ entitlements: response.data,
3465
+ env: response.env,
3466
+ savedAt: Date.now()
3467
+ };
3468
+ const keys = /* @__PURE__ */ new Set([customerId]);
3469
+ if (hints.customerId) keys.add(hints.customerId);
3470
+ if (hints.userId) keys.add(hints.userId);
3471
+ if (hints.anonymousId) keys.add(hints.anonymousId);
3472
+ for (const key of keys) {
3473
+ try {
3474
+ await this.entitlementStore.save(key, snapshot);
3475
+ } catch (err) {
3476
+ this.debug.emit(
3477
+ "sdk.entitlement_store_recovered",
3478
+ `entitlementStore.save failed for key ${key} \u2014 durability weakened for this customer.`,
3479
+ { key, error: err instanceof Error ? err.message : String(err) }
3480
+ );
3481
+ }
3482
+ }
3483
+ }
3484
+ /**
3485
+ * Load last-known-good entitlements from the durable store on a
3486
+ * network-failure path. Returns the first snapshot found across the
3487
+ * caller's identity keys, or `null` if there is no store / no stored
3488
+ * snapshot / every read failed.
3489
+ *
3490
+ * Tries the canonical `customerId` hint first, then `userId`, then
3491
+ * `anonymousId` — the order callers most commonly key by. A corrupt
3492
+ * or wrong-shaped blob is treated as a miss (the store is developer-
3493
+ * supplied; the SDK validates rather than trusts).
3494
+ */
3495
+ async loadEntitlementsFromStore(hints) {
3496
+ if (!this.entitlementStore) return null;
3497
+ const keys = [];
3498
+ if (hints.customerId) keys.push(hints.customerId);
3499
+ if (hints.userId) keys.push(hints.userId);
3500
+ if (hints.anonymousId) keys.push(hints.anonymousId);
3501
+ for (const key of keys) {
3502
+ let loaded = null;
3503
+ try {
3504
+ loaded = await this.entitlementStore.load(key);
3505
+ } catch {
3506
+ continue;
3507
+ }
3508
+ if (isValidStoredEntitlements(loaded)) return loaded;
3509
+ }
3510
+ return null;
3511
+ }
3512
+ /**
3513
+ * Resolve the customer ID to stamp a failed-refresh marker against.
3514
+ *
3515
+ * Prefers a canonical ID the cache already knows (so the marker lands
3516
+ * on the existing warm entry), then falls back to whatever raw hint
3517
+ * the caller supplied — on a true cold-start failure there is no
3518
+ * cache entry yet, and marking under the hint still makes "we tried
3519
+ * for this customer and Crossdeck was down" observable.
3520
+ */
3521
+ resolveFailedRefreshCustomerId(hints) {
3522
+ const known = this.resolveCacheCustomerId(hints);
3523
+ if (known) return known;
3524
+ return hints.customerId ?? hints.userId ?? hints.anonymousId ?? null;
3525
+ }
2996
3526
  touchAlias(alias, customerId) {
2997
3527
  this.customerIdAliases.delete(alias);
2998
3528
  this.customerIdAliases.set(alias, customerId);
@@ -3094,6 +3624,11 @@ function sanitizePropertyBag(input, fieldName) {
3094
3624
  });
3095
3625
  }
3096
3626
  }
3627
+ function isValidStoredEntitlements(value) {
3628
+ if (typeof value !== "object" || value === null) return false;
3629
+ const v = value;
3630
+ return v.v === 1 && typeof v.crossdeckCustomerId === "string" && v.crossdeckCustomerId.length > 0 && Array.isArray(v.entitlements) && (v.env === "production" || v.env === "sandbox") && typeof v.savedAt === "number";
3631
+ }
3097
3632
  function categoryFor(name) {
3098
3633
  if (name.startsWith("page.") || name.startsWith("navigation.")) return "navigation";
3099
3634
  if (name.startsWith("element.") || name.startsWith("ui.click")) return "ui.click";