@mpgd/adapter-devvit 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
- ...capabilities,
68
- commerce: commerce === undefined
69
- ? createUnsupportedCommerceCapabilities()
70
- : await commerce.getCapabilities(),
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) => request('identity.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
- notifications: {
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
- // Notifications v2 arrives with the Devvit provider (P3); until then
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;