@chipmobilesdk/rn-ads 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Loading full-screen ads (FR-029, FR-134 – FR-140) and the provider callbacks
3
+ * that follow a load through its whole cycle.
4
+ */
5
+ import {
6
+ isFullScreenFormat,
7
+ type AdFormat,
8
+ type FullScreenFormat,
9
+ type PlacementDefinition,
10
+ } from '../../config/types';
11
+ import { AdsError, normalizeProviderError } from '../../diagnostics/errors';
12
+ import type { EngineAdEvent } from '../../engine/port';
13
+ import { canRequest } from '../initMachine';
14
+ import { canLoad } from '../placementMachine';
15
+ import { toEngineRequest } from '../privacyGate';
16
+ import { canRetry, delayForAttempt } from '../retryPolicy';
17
+ import type { InstanceContext } from './context';
18
+ import { currentGate, gateRefusal } from './gate';
19
+ import type { LoadResult } from './types';
20
+
21
+ export function unknownPlacement(
22
+ context: InstanceContext,
23
+ placementKey: string,
24
+ operation: 'load' | 'show',
25
+ ): LoadResult & { status: 'failed' } {
26
+ return {
27
+ status: 'failed',
28
+ error: new AdsError('placement_not_found', {
29
+ operation,
30
+ placementKey,
31
+ message: `unknown placement "${placementKey}"; configured keys are ${[...context.resolved.placements.keys()].join(', ')}`,
32
+ }),
33
+ };
34
+ }
35
+
36
+ export function createLoader(context: InstanceContext): {
37
+ load(placementKey: string): Promise<LoadResult>;
38
+ } {
39
+ const { state, engine, events, timers, resolved, lock, adReturn, now } = context;
40
+
41
+ /**
42
+ * Takes a throttle slot, or reports the refusal (FR-140). `null` means
43
+ * proceed. A placement still holding a `ready` ad keeps that state: the
44
+ * refusal is about asking again, and overwriting it would strand an ad that
45
+ * was already paid for (ADS-01).
46
+ */
47
+ function checkThrottle(key: string, format: AdFormat): LoadResult | null {
48
+ const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(key) ?? 0;
49
+ const decision = context.requestThrottle.tryAcquire(key, minRequestIntervalMs);
50
+ if (decision.allowed) {
51
+ return null;
52
+ }
53
+ const runtime = context.placements.get(key);
54
+ if (runtime && runtime.snapshot.state !== 'ready') {
55
+ context.setPlacement(key, { ...runtime.snapshot, state: 'throttled' });
56
+ }
57
+ events.emit({
58
+ type: 'request_throttled',
59
+ timestamp: now(),
60
+ placementKey: key,
61
+ format,
62
+ retryAfterMs: decision.retryAfterMs,
63
+ minRequestIntervalMs,
64
+ });
65
+ return { status: 'throttled', retryAfterMs: decision.retryAfterMs };
66
+ }
67
+
68
+ function handleEngineEvent(
69
+ key: string,
70
+ definition: PlacementDefinition,
71
+ event: EngineAdEvent,
72
+ ): void {
73
+ // After dispose nothing is honored (FR-035, SC-008).
74
+ const runtime = context.placements.get(key);
75
+ if (state.disposed || !runtime) {
76
+ return;
77
+ }
78
+ const base = {
79
+ timestamp: now(),
80
+ placementKey: key,
81
+ format: definition.format,
82
+ correlationId: runtime.correlationId ?? undefined,
83
+ };
84
+ switch (event.type) {
85
+ case 'opened':
86
+ // The provider confirming an ad is on screen arms ad-originated return
87
+ // detection (FR-128); the app's own background report arrives too late.
88
+ if (isFullScreenFormat(definition.format)) {
89
+ adReturn.noteFullScreenShown(now());
90
+ }
91
+ events.emit({ ...base, type: 'display_started' });
92
+ // R5: no impression callback for full-screen formats, so this one is
93
+ // derived from the ad opening, and labelled as such.
94
+ events.emit({ ...base, type: 'impression', source: 'derived' });
95
+ return;
96
+ case 'clicked':
97
+ // A background moments later is "the ad opened a browser" (FR-128).
98
+ adReturn.noteClick(now());
99
+ events.emit({ ...base, type: 'click' });
100
+ return;
101
+ case 'rewarded': {
102
+ const first = runtime.rewarded?.confirmReward(event.reward) ?? null;
103
+ if (first) {
104
+ events.emit({ ...base, type: 'reward_earned', reward: first });
105
+ }
106
+ return;
107
+ }
108
+ case 'dismissed': {
109
+ const rewardedFirst = runtime.rewarded ? runtime.rewarded.dismiss() : true;
110
+ if (runtime.dismissed || !rewardedFirst) {
111
+ return;
112
+ }
113
+ runtime.dismissed = true;
114
+ lock.release(key);
115
+ runtime.cancelShowTimer?.();
116
+ runtime.cancelShowTimer = null;
117
+ events.emit({
118
+ ...base,
119
+ type: 'dismissed',
120
+ rewardEarned: runtime.rewarded ? runtime.rewarded.rewardEarned : undefined,
121
+ });
122
+ context.dispatchPlacement(key, { type: 'DISMISSED' });
123
+ // Deliberately NOT released: FR-049 handles the reward and the
124
+ // dismissal in either order, and a provider that confirms the reward
125
+ // after closing would otherwise reach a torn-down listener. At most one
126
+ // handle and one listener per placement, released by the next load or
127
+ // by dispose().
128
+ return;
129
+ }
130
+ case 'failed': {
131
+ const normalized = normalizeProviderError(
132
+ event.cause,
133
+ {
134
+ operation: 'show',
135
+ placementKey: key,
136
+ format: definition.format,
137
+ },
138
+ event.kind,
139
+ );
140
+ lock.release(key);
141
+ events.emit({ ...base, type: 'display_failed', error: normalized });
142
+ context.dispatchPlacement(key, { type: 'SHOW_FAILED', error: normalized });
143
+ context.releasePlacementResources(key);
144
+ return;
145
+ }
146
+ default:
147
+ }
148
+ }
149
+
150
+ function loadFailed(
151
+ key: string,
152
+ format: FullScreenFormat,
153
+ correlationId: string,
154
+ error: AdsError,
155
+ ): LoadResult {
156
+ events.emit({
157
+ type: 'load_failed',
158
+ timestamp: now(),
159
+ placementKey: key,
160
+ format,
161
+ correlationId,
162
+ error,
163
+ });
164
+ context.dispatchPlacement(key, { type: 'LOAD_FAILED', error });
165
+ return error.code === 'no_fill' ? { status: 'no_fill' } : { status: 'failed', error };
166
+ }
167
+
168
+ function loadFullScreen(
169
+ key: string,
170
+ definition: PlacementDefinition & { format: FullScreenFormat },
171
+ adUnitId: string,
172
+ correlationId: string,
173
+ ): Promise<LoadResult> {
174
+ const runtime = context.placements.get(key);
175
+ const { format } = definition;
176
+ const startedAt = now();
177
+ if (!runtime) {
178
+ return Promise.resolve({ status: 'unavailable', reason: 'provider_missing' });
179
+ }
180
+
181
+ return new Promise<LoadResult>(resolve => {
182
+ let settled = false;
183
+ const finish = (result: LoadResult) => {
184
+ if (settled) {
185
+ return;
186
+ }
187
+ settled = true;
188
+ runtime.cancelLoadTimer?.();
189
+ runtime.cancelLoadTimer = null;
190
+ runtime.inFlightLoad = null;
191
+ if (runtime.abortLoad === finish) {
192
+ runtime.abortLoad = null;
193
+ }
194
+ resolve(result);
195
+ };
196
+ // `settled` makes this safe: a handle the provider hands back after an
197
+ // abort is disposed by the `.then` below instead of adopted.
198
+ runtime.abortLoad = finish;
199
+
200
+ runtime.cancelLoadTimer = timers.after(resolved.timeouts.load, () => {
201
+ const error = new AdsError('load_timeout', {
202
+ operation: 'load',
203
+ placementKey: key,
204
+ format,
205
+ message: `load timed out after ${resolved.timeouts.load}ms`,
206
+ });
207
+ loadFailed(key, format, correlationId, error);
208
+ context.releasePlacementResources(key);
209
+ finish({ status: 'timeout' });
210
+ });
211
+
212
+ engine
213
+ .createFullScreen(format, adUnitId, toEngineRequest(state.currentPrivacy))
214
+ .then(handle => {
215
+ if (state.disposed || settled) {
216
+ engine.dispose(handle);
217
+ return;
218
+ }
219
+ runtime.handle = handle;
220
+ runtime.unsubscribeEngine = engine.subscribe(handle, event => {
221
+ if (event.type === 'loaded') {
222
+ context.dispatchPlacement(key, { type: 'LOADED', atMs: now() });
223
+ events.emit({
224
+ type: 'loaded',
225
+ timestamp: now(),
226
+ placementKey: key,
227
+ format,
228
+ correlationId,
229
+ durationMs: now() - startedAt,
230
+ });
231
+ finish({ status: 'loaded', correlationId });
232
+ } else if (event.type === 'failed') {
233
+ const error = normalizeProviderError(
234
+ event.cause,
235
+ {
236
+ operation: 'load',
237
+ placementKey: key,
238
+ format,
239
+ },
240
+ event.kind,
241
+ );
242
+ const result = loadFailed(key, format, correlationId, error);
243
+ context.releasePlacementResources(key);
244
+ finish(result);
245
+ } else {
246
+ handleEngineEvent(key, definition, event);
247
+ }
248
+ });
249
+ })
250
+ .catch(error => {
251
+ const normalized = normalizeProviderError(error, {
252
+ operation: 'load',
253
+ placementKey: key,
254
+ format,
255
+ });
256
+ finish(loadFailed(key, format, correlationId, normalized));
257
+ });
258
+ });
259
+ }
260
+
261
+ /**
262
+ * One load, plus however many bounded retries the policy allows (FR-134).
263
+ *
264
+ * Off unless configured. When on, only errors flagged `retryable` are retried
265
+ * — a configuration or consent failure is a state another attempt cannot
266
+ * change, and retrying it would spend a billable request on a certainty
267
+ * (FR-135). Every attempt is emitted before it is waited on, with the cycle's
268
+ * correlation id, so the stream reconciles against a bill (FR-138).
269
+ */
270
+ async function loadWithRetry(
271
+ key: string,
272
+ definition: PlacementDefinition & { format: FullScreenFormat },
273
+ adUnitId: string,
274
+ correlationId: string,
275
+ ): Promise<LoadResult> {
276
+ const runtime = context.placements.get(key);
277
+ const policy = resolved.retryByKey.get(key) ?? null;
278
+ for (;;) {
279
+ const result = await loadFullScreen(key, definition, adUnitId, correlationId);
280
+ if (result.status === 'loaded' || state.disposed || !runtime) {
281
+ return result;
282
+ }
283
+ const error = retryableErrorOf(result, key);
284
+ // A refusal is not a failure to retry: nothing was requested. And a retry
285
+ // is a new provider request, so it needs the whole gate (FR-017).
286
+ if (
287
+ !error?.retryable ||
288
+ !policy ||
289
+ !canRetry(policy, runtime.retryAttempts) ||
290
+ !currentGate(context).allowed
291
+ ) {
292
+ return result;
293
+ }
294
+ runtime.retryAttempts += 1;
295
+ // A retry is a provider request like any other, so it waits at least as
296
+ // long as the placement's floor requires (FR-140, ADS-07): a 2 s backoff
297
+ // on a placement with a 20 s floor waits 20 s.
298
+ const floor = resolved.minRequestIntervalByKey.get(key) ?? 0;
299
+ const delayMs = Math.max(
300
+ delayForAttempt(policy, runtime.retryAttempts),
301
+ context.requestThrottle.waitFor(key, floor),
302
+ );
303
+ events.emit({
304
+ type: 'retry_scheduled',
305
+ timestamp: now(),
306
+ placementKey: key,
307
+ format: definition.format,
308
+ correlationId,
309
+ attempt: runtime.retryAttempts,
310
+ delayMs,
311
+ error,
312
+ });
313
+ const waited = await new Promise<boolean>(resolve => {
314
+ const cancelTimer = timers.after(delayMs, () => {
315
+ runtime.cancelRetry = null;
316
+ resolve(true);
317
+ });
318
+ // A disable or dispose that cancels resolves this wait as "do not
319
+ // continue" rather than leaving a promise nobody will settle.
320
+ runtime.cancelRetry = () => {
321
+ cancelTimer();
322
+ runtime.cancelRetry = null;
323
+ resolve(false);
324
+ };
325
+ });
326
+ if (!waited || state.disposed || !currentGate(context).allowed) {
327
+ return result;
328
+ }
329
+ // Recorded, so the next load is measured from this request. Refused only
330
+ // if the clock jumped while waiting; then the retry is skipped, not sent.
331
+ if (!context.requestThrottle.tryAcquire(key, floor).allowed) {
332
+ return result;
333
+ }
334
+ context.dispatchPlacement(key, { type: 'LOAD' });
335
+ }
336
+ }
337
+
338
+ return {
339
+ async load(placementKey) {
340
+ if (state.disposed) {
341
+ return { status: 'unavailable', reason: 'provider_missing' };
342
+ }
343
+ const found = context.placement(placementKey);
344
+ if (!found) {
345
+ return unknownPlacement(context, placementKey, 'load');
346
+ }
347
+ const { runtime, definition } = found;
348
+ const refusal = gateRefusal(context);
349
+ if (refusal) {
350
+ context.emitRefusal('load', refusal, placementKey, definition.format);
351
+ return refusal;
352
+ }
353
+ const fail = (
354
+ code: 'not_ready' | 'busy' | 'format_mismatch',
355
+ detail: { format?: AdFormat; message?: string } = {},
356
+ ): LoadResult => ({
357
+ status: 'failed',
358
+ error: new AdsError(code, { operation: 'load', placementKey, ...detail }),
359
+ });
360
+ if (!canRequest(state.init.status)) {
361
+ return fail('not_ready', {
362
+ format: definition.format,
363
+ message: 'initialize() must complete before loading',
364
+ });
365
+ }
366
+ if (!isFullScreenFormat(definition.format)) {
367
+ return fail('format_mismatch', {
368
+ format: definition.format,
369
+ message: `"${placementKey}" is a banner; banners render through <AdsBanner> rather than load()`,
370
+ });
371
+ }
372
+ // FR-029: a repeat load while one is in flight joins it.
373
+ if (runtime.inFlightLoad) {
374
+ return runtime.inFlightLoad;
375
+ }
376
+ if (runtime.snapshot.state === 'showing') {
377
+ return fail('busy');
378
+ }
379
+ if (!canLoad(runtime.snapshot.state)) {
380
+ return fail('not_ready');
381
+ }
382
+ // FR-140. After the in-flight join, so a caller that arrived twice is not
383
+ // charged; after the busy and not-ready guards, so a load that would not
384
+ // request anything spends no slot; and before the correlation id, because
385
+ // a refused request is not a cycle.
386
+ const throttled = checkThrottle(placementKey, definition.format);
387
+ if (throttled) {
388
+ return throttled;
389
+ }
390
+
391
+ context.releasePlacementResources(placementKey);
392
+ const correlationId = context.correlation.next();
393
+ runtime.correlationId = correlationId;
394
+ runtime.dismissed = false;
395
+ context.dispatchPlacement(placementKey, { type: 'LOAD' });
396
+ context.setPlacement(placementKey, { ...runtime.snapshot, correlationId });
397
+ events.emit({
398
+ type: 'load_started',
399
+ timestamp: now(),
400
+ placementKey,
401
+ format: definition.format,
402
+ correlationId,
403
+ });
404
+ const adUnitId = resolved.catalog.byKey.get(placementKey) ?? '';
405
+ runtime.retryAttempts = 0;
406
+ const fullScreen = { ...definition, format: definition.format };
407
+ const promise = loadWithRetry(placementKey, fullScreen, adUnitId, correlationId);
408
+ runtime.inFlightLoad = promise;
409
+ return promise;
410
+ },
411
+ };
412
+ }
413
+
414
+ /** The error a failed attempt carries, for the retry decision; null for a refusal. */
415
+ function retryableErrorOf(result: LoadResult, key: string): AdsError | null {
416
+ switch (result.status) {
417
+ case 'failed':
418
+ return result.error;
419
+ case 'no_fill':
420
+ return new AdsError('no_fill', { operation: 'load', placementKey: key });
421
+ case 'timeout':
422
+ return new AdsError('load_timeout', { operation: 'load', placementKey: key });
423
+ default:
424
+ return null;
425
+ }
426
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Showing full-screen ads (FR-028, FR-030, FR-034, FR-128) and the foreground
3
+ * re-evaluation of what was loaded.
4
+ */
5
+ import { isFullScreenFormat } from '../../config/types';
6
+ import { AdsError, normalizeProviderError } from '../../diagnostics/errors';
7
+ import { canShow } from '../placementMachine';
8
+ import { createRewardedSession } from '../rewardedSession';
9
+ import { isStale } from '../timers';
10
+ import { validityFor, type InstanceContext } from './context';
11
+ import { gateRefusal } from './gate';
12
+ import { unknownPlacement } from './load';
13
+ import type { ShowResult } from './types';
14
+
15
+ export function createShower(context: InstanceContext): {
16
+ show(placementKey: string): Promise<ShowResult>;
17
+ setAppActive(active: boolean): void;
18
+ } {
19
+ const { state, engine, events, timers, resolved, lock, adReturn, now } = context;
20
+
21
+ /** Releases and expires a `ready` ad that outlived its validity (FR-034). */
22
+ function expireIfStale(key: string, at: number): boolean {
23
+ const found = context.placement(key);
24
+ if (!found) {
25
+ return false;
26
+ }
27
+ const { snapshot } = found.runtime;
28
+ if (
29
+ snapshot.state !== 'ready' ||
30
+ snapshot.loadedAtMs === null ||
31
+ !isStale(snapshot.loadedAtMs, at, validityFor(context, found.definition.format))
32
+ ) {
33
+ return false;
34
+ }
35
+ context.releasePlacementResources(key);
36
+ context.dispatchPlacement(key, { type: 'EXPIRE' });
37
+ return true;
38
+ }
39
+
40
+ return {
41
+ async show(placementKey) {
42
+ if (state.disposed) {
43
+ return { status: 'unavailable', reason: 'provider_missing' };
44
+ }
45
+ const found = context.placement(placementKey);
46
+ if (!found) {
47
+ return unknownPlacement(context, placementKey, 'show');
48
+ }
49
+ const { runtime, definition } = found;
50
+ if (!isFullScreenFormat(definition.format)) {
51
+ return {
52
+ status: 'failed',
53
+ error: new AdsError('format_mismatch', {
54
+ operation: 'show',
55
+ placementKey,
56
+ format: definition.format,
57
+ message: `"${placementKey}" is a banner; banners are rendered, not shown`,
58
+ }),
59
+ };
60
+ }
61
+ const refusal = gateRefusal(context);
62
+ if (refusal) {
63
+ context.emitRefusal('show', refusal, placementKey, definition.format);
64
+ return refusal;
65
+ }
66
+ // FR-028: never open an ad while the app is not in front of the user.
67
+ if (!state.appActive) {
68
+ return { status: 'app_inactive' };
69
+ }
70
+ // FR-128. Only app-open is refused: it is the format that would fire on
71
+ // the return from an ad. An interstitial at the same moment is the app's
72
+ // own choice, and refusing it would be policy rather than capability.
73
+ if (definition.format === 'app_open' && adReturn.isRefusing(now())) {
74
+ return { status: 'ad_originated_return' };
75
+ }
76
+ if (runtime.snapshot.state === 'consumed') {
77
+ return { status: 'consumed' };
78
+ }
79
+ // FR-034: a stale ad is not shown and is not reported ready.
80
+ if (expireIfStale(placementKey, now())) {
81
+ return { status: 'not_ready' };
82
+ }
83
+ const handle = runtime.handle;
84
+ if (!canShow(runtime.snapshot.state) || !handle) {
85
+ return { status: 'not_ready' };
86
+ }
87
+ // FR-030. Never queued: a refused show is refused now, not deferred to a
88
+ // moment the app never chose.
89
+ if (!lock.acquire(placementKey)) {
90
+ return { status: 'busy' };
91
+ }
92
+
93
+ const correlationId = runtime.correlationId ?? context.correlation.next();
94
+ runtime.rewarded = definition.format === 'rewarded' ? createRewardedSession() : null;
95
+ context.dispatchPlacement(placementKey, { type: 'SHOW' });
96
+
97
+ const failShow = (error: AdsError): void => {
98
+ lock.release(placementKey);
99
+ events.emit({
100
+ type: 'display_failed',
101
+ timestamp: now(),
102
+ placementKey,
103
+ format: definition.format,
104
+ correlationId,
105
+ error,
106
+ });
107
+ context.dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
108
+ context.releasePlacementResources(placementKey);
109
+ };
110
+
111
+ // ADS-09. Whichever of the provider's answer and the timeout comes first
112
+ // decides the result; the other must not act on it a second time.
113
+ let settled = false;
114
+ const showTimeout = new Promise<ShowResult>(resolve => {
115
+ runtime.cancelShowTimer = timers.after(resolved.timeouts.show, () => {
116
+ runtime.cancelShowTimer = null;
117
+ if (settled) {
118
+ return;
119
+ }
120
+ settled = true;
121
+ // The provider may still open the ad. Its handle and listener stay, so
122
+ // a late open, reward or dismissal is still heard and the reward is not
123
+ // lost (FR-049); they go with the next load or with dispose. The lock is
124
+ // released now and taken back if the ad does open.
125
+ lock.release(placementKey);
126
+ const error = new AdsError('show_timeout', {
127
+ operation: 'show',
128
+ placementKey,
129
+ format: definition.format,
130
+ message: `show timed out after ${resolved.timeouts.show}ms`,
131
+ });
132
+ events.emit({
133
+ type: 'display_failed',
134
+ timestamp: now(),
135
+ placementKey,
136
+ format: definition.format,
137
+ correlationId,
138
+ error,
139
+ });
140
+ context.dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
141
+ resolve({ status: 'timeout' });
142
+ });
143
+ });
144
+
145
+ const attempt = engine
146
+ .show(handle)
147
+ .then<ShowResult>(() => {
148
+ if (settled) {
149
+ // Opened after the timeout was reported. It is on screen now, so it
150
+ // holds the one full-screen lock until it is dismissed (FR-030).
151
+ lock.acquire(placementKey);
152
+ return { status: 'timeout' };
153
+ }
154
+ settled = true;
155
+ runtime.cancelShowTimer?.();
156
+ runtime.cancelShowTimer = null;
157
+ return { status: 'shown', correlationId };
158
+ })
159
+ .catch<ShowResult>(error => {
160
+ if (settled) {
161
+ // Failed after the timeout already said so: nothing new to report,
162
+ // and nothing will show, so its resources can go.
163
+ context.releasePlacementResources(placementKey);
164
+ return { status: 'timeout' };
165
+ }
166
+ settled = true;
167
+ runtime.cancelShowTimer?.();
168
+ runtime.cancelShowTimer = null;
169
+ const normalized = normalizeProviderError(error, {
170
+ operation: 'show',
171
+ placementKey,
172
+ format: definition.format,
173
+ });
174
+ failShow(normalized);
175
+ return { status: 'failed', error: normalized };
176
+ });
177
+
178
+ return Promise.race([attempt, showTimeout]);
179
+ },
180
+
181
+ setAppActive(active) {
182
+ if (state.disposed) {
183
+ return;
184
+ }
185
+ state.appActive = active;
186
+ if (!active) {
187
+ // Arming happens when the provider reports an ad opened; this event can
188
+ // arrive after the ad is gone. All it adds is the browser case, a
189
+ // background closely following a click (FR-128).
190
+ adReturn.noteBackground(now());
191
+ return;
192
+ }
193
+ adReturn.noteForeground(now());
194
+ // FR-034: an ad that went stale while the app was away stops reporting
195
+ // `ready` before the app offers a button that would fail on tap.
196
+ const current = now();
197
+ for (const key of context.placements.keys()) {
198
+ expireIfStale(key, current);
199
+ }
200
+ },
201
+ };
202
+ }