@chipmobilesdk/rn-payment 0.4.1 → 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.
- package/CHANGELOG.md +202 -0
- package/README.md +70 -16
- package/package.json +5 -2
- package/skills/sdk-rn-payment/SKILL.md +8 -6
- package/src/adapter/iap-adapter.ts +44 -251
- package/src/adapter/iapShapes.ts +232 -0
- package/src/config/types.ts +8 -0
- package/src/config/validate.ts +19 -4
- package/src/core/mutex.ts +9 -110
- package/src/core/retry.ts +9 -104
- package/src/core/timers.ts +40 -18
- package/src/diagnostics/errors.ts +20 -13
- package/src/diagnostics/events.ts +51 -32
- package/src/diagnostics/messageKeys.ts +15 -9
- package/src/engine/connection.ts +194 -0
- package/src/engine/engine.ts +142 -733
- package/src/engine/foreground.ts +29 -0
- package/src/engine/internals.ts +40 -0
- package/src/engine/products.ts +39 -17
- package/src/engine/purchase.ts +22 -130
- package/src/engine/purchaseFailure.ts +48 -0
- package/src/engine/receiptValidation.ts +101 -0
- package/src/engine/state.ts +253 -0
- package/src/engine/sync.ts +161 -0
- package/src/entitlements/aggregate.ts +37 -5
- package/src/entitlements/mapping.ts +0 -22
- package/src/index.ts +3 -0
- package/src/persistence/mmkv-store.ts +45 -35
- package/src/persistence/snapshot.ts +102 -27
- package/src/react/PaymentProvider.tsx +85 -23
- package/src/react/hooks.ts +23 -34
- package/src/testing/conformance.ts +169 -0
- package/src/testing/fakeStore.ts +11 -29
- package/src/testing/index.ts +9 -0
package/src/core/mutex.ts
CHANGED
|
@@ -14,113 +14,12 @@
|
|
|
14
14
|
* expects.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
inFlight(): boolean;
|
|
27
|
-
} {
|
|
28
|
-
let current: Promise<T> | undefined;
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
run(operation: () => Promise<T>): Promise<T> {
|
|
32
|
-
if (current) {
|
|
33
|
-
return current;
|
|
34
|
-
}
|
|
35
|
-
const flight = operation().finally(() => {
|
|
36
|
-
if (current === flight) {
|
|
37
|
-
current = undefined;
|
|
38
|
-
}
|
|
39
|
-
});
|
|
40
|
-
current = flight;
|
|
41
|
-
return flight;
|
|
42
|
-
},
|
|
43
|
-
inFlight: () => current !== undefined,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Distinguishes "refused because busy" from any other failure, without throwing. */
|
|
48
|
-
export type ExclusiveOutcome<T> = { acquired: true; value: T } | { acquired: false };
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Admits one operation at a time and refuses the rest.
|
|
52
|
-
*
|
|
53
|
-
* Refusal is returned, not thrown, because for a purchase it is a normal
|
|
54
|
-
* outcome the app reports to the user — not an error to be caught.
|
|
55
|
-
*/
|
|
56
|
-
export function createExclusive(): {
|
|
57
|
-
run<T>(operation: () => Promise<T>): Promise<ExclusiveOutcome<T>>;
|
|
58
|
-
busy(): boolean;
|
|
59
|
-
} {
|
|
60
|
-
let held = false;
|
|
61
|
-
|
|
62
|
-
return {
|
|
63
|
-
async run<T>(operation: () => Promise<T>): Promise<ExclusiveOutcome<T>> {
|
|
64
|
-
if (held) {
|
|
65
|
-
return { acquired: false };
|
|
66
|
-
}
|
|
67
|
-
held = true;
|
|
68
|
-
try {
|
|
69
|
-
return { acquired: true, value: await operation() };
|
|
70
|
-
} finally {
|
|
71
|
-
held = false;
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
busy: () => held,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Rate limiter for automatic foreground sync (PERF-002).
|
|
80
|
-
*
|
|
81
|
-
* Rejects rather than defers: a skipped automatic sync costs nothing because the
|
|
82
|
-
* next foreground event will try again, whereas a queue of deferred syncs would
|
|
83
|
-
* fire a burst the moment the interval elapsed — the exact storm this prevents.
|
|
84
|
-
*/
|
|
85
|
-
export function createRateLimiter(
|
|
86
|
-
minIntervalMs: number,
|
|
87
|
-
now: () => number = Date.now,
|
|
88
|
-
): { tryAcquire(): boolean; reset(): void } {
|
|
89
|
-
let lastAt: number | undefined;
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
tryAcquire(): boolean {
|
|
93
|
-
const at = now();
|
|
94
|
-
if (lastAt !== undefined && at - lastAt < minIntervalMs) {
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
lastAt = at;
|
|
98
|
-
return true;
|
|
99
|
-
},
|
|
100
|
-
reset(): void {
|
|
101
|
-
lastAt = undefined;
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Rejects with a `PAYMENT_TIMEOUT`-shaped error after `ms`.
|
|
108
|
-
*
|
|
109
|
-
* Always clears its timer, including on the success path: a package that leaks a
|
|
110
|
-
* timer per operation fails the cleanup guarantee (FR-082, PERF-002).
|
|
111
|
-
*/
|
|
112
|
-
export function withTimeout<T>(
|
|
113
|
-
promise: Promise<T>,
|
|
114
|
-
ms: number,
|
|
115
|
-
onTimeout: () => Error,
|
|
116
|
-
): Promise<T> {
|
|
117
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
118
|
-
const timeout = new Promise<never>((_, reject) => {
|
|
119
|
-
timer = setTimeout(() => reject(onTimeout()), ms);
|
|
120
|
-
});
|
|
121
|
-
return Promise.race([promise, timeout]).finally(() => {
|
|
122
|
-
if (timer !== undefined) {
|
|
123
|
-
clearTimeout(timer);
|
|
124
|
-
}
|
|
125
|
-
}) as Promise<T>;
|
|
126
|
-
}
|
|
17
|
+
// The primitives themselves are rn-core's; the reasoning above is why rn-payment
|
|
18
|
+
// uses each where it does.
|
|
19
|
+
export {
|
|
20
|
+
createExclusive,
|
|
21
|
+
createRateLimiter,
|
|
22
|
+
createSingleFlight,
|
|
23
|
+
withTimeout,
|
|
24
|
+
type ExclusiveOutcome,
|
|
25
|
+
} from '@chipmobilesdk/rn-core';
|
package/src/core/retry.ts
CHANGED
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
* schedule without burning real time.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import
|
|
12
|
+
import { computeBackoff } from '@chipmobilesdk/rn-core';
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
type TimerHandle = ReturnType<typeof setTimeout> | number;
|
|
14
|
+
import type { BackoffConfig } from '../config/types';
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Delay before `attempt` (1-based), capped and optionally jittered.
|
|
@@ -25,109 +24,15 @@ export function backoffDelayMs(
|
|
|
25
24
|
config: Required<BackoffConfig>,
|
|
26
25
|
random: () => number = Math.random,
|
|
27
26
|
): number {
|
|
28
|
-
|
|
29
|
-
const capped = Math.min(raw, config.maxDelayMs);
|
|
30
|
-
return config.jitter ? Math.floor(capped * random()) : capped;
|
|
27
|
+
return computeBackoff(attempt, toSpec(config), random);
|
|
31
28
|
}
|
|
32
29
|
|
|
33
|
-
|
|
34
|
-
export function backoffSchedule(config: Required<BackoffConfig>): number[] {
|
|
35
|
-
const delays: number[] = [];
|
|
36
|
-
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
|
|
37
|
-
delays.push(backoffDelayMs(attempt, config, () => 1));
|
|
38
|
-
}
|
|
39
|
-
return delays;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface RetryHandle {
|
|
43
|
-
/** Cancels any pending attempt. Idempotent, so cleanup can call it freely (FR-082). */
|
|
44
|
-
cancel(): void;
|
|
45
|
-
/** Attempts consumed so far, so a retry storm is observable rather than inferred. */
|
|
46
|
-
attempts(): number;
|
|
47
|
-
/** True once the per-session budget is spent. */
|
|
48
|
-
exhausted(): boolean;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface RetryRunnerOptions {
|
|
52
|
-
config: Required<BackoffConfig>;
|
|
53
|
-
/** Return true when the attempt succeeded and retrying should stop. */
|
|
54
|
-
attempt: (attemptNumber: number) => Promise<boolean>;
|
|
55
|
-
/** Called once the per-session budget is spent without success. */
|
|
56
|
-
onExhausted?: (attempts: number) => void;
|
|
57
|
-
/**
|
|
58
|
-
* Injected so a test can run the whole schedule synchronously instead of
|
|
59
|
-
* waiting minutes of real backoff. The handle is opaque, because a Node
|
|
60
|
-
* `Timeout` and a browser numeric id are both valid here.
|
|
61
|
-
*/
|
|
62
|
-
setTimeoutFn?: (fn: () => void, ms: number) => TimerHandle;
|
|
63
|
-
clearTimeoutFn?: (handle: TimerHandle) => void;
|
|
64
|
-
random?: () => number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Runs `attempt` under the bounded schedule.
|
|
69
|
-
*
|
|
70
|
-
* Exhaustion is reported rather than thrown: for finalization, a spent session
|
|
71
|
-
* budget is not a failure but a handoff to the next launch, and the caller is
|
|
72
|
-
* the only one that knows which of the two it is.
|
|
73
|
-
*/
|
|
74
|
-
export function runWithRetry(options: RetryRunnerOptions): RetryHandle {
|
|
75
|
-
const {
|
|
76
|
-
config,
|
|
77
|
-
attempt,
|
|
78
|
-
onExhausted,
|
|
79
|
-
setTimeoutFn = setTimeout,
|
|
80
|
-
clearTimeoutFn = clearTimeout,
|
|
81
|
-
random = Math.random,
|
|
82
|
-
} = options;
|
|
83
|
-
|
|
84
|
-
let attemptCount = 0;
|
|
85
|
-
let cancelled = false;
|
|
86
|
-
let pending: TimerHandle | undefined;
|
|
87
|
-
|
|
88
|
-
const schedule = (delayMs: number): void => {
|
|
89
|
-
pending = setTimeoutFn(() => {
|
|
90
|
-
pending = undefined;
|
|
91
|
-
run().catch(() => undefined);
|
|
92
|
-
}, delayMs);
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const run = async (): Promise<void> => {
|
|
96
|
-
if (cancelled) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
attemptCount += 1;
|
|
100
|
-
let succeeded = false;
|
|
101
|
-
try {
|
|
102
|
-
succeeded = await attempt(attemptCount);
|
|
103
|
-
} catch {
|
|
104
|
-
// A throwing attempt is a failed attempt, nothing more. Letting it escape
|
|
105
|
-
// would abandon the schedule and, for finalization, silently drop a paid
|
|
106
|
-
// transaction.
|
|
107
|
-
succeeded = false;
|
|
108
|
-
}
|
|
109
|
-
if (cancelled || succeeded) {
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
if (attemptCount >= config.maxAttempts) {
|
|
113
|
-
onExhausted?.(attemptCount);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
schedule(backoffDelayMs(attemptCount + 1, config, random));
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
// Fire the first attempt immediately; the schedule owns everything after it.
|
|
120
|
-
run().catch(() => undefined);
|
|
121
|
-
|
|
30
|
+
function toSpec(config: Required<BackoffConfig>) {
|
|
122
31
|
return {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
attempts: () => attemptCount,
|
|
131
|
-
exhausted: () => attemptCount >= config.maxAttempts,
|
|
32
|
+
initialDelayMs: config.initialDelayMs,
|
|
33
|
+
maxDelayMs: config.maxDelayMs,
|
|
34
|
+
factor: config.factor,
|
|
35
|
+
jitter: config.jitter ? ('full' as const) : ('none' as const),
|
|
36
|
+
maxAttempts: config.maxAttempts,
|
|
132
37
|
};
|
|
133
38
|
}
|
package/src/core/timers.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* it configured something it did not.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { checkRange, type Range } from '@chipmobilesdk/rn-core';
|
|
15
|
+
|
|
14
16
|
import { PaymentConfigError } from '../diagnostics/errors';
|
|
15
17
|
import type { BackoffConfig, RetryConfig, TimeoutConfig } from '../config/types';
|
|
16
18
|
|
|
@@ -113,30 +115,31 @@ export const DEFAULT_FINALIZE_BACKOFF: Required<BackoffConfig> = {
|
|
|
113
115
|
|
|
114
116
|
export const BACKOFF_MAX_DELAY_BOUNDS = { min: 5_000, max: 600_000 } as const;
|
|
115
117
|
export const BACKOFF_ATTEMPT_BOUNDS = { min: 1, max: 10 } as const;
|
|
118
|
+
/** A first retry sooner than this is a storm; later than this, the user has given up. */
|
|
119
|
+
export const BACKOFF_INITIAL_DELAY_BOUNDS = { min: 100, max: 60_000 } as const;
|
|
120
|
+
/** 1 is a constant delay; 0 or below would retry at once, forever (CORE-05). */
|
|
121
|
+
export const BACKOFF_FACTOR_BOUNDS = { min: 1, max: 10 } as const;
|
|
116
122
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
function invalidConfig(label: string) {
|
|
124
|
+
return (message: string) =>
|
|
125
|
+
new PaymentConfigError({
|
|
126
|
+
code: 'PAYMENT_INVALID_CONFIG',
|
|
127
|
+
message,
|
|
128
|
+
operation: 'configure',
|
|
129
|
+
offenders: [label],
|
|
130
|
+
});
|
|
120
131
|
}
|
|
121
132
|
|
|
122
133
|
function requireInBounds(
|
|
123
134
|
label: string,
|
|
124
135
|
value: number | undefined,
|
|
125
136
|
fallback: number,
|
|
126
|
-
bounds:
|
|
137
|
+
bounds: Range,
|
|
138
|
+
unit = 'ms',
|
|
127
139
|
): number {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (!Number.isFinite(value) || value < bounds.min || value > bounds.max) {
|
|
132
|
-
throw new PaymentConfigError({
|
|
133
|
-
code: 'PAYMENT_INVALID_CATALOG',
|
|
134
|
-
message: `${label} must be between ${bounds.min} and ${bounds.max}ms, received ${value}`,
|
|
135
|
-
operation: 'configure',
|
|
136
|
-
offenders: [label],
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
return value;
|
|
140
|
+
return value === undefined
|
|
141
|
+
? fallback
|
|
142
|
+
: checkRange(label, value, bounds, invalidConfig(label), unit);
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
export interface ResolvedTimeouts {
|
|
@@ -205,11 +208,30 @@ function resolveBackoff(
|
|
|
205
208
|
config?.maxAttempts,
|
|
206
209
|
fallback.maxAttempts,
|
|
207
210
|
BACKOFF_ATTEMPT_BOUNDS,
|
|
211
|
+
'',
|
|
208
212
|
);
|
|
213
|
+
const initialDelayMs = requireInBounds(
|
|
214
|
+
`${label}.initialDelayMs`,
|
|
215
|
+
config?.initialDelayMs,
|
|
216
|
+
fallback.initialDelayMs,
|
|
217
|
+
BACKOFF_INITIAL_DELAY_BOUNDS,
|
|
218
|
+
);
|
|
219
|
+
const factor = requireInBounds(
|
|
220
|
+
`${label}.factor`,
|
|
221
|
+
config?.factor,
|
|
222
|
+
fallback.factor,
|
|
223
|
+
BACKOFF_FACTOR_BOUNDS,
|
|
224
|
+
'',
|
|
225
|
+
);
|
|
226
|
+
if (initialDelayMs > maxDelayMs) {
|
|
227
|
+
throw invalidConfig(`${label}.initialDelayMs`)(
|
|
228
|
+
`${label}.initialDelayMs (${initialDelayMs}ms) exceeds ${label}.maxDelayMs (${maxDelayMs}ms)`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
209
231
|
return {
|
|
210
|
-
initialDelayMs
|
|
232
|
+
initialDelayMs,
|
|
211
233
|
maxDelayMs,
|
|
212
|
-
factor
|
|
234
|
+
factor,
|
|
213
235
|
maxAttempts,
|
|
214
236
|
jitter: config?.jitter ?? fallback.jitter,
|
|
215
237
|
};
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* internal refactor and must never leak a store's own code.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { REDACTED, SdkError, createRedactor } from '@chipmobilesdk/rn-core';
|
|
10
|
+
|
|
11
|
+
const identifiers = createRedactor();
|
|
12
|
+
|
|
9
13
|
export const PAYMENT_ERROR_CODES = [
|
|
10
14
|
'PAYMENT_NOT_CONFIGURED',
|
|
11
15
|
'PAYMENT_INVALID_CATALOG',
|
|
@@ -31,6 +35,11 @@ export const PAYMENT_ERROR_CODES = [
|
|
|
31
35
|
'PAYMENT_STORAGE_UNAVAILABLE',
|
|
32
36
|
/** `consume` on a transaction that is not a consumable, added in 0.3.2. */
|
|
33
37
|
'PAYMENT_NOT_CONSUMABLE',
|
|
38
|
+
/**
|
|
39
|
+
* A timeout, backoff or interval outside its bounds, added in 0.5.0. These
|
|
40
|
+
* used to be reported as `PAYMENT_INVALID_CATALOG`, which is about products.
|
|
41
|
+
*/
|
|
42
|
+
'PAYMENT_INVALID_CONFIG',
|
|
34
43
|
] as const;
|
|
35
44
|
|
|
36
45
|
export type PaymentErrorCode = (typeof PAYMENT_ERROR_CODES)[number];
|
|
@@ -49,10 +58,9 @@ export type PaymentOperation =
|
|
|
49
58
|
| 'validate'
|
|
50
59
|
| 'persist';
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-payment'`. */
|
|
62
|
+
export class PaymentError extends SdkError<PaymentErrorCode> {
|
|
54
63
|
readonly operation: PaymentOperation;
|
|
55
|
-
readonly retryable: boolean;
|
|
56
64
|
/** Redacted where appropriate — never a raw receipt, token, or store object (FR-074). */
|
|
57
65
|
readonly cause?: string;
|
|
58
66
|
|
|
@@ -63,11 +71,13 @@ export class PaymentError extends Error {
|
|
|
63
71
|
retryable?: boolean;
|
|
64
72
|
cause?: string;
|
|
65
73
|
}) {
|
|
66
|
-
super(init.
|
|
74
|
+
super('rn-payment', init.code, {
|
|
75
|
+
message: init.message,
|
|
76
|
+
retryable: init.retryable ?? false,
|
|
77
|
+
redactedCause: init.cause,
|
|
78
|
+
});
|
|
67
79
|
this.name = 'PaymentError';
|
|
68
|
-
this.code = init.code;
|
|
69
80
|
this.operation = init.operation;
|
|
70
|
-
this.retryable = init.retryable ?? false;
|
|
71
81
|
this.cause = init.cause;
|
|
72
82
|
}
|
|
73
83
|
}
|
|
@@ -107,11 +117,8 @@ export class PaymentConfigError extends PaymentError {
|
|
|
107
117
|
* purchase token or an account id and nothing here can tell which.
|
|
108
118
|
*/
|
|
109
119
|
export function redact(value: unknown): string {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return value.name;
|
|
115
|
-
}
|
|
116
|
-
return '[redacted]';
|
|
120
|
+
// An SDK error reduces to its code and any other Error to its class name
|
|
121
|
+
// (rn-core's identifier-only redactor). Strings never survive here, not even
|
|
122
|
+
// a leading constant: a store message is free text from a payments SDK.
|
|
123
|
+
return value instanceof Error ? identifiers.text(value) : REDACTED;
|
|
117
124
|
}
|
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
* obfuscated account identifier, and the cache-scope token.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import {
|
|
14
|
+
createDiagnosticChannel,
|
|
15
|
+
createEmitter as createCoreEmitter,
|
|
16
|
+
} from '@chipmobilesdk/rn-core';
|
|
17
|
+
|
|
18
|
+
/** Every delivered event also goes to the app's SDK sinks (`installSdkSink`, CORE-08). */
|
|
19
|
+
const channel = createDiagnosticChannel<PaymentEvent['type'] | 'listener_reporter_failed'>(
|
|
20
|
+
'rn-payment',
|
|
21
|
+
);
|
|
22
|
+
|
|
13
23
|
import { redact, type PaymentErrorCode, type PaymentOperation } from './errors';
|
|
14
24
|
import type {
|
|
15
25
|
EntitlementKey,
|
|
@@ -188,49 +198,58 @@ export interface PaymentEmitter {
|
|
|
188
198
|
export function createEmitter(
|
|
189
199
|
onListenerError?: (error: unknown, event: PaymentEvent) => void,
|
|
190
200
|
): PaymentEmitter {
|
|
191
|
-
|
|
201
|
+
// The event whose listeners are running, so a failure is reported against it.
|
|
202
|
+
let dispatching: PaymentEvent | null = null;
|
|
192
203
|
let isDisposed = false;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// delivery must not mutate the set being iterated.
|
|
200
|
-
for (const listener of [...listeners]) {
|
|
204
|
+
// rn-core's emitter: a snapshot per emit, each listener isolated, idempotent
|
|
205
|
+
// unsubscribe. Even the error reporter is isolated: if it throws, rn-core
|
|
206
|
+
// rethrows that on a fresh task rather than into the emit.
|
|
207
|
+
const emitter = createCoreEmitter<PaymentEvent>({
|
|
208
|
+
onListenerError: error => {
|
|
209
|
+
if (!dispatching) return;
|
|
201
210
|
try {
|
|
202
|
-
|
|
203
|
-
} catch
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
onListenerError?.(error, dispatching);
|
|
212
|
+
} catch {
|
|
213
|
+
// The app's own reporter failed: nothing in the app is left to tell, so
|
|
214
|
+
// the failure goes to the SDK sinks rather than becoming an uncaught
|
|
215
|
+
// error on some later tick (FR-080).
|
|
216
|
+
channel.emit('listener_reporter_failed', 'error', {
|
|
217
|
+
eventType: dispatching.type,
|
|
218
|
+
});
|
|
209
219
|
}
|
|
210
|
-
}
|
|
211
|
-
};
|
|
220
|
+
},
|
|
221
|
+
});
|
|
212
222
|
|
|
213
223
|
return {
|
|
214
|
-
emit
|
|
215
|
-
subscribe(listener: PaymentEventListener): Unsubscribe {
|
|
224
|
+
emit(event) {
|
|
216
225
|
if (isDisposed) {
|
|
217
|
-
return
|
|
226
|
+
return;
|
|
218
227
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (
|
|
223
|
-
|
|
228
|
+
const previous = dispatching;
|
|
229
|
+
dispatching = event;
|
|
230
|
+
try {
|
|
231
|
+
if (emitter.emit(event)) {
|
|
232
|
+
// The type and the cycle only: payment events carry product and
|
|
233
|
+
// entitlement keys an app may treat as personal.
|
|
234
|
+
channel.emit(
|
|
235
|
+
event.type,
|
|
236
|
+
/failed|error|blocked|rejected|revoked/.test(event.type) ? 'warn' : 'info',
|
|
237
|
+
undefined,
|
|
238
|
+
event.correlationId,
|
|
239
|
+
);
|
|
224
240
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
241
|
+
} finally {
|
|
242
|
+
dispatching = previous;
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
subscribe(listener: PaymentEventListener): Unsubscribe {
|
|
246
|
+
return isDisposed ? () => undefined : emitter.subscribe(listener);
|
|
228
247
|
},
|
|
229
248
|
dispose(): void {
|
|
230
249
|
isDisposed = true;
|
|
231
|
-
|
|
250
|
+
emitter.dispose();
|
|
232
251
|
},
|
|
233
|
-
listenerCount: () =>
|
|
252
|
+
listenerCount: () => emitter.listenerCount(),
|
|
234
253
|
disposed: () => isDisposed,
|
|
235
254
|
};
|
|
236
255
|
}
|
|
@@ -238,7 +257,7 @@ export function createEmitter(
|
|
|
238
257
|
/**
|
|
239
258
|
* Fields that must never appear in an event payload.
|
|
240
259
|
*
|
|
241
|
-
* Used by `__tests__/
|
|
260
|
+
* Used by `__tests__/diagnostics.test.ts` to walk every emitted event across the
|
|
242
261
|
* whole suite. Enumerating the ban here rather than in the test keeps the rule
|
|
243
262
|
* next to the type it constrains.
|
|
244
263
|
*/
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/** Copy an end user may reasonably be shown. */
|
|
17
|
+
import { defineMessages } from '@chipmobilesdk/rn-core';
|
|
18
|
+
|
|
17
19
|
export interface PaymentUserMessageMap {
|
|
18
20
|
'payment.state.pending': string;
|
|
19
21
|
'payment.state.alreadyOwned': string;
|
|
@@ -68,20 +70,24 @@ export const DEFAULT_DEVELOPER_MESSAGES: PaymentDeveloperMessageMap = {
|
|
|
68
70
|
'Purchase blocked: this environment may not create a real transaction',
|
|
69
71
|
};
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The catalog as an rn-core message set. Register `paymentMessages.user` with
|
|
75
|
+
* rn-i18n to translate it like app copy; the developer copy stays out of bundles.
|
|
76
|
+
*/
|
|
77
|
+
export const paymentMessages = defineMessages({
|
|
78
|
+
user: DEFAULT_USER_MESSAGES,
|
|
79
|
+
developer: DEFAULT_DEVELOPER_MESSAGES,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const DEFAULT_MESSAGES: PaymentMessageMap = paymentMessages.defaults;
|
|
75
83
|
|
|
76
84
|
/** Every user-actionable key, for an app building its translation resource. */
|
|
77
|
-
export const USER_MESSAGE_KEYS =
|
|
85
|
+
export const USER_MESSAGE_KEYS = paymentMessages.userKeys as PaymentUserMessageKey[];
|
|
78
86
|
|
|
79
87
|
/** Every developer-facing key, so an app can exclude them from user-visible bundles. */
|
|
80
|
-
export const DEVELOPER_MESSAGE_KEYS =
|
|
81
|
-
DEFAULT_DEVELOPER_MESSAGES,
|
|
82
|
-
) as PaymentDeveloperMessageKey[];
|
|
88
|
+
export const DEVELOPER_MESSAGE_KEYS = paymentMessages.developerKeys as PaymentDeveloperMessageKey[];
|
|
83
89
|
|
|
84
90
|
/** True for keys safe to show an end user. Lets an app assert that at its boundary. */
|
|
85
91
|
export function isUserMessageKey(key: PaymentMessageKey): key is PaymentUserMessageKey {
|
|
86
|
-
return key
|
|
92
|
+
return paymentMessages.audienceOf(key) === 'user';
|
|
87
93
|
}
|