@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
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
/** The reference provider id of the Devvit profile. */
|
|
19
|
+
export const devvitNotificationProviderId = 'devvit';
|
|
20
|
+
/** The catalog target Devvit serves. */
|
|
21
|
+
export const devvitNotificationTarget = 'reddit';
|
|
22
|
+
/** Longest title Devvit accepts, in Unicode code points of the filled text as the kit counts them. */
|
|
23
|
+
export const devvitNotificationTitleMaxLength = 60;
|
|
24
|
+
/** Longest body Devvit accepts, in Unicode code points of the filled text as the kit counts them. */
|
|
25
|
+
export const devvitNotificationBodyMaxLength = 100;
|
|
26
|
+
/** Sends per Reddit user per rolling day (a provider limit). */
|
|
27
|
+
export const devvitNotificationsPerUserPerDay = 2;
|
|
28
|
+
/** Sends per app per day (the registry entry's `rate`). */
|
|
29
|
+
export const devvitNotificationsPerAppPerDay = 25_000;
|
|
30
|
+
/**
|
|
31
|
+
* Devvit's length unit, which the RFC left to P3. The docs give the limits
|
|
32
|
+
* in "characters" without a unit, the SDK (`@devvit/notifications` 0.14.1)
|
|
33
|
+
* checks nothing before `Enqueue`, and Devvit admins approve the copy
|
|
34
|
+
* before a game may send it, so the limit is a copy-review rule more than an
|
|
35
|
+
* API one. The kit counts Unicode code points, as the catalog validator
|
|
36
|
+
* counts its mapping fields: an accented letter or an emoji counts once, as
|
|
37
|
+
* a reader sees it, and a grapheme cluster of several code points (a flag, a
|
|
38
|
+
* family emoji) counts more than once, which is stricter than a reader.
|
|
39
|
+
* Devvit has not confirmed the unit; if its server counts UTF-16 units, a
|
|
40
|
+
* text with astral characters (most emoji) that passes here could still be
|
|
41
|
+
* refused there, and the refusal arrives as an `enqueue` error. It counts
|
|
42
|
+
* the filled text, at send time, because `{{name}}` placeholders change the
|
|
43
|
+
* length once template data fills them.
|
|
44
|
+
*/
|
|
45
|
+
export function devvitNotificationTextLength(text) {
|
|
46
|
+
return [...text].length;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The characters of a Devvit host code. The server bridge names a host code
|
|
50
|
+
* in an error message as `(host code <code>)`, and the webview reads it back.
|
|
51
|
+
*/
|
|
52
|
+
export const devvitHostCodeCharacters = '[A-Za-z0-9_.:-]{1,128}';
|
|
53
|
+
/** A post (`t3_`) or comment (`t1_`) id, the only links `enqueue` accepts. */
|
|
54
|
+
export const devvitNotificationLinkPattern = /^t[13]_[A-Za-z0-9]+$/u;
|
|
55
|
+
/** A Reddit user id (`t2_`), the only recipient `enqueue` accepts. */
|
|
56
|
+
export const devvitUserIdPattern = /^t2_[A-Za-z0-9]+$/u;
|
|
57
|
+
/**
|
|
58
|
+
* The Devvit capability block (RFC "Provider profiles"): app-scope consent
|
|
59
|
+
* the game renders, readable through the server bridge and at send time,
|
|
60
|
+
* persistent, behind a player gesture, revocable in the game, with no
|
|
61
|
+
* settings link; platform-user, free-form (approved copy) remote delivery for
|
|
62
|
+
* `service` and `requested` topics; no local notifications; opens through
|
|
63
|
+
* the send's post or comment link. While the viewer is logged out the
|
|
64
|
+
* availability is `action-required` (login). A fresh object on every call.
|
|
65
|
+
*/
|
|
66
|
+
export function createDevvitNotificationCapabilities(availability = 'available') {
|
|
67
|
+
return {
|
|
68
|
+
availability,
|
|
69
|
+
permission: {
|
|
70
|
+
scope: 'app',
|
|
71
|
+
prompt: 'game',
|
|
72
|
+
readable: true,
|
|
73
|
+
serverReadable: true,
|
|
74
|
+
allowance: 'persistent',
|
|
75
|
+
userGesture: true,
|
|
76
|
+
settings: false,
|
|
77
|
+
},
|
|
78
|
+
remote: {
|
|
79
|
+
supported: true,
|
|
80
|
+
purposes: ['service', 'requested'],
|
|
81
|
+
addressing: 'platform-user',
|
|
82
|
+
content: 'free-form',
|
|
83
|
+
},
|
|
84
|
+
local: { supported: false },
|
|
85
|
+
open: 'host-link',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A block that offers nothing, for a provider read that failed or stalled
|
|
90
|
+
* (`createUnsupportedNotificationCapabilities()` of `@mpgd/platform/notifications`
|
|
91
|
+
* with the given availability, kept equal by a test). A fresh object.
|
|
92
|
+
*/
|
|
93
|
+
export function createDevvitDegradedNotificationCapabilities(availability) {
|
|
94
|
+
return {
|
|
95
|
+
availability,
|
|
96
|
+
permission: {
|
|
97
|
+
scope: 'app',
|
|
98
|
+
prompt: 'os',
|
|
99
|
+
readable: true,
|
|
100
|
+
serverReadable: false,
|
|
101
|
+
allowance: 'persistent',
|
|
102
|
+
userGesture: false,
|
|
103
|
+
settings: false,
|
|
104
|
+
},
|
|
105
|
+
remote: { supported: false, purposes: [], addressing: 'none', content: 'free-form' },
|
|
106
|
+
local: { supported: false },
|
|
107
|
+
open: 'none',
|
|
108
|
+
};
|
|
109
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -4,3 +4,6 @@ export * from './payment-order.js';
|
|
|
4
4
|
export * from './post-operation.js';
|
|
5
5
|
export * from './redis-post-operation-store.js';
|
|
6
6
|
export * from './redis-verified-leaderboard.js';
|
|
7
|
+
export * from './redis-notification-store.js';
|
|
8
|
+
export * from './notifications-delivery.js';
|
|
9
|
+
export * from './notifications-bridge.js';
|
package/dist/server/index.js
CHANGED
|
@@ -4,3 +4,6 @@ export * from './payment-order.js';
|
|
|
4
4
|
export * from './post-operation.js';
|
|
5
5
|
export * from './redis-post-operation-store.js';
|
|
6
6
|
export * from './redis-verified-leaderboard.js';
|
|
7
|
+
export * from './redis-notification-store.js';
|
|
8
|
+
export * from './notifications-delivery.js';
|
|
9
|
+
export * from './notifications-bridge.js';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
|
|
2
|
+
import { type NotificationProvider } from '@mpgd/platform/notifications';
|
|
3
|
+
/**
|
|
4
|
+
* The game-rendered consent of the Devvit profile (notifications v2 RFC,
|
|
5
|
+
* "Provider profiles", Devvit), served from the Devvit server bridge. Reddit
|
|
6
|
+
* shows no consent screen: the game draws its own on/off control, and the
|
|
7
|
+
* server opts the requesting user in or out with `optInCurrentUser` and
|
|
8
|
+
* `optOutCurrentUser` and reads `isOptedIn` (`notifications` from
|
|
9
|
+
* `@devvit/web/server`; <https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/notifications/pn-best-practices>).
|
|
10
|
+
*
|
|
11
|
+
* The user is the Devvit request context's (`context.userId`), never a
|
|
12
|
+
* payload field. A logged-out viewer has none: reads answer
|
|
13
|
+
* `action-required` with `requiredAction: 'login'`, and requests answer the
|
|
14
|
+
* `action-required` outcome (`unavailable` for an unmapped topic) without
|
|
15
|
+
* reaching the plugin; the game then calls
|
|
16
|
+
* `identity.requestUpgrade({ reason: 'notifications' })`.
|
|
17
|
+
*
|
|
18
|
+
* Notifications are a gated beta: an app Reddit has not approved is kept
|
|
19
|
+
* `approval-required` by the target config, which hides the provider from
|
|
20
|
+
* the game. No opt-out event exists, so nothing here pushes events; the
|
|
21
|
+
* backend reads `isOptedIn` at send time instead.
|
|
22
|
+
*/
|
|
23
|
+
/** The server side of the Devvit notifications plugin that opts the current user in and out. */
|
|
24
|
+
export interface DevvitNotificationsConsentClient {
|
|
25
|
+
optInCurrentUser(): Promise<{
|
|
26
|
+
readonly success: boolean;
|
|
27
|
+
readonly message?: string | undefined;
|
|
28
|
+
}>;
|
|
29
|
+
optOutCurrentUser(): Promise<{
|
|
30
|
+
readonly success: boolean;
|
|
31
|
+
readonly message?: string | undefined;
|
|
32
|
+
}>;
|
|
33
|
+
isOptedIn(userId: `t2_${string}`): Promise<boolean>;
|
|
34
|
+
}
|
|
35
|
+
export interface CreateDevvitServerNotificationProviderInput {
|
|
36
|
+
readonly notifications: DevvitNotificationsConsentClient;
|
|
37
|
+
/** The logged-in Reddit user of the current Devvit request (`context.userId`). */
|
|
38
|
+
readonly currentUserId: () => string | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* The topic ids the game's catalog maps for the `reddit` target. A request
|
|
41
|
+
* for a topic outside them answers `unavailable` without opting in.
|
|
42
|
+
*/
|
|
43
|
+
readonly topics: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
export interface CreateDevvitNotificationsV2BridgeHandlerInput extends CreateDevvitServerNotificationProviderInput {
|
|
46
|
+
/**
|
|
47
|
+
* Every failure of a notifications method the handler answers with an
|
|
48
|
+
* error, except an invalid payload (the caller's), with its cause (the
|
|
49
|
+
* plugin's own text, which the webview never sees). Synchronous; a hook
|
|
50
|
+
* that throws or rejects changes nothing. By default `console.warn`, which
|
|
51
|
+
* skips the expected `NOTIFICATIONS_UNSUPPORTED` of the local methods.
|
|
52
|
+
*/
|
|
53
|
+
readonly onError?: ((error: unknown, context: {
|
|
54
|
+
readonly method: string;
|
|
55
|
+
}) => void) | undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Host codes of the failures this provider normalizes. */
|
|
58
|
+
export declare const devvitNotificationHostCodes: Readonly<{
|
|
59
|
+
readonly optInRefused: 'DEVVIT_OPT_IN_REFUSED';
|
|
60
|
+
readonly optOutRefused: 'DEVVIT_OPT_OUT_REFUSED';
|
|
61
|
+
readonly pluginFailed: 'DEVVIT_NOTIFICATIONS_PLUGIN_FAILED';
|
|
62
|
+
}>;
|
|
63
|
+
/**
|
|
64
|
+
* The Devvit server's `NotificationProvider`, for one request at a time: the
|
|
65
|
+
* bridge handler calls it inside the request whose user it reads.
|
|
66
|
+
*
|
|
67
|
+
* - **Reads.** Opted in reads `granted`, otherwise `prompt` (the runtime
|
|
68
|
+
* turns `prompt` into `denied` once a decision is recorded).
|
|
69
|
+
* - **Requests.** Already opted in is `already-granted` without a second
|
|
70
|
+
* opt-in; otherwise `optInCurrentUser` answers `granted`. A refused opt-in
|
|
71
|
+
* (`success: false`, such as an app not yet approved) throws
|
|
72
|
+
* `NOTIFICATIONS_CONFIGURATION_REQUIRED` with host code
|
|
73
|
+
* `DEVVIT_OPT_IN_REFUSED`, which is not retryable: Devvit documents no
|
|
74
|
+
* transient refusal, and a refusal the game retried would loop. A plugin
|
|
75
|
+
* call that throws is `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`.
|
|
76
|
+
* - **Revocation.** `optOutCurrentUser` for a user who is opted in (one who
|
|
77
|
+
* is already out needs none, which Devvit might refuse), the `denied`
|
|
78
|
+
* outcome for every mapped topic (`unavailable` for the others, and no
|
|
79
|
+
* opt-out when none is mapped), and the state the opt-out produced:
|
|
80
|
+
* `prompt`, not opted in.
|
|
81
|
+
* - **Unmapped topics.** A topic the catalog does not map for `reddit`
|
|
82
|
+
* reads `unsupported` in every snapshot.
|
|
83
|
+
* - **Registration.** A logged-in user registers `{ kind: 'platform-user' }`;
|
|
84
|
+
* the webview adds its locale and zone. No device address exists.
|
|
85
|
+
*/
|
|
86
|
+
export declare function createDevvitServerNotificationProvider(input: CreateDevvitServerNotificationProviderInput): NotificationProvider;
|
|
87
|
+
/** Serves the nine `notifications.v2.*` bridge methods; the Devvit server bridge's `notifications` dependency. */
|
|
88
|
+
export interface DevvitNotificationsV2BridgeHandler {
|
|
89
|
+
request(input: BridgeRequest): Promise<BridgeResponse>;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The server bridge side of notifications v2 on Devvit (RFC "Bridge and
|
|
93
|
+
* versioning"): each method's payload and answer are checked with the
|
|
94
|
+
* `@mpgd/bridge/notifications` validators. A payload the validator rejects
|
|
95
|
+
* answers `NOTIFICATIONS_INVALID_INPUT`; a method the profile lacks (the
|
|
96
|
+
* local-notification methods) `NOTIFICATIONS_UNSUPPORTED`; `openSettings`
|
|
97
|
+
* answers `unavailable`, because Devvit has no settings link; an answer the
|
|
98
|
+
* contract rejects `NOTIFICATIONS_CONFIGURATION_REQUIRED`. A thrown
|
|
99
|
+
* `NotificationError` crosses as `{ code, message, retryable }` with its host
|
|
100
|
+
* code named in the message. Devvit has no server-to-webview event channel,
|
|
101
|
+
* so the bridge carries no notifications events.
|
|
102
|
+
*/
|
|
103
|
+
export declare function createDevvitNotificationsV2BridgeHandler(input: CreateDevvitNotificationsV2BridgeHandlerInput): DevvitNotificationsV2BridgeHandler;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createBridgeError, isBridgeNotificationsV2Method, } from '@mpgd/bridge';
|
|
2
|
+
import { assertBridgeNotificationsV2Payload, assertBridgeNotificationsV2Result, } from '@mpgd/bridge/notifications';
|
|
3
|
+
import { PlatformOperationError } from '@mpgd/platform';
|
|
4
|
+
import { isNotificationErrorCode, NotificationError, notificationsProtocolVersion, } from '@mpgd/platform/notifications';
|
|
5
|
+
import { createDevvitNotificationCapabilities, devvitHostCodeCharacters, devvitNotificationProviderId, devvitUserIdPattern, } from '../notifications-contract.js';
|
|
6
|
+
/** Host codes of the failures this provider normalizes. */
|
|
7
|
+
export const devvitNotificationHostCodes = Object.freeze({
|
|
8
|
+
optInRefused: 'DEVVIT_OPT_IN_REFUSED',
|
|
9
|
+
optOutRefused: 'DEVVIT_OPT_OUT_REFUSED',
|
|
10
|
+
pluginFailed: 'DEVVIT_NOTIFICATIONS_PLUGIN_FAILED',
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* The Devvit server's `NotificationProvider`, for one request at a time: the
|
|
14
|
+
* bridge handler calls it inside the request whose user it reads.
|
|
15
|
+
*
|
|
16
|
+
* - **Reads.** Opted in reads `granted`, otherwise `prompt` (the runtime
|
|
17
|
+
* turns `prompt` into `denied` once a decision is recorded).
|
|
18
|
+
* - **Requests.** Already opted in is `already-granted` without a second
|
|
19
|
+
* opt-in; otherwise `optInCurrentUser` answers `granted`. A refused opt-in
|
|
20
|
+
* (`success: false`, such as an app not yet approved) throws
|
|
21
|
+
* `NOTIFICATIONS_CONFIGURATION_REQUIRED` with host code
|
|
22
|
+
* `DEVVIT_OPT_IN_REFUSED`, which is not retryable: Devvit documents no
|
|
23
|
+
* transient refusal, and a refusal the game retried would loop. A plugin
|
|
24
|
+
* call that throws is `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`.
|
|
25
|
+
* - **Revocation.** `optOutCurrentUser` for a user who is opted in (one who
|
|
26
|
+
* is already out needs none, which Devvit might refuse), the `denied`
|
|
27
|
+
* outcome for every mapped topic (`unavailable` for the others, and no
|
|
28
|
+
* opt-out when none is mapped), and the state the opt-out produced:
|
|
29
|
+
* `prompt`, not opted in.
|
|
30
|
+
* - **Unmapped topics.** A topic the catalog does not map for `reddit`
|
|
31
|
+
* reads `unsupported` in every snapshot.
|
|
32
|
+
* - **Registration.** A logged-in user registers `{ kind: 'platform-user' }`;
|
|
33
|
+
* the webview adds its locale and zone. No device address exists.
|
|
34
|
+
*/
|
|
35
|
+
export function createDevvitServerNotificationProvider(input) {
|
|
36
|
+
const mapped = new Set(input.topics);
|
|
37
|
+
const user = () => {
|
|
38
|
+
const id = input.currentUserId();
|
|
39
|
+
return typeof id === 'string' && devvitUserIdPattern.test(id)
|
|
40
|
+
? id
|
|
41
|
+
: undefined;
|
|
42
|
+
};
|
|
43
|
+
const plugin = async (call, what) => {
|
|
44
|
+
try {
|
|
45
|
+
return await call();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// The plugin's own text stays on the server (as the cause); the
|
|
49
|
+
// webview gets a fixed message.
|
|
50
|
+
throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
51
|
+
message: `Devvit notifications ${what} failed.`,
|
|
52
|
+
hostCode: devvitNotificationHostCodes.pluginFailed,
|
|
53
|
+
cause: error,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/** The app's state for each mapped topic; a topic the catalog does not map for `reddit` reads `unsupported`. */
|
|
58
|
+
const snapshotOf = (topics, state) => ({
|
|
59
|
+
app: state,
|
|
60
|
+
topics: Object.fromEntries(topics.map((topic) => [topic, mapped.has(topic) ? state : 'unsupported'])),
|
|
61
|
+
...(state === 'action-required' ? { requiredAction: 'login' } : {}),
|
|
62
|
+
});
|
|
63
|
+
const read = async (userId) => (await plugin(() => input.notifications.isOptedIn(userId), 'isOptedIn')) ? 'granted' : 'prompt';
|
|
64
|
+
/** `forMapped` for each mapped topic, `unavailable` for the others. */
|
|
65
|
+
const outcomesWith = (topics, forMapped) => Object.fromEntries(topics.map((topic) => [topic, mapped.has(topic) ? forMapped : 'unavailable']));
|
|
66
|
+
const loginRequired = (topics) => ({
|
|
67
|
+
snapshot: snapshotOf(topics, 'action-required'),
|
|
68
|
+
outcomes: outcomesWith(topics, 'action-required'),
|
|
69
|
+
// Logging in could not enable a topic the catalog does not map.
|
|
70
|
+
...(topics.some((topic) => mapped.has(topic)) ? { requiredAction: 'login' } : {}),
|
|
71
|
+
});
|
|
72
|
+
const refused = (response, hostCode, what) => {
|
|
73
|
+
if (response?.success === true) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
// Devvit's own message stays on the server (as the cause).
|
|
77
|
+
return new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
|
|
78
|
+
message: `Devvit refused ${what}.`,
|
|
79
|
+
hostCode,
|
|
80
|
+
cause: new Error(typeof response?.message === 'string' ? response.message : 'no message'),
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
provider: devvitNotificationProviderId,
|
|
85
|
+
async getCapabilities() {
|
|
86
|
+
return createDevvitNotificationCapabilities(user() === undefined ? 'action-required' : 'available');
|
|
87
|
+
},
|
|
88
|
+
async getPermission({ topics }) {
|
|
89
|
+
const userId = user();
|
|
90
|
+
return snapshotOf(topics, userId === undefined ? 'action-required' : await read(userId));
|
|
91
|
+
},
|
|
92
|
+
async requestPermission({ topics }) {
|
|
93
|
+
const userId = user();
|
|
94
|
+
if (userId === undefined) {
|
|
95
|
+
return loginRequired(topics);
|
|
96
|
+
}
|
|
97
|
+
const askable = topics.filter((topic) => mapped.has(topic));
|
|
98
|
+
if (askable.length === 0) {
|
|
99
|
+
return { snapshot: snapshotOf(topics, await read(userId)), outcomes: outcomesWith(topics, 'unavailable') };
|
|
100
|
+
}
|
|
101
|
+
let outcome = 'already-granted';
|
|
102
|
+
if ((await read(userId)) !== 'granted') {
|
|
103
|
+
const response = await plugin(() => input.notifications.optInCurrentUser(), 'optInCurrentUser');
|
|
104
|
+
const failure = refused(response, devvitNotificationHostCodes.optInRefused, 'the opt-in');
|
|
105
|
+
if (failure !== undefined) {
|
|
106
|
+
throw failure;
|
|
107
|
+
}
|
|
108
|
+
outcome = 'granted';
|
|
109
|
+
}
|
|
110
|
+
return { snapshot: snapshotOf(topics, 'granted'), outcomes: outcomesWith(topics, outcome) };
|
|
111
|
+
},
|
|
112
|
+
async revokePermission({ topics }) {
|
|
113
|
+
const userId = user();
|
|
114
|
+
if (userId === undefined) {
|
|
115
|
+
return loginRequired(topics);
|
|
116
|
+
}
|
|
117
|
+
const outcomes = outcomesWith(topics, 'denied');
|
|
118
|
+
if (!topics.some((topic) => mapped.has(topic))) {
|
|
119
|
+
// Nothing this provider maps: no opt-out, as a request opts nothing in.
|
|
120
|
+
return { snapshot: snapshotOf(topics, await read(userId)), outcomes };
|
|
121
|
+
}
|
|
122
|
+
// A user who is already out needs no opt-out, which Devvit might refuse.
|
|
123
|
+
if ((await read(userId)) === 'granted') {
|
|
124
|
+
const response = await plugin(() => input.notifications.optOutCurrentUser(), 'optOutCurrentUser');
|
|
125
|
+
const failure = refused(response, devvitNotificationHostCodes.optOutRefused, 'the opt-out');
|
|
126
|
+
if (failure !== undefined) {
|
|
127
|
+
throw failure;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// The opt-out (or the read that found the user out) decided the state:
|
|
131
|
+
// not opted in, which reads `prompt`. A second read could be stale.
|
|
132
|
+
return { snapshot: snapshotOf(topics, 'prompt'), outcomes };
|
|
133
|
+
},
|
|
134
|
+
async register() {
|
|
135
|
+
return user() === undefined
|
|
136
|
+
? null
|
|
137
|
+
: { provider: devvitNotificationProviderId, address: { kind: 'platform-user' } };
|
|
138
|
+
},
|
|
139
|
+
subscribe() {
|
|
140
|
+
// No opt-out event exists, and opens arrive as post links.
|
|
141
|
+
return () => { };
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The server bridge side of notifications v2 on Devvit (RFC "Bridge and
|
|
147
|
+
* versioning"): each method's payload and answer are checked with the
|
|
148
|
+
* `@mpgd/bridge/notifications` validators. A payload the validator rejects
|
|
149
|
+
* answers `NOTIFICATIONS_INVALID_INPUT`; a method the profile lacks (the
|
|
150
|
+
* local-notification methods) `NOTIFICATIONS_UNSUPPORTED`; `openSettings`
|
|
151
|
+
* answers `unavailable`, because Devvit has no settings link; an answer the
|
|
152
|
+
* contract rejects `NOTIFICATIONS_CONFIGURATION_REQUIRED`. A thrown
|
|
153
|
+
* `NotificationError` crosses as `{ code, message, retryable }` with its host
|
|
154
|
+
* code named in the message. Devvit has no server-to-webview event channel,
|
|
155
|
+
* so the bridge carries no notifications events.
|
|
156
|
+
*/
|
|
157
|
+
export function createDevvitNotificationsV2BridgeHandler(input) {
|
|
158
|
+
const provider = createDevvitServerNotificationProvider(input);
|
|
159
|
+
const onError = input.onError ?? ((error, context) => {
|
|
160
|
+
if (error instanceof NotificationError && error.code === 'NOTIFICATIONS_UNSUPPORTED') {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
console.warn(`devvit notifications ${context.method} failed`, error);
|
|
164
|
+
});
|
|
165
|
+
const serve = async (method, payload) => {
|
|
166
|
+
switch (method) {
|
|
167
|
+
case 'notifications.v2.getCapabilities':
|
|
168
|
+
validPayload(method, payload);
|
|
169
|
+
return {
|
|
170
|
+
protocolVersion: notificationsProtocolVersion,
|
|
171
|
+
provider: provider.provider,
|
|
172
|
+
capabilities: await provider.getCapabilities(),
|
|
173
|
+
};
|
|
174
|
+
case 'notifications.v2.getPermission':
|
|
175
|
+
return provider.getPermission(validPayload(method, payload));
|
|
176
|
+
case 'notifications.v2.requestPermission':
|
|
177
|
+
return provider.requestPermission(validPayload(method, payload));
|
|
178
|
+
case 'notifications.v2.revokePermission':
|
|
179
|
+
return provider.revokePermission
|
|
180
|
+
.call(provider, validPayload(method, payload));
|
|
181
|
+
case 'notifications.v2.register':
|
|
182
|
+
validPayload(method, payload);
|
|
183
|
+
return { registration: (await provider.register?.()) ?? null };
|
|
184
|
+
case 'notifications.v2.openSettings':
|
|
185
|
+
validPayload(method, payload);
|
|
186
|
+
return { status: 'unavailable' };
|
|
187
|
+
case 'notifications.v2.scheduleLocal':
|
|
188
|
+
case 'notifications.v2.cancelLocal':
|
|
189
|
+
case 'notifications.v2.listLocal':
|
|
190
|
+
validPayload(method, payload);
|
|
191
|
+
throw new NotificationError('NOTIFICATIONS_UNSUPPORTED', {
|
|
192
|
+
message: `Devvit has no local notifications (${method}).`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
async request(request) {
|
|
198
|
+
if (!isBridgeNotificationsV2Method(request.method)) {
|
|
199
|
+
return createBridgeError(request.id, 'UNSUPPORTED_METHOD', `${request.method} is not a notifications v2 method.`);
|
|
200
|
+
}
|
|
201
|
+
const method = request.method;
|
|
202
|
+
try {
|
|
203
|
+
const data = await serve(method, request.payload);
|
|
204
|
+
return { id: request.id, ok: true, data: validResult(method, data) };
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (!(error instanceof InvalidPayloadError)) {
|
|
208
|
+
try {
|
|
209
|
+
const logged = onError(error, { method });
|
|
210
|
+
if (logged instanceof Promise) {
|
|
211
|
+
logged.catch(() => { });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// A failing log never changes the answer.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return toBridgeError(request, error);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
class InvalidPayloadError extends Error {
|
|
224
|
+
name = 'InvalidPayloadError';
|
|
225
|
+
}
|
|
226
|
+
function validPayload(method, payload) {
|
|
227
|
+
try {
|
|
228
|
+
return assertBridgeNotificationsV2Payload(method, payload);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
throw new InvalidPayloadError(`${method} payload is invalid: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function validResult(method, data) {
|
|
235
|
+
try {
|
|
236
|
+
return assertBridgeNotificationsV2Result(method, data);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
|
|
240
|
+
message: `The Devvit notifications provider answered ${method} with invalid data: ${error instanceof Error ? error.message : String(error)}`,
|
|
241
|
+
cause: error,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const hostCodeValue = new RegExp(`^${devvitHostCodeCharacters}$`, 'u');
|
|
246
|
+
function toBridgeError(request, error) {
|
|
247
|
+
if (error instanceof InvalidPayloadError) {
|
|
248
|
+
return createBridgeError(request.id, 'NOTIFICATIONS_INVALID_INPUT', error.message);
|
|
249
|
+
}
|
|
250
|
+
if (error instanceof NotificationError) {
|
|
251
|
+
return createBridgeError(request.id, error.code, error.hostCode === undefined || !hostCodeValue.test(error.hostCode)
|
|
252
|
+
? error.message
|
|
253
|
+
: `${error.message} (host code ${error.hostCode})`, error.retryable);
|
|
254
|
+
}
|
|
255
|
+
if (error instanceof PlatformOperationError) {
|
|
256
|
+
return isNotificationErrorCode(error.code)
|
|
257
|
+
? createBridgeError(request.id, error.code, error.message, error.retryable)
|
|
258
|
+
: createBridgeError(request.id, 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', `${request.method} failed: ${error.message}`, error.retryable);
|
|
259
|
+
}
|
|
260
|
+
// An unexpected failure's text stays on the server.
|
|
261
|
+
return createBridgeError(request.id, 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', `${request.method} failed on the Devvit server.`, true);
|
|
262
|
+
}
|