@chipmobilesdk/rn-ads 0.5.0 → 0.5.1

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.
@@ -1,1933 +1,1929 @@
1
- /**
2
- * The ads instance — the public contract's implementation.
3
- *
4
- * Every guarantee the specification treats as a rule rather than a convention
5
- * converges here, and none of it needs a device to prove:
6
- *
7
- * FR-022 one native initialization, however many callers race
8
- * FR-017 no request before the privacy gate permits one
9
- * FR-011 no production ad unit outside production
10
- * FR-029 one in-flight provider request per placement
11
- * FR-030 exactly one full-screen ad, never a queue
12
- * FR-031 a consumed ad is never reused
13
- * FR-035 after dispose, a late callback updates nothing and emits nothing
14
- *
15
- * Nothing in this file imports the provider. It talks to the engine port, which
16
- * the caller injects — the AdMob adapter in production, the fake in tests.
17
- */
18
-
19
- import {
20
- isFullScreenFormat,
21
- type AdFormat,
22
- type AdsConfig,
23
- type ConsentSnapshot,
24
- type Enablement,
25
- type FullScreenFormat,
26
- type InitStatus,
27
- type PlacementDefinition,
28
- type PlacementState,
29
- type PrivacyBlockReason,
30
- type PrivacyOptions,
31
- type UnavailableReason,
32
- type Unsubscribe,
33
- } from '../config/types';
34
- import { validateConfig, type ResolvedConfig } from '../config/validate';
35
- import { AdsError, normalizeProviderError } from '../diagnostics/errors';
36
- import {
37
- createEventEmitter,
38
- type AdEvent,
39
- type AdEventListener,
40
- type ImpressionSource,
41
- } from '../diagnostics/events';
42
- import { createRequestThrottle } from './requestThrottle';
43
- import { createCorrelationIssuer } from '../diagnostics/correlation';
44
- import type { AdsEngine, EngineAdEvent, EngineAdHandle } from '../engine/port';
45
- import { createFullScreenLock } from './fullScreenLock';
46
- import { canRequest, canStart, initialInit, initReducer, type InitSnapshot } from './initMachine';
47
- import {
48
- canLoad,
49
- initialPlacement,
50
- placementReducer,
51
- type PlacementSnapshot,
52
- } from './placementMachine';
53
- import {
54
- evaluateGate,
55
- requiresReinitialization,
56
- toEnginePrivacy,
57
- toEngineRequest,
58
- } from './privacyGate';
59
- import { evaluateGates, type ConsentGateInput, type GateDecision } from './gates';
60
- import { createRewardedSession, type RewardedSession } from './rewardedSession';
61
- import { createTimerSet, isStale, type Scheduler } from './timers';
62
- import { canResume, permitsRequests, type EnablementResult } from './enablement';
63
- import { canRetry, delayForAttempt } from './retryPolicy';
64
- import { createAdOriginatedTracker } from './adOriginatedReturn';
65
- import {
66
- buildConsentOptions,
67
- INITIAL_CONSENT,
68
- needsForm,
69
- normalizeConsent,
70
- runConsentStep,
71
- toConsentError,
72
- toGateInput,
73
- } from './consentFlow';
74
-
75
- export type InitResult =
76
- | { readonly status: 'ready' }
77
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
78
- | { readonly status: 'disabled' }
79
- /** The app has not decided whether ads are on. Nothing was requested (FR-115). */
80
- | { readonly status: 'undecided' }
81
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
82
- | { readonly status: 'failed'; readonly error: AdsError };
83
-
84
- export type PrivacyUpdateResult =
85
- | { readonly status: 'applied' }
86
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
87
- | { readonly status: 'requires_reinitialization' }
88
- | { readonly status: 'disabled' }
89
- | { readonly status: 'undecided' }
90
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
91
- /**
92
- * The initialization this update triggered failed (added in 0.5.0). Before,
93
- * it was reported as `applied`, so the app never retried and no ad ever
94
- * showed, with nothing to say why.
95
- */
96
- | { readonly status: 'failed'; readonly error: AdsError };
97
-
98
- export type LoadResult =
99
- | { readonly status: 'loaded'; readonly correlationId: string }
100
- | { readonly status: 'disabled' }
101
- | { readonly status: 'undecided' }
102
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
103
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
104
- | { readonly status: 'no_fill' }
105
- /**
106
- * Refused by the per-placement request throttle (FR-140), added in 0.3.0.
107
- *
108
- * Nothing was requested and nothing is broken — this placement asked too
109
- * recently. Distinct from `no_fill` on purpose: no-fill means the network had
110
- * nothing to give, this means we did not ask. An app that treats them the same
111
- * will misdiagnose the first time a banner goes quiet.
112
- */
113
- | ThrottledResult
114
- | { readonly status: 'timeout' }
115
- | { readonly status: 'failed'; readonly error: AdsError };
116
-
117
- export interface ThrottledResult {
118
- readonly status: 'throttled';
119
- readonly retryAfterMs: number;
120
- }
121
-
122
- /** A gate refusal: what a load gets when it may not ask at all. */
123
- type LoadRefusal = Extract<
124
- LoadResult,
125
- { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
126
- >;
127
-
128
- export type ShowResult =
129
- | { readonly status: 'shown'; readonly correlationId: string }
130
- | { readonly status: 'not_ready' }
131
- | { readonly status: 'busy' }
132
- | { readonly status: 'consumed' }
133
- | { readonly status: 'app_inactive' }
134
- /**
135
- * The user came back to the app from an ad, or from a browser an ad opened,
136
- * so an app-open ad would be an ad immediately after an ad. Distinct from
137
- * `busy` and `not_ready` because the app cannot detect this itself and
138
- * therefore cannot infer it from either of those (FR-128).
139
- */
140
- | { readonly status: 'ad_originated_return' }
141
- | { readonly status: 'disabled' }
142
- | { readonly status: 'undecided' }
143
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
144
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
145
- | { readonly status: 'timeout' }
146
- | { readonly status: 'failed'; readonly error: AdsError };
147
-
148
- /**
149
- * What `consent.showPrivacyOptionsForm()` reports (FR-151), added in 0.4.0.
150
- *
151
- * Every refusal is its own member, so an app can tell "there is nothing to
152
- * offer here" from "you asked too early" from "it broke" without parsing an
153
- * error. None of them throws.
154
- */
155
- export type PrivacyOptionsFormResult =
156
- /**
157
- * The provider's form was presented and has closed. `consent` is the state it
158
- * left behind, already applied to the gate. A user who closed the form without
159
- * changing anything lands here too, with the state they already had.
160
- */
161
- | { readonly status: 'shown'; readonly consent: ConsentSnapshot }
162
- /** The provider does not require privacy options here. Nothing was presented. */
163
- | { readonly status: 'not_required' }
164
- /**
165
- * The requirement is not known yet: no successful `requestInfoUpdate()` since
166
- * the instance was created or reset, or the last one failed. Nothing was
167
- * presented.
168
- */
169
- | { readonly status: 'info_update_required' }
170
- /** A consent form is already on screen. Nothing was presented. */
171
- | { readonly status: 'busy' }
172
- /**
173
- * The form could not be presented or failed while open. The consent state is
174
- * whatever the provider reports it still holds, and never a new grant.
175
- */
176
- | { readonly status: 'failed'; readonly error: AdsError }
177
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason };
178
-
179
- /**
180
- * The optional consent capability (FR-101).
181
- *
182
- * Grouped under its own namespace rather than flattened onto the instance so
183
- * that FR-102's optionality stays legible: an app that never touches
184
- * `ads.consent` never starts a flow. Five loose methods on the instance would
185
- * invite the assumption that they are part of the normal path.
186
- */
187
- export interface ConsentApi {
188
- /** Refreshes consent information from the provider. */
189
- requestInfoUpdate(): Promise<ConsentSnapshot>;
190
- /** Presents the provider's form if one is required. Renders nothing itself. */
191
- showFormIfRequired(): Promise<ConsentSnapshot>;
192
- /**
193
- * Presents the provider's privacy options form, so a user can change an
194
- * answer already given (FR-151). Added in 0.4.0.
195
- *
196
- * Offer it when `getState().privacyOptionsRequirement` is `required`. Where
197
- * the entry point lives, and what it says, stay the app's. The outcome feeds
198
- * the privacy gate by itself, exactly as `showFormIfRequired` does.
199
- */
200
- showPrivacyOptionsForm(): Promise<PrivacyOptionsFormResult>;
201
- getState(): ConsentSnapshot;
202
- subscribe(listener: (snapshot: ConsentSnapshot) => void): Unsubscribe;
203
- /** Clears the stored decision. Inert in production (FR-113). */
204
- reset(): Promise<void>;
205
- }
206
-
207
- export interface AdsInstance {
208
- initialize(privacy: PrivacyOptions): Promise<InitResult>;
209
- updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
210
- /**
211
- * Turns ads on or off. Returns a result rather than `void` (FR-122).
212
- *
213
- * 0.1.0 returned nothing, which meant an app re-enabling ads after a lapsed
214
- * subscription had no way to learn that it also had to re-initialize — and
215
- * every load and show after that was refused in silence.
216
- */
217
- setEnabled(enabled: boolean): EnablementResult;
218
- /** The same, including the third state the app has not resolved yet. */
219
- setEnablement(next: Enablement): EnablementResult;
220
- setAppActive(active: boolean): void;
221
- readonly consent: ConsentApi;
222
- getStatus(): InitStatus;
223
- subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
224
-
225
- load(placementKey: string): Promise<LoadResult>;
226
- show(placementKey: string): Promise<ShowResult>;
227
- getPlacementState(placementKey: string): PlacementState;
228
- subscribePlacement(key: string, listener: (state: PlacementState) => void): Unsubscribe;
229
-
230
- subscribeEvents(listener: AdEventListener): Unsubscribe;
231
-
232
- /** FR-011a — substitution is observable state, not only a log line. */
233
- getSubstitutedPlacements(): readonly string[];
234
- /** FR-113 — the same treatment for consent tools dropped in production. */
235
- getIgnoredConsentTools(): readonly string[];
236
- /** Internal wiring for the banner host; not part of the documented surface. */
237
- readonly internals: InstanceInternals;
238
-
239
- dispose(): void;
240
- }
241
-
242
- /** What `AdsBanner` needs. Deliberately not documented as consumer API. */
243
- export interface InstanceInternals {
244
- resolveBanner(placementKey: string):
245
- | {
246
- readonly ok: true;
247
- readonly adUnitId: string;
248
- readonly correlationId: string;
249
- /** Current at resolution time, so a banner request honors `updatePrivacy`. */
250
- readonly personalizedAds: boolean;
251
- }
252
- | { readonly ok: false; readonly result: LoadResult };
253
- /**
254
- * Takes the throttle slot for a banner cycle (FR-140).
255
- *
256
- * Called from the banner host's effect, after `resolveBanner` allowed it and
257
- * immediately before the provider component mounts. Idempotent for one
258
- * `correlationId`, so React running the effect twice charges one slot.
259
- *
260
- * Returns the refusal if the window closed between resolve and commit — rare,
261
- * but a second banner for the same placement mounting in the same tick would
262
- * do it.
263
- *
264
- * Emits nothing and sets no state: it is called during render, so the host
265
- * reports the refusal from its effect instead.
266
- */
267
- commitBannerRequest(
268
- placementKey: string,
269
- correlationId: string,
270
- ): ThrottledResult | null;
271
- /** The configured floor for a placement, so the host can report the cause. */
272
- throttleFloorFor(placementKey: string): number;
273
- emit(event: AdEvent): void;
274
- setPlacementState(key: string, state: PlacementState): void;
275
- isReady(): boolean;
276
- nextCorrelationId(): string;
277
- timerCount(): number;
278
- }
279
-
280
- export interface CreateAdsOptions {
281
- /** Injected in tests; the AdMob adapter is the default in production. */
282
- readonly engine?: AdsEngine;
283
- /** Injected so the FR-012 rule is testable without touching a global. */
284
- readonly isDevBuild?: boolean;
285
- readonly now?: () => number;
286
- /**
287
- * Injected so retry backoff, app-open expiry, and the undecided warning can
288
- * be driven by a test clock instead of by waiting. Production uses real
289
- * timers.
290
- */
291
- readonly scheduler?: Scheduler;
292
- }
293
-
294
- interface PlacementRuntime {
295
- snapshot: PlacementSnapshot;
296
- /**
297
- * The cycle's correlation id, held here rather than read from the snapshot:
298
- * the reducer resets the snapshot on dismissal, and events emitted around
299
- * that transition must still carry the id of the cycle they belong to.
300
- */
301
- correlationId: string | null;
302
- /**
303
- * Whether this cycle already reported a dismissal. Format-independent — the
304
- * rewarded session guards its own, but interstitials need the same guard and
305
- * would otherwise report a repeated provider callback twice.
306
- */
307
- dismissed: boolean;
308
- handle: EngineAdHandle | null;
309
- unsubscribeEngine: Unsubscribe | null;
310
- inFlightLoad: Promise<LoadResult> | null;
311
- /**
312
- * Settles the in-flight load with a given result. Only a consent withdrawal
313
- * uses it (FR-153): the caller awaiting `load()` is told `blocked` instead of
314
- * waiting on a request that has been abandoned.
315
- */
316
- abortLoad: ((result: LoadResult) => void) | null;
317
- cancelLoadTimer: Unsubscribe | null;
318
- cancelShowTimer: Unsubscribe | null;
319
- rewarded: RewardedSession | null;
320
- listeners: Set<(state: PlacementState) => void>;
321
- /** Retries already spent in this load cycle. Reset when a new cycle starts. */
322
- retryAttempts: number;
323
- /** Cancels a scheduled retry. Called by disable, undecided, and dispose. */
324
- cancelRetry: Unsubscribe | null;
325
- }
326
-
327
- function detectDevBuild(): boolean {
328
- return typeof __DEV__ !== 'undefined' ? Boolean(__DEV__) : false;
329
- }
330
-
331
- export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): AdsInstance {
332
- const isDevBuild = options.isDevBuild ?? detectDevBuild();
333
- const now = options.now ?? (() => Date.now());
334
-
335
- // Throws on a structural fault or the FR-012 fail-closed case. Deliberately
336
- // before anything else: a misconfigured instance should not exist at all.
337
- const resolved: ResolvedConfig = validateConfig(config, { isDevBuild });
338
-
339
- const engine: AdsEngine = options.engine ?? createDefaultEngine();
340
- const events = createEventEmitter();
341
- const correlation = createCorrelationIssuer();
342
- // FR-140. Account safety, not product policy — see core/requestThrottle.ts.
343
- const requestThrottle = createRequestThrottle(now);
344
- const timers = createTimerSet(options.scheduler);
345
- const lock = createFullScreenLock();
346
- const adReturn = createAdOriginatedTracker();
347
-
348
- /**
349
- * An instance told it is `undecided` says so from the moment it exists.
350
- *
351
- * Without this it reports `not_started` until `initialize()` runs, which is
352
- * indistinguishable from a disabled instance at exactly the point an app is
353
- * deciding whether to reserve the banner's space — so FR-116 would hold only
354
- * after a call the app has no reason to make yet. Found on device, not in a
355
- * unit test, because the demo screen defers initialization the way a real
356
- * entitlement-gated app does.
357
- *
358
- * `disabled` deliberately keeps 0.1.0's `not_started` here: that behavior is
359
- * observable today and BC-004 protects it. The asymmetry is the cost of not
360
- * changing something that already shipped.
361
- */
362
- let init: InitSnapshot =
363
- resolved.enablement === 'undecided'
364
- ? initReducer(initialInit, { type: 'UNDECIDE' })
365
- : initialInit;
366
- let enablement: Enablement = resolved.enablement;
367
- let appActive = true;
368
- let consent: ConsentSnapshot = INITIAL_CONSENT;
369
- let consentStarted = false;
370
- /**
371
- * Consent forms currently on screen. A count rather than a flag because
372
- * `showFormIfRequired` has never refused a concurrent call and 0.4.0 does not
373
- * start now; only the privacy options form reads it, to report `busy`.
374
- */
375
- let consentFormsOpen = 0;
376
- let privacyOptionsFormOpen = false;
377
- /**
378
- * Whether the provider has been natively initialized, ever, by this instance.
379
- *
380
- * Tracked apart from the init status because the status can leave `ready` —
381
- * a withdrawn consent moves it to `blocked` — while the native SDK stays
382
- * initialized. Re-granting must resume, not initialize again (FR-022, FR-154).
383
- */
384
- let providerInitialized = false;
385
- let undecidedWarned = false;
386
- let cancelUndecidedTimer: Unsubscribe | null = null;
387
- let appliedPrivacy: PrivacyOptions | null = null;
388
- let currentPrivacy: PrivacyOptions | null = null;
389
- let inFlightInit: Promise<InitResult> | null = null;
390
- let disposed = false;
391
- let warnedSubstitution = false;
392
-
393
- const statusListeners = new Set<(status: InitStatus) => void>();
394
- const placements = new Map<string, PlacementRuntime>();
395
-
396
- for (const key of resolved.placements.keys()) {
397
- placements.set(key, {
398
- snapshot: initialPlacement,
399
- correlationId: null,
400
- dismissed: false,
401
- handle: null,
402
- unsubscribeEngine: null,
403
- inFlightLoad: null,
404
- abortLoad: null,
405
- cancelLoadTimer: null,
406
- cancelShowTimer: null,
407
- rewarded: null,
408
- listeners: new Set(),
409
- retryAttempts: 0,
410
- cancelRetry: null,
411
- });
412
- }
413
-
414
- const consentListeners = new Set<(snapshot: ConsentSnapshot) => void>();
415
-
416
- function setConsent(next: ConsentSnapshot): void {
417
- consent = next;
418
- for (const listener of Array.from(consentListeners)) {
419
- try {
420
- listener(next);
421
- } catch {
422
- // Consumer code; isolated exactly as event listeners are (FR-056).
423
- }
424
- }
425
- }
426
-
427
- /**
428
- * The consent flow's contribution to the gate, or `null` when the app never
429
- * opted in.
430
- *
431
- * `null` is what makes FR-102 structural rather than conditional: with no
432
- * flow started, the gate sees only the app's own input and behaves exactly as
433
- * 0.1.0 did. There is no code path to skip and therefore none to forget.
434
- */
435
- function consentGateInput(): ConsentGateInput {
436
- return consentStarted ? toGateInput(consent) : null;
437
- }
438
-
439
- /**
440
- * Warns once if the app leaves `undecided` unresolved (FR-119).
441
- *
442
- * Once, not repeatedly: a warning that fires every ten seconds becomes noise
443
- * that gets filtered, which defeats the diagnostic. And it only warns — the
444
- * package never resolves the state itself, because doing so would mean
445
- * guessing at exactly the business decision FR-120 forbids it from knowing.
446
- */
447
- function armUndecidedWarning(): void {
448
- if (enablement !== 'undecided' || undecidedWarned || cancelUndecidedTimer) {
449
- return;
450
- }
451
- const armedAt = now();
452
- cancelUndecidedTimer = timers.after(resolved.timeouts.undecidedWarning, () => {
453
- cancelUndecidedTimer = null;
454
- if (disposed || enablement !== 'undecided' || undecidedWarned) {
455
- return;
456
- }
457
- undecidedWarned = true;
458
- events.emit({
459
- type: 'config_warning',
460
- timestamp: now(),
461
- warning: 'enablement_undecided_timeout',
462
- placementKeys: [],
463
- elapsedMs: now() - armedAt,
464
- });
465
- });
466
- }
467
-
468
- function disarmUndecidedWarning(): void {
469
- cancelUndecidedTimer?.();
470
- cancelUndecidedTimer = null;
471
- }
472
-
473
- /** Cancels every pending retry, everywhere (FR-137). */
474
- function cancelAllRetries(): void {
475
- for (const runtime of placements.values()) {
476
- runtime.cancelRetry?.();
477
- runtime.cancelRetry = null;
478
- }
479
- }
480
-
481
- function setInit(next: InitSnapshot): void {
482
- if (next === init || next.status === init.status) {
483
- init = next;
484
- return;
485
- }
486
- init = next;
487
- events.emit({
488
- type: 'initialization',
489
- timestamp: now(),
490
- status: next.status,
491
- retryable: next.error?.retryable,
492
- });
493
- for (const listener of Array.from(statusListeners)) {
494
- try {
495
- listener(next.status);
496
- } catch {
497
- // A status listener is consumer code; its failure is isolated the
498
- // same way an event listener's is (FR-056).
499
- }
500
- }
501
- }
502
-
503
- function setPlacement(key: string, next: PlacementSnapshot): void {
504
- const runtime = placements.get(key);
505
- if (!runtime || runtime.snapshot.state === next.state) {
506
- if (runtime) {
507
- runtime.snapshot = next;
508
- }
509
- return;
510
- }
511
- runtime.snapshot = next;
512
- for (const listener of Array.from(runtime.listeners)) {
513
- try {
514
- listener(next.state);
515
- } catch {
516
- // Isolated, as above.
517
- }
518
- }
519
- }
520
-
521
- function dispatchPlacement(key: string, event: Parameters<typeof placementReducer>[1]): void {
522
- const runtime = placements.get(key);
523
- const definition = resolved.placements.get(key);
524
- if (!runtime || !definition) {
525
- return;
526
- }
527
- setPlacement(key, placementReducer(runtime.snapshot, event, definition.format));
528
- }
529
-
530
- /**
531
- * The single gate evaluation (FR-140).
532
- *
533
- * Assembles the three inputs and defers the precedence entirely to
534
- * `evaluateGates`. Nothing in this file compares gate state itself — the
535
- * whole point of FR-140 is that the order is a decision made once, not four
536
- * `if` chains that happen to agree today.
537
- *
538
- * Note the order differs from 0.1.0's, which checked `enabled` before
539
- * availability. That was never observable in isolation, only when two gates
540
- * were closed at once, and the case it changes — disabled on an unsupported
541
- * platform, previously decided by whichever transition arrived last — is the
542
- * incidental ordering FR-140a exists to fix.
543
- */
544
- function currentGate(): GateDecision {
545
- return evaluateGates({
546
- available: engine.isAvailable()
547
- ? { ok: true }
548
- : { ok: false, reason: 'unsupported_platform' },
549
- enablement,
550
- appGate: (() => {
551
- const gate = evaluateGate(currentPrivacy);
552
- return gate.allowed
553
- ? ({ permitted: true } as const)
554
- : ({ permitted: false, reason: gate.reason } as const);
555
- })(),
556
- consentGate: consentGateInput(),
557
- });
558
- }
559
-
560
- /** Maps a closed gate onto the refusal shape load, show, and the banner share. */
561
- function refusalFor(
562
- decision: Extract<GateDecision, { allowed: false }>,
563
- ): Extract<LoadResult, { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }> {
564
- switch (decision.kind) {
565
- case 'unavailable':
566
- return { status: 'unavailable', reason: decision.reason };
567
- case 'disabled':
568
- return { status: 'disabled' };
569
- case 'undecided':
570
- return { status: 'undecided' };
571
- default:
572
- return { status: 'blocked', reason: decision.reason };
573
- }
574
- }
575
-
576
- /** Every refusal that applies before a placement is even looked up. */
577
- function gateCheck():
578
- | { readonly ok: true }
579
- | {
580
- readonly ok: false;
581
- readonly result: Extract<
582
- LoadResult,
583
- { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
584
- >;
585
- } {
586
- const decision = currentGate();
587
- if (decision.allowed) {
588
- return { ok: true };
589
- }
590
- return { ok: false, result: refusalFor(decision) };
591
- }
592
-
593
- function emitRefusal(
594
- operation: 'initialize' | 'load' | 'show',
595
- result: { readonly status: string; readonly reason?: string },
596
- placementKey?: string,
597
- format?: AdFormat,
598
- ): void {
599
- if (result.status === 'disabled' || result.status === 'undecided') {
600
- // `undecided` reuses the `disabled` event rather than minting a new
601
- // one: for an observer of the event stream the fact is the same — no
602
- // request was made — and the distinction that matters is carried by
603
- // the typed result and the init status, where the app reads it.
604
- events.emit({ type: 'disabled', timestamp: now(), operation, placementKey, format });
605
- } else if (result.status === 'unavailable' || result.status === 'blocked') {
606
- events.emit({
607
- type: 'unavailable',
608
- timestamp: now(),
609
- operation,
610
- placementKey,
611
- format,
612
- reason: (result.reason ?? 'unsupported_platform') as UnavailableReason | PrivacyBlockReason,
613
- });
614
- }
615
- }
616
-
617
- /**
618
- * Consumes a throttle slot for a placement, or reports the refusal (FR-140).
619
- *
620
- * Returns `null` when the request may proceed, so the two call sites read as a
621
- * guard clause. On refusal it moves the placement to `throttled` and emits,
622
- * which is what makes the refusal observable rather than a silent absence of
623
- * ads — the app would otherwise have no way to tell this from no-fill.
624
- *
625
- * A placement still holding a `ready` ad keeps that state: the refusal is
626
- * about asking again, and overwriting it would strand an ad that was already
627
- * paid for and is still showable.
628
- */
629
- function reportThrottled(
630
- key: string,
631
- format: AdFormat,
632
- retryAfterMs: number,
633
- minRequestIntervalMs: number,
634
- ): LoadResult {
635
- const runtime = placements.get(key);
636
- if (runtime && runtime.snapshot.state !== 'ready') {
637
- setPlacement(key, { ...runtime.snapshot, state: 'throttled' });
638
- }
639
- events.emit({
640
- type: 'request_throttled',
641
- timestamp: now(),
642
- placementKey: key,
643
- format,
644
- retryAfterMs,
645
- minRequestIntervalMs,
646
- });
647
- return { status: 'throttled', retryAfterMs };
648
- }
649
-
650
- /** Consumes a slot, or reports the refusal. `null` means proceed. */
651
- function checkThrottle(key: string, format: AdFormat, token?: string): LoadResult | null {
652
- const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(key) ?? 0;
653
- const decision = requestThrottle.tryAcquire(key, minRequestIntervalMs, token);
654
- return decision.allowed
655
- ? null
656
- : reportThrottled(key, format, decision.retryAfterMs, minRequestIntervalMs);
657
- }
658
-
659
- function warnSubstitutionOnce(): void {
660
- if (warnedSubstitution || resolved.catalog.substitutedKeys.length === 0) {
661
- return;
662
- }
663
- warnedSubstitution = true;
664
- events.emit({
665
- type: 'config_warning',
666
- timestamp: now(),
667
- warning: 'test_ad_unit_substituted',
668
- placementKeys: resolved.catalog.substitutedKeys,
669
- });
670
- }
671
-
672
- function releasePlacementResources(key: string): void {
673
- const runtime = placements.get(key);
674
- if (!runtime) {
675
- return;
676
- }
677
- runtime.cancelLoadTimer?.();
678
- runtime.cancelLoadTimer = null;
679
- runtime.cancelShowTimer?.();
680
- runtime.cancelShowTimer = null;
681
- runtime.unsubscribeEngine?.();
682
- runtime.unsubscribeEngine = null;
683
- if (runtime.handle) {
684
- engine.dispose(runtime.handle);
685
- runtime.handle = null;
686
- }
687
- runtime.inFlightLoad = null;
688
- runtime.abortLoad = null;
689
- runtime.rewarded = null;
690
- runtime.cancelRetry?.();
691
- runtime.cancelRetry = null;
692
- }
693
-
694
- /**
695
- * Stops every request a gate that just closed forbids (FR-125, FR-153,
696
- * FR-159) — ads turned off, turned undecided, or consent withdrawn.
697
- *
698
- * In-flight loads are abandoned and their resources released, a loaded ad
699
- * that may no longer be shown is released with them, and an ad already on
700
- * screen is left alone — the provider offers no safe way to close it.
701
- *
702
- * An abandoned load *settles*: its caller gets `refusal`, the same answer a
703
- * load started now would get. Until 0.4.0 disabling released the resources
704
- * but never resolved the caller's promise, so an app awaiting `load()` across
705
- * a purchase of ad removal waited forever — and a handle the provider handed
706
- * back after the disable was adopted and went on loading.
707
- */
708
- function haltPlacements(refusal: LoadRefusal): void {
709
- for (const [key, runtime] of placements) {
710
- if (runtime.snapshot.state === 'showing') {
711
- continue;
712
- }
713
- const abort = runtime.abortLoad;
714
- releasePlacementResources(key);
715
- dispatchPlacement(key, { type: 'RESET' });
716
- if (abort) {
717
- emitRefusal('load', refusal, key, resolved.placements.get(key)?.format);
718
- abort(refusal);
719
- }
720
- }
721
- }
722
-
723
- /** App-open expires on its own, shorter window (FR-130). */
724
- function validityFor(format: AdFormat): number {
725
- return format === 'app_open'
726
- ? resolved.timeouts.appOpenValidity
727
- : resolved.timeouts.adValidity;
728
- }
729
-
730
- async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
731
- const enginePrivacy = toEnginePrivacy(privacy, resolved.testDeviceIds);
732
- try {
733
- // The adapter applies privacy configuration before initializing the
734
- // provider; that ordering is the whole reason FR-017 is enforceable.
735
- await engine.initialize(enginePrivacy);
736
- // Recorded before the status moves, and whether or not it moves: a gate
737
- // that closed while this was in flight leaves the status `blocked`, but
738
- // the provider is initialized all the same and must not be again.
739
- providerInitialized = true;
740
- appliedPrivacy = privacy;
741
- setInit(initReducer(init, { type: 'SUCCEED' }));
742
- warnSubstitutionOnce();
743
- return { status: 'ready' };
744
- } catch (error) {
745
- const normalized = normalizeProviderError(error, { operation: 'initialize' });
746
- setInit(initReducer(init, { type: 'FAIL', error: normalized }));
747
- return { status: 'failed', error: normalized };
748
- } finally {
749
- inFlightInit = null;
750
- }
751
- }
752
-
753
- function handleEngineEvent(
754
- key: string,
755
- definition: PlacementDefinition,
756
- event: EngineAdEvent,
757
- ): void {
758
- // After dispose nothing is honored: a late provider callback must not
759
- // update a consumer that has gone away (FR-035, SC-008).
760
- if (disposed) {
761
- return;
762
- }
763
- const runtime = placements.get(key);
764
- if (!runtime) {
765
- return;
766
- }
767
- const correlationId = runtime.correlationId ?? undefined;
768
- const base = { timestamp: now(), placementKey: key, format: definition.format, correlationId };
769
-
770
- switch (event.type) {
771
- case 'opened': {
772
- // The provider confirming an ad is on screen is what arms
773
- // ad-originated return detection (FR-128). Taken from here rather
774
- // than from the app's background report, which arrives too late to
775
- // be trusted — see core/adOriginatedReturn.ts.
776
- if (isFullScreenFormat(definition.format)) {
777
- adReturn.noteFullScreenShown(now());
778
- }
779
- events.emit({ ...base, type: 'display_started' });
780
- // R5: the provider exposes no impression callback for full-screen
781
- // formats, so this one is derived from the ad opening. Labelled
782
- // honestly rather than passed off as the provider's own.
783
- events.emit({ ...base, type: 'impression', source: 'derived' as ImpressionSource });
784
- break;
785
- }
786
- case 'clicked':
787
- // Recorded so that a background transition moments later is
788
- // recognized as "the ad opened a browser" — the only signal the
789
- // provider gives for that case (FR-128).
790
- adReturn.noteClick(now());
791
- events.emit({ ...base, type: 'click' });
792
- break;
793
- case 'rewarded': {
794
- const first = runtime.rewarded?.confirmReward(event.reward) ?? null;
795
- if (first) {
796
- events.emit({ ...base, type: 'reward_earned', reward: first });
797
- }
798
- break;
799
- }
800
- case 'dismissed': {
801
- const rewardedFirst = runtime.rewarded ? runtime.rewarded.dismiss() : true;
802
- if (runtime.dismissed || !rewardedFirst) {
803
- break;
804
- }
805
- runtime.dismissed = true;
806
- lock.release(key);
807
- runtime.cancelShowTimer?.();
808
- runtime.cancelShowTimer = null;
809
- events.emit({
810
- ...base,
811
- type: 'dismissed',
812
- rewardEarned: runtime.rewarded ? runtime.rewarded.rewardEarned : undefined,
813
- });
814
- dispatchPlacement(key, { type: 'DISMISSED' });
815
- // Deliberately NOT released here. FR-049 requires the reward and
816
- // the dismissal to be handled in either order, and a provider that
817
- // confirms the reward *after* closing the ad would otherwise have
818
- // its confirmation dropped by a torn-down listener — silently
819
- // costing the user a reward they earned.
820
- //
821
- // This is a retained resource, not a leak: at most one handle and
822
- // one listener per placement, released deterministically by the
823
- // next load of that placement or by dispose().
824
- break;
825
- }
826
- case 'failed': {
827
- const normalized = normalizeProviderError(event.cause, {
828
- operation: 'show',
829
- placementKey: key,
830
- format: definition.format,
831
- });
832
- lock.release(key);
833
- events.emit({ ...base, type: 'display_failed', error: normalized });
834
- dispatchPlacement(key, { type: 'SHOW_FAILED', error: normalized });
835
- releasePlacementResources(key);
836
- break;
837
- }
838
- default:
839
- break;
840
- }
841
- }
842
-
843
- function loadFullScreen(
844
- key: string,
845
- definition: PlacementDefinition,
846
- adUnitId: string,
847
- correlationId: string,
848
- ): Promise<LoadResult> {
849
- const runtime = placements.get(key)!;
850
- const format = definition.format as FullScreenFormat;
851
- const startedAt = now();
852
-
853
- return new Promise<LoadResult>(resolve => {
854
- let settled = false;
855
-
856
- const finish = (result: LoadResult) => {
857
- if (settled) {
858
- return;
859
- }
860
- settled = true;
861
- runtime.cancelLoadTimer?.();
862
- runtime.cancelLoadTimer = null;
863
- runtime.inFlightLoad = null;
864
- if (runtime.abortLoad === finish) {
865
- runtime.abortLoad = null;
866
- }
867
- resolve(result);
868
- };
869
- // `settled` is what makes this safe: a handle the provider hands back
870
- // after an abort is disposed by the `.then` below instead of adopted.
871
- runtime.abortLoad = finish;
872
-
873
- runtime.cancelLoadTimer = timers.after(resolved.timeouts.load, () => {
874
- const error = new AdsError('load_timeout', {
875
- operation: 'load',
876
- placementKey: key,
877
- format,
878
- message: `load timed out after ${resolved.timeouts.load}ms`,
879
- });
880
- events.emit({
881
- type: 'load_failed',
882
- timestamp: now(),
883
- placementKey: key,
884
- format,
885
- correlationId,
886
- error,
887
- });
888
- dispatchPlacement(key, { type: 'LOAD_FAILED', error });
889
- releasePlacementResources(key);
890
- finish({ status: 'timeout' });
891
- });
892
-
893
- engine
894
- .createFullScreen(format, adUnitId, toEngineRequest(currentPrivacy))
895
- .then(handle => {
896
- if (disposed || settled) {
897
- engine.dispose(handle);
898
- return;
899
- }
900
- runtime.handle = handle;
901
- runtime.unsubscribeEngine = engine.subscribe(handle, event => {
902
- if (event.type === 'loaded') {
903
- dispatchPlacement(key, { type: 'LOADED', atMs: now() });
904
- events.emit({
905
- type: 'loaded',
906
- timestamp: now(),
907
- placementKey: key,
908
- format,
909
- correlationId,
910
- durationMs: now() - startedAt,
911
- });
912
- finish({ status: 'loaded', correlationId });
913
- return;
914
- }
915
- if (event.type === 'failed') {
916
- const normalized = normalizeProviderError(event.cause, {
917
- operation: 'load',
918
- placementKey: key,
919
- format,
920
- });
921
- events.emit({
922
- type: 'load_failed',
923
- timestamp: now(),
924
- placementKey: key,
925
- format,
926
- correlationId,
927
- error: normalized,
928
- });
929
- dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
930
- releasePlacementResources(key);
931
- finish(
932
- normalized.code === 'no_fill'
933
- ? { status: 'no_fill' }
934
- : { status: 'failed', error: normalized },
935
- );
936
- return;
937
- }
938
- handleEngineEvent(key, definition, event);
939
- });
940
- })
941
- .catch(error => {
942
- const normalized = normalizeProviderError(error, {
943
- operation: 'load',
944
- placementKey: key,
945
- format,
946
- });
947
- events.emit({
948
- type: 'load_failed',
949
- timestamp: now(),
950
- placementKey: key,
951
- format,
952
- correlationId,
953
- error: normalized,
954
- });
955
- dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
956
- finish(
957
- normalized.code === 'no_fill'
958
- ? { status: 'no_fill' }
959
- : { status: 'failed', error: normalized },
960
- );
961
- });
962
- });
963
- }
964
-
965
- /**
966
- * One load, plus however many bounded retries the policy allows (FR-134).
967
- *
968
- * Off unless configured, so an upgraded 0.1.0 consumer goes through here once
969
- * and issues exactly the requests it always did. When on, only errors already
970
- * flagged `retryable` are retried — a configuration or consent failure is a
971
- * state another attempt cannot change, and retrying it would spend a billable
972
- * request on a certainty (FR-135).
973
- *
974
- * Every attempt is emitted before it is waited on, carrying the cycle's
975
- * correlation id, so the event stream can be reconciled against a bill
976
- * without inferring anything (FR-138).
977
- */
978
- async function loadWithRetry(
979
- key: string,
980
- definition: PlacementDefinition,
981
- adUnitId: string,
982
- correlationId: string,
983
- ): Promise<LoadResult> {
984
- const runtime = placements.get(key)!;
985
- const policy = resolved.retryByKey.get(key) ?? null;
986
-
987
- for (;;) {
988
- const result = await loadFullScreen(key, definition, adUnitId, correlationId);
989
- if (result.status === 'loaded' || disposed) {
990
- return result;
991
- }
992
-
993
- const error =
994
- result.status === 'failed'
995
- ? result.error
996
- : result.status === 'no_fill'
997
- ? new AdsError('no_fill', { operation: 'load', placementKey: key })
998
- : result.status === 'timeout'
999
- ? new AdsError('load_timeout', { operation: 'load', placementKey: key })
1000
- : null;
1001
-
1002
- // A refusal — disabled, undecided, blocked, unavailable — is not a
1003
- // failure to retry. Nothing was requested, and nothing about asking
1004
- // again would change that.
1005
- if (!error || !error.retryable) {
1006
- return result;
1007
- }
1008
- // The whole gate, not just enablement: a retry is a new provider request
1009
- // and needs the same permission the first one had (FR-017).
1010
- if (!canRetry(policy, runtime.retryAttempts) || !currentGate().allowed) {
1011
- return result;
1012
- }
1013
-
1014
- runtime.retryAttempts += 1;
1015
- const delayMs = delayForAttempt(policy!, runtime.retryAttempts);
1016
- events.emit({
1017
- type: 'retry_scheduled',
1018
- timestamp: now(),
1019
- placementKey: key,
1020
- format: definition.format,
1021
- correlationId,
1022
- attempt: runtime.retryAttempts,
1023
- delayMs,
1024
- error,
1025
- });
1026
-
1027
- const waited = await new Promise<boolean>(resolve => {
1028
- runtime.cancelRetry = timers.after(delayMs, () => {
1029
- runtime.cancelRetry = null;
1030
- resolve(true);
1031
- });
1032
- const cancel = runtime.cancelRetry;
1033
- // Cancellation replaces the handle, so a disable or dispose that
1034
- // clears it resolves this wait as "do not continue" rather than
1035
- // leaving a promise nobody will settle.
1036
- runtime.cancelRetry = () => {
1037
- cancel?.();
1038
- runtime.cancelRetry = null;
1039
- resolve(false);
1040
- };
1041
- });
1042
-
1043
- if (!waited || disposed || !currentGate().allowed) {
1044
- return result;
1045
- }
1046
-
1047
- dispatchPlacement(key, { type: 'LOAD' });
1048
- }
1049
- }
1050
-
1051
- /**
1052
- * Applies a consent outcome to the gate without a second initialization.
1053
- *
1054
- * This is FR-105's "the app does not wire it by hand" in one function. When
1055
- * consent opens the gate and the instance was blocked, it initializes — and
1056
- * because `initialize` is idempotent and shares one in-flight operation, the
1057
- * total native initialization count across a whole consent flow stays at one
1058
- * (FR-022, SC-102). When consent closes the gate, requests stop and every
1059
- * pending retry is cancelled with them (FR-137).
1060
- *
1061
- * Since 0.4.0 a closed gate also moves a `ready` instance to `blocked` and
1062
- * abandons in-flight loads (FR-153). 0.3.0 left the status at `ready`, which
1063
- * was harmless while the only consent outcome that could close the gate ran
1064
- * before anything was loaded. The privacy options form changes that: a user
1065
- * can withdraw consent with a banner mounted, and a banner host keyed on
1066
- * `ready` would otherwise keep requesting. Re-granting resumes through
1067
- * `initialize`, which knows the provider is already up and does not touch it
1068
- * again (FR-154).
1069
- */
1070
- async function reevaluateAfterConsent(): Promise<void> {
1071
- if (disposed || !currentPrivacy) {
1072
- return;
1073
- }
1074
- const decision = currentGate();
1075
- if (!decision.allowed) {
1076
- cancelAllRetries();
1077
- if (decision.kind === 'blocked') {
1078
- haltPlacements({ status: 'blocked', reason: decision.reason });
1079
- setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
1080
- }
1081
- return;
1082
- }
1083
- if (init.status === 'ready' || init.status === 'initializing') {
1084
- return;
1085
- }
1086
- await instance.initialize(currentPrivacy);
1087
- }
1088
-
1089
- /**
1090
- * Answers `initialize` without a native call when the provider is already up
1091
- * (FR-022, FR-154), or returns `null` to let a real initialization proceed.
1092
- *
1093
- * The status leaves `ready` when a gate closes, but nothing is torn down, so
1094
- * the gate reopening resumes. Init-time-only flags that changed in the
1095
- * meantime fall through to a real initialization: it is the only way they can
1096
- * take effect, and it is what 0.3.0 did.
1097
- *
1098
- * Only reached once the caller has passed the whole gate, consent flow
1099
- * included, so an app that re-runs `initialize` on a consent change cannot
1100
- * reopen what the user just closed.
1101
- */
1102
- function resumeInitialized(privacy: PrivacyOptions): InitResult | null {
1103
- if (
1104
- !providerInitialized ||
1105
- !canStart(init.status) ||
1106
- requiresReinitialization(appliedPrivacy, privacy)
1107
- ) {
1108
- return null;
1109
- }
1110
- setInit(initReducer(init, { type: 'RESUME' }));
1111
- return { status: 'ready' };
1112
- }
1113
-
1114
- const instance: AdsInstance = {
1115
- async initialize(privacy) {
1116
- if (disposed) {
1117
- return { status: 'unavailable', reason: 'provider_missing' };
1118
- }
1119
- currentPrivacy = privacy;
1120
-
1121
- // Availability first, then enablement, then consent — the one order,
1122
- // evaluated in the one place (FR-140).
1123
- const decision = currentGate();
1124
- if (!decision.allowed && decision.kind === 'unavailable') {
1125
- setInit(initReducer(init, { type: 'UNAVAILABLE', reason: decision.reason }));
1126
- const result = { status: 'unavailable', reason: decision.reason } as const;
1127
- emitRefusal('initialize', result);
1128
- return result;
1129
- }
1130
- if (!decision.allowed && decision.kind === 'disabled') {
1131
- setInit(initReducer(init, { type: 'DISABLE' }));
1132
- const result = { status: 'disabled' } as const;
1133
- emitRefusal('initialize', result);
1134
- return result;
1135
- }
1136
- if (!decision.allowed && decision.kind === 'undecided') {
1137
- // FR-115. Nothing provider-facing happens, exactly as when
1138
- // disabled — but the status the app observes says `undecided`, so
1139
- // a banner host can hold its space instead of reclaiming it.
1140
- setInit(initReducer(init, { type: 'UNDECIDE' }));
1141
- armUndecidedWarning();
1142
- const result = { status: 'undecided' } as const;
1143
- emitRefusal('initialize', result);
1144
- return result;
1145
- }
1146
-
1147
- if (!decision.allowed && decision.kind === 'blocked') {
1148
- // FR-017, FR-158. Nothing provider-facing happens here at all — not a
1149
- // configuration call, not an initialization. The engine is untouched.
1150
- //
1151
- // `decision` is the full gate: the app's own input *and*, once the app
1152
- // has opted into it, the package's consent flow. Until 0.4.0 this
1153
- // checked the app's input alone, so a user who declined the consent
1154
- // form still got a natively initialized SDK — `load` refused, but the
1155
- // provider was up, which is exactly what the provider's own guidance
1156
- // says not to do before `canRequestAds`. The consent flow opening the
1157
- // gate later initializes it, through `reevaluateAfterConsent`.
1158
- setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
1159
- const result = { status: 'blocked', reason: decision.reason } as const;
1160
- emitRefusal('initialize', result);
1161
- return result;
1162
- }
1163
-
1164
- if (init.status === 'ready') {
1165
- return { status: 'ready' };
1166
- }
1167
-
1168
- const resumed = resumeInitialized(privacy);
1169
- if (resumed) {
1170
- return resumed;
1171
- }
1172
-
1173
- // FR-022, SC-005. Racing callers share one operation, so the provider
1174
- // initializes exactly once however many arrive.
1175
- if (inFlightInit) {
1176
- return inFlightInit;
1177
- }
1178
- if (!canStart(init.status)) {
1179
- return { status: 'ready' };
1180
- }
1181
-
1182
- setInit(initReducer(init, { type: 'START' }));
1183
- inFlightInit = doInitialize(privacy);
1184
- return inFlightInit;
1185
- },
1186
-
1187
- async updatePrivacy(privacy) {
1188
- if (disposed) {
1189
- // The same answer initialize and load give a disposed instance.
1190
- return { status: 'unavailable', reason: 'provider_missing' };
1191
- }
1192
- if (enablement === 'undecided') {
1193
- return { status: 'undecided' };
1194
- }
1195
- if (enablement === 'disabled') {
1196
- return { status: 'disabled' };
1197
- }
1198
- if (!engine.isAvailable()) {
1199
- return { status: 'unavailable', reason: 'unsupported_platform' };
1200
- }
1201
-
1202
- // FR-020: a flag the provider only honors at initialization cannot be
1203
- // applied now. Say so, rather than accepting it and running half-old.
1204
- if (requiresReinitialization(appliedPrivacy, privacy)) {
1205
- return { status: 'requires_reinitialization' };
1206
- }
1207
-
1208
- currentPrivacy = privacy;
1209
- const gate = evaluateGate(privacy);
1210
- if (!gate.allowed) {
1211
- // FR-017, FR-137, FR-153: the same teardown as a consent outcome that
1212
- // closes the gate. Without it a retry already waiting on its backoff
1213
- // would still reach the provider after the app withdrew consent.
1214
- cancelAllRetries();
1215
- haltPlacements({ status: 'blocked', reason: gate.reason });
1216
- setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
1217
- return { status: 'blocked', reason: gate.reason };
1218
- }
1219
-
1220
- // FR-019: resolving consent after a block must not produce a second
1221
- // native initialization.
1222
- if (init.status === 'ready') {
1223
- return { status: 'applied' };
1224
- }
1225
- // Exhaustive on purpose (ADS-04): the old ternary mapped only `ready` and
1226
- // `blocked`, and every other outcome — `failed` included — fell through to
1227
- // `applied`. A new InitResult member now fails to compile here.
1228
- const result = await instance.initialize(privacy);
1229
- switch (result.status) {
1230
- case 'ready':
1231
- return { status: 'applied' };
1232
- case 'blocked':
1233
- case 'disabled':
1234
- case 'undecided':
1235
- case 'unavailable':
1236
- case 'failed':
1237
- return result;
1238
- default: {
1239
- const unreachable: never = result;
1240
- return unreachable;
1241
- }
1242
- }
1243
- },
1244
-
1245
- setEnabled(next) {
1246
- return instance.setEnablement(next ? 'enabled' : 'disabled');
1247
- },
1248
-
1249
- setEnablement(next) {
1250
- if (disposed) {
1251
- return { status: 'unavailable', reason: 'provider_missing' };
1252
- }
1253
- if (enablement === next) {
1254
- return { status: 'unchanged' };
1255
- }
1256
-
1257
- const previous = enablement;
1258
- enablement = next;
1259
-
1260
- if (next === 'disabled' || next === 'undecided') {
1261
- // FR-037 and FR-125: new work is prevented and in-flight resources
1262
- // released deterministically, and moving to `undecided` from
1263
- // `enabled` follows the same rules as disabling. An ad already on
1264
- // screen is left alone — the provider offers no safe way to close
1265
- // it, and yanking it would be worse than letting it finish.
1266
- cancelAllRetries();
1267
- haltPlacements(next === 'disabled' ? { status: 'disabled' } : { status: 'undecided' });
1268
- setInit(initReducer(init, { type: next === 'disabled' ? 'DISABLE' : 'UNDECIDE' }));
1269
- if (next === 'undecided') {
1270
- armUndecidedWarning();
1271
- return { status: 'undecided' };
1272
- }
1273
- disarmUndecidedWarning();
1274
- return { status: 'disabled' };
1275
- }
1276
-
1277
- // Turning ads on. This is the path 0.1.0 got wrong: it dropped the
1278
- // status to `not_started` and returned nothing, so an app whose user
1279
- // lost their entitlement turned ads back on and then had every load
1280
- // silently refused (FR-121).
1281
- disarmUndecidedWarning();
1282
- const resumable = canResume(init.resumeTo);
1283
- setInit(initReducer(init, { type: 'ENABLE' }));
1284
-
1285
- if (init.status === 'unavailable') {
1286
- return { status: 'unavailable', reason: init.unavailableReason ?? 'unsupported_platform' };
1287
- }
1288
- if (resumable && init.status === 'ready') {
1289
- // The provider was never un-initialized, so there is genuinely
1290
- // nothing to redo — and FR-022 forbids initializing it twice.
1291
- return { status: 'resumed', initStatus: init.status };
1292
- }
1293
- if (previous === 'undecided' && init.status === 'ready') {
1294
- return { status: 'resumed', initStatus: init.status };
1295
- }
1296
- // Said out loud rather than left for the app to discover from a
1297
- // refused load. The package will not initialize implicitly: that would
1298
- // hide an asynchronous, billable operation behind a setter the caller
1299
- // may not be awaiting.
1300
- return { status: 'requires_initialization' };
1301
- },
1302
-
1303
- setAppActive(active) {
1304
- if (disposed) {
1305
- return;
1306
- }
1307
- appActive = active;
1308
- if (!active) {
1309
- // Arming happens when the provider reports an ad opened, not here:
1310
- // this event can be delivered late enough that the ad is already
1311
- // gone. All this adds is the browser case, where a background
1312
- // closely follows a click (FR-128).
1313
- adReturn.noteBackground(now());
1314
- return;
1315
- }
1316
- adReturn.noteForeground(now());
1317
- // FR-034: on return to the foreground, re-evaluate what was loaded.
1318
- // An ad that went stale while the app was away must stop reporting
1319
- // `ready` before the app offers a button that would fail on tap.
1320
- const current = now();
1321
- for (const [key, runtime] of placements) {
1322
- const definition = resolved.placements.get(key);
1323
- if (
1324
- runtime.snapshot.state === 'ready' &&
1325
- runtime.snapshot.loadedAtMs !== null &&
1326
- definition !== undefined &&
1327
- isStale(runtime.snapshot.loadedAtMs, current, validityFor(definition.format))
1328
- ) {
1329
- releasePlacementResources(key);
1330
- dispatchPlacement(key, { type: 'EXPIRE' });
1331
- }
1332
- }
1333
- },
1334
-
1335
- getStatus() {
1336
- return init.status;
1337
- },
1338
-
1339
- subscribeStatus(listener) {
1340
- statusListeners.add(listener);
1341
- let active = true;
1342
- return () => {
1343
- if (!active) {
1344
- return;
1345
- }
1346
- active = false;
1347
- statusListeners.delete(listener);
1348
- };
1349
- },
1350
-
1351
- async load(placementKey) {
1352
- if (disposed) {
1353
- return { status: 'unavailable', reason: 'provider_missing' };
1354
- }
1355
-
1356
- const definition = resolved.placements.get(placementKey);
1357
- if (!definition) {
1358
- return {
1359
- status: 'failed',
1360
- error: new AdsError('placement_not_found', {
1361
- operation: 'load',
1362
- placementKey,
1363
- message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
1364
- }),
1365
- };
1366
- }
1367
-
1368
- const gate = gateCheck();
1369
- if (!gate.ok) {
1370
- emitRefusal('load', gate.result, placementKey, definition.format);
1371
- return gate.result;
1372
- }
1373
-
1374
- if (!canRequest(init.status)) {
1375
- return {
1376
- status: 'failed',
1377
- error: new AdsError('not_ready', {
1378
- operation: 'load',
1379
- placementKey,
1380
- format: definition.format,
1381
- message: 'initialize() must complete before loading',
1382
- }),
1383
- };
1384
- }
1385
-
1386
- if (definition.format === 'banner') {
1387
- return {
1388
- status: 'failed',
1389
- error: new AdsError('format_mismatch', {
1390
- operation: 'load',
1391
- placementKey,
1392
- format: definition.format,
1393
- message: `"${placementKey}" is a banner; banners render through <AdsBanner> rather than load()`,
1394
- }),
1395
- };
1396
- }
1397
-
1398
- const runtime = placements.get(placementKey)!;
1399
-
1400
- // FR-029, SC-002's sibling guarantee: a repeat load while one is in
1401
- // flight joins it instead of creating a second provider request.
1402
- if (runtime.inFlightLoad) {
1403
- return runtime.inFlightLoad;
1404
- }
1405
-
1406
- if (runtime.snapshot.state === 'showing') {
1407
- return { status: 'failed', error: new AdsError('busy', { operation: 'load', placementKey }) };
1408
- }
1409
-
1410
- if (!canLoad(runtime.snapshot.state)) {
1411
- return {
1412
- status: 'failed',
1413
- error: new AdsError('not_ready', { operation: 'load', placementKey }),
1414
- };
1415
- }
1416
-
1417
- // FR-140. Checked after the in-flight join so a caller that merely
1418
- // arrived twice for one cycle is answered by the join, not charged a
1419
- // throttle slot; after the busy and not-ready guards so a load that was
1420
- // never going to request anything does not spend one either; and before
1421
- // the correlation id and `load_started`, because a refused request is
1422
- // not a cycle and must not look like one in the event stream.
1423
- const throttled = checkThrottle(placementKey, definition.format);
1424
- if (throttled) {
1425
- return throttled;
1426
- }
1427
-
1428
- releasePlacementResources(placementKey);
1429
-
1430
- const correlationId = correlation.next();
1431
- runtime.correlationId = correlationId;
1432
- runtime.dismissed = false;
1433
- dispatchPlacement(placementKey, { type: 'LOAD' });
1434
- setPlacement(placementKey, { ...runtime.snapshot, correlationId });
1435
- events.emit({
1436
- type: 'load_started',
1437
- timestamp: now(),
1438
- placementKey,
1439
- format: definition.format,
1440
- correlationId,
1441
- });
1442
-
1443
- const adUnitId = resolved.catalog.byKey.get(placementKey)!;
1444
- runtime.retryAttempts = 0;
1445
- const promise = loadWithRetry(placementKey, definition, adUnitId, correlationId);
1446
- runtime.inFlightLoad = promise;
1447
- return promise;
1448
- },
1449
-
1450
- async show(placementKey) {
1451
- if (disposed) {
1452
- return { status: 'unavailable', reason: 'provider_missing' };
1453
- }
1454
-
1455
- const definition = resolved.placements.get(placementKey);
1456
- if (!definition) {
1457
- return {
1458
- status: 'failed',
1459
- error: new AdsError('placement_not_found', {
1460
- operation: 'show',
1461
- placementKey,
1462
- message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
1463
- }),
1464
- };
1465
- }
1466
-
1467
- if (!isFullScreenFormat(definition.format)) {
1468
- return {
1469
- status: 'failed',
1470
- error: new AdsError('format_mismatch', {
1471
- operation: 'show',
1472
- placementKey,
1473
- format: definition.format,
1474
- message: `"${placementKey}" is a banner; banners are rendered, not shown`,
1475
- }),
1476
- };
1477
- }
1478
-
1479
- const gate = gateCheck();
1480
- if (!gate.ok) {
1481
- emitRefusal('show', gate.result, placementKey, definition.format);
1482
- return gate.result;
1483
- }
1484
-
1485
- // FR-028: never open an ad while the app is not in front of the user.
1486
- if (!appActive) {
1487
- return { status: 'app_inactive' };
1488
- }
1489
-
1490
- // FR-128. Only app-open is refused: the user just came back from an ad
1491
- // or from a browser an ad opened, and app-open is the format that
1492
- // would fire on exactly that return. An interstitial at the same
1493
- // moment is the app's own deliberate choice, made somewhere the
1494
- // package cannot see, so refusing it would be policy rather than
1495
- // capability.
1496
- if (definition.format === 'app_open' && adReturn.isRefusing(now())) {
1497
- return { status: 'ad_originated_return' };
1498
- }
1499
-
1500
- const runtime = placements.get(placementKey)!;
1501
-
1502
- if (runtime.snapshot.state === 'consumed') {
1503
- return { status: 'consumed' };
1504
- }
1505
-
1506
- // FR-034: a stale ad is not shown and is not reported ready.
1507
- if (
1508
- runtime.snapshot.state === 'ready' &&
1509
- runtime.snapshot.loadedAtMs !== null &&
1510
- isStale(runtime.snapshot.loadedAtMs, now(), validityFor(definition.format))
1511
- ) {
1512
- releasePlacementResources(placementKey);
1513
- dispatchPlacement(placementKey, { type: 'EXPIRE' });
1514
- return { status: 'not_ready' };
1515
- }
1516
-
1517
- if (runtime.snapshot.state !== 'ready' || !runtime.handle) {
1518
- return { status: 'not_ready' };
1519
- }
1520
-
1521
- // FR-030. Acquired before anything else can open a screen, and never
1522
- // queued: a refused show is refused now, not deferred to a moment the
1523
- // app never chose.
1524
- if (!lock.acquire(placementKey)) {
1525
- return { status: 'busy' };
1526
- }
1527
-
1528
- const correlationId = runtime.correlationId ?? correlation.next();
1529
- runtime.rewarded = definition.format === 'rewarded' ? createRewardedSession() : null;
1530
- dispatchPlacement(placementKey, { type: 'SHOW' });
1531
-
1532
- let settled = false;
1533
- const showTimeout = new Promise<ShowResult>(resolve => {
1534
- runtime.cancelShowTimer = timers.after(resolved.timeouts.show, () => {
1535
- if (settled) {
1536
- return;
1537
- }
1538
- settled = true;
1539
- const error = new AdsError('show_timeout', {
1540
- operation: 'show',
1541
- placementKey,
1542
- format: definition.format,
1543
- message: `show timed out after ${resolved.timeouts.show}ms`,
1544
- });
1545
- lock.release(placementKey);
1546
- events.emit({
1547
- type: 'display_failed',
1548
- timestamp: now(),
1549
- placementKey,
1550
- format: definition.format,
1551
- correlationId,
1552
- error,
1553
- });
1554
- dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
1555
- releasePlacementResources(placementKey);
1556
- resolve({ status: 'timeout' });
1557
- });
1558
- });
1559
-
1560
- const attempt = engine
1561
- .show(runtime.handle)
1562
- .then<ShowResult>(() => {
1563
- settled = true;
1564
- runtime.cancelShowTimer?.();
1565
- runtime.cancelShowTimer = null;
1566
- return { status: 'shown', correlationId };
1567
- })
1568
- .catch<ShowResult>(error => {
1569
- settled = true;
1570
- runtime.cancelShowTimer?.();
1571
- runtime.cancelShowTimer = null;
1572
- const normalized = normalizeProviderError(error, {
1573
- operation: 'show',
1574
- placementKey,
1575
- format: definition.format,
1576
- });
1577
- lock.release(placementKey);
1578
- events.emit({
1579
- type: 'display_failed',
1580
- timestamp: now(),
1581
- placementKey,
1582
- format: definition.format,
1583
- correlationId,
1584
- error: normalized,
1585
- });
1586
- dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error: normalized });
1587
- releasePlacementResources(placementKey);
1588
- return { status: 'failed', error: normalized };
1589
- });
1590
-
1591
- return Promise.race([attempt, showTimeout]);
1592
- },
1593
-
1594
- getPlacementState(placementKey) {
1595
- if (!resolved.placements.has(placementKey)) {
1596
- return 'unavailable';
1597
- }
1598
- if (!engine.isAvailable() || enablement === 'disabled') {
1599
- return 'unavailable';
1600
- }
1601
- // Reported distinctly from `unavailable` so the app can choose to hold
1602
- // the banner's space rather than reclaim it. That choice is the entire
1603
- // reason the third state is worth having (FR-116).
1604
- if (enablement === 'undecided') {
1605
- return 'undecided';
1606
- }
1607
- return placements.get(placementKey)?.snapshot.state ?? 'idle';
1608
- },
1609
-
1610
- subscribePlacement(key, listener) {
1611
- const runtime = placements.get(key);
1612
- if (!runtime) {
1613
- return () => undefined;
1614
- }
1615
- runtime.listeners.add(listener);
1616
- let active = true;
1617
- return () => {
1618
- if (!active) {
1619
- return;
1620
- }
1621
- active = false;
1622
- runtime.listeners.delete(listener);
1623
- };
1624
- },
1625
-
1626
- subscribeEvents(listener) {
1627
- return events.subscribe(listener);
1628
- },
1629
-
1630
- getSubstitutedPlacements() {
1631
- return resolved.catalog.substitutedKeys;
1632
- },
1633
-
1634
- getIgnoredConsentTools() {
1635
- return resolved.ignoredConsentTools;
1636
- },
1637
-
1638
- consent: {
1639
- async requestInfoUpdate() {
1640
- if (disposed) {
1641
- return consent;
1642
- }
1643
- // Marks the flow as in use. Until this is called the gate sees no
1644
- // consent input at all, which is how an app that supplies its own
1645
- // gate keeps 0.1.0 behavior without opting out of anything
1646
- // (FR-102).
1647
- consentStarted = true;
1648
- if (resolved.consentTesting?.resetOnStart) {
1649
- try {
1650
- engine.consent.reset();
1651
- } catch {
1652
- // A reset that fails must not abort the update; the flow
1653
- // continues against whatever state is stored.
1654
- }
1655
- }
1656
- const next = await runConsentStep(
1657
- () =>
1658
- engine.consent.requestInfoUpdate(
1659
- buildConsentOptions(
1660
- resolved.consentTesting,
1661
- currentPrivacy?.underAgeOfConsent,
1662
- resolved.testDeviceIds,
1663
- ),
1664
- ),
1665
- 'consent information update failed',
1666
- );
1667
- if (disposed) {
1668
- return consent;
1669
- }
1670
- setConsent(next);
1671
- await reevaluateAfterConsent();
1672
- return next;
1673
- },
1674
-
1675
- async showFormIfRequired() {
1676
- if (disposed) {
1677
- return consent;
1678
- }
1679
- consentStarted = true;
1680
- if (!needsForm(consent) || privacyOptionsFormOpen) {
1681
- // Nothing to present. Not an error: a region that does not
1682
- // require consent is the normal case, and showing a form there
1683
- // is exactly what FR-107 forbids the package from deciding.
1684
- // Nor is a privacy options form already on screen — one
1685
- // provider form at a time.
1686
- return consent;
1687
- }
1688
- consentFormsOpen += 1;
1689
- let next: ConsentSnapshot;
1690
- try {
1691
- next = await runConsentStep(
1692
- () => engine.consent.showFormIfRequired(),
1693
- 'consent form failed',
1694
- );
1695
- } finally {
1696
- consentFormsOpen -= 1;
1697
- }
1698
- if (disposed) {
1699
- return consent;
1700
- }
1701
- setConsent(next);
1702
- await reevaluateAfterConsent();
1703
- return next;
1704
- },
1705
-
1706
- async showPrivacyOptionsForm() {
1707
- // The refusals, most immovable first — the same reading order as
1708
- // the request gates, so the answer is the most useful one rather
1709
- // than the first one checked.
1710
- if (disposed) {
1711
- return { status: 'unavailable', reason: 'provider_missing' };
1712
- }
1713
- if (!engine.isAvailable()) {
1714
- return { status: 'unavailable', reason: 'unsupported_platform' };
1715
- }
1716
- if (privacyOptionsFormOpen || consentFormsOpen > 0) {
1717
- return { status: 'busy' };
1718
- }
1719
- if (consent.privacyOptionsRequirement === 'unknown') {
1720
- return { status: 'info_update_required' };
1721
- }
1722
- if (consent.privacyOptionsRequirement !== 'required') {
1723
- // The provider's verdict, not a region guess of ours (FR-150).
1724
- return { status: 'not_required' };
1725
- }
1726
-
1727
- privacyOptionsFormOpen = true;
1728
- let next: ConsentSnapshot;
1729
- let failure: AdsError | null = null;
1730
- try {
1731
- next = normalizeConsent(await engine.consent.showPrivacyOptionsForm());
1732
- } catch (cause) {
1733
- failure = toConsentError(cause, 'privacy options form failed');
1734
- // The form did not complete, so the stored decision is the
1735
- // provider's to report — ask it rather than assume. Collapsing to
1736
- // `error` the way a failed first-time form does would revoke a
1737
- // consent the user never changed: that form runs while the gate is
1738
- // already closed, this one usually while it is open. If the
1739
- // provider cannot answer either, the gate closes; a failure is
1740
- // never consent (FR-106). Nothing is asked of a disposed instance.
1741
- next = disposed
1742
- ? consent
1743
- : await runConsentStep(
1744
- () => engine.consent.getInfo(),
1745
- 'consent information could not be read after the privacy options form failed',
1746
- );
1747
- } finally {
1748
- privacyOptionsFormOpen = false;
1749
- }
1750
- // dispose() during the form: the form is the provider's and stays
1751
- // until the user closes it, but its outcome reaches nobody (FR-157).
1752
- if (disposed) {
1753
- return { status: 'unavailable', reason: 'provider_missing' };
1754
- }
1755
- setConsent(next);
1756
- await reevaluateAfterConsent();
1757
- return failure
1758
- ? { status: 'failed', error: failure }
1759
- : { status: 'shown', consent: next };
1760
- },
1761
-
1762
- getState() {
1763
- return consent;
1764
- },
1765
-
1766
- subscribe(listener) {
1767
- consentListeners.add(listener);
1768
- let active = true;
1769
- return () => {
1770
- if (!active) {
1771
- return;
1772
- }
1773
- active = false;
1774
- consentListeners.delete(listener);
1775
- };
1776
- },
1777
-
1778
- async reset() {
1779
- if (disposed) {
1780
- return;
1781
- }
1782
- // Config validation already stripped the testing tools in
1783
- // production, so this is inert there without a check here (FR-113).
1784
- if (!resolved.consentTesting) {
1785
- return;
1786
- }
1787
- try {
1788
- engine.consent.reset();
1789
- } catch {
1790
- // Nothing to report: the stored state is the provider's, and a
1791
- // failed reset leaves it as it was.
1792
- }
1793
- setConsent(INITIAL_CONSENT);
1794
- },
1795
- },
1796
-
1797
- internals: {
1798
- resolveBanner(placementKey) {
1799
- const definition = resolved.placements.get(placementKey);
1800
- if (!definition) {
1801
- return {
1802
- ok: false,
1803
- result: {
1804
- status: 'failed',
1805
- error: new AdsError('placement_not_found', {
1806
- operation: 'load',
1807
- placementKey,
1808
- message: `unknown placement "${placementKey}"`,
1809
- }),
1810
- },
1811
- };
1812
- }
1813
- if (definition.format !== 'banner') {
1814
- return {
1815
- ok: false,
1816
- result: {
1817
- status: 'failed',
1818
- error: new AdsError('format_mismatch', {
1819
- operation: 'load',
1820
- placementKey,
1821
- format: definition.format,
1822
- message: `"${placementKey}" is not a banner placement`,
1823
- }),
1824
- },
1825
- };
1826
- }
1827
- const gate = gateCheck();
1828
- if (!gate.ok) {
1829
- return { ok: false, result: gate.result };
1830
- }
1831
- if (!canRequest(init.status)) {
1832
- return { ok: false, result: { status: 'blocked', reason: 'consent_required' } };
1833
- }
1834
- // The throttle is deliberately **not** consulted here. It cannot be
1835
- // decided from configuration alone: a fresh mount must be throttled
1836
- // (that is the navigation burst the feature exists for) while a width
1837
- // re-adaptation of an already-mounted banner must not be (that is
1838
- // rotation, and refusing it would leave the user with no banner for a
1839
- // minute). Only the host knows which it is, so the host calls
1840
- // `commitBannerRequest` (FR-140).
1841
- return {
1842
- ok: true,
1843
- adUnitId: resolved.catalog.byKey.get(placementKey)!,
1844
- correlationId: correlation.next(),
1845
- personalizedAds: toEngineRequest(currentPrivacy).personalizedAds,
1846
- };
1847
- },
1848
- commitBannerRequest(placementKey, correlationId) {
1849
- if (!resolved.placements.has(placementKey)) {
1850
- return null;
1851
- }
1852
- const minRequestIntervalMs =
1853
- resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
1854
- const decision = requestThrottle.tryAcquire(
1855
- placementKey,
1856
- minRequestIntervalMs,
1857
- correlationId,
1858
- );
1859
- // Deliberately silent: the host calls this during render, because the
1860
- // provider component requests as part of that same render and an effect
1861
- // would be too late to stop it. Emitting an event or setting placement
1862
- // state here would mean mutating during another component's render, so
1863
- // the refusal is only returned — the host reports it from its effect.
1864
- return decision.allowed
1865
- ? null
1866
- : { status: 'throttled', retryAfterMs: decision.retryAfterMs };
1867
- },
1868
- throttleFloorFor(placementKey) {
1869
- return resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
1870
- },
1871
- emit(event) {
1872
- events.emit(event);
1873
- },
1874
- setPlacementState(key, state) {
1875
- const runtime = placements.get(key);
1876
- if (runtime) {
1877
- setPlacement(key, { ...runtime.snapshot, state });
1878
- }
1879
- },
1880
- isReady() {
1881
- return permitsRequests(enablement) && engine.isAvailable() && canRequest(init.status);
1882
- },
1883
- nextCorrelationId() {
1884
- return correlation.next();
1885
- },
1886
- timerCount() {
1887
- return timers.size();
1888
- },
1889
- },
1890
-
1891
- dispose() {
1892
- // FR-005: idempotent. A second call is a no-op, not an error.
1893
- if (disposed) {
1894
- return;
1895
- }
1896
- disposed = true;
1897
- cancelAllRetries();
1898
- disarmUndecidedWarning();
1899
- for (const [key, runtime] of placements) {
1900
- const abort = runtime.abortLoad;
1901
- releasePlacementResources(key);
1902
- runtime.listeners.clear();
1903
- // Settled rather than left hanging, with the answer a `load` after
1904
- // dispose gets (FR-159). No refusal event goes with it: nothing is
1905
- // emitted after dispose (FR-035).
1906
- abort?.({ status: 'unavailable', reason: 'provider_missing' });
1907
- }
1908
- timers.clearAll();
1909
- requestThrottle.clear();
1910
- lock.reset();
1911
- adReturn.reset();
1912
- statusListeners.clear();
1913
- consentListeners.clear();
1914
- events.dispose();
1915
- inFlightInit = null;
1916
- },
1917
- };
1918
-
1919
- return instance;
1920
- }
1921
-
1922
- /**
1923
- * Resolved lazily so importing the package on a platform with no adapter — or
1924
- * with the bridge absent — costs nothing and crashes nothing (FR-025, SC-004).
1925
- */
1926
- function createDefaultEngine(): AdsEngine {
1927
- // Deferred require keeps the provider off the import graph of every module
1928
- // that merely mentions `createAds`.
1929
- const { createAdMobEngine } = require('../engine/admobEngine') as typeof import('../engine/admobEngine');
1930
- return createAdMobEngine();
1931
- }
1932
-
1933
- declare const __DEV__: boolean | undefined;
1
+ /**
2
+ * The ads instance — the public contract's implementation.
3
+ *
4
+ * Every guarantee the specification treats as a rule rather than a convention
5
+ * converges here, and none of it needs a device to prove:
6
+ *
7
+ * FR-022 one native initialization, however many callers race
8
+ * FR-017 no request before the privacy gate permits one
9
+ * FR-011 no production ad unit outside production
10
+ * FR-029 one in-flight provider request per placement
11
+ * FR-030 exactly one full-screen ad, never a queue
12
+ * FR-031 a consumed ad is never reused
13
+ * FR-035 after dispose, a late callback updates nothing and emits nothing
14
+ *
15
+ * Nothing in this file imports the provider. It talks to the engine port, which
16
+ * the caller injects — the AdMob adapter in production, the fake in tests.
17
+ */
18
+
19
+ import {
20
+ isFullScreenFormat,
21
+ type AdFormat,
22
+ type AdsConfig,
23
+ type ConsentSnapshot,
24
+ type Enablement,
25
+ type FullScreenFormat,
26
+ type InitStatus,
27
+ type PlacementDefinition,
28
+ type PlacementState,
29
+ type PrivacyBlockReason,
30
+ type PrivacyOptions,
31
+ type UnavailableReason,
32
+ type Unsubscribe,
33
+ } from '../config/types';
34
+ import { validateConfig, type ResolvedConfig } from '../config/validate';
35
+ import { AdsError, normalizeProviderError } from '../diagnostics/errors';
36
+ import {
37
+ createEventEmitter,
38
+ type AdEvent,
39
+ type AdEventListener,
40
+ type ImpressionSource,
41
+ } from '../diagnostics/events';
42
+ import { createRequestThrottle } from './requestThrottle';
43
+ import { createCorrelationIssuer } from '../diagnostics/correlation';
44
+ import type { AdsEngine, EngineAdEvent, EngineAdHandle } from '../engine/port';
45
+ import { createFullScreenLock } from './fullScreenLock';
46
+ import { canRequest, canStart, initialInit, initReducer, type InitSnapshot } from './initMachine';
47
+ import {
48
+ canLoad,
49
+ initialPlacement,
50
+ placementReducer,
51
+ type PlacementSnapshot,
52
+ } from './placementMachine';
53
+ import {
54
+ evaluateGate,
55
+ requiresReinitialization,
56
+ toEnginePrivacy,
57
+ toEngineRequest,
58
+ } from './privacyGate';
59
+ import { evaluateGates, type ConsentGateInput, type GateDecision } from './gates';
60
+ import { createRewardedSession, type RewardedSession } from './rewardedSession';
61
+ import { createTimerSet, isStale, type Scheduler } from './timers';
62
+ import { canResume, permitsRequests, type EnablementResult } from './enablement';
63
+ import { canRetry, delayForAttempt } from './retryPolicy';
64
+ import { createAdOriginatedTracker } from './adOriginatedReturn';
65
+ import {
66
+ buildConsentOptions,
67
+ INITIAL_CONSENT,
68
+ needsForm,
69
+ normalizeConsent,
70
+ runConsentStep,
71
+ toConsentError,
72
+ toGateInput,
73
+ } from './consentFlow';
74
+
75
+ export type InitResult =
76
+ | { readonly status: 'ready' }
77
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
78
+ | { readonly status: 'disabled' }
79
+ /** The app has not decided whether ads are on. Nothing was requested (FR-115). */
80
+ | { readonly status: 'undecided' }
81
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
82
+ | { readonly status: 'failed'; readonly error: AdsError };
83
+
84
+ export type PrivacyUpdateResult =
85
+ | { readonly status: 'applied' }
86
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
87
+ | { readonly status: 'requires_reinitialization' }
88
+ | { readonly status: 'disabled' }
89
+ | { readonly status: 'undecided' }
90
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
91
+ /**
92
+ * The initialization this update triggered failed (added in 0.5.0). Before,
93
+ * it was reported as `applied`, so the app never retried and no ad ever
94
+ * showed, with nothing to say why.
95
+ */
96
+ | { readonly status: 'failed'; readonly error: AdsError };
97
+
98
+ export type LoadResult =
99
+ | { readonly status: 'loaded'; readonly correlationId: string }
100
+ | { readonly status: 'disabled' }
101
+ | { readonly status: 'undecided' }
102
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
103
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
104
+ | { readonly status: 'no_fill' }
105
+ /**
106
+ * Refused by the per-placement request throttle (FR-140), added in 0.3.0.
107
+ *
108
+ * Nothing was requested and nothing is broken — this placement asked too
109
+ * recently. Distinct from `no_fill` on purpose: no-fill means the network had
110
+ * nothing to give, this means we did not ask. An app that treats them the same
111
+ * will misdiagnose the first time a banner goes quiet.
112
+ */
113
+ | ThrottledResult
114
+ | { readonly status: 'timeout' }
115
+ | { readonly status: 'failed'; readonly error: AdsError };
116
+
117
+ export interface ThrottledResult {
118
+ readonly status: 'throttled';
119
+ readonly retryAfterMs: number;
120
+ }
121
+
122
+ /** A gate refusal: what a load gets when it may not ask at all. */
123
+ type LoadRefusal = Extract<
124
+ LoadResult,
125
+ { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
126
+ >;
127
+
128
+ export type ShowResult =
129
+ | { readonly status: 'shown'; readonly correlationId: string }
130
+ | { readonly status: 'not_ready' }
131
+ | { readonly status: 'busy' }
132
+ | { readonly status: 'consumed' }
133
+ | { readonly status: 'app_inactive' }
134
+ /**
135
+ * The user came back to the app from an ad, or from a browser an ad opened,
136
+ * so an app-open ad would be an ad immediately after an ad. Distinct from
137
+ * `busy` and `not_ready` because the app cannot detect this itself and
138
+ * therefore cannot infer it from either of those (FR-128).
139
+ */
140
+ | { readonly status: 'ad_originated_return' }
141
+ | { readonly status: 'disabled' }
142
+ | { readonly status: 'undecided' }
143
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
144
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
145
+ | { readonly status: 'timeout' }
146
+ | { readonly status: 'failed'; readonly error: AdsError };
147
+
148
+ /**
149
+ * What `consent.showPrivacyOptionsForm()` reports (FR-151), added in 0.4.0.
150
+ *
151
+ * Every refusal is its own member, so an app can tell "there is nothing to
152
+ * offer here" from "you asked too early" from "it broke" without parsing an
153
+ * error. None of them throws.
154
+ */
155
+ export type PrivacyOptionsFormResult =
156
+ /**
157
+ * The provider's form was presented and has closed. `consent` is the state it
158
+ * left behind, already applied to the gate. A user who closed the form without
159
+ * changing anything lands here too, with the state they already had.
160
+ */
161
+ | { readonly status: 'shown'; readonly consent: ConsentSnapshot }
162
+ /** The provider does not require privacy options here. Nothing was presented. */
163
+ | { readonly status: 'not_required' }
164
+ /**
165
+ * The requirement is not known yet: no successful `requestInfoUpdate()` since
166
+ * the instance was created or reset, or the last one failed. Nothing was
167
+ * presented.
168
+ */
169
+ | { readonly status: 'info_update_required' }
170
+ /** A consent form is already on screen. Nothing was presented. */
171
+ | { readonly status: 'busy' }
172
+ /**
173
+ * The form could not be presented or failed while open. The consent state is
174
+ * whatever the provider reports it still holds, and never a new grant.
175
+ */
176
+ | { readonly status: 'failed'; readonly error: AdsError }
177
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason };
178
+
179
+ /**
180
+ * The optional consent capability (FR-101).
181
+ *
182
+ * Grouped under its own namespace rather than flattened onto the instance so
183
+ * that FR-102's optionality stays legible: an app that never touches
184
+ * `ads.consent` never starts a flow. Five loose methods on the instance would
185
+ * invite the assumption that they are part of the normal path.
186
+ */
187
+ export interface ConsentApi {
188
+ /** Refreshes consent information from the provider. */
189
+ requestInfoUpdate(): Promise<ConsentSnapshot>;
190
+ /** Presents the provider's form if one is required. Renders nothing itself. */
191
+ showFormIfRequired(): Promise<ConsentSnapshot>;
192
+ /**
193
+ * Presents the provider's privacy options form, so a user can change an
194
+ * answer already given (FR-151). Added in 0.4.0.
195
+ *
196
+ * Offer it when `getState().privacyOptionsRequirement` is `required`. Where
197
+ * the entry point lives, and what it says, stay the app's. The outcome feeds
198
+ * the privacy gate by itself, exactly as `showFormIfRequired` does.
199
+ */
200
+ showPrivacyOptionsForm(): Promise<PrivacyOptionsFormResult>;
201
+ getState(): ConsentSnapshot;
202
+ subscribe(listener: (snapshot: ConsentSnapshot) => void): Unsubscribe;
203
+ /** Clears the stored decision. Inert in production (FR-113). */
204
+ reset(): Promise<void>;
205
+ }
206
+
207
+ export interface AdsInstance {
208
+ initialize(privacy: PrivacyOptions): Promise<InitResult>;
209
+ updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
210
+ /**
211
+ * Turns ads on or off. Returns a result rather than `void` (FR-122).
212
+ *
213
+ * 0.1.0 returned nothing, which meant an app re-enabling ads after a lapsed
214
+ * subscription had no way to learn that it also had to re-initialize — and
215
+ * every load and show after that was refused in silence.
216
+ */
217
+ setEnabled(enabled: boolean): EnablementResult;
218
+ /** The same, including the third state the app has not resolved yet. */
219
+ setEnablement(next: Enablement): EnablementResult;
220
+ setAppActive(active: boolean): void;
221
+ readonly consent: ConsentApi;
222
+ getStatus(): InitStatus;
223
+ subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
224
+
225
+ load(placementKey: string): Promise<LoadResult>;
226
+ show(placementKey: string): Promise<ShowResult>;
227
+ getPlacementState(placementKey: string): PlacementState;
228
+ subscribePlacement(key: string, listener: (state: PlacementState) => void): Unsubscribe;
229
+
230
+ subscribeEvents(listener: AdEventListener): Unsubscribe;
231
+
232
+ /** FR-011a — substitution is observable state, not only a log line. */
233
+ getSubstitutedPlacements(): readonly string[];
234
+ /** FR-113 — the same treatment for consent tools dropped in production. */
235
+ getIgnoredConsentTools(): readonly string[];
236
+ /** Internal wiring for the banner host; not part of the documented surface. */
237
+ readonly internals: InstanceInternals;
238
+
239
+ dispose(): void;
240
+ }
241
+
242
+ /** What `AdsBanner` needs. Deliberately not documented as consumer API. */
243
+ export interface InstanceInternals {
244
+ resolveBanner(placementKey: string):
245
+ | {
246
+ readonly ok: true;
247
+ readonly adUnitId: string;
248
+ readonly correlationId: string;
249
+ /** Current at resolution time, so a banner request honors `updatePrivacy`. */
250
+ readonly personalizedAds: boolean;
251
+ }
252
+ | { readonly ok: false; readonly result: LoadResult };
253
+ /**
254
+ * Takes the throttle slot for a banner cycle (FR-140).
255
+ *
256
+ * Called from the banner host's effect, after `resolveBanner` allowed it and
257
+ * immediately before the provider component mounts. Idempotent for one
258
+ * `correlationId`, so React running the effect twice charges one slot.
259
+ *
260
+ * Returns the refusal if the window closed between resolve and commit — rare,
261
+ * but a second banner for the same placement mounting in the same tick would
262
+ * do it.
263
+ *
264
+ * Emits nothing and sets no state: it is called during render, so the host
265
+ * reports the refusal from its effect instead.
266
+ */
267
+ commitBannerRequest(placementKey: string, correlationId: string): ThrottledResult | null;
268
+ /** The configured floor for a placement, so the host can report the cause. */
269
+ throttleFloorFor(placementKey: string): number;
270
+ emit(event: AdEvent): void;
271
+ setPlacementState(key: string, state: PlacementState): void;
272
+ isReady(): boolean;
273
+ nextCorrelationId(): string;
274
+ timerCount(): number;
275
+ }
276
+
277
+ export interface CreateAdsOptions {
278
+ /** Injected in tests; the AdMob adapter is the default in production. */
279
+ readonly engine?: AdsEngine;
280
+ /** Injected so the FR-012 rule is testable without touching a global. */
281
+ readonly isDevBuild?: boolean;
282
+ readonly now?: () => number;
283
+ /**
284
+ * Injected so retry backoff, app-open expiry, and the undecided warning can
285
+ * be driven by a test clock instead of by waiting. Production uses real
286
+ * timers.
287
+ */
288
+ readonly scheduler?: Scheduler;
289
+ }
290
+
291
+ interface PlacementRuntime {
292
+ snapshot: PlacementSnapshot;
293
+ /**
294
+ * The cycle's correlation id, held here rather than read from the snapshot:
295
+ * the reducer resets the snapshot on dismissal, and events emitted around
296
+ * that transition must still carry the id of the cycle they belong to.
297
+ */
298
+ correlationId: string | null;
299
+ /**
300
+ * Whether this cycle already reported a dismissal. Format-independent — the
301
+ * rewarded session guards its own, but interstitials need the same guard and
302
+ * would otherwise report a repeated provider callback twice.
303
+ */
304
+ dismissed: boolean;
305
+ handle: EngineAdHandle | null;
306
+ unsubscribeEngine: Unsubscribe | null;
307
+ inFlightLoad: Promise<LoadResult> | null;
308
+ /**
309
+ * Settles the in-flight load with a given result. Only a consent withdrawal
310
+ * uses it (FR-153): the caller awaiting `load()` is told `blocked` instead of
311
+ * waiting on a request that has been abandoned.
312
+ */
313
+ abortLoad: ((result: LoadResult) => void) | null;
314
+ cancelLoadTimer: Unsubscribe | null;
315
+ cancelShowTimer: Unsubscribe | null;
316
+ rewarded: RewardedSession | null;
317
+ listeners: Set<(state: PlacementState) => void>;
318
+ /** Retries already spent in this load cycle. Reset when a new cycle starts. */
319
+ retryAttempts: number;
320
+ /** Cancels a scheduled retry. Called by disable, undecided, and dispose. */
321
+ cancelRetry: Unsubscribe | null;
322
+ }
323
+
324
+ function detectDevBuild(): boolean {
325
+ return typeof __DEV__ !== 'undefined' ? Boolean(__DEV__) : false;
326
+ }
327
+
328
+ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): AdsInstance {
329
+ const isDevBuild = options.isDevBuild ?? detectDevBuild();
330
+ const now = options.now ?? (() => Date.now());
331
+
332
+ // Throws on a structural fault or the FR-012 fail-closed case. Deliberately
333
+ // before anything else: a misconfigured instance should not exist at all.
334
+ const resolved: ResolvedConfig = validateConfig(config, { isDevBuild });
335
+
336
+ const engine: AdsEngine = options.engine ?? createDefaultEngine();
337
+ const events = createEventEmitter();
338
+ const correlation = createCorrelationIssuer();
339
+ // FR-140. Account safety, not product policy — see core/requestThrottle.ts.
340
+ const requestThrottle = createRequestThrottle(now);
341
+ const timers = createTimerSet(options.scheduler);
342
+ const lock = createFullScreenLock();
343
+ const adReturn = createAdOriginatedTracker();
344
+
345
+ /**
346
+ * An instance told it is `undecided` says so from the moment it exists.
347
+ *
348
+ * Without this it reports `not_started` until `initialize()` runs, which is
349
+ * indistinguishable from a disabled instance at exactly the point an app is
350
+ * deciding whether to reserve the banner's space — so FR-116 would hold only
351
+ * after a call the app has no reason to make yet. Found on device, not in a
352
+ * unit test, because the demo screen defers initialization the way a real
353
+ * entitlement-gated app does.
354
+ *
355
+ * `disabled` deliberately keeps 0.1.0's `not_started` here: that behavior is
356
+ * observable today and BC-004 protects it. The asymmetry is the cost of not
357
+ * changing something that already shipped.
358
+ */
359
+ let init: InitSnapshot =
360
+ resolved.enablement === 'undecided'
361
+ ? initReducer(initialInit, { type: 'UNDECIDE' })
362
+ : initialInit;
363
+ let enablement: Enablement = resolved.enablement;
364
+ let appActive = true;
365
+ let consent: ConsentSnapshot = INITIAL_CONSENT;
366
+ let consentStarted = false;
367
+ /**
368
+ * Consent forms currently on screen. A count rather than a flag because
369
+ * `showFormIfRequired` has never refused a concurrent call and 0.4.0 does not
370
+ * start now; only the privacy options form reads it, to report `busy`.
371
+ */
372
+ let consentFormsOpen = 0;
373
+ let privacyOptionsFormOpen = false;
374
+ /**
375
+ * Whether the provider has been natively initialized, ever, by this instance.
376
+ *
377
+ * Tracked apart from the init status because the status can leave `ready` —
378
+ * a withdrawn consent moves it to `blocked` — while the native SDK stays
379
+ * initialized. Re-granting must resume, not initialize again (FR-022, FR-154).
380
+ */
381
+ let providerInitialized = false;
382
+ let undecidedWarned = false;
383
+ let cancelUndecidedTimer: Unsubscribe | null = null;
384
+ let appliedPrivacy: PrivacyOptions | null = null;
385
+ let currentPrivacy: PrivacyOptions | null = null;
386
+ let inFlightInit: Promise<InitResult> | null = null;
387
+ let disposed = false;
388
+ let warnedSubstitution = false;
389
+
390
+ const statusListeners = new Set<(status: InitStatus) => void>();
391
+ const placements = new Map<string, PlacementRuntime>();
392
+
393
+ for (const key of resolved.placements.keys()) {
394
+ placements.set(key, {
395
+ snapshot: initialPlacement,
396
+ correlationId: null,
397
+ dismissed: false,
398
+ handle: null,
399
+ unsubscribeEngine: null,
400
+ inFlightLoad: null,
401
+ abortLoad: null,
402
+ cancelLoadTimer: null,
403
+ cancelShowTimer: null,
404
+ rewarded: null,
405
+ listeners: new Set(),
406
+ retryAttempts: 0,
407
+ cancelRetry: null,
408
+ });
409
+ }
410
+
411
+ const consentListeners = new Set<(snapshot: ConsentSnapshot) => void>();
412
+
413
+ function setConsent(next: ConsentSnapshot): void {
414
+ consent = next;
415
+ for (const listener of Array.from(consentListeners)) {
416
+ try {
417
+ listener(next);
418
+ } catch {
419
+ // Consumer code; isolated exactly as event listeners are (FR-056).
420
+ }
421
+ }
422
+ }
423
+
424
+ /**
425
+ * The consent flow's contribution to the gate, or `null` when the app never
426
+ * opted in.
427
+ *
428
+ * `null` is what makes FR-102 structural rather than conditional: with no
429
+ * flow started, the gate sees only the app's own input and behaves exactly as
430
+ * 0.1.0 did. There is no code path to skip and therefore none to forget.
431
+ */
432
+ function consentGateInput(): ConsentGateInput {
433
+ return consentStarted ? toGateInput(consent) : null;
434
+ }
435
+
436
+ /**
437
+ * Warns once if the app leaves `undecided` unresolved (FR-119).
438
+ *
439
+ * Once, not repeatedly: a warning that fires every ten seconds becomes noise
440
+ * that gets filtered, which defeats the diagnostic. And it only warns — the
441
+ * package never resolves the state itself, because doing so would mean
442
+ * guessing at exactly the business decision FR-120 forbids it from knowing.
443
+ */
444
+ function armUndecidedWarning(): void {
445
+ if (enablement !== 'undecided' || undecidedWarned || cancelUndecidedTimer) {
446
+ return;
447
+ }
448
+ const armedAt = now();
449
+ cancelUndecidedTimer = timers.after(resolved.timeouts.undecidedWarning, () => {
450
+ cancelUndecidedTimer = null;
451
+ if (disposed || enablement !== 'undecided' || undecidedWarned) {
452
+ return;
453
+ }
454
+ undecidedWarned = true;
455
+ events.emit({
456
+ type: 'config_warning',
457
+ timestamp: now(),
458
+ warning: 'enablement_undecided_timeout',
459
+ placementKeys: [],
460
+ elapsedMs: now() - armedAt,
461
+ });
462
+ });
463
+ }
464
+
465
+ function disarmUndecidedWarning(): void {
466
+ cancelUndecidedTimer?.();
467
+ cancelUndecidedTimer = null;
468
+ }
469
+
470
+ /** Cancels every pending retry, everywhere (FR-137). */
471
+ function cancelAllRetries(): void {
472
+ for (const runtime of placements.values()) {
473
+ runtime.cancelRetry?.();
474
+ runtime.cancelRetry = null;
475
+ }
476
+ }
477
+
478
+ function setInit(next: InitSnapshot): void {
479
+ if (next === init || next.status === init.status) {
480
+ init = next;
481
+ return;
482
+ }
483
+ init = next;
484
+ events.emit({
485
+ type: 'initialization',
486
+ timestamp: now(),
487
+ status: next.status,
488
+ retryable: next.error?.retryable,
489
+ });
490
+ for (const listener of Array.from(statusListeners)) {
491
+ try {
492
+ listener(next.status);
493
+ } catch {
494
+ // A status listener is consumer code; its failure is isolated the
495
+ // same way an event listener's is (FR-056).
496
+ }
497
+ }
498
+ }
499
+
500
+ function setPlacement(key: string, next: PlacementSnapshot): void {
501
+ const runtime = placements.get(key);
502
+ if (!runtime || runtime.snapshot.state === next.state) {
503
+ if (runtime) {
504
+ runtime.snapshot = next;
505
+ }
506
+ return;
507
+ }
508
+ runtime.snapshot = next;
509
+ for (const listener of Array.from(runtime.listeners)) {
510
+ try {
511
+ listener(next.state);
512
+ } catch {
513
+ // Isolated, as above.
514
+ }
515
+ }
516
+ }
517
+
518
+ function dispatchPlacement(key: string, event: Parameters<typeof placementReducer>[1]): void {
519
+ const runtime = placements.get(key);
520
+ const definition = resolved.placements.get(key);
521
+ if (!runtime || !definition) {
522
+ return;
523
+ }
524
+ setPlacement(key, placementReducer(runtime.snapshot, event, definition.format));
525
+ }
526
+
527
+ /**
528
+ * The single gate evaluation (FR-140).
529
+ *
530
+ * Assembles the three inputs and defers the precedence entirely to
531
+ * `evaluateGates`. Nothing in this file compares gate state itself — the
532
+ * whole point of FR-140 is that the order is a decision made once, not four
533
+ * `if` chains that happen to agree today.
534
+ *
535
+ * Note the order differs from 0.1.0's, which checked `enabled` before
536
+ * availability. That was never observable in isolation, only when two gates
537
+ * were closed at once, and the case it changes — disabled on an unsupported
538
+ * platform, previously decided by whichever transition arrived last — is the
539
+ * incidental ordering FR-140a exists to fix.
540
+ */
541
+ function currentGate(): GateDecision {
542
+ return evaluateGates({
543
+ available: engine.isAvailable()
544
+ ? { ok: true }
545
+ : { ok: false, reason: 'unsupported_platform' },
546
+ enablement,
547
+ appGate: (() => {
548
+ const gate = evaluateGate(currentPrivacy);
549
+ return gate.allowed
550
+ ? ({ permitted: true } as const)
551
+ : ({ permitted: false, reason: gate.reason } as const);
552
+ })(),
553
+ consentGate: consentGateInput(),
554
+ });
555
+ }
556
+
557
+ /** Maps a closed gate onto the refusal shape load, show, and the banner share. */
558
+ function refusalFor(
559
+ decision: Extract<GateDecision, { allowed: false }>,
560
+ ): Extract<LoadResult, { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }> {
561
+ switch (decision.kind) {
562
+ case 'unavailable':
563
+ return { status: 'unavailable', reason: decision.reason };
564
+ case 'disabled':
565
+ return { status: 'disabled' };
566
+ case 'undecided':
567
+ return { status: 'undecided' };
568
+ default:
569
+ return { status: 'blocked', reason: decision.reason };
570
+ }
571
+ }
572
+
573
+ /** Every refusal that applies before a placement is even looked up. */
574
+ function gateCheck():
575
+ | { readonly ok: true }
576
+ | {
577
+ readonly ok: false;
578
+ readonly result: Extract<
579
+ LoadResult,
580
+ { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
581
+ >;
582
+ } {
583
+ const decision = currentGate();
584
+ if (decision.allowed) {
585
+ return { ok: true };
586
+ }
587
+ return { ok: false, result: refusalFor(decision) };
588
+ }
589
+
590
+ function emitRefusal(
591
+ operation: 'initialize' | 'load' | 'show',
592
+ result: { readonly status: string; readonly reason?: string },
593
+ placementKey?: string,
594
+ format?: AdFormat,
595
+ ): void {
596
+ if (result.status === 'disabled' || result.status === 'undecided') {
597
+ // `undecided` reuses the `disabled` event rather than minting a new
598
+ // one: for an observer of the event stream the fact is the same — no
599
+ // request was made — and the distinction that matters is carried by
600
+ // the typed result and the init status, where the app reads it.
601
+ events.emit({ type: 'disabled', timestamp: now(), operation, placementKey, format });
602
+ } else if (result.status === 'unavailable' || result.status === 'blocked') {
603
+ events.emit({
604
+ type: 'unavailable',
605
+ timestamp: now(),
606
+ operation,
607
+ placementKey,
608
+ format,
609
+ reason: (result.reason ?? 'unsupported_platform') as UnavailableReason | PrivacyBlockReason,
610
+ });
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Consumes a throttle slot for a placement, or reports the refusal (FR-140).
616
+ *
617
+ * Returns `null` when the request may proceed, so the two call sites read as a
618
+ * guard clause. On refusal it moves the placement to `throttled` and emits,
619
+ * which is what makes the refusal observable rather than a silent absence of
620
+ * ads — the app would otherwise have no way to tell this from no-fill.
621
+ *
622
+ * A placement still holding a `ready` ad keeps that state: the refusal is
623
+ * about asking again, and overwriting it would strand an ad that was already
624
+ * paid for and is still showable.
625
+ */
626
+ function reportThrottled(
627
+ key: string,
628
+ format: AdFormat,
629
+ retryAfterMs: number,
630
+ minRequestIntervalMs: number,
631
+ ): LoadResult {
632
+ const runtime = placements.get(key);
633
+ if (runtime && runtime.snapshot.state !== 'ready') {
634
+ setPlacement(key, { ...runtime.snapshot, state: 'throttled' });
635
+ }
636
+ events.emit({
637
+ type: 'request_throttled',
638
+ timestamp: now(),
639
+ placementKey: key,
640
+ format,
641
+ retryAfterMs,
642
+ minRequestIntervalMs,
643
+ });
644
+ return { status: 'throttled', retryAfterMs };
645
+ }
646
+
647
+ /** Consumes a slot, or reports the refusal. `null` means proceed. */
648
+ function checkThrottle(key: string, format: AdFormat, token?: string): LoadResult | null {
649
+ const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(key) ?? 0;
650
+ const decision = requestThrottle.tryAcquire(key, minRequestIntervalMs, token);
651
+ return decision.allowed
652
+ ? null
653
+ : reportThrottled(key, format, decision.retryAfterMs, minRequestIntervalMs);
654
+ }
655
+
656
+ function warnSubstitutionOnce(): void {
657
+ if (warnedSubstitution || resolved.catalog.substitutedKeys.length === 0) {
658
+ return;
659
+ }
660
+ warnedSubstitution = true;
661
+ events.emit({
662
+ type: 'config_warning',
663
+ timestamp: now(),
664
+ warning: 'test_ad_unit_substituted',
665
+ placementKeys: resolved.catalog.substitutedKeys,
666
+ });
667
+ }
668
+
669
+ function releasePlacementResources(key: string): void {
670
+ const runtime = placements.get(key);
671
+ if (!runtime) {
672
+ return;
673
+ }
674
+ runtime.cancelLoadTimer?.();
675
+ runtime.cancelLoadTimer = null;
676
+ runtime.cancelShowTimer?.();
677
+ runtime.cancelShowTimer = null;
678
+ runtime.unsubscribeEngine?.();
679
+ runtime.unsubscribeEngine = null;
680
+ if (runtime.handle) {
681
+ engine.dispose(runtime.handle);
682
+ runtime.handle = null;
683
+ }
684
+ runtime.inFlightLoad = null;
685
+ runtime.abortLoad = null;
686
+ runtime.rewarded = null;
687
+ runtime.cancelRetry?.();
688
+ runtime.cancelRetry = null;
689
+ }
690
+
691
+ /**
692
+ * Stops every request a gate that just closed forbids (FR-125, FR-153,
693
+ * FR-159) — ads turned off, turned undecided, or consent withdrawn.
694
+ *
695
+ * In-flight loads are abandoned and their resources released, a loaded ad
696
+ * that may no longer be shown is released with them, and an ad already on
697
+ * screen is left alone — the provider offers no safe way to close it.
698
+ *
699
+ * An abandoned load *settles*: its caller gets `refusal`, the same answer a
700
+ * load started now would get. Until 0.4.0 disabling released the resources
701
+ * but never resolved the caller's promise, so an app awaiting `load()` across
702
+ * a purchase of ad removal waited forever — and a handle the provider handed
703
+ * back after the disable was adopted and went on loading.
704
+ */
705
+ function haltPlacements(refusal: LoadRefusal): void {
706
+ for (const [key, runtime] of placements) {
707
+ if (runtime.snapshot.state === 'showing') {
708
+ continue;
709
+ }
710
+ const abort = runtime.abortLoad;
711
+ releasePlacementResources(key);
712
+ dispatchPlacement(key, { type: 'RESET' });
713
+ if (abort) {
714
+ emitRefusal('load', refusal, key, resolved.placements.get(key)?.format);
715
+ abort(refusal);
716
+ }
717
+ }
718
+ }
719
+
720
+ /** App-open expires on its own, shorter window (FR-130). */
721
+ function validityFor(format: AdFormat): number {
722
+ return format === 'app_open' ? resolved.timeouts.appOpenValidity : resolved.timeouts.adValidity;
723
+ }
724
+
725
+ async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
726
+ const enginePrivacy = toEnginePrivacy(privacy, resolved.testDeviceIds);
727
+ try {
728
+ // The adapter applies privacy configuration before initializing the
729
+ // provider; that ordering is the whole reason FR-017 is enforceable.
730
+ await engine.initialize(enginePrivacy);
731
+ // Recorded before the status moves, and whether or not it moves: a gate
732
+ // that closed while this was in flight leaves the status `blocked`, but
733
+ // the provider is initialized all the same and must not be again.
734
+ providerInitialized = true;
735
+ appliedPrivacy = privacy;
736
+ setInit(initReducer(init, { type: 'SUCCEED' }));
737
+ warnSubstitutionOnce();
738
+ return { status: 'ready' };
739
+ } catch (error) {
740
+ const normalized = normalizeProviderError(error, { operation: 'initialize' });
741
+ setInit(initReducer(init, { type: 'FAIL', error: normalized }));
742
+ return { status: 'failed', error: normalized };
743
+ } finally {
744
+ inFlightInit = null;
745
+ }
746
+ }
747
+
748
+ function handleEngineEvent(
749
+ key: string,
750
+ definition: PlacementDefinition,
751
+ event: EngineAdEvent,
752
+ ): void {
753
+ // After dispose nothing is honored: a late provider callback must not
754
+ // update a consumer that has gone away (FR-035, SC-008).
755
+ if (disposed) {
756
+ return;
757
+ }
758
+ const runtime = placements.get(key);
759
+ if (!runtime) {
760
+ return;
761
+ }
762
+ const correlationId = runtime.correlationId ?? undefined;
763
+ const base = { timestamp: now(), placementKey: key, format: definition.format, correlationId };
764
+
765
+ switch (event.type) {
766
+ case 'opened': {
767
+ // The provider confirming an ad is on screen is what arms
768
+ // ad-originated return detection (FR-128). Taken from here rather
769
+ // than from the app's background report, which arrives too late to
770
+ // be trusted — see core/adOriginatedReturn.ts.
771
+ if (isFullScreenFormat(definition.format)) {
772
+ adReturn.noteFullScreenShown(now());
773
+ }
774
+ events.emit({ ...base, type: 'display_started' });
775
+ // R5: the provider exposes no impression callback for full-screen
776
+ // formats, so this one is derived from the ad opening. Labelled
777
+ // honestly rather than passed off as the provider's own.
778
+ events.emit({ ...base, type: 'impression', source: 'derived' as ImpressionSource });
779
+ break;
780
+ }
781
+ case 'clicked':
782
+ // Recorded so that a background transition moments later is
783
+ // recognized as "the ad opened a browser" — the only signal the
784
+ // provider gives for that case (FR-128).
785
+ adReturn.noteClick(now());
786
+ events.emit({ ...base, type: 'click' });
787
+ break;
788
+ case 'rewarded': {
789
+ const first = runtime.rewarded?.confirmReward(event.reward) ?? null;
790
+ if (first) {
791
+ events.emit({ ...base, type: 'reward_earned', reward: first });
792
+ }
793
+ break;
794
+ }
795
+ case 'dismissed': {
796
+ const rewardedFirst = runtime.rewarded ? runtime.rewarded.dismiss() : true;
797
+ if (runtime.dismissed || !rewardedFirst) {
798
+ break;
799
+ }
800
+ runtime.dismissed = true;
801
+ lock.release(key);
802
+ runtime.cancelShowTimer?.();
803
+ runtime.cancelShowTimer = null;
804
+ events.emit({
805
+ ...base,
806
+ type: 'dismissed',
807
+ rewardEarned: runtime.rewarded ? runtime.rewarded.rewardEarned : undefined,
808
+ });
809
+ dispatchPlacement(key, { type: 'DISMISSED' });
810
+ // Deliberately NOT released here. FR-049 requires the reward and
811
+ // the dismissal to be handled in either order, and a provider that
812
+ // confirms the reward *after* closing the ad would otherwise have
813
+ // its confirmation dropped by a torn-down listener — silently
814
+ // costing the user a reward they earned.
815
+ //
816
+ // This is a retained resource, not a leak: at most one handle and
817
+ // one listener per placement, released deterministically by the
818
+ // next load of that placement or by dispose().
819
+ break;
820
+ }
821
+ case 'failed': {
822
+ const normalized = normalizeProviderError(event.cause, {
823
+ operation: 'show',
824
+ placementKey: key,
825
+ format: definition.format,
826
+ });
827
+ lock.release(key);
828
+ events.emit({ ...base, type: 'display_failed', error: normalized });
829
+ dispatchPlacement(key, { type: 'SHOW_FAILED', error: normalized });
830
+ releasePlacementResources(key);
831
+ break;
832
+ }
833
+ default:
834
+ break;
835
+ }
836
+ }
837
+
838
+ function loadFullScreen(
839
+ key: string,
840
+ definition: PlacementDefinition,
841
+ adUnitId: string,
842
+ correlationId: string,
843
+ ): Promise<LoadResult> {
844
+ const runtime = placements.get(key)!;
845
+ const format = definition.format as FullScreenFormat;
846
+ const startedAt = now();
847
+
848
+ return new Promise<LoadResult>(resolve => {
849
+ let settled = false;
850
+
851
+ const finish = (result: LoadResult) => {
852
+ if (settled) {
853
+ return;
854
+ }
855
+ settled = true;
856
+ runtime.cancelLoadTimer?.();
857
+ runtime.cancelLoadTimer = null;
858
+ runtime.inFlightLoad = null;
859
+ if (runtime.abortLoad === finish) {
860
+ runtime.abortLoad = null;
861
+ }
862
+ resolve(result);
863
+ };
864
+ // `settled` is what makes this safe: a handle the provider hands back
865
+ // after an abort is disposed by the `.then` below instead of adopted.
866
+ runtime.abortLoad = finish;
867
+
868
+ runtime.cancelLoadTimer = timers.after(resolved.timeouts.load, () => {
869
+ const error = new AdsError('load_timeout', {
870
+ operation: 'load',
871
+ placementKey: key,
872
+ format,
873
+ message: `load timed out after ${resolved.timeouts.load}ms`,
874
+ });
875
+ events.emit({
876
+ type: 'load_failed',
877
+ timestamp: now(),
878
+ placementKey: key,
879
+ format,
880
+ correlationId,
881
+ error,
882
+ });
883
+ dispatchPlacement(key, { type: 'LOAD_FAILED', error });
884
+ releasePlacementResources(key);
885
+ finish({ status: 'timeout' });
886
+ });
887
+
888
+ engine
889
+ .createFullScreen(format, adUnitId, toEngineRequest(currentPrivacy))
890
+ .then(handle => {
891
+ if (disposed || settled) {
892
+ engine.dispose(handle);
893
+ return;
894
+ }
895
+ runtime.handle = handle;
896
+ runtime.unsubscribeEngine = engine.subscribe(handle, event => {
897
+ if (event.type === 'loaded') {
898
+ dispatchPlacement(key, { type: 'LOADED', atMs: now() });
899
+ events.emit({
900
+ type: 'loaded',
901
+ timestamp: now(),
902
+ placementKey: key,
903
+ format,
904
+ correlationId,
905
+ durationMs: now() - startedAt,
906
+ });
907
+ finish({ status: 'loaded', correlationId });
908
+ return;
909
+ }
910
+ if (event.type === 'failed') {
911
+ const normalized = normalizeProviderError(event.cause, {
912
+ operation: 'load',
913
+ placementKey: key,
914
+ format,
915
+ });
916
+ events.emit({
917
+ type: 'load_failed',
918
+ timestamp: now(),
919
+ placementKey: key,
920
+ format,
921
+ correlationId,
922
+ error: normalized,
923
+ });
924
+ dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
925
+ releasePlacementResources(key);
926
+ finish(
927
+ normalized.code === 'no_fill'
928
+ ? { status: 'no_fill' }
929
+ : { status: 'failed', error: normalized },
930
+ );
931
+ return;
932
+ }
933
+ handleEngineEvent(key, definition, event);
934
+ });
935
+ })
936
+ .catch(error => {
937
+ const normalized = normalizeProviderError(error, {
938
+ operation: 'load',
939
+ placementKey: key,
940
+ format,
941
+ });
942
+ events.emit({
943
+ type: 'load_failed',
944
+ timestamp: now(),
945
+ placementKey: key,
946
+ format,
947
+ correlationId,
948
+ error: normalized,
949
+ });
950
+ dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
951
+ finish(
952
+ normalized.code === 'no_fill'
953
+ ? { status: 'no_fill' }
954
+ : { status: 'failed', error: normalized },
955
+ );
956
+ });
957
+ });
958
+ }
959
+
960
+ /**
961
+ * One load, plus however many bounded retries the policy allows (FR-134).
962
+ *
963
+ * Off unless configured, so an upgraded 0.1.0 consumer goes through here once
964
+ * and issues exactly the requests it always did. When on, only errors already
965
+ * flagged `retryable` are retried — a configuration or consent failure is a
966
+ * state another attempt cannot change, and retrying it would spend a billable
967
+ * request on a certainty (FR-135).
968
+ *
969
+ * Every attempt is emitted before it is waited on, carrying the cycle's
970
+ * correlation id, so the event stream can be reconciled against a bill
971
+ * without inferring anything (FR-138).
972
+ */
973
+ async function loadWithRetry(
974
+ key: string,
975
+ definition: PlacementDefinition,
976
+ adUnitId: string,
977
+ correlationId: string,
978
+ ): Promise<LoadResult> {
979
+ const runtime = placements.get(key)!;
980
+ const policy = resolved.retryByKey.get(key) ?? null;
981
+
982
+ for (;;) {
983
+ const result = await loadFullScreen(key, definition, adUnitId, correlationId);
984
+ if (result.status === 'loaded' || disposed) {
985
+ return result;
986
+ }
987
+
988
+ const error =
989
+ result.status === 'failed'
990
+ ? result.error
991
+ : result.status === 'no_fill'
992
+ ? new AdsError('no_fill', { operation: 'load', placementKey: key })
993
+ : result.status === 'timeout'
994
+ ? new AdsError('load_timeout', { operation: 'load', placementKey: key })
995
+ : null;
996
+
997
+ // A refusal — disabled, undecided, blocked, unavailable — is not a
998
+ // failure to retry. Nothing was requested, and nothing about asking
999
+ // again would change that.
1000
+ if (!error || !error.retryable) {
1001
+ return result;
1002
+ }
1003
+ // The whole gate, not just enablement: a retry is a new provider request
1004
+ // and needs the same permission the first one had (FR-017).
1005
+ if (!canRetry(policy, runtime.retryAttempts) || !currentGate().allowed) {
1006
+ return result;
1007
+ }
1008
+
1009
+ runtime.retryAttempts += 1;
1010
+ const delayMs = delayForAttempt(policy!, runtime.retryAttempts);
1011
+ events.emit({
1012
+ type: 'retry_scheduled',
1013
+ timestamp: now(),
1014
+ placementKey: key,
1015
+ format: definition.format,
1016
+ correlationId,
1017
+ attempt: runtime.retryAttempts,
1018
+ delayMs,
1019
+ error,
1020
+ });
1021
+
1022
+ const waited = await new Promise<boolean>(resolve => {
1023
+ runtime.cancelRetry = timers.after(delayMs, () => {
1024
+ runtime.cancelRetry = null;
1025
+ resolve(true);
1026
+ });
1027
+ const cancel = runtime.cancelRetry;
1028
+ // Cancellation replaces the handle, so a disable or dispose that
1029
+ // clears it resolves this wait as "do not continue" rather than
1030
+ // leaving a promise nobody will settle.
1031
+ runtime.cancelRetry = () => {
1032
+ cancel?.();
1033
+ runtime.cancelRetry = null;
1034
+ resolve(false);
1035
+ };
1036
+ });
1037
+
1038
+ if (!waited || disposed || !currentGate().allowed) {
1039
+ return result;
1040
+ }
1041
+
1042
+ dispatchPlacement(key, { type: 'LOAD' });
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * Applies a consent outcome to the gate without a second initialization.
1048
+ *
1049
+ * This is FR-105's "the app does not wire it by hand" in one function. When
1050
+ * consent opens the gate and the instance was blocked, it initializes — and
1051
+ * because `initialize` is idempotent and shares one in-flight operation, the
1052
+ * total native initialization count across a whole consent flow stays at one
1053
+ * (FR-022, SC-102). When consent closes the gate, requests stop and every
1054
+ * pending retry is cancelled with them (FR-137).
1055
+ *
1056
+ * Since 0.4.0 a closed gate also moves a `ready` instance to `blocked` and
1057
+ * abandons in-flight loads (FR-153). 0.3.0 left the status at `ready`, which
1058
+ * was harmless while the only consent outcome that could close the gate ran
1059
+ * before anything was loaded. The privacy options form changes that: a user
1060
+ * can withdraw consent with a banner mounted, and a banner host keyed on
1061
+ * `ready` would otherwise keep requesting. Re-granting resumes through
1062
+ * `initialize`, which knows the provider is already up and does not touch it
1063
+ * again (FR-154).
1064
+ */
1065
+ async function reevaluateAfterConsent(): Promise<void> {
1066
+ if (disposed || !currentPrivacy) {
1067
+ return;
1068
+ }
1069
+ const decision = currentGate();
1070
+ if (!decision.allowed) {
1071
+ cancelAllRetries();
1072
+ if (decision.kind === 'blocked') {
1073
+ haltPlacements({ status: 'blocked', reason: decision.reason });
1074
+ setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
1075
+ }
1076
+ return;
1077
+ }
1078
+ if (init.status === 'ready' || init.status === 'initializing') {
1079
+ return;
1080
+ }
1081
+ await instance.initialize(currentPrivacy);
1082
+ }
1083
+
1084
+ /**
1085
+ * Answers `initialize` without a native call when the provider is already up
1086
+ * (FR-022, FR-154), or returns `null` to let a real initialization proceed.
1087
+ *
1088
+ * The status leaves `ready` when a gate closes, but nothing is torn down, so
1089
+ * the gate reopening resumes. Init-time-only flags that changed in the
1090
+ * meantime fall through to a real initialization: it is the only way they can
1091
+ * take effect, and it is what 0.3.0 did.
1092
+ *
1093
+ * Only reached once the caller has passed the whole gate, consent flow
1094
+ * included, so an app that re-runs `initialize` on a consent change cannot
1095
+ * reopen what the user just closed.
1096
+ */
1097
+ function resumeInitialized(privacy: PrivacyOptions): InitResult | null {
1098
+ if (
1099
+ !providerInitialized ||
1100
+ !canStart(init.status) ||
1101
+ requiresReinitialization(appliedPrivacy, privacy)
1102
+ ) {
1103
+ return null;
1104
+ }
1105
+ setInit(initReducer(init, { type: 'RESUME' }));
1106
+ return { status: 'ready' };
1107
+ }
1108
+
1109
+ const instance: AdsInstance = {
1110
+ async initialize(privacy) {
1111
+ if (disposed) {
1112
+ return { status: 'unavailable', reason: 'provider_missing' };
1113
+ }
1114
+ currentPrivacy = privacy;
1115
+
1116
+ // Availability first, then enablement, then consent — the one order,
1117
+ // evaluated in the one place (FR-140).
1118
+ const decision = currentGate();
1119
+ if (!decision.allowed && decision.kind === 'unavailable') {
1120
+ setInit(initReducer(init, { type: 'UNAVAILABLE', reason: decision.reason }));
1121
+ const result = { status: 'unavailable', reason: decision.reason } as const;
1122
+ emitRefusal('initialize', result);
1123
+ return result;
1124
+ }
1125
+ if (!decision.allowed && decision.kind === 'disabled') {
1126
+ setInit(initReducer(init, { type: 'DISABLE' }));
1127
+ const result = { status: 'disabled' } as const;
1128
+ emitRefusal('initialize', result);
1129
+ return result;
1130
+ }
1131
+ if (!decision.allowed && decision.kind === 'undecided') {
1132
+ // FR-115. Nothing provider-facing happens, exactly as when
1133
+ // disabled — but the status the app observes says `undecided`, so
1134
+ // a banner host can hold its space instead of reclaiming it.
1135
+ setInit(initReducer(init, { type: 'UNDECIDE' }));
1136
+ armUndecidedWarning();
1137
+ const result = { status: 'undecided' } as const;
1138
+ emitRefusal('initialize', result);
1139
+ return result;
1140
+ }
1141
+
1142
+ if (!decision.allowed && decision.kind === 'blocked') {
1143
+ // FR-017, FR-158. Nothing provider-facing happens here at all — not a
1144
+ // configuration call, not an initialization. The engine is untouched.
1145
+ //
1146
+ // `decision` is the full gate: the app's own input *and*, once the app
1147
+ // has opted into it, the package's consent flow. Until 0.4.0 this
1148
+ // checked the app's input alone, so a user who declined the consent
1149
+ // form still got a natively initialized SDK — `load` refused, but the
1150
+ // provider was up, which is exactly what the provider's own guidance
1151
+ // says not to do before `canRequestAds`. The consent flow opening the
1152
+ // gate later initializes it, through `reevaluateAfterConsent`.
1153
+ setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
1154
+ const result = { status: 'blocked', reason: decision.reason } as const;
1155
+ emitRefusal('initialize', result);
1156
+ return result;
1157
+ }
1158
+
1159
+ if (init.status === 'ready') {
1160
+ return { status: 'ready' };
1161
+ }
1162
+
1163
+ const resumed = resumeInitialized(privacy);
1164
+ if (resumed) {
1165
+ return resumed;
1166
+ }
1167
+
1168
+ // FR-022, SC-005. Racing callers share one operation, so the provider
1169
+ // initializes exactly once however many arrive.
1170
+ if (inFlightInit) {
1171
+ return inFlightInit;
1172
+ }
1173
+ if (!canStart(init.status)) {
1174
+ return { status: 'ready' };
1175
+ }
1176
+
1177
+ setInit(initReducer(init, { type: 'START' }));
1178
+ inFlightInit = doInitialize(privacy);
1179
+ return inFlightInit;
1180
+ },
1181
+
1182
+ async updatePrivacy(privacy) {
1183
+ if (disposed) {
1184
+ // The same answer initialize and load give a disposed instance.
1185
+ return { status: 'unavailable', reason: 'provider_missing' };
1186
+ }
1187
+ if (enablement === 'undecided') {
1188
+ return { status: 'undecided' };
1189
+ }
1190
+ if (enablement === 'disabled') {
1191
+ return { status: 'disabled' };
1192
+ }
1193
+ if (!engine.isAvailable()) {
1194
+ return { status: 'unavailable', reason: 'unsupported_platform' };
1195
+ }
1196
+
1197
+ // FR-020: a flag the provider only honors at initialization cannot be
1198
+ // applied now. Say so, rather than accepting it and running half-old.
1199
+ if (requiresReinitialization(appliedPrivacy, privacy)) {
1200
+ return { status: 'requires_reinitialization' };
1201
+ }
1202
+
1203
+ currentPrivacy = privacy;
1204
+ const gate = evaluateGate(privacy);
1205
+ if (!gate.allowed) {
1206
+ // FR-017, FR-137, FR-153: the same teardown as a consent outcome that
1207
+ // closes the gate. Without it a retry already waiting on its backoff
1208
+ // would still reach the provider after the app withdrew consent.
1209
+ cancelAllRetries();
1210
+ haltPlacements({ status: 'blocked', reason: gate.reason });
1211
+ setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
1212
+ return { status: 'blocked', reason: gate.reason };
1213
+ }
1214
+
1215
+ // FR-019: resolving consent after a block must not produce a second
1216
+ // native initialization.
1217
+ if (init.status === 'ready') {
1218
+ return { status: 'applied' };
1219
+ }
1220
+ // Exhaustive on purpose (ADS-04): the old ternary mapped only `ready` and
1221
+ // `blocked`, and every other outcome — `failed` included — fell through to
1222
+ // `applied`. A new InitResult member now fails to compile here.
1223
+ const result = await instance.initialize(privacy);
1224
+ switch (result.status) {
1225
+ case 'ready':
1226
+ return { status: 'applied' };
1227
+ case 'blocked':
1228
+ case 'disabled':
1229
+ case 'undecided':
1230
+ case 'unavailable':
1231
+ case 'failed':
1232
+ return result;
1233
+ default: {
1234
+ const unreachable: never = result;
1235
+ return unreachable;
1236
+ }
1237
+ }
1238
+ },
1239
+
1240
+ setEnabled(next) {
1241
+ return instance.setEnablement(next ? 'enabled' : 'disabled');
1242
+ },
1243
+
1244
+ setEnablement(next) {
1245
+ if (disposed) {
1246
+ return { status: 'unavailable', reason: 'provider_missing' };
1247
+ }
1248
+ if (enablement === next) {
1249
+ return { status: 'unchanged' };
1250
+ }
1251
+
1252
+ const previous = enablement;
1253
+ enablement = next;
1254
+
1255
+ if (next === 'disabled' || next === 'undecided') {
1256
+ // FR-037 and FR-125: new work is prevented and in-flight resources
1257
+ // released deterministically, and moving to `undecided` from
1258
+ // `enabled` follows the same rules as disabling. An ad already on
1259
+ // screen is left alone — the provider offers no safe way to close
1260
+ // it, and yanking it would be worse than letting it finish.
1261
+ cancelAllRetries();
1262
+ haltPlacements(next === 'disabled' ? { status: 'disabled' } : { status: 'undecided' });
1263
+ setInit(initReducer(init, { type: next === 'disabled' ? 'DISABLE' : 'UNDECIDE' }));
1264
+ if (next === 'undecided') {
1265
+ armUndecidedWarning();
1266
+ return { status: 'undecided' };
1267
+ }
1268
+ disarmUndecidedWarning();
1269
+ return { status: 'disabled' };
1270
+ }
1271
+
1272
+ // Turning ads on. This is the path 0.1.0 got wrong: it dropped the
1273
+ // status to `not_started` and returned nothing, so an app whose user
1274
+ // lost their entitlement turned ads back on and then had every load
1275
+ // silently refused (FR-121).
1276
+ disarmUndecidedWarning();
1277
+ const resumable = canResume(init.resumeTo);
1278
+ setInit(initReducer(init, { type: 'ENABLE' }));
1279
+
1280
+ if (init.status === 'unavailable') {
1281
+ return { status: 'unavailable', reason: init.unavailableReason ?? 'unsupported_platform' };
1282
+ }
1283
+ if (resumable && init.status === 'ready') {
1284
+ // The provider was never un-initialized, so there is genuinely
1285
+ // nothing to redo — and FR-022 forbids initializing it twice.
1286
+ return { status: 'resumed', initStatus: init.status };
1287
+ }
1288
+ if (previous === 'undecided' && init.status === 'ready') {
1289
+ return { status: 'resumed', initStatus: init.status };
1290
+ }
1291
+ // Said out loud rather than left for the app to discover from a
1292
+ // refused load. The package will not initialize implicitly: that would
1293
+ // hide an asynchronous, billable operation behind a setter the caller
1294
+ // may not be awaiting.
1295
+ return { status: 'requires_initialization' };
1296
+ },
1297
+
1298
+ setAppActive(active) {
1299
+ if (disposed) {
1300
+ return;
1301
+ }
1302
+ appActive = active;
1303
+ if (!active) {
1304
+ // Arming happens when the provider reports an ad opened, not here:
1305
+ // this event can be delivered late enough that the ad is already
1306
+ // gone. All this adds is the browser case, where a background
1307
+ // closely follows a click (FR-128).
1308
+ adReturn.noteBackground(now());
1309
+ return;
1310
+ }
1311
+ adReturn.noteForeground(now());
1312
+ // FR-034: on return to the foreground, re-evaluate what was loaded.
1313
+ // An ad that went stale while the app was away must stop reporting
1314
+ // `ready` before the app offers a button that would fail on tap.
1315
+ const current = now();
1316
+ for (const [key, runtime] of placements) {
1317
+ const definition = resolved.placements.get(key);
1318
+ if (
1319
+ runtime.snapshot.state === 'ready' &&
1320
+ runtime.snapshot.loadedAtMs !== null &&
1321
+ definition !== undefined &&
1322
+ isStale(runtime.snapshot.loadedAtMs, current, validityFor(definition.format))
1323
+ ) {
1324
+ releasePlacementResources(key);
1325
+ dispatchPlacement(key, { type: 'EXPIRE' });
1326
+ }
1327
+ }
1328
+ },
1329
+
1330
+ getStatus() {
1331
+ return init.status;
1332
+ },
1333
+
1334
+ subscribeStatus(listener) {
1335
+ statusListeners.add(listener);
1336
+ let active = true;
1337
+ return () => {
1338
+ if (!active) {
1339
+ return;
1340
+ }
1341
+ active = false;
1342
+ statusListeners.delete(listener);
1343
+ };
1344
+ },
1345
+
1346
+ async load(placementKey) {
1347
+ if (disposed) {
1348
+ return { status: 'unavailable', reason: 'provider_missing' };
1349
+ }
1350
+
1351
+ const definition = resolved.placements.get(placementKey);
1352
+ if (!definition) {
1353
+ return {
1354
+ status: 'failed',
1355
+ error: new AdsError('placement_not_found', {
1356
+ operation: 'load',
1357
+ placementKey,
1358
+ message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
1359
+ }),
1360
+ };
1361
+ }
1362
+
1363
+ const gate = gateCheck();
1364
+ if (!gate.ok) {
1365
+ emitRefusal('load', gate.result, placementKey, definition.format);
1366
+ return gate.result;
1367
+ }
1368
+
1369
+ if (!canRequest(init.status)) {
1370
+ return {
1371
+ status: 'failed',
1372
+ error: new AdsError('not_ready', {
1373
+ operation: 'load',
1374
+ placementKey,
1375
+ format: definition.format,
1376
+ message: 'initialize() must complete before loading',
1377
+ }),
1378
+ };
1379
+ }
1380
+
1381
+ if (definition.format === 'banner') {
1382
+ return {
1383
+ status: 'failed',
1384
+ error: new AdsError('format_mismatch', {
1385
+ operation: 'load',
1386
+ placementKey,
1387
+ format: definition.format,
1388
+ message: `"${placementKey}" is a banner; banners render through <AdsBanner> rather than load()`,
1389
+ }),
1390
+ };
1391
+ }
1392
+
1393
+ const runtime = placements.get(placementKey)!;
1394
+
1395
+ // FR-029, SC-002's sibling guarantee: a repeat load while one is in
1396
+ // flight joins it instead of creating a second provider request.
1397
+ if (runtime.inFlightLoad) {
1398
+ return runtime.inFlightLoad;
1399
+ }
1400
+
1401
+ if (runtime.snapshot.state === 'showing') {
1402
+ return {
1403
+ status: 'failed',
1404
+ error: new AdsError('busy', { operation: 'load', placementKey }),
1405
+ };
1406
+ }
1407
+
1408
+ if (!canLoad(runtime.snapshot.state)) {
1409
+ return {
1410
+ status: 'failed',
1411
+ error: new AdsError('not_ready', { operation: 'load', placementKey }),
1412
+ };
1413
+ }
1414
+
1415
+ // FR-140. Checked after the in-flight join so a caller that merely
1416
+ // arrived twice for one cycle is answered by the join, not charged a
1417
+ // throttle slot; after the busy and not-ready guards so a load that was
1418
+ // never going to request anything does not spend one either; and before
1419
+ // the correlation id and `load_started`, because a refused request is
1420
+ // not a cycle and must not look like one in the event stream.
1421
+ const throttled = checkThrottle(placementKey, definition.format);
1422
+ if (throttled) {
1423
+ return throttled;
1424
+ }
1425
+
1426
+ releasePlacementResources(placementKey);
1427
+
1428
+ const correlationId = correlation.next();
1429
+ runtime.correlationId = correlationId;
1430
+ runtime.dismissed = false;
1431
+ dispatchPlacement(placementKey, { type: 'LOAD' });
1432
+ setPlacement(placementKey, { ...runtime.snapshot, correlationId });
1433
+ events.emit({
1434
+ type: 'load_started',
1435
+ timestamp: now(),
1436
+ placementKey,
1437
+ format: definition.format,
1438
+ correlationId,
1439
+ });
1440
+
1441
+ const adUnitId = resolved.catalog.byKey.get(placementKey)!;
1442
+ runtime.retryAttempts = 0;
1443
+ const promise = loadWithRetry(placementKey, definition, adUnitId, correlationId);
1444
+ runtime.inFlightLoad = promise;
1445
+ return promise;
1446
+ },
1447
+
1448
+ async show(placementKey) {
1449
+ if (disposed) {
1450
+ return { status: 'unavailable', reason: 'provider_missing' };
1451
+ }
1452
+
1453
+ const definition = resolved.placements.get(placementKey);
1454
+ if (!definition) {
1455
+ return {
1456
+ status: 'failed',
1457
+ error: new AdsError('placement_not_found', {
1458
+ operation: 'show',
1459
+ placementKey,
1460
+ message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
1461
+ }),
1462
+ };
1463
+ }
1464
+
1465
+ if (!isFullScreenFormat(definition.format)) {
1466
+ return {
1467
+ status: 'failed',
1468
+ error: new AdsError('format_mismatch', {
1469
+ operation: 'show',
1470
+ placementKey,
1471
+ format: definition.format,
1472
+ message: `"${placementKey}" is a banner; banners are rendered, not shown`,
1473
+ }),
1474
+ };
1475
+ }
1476
+
1477
+ const gate = gateCheck();
1478
+ if (!gate.ok) {
1479
+ emitRefusal('show', gate.result, placementKey, definition.format);
1480
+ return gate.result;
1481
+ }
1482
+
1483
+ // FR-028: never open an ad while the app is not in front of the user.
1484
+ if (!appActive) {
1485
+ return { status: 'app_inactive' };
1486
+ }
1487
+
1488
+ // FR-128. Only app-open is refused: the user just came back from an ad
1489
+ // or from a browser an ad opened, and app-open is the format that
1490
+ // would fire on exactly that return. An interstitial at the same
1491
+ // moment is the app's own deliberate choice, made somewhere the
1492
+ // package cannot see, so refusing it would be policy rather than
1493
+ // capability.
1494
+ if (definition.format === 'app_open' && adReturn.isRefusing(now())) {
1495
+ return { status: 'ad_originated_return' };
1496
+ }
1497
+
1498
+ const runtime = placements.get(placementKey)!;
1499
+
1500
+ if (runtime.snapshot.state === 'consumed') {
1501
+ return { status: 'consumed' };
1502
+ }
1503
+
1504
+ // FR-034: a stale ad is not shown and is not reported ready.
1505
+ if (
1506
+ runtime.snapshot.state === 'ready' &&
1507
+ runtime.snapshot.loadedAtMs !== null &&
1508
+ isStale(runtime.snapshot.loadedAtMs, now(), validityFor(definition.format))
1509
+ ) {
1510
+ releasePlacementResources(placementKey);
1511
+ dispatchPlacement(placementKey, { type: 'EXPIRE' });
1512
+ return { status: 'not_ready' };
1513
+ }
1514
+
1515
+ if (runtime.snapshot.state !== 'ready' || !runtime.handle) {
1516
+ return { status: 'not_ready' };
1517
+ }
1518
+
1519
+ // FR-030. Acquired before anything else can open a screen, and never
1520
+ // queued: a refused show is refused now, not deferred to a moment the
1521
+ // app never chose.
1522
+ if (!lock.acquire(placementKey)) {
1523
+ return { status: 'busy' };
1524
+ }
1525
+
1526
+ const correlationId = runtime.correlationId ?? correlation.next();
1527
+ runtime.rewarded = definition.format === 'rewarded' ? createRewardedSession() : null;
1528
+ dispatchPlacement(placementKey, { type: 'SHOW' });
1529
+
1530
+ let settled = false;
1531
+ const showTimeout = new Promise<ShowResult>(resolve => {
1532
+ runtime.cancelShowTimer = timers.after(resolved.timeouts.show, () => {
1533
+ if (settled) {
1534
+ return;
1535
+ }
1536
+ settled = true;
1537
+ const error = new AdsError('show_timeout', {
1538
+ operation: 'show',
1539
+ placementKey,
1540
+ format: definition.format,
1541
+ message: `show timed out after ${resolved.timeouts.show}ms`,
1542
+ });
1543
+ lock.release(placementKey);
1544
+ events.emit({
1545
+ type: 'display_failed',
1546
+ timestamp: now(),
1547
+ placementKey,
1548
+ format: definition.format,
1549
+ correlationId,
1550
+ error,
1551
+ });
1552
+ dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
1553
+ releasePlacementResources(placementKey);
1554
+ resolve({ status: 'timeout' });
1555
+ });
1556
+ });
1557
+
1558
+ const attempt = engine
1559
+ .show(runtime.handle)
1560
+ .then<ShowResult>(() => {
1561
+ settled = true;
1562
+ runtime.cancelShowTimer?.();
1563
+ runtime.cancelShowTimer = null;
1564
+ return { status: 'shown', correlationId };
1565
+ })
1566
+ .catch<ShowResult>(error => {
1567
+ settled = true;
1568
+ runtime.cancelShowTimer?.();
1569
+ runtime.cancelShowTimer = null;
1570
+ const normalized = normalizeProviderError(error, {
1571
+ operation: 'show',
1572
+ placementKey,
1573
+ format: definition.format,
1574
+ });
1575
+ lock.release(placementKey);
1576
+ events.emit({
1577
+ type: 'display_failed',
1578
+ timestamp: now(),
1579
+ placementKey,
1580
+ format: definition.format,
1581
+ correlationId,
1582
+ error: normalized,
1583
+ });
1584
+ dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error: normalized });
1585
+ releasePlacementResources(placementKey);
1586
+ return { status: 'failed', error: normalized };
1587
+ });
1588
+
1589
+ return Promise.race([attempt, showTimeout]);
1590
+ },
1591
+
1592
+ getPlacementState(placementKey) {
1593
+ if (!resolved.placements.has(placementKey)) {
1594
+ return 'unavailable';
1595
+ }
1596
+ if (!engine.isAvailable() || enablement === 'disabled') {
1597
+ return 'unavailable';
1598
+ }
1599
+ // Reported distinctly from `unavailable` so the app can choose to hold
1600
+ // the banner's space rather than reclaim it. That choice is the entire
1601
+ // reason the third state is worth having (FR-116).
1602
+ if (enablement === 'undecided') {
1603
+ return 'undecided';
1604
+ }
1605
+ return placements.get(placementKey)?.snapshot.state ?? 'idle';
1606
+ },
1607
+
1608
+ subscribePlacement(key, listener) {
1609
+ const runtime = placements.get(key);
1610
+ if (!runtime) {
1611
+ return () => undefined;
1612
+ }
1613
+ runtime.listeners.add(listener);
1614
+ let active = true;
1615
+ return () => {
1616
+ if (!active) {
1617
+ return;
1618
+ }
1619
+ active = false;
1620
+ runtime.listeners.delete(listener);
1621
+ };
1622
+ },
1623
+
1624
+ subscribeEvents(listener) {
1625
+ return events.subscribe(listener);
1626
+ },
1627
+
1628
+ getSubstitutedPlacements() {
1629
+ return resolved.catalog.substitutedKeys;
1630
+ },
1631
+
1632
+ getIgnoredConsentTools() {
1633
+ return resolved.ignoredConsentTools;
1634
+ },
1635
+
1636
+ consent: {
1637
+ async requestInfoUpdate() {
1638
+ if (disposed) {
1639
+ return consent;
1640
+ }
1641
+ // Marks the flow as in use. Until this is called the gate sees no
1642
+ // consent input at all, which is how an app that supplies its own
1643
+ // gate keeps 0.1.0 behavior without opting out of anything
1644
+ // (FR-102).
1645
+ consentStarted = true;
1646
+ if (resolved.consentTesting?.resetOnStart) {
1647
+ try {
1648
+ engine.consent.reset();
1649
+ } catch {
1650
+ // A reset that fails must not abort the update; the flow
1651
+ // continues against whatever state is stored.
1652
+ }
1653
+ }
1654
+ const next = await runConsentStep(
1655
+ () =>
1656
+ engine.consent.requestInfoUpdate(
1657
+ buildConsentOptions(
1658
+ resolved.consentTesting,
1659
+ currentPrivacy?.underAgeOfConsent,
1660
+ resolved.testDeviceIds,
1661
+ ),
1662
+ ),
1663
+ 'consent information update failed',
1664
+ );
1665
+ if (disposed) {
1666
+ return consent;
1667
+ }
1668
+ setConsent(next);
1669
+ await reevaluateAfterConsent();
1670
+ return next;
1671
+ },
1672
+
1673
+ async showFormIfRequired() {
1674
+ if (disposed) {
1675
+ return consent;
1676
+ }
1677
+ consentStarted = true;
1678
+ if (!needsForm(consent) || privacyOptionsFormOpen) {
1679
+ // Nothing to present. Not an error: a region that does not
1680
+ // require consent is the normal case, and showing a form there
1681
+ // is exactly what FR-107 forbids the package from deciding.
1682
+ // Nor is a privacy options form already on screen — one
1683
+ // provider form at a time.
1684
+ return consent;
1685
+ }
1686
+ consentFormsOpen += 1;
1687
+ let next: ConsentSnapshot;
1688
+ try {
1689
+ next = await runConsentStep(
1690
+ () => engine.consent.showFormIfRequired(),
1691
+ 'consent form failed',
1692
+ );
1693
+ } finally {
1694
+ consentFormsOpen -= 1;
1695
+ }
1696
+ if (disposed) {
1697
+ return consent;
1698
+ }
1699
+ setConsent(next);
1700
+ await reevaluateAfterConsent();
1701
+ return next;
1702
+ },
1703
+
1704
+ async showPrivacyOptionsForm() {
1705
+ // The refusals, most immovable first — the same reading order as
1706
+ // the request gates, so the answer is the most useful one rather
1707
+ // than the first one checked.
1708
+ if (disposed) {
1709
+ return { status: 'unavailable', reason: 'provider_missing' };
1710
+ }
1711
+ if (!engine.isAvailable()) {
1712
+ return { status: 'unavailable', reason: 'unsupported_platform' };
1713
+ }
1714
+ if (privacyOptionsFormOpen || consentFormsOpen > 0) {
1715
+ return { status: 'busy' };
1716
+ }
1717
+ if (consent.privacyOptionsRequirement === 'unknown') {
1718
+ return { status: 'info_update_required' };
1719
+ }
1720
+ if (consent.privacyOptionsRequirement !== 'required') {
1721
+ // The provider's verdict, not a region guess of ours (FR-150).
1722
+ return { status: 'not_required' };
1723
+ }
1724
+
1725
+ privacyOptionsFormOpen = true;
1726
+ let next: ConsentSnapshot;
1727
+ let failure: AdsError | null = null;
1728
+ try {
1729
+ next = normalizeConsent(await engine.consent.showPrivacyOptionsForm());
1730
+ } catch (cause) {
1731
+ failure = toConsentError(cause, 'privacy options form failed');
1732
+ // The form did not complete, so the stored decision is the
1733
+ // provider's to report — ask it rather than assume. Collapsing to
1734
+ // `error` the way a failed first-time form does would revoke a
1735
+ // consent the user never changed: that form runs while the gate is
1736
+ // already closed, this one usually while it is open. If the
1737
+ // provider cannot answer either, the gate closes; a failure is
1738
+ // never consent (FR-106). Nothing is asked of a disposed instance.
1739
+ next = disposed
1740
+ ? consent
1741
+ : await runConsentStep(
1742
+ () => engine.consent.getInfo(),
1743
+ 'consent information could not be read after the privacy options form failed',
1744
+ );
1745
+ } finally {
1746
+ privacyOptionsFormOpen = false;
1747
+ }
1748
+ // dispose() during the form: the form is the provider's and stays
1749
+ // until the user closes it, but its outcome reaches nobody (FR-157).
1750
+ if (disposed) {
1751
+ return { status: 'unavailable', reason: 'provider_missing' };
1752
+ }
1753
+ setConsent(next);
1754
+ await reevaluateAfterConsent();
1755
+ return failure ? { status: 'failed', error: failure } : { status: 'shown', consent: next };
1756
+ },
1757
+
1758
+ getState() {
1759
+ return consent;
1760
+ },
1761
+
1762
+ subscribe(listener) {
1763
+ consentListeners.add(listener);
1764
+ let active = true;
1765
+ return () => {
1766
+ if (!active) {
1767
+ return;
1768
+ }
1769
+ active = false;
1770
+ consentListeners.delete(listener);
1771
+ };
1772
+ },
1773
+
1774
+ async reset() {
1775
+ if (disposed) {
1776
+ return;
1777
+ }
1778
+ // Config validation already stripped the testing tools in
1779
+ // production, so this is inert there without a check here (FR-113).
1780
+ if (!resolved.consentTesting) {
1781
+ return;
1782
+ }
1783
+ try {
1784
+ engine.consent.reset();
1785
+ } catch {
1786
+ // Nothing to report: the stored state is the provider's, and a
1787
+ // failed reset leaves it as it was.
1788
+ }
1789
+ setConsent(INITIAL_CONSENT);
1790
+ },
1791
+ },
1792
+
1793
+ internals: {
1794
+ resolveBanner(placementKey) {
1795
+ const definition = resolved.placements.get(placementKey);
1796
+ if (!definition) {
1797
+ return {
1798
+ ok: false,
1799
+ result: {
1800
+ status: 'failed',
1801
+ error: new AdsError('placement_not_found', {
1802
+ operation: 'load',
1803
+ placementKey,
1804
+ message: `unknown placement "${placementKey}"`,
1805
+ }),
1806
+ },
1807
+ };
1808
+ }
1809
+ if (definition.format !== 'banner') {
1810
+ return {
1811
+ ok: false,
1812
+ result: {
1813
+ status: 'failed',
1814
+ error: new AdsError('format_mismatch', {
1815
+ operation: 'load',
1816
+ placementKey,
1817
+ format: definition.format,
1818
+ message: `"${placementKey}" is not a banner placement`,
1819
+ }),
1820
+ },
1821
+ };
1822
+ }
1823
+ const gate = gateCheck();
1824
+ if (!gate.ok) {
1825
+ return { ok: false, result: gate.result };
1826
+ }
1827
+ if (!canRequest(init.status)) {
1828
+ return { ok: false, result: { status: 'blocked', reason: 'consent_required' } };
1829
+ }
1830
+ // The throttle is deliberately **not** consulted here. It cannot be
1831
+ // decided from configuration alone: a fresh mount must be throttled
1832
+ // (that is the navigation burst the feature exists for) while a width
1833
+ // re-adaptation of an already-mounted banner must not be (that is
1834
+ // rotation, and refusing it would leave the user with no banner for a
1835
+ // minute). Only the host knows which it is, so the host calls
1836
+ // `commitBannerRequest` (FR-140).
1837
+ return {
1838
+ ok: true,
1839
+ adUnitId: resolved.catalog.byKey.get(placementKey)!,
1840
+ correlationId: correlation.next(),
1841
+ personalizedAds: toEngineRequest(currentPrivacy).personalizedAds,
1842
+ };
1843
+ },
1844
+ commitBannerRequest(placementKey, correlationId) {
1845
+ if (!resolved.placements.has(placementKey)) {
1846
+ return null;
1847
+ }
1848
+ const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
1849
+ const decision = requestThrottle.tryAcquire(
1850
+ placementKey,
1851
+ minRequestIntervalMs,
1852
+ correlationId,
1853
+ );
1854
+ // Deliberately silent: the host calls this during render, because the
1855
+ // provider component requests as part of that same render and an effect
1856
+ // would be too late to stop it. Emitting an event or setting placement
1857
+ // state here would mean mutating during another component's render, so
1858
+ // the refusal is only returned — the host reports it from its effect.
1859
+ return decision.allowed
1860
+ ? null
1861
+ : { status: 'throttled', retryAfterMs: decision.retryAfterMs };
1862
+ },
1863
+ throttleFloorFor(placementKey) {
1864
+ return resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
1865
+ },
1866
+ emit(event) {
1867
+ events.emit(event);
1868
+ },
1869
+ setPlacementState(key, state) {
1870
+ const runtime = placements.get(key);
1871
+ if (runtime) {
1872
+ setPlacement(key, { ...runtime.snapshot, state });
1873
+ }
1874
+ },
1875
+ isReady() {
1876
+ return permitsRequests(enablement) && engine.isAvailable() && canRequest(init.status);
1877
+ },
1878
+ nextCorrelationId() {
1879
+ return correlation.next();
1880
+ },
1881
+ timerCount() {
1882
+ return timers.size();
1883
+ },
1884
+ },
1885
+
1886
+ dispose() {
1887
+ // FR-005: idempotent. A second call is a no-op, not an error.
1888
+ if (disposed) {
1889
+ return;
1890
+ }
1891
+ disposed = true;
1892
+ cancelAllRetries();
1893
+ disarmUndecidedWarning();
1894
+ for (const [key, runtime] of placements) {
1895
+ const abort = runtime.abortLoad;
1896
+ releasePlacementResources(key);
1897
+ runtime.listeners.clear();
1898
+ // Settled rather than left hanging, with the answer a `load` after
1899
+ // dispose gets (FR-159). No refusal event goes with it: nothing is
1900
+ // emitted after dispose (FR-035).
1901
+ abort?.({ status: 'unavailable', reason: 'provider_missing' });
1902
+ }
1903
+ timers.clearAll();
1904
+ requestThrottle.clear();
1905
+ lock.reset();
1906
+ adReturn.reset();
1907
+ statusListeners.clear();
1908
+ consentListeners.clear();
1909
+ events.dispose();
1910
+ inFlightInit = null;
1911
+ },
1912
+ };
1913
+
1914
+ return instance;
1915
+ }
1916
+
1917
+ /**
1918
+ * Resolved lazily so importing the package on a platform with no adapter — or
1919
+ * with the bridge absent — costs nothing and crashes nothing (FR-025, SC-004).
1920
+ */
1921
+ function createDefaultEngine(): AdsEngine {
1922
+ // Deferred require keeps the provider off the import graph of every module
1923
+ // that merely mentions `createAds`.
1924
+ const { createAdMobEngine } =
1925
+ require('../engine/admobEngine') as typeof import('../engine/admobEngine');
1926
+ return createAdMobEngine();
1927
+ }
1928
+
1929
+ declare const __DEV__: boolean | undefined;