@chipmobilesdk/rn-ads 0.2.0 → 0.3.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,176 @@
1
+ /**
2
+ * Per-placement request throttle (FR-140).
3
+ *
4
+ * This exists to protect the **ad account**, not to shape product behaviour, and
5
+ * that distinction is the whole reason it lives in the package:
6
+ *
7
+ * | Decision | Owner |
8
+ * |---|---|
9
+ * | How often to *show* an interstitial | the app — product policy |
10
+ * | How often the same placement may *request* | the package — account safety |
11
+ *
12
+ * Rapid repeat requests for one placement are read by ad networks as invalid
13
+ * traffic, and the penalty is account-level and not recoverable by shipping a
14
+ * fix. That puts it in the same class as the test-ad substitution the package
15
+ * already enforces: a safety property the library guarantees rather than
16
+ * documents and hopes each app remembers.
17
+ *
18
+ * ## The pattern it is built for
19
+ *
20
+ * A banner mounted inside a view the user navigates between — a day in a
21
+ * calendar, a tab, a row in a list — is unmounted and remounted by React on every
22
+ * navigation. Each remount is a fresh provider request. A user swiping quickly
23
+ * through ten days produces ten requests in a few seconds, and nothing about that
24
+ * is the app author doing something wrong.
25
+ *
26
+ * Full-screen formats do not have this problem: they are loaded by an explicit
27
+ * call, not mounted by a renderer. That asymmetry is why the default differs by
28
+ * format — see `defaultMinRequestIntervalMs`.
29
+ *
30
+ * The better *architectural* answer is to keep the banner outside the subtree that
31
+ * remounts. This throttle is the safety net for when that is not possible, not a
32
+ * licence to ignore the problem; the README says so where an app author will read
33
+ * it.
34
+ */
35
+
36
+ import type { AdFormat } from '../config/types';
37
+
38
+ /**
39
+ * AdMob's own documented floor for banner auto-refresh is 60 seconds, so
40
+ * requesting the same banner placement more often than that is, by the provider's
41
+ * own standard, more often than it considers reasonable. Taking the provider's
42
+ * number rather than inventing one means the default is defensible rather than
43
+ * arbitrary.
44
+ */
45
+ export const DEFAULT_BANNER_MIN_REQUEST_INTERVAL_MS = 60_000;
46
+
47
+ /**
48
+ * Off by default for full-screen formats.
49
+ *
50
+ * `load` → `show` → `load` is the normal, healthy cycle for an interstitial, and
51
+ * an app that sells three things in two minutes legitimately loads three times.
52
+ * Throttling that by default would break correct apps to solve a problem those
53
+ * formats do not have. An app that wants a floor here can set one.
54
+ */
55
+ export const DEFAULT_FULL_SCREEN_MIN_REQUEST_INTERVAL_MS = 0;
56
+
57
+ export const MIN_REQUEST_INTERVAL_BOUNDS = { min: 0, max: 600_000 } as const;
58
+
59
+ /** The format-aware default, applied when a placement configures nothing. */
60
+ export function defaultMinRequestIntervalMs(format: AdFormat): number {
61
+ return format === 'banner'
62
+ ? DEFAULT_BANNER_MIN_REQUEST_INTERVAL_MS
63
+ : DEFAULT_FULL_SCREEN_MIN_REQUEST_INTERVAL_MS;
64
+ }
65
+
66
+ export type ThrottleDecision =
67
+ | { readonly allowed: true }
68
+ /**
69
+ * `retryAfterMs` is reported rather than left for the caller to compute,
70
+ * because the app needs it to decide between collapsing the space and holding
71
+ * it — and a caller recomputing it from a timestamp would drift from the
72
+ * clock this throttle actually used.
73
+ */
74
+ | { readonly allowed: false; readonly retryAfterMs: number };
75
+
76
+ export interface RequestThrottle {
77
+ /**
78
+ * Records a request and reports whether it may proceed.
79
+ *
80
+ * Call immediately before handing a request to the provider.
81
+ *
82
+ * `token` makes the acquisition **idempotent for one request cycle**, and the
83
+ * banner path depends on it. React may run a `useMemo` or an effect more than
84
+ * once for a single committed render — StrictMode does it deliberately — and
85
+ * without a token the second run would consume a second slot and be refused,
86
+ * so a banner would never appear in development. Passing the cycle's
87
+ * correlation id means "this is the same request I already asked about", and
88
+ * the previous decision is returned unchanged.
89
+ *
90
+ * Omit `token` for a genuinely new imperative request, where every call is a
91
+ * distinct intent to ask the network.
92
+ */
93
+ tryAcquire(key: string, minIntervalMs: number, token?: string): ThrottleDecision;
94
+ /**
95
+ * Forgets a placement's last request.
96
+ *
97
+ * Used when a placement's resources are released, so a genuinely new cycle is
98
+ * not charged for the previous one.
99
+ */
100
+ reset(key: string): void;
101
+ clear(): void;
102
+ /** Diagnostics only: when the placement last requested, or undefined. */
103
+ lastRequestAt(key: string): number | undefined;
104
+ }
105
+
106
+ export function createRequestThrottle(now: () => number): RequestThrottle {
107
+ const lastAt = new Map<string, number>();
108
+ /** The token and verdict of the most recent acquisition, for idempotency. */
109
+ const lastToken = new Map<string, { token: string; decision: ThrottleDecision }>();
110
+
111
+ const decide = (key: string, minIntervalMs: number): ThrottleDecision => {
112
+ // Zero is the documented way to turn the throttle off, so it must never
113
+ // consult the clock — an app that sets 0 should behave exactly as it did
114
+ // before this feature existed.
115
+ if (minIntervalMs <= 0) {
116
+ return { allowed: true };
117
+ }
118
+ const previous = lastAt.get(key);
119
+ if (previous === undefined) {
120
+ return { allowed: true };
121
+ }
122
+ const elapsed = now() - previous;
123
+ // A clock that moved backwards — a manual time change, or an NTP
124
+ // correction — must not lock a placement out until the clock catches up.
125
+ // Treating it as "enough time passed" fails open, which is right: the
126
+ // throttle protects against bursts, and a backwards clock is not a burst.
127
+ if (elapsed < 0) {
128
+ return { allowed: true };
129
+ }
130
+ if (elapsed >= minIntervalMs) {
131
+ return { allowed: true };
132
+ }
133
+ return { allowed: false, retryAfterMs: minIntervalMs - elapsed };
134
+ };
135
+
136
+ return {
137
+ tryAcquire(key, minIntervalMs, token) {
138
+ if (token !== undefined) {
139
+ const previous = lastToken.get(key);
140
+ if (previous?.token === token) {
141
+ // Same cycle asking again. Replaying the stored verdict keeps a
142
+ // double-invoked render from charging twice, and keeps a refused
143
+ // cycle refused rather than letting a retry slip through.
144
+ return previous.decision;
145
+ }
146
+ }
147
+
148
+ // Nothing is recorded when the throttle is off for this placement.
149
+ // Recording would be dead work, and it would also mean that turning the
150
+ // throttle on later immediately compared against a stale timestamp.
151
+ if (minIntervalMs <= 0) {
152
+ return { allowed: true };
153
+ }
154
+
155
+ const decision = decide(key, minIntervalMs);
156
+ if (decision.allowed) {
157
+ lastAt.set(key, now());
158
+ }
159
+ if (token !== undefined) {
160
+ lastToken.set(key, { token, decision });
161
+ }
162
+ return decision;
163
+ },
164
+ reset(key) {
165
+ lastAt.delete(key);
166
+ lastToken.delete(key);
167
+ },
168
+ clear() {
169
+ lastAt.clear();
170
+ lastToken.clear();
171
+ },
172
+ lastRequestAt(key) {
173
+ return lastAt.get(key);
174
+ },
175
+ };
176
+ }
@@ -33,6 +33,12 @@ export type AdEventType =
33
33
  // cycle that triggered it. Each attempt is a billable request, so it has to
34
34
  // be reconcilable against a bill from the event stream alone (FR-138).
35
35
  | 'retry_scheduled'
36
+ // Emitted when the per-placement throttle refuses a request (FR-140). Carries
37
+ // no correlation id because no cycle began: nothing was requested, so there is
38
+ // nothing to correlate. Present so a throttled placement is visible in
39
+ // whatever logging the app already has, rather than looking like a silent
40
+ // absence of ads.
41
+ | 'request_throttled'
36
42
  | 'internal_error';
37
43
 
38
44
  interface BaseEvent {
@@ -76,6 +82,13 @@ export type AdEvent = BaseEvent &
76
82
  readonly delayMs: number;
77
83
  readonly error: AdsError;
78
84
  }
85
+ | {
86
+ readonly type: 'request_throttled';
87
+ /** How long until this placement may request again. */
88
+ readonly retryAfterMs: number;
89
+ /** The configured floor that refused it, so the cause is readable. */
90
+ readonly minRequestIntervalMs: number;
91
+ }
79
92
  | {
80
93
  readonly type: 'config_warning';
81
94
  readonly warning: ConfigWarning;
package/src/index.ts CHANGED
@@ -57,6 +57,16 @@ export {
57
57
  UNDECIDED_WARNING_BOUNDS,
58
58
  } from './core/timers';
59
59
 
60
+ // Request throttle — account safety, added in 0.3.0 (FR-140). Exported so an app
61
+ // can read the floor it is bound by, and so the format-aware default is legible
62
+ // rather than something to discover from a `throttled` state in production.
63
+ export {
64
+ DEFAULT_BANNER_MIN_REQUEST_INTERVAL_MS,
65
+ DEFAULT_FULL_SCREEN_MIN_REQUEST_INTERVAL_MS,
66
+ MIN_REQUEST_INTERVAL_BOUNDS,
67
+ defaultMinRequestIntervalMs,
68
+ } from './core/requestThrottle';
69
+
60
70
  // Enablement. `EnablementResult` is what `setEnabled` returns in place of
61
71
  // 0.1.0's `void` — the defect that let a re-enable fail in silence (FR-122).
62
72
  export type { EnablementResult } from './core/enablement';