@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.
- package/README.md +107 -0
- package/dist/commerce-bridge.d.ts +59 -0
- package/dist/commerce-bridge.js +265 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +96 -6
- package/dist/providers.d.ts +46 -1
- package/dist/providers.js +155 -37
- package/dist/store-commerce-provider.d.ts +55 -0
- package/dist/store-commerce-provider.js +437 -0
- package/dist/store-sdk.d.ts +131 -0
- package/dist/store-sdk.js +83 -0
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -64,6 +64,113 @@ The source tests and installed-tarball consumer validate composition, types,
|
|
|
64
64
|
error paths, and fallback behavior. They do not establish that any optional
|
|
65
65
|
SDK works on a physical device or that a store/ad setup is release-ready.
|
|
66
66
|
|
|
67
|
+
## Commerce v2 providers
|
|
68
|
+
|
|
69
|
+
Commerce v2 ([RFC](../../docs/specs/commerce/commerce-v2-rfc.md), PR B1) adds
|
|
70
|
+
a second commerce surface next to the v1 `commerce.*` bridge methods. A store
|
|
71
|
+
plugin exposes an in-process `StoreSdk`, the adapter turns it into a
|
|
72
|
+
`CommerceProvider` from `@mpgd/platform/commerce`, and the provider registry
|
|
73
|
+
serves both the six `commerce.v2.*` bridge methods and the four v1 methods
|
|
74
|
+
from it.
|
|
75
|
+
|
|
76
|
+
`StoreSdk` is shaped like the OpenIAP Client Protocol so a future Capacitor
|
|
77
|
+
IAP plugin over the OpenIAP cores is a drop-in: `fetchProducts({ skus, type })`,
|
|
78
|
+
`requestPurchase({ sku, offerId?, quantity?, accountBinding? })` answering
|
|
79
|
+
`launched`, `purchased`, `cancelled`, or `failed`, `getAvailablePurchases`
|
|
80
|
+
listing unfinished store purchases, optional `finishTransaction` and
|
|
81
|
+
`restorePurchases`, `addListener('purchaseUpdated' | 'purchaseError')`, an
|
|
82
|
+
optional `getAvailability`, and a static `capabilities` declaration (purchase
|
|
83
|
+
kinds, quantity, pending, restore, finish owner). The SDK speaks store SKUs
|
|
84
|
+
and its own error codes only. B2 adapts `@mpgd/capacitor-play-billing` and B3
|
|
85
|
+
adapts `@mpgd/capacitor-storekit` to this interface; B1 ships no plugin
|
|
86
|
+
change.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import {
|
|
90
|
+
createCapacitorPlatformGateway,
|
|
91
|
+
createCommerceServiceProvider,
|
|
92
|
+
createStoreCommerceProvider,
|
|
93
|
+
type StoreSdk,
|
|
94
|
+
} from '@mpgd/adapter-capacitor';
|
|
95
|
+
|
|
96
|
+
export function createStoreGateway(sdk: StoreSdk) {
|
|
97
|
+
const commerce = createStoreCommerceProvider({
|
|
98
|
+
sdk,
|
|
99
|
+
products: [
|
|
100
|
+
{ logicalId: 'gems_small', storeSku: 'gems.small', kind: 'consumable' },
|
|
101
|
+
{ logicalId: 'remove_ads', storeSku: 'remove.ads', kind: 'durable' },
|
|
102
|
+
],
|
|
103
|
+
evidenceSchema: 'google-play.product-purchase.v2',
|
|
104
|
+
evidencePayload: (purchase) => ({ purchaseToken: purchase.token }),
|
|
105
|
+
accountBinding: async () => ({ kind: 'obfuscated-account-id', value: 'player-1' }),
|
|
106
|
+
authority: { evidence: 'client-token', accountBinding: 'required' },
|
|
107
|
+
mapError: { PLAY_BILLING_OFFER_REQUIRED: 'CONFIGURATION_REQUIRED' },
|
|
108
|
+
});
|
|
109
|
+
return createCapacitorPlatformGateway({
|
|
110
|
+
target: 'android',
|
|
111
|
+
appVersion: '1.0.0',
|
|
112
|
+
buildId: 'game-build',
|
|
113
|
+
providers: [createCommerceServiceProvider({ id: 'play-billing', commerce })],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`createStoreCommerceProvider` maps logical ids to store SKUs both ways (an
|
|
119
|
+
unknown SKU is still reported, with `logicalId` undefined; an unknown logical
|
|
120
|
+
id fails with `ITEM_UNAVAILABLE`), rejects unsupported kinds and quantities
|
|
121
|
+
with `assertRequestPurchaseSupported` before any store UI opens, refuses to
|
|
122
|
+
open the store while `getAvailability` is not `available`, builds every
|
|
123
|
+
`StorePurchase` with the store `token` and the `{ schema: evidenceSchema,
|
|
124
|
+
payload }` envelope, buffers SDK events until the first `subscribe` with
|
|
125
|
+
`createCommerceEventBuffer`, composes `CommerceCapabilities` from the SDK
|
|
126
|
+
declaration plus `capabilities` overrides and a fresh `availability` read
|
|
127
|
+
(three-second timeout, degraded to `temporarily-unavailable`), validates every
|
|
128
|
+
SDK answer with the platform typia asserts (`INVALID_RESPONSE` otherwise), and
|
|
129
|
+
normalizes every SDK failure through `mapError` into a `CommerceError` whose
|
|
130
|
+
`storeCode` keeps the store's own code. It never grants and never sets
|
|
131
|
+
`settlement`; the backend ledger decides.
|
|
132
|
+
|
|
133
|
+
Two failure classes are not terminal. When `requestPurchase` throws or
|
|
134
|
+
returns a code that normalizes to `OUTCOME_UNKNOWN` (for example
|
|
135
|
+
`PLAY_BILLING_TIMEOUT`, `PLAY_BILLING_INTERRUPTED`,
|
|
136
|
+
`STOREKIT_PURCHASE_UNCERTAIN`) or `ALREADY_OWNED`, an unfinished purchase may
|
|
137
|
+
exist, so the provider answers `{ outcome: 'launched' }` instead of `failed`
|
|
138
|
+
and does not forward a `purchaseError` event carrying those codes
|
|
139
|
+
(`recoverableErrorCodes`). The attempt stays open and the reconciler resolves
|
|
140
|
+
it from `getAvailablePurchases`; the store UI is never reopened blindly. A
|
|
141
|
+
`capabilities.lifecycle` override may hide `restore` or `finish`, but it
|
|
142
|
+
cannot advertise one the SDK lacks: `restore: true` without
|
|
143
|
+
`sdk.restorePurchases`, like `finish: 'client' | 'host'` without
|
|
144
|
+
`sdk.finishTransaction`, is rejected when the provider is created.
|
|
145
|
+
|
|
146
|
+
`createCommerceServiceProvider` wraps the provider as a `CapacitorServiceProvider`
|
|
147
|
+
whose `commerce` field the registry recognizes: the ten commerce bridge
|
|
148
|
+
methods are implied (declaring a v1 `commerce.*` method alongside is an
|
|
149
|
+
error), `nativeIap` and `subscriptionIap` readiness derive from
|
|
150
|
+
`commerce.getCapabilities()`, and the in-process bridge delivers events as
|
|
151
|
+
`{ protocol: 'mpgd.commerce.v2', sequence, event }` with a per-session
|
|
152
|
+
sequence starting at 0. v1 callers keep working: `gateway.commerce` is served
|
|
153
|
+
by `toCommerceAdapter(provider)`, so a `launched` purchase is `pending`, a
|
|
154
|
+
synchronous purchase is `completed` with evidence and no entitlements, and
|
|
155
|
+
`restore` returns no entitlements. The gateway exposes the v2 surface as
|
|
156
|
+
`gateway.commerceV2` (served through the same validated bridge path, which
|
|
157
|
+
the conformance test exercises for every store vector) and adds a `commerce`
|
|
158
|
+
block to `getCapabilities()` from which `nativeIap` and `subscriptionIap` are
|
|
159
|
+
derived. The block comes from the registry's one bounded read per snapshot
|
|
160
|
+
(`getCapabilitySnapshot`, three-second timeout, shape validated): a stalled,
|
|
161
|
+
failing, or invalid provider yields a degraded block
|
|
162
|
+
(`createDegradedCommerceCapabilities`: `availability:
|
|
163
|
+
'temporarily-unavailable'`, nothing sold, `nativeIap: false`) and never
|
|
164
|
+
rejects the capability read. A `launched` v2 purchase keeps
|
|
165
|
+
`beginExternalActivity()` open after the request returns, since the store UI
|
|
166
|
+
is still up: the gateway releases it when the provider reports that product
|
|
167
|
+
(`purchaseUpdated` or `purchaseError`), after
|
|
168
|
+
`launchedPurchaseActivityTimeoutMs` (two minutes), or on `lifecycle.dispose`;
|
|
169
|
+
the v1 `commerce.purchase` path has no event to wait for and releases when
|
|
170
|
+
its request returns. Without a v2 provider nothing changes: `commerceV2` is
|
|
171
|
+
absent and a `commerce.v2.*` request still answers `COMMERCE_V2_UNAVAILABLE`
|
|
172
|
+
as the transport left it.
|
|
173
|
+
|
|
67
174
|
## App Store server-side transaction signatures
|
|
68
175
|
|
|
69
176
|
The server-side verifier moved to `@mpgd/game-services/app-store-server`
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type BridgeCommerceV2Method, type BridgeEvent, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
|
|
2
|
+
import { type PlatformProviderFeature } from '@mpgd/platform';
|
|
3
|
+
import { type CommerceBridgeMethods, type CommerceProvider, type StoreId } from '@mpgd/platform/commerce';
|
|
4
|
+
import type { CapacitorCommerceServiceProvider } from './providers.js';
|
|
5
|
+
/** Bridge error for a commerce v2 payload the typia assert rejected. */
|
|
6
|
+
export declare const commerceV2InvalidPayloadBridgeErrorCode: 'COMMERCE_V2_INVALID_PAYLOAD';
|
|
7
|
+
/** Bridge error for a v2 method the provider does not implement (`finishTransaction`). */
|
|
8
|
+
export declare const commerceV2MethodUnsupportedBridgeErrorCode: 'COMMERCE_V2_METHOD_UNSUPPORTED';
|
|
9
|
+
export interface CommerceProviderBridge {
|
|
10
|
+
request(input: BridgeRequest): Promise<BridgeResponse>;
|
|
11
|
+
/** Delivers `{ protocol: 'mpgd.commerce.v2', sequence, event }`; `sequence` starts at 0 per bridge. */
|
|
12
|
+
subscribe(listener: (event: BridgeEvent) => void): () => void;
|
|
13
|
+
}
|
|
14
|
+
export interface CreateCommerceProviderBridgeInput {
|
|
15
|
+
readonly provider: CommerceProvider;
|
|
16
|
+
/** Serves the methods the provider does not own, for example a plugin's ad methods. */
|
|
17
|
+
readonly fallback?: {
|
|
18
|
+
request(input: BridgeRequest): Promise<BridgeResponse>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* In-process `NativeBridge` over a commerce v2 provider: the six
|
|
23
|
+
* `commerce.v2.*` methods validate their payload and result with the
|
|
24
|
+
* `@mpgd/platform/commerce` asserts, the four v1 `commerce.*` methods are
|
|
25
|
+
* served by `toCommerceAdapter(provider)`, and events carry the bridge envelope
|
|
26
|
+
* with a per-session sequence. A thrown failure crosses the bridge as
|
|
27
|
+
* `{ code, message, retryable }`; a `cancelled` or `failed` purchase result
|
|
28
|
+
* keeps its `storeCode` because it is data, not an error.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createCommerceProviderBridge(input: CreateCommerceProviderBridgeInput): CommerceProviderBridge;
|
|
31
|
+
export type CapacitorCommerceFeature = Extract<PlatformProviderFeature, 'nativeIap' | 'subscriptionIap'>;
|
|
32
|
+
export interface CreateCommerceServiceProviderInput {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly commerce: CommerceProvider;
|
|
35
|
+
/** Commerce features the provider serves. Defaults to `['nativeIap']`. */
|
|
36
|
+
readonly features?: readonly CapacitorCommerceFeature[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Registry entry for a commerce v2 provider: the implied commerce bridge
|
|
40
|
+
* methods are served in process by `createCommerceProviderBridge`, and the
|
|
41
|
+
* registry derives `nativeIap` and `subscriptionIap` readiness from
|
|
42
|
+
* `commerce.getCapabilities()`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createCommerceServiceProvider(input: CreateCommerceServiceProviderInput): CapacitorCommerceServiceProvider;
|
|
45
|
+
/** Validates a commerce v2 result; a provider answer the contract rejects never reaches the caller. */
|
|
46
|
+
export declare function assertCommerceBridgeResult<TMethod extends BridgeCommerceV2Method>(method: TMethod, data: unknown): CommerceBridgeMethods[TMethod]['result'];
|
|
47
|
+
export interface CreateBridgeCommerceProviderInput {
|
|
48
|
+
readonly store: StoreId;
|
|
49
|
+
/** Sends one validated bridge request; the gateway's `request` fits. */
|
|
50
|
+
readonly request: <TData>(method: BridgeCommerceV2Method, payload: unknown) => Promise<TData>;
|
|
51
|
+
readonly subscribe: (listener: (event: BridgeEvent) => void) => () => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `CommerceProvider` over a bridge that serves the `commerce.v2.*` methods and
|
|
55
|
+
* the event envelope. Results are validated again on this side, so a transport
|
|
56
|
+
* that forgets to is still fail-closed; bridge errors become `CommerceError`s
|
|
57
|
+
* with the bridge code kept as `storeCode` when it is not already a v2 code.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createBridgeCommerceProvider(input: CreateBridgeCommerceProviderInput): CommerceProvider;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { assertBridgeEvent, bridgeEventProtocol, createBridgeError, isBridgeCommerceV2Method, } from '@mpgd/bridge';
|
|
2
|
+
import { PlatformOperationError, } from '@mpgd/platform';
|
|
3
|
+
import { assertCommerceBridgeFetchProductsPayload, assertCommerceBridgeFetchProductsResult, assertCommerceBridgeFinishTransactionPayload, assertCommerceBridgeFinishTransactionResult, assertCommerceBridgeGetAvailablePurchasesPayload, assertCommerceBridgeGetAvailablePurchasesResult, assertCommerceBridgeGetCapabilitiesPayload, assertCommerceBridgeGetCapabilitiesResult, assertCommerceBridgeRequestPurchasePayload, assertCommerceBridgeRequestPurchaseResult, assertCommerceBridgeRestorePurchasesPayload, assertCommerceBridgeRestorePurchasesResult, CommerceError, commerceErrorInfoOf, commerceProtocol, commerceProtocolVersion, toCommerceAdapter, } from '@mpgd/platform/commerce';
|
|
4
|
+
import { normalizeStoreSdkError } from './store-sdk.js';
|
|
5
|
+
/** Bridge error for a commerce v2 payload the typia assert rejected. */
|
|
6
|
+
export const commerceV2InvalidPayloadBridgeErrorCode = 'COMMERCE_V2_INVALID_PAYLOAD';
|
|
7
|
+
/** Bridge error for a v2 method the provider does not implement (`finishTransaction`). */
|
|
8
|
+
export const commerceV2MethodUnsupportedBridgeErrorCode = 'COMMERCE_V2_METHOD_UNSUPPORTED';
|
|
9
|
+
const v1CommerceMethods = Object.freeze([
|
|
10
|
+
'commerce.getProducts',
|
|
11
|
+
'commerce.purchase',
|
|
12
|
+
'commerce.restore',
|
|
13
|
+
'commerce.getEntitlements',
|
|
14
|
+
]);
|
|
15
|
+
/**
|
|
16
|
+
* In-process `NativeBridge` over a commerce v2 provider: the six
|
|
17
|
+
* `commerce.v2.*` methods validate their payload and result with the
|
|
18
|
+
* `@mpgd/platform/commerce` asserts, the four v1 `commerce.*` methods are
|
|
19
|
+
* served by `toCommerceAdapter(provider)`, and events carry the bridge envelope
|
|
20
|
+
* with a per-session sequence. A thrown failure crosses the bridge as
|
|
21
|
+
* `{ code, message, retryable }`; a `cancelled` or `failed` purchase result
|
|
22
|
+
* keeps its `storeCode` because it is data, not an error.
|
|
23
|
+
*/
|
|
24
|
+
export function createCommerceProviderBridge(input) {
|
|
25
|
+
const { provider } = input;
|
|
26
|
+
const adapter = toCommerceAdapter(provider);
|
|
27
|
+
const listeners = new Set();
|
|
28
|
+
let sequence = 0;
|
|
29
|
+
let detach;
|
|
30
|
+
const deliver = (event) => {
|
|
31
|
+
const envelope = { protocol: commerceProtocol, sequence, event };
|
|
32
|
+
sequence += 1;
|
|
33
|
+
for (const listener of [...listeners]) {
|
|
34
|
+
listener(envelope);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const serve = async (request) => {
|
|
38
|
+
const { payload } = request;
|
|
39
|
+
switch (request.method) {
|
|
40
|
+
case 'commerce.v2.getCapabilities': {
|
|
41
|
+
assertCommerceBridgeGetCapabilitiesPayload(payload);
|
|
42
|
+
return assertResult('commerce.v2.getCapabilities', {
|
|
43
|
+
protocolVersion: commerceProtocolVersion,
|
|
44
|
+
store: provider.store,
|
|
45
|
+
capabilities: await provider.getCapabilities(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
case 'commerce.v2.fetchProducts': {
|
|
49
|
+
const products = await provider.fetchProducts(assertCommerceBridgeFetchProductsPayload(payload));
|
|
50
|
+
return assertResult('commerce.v2.fetchProducts', { products });
|
|
51
|
+
}
|
|
52
|
+
case 'commerce.v2.requestPurchase':
|
|
53
|
+
return assertResult('commerce.v2.requestPurchase', await provider.requestPurchase(assertCommerceBridgeRequestPurchasePayload(payload)));
|
|
54
|
+
case 'commerce.v2.getAvailablePurchases': {
|
|
55
|
+
const purchases = await provider.getAvailablePurchases(
|
|
56
|
+
// The payload is asserted as sent; a caller meaning the default sends `{}`.
|
|
57
|
+
assertCommerceBridgeGetAvailablePurchasesPayload(payload));
|
|
58
|
+
return assertResult('commerce.v2.getAvailablePurchases', { purchases });
|
|
59
|
+
}
|
|
60
|
+
case 'commerce.v2.finishTransaction': {
|
|
61
|
+
const finish = assertCommerceBridgeFinishTransactionPayload(payload);
|
|
62
|
+
if (provider.finishTransaction === undefined) {
|
|
63
|
+
throw new PlatformOperationError({
|
|
64
|
+
code: commerceV2MethodUnsupportedBridgeErrorCode,
|
|
65
|
+
message: `Store ${provider.store} does not finish transactions on the client.`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return assertResult('commerce.v2.finishTransaction', await provider.finishTransaction(finish));
|
|
69
|
+
}
|
|
70
|
+
case 'commerce.v2.restorePurchases': {
|
|
71
|
+
assertCommerceBridgeRestorePurchasesPayload(payload);
|
|
72
|
+
if (provider.restorePurchases === undefined) {
|
|
73
|
+
return assertResult('commerce.v2.restorePurchases', { restored: false });
|
|
74
|
+
}
|
|
75
|
+
await provider.restorePurchases();
|
|
76
|
+
return assertResult('commerce.v2.restorePurchases', { restored: true });
|
|
77
|
+
}
|
|
78
|
+
case 'commerce.getProducts':
|
|
79
|
+
return adapter.getProducts();
|
|
80
|
+
case 'commerce.purchase':
|
|
81
|
+
return adapter.purchase(payload);
|
|
82
|
+
case 'commerce.restore':
|
|
83
|
+
if (adapter.restore === undefined) {
|
|
84
|
+
return { restoredEntitlements: [] };
|
|
85
|
+
}
|
|
86
|
+
return adapter.restore();
|
|
87
|
+
case 'commerce.getEntitlements':
|
|
88
|
+
return adapter.getEntitlements();
|
|
89
|
+
default:
|
|
90
|
+
throw new PlatformOperationError({
|
|
91
|
+
code: 'UNSUPPORTED_METHOD',
|
|
92
|
+
message: `${request.method} is not a commerce method.`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
async request(request) {
|
|
98
|
+
const isCommerce = isBridgeCommerceV2Method(request.method)
|
|
99
|
+
|| v1CommerceMethods.includes(request.method);
|
|
100
|
+
if (!isCommerce && input.fallback !== undefined) {
|
|
101
|
+
return input.fallback.request(request);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return { id: request.id, ok: true, data: await serve(request) };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return toBridgeError(request, error);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
subscribe(listener) {
|
|
111
|
+
listeners.add(listener);
|
|
112
|
+
if (listeners.size === 1) {
|
|
113
|
+
detach = provider.subscribe(deliver);
|
|
114
|
+
}
|
|
115
|
+
return () => {
|
|
116
|
+
if (!listeners.delete(listener) || listeners.size > 0) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
detach?.();
|
|
120
|
+
detach = undefined;
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Registry entry for a commerce v2 provider: the implied commerce bridge
|
|
127
|
+
* methods are served in process by `createCommerceProviderBridge`, and the
|
|
128
|
+
* registry derives `nativeIap` and `subscriptionIap` readiness from
|
|
129
|
+
* `commerce.getCapabilities()`.
|
|
130
|
+
*/
|
|
131
|
+
export function createCommerceServiceProvider(input) {
|
|
132
|
+
return {
|
|
133
|
+
id: input.id,
|
|
134
|
+
features: input.features ?? ['nativeIap'],
|
|
135
|
+
methods: [],
|
|
136
|
+
commerce: input.commerce,
|
|
137
|
+
bridge: createCommerceProviderBridge({ provider: input.commerce }),
|
|
138
|
+
async getAvailability() {
|
|
139
|
+
return {};
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// Each entry is the typia assert of that method's result type; the lookup is
|
|
144
|
+
// typed by `assertCommerceBridgeResult`.
|
|
145
|
+
const resultAsserts = {
|
|
146
|
+
'commerce.v2.getCapabilities': assertCommerceBridgeGetCapabilitiesResult,
|
|
147
|
+
'commerce.v2.fetchProducts': assertCommerceBridgeFetchProductsResult,
|
|
148
|
+
'commerce.v2.requestPurchase': assertCommerceBridgeRequestPurchaseResult,
|
|
149
|
+
'commerce.v2.getAvailablePurchases': assertCommerceBridgeGetAvailablePurchasesResult,
|
|
150
|
+
'commerce.v2.finishTransaction': assertCommerceBridgeFinishTransactionResult,
|
|
151
|
+
'commerce.v2.restorePurchases': assertCommerceBridgeRestorePurchasesResult,
|
|
152
|
+
};
|
|
153
|
+
/** Validates a commerce v2 result; a provider answer the contract rejects never reaches the caller. */
|
|
154
|
+
export function assertCommerceBridgeResult(method, data) {
|
|
155
|
+
return resultAsserts[method](data);
|
|
156
|
+
}
|
|
157
|
+
function assertResult(method, data) {
|
|
158
|
+
try {
|
|
159
|
+
return assertCommerceBridgeResult(method, data);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
throw new PlatformOperationError({
|
|
163
|
+
code: 'NATIVE_PROVIDER_INVALID_RESPONSE',
|
|
164
|
+
message: `Commerce provider returned invalid data for ${method}: ${error instanceof Error ? error.message : String(error)}`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function toBridgeError(request, error) {
|
|
169
|
+
if (isTypiaGuardError(error)) {
|
|
170
|
+
return createBridgeError(request.id, commerceV2InvalidPayloadBridgeErrorCode, `${request.method} payload is invalid: ${error.message}`);
|
|
171
|
+
}
|
|
172
|
+
if (error instanceof PlatformOperationError) {
|
|
173
|
+
return createBridgeError(request.id, error.code, error.message, error.retryable);
|
|
174
|
+
}
|
|
175
|
+
const info = commerceErrorInfoOf(error);
|
|
176
|
+
return createBridgeError(request.id, info.code, info.message ?? `${request.method} failed.`, info.retryable);
|
|
177
|
+
}
|
|
178
|
+
function isTypiaGuardError(error) {
|
|
179
|
+
return error instanceof Error && error.name === 'TypeGuardError';
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* `CommerceProvider` over a bridge that serves the `commerce.v2.*` methods and
|
|
183
|
+
* the event envelope. Results are validated again on this side, so a transport
|
|
184
|
+
* that forgets to is still fail-closed; bridge errors become `CommerceError`s
|
|
185
|
+
* with the bridge code kept as `storeCode` when it is not already a v2 code.
|
|
186
|
+
*/
|
|
187
|
+
export function createBridgeCommerceProvider(input) {
|
|
188
|
+
const send = async (method, payload) => {
|
|
189
|
+
let data;
|
|
190
|
+
try {
|
|
191
|
+
data = await input.request(method, payload);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw toCommerceError(error);
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return assertCommerceBridgeResult(method, data);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
throw new CommerceError('INVALID_RESPONSE', {
|
|
201
|
+
message: `Bridge returned invalid data for ${method}.`,
|
|
202
|
+
cause: error,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
return {
|
|
207
|
+
store: input.store,
|
|
208
|
+
async getCapabilities() {
|
|
209
|
+
return (await send('commerce.v2.getCapabilities', { protocol: commerceProtocol }))
|
|
210
|
+
.capabilities;
|
|
211
|
+
},
|
|
212
|
+
async fetchProducts(request) {
|
|
213
|
+
return (await send('commerce.v2.fetchProducts', request)).products;
|
|
214
|
+
},
|
|
215
|
+
requestPurchase(request) {
|
|
216
|
+
return send('commerce.v2.requestPurchase', request);
|
|
217
|
+
},
|
|
218
|
+
async getAvailablePurchases(request = {}) {
|
|
219
|
+
return (await send('commerce.v2.getAvailablePurchases', request)).purchases;
|
|
220
|
+
},
|
|
221
|
+
finishTransaction(request) {
|
|
222
|
+
return send('commerce.v2.finishTransaction', request);
|
|
223
|
+
},
|
|
224
|
+
async restorePurchases() {
|
|
225
|
+
await send('commerce.v2.restorePurchases', { protocol: commerceProtocol });
|
|
226
|
+
},
|
|
227
|
+
subscribe(listener) {
|
|
228
|
+
return input.subscribe((envelope) => {
|
|
229
|
+
// Another protocol on a shared event channel is not ours to judge.
|
|
230
|
+
if (typeof envelope === 'object' && envelope !== null
|
|
231
|
+
&& envelope.protocol !== bridgeEventProtocol) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
let event;
|
|
235
|
+
try {
|
|
236
|
+
event = assertBridgeEvent(envelope);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
listener({
|
|
240
|
+
type: 'purchaseError',
|
|
241
|
+
error: new CommerceError('INVALID_RESPONSE', {
|
|
242
|
+
message: 'Bridge delivered an invalid commerce event.',
|
|
243
|
+
cause: error,
|
|
244
|
+
}).toCommerceErrorInfo(),
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
listener(event.event);
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function toCommerceError(error) {
|
|
254
|
+
if (error instanceof CommerceError) {
|
|
255
|
+
return error;
|
|
256
|
+
}
|
|
257
|
+
if (error instanceof PlatformOperationError) {
|
|
258
|
+
return normalizeStoreSdkError({
|
|
259
|
+
code: error.code,
|
|
260
|
+
message: error.message,
|
|
261
|
+
retryable: error.retryable,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return normalizeStoreSdkError(error);
|
|
265
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { type PlatformGateway, type PlatformTarget, type ViewportAdapter } from '@mpgd/platform';
|
|
2
2
|
import { type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource } from './app-events.js';
|
|
3
3
|
import { type CapacitorServiceProvider, type NativeBridge } from './providers.js';
|
|
4
|
-
export { createCapacitorProviderRegistry, type CapacitorServiceProvider, type NativeBridge, } from './providers.js';
|
|
4
|
+
export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, deriveCommerceFeatureStates, type CapacitorCommerceServiceProvider, type CapacitorProviderCapabilitySnapshot, type CapacitorProviderRegistry, type CapacitorServiceProvider, type NativeBridge, } from './providers.js';
|
|
5
|
+
export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, type CapacitorCommerceFeature, type CommerceProviderBridge, type CreateBridgeCommerceProviderInput, type CreateCommerceProviderBridgeInput, type CreateCommerceServiceProviderInput, } from './commerce-bridge.js';
|
|
6
|
+
export { createStoreCommerceProvider, recoverableErrorCodes, type CreateStoreCommerceProviderInput, type StoreCommerceCapabilityOverrides, type StoreCommerceProductMapping, type StoreCommerceProvider, } from './store-commerce-provider.js';
|
|
7
|
+
export { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, type StoreSdk, type StoreSdkCapabilities, type StoreSdkError, type StoreSdkErrorCodeMap, type StoreSdkEvent, type StoreSdkEventName, type StoreSdkEventOf, type StoreSdkFetchProductsInput, type StoreSdkFinishTransactionInput, type StoreSdkFinishTransactionResult, type StoreSdkGetAvailablePurchasesInput, type StoreSdkProduct, type StoreSdkPurchase, type StoreSdkRequestPurchaseInput, type StoreSdkRequestPurchaseResult, } from './store-sdk.js';
|
|
5
8
|
export { createCapacitorAppEvents, type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource, } from './app-events.js';
|
|
6
9
|
export { CapacitorNativeHttpError, createCapacitorNativeJsonTransport, type CapacitorJsonRequest, type CapacitorJsonResponse, type CapacitorNativeHttpErrorCode, type CapacitorNativeJsonTransport, type CreateCapacitorNativeJsonTransportInput, } from './native-http.js';
|
|
7
10
|
export { createCapacitorViewport, type CapacitorViewportBaseState, type CapacitorOccupiedSurfaceInput, type CapacitorViewportController, type CapacitorViewportHost, } from './viewport.js';
|
|
11
|
+
/**
|
|
12
|
+
* Upper bound on how long a `launched` commerce v2 purchase keeps the game
|
|
13
|
+
* paused when no purchase event for the product arrives (for example a store
|
|
14
|
+
* sheet dismissed without a callback).
|
|
15
|
+
*/
|
|
16
|
+
export declare const launchedPurchaseActivityTimeoutMs = 120000;
|
|
8
17
|
export declare function createCapacitorPlatformGateway(input: {
|
|
9
18
|
readonly target: Extract<PlatformTarget, 'android' | 'ios'>;
|
|
10
19
|
readonly appVersion: string;
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { decodeBridgeCredentialLoadData, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
|
|
2
2
|
import { CapacitorGameServices } from '@mpgd/capacitor-game-services';
|
|
3
3
|
import { PlatformOperationError, } from '@mpgd/platform';
|
|
4
|
+
import { deriveLegacyCommerceCapabilities } from '@mpgd/platform/commerce';
|
|
4
5
|
import { createCapacitorAppEvents, } from './app-events.js';
|
|
6
|
+
import { createBridgeCommerceProvider } from './commerce-bridge.js';
|
|
5
7
|
import { assertProviderResponseData, createCapacitorProviderRegistry, } from './providers.js';
|
|
6
8
|
import { createCapacitorViewport } from './viewport.js';
|
|
7
|
-
export { createCapacitorProviderRegistry, } from './providers.js';
|
|
9
|
+
export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, deriveCommerceFeatureStates, } from './providers.js';
|
|
10
|
+
export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, } from './commerce-bridge.js';
|
|
11
|
+
export { createStoreCommerceProvider, recoverableErrorCodes, } from './store-commerce-provider.js';
|
|
12
|
+
export { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, } from './store-sdk.js';
|
|
8
13
|
export { createCapacitorAppEvents, } from './app-events.js';
|
|
9
14
|
export { CapacitorNativeHttpError, createCapacitorNativeJsonTransport, } from './native-http.js';
|
|
10
15
|
export { createCapacitorViewport, } from './viewport.js';
|
|
11
16
|
const externalActivityMethods = new Set([
|
|
12
17
|
'commerce.purchase',
|
|
18
|
+
'commerce.v2.requestPurchase',
|
|
13
19
|
'ads.showRewarded',
|
|
14
20
|
'ads.showInterstitial',
|
|
15
21
|
'identity.requestUpgrade',
|
|
@@ -18,6 +24,12 @@ const externalActivityMethods = new Set([
|
|
|
18
24
|
function isRecord(value) {
|
|
19
25
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Upper bound on how long a `launched` commerce v2 purchase keeps the game
|
|
29
|
+
* paused when no purchase event for the product arrives (for example a store
|
|
30
|
+
* sheet dismissed without a callback).
|
|
31
|
+
*/
|
|
32
|
+
export const launchedPurchaseActivityTimeoutMs = 120_000;
|
|
21
33
|
const safeFallbackMethods = new Set([
|
|
22
34
|
'identity.getPlayer',
|
|
23
35
|
'identity.getSession',
|
|
@@ -46,6 +58,17 @@ export function createCapacitorPlatformGateway(input) {
|
|
|
46
58
|
const providers = createCapacitorProviderRegistry(input.providers ?? []);
|
|
47
59
|
const adProvider = providers.byMethod.get('ads.showRewarded')?.adProvider
|
|
48
60
|
?? providers.byMethod.get('ads.showInterstitial')?.adProvider;
|
|
61
|
+
const commerceProvider = providers.commerce;
|
|
62
|
+
// The v2 surface is served through the same request path as every other
|
|
63
|
+
// provider method, so the bridge envelope, response validation, and external
|
|
64
|
+
// activity bookkeeping are exercised in process exactly as over a transport.
|
|
65
|
+
const commerceV2 = commerceProvider === undefined
|
|
66
|
+
? undefined
|
|
67
|
+
: createBridgeCommerceProvider({
|
|
68
|
+
store: commerceProvider.commerce.store,
|
|
69
|
+
request: (method, payload) => request(method, payload),
|
|
70
|
+
subscribe: (listener) => commerceProvider.bridge.subscribe(listener),
|
|
71
|
+
});
|
|
49
72
|
const lifecycle = createCapacitorAppEvents({
|
|
50
73
|
target: input.target,
|
|
51
74
|
...(input.app === undefined ? {} : { app: input.app }),
|
|
@@ -59,10 +82,14 @@ export function createCapacitorPlatformGateway(input) {
|
|
|
59
82
|
const viewport = input.viewport ?? createCapacitorViewport({
|
|
60
83
|
...(input.onAppEventError === undefined ? {} : { onError: input.onAppEventError }),
|
|
61
84
|
});
|
|
85
|
+
const launchedPurchaseHolds = new Set();
|
|
62
86
|
const gatewayLifecycle = {
|
|
63
87
|
...lifecycle,
|
|
64
88
|
async dispose() {
|
|
65
89
|
try {
|
|
90
|
+
for (const finish of [...launchedPurchaseHolds]) {
|
|
91
|
+
finish();
|
|
92
|
+
}
|
|
66
93
|
await lifecycle.dispose?.();
|
|
67
94
|
}
|
|
68
95
|
finally {
|
|
@@ -76,6 +103,7 @@ export function createCapacitorPlatformGateway(input) {
|
|
|
76
103
|
const releaseExternalActivity = externalActivityMethods.has(method)
|
|
77
104
|
? lifecycle.beginExternalActivity?.()
|
|
78
105
|
: undefined;
|
|
106
|
+
let holdActivity = false;
|
|
79
107
|
try {
|
|
80
108
|
const provider = leaderboardRoute === 'remote'
|
|
81
109
|
&& (method === 'leaderboard.submitScore' || method === 'leaderboard.open')
|
|
@@ -98,7 +126,17 @@ export function createCapacitorPlatformGateway(input) {
|
|
|
98
126
|
}
|
|
99
127
|
try {
|
|
100
128
|
await providers.assertMethodReady(method, payload, productType);
|
|
101
|
-
|
|
129
|
+
const data = await sendRequest(method, payload, provider);
|
|
130
|
+
if (releaseExternalActivity !== undefined
|
|
131
|
+
&& method === 'commerce.v2.requestPurchase'
|
|
132
|
+
&& isRecord(data) && data.outcome === 'launched') {
|
|
133
|
+
// The store UI is still open: stay paused until this product's
|
|
134
|
+
// purchase event arrives or the safety timeout ends. The v1 path
|
|
135
|
+
// has no event to wait for and releases when its request returns.
|
|
136
|
+
holdActivity = true;
|
|
137
|
+
holdLaunchedPurchaseActivity(releaseExternalActivity, isRecord(payload) ? payload.logicalId : undefined);
|
|
138
|
+
}
|
|
139
|
+
return data;
|
|
102
140
|
}
|
|
103
141
|
catch (error) {
|
|
104
142
|
// Only non-mutating guest/status queries may degrade. A stale readiness
|
|
@@ -112,7 +150,53 @@ export function createCapacitorPlatformGateway(input) {
|
|
|
112
150
|
return await sendRequest(method, payload, undefined);
|
|
113
151
|
}
|
|
114
152
|
finally {
|
|
115
|
-
|
|
153
|
+
if (!holdActivity) {
|
|
154
|
+
releaseExternalActivity?.();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Keeps the external activity of a `launched` purchase open until the
|
|
160
|
+
* provider reports that product (`purchaseUpdated` or `purchaseError`, or an
|
|
161
|
+
* event without a logical id), the safety timeout ends, or the gateway is
|
|
162
|
+
* disposed. Every path releases exactly once.
|
|
163
|
+
*/
|
|
164
|
+
function holdLaunchedPurchaseActivity(release, logicalId) {
|
|
165
|
+
let done = false;
|
|
166
|
+
let unsubscribe;
|
|
167
|
+
const finish = () => {
|
|
168
|
+
if (done) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
done = true;
|
|
172
|
+
launchedPurchaseHolds.delete(finish);
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
unsubscribe?.();
|
|
175
|
+
release();
|
|
176
|
+
};
|
|
177
|
+
launchedPurchaseHolds.add(finish);
|
|
178
|
+
const timer = setTimeout(finish, launchedPurchaseActivityTimeoutMs);
|
|
179
|
+
if (commerceProvider === undefined) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const subscription = commerceProvider.bridge.subscribe(({ event }) => {
|
|
183
|
+
if (event.type === 'subscriptionUpdated') {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const eventLogicalId = event.type === 'purchaseUpdated'
|
|
187
|
+
? event.purchase.logicalId
|
|
188
|
+
: event.logicalId;
|
|
189
|
+
if (eventLogicalId === undefined || typeof logicalId !== 'string'
|
|
190
|
+
|| eventLogicalId === logicalId) {
|
|
191
|
+
finish();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
// A buffered event may have finished the hold while subscribing.
|
|
195
|
+
if (done) {
|
|
196
|
+
subscription();
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
unsubscribe = subscription;
|
|
116
200
|
}
|
|
117
201
|
}
|
|
118
202
|
async function sendRequest(method, payload, provider) {
|
|
@@ -161,22 +245,28 @@ export function createCapacitorPlatformGateway(input) {
|
|
|
161
245
|
target: input.target,
|
|
162
246
|
async getCapabilities() {
|
|
163
247
|
const base = await request('runtime.getCapabilities', {});
|
|
164
|
-
|
|
248
|
+
// One bounded registry read serves the feature states and the commerce
|
|
249
|
+
// block, so a stalled or invalid provider degrades the snapshot instead
|
|
250
|
+
// of rejecting it, and the boolean flags always follow the block.
|
|
251
|
+
const { availability: states, commerce } = await providers.getCapabilitySnapshot();
|
|
165
252
|
const rewardedAds = states.rewardedAds === 'available';
|
|
166
253
|
const interstitialAds = states.interstitialAds === 'available';
|
|
167
254
|
const bannerAds = states.bannerAds === 'available';
|
|
255
|
+
const legacy = commerce === undefined ? undefined : deriveLegacyCommerceCapabilities(commerce);
|
|
168
256
|
return {
|
|
169
257
|
...base,
|
|
170
|
-
nativeIap: states.nativeIap === 'available',
|
|
171
|
-
subscriptionIap: states.subscriptionIap === 'available',
|
|
258
|
+
nativeIap: legacy?.nativeIap ?? states.nativeIap === 'available',
|
|
259
|
+
subscriptionIap: legacy?.subscriptionIap ?? states.subscriptionIap === 'available',
|
|
172
260
|
nativeAds: rewardedAds || interstitialAds || bannerAds,
|
|
173
261
|
rewardedAds,
|
|
174
262
|
interstitialAds,
|
|
175
263
|
bannerAds,
|
|
176
264
|
nativeLeaderboard: states.nativeLeaderboard === 'available',
|
|
177
265
|
providerAvailability: states,
|
|
266
|
+
...(commerce === undefined ? {} : { commerce }),
|
|
178
267
|
};
|
|
179
268
|
},
|
|
269
|
+
...(commerceV2 === undefined ? {} : { commerceV2 }),
|
|
180
270
|
identity: {
|
|
181
271
|
getPlayer: () => request('identity.getPlayer', {}),
|
|
182
272
|
getSession: () => request('identity.getSession', {}),
|