@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.mjs CHANGED
@@ -67,7 +67,7 @@ function typeMapForStatus(status) {
67
67
  }
68
68
 
69
69
  // src/_version.ts
70
- var SDK_VERSION = "1.6.4";
70
+ var SDK_VERSION = "1.7.0";
71
71
  var SDK_NAME = "@cross-deck/web";
72
72
 
73
73
  // src/http.ts
@@ -983,9 +983,13 @@ var EventQueue = class {
983
983
  const env = this.cfg.envelope();
984
984
  const result = await this.cfg.http.request("POST", "/events", {
985
985
  body: {
986
- // NorthStar §13.1 batch envelope. The backend validates these
987
- // against the API-key-resolved app and rejects mismatches
988
- // loudly (env_mismatch).
986
+ // Event Envelope v1 batch envelope (backend/docs/
987
+ // event-envelope-spec-v1.md §1). `envelopeVersion` is the
988
+ // schema/wire version the server parses against ("can I parse
989
+ // this?") and is DISTINCT from `sdk.version` ("which build is in
990
+ // the wild?") — two questions, two fields, never conflated. The
991
+ // backend refuses payloads with a missing/unknown envelopeVersion.
992
+ envelopeVersion: 1,
989
993
  appId: env.appId,
990
994
  environment: env.environment,
991
995
  sdk: env.sdk,
@@ -1440,6 +1444,15 @@ var AutoTracker = class {
1440
1444
  this.cleanups = [];
1441
1445
  /** Last time we flushed lastActivityAt to storage (throttle gate). */
1442
1446
  this.lastPersistAt = 0;
1447
+ /**
1448
+ * Event Envelope v1 §3 — per-session monotonic sequence counter.
1449
+ * The single owner of the seq counter lives here, alongside all other
1450
+ * session state. Incremented atomically at track() time (via nextSeq()),
1451
+ * reset to 0 whenever a new session boundary is crossed (startNewSession
1452
+ * path + resetSession). Persists across background/foreground within a
1453
+ * session — only a real session boundary (not a tab hide/show) resets it.
1454
+ */
1455
+ this._sessionSeq = 0;
1443
1456
  /**
1444
1457
  * Stable per-page-view identifier. Minted at every `page.viewed`
1445
1458
  * emission and attached to every subsequent event until the next
@@ -1524,6 +1537,20 @@ var AutoTracker = class {
1524
1537
  get currentAcquisition() {
1525
1538
  return this.session?.acquisition ?? EMPTY_ACQUISITION;
1526
1539
  }
1540
+ /**
1541
+ * Event Envelope v1 §3 — return the next seq value for the current session
1542
+ * and advance the counter. Called SYNCHRONOUSLY at track() time, before any
1543
+ * async dispatch, so the seq assignment order is deterministic (call order,
1544
+ * not scheduler luck). The counter is reset to 0 at every session boundary;
1545
+ * it is NOT reset by tab hide/show (spec §3 clause 1: backgrounding does not
1546
+ * reset seq). When no session is active (Node, before init, after uninstall),
1547
+ * returns 0 and leaves the counter unchanged — fallback, not an error.
1548
+ */
1549
+ nextSeq() {
1550
+ const seq = this._sessionSeq;
1551
+ this._sessionSeq += 1;
1552
+ return seq;
1553
+ }
1527
1554
  // ---------- sessions ----------
1528
1555
  installSessionTracking() {
1529
1556
  const now = Date.now();
@@ -1575,6 +1602,7 @@ var AutoTracker = class {
1575
1602
  }
1576
1603
  startNewSession() {
1577
1604
  const now = Date.now();
1605
+ this._sessionSeq = 0;
1578
1606
  return {
1579
1607
  sessionId: mintSessionId(),
1580
1608
  startedAt: now,
@@ -2303,6 +2331,40 @@ function writeJson(storage, key, value) {
2303
2331
  }
2304
2332
  }
2305
2333
 
2334
+ // src/internal-opt-out.ts
2335
+ var INTERNAL_OPT_OUT_PROPERTY = "$crossdeck_internal";
2336
+ var STORAGE_KEY = "crossdeck.internalOptOut";
2337
+ function localStore() {
2338
+ try {
2339
+ return typeof localStorage !== "undefined" ? localStorage : null;
2340
+ } catch {
2341
+ return null;
2342
+ }
2343
+ }
2344
+ function processInternalOptOutUrl() {
2345
+ try {
2346
+ const search = typeof location !== "undefined" ? location.search : "";
2347
+ const params = new URLSearchParams(search || "");
2348
+ if (!params.has("crossdeck_internal")) return;
2349
+ const store = localStore();
2350
+ if (!store) return;
2351
+ const v = params.get("crossdeck_internal");
2352
+ if (v === "1" || v === "true") {
2353
+ store.setItem(STORAGE_KEY, "1");
2354
+ } else if (v === "0" || v === "false") {
2355
+ store.removeItem(STORAGE_KEY);
2356
+ }
2357
+ } catch {
2358
+ }
2359
+ }
2360
+ function isInternalOptOut() {
2361
+ try {
2362
+ return localStore()?.getItem(STORAGE_KEY) === "1";
2363
+ } catch {
2364
+ return false;
2365
+ }
2366
+ }
2367
+
2306
2368
  // src/web-vitals.ts
2307
2369
  var WebVitalsTracker = class {
2308
2370
  constructor(cfg, report) {
@@ -2833,15 +2895,6 @@ var VerifierReporter = class {
2833
2895
  constructor(ctx) {
2834
2896
  this.ctx = ctx;
2835
2897
  this.reentrancyDepth = 0;
2836
- /** The live verifier set — attached by the SDK right after init so the
2837
- * emit path can schema-lock its OWN outgoing telemetry against the real
2838
- * payload. Null until attached (boot-time reports still work without it). */
2839
- this.verifiers = null;
2840
- }
2841
- /** Hand the reporter the live verifier set so `reportFail` can run the
2842
- * `onReportContractFailure` hook on the exact payload it's about to emit. */
2843
- attachVerifiers(verifiers) {
2844
- this.verifiers = verifiers;
2845
2898
  }
2846
2899
  /**
2847
2900
  * Report a verifier result. Pass `operation` for hot-path results
@@ -2870,7 +2923,7 @@ var VerifierReporter = class {
2870
2923
  if (this.reentrancyDepth > 0) return;
2871
2924
  this.reentrancyDepth += 1;
2872
2925
  try {
2873
- const payload = {
2926
+ this.ctx.emitTelemetry({
2874
2927
  contract_id: result.contractId,
2875
2928
  sdk_version: this.ctx.sdkVersion,
2876
2929
  sdk_platform: "web",
@@ -2878,13 +2931,7 @@ var VerifierReporter = class {
2878
2931
  run_context: this.ctx.runContext,
2879
2932
  run_id: this.ctx.runId,
2880
2933
  verification_phase: phase
2881
- };
2882
- if (this.verifiers) {
2883
- runOnReportContractFailure(this.verifiers, this, this.ctx, {
2884
- outgoingPayload: payload
2885
- });
2886
- }
2887
- this.ctx.emitTelemetry(payload);
2934
+ });
2888
2935
  } finally {
2889
2936
  this.reentrancyDepth -= 1;
2890
2937
  }
@@ -3174,31 +3221,9 @@ var VERIFIER_FLUSH_INTERVAL_PARITY = {
3174
3221
  "eventFlushIntervalMs default = 2000ms (Web/Node/RN/Swift/Android parity)",
3175
3222
  nowMs() - t0
3176
3223
  );
3177
- },
3178
- // Hot-path hook — on every real track() we re-affirm the LIVE configured
3179
- // flush interval (carried on the observation), so the parity guarantee is
3180
- // verified against the running SDK in the customer flow, not just a
3181
- // canonical constant at boot. It can't change per-event, but observing it
3182
- // per-event is what makes the contract genuinely runtime-verified.
3183
- hooks: {
3184
- onTrack(obs) {
3185
- const t0 = nowMs();
3186
- const CANONICAL_DEFAULT_MS = 2e3;
3187
- const ms = obs.flushIntervalMs;
3188
- if (typeof ms !== "number" || ms < 100 || ms > 6e4) {
3189
- return fail(
3190
- "flush-interval-parity",
3191
- `live eventFlushIntervalMs=${ms} outside reasonable bounds [100, 60000]`,
3192
- nowMs() - t0
3193
- );
3194
- }
3195
- return pass(
3196
- "flush-interval-parity",
3197
- ms === CANONICAL_DEFAULT_MS ? "live eventFlushIntervalMs = 2000ms (canonical parity value)" : `live eventFlushIntervalMs = ${ms}ms (permitted override; canonical default 2000ms)`,
3198
- nowMs() - t0
3199
- );
3200
- }
3201
3224
  }
3225
+ // No hot-path hook — the flush interval is set once at start() and
3226
+ // never changes per-operation.
3202
3227
  };
3203
3228
  function buildFlushIntervalVerifier(configuredIntervalMs) {
3204
3229
  return {
@@ -3325,26 +3350,6 @@ var CONTRACT_FAILED_FORBIDDEN_FIELDS = [
3325
3350
  "referrer",
3326
3351
  "deviceId"
3327
3352
  ];
3328
- function contractFailedSchemaViolation(keys) {
3329
- const allowed = /* @__PURE__ */ new Set([
3330
- ...CONTRACT_FAILED_REQUIRED_FIELDS,
3331
- ...CONTRACT_FAILED_OPTIONAL_FIELDS
3332
- ]);
3333
- const forbidden = new Set(CONTRACT_FAILED_FORBIDDEN_FIELDS);
3334
- for (const required of CONTRACT_FAILED_REQUIRED_FIELDS) {
3335
- if (!keys.includes(required)) return `missing required field: ${required}`;
3336
- }
3337
- for (const k of keys) {
3338
- if (forbidden.has(k)) return `forbidden field on wire: ${k}`;
3339
- }
3340
- for (const k of keys) {
3341
- if (!allowed.has(k)) {
3342
- return `unrecognised field on wire: ${k} (not in required \u222A optional)`;
3343
- }
3344
- }
3345
- return null;
3346
- }
3347
- 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`;
3348
3353
  var VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK = {
3349
3354
  contractId: "contract-failed-payload-schema-lock",
3350
3355
  bootTest() {
@@ -3359,35 +3364,43 @@ var VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK = {
3359
3364
  verification_phase: "boot"
3360
3365
  };
3361
3366
  const keys = Object.keys(syntheticPayload);
3362
- const violation = contractFailedSchemaViolation(keys);
3363
- if (violation) {
3364
- return fail("contract-failed-payload-schema-lock", violation, nowMs() - t0);
3367
+ const allowed = /* @__PURE__ */ new Set([
3368
+ ...CONTRACT_FAILED_REQUIRED_FIELDS,
3369
+ ...CONTRACT_FAILED_OPTIONAL_FIELDS
3370
+ ]);
3371
+ const forbidden = new Set(CONTRACT_FAILED_FORBIDDEN_FIELDS);
3372
+ for (const required of CONTRACT_FAILED_REQUIRED_FIELDS) {
3373
+ if (!keys.includes(required)) {
3374
+ return fail(
3375
+ "contract-failed-payload-schema-lock",
3376
+ `missing required field: ${required}`,
3377
+ nowMs() - t0
3378
+ );
3379
+ }
3380
+ }
3381
+ for (const k of keys) {
3382
+ if (forbidden.has(k)) {
3383
+ return fail(
3384
+ "contract-failed-payload-schema-lock",
3385
+ `forbidden field on wire: ${k}`,
3386
+ nowMs() - t0
3387
+ );
3388
+ }
3389
+ }
3390
+ for (const k of keys) {
3391
+ if (!allowed.has(k)) {
3392
+ return fail(
3393
+ "contract-failed-payload-schema-lock",
3394
+ `unrecognised field on wire: ${k} (not in required \u222A optional)`,
3395
+ nowMs() - t0
3396
+ );
3397
+ }
3365
3398
  }
3366
3399
  return pass(
3367
3400
  "contract-failed-payload-schema-lock",
3368
- `${keys.length} ${CONTRACT_FAILED_OK_EVIDENCE}`,
3401
+ `${keys.length} fields \u2286 required(${CONTRACT_FAILED_REQUIRED_FIELDS.length}) \u222A optional(${CONTRACT_FAILED_OPTIONAL_FIELDS.length}); ${CONTRACT_FAILED_FORBIDDEN_FIELDS.length} forbidden absent`,
3369
3402
  nowMs() - t0
3370
3403
  );
3371
- },
3372
- // Hot-path hook — fires on the REAL reportFail emit (VerifierReporter,
3373
- // inside the re-entrancy guard) against the exact payload about to go on
3374
- // the wire. This is the assertion the guard at reportFail was built in
3375
- // anticipation of: every reliability-channel write is schema-locked in the
3376
- // field, against real data, not just the synthetic boot mirror.
3377
- hooks: {
3378
- onReportContractFailure(obs) {
3379
- const t0 = nowMs();
3380
- const keys = Object.keys(obs.outgoingPayload);
3381
- const violation = contractFailedSchemaViolation(keys);
3382
- if (violation) {
3383
- return fail("contract-failed-payload-schema-lock", violation, nowMs() - t0);
3384
- }
3385
- return pass(
3386
- "contract-failed-payload-schema-lock",
3387
- `${keys.length} ${CONTRACT_FAILED_OK_EVIDENCE}`,
3388
- nowMs() - t0
3389
- );
3390
- }
3391
3404
  }
3392
3405
  };
3393
3406
  var BACKEND_WIRE_CODES = Object.freeze([
@@ -3438,39 +3451,6 @@ var VERIFIER_SDK_ERROR_CODES_CATALOGUE = {
3438
3451
  nowMs() - t0
3439
3452
  );
3440
3453
  }
3441
- },
3442
- // Hot-path hook — when the SDK parses a real wire error, assert the code
3443
- // the customer actually hit carries usable remediation in the shipped
3444
- // catalogue. Scoped to BACKEND_WIRE_CODES (the contract's set); other
3445
- // codes are out of scope and pass. This turns the catalogue from a
3446
- // boot-only completeness claim into a per-error field assertion: a backend
3447
- // code that ships without remediation is caught the first time a customer
3448
- // hits it, not only if the off-by-default boot self-test ran.
3449
- hooks: {
3450
- onErrorParse(obs) {
3451
- const t0 = nowMs();
3452
- const code = obs.errorCode;
3453
- if (!code || !BACKEND_WIRE_CODES.includes(code)) {
3454
- return pass(
3455
- "sdk-error-codes-catalogue",
3456
- `wire code "${code || "(none)"}" is out of the backend-catalogue scope`,
3457
- nowMs() - t0
3458
- );
3459
- }
3460
- const entry = getErrorCode(code);
3461
- if (!entry || !entry.description || entry.description.trim().length === 0 || !entry.resolution || entry.resolution.trim().length === 0) {
3462
- return fail(
3463
- "sdk-error-codes-catalogue",
3464
- `backend wire code "${code}" hit in the field has no description+resolution in the shipped catalogue`,
3465
- nowMs() - t0
3466
- );
3467
- }
3468
- return pass(
3469
- "sdk-error-codes-catalogue",
3470
- `backend wire code "${code}" carries description + resolution`,
3471
- nowMs() - t0
3472
- );
3473
- }
3474
3454
  }
3475
3455
  };
3476
3456
  var STATIC_VERIFIERS = Object.freeze([
@@ -3594,24 +3574,6 @@ function runOnErrorParse(verifiers, reporter, ctx, obs) {
3594
3574
  reporter.report(result, "hot_path", "errorParse");
3595
3575
  }
3596
3576
  }
3597
- function runOnReportContractFailure(verifiers, reporter, ctx, obs) {
3598
- if (ctx.disableContractAssertions) return;
3599
- for (const verifier of verifiers) {
3600
- const hook = verifier.hooks?.onReportContractFailure;
3601
- if (!hook) continue;
3602
- let result;
3603
- try {
3604
- result = hook(obs);
3605
- } catch (err) {
3606
- result = fail(
3607
- verifier.contractId,
3608
- `hook threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
3609
- 0
3610
- );
3611
- }
3612
- reporter.report(result, "hot_path", "reportContractFailure");
3613
- }
3614
- }
3615
3577
  function defaultDebugModeFlag() {
3616
3578
  try {
3617
3579
  if (typeof process !== "undefined" && process.env) {
@@ -3763,18 +3725,18 @@ function isInAppFrame(filename) {
3763
3725
  if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
3764
3726
  return true;
3765
3727
  }
3766
- function fingerprintError(message, frames, location) {
3728
+ function fingerprintError(message, frames, location2) {
3767
3729
  const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
3768
3730
  const parts = [
3769
3731
  (message || "").slice(0, 200),
3770
3732
  ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
3771
3733
  ];
3772
- if (inAppFrames.length === 0 && location) {
3734
+ if (inAppFrames.length === 0 && location2) {
3773
3735
  const loc = [
3774
- location.errorType ?? "",
3775
- location.filename ?? "",
3776
- location.lineno ?? "",
3777
- location.colno ?? ""
3736
+ location2.errorType ?? "",
3737
+ location2.filename ?? "",
3738
+ location2.lineno ?? "",
3739
+ location2.colno ?? ""
3778
3740
  ].join(":");
3779
3741
  if (loc !== ":::") parts.push(loc);
3780
3742
  }
@@ -4444,9 +4406,6 @@ var CrossdeckClient = class {
4444
4406
  this.verifiers = null;
4445
4407
  this.verifierReporter = null;
4446
4408
  this.verifierCtx = null;
4447
- // The live configured event-flush interval, surfaced to the track-path
4448
- // verifier so flush-interval-parity validates the real cadence per event.
4449
- this.flushIntervalMs = 2e3;
4450
4409
  }
4451
4410
  /**
4452
4411
  * Boot the SDK. Idempotent — calling init twice with the same options
@@ -4623,6 +4582,7 @@ var CrossdeckClient = class {
4623
4582
  persistIdentity ? effectiveStorage : new MemoryStorage(),
4624
4583
  opts.storagePrefix
4625
4584
  );
4585
+ processInternalOptOutUrl();
4626
4586
  const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
4627
4587
  if (consent.isDntDenied) {
4628
4588
  debug.emit(
@@ -4726,8 +4686,6 @@ var CrossdeckClient = class {
4726
4686
  this.verifierReporter = new VerifierReporter(this.verifierCtx);
4727
4687
  const flushVerifier = buildFlushIntervalVerifier(opts.eventFlushIntervalMs);
4728
4688
  this.verifiers = Object.freeze([...STATIC_VERIFIERS, flushVerifier]);
4729
- this.flushIntervalMs = opts.eventFlushIntervalMs;
4730
- this.verifierReporter.attachVerifiers(this.verifiers);
4731
4689
  if (localDevMode) {
4732
4690
  const verifyAtBootLocal = options.verifyContractsAtBoot ?? debugDefault;
4733
4691
  if (verifyAtBootLocal && this.verifierReporter) {
@@ -5278,8 +5236,27 @@ var CrossdeckClient = class {
5278
5236
  );
5279
5237
  }
5280
5238
  }
5239
+ const seq = s.autoTracker?.nextSeq() ?? 0;
5240
+ const occurrenceTimestamp = Date.now();
5281
5241
  s.autoTracker?.markActivity();
5282
- const enriched = { ...s.deviceInfo };
5242
+ const wireContext = {};
5243
+ const di = s.deviceInfo;
5244
+ if (di.os) wireContext.os = di.os;
5245
+ if (di.osVersion) wireContext.osVersion = di.osVersion;
5246
+ const appVer = s.options.appVersion;
5247
+ if (appVer) wireContext.appVersion = appVer;
5248
+ wireContext.sdkName = SDK_NAME;
5249
+ wireContext.sdkVersion = s.options.sdkVersion;
5250
+ if (di.locale) wireContext.locale = di.locale;
5251
+ if (di.timezone) wireContext.timezone = di.timezone;
5252
+ if (di.browser) wireContext.browser = di.browser;
5253
+ if (di.browserVersion) wireContext.browserVersion = di.browserVersion;
5254
+ const enriched = {};
5255
+ if (di.screenWidth !== void 0) enriched.screenWidth = di.screenWidth;
5256
+ if (di.screenHeight !== void 0) enriched.screenHeight = di.screenHeight;
5257
+ if (di.viewportWidth !== void 0) enriched.viewportWidth = di.viewportWidth;
5258
+ if (di.viewportHeight !== void 0) enriched.viewportHeight = di.viewportHeight;
5259
+ if (di.devicePixelRatio !== void 0) enriched.devicePixelRatio = di.devicePixelRatio;
5283
5260
  const sessionId = s.autoTracker?.currentSessionId;
5284
5261
  if (sessionId) enriched.sessionId = sessionId;
5285
5262
  const pageviewId = s.autoTracker?.currentPageviewId;
@@ -5302,17 +5279,24 @@ var CrossdeckClient = class {
5302
5279
  }
5303
5280
  }
5304
5281
  const supers = s.superProps.getSuperProperties();
5305
- Object.assign(enriched, supers);
5282
+ for (const k of Object.keys(supers)) {
5283
+ if (!(k in enriched)) enriched[k] = supers[k];
5284
+ }
5306
5285
  const groupIds = s.superProps.getGroupIds();
5307
5286
  if (Object.keys(groupIds).length > 0) {
5308
5287
  enriched.$groups = groupIds;
5309
5288
  }
5310
5289
  Object.assign(enriched, validation.properties);
5290
+ if (isInternalOptOut()) {
5291
+ enriched[INTERNAL_OPT_OUT_PROPERTY] = true;
5292
+ }
5311
5293
  const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
5312
5294
  const event = {
5313
5295
  eventId: this.mintEventId(),
5314
5296
  name,
5315
- timestamp: Date.now(),
5297
+ timestamp: occurrenceTimestamp,
5298
+ seq,
5299
+ context: wireContext,
5316
5300
  properties: finalProperties
5317
5301
  };
5318
5302
  Object.assign(event, this.identityHintForEvent());
@@ -5322,8 +5306,7 @@ var CrossdeckClient = class {
5322
5306
  callerProperties: validation.properties,
5323
5307
  superProperties: supers,
5324
5308
  deviceProperties: s.deviceInfo,
5325
- mergedProperties: enriched,
5326
- flushIntervalMs: this.flushIntervalMs
5309
+ mergedProperties: enriched
5327
5310
  });
5328
5311
  }
5329
5312
  if (!isError && !isWebVital) {