@mpgd/adapter-devvit 0.7.0 → 0.8.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/dist/index.d.ts +1 -1
- package/dist/index.js +3 -2
- package/dist/payments.d.ts +21 -0
- package/dist/payments.js +82 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/payment-order.d.ts +8 -0
- package/dist/server/payment-order.js +80 -0
- package/dist/server/post-operation.d.ts +50 -0
- package/dist/server/post-operation.js +212 -10
- package/dist/server/redis-post-operation-store.d.ts +15 -2
- package/dist/server/redis-post-operation-store.js +45 -0
- package/dist/view-mode.d.ts +31 -0
- package/dist/view-mode.js +45 -0
- package/dist/web.d.ts +8 -0
- package/dist/web.js +11 -0
- package/package.json +12 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
|
|
2
2
|
import { type BridgeRpcClient, type BridgeRpcEndpoint } from '@mpgd/bridge/orpc';
|
|
3
3
|
import type { PlatformGateway } from '@mpgd/platform';
|
|
4
|
+
export * from './payments.js';
|
|
4
5
|
export declare const defaultDevvitRpcEndpoint = "/api/mpgd/rpc";
|
|
5
6
|
type BridgeErrorResponse = Extract<BridgeResponse, {
|
|
6
7
|
readonly ok: false;
|
|
@@ -28,4 +29,3 @@ export declare function createDevvitOrpcBridge(input?: {
|
|
|
28
29
|
readonly client?: BridgeRpcClient | undefined;
|
|
29
30
|
}): DevvitBridge;
|
|
30
31
|
export declare function createDevvitSandboxBridge(): DevvitBridge;
|
|
31
|
-
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createBridgeError, } from '@mpgd/bridge';
|
|
2
2
|
import { createBridgeOrpcClient, defaultBridgeRpcEndpoint, } from '@mpgd/bridge/orpc';
|
|
3
|
+
export * from './payments.js';
|
|
3
4
|
export const defaultDevvitRpcEndpoint = defaultBridgeRpcEndpoint;
|
|
4
5
|
const devvitFallbackStoragePrefix = 'mpgd:devvit:fallback:';
|
|
5
6
|
export class DevvitBridgeError extends Error {
|
|
@@ -233,7 +234,7 @@ export function createDevvitSandboxBridge() {
|
|
|
233
234
|
nativeAds: false,
|
|
234
235
|
rewardedAds: false,
|
|
235
236
|
interstitialAds: false,
|
|
236
|
-
nativeLeaderboard:
|
|
237
|
+
nativeLeaderboard: false,
|
|
237
238
|
achievements: false,
|
|
238
239
|
cloudSave: true,
|
|
239
240
|
socialShare: false,
|
|
@@ -299,7 +300,7 @@ export function createDevvitSandboxBridge() {
|
|
|
299
300
|
});
|
|
300
301
|
case 'leaderboard.submitScore':
|
|
301
302
|
return ok(input, {
|
|
302
|
-
submitted:
|
|
303
|
+
submitted: false,
|
|
303
304
|
});
|
|
304
305
|
case 'storage.load': {
|
|
305
306
|
const payload = optionalObjectPayload(input.payload);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CommerceAdapter, Entitlement, PlatformEvidenceEnvelope, PlatformGateway, ProductInfo, PurchaseResult } from '@mpgd/platform';
|
|
2
|
+
export interface DevvitCheckoutResult {
|
|
3
|
+
readonly status: PurchaseResult['status'];
|
|
4
|
+
readonly orderId?: string;
|
|
5
|
+
readonly evidence?: PlatformEvidenceEnvelope;
|
|
6
|
+
}
|
|
7
|
+
export interface DevvitPaymentsClient {
|
|
8
|
+
purchase(sku: string): Promise<DevvitCheckoutResult>;
|
|
9
|
+
getEntitlements(): Promise<readonly Entitlement[]>;
|
|
10
|
+
}
|
|
11
|
+
export interface DevvitCommerceProduct {
|
|
12
|
+
readonly info: ProductInfo;
|
|
13
|
+
readonly sku: string;
|
|
14
|
+
}
|
|
15
|
+
export interface CreateDevvitCommerceAdapterInput {
|
|
16
|
+
readonly products: readonly DevvitCommerceProduct[];
|
|
17
|
+
readonly client: DevvitPaymentsClient;
|
|
18
|
+
readonly onCheckoutError?: (error: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
export declare function createDevvitCommerceAdapter(input: CreateDevvitCommerceAdapterInput): CommerceAdapter;
|
|
21
|
+
export declare function withDevvitCommerceAdapter(gateway: PlatformGateway, commerce: CommerceAdapter): PlatformGateway;
|
package/dist/payments.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export function createDevvitCommerceAdapter(input) {
|
|
2
|
+
const products = normalizeProducts(input.products);
|
|
3
|
+
const productsById = new Map(products.map((product) => [product.info.id, product]));
|
|
4
|
+
return {
|
|
5
|
+
async getProducts() {
|
|
6
|
+
return products.map((product) => product.info);
|
|
7
|
+
},
|
|
8
|
+
async purchase(request) {
|
|
9
|
+
const product = productsById.get(request.productId);
|
|
10
|
+
if (product === undefined) {
|
|
11
|
+
return failedPurchase();
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
// Reddit's platform order ID is authoritative. Do not attach client operation
|
|
15
|
+
// metadata whose identifiers can exceed Devvit's metadata constraints.
|
|
16
|
+
const result = await input.client.purchase(product.sku);
|
|
17
|
+
return {
|
|
18
|
+
status: result.status,
|
|
19
|
+
...(result.orderId === undefined ? {} : { transactionId: result.orderId }),
|
|
20
|
+
// Devvit server fulfillment and a subsequent entitlement read are authoritative.
|
|
21
|
+
entitlementIds: [],
|
|
22
|
+
...(result.evidence === undefined ? {} : { evidence: result.evidence }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
input.onCheckoutError?.(error);
|
|
27
|
+
return failedPurchase();
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
async restore() {
|
|
31
|
+
return { restoredEntitlements: await input.client.getEntitlements() };
|
|
32
|
+
},
|
|
33
|
+
async getEntitlements() {
|
|
34
|
+
return input.client.getEntitlements();
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function withDevvitCommerceAdapter(gateway, commerce) {
|
|
39
|
+
if (gateway.target !== 'reddit') {
|
|
40
|
+
throw new TypeError('Devvit commerce can only be installed on a reddit gateway.');
|
|
41
|
+
}
|
|
42
|
+
if ('getTargetRuntime' in gateway && typeof gateway.getTargetRuntime === 'function') {
|
|
43
|
+
throw new TypeError('Devvit commerce must be installed before target availability is applied.');
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
...gateway,
|
|
47
|
+
async getCapabilities() {
|
|
48
|
+
return {
|
|
49
|
+
...await gateway.getCapabilities(),
|
|
50
|
+
nativeIap: true,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
commerce,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function normalizeProducts(input) {
|
|
57
|
+
const productIds = new Set();
|
|
58
|
+
const skus = new Set();
|
|
59
|
+
return Object.freeze(input.map((product) => {
|
|
60
|
+
if (productIds.has(product.info.id)) {
|
|
61
|
+
throw new TypeError(`Duplicate Devvit logical product ID: ${product.info.id}`);
|
|
62
|
+
}
|
|
63
|
+
if (skus.has(product.sku)) {
|
|
64
|
+
throw new TypeError(`Duplicate Devvit product SKU: ${product.sku}`);
|
|
65
|
+
}
|
|
66
|
+
if (product.sku.length === 0 || product.sku.trim() !== product.sku) {
|
|
67
|
+
throw new TypeError('Devvit product SKU must be a non-empty identifier.');
|
|
68
|
+
}
|
|
69
|
+
productIds.add(product.info.id);
|
|
70
|
+
skus.add(product.sku);
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
info: Object.freeze({ ...product.info }),
|
|
73
|
+
sku: product.sku,
|
|
74
|
+
});
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
function failedPurchase() {
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
status: 'failed',
|
|
80
|
+
entitlementIds: [],
|
|
81
|
+
});
|
|
82
|
+
}
|
package/dist/server/index.d.ts
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type FulfillPlatformOrderInput, type RefundPlatformOrderInput } from '@mpgd/game-services/platform-order';
|
|
2
|
+
export interface NormalizeDevvitPaymentOrderInput {
|
|
3
|
+
readonly order: unknown;
|
|
4
|
+
/** Authenticated player ID supplied by the Devvit server request context. */
|
|
5
|
+
readonly playerId: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function normalizeDevvitFulfillmentOrder(input: NormalizeDevvitPaymentOrderInput): FulfillPlatformOrderInput;
|
|
8
|
+
export declare function normalizeDevvitRefundOrder(input: NormalizeDevvitPaymentOrderInput): RefundPlatformOrderInput;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { assertFulfillPlatformOrderInput, assertRefundPlatformOrderInput, } from '@mpgd/game-services/platform-order';
|
|
2
|
+
const devvitPaymentOrderEvidenceSchema = 'devvit.payment-order.v1';
|
|
3
|
+
export function normalizeDevvitFulfillmentOrder(input) {
|
|
4
|
+
const order = normalizeDevvitOrder(input, 'PAID');
|
|
5
|
+
const normalized = Object.freeze({
|
|
6
|
+
target: 'reddit',
|
|
7
|
+
orderId: order.orderId,
|
|
8
|
+
playerId: order.playerId,
|
|
9
|
+
platformSku: order.platformSku,
|
|
10
|
+
paidAt: order.occurredAt,
|
|
11
|
+
evidence: order.evidence,
|
|
12
|
+
});
|
|
13
|
+
return assertFulfillPlatformOrderInput(normalized);
|
|
14
|
+
}
|
|
15
|
+
export function normalizeDevvitRefundOrder(input) {
|
|
16
|
+
const order = normalizeDevvitOrder(input, 'REVERTED');
|
|
17
|
+
const normalized = Object.freeze({
|
|
18
|
+
target: 'reddit',
|
|
19
|
+
orderId: order.orderId,
|
|
20
|
+
playerId: order.playerId,
|
|
21
|
+
platformSku: order.platformSku,
|
|
22
|
+
refundedAt: order.occurredAt,
|
|
23
|
+
evidence: order.evidence,
|
|
24
|
+
});
|
|
25
|
+
return assertRefundPlatformOrderInput(normalized);
|
|
26
|
+
}
|
|
27
|
+
function normalizeDevvitOrder(input, expectedStatus) {
|
|
28
|
+
const playerId = requireString(input.playerId, 'Devvit payment context playerId');
|
|
29
|
+
const record = requireRecord(input.order, 'Devvit payment order');
|
|
30
|
+
if (record.status !== expectedStatus) {
|
|
31
|
+
throw new TypeError(`Devvit payment order status must be ${expectedStatus}.`);
|
|
32
|
+
}
|
|
33
|
+
if (!Array.isArray(record.products) || record.products.length !== 1) {
|
|
34
|
+
throw new TypeError('Devvit payment order must contain exactly one product.');
|
|
35
|
+
}
|
|
36
|
+
const product = requireRecord(record.products[0], 'Devvit payment product');
|
|
37
|
+
const orderId = requireString(record.id, 'order.id');
|
|
38
|
+
if (record.userId !== undefined
|
|
39
|
+
&& requireString(record.userId, 'order.userId') !== playerId) {
|
|
40
|
+
throw new TypeError('Devvit payment order userId must match the authenticated context playerId.');
|
|
41
|
+
}
|
|
42
|
+
const platformSku = requireString(product.sku, 'order.products[0].sku');
|
|
43
|
+
const occurredAt = requireString(record.updatedAt ?? record.createdAt, 'order.updatedAt');
|
|
44
|
+
const evidencePayload = {
|
|
45
|
+
orderId,
|
|
46
|
+
status: expectedStatus,
|
|
47
|
+
occurredAt,
|
|
48
|
+
playerIdSource: 'devvit-context',
|
|
49
|
+
};
|
|
50
|
+
copyOptionalEvidenceField(record, evidencePayload, 'postId');
|
|
51
|
+
copyOptionalEvidenceField(record, evidencePayload, 'environment');
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
orderId,
|
|
54
|
+
playerId,
|
|
55
|
+
platformSku,
|
|
56
|
+
occurredAt,
|
|
57
|
+
evidence: Object.freeze({
|
|
58
|
+
schema: devvitPaymentOrderEvidenceSchema,
|
|
59
|
+
payload: Object.freeze(evidencePayload),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function copyOptionalEvidenceField(source, target, key) {
|
|
64
|
+
const value = source[key];
|
|
65
|
+
if (value !== undefined) {
|
|
66
|
+
target[key] = requireString(value, `order.${key}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function requireString(input, label) {
|
|
70
|
+
if (typeof input !== 'string') {
|
|
71
|
+
throw new TypeError(`${label} must be a string.`);
|
|
72
|
+
}
|
|
73
|
+
return input;
|
|
74
|
+
}
|
|
75
|
+
function requireRecord(input, label) {
|
|
76
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
77
|
+
throw new TypeError(`${label} must be an object.`);
|
|
78
|
+
}
|
|
79
|
+
return input;
|
|
80
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
declare const operationSchemaVersion: 1;
|
|
2
|
+
export declare const devvitPostOperationMaximumPendingPageLimit = 100;
|
|
2
3
|
export type DevvitJsonValue = null | boolean | number | string | readonly DevvitJsonValue[] | DevvitJsonObject;
|
|
3
4
|
export interface DevvitJsonObject {
|
|
4
5
|
readonly [key: string]: DevvitJsonValue;
|
|
@@ -15,6 +16,24 @@ export interface DevvitDurableOperationStore {
|
|
|
15
16
|
/** Deletes a lease only when the current token matches. */
|
|
16
17
|
releaseLease(key: string, token: string): Promise<void>;
|
|
17
18
|
}
|
|
19
|
+
export interface DevvitDurableOperationIndexEntry {
|
|
20
|
+
readonly indexKey: string;
|
|
21
|
+
readonly member: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Optional durable-store capability used to discover pending operations without
|
|
25
|
+
* scanning the Redis keyspace. Implementations must establish stable registry
|
|
26
|
+
* membership before creating durable state and retain it for the record's
|
|
27
|
+
* lifetime. A failed state creation can leave a safe stale member, but a live
|
|
28
|
+
* operation must never be absent from its registry.
|
|
29
|
+
*/
|
|
30
|
+
export interface DevvitIndexedDurableOperationStore extends DevvitDurableOperationStore {
|
|
31
|
+
/** Idempotently establishes stable registry membership for an existing record. */
|
|
32
|
+
ensureIndexed(index: DevvitDurableOperationIndexEntry): Promise<void>;
|
|
33
|
+
createIndexed(key: string, value: string, index: DevvitDurableOperationIndexEntry): Promise<boolean>;
|
|
34
|
+
compareAndSetIndexed(key: string, expectedValue: string, nextValue: string, index: DevvitDurableOperationIndexEntry): Promise<boolean>;
|
|
35
|
+
listIndex(key: string, startExclusive: string | undefined, limit: number): Promise<readonly string[]>;
|
|
36
|
+
}
|
|
18
37
|
export interface DevvitPostOperationScope {
|
|
19
38
|
readonly appScope: string;
|
|
20
39
|
/** Use the canonical subreddit identifier, not a display name. */
|
|
@@ -106,10 +125,41 @@ export interface ExecuteDevvitPostOperationInput<TPayload extends DevvitJsonObje
|
|
|
106
125
|
export interface ReconcileDevvitPostOperationInput<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> extends DevvitPostOperationDescriptorInput<TPayload, TLaunchParams> {
|
|
107
126
|
readonly findCandidates: (input: DevvitPostPublishInput<TPayload, TLaunchParams>) => Promise<readonly DevvitPostReconciliationCandidate[]>;
|
|
108
127
|
}
|
|
128
|
+
export interface ListPendingDevvitPostOperationsInput {
|
|
129
|
+
readonly scope: DevvitPostOperationScope;
|
|
130
|
+
/** Opaque continuation returned by the previous page for the same scope. */
|
|
131
|
+
readonly cursor?: string;
|
|
132
|
+
readonly limit?: number;
|
|
133
|
+
}
|
|
134
|
+
export type DevvitPendingPostOperation<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> = {
|
|
135
|
+
readonly status: 'prepared';
|
|
136
|
+
readonly operationId: string;
|
|
137
|
+
readonly updatedAt: number;
|
|
138
|
+
readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
|
|
139
|
+
} | {
|
|
140
|
+
readonly status: 'reconciliation-required';
|
|
141
|
+
readonly operationId: string;
|
|
142
|
+
readonly reason: 'submission-attempted';
|
|
143
|
+
readonly updatedAt: number;
|
|
144
|
+
readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
|
|
145
|
+
} | {
|
|
146
|
+
readonly status: 'terminal-unresolved';
|
|
147
|
+
readonly operationId: string;
|
|
148
|
+
readonly reason: 'multiple-exact-matches' | 'published-receipt-conflict';
|
|
149
|
+
readonly postIds: readonly string[];
|
|
150
|
+
readonly updatedAt: number;
|
|
151
|
+
readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
|
|
152
|
+
};
|
|
153
|
+
export interface DevvitPendingPostOperationPage<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
|
|
154
|
+
readonly operations: readonly DevvitPendingPostOperation<TPayload, TLaunchParams>[];
|
|
155
|
+
readonly nextCursor?: string;
|
|
156
|
+
}
|
|
109
157
|
export interface DevvitPostOperationCoordinator<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
|
|
110
158
|
execute(input: ExecuteDevvitPostOperationInput<TPayload, TLaunchParams>): Promise<DevvitPostOperationResult<TPayload, TLaunchParams>>;
|
|
111
159
|
read(input: DevvitPostOperationDescriptorInput<TPayload, TLaunchParams>): Promise<DevvitPostOperationResult<TPayload, TLaunchParams>>;
|
|
112
160
|
reconcile(input: ReconcileDevvitPostOperationInput<TPayload, TLaunchParams>): Promise<DevvitPostOperationResult<TPayload, TLaunchParams>>;
|
|
161
|
+
/** Lists durable work only; it never invokes a Reddit API or reconciliation callback. */
|
|
162
|
+
listPending(input: ListPendingDevvitPostOperationsInput): Promise<DevvitPendingPostOperationPage<TPayload, TLaunchParams>>;
|
|
113
163
|
}
|
|
114
164
|
export interface CreateDevvitPostOperationCoordinatorOptions<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
|
|
115
165
|
readonly definition: DevvitPostOperationDefinition<TPayload, TLaunchParams>;
|
|
@@ -3,6 +3,8 @@ const defaultLeaseTtlMs = 30000;
|
|
|
3
3
|
const defaultMaxPostDataBytes = 2048;
|
|
4
4
|
const defaultStoreAttempts = 8;
|
|
5
5
|
const maximumStoreAttempts = 32;
|
|
6
|
+
const defaultPendingPageLimit = 20;
|
|
7
|
+
export const devvitPostOperationMaximumPendingPageLimit = 100;
|
|
6
8
|
const maximumIdentifierLength = 128;
|
|
7
9
|
const maximumOperationTypeLength = 64;
|
|
8
10
|
const maximumLeaseTtlMs = 60 * 60 * 1000;
|
|
@@ -39,6 +41,7 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
39
41
|
const createToken = input.createToken ?? defaultTokenFactory;
|
|
40
42
|
const leaseTtlMs = input.leaseTtlMs ?? defaultLeaseTtlMs;
|
|
41
43
|
const storeAttempts = input.storeAttempts ?? defaultStoreAttempts;
|
|
44
|
+
const indexedStore = readIndexedStore(input.store);
|
|
42
45
|
assertPositiveSafeInteger(leaseTtlMs, 'leaseTtlMs');
|
|
43
46
|
if (leaseTtlMs > maximumLeaseTtlMs) {
|
|
44
47
|
throw new DevvitPostOperationValidationError(`leaseTtlMs must not exceed ${String(maximumLeaseTtlMs)}.`);
|
|
@@ -51,6 +54,7 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
51
54
|
execute,
|
|
52
55
|
read,
|
|
53
56
|
reconcile,
|
|
57
|
+
listPending,
|
|
54
58
|
};
|
|
55
59
|
async function execute(request) {
|
|
56
60
|
if (typeof request.publish !== 'function') {
|
|
@@ -63,7 +67,7 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
63
67
|
operationId: request.operationId,
|
|
64
68
|
});
|
|
65
69
|
for (let attempt = 0; attempt < storeAttempts; attempt += 1) {
|
|
66
|
-
let current = await
|
|
70
|
+
let current = await readStoredOperation(operationKey, descriptor);
|
|
67
71
|
if (current.kind === 'missing') {
|
|
68
72
|
const timestamp = readTimestamp(now);
|
|
69
73
|
const prepared = {
|
|
@@ -76,7 +80,7 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
76
80
|
updatedAt: timestamp,
|
|
77
81
|
};
|
|
78
82
|
const preparedRaw = serializeRecord(prepared);
|
|
79
|
-
if (!await
|
|
83
|
+
if (!await createStoredRecord(operationKey, preparedRaw, prepared)) {
|
|
80
84
|
continue;
|
|
81
85
|
}
|
|
82
86
|
current = { kind: 'record', raw: preparedRaw, record: prepared };
|
|
@@ -96,7 +100,7 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
96
100
|
updatedAt: timestamp,
|
|
97
101
|
};
|
|
98
102
|
const attemptedRaw = serializeRecord(attempted);
|
|
99
|
-
if (!await
|
|
103
|
+
if (!await compareAndSetStoredRecord(operationKey, current.raw, attemptedRaw, current.record, attempted)) {
|
|
100
104
|
continue;
|
|
101
105
|
}
|
|
102
106
|
return submitOnce(attempted, attemptedRaw, request.publish);
|
|
@@ -118,7 +122,7 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
118
122
|
operationType: definition.operationType,
|
|
119
123
|
operationId: request.operationId,
|
|
120
124
|
});
|
|
121
|
-
const current = await
|
|
125
|
+
const current = await readStoredOperation(operationKey, descriptor);
|
|
122
126
|
if (current.kind === 'missing') {
|
|
123
127
|
return missingResult(descriptor);
|
|
124
128
|
}
|
|
@@ -146,7 +150,7 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
146
150
|
operationType: definition.operationType,
|
|
147
151
|
operationId: request.operationId,
|
|
148
152
|
});
|
|
149
|
-
const current = await
|
|
153
|
+
const current = await readStoredOperation(operationKey, descriptor);
|
|
150
154
|
if (current.kind === 'missing') {
|
|
151
155
|
return missingResult(descriptor);
|
|
152
156
|
}
|
|
@@ -214,6 +218,56 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
214
218
|
}
|
|
215
219
|
}
|
|
216
220
|
}
|
|
221
|
+
async function listPending(request) {
|
|
222
|
+
if (indexedStore === undefined) {
|
|
223
|
+
throw new DevvitPostOperationStateError('The durable post operation store does not support operation registries.');
|
|
224
|
+
}
|
|
225
|
+
const scope = normalizeScope(request.scope);
|
|
226
|
+
const limit = normalizePendingPageLimit(request.limit);
|
|
227
|
+
const indexKey = createDevvitPostOperationIndexKey({
|
|
228
|
+
scope,
|
|
229
|
+
operationType: definition.operationType,
|
|
230
|
+
});
|
|
231
|
+
const cursor = normalizePendingCursor(request.cursor, scope, definition.operationType);
|
|
232
|
+
const members = await indexedStore.listIndex(indexKey, cursor, limit + 1);
|
|
233
|
+
const pageMembers = members.slice(0, limit);
|
|
234
|
+
const indexedOperations = await Promise.all(pageMembers.map(async (member) => {
|
|
235
|
+
const indexed = parsePendingIndexMember(member, scope, definition.operationType);
|
|
236
|
+
const raw = await indexedStore.read(indexed.operationKey);
|
|
237
|
+
if (raw === undefined) {
|
|
238
|
+
// Index-first creation can leave a conservative stale member if the
|
|
239
|
+
// following state write fails. It is safe to skip and advance the cursor.
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
const record = parseStoredRecord(raw, indexed.operationKey, definition);
|
|
243
|
+
const expectedOperationKey = createDevvitPostOperationKey({
|
|
244
|
+
scope: record.descriptor.mpgd,
|
|
245
|
+
operationType: record.descriptor.mpgd.operationType,
|
|
246
|
+
operationId: record.descriptor.mpgd.operationId,
|
|
247
|
+
});
|
|
248
|
+
const recordIndexKey = createDevvitPostOperationIndexKey({
|
|
249
|
+
scope: record.descriptor.mpgd,
|
|
250
|
+
operationType: record.descriptor.mpgd.operationType,
|
|
251
|
+
});
|
|
252
|
+
if (expectedOperationKey !== indexed.operationKey || recordIndexKey !== indexKey) {
|
|
253
|
+
throw new DevvitPostOperationStateError(`Pending operation index scope is inconsistent for state: ${indexed.operationKey}`);
|
|
254
|
+
}
|
|
255
|
+
const expectedMember = operationIndexMember(record);
|
|
256
|
+
if (record.phase === 'published') {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
if (expectedMember !== member) {
|
|
260
|
+
throw new DevvitPostOperationStateError(`Pending operation index is inconsistent for state: ${indexed.operationKey}`);
|
|
261
|
+
}
|
|
262
|
+
return pendingOperationResult(record);
|
|
263
|
+
}));
|
|
264
|
+
const operations = indexedOperations.filter((operation) => operation !== undefined);
|
|
265
|
+
const nextCursor = members.length > limit ? pageMembers.at(-1) : undefined;
|
|
266
|
+
return Object.freeze({
|
|
267
|
+
operations: Object.freeze(operations),
|
|
268
|
+
...(nextCursor === undefined ? {} : { nextCursor }),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
217
271
|
async function submitOnce(record, raw, publish) {
|
|
218
272
|
let receipt;
|
|
219
273
|
try {
|
|
@@ -251,10 +305,10 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
251
305
|
publishedAt: timestamp,
|
|
252
306
|
recovered,
|
|
253
307
|
};
|
|
254
|
-
if (await
|
|
308
|
+
if (await compareAndSetStoredRecord(source.operationKey, expectedRaw, serializeRecord(published), expectedRecord, published)) {
|
|
255
309
|
return publishedResult(published, successStatus);
|
|
256
310
|
}
|
|
257
|
-
const reread = await
|
|
311
|
+
const reread = await readStoredOperation(source.operationKey, source.descriptor);
|
|
258
312
|
if (reread.kind === 'missing' || reread.kind === 'conflict') {
|
|
259
313
|
throw new DevvitPostOperationStateError(`Durable post operation changed incompatibly while recording post ${postId}.`);
|
|
260
314
|
}
|
|
@@ -302,10 +356,10 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
302
356
|
postIds: expectedPostIds,
|
|
303
357
|
unresolvedAt: timestamp,
|
|
304
358
|
};
|
|
305
|
-
if (await
|
|
359
|
+
if (await compareAndSetStoredRecord(source.operationKey, expectedRaw, serializeRecord(terminal), expectedRecord, terminal)) {
|
|
306
360
|
return terminalResult(terminal);
|
|
307
361
|
}
|
|
308
|
-
const current = await
|
|
362
|
+
const current = await readStoredOperation(source.operationKey, source.descriptor);
|
|
309
363
|
if (current.kind !== 'record') {
|
|
310
364
|
throw new DevvitPostOperationStateError('Durable post operation changed incompatibly while recording a terminal outcome.');
|
|
311
365
|
}
|
|
@@ -324,6 +378,32 @@ export function createDevvitPostOperationCoordinator(input) {
|
|
|
324
378
|
}
|
|
325
379
|
return busyResult(source.descriptor, 'store-contention');
|
|
326
380
|
}
|
|
381
|
+
function createStoredRecord(operationKey, raw, record) {
|
|
382
|
+
if (indexedStore === undefined) {
|
|
383
|
+
return input.store.create(operationKey, raw);
|
|
384
|
+
}
|
|
385
|
+
return indexedStore.createIndexed(operationKey, raw, createOperationIndexEntry(undefined, record));
|
|
386
|
+
}
|
|
387
|
+
async function readStoredOperation(operationKey, descriptor) {
|
|
388
|
+
const current = await readOperation(operationKey, descriptor, definition, input.store);
|
|
389
|
+
if (indexedStore !== undefined && current.kind !== 'missing') {
|
|
390
|
+
try {
|
|
391
|
+
await indexedStore.ensureIndexed(createOperationIndexEntry(undefined, current.record));
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// A best-effort migration write must not hide an already-readable exact
|
|
395
|
+
// result. Indexed creation and every state CAS still require registry
|
|
396
|
+
// membership before mutating durable state.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return current;
|
|
400
|
+
}
|
|
401
|
+
function compareAndSetStoredRecord(operationKey, expectedRaw, nextRaw, previous, next) {
|
|
402
|
+
if (indexedStore === undefined) {
|
|
403
|
+
return input.store.compareAndSet(operationKey, expectedRaw, nextRaw);
|
|
404
|
+
}
|
|
405
|
+
return indexedStore.compareAndSetIndexed(operationKey, expectedRaw, nextRaw, createOperationIndexEntry(previous, next));
|
|
406
|
+
}
|
|
327
407
|
}
|
|
328
408
|
export function createDevvitPostOperationKey(input) {
|
|
329
409
|
const appScope = assertIdentifier(input.scope.appScope, 'appScope');
|
|
@@ -339,6 +419,128 @@ export function createDevvitPostOperationKey(input) {
|
|
|
339
419
|
'state',
|
|
340
420
|
].join(':');
|
|
341
421
|
}
|
|
422
|
+
function createDevvitPostOperationIndexKey(input) {
|
|
423
|
+
const scope = normalizeScope(input.scope);
|
|
424
|
+
assertOperationType(input.operationType);
|
|
425
|
+
return [
|
|
426
|
+
'mpgd:devvit:post-operation-index:v1',
|
|
427
|
+
encodeKeyComponent(scope.appScope),
|
|
428
|
+
encodeKeyComponent(scope.subredditId),
|
|
429
|
+
encodeKeyComponent(input.operationType),
|
|
430
|
+
'operations',
|
|
431
|
+
].join(':');
|
|
432
|
+
}
|
|
433
|
+
function readIndexedStore(store) {
|
|
434
|
+
const candidate = store;
|
|
435
|
+
const capabilities = [
|
|
436
|
+
candidate.createIndexed,
|
|
437
|
+
candidate.compareAndSetIndexed,
|
|
438
|
+
candidate.listIndex,
|
|
439
|
+
candidate.ensureIndexed,
|
|
440
|
+
];
|
|
441
|
+
const supportedCapabilities = capabilities.filter((capability) => typeof capability === 'function').length;
|
|
442
|
+
if (supportedCapabilities === 0) {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
if (supportedCapabilities !== capabilities.length) {
|
|
446
|
+
throw new DevvitPostOperationValidationError('Indexed durable operation stores must implement createIndexed, '
|
|
447
|
+
+ 'compareAndSetIndexed, ensureIndexed, and listIndex together.');
|
|
448
|
+
}
|
|
449
|
+
return candidate;
|
|
450
|
+
}
|
|
451
|
+
function createOperationIndexEntry(previous, next) {
|
|
452
|
+
const indexKey = createDevvitPostOperationIndexKey({
|
|
453
|
+
scope: next.descriptor.mpgd,
|
|
454
|
+
operationType: next.descriptor.mpgd.operationType,
|
|
455
|
+
});
|
|
456
|
+
if (previous !== undefined) {
|
|
457
|
+
const previousIndexKey = createDevvitPostOperationIndexKey({
|
|
458
|
+
scope: previous.descriptor.mpgd,
|
|
459
|
+
operationType: previous.descriptor.mpgd.operationType,
|
|
460
|
+
});
|
|
461
|
+
if (previousIndexKey !== indexKey) {
|
|
462
|
+
throw new DevvitPostOperationStateError('A durable post operation cannot move between operation registries.');
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
indexKey,
|
|
467
|
+
member: operationIndexMember(next),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
function operationIndexMember(record) {
|
|
471
|
+
return record.operationKey;
|
|
472
|
+
}
|
|
473
|
+
function parsePendingIndexMember(member, scope, operationType) {
|
|
474
|
+
if (typeof member !== 'string' || member.length > 8192) {
|
|
475
|
+
throw new DevvitPostOperationStateError('Pending operation index member is invalid.');
|
|
476
|
+
}
|
|
477
|
+
const operationKey = member;
|
|
478
|
+
const operationKeyPrefix = [
|
|
479
|
+
'mpgd:devvit:post-operation:v1',
|
|
480
|
+
encodeKeyComponent(scope.appScope),
|
|
481
|
+
encodeKeyComponent(scope.subredditId),
|
|
482
|
+
encodeKeyComponent(operationType),
|
|
483
|
+
'',
|
|
484
|
+
].join(':');
|
|
485
|
+
if (!operationKey.startsWith(operationKeyPrefix)
|
|
486
|
+
|| !operationKey.endsWith(':state')) {
|
|
487
|
+
throw new DevvitPostOperationStateError('Pending operation index member is invalid.');
|
|
488
|
+
}
|
|
489
|
+
return { operationKey };
|
|
490
|
+
}
|
|
491
|
+
function normalizePendingCursor(cursor, scope, operationType) {
|
|
492
|
+
if (cursor === undefined) {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
parsePendingIndexMember(cursor, scope, operationType);
|
|
496
|
+
return cursor;
|
|
497
|
+
}
|
|
498
|
+
function normalizePendingPageLimit(limit) {
|
|
499
|
+
const normalized = limit ?? defaultPendingPageLimit;
|
|
500
|
+
if (!Number.isSafeInteger(normalized)
|
|
501
|
+
|| normalized < 1
|
|
502
|
+
|| normalized > devvitPostOperationMaximumPendingPageLimit) {
|
|
503
|
+
throw new DevvitPostOperationValidationError('limit must be a safe integer from 1 to '
|
|
504
|
+
+ `${String(devvitPostOperationMaximumPendingPageLimit)}.`);
|
|
505
|
+
}
|
|
506
|
+
return normalized;
|
|
507
|
+
}
|
|
508
|
+
function normalizeScope(scope) {
|
|
509
|
+
return Object.freeze({
|
|
510
|
+
appScope: assertIdentifier(scope.appScope, 'appScope'),
|
|
511
|
+
subredditId: assertSubredditId(scope.subredditId),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
function pendingOperationResult(record) {
|
|
515
|
+
switch (record.phase) {
|
|
516
|
+
case 'prepared':
|
|
517
|
+
return Object.freeze({
|
|
518
|
+
status: 'prepared',
|
|
519
|
+
operationId: record.descriptor.mpgd.operationId,
|
|
520
|
+
updatedAt: record.updatedAt,
|
|
521
|
+
postData: record.descriptor,
|
|
522
|
+
});
|
|
523
|
+
case 'attempted':
|
|
524
|
+
return Object.freeze({
|
|
525
|
+
status: 'reconciliation-required',
|
|
526
|
+
operationId: record.descriptor.mpgd.operationId,
|
|
527
|
+
reason: 'submission-attempted',
|
|
528
|
+
updatedAt: record.updatedAt,
|
|
529
|
+
postData: record.descriptor,
|
|
530
|
+
});
|
|
531
|
+
case 'terminal-unresolved':
|
|
532
|
+
return Object.freeze({
|
|
533
|
+
status: 'terminal-unresolved',
|
|
534
|
+
operationId: record.descriptor.mpgd.operationId,
|
|
535
|
+
reason: record.reason,
|
|
536
|
+
postIds: record.postIds,
|
|
537
|
+
updatedAt: record.updatedAt,
|
|
538
|
+
postData: record.descriptor,
|
|
539
|
+
});
|
|
540
|
+
case 'published':
|
|
541
|
+
throw new DevvitPostOperationStateError('Published post operation cannot be returned as pending work.');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
342
544
|
function createDescriptor(definition, input) {
|
|
343
545
|
const scope = {
|
|
344
546
|
appScope: assertIdentifier(input.scope.appScope, 'appScope'),
|
|
@@ -373,7 +575,7 @@ async function readOperation(operationKey, descriptor, definition, store) {
|
|
|
373
575
|
}
|
|
374
576
|
const record = parseStoredRecord(raw, operationKey, definition);
|
|
375
577
|
if (canonicalJson(record.descriptor) !== canonicalJson(descriptor)) {
|
|
376
|
-
return { kind: 'conflict' };
|
|
578
|
+
return { kind: 'conflict', raw, record };
|
|
377
579
|
}
|
|
378
580
|
return { kind: 'record', raw, record };
|
|
379
581
|
}
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DevvitIndexedDurableOperationStore } from './post-operation.js';
|
|
2
2
|
export interface DevvitRedisSetOptions {
|
|
3
3
|
readonly nx?: boolean;
|
|
4
4
|
readonly xx?: boolean;
|
|
5
5
|
readonly expiration?: Date;
|
|
6
6
|
}
|
|
7
|
+
export interface DevvitRedisSortedSetMember {
|
|
8
|
+
readonly member: string;
|
|
9
|
+
readonly score: number;
|
|
10
|
+
}
|
|
11
|
+
export interface DevvitRedisRangeOptions {
|
|
12
|
+
readonly by: 'lex';
|
|
13
|
+
readonly limit: {
|
|
14
|
+
readonly offset: number;
|
|
15
|
+
readonly count: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
7
18
|
export interface DevvitRedisTransactionLike {
|
|
8
19
|
multi(): Promise<void>;
|
|
9
20
|
discard(): Promise<unknown>;
|
|
@@ -15,9 +26,11 @@ export interface DevvitRedisTransactionLike {
|
|
|
15
26
|
export interface DevvitRedisLike {
|
|
16
27
|
get(key: string): Promise<string | undefined>;
|
|
17
28
|
set(key: string, value: string, options?: DevvitRedisSetOptions): Promise<string | undefined | null>;
|
|
29
|
+
zAdd(key: string, ...members: readonly DevvitRedisSortedSetMember[]): Promise<unknown>;
|
|
30
|
+
zRange(key: string, start: string, stop: string, options: DevvitRedisRangeOptions): Promise<readonly DevvitRedisSortedSetMember[]>;
|
|
18
31
|
watch(...keys: readonly string[]): Promise<DevvitRedisTransactionLike>;
|
|
19
32
|
}
|
|
20
33
|
export interface DevvitRedisPostOperationStoreOptions {
|
|
21
34
|
readonly transactionAttempts?: number;
|
|
22
35
|
}
|
|
23
|
-
export declare function createDevvitRedisPostOperationStore(redis: DevvitRedisLike, options?: DevvitRedisPostOperationStoreOptions):
|
|
36
|
+
export declare function createDevvitRedisPostOperationStore(redis: DevvitRedisLike, options?: DevvitRedisPostOperationStoreOptions): DevvitIndexedDurableOperationStore;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { devvitPostOperationMaximumPendingPageLimit, } from './post-operation.js';
|
|
1
2
|
const defaultTransactionAttempts = 3;
|
|
2
3
|
const maximumTransactionAttempts = 32;
|
|
3
4
|
export function createDevvitRedisPostOperationStore(redis, options = {}) {
|
|
@@ -6,9 +7,21 @@ export function createDevvitRedisPostOperationStore(redis, options = {}) {
|
|
|
6
7
|
read(key) {
|
|
7
8
|
return redis.get(key);
|
|
8
9
|
},
|
|
10
|
+
async ensureIndexed(index) {
|
|
11
|
+
await redis.zAdd(index.indexKey, { member: index.member, score: 0 });
|
|
12
|
+
},
|
|
9
13
|
async create(key, value) {
|
|
10
14
|
return setIfAbsent(redis, key, value);
|
|
11
15
|
},
|
|
16
|
+
async createIndexed(key, value, index) {
|
|
17
|
+
if (index.member !== key) {
|
|
18
|
+
throw new TypeError('Indexed creation member must equal the durable state key.');
|
|
19
|
+
}
|
|
20
|
+
// Membership is intentionally established first. If the following state
|
|
21
|
+
// write fails, listing may see a stale member but cannot miss live work.
|
|
22
|
+
await redis.zAdd(index.indexKey, { member: index.member, score: 0 });
|
|
23
|
+
return setIfAbsent(redis, key, value);
|
|
24
|
+
},
|
|
12
25
|
async compareAndSet(key, expectedValue, nextValue) {
|
|
13
26
|
return mutateIfValue({
|
|
14
27
|
redis,
|
|
@@ -18,6 +31,32 @@ export function createDevvitRedisPostOperationStore(redis, options = {}) {
|
|
|
18
31
|
queueMutation: (transaction) => transaction.set(key, nextValue),
|
|
19
32
|
});
|
|
20
33
|
},
|
|
34
|
+
async compareAndSetIndexed(key, expectedValue, nextValue, index) {
|
|
35
|
+
if (index.member !== key) {
|
|
36
|
+
throw new TypeError('Indexed CAS member must equal the durable state key.');
|
|
37
|
+
}
|
|
38
|
+
// Stable registry membership was established before state creation and
|
|
39
|
+
// does not change across prepared, attempted, published, or terminal state.
|
|
40
|
+
// Re-adding it backfills records created before indexed stores existed.
|
|
41
|
+
await redis.zAdd(index.indexKey, { member: index.member, score: 0 });
|
|
42
|
+
return mutateIfValue({
|
|
43
|
+
redis,
|
|
44
|
+
key,
|
|
45
|
+
expectedValue,
|
|
46
|
+
transactionAttempts,
|
|
47
|
+
queueMutation: (transaction) => transaction.set(key, nextValue),
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
async listIndex(key, startExclusive, limit) {
|
|
51
|
+
assertIndexPageLimit(limit);
|
|
52
|
+
const members = await redis.zRange(key, startExclusive === undefined ? '-' : `(${startExclusive}`, '+', { by: 'lex', limit: { offset: 0, count: limit } });
|
|
53
|
+
return members.map((member) => {
|
|
54
|
+
if (member.score !== 0 || typeof member.member !== 'string') {
|
|
55
|
+
throw new Error(`Devvit Redis operation registry is invalid for key: ${key}`);
|
|
56
|
+
}
|
|
57
|
+
return member.member;
|
|
58
|
+
});
|
|
59
|
+
},
|
|
21
60
|
async createLease(key, token, expiresAt) {
|
|
22
61
|
assertExpirationDate(expiresAt);
|
|
23
62
|
return setIfAbsent(redis, key, token, { expiration: expiresAt });
|
|
@@ -100,6 +139,12 @@ function normalizeTransactionAttempts(value) {
|
|
|
100
139
|
}
|
|
101
140
|
return transactionAttempts;
|
|
102
141
|
}
|
|
142
|
+
function assertIndexPageLimit(value) {
|
|
143
|
+
const maximum = devvitPostOperationMaximumPendingPageLimit + 1;
|
|
144
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
145
|
+
throw new TypeError(`Devvit Redis index page limit must be a safe integer from 1 to ${String(maximum)}.`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
103
148
|
function assertExpirationDate(value) {
|
|
104
149
|
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
|
|
105
150
|
throw new TypeError('Devvit Redis lease expiration must be a valid Date.');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type DevvitViewMode = 'inline' | 'expanded';
|
|
2
|
+
export interface DevvitViewModeClient {
|
|
3
|
+
getWebViewMode(): DevvitViewMode;
|
|
4
|
+
}
|
|
5
|
+
export interface DevvitInlineModeContext {
|
|
6
|
+
/**
|
|
7
|
+
* Loads gameplay in the current inline mode. Call this only from an explicit
|
|
8
|
+
* user action so the initial post entry can remain a lightweight launch screen.
|
|
9
|
+
*/
|
|
10
|
+
readonly startGameplay: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export type DevvitViewModeResult = {
|
|
13
|
+
readonly mode: 'inline';
|
|
14
|
+
readonly startGameplay: () => Promise<void>;
|
|
15
|
+
} | {
|
|
16
|
+
readonly mode: 'expanded';
|
|
17
|
+
};
|
|
18
|
+
export interface DevvitViewModeOptions {
|
|
19
|
+
readonly client: DevvitViewModeClient;
|
|
20
|
+
readonly mountInlineMode: (context: DevvitInlineModeContext) => void | Promise<void>;
|
|
21
|
+
readonly loadGameplay: (mode: DevvitViewMode) => void | Promise<void>;
|
|
22
|
+
readonly onModeUnavailable?: (error: unknown) => void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Starts a Devvit web view using Reddit's official inline and expanded mode
|
|
26
|
+
* terminology. Inline mode mounts lightweight content first and exposes a
|
|
27
|
+
* retryable, concurrency-safe gameplay loader for a later user action.
|
|
28
|
+
* Expanded mode loads gameplay immediately. Outside a Devvit host, expanded
|
|
29
|
+
* mode is used as the local-browser fallback.
|
|
30
|
+
*/
|
|
31
|
+
export declare function startDevvitViewMode(options: DevvitViewModeOptions): Promise<DevvitViewModeResult>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts a Devvit web view using Reddit's official inline and expanded mode
|
|
3
|
+
* terminology. Inline mode mounts lightweight content first and exposes a
|
|
4
|
+
* retryable, concurrency-safe gameplay loader for a later user action.
|
|
5
|
+
* Expanded mode loads gameplay immediately. Outside a Devvit host, expanded
|
|
6
|
+
* mode is used as the local-browser fallback.
|
|
7
|
+
*/
|
|
8
|
+
export async function startDevvitViewMode(options) {
|
|
9
|
+
let mode;
|
|
10
|
+
try {
|
|
11
|
+
mode = options.client.getWebViewMode();
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
options.onModeUnavailable?.(error);
|
|
15
|
+
await options.loadGameplay('expanded');
|
|
16
|
+
return { mode: 'expanded' };
|
|
17
|
+
}
|
|
18
|
+
if (mode === 'expanded') {
|
|
19
|
+
await options.loadGameplay('expanded');
|
|
20
|
+
return { mode: 'expanded' };
|
|
21
|
+
}
|
|
22
|
+
const startGameplay = createInlineModeGameplayStarter(options.loadGameplay);
|
|
23
|
+
await options.mountInlineMode({ startGameplay });
|
|
24
|
+
return {
|
|
25
|
+
mode: 'inline',
|
|
26
|
+
startGameplay,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function createInlineModeGameplayStarter(loadGameplay) {
|
|
30
|
+
let activeLoad;
|
|
31
|
+
return () => {
|
|
32
|
+
if (activeLoad !== undefined) {
|
|
33
|
+
return activeLoad.promise;
|
|
34
|
+
}
|
|
35
|
+
const pendingLoad = Promise.resolve().then(() => loadGameplay('inline'));
|
|
36
|
+
const loadState = { promise: pendingLoad };
|
|
37
|
+
activeLoad = loadState;
|
|
38
|
+
void pendingLoad.catch(() => {
|
|
39
|
+
if (activeLoad === loadState) {
|
|
40
|
+
activeLoad = undefined;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return pendingLoad;
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/web.d.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import type { ShareResult } from '@mpgd/platform';
|
|
2
2
|
import { type DevvitSurfaceOptions, type DevvitSurfaceResult } from './surface.js';
|
|
3
|
+
import { type DevvitViewModeOptions, type DevvitViewModeResult } from './view-mode.js';
|
|
3
4
|
export type { DevvitSurfaceClient, DevvitSurfaceMode, DevvitSurfaceOptions, DevvitSurfaceResult, } from './surface.js';
|
|
5
|
+
export type { DevvitInlineModeContext, DevvitViewMode, DevvitViewModeClient, DevvitViewModeOptions, DevvitViewModeResult, } from './view-mode.js';
|
|
4
6
|
export type DevvitWebSurfaceOptions = Omit<DevvitSurfaceOptions, 'client'>;
|
|
7
|
+
export type DevvitWebViewOptions = Omit<DevvitViewModeOptions, 'client'>;
|
|
5
8
|
export interface DevvitShareSheetOptions {
|
|
6
9
|
readonly data?: string;
|
|
7
10
|
readonly title?: string;
|
|
8
11
|
readonly text?: string;
|
|
9
12
|
}
|
|
10
13
|
export declare function startDevvitWebSurface(options: DevvitWebSurfaceOptions): Promise<DevvitSurfaceResult>;
|
|
14
|
+
/**
|
|
15
|
+
* Starts a Devvit web view in inline or expanded mode. Inline mode can defer
|
|
16
|
+
* gameplay until an explicit user action while remaining inside the post.
|
|
17
|
+
*/
|
|
18
|
+
export declare function startDevvitWebView(options: DevvitWebViewOptions): Promise<DevvitViewModeResult>;
|
|
11
19
|
export declare function requestDevvitExpandedMode(event: MouseEvent, entry: string): Promise<void>;
|
|
12
20
|
/**
|
|
13
21
|
* Presents Devvit's native share surface without claiming that the user
|
package/dist/web.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as devvitWebClient from '@devvit/web/client';
|
|
2
2
|
import { startDevvitSurface, } from './surface.js';
|
|
3
|
+
import { startDevvitViewMode, } from './view-mode.js';
|
|
3
4
|
// The Devvit package publishes browser implementations behind an export
|
|
4
5
|
// condition while its default declaration is a server-side panic module.
|
|
5
6
|
// Keep the narrow browser contract explicit until its default types expose it.
|
|
@@ -10,6 +11,16 @@ export function startDevvitWebSurface(options) {
|
|
|
10
11
|
client: { getWebViewMode: browserClient.getWebViewMode },
|
|
11
12
|
});
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Starts a Devvit web view in inline or expanded mode. Inline mode can defer
|
|
16
|
+
* gameplay until an explicit user action while remaining inside the post.
|
|
17
|
+
*/
|
|
18
|
+
export function startDevvitWebView(options) {
|
|
19
|
+
return startDevvitViewMode({
|
|
20
|
+
...options,
|
|
21
|
+
client: { getWebViewMode: browserClient.getWebViewMode },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
13
24
|
export async function requestDevvitExpandedMode(event, entry) {
|
|
14
25
|
await browserClient.requestExpandedMode(event, entry);
|
|
15
26
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/adapter-devvit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Reddit Devvit Web platform adapter for mpgd games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
30
|
"default": "./dist/index.js"
|
|
31
31
|
},
|
|
32
|
+
"./payments": {
|
|
33
|
+
"types": "./dist/payments.d.ts",
|
|
34
|
+
"default": "./dist/payments.js"
|
|
35
|
+
},
|
|
32
36
|
"./server": {
|
|
33
37
|
"types": "./dist/server/index.d.ts",
|
|
34
38
|
"default": "./dist/server/index.js"
|
|
@@ -37,6 +41,10 @@
|
|
|
37
41
|
"types": "./dist/surface.d.ts",
|
|
38
42
|
"default": "./dist/surface.js"
|
|
39
43
|
},
|
|
44
|
+
"./view-mode": {
|
|
45
|
+
"types": "./dist/view-mode.d.ts",
|
|
46
|
+
"default": "./dist/view-mode.js"
|
|
47
|
+
},
|
|
40
48
|
"./web": {
|
|
41
49
|
"types": "./dist/web.d.ts",
|
|
42
50
|
"default": "./dist/web.js"
|
|
@@ -47,9 +55,9 @@
|
|
|
47
55
|
],
|
|
48
56
|
"dependencies": {
|
|
49
57
|
"@devvit/web": "0.13.8",
|
|
58
|
+
"@mpgd/game-services": "0.8.1",
|
|
50
59
|
"@mpgd/bridge": "0.6.0",
|
|
51
|
-
"@mpgd/
|
|
52
|
-
"@mpgd/platform": "0.5.1"
|
|
60
|
+
"@mpgd/platform": "0.6.0"
|
|
53
61
|
},
|
|
54
62
|
"devDependencies": {
|
|
55
63
|
"ttsc": "0.18.4",
|
|
@@ -66,6 +74,6 @@
|
|
|
66
74
|
"lint": "ttsc --noEmit",
|
|
67
75
|
"format": "ttsc format",
|
|
68
76
|
"fix": "ttsc fix",
|
|
69
|
-
"test": "vitest run && node test/server-dist-import.mjs && node --conditions=browser test/web-dist-import.mjs"
|
|
77
|
+
"test": "vitest run && node test/payments-dist-import.mjs && node test/server-dist-import.mjs && node --conditions=browser test/web-dist-import.mjs"
|
|
70
78
|
}
|
|
71
79
|
}
|