@svta/cml-cmcd 2.3.2 → 2.4.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/README.md +27 -0
- package/dist/index.d.ts +381 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +550 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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 = 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
|
/**
|
|
@@ -550,6 +583,8 @@ function prepareCmcdData(obj, options = {}) {
|
|
|
550
583
|
if (!keys.includes("e") && eventType != null) keys.push("e");
|
|
551
584
|
if (!keys.includes("ts")) keys.push("ts");
|
|
552
585
|
if (!keys.includes("cen") && data["cen"] != null && eventType === CMCD_EVENT_CUSTOM_EVENT) keys.push("cen");
|
|
586
|
+
const requiredField = eventType ? CMCD_STATE_EVENT_FIELDS.get(eventType) : void 0;
|
|
587
|
+
if (requiredField && data[requiredField] != null && !keys.includes(requiredField)) keys.push(requiredField);
|
|
553
588
|
}
|
|
554
589
|
if (keys.length === 0) return results;
|
|
555
590
|
if (version > 1 && !keys.includes("v")) keys.push("v");
|
|
@@ -565,9 +600,10 @@ function prepareCmcdData(obj, options = {}) {
|
|
|
565
600
|
if (typeof formatter === "function") value = formatter(value, formatterOptions);
|
|
566
601
|
if (key === "v") if (version === 1) continue;
|
|
567
602
|
else value = version;
|
|
568
|
-
if (key === "pr" && value === 1) continue;
|
|
603
|
+
if (key === "pr" && value === 1 && !(isEventMode && data["e"] === CMCD_EVENT_PLAYBACK_RATE)) continue;
|
|
569
604
|
if (isEventMode && key === "ts" && !Number.isFinite(value)) value = Date.now();
|
|
570
|
-
|
|
605
|
+
const isBgFalseTransition = isEventMode && value === false && key === "bg" && data["e"] === CMCD_EVENT_BACKGROUNDED_MODE;
|
|
606
|
+
if (!isValid(value) && !isBgFalseTransition) continue;
|
|
571
607
|
if (isTokenField(key) && typeof value === "string") value = new SfToken(value);
|
|
572
608
|
results[key] = value;
|
|
573
609
|
}
|
|
@@ -979,6 +1015,417 @@ const CmcdPlayerState = {
|
|
|
979
1015
|
PRELOADING: "d"
|
|
980
1016
|
};
|
|
981
1017
|
|
|
1018
|
+
//#endregion
|
|
1019
|
+
//#region src/CmcdRecordedReportMode.ts
|
|
1020
|
+
/**
|
|
1021
|
+
* CMCD query reporting mode — CMCD data carried as a `CMCD=` URL
|
|
1022
|
+
* query parameter.
|
|
1023
|
+
*
|
|
1024
|
+
* @public
|
|
1025
|
+
*/
|
|
1026
|
+
const CMCD_RECORDED_REPORT_MODE_QUERY = "query";
|
|
1027
|
+
/**
|
|
1028
|
+
* CMCD header reporting mode — CMCD data carried as `Cmcd-*` HTTP
|
|
1029
|
+
* headers.
|
|
1030
|
+
*
|
|
1031
|
+
* @public
|
|
1032
|
+
*/
|
|
1033
|
+
const CMCD_RECORDED_REPORT_MODE_HEADER = "header";
|
|
1034
|
+
/**
|
|
1035
|
+
* CMCD event reporting mode — CMCD data carried as a POST body to an
|
|
1036
|
+
* event-target URL.
|
|
1037
|
+
*
|
|
1038
|
+
* @public
|
|
1039
|
+
*/
|
|
1040
|
+
const CMCD_RECORDED_REPORT_MODE_EVENT = "event";
|
|
1041
|
+
/**
|
|
1042
|
+
* Reporting mode under which a captured report was observed by
|
|
1043
|
+
* `CmcdReportRecorder`.
|
|
1044
|
+
*
|
|
1045
|
+
* @enum
|
|
1046
|
+
*
|
|
1047
|
+
* @public
|
|
1048
|
+
*/
|
|
1049
|
+
const CmcdRecordedReportMode = {
|
|
1050
|
+
QUERY: CMCD_RECORDED_REPORT_MODE_QUERY,
|
|
1051
|
+
HEADER: CMCD_RECORDED_REPORT_MODE_HEADER,
|
|
1052
|
+
EVENT: CMCD_RECORDED_REPORT_MODE_EVENT
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
//#endregion
|
|
1056
|
+
//#region src/CmcdRecordedRequestType.ts
|
|
1057
|
+
/**
|
|
1058
|
+
* Manifest or playlist request classification.
|
|
1059
|
+
*
|
|
1060
|
+
* @public
|
|
1061
|
+
*/
|
|
1062
|
+
const CMCD_RECORDED_REQUEST_TYPE_MANIFEST = "manifest";
|
|
1063
|
+
/**
|
|
1064
|
+
* Media segment request classification.
|
|
1065
|
+
*
|
|
1066
|
+
* @public
|
|
1067
|
+
*/
|
|
1068
|
+
const CMCD_RECORDED_REQUEST_TYPE_SEGMENT = "segment";
|
|
1069
|
+
/**
|
|
1070
|
+
* CMCD event-mode report request classification (POST to a URL in
|
|
1071
|
+
* `eventTargetUrls`).
|
|
1072
|
+
*
|
|
1073
|
+
* @public
|
|
1074
|
+
*/
|
|
1075
|
+
const CMCD_RECORDED_REQUEST_TYPE_EVENT = "event";
|
|
1076
|
+
/**
|
|
1077
|
+
* Fallback classification for requests the recorder did not recognize
|
|
1078
|
+
* as manifest, segment, or event.
|
|
1079
|
+
*
|
|
1080
|
+
* @public
|
|
1081
|
+
*/
|
|
1082
|
+
const CMCD_RECORDED_REQUEST_TYPE_UNKNOWN = "unknown";
|
|
1083
|
+
/**
|
|
1084
|
+
* Classification of a captured request, used by `CmcdReportRecorder`
|
|
1085
|
+
* for filtering.
|
|
1086
|
+
*
|
|
1087
|
+
* @enum
|
|
1088
|
+
*
|
|
1089
|
+
* @public
|
|
1090
|
+
*/
|
|
1091
|
+
const CmcdRecordedRequestType = {
|
|
1092
|
+
MANIFEST: CMCD_RECORDED_REQUEST_TYPE_MANIFEST,
|
|
1093
|
+
SEGMENT: CMCD_RECORDED_REQUEST_TYPE_SEGMENT,
|
|
1094
|
+
EVENT: CMCD_RECORDED_REQUEST_TYPE_EVENT,
|
|
1095
|
+
UNKNOWN: CMCD_RECORDED_REQUEST_TYPE_UNKNOWN
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
//#endregion
|
|
1099
|
+
//#region src/createFetchTransport.ts
|
|
1100
|
+
async function toHttpRequest(request) {
|
|
1101
|
+
const headers = {};
|
|
1102
|
+
request.headers.forEach((value, name) => {
|
|
1103
|
+
headers[name.toLowerCase()] = value;
|
|
1104
|
+
});
|
|
1105
|
+
let body;
|
|
1106
|
+
if (request.body) try {
|
|
1107
|
+
body = await request.text();
|
|
1108
|
+
} catch {
|
|
1109
|
+
body = void 0;
|
|
1110
|
+
}
|
|
1111
|
+
return {
|
|
1112
|
+
url: request.url,
|
|
1113
|
+
method: request.method,
|
|
1114
|
+
headers,
|
|
1115
|
+
body
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Create a transport adapter that patches `globalThis.fetch` to capture
|
|
1120
|
+
* CMCD-bearing requests, normalizing each to `HttpRequest` (lowercase
|
|
1121
|
+
* headers, body read as UTF-8 string). Returns the adapter object
|
|
1122
|
+
* expected by `CmcdReportRecorder`.
|
|
1123
|
+
*
|
|
1124
|
+
* Bodies are read once via `Request.text()` for inspection. This is
|
|
1125
|
+
* safe for the body types CMCD reports use in practice (`string`,
|
|
1126
|
+
* `Blob`, `ArrayBuffer`, `FormData`, `URLSearchParams`), which can be
|
|
1127
|
+
* read by the wrapper and re-read by the underlying `fetch`. Passing
|
|
1128
|
+
* a `ReadableStream` as `init.body` is not supported — the stream is
|
|
1129
|
+
* consumed by the wrapper and the underlying `fetch` will receive an
|
|
1130
|
+
* already-disturbed stream.
|
|
1131
|
+
*
|
|
1132
|
+
* @public
|
|
1133
|
+
*/
|
|
1134
|
+
function createFetchTransport() {
|
|
1135
|
+
return { attach(deliver) {
|
|
1136
|
+
const origFetch = globalThis.fetch;
|
|
1137
|
+
globalThis.fetch = async (input, init) => {
|
|
1138
|
+
const synthetic = deliver(await toHttpRequest(input instanceof Request ? input.clone() : new Request(input, init)));
|
|
1139
|
+
if (synthetic) return synthetic;
|
|
1140
|
+
return origFetch.call(globalThis, input, init);
|
|
1141
|
+
};
|
|
1142
|
+
return () => {
|
|
1143
|
+
globalThis.fetch = origFetch;
|
|
1144
|
+
};
|
|
1145
|
+
} };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
//#endregion
|
|
1149
|
+
//#region src/createXhrTransport.ts
|
|
1150
|
+
async function completeXhrWith(xhr, response) {
|
|
1151
|
+
const text = await response.clone().text().catch(() => "");
|
|
1152
|
+
const ProgressEventCtor = typeof ProgressEvent !== "undefined" ? ProgressEvent : Event;
|
|
1153
|
+
queueMicrotask(() => {
|
|
1154
|
+
try {
|
|
1155
|
+
Object.defineProperty(xhr, "status", {
|
|
1156
|
+
value: response.status,
|
|
1157
|
+
configurable: true
|
|
1158
|
+
});
|
|
1159
|
+
Object.defineProperty(xhr, "statusText", {
|
|
1160
|
+
value: response.statusText,
|
|
1161
|
+
configurable: true
|
|
1162
|
+
});
|
|
1163
|
+
Object.defineProperty(xhr, "readyState", {
|
|
1164
|
+
value: 4,
|
|
1165
|
+
configurable: true
|
|
1166
|
+
});
|
|
1167
|
+
Object.defineProperty(xhr, "responseURL", {
|
|
1168
|
+
value: xhr._cmcdUrl ?? "",
|
|
1169
|
+
configurable: true
|
|
1170
|
+
});
|
|
1171
|
+
Object.defineProperty(xhr, "response", {
|
|
1172
|
+
value: text,
|
|
1173
|
+
configurable: true
|
|
1174
|
+
});
|
|
1175
|
+
Object.defineProperty(xhr, "responseText", {
|
|
1176
|
+
value: text,
|
|
1177
|
+
configurable: true
|
|
1178
|
+
});
|
|
1179
|
+
if (typeof xhr.onload === "function") xhr.onload.call(xhr, new ProgressEventCtor("load"));
|
|
1180
|
+
if (typeof xhr.onloadend === "function") xhr.onloadend.call(xhr, new ProgressEventCtor("loadend"));
|
|
1181
|
+
} catch {
|
|
1182
|
+
const target = xhr;
|
|
1183
|
+
target["status"] = response.status;
|
|
1184
|
+
target["statusText"] = response.statusText;
|
|
1185
|
+
target["readyState"] = 4;
|
|
1186
|
+
target["responseURL"] = xhr._cmcdUrl ?? "";
|
|
1187
|
+
target["response"] = text;
|
|
1188
|
+
target["responseText"] = text;
|
|
1189
|
+
if (typeof xhr.onload === "function") xhr.onload.call(xhr, new ProgressEventCtor("load"));
|
|
1190
|
+
if (typeof xhr.onloadend === "function") xhr.onloadend.call(xhr, new ProgressEventCtor("loadend"));
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Create a transport adapter that patches `XMLHttpRequest.prototype` to
|
|
1196
|
+
* capture CMCD-bearing requests. Returns the adapter object expected by
|
|
1197
|
+
* `CmcdReportRecorder`.
|
|
1198
|
+
*
|
|
1199
|
+
* @public
|
|
1200
|
+
*/
|
|
1201
|
+
function createXhrTransport() {
|
|
1202
|
+
return { attach(deliver) {
|
|
1203
|
+
const Xhr = globalThis.XMLHttpRequest;
|
|
1204
|
+
if (!Xhr) return () => {};
|
|
1205
|
+
const origOpen = Xhr.prototype.open;
|
|
1206
|
+
const origSetRequestHeader = Xhr.prototype.setRequestHeader;
|
|
1207
|
+
const origSend = Xhr.prototype.send;
|
|
1208
|
+
Xhr.prototype.open = function(method, url) {
|
|
1209
|
+
this._cmcdMethod = method;
|
|
1210
|
+
this._cmcdUrl = typeof url === "string" ? url : url.toString();
|
|
1211
|
+
this._cmcdHeaders = {};
|
|
1212
|
+
return origOpen.apply(this, arguments);
|
|
1213
|
+
};
|
|
1214
|
+
Xhr.prototype.setRequestHeader = function(name, value) {
|
|
1215
|
+
if (this._cmcdHeaders) this._cmcdHeaders[name.toLowerCase()] = value;
|
|
1216
|
+
return origSetRequestHeader.apply(this, [name, value]);
|
|
1217
|
+
};
|
|
1218
|
+
Xhr.prototype.send = function(body) {
|
|
1219
|
+
const synthetic = deliver({
|
|
1220
|
+
url: this._cmcdUrl ?? "",
|
|
1221
|
+
method: (this._cmcdMethod ?? "GET").toUpperCase(),
|
|
1222
|
+
headers: this._cmcdHeaders ?? {},
|
|
1223
|
+
body: (typeof Document !== "undefined" && body instanceof Document ? void 0 : body) ?? void 0
|
|
1224
|
+
});
|
|
1225
|
+
if (synthetic) {
|
|
1226
|
+
completeXhrWith(this, synthetic);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
return origSend.apply(this, [body]);
|
|
1230
|
+
};
|
|
1231
|
+
return () => {
|
|
1232
|
+
Xhr.prototype.open = origOpen;
|
|
1233
|
+
Xhr.prototype.setRequestHeader = origSetRequestHeader;
|
|
1234
|
+
Xhr.prototype.send = origSend;
|
|
1235
|
+
};
|
|
1236
|
+
} };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
//#endregion
|
|
1240
|
+
//#region src/CmcdReportRecorder.ts
|
|
1241
|
+
const MANIFEST_EXTENSIONS = /\.(m3u8|mpd)(\?|$|\/)/i;
|
|
1242
|
+
const SEGMENT_EXTENSIONS = /\.(m4s|m4v|m4a|mp4|ts|aac)(\?|$|\/)/i;
|
|
1243
|
+
function classifyUrl(url, isEventTarget) {
|
|
1244
|
+
if (isEventTarget) return CmcdRecordedRequestType.EVENT;
|
|
1245
|
+
if (MANIFEST_EXTENSIONS.test(url)) return CmcdRecordedRequestType.MANIFEST;
|
|
1246
|
+
if (SEGMENT_EXTENSIONS.test(url)) return CmcdRecordedRequestType.SEGMENT;
|
|
1247
|
+
return CmcdRecordedRequestType.UNKNOWN;
|
|
1248
|
+
}
|
|
1249
|
+
function hasCmcdHeader(headers) {
|
|
1250
|
+
if (!headers) return false;
|
|
1251
|
+
for (const name in headers) if (name.toLowerCase().startsWith("cmcd-")) return true;
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
function detectReportingMode(url, headers, isEventTarget) {
|
|
1255
|
+
if (isEventTarget) return "event";
|
|
1256
|
+
if (hasCmcdHeader(headers)) return "header";
|
|
1257
|
+
return url.includes(`${CMCD_PARAM}=`) ? "query" : null;
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Test helper that records CMCD-bearing reports across XHR and fetch
|
|
1261
|
+
* transports for assertion in e2e tests. Each captured request is
|
|
1262
|
+
* normalized to {@link @svta/cml-utils!HttpRequest | HttpRequest} so
|
|
1263
|
+
* tests are identical regardless of which transport the player uses.
|
|
1264
|
+
*
|
|
1265
|
+
* @example
|
|
1266
|
+
* {@includeCode ../test/CmcdReportRecorder.test.ts#example}
|
|
1267
|
+
*
|
|
1268
|
+
* @public
|
|
1269
|
+
*/
|
|
1270
|
+
var CmcdReportRecorder = class {
|
|
1271
|
+
#reports = [];
|
|
1272
|
+
#detachers = [];
|
|
1273
|
+
#attached = false;
|
|
1274
|
+
#eventTargetUrls = [];
|
|
1275
|
+
#waiters = /* @__PURE__ */ new Map();
|
|
1276
|
+
#onReport;
|
|
1277
|
+
#waitTimeout = 15e3;
|
|
1278
|
+
#deliver = (request) => {
|
|
1279
|
+
const url = request.url;
|
|
1280
|
+
const isEventTarget = (request.method ?? "GET").toUpperCase() === "POST" && this.#eventTargetUrls.some((t) => url.startsWith(t));
|
|
1281
|
+
const reportingMode = detectReportingMode(url, request.headers, isEventTarget);
|
|
1282
|
+
if (reportingMode === null) return;
|
|
1283
|
+
const captured = {
|
|
1284
|
+
request,
|
|
1285
|
+
type: classifyUrl(url, isEventTarget),
|
|
1286
|
+
reportingMode,
|
|
1287
|
+
timestamp: Date.now()
|
|
1288
|
+
};
|
|
1289
|
+
this.#reports.push(captured);
|
|
1290
|
+
if (this.#onReport) try {
|
|
1291
|
+
this.#onReport(captured);
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
console.error("CmcdReportRecorder onReport listener threw:", err);
|
|
1294
|
+
}
|
|
1295
|
+
this.#notifyWaiters();
|
|
1296
|
+
return isEventTarget ? new Response(null, { status: 204 }) : void 0;
|
|
1297
|
+
};
|
|
1298
|
+
#getMatching(type) {
|
|
1299
|
+
return type === void 0 ? [...this.#reports] : this.#reports.filter((r) => r.type === type);
|
|
1300
|
+
}
|
|
1301
|
+
#notifyWaiters() {
|
|
1302
|
+
for (const [timer, waiter] of this.#waiters) {
|
|
1303
|
+
const matching = this.#getMatching(waiter.type);
|
|
1304
|
+
if (matching.length >= waiter.count) {
|
|
1305
|
+
clearTimeout(timer);
|
|
1306
|
+
this.#waiters.delete(timer);
|
|
1307
|
+
waiter.resolve(matching);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
#waitFor(type, options) {
|
|
1312
|
+
const count = options.count ?? 1;
|
|
1313
|
+
const timeout = options.timeout ?? this.#waitTimeout;
|
|
1314
|
+
const matching = this.#getMatching(type);
|
|
1315
|
+
if (matching.length >= count) return Promise.resolve(matching);
|
|
1316
|
+
return new Promise((resolve, reject) => {
|
|
1317
|
+
const timer = setTimeout(() => {
|
|
1318
|
+
this.#waiters.delete(timer);
|
|
1319
|
+
const current = this.#getMatching(type);
|
|
1320
|
+
reject(/* @__PURE__ */ new Error(`Timeout waiting for ${count} ${type ?? "any"} CMCD report(s). Got ${current.length}. Total recorded: ${this.#reports.length}.`));
|
|
1321
|
+
}, timeout);
|
|
1322
|
+
this.#waiters.set(timer, {
|
|
1323
|
+
type,
|
|
1324
|
+
count,
|
|
1325
|
+
resolve,
|
|
1326
|
+
reject
|
|
1327
|
+
});
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Install transport patches and begin recording CMCD reports.
|
|
1332
|
+
* No-op if already attached — the previously supplied options are
|
|
1333
|
+
* retained and the new `options` argument is silently ignored.
|
|
1334
|
+
* Call `detach()` first if you need to re-attach with different
|
|
1335
|
+
* options.
|
|
1336
|
+
*
|
|
1337
|
+
* @public
|
|
1338
|
+
*/
|
|
1339
|
+
attach(options = {}) {
|
|
1340
|
+
if (this.#attached) return;
|
|
1341
|
+
this.#attached = true;
|
|
1342
|
+
this.#eventTargetUrls = options.eventTargetUrls ?? [];
|
|
1343
|
+
this.#onReport = options.onReport;
|
|
1344
|
+
this.#waitTimeout = options.waitTimeout ?? 15e3;
|
|
1345
|
+
const transports = options.transports ?? [createXhrTransport(), createFetchTransport()];
|
|
1346
|
+
for (const transport of transports) this.#detachers.push(transport.attach(this.#deliver));
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Remove transport patches and stop recording. Rejects any pending
|
|
1350
|
+
* wait promises with `Error('Recorder detached while waiting')`.
|
|
1351
|
+
*
|
|
1352
|
+
* @public
|
|
1353
|
+
*/
|
|
1354
|
+
detach() {
|
|
1355
|
+
if (!this.#attached) return;
|
|
1356
|
+
for (const detacher of this.#detachers) detacher();
|
|
1357
|
+
this.#detachers = [];
|
|
1358
|
+
this.#attached = false;
|
|
1359
|
+
this.#eventTargetUrls = [];
|
|
1360
|
+
this.#onReport = void 0;
|
|
1361
|
+
for (const [timer, waiter] of this.#waiters) {
|
|
1362
|
+
clearTimeout(timer);
|
|
1363
|
+
waiter.reject(/* @__PURE__ */ new Error("Recorder detached while waiting"));
|
|
1364
|
+
}
|
|
1365
|
+
this.#waiters.clear();
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Discard all recorded reports. Does not affect the attached state.
|
|
1369
|
+
*
|
|
1370
|
+
* @public
|
|
1371
|
+
*/
|
|
1372
|
+
clear() {
|
|
1373
|
+
this.#reports = [];
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Return a defensive copy of all recorded reports.
|
|
1377
|
+
*
|
|
1378
|
+
* @public
|
|
1379
|
+
*/
|
|
1380
|
+
getReports() {
|
|
1381
|
+
return [...this.#reports];
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Wait until at least `count` reports of any type are recorded.
|
|
1385
|
+
* Resolves with all matching reports; rejects with a diagnostic error
|
|
1386
|
+
* on timeout. `count` defaults to 1; `timeout` falls back to the
|
|
1387
|
+
* recorder's `waitTimeout` option (default 15000 ms).
|
|
1388
|
+
*
|
|
1389
|
+
* @public
|
|
1390
|
+
*/
|
|
1391
|
+
waitForReports(options = {}) {
|
|
1392
|
+
return this.#waitFor(void 0, options);
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Wait until at least `count` manifest reports are recorded.
|
|
1396
|
+
* Resolves with all matching reports; rejects with a diagnostic error
|
|
1397
|
+
* on timeout. `count` defaults to 1; `timeout` falls back to the
|
|
1398
|
+
* recorder's `waitTimeout` option (default 15000 ms).
|
|
1399
|
+
*
|
|
1400
|
+
* @public
|
|
1401
|
+
*/
|
|
1402
|
+
waitForManifest(options = {}) {
|
|
1403
|
+
return this.#waitFor(CmcdRecordedRequestType.MANIFEST, options);
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Wait until at least `count` segment reports are recorded.
|
|
1407
|
+
* Resolves with all matching reports; rejects with a diagnostic error
|
|
1408
|
+
* on timeout. `count` defaults to 1; `timeout` falls back to the
|
|
1409
|
+
* recorder's `waitTimeout` option (default 15000 ms).
|
|
1410
|
+
*
|
|
1411
|
+
* @public
|
|
1412
|
+
*/
|
|
1413
|
+
waitForSegments(options = {}) {
|
|
1414
|
+
return this.#waitFor(CmcdRecordedRequestType.SEGMENT, options);
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Wait until at least `count` event-mode reports are recorded.
|
|
1418
|
+
* Resolves with all matching reports; rejects with a diagnostic error
|
|
1419
|
+
* on timeout. `count` defaults to 1; `timeout` falls back to the
|
|
1420
|
+
* recorder's `waitTimeout` option (default 15000 ms).
|
|
1421
|
+
*
|
|
1422
|
+
* @public
|
|
1423
|
+
*/
|
|
1424
|
+
waitForEvents(options = {}) {
|
|
1425
|
+
return this.#waitFor(CmcdRecordedRequestType.EVENT, options);
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
|
|
982
1429
|
//#endregion
|
|
983
1430
|
//#region src/CmcdTransmissionMode.ts
|
|
984
1431
|
/**
|
|
@@ -1025,6 +1472,53 @@ function createEncodingOptions(reportingMode, config, baseUrl) {
|
|
|
1025
1472
|
baseUrl
|
|
1026
1473
|
};
|
|
1027
1474
|
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Deep equality for CmcdObjectTypeList (used for `br` dedup).
|
|
1477
|
+
*
|
|
1478
|
+
* Order-sensitive: arrays with the same elements in different positions
|
|
1479
|
+
* are treated as different. Players that construct `br` consistently
|
|
1480
|
+
* get correct dedup; shuffling produces spurious emits, which is the
|
|
1481
|
+
* safer failure mode.
|
|
1482
|
+
*/
|
|
1483
|
+
function cmcdObjectTypeListEqual(a, b) {
|
|
1484
|
+
if (a === b) return true;
|
|
1485
|
+
if (a.length !== b.length) return false;
|
|
1486
|
+
for (let i = 0; i < a.length; i++) {
|
|
1487
|
+
const ai = a[i];
|
|
1488
|
+
const bi = b[i];
|
|
1489
|
+
if (ai === bi) continue;
|
|
1490
|
+
if (typeof ai === "number" || typeof bi === "number") return false;
|
|
1491
|
+
if (ai.value !== bi.value) return false;
|
|
1492
|
+
const ap = ai.params;
|
|
1493
|
+
const bp = bi.params;
|
|
1494
|
+
const ak = ap && Object.keys(ap)[0];
|
|
1495
|
+
const bk = bp && Object.keys(bp)[0];
|
|
1496
|
+
if (ak !== bk) return false;
|
|
1497
|
+
if (ak !== void 0 && bk !== void 0 && ap && bp && ap[ak] !== bp[bk]) return false;
|
|
1498
|
+
}
|
|
1499
|
+
return true;
|
|
1500
|
+
}
|
|
1501
|
+
const equal = Object.is;
|
|
1502
|
+
const identity = (v) => v;
|
|
1503
|
+
/**
|
|
1504
|
+
* Maps each tracked state field to its event type and equality function.
|
|
1505
|
+
* Order matters: `update()` fires events in this order for multi-field updates.
|
|
1506
|
+
*/
|
|
1507
|
+
const STATE_FIELDS = Array.from(CMCD_STATE_EVENT_FIELDS, ([event, field]) => {
|
|
1508
|
+
if (field === "br") return {
|
|
1509
|
+
event,
|
|
1510
|
+
field,
|
|
1511
|
+
equal: (a, b) => a === void 0 || b === void 0 ? a === b : cmcdObjectTypeListEqual(a, b),
|
|
1512
|
+
snapshot: (v) => v.slice()
|
|
1513
|
+
};
|
|
1514
|
+
return {
|
|
1515
|
+
event,
|
|
1516
|
+
field,
|
|
1517
|
+
equal,
|
|
1518
|
+
snapshot: identity
|
|
1519
|
+
};
|
|
1520
|
+
});
|
|
1521
|
+
const STATE_FIELDS_BY_EVENT = new Map(STATE_FIELDS.map((e) => [e.event, e]));
|
|
1028
1522
|
function defaultRequester(request) {
|
|
1029
1523
|
const { url,...init } = request;
|
|
1030
1524
|
return fetch(url, init);
|
|
@@ -1070,6 +1564,7 @@ var CmcdReporter = class {
|
|
|
1070
1564
|
this.data = {};
|
|
1071
1565
|
this.msd = NaN;
|
|
1072
1566
|
this.eventTargets = /* @__PURE__ */ new Map();
|
|
1567
|
+
this.lastEmitted = {};
|
|
1073
1568
|
this.requestTarget = {
|
|
1074
1569
|
sn: 0,
|
|
1075
1570
|
msdSent: false
|
|
@@ -1098,7 +1593,7 @@ var CmcdReporter = class {
|
|
|
1098
1593
|
start() {
|
|
1099
1594
|
this.eventTargets.forEach((target, config) => {
|
|
1100
1595
|
this.disarmInterval(target);
|
|
1101
|
-
if (config.interval === 0 || !config.events.includes(
|
|
1596
|
+
if (config.interval === 0 || !config.events.includes(CMCD_EVENT_TIME_INTERVAL)) return;
|
|
1102
1597
|
const timeIntervalEvent = () => {
|
|
1103
1598
|
this.recordTargetEvent(target, config, CMCD_EVENT_TIME_INTERVAL);
|
|
1104
1599
|
this.processEventTargets();
|
|
@@ -1127,7 +1622,22 @@ var CmcdReporter = class {
|
|
|
1127
1622
|
this.processEventTargets(true);
|
|
1128
1623
|
}
|
|
1129
1624
|
/**
|
|
1130
|
-
* Updates the CMCD data.
|
|
1625
|
+
* Updates the CMCD data.
|
|
1626
|
+
*
|
|
1627
|
+
* Called by the player when data changes. For tracked state fields
|
|
1628
|
+
* (`sta`, `pr`, `cid`, `bg`, `br`), if the new value differs from the
|
|
1629
|
+
* last-reported value (the value most recently emitted on the wire for
|
|
1630
|
+
* that field), the corresponding state-change event is automatically
|
|
1631
|
+
* fired. Comparing against the last-reported value (rather than the
|
|
1632
|
+
* previous persisted value) ensures the first state-change event in a
|
|
1633
|
+
* new session always emits, even when the persisted value didn't change
|
|
1634
|
+
* across the `sid` boundary.
|
|
1635
|
+
*
|
|
1636
|
+
* Multi-field updates fire multiple events in the order: `sta` → `pr` →
|
|
1637
|
+
* `cid` → `bg` → `br`. The order of keys in the input object does not
|
|
1638
|
+
* affect the firing order.
|
|
1639
|
+
*
|
|
1640
|
+
* A `sid` change resets the dedup baseline.
|
|
1131
1641
|
*
|
|
1132
1642
|
* @param data - The data to update.
|
|
1133
1643
|
*/
|
|
@@ -1139,16 +1649,44 @@ var CmcdReporter = class {
|
|
|
1139
1649
|
...data,
|
|
1140
1650
|
msd: void 0
|
|
1141
1651
|
};
|
|
1652
|
+
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
1653
|
}
|
|
1143
1654
|
/**
|
|
1144
1655
|
* Records an event. Called by the player when an event occurs.
|
|
1145
1656
|
*
|
|
1657
|
+
* For state-change events (`PLAY_STATE`, `PLAYBACK_RATE`, `CONTENT_ID`,
|
|
1658
|
+
* `BACKGROUNDED_MODE`, `BITRATE_CHANGE`), this method:
|
|
1659
|
+
* 1. Persists the dedup field from `data` (if present) into the reporter's
|
|
1660
|
+
* persistent data store — equivalent to a write-through `update()`.
|
|
1661
|
+
* 2. Drops the event entirely if the dedup field has no value after the
|
|
1662
|
+
* write-through (never set, or cleared via `update({ field: undefined })`).
|
|
1663
|
+
* State-change events without their required field would violate CTA-5004-B.
|
|
1664
|
+
* 3. Suppresses the event if the field's current value matches the
|
|
1665
|
+
* last-emitted value (no state transition).
|
|
1666
|
+
*
|
|
1667
|
+
* For all other event types, the event is always emitted.
|
|
1668
|
+
*
|
|
1669
|
+
* Most callers can rely on {@link CmcdReporter.update} to auto-fire state-change events.
|
|
1670
|
+
* Use `recordEvent` directly when you need to attach extra context to the
|
|
1671
|
+
* event report (e.g., `bl`, `pt` at the moment of a play-state transition).
|
|
1672
|
+
*
|
|
1146
1673
|
* @param type - The type of event to record.
|
|
1147
1674
|
* @param data - Additional data to record with the event. This data
|
|
1148
|
-
* only applies to this event report
|
|
1149
|
-
*
|
|
1675
|
+
* only applies to this event report, except for the dedup
|
|
1676
|
+
* field of a state-change event, which is also persisted
|
|
1677
|
+
* into the reporter's data store.
|
|
1150
1678
|
*/
|
|
1151
1679
|
recordEvent(type, data = {}) {
|
|
1680
|
+
const entry = STATE_FIELDS_BY_EVENT.get(type);
|
|
1681
|
+
if (entry) {
|
|
1682
|
+
const field = entry.field;
|
|
1683
|
+
const incoming = data[field];
|
|
1684
|
+
if (incoming !== void 0) Object.assign(this.data, { [field]: incoming });
|
|
1685
|
+
const current = this.data[field];
|
|
1686
|
+
if (current === void 0) return;
|
|
1687
|
+
if (entry.equal(current, this.lastEmitted[field])) return;
|
|
1688
|
+
Object.assign(this.lastEmitted, { [field]: entry.snapshot(current) });
|
|
1689
|
+
}
|
|
1152
1690
|
this.eventTargets.forEach((target, config) => {
|
|
1153
1691
|
this.recordTargetEvent(target, config, type, data);
|
|
1154
1692
|
});
|
|
@@ -1351,6 +1889,7 @@ var CmcdReporter = class {
|
|
|
1351
1889
|
resetSession() {
|
|
1352
1890
|
this.eventTargets.forEach((target) => target.sn = 0);
|
|
1353
1891
|
this.requestTarget.sn = 0;
|
|
1892
|
+
this.lastEmitted = {};
|
|
1354
1893
|
}
|
|
1355
1894
|
};
|
|
1356
1895
|
|
|
@@ -1680,9 +2219,9 @@ function validateCmcdStructure(data, options) {
|
|
|
1680
2219
|
message: `Response key "${key}" must only be present when e="rr".`,
|
|
1681
2220
|
severity: CMCD_VALIDATION_SEVERITY_ERROR
|
|
1682
2221
|
});
|
|
1683
|
-
if (data["e"] ===
|
|
1684
|
-
key:
|
|
1685
|
-
message:
|
|
2222
|
+
for (const [eventType, requiredField] of CMCD_STATE_EVENT_FIELDS) if (data["e"] === eventType && !(requiredField in data)) issues.push({
|
|
2223
|
+
key: requiredField,
|
|
2224
|
+
message: `State-change event (e="${eventType}") requires the "${requiredField}" key to be present.`,
|
|
1686
2225
|
severity: CMCD_VALIDATION_SEVERITY_ERROR
|
|
1687
2226
|
});
|
|
1688
2227
|
if (data["e"] === CMCD_EVENT_ERROR && !("ec" in data)) issues.push({
|
|
@@ -1860,6 +2399,7 @@ const CMCD_TOKEN_VALUES = {
|
|
|
1860
2399
|
e: [
|
|
1861
2400
|
"bc",
|
|
1862
2401
|
"ps",
|
|
2402
|
+
"pr",
|
|
1863
2403
|
"e",
|
|
1864
2404
|
"t",
|
|
1865
2405
|
"c",
|
|
@@ -2377,5 +2917,5 @@ function extractHeaderRecord(headers) {
|
|
|
2377
2917
|
}
|
|
2378
2918
|
|
|
2379
2919
|
//#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 };
|
|
2920
|
+
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
2921
|
//# sourceMappingURL=index.js.map
|