@mpgd/adapter-devvit 0.12.1 → 0.13.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.
- package/README.md +55 -3
- package/dist/index.d.ts +36 -0
- package/dist/index.js +167 -8
- package/dist/notifications-client.d.ts +44 -0
- package/dist/notifications-client.js +141 -0
- package/dist/notifications-contract.d.ts +71 -0
- package/dist/notifications-contract.js +109 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +3 -0
- package/dist/server/notifications-bridge.d.ts +103 -0
- package/dist/server/notifications-bridge.js +262 -0
- package/dist/server/notifications-delivery.d.ts +257 -0
- package/dist/server/notifications-delivery.js +431 -0
- package/dist/server/redis-notification-store.d.ts +130 -0
- package/dist/server/redis-notification-store.js +1058 -0
- package/dist/web.d.ts +10 -0
- package/dist/web.js +15 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@ Reddit Devvit Web platform adapter for mpgd games.
|
|
|
4
4
|
|
|
5
5
|
| Entry point | Purpose |
|
|
6
6
|
| --- | --- |
|
|
7
|
-
| `@mpgd/adapter-devvit` | `createDevvitPlatformGateway`, the oRPC bridge and sandbox bridge, the commerce provider and catalog helpers |
|
|
7
|
+
| `@mpgd/adapter-devvit` | `createDevvitPlatformGateway`, the oRPC bridge and sandbox bridge, the commerce provider and catalog helpers, the Devvit notifications profile values |
|
|
8
8
|
| `@mpgd/adapter-devvit/payments` | `normalizeDevvitCommerceProducts` and the checkout types |
|
|
9
|
-
| `@mpgd/adapter-devvit/server` | `createDevvitCommerceV2BridgeHandler`, payment-order normalizers, durable post operations, Redis stores |
|
|
10
|
-
| `@mpgd/adapter-devvit/web` | Webview helpers, including `createDevvitWebPaymentsClient()` over `purchase()` from `@devvit/web/client` |
|
|
9
|
+
| `@mpgd/adapter-devvit/server` | `createDevvitCommerceV2BridgeHandler`, `createDevvitNotificationsV2BridgeHandler`, the notifications service and delivery provider, payment-order normalizers, durable post operations, Redis stores |
|
|
10
|
+
| `@mpgd/adapter-devvit/web` | Webview helpers, including `createDevvitWebPaymentsClient()` over `purchase()` and `createDevvitWebLoginPrompt()` over `showLoginPrompt()` from `@devvit/web/client` |
|
|
11
11
|
| `@mpgd/adapter-devvit/surface`, `/view-mode` | Inline and expanded view-mode helpers |
|
|
12
12
|
|
|
13
13
|
## Commerce
|
|
@@ -20,6 +20,58 @@ settles a purchase on the server, so only the backend ledger the fulfillment han
|
|
|
20
20
|
anything. Games purchase through `@mpgd/game-services`; see the
|
|
21
21
|
[Devvit commerce guide](../../docs/DEVVIT_COMMERCE.md).
|
|
22
22
|
|
|
23
|
+
## Notifications
|
|
24
|
+
|
|
25
|
+
Devvit push notifications are a gated beta: an app must
|
|
26
|
+
[apply](https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/notifications/notifications-overview)
|
|
27
|
+
and Devvit admins pre-approve every title and body. Until Reddit approves the
|
|
28
|
+
app, keep `integrations.notifications` at `approval-required` (the default for
|
|
29
|
+
`reddit`), which hides notifications from the game; set it to `available` once
|
|
30
|
+
Reddit approves. The profile follows the
|
|
31
|
+
[notifications v2 RFC](../../docs/specs/notifications/notifications-v2-rfc.md#profile-devvit):
|
|
32
|
+
|
|
33
|
+
- **Consent the game renders.** Reddit shows no consent screen. The game draws
|
|
34
|
+
an on/off control with its current state and offers both directions.
|
|
35
|
+
- **The server bridge.** Pass
|
|
36
|
+
`createDevvitNotificationsV2BridgeHandler({ notifications, currentUserId, topics })`
|
|
37
|
+
as the bridge's `notifications` dependency; it takes the Devvit plugin as
|
|
38
|
+
`notifications`. It opts the request's logged-in user in with
|
|
39
|
+
`optInCurrentUser` from `requestPermission`, out with `optOutCurrentUser`
|
|
40
|
+
from `revokePermission`, and reads `isOptedIn`.
|
|
41
|
+
- **The gateway.** `createDevvitPlatformGateway({ notifications: { topics } })`
|
|
42
|
+
exposes `notificationsV2` over that bridge (its client loads in its own
|
|
43
|
+
chunk, starting with the gateway)
|
|
44
|
+
and serves the v1 `notifications` adapter from it. `topics` are the ids the
|
|
45
|
+
catalog maps for `reddit`.
|
|
46
|
+
- **Login.** A logged-out viewer reads `action-required` with
|
|
47
|
+
`requiredAction: 'login'`. With `loginPrompt: createDevvitWebLoginPrompt()`
|
|
48
|
+
(from `@mpgd/adapter-devvit/web`), `identity.requestUpgrade` opens Reddit's
|
|
49
|
+
login and answers `reloadExpected: true`, since Reddit reloads the post after
|
|
50
|
+
login; set `integrations.identityUpgrade` to `available` in the game's target
|
|
51
|
+
config to expose it.
|
|
52
|
+
- **No opt-out event.** Devvit reports no opt-out, and opt-out may move into
|
|
53
|
+
Reddit settings, so the backend reads `isOptedIn` at every send.
|
|
54
|
+
- **Sending.** Remote delivery runs inside the Devvit server:
|
|
55
|
+
`createDevvitNotificationService({ redis, notifications, catalog })` is the
|
|
56
|
+
`@mpgd/game-services/notifications` service over a Redis store
|
|
57
|
+
(`createDevvitRedisNotificationStore`) with the Devvit delivery provider. Each
|
|
58
|
+
send needs a post (`t3_`) or comment (`t1_`) link, from the send or the
|
|
59
|
+
topic's mapping. Titles are at most 60 and bodies at most 100 Unicode code
|
|
60
|
+
points of the filled text. Devvit allows 2 sends per user per day and 25,000
|
|
61
|
+
per app per day. Players' own calls take `devvitNotificationSession(userId)`.
|
|
62
|
+
Two `devvit.json` scheduler tasks keep its work moving:
|
|
63
|
+
`mpgd-notifications-dispatch` (every minute, the service's `runScheduler`:
|
|
64
|
+
due server reminders and due sends, in rounds of 10, starting no round
|
|
65
|
+
after 20 seconds; Devvit carries no consent notices, since its entry fixes
|
|
66
|
+
the title and body per topic) and `mpgd-notifications-purge` (every
|
|
67
|
+
five minutes, at most 200 rows of each kind). Route their endpoints
|
|
68
|
+
(`devvitNotificationSchedulerTasks`) to
|
|
69
|
+
`runDevvitNotificationTask(service, task)`, as the kit's
|
|
70
|
+
`apps/target-devvit` server does. A player's `setReminder` fires through
|
|
71
|
+
the dispatch task.
|
|
72
|
+
- **Opens.** A notification opens its post; the game routes from that post's
|
|
73
|
+
data.
|
|
74
|
+
|
|
23
75
|
## Migrating from v1
|
|
24
76
|
|
|
25
77
|
| Removed | Replacement |
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './commerce-contract.js';
|
|
|
6
6
|
export * from './commerce-provider.js';
|
|
7
7
|
export * from './payments.js';
|
|
8
8
|
export * from './catalog-products.js';
|
|
9
|
+
export * from './notifications-contract.js';
|
|
9
10
|
export declare const defaultDevvitRpcEndpoint = "/api/mpgd/rpc";
|
|
10
11
|
/** Bridge error of `createDevvitOrpcBridge` when the oRPC request itself failed. */
|
|
11
12
|
export declare const devvitBridgeNetworkErrorCode: 'DEVVIT_BRIDGE_NETWORK_ERROR';
|
|
@@ -49,6 +50,41 @@ export interface DevvitPlatformGatewayOptions {
|
|
|
49
50
|
* from the payments client.
|
|
50
51
|
*/
|
|
51
52
|
readonly commerce?: DevvitGatewayCommerceOptions;
|
|
53
|
+
/**
|
|
54
|
+
* Exposes notifications v2 (`notificationsV2`) over the server bridge,
|
|
55
|
+
* which serves the game-rendered opt-in from
|
|
56
|
+
* `createDevvitNotificationsV2BridgeHandler` (`@mpgd/adapter-devvit/server`),
|
|
57
|
+
* and serves the v1 `notifications` adapter from the same provider.
|
|
58
|
+
* Absent, the gateway offers no `notificationsV2` and keeps the server's
|
|
59
|
+
* v1 answers. Notifications are a gated beta: the target config keeps
|
|
60
|
+
* `integrations.notifications` at `approval-required` until Reddit
|
|
61
|
+
* approves the app, which hides both from the game.
|
|
62
|
+
*/
|
|
63
|
+
readonly notifications?: DevvitGatewayNotificationsOptions;
|
|
64
|
+
/**
|
|
65
|
+
* The webview's Reddit login prompt (`createDevvitWebLoginPrompt()` from
|
|
66
|
+
* `@mpgd/adapter-devvit/web`). With it, `identity.requestUpgrade` shows the
|
|
67
|
+
* prompt to a logged-out viewer and answers `reloadExpected: true`, since
|
|
68
|
+
* Reddit reloads the post after login; a logged-in viewer gets the
|
|
69
|
+
* server's `completed` answer. Without it, the server answers alone.
|
|
70
|
+
*/
|
|
71
|
+
readonly loginPrompt?: DevvitLoginPrompt;
|
|
72
|
+
}
|
|
73
|
+
export interface DevvitGatewayNotificationsOptions {
|
|
74
|
+
/** The topic ids the catalog maps for the `reddit` target; v1 reads any other as `configuration-required`. */
|
|
75
|
+
readonly topics: readonly string[];
|
|
76
|
+
/** The webview's IANA zone for the registration; `Intl` by default. */
|
|
77
|
+
readonly timeZone?: () => string | undefined;
|
|
78
|
+
/** The webview's locale for the registration; `navigator.language` by default. */
|
|
79
|
+
readonly locale?: () => string | undefined;
|
|
80
|
+
/** Loads the notifications client chunk, when the gateway is created; the dynamic import by default. Tests inject it. */
|
|
81
|
+
readonly load?: () => Promise<DevvitNotificationClientModule>;
|
|
82
|
+
}
|
|
83
|
+
/** What the gateway uses from the notifications client chunk. */
|
|
84
|
+
export type DevvitNotificationClientModule = Pick<typeof import('./notifications-client.js'), 'createDevvitBridgeNotificationProvider' | 'createDevvitNotificationSubscriptionAdapter'>;
|
|
85
|
+
/** Opens Reddit's login or sign-up flow (`showLoginPrompt` from `@devvit/web/client`). */
|
|
86
|
+
export interface DevvitLoginPrompt {
|
|
87
|
+
showLoginPrompt(): void;
|
|
52
88
|
}
|
|
53
89
|
export declare function createDevvitPlatformGateway(input: DevvitPlatformGatewayOptions): PlatformGateway;
|
|
54
90
|
/**
|
package/dist/index.js
CHANGED
|
@@ -4,11 +4,13 @@ import { createUnsupportedCommerceCapabilities, PlatformOperationError, } from '
|
|
|
4
4
|
import { assertCommerceBridgeGetAvailablePurchasesResult, assertCommerceBridgeGetCapabilitiesResult, CommerceError, commerceProtocol, isCommerceErrorCode, } from '@mpgd/platform/commerce';
|
|
5
5
|
import { withShareIntentCheck } from '@mpgd/platform/routes';
|
|
6
6
|
import { assertDevvitOrderEvidence } from './commerce-contract.js';
|
|
7
|
+
import { createDevvitDegradedNotificationCapabilities, devvitNotificationProviderId, } from './notifications-contract.js';
|
|
7
8
|
import { createDevvitCommerceProvider, } from './commerce-provider.js';
|
|
8
9
|
export * from './commerce-contract.js';
|
|
9
10
|
export * from './commerce-provider.js';
|
|
10
11
|
export * from './payments.js';
|
|
11
12
|
export * from './catalog-products.js';
|
|
13
|
+
export * from './notifications-contract.js';
|
|
12
14
|
export const defaultDevvitRpcEndpoint = defaultBridgeRpcEndpoint;
|
|
13
15
|
/** Bridge error of `createDevvitOrpcBridge` when the oRPC request itself failed. */
|
|
14
16
|
export const devvitBridgeNetworkErrorCode = 'DEVVIT_BRIDGE_NETWORK_ERROR';
|
|
@@ -52,6 +54,10 @@ export function createDevvitPlatformGateway(input) {
|
|
|
52
54
|
...input.commerce,
|
|
53
55
|
orders: createDevvitBridgeOrderSource(request),
|
|
54
56
|
});
|
|
57
|
+
const notificationSurface = input.notifications === undefined
|
|
58
|
+
? undefined
|
|
59
|
+
: createLazyDevvitNotificationSurface(input.notifications, (method, payload) => request(method, payload));
|
|
60
|
+
const notificationsV2 = notificationSurface?.notificationsV2;
|
|
55
61
|
return {
|
|
56
62
|
target: 'reddit',
|
|
57
63
|
async getCapabilities() {
|
|
@@ -63,17 +69,45 @@ export function createDevvitPlatformGateway(input) {
|
|
|
63
69
|
}
|
|
64
70
|
// The webview provider owns the block: the server snapshot stays honest
|
|
65
71
|
// about the server, and a gateway without a provider sells nothing.
|
|
72
|
+
// The two provider reads are independent bridge calls.
|
|
73
|
+
const [commerceBlock, notifications] = await Promise.all([
|
|
74
|
+
commerce === undefined ? createUnsupportedCommerceCapabilities() : commerce.getCapabilities(),
|
|
75
|
+
notificationsV2 === undefined ? undefined : readNotificationCapabilities(notificationsV2),
|
|
76
|
+
]);
|
|
77
|
+
const { notifications: _serverNotifications, ...withCommerce } = { ...capabilities, commerce: commerceBlock };
|
|
78
|
+
if (notifications === undefined) {
|
|
79
|
+
return withCommerce;
|
|
80
|
+
}
|
|
81
|
+
// The block is present exactly when the gateway exposes notificationsV2,
|
|
82
|
+
// and the push readiness follows it.
|
|
66
83
|
return {
|
|
67
|
-
...
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
:
|
|
84
|
+
...withCommerce,
|
|
85
|
+
providerAvailability: {
|
|
86
|
+
...withCommerce.providerAvailability,
|
|
87
|
+
pushNotifications: notifications.availability,
|
|
88
|
+
},
|
|
89
|
+
notifications,
|
|
71
90
|
};
|
|
72
91
|
},
|
|
73
92
|
identity: {
|
|
74
93
|
getPlayer: () => request('identity.getPlayer', {}),
|
|
75
94
|
getSession: () => request('identity.getSession', {}),
|
|
76
|
-
requestUpgrade: (payload) =>
|
|
95
|
+
requestUpgrade: async (payload) => {
|
|
96
|
+
const answer = await request('identity.requestUpgrade', payload);
|
|
97
|
+
// The server answers `unavailable` for a logged-out viewer; any other
|
|
98
|
+
// answer is passed through.
|
|
99
|
+
if (input.loginPrompt === undefined || answer.status !== 'unavailable') {
|
|
100
|
+
return answer;
|
|
101
|
+
}
|
|
102
|
+
// A logged-out viewer: Reddit's login flow reloads the post afterwards.
|
|
103
|
+
try {
|
|
104
|
+
input.loginPrompt.showLoginPrompt();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { status: 'unavailable', reloadExpected: false };
|
|
108
|
+
}
|
|
109
|
+
return { status: 'completed', reloadExpected: true };
|
|
110
|
+
},
|
|
77
111
|
},
|
|
78
112
|
presentation: {
|
|
79
113
|
getLaunchIntent: () => request('presentation.getLaunchIntent', {}),
|
|
@@ -85,11 +119,14 @@ export function createDevvitPlatformGateway(input) {
|
|
|
85
119
|
share: (payload) => request('share.share', payload),
|
|
86
120
|
readInboundShare: () => request('share.readInboundShare', {}),
|
|
87
121
|
}),
|
|
88
|
-
|
|
122
|
+
// v1 is served from the v2 provider whenever the gateway exposes v2, so
|
|
123
|
+
// the two report the same readiness.
|
|
124
|
+
notifications: notificationSurface?.notifications ?? {
|
|
89
125
|
getStatus: (topic) => request('notifications.getStatus', { topic }),
|
|
90
126
|
requestSubscription: (topic) => request('notifications.requestSubscription', { topic }),
|
|
91
127
|
},
|
|
92
128
|
...(commerce === undefined ? {} : { commerce }),
|
|
129
|
+
...(notificationsV2 === undefined ? {} : { notificationsV2 }),
|
|
93
130
|
ads: {
|
|
94
131
|
preload: (payload) => request('ads.preload', payload),
|
|
95
132
|
showRewarded: (payload) => request('ads.showRewarded', payload),
|
|
@@ -120,6 +157,128 @@ export function createDevvitPlatformGateway(input) {
|
|
|
120
157
|
},
|
|
121
158
|
};
|
|
122
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Notifications v2 and the v1 adapter served from it, with the client chunk
|
|
162
|
+
* (the bridge provider and the `@mpgd/bridge/notifications` validators) in
|
|
163
|
+
* its own chunk, so a game that does not opt in carries none of it. A game
|
|
164
|
+
* that opts in starts loading it with the gateway. A
|
|
165
|
+
* chunk that fails to load rejects with a retryable
|
|
166
|
+
* `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`, and the next call loads again.
|
|
167
|
+
* Devvit pushes no notifications events, so `subscribe` attaches nothing.
|
|
168
|
+
*/
|
|
169
|
+
function createLazyDevvitNotificationSurface(options, request) {
|
|
170
|
+
const importClient = options.load ?? (() => import('./notifications-client.js'));
|
|
171
|
+
const topics = [...options.topics];
|
|
172
|
+
let loading;
|
|
173
|
+
const load = () => {
|
|
174
|
+
if (loading !== undefined) {
|
|
175
|
+
return loading;
|
|
176
|
+
}
|
|
177
|
+
// Any failure, a synchronous throw of the loader or one while the
|
|
178
|
+
// provider is built, is forgotten once settled, so the next call loads
|
|
179
|
+
// again; the reset runs after the attempt is stored, never before.
|
|
180
|
+
const attempt = (async () => {
|
|
181
|
+
try {
|
|
182
|
+
const module = await importClient();
|
|
183
|
+
const provider = module.createDevvitBridgeNotificationProvider({
|
|
184
|
+
request,
|
|
185
|
+
...(options.timeZone === undefined ? {} : { timeZone: options.timeZone }),
|
|
186
|
+
...(options.locale === undefined ? {} : { locale: options.locale }),
|
|
187
|
+
});
|
|
188
|
+
return { provider, v1: module.createDevvitNotificationSubscriptionAdapter(provider, topics) };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
throw new PlatformOperationError({
|
|
192
|
+
code: 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE',
|
|
193
|
+
message: `The Devvit notifications client did not load: ${errorMessage(error)}`,
|
|
194
|
+
retryable: true,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
loading = attempt;
|
|
199
|
+
attempt.catch(() => {
|
|
200
|
+
if (loading === attempt) {
|
|
201
|
+
loading = undefined;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
return attempt;
|
|
205
|
+
};
|
|
206
|
+
// The game opted in, so the chunk is needed: load it now rather than on the
|
|
207
|
+
// first capability read, which is bounded in time.
|
|
208
|
+
void load().catch(() => { });
|
|
209
|
+
const provider = async () => (await load()).provider;
|
|
210
|
+
return {
|
|
211
|
+
notificationsV2: {
|
|
212
|
+
provider: devvitNotificationProviderId,
|
|
213
|
+
getCapabilities: async () => (await provider()).getCapabilities(),
|
|
214
|
+
getPermission: async (input) => (await provider()).getPermission(input),
|
|
215
|
+
requestPermission: async (input) => (await provider()).requestPermission(input),
|
|
216
|
+
revokePermission: async (input) => {
|
|
217
|
+
const loaded = await provider();
|
|
218
|
+
return loaded.revokePermission
|
|
219
|
+
.call(loaded, input);
|
|
220
|
+
},
|
|
221
|
+
register: async () => (await (await provider()).register?.()) ?? null,
|
|
222
|
+
subscribe: () => () => { },
|
|
223
|
+
},
|
|
224
|
+
notifications: {
|
|
225
|
+
getStatus: async (topic) => (await load()).v1.getStatus(topic),
|
|
226
|
+
// As v1 answers any failed request: `unavailable`, also when the chunk did not load.
|
|
227
|
+
requestSubscription: async (topic) => {
|
|
228
|
+
let v1;
|
|
229
|
+
try {
|
|
230
|
+
v1 = (await load()).v1;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return 'unavailable';
|
|
234
|
+
}
|
|
235
|
+
return v1.requestSubscription(topic);
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/** Bound on one notifications capability read, as `withTargetAvailability` bounds it. */
|
|
241
|
+
const notificationCapabilityReadTimeoutMs = 3_000;
|
|
242
|
+
/** Failures the same server answers the same way again; `@mpgd/target-config` reads them as unsupported. */
|
|
243
|
+
const unsupportedNotificationReadCodes = new Set([
|
|
244
|
+
'NOTIFICATIONS_V2_UNAVAILABLE',
|
|
245
|
+
'NOTIFICATIONS_UNSUPPORTED',
|
|
246
|
+
'NOTIFICATIONS_HOST_VERSION_UNSUPPORTED',
|
|
247
|
+
]);
|
|
248
|
+
/**
|
|
249
|
+
* One bounded read of the provider's block. The provider validated it, so
|
|
250
|
+
* it is a fresh, exact block; a read that fails or stalls yields a block
|
|
251
|
+
* that offers nothing, with the availability that explains why.
|
|
252
|
+
*/
|
|
253
|
+
async function readNotificationCapabilities(provider) {
|
|
254
|
+
let timer;
|
|
255
|
+
try {
|
|
256
|
+
return await Promise.race([
|
|
257
|
+
provider.getCapabilities(),
|
|
258
|
+
new Promise((_resolve, reject) => {
|
|
259
|
+
timer = setTimeout(() => reject(new Error('Notification capabilities read timed out.')), notificationCapabilityReadTimeoutMs);
|
|
260
|
+
}),
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
const failure = typeof error === 'object' && error !== null
|
|
265
|
+
? error
|
|
266
|
+
: {};
|
|
267
|
+
let availability = 'temporarily-unavailable';
|
|
268
|
+
if (failure.code === 'NOTIFICATIONS_CONFIGURATION_REQUIRED') {
|
|
269
|
+
availability = 'configuration-required';
|
|
270
|
+
}
|
|
271
|
+
else if (failure.retryable !== true && unsupportedNotificationReadCodes.has(failure.code)) {
|
|
272
|
+
availability = 'unsupported';
|
|
273
|
+
}
|
|
274
|
+
return createDevvitDegradedNotificationCapabilities(availability);
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
if (timer !== undefined) {
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
123
282
|
/**
|
|
124
283
|
* The webview provider's order source over the server bridge: the paid orders
|
|
125
284
|
* the server lists as available purchases, mapped back to orders (the
|
|
@@ -290,8 +449,8 @@ export function createDevvitSandboxBridge() {
|
|
|
290
449
|
const storage = new Map();
|
|
291
450
|
return {
|
|
292
451
|
async request(input) {
|
|
293
|
-
//
|
|
294
|
-
// every v2 method answers the dedicated code.
|
|
452
|
+
// Only the Devvit server can opt a user in: the sandbox serves no
|
|
453
|
+
// notifications v2, and every v2 method answers the dedicated code.
|
|
295
454
|
if (isBridgeNotificationsV2Method(input.method)) {
|
|
296
455
|
return createNotificationsV2UnavailableBridgeError(input, 'Devvit sandbox bridge');
|
|
297
456
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { BridgeNotificationsV2Method } from '@mpgd/bridge';
|
|
2
|
+
import type { NotificationSubscriptionAdapter } from '@mpgd/platform';
|
|
3
|
+
import { type NotificationProvider } from '@mpgd/platform/notifications';
|
|
4
|
+
/**
|
|
5
|
+
* The webview side of notifications v2 on Devvit, loaded in its own chunk
|
|
6
|
+
* by `createDevvitPlatformGateway({ notifications })`: a
|
|
7
|
+
* `NotificationProvider` over the Devvit server bridge, which serves the
|
|
8
|
+
* game-rendered opt-in from `createDevvitNotificationsV2BridgeHandler`
|
|
9
|
+
* (`@mpgd/adapter-devvit/server`), and the v1 adapter served from it.
|
|
10
|
+
*/
|
|
11
|
+
export interface CreateDevvitBridgeNotificationProviderInput {
|
|
12
|
+
/** Sends one server bridge request and returns its data; bridge errors reject with their code. */
|
|
13
|
+
readonly request: (method: BridgeNotificationsV2Method, payload: unknown) => Promise<unknown>;
|
|
14
|
+
/** The webview's IANA zone, which the registration carries; `Intl` by default. */
|
|
15
|
+
readonly timeZone?: () => string | undefined;
|
|
16
|
+
/** The webview's locale, which the registration carries; `navigator.language` by default. */
|
|
17
|
+
readonly locale?: () => string | undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The Devvit profile's provider: reads, the opt-in request, the opt-out
|
|
21
|
+
* (`revokePermission`, because the game renders the consent), and the
|
|
22
|
+
* `platform-user` registration with the webview's locale and zone. No
|
|
23
|
+
* local notifications and no settings link exist, and no event reaches the
|
|
24
|
+
* webview: a notification opens its post, and the game routes from that
|
|
25
|
+
* post's data. Answers are validated again here, so a server that skips
|
|
26
|
+
* validation stays fail-closed.
|
|
27
|
+
*
|
|
28
|
+
* Errors: a bridge error with a `NOTIFICATIONS_*` code becomes a
|
|
29
|
+
* `NotificationError` with that code (and the host code its message names);
|
|
30
|
+
* `NOTIFICATIONS_V2_UNAVAILABLE` from a server without the handler stays
|
|
31
|
+
* that; `UNSUPPORTED_METHOD` or `INVALID_BRIDGE_REQUEST` from a server that
|
|
32
|
+
* predates the methods becomes `NOTIFICATIONS_V2_UNAVAILABLE`; anything else
|
|
33
|
+
* (the oRPC transport failing) is `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`,
|
|
34
|
+
* retryable unless the failure says otherwise.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createDevvitBridgeNotificationProvider(input: CreateDevvitBridgeNotificationProviderInput): NotificationProvider;
|
|
37
|
+
/**
|
|
38
|
+
* The v1 `NotificationSubscriptionAdapter` served from the v2 provider (RFC
|
|
39
|
+
* "Migration and PR plan", through `toNotificationSubscriptionAdapter`), so
|
|
40
|
+
* v1 and v2 report the same readiness. Every state comes from the host
|
|
41
|
+
* (Devvit `isOptedIn` through the server); a topic outside `topics` reads
|
|
42
|
+
* `configuration-required` and requests `unavailable`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createDevvitNotificationSubscriptionAdapter(provider: Pick<NotificationProvider, 'getPermission' | 'requestPermission'>, topics: readonly string[]): NotificationSubscriptionAdapter;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { assertBridgeNotificationsV2Result, } from '@mpgd/bridge/notifications';
|
|
2
|
+
import { isNotificationErrorCode, isValidNotificationTimeZone, NotificationError, notificationsProtocol, toNotificationSubscriptionAdapter, } from '@mpgd/platform/notifications';
|
|
3
|
+
import { devvitHostCodeCharacters, devvitNotificationProviderId, } from './notifications-contract.js';
|
|
4
|
+
/**
|
|
5
|
+
* The Devvit profile's provider: reads, the opt-in request, the opt-out
|
|
6
|
+
* (`revokePermission`, because the game renders the consent), and the
|
|
7
|
+
* `platform-user` registration with the webview's locale and zone. No
|
|
8
|
+
* local notifications and no settings link exist, and no event reaches the
|
|
9
|
+
* webview: a notification opens its post, and the game routes from that
|
|
10
|
+
* post's data. Answers are validated again here, so a server that skips
|
|
11
|
+
* validation stays fail-closed.
|
|
12
|
+
*
|
|
13
|
+
* Errors: a bridge error with a `NOTIFICATIONS_*` code becomes a
|
|
14
|
+
* `NotificationError` with that code (and the host code its message names);
|
|
15
|
+
* `NOTIFICATIONS_V2_UNAVAILABLE` from a server without the handler stays
|
|
16
|
+
* that; `UNSUPPORTED_METHOD` or `INVALID_BRIDGE_REQUEST` from a server that
|
|
17
|
+
* predates the methods becomes `NOTIFICATIONS_V2_UNAVAILABLE`; anything else
|
|
18
|
+
* (the oRPC transport failing) is `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`,
|
|
19
|
+
* retryable unless the failure says otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export function createDevvitBridgeNotificationProvider(input) {
|
|
22
|
+
const send = async (method, payload) => {
|
|
23
|
+
let data;
|
|
24
|
+
try {
|
|
25
|
+
data = await input.request(method, payload);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
throw toNotificationError(error);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return assertBridgeNotificationsV2Result(method, data);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
|
|
35
|
+
message: `The Devvit server answered ${method} with invalid data.`,
|
|
36
|
+
cause: error,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const timeZone = input.timeZone ?? defaultTimeZone;
|
|
41
|
+
const locale = input.locale ?? defaultLocale;
|
|
42
|
+
return {
|
|
43
|
+
provider: devvitNotificationProviderId,
|
|
44
|
+
async getCapabilities() {
|
|
45
|
+
const answer = await send('notifications.v2.getCapabilities', { protocol: notificationsProtocol });
|
|
46
|
+
if (answer.provider !== devvitNotificationProviderId) {
|
|
47
|
+
throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
|
|
48
|
+
message: `The Devvit server serves notifications provider ${answer.provider}, not ${devvitNotificationProviderId}.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return answer.capabilities;
|
|
52
|
+
},
|
|
53
|
+
getPermission: (request) => send('notifications.v2.getPermission', request),
|
|
54
|
+
requestPermission: (request) => send('notifications.v2.requestPermission', request),
|
|
55
|
+
revokePermission: (request) => send('notifications.v2.revokePermission', request),
|
|
56
|
+
async register() {
|
|
57
|
+
const { registration } = await send('notifications.v2.register', { protocol: notificationsProtocol });
|
|
58
|
+
if (registration === null || registration.address.kind !== 'platform-user') {
|
|
59
|
+
return registration;
|
|
60
|
+
}
|
|
61
|
+
// The server knows the user; only the webview knows its locale and zone.
|
|
62
|
+
const zone = registration.timeZone ?? readOptional(timeZone);
|
|
63
|
+
const language = registration.locale ?? readOptional(locale);
|
|
64
|
+
return {
|
|
65
|
+
...registration,
|
|
66
|
+
...(language === undefined || language.length === 0 || language.length > 2048 ? {} : { locale: language }),
|
|
67
|
+
...(zone === undefined || !isValidNotificationTimeZone(zone) ? {} : { timeZone: zone }),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
subscribe() {
|
|
71
|
+
// Devvit has no server-to-webview notifications channel.
|
|
72
|
+
return () => { };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The v1 `NotificationSubscriptionAdapter` served from the v2 provider (RFC
|
|
78
|
+
* "Migration and PR plan", through `toNotificationSubscriptionAdapter`), so
|
|
79
|
+
* v1 and v2 report the same readiness. Every state comes from the host
|
|
80
|
+
* (Devvit `isOptedIn` through the server); a topic outside `topics` reads
|
|
81
|
+
* `configuration-required` and requests `unavailable`.
|
|
82
|
+
*/
|
|
83
|
+
export function createDevvitNotificationSubscriptionAdapter(provider, topics) {
|
|
84
|
+
return toNotificationSubscriptionAdapter({
|
|
85
|
+
async getPermission(request) {
|
|
86
|
+
const snapshot = await provider.getPermission(request);
|
|
87
|
+
return {
|
|
88
|
+
...snapshot,
|
|
89
|
+
sources: Object.fromEntries(Object.keys(snapshot.topics).map((topic) => [topic, 'host'])),
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
requestPermission: ({ topics: requested }) => provider.requestPermission({ topics: requested }),
|
|
93
|
+
}, topics.map((id) => ({ id })));
|
|
94
|
+
}
|
|
95
|
+
function readOptional(read) {
|
|
96
|
+
try {
|
|
97
|
+
const value = read();
|
|
98
|
+
return typeof value === 'string' ? value : undefined;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function defaultTimeZone() {
|
|
105
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
106
|
+
}
|
|
107
|
+
function defaultLocale() {
|
|
108
|
+
const navigatorLike = globalThis.navigator;
|
|
109
|
+
return typeof navigatorLike?.language === 'string' ? navigatorLike.language : undefined;
|
|
110
|
+
}
|
|
111
|
+
const hostCodePattern = new RegExp(`\\(host code (${devvitHostCodeCharacters})\\)$`, 'u');
|
|
112
|
+
function toNotificationError(error) {
|
|
113
|
+
if (error instanceof NotificationError) {
|
|
114
|
+
return error;
|
|
115
|
+
}
|
|
116
|
+
const failure = typeof error === 'object' && error !== null
|
|
117
|
+
? error
|
|
118
|
+
: {};
|
|
119
|
+
const message = typeof failure.message === 'string' ? failure.message : undefined;
|
|
120
|
+
const explicit = typeof failure.retryable === 'boolean' ? { retryable: failure.retryable } : {};
|
|
121
|
+
if (isNotificationErrorCode(failure.code)) {
|
|
122
|
+
const hostCode = message === undefined ? undefined : hostCodePattern.exec(message)?.[1];
|
|
123
|
+
return new NotificationError(failure.code, {
|
|
124
|
+
...(message === undefined ? {} : { message }),
|
|
125
|
+
...explicit,
|
|
126
|
+
...(hostCode === undefined ? {} : { hostCode }),
|
|
127
|
+
cause: error,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (failure.code === 'UNSUPPORTED_METHOD' || failure.code === 'INVALID_BRIDGE_REQUEST') {
|
|
131
|
+
return new NotificationError('NOTIFICATIONS_V2_UNAVAILABLE', {
|
|
132
|
+
...(message === undefined ? {} : { message }),
|
|
133
|
+
cause: error,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
137
|
+
...(message === undefined ? {} : { message }),
|
|
138
|
+
...explicit,
|
|
139
|
+
cause: error,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Devvit notifications profile (notifications v2 RFC, "Provider
|
|
3
|
+
* profiles", Devvit), shared by the webview gateway and the Devvit server.
|
|
4
|
+
* Pure values: no Devvit SDK, no `@mpgd/platform/notifications` runtime code,
|
|
5
|
+
* so the gateway's main entry can import it without pulling in validators.
|
|
6
|
+
*
|
|
7
|
+
* Sources (checked 2026-10-19 through the Devvit MCP `devvit_search`):
|
|
8
|
+
* - <https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/notifications/notifications-overview>:
|
|
9
|
+
* a gated beta that an app applies for.
|
|
10
|
+
* - <https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/notifications/pn-best-practices>:
|
|
11
|
+
* copy pre-approved by Devvit admins, "titles ≤ 60 characters, body ≤ 100
|
|
12
|
+
* characters", at most 2 per user per day and 25K per app per day once
|
|
13
|
+
* published, opt-in enforced at the API level, an in-game control for both
|
|
14
|
+
* directions, and opt-out controls planned for Reddit settings.
|
|
15
|
+
* - <https://developers.reddit.com/docs/versioned_docs/version-0.14/guides/logged-out-users>:
|
|
16
|
+
* `showLoginPrompt()` reloads the page after the player logs in.
|
|
17
|
+
*/
|
|
18
|
+
import type { NotificationCapabilities } from '@mpgd/platform/notifications';
|
|
19
|
+
/** The reference provider id of the Devvit profile. */
|
|
20
|
+
export declare const devvitNotificationProviderId: 'devvit';
|
|
21
|
+
/** The catalog target Devvit serves. */
|
|
22
|
+
export declare const devvitNotificationTarget: 'reddit';
|
|
23
|
+
/** Longest title Devvit accepts, in Unicode code points of the filled text as the kit counts them. */
|
|
24
|
+
export declare const devvitNotificationTitleMaxLength = 60;
|
|
25
|
+
/** Longest body Devvit accepts, in Unicode code points of the filled text as the kit counts them. */
|
|
26
|
+
export declare const devvitNotificationBodyMaxLength = 100;
|
|
27
|
+
/** Sends per Reddit user per rolling day (a provider limit). */
|
|
28
|
+
export declare const devvitNotificationsPerUserPerDay = 2;
|
|
29
|
+
/** Sends per app per day (the registry entry's `rate`). */
|
|
30
|
+
export declare const devvitNotificationsPerAppPerDay = 25000;
|
|
31
|
+
/**
|
|
32
|
+
* Devvit's length unit, which the RFC left to P3. The docs give the limits
|
|
33
|
+
* in "characters" without a unit, the SDK (`@devvit/notifications` 0.14.1)
|
|
34
|
+
* checks nothing before `Enqueue`, and Devvit admins approve the copy
|
|
35
|
+
* before a game may send it, so the limit is a copy-review rule more than an
|
|
36
|
+
* API one. The kit counts Unicode code points, as the catalog validator
|
|
37
|
+
* counts its mapping fields: an accented letter or an emoji counts once, as
|
|
38
|
+
* a reader sees it, and a grapheme cluster of several code points (a flag, a
|
|
39
|
+
* family emoji) counts more than once, which is stricter than a reader.
|
|
40
|
+
* Devvit has not confirmed the unit; if its server counts UTF-16 units, a
|
|
41
|
+
* text with astral characters (most emoji) that passes here could still be
|
|
42
|
+
* refused there, and the refusal arrives as an `enqueue` error. It counts
|
|
43
|
+
* the filled text, at send time, because `{{name}}` placeholders change the
|
|
44
|
+
* length once template data fills them.
|
|
45
|
+
*/
|
|
46
|
+
export declare function devvitNotificationTextLength(text: string): number;
|
|
47
|
+
/**
|
|
48
|
+
* The characters of a Devvit host code. The server bridge names a host code
|
|
49
|
+
* in an error message as `(host code <code>)`, and the webview reads it back.
|
|
50
|
+
*/
|
|
51
|
+
export declare const devvitHostCodeCharacters = "[A-Za-z0-9_.:-]{1,128}";
|
|
52
|
+
/** A post (`t3_`) or comment (`t1_`) id, the only links `enqueue` accepts. */
|
|
53
|
+
export declare const devvitNotificationLinkPattern: RegExp;
|
|
54
|
+
/** A Reddit user id (`t2_`), the only recipient `enqueue` accepts. */
|
|
55
|
+
export declare const devvitUserIdPattern: RegExp;
|
|
56
|
+
/**
|
|
57
|
+
* The Devvit capability block (RFC "Provider profiles"): app-scope consent
|
|
58
|
+
* the game renders, readable through the server bridge and at send time,
|
|
59
|
+
* persistent, behind a player gesture, revocable in the game, with no
|
|
60
|
+
* settings link; platform-user, free-form (approved copy) remote delivery for
|
|
61
|
+
* `service` and `requested` topics; no local notifications; opens through
|
|
62
|
+
* the send's post or comment link. While the viewer is logged out the
|
|
63
|
+
* availability is `action-required` (login). A fresh object on every call.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createDevvitNotificationCapabilities(availability?: NotificationCapabilities['availability']): NotificationCapabilities;
|
|
66
|
+
/**
|
|
67
|
+
* A block that offers nothing, for a provider read that failed or stalled
|
|
68
|
+
* (`createUnsupportedNotificationCapabilities()` of `@mpgd/platform/notifications`
|
|
69
|
+
* with the given availability, kept equal by a test). A fresh object.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createDevvitDegradedNotificationCapabilities(availability: NotificationCapabilities['availability']): NotificationCapabilities;
|