@mpgd/adapter-capacitor 0.8.0 → 0.9.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,6 +1,7 @@
1
1
  import { type BridgeEvent, type BridgeMethod, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import type { AdProvider } from '@mpgd/platform/ads';
3
- import { type PlatformProviderAvailability, type PlatformProviderFeature, type ProductType } from '@mpgd/platform';
3
+ import { type CommerceCapabilities, type PlatformProviderAvailability, type PlatformProviderFeature, type ProductType } from '@mpgd/platform';
4
+ import { type CommerceProvider } from '@mpgd/platform/commerce';
4
5
  export interface NativeBridge {
5
6
  request(input: BridgeRequest): Promise<BridgeResponse>;
6
7
  /**
@@ -17,17 +18,61 @@ export interface NativeBridge {
17
18
  */
18
19
  export interface CapacitorServiceProvider {
19
20
  readonly id: string;
21
+ /**
22
+ * Serves the declared `methods`. For a provider with `commerce`, it also
23
+ * serves the ten commerce bridge methods and must implement `subscribe`;
24
+ * `createCommerceServiceProvider` builds one from a `CommerceProvider`.
25
+ */
20
26
  readonly bridge: NativeBridge;
21
27
  readonly features: readonly PlatformProviderFeature[];
28
+ /**
29
+ * Declared v1 bridge methods. A provider with `commerce` declares no
30
+ * `commerce.*` method: the four v1 commerce methods and the six
31
+ * `commerce.v2.*` methods are implied and owned by the v2 provider.
32
+ */
22
33
  readonly methods: readonly BridgeMethod[];
23
34
  /** Optional native event surface. The base adapter never imports its SDK. */
24
35
  readonly adProvider?: AdProvider;
36
+ /**
37
+ * Optional commerce v2 provider (`createStoreCommerceProvider`). Its
38
+ * `getCapabilities()` is the readiness of the declared `nativeIap` and
39
+ * `subscriptionIap` features; `getAvailability` covers the other features.
40
+ */
41
+ readonly commerce?: CommerceProvider;
25
42
  getAvailability(): Promise<Readonly<Partial<Record<PlatformProviderFeature, PlatformProviderAvailability>>>>;
26
43
  }
44
+ /** The provider the registry serves commerce v2 from. */
45
+ export interface CapacitorCommerceServiceProvider extends CapacitorServiceProvider {
46
+ readonly commerce: CommerceProvider;
47
+ readonly bridge: NativeBridge & Required<Pick<NativeBridge, 'subscribe'>>;
48
+ }
49
+ export interface CapacitorProviderCapabilitySnapshot {
50
+ readonly availability: Readonly<Record<PlatformProviderFeature, PlatformProviderAvailability>>;
51
+ /**
52
+ * Present when a commerce v2 provider is registered: its validated
53
+ * `getCapabilities()` answer from the same bounded read that produced the
54
+ * `nativeIap` and `subscriptionIap` states, or a degraded block
55
+ * (`availability: 'temporarily-unavailable'`, nothing sold) when that read
56
+ * stalled, failed, or returned a shape the contract rejects.
57
+ */
58
+ readonly commerce?: CommerceCapabilities;
59
+ }
27
60
  export interface CapacitorProviderRegistry {
28
61
  readonly byMethod: ReadonlyMap<BridgeMethod, CapacitorServiceProvider>;
62
+ /** The provider that serves commerce v2, when one is registered. */
63
+ readonly commerce?: CapacitorCommerceServiceProvider;
64
+ /** Feature readiness plus the commerce block, from one bounded read per provider. */
65
+ getCapabilitySnapshot(): Promise<CapacitorProviderCapabilitySnapshot>;
29
66
  assertMethodReady(method: BridgeMethod, payload?: unknown, productType?: ProductType): Promise<void>;
30
67
  getAvailability(): Promise<Readonly<Record<PlatformProviderFeature, PlatformProviderAvailability>>>;
31
68
  }
32
69
  export declare function createCapacitorProviderRegistry(input: readonly CapacitorServiceProvider[]): CapacitorProviderRegistry;
33
70
  export declare function assertProviderResponseData(method: BridgeMethod, data: unknown): void;
71
+ /** The commerce block a gateway reports while its provider cannot be read. */
72
+ export declare function createDegradedCommerceCapabilities(): CommerceCapabilities;
73
+ /**
74
+ * Readiness of the commerce features a v2 provider declares, from its own
75
+ * capability declaration: a feature it does not sell is `unsupported`, and a
76
+ * read that failed or stalled is `temporarily-unavailable`.
77
+ */
78
+ export declare function deriveCommerceFeatureStates(capabilities: CommerceCapabilities | undefined, features: readonly PlatformProviderFeature[]): Partial<Record<PlatformProviderFeature, PlatformProviderAvailability>>;
package/dist/providers.js CHANGED
@@ -1,6 +1,8 @@
1
- import { isBridgeCommerceV2Method, } from '@mpgd/bridge';
1
+ import { bridgeCommerceV2Methods, isBridgeCommerceV2Method, } from '@mpgd/bridge';
2
2
  import { isAdMobClientRewardEvidence } from '@mpgd/game-services/admob-client-reward';
3
3
  import { PlatformOperationError, } from '@mpgd/platform';
4
+ import { createUnsupportedCommerceCapabilities, isCommerceCapabilities, } from '@mpgd/platform/commerce';
5
+ import { assertCommerceBridgeResult } from './commerce-bridge.js';
4
6
  const featureMethods = {
5
7
  nativeIap: [
6
8
  'commerce.getProducts',
@@ -23,6 +25,12 @@ const featureMethods = {
23
25
  };
24
26
  const providerFeatures = Object.keys(featureMethods);
25
27
  const providerMethodSet = new Set(Object.values(featureMethods).flat());
28
+ const commerceFeatures = ['nativeIap', 'subscriptionIap'];
29
+ const v1CommerceMethods = featureMethods.nativeIap;
30
+ const impliedCommerceMethods = [
31
+ ...v1CommerceMethods,
32
+ ...bridgeCommerceV2Methods,
33
+ ];
26
34
  const availabilitySet = new Set([
27
35
  'unsupported',
28
36
  'configuration-required',
@@ -41,12 +49,14 @@ export function createCapacitorProviderRegistry(input) {
41
49
  const byMethod = new Map();
42
50
  const byFeature = new Map();
43
51
  const ids = new Set();
52
+ let commerce;
44
53
  for (const provider of providers) {
45
54
  if (provider.id.trim() === '' || ids.has(provider.id)) {
46
55
  throw new Error(`Capacitor provider ID is empty or registered twice: ${provider.id}`);
47
56
  }
48
57
  ids.add(provider.id);
49
- if (provider.features.length === 0 || provider.methods.length === 0) {
58
+ if (provider.features.length === 0
59
+ || (provider.methods.length === 0 && provider.commerce === undefined)) {
50
60
  throw new Error(`Capacitor provider ${provider.id} must declare features and methods.`);
51
61
  }
52
62
  const methods = new Set(provider.methods);
@@ -55,24 +65,43 @@ export function createCapacitorProviderRegistry(input) {
55
65
  }
56
66
  for (const method of methods) {
57
67
  if (isBridgeCommerceV2Method(method)) {
58
- // v1 providers own the v1 commerce methods only; a v2 provider arrives
59
- // with B1 and is registered through its own surface.
68
+ // A v2 provider is registered through `commerce`; its bridge methods are implied.
60
69
  throw new Error(`Capacitor provider ${provider.id} cannot declare commerce v2 method ${method}; `
61
- + 'commerce v2 is unavailable through the provider registry.');
70
+ + 'register a v2 provider through `commerce` instead, commerce v2 is unavailable as '
71
+ + 'a declared method.');
62
72
  }
63
73
  if (!providerMethodSet.has(method)) {
64
74
  throw new Error(`Capacitor provider ${provider.id} cannot replace base method ${method}.`);
65
75
  }
76
+ if (provider.commerce !== undefined && v1CommerceMethods.includes(method)) {
77
+ throw new Error(`Capacitor provider ${provider.id} cannot declare both a commerce v2 provider and `
78
+ + `v1 method ${method}; the registry serves v1 callers from the v2 provider.`);
79
+ }
80
+ }
81
+ const implied = provider.commerce === undefined ? [] : impliedCommerceMethods;
82
+ for (const method of [...methods, ...implied]) {
66
83
  if (byMethod.has(method)) {
67
84
  throw new Error(`Capacitor bridge method ${method} has multiple providers.`);
68
85
  }
69
86
  }
87
+ const owned = new Set([...methods, ...implied]);
88
+ if (provider.commerce !== undefined) {
89
+ if (!provider.features.some((feature) => commerceFeatures.includes(feature))) {
90
+ throw new Error(`Capacitor provider ${provider.id} declares a commerce v2 provider but no commerce feature.`);
91
+ }
92
+ if (provider.bridge.subscribe === undefined) {
93
+ // Without the event surface a purchase that completes after the store
94
+ // UI closed would never reach the reconciler.
95
+ throw new Error(`Capacitor provider ${provider.id} declares a commerce v2 provider but its bridge has no `
96
+ + 'subscribe; build it with createCommerceServiceProvider.');
97
+ }
98
+ }
70
99
  for (const feature of provider.features) {
71
100
  const required = featureMethods[feature];
72
101
  if (required === undefined || byFeature.has(feature)) {
73
102
  throw new Error(`Capacitor feature ${feature} has multiple or invalid providers.`);
74
103
  }
75
- if (required.some((method) => !methods.has(method))) {
104
+ if (required.some((method) => !owned.has(method))) {
76
105
  throw new Error(`Capacitor provider ${provider.id} lacks methods for ${feature}.`);
77
106
  }
78
107
  byFeature.set(feature, provider);
@@ -80,67 +109,80 @@ export function createCapacitorProviderRegistry(input) {
80
109
  if ([...methods].some((method) => !provider.features.some((feature) => featureMethods[feature].includes(method)))) {
81
110
  throw new Error(`Capacitor provider ${provider.id} declares an unowned method.`);
82
111
  }
83
- for (const method of methods) {
112
+ for (const method of owned) {
84
113
  byMethod.set(method, provider);
85
114
  }
115
+ if (provider.commerce !== undefined) {
116
+ // Validated above: the bridge implements `subscribe`.
117
+ commerce = provider;
118
+ }
86
119
  }
87
120
  const readProviderStates = async (provider) => {
88
- let reported;
89
- let timer;
90
- try {
91
- reported = await Promise.race([
92
- Promise.resolve().then(() => provider.getAvailability()),
93
- new Promise((_resolve, reject) => {
94
- timer = setTimeout(() => reject(new Error('Provider availability timed out.')), availabilityTimeoutMs);
95
- }),
96
- ]);
97
- if (!isRecord(reported)) {
98
- throw new Error('Invalid provider availability.');
99
- }
100
- }
101
- catch {
102
- reported = {};
121
+ const states = {};
122
+ const commerceOwned = provider.commerce === undefined
123
+ ? []
124
+ : provider.features.filter((feature) => commerceFeatures.includes(feature));
125
+ const bridged = provider.features.filter((feature) => !commerceOwned.includes(feature));
126
+ let commerceBlock;
127
+ if (provider.commerce !== undefined && commerceOwned.length > 0) {
128
+ commerceBlock = await readCommerceCapabilities(provider.commerce);
129
+ Object.assign(states, deriveCommerceFeatureStates(commerceBlock, commerceOwned));
103
130
  }
104
- finally {
105
- if (timer !== undefined) {
106
- clearTimeout(timer);
131
+ if (bridged.length > 0) {
132
+ const reported = await readWithTimeout(Promise.resolve().then(() => provider.getAvailability()));
133
+ for (const feature of bridged) {
134
+ const state = isRecord(reported) ? reported[feature] : undefined;
135
+ states[feature] = availabilitySet.has(state)
136
+ ? state
137
+ : 'temporarily-unavailable';
107
138
  }
108
139
  }
109
- const states = {};
110
- for (const feature of provider.features) {
111
- const state = reported[feature];
112
- states[feature] = availabilitySet.has(state)
113
- ? state
114
- : 'temporarily-unavailable';
115
- }
116
- return states;
140
+ return { states, ...(commerceBlock === undefined ? {} : { commerce: commerceBlock }) };
117
141
  };
118
- const getAvailability = async () => {
142
+ const getCapabilitySnapshot = async () => {
119
143
  const states = {};
120
144
  for (const feature of providerFeatures) {
121
145
  states[feature] = 'unsupported';
122
146
  }
147
+ let commerceBlock;
123
148
  const snapshots = await Promise.all(providers.map(readProviderStates));
124
149
  for (const snapshot of snapshots) {
125
- Object.assign(states, snapshot);
150
+ Object.assign(states, snapshot.states);
151
+ commerceBlock = snapshot.commerce ?? commerceBlock;
126
152
  }
127
- return states;
153
+ if (commerce !== undefined && commerceBlock === undefined) {
154
+ // One bounded read serves the feature states and the block, so a stalled,
155
+ // failed, or invalid provider degrades both the same way.
156
+ commerceBlock = createDegradedCommerceCapabilities();
157
+ }
158
+ return {
159
+ availability: states,
160
+ ...(commerceBlock === undefined ? {} : { commerce: commerceBlock }),
161
+ };
128
162
  };
163
+ const getAvailability = async () => (await getCapabilitySnapshot()).availability;
129
164
  return {
130
165
  byMethod,
166
+ ...(commerce === undefined ? {} : { commerce }),
131
167
  getAvailability,
168
+ getCapabilitySnapshot,
132
169
  async assertMethodReady(method, payload, productType) {
133
170
  const provider = byMethod.get(method);
134
171
  if (provider === undefined) {
135
172
  return;
136
173
  }
174
+ if (isBridgeCommerceV2Method(method)) {
175
+ // The v2 provider reads its own `availability` and fails closed before
176
+ // any store UI opens; the registry adds no second, stale snapshot.
177
+ return;
178
+ }
137
179
  if (method === 'ads.unmountBanner') {
138
180
  // Teardown must reach the provider that mounted the banner even after
139
181
  // its serving state becomes unavailable.
140
182
  return;
141
183
  }
142
184
  // An unrelated provider must not delay a purchase, ad, or identity call.
143
- const states = await readProviderStates(provider);
185
+ const { states } = await readProviderStates(provider);
144
186
  let relevant = provider.features.filter((feature) => featureMethods[feature].includes(method));
145
187
  if (method === 'commerce.purchase' && relevant.length > 1) {
146
188
  relevant = relevant.filter((feature) => feature === (productType === 'subscription' ? 'subscriptionIap'
@@ -205,6 +247,73 @@ export function assertProviderResponseData(method, data) {
205
247
  });
206
248
  }
207
249
  }
250
+ /**
251
+ * One bounded, validated capability read. A stalled, throwing, or invalid
252
+ * provider yields `undefined`; the block is copied so the gateway never hands
253
+ * out a provider-owned object.
254
+ */
255
+ async function readCommerceCapabilities(provider) {
256
+ const reported = await readWithTimeout(Promise.resolve().then(() => provider.getCapabilities()));
257
+ if (!isCommerceCapabilities(reported)) {
258
+ return undefined;
259
+ }
260
+ return {
261
+ purchase: { ...reported.purchase },
262
+ catalog: { ...reported.catalog },
263
+ lifecycle: { ...reported.lifecycle },
264
+ authority: { ...reported.authority },
265
+ availability: reported.availability,
266
+ };
267
+ }
268
+ /** The commerce block a gateway reports while its provider cannot be read. */
269
+ export function createDegradedCommerceCapabilities() {
270
+ return { ...createUnsupportedCommerceCapabilities(), availability: 'temporarily-unavailable' };
271
+ }
272
+ async function readWithTimeout(read) {
273
+ let timer;
274
+ try {
275
+ return await Promise.race([
276
+ read,
277
+ new Promise((_resolve, reject) => {
278
+ timer = setTimeout(() => reject(new Error('Provider availability timed out.')), availabilityTimeoutMs);
279
+ }),
280
+ ]);
281
+ }
282
+ catch {
283
+ return undefined;
284
+ }
285
+ finally {
286
+ if (timer !== undefined) {
287
+ clearTimeout(timer);
288
+ }
289
+ }
290
+ }
291
+ /**
292
+ * Readiness of the commerce features a v2 provider declares, from its own
293
+ * capability declaration: a feature it does not sell is `unsupported`, and a
294
+ * read that failed or stalled is `temporarily-unavailable`.
295
+ */
296
+ export function deriveCommerceFeatureStates(capabilities, features) {
297
+ const states = {};
298
+ const availability = capabilities === undefined
299
+ || !availabilitySet.has(capabilities.availability)
300
+ ? 'temporarily-unavailable'
301
+ : capabilities.availability;
302
+ for (const feature of features) {
303
+ if (feature === 'nativeIap') {
304
+ states.nativeIap = capabilities !== undefined
305
+ && !(capabilities.purchase.consumable || capabilities.purchase.durable)
306
+ ? 'unsupported'
307
+ : availability;
308
+ }
309
+ else if (feature === 'subscriptionIap') {
310
+ states.subscriptionIap = capabilities !== undefined && !capabilities.purchase.subscription
311
+ ? 'unsupported'
312
+ : availability;
313
+ }
314
+ }
315
+ return states;
316
+ }
208
317
  function isRecord(value) {
209
318
  return typeof value === 'object' && value !== null && !Array.isArray(value);
210
319
  }
@@ -218,6 +327,15 @@ function isVoidData(value) {
218
327
  return value === undefined || value === null || (isRecord(value) && Object.keys(value).length === 0);
219
328
  }
220
329
  function isValidProviderData(method, value) {
330
+ if (isBridgeCommerceV2Method(method)) {
331
+ try {
332
+ assertCommerceBridgeResult(method, value);
333
+ return true;
334
+ }
335
+ catch {
336
+ return false;
337
+ }
338
+ }
221
339
  switch (method) {
222
340
  case 'commerce.getProducts':
223
341
  return Array.isArray(value) && value.every((product) => isRecord(product)
@@ -0,0 +1,55 @@
1
+ import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
2
+ import { type CommerceAuthorityCapabilities, type CommerceCatalogCapabilities, type CommerceErrorCode, type CommerceLifecycleCapabilities, type CommerceLogicalProductId, type CommerceProvider, type CommercePurchaseCapabilities, type PurchaseKind, type StoreId, type StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
3
+ import { type StoreSdk, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from './store-sdk.js';
4
+ /** One catalog product sold through the store under `storeSku`. */
5
+ export interface StoreCommerceProductMapping {
6
+ readonly logicalId: CommerceLogicalProductId;
7
+ readonly storeSku: string;
8
+ readonly kind: PurchaseKind;
9
+ /** Default store offer when the request names none. */
10
+ readonly offerId?: string;
11
+ }
12
+ /** Deep-partial overrides applied over the capabilities composed from the SDK declaration. */
13
+ export interface StoreCommerceCapabilityOverrides {
14
+ readonly purchase?: Partial<CommercePurchaseCapabilities>;
15
+ readonly catalog?: Partial<CommerceCatalogCapabilities>;
16
+ readonly lifecycle?: Partial<CommerceLifecycleCapabilities>;
17
+ }
18
+ export interface CreateStoreCommerceProviderInput {
19
+ readonly sdk: StoreSdk;
20
+ /** Defaults to `sdk.store`. */
21
+ readonly store?: StoreId;
22
+ readonly products: readonly StoreCommerceProductMapping[];
23
+ /** Evidence schema the backend registry routes on, for example `google-play.product-purchase.v2`. */
24
+ readonly evidenceSchema: string;
25
+ /**
26
+ * Evidence payload for a purchase. Defaults to the token, transaction id,
27
+ * and SKU; a store provider replaces it to match its backend verifier.
28
+ */
29
+ readonly evidencePayload?: (purchase: StoreSdkPurchase) => PlatformEvidenceEnvelope['payload'];
30
+ /** Resolves the player's store account binding before a purchase opens. */
31
+ readonly accountBinding?: () => Promise<StorePurchaseAccountBinding | undefined>;
32
+ readonly capabilities?: StoreCommerceCapabilityOverrides;
33
+ readonly authority: CommerceAuthorityCapabilities;
34
+ /** Per-store error codes merged over `defaultStoreSdkErrorCodeMap`. */
35
+ readonly mapError?: StoreSdkErrorCodeMap;
36
+ }
37
+ export interface StoreCommerceProvider extends CommerceProvider {
38
+ /** Detaches the SDK listeners; buffered events are dropped. */
39
+ dispose(): void;
40
+ }
41
+ /**
42
+ * `CommerceProvider` over an in-process `StoreSdk`. The provider maps logical
43
+ * ids to store SKUs both ways, composes `CommerceCapabilities` from the SDK
44
+ * declaration, builds `StorePurchase` evidence, buffers SDK events until the
45
+ * first subscriber, and normalizes every SDK failure into a `CommerceError`.
46
+ * It never grants and never sets `settlement`; the backend ledger decides.
47
+ */
48
+ export declare function createStoreCommerceProvider(input: CreateStoreCommerceProviderInput): StoreCommerceProvider;
49
+ /**
50
+ * Codes that mean an unfinished purchase may exist (RFC "Errors": reconcile,
51
+ * never reopen the store UI blindly). The attempt stays `launched` so the
52
+ * reconciler resolves it from `getAvailablePurchases`; a terminal `failed`
53
+ * would exclude it from recovery.
54
+ */
55
+ export declare const recoverableErrorCodes: ReadonlySet<CommerceErrorCode>;