@mpgd/game-services 0.26.0 → 0.27.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/dist/client.d.ts +2 -11
- package/dist/client.js +2 -25
- package/dist/microsoft-store-purchase.d.ts +9 -5
- package/dist/microsoft-store-purchase.js +175 -43
- package/dist/notifications/ait-functional-delivery.d.ts +25 -8
- package/dist/notifications/ait-functional-delivery.js +38 -17
- package/dist/notifications/client.d.ts +168 -0
- package/dist/notifications/client.js +432 -0
- package/dist/notifications/fcm.d.ts +15 -1
- package/dist/notifications/fcm.js +107 -5
- package/dist/notifications/service.d.ts +4 -1
- package/dist/notifications/service.js +221 -37
- package/dist/notifications/store-conformance.js +19 -0
- package/dist/notifications/store.d.ts +5 -1
- package/dist/notifications/store.js +1 -1
- package/dist/request-headers.d.ts +16 -0
- package/dist/request-headers.js +30 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +9 -0
- package/package.json +9 -5
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { NotificationError, type NotificationDeliveryProviderEntry, type NotificationErrorCode } from '@mpgd/platform/notifications';
|
|
2
|
+
import type { NotificationRuntimeBackend } from '@mpgd/platform/notifications-runtime';
|
|
3
|
+
import { type GameServicesHeaderResolver } from '../request-headers.js';
|
|
4
|
+
/**
|
|
5
|
+
* The game-services notifications client: the notifications runtime's
|
|
6
|
+
* backend (`NotificationRuntimeBackend` from
|
|
7
|
+
* `@mpgd/platform/notifications-runtime`) over the game-services Worker's
|
|
8
|
+
* session routes under `/game-services/notifications` (see
|
|
9
|
+
* docs/CLOUDFLARE_WORKER_DEPLOY.md, "Sessions" and "Client routes").
|
|
10
|
+
*
|
|
11
|
+
* The Worker learns the player, the session, and the installation from the
|
|
12
|
+
* `Authorization` header alone, through its `GAME_SERVICES_NOTIFICATIONS_AUTH`
|
|
13
|
+
* binding (the identity Worker's `authenticateNotificationSession`); no
|
|
14
|
+
* request carries a player or an installation. The client learns which
|
|
15
|
+
* session is current from the game's identity, which also supplies the
|
|
16
|
+
* header through the same resolver the game gives its commerce client.
|
|
17
|
+
*
|
|
18
|
+
* No platform SDK, DOM, or Phaser import: only `fetch`.
|
|
19
|
+
*/
|
|
20
|
+
/** The Worker's route prefix for notifications. */
|
|
21
|
+
export declare const gameServicesNotificationsRoutePrefix = "/game-services/notifications";
|
|
22
|
+
/** The server-backed session the game is signed in with. */
|
|
23
|
+
export interface GameServicesNotificationSession {
|
|
24
|
+
readonly playerId: string;
|
|
25
|
+
/**
|
|
26
|
+
* The stable session-family id: the `sessionId` the identity service
|
|
27
|
+
* answers to the Worker's `authenticateNotificationSession` for this
|
|
28
|
+
* session's header, the same across token refreshes. A new sign-in starts
|
|
29
|
+
* a new one. A per-token or rotating id is not it (a
|
|
30
|
+
* `GuestSessionCoordinator` view's `sessionId` may rotate on refresh); with
|
|
31
|
+
* one, each rotation counts as a new session and sends the registration
|
|
32
|
+
* again.
|
|
33
|
+
*/
|
|
34
|
+
readonly sessionId: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The game's identity as the notifications client needs it. The game wires
|
|
38
|
+
* it to its own session holder and identity service; the kit does not
|
|
39
|
+
* create sessions.
|
|
40
|
+
*/
|
|
41
|
+
export interface GameServicesNotificationIdentity {
|
|
42
|
+
/** The current session, or `undefined` while signed out. */
|
|
43
|
+
current(): GameServicesNotificationSession | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Calls `listener` when a session becomes current after the subscription:
|
|
46
|
+
* a sign-in, a session restored after the runtime started, or an account
|
|
47
|
+
* switch. Returns the unsubscribe. A repeat for the session already
|
|
48
|
+
* announced (a token refresh) is ignored, and so is one for the session
|
|
49
|
+
* current when the runtime subscribed (at its `start()`); the runtime's
|
|
50
|
+
* start registers again on its own when that session is not the one its
|
|
51
|
+
* last registration was acknowledged for.
|
|
52
|
+
*/
|
|
53
|
+
onAuthenticated(listener: (session: GameServicesNotificationSession) => void): () => void;
|
|
54
|
+
}
|
|
55
|
+
/** The part of the Fetch API the client uses; `globalThis.fetch` satisfies it. */
|
|
56
|
+
export type GameServicesNotificationsFetch = (url: string, init: {
|
|
57
|
+
readonly method: 'GET' | 'POST';
|
|
58
|
+
readonly headers: Record<string, string>;
|
|
59
|
+
readonly body?: string;
|
|
60
|
+
/** Aborted when the request times out. */
|
|
61
|
+
readonly signal?: AbortSignal;
|
|
62
|
+
}) => Promise<GameServicesNotificationsFetchResponse>;
|
|
63
|
+
export interface GameServicesNotificationsFetchResponse {
|
|
64
|
+
readonly status: number;
|
|
65
|
+
text(): Promise<string>;
|
|
66
|
+
}
|
|
67
|
+
export interface CreateGameServicesNotificationsClientInput {
|
|
68
|
+
/**
|
|
69
|
+
* The game-services Worker's origin, as the commerce client takes it
|
|
70
|
+
* (`VITE_MPGD_GAME_SERVICES_URL`). The client sends the session's
|
|
71
|
+
* credentials to it as given: in a production build, check it with
|
|
72
|
+
* `isPublicGameServicesUrl` from `@mpgd/game-services` first.
|
|
73
|
+
*/
|
|
74
|
+
readonly baseUrl: string;
|
|
75
|
+
readonly identity: GameServicesNotificationIdentity;
|
|
76
|
+
/** Defaults to `globalThis.fetch`. */
|
|
77
|
+
readonly fetch?: GameServicesNotificationsFetch;
|
|
78
|
+
/** Static headers sent with every request. */
|
|
79
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
80
|
+
/** Resolves the session's `Authorization` header at request time, as for the commerce client. */
|
|
81
|
+
readonly getHeaders?: GameServicesHeaderResolver;
|
|
82
|
+
/**
|
|
83
|
+
* The catalog target a device registration is for (`android`, `ios`): the
|
|
84
|
+
* Worker applies that target's topic mapping to the device and refuses a
|
|
85
|
+
* registration without it when one provider serves two targets.
|
|
86
|
+
*/
|
|
87
|
+
readonly target?: string;
|
|
88
|
+
/**
|
|
89
|
+
* How long one request may take, from resolving its headers to reading
|
|
90
|
+
* its answer, before it is aborted and answers
|
|
91
|
+
* `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`; 15 seconds by default, at most
|
|
92
|
+
* 2147483647.
|
|
93
|
+
*/
|
|
94
|
+
readonly timeoutMs?: number;
|
|
95
|
+
}
|
|
96
|
+
/** The default `timeoutMs`: a request still unanswered after this is aborted. */
|
|
97
|
+
export declare const defaultGameServicesNotificationsTimeoutMs = 15000;
|
|
98
|
+
/** The registry the Worker publishes at `GET /providers`. */
|
|
99
|
+
export interface GameServicesNotificationProviders {
|
|
100
|
+
readonly protocolVersion: string;
|
|
101
|
+
readonly providers: readonly NotificationDeliveryProviderEntry[];
|
|
102
|
+
/** Each served target and the provider id that delivers to it. */
|
|
103
|
+
readonly targets: Readonly<Record<string, string>>;
|
|
104
|
+
}
|
|
105
|
+
export interface GameServicesNotificationsClient extends NotificationRuntimeBackend {
|
|
106
|
+
/**
|
|
107
|
+
* The registry the Worker publishes (`GET /providers`, no session needed):
|
|
108
|
+
* pass `providers.find((entry) => entry.provider === provider.provider)` as
|
|
109
|
+
* the runtime's `registryEntry`.
|
|
110
|
+
*/
|
|
111
|
+
getProviders(): Promise<GameServicesNotificationProviders>;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A failed notifications request, with the `NotificationError` code the
|
|
115
|
+
* runtime acts on and what the Worker answered.
|
|
116
|
+
*
|
|
117
|
+
* - 401: the session is not authenticated (signed out, token expired, or
|
|
118
|
+
* the session ended): `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`, retryable
|
|
119
|
+
* once the game has a session again.
|
|
120
|
+
* - 404 on a session route: the Worker serves no notifications for sessions
|
|
121
|
+
* (no catalog, or no `GAME_SERVICES_NOTIFICATIONS_AUTH` binding), and 405:
|
|
122
|
+
* the Worker does not serve this client's routes;
|
|
123
|
+
* `NOTIFICATIONS_CONFIGURATION_REQUIRED`, not retryable.
|
|
124
|
+
* - 400 and 413: `NOTIFICATIONS_INVALID_INPUT` (or the code the Worker names).
|
|
125
|
+
* - 408, 425, 429, and 503: `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`. The
|
|
126
|
+
* runtime retries at its next trigger (a read, a start, a sign-in), not
|
|
127
|
+
* on the Worker's `Retry-After`, which the client does not read.
|
|
128
|
+
* - Another status names the code the Worker answered when it is a
|
|
129
|
+
* notifications code (a Worker whose notifications configuration is
|
|
130
|
+
* broken answers 500 with `NOTIFICATIONS_CONFIGURATION_REQUIRED`, not
|
|
131
|
+
* retryable); otherwise a 5xx is `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`
|
|
132
|
+
* and a 4xx `NOTIFICATIONS_CONFIGURATION_REQUIRED`.
|
|
133
|
+
* - A 2xx whose body is not JSON, or is empty (a captive portal or proxy
|
|
134
|
+
* answered): `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` with that status. A
|
|
135
|
+
* 2xx with JSON outside the route's contract:
|
|
136
|
+
* `NOTIFICATIONS_CONFIGURATION_REQUIRED`.
|
|
137
|
+
* - `status` 0, `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`, nothing sent: no
|
|
138
|
+
* signed-in player with a session id, the header resolver failed or did
|
|
139
|
+
* not settle within `timeoutMs`, or the player signed out or changed
|
|
140
|
+
* while the headers were resolved.
|
|
141
|
+
* - `status` 0, `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`, no answer read: the
|
|
142
|
+
* request failed in transit, timed out (`timeoutMs`), or its body could
|
|
143
|
+
* not be read, so the Worker may have handled it. Retrying may repeat it: reminders and endpoints
|
|
144
|
+
* replace by key, but an advertising or night consent record is written
|
|
145
|
+
* again.
|
|
146
|
+
*/
|
|
147
|
+
export declare class GameServicesNotificationsRequestError extends NotificationError {
|
|
148
|
+
/** The HTTP status, or 0 when no answer arrived. */
|
|
149
|
+
readonly status: number;
|
|
150
|
+
/** The route, such as `/consent`. */
|
|
151
|
+
readonly route: string;
|
|
152
|
+
/** The `error` the Worker answered, such as `UNAUTHORIZED`. */
|
|
153
|
+
readonly serverCode: string | undefined;
|
|
154
|
+
constructor(input: {
|
|
155
|
+
readonly code: NotificationErrorCode;
|
|
156
|
+
readonly route: string;
|
|
157
|
+
readonly status: number;
|
|
158
|
+
readonly message: string;
|
|
159
|
+
readonly serverCode?: string;
|
|
160
|
+
readonly cause?: unknown;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Creates the client. Every session call needs a current session and sends
|
|
165
|
+
* the headers the resolver gives for it; without a session it throws
|
|
166
|
+
* `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` and sends nothing.
|
|
167
|
+
*/
|
|
168
|
+
export declare function createGameServicesNotificationsClient(input: CreateGameServicesNotificationsClientInput): GameServicesNotificationsClient;
|
|
@@ -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
|
+
}
|
|
@@ -16,7 +16,10 @@ import type { NotificationProviderRegistration } from './registry.js';
|
|
|
16
16
|
* names the iOS category `mpgd-advertising`, whose opt-out action the app
|
|
17
17
|
* registers; an Android FCM notification message cannot carry actions, and
|
|
18
18
|
* the Capacitor shell does not render advertising natively yet. An expiry
|
|
19
|
-
* becomes `android.ttl` and the `apns-expiration` header.
|
|
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 {};
|