@chipmobilesdk/rn-ads 0.1.0 → 0.2.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.
- package/CHANGELOG.md +57 -0
- package/README.md +310 -11
- package/package.json +1 -1
- package/skills/sdk-rn-ads/SKILL.md +125 -32
- package/src/config/types.ts +170 -8
- package/src/config/validate.ts +83 -7
- package/src/core/adOriginatedReturn.ts +134 -0
- package/src/core/adsInstance.ts +565 -42
- package/src/core/consentFlow.ts +167 -0
- package/src/core/enablement.ts +76 -0
- package/src/core/gates.ts +91 -0
- package/src/core/initMachine.ts +73 -7
- package/src/core/retryPolicy.ts +127 -0
- package/src/core/timers.ts +109 -10
- package/src/diagnostics/errors.ts +27 -3
- package/src/diagnostics/events.ts +41 -1
- package/src/engine/admobEngine.ts +144 -2
- package/src/engine/port.ts +44 -0
- package/src/engine/testAdUnits.ts +3 -0
- package/src/index.ts +23 -1
- package/src/react/AdsBanner.tsx +23 -1
- package/src/testing/index.ts +155 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent orchestration and normalization (FR-101 – FR-110).
|
|
3
|
+
*
|
|
4
|
+
* This closes the most serious gap in 0.1.0. The package *consumed* a consent
|
|
5
|
+
* decision but nothing *produced* one, and the flow that produces it lives
|
|
6
|
+
* inside the very provider library the package promised an app would never have
|
|
7
|
+
* to import. So any app shipping to a consent-required region had to import the
|
|
8
|
+
* provider directly — breaking the package's central promise at exactly the
|
|
9
|
+
* point where it mattered most — and hand-write the same order-sensitive
|
|
10
|
+
* orchestration every other such app was also writing.
|
|
11
|
+
*
|
|
12
|
+
* What this module does **not** do is as load-bearing as what it does:
|
|
13
|
+
*
|
|
14
|
+
* - It renders no consent UI. The form is the provider's (FR-103).
|
|
15
|
+
* - It decides nothing about jurisdictions. Which regions require consent is
|
|
16
|
+
* the provider's answer and the publisher's console configuration; the
|
|
17
|
+
* package forwards it (FR-107).
|
|
18
|
+
* - It never infers consent. An error, a cancellation, a dismissed form, a
|
|
19
|
+
* network failure — all of them land on `error`, and `error` never carries
|
|
20
|
+
* permission (FR-106). This is the invariant the whole module exists to
|
|
21
|
+
* protect, because the failure it prevents is invisible: a request that
|
|
22
|
+
* should not have happened looks exactly like one that should.
|
|
23
|
+
*
|
|
24
|
+
* Pure apart from the port calls it awaits. No React, no provider type crossing
|
|
25
|
+
* outward.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { ConsentSnapshot, ConsentState, ConsentTestingOptions } from '../config/types';
|
|
29
|
+
import { AdsError } from '../diagnostics/errors';
|
|
30
|
+
import type { ConsentPort, EngineConsentInfo, EngineConsentOptions } from '../engine/port';
|
|
31
|
+
import type { ConsentGateInput } from './gates';
|
|
32
|
+
|
|
33
|
+
export const INITIAL_CONSENT: ConsentSnapshot = {
|
|
34
|
+
state: 'undetermined',
|
|
35
|
+
canRequestAds: false,
|
|
36
|
+
isFormAvailable: false,
|
|
37
|
+
error: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Maps the engine's status onto the published one.
|
|
42
|
+
*
|
|
43
|
+
* `canRequestAds` is taken from the provider rather than derived from the
|
|
44
|
+
* status. The two are not the same question — a region can be `not_required`
|
|
45
|
+
* while the provider still withholds permission — and inventing the second
|
|
46
|
+
* answer from the first is how a package ends up requesting ads it should not.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeConsent(info: EngineConsentInfo): ConsentSnapshot {
|
|
49
|
+
const state: ConsentState =
|
|
50
|
+
info.status === 'obtained'
|
|
51
|
+
? 'obtained'
|
|
52
|
+
: info.status === 'required'
|
|
53
|
+
? 'required'
|
|
54
|
+
: info.status === 'not_required'
|
|
55
|
+
? 'not_required'
|
|
56
|
+
: 'undetermined';
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
state,
|
|
60
|
+
canRequestAds: info.canRequestAds === true,
|
|
61
|
+
isFormAvailable: info.isFormAvailable === true,
|
|
62
|
+
error: null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The snapshot for a failed flow.
|
|
68
|
+
*
|
|
69
|
+
* `canRequestAds` is hard-coded false rather than carried through from anywhere.
|
|
70
|
+
* There is no failure mode in which "the flow broke" should mean "go ahead", and
|
|
71
|
+
* making that structural rather than conditional means no future edit can
|
|
72
|
+
* accidentally reintroduce the inference.
|
|
73
|
+
*/
|
|
74
|
+
export function consentFailure(error: AdsError): ConsentSnapshot {
|
|
75
|
+
return { state: 'error', canRequestAds: false, isFormAvailable: false, error };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function toConsentError(cause: unknown, message: string): AdsError {
|
|
79
|
+
return new AdsError('consent_flow_failed', {
|
|
80
|
+
message,
|
|
81
|
+
operation: 'configure',
|
|
82
|
+
cause,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Projects a snapshot onto the consent gate's second input.
|
|
88
|
+
*
|
|
89
|
+
* Only `obtained` and `not_required` open it. `undetermined` and `required` are
|
|
90
|
+
* both "we do not have permission yet" and are reported as
|
|
91
|
+
* `consent_flow_required`; `error` gets its own reason so an app can tell a
|
|
92
|
+
* user who has not answered from a flow that broke.
|
|
93
|
+
*/
|
|
94
|
+
export function toGateInput(snapshot: ConsentSnapshot): ConsentGateInput {
|
|
95
|
+
if (snapshot.canRequestAds && (snapshot.state === 'obtained' || snapshot.state === 'not_required')) {
|
|
96
|
+
return { permitted: true };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
permitted: false,
|
|
100
|
+
reason: snapshot.state === 'error' ? 'consent_flow_error' : 'consent_flow_required',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Builds the options for a consent information update.
|
|
106
|
+
*
|
|
107
|
+
* `testing` has already been stripped by config validation when the environment
|
|
108
|
+
* is production, so this function does not re-check. That is deliberate: the
|
|
109
|
+
* guarantee lives at one point rather than being re-asserted at each call site,
|
|
110
|
+
* which is the same reason the ad-unit substitution guarantee is provable
|
|
111
|
+
* (FR-113).
|
|
112
|
+
*/
|
|
113
|
+
export function buildConsentOptions(
|
|
114
|
+
testing: ConsentTestingOptions | undefined,
|
|
115
|
+
underAgeOfConsent: boolean | undefined,
|
|
116
|
+
testDeviceIds: readonly string[],
|
|
117
|
+
): EngineConsentOptions {
|
|
118
|
+
return {
|
|
119
|
+
debugGeography: testing?.debugGeography,
|
|
120
|
+
underAgeOfConsent,
|
|
121
|
+
testDeviceIds,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One consent operation, with the never-infer rule applied to every exit.
|
|
127
|
+
*
|
|
128
|
+
* Every path out of here is either a normalized snapshot or a normalized error
|
|
129
|
+
* snapshot. Nothing throws, because a consent failure must not be able to block
|
|
130
|
+
* app startup or crash the host (FR-108).
|
|
131
|
+
*/
|
|
132
|
+
export async function runConsentStep(
|
|
133
|
+
step: () => Promise<EngineConsentInfo>,
|
|
134
|
+
failureMessage: string,
|
|
135
|
+
): Promise<ConsentSnapshot> {
|
|
136
|
+
try {
|
|
137
|
+
return normalizeConsent(await step());
|
|
138
|
+
} catch (cause) {
|
|
139
|
+
return consentFailure(toConsentError(cause, failureMessage));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Whether a form should be presented, per the provider's own report. */
|
|
144
|
+
export function needsForm(snapshot: ConsentSnapshot): boolean {
|
|
145
|
+
return (
|
|
146
|
+
snapshot.isFormAvailable &&
|
|
147
|
+
(snapshot.state === 'required' || snapshot.state === 'undetermined')
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createConsentPortGuard(port: ConsentPort | undefined): ConsentPort {
|
|
152
|
+
if (port) {
|
|
153
|
+
return port;
|
|
154
|
+
}
|
|
155
|
+
// An engine without a consent port cannot serve the capability. Reporting it
|
|
156
|
+
// as a normalized failure keeps FR-108 true — the app does not crash, it
|
|
157
|
+
// learns the flow is unavailable and can fall back to its own gate.
|
|
158
|
+
const unavailable = async (): Promise<EngineConsentInfo> => {
|
|
159
|
+
throw new Error('provider_missing');
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
requestInfoUpdate: unavailable,
|
|
163
|
+
showFormIfRequired: unavailable,
|
|
164
|
+
getInfo: unavailable,
|
|
165
|
+
reset: () => undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-state enablement and the typed result of changing it (FR-114 – FR-125).
|
|
3
|
+
*
|
|
4
|
+
* 0.1.0 had a boolean, which forced an app selling an ad-removal tier to lie at
|
|
5
|
+
* startup. It does not yet know whether this user has paid — that answer arrives
|
|
6
|
+
* after a store sync — and both available answers are wrong: `true` flashes ads
|
|
7
|
+
* at someone who paid to never see them, `false` delays ads for everyone else
|
|
8
|
+
* and then runs into the silent re-enable trap below. The missing third state
|
|
9
|
+
* was a hole in the contract, not an omission in the app.
|
|
10
|
+
*
|
|
11
|
+
* The package still knows nothing about payments, entitlements, or why the app
|
|
12
|
+
* decided anything (FR-120). It only stops insisting the decision already exists
|
|
13
|
+
* at construction time.
|
|
14
|
+
*
|
|
15
|
+
* Pure — no provider, no React, no I/O.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Enablement, InitStatus, UnavailableReason } from '../config/types';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalizes the configured value. `true`/`false` keep their exact 0.1.0
|
|
22
|
+
* meaning, which is what makes the widened config type additive (BC-003).
|
|
23
|
+
*/
|
|
24
|
+
export function toEnablement(value: boolean | 'undecided'): Enablement {
|
|
25
|
+
if (value === 'undecided') {
|
|
26
|
+
return 'undecided';
|
|
27
|
+
}
|
|
28
|
+
return value ? 'enabled' : 'disabled';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Neither `disabled` nor `undecided` may reach the provider (FR-115). */
|
|
32
|
+
export function permitsRequests(enablement: Enablement): boolean {
|
|
33
|
+
return enablement === 'enabled';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The outcome of an enablement change.
|
|
38
|
+
*
|
|
39
|
+
* 0.1.0 returned `void`, which is the defect FR-122 supersedes. Re-enabling
|
|
40
|
+
* after a disable sent the init status back to `not_started`, so the app *had*
|
|
41
|
+
* to call `initialize()` again or every later load and show was refused — and
|
|
42
|
+
* nothing said so. Not the result, not the status, not the documentation. An app
|
|
43
|
+
* whose user's subscription lapsed would turn ads back on and silently show
|
|
44
|
+
* none.
|
|
45
|
+
*/
|
|
46
|
+
export type EnablementResult =
|
|
47
|
+
/**
|
|
48
|
+
* Returned to the status held before disabling, with zero provider calls.
|
|
49
|
+
* The common case: disabling never un-initializes the provider, so there is
|
|
50
|
+
* genuinely nothing to redo.
|
|
51
|
+
*/
|
|
52
|
+
| { readonly status: 'resumed'; readonly initStatus: InitStatus }
|
|
53
|
+
/**
|
|
54
|
+
* The instance never reached `ready`, privacy options changed, or the
|
|
55
|
+
* platform became unavailable. The caller must initialize; the package will
|
|
56
|
+
* not do it implicitly, because that would hide an asynchronous, billable
|
|
57
|
+
* operation behind a setter the caller may not be awaiting (FR-121).
|
|
58
|
+
*/
|
|
59
|
+
| { readonly status: 'requires_initialization' }
|
|
60
|
+
| { readonly status: 'disabled' }
|
|
61
|
+
| { readonly status: 'undecided' }
|
|
62
|
+
/** The requested state was already the current one. No work, no event. */
|
|
63
|
+
| { readonly status: 'unchanged' }
|
|
64
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether re-enabling can resume, given what the instance was doing when it was
|
|
68
|
+
* turned off.
|
|
69
|
+
*
|
|
70
|
+
* Only `ready` resumes. Everything else — never started, still initializing,
|
|
71
|
+
* blocked, failed — has no completed initialization to return to, so claiming a
|
|
72
|
+
* resume would put the instance in a state it never actually reached.
|
|
73
|
+
*/
|
|
74
|
+
export function canResume(resumeTo: InitStatus | null): resumeTo is 'ready' {
|
|
75
|
+
return resumeTo === 'ready';
|
|
76
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that decides why a request was refused (FR-140, FR-141).
|
|
3
|
+
*
|
|
4
|
+
* After 0.2.0 there are three independent ways to be blocked — platform
|
|
5
|
+
* availability, the app's three-state enable flag, and consent — and they can be
|
|
6
|
+
* closed in any combination. A precedence spread across four call sites is not a
|
|
7
|
+
* precedence; it is four coincidences that currently agree. So every refusal
|
|
8
|
+
* path in the package routes through `evaluateGates` and nothing else compares
|
|
9
|
+
* gate state.
|
|
10
|
+
*
|
|
11
|
+
* **The order is availability, then enablement, then consent — most immovable
|
|
12
|
+
* first.** Two reasons, and they agree:
|
|
13
|
+
*
|
|
14
|
+
* 1. It is what 0.1.0 already did. `initMachine`'s BLOCK transition is refused
|
|
15
|
+
* outright when the status is `disabled` or `unavailable`, so consent was
|
|
16
|
+
* already the weakest of the three. Any other order would change observable
|
|
17
|
+
* behavior for a consumer who configured nothing new.
|
|
18
|
+
* 2. It is the most useful order for whoever is debugging. An unsupported
|
|
19
|
+
* platform makes the other two unfixable, so reporting "consent required" on
|
|
20
|
+
* a platform that can never serve an ad sends a developer off to build a flow
|
|
21
|
+
* that will never help. Reporting `unavailable` to an app that has also
|
|
22
|
+
* disabled ads costs nothing — that app was not going to show an ad either
|
|
23
|
+
* way.
|
|
24
|
+
*
|
|
25
|
+
* The accepted cost is that an app which *deliberately* disabled ads on an
|
|
26
|
+
* unsupported platform is told about the platform rather than about its own
|
|
27
|
+
* switch. That is fine: neither answer changes what the app does next.
|
|
28
|
+
*
|
|
29
|
+
* Pure. No provider, no React, no I/O — which is what lets the full combination
|
|
30
|
+
* table be a unit test rather than an integration one.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Enablement, PrivacyBlockReason, UnavailableReason } from '../config/types';
|
|
34
|
+
|
|
35
|
+
/** What the consent flow contributes, or `null` when the app never opted in. */
|
|
36
|
+
export type ConsentGateInput =
|
|
37
|
+
| { readonly permitted: true }
|
|
38
|
+
| { readonly permitted: false; readonly reason: 'consent_flow_required' | 'consent_flow_error' }
|
|
39
|
+
| null;
|
|
40
|
+
|
|
41
|
+
export interface GateInput {
|
|
42
|
+
readonly available: { readonly ok: true } | { readonly ok: false; readonly reason: UnavailableReason };
|
|
43
|
+
readonly enablement: Enablement;
|
|
44
|
+
/** The app's own gate, from `PrivacyOptions.canRequestAds`. */
|
|
45
|
+
readonly appGate: { readonly permitted: true } | { readonly permitted: false; readonly reason: PrivacyBlockReason };
|
|
46
|
+
/** The package's consent flow. `null` when unused — then the app's gate alone decides. */
|
|
47
|
+
readonly consentGate: ConsentGateInput;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type GateDecision =
|
|
51
|
+
| { readonly allowed: true }
|
|
52
|
+
| { readonly allowed: false; readonly kind: 'unavailable'; readonly reason: UnavailableReason }
|
|
53
|
+
| { readonly allowed: false; readonly kind: 'disabled' }
|
|
54
|
+
| { readonly allowed: false; readonly kind: 'undecided' }
|
|
55
|
+
| { readonly allowed: false; readonly kind: 'blocked'; readonly reason: PrivacyBlockReason };
|
|
56
|
+
|
|
57
|
+
export function evaluateGates(input: GateInput): GateDecision {
|
|
58
|
+
// 1. Availability. Nothing downstream can be fixed while this is closed.
|
|
59
|
+
if (!input.available.ok) {
|
|
60
|
+
return { allowed: false, kind: 'unavailable', reason: input.available.reason };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. Enablement. `undecided` is reported separately from `disabled` because
|
|
64
|
+
// that distinction is the entire reason the third state exists (FR-116).
|
|
65
|
+
if (input.enablement === 'disabled') {
|
|
66
|
+
return { allowed: false, kind: 'disabled' };
|
|
67
|
+
}
|
|
68
|
+
if (input.enablement === 'undecided') {
|
|
69
|
+
return { allowed: false, kind: 'undecided' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. Consent, whose two inputs combine as most-restrictive-wins (FR-105a).
|
|
73
|
+
//
|
|
74
|
+
// Requesting is permitted only when both permit it, and neither can widen
|
|
75
|
+
// what the other allows. The case this protects is an app that obtained
|
|
76
|
+
// consent successfully but independently blocked ads for a reason of its own
|
|
77
|
+
// — a reason FR-120 forbids the package from knowing, and therefore one it
|
|
78
|
+
// must never overrule.
|
|
79
|
+
//
|
|
80
|
+
// When both are closed the app's own gate is reported: it is the input the
|
|
81
|
+
// app controls directly, and the flow's state is separately readable through
|
|
82
|
+
// `consent.getState()` at any time.
|
|
83
|
+
if (!input.appGate.permitted) {
|
|
84
|
+
return { allowed: false, kind: 'blocked', reason: input.appGate.reason };
|
|
85
|
+
}
|
|
86
|
+
if (input.consentGate && !input.consentGate.permitted) {
|
|
87
|
+
return { allowed: false, kind: 'blocked', reason: input.consentGate.reason };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { allowed: true };
|
|
91
|
+
}
|
package/src/core/initMachine.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type InitEvent =
|
|
|
20
20
|
| { readonly type: 'BLOCK'; readonly reason: PrivacyBlockReason }
|
|
21
21
|
| { readonly type: 'UNAVAILABLE'; readonly reason: UnavailableReason }
|
|
22
22
|
| { readonly type: 'DISABLE' }
|
|
23
|
+
| { readonly type: 'UNDECIDE' }
|
|
23
24
|
| { readonly type: 'ENABLE' }
|
|
24
25
|
| { readonly type: 'SUCCEED' }
|
|
25
26
|
| { readonly type: 'FAIL'; readonly error: AdsError };
|
|
@@ -29,6 +30,16 @@ export interface InitSnapshot {
|
|
|
29
30
|
readonly blockReason: PrivacyBlockReason | null;
|
|
30
31
|
readonly unavailableReason: UnavailableReason | null;
|
|
31
32
|
readonly error: AdsError | null;
|
|
33
|
+
/**
|
|
34
|
+
* The status held when ads were switched off, so re-enabling can return to
|
|
35
|
+
* it instead of silently dropping to `not_started`.
|
|
36
|
+
*
|
|
37
|
+
* 0.1.0's `ENABLE` sent a disabled instance to `not_started`, which meant the
|
|
38
|
+
* app had to call `initialize()` again or every later load and show was
|
|
39
|
+
* refused — and nothing said so. Remembering one value is the whole fix
|
|
40
|
+
* (FR-121).
|
|
41
|
+
*/
|
|
42
|
+
readonly resumeTo: InitStatus | null;
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
export const initialInit: InitSnapshot = {
|
|
@@ -36,12 +47,24 @@ export const initialInit: InitSnapshot = {
|
|
|
36
47
|
blockReason: null,
|
|
37
48
|
unavailableReason: null,
|
|
38
49
|
error: null,
|
|
50
|
+
resumeTo: null,
|
|
39
51
|
};
|
|
40
52
|
|
|
41
53
|
function at(status: InitStatus, patch: Partial<InitSnapshot> = {}): InitSnapshot {
|
|
42
54
|
return { ...initialInit, status, ...patch };
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
/**
|
|
58
|
+
* States that behave as "switched off" for everything downstream.
|
|
59
|
+
*
|
|
60
|
+
* `undecided` is grouped with `disabled` for request handling — neither may
|
|
61
|
+
* reach the provider — while staying a distinct reported status, which is the
|
|
62
|
+
* only reason the third state is worth having (FR-115, FR-116).
|
|
63
|
+
*/
|
|
64
|
+
function isSwitchedOff(status: InitStatus): boolean {
|
|
65
|
+
return status === 'disabled' || status === 'undecided';
|
|
66
|
+
}
|
|
67
|
+
|
|
45
68
|
/**
|
|
46
69
|
* States a fresh start may leave from.
|
|
47
70
|
*
|
|
@@ -65,18 +88,38 @@ export function canRequest(status: InitStatus): boolean {
|
|
|
65
88
|
return status === 'ready';
|
|
66
89
|
}
|
|
67
90
|
|
|
91
|
+
/**
|
|
92
|
+
* What to remember when switching off.
|
|
93
|
+
*
|
|
94
|
+
* Only a completed initialization is worth keeping. A `blocked` or `failed`
|
|
95
|
+
* instance has nothing to resume to, and remembering `initializing` would mean
|
|
96
|
+
* resuming into a state whose in-flight operation was already abandoned.
|
|
97
|
+
*/
|
|
98
|
+
function rememberFor(state: InitSnapshot): InitStatus | null {
|
|
99
|
+
if (state.status === 'ready') {
|
|
100
|
+
return 'ready';
|
|
101
|
+
}
|
|
102
|
+
// Preserve an earlier memory across an off -> off transition, so
|
|
103
|
+
// disable -> undecided -> enable still resumes.
|
|
104
|
+
return isSwitchedOff(state.status) ? state.resumeTo : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
68
107
|
export function initReducer(state: InitSnapshot, event: InitEvent): InitSnapshot {
|
|
69
108
|
switch (event.type) {
|
|
70
109
|
case 'START':
|
|
71
|
-
// `disabled` and `unavailable`
|
|
72
|
-
//
|
|
73
|
-
if (state.status
|
|
110
|
+
// `disabled`, `undecided`, and `unavailable` all refuse a start:
|
|
111
|
+
// nothing may reach the provider while any of them holds.
|
|
112
|
+
if (isSwitchedOff(state.status) || state.status === 'unavailable') {
|
|
74
113
|
return state;
|
|
75
114
|
}
|
|
76
115
|
return canStart(state.status) ? at('initializing') : state;
|
|
77
116
|
|
|
78
117
|
case 'BLOCK':
|
|
79
|
-
|
|
118
|
+
// Consent is the weakest of the three gates, so it never overwrites a
|
|
119
|
+
// stronger one. This guard is the 0.1.0 behavior that settled the
|
|
120
|
+
// precedence question: availability and enablement already outranked
|
|
121
|
+
// consent, and FR-140 makes that explicit rather than inventing it.
|
|
122
|
+
if (isSwitchedOff(state.status) || state.status === 'unavailable') {
|
|
80
123
|
return state;
|
|
81
124
|
}
|
|
82
125
|
// A gate withdrawn after initialization does not un-initialize the
|
|
@@ -88,10 +131,33 @@ export function initReducer(state: InitSnapshot, event: InitEvent): InitSnapshot
|
|
|
88
131
|
return at('unavailable', { unavailableReason: event.reason });
|
|
89
132
|
|
|
90
133
|
case 'DISABLE':
|
|
91
|
-
|
|
134
|
+
// Availability outranks enablement (FR-140a). 0.1.0 left these two
|
|
135
|
+
// unguarded against each other, so whichever arrived last won and the
|
|
136
|
+
// reported reason depended on timing — exactly the incidental ordering
|
|
137
|
+
// FR-140 forbids. Availability winning is deterministic and is the
|
|
138
|
+
// more useful answer: an unsupported platform cannot be fixed by
|
|
139
|
+
// turning ads back on.
|
|
140
|
+
if (state.status === 'unavailable') {
|
|
141
|
+
return state;
|
|
142
|
+
}
|
|
143
|
+
return at('disabled', { resumeTo: rememberFor(state) });
|
|
92
144
|
|
|
93
|
-
case '
|
|
94
|
-
|
|
145
|
+
case 'UNDECIDE':
|
|
146
|
+
if (state.status === 'unavailable') {
|
|
147
|
+
return state;
|
|
148
|
+
}
|
|
149
|
+
return at('undecided', { resumeTo: rememberFor(state) });
|
|
150
|
+
|
|
151
|
+
case 'ENABLE': {
|
|
152
|
+
if (!isSwitchedOff(state.status)) {
|
|
153
|
+
return state;
|
|
154
|
+
}
|
|
155
|
+
// Resume only from a completed initialization. Anything else has no
|
|
156
|
+
// ready state to return to, and claiming one would put the instance in
|
|
157
|
+
// a state it never actually reached — so the caller is told to
|
|
158
|
+
// initialize instead, explicitly (FR-121).
|
|
159
|
+
return state.resumeTo === 'ready' ? at('ready') : at('not_started');
|
|
160
|
+
}
|
|
95
161
|
|
|
96
162
|
case 'SUCCEED':
|
|
97
163
|
// Only a transition out of `initializing` counts. A `SUCCEED` arriving
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded, opt-in retry for failed loads (FR-134 – FR-139).
|
|
3
|
+
*
|
|
4
|
+
* 0.1.0 shipped no retry on purpose, and the reason was sound: every retry is a
|
|
5
|
+
* billable request, and only the app knows what that costs. But the practical
|
|
6
|
+
* result was that every app rewrote the same backoff loop, and the cost of
|
|
7
|
+
* writing it wrong landed on a bill. This ships the loop once, keeps it **off by
|
|
8
|
+
* default**, and makes every attempt visible.
|
|
9
|
+
*
|
|
10
|
+
* Three properties do the work:
|
|
11
|
+
*
|
|
12
|
+
* - Off unless configured, so upgrading from 0.1.0 issues not one extra
|
|
13
|
+
* request (FR-134, and the compat test proves it rather than asserting it).
|
|
14
|
+
* - Only `retryable` errors. Configuration and consent errors, an unsupported
|
|
15
|
+
* platform, and a disabled or undecided instance are states another attempt
|
|
16
|
+
* cannot change, so retrying them would spend money on a certainty (FR-135).
|
|
17
|
+
* - Every attempt is an event carrying the load cycle's correlation id, so a
|
|
18
|
+
* bill can be reconciled against the event stream alone (FR-138).
|
|
19
|
+
*
|
|
20
|
+
* Pure — the schedule is computed here, the waiting happens in the instance's
|
|
21
|
+
* TimerSet so `dispose()` can prove it released everything.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { AdsConfigError } from '../diagnostics/errors';
|
|
25
|
+
import { checkBand, DEFAULT_RETRY_POLICY, RETRY_BOUNDS } from './timers';
|
|
26
|
+
import type { RetryPolicy } from '../config/types';
|
|
27
|
+
|
|
28
|
+
export interface ResolvedRetryPolicy {
|
|
29
|
+
readonly maxRetries: number;
|
|
30
|
+
readonly initialDelayMs: number;
|
|
31
|
+
readonly maxDelayMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validates one policy against the bounds.
|
|
36
|
+
*
|
|
37
|
+
* Out-of-range values **throw** rather than being clamped, matching the rule
|
|
38
|
+
* `timers.ts` already follows: silently clamping a request for ten retries down
|
|
39
|
+
* to five tells the app it configured something it did not, and the discrepancy
|
|
40
|
+
* only surfaces later as a bill that does not match the code.
|
|
41
|
+
*
|
|
42
|
+
* `label` names the offending field precisely — an error that says only
|
|
43
|
+
* "invalid retry policy" makes an integrator hunt through a catalog by hand.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveRetryPolicy(policy: RetryPolicy, label: string): ResolvedRetryPolicy {
|
|
46
|
+
const maxRetries = checkBand(
|
|
47
|
+
`${label}.maxRetries`,
|
|
48
|
+
policy.maxRetries ?? DEFAULT_RETRY_POLICY.maxRetries,
|
|
49
|
+
RETRY_BOUNDS.maxRetries,
|
|
50
|
+
);
|
|
51
|
+
const initialDelayMs = checkBand(
|
|
52
|
+
`${label}.initialDelayMs`,
|
|
53
|
+
policy.initialDelayMs ?? DEFAULT_RETRY_POLICY.initialDelayMs,
|
|
54
|
+
RETRY_BOUNDS.initialDelayMs,
|
|
55
|
+
);
|
|
56
|
+
const maxDelayMs = checkBand(
|
|
57
|
+
`${label}.maxDelayMs`,
|
|
58
|
+
policy.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
|
|
59
|
+
RETRY_BOUNDS.maxDelayMs,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (!Number.isInteger(maxRetries)) {
|
|
63
|
+
throw new AdsConfigError('retry_out_of_bounds', {
|
|
64
|
+
message: `${label}.maxRetries must be a whole number; received ${maxRetries}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (initialDelayMs > maxDelayMs) {
|
|
68
|
+
throw new AdsConfigError('retry_out_of_bounds', {
|
|
69
|
+
message: `${label}.initialDelayMs (${initialDelayMs}ms) exceeds ${label}.maxDelayMs (${maxDelayMs}ms); the first retry would already be past the ceiling`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { maxRetries, initialDelayMs, maxDelayMs };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolves the effective policy for one placement.
|
|
78
|
+
*
|
|
79
|
+
* An override **replaces** the instance-wide policy rather than merging into it.
|
|
80
|
+
* Merging would mean the effective policy for a placement could not be read off
|
|
81
|
+
* the configuration — you would have to simulate the merge to know what it was —
|
|
82
|
+
* and a per-field merge makes "this placement retries twice" quietly depend on a
|
|
83
|
+
* value written somewhere else.
|
|
84
|
+
*
|
|
85
|
+
* Both are validated against the same bounds, so an override narrows behavior
|
|
86
|
+
* but can never exceed a ceiling (FR-136a).
|
|
87
|
+
*/
|
|
88
|
+
export function resolveEffectiveRetry(
|
|
89
|
+
instancePolicy: RetryPolicy | undefined,
|
|
90
|
+
placementPolicy: RetryPolicy | false | undefined,
|
|
91
|
+
placementKey: string,
|
|
92
|
+
): ResolvedRetryPolicy | null {
|
|
93
|
+
if (placementPolicy === false) {
|
|
94
|
+
// Explicit opt-out for this placement alone. A banner retry is cheap and
|
|
95
|
+
// invisible; a rewarded retry happens with a user waiting on the other
|
|
96
|
+
// side of it. An app may reasonably want one and not the other.
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (placementPolicy) {
|
|
100
|
+
return resolveRetryPolicy(placementPolicy, `placements.${placementKey}.retry`);
|
|
101
|
+
}
|
|
102
|
+
if (instancePolicy) {
|
|
103
|
+
return resolveRetryPolicy(instancePolicy, 'retry');
|
|
104
|
+
}
|
|
105
|
+
// Nothing configured anywhere: retry is entirely off, which is the default
|
|
106
|
+
// and the only behavior a 0.1.0 consumer can observe (FR-134).
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Delay before the given attempt. `attempt` is 1-based — the first retry is 1.
|
|
112
|
+
*
|
|
113
|
+
* Exponential with a fixed x2 multiplier and **no jitter**. Jitter exists to
|
|
114
|
+
* desynchronize a fleet of clients hitting one server; a single device retrying
|
|
115
|
+
* its own load has no herd to avoid. Leaving it out keeps "two retries, 2s then
|
|
116
|
+
* 4s" an exact assertion instead of a statistical one, which matters more here
|
|
117
|
+
* than a property that does not apply.
|
|
118
|
+
*/
|
|
119
|
+
export function delayForAttempt(policy: ResolvedRetryPolicy, attempt: number): number {
|
|
120
|
+
const raw = policy.initialDelayMs * Math.pow(2, Math.max(0, attempt - 1));
|
|
121
|
+
return Math.min(raw, policy.maxDelayMs);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Whether another attempt is permitted. `attemptsMade` counts retries only. */
|
|
125
|
+
export function canRetry(policy: ResolvedRetryPolicy | null, attemptsMade: number): boolean {
|
|
126
|
+
return policy !== null && attemptsMade < policy.maxRetries;
|
|
127
|
+
}
|