@streamlayer/feature-advertisement 1.2.0 → 1.2.1-rc.12.pc6c8e1c0
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/classic/package.json +5 -0
- package/lib/advertisement.d.ts +6 -6
- package/lib/advertisement.js +9 -9
- package/lib/cjs/advertisement.d.ts +64 -0
- package/lib/cjs/advertisement.js +322 -0
- package/lib/cjs/index.d.ts +21 -0
- package/lib/cjs/index.js +41 -0
- package/lib/cjs/package.json +1 -0
- package/lib/cjs/units/ad-macros.d.ts +7 -0
- package/lib/cjs/units/ad-macros.js +91 -0
- package/lib/cjs/units/external-ad.d.ts +127 -0
- package/lib/cjs/units/external-ad.js +421 -0
- package/lib/cjs/units/external-pause-ad.d.ts +50 -0
- package/lib/cjs/units/external-pause-ad.js +259 -0
- package/lib/cjs/units/gam.d.ts +26 -0
- package/lib/cjs/units/gam.js +152 -0
- package/lib/cjs/units/parse-vast-extensions.d.ts +26 -0
- package/lib/cjs/units/parse-vast-extensions.js +56 -0
- package/lib/cjs/units/pause-ad-card.d.ts +49 -0
- package/lib/cjs/units/pause-ad-card.js +136 -0
- package/lib/cjs/units/pause-ad.d.ts +16 -0
- package/lib/cjs/units/pause-ad.js +52 -0
- package/lib/cjs/units/sponsor-logo.d.ts +78 -0
- package/lib/cjs/units/sponsor-logo.js +194 -0
- package/lib/cjs/units/ssai-ad-break.d.ts +21 -0
- package/lib/cjs/units/ssai-ad-break.js +11 -0
- package/lib/cjs/units/ssai-creative.d.ts +89 -0
- package/lib/cjs/units/ssai-creative.js +136 -0
- package/lib/cjs/units/ssai-detector.d.ts +180 -0
- package/lib/cjs/units/ssai-detector.js +1172 -0
- package/lib/cjs/units/ssai-log.d.ts +16 -0
- package/lib/cjs/units/ssai-log.js +23 -0
- package/lib/cjs/units/ssai-preload.d.ts +20 -0
- package/lib/cjs/units/ssai-preload.js +62 -0
- package/lib/cjs/units/ssai-providers.d.ts +117 -0
- package/lib/cjs/units/ssai-providers.js +300 -0
- package/lib/cjs/units/vast-tracker.d.ts +45 -0
- package/lib/cjs/units/vast-tracker.js +64 -0
- package/lib/classic/_virtual/_rollupPluginBabelHelpers.js +71 -0
- package/lib/classic/advertisement.js +348 -0
- package/lib/classic/cjs/_virtual/_rollupPluginBabelHelpers.js +77 -0
- package/lib/classic/cjs/advertisement.js +350 -0
- package/lib/classic/cjs/index.js +49 -0
- package/lib/classic/cjs/package.json +1 -0
- package/lib/classic/cjs/units/ad-macros.js +115 -0
- package/lib/classic/cjs/units/external-ad.js +500 -0
- package/lib/classic/cjs/units/external-pause-ad.js +278 -0
- package/lib/classic/cjs/units/gam.js +173 -0
- package/lib/classic/cjs/units/parse-vast-extensions.js +60 -0
- package/lib/classic/cjs/units/pause-ad-card.js +134 -0
- package/lib/classic/cjs/units/pause-ad.js +72 -0
- package/lib/classic/cjs/units/sponsor-logo.js +201 -0
- package/lib/classic/cjs/units/ssai-ad-break.js +11 -0
- package/lib/classic/cjs/units/ssai-creative.js +142 -0
- package/lib/classic/cjs/units/ssai-detector.js +1195 -0
- package/lib/classic/cjs/units/ssai-log.js +23 -0
- package/lib/classic/cjs/units/ssai-preload.js +72 -0
- package/lib/classic/cjs/units/ssai-providers.js +361 -0
- package/lib/classic/cjs/units/vast-tracker.js +81 -0
- package/lib/classic/index.js +29 -0
- package/lib/classic/units/ad-macros.js +113 -0
- package/lib/classic/units/external-ad.js +491 -0
- package/lib/classic/units/external-pause-ad.js +275 -0
- package/lib/classic/units/gam.js +171 -0
- package/lib/classic/units/parse-vast-extensions.js +58 -0
- package/lib/classic/units/pause-ad-card.js +131 -0
- package/lib/classic/units/pause-ad.js +70 -0
- package/lib/classic/units/sponsor-logo.js +197 -0
- package/lib/classic/units/ssai-ad-break.js +9 -0
- package/lib/classic/units/ssai-creative.js +140 -0
- package/lib/classic/units/ssai-detector.js +1192 -0
- package/lib/classic/units/ssai-log.js +21 -0
- package/lib/classic/units/ssai-preload.js +68 -0
- package/lib/classic/units/ssai-providers.js +351 -0
- package/lib/classic/units/vast-tracker.js +77 -0
- package/lib/index.d.ts +15 -14
- package/lib/index.js +9 -9
- package/lib/units/external-ad.d.ts +2 -2
- package/lib/units/external-ad.js +3 -3
- package/lib/units/external-pause-ad.js +1 -1
- package/lib/units/gam.js +1 -1
- package/lib/units/pause-ad.d.ts +1 -1
- package/lib/units/pause-ad.js +1 -1
- package/lib/units/sponsor-logo.d.ts +12 -4
- package/lib/units/sponsor-logo.js +4 -3
- package/lib/units/ssai-ad-break.d.ts +1 -1
- package/lib/units/ssai-creative.d.ts +1 -1
- package/lib/units/ssai-creative.js +1 -1
- package/lib/units/ssai-detector.d.ts +1 -1
- package/lib/units/ssai-detector.js +1 -1
- package/lib/units/ssai-preload.d.ts +1 -1
- package/lib/units/ssai-providers.d.ts +1 -1
- package/lib/units/ssai-providers.js +1 -1
- package/lib/units/vast-tracker.d.ts +1 -1
- package/lib/vast-client.d.ts +9 -0
- package/package.json +42 -9
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { AdTagParams } from '@streamlayer/sdk-web-core';
|
|
2
|
+
import { StreamLayerContext } from '@streamlayer/sdk-web-interfaces';
|
|
3
|
+
import { VastAd, VastCompanionAd, VastCreativeCompanion, VastCreativeLinear, VastCreativeNonLinear, VastNonLinearAd, VastResponse } from '@dailymotion/vast-client';
|
|
4
|
+
import '@streamlayer/sdk-web-api';
|
|
5
|
+
import { type PalNonceManager } from './gam.js';
|
|
6
|
+
import { VastCreative } from './parse-vast-extensions.js';
|
|
7
|
+
export type AdCompanion = {
|
|
8
|
+
imageUrl?: string;
|
|
9
|
+
clickUrl?: string;
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
/** The `<CompanionAds>` creative — the `VASTTracker` 3rd argument. */
|
|
13
|
+
creative?: VastCreativeCompanion;
|
|
14
|
+
/** The `<Companion>` variation — the 4th argument; carries this companion's own `creativeView`. */
|
|
15
|
+
variation?: VastCompanionAd;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* First `<NonLinear>` variation of a `<NonLinearAds>` creative. Tags that ship this instead of a
|
|
19
|
+
* `<Linear>` creative make it the ad's main media, not a companion strip.
|
|
20
|
+
*/
|
|
21
|
+
export type AdNonLinear = {
|
|
22
|
+
imageUrl: string;
|
|
23
|
+
clickUrl?: string;
|
|
24
|
+
width?: number;
|
|
25
|
+
height?: number;
|
|
26
|
+
/** The `<NonLinearAds>` creative — carries the creative-level `<TrackingEvents>`. */
|
|
27
|
+
creative: VastCreativeNonLinear;
|
|
28
|
+
/** The `<NonLinear>` variation — the `VASTTracker` 4th argument (click-through + its trackers). */
|
|
29
|
+
variation: VastNonLinearAd;
|
|
30
|
+
};
|
|
31
|
+
export type AdData = {
|
|
32
|
+
creative: VastCreativeLinear;
|
|
33
|
+
order: number;
|
|
34
|
+
mediaFileUrl?: string;
|
|
35
|
+
/** First companion creative (static image + click-through) — sources the L-Bar banner strip. */
|
|
36
|
+
companion?: AdCompanion;
|
|
37
|
+
/** The 300x75 `<Companion>` — the sponsor logo. Only set for a companion pair. */
|
|
38
|
+
logo?: AdCompanion;
|
|
39
|
+
/**
|
|
40
|
+
* The iOS `SLRVASTSlotRole.cardLogo` pick: companion-only, aspect-scored toward 1:1, and minus
|
|
41
|
+
* whatever the hero slot already claimed. Consumed **only** by the hero-card placements
|
|
42
|
+
* (Side-by-Side / Picture in Picture / Sponsor Frame) as a fallback for an otherwise empty
|
|
43
|
+
* header logo. `logo` is the SC-335 display rule and is unchanged; when it fires, this stays
|
|
44
|
+
* `undefined` because the display rule already won the slot.
|
|
45
|
+
*/
|
|
46
|
+
cardLogo?: AdCompanion;
|
|
47
|
+
/**
|
|
48
|
+
* The tag's ONLY `<Companion>`, when it is exactly 300x75 and the hero is a Linear or a
|
|
49
|
+
* NonLinear — i.e. the companion is logo-sized and nothing else needs it. The pair rule behind
|
|
50
|
+
* `logo` exists to tell the logo from the media among companions; with the media coming from
|
|
51
|
+
* elsewhere there is nothing to tell apart, and iOS fills its logo from a single companion.
|
|
52
|
+
* Consumed only by the Standard Ad Sidebar (`createActiveAdFromVast`) as a header-logo fallback;
|
|
53
|
+
* a companion-only tag leaves it `undefined`, because there the companion IS the media.
|
|
54
|
+
*/
|
|
55
|
+
loneLogo?: AdCompanion;
|
|
56
|
+
/** Banner-sized `<Companion>` (468x60 class) — the sidebar media for companion-pair tags. */
|
|
57
|
+
mediaCompanion?: AdCompanion;
|
|
58
|
+
/** NonLinear (image-only) creative — present when the tag has no `<Linear>` media. */
|
|
59
|
+
nonLinear?: AdNonLinear;
|
|
60
|
+
/** VAST `<AdTitle>`. */
|
|
61
|
+
title?: string;
|
|
62
|
+
/** VAST `<Description>`. */
|
|
63
|
+
description?: string;
|
|
64
|
+
/** Linear `<ClickThrough>`, else `<NonLinearClickThrough>`, else `<CompanionClickThrough>`. */
|
|
65
|
+
clickThrough?: string;
|
|
66
|
+
ad: VastAd;
|
|
67
|
+
extension?: VastCreative;
|
|
68
|
+
raw: VastResponse;
|
|
69
|
+
};
|
|
70
|
+
export type AdStore = {
|
|
71
|
+
ads: AdData[];
|
|
72
|
+
autoplay: boolean;
|
|
73
|
+
muted: boolean;
|
|
74
|
+
nonceManager?: PalNonceManager;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Split a `<CompanionAds>` creative's variations into the logo and media slots.
|
|
78
|
+
*
|
|
79
|
+
* The pair mapping activates only for a tag that ships an exact 300x75 companion next to at
|
|
80
|
+
* least one other. Deliberately conservative pending broader product sizes: on any other tag
|
|
81
|
+
* no companion is promoted to the logo slot and the first one stays the media/strip candidate.
|
|
82
|
+
*
|
|
83
|
+
* Both halves of that gate are load-bearing and neither is an aspect rule: a 320x80 companion is
|
|
84
|
+
* the same 4:1 shape as 300x75 and is still rejected, and a lone 300x75 companion is rejected too
|
|
85
|
+
* (the Standard Ad Sidebar recovers that one as `AdData.loneLogo` when a Linear / NonLinear is
|
|
86
|
+
* the media, so it is not needed here).
|
|
87
|
+
* This is **narrower than iOS**, which has no size rule at all and fills its logo slot from a
|
|
88
|
+
* single companion — see `docs/wiki/programmatic-ads-display-ios-parity.md`. Exported for
|
|
89
|
+
* `external-ad.spec.ts` only; `src/index.ts` re-exports names explicitly, so this stays out of the
|
|
90
|
+
* package's public API.
|
|
91
|
+
*/
|
|
92
|
+
export declare const selectCompanions: (companions: AdCompanion[]) => {
|
|
93
|
+
logo: undefined;
|
|
94
|
+
media: AdCompanion;
|
|
95
|
+
} | {
|
|
96
|
+
logo: AdCompanion;
|
|
97
|
+
media: AdCompanion | undefined;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Map a parsed VAST response into our `AdData` shape. Shared by both acquisition
|
|
101
|
+
* paths — the GAM/VMAP tag flow and the Prebid auction flow — so the two always
|
|
102
|
+
* produce identical downstream data.
|
|
103
|
+
*
|
|
104
|
+
* Returns `undefined` for a no-ad, which is two distinct shapes: a response with no ad carrying
|
|
105
|
+
* creatives, and an ad whose creatives carry no RENDERABLE asset — no linear `<MediaFile>`, no
|
|
106
|
+
* `<NonLinear>` still and no `<Companion>` image. Both callers `filter(Boolean)` the result, so a
|
|
107
|
+
* no-ad leaves `ads: []` and the card keeps whatever the server configured for it.
|
|
108
|
+
*
|
|
109
|
+
* Exported for `vast-mapping.spec.ts`, which drives it over the same fixture corpus the iOS suite
|
|
110
|
+
* uses. `src/index.ts` re-exports names explicitly, so this stays out of the package's public API.
|
|
111
|
+
*/
|
|
112
|
+
export declare const buildAdData: (parsedVAST: VastResponse, order: number) => AdData | undefined;
|
|
113
|
+
/**
|
|
114
|
+
* Build the OpenRTB request body for a single video auction. Placement config is
|
|
115
|
+
* carried on the Prebid link's query string so the consumer-facing interface stays a
|
|
116
|
+
* single URL string:
|
|
117
|
+
* - `configId` → `imp.ext.prebid.storedrequest.id` (production stored request).
|
|
118
|
+
* - `storedauctionresponse` → `imp.ext.prebid.storedauctionresponse.id` (staging mock).
|
|
119
|
+
* - `test=1` → top-level `test` flag.
|
|
120
|
+
* - `w` / `h` → video dimensions (default 1920×1080).
|
|
121
|
+
*
|
|
122
|
+
* The host's GDPR consent (`AdTagParams`) rides the OpenRTB 2.5 extension fields Prebid Server
|
|
123
|
+
* reads — `regs.ext.gdpr` and `user.ext.consent` — the same signal the `${GDPR}` /
|
|
124
|
+
* `${GDPR_CONSENT}` macros carry for a VAST tag. Each is sent only when the host passed it.
|
|
125
|
+
*/
|
|
126
|
+
export declare const buildAuctionRequest: (url: URL, adTagParams?: AdTagParams) => Record<string, unknown>;
|
|
127
|
+
export declare const externalAd: (instance: StreamLayerContext) => (gamUrl: string) => import("@nanostores/query", { with: { "resolution-mode": "import" } }).FetcherStore<AdStore | undefined, any>;
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var sdkWebLogger = require('@streamlayer/sdk-web-logger');
|
|
4
|
+
var vastClient = require('@dailymotion/vast-client');
|
|
5
|
+
require('@streamlayer/sdk-web-api');
|
|
6
|
+
var VMAP = require('vmap');
|
|
7
|
+
var gam = require('./gam.js');
|
|
8
|
+
var parseVastExtensions = require('./parse-vast-extensions.js');
|
|
9
|
+
var sponsorLogo = require('./sponsor-logo.js');
|
|
10
|
+
|
|
11
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
12
|
+
|
|
13
|
+
var VMAP__default = /*#__PURE__*/_interopDefault(VMAP);
|
|
14
|
+
|
|
15
|
+
// Pins the `@dailymotion/vast-client` → `@types/vast-client` shim: TypeDoc
|
|
16
|
+
// compiles this file through dependent packages' programs, whose tsconfig
|
|
17
|
+
// `include` does not cover this package's ambient declarations.
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
|
19
|
+
/// <reference path="../module.d.ts" />
|
|
20
|
+
const log = sdkWebLogger.createLogger('ui:gam-external-ad');
|
|
21
|
+
/** Default budget for a single VAST resolution before it is treated as "no ad". */
|
|
22
|
+
const VAST_FETCH_TIMEOUT_MS = 10_000;
|
|
23
|
+
/**
|
|
24
|
+
* Resolve `promise`, but fall back to `undefined` if it doesn't settle within `ms`.
|
|
25
|
+
* A slow/unreachable VAST endpoint must degrade to "no ad" instead of leaving the
|
|
26
|
+
* fetcher (and the sidebar) stuck waiting. Uses a timer rather than `AbortController`
|
|
27
|
+
* so it stays safe on the ES5/Chrome-32 target.
|
|
28
|
+
*/
|
|
29
|
+
const withTimeout = (promise, ms) => {
|
|
30
|
+
return Promise.race([
|
|
31
|
+
promise,
|
|
32
|
+
new Promise((resolve) => {
|
|
33
|
+
setTimeout(() => resolve(undefined), ms);
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
36
|
+
};
|
|
37
|
+
// const preloadImage = (src: string) => {
|
|
38
|
+
// return new Promise((resolve, reject) => {
|
|
39
|
+
// const image = new Image()
|
|
40
|
+
// image.onload = () => resolve(src)
|
|
41
|
+
// image.onerror = () => reject()
|
|
42
|
+
// image.src = src
|
|
43
|
+
// })
|
|
44
|
+
// }
|
|
45
|
+
/**
|
|
46
|
+
* Get the first valid creative from an ad
|
|
47
|
+
*/
|
|
48
|
+
const getCreative = (ad) => {
|
|
49
|
+
return ad.creatives.find((creative) => getMediaFileUrl(creative));
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Get the URL of the first media file in the creative
|
|
53
|
+
*/
|
|
54
|
+
const getMediaFileUrl = (creative) => {
|
|
55
|
+
const mediaFiles = creative?.mediaFiles;
|
|
56
|
+
if (!mediaFiles?.length) {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
return mediaFiles.find((mediaFile) => mediaFile.fileURL)?.fileURL || '';
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Extract the linear creative's `<ClickThrough>` URL. Sources the sidebar CTA (QR code
|
|
63
|
+
* + click-through). `videoClickThroughURLTemplate` isn't strongly typed in the client lib.
|
|
64
|
+
*/
|
|
65
|
+
const getClickThrough = (creative) => {
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
67
|
+
// @ts-ignore — vast-client types `videoClickThroughURLTemplate` loosely
|
|
68
|
+
return creative?.videoClickThroughURLTemplate?.url || undefined;
|
|
69
|
+
};
|
|
70
|
+
/** Product companion mapping: the 300x75 companion is the logo, the banner-sized one the media. */
|
|
71
|
+
const LOGO_COMPANION_WIDTH = 300;
|
|
72
|
+
const LOGO_COMPANION_HEIGHT = 75;
|
|
73
|
+
const isLogoSized = (companion) => companion?.width === LOGO_COMPANION_WIDTH && companion?.height === LOGO_COMPANION_HEIGHT;
|
|
74
|
+
const companionArea = (companion) => (companion.width || 0) * (companion.height || 0);
|
|
75
|
+
/**
|
|
76
|
+
* Split a `<CompanionAds>` creative's variations into the logo and media slots.
|
|
77
|
+
*
|
|
78
|
+
* The pair mapping activates only for a tag that ships an exact 300x75 companion next to at
|
|
79
|
+
* least one other. Deliberately conservative pending broader product sizes: on any other tag
|
|
80
|
+
* no companion is promoted to the logo slot and the first one stays the media/strip candidate.
|
|
81
|
+
*
|
|
82
|
+
* Both halves of that gate are load-bearing and neither is an aspect rule: a 320x80 companion is
|
|
83
|
+
* the same 4:1 shape as 300x75 and is still rejected, and a lone 300x75 companion is rejected too
|
|
84
|
+
* (the Standard Ad Sidebar recovers that one as `AdData.loneLogo` when a Linear / NonLinear is
|
|
85
|
+
* the media, so it is not needed here).
|
|
86
|
+
* This is **narrower than iOS**, which has no size rule at all and fills its logo slot from a
|
|
87
|
+
* single companion — see `docs/wiki/programmatic-ads-display-ios-parity.md`. Exported for
|
|
88
|
+
* `external-ad.spec.ts` only; `src/index.ts` re-exports names explicitly, so this stays out of the
|
|
89
|
+
* package's public API.
|
|
90
|
+
*/
|
|
91
|
+
const selectCompanions = (companions) => {
|
|
92
|
+
const logo = companions.length > 1 ? companions.find((companion) => isLogoSized(companion)) : undefined;
|
|
93
|
+
if (!logo) {
|
|
94
|
+
return { logo: undefined, media: companions[0] };
|
|
95
|
+
}
|
|
96
|
+
const media = companions
|
|
97
|
+
.filter((companion) => companion !== logo)
|
|
98
|
+
.sort((a, b) => companionArea(a) - companionArea(b))
|
|
99
|
+
.pop();
|
|
100
|
+
return { logo, media };
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Map a parsed VAST response into our `AdData` shape. Shared by both acquisition
|
|
104
|
+
* paths — the GAM/VMAP tag flow and the Prebid auction flow — so the two always
|
|
105
|
+
* produce identical downstream data.
|
|
106
|
+
*
|
|
107
|
+
* Returns `undefined` for a no-ad, which is two distinct shapes: a response with no ad carrying
|
|
108
|
+
* creatives, and an ad whose creatives carry no RENDERABLE asset — no linear `<MediaFile>`, no
|
|
109
|
+
* `<NonLinear>` still and no `<Companion>` image. Both callers `filter(Boolean)` the result, so a
|
|
110
|
+
* no-ad leaves `ads: []` and the card keeps whatever the server configured for it.
|
|
111
|
+
*
|
|
112
|
+
* Exported for `vast-mapping.spec.ts`, which drives it over the same fixture corpus the iOS suite
|
|
113
|
+
* uses. `src/index.ts` re-exports names explicitly, so this stays out of the package's public API.
|
|
114
|
+
*/
|
|
115
|
+
const buildAdData = (parsedVAST, order) => {
|
|
116
|
+
// Defensive, and unreachable through either production path: `@dailymotion/vast-client` discards
|
|
117
|
+
// an ad whose creatives all fail to parse (empty `<Linear>`, empty/missing `<Creatives>`, unknown
|
|
118
|
+
// creative element) before `parseVAST` resolves, so it never yields an Ad with zero creatives.
|
|
119
|
+
// Kept because it costs nothing and `buildAdData` is also reachable from tests with a hand-built
|
|
120
|
+
// response — see the `invalid_ad` case in `vast-mapping.spec.ts`, which pins the parser contract
|
|
121
|
+
// this relies on rather than pretending to exercise the guard.
|
|
122
|
+
const validAd = parsedVAST.ads.find((ads) => ads.creatives.length > 0);
|
|
123
|
+
if (!validAd) {
|
|
124
|
+
log.warn({ order, ads: parsedVAST.ads.length }, 'vast response carries no ad with creatives — treated as no-ad');
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const extension = validAd.extensions.find((ext) => ext.attributes.type.startsWith('streamlayer'))?.children[0];
|
|
128
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
129
|
+
// @ts-expect-error
|
|
130
|
+
const parsedExtension = parseVastExtensions.parseVastExtension(extension);
|
|
131
|
+
const creative = getCreative(validAd);
|
|
132
|
+
const mediaFileUrl = getMediaFileUrl(creative);
|
|
133
|
+
const images = sponsorLogo.collectAssets(validAd);
|
|
134
|
+
const companions = images.filter((asset) => asset.origin === 'companion');
|
|
135
|
+
const nonLinear = images.find((asset) => asset.origin === 'nonlinear');
|
|
136
|
+
// An ad the parser KEPT whose creatives carry nothing any shell can draw is a no-ad, exactly
|
|
137
|
+
// like a response with no ad at all. vast-client only discards a creative it cannot parse; a
|
|
138
|
+
// `<NonLinear>` or `<Companion>` whose `<StaticResource>` body is empty parses fine and just
|
|
139
|
+
// yields no url, so the guard above cannot catch it. Without this the fabricated promotion still
|
|
140
|
+
// took the slot and drew the ad's TEXT — title and description over an empty media box — which
|
|
141
|
+
// is a billable impression for a creative that does not exist.
|
|
142
|
+
//
|
|
143
|
+
// "Renderable" is the union of the three slots the shells can fill: a linear `<MediaFile>`, a
|
|
144
|
+
// `<NonLinear>` still, or a `<Companion>` image. `collectAssets` already drops the imageless
|
|
145
|
+
// ones, so a non-empty `companions` means at least one drawable image. A tag carrying only a
|
|
146
|
+
// `<Mezzanine>` never reaches here — the parser drops that Linear itself — but it lands in the
|
|
147
|
+
// same place either way.
|
|
148
|
+
if (!mediaFileUrl && !nonLinear && companions.length === 0) {
|
|
149
|
+
log.warn({ order, adId: validAd.id, creatives: validAd.creatives.length }, 'vast ad carries no renderable asset (no media file, no nonlinear, no companion) — treated as no-ad');
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
const { logo, media: mediaCompanion } = selectCompanions(companions);
|
|
153
|
+
const clickThrough = getClickThrough(creative) || nonLinear?.clickUrl || mediaCompanion?.clickUrl;
|
|
154
|
+
// Mirror iOS's greedy `pool.select([hero, .cardLogo])`: the hero claims first and a claimed
|
|
155
|
+
// asset is gone for later roles. Web's hero precedence (`createActiveAdFromVast`) is linear
|
|
156
|
+
// video → pair media companion (unreachable here: a `logo` short-circuits the whole selection)
|
|
157
|
+
// → NonLinear still → lone companion, so a companion is only unavailable to the logo slot when
|
|
158
|
+
// it IS the hero, i.e. when the tag ships neither. `selectCardLogo` already filters to companion
|
|
159
|
+
// origins, so the NonLinear never needs excluding.
|
|
160
|
+
//
|
|
161
|
+
// Excluding `companions[0]` (which is what `mediaCompanion` is when there is no `logo`) rather
|
|
162
|
+
// than the largest-area asset is a deliberate difference from iOS's `areaDominant` hero: web's
|
|
163
|
+
// content slot draws the FIRST companion in document order, so document order is the correct
|
|
164
|
+
// criterion for "what the hero already claimed" on this platform.
|
|
165
|
+
const companionIsHero = !mediaFileUrl && !nonLinear;
|
|
166
|
+
const cardLogoAsset = logo ? undefined : sponsorLogo.selectCardLogo(validAd, companionIsHero ? [mediaCompanion] : []);
|
|
167
|
+
// Share the selected resource wrapper so drawn-companion identity remains stable.
|
|
168
|
+
const cardLogo = cardLogoAsset
|
|
169
|
+
? companions.find((item) => item.variation === cardLogoAsset.variation && item.imageUrl === cardLogoAsset.imageUrl)
|
|
170
|
+
: undefined;
|
|
171
|
+
// Same collected instance as `companion` (identity matters to `drawnCompanions`). A lone
|
|
172
|
+
// companion never satisfies the pair rule, so this can only fill a slot `logo` left empty.
|
|
173
|
+
const loneLogo = companions.length === 1 && !companionIsHero && isLogoSized(companions[0]) ? companions[0] : undefined;
|
|
174
|
+
log.info({
|
|
175
|
+
order,
|
|
176
|
+
adId: validAd.id,
|
|
177
|
+
title: validAd.title || undefined,
|
|
178
|
+
found: {
|
|
179
|
+
linear: !!creative,
|
|
180
|
+
mediaFile: mediaFileUrl || undefined,
|
|
181
|
+
nonLinear: nonLinear ? `${nonLinear.width}x${nonLinear.height}` : undefined,
|
|
182
|
+
companions: companions.map((item) => `${item.width}x${item.height}`),
|
|
183
|
+
},
|
|
184
|
+
selected: {
|
|
185
|
+
logo: logo ? { size: `${logo.width}x${logo.height}`, url: logo.imageUrl } : undefined,
|
|
186
|
+
media: mediaCompanion
|
|
187
|
+
? { size: `${mediaCompanion.width}x${mediaCompanion.height}`, url: mediaCompanion.imageUrl }
|
|
188
|
+
: undefined,
|
|
189
|
+
cardLogo: cardLogo ? { size: `${cardLogo.width}x${cardLogo.height}`, url: cardLogo.imageUrl } : undefined,
|
|
190
|
+
loneLogo: loneLogo ? { size: `${loneLogo.width}x${loneLogo.height}`, url: loneLogo.imageUrl } : undefined,
|
|
191
|
+
clickThrough: !!clickThrough,
|
|
192
|
+
},
|
|
193
|
+
}, 'vast ad parsed');
|
|
194
|
+
return {
|
|
195
|
+
creative,
|
|
196
|
+
order,
|
|
197
|
+
mediaFileUrl,
|
|
198
|
+
companion: companions[0],
|
|
199
|
+
logo,
|
|
200
|
+
cardLogo,
|
|
201
|
+
loneLogo,
|
|
202
|
+
mediaCompanion,
|
|
203
|
+
nonLinear,
|
|
204
|
+
clickThrough,
|
|
205
|
+
title: validAd.title || undefined,
|
|
206
|
+
description: validAd.description || undefined,
|
|
207
|
+
ad: validAd,
|
|
208
|
+
extension: parsedExtension,
|
|
209
|
+
raw: parsedVAST,
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
/** Path of the self-hosted Prebid Server OpenRTB auction endpoint. */
|
|
213
|
+
const PREBID_AUCTION_PATH = '/openrtb2/auction';
|
|
214
|
+
/**
|
|
215
|
+
* A Prebid link points at our Prebid Server auction endpoint (staging:
|
|
216
|
+
* `https://prebid.next.streamlayer.io/openrtb2/auction`) rather than at a VAST tag.
|
|
217
|
+
* Detected by path so both staging and production hosts match.
|
|
218
|
+
*/
|
|
219
|
+
const isPrebidUrl = (url) => url.pathname.endsWith(PREBID_AUCTION_PATH);
|
|
220
|
+
/** Unique per-auction request id — Prebid responses are never cached/reused (RFC §5). */
|
|
221
|
+
const createRequestId = () => `sl-web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
222
|
+
/**
|
|
223
|
+
* Build the OpenRTB request body for a single video auction. Placement config is
|
|
224
|
+
* carried on the Prebid link's query string so the consumer-facing interface stays a
|
|
225
|
+
* single URL string:
|
|
226
|
+
* - `configId` → `imp.ext.prebid.storedrequest.id` (production stored request).
|
|
227
|
+
* - `storedauctionresponse` → `imp.ext.prebid.storedauctionresponse.id` (staging mock).
|
|
228
|
+
* - `test=1` → top-level `test` flag.
|
|
229
|
+
* - `w` / `h` → video dimensions (default 1920×1080).
|
|
230
|
+
*
|
|
231
|
+
* The host's GDPR consent (`AdTagParams`) rides the OpenRTB 2.5 extension fields Prebid Server
|
|
232
|
+
* reads — `regs.ext.gdpr` and `user.ext.consent` — the same signal the `${GDPR}` /
|
|
233
|
+
* `${GDPR_CONSENT}` macros carry for a VAST tag. Each is sent only when the host passed it.
|
|
234
|
+
*/
|
|
235
|
+
const buildAuctionRequest = (url, adTagParams) => {
|
|
236
|
+
const params = url.searchParams;
|
|
237
|
+
const width = Number(params.get('w')) || 1920;
|
|
238
|
+
const height = Number(params.get('h')) || 1080;
|
|
239
|
+
const configId = params.get('configId') ?? params.get('config_id') ?? undefined;
|
|
240
|
+
const storedAuctionResponse = params.get('storedauctionresponse') ?? undefined;
|
|
241
|
+
const prebid = {};
|
|
242
|
+
if (storedAuctionResponse) {
|
|
243
|
+
prebid.storedauctionresponse = { id: storedAuctionResponse };
|
|
244
|
+
}
|
|
245
|
+
if (configId) {
|
|
246
|
+
prebid.storedrequest = { id: configId };
|
|
247
|
+
}
|
|
248
|
+
prebid.bidder = {
|
|
249
|
+
appnexus: { placementId: 13144370 },
|
|
250
|
+
};
|
|
251
|
+
const request = {
|
|
252
|
+
id: createRequestId(),
|
|
253
|
+
imp: [
|
|
254
|
+
{
|
|
255
|
+
id: '1',
|
|
256
|
+
video: {
|
|
257
|
+
mimes: ['video/mp4'],
|
|
258
|
+
protocols: [2, 3, 5, 6],
|
|
259
|
+
w: width,
|
|
260
|
+
h: height,
|
|
261
|
+
placement: 1,
|
|
262
|
+
linearity: 1,
|
|
263
|
+
},
|
|
264
|
+
ext: { prebid },
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
site: { page: typeof location !== 'undefined' ? location.href : url.origin },
|
|
268
|
+
tmax: 500,
|
|
269
|
+
};
|
|
270
|
+
if (params.get('test') === '1') {
|
|
271
|
+
request.test = 1;
|
|
272
|
+
}
|
|
273
|
+
if (adTagParams?.gdprApplies !== undefined) {
|
|
274
|
+
request.regs = { ext: { gdpr: adTagParams.gdprApplies ? 1 : 0 } };
|
|
275
|
+
}
|
|
276
|
+
if (adTagParams?.tcString) {
|
|
277
|
+
request.user = { ext: { consent: adTagParams.tcString } };
|
|
278
|
+
}
|
|
279
|
+
return request;
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* Run one Prebid Server auction and turn the winning inline VAST into an `AdStore`.
|
|
283
|
+
* Every bid's `adm` is inline VAST XML (no URL to fetch); bids are ordered by price
|
|
284
|
+
* so the winner plays first (RFC §2). A single bad bid must not drop the batch.
|
|
285
|
+
*/
|
|
286
|
+
const fetchPrebidAds = async (prebidUrl, adTagParams) => {
|
|
287
|
+
const response = await fetch(prebidUrl.toString(), {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'Content-Type': 'application/json' },
|
|
290
|
+
body: JSON.stringify(buildAuctionRequest(prebidUrl, adTagParams)),
|
|
291
|
+
});
|
|
292
|
+
if (!response.ok) {
|
|
293
|
+
throw new Error(`prebid auction failed with status ${response.status}`);
|
|
294
|
+
}
|
|
295
|
+
const auction = (await response.json());
|
|
296
|
+
const bids = (auction.seatbid ?? [])
|
|
297
|
+
.reduce((acc, seat) => acc.concat(seat.bid ?? []), [])
|
|
298
|
+
.filter((bid) => !!bid.adm)
|
|
299
|
+
// Highest price wins — the winner is order 0 (RFC §2).
|
|
300
|
+
.sort((a, b) => (b.price ?? 0) - (a.price ?? 0));
|
|
301
|
+
const parser = new DOMParser();
|
|
302
|
+
const loadedAds = await Promise.all(bids.map((bid, i) => {
|
|
303
|
+
const xmlDoc = parser.parseFromString(bid.adm, 'text/xml');
|
|
304
|
+
// A fresh `VASTParser` per bid isolates parser state, mirroring the fresh
|
|
305
|
+
// `VASTClient` per link in the tag flow. `resolveAll: true` follows any
|
|
306
|
+
// `VASTAdTagURI` wrapper chain the bid's inline VAST may still contain.
|
|
307
|
+
return (new vastClient.VASTParser()
|
|
308
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
309
|
+
// @ts-ignore
|
|
310
|
+
.parseVAST(xmlDoc, { resolveAll: true, wrapperLimit: 10, allowMultipleAds: true })
|
|
311
|
+
.then((parsedVAST) => buildAdData(parsedVAST, i))
|
|
312
|
+
.catch((err) => {
|
|
313
|
+
// A single failing bid must not drop the whole batch.
|
|
314
|
+
log.error(err, 'failed to parse prebid VAST');
|
|
315
|
+
return undefined;
|
|
316
|
+
}));
|
|
317
|
+
}));
|
|
318
|
+
const params = prebidUrl.searchParams;
|
|
319
|
+
const autoplay = params.get('vpa')?.replaceAll('?', '') !== 'click';
|
|
320
|
+
const muted = params.get('vpmute')?.replaceAll('?', '') === '1';
|
|
321
|
+
return {
|
|
322
|
+
ads: loadedAds.filter((d) => !!d),
|
|
323
|
+
autoplay,
|
|
324
|
+
muted,
|
|
325
|
+
};
|
|
326
|
+
};
|
|
327
|
+
const externalAd = (instance) => {
|
|
328
|
+
return (gamUrl) => instance.transport.nanoquery.createFetcherStore([gamUrl], {
|
|
329
|
+
dedupeTime: Infinity,
|
|
330
|
+
revalidateInterval: 0,
|
|
331
|
+
fetcher: async (adUrl) => {
|
|
332
|
+
if (!adUrl || typeof adUrl !== 'string') {
|
|
333
|
+
throw new Error('ad url is empty');
|
|
334
|
+
}
|
|
335
|
+
const parsedAdUrl = (() => {
|
|
336
|
+
try {
|
|
337
|
+
return new URL(adUrl);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
})();
|
|
343
|
+
// The host's CURRENT macro values, read per request rather than baked into the store: the
|
|
344
|
+
// store is cached by url for the session, and consent routinely arrives after the SDK
|
|
345
|
+
// starts (a CMP answers asynchronously) or changes mid-session.
|
|
346
|
+
const adTagParams = instance.sdk.options.get().adTagParams;
|
|
347
|
+
// Prebid links hit our Prebid Server auction endpoint instead of a VAST tag.
|
|
348
|
+
// Run the OpenRTB auction and parse the winning bid's inline VAST (`adm`) into
|
|
349
|
+
// the same `AdStore` shape the GAM/VMAP path returns. See rfcs/prebid.
|
|
350
|
+
if (parsedAdUrl && isPrebidUrl(parsedAdUrl)) {
|
|
351
|
+
return fetchPrebidAds(parsedAdUrl, adTagParams);
|
|
352
|
+
}
|
|
353
|
+
const generateUrl = gam.gam(adUrl, undefined, adTagParams);
|
|
354
|
+
const generatedUrl = await generateUrl();
|
|
355
|
+
const noncedLink = generatedUrl?.url;
|
|
356
|
+
if (!noncedLink) {
|
|
357
|
+
throw new Error('ad url is not generated');
|
|
358
|
+
}
|
|
359
|
+
let links;
|
|
360
|
+
if (noncedLink.searchParams.get('output')?.replaceAll('?', '') === 'vmap') {
|
|
361
|
+
const response = await withTimeout(fetch(noncedLink.toString()), VAST_FETCH_TIMEOUT_MS);
|
|
362
|
+
if (!response) {
|
|
363
|
+
throw new Error('vmap fetch timed out');
|
|
364
|
+
}
|
|
365
|
+
const parser = new DOMParser();
|
|
366
|
+
const xmlStr = await response.text();
|
|
367
|
+
const xmlDoc = parser.parseFromString(xmlStr, 'text/xml');
|
|
368
|
+
const vmap = new VMAP__default.default(xmlDoc);
|
|
369
|
+
links = vmap.adBreaks.map((ad) => {
|
|
370
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
371
|
+
// @ts-expect-error
|
|
372
|
+
return ad.adSource.adTagURI.uri;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
links = [noncedLink.toString()];
|
|
377
|
+
}
|
|
378
|
+
const loadedAds = await Promise.all(links.map((link, i) => {
|
|
379
|
+
// `resolveAll: true` makes the client follow every `VASTAdTagURI` wrapper chain
|
|
380
|
+
// (up to the default depth of 10) and resolve all Ads, merging wrapper-level
|
|
381
|
+
// Impression/tracking URLs into the inline Ad. The default (`resolveAll: false`)
|
|
382
|
+
// only resolves the first standalone Ad and defers wrappers/remaining Ads to
|
|
383
|
+
// `getNextAds()`, which this fetcher never calls. A fresh `VASTClient` per link
|
|
384
|
+
// isolates parser state so concurrent (VMAP) resolutions don't clobber each other.
|
|
385
|
+
return withTimeout(new vastClient.VASTClient()
|
|
386
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
387
|
+
// @ts-ignore
|
|
388
|
+
.get(link, { resolveAll: true, wrapperLimit: 10, allowMultipleAds: true }), VAST_FETCH_TIMEOUT_MS)
|
|
389
|
+
.then((parsedVAST) => {
|
|
390
|
+
// `parsedVAST` is `undefined` when the resolution timed out (see `withTimeout`).
|
|
391
|
+
if (!parsedVAST) {
|
|
392
|
+
log.warn({ link, timeoutMs: VAST_FETCH_TIMEOUT_MS }, 'vast resolution timed out — treated as no-ad');
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
return buildAdData(parsedVAST, i);
|
|
396
|
+
})
|
|
397
|
+
.catch((err) => {
|
|
398
|
+
// A single failing wrapper chain must not drop the whole batch.
|
|
399
|
+
log.error(err, 'failed to resolve VAST');
|
|
400
|
+
return undefined;
|
|
401
|
+
});
|
|
402
|
+
}));
|
|
403
|
+
const autoplay = noncedLink.searchParams.get('vpa')?.replaceAll('?', '') !== 'click';
|
|
404
|
+
const muted = noncedLink.searchParams.get('vpmute')?.replaceAll('?', '') === '1';
|
|
405
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
406
|
+
// @ts-expect-error
|
|
407
|
+
const nonceManager = generatedUrl.manager;
|
|
408
|
+
return {
|
|
409
|
+
ads: loadedAds.filter((d) => !!d),
|
|
410
|
+
autoplay,
|
|
411
|
+
muted,
|
|
412
|
+
nonceManager,
|
|
413
|
+
};
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
exports.buildAdData = buildAdData;
|
|
419
|
+
exports.buildAuctionRequest = buildAuctionRequest;
|
|
420
|
+
exports.externalAd = externalAd;
|
|
421
|
+
exports.selectCompanions = selectCompanions;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createSingleStore, StreamLayerContext } from '@streamlayer/sdk-web-interfaces';
|
|
2
|
+
import '@streamlayer/sdk-web-api';
|
|
3
|
+
export type GAMStaticCore = {
|
|
4
|
+
id: string;
|
|
5
|
+
parentId: string;
|
|
6
|
+
url: string;
|
|
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;
|
|
15
|
+
adUrl?: string;
|
|
16
|
+
ad?: any;
|
|
17
|
+
creative?: any;
|
|
18
|
+
variation?: any;
|
|
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;
|
|
36
|
+
export type ExternalPauseAdOptions = {
|
|
37
|
+
refetchInterval: number;
|
|
38
|
+
prefetch: boolean;
|
|
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;
|
|
49
|
+
};
|
|
50
|
+
export declare const externalPauseAd: (instance: StreamLayerContext) => (gamUrl: string, $enabled: ReturnType<typeof createSingleStore<boolean>>, options: ExternalPauseAdOptions) => import("@nanostores/query", { with: { "resolution-mode": "import" } }).FetcherStore<GAMStaticCore | undefined, any>;
|