@cross-deck/node 0.1.0 → 1.1.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 +139 -0
- package/README.md +406 -124
- package/dist/auto-events/index.cjs +354 -0
- package/dist/auto-events/index.cjs.map +1 -0
- package/dist/auto-events/index.d.mts +316 -0
- package/dist/auto-events/index.d.ts +316 -0
- package/dist/auto-events/index.mjs +322 -0
- package/dist/auto-events/index.mjs.map +1 -0
- package/dist/crossdeck-server-BXQaFjVx.d.mts +1414 -0
- package/dist/crossdeck-server-BXQaFjVx.d.ts +1414 -0
- package/dist/index.cjs +3069 -179
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +339 -178
- package/dist/index.d.ts +339 -178
- package/dist/index.mjs +3054 -180
- package/dist/index.mjs.map +1 -1
- package/package.json +18 -4
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/crossdeck-server.ts
|
|
2
|
-
import {
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
3
|
|
|
4
4
|
// src/errors.ts
|
|
5
5
|
var CrossdeckError = class _CrossdeckError extends Error {
|
|
@@ -18,7 +18,98 @@ var CrossdeckError = class _CrossdeckError extends Error {
|
|
|
18
18
|
this.retryAfterMs = payload.retryAfterMs;
|
|
19
19
|
Object.setPrototypeOf(this, _CrossdeckError.prototype);
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* JSON representation suitable for structured loggers. Without this,
|
|
23
|
+
* `console.log(err)` and most log frameworks (Pino, Winston) emit
|
|
24
|
+
* only `name` + `message` + `stack` — losing `type`, `code`,
|
|
25
|
+
* `requestId`, `status`, `retryAfterMs`. With `toJSON`, calling
|
|
26
|
+
* `JSON.stringify(err)` or passing the error to a logger that
|
|
27
|
+
* serialises via JSON includes the full diagnostic surface.
|
|
28
|
+
*
|
|
29
|
+
* Stripe pattern. Critical for production observability.
|
|
30
|
+
*/
|
|
31
|
+
toJSON() {
|
|
32
|
+
return {
|
|
33
|
+
name: this.name,
|
|
34
|
+
message: this.message,
|
|
35
|
+
type: this.type,
|
|
36
|
+
code: this.code,
|
|
37
|
+
requestId: this.requestId,
|
|
38
|
+
status: this.status,
|
|
39
|
+
retryAfterMs: this.retryAfterMs,
|
|
40
|
+
stack: this.stack
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var CrossdeckAuthenticationError = class _CrossdeckAuthenticationError extends CrossdeckError {
|
|
45
|
+
constructor(payload) {
|
|
46
|
+
super(payload);
|
|
47
|
+
this.name = "CrossdeckAuthenticationError";
|
|
48
|
+
Object.setPrototypeOf(this, _CrossdeckAuthenticationError.prototype);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var CrossdeckPermissionError = class _CrossdeckPermissionError extends CrossdeckError {
|
|
52
|
+
constructor(payload) {
|
|
53
|
+
super(payload);
|
|
54
|
+
this.name = "CrossdeckPermissionError";
|
|
55
|
+
Object.setPrototypeOf(this, _CrossdeckPermissionError.prototype);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var CrossdeckValidationError = class _CrossdeckValidationError extends CrossdeckError {
|
|
59
|
+
constructor(payload) {
|
|
60
|
+
super(payload);
|
|
61
|
+
this.name = "CrossdeckValidationError";
|
|
62
|
+
Object.setPrototypeOf(this, _CrossdeckValidationError.prototype);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
var CrossdeckRateLimitError = class _CrossdeckRateLimitError extends CrossdeckError {
|
|
66
|
+
constructor(payload) {
|
|
67
|
+
super(payload);
|
|
68
|
+
this.name = "CrossdeckRateLimitError";
|
|
69
|
+
Object.setPrototypeOf(this, _CrossdeckRateLimitError.prototype);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
var CrossdeckNetworkError = class _CrossdeckNetworkError extends CrossdeckError {
|
|
73
|
+
constructor(payload) {
|
|
74
|
+
super(payload);
|
|
75
|
+
this.name = "CrossdeckNetworkError";
|
|
76
|
+
Object.setPrototypeOf(this, _CrossdeckNetworkError.prototype);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
var CrossdeckInternalError = class _CrossdeckInternalError extends CrossdeckError {
|
|
80
|
+
constructor(payload) {
|
|
81
|
+
super(payload);
|
|
82
|
+
this.name = "CrossdeckInternalError";
|
|
83
|
+
Object.setPrototypeOf(this, _CrossdeckInternalError.prototype);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var CrossdeckConfigurationError = class _CrossdeckConfigurationError extends CrossdeckError {
|
|
87
|
+
constructor(payload) {
|
|
88
|
+
super(payload);
|
|
89
|
+
this.name = "CrossdeckConfigurationError";
|
|
90
|
+
Object.setPrototypeOf(this, _CrossdeckConfigurationError.prototype);
|
|
91
|
+
}
|
|
21
92
|
};
|
|
93
|
+
function makeCrossdeckError(payload) {
|
|
94
|
+
switch (payload.type) {
|
|
95
|
+
case "authentication_error":
|
|
96
|
+
return new CrossdeckAuthenticationError(payload);
|
|
97
|
+
case "permission_error":
|
|
98
|
+
return new CrossdeckPermissionError(payload);
|
|
99
|
+
case "invalid_request_error":
|
|
100
|
+
return new CrossdeckValidationError(payload);
|
|
101
|
+
case "rate_limit_error":
|
|
102
|
+
return new CrossdeckRateLimitError(payload);
|
|
103
|
+
case "network_error":
|
|
104
|
+
return new CrossdeckNetworkError(payload);
|
|
105
|
+
case "internal_error":
|
|
106
|
+
return new CrossdeckInternalError(payload);
|
|
107
|
+
case "configuration_error":
|
|
108
|
+
return new CrossdeckConfigurationError(payload);
|
|
109
|
+
default:
|
|
110
|
+
return new CrossdeckError(payload);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
22
113
|
async function crossdeckErrorFromResponse(res) {
|
|
23
114
|
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
24
115
|
const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
|
|
@@ -30,7 +121,7 @@ async function crossdeckErrorFromResponse(res) {
|
|
|
30
121
|
}
|
|
31
122
|
const envelope = body?.error;
|
|
32
123
|
if (envelope && typeof envelope.type === "string" && typeof envelope.code === "string") {
|
|
33
|
-
return
|
|
124
|
+
return makeCrossdeckError({
|
|
34
125
|
type: envelope.type,
|
|
35
126
|
code: envelope.code,
|
|
36
127
|
message: envelope.message ?? `HTTP ${res.status}`,
|
|
@@ -39,7 +130,7 @@ async function crossdeckErrorFromResponse(res) {
|
|
|
39
130
|
retryAfterMs
|
|
40
131
|
});
|
|
41
132
|
}
|
|
42
|
-
return
|
|
133
|
+
return makeCrossdeckError({
|
|
43
134
|
type: typeMapForStatus(res.status),
|
|
44
135
|
code: `http_${res.status}`,
|
|
45
136
|
message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
|
|
@@ -227,66 +318,193 @@ function byteLength(s) {
|
|
|
227
318
|
|
|
228
319
|
// src/http.ts
|
|
229
320
|
var SDK_NAME = "@cross-deck/node";
|
|
230
|
-
var SDK_VERSION = "
|
|
321
|
+
var SDK_VERSION = "1.1.0";
|
|
231
322
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
232
323
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
324
|
+
var CROSSDECK_API_VERSION = "2025-01-01";
|
|
325
|
+
var DEFAULT_GET_RETRY_ATTEMPTS = 3;
|
|
326
|
+
var DEFAULT_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 500, 502, 503, 504]);
|
|
233
327
|
var HttpClient = class {
|
|
234
328
|
constructor(config) {
|
|
235
329
|
this.config = config;
|
|
330
|
+
this.userAgent = buildUserAgent(config.sdkVersion, config.runtimeToken);
|
|
236
331
|
}
|
|
237
332
|
config;
|
|
333
|
+
userAgent;
|
|
238
334
|
async request(method, path, options = {}) {
|
|
239
335
|
const url = this.buildUrl(path, options.query);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
336
|
+
if (this.config.testMode === true) {
|
|
337
|
+
return this.synthesizeTestModeResponse(method, path, url, options);
|
|
338
|
+
}
|
|
339
|
+
const headers = this.buildHeaders(options);
|
|
340
|
+
const bodyInit = this.buildBody(headers, options.body);
|
|
341
|
+
const retryCfg = options.retries ?? this.config.httpRetries ?? {};
|
|
342
|
+
const maxAttempts = method === "GET" ? retryCfg.maxAttempts ?? DEFAULT_GET_RETRY_ATTEMPTS : 1;
|
|
343
|
+
const retryableStatuses = retryCfg.retryableStatuses ? new Set(retryCfg.retryableStatuses) : DEFAULT_RETRYABLE_STATUSES;
|
|
344
|
+
let lastError = null;
|
|
345
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
346
|
+
const reqInfo = {
|
|
347
|
+
method,
|
|
348
|
+
url,
|
|
349
|
+
headers,
|
|
350
|
+
bodyPreview: typeof bodyInit === "string" ? bodyInit : void 0,
|
|
351
|
+
attempt
|
|
352
|
+
};
|
|
353
|
+
try {
|
|
354
|
+
this.config.onRequest?.(reqInfo);
|
|
355
|
+
} catch {
|
|
356
|
+
}
|
|
357
|
+
const start = Date.now();
|
|
358
|
+
let response = null;
|
|
359
|
+
let networkError = null;
|
|
360
|
+
try {
|
|
361
|
+
response = await this.dispatch(url, method, headers, bodyInit, options);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
networkError = err;
|
|
364
|
+
}
|
|
365
|
+
const durationMs = Date.now() - start;
|
|
366
|
+
if (response) {
|
|
367
|
+
try {
|
|
368
|
+
this.config.onResponse?.({
|
|
369
|
+
method,
|
|
370
|
+
url,
|
|
371
|
+
status: response.status,
|
|
372
|
+
durationMs,
|
|
373
|
+
attempt,
|
|
374
|
+
testMode: false
|
|
375
|
+
});
|
|
376
|
+
} catch {
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (networkError !== null) {
|
|
380
|
+
lastError = this.translateNetworkError(networkError, path, options);
|
|
381
|
+
if (method === "GET" && attempt < maxAttempts) {
|
|
382
|
+
await sleepWithJitter(attempt);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
throw lastError;
|
|
386
|
+
}
|
|
387
|
+
if (response && !response.ok) {
|
|
388
|
+
const err = await crossdeckErrorFromResponse(response);
|
|
389
|
+
if (method === "GET" && retryableStatuses.has(response.status) && attempt < maxAttempts) {
|
|
390
|
+
lastError = err;
|
|
391
|
+
await sleepForRetry(err, attempt);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
throw err;
|
|
395
|
+
}
|
|
396
|
+
if (response.status === 204) return void 0;
|
|
397
|
+
try {
|
|
398
|
+
return await response.json();
|
|
399
|
+
} catch {
|
|
400
|
+
throw makeCrossdeckError({
|
|
401
|
+
type: "internal_error",
|
|
402
|
+
code: "invalid_json_response",
|
|
403
|
+
message: "Server returned a 2xx with an unparseable body.",
|
|
404
|
+
requestId: response.headers.get("x-request-id") ?? void 0,
|
|
405
|
+
status: response.status
|
|
406
|
+
});
|
|
407
|
+
}
|
|
250
408
|
}
|
|
409
|
+
throw lastError ?? makeCrossdeckError({
|
|
410
|
+
type: "internal_error",
|
|
411
|
+
code: "retry_exhausted",
|
|
412
|
+
message: `GET ${path} exhausted ${maxAttempts} attempts.`
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Issue a single fetch invocation. Composes the per-request timeout
|
|
417
|
+
* with the caller-supplied AbortSignal — whichever fires first wins.
|
|
418
|
+
*/
|
|
419
|
+
async dispatch(url, method, headers, bodyInit, options) {
|
|
251
420
|
const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
252
|
-
const
|
|
421
|
+
const supportsAbort = typeof AbortController !== "undefined";
|
|
422
|
+
const controller = supportsAbort && effectiveTimeout > 0 ? new AbortController() : null;
|
|
423
|
+
let externalAbortHandler = null;
|
|
424
|
+
if (controller && options.signal) {
|
|
425
|
+
if (options.signal.aborted) {
|
|
426
|
+
controller.abort();
|
|
427
|
+
} else {
|
|
428
|
+
externalAbortHandler = () => controller.abort();
|
|
429
|
+
options.signal.addEventListener("abort", externalAbortHandler, { once: true });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
253
432
|
let timeoutHandle = null;
|
|
254
433
|
if (controller && effectiveTimeout > 0) {
|
|
255
434
|
timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
|
|
256
435
|
}
|
|
257
|
-
let response;
|
|
258
436
|
try {
|
|
259
|
-
|
|
437
|
+
return await fetch(url, {
|
|
260
438
|
method,
|
|
261
439
|
headers,
|
|
262
440
|
body: bodyInit,
|
|
263
|
-
signal: controller?.signal
|
|
264
|
-
});
|
|
265
|
-
} catch (err) {
|
|
266
|
-
const aborted = controller?.signal?.aborted === true;
|
|
267
|
-
throw new CrossdeckError({
|
|
268
|
-
type: "network_error",
|
|
269
|
-
code: aborted ? "request_timeout" : "fetch_failed",
|
|
270
|
-
message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
|
|
441
|
+
signal: controller?.signal ?? options.signal
|
|
271
442
|
});
|
|
272
443
|
} finally {
|
|
273
444
|
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
445
|
+
if (externalAbortHandler && options.signal) {
|
|
446
|
+
try {
|
|
447
|
+
options.signal.removeEventListener("abort", externalAbortHandler);
|
|
448
|
+
} catch {
|
|
449
|
+
}
|
|
450
|
+
}
|
|
274
451
|
}
|
|
275
|
-
|
|
276
|
-
|
|
452
|
+
}
|
|
453
|
+
/** Build the request headers. Same across attempts so caches can dedupe. */
|
|
454
|
+
buildHeaders(options) {
|
|
455
|
+
const headers = {
|
|
456
|
+
Authorization: `Bearer ${this.config.secretKey}`,
|
|
457
|
+
"Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
|
|
458
|
+
"Crossdeck-Api-Version": CROSSDECK_API_VERSION,
|
|
459
|
+
"User-Agent": this.userAgent,
|
|
460
|
+
Accept: "application/json"
|
|
461
|
+
};
|
|
462
|
+
if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
|
|
463
|
+
if (options.body !== void 0) {
|
|
464
|
+
headers["Content-Type"] = "application/json";
|
|
277
465
|
}
|
|
278
|
-
|
|
466
|
+
return headers;
|
|
467
|
+
}
|
|
468
|
+
buildBody(headers, body) {
|
|
469
|
+
if (body === void 0) return void 0;
|
|
470
|
+
void headers;
|
|
471
|
+
return serializeRequestBody(body);
|
|
472
|
+
}
|
|
473
|
+
/** Translate a thrown fetch error or abort into a typed `CrossdeckError`. */
|
|
474
|
+
translateNetworkError(err, path, options) {
|
|
475
|
+
const callerAborted = options.signal?.aborted === true || err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message));
|
|
476
|
+
const callerInitiated = options.signal?.aborted === true;
|
|
477
|
+
return makeCrossdeckError({
|
|
478
|
+
type: "network_error",
|
|
479
|
+
code: callerAborted ? callerInitiated ? "request_aborted" : "request_timeout" : "fetch_failed",
|
|
480
|
+
message: callerAborted ? callerInitiated ? `Request to ${path} aborted by caller AbortSignal.` : `Request to ${path} aborted after ${options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` : err instanceof Error ? err.message : "fetch failed"
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
/** Synthesise a benign success-shaped response for `testMode: true`. */
|
|
484
|
+
synthesizeTestModeResponse(method, path, url, options) {
|
|
279
485
|
try {
|
|
280
|
-
|
|
486
|
+
this.config.onRequest?.({
|
|
487
|
+
method,
|
|
488
|
+
url,
|
|
489
|
+
headers: this.buildHeaders(options),
|
|
490
|
+
bodyPreview: options.body !== void 0 ? safeStringify2(options.body) : void 0,
|
|
491
|
+
attempt: 1
|
|
492
|
+
});
|
|
281
493
|
} catch {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
494
|
+
}
|
|
495
|
+
const synth = synthForPath(path);
|
|
496
|
+
try {
|
|
497
|
+
this.config.onResponse?.({
|
|
498
|
+
method,
|
|
499
|
+
url,
|
|
500
|
+
status: 200,
|
|
501
|
+
durationMs: 0,
|
|
502
|
+
attempt: 1,
|
|
503
|
+
testMode: true
|
|
288
504
|
});
|
|
505
|
+
} catch {
|
|
289
506
|
}
|
|
507
|
+
return synth;
|
|
290
508
|
}
|
|
291
509
|
buildUrl(path, query) {
|
|
292
510
|
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
@@ -303,6 +521,134 @@ var HttpClient = class {
|
|
|
303
521
|
return url;
|
|
304
522
|
}
|
|
305
523
|
};
|
|
524
|
+
function buildUserAgent(sdkVersion, override) {
|
|
525
|
+
if (override) return `${SDK_NAME}/${sdkVersion} ${override}`;
|
|
526
|
+
const nodeVersion = typeof process !== "undefined" && process.versions ? process.versions.node : "unknown";
|
|
527
|
+
const osPlatform2 = typeof process !== "undefined" && process.platform ? process.platform : "unknown";
|
|
528
|
+
return `${SDK_NAME}/${sdkVersion} node/${nodeVersion} ${osPlatform2}`;
|
|
529
|
+
}
|
|
530
|
+
async function sleepWithJitter(attempt) {
|
|
531
|
+
const ceiling = Math.min(2e3, 50 * Math.pow(2, attempt - 1));
|
|
532
|
+
const delay = Math.round(ceiling * Math.random());
|
|
533
|
+
await new Promise((resolve) => {
|
|
534
|
+
const t = setTimeout(resolve, delay);
|
|
535
|
+
if (typeof t.unref === "function") {
|
|
536
|
+
try {
|
|
537
|
+
t.unref();
|
|
538
|
+
} catch {
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
async function sleepForRetry(err, attempt) {
|
|
544
|
+
if (err.retryAfterMs !== void 0 && err.retryAfterMs > 0) {
|
|
545
|
+
await new Promise((resolve) => {
|
|
546
|
+
const t = setTimeout(resolve, err.retryAfterMs);
|
|
547
|
+
if (typeof t.unref === "function") {
|
|
548
|
+
try {
|
|
549
|
+
t.unref();
|
|
550
|
+
} catch {
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
await sleepWithJitter(attempt);
|
|
557
|
+
}
|
|
558
|
+
function synthForPath(path) {
|
|
559
|
+
if (path.startsWith("/sdk/heartbeat")) {
|
|
560
|
+
return {
|
|
561
|
+
object: "heartbeat",
|
|
562
|
+
ok: true,
|
|
563
|
+
projectId: "proj_test_mode",
|
|
564
|
+
appId: "app_test_mode",
|
|
565
|
+
platform: "node",
|
|
566
|
+
env: "sandbox",
|
|
567
|
+
serverTime: Date.now()
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (path.startsWith("/identity/alias")) {
|
|
571
|
+
return {
|
|
572
|
+
object: "alias_result",
|
|
573
|
+
crossdeckCustomerId: "cdcust_test_mode",
|
|
574
|
+
linked: [],
|
|
575
|
+
mergePending: false,
|
|
576
|
+
env: "sandbox"
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
if (path.startsWith("/identity/forget")) {
|
|
580
|
+
return {
|
|
581
|
+
object: "forgot",
|
|
582
|
+
crossdeckCustomerId: null,
|
|
583
|
+
queuedAt: Date.now(),
|
|
584
|
+
env: "sandbox"
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (path.includes("/entitlements")) {
|
|
588
|
+
return {
|
|
589
|
+
object: "list",
|
|
590
|
+
data: [],
|
|
591
|
+
crossdeckCustomerId: "cdcust_test_mode",
|
|
592
|
+
env: "sandbox"
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
if (path.startsWith("/events")) {
|
|
596
|
+
return {
|
|
597
|
+
object: "list",
|
|
598
|
+
received: 0,
|
|
599
|
+
env: "sandbox"
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
if (path.includes("/purchases/sync")) {
|
|
603
|
+
return {
|
|
604
|
+
object: "purchase_result",
|
|
605
|
+
crossdeckCustomerId: "cdcust_test_mode",
|
|
606
|
+
env: "sandbox",
|
|
607
|
+
entitlements: []
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
if (path.includes("/grant") || path.includes("/revoke")) {
|
|
611
|
+
return {
|
|
612
|
+
object: "entitlement_mutation",
|
|
613
|
+
action: path.includes("/grant") ? "grant" : "revoke",
|
|
614
|
+
crossdeckCustomerId: "cdcust_test_mode",
|
|
615
|
+
entitlement: {
|
|
616
|
+
object: "entitlement",
|
|
617
|
+
key: "pro",
|
|
618
|
+
isActive: path.includes("/grant"),
|
|
619
|
+
validUntil: null,
|
|
620
|
+
source: { rail: "manual", productId: "manual", subscriptionId: "manual:test_mode" },
|
|
621
|
+
updatedAt: Date.now()
|
|
622
|
+
},
|
|
623
|
+
env: "sandbox"
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (path.startsWith("/server/audit/")) {
|
|
627
|
+
return {
|
|
628
|
+
object: "audit_entry",
|
|
629
|
+
data: {
|
|
630
|
+
eventId: "audit_test_mode",
|
|
631
|
+
rail: "manual",
|
|
632
|
+
env: "sandbox",
|
|
633
|
+
eventType: "test_mode",
|
|
634
|
+
projectId: "proj_test_mode",
|
|
635
|
+
decision: "applied",
|
|
636
|
+
signatureVerified: true,
|
|
637
|
+
reconciledWithProvider: false,
|
|
638
|
+
rawEventReceivedAt: Date.now(),
|
|
639
|
+
processedAt: Date.now()
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
return {};
|
|
644
|
+
}
|
|
645
|
+
function safeStringify2(v) {
|
|
646
|
+
try {
|
|
647
|
+
return JSON.stringify(v) ?? "";
|
|
648
|
+
} catch {
|
|
649
|
+
return "[unserialisable]";
|
|
650
|
+
}
|
|
651
|
+
}
|
|
306
652
|
function serializeRequestBody(body) {
|
|
307
653
|
try {
|
|
308
654
|
const direct = JSON.stringify(body);
|
|
@@ -329,164 +675,2348 @@ function serializeRequestBody(body) {
|
|
|
329
675
|
});
|
|
330
676
|
}
|
|
331
677
|
|
|
332
|
-
// src/
|
|
333
|
-
var
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
this.appId = options.appId;
|
|
347
|
-
this.http = new HttpClient({
|
|
348
|
-
secretKey: options.secretKey,
|
|
349
|
-
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
|
|
350
|
-
sdkVersion: this.sdkVersion,
|
|
351
|
-
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
352
|
-
});
|
|
678
|
+
// src/retry-policy.ts
|
|
679
|
+
var DEFAULT_BASE = 1e3;
|
|
680
|
+
var DEFAULT_MAX = 6e4;
|
|
681
|
+
var DEFAULT_FACTOR = 2;
|
|
682
|
+
var DEFAULT_WARN = 8;
|
|
683
|
+
function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
|
|
684
|
+
const base = options.baseMs ?? DEFAULT_BASE;
|
|
685
|
+
const max = options.maxMs ?? DEFAULT_MAX;
|
|
686
|
+
const factor = options.factor ?? DEFAULT_FACTOR;
|
|
687
|
+
const safeAttempts = Math.min(attempts, 30);
|
|
688
|
+
const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
|
|
689
|
+
const jittered = ceiling * random();
|
|
690
|
+
if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
|
|
691
|
+
return Math.min(max, retryAfterMs);
|
|
353
692
|
}
|
|
354
|
-
|
|
355
|
-
|
|
693
|
+
return Math.max(0, Math.round(jittered));
|
|
694
|
+
}
|
|
695
|
+
var RetryPolicy = class {
|
|
696
|
+
constructor(options = {}) {
|
|
697
|
+
this.options = options;
|
|
356
698
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
699
|
+
options;
|
|
700
|
+
attempts = 0;
|
|
701
|
+
/** How many consecutive failures since the last success. */
|
|
702
|
+
get consecutiveFailures() {
|
|
703
|
+
return this.attempts;
|
|
704
|
+
}
|
|
705
|
+
/** Whether we've crossed the failuresBeforeWarn threshold. */
|
|
706
|
+
get isWarning() {
|
|
707
|
+
return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
|
|
708
|
+
}
|
|
709
|
+
/** Schedule-time delay for the NEXT retry. Increments the counter. */
|
|
710
|
+
nextDelay(retryAfterMs, random = Math.random) {
|
|
711
|
+
const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
|
|
712
|
+
this.attempts += 1;
|
|
713
|
+
return delay;
|
|
714
|
+
}
|
|
715
|
+
/** Mark a successful flush — reset the counter. */
|
|
716
|
+
recordSuccess() {
|
|
717
|
+
this.attempts = 0;
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
// src/_rand.ts
|
|
722
|
+
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
723
|
+
function randomChars(count) {
|
|
724
|
+
const out = [];
|
|
725
|
+
const cryptoApi = globalThis.crypto;
|
|
726
|
+
if (cryptoApi?.getRandomValues) {
|
|
727
|
+
const buf = new Uint8Array(count);
|
|
728
|
+
cryptoApi.getRandomValues(buf);
|
|
729
|
+
for (let i = 0; i < count; i++) {
|
|
730
|
+
out.push(ALPHABET[buf[i] % ALPHABET.length] ?? "0");
|
|
364
731
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
code: "missing_anonymous_id",
|
|
369
|
-
message: "aliasIdentity requires a non-empty anonymousId."
|
|
370
|
-
});
|
|
732
|
+
} else {
|
|
733
|
+
for (let i = 0; i < count; i++) {
|
|
734
|
+
out.push(ALPHABET[Math.floor(Math.random() * ALPHABET.length)] ?? "0");
|
|
371
735
|
}
|
|
372
|
-
const traits = sanitizePropertyBag(input.traits, "traits");
|
|
373
|
-
const body = {
|
|
374
|
-
userId: input.userId,
|
|
375
|
-
anonymousId: input.anonymousId
|
|
376
|
-
};
|
|
377
|
-
if (input.email) body.email = input.email;
|
|
378
|
-
if (traits && Object.keys(traits).length > 0) body.traits = traits;
|
|
379
|
-
return this.http.request("POST", "/identity/alias", { body });
|
|
380
736
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
737
|
+
return out.join("");
|
|
738
|
+
}
|
|
739
|
+
function mintId(prefix, randLen = 10) {
|
|
740
|
+
return `${prefix}_${Date.now().toString(36)}${randomChars(randLen)}`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/event-queue.ts
|
|
744
|
+
var HARD_BUFFER_CAP = 1e3;
|
|
745
|
+
var EventQueue = class {
|
|
746
|
+
constructor(cfg) {
|
|
747
|
+
this.cfg = cfg;
|
|
748
|
+
this.retry = new RetryPolicy(cfg.retry ?? {});
|
|
384
749
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
750
|
+
cfg;
|
|
751
|
+
buffer = [];
|
|
752
|
+
dropped = 0;
|
|
753
|
+
inFlight = 0;
|
|
754
|
+
lastFlushAt = 0;
|
|
755
|
+
lastError = null;
|
|
756
|
+
cancelTimer = null;
|
|
757
|
+
firstFlushFired = false;
|
|
758
|
+
nextRetryAt = null;
|
|
759
|
+
retry;
|
|
760
|
+
/**
|
|
761
|
+
* Stable Idempotency-Key for the current in-flight batch. Minted
|
|
762
|
+
* lazily inside `flush()` when no key is pending. Reused across
|
|
763
|
+
* retries of the same logical batch so the backend's idempotency
|
|
764
|
+
* layer can short-circuit duplicates (Stripe pattern). Reset to
|
|
765
|
+
* `null` after a successful flush.
|
|
766
|
+
*/
|
|
767
|
+
pendingBatchId = null;
|
|
768
|
+
/**
|
|
769
|
+
* In-flight events that have been spliced from the buffer for the
|
|
770
|
+
* current batch but haven't yet been confirmed (success or final
|
|
771
|
+
* failure). On a retry-driven flush, we re-use this batch alongside
|
|
772
|
+
* `pendingBatchId` instead of re-splicing. New events that arrive
|
|
773
|
+
* during in-flight are buffered separately and join the next batch
|
|
774
|
+
* AFTER this one settles.
|
|
775
|
+
*/
|
|
776
|
+
pendingBatch = null;
|
|
777
|
+
enqueue(event) {
|
|
778
|
+
this.buffer.push(event);
|
|
779
|
+
if (this.buffer.length > HARD_BUFFER_CAP) {
|
|
780
|
+
const overflow = this.buffer.length - HARD_BUFFER_CAP;
|
|
781
|
+
this.buffer.splice(0, overflow);
|
|
782
|
+
this.dropped += overflow;
|
|
783
|
+
this.cfg.onDrop?.(overflow);
|
|
784
|
+
}
|
|
785
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
786
|
+
if (this.buffer.length >= this.cfg.batchSize) {
|
|
787
|
+
void this.flush();
|
|
788
|
+
} else {
|
|
789
|
+
this.scheduleIdleFlush();
|
|
790
|
+
}
|
|
389
791
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
792
|
+
/**
|
|
793
|
+
* Flush the buffer to /v1/events. Resolves when the network call
|
|
794
|
+
* completes (success or failure). On failure, events stay in the
|
|
795
|
+
* `pendingBatch` slot for the next scheduled retry — the SAME batch
|
|
796
|
+
* with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
|
|
797
|
+
*
|
|
798
|
+
* The `pendingBatch` slot guarantees retry semantics:
|
|
799
|
+
* - First call: splices buffer → pendingBatch + mints batchId.
|
|
800
|
+
* - On 5xx / network failure: pendingBatch stays; scheduler fires
|
|
801
|
+
* `flush()` again later, which re-uses pendingBatch + the same
|
|
802
|
+
* batchId.
|
|
803
|
+
* - On success: pendingBatch + batchId cleared; subsequent calls
|
|
804
|
+
* splice the buffer again with a fresh batchId.
|
|
805
|
+
*
|
|
806
|
+
* New events that arrive during an in-flight batch land in `buffer`
|
|
807
|
+
* (separate from `pendingBatch`) and ship on the next batch after
|
|
808
|
+
* this one settles. Strict ordering preserved.
|
|
809
|
+
*/
|
|
810
|
+
async flush() {
|
|
811
|
+
let batch;
|
|
812
|
+
let batchId;
|
|
813
|
+
if (this.pendingBatch !== null && this.pendingBatchId !== null) {
|
|
814
|
+
batch = this.pendingBatch;
|
|
815
|
+
batchId = this.pendingBatchId;
|
|
816
|
+
} else {
|
|
817
|
+
if (this.buffer.length === 0) return null;
|
|
818
|
+
batch = this.buffer.splice(0);
|
|
819
|
+
batchId = mintId("batch");
|
|
820
|
+
this.pendingBatch = batch;
|
|
821
|
+
this.pendingBatchId = batchId;
|
|
822
|
+
this.inFlight += batch.length;
|
|
823
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
824
|
+
}
|
|
825
|
+
this.cancelTimerIfSet();
|
|
826
|
+
this.nextRetryAt = null;
|
|
827
|
+
try {
|
|
828
|
+
const env = this.cfg.envelope();
|
|
829
|
+
const body = {
|
|
830
|
+
events: batch,
|
|
831
|
+
sdk: env.sdk
|
|
832
|
+
};
|
|
833
|
+
if (env.appId) body.appId = env.appId;
|
|
834
|
+
const result = await this.cfg.http.request("POST", "/events", {
|
|
835
|
+
body,
|
|
836
|
+
idempotencyKey: batchId
|
|
396
837
|
});
|
|
838
|
+
this.lastFlushAt = Date.now();
|
|
839
|
+
this.lastError = null;
|
|
840
|
+
this.inFlight -= batch.length;
|
|
841
|
+
this.pendingBatch = null;
|
|
842
|
+
this.pendingBatchId = null;
|
|
843
|
+
this.retry.recordSuccess();
|
|
844
|
+
if (!this.firstFlushFired) {
|
|
845
|
+
this.firstFlushFired = true;
|
|
846
|
+
this.cfg.onFirstFlushSuccess?.();
|
|
847
|
+
}
|
|
848
|
+
return result;
|
|
849
|
+
} catch (err) {
|
|
850
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
851
|
+
this.lastError = message;
|
|
852
|
+
const retryAfterMs = extractRetryAfterMs(err);
|
|
853
|
+
const delay = this.retry.nextDelay(retryAfterMs);
|
|
854
|
+
this.scheduleRetry(delay);
|
|
855
|
+
this.cfg.onRetryScheduled?.({
|
|
856
|
+
delayMs: delay,
|
|
857
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
858
|
+
retryAfterMs,
|
|
859
|
+
lastError: message
|
|
860
|
+
});
|
|
861
|
+
return null;
|
|
397
862
|
}
|
|
398
|
-
return this.http.request(
|
|
399
|
-
"GET",
|
|
400
|
-
`/server/customers/${encodeURIComponent(customerId)}/entitlements`
|
|
401
|
-
);
|
|
402
863
|
}
|
|
403
|
-
|
|
404
|
-
|
|
864
|
+
/** Cancel any pending timer and clear in-memory state. */
|
|
865
|
+
reset() {
|
|
866
|
+
this.cancelTimerIfSet();
|
|
867
|
+
this.nextRetryAt = null;
|
|
868
|
+
this.buffer = [];
|
|
869
|
+
this.pendingBatch = null;
|
|
870
|
+
this.pendingBatchId = null;
|
|
871
|
+
this.dropped = 0;
|
|
872
|
+
this.inFlight = 0;
|
|
873
|
+
this.lastError = null;
|
|
874
|
+
this.retry.recordSuccess();
|
|
875
|
+
this.cfg.onBufferChange?.(0);
|
|
405
876
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
877
|
+
getStats() {
|
|
878
|
+
return {
|
|
879
|
+
// `buffered` counts events waiting for their FIRST flush. The
|
|
880
|
+
// in-flight pendingBatch (retrying) is tracked separately via
|
|
881
|
+
// `inFlight` — surfacing both lets diagnostics show "we have
|
|
882
|
+
// events stuck retrying" distinct from "new events arriving".
|
|
883
|
+
buffered: this.buffer.length,
|
|
884
|
+
dropped: this.dropped,
|
|
885
|
+
inFlight: this.inFlight,
|
|
886
|
+
lastFlushAt: this.lastFlushAt,
|
|
887
|
+
lastError: this.lastError,
|
|
888
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
889
|
+
nextRetryAt: this.nextRetryAt
|
|
418
890
|
};
|
|
419
|
-
if (this.appId) body.appId = this.appId;
|
|
420
|
-
return this.http.request("POST", "/events", {
|
|
421
|
-
body,
|
|
422
|
-
idempotencyKey: options.idempotencyKey ?? this.mintBatchId()
|
|
423
|
-
});
|
|
424
891
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
return this.http.request("POST", "/purchases/sync", {
|
|
434
|
-
body: { rail: input.rail ?? "apple", ...input }
|
|
435
|
-
});
|
|
892
|
+
/**
|
|
893
|
+
* The Idempotency-Key of the in-flight pending batch (if any).
|
|
894
|
+
* Exposed for testing the Stripe-style reuse contract. Production
|
|
895
|
+
* callers don't need this.
|
|
896
|
+
*/
|
|
897
|
+
get pendingIdempotencyKey() {
|
|
898
|
+
return this.pendingBatchId;
|
|
436
899
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
900
|
+
// ---------- internal scheduling ----------
|
|
901
|
+
scheduleIdleFlush() {
|
|
902
|
+
this.cancelTimerIfSet();
|
|
903
|
+
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
904
|
+
this.cancelTimer = sched(() => {
|
|
905
|
+
void this.flush();
|
|
906
|
+
}, this.cfg.intervalMs);
|
|
907
|
+
}
|
|
908
|
+
scheduleRetry(delayMs) {
|
|
909
|
+
this.cancelTimerIfSet();
|
|
910
|
+
this.nextRetryAt = Date.now() + delayMs;
|
|
911
|
+
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
912
|
+
this.cancelTimer = sched(() => {
|
|
913
|
+
void this.flush();
|
|
914
|
+
}, delayMs);
|
|
915
|
+
}
|
|
916
|
+
cancelTimerIfSet() {
|
|
917
|
+
if (this.cancelTimer) {
|
|
918
|
+
this.cancelTimer();
|
|
919
|
+
this.cancelTimer = null;
|
|
444
920
|
}
|
|
445
|
-
return this.http.request(
|
|
446
|
-
"POST",
|
|
447
|
-
`/server/customers/${encodeURIComponent(input.customerId)}/grant`,
|
|
448
|
-
{
|
|
449
|
-
body: {
|
|
450
|
-
entitlementKey: input.entitlementKey,
|
|
451
|
-
duration: input.duration,
|
|
452
|
-
reason: input.reason
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
);
|
|
456
921
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
922
|
+
};
|
|
923
|
+
function extractRetryAfterMs(err) {
|
|
924
|
+
if (err && typeof err === "object" && "retryAfterMs" in err) {
|
|
925
|
+
const v = err.retryAfterMs;
|
|
926
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
|
|
927
|
+
}
|
|
928
|
+
return void 0;
|
|
929
|
+
}
|
|
930
|
+
function defaultScheduler(fn, ms) {
|
|
931
|
+
const id = setTimeout(fn, ms);
|
|
932
|
+
if (typeof id.unref === "function") {
|
|
933
|
+
try {
|
|
934
|
+
id.unref();
|
|
935
|
+
} catch {
|
|
464
936
|
}
|
|
465
|
-
return this.http.request(
|
|
466
|
-
"POST",
|
|
467
|
-
`/server/customers/${encodeURIComponent(input.customerId)}/revoke`,
|
|
468
|
-
{
|
|
469
|
-
body: {
|
|
470
|
-
entitlementKey: input.entitlementKey,
|
|
471
|
-
reason: input.reason
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
);
|
|
475
937
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
938
|
+
return () => clearTimeout(id);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// src/breadcrumbs.ts
|
|
942
|
+
var BreadcrumbBuffer = class {
|
|
943
|
+
constructor(maxSize = 50) {
|
|
944
|
+
this.maxSize = maxSize;
|
|
945
|
+
}
|
|
946
|
+
maxSize;
|
|
947
|
+
items = [];
|
|
948
|
+
add(crumb) {
|
|
949
|
+
this.items.push(crumb);
|
|
950
|
+
if (this.items.length > this.maxSize) {
|
|
951
|
+
this.items.shift();
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
/** Defensive copy — caller can read freely without mutating buffer state. */
|
|
955
|
+
snapshot() {
|
|
956
|
+
return this.items.slice();
|
|
957
|
+
}
|
|
958
|
+
clear() {
|
|
959
|
+
this.items = [];
|
|
960
|
+
}
|
|
961
|
+
get size() {
|
|
962
|
+
return this.items.length;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
// src/stack-parser.ts
|
|
967
|
+
function parseStack(stack) {
|
|
968
|
+
if (!stack || typeof stack !== "string") return [];
|
|
969
|
+
const lines = stack.split("\n");
|
|
970
|
+
const frames = [];
|
|
971
|
+
for (const line of lines) {
|
|
972
|
+
const trimmed = line.trim();
|
|
973
|
+
if (!trimmed) continue;
|
|
974
|
+
const frame = parseLine(trimmed);
|
|
975
|
+
if (frame) frames.push(frame);
|
|
976
|
+
}
|
|
977
|
+
return frames;
|
|
978
|
+
}
|
|
979
|
+
function parseLine(line) {
|
|
980
|
+
let m = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/.exec(line);
|
|
981
|
+
if (m) {
|
|
982
|
+
return buildFrame({
|
|
983
|
+
function: m[1],
|
|
984
|
+
filename: m[2],
|
|
985
|
+
lineno: parseInt(m[3], 10),
|
|
986
|
+
colno: parseInt(m[4], 10),
|
|
987
|
+
raw: line
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
m = /^at\s+(.+?):(\d+):(\d+)$/.exec(line);
|
|
991
|
+
if (m) {
|
|
992
|
+
return buildFrame({
|
|
993
|
+
function: "?",
|
|
994
|
+
filename: m[1],
|
|
995
|
+
lineno: parseInt(m[2], 10),
|
|
996
|
+
colno: parseInt(m[3], 10),
|
|
997
|
+
raw: line
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
m = /^(.*?)@(.+?):(\d+):(\d+)$/.exec(line);
|
|
1001
|
+
if (m) {
|
|
1002
|
+
return buildFrame({
|
|
1003
|
+
function: m[1] || "?",
|
|
1004
|
+
filename: m[2],
|
|
1005
|
+
lineno: parseInt(m[3], 10),
|
|
1006
|
+
colno: parseInt(m[4], 10),
|
|
1007
|
+
raw: line
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
if (/^\w*Error/.test(line) || !line.includes(":")) {
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
function: "?",
|
|
1015
|
+
filename: "",
|
|
1016
|
+
lineno: 0,
|
|
1017
|
+
colno: 0,
|
|
1018
|
+
in_app: true,
|
|
1019
|
+
raw: line
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
function buildFrame(input) {
|
|
1023
|
+
return {
|
|
1024
|
+
function: input.function || "?",
|
|
1025
|
+
filename: input.filename,
|
|
1026
|
+
lineno: Number.isFinite(input.lineno) ? input.lineno : 0,
|
|
1027
|
+
colno: Number.isFinite(input.colno) ? input.colno : 0,
|
|
1028
|
+
in_app: isInAppFrame(input.filename),
|
|
1029
|
+
raw: input.raw
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
function isInAppFrame(filename) {
|
|
1033
|
+
if (!filename) return true;
|
|
1034
|
+
if (/@cross-deck[\\/]node/.test(filename)) return false;
|
|
1035
|
+
if (/[\\/]node_modules[\\/]/.test(filename)) return false;
|
|
1036
|
+
if (/^node:/.test(filename)) return false;
|
|
1037
|
+
if (/^internal[\\/]/.test(filename)) return false;
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
function fingerprintError(message, frames) {
|
|
1041
|
+
const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
|
|
1042
|
+
const key = [
|
|
1043
|
+
(message || "").slice(0, 200),
|
|
1044
|
+
...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
|
|
1045
|
+
].join("|");
|
|
1046
|
+
return djb2Hex(key);
|
|
1047
|
+
}
|
|
1048
|
+
function djb2Hex(input) {
|
|
1049
|
+
let h = 5381;
|
|
1050
|
+
for (let i = 0; i < input.length; i++) {
|
|
1051
|
+
h = (h << 5) + h + input.charCodeAt(i) | 0;
|
|
1052
|
+
}
|
|
1053
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// src/error-capture.ts
|
|
1057
|
+
var DEFAULT_ERROR_CAPTURE = {
|
|
1058
|
+
enabled: true,
|
|
1059
|
+
onUncaughtException: true,
|
|
1060
|
+
onUnhandledRejection: true,
|
|
1061
|
+
wrapFetch: true,
|
|
1062
|
+
captureConsole: false,
|
|
1063
|
+
ignoreErrors: [],
|
|
1064
|
+
allowPaths: [],
|
|
1065
|
+
denyPaths: [
|
|
1066
|
+
// SDK self-skip — caught by stack-parser's `isInAppFrame` too,
|
|
1067
|
+
// but defensive here in case a future change to the heuristic
|
|
1068
|
+
// misses one of these paths.
|
|
1069
|
+
/[\\/]node_modules[\\/]@cross-deck[\\/]node[\\/]/
|
|
1070
|
+
],
|
|
1071
|
+
sampleRate: 1,
|
|
1072
|
+
maxPerFingerprintPerMinute: 5,
|
|
1073
|
+
maxPerSession: 100
|
|
1074
|
+
};
|
|
1075
|
+
var MAX_FINGERPRINTS_TRACKED = 4096;
|
|
1076
|
+
var FINGERPRINT_WINDOW_MS = 6e4;
|
|
1077
|
+
var ErrorTracker = class {
|
|
1078
|
+
constructor(opts) {
|
|
1079
|
+
this.opts = opts;
|
|
1080
|
+
}
|
|
1081
|
+
opts;
|
|
1082
|
+
installed = false;
|
|
1083
|
+
cleanups = [];
|
|
1084
|
+
_reporting = false;
|
|
1085
|
+
sessionCount = 0;
|
|
1086
|
+
fingerprintWindow = /* @__PURE__ */ new Map();
|
|
1087
|
+
install() {
|
|
1088
|
+
if (this.installed) return;
|
|
1089
|
+
if (!this.opts.config.enabled) return;
|
|
1090
|
+
if (this.opts.config.onUncaughtException) this.installUncaughtExceptionHandler();
|
|
1091
|
+
if (this.opts.config.onUnhandledRejection) this.installUnhandledRejectionHandler();
|
|
1092
|
+
if (this.opts.config.wrapFetch) this.installFetchWrap();
|
|
1093
|
+
if (this.opts.config.captureConsole) this.installConsoleWrap();
|
|
1094
|
+
this.installed = true;
|
|
1095
|
+
}
|
|
1096
|
+
uninstall() {
|
|
1097
|
+
for (const fn of this.cleanups.splice(0)) {
|
|
1098
|
+
try {
|
|
1099
|
+
fn();
|
|
1100
|
+
} catch {
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
this.installed = false;
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Manual API. Either an Error instance or any unknown value (we
|
|
1107
|
+
* coerce). Returns silently — never throws, even if the SDK isn't
|
|
1108
|
+
* initialised.
|
|
1109
|
+
*/
|
|
1110
|
+
captureError(error, options) {
|
|
1111
|
+
if (!this.opts.isConsented()) return;
|
|
1112
|
+
try {
|
|
1113
|
+
const captured = this.buildFromUnknown(error, "error.handled", options?.level ?? "error");
|
|
1114
|
+
if (options?.context) captured.context = { ...captured.context, ...options.context };
|
|
1115
|
+
if (options?.tags) captured.tags = { ...captured.tags, ...options.tags };
|
|
1116
|
+
this.maybeReport(captured);
|
|
1117
|
+
} catch {
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Capture a non-error event as an issue. For "we hit a soft-warning
|
|
1122
|
+
* code path" / "deprecated API used" kinds of signals. Pairs with
|
|
1123
|
+
* Sentry's captureMessage().
|
|
1124
|
+
*/
|
|
1125
|
+
captureMessage(message, level = "info") {
|
|
1126
|
+
if (!this.opts.isConsented()) return;
|
|
1127
|
+
try {
|
|
1128
|
+
const captured = {
|
|
1129
|
+
timestamp: Date.now(),
|
|
1130
|
+
kind: "error.message",
|
|
1131
|
+
level,
|
|
1132
|
+
message,
|
|
1133
|
+
errorType: null,
|
|
1134
|
+
frames: [],
|
|
1135
|
+
rawStack: null,
|
|
1136
|
+
fingerprint: fingerprintError(message, []),
|
|
1137
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
1138
|
+
context: this.opts.getContext(),
|
|
1139
|
+
tags: this.opts.getTags()
|
|
1140
|
+
};
|
|
1141
|
+
this.maybeReport(captured);
|
|
1142
|
+
} catch {
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
/** Inspection hook — total reports captured this process lifetime. */
|
|
1146
|
+
get reportedCount() {
|
|
1147
|
+
return this.sessionCount;
|
|
1148
|
+
}
|
|
1149
|
+
/** Inspection hook — number of distinct fingerprints inside the rate-limit window. */
|
|
1150
|
+
get fingerprintsTracked() {
|
|
1151
|
+
return this.fingerprintWindow.size;
|
|
1152
|
+
}
|
|
1153
|
+
/** Inspection hook — whether global handlers are installed. */
|
|
1154
|
+
get handlersInstalled() {
|
|
1155
|
+
return this.installed;
|
|
1156
|
+
}
|
|
1157
|
+
// ============================================================
|
|
1158
|
+
// Listener installation — Node hooks
|
|
1159
|
+
// ============================================================
|
|
1160
|
+
installUncaughtExceptionHandler() {
|
|
1161
|
+
const handler = (err) => {
|
|
1162
|
+
if (this._reporting) return;
|
|
1163
|
+
if (!this.opts.isConsented()) return;
|
|
1164
|
+
try {
|
|
1165
|
+
this._reporting = true;
|
|
1166
|
+
const captured = this.buildFromUnknown(err, "error.unhandled", "error");
|
|
1167
|
+
this.maybeReport(captured);
|
|
1168
|
+
} catch {
|
|
1169
|
+
} finally {
|
|
1170
|
+
this._reporting = false;
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
process.on("uncaughtException", handler);
|
|
1174
|
+
this.cleanups.push(() => process.off("uncaughtException", handler));
|
|
1175
|
+
}
|
|
1176
|
+
installUnhandledRejectionHandler() {
|
|
1177
|
+
const handler = (reason) => {
|
|
1178
|
+
if (this._reporting) return;
|
|
1179
|
+
if (!this.opts.isConsented()) return;
|
|
1180
|
+
try {
|
|
1181
|
+
this._reporting = true;
|
|
1182
|
+
const captured = this.buildFromUnknown(reason, "error.unhandledrejection", "error");
|
|
1183
|
+
this.maybeReport(captured);
|
|
1184
|
+
} catch {
|
|
1185
|
+
} finally {
|
|
1186
|
+
this._reporting = false;
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
process.on("unhandledRejection", handler);
|
|
1190
|
+
this.cleanups.push(() => process.off("unhandledRejection", handler));
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Wrap `globalThis.fetch` so failed HTTP requests get auto-captured.
|
|
1194
|
+
* We do NOT call 4xx an "error" (those are often expected — auth
|
|
1195
|
+
* required, validation failed). Only 5xx + network failures fire.
|
|
1196
|
+
*
|
|
1197
|
+
* Node 18+ exposes `fetch` natively on `globalThis`. We tolerate
|
|
1198
|
+
* its absence (some sandboxed runtimes / patched globals) by
|
|
1199
|
+
* skipping the wrap rather than throwing.
|
|
1200
|
+
*/
|
|
1201
|
+
installFetchWrap() {
|
|
1202
|
+
const origFetch = globalThis.fetch;
|
|
1203
|
+
if (typeof origFetch !== "function") return;
|
|
1204
|
+
const tracker = this;
|
|
1205
|
+
const wrapped = async (...args) => {
|
|
1206
|
+
const input = args[0];
|
|
1207
|
+
const init = args[1] ?? {};
|
|
1208
|
+
const url = typeof input === "string" ? input : input?.url ?? "";
|
|
1209
|
+
const method = (init.method || "GET").toUpperCase();
|
|
1210
|
+
const start = Date.now();
|
|
1211
|
+
tracker.opts.breadcrumbs.add({
|
|
1212
|
+
timestamp: start,
|
|
1213
|
+
category: "http",
|
|
1214
|
+
message: `${method} ${url}`,
|
|
1215
|
+
data: { url, method }
|
|
1216
|
+
});
|
|
1217
|
+
try {
|
|
1218
|
+
const response = await origFetch(...args);
|
|
1219
|
+
if (response.status >= 500 && tracker.opts.isConsented()) {
|
|
1220
|
+
if (!url.includes("api.cross-deck.com")) {
|
|
1221
|
+
tracker.captureHttp({
|
|
1222
|
+
url,
|
|
1223
|
+
method,
|
|
1224
|
+
status: response.status,
|
|
1225
|
+
statusText: response.statusText
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return response;
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
if (tracker.opts.isConsented() && !url.includes("api.cross-deck.com")) {
|
|
1232
|
+
tracker.captureHttp({
|
|
1233
|
+
url,
|
|
1234
|
+
method,
|
|
1235
|
+
status: 0,
|
|
1236
|
+
statusText: err instanceof Error ? err.message : "network error"
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
throw err;
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
globalThis.fetch = wrapped;
|
|
1243
|
+
this.cleanups.push(() => {
|
|
1244
|
+
if (globalThis.fetch === wrapped) globalThis.fetch = origFetch;
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
installConsoleWrap() {
|
|
1248
|
+
const orig = console.error.bind(console);
|
|
1249
|
+
const tracker = this;
|
|
1250
|
+
console.error = (...args) => {
|
|
1251
|
+
try {
|
|
1252
|
+
if (tracker.opts.isConsented()) {
|
|
1253
|
+
tracker.captureMessage(args.map((a) => safeStringify3(a)).join(" "), "error");
|
|
1254
|
+
}
|
|
1255
|
+
} catch {
|
|
1256
|
+
}
|
|
1257
|
+
return orig(...args);
|
|
1258
|
+
};
|
|
1259
|
+
this.cleanups.push(() => {
|
|
1260
|
+
console.error = orig;
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
// ============================================================
|
|
1264
|
+
// Builders
|
|
1265
|
+
// ============================================================
|
|
1266
|
+
/**
|
|
1267
|
+
* Build a `CapturedError` from any value. Handles:
|
|
1268
|
+
* - Error instances (the common case) — parses `err.stack` into
|
|
1269
|
+
* frames, fingerprints over message + top in-app frames.
|
|
1270
|
+
* - Non-Error rejections (promise rejected with a string / number
|
|
1271
|
+
* / plain object) — coerces via `safeStringify`, no frames.
|
|
1272
|
+
*
|
|
1273
|
+
* Verbatim port of web's `buildFromUnknown` — the logic is
|
|
1274
|
+
* runtime-agnostic.
|
|
1275
|
+
*/
|
|
1276
|
+
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);
|
|
1294
|
+
return {
|
|
1295
|
+
timestamp: Date.now(),
|
|
1296
|
+
kind,
|
|
1297
|
+
level,
|
|
1298
|
+
message,
|
|
1299
|
+
errorType: null,
|
|
1300
|
+
frames: [],
|
|
1301
|
+
rawStack: null,
|
|
1302
|
+
fingerprint: fingerprintError(message, []),
|
|
1303
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
1304
|
+
context: this.opts.getContext(),
|
|
1305
|
+
tags: this.opts.getTags()
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
captureHttp(info) {
|
|
1309
|
+
try {
|
|
1310
|
+
const message = `HTTP ${info.status} ${info.method} ${info.url}`;
|
|
1311
|
+
const captured = {
|
|
1312
|
+
timestamp: Date.now(),
|
|
1313
|
+
kind: "error.http",
|
|
1314
|
+
level: "error",
|
|
1315
|
+
message,
|
|
1316
|
+
errorType: "HTTPError",
|
|
1317
|
+
frames: [],
|
|
1318
|
+
rawStack: null,
|
|
1319
|
+
fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
|
|
1320
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
1321
|
+
context: this.opts.getContext(),
|
|
1322
|
+
tags: this.opts.getTags(),
|
|
1323
|
+
http: info
|
|
1324
|
+
};
|
|
1325
|
+
this.maybeReport(captured);
|
|
1326
|
+
} catch {
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
// ============================================================
|
|
1330
|
+
// Reporting pipeline — filter / sample / rate-limit / send
|
|
1331
|
+
// ============================================================
|
|
1332
|
+
maybeReport(err) {
|
|
1333
|
+
if (this.sessionCount >= this.opts.config.maxPerSession) return;
|
|
1334
|
+
if (this.shouldIgnore(err)) return;
|
|
1335
|
+
if (!this.passesPathGate(err)) return;
|
|
1336
|
+
if (!this.passesSample(err)) return;
|
|
1337
|
+
if (!this.passesRateLimit(err)) return;
|
|
1338
|
+
let finalErr = err;
|
|
1339
|
+
if (this.opts.beforeSend) {
|
|
1340
|
+
try {
|
|
1341
|
+
finalErr = this.opts.beforeSend(err);
|
|
1342
|
+
} catch {
|
|
1343
|
+
finalErr = err;
|
|
1344
|
+
}
|
|
1345
|
+
if (!finalErr) return;
|
|
1346
|
+
}
|
|
1347
|
+
this.sessionCount += 1;
|
|
1348
|
+
try {
|
|
1349
|
+
this.opts.report(finalErr);
|
|
1350
|
+
} catch {
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
shouldIgnore(err) {
|
|
1354
|
+
for (const pat of this.opts.config.ignoreErrors) {
|
|
1355
|
+
if (typeof pat === "string" && err.message.includes(pat)) return true;
|
|
1356
|
+
if (pat instanceof RegExp && pat.test(err.message)) return true;
|
|
1357
|
+
}
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
passesPathGate(err) {
|
|
1361
|
+
const topFrame = err.frames.find((f) => f.filename) ?? null;
|
|
1362
|
+
const path = topFrame?.filename ?? "";
|
|
1363
|
+
if (!path) return true;
|
|
1364
|
+
for (const pat of this.opts.config.denyPaths) {
|
|
1365
|
+
if (typeof pat === "string" && path.includes(pat)) return false;
|
|
1366
|
+
if (pat instanceof RegExp && pat.test(path)) return false;
|
|
1367
|
+
}
|
|
1368
|
+
if (this.opts.config.allowPaths.length > 0) {
|
|
1369
|
+
for (const pat of this.opts.config.allowPaths) {
|
|
1370
|
+
if (typeof pat === "string" && path.includes(pat)) return true;
|
|
1371
|
+
if (pat instanceof RegExp && pat.test(path)) return true;
|
|
1372
|
+
}
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
return true;
|
|
1376
|
+
}
|
|
1377
|
+
passesSample(err) {
|
|
1378
|
+
if (this.opts.config.sampleRate >= 1) return true;
|
|
1379
|
+
if (this.opts.config.sampleRate <= 0) return false;
|
|
1380
|
+
const hashByte = parseInt(err.fingerprint.slice(0, 2), 16);
|
|
1381
|
+
return hashByte / 255 < this.opts.config.sampleRate;
|
|
1382
|
+
}
|
|
1383
|
+
passesRateLimit(err) {
|
|
1384
|
+
const now = Date.now();
|
|
1385
|
+
const max = this.opts.config.maxPerFingerprintPerMinute;
|
|
1386
|
+
const arr = this.fingerprintWindow.get(err.fingerprint) ?? [];
|
|
1387
|
+
const fresh = arr.filter((t) => now - t < FINGERPRINT_WINDOW_MS);
|
|
1388
|
+
if (fresh.length >= max) {
|
|
1389
|
+
this.fingerprintWindow.set(err.fingerprint, fresh);
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
fresh.push(now);
|
|
1393
|
+
this.fingerprintWindow.set(err.fingerprint, fresh);
|
|
1394
|
+
this.maybePruneFingerprintWindow(now);
|
|
1395
|
+
return true;
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Bound the fingerprint Map's memory footprint. Runs opportunistically
|
|
1399
|
+
* — only when the Map exceeds `MAX_FINGERPRINTS_TRACKED`. First pass:
|
|
1400
|
+
* delete entries whose ENTIRE window is stale (no live timestamps
|
|
1401
|
+
* inside the 60s window). Second pass (if still over): FIFO-evict
|
|
1402
|
+
* the oldest entries by Map insertion order until we're under the
|
|
1403
|
+
* cap. Defends against a long-running process with high-cardinality
|
|
1404
|
+
* fingerprints leaking memory forever.
|
|
1405
|
+
*/
|
|
1406
|
+
maybePruneFingerprintWindow(now) {
|
|
1407
|
+
if (this.fingerprintWindow.size <= MAX_FINGERPRINTS_TRACKED) return;
|
|
1408
|
+
for (const [fp, timestamps] of this.fingerprintWindow) {
|
|
1409
|
+
const hasLive = timestamps.some((t) => now - t < FINGERPRINT_WINDOW_MS);
|
|
1410
|
+
if (!hasLive) this.fingerprintWindow.delete(fp);
|
|
1411
|
+
}
|
|
1412
|
+
if (this.fingerprintWindow.size <= MAX_FINGERPRINTS_TRACKED) return;
|
|
1413
|
+
const overflow = this.fingerprintWindow.size - MAX_FINGERPRINTS_TRACKED;
|
|
1414
|
+
let dropped = 0;
|
|
1415
|
+
for (const fp of this.fingerprintWindow.keys()) {
|
|
1416
|
+
if (dropped >= overflow) break;
|
|
1417
|
+
this.fingerprintWindow.delete(fp);
|
|
1418
|
+
dropped += 1;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
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);
|
|
1426
|
+
try {
|
|
1427
|
+
return JSON.stringify(v);
|
|
1428
|
+
} catch {
|
|
1429
|
+
return Object.prototype.toString.call(v);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// src/runtime-info.ts
|
|
1434
|
+
import { hostname as osHostname, platform as osPlatform, release as osRelease } from "os";
|
|
1435
|
+
var cached = null;
|
|
1436
|
+
function collectRuntimeInfo(options = {}) {
|
|
1437
|
+
if (cached) return cached;
|
|
1438
|
+
cached = detect(options);
|
|
1439
|
+
return cached;
|
|
1440
|
+
}
|
|
1441
|
+
function detect(options) {
|
|
1442
|
+
const env = typeof process !== "undefined" && process.env ? process.env : {};
|
|
1443
|
+
const detected = detectHost(env);
|
|
1444
|
+
return Object.freeze({
|
|
1445
|
+
nodeVersion: typeof process !== "undefined" && process.versions ? process.versions.node : "unknown",
|
|
1446
|
+
platform: safePlatform(),
|
|
1447
|
+
platformRelease: safeRelease(),
|
|
1448
|
+
hostname: safeHostname(),
|
|
1449
|
+
host: detected.host,
|
|
1450
|
+
region: detected.region,
|
|
1451
|
+
serviceName: options.serviceName ?? detected.serviceName,
|
|
1452
|
+
serviceVersion: options.serviceVersion ?? detected.serviceVersion,
|
|
1453
|
+
instanceId: detected.instanceId,
|
|
1454
|
+
appVersion: options.appVersion ?? null
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
function detectHost(env) {
|
|
1458
|
+
const pid = safePid();
|
|
1459
|
+
if (env.AWS_LAMBDA_FUNCTION_NAME) {
|
|
1460
|
+
return {
|
|
1461
|
+
host: "aws-lambda",
|
|
1462
|
+
region: env.AWS_REGION ?? null,
|
|
1463
|
+
serviceName: env.AWS_LAMBDA_FUNCTION_NAME,
|
|
1464
|
+
serviceVersion: env.AWS_LAMBDA_FUNCTION_VERSION ?? null,
|
|
1465
|
+
instanceId: env.AWS_LAMBDA_LOG_STREAM_NAME ?? pid
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
if (env.FUNCTIONS_WORKER_RUNTIME && env.WEBSITE_INSTANCE_ID) {
|
|
1469
|
+
return {
|
|
1470
|
+
host: "azure-functions",
|
|
1471
|
+
region: env.REGION_NAME ?? env.WEBSITE_LOCATION ?? null,
|
|
1472
|
+
serviceName: env.WEBSITE_SITE_NAME ?? null,
|
|
1473
|
+
serviceVersion: env.WEBSITE_BUILD_ID ?? null,
|
|
1474
|
+
instanceId: env.WEBSITE_INSTANCE_ID
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
if (env.GAE_APPLICATION) {
|
|
1478
|
+
return {
|
|
1479
|
+
host: "google-app-engine",
|
|
1480
|
+
region: env.GAE_REGION ?? env.GOOGLE_CLOUD_REGION ?? null,
|
|
1481
|
+
serviceName: env.GAE_SERVICE ?? null,
|
|
1482
|
+
serviceVersion: env.GAE_VERSION ?? null,
|
|
1483
|
+
instanceId: env.GAE_INSTANCE ?? pid
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
if (env.K_SERVICE && env.K_REVISION) {
|
|
1487
|
+
const isFirebase = Boolean(env.FIREBASE_CONFIG || env.GCLOUD_PROJECT);
|
|
1488
|
+
return {
|
|
1489
|
+
host: isFirebase ? "firebase-functions-v2" : "cloud-run",
|
|
1490
|
+
region: env.FUNCTION_REGION ?? env.GOOGLE_CLOUD_REGION ?? null,
|
|
1491
|
+
serviceName: env.K_SERVICE,
|
|
1492
|
+
serviceVersion: env.K_REVISION,
|
|
1493
|
+
instanceId: `${env.K_REVISION}:${pid}`
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
if (env.FUNCTION_NAME && env.FUNCTION_REGION) {
|
|
1497
|
+
return {
|
|
1498
|
+
host: "firebase-functions-v1",
|
|
1499
|
+
region: env.FUNCTION_REGION,
|
|
1500
|
+
serviceName: env.FUNCTION_NAME,
|
|
1501
|
+
serviceVersion: env.X_GOOGLE_FUNCTION_VERSION ?? null,
|
|
1502
|
+
instanceId: pid
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
if (env.VERCEL === "1") {
|
|
1506
|
+
return {
|
|
1507
|
+
host: "vercel",
|
|
1508
|
+
region: env.VERCEL_REGION ?? null,
|
|
1509
|
+
serviceName: env.VERCEL_URL ?? null,
|
|
1510
|
+
serviceVersion: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null,
|
|
1511
|
+
instanceId: pid
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
if (env.NETLIFY === "true" || env.NETLIFY_BUILD_BASE) {
|
|
1515
|
+
return {
|
|
1516
|
+
host: "netlify",
|
|
1517
|
+
region: env.AWS_REGION ?? null,
|
|
1518
|
+
serviceName: env.SITE_NAME ?? env.SITE_ID ?? null,
|
|
1519
|
+
serviceVersion: env.COMMIT_REF?.slice(0, 7) ?? null,
|
|
1520
|
+
instanceId: pid
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
if (env.DYNO) {
|
|
1524
|
+
return {
|
|
1525
|
+
host: "heroku",
|
|
1526
|
+
region: null,
|
|
1527
|
+
serviceName: env.HEROKU_APP_NAME ?? null,
|
|
1528
|
+
serviceVersion: env.HEROKU_RELEASE_VERSION ?? env.HEROKU_SLUG_COMMIT?.slice(0, 7) ?? null,
|
|
1529
|
+
instanceId: env.DYNO
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
if (env.RENDER === "true" || env.RENDER_INSTANCE_ID) {
|
|
1533
|
+
return {
|
|
1534
|
+
host: "render",
|
|
1535
|
+
region: env.RENDER_SERVICE_REGION ?? null,
|
|
1536
|
+
serviceName: env.RENDER_SERVICE_NAME ?? null,
|
|
1537
|
+
serviceVersion: env.RENDER_GIT_COMMIT?.slice(0, 7) ?? null,
|
|
1538
|
+
instanceId: env.RENDER_INSTANCE_ID ?? pid
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
if (env.RAILWAY_ENVIRONMENT) {
|
|
1542
|
+
return {
|
|
1543
|
+
host: "railway",
|
|
1544
|
+
region: env.RAILWAY_REGION ?? null,
|
|
1545
|
+
serviceName: env.RAILWAY_SERVICE_NAME ?? null,
|
|
1546
|
+
serviceVersion: env.RAILWAY_GIT_COMMIT_SHA?.slice(0, 7) ?? null,
|
|
1547
|
+
instanceId: env.RAILWAY_REPLICA_ID ?? pid
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
if (env.FLY_APP_NAME) {
|
|
1551
|
+
return {
|
|
1552
|
+
host: "fly",
|
|
1553
|
+
region: env.FLY_REGION ?? null,
|
|
1554
|
+
serviceName: env.FLY_APP_NAME,
|
|
1555
|
+
serviceVersion: env.FLY_IMAGE_REF?.slice(-7) ?? null,
|
|
1556
|
+
instanceId: env.FLY_ALLOC_ID ?? pid
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
if (env.KUBERNETES_SERVICE_HOST) {
|
|
1560
|
+
return {
|
|
1561
|
+
host: "kubernetes",
|
|
1562
|
+
region: null,
|
|
1563
|
+
serviceName: env.POD_NAME ?? env.HOSTNAME ?? null,
|
|
1564
|
+
serviceVersion: null,
|
|
1565
|
+
instanceId: env.POD_NAME ?? env.HOSTNAME ?? pid
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
return {
|
|
1569
|
+
host: "node",
|
|
1570
|
+
region: null,
|
|
1571
|
+
serviceName: null,
|
|
1572
|
+
serviceVersion: null,
|
|
1573
|
+
instanceId: pid
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
function safeHostname() {
|
|
1577
|
+
try {
|
|
1578
|
+
return osHostname();
|
|
1579
|
+
} catch {
|
|
1580
|
+
return "unknown";
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
function safePlatform() {
|
|
1584
|
+
try {
|
|
1585
|
+
return osPlatform();
|
|
1586
|
+
} catch {
|
|
1587
|
+
return "unknown";
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function safeRelease() {
|
|
1591
|
+
try {
|
|
1592
|
+
return osRelease();
|
|
1593
|
+
} catch {
|
|
1594
|
+
return "unknown";
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
function safePid() {
|
|
1598
|
+
try {
|
|
1599
|
+
return typeof process !== "undefined" && process.pid ? String(process.pid) : "0";
|
|
1600
|
+
} catch {
|
|
1601
|
+
return "0";
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
function runtimeInfoToProperties(info) {
|
|
1605
|
+
const out = {
|
|
1606
|
+
"runtime.nodeVersion": info.nodeVersion,
|
|
1607
|
+
"runtime.platform": info.platform,
|
|
1608
|
+
"runtime.platformRelease": info.platformRelease,
|
|
1609
|
+
"runtime.hostname": info.hostname,
|
|
1610
|
+
"runtime.host": info.host
|
|
1611
|
+
};
|
|
1612
|
+
if (info.region) out["runtime.region"] = info.region;
|
|
1613
|
+
if (info.serviceName) out["runtime.serviceName"] = info.serviceName;
|
|
1614
|
+
if (info.serviceVersion) out["runtime.serviceVersion"] = info.serviceVersion;
|
|
1615
|
+
if (info.instanceId) out["runtime.instanceId"] = info.instanceId;
|
|
1616
|
+
if (info.appVersion) out.appVersion = info.appVersion;
|
|
1617
|
+
return out;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// src/flush-on-exit.ts
|
|
1621
|
+
var SIGNALS = ["SIGTERM", "SIGINT"];
|
|
1622
|
+
var DEFAULT_TIMEOUT_MS2 = 2e3;
|
|
1623
|
+
var FlushOnExit = class {
|
|
1624
|
+
constructor(options) {
|
|
1625
|
+
this.options = options;
|
|
1626
|
+
}
|
|
1627
|
+
options;
|
|
1628
|
+
installed = false;
|
|
1629
|
+
draining = false;
|
|
1630
|
+
drained = false;
|
|
1631
|
+
beforeExitHandler = null;
|
|
1632
|
+
signalHandlers = {};
|
|
1633
|
+
/**
|
|
1634
|
+
* Install handlers for `beforeExit` + `SIGTERM` + `SIGINT`. Idempotent —
|
|
1635
|
+
* calling twice does NOT register duplicate handlers.
|
|
1636
|
+
*/
|
|
1637
|
+
install() {
|
|
1638
|
+
if (this.installed) return;
|
|
1639
|
+
this.installed = true;
|
|
1640
|
+
this.beforeExitHandler = () => {
|
|
1641
|
+
void this.runDrain("beforeExit");
|
|
1642
|
+
};
|
|
1643
|
+
process.on("beforeExit", this.beforeExitHandler);
|
|
1644
|
+
for (const sig of SIGNALS) {
|
|
1645
|
+
const handler = () => {
|
|
1646
|
+
void this.runDrainAndExit(sig);
|
|
1647
|
+
};
|
|
1648
|
+
this.signalHandlers[sig] = handler;
|
|
1649
|
+
process.on(sig, handler);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Remove all handlers. Tests + custom-lifecycle callers only.
|
|
1654
|
+
*/
|
|
1655
|
+
uninstall() {
|
|
1656
|
+
if (!this.installed) return;
|
|
1657
|
+
this.installed = false;
|
|
1658
|
+
if (this.beforeExitHandler) {
|
|
1659
|
+
process.off("beforeExit", this.beforeExitHandler);
|
|
1660
|
+
this.beforeExitHandler = null;
|
|
1661
|
+
}
|
|
1662
|
+
for (const sig of SIGNALS) {
|
|
1663
|
+
const handler = this.signalHandlers[sig];
|
|
1664
|
+
if (handler) {
|
|
1665
|
+
process.off(sig, handler);
|
|
1666
|
+
delete this.signalHandlers[sig];
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Force-drain immediately (without waiting for an exit signal).
|
|
1672
|
+
* Used by `wrapLambdaHandler` / `wrapFunction` — Lambda freezes the
|
|
1673
|
+
* process between invocations, so we drain at the END of each
|
|
1674
|
+
* invocation rather than waiting for SIGTERM.
|
|
1675
|
+
*/
|
|
1676
|
+
async drainNow() {
|
|
1677
|
+
return this.runDrain("manual");
|
|
1678
|
+
}
|
|
1679
|
+
/** True if the drain has already completed (one-shot lifecycle). */
|
|
1680
|
+
get hasDrained() {
|
|
1681
|
+
return this.drained;
|
|
1682
|
+
}
|
|
1683
|
+
/** True if a drain is in flight. */
|
|
1684
|
+
get isDraining() {
|
|
1685
|
+
return this.draining;
|
|
1686
|
+
}
|
|
1687
|
+
// ---------- internals ----------
|
|
1688
|
+
async runDrain(_reason) {
|
|
1689
|
+
if (this.drained || this.draining) return;
|
|
1690
|
+
this.draining = true;
|
|
1691
|
+
this.options.onStart?.();
|
|
1692
|
+
const start = Date.now();
|
|
1693
|
+
const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
1694
|
+
let timedOut = false;
|
|
1695
|
+
let drainError = null;
|
|
1696
|
+
await new Promise((resolve) => {
|
|
1697
|
+
let settled = false;
|
|
1698
|
+
const timer = setTimeout(() => {
|
|
1699
|
+
if (settled) return;
|
|
1700
|
+
settled = true;
|
|
1701
|
+
timedOut = true;
|
|
1702
|
+
resolve();
|
|
1703
|
+
}, timeoutMs);
|
|
1704
|
+
if (typeof timer.unref === "function") {
|
|
1705
|
+
try {
|
|
1706
|
+
timer.unref();
|
|
1707
|
+
} catch {
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
let drainPromise;
|
|
1711
|
+
try {
|
|
1712
|
+
drainPromise = this.options.drain();
|
|
1713
|
+
} catch (syncErr) {
|
|
1714
|
+
if (settled) return;
|
|
1715
|
+
settled = true;
|
|
1716
|
+
drainError = syncErr;
|
|
1717
|
+
clearTimeout(timer);
|
|
1718
|
+
resolve();
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
drainPromise.then(
|
|
1722
|
+
() => {
|
|
1723
|
+
if (settled) return;
|
|
1724
|
+
settled = true;
|
|
1725
|
+
clearTimeout(timer);
|
|
1726
|
+
resolve();
|
|
1727
|
+
},
|
|
1728
|
+
(err) => {
|
|
1729
|
+
if (settled) return;
|
|
1730
|
+
settled = true;
|
|
1731
|
+
drainError = err;
|
|
1732
|
+
clearTimeout(timer);
|
|
1733
|
+
resolve();
|
|
1734
|
+
}
|
|
1735
|
+
);
|
|
1736
|
+
});
|
|
1737
|
+
if (drainError !== null) {
|
|
1738
|
+
try {
|
|
1739
|
+
this.options.onError?.(drainError);
|
|
1740
|
+
} catch {
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
this.draining = false;
|
|
1744
|
+
this.drained = true;
|
|
1745
|
+
this.options.onComplete?.({
|
|
1746
|
+
durationMs: Date.now() - start,
|
|
1747
|
+
timedOut
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Drain in response to a termination signal. After the drain
|
|
1752
|
+
* completes, we re-raise the signal so the process exits with the
|
|
1753
|
+
* correct exit code — `kill -TERM <pid>` should still terminate
|
|
1754
|
+
* the process even though we installed a handler that "consumed" it.
|
|
1755
|
+
*
|
|
1756
|
+
* Detail: Node attaches a default SIGTERM handler that exits the
|
|
1757
|
+
* process. The MOMENT we register our own handler with `process.on`,
|
|
1758
|
+
* the default is removed. So we have to re-raise to mimic the
|
|
1759
|
+
* default behaviour after our drain completes.
|
|
1760
|
+
*/
|
|
1761
|
+
async runDrainAndExit(sig) {
|
|
1762
|
+
await this.runDrain(sig);
|
|
1763
|
+
const handler = this.signalHandlers[sig];
|
|
1764
|
+
if (handler) {
|
|
1765
|
+
process.off(sig, handler);
|
|
1766
|
+
delete this.signalHandlers[sig];
|
|
1767
|
+
}
|
|
1768
|
+
try {
|
|
1769
|
+
process.kill(process.pid, sig);
|
|
1770
|
+
} catch {
|
|
1771
|
+
const code = sig === "SIGTERM" ? 143 : 130;
|
|
1772
|
+
process.exit(code);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
// src/super-properties.ts
|
|
1778
|
+
var SuperPropertyStore = class {
|
|
1779
|
+
superProps = {};
|
|
1780
|
+
groups = {};
|
|
1781
|
+
/**
|
|
1782
|
+
* Merge new keys into the super-property bag. Returns a snapshot
|
|
1783
|
+
* of the resulting bag. Values that are `null` are deleted
|
|
1784
|
+
* (Mixpanel's explicit "stop tracking this key" idiom).
|
|
1785
|
+
*/
|
|
1786
|
+
register(props) {
|
|
1787
|
+
for (const [k, v] of Object.entries(props)) {
|
|
1788
|
+
if (v === null) {
|
|
1789
|
+
delete this.superProps[k];
|
|
1790
|
+
} else if (v !== void 0) {
|
|
1791
|
+
this.superProps[k] = v;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
return { ...this.superProps };
|
|
1795
|
+
}
|
|
1796
|
+
/** Remove a single super-property key. Idempotent. */
|
|
1797
|
+
unregister(key) {
|
|
1798
|
+
if (key in this.superProps) {
|
|
1799
|
+
delete this.superProps[key];
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
/** Defensive snapshot of the current super-property bag. */
|
|
1803
|
+
getSuperProperties() {
|
|
1804
|
+
return { ...this.superProps };
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Set a group membership. Passing `id: null` clears the membership
|
|
1808
|
+
* for that type — the SDK stops attaching it to events.
|
|
1809
|
+
*/
|
|
1810
|
+
setGroup(type, id, traits) {
|
|
1811
|
+
if (id === null) {
|
|
1812
|
+
delete this.groups[type];
|
|
1813
|
+
} else {
|
|
1814
|
+
this.groups[type] = traits !== void 0 ? { id, traits } : { id };
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Defensive snapshot of the current groups map, keyed by group type.
|
|
1819
|
+
* The `traits` sub-object is the most-recent traits payload passed
|
|
1820
|
+
* to `setGroup` for that type.
|
|
1821
|
+
*/
|
|
1822
|
+
getGroups() {
|
|
1823
|
+
const out = {};
|
|
1824
|
+
for (const [type, membership] of Object.entries(this.groups)) {
|
|
1825
|
+
out[type] = {
|
|
1826
|
+
id: membership.id,
|
|
1827
|
+
...membership.traits ? { traits: { ...membership.traits } } : {}
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
return out;
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Flat `{ type: id }` projection used for event-attachment. Stable
|
|
1834
|
+
* for fast every-event merge — we don't JSON-clone on each
|
|
1835
|
+
* `track()` call (hot path).
|
|
1836
|
+
*/
|
|
1837
|
+
getGroupIds() {
|
|
1838
|
+
const out = {};
|
|
1839
|
+
for (const [type, info] of Object.entries(this.groups)) {
|
|
1840
|
+
out[type] = info.id;
|
|
1841
|
+
}
|
|
1842
|
+
return out;
|
|
1843
|
+
}
|
|
1844
|
+
/** Wipe both bags. Called by `server.shutdown()`. */
|
|
1845
|
+
clear() {
|
|
1846
|
+
this.superProps = {};
|
|
1847
|
+
this.groups = {};
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1851
|
+
// src/entitlement-cache.ts
|
|
1852
|
+
var DEFAULT_MAX_CUSTOMERS = 1e4;
|
|
1853
|
+
var EntitlementCache = class {
|
|
1854
|
+
ttlMs;
|
|
1855
|
+
maxCustomers;
|
|
1856
|
+
byCustomer = /* @__PURE__ */ new Map();
|
|
1857
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1858
|
+
listenerErrorCount = 0;
|
|
1859
|
+
evicted = 0;
|
|
1860
|
+
constructor(options = {}) {
|
|
1861
|
+
this.ttlMs = options.ttlMs ?? 6e4;
|
|
1862
|
+
this.maxCustomers = options.maxCustomers ?? DEFAULT_MAX_CUSTOMERS;
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
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).
|
|
1868
|
+
*/
|
|
1869
|
+
isEntitled(customerId, key) {
|
|
1870
|
+
const entry = this.byCustomer.get(customerId);
|
|
1871
|
+
if (!entry) return false;
|
|
1872
|
+
if (Date.now() > entry.expiresAt) return false;
|
|
1873
|
+
return entry.active.has(key);
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Full snapshot for callers that need source / validUntil. Returns
|
|
1877
|
+
* `[]` when the customer has no cached entry or the entry has
|
|
1878
|
+
* expired.
|
|
1879
|
+
*/
|
|
1880
|
+
list(customerId) {
|
|
1881
|
+
const entry = this.byCustomer.get(customerId);
|
|
1882
|
+
if (!entry) return [];
|
|
1883
|
+
if (Date.now() > entry.expiresAt) return [];
|
|
1884
|
+
return entry.all.slice();
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Whether a fresh entry exists for the customer. Useful for
|
|
1888
|
+
* deciding whether to warm before a hot path.
|
|
1889
|
+
*/
|
|
1890
|
+
isFresh(customerId) {
|
|
1891
|
+
const entry = this.byCustomer.get(customerId);
|
|
1892
|
+
return Boolean(entry && Date.now() <= entry.expiresAt);
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
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.
|
|
1900
|
+
*/
|
|
1901
|
+
setForCustomer(customerId, entitlements) {
|
|
1902
|
+
const now = Date.now();
|
|
1903
|
+
this.byCustomer.delete(customerId);
|
|
1904
|
+
this.byCustomer.set(customerId, {
|
|
1905
|
+
all: entitlements.slice(),
|
|
1906
|
+
active: new Set(entitlements.filter((e) => e.isActive).map((e) => e.key)),
|
|
1907
|
+
expiresAt: now + this.ttlMs,
|
|
1908
|
+
populatedAt: now
|
|
1909
|
+
});
|
|
1910
|
+
while (this.byCustomer.size > this.maxCustomers) {
|
|
1911
|
+
const oldestKey = this.byCustomer.keys().next().value;
|
|
1912
|
+
if (oldestKey === void 0) break;
|
|
1913
|
+
this.byCustomer.delete(oldestKey);
|
|
1914
|
+
this.evicted += 1;
|
|
1915
|
+
}
|
|
1916
|
+
this.notify(customerId, entitlements);
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Drop a single customer's entry. Fires listeners with an empty
|
|
1920
|
+
* list so subscribers know that customer's cache is gone.
|
|
1921
|
+
*/
|
|
1922
|
+
clearCustomer(customerId) {
|
|
1923
|
+
if (!this.byCustomer.delete(customerId)) return;
|
|
1924
|
+
this.notify(customerId, []);
|
|
1925
|
+
}
|
|
1926
|
+
/** Wipe the whole cache. Fires listeners for each customer that had a cached entry. */
|
|
1927
|
+
clear() {
|
|
1928
|
+
const customers = [...this.byCustomer.keys()];
|
|
1929
|
+
this.byCustomer.clear();
|
|
1930
|
+
for (const id of customers) this.notify(id, []);
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Subscribe to cache mutations. Returns an unsubscribe function.
|
|
1934
|
+
* Listener is invoked AFTER each `setForCustomer` / `clearCustomer`
|
|
1935
|
+
* / `clear` call with the affected customer ID + fresh entitlements
|
|
1936
|
+
* snapshot. NOT fired on TTL expiry (passive eviction is not a
|
|
1937
|
+
* mutation by design).
|
|
1938
|
+
*
|
|
1939
|
+
* Listener errors are swallowed — a buggy consumer must not crash
|
|
1940
|
+
* the SDK or other listeners. The error count is surfaced via
|
|
1941
|
+
* `listenerErrors`.
|
|
1942
|
+
*
|
|
1943
|
+
* Returned unsubscribe is idempotent.
|
|
1944
|
+
*/
|
|
1945
|
+
subscribe(listener) {
|
|
1946
|
+
this.listeners.add(listener);
|
|
1947
|
+
let unsubscribed = false;
|
|
1948
|
+
return () => {
|
|
1949
|
+
if (unsubscribed) return;
|
|
1950
|
+
unsubscribed = true;
|
|
1951
|
+
this.listeners.delete(listener);
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
// ---------- diagnostics ----------
|
|
1955
|
+
/** Total customers currently cached (counts expired entries too — eviction is lazy). */
|
|
1956
|
+
get customerCount() {
|
|
1957
|
+
return this.byCustomer.size;
|
|
1958
|
+
}
|
|
1959
|
+
/** Most-recent populatedAt across all entries, or 0 if cache empty. */
|
|
1960
|
+
get lastUpdated() {
|
|
1961
|
+
let max = 0;
|
|
1962
|
+
for (const entry of this.byCustomer.values()) {
|
|
1963
|
+
if (entry.populatedAt > max) max = entry.populatedAt;
|
|
1964
|
+
}
|
|
1965
|
+
return max;
|
|
1966
|
+
}
|
|
1967
|
+
/** Configured TTL in ms. */
|
|
1968
|
+
get ttl() {
|
|
1969
|
+
return this.ttlMs;
|
|
1970
|
+
}
|
|
1971
|
+
/** Cumulative count of listener invocations that threw. Surfaced in `diagnostics()`. */
|
|
1972
|
+
get listenerErrors() {
|
|
1973
|
+
return this.listenerErrorCount;
|
|
1974
|
+
}
|
|
1975
|
+
/** Cumulative count of entries evicted by the max-customers cap. */
|
|
1976
|
+
get evictedCount() {
|
|
1977
|
+
return this.evicted;
|
|
1978
|
+
}
|
|
1979
|
+
/** Configured max-customers cap. */
|
|
1980
|
+
get maxSize() {
|
|
1981
|
+
return this.maxCustomers;
|
|
1982
|
+
}
|
|
1983
|
+
// ---------- internals ----------
|
|
1984
|
+
notify(customerId, snapshot) {
|
|
1985
|
+
if (this.listeners.size === 0) return;
|
|
1986
|
+
const snap = snapshot.slice();
|
|
1987
|
+
const listenersSnapshot = [...this.listeners];
|
|
1988
|
+
for (const listener of listenersSnapshot) {
|
|
1989
|
+
try {
|
|
1990
|
+
listener(customerId, snap);
|
|
1991
|
+
} catch {
|
|
1992
|
+
this.listenerErrorCount += 1;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1998
|
+
// src/debug.ts
|
|
1999
|
+
var SENSITIVE_KEY_PATTERNS = [
|
|
2000
|
+
/^email$/i,
|
|
2001
|
+
/^password$/i,
|
|
2002
|
+
/^token$/i,
|
|
2003
|
+
/^secret$/i,
|
|
2004
|
+
/^card$/i,
|
|
2005
|
+
/^phone$/i,
|
|
2006
|
+
/password/i,
|
|
2007
|
+
/credit_?card/i
|
|
2008
|
+
];
|
|
2009
|
+
function findSensitivePropertyKeys(properties) {
|
|
2010
|
+
if (!properties) return [];
|
|
2011
|
+
const hits = [];
|
|
2012
|
+
for (const k of Object.keys(properties)) {
|
|
2013
|
+
if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(k))) hits.push(k);
|
|
2014
|
+
}
|
|
2015
|
+
return hits;
|
|
2016
|
+
}
|
|
2017
|
+
var ONCE_SIGNALS = /* @__PURE__ */ new Set([
|
|
2018
|
+
"sdk.configured",
|
|
2019
|
+
"sdk.first_event_sent",
|
|
2020
|
+
"sdk.environment_mismatch",
|
|
2021
|
+
"sdk.runtime_detected"
|
|
2022
|
+
]);
|
|
2023
|
+
var ConsoleDebugLogger = class {
|
|
2024
|
+
enabled = false;
|
|
2025
|
+
seen = /* @__PURE__ */ new Set();
|
|
2026
|
+
emit(signal, message, context) {
|
|
2027
|
+
if (!this.enabled) return;
|
|
2028
|
+
if (ONCE_SIGNALS.has(signal)) {
|
|
2029
|
+
if (this.seen.has(signal)) return;
|
|
2030
|
+
this.seen.add(signal);
|
|
2031
|
+
}
|
|
2032
|
+
const ctx = context ? ` ${safeJson(context)}` : "";
|
|
2033
|
+
console.info(`[crossdeck:${signal}] ${message}${ctx}`);
|
|
2034
|
+
}
|
|
2035
|
+
};
|
|
2036
|
+
var NullDebugLogger = class {
|
|
2037
|
+
enabled = false;
|
|
2038
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
2039
|
+
emit(_signal, _message, _context) {
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
function safeJson(obj) {
|
|
2043
|
+
try {
|
|
2044
|
+
return JSON.stringify(obj);
|
|
2045
|
+
} catch {
|
|
2046
|
+
return "[unserialisable context]";
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// src/crossdeck-server.ts
|
|
2051
|
+
var CrossdeckServer = class extends EventEmitter {
|
|
2052
|
+
http;
|
|
2053
|
+
sdkVersion;
|
|
2054
|
+
baseUrl;
|
|
2055
|
+
appId;
|
|
2056
|
+
env;
|
|
2057
|
+
secretKeyPrefix;
|
|
2058
|
+
/**
|
|
2059
|
+
* Process-stable pseudo-anonymous ID. Used as the default identity
|
|
2060
|
+
* for `track()` / `captureError()` calls where the caller doesn't
|
|
2061
|
+
* supply one (e.g. an `uncaughtException` handler has no per-request
|
|
2062
|
+
* context). Stable for the SDK instance's lifetime so events from
|
|
2063
|
+
* the same process correlate.
|
|
2064
|
+
*/
|
|
2065
|
+
processAnonymousId;
|
|
2066
|
+
runtime;
|
|
2067
|
+
runtimeProperties;
|
|
2068
|
+
breadcrumbs;
|
|
2069
|
+
eventQueue;
|
|
2070
|
+
errorTracker;
|
|
2071
|
+
flushOnExit;
|
|
2072
|
+
superProps;
|
|
2073
|
+
entitlementCache;
|
|
2074
|
+
debug;
|
|
2075
|
+
/**
|
|
2076
|
+
* Alias map — `developerUserId` / `anonymousId` → canonical
|
|
2077
|
+
* `crossdeckCustomerId`. Populated by `getEntitlements()` so a
|
|
2078
|
+
* subsequent `isEntitled({ userId }, "pro")` resolves to the same
|
|
2079
|
+
* cache entry the prior `getEntitlements({ userId })` populated.
|
|
2080
|
+
*
|
|
2081
|
+
* Bounded by `MAX_CUSTOMER_ID_ALIASES` (matches the entitlement
|
|
2082
|
+
* cache's default max-customers for symmetry — if the underlying
|
|
2083
|
+
* cache entry was evicted, a stale alias is dead weight anyway).
|
|
2084
|
+
* Long-running multi-tenant servers handling a long tail of customers
|
|
2085
|
+
* are the failure mode this bound defends against.
|
|
2086
|
+
*/
|
|
2087
|
+
customerIdAliases = /* @__PURE__ */ new Map();
|
|
2088
|
+
/** Mutable error-state — modified by setTag / setContext / setErrorBeforeSend. */
|
|
2089
|
+
errorContext = {};
|
|
2090
|
+
errorTags = {};
|
|
2091
|
+
errorBeforeSend = null;
|
|
2092
|
+
constructor(options) {
|
|
2093
|
+
super();
|
|
2094
|
+
if (!options.secretKey || !options.secretKey.startsWith("cd_sk_")) {
|
|
2095
|
+
throw new CrossdeckError({
|
|
2096
|
+
type: "configuration_error",
|
|
2097
|
+
code: "invalid_secret_key",
|
|
2098
|
+
message: "CrossdeckServer requires a secret key starting with cd_sk_."
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
this.sdkVersion = options.sdkVersion ?? SDK_VERSION;
|
|
2102
|
+
this.appId = options.appId;
|
|
2103
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
2104
|
+
this.env = inferEnvFromKey(options.secretKey);
|
|
2105
|
+
this.secretKeyPrefix = maskSecretKey(options.secretKey);
|
|
2106
|
+
this.http = new HttpClient({
|
|
2107
|
+
secretKey: options.secretKey,
|
|
2108
|
+
baseUrl: this.baseUrl,
|
|
2109
|
+
sdkVersion: this.sdkVersion,
|
|
2110
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
2111
|
+
testMode: options.testMode,
|
|
2112
|
+
onRequest: options.onRequest,
|
|
2113
|
+
onResponse: options.onResponse,
|
|
2114
|
+
httpRetries: options.httpRetries,
|
|
2115
|
+
runtimeToken: options.runtimeToken
|
|
2116
|
+
});
|
|
2117
|
+
this.processAnonymousId = mintId("anon_node");
|
|
2118
|
+
this.runtime = collectRuntimeInfo({
|
|
2119
|
+
serviceName: options.serviceName,
|
|
2120
|
+
serviceVersion: options.serviceVersion,
|
|
2121
|
+
appVersion: options.appVersion
|
|
2122
|
+
});
|
|
2123
|
+
this.runtimeProperties = runtimeInfoToProperties(this.runtime);
|
|
2124
|
+
this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
|
|
2125
|
+
this.superProps = new SuperPropertyStore();
|
|
2126
|
+
this.entitlementCache = new EntitlementCache({
|
|
2127
|
+
ttlMs: options.entitlementCacheTtlMs ?? 6e4
|
|
2128
|
+
});
|
|
2129
|
+
this.debug = options.debug === true ? new ConsoleDebugLogger() : new NullDebugLogger();
|
|
2130
|
+
if (options.debug === true) this.debug.enabled = true;
|
|
2131
|
+
this.debug.emit(
|
|
2132
|
+
"sdk.configured",
|
|
2133
|
+
`Crossdeck server SDK connected. env=${this.env}, host=${this.runtime?.host ?? "node"}`,
|
|
2134
|
+
{
|
|
2135
|
+
env: this.env,
|
|
2136
|
+
sdkVersion: this.sdkVersion,
|
|
2137
|
+
secretKeyPrefix: this.secretKeyPrefix
|
|
2138
|
+
}
|
|
2139
|
+
);
|
|
2140
|
+
this.eventQueue = new EventQueue({
|
|
2141
|
+
http: this.http,
|
|
2142
|
+
batchSize: options.eventFlushBatchSize ?? 20,
|
|
2143
|
+
intervalMs: options.eventFlushIntervalMs ?? 1500,
|
|
2144
|
+
envelope: () => ({
|
|
2145
|
+
appId: this.appId,
|
|
2146
|
+
sdk: { name: SDK_NAME, version: this.sdkVersion }
|
|
2147
|
+
}),
|
|
2148
|
+
onDrop: (count) => {
|
|
2149
|
+
this.emit("queue.dropped", { count });
|
|
2150
|
+
},
|
|
2151
|
+
onBufferChange: (size) => {
|
|
2152
|
+
this.emit("queue.buffer_changed", { size });
|
|
2153
|
+
},
|
|
2154
|
+
onRetryScheduled: (info) => {
|
|
2155
|
+
this.emit("queue.flush_failed", {
|
|
2156
|
+
error: info.lastError,
|
|
2157
|
+
attempt: info.consecutiveFailures,
|
|
2158
|
+
nextRetryMs: info.delayMs
|
|
2159
|
+
});
|
|
2160
|
+
},
|
|
2161
|
+
onFirstFlushSuccess: () => {
|
|
2162
|
+
this.debug.emit("sdk.first_event_sent", "First batch landed.");
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
2165
|
+
if (options.errorCapture === false) {
|
|
2166
|
+
this.errorTracker = null;
|
|
2167
|
+
} else {
|
|
2168
|
+
const config = options.errorCapture && typeof options.errorCapture === "object" ? { ...DEFAULT_ERROR_CAPTURE, ...options.errorCapture } : { ...DEFAULT_ERROR_CAPTURE };
|
|
2169
|
+
this.errorTracker = new ErrorTracker({
|
|
2170
|
+
config,
|
|
2171
|
+
breadcrumbs: this.breadcrumbs,
|
|
2172
|
+
report: (err) => this.reportCapturedError(err),
|
|
2173
|
+
getContext: () => ({ ...this.errorContext }),
|
|
2174
|
+
getTags: () => ({ ...this.errorTags }),
|
|
2175
|
+
beforeSend: null,
|
|
2176
|
+
// wired via setErrorBeforeSend; ErrorTracker reads it through the live ref below
|
|
2177
|
+
isConsented: () => true
|
|
2178
|
+
});
|
|
2179
|
+
const trackerOpts = this.errorTracker.opts;
|
|
2180
|
+
Object.defineProperty(trackerOpts, "beforeSend", {
|
|
2181
|
+
get: () => this.errorBeforeSend,
|
|
2182
|
+
configurable: true
|
|
2183
|
+
});
|
|
2184
|
+
this.errorTracker.install();
|
|
2185
|
+
}
|
|
2186
|
+
if (options.flushOnExit === false) {
|
|
2187
|
+
this.flushOnExit = null;
|
|
2188
|
+
} else {
|
|
2189
|
+
this.flushOnExit = new FlushOnExit({
|
|
2190
|
+
drain: () => this.eventQueue.flush().then(() => void 0),
|
|
2191
|
+
timeoutMs: options.flushOnExitTimeoutMs
|
|
2192
|
+
});
|
|
2193
|
+
this.flushOnExit.install();
|
|
2194
|
+
}
|
|
2195
|
+
if (options.testMode !== true && options.bootHeartbeat !== false) {
|
|
2196
|
+
setImmediate(() => {
|
|
2197
|
+
void this.heartbeat().catch((err) => {
|
|
2198
|
+
this.debug.emit(
|
|
2199
|
+
"sdk.boot_heartbeat_failed",
|
|
2200
|
+
"Boot heartbeat failed (non-fatal \u2014 events will still flush).",
|
|
2201
|
+
{ message: err instanceof Error ? err.message : String(err) }
|
|
2202
|
+
);
|
|
2203
|
+
});
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
// ============================================================
|
|
2208
|
+
// Identity — direct HTTP (transactional, not telemetry)
|
|
2209
|
+
// ============================================================
|
|
2210
|
+
async identify(userId, anonymousId, options) {
|
|
2211
|
+
const { signal, timeoutMs, ...identifyOpts } = options ?? {};
|
|
2212
|
+
return this.aliasIdentity(
|
|
2213
|
+
{ userId, anonymousId, ...identifyOpts },
|
|
2214
|
+
{ signal, timeoutMs }
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
async aliasIdentity(input, options) {
|
|
2218
|
+
if (!input.userId) {
|
|
2219
|
+
throw new CrossdeckError({
|
|
2220
|
+
type: "invalid_request_error",
|
|
2221
|
+
code: "missing_user_id",
|
|
2222
|
+
message: "aliasIdentity requires a non-empty userId."
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
if (!input.anonymousId) {
|
|
2226
|
+
throw new CrossdeckError({
|
|
2227
|
+
type: "invalid_request_error",
|
|
2228
|
+
code: "missing_anonymous_id",
|
|
2229
|
+
message: "aliasIdentity requires a non-empty anonymousId."
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
const traits = sanitizePropertyBag(input.traits, "traits");
|
|
2233
|
+
const body = {
|
|
2234
|
+
userId: input.userId,
|
|
2235
|
+
anonymousId: input.anonymousId
|
|
2236
|
+
};
|
|
2237
|
+
if (input.email) body.email = input.email;
|
|
2238
|
+
if (traits && Object.keys(traits).length > 0) body.traits = traits;
|
|
2239
|
+
return this.http.request("POST", "/identity/alias", {
|
|
2240
|
+
body,
|
|
2241
|
+
signal: options?.signal,
|
|
2242
|
+
timeoutMs: options?.timeoutMs
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
async forget(hints, options) {
|
|
2246
|
+
const body = this.identityPayload(hints);
|
|
2247
|
+
return this.http.request("POST", "/identity/forget", {
|
|
2248
|
+
body,
|
|
2249
|
+
signal: options?.signal,
|
|
2250
|
+
timeoutMs: options?.timeoutMs
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
// ============================================================
|
|
2254
|
+
// Entitlements — direct HTTP + TTL cache (v1.0.0+)
|
|
2255
|
+
//
|
|
2256
|
+
// `getEntitlements()` POSTs over the wire and populates the cache
|
|
2257
|
+
// under the response's canonical `crossdeckCustomerId`. Any
|
|
2258
|
+
// `userId` / `anonymousId` supplied as a hint is recorded as an
|
|
2259
|
+
// alias so a subsequent `isEntitled({ userId }, "pro")` resolves
|
|
2260
|
+
// to the same cache entry.
|
|
2261
|
+
// ============================================================
|
|
2262
|
+
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
|
+
});
|
|
2268
|
+
this.populateEntitlementCache(hints, response);
|
|
2269
|
+
return response;
|
|
2270
|
+
}
|
|
2271
|
+
async getCustomerEntitlements(customerId, options) {
|
|
2272
|
+
if (!customerId) {
|
|
2273
|
+
throw new CrossdeckError({
|
|
2274
|
+
type: "invalid_request_error",
|
|
2275
|
+
code: "missing_customer_id",
|
|
2276
|
+
message: "getCustomerEntitlements requires a customerId."
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
const response = await this.http.request(
|
|
2280
|
+
"GET",
|
|
2281
|
+
`/server/customers/${encodeURIComponent(customerId)}/entitlements`,
|
|
2282
|
+
{
|
|
2283
|
+
signal: options?.signal,
|
|
2284
|
+
timeoutMs: options?.timeoutMs
|
|
2285
|
+
}
|
|
2286
|
+
);
|
|
2287
|
+
this.populateEntitlementCache({ customerId }, response);
|
|
2288
|
+
return response;
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Synchronous entitlement check. Returns `true` iff the customer
|
|
2292
|
+
* has the entitlement AND the cache entry is fresh (within
|
|
2293
|
+
* `entitlementCacheTtlMs`, default 60s). Returns `false` when the
|
|
2294
|
+
* cache is cold or expired.
|
|
2295
|
+
*
|
|
2296
|
+
* The hint can be any combination of `customerId` / `userId` /
|
|
2297
|
+
* `anonymousId`. After `getEntitlements({ userId })` populates the
|
|
2298
|
+
* cache, subsequent `isEntitled({ userId }, "pro")` calls within
|
|
2299
|
+
* TTL are memory reads (no HTTP). The "warm cache" pattern that
|
|
2300
|
+
* makes hot-path entitlement gates cheap.
|
|
2301
|
+
*
|
|
2302
|
+
* await server.getEntitlements({ userId }); // warm
|
|
2303
|
+
* if (server.isEntitled({ userId }, "pro")) { // synchronous
|
|
2304
|
+
* // ...
|
|
2305
|
+
* }
|
|
2306
|
+
*
|
|
2307
|
+
* Caller is responsible for re-warming after TTL elapses. The cache
|
|
2308
|
+
* does NOT auto-refresh on read (would block the hot path).
|
|
2309
|
+
*/
|
|
2310
|
+
isEntitled(hint, key) {
|
|
2311
|
+
const customerId = this.resolveCacheCustomerId(hint);
|
|
2312
|
+
if (!customerId) return false;
|
|
2313
|
+
const result = this.entitlementCache.isEntitled(customerId, key);
|
|
2314
|
+
if (result) {
|
|
2315
|
+
this.debug.emit("sdk.entitlement_cache_used", `Cache hit for ${customerId}/${key}.`);
|
|
2316
|
+
}
|
|
2317
|
+
return result;
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* Snapshot of the customer's cached entitlements. Returns `[]` when
|
|
2321
|
+
* the cache is cold or expired. Same hint resolution as
|
|
2322
|
+
* `isEntitled()`.
|
|
2323
|
+
*/
|
|
2324
|
+
listEntitlements(hint) {
|
|
2325
|
+
const customerId = this.resolveCacheCustomerId(hint);
|
|
2326
|
+
if (!customerId) return [];
|
|
2327
|
+
return this.entitlementCache.list(customerId);
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* Subscribe to entitlement-cache mutations. Listener fires after
|
|
2331
|
+
* `getEntitlements()` populates the cache or `shutdown()` clears
|
|
2332
|
+
* it. Returns an idempotent unsubscribe function.
|
|
2333
|
+
*
|
|
2334
|
+
* Used by callers that want to react to entitlement changes (e.g.
|
|
2335
|
+
* a websocket layer notifying connected clients of plan upgrades).
|
|
2336
|
+
* Listener errors are swallowed — surfaced via
|
|
2337
|
+
* `diagnostics().entitlements.listenerErrors`.
|
|
2338
|
+
*/
|
|
2339
|
+
onEntitlementsChange(listener) {
|
|
2340
|
+
return this.entitlementCache.subscribe(listener);
|
|
2341
|
+
}
|
|
2342
|
+
// ============================================================
|
|
2343
|
+
// Events — queue-based track(); immediate ingest() for bulk imports
|
|
2344
|
+
// ============================================================
|
|
2345
|
+
/**
|
|
2346
|
+
* Queue an event for batched delivery. Returns synchronously — the
|
|
2347
|
+
* HTTP round-trip happens in the background.
|
|
2348
|
+
*
|
|
2349
|
+
* Behaviour parity with `@cross-deck/web`'s `track()`:
|
|
2350
|
+
* - Synchronous return, void.
|
|
2351
|
+
* - Throws sync on `missing_event_name`.
|
|
2352
|
+
* - Property bag sanitised through `validateEventProperties`.
|
|
2353
|
+
* - Runtime info (`runtime.*`) auto-merged into every event's
|
|
2354
|
+
* properties. Caller-supplied properties win on key collision.
|
|
2355
|
+
* - Breadcrumb auto-emitted (unless the name starts with `error.`,
|
|
2356
|
+
* which would cause a cycle).
|
|
2357
|
+
*
|
|
2358
|
+
* Differences from `@cross-deck/web`:
|
|
2359
|
+
* - Single-argument signature `track(event)` instead of
|
|
2360
|
+
* `track(name, properties)` — the Node wire shape needs the full
|
|
2361
|
+
* `ServerEvent` (identity hint, optional level + tags + categoryTags).
|
|
2362
|
+
* - Auto-fills `anonymousId` with `this.processAnonymousId` when no
|
|
2363
|
+
* identity hint is supplied. A captureError from
|
|
2364
|
+
* `uncaughtException` has no per-request context; without the
|
|
2365
|
+
* auto-fill, the event would be rejected at queue enqueue.
|
|
2366
|
+
*/
|
|
2367
|
+
track(event) {
|
|
2368
|
+
if (!event.name) {
|
|
2369
|
+
throw new CrossdeckError({
|
|
2370
|
+
type: "invalid_request_error",
|
|
2371
|
+
code: "missing_event_name",
|
|
2372
|
+
message: "track(event) requires a non-empty event.name."
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
const sanitized = sanitizePropertyBag(event.properties, "event properties") ?? {};
|
|
2376
|
+
if (this.debug.enabled) {
|
|
2377
|
+
const flagged = findSensitivePropertyKeys(sanitized);
|
|
2378
|
+
if (flagged.length > 0) {
|
|
2379
|
+
this.debug.emit(
|
|
2380
|
+
"sdk.sensitive_property_warning",
|
|
2381
|
+
`Event "${event.name}" has potentially sensitive property names: ${flagged.join(", ")}. Crossdeck is privacy-first \u2014 avoid sending PII unless intentional.`,
|
|
2382
|
+
{ eventName: event.name, flagged }
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
const properties = {
|
|
2387
|
+
...this.runtimeProperties,
|
|
2388
|
+
...this.superProps.getSuperProperties(),
|
|
2389
|
+
...sanitized
|
|
2390
|
+
};
|
|
2391
|
+
const groupIds = this.superProps.getGroupIds();
|
|
2392
|
+
if (Object.keys(groupIds).length > 0 && properties.$groups === void 0) {
|
|
2393
|
+
properties.$groups = groupIds;
|
|
2394
|
+
}
|
|
2395
|
+
const identity = this.resolveIdentity(event);
|
|
2396
|
+
const queued = {
|
|
2397
|
+
eventId: event.eventId ?? mintId("evt", 8),
|
|
2398
|
+
name: event.name,
|
|
2399
|
+
timestamp: event.timestamp ?? Date.now(),
|
|
2400
|
+
properties,
|
|
2401
|
+
...identity
|
|
2402
|
+
};
|
|
2403
|
+
if (event.level !== void 0) queued.level = event.level;
|
|
2404
|
+
if (event.tags !== void 0) queued.tags = event.tags;
|
|
2405
|
+
if (event.categoryTags !== void 0) queued.categoryTags = event.categoryTags;
|
|
2406
|
+
this.eventQueue.enqueue(queued);
|
|
2407
|
+
if (!event.name.startsWith("error.")) {
|
|
2408
|
+
this.breadcrumbs.add({
|
|
2409
|
+
timestamp: queued.timestamp,
|
|
2410
|
+
category: categoryFor(event.name),
|
|
2411
|
+
message: event.name,
|
|
2412
|
+
data: sanitized
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Immediate POST of one or more events. For bulk imports / replay
|
|
2418
|
+
* scenarios where the caller wants synchronous confirmation. Bypasses
|
|
2419
|
+
* the queue — no batching, no auto-fill of identity, no
|
|
2420
|
+
* runtime-enrichment.
|
|
2421
|
+
*
|
|
2422
|
+
* Use `track()` for the standard fire-and-forget telemetry path.
|
|
2423
|
+
* Use `ingest()` when you need:
|
|
2424
|
+
* - The IngestResponse synchronously.
|
|
2425
|
+
* - Strict per-event identity validation (no auto-fill).
|
|
2426
|
+
* - Caller-controlled idempotency key.
|
|
2427
|
+
*/
|
|
2428
|
+
async ingest(events, options = {}) {
|
|
2429
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
2430
|
+
throw new CrossdeckError({
|
|
2431
|
+
type: "invalid_request_error",
|
|
2432
|
+
code: "missing_events",
|
|
2433
|
+
message: "ingest requires at least one event."
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
const normalized = events.map((event) => this.normalizeIngestEvent(event));
|
|
2437
|
+
const body = {
|
|
2438
|
+
events: normalized,
|
|
2439
|
+
sdk: { name: SDK_NAME, version: this.sdkVersion }
|
|
2440
|
+
};
|
|
2441
|
+
if (this.appId) body.appId = this.appId;
|
|
2442
|
+
return this.http.request("POST", "/events", {
|
|
2443
|
+
body,
|
|
2444
|
+
idempotencyKey: options.idempotencyKey ?? mintId("batch"),
|
|
2445
|
+
signal: options.signal,
|
|
2446
|
+
timeoutMs: options.timeoutMs
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* Validate the secret key against the Crossdeck API and return the
|
|
2451
|
+
* resolved project + app metadata. Useful at boot to fail fast on a
|
|
2452
|
+
* misconfigured deployment — without this, a wrong secret key only
|
|
2453
|
+
* surfaces on the first event flush attempt, which may be minutes
|
|
2454
|
+
* after process start.
|
|
2455
|
+
*
|
|
2456
|
+
* const { projectId, appId, env, serverTime } = await server.heartbeat();
|
|
2457
|
+
*
|
|
2458
|
+
* Throws `CrossdeckError` on:
|
|
2459
|
+
* - `authentication_error` — secret key invalid / revoked
|
|
2460
|
+
* - `network_error` — couldn't reach the backend
|
|
2461
|
+
* - `request_timeout` — backend slow / unreachable
|
|
2462
|
+
*
|
|
2463
|
+
* Side effect: success records `(serverTime, clientTime)` for clock-
|
|
2464
|
+
* skew detection in `diagnostics().clock` (Phase 2 — not yet exposed
|
|
2465
|
+
* in this SDK release but the data is captured).
|
|
2466
|
+
*
|
|
2467
|
+
* Not auto-called. Caller decides whether the trade-off (one extra
|
|
2468
|
+
* boot request + ~50ms p50 latency) is worth the early-failure
|
|
2469
|
+
* signal. For serverless cold-starts, it usually is — cheap
|
|
2470
|
+
* compared to the cost of a silent broken secret in production.
|
|
2471
|
+
*/
|
|
2472
|
+
async heartbeat(options) {
|
|
2473
|
+
const result = await this.http.request("GET", "/sdk/heartbeat", {
|
|
2474
|
+
signal: options?.signal,
|
|
2475
|
+
timeoutMs: options?.timeoutMs
|
|
2476
|
+
});
|
|
2477
|
+
return result;
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* Drain the event queue. Resolves when the in-flight batch completes
|
|
2481
|
+
* (success or failure). On failure, events stay queued for the next
|
|
2482
|
+
* scheduled retry — the resolved promise does NOT throw.
|
|
2483
|
+
*
|
|
2484
|
+
* Typical callers:
|
|
2485
|
+
* - End of a Lambda handler: `await server.flush()` before return
|
|
2486
|
+
* so events land before the platform freezes the process.
|
|
2487
|
+
* - Express server shutdown: `await server.flush()` inside the
|
|
2488
|
+
* SIGTERM handler.
|
|
2489
|
+
* - Tests: drain between assertions.
|
|
2490
|
+
*
|
|
2491
|
+
* Idempotent — flush on an empty queue is a no-op.
|
|
2492
|
+
*/
|
|
2493
|
+
async flush() {
|
|
2494
|
+
await this.eventQueue.flush();
|
|
2495
|
+
}
|
|
2496
|
+
async syncPurchases(input, options) {
|
|
2497
|
+
if (!input.signedTransactionInfo) {
|
|
2498
|
+
throw new CrossdeckError({
|
|
2499
|
+
type: "invalid_request_error",
|
|
2500
|
+
code: "missing_signed_transaction_info",
|
|
2501
|
+
message: "syncPurchases requires a signedTransactionInfo string."
|
|
2502
|
+
});
|
|
2503
|
+
}
|
|
2504
|
+
return this.http.request("POST", "/purchases/sync", {
|
|
2505
|
+
body: { rail: input.rail ?? "apple", ...input },
|
|
2506
|
+
signal: options?.signal,
|
|
2507
|
+
timeoutMs: options?.timeoutMs
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
// ============================================================
|
|
2511
|
+
// Manual entitlement controls + audit — direct HTTP
|
|
2512
|
+
// ============================================================
|
|
2513
|
+
async grantEntitlement(input, options) {
|
|
2514
|
+
if (!input.customerId) {
|
|
2515
|
+
throw new CrossdeckError({
|
|
2516
|
+
type: "invalid_request_error",
|
|
2517
|
+
code: "missing_customer_id",
|
|
2518
|
+
message: "grantEntitlement requires a customerId."
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
return this.http.request(
|
|
2522
|
+
"POST",
|
|
2523
|
+
`/server/customers/${encodeURIComponent(input.customerId)}/grant`,
|
|
2524
|
+
{
|
|
2525
|
+
body: {
|
|
2526
|
+
entitlementKey: input.entitlementKey,
|
|
2527
|
+
duration: input.duration,
|
|
2528
|
+
reason: input.reason
|
|
2529
|
+
},
|
|
2530
|
+
signal: options?.signal,
|
|
2531
|
+
timeoutMs: options?.timeoutMs
|
|
2532
|
+
}
|
|
2533
|
+
);
|
|
2534
|
+
}
|
|
2535
|
+
/**
|
|
2536
|
+
* Grant multiple entitlements in one logical call. Backend lacks a
|
|
2537
|
+
* bulk endpoint today, so this is a client-side fan-out — each
|
|
2538
|
+
* grant fires a separate request. Results return as a
|
|
2539
|
+
* settled-promise array so partial failures don't drop the rest:
|
|
2540
|
+
* the caller decides how to handle each `{ ok, value }` /
|
|
2541
|
+
* `{ ok: false, error }` entry.
|
|
2542
|
+
*
|
|
2543
|
+
* Use for ops sweeps (e.g. "grant the entire `pro` tier a one-time
|
|
2544
|
+
* `pro_q1_bonus` entitlement"). The bounded concurrency (default
|
|
2545
|
+
* `maxConcurrency: 5`) avoids hammering the backend; the rate-
|
|
2546
|
+
* limit policy on the server still kicks in if needed.
|
|
2547
|
+
*/
|
|
2548
|
+
async bulkGrantEntitlement(grants, options) {
|
|
2549
|
+
return runBulkOperation(
|
|
2550
|
+
grants,
|
|
2551
|
+
options?.maxConcurrency ?? 5,
|
|
2552
|
+
(input) => this.grantEntitlement(input, { signal: options?.signal, timeoutMs: options?.timeoutMs })
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
async revokeEntitlement(input, options) {
|
|
2556
|
+
if (!input.customerId) {
|
|
2557
|
+
throw new CrossdeckError({
|
|
2558
|
+
type: "invalid_request_error",
|
|
2559
|
+
code: "missing_customer_id",
|
|
2560
|
+
message: "revokeEntitlement requires a customerId."
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
return this.http.request(
|
|
2564
|
+
"POST",
|
|
2565
|
+
`/server/customers/${encodeURIComponent(input.customerId)}/revoke`,
|
|
2566
|
+
{
|
|
2567
|
+
body: {
|
|
2568
|
+
entitlementKey: input.entitlementKey,
|
|
2569
|
+
reason: input.reason
|
|
2570
|
+
},
|
|
2571
|
+
signal: options?.signal,
|
|
2572
|
+
timeoutMs: options?.timeoutMs
|
|
2573
|
+
}
|
|
2574
|
+
);
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Revoke multiple entitlements in one logical call. Same
|
|
2578
|
+
* settled-array contract as `bulkGrantEntitlement` — see that
|
|
2579
|
+
* doc for behaviour notes.
|
|
2580
|
+
*/
|
|
2581
|
+
async bulkRevokeEntitlement(revokes, options) {
|
|
2582
|
+
return runBulkOperation(
|
|
2583
|
+
revokes,
|
|
2584
|
+
options?.maxConcurrency ?? 5,
|
|
2585
|
+
(input) => this.revokeEntitlement(input, { signal: options?.signal, timeoutMs: options?.timeoutMs })
|
|
2586
|
+
);
|
|
2587
|
+
}
|
|
2588
|
+
async getAuditEntry(eventId, options) {
|
|
2589
|
+
if (!eventId) {
|
|
2590
|
+
throw new CrossdeckError({
|
|
2591
|
+
type: "invalid_request_error",
|
|
2592
|
+
code: "missing_event_id",
|
|
2593
|
+
message: "getAuditEntry requires an eventId."
|
|
2594
|
+
});
|
|
483
2595
|
}
|
|
484
2596
|
const result = await this.http.request(
|
|
485
2597
|
"GET",
|
|
486
|
-
`/server/audit/${encodeURIComponent(eventId)}
|
|
2598
|
+
`/server/audit/${encodeURIComponent(eventId)}`,
|
|
2599
|
+
{
|
|
2600
|
+
signal: options?.signal,
|
|
2601
|
+
timeoutMs: options?.timeoutMs
|
|
2602
|
+
}
|
|
487
2603
|
);
|
|
488
2604
|
return result.data;
|
|
489
2605
|
}
|
|
2606
|
+
// ============================================================
|
|
2607
|
+
// USP 1 — Error capture public surface
|
|
2608
|
+
// ============================================================
|
|
2609
|
+
/**
|
|
2610
|
+
* Manually capture an error from a try/catch block.
|
|
2611
|
+
*
|
|
2612
|
+
* try { … } catch (err) {
|
|
2613
|
+
* server.captureError(err, { context: { jobId }, tags: { flow: "checkout" } });
|
|
2614
|
+
* }
|
|
2615
|
+
*
|
|
2616
|
+
* The error ships through the same event queue analytics rides on
|
|
2617
|
+
* (retried, idempotent, runtime-enriched). Returns silently — never
|
|
2618
|
+
* throws, even if error capture is disabled.
|
|
2619
|
+
*/
|
|
2620
|
+
captureError(error, options) {
|
|
2621
|
+
if (!this.errorTracker) return;
|
|
2622
|
+
this.errorTracker.captureError(error, options);
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Capture a non-error signal as an issue. Sentry's `captureMessage`
|
|
2626
|
+
* pattern — for "we hit the deprecated code path" / "soft-warning
|
|
2627
|
+
* triggered" signals where there's no Error to throw.
|
|
2628
|
+
*/
|
|
2629
|
+
captureMessage(message, level = "info") {
|
|
2630
|
+
if (!this.errorTracker) return;
|
|
2631
|
+
this.errorTracker.captureMessage(message, level);
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* Attach a tag to every subsequent error report. Sentry pattern.
|
|
2635
|
+
* Tags are flat string key/value (queryable in the dashboard);
|
|
2636
|
+
* use `setContext()` for structured blobs.
|
|
2637
|
+
*/
|
|
2638
|
+
setTag(key, value) {
|
|
2639
|
+
this.errorTags[key] = value;
|
|
2640
|
+
}
|
|
2641
|
+
/** Bulk-set tags. Merges with existing tags. */
|
|
2642
|
+
setTags(tags) {
|
|
2643
|
+
Object.assign(this.errorTags, tags);
|
|
2644
|
+
}
|
|
2645
|
+
/**
|
|
2646
|
+
* Attach a structured context blob to every subsequent error report.
|
|
2647
|
+
* Unlike tags (flat key/value), context is a named bag of arbitrary
|
|
2648
|
+
* JSON-serialisable data.
|
|
2649
|
+
*
|
|
2650
|
+
* server.setContext("cart", { items: 3, total: 42.99 });
|
|
2651
|
+
*/
|
|
2652
|
+
setContext(name, data) {
|
|
2653
|
+
this.errorContext[name] = data;
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* Add a custom breadcrumb to the rolling buffer. The last 50
|
|
2657
|
+
* breadcrumbs are attached to every subsequent error report —
|
|
2658
|
+
* "what was the request doing right before things broke."
|
|
2659
|
+
*/
|
|
2660
|
+
addBreadcrumb(crumb) {
|
|
2661
|
+
this.breadcrumbs.add(crumb);
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* Install a pre-send hook for errors. Return null to drop the report,
|
|
2665
|
+
* or a modified `CapturedError` to scrub fields. Sentry's
|
|
2666
|
+
* `beforeSend` pattern — the only place to add app-specific PII
|
|
2667
|
+
* redaction (auth tokens in URLs, etc.) before the report leaves the
|
|
2668
|
+
* process.
|
|
2669
|
+
*
|
|
2670
|
+
* The hook is called LAST, after rate-limit + sampling + path gates
|
|
2671
|
+
* already passed. A throwing hook falls back to the original error.
|
|
2672
|
+
*/
|
|
2673
|
+
setErrorBeforeSend(hook) {
|
|
2674
|
+
this.errorBeforeSend = hook;
|
|
2675
|
+
}
|
|
2676
|
+
// ============================================================
|
|
2677
|
+
// USP 2 — Super-properties + groups (Mixpanel pattern)
|
|
2678
|
+
// ============================================================
|
|
2679
|
+
/**
|
|
2680
|
+
* Register super-properties — every subsequent event carries these
|
|
2681
|
+
* keys on its `properties` bag automatically. Mixpanel pattern.
|
|
2682
|
+
*
|
|
2683
|
+
* server.register({ tenant: "acme", plan: "pro" });
|
|
2684
|
+
* server.track({ name: "paywall_shown", developerUserId: userId });
|
|
2685
|
+
* // ^ event carries `tenant` + `plan` in properties
|
|
2686
|
+
*
|
|
2687
|
+
* Values that are `null` are deleted (the explicit "stop tracking
|
|
2688
|
+
* this key" idiom). Sanitised through `validateEventProperties` so
|
|
2689
|
+
* a `{ avatar: <Buffer> }` payload can't poison the queue.
|
|
2690
|
+
*
|
|
2691
|
+
* Returns a defensive snapshot of the resulting bag.
|
|
2692
|
+
*
|
|
2693
|
+
* **Multi-tenant servers — read carefully.** Super-properties are
|
|
2694
|
+
* PROCESS-SCOPED. In a single Node process handling requests for
|
|
2695
|
+
* many tenants (the common multi-tenant SaaS shape), calling
|
|
2696
|
+
* `server.register({ tenant: "acme" })` taints EVERY subsequent
|
|
2697
|
+
* event from that process — including ones serving tenant "beta".
|
|
2698
|
+
* That's almost never what you want.
|
|
2699
|
+
*
|
|
2700
|
+
* The right pattern for per-request properties is to pass them on
|
|
2701
|
+
* the `track()` call itself:
|
|
2702
|
+
*
|
|
2703
|
+
* server.track({
|
|
2704
|
+
* name: "paywall_shown",
|
|
2705
|
+
* developerUserId: req.user.id,
|
|
2706
|
+
* properties: { tenant: req.tenantId, plan: req.user.plan },
|
|
2707
|
+
* });
|
|
2708
|
+
*
|
|
2709
|
+
* Reserve `register()` for properties that genuinely apply to every
|
|
2710
|
+
* event from this process — e.g. service version, region, build
|
|
2711
|
+
* commit. For those, `runtime-info` already provides
|
|
2712
|
+
* `runtime.serviceVersion` etc. automatically.
|
|
2713
|
+
*/
|
|
2714
|
+
register(properties) {
|
|
2715
|
+
const validation = validateEventProperties(properties);
|
|
2716
|
+
const result = this.superProps.register(validation.properties);
|
|
2717
|
+
this.debug.emit(
|
|
2718
|
+
"sdk.super_property_registered",
|
|
2719
|
+
`Super-properties updated. ${Object.keys(validation.properties).length} key(s) merged.`
|
|
2720
|
+
);
|
|
2721
|
+
return result;
|
|
2722
|
+
}
|
|
2723
|
+
/** Remove a single super-property key. Idempotent. */
|
|
2724
|
+
unregister(key) {
|
|
2725
|
+
this.superProps.unregister(key);
|
|
2726
|
+
}
|
|
2727
|
+
/** Snapshot of the current super-property bag. */
|
|
2728
|
+
getSuperProperties() {
|
|
2729
|
+
return this.superProps.getSuperProperties();
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Associate the current SDK instance with a group (org, team,
|
|
2733
|
+
* account, plan). Mixpanel / Segment Group Analytics pattern.
|
|
2734
|
+
*
|
|
2735
|
+
* server.group("org", "acme_inc");
|
|
2736
|
+
* server.group("team", "design", { headcount: 12 });
|
|
2737
|
+
*
|
|
2738
|
+
* Once set, every subsequent event carries `$groups.<type>: id` on
|
|
2739
|
+
* its `properties` bag, enabling B2B dashboard pivots. Pass
|
|
2740
|
+
* `id: null` to clear a group membership.
|
|
2741
|
+
*
|
|
2742
|
+
* Group traits are sanitised through `validateEventProperties`.
|
|
2743
|
+
*/
|
|
2744
|
+
group(type, id, traits) {
|
|
2745
|
+
if (!type) {
|
|
2746
|
+
throw new CrossdeckError({
|
|
2747
|
+
type: "invalid_request_error",
|
|
2748
|
+
code: "missing_group_type",
|
|
2749
|
+
message: "group(type, id) requires a non-empty type."
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
|
|
2753
|
+
this.superProps.setGroup(type, id, sanitisedTraits);
|
|
2754
|
+
}
|
|
2755
|
+
/** Snapshot of current group memberships keyed by type. */
|
|
2756
|
+
getGroups() {
|
|
2757
|
+
return this.superProps.getGroups();
|
|
2758
|
+
}
|
|
2759
|
+
// ============================================================
|
|
2760
|
+
// Diagnostics — for debugging + the dashboard's heartbeat row
|
|
2761
|
+
// ============================================================
|
|
2762
|
+
diagnostics() {
|
|
2763
|
+
return {
|
|
2764
|
+
sdkVersion: this.sdkVersion,
|
|
2765
|
+
baseUrl: this.baseUrl,
|
|
2766
|
+
secretKeyPrefix: this.secretKeyPrefix,
|
|
2767
|
+
env: this.env,
|
|
2768
|
+
runtime: {
|
|
2769
|
+
nodeVersion: this.runtime.nodeVersion,
|
|
2770
|
+
platform: this.runtime.platform,
|
|
2771
|
+
hostname: this.runtime.hostname,
|
|
2772
|
+
host: this.runtime.host,
|
|
2773
|
+
region: this.runtime.region,
|
|
2774
|
+
serviceName: this.runtime.serviceName,
|
|
2775
|
+
serviceVersion: this.runtime.serviceVersion,
|
|
2776
|
+
instanceId: this.runtime.instanceId
|
|
2777
|
+
},
|
|
2778
|
+
entitlements: {
|
|
2779
|
+
count: this.entitlementCache.customerCount,
|
|
2780
|
+
lastUpdated: this.entitlementCache.lastUpdated,
|
|
2781
|
+
ttlMs: this.entitlementCache.ttl,
|
|
2782
|
+
listenerErrors: this.entitlementCache.listenerErrors
|
|
2783
|
+
},
|
|
2784
|
+
events: this.eventQueue.getStats(),
|
|
2785
|
+
errors: {
|
|
2786
|
+
sessionCount: this.errorTracker?.reportedCount ?? 0,
|
|
2787
|
+
fingerprintsTracked: this.errorTracker?.fingerprintsTracked ?? 0,
|
|
2788
|
+
handlersInstalled: this.errorTracker?.handlersInstalled ?? false
|
|
2789
|
+
}
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* Tear down handlers and clear in-memory state. Tests + custom
|
|
2794
|
+
* lifecycle callers only. Production code should rely on
|
|
2795
|
+
* `flush-on-exit` instead.
|
|
2796
|
+
*/
|
|
2797
|
+
shutdown(reason = "shutdown") {
|
|
2798
|
+
this.emit("sdk.shutdown", { reason });
|
|
2799
|
+
this.errorTracker?.uninstall();
|
|
2800
|
+
this.flushOnExit?.uninstall();
|
|
2801
|
+
this.eventQueue.reset();
|
|
2802
|
+
this.breadcrumbs.clear();
|
|
2803
|
+
this.superProps.clear();
|
|
2804
|
+
this.entitlementCache.clear();
|
|
2805
|
+
this.customerIdAliases.clear();
|
|
2806
|
+
this.errorContext = {};
|
|
2807
|
+
this.errorTags = {};
|
|
2808
|
+
this.errorBeforeSend = null;
|
|
2809
|
+
this.removeAllListeners();
|
|
2810
|
+
}
|
|
2811
|
+
on(event, listener) {
|
|
2812
|
+
return super.on(event, listener);
|
|
2813
|
+
}
|
|
2814
|
+
once(event, listener) {
|
|
2815
|
+
return super.once(event, listener);
|
|
2816
|
+
}
|
|
2817
|
+
off(event, listener) {
|
|
2818
|
+
return super.off(event, listener);
|
|
2819
|
+
}
|
|
2820
|
+
emit(event, ...args) {
|
|
2821
|
+
return super.emit(event, ...args);
|
|
2822
|
+
}
|
|
2823
|
+
// ============================================================
|
|
2824
|
+
// Health + readiness + lifecycle
|
|
2825
|
+
// ============================================================
|
|
2826
|
+
/**
|
|
2827
|
+
* Synchronous readiness check — "is the SDK in a state where it
|
|
2828
|
+
* should accept new traffic?". Used by Kubernetes readiness probes
|
|
2829
|
+
* and backpressure-aware callers.
|
|
2830
|
+
*
|
|
2831
|
+
* Returns `false` if:
|
|
2832
|
+
* - The event queue is in a sustained retry storm
|
|
2833
|
+
* (`consecutiveFailures >= 5`).
|
|
2834
|
+
* - The event queue's buffered count is at >= 80% of HARD_BUFFER_CAP.
|
|
2835
|
+
*
|
|
2836
|
+
* Otherwise `true`. The default isn't "perfectly healthy" — the
|
|
2837
|
+
* SDK is happy to enqueue events even during transient flush
|
|
2838
|
+
* failures because the queue's retry path handles them. Only
|
|
2839
|
+
* sustained failure flips this to `false`.
|
|
2840
|
+
*/
|
|
2841
|
+
isReady() {
|
|
2842
|
+
const stats = this.eventQueue.getStats();
|
|
2843
|
+
if (stats.consecutiveFailures >= 5) return false;
|
|
2844
|
+
if (stats.buffered >= 800) return false;
|
|
2845
|
+
return true;
|
|
2846
|
+
}
|
|
2847
|
+
/**
|
|
2848
|
+
* Async wait until `isReady()` returns true OR the timeout elapses.
|
|
2849
|
+
* Resolves `true` on ready, `false` on timeout. Polls every 50ms by
|
|
2850
|
+
* default — backpressure for callers writing high-volume servers.
|
|
2851
|
+
*
|
|
2852
|
+
* if (!(await server.awaitReady(2000))) {
|
|
2853
|
+
* // shed load — SDK is in a retry storm, don't queue more
|
|
2854
|
+
* }
|
|
2855
|
+
*/
|
|
2856
|
+
async awaitReady(timeoutMs = 5e3, pollIntervalMs = 50) {
|
|
2857
|
+
if (this.isReady()) return true;
|
|
2858
|
+
const start = Date.now();
|
|
2859
|
+
return new Promise((resolve) => {
|
|
2860
|
+
const tick = () => {
|
|
2861
|
+
if (this.isReady()) {
|
|
2862
|
+
resolve(true);
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2865
|
+
if (Date.now() - start >= timeoutMs) {
|
|
2866
|
+
resolve(false);
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
const t = setTimeout(tick, pollIntervalMs);
|
|
2870
|
+
if (typeof t.unref === "function") {
|
|
2871
|
+
try {
|
|
2872
|
+
t.unref();
|
|
2873
|
+
} catch {
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
};
|
|
2877
|
+
tick();
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
/**
|
|
2881
|
+
* Snapshot for Kubernetes liveness + readiness probes. `healthy`
|
|
2882
|
+
* stays true unless the SDK is in a catastrophic state (which
|
|
2883
|
+
* currently can't happen without crashing the process). `ready`
|
|
2884
|
+
* matches `isReady()`.
|
|
2885
|
+
*
|
|
2886
|
+
* app.get("/healthz", (_req, res) => {
|
|
2887
|
+
* const h = server.getHealth();
|
|
2888
|
+
* res.status(h.healthy ? 200 : 503).json(h);
|
|
2889
|
+
* });
|
|
2890
|
+
*/
|
|
2891
|
+
getHealth() {
|
|
2892
|
+
const stats = this.eventQueue.getStats();
|
|
2893
|
+
return {
|
|
2894
|
+
ready: this.isReady(),
|
|
2895
|
+
healthy: true,
|
|
2896
|
+
bufferedEvents: stats.buffered,
|
|
2897
|
+
inFlight: stats.inFlight,
|
|
2898
|
+
consecutiveFailures: stats.consecutiveFailures,
|
|
2899
|
+
lastFlushAt: stats.lastFlushAt,
|
|
2900
|
+
lastError: stats.lastError,
|
|
2901
|
+
errorHandlersInstalled: this.errorTracker?.handlersInstalled ?? false
|
|
2902
|
+
};
|
|
2903
|
+
}
|
|
2904
|
+
/**
|
|
2905
|
+
* Sync disposal hook — runs when a `using` declaration exits scope
|
|
2906
|
+
* (TC39 explicit-resource-management, Node 20+ / TS 5.2+).
|
|
2907
|
+
*
|
|
2908
|
+
* using server = new CrossdeckServer({ ... });
|
|
2909
|
+
* // ... use server ...
|
|
2910
|
+
* // at end of block, server[Symbol.dispose]() runs automatically
|
|
2911
|
+
*
|
|
2912
|
+
* `Symbol.dispose` is synchronous so we can't await `flush()` here
|
|
2913
|
+
* — for that, use `await using` + `[Symbol.asyncDispose]()`. This
|
|
2914
|
+
* sync variant just calls `shutdown()` (handler cleanup +
|
|
2915
|
+
* in-memory state wipe).
|
|
2916
|
+
*/
|
|
2917
|
+
[Symbol.dispose]() {
|
|
2918
|
+
this.shutdown("dispose");
|
|
2919
|
+
}
|
|
2920
|
+
/**
|
|
2921
|
+
* Async disposal hook — runs when an `await using` declaration
|
|
2922
|
+
* exits scope. Awaits `flush()` THEN runs `shutdown()`. Use this
|
|
2923
|
+
* variant when the caller needs the queue drained before exit
|
|
2924
|
+
* (the common case for serverless handlers).
|
|
2925
|
+
*
|
|
2926
|
+
* await using server = new CrossdeckServer({ ... });
|
|
2927
|
+
*/
|
|
2928
|
+
async [Symbol.asyncDispose]() {
|
|
2929
|
+
try {
|
|
2930
|
+
await this.flush();
|
|
2931
|
+
} catch {
|
|
2932
|
+
}
|
|
2933
|
+
this.shutdown("asyncDispose");
|
|
2934
|
+
}
|
|
2935
|
+
// ============================================================
|
|
2936
|
+
reportCapturedError(captured) {
|
|
2937
|
+
try {
|
|
2938
|
+
this.emit("error.captured", {
|
|
2939
|
+
fingerprint: captured.fingerprint,
|
|
2940
|
+
kind: captured.kind,
|
|
2941
|
+
message: captured.message
|
|
2942
|
+
});
|
|
2943
|
+
} catch {
|
|
2944
|
+
}
|
|
2945
|
+
const properties = {
|
|
2946
|
+
fingerprint: captured.fingerprint,
|
|
2947
|
+
level: captured.level,
|
|
2948
|
+
errorType: captured.errorType,
|
|
2949
|
+
message: captured.message,
|
|
2950
|
+
stack: captured.rawStack ?? void 0,
|
|
2951
|
+
frames: captured.frames,
|
|
2952
|
+
tags: captured.tags,
|
|
2953
|
+
context: captured.context,
|
|
2954
|
+
breadcrumbs: captured.breadcrumbs,
|
|
2955
|
+
http: captured.http
|
|
2956
|
+
};
|
|
2957
|
+
for (const k of Object.keys(properties)) {
|
|
2958
|
+
if (properties[k] === void 0) delete properties[k];
|
|
2959
|
+
}
|
|
2960
|
+
this.track({
|
|
2961
|
+
name: captured.kind,
|
|
2962
|
+
timestamp: captured.timestamp,
|
|
2963
|
+
properties,
|
|
2964
|
+
level: captured.level,
|
|
2965
|
+
tags: captured.tags
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2969
|
+
* Populate the entitlement cache from a fresh server response.
|
|
2970
|
+
* Records aliases so `userId` / `anonymousId` hints supplied to
|
|
2971
|
+
* `getEntitlements()` resolve to the same cache entry on subsequent
|
|
2972
|
+
* `isEntitled({ userId }, ...)` calls.
|
|
2973
|
+
*
|
|
2974
|
+
* Bounds the alias map at MAX_CUSTOMER_ID_ALIASES — once full, the
|
|
2975
|
+
* oldest aliases (by insertion order) are evicted FIFO. Symmetric
|
|
2976
|
+
* with the entitlement cache's max-customers cap.
|
|
2977
|
+
*/
|
|
2978
|
+
populateEntitlementCache(hints, response) {
|
|
2979
|
+
const customerId = response.crossdeckCustomerId;
|
|
2980
|
+
if (!customerId) return;
|
|
2981
|
+
this.entitlementCache.setForCustomer(customerId, response.data);
|
|
2982
|
+
if (hints.userId) this.touchAlias(hints.userId, customerId);
|
|
2983
|
+
if (hints.anonymousId) this.touchAlias(hints.anonymousId, customerId);
|
|
2984
|
+
this.debug.emit(
|
|
2985
|
+
"sdk.entitlement_cache_warm",
|
|
2986
|
+
`Entitlement cache warmed for ${customerId} (${response.data.length} entitlement(s)).`
|
|
2987
|
+
);
|
|
2988
|
+
try {
|
|
2989
|
+
this.emit("entitlements.warmed", {
|
|
2990
|
+
customerId,
|
|
2991
|
+
count: response.data.length
|
|
2992
|
+
});
|
|
2993
|
+
} catch {
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
touchAlias(alias, customerId) {
|
|
2997
|
+
this.customerIdAliases.delete(alias);
|
|
2998
|
+
this.customerIdAliases.set(alias, customerId);
|
|
2999
|
+
while (this.customerIdAliases.size > MAX_CUSTOMER_ID_ALIASES) {
|
|
3000
|
+
const oldest = this.customerIdAliases.keys().next().value;
|
|
3001
|
+
if (oldest === void 0) break;
|
|
3002
|
+
this.customerIdAliases.delete(oldest);
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
/**
|
|
3006
|
+
* Resolve any hint shape (canonical customerId / userId hint /
|
|
3007
|
+
* anonymousId hint / raw string) to a `crossdeckCustomerId` if we
|
|
3008
|
+
* have a cache entry for it.
|
|
3009
|
+
*/
|
|
3010
|
+
resolveCacheCustomerId(hint) {
|
|
3011
|
+
if (typeof hint === "string") {
|
|
3012
|
+
if (this.entitlementCache.isFresh(hint)) return hint;
|
|
3013
|
+
return this.customerIdAliases.get(hint) ?? null;
|
|
3014
|
+
}
|
|
3015
|
+
if (hint.customerId) return hint.customerId;
|
|
3016
|
+
if (hint.userId) return this.customerIdAliases.get(hint.userId) ?? null;
|
|
3017
|
+
if (hint.anonymousId) return this.customerIdAliases.get(hint.anonymousId) ?? null;
|
|
3018
|
+
return null;
|
|
3019
|
+
}
|
|
490
3020
|
identityPayload(hints) {
|
|
491
3021
|
const payload = {};
|
|
492
3022
|
if (typeof hints.customerId === "string" && hints.customerId) {
|
|
@@ -507,7 +3037,27 @@ var CrossdeckServer = class {
|
|
|
507
3037
|
}
|
|
508
3038
|
return payload;
|
|
509
3039
|
}
|
|
510
|
-
|
|
3040
|
+
/**
|
|
3041
|
+
* Resolve event identity. Caller-supplied wins; falls back to
|
|
3042
|
+
* `processAnonymousId` so events from `captureError` /
|
|
3043
|
+
* uncaughtException always have at least one identity hint.
|
|
3044
|
+
*/
|
|
3045
|
+
resolveIdentity(event) {
|
|
3046
|
+
const out = {};
|
|
3047
|
+
if (event.developerUserId) out.developerUserId = event.developerUserId;
|
|
3048
|
+
if (event.anonymousId) out.anonymousId = event.anonymousId;
|
|
3049
|
+
if (event.crossdeckCustomerId) out.crossdeckCustomerId = event.crossdeckCustomerId;
|
|
3050
|
+
if (!out.developerUserId && !out.anonymousId && !out.crossdeckCustomerId) {
|
|
3051
|
+
out.anonymousId = this.processAnonymousId;
|
|
3052
|
+
}
|
|
3053
|
+
return out;
|
|
3054
|
+
}
|
|
3055
|
+
/**
|
|
3056
|
+
* Strict normalisation for `ingest()` — no auto-fill of identity,
|
|
3057
|
+
* caller must supply at least one hint per event. Matches v0.1.0
|
|
3058
|
+
* behaviour for backward compatibility with bulk-import callers.
|
|
3059
|
+
*/
|
|
3060
|
+
normalizeIngestEvent(event) {
|
|
511
3061
|
if (!event.name) {
|
|
512
3062
|
throw new CrossdeckError({
|
|
513
3063
|
type: "invalid_request_error",
|
|
@@ -527,18 +3077,10 @@ var CrossdeckServer = class {
|
|
|
527
3077
|
return {
|
|
528
3078
|
...event,
|
|
529
3079
|
properties,
|
|
530
|
-
eventId: event.eventId ??
|
|
3080
|
+
eventId: event.eventId ?? mintId("evt", 8),
|
|
531
3081
|
timestamp: event.timestamp ?? Date.now()
|
|
532
3082
|
};
|
|
533
3083
|
}
|
|
534
|
-
mintEventId() {
|
|
535
|
-
const ts = Date.now().toString(36);
|
|
536
|
-
return `evt_${ts}${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
537
|
-
}
|
|
538
|
-
mintBatchId() {
|
|
539
|
-
const ts = Date.now().toString(36);
|
|
540
|
-
return `batch_${ts}${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
541
|
-
}
|
|
542
3084
|
};
|
|
543
3085
|
function sanitizePropertyBag(input, fieldName) {
|
|
544
3086
|
if (input === void 0) return void 0;
|
|
@@ -552,12 +3094,344 @@ function sanitizePropertyBag(input, fieldName) {
|
|
|
552
3094
|
});
|
|
553
3095
|
}
|
|
554
3096
|
}
|
|
3097
|
+
function categoryFor(name) {
|
|
3098
|
+
if (name.startsWith("page.") || name.startsWith("navigation.")) return "navigation";
|
|
3099
|
+
if (name.startsWith("element.") || name.startsWith("ui.click")) return "ui.click";
|
|
3100
|
+
if (name.startsWith("http.") || name === "request.handled") return "http";
|
|
3101
|
+
return "custom";
|
|
3102
|
+
}
|
|
3103
|
+
var MAX_CUSTOMER_ID_ALIASES = 1e4;
|
|
3104
|
+
function inferEnvFromKey(secretKey) {
|
|
3105
|
+
return secretKey.startsWith("cd_sk_live_") ? "production" : "sandbox";
|
|
3106
|
+
}
|
|
3107
|
+
async function runBulkOperation(inputs, maxConcurrency, op) {
|
|
3108
|
+
const results = new Array(inputs.length);
|
|
3109
|
+
let cursor = 0;
|
|
3110
|
+
const workers = new Array(Math.min(maxConcurrency, Math.max(1, inputs.length))).fill(0).map(async () => {
|
|
3111
|
+
while (true) {
|
|
3112
|
+
const index = cursor++;
|
|
3113
|
+
if (index >= inputs.length) return;
|
|
3114
|
+
const input = inputs[index];
|
|
3115
|
+
try {
|
|
3116
|
+
const value = await op(input);
|
|
3117
|
+
results[index] = { input, ok: true, value };
|
|
3118
|
+
} catch (err) {
|
|
3119
|
+
results[index] = {
|
|
3120
|
+
input,
|
|
3121
|
+
ok: false,
|
|
3122
|
+
error: err instanceof CrossdeckError ? err : new CrossdeckError({
|
|
3123
|
+
type: "internal_error",
|
|
3124
|
+
code: "bulk_operation_failed",
|
|
3125
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3126
|
+
})
|
|
3127
|
+
};
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
});
|
|
3131
|
+
await Promise.all(workers);
|
|
3132
|
+
return results;
|
|
3133
|
+
}
|
|
3134
|
+
function maskSecretKey(secretKey) {
|
|
3135
|
+
const m = secretKey.match(/^cd_sk_(test|live)_/);
|
|
3136
|
+
const prefix = m ? m[0] : secretKey.slice(0, 11);
|
|
3137
|
+
const tail = secretKey.length > prefix.length + 4 ? secretKey.slice(-4) : "";
|
|
3138
|
+
return `${prefix}****${tail}`;
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
// src/error-codes.ts
|
|
3142
|
+
var _CROSSDECK_ERROR_CODES = Object.freeze([
|
|
3143
|
+
// ----- Configuration -----
|
|
3144
|
+
{
|
|
3145
|
+
code: "invalid_secret_key",
|
|
3146
|
+
type: "configuration_error",
|
|
3147
|
+
description: "The secret key passed to new CrossdeckServer({ secretKey }) doesn't start with cd_sk_.",
|
|
3148
|
+
resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page. Server SDKs use cd_sk_test_\u2026 (sandbox) or cd_sk_live_\u2026 (production). Never ship this key to a browser.",
|
|
3149
|
+
retryable: false
|
|
3150
|
+
},
|
|
3151
|
+
// ----- Argument validation -----
|
|
3152
|
+
{
|
|
3153
|
+
code: "missing_user_id",
|
|
3154
|
+
type: "invalid_request_error",
|
|
3155
|
+
description: "identify() / aliasIdentity() called with an empty userId.",
|
|
3156
|
+
resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
|
|
3157
|
+
retryable: false
|
|
3158
|
+
},
|
|
3159
|
+
{
|
|
3160
|
+
code: "missing_anonymous_id",
|
|
3161
|
+
type: "invalid_request_error",
|
|
3162
|
+
description: "aliasIdentity() called with an empty anonymousId.",
|
|
3163
|
+
resolution: "Pass the anonymousId originally minted by the web SDK on this user's device.",
|
|
3164
|
+
retryable: false
|
|
3165
|
+
},
|
|
3166
|
+
{
|
|
3167
|
+
code: "missing_customer_id",
|
|
3168
|
+
type: "invalid_request_error",
|
|
3169
|
+
description: "An operation that requires a Crossdeck customer ID was called with an empty value.",
|
|
3170
|
+
resolution: "Pass the customerId returned from a prior identify() / getEntitlements() call.",
|
|
3171
|
+
retryable: false
|
|
3172
|
+
},
|
|
3173
|
+
{
|
|
3174
|
+
code: "missing_identity",
|
|
3175
|
+
type: "invalid_request_error",
|
|
3176
|
+
description: "An ingest / forget / entitlements call received no identity hints.",
|
|
3177
|
+
resolution: "Pass at least one of customerId, userId, or anonymousId on the call (or per-event for ingest).",
|
|
3178
|
+
retryable: false
|
|
3179
|
+
},
|
|
3180
|
+
{
|
|
3181
|
+
code: "missing_event_name",
|
|
3182
|
+
type: "invalid_request_error",
|
|
3183
|
+
description: "track() / ingest() received an event without a name.",
|
|
3184
|
+
resolution: "Pass a non-empty string as the event name. The wire shape is { name, properties? }.",
|
|
3185
|
+
retryable: false
|
|
3186
|
+
},
|
|
3187
|
+
{
|
|
3188
|
+
code: "missing_events",
|
|
3189
|
+
type: "invalid_request_error",
|
|
3190
|
+
description: "ingest() received an empty array.",
|
|
3191
|
+
resolution: "Pass at least one event. Use server.track(event) to send a single event.",
|
|
3192
|
+
retryable: false
|
|
3193
|
+
},
|
|
3194
|
+
{
|
|
3195
|
+
code: "missing_event_id",
|
|
3196
|
+
type: "invalid_request_error",
|
|
3197
|
+
description: "getAuditEntry() called with an empty eventId.",
|
|
3198
|
+
resolution: "Pass the eventId from the audit row you want to inspect.",
|
|
3199
|
+
retryable: false
|
|
3200
|
+
},
|
|
3201
|
+
{
|
|
3202
|
+
code: "missing_signed_transaction_info",
|
|
3203
|
+
type: "invalid_request_error",
|
|
3204
|
+
description: "syncPurchases() called without StoreKit 2 signed transaction info.",
|
|
3205
|
+
resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
|
|
3206
|
+
retryable: false
|
|
3207
|
+
},
|
|
3208
|
+
{
|
|
3209
|
+
code: "missing_group_type",
|
|
3210
|
+
type: "invalid_request_error",
|
|
3211
|
+
description: "group(type, id) called with an empty type.",
|
|
3212
|
+
resolution: 'Pass a non-empty group type (e.g. "org", "team", "plan") as the first argument.',
|
|
3213
|
+
retryable: false
|
|
3214
|
+
},
|
|
3215
|
+
{
|
|
3216
|
+
code: "serialization_failed",
|
|
3217
|
+
type: "invalid_request_error",
|
|
3218
|
+
description: "An event payload or trait bag could not be JSON-serialised even after sanitisation.",
|
|
3219
|
+
resolution: "Inspect the payload for non-JSON-friendly values (functions, symbols, deeply circular refs). The SDK's validator drops these by default, so this usually means a bug \u2014 file an issue with the payload shape.",
|
|
3220
|
+
retryable: false
|
|
3221
|
+
},
|
|
3222
|
+
// ----- Network / transport -----
|
|
3223
|
+
{
|
|
3224
|
+
code: "fetch_failed",
|
|
3225
|
+
type: "network_error",
|
|
3226
|
+
description: "The underlying fetch() call failed (typically a network outage, DNS, or refused connection).",
|
|
3227
|
+
resolution: "Check the host's outbound network. The SDK retries automatically with exponential backoff + jitter for queued events.",
|
|
3228
|
+
retryable: true
|
|
3229
|
+
},
|
|
3230
|
+
{
|
|
3231
|
+
code: "request_timeout",
|
|
3232
|
+
type: "network_error",
|
|
3233
|
+
description: "A request was aborted after the configured timeoutMs (default 15s).",
|
|
3234
|
+
resolution: "Check the host's network. Increase timeoutMs in CrossdeckServer options if you're on a known-slow link.",
|
|
3235
|
+
retryable: true
|
|
3236
|
+
},
|
|
3237
|
+
{
|
|
3238
|
+
code: "invalid_json_response",
|
|
3239
|
+
type: "internal_error",
|
|
3240
|
+
description: "The server returned a 2xx with an unparseable body.",
|
|
3241
|
+
resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
|
|
3242
|
+
retryable: true
|
|
3243
|
+
},
|
|
3244
|
+
// ----- Lifecycle (Node-specific) -----
|
|
3245
|
+
{
|
|
3246
|
+
code: "flush_on_exit_failed",
|
|
3247
|
+
type: "internal_error",
|
|
3248
|
+
description: "The on-exit drain (beforeExit / SIGTERM / SIGINT) did not complete before flushOnExitTimeoutMs.",
|
|
3249
|
+
resolution: "Increase flushOnExitTimeoutMs in CrossdeckServer options. Default is 2000ms; serverless runtimes typically allow 5-10s before SIGKILL. If events are dropping silently in production, raise this.",
|
|
3250
|
+
retryable: false
|
|
3251
|
+
},
|
|
3252
|
+
// ----- Webhook verification (Node-specific) -----
|
|
3253
|
+
{
|
|
3254
|
+
code: "webhook_invalid_signature",
|
|
3255
|
+
type: "authentication_error",
|
|
3256
|
+
description: "The webhook signature header did not verify against the supplied secret.",
|
|
3257
|
+
resolution: "Confirm the secret matches the one in your Crossdeck dashboard \u2192 Webhooks page. If the request is genuinely from Crossdeck, the secret is wrong, stale, or recently rotated.",
|
|
3258
|
+
retryable: false
|
|
3259
|
+
},
|
|
3260
|
+
{
|
|
3261
|
+
code: "webhook_replay_window_exceeded",
|
|
3262
|
+
type: "authentication_error",
|
|
3263
|
+
description: "The webhook timestamp is older than the replay-tolerance window (default 5 minutes).",
|
|
3264
|
+
resolution: "The webhook is either replayed or your receiving clock is wildly skewed. Verify NTP on the receiving host. Increase replayToleranceMs only if you accept the replay-attack risk.",
|
|
3265
|
+
retryable: false
|
|
3266
|
+
},
|
|
3267
|
+
{
|
|
3268
|
+
code: "webhook_missing_secret",
|
|
3269
|
+
type: "configuration_error",
|
|
3270
|
+
description: "verifyWebhookSignature() was called without a signing secret.",
|
|
3271
|
+
resolution: "Pass the secret from your Crossdeck dashboard \u2192 Webhooks page. Never hardcode in source \u2014 read from an env var.",
|
|
3272
|
+
retryable: false
|
|
3273
|
+
}
|
|
3274
|
+
]);
|
|
3275
|
+
function isCrossdeckErrorCode(code) {
|
|
3276
|
+
return _CROSSDECK_ERROR_CODES.some((e) => e.code === code);
|
|
3277
|
+
}
|
|
3278
|
+
var CROSSDECK_ERROR_CODES = _CROSSDECK_ERROR_CODES;
|
|
3279
|
+
function getErrorCode(code) {
|
|
3280
|
+
return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
// src/webhooks.ts
|
|
3284
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
3285
|
+
var DEFAULT_REPLAY_TOLERANCE_MS = 5 * 60 * 1e3;
|
|
3286
|
+
function verifyWebhookSignature(payload, signatureHeader, secret, options = {}) {
|
|
3287
|
+
const secrets = normaliseSecrets(secret);
|
|
3288
|
+
if (secrets.length === 0) {
|
|
3289
|
+
throw new CrossdeckError({
|
|
3290
|
+
type: "configuration_error",
|
|
3291
|
+
code: "webhook_missing_secret",
|
|
3292
|
+
message: "verifyWebhookSignature requires a non-empty secret. Read it from process.env.CROSSDECK_WEBHOOK_SECRET \u2014 never hardcode in source."
|
|
3293
|
+
});
|
|
3294
|
+
}
|
|
3295
|
+
const header = normaliseHeader(signatureHeader);
|
|
3296
|
+
const parsed = parseSignatureHeader(header);
|
|
3297
|
+
if (!parsed) {
|
|
3298
|
+
throw new CrossdeckError({
|
|
3299
|
+
type: "authentication_error",
|
|
3300
|
+
code: "webhook_invalid_signature",
|
|
3301
|
+
message: "Webhook signature header is missing or malformed. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
|
|
3302
|
+
});
|
|
3303
|
+
}
|
|
3304
|
+
const tolerance = options.replayToleranceMs ?? DEFAULT_REPLAY_TOLERANCE_MS;
|
|
3305
|
+
if (tolerance > 0) {
|
|
3306
|
+
const now = (options.now ?? Date.now)();
|
|
3307
|
+
const timestampMs = parsed.timestampSec * 1e3;
|
|
3308
|
+
const drift = Math.abs(now - timestampMs);
|
|
3309
|
+
if (drift > tolerance) {
|
|
3310
|
+
throw new CrossdeckError({
|
|
3311
|
+
type: "authentication_error",
|
|
3312
|
+
code: "webhook_replay_window_exceeded",
|
|
3313
|
+
message: `Webhook timestamp is ${drift}ms outside the ${tolerance}ms replay-tolerance window. Either the request is replayed or the receiving clock is skewed \u2014 verify NTP on the host.`
|
|
3314
|
+
});
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
const signedPayload = `${parsed.timestampSec}.${payload}`;
|
|
3318
|
+
const expectedBuf = Buffer.from(parsed.signature, "hex");
|
|
3319
|
+
if (expectedBuf.length === 0) {
|
|
3320
|
+
throw new CrossdeckError({
|
|
3321
|
+
type: "authentication_error",
|
|
3322
|
+
code: "webhook_invalid_signature",
|
|
3323
|
+
message: "Webhook signature is not a valid hex string."
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
const anyMatch = secrets.some((s) => {
|
|
3327
|
+
const computed = createHmac("sha256", s).update(signedPayload).digest();
|
|
3328
|
+
return computed.length === expectedBuf.length && timingSafeEqual(computed, expectedBuf);
|
|
3329
|
+
});
|
|
3330
|
+
if (!anyMatch) {
|
|
3331
|
+
throw new CrossdeckError({
|
|
3332
|
+
type: "authentication_error",
|
|
3333
|
+
code: "webhook_invalid_signature",
|
|
3334
|
+
message: "Webhook signature did not verify. Confirm the secret matches your Crossdeck dashboard \u2192 Webhooks page (and that you're not on a stale rotation)."
|
|
3335
|
+
});
|
|
3336
|
+
}
|
|
3337
|
+
try {
|
|
3338
|
+
return JSON.parse(payload);
|
|
3339
|
+
} catch {
|
|
3340
|
+
throw new CrossdeckError({
|
|
3341
|
+
type: "authentication_error",
|
|
3342
|
+
code: "webhook_invalid_signature",
|
|
3343
|
+
message: "Webhook signature verified but the payload is not valid JSON. Either the payload was tampered with after signing, or the webhook source is misconfigured."
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
function signWebhookPayload(payload, secret, timestampSec) {
|
|
3348
|
+
return createHmac("sha256", secret).update(`${timestampSec}.${payload}`).digest("hex");
|
|
3349
|
+
}
|
|
3350
|
+
function parseSignatureHeader(header) {
|
|
3351
|
+
if (!header) return null;
|
|
3352
|
+
let timestampSec = null;
|
|
3353
|
+
let signature = null;
|
|
3354
|
+
for (const part of header.split(",")) {
|
|
3355
|
+
const eqIdx = part.indexOf("=");
|
|
3356
|
+
if (eqIdx <= 0) continue;
|
|
3357
|
+
const key = part.slice(0, eqIdx).trim();
|
|
3358
|
+
const value = part.slice(eqIdx + 1).trim();
|
|
3359
|
+
if (key === "t") {
|
|
3360
|
+
const n = Number(value);
|
|
3361
|
+
if (Number.isFinite(n) && n > 0) timestampSec = Math.floor(n);
|
|
3362
|
+
} else if (key === "v1") {
|
|
3363
|
+
if (/^[0-9a-fA-F]+$/.test(value)) signature = value.toLowerCase();
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
if (timestampSec === null || signature === null) return null;
|
|
3367
|
+
return { timestampSec, signature };
|
|
3368
|
+
}
|
|
3369
|
+
function normaliseHeader(input) {
|
|
3370
|
+
if (input === void 0) return null;
|
|
3371
|
+
if (Array.isArray(input)) return input[0] ?? null;
|
|
3372
|
+
return input;
|
|
3373
|
+
}
|
|
3374
|
+
function normaliseSecrets(input) {
|
|
3375
|
+
if (input === void 0 || input === null) return [];
|
|
3376
|
+
const arr = Array.isArray(input) ? input : [input];
|
|
3377
|
+
return arr.filter((s) => typeof s === "string" && s.length > 0);
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
// src/consent.ts
|
|
3381
|
+
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
3382
|
+
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
3383
|
+
var REPLACEMENT_EMAIL = "[email]";
|
|
3384
|
+
var REPLACEMENT_CARD = "[card]";
|
|
3385
|
+
function scrubPii(value) {
|
|
3386
|
+
if (!value) return value;
|
|
3387
|
+
let out = value;
|
|
3388
|
+
if (EMAIL_PATTERN.test(out)) {
|
|
3389
|
+
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
3390
|
+
}
|
|
3391
|
+
EMAIL_PATTERN.lastIndex = 0;
|
|
3392
|
+
if (CARD_PATTERN.test(out)) {
|
|
3393
|
+
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
3394
|
+
}
|
|
3395
|
+
CARD_PATTERN.lastIndex = 0;
|
|
3396
|
+
return out;
|
|
3397
|
+
}
|
|
3398
|
+
function scrubPiiFromProperties(properties) {
|
|
3399
|
+
const out = {};
|
|
3400
|
+
for (const k of Object.keys(properties)) {
|
|
3401
|
+
out[k] = scrubValue(properties[k]);
|
|
3402
|
+
}
|
|
3403
|
+
return out;
|
|
3404
|
+
}
|
|
3405
|
+
function scrubValue(v) {
|
|
3406
|
+
if (typeof v === "string") return scrubPii(v);
|
|
3407
|
+
if (Array.isArray(v)) return v.map(scrubValue);
|
|
3408
|
+
if (v && typeof v === "object" && v.constructor === Object) {
|
|
3409
|
+
return scrubPiiFromProperties(v);
|
|
3410
|
+
}
|
|
3411
|
+
return v;
|
|
3412
|
+
}
|
|
555
3413
|
export {
|
|
3414
|
+
CROSSDECK_API_VERSION,
|
|
3415
|
+
CROSSDECK_ERROR_CODES,
|
|
3416
|
+
CrossdeckAuthenticationError,
|
|
3417
|
+
CrossdeckConfigurationError,
|
|
556
3418
|
CrossdeckError,
|
|
3419
|
+
CrossdeckInternalError,
|
|
3420
|
+
CrossdeckNetworkError,
|
|
3421
|
+
CrossdeckPermissionError,
|
|
3422
|
+
CrossdeckRateLimitError,
|
|
557
3423
|
CrossdeckServer,
|
|
3424
|
+
CrossdeckValidationError,
|
|
558
3425
|
DEFAULT_BASE_URL,
|
|
559
3426
|
DEFAULT_TIMEOUT_MS,
|
|
560
3427
|
SDK_NAME,
|
|
561
|
-
SDK_VERSION
|
|
3428
|
+
SDK_VERSION,
|
|
3429
|
+
getErrorCode,
|
|
3430
|
+
isCrossdeckErrorCode,
|
|
3431
|
+
makeCrossdeckError,
|
|
3432
|
+
scrubPii,
|
|
3433
|
+
scrubPiiFromProperties,
|
|
3434
|
+
signWebhookPayload,
|
|
3435
|
+
verifyWebhookSignature
|
|
562
3436
|
};
|
|
563
3437
|
//# sourceMappingURL=index.mjs.map
|