@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 +34 -2
- package/dist/index.d.ts +57 -0
- package/dist/index.js +145 -3
- package/dist/notifications-backend-client.d.ts +67 -0
- package/dist/notifications-backend-client.js +319 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/notifications-backend-bridge.d.ts +67 -0
- package/dist/server/notifications-backend-bridge.js +73 -0
- package/dist/server/notifications-delivery.d.ts +2 -2
- package/package.json +4 -4
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
|
-
|
|
30
|
-
|
|
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
|
+
}
|
package/dist/server/index.d.ts
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
59
|
-
"@mpgd/
|
|
60
|
-
"@mpgd/
|
|
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",
|