@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/README.md CHANGED
@@ -1,11 +1,6 @@
1
1
  # @cross-deck/node
2
2
 
3
- The Crossdeck server SDK for Node.js.
4
-
5
- This is the **secret-key** SDK: server-only, no browser assumptions, no
6
- auto-tracking, no local state. It wraps the real HTTP surface for
7
- entitlements, identity aliasing, event ingest, purchase forwarding, manual
8
- entitlement overrides, and audit reads.
3
+ The Crossdeck server SDK for Node.js — one install, three pillars: **errors**, **analytics**, **entitlements**.
9
4
 
10
5
  ```bash
11
6
  npm install @cross-deck/node
@@ -18,220 +13,507 @@ import { CrossdeckServer } from "@cross-deck/node";
18
13
 
19
14
  const crossdeck = new CrossdeckServer({
20
15
  secretKey: process.env.CROSSDECK_SECRET_KEY!,
16
+ appId: "app_node_xxxxxxxxxxxx",
17
+ // env is inferred from the key prefix: cd_sk_test_… → sandbox, cd_sk_live_… → production
21
18
  });
22
19
 
23
- const entitlements = await crossdeck.getEntitlements({ userId: "user_847" });
20
+ // Optional: validate the key at boot (recommended for serverless cold-starts)
21
+ await crossdeck.heartbeat();
24
22
 
25
- await crossdeck.track({
26
- name: "invoice.retry_started",
23
+ // USP 1 — manual error capture
24
+ try {
25
+ await processOrder(orderId);
26
+ } catch (err) {
27
+ crossdeck.captureError(err, { context: { orderId } });
28
+ throw err;
29
+ }
30
+
31
+ // USP 2 — analytics
32
+ crossdeck.track({
33
+ name: "checkout.completed",
27
34
  developerUserId: "user_847",
28
- properties: { invoiceId: "inv_123" },
35
+ properties: { plan: "pro", revenue: 9_900 },
29
36
  });
37
+
38
+ // USP 3 — entitlement gating (synchronous after first warm)
39
+ await crossdeck.getEntitlements({ userId: "user_847" });
40
+ if (crossdeck.isEntitled({ userId: "user_847" }, "pro")) {
41
+ // grant access
42
+ }
30
43
  ```
31
44
 
32
- ## What this SDK is for
45
+ ## Three USPs, one SDK
33
46
 
34
- - **Server-side entitlement reads.** Query by `customerId`, `userId`, or `anonymousId`.
35
- - **Identity graph writes.** Alias a known `anonymousId` to your stable `userId`.
36
- - **Telemetry from jobs and backends.** Send events from cron jobs, webhooks, workers, and admin tools.
37
- - **Fast purchase forwarding.** Push signed Apple purchase evidence directly.
38
- - **Manual entitlement controls.** Grant or revoke an entitlement with a reason.
39
- - **Audit lookup.** Read one audit-log entry by event ID.
47
+ ### USP 1 Errors
40
48
 
41
- ## What this SDK is not
49
+ Auto-wired by default: `process.on('uncaughtException')`, `process.on('unhandledRejection')`, and `globalThis.fetch` wrap (5xx + network failures). Plus the full manual surface:
42
50
 
43
- - Not for browsers.
44
- - Not a wrapper around the web SDK.
45
- - Not a stateful singleton that keeps identity or a queue in memory.
46
- - Not an auto-tracker. Every server call is explicit.
51
+ ```ts
52
+ // Manual capture from try/catch
53
+ crossdeck.captureError(err, {
54
+ context: { jobId },
55
+ tags: { flow: "checkout" },
56
+ level: "error", // "error" | "warning" | "info"
57
+ });
47
58
 
48
- ## Constructing the client
59
+ // Non-error signals (Sentry pattern)
60
+ crossdeck.captureMessage("deprecated path hit", "warning");
49
61
 
50
- ```ts
51
- import { CrossdeckServer } from "@cross-deck/node";
62
+ // Pin tags + context to all subsequent errors
63
+ crossdeck.setTag("release", process.env.K_REVISION);
64
+ crossdeck.setContext("region", { az: "us-east-1a" });
52
65
 
53
- const crossdeck = new CrossdeckServer({
54
- secretKey: process.env.CROSSDECK_SECRET_KEY!,
55
- baseUrl: "https://api.cross-deck.com/v1", // optional
56
- timeoutMs: 15_000, // optional
57
- appId: "app_web_xxx", // optional informational event envelope field
66
+ // Add breadcrumbs (last 50 attached to every error report)
67
+ crossdeck.addBreadcrumb({
68
+ timestamp: Date.now(),
69
+ category: "custom",
70
+ message: "user.opened_paywall",
71
+ });
72
+
73
+ // Pre-send hook for app-specific PII scrubbing
74
+ crossdeck.setErrorBeforeSend((err) => {
75
+ if (err.message.includes("auth-token=")) return null;
76
+ return err;
58
77
  });
59
78
  ```
60
79
 
61
- `secretKey` must start with `cd_sk_`. The constructor throws immediately on
62
- an invalid key prefix so a server misconfiguration fails at boot, not under
63
- load.
80
+ Stack frames are parsed (V8 + Firefox/Safari formats), fingerprinted via djb2 over message + top-3 in-app frames, attached with the breadcrumb buffer + your context + tags. Rate-limited per fingerprint (default 5/min), session-capped (default 100/process). Frames inside `node_modules/`, `node:`, `internal/`, or `@cross-deck/node` are marked not-in-app and excluded from fingerprints.
81
+
82
+ To opt out (e.g. if you have a separate error tracker):
64
83
 
65
- ## Identity
84
+ ```ts
85
+ new CrossdeckServer({ secretKey, errorCapture: false });
86
+ ```
66
87
 
67
- ### `await crossdeck.identify(userId, anonymousId, options?)`
88
+ ### USP 2 Analytics
68
89
 
69
- Alias a pre-login `anonymousId` to your stable user ID. This is the same
70
- identity graph the web SDK uses, just called explicitly from your backend.
90
+ `track()` enqueues synchronously into a durable retry-with-jitter queue with per-batch `Idempotency-Key` reuse on retry. Flush-on-exit drains before the process terminates — critical for Cloud Functions / Lambda where the runtime freezes the process and any pending events would otherwise vanish.
71
91
 
72
92
  ```ts
73
- await crossdeck.identify("user_847", "anon_123", {
74
- email: "wes@example.com",
75
- traits: { plan: "pro", region: "za" },
93
+ crossdeck.track({
94
+ name: "paywall_shown",
95
+ developerUserId: "user_847",
96
+ properties: { variant: "v3" },
76
97
  });
77
- ```
78
-
79
- `identify()` is a convenience alias for `aliasIdentity(...)`.
80
98
 
81
- Traits are sanitised before send with the same rules as `@cross-deck/web`:
82
- `BigInt` becomes a string, circular refs become `"[circular]"`, `Map`/`Set`
83
- normalise to JSON-friendly shapes, and functions/symbols/`undefined` are dropped.
99
+ // Super-properties (Mixpanel pattern) carried on every subsequent event
100
+ crossdeck.register({ serviceVersion: process.env.K_REVISION });
101
+ crossdeck.unregister("oldField");
84
102
 
85
- ### `await crossdeck.forget(hints)`
103
+ // Group analytics — attach $groups.<type> for B2B dashboard pivots
104
+ crossdeck.group("org", "acme_inc");
105
+ crossdeck.group("team", "design", { headcount: 12 });
86
106
 
87
- Queue GDPR/CCPA deletion by `customerId`, `userId`, or `anonymousId`.
107
+ // Bulk imports synchronous POST, returns IngestResponse
108
+ await crossdeck.ingest([
109
+ { name: "job.completed", crossdeckCustomerId: "cdcust_x", properties: { durationMs: 1200 } },
110
+ { name: "job.completed", crossdeckCustomerId: "cdcust_y", properties: { durationMs: 950 } },
111
+ ]);
88
112
 
89
- ```ts
90
- await crossdeck.forget({ customerId: "cdcust_123" });
113
+ // Drain the queue (call at end of Lambda/CF invocations)
114
+ await crossdeck.flush();
91
115
  ```
92
116
 
93
- ## Entitlements
117
+ > **Multi-tenant servers:** `register()` is **process-scoped**, not per-request. In a single Node process handling requests for many tenants, registering `{ tenant: "acme" }` taints every subsequent event from that process — including ones serving other tenants. For per-request properties, pass them on the `track()` call itself.
94
118
 
95
- ### `await crossdeck.getEntitlements(hints)`
119
+ #### Framework adapters (`@cross-deck/node/auto-events`)
96
120
 
97
- Read entitlements by any supported identity hint.
121
+ Plug Crossdeck into your existing framework with a single middleware/wrap call. Auto-emits `request.handled` / `function.invoked` / `function.completed` / `function.failed` events, captures uncaught errors with request context, and (on Lambda + Firebase) awaits `flush()` before the handler returns.
98
122
 
99
123
  ```ts
100
- const result = await crossdeck.getEntitlements({ userId: "user_847" });
101
- console.log(result.data.map((e) => e.key));
124
+ import {
125
+ crossdeckExpress,
126
+ crossdeckExpressErrorHandler,
127
+ wrapLambdaHandler,
128
+ wrapFunction,
129
+ } from "@cross-deck/node/auto-events";
130
+
131
+ // Express 4 + 5
132
+ app.use(crossdeckExpress(crossdeck, {
133
+ getIdentity: (req) => ({ developerUserId: req.user?.id }),
134
+ }));
135
+ // ... routes ...
136
+ app.use(crossdeckExpressErrorHandler(crossdeck)); // register LAST
137
+
138
+ // AWS Lambda + Vercel Functions (which run on Lambda underneath)
139
+ export const handler = wrapLambdaHandler(crossdeck, async (event, ctx) => {
140
+ return { statusCode: 200, body: "ok" };
141
+ });
142
+
143
+ // Firebase Functions v1 + v2, Cloud Run (generic shape-preserving wrap)
144
+ export const myFunction = onRequest(
145
+ wrapFunction(crossdeck, async (req, res) => {
146
+ res.send("ok");
147
+ }),
148
+ );
102
149
  ```
103
150
 
104
- ### `await crossdeck.getCustomerEntitlements(customerId)`
151
+ ### USP 3 — Entitlements
105
152
 
106
- Server-only direct lookup by canonical Crossdeck customer ID.
153
+ Per-customer TTL cache (default 60s). Hot-path entitlement gates become synchronous memory reads after the first warm. Bounded by `maxCustomers` (default 10,000) with LRU eviction for long-running multi-tenant servers.
107
154
 
108
155
  ```ts
109
- const result = await crossdeck.getCustomerEntitlements("cdcust_123");
110
- ```
156
+ // Warm the cache (records userId → customerId alias)
157
+ await crossdeck.getEntitlements({ userId: "user_847" });
158
+
159
+ // Synchronous gate — memory read within TTL, no HTTP
160
+ if (crossdeck.isEntitled({ userId: "user_847" }, "pro")) {
161
+ // grant access
162
+ }
111
163
 
112
- ### `await crossdeck.grantEntitlement(input)`
164
+ // Full snapshot for callers needing source / validUntil
165
+ const ents = crossdeck.listEntitlements({ userId: "user_847" });
113
166
 
114
- Manually grant an entitlement.
167
+ // Subscribe to cache mutations (e.g. push to connected clients)
168
+ const unsubscribe = crossdeck.onEntitlementsChange((customerId, ents) => {
169
+ // ...
170
+ });
115
171
 
116
- ```ts
172
+ // Server-side manual overrides
117
173
  await crossdeck.grantEntitlement({
118
174
  customerId: "cdcust_123",
119
175
  entitlementKey: "pro",
120
176
  duration: "P30D",
121
177
  reason: "Support recovery after billing incident",
122
178
  });
179
+ await crossdeck.revokeEntitlement({
180
+ customerId: "cdcust_123",
181
+ entitlementKey: "pro",
182
+ reason: "Chargeback",
183
+ });
123
184
  ```
124
185
 
125
- ### `await crossdeck.revokeEntitlement(input)`
186
+ #### Webhook signature verification
126
187
 
127
- Manually revoke an entitlement.
188
+ Stripe-compatible HMAC-SHA256 with constant-time comparison + replay window. Supports multi-secret rotation.
128
189
 
129
190
  ```ts
130
- await crossdeck.revokeEntitlement({
131
- customerId: "cdcust_123",
132
- entitlementKey: "pro",
133
- reason: "Chargeback",
191
+ import { verifyWebhookSignature } from "@cross-deck/node";
192
+ import express from "express";
193
+
194
+ app.post("/crossdeck-webhook", express.raw({ type: "application/json" }), (req, res) => {
195
+ try {
196
+ const event = verifyWebhookSignature(
197
+ req.body.toString("utf8"),
198
+ req.headers["crossdeck-signature"],
199
+ [process.env.CROSSDECK_WEBHOOK_SECRET, process.env.CROSSDECK_WEBHOOK_SECRET_OLD],
200
+ // 5-min default replay window
201
+ );
202
+ handleCrossdeckEvent(event);
203
+ res.sendStatus(200);
204
+ } catch (err) {
205
+ res.sendStatus(401);
206
+ }
134
207
  });
135
208
  ```
136
209
 
137
- ## Events
210
+ For test fixtures that need to mint signed webhooks against the same scheme, `signWebhookPayload(payload, secret, timestampSec)` is exported.
211
+
212
+ ## Cross-cutting
138
213
 
139
- ### `await crossdeck.track(event)`
214
+ ### Runtime info
140
215
 
141
- Send one event immediately.
216
+ Auto-detected at construction. Attached to every event + error as `runtime.*` properties:
217
+
218
+ | Detected platform | Trigger env var | Surfaces as `runtime.host` |
219
+ |---|---|---|
220
+ | AWS Lambda + Vercel Functions | `AWS_LAMBDA_FUNCTION_NAME` | `aws-lambda` |
221
+ | Azure Functions | `FUNCTIONS_WORKER_RUNTIME` + `WEBSITE_INSTANCE_ID` | `azure-functions` |
222
+ | Google App Engine | `GAE_APPLICATION` | `google-app-engine` |
223
+ | Firebase Functions v2 / Cloud Functions Gen 2 | `K_SERVICE` + `FIREBASE_CONFIG` | `firebase-functions-v2` |
224
+ | Firebase Functions v1 | `FUNCTION_NAME` + `FUNCTION_REGION` | `firebase-functions-v1` |
225
+ | Google Cloud Run | `K_SERVICE` + `K_REVISION` (no Firebase) | `cloud-run` |
226
+ | Vercel | `VERCEL === "1"` | `vercel` |
227
+ | Netlify Functions | `NETLIFY === "true"` | `netlify` |
228
+ | Heroku | `DYNO` | `heroku` |
229
+ | Render | `RENDER === "true"` | `render` |
230
+ | Railway | `RAILWAY_ENVIRONMENT` | `railway` |
231
+ | Fly.io | `FLY_APP_NAME` | `fly` |
232
+ | Generic Kubernetes | `KUBERNETES_SERVICE_HOST` | `kubernetes` |
233
+ | Plain Node | (fallback) | `node` |
234
+
235
+ Every detected platform exposes `serviceName`, `serviceVersion`, `region`, `instanceId` where available. Override via constructor:
142
236
 
143
237
  ```ts
144
- await crossdeck.track({
145
- name: "support.refund_issued",
146
- crossdeckCustomerId: "cdcust_123",
147
- properties: { ticketId: "ticket_987" },
238
+ new CrossdeckServer({
239
+ secretKey,
240
+ serviceName: "my-fn",
241
+ serviceVersion: process.env.K_REVISION,
242
+ appVersion: "1.2.3", // attached to events as `appVersion`
148
243
  });
149
244
  ```
150
245
 
151
- Identity is required on every event. Provide at least one of:
246
+ ### Diagnostics
152
247
 
153
- - `developerUserId`
154
- - `anonymousId`
155
- - `crossdeckCustomerId`
248
+ ```ts
249
+ const d = crossdeck.diagnostics();
250
+ // {
251
+ // sdkVersion, baseUrl, secretKeyPrefix (masked), env,
252
+ // runtime: { nodeVersion, platform, host, region, serviceName, ... },
253
+ // events: { buffered, dropped, inFlight, consecutiveFailures, ... },
254
+ // errors: { sessionCount, fingerprintsTracked, handlersInstalled },
255
+ // entitlements: { count, ttlMs, lastUpdated, listenerErrors },
256
+ // }
257
+ ```
156
258
 
157
- ### `await crossdeck.ingest(events)`
259
+ Useful for `/health` and `/metrics` endpoints exposed to your platform.
158
260
 
159
- Send a batch in one call.
261
+ ### Debug mode
160
262
 
161
263
  ```ts
162
- await crossdeck.ingest([
163
- {
164
- name: "job.started",
165
- developerUserId: "user_847",
166
- properties: { job: "daily-mrr-reconcile" },
167
- },
168
- {
169
- name: "job.completed",
170
- developerUserId: "user_847",
171
- properties: { job: "daily-mrr-reconcile", durationMs: 842 },
172
- },
173
- ]);
264
+ new CrossdeckServer({ secretKey, debug: true });
174
265
  ```
175
266
 
176
- The SDK auto-mints `eventId` and `timestamp` if you omit them.
177
-
178
- Event `properties` are sanitised with the same contract as the web SDK before
179
- they hit the wire, so one bad backend-shaped object cannot crash request
180
- serialization.
267
+ Emits NorthStar §16 debug signals to `console.info`:
181
268
 
182
- ## Purchases
269
+ - `sdk.configured` — boot confirmation
270
+ - `sdk.first_event_sent` — proves wire connectivity
271
+ - `sdk.flush_retry_scheduled` — surfaces flush failures + retry delay
272
+ - `sdk.flush_on_exit_started` / `sdk.flush_on_exit_completed` — drain lifecycle
273
+ - `sdk.entitlement_cache_warm` / `sdk.entitlement_cache_used` — cache observability
274
+ - `sdk.webhook_verified` — signature verification confirmation
275
+ - `sdk.sensitive_property_warning` — flagged property names on `track()`
276
+ - `sdk.runtime_detected` — host platform detection
183
277
 
184
- ### `await crossdeck.syncPurchases(input)`
278
+ ### PII scrub utility
185
279
 
186
- Forward Apple signed purchase evidence to Crossdeck.
280
+ Opt-in regex-based scrub for email + card-number-shaped substrings. Use before forwarding caller-supplied properties:
187
281
 
188
282
  ```ts
189
- await crossdeck.syncPurchases({
190
- signedTransactionInfo: transactionJws,
191
- signedRenewalInfo: renewalJws,
283
+ import { scrubPiiFromProperties } from "@cross-deck/node";
284
+
285
+ crossdeck.track({
286
+ name: "checkout.failed",
287
+ developerUserId,
288
+ properties: scrubPiiFromProperties({
289
+ url: req.url, // /users/wes@example.com/ → /users/[email]/
290
+ failedCardLast4: payload.card_number, // 4242 4242 4242 4242 → [card]
291
+ }),
192
292
  });
193
293
  ```
194
294
 
195
- ## Audit
295
+ ## Configuration
196
296
 
197
- ### `await crossdeck.getAuditEntry(eventId)`
198
-
199
- Read one audit row by event ID.
297
+ All options on `new CrossdeckServer({...})`:
200
298
 
201
299
  ```ts
202
- const audit = await crossdeck.getAuditEntry("srv_grant_123");
203
- console.log(audit.decision, audit.reason);
300
+ {
301
+ secretKey: string; // required — `cd_sk_test_…` (sandbox) | `cd_sk_live_…` (production)
302
+ baseUrl?: string; // default "https://api.cross-deck.com/v1"
303
+ timeoutMs?: number; // default 15_000, 0 disables
304
+ appId?: string; // optional metadata on event envelope
305
+ sdkVersion?: string; // override the version reported on the wire
306
+
307
+ // USP 1
308
+ errorCapture?: boolean | Partial<ErrorCaptureConfig>;
309
+ // false to disable; partial object to override specific hooks
310
+ // (onUncaughtException, onUnhandledRejection, wrapFetch, etc.)
311
+
312
+ // USP 2
313
+ eventFlushBatchSize?: number; // default 20
314
+ eventFlushIntervalMs?: number;// default 1500
315
+ flushOnExit?: boolean; // default true — beforeExit + SIGTERM + SIGINT drain
316
+ flushOnExitTimeoutMs?: number;// default 2000
317
+
318
+ // USP 3
319
+ entitlementCacheTtlMs?: number; // default 60_000, 0 disables
320
+
321
+ // Cross-cutting
322
+ serviceName?: string; // overrides env-detected
323
+ serviceVersion?: string; // overrides env-detected
324
+ appVersion?: string; // attached as `appVersion` on events
325
+ debug?: boolean; // default false
326
+ breadcrumbsMaxSize?: number; // default 50
327
+
328
+ // Bank-grade SDK extras (QA-review v2)
329
+ testMode?: boolean; // default false — short-circuits HTTP to synthetic responses
330
+ onRequest?: (info) => void; // fires on every request (incl. retries)
331
+ onResponse?: (info) => void; // fires on every response
332
+ httpRetries?: { // idempotent GET retry policy
333
+ maxAttempts?: number; // default 3 (1 initial + 2 retries)
334
+ retryableStatuses?: number[]; // default [408, 500, 502, 503, 504]
335
+ };
336
+ runtimeToken?: string; // override the User-Agent runtime token
337
+ }
204
338
  ```
205
339
 
206
- ## Errors
340
+ ## Error model
207
341
 
208
- Every non-2xx response is normalised to `CrossdeckError`:
342
+ Stripe-style subclass hierarchy. Use `instanceof` for typed narrowing in your `catch` blocks.
209
343
 
210
344
  ```ts
211
- import { CrossdeckError } from "@cross-deck/node";
345
+ import {
346
+ CrossdeckError,
347
+ CrossdeckAuthenticationError,
348
+ CrossdeckRateLimitError,
349
+ CrossdeckNetworkError,
350
+ isCrossdeckErrorCode,
351
+ } from "@cross-deck/node";
212
352
 
213
353
  try {
214
- await crossdeck.getEntitlements({ userId: "user_847" });
354
+ await crossdeck.heartbeat();
215
355
  } catch (err) {
216
- if (err instanceof CrossdeckError) {
356
+ if (err instanceof CrossdeckAuthenticationError) {
357
+ // 401 path — bad/revoked secret key, or bad webhook signature
358
+ } else if (err instanceof CrossdeckRateLimitError) {
359
+ // 429 — back off for err.retryAfterMs
360
+ } else if (err instanceof CrossdeckNetworkError) {
361
+ // fetch failed / aborted / timed out — likely transient
362
+ } else if (err instanceof CrossdeckError) {
363
+ if (isCrossdeckErrorCode(err.code) && err.code === "invalid_secret_key") {
364
+ // narrowed to the catalogue's literal union
365
+ }
217
366
  console.error(err.type, err.code, err.requestId);
218
367
  }
219
368
  }
220
369
  ```
221
370
 
222
- The error fields mirror the backend envelope:
371
+ Subclasses: `CrossdeckAuthenticationError`, `CrossdeckPermissionError`, `CrossdeckValidationError`, `CrossdeckRateLimitError`, `CrossdeckNetworkError`, `CrossdeckInternalError`, `CrossdeckConfigurationError`. All extend `CrossdeckError`. Constructed automatically by the SDK — you never need to instantiate them yourself.
372
+
373
+ `CrossdeckErrorCode` is the literal union of every documented code in `CROSSDECK_ERROR_CODES`. Use `isCrossdeckErrorCode` to narrow `string` to the union for type-safe comparisons (catches misspelled codes at compile time).
374
+
375
+ `err.toJSON()` is implemented — your structured logger sees `type`, `code`, `requestId`, `status`, `retryAfterMs`, and `stack` instead of just `name + message`:
376
+
377
+ ```ts
378
+ logger.error({ err }, "crossdeck request failed");
379
+ // → { err: { name: "CrossdeckRateLimitError", type: "rate_limit_error",
380
+ // code: "too_many_requests", retryAfterMs: 30000, ... } }
381
+ ```
223
382
 
224
- - `type`
225
- - `code`
226
- - `message`
227
- - `requestId`
228
- - `status`
229
- - `retryAfterMs`
383
+ Every entry in `CROSSDECK_ERROR_CODES` carries `{ code, type, description, resolution, retryable }` — render-able in dashboards and AI assistants.
384
+
385
+ ## Reliability + lifecycle
386
+
387
+ ### Idempotent GET retry
388
+
389
+ Read methods (`getEntitlements`, `getCustomerEntitlements`, `getAuditEntry`, `heartbeat`) automatically retry on 408 + 5xx (except 501) and on network failures. Default 3 attempts with exponential backoff + full jitter. Honours server `Retry-After`. Configurable per-instance:
390
+
391
+ ```ts
392
+ new CrossdeckServer({
393
+ secretKey,
394
+ httpRetries: { maxAttempts: 5 }, // up to 5 attempts
395
+ });
396
+ ```
397
+
398
+ POST methods (`track`/`ingest`/`syncPurchases`/`grantEntitlement`/`revokeEntitlement`) DO NOT auto-retry at the HTTP layer. Retries happen via the event queue with per-batch `Idempotency-Key` reuse — the server can dedupe replays.
399
+
400
+ ### AbortSignal — caller-controlled cancellation
401
+
402
+ Every async method accepts a final `RequestOptions?` with `{ signal, timeoutMs }`:
403
+
404
+ ```ts
405
+ const ctrl = new AbortController();
406
+ const flight = crossdeck.heartbeat({ signal: ctrl.signal });
407
+ setTimeout(() => ctrl.abort(), 100);
408
+ try {
409
+ await flight;
410
+ } catch (err) {
411
+ if (err instanceof CrossdeckNetworkError && err.code === "request_aborted") {
412
+ // caller-cancelled
413
+ }
414
+ }
415
+ ```
416
+
417
+ ### EventEmitter — internal events
418
+
419
+ `CrossdeckServer extends EventEmitter`. Subscribe to internal lifecycle events with typed listeners:
420
+
421
+ ```ts
422
+ crossdeck.on("queue.flush_failed", ({ error, attempt, nextRetryMs }) => {
423
+ metrics.increment("crossdeck.flush_failed", { attempt });
424
+ });
425
+ crossdeck.on("error.captured", ({ fingerprint, kind, message }) => {
426
+ // forward to your other observability tools
427
+ });
428
+ crossdeck.on("sdk.shutdown", ({ reason }) => {
429
+ // last-chance cleanup
430
+ });
431
+ ```
432
+
433
+ Events: `queue.flush_succeeded`, `queue.flush_failed`, `queue.dropped`, `queue.buffer_changed`, `error.captured`, `entitlements.warmed`, `sdk.shutdown`.
434
+
435
+ ### Health probes — Kubernetes / load balancers
436
+
437
+ ```ts
438
+ crossdeck.isReady(); // synchronous: false on sustained retry storm or buffer pressure
439
+ await crossdeck.awaitReady(2000); // backpressure-aware wait
440
+ crossdeck.getHealth(); // full snapshot
441
+
442
+ // Express health endpoint
443
+ app.get("/healthz", (_req, res) => {
444
+ const h = crossdeck.getHealth();
445
+ res.status(h.healthy ? 200 : 503).json(h);
446
+ });
447
+ ```
448
+
449
+ ### Explicit resource management
450
+
451
+ TC39 `using` / `await using` syntax (Node 20+, TS 5.2+):
452
+
453
+ ```ts
454
+ {
455
+ using crossdeck = new CrossdeckServer({ secretKey });
456
+ // ... use crossdeck ...
457
+ } // crossdeck[Symbol.dispose]() runs — handlers cleaned up
458
+
459
+ async function lambdaHandler(event) {
460
+ await using crossdeck = new CrossdeckServer({ secretKey });
461
+ crossdeck.track({ name: "handler.invoked", developerUserId: event.userId });
462
+ // ... do work ...
463
+ } // crossdeck[Symbol.asyncDispose]() runs — awaits flush() then cleans up
464
+ ```
465
+
466
+ ### testMode — caller tests without mocking fetch
467
+
468
+ ```ts
469
+ const crossdeck = new CrossdeckServer({
470
+ secretKey: "cd_sk_test_test",
471
+ testMode: true,
472
+ });
473
+ // Every call returns a synthetic success shape — no network.
474
+ // Use crossdeck.on("entitlements.warmed", ...) etc. to assert behaviour.
475
+ ```
476
+
477
+ ### onRequest / onResponse hooks
478
+
479
+ ```ts
480
+ new CrossdeckServer({
481
+ secretKey,
482
+ onRequest: (info) => debug.log({ method: info.method, url: info.url, attempt: info.attempt }),
483
+ onResponse: (info) => metrics.histogram("crossdeck.request_ms", info.durationMs),
484
+ });
485
+ ```
486
+
487
+ Synchronous, errors swallowed — telemetry must never break the request pipeline.
488
+
489
+ ### Bulk entitlement ops
490
+
491
+ ```ts
492
+ // Grant `pro_q1_bonus` to a list of customers, bounded concurrency
493
+ const results = await crossdeck.bulkGrantEntitlement(
494
+ customerIds.map((customerId) => ({
495
+ customerId,
496
+ entitlementKey: "pro_q1_bonus",
497
+ duration: "P30D",
498
+ reason: "Q1 promo",
499
+ })),
500
+ { maxConcurrency: 10 },
501
+ );
502
+
503
+ const succeeded = results.filter((r) => r.ok);
504
+ const failed = results.filter((r) => !r.ok);
505
+ // Partial failures preserved as { ok: false, error }
506
+ ```
507
+
508
+ Symmetric `bulkRevokeEntitlement(revokes[], options?)`.
230
509
 
231
510
  ## Node version
232
511
 
233
- Node 18+ is required. The SDK uses the platform `fetch` implementation and
234
- does not ship an HTTP dependency.
512
+ Node 18+. Uses the platform `fetch` and `node:crypto` — zero runtime dependencies.
513
+
514
+ ## Bundle
515
+
516
+ `dist/index.cjs` + `dist/index.mjs` (main entry) + `dist/auto-events/index.cjs` + `dist/auto-events/index.mjs` (framework adapters subpath). Strict TypeScript, full `.d.ts` for both entries, source maps included.
235
517
 
236
518
  ## License
237
519