@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/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 import_node_crypto = require("crypto");
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 new CrossdeckError({
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 new CrossdeckError({
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 = "0.1.0";
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
- const headers = {
272
- Authorization: `Bearer ${this.config.secretKey}`,
273
- "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
274
- Accept: "application/json"
275
- };
276
- if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
277
- let bodyInit;
278
- if (options.body !== void 0) {
279
- headers["Content-Type"] = "application/json";
280
- bodyInit = serializeRequestBody(options.body);
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 controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
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
- response = await fetch(url, {
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
- if (!response.ok) {
307
- throw await crossdeckErrorFromResponse(response);
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
- if (response.status === 204) return void 0;
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
- return await response.json();
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
- throw new CrossdeckError({
314
- type: "internal_error",
315
- code: "invalid_json_response",
316
- message: "Server returned a 2xx with an unparseable body.",
317
- requestId: response.headers.get("x-request-id") ?? void 0,
318
- status: response.status
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/crossdeck-server.ts
364
- var CrossdeckServer = class {
365
- http;
366
- sdkVersion;
367
- appId;
368
- constructor(options) {
369
- if (!options.secretKey || !options.secretKey.startsWith("cd_sk_")) {
370
- throw new CrossdeckError({
371
- type: "configuration_error",
372
- code: "invalid_secret_key",
373
- message: "CrossdeckServer requires a secret key starting with cd_sk_."
374
- });
375
- }
376
- this.sdkVersion = options.sdkVersion ?? SDK_VERSION;
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
- async identify(userId, anonymousId, options) {
386
- return this.aliasIdentity({ userId, anonymousId, ...options });
740
+ return Math.max(0, Math.round(jittered));
741
+ }
742
+ var RetryPolicy = class {
743
+ constructor(options = {}) {
744
+ this.options = options;
387
745
  }
388
- async aliasIdentity(input) {
389
- if (!input.userId) {
390
- throw new CrossdeckError({
391
- type: "invalid_request_error",
392
- code: "missing_user_id",
393
- message: "aliasIdentity requires a non-empty userId."
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
- if (!input.anonymousId) {
397
- throw new CrossdeckError({
398
- type: "invalid_request_error",
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
- async forget(hints) {
413
- const body = this.identityPayload(hints);
414
- return this.http.request("POST", "/identity/forget", { body });
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
- async getEntitlements(hints) {
417
- return this.http.request("GET", "/entitlements", {
418
- query: this.identityPayload(hints)
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
- async getCustomerEntitlements(customerId) {
422
- if (!customerId) {
423
- throw new CrossdeckError({
424
- type: "invalid_request_error",
425
- code: "missing_customer_id",
426
- message: "getCustomerEntitlements requires a customerId."
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
- async track(event, options = {}) {
435
- return this.ingest([event], options);
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
- async ingest(events, options = {}) {
438
- if (!Array.isArray(events) || events.length === 0) {
439
- throw new CrossdeckError({
440
- type: "invalid_request_error",
441
- code: "missing_events",
442
- message: "ingest requires at least one event."
443
- });
444
- }
445
- const normalized = events.map((event) => this.normalizeEvent(event));
446
- const body = {
447
- events: normalized,
448
- sdk: { name: SDK_NAME, version: this.sdkVersion }
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
- async syncPurchases(input) {
457
- if (!input.signedTransactionInfo) {
458
- throw new CrossdeckError({
459
- type: "invalid_request_error",
460
- code: "missing_signed_transaction_info",
461
- message: "syncPurchases requires a signedTransactionInfo string."
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
- async grantEntitlement(input) {
469
- if (!input.customerId) {
470
- throw new CrossdeckError({
471
- type: "invalid_request_error",
472
- code: "missing_customer_id",
473
- message: "grantEntitlement requires a customerId."
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
- async revokeEntitlement(input) {
489
- if (!input.customerId) {
490
- throw new CrossdeckError({
491
- type: "invalid_request_error",
492
- code: "missing_customer_id",
493
- message: "revokeEntitlement requires a customerId."
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
- async getAuditEntry(eventId) {
508
- if (!eventId) {
509
- throw new CrossdeckError({
510
- type: "invalid_request_error",
511
- code: "missing_event_id",
512
- message: "getAuditEntry requires an eventId."
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
- normalizeEvent(event) {
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 ?? this.mintEventId(),
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