@cross-deck/node 1.6.0 → 1.8.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 +60 -0
- package/README.md +11 -3
- package/dist/auto-events/index.d.mts +1 -1
- package/dist/auto-events/index.d.ts +1 -1
- package/dist/{crossdeck-server-C1Ue0rv4.d.mts → crossdeck-server-D9RvKxgA.d.mts} +21 -3
- package/dist/{crossdeck-server-C1Ue0rv4.d.ts → crossdeck-server-D9RvKxgA.d.ts} +21 -3
- package/dist/index.cjs +169 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +10 -4
- package/dist/index.d.ts +10 -4
- package/dist/index.mjs +169 -15
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/dist/contracts.json +0 -557
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,66 @@ All notable changes to `@cross-deck/node` will be documented here. The
|
|
|
4
4
|
format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [1.8.0] — 2026-06-21
|
|
10
|
+
|
|
11
|
+
**Self-defends against re-instantiation (Next.js / serverless).** Constructing
|
|
12
|
+
`new CrossdeckServer()` at module top-level is the documented pattern, but
|
|
13
|
+
frameworks like Next.js re-evaluate module scope (HMR, per-route isolation, chunk
|
|
14
|
+
splitting), so it could run many times in one process — each time firing another
|
|
15
|
+
boot heartbeat, starting another flush timer, stacking another set of process
|
|
16
|
+
listeners (`beforeExit` / `SIGTERM` / `uncaughtException` / `unhandledRejection`),
|
|
17
|
+
and re-wrapping global `fetch`. That produced a storm of duplicate phone-homes and
|
|
18
|
+
an `EventEmitter` max-listeners warning. The SDK now guards against it — minor,
|
|
19
|
+
backwards-compatible.
|
|
20
|
+
|
|
21
|
+
**Fixed:**
|
|
22
|
+
|
|
23
|
+
- **Singleton guard.** The constructor now returns the EXISTING instance for the
|
|
24
|
+
same credentials (secretKey + appId + baseUrl) instead of building a second one,
|
|
25
|
+
so re-evaluation never re-boots. Same defence Prisma / Firebase Admin ship for
|
|
26
|
+
the same reason. New `CrossdeckServer.clearSingletonCache()` static for tests /
|
|
27
|
+
bespoke hot-reload teardown.
|
|
28
|
+
- **Idempotent `fetch` wrap.** The error-capture fetch wrapper tags itself and
|
|
29
|
+
skips wrapping an already-wrapped `fetch`, so a double-install can't
|
|
30
|
+
double-capture every request.
|
|
31
|
+
|
|
32
|
+
## [1.7.0] — 2026-06-11
|
|
33
|
+
|
|
34
|
+
**PARK on version-rejection — events are held, never dropped.** A third
|
|
35
|
+
event-queue outcome for the day the server stops accepting an outdated event
|
|
36
|
+
format. Purely additive; no public API change.
|
|
37
|
+
|
|
38
|
+
**Added:**
|
|
39
|
+
|
|
40
|
+
- **PARK (HTTP `426` / `sdk_version_unsupported`).** A version-rejection is now
|
|
41
|
+
recognised as its own outcome — distinct from retry (transient) and drop
|
|
42
|
+
(invalid): the data is good, only the wire dialect is stale. The queue
|
|
43
|
+
**holds** the events (folded to the buffer front, FIFO-capped at 1000),
|
|
44
|
+
**hushes** (stops flushing a known-too-old payload), **signals** once (one
|
|
45
|
+
`console.warn` + a typed `sdk.parked` debug event), and delivers on restart
|
|
46
|
+
after you upgrade. Node's queue is in-memory, so a process restart *before*
|
|
47
|
+
upgrade clears the held events — an opt-in disk-backed queue is on the
|
|
48
|
+
roadmap; the messaging says exactly this, never more.
|
|
49
|
+
- **`sdk_version_unsupported`** added to the error-codes catalogue with
|
|
50
|
+
remediation, and `version_error` to `CrossdeckErrorType`. `CrossdeckError`
|
|
51
|
+
carries `minVersion` / `surface` from the 426 body. New `onParked` callback.
|
|
52
|
+
|
|
53
|
+
**Fixed (no public API change):**
|
|
54
|
+
|
|
55
|
+
- The empty-input contract is now codified cross-SDK as
|
|
56
|
+
`invalid-input-rejected-natively`: `track("")` / `aliasIdentity` with a
|
|
57
|
+
missing `userId` reject at the call site by throwing a typed `CrossdeckError`
|
|
58
|
+
(`missing_event_name` / `missing_user_id`) and never reach the wire — the
|
|
59
|
+
Node/JS idiom of the invariant *"invalid input never crashes the app."* No
|
|
60
|
+
behaviour change; the guarantee is now documented and bundled.
|
|
61
|
+
- Standalone-build fix: the `contract-failed` schema-lock test now reads the
|
|
62
|
+
bundled contract (`_contracts-bundled.ts`) instead of the monorepo
|
|
63
|
+
`contracts/` path, so the published-mirror release build no longer fails.
|
|
64
|
+
|
|
65
|
+
See https://cross-deck.com/docs/sdk-event-durability/ for the durability contract.
|
|
66
|
+
|
|
7
67
|
## [1.6.0] — 2026-06-10
|
|
8
68
|
|
|
9
69
|
Event Envelope v1 conformance — server-enforced contract (spec
|
package/README.md
CHANGED
|
@@ -42,6 +42,8 @@ if (crossdeck.isEntitled({ userId: "user_847" }, "pro")) {
|
|
|
42
42
|
}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
Construct the client **once** at module scope and import it where you need it. This is safe even under frameworks that re-evaluate modules (Next.js HMR, per-route isolation, React Server Components): for a given secret key the SDK returns the same instance every time, so you never get a duplicate client. _(Single-instance guard added in 1.8.0.)_
|
|
46
|
+
|
|
45
47
|
## Three USPs, one SDK
|
|
46
48
|
|
|
47
49
|
### USP 1 — Errors
|
|
@@ -368,7 +370,7 @@ try {
|
|
|
368
370
|
}
|
|
369
371
|
```
|
|
370
372
|
|
|
371
|
-
Subclasses: `CrossdeckAuthenticationError`, `CrossdeckPermissionError`, `CrossdeckValidationError`, `CrossdeckRateLimitError`, `CrossdeckNetworkError`, `CrossdeckInternalError`, `CrossdeckConfigurationError`. All extend `CrossdeckError`. Constructed automatically by the SDK — you never need to instantiate them yourself.
|
|
373
|
+
Subclasses: `CrossdeckAuthenticationError`, `CrossdeckPermissionError`, `CrossdeckValidationError`, `CrossdeckRateLimitError`, `CrossdeckNetworkError`, `CrossdeckInternalError`, `CrossdeckConfigurationError`. All extend `CrossdeckError`. The `version_error` type (code `sdk_version_unsupported`, HTTP 426) carries `minVersion`/`surface` and routes to PARK — see "Outdated-version PARK" above. Constructed automatically by the SDK — you never need to instantiate them yourself.
|
|
372
374
|
|
|
373
375
|
`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
376
|
|
|
@@ -397,6 +399,12 @@ new CrossdeckServer({
|
|
|
397
399
|
|
|
398
400
|
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
401
|
|
|
402
|
+
### Outdated-version PARK (v1.7.0)
|
|
403
|
+
|
|
404
|
+
If the server ever stops accepting this SDK version's event format, the rejection is machine-distinguishable — HTTP `426` with code `sdk_version_unsupported` — and the queue treats it as its own outcome, distinct from retry (transient) and drop (invalid): the events are **parked**. The queue holds them (FIFO-capped at 1,000), stops flushing a known-too-old payload, warns once on the console naming the exact version to update to, and fires the `onParked` callback + a typed `sdk.parked` debug event.
|
|
405
|
+
|
|
406
|
+
**Honest bound:** the Node queue is in-memory, so a process restart *before* you upgrade clears the held events — an opt-in disk-backed queue is on the roadmap. After you deploy the upgraded SDK, held events deliver on the next flush. Web/RN/Swift hold theirs durably across restarts. Full story: [the durability contract](https://cross-deck.com/docs/sdk-event-durability/).
|
|
407
|
+
|
|
400
408
|
**v1.4.0 — `syncPurchases` deterministic key.** The Idempotency-Key
|
|
401
409
|
on `syncPurchases` is derived from the request body (UUID-shaped
|
|
402
410
|
SHA-256 of `crossdeck:purchases/sync:<rail>:<jws|token>`). Two retries
|
|
@@ -584,8 +592,8 @@ CrossdeckContracts.byId("idempotency-key-deterministic");
|
|
|
584
592
|
CrossdeckContracts.byPillar("revenue");
|
|
585
593
|
CrossdeckContracts.withStatus("proposed");
|
|
586
594
|
CrossdeckContracts.findByTestName("rail namespacing prevents cross-rail collisions");
|
|
587
|
-
CrossdeckContracts.sdkVersion; // "1.
|
|
588
|
-
CrossdeckContracts.bundledIn; // "@cross-deck/node@1.
|
|
595
|
+
CrossdeckContracts.sdkVersion; // "1.7.0"
|
|
596
|
+
CrossdeckContracts.bundledIn; // "@cross-deck/node@1.7.0"
|
|
589
597
|
```
|
|
590
598
|
|
|
591
599
|
The `Contract` type is exported alongside; the binary-stability promise is documented in [`contracts/README.md`](https://github.com/VistaApps-za/crossdeck/blob/main/contracts/README.md).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
|
|
3
|
-
type CrossdeckErrorType = "authentication_error" | "permission_error" | "invalid_request_error" | "rate_limit_error" | "internal_error" | "network_error" | "configuration_error";
|
|
3
|
+
type CrossdeckErrorType = "authentication_error" | "permission_error" | "invalid_request_error" | "rate_limit_error" | "version_error" | "internal_error" | "network_error" | "configuration_error";
|
|
4
4
|
interface CrossdeckErrorPayload {
|
|
5
5
|
type: CrossdeckErrorType;
|
|
6
6
|
/**
|
|
@@ -19,6 +19,14 @@ interface CrossdeckErrorPayload {
|
|
|
19
19
|
requestId?: string;
|
|
20
20
|
status?: number;
|
|
21
21
|
retryAfterMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Required SDK version floor — populated only on a `426 Upgrade Required`
|
|
24
|
+
* / `sdk_version_unsupported` response, so the queue's PARK message names
|
|
25
|
+
* the exact version to update to.
|
|
26
|
+
*/
|
|
27
|
+
minVersion?: string;
|
|
28
|
+
/** SDK surface the rejection applies to (web/node/swift/…), on a 426. */
|
|
29
|
+
surface?: string;
|
|
22
30
|
}
|
|
23
31
|
declare class CrossdeckError extends Error {
|
|
24
32
|
readonly type: CrossdeckErrorType;
|
|
@@ -26,6 +34,8 @@ declare class CrossdeckError extends Error {
|
|
|
26
34
|
readonly requestId?: string;
|
|
27
35
|
readonly status?: number;
|
|
28
36
|
readonly retryAfterMs?: number;
|
|
37
|
+
readonly minVersion?: string;
|
|
38
|
+
readonly surface?: string;
|
|
29
39
|
constructor(payload: CrossdeckErrorPayload);
|
|
30
40
|
/**
|
|
31
41
|
* JSON representation suitable for structured loggers. Without this,
|
|
@@ -173,8 +183,8 @@ declare const CrossdeckContracts: {
|
|
|
173
183
|
readonly byId: (id: string) => Contract | undefined;
|
|
174
184
|
readonly byPillar: (pillar: ContractPillar) => readonly Contract[];
|
|
175
185
|
readonly withStatus: (status: ContractStatus) => readonly Contract[];
|
|
176
|
-
readonly sdkVersion: "1.
|
|
177
|
-
readonly bundledIn: "@cross-deck/node@1.
|
|
186
|
+
readonly sdkVersion: "1.8.0";
|
|
187
|
+
readonly bundledIn: "@cross-deck/node@1.8.0";
|
|
178
188
|
/**
|
|
179
189
|
* Resolve a failing test back to the contract it exercises.
|
|
180
190
|
* Used by test-framework hooks to find the contract id of a
|
|
@@ -1375,6 +1385,14 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1375
1385
|
*/
|
|
1376
1386
|
private didEmitShutdown;
|
|
1377
1387
|
constructor(options: CrossdeckServerOptions);
|
|
1388
|
+
/**
|
|
1389
|
+
* Clear the process-wide singleton cache. The SDK hands back the SAME instance
|
|
1390
|
+
* for the same credentials (the Next.js / serverless re-instantiation guard in
|
|
1391
|
+
* the constructor); this resets that so the next `new CrossdeckServer()` builds a
|
|
1392
|
+
* fresh instance. For TESTS (per-test isolation) and bespoke hot-reload teardown
|
|
1393
|
+
* only — production code never needs it.
|
|
1394
|
+
*/
|
|
1395
|
+
static clearSingletonCache(): void;
|
|
1378
1396
|
/**
|
|
1379
1397
|
* Emit the honest "no cold-start durability" warning when the runtime
|
|
1380
1398
|
* is serverless AND no `entitlementStore` is wired. Local-only debug
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
|
|
3
|
-
type CrossdeckErrorType = "authentication_error" | "permission_error" | "invalid_request_error" | "rate_limit_error" | "internal_error" | "network_error" | "configuration_error";
|
|
3
|
+
type CrossdeckErrorType = "authentication_error" | "permission_error" | "invalid_request_error" | "rate_limit_error" | "version_error" | "internal_error" | "network_error" | "configuration_error";
|
|
4
4
|
interface CrossdeckErrorPayload {
|
|
5
5
|
type: CrossdeckErrorType;
|
|
6
6
|
/**
|
|
@@ -19,6 +19,14 @@ interface CrossdeckErrorPayload {
|
|
|
19
19
|
requestId?: string;
|
|
20
20
|
status?: number;
|
|
21
21
|
retryAfterMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Required SDK version floor — populated only on a `426 Upgrade Required`
|
|
24
|
+
* / `sdk_version_unsupported` response, so the queue's PARK message names
|
|
25
|
+
* the exact version to update to.
|
|
26
|
+
*/
|
|
27
|
+
minVersion?: string;
|
|
28
|
+
/** SDK surface the rejection applies to (web/node/swift/…), on a 426. */
|
|
29
|
+
surface?: string;
|
|
22
30
|
}
|
|
23
31
|
declare class CrossdeckError extends Error {
|
|
24
32
|
readonly type: CrossdeckErrorType;
|
|
@@ -26,6 +34,8 @@ declare class CrossdeckError extends Error {
|
|
|
26
34
|
readonly requestId?: string;
|
|
27
35
|
readonly status?: number;
|
|
28
36
|
readonly retryAfterMs?: number;
|
|
37
|
+
readonly minVersion?: string;
|
|
38
|
+
readonly surface?: string;
|
|
29
39
|
constructor(payload: CrossdeckErrorPayload);
|
|
30
40
|
/**
|
|
31
41
|
* JSON representation suitable for structured loggers. Without this,
|
|
@@ -173,8 +183,8 @@ declare const CrossdeckContracts: {
|
|
|
173
183
|
readonly byId: (id: string) => Contract | undefined;
|
|
174
184
|
readonly byPillar: (pillar: ContractPillar) => readonly Contract[];
|
|
175
185
|
readonly withStatus: (status: ContractStatus) => readonly Contract[];
|
|
176
|
-
readonly sdkVersion: "1.
|
|
177
|
-
readonly bundledIn: "@cross-deck/node@1.
|
|
186
|
+
readonly sdkVersion: "1.8.0";
|
|
187
|
+
readonly bundledIn: "@cross-deck/node@1.8.0";
|
|
178
188
|
/**
|
|
179
189
|
* Resolve a failing test back to the contract it exercises.
|
|
180
190
|
* Used by test-framework hooks to find the contract id of a
|
|
@@ -1375,6 +1385,14 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1375
1385
|
*/
|
|
1376
1386
|
private didEmitShutdown;
|
|
1377
1387
|
constructor(options: CrossdeckServerOptions);
|
|
1388
|
+
/**
|
|
1389
|
+
* Clear the process-wide singleton cache. The SDK hands back the SAME instance
|
|
1390
|
+
* for the same credentials (the Next.js / serverless re-instantiation guard in
|
|
1391
|
+
* the constructor); this resets that so the next `new CrossdeckServer()` builds a
|
|
1392
|
+
* fresh instance. For TESTS (per-test isolation) and bespoke hot-reload teardown
|
|
1393
|
+
* only — production code never needs it.
|
|
1394
|
+
*/
|
|
1395
|
+
static clearSingletonCache(): void;
|
|
1378
1396
|
/**
|
|
1379
1397
|
* Emit the honest "no cold-start durability" warning when the runtime
|
|
1380
1398
|
* is serverless AND no `entitlementStore` is wired. Local-only debug
|
package/dist/index.cjs
CHANGED
|
@@ -66,6 +66,8 @@ var CrossdeckError = class _CrossdeckError extends Error {
|
|
|
66
66
|
requestId;
|
|
67
67
|
status;
|
|
68
68
|
retryAfterMs;
|
|
69
|
+
minVersion;
|
|
70
|
+
surface;
|
|
69
71
|
constructor(payload) {
|
|
70
72
|
super(payload.message);
|
|
71
73
|
this.name = "CrossdeckError";
|
|
@@ -74,6 +76,8 @@ var CrossdeckError = class _CrossdeckError extends Error {
|
|
|
74
76
|
this.requestId = payload.requestId;
|
|
75
77
|
this.status = payload.status;
|
|
76
78
|
this.retryAfterMs = payload.retryAfterMs;
|
|
79
|
+
this.minVersion = payload.minVersion;
|
|
80
|
+
this.surface = payload.surface;
|
|
77
81
|
Object.setPrototypeOf(this, _CrossdeckError.prototype);
|
|
78
82
|
}
|
|
79
83
|
/**
|
|
@@ -95,6 +99,8 @@ var CrossdeckError = class _CrossdeckError extends Error {
|
|
|
95
99
|
requestId: this.requestId,
|
|
96
100
|
status: this.status,
|
|
97
101
|
retryAfterMs: this.retryAfterMs,
|
|
102
|
+
minVersion: this.minVersion,
|
|
103
|
+
surface: this.surface,
|
|
98
104
|
stack: this.stack
|
|
99
105
|
};
|
|
100
106
|
}
|
|
@@ -185,7 +191,10 @@ async function crossdeckErrorFromResponse(res) {
|
|
|
185
191
|
message: envelope.message ?? `HTTP ${res.status}`,
|
|
186
192
|
requestId: envelope.request_id ?? requestId,
|
|
187
193
|
status: res.status,
|
|
188
|
-
retryAfterMs
|
|
194
|
+
retryAfterMs,
|
|
195
|
+
// PARK metadata, present only on a 426 / sdk_version_unsupported body.
|
|
196
|
+
minVersion: typeof envelope.minVersion === "string" ? envelope.minVersion : void 0,
|
|
197
|
+
surface: typeof envelope.surface === "string" ? envelope.surface : void 0
|
|
189
198
|
});
|
|
190
199
|
}
|
|
191
200
|
return makeCrossdeckError({
|
|
@@ -378,7 +387,7 @@ function byteLength(s) {
|
|
|
378
387
|
var https = __toESM(require("https"));
|
|
379
388
|
|
|
380
389
|
// src/_version.ts
|
|
381
|
-
var SDK_VERSION = "1.
|
|
390
|
+
var SDK_VERSION = "1.8.0";
|
|
382
391
|
var SDK_NAME = "@cross-deck/node";
|
|
383
392
|
|
|
384
393
|
// src/_diagnostic-telemetry.ts
|
|
@@ -398,7 +407,12 @@ var DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS = /* @__PURE__ */ new Set([
|
|
|
398
407
|
"run_id",
|
|
399
408
|
"test_file",
|
|
400
409
|
"test_name",
|
|
401
|
-
"device_class"
|
|
410
|
+
"device_class",
|
|
411
|
+
// verification_phase — categorical `boot` / `hot_path` bucket set by the
|
|
412
|
+
// runtime contract verifier layer. Declared in the shared diagnostics
|
|
413
|
+
// contract (contracts/diagnostics/contract-failed-payload-schema-lock.json)
|
|
414
|
+
// and carried by web/swift; node had drifted by omitting it.
|
|
415
|
+
"verification_phase"
|
|
402
416
|
]);
|
|
403
417
|
function filterDiagnosticPayload(payload) {
|
|
404
418
|
const filtered = {};
|
|
@@ -891,6 +905,16 @@ var EventQueue = class {
|
|
|
891
905
|
cancelTimer = null;
|
|
892
906
|
firstFlushFired = false;
|
|
893
907
|
nextRetryAt = null;
|
|
908
|
+
/**
|
|
909
|
+
* PARK state (HTTP 426 / `sdk_version_unsupported`). Once parked, the
|
|
910
|
+
* queue stops flushing — retrying a known-too-old payload only wastes
|
|
911
|
+
* bandwidth and drips pointless rejects into the server logs until the
|
|
912
|
+
* process restarts on an upgraded SDK. In-memory: a fresh process starts
|
|
913
|
+
* unparked, retries once, and either delivers (upgraded) or re-parks.
|
|
914
|
+
*/
|
|
915
|
+
parked = false;
|
|
916
|
+
/** One developer-facing console warning per process — never per-event spam. */
|
|
917
|
+
parkWarned = false;
|
|
894
918
|
retry;
|
|
895
919
|
/**
|
|
896
920
|
* Stable Idempotency-Key for the current in-flight batch. Minted
|
|
@@ -943,6 +967,7 @@ var EventQueue = class {
|
|
|
943
967
|
* this one settles. Strict ordering preserved.
|
|
944
968
|
*/
|
|
945
969
|
async flush() {
|
|
970
|
+
if (this.parked) return null;
|
|
946
971
|
let batch;
|
|
947
972
|
let batchId;
|
|
948
973
|
if (this.pendingBatch !== null && this.pendingBatchId !== null) {
|
|
@@ -990,6 +1015,28 @@ var EventQueue = class {
|
|
|
990
1015
|
} catch (err) {
|
|
991
1016
|
const message = err instanceof Error ? err.message : String(err);
|
|
992
1017
|
this.lastError = message;
|
|
1018
|
+
if (isVersionRejected(err)) {
|
|
1019
|
+
this.parked = true;
|
|
1020
|
+
this.buffer = [...batch, ...this.buffer];
|
|
1021
|
+
if (this.buffer.length > HARD_BUFFER_CAP) {
|
|
1022
|
+
const overflow = this.buffer.length - HARD_BUFFER_CAP;
|
|
1023
|
+
this.buffer.splice(0, overflow);
|
|
1024
|
+
this.dropped += overflow;
|
|
1025
|
+
}
|
|
1026
|
+
this.pendingBatch = null;
|
|
1027
|
+
this.pendingBatchId = null;
|
|
1028
|
+
this.inFlight -= batch.length;
|
|
1029
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
1030
|
+
const minVersion = versionRejectionFloor(err);
|
|
1031
|
+
if (!this.parkWarned) {
|
|
1032
|
+
this.parkWarned = true;
|
|
1033
|
+
console.warn(
|
|
1034
|
+
`[Crossdeck] SDK outdated \u2014 the server is no longer accepting this version's event format. Events are PARKED in memory (held, not lost across this process) and will deliver once you update @cross-deck/node${minVersion ? ` to >= ${minVersion}` : ""} and restart. A restart before upgrade clears them \u2014 configure a disk queue for cross-restart durability.`
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
this.cfg.onParked?.({ minVersion, surface: versionRejectionSurface(err) });
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
993
1040
|
if (isPermanent4xx(err)) {
|
|
994
1041
|
const droppedCount = batch.length;
|
|
995
1042
|
this.pendingBatch = null;
|
|
@@ -1088,8 +1135,22 @@ function isPermanent4xx(err) {
|
|
|
1088
1135
|
if (typeof status !== "number" || !Number.isFinite(status)) return false;
|
|
1089
1136
|
if (status < 400 || status >= 500) return false;
|
|
1090
1137
|
if (status === 408 || status === 429) return false;
|
|
1138
|
+
if (status === 426) return false;
|
|
1091
1139
|
return true;
|
|
1092
1140
|
}
|
|
1141
|
+
function isVersionRejected(err) {
|
|
1142
|
+
if (!err || typeof err !== "object") return false;
|
|
1143
|
+
if (err.status === 426) return true;
|
|
1144
|
+
return err.code === "sdk_version_unsupported";
|
|
1145
|
+
}
|
|
1146
|
+
function versionRejectionFloor(err) {
|
|
1147
|
+
const v = err?.minVersion;
|
|
1148
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
1149
|
+
}
|
|
1150
|
+
function versionRejectionSurface(err) {
|
|
1151
|
+
const v = err?.surface;
|
|
1152
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
1153
|
+
}
|
|
1093
1154
|
function defaultScheduler(fn, ms) {
|
|
1094
1155
|
const id = setTimeout(fn, ms);
|
|
1095
1156
|
if (typeof id.unref === "function") {
|
|
@@ -1372,6 +1433,7 @@ var ErrorTracker = class {
|
|
|
1372
1433
|
installFetchWrap() {
|
|
1373
1434
|
const origFetch = globalThis.fetch;
|
|
1374
1435
|
if (typeof origFetch !== "function") return;
|
|
1436
|
+
if (origFetch.__crossdeckWrapped__) return;
|
|
1375
1437
|
const tracker = this;
|
|
1376
1438
|
const wrapped = async (...args) => {
|
|
1377
1439
|
const input = args[0];
|
|
@@ -1412,6 +1474,7 @@ var ErrorTracker = class {
|
|
|
1412
1474
|
throw err;
|
|
1413
1475
|
}
|
|
1414
1476
|
};
|
|
1477
|
+
wrapped.__crossdeckWrapped__ = true;
|
|
1415
1478
|
globalThis.fetch = wrapped;
|
|
1416
1479
|
this.cleanups.push(() => {
|
|
1417
1480
|
if (globalThis.fetch === wrapped) globalThis.fetch = origFetch;
|
|
@@ -2617,6 +2680,9 @@ function safeJson(obj) {
|
|
|
2617
2680
|
|
|
2618
2681
|
// src/crossdeck-server.ts
|
|
2619
2682
|
var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
2683
|
+
// `!` (definite assignment): these are assigned on the real construction path,
|
|
2684
|
+
// but the singleton guard in the constructor can `return` an existing instance
|
|
2685
|
+
// before reaching them — that early return is the only path that skips them.
|
|
2620
2686
|
http;
|
|
2621
2687
|
sdkVersion;
|
|
2622
2688
|
baseUrl;
|
|
@@ -2712,6 +2778,13 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2712
2778
|
this.appId = options.appId;
|
|
2713
2779
|
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
2714
2780
|
this.env = inferEnvFromKey(options.secretKey);
|
|
2781
|
+
const _store = globalThis;
|
|
2782
|
+
const _singletonKey = `${options.secretKey}|${this.appId ?? ""}|${this.baseUrl}`;
|
|
2783
|
+
_store.__crossdeckServers__ ??= /* @__PURE__ */ new Map();
|
|
2784
|
+
const _existing = _store.__crossdeckServers__.get(_singletonKey);
|
|
2785
|
+
if (_existing) {
|
|
2786
|
+
return _existing;
|
|
2787
|
+
}
|
|
2715
2788
|
this.secretKeyPrefix = maskSecretKey(options.secretKey);
|
|
2716
2789
|
this.scrubPii = options.scrubPii !== false;
|
|
2717
2790
|
this.http = new HttpClient({
|
|
@@ -2793,6 +2866,13 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2793
2866
|
error: info.lastError
|
|
2794
2867
|
});
|
|
2795
2868
|
},
|
|
2869
|
+
onParked: (info) => {
|
|
2870
|
+
this.debug.emit(
|
|
2871
|
+
"sdk.parked",
|
|
2872
|
+
`[crossdeck] SDK parked \u2014 server no longer accepts this version's event format. Events held (paused, not lost); update @cross-deck/node${info.minVersion ? ` to >= ${info.minVersion}` : ""} and restart to resume.`,
|
|
2873
|
+
{ ...info }
|
|
2874
|
+
);
|
|
2875
|
+
},
|
|
2796
2876
|
onFirstFlushSuccess: () => {
|
|
2797
2877
|
this.debug.emit("sdk.first_event_sent", "First batch landed.");
|
|
2798
2878
|
}
|
|
@@ -2846,6 +2926,17 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2846
2926
|
this.emitBootTelemetryEvent();
|
|
2847
2927
|
});
|
|
2848
2928
|
}
|
|
2929
|
+
_store.__crossdeckServers__.set(_singletonKey, this);
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* Clear the process-wide singleton cache. The SDK hands back the SAME instance
|
|
2933
|
+
* for the same credentials (the Next.js / serverless re-instantiation guard in
|
|
2934
|
+
* the constructor); this resets that so the next `new CrossdeckServer()` builds a
|
|
2935
|
+
* fresh instance. For TESTS (per-test isolation) and bespoke hot-reload teardown
|
|
2936
|
+
* only — production code never needs it.
|
|
2937
|
+
*/
|
|
2938
|
+
static clearSingletonCache() {
|
|
2939
|
+
globalThis.__crossdeckServers__?.clear();
|
|
2849
2940
|
}
|
|
2850
2941
|
/**
|
|
2851
2942
|
* Emit the honest "no cold-start durability" warning when the runtime
|
|
@@ -4367,6 +4458,13 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
|
|
|
4367
4458
|
description: "A field is present but the value failed validation.",
|
|
4368
4459
|
resolution: "Read error.message for the field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
|
|
4369
4460
|
retryable: false
|
|
4461
|
+
},
|
|
4462
|
+
{
|
|
4463
|
+
code: "sdk_version_unsupported",
|
|
4464
|
+
type: "version_error",
|
|
4465
|
+
description: "HTTP 426 \u2014 your installed SDK sends an event format the server no longer accepts. The data is good; only the wire dialect is too old. The SDK PARKS automatically: events are held in memory and deliver once you upgrade and restart.",
|
|
4466
|
+
resolution: "Update @cross-deck/node to at least the version in error.minVersion and restart \u2014 the held queue backfills. See https://cross-deck.com/docs/sdk-event-durability/.",
|
|
4467
|
+
retryable: false
|
|
4370
4468
|
}
|
|
4371
4469
|
]);
|
|
4372
4470
|
function isCrossdeckErrorCode(code) {
|
|
@@ -4498,8 +4596,8 @@ function normaliseSecrets(input) {
|
|
|
4498
4596
|
}
|
|
4499
4597
|
|
|
4500
4598
|
// src/_contracts-bundled.ts
|
|
4501
|
-
var BUNDLED_IN = "@cross-deck/node@1.
|
|
4502
|
-
var SDK_VERSION2 = "1.
|
|
4599
|
+
var BUNDLED_IN = "@cross-deck/node@1.8.0";
|
|
4600
|
+
var SDK_VERSION2 = "1.8.0";
|
|
4503
4601
|
var BUNDLED_CONTRACTS = Object.freeze([
|
|
4504
4602
|
{
|
|
4505
4603
|
"id": "contract-failed-payload-schema-lock",
|
|
@@ -4621,7 +4719,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
|
|
|
4621
4719
|
"legal/security/index.html#diagnostic",
|
|
4622
4720
|
"legal/sdk-data/index.html#b-diagnostic"
|
|
4623
4721
|
],
|
|
4624
|
-
"bundledIn": "@cross-deck/node@1.
|
|
4722
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
4625
4723
|
},
|
|
4626
4724
|
{
|
|
4627
4725
|
"id": "documentation-honesty",
|
|
@@ -4653,7 +4751,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
|
|
|
4653
4751
|
],
|
|
4654
4752
|
"registeredAt": "2026-05-26",
|
|
4655
4753
|
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.1",
|
|
4656
|
-
"bundledIn": "@cross-deck/node@1.
|
|
4754
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
4657
4755
|
},
|
|
4658
4756
|
{
|
|
4659
4757
|
"id": "error-envelope-shape",
|
|
@@ -4692,7 +4790,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
|
|
|
4692
4790
|
],
|
|
4693
4791
|
"registeredAt": "2026-05-26",
|
|
4694
4792
|
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
|
|
4695
|
-
"bundledIn": "@cross-deck/node@1.
|
|
4793
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
4696
4794
|
},
|
|
4697
4795
|
{
|
|
4698
4796
|
"id": "flush-interval-parity",
|
|
@@ -4737,7 +4835,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
|
|
|
4737
4835
|
],
|
|
4738
4836
|
"registeredAt": "2026-05-26",
|
|
4739
4837
|
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
|
|
4740
|
-
"bundledIn": "@cross-deck/node@1.
|
|
4838
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
4741
4839
|
},
|
|
4742
4840
|
{
|
|
4743
4841
|
"id": "idempotency-key-deterministic",
|
|
@@ -4842,7 +4940,63 @@ var BUNDLED_CONTRACTS = Object.freeze([
|
|
|
4842
4940
|
],
|
|
4843
4941
|
"registeredAt": "2026-05-26",
|
|
4844
4942
|
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
|
|
4845
|
-
"bundledIn": "@cross-deck/node@1.
|
|
4943
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
4944
|
+
},
|
|
4945
|
+
{
|
|
4946
|
+
"id": "invalid-input-rejected-natively",
|
|
4947
|
+
"pillar": "errors",
|
|
4948
|
+
"status": "enforced",
|
|
4949
|
+
"claim": "No public SDK API ever crashes the host app, and invalid input never reaches the wire. Invalid input (empty event name, empty userId, out-of-range config such as a non-positive breadcrumb capacity, NaN/Infinity/oversize/cyclic property values) is rejected at the call site WITHOUT a fatal trap. The signalling IDIOM is per-language and intentionally NOT uniform: Web, Node, and React Native THROW a typed CrossdeckError synchronously (code missing_event_name / missing_user_id / invalid_request_error) \u2014 a normal, catchable JavaScript convention where an uncaught throw logs and the app continues; Swift DROPS with a debug-log signal (track_dropped / identify_dropped) to match its non-throwing fire-and-forget surface, and exposes the throwing equivalent only via identifyAndWait(userId:). What is UNIFORM is the invariant, not the mechanism: every SDK rejects the same inputs, no public fire-and-forget API contains a fatalError / assertionFailure / precondition reachable from customer input, and no rejected input is enqueued or transmitted. Swift additionally proves this in BOTH debug and release configuration, because precondition fires under -O while assertionFailure does not. The bug class this contract closes was never the per-language difference \u2014 it was the UNDECLARED difference: each SDK's public API documentation must state its own semantics explicitly (TS docs: 'throws on empty name'; Swift docs: 'drops and logs').",
|
|
4950
|
+
"appliesTo": [
|
|
4951
|
+
"web",
|
|
4952
|
+
"node",
|
|
4953
|
+
"react-native",
|
|
4954
|
+
"swift"
|
|
4955
|
+
],
|
|
4956
|
+
"codeRef": [
|
|
4957
|
+
"sdks/web/src/crossdeck.ts",
|
|
4958
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
4959
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
4960
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
4961
|
+
"sdks/swift/Sources/Crossdeck/Breadcrumbs.swift"
|
|
4962
|
+
],
|
|
4963
|
+
"testRef": [
|
|
4964
|
+
{
|
|
4965
|
+
"file": "sdks/web/tests/crossdeck.test.ts",
|
|
4966
|
+
"name": "track with empty name throws synchronously"
|
|
4967
|
+
},
|
|
4968
|
+
{
|
|
4969
|
+
"file": "sdks/web/tests/crossdeck.test.ts",
|
|
4970
|
+
"name": "rejects empty userId"
|
|
4971
|
+
},
|
|
4972
|
+
{
|
|
4973
|
+
"file": "sdks/node/tests/crossdeck-server.test.ts",
|
|
4974
|
+
"name": "track() throws CrossdeckError with code 'missing_event_name' when event name is empty"
|
|
4975
|
+
},
|
|
4976
|
+
{
|
|
4977
|
+
"file": "sdks/react-native/tests/crossdeck.test.ts",
|
|
4978
|
+
"name": "track('') throws CrossdeckError(missing_event_name) synchronously"
|
|
4979
|
+
},
|
|
4980
|
+
{
|
|
4981
|
+
"file": "sdks/react-native/tests/crossdeck.test.ts",
|
|
4982
|
+
"name": "identify('') rejects with CrossdeckError(missing_user_id)"
|
|
4983
|
+
},
|
|
4984
|
+
{
|
|
4985
|
+
"file": "sdks/swift/Tests/CrossdeckTests/CrossdeckPublicAPITests.swift",
|
|
4986
|
+
"name": "test_track_dropsEmptyName"
|
|
4987
|
+
},
|
|
4988
|
+
{
|
|
4989
|
+
"file": "sdks/swift/Tests/CrossdeckTests/CrossdeckPublicAPITests.swift",
|
|
4990
|
+
"name": "test_identifyAndWait_rejectsEmptyId"
|
|
4991
|
+
},
|
|
4992
|
+
{
|
|
4993
|
+
"file": "sdks/swift/Tests/CrossdeckTests/PublicAPIInputSafetyTests.swift",
|
|
4994
|
+
"name": "test_start_withZeroBreadcrumbCapacity_doesNotTrap"
|
|
4995
|
+
}
|
|
4996
|
+
],
|
|
4997
|
+
"registeredAt": "2026-06-11",
|
|
4998
|
+
"firstRegisteredIn": "swift trap-on-input class fix \u2014 first machine-tested Swift release",
|
|
4999
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
4846
5000
|
},
|
|
4847
5001
|
{
|
|
4848
5002
|
"id": "node-pii-scrubber",
|
|
@@ -4881,7 +5035,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
|
|
|
4881
5035
|
],
|
|
4882
5036
|
"registeredAt": "2026-05-26",
|
|
4883
5037
|
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.1",
|
|
4884
|
-
"bundledIn": "@cross-deck/node@1.
|
|
5038
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
4885
5039
|
},
|
|
4886
5040
|
{
|
|
4887
5041
|
"id": "node-shutdown-awaits-flush",
|
|
@@ -4914,7 +5068,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
|
|
|
4914
5068
|
],
|
|
4915
5069
|
"registeredAt": "2026-05-26",
|
|
4916
5070
|
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.4",
|
|
4917
|
-
"bundledIn": "@cross-deck/node@1.
|
|
5071
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
4918
5072
|
},
|
|
4919
5073
|
{
|
|
4920
5074
|
"id": "sdk-error-codes-catalogue",
|
|
@@ -4959,7 +5113,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
|
|
|
4959
5113
|
],
|
|
4960
5114
|
"registeredAt": "2026-05-26",
|
|
4961
5115
|
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
|
|
4962
|
-
"bundledIn": "@cross-deck/node@1.
|
|
5116
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
4963
5117
|
},
|
|
4964
5118
|
{
|
|
4965
5119
|
"id": "sync-purchases-funnel-parity",
|
|
@@ -4992,7 +5146,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
|
|
|
4992
5146
|
],
|
|
4993
5147
|
"registeredAt": "2026-05-26",
|
|
4994
5148
|
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
|
|
4995
|
-
"bundledIn": "@cross-deck/node@1.
|
|
5149
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
4996
5150
|
},
|
|
4997
5151
|
{
|
|
4998
5152
|
"id": "verifier-timestamp-mandatory",
|
|
@@ -5046,7 +5200,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
|
|
|
5046
5200
|
],
|
|
5047
5201
|
"registeredAt": "2026-05-26",
|
|
5048
5202
|
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.2",
|
|
5049
|
-
"bundledIn": "@cross-deck/node@1.
|
|
5203
|
+
"bundledIn": "@cross-deck/node@1.8.0"
|
|
5050
5204
|
}
|
|
5051
5205
|
]);
|
|
5052
5206
|
|