@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,232 +1,238 @@
1
- /**
2
- * Normalized event model and emitter (FR-051, FR-052, FR-054, FR-056).
3
- *
4
- * Events are the package's only observability surface. They carry no provider
5
- * object, no advertising identifier, no consent payload, and nothing that
6
- * identifies a user — a consuming app forwards them to whatever logging or
7
- * analytics it already has, and the package depends on none (FR-055).
8
- *
9
- * Dispatch iterates a snapshot of the listener set so subscribing or
10
- * unsubscribing during a dispatch neither skips a listener nor double-invokes
11
- * one. A listener that throws is caught and reported once; it cannot corrupt a
12
- * state machine or starve another listener (FR-056).
13
- */
14
-
15
- import type { AdFormat, PrivacyBlockReason, Reward, UnavailableReason, Unsubscribe } from '../config/types';
16
- import { AdsError, type AdsErrorCode, type AdsOperation } from './errors';
17
-
18
- export type AdEventType =
19
- | 'initialization'
20
- | 'load_started'
21
- | 'loaded'
22
- | 'load_failed'
23
- | 'display_started'
24
- | 'impression'
25
- | 'click'
26
- | 'display_failed'
27
- | 'dismissed'
28
- | 'reward_earned'
29
- | 'disabled'
30
- | 'unavailable'
31
- | 'config_warning'
32
- // Emitted once per retry attempt, carrying the correlation id of the load
33
- // cycle that triggered it. Each attempt is a billable request, so it has to
34
- // be reconcilable against a bill from the event stream alone (FR-138).
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'
42
- | 'internal_error';
43
-
44
- interface BaseEvent {
45
- readonly timestamp: number;
46
- readonly placementKey?: string;
47
- readonly format?: AdFormat;
48
- readonly correlationId?: string;
49
- }
50
-
51
- /**
52
- * `source` records whether the provider reported a true impression or the
53
- * package derived one. Banners get a real impression callback; full-screen
54
- * formats have none in the provider's event enum, so theirs is derived from the
55
- * ad opening. Flattening that away would make the model quietly wrong for anyone
56
- * reconciling these events against revenue reporting.
57
- */
58
- export type ImpressionSource = 'provider' | 'derived';
59
-
60
- export type AdEvent = BaseEvent &
61
- (
62
- | { readonly type: 'initialization'; readonly status: string; readonly retryable?: boolean }
63
- | { readonly type: 'load_started' }
64
- | { readonly type: 'loaded'; readonly durationMs: number }
65
- | { readonly type: 'load_failed'; readonly error: AdsError }
66
- | { readonly type: 'display_started' }
67
- | { readonly type: 'impression'; readonly source: ImpressionSource }
68
- | { readonly type: 'click' }
69
- | { readonly type: 'display_failed'; readonly error: AdsError }
70
- | { readonly type: 'dismissed'; readonly rewardEarned?: boolean }
71
- | { readonly type: 'reward_earned'; readonly reward: Reward }
72
- | { readonly type: 'disabled'; readonly operation: AdsOperation }
73
- | {
74
- readonly type: 'unavailable';
75
- readonly operation: AdsOperation;
76
- readonly reason: UnavailableReason | PrivacyBlockReason;
77
- }
78
- | {
79
- readonly type: 'retry_scheduled';
80
- /** 1-based: the first retry is attempt 1, not 0. */
81
- readonly attempt: number;
82
- readonly delayMs: number;
83
- readonly error: AdsError;
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
- }
92
- | {
93
- readonly type: 'config_warning';
94
- readonly warning: ConfigWarning;
95
- /**
96
- * Placements the warning concerns. Empty for warnings that are not
97
- * placement-scoped.
98
- *
99
- * Present on every config warning rather than only on the one that
100
- * uses it, so that 0.1.0 code which reads `placementKeys` after
101
- * checking `type === 'config_warning'` keeps compiling. Splitting
102
- * this into one variant per warning would have been tidier and
103
- * would have broken every such consumer for no behavioral gain.
104
- */
105
- readonly placementKeys: readonly string[];
106
- /** Consent tools ignored, for `consent_tool_ignored_in_production`. */
107
- readonly tools?: readonly string[];
108
- /** How long the app left the state unresolved, for the undecided warning. */
109
- readonly elapsedMs?: number;
110
- }
111
- | { readonly type: 'internal_error'; readonly error: AdsError }
112
- );
113
-
114
- /**
115
- * Configuration problems worth telling the app about, all under one event type.
116
- *
117
- * One type rather than three because FR-132 forbids a parallel vocabulary, and
118
- * because a consumer already routing `config_warning` into its logging should
119
- * not have to subscribe again to keep seeing everything.
120
- */
121
- export type ConfigWarning =
122
- /** A production ad unit was replaced with an official test unit (FR-011a). */
123
- | 'test_ad_unit_substituted'
124
- /** The app left `enabled: 'undecided'` unresolved past its window (FR-119). */
125
- | 'enablement_undecided_timeout'
126
- /** Consent testing tools were supplied in production and ignored (FR-113). */
127
- | 'consent_tool_ignored_in_production';
128
-
129
- export type AdEventListener = (event: AdEvent) => void;
130
-
131
- /** Events that may occur at most once within one correlated cycle (FR-054). */
132
- const ONCE_PER_CYCLE: ReadonlySet<AdEventType> = new Set<AdEventType>([
133
- 'loaded',
134
- 'display_started',
135
- 'impression',
136
- 'dismissed',
137
- 'reward_earned',
138
- ]);
139
-
140
- export interface AdEventEmitter {
141
- emit(event: AdEvent): void;
142
- subscribe(listener: AdEventListener): Unsubscribe;
143
- /** Releases every listener and the once-per-cycle bookkeeping (FR-035). */
144
- dispose(): void;
145
- listenerCount(): number;
146
- }
147
-
148
- export function createEventEmitter(): AdEventEmitter {
149
- const listeners = new Set<AdEventListener>();
150
- // `${correlationId}:${type}` for everything already emitted in a cycle.
151
- const emittedOnce = new Set<string>();
152
- let disposed = false;
153
- // Guards against an internal_error thrown by a listener re-entering dispatch
154
- // and recursing forever.
155
- let reportingListenerFailure = false;
156
-
157
- function dispatch(event: AdEvent): void {
158
- for (const listener of Array.from(listeners)) {
159
- try {
160
- listener(event);
161
- } catch (error) {
162
- if (reportingListenerFailure) {
163
- continue;
164
- }
165
- reportingListenerFailure = true;
166
- try {
167
- dispatch({
168
- type: 'internal_error',
169
- timestamp: Date.now(),
170
- placementKey: event.placementKey,
171
- format: event.format,
172
- correlationId: event.correlationId,
173
- error: new AdsError('internal_error' as AdsErrorCode, {
174
- message: 'consumer event listener threw',
175
- cause: error,
176
- }),
177
- });
178
- } finally {
179
- reportingListenerFailure = false;
180
- }
181
- }
182
- }
183
- }
184
-
185
- return {
186
- emit(event) {
187
- // After dispose, a late provider callback updates nothing and emits
188
- // nothing (FR-035, SC-008).
189
- if (disposed) {
190
- return;
191
- }
192
- if (event.correlationId && ONCE_PER_CYCLE.has(event.type)) {
193
- const key = `${event.correlationId}:${event.type}`;
194
- if (emittedOnce.has(key)) {
195
- return;
196
- }
197
- emittedOnce.add(key);
198
- }
199
- dispatch(event);
200
- },
201
- subscribe(listener) {
202
- listeners.add(listener);
203
- let active = true;
204
- return () => {
205
- // Idempotent: a second call is a no-op, not an error (FR-005).
206
- if (!active) {
207
- return;
208
- }
209
- active = false;
210
- listeners.delete(listener);
211
- };
212
- },
213
- dispose() {
214
- disposed = true;
215
- listeners.clear();
216
- emittedOnce.clear();
217
- },
218
- listenerCount() {
219
- return listeners.size;
220
- },
221
- };
222
- }
223
-
224
- /** Normalizes a provider reward descriptor. No provider object crosses this (FR-047). */
225
- export function normalizeReward(value: unknown): Reward {
226
- const source = (value ?? {}) as { type?: unknown; amount?: unknown };
227
- const amount = Number(source.amount);
228
- return {
229
- type: typeof source.type === 'string' ? source.type : 'reward',
230
- amount: Number.isFinite(amount) ? amount : 0,
231
- };
232
- }
1
+ /**
2
+ * Normalized event model and emitter (FR-051, FR-052, FR-054, FR-056).
3
+ *
4
+ * Events are the package's only observability surface. They carry no provider
5
+ * object, no advertising identifier, no consent payload, and nothing that
6
+ * identifies a user — a consuming app forwards them to whatever logging or
7
+ * analytics it already has, and the package depends on none (FR-055).
8
+ *
9
+ * Dispatch iterates a snapshot of the listener set so subscribing or
10
+ * unsubscribing during a dispatch neither skips a listener nor double-invokes
11
+ * one. A listener that throws is caught and reported once; it cannot corrupt a
12
+ * state machine or starve another listener (FR-056).
13
+ */
14
+
15
+ import type {
16
+ AdFormat,
17
+ PrivacyBlockReason,
18
+ Reward,
19
+ UnavailableReason,
20
+ Unsubscribe,
21
+ } from '../config/types';
22
+ import { AdsError, type AdsErrorCode, type AdsOperation } from './errors';
23
+
24
+ export type AdEventType =
25
+ | 'initialization'
26
+ | 'load_started'
27
+ | 'loaded'
28
+ | 'load_failed'
29
+ | 'display_started'
30
+ | 'impression'
31
+ | 'click'
32
+ | 'display_failed'
33
+ | 'dismissed'
34
+ | 'reward_earned'
35
+ | 'disabled'
36
+ | 'unavailable'
37
+ | 'config_warning'
38
+ // Emitted once per retry attempt, carrying the correlation id of the load
39
+ // cycle that triggered it. Each attempt is a billable request, so it has to
40
+ // be reconcilable against a bill from the event stream alone (FR-138).
41
+ | 'retry_scheduled'
42
+ // Emitted when the per-placement throttle refuses a request (FR-140). Carries
43
+ // no correlation id because no cycle began: nothing was requested, so there is
44
+ // nothing to correlate. Present so a throttled placement is visible in
45
+ // whatever logging the app already has, rather than looking like a silent
46
+ // absence of ads.
47
+ | 'request_throttled'
48
+ | 'internal_error';
49
+
50
+ interface BaseEvent {
51
+ readonly timestamp: number;
52
+ readonly placementKey?: string;
53
+ readonly format?: AdFormat;
54
+ readonly correlationId?: string;
55
+ }
56
+
57
+ /**
58
+ * `source` records whether the provider reported a true impression or the
59
+ * package derived one. Banners get a real impression callback; full-screen
60
+ * formats have none in the provider's event enum, so theirs is derived from the
61
+ * ad opening. Flattening that away would make the model quietly wrong for anyone
62
+ * reconciling these events against revenue reporting.
63
+ */
64
+ export type ImpressionSource = 'provider' | 'derived';
65
+
66
+ export type AdEvent = BaseEvent &
67
+ (
68
+ | { readonly type: 'initialization'; readonly status: string; readonly retryable?: boolean }
69
+ | { readonly type: 'load_started' }
70
+ | { readonly type: 'loaded'; readonly durationMs: number }
71
+ | { readonly type: 'load_failed'; readonly error: AdsError }
72
+ | { readonly type: 'display_started' }
73
+ | { readonly type: 'impression'; readonly source: ImpressionSource }
74
+ | { readonly type: 'click' }
75
+ | { readonly type: 'display_failed'; readonly error: AdsError }
76
+ | { readonly type: 'dismissed'; readonly rewardEarned?: boolean }
77
+ | { readonly type: 'reward_earned'; readonly reward: Reward }
78
+ | { readonly type: 'disabled'; readonly operation: AdsOperation }
79
+ | {
80
+ readonly type: 'unavailable';
81
+ readonly operation: AdsOperation;
82
+ readonly reason: UnavailableReason | PrivacyBlockReason;
83
+ }
84
+ | {
85
+ readonly type: 'retry_scheduled';
86
+ /** 1-based: the first retry is attempt 1, not 0. */
87
+ readonly attempt: number;
88
+ readonly delayMs: number;
89
+ readonly error: AdsError;
90
+ }
91
+ | {
92
+ readonly type: 'request_throttled';
93
+ /** How long until this placement may request again. */
94
+ readonly retryAfterMs: number;
95
+ /** The configured floor that refused it, so the cause is readable. */
96
+ readonly minRequestIntervalMs: number;
97
+ }
98
+ | {
99
+ readonly type: 'config_warning';
100
+ readonly warning: ConfigWarning;
101
+ /**
102
+ * Placements the warning concerns. Empty for warnings that are not
103
+ * placement-scoped.
104
+ *
105
+ * Present on every config warning rather than only on the one that
106
+ * uses it, so that 0.1.0 code which reads `placementKeys` after
107
+ * checking `type === 'config_warning'` keeps compiling. Splitting
108
+ * this into one variant per warning would have been tidier and
109
+ * would have broken every such consumer for no behavioral gain.
110
+ */
111
+ readonly placementKeys: readonly string[];
112
+ /** Consent tools ignored, for `consent_tool_ignored_in_production`. */
113
+ readonly tools?: readonly string[];
114
+ /** How long the app left the state unresolved, for the undecided warning. */
115
+ readonly elapsedMs?: number;
116
+ }
117
+ | { readonly type: 'internal_error'; readonly error: AdsError }
118
+ );
119
+
120
+ /**
121
+ * Configuration problems worth telling the app about, all under one event type.
122
+ *
123
+ * One type rather than three because FR-132 forbids a parallel vocabulary, and
124
+ * because a consumer already routing `config_warning` into its logging should
125
+ * not have to subscribe again to keep seeing everything.
126
+ */
127
+ export type ConfigWarning =
128
+ /** A production ad unit was replaced with an official test unit (FR-011a). */
129
+ | 'test_ad_unit_substituted'
130
+ /** The app left `enabled: 'undecided'` unresolved past its window (FR-119). */
131
+ | 'enablement_undecided_timeout'
132
+ /** Consent testing tools were supplied in production and ignored (FR-113). */
133
+ | 'consent_tool_ignored_in_production';
134
+
135
+ export type AdEventListener = (event: AdEvent) => void;
136
+
137
+ /** Events that may occur at most once within one correlated cycle (FR-054). */
138
+ const ONCE_PER_CYCLE: ReadonlySet<AdEventType> = new Set<AdEventType>([
139
+ 'loaded',
140
+ 'display_started',
141
+ 'impression',
142
+ 'dismissed',
143
+ 'reward_earned',
144
+ ]);
145
+
146
+ export interface AdEventEmitter {
147
+ emit(event: AdEvent): void;
148
+ subscribe(listener: AdEventListener): Unsubscribe;
149
+ /** Releases every listener and the once-per-cycle bookkeeping (FR-035). */
150
+ dispose(): void;
151
+ listenerCount(): number;
152
+ }
153
+
154
+ export function createEventEmitter(): AdEventEmitter {
155
+ const listeners = new Set<AdEventListener>();
156
+ // `${correlationId}:${type}` for everything already emitted in a cycle.
157
+ const emittedOnce = new Set<string>();
158
+ let disposed = false;
159
+ // Guards against an internal_error thrown by a listener re-entering dispatch
160
+ // and recursing forever.
161
+ let reportingListenerFailure = false;
162
+
163
+ function dispatch(event: AdEvent): void {
164
+ for (const listener of Array.from(listeners)) {
165
+ try {
166
+ listener(event);
167
+ } catch (error) {
168
+ if (reportingListenerFailure) {
169
+ continue;
170
+ }
171
+ reportingListenerFailure = true;
172
+ try {
173
+ dispatch({
174
+ type: 'internal_error',
175
+ timestamp: Date.now(),
176
+ placementKey: event.placementKey,
177
+ format: event.format,
178
+ correlationId: event.correlationId,
179
+ error: new AdsError('internal_error' as AdsErrorCode, {
180
+ message: 'consumer event listener threw',
181
+ cause: error,
182
+ }),
183
+ });
184
+ } finally {
185
+ reportingListenerFailure = false;
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ return {
192
+ emit(event) {
193
+ // After dispose, a late provider callback updates nothing and emits
194
+ // nothing (FR-035, SC-008).
195
+ if (disposed) {
196
+ return;
197
+ }
198
+ if (event.correlationId && ONCE_PER_CYCLE.has(event.type)) {
199
+ const key = `${event.correlationId}:${event.type}`;
200
+ if (emittedOnce.has(key)) {
201
+ return;
202
+ }
203
+ emittedOnce.add(key);
204
+ }
205
+ dispatch(event);
206
+ },
207
+ subscribe(listener) {
208
+ listeners.add(listener);
209
+ let active = true;
210
+ return () => {
211
+ // Idempotent: a second call is a no-op, not an error (FR-005).
212
+ if (!active) {
213
+ return;
214
+ }
215
+ active = false;
216
+ listeners.delete(listener);
217
+ };
218
+ },
219
+ dispose() {
220
+ disposed = true;
221
+ listeners.clear();
222
+ emittedOnce.clear();
223
+ },
224
+ listenerCount() {
225
+ return listeners.size;
226
+ },
227
+ };
228
+ }
229
+
230
+ /** Normalizes a provider reward descriptor. No provider object crosses this (FR-047). */
231
+ export function normalizeReward(value: unknown): Reward {
232
+ const source = (value ?? {}) as { type?: unknown; amount?: unknown };
233
+ const amount = Number(source.amount);
234
+ return {
235
+ type: typeof source.type === 'string' ? source.type : 'reward',
236
+ amount: Number.isFinite(amount) ? amount : 0,
237
+ };
238
+ }