@streamlayer/feature-advertisement 1.1.0 → 1.1.1-rc.10.p6eb7304a

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.
Files changed (103) hide show
  1. package/classic/package.json +5 -0
  2. package/lib/advertisement.d.ts +6 -6
  3. package/lib/advertisement.js +32 -17
  4. package/lib/cjs/advertisement.d.ts +64 -0
  5. package/lib/cjs/advertisement.js +322 -0
  6. package/lib/cjs/index.d.ts +21 -0
  7. package/lib/cjs/index.js +41 -0
  8. package/lib/cjs/package.json +1 -0
  9. package/lib/cjs/units/ad-macros.d.ts +7 -0
  10. package/lib/cjs/units/ad-macros.js +91 -0
  11. package/lib/cjs/units/external-ad.d.ts +127 -0
  12. package/lib/cjs/units/external-ad.js +421 -0
  13. package/lib/cjs/units/external-pause-ad.d.ts +50 -0
  14. package/lib/cjs/units/external-pause-ad.js +259 -0
  15. package/lib/cjs/units/gam.d.ts +26 -0
  16. package/lib/cjs/units/gam.js +152 -0
  17. package/lib/cjs/units/parse-vast-extensions.d.ts +26 -0
  18. package/lib/cjs/units/parse-vast-extensions.js +56 -0
  19. package/lib/cjs/units/pause-ad-card.d.ts +49 -0
  20. package/lib/cjs/units/pause-ad-card.js +136 -0
  21. package/lib/cjs/units/pause-ad.d.ts +16 -0
  22. package/lib/cjs/units/pause-ad.js +52 -0
  23. package/lib/cjs/units/sponsor-logo.d.ts +78 -0
  24. package/lib/cjs/units/sponsor-logo.js +194 -0
  25. package/lib/cjs/units/ssai-ad-break.d.ts +21 -0
  26. package/lib/cjs/units/ssai-ad-break.js +11 -0
  27. package/lib/cjs/units/ssai-creative.d.ts +89 -0
  28. package/lib/cjs/units/ssai-creative.js +136 -0
  29. package/lib/cjs/units/ssai-detector.d.ts +180 -0
  30. package/lib/cjs/units/ssai-detector.js +1172 -0
  31. package/lib/cjs/units/ssai-log.d.ts +16 -0
  32. package/lib/cjs/units/ssai-log.js +23 -0
  33. package/lib/cjs/units/ssai-preload.d.ts +20 -0
  34. package/lib/cjs/units/ssai-preload.js +62 -0
  35. package/lib/cjs/units/ssai-providers.d.ts +117 -0
  36. package/lib/cjs/units/ssai-providers.js +300 -0
  37. package/lib/cjs/units/vast-tracker.d.ts +45 -0
  38. package/lib/cjs/units/vast-tracker.js +64 -0
  39. package/lib/classic/_virtual/_rollupPluginBabelHelpers.js +71 -0
  40. package/lib/classic/advertisement.js +348 -0
  41. package/lib/classic/cjs/_virtual/_rollupPluginBabelHelpers.js +77 -0
  42. package/lib/classic/cjs/advertisement.js +350 -0
  43. package/lib/classic/cjs/index.js +49 -0
  44. package/lib/classic/cjs/package.json +1 -0
  45. package/lib/classic/cjs/units/ad-macros.js +115 -0
  46. package/lib/classic/cjs/units/external-ad.js +500 -0
  47. package/lib/classic/cjs/units/external-pause-ad.js +278 -0
  48. package/lib/classic/cjs/units/gam.js +173 -0
  49. package/lib/classic/cjs/units/parse-vast-extensions.js +60 -0
  50. package/lib/classic/cjs/units/pause-ad-card.js +134 -0
  51. package/lib/classic/cjs/units/pause-ad.js +72 -0
  52. package/lib/classic/cjs/units/sponsor-logo.js +201 -0
  53. package/lib/classic/cjs/units/ssai-ad-break.js +11 -0
  54. package/lib/classic/cjs/units/ssai-creative.js +142 -0
  55. package/lib/classic/cjs/units/ssai-detector.js +1195 -0
  56. package/lib/classic/cjs/units/ssai-log.js +23 -0
  57. package/lib/classic/cjs/units/ssai-preload.js +72 -0
  58. package/lib/classic/cjs/units/ssai-providers.js +361 -0
  59. package/lib/classic/cjs/units/vast-tracker.js +81 -0
  60. package/lib/classic/index.js +29 -0
  61. package/lib/classic/units/ad-macros.js +113 -0
  62. package/lib/classic/units/external-ad.js +491 -0
  63. package/lib/classic/units/external-pause-ad.js +275 -0
  64. package/lib/classic/units/gam.js +171 -0
  65. package/lib/classic/units/parse-vast-extensions.js +58 -0
  66. package/lib/classic/units/pause-ad-card.js +131 -0
  67. package/lib/classic/units/pause-ad.js +70 -0
  68. package/lib/classic/units/sponsor-logo.js +197 -0
  69. package/lib/classic/units/ssai-ad-break.js +9 -0
  70. package/lib/classic/units/ssai-creative.js +140 -0
  71. package/lib/classic/units/ssai-detector.js +1192 -0
  72. package/lib/classic/units/ssai-log.js +21 -0
  73. package/lib/classic/units/ssai-preload.js +68 -0
  74. package/lib/classic/units/ssai-providers.js +351 -0
  75. package/lib/classic/units/vast-tracker.js +77 -0
  76. package/lib/index.d.ts +15 -13
  77. package/lib/index.js +9 -8
  78. package/lib/units/ad-macros.d.ts +7 -0
  79. package/lib/units/ad-macros.js +87 -0
  80. package/lib/units/external-ad.d.ts +71 -2
  81. package/lib/units/external-ad.js +94 -66
  82. package/lib/units/external-pause-ad.js +7 -1
  83. package/lib/units/gam.d.ts +11 -1
  84. package/lib/units/gam.js +65 -17
  85. package/lib/units/parse-vast-extensions.d.ts +10 -1
  86. package/lib/units/parse-vast-extensions.js +5 -1
  87. package/lib/units/pause-ad.d.ts +1 -1
  88. package/lib/units/pause-ad.js +1 -1
  89. package/lib/units/sponsor-logo.d.ts +78 -0
  90. package/lib/units/sponsor-logo.js +188 -0
  91. package/lib/units/ssai-ad-break.d.ts +1 -1
  92. package/lib/units/ssai-creative.d.ts +1 -1
  93. package/lib/units/ssai-creative.js +1 -1
  94. package/lib/units/ssai-detector.d.ts +1 -1
  95. package/lib/units/ssai-detector.js +27 -11
  96. package/lib/units/ssai-log.d.ts +16 -0
  97. package/lib/units/ssai-log.js +18 -0
  98. package/lib/units/ssai-preload.d.ts +1 -1
  99. package/lib/units/ssai-providers.d.ts +1 -1
  100. package/lib/units/ssai-providers.js +48 -11
  101. package/lib/units/vast-tracker.d.ts +1 -1
  102. package/lib/vast-client.d.ts +9 -0
  103. package/package.json +42 -9
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Ad-tag macro substitution, for every ad server — not just GAM.
5
+ *
6
+ * Studio stores a programmatic tag verbatim (`gamRawUrl`, returned untouched by
7
+ * `processGamAdvertisement`), so whatever macros the ad server's own tag builder emitted reach the
8
+ * SDK literal. `gam()` used to fill only the GAM `[placeholder]` token, and only for
9
+ * `pubads.g.doubleclick.net` — every other vendor's tag went out with its macros unexpanded.
10
+ *
11
+ * That is not inert. Measured against the ADITION tag on event 11089
12
+ * (`…/banner?…&ts=[timestamp]&clickurl=[clickmacro]`): ADITION prepends the value of `clickurl` to
13
+ * its own redi URL, so a literal `[clickmacro]` came back inside `<CompanionClickThrough>` as
14
+ * `[clickmacro]https%3A%2F%2Fad1…` — a broken click-through. Sending `clickurl=` empty answers with
15
+ * a valid redi URL.
16
+ *
17
+ * **Two sources.** The cache buster is the SDK's own. The consent family — `${GDPR}` and
18
+ * `${GDPR_CONSENT_<gvlid>}`, IAB TCF, where the id is the vendor's Global Vendor List entry (39 is
19
+ * ADITION / Virtual Minds) — and the click macro come from the host app (`AdTagParams`, set through
20
+ * the `adTagParams` option or `sdk.setAdTagParams`). The SDK never reads a CMP and never guesses
21
+ * consent: a value the host did not pass goes out EMPTY, which ad servers read as "unknown" — never
22
+ * as `gdpr=0`. Empty rather than literal because a literal is not inert either: ADITION copied
23
+ * `${GDPR}` into its own redirect URL (`gdpr%3D%24%7BGDPR%7D`). See
24
+ * `docs/wiki/programmatic-ads-macros.md`.
25
+ *
26
+ * An unknown macro is left exactly as configured, which is what keeps the GAM `[placeholder]`
27
+ * tokens for the PAL path (`gam.ts` fills those per parameter, not per token).
28
+ */
29
+ /**
30
+ * All three spellings in the wild — `[NAME]` (IAB / SpotX), `${NAME}` (Google) and bare `{NAME}` —
31
+ * each with its delimiters either literal or PERCENT-ENCODED.
32
+ *
33
+ * Neither half of that is theoretical. ADITION emitted `[timestamp]` / `[clickmacro]` for the
34
+ * campaign on event 11089 and `{CACHEBUSTER}` / `{GDPR}` for the one on 6203 — same ad server, same
35
+ * advertiser, different tag builder. And Studio stores the 6203 tag as `ts=%7BCACHEBUSTER%7D`, so
36
+ * matching only literal braces made the whole substitution a silent no-op on it: an unexpanded
37
+ * macro raises no error and the ad server still answers 200. `searchParams.get()` decodes on read,
38
+ * which is what hides the encoding from anyone inspecting the parsed URL.
39
+ *
40
+ * The `$` is optional rather than a fourth branch — an unowned `${NAME}` still returns its full
41
+ * matched text, `$` included, so the brace forms cannot corrupt each other.
42
+ */
43
+ const MACRO_PATTERN = /(?:\[|%5B)([a-z0-9_]+)(?:\]|%5D)|(?:\$|%24)?(?:\{|%7B)([a-z0-9_]+)(?:\}|%7D)/gi;
44
+ /**
45
+ * Every host value lands inside a query-string VALUE, so it is encoded exactly once. That matters
46
+ * most for the click URL, which carries its own `?`/`&`: sent raw it re-opens the tag's query, the
47
+ * way the malformed 6203 tag did (`clickurl` truncated, every following parameter duplicated).
48
+ */
49
+ const hostValue = (value) => (value ? encodeURIComponent(value) : '');
50
+ const gdprConsent = ({ params }) => hostValue(params?.tcString);
51
+ /**
52
+ * Macro name (lower-cased) → the value this SDK sends for it.
53
+ *
54
+ * `clickurl` / `clickmacro` take the host's click tracker, which the ad server PREPENDS to its own
55
+ * redirect — so the viewer's click passes host tracker → vendor redirect → landing page. The SDK
56
+ * does not fire that tracker itself; it would count the click twice. Without one it is empty, the
57
+ * documented "no player tracker" value. VAST click beacons are reported separately by
58
+ * `useSidebarVastTracker`.
59
+ */
60
+ const MACRO_VALUES = {
61
+ // One family, one value: every name here means "make this request unique so nothing caches it".
62
+ // Epoch milliseconds satisfies that for all of them, and keeps the value already shipped for
63
+ // `timestamp` unchanged. (IAB defines `[CACHEBUSTING]` as a random 8-digit number and
64
+ // `[TIMESTAMP]` as ISO-8601, but those describe VAST *tracking* URLs; this substitution only
65
+ // ever touches the ad REQUEST url, where every ad server documents the parameter as a number.)
66
+ timestamp: ({ now }) => String(now),
67
+ cachebuster: ({ now }) => String(now),
68
+ cachebusting: ({ now }) => String(now),
69
+ clickmacro: ({ params }) => hostValue(params?.clickUrl),
70
+ clickurl: ({ params }) => hostValue(params?.clickUrl),
71
+ // `1` / `0` only from an explicit boolean — an absent value is unknown, not "does not apply".
72
+ gdpr: ({ params }) => (params?.gdprApplies === undefined ? '' : params.gdprApplies ? '1' : '0'),
73
+ gdpr_consent: gdprConsent,
74
+ };
75
+ /** `GDPR_CONSENT_<gvlid>`: the vendor id names whose tag this is; the value is the same TC string. */
76
+ const GDPR_CONSENT_FOR_VENDOR = /^gdpr_consent_\d+$/;
77
+ const resolveMacro = (name) => MACRO_VALUES[name] ?? (GDPR_CONSENT_FOR_VENDOR.test(name) ? gdprConsent : undefined);
78
+ /**
79
+ * Fill the macros this SDK knows in an ad-tag URL. One timestamp per call, so a tag naming the macro
80
+ * twice stays self-consistent. `params` is read as given — the caller passes the host's CURRENT
81
+ * values at request time.
82
+ */
83
+ const substituteAdTagMacros = (url, params) => {
84
+ const now = Date.now();
85
+ return url.replace(MACRO_PATTERN, (match, bracketed, braced) => {
86
+ const value = resolveMacro((bracketed ?? braced ?? '').toLowerCase());
87
+ return value ? value({ now, params }) : match;
88
+ });
89
+ };
90
+
91
+ exports.substituteAdTagMacros = substituteAdTagMacros;
@@ -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;