@mpgd/adapter-devvit 0.13.5 → 0.14.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 CHANGED
@@ -4,9 +4,9 @@ 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, the Devvit notifications profile values |
7
+ | `@mpgd/adapter-devvit` | `createDevvitPlatformGateway`, the oRPC bridge and sandbox bridge, the commerce provider and catalog helpers, the Devvit notifications profile values, `createDevvitNotificationsBackend` (the notifications runtime's backend) |
8
8
  | `@mpgd/adapter-devvit/payments` | `normalizeDevvitCommerceProducts` and the checkout types |
9
- | `@mpgd/adapter-devvit/server` | `createDevvitCommerceV2BridgeHandler`, `createDevvitNotificationsV2BridgeHandler`, the notifications service and delivery provider, payment-order normalizers, durable post operations, Redis stores |
9
+ | `@mpgd/adapter-devvit/server` | `createDevvitCommerceV2BridgeHandler`, `createDevvitNotificationsV2BridgeHandler`, `createDevvitNotificationsBackendBridgeHandler`, the notifications service and delivery provider, payment-order normalizers, durable post operations, Redis stores |
10
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
 
@@ -76,6 +76,38 @@ Reddit approves. The profile follows the
76
76
  `runDevvitNotificationTask(service, task)`, as the kit's
77
77
  `apps/target-devvit` server does. A player's `setReminder` fires through
78
78
  the dispatch task.
79
+ - **The runtime's backend.** The game's notifications runtime
80
+ (`createNotificationRuntime` from `@mpgd/platform/notifications-runtime`)
81
+ keeps consent records, the player's registration (locale and zone),
82
+ server reminders, and notices in that same service:
83
+ - The server passes
84
+ `createDevvitNotificationsBackendBridgeHandler({ service, context: () => context })`
85
+ as the bridge's `notificationsBackend` dependency. It serves the nine
86
+ `notifications.backend.*` methods for `devvitNotificationSessionFromContext(context())`,
87
+ the request's logged-in user, never a payload field; a call that names
88
+ another player (`expectedPlayer`) or comes from a logged-out viewer is
89
+ refused before the service. Without the dependency the bridge answers
90
+ `NOTIFICATIONS_CONFIGURATION_REQUIRED`.
91
+ - The webview passes `createDevvitNotificationsBackend({ appVersion, buildId })`
92
+ as the runtime's `backend`. Its client loads in its own chunk and reads
93
+ the session the server answers (`getSession`), so the game sets no session
94
+ of its own; it announces the player to the runtime, reads again when the
95
+ server refuses a call for another user, and retries a failed read at most
96
+ every 5 seconds when the runtime next reads the session. Requests time
97
+ out after 15 seconds. While the server answers signed out, the client
98
+ keeps reading the session at most every 5 seconds as the runtime reads
99
+ it, so a login is picked up whether or not Reddit reloads the post after
100
+ `showLoginPrompt()`. The runtime's
101
+ `unregister()` call removes this installation's endpoint record for the
102
+ user and provider; the platform-user
103
+ registration (locale and zone) is not an endpoint and stays, and sends
104
+ still need the user's opt-in.
105
+ - A reminder set through the runtime (`runtime.reminders.set`) is stored
106
+ by the service and fired by the `dispatch` task through `enqueue`, with
107
+ the reminder's `link` (a post or comment id such as `context.postId` from
108
+ `@devvit/web/client`) or the topic mapping's default `link`. Notices stay empty unless the deployment applies the Korean
109
+ rules, and Devvit carries no advertising topic, so the starter shows no
110
+ advertising or night consent on Reddit.
79
111
  - **Opens.** A notification opens its post; the game routes from that post's
80
112
  data.
81
113
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type BridgeMethod, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import { type BridgeRpcClient, type BridgeRpcEndpoint } from '@mpgd/bridge/orpc';
3
3
  import { type PlatformGateway } from '@mpgd/platform';
4
+ import type { NotificationRuntimeBackend, NotificationRuntimeSession } from '@mpgd/platform/notifications-runtime';
4
5
  import { type CreateDevvitCommerceProviderInput, type DevvitOrderSource } from './commerce-provider.js';
5
6
  export * from './commerce-contract.js';
6
7
  export * from './commerce-provider.js';
@@ -86,6 +87,62 @@ export type DevvitNotificationClientModule = Pick<typeof import('./notifications
86
87
  export interface DevvitLoginPrompt {
87
88
  showLoginPrompt(): void;
88
89
  }
90
+ /** Where a request goes: the injected bridge, the host's global bridge, the fallback, or the oRPC bridge. */
91
+ type DevvitBridgeTarget = Pick<DevvitPlatformGatewayOptions, 'appVersion' | 'buildId' | 'bridge' | 'fallbackBridge' | 'rpcEndpoint'>;
92
+ export interface DevvitNotificationsBackendOptions extends DevvitBridgeTarget {
93
+ /** How long one backend request may take; 15 seconds by default (see `createDevvitNotificationsBackendClient`). */
94
+ readonly timeoutMs?: number;
95
+ /**
96
+ * While the client chunk has not loaded, or no session read has settled
97
+ * (none answered a signed-in player, and none failed in a way a retry
98
+ * cannot change), `session()` tries again at most this
99
+ * often; 5 seconds by default. After a
100
+ * load the client refuses (options out of range), `session()` does not try
101
+ * again; the other calls answer `NOTIFICATIONS_INVALID_INPUT`, except
102
+ * `unregisterEndpoint`, which has nothing to unbind and resolves.
103
+ */
104
+ readonly retryIntervalMs?: number;
105
+ /** The clock the retry interval reads, in milliseconds; `Date.now` by default. */
106
+ readonly now?: () => number;
107
+ /** Loads the backend client chunk, when the backend is created; the dynamic import by default. Tests inject it. */
108
+ readonly load?: () => Promise<DevvitNotificationsBackendClientModule>;
109
+ }
110
+ /** What `createDevvitNotificationsBackend` uses from the backend client chunk. */
111
+ export type DevvitNotificationsBackendClientModule = Pick<typeof import('./notifications-backend-client.js'), 'createDevvitNotificationsBackendClient'>;
112
+ /** The notifications runtime's backend on Devvit, with the session read the server answers. */
113
+ export interface DevvitNotificationsBackend extends NotificationRuntimeBackend {
114
+ /**
115
+ * Reads the session the Devvit server answers for the request's logged-in
116
+ * user and announces a new one through `onAuthenticated`; the backend
117
+ * starts one read when it is created.
118
+ */
119
+ refreshSession(): Promise<NotificationRuntimeSession | undefined>;
120
+ }
121
+ /**
122
+ * The notifications runtime's backend for a Reddit game
123
+ * (`NotificationRuntimeBackend` for `createNotificationRuntime` from
124
+ * `@mpgd/platform/notifications-runtime`): the Devvit server's notifications
125
+ * service over the same server bridge as `createDevvitPlatformGateway`,
126
+ * served by `createDevvitNotificationsBackendBridgeHandler` from
127
+ * `@mpgd/adapter-devvit/server`.
128
+ *
129
+ * Its client and validators load in their own chunk, starting now, and the
130
+ * backend reads the session as soon as they have loaded. Until the server
131
+ * has answered for a logged-in user, `session()` is `undefined` and calls
132
+ * answer `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`; the runtime hears of the
133
+ * user through `onAuthenticated`. A chunk that fails to load is
134
+ * `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` too; the next call, or a
135
+ * `session()` read at most once per `retryIntervalMs`, loads again, and a
136
+ * session read that failed is retried the same way. Options the client
137
+ * refuses are not retried from `session()`: calls answer
138
+ * `NOTIFICATIONS_INVALID_INPUT`, and the refusal is logged once. While the
139
+ * server answers signed out, `session()` reads again at most once per
140
+ * `retryIntervalMs`, so a login is picked up whether or not Reddit reloads
141
+ * the post.
142
+ * The player is always the one the server answers for; the webview never
143
+ * names one it chose.
144
+ */
145
+ export declare function createDevvitNotificationsBackend(options: DevvitNotificationsBackendOptions): DevvitNotificationsBackend;
89
146
  export declare function createDevvitPlatformGateway(input: DevvitPlatformGatewayOptions): PlatformGateway;
90
147
  /**
91
148
  * The webview provider's order source over the server bridge: the paid orders
package/dist/index.js CHANGED
@@ -26,8 +26,9 @@ export class DevvitBridgeError extends Error {
26
26
  this.retryable = response.error.retryable;
27
27
  }
28
28
  }
29
- export function createDevvitPlatformGateway(input) {
30
- async function request(method, payload) {
29
+ /** Sends one request over the bridge the gateway uses; a bridge error rejects as a `DevvitBridgeError`. */
30
+ function createDevvitBridgeRequest(input) {
31
+ return async function request(method, payload) {
31
32
  const bridge = input.bridge ??
32
33
  getBridge() ??
33
34
  input.fallbackBridge ??
@@ -47,7 +48,139 @@ export function createDevvitPlatformGateway(input) {
47
48
  throw new DevvitBridgeError(response);
48
49
  }
49
50
  return response.data;
50
- }
51
+ };
52
+ }
53
+ /**
54
+ * The notifications runtime's backend for a Reddit game
55
+ * (`NotificationRuntimeBackend` for `createNotificationRuntime` from
56
+ * `@mpgd/platform/notifications-runtime`): the Devvit server's notifications
57
+ * service over the same server bridge as `createDevvitPlatformGateway`,
58
+ * served by `createDevvitNotificationsBackendBridgeHandler` from
59
+ * `@mpgd/adapter-devvit/server`.
60
+ *
61
+ * Its client and validators load in their own chunk, starting now, and the
62
+ * backend reads the session as soon as they have loaded. Until the server
63
+ * has answered for a logged-in user, `session()` is `undefined` and calls
64
+ * answer `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`; the runtime hears of the
65
+ * user through `onAuthenticated`. A chunk that fails to load is
66
+ * `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` too; the next call, or a
67
+ * `session()` read at most once per `retryIntervalMs`, loads again, and a
68
+ * session read that failed is retried the same way. Options the client
69
+ * refuses are not retried from `session()`: calls answer
70
+ * `NOTIFICATIONS_INVALID_INPUT`, and the refusal is logged once. While the
71
+ * server answers signed out, `session()` reads again at most once per
72
+ * `retryIntervalMs`, so a login is picked up whether or not Reddit reloads
73
+ * the post.
74
+ * The player is always the one the server answers for; the webview never
75
+ * names one it chose.
76
+ */
77
+ export function createDevvitNotificationsBackend(options) {
78
+ const request = createDevvitBridgeRequest(options);
79
+ const importClient = options.load ?? (() => import('./notifications-backend-client.js'));
80
+ // `defaultDevvitNotificationsBackendRetryIntervalMs` of the client chunk, which this entry does not import.
81
+ const retryIntervalMs = options.retryIntervalMs ?? 5_000;
82
+ const now = options.now ?? Date.now;
83
+ const listeners = new Set();
84
+ let client;
85
+ let loading;
86
+ /** When the last chunk load started or failed, whichever is later. */
87
+ let lastLoadAt;
88
+ /** A load failed in a way another load cannot change (options the client refuses). */
89
+ let loadRefused = false;
90
+ const load = () => {
91
+ if (client !== undefined) {
92
+ return Promise.resolve(client);
93
+ }
94
+ if (loading !== undefined) {
95
+ return loading;
96
+ }
97
+ lastLoadAt = now();
98
+ const attempt = (async () => {
99
+ let module;
100
+ try {
101
+ module = await importClient();
102
+ }
103
+ catch (error) {
104
+ throw new PlatformOperationError({
105
+ code: 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE',
106
+ message: `The Devvit notifications backend client did not load: ${errorMessage(error)}`,
107
+ retryable: true,
108
+ });
109
+ }
110
+ // Options the client refuses (a timeoutMs out of range) fail as they are, not as a load to retry.
111
+ const created = module.createDevvitNotificationsBackendClient({
112
+ request: (method, payload) => request(method, payload),
113
+ retryIntervalMs,
114
+ now,
115
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
116
+ });
117
+ created.onAuthenticated((session) => {
118
+ for (const listener of [...listeners]) {
119
+ try {
120
+ listener(session);
121
+ }
122
+ catch {
123
+ // One listener failing never keeps the session from the others.
124
+ }
125
+ }
126
+ });
127
+ client = created;
128
+ // Read the session now, so the runtime hears of a logged-in user.
129
+ void created.refreshSession().catch(() => { });
130
+ return created;
131
+ })();
132
+ loading = attempt;
133
+ attempt.then(() => {
134
+ loading = undefined;
135
+ }, (error) => {
136
+ loading = undefined;
137
+ lastLoadAt = now();
138
+ const refused = !(typeof error === 'object' && error !== null
139
+ && error.retryable === true);
140
+ if (refused && !loadRefused) {
141
+ console.error('[notifications] the Devvit notifications backend refused its options.', error);
142
+ }
143
+ loadRefused = loadRefused || refused;
144
+ });
145
+ return attempt;
146
+ };
147
+ void load().catch(() => { });
148
+ return {
149
+ session() {
150
+ if (client !== undefined) {
151
+ return client.session();
152
+ }
153
+ // The runtime makes no backend call while it sees no session, so a
154
+ // chunk that failed to load is loaded again from here.
155
+ // A load another load cannot fix is not retried from here; calls still report it.
156
+ if (loading === undefined
157
+ && !loadRefused
158
+ && (lastLoadAt === undefined || !withinBackendRetryInterval(now() - lastLoadAt, retryIntervalMs))) {
159
+ void load().catch(() => { });
160
+ }
161
+ return undefined;
162
+ },
163
+ onAuthenticated(listener) {
164
+ const entry = (session) => listener(session);
165
+ listeners.add(entry);
166
+ return () => {
167
+ listeners.delete(entry);
168
+ };
169
+ },
170
+ refreshSession: async () => (await load()).refreshSession(),
171
+ recordConsent: async (record) => (await load()).recordConsent(record),
172
+ listConsent: async () => (await load()).listConsent(),
173
+ registerEndpoint: async (registration) => (await load()).registerEndpoint(registration),
174
+ // Nothing was registered before the client loaded, and no session is signed in.
175
+ unregisterEndpoint: async (input) => client?.unregisterEndpoint(input),
176
+ setReminder: async (reminder) => (await load()).setReminder(reminder),
177
+ cancelReminder: async (input) => (await load()).cancelReminder(input),
178
+ listNotices: async () => (await load()).listNotices(),
179
+ recordNotice: async (input) => (await load()).recordNotice(input),
180
+ };
181
+ }
182
+ export function createDevvitPlatformGateway(input) {
183
+ const request = createDevvitBridgeRequest(input);
51
184
  const commerce = input.commerce === undefined
52
185
  ? undefined
53
186
  : createDevvitCommerceProvider({
@@ -442,6 +575,15 @@ function generateRequestId() {
442
575
  }
443
576
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
444
577
  }
578
+ /**
579
+ * Whether `elapsed` milliseconds are still inside the retry interval; a
580
+ * clock that went backwards (a negative or non-finite difference) counts as
581
+ * past it. The same rule as the client chunk's `withinRetryInterval`, which
582
+ * this entry does not import.
583
+ */
584
+ function withinBackendRetryInterval(elapsed, retryIntervalMs) {
585
+ return elapsed >= 0 && elapsed < retryIntervalMs;
586
+ }
445
587
  function errorMessage(error) {
446
588
  return error instanceof Error ? error.message : String(error);
447
589
  }
@@ -0,0 +1,67 @@
1
+ import type { BridgeNotificationsBackendMethod } from '@mpgd/bridge';
2
+ import type { NotificationRuntimeBackend, NotificationRuntimeSession } from '@mpgd/platform/notifications-runtime';
3
+ /**
4
+ * The webview side of the notifications runtime's backend on Devvit
5
+ * (notifications v2 RFC, "Bridge and versioning", "Backend methods"): a
6
+ * `NotificationRuntimeBackend` over the Devvit server bridge, which serves
7
+ * the Devvit notifications service from
8
+ * `createDevvitNotificationsBackendBridgeHandler`
9
+ * (`@mpgd/adapter-devvit/server`). Loaded in its own chunk.
10
+ *
11
+ * - **Session.** The client never chooses a player. It reads the session
12
+ * the server answers for the request's logged-in user (`getSession`,
13
+ * through `refreshSession()`), and `session()` answers the last one read.
14
+ * Every call names that player as `expectedPlayer`; the server acts for
15
+ * its own user and refuses a call whose player is not it. A refusal for a
16
+ * logged-out or changed user makes the client read the session again
17
+ * (announcing a new one through `onAuthenticated`) and answers
18
+ * `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`, which the runtime retries at
19
+ * its next trigger.
20
+ * - **Errors.** A bridge error with a `NOTIFICATIONS_*` code keeps its code,
21
+ * retryable flag, and host code. `UNSUPPORTED_METHOD`,
22
+ * `INVALID_BRIDGE_REQUEST`, and `NOTIFICATIONS_V2_UNAVAILABLE` from a
23
+ * server that serves no backend are `NOTIFICATIONS_CONFIGURATION_REQUIRED`
24
+ * (not retryable), as the game-services client reads a Worker without the
25
+ * session routes. An answer outside the method's contract is
26
+ * `NOTIFICATIONS_CONFIGURATION_REQUIRED`. Anything else (the transport
27
+ * failing) and a request with no answer within `timeoutMs` is a retryable
28
+ * `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`; a request that timed out may
29
+ * still have reached the server. A payload the bridge contract refuses is
30
+ * `NOTIFICATIONS_INVALID_INPUT` and is not sent.
31
+ */
32
+ export interface CreateDevvitNotificationsBackendClientInput {
33
+ /** Sends one server bridge request and returns its data; a bridge error rejects with its `code`, `message`, and `retryable`. */
34
+ readonly request: (method: BridgeNotificationsBackendMethod, payload: unknown) => Promise<unknown>;
35
+ /**
36
+ * How long one request may take before the client stops waiting and
37
+ * answers `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`; 15 seconds by default,
38
+ * at most 2147483647. Devvit ends a server request after 30 seconds.
39
+ */
40
+ readonly timeoutMs?: number;
41
+ /**
42
+ * While no session read has settled (none was made, the last one answered
43
+ * signed out, or it failed in a way a retry may fix), `session()` and a
44
+ * call that finds no session start another read, at most one this often;
45
+ * 5 seconds by default. 0 reads again on every such call, one at a time.
46
+ */
47
+ readonly retryIntervalMs?: number;
48
+ /** The clock the retry interval reads, in milliseconds; `Date.now` by default. */
49
+ readonly now?: () => number;
50
+ }
51
+ export interface DevvitNotificationsBackendClient extends NotificationRuntimeBackend {
52
+ /**
53
+ * Reads the session the server answers for the request's user, keeps it
54
+ * for `session()`, and announces it through `onAuthenticated` when it is a
55
+ * new one (a first sign-in, or another user). A logged-out answer clears
56
+ * it, and `session()` keeps reading at `retryIntervalMs` until a player
57
+ * signs in. Concurrent reads share one request. A read that fails keeps the
58
+ * session as it was and rejects.
59
+ */
60
+ refreshSession(): Promise<NotificationRuntimeSession | undefined>;
61
+ }
62
+ /** The default `timeoutMs`: a request still unanswered after this is no longer awaited. */
63
+ export declare const defaultDevvitNotificationsBackendTimeoutMs = 15000;
64
+ /** The default `retryIntervalMs` between session reads while none has settled. */
65
+ export declare const defaultDevvitNotificationsBackendRetryIntervalMs = 5000;
66
+ /** Creates the client. It holds no session until `refreshSession()` has read one. */
67
+ export declare function createDevvitNotificationsBackendClient(input: CreateDevvitNotificationsBackendClientInput): DevvitNotificationsBackendClient;
@@ -0,0 +1,319 @@
1
+ import { assertBridgeNotificationsBackendPayload, assertBridgeNotificationsBackendResult, notificationsBackendBridgeHostCodes, readBridgeNotificationsHostCode, } from '@mpgd/bridge/notifications';
2
+ import { isNotificationErrorCode, NotificationError, notificationsProtocol, } from '@mpgd/platform/notifications';
3
+ /** The default `timeoutMs`: a request still unanswered after this is no longer awaited. */
4
+ export const defaultDevvitNotificationsBackendTimeoutMs = 15_000;
5
+ /** The default `retryIntervalMs` between session reads while none has settled. */
6
+ export const defaultDevvitNotificationsBackendRetryIntervalMs = 5_000;
7
+ /** Bridge codes a server that serves no notifications backend answers with. */
8
+ const backendUnavailableCodes = new Set([
9
+ 'UNSUPPORTED_METHOD',
10
+ 'INVALID_BRIDGE_REQUEST',
11
+ 'NOTIFICATIONS_V2_UNAVAILABLE',
12
+ ]);
13
+ /** Host codes of a call the server refused for its session; nothing was read or changed. */
14
+ const sessionRefusalHostCodes = new Set([
15
+ notificationsBackendBridgeHostCodes.signedOut,
16
+ notificationsBackendBridgeHostCodes.sessionChanged,
17
+ ]);
18
+ /** Creates the client. It holds no session until `refreshSession()` has read one. */
19
+ export function createDevvitNotificationsBackendClient(input) {
20
+ const timeoutMs = input.timeoutMs ?? defaultDevvitNotificationsBackendTimeoutMs;
21
+ // setTimeout takes at most 2^31 - 1 ms; a longer delay would fire at once.
22
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
23
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
24
+ message: 'The Devvit notifications backend timeoutMs must be a positive number of milliseconds, at most 2147483647.',
25
+ });
26
+ }
27
+ const retryIntervalMs = input.retryIntervalMs ?? defaultDevvitNotificationsBackendRetryIntervalMs;
28
+ if (!Number.isFinite(retryIntervalMs) || retryIntervalMs < 0) {
29
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
30
+ message: 'The Devvit notifications backend retryIntervalMs must be a non-negative number of milliseconds.',
31
+ });
32
+ }
33
+ const now = input.now ?? Date.now;
34
+ let current;
35
+ let refreshing;
36
+ /** When the last session read started or settled, whichever is later. */
37
+ let lastReadAt;
38
+ /**
39
+ * Whether the session read settled: the server answered a
40
+ * signed-in player, or a read failed in a way a retry cannot change (a
41
+ * server without the backend, or an answer outside the contract, which
42
+ * only a fixed server or a reload changes). Until then `session()` and a
43
+ * call that finds no session start a read (`retryIfUnsettled`): a first
44
+ * read that failed (offline, timed out) is tried again, and so is a
45
+ * signed-out answer, so a login is picked up whether or not Reddit
46
+ * reloads the post, even though the runtime makes no backend call while
47
+ * it sees no session. A later signed-out answer (an explicit
48
+ * `refreshSession()`, or the read after a refusal) unsettles it again.
49
+ */
50
+ let sessionSettled = false;
51
+ const listeners = new Set();
52
+ const send = async (method, payload) => {
53
+ try {
54
+ assertBridgeNotificationsBackendPayload(method, payload);
55
+ }
56
+ catch (error) {
57
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
58
+ message: `${method}: the request is outside the bridge contract; it was not sent.`,
59
+ cause: error,
60
+ });
61
+ }
62
+ let timer;
63
+ let data;
64
+ try {
65
+ data = await Promise.race([
66
+ input.request(method, payload),
67
+ new Promise((_resolve, reject) => {
68
+ timer = setTimeout(() => {
69
+ reject(new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
70
+ message: `${method}: the Devvit server did not answer within ${timeoutMs} ms; the client stopped `
71
+ + 'waiting, and the server may still have handled it.',
72
+ }));
73
+ }, timeoutMs);
74
+ }),
75
+ ]);
76
+ }
77
+ catch (error) {
78
+ throw toNotificationError(method, error);
79
+ }
80
+ finally {
81
+ clearTimeout(timer);
82
+ }
83
+ try {
84
+ return assertBridgeNotificationsBackendResult(method, data);
85
+ }
86
+ catch (error) {
87
+ throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
88
+ message: `The Devvit server answered ${method} outside the notifications backend contract.`,
89
+ cause: error,
90
+ });
91
+ }
92
+ };
93
+ const announce = (session) => {
94
+ for (const listener of [...listeners]) {
95
+ try {
96
+ listener(session);
97
+ }
98
+ catch {
99
+ // One listener failing never keeps the session from the others.
100
+ }
101
+ }
102
+ };
103
+ const refreshSession = () => {
104
+ if (refreshing !== undefined) {
105
+ return refreshing;
106
+ }
107
+ lastReadAt = now();
108
+ const attempt = (async () => {
109
+ let session;
110
+ try {
111
+ ({ session } = await send('notifications.backend.getSession', { protocol: notificationsProtocol }));
112
+ }
113
+ catch (error) {
114
+ if (error instanceof NotificationError && !error.retryable) {
115
+ sessionSettled = true;
116
+ }
117
+ throw error;
118
+ }
119
+ // A signed-out answer leaves the read unsettled: the player may log in
120
+ // without the post being reloaded, so the next read asks again.
121
+ sessionSettled = session !== null;
122
+ if (session === null) {
123
+ current = undefined;
124
+ return undefined;
125
+ }
126
+ const previous = current;
127
+ const next = { id: session.id, player: session.player };
128
+ if (previous?.id === next.id && previous.player === next.player) {
129
+ return previous;
130
+ }
131
+ current = next;
132
+ announce(next);
133
+ return next;
134
+ })();
135
+ refreshing = attempt;
136
+ // The next read asks again once this one settles, whatever it answered;
137
+ // a retry waits the interval from the settle, so a read that timed out
138
+ // is not followed by another at once.
139
+ void attempt.then(() => {
140
+ refreshing = undefined;
141
+ lastReadAt = now();
142
+ }, () => {
143
+ refreshing = undefined;
144
+ lastReadAt = now();
145
+ });
146
+ return attempt;
147
+ };
148
+ const requireSession = (method) => {
149
+ if (current === undefined) {
150
+ retryIfUnsettled();
151
+ throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
152
+ message: `${method} needs a signed-in player; no request was sent.`,
153
+ });
154
+ }
155
+ return current;
156
+ };
157
+ /**
158
+ * While no session is known and no read has settled (none was made, the
159
+ * last one answered signed out, or it failed in a way a retry may fix),
160
+ * starts a read in the
161
+ * background, at most one at a time and one per `retryIntervalMs`, so the
162
+ * runtime hears of a signed-in player through onAuthenticated instead of
163
+ * waiting forever.
164
+ */
165
+ const retryIfUnsettled = () => {
166
+ if (current !== undefined
167
+ || sessionSettled
168
+ || refreshing !== undefined
169
+ || (lastReadAt !== undefined && withinRetryInterval(now() - lastReadAt, retryIntervalMs))) {
170
+ return;
171
+ }
172
+ void refreshSession().catch(() => undefined);
173
+ };
174
+ /**
175
+ * Reads the session again after the server refused a call for `refused`.
176
+ * A read already in flight may have been answered before the user
177
+ * changed. When its answer contradicts the refusal, one more read asks the
178
+ * server again: the refused session itself, or, after a refusal that said
179
+ * another user is signed in, no user. A failed read leaves the session as
180
+ * it was; the next refusal reads again.
181
+ */
182
+ const rereadAfterRefusal = async (refused, hostCode) => {
183
+ const contradicts = (read) => read === undefined
184
+ ? hostCode === notificationsBackendBridgeHostCodes.sessionChanged
185
+ : read.id === refused.id && read.player === refused.player;
186
+ try {
187
+ if (contradicts(await refreshSession())) {
188
+ await refreshSession();
189
+ }
190
+ }
191
+ catch (error) {
192
+ // A read failed in a way a retry may fix after a stale answer cleared
193
+ // the session: read again later rather than wait for a refusal that
194
+ // cannot come.
195
+ if (current === undefined && error instanceof NotificationError && error.retryable) {
196
+ sessionSettled = false;
197
+ }
198
+ // The refusal is what the caller needs to see.
199
+ }
200
+ };
201
+ /**
202
+ * One call for the current session's player. A refusal for its session
203
+ * reads the session again before it answers, so the runtime sees the
204
+ * player the server serves now when it reacts.
205
+ */
206
+ const call = async (method, body, session = requireSession(method)) => {
207
+ try {
208
+ return await send(method, {
209
+ ...body,
210
+ expectedPlayer: session.player,
211
+ });
212
+ }
213
+ catch (error) {
214
+ if (error instanceof NotificationError && sessionRefusalHostCodes.has(error.hostCode)) {
215
+ await rereadAfterRefusal(session, error.hostCode);
216
+ }
217
+ throw error;
218
+ }
219
+ };
220
+ return {
221
+ session() {
222
+ retryIfUnsettled();
223
+ return current;
224
+ },
225
+ onAuthenticated(listener) {
226
+ // The session already read when the runtime subscribes is not a new one.
227
+ const entry = (session) => listener(session);
228
+ listeners.add(entry);
229
+ return () => {
230
+ listeners.delete(entry);
231
+ };
232
+ },
233
+ refreshSession,
234
+ async recordConsent(record) {
235
+ await call('notifications.backend.recordConsent', { record });
236
+ },
237
+ async listConsent() {
238
+ return (await call('notifications.backend.listConsent', {})).records;
239
+ },
240
+ async registerEndpoint(registration) {
241
+ await call('notifications.backend.registerEndpoint', { registration });
242
+ },
243
+ async unregisterEndpoint({ provider, player }) {
244
+ const session = current;
245
+ if (session === undefined || session.player !== player) {
246
+ // A Devvit registration belongs to the Reddit user, not to a device,
247
+ // and another user's request cannot reach it.
248
+ return;
249
+ }
250
+ try {
251
+ await call('notifications.backend.unregisterEndpoint', { provider }, session);
252
+ }
253
+ catch (error) {
254
+ // The user left before the call reached the server: nothing on this
255
+ // webview belongs to that user any more. (On Devvit the service keeps
256
+ // the user's platform-user locale and zone; sends still need the
257
+ // user's opt-in.)
258
+ if (error instanceof NotificationError && sessionRefusalHostCodes.has(error.hostCode)) {
259
+ return;
260
+ }
261
+ throw error;
262
+ }
263
+ },
264
+ async setReminder(reminder) {
265
+ return (await call('notifications.backend.setReminder', { reminder })).status;
266
+ },
267
+ async cancelReminder({ key }) {
268
+ return (await call('notifications.backend.cancelReminder', { key })).cancelled;
269
+ },
270
+ async listNotices() {
271
+ return (await call('notifications.backend.listNotices', {})).notices;
272
+ },
273
+ async recordNotice({ channel, kind, record, dueBy }) {
274
+ await call('notifications.backend.recordNotice', {
275
+ notice: { kind, record: { kind: record.kind, at: record.at }, dueBy },
276
+ channel,
277
+ });
278
+ },
279
+ };
280
+ }
281
+ function toNotificationError(method, error) {
282
+ if (error instanceof NotificationError) {
283
+ return error;
284
+ }
285
+ const failure = typeof error === 'object' && error !== null
286
+ ? error
287
+ : {};
288
+ const message = typeof failure.message === 'string' ? failure.message : undefined;
289
+ if (backendUnavailableCodes.has(failure.code)) {
290
+ return new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
291
+ message: `${method}: the Devvit server serves no notifications backend (wire `
292
+ + 'createDevvitNotificationsBackendBridgeHandler into its bridge).',
293
+ cause: error,
294
+ });
295
+ }
296
+ const explicit = typeof failure.retryable === 'boolean' ? { retryable: failure.retryable } : {};
297
+ if (isNotificationErrorCode(failure.code)) {
298
+ const hostCode = message === undefined ? undefined : readBridgeNotificationsHostCode(message);
299
+ return new NotificationError(failure.code, {
300
+ ...(message === undefined ? {} : { message }),
301
+ ...explicit,
302
+ ...(hostCode === undefined ? {} : { hostCode }),
303
+ cause: error,
304
+ });
305
+ }
306
+ return new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
307
+ message: `${method}: the Devvit server could not be reached.`,
308
+ ...explicit,
309
+ cause: error,
310
+ });
311
+ }
312
+ /**
313
+ * Whether `elapsed` milliseconds are still inside the retry interval. A
314
+ * clock that went backwards (a negative or non-finite difference) counts
315
+ * as past it, so a clock change never holds a retry back.
316
+ */
317
+ function withinRetryInterval(elapsed, retryIntervalMs) {
318
+ return elapsed >= 0 && elapsed < retryIntervalMs;
319
+ }
@@ -7,3 +7,4 @@ export * from './redis-verified-leaderboard.js';
7
7
  export * from './redis-notification-store.js';
8
8
  export * from './notifications-delivery.js';
9
9
  export * from './notifications-bridge.js';
10
+ export * from './notifications-backend-bridge.js';
@@ -7,3 +7,4 @@ export * from './redis-verified-leaderboard.js';
7
7
  export * from './redis-notification-store.js';
8
8
  export * from './notifications-delivery.js';
9
9
  export * from './notifications-bridge.js';
10
+ export * from './notifications-backend-bridge.js';
@@ -0,0 +1,67 @@
1
+ import type { BridgeRequest, BridgeResponse } from '@mpgd/bridge';
2
+ import { type NotificationService } from '@mpgd/game-services/notifications';
3
+ import { type DevvitNotificationSessionContext } from './notifications-delivery.js';
4
+ /**
5
+ * The notifications runtime's backend on Devvit (notifications v2 RFC,
6
+ * "Bridge and versioning", "Backend methods", and "Provider profiles",
7
+ * Devvit): the Devvit server's notifications service
8
+ * (`createDevvitNotificationService`) served over the server bridge for the
9
+ * request's logged-in user, so the webview's runtime keeps consent records,
10
+ * the platform-user registration, server reminders, and notices there.
11
+ *
12
+ * The player is always the request's `context.userId`, read through
13
+ * `devvitNotificationSessionFromContext`, never a payload field: the payload
14
+ * only names the player its call began for, and a call for another player
15
+ * is refused before the service.
16
+ */
17
+ /** The parts of the notifications service the backend bridge serves. */
18
+ export type DevvitNotificationsBackendService = Pick<NotificationService, 'recordConsent' | 'listConsent' | 'registerEndpoint' | 'unregisterEndpoint' | 'setReminder' | 'cancelReminder' | 'listNotices' | 'recordNoticeShown'>;
19
+ export interface CreateDevvitNotificationsBackendBridgeHandlerInput {
20
+ /** `createDevvitNotificationService(...).service`. */
21
+ readonly service: DevvitNotificationsBackendService;
22
+ /** The current Devvit request's context (`context` from `@devvit/web/server`). */
23
+ readonly context: () => DevvitNotificationSessionContext;
24
+ /**
25
+ * Every failure the handler answers with an error, except an invalid
26
+ * payload and a logged-out or changed session, with its cause (the
27
+ * service's or Redis's own text, which the webview never sees).
28
+ * Synchronous; a hook that throws or rejects changes nothing. By default
29
+ * `console.warn`, which skips a player's own consent write cap and an
30
+ * input the service refuses as invalid.
31
+ */
32
+ readonly onError?: ((error: unknown, context: {
33
+ readonly method: string;
34
+ }) => void) | undefined;
35
+ }
36
+ /**
37
+ * Serves the nine `notifications.backend.*` methods; the handler a Devvit
38
+ * server bridge routes them to (the `notificationsBackend` dependency of the
39
+ * kit's and the CLI template's `createDevvitBridgeHandler`).
40
+ */
41
+ export interface DevvitNotificationsBackendBridgeHandler {
42
+ request(input: BridgeRequest): Promise<BridgeResponse>;
43
+ }
44
+ /**
45
+ * The server bridge side of the runtime's backend on Devvit:
46
+ * `createNotificationsBackendBridgeHandler` from `@mpgd/bridge/notifications`
47
+ * over the Devvit notifications service.
48
+ *
49
+ * - **Session.** `getSession` answers the request's user as
50
+ * `{ player: 't2_…', id: 'devvit:t2_…' }`, or `null` for a logged-out
51
+ * viewer. Every other call needs that user: a logged-out request answers
52
+ * `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` (host code
53
+ * `NOTIFICATIONS_SIGNED_OUT`), which the runtime reads as "not signed in",
54
+ * and a call that began for another user answers the same code with
55
+ * `NOTIFICATIONS_SESSION_CHANGED`.
56
+ * - **Calls.** Each method is the service's, for
57
+ * `devvitNotificationSessionFromContext(context())`: records (the service
58
+ * dates them and caps a player's writes per day), the `platform-user`
59
+ * registration's locale and zone, server reminders (which the `dispatch`
60
+ * scheduler task fires through `enqueue`), and notices, which stay empty
61
+ * unless the deployment applies the Korean rules.
62
+ * - **Failures.** A service `NotificationError` crosses with its code (an
63
+ * invalid record or reminder is `NOTIFICATIONS_INVALID_INPUT`); any other
64
+ * failure is a retryable `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` with a
65
+ * fixed message, and its text stays on the server (`onError`).
66
+ */
67
+ export declare function createDevvitNotificationsBackendBridgeHandler(input: CreateDevvitNotificationsBackendBridgeHandlerInput): DevvitNotificationsBackendBridgeHandler;
@@ -0,0 +1,73 @@
1
+ import { createNotificationsBackendBridgeHandler } from '@mpgd/bridge/notifications';
2
+ import { NotificationError } from '@mpgd/platform/notifications';
3
+ import { NotificationConsentRateLimitError, } from '@mpgd/game-services/notifications';
4
+ import { devvitUserIdPattern } from '../notifications-contract.js';
5
+ import { devvitNotificationSessionFromContext, } from './notifications-delivery.js';
6
+ /**
7
+ * The server bridge side of the runtime's backend on Devvit:
8
+ * `createNotificationsBackendBridgeHandler` from `@mpgd/bridge/notifications`
9
+ * over the Devvit notifications service.
10
+ *
11
+ * - **Session.** `getSession` answers the request's user as
12
+ * `{ player: 't2_…', id: 'devvit:t2_…' }`, or `null` for a logged-out
13
+ * viewer. Every other call needs that user: a logged-out request answers
14
+ * `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` (host code
15
+ * `NOTIFICATIONS_SIGNED_OUT`), which the runtime reads as "not signed in",
16
+ * and a call that began for another user answers the same code with
17
+ * `NOTIFICATIONS_SESSION_CHANGED`.
18
+ * - **Calls.** Each method is the service's, for
19
+ * `devvitNotificationSessionFromContext(context())`: records (the service
20
+ * dates them and caps a player's writes per day), the `platform-user`
21
+ * registration's locale and zone, server reminders (which the `dispatch`
22
+ * scheduler task fires through `enqueue`), and notices, which stay empty
23
+ * unless the deployment applies the Korean rules.
24
+ * - **Failures.** A service `NotificationError` crosses with its code (an
25
+ * invalid record or reminder is `NOTIFICATIONS_INVALID_INPUT`); any other
26
+ * failure is a retryable `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` with a
27
+ * fixed message, and its text stays on the server (`onError`).
28
+ */
29
+ export function createDevvitNotificationsBackendBridgeHandler(input) {
30
+ const { service } = input;
31
+ const handler = createNotificationsBackendBridgeHandler({
32
+ label: 'Devvit',
33
+ unexpectedFailureMessage: (method) => `${method} failed on the Devvit server.`,
34
+ onError: (error, context) => {
35
+ (input.onError ?? warnBackendFailure)(error, context);
36
+ },
37
+ backend: {
38
+ session() {
39
+ const context = input.context();
40
+ // A request without a Devvit user id (missing, `null`, or not a t2_
41
+ // id) has no signed-in player, as the provider side reads it. A
42
+ // context that cannot be read at all is the server's failure and
43
+ // reaches onError.
44
+ const userId = context.userId;
45
+ if (typeof userId !== 'string' || !devvitUserIdPattern.test(userId)) {
46
+ return undefined;
47
+ }
48
+ // The id checked above, so the session cannot fail on a second read of the context.
49
+ const notifications = devvitNotificationSessionFromContext({ userId });
50
+ return { id: notifications.sessionId, player: notifications.player, notifications };
51
+ },
52
+ recordConsent: (session, record) => service.recordConsent(session.notifications, record),
53
+ listConsent: (session) => service.listConsent(session.notifications),
54
+ registerEndpoint: (session, registration) => service.registerEndpoint(session.notifications, { registration }),
55
+ unregisterEndpoint: (session, { provider }) => service.unregisterEndpoint(session.notifications, { provider }),
56
+ setReminder: async (session, reminder) => (await service.setReminder(session.notifications, reminder)).status,
57
+ cancelReminder: async (session, { key }) => (await service.cancelReminder(session.notifications, { key })).cancelled,
58
+ listNotices: (session) => service.listNotices(session.notifications),
59
+ recordNotice: (session, notice, { channel }) => service.recordNoticeShown(session.notifications, notice, { channel }),
60
+ },
61
+ });
62
+ return { request: (request) => handler.request(request) };
63
+ }
64
+ function warnBackendFailure(error, context) {
65
+ // A throttled consent write is the player's own cap, and an input the
66
+ // service refuses is the caller's: neither is a server failure, and a
67
+ // webview could otherwise fill the log with them.
68
+ if (error instanceof NotificationConsentRateLimitError
69
+ || (error instanceof NotificationError && error.code === 'NOTIFICATIONS_INVALID_INPUT')) {
70
+ return;
71
+ }
72
+ console.warn(`devvit notifications ${context.method} failed`, error);
73
+ }
@@ -186,8 +186,8 @@ export declare function createDevvitNotificationService(input: CreateDevvitNotif
186
186
  export declare function devvitNotificationSession(userId: string): NotificationSession;
187
187
  /** The Devvit request context fields this session reads (`context` from `@devvit/web/server`). */
188
188
  export interface DevvitNotificationSessionContext {
189
- /** The logged-in Reddit user of the current request (`context.userId`). */
190
- readonly userId?: string | undefined;
189
+ /** The logged-in Reddit user of the current request (`context.userId`); absent or `null` when logged out. */
190
+ readonly userId?: string | null | undefined;
191
191
  }
192
192
  /**
193
193
  * The notifications session of the current Devvit request. The player is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-devvit",
3
- "version": "0.13.5",
3
+ "version": "0.14.0",
4
4
  "description": "Reddit Devvit Web platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,9 +55,9 @@
55
55
  ],
56
56
  "dependencies": {
57
57
  "@devvit/web": "0.15.0",
58
- "@mpgd/bridge": "0.15.0",
59
- "@mpgd/game-services": "0.27.2",
60
- "@mpgd/platform": "0.27.2"
58
+ "@mpgd/bridge": "0.16.0",
59
+ "@mpgd/platform": "0.28.0",
60
+ "@mpgd/game-services": "0.28.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^24.0.0",