@streamlayer/feature-advertisement 0.4.3 → 1.0.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/lib/advertisement.d.ts +46 -3
- package/lib/advertisement.js +216 -23
- package/lib/index.d.ts +10 -3
- package/lib/index.js +6 -0
- package/lib/units/external-ad.d.ts +53 -14
- package/lib/units/external-ad.js +365 -67
- package/lib/units/external-pause-ad.d.ts +32 -0
- package/lib/units/external-pause-ad.js +144 -3
- package/lib/units/gam.d.ts +16 -0
- package/lib/units/gam.js +99 -0
- package/lib/units/parse-vast-extensions.d.ts +17 -0
- package/lib/units/parse-vast-extensions.js +47 -0
- package/lib/units/pause-ad.d.ts +4 -1
- package/lib/units/ssai-ad-break.d.ts +21 -0
- package/lib/units/ssai-ad-break.js +6 -0
- package/lib/units/ssai-creative.d.ts +89 -0
- package/lib/units/ssai-creative.js +131 -0
- package/lib/units/ssai-detector.d.ts +180 -0
- package/lib/units/ssai-detector.js +1150 -0
- package/lib/units/ssai-preload.d.ts +20 -0
- package/lib/units/ssai-preload.js +55 -0
- package/lib/units/ssai-providers.d.ts +117 -0
- package/lib/units/ssai-providers.js +250 -0
- package/lib/units/vast-tracker.d.ts +45 -0
- package/lib/units/vast-tracker.js +57 -0
- package/package.json +9 -7
package/lib/units/external-ad.js
CHANGED
|
@@ -1,83 +1,381 @@
|
|
|
1
|
+
// Pins the `@dailymotion/vast-client` → `@types/vast-client` shim: TypeDoc
|
|
2
|
+
// compiles this file through dependent packages' programs, whose tsconfig
|
|
3
|
+
// `include` does not cover this package's ambient declarations.
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
|
5
|
+
/// <reference path="../module.d.ts" />
|
|
1
6
|
import { createLogger } from '@streamlayer/sdk-web-logger';
|
|
7
|
+
import { VASTClient, VASTParser, } from '@dailymotion/vast-client';
|
|
2
8
|
import '@streamlayer/sdk-web-api';
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
import VMAP from 'vmap';
|
|
10
|
+
import { gam } from './gam';
|
|
11
|
+
import { parseVastExtension } from './parse-vast-extensions';
|
|
12
|
+
const log = createLogger('ui:gam-external-ad');
|
|
13
|
+
/** Default budget for a single VAST resolution before it is treated as "no ad". */
|
|
14
|
+
const VAST_FETCH_TIMEOUT_MS = 10_000;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve `promise`, but fall back to `undefined` if it doesn't settle within `ms`.
|
|
17
|
+
* A slow/unreachable VAST endpoint must degrade to "no ad" instead of leaving the
|
|
18
|
+
* fetcher (and the sidebar) stuck waiting. Uses a timer rather than `AbortController`
|
|
19
|
+
* so it stays safe on the ES5/Chrome-32 target.
|
|
20
|
+
*/
|
|
21
|
+
const withTimeout = (promise, ms) => {
|
|
22
|
+
return Promise.race([
|
|
23
|
+
promise,
|
|
24
|
+
new Promise((resolve) => {
|
|
25
|
+
setTimeout(() => resolve(undefined), ms);
|
|
26
|
+
}),
|
|
27
|
+
]);
|
|
28
|
+
};
|
|
29
|
+
// const preloadImage = (src: string) => {
|
|
30
|
+
// return new Promise((resolve, reject) => {
|
|
31
|
+
// const image = new Image()
|
|
32
|
+
// image.onload = () => resolve(src)
|
|
33
|
+
// image.onerror = () => reject()
|
|
34
|
+
// image.src = src
|
|
35
|
+
// })
|
|
36
|
+
// }
|
|
37
|
+
/**
|
|
38
|
+
* Get the first valid creative from an ad
|
|
39
|
+
*/
|
|
40
|
+
const getCreative = (ad) => {
|
|
41
|
+
return ad.creatives.find((creative) => creative.mediaFiles);
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Get the URL of the first media file in the creative
|
|
45
|
+
*/
|
|
46
|
+
const getMediaFileUrl = (creative) => {
|
|
47
|
+
const mediaFiles = creative?.mediaFiles;
|
|
48
|
+
if (!mediaFiles?.length) {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
return mediaFiles.find((mediaFile) => mediaFile.fileURL)?.fileURL || '';
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Extract the linear creative's `<ClickThrough>` URL. Sources the sidebar CTA (QR code
|
|
55
|
+
* + click-through). `videoClickThroughURLTemplate` isn't strongly typed in the client lib.
|
|
56
|
+
*/
|
|
57
|
+
const getClickThrough = (creative) => {
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
59
|
+
// @ts-ignore — vast-client types `videoClickThroughURLTemplate` loosely
|
|
60
|
+
return creative?.videoClickThroughURLTemplate?.url || undefined;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Extract every `<Companion>` variation of the first `<CompanionAds>` creative that carries a
|
|
64
|
+
* `<StaticResource>`, in document order. `width`/`height` are XML attributes (strings at
|
|
65
|
+
* runtime), normalised to numbers so the size mapping can compare them.
|
|
66
|
+
*/
|
|
67
|
+
const getCompanions = (ad) => {
|
|
68
|
+
const creative = ad.creatives.find((item) => item.type === 'companion');
|
|
69
|
+
return (creative?.variations || []).reduce((acc, variation) => {
|
|
70
|
+
const imageUrl = variation?.staticResources?.[0]?.url;
|
|
71
|
+
if (!imageUrl) {
|
|
72
|
+
return acc;
|
|
73
|
+
}
|
|
74
|
+
return acc.concat({
|
|
75
|
+
imageUrl,
|
|
76
|
+
clickUrl: variation.companionClickThroughURLTemplate ?? undefined,
|
|
77
|
+
width: Number(variation.width) || undefined,
|
|
78
|
+
height: Number(variation.height) || undefined,
|
|
79
|
+
creative,
|
|
80
|
+
variation,
|
|
81
|
+
});
|
|
82
|
+
}, []);
|
|
83
|
+
};
|
|
84
|
+
/** Product companion mapping: the 300x75 companion is the logo, the banner-sized one the media. */
|
|
85
|
+
const LOGO_COMPANION_WIDTH = 300;
|
|
86
|
+
const LOGO_COMPANION_HEIGHT = 75;
|
|
87
|
+
const companionArea = (companion) => (companion.width || 0) * (companion.height || 0);
|
|
88
|
+
/**
|
|
89
|
+
* Split a `<CompanionAds>` creative's variations into the logo and media slots.
|
|
90
|
+
*
|
|
91
|
+
* The pair mapping activates only for a tag that ships an exact 300x75 companion next to at
|
|
92
|
+
* least one other. Deliberately conservative pending broader product sizes: on any other tag
|
|
93
|
+
* no companion is promoted to the logo slot and the first one stays the media/strip candidate.
|
|
94
|
+
*/
|
|
95
|
+
const selectCompanions = (companions) => {
|
|
96
|
+
const logo = companions.length > 1
|
|
97
|
+
? companions.find((companion) => companion.width === LOGO_COMPANION_WIDTH && companion.height === LOGO_COMPANION_HEIGHT)
|
|
98
|
+
: undefined;
|
|
99
|
+
if (!logo) {
|
|
100
|
+
return { logo: undefined, media: companions[0] };
|
|
101
|
+
}
|
|
102
|
+
const media = companions
|
|
103
|
+
.filter((companion) => companion !== logo)
|
|
104
|
+
.sort((a, b) => companionArea(a) - companionArea(b))
|
|
105
|
+
.pop();
|
|
106
|
+
return { logo, media };
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Extract the first `<NonLinearAds>` creative's static-image variation. Both the creative (which
|
|
110
|
+
* owns the `<TrackingEvents>`) and the variation (which owns the click-through) are kept —
|
|
111
|
+
* `VASTTracker` needs both.
|
|
112
|
+
*
|
|
113
|
+
* `@dailymotion/vast-client` 6.4.2 emits `nonlinearClickThroughURLTemplate` (lower-case `l`),
|
|
114
|
+
* which the `@types/vast-client` shim declares as `nonLinearClickThroughURLTemplate` — the
|
|
115
|
+
* runtime spelling is authoritative.
|
|
116
|
+
*/
|
|
117
|
+
const getNonLinear = (ad) => {
|
|
118
|
+
const creative = ad.creatives.find((item) => item.type === 'nonlinear');
|
|
119
|
+
const variation = creative?.variations?.[0];
|
|
120
|
+
const imageUrl = variation?.staticResource;
|
|
121
|
+
if (!creative || !variation || !imageUrl) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
imageUrl,
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
127
|
+
// @ts-ignore — see the runtime-vs-shim spelling note above
|
|
128
|
+
clickUrl: variation.nonlinearClickThroughURLTemplate ?? undefined,
|
|
129
|
+
width: Number(variation.width) || undefined,
|
|
130
|
+
height: Number(variation.height) || undefined,
|
|
131
|
+
creative,
|
|
132
|
+
variation,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Map a parsed VAST response into our `AdData` shape. Shared by both acquisition
|
|
137
|
+
* paths — the GAM/VMAP tag flow and the Prebid auction flow — so the two always
|
|
138
|
+
* produce identical downstream data. Returns `undefined` when the response has no
|
|
139
|
+
* ad carrying creatives.
|
|
140
|
+
*/
|
|
141
|
+
const buildAdData = (parsedVAST, order) => {
|
|
142
|
+
const validAd = parsedVAST.ads.find((ads) => ads.creatives.length > 0);
|
|
143
|
+
if (!validAd) {
|
|
144
|
+
log.warn({ order, ads: parsedVAST.ads.length }, 'vast response carries no ad with creatives — treated as no-ad');
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
const extension = validAd.extensions.find((ext) => ext.attributes.type.startsWith('streamlayer'))?.children[0];
|
|
148
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
149
|
+
// @ts-expect-error
|
|
150
|
+
const parsedExtension = parseVastExtension(extension);
|
|
151
|
+
const creative = getCreative(validAd);
|
|
152
|
+
const mediaFileUrl = getMediaFileUrl(creative);
|
|
153
|
+
const companions = getCompanions(validAd);
|
|
154
|
+
const { logo, media: mediaCompanion } = selectCompanions(companions);
|
|
155
|
+
const nonLinear = getNonLinear(validAd);
|
|
156
|
+
const clickThrough = getClickThrough(creative) || nonLinear?.clickUrl || mediaCompanion?.clickUrl;
|
|
157
|
+
log.info({
|
|
158
|
+
order,
|
|
159
|
+
adId: validAd.id,
|
|
160
|
+
title: validAd.title || undefined,
|
|
161
|
+
found: {
|
|
162
|
+
linear: !!creative,
|
|
163
|
+
mediaFile: mediaFileUrl || undefined,
|
|
164
|
+
nonLinear: nonLinear ? `${nonLinear.width}x${nonLinear.height}` : undefined,
|
|
165
|
+
companions: companions.map((item) => `${item.width}x${item.height}`),
|
|
166
|
+
},
|
|
167
|
+
selected: {
|
|
168
|
+
logo: logo ? { size: `${logo.width}x${logo.height}`, url: logo.imageUrl } : undefined,
|
|
169
|
+
media: mediaCompanion
|
|
170
|
+
? { size: `${mediaCompanion.width}x${mediaCompanion.height}`, url: mediaCompanion.imageUrl }
|
|
171
|
+
: undefined,
|
|
172
|
+
clickThrough: !!clickThrough,
|
|
173
|
+
},
|
|
174
|
+
}, 'vast ad parsed');
|
|
175
|
+
return {
|
|
176
|
+
creative,
|
|
177
|
+
order,
|
|
178
|
+
mediaFileUrl,
|
|
179
|
+
companion: companions[0],
|
|
180
|
+
logo,
|
|
181
|
+
mediaCompanion,
|
|
182
|
+
nonLinear,
|
|
183
|
+
clickThrough,
|
|
184
|
+
title: validAd.title || undefined,
|
|
185
|
+
description: validAd.description || undefined,
|
|
186
|
+
ad: validAd,
|
|
187
|
+
extension: parsedExtension,
|
|
188
|
+
raw: parsedVAST,
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
/** Path of the self-hosted Prebid Server OpenRTB auction endpoint. */
|
|
192
|
+
const PREBID_AUCTION_PATH = '/openrtb2/auction';
|
|
193
|
+
/**
|
|
194
|
+
* A Prebid link points at our Prebid Server auction endpoint (staging:
|
|
195
|
+
* `https://prebid.next.streamlayer.io/openrtb2/auction`) rather than at a VAST tag.
|
|
196
|
+
* Detected by path so both staging and production hosts match.
|
|
197
|
+
*/
|
|
198
|
+
const isPrebidUrl = (url) => url.pathname.endsWith(PREBID_AUCTION_PATH);
|
|
199
|
+
/** Unique per-auction request id — Prebid responses are never cached/reused (RFC §5). */
|
|
200
|
+
const createRequestId = () => `sl-web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
201
|
+
/**
|
|
202
|
+
* Build the OpenRTB request body for a single video auction. Placement config is
|
|
203
|
+
* carried on the Prebid link's query string so the consumer-facing interface stays a
|
|
204
|
+
* single URL string:
|
|
205
|
+
* - `configId` → `imp.ext.prebid.storedrequest.id` (production stored request).
|
|
206
|
+
* - `storedauctionresponse` → `imp.ext.prebid.storedauctionresponse.id` (staging mock).
|
|
207
|
+
* - `test=1` → top-level `test` flag.
|
|
208
|
+
* - `w` / `h` → video dimensions (default 1920×1080).
|
|
209
|
+
*/
|
|
210
|
+
const buildAuctionRequest = (url) => {
|
|
211
|
+
const params = url.searchParams;
|
|
212
|
+
const width = Number(params.get('w')) || 1920;
|
|
213
|
+
const height = Number(params.get('h')) || 1080;
|
|
214
|
+
const configId = params.get('configId') ?? params.get('config_id') ?? undefined;
|
|
215
|
+
const storedAuctionResponse = params.get('storedauctionresponse') ?? undefined;
|
|
216
|
+
const prebid = {};
|
|
217
|
+
if (storedAuctionResponse) {
|
|
218
|
+
prebid.storedauctionresponse = { id: storedAuctionResponse };
|
|
219
|
+
}
|
|
220
|
+
if (configId) {
|
|
221
|
+
prebid.storedrequest = { id: configId };
|
|
222
|
+
}
|
|
223
|
+
prebid.bidder = {
|
|
224
|
+
appnexus: { placementId: 13144370 },
|
|
225
|
+
};
|
|
226
|
+
const request = {
|
|
227
|
+
id: createRequestId(),
|
|
228
|
+
imp: [
|
|
229
|
+
{
|
|
230
|
+
id: '1',
|
|
231
|
+
video: {
|
|
232
|
+
mimes: ['video/mp4'],
|
|
233
|
+
protocols: [2, 3, 5, 6],
|
|
234
|
+
w: width,
|
|
235
|
+
h: height,
|
|
236
|
+
placement: 1,
|
|
237
|
+
linearity: 1,
|
|
238
|
+
},
|
|
239
|
+
ext: { prebid },
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
site: { page: typeof location !== 'undefined' ? location.href : url.origin },
|
|
243
|
+
tmax: 500,
|
|
244
|
+
};
|
|
245
|
+
if (params.get('test') === '1') {
|
|
246
|
+
request.test = 1;
|
|
247
|
+
}
|
|
248
|
+
return request;
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* Run one Prebid Server auction and turn the winning inline VAST into an `AdStore`.
|
|
252
|
+
* Every bid's `adm` is inline VAST XML (no URL to fetch); bids are ordered by price
|
|
253
|
+
* so the winner plays first (RFC §2). A single bad bid must not drop the batch.
|
|
254
|
+
*/
|
|
255
|
+
const fetchPrebidAds = async (prebidUrl) => {
|
|
256
|
+
const response = await fetch(prebidUrl.toString(), {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: { 'Content-Type': 'application/json' },
|
|
259
|
+
body: JSON.stringify(buildAuctionRequest(prebidUrl)),
|
|
12
260
|
});
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
throw new Error(`prebid auction failed with status ${response.status}`);
|
|
263
|
+
}
|
|
264
|
+
const auction = (await response.json());
|
|
265
|
+
const bids = (auction.seatbid ?? [])
|
|
266
|
+
.reduce((acc, seat) => acc.concat(seat.bid ?? []), [])
|
|
267
|
+
.filter((bid) => !!bid.adm)
|
|
268
|
+
// Highest price wins — the winner is order 0 (RFC §2).
|
|
269
|
+
.sort((a, b) => (b.price ?? 0) - (a.price ?? 0));
|
|
270
|
+
const parser = new DOMParser();
|
|
271
|
+
const loadedAds = await Promise.all(bids.map((bid, i) => {
|
|
272
|
+
const xmlDoc = parser.parseFromString(bid.adm, 'text/xml');
|
|
273
|
+
// A fresh `VASTParser` per bid isolates parser state, mirroring the fresh
|
|
274
|
+
// `VASTClient` per link in the tag flow. `resolveAll: true` follows any
|
|
275
|
+
// `VASTAdTagURI` wrapper chain the bid's inline VAST may still contain.
|
|
276
|
+
return (new VASTParser()
|
|
277
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
278
|
+
// @ts-ignore
|
|
279
|
+
.parseVAST(xmlDoc, { resolveAll: true, wrapperLimit: 10, allowMultipleAds: true })
|
|
280
|
+
.then((parsedVAST) => buildAdData(parsedVAST, i))
|
|
281
|
+
.catch((err) => {
|
|
282
|
+
// A single failing bid must not drop the whole batch.
|
|
283
|
+
log.error(err, 'failed to parse prebid VAST');
|
|
284
|
+
return undefined;
|
|
285
|
+
}));
|
|
286
|
+
}));
|
|
287
|
+
const params = prebidUrl.searchParams;
|
|
288
|
+
const autoplay = params.get('vpa')?.replaceAll('?', '') !== 'click';
|
|
289
|
+
const muted = params.get('vpmute')?.replaceAll('?', '') === '1';
|
|
290
|
+
return {
|
|
291
|
+
ads: loadedAds.filter((d) => !!d),
|
|
292
|
+
autoplay,
|
|
293
|
+
muted,
|
|
294
|
+
};
|
|
13
295
|
};
|
|
14
296
|
export const externalAd = (instance) => {
|
|
15
|
-
return (gamUrl
|
|
16
|
-
dedupeTime:
|
|
17
|
-
revalidateInterval:
|
|
297
|
+
return (gamUrl) => instance.transport.nanoquery.createFetcherStore([gamUrl], {
|
|
298
|
+
dedupeTime: Infinity,
|
|
299
|
+
revalidateInterval: 0,
|
|
18
300
|
fetcher: async (adUrl) => {
|
|
19
301
|
if (!adUrl || typeof adUrl !== 'string') {
|
|
20
302
|
throw new Error('ad url is empty');
|
|
21
303
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
id: '',
|
|
29
|
-
parentId,
|
|
30
|
-
url: adUrl,
|
|
31
|
-
title: validAd.title || '',
|
|
32
|
-
description: validAd.description || '',
|
|
33
|
-
};
|
|
34
|
-
log.debug(validAd, 'validAd');
|
|
35
|
-
const linear = validAd.creatives.find((creative) => creative.type === 'linear');
|
|
36
|
-
if (linear) {
|
|
37
|
-
store.id = linear.adId || linear.id || parentId;
|
|
38
|
-
log.debug(linear, 'linear');
|
|
39
|
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
40
|
-
// @ts-ignore
|
|
41
|
-
store.videoSrc = linear.mediaFiles?.find((mediaFile) => !!mediaFile.fileURL)?.fileURL;
|
|
42
|
-
}
|
|
43
|
-
const nonlinear = validAd.creatives.find((creative) => creative.type === 'nonlinear');
|
|
44
|
-
if (nonlinear) {
|
|
45
|
-
store.id = nonlinear.adId || nonlinear.id || parentId;
|
|
46
|
-
log.debug(nonlinear, 'nonlinear');
|
|
47
|
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
48
|
-
// @ts-ignore
|
|
49
|
-
const staticResource = nonlinear.variations?.[0]?.staticResource;
|
|
50
|
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
51
|
-
// @ts-ignore
|
|
52
|
-
store.adUrl = nonlinear.variations?.[0]?.nonlinearClickThroughURLTemplate;
|
|
53
|
-
if (staticResource) {
|
|
54
|
-
await preloadImage(staticResource);
|
|
55
|
-
store.imageSrc = staticResource;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
const companion = validAd.creatives.find((creative) => creative.type === 'companion');
|
|
59
|
-
log.debug(companion, 'companion');
|
|
60
|
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
61
|
-
// @ts-ignore
|
|
62
|
-
const staticResource = companion?.variations?.[0]?.staticResources?.[0]?.url;
|
|
63
|
-
if (staticResource) {
|
|
64
|
-
await preloadImage(staticResource);
|
|
65
|
-
store.sponsorSrc = staticResource;
|
|
66
|
-
}
|
|
67
|
-
if (store.imageSrc || store.videoSrc) {
|
|
68
|
-
return store;
|
|
69
|
-
}
|
|
304
|
+
const parsedAdUrl = (() => {
|
|
305
|
+
try {
|
|
306
|
+
return new URL(adUrl);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return undefined;
|
|
70
310
|
}
|
|
71
|
-
|
|
311
|
+
})();
|
|
312
|
+
// Prebid links hit our Prebid Server auction endpoint instead of a VAST tag.
|
|
313
|
+
// Run the OpenRTB auction and parse the winning bid's inline VAST (`adm`) into
|
|
314
|
+
// the same `AdStore` shape the GAM/VMAP path returns. See rfcs/prebid.
|
|
315
|
+
if (parsedAdUrl && isPrebidUrl(parsedAdUrl)) {
|
|
316
|
+
return fetchPrebidAds(parsedAdUrl);
|
|
72
317
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
318
|
+
const generateUrl = gam(adUrl);
|
|
319
|
+
const generatedUrl = await generateUrl();
|
|
320
|
+
const noncedLink = generatedUrl?.url;
|
|
321
|
+
if (!noncedLink) {
|
|
322
|
+
throw new Error('ad url is not generated');
|
|
323
|
+
}
|
|
324
|
+
let links;
|
|
325
|
+
if (noncedLink.searchParams.get('output')?.replaceAll('?', '') === 'vmap') {
|
|
326
|
+
const response = await withTimeout(fetch(noncedLink.toString()), VAST_FETCH_TIMEOUT_MS);
|
|
327
|
+
if (!response) {
|
|
328
|
+
throw new Error('vmap fetch timed out');
|
|
78
329
|
}
|
|
79
|
-
|
|
330
|
+
const parser = new DOMParser();
|
|
331
|
+
const xmlStr = await response.text();
|
|
332
|
+
const xmlDoc = parser.parseFromString(xmlStr, 'text/xml');
|
|
333
|
+
const vmap = new VMAP(xmlDoc);
|
|
334
|
+
links = vmap.adBreaks.map((ad) => {
|
|
335
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
336
|
+
// @ts-expect-error
|
|
337
|
+
return ad.adSource.adTagURI.uri;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
links = [noncedLink.toString()];
|
|
80
342
|
}
|
|
343
|
+
const loadedAds = await Promise.all(links.map((link, i) => {
|
|
344
|
+
// `resolveAll: true` makes the client follow every `VASTAdTagURI` wrapper chain
|
|
345
|
+
// (up to the default depth of 10) and resolve all Ads, merging wrapper-level
|
|
346
|
+
// Impression/tracking URLs into the inline Ad. The default (`resolveAll: false`)
|
|
347
|
+
// only resolves the first standalone Ad and defers wrappers/remaining Ads to
|
|
348
|
+
// `getNextAds()`, which this fetcher never calls. A fresh `VASTClient` per link
|
|
349
|
+
// isolates parser state so concurrent (VMAP) resolutions don't clobber each other.
|
|
350
|
+
return withTimeout(new VASTClient()
|
|
351
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
352
|
+
// @ts-ignore
|
|
353
|
+
.get(link, { resolveAll: true, wrapperLimit: 10, allowMultipleAds: true }), VAST_FETCH_TIMEOUT_MS)
|
|
354
|
+
.then((parsedVAST) => {
|
|
355
|
+
// `parsedVAST` is `undefined` when the resolution timed out (see `withTimeout`).
|
|
356
|
+
if (!parsedVAST) {
|
|
357
|
+
log.warn({ link, timeoutMs: VAST_FETCH_TIMEOUT_MS }, 'vast resolution timed out — treated as no-ad');
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
return buildAdData(parsedVAST, i);
|
|
361
|
+
})
|
|
362
|
+
.catch((err) => {
|
|
363
|
+
// A single failing wrapper chain must not drop the whole batch.
|
|
364
|
+
log.error(err, 'failed to resolve VAST');
|
|
365
|
+
return undefined;
|
|
366
|
+
});
|
|
367
|
+
}));
|
|
368
|
+
const autoplay = noncedLink.searchParams.get('vpa')?.replaceAll('?', '') !== 'click';
|
|
369
|
+
const muted = noncedLink.searchParams.get('vpmute')?.replaceAll('?', '') === '1';
|
|
370
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
371
|
+
// @ts-expect-error
|
|
372
|
+
const nonceManager = generatedUrl.manager;
|
|
373
|
+
return {
|
|
374
|
+
ads: loadedAds.filter((d) => !!d),
|
|
375
|
+
autoplay,
|
|
376
|
+
muted,
|
|
377
|
+
nonceManager,
|
|
378
|
+
};
|
|
81
379
|
},
|
|
82
380
|
});
|
|
83
381
|
};
|
|
@@ -5,14 +5,46 @@ export type GAMStaticCore = {
|
|
|
5
5
|
parentId: string;
|
|
6
6
|
url: string;
|
|
7
7
|
imageSrc?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Linear MP4 from the VAST response → renders full-bleed `<video>` (SC-322).
|
|
10
|
+
* Only produced when the host asked for it via one of the `fullbleed-video*`
|
|
11
|
+
* values of `ExternalPauseAdOptions.template`; a `'default'` ad can never carry
|
|
12
|
+
* this field.
|
|
13
|
+
*/
|
|
14
|
+
backgroundVideoSrc?: string;
|
|
8
15
|
adUrl?: string;
|
|
9
16
|
ad?: any;
|
|
10
17
|
creative?: any;
|
|
11
18
|
variation?: any;
|
|
12
19
|
};
|
|
20
|
+
/**
|
|
21
|
+
* Which VAST creative this request resolves, set by the host on
|
|
22
|
+
* `pauseAdExternalUrls[].template`:
|
|
23
|
+
* - `'default'` — static image (nonlinear → companion) → sidebar overlay.
|
|
24
|
+
* - `'fullbleed-video*'` — the **linear** creative's MP4 → full-screen video (SC-322).
|
|
25
|
+
*
|
|
26
|
+
* This is a resolution STRATEGY, not a renderer hint the fetcher may ignore. The
|
|
27
|
+
* `fullbleed-video` variants differ ONLY in the chrome the renderer draws around
|
|
28
|
+
* the video, so creative resolution treats them identically.
|
|
29
|
+
*/
|
|
30
|
+
export type PauseAdTemplate = 'default' | 'fullbleed-video' | 'fullbleed-video-center' | 'fullbleed-video-left';
|
|
31
|
+
/**
|
|
32
|
+
* `indexOf` rather than `Array.prototype.includes`: this package ships to Chrome 32
|
|
33
|
+
* TV webviews and must stay ES5-compatible without a polyfill.
|
|
34
|
+
*/
|
|
35
|
+
export declare const isFullbleedVideoTemplate: (template?: PauseAdTemplate) => boolean;
|
|
13
36
|
export type ExternalPauseAdOptions = {
|
|
14
37
|
refetchInterval: number;
|
|
15
38
|
prefetch: boolean;
|
|
16
39
|
$enabled: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Creative-resolution strategy. Defaults to `'default'`.
|
|
42
|
+
*
|
|
43
|
+
* NOT reactive — it is part of the store's IDENTITY (see the composite Map key
|
|
44
|
+
* in `Advertisement.getExternalPauseAd`), not of its state. `$enabled` is the
|
|
45
|
+
* only reactive option here; `refetchInterval`/`prefetch` are read once at
|
|
46
|
+
* store-creation time.
|
|
47
|
+
*/
|
|
48
|
+
template?: PauseAdTemplate;
|
|
17
49
|
};
|
|
18
50
|
export declare const externalPauseAd: (instance: StreamLayerContext) => (gamUrl: string, $enabled: ReturnType<typeof createSingleStore<boolean>>, options: ExternalPauseAdOptions) => import("@nanostores/query").FetcherStore<GAMStaticCore | undefined, any>;
|
|
@@ -1,7 +1,62 @@
|
|
|
1
|
+
// Pins the `@dailymotion/vast-client` → `@types/vast-client` shim: TypeDoc
|
|
2
|
+
// compiles this file through dependent packages' programs, whose tsconfig
|
|
3
|
+
// `include` does not cover this package's ambient declarations.
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
|
5
|
+
/// <reference path="../module.d.ts" />
|
|
1
6
|
import { eventBus } from '@streamlayer/sdk-web-interfaces';
|
|
2
7
|
import { createLogger } from '@streamlayer/sdk-web-logger';
|
|
3
8
|
import '@streamlayer/sdk-web-api';
|
|
4
9
|
import { VASTClient } from '@dailymotion/vast-client';
|
|
10
|
+
const FULLBLEED_VIDEO_TEMPLATES = [
|
|
11
|
+
'fullbleed-video',
|
|
12
|
+
'fullbleed-video-center',
|
|
13
|
+
'fullbleed-video-left',
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* `indexOf` rather than `Array.prototype.includes`: this package ships to Chrome 32
|
|
17
|
+
* TV webviews and must stay ES5-compatible without a polyfill.
|
|
18
|
+
*/
|
|
19
|
+
export const isFullbleedVideoTemplate = (template) => !!template && FULLBLEED_VIDEO_TEMPLATES.indexOf(template) !== -1;
|
|
20
|
+
const MP4_MIME = 'video/mp4';
|
|
21
|
+
// Programmatic spec (Index Exchange): bitrate window for a linear pause-ad MP4.
|
|
22
|
+
const MIN_BITRATE = 5000;
|
|
23
|
+
const MAX_BITRATE = 40000;
|
|
24
|
+
const TARGET_WIDTH = 1920;
|
|
25
|
+
const TARGET_HEIGHT = 1080;
|
|
26
|
+
const bitrateDistanceToWindow = (bitrate) => {
|
|
27
|
+
if (bitrate < MIN_BITRATE)
|
|
28
|
+
return MIN_BITRATE - bitrate;
|
|
29
|
+
if (bitrate > MAX_BITRATE)
|
|
30
|
+
return bitrate - MAX_BITRATE;
|
|
31
|
+
return 0;
|
|
32
|
+
};
|
|
33
|
+
const resolutionDistanceToTarget = (file) => Math.abs((file.width ?? 0) - TARGET_WIDTH) + Math.abs((file.height ?? 0) - TARGET_HEIGHT);
|
|
34
|
+
/**
|
|
35
|
+
* Pick the best linear MP4 from a VAST creative's media files: prefer the
|
|
36
|
+
* highest bitrate inside the 5000–40000 kbps window; if none qualify, pick the
|
|
37
|
+
* one closest to the window. Ties break toward 1920×1080. Returns `undefined`
|
|
38
|
+
* when no MP4 is present so image-only VAST keeps working.
|
|
39
|
+
*/
|
|
40
|
+
const selectMp4MediaFile = (mediaFiles = []) => {
|
|
41
|
+
const mp4s = (mediaFiles ?? []).filter((file) => !!file?.fileURL && file.mimeType === MP4_MIME);
|
|
42
|
+
if (mp4s.length === 0) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const inWindow = mp4s.filter((file) => {
|
|
46
|
+
const bitrate = file.bitrate ?? 0;
|
|
47
|
+
return bitrate >= MIN_BITRATE && bitrate <= MAX_BITRATE;
|
|
48
|
+
});
|
|
49
|
+
if (inWindow.length > 0) {
|
|
50
|
+
return [...inWindow].sort((a, b) => {
|
|
51
|
+
const byBitrate = (b.bitrate ?? 0) - (a.bitrate ?? 0);
|
|
52
|
+
return byBitrate !== 0 ? byBitrate : resolutionDistanceToTarget(a) - resolutionDistanceToTarget(b);
|
|
53
|
+
})[0];
|
|
54
|
+
}
|
|
55
|
+
return [...mp4s].sort((a, b) => {
|
|
56
|
+
const byWindow = bitrateDistanceToWindow(a.bitrate ?? 0) - bitrateDistanceToWindow(b.bitrate ?? 0);
|
|
57
|
+
return byWindow !== 0 ? byWindow : resolutionDistanceToTarget(a) - resolutionDistanceToTarget(b);
|
|
58
|
+
})[0];
|
|
59
|
+
};
|
|
5
60
|
const vastClient = new VASTClient();
|
|
6
61
|
const log = createLogger('ui:gam-static');
|
|
7
62
|
const preloadImage = (src) => {
|
|
@@ -13,9 +68,42 @@ const preloadImage = (src) => {
|
|
|
13
68
|
});
|
|
14
69
|
};
|
|
15
70
|
export const externalPauseAd = (instance) => {
|
|
16
|
-
return (gamUrl, $enabled, options) =>
|
|
71
|
+
return (gamUrl, $enabled, options) =>
|
|
72
|
+
// `template` MUST be a key part: nanoquery joins parts with `''` (no separator)
|
|
73
|
+
// and its `cache` Map is global per transport, so two stores that produce the
|
|
74
|
+
// same joined key share data AND cross-trigger each other's `revalidate()`.
|
|
75
|
+
// The `tpl:` prefix keeps the url/template boundary unambiguous. Never let this
|
|
76
|
+
// part be `undefined` — an undefined part nulls the WHOLE key and permanently
|
|
77
|
+
// suspends fetching (that is exactly how `$enabled: false` works).
|
|
78
|
+
instance.transport.nanoquery.createFetcherStore([gamUrl, $enabled, `tpl:${options.template ?? 'default'}`], {
|
|
17
79
|
dedupeTime: options.prefetch ? options.refetchInterval : Infinity,
|
|
18
80
|
revalidateInterval: options.refetchInterval > 0 ? options.refetchInterval : 0,
|
|
81
|
+
// A creative-type mismatch is DETERMINISTIC: this tag returned no usable linear
|
|
82
|
+
// MP4 and retrying it can never change that, so cancel nanoquery's BACKOFF RETRY
|
|
83
|
+
// LOOP (any falsy return does) instead of re-hitting the ad server for the rest of
|
|
84
|
+
// the session — with the default `pauseAdDelay: 0` the store is enabled from mount.
|
|
85
|
+
// This only cancels the retry loop: `revalidateInterval` still re-runs the fetcher
|
|
86
|
+
// on its timer, and `Advertisement.getExternalPauseAd` forces a `revalidate()` on
|
|
87
|
+
// every `$enabled` false → true edge, so with `pauseAdDelay > 0` the tag is still
|
|
88
|
+
// re-requested on every pause.
|
|
89
|
+
// Transient failures (network, CORS, malformed XML, 5xx) still retry; the backoff
|
|
90
|
+
// below mirrors nanoquery's `defaultOnErrorRetry` so the two templates stay on the
|
|
91
|
+
// same curve. The jitter is not decorative — it spreads out retries that would
|
|
92
|
+
// otherwise fire in lockstep when the failure is correlated across stores/clients
|
|
93
|
+
// (ad-server 5xx, CORS), which the `pauseAdDelay: 0` prefetch-at-mount makes more
|
|
94
|
+
// likely.
|
|
95
|
+
// On `default` this is `undefined`, i.e. exactly as if the option were not passed —
|
|
96
|
+
// true as long as the shared nanoquery instance sets no global `onErrorRetry` (an
|
|
97
|
+
// explicit `undefined` DOES shadow a global one, and only then does nanoquery's
|
|
98
|
+
// destructuring default restore `defaultOnErrorRetry`).
|
|
99
|
+
onErrorRetry: isFullbleedVideoTemplate(options.template)
|
|
100
|
+
? ({ error, retryCount }) => {
|
|
101
|
+
if (error instanceof Error && error.message === 'no_valid_ad') {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return ~~((Math.random() + 0.5) * (1 << Math.min(retryCount, 8))) * 2000;
|
|
105
|
+
}
|
|
106
|
+
: undefined,
|
|
19
107
|
fetcher: async (adUrl) => {
|
|
20
108
|
if (!adUrl || typeof adUrl !== 'string') {
|
|
21
109
|
throw new Error('ad url is empty');
|
|
@@ -25,11 +113,64 @@ export const externalPauseAd = (instance) => {
|
|
|
25
113
|
payload: {},
|
|
26
114
|
});
|
|
27
115
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
116
|
+
// `resolveAll: true` follows every `VASTAdTagURI` wrapper chain and resolves all
|
|
117
|
+
// Ads (the default only resolves the first standalone Ad and stops at the wrapper).
|
|
118
|
+
const parsedVAST = await vastClient.get(adUrl, { resolveAll: true });
|
|
119
|
+
const template = options.template ?? 'default';
|
|
120
|
+
// `fullbleed-video` needs an ad that actually carries a usable linear MP4. With
|
|
121
|
+
// `resolveAll: true` the response can be a pod/wrapper chain, so prefer the first
|
|
122
|
+
// ad that satisfies the template and fall back to the first ad with any creative
|
|
123
|
+
// (which preserves the existing `no_valid_ad` error and event sequencing).
|
|
124
|
+
const validAd = isFullbleedVideoTemplate(template)
|
|
125
|
+
? (parsedVAST.ads.find((ad) => ad.creatives.some((creative) => creative.type === 'linear' &&
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
127
|
+
// @ts-ignore vast-client linear creative carries mediaFiles
|
|
128
|
+
selectMp4MediaFile(creative.mediaFiles)?.fileURL)) ?? parsedVAST.ads.find((ads) => ads.creatives.length > 0))
|
|
129
|
+
: parsedVAST.ads.find((ads) => ads.creatives.length > 0);
|
|
30
130
|
if (validAd) {
|
|
31
131
|
const parentId = validAd.id || '';
|
|
32
132
|
log.debug(validAd, 'validAd');
|
|
133
|
+
// The HOST decides which creative wins (SC-322-2). `fullbleed-video` asks
|
|
134
|
+
// for the linear MP4 explicitly, so it is resolved FIRST and never falls
|
|
135
|
+
// back to a static image — the nonlinear/companion branches below `return`
|
|
136
|
+
// early, so without this a VAST carrying BOTH an image and a linear MP4
|
|
137
|
+
// (e.g. Google `single_ad_samples`) would never reach the linear creative.
|
|
138
|
+
// `default` keeps the pre-SC-322 nonlinear → companion order below and can
|
|
139
|
+
// never produce a video.
|
|
140
|
+
if (isFullbleedVideoTemplate(template)) {
|
|
141
|
+
const linear = validAd.creatives.find((creative) => creative.type === 'linear' &&
|
|
142
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
143
|
+
// @ts-ignore vast-client linear creative carries mediaFiles
|
|
144
|
+
selectMp4MediaFile(creative.mediaFiles)?.fileURL);
|
|
145
|
+
if (linear) {
|
|
146
|
+
log.debug(linear, 'linear');
|
|
147
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
148
|
+
// @ts-ignore vast-client linear creative carries mediaFiles
|
|
149
|
+
const mediaFile = selectMp4MediaFile(linear.mediaFiles);
|
|
150
|
+
if (mediaFile?.fileURL) {
|
|
151
|
+
const id = linear.adId || linear.id || parentId;
|
|
152
|
+
eventBus.emit('exposedPauseAd', {
|
|
153
|
+
action: 'loaded',
|
|
154
|
+
payload: {
|
|
155
|
+
id,
|
|
156
|
+
parentId,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
id,
|
|
161
|
+
parentId,
|
|
162
|
+
url: adUrl,
|
|
163
|
+
backgroundVideoSrc: mediaFile.fileURL,
|
|
164
|
+
ad: validAd,
|
|
165
|
+
creative: linear,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Host asked for full-bleed video and this VAST carries no usable linear
|
|
170
|
+
// MP4 → fail closed so `ExposedPauseAd`'s error effect auto-closes the ad,
|
|
171
|
+
// exactly like any other unusable VAST. See §5 of the brief.
|
|
172
|
+
throw new Error('no_valid_ad');
|
|
173
|
+
}
|
|
33
174
|
const nonlinear = validAd.creatives.find((creative) => creative.type === 'nonlinear');
|
|
34
175
|
if (nonlinear) {
|
|
35
176
|
const id = nonlinear.adId || nonlinear.id || parentId;
|