@cross-deck/web 1.6.4 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.cjs CHANGED
@@ -93,7 +93,7 @@ function typeMapForStatus(status) {
93
93
  }
94
94
 
95
95
  // src/_version.ts
96
- var SDK_VERSION = "1.6.4";
96
+ var SDK_VERSION = "1.7.0";
97
97
  var SDK_NAME = "@cross-deck/web";
98
98
 
99
99
  // src/http.ts
@@ -1009,9 +1009,13 @@ var EventQueue = class {
1009
1009
  const env = this.cfg.envelope();
1010
1010
  const result = await this.cfg.http.request("POST", "/events", {
1011
1011
  body: {
1012
- // NorthStar §13.1 batch envelope. The backend validates these
1013
- // against the API-key-resolved app and rejects mismatches
1014
- // loudly (env_mismatch).
1012
+ // Event Envelope v1 batch envelope (backend/docs/
1013
+ // event-envelope-spec-v1.md §1). `envelopeVersion` is the
1014
+ // schema/wire version the server parses against ("can I parse
1015
+ // this?") and is DISTINCT from `sdk.version` ("which build is in
1016
+ // the wild?") — two questions, two fields, never conflated. The
1017
+ // backend refuses payloads with a missing/unknown envelopeVersion.
1018
+ envelopeVersion: 1,
1015
1019
  appId: env.appId,
1016
1020
  environment: env.environment,
1017
1021
  sdk: env.sdk,
@@ -1466,6 +1470,15 @@ var AutoTracker = class {
1466
1470
  this.cleanups = [];
1467
1471
  /** Last time we flushed lastActivityAt to storage (throttle gate). */
1468
1472
  this.lastPersistAt = 0;
1473
+ /**
1474
+ * Event Envelope v1 §3 — per-session monotonic sequence counter.
1475
+ * The single owner of the seq counter lives here, alongside all other
1476
+ * session state. Incremented atomically at track() time (via nextSeq()),
1477
+ * reset to 0 whenever a new session boundary is crossed (startNewSession
1478
+ * path + resetSession). Persists across background/foreground within a
1479
+ * session — only a real session boundary (not a tab hide/show) resets it.
1480
+ */
1481
+ this._sessionSeq = 0;
1469
1482
  /**
1470
1483
  * Stable per-page-view identifier. Minted at every `page.viewed`
1471
1484
  * emission and attached to every subsequent event until the next
@@ -1550,6 +1563,20 @@ var AutoTracker = class {
1550
1563
  get currentAcquisition() {
1551
1564
  return this.session?.acquisition ?? EMPTY_ACQUISITION;
1552
1565
  }
1566
+ /**
1567
+ * Event Envelope v1 §3 — return the next seq value for the current session
1568
+ * and advance the counter. Called SYNCHRONOUSLY at track() time, before any
1569
+ * async dispatch, so the seq assignment order is deterministic (call order,
1570
+ * not scheduler luck). The counter is reset to 0 at every session boundary;
1571
+ * it is NOT reset by tab hide/show (spec §3 clause 1: backgrounding does not
1572
+ * reset seq). When no session is active (Node, before init, after uninstall),
1573
+ * returns 0 and leaves the counter unchanged — fallback, not an error.
1574
+ */
1575
+ nextSeq() {
1576
+ const seq = this._sessionSeq;
1577
+ this._sessionSeq += 1;
1578
+ return seq;
1579
+ }
1553
1580
  // ---------- sessions ----------
1554
1581
  installSessionTracking() {
1555
1582
  const now = Date.now();
@@ -1601,6 +1628,7 @@ var AutoTracker = class {
1601
1628
  }
1602
1629
  startNewSession() {
1603
1630
  const now = Date.now();
1631
+ this._sessionSeq = 0;
1604
1632
  return {
1605
1633
  sessionId: mintSessionId(),
1606
1634
  startedAt: now,
@@ -2329,6 +2357,40 @@ function writeJson(storage, key, value) {
2329
2357
  }
2330
2358
  }
2331
2359
 
2360
+ // src/internal-opt-out.ts
2361
+ var INTERNAL_OPT_OUT_PROPERTY = "$crossdeck_internal";
2362
+ var STORAGE_KEY = "crossdeck.internalOptOut";
2363
+ function localStore() {
2364
+ try {
2365
+ return typeof localStorage !== "undefined" ? localStorage : null;
2366
+ } catch {
2367
+ return null;
2368
+ }
2369
+ }
2370
+ function processInternalOptOutUrl() {
2371
+ try {
2372
+ const search = typeof location !== "undefined" ? location.search : "";
2373
+ const params = new URLSearchParams(search || "");
2374
+ if (!params.has("crossdeck_internal")) return;
2375
+ const store = localStore();
2376
+ if (!store) return;
2377
+ const v = params.get("crossdeck_internal");
2378
+ if (v === "1" || v === "true") {
2379
+ store.setItem(STORAGE_KEY, "1");
2380
+ } else if (v === "0" || v === "false") {
2381
+ store.removeItem(STORAGE_KEY);
2382
+ }
2383
+ } catch {
2384
+ }
2385
+ }
2386
+ function isInternalOptOut() {
2387
+ try {
2388
+ return localStore()?.getItem(STORAGE_KEY) === "1";
2389
+ } catch {
2390
+ return false;
2391
+ }
2392
+ }
2393
+
2332
2394
  // src/web-vitals.ts
2333
2395
  var WebVitalsTracker = class {
2334
2396
  constructor(cfg, report) {
@@ -2859,15 +2921,6 @@ var VerifierReporter = class {
2859
2921
  constructor(ctx) {
2860
2922
  this.ctx = ctx;
2861
2923
  this.reentrancyDepth = 0;
2862
- /** The live verifier set — attached by the SDK right after init so the
2863
- * emit path can schema-lock its OWN outgoing telemetry against the real
2864
- * payload. Null until attached (boot-time reports still work without it). */
2865
- this.verifiers = null;
2866
- }
2867
- /** Hand the reporter the live verifier set so `reportFail` can run the
2868
- * `onReportContractFailure` hook on the exact payload it's about to emit. */
2869
- attachVerifiers(verifiers) {
2870
- this.verifiers = verifiers;
2871
2924
  }
2872
2925
  /**
2873
2926
  * Report a verifier result. Pass `operation` for hot-path results
@@ -2896,7 +2949,7 @@ var VerifierReporter = class {
2896
2949
  if (this.reentrancyDepth > 0) return;
2897
2950
  this.reentrancyDepth += 1;
2898
2951
  try {
2899
- const payload = {
2952
+ this.ctx.emitTelemetry({
2900
2953
  contract_id: result.contractId,
2901
2954
  sdk_version: this.ctx.sdkVersion,
2902
2955
  sdk_platform: "web",
@@ -2904,13 +2957,7 @@ var VerifierReporter = class {
2904
2957
  run_context: this.ctx.runContext,
2905
2958
  run_id: this.ctx.runId,
2906
2959
  verification_phase: phase
2907
- };
2908
- if (this.verifiers) {
2909
- runOnReportContractFailure(this.verifiers, this, this.ctx, {
2910
- outgoingPayload: payload
2911
- });
2912
- }
2913
- this.ctx.emitTelemetry(payload);
2960
+ });
2914
2961
  } finally {
2915
2962
  this.reentrancyDepth -= 1;
2916
2963
  }
@@ -3200,31 +3247,9 @@ var VERIFIER_FLUSH_INTERVAL_PARITY = {
3200
3247
  "eventFlushIntervalMs default = 2000ms (Web/Node/RN/Swift/Android parity)",
3201
3248
  nowMs() - t0
3202
3249
  );
3203
- },
3204
- // Hot-path hook — on every real track() we re-affirm the LIVE configured
3205
- // flush interval (carried on the observation), so the parity guarantee is
3206
- // verified against the running SDK in the customer flow, not just a
3207
- // canonical constant at boot. It can't change per-event, but observing it
3208
- // per-event is what makes the contract genuinely runtime-verified.
3209
- hooks: {
3210
- onTrack(obs) {
3211
- const t0 = nowMs();
3212
- const CANONICAL_DEFAULT_MS = 2e3;
3213
- const ms = obs.flushIntervalMs;
3214
- if (typeof ms !== "number" || ms < 100 || ms > 6e4) {
3215
- return fail(
3216
- "flush-interval-parity",
3217
- `live eventFlushIntervalMs=${ms} outside reasonable bounds [100, 60000]`,
3218
- nowMs() - t0
3219
- );
3220
- }
3221
- return pass(
3222
- "flush-interval-parity",
3223
- ms === CANONICAL_DEFAULT_MS ? "live eventFlushIntervalMs = 2000ms (canonical parity value)" : `live eventFlushIntervalMs = ${ms}ms (permitted override; canonical default 2000ms)`,
3224
- nowMs() - t0
3225
- );
3226
- }
3227
3250
  }
3251
+ // No hot-path hook — the flush interval is set once at start() and
3252
+ // never changes per-operation.
3228
3253
  };
3229
3254
  function buildFlushIntervalVerifier(configuredIntervalMs) {
3230
3255
  return {
@@ -3351,26 +3376,6 @@ var CONTRACT_FAILED_FORBIDDEN_FIELDS = [
3351
3376
  "referrer",
3352
3377
  "deviceId"
3353
3378
  ];
3354
- function contractFailedSchemaViolation(keys) {
3355
- const allowed = /* @__PURE__ */ new Set([
3356
- ...CONTRACT_FAILED_REQUIRED_FIELDS,
3357
- ...CONTRACT_FAILED_OPTIONAL_FIELDS
3358
- ]);
3359
- const forbidden = new Set(CONTRACT_FAILED_FORBIDDEN_FIELDS);
3360
- for (const required of CONTRACT_FAILED_REQUIRED_FIELDS) {
3361
- if (!keys.includes(required)) return `missing required field: ${required}`;
3362
- }
3363
- for (const k of keys) {
3364
- if (forbidden.has(k)) return `forbidden field on wire: ${k}`;
3365
- }
3366
- for (const k of keys) {
3367
- if (!allowed.has(k)) {
3368
- return `unrecognised field on wire: ${k} (not in required \u222A optional)`;
3369
- }
3370
- }
3371
- return null;
3372
- }
3373
- var CONTRACT_FAILED_OK_EVIDENCE = `fields \u2286 required(${CONTRACT_FAILED_REQUIRED_FIELDS.length}) \u222A optional(${CONTRACT_FAILED_OPTIONAL_FIELDS.length}); ${CONTRACT_FAILED_FORBIDDEN_FIELDS.length} forbidden absent`;
3374
3379
  var VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK = {
3375
3380
  contractId: "contract-failed-payload-schema-lock",
3376
3381
  bootTest() {
@@ -3385,35 +3390,43 @@ var VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK = {
3385
3390
  verification_phase: "boot"
3386
3391
  };
3387
3392
  const keys = Object.keys(syntheticPayload);
3388
- const violation = contractFailedSchemaViolation(keys);
3389
- if (violation) {
3390
- return fail("contract-failed-payload-schema-lock", violation, nowMs() - t0);
3393
+ const allowed = /* @__PURE__ */ new Set([
3394
+ ...CONTRACT_FAILED_REQUIRED_FIELDS,
3395
+ ...CONTRACT_FAILED_OPTIONAL_FIELDS
3396
+ ]);
3397
+ const forbidden = new Set(CONTRACT_FAILED_FORBIDDEN_FIELDS);
3398
+ for (const required of CONTRACT_FAILED_REQUIRED_FIELDS) {
3399
+ if (!keys.includes(required)) {
3400
+ return fail(
3401
+ "contract-failed-payload-schema-lock",
3402
+ `missing required field: ${required}`,
3403
+ nowMs() - t0
3404
+ );
3405
+ }
3406
+ }
3407
+ for (const k of keys) {
3408
+ if (forbidden.has(k)) {
3409
+ return fail(
3410
+ "contract-failed-payload-schema-lock",
3411
+ `forbidden field on wire: ${k}`,
3412
+ nowMs() - t0
3413
+ );
3414
+ }
3415
+ }
3416
+ for (const k of keys) {
3417
+ if (!allowed.has(k)) {
3418
+ return fail(
3419
+ "contract-failed-payload-schema-lock",
3420
+ `unrecognised field on wire: ${k} (not in required \u222A optional)`,
3421
+ nowMs() - t0
3422
+ );
3423
+ }
3391
3424
  }
3392
3425
  return pass(
3393
3426
  "contract-failed-payload-schema-lock",
3394
- `${keys.length} ${CONTRACT_FAILED_OK_EVIDENCE}`,
3427
+ `${keys.length} fields \u2286 required(${CONTRACT_FAILED_REQUIRED_FIELDS.length}) \u222A optional(${CONTRACT_FAILED_OPTIONAL_FIELDS.length}); ${CONTRACT_FAILED_FORBIDDEN_FIELDS.length} forbidden absent`,
3395
3428
  nowMs() - t0
3396
3429
  );
3397
- },
3398
- // Hot-path hook — fires on the REAL reportFail emit (VerifierReporter,
3399
- // inside the re-entrancy guard) against the exact payload about to go on
3400
- // the wire. This is the assertion the guard at reportFail was built in
3401
- // anticipation of: every reliability-channel write is schema-locked in the
3402
- // field, against real data, not just the synthetic boot mirror.
3403
- hooks: {
3404
- onReportContractFailure(obs) {
3405
- const t0 = nowMs();
3406
- const keys = Object.keys(obs.outgoingPayload);
3407
- const violation = contractFailedSchemaViolation(keys);
3408
- if (violation) {
3409
- return fail("contract-failed-payload-schema-lock", violation, nowMs() - t0);
3410
- }
3411
- return pass(
3412
- "contract-failed-payload-schema-lock",
3413
- `${keys.length} ${CONTRACT_FAILED_OK_EVIDENCE}`,
3414
- nowMs() - t0
3415
- );
3416
- }
3417
3430
  }
3418
3431
  };
3419
3432
  var BACKEND_WIRE_CODES = Object.freeze([
@@ -3464,39 +3477,6 @@ var VERIFIER_SDK_ERROR_CODES_CATALOGUE = {
3464
3477
  nowMs() - t0
3465
3478
  );
3466
3479
  }
3467
- },
3468
- // Hot-path hook — when the SDK parses a real wire error, assert the code
3469
- // the customer actually hit carries usable remediation in the shipped
3470
- // catalogue. Scoped to BACKEND_WIRE_CODES (the contract's set); other
3471
- // codes are out of scope and pass. This turns the catalogue from a
3472
- // boot-only completeness claim into a per-error field assertion: a backend
3473
- // code that ships without remediation is caught the first time a customer
3474
- // hits it, not only if the off-by-default boot self-test ran.
3475
- hooks: {
3476
- onErrorParse(obs) {
3477
- const t0 = nowMs();
3478
- const code = obs.errorCode;
3479
- if (!code || !BACKEND_WIRE_CODES.includes(code)) {
3480
- return pass(
3481
- "sdk-error-codes-catalogue",
3482
- `wire code "${code || "(none)"}" is out of the backend-catalogue scope`,
3483
- nowMs() - t0
3484
- );
3485
- }
3486
- const entry = getErrorCode(code);
3487
- if (!entry || !entry.description || entry.description.trim().length === 0 || !entry.resolution || entry.resolution.trim().length === 0) {
3488
- return fail(
3489
- "sdk-error-codes-catalogue",
3490
- `backend wire code "${code}" hit in the field has no description+resolution in the shipped catalogue`,
3491
- nowMs() - t0
3492
- );
3493
- }
3494
- return pass(
3495
- "sdk-error-codes-catalogue",
3496
- `backend wire code "${code}" carries description + resolution`,
3497
- nowMs() - t0
3498
- );
3499
- }
3500
3480
  }
3501
3481
  };
3502
3482
  var STATIC_VERIFIERS = Object.freeze([
@@ -3620,24 +3600,6 @@ function runOnErrorParse(verifiers, reporter, ctx, obs) {
3620
3600
  reporter.report(result, "hot_path", "errorParse");
3621
3601
  }
3622
3602
  }
3623
- function runOnReportContractFailure(verifiers, reporter, ctx, obs) {
3624
- if (ctx.disableContractAssertions) return;
3625
- for (const verifier of verifiers) {
3626
- const hook = verifier.hooks?.onReportContractFailure;
3627
- if (!hook) continue;
3628
- let result;
3629
- try {
3630
- result = hook(obs);
3631
- } catch (err) {
3632
- result = fail(
3633
- verifier.contractId,
3634
- `hook threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
3635
- 0
3636
- );
3637
- }
3638
- reporter.report(result, "hot_path", "reportContractFailure");
3639
- }
3640
- }
3641
3603
  function defaultDebugModeFlag() {
3642
3604
  try {
3643
3605
  if (typeof process !== "undefined" && process.env) {
@@ -3789,18 +3751,18 @@ function isInAppFrame(filename) {
3789
3751
  if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
3790
3752
  return true;
3791
3753
  }
3792
- function fingerprintError(message, frames, location) {
3754
+ function fingerprintError(message, frames, location2) {
3793
3755
  const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
3794
3756
  const parts = [
3795
3757
  (message || "").slice(0, 200),
3796
3758
  ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
3797
3759
  ];
3798
- if (inAppFrames.length === 0 && location) {
3760
+ if (inAppFrames.length === 0 && location2) {
3799
3761
  const loc = [
3800
- location.errorType ?? "",
3801
- location.filename ?? "",
3802
- location.lineno ?? "",
3803
- location.colno ?? ""
3762
+ location2.errorType ?? "",
3763
+ location2.filename ?? "",
3764
+ location2.lineno ?? "",
3765
+ location2.colno ?? ""
3804
3766
  ].join(":");
3805
3767
  if (loc !== ":::") parts.push(loc);
3806
3768
  }
@@ -4470,9 +4432,6 @@ var CrossdeckClient = class {
4470
4432
  this.verifiers = null;
4471
4433
  this.verifierReporter = null;
4472
4434
  this.verifierCtx = null;
4473
- // The live configured event-flush interval, surfaced to the track-path
4474
- // verifier so flush-interval-parity validates the real cadence per event.
4475
- this.flushIntervalMs = 2e3;
4476
4435
  }
4477
4436
  /**
4478
4437
  * Boot the SDK. Idempotent — calling init twice with the same options
@@ -4649,6 +4608,7 @@ var CrossdeckClient = class {
4649
4608
  persistIdentity ? effectiveStorage : new MemoryStorage(),
4650
4609
  opts.storagePrefix
4651
4610
  );
4611
+ processInternalOptOutUrl();
4652
4612
  const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
4653
4613
  if (consent.isDntDenied) {
4654
4614
  debug.emit(
@@ -4752,8 +4712,6 @@ var CrossdeckClient = class {
4752
4712
  this.verifierReporter = new VerifierReporter(this.verifierCtx);
4753
4713
  const flushVerifier = buildFlushIntervalVerifier(opts.eventFlushIntervalMs);
4754
4714
  this.verifiers = Object.freeze([...STATIC_VERIFIERS, flushVerifier]);
4755
- this.flushIntervalMs = opts.eventFlushIntervalMs;
4756
- this.verifierReporter.attachVerifiers(this.verifiers);
4757
4715
  if (localDevMode) {
4758
4716
  const verifyAtBootLocal = options.verifyContractsAtBoot ?? debugDefault;
4759
4717
  if (verifyAtBootLocal && this.verifierReporter) {
@@ -5304,8 +5262,27 @@ var CrossdeckClient = class {
5304
5262
  );
5305
5263
  }
5306
5264
  }
5265
+ const seq = s.autoTracker?.nextSeq() ?? 0;
5266
+ const occurrenceTimestamp = Date.now();
5307
5267
  s.autoTracker?.markActivity();
5308
- const enriched = { ...s.deviceInfo };
5268
+ const wireContext = {};
5269
+ const di = s.deviceInfo;
5270
+ if (di.os) wireContext.os = di.os;
5271
+ if (di.osVersion) wireContext.osVersion = di.osVersion;
5272
+ const appVer = s.options.appVersion;
5273
+ if (appVer) wireContext.appVersion = appVer;
5274
+ wireContext.sdkName = SDK_NAME;
5275
+ wireContext.sdkVersion = s.options.sdkVersion;
5276
+ if (di.locale) wireContext.locale = di.locale;
5277
+ if (di.timezone) wireContext.timezone = di.timezone;
5278
+ if (di.browser) wireContext.browser = di.browser;
5279
+ if (di.browserVersion) wireContext.browserVersion = di.browserVersion;
5280
+ const enriched = {};
5281
+ if (di.screenWidth !== void 0) enriched.screenWidth = di.screenWidth;
5282
+ if (di.screenHeight !== void 0) enriched.screenHeight = di.screenHeight;
5283
+ if (di.viewportWidth !== void 0) enriched.viewportWidth = di.viewportWidth;
5284
+ if (di.viewportHeight !== void 0) enriched.viewportHeight = di.viewportHeight;
5285
+ if (di.devicePixelRatio !== void 0) enriched.devicePixelRatio = di.devicePixelRatio;
5309
5286
  const sessionId = s.autoTracker?.currentSessionId;
5310
5287
  if (sessionId) enriched.sessionId = sessionId;
5311
5288
  const pageviewId = s.autoTracker?.currentPageviewId;
@@ -5328,17 +5305,24 @@ var CrossdeckClient = class {
5328
5305
  }
5329
5306
  }
5330
5307
  const supers = s.superProps.getSuperProperties();
5331
- Object.assign(enriched, supers);
5308
+ for (const k of Object.keys(supers)) {
5309
+ if (!(k in enriched)) enriched[k] = supers[k];
5310
+ }
5332
5311
  const groupIds = s.superProps.getGroupIds();
5333
5312
  if (Object.keys(groupIds).length > 0) {
5334
5313
  enriched.$groups = groupIds;
5335
5314
  }
5336
5315
  Object.assign(enriched, validation.properties);
5316
+ if (isInternalOptOut()) {
5317
+ enriched[INTERNAL_OPT_OUT_PROPERTY] = true;
5318
+ }
5337
5319
  const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
5338
5320
  const event = {
5339
5321
  eventId: this.mintEventId(),
5340
5322
  name,
5341
- timestamp: Date.now(),
5323
+ timestamp: occurrenceTimestamp,
5324
+ seq,
5325
+ context: wireContext,
5342
5326
  properties: finalProperties
5343
5327
  };
5344
5328
  Object.assign(event, this.identityHintForEvent());
@@ -5348,8 +5332,7 @@ var CrossdeckClient = class {
5348
5332
  callerProperties: validation.properties,
5349
5333
  superProperties: supers,
5350
5334
  deviceProperties: s.deviceInfo,
5351
- mergedProperties: enriched,
5352
- flushIntervalMs: this.flushIntervalMs
5335
+ mergedProperties: enriched
5353
5336
  });
5354
5337
  }
5355
5338
  if (!isError && !isWebVital) {