@mpgd/cli 0.53.0 → 0.54.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/capacitor-shell-starter.js +9 -1
- package/dist/deploy-planning.js +2 -2
- package/dist/index.js +148 -52
- package/dist/kit-upgrade.js +105 -6
- package/dist/legal-site-output.d.ts +12 -0
- package/dist/legal-site-output.js +226 -0
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +166 -14
- package/dist/notifications-starter.d.ts +6 -0
- package/dist/notifications-starter.js +9 -0
- package/dist/offline-playtest.js +1 -1
- package/dist/tooling-migration.js +1 -1
- package/package.json +17 -17
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +36 -7
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +157 -0
- package/templates/notifications-overlay/common/src/platform/notifications.ts +67 -35
- package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +26 -7
- package/templates/phaser-game/src/platform/gameServices.ts +3 -0
- package/templates/phaser-game/src/platform/serverSession.ts +66 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPublicGameServicesUrl,
|
|
3
|
+
resolveGameServicesAuthorityMode,
|
|
4
|
+
type GameServicesHeaderResolver,
|
|
5
|
+
} from '@mpgd/game-services';
|
|
6
|
+
import {
|
|
7
|
+
createGameServicesNotificationsClient,
|
|
8
|
+
type GameServicesNotificationIdentity,
|
|
9
|
+
type GameServicesNotificationsFetch,
|
|
10
|
+
} from '@mpgd/game-services/notifications/client';
|
|
11
|
+
import type {
|
|
12
|
+
NotificationConsentRecord,
|
|
13
|
+
NotificationRuntime,
|
|
14
|
+
NotificationTopicDeclaration,
|
|
15
|
+
} from '@mpgd/platform/notifications';
|
|
16
|
+
import {
|
|
17
|
+
createNotificationRuntime,
|
|
18
|
+
createNotificationRuntimeStorage,
|
|
19
|
+
} from '@mpgd/platform/notifications-runtime';
|
|
20
|
+
import type { TargetConfiguredGateway } from '@mpgd/target-config';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The game backend's notifications surface the starter uses: the runtime
|
|
24
|
+
* (notifications RFC "Runtime"), which composes the target's provider with
|
|
25
|
+
* the backend that keeps consent records, endpoint registrations, server
|
|
26
|
+
* reminders, and notices; and the signed-in player's consent records (the
|
|
27
|
+
* game-services Worker's `GET /consent`), which the settings screen shows.
|
|
28
|
+
*/
|
|
29
|
+
export interface StarterNotificationBackend {
|
|
30
|
+
readonly runtime: NotificationRuntime;
|
|
31
|
+
listConsent(): Promise<readonly NotificationConsentRecord[]>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The server-backed session the Worker client needs (see `serverSession.ts`). */
|
|
35
|
+
export interface StarterWorkerSession extends GameServicesNotificationIdentity {
|
|
36
|
+
readonly getHeaders: GameServicesHeaderResolver;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StarterWorkerNotificationInput {
|
|
40
|
+
/** `VITE_MPGD_GAME_SERVICES_URL`. */
|
|
41
|
+
readonly baseUrl: string | undefined;
|
|
42
|
+
/** The Vite mode (`import.meta.env.MODE`), which decides the production URL rule. */
|
|
43
|
+
readonly mode: string;
|
|
44
|
+
readonly session: StarterWorkerSession | undefined;
|
|
45
|
+
/** This target's topics, each with its mapping. */
|
|
46
|
+
readonly topics: readonly NotificationTopicDeclaration[];
|
|
47
|
+
readonly defaultLocale: string;
|
|
48
|
+
/** Logs a message, once per gateway and message. */
|
|
49
|
+
readonly log: (message: string, error?: unknown) => void;
|
|
50
|
+
/** Defaults to `globalThis.fetch`. */
|
|
51
|
+
readonly fetch?: GameServicesNotificationsFetch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Config targets whose notifications backend is not the game-services
|
|
56
|
+
* Worker. A Reddit game's backend is its Devvit server: the opt-in, the
|
|
57
|
+
* consent records, and the sends live there, and the Worker serves no
|
|
58
|
+
* `reddit` target. A runtime over the Worker would send the game's session
|
|
59
|
+
* header to a backend that cannot use it, so Reddit keeps no backend here,
|
|
60
|
+
* as before R1, until the Devvit server has a runtime client of its own.
|
|
61
|
+
*/
|
|
62
|
+
const targetsWithoutWorkerBackend: ReadonlySet<string> = new Set(['reddit']);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The notifications runtime over the game-services Worker's notification
|
|
66
|
+
* routes (`@mpgd/game-services/notifications/client`), when the target's
|
|
67
|
+
* backend is the Worker, the game has a game-services URL and a
|
|
68
|
+
* server-backed session, and the target exposes a notifications provider.
|
|
69
|
+
* Otherwise `undefined`.
|
|
70
|
+
*
|
|
71
|
+
* The runtime keeps its registration state in the platform storage, starts
|
|
72
|
+
* here, and compares its provider with the Worker's registry entry for this
|
|
73
|
+
* target (`GET /providers`), loaded in the background: a Worker it cannot
|
|
74
|
+
* reach is logged once and the runtime runs without the comparison.
|
|
75
|
+
*/
|
|
76
|
+
export function createStarterWorkerNotificationBackend(
|
|
77
|
+
platform: TargetConfiguredGateway,
|
|
78
|
+
input: StarterWorkerNotificationInput,
|
|
79
|
+
): StarterNotificationBackend | undefined {
|
|
80
|
+
// By platform, and by config target name for a gateway that reports another platform.
|
|
81
|
+
if (platform.target === 'reddit' || targetsWithoutWorkerBackend.has(platform.configTarget)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const baseUrl = input.baseUrl?.trim();
|
|
85
|
+
const provider = platform.notificationsV2;
|
|
86
|
+
if (baseUrl === undefined || baseUrl === '' || provider === undefined) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const { session } = input;
|
|
90
|
+
if (session === undefined) {
|
|
91
|
+
input.log(
|
|
92
|
+
'[notifications] VITE_MPGD_GAME_SERVICES_URL is set but no server session is: call setStarterServerSession '
|
|
93
|
+
+ '(src/platform/serverSession.ts) before the game starts. The settings screen shows no switch until then.',
|
|
94
|
+
);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
if (!isAllowedGameServicesUrl(baseUrl, input.mode)) {
|
|
99
|
+
// The session's credentials never travel in clear text or to a private host in production.
|
|
100
|
+
input.log(
|
|
101
|
+
'[notifications] VITE_MPGD_GAME_SERVICES_URL must be an absolute http(s) URL, and a public https URL '
|
|
102
|
+
+ 'without credentials in production; notifications stay off.',
|
|
103
|
+
);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const client = createGameServicesNotificationsClient({
|
|
107
|
+
baseUrl,
|
|
108
|
+
identity: session,
|
|
109
|
+
getHeaders: () => session.getHeaders(),
|
|
110
|
+
// The catalog target whose mapping applies to this device (android or ios share FCM).
|
|
111
|
+
target: platform.configTarget,
|
|
112
|
+
...(input.fetch === undefined ? {} : { fetch: input.fetch }),
|
|
113
|
+
});
|
|
114
|
+
const runtime = createNotificationRuntime({
|
|
115
|
+
provider,
|
|
116
|
+
backend: client,
|
|
117
|
+
catalog: input.topics,
|
|
118
|
+
defaultLocale: input.defaultLocale,
|
|
119
|
+
storage: createNotificationRuntimeStorage({ storage: platform.storage }),
|
|
120
|
+
// This target's entry in the Worker's registry, or none when the Worker does not serve it.
|
|
121
|
+
async loadRegistryEntry() {
|
|
122
|
+
const { providers, targets } = await client.getProviders();
|
|
123
|
+
const id = targets[platform.configTarget];
|
|
124
|
+
return id === undefined ? undefined : providers.find((entry) => entry.provider === id);
|
|
125
|
+
},
|
|
126
|
+
onError(error) {
|
|
127
|
+
console.error('[notifications]', error);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
// It subscribes to the provider at once; a backend that cannot answer yet does not fail the game.
|
|
131
|
+
runtime.start().catch((error: unknown) => {
|
|
132
|
+
console.error('[notifications] the runtime could not start.', error);
|
|
133
|
+
});
|
|
134
|
+
return { runtime, listConsent: () => client.listConsent() };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// A configuration the client refuses turns notifications off, never the game.
|
|
137
|
+
input.log('[notifications] the notifications backend could not be created.', error);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether the game-services URL may carry the session's credentials: any
|
|
144
|
+
* absolute http(s) URL outside production builds, and in production the
|
|
145
|
+
* public https URL the commerce client requires (`isPublicGameServicesUrl`).
|
|
146
|
+
*/
|
|
147
|
+
function isAllowedGameServicesUrl(baseUrl: string, mode: string): boolean {
|
|
148
|
+
if (resolveGameServicesAuthorityMode(mode) === 'production') {
|
|
149
|
+
return isPublicGameServicesUrl(baseUrl);
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const { protocol } = new URL(baseUrl);
|
|
153
|
+
return protocol === 'https:' || protocol === 'http:';
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { resolveNotificationTopicsForTarget, type NotificationTopicCatalog } from '@mpgd/catalog';
|
|
2
|
-
import type {
|
|
2
|
+
import type { NotificationTopicDeclaration } from '@mpgd/platform/notifications';
|
|
3
3
|
import type { TargetConfiguredGateway } from '@mpgd/target-config';
|
|
4
|
-
import type {
|
|
5
|
-
NotificationConsentRecord,
|
|
6
|
-
NotificationRuntime,
|
|
7
|
-
NotificationTopicDeclaration,
|
|
8
|
-
} from '@mpgd/platform/notifications';
|
|
9
4
|
|
|
10
5
|
import notificationTopicsJson from '../../notifications/topics.json';
|
|
6
|
+
import {
|
|
7
|
+
createStarterWorkerNotificationBackend,
|
|
8
|
+
type StarterNotificationBackend,
|
|
9
|
+
} from './notificationBackend';
|
|
10
|
+
import { resolveStarterServerSession } from './serverSession';
|
|
11
11
|
import { routeNotificationOpens, type NotificationOpenRoutes } from './notificationOpens';
|
|
12
12
|
import {
|
|
13
13
|
createStarterNotificationSettings,
|
|
@@ -43,45 +43,77 @@ export function starterNotificationTopics(configTarget: string): readonly Notifi
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
* The game backend's notifications surface the starter uses: the runtime
|
|
48
|
-
* (notifications RFC "Runtime"), which composes the target's provider with
|
|
49
|
-
* the backend that keeps consent records, endpoint registrations, server
|
|
50
|
-
* reminders, and notices; and the signed-in player's consent records (the
|
|
51
|
-
* game-services Worker's `GET /consent`), which the settings screen shows.
|
|
52
|
-
*/
|
|
53
|
-
export interface StarterNotificationBackend {
|
|
54
|
-
readonly runtime: NotificationRuntime;
|
|
55
|
-
listConsent(): Promise<readonly NotificationConsentRecord[]>;
|
|
56
|
-
}
|
|
46
|
+
export type { StarterNotificationBackend } from './notificationBackend';
|
|
57
47
|
|
|
58
48
|
/**
|
|
59
|
-
* The game's notifications backend
|
|
49
|
+
* The game's notifications backend: the notifications runtime over the
|
|
50
|
+
* game-services Worker's notification routes (see `notificationBackend.ts`),
|
|
51
|
+
* when the game has both a game-services URL (`VITE_MPGD_GAME_SERVICES_URL`,
|
|
52
|
+
* as the commerce client uses) and a server-backed session
|
|
53
|
+
* (`setStarterServerSession` in `serverSession.ts`), and the target exposes
|
|
54
|
+
* a notifications provider. Otherwise `undefined`: the notification settings
|
|
55
|
+
* screen then asks only for the OS or host permission and shows no
|
|
56
|
+
* reminder, advertising, or night switch, because nothing could record them.
|
|
60
57
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
58
|
+
* Reddit gets no Worker backend: its notifications backend is the Devvit
|
|
59
|
+
* server, which keeps the opt-in, the records, and the sends. The Worker
|
|
60
|
+
* serves the Android, iOS, and Apps in Toss providers and binds a device to
|
|
61
|
+
* the session its `Authorization` header names, through its
|
|
62
|
+
* `GAME_SERVICES_NOTIFICATIONS_AUTH` binding to your identity service
|
|
63
|
+
* (docs/CLOUDFLARE_WORKER_DEPLOY.md, "Sessions"). Call this through the
|
|
64
|
+
* settings model or `subscribeStarterNotificationOpens`, which keep one
|
|
65
|
+
* runtime per gateway: a second call starts a second runtime on the same
|
|
66
|
+
* provider.
|
|
68
67
|
*/
|
|
69
68
|
export function createStarterNotificationBackend(
|
|
70
|
-
|
|
69
|
+
platform: TargetConfiguredGateway,
|
|
71
70
|
): StarterNotificationBackend | undefined {
|
|
72
|
-
return
|
|
71
|
+
return createStarterWorkerNotificationBackend(platform, {
|
|
72
|
+
baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL,
|
|
73
|
+
mode: import.meta.env.MODE,
|
|
74
|
+
session: resolveStarterServerSession(),
|
|
75
|
+
topics: starterNotificationTopics(platform.configTarget),
|
|
76
|
+
defaultLocale: starterNotificationCatalog.defaultLocale,
|
|
77
|
+
log: (message, error) => warnOnce(platform, message, error),
|
|
78
|
+
});
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
|
|
81
|
+
/** Messages already logged per gateway: a screen opened again does not log them again. */
|
|
82
|
+
const warned = new WeakMap<TargetConfiguredGateway, Set<string>>();
|
|
83
|
+
function warnOnce(platform: TargetConfiguredGateway, message: string, error?: unknown): void {
|
|
84
|
+
let messages = warned.get(platform);
|
|
85
|
+
if (messages === undefined) {
|
|
86
|
+
messages = new Set();
|
|
87
|
+
warned.set(platform, messages);
|
|
88
|
+
}
|
|
89
|
+
if (!messages.has(message)) {
|
|
90
|
+
messages.add(message);
|
|
91
|
+
if (error === undefined) {
|
|
92
|
+
console.warn(message);
|
|
93
|
+
} else {
|
|
94
|
+
console.error(message, error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const backends = new WeakMap<TargetConfiguredGateway, StarterNotificationBackend>();
|
|
76
100
|
/** Per gateway, for the process: agreements given on a host that gives no read. */
|
|
77
|
-
const agreements = new WeakMap<
|
|
101
|
+
const agreements = new WeakMap<TargetConfiguredGateway, Set<string>>();
|
|
78
102
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
103
|
+
/**
|
|
104
|
+
* One backend per gateway: a runtime subscribes to its provider once, for the
|
|
105
|
+
* process. A gateway without one yet (no session set) is asked again on the
|
|
106
|
+
* next use.
|
|
107
|
+
*/
|
|
108
|
+
function starterNotificationBackend(platform: TargetConfiguredGateway): StarterNotificationBackend | undefined {
|
|
109
|
+
let backend = backends.get(platform);
|
|
110
|
+
if (backend === undefined) {
|
|
111
|
+
backend = createStarterNotificationBackend(platform);
|
|
112
|
+
if (backend !== undefined) {
|
|
113
|
+
backends.set(platform, backend);
|
|
114
|
+
}
|
|
83
115
|
}
|
|
84
|
-
return
|
|
116
|
+
return backend;
|
|
85
117
|
}
|
|
86
118
|
|
|
87
119
|
/** The settings screen's model for this gateway and locale. */
|
|
@@ -121,7 +153,7 @@ export function createStarterNotificationSettingsModel(
|
|
|
121
153
|
* the game has one, else the provider. Returns the unsubscribe.
|
|
122
154
|
*/
|
|
123
155
|
export function subscribeStarterNotificationOpens(
|
|
124
|
-
platform:
|
|
156
|
+
platform: TargetConfiguredGateway,
|
|
125
157
|
routes: NotificationOpenRoutes,
|
|
126
158
|
): () => void {
|
|
127
159
|
const source = starterNotificationBackend(platform)?.runtime ?? platform.notificationsV2;
|
|
@@ -16,8 +16,13 @@ export const notificationSettingsSceneKey = 'NotificationSettingsScene';
|
|
|
16
16
|
|
|
17
17
|
const textStyle = { color: '#d6dee8', fontFamily: 'Arial, sans-serif', fontSize: '18px' } as const;
|
|
18
18
|
const buttonStyle = { ...textStyle, color: '#5eead4' } as const;
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
19
|
+
/** List rows and the outcome strip share this column (x=60..900) on the 960x540 game. */
|
|
20
|
+
const listTextWidth = 840;
|
|
21
|
+
/** Below the title. The outcome strip grows downward from here. */
|
|
22
|
+
const outcomeTop = 68;
|
|
23
|
+
/** The list ends above the Back button. */
|
|
24
|
+
const listBottom = 480;
|
|
25
|
+
const minListTop = 110;
|
|
21
26
|
|
|
22
27
|
/**
|
|
23
28
|
* Routes notification opens: one whose route is the settings screen (the
|
|
@@ -108,10 +113,17 @@ export class NotificationSettingsScene extends Phaser.Scene {
|
|
|
108
113
|
this.scene.start('LobbyScene');
|
|
109
114
|
}).setOrigin(0.5);
|
|
110
115
|
this.outcome = this.add
|
|
111
|
-
.text(480,
|
|
112
|
-
|
|
116
|
+
.text(480, outcomeTop, '', {
|
|
117
|
+
...textStyle,
|
|
118
|
+
align: 'center',
|
|
119
|
+
color: '#f59e0b',
|
|
120
|
+
fontSize: '16px',
|
|
121
|
+
wordWrap: { width: listTextWidth },
|
|
122
|
+
})
|
|
123
|
+
.setOrigin(0.5, 0);
|
|
113
124
|
this.content = this.add.container(0, 0);
|
|
114
|
-
this.listCamera = this.cameras.add(0,
|
|
125
|
+
this.listCamera = this.cameras.add(0, minListTop, 960, listBottom - minListTop);
|
|
126
|
+
this.placeListBelowOutcome();
|
|
115
127
|
this.listCamera.ignore([title, back, this.outcome]);
|
|
116
128
|
this.cameras.main.ignore(this.content);
|
|
117
129
|
this.enableScroll();
|
|
@@ -136,9 +148,15 @@ export class NotificationSettingsScene extends Phaser.Scene {
|
|
|
136
148
|
});
|
|
137
149
|
}
|
|
138
150
|
|
|
151
|
+
private placeListBelowOutcome(): void {
|
|
152
|
+
const top = Math.max(minListTop, Math.ceil(this.outcome.y + this.outcome.height) + 8);
|
|
153
|
+
this.listCamera.setPosition(0, top);
|
|
154
|
+
this.listCamera.setSize(960, Math.max(0, listBottom - top));
|
|
155
|
+
}
|
|
156
|
+
|
|
139
157
|
private scrollTo(scrollY: number): void {
|
|
140
158
|
const height = this.content.getBounds().height;
|
|
141
|
-
this.listCamera.scrollY = Phaser.Math.Clamp(scrollY, 0, Math.max(0, height -
|
|
159
|
+
this.listCamera.scrollY = Phaser.Math.Clamp(scrollY, 0, Math.max(0, height - this.listCamera.height));
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
private async apply(visit: number, action: () => Promise<StarterNotificationSettingsView>): Promise<void> {
|
|
@@ -172,7 +190,7 @@ export class NotificationSettingsScene extends Phaser.Scene {
|
|
|
172
190
|
const visit = this.visit;
|
|
173
191
|
let y = 0;
|
|
174
192
|
const line = (value: string, style: Phaser.Types.GameObjects.Text.TextStyle = textStyle) => {
|
|
175
|
-
const object = this.add.text(60, y, value, { ...style, wordWrap: { width:
|
|
193
|
+
const object = this.add.text(60, y, value, { ...style, wordWrap: { width: listTextWidth } });
|
|
176
194
|
this.content.add(object);
|
|
177
195
|
y += object.height + 10;
|
|
178
196
|
};
|
|
@@ -186,6 +204,7 @@ export class NotificationSettingsScene extends Phaser.Scene {
|
|
|
186
204
|
|
|
187
205
|
// The outcome of the last tap stays in view above the list, wherever it is scrolled.
|
|
188
206
|
this.outcome.setText(view.message ?? '');
|
|
207
|
+
this.placeListBelowOutcome();
|
|
189
208
|
if (!view.available) {
|
|
190
209
|
if (view.message === undefined) {
|
|
191
210
|
line(text.unavailable);
|
|
@@ -15,6 +15,7 @@ import type { PurchasePresentationEvent } from '@mpgd/game-runtime/ads';
|
|
|
15
15
|
|
|
16
16
|
import { resolveStarterCommerceProducts } from './commerceProducts';
|
|
17
17
|
import { createStorageMonetizationOperationStore } from './monetizationJournal';
|
|
18
|
+
import { resolveStarterServerSession } from './serverSession';
|
|
18
19
|
|
|
19
20
|
const productCatalog = productCatalogJson as ProductCatalog;
|
|
20
21
|
|
|
@@ -47,6 +48,7 @@ export function createStarterGameServices(input: {
|
|
|
47
48
|
readonly analytics?: AnalyticsSink;
|
|
48
49
|
readonly analyticsSessionId?: string;
|
|
49
50
|
}): StarterGameServices {
|
|
51
|
+
const session = resolveStarterServerSession({ once: true });
|
|
50
52
|
return createGameServicesRuntime({
|
|
51
53
|
gateway: input.gateway,
|
|
52
54
|
playerId: input.playerId,
|
|
@@ -64,5 +66,6 @@ export function createStarterGameServices(input: {
|
|
|
64
66
|
? {}
|
|
65
67
|
: { baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL }),
|
|
66
68
|
transport: resolveGameServicesTransport(import.meta.env.VITE_MPGD_GAME_SERVICES_TRANSPORT),
|
|
69
|
+
...(session === undefined ? {} : { getHeaders: () => session.getHeaders() }),
|
|
67
70
|
});
|
|
68
71
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { GameServicesHeaderResolver } from '@mpgd/game-services';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The game's server-backed session: who is signed in to the game backend,
|
|
5
|
+
* and the `Authorization` header for that session. The starter has none, so
|
|
6
|
+
* `resolveStarterServerSession` returns `undefined`. Set yours with
|
|
7
|
+
* `setStarterServerSession`, over your own session holder and identity
|
|
8
|
+
* service. The commerce client then sends its header. In a game created with
|
|
9
|
+
* `--providers notifications`, the notifications runtime uses the same
|
|
10
|
+
* session and header for the game-services Worker's notification routes.
|
|
11
|
+
*
|
|
12
|
+
* `sessionId` must be the stable session-family id: the `sessionId` your
|
|
13
|
+
* identity service answers to the Worker's `authenticateNotificationSession`
|
|
14
|
+
* for this session's header, the same across token refreshes. Do not pass a
|
|
15
|
+
* per-token or rotating id: a `GuestSessionCoordinator` view's `sessionId`
|
|
16
|
+
* may rotate on refresh, so it is not that id.
|
|
17
|
+
*/
|
|
18
|
+
export interface StarterServerSession {
|
|
19
|
+
/**
|
|
20
|
+
* The signed-in player and session, or `undefined` while signed out.
|
|
21
|
+
* `sessionId` is the stable session-family id (see above).
|
|
22
|
+
*/
|
|
23
|
+
current(): { readonly playerId: string; readonly sessionId: string } | undefined;
|
|
24
|
+
/** Calls `listener` when a session becomes current (sign-in, restore, account switch); returns the unsubscribe. */
|
|
25
|
+
onAuthenticated(
|
|
26
|
+
listener: (session: { readonly playerId: string; readonly sessionId: string }) => void,
|
|
27
|
+
): () => void;
|
|
28
|
+
/** The current session's request headers, such as `{ authorization: 'Bearer …' }`. */
|
|
29
|
+
readonly getHeaders: GameServicesHeaderResolver;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let serverSession: StarterServerSession | undefined;
|
|
33
|
+
/** Whether the commerce client read the session already: it reads it once. */
|
|
34
|
+
let readOnce = false;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The game's server-backed session; `undefined` until the game sets one with
|
|
38
|
+
* `setStarterServerSession`. `once` marks a reader that keeps what it reads
|
|
39
|
+
* (the commerce client), so a later change warns.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveStarterServerSession(
|
|
42
|
+
options: { readonly once?: boolean } = {},
|
|
43
|
+
): StarterServerSession | undefined {
|
|
44
|
+
if (options.once === true) {
|
|
45
|
+
readOnce = true;
|
|
46
|
+
}
|
|
47
|
+
return serverSession;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sets the game's server-backed session. Call it at the top of `main.ts`,
|
|
52
|
+
* before `bootstrap()`: `createStarterGameServices` reads it once, and the
|
|
53
|
+
* notification settings and opens read it until a backend exists. Its
|
|
54
|
+
* `current()` may answer `undefined` until the player signs in. A session
|
|
55
|
+
* set after the commerce client was created does not reach that client,
|
|
56
|
+
* which this warns about.
|
|
57
|
+
*/
|
|
58
|
+
export function setStarterServerSession(session: StarterServerSession | undefined): void {
|
|
59
|
+
if (readOnce && session !== undefined && session !== serverSession) {
|
|
60
|
+
console.warn(
|
|
61
|
+
'[game-services] setStarterServerSession ran after the commerce client was created; '
|
|
62
|
+
+ "it does not send this session's header. Call it before bootstrap().",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
serverSession = session;
|
|
66
|
+
}
|