@mpgd/adapter-devvit 0.12.1 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -3
- package/dist/index.d.ts +36 -0
- package/dist/index.js +167 -8
- package/dist/notifications-client.d.ts +44 -0
- package/dist/notifications-client.js +141 -0
- package/dist/notifications-contract.d.ts +71 -0
- package/dist/notifications-contract.js +109 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +3 -0
- package/dist/server/notifications-bridge.d.ts +103 -0
- package/dist/server/notifications-bridge.js +262 -0
- package/dist/server/notifications-delivery.d.ts +257 -0
- package/dist/server/notifications-delivery.js +431 -0
- package/dist/server/redis-notification-store.d.ts +130 -0
- package/dist/server/redis-notification-store.js +1058 -0
- package/dist/web.d.ts +10 -0
- package/dist/web.js +15 -0
- package/package.json +4 -4
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { type CreateNotificationServiceInput, type NotificationProviderRegistry, type NotificationRemoteDeliveryAnswer, type NotificationRemoteDeliveryProvider, type NotificationService, type NotificationServiceCatalog, type NotificationSession, type NotificationStore } from '@mpgd/game-services/notifications';
|
|
2
|
+
import { type NotificationDeployment, type NotificationProviderRegistryEntry } from '@mpgd/platform/notifications';
|
|
3
|
+
import { type CreateDevvitRedisNotificationStoreOptions, type DevvitNotificationRedis } from './redis-notification-store.js';
|
|
4
|
+
/**
|
|
5
|
+
* Remote delivery on Devvit (notifications v2 RFC, "Delivery providers",
|
|
6
|
+
* Devvit). It runs inside the Devvit server, not the game-services Worker,
|
|
7
|
+
* because only that runtime can call the notifications plugin: the same
|
|
8
|
+
* `@mpgd/game-services/notifications` service, policy gate, and delivery
|
|
9
|
+
* module, over a Redis-backed store.
|
|
10
|
+
*
|
|
11
|
+
* `enqueue` and `isOptedIn` are `notifications` from `@devvit/web/server`
|
|
12
|
+
* (`@devvit/notifications` 0.14.1): <https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/notifications/pn-best-practices>.
|
|
13
|
+
*/
|
|
14
|
+
/** One recipient of `notifications.enqueue` (`EnqueueOptions['recipients'][number]`). */
|
|
15
|
+
export interface DevvitEnqueueRecipient {
|
|
16
|
+
readonly userId: `t2_${string}`;
|
|
17
|
+
readonly link: `t1_${string}` | `t3_${string}`;
|
|
18
|
+
readonly data: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
/** What `notifications.enqueue` answers (`EnqueueResponse`). */
|
|
21
|
+
export interface DevvitEnqueueResponse {
|
|
22
|
+
readonly successCount: number;
|
|
23
|
+
readonly failureCount: number;
|
|
24
|
+
readonly errors: readonly {
|
|
25
|
+
readonly userId: string | undefined;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
}[];
|
|
28
|
+
}
|
|
29
|
+
/** The server side of the Devvit notifications plugin that sends and reads opt-in. */
|
|
30
|
+
export interface DevvitNotificationsDeliveryClient {
|
|
31
|
+
enqueue(options: {
|
|
32
|
+
title: string;
|
|
33
|
+
body: string;
|
|
34
|
+
recipients: DevvitEnqueueRecipient[];
|
|
35
|
+
}): Promise<DevvitEnqueueResponse>;
|
|
36
|
+
isOptedIn(userId: `t2_${string}`): Promise<boolean>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* How an `enqueue` failure is answered: a per-recipient error of the answer
|
|
40
|
+
* (`source: 'answer'`: Devvit counted a failure for the user, but an error
|
|
41
|
+
* the defaults cannot read is still `ambiguous`) or a call that threw
|
|
42
|
+
* (`source: 'thrown'`, Devvit may have queued it, so `ambiguous` is the
|
|
43
|
+
* default and the only safe answer unless the error proves nothing was
|
|
44
|
+
* sent: a classifier must answer `undefined` or `ambiguous` for a thrown
|
|
45
|
+
* error it does not know). An answer that counts neither a success nor a
|
|
46
|
+
* failure is not classified. Devvit documents no error messages (checked
|
|
47
|
+
* 2026-10-19); a deployment that observes one (through `onEnqueueError`)
|
|
48
|
+
* names it here. `undefined` keeps the default.
|
|
49
|
+
*/
|
|
50
|
+
export type DevvitEnqueueErrorClassifier = (message: string, source: 'answer' | 'thrown') => 'rate-limited' | 'recipient' | 'configuration' | 'ambiguous' | undefined;
|
|
51
|
+
/** An `enqueue` failure, for the operator's log; the message is Devvit's and may name the user. */
|
|
52
|
+
export interface DevvitEnqueueErrorReport {
|
|
53
|
+
readonly sendKey: string;
|
|
54
|
+
readonly source: 'answer' | 'thrown';
|
|
55
|
+
readonly message: string;
|
|
56
|
+
readonly answer: NotificationRemoteDeliveryAnswer['status'];
|
|
57
|
+
/** The reason of a `rejected` answer. */
|
|
58
|
+
readonly reason?: 'configuration' | 'recipient';
|
|
59
|
+
}
|
|
60
|
+
export interface CreateDevvitNotificationDeliveryProviderInput {
|
|
61
|
+
readonly notifications: DevvitNotificationsDeliveryClient;
|
|
62
|
+
/**
|
|
63
|
+
* The Reddit user id of a player, for the send-time opt-in read. By
|
|
64
|
+
* default a player id that is a `t2_` id (the Devvit server bridge's
|
|
65
|
+
* `context.userId`) is its own Reddit id.
|
|
66
|
+
*/
|
|
67
|
+
readonly userIdOf?: (player: string) => string | undefined;
|
|
68
|
+
readonly classifyEnqueueError?: DevvitEnqueueErrorClassifier;
|
|
69
|
+
/**
|
|
70
|
+
* Every `enqueue` failure and how it was answered, so an operator learns
|
|
71
|
+
* Devvit's messages. `createDevvitNotificationService` forwards the
|
|
72
|
+
* `ambiguous` ones and the `configuration` rejections to `onError`.
|
|
73
|
+
*/
|
|
74
|
+
readonly onEnqueueError?: (report: DevvitEnqueueErrorReport) => void;
|
|
75
|
+
}
|
|
76
|
+
/** Provider codes of the answers this provider gives without calling `enqueue`. */
|
|
77
|
+
export declare const devvitNotificationProviderCodes: Readonly<{
|
|
78
|
+
readonly deviceRecipient: 'DEVVIT_DEVICE_RECIPIENT';
|
|
79
|
+
readonly recipientInvalid: 'DEVVIT_RECIPIENT_INVALID';
|
|
80
|
+
readonly linkInvalid: 'DEVVIT_LINK_INVALID';
|
|
81
|
+
readonly textMissing: 'DEVVIT_TEXT_MISSING';
|
|
82
|
+
readonly titleTooLong: 'DEVVIT_TITLE_TOO_LONG';
|
|
83
|
+
readonly bodyTooLong: 'DEVVIT_BODY_TOO_LONG';
|
|
84
|
+
readonly templateSyntax: 'DEVVIT_TEMPLATE_SYNTAX';
|
|
85
|
+
readonly enqueueFailed: 'DEVVIT_ENQUEUE_FAILED';
|
|
86
|
+
readonly enqueueRejected: 'DEVVIT_ENQUEUE_REJECTED';
|
|
87
|
+
readonly enqueueUnanswered: 'DEVVIT_ENQUEUE_UNANSWERED';
|
|
88
|
+
readonly enqueueUnclassified: 'DEVVIT_ENQUEUE_UNCLASSIFIED';
|
|
89
|
+
}>;
|
|
90
|
+
/**
|
|
91
|
+
* The Devvit delivery provider (`{ id: 'devvit', send, readPermission }`).
|
|
92
|
+
* One recipient per `enqueue` call in the first wave (RFC "Open questions",
|
|
93
|
+
* bulk sends).
|
|
94
|
+
*
|
|
95
|
+
* - **What it sends.** The gate's title and body as they are, with the
|
|
96
|
+
* send's `link` (else the mapping's) and no template data. Every Mustache
|
|
97
|
+
* tag starts with `{{`, so a filled text that still holds one (from
|
|
98
|
+
* template data such as a player name, or a catalog brace outside a
|
|
99
|
+
* `{{name}}` placeholder) is refused (`rejected`, `configuration`, provider
|
|
100
|
+
* code `DEVVIT_TEMPLATE_SYNTAX`) before anything is sent.
|
|
101
|
+
* - **Limits it checks first.** A missing or blank title or body, a title over 60 or a body over 100 code
|
|
102
|
+
* points of the filled text (see `devvitNotificationTextLength`), a link
|
|
103
|
+
* that is not a `t3_` or `t1_` id, and a recipient that is not a `t2_` id
|
|
104
|
+
* are `rejected` with reason `configuration` (the recipient: `recipient`),
|
|
105
|
+
* because the same send would fail again.
|
|
106
|
+
* - **Answers.** `successCount` above 0 is `accepted`. Otherwise an error for
|
|
107
|
+
* this recipient (or for no named user) is a failure Devvit counted:
|
|
108
|
+
* a message that reads as a rate limit is `rate-limited` (deferred, for
|
|
109
|
+
* at most a day), one that reads as the user's refusal (not opted in, no
|
|
110
|
+
* such user) in an error that names this recipient is `rejected` with
|
|
111
|
+
* reason `recipient`. Any other error is `ambiguous` (provider code
|
|
112
|
+
* `DEVVIT_ENQUEUE_UNCLASSIFIED`): Devvit documents no error messages, so
|
|
113
|
+
* an unknown one is neither proof of a setup error nor of a refusal. The
|
|
114
|
+
* service reports it to the operator and replays it after each lease,
|
|
115
|
+
* which doubles from the claim lease up to an hour, for at most 24 hours
|
|
116
|
+
* after the first attempt (`notificationAmbiguousReplayHours`): with the
|
|
117
|
+
* default 300-second lease, at most 27 calls. A call that throws,
|
|
118
|
+
* or an answer that counts neither a success nor a failure, is
|
|
119
|
+
* `ambiguous` too: Devvit may have queued it, and `enqueue` takes no
|
|
120
|
+
* idempotency key, so a replay may show twice.
|
|
121
|
+
* `classifyEnqueueError` overrides each default, and `onEnqueueError`
|
|
122
|
+
* sees every failure with Devvit's message.
|
|
123
|
+
* - **Opt-in at send time.** `readPermission` reads `isOptedIn`, since no
|
|
124
|
+
* opt-out event exists and opt-out may move into Reddit settings: opted in
|
|
125
|
+
* is `granted`, otherwise `prompt`.
|
|
126
|
+
*/
|
|
127
|
+
export declare function createDevvitNotificationDeliveryProvider(input: CreateDevvitNotificationDeliveryProviderInput): NotificationRemoteDeliveryProvider;
|
|
128
|
+
/**
|
|
129
|
+
* The Devvit registry entry: the reference `devvit` entry of
|
|
130
|
+
* `@mpgd/platform/notifications`, whose values this provider owns. 2 sends
|
|
131
|
+
* per user per day is a provider limit (a day-long window suppresses), and
|
|
132
|
+
* 25,000 per app per day is the `rate` the scheduler keeps to. A fresh copy
|
|
133
|
+
* on every call.
|
|
134
|
+
*/
|
|
135
|
+
export declare function createDevvitNotificationProviderEntry(): NotificationProviderRegistryEntry;
|
|
136
|
+
export interface CreateDevvitNotificationServiceInput extends Omit<CreateNotificationServiceInput, 'store' | 'registry' | 'catalog' | 'resolvePlatformRecipient' | 'deployment'> {
|
|
137
|
+
/** `redis` from `@devvit/web/server`, for the Redis store; not needed with `store`. */
|
|
138
|
+
readonly redis?: DevvitNotificationRedis;
|
|
139
|
+
/** `notifications` from `@devvit/web/server`. */
|
|
140
|
+
readonly notifications: DevvitNotificationsDeliveryClient;
|
|
141
|
+
/** The game's topic catalog file, validated against the Devvit entry for the `reddit` target. */
|
|
142
|
+
readonly catalog: unknown;
|
|
143
|
+
readonly deployment?: NotificationDeployment;
|
|
144
|
+
readonly store?: NotificationStore;
|
|
145
|
+
readonly storeOptions?: CreateDevvitRedisNotificationStoreOptions;
|
|
146
|
+
readonly classifyEnqueueError?: DevvitEnqueueErrorClassifier;
|
|
147
|
+
}
|
|
148
|
+
export interface DevvitNotificationService {
|
|
149
|
+
readonly service: NotificationService;
|
|
150
|
+
readonly store: NotificationStore;
|
|
151
|
+
readonly registry: NotificationProviderRegistry;
|
|
152
|
+
readonly catalog: NotificationServiceCatalog;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The notifications v2 service as the Devvit server runs it: the Redis
|
|
156
|
+
* store, the Devvit provider for the `reddit` target, and the catalog
|
|
157
|
+
* checked against the Devvit entry (title ≤ 60 and body ≤ 100 code points,
|
|
158
|
+
* an optional `t3_`/`t1_` default link). A player is a Reddit user id
|
|
159
|
+
* (`context.userId`), which is also the recipient; any other player id has
|
|
160
|
+
* no Devvit recipient (`unavailable: no-endpoint`).
|
|
161
|
+
*
|
|
162
|
+
* Players' own calls take a `devvitNotificationSession(userId)`. Server
|
|
163
|
+
* reminders (`setReminder`) fire through the `dispatch` scheduler task
|
|
164
|
+
* (`runDevvitNotificationTask`). The scheduler pushes no consent notices
|
|
165
|
+
* through Devvit: its entry fixes the title and body per topic, so it does
|
|
166
|
+
* not carry notices (`notificationProviderCarriesNotices`).
|
|
167
|
+
*/
|
|
168
|
+
export declare function createDevvitNotificationService(input: CreateDevvitNotificationServiceInput): DevvitNotificationService;
|
|
169
|
+
/**
|
|
170
|
+
* The notifications session of a Devvit request's logged-in user. A Devvit
|
|
171
|
+
* player has no game-services session: the user id is the player, and one
|
|
172
|
+
* session and installation per user, which no endpoint uses because Devvit
|
|
173
|
+
* registers no device address.
|
|
174
|
+
*/
|
|
175
|
+
export declare function devvitNotificationSession(userId: string): NotificationSession;
|
|
176
|
+
/**
|
|
177
|
+
* The Devvit scheduler tasks that keep the service's own work moving:
|
|
178
|
+
* `dispatch` runs the service's scheduler (`runScheduler`) every minute,
|
|
179
|
+
* which turns due server reminders into sends and replays due rows
|
|
180
|
+
* (deferred sends, rate-limited ones, and ambiguous answers once their
|
|
181
|
+
* lease ends), and `purge` forgets old keys,
|
|
182
|
+
* history, and dead endpoints every five minutes. They run, and use two of
|
|
183
|
+
* the installation's recurring tasks, even while the game has no
|
|
184
|
+
* notifications service; such a run does no work. A game declares both in
|
|
185
|
+
* `devvit.json` (`scheduler.tasks`, each with its `endpoint` and `cron`)
|
|
186
|
+
* and routes each endpoint to `runDevvitNotificationTask`.
|
|
187
|
+
*
|
|
188
|
+
* Devvit scheduler: <https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/server/scheduler>
|
|
189
|
+
* (a recurring task is a `devvit.json` `scheduler.tasks` entry whose
|
|
190
|
+
* endpoint matches `/internal/.+`, answered with a `TaskResponse`, `{}`).
|
|
191
|
+
*/
|
|
192
|
+
export declare const devvitNotificationSchedulerTasks: Readonly<{
|
|
193
|
+
readonly dispatch: Readonly<{
|
|
194
|
+
name: "mpgd-notifications-dispatch";
|
|
195
|
+
endpoint: "/internal/scheduler/mpgd-notifications-dispatch";
|
|
196
|
+
cron: "* * * * *";
|
|
197
|
+
}>;
|
|
198
|
+
readonly purge: Readonly<{
|
|
199
|
+
name: "mpgd-notifications-purge";
|
|
200
|
+
endpoint: "/internal/scheduler/mpgd-notifications-purge";
|
|
201
|
+
cron: "*/5 * * * *";
|
|
202
|
+
}>;
|
|
203
|
+
}>;
|
|
204
|
+
export type DevvitNotificationTask = keyof typeof devvitNotificationSchedulerTasks;
|
|
205
|
+
/**
|
|
206
|
+
* Due schedule entries, and due sends, one scheduler round of a dispatch
|
|
207
|
+
* task takes at most (`runScheduler`'s `limit`).
|
|
208
|
+
*/
|
|
209
|
+
export declare const devvitNotificationDispatchBatch = 10;
|
|
210
|
+
/**
|
|
211
|
+
* Rows of each kind one purge task deletes at most (the Redis store's
|
|
212
|
+
* `purgeLimit`). Every five minutes that is 57,600 a day of each kind,
|
|
213
|
+
* above the 25,000 sends a day Devvit allows an app.
|
|
214
|
+
*/
|
|
215
|
+
export declare const devvitNotificationPurgeBatch = 200;
|
|
216
|
+
/**
|
|
217
|
+
* A dispatch task starts no scheduler round after this long
|
|
218
|
+
* (`runScheduler`'s `budgetMs`), so a run lasts the budget plus at most one
|
|
219
|
+
* round. Devvit documents no time limit for a scheduler task; this keeps a
|
|
220
|
+
* run well inside the next minute's.
|
|
221
|
+
*/
|
|
222
|
+
export declare const devvitNotificationDispatchBudgetMs = 20000;
|
|
223
|
+
/** The task an internal scheduler endpoint names, or `undefined` for any other path. */
|
|
224
|
+
export declare function devvitNotificationTaskForEndpoint(pathname: string): DevvitNotificationTask | undefined;
|
|
225
|
+
export type DevvitNotificationTaskResult = {
|
|
226
|
+
readonly task: 'dispatch';
|
|
227
|
+
/**
|
|
228
|
+
* The sends the run touched, whatever their outcome (delivered, in
|
|
229
|
+
* flight, deferred, or refused by the gate): fired reminders and due rows.
|
|
230
|
+
*/
|
|
231
|
+
readonly touched: number;
|
|
232
|
+
readonly failed: number;
|
|
233
|
+
} | {
|
|
234
|
+
readonly task: 'purge';
|
|
235
|
+
readonly sends: number;
|
|
236
|
+
readonly history: number;
|
|
237
|
+
readonly endpoints: number;
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* Runs one scheduler task.
|
|
241
|
+
*
|
|
242
|
+
* `dispatch` is one `runScheduler` run, bounded by time: rounds of at most
|
|
243
|
+
* `dispatchLimit` (10, a positive integer) due schedule entries and due
|
|
244
|
+
* sends, which go on while a round filled its batch and got somewhere, and
|
|
245
|
+
* start no round after `budgetMs` (20 seconds), so a run lasts the budget
|
|
246
|
+
* plus at most one round; the sends of a run are not otherwise capped. Due
|
|
247
|
+
* rows count against the Devvit entry's `rate`, 25,000 a day, a daily cap
|
|
248
|
+
* rather than a spread. One failing send never stops the others.
|
|
249
|
+
*
|
|
250
|
+
* `purge` deletes at most the store's `purgeLimit` rows of each kind (200
|
|
251
|
+
* for the store `createDevvitNotificationService` builds). A backlog
|
|
252
|
+
* drains over the following runs.
|
|
253
|
+
*/
|
|
254
|
+
export declare function runDevvitNotificationTask(service: Pick<NotificationService, 'runScheduler' | 'purge'>, task: DevvitNotificationTask, options?: {
|
|
255
|
+
readonly dispatchLimit?: number;
|
|
256
|
+
readonly budgetMs?: number;
|
|
257
|
+
}): Promise<DevvitNotificationTaskResult>;
|