@mpgd/game-services 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ import { assertNotificationConsentRecord, assertNotificationDeliveryProviderEntry, assertNotificationNotice, isNotificationErrorCode, NotificationError, } from '@mpgd/platform/notifications';
2
+ import { GameServicesHeaderResolutionError, resolveGameServicesRequestHeaders, } from '../request-headers.js';
3
+ /**
4
+ * The game-services notifications client: the notifications runtime's
5
+ * backend (`NotificationRuntimeBackend` from
6
+ * `@mpgd/platform/notifications-runtime`) over the game-services Worker's
7
+ * session routes under `/game-services/notifications` (see
8
+ * docs/CLOUDFLARE_WORKER_DEPLOY.md, "Sessions" and "Client routes").
9
+ *
10
+ * The Worker learns the player, the session, and the installation from the
11
+ * `Authorization` header alone, through its `GAME_SERVICES_NOTIFICATIONS_AUTH`
12
+ * binding (the identity Worker's `authenticateNotificationSession`); no
13
+ * request carries a player or an installation. The client learns which
14
+ * session is current from the game's identity, which also supplies the
15
+ * header through the same resolver the game gives its commerce client.
16
+ *
17
+ * No platform SDK, DOM, or Phaser import: only `fetch`.
18
+ */
19
+ /** The Worker's route prefix for notifications. */
20
+ export const gameServicesNotificationsRoutePrefix = '/game-services/notifications';
21
+ /** The default `timeoutMs`: a request still unanswered after this is aborted. */
22
+ export const defaultGameServicesNotificationsTimeoutMs = 15_000;
23
+ /**
24
+ * A failed notifications request, with the `NotificationError` code the
25
+ * runtime acts on and what the Worker answered.
26
+ *
27
+ * - 401: the session is not authenticated (signed out, token expired, or
28
+ * the session ended): `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`, retryable
29
+ * once the game has a session again.
30
+ * - 404 on a session route: the Worker serves no notifications for sessions
31
+ * (no catalog, or no `GAME_SERVICES_NOTIFICATIONS_AUTH` binding), and 405:
32
+ * the Worker does not serve this client's routes;
33
+ * `NOTIFICATIONS_CONFIGURATION_REQUIRED`, not retryable.
34
+ * - 400 and 413: `NOTIFICATIONS_INVALID_INPUT` (or the code the Worker names).
35
+ * - 408, 425, 429, and 503: `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`. The
36
+ * runtime retries at its next trigger (a read, a start, a sign-in), not
37
+ * on the Worker's `Retry-After`, which the client does not read.
38
+ * - Another status names the code the Worker answered when it is a
39
+ * notifications code (a Worker whose notifications configuration is
40
+ * broken answers 500 with `NOTIFICATIONS_CONFIGURATION_REQUIRED`, not
41
+ * retryable); otherwise a 5xx is `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`
42
+ * and a 4xx `NOTIFICATIONS_CONFIGURATION_REQUIRED`.
43
+ * - A 2xx whose body is not JSON, or is empty (a captive portal or proxy
44
+ * answered): `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` with that status. A
45
+ * 2xx with JSON outside the route's contract:
46
+ * `NOTIFICATIONS_CONFIGURATION_REQUIRED`.
47
+ * - `status` 0, `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`, nothing sent: no
48
+ * signed-in player with a session id, the header resolver failed or did
49
+ * not settle within `timeoutMs`, or the player signed out or changed
50
+ * while the headers were resolved.
51
+ * - `status` 0, `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`, no answer read: the
52
+ * request failed in transit, timed out (`timeoutMs`), or its body could
53
+ * not be read, so the Worker may have handled it. Retrying may repeat it: reminders and endpoints
54
+ * replace by key, but an advertising or night consent record is written
55
+ * again.
56
+ */
57
+ export class GameServicesNotificationsRequestError extends NotificationError {
58
+ /** The HTTP status, or 0 when no answer arrived. */
59
+ status;
60
+ /** The route, such as `/consent`. */
61
+ route;
62
+ /** The `error` the Worker answered, such as `UNAUTHORIZED`. */
63
+ serverCode;
64
+ constructor(input) {
65
+ super(input.code, {
66
+ message: input.message,
67
+ ...(input.cause === undefined ? {} : { cause: input.cause }),
68
+ });
69
+ this.status = input.status;
70
+ this.route = input.route;
71
+ this.serverCode = input.serverCode;
72
+ }
73
+ }
74
+ /** The player changed while a request's headers were resolved; nothing was sent. */
75
+ class NotificationsPlayerChangedError extends GameServicesNotificationsRequestError {
76
+ }
77
+ const reminderSetStatuses = [
78
+ 'scheduled',
79
+ 'replaced',
80
+ 'permission-required',
81
+ 'request-missing',
82
+ 'preference-off',
83
+ 'advertising-consent-missing',
84
+ 'capacity-exceeded',
85
+ 'unavailable',
86
+ ];
87
+ /**
88
+ * Creates the client. Every session call needs a current session and sends
89
+ * the headers the resolver gives for it; without a session it throws
90
+ * `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` and sends nothing.
91
+ */
92
+ export function createGameServicesNotificationsClient(input) {
93
+ const baseUrl = input.baseUrl.trim().replace(/\/+$/u, '');
94
+ if (baseUrl.length === 0) {
95
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
96
+ message: 'The game-services notifications client needs a baseUrl.',
97
+ });
98
+ }
99
+ const { identity } = input;
100
+ const timeoutMs = input.timeoutMs ?? defaultGameServicesNotificationsTimeoutMs;
101
+ // setTimeout takes at most 2^31 - 1 ms; a longer delay would fire at once.
102
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
103
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
104
+ message: 'The notifications client timeoutMs must be a positive number of milliseconds, at most 2147483647.',
105
+ });
106
+ }
107
+ if (input.target !== undefined && (typeof input.target !== 'string' || input.target.length === 0)) {
108
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
109
+ message: 'The notifications client target must be a non-empty string.',
110
+ });
111
+ }
112
+ const sessionOf = (session) => session !== undefined
113
+ && typeof session.playerId === 'string'
114
+ && session.playerId.length > 0
115
+ && typeof session.sessionId === 'string'
116
+ && session.sessionId.length > 0
117
+ ? { id: session.sessionId, player: session.playerId }
118
+ : undefined;
119
+ const requireSession = (route) => {
120
+ const session = sessionOf(identity.current());
121
+ if (session === undefined) {
122
+ throw new GameServicesNotificationsRequestError({
123
+ code: 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE',
124
+ route,
125
+ status: 0,
126
+ message: `${route} needs a signed-in player; no request was sent.`,
127
+ });
128
+ }
129
+ return session;
130
+ };
131
+ /**
132
+ * Sends one request. For a session route, `session` is the session the call
133
+ * began for: if another player is signed in once the headers are resolved
134
+ * (an account switch during a token refresh), nothing is sent, so one
135
+ * player's call never goes out with the next player's credentials. The
136
+ * same player under a new session id (an identity whose id rotates on
137
+ * refresh) still sends: the Worker reads the session from the header.
138
+ */
139
+ const request = async (route, method, body, session) => {
140
+ const fetcher = input.fetch ?? globalFetch();
141
+ // One deadline for the whole request, from resolving its headers to
142
+ // reading its answer: at the deadline the fetch is aborted and the call
143
+ // answers, even when a header resolver or a fetch never settles, so no
144
+ // runtime queue waits on it.
145
+ const controller = typeof AbortController === 'function' ? new AbortController() : undefined;
146
+ let timer;
147
+ let timedOut = false;
148
+ const deadline = new Promise((_resolve, reject) => {
149
+ timer = setTimeout(() => {
150
+ timedOut = true;
151
+ reject(new Error(`no answer within ${timeoutMs} ms`));
152
+ try {
153
+ controller?.abort();
154
+ }
155
+ catch {
156
+ // The call has answered already.
157
+ }
158
+ }, timeoutMs);
159
+ });
160
+ deadline.catch(() => undefined);
161
+ try {
162
+ let headers;
163
+ try {
164
+ headers = await Promise.race([
165
+ resolveGameServicesRequestHeaders({
166
+ headers: input.headers,
167
+ // The public registry read needs no credentials, so it works signed out.
168
+ getHeaders: session === undefined ? undefined : input.getHeaders,
169
+ }),
170
+ deadline,
171
+ ]);
172
+ }
173
+ catch (error) {
174
+ throw new GameServicesNotificationsRequestError({
175
+ code: 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE',
176
+ route,
177
+ status: 0,
178
+ message: timedOut
179
+ ? `${route}: the session's request headers were not resolved within ${timeoutMs} ms; no request was sent.`
180
+ : `${route}: the session's request headers could not be resolved; no request was sent.`,
181
+ cause: error instanceof GameServicesHeaderResolutionError ? error : undefined,
182
+ });
183
+ }
184
+ if (session !== undefined) {
185
+ const now = sessionOf(identity.current());
186
+ if (now === undefined || now.player !== session.player) {
187
+ throw new NotificationsPlayerChangedError({
188
+ code: 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE',
189
+ route,
190
+ status: 0,
191
+ message: `${route}: the player signed out or changed while the request headers were resolved; `
192
+ + 'no request was sent.',
193
+ });
194
+ }
195
+ }
196
+ try {
197
+ const response = await Promise.race([
198
+ fetcher(`${baseUrl}${gameServicesNotificationsRoutePrefix}${route}`, {
199
+ method,
200
+ headers: method === 'POST'
201
+ ? { ...headers, 'content-type': 'application/json' }
202
+ : { ...headers },
203
+ ...(method === 'POST' ? { body: JSON.stringify(body) } : {}),
204
+ ...(controller === undefined ? {} : { signal: controller.signal }),
205
+ }),
206
+ deadline,
207
+ ]);
208
+ return { response, text: await Promise.race([response.text(), deadline]) };
209
+ }
210
+ catch (error) {
211
+ throw new GameServicesNotificationsRequestError({
212
+ code: 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE',
213
+ route,
214
+ status: 0,
215
+ message: timedOut
216
+ ? `${route}: the game-services Worker did not answer within ${timeoutMs} ms; the client stopped waiting `
217
+ + '(and aborted the fetch, where it takes a signal).'
218
+ : `${route}: the game-services Worker could not be reached.`,
219
+ cause: error,
220
+ });
221
+ }
222
+ }
223
+ finally {
224
+ clearTimeout(timer);
225
+ }
226
+ };
227
+ /** Sends one request and reads its answer as the route's JSON object. */
228
+ const call = async (route, method, body, session) => {
229
+ const { response, text } = await request(route, method, body, session);
230
+ const parsed = parseJson(text);
231
+ if (response.status >= 200 && response.status < 300) {
232
+ if (parsed === 'not-json') {
233
+ // A success page that is not JSON, or an empty one (a 204), came from
234
+ // something in between (a captive portal, a proxy), not from the
235
+ // Worker, which always answers a JSON object: try again later.
236
+ throw new GameServicesNotificationsRequestError({
237
+ code: 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE',
238
+ route,
239
+ status: response.status,
240
+ message: `${route}: the answer was not JSON; something between the game and the Worker answered.`,
241
+ });
242
+ }
243
+ if (parsed === undefined) {
244
+ throw contractBroken(route, response.status, 'the answer is not a JSON object');
245
+ }
246
+ return parsed;
247
+ }
248
+ throw errorOf(route, response, parsed === 'not-json' ? undefined : parsed);
249
+ };
250
+ return {
251
+ session: () => sessionOf(identity.current()),
252
+ onAuthenticated(listener) {
253
+ // The session already current when the runtime subscribes is not a new
254
+ // authentication; a later refresh of it is not either.
255
+ let announced = sessionOf(identity.current());
256
+ return identity.onAuthenticated((session) => {
257
+ const next = sessionOf(session);
258
+ if (next === undefined || (announced?.id === next.id && announced.player === next.player)) {
259
+ return;
260
+ }
261
+ announced = next;
262
+ listener(next);
263
+ });
264
+ },
265
+ async recordConsent(record) {
266
+ const session = requireSession('/consent');
267
+ const answer = await call('/consent', 'POST', { record }, session);
268
+ checked('/consent', () => assertNotificationConsentRecord(answer['record']));
269
+ },
270
+ async listConsent() {
271
+ const session = requireSession('/consent');
272
+ const answer = await call('/consent', 'GET', undefined, session);
273
+ const records = answer['records'];
274
+ if (!Array.isArray(records)) {
275
+ throw contractBroken('/consent', 200, 'records is not a list');
276
+ }
277
+ return records.map((record) => checked('/consent', () => assertNotificationConsentRecord(record)));
278
+ },
279
+ async registerEndpoint(registration) {
280
+ const session = requireSession('/endpoints');
281
+ await call('/endpoints', 'POST', { registration, ...(input.target === undefined ? {} : { target: input.target }) }, session);
282
+ },
283
+ async unregisterEndpoint({ provider, player }) {
284
+ const session = sessionOf(identity.current());
285
+ if (session === undefined || session.player !== player) {
286
+ // The Worker unbinds the session's installation from the session's
287
+ // player. Another player's session must not send it: the leaving
288
+ // player's session already ended, and its end unbound what it registered.
289
+ return;
290
+ }
291
+ try {
292
+ await call('/endpoints/unregister', 'POST', { provider }, session);
293
+ }
294
+ catch (error) {
295
+ // The player left while the headers were resolved, so nothing was
296
+ // sent: as above, the session's end unbinds what it registered. A
297
+ // request that went out and failed is the caller's to see.
298
+ if (error instanceof NotificationsPlayerChangedError) {
299
+ return;
300
+ }
301
+ throw error;
302
+ }
303
+ },
304
+ async setReminder(reminder) {
305
+ const session = requireSession('/reminders');
306
+ const answer = await call('/reminders', 'POST', { reminder }, session);
307
+ const status = answer['status'];
308
+ if (!reminderSetStatuses.includes(status)) {
309
+ throw contractBroken('/reminders', 200, `status ${JSON.stringify(status)} is not a reminder status`);
310
+ }
311
+ return status;
312
+ },
313
+ async cancelReminder({ key }) {
314
+ const session = requireSession('/reminders/cancel');
315
+ const answer = await call('/reminders/cancel', 'POST', { key }, session);
316
+ if (typeof answer['cancelled'] !== 'boolean') {
317
+ throw contractBroken('/reminders/cancel', 200, 'cancelled is not a boolean');
318
+ }
319
+ return answer['cancelled'];
320
+ },
321
+ async listNotices() {
322
+ const session = requireSession('/notices');
323
+ const answer = await call('/notices', 'GET', undefined, session);
324
+ const notices = answer['notices'];
325
+ if (!Array.isArray(notices)) {
326
+ throw contractBroken('/notices', 200, 'notices is not a list');
327
+ }
328
+ return notices.map((notice) => checked('/notices', () => assertNotificationNotice(notice)));
329
+ },
330
+ async recordNotice({ channel, kind, record, dueBy }) {
331
+ const session = requireSession('/notices/shown');
332
+ await call('/notices/shown', 'POST', { notice: { kind, record, dueBy }, channel }, session);
333
+ },
334
+ async getProviders() {
335
+ const answer = await call('/providers', 'GET', undefined, undefined);
336
+ const { protocolVersion, providers, targets } = answer;
337
+ if (typeof protocolVersion !== 'string'
338
+ || !Array.isArray(providers)
339
+ || typeof targets !== 'object'
340
+ || targets === null
341
+ || Array.isArray(targets)
342
+ || !Object.values(targets).every((provider) => typeof provider === 'string')) {
343
+ throw contractBroken('/providers', 200, 'the registry is not { protocolVersion, providers, targets }');
344
+ }
345
+ return {
346
+ protocolVersion,
347
+ providers: providers.map((entry) => checked('/providers', () => assertNotificationDeliveryProviderEntry(entry))),
348
+ targets: { ...targets },
349
+ };
350
+ },
351
+ };
352
+ }
353
+ function globalFetch() {
354
+ const fetcher = globalThis.fetch;
355
+ if (fetcher === undefined) {
356
+ throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
357
+ message: 'globalThis.fetch is unavailable; give the notifications client a fetch.',
358
+ });
359
+ }
360
+ return fetcher;
361
+ }
362
+ /** The JSON object an answer holds, `undefined` for other JSON, or `'not-json'`. */
363
+ function parseJson(text) {
364
+ let parsed;
365
+ try {
366
+ parsed = JSON.parse(text);
367
+ }
368
+ catch {
369
+ return 'not-json';
370
+ }
371
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
372
+ ? parsed
373
+ : undefined;
374
+ }
375
+ /** An answer that breaks the route's contract: the Worker, not the game, is wrong. */
376
+ function contractBroken(route, status, issue) {
377
+ return new GameServicesNotificationsRequestError({
378
+ code: 'NOTIFICATIONS_CONFIGURATION_REQUIRED',
379
+ route,
380
+ status,
381
+ message: `${route}: the game-services Worker answered outside the notifications contract: ${issue}.`,
382
+ });
383
+ }
384
+ function checked(route, read) {
385
+ try {
386
+ return read();
387
+ }
388
+ catch (error) {
389
+ throw contractBroken(route, 200, error instanceof Error ? error.message : String(error));
390
+ }
391
+ }
392
+ function errorOf(route, response, body) {
393
+ const { status } = response;
394
+ const serverCode = typeof body?.['error'] === 'string' ? body['error'] : undefined;
395
+ const serverMessage = typeof body?.['message'] === 'string' ? body['message'] : undefined;
396
+ let code;
397
+ let message;
398
+ if (status === 401) {
399
+ code = 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE';
400
+ message = `${route}: the game-services session is not authenticated (signed out, expired, or ended).`;
401
+ }
402
+ else if (status === 404 || status === 405) {
403
+ code = 'NOTIFICATIONS_CONFIGURATION_REQUIRED';
404
+ message = route === '/providers'
405
+ ? `${route}: the game-services Worker serves no notifications (no MPGD_NOTIFICATIONS_CATALOG).`
406
+ : `${route}: the game-services Worker serves no notifications for sessions `
407
+ + '(no MPGD_NOTIFICATIONS_CATALOG or no GAME_SERVICES_NOTIFICATIONS_AUTH binding).';
408
+ }
409
+ else if (status === 400 || status === 413) {
410
+ code = serverCode !== undefined && isNotificationErrorCode(serverCode) ? serverCode : 'NOTIFICATIONS_INVALID_INPUT';
411
+ message = `${route}: ${serverMessage ?? 'the game-services Worker refused the request as invalid.'}`;
412
+ }
413
+ else if (status === 408 || status === 425 || status === 429 || status === 503) {
414
+ code = 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE';
415
+ message = `${route}: ${serverMessage ?? 'the game-services Worker is busy; retry later.'}`;
416
+ }
417
+ else if (serverCode !== undefined && isNotificationErrorCode(serverCode)) {
418
+ code = serverCode;
419
+ message = `${route}: ${serverMessage ?? `the game-services Worker answered ${serverCode}.`}`;
420
+ }
421
+ else {
422
+ code = status >= 500 ? 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE' : 'NOTIFICATIONS_CONFIGURATION_REQUIRED';
423
+ message = `${route}: the game-services Worker answered ${status}${serverCode === undefined ? '' : ` ${serverCode}`}.`;
424
+ }
425
+ return new GameServicesNotificationsRequestError({
426
+ code,
427
+ route,
428
+ status,
429
+ message,
430
+ ...(serverCode === undefined ? {} : { serverCode }),
431
+ });
432
+ }
@@ -14,9 +14,12 @@ import type { NotificationProviderRegistration } from './registry.js';
14
14
  * Android posts to the mapping's `channelId`, else to `mpgd-service`,
15
15
  * `mpgd-requested`, or `mpgd-advertising` by purpose. An advertising send
16
16
  * names the iOS category `mpgd-advertising`, whose opt-out action the app
17
- * registers; an Android FCM notification message cannot carry actions, so
18
- * the shell renders advertising natively (C1). An expiry becomes
19
- * `android.ttl` and the `apns-expiration` header.
17
+ * registers; an Android FCM notification message cannot carry actions, and
18
+ * the Capacitor shell does not render advertising natively yet. An expiry
19
+ * becomes `android.ttl` and the `apns-expiration` header. A replay of the
20
+ * same send key sets `android.collapse_key` and `apns-collapse-id` so a
21
+ * crash after HTTP 200, before the ledger write, does not show a second
22
+ * notification.
20
23
  */
21
24
  export declare const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging";
22
25
  export declare const fcmTokenEndpoint = "https://oauth2.googleapis.com/token";
@@ -31,6 +34,11 @@ export declare const fcmAddressKinds: readonly ["fcm-token"];
31
34
  export declare const fcmReservedDataKeys: readonly ["topic", "route"];
32
35
  /** The iOS notification category of advertising, which carries the opt-out action. */
33
36
  export declare const fcmAdvertisingCategory = "mpgd-advertising";
37
+ /**
38
+ * `android.collapse_key` and `apns-collapse-id` are at most 64 bytes. The
39
+ * id is the SHA-256 of the send key, which is 64 hex characters.
40
+ */
41
+ export declare const fcmCollapseIdBytes = 64;
34
42
  /** The fields of a Google service-account key this provider reads. */
35
43
  export interface FcmServiceAccount {
36
44
  readonly projectId: string;
@@ -135,4 +143,10 @@ export declare function createFcmDeliveryProvider(input: CreateFcmDeliveryProvid
135
143
  export declare function createFcmProviderRegistration(input: CreateFcmDeliveryProviderInput & {
136
144
  readonly targets?: readonly string[];
137
145
  }): NotificationProviderRegistration;
146
+ /**
147
+ * Collapse id for one send key: SHA-256 hex, exactly 64 bytes, so it fits
148
+ * `android.collapse_key` and `apns-collapse-id`. Sync because
149
+ * `buildFcmMessage` is sync; Workers have no sync WebCrypto digest.
150
+ */
151
+ export declare function fcmCollapseId(sendKey: string): string;
138
152
  export {};
@@ -14,9 +14,12 @@ import { fcmInstallationIdRefusal, } from './delivery-provider.js';
14
14
  * Android posts to the mapping's `channelId`, else to `mpgd-service`,
15
15
  * `mpgd-requested`, or `mpgd-advertising` by purpose. An advertising send
16
16
  * names the iOS category `mpgd-advertising`, whose opt-out action the app
17
- * registers; an Android FCM notification message cannot carry actions, so
18
- * the shell renders advertising natively (C1). An expiry becomes
19
- * `android.ttl` and the `apns-expiration` header.
17
+ * registers; an Android FCM notification message cannot carry actions, and
18
+ * the Capacitor shell does not render advertising natively yet. An expiry
19
+ * becomes `android.ttl` and the `apns-expiration` header. A replay of the
20
+ * same send key sets `android.collapse_key` and `apns-collapse-id` so a
21
+ * crash after HTTP 200, before the ledger write, does not show a second
22
+ * notification.
20
23
  */
21
24
  export const fcmMessagingScope = 'https://www.googleapis.com/auth/firebase.messaging';
22
25
  export const fcmTokenEndpoint = 'https://oauth2.googleapis.com/token';
@@ -37,6 +40,11 @@ export const fcmAdvertisingCategory = 'mpgd-advertising';
37
40
  * not refused for its size.
38
41
  */
39
42
  const maxPayloadBytes = 3840;
43
+ /**
44
+ * `android.collapse_key` and `apns-collapse-id` are at most 64 bytes. The
45
+ * id is the SHA-256 of the send key, which is 64 hex characters.
46
+ */
47
+ export const fcmCollapseIdBytes = 64;
40
48
  /** The longest `Retry-After` this provider passes on; the service gives up after a day anyway. */
41
49
  const maxRetryAfterSeconds = 86_400;
42
50
  /** How long a refused grant or an unusable key answers without asking Google again. */
@@ -307,22 +315,28 @@ export function buildFcmMessage(request) {
307
315
  code: 'MESSAGE_EXPIRED',
308
316
  };
309
317
  }
318
+ // Same id on both transports: a replay of this send replaces the first
319
+ // instead of showing beside it. Collapse is per token, so two devices of
320
+ // one send do not hide each other.
321
+ const collapseId = fcmCollapseId(request.sendKey);
310
322
  const android = {
323
+ collapse_key: collapseId,
311
324
  ...(expiresAt === undefined ? {} : { ttl: `${Math.floor((expiresAt - at) / 1000)}s` }),
312
325
  notification: { channel_id: channelId },
313
326
  };
314
327
  const category = request.topic.purpose === 'advertising' ? fcmAdvertisingCategory : undefined;
315
328
  const apns = {
316
- ...(expiresAt === undefined
317
- ? {}
318
- : { headers: { 'apns-expiration': String(Math.floor(expiresAt / 1000)) } }),
329
+ headers: {
330
+ 'apns-collapse-id': collapseId,
331
+ ...(expiresAt === undefined ? {} : { 'apns-expiration': String(Math.floor(expiresAt / 1000)) }),
332
+ },
319
333
  ...(category === undefined ? {} : { payload: { aps: { category } } }),
320
334
  };
321
335
  const payload = {
322
336
  notification,
323
337
  data: { ...data },
324
338
  android,
325
- ...(Object.keys(apns).length === 0 ? {} : { apns }),
339
+ apns,
326
340
  };
327
341
  // Everything but the token counts toward the size FCM and APNs carry.
328
342
  const payloadBytes = new TextEncoder().encode(JSON.stringify(payload)).length;
@@ -600,3 +614,91 @@ function retryAfterOf(response, now) {
600
614
  function isRecord(value) {
601
615
  return typeof value === 'object' && value !== null && !Array.isArray(value);
602
616
  }
617
+ /**
618
+ * Collapse id for one send key: SHA-256 hex, exactly 64 bytes, so it fits
619
+ * `android.collapse_key` and `apns-collapse-id`. Sync because
620
+ * `buildFcmMessage` is sync; Workers have no sync WebCrypto digest.
621
+ */
622
+ export function fcmCollapseId(sendKey) {
623
+ const hex = sha256Hex(new TextEncoder().encode(sendKey));
624
+ return hex.length === fcmCollapseIdBytes ? hex : hex.slice(0, fcmCollapseIdBytes);
625
+ }
626
+ const sha256K = new Uint32Array([
627
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
628
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
629
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
630
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
631
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
632
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
633
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
634
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
635
+ ]);
636
+ function rotr(value, bits) {
637
+ return (value >>> bits) | (value << (32 - bits));
638
+ }
639
+ function sha256Hex(data) {
640
+ const bitLength = data.length * 8;
641
+ const paddedLength = (Math.floor((data.length + 8) / 64) + 1) * 64;
642
+ const padded = new Uint8Array(paddedLength);
643
+ padded.set(data);
644
+ padded[data.length] = 0x80;
645
+ const view = new DataView(padded.buffer);
646
+ view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000));
647
+ view.setUint32(paddedLength - 4, bitLength >>> 0);
648
+ let h0 = 0x6a09e667;
649
+ let h1 = 0xbb67ae85;
650
+ let h2 = 0x3c6ef372;
651
+ let h3 = 0xa54ff53a;
652
+ let h4 = 0x510e527f;
653
+ let h5 = 0x9b05688c;
654
+ let h6 = 0x1f83d9ab;
655
+ let h7 = 0x5be0cd19;
656
+ const words = new Uint32Array(64);
657
+ for (let offset = 0; offset < paddedLength; offset += 64) {
658
+ for (let index = 0; index < 16; index += 1) {
659
+ words[index] = view.getUint32(offset + index * 4);
660
+ }
661
+ for (let index = 16; index < 64; index += 1) {
662
+ const earlier = words[index - 15] ?? 0;
663
+ const recent = words[index - 2] ?? 0;
664
+ const s0 = rotr(earlier, 7) ^ rotr(earlier, 18) ^ (earlier >>> 3);
665
+ const s1 = rotr(recent, 17) ^ rotr(recent, 19) ^ (recent >>> 10);
666
+ words[index] = ((words[index - 16] ?? 0) + s0 + (words[index - 7] ?? 0) + s1) >>> 0;
667
+ }
668
+ let a = h0;
669
+ let b = h1;
670
+ let c = h2;
671
+ let d = h3;
672
+ let e = h4;
673
+ let f = h5;
674
+ let g = h6;
675
+ let h = h7;
676
+ for (let index = 0; index < 64; index += 1) {
677
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
678
+ const ch = (e & f) ^ (~e & g);
679
+ const temp1 = (h + s1 + ch + (sha256K[index] ?? 0) + (words[index] ?? 0)) >>> 0;
680
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
681
+ const maj = (a & b) ^ (a & c) ^ (b & c);
682
+ const temp2 = (s0 + maj) >>> 0;
683
+ h = g;
684
+ g = f;
685
+ f = e;
686
+ e = (d + temp1) >>> 0;
687
+ d = c;
688
+ c = b;
689
+ b = a;
690
+ a = (temp1 + temp2) >>> 0;
691
+ }
692
+ h0 = (h0 + a) >>> 0;
693
+ h1 = (h1 + b) >>> 0;
694
+ h2 = (h2 + c) >>> 0;
695
+ h3 = (h3 + d) >>> 0;
696
+ h4 = (h4 + e) >>> 0;
697
+ h5 = (h5 + f) >>> 0;
698
+ h6 = (h6 + g) >>> 0;
699
+ h7 = (h7 + h) >>> 0;
700
+ }
701
+ return [h0, h1, h2, h3, h4, h5, h6, h7]
702
+ .map((word) => word.toString(16).padStart(8, '0'))
703
+ .join('');
704
+ }
@@ -179,7 +179,10 @@ export interface NotificationService {
179
179
  * each endpoint reported, or the host read, for any provider the player
180
180
  * can be reached on; a refusal stores nothing. Otherwise the reminder with
181
181
  * this key is stored or replaced, and a send of the previous one that
182
- * still waits (deferred or in flight) is stopped.
182
+ * still waits (deferred, or in flight with no live claim) is stopped. A
183
+ * row a holder has claimed is left so an acceptance the provider already
184
+ * took can be recorded; that holder does not call the provider if the
185
+ * reminder was cancelled or replaced before the call.
183
186
  */
184
187
  setReminder(session: NotificationSession, input: unknown): Promise<{
185
188
  readonly status: NotificationReminderSetStatus;