vastlint-client 0.4.20

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.
@@ -0,0 +1,818 @@
1
+ import { fix, fixWithOptions, inspectDocument, validate, validateWithOptions } from "vastlint";
2
+ import { buildResolvedState } from "./resolved.js";
3
+ import { buildTrackingPlan, createEmptyTrackingPlan, expandTrackingUrl, selectTrackingTargets } from "./tracking.js";
4
+ function createTimestamp() {
5
+ return new Date().toISOString();
6
+ }
7
+ function defaultMaxWrapperDepth(options) {
8
+ const configuredDepth = options.maxWrapperDepth ?? options.validateOptions?.max_wrapper_depth ?? 5;
9
+ return configuredDepth > 0 ? configuredDepth : 5;
10
+ }
11
+ function resolveWrapperUri(wrapperUri, currentUrl) {
12
+ if (currentUrl) {
13
+ return new URL(wrapperUri, currentUrl).toString();
14
+ }
15
+ return new URL(wrapperUri).toString();
16
+ }
17
+ function buildResolutionSummary(wrapperChain, stoppedReason) {
18
+ const totals = wrapperChain.reduce((summary, hop) => {
19
+ if (!hop.validation) {
20
+ return summary;
21
+ }
22
+ return {
23
+ totalErrors: summary.totalErrors + hop.validation.summary.errors,
24
+ totalWarnings: summary.totalWarnings + hop.validation.summary.warnings,
25
+ totalInfos: summary.totalInfos + hop.validation.summary.infos,
26
+ };
27
+ }, {
28
+ totalErrors: 0,
29
+ totalWarnings: 0,
30
+ totalInfos: 0,
31
+ });
32
+ const lastHop = wrapperChain[wrapperChain.length - 1] ?? null;
33
+ const resolved = lastHop?.adType === "InLine";
34
+ return {
35
+ hopCount: wrapperChain.length,
36
+ resolved,
37
+ chainValid: totals.totalErrors === 0,
38
+ totalErrors: totals.totalErrors,
39
+ totalWarnings: totals.totalWarnings,
40
+ totalInfos: totals.totalInfos,
41
+ stoppedReason,
42
+ };
43
+ }
44
+ function cloneTrackingState(tracking) {
45
+ return {
46
+ plan: {
47
+ impressions: tracking.plan.impressions.map((target) => ({ ...target })),
48
+ errors: tracking.plan.errors.map((target) => ({ ...target })),
49
+ clickTrackings: tracking.plan.clickTrackings.map((target) => ({ ...target })),
50
+ clickThroughs: tracking.plan.clickThroughs.map((target) => ({ ...target })),
51
+ events: tracking.plan.events.map((target) => ({ ...target })),
52
+ },
53
+ history: tracking.history.map((entry) => ({ ...entry })),
54
+ };
55
+ }
56
+ function cloneTrackingPlan(plan) {
57
+ return {
58
+ impressions: plan.impressions.map((target) => ({ ...target })),
59
+ errors: plan.errors.map((target) => ({ ...target })),
60
+ clickTrackings: plan.clickTrackings.map((target) => ({ ...target })),
61
+ clickThroughs: plan.clickThroughs.map((target) => ({ ...target })),
62
+ events: plan.events.map((target) => ({ ...target })),
63
+ };
64
+ }
65
+ function cloneCompanionAd(companion) {
66
+ return {
67
+ ...companion,
68
+ resources: companion.resources.map((resource) => ({ ...resource })),
69
+ clickTrackingUrls: [...companion.clickTrackingUrls],
70
+ trackingEvents: Object.fromEntries(Object.entries(companion.trackingEvents).map(([event, urls]) => [event, [...urls]])),
71
+ };
72
+ }
73
+ function cloneCompanions(companions) {
74
+ return companions.map((companion) => cloneCompanionAd(companion));
75
+ }
76
+ function cloneResolvedAd(resolvedAd) {
77
+ if (!resolvedAd) {
78
+ return null;
79
+ }
80
+ return {
81
+ ...resolvedAd,
82
+ mediaFiles: resolvedAd.mediaFiles.map((mediaFile) => ({ ...mediaFile })),
83
+ companions: cloneCompanions(resolvedAd.companions),
84
+ icons: resolvedAd.icons.map((icon) => ({
85
+ ...icon,
86
+ resources: icon.resources.map((resource) => ({ ...resource })),
87
+ clickTrackingUrls: [...icon.clickTrackingUrls],
88
+ viewTrackingUrls: [...icon.viewTrackingUrls],
89
+ })),
90
+ universalAdIds: resolvedAd.universalAdIds.map((universalAdId) => ({ ...universalAdId })),
91
+ categories: resolvedAd.categories.map((category) => ({ ...category })),
92
+ adVerifications: resolvedAd.adVerifications.map((verification) => ({
93
+ ...verification,
94
+ resources: verification.resources.map((resource) => ({ ...resource })),
95
+ })),
96
+ adPod: { ...resolvedAd.adPod },
97
+ impressionUrls: [...resolvedAd.impressionUrls],
98
+ errorUrls: [...resolvedAd.errorUrls],
99
+ clickTrackingUrls: [...resolvedAd.clickTrackingUrls],
100
+ clickThroughUrls: [...resolvedAd.clickThroughUrls],
101
+ trackingPlan: cloneTrackingPlan(resolvedAd.trackingPlan),
102
+ trackingEvents: Object.fromEntries(Object.entries(resolvedAd.trackingEvents).map(([event, urls]) => [event, [...urls]])),
103
+ };
104
+ }
105
+ function cloneResolvedAds(resolvedAds) {
106
+ return resolvedAds.map((resolvedAd) => cloneResolvedAd(resolvedAd)).filter((resolvedAd) => resolvedAd !== null);
107
+ }
108
+ function cloneSnapshot(snapshot) {
109
+ return {
110
+ ...snapshot,
111
+ events: [...snapshot.events],
112
+ tracking: cloneTrackingState(snapshot.tracking),
113
+ resolvedAd: cloneResolvedAd(snapshot.resolvedAd),
114
+ resolvedAds: cloneResolvedAds(snapshot.resolvedAds),
115
+ wrapperChain: snapshot.wrapperChain.map((hop) => ({
116
+ ...hop,
117
+ mediaFiles: hop.mediaFiles.map((mediaFile) => ({ ...mediaFile })),
118
+ })),
119
+ };
120
+ }
121
+ function toError(value) {
122
+ if (value instanceof Error) {
123
+ return value;
124
+ }
125
+ return new Error(typeof value === "string" ? value : "Unknown vastlint-client error.");
126
+ }
127
+ export function createVastSession(options) {
128
+ const listeners = new Set();
129
+ const fetchRequest = options.source.kind === "url" ? options.source.request : undefined;
130
+ const trackingHistory = [];
131
+ const dispatchedTrackingKeys = new Set();
132
+ const stripTracking = (current) => {
133
+ if ("tracking" in current) {
134
+ const { tracking: _tracking, resolvedAd: _resolvedAd, resolvedAds: _resolvedAds, ...base } = current;
135
+ return base;
136
+ }
137
+ return current;
138
+ };
139
+ const buildTrackingState = (current) => {
140
+ const trackingHops = current.wrapperChain.length
141
+ ? current.wrapperChain.map((hop) => ({
142
+ index: hop.index,
143
+ url: hop.url,
144
+ xml: hop.xml,
145
+ }))
146
+ : current.xml
147
+ ? [
148
+ {
149
+ index: 0,
150
+ url: current.source.kind === "url" ? current.source.url : null,
151
+ xml: current.xml,
152
+ },
153
+ ]
154
+ : [];
155
+ return {
156
+ plan: trackingHops.length ? buildTrackingPlan(trackingHops) : createEmptyTrackingPlan(),
157
+ history: trackingHistory.map((entry) => ({ ...entry })),
158
+ };
159
+ };
160
+ const withTracking = (next) => {
161
+ const base = stripTracking(next);
162
+ const tracking = buildTrackingState(base);
163
+ const resolvedState = buildResolvedState(base.wrapperChain, base.resolution);
164
+ return {
165
+ ...base,
166
+ tracking,
167
+ resolvedAd: resolvedState.resolvedAd,
168
+ resolvedAds: resolvedState.resolvedAds,
169
+ };
170
+ };
171
+ let snapshot = withTracking({
172
+ status: "idle",
173
+ source: options.source,
174
+ xml: options.source.kind === "xml" ? options.source.xml : null,
175
+ rootXml: options.source.kind === "xml" ? options.source.xml : null,
176
+ validation: null,
177
+ fixed: null,
178
+ wrapperChain: [],
179
+ resolution: null,
180
+ resolvedAds: [],
181
+ events: [],
182
+ error: null,
183
+ });
184
+ const notify = () => {
185
+ const current = cloneSnapshot(snapshot);
186
+ for (const listener of listeners) {
187
+ listener(current);
188
+ }
189
+ };
190
+ const setSnapshot = (next) => {
191
+ snapshot = withTracking(next);
192
+ notify();
193
+ };
194
+ const emit = (type, detail) => {
195
+ const event = detail
196
+ ? { type, timestamp: createTimestamp(), detail }
197
+ : { type, timestamp: createTimestamp() };
198
+ snapshot = withTracking({
199
+ ...snapshot,
200
+ events: [...snapshot.events, event],
201
+ });
202
+ notify();
203
+ };
204
+ const setError = (error) => {
205
+ const nextError = toError(error);
206
+ snapshot = withTracking({
207
+ ...snapshot,
208
+ status: "error",
209
+ error: nextError,
210
+ });
211
+ emit("session:error", { message: nextError.message });
212
+ };
213
+ const buildTrackingDispatchKey = (event, url, hopIndex, offset) => `${event}:${hopIndex}:${offset ?? ""}:${url}`;
214
+ const getScopedDispatchKey = (scope, event, url, hopIndex, offset) => {
215
+ const base = buildTrackingDispatchKey(event, url, hopIndex, offset);
216
+ return scope ? `${scope}:${base}` : base;
217
+ };
218
+ const dispatchTrackingTargets = async (event, availableTargets, trackOptions, scope) => {
219
+ const dedupe = trackOptions.dedupe ?? true;
220
+ const targets = dedupe
221
+ ? availableTargets.filter((target) => !dispatchedTrackingKeys.has(getScopedDispatchKey(scope, event, target.url, target.hopIndex, target.offset)))
222
+ : [...availableTargets];
223
+ if (targets.length === 0) {
224
+ emit("track:completed", {
225
+ event,
226
+ adIndex: scope?.startsWith("ad:") ? Number.parseInt(scope.slice(3), 10) : null,
227
+ requested: availableTargets.length,
228
+ dispatched: 0,
229
+ succeeded: 0,
230
+ failed: 0,
231
+ });
232
+ return [];
233
+ }
234
+ const fetchImpl = options.fetch ?? globalThis.fetch;
235
+ if (typeof fetchImpl !== "function") {
236
+ throw new Error("No fetch implementation is available for VAST tracking dispatch.");
237
+ }
238
+ const results = await Promise.all(targets.map(async (target) => {
239
+ const resolvedUrl = expandTrackingUrl(target.url, trackOptions.macros);
240
+ try {
241
+ const response = await fetchImpl(resolvedUrl, { method: "GET" });
242
+ return {
243
+ event,
244
+ url: target.url,
245
+ resolvedUrl,
246
+ hopIndex: target.hopIndex,
247
+ sourceUrl: target.sourceUrl,
248
+ offset: target.offset,
249
+ ok: response.ok,
250
+ status: response.status,
251
+ dispatchedAt: createTimestamp(),
252
+ error: null,
253
+ };
254
+ }
255
+ catch (dispatchError) {
256
+ return {
257
+ event,
258
+ url: target.url,
259
+ resolvedUrl,
260
+ hopIndex: target.hopIndex,
261
+ sourceUrl: target.sourceUrl,
262
+ offset: target.offset,
263
+ ok: false,
264
+ status: null,
265
+ dispatchedAt: createTimestamp(),
266
+ error: toError(dispatchError).message,
267
+ };
268
+ }
269
+ }));
270
+ for (const target of targets) {
271
+ dispatchedTrackingKeys.add(getScopedDispatchKey(scope, event, target.url, target.hopIndex, target.offset));
272
+ }
273
+ trackingHistory.push(...results);
274
+ setSnapshot(snapshot);
275
+ const succeeded = results.filter((result) => result.ok).length;
276
+ emit("track:completed", {
277
+ event,
278
+ adIndex: scope?.startsWith("ad:") ? Number.parseInt(scope.slice(3), 10) : null,
279
+ requested: availableTargets.length,
280
+ dispatched: results.length,
281
+ succeeded,
282
+ failed: results.length - succeeded,
283
+ });
284
+ return results;
285
+ };
286
+ const getResolvedAdAtIndex = (adIndex) => {
287
+ if (!Number.isInteger(adIndex) || adIndex < 0) {
288
+ throw new Error(`Ad index must be a non-negative integer, got ${String(adIndex)}.`);
289
+ }
290
+ const resolvedAd = snapshot.resolvedAds[adIndex] ?? null;
291
+ if (!resolvedAd) {
292
+ throw new Error(`Resolved ad at index ${String(adIndex)} is unavailable. Call resolve() first and use a valid ad index.`);
293
+ }
294
+ return {
295
+ adIndex,
296
+ resolvedAd,
297
+ };
298
+ };
299
+ const findResolvedAd = (predicate, description) => {
300
+ let match = null;
301
+ for (const [adIndex, resolvedAd] of snapshot.resolvedAds.entries()) {
302
+ if (!predicate(resolvedAd)) {
303
+ continue;
304
+ }
305
+ if (match) {
306
+ throw new Error(`Resolved ad selector ${description} matched multiple ads. Use adIndex to disambiguate.`);
307
+ }
308
+ match = {
309
+ adIndex,
310
+ resolvedAd,
311
+ };
312
+ }
313
+ if (!match) {
314
+ throw new Error(`Resolved ad for ${description} is unavailable. Call resolve() first and use a valid selector.`);
315
+ }
316
+ return match;
317
+ };
318
+ const describeAdSelector = (adSelector) => {
319
+ if (typeof adSelector === "number") {
320
+ return `adIndex ${String(adSelector)}`;
321
+ }
322
+ if ("adIndex" in adSelector) {
323
+ return `adIndex ${String(adSelector.adIndex)}`;
324
+ }
325
+ if ("adId" in adSelector) {
326
+ return `adId '${adSelector.adId}'`;
327
+ }
328
+ return `sequence ${String(adSelector.sequence)}`;
329
+ };
330
+ const buildTrackAdStartedDetail = (adSelector, event, offset) => {
331
+ const detail = {
332
+ event,
333
+ offset,
334
+ };
335
+ if (typeof adSelector === "number") {
336
+ detail.adIndex = adSelector;
337
+ return detail;
338
+ }
339
+ if ("adIndex" in adSelector) {
340
+ detail.adIndex = adSelector.adIndex;
341
+ return detail;
342
+ }
343
+ if ("adId" in adSelector) {
344
+ detail.adId = adSelector.adId;
345
+ return detail;
346
+ }
347
+ detail.sequence = adSelector.sequence;
348
+ return detail;
349
+ };
350
+ const getResolvedAdSelection = (adSelector) => {
351
+ if (typeof adSelector === "number") {
352
+ return getResolvedAdAtIndex(adSelector);
353
+ }
354
+ if ("adIndex" in adSelector) {
355
+ return getResolvedAdAtIndex(adSelector.adIndex);
356
+ }
357
+ if ("adId" in adSelector) {
358
+ const adId = adSelector.adId.trim();
359
+ if (!adId) {
360
+ throw new Error("Ad selector adId must be a non-empty string.");
361
+ }
362
+ return findResolvedAd((resolvedAd) => resolvedAd.adPod.adId === adId, describeAdSelector({ adId }));
363
+ }
364
+ const sequence = adSelector.sequence;
365
+ if (!Number.isInteger(sequence) || sequence < 1) {
366
+ throw new Error(`Ad selector sequence must be a positive integer, got ${String(sequence)}.`);
367
+ }
368
+ return findResolvedAd((resolvedAd) => resolvedAd.adPod.sequence === sequence, describeAdSelector({ sequence }));
369
+ };
370
+ const getCompanionAtIndex = (resolvedAd, companionIndex) => {
371
+ if (!Number.isInteger(companionIndex) || companionIndex < 0) {
372
+ throw new Error(`Companion index must be a non-negative integer, got ${String(companionIndex)}.`);
373
+ }
374
+ const companion = resolvedAd.companions[companionIndex] ?? null;
375
+ if (!companion) {
376
+ throw new Error(`Companion at index ${String(companionIndex)} is unavailable for the selected VAST ad.`);
377
+ }
378
+ return {
379
+ companionIndex,
380
+ companion,
381
+ };
382
+ };
383
+ const findCompanion = (resolvedAd, predicate, description) => {
384
+ let match = null;
385
+ for (const [companionIndex, companion] of resolvedAd.companions.entries()) {
386
+ if (!predicate(companion)) {
387
+ continue;
388
+ }
389
+ if (match) {
390
+ throw new Error(`Companion selector ${description} matched multiple companions. Use companionIndex to disambiguate.`);
391
+ }
392
+ match = {
393
+ companionIndex,
394
+ companion,
395
+ };
396
+ }
397
+ if (!match) {
398
+ throw new Error(`Companion for ${description} is unavailable on the selected VAST ad.`);
399
+ }
400
+ return match;
401
+ };
402
+ const describeCompanionSelector = (companionSelector) => {
403
+ if (typeof companionSelector === "number") {
404
+ return `companionIndex ${String(companionSelector)}`;
405
+ }
406
+ if ("companionIndex" in companionSelector) {
407
+ return `companionIndex ${String(companionSelector.companionIndex)}`;
408
+ }
409
+ if ("companionId" in companionSelector) {
410
+ return `companionId '${companionSelector.companionId}'`;
411
+ }
412
+ return `adSlotId '${companionSelector.adSlotId}'`;
413
+ };
414
+ const getCompanionSelection = (resolvedAd, companionSelector) => {
415
+ if (typeof companionSelector === "number") {
416
+ return getCompanionAtIndex(resolvedAd, companionSelector);
417
+ }
418
+ if ("companionIndex" in companionSelector) {
419
+ return getCompanionAtIndex(resolvedAd, companionSelector.companionIndex);
420
+ }
421
+ if ("companionId" in companionSelector) {
422
+ const companionId = companionSelector.companionId.trim();
423
+ if (!companionId) {
424
+ throw new Error("Companion selector companionId must be a non-empty string.");
425
+ }
426
+ return findCompanion(resolvedAd, (companion) => companion.id === companionId, describeCompanionSelector({ companionId }));
427
+ }
428
+ const adSlotId = companionSelector.adSlotId.trim();
429
+ if (!adSlotId) {
430
+ throw new Error("Companion selector adSlotId must be a non-empty string.");
431
+ }
432
+ return findCompanion(resolvedAd, (companion) => companion.adSlotId === adSlotId, describeCompanionSelector({ adSlotId }));
433
+ };
434
+ const buildTrackCompanionStartedDetail = (adSelector, companionSelector, event) => ({
435
+ ...buildTrackAdStartedDetail(adSelector, event, null),
436
+ companion: describeCompanionSelector(companionSelector),
437
+ });
438
+ const buildCompanionTrackingTargets = (resolvedAd, companion, event) => {
439
+ const hopIndex = resolvedAd.finalHopIndex ?? 0;
440
+ const sourceUrl = resolvedAd.finalUrl;
441
+ if (event === "clickTracking") {
442
+ return companion.clickTrackingUrls.map((url) => ({
443
+ kind: "clickTracking",
444
+ event,
445
+ url,
446
+ hopIndex,
447
+ sourceUrl,
448
+ offset: null,
449
+ }));
450
+ }
451
+ return (companion.trackingEvents[event] ?? []).map((url) => ({
452
+ kind: "event",
453
+ event,
454
+ url,
455
+ hopIndex,
456
+ sourceUrl,
457
+ offset: null,
458
+ }));
459
+ };
460
+ const buildHop = (xml, index, source, url, fetchMs, validation) => {
461
+ const meta = inspectDocument(xml);
462
+ return {
463
+ index,
464
+ source,
465
+ url,
466
+ xml,
467
+ fetchedAt: createTimestamp(),
468
+ fetchMs,
469
+ adType: meta.adType,
470
+ adSystem: meta.adSystem,
471
+ adTitle: meta.adTitle,
472
+ duration: meta.duration,
473
+ impressionCount: meta.impressionCount,
474
+ trackingEventCount: meta.trackingEventCount,
475
+ companionCount: meta.companionCount,
476
+ mediaFiles: meta.mediaFiles,
477
+ wrapperUri: meta.wrapperUri,
478
+ validation,
479
+ };
480
+ };
481
+ const validateXmlAtDepth = (xml, wrapperDepth) => {
482
+ const maxWrapperDepth = defaultMaxWrapperDepth(options);
483
+ const validateOptions = {
484
+ ...options.validateOptions,
485
+ wrapper_depth: wrapperDepth,
486
+ max_wrapper_depth: maxWrapperDepth,
487
+ };
488
+ if (!options.validateOptions && wrapperDepth === 0 && maxWrapperDepth === 5) {
489
+ return validate(xml);
490
+ }
491
+ return validateWithOptions(xml, validateOptions);
492
+ };
493
+ const fetchXmlFromUrl = async (url) => {
494
+ const fetchImpl = options.fetch ?? globalThis.fetch;
495
+ if (typeof fetchImpl !== "function") {
496
+ throw new Error("No fetch implementation is available for URL-backed VAST sessions.");
497
+ }
498
+ const controller = typeof AbortController === "function" && options.timeoutMs ? new AbortController() : null;
499
+ const timeoutId = controller
500
+ ? setTimeout(() => controller.abort(new Error(`Timed out fetching VAST URL after ${options.timeoutMs} ms`)), options.timeoutMs)
501
+ : null;
502
+ const startedAt = Date.now();
503
+ try {
504
+ const resolvedSignal = controller?.signal ?? fetchRequest?.signal ?? null;
505
+ const requestInit = resolvedSignal
506
+ ? {
507
+ ...(fetchRequest ?? {}),
508
+ signal: resolvedSignal,
509
+ }
510
+ : { ...(fetchRequest ?? {}) };
511
+ const response = await fetchImpl(url, requestInit);
512
+ if (!response.ok) {
513
+ throw new Error(`Failed to load VAST URL: ${response.status} ${response.statusText}`);
514
+ }
515
+ return {
516
+ xml: await response.text(),
517
+ fetchMs: Date.now() - startedAt,
518
+ };
519
+ }
520
+ finally {
521
+ if (timeoutId) {
522
+ clearTimeout(timeoutId);
523
+ }
524
+ }
525
+ };
526
+ const loadXml = async () => {
527
+ if (snapshot.xml) {
528
+ return snapshot.xml;
529
+ }
530
+ emit("source:loading", {
531
+ sourceKind: snapshot.source.kind,
532
+ label: snapshot.source.label ?? null,
533
+ });
534
+ snapshot = {
535
+ ...snapshot,
536
+ status: "loading",
537
+ error: null,
538
+ };
539
+ notify();
540
+ try {
541
+ let xml = "";
542
+ let fetchMs = 0;
543
+ if (snapshot.source.kind === "xml") {
544
+ xml = snapshot.source.xml;
545
+ }
546
+ else {
547
+ const fetched = await fetchXmlFromUrl(snapshot.source.url);
548
+ xml = fetched.xml;
549
+ fetchMs = fetched.fetchMs;
550
+ }
551
+ const nextSnapshot = {
552
+ ...snapshot,
553
+ status: "ready",
554
+ xml,
555
+ rootXml: xml,
556
+ wrapperChain: [
557
+ buildHop(xml, 0, snapshot.source, snapshot.source.kind === "url" ? snapshot.source.url : null, fetchMs, null),
558
+ ],
559
+ resolution: null,
560
+ };
561
+ setSnapshot(nextSnapshot);
562
+ emit("source:loaded", { bytes: xml.length, hops: nextSnapshot.wrapperChain.length });
563
+ return xml;
564
+ }
565
+ catch (error) {
566
+ setError(error);
567
+ throw toError(error);
568
+ }
569
+ };
570
+ const runValidation = async () => {
571
+ emit("validate:started", {
572
+ hasOptions: Boolean(options.validateOptions),
573
+ });
574
+ snapshot = {
575
+ ...snapshot,
576
+ status: "validating",
577
+ error: null,
578
+ };
579
+ notify();
580
+ try {
581
+ const xml = await loadXml();
582
+ const result = validateXmlAtDepth(xml, 0);
583
+ const wrapperChain = snapshot.wrapperChain.length
584
+ ? snapshot.wrapperChain.map((hop, index) => (index === 0 ? { ...hop, validation: result } : hop))
585
+ : [
586
+ {
587
+ ...buildHop(xml, 0, snapshot.source, snapshot.source.kind === "url" ? snapshot.source.url : null, 0, null),
588
+ validation: result,
589
+ },
590
+ ];
591
+ setSnapshot({
592
+ ...snapshot,
593
+ status: "ready",
594
+ validation: result,
595
+ rootXml: snapshot.rootXml ?? xml,
596
+ wrapperChain,
597
+ });
598
+ emit("validate:completed", {
599
+ errors: result.summary.errors,
600
+ warnings: result.summary.warnings,
601
+ infos: result.summary.infos,
602
+ });
603
+ return result;
604
+ }
605
+ catch (error) {
606
+ setError(error);
607
+ throw toError(error);
608
+ }
609
+ };
610
+ emit("session:created", {
611
+ sourceKind: snapshot.source.kind,
612
+ timeoutMs: options.timeoutMs ?? null,
613
+ maxWrapperDepth: options.maxWrapperDepth ?? null,
614
+ });
615
+ const resolveSession = async () => {
616
+ emit("resolve:started", {
617
+ strategy: "wrapper-chain",
618
+ maxWrapperDepth: defaultMaxWrapperDepth(options),
619
+ });
620
+ snapshot = {
621
+ ...snapshot,
622
+ status: "loading",
623
+ error: null,
624
+ };
625
+ notify();
626
+ try {
627
+ const rootXml = await loadXml();
628
+ const rootUrl = snapshot.source.kind === "url" ? snapshot.source.url : null;
629
+ const wrapperChain = [];
630
+ const seenUrls = new Set(rootUrl ? [rootUrl] : []);
631
+ const maxWrapperDepth = defaultMaxWrapperDepth(options);
632
+ let currentXml = rootXml;
633
+ let currentUrl = rootUrl;
634
+ let currentSource = snapshot.source;
635
+ let currentFetchMs = snapshot.wrapperChain[0]?.fetchMs ?? 0;
636
+ let stoppedReason = "resolved";
637
+ for (let hopIndex = 0; hopIndex < maxWrapperDepth; hopIndex++) {
638
+ const validation = validateXmlAtDepth(currentXml, hopIndex);
639
+ const hop = buildHop(currentXml, hopIndex, currentSource, currentUrl, currentFetchMs, validation);
640
+ wrapperChain.push(hop);
641
+ emit("resolve:hop", {
642
+ index: hopIndex,
643
+ url: currentUrl,
644
+ adType: hop.adType,
645
+ wrapperUri: hop.wrapperUri,
646
+ errors: validation.summary.errors,
647
+ warnings: validation.summary.warnings,
648
+ });
649
+ if (hop.adType !== "Wrapper") {
650
+ stoppedReason = hop.adType === "InLine" ? "resolved" : "parse_error: Could not determine ad type";
651
+ break;
652
+ }
653
+ if (!hop.wrapperUri) {
654
+ stoppedReason = "parse_error: Wrapper has no VASTAdTagURI";
655
+ break;
656
+ }
657
+ let nextUrl;
658
+ try {
659
+ nextUrl = resolveWrapperUri(hop.wrapperUri, currentUrl);
660
+ }
661
+ catch {
662
+ stoppedReason = `parse_error: Could not resolve wrapper URI '${hop.wrapperUri}'`;
663
+ break;
664
+ }
665
+ if (seenUrls.has(nextUrl)) {
666
+ stoppedReason = `parse_error: Circular wrapper chain detected at ${nextUrl}`;
667
+ break;
668
+ }
669
+ seenUrls.add(nextUrl);
670
+ if (hopIndex + 1 >= maxWrapperDepth) {
671
+ stoppedReason = "max_depth";
672
+ break;
673
+ }
674
+ const fetched = await fetchXmlFromUrl(nextUrl);
675
+ currentXml = fetched.xml;
676
+ currentUrl = nextUrl;
677
+ currentSource = { kind: "url", url: nextUrl };
678
+ currentFetchMs = fetched.fetchMs;
679
+ }
680
+ const lastHop = wrapperChain[wrapperChain.length - 1] ?? null;
681
+ const resolution = buildResolutionSummary(wrapperChain, stoppedReason);
682
+ setSnapshot({
683
+ ...snapshot,
684
+ status: "resolved",
685
+ xml: lastHop?.xml ?? rootXml,
686
+ rootXml,
687
+ validation: lastHop?.validation ?? null,
688
+ wrapperChain,
689
+ resolution,
690
+ });
691
+ emit("resolve:completed", {
692
+ hopCount: resolution.hopCount,
693
+ resolved: resolution.resolved,
694
+ stoppedReason: resolution.stoppedReason,
695
+ totalErrors: resolution.totalErrors,
696
+ totalWarnings: resolution.totalWarnings,
697
+ });
698
+ return cloneSnapshot(snapshot);
699
+ }
700
+ catch (error) {
701
+ setError(error);
702
+ throw toError(error);
703
+ }
704
+ };
705
+ return {
706
+ async load() {
707
+ await loadXml();
708
+ return cloneSnapshot(snapshot);
709
+ },
710
+ async validate() {
711
+ return runValidation();
712
+ },
713
+ async fix() {
714
+ emit("fix:started");
715
+ snapshot = {
716
+ ...snapshot,
717
+ status: "fixing",
718
+ error: null,
719
+ };
720
+ notify();
721
+ try {
722
+ const xml = await loadXml();
723
+ const result = options.validateOptions
724
+ ? fixWithOptions(xml, options.validateOptions)
725
+ : fix(xml);
726
+ setSnapshot({
727
+ ...snapshot,
728
+ status: "ready",
729
+ fixed: result,
730
+ });
731
+ emit("fix:completed", {
732
+ applied: result.applied.length,
733
+ remaining: result.remaining.length,
734
+ });
735
+ return result;
736
+ }
737
+ catch (error) {
738
+ setError(error);
739
+ throw toError(error);
740
+ }
741
+ },
742
+ getTracking() {
743
+ return cloneTrackingState(snapshot.tracking);
744
+ },
745
+ getAdCompanions(adSelector) {
746
+ const { resolvedAd } = getResolvedAdSelection(adSelector);
747
+ return cloneCompanions(resolvedAd.companions);
748
+ },
749
+ getAdTrackingTargets(adSelector, event, trackOptions) {
750
+ const { resolvedAd } = getResolvedAdSelection(adSelector);
751
+ return selectTrackingTargets(resolvedAd.trackingPlan, event, trackOptions?.offset);
752
+ },
753
+ getCompanionTrackingTargets(adSelector, companionSelector, event) {
754
+ const { resolvedAd } = getResolvedAdSelection(adSelector);
755
+ const { companion } = getCompanionSelection(resolvedAd, companionSelector);
756
+ return buildCompanionTrackingTargets(resolvedAd, companion, event);
757
+ },
758
+ async track(event, trackOptions = {}) {
759
+ emit("track:started", {
760
+ event,
761
+ offset: trackOptions.offset ?? null,
762
+ });
763
+ try {
764
+ await loadXml();
765
+ const availableTargets = selectTrackingTargets(snapshot.tracking.plan, event, trackOptions.offset);
766
+ return dispatchTrackingTargets(event, availableTargets, trackOptions, null);
767
+ }
768
+ catch (error) {
769
+ setError(error);
770
+ throw toError(error);
771
+ }
772
+ },
773
+ async trackAd(adSelector, event, trackOptions = {}) {
774
+ emit("track:started", buildTrackAdStartedDetail(adSelector, event, trackOptions.offset ?? null));
775
+ try {
776
+ if (snapshot.resolvedAds.length === 0) {
777
+ await resolveSession();
778
+ }
779
+ const { adIndex, resolvedAd } = getResolvedAdSelection(adSelector);
780
+ const availableTargets = selectTrackingTargets(resolvedAd.trackingPlan, event, trackOptions.offset);
781
+ return dispatchTrackingTargets(event, availableTargets, trackOptions, `ad:${String(adIndex)}`);
782
+ }
783
+ catch (error) {
784
+ setError(error);
785
+ throw toError(error);
786
+ }
787
+ },
788
+ async trackCompanion(adSelector, companionSelector, event, trackOptions = {}) {
789
+ emit("track:started", buildTrackCompanionStartedDetail(adSelector, companionSelector, event));
790
+ try {
791
+ if (snapshot.resolvedAds.length === 0) {
792
+ await resolveSession();
793
+ }
794
+ const { adIndex, resolvedAd } = getResolvedAdSelection(adSelector);
795
+ const { companionIndex, companion } = getCompanionSelection(resolvedAd, companionSelector);
796
+ const availableTargets = buildCompanionTrackingTargets(resolvedAd, companion, event);
797
+ return dispatchTrackingTargets(event, availableTargets, trackOptions, `ad:${String(adIndex)}:companion:${String(companionIndex)}`);
798
+ }
799
+ catch (error) {
800
+ setError(error);
801
+ throw toError(error);
802
+ }
803
+ },
804
+ async resolve() {
805
+ return resolveSession();
806
+ },
807
+ getSnapshot() {
808
+ return cloneSnapshot(snapshot);
809
+ },
810
+ subscribe(listener) {
811
+ listeners.add(listener);
812
+ listener(cloneSnapshot(snapshot));
813
+ return () => {
814
+ listeners.delete(listener);
815
+ };
816
+ },
817
+ };
818
+ }