@cross-deck/node 1.1.1 → 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/CHANGELOG.md +38 -0
- package/dist/auto-events/index.d.mts +1 -1
- package/dist/auto-events/index.d.ts +1 -1
- package/dist/{crossdeck-server-BXQaFjVx.d.mts → crossdeck-server-BZVZEuS-.d.mts} +303 -20
- package/dist/{crossdeck-server-BXQaFjVx.d.ts → crossdeck-server-BZVZEuS-.d.ts} +303 -20
- package/dist/index.cjs +421 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.mjs +421 -28
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -365,7 +365,7 @@ function byteLength(s) {
|
|
|
365
365
|
|
|
366
366
|
// src/http.ts
|
|
367
367
|
var SDK_NAME = "@cross-deck/node";
|
|
368
|
-
var SDK_VERSION = "1.
|
|
368
|
+
var SDK_VERSION = "1.2.0";
|
|
369
369
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
370
370
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
371
371
|
var CROSSDECK_API_VERSION = "2025-01-01";
|
|
@@ -1621,6 +1621,16 @@ function safeStringify3(v) {
|
|
|
1621
1621
|
|
|
1622
1622
|
// src/runtime-info.ts
|
|
1623
1623
|
var import_node_os = require("os");
|
|
1624
|
+
var SERVERLESS_HOSTS = /* @__PURE__ */ new Set([
|
|
1625
|
+
"aws-lambda",
|
|
1626
|
+
"azure-functions",
|
|
1627
|
+
"google-app-engine",
|
|
1628
|
+
"firebase-functions-v1",
|
|
1629
|
+
"firebase-functions-v2",
|
|
1630
|
+
"cloud-run",
|
|
1631
|
+
"vercel",
|
|
1632
|
+
"netlify"
|
|
1633
|
+
]);
|
|
1624
1634
|
var cached = null;
|
|
1625
1635
|
function collectRuntimeInfo(options = {}) {
|
|
1626
1636
|
if (cached) return cached;
|
|
@@ -1636,6 +1646,7 @@ function detect(options) {
|
|
|
1636
1646
|
platformRelease: safeRelease(),
|
|
1637
1647
|
hostname: safeHostname(),
|
|
1638
1648
|
host: detected.host,
|
|
1649
|
+
isServerless: SERVERLESS_HOSTS.has(detected.host),
|
|
1639
1650
|
region: detected.region,
|
|
1640
1651
|
serviceName: options.serviceName ?? detected.serviceName,
|
|
1641
1652
|
serviceVersion: options.serviceVersion ?? detected.serviceVersion,
|
|
@@ -2039,9 +2050,11 @@ var SuperPropertyStore = class {
|
|
|
2039
2050
|
|
|
2040
2051
|
// src/entitlement-cache.ts
|
|
2041
2052
|
var DEFAULT_MAX_CUSTOMERS = 1e4;
|
|
2053
|
+
var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
|
|
2042
2054
|
var EntitlementCache = class {
|
|
2043
2055
|
ttlMs;
|
|
2044
2056
|
maxCustomers;
|
|
2057
|
+
staleAfterMs;
|
|
2045
2058
|
byCustomer = /* @__PURE__ */ new Map();
|
|
2046
2059
|
listeners = /* @__PURE__ */ new Set();
|
|
2047
2060
|
listenerErrorCount = 0;
|
|
@@ -2049,52 +2062,95 @@ var EntitlementCache = class {
|
|
|
2049
2062
|
constructor(options = {}) {
|
|
2050
2063
|
this.ttlMs = options.ttlMs ?? 6e4;
|
|
2051
2064
|
this.maxCustomers = options.maxCustomers ?? DEFAULT_MAX_CUSTOMERS;
|
|
2065
|
+
this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
2052
2066
|
}
|
|
2053
2067
|
/**
|
|
2054
|
-
* Synchronous lookup
|
|
2055
|
-
* entitlement
|
|
2056
|
-
*
|
|
2068
|
+
* Synchronous lookup — true iff the customer currently has the
|
|
2069
|
+
* entitlement granting access. Pure in-memory `Map` read, ZERO I/O.
|
|
2070
|
+
*
|
|
2071
|
+
* Served from last-known-good: a stale entry (Crossdeck unreachable
|
|
2072
|
+
* since the last successful fetch, or past `ttlMs`) STILL answers true
|
|
2073
|
+
* for a still-valid entitlement. Cache staleness alone never makes
|
|
2074
|
+
* this `false` — the central durability fix. The only things that
|
|
2075
|
+
* turn it false:
|
|
2076
|
+
* - the customer has no cached entry at all (genuine cold miss)
|
|
2077
|
+
* - no matching `key` in the customer's entitlement set
|
|
2078
|
+
* - the matching entitlement is `isActive: false`
|
|
2079
|
+
* - the matching entitlement is past its OWN `validUntil` — a
|
|
2080
|
+
* time-based trial expiry still applies mid-outage (mirrors the
|
|
2081
|
+
* web SDK's `validUntil` check exactly).
|
|
2082
|
+
*
|
|
2083
|
+
* An entry being past `ttlMs`, or marked refresh-failed, does NOT
|
|
2084
|
+
* affect the answer — `getEntitlements()` re-fetches on the TTL hint,
|
|
2085
|
+
* but until it succeeds the customer keeps their access.
|
|
2057
2086
|
*/
|
|
2058
2087
|
isEntitled(customerId, key) {
|
|
2059
2088
|
const entry = this.byCustomer.get(customerId);
|
|
2060
2089
|
if (!entry) return false;
|
|
2061
|
-
|
|
2062
|
-
return entry.
|
|
2090
|
+
const nowSec = Date.now() / 1e3;
|
|
2091
|
+
return entry.all.some(
|
|
2092
|
+
(e) => e.key === key && e.isActive && (e.validUntil == null || e.validUntil > nowSec)
|
|
2093
|
+
);
|
|
2063
2094
|
}
|
|
2064
2095
|
/**
|
|
2065
|
-
* Full snapshot for callers that need source / validUntil.
|
|
2066
|
-
* `[]` when the customer has no cached entry
|
|
2067
|
-
*
|
|
2096
|
+
* Full snapshot for callers that need source / validUntil details.
|
|
2097
|
+
* Returns `[]` ONLY when the customer has no cached entry — a stale
|
|
2098
|
+
* or past-TTL entry still returns its last-known-good entitlements
|
|
2099
|
+
* (same durability posture as `isEntitled()`; per-entitlement
|
|
2100
|
+
* `validUntil` is the caller's to honour from the returned objects).
|
|
2068
2101
|
*/
|
|
2069
2102
|
list(customerId) {
|
|
2070
2103
|
const entry = this.byCustomer.get(customerId);
|
|
2071
2104
|
if (!entry) return [];
|
|
2072
|
-
if (Date.now() > entry.expiresAt) return [];
|
|
2073
2105
|
return entry.all.slice();
|
|
2074
2106
|
}
|
|
2075
2107
|
/**
|
|
2076
|
-
* Whether
|
|
2077
|
-
* deciding whether to warm before
|
|
2108
|
+
* Whether the customer's entry is still within `ttlMs` — i.e. a
|
|
2109
|
+
* re-fetch is NOT yet due. Useful for deciding whether to warm before
|
|
2110
|
+
* a hot path. A `false` result does NOT mean the cache is empty or
|
|
2111
|
+
* that `isEntitled()` will return false — it only means the data is
|
|
2112
|
+
* past its refresh hint. See `needsRefresh()` for the inverse.
|
|
2078
2113
|
*/
|
|
2079
2114
|
isFresh(customerId) {
|
|
2080
2115
|
const entry = this.byCustomer.get(customerId);
|
|
2081
|
-
return Boolean(entry && Date.now() <= entry.
|
|
2116
|
+
return Boolean(entry && Date.now() <= entry.refreshDueAt);
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Whether the customer should be re-fetched: either there is no
|
|
2120
|
+
* cached entry, or the entry is past its `ttlMs` refresh hint, or the
|
|
2121
|
+
* most recent refresh attempt for them failed (retry it).
|
|
2122
|
+
*
|
|
2123
|
+
* This is purely advisory — `getEntitlements()` decides when to act
|
|
2124
|
+
* on it. It NEVER gates `isEntitled()`, which serves last-known-good
|
|
2125
|
+
* regardless.
|
|
2126
|
+
*/
|
|
2127
|
+
needsRefresh(customerId) {
|
|
2128
|
+
const entry = this.byCustomer.get(customerId);
|
|
2129
|
+
if (!entry) return true;
|
|
2130
|
+
if (entry.refreshFailedAt > 0) return true;
|
|
2131
|
+
return Date.now() > entry.refreshDueAt;
|
|
2082
2132
|
}
|
|
2083
2133
|
/**
|
|
2084
|
-
* Replace (or insert) the cache entry for a customer
|
|
2085
|
-
* `
|
|
2086
|
-
*
|
|
2087
|
-
*
|
|
2088
|
-
*
|
|
2134
|
+
* Replace (or insert) the cache entry for a customer with a fresh
|
|
2135
|
+
* server response. Sets `refreshDueAt` to `now + ttlMs` and CLEARS
|
|
2136
|
+
* any failed-refresh marker — a success ends the stale state.
|
|
2137
|
+
*
|
|
2138
|
+
* Called ONLY after a SUCCESSFUL server read — a failed fetch is
|
|
2139
|
+
* routed to `markRefreshFailed` instead and never reaches here, so
|
|
2140
|
+
* last-known-good is preserved through an outage.
|
|
2141
|
+
*
|
|
2142
|
+
* Re-inserting an existing customerId "touches" it — the entry moves
|
|
2143
|
+
* to the end of insertion order (Map semantics) so it's treated as
|
|
2144
|
+
* most-recently-used for LRU eviction. Fires listeners.
|
|
2089
2145
|
*/
|
|
2090
2146
|
setForCustomer(customerId, entitlements) {
|
|
2091
2147
|
const now = Date.now();
|
|
2092
2148
|
this.byCustomer.delete(customerId);
|
|
2093
2149
|
this.byCustomer.set(customerId, {
|
|
2094
2150
|
all: entitlements.slice(),
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2151
|
+
refreshDueAt: now + this.ttlMs,
|
|
2152
|
+
populatedAt: now,
|
|
2153
|
+
refreshFailedAt: 0
|
|
2098
2154
|
});
|
|
2099
2155
|
while (this.byCustomer.size > this.maxCustomers) {
|
|
2100
2156
|
const oldestKey = this.byCustomer.keys().next().value;
|
|
@@ -2104,6 +2160,68 @@ var EntitlementCache = class {
|
|
|
2104
2160
|
}
|
|
2105
2161
|
this.notify(customerId, entitlements);
|
|
2106
2162
|
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Record that a refresh attempt for a customer FAILED (Crossdeck
|
|
2165
|
+
* unreachable / transient error). `getEntitlements()` calls this in
|
|
2166
|
+
* its catch path.
|
|
2167
|
+
*
|
|
2168
|
+
* It does NOT touch the customer's cached entitlements — last-known-
|
|
2169
|
+
* good keeps serving — it only stamps `refreshFailedAt` so the
|
|
2170
|
+
* customer shows up as stale in `diagnostics()` rather than the
|
|
2171
|
+
* staleness being a silent unbounded window.
|
|
2172
|
+
*
|
|
2173
|
+
* If the customer has no entry yet (a genuine cold miss whose first
|
|
2174
|
+
* fetch failed) a stub entry with no entitlements is created purely
|
|
2175
|
+
* to carry the failed-refresh marker — so "we tried and Crossdeck was
|
|
2176
|
+
* down" is observable even before any successful warm. The stub holds
|
|
2177
|
+
* an empty entitlement set, so `isEntitled()` still correctly returns
|
|
2178
|
+
* false for it; there is genuinely nothing to serve.
|
|
2179
|
+
*/
|
|
2180
|
+
markRefreshFailed(customerId) {
|
|
2181
|
+
const now = Date.now();
|
|
2182
|
+
const entry = this.byCustomer.get(customerId);
|
|
2183
|
+
if (entry) {
|
|
2184
|
+
entry.refreshFailedAt = now;
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
this.byCustomer.set(customerId, {
|
|
2188
|
+
all: [],
|
|
2189
|
+
refreshDueAt: now + this.ttlMs,
|
|
2190
|
+
populatedAt: 0,
|
|
2191
|
+
refreshFailedAt: now
|
|
2192
|
+
});
|
|
2193
|
+
while (this.byCustomer.size > this.maxCustomers) {
|
|
2194
|
+
const oldestKey = this.byCustomer.keys().next().value;
|
|
2195
|
+
if (oldestKey === void 0) break;
|
|
2196
|
+
this.byCustomer.delete(oldestKey);
|
|
2197
|
+
this.evicted += 1;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Whether a customer is knowingly serving older-than-trustworthy
|
|
2202
|
+
* data. True when the most recent refresh ATTEMPT for them failed
|
|
2203
|
+
* (Crossdeck unreachable since the last success — the outage case,
|
|
2204
|
+
* distinct from a benign idle customer simply past `ttlMs`), OR when
|
|
2205
|
+
* their last-known-good has aged past `staleAfterMs`.
|
|
2206
|
+
*
|
|
2207
|
+
* `isStale` NEVER changes what `isEntitled()` returns — the cache
|
|
2208
|
+
* still serves last-known-good. It exists so the staleness is
|
|
2209
|
+
* observable via `diagnostics()` instead of an unbounded silent
|
|
2210
|
+
* window where a revoked customer (event-based revoke, no
|
|
2211
|
+
* `validUntil`) holds access with nobody able to see it.
|
|
2212
|
+
*
|
|
2213
|
+
* Returns false for an unknown customer — nothing cached, nothing
|
|
2214
|
+
* stale.
|
|
2215
|
+
*/
|
|
2216
|
+
isStale(customerId) {
|
|
2217
|
+
const entry = this.byCustomer.get(customerId);
|
|
2218
|
+
if (!entry) return false;
|
|
2219
|
+
return this.entryIsStale(entry);
|
|
2220
|
+
}
|
|
2221
|
+
/** Epoch ms of a customer's last failed refresh, or 0 if none / unknown. */
|
|
2222
|
+
refreshFailedAt(customerId) {
|
|
2223
|
+
return this.byCustomer.get(customerId)?.refreshFailedAt ?? 0;
|
|
2224
|
+
}
|
|
2107
2225
|
/**
|
|
2108
2226
|
* Drop a single customer's entry. Fires listeners with an empty
|
|
2109
2227
|
* list so subscribers know that customer's cache is gone.
|
|
@@ -2169,7 +2287,57 @@ var EntitlementCache = class {
|
|
|
2169
2287
|
get maxSize() {
|
|
2170
2288
|
return this.maxCustomers;
|
|
2171
2289
|
}
|
|
2290
|
+
/** Configured staleness window in ms. */
|
|
2291
|
+
get staleWindowMs() {
|
|
2292
|
+
return this.staleAfterMs;
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Count of cached customers currently flagged stale — most recent
|
|
2296
|
+
* refresh failed, or data aged past `staleAfterMs`. The cache keeps
|
|
2297
|
+
* serving last-known-good for them; this is the observability number
|
|
2298
|
+
* `diagnostics()` surfaces.
|
|
2299
|
+
*/
|
|
2300
|
+
get staleCustomerCount() {
|
|
2301
|
+
let count = 0;
|
|
2302
|
+
for (const entry of this.byCustomer.values()) {
|
|
2303
|
+
if (this.entryIsStale(entry)) count += 1;
|
|
2304
|
+
}
|
|
2305
|
+
return count;
|
|
2306
|
+
}
|
|
2307
|
+
/** Whether ANY cached customer is currently stale. */
|
|
2308
|
+
get isAnyStale() {
|
|
2309
|
+
for (const entry of this.byCustomer.values()) {
|
|
2310
|
+
if (this.entryIsStale(entry)) return true;
|
|
2311
|
+
}
|
|
2312
|
+
return false;
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Most recent failed-refresh timestamp across all customers (epoch
|
|
2316
|
+
* ms), or 0 if every cached customer's last refresh succeeded.
|
|
2317
|
+
*/
|
|
2318
|
+
get lastRefreshFailedAt() {
|
|
2319
|
+
let max = 0;
|
|
2320
|
+
for (const entry of this.byCustomer.values()) {
|
|
2321
|
+
if (entry.refreshFailedAt > max) max = entry.refreshFailedAt;
|
|
2322
|
+
}
|
|
2323
|
+
return max;
|
|
2324
|
+
}
|
|
2172
2325
|
// ---------- internals ----------
|
|
2326
|
+
/**
|
|
2327
|
+
* Stale iff the entry's most recent refresh attempt failed, OR its
|
|
2328
|
+
* last-known-good has aged past `staleAfterMs`.
|
|
2329
|
+
*
|
|
2330
|
+
* `refreshFailedAt` is non-zero ONLY between a failed refresh and the
|
|
2331
|
+
* next successful one (`setForCustomer` zeroes it), so `> 0` alone
|
|
2332
|
+
* means "a failure occurred since the last success" — no need to
|
|
2333
|
+
* compare against `populatedAt`, which would mis-fire when a failure
|
|
2334
|
+
* and a populate land in the same millisecond. A marker-only stub
|
|
2335
|
+
* (populatedAt 0, failure stamped) is stale via this first clause.
|
|
2336
|
+
*/
|
|
2337
|
+
entryIsStale(entry) {
|
|
2338
|
+
if (entry.refreshFailedAt > 0) return true;
|
|
2339
|
+
return entry.populatedAt > 0 && Date.now() - entry.populatedAt > this.staleAfterMs;
|
|
2340
|
+
}
|
|
2173
2341
|
notify(customerId, snapshot) {
|
|
2174
2342
|
if (this.listeners.size === 0) return;
|
|
2175
2343
|
const snap = snapshot.slice();
|
|
@@ -2260,6 +2428,16 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2260
2428
|
flushOnExit;
|
|
2261
2429
|
superProps;
|
|
2262
2430
|
entitlementCache;
|
|
2431
|
+
/**
|
|
2432
|
+
* Optional developer-supplied durable store for last-known-good
|
|
2433
|
+
* entitlements (Redis / their DB / a KV). `undefined` when not
|
|
2434
|
+
* configured — the SDK then has no cold-start durability on
|
|
2435
|
+
* serverless, which it states explicitly at boot.
|
|
2436
|
+
*
|
|
2437
|
+
* Touched ONLY from the async `getEntitlements()` — never from the
|
|
2438
|
+
* synchronous `isEntitled()`.
|
|
2439
|
+
*/
|
|
2440
|
+
entitlementStore;
|
|
2263
2441
|
debug;
|
|
2264
2442
|
/**
|
|
2265
2443
|
* Alias map — `developerUserId` / `anonymousId` → canonical
|
|
@@ -2313,8 +2491,10 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2313
2491
|
this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
|
|
2314
2492
|
this.superProps = new SuperPropertyStore();
|
|
2315
2493
|
this.entitlementCache = new EntitlementCache({
|
|
2316
|
-
ttlMs: options.entitlementCacheTtlMs ?? 6e4
|
|
2494
|
+
ttlMs: options.entitlementCacheTtlMs ?? 6e4,
|
|
2495
|
+
staleAfterMs: options.entitlementStaleAfterMs
|
|
2317
2496
|
});
|
|
2497
|
+
this.entitlementStore = options.entitlementStore ?? null;
|
|
2318
2498
|
this.debug = options.debug === true ? new ConsoleDebugLogger() : new NullDebugLogger();
|
|
2319
2499
|
if (options.debug === true) this.debug.enabled = true;
|
|
2320
2500
|
this.debug.emit(
|
|
@@ -2390,7 +2570,60 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2390
2570
|
{ message: err instanceof Error ? err.message : String(err) }
|
|
2391
2571
|
);
|
|
2392
2572
|
});
|
|
2573
|
+
this.emitBootTelemetry();
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Emit the one-time `sdk.boot` telemetry event and, when the runtime
|
|
2579
|
+
* is serverless with no `entitlementStore`, the honest "no cold-start
|
|
2580
|
+
* durability" warning.
|
|
2581
|
+
*
|
|
2582
|
+
* Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
|
|
2583
|
+
* carries no request body, so it cannot transport a structured
|
|
2584
|
+
* `durability` fact. The event pipeline can — every `track()` event
|
|
2585
|
+
* lands as an aggregatable document the backend can query, so
|
|
2586
|
+
* Crossdeck can compute fleet-wide "% serverless-with-no-durable-
|
|
2587
|
+
* store" from `sdk.boot` events (denominator = all `sdk.boot`,
|
|
2588
|
+
* numerator = those with `durability.coldStartDurable === false`).
|
|
2589
|
+
* The event rides the existing batched + retried + idempotent queue
|
|
2590
|
+
* and is drained by flush-on-exit, so it survives a serverless
|
|
2591
|
+
* teardown — it is NOT a local-only debug log.
|
|
2592
|
+
*
|
|
2593
|
+
* `isServerless` AND no store is the gap: a cold start begins with an
|
|
2594
|
+
* empty in-memory cache and a brief Crossdeck outage in that window
|
|
2595
|
+
* would read a paying customer as un-entitled. That gap is
|
|
2596
|
+
* unavoidable without a store — so the SDK STATES it (a
|
|
2597
|
+
* `sdk.no_durable_store` debug warning) rather than hiding it.
|
|
2598
|
+
*
|
|
2599
|
+
* Called once, from the deferred boot block — so it inherits the
|
|
2600
|
+
* `testMode` / `bootHeartbeat:false` opt-outs and never fires before
|
|
2601
|
+
* the constructor returns.
|
|
2602
|
+
*/
|
|
2603
|
+
emitBootTelemetry() {
|
|
2604
|
+
const isServerless = this.runtime.isServerless;
|
|
2605
|
+
const hasStore = this.entitlementStore !== null;
|
|
2606
|
+
const coldStartDurable = hasStore || !isServerless;
|
|
2607
|
+
if (isServerless && !hasStore) {
|
|
2608
|
+
this.debug.emit(
|
|
2609
|
+
"sdk.no_durable_store",
|
|
2610
|
+
`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.`,
|
|
2611
|
+
{ host: this.runtime.host, isServerless, durableStore: false }
|
|
2612
|
+
);
|
|
2613
|
+
}
|
|
2614
|
+
try {
|
|
2615
|
+
this.track({
|
|
2616
|
+
name: "sdk.boot",
|
|
2617
|
+
anonymousId: this.processAnonymousId,
|
|
2618
|
+
properties: {
|
|
2619
|
+
"durability.entitlementStore": hasStore,
|
|
2620
|
+
"durability.coldStartDurable": coldStartDurable,
|
|
2621
|
+
"durability.runtimeIsServerless": isServerless,
|
|
2622
|
+
"durability.runtimeHost": this.runtime.host,
|
|
2623
|
+
"durability.entitlementCacheTtlMs": this.entitlementCache.ttl
|
|
2624
|
+
}
|
|
2393
2625
|
});
|
|
2626
|
+
} catch {
|
|
2394
2627
|
}
|
|
2395
2628
|
}
|
|
2396
2629
|
// ============================================================
|
|
@@ -2448,13 +2681,77 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2448
2681
|
// alias so a subsequent `isEntitled({ userId }, "pro")` resolves
|
|
2449
2682
|
// to the same cache entry.
|
|
2450
2683
|
// ============================================================
|
|
2684
|
+
/**
|
|
2685
|
+
* Fetch a customer's entitlements from Crossdeck and warm the cache.
|
|
2686
|
+
*
|
|
2687
|
+
* Durability — this is where last-known-good lives, NOT in the
|
|
2688
|
+
* synchronous `isEntitled()`:
|
|
2689
|
+
* - On a SUCCESSFUL fetch: the entitlement cache is populated and,
|
|
2690
|
+
* if an `entitlementStore` is configured, the result is persisted
|
|
2691
|
+
* to it (`await store.save(...)`). The cache + store now hold
|
|
2692
|
+
* server-confirmed truth.
|
|
2693
|
+
* - On a network FAILURE: the cache is marked refresh-failed for the
|
|
2694
|
+
* customer (so `diagnostics()` shows the staleness), then — if a
|
|
2695
|
+
* store is configured — last-known-good is loaded back from it
|
|
2696
|
+
* (`await store.load(...)`). If the store yields a snapshot, the
|
|
2697
|
+
* cache is populated from it and that snapshot is RETURNED as a
|
|
2698
|
+
* normal `EntitlementsListResponse` — a cold-start / outage no
|
|
2699
|
+
* longer fails a paying customer. If there is no store, or the
|
|
2700
|
+
* store is empty, the network error is rethrown unchanged so the
|
|
2701
|
+
* caller still sees the failure.
|
|
2702
|
+
*
|
|
2703
|
+
* The store is touched only here, inside the `await` that already
|
|
2704
|
+
* existed. `isEntitled()` remains a pure synchronous `Map` read.
|
|
2705
|
+
*/
|
|
2451
2706
|
async getEntitlements(hints, options) {
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2707
|
+
let response;
|
|
2708
|
+
try {
|
|
2709
|
+
response = await this.http.request("GET", "/entitlements", {
|
|
2710
|
+
query: this.identityPayload(hints),
|
|
2711
|
+
signal: options?.signal,
|
|
2712
|
+
timeoutMs: options?.timeoutMs
|
|
2713
|
+
});
|
|
2714
|
+
} catch (err) {
|
|
2715
|
+
const failedCustomerId = this.resolveFailedRefreshCustomerId(hints);
|
|
2716
|
+
if (failedCustomerId) {
|
|
2717
|
+
this.entitlementCache.markRefreshFailed(failedCustomerId);
|
|
2718
|
+
}
|
|
2719
|
+
const recovered = await this.loadEntitlementsFromStore(hints);
|
|
2720
|
+
if (recovered) {
|
|
2721
|
+
const recoveredResponse = {
|
|
2722
|
+
object: "list",
|
|
2723
|
+
data: recovered.entitlements,
|
|
2724
|
+
crossdeckCustomerId: recovered.crossdeckCustomerId,
|
|
2725
|
+
env: recovered.env
|
|
2726
|
+
};
|
|
2727
|
+
this.populateEntitlementCache(hints, recoveredResponse);
|
|
2728
|
+
this.entitlementCache.markRefreshFailed(recovered.crossdeckCustomerId);
|
|
2729
|
+
this.debug.emit(
|
|
2730
|
+
"sdk.entitlement_store_recovered",
|
|
2731
|
+
`Crossdeck unreachable \u2014 served ${recovered.crossdeckCustomerId} from the durable store (${recovered.entitlements.length} entitlement(s), last refreshed ${new Date(recovered.savedAt).toISOString()}).`,
|
|
2732
|
+
{
|
|
2733
|
+
customerId: recovered.crossdeckCustomerId,
|
|
2734
|
+
savedAt: recovered.savedAt,
|
|
2735
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2736
|
+
}
|
|
2737
|
+
);
|
|
2738
|
+
return recoveredResponse;
|
|
2739
|
+
}
|
|
2740
|
+
if (failedCustomerId && this.entitlementCache.isStale(failedCustomerId)) {
|
|
2741
|
+
this.debug.emit(
|
|
2742
|
+
"sdk.entitlement_cache_stale",
|
|
2743
|
+
`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().",
|
|
2744
|
+
{
|
|
2745
|
+
customerId: failedCustomerId,
|
|
2746
|
+
durableStore: this.entitlementStore !== null,
|
|
2747
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2748
|
+
}
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2751
|
+
throw err;
|
|
2752
|
+
}
|
|
2457
2753
|
this.populateEntitlementCache(hints, response);
|
|
2754
|
+
await this.saveEntitlementsToStore(hints, response);
|
|
2458
2755
|
return response;
|
|
2459
2756
|
}
|
|
2460
2757
|
async getCustomerEntitlements(customerId, options) {
|
|
@@ -2968,7 +3265,14 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2968
3265
|
count: this.entitlementCache.customerCount,
|
|
2969
3266
|
lastUpdated: this.entitlementCache.lastUpdated,
|
|
2970
3267
|
ttlMs: this.entitlementCache.ttl,
|
|
2971
|
-
listenerErrors: this.entitlementCache.listenerErrors
|
|
3268
|
+
listenerErrors: this.entitlementCache.listenerErrors,
|
|
3269
|
+
staleCustomers: this.entitlementCache.staleCustomerCount,
|
|
3270
|
+
isStale: this.entitlementCache.isAnyStale,
|
|
3271
|
+
lastRefreshFailedAt: this.entitlementCache.lastRefreshFailedAt,
|
|
3272
|
+
durableStore: this.entitlementStore !== null,
|
|
3273
|
+
// Cold-start durable iff a store is wired, OR the host is
|
|
3274
|
+
// long-lived (the process, hence the in-memory cache, survives).
|
|
3275
|
+
coldStartDurable: this.entitlementStore !== null || !this.runtime.isServerless
|
|
2972
3276
|
},
|
|
2973
3277
|
events: this.eventQueue.getStats(),
|
|
2974
3278
|
errors: {
|
|
@@ -3182,6 +3486,90 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
3182
3486
|
} catch {
|
|
3183
3487
|
}
|
|
3184
3488
|
}
|
|
3489
|
+
/**
|
|
3490
|
+
* Persist a successful entitlements fetch to the durable store, if
|
|
3491
|
+
* one is configured. No-op when there is no store.
|
|
3492
|
+
*
|
|
3493
|
+
* Saved under EVERY identity the caller might later look up by — the
|
|
3494
|
+
* canonical `crossdeckCustomerId` plus any `userId` / `anonymousId`
|
|
3495
|
+
* hint. The Node cache resolves a hint to a canonical ID via an
|
|
3496
|
+
* in-memory alias map; on a cold start that map is empty, so a
|
|
3497
|
+
* failure-path `load()` must be able to hit the store with the raw
|
|
3498
|
+
* hint the caller passed. Saving under all keys makes that work.
|
|
3499
|
+
*
|
|
3500
|
+
* Best-effort: a store `save()` that throws is swallowed (logged in
|
|
3501
|
+
* debug) — it weakens durability for that customer but must never
|
|
3502
|
+
* fail an otherwise-successful `getEntitlements()`.
|
|
3503
|
+
*/
|
|
3504
|
+
async saveEntitlementsToStore(hints, response) {
|
|
3505
|
+
if (!this.entitlementStore) return;
|
|
3506
|
+
const customerId = response.crossdeckCustomerId;
|
|
3507
|
+
if (!customerId) return;
|
|
3508
|
+
const snapshot = {
|
|
3509
|
+
v: 1,
|
|
3510
|
+
crossdeckCustomerId: customerId,
|
|
3511
|
+
entitlements: response.data,
|
|
3512
|
+
env: response.env,
|
|
3513
|
+
savedAt: Date.now()
|
|
3514
|
+
};
|
|
3515
|
+
const keys = /* @__PURE__ */ new Set([customerId]);
|
|
3516
|
+
if (hints.customerId) keys.add(hints.customerId);
|
|
3517
|
+
if (hints.userId) keys.add(hints.userId);
|
|
3518
|
+
if (hints.anonymousId) keys.add(hints.anonymousId);
|
|
3519
|
+
for (const key of keys) {
|
|
3520
|
+
try {
|
|
3521
|
+
await this.entitlementStore.save(key, snapshot);
|
|
3522
|
+
} catch (err) {
|
|
3523
|
+
this.debug.emit(
|
|
3524
|
+
"sdk.entitlement_store_recovered",
|
|
3525
|
+
`entitlementStore.save failed for key ${key} \u2014 durability weakened for this customer.`,
|
|
3526
|
+
{ key, error: err instanceof Error ? err.message : String(err) }
|
|
3527
|
+
);
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
/**
|
|
3532
|
+
* Load last-known-good entitlements from the durable store on a
|
|
3533
|
+
* network-failure path. Returns the first snapshot found across the
|
|
3534
|
+
* caller's identity keys, or `null` if there is no store / no stored
|
|
3535
|
+
* snapshot / every read failed.
|
|
3536
|
+
*
|
|
3537
|
+
* Tries the canonical `customerId` hint first, then `userId`, then
|
|
3538
|
+
* `anonymousId` — the order callers most commonly key by. A corrupt
|
|
3539
|
+
* or wrong-shaped blob is treated as a miss (the store is developer-
|
|
3540
|
+
* supplied; the SDK validates rather than trusts).
|
|
3541
|
+
*/
|
|
3542
|
+
async loadEntitlementsFromStore(hints) {
|
|
3543
|
+
if (!this.entitlementStore) return null;
|
|
3544
|
+
const keys = [];
|
|
3545
|
+
if (hints.customerId) keys.push(hints.customerId);
|
|
3546
|
+
if (hints.userId) keys.push(hints.userId);
|
|
3547
|
+
if (hints.anonymousId) keys.push(hints.anonymousId);
|
|
3548
|
+
for (const key of keys) {
|
|
3549
|
+
let loaded = null;
|
|
3550
|
+
try {
|
|
3551
|
+
loaded = await this.entitlementStore.load(key);
|
|
3552
|
+
} catch {
|
|
3553
|
+
continue;
|
|
3554
|
+
}
|
|
3555
|
+
if (isValidStoredEntitlements(loaded)) return loaded;
|
|
3556
|
+
}
|
|
3557
|
+
return null;
|
|
3558
|
+
}
|
|
3559
|
+
/**
|
|
3560
|
+
* Resolve the customer ID to stamp a failed-refresh marker against.
|
|
3561
|
+
*
|
|
3562
|
+
* Prefers a canonical ID the cache already knows (so the marker lands
|
|
3563
|
+
* on the existing warm entry), then falls back to whatever raw hint
|
|
3564
|
+
* the caller supplied — on a true cold-start failure there is no
|
|
3565
|
+
* cache entry yet, and marking under the hint still makes "we tried
|
|
3566
|
+
* for this customer and Crossdeck was down" observable.
|
|
3567
|
+
*/
|
|
3568
|
+
resolveFailedRefreshCustomerId(hints) {
|
|
3569
|
+
const known = this.resolveCacheCustomerId(hints);
|
|
3570
|
+
if (known) return known;
|
|
3571
|
+
return hints.customerId ?? hints.userId ?? hints.anonymousId ?? null;
|
|
3572
|
+
}
|
|
3185
3573
|
touchAlias(alias, customerId) {
|
|
3186
3574
|
this.customerIdAliases.delete(alias);
|
|
3187
3575
|
this.customerIdAliases.set(alias, customerId);
|
|
@@ -3283,6 +3671,11 @@ function sanitizePropertyBag(input, fieldName) {
|
|
|
3283
3671
|
});
|
|
3284
3672
|
}
|
|
3285
3673
|
}
|
|
3674
|
+
function isValidStoredEntitlements(value) {
|
|
3675
|
+
if (typeof value !== "object" || value === null) return false;
|
|
3676
|
+
const v = value;
|
|
3677
|
+
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";
|
|
3678
|
+
}
|
|
3286
3679
|
function categoryFor(name) {
|
|
3287
3680
|
if (name.startsWith("page.") || name.startsWith("navigation.")) return "navigation";
|
|
3288
3681
|
if (name.startsWith("element.") || name.startsWith("ui.click")) return "ui.click";
|