@mpgd/adapter-devvit 0.10.0 → 0.11.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.
@@ -0,0 +1,49 @@
1
+ import type { ProductInfo, ProductType } from '@mpgd/platform';
2
+ import { type DevvitCommerceProduct } from './payments.js';
3
+ /** The catalog target whose `platformProductIds` entry is the Devvit SKU. */
4
+ export declare const defaultDevvitCatalogTarget = "reddit";
5
+ /**
6
+ * The product catalog fields this module reads (`ProductCatalog` from
7
+ * `@mpgd/catalog` fits). Structural so the adapter adds no catalog dependency.
8
+ */
9
+ export interface DevvitCatalogProductLike {
10
+ readonly id: string;
11
+ readonly type: ProductType;
12
+ readonly platformProductIds: Readonly<Partial<Record<string, string>>>;
13
+ }
14
+ export interface DevvitCatalogLike {
15
+ readonly products: readonly DevvitCatalogProductLike[];
16
+ }
17
+ /** A logical product paired with its Devvit SKU, as the server bridge handler takes it. */
18
+ export interface DevvitCatalogBridgeProduct {
19
+ readonly productId: string;
20
+ readonly sku: string;
21
+ }
22
+ export interface CreateDevvitCommerceProductsOptions {
23
+ /** Defaults to `reddit`. */
24
+ readonly target?: string;
25
+ /**
26
+ * Static product info for the webview provider's `fetchProducts`. Devvit
27
+ * prices live products in Reddit Gold on the server (`payments.getProducts`),
28
+ * so the default lists the logical id as title with a Reddit Gold price
29
+ * placeholder; a game that wants localized copy supplies its own.
30
+ */
31
+ readonly describe?: (product: DevvitCatalogProductLike, sku: string) => Pick<ProductInfo, 'title' | 'description' | 'price'>;
32
+ }
33
+ /**
34
+ * The `{ productId, sku }` pairs `createDevvitCommerceV2BridgeHandler` takes,
35
+ * read from the catalog's `platformProductIds[target]` entries. The catalog is
36
+ * the single source of the Devvit SKUs and its rules apply, as
37
+ * `resolveProductPlatformId` reads them: the SKU is trimmed, and a product
38
+ * without an entry, or with a blank one, is not sold on Reddit. A subscription
39
+ * is skipped because Devvit sells one-time goods only. Duplicate product ids
40
+ * and SKUs are rejected.
41
+ */
42
+ export declare function createDevvitCommerceBridgeProducts(catalog: DevvitCatalogLike, target?: string): readonly DevvitCatalogBridgeProduct[];
43
+ /**
44
+ * The webview products for `createDevvitPlatformGateway({ commerce: { products } })`
45
+ * and `createDevvitCommerceAdapter`, derived from the same catalog entries as
46
+ * `createDevvitCommerceBridgeProducts`, so the webview and the server agree on
47
+ * every SKU.
48
+ */
49
+ export declare function createDevvitCommerceProducts(catalog: DevvitCatalogLike, options?: CreateDevvitCommerceProductsOptions): readonly DevvitCommerceProduct[];
@@ -0,0 +1,79 @@
1
+ import { normalizeDevvitCommerceProducts } from './payments.js';
2
+ /** The catalog target whose `platformProductIds` entry is the Devvit SKU. */
3
+ export const defaultDevvitCatalogTarget = 'reddit';
4
+ /**
5
+ * The `{ productId, sku }` pairs `createDevvitCommerceV2BridgeHandler` takes,
6
+ * read from the catalog's `platformProductIds[target]` entries. The catalog is
7
+ * the single source of the Devvit SKUs and its rules apply, as
8
+ * `resolveProductPlatformId` reads them: the SKU is trimmed, and a product
9
+ * without an entry, or with a blank one, is not sold on Reddit. A subscription
10
+ * is skipped because Devvit sells one-time goods only. Duplicate product ids
11
+ * and SKUs are rejected.
12
+ */
13
+ export function createDevvitCommerceBridgeProducts(catalog, target = defaultDevvitCatalogTarget) {
14
+ const skus = new Set();
15
+ const productIds = new Set();
16
+ const products = [];
17
+ for (const product of catalog.products) {
18
+ const sku = readSku(product, target);
19
+ if (sku === undefined || product.type === 'subscription') {
20
+ continue;
21
+ }
22
+ if (productIds.has(product.id)) {
23
+ throw new TypeError(`Duplicate Devvit logical product ID: ${product.id}`);
24
+ }
25
+ if (skus.has(sku)) {
26
+ throw new TypeError(`Duplicate Devvit product SKU: ${sku}`);
27
+ }
28
+ productIds.add(product.id);
29
+ skus.add(sku);
30
+ products.push(Object.freeze({ productId: product.id, sku }));
31
+ }
32
+ return Object.freeze(products);
33
+ }
34
+ /**
35
+ * The webview products for `createDevvitPlatformGateway({ commerce: { products } })`
36
+ * and `createDevvitCommerceAdapter`, derived from the same catalog entries as
37
+ * `createDevvitCommerceBridgeProducts`, so the webview and the server agree on
38
+ * every SKU.
39
+ */
40
+ export function createDevvitCommerceProducts(catalog, options = {}) {
41
+ const target = options.target ?? defaultDevvitCatalogTarget;
42
+ const describe = options.describe ?? describeDevvitProduct;
43
+ const bridgeProducts = createDevvitCommerceBridgeProducts(catalog, target);
44
+ const byId = new Map(catalog.products.map((product) => [product.id, product]));
45
+ return normalizeDevvitCommerceProducts(bridgeProducts.map((bridgeProduct) => {
46
+ const product = byId.get(bridgeProduct.productId);
47
+ if (product === undefined) {
48
+ throw new TypeError(`Devvit product ${bridgeProduct.productId} is missing from the catalog.`);
49
+ }
50
+ return {
51
+ info: {
52
+ id: product.id,
53
+ type: product.type,
54
+ ...describe(product, bridgeProduct.sku),
55
+ },
56
+ sku: bridgeProduct.sku,
57
+ };
58
+ }));
59
+ }
60
+ function describeDevvitProduct(product) {
61
+ return {
62
+ title: product.id,
63
+ description: '',
64
+ price: { formatted: 'Reddit Gold', currencyCode: 'GOLD' },
65
+ };
66
+ }
67
+ /** The catalog's reading of a platform id: own key only, trimmed, blank as unsold. */
68
+ function readSku(product, target) {
69
+ const identifiers = product.platformProductIds;
70
+ if (!Object.prototype.hasOwnProperty.call(identifiers, target)) {
71
+ return undefined;
72
+ }
73
+ const value = identifiers[target];
74
+ if (typeof value !== 'string') {
75
+ return undefined;
76
+ }
77
+ const sku = value.trim();
78
+ return sku.length === 0 ? undefined : sku;
79
+ }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ import { type CreateDevvitCommerceProviderInput, type DevvitOrderSource } from '
5
5
  export * from './commerce-contract.js';
6
6
  export * from './commerce-provider.js';
7
7
  export * from './payments.js';
8
+ export * from './catalog-products.js';
8
9
  export declare const defaultDevvitRpcEndpoint = "/api/mpgd/rpc";
9
10
  /** Bridge error of `createDevvitOrpcBridge` when the oRPC request itself failed. */
10
11
  export declare const devvitBridgeNetworkErrorCode: 'DEVVIT_BRIDGE_NETWORK_ERROR';
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { createDevvitCommerceProvider, } from './commerce-provider.js';
7
7
  export * from './commerce-contract.js';
8
8
  export * from './commerce-provider.js';
9
9
  export * from './payments.js';
10
+ export * from './catalog-products.js';
10
11
  export const defaultDevvitRpcEndpoint = defaultBridgeRpcEndpoint;
11
12
  /** Bridge error of `createDevvitOrpcBridge` when the oRPC request itself failed. */
12
13
  export const devvitBridgeNetworkErrorCode = 'DEVVIT_BRIDGE_NETWORK_ERROR';
@@ -1,4 +1,5 @@
1
1
  export * from './commerce-bridge.js';
2
+ export { createDevvitCommerceBridgeProducts, defaultDevvitCatalogTarget, type DevvitCatalogBridgeProduct, type DevvitCatalogLike, type DevvitCatalogProductLike, } from '../catalog-products.js';
2
3
  export * from './payment-order.js';
3
4
  export * from './post-operation.js';
4
5
  export * from './redis-post-operation-store.js';
@@ -1,4 +1,5 @@
1
1
  export * from './commerce-bridge.js';
2
+ export { createDevvitCommerceBridgeProducts, defaultDevvitCatalogTarget, } from '../catalog-products.js';
2
3
  export * from './payment-order.js';
3
4
  export * from './post-operation.js';
4
5
  export * from './redis-post-operation-store.js';
package/dist/web.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ShareResult } from '@mpgd/platform';
2
+ import type { DevvitCheckoutResult, DevvitPaymentsClient } from './payments.js';
2
3
  import { type DevvitSurfaceOptions, type DevvitSurfaceResult } from './surface.js';
3
4
  import { type DevvitPreviewViewModeOptions, type DevvitPreviewViewModeResult, type DevvitViewModeOptions, type DevvitViewModeResult } from './view-mode.js';
4
5
  export type { DevvitSurfaceClient, DevvitSurfaceMode, DevvitSurfaceOptions, DevvitSurfaceResult, } from './surface.js';
@@ -30,3 +31,30 @@ export declare function requestDevvitExpandedMode(event: MouseEvent, entry: stri
30
31
  * completed sharing. Devvit does not report a completion callback.
31
32
  */
32
33
  export declare function presentDevvitShareSheet(options: DevvitShareSheetOptions): Promise<ShareResult>;
34
+ /** `OrderResultEvent` of `@devvit/payments/client`, as the webview reads it. */
35
+ export interface DevvitOrderResultEventLike {
36
+ /** `OrderResultStatus`: 0 cancelled, 1 success, 2 error (or its name). */
37
+ readonly status: number | string;
38
+ readonly orderId?: string | undefined;
39
+ readonly errorCode?: number | undefined;
40
+ readonly errorMessage?: string | undefined;
41
+ }
42
+ /** The checkout half of `DevvitPaymentsClient`; the v2 provider needs nothing else. */
43
+ export type DevvitWebPaymentsClient = Pick<DevvitPaymentsClient, 'purchase'>;
44
+ /**
45
+ * Maps one Reddit checkout answer to the adapter's `DevvitCheckoutResult`:
46
+ * `STATUS_SUCCESS` with an order id is `completed` (the order id is Reddit's;
47
+ * the backend ledger decides the grant), `STATUS_SUCCESS` without one is
48
+ * `pending` so the reconciler resolves it from the paid-order list,
49
+ * `STATUS_CANCELLED` is `cancelled`, and `STATUS_ERROR` is `failed` with the
50
+ * platform code kept as `storeCode`. An unknown status is `pending`: an order
51
+ * may exist and nothing is granted or dropped on the client.
52
+ */
53
+ export declare function toDevvitCheckoutResult(event: DevvitOrderResultEventLike): DevvitCheckoutResult;
54
+ /**
55
+ * `purchase()` from `@devvit/payments/client` (re-exported by
56
+ * `@devvit/web/client`) as the checkout client of `createDevvitPlatformGateway({
57
+ * commerce: { client } })`. One SKU per checkout, no client metadata: Reddit's
58
+ * order id is the only identity the purchase carries.
59
+ */
60
+ export declare function createDevvitWebPaymentsClient(): DevvitWebPaymentsClient;
package/dist/web.js CHANGED
@@ -65,3 +65,67 @@ function isAbortError(error) {
65
65
  && error !== null
66
66
  && error.name === 'AbortError';
67
67
  }
68
+ const successStatuses = new Set([1, 'STATUS_SUCCESS', 'SUCCESS']);
69
+ const cancelledStatuses = new Set([
70
+ 0,
71
+ 'STATUS_CANCELLED',
72
+ 'CANCELLED',
73
+ ]);
74
+ const errorStatuses = new Set([2, 'STATUS_ERROR', 'ERROR']);
75
+ /**
76
+ * Maps one Reddit checkout answer to the adapter's `DevvitCheckoutResult`:
77
+ * `STATUS_SUCCESS` with an order id is `completed` (the order id is Reddit's;
78
+ * the backend ledger decides the grant), `STATUS_SUCCESS` without one is
79
+ * `pending` so the reconciler resolves it from the paid-order list,
80
+ * `STATUS_CANCELLED` is `cancelled`, and `STATUS_ERROR` is `failed` with the
81
+ * platform code kept as `storeCode`. An unknown status is `pending`: an order
82
+ * may exist and nothing is granted or dropped on the client.
83
+ */
84
+ export function toDevvitCheckoutResult(event) {
85
+ const orderId = typeof event.orderId === 'string' && event.orderId.trim().length > 0
86
+ ? event.orderId.trim()
87
+ : undefined;
88
+ if (successStatuses.has(event.status)) {
89
+ return orderId === undefined ? { status: 'pending' } : { status: 'completed', orderId };
90
+ }
91
+ if (cancelledStatuses.has(event.status)) {
92
+ return {
93
+ status: 'cancelled',
94
+ ...(orderId === undefined ? {} : { orderId }),
95
+ error: { code: 'USER_CANCELLED', storeCode: 'DEVVIT_CHECKOUT_CANCELLED', retryable: false },
96
+ };
97
+ }
98
+ if (errorStatuses.has(event.status)) {
99
+ const storeCode = event.errorCode === undefined
100
+ ? 'DEVVIT_CHECKOUT_FAILED'
101
+ : String(event.errorCode);
102
+ return {
103
+ status: 'failed',
104
+ ...(orderId === undefined ? {} : { orderId }),
105
+ error: {
106
+ code: storeCode,
107
+ storeCode,
108
+ ...(typeof event.errorMessage === 'string' && event.errorMessage.length > 0
109
+ ? { message: event.errorMessage }
110
+ : {}),
111
+ },
112
+ };
113
+ }
114
+ return { status: 'pending', ...(orderId === undefined ? {} : { orderId }) };
115
+ }
116
+ /**
117
+ * `purchase()` from `@devvit/payments/client` (re-exported by
118
+ * `@devvit/web/client`) as the checkout client of `createDevvitPlatformGateway({
119
+ * commerce: { client } })`. One SKU per checkout, no client metadata: Reddit's
120
+ * order id is the only identity the purchase carries.
121
+ */
122
+ export function createDevvitWebPaymentsClient() {
123
+ return {
124
+ async purchase(sku) {
125
+ if (typeof sku !== 'string' || sku.trim().length === 0) {
126
+ throw new TypeError('Devvit checkout requires a non-empty SKU.');
127
+ }
128
+ return toDevvitCheckoutResult(await browserClient.purchase(sku));
129
+ },
130
+ };
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-devvit",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Reddit Devvit Web platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,9 +55,9 @@
55
55
  ],
56
56
  "dependencies": {
57
57
  "@devvit/web": "0.14.1",
58
- "@mpgd/bridge": "0.12.2",
59
- "@mpgd/game-services": "0.21.1",
60
- "@mpgd/platform": "0.21.0"
58
+ "@mpgd/game-services": "0.23.0",
59
+ "@mpgd/bridge": "0.12.3",
60
+ "@mpgd/platform": "0.22.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^24.0.0",