@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.
@@ -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
+ }
@@ -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';
@@ -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
+ }