@svta/cml-cmcd 2.3.2 → 2.4.1

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/index.js CHANGED
@@ -74,6 +74,12 @@ const CMCD_EVENT_BITRATE_CHANGE = "bc";
74
74
  */
75
75
  const CMCD_EVENT_PLAY_STATE = "ps";
76
76
  /**
77
+ * CMCD event type for the 'pr' key (playback rate change).
78
+ *
79
+ * @public
80
+ */
81
+ const CMCD_EVENT_PLAYBACK_RATE = "pr";
82
+ /**
77
83
  * CMCD event type for the 'e' key (error).
78
84
  *
79
85
  * @public
@@ -175,6 +181,7 @@ const CMCD_EVENT_CUSTOM_EVENT = "ce";
175
181
  const CmcdEventType = {
176
182
  BITRATE_CHANGE: CMCD_EVENT_BITRATE_CHANGE,
177
183
  PLAY_STATE: CMCD_EVENT_PLAY_STATE,
184
+ PLAYBACK_RATE: CMCD_EVENT_PLAYBACK_RATE,
178
185
  ERROR: CMCD_EVENT_ERROR,
179
186
  TIME_INTERVAL: CMCD_EVENT_TIME_INTERVAL,
180
187
  CONTENT_ID: CMCD_EVENT_CONTENT_ID,
@@ -192,6 +199,32 @@ const CmcdEventType = {
192
199
  CUSTOM_EVENT: CMCD_EVENT_CUSTOM_EVENT
193
200
  };
194
201
 
202
+ //#endregion
203
+ //#region src/CMCD_STATE_EVENT_FIELDS.ts
204
+ /**
205
+ * Maps each state-change event type to the persistent field whose value
206
+ * the event signals.
207
+ *
208
+ * Per CTA-5004-B, the state-change events `ps`, `pr`, `c`, `b`, `bc` are
209
+ * state-transition markers and must carry the field whose value they signal.
210
+ * Consumers force-include the field post-filter (`prepareCmcdData`), dedup
211
+ * against its value (`CmcdReporter`), and check its presence in payloads
212
+ * (`validateCmcdStructure`).
213
+ *
214
+ * Iteration order is load-bearing: `CmcdReporter.update()` fires state-change
215
+ * events in map order when multiple tracked fields change in a single call.
216
+ * Do not reorder entries without auditing reporter behavior.
217
+ *
218
+ * @internal
219
+ */
220
+ const CMCD_STATE_EVENT_FIELDS = /* @__PURE__ */ new Map([
221
+ [CMCD_EVENT_PLAY_STATE, "sta"],
222
+ [CMCD_EVENT_PLAYBACK_RATE, "pr"],
223
+ [CMCD_EVENT_CONTENT_ID, "cid"],
224
+ [CMCD_EVENT_BACKGROUNDED_MODE, "bg"],
225
+ [CMCD_EVENT_BITRATE_CHANGE, "br"]
226
+ ]);
227
+
195
228
  //#endregion
196
229
  //#region src/CmcdReportingMode.ts
197
230
  /**
@@ -282,7 +315,7 @@ const CMCD_REQUEST_KEYS = [
282
315
 
283
316
  //#endregion
284
317
  //#region src/isCmcdCustomKey.ts
285
- const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9-.]+-[a-zA-Z0-9-.]+$/;
318
+ const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9.-]+$/;
286
319
  /**
287
320
  * Check if a key is a custom key.
288
321
  *
@@ -291,14 +324,18 @@ const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9-.]+-[a-zA-Z0-9-.]+$/;
291
324
  * @returns `true` if the key is a custom key, `false` otherwise.
292
325
  *
293
326
  * @public
327
+ *
328
+ * @example
329
+ * {@includeCode ../test/isCmcdCustomKey.test.ts#example}
294
330
  */
295
331
  function isCmcdCustomKey(key) {
296
- return CUSTOM_KEY_REGEX.test(key);
332
+ const separator = key.indexOf("-", 1);
333
+ return separator > 0 && separator < key.length - 1 && CUSTOM_KEY_REGEX.test(key);
297
334
  }
298
335
 
299
336
  //#endregion
300
337
  //#region src/isCmcdRequestKey.ts
301
- const CMCD_REQUEST_KEY_SET = new Set(CMCD_REQUEST_KEYS);
338
+ const CMCD_REQUEST_KEY_SET = /* @__PURE__ */ new Set(CMCD_REQUEST_KEYS);
302
339
  /**
303
340
  * Check if a key is a valid CMCD request key.
304
341
  *
@@ -335,7 +372,7 @@ const CMCD_RESPONSE_KEYS = [
335
372
 
336
373
  //#endregion
337
374
  //#region src/isCmcdResponseReceivedKey.ts
338
- const CMCD_RESPONSE_KEY_SET = new Set(CMCD_RESPONSE_KEYS);
375
+ const CMCD_RESPONSE_KEY_SET = /* @__PURE__ */ new Set(CMCD_RESPONSE_KEYS);
339
376
  /**
340
377
  * Check if a key is a valid CMCD response key.
341
378
  *
@@ -354,7 +391,7 @@ function isCmcdResponseReceivedKey(key) {
354
391
 
355
392
  //#endregion
356
393
  //#region src/isCmcdEventKey.ts
357
- const CMCD_EVENT_KEY_SET = new Set(CMCD_EVENT_KEYS);
394
+ const CMCD_EVENT_KEY_SET = /* @__PURE__ */ new Set(CMCD_EVENT_KEYS);
358
395
  /**
359
396
  * Check if a key is a valid CMCD event key.
360
397
  *
@@ -427,7 +464,7 @@ const CMCD_V1_KEYS = [
427
464
 
428
465
  //#endregion
429
466
  //#region src/isCmcdV1Key.ts
430
- const CMCD_V1_KEY_SET$1 = new Set(CMCD_V1_KEYS);
467
+ const CMCD_V1_KEY_SET$1 = /* @__PURE__ */ new Set(CMCD_V1_KEYS);
431
468
  /**
432
469
  * Filter function for CMCD v1 keys.
433
470
  *
@@ -446,7 +483,7 @@ function isCmcdV1Key(key) {
446
483
 
447
484
  //#endregion
448
485
  //#region src/isTokenField.ts
449
- const TOKEN_FIELDS = new Set([
486
+ const TOKEN_FIELDS = /* @__PURE__ */ new Set([
450
487
  "ot",
451
488
  "sf",
452
489
  "st",
@@ -550,6 +587,8 @@ function prepareCmcdData(obj, options = {}) {
550
587
  if (!keys.includes("e") && eventType != null) keys.push("e");
551
588
  if (!keys.includes("ts")) keys.push("ts");
552
589
  if (!keys.includes("cen") && data["cen"] != null && eventType === CMCD_EVENT_CUSTOM_EVENT) keys.push("cen");
590
+ const requiredField = eventType ? CMCD_STATE_EVENT_FIELDS.get(eventType) : void 0;
591
+ if (requiredField && data[requiredField] != null && !keys.includes(requiredField)) keys.push(requiredField);
553
592
  }
554
593
  if (keys.length === 0) return results;
555
594
  if (version > 1 && !keys.includes("v")) keys.push("v");
@@ -565,9 +604,10 @@ function prepareCmcdData(obj, options = {}) {
565
604
  if (typeof formatter === "function") value = formatter(value, formatterOptions);
566
605
  if (key === "v") if (version === 1) continue;
567
606
  else value = version;
568
- if (key === "pr" && value === 1) continue;
607
+ if (key === "pr" && value === 1 && !(isEventMode && data["e"] === CMCD_EVENT_PLAYBACK_RATE)) continue;
569
608
  if (isEventMode && key === "ts" && !Number.isFinite(value)) value = Date.now();
570
- if (!isValid(value)) continue;
609
+ const isBgFalseTransition = isEventMode && value === false && key === "bg" && data["e"] === CMCD_EVENT_BACKGROUNDED_MODE;
610
+ if (!isValid(value) && !isBgFalseTransition) continue;
571
611
  if (isTokenField(key) && typeof value === "string") value = new SfToken(value);
572
612
  results[key] = value;
573
613
  }
@@ -906,7 +946,7 @@ const CMCD_DEFAULT_TIME_INTERVAL = 30;
906
946
  *
907
947
  * @public
908
948
  */
909
- const CMCD_KEYS = [
949
+ const CMCD_KEYS = /* @__PURE__ */ [
910
950
  ...CMCD_V1_KEYS,
911
951
  ...CMCD_REQUEST_KEYS,
912
952
  ...CMCD_RESPONSE_KEYS,
@@ -979,6 +1019,417 @@ const CmcdPlayerState = {
979
1019
  PRELOADING: "d"
980
1020
  };
981
1021
 
1022
+ //#endregion
1023
+ //#region src/CmcdRecordedReportMode.ts
1024
+ /**
1025
+ * CMCD query reporting mode — CMCD data carried as a `CMCD=` URL
1026
+ * query parameter.
1027
+ *
1028
+ * @public
1029
+ */
1030
+ const CMCD_RECORDED_REPORT_MODE_QUERY = "query";
1031
+ /**
1032
+ * CMCD header reporting mode — CMCD data carried as `Cmcd-*` HTTP
1033
+ * headers.
1034
+ *
1035
+ * @public
1036
+ */
1037
+ const CMCD_RECORDED_REPORT_MODE_HEADER = "header";
1038
+ /**
1039
+ * CMCD event reporting mode — CMCD data carried as a POST body to an
1040
+ * event-target URL.
1041
+ *
1042
+ * @public
1043
+ */
1044
+ const CMCD_RECORDED_REPORT_MODE_EVENT = "event";
1045
+ /**
1046
+ * Reporting mode under which a captured report was observed by
1047
+ * `CmcdReportRecorder`.
1048
+ *
1049
+ * @enum
1050
+ *
1051
+ * @public
1052
+ */
1053
+ const CmcdRecordedReportMode = {
1054
+ QUERY: CMCD_RECORDED_REPORT_MODE_QUERY,
1055
+ HEADER: CMCD_RECORDED_REPORT_MODE_HEADER,
1056
+ EVENT: CMCD_RECORDED_REPORT_MODE_EVENT
1057
+ };
1058
+
1059
+ //#endregion
1060
+ //#region src/CmcdRecordedRequestType.ts
1061
+ /**
1062
+ * Manifest or playlist request classification.
1063
+ *
1064
+ * @public
1065
+ */
1066
+ const CMCD_RECORDED_REQUEST_TYPE_MANIFEST = "manifest";
1067
+ /**
1068
+ * Media segment request classification.
1069
+ *
1070
+ * @public
1071
+ */
1072
+ const CMCD_RECORDED_REQUEST_TYPE_SEGMENT = "segment";
1073
+ /**
1074
+ * CMCD event-mode report request classification (POST to a URL in
1075
+ * `eventTargetUrls`).
1076
+ *
1077
+ * @public
1078
+ */
1079
+ const CMCD_RECORDED_REQUEST_TYPE_EVENT = "event";
1080
+ /**
1081
+ * Fallback classification for requests the recorder did not recognize
1082
+ * as manifest, segment, or event.
1083
+ *
1084
+ * @public
1085
+ */
1086
+ const CMCD_RECORDED_REQUEST_TYPE_UNKNOWN = "unknown";
1087
+ /**
1088
+ * Classification of a captured request, used by `CmcdReportRecorder`
1089
+ * for filtering.
1090
+ *
1091
+ * @enum
1092
+ *
1093
+ * @public
1094
+ */
1095
+ const CmcdRecordedRequestType = {
1096
+ MANIFEST: CMCD_RECORDED_REQUEST_TYPE_MANIFEST,
1097
+ SEGMENT: CMCD_RECORDED_REQUEST_TYPE_SEGMENT,
1098
+ EVENT: CMCD_RECORDED_REQUEST_TYPE_EVENT,
1099
+ UNKNOWN: CMCD_RECORDED_REQUEST_TYPE_UNKNOWN
1100
+ };
1101
+
1102
+ //#endregion
1103
+ //#region src/createFetchTransport.ts
1104
+ async function toHttpRequest(request) {
1105
+ const headers = {};
1106
+ request.headers.forEach((value, name) => {
1107
+ headers[name.toLowerCase()] = value;
1108
+ });
1109
+ let body;
1110
+ if (request.body) try {
1111
+ body = await request.text();
1112
+ } catch {
1113
+ body = void 0;
1114
+ }
1115
+ return {
1116
+ url: request.url,
1117
+ method: request.method,
1118
+ headers,
1119
+ body
1120
+ };
1121
+ }
1122
+ /**
1123
+ * Create a transport adapter that patches `globalThis.fetch` to capture
1124
+ * CMCD-bearing requests, normalizing each to `HttpRequest` (lowercase
1125
+ * headers, body read as UTF-8 string). Returns the adapter object
1126
+ * expected by `CmcdReportRecorder`.
1127
+ *
1128
+ * Bodies are read once via `Request.text()` for inspection. This is
1129
+ * safe for the body types CMCD reports use in practice (`string`,
1130
+ * `Blob`, `ArrayBuffer`, `FormData`, `URLSearchParams`), which can be
1131
+ * read by the wrapper and re-read by the underlying `fetch`. Passing
1132
+ * a `ReadableStream` as `init.body` is not supported — the stream is
1133
+ * consumed by the wrapper and the underlying `fetch` will receive an
1134
+ * already-disturbed stream.
1135
+ *
1136
+ * @public
1137
+ */
1138
+ function createFetchTransport() {
1139
+ return { attach(deliver) {
1140
+ const origFetch = globalThis.fetch;
1141
+ globalThis.fetch = async (input, init) => {
1142
+ const synthetic = deliver(await toHttpRequest(input instanceof Request ? input.clone() : new Request(input, init)));
1143
+ if (synthetic) return synthetic;
1144
+ return origFetch.call(globalThis, input, init);
1145
+ };
1146
+ return () => {
1147
+ globalThis.fetch = origFetch;
1148
+ };
1149
+ } };
1150
+ }
1151
+
1152
+ //#endregion
1153
+ //#region src/createXhrTransport.ts
1154
+ async function completeXhrWith(xhr, response) {
1155
+ const text = await response.clone().text().catch(() => "");
1156
+ const ProgressEventCtor = typeof ProgressEvent !== "undefined" ? ProgressEvent : Event;
1157
+ queueMicrotask(() => {
1158
+ try {
1159
+ Object.defineProperty(xhr, "status", {
1160
+ value: response.status,
1161
+ configurable: true
1162
+ });
1163
+ Object.defineProperty(xhr, "statusText", {
1164
+ value: response.statusText,
1165
+ configurable: true
1166
+ });
1167
+ Object.defineProperty(xhr, "readyState", {
1168
+ value: 4,
1169
+ configurable: true
1170
+ });
1171
+ Object.defineProperty(xhr, "responseURL", {
1172
+ value: xhr._cmcdUrl ?? "",
1173
+ configurable: true
1174
+ });
1175
+ Object.defineProperty(xhr, "response", {
1176
+ value: text,
1177
+ configurable: true
1178
+ });
1179
+ Object.defineProperty(xhr, "responseText", {
1180
+ value: text,
1181
+ configurable: true
1182
+ });
1183
+ if (typeof xhr.onload === "function") xhr.onload.call(xhr, new ProgressEventCtor("load"));
1184
+ if (typeof xhr.onloadend === "function") xhr.onloadend.call(xhr, new ProgressEventCtor("loadend"));
1185
+ } catch {
1186
+ const target = xhr;
1187
+ target["status"] = response.status;
1188
+ target["statusText"] = response.statusText;
1189
+ target["readyState"] = 4;
1190
+ target["responseURL"] = xhr._cmcdUrl ?? "";
1191
+ target["response"] = text;
1192
+ target["responseText"] = text;
1193
+ if (typeof xhr.onload === "function") xhr.onload.call(xhr, new ProgressEventCtor("load"));
1194
+ if (typeof xhr.onloadend === "function") xhr.onloadend.call(xhr, new ProgressEventCtor("loadend"));
1195
+ }
1196
+ });
1197
+ }
1198
+ /**
1199
+ * Create a transport adapter that patches `XMLHttpRequest.prototype` to
1200
+ * capture CMCD-bearing requests. Returns the adapter object expected by
1201
+ * `CmcdReportRecorder`.
1202
+ *
1203
+ * @public
1204
+ */
1205
+ function createXhrTransport() {
1206
+ return { attach(deliver) {
1207
+ const Xhr = globalThis.XMLHttpRequest;
1208
+ if (!Xhr) return () => {};
1209
+ const origOpen = Xhr.prototype.open;
1210
+ const origSetRequestHeader = Xhr.prototype.setRequestHeader;
1211
+ const origSend = Xhr.prototype.send;
1212
+ Xhr.prototype.open = function(method, url) {
1213
+ this._cmcdMethod = method;
1214
+ this._cmcdUrl = typeof url === "string" ? url : url.toString();
1215
+ this._cmcdHeaders = {};
1216
+ return origOpen.apply(this, arguments);
1217
+ };
1218
+ Xhr.prototype.setRequestHeader = function(name, value) {
1219
+ if (this._cmcdHeaders) this._cmcdHeaders[name.toLowerCase()] = value;
1220
+ return origSetRequestHeader.apply(this, [name, value]);
1221
+ };
1222
+ Xhr.prototype.send = function(body) {
1223
+ const synthetic = deliver({
1224
+ url: this._cmcdUrl ?? "",
1225
+ method: (this._cmcdMethod ?? "GET").toUpperCase(),
1226
+ headers: this._cmcdHeaders ?? {},
1227
+ body: (typeof Document !== "undefined" && body instanceof Document ? void 0 : body) ?? void 0
1228
+ });
1229
+ if (synthetic) {
1230
+ completeXhrWith(this, synthetic);
1231
+ return;
1232
+ }
1233
+ return origSend.apply(this, [body]);
1234
+ };
1235
+ return () => {
1236
+ Xhr.prototype.open = origOpen;
1237
+ Xhr.prototype.setRequestHeader = origSetRequestHeader;
1238
+ Xhr.prototype.send = origSend;
1239
+ };
1240
+ } };
1241
+ }
1242
+
1243
+ //#endregion
1244
+ //#region src/CmcdReportRecorder.ts
1245
+ const MANIFEST_EXTENSIONS = /\.(m3u8|mpd)(\?|$|\/)/i;
1246
+ const SEGMENT_EXTENSIONS = /\.(m4s|m4v|m4a|mp4|ts|aac)(\?|$|\/)/i;
1247
+ function classifyUrl(url, isEventTarget) {
1248
+ if (isEventTarget) return CmcdRecordedRequestType.EVENT;
1249
+ if (MANIFEST_EXTENSIONS.test(url)) return CmcdRecordedRequestType.MANIFEST;
1250
+ if (SEGMENT_EXTENSIONS.test(url)) return CmcdRecordedRequestType.SEGMENT;
1251
+ return CmcdRecordedRequestType.UNKNOWN;
1252
+ }
1253
+ function hasCmcdHeader(headers) {
1254
+ if (!headers) return false;
1255
+ for (const name in headers) if (name.toLowerCase().startsWith("cmcd-")) return true;
1256
+ return false;
1257
+ }
1258
+ function detectReportingMode(url, headers, isEventTarget) {
1259
+ if (isEventTarget) return "event";
1260
+ if (hasCmcdHeader(headers)) return "header";
1261
+ return url.includes(`${CMCD_PARAM}=`) ? "query" : null;
1262
+ }
1263
+ /**
1264
+ * Test helper that records CMCD-bearing reports across XHR and fetch
1265
+ * transports for assertion in e2e tests. Each captured request is
1266
+ * normalized to {@link @svta/cml-utils!HttpRequest | HttpRequest} so
1267
+ * tests are identical regardless of which transport the player uses.
1268
+ *
1269
+ * @example
1270
+ * {@includeCode ../test/CmcdReportRecorder.test.ts#example}
1271
+ *
1272
+ * @public
1273
+ */
1274
+ var CmcdReportRecorder = class {
1275
+ #reports = [];
1276
+ #detachers = [];
1277
+ #attached = false;
1278
+ #eventTargetUrls = [];
1279
+ #waiters = /* @__PURE__ */ new Map();
1280
+ #onReport;
1281
+ #waitTimeout = 15e3;
1282
+ #deliver = (request) => {
1283
+ const url = request.url;
1284
+ const isEventTarget = (request.method ?? "GET").toUpperCase() === "POST" && this.#eventTargetUrls.some((t) => url.startsWith(t));
1285
+ const reportingMode = detectReportingMode(url, request.headers, isEventTarget);
1286
+ if (reportingMode === null) return;
1287
+ const captured = {
1288
+ request,
1289
+ type: classifyUrl(url, isEventTarget),
1290
+ reportingMode,
1291
+ timestamp: Date.now()
1292
+ };
1293
+ this.#reports.push(captured);
1294
+ if (this.#onReport) try {
1295
+ this.#onReport(captured);
1296
+ } catch (err) {
1297
+ console.error("CmcdReportRecorder onReport listener threw:", err);
1298
+ }
1299
+ this.#notifyWaiters();
1300
+ return isEventTarget ? new Response(null, { status: 204 }) : void 0;
1301
+ };
1302
+ #getMatching(type) {
1303
+ return type === void 0 ? [...this.#reports] : this.#reports.filter((r) => r.type === type);
1304
+ }
1305
+ #notifyWaiters() {
1306
+ for (const [timer, waiter] of this.#waiters) {
1307
+ const matching = this.#getMatching(waiter.type);
1308
+ if (matching.length >= waiter.count) {
1309
+ clearTimeout(timer);
1310
+ this.#waiters.delete(timer);
1311
+ waiter.resolve(matching);
1312
+ }
1313
+ }
1314
+ }
1315
+ #waitFor(type, options) {
1316
+ const count = options.count ?? 1;
1317
+ const timeout = options.timeout ?? this.#waitTimeout;
1318
+ const matching = this.#getMatching(type);
1319
+ if (matching.length >= count) return Promise.resolve(matching);
1320
+ return new Promise((resolve, reject) => {
1321
+ const timer = setTimeout(() => {
1322
+ this.#waiters.delete(timer);
1323
+ const current = this.#getMatching(type);
1324
+ reject(/* @__PURE__ */ new Error(`Timeout waiting for ${count} ${type ?? "any"} CMCD report(s). Got ${current.length}. Total recorded: ${this.#reports.length}.`));
1325
+ }, timeout);
1326
+ this.#waiters.set(timer, {
1327
+ type,
1328
+ count,
1329
+ resolve,
1330
+ reject
1331
+ });
1332
+ });
1333
+ }
1334
+ /**
1335
+ * Install transport patches and begin recording CMCD reports.
1336
+ * No-op if already attached — the previously supplied options are
1337
+ * retained and the new `options` argument is silently ignored.
1338
+ * Call `detach()` first if you need to re-attach with different
1339
+ * options.
1340
+ *
1341
+ * @public
1342
+ */
1343
+ attach(options = {}) {
1344
+ if (this.#attached) return;
1345
+ this.#attached = true;
1346
+ this.#eventTargetUrls = options.eventTargetUrls ?? [];
1347
+ this.#onReport = options.onReport;
1348
+ this.#waitTimeout = options.waitTimeout ?? 15e3;
1349
+ const transports = options.transports ?? [createXhrTransport(), createFetchTransport()];
1350
+ for (const transport of transports) this.#detachers.push(transport.attach(this.#deliver));
1351
+ }
1352
+ /**
1353
+ * Remove transport patches and stop recording. Rejects any pending
1354
+ * wait promises with `Error('Recorder detached while waiting')`.
1355
+ *
1356
+ * @public
1357
+ */
1358
+ detach() {
1359
+ if (!this.#attached) return;
1360
+ for (const detacher of this.#detachers) detacher();
1361
+ this.#detachers = [];
1362
+ this.#attached = false;
1363
+ this.#eventTargetUrls = [];
1364
+ this.#onReport = void 0;
1365
+ for (const [timer, waiter] of this.#waiters) {
1366
+ clearTimeout(timer);
1367
+ waiter.reject(/* @__PURE__ */ new Error("Recorder detached while waiting"));
1368
+ }
1369
+ this.#waiters.clear();
1370
+ }
1371
+ /**
1372
+ * Discard all recorded reports. Does not affect the attached state.
1373
+ *
1374
+ * @public
1375
+ */
1376
+ clear() {
1377
+ this.#reports = [];
1378
+ }
1379
+ /**
1380
+ * Return a defensive copy of all recorded reports.
1381
+ *
1382
+ * @public
1383
+ */
1384
+ getReports() {
1385
+ return [...this.#reports];
1386
+ }
1387
+ /**
1388
+ * Wait until at least `count` reports of any type are recorded.
1389
+ * Resolves with all matching reports; rejects with a diagnostic error
1390
+ * on timeout. `count` defaults to 1; `timeout` falls back to the
1391
+ * recorder's `waitTimeout` option (default 15000 ms).
1392
+ *
1393
+ * @public
1394
+ */
1395
+ waitForReports(options = {}) {
1396
+ return this.#waitFor(void 0, options);
1397
+ }
1398
+ /**
1399
+ * Wait until at least `count` manifest reports are recorded.
1400
+ * Resolves with all matching reports; rejects with a diagnostic error
1401
+ * on timeout. `count` defaults to 1; `timeout` falls back to the
1402
+ * recorder's `waitTimeout` option (default 15000 ms).
1403
+ *
1404
+ * @public
1405
+ */
1406
+ waitForManifest(options = {}) {
1407
+ return this.#waitFor(CmcdRecordedRequestType.MANIFEST, options);
1408
+ }
1409
+ /**
1410
+ * Wait until at least `count` segment reports are recorded.
1411
+ * Resolves with all matching reports; rejects with a diagnostic error
1412
+ * on timeout. `count` defaults to 1; `timeout` falls back to the
1413
+ * recorder's `waitTimeout` option (default 15000 ms).
1414
+ *
1415
+ * @public
1416
+ */
1417
+ waitForSegments(options = {}) {
1418
+ return this.#waitFor(CmcdRecordedRequestType.SEGMENT, options);
1419
+ }
1420
+ /**
1421
+ * Wait until at least `count` event-mode reports are recorded.
1422
+ * Resolves with all matching reports; rejects with a diagnostic error
1423
+ * on timeout. `count` defaults to 1; `timeout` falls back to the
1424
+ * recorder's `waitTimeout` option (default 15000 ms).
1425
+ *
1426
+ * @public
1427
+ */
1428
+ waitForEvents(options = {}) {
1429
+ return this.#waitFor(CmcdRecordedRequestType.EVENT, options);
1430
+ }
1431
+ };
1432
+
982
1433
  //#endregion
983
1434
  //#region src/CmcdTransmissionMode.ts
984
1435
  /**
@@ -1025,6 +1476,53 @@ function createEncodingOptions(reportingMode, config, baseUrl) {
1025
1476
  baseUrl
1026
1477
  };
1027
1478
  }
1479
+ /**
1480
+ * Deep equality for CmcdObjectTypeList (used for `br` dedup).
1481
+ *
1482
+ * Order-sensitive: arrays with the same elements in different positions
1483
+ * are treated as different. Players that construct `br` consistently
1484
+ * get correct dedup; shuffling produces spurious emits, which is the
1485
+ * safer failure mode.
1486
+ */
1487
+ function cmcdObjectTypeListEqual(a, b) {
1488
+ if (a === b) return true;
1489
+ if (a.length !== b.length) return false;
1490
+ for (let i = 0; i < a.length; i++) {
1491
+ const ai = a[i];
1492
+ const bi = b[i];
1493
+ if (ai === bi) continue;
1494
+ if (typeof ai === "number" || typeof bi === "number") return false;
1495
+ if (ai.value !== bi.value) return false;
1496
+ const ap = ai.params;
1497
+ const bp = bi.params;
1498
+ const ak = ap && Object.keys(ap)[0];
1499
+ const bk = bp && Object.keys(bp)[0];
1500
+ if (ak !== bk) return false;
1501
+ if (ak !== void 0 && bk !== void 0 && ap && bp && ap[ak] !== bp[bk]) return false;
1502
+ }
1503
+ return true;
1504
+ }
1505
+ const equal = Object.is;
1506
+ const identity = (v) => v;
1507
+ /**
1508
+ * Maps each tracked state field to its event type and equality function.
1509
+ * Order matters: `update()` fires events in this order for multi-field updates.
1510
+ */
1511
+ const STATE_FIELDS = /* @__PURE__ */ Array.from(CMCD_STATE_EVENT_FIELDS, ([event, field]) => {
1512
+ if (field === "br") return {
1513
+ event,
1514
+ field,
1515
+ equal: (a, b) => a === void 0 || b === void 0 ? a === b : cmcdObjectTypeListEqual(a, b),
1516
+ snapshot: (v) => v.slice()
1517
+ };
1518
+ return {
1519
+ event,
1520
+ field,
1521
+ equal,
1522
+ snapshot: identity
1523
+ };
1524
+ });
1525
+ const STATE_FIELDS_BY_EVENT = /* @__PURE__ */ new Map(/* @__PURE__ */ STATE_FIELDS.map((e) => [e.event, e]));
1028
1526
  function defaultRequester(request) {
1029
1527
  const { url,...init } = request;
1030
1528
  return fetch(url, init);
@@ -1070,6 +1568,7 @@ var CmcdReporter = class {
1070
1568
  this.data = {};
1071
1569
  this.msd = NaN;
1072
1570
  this.eventTargets = /* @__PURE__ */ new Map();
1571
+ this.lastEmitted = {};
1073
1572
  this.requestTarget = {
1074
1573
  sn: 0,
1075
1574
  msdSent: false
@@ -1098,7 +1597,7 @@ var CmcdReporter = class {
1098
1597
  start() {
1099
1598
  this.eventTargets.forEach((target, config) => {
1100
1599
  this.disarmInterval(target);
1101
- if (config.interval === 0 || !config.events.includes(CmcdEventType.TIME_INTERVAL)) return;
1600
+ if (config.interval === 0 || !config.events.includes(CMCD_EVENT_TIME_INTERVAL)) return;
1102
1601
  const timeIntervalEvent = () => {
1103
1602
  this.recordTargetEvent(target, config, CMCD_EVENT_TIME_INTERVAL);
1104
1603
  this.processEventTargets();
@@ -1127,7 +1626,32 @@ var CmcdReporter = class {
1127
1626
  this.processEventTargets(true);
1128
1627
  }
1129
1628
  /**
1130
- * Updates the CMCD data. Called by the player when the data changes.
1629
+ * Updates the CMCD data.
1630
+ *
1631
+ * Called by the player when data changes. For tracked state fields
1632
+ * (`sta`, `pr`, `cid`, `bg`, `br`), if the new value differs from the
1633
+ * last-reported value (the value most recently emitted on the wire for
1634
+ * that field), the corresponding state-change event is automatically
1635
+ * fired. Comparing against the last-reported value (rather than the
1636
+ * previous persisted value) ensures the first state-change event in a
1637
+ * new session always emits, even when the persisted value didn't change
1638
+ * across the `sid` boundary.
1639
+ *
1640
+ * Multi-field updates fire multiple events in the order: `sta` → `pr` →
1641
+ * `cid` → `bg` → `br`. The order of keys in the input object does not
1642
+ * affect the firing order.
1643
+ *
1644
+ * To attach snapshot context (e.g., `bl`, `mtp`, `pt`, `ltc`) to a
1645
+ * state-change event, ensure those fields are in the reporter's data
1646
+ * before the state field changes. Either include them alongside the
1647
+ * state field in the same `update()` call, or persist them via earlier
1648
+ * `update()` calls — auto-fired events emit whatever is currently in
1649
+ * the persistent data store. This is also how to keep `TIME_INTERVAL`
1650
+ * reports useful — those events draw from the persistent data store
1651
+ * with no caller hook for per-event data, so fields the player wants
1652
+ * in periodic reports must be kept fresh here.
1653
+ *
1654
+ * A `sid` change resets the dedup baseline.
1131
1655
  *
1132
1656
  * @param data - The data to update.
1133
1657
  */
@@ -1139,16 +1663,54 @@ var CmcdReporter = class {
1139
1663
  ...data,
1140
1664
  msd: void 0
1141
1665
  };
1666
+ for (const entry of STATE_FIELDS) if (entry.field in data && !entry.equal(this.data[entry.field], this.lastEmitted[entry.field])) this.recordEvent(entry.event);
1142
1667
  }
1143
1668
  /**
1144
1669
  * Records an event. Called by the player when an event occurs.
1145
1670
  *
1671
+ * For state-change events (`PLAY_STATE`, `PLAYBACK_RATE`, `CONTENT_ID`,
1672
+ * `BACKGROUNDED_MODE`, `BITRATE_CHANGE`), this method:
1673
+ * 1. Persists the dedup field from `data` (if present) into the reporter's
1674
+ * persistent data store — equivalent to a write-through `update()`.
1675
+ * 2. Drops the event entirely if the dedup field has no value after the
1676
+ * write-through (never set, or cleared via `update({ field: undefined })`).
1677
+ * State-change events without their required field would violate CTA-5004-B.
1678
+ * 3. Suppresses the event if the field's current value matches the
1679
+ * last-emitted value (no state transition).
1680
+ *
1681
+ * For all other event types, the event is always emitted.
1682
+ *
1683
+ * For state-change events, prefer {@link CmcdReporter.update} — including
1684
+ * for snapshot enrichment via a combined call like
1685
+ * `update({ sta: 'p', bl: [3000], mtp: [8500] })`. Calling `recordEvent()`
1686
+ * for a state-change event after `update()` has already auto-fired it
1687
+ * silently drops the second call's `data`, because dedup suppresses the
1688
+ * second emission.
1689
+ *
1690
+ * Use `recordEvent()` directly for events whose payload is intrinsic to
1691
+ * the event call — `CUSTOM_EVENT` with `cen`, `ERROR` with `ec`,
1692
+ * ad-lifecycle events, `MUTE`/`UNMUTE`, `PLAYER_EXPAND`/`PLAYER_COLLAPSE`,
1693
+ * `SKIP`. For `RESPONSE_RECEIVED`, prefer
1694
+ * {@link CmcdReporter.recordResponseReceived}, which derives the
1695
+ * per-response fields automatically.
1696
+ *
1146
1697
  * @param type - The type of event to record.
1147
1698
  * @param data - Additional data to record with the event. This data
1148
- * only applies to this event report. Persistent data should
1149
- * be updated using `update()`.
1699
+ * only applies to this event report, except for the dedup
1700
+ * field of a state-change event, which is also persisted
1701
+ * into the reporter's data store.
1150
1702
  */
1151
1703
  recordEvent(type, data = {}) {
1704
+ const entry = STATE_FIELDS_BY_EVENT.get(type);
1705
+ if (entry) {
1706
+ const field = entry.field;
1707
+ const incoming = data[field];
1708
+ if (incoming !== void 0) Object.assign(this.data, { [field]: incoming });
1709
+ const current = this.data[field];
1710
+ if (current === void 0) return;
1711
+ if (entry.equal(current, this.lastEmitted[field])) return;
1712
+ Object.assign(this.lastEmitted, { [field]: entry.snapshot(current) });
1713
+ }
1152
1714
  this.eventTargets.forEach((target, config) => {
1153
1715
  this.recordTargetEvent(target, config, type, data);
1154
1716
  });
@@ -1351,6 +1913,7 @@ var CmcdReporter = class {
1351
1913
  resetSession() {
1352
1914
  this.eventTargets.forEach((target) => target.sn = 0);
1353
1915
  this.requestTarget.sn = 0;
1916
+ this.lastEmitted = {};
1354
1917
  }
1355
1918
  };
1356
1919
 
@@ -1580,8 +2143,8 @@ function resolveVersion(data, options) {
1580
2143
 
1581
2144
  //#endregion
1582
2145
  //#region src/validateCmcdKeys.ts
1583
- const CMCD_V1_KEY_SET = new Set(CMCD_V1_KEYS);
1584
- const CMCD_KEY_SET = new Set(CMCD_KEYS);
2146
+ const CMCD_V1_KEY_SET = /* @__PURE__ */ new Set(CMCD_V1_KEYS);
2147
+ const CMCD_KEY_SET = /* @__PURE__ */ new Set(CMCD_KEYS);
1585
2148
  /**
1586
2149
  * Validates that all keys in a CMCD payload are recognized spec keys or valid custom keys.
1587
2150
  *
@@ -1680,9 +2243,9 @@ function validateCmcdStructure(data, options) {
1680
2243
  message: `Response key "${key}" must only be present when e="rr".`,
1681
2244
  severity: CMCD_VALIDATION_SEVERITY_ERROR
1682
2245
  });
1683
- if (data["e"] === CMCD_EVENT_PLAY_STATE && !("sta" in data)) issues.push({
1684
- key: "sta",
1685
- message: "Play state event (e=\"ps\") requires the \"sta\" key to be present.",
2246
+ for (const [eventType, requiredField] of CMCD_STATE_EVENT_FIELDS) if (data["e"] === eventType && !(requiredField in data)) issues.push({
2247
+ key: requiredField,
2248
+ message: `State-change event (e="${eventType}") requires the "${requiredField}" key to be present.`,
1686
2249
  severity: CMCD_VALIDATION_SEVERITY_ERROR
1687
2250
  });
1688
2251
  if (data["e"] === CMCD_EVENT_ERROR && !("ec" in data)) issues.push({
@@ -1860,6 +2423,7 @@ const CMCD_TOKEN_VALUES = {
1860
2423
  e: [
1861
2424
  "bc",
1862
2425
  "ps",
2426
+ "pr",
1863
2427
  "e",
1864
2428
  "t",
1865
2429
  "c",
@@ -1914,14 +2478,14 @@ const CMCD_TOKEN_VALUES = {
1914
2478
 
1915
2479
  //#endregion
1916
2480
  //#region src/validateCmcdValues.ts
1917
- const HUNDRED_ROUNDING_KEYS = new Set([
2481
+ const HUNDRED_ROUNDING_KEYS = /* @__PURE__ */ new Set([
1918
2482
  "bl",
1919
2483
  "dl",
1920
2484
  "mtp",
1921
2485
  "rtp",
1922
2486
  "tbl"
1923
2487
  ]);
1924
- const INTEGER_ROUNDING_KEYS = new Set([
2488
+ const INTEGER_ROUNDING_KEYS = /* @__PURE__ */ new Set([
1925
2489
  "br",
1926
2490
  "d",
1927
2491
  "tb"
@@ -2377,5 +2941,5 @@ function extractHeaderRecord(headers) {
2377
2941
  }
2378
2942
 
2379
2943
  //#endregion
2380
- export { CMCD_DEFAULT_TIME_INTERVAL, CMCD_EVENT_AD_BREAK_END, CMCD_EVENT_AD_BREAK_START, CMCD_EVENT_AD_END, CMCD_EVENT_AD_START, CMCD_EVENT_BACKGROUNDED_MODE, CMCD_EVENT_BITRATE_CHANGE, CMCD_EVENT_CONTENT_ID, CMCD_EVENT_CUSTOM_EVENT, CMCD_EVENT_ERROR, CMCD_EVENT_KEYS, CMCD_EVENT_MODE, CMCD_EVENT_MUTE, CMCD_EVENT_PLAYER_COLLAPSE, CMCD_EVENT_PLAYER_EXPAND, CMCD_EVENT_PLAY_STATE, CMCD_EVENT_RESPONSE_RECEIVED, CMCD_EVENT_SKIP, CMCD_EVENT_TIME_INTERVAL, CMCD_EVENT_UNMUTE, CMCD_FORMATTER_MAP, CMCD_HEADERS, CMCD_HEADER_FIELDS, CMCD_HEADER_MAP, CMCD_JSON, CMCD_KEYS, CMCD_MIME_TYPE, CMCD_OBJECT, CMCD_PARAM, CMCD_QUERY, CMCD_REQUEST, CMCD_REQUEST_KEYS, CMCD_REQUEST_MODE, CMCD_RESPONSE_KEYS, CMCD_SESSION, CMCD_STATUS, CMCD_V1, CMCD_V1_KEYS, CMCD_V2, CMCD_VALIDATION_SEVERITY_ERROR, CMCD_VALIDATION_SEVERITY_WARNING, CmcdEventType, CmcdHeaderField, CmcdObjectType, CmcdPlayerState, CmcdReporter, CmcdReportingMode, CmcdStreamType, CmcdStreamingFormat, CmcdTransmissionMode, CmcdValidationSeverity, appendCmcdHeaders, appendCmcdQuery, decodeCmcd, encodeCmcd, fromCmcdHeaders, fromCmcdQuery, fromCmcdUrl, groupCmcdHeaders, isCmcdCustomKey, isCmcdEventKey, isCmcdRequestKey, isCmcdResponseReceivedKey, isCmcdV1Data, isCmcdV1Key, isCmcdV2Data, prepareCmcdData, toCmcdHeaders, toCmcdQuery, toCmcdUrl, toCmcdValue, validateCmcd, validateCmcdEventReport, validateCmcdEvents, validateCmcdHeaders, validateCmcdKeys, validateCmcdRequest, validateCmcdStructure, validateCmcdValues };
2944
+ export { CMCD_DEFAULT_TIME_INTERVAL, CMCD_EVENT_AD_BREAK_END, CMCD_EVENT_AD_BREAK_START, CMCD_EVENT_AD_END, CMCD_EVENT_AD_START, CMCD_EVENT_BACKGROUNDED_MODE, CMCD_EVENT_BITRATE_CHANGE, CMCD_EVENT_CONTENT_ID, CMCD_EVENT_CUSTOM_EVENT, CMCD_EVENT_ERROR, CMCD_EVENT_KEYS, CMCD_EVENT_MODE, CMCD_EVENT_MUTE, CMCD_EVENT_PLAYBACK_RATE, CMCD_EVENT_PLAYER_COLLAPSE, CMCD_EVENT_PLAYER_EXPAND, CMCD_EVENT_PLAY_STATE, CMCD_EVENT_RESPONSE_RECEIVED, CMCD_EVENT_SKIP, CMCD_EVENT_TIME_INTERVAL, CMCD_EVENT_UNMUTE, CMCD_FORMATTER_MAP, CMCD_HEADERS, CMCD_HEADER_FIELDS, CMCD_HEADER_MAP, CMCD_JSON, CMCD_KEYS, CMCD_MIME_TYPE, CMCD_OBJECT, CMCD_PARAM, CMCD_QUERY, CMCD_RECORDED_REPORT_MODE_EVENT, CMCD_RECORDED_REPORT_MODE_HEADER, CMCD_RECORDED_REPORT_MODE_QUERY, CMCD_RECORDED_REQUEST_TYPE_EVENT, CMCD_RECORDED_REQUEST_TYPE_MANIFEST, CMCD_RECORDED_REQUEST_TYPE_SEGMENT, CMCD_RECORDED_REQUEST_TYPE_UNKNOWN, CMCD_REQUEST, CMCD_REQUEST_KEYS, CMCD_REQUEST_MODE, CMCD_RESPONSE_KEYS, CMCD_SESSION, CMCD_STATUS, CMCD_V1, CMCD_V1_KEYS, CMCD_V2, CMCD_VALIDATION_SEVERITY_ERROR, CMCD_VALIDATION_SEVERITY_WARNING, CmcdEventType, CmcdHeaderField, CmcdObjectType, CmcdPlayerState, CmcdRecordedReportMode, CmcdRecordedRequestType, CmcdReportRecorder, CmcdReporter, CmcdReportingMode, CmcdStreamType, CmcdStreamingFormat, CmcdTransmissionMode, CmcdValidationSeverity, appendCmcdHeaders, appendCmcdQuery, createFetchTransport, createXhrTransport, decodeCmcd, encodeCmcd, fromCmcdHeaders, fromCmcdQuery, fromCmcdUrl, groupCmcdHeaders, isCmcdCustomKey, isCmcdEventKey, isCmcdRequestKey, isCmcdResponseReceivedKey, isCmcdV1Data, isCmcdV1Key, isCmcdV2Data, prepareCmcdData, toCmcdHeaders, toCmcdQuery, toCmcdUrl, toCmcdValue, validateCmcd, validateCmcdEventReport, validateCmcdEvents, validateCmcdHeaders, validateCmcdKeys, validateCmcdRequest, validateCmcdStructure, validateCmcdValues };
2381
2945
  //# sourceMappingURL=index.js.map