@meith/notifications 0.31.0 → 0.33.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/notifications",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,8 +19,8 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@meith/core": "0.31.0",
23
- "@meith/i18n": "0.31.0",
24
- "@meith/mail": "0.31.0"
22
+ "@meith/core": "0.33.0",
23
+ "@meith/i18n": "0.33.0",
24
+ "@meith/mail": "0.33.0"
25
25
  }
26
26
  }
@@ -7,6 +7,25 @@ import type { NotificationRepository } from './types'
7
7
 
8
8
  const BODY_LIMIT = 300
9
9
 
10
+ export const PUSH_DELIVERY_CONCURRENCY = 4
11
+
12
+ async function mapBounded<T, R>(
13
+ items: readonly T[],
14
+ limit: number,
15
+ run: (item: T) => Promise<R>,
16
+ ): Promise<R[]> {
17
+ const results = new Array<R>(items.length)
18
+ let next = 0
19
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
20
+ while (next < items.length) {
21
+ const index = next++
22
+ results[index] = await run(items[index]!)
23
+ }
24
+ })
25
+ await Promise.all(workers)
26
+ return results
27
+ }
28
+
10
29
  export interface PushDeliveryResult {
11
30
  readonly outcome: DeliveryOutcome
12
31
  readonly sent: number
@@ -47,6 +66,7 @@ export async function deliverNotificationPush(deps: {
47
66
  readonly translatorForLocale?: NotificationTranslatorResolver
48
67
  readonly now?: () => Date
49
68
  readonly send?: typeof sendWebPush
69
+ readonly signal?: AbortSignal
50
70
  }): Promise<PushDeliveryResult> {
51
71
  const nothing = { sent: 0, pruned: 0, failed: 0 }
52
72
 
@@ -73,12 +93,8 @@ export async function deliverNotificationPush(deps: {
73
93
  badge: await deps.notifications.unreadCount(deliverable.recipient.userId),
74
94
  })
75
95
 
76
- let sent = 0
77
- let pruned = 0
78
- let failed = 0
79
-
80
- for (const subscription of subscriptions) {
81
- const result = await send({
96
+ const outcomes = await mapBounded(subscriptions, PUSH_DELIVERY_CONCURRENCY, (subscription) =>
97
+ send({
82
98
  subscription: {
83
99
  endpoint: subscription.endpoint,
84
100
  p256dh: subscription.p256dh,
@@ -87,7 +103,16 @@ export async function deliverNotificationPush(deps: {
87
103
  payload,
88
104
  vapid: deps.vapid,
89
105
  now: now(),
90
- })
106
+ ...(deps.signal === undefined ? {} : { signal: deps.signal }),
107
+ }),
108
+ )
109
+
110
+ let sent = 0
111
+ let pruned = 0
112
+ let failed = 0
113
+
114
+ for (const [index, subscription] of subscriptions.entries()) {
115
+ const result = outcomes[index]!
91
116
 
92
117
  if (result.outcome === 'sent') {
93
118
  sent += 1
package/src/kinds.ts CHANGED
@@ -70,6 +70,20 @@ export const NOTIFICATION_KINDS = [
70
70
  pushByDefault: false,
71
71
  pushConfigurable: true,
72
72
  },
73
+ {
74
+ id: 'board.digest',
75
+ titleKey: 'notification.board.digest.title',
76
+ descriptionKey: 'notification.board.digest.description',
77
+ title: 'A recap of what you missed on the board',
78
+ description:
79
+ 'Sent only after you have been away a while, listing the busiest threads since ' +
80
+ 'your last visit. Off by default — nobody gets this without asking for it.',
81
+ audience: 'member',
82
+ emailByDefault: false,
83
+ emailConfigurable: true,
84
+ pushByDefault: false,
85
+ pushConfigurable: true,
86
+ },
73
87
  {
74
88
  id: 'post.mentioned',
75
89
  titleKey: 'notification.post.mentioned.title',
package/src/push.ts CHANGED
@@ -9,6 +9,8 @@ export const PUSH_RECORD_SIZE = 4096
9
9
 
10
10
  export const PUSH_REQUEST_TIMEOUT_MS = 10_000
11
11
 
12
+ export const PUSH_MAX_RESPONSE_BYTES = 8_192
13
+
12
14
  export const PUSH_TTL_SECONDS = 86_400
13
15
 
14
16
  const VAPID_EXPIRY_SECONDS = 12 * 60 * 60
@@ -199,6 +201,8 @@ const guardedPushFetch: typeof fetch = (async (url: string, init: RequestInit) =
199
201
  body: init.body as Uint8Array,
200
202
  timeoutMs: PUSH_REQUEST_TIMEOUT_MS,
201
203
  allowPrivateHosts,
204
+ maxResponseBytes: PUSH_MAX_RESPONSE_BYTES,
205
+ ...(init.signal ? { signal: init.signal } : {}),
202
206
  })
203
207
  return { status } as Response
204
208
  }) as unknown as typeof fetch
@@ -210,6 +214,7 @@ export async function sendWebPush(input: {
210
214
  readonly ttlSeconds?: number
211
215
  readonly now?: Date
212
216
  readonly fetchImpl?: typeof fetch
217
+ readonly signal?: AbortSignal
213
218
  }): Promise<PushSendResult> {
214
219
  const doFetch = input.fetchImpl ?? guardedPushFetch
215
220
 
@@ -238,7 +243,10 @@ export async function sendWebPush(input: {
238
243
  },
239
244
  body: bytes(encrypted.body),
240
245
  redirect: 'manual',
241
- signal: AbortSignal.timeout(PUSH_REQUEST_TIMEOUT_MS),
246
+ signal:
247
+ input.signal === undefined
248
+ ? AbortSignal.timeout(PUSH_REQUEST_TIMEOUT_MS)
249
+ : AbortSignal.any([input.signal, AbortSignal.timeout(PUSH_REQUEST_TIMEOUT_MS)]),
242
250
  })
243
251
 
244
252
  const outcome = pushOutcomeFor(response.status)
package/src/render.ts CHANGED
@@ -80,6 +80,11 @@ const DIGEST_CADENCE_KEYS = {
80
80
  weekly: 'notification.render.digest.weekly',
81
81
  } as const
82
82
 
83
+ const BOARD_DIGEST_CADENCE_KEYS = {
84
+ weekly: 'notification.render.digest.weekly',
85
+ monthly: 'notification.render.boardDigest.monthly',
86
+ } as const
87
+
83
88
  const TEMPLATES: Readonly<
84
89
  Record<string, (data: NotificationData, t: Translator) => { subject: string; body: string }>
85
90
  > = {
@@ -167,6 +172,29 @@ const TEMPLATES: Readonly<
167
172
  }
168
173
  },
169
174
 
175
+ 'board.digest': (data, t) => {
176
+ const cadence = str(data, 'cadence') === 'monthly' ? 'monthly' : 'weekly'
177
+ const threadCount = num(data, 'threadCount')
178
+ const more = num(data, 'more')
179
+
180
+ const lines = objects(data, 'threads').map((entry) =>
181
+ t.t('notification.render.boardDigest.entry', {
182
+ title: str(entry, 'title', t.t('notification.render.digest.fallbackTitle')),
183
+ forum: str(entry, 'forumTitle', t.t('notification.render.boardDigest.forumFallback')),
184
+ }),
185
+ )
186
+
187
+ if (more > 0) lines.push(t.t('notification.render.digest.more', { count: more }))
188
+
189
+ return {
190
+ subject: t.t('notification.render.boardDigest.subject', {
191
+ cadence: t.t(BOARD_DIGEST_CADENCE_KEYS[cadence]),
192
+ threads: threadCount,
193
+ }),
194
+ body: lines.join('\n'),
195
+ }
196
+ },
197
+
170
198
  'post.mentioned': (data, t) => {
171
199
  const by = str(data, 'byUsername', t.t('notification.render.somebody'))
172
200
  const title = str(data, 'threadTitle', t.t('notification.render.threadFallback'))