@chipmobilesdk/rn-payment 0.4.1 → 0.5.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 +141 -0
- package/README.md +24 -1
- package/package.json +5 -2
- package/skills/sdk-rn-payment/SKILL.md +4 -2
- package/src/adapter/iap-adapter.ts +35 -38
- package/src/core/mutex.ts +9 -110
- package/src/core/retry.ts +32 -70
- package/src/core/timers.ts +40 -18
- package/src/diagnostics/errors.ts +20 -13
- package/src/diagnostics/events.ts +50 -31
- package/src/diagnostics/messageKeys.ts +15 -9
- package/src/engine/engine.ts +35 -66
- package/src/engine/foreground.ts +29 -0
- package/src/entitlements/aggregate.ts +34 -0
- package/src/index.ts +3 -0
- package/src/persistence/mmkv-store.ts +35 -32
- package/src/react/PaymentProvider.tsx +13 -0
- package/src/react/hooks.ts +10 -31
- package/src/testing/conformance.ts +169 -0
- package/src/testing/fakeStore.ts +11 -29
- package/src/testing/index.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,146 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-payment
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- One diagnostic stream: install a sink with `installSdkSink` from
|
|
8
|
+
`@chipmobilesdk/rn-core` and it receives what every package observes (events,
|
|
9
|
+
failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
|
|
10
|
+
rn-core, so a listener that throws no longer stops the others nor fails the call that
|
|
11
|
+
notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
12
|
+
|
|
13
|
+
- rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
|
|
14
|
+
events are no longer deduplicated away; the dedupe memory is bounded. A throwing
|
|
15
|
+
status, consent or placement listener is reported as an `internal_error` event.
|
|
16
|
+
- rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
|
|
17
|
+
with a stable `code`); without it, warnings go to `console.warn` in development
|
|
18
|
+
builds only. The warn-once memory is bounded.
|
|
19
|
+
- rn-chart: now depends on rn-core for its diagnostics.
|
|
20
|
+
- rn-payment: status, entitlement and pending listeners report a throw to the logger
|
|
21
|
+
instead of swallowing it.
|
|
22
|
+
|
|
23
|
+
- The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
|
|
24
|
+
dependency: `isSdkError` recognises them alongside every other package's, and each
|
|
25
|
+
carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
26
|
+
|
|
27
|
+
- rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
|
|
28
|
+
deprecated alias.
|
|
29
|
+
- rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
|
|
30
|
+
e-mail addresses and UUIDs.
|
|
31
|
+
- rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
|
|
32
|
+
to its code.
|
|
33
|
+
- rn-ai: an error message quoted from the gateway is pattern-redacted.
|
|
34
|
+
- rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
|
|
35
|
+
- rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
|
|
36
|
+
`code: 'contrast'` (new `ThemeErrorCode` type).
|
|
37
|
+
- rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
|
|
38
|
+
`code: 'unit_conversion'` (new `I18nErrorCode` type).
|
|
39
|
+
|
|
40
|
+
- Timers, retries and bounds now come from `@chipmobilesdk/rn-core`, and the drift
|
|
41
|
+
between the copies is fixed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
42
|
+
|
|
43
|
+
- rn-ads: a retry policy out of bounds throws `retry_out_of_bounds`, as documented,
|
|
44
|
+
instead of `timeout_out_of_bounds`; a placement's `minRequestIntervalMs` out of
|
|
45
|
+
bounds throws `invalid_config`.
|
|
46
|
+
- rn-payment: backoff validates `initialDelayMs` (100 ms to 60 s, not above
|
|
47
|
+
`maxDelayMs`) and `factor` (1 to 10); `factor: 0` used to retry at once, forever.
|
|
48
|
+
Every timeout, backoff or interval out of bounds now throws the new
|
|
49
|
+
`PAYMENT_INVALID_CONFIG` instead of `PAYMENT_INVALID_CATALOG`, which is about
|
|
50
|
+
products.
|
|
51
|
+
- rn-ai: concurrent calls share one behavior-config request, so a cold start makes
|
|
52
|
+
one instead of one per call. A silent retry after a config reload waits
|
|
53
|
+
`recovery.retryBackoffMs`, which was parsed but never applied.
|
|
54
|
+
|
|
55
|
+
- Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
|
|
56
|
+
and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
57
|
+
|
|
58
|
+
- rn-auth: creating `auth` no longer loads react-native-app-auth or
|
|
59
|
+
react-native-keychain; each loads on first use. New entrypoints
|
|
60
|
+
`@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
|
|
61
|
+
rn-core.
|
|
62
|
+
- rn-camera: a preview whose VisionCamera feed cannot load fails the session with
|
|
63
|
+
`cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
|
|
64
|
+
showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
|
|
65
|
+
- rn-notification: `createMmkvStore` reports a fallback to memory as
|
|
66
|
+
`STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
|
|
67
|
+
exposes `durable`.
|
|
68
|
+
- rn-payment: an iOS app validates its catalog against the iOS product ids; the
|
|
69
|
+
default platform used to be Android everywhere. A missing react-native-iap is
|
|
70
|
+
`PAYMENT_UNAVAILABLE`.
|
|
71
|
+
- rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
|
|
72
|
+
importing the package no longer loads Unistyles; `applyThemeMode` loads it on
|
|
73
|
+
first use.
|
|
74
|
+
- rn-ads, rn-local-db: the same loading, with the same error codes as before.
|
|
75
|
+
|
|
76
|
+
- Hooks read through `useSyncExternalStore` (rn-core's `useExternalStore`) and
|
|
77
|
+
re-render only when what they return changes. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
78
|
+
|
|
79
|
+
- rn-auth: `useProfile` and `useAccessTier` no longer re-render for a status change,
|
|
80
|
+
and the provider's context no longer changes on every transition. New
|
|
81
|
+
`useAuthSelector`. `auth.getState()` returns the same frozen object until the state
|
|
82
|
+
changes, instead of a copy per call.
|
|
83
|
+
- rn-payment: `useEntitlement` re-renders only when its own entitlement changes; the
|
|
84
|
+
engine keeps unchanged entitlement objects across syncs and does not notify for a
|
|
85
|
+
sync that changed nothing.
|
|
86
|
+
- rn-ads: `usePlacementState` and `useAdsStatus` read tear-free; `useAdsEvents` keeps
|
|
87
|
+
one subscription when given an inline listener.
|
|
88
|
+
- rn-i18n: `useTranslation` returns the same `t` until the locale changes, and
|
|
89
|
+
`useLocale`'s `setLocale` is stable.
|
|
90
|
+
- rn-ai: new `useAiBehaviorState`, with `client.subscribe` and a stable
|
|
91
|
+
`client.getBehaviorState()`.
|
|
92
|
+
|
|
93
|
+
- Every package's own copy is an rn-core message catalog that an app can translate. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
94
|
+
|
|
95
|
+
- rn-i18n: new `runtimeEntriesFromCatalog(catalog)` registers any SDK catalog in one
|
|
96
|
+
line, converting `{name}` variables to `{{name}}`.
|
|
97
|
+
- rn-ai: new `messageKeyForError(error)` gives the copy for every error code, and
|
|
98
|
+
`aiMessageSet`. The input placeholder default is neutral ("Type a message…"), and
|
|
99
|
+
two keys were added for errors that had none (`ai.error.signIn`, `ai.error.generic`).
|
|
100
|
+
- rn-auth: new `authMessages`, the catalog split into user and developer copy.
|
|
101
|
+
- rn-payment: new `paymentMessages`.
|
|
102
|
+
- rn-chart: summaries, announcements, adjustable-action labels, built-in state text
|
|
103
|
+
and legend hints come from `chartStrings`; pass `strings` to translate them. The
|
|
104
|
+
`accessibility.summaryFormatter` prop, declared before, now replaces the summary.
|
|
105
|
+
|
|
106
|
+
- Port conformance suites: one list of checks that a package's fake and its real adapter
|
|
107
|
+
both pass, so a test written against the fake proves something about the device. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
108
|
+
|
|
109
|
+
- rn-core: `@chipmobilesdk/rn-core/testing` adds `runChecks`, `expectConformance`,
|
|
110
|
+
`ensure` and `createFaultRegistry` (deterministic fault injection by named point).
|
|
111
|
+
- rn-camera: a `./testing` entrypoint with `runPermissionPortConformance`,
|
|
112
|
+
`runCameraEngineConformance` and the test adapter, which stays on the main
|
|
113
|
+
entrypoint too until the next major. `createTestCamera(...).prompts()` counts system
|
|
114
|
+
prompts.
|
|
115
|
+
- rn-payment: `runProviderPortConformance` in `./testing`, passed by the fake store and
|
|
116
|
+
the Play adapter.
|
|
117
|
+
- rn-ads: `runAdsEngineConformance` in `./testing`, passed by the fake engine and the
|
|
118
|
+
AdMob adapter.
|
|
119
|
+
- rn-auth: a `./testing` entrypoint with `createMemorySecureStore`,
|
|
120
|
+
`runSecureTokenStoreConformance` and `runOidcAuthClientConformance`, passed by the
|
|
121
|
+
in-memory doubles and the keychain and app-auth adapters.
|
|
122
|
+
|
|
123
|
+
- One app-lifecycle source, and a startup that needs no extra frame. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
124
|
+
|
|
125
|
+
- rn-core: `appLifecycle()`, a shared, reference-counted `AppState` subscription that
|
|
126
|
+
reports changes only (`active`, `inactive`, `background`), with `onForeground` for a
|
|
127
|
+
return from the background and `createManualLifecycle` in `./testing`.
|
|
128
|
+
- rn-camera, rn-ads and rn-notification hear the lifecycle through it instead of
|
|
129
|
+
subscribing to `AppState` each, so an app holds one native listener. The camera
|
|
130
|
+
controller's core layer no longer imports `react-native`.
|
|
131
|
+
- rn-payment: `syncOnForeground(engine)` and `<PaymentProvider syncOnForeground>` call
|
|
132
|
+
`handleForeground()` on each return from the background. Off by default; calling
|
|
133
|
+
`handleForeground()` yourself works as before.
|
|
134
|
+
- rn-i18n: `i18n.initSync()` resolves the locale before the first render when the
|
|
135
|
+
storage adapter has the new optional `getItemSync`, and returns false otherwise.
|
|
136
|
+
`localeStorageFromKeyValue(port)` builds such an adapter from rn-core's
|
|
137
|
+
`KeyValuePort` (MMKV).
|
|
138
|
+
|
|
139
|
+
### Patch Changes
|
|
140
|
+
|
|
141
|
+
- Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
|
|
142
|
+
- @chipmobilesdk/rn-core@0.1.0
|
|
143
|
+
|
|
3
144
|
## 0.4.1
|
|
4
145
|
|
|
5
146
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -192,6 +192,13 @@ what "foreground" means for an app that may be backgrounded by its own modal, a
|
|
|
192
192
|
biometric prompt, or the store's own purchase sheet. It is rate-limited
|
|
193
193
|
regardless, so calling it liberally is safe.
|
|
194
194
|
|
|
195
|
+
If "back from the background" is what you mean, opt in instead of wiring `AppState`:
|
|
196
|
+
`<PaymentProvider syncOnForeground>` or `syncOnForeground(engine)` (returns an
|
|
197
|
+
unsubscribe). Both listen through rn-core's shared lifecycle port, so they add no
|
|
198
|
+
native subscription of their own, and a trip through `inactive` alone does not count.
|
|
199
|
+
On Android the Play purchase sheet does background the app, so a purchase is followed
|
|
200
|
+
by one rate-limited sync.
|
|
201
|
+
|
|
195
202
|
---
|
|
196
203
|
|
|
197
204
|
## Catalog and entitlement mapping
|
|
@@ -604,7 +611,9 @@ import { DEFAULT_USER_MESSAGES, USER_MESSAGE_KEYS, isUserMessageKey } from '@chi
|
|
|
604
611
|
|
|
605
612
|
User-actionable and developer copy are **separate maps**, so you cannot
|
|
606
613
|
accidentally show "Payment catalog is invalid" to a customer. `isUserMessageKey()`
|
|
607
|
-
lets you assert that at your own boundary.
|
|
614
|
+
lets you assert that at your own boundary. `paymentMessages` is the same catalog as
|
|
615
|
+
an rn-core message set; register the user copy with rn-i18n in one line:
|
|
616
|
+
`runtimeEntriesFromCatalog(paymentMessages.user)`.
|
|
608
617
|
|
|
609
618
|
Message keys are public API; renaming one is a breaking change.
|
|
610
619
|
|
|
@@ -612,6 +621,9 @@ Message keys are public API; renaming one is a breaking change.
|
|
|
612
621
|
|
|
613
622
|
## Events and errors
|
|
614
623
|
|
|
624
|
+
Every error is a `PaymentError` (or `PaymentConfigError`) with a stable `code`, the
|
|
625
|
+
`operation` and a `retryable` flag. `PaymentError` extends `SdkError` from `@chipmobilesdk/rn-core`, so `isSdkError(error)` recognises it alongside every other package's errors, `error.pkg` names this package, and `error.redactedCause` (when present) is the underlying failure reduced to log-safe text.
|
|
626
|
+
|
|
615
627
|
20 event types, each carrying a timestamp and — within a purchase cycle — one
|
|
616
628
|
`correlationId`. The bar is that the event stream **alone** is enough to
|
|
617
629
|
reconstruct a transaction's lifecycle, so "the user says they paid but has no
|
|
@@ -647,6 +659,11 @@ The failure goes to your `logger`, not back into the event stream.
|
|
|
647
659
|
Errors carry a stable `code`, a log-safe `message`, the `operation`, a `retryable`
|
|
648
660
|
flag, and a redacted `cause`. Error codes are public API.
|
|
649
661
|
|
|
662
|
+
A timeout, backoff or interval outside its bounds throws `PaymentConfigError` with
|
|
663
|
+
`PAYMENT_INVALID_CONFIG`, naming the field in `offenders`. Backoff checks every
|
|
664
|
+
field: `initialDelayMs` 100 ms to 60 s and not above `maxDelayMs`, `factor` 1 to
|
|
665
|
+
10, `maxDelayMs` 5 s to 10 min, `maxAttempts` 1 to 10.
|
|
666
|
+
|
|
650
667
|
---
|
|
651
668
|
|
|
652
669
|
## Wiring an entitlement to another system
|
|
@@ -710,6 +727,12 @@ store.pushPurchaseUpdate(...); // deliver a settlement with no sync
|
|
|
710
727
|
`./testing` is a separate entrypoint so test doubles cannot reach a production
|
|
711
728
|
bundle.
|
|
712
729
|
|
|
730
|
+
`runProviderPortConformance(harness)` holds the checks every store adapter must pass:
|
|
731
|
+
connection and a dropped service, products with the store's own price, pending
|
|
732
|
+
purchases flagged rather than filtered, a bought and a cancelled flow, and
|
|
733
|
+
finalization that is safe twice. The package runs it against the fake store and the
|
|
734
|
+
Play adapter; an iOS adapter, or a device run from a demo screen, uses the same suite.
|
|
735
|
+
|
|
713
736
|
### Licence testers, for device verification
|
|
714
737
|
|
|
715
738
|
1. Play Console → **Setup → Licence testing**, add tester accounts.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-payment",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"description": "Reusable in-app payment and entitlement capability for ChipMobileSdk React Native apps",
|
|
@@ -27,9 +27,12 @@
|
|
|
27
27
|
],
|
|
28
28
|
"sideEffects": false,
|
|
29
29
|
"scripts": {
|
|
30
|
-
"typecheck": "tsc -p tsconfig.json
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
|
|
31
31
|
"pack:check": "npm pack --dry-run"
|
|
32
32
|
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@chipmobilesdk/rn-core": "^0.1.0"
|
|
35
|
+
},
|
|
33
36
|
"peerDependencies": {
|
|
34
37
|
"react": ">=19",
|
|
35
38
|
"react-native": ">=0.85",
|
|
@@ -91,6 +91,8 @@ Unlike `@chipmobilesdk/rn-ads`, there is **no test-id substitution**: Play has n
|
|
|
91
91
|
|
|
92
92
|
Call `handleForeground()` yourself on app resume — the package will not guess what
|
|
93
93
|
"foreground" means for a host backgrounded by its own modal or the store's purchase sheet.
|
|
94
|
+
When a return from the background is enough, opt in with `<PaymentProvider
|
|
95
|
+
syncOnForeground>` or `syncOnForeground(engine)` (rn-core's shared lifecycle port).
|
|
94
96
|
|
|
95
97
|
## Public API
|
|
96
98
|
|
|
@@ -103,8 +105,8 @@ environment; everything else returns a typed result) · `createStoreProvider` ·
|
|
|
103
105
|
`DEFAULT_USER_MESSAGES`, `DEFAULT_DEVELOPER_MESSAGES`, `USER_MESSAGE_KEYS`,
|
|
104
106
|
`DEVELOPER_MESSAGE_KEYS`, `isUserMessageKey` · timeout/retry/cache defaults and bounds
|
|
105
107
|
(`DEFAULT_STALE_AFTER_MS`, `DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS`, `STALE_AFTER_BOUNDS`, …)
|
|
106
|
-
· `createFakeStore`, `makePurchase` from
|
|
107
|
-
doubles only).
|
|
108
|
+
· `createFakeStore`, `makePurchase`, `runProviderPortConformance` from
|
|
109
|
+
`@chipmobilesdk/rn-payment/testing` (test doubles and the store-adapter suite only).
|
|
108
110
|
|
|
109
111
|
Engine: `initialize`, `cleanup`, `getStatus`, `subscribeStatus`, `getEntitlement`,
|
|
110
112
|
`getAllEntitlements`, `subscribeEntitlements`, `getPendingPurchases`,
|
|
@@ -36,9 +36,12 @@
|
|
|
36
36
|
* shortcut.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
import { createListenerSet, lazyPeer } from '@chipmobilesdk/rn-core';
|
|
40
|
+
|
|
39
41
|
import type { RequestPurchaseProps, SubscriptionReplacementModeAndroid } from 'react-native-iap';
|
|
40
42
|
|
|
41
43
|
import type { ProductType, Unsubscribe } from '../config/types';
|
|
44
|
+
import { PaymentError, redact } from '../diagnostics/errors';
|
|
42
45
|
import type {
|
|
43
46
|
ProviderCapabilities,
|
|
44
47
|
ProviderConnectOutcome,
|
|
@@ -194,10 +197,27 @@ export function toRequestProps(request: ProviderPurchaseRequest): RequestPurchas
|
|
|
194
197
|
const PLAY_STATE_PURCHASED = 1;
|
|
195
198
|
const PLAY_STATE_PENDING = 2;
|
|
196
199
|
|
|
200
|
+
// Required lazily (rn-core's lazy peer) so importing the package does not require
|
|
201
|
+
// the native module.
|
|
202
|
+
const iapPeer = lazyPeer('react-native-iap', () => {
|
|
203
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
204
|
+
try {
|
|
205
|
+
return require('react-native-iap') as IapModule;
|
|
206
|
+
} catch (cause) {
|
|
207
|
+
throw cause;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
197
211
|
function loadModule(): IapModule {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
212
|
+
return iapPeer.require(
|
|
213
|
+
(peer, cause) =>
|
|
214
|
+
new PaymentError({
|
|
215
|
+
code: 'PAYMENT_UNAVAILABLE',
|
|
216
|
+
message: `${peer} is not installed or its native module is not linked`,
|
|
217
|
+
operation: 'initialize',
|
|
218
|
+
cause: redact(cause),
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
201
221
|
}
|
|
202
222
|
|
|
203
223
|
function toMicros(value: string | number | undefined): number {
|
|
@@ -380,10 +400,10 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
380
400
|
/** Tracked here, never read from the binding — see the openiap#408 note above. */
|
|
381
401
|
let connected = false;
|
|
382
402
|
|
|
383
|
-
const connectionListeners =
|
|
384
|
-
const purchaseListeners =
|
|
403
|
+
const connectionListeners = createListenerSet<[boolean, string | undefined]>();
|
|
404
|
+
const purchaseListeners = createListenerSet<[ProviderPurchase]>();
|
|
385
405
|
/** Only an in-flight `launchPurchase` listens here; see there. */
|
|
386
|
-
const purchaseErrorListeners =
|
|
406
|
+
const purchaseErrorListeners = createListenerSet<[RawPurchaseError]>();
|
|
387
407
|
let updateSub: { remove(): void } | undefined;
|
|
388
408
|
let errorSub: { remove(): void } | undefined;
|
|
389
409
|
|
|
@@ -399,9 +419,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
399
419
|
return;
|
|
400
420
|
}
|
|
401
421
|
connected = next;
|
|
402
|
-
|
|
403
|
-
listener(next, reason);
|
|
404
|
-
}
|
|
422
|
+
connectionListeners.notify(next, reason);
|
|
405
423
|
};
|
|
406
424
|
|
|
407
425
|
const attachListeners = (): void => {
|
|
@@ -411,10 +429,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
411
429
|
detachListeners();
|
|
412
430
|
const mod = module();
|
|
413
431
|
updateSub = mod.purchaseUpdatedListener(raw => {
|
|
414
|
-
|
|
415
|
-
for (const listener of purchaseListeners) {
|
|
416
|
-
listener(purchase);
|
|
417
|
-
}
|
|
432
|
+
purchaseListeners.notify(normalizePurchase(raw));
|
|
418
433
|
});
|
|
419
434
|
errorSub = mod.purchaseErrorListener(error => {
|
|
420
435
|
// A `not-prepared` error is the one signal the binding gives that the
|
|
@@ -423,9 +438,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
423
438
|
if (mapErrorCode(error.code) === 'not_ready') {
|
|
424
439
|
notifyConnection(false, 'not_prepared');
|
|
425
440
|
}
|
|
426
|
-
|
|
427
|
-
listener(error);
|
|
428
|
-
}
|
|
441
|
+
purchaseErrorListeners.notify(error);
|
|
429
442
|
});
|
|
430
443
|
};
|
|
431
444
|
|
|
@@ -486,15 +499,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
486
499
|
},
|
|
487
500
|
|
|
488
501
|
observeConnection(listener): Unsubscribe {
|
|
489
|
-
connectionListeners.add(listener);
|
|
490
|
-
let removed = false;
|
|
491
|
-
return () => {
|
|
492
|
-
if (removed) {
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
removed = true;
|
|
496
|
-
connectionListeners.delete(listener);
|
|
497
|
-
};
|
|
502
|
+
return connectionListeners.add(listener);
|
|
498
503
|
},
|
|
499
504
|
|
|
500
505
|
async queryProducts(
|
|
@@ -551,13 +556,13 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
551
556
|
}
|
|
552
557
|
settled = true;
|
|
553
558
|
clearTimeout(grace);
|
|
554
|
-
|
|
555
|
-
|
|
559
|
+
stopPurchase();
|
|
560
|
+
stopError();
|
|
556
561
|
resolve(outcome);
|
|
557
562
|
}
|
|
558
563
|
|
|
559
|
-
purchaseListeners.add(onPurchase);
|
|
560
|
-
purchaseErrorListeners.add(onError);
|
|
564
|
+
const stopPurchase = purchaseListeners.add(onPurchase);
|
|
565
|
+
const stopError = purchaseErrorListeners.add(onError);
|
|
561
566
|
try {
|
|
562
567
|
module()
|
|
563
568
|
.requestPurchase(toRequestProps(request))
|
|
@@ -581,15 +586,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
581
586
|
},
|
|
582
587
|
|
|
583
588
|
observePurchases(listener): Unsubscribe {
|
|
584
|
-
purchaseListeners.add(listener);
|
|
585
|
-
let removed = false;
|
|
586
|
-
return () => {
|
|
587
|
-
if (removed) {
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
removed = true;
|
|
591
|
-
purchaseListeners.delete(listener);
|
|
592
|
-
};
|
|
589
|
+
return purchaseListeners.add(listener);
|
|
593
590
|
},
|
|
594
591
|
|
|
595
592
|
async acknowledge(token: ProviderToken): Promise<void> {
|
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,6 +9,12 @@
|
|
|
9
9
|
* schedule without burning real time.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import {
|
|
13
|
+
computeBackoff,
|
|
14
|
+
runWithRetry as runCoreRetry,
|
|
15
|
+
type RetryHandle,
|
|
16
|
+
} from '@chipmobilesdk/rn-core';
|
|
17
|
+
|
|
12
18
|
import type { BackoffConfig } from '../config/types';
|
|
13
19
|
|
|
14
20
|
/** Opaque timer handle: a Node Timeout and a browser numeric id are both valid. */
|
|
@@ -25,9 +31,17 @@ export function backoffDelayMs(
|
|
|
25
31
|
config: Required<BackoffConfig>,
|
|
26
32
|
random: () => number = Math.random,
|
|
27
33
|
): number {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
return computeBackoff(attempt, toSpec(config), random);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toSpec(config: Required<BackoffConfig>) {
|
|
38
|
+
return {
|
|
39
|
+
initialDelayMs: config.initialDelayMs,
|
|
40
|
+
maxDelayMs: config.maxDelayMs,
|
|
41
|
+
factor: config.factor,
|
|
42
|
+
jitter: config.jitter ? ('full' as const) : ('none' as const),
|
|
43
|
+
maxAttempts: config.maxAttempts,
|
|
44
|
+
};
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
/** The full schedule, for asserting a retry policy without waiting for it. */
|
|
@@ -39,14 +53,7 @@ export function backoffSchedule(config: Required<BackoffConfig>): number[] {
|
|
|
39
53
|
return delays;
|
|
40
54
|
}
|
|
41
55
|
|
|
42
|
-
export
|
|
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
|
-
}
|
|
56
|
+
export type { RetryHandle } from '@chipmobilesdk/rn-core';
|
|
50
57
|
|
|
51
58
|
export interface RetryRunnerOptions {
|
|
52
59
|
config: Required<BackoffConfig>;
|
|
@@ -65,69 +72,24 @@ export interface RetryRunnerOptions {
|
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
/**
|
|
68
|
-
* Runs `attempt` under the bounded schedule.
|
|
75
|
+
* Runs `attempt` under the bounded schedule (rn-core's runner).
|
|
69
76
|
*
|
|
70
77
|
* Exhaustion is reported rather than thrown: for finalization, a spent session
|
|
71
78
|
* 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.
|
|
79
|
+
* the only one that knows which of the two it is. A throwing attempt is a failed
|
|
80
|
+
* attempt, nothing more: letting it escape would silently drop a paid transaction.
|
|
73
81
|
*/
|
|
74
82
|
export function runWithRetry(options: RetryRunnerOptions): RetryHandle {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
random
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
122
|
-
return {
|
|
123
|
-
cancel(): void {
|
|
124
|
-
cancelled = true;
|
|
125
|
-
if (pending !== undefined) {
|
|
126
|
-
clearTimeoutFn(pending);
|
|
127
|
-
pending = undefined;
|
|
128
|
-
}
|
|
83
|
+
const setTimeoutFn = options.setTimeoutFn ?? setTimeout;
|
|
84
|
+
const clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout;
|
|
85
|
+
return runCoreRetry({
|
|
86
|
+
spec: toSpec(options.config),
|
|
87
|
+
attempt: options.attempt,
|
|
88
|
+
onExhausted: options.onExhausted,
|
|
89
|
+
random: options.random,
|
|
90
|
+
scheduler: {
|
|
91
|
+
setTimeout: (callback, ms) => setTimeoutFn(callback, ms),
|
|
92
|
+
clearTimeout: handle => clearTimeoutFn(handle as TimerHandle),
|
|
129
93
|
},
|
|
130
|
-
|
|
131
|
-
exhausted: () => attemptCount >= config.maxAttempts,
|
|
132
|
-
};
|
|
94
|
+
});
|
|
133
95
|
}
|