@mpgd/cli 0.56.0 → 0.57.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.
@@ -64,6 +64,15 @@ const commonEdits = {
64
64
  'src/platform/buildGateways/capacitorAndroid.ts': [gatewayImport, gatewayProviders('android')],
65
65
  'src/platform/buildGateways/capacitorIos.ts': [gatewayImport, gatewayProviders('ios')],
66
66
  'src/platform/buildGateways/reddit.ts': [
67
+ {
68
+ anchor: ' createDevvitCommerceProducts,\n createDevvitPlatformGateway,\n',
69
+ replacement: [
70
+ ' createDevvitCommerceProducts,',
71
+ ' createDevvitNotificationsBackend,',
72
+ ' createDevvitPlatformGateway,',
73
+ '',
74
+ ].join('\n'),
75
+ },
67
76
  {
68
77
  anchor: "import { createDevvitWebPaymentsClient } from '@mpgd/adapter-devvit/web';\n",
69
78
  replacement: [
@@ -77,6 +86,7 @@ const commonEdits = {
77
86
  {
78
87
  anchor: "import type { RuntimeConfig } from '../runtimeDetector';\n",
79
88
  replacement: [
89
+ "import { setStarterHostedNotificationBackend } from '../notificationBackend';",
80
90
  "import { starterNotificationTopics } from '../notifications';",
81
91
  "import type { RuntimeConfig } from '../runtimeDetector';",
82
92
  '',
@@ -102,7 +112,14 @@ const commonEdits = {
102
112
  ' runtime: RuntimeConfig,',
103
113
  "): Pick<Parameters<typeof createDevvitPlatformGateway>[0], 'notifications'> {",
104
114
  ' const topics = starterNotificationTopics(runtime.configTarget).map((topic) => topic.id);',
105
- ' return topics.length === 0 ? {} : { notifications: { topics } };',
115
+ ' if (topics.length === 0) {',
116
+ ' return {};',
117
+ ' }',
118
+ " // The runtime's backend is the Devvit server (consent records, reminders, notices for the",
119
+ ' // logged-in Reddit user), over the same server bridge as the gateway; created on first use.',
120
+ ' setStarterHostedNotificationBackend(runtime.configTarget, () =>',
121
+ ' createDevvitNotificationsBackend({ appVersion: runtime.appVersion, buildId: runtime.buildId }));',
122
+ ' return { notifications: { topics } };',
106
123
  '}',
107
124
  '',
108
125
  'function createCommerceOptions(): DevvitGatewayCommerceOptions | undefined {',
@@ -139,6 +156,7 @@ const commonEdits = {
139
156
  ' createDevvitCommerceBridgeProducts,',
140
157
  ' createDevvitCommerceV2BridgeHandler,',
141
158
  ' createDevvitNotificationService,',
159
+ ' createDevvitNotificationsBackendBridgeHandler,',
142
160
  ' createDevvitNotificationsV2BridgeHandler,',
143
161
  "} from '@mpgd/adapter-devvit/server';",
144
162
  '',
@@ -182,7 +200,7 @@ const commonEdits = {
182
200
  ' */',
183
201
  'const notificationTopicCatalog = notificationTopicsJson as NotificationTopicCatalog;',
184
202
  'const redditNotificationTopics = readRedditNotificationTopics();',
185
- "const notificationService: Parameters<typeof handleNotificationTask>[0]['notifications'] =",
203
+ "const notificationService: ReturnType<typeof createDevvitNotificationService>['service'] | undefined =",
186
204
  ' redditNotificationTopics.length === 0 ? undefined : createNotificationService();',
187
205
  '// The opt-in is served only with the service that sends; a refused catalog turns both off.',
188
206
  'const notificationsBridge = notificationService === undefined',
@@ -192,6 +210,10 @@ const commonEdits = {
192
210
  ' currentUserId: currentPlayerId,',
193
211
  ' topics: redditNotificationTopics,',
194
212
  ' });',
213
+ "// The webview runtime's backend: consent records, reminders, and notices for the request's user.",
214
+ 'const notificationsBackend = notificationService === undefined',
215
+ ' ? undefined',
216
+ ' : createDevvitNotificationsBackendBridgeHandler({ service: notificationService, context: () => context });',
195
217
  'const handleBridgeRequest = createDevvitBridgeHandler({',
196
218
  ' redis,',
197
219
  ' currentPlayerId,',
@@ -199,6 +221,7 @@ const commonEdits = {
199
221
  ' storageKeyNamespace: gameName,',
200
222
  ' ...(commerce === undefined ? {} : { commerce }),',
201
223
  ' ...(notificationsBridge === undefined ? {} : { notifications: notificationsBridge }),',
224
+ ' ...(notificationsBackend === undefined ? {} : { notificationsBackend }),',
202
225
  '});',
203
226
  '',
204
227
  ].join('\n'),
@@ -230,7 +253,7 @@ const commonEdits = {
230
253
  ' * deployment the service refuses turns notifications off, never the game',
231
254
  ' * server.',
232
255
  ' */',
233
- "function createNotificationService(): Parameters<typeof handleNotificationTask>[0]['notifications'] {",
256
+ "function createNotificationService(): ReturnType<typeof createDevvitNotificationService>['service'] | undefined {",
234
257
  ' try {',
235
258
  ' return createDevvitNotificationService({',
236
259
  ' redis,',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,10 +70,10 @@
70
70
  "parse5": "7.3.0",
71
71
  "postcss": "8.5.28",
72
72
  "postcss-value-parser": "4.2.0",
73
- "@mpgd/catalog": "0.10.7",
73
+ "@mpgd/catalog": "0.10.8",
74
+ "@mpgd/platform": "0.28.0",
74
75
  "@mpgd/phaser-assets": "0.9.1",
75
- "@mpgd/target-config": "0.23.7",
76
- "@mpgd/platform": "0.27.2"
76
+ "@mpgd/target-config": "0.23.8"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@types/node": "^24.0.0",
@@ -81,19 +81,19 @@
81
81
  "@types/yauzl": "3.4.0",
82
82
  "ttsc": "0.32.1",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-ait": "0.20.4",
85
- "@mpgd/adapter-browser": "0.11.7",
86
- "@mpgd/adapter-capacitor": "0.12.4",
87
- "@mpgd/adapter-devvit": "0.13.6",
88
- "@mpgd/adapter-verse8": "0.7.8",
89
- "@mpgd/analytics": "0.5.0",
90
- "@mpgd/bridge": "0.15.0",
91
- "@mpgd/capacitor-play-billing": "0.3.8",
92
- "@mpgd/capacitor-storekit": "0.3.8",
93
- "@mpgd/game-runtime": "0.8.1",
94
- "@mpgd/i18n": "0.6.21",
95
- "@mpgd/game-services": "0.28.0",
96
- "@mpgd/lite-shell": "0.1.0"
84
+ "@mpgd/adapter-ait": "0.20.5",
85
+ "@mpgd/adapter-browser": "0.11.8",
86
+ "@mpgd/adapter-capacitor": "0.12.5",
87
+ "@mpgd/adapter-devvit": "0.14.0",
88
+ "@mpgd/adapter-verse8": "0.7.9",
89
+ "@mpgd/capacitor-play-billing": "0.3.9",
90
+ "@mpgd/game-runtime": "0.8.2",
91
+ "@mpgd/bridge": "0.16.0",
92
+ "@mpgd/capacitor-storekit": "0.3.9",
93
+ "@mpgd/analytics": "0.5.1",
94
+ "@mpgd/game-services": "0.28.1",
95
+ "@mpgd/lite-shell": "0.1.0",
96
+ "@mpgd/i18n": "0.6.22"
97
97
  },
98
98
  "main": "./dist/index.js",
99
99
  "types": "./dist/index.d.ts",
@@ -104,6 +104,22 @@ game's notifications backend is its Devvit server, which keeps the opt-in,
104
104
  the records, and the sends, and the Worker serves no `reddit` target. The
105
105
  Worker serves the Android, iOS, and Apps in Toss providers.
106
106
 
107
+ On Reddit the runtime runs over the Devvit server instead:
108
+ `src/platform/buildGateways/reddit.ts` sets
109
+ `createDevvitNotificationsBackend` as the hosted backend
110
+ (`setStarterHostedNotificationBackend`), and the generated Devvit server
111
+ serves it with `createDevvitNotificationsBackendBridgeHandler`. No session
112
+ is needed: the server answers for the logged-in Reddit user, and a
113
+ logged-out player is asked to log in first. Once Reddit approves the app
114
+ and `integrations.notifications` is `available`, the settings screen shows
115
+ the reminder switches there; advertising and night consent stay off,
116
+ because Devvit delivers `service` and `requested` topics only. A reminder
117
+ the game schedules on Reddit (`runtime.reminders.set`) needs a post or
118
+ comment to open: pass `link` (the post id, `context.postId` from
119
+ `@devvit/web/client`), or give the topic's `reddit` mapping a default
120
+ `link`; the `mpgd-notifications-dispatch` task sends it through Devvit when
121
+ it is due.
122
+
107
123
  The settings screen shows each
108
124
  switch from the latest record, read every time it opens and after every
109
125
  change, so a choice made on another device shows. A tap reads the records
@@ -16,6 +16,7 @@ import type {
16
16
  import {
17
17
  createNotificationRuntime,
18
18
  createNotificationRuntimeStorage,
19
+ type NotificationRuntimeBackend,
19
20
  } from '@mpgd/platform/notifications-runtime';
20
21
  import type { TargetConfiguredGateway } from '@mpgd/target-config';
21
22
 
@@ -56,8 +57,8 @@ export interface StarterWorkerNotificationInput {
56
57
  * Worker. A Reddit game's backend is its Devvit server: the opt-in, the
57
58
  * consent records, and the sends live there, and the Worker serves no
58
59
  * `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.
60
+ * header to a backend that cannot use it, so Reddit gets the hosted backend
61
+ * below instead (`createStarterHostedNotificationBackend`).
61
62
  */
62
63
  const targetsWithoutWorkerBackend: ReadonlySet<string> = new Set(['reddit']);
63
64
 
@@ -140,6 +141,114 @@ export function createStarterWorkerNotificationBackend(
140
141
  }
141
142
  }
142
143
 
144
+ /**
145
+ * A backend the target's own server hosts, set by the target's gateway
146
+ * builder for its config target: on Reddit, `createDevvitNotificationsBackend`
147
+ * from `@mpgd/adapter-devvit` over the Devvit server bridge
148
+ * (`buildGateways/reddit.ts`). The server answers for the request's
149
+ * logged-in Reddit user, so the game sets no session of its own. The
150
+ * backend is created on first use, so a game whose target exposes no
151
+ * notifications provider (Reddit's gated beta before approval) sends
152
+ * nothing.
153
+ */
154
+ let hostedBackend:
155
+ | {
156
+ readonly configTarget: string;
157
+ readonly create: () => NotificationRuntimeBackend;
158
+ created?: NotificationRuntimeBackend;
159
+ /** The factory threw once: notifications stay off, and the failure is logged once. */
160
+ failed?: true;
161
+ }
162
+ | undefined;
163
+
164
+ /**
165
+ * Sets the backend `configTarget`'s own server hosts, as a factory called on
166
+ * first use; `undefined` clears it.
167
+ */
168
+ export function setStarterHostedNotificationBackend(
169
+ configTarget: string,
170
+ create: (() => NotificationRuntimeBackend) | undefined,
171
+ ): void {
172
+ hostedBackend = create === undefined ? undefined : { configTarget, create };
173
+ }
174
+
175
+ /**
176
+ * The backend `configTarget`'s own server hosts, created on the first call;
177
+ * `undefined` when none was set for this config target, or when its factory
178
+ * threw (logged once; not called again until a new factory is set).
179
+ */
180
+ export function resolveStarterHostedNotificationBackend(configTarget: string): NotificationRuntimeBackend | undefined {
181
+ if (hostedBackend === undefined || hostedBackend.configTarget !== configTarget) {
182
+ return undefined;
183
+ }
184
+ if (hostedBackend.failed === true) {
185
+ return undefined;
186
+ }
187
+ try {
188
+ hostedBackend.created ??= hostedBackend.create();
189
+ } catch (error) {
190
+ // A backend that cannot be created turns notifications off, never the game.
191
+ hostedBackend.failed = true;
192
+ console.error('[notifications] the hosted notifications backend could not be created.', error);
193
+ return undefined;
194
+ }
195
+ return hostedBackend.created;
196
+ }
197
+
198
+ export interface StarterHostedNotificationInput {
199
+ readonly backend: NotificationRuntimeBackend | undefined;
200
+ /** This target's topics, each with its mapping. */
201
+ readonly topics: readonly NotificationTopicDeclaration[];
202
+ readonly defaultLocale: string;
203
+ /** Logs a message, once per gateway and message. */
204
+ readonly log: (message: string, error?: unknown) => void;
205
+ }
206
+
207
+ /**
208
+ * The notifications runtime over a backend the target's own server hosts
209
+ * (Reddit: the Devvit server's notifications service), when the target
210
+ * exposes a notifications provider and its gateway builder set the backend.
211
+ * Otherwise `undefined`.
212
+ *
213
+ * The runtime keeps its registration state in the platform storage and
214
+ * starts here. It learns the player from the backend, which reads the
215
+ * session the server answers, so the settings screen shows the reminder
216
+ * switches once a logged-in Reddit user is known; a logged-out viewer is
217
+ * asked to log in first. It runs without a registry entry comparison: the
218
+ * provider and the server's service come from the same adapter and
219
+ * deployment, and the entry lives in server code the webview does not load.
220
+ */
221
+ export function createStarterHostedNotificationBackend(
222
+ platform: TargetConfiguredGateway,
223
+ input: StarterHostedNotificationInput,
224
+ ): StarterNotificationBackend | undefined {
225
+ const provider = platform.notificationsV2;
226
+ const { backend } = input;
227
+ if (provider === undefined || backend === undefined) {
228
+ return undefined;
229
+ }
230
+ try {
231
+ const runtime = createNotificationRuntime({
232
+ provider,
233
+ backend,
234
+ catalog: input.topics,
235
+ defaultLocale: input.defaultLocale,
236
+ storage: createNotificationRuntimeStorage({ storage: platform.storage }),
237
+ onError(error) {
238
+ console.error('[notifications]', error);
239
+ },
240
+ });
241
+ runtime.start().catch((error: unknown) => {
242
+ console.error('[notifications] the runtime could not start.', error);
243
+ });
244
+ return { runtime, listConsent: () => backend.listConsent() };
245
+ } catch (error) {
246
+ // A configuration the runtime refuses turns notifications off, never the game.
247
+ input.log('[notifications] the notifications backend could not be created.', error);
248
+ return undefined;
249
+ }
250
+ }
251
+
143
252
  /**
144
253
  * Whether the game-services URL may carry the session's credentials: any
145
254
  * absolute http(s) URL outside production builds, and in production the
@@ -4,7 +4,9 @@ import type { TargetConfiguredGateway } from '@mpgd/target-config';
4
4
 
5
5
  import notificationTopicsJson from '../../notifications/topics.json';
6
6
  import {
7
+ createStarterHostedNotificationBackend,
7
8
  createStarterWorkerNotificationBackend,
9
+ resolveStarterHostedNotificationBackend,
8
10
  type StarterNotificationBackend,
9
11
  } from './notificationBackend';
10
12
  import { resolveStarterServerSession } from './serverSession';
@@ -56,7 +58,11 @@ export type { StarterNotificationBackend } from './notificationBackend';
56
58
  * reminder, advertising, or night switch, because nothing could record them.
57
59
  *
58
60
  * 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
61
+ * server, which keeps the opt-in, the records, and the sends. The runtime
62
+ * there runs over the backend `buildGateways/reddit.ts` sets
63
+ * (`createDevvitNotificationsBackend`, through
64
+ * `setStarterHostedNotificationBackend`), which needs no game session: the
65
+ * Devvit server answers for the logged-in Reddit user. The Worker
60
66
  * serves the Android, iOS, and Apps in Toss providers and binds a device to
61
67
  * the session its `Authorization` header names, through its
62
68
  * `GAME_SERVICES_NOTIFICATIONS_AUTH` binding to your identity service
@@ -68,6 +74,18 @@ export type { StarterNotificationBackend } from './notificationBackend';
68
74
  export function createStarterNotificationBackend(
69
75
  platform: TargetConfiguredGateway,
70
76
  ): StarterNotificationBackend | undefined {
77
+ // Only a hosted backend set for this config target; other targets keep the Worker path.
78
+ const hosted = platform.notificationsV2 === undefined
79
+ ? undefined
80
+ : resolveStarterHostedNotificationBackend(platform.configTarget);
81
+ if (hosted !== undefined) {
82
+ return createStarterHostedNotificationBackend(platform, {
83
+ backend: hosted,
84
+ topics: starterNotificationTopics(platform.configTarget),
85
+ defaultLocale: starterNotificationCatalog.defaultLocale,
86
+ log: (message, error) => warnOnce(platform, message, error),
87
+ });
88
+ }
71
89
  return createStarterWorkerNotificationBackend(platform, {
72
90
  baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL,
73
91
  mode: import.meta.env.MODE,
@@ -3,7 +3,9 @@ import {
3
3
  bridgeStorageLoadProtocol,
4
4
  createBridgeError,
5
5
  createCommerceV2UnavailableBridgeError,
6
+ createNotificationsBackendUnavailableBridgeError,
6
7
  createNotificationsV2UnavailableBridgeError,
8
+ isBridgeNotificationsBackendMethod,
7
9
  isBridgeNotificationsV2Method,
8
10
  type BridgeRequest,
9
11
  type BridgeResponse,
@@ -68,6 +70,16 @@ export interface DevvitBridgeNotificationsHandler {
68
70
  request(input: BridgeRequest): Promise<BridgeResponse>;
69
71
  }
70
72
 
73
+ /**
74
+ * Serves the nine `notifications.backend.*` bridge methods:
75
+ * `createDevvitNotificationsBackendBridgeHandler` from
76
+ * `@mpgd/adapter-devvit/server` over the Devvit notifications service, for
77
+ * the request's logged-in user.
78
+ */
79
+ export interface DevvitBridgeNotificationsBackendHandler {
80
+ request(input: BridgeRequest): Promise<BridgeResponse>;
81
+ }
82
+
71
83
  export interface DevvitBridgeHandlerDependencies {
72
84
  readonly redis: DevvitBridgeRedisLike;
73
85
  /** Authenticated Reddit user ID from the Devvit request context, never from the payload. */
@@ -91,6 +103,14 @@ export interface DevvitBridgeHandlerDependencies {
91
103
  * `unavailable`.
92
104
  */
93
105
  readonly notifications?: DevvitBridgeNotificationsHandler | undefined;
106
+ /**
107
+ * The notifications runtime's backend: consent records, the player's
108
+ * registration, server reminders, and notices, kept by the Devvit
109
+ * notifications service. Absent, every `notifications.backend.*` method
110
+ * answers `NOTIFICATIONS_CONFIGURATION_REQUIRED`, and the webview's
111
+ * runtime runs without a backend.
112
+ */
113
+ readonly notificationsBackend?: DevvitBridgeNotificationsBackendHandler | undefined;
94
114
  readonly warn?: ((message: string) => void) | undefined;
95
115
  }
96
116
 
@@ -129,6 +149,23 @@ export function createDevvitBridgeHandler(
129
149
  );
130
150
  }
131
151
  }
152
+ // The runtime's backend calls, kept apart from the provider methods above.
153
+ if (isBridgeNotificationsBackendMethod(input.method)) {
154
+ if (dependencies.notificationsBackend === undefined) {
155
+ return createNotificationsBackendUnavailableBridgeError(input, 'Devvit server bridge');
156
+ }
157
+ try {
158
+ return await dependencies.notificationsBackend.request(input);
159
+ } catch (error) {
160
+ warn(`devvit notifications ${input.method} failed: ${errorMessage(error)}`);
161
+ return createBridgeError(
162
+ input.id,
163
+ 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE',
164
+ `${input.method} failed on the Devvit server.`,
165
+ true,
166
+ );
167
+ }
168
+ }
132
169
  switch (input.method) {
133
170
  case 'runtime.getCapabilities':
134
171
  return ok(input, {