@chipmobilesdk/rn-ads 0.5.0 → 0.6.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.
@@ -1,208 +1,164 @@
1
- /**
2
- * Timeout and staleness policy (FR-032, FR-034).
3
- *
4
- * Defaults and bounds live here rather than at each call site so there is one
5
- * place to read what the package will wait for. Values outside a band are a
6
- * configuration error rather than being silently clamped: silently clamping a
7
- * one-second load timeout to five would mean the app is told it configured
8
- * something it did not (FR-008).
9
- */
10
-
11
- import { AdsConfigError } from '../diagnostics/errors';
12
- import type { TimeoutConfig, Unsubscribe } from '../config/types';
13
-
14
- /** Comfortably longer than a normal fill on a slow mobile network, still bounded. */
15
- export const DEFAULT_LOAD_TIMEOUT_MS = 30_000;
16
- /** Generous for what is essentially a local presentation. */
17
- export const DEFAULT_SHOW_TIMEOUT_MS = 10_000;
18
- /**
19
- * Google documents that a loaded ad object expires about an hour after load.
20
- * Sixty minutes is the provider's own number; choosing anything else would be
21
- * inventing a constraint (R7).
22
- */
23
- export const DEFAULT_AD_VALIDITY_MS = 3_600_000;
24
-
25
- /**
26
- * App-open's own window, deliberately shorter than the hour the other
27
- * full-screen formats get (FR-130).
28
- *
29
- * Worth being explicit about why: Google expires app-open ads after **four
30
- * hours**, which is *longer* than DEFAULT_AD_VALIDITY_MS, so the provider does
31
- * not constrain this at all. The shorter window is a product decision. An
32
- * app-open ad shows at the most prominent moment the app has, and a creative
33
- * cached for an hour is stale by then; thirty minutes still spares a returning
34
- * user the spinner of a cold load, which is both the worst app-open experience
35
- * and the specific behavior store policy penalizes.
36
- */
37
- export const DEFAULT_APP_OPEN_VALIDITY_MS = 1_800_000;
38
-
39
- /**
40
- * How long `enabled: 'undecided'` may go unresolved before one warning fires.
41
- *
42
- * Ten seconds is several times a realistic worst case for the round trip this
43
- * state exists to cover — asking a store whether the user owns an ad-removal
44
- * entitlement — so an app doing the right thing essentially never trips it,
45
- * while an app that forgot to resolve trips it inside a single QA session,
46
- * which is where it needs to be caught (FR-119).
47
- */
48
- export const DEFAULT_UNDECIDED_WARNING_MS = 10_000;
49
-
50
- export const LOAD_TIMEOUT_BOUNDS = { min: 5_000, max: 120_000 } as const;
51
- export const SHOW_TIMEOUT_BOUNDS = { min: 5_000, max: 30_000 } as const;
52
- export const AD_VALIDITY_BOUNDS = { min: 60_000, max: 3_600_000 } as const;
53
- /** Ceiling is one hour so app-open can never outlive the general window. */
54
- export const APP_OPEN_VALIDITY_BOUNDS = { min: 60_000, max: 3_600_000 } as const;
55
- export const UNDECIDED_WARNING_BOUNDS = { min: 1_000, max: 120_000 } as const;
56
-
57
- /**
58
- * Retry defaults (FR-136).
59
- *
60
- * Two retries rather than more: each one is a billable request, and two covers
61
- * the transient network failure and the momentary no-fill — the cases where
62
- * trying again plausibly helps. A third mostly pays for a failure that was not
63
- * transient. 2s then 4s keeps both inside a window the user still associates
64
- * with the action that triggered the load.
65
- */
66
- export const DEFAULT_RETRY_POLICY = {
67
- maxRetries: 2,
68
- initialDelayMs: 2_000,
69
- maxDelayMs: 30_000,
70
- } as const;
71
-
72
- export const RETRY_BOUNDS = {
73
- maxRetries: { min: 1, max: 5 },
74
- initialDelayMs: { min: 500, max: 30_000 },
75
- maxDelayMs: { min: 1_000, max: 60_000 },
76
- } as const;
77
-
78
- /**
79
- * Ad-originated return detection (FR-128).
80
- *
81
- * A background transition within CLICK_WINDOW of a click is attributed to a
82
- * browser the ad opened. An absence longer than MAX_AWAY is no longer
83
- * attributable to the ad — the user did something else in between. GRACE covers
84
- * the moment right after the return, where an app-open show would otherwise
85
- * fire, without outliving it and refusing a genuinely later show.
86
- */
87
- export const AD_CLICK_WINDOW_MS = 2_000;
88
- export const AD_RETURN_MAX_AWAY_MS = 300_000;
89
- export const AD_RETURN_GRACE_MS = 10_000;
90
-
91
- export interface ResolvedTimeouts {
92
- readonly load: number;
93
- readonly show: number;
94
- readonly adValidity: number;
95
- readonly appOpenValidity: number;
96
- readonly undecidedWarning: number;
97
- }
98
-
99
- export function checkBand(
100
- label: string,
101
- value: number,
102
- bounds: { readonly min: number; readonly max: number },
103
- ): number {
104
- if (!Number.isFinite(value) || value < bounds.min || value > bounds.max) {
105
- throw new AdsConfigError('timeout_out_of_bounds', {
106
- message: `${label} must be between ${bounds.min}ms and ${bounds.max}ms; received ${value}`,
107
- });
108
- }
109
- return value;
110
- }
111
-
112
- export interface TimeoutInput {
113
- readonly timeouts?: TimeoutConfig;
114
- readonly adValidityMs?: number;
115
- readonly appOpenValidityMs?: number;
116
- readonly undecidedWarningMs?: number;
117
- }
118
-
119
- export function resolveTimeouts(input: TimeoutInput): ResolvedTimeouts {
120
- const { timeouts, adValidityMs, appOpenValidityMs, undecidedWarningMs } = input;
121
- return {
122
- load: checkBand('timeouts.load', timeouts?.load ?? DEFAULT_LOAD_TIMEOUT_MS, LOAD_TIMEOUT_BOUNDS),
123
- show: checkBand('timeouts.show', timeouts?.show ?? DEFAULT_SHOW_TIMEOUT_MS, SHOW_TIMEOUT_BOUNDS),
124
- adValidity: checkBand(
125
- 'adValidityMs',
126
- adValidityMs ?? DEFAULT_AD_VALIDITY_MS,
127
- AD_VALIDITY_BOUNDS,
128
- ),
129
- appOpenValidity: checkBand(
130
- 'appOpenValidityMs',
131
- appOpenValidityMs ?? DEFAULT_APP_OPEN_VALIDITY_MS,
132
- APP_OPEN_VALIDITY_BOUNDS,
133
- ),
134
- undecidedWarning: checkBand(
135
- 'undecidedWarningMs',
136
- undecidedWarningMs ?? DEFAULT_UNDECIDED_WARNING_MS,
137
- UNDECIDED_WARNING_BOUNDS,
138
- ),
139
- };
140
- }
141
-
142
- /**
143
- * How timers are actually scheduled.
144
- *
145
- * Injectable so a test can drive retry backoff, app-open expiry, and the
146
- * undecided warning by moving a clock rather than by waiting. The production
147
- * default is `setTimeout`; the fake engine supplies a controllable one. Without
148
- * this, every timing assertion in the suite would either sleep for real or
149
- * depend on the test runner's timer mocking, and NFR-103's "no device, no
150
- * network" would quietly become "no device, no network, but several seconds".
151
- */
152
- export interface Scheduler {
153
- setTimeout(callback: () => void, ms: number): unknown;
154
- clearTimeout(handle: unknown): void;
155
- }
156
-
157
- const realScheduler: Scheduler = {
158
- setTimeout: (callback, ms) => setTimeout(callback, ms),
159
- clearTimeout: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
160
- };
161
-
162
- /**
163
- * Tracks every timer the instance owns so `dispose()` can prove it released
164
- * them. Counting is not incidental — SC-008 asserts the count reaches zero, and
165
- * a leaked timer is otherwise invisible until it fires into a disposed consumer.
166
- */
167
- export interface TimerSet {
168
- after(ms: number, callback: () => void): Unsubscribe;
169
- clearAll(): void;
170
- size(): number;
171
- }
172
-
173
- export function createTimerSet(scheduler: Scheduler = realScheduler): TimerSet {
174
- const handles = new Set<unknown>();
175
-
176
- return {
177
- after(ms, callback) {
178
- const handle = scheduler.setTimeout(() => {
179
- handles.delete(handle);
180
- callback();
181
- }, ms);
182
- handles.add(handle);
183
- let cleared = false;
184
- return () => {
185
- if (cleared) {
186
- return;
187
- }
188
- cleared = true;
189
- scheduler.clearTimeout(handle);
190
- handles.delete(handle);
191
- };
192
- },
193
- clearAll() {
194
- for (const handle of handles) {
195
- scheduler.clearTimeout(handle);
196
- }
197
- handles.clear();
198
- },
199
- size() {
200
- return handles.size;
201
- },
202
- };
203
- }
204
-
205
- /** A loaded ad past its validity window must never be reported ready (FR-034). */
206
- export function isStale(loadedAtMs: number, nowMs: number, adValidityMs: number): boolean {
207
- return nowMs - loadedAtMs >= adValidityMs;
208
- }
1
+ /**
2
+ * Timeout and staleness policy (FR-032, FR-034).
3
+ *
4
+ * Defaults and bounds live here rather than at each call site so there is one
5
+ * place to read what the package will wait for. Values outside a band are a
6
+ * configuration error rather than being silently clamped: silently clamping a
7
+ * one-second load timeout to five would mean the app is told it configured
8
+ * something it did not (FR-008).
9
+ */
10
+
11
+ import { checkRange, type Range } from '@chipmobilesdk/rn-core';
12
+
13
+ import { AdsConfigError, type AdsErrorCode } from '../diagnostics/errors';
14
+ import type { TimeoutConfig } from '../config/types';
15
+
16
+ /** Comfortably longer than a normal fill on a slow mobile network, still bounded. */
17
+ export const DEFAULT_LOAD_TIMEOUT_MS = 30_000;
18
+ /** Generous for what is essentially a local presentation. */
19
+ export const DEFAULT_SHOW_TIMEOUT_MS = 10_000;
20
+ /**
21
+ * Google documents that a loaded ad object expires about an hour after load.
22
+ * Sixty minutes is the provider's own number; choosing anything else would be
23
+ * inventing a constraint (R7).
24
+ */
25
+ export const DEFAULT_AD_VALIDITY_MS = 3_600_000;
26
+
27
+ /**
28
+ * App-open's own window, deliberately shorter than the hour the other
29
+ * full-screen formats get (FR-130).
30
+ *
31
+ * Worth being explicit about why: Google expires app-open ads after **four
32
+ * hours**, which is *longer* than DEFAULT_AD_VALIDITY_MS, so the provider does
33
+ * not constrain this at all. The shorter window is a product decision. An
34
+ * app-open ad shows at the most prominent moment the app has, and a creative
35
+ * cached for an hour is stale by then; thirty minutes still spares a returning
36
+ * user the spinner of a cold load, which is both the worst app-open experience
37
+ * and the specific behavior store policy penalizes.
38
+ */
39
+ export const DEFAULT_APP_OPEN_VALIDITY_MS = 1_800_000;
40
+
41
+ /**
42
+ * How long `enabled: 'undecided'` may go unresolved before one warning fires.
43
+ *
44
+ * Ten seconds is several times a realistic worst case for the round trip this
45
+ * state exists to cover — asking a store whether the user owns an ad-removal
46
+ * entitlement — so an app doing the right thing essentially never trips it,
47
+ * while an app that forgot to resolve trips it inside a single QA session,
48
+ * which is where it needs to be caught (FR-119).
49
+ */
50
+ export const DEFAULT_UNDECIDED_WARNING_MS = 10_000;
51
+
52
+ export const LOAD_TIMEOUT_BOUNDS = { min: 5_000, max: 120_000 } as const;
53
+ export const SHOW_TIMEOUT_BOUNDS = { min: 5_000, max: 30_000 } as const;
54
+ export const AD_VALIDITY_BOUNDS = { min: 60_000, max: 3_600_000 } as const;
55
+ /** Ceiling is one hour so app-open can never outlive the general window. */
56
+ export const APP_OPEN_VALIDITY_BOUNDS = { min: 60_000, max: 3_600_000 } as const;
57
+ export const UNDECIDED_WARNING_BOUNDS = { min: 1_000, max: 120_000 } as const;
58
+
59
+ /**
60
+ * Retry defaults (FR-136).
61
+ *
62
+ * Two retries rather than more: each one is a billable request, and two covers
63
+ * the transient network failure and the momentary no-fill — the cases where
64
+ * trying again plausibly helps. A third mostly pays for a failure that was not
65
+ * transient. 2s then 4s keeps both inside a window the user still associates
66
+ * with the action that triggered the load.
67
+ */
68
+ export const DEFAULT_RETRY_POLICY = {
69
+ maxRetries: 2,
70
+ initialDelayMs: 2_000,
71
+ maxDelayMs: 30_000,
72
+ } as const;
73
+
74
+ export const RETRY_BOUNDS = {
75
+ maxRetries: { min: 1, max: 5 },
76
+ initialDelayMs: { min: 500, max: 30_000 },
77
+ maxDelayMs: { min: 1_000, max: 60_000 },
78
+ } as const;
79
+
80
+ /**
81
+ * Ad-originated return detection (FR-128).
82
+ *
83
+ * A background transition within CLICK_WINDOW of a click is attributed to a
84
+ * browser the ad opened. An absence longer than MAX_AWAY is no longer
85
+ * attributable to the ad — the user did something else in between. GRACE covers
86
+ * the moment right after the return, where an app-open show would otherwise
87
+ * fire, without outliving it and refusing a genuinely later show.
88
+ */
89
+ export const AD_CLICK_WINDOW_MS = 2_000;
90
+ export const AD_RETURN_MAX_AWAY_MS = 300_000;
91
+ export const AD_RETURN_GRACE_MS = 10_000;
92
+
93
+ export interface ResolvedTimeouts {
94
+ readonly load: number;
95
+ readonly show: number;
96
+ readonly adValidity: number;
97
+ readonly appOpenValidity: number;
98
+ readonly undecidedWarning: number;
99
+ }
100
+
101
+ /**
102
+ * `value` when it is inside `bounds`, else an `AdsConfigError` with `code`: each
103
+ * field reports the code its documentation names, so a retry policy out of bounds
104
+ * is `retry_out_of_bounds`, not a timeout's code (CORE-04, ADS-16).
105
+ */
106
+ export function checkBand(
107
+ label: string,
108
+ value: number,
109
+ bounds: Range,
110
+ code: AdsErrorCode = 'timeout_out_of_bounds',
111
+ unit = 'ms',
112
+ ): number {
113
+ return checkRange(label, value, bounds, message => new AdsConfigError(code, { message }), unit);
114
+ }
115
+
116
+ export interface TimeoutInput {
117
+ readonly timeouts?: TimeoutConfig;
118
+ readonly adValidityMs?: number;
119
+ readonly appOpenValidityMs?: number;
120
+ readonly undecidedWarningMs?: number;
121
+ }
122
+
123
+ export function resolveTimeouts(input: TimeoutInput): ResolvedTimeouts {
124
+ const { timeouts, adValidityMs, appOpenValidityMs, undecidedWarningMs } = input;
125
+ return {
126
+ load: checkBand(
127
+ 'timeouts.load',
128
+ timeouts?.load ?? DEFAULT_LOAD_TIMEOUT_MS,
129
+ LOAD_TIMEOUT_BOUNDS,
130
+ ),
131
+ show: checkBand(
132
+ 'timeouts.show',
133
+ timeouts?.show ?? DEFAULT_SHOW_TIMEOUT_MS,
134
+ SHOW_TIMEOUT_BOUNDS,
135
+ ),
136
+ adValidity: checkBand(
137
+ 'adValidityMs',
138
+ adValidityMs ?? DEFAULT_AD_VALIDITY_MS,
139
+ AD_VALIDITY_BOUNDS,
140
+ ),
141
+ appOpenValidity: checkBand(
142
+ 'appOpenValidityMs',
143
+ appOpenValidityMs ?? DEFAULT_APP_OPEN_VALIDITY_MS,
144
+ APP_OPEN_VALIDITY_BOUNDS,
145
+ ),
146
+ undecidedWarning: checkBand(
147
+ 'undecidedWarningMs',
148
+ undecidedWarningMs ?? DEFAULT_UNDECIDED_WARNING_MS,
149
+ UNDECIDED_WARNING_BOUNDS,
150
+ ),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * How timers are scheduled, and the set that tracks them so `dispose()` can prove
156
+ * it released every one (SC-008): rn-core's, injectable so a test drives retry
157
+ * backoff, app-open expiry and the undecided warning by moving a clock.
158
+ */
159
+ export { createTimerSet, type Scheduler, type TimerSet } from '@chipmobilesdk/rn-core';
160
+
161
+ /** A loaded ad past its validity window must never be reported ready (FR-034). */
162
+ export function isStale(loadedAtMs: number, nowMs: number, adValidityMs: number): boolean {
163
+ return nowMs - loadedAtMs >= adValidityMs;
164
+ }
@@ -1,31 +1,31 @@
1
- /**
2
- * Correlation identifiers (FR-052).
3
- *
4
- * One value is minted when a load begins and carried by every event of that
5
- * load/show cycle through its terminal event. It is what lets a consumer
6
- * reconstruct a single ad's life from an event stream alone (SC-009) without the
7
- * package having to store any history of its own.
8
- *
9
- * Opaque and monotonic per instance. Not a UUID, and deliberately not derived
10
- * from anything about the device or the user — it exists to group events, and
11
- * anything with more meaning than that would be a payload the package has
12
- * promised not to carry (FR-021).
13
- */
14
-
15
- export interface CorrelationIssuer {
16
- next(): string;
17
- }
18
-
19
- export function createCorrelationIssuer(prefix = 'ad'): CorrelationIssuer {
20
- let counter = 0;
21
- // Distinguishes cycles from two instances in one process, which otherwise
22
- // both start at 1 and would collide in a merged log.
23
- const instance = Math.random().toString(36).slice(2, 8);
24
-
25
- return {
26
- next() {
27
- counter += 1;
28
- return `${prefix}-${instance}-${counter}`;
29
- },
30
- };
31
- }
1
+ /**
2
+ * Correlation identifiers (FR-052).
3
+ *
4
+ * One value is minted when a load begins and carried by every event of that
5
+ * load/show cycle through its terminal event. It is what lets a consumer
6
+ * reconstruct a single ad's life from an event stream alone (SC-009) without the
7
+ * package having to store any history of its own.
8
+ *
9
+ * Opaque and monotonic per instance. Not a UUID, and deliberately not derived
10
+ * from anything about the device or the user — it exists to group events, and
11
+ * anything with more meaning than that would be a payload the package has
12
+ * promised not to carry (FR-021).
13
+ */
14
+
15
+ export interface CorrelationIssuer {
16
+ next(): string;
17
+ }
18
+
19
+ export function createCorrelationIssuer(prefix = 'ad'): CorrelationIssuer {
20
+ let counter = 0;
21
+ // Distinguishes cycles from two instances in one process, which otherwise
22
+ // both start at 1 and would collide in a merged log.
23
+ const instance = Math.random().toString(36).slice(2, 8);
24
+
25
+ return {
26
+ next() {
27
+ counter += 1;
28
+ return `${prefix}-${instance}-${counter}`;
29
+ },
30
+ };
31
+ }