@cross-deck/node 0.1.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +139 -0
- package/README.md +406 -124
- package/dist/auto-events/index.cjs +354 -0
- package/dist/auto-events/index.cjs.map +1 -0
- package/dist/auto-events/index.d.mts +316 -0
- package/dist/auto-events/index.d.ts +316 -0
- package/dist/auto-events/index.mjs +322 -0
- package/dist/auto-events/index.mjs.map +1 -0
- package/dist/crossdeck-server-BXQaFjVx.d.mts +1414 -0
- package/dist/crossdeck-server-BXQaFjVx.d.ts +1414 -0
- package/dist/index.cjs +3069 -179
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +339 -178
- package/dist/index.d.ts +339 -178
- package/dist/index.mjs +3054 -180
- package/dist/index.mjs.map +1 -1
- package/package.json +18 -4
package/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
|
-
|
|
20
|
+
// Optional: validate the key at boot (recommended for serverless cold-starts)
|
|
21
|
+
await crossdeck.heartbeat();
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
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: {
|
|
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
|
-
##
|
|
45
|
+
## Three USPs, one SDK
|
|
33
46
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
59
|
+
// Non-error signals (Sentry pattern)
|
|
60
|
+
crossdeck.captureMessage("deprecated path hit", "warning");
|
|
49
61
|
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
84
|
+
```ts
|
|
85
|
+
new CrossdeckServer({ secretKey, errorCapture: false });
|
|
86
|
+
```
|
|
66
87
|
|
|
67
|
-
###
|
|
88
|
+
### USP 2 — Analytics
|
|
68
89
|
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
await crossdeck.
|
|
113
|
+
// Drain the queue (call at end of Lambda/CF invocations)
|
|
114
|
+
await crossdeck.flush();
|
|
91
115
|
```
|
|
92
116
|
|
|
93
|
-
|
|
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
|
-
|
|
119
|
+
#### Framework adapters (`@cross-deck/node/auto-events`)
|
|
96
120
|
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
###
|
|
151
|
+
### USP 3 — Entitlements
|
|
105
152
|
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
// Full snapshot for callers needing source / validUntil
|
|
165
|
+
const ents = crossdeck.listEntitlements({ userId: "user_847" });
|
|
113
166
|
|
|
114
|
-
|
|
167
|
+
// Subscribe to cache mutations (e.g. push to connected clients)
|
|
168
|
+
const unsubscribe = crossdeck.onEntitlementsChange((customerId, ents) => {
|
|
169
|
+
// ...
|
|
170
|
+
});
|
|
115
171
|
|
|
116
|
-
|
|
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
|
-
|
|
186
|
+
#### Webhook signature verification
|
|
126
187
|
|
|
127
|
-
|
|
188
|
+
Stripe-compatible HMAC-SHA256 with constant-time comparison + replay window. Supports multi-secret rotation.
|
|
128
189
|
|
|
129
190
|
```ts
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
214
|
+
### Runtime info
|
|
140
215
|
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
246
|
+
### Diagnostics
|
|
152
247
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
259
|
+
Useful for `/health` and `/metrics` endpoints exposed to your platform.
|
|
158
260
|
|
|
159
|
-
|
|
261
|
+
### Debug mode
|
|
160
262
|
|
|
161
263
|
```ts
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
278
|
+
### PII scrub utility
|
|
185
279
|
|
|
186
|
-
|
|
280
|
+
Opt-in regex-based scrub for email + card-number-shaped substrings. Use before forwarding caller-supplied properties:
|
|
187
281
|
|
|
188
282
|
```ts
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
##
|
|
295
|
+
## Configuration
|
|
196
296
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
Read one audit row by event ID.
|
|
297
|
+
All options on `new CrossdeckServer({...})`:
|
|
200
298
|
|
|
201
299
|
```ts
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
##
|
|
340
|
+
## Error model
|
|
207
341
|
|
|
208
|
-
|
|
342
|
+
Stripe-style subclass hierarchy. Use `instanceof` for typed narrowing in your `catch` blocks.
|
|
209
343
|
|
|
210
344
|
```ts
|
|
211
|
-
import {
|
|
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.
|
|
354
|
+
await crossdeck.heartbeat();
|
|
215
355
|
} catch (err) {
|
|
216
|
-
if (err instanceof
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
234
|
-
|
|
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
|
|