@cross-deck/node
The Crossdeck server SDK for Node.js — one install, three pillars: errors, analytics, entitlements.
npm install @cross-deck/node
Quick start
import { CrossdeckServer } from "@cross-deck/node";
const crossdeck = new CrossdeckServer({
secretKey: process.env.CROSSDECK_SECRET_KEY!,
appId: "app_node_xxxxxxxxxxxx",
});
await crossdeck.heartbeat();
try {
await processOrder(orderId);
} catch (err) {
crossdeck.captureError(err, { context: { orderId } });
throw err;
}
crossdeck.track({
name: "checkout.completed",
developerUserId: "user_847",
properties: { plan: "pro", revenue: 9_900 },
});
await crossdeck.getEntitlements({ userId: "user_847" });
if (crossdeck.isEntitled({ userId: "user_847" }, "pro")) {
}
Three USPs, one SDK
USP 1 — Errors
Auto-wired by default: process.on('uncaughtException'), process.on('unhandledRejection'), and globalThis.fetch wrap (5xx + network failures). Plus the full manual surface:
crossdeck.captureError(err, {
context: { jobId },
tags: { flow: "checkout" },
level: "error",
});
crossdeck.captureMessage("deprecated path hit", "warning");
crossdeck.setTag("release", process.env.K_REVISION);
crossdeck.setContext("region", { az: "us-east-1a" });
crossdeck.addBreadcrumb({
timestamp: Date.now(),
category: "custom",
message: "user.opened_paywall",
});
crossdeck.setErrorBeforeSend((err) => {
if (err.message.includes("auth-token=")) return null;
return err;
});
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.
To opt out (e.g. if you have a separate error tracker):
new CrossdeckServer({ secretKey, errorCapture: false });
USP 2 — Analytics
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.
crossdeck.track({
name: "paywall_shown",
developerUserId: "user_847",
properties: { variant: "v3" },
});
crossdeck.register({ serviceVersion: process.env.K_REVISION });
crossdeck.unregister("oldField");
crossdeck.group("org", "acme_inc");
crossdeck.group("team", "design", { headcount: 12 });
await crossdeck.ingest([
{ name: "job.completed", crossdeckCustomerId: "cdcust_x", properties: { durationMs: 1200 } },
{ name: "job.completed", crossdeckCustomerId: "cdcust_y", properties: { durationMs: 950 } },
]);
await crossdeck.flush();
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.
Framework adapters (@cross-deck/node/auto-events)
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.
import {
crossdeckExpress,
crossdeckExpressErrorHandler,
wrapLambdaHandler,
wrapFunction,
} from "@cross-deck/node/auto-events";
app.use(crossdeckExpress(crossdeck, {
getIdentity: (req) => ({ developerUserId: req.user?.id }),
}));
app.use(crossdeckExpressErrorHandler(crossdeck));
export const handler = wrapLambdaHandler(crossdeck, async (event, ctx) => {
return { statusCode: 200, body: "ok" };
});
export const myFunction = onRequest(
wrapFunction(crossdeck, async (req, res) => {
res.send("ok");
}),
);
USP 3 — Entitlements
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.
await crossdeck.getEntitlements({ userId: "user_847" });
if (crossdeck.isEntitled({ userId: "user_847" }, "pro")) {
}
const ents = crossdeck.listEntitlements({ userId: "user_847" });
const unsubscribe = crossdeck.onEntitlementsChange((customerId, ents) => {
});
await crossdeck.grantEntitlement({
customerId: "cdcust_123",
entitlementKey: "pro",
duration: "P30D",
reason: "Support recovery after billing incident",
});
await crossdeck.revokeEntitlement({
customerId: "cdcust_123",
entitlementKey: "pro",
reason: "Chargeback",
});
Webhook signature verification
Stripe-compatible HMAC-SHA256 with constant-time comparison + replay window. Supports multi-secret rotation.
import { verifyWebhookSignature } from "@cross-deck/node";
import express from "express";
app.post("/crossdeck-webhook", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = verifyWebhookSignature(
req.body.toString("utf8"),
req.headers["crossdeck-signature"],
[process.env.CROSSDECK_WEBHOOK_SECRET, process.env.CROSSDECK_WEBHOOK_SECRET_OLD],
);
handleCrossdeckEvent(event);
res.sendStatus(200);
} catch (err) {
res.sendStatus(401);
}
});
For test fixtures that need to mint signed webhooks against the same scheme, signWebhookPayload(payload, secret, timestampSec) is exported.
Cross-cutting
Runtime info
Auto-detected at construction. Attached to every event + error as runtime.* properties:
| AWS Lambda + Vercel Functions | AWS_LAMBDA_FUNCTION_NAME | aws-lambda |
| Azure Functions | FUNCTIONS_WORKER_RUNTIME + WEBSITE_INSTANCE_ID | azure-functions |
| Google App Engine | GAE_APPLICATION | google-app-engine |
| Firebase Functions v2 / Cloud Functions Gen 2 | K_SERVICE + FIREBASE_CONFIG | firebase-functions-v2 |
| Firebase Functions v1 | FUNCTION_NAME + FUNCTION_REGION | firebase-functions-v1 |
| Google Cloud Run | K_SERVICE + K_REVISION (no Firebase) | cloud-run |
| Vercel | VERCEL === "1" | vercel |
| Netlify Functions | NETLIFY === "true" | netlify |
| Heroku | DYNO | heroku |
| Render | RENDER === "true" | render |
| Railway | RAILWAY_ENVIRONMENT | railway |
| Fly.io | FLY_APP_NAME | fly |
| Generic Kubernetes | KUBERNETES_SERVICE_HOST | kubernetes |
| Plain Node | (fallback) | node |
Every detected platform exposes serviceName, serviceVersion, region, instanceId where available. Override via constructor:
new CrossdeckServer({
secretKey,
serviceName: "my-fn",
serviceVersion: process.env.K_REVISION,
appVersion: "1.2.3",
});
Diagnostics
const d = crossdeck.diagnostics();
Useful for /health and /metrics endpoints exposed to your platform.
Debug mode
new CrossdeckServer({ secretKey, debug: true });
Emits NorthStar §16 debug signals to console.info:
sdk.configured — boot confirmation
sdk.first_event_sent — proves wire connectivity
sdk.flush_retry_scheduled — surfaces flush failures + retry delay
sdk.flush_on_exit_started / sdk.flush_on_exit_completed — drain lifecycle
sdk.entitlement_cache_warm / sdk.entitlement_cache_used — cache observability
sdk.webhook_verified — signature verification confirmation
sdk.sensitive_property_warning — flagged property names on track()
sdk.runtime_detected — host platform detection
PII scrub utility
Opt-in regex-based scrub for email + card-number-shaped substrings. Use before forwarding caller-supplied properties:
import { scrubPiiFromProperties } from "@cross-deck/node";
crossdeck.track({
name: "checkout.failed",
developerUserId,
properties: scrubPiiFromProperties({
url: req.url,
failedCardLast4: payload.card_number,
}),
});
Configuration
All options on new CrossdeckServer({...}):
{
secretKey: string;
baseUrl?: string;
timeoutMs?: number;
appId?: string;
sdkVersion?: string;
errorCapture?: boolean | Partial<ErrorCaptureConfig>;
eventFlushBatchSize?: number;
eventFlushIntervalMs?: number;
flushOnExit?: boolean;
flushOnExitTimeoutMs?: number;
entitlementCacheTtlMs?: number;
serviceName?: string;
serviceVersion?: string;
appVersion?: string;
debug?: boolean;
breadcrumbsMaxSize?: number;
testMode?: boolean;
onRequest?: (info) => void;
onResponse?: (info) => void;
httpRetries?: {
maxAttempts?: number;
retryableStatuses?: number[];
};
runtimeToken?: string;
}
Error model
Stripe-style subclass hierarchy. Use instanceof for typed narrowing in your catch blocks.
import {
CrossdeckError,
CrossdeckAuthenticationError,
CrossdeckRateLimitError,
CrossdeckNetworkError,
isCrossdeckErrorCode,
} from "@cross-deck/node";
try {
await crossdeck.heartbeat();
} catch (err) {
if (err instanceof CrossdeckAuthenticationError) {
} else if (err instanceof CrossdeckRateLimitError) {
} else if (err instanceof CrossdeckNetworkError) {
} else if (err instanceof CrossdeckError) {
if (isCrossdeckErrorCode(err.code) && err.code === "invalid_secret_key") {
}
console.error(err.type, err.code, err.requestId);
}
}
Subclasses: CrossdeckAuthenticationError, CrossdeckPermissionError, CrossdeckValidationError, CrossdeckRateLimitError, CrossdeckNetworkError, CrossdeckInternalError, CrossdeckConfigurationError. All extend CrossdeckError. Constructed automatically by the SDK — you never need to instantiate them yourself.
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).
err.toJSON() is implemented — your structured logger sees type, code, requestId, status, retryAfterMs, and stack instead of just name + message:
logger.error({ err }, "crossdeck request failed");
Every entry in CROSSDECK_ERROR_CODES carries { code, type, description, resolution, retryable } — render-able in dashboards and AI assistants.
Reliability + lifecycle
Idempotent GET retry
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:
new CrossdeckServer({
secretKey,
httpRetries: { maxAttempts: 5 },
});
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.
AbortSignal — caller-controlled cancellation
Every async method accepts a final RequestOptions? with { signal, timeoutMs }:
const ctrl = new AbortController();
const flight = crossdeck.heartbeat({ signal: ctrl.signal });
setTimeout(() => ctrl.abort(), 100);
try {
await flight;
} catch (err) {
if (err instanceof CrossdeckNetworkError && err.code === "request_aborted") {
}
}
EventEmitter — internal events
CrossdeckServer extends EventEmitter. Subscribe to internal lifecycle events with typed listeners:
crossdeck.on("queue.flush_failed", ({ error, attempt, nextRetryMs }) => {
metrics.increment("crossdeck.flush_failed", { attempt });
});
crossdeck.on("error.captured", ({ fingerprint, kind, message }) => {
});
crossdeck.on("sdk.shutdown", ({ reason }) => {
});
Events: queue.flush_succeeded, queue.flush_failed, queue.dropped, queue.buffer_changed, error.captured, entitlements.warmed, sdk.shutdown.
Health probes — Kubernetes / load balancers
crossdeck.isReady();
await crossdeck.awaitReady(2000);
crossdeck.getHealth();
app.get("/healthz", (_req, res) => {
const h = crossdeck.getHealth();
res.status(h.healthy ? 200 : 503).json(h);
});
Explicit resource management
TC39 using / await using syntax (Node 20+, TS 5.2+):
{
using crossdeck = new CrossdeckServer({ secretKey });
}
async function lambdaHandler(event) {
await using crossdeck = new CrossdeckServer({ secretKey });
crossdeck.track({ name: "handler.invoked", developerUserId: event.userId });
}
testMode — caller tests without mocking fetch
const crossdeck = new CrossdeckServer({
secretKey: "cd_sk_test_test",
testMode: true,
});
onRequest / onResponse hooks
new CrossdeckServer({
secretKey,
onRequest: (info) => debug.log({ method: info.method, url: info.url, attempt: info.attempt }),
onResponse: (info) => metrics.histogram("crossdeck.request_ms", info.durationMs),
});
Synchronous, errors swallowed — telemetry must never break the request pipeline.
Bulk entitlement ops
const results = await crossdeck.bulkGrantEntitlement(
customerIds.map((customerId) => ({
customerId,
entitlementKey: "pro_q1_bonus",
duration: "P30D",
reason: "Q1 promo",
})),
{ maxConcurrency: 10 },
);
const succeeded = results.filter((r) => r.ok);
const failed = results.filter((r) => !r.ok);
Symmetric bulkRevokeEntitlement(revokes[], options?).
Node version
Node 18+. Uses the platform fetch and node:crypto — zero runtime dependencies.
Bundle
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.
License
MIT