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