@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,87 @@
1
+ /**
2
+ * Ad-tag macro substitution, for every ad server — not just GAM.
3
+ *
4
+ * Studio stores a programmatic tag verbatim (`gamRawUrl`, returned untouched by
5
+ * `processGamAdvertisement`), so whatever macros the ad server's own tag builder emitted reach the
6
+ * SDK literal. `gam()` used to fill only the GAM `[placeholder]` token, and only for
7
+ * `pubads.g.doubleclick.net` — every other vendor's tag went out with its macros unexpanded.
8
+ *
9
+ * That is not inert. Measured against the ADITION tag on event 11089
10
+ * (`…/banner?…&ts=[timestamp]&clickurl=[clickmacro]`): ADITION prepends the value of `clickurl` to
11
+ * its own redi URL, so a literal `[clickmacro]` came back inside `<CompanionClickThrough>` as
12
+ * `[clickmacro]https%3A%2F%2Fad1…` — a broken click-through. Sending `clickurl=` empty answers with
13
+ * a valid redi URL.
14
+ *
15
+ * **Two sources.** The cache buster is the SDK's own. The consent family — `${GDPR}` and
16
+ * `${GDPR_CONSENT_<gvlid>}`, IAB TCF, where the id is the vendor's Global Vendor List entry (39 is
17
+ * ADITION / Virtual Minds) — and the click macro come from the host app (`AdTagParams`, set through
18
+ * the `adTagParams` option or `sdk.setAdTagParams`). The SDK never reads a CMP and never guesses
19
+ * consent: a value the host did not pass goes out EMPTY, which ad servers read as "unknown" — never
20
+ * as `gdpr=0`. Empty rather than literal because a literal is not inert either: ADITION copied
21
+ * `${GDPR}` into its own redirect URL (`gdpr%3D%24%7BGDPR%7D`). See
22
+ * `docs/wiki/programmatic-ads-macros.md`.
23
+ *
24
+ * An unknown macro is left exactly as configured, which is what keeps the GAM `[placeholder]`
25
+ * tokens for the PAL path (`gam.ts` fills those per parameter, not per token).
26
+ */
27
+ /**
28
+ * All three spellings in the wild — `[NAME]` (IAB / SpotX), `${NAME}` (Google) and bare `{NAME}` —
29
+ * each with its delimiters either literal or PERCENT-ENCODED.
30
+ *
31
+ * Neither half of that is theoretical. ADITION emitted `[timestamp]` / `[clickmacro]` for the
32
+ * campaign on event 11089 and `{CACHEBUSTER}` / `{GDPR}` for the one on 6203 — same ad server, same
33
+ * advertiser, different tag builder. And Studio stores the 6203 tag as `ts=%7BCACHEBUSTER%7D`, so
34
+ * matching only literal braces made the whole substitution a silent no-op on it: an unexpanded
35
+ * macro raises no error and the ad server still answers 200. `searchParams.get()` decodes on read,
36
+ * which is what hides the encoding from anyone inspecting the parsed URL.
37
+ *
38
+ * The `$` is optional rather than a fourth branch — an unowned `${NAME}` still returns its full
39
+ * matched text, `$` included, so the brace forms cannot corrupt each other.
40
+ */
41
+ const MACRO_PATTERN = /(?:\[|%5B)([a-z0-9_]+)(?:\]|%5D)|(?:\$|%24)?(?:\{|%7B)([a-z0-9_]+)(?:\}|%7D)/gi;
42
+ /**
43
+ * Every host value lands inside a query-string VALUE, so it is encoded exactly once. That matters
44
+ * most for the click URL, which carries its own `?`/`&`: sent raw it re-opens the tag's query, the
45
+ * way the malformed 6203 tag did (`clickurl` truncated, every following parameter duplicated).
46
+ */
47
+ const hostValue = (value) => (value ? encodeURIComponent(value) : '');
48
+ const gdprConsent = ({ params }) => hostValue(params?.tcString);
49
+ /**
50
+ * Macro name (lower-cased) → the value this SDK sends for it.
51
+ *
52
+ * `clickurl` / `clickmacro` take the host's click tracker, which the ad server PREPENDS to its own
53
+ * redirect — so the viewer's click passes host tracker → vendor redirect → landing page. The SDK
54
+ * does not fire that tracker itself; it would count the click twice. Without one it is empty, the
55
+ * documented "no player tracker" value. VAST click beacons are reported separately by
56
+ * `useSidebarVastTracker`.
57
+ */
58
+ const MACRO_VALUES = {
59
+ // One family, one value: every name here means "make this request unique so nothing caches it".
60
+ // Epoch milliseconds satisfies that for all of them, and keeps the value already shipped for
61
+ // `timestamp` unchanged. (IAB defines `[CACHEBUSTING]` as a random 8-digit number and
62
+ // `[TIMESTAMP]` as ISO-8601, but those describe VAST *tracking* URLs; this substitution only
63
+ // ever touches the ad REQUEST url, where every ad server documents the parameter as a number.)
64
+ timestamp: ({ now }) => String(now),
65
+ cachebuster: ({ now }) => String(now),
66
+ cachebusting: ({ now }) => String(now),
67
+ clickmacro: ({ params }) => hostValue(params?.clickUrl),
68
+ clickurl: ({ params }) => hostValue(params?.clickUrl),
69
+ // `1` / `0` only from an explicit boolean — an absent value is unknown, not "does not apply".
70
+ gdpr: ({ params }) => (params?.gdprApplies === undefined ? '' : params.gdprApplies ? '1' : '0'),
71
+ gdpr_consent: gdprConsent,
72
+ };
73
+ /** `GDPR_CONSENT_<gvlid>`: the vendor id names whose tag this is; the value is the same TC string. */
74
+ const GDPR_CONSENT_FOR_VENDOR = /^gdpr_consent_\d+$/;
75
+ const resolveMacro = (name) => MACRO_VALUES[name] ?? (GDPR_CONSENT_FOR_VENDOR.test(name) ? gdprConsent : undefined);
76
+ /**
77
+ * Fill the macros this SDK knows in an ad-tag URL. One timestamp per call, so a tag naming the macro
78
+ * twice stays self-consistent. `params` is read as given — the caller passes the host's CURRENT
79
+ * values at request time.
80
+ */
81
+ export const substituteAdTagMacros = (url, params) => {
82
+ const now = Date.now();
83
+ return url.replace(MACRO_PATTERN, (match, bracketed, braced) => {
84
+ const value = resolveMacro((bracketed ?? braced ?? '').toLowerCase());
85
+ return value ? value({ now, params }) : match;
86
+ });
87
+ };
@@ -1,8 +1,9 @@
1
+ import type { AdTagParams } from '@streamlayer/sdk-web-core';
1
2
  import { StreamLayerContext } from '@streamlayer/sdk-web-interfaces';
2
3
  import { VastAd, VastCompanionAd, VastCreativeCompanion, VastCreativeLinear, VastCreativeNonLinear, VastNonLinearAd, VastResponse } from '@dailymotion/vast-client';
3
4
  import '@streamlayer/sdk-web-api';
4
- import { type PalNonceManager } from './gam';
5
- import { VastCreative } from './parse-vast-extensions';
5
+ import { type PalNonceManager } from './gam.js';
6
+ import { VastCreative } from './parse-vast-extensions.js';
6
7
  export type AdCompanion = {
7
8
  imageUrl?: string;
8
9
  clickUrl?: string;
@@ -35,6 +36,23 @@ export type AdData = {
35
36
  companion?: AdCompanion;
36
37
  /** The 300x75 `<Companion>` — the sponsor logo. Only set for a companion pair. */
37
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;
38
56
  /** Banner-sized `<Companion>` (468x60 class) — the sidebar media for companion-pair tags. */
39
57
  mediaCompanion?: AdCompanion;
40
58
  /** NonLinear (image-only) creative — present when the tag has no `<Linear>` media. */
@@ -55,4 +73,55 @@ export type AdStore = {
55
73
  muted: boolean;
56
74
  nonceManager?: PalNonceManager;
57
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>;
58
127
  export declare const externalAd: (instance: StreamLayerContext) => (gamUrl: string) => import("@nanostores/query").FetcherStore<AdStore | undefined, any>;
@@ -7,8 +7,9 @@ import { createLogger } from '@streamlayer/sdk-web-logger';
7
7
  import { VASTClient, VASTParser, } from '@dailymotion/vast-client';
8
8
  import '@streamlayer/sdk-web-api';
9
9
  import VMAP from 'vmap';
10
- import { gam } from './gam';
11
- import { parseVastExtension } from './parse-vast-extensions';
10
+ import { gam } from './gam.js';
11
+ import { parseVastExtension } from './parse-vast-extensions.js';
12
+ import { collectAssets, selectCardLogo } from './sponsor-logo.js';
12
13
  const log = createLogger('ui:gam-external-ad');
13
14
  /** Default budget for a single VAST resolution before it is treated as "no ad". */
14
15
  const VAST_FETCH_TIMEOUT_MS = 10_000;
@@ -38,7 +39,7 @@ const withTimeout = (promise, ms) => {
38
39
  * Get the first valid creative from an ad
39
40
  */
40
41
  const getCreative = (ad) => {
41
- return ad.creatives.find((creative) => creative.mediaFiles);
42
+ return ad.creatives.find((creative) => getMediaFileUrl(creative));
42
43
  };
43
44
  /**
44
45
  * Get the URL of the first media file in the creative
@@ -59,31 +60,10 @@ const getClickThrough = (creative) => {
59
60
  // @ts-ignore — vast-client types `videoClickThroughURLTemplate` loosely
60
61
  return creative?.videoClickThroughURLTemplate?.url || undefined;
61
62
  };
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
63
  /** Product companion mapping: the 300x75 companion is the logo, the banner-sized one the media. */
85
64
  const LOGO_COMPANION_WIDTH = 300;
86
65
  const LOGO_COMPANION_HEIGHT = 75;
66
+ const isLogoSized = (companion) => companion?.width === LOGO_COMPANION_WIDTH && companion?.height === LOGO_COMPANION_HEIGHT;
87
67
  const companionArea = (companion) => (companion.width || 0) * (companion.height || 0);
88
68
  /**
89
69
  * Split a `<CompanionAds>` creative's variations into the logo and media slots.
@@ -91,11 +71,18 @@ const companionArea = (companion) => (companion.width || 0) * (companion.height
91
71
  * The pair mapping activates only for a tag that ships an exact 300x75 companion next to at
92
72
  * least one other. Deliberately conservative pending broader product sizes: on any other tag
93
73
  * no companion is promoted to the logo slot and the first one stays the media/strip candidate.
74
+ *
75
+ * Both halves of that gate are load-bearing and neither is an aspect rule: a 320x80 companion is
76
+ * the same 4:1 shape as 300x75 and is still rejected, and a lone 300x75 companion is rejected too
77
+ * (the Standard Ad Sidebar recovers that one as `AdData.loneLogo` when a Linear / NonLinear is
78
+ * the media, so it is not needed here).
79
+ * This is **narrower than iOS**, which has no size rule at all and fills its logo slot from a
80
+ * single companion — see `docs/wiki/programmatic-ads-display-ios-parity.md`. Exported for
81
+ * `external-ad.spec.ts` only; `src/index.ts` re-exports names explicitly, so this stays out of the
82
+ * package's public API.
94
83
  */
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;
84
+ export const selectCompanions = (companions) => {
85
+ const logo = companions.length > 1 ? companions.find((companion) => isLogoSized(companion)) : undefined;
99
86
  if (!logo) {
100
87
  return { logo: undefined, media: companions[0] };
101
88
  }
@@ -105,40 +92,26 @@ const selectCompanions = (companions) => {
105
92
  .pop();
106
93
  return { logo, media };
107
94
  };
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
95
  /**
136
96
  * Map a parsed VAST response into our `AdData` shape. Shared by both acquisition
137
97
  * 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.
98
+ * produce identical downstream data.
99
+ *
100
+ * Returns `undefined` for a no-ad, which is two distinct shapes: a response with no ad carrying
101
+ * creatives, and an ad whose creatives carry no RENDERABLE asset — no linear `<MediaFile>`, no
102
+ * `<NonLinear>` still and no `<Companion>` image. Both callers `filter(Boolean)` the result, so a
103
+ * no-ad leaves `ads: []` and the card keeps whatever the server configured for it.
104
+ *
105
+ * Exported for `vast-mapping.spec.ts`, which drives it over the same fixture corpus the iOS suite
106
+ * uses. `src/index.ts` re-exports names explicitly, so this stays out of the package's public API.
140
107
  */
141
- const buildAdData = (parsedVAST, order) => {
108
+ export const buildAdData = (parsedVAST, order) => {
109
+ // Defensive, and unreachable through either production path: `@dailymotion/vast-client` discards
110
+ // an ad whose creatives all fail to parse (empty `<Linear>`, empty/missing `<Creatives>`, unknown
111
+ // creative element) before `parseVAST` resolves, so it never yields an Ad with zero creatives.
112
+ // Kept because it costs nothing and `buildAdData` is also reachable from tests with a hand-built
113
+ // response — see the `invalid_ad` case in `vast-mapping.spec.ts`, which pins the parser contract
114
+ // this relies on rather than pretending to exercise the guard.
142
115
  const validAd = parsedVAST.ads.find((ads) => ads.creatives.length > 0);
143
116
  if (!validAd) {
144
117
  log.warn({ order, ads: parsedVAST.ads.length }, 'vast response carries no ad with creatives — treated as no-ad');
@@ -150,10 +123,47 @@ const buildAdData = (parsedVAST, order) => {
150
123
  const parsedExtension = parseVastExtension(extension);
151
124
  const creative = getCreative(validAd);
152
125
  const mediaFileUrl = getMediaFileUrl(creative);
153
- const companions = getCompanions(validAd);
126
+ const images = collectAssets(validAd);
127
+ const companions = images.filter((asset) => asset.origin === 'companion');
128
+ const nonLinear = images.find((asset) => asset.origin === 'nonlinear');
129
+ // An ad the parser KEPT whose creatives carry nothing any shell can draw is a no-ad, exactly
130
+ // like a response with no ad at all. vast-client only discards a creative it cannot parse; a
131
+ // `<NonLinear>` or `<Companion>` whose `<StaticResource>` body is empty parses fine and just
132
+ // yields no url, so the guard above cannot catch it. Without this the fabricated promotion still
133
+ // took the slot and drew the ad's TEXT — title and description over an empty media box — which
134
+ // is a billable impression for a creative that does not exist.
135
+ //
136
+ // "Renderable" is the union of the three slots the shells can fill: a linear `<MediaFile>`, a
137
+ // `<NonLinear>` still, or a `<Companion>` image. `collectAssets` already drops the imageless
138
+ // ones, so a non-empty `companions` means at least one drawable image. A tag carrying only a
139
+ // `<Mezzanine>` never reaches here — the parser drops that Linear itself — but it lands in the
140
+ // same place either way.
141
+ if (!mediaFileUrl && !nonLinear && companions.length === 0) {
142
+ 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');
143
+ return undefined;
144
+ }
154
145
  const { logo, media: mediaCompanion } = selectCompanions(companions);
155
- const nonLinear = getNonLinear(validAd);
156
146
  const clickThrough = getClickThrough(creative) || nonLinear?.clickUrl || mediaCompanion?.clickUrl;
147
+ // Mirror iOS's greedy `pool.select([hero, .cardLogo])`: the hero claims first and a claimed
148
+ // asset is gone for later roles. Web's hero precedence (`createActiveAdFromVast`) is linear
149
+ // video → pair media companion (unreachable here: a `logo` short-circuits the whole selection)
150
+ // → NonLinear still → lone companion, so a companion is only unavailable to the logo slot when
151
+ // it IS the hero, i.e. when the tag ships neither. `selectCardLogo` already filters to companion
152
+ // origins, so the NonLinear never needs excluding.
153
+ //
154
+ // Excluding `companions[0]` (which is what `mediaCompanion` is when there is no `logo`) rather
155
+ // than the largest-area asset is a deliberate difference from iOS's `areaDominant` hero: web's
156
+ // content slot draws the FIRST companion in document order, so document order is the correct
157
+ // criterion for "what the hero already claimed" on this platform.
158
+ const companionIsHero = !mediaFileUrl && !nonLinear;
159
+ const cardLogoAsset = logo ? undefined : selectCardLogo(validAd, companionIsHero ? [mediaCompanion] : []);
160
+ // Share the selected resource wrapper so drawn-companion identity remains stable.
161
+ const cardLogo = cardLogoAsset
162
+ ? companions.find((item) => item.variation === cardLogoAsset.variation && item.imageUrl === cardLogoAsset.imageUrl)
163
+ : undefined;
164
+ // Same collected instance as `companion` (identity matters to `drawnCompanions`). A lone
165
+ // companion never satisfies the pair rule, so this can only fill a slot `logo` left empty.
166
+ const loneLogo = companions.length === 1 && !companionIsHero && isLogoSized(companions[0]) ? companions[0] : undefined;
157
167
  log.info({
158
168
  order,
159
169
  adId: validAd.id,
@@ -169,6 +179,8 @@ const buildAdData = (parsedVAST, order) => {
169
179
  media: mediaCompanion
170
180
  ? { size: `${mediaCompanion.width}x${mediaCompanion.height}`, url: mediaCompanion.imageUrl }
171
181
  : undefined,
182
+ cardLogo: cardLogo ? { size: `${cardLogo.width}x${cardLogo.height}`, url: cardLogo.imageUrl } : undefined,
183
+ loneLogo: loneLogo ? { size: `${loneLogo.width}x${loneLogo.height}`, url: loneLogo.imageUrl } : undefined,
172
184
  clickThrough: !!clickThrough,
173
185
  },
174
186
  }, 'vast ad parsed');
@@ -178,6 +190,8 @@ const buildAdData = (parsedVAST, order) => {
178
190
  mediaFileUrl,
179
191
  companion: companions[0],
180
192
  logo,
193
+ cardLogo,
194
+ loneLogo,
181
195
  mediaCompanion,
182
196
  nonLinear,
183
197
  clickThrough,
@@ -206,8 +220,12 @@ const createRequestId = () => `sl-web-${Date.now().toString(36)}-${Math.random()
206
220
  * - `storedauctionresponse` → `imp.ext.prebid.storedauctionresponse.id` (staging mock).
207
221
  * - `test=1` → top-level `test` flag.
208
222
  * - `w` / `h` → video dimensions (default 1920×1080).
223
+ *
224
+ * The host's GDPR consent (`AdTagParams`) rides the OpenRTB 2.5 extension fields Prebid Server
225
+ * reads — `regs.ext.gdpr` and `user.ext.consent` — the same signal the `${GDPR}` /
226
+ * `${GDPR_CONSENT}` macros carry for a VAST tag. Each is sent only when the host passed it.
209
227
  */
210
- const buildAuctionRequest = (url) => {
228
+ export const buildAuctionRequest = (url, adTagParams) => {
211
229
  const params = url.searchParams;
212
230
  const width = Number(params.get('w')) || 1920;
213
231
  const height = Number(params.get('h')) || 1080;
@@ -245,6 +263,12 @@ const buildAuctionRequest = (url) => {
245
263
  if (params.get('test') === '1') {
246
264
  request.test = 1;
247
265
  }
266
+ if (adTagParams?.gdprApplies !== undefined) {
267
+ request.regs = { ext: { gdpr: adTagParams.gdprApplies ? 1 : 0 } };
268
+ }
269
+ if (adTagParams?.tcString) {
270
+ request.user = { ext: { consent: adTagParams.tcString } };
271
+ }
248
272
  return request;
249
273
  };
250
274
  /**
@@ -252,11 +276,11 @@ const buildAuctionRequest = (url) => {
252
276
  * Every bid's `adm` is inline VAST XML (no URL to fetch); bids are ordered by price
253
277
  * so the winner plays first (RFC §2). A single bad bid must not drop the batch.
254
278
  */
255
- const fetchPrebidAds = async (prebidUrl) => {
279
+ const fetchPrebidAds = async (prebidUrl, adTagParams) => {
256
280
  const response = await fetch(prebidUrl.toString(), {
257
281
  method: 'POST',
258
282
  headers: { 'Content-Type': 'application/json' },
259
- body: JSON.stringify(buildAuctionRequest(prebidUrl)),
283
+ body: JSON.stringify(buildAuctionRequest(prebidUrl, adTagParams)),
260
284
  });
261
285
  if (!response.ok) {
262
286
  throw new Error(`prebid auction failed with status ${response.status}`);
@@ -309,13 +333,17 @@ export const externalAd = (instance) => {
309
333
  return undefined;
310
334
  }
311
335
  })();
336
+ // The host's CURRENT macro values, read per request rather than baked into the store: the
337
+ // store is cached by url for the session, and consent routinely arrives after the SDK
338
+ // starts (a CMP answers asynchronously) or changes mid-session.
339
+ const adTagParams = instance.sdk.options.get().adTagParams;
312
340
  // Prebid links hit our Prebid Server auction endpoint instead of a VAST tag.
313
341
  // Run the OpenRTB auction and parse the winning bid's inline VAST (`adm`) into
314
342
  // the same `AdStore` shape the GAM/VMAP path returns. See rfcs/prebid.
315
343
  if (parsedAdUrl && isPrebidUrl(parsedAdUrl)) {
316
- return fetchPrebidAds(parsedAdUrl);
344
+ return fetchPrebidAds(parsedAdUrl, adTagParams);
317
345
  }
318
- const generateUrl = gam(adUrl);
346
+ const generateUrl = gam(adUrl, undefined, adTagParams);
319
347
  const generatedUrl = await generateUrl();
320
348
  const noncedLink = generatedUrl?.url;
321
349
  if (!noncedLink) {
@@ -7,6 +7,7 @@ import { eventBus } from '@streamlayer/sdk-web-interfaces';
7
7
  import { createLogger } from '@streamlayer/sdk-web-logger';
8
8
  import '@streamlayer/sdk-web-api';
9
9
  import { VASTClient } from '@dailymotion/vast-client';
10
+ import { substituteAdTagMacros } from './ad-macros.js';
10
11
  const FULLBLEED_VIDEO_TEMPLATES = [
11
12
  'fullbleed-video',
12
13
  'fullbleed-video-center',
@@ -115,7 +116,12 @@ export const externalPauseAd = (instance) => {
115
116
  try {
116
117
  // `resolveAll: true` follows every `VASTAdTagURI` wrapper chain and resolves all
117
118
  // Ads (the default only resolves the first standalone Ad and stops at the wrapper).
118
- const parsedVAST = await vastClient.get(adUrl, { resolveAll: true });
119
+ // Pause-ad tags do not go through `gam()`, so fill the macros here — with the host's values
120
+ // as they are NOW, since this fetcher re-runs on every pause. The store key stays the
121
+ // configured url: `ExposedPauseAd` compares against it.
122
+ const parsedVAST = await vastClient.get(substituteAdTagMacros(adUrl, instance.sdk.options.get().adTagParams), {
123
+ resolveAll: true,
124
+ });
119
125
  const template = options.template ?? 'default';
120
126
  // `fullbleed-video` needs an ad that actually carries a usable linear MP4. With
121
127
  // `resolveAll: true` the response can be a pod/wrapper chain, so prefer the first
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Google PAL nonce manager + ad-tag resolution for programmatic (GAM) VAST requests.
3
+ *
4
+ * Lives here because the VAST fetcher (`units/external-ad.ts`) is its only consumer: this is the
5
+ * request side of an ad tag, not the promotion side. The *composition* of a tag URL from the proto
6
+ * (`processGamAdvertisement` / `selectGamOptions`) stays in `feature-gamification`, next to the
7
+ * `$store` bridge that is its only consumer — so the two feature packages share no code and
8
+ * neither depends on the other.
9
+ */
10
+ import type { AdTagParams } from '@streamlayer/sdk-web-core';
1
11
  export type PalNonceManager = {
2
12
  getNonce: () => string;
3
13
  sendAdClick: () => void;
@@ -8,7 +18,7 @@ export type GamNonceResult = {
8
18
  url: URL;
9
19
  manager?: PalNonceManager;
10
20
  };
11
- export declare const gam: (url: string, adContainer?: HTMLDivElement) => (() => Promise<{
21
+ export declare const gam: (rawUrl: string, adContainer?: HTMLDivElement, adTagParams?: AdTagParams) => (() => Promise<{
12
22
  url: URL;
13
23
  }>) | (() => Promise<{
14
24
  url: URL;
package/lib/units/gam.js CHANGED
@@ -1,18 +1,67 @@
1
- /**
2
- * Google PAL nonce manager + ad-tag resolution for programmatic (GAM) VAST requests.
3
- *
4
- * Lives here because the VAST fetcher (`units/external-ad.ts`) is its only consumer: this is the
5
- * request side of an ad tag, not the promotion side. The *composition* of a tag URL from the proto
6
- * (`processGamAdvertisement` / `selectGamOptions`) stays in `feature-gamification`, next to the
7
- * `$store` bridge that is its only consumer — so the two feature packages share no code and
8
- * neither depends on the other.
9
- */
10
1
  import { safeLocalStorage, safeSessionStorage } from '@streamlayer/sdk-web-storage';
11
2
  import { validate as uuidValidate, v5 as uuid5, v4 as uuid4 } from 'uuid';
3
+ import { substituteAdTagMacros } from './ad-macros.js';
12
4
  const urlIsNotGam = (url) => {
13
5
  return url.origin !== 'https://pubads.g.doubleclick.net';
14
6
  };
15
- export const gam = (url, adContainer) => {
7
+ const palScriptUrl = 'https://imasdk.googleapis.com/pal/sdkloader/pal.js';
8
+ const getPal = () => {
9
+ // @ts-expect-error Google PAL is supplied by the host or the script loader below.
10
+ return typeof goog === 'undefined' ? undefined : goog.pal;
11
+ };
12
+ let palReady;
13
+ const ensurePal = async () => {
14
+ if (getPal()) {
15
+ return;
16
+ }
17
+ if (!palReady) {
18
+ palReady = new Promise((resolve, reject) => {
19
+ if (typeof document === 'undefined') {
20
+ reject(new Error('Google PAL requires a browser document'));
21
+ return;
22
+ }
23
+ const existing = document.querySelector(`script[src="${palScriptUrl}"]`);
24
+ const script = existing || document.createElement('script');
25
+ const finish = (error) => {
26
+ clearTimeout(timeout);
27
+ script.removeEventListener('load', onLoad);
28
+ script.removeEventListener('error', onError);
29
+ if (error) {
30
+ if (!existing) {
31
+ script.remove();
32
+ }
33
+ reject(error);
34
+ }
35
+ else {
36
+ resolve();
37
+ }
38
+ };
39
+ const onLoad = () => finish(getPal() ? undefined : new Error('Google PAL did not initialize'));
40
+ const onError = () => finish(new Error('Google PAL failed to load'));
41
+ const timeout = setTimeout(() => finish(new Error('Google PAL loading timed out')), 10000);
42
+ script.addEventListener('load', onLoad);
43
+ script.addEventListener('error', onError);
44
+ if (!existing) {
45
+ script.async = true;
46
+ script.src = palScriptUrl;
47
+ document.head.appendChild(script);
48
+ }
49
+ }).catch((error) => {
50
+ palReady = undefined;
51
+ throw error;
52
+ });
53
+ }
54
+ await palReady;
55
+ palReady = undefined;
56
+ };
57
+ export const gam = (rawUrl, adContainer, adTagParams) => {
58
+ // Every VAST/VMAP tag funnels through here, so this is where the macros get filled — for GAM and
59
+ // for any other vendor alike. Studio stores a programmatic tag verbatim, so an unexpanded
60
+ // `[timestamp]` / `[clickmacro]` / `${GDPR}` would otherwise reach the ad server literal, and a
61
+ // literal macro comes back copied into the vendor's click-through. `adTagParams` are the host's
62
+ // consent and click values, read by the caller at request time. The GAM `[placeholder]` tokens
63
+ // are left alone here and filled per parameter below.
64
+ const url = substituteAdTagMacros(rawUrl, adTagParams);
16
65
  const parsedUrl = (() => {
17
66
  try {
18
67
  const parsedUrl = new URL(url);
@@ -32,20 +81,19 @@ export const gam = (url, adContainer) => {
32
81
  return { url: parsedUrl };
33
82
  };
34
83
  }
35
- // @ts-expect-error goog is injected by the externally-loaded Google PAL SDK
36
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
- const pal = (typeof goog === 'undefined' ? undefined : goog.pal);
38
- if (!pal || !parsedUrl) {
84
+ if (!parsedUrl) {
39
85
  // eslint-disable-next-line require-await
40
86
  return async () => {
41
87
  return undefined;
42
88
  };
43
89
  }
44
- const consentSettings = new pal.ConsentSettings();
45
- consentSettings.allowStorage = false;
46
- const nonceLoader = new pal.NonceLoader(consentSettings);
47
90
  const generateUrl = async () => {
48
91
  try {
92
+ await ensurePal();
93
+ const pal = getPal();
94
+ const consentSettings = new pal.ConsentSettings();
95
+ consentSettings.allowStorage = false;
96
+ const nonceLoader = new pal.NonceLoader(consentSettings);
49
97
  const parsedUrl = new URL(url);
50
98
  const request = new pal.NonceRequest();
51
99
  let deviceId = safeLocalStorage().getItem('sl-device-id') || uuid4();
@@ -1,7 +1,16 @@
1
1
  import { AdType } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
2
2
  export interface VastCreative {
3
3
  adName: string;
4
- adType: AdType;
4
+ /**
5
+ * The placement this extension NAMES, or `undefined` when its root element is not one we map.
6
+ *
7
+ * Deliberately not defaulted here. An unrecognised extension means "this tag says nothing about
8
+ * placement", which must not outrank a placement the promotion proto already stated — defaulting
9
+ * it to `SIDE_BY_SIDE` at this layer silently re-routed a configured Sidebar/L-Bar, because the
10
+ * consumers join with `extension?.adType ?? placement`. The default now lives in
11
+ * `resolveExternalAdType`, applied only after the proto placement has had its turn.
12
+ */
13
+ adType?: AdType;
5
14
  landscapeBackground?: string;
6
15
  portraitBackground?: string;
7
16
  sponsorLogo?: string;
@@ -1,4 +1,8 @@
1
1
  import { AdType } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
2
+ /**
3
+ * Extension root element name → the placement it names. Only `DoubleBox` is defined by the
4
+ * StreamLayer extension schema today; anything else is a vendor extension we do not interpret.
5
+ */
2
6
  const MAP_EXTENSION_NAME_AD_TYPE = {
3
7
  DoubleBox: AdType.SIDE_BY_SIDE,
4
8
  };
@@ -38,7 +42,7 @@ export function parseVastExtension(root) {
38
42
  };
39
43
  return {
40
44
  adName,
41
- adType: MAP_EXTENSION_NAME_AD_TYPE[root.name] || AdType.SIDE_BY_SIDE,
45
+ adType: MAP_EXTENSION_NAME_AD_TYPE[root.name],
42
46
  landscapeBackground: backgrounds.find((b) => b.orientation === 'landscape')?.url,
43
47
  portraitBackground: backgrounds.find((b) => b.orientation === 'portrait')?.url,
44
48
  sponsorLogo: logos.find((l) => l.role === 'header')?.url,
@@ -1,7 +1,7 @@
1
1
  import { createSingleStore, StreamLayerContext } from '@streamlayer/sdk-web-interfaces';
2
2
  import '@streamlayer/sdk-web-api';
3
3
  import '@streamlayer/sdk-web-core/store';
4
- import { type PauseAdCard } from './pause-ad-card';
4
+ import { type PauseAdCard } from './pause-ad-card.js';
5
5
  export type PauseAdListOptions = {
6
6
  refetchInterval: number;
7
7
  $enabled: boolean;
@@ -3,7 +3,7 @@ import { atom } from 'nanostores';
3
3
  import '@streamlayer/sdk-web-api';
4
4
  import '@streamlayer/sdk-web-core/store';
5
5
  import { Feed } from '@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb';
6
- import { parsePauseAdCards } from './pause-ad-card';
6
+ import { parsePauseAdCards } from './pause-ad-card.js';
7
7
  const log = createLogger('advertisement:pause-ad');
8
8
  export const pauseAdListStore = (instance) => {
9
9
  const $slStreamId = instance.stores.slStreamId.getAtomStore();