@mpgd/adapter-devvit 0.9.19 → 0.10.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,472 @@
1
+ import { assertRequestPurchaseSupported, assertStoreProduct, CommerceError, createCommerceEventBuffer, isCommerceErrorCode, } from '@mpgd/platform/commerce';
2
+ import { createDevvitCommerceCapabilities, createDevvitStorePurchase, devvitCommerceStoreId, maximumDevvitAvailableOrders, readDevvitIdentifier, } from './commerce-contract.js';
3
+ import { normalizeDevvitCommerceProducts, } from './payments.js';
4
+ /**
5
+ * Refreshes an order reported ahead of the list (by a checkout result or a
6
+ * signal) survives without a listing showing it; it is forgotten on the next
7
+ * one, so a re-listed order is reported again.
8
+ */
9
+ export const unlistedOrderGraceRefreshes = 7;
10
+ /** Store codes the provider attaches to failures of its own. */
11
+ export const devvitCommerceStoreCodes = Object.freeze({
12
+ productNotConfigured: 'DEVVIT_PRODUCT_NOT_CONFIGURED',
13
+ checkoutFailed: 'DEVVIT_CHECKOUT_FAILED',
14
+ checkoutCancelled: 'DEVVIT_CHECKOUT_CANCELLED',
15
+ ordersUnavailable: 'DEVVIT_ORDERS_UNAVAILABLE',
16
+ });
17
+ /**
18
+ * Commerce v2 provider for Devvit (RFC profile `devvit`, see
19
+ * `createDevvitCommerceCapabilities`). Reddit's fulfillment handler settles a
20
+ * purchase on the server: it writes the backend ledger, and the client learns
21
+ * of the grant through the backend's server-push lookup verifier, which
22
+ * answers `pending` until that entry exists. The provider therefore never
23
+ * grants and has nothing to finish.
24
+ *
25
+ * - `requestPurchase` opens the Reddit checkout. A checkout that already
26
+ * carries the created order id answers `purchased` with the order id as
27
+ * `transactionId` and `token` and `devvit.order.v1` evidence; the backend
28
+ * verify is what grants it. A checkout without an order id, or one that
29
+ * ended `pending`, is `launched`: an order may exist, and the reconciler
30
+ * resolves it from `getAvailablePurchases`. A dismissed checkout is
31
+ * `cancelled`; a failed one is `failed` with the platform code as
32
+ * `storeCode` (`OUTCOME_UNKNOWN` and `ALREADY_OWNED` classes stay open as
33
+ * `launched`, as the RFC requires, and so does a `purchase()` call that
34
+ * rejects, since a missing response does not prove a missing order).
35
+ * - `getAvailablePurchases` lists the paid orders whose delivery the server
36
+ * has not acknowledged (the order source without its delivered entries),
37
+ * bounded by `maximumDevvitAvailableOrders`; `includePending` adds nothing
38
+ * because Devvit has no store-side pending order.
39
+ * - `restorePurchases` re-reads the order source and emits `purchaseUpdated`
40
+ * once per order not reported before, delivered orders included: Reddit
41
+ * marks an order delivered as soon as the fulfillment handler answered, so
42
+ * a session that lost its checkout result finds the order this way and the
43
+ * backend lookup answers `granted` for it. The first listener triggers the
44
+ * same read. Events before the first `subscribe` are buffered.
45
+ * - The provider emits no `purchaseError`: every checkout outcome is a
46
+ * synchronous result of the purchase call.
47
+ */
48
+ export function createDevvitCommerceProvider(input) {
49
+ const store = input.store ?? devvitCommerceStoreId;
50
+ if (store.length === 0) {
51
+ throw new TypeError('A Devvit commerce provider requires a non-empty store id.');
52
+ }
53
+ const products = normalizeDevvitCommerceProducts(input.products);
54
+ const oneTimeProductCount = products.filter((product) => product.info.type !== 'subscription').length;
55
+ const productsById = new Map(products.map((product) => [product.info.id, product]));
56
+ const productsBySku = new Map(products.map((product) => [product.sku, product]));
57
+ const buffer = createCommerceEventBuffer();
58
+ const listeners = new Set();
59
+ /** Orders already reported, through a checkout result or a `purchaseUpdated` event. */
60
+ const reportedOrders = new Set();
61
+ /**
62
+ * Orders reported ahead of the list (a checkout result or a signal) that no
63
+ * listing has confirmed yet, with the unlisted refreshes left before one is
64
+ * forgotten: the server may list a just-created order a moment later, and
65
+ * it must not be reported twice.
66
+ */
67
+ const unlistedOrders = new Map();
68
+ /** Bounds the reported set (oldest first) and drops grace entries of evicted orders. */
69
+ const boundReported = () => {
70
+ for (const oldest of reportedOrders) {
71
+ if (reportedOrders.size <= maximumDevvitAvailableOrders) {
72
+ break;
73
+ }
74
+ reportedOrders.delete(oldest);
75
+ }
76
+ for (const tracked of [...unlistedOrders.keys()]) {
77
+ if (!reportedOrders.has(tracked)) {
78
+ unlistedOrders.delete(tracked);
79
+ }
80
+ }
81
+ };
82
+ const emit = (event) => {
83
+ if (listeners.size === 0) {
84
+ buffer.push(event);
85
+ return;
86
+ }
87
+ for (const listener of [...listeners]) {
88
+ try {
89
+ listener(event);
90
+ }
91
+ catch (error) {
92
+ // One listener must not stop the others from receiving the event.
93
+ console.warn('Devvit commerce listener failed.', errorMessage(error));
94
+ }
95
+ }
96
+ };
97
+ const toPurchase = (order, source) => {
98
+ const product = productsBySku.get(order.sku);
99
+ if (product === undefined) {
100
+ // An order for a SKU this build does not sell is not this game's purchase.
101
+ return undefined;
102
+ }
103
+ return createDevvitStorePurchase({
104
+ store,
105
+ orderId: order.orderId,
106
+ sku: order.sku,
107
+ logicalId: product.info.id,
108
+ source,
109
+ paidAt: order.paidAt,
110
+ environment: order.environment,
111
+ delivered: order.delivered,
112
+ });
113
+ };
114
+ const readPaidOrders = async () => {
115
+ let orders;
116
+ try {
117
+ orders = await input.orders.listPaidOrders();
118
+ }
119
+ catch (error) {
120
+ throw toCommerceError(error);
121
+ }
122
+ if (orders.length > maximumDevvitAvailableOrders) {
123
+ console.warn('Devvit paid-order list exceeds the available-purchase bound.', {
124
+ limit: maximumDevvitAvailableOrders,
125
+ ignoredOrderCount: orders.length - maximumDevvitAvailableOrders,
126
+ });
127
+ }
128
+ return orders.slice(0, maximumDevvitAvailableOrders).filter((order) => readDevvitIdentifier(order.orderId) !== undefined
129
+ && readDevvitIdentifier(order.sku) !== undefined);
130
+ };
131
+ /**
132
+ * Reports a paid order once. The purchase is built before any bookkeeping,
133
+ * so an order the contract rejects is neither marked reported nor emitted.
134
+ */
135
+ const reportOrder = (order, aheadOfList = false) => {
136
+ if (reportedOrders.has(order.orderId)) {
137
+ return;
138
+ }
139
+ let purchase;
140
+ try {
141
+ purchase = toPurchase(order, 'order-list');
142
+ }
143
+ catch (error) {
144
+ // One invalid order must not hide the others.
145
+ console.warn('Devvit paid order was not reported.', errorMessage(error));
146
+ return;
147
+ }
148
+ if (purchase === undefined) {
149
+ return;
150
+ }
151
+ reportedOrders.add(order.orderId);
152
+ if (aheadOfList) {
153
+ unlistedOrders.set(order.orderId, unlistedOrderGraceRefreshes);
154
+ }
155
+ boundReported();
156
+ emit({ type: 'purchaseUpdated', purchase });
157
+ };
158
+ /** Re-reads the paid orders and reports the ones not reported yet. */
159
+ const refreshOrders = async () => {
160
+ const orders = await readPaidOrders();
161
+ const listed = new Set(orders.map((order) => order.orderId));
162
+ for (const order of orders) {
163
+ reportOrder(order);
164
+ }
165
+ for (const orderId of [...reportedOrders]) {
166
+ if (listed.has(orderId)) {
167
+ // A listing confirmed the order; it is a listed order now.
168
+ unlistedOrders.delete(orderId);
169
+ continue;
170
+ }
171
+ const remaining = unlistedOrders.get(orderId);
172
+ if (remaining !== undefined && remaining > 0) {
173
+ // Not listed yet: the server may still be catching up.
174
+ unlistedOrders.set(orderId, remaining - 1);
175
+ continue;
176
+ }
177
+ // An order the server stopped listing (or never listed within the grace)
178
+ // was delivered; it is forgotten so a re-listed order is reported again.
179
+ unlistedOrders.delete(orderId);
180
+ reportedOrders.delete(orderId);
181
+ }
182
+ };
183
+ const refreshInBackground = () => {
184
+ void refreshOrders().catch((error) => {
185
+ console.warn('Devvit paid-order refresh failed.', errorMessage(error));
186
+ });
187
+ };
188
+ /** Reports the orders a signal carries; the read that follows confirms the rest. */
189
+ const reportSignalledOrders = (orders) => {
190
+ for (const order of orders.slice(0, maximumDevvitAvailableOrders)) {
191
+ if (readDevvitIdentifier(order.orderId) === undefined
192
+ || readDevvitIdentifier(order.sku) === undefined) {
193
+ continue;
194
+ }
195
+ reportOrder(order, true);
196
+ }
197
+ };
198
+ let detachOrderSignal;
199
+ const attachOrderSignal = () => {
200
+ if (detachOrderSignal !== undefined || input.onOrdersChanged === undefined) {
201
+ return;
202
+ }
203
+ detachOrderSignal = input.onOrdersChanged((change) => {
204
+ if (change?.orders !== undefined && Array.isArray(change.orders)) {
205
+ reportSignalledOrders(change.orders);
206
+ }
207
+ refreshInBackground();
208
+ });
209
+ };
210
+ const detachOrderSignalIfIdle = () => {
211
+ if (listeners.size > 0 || detachOrderSignal === undefined) {
212
+ return;
213
+ }
214
+ const detach = detachOrderSignal;
215
+ detachOrderSignal = undefined;
216
+ detach();
217
+ };
218
+ const toRequestPurchaseResult = (product, result) => {
219
+ switch (result.status) {
220
+ case 'completed': {
221
+ const orderId = readDevvitIdentifier(result.orderId);
222
+ if (orderId === undefined) {
223
+ // Reddit answered without an order id: an order may still exist.
224
+ return { outcome: 'launched' };
225
+ }
226
+ const purchase = createDevvitStorePurchase({
227
+ store,
228
+ orderId,
229
+ sku: product.sku,
230
+ logicalId: product.info.id,
231
+ source: 'checkout',
232
+ });
233
+ reportedOrders.add(orderId);
234
+ unlistedOrders.set(orderId, unlistedOrderGraceRefreshes);
235
+ boundReported();
236
+ return { outcome: 'purchased', purchase };
237
+ }
238
+ case 'pending':
239
+ return { outcome: 'launched' };
240
+ case 'cancelled':
241
+ return { outcome: 'cancelled', error: toCancelledErrorInfo(result.error) };
242
+ case 'failed': {
243
+ const error = toCheckoutErrorInfo(result.error);
244
+ if (error.code === 'OUTCOME_UNKNOWN' || error.code === 'ALREADY_OWNED') {
245
+ // RFC "Errors": an unfinished order may exist, so the attempt stays
246
+ // open for the reconciler instead of becoming terminal.
247
+ return { outcome: 'launched' };
248
+ }
249
+ if (error.code === 'USER_CANCELLED') {
250
+ return { outcome: 'cancelled', error };
251
+ }
252
+ return { outcome: 'failed', error };
253
+ }
254
+ default:
255
+ return {
256
+ outcome: 'failed',
257
+ error: new CommerceError('INVALID_RESPONSE', {
258
+ message: `Devvit checkout answered an unknown status: ${String(result.status)}.`,
259
+ }).toCommerceErrorInfo(),
260
+ };
261
+ }
262
+ };
263
+ const provider = {
264
+ store,
265
+ async getCapabilities() {
266
+ let availability = 'available';
267
+ if (oneTimeProductCount === 0) {
268
+ // A catalog without a consumable or durable product sells nothing here.
269
+ availability = 'configuration-required';
270
+ }
271
+ else if (input.orders.getAvailability !== undefined) {
272
+ try {
273
+ availability = await input.orders.getAvailability();
274
+ }
275
+ catch (error) {
276
+ console.warn('Devvit commerce availability read failed.', errorMessage(error));
277
+ availability = 'temporarily-unavailable';
278
+ }
279
+ }
280
+ return createDevvitCommerceCapabilities(availability);
281
+ },
282
+ async fetchProducts(request = {}) {
283
+ if ((request.type ?? 'all') === 'subscription') {
284
+ return [];
285
+ }
286
+ const listed = [];
287
+ for (const product of products) {
288
+ if (request.logicalIds !== undefined && !request.logicalIds.includes(product.info.id)) {
289
+ continue;
290
+ }
291
+ const storeProduct = toStoreProduct(store, product);
292
+ if (storeProduct !== undefined) {
293
+ listed.push(storeProduct);
294
+ }
295
+ }
296
+ return listed;
297
+ },
298
+ async requestPurchase(request) {
299
+ const product = productsById.get(request.logicalId);
300
+ if (product === undefined) {
301
+ return {
302
+ outcome: 'failed',
303
+ error: new CommerceError('ITEM_UNAVAILABLE', {
304
+ message: `Devvit product ${request.logicalId} is not configured.`,
305
+ storeCode: devvitCommerceStoreCodes.productNotConfigured,
306
+ }).toCommerceErrorInfo(),
307
+ };
308
+ }
309
+ // Both one-time kinds are sold; a subscription-typed product and a
310
+ // quantity above one are rejected before the checkout opens.
311
+ assertRequestPurchaseSupported(createDevvitCommerceCapabilities('available'), request, purchaseKindOf(product.info.type));
312
+ let result;
313
+ try {
314
+ // Reddit's order id is authoritative; no client metadata is attached.
315
+ result = await input.client.purchase(product.sku);
316
+ }
317
+ catch (error) {
318
+ // `purchase()` rejects when the checkout effect produced no usable
319
+ // response, which does not prove that no order was created: the
320
+ // outcome is unknown, so the attempt stays open (RFC "Errors") and
321
+ // the reconciler resolves it from the paid-order list.
322
+ input.onCheckoutError?.(error);
323
+ refreshInBackground();
324
+ return { outcome: 'launched' };
325
+ }
326
+ const outcome = toRequestPurchaseResult(product, result);
327
+ if (outcome.outcome === 'launched') {
328
+ // The order, if any, is in the server's list; report it without
329
+ // waiting for a signal.
330
+ refreshInBackground();
331
+ }
332
+ return outcome;
333
+ },
334
+ async getAvailablePurchases(_request = {}) {
335
+ const purchases = [];
336
+ for (const order of await readPaidOrders()) {
337
+ if (order.delivered === true) {
338
+ // Delivered: the fulfillment handler settled it; it reaches the
339
+ // reconciler as an event, not as an unfinished purchase.
340
+ continue;
341
+ }
342
+ try {
343
+ const purchase = toPurchase(order, 'order-list');
344
+ if (purchase !== undefined) {
345
+ purchases.push(purchase);
346
+ }
347
+ }
348
+ catch (error) {
349
+ // One invalid order must not hide the others, as on a refresh.
350
+ console.warn('Devvit paid order was not listed.', errorMessage(error));
351
+ }
352
+ }
353
+ return purchases;
354
+ },
355
+ async restorePurchases() {
356
+ await refreshOrders();
357
+ },
358
+ subscribe(listener) {
359
+ listeners.add(listener);
360
+ attachOrderSignal();
361
+ if (listeners.size === 1) {
362
+ buffer.drain((event) => {
363
+ try {
364
+ listener(event);
365
+ }
366
+ catch (error) {
367
+ console.warn('Devvit commerce listener failed.', errorMessage(error));
368
+ }
369
+ });
370
+ // A fresh listener (a new session, or a restart) learns of the recent
371
+ // orders, delivered ones included, so a checkout result lost to a
372
+ // restart still reaches the reconciler; it matches them to its
373
+ // records by store and transaction id.
374
+ refreshInBackground();
375
+ }
376
+ return () => {
377
+ listeners.delete(listener);
378
+ detachOrderSignalIfIdle();
379
+ };
380
+ },
381
+ dispose() {
382
+ listeners.clear();
383
+ detachOrderSignalIfIdle();
384
+ },
385
+ };
386
+ return provider;
387
+ }
388
+ function toStoreProduct(store, product) {
389
+ const { info } = product;
390
+ const kind = info.type === 'consumable'
391
+ ? 'consumable'
392
+ : info.type === 'non_consumable'
393
+ ? 'durable'
394
+ : undefined;
395
+ if (kind === undefined) {
396
+ return undefined;
397
+ }
398
+ try {
399
+ return assertStoreProduct({
400
+ logicalId: info.id,
401
+ store,
402
+ storeSku: product.sku,
403
+ kind,
404
+ title: info.title,
405
+ description: info.description,
406
+ price: { formatted: info.price.formatted, currencyCode: info.price.currencyCode },
407
+ });
408
+ }
409
+ catch (error) {
410
+ throw new CommerceError('INVALID_RESPONSE', {
411
+ message: `Devvit product ${product.sku} is configured with invalid product info.`,
412
+ cause: error,
413
+ });
414
+ }
415
+ }
416
+ function purchaseKindOf(type) {
417
+ switch (type) {
418
+ case 'consumable':
419
+ return 'consumable';
420
+ case 'non_consumable':
421
+ return 'durable';
422
+ case 'subscription':
423
+ return 'subscription';
424
+ }
425
+ }
426
+ /** A cancelled checkout: `USER_CANCELLED`, with the platform's own code kept as `storeCode`. */
427
+ function toCancelledErrorInfo(failure) {
428
+ const storeCode = failure?.storeCode
429
+ ?? (failure !== undefined && !isCommerceErrorCode(failure.code) ? failure.code : undefined)
430
+ ?? devvitCommerceStoreCodes.checkoutCancelled;
431
+ return new CommerceError('USER_CANCELLED', {
432
+ message: failure?.message ?? 'Devvit checkout was cancelled.',
433
+ storeCode,
434
+ retryable: false,
435
+ }).toCommerceErrorInfo();
436
+ }
437
+ /**
438
+ * The normalized error of a failed checkout: a v2 code the client classified
439
+ * is kept with its own `storeCode`; any other code is `UNKNOWN` and travels
440
+ * as `storeCode`. `retryable` defaults from the normalized code.
441
+ */
442
+ function toCheckoutErrorInfo(failure) {
443
+ if (failure === undefined) {
444
+ return new CommerceError('UNKNOWN', {
445
+ message: 'Devvit checkout failed.',
446
+ storeCode: devvitCommerceStoreCodes.checkoutFailed,
447
+ retryable: false,
448
+ }).toCommerceErrorInfo();
449
+ }
450
+ const normalized = isCommerceErrorCode(failure.code) ? failure.code : 'UNKNOWN';
451
+ const storeCode = failure.storeCode ?? (normalized === 'UNKNOWN' ? failure.code : undefined);
452
+ return new CommerceError(normalized, {
453
+ message: failure.message ?? `Devvit checkout failed: ${storeCode ?? normalized}.`,
454
+ ...(storeCode === undefined ? {} : { storeCode }),
455
+ ...(failure.retryable === undefined ? {} : { retryable: failure.retryable }),
456
+ }).toCommerceErrorInfo();
457
+ }
458
+ /** A thrown order-source failure as a retryable `STORE_UNAVAILABLE`, unless it already is a commerce error. */
459
+ function toCommerceError(error) {
460
+ if (error instanceof CommerceError) {
461
+ return error;
462
+ }
463
+ return new CommerceError('STORE_UNAVAILABLE', {
464
+ message: `Devvit paid orders are unavailable: ${errorMessage(error)}`,
465
+ storeCode: devvitCommerceStoreCodes.ordersUnavailable,
466
+ retryable: true,
467
+ cause: error,
468
+ });
469
+ }
470
+ function errorMessage(error) {
471
+ return error instanceof Error ? error.message : String(error);
472
+ }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,13 @@
1
- import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
1
+ import { type BridgeMethod, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import { type BridgeRpcClient, type BridgeRpcEndpoint } from '@mpgd/bridge/orpc';
3
- import type { PlatformGateway } from '@mpgd/platform';
3
+ import { type PlatformGateway } from '@mpgd/platform';
4
+ import { type CreateDevvitCommerceProviderInput, type DevvitOrderSource } from './commerce-provider.js';
5
+ export * from './commerce-contract.js';
6
+ export * from './commerce-provider.js';
4
7
  export * from './payments.js';
5
8
  export declare const defaultDevvitRpcEndpoint = "/api/mpgd/rpc";
9
+ /** Bridge error of `createDevvitOrpcBridge` when the oRPC request itself failed. */
10
+ export declare const devvitBridgeNetworkErrorCode: 'DEVVIT_BRIDGE_NETWORK_ERROR';
6
11
  type BridgeErrorResponse = Extract<BridgeResponse, {
7
12
  readonly ok: false;
8
13
  }>;
@@ -15,14 +20,48 @@ export declare class DevvitBridgeError extends Error {
15
20
  readonly retryable: boolean;
16
21
  constructor(response: BridgeErrorResponse);
17
22
  }
23
+ /**
24
+ * Webview side of commerce v2: the checkout client and the product catalog.
25
+ * The paid-order list and the capability availability come from the server
26
+ * bridge (`commerce.v2.getAvailablePurchases`, `commerce.v2.getCapabilities`),
27
+ * which the server serves through `createDevvitCommerceV2BridgeHandler` from
28
+ * `@mpgd/adapter-devvit/server`.
29
+ */
30
+ export type DevvitGatewayCommerceOptions = Omit<CreateDevvitCommerceProviderInput, 'orders'>;
18
31
  export interface DevvitPlatformGatewayOptions {
19
32
  readonly appVersion: string;
20
33
  readonly buildId: string;
21
34
  readonly bridge?: DevvitBridge;
22
35
  readonly fallbackBridge?: DevvitBridge;
23
36
  readonly rpcEndpoint?: BridgeRpcEndpoint;
37
+ /**
38
+ * Installs the commerce v2 provider (`createDevvitCommerceProvider`) as
39
+ * `commerceV2`, with the `commerce` capability block and `nativeIap` and
40
+ * `subscriptionIap` derived from it. Absent, the gateway offers no
41
+ * `commerceV2`; the v1 `commerce` surface is the bridge's either way.
42
+ *
43
+ * Not yet usable in production before C2: the backend lookup verifier for
44
+ * `devvit.order.v1` does not exist yet, so a verify request for a Devvit
45
+ * purchase is refused today instead of answering `pending`, and no journal
46
+ * record can reach `granted`. Enable it once the backend can answer.
47
+ */
48
+ readonly commerce?: DevvitGatewayCommerceOptions;
24
49
  }
25
50
  export declare function createDevvitPlatformGateway(input: DevvitPlatformGatewayOptions): PlatformGateway;
51
+ /**
52
+ * The webview provider's order source over the server bridge: the paid orders
53
+ * the server lists as available purchases, mapped back to orders (the
54
+ * provider rebuilds the purchases under its own store id), and the
55
+ * availability the server reports. `COMMERCE_V2_UNAVAILABLE` from a bridge
56
+ * without the commerce handler is `configuration-required`; another bridge
57
+ * failure that is retryable (or the transport failing) keeps the availability
58
+ * the server last answered for the capability read (`temporarily-unavailable`
59
+ * before any answer; a non-retryable failure or an invalid answer fails
60
+ * closed) and is
61
+ * a `CommerceError` for a list read (`NETWORK` for a transport failure,
62
+ * otherwise `UNKNOWN` with the bridge code as `storeCode`).
63
+ */
64
+ export declare function createDevvitBridgeOrderSource(request: <TData>(method: BridgeMethod, payload: unknown) => Promise<TData>): DevvitOrderSource;
26
65
  export declare function createDevvitOrpcBridge(input?: {
27
66
  readonly endpoint?: BridgeRpcEndpoint | undefined;
28
67
  readonly fetch?: typeof fetch | undefined;