@mpgd/cli 0.54.1 → 0.56.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.
@@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { DOMParser } from '@xmldom/xmldom';
7
7
  import { satisfies, valid, validRange } from 'semver';
8
- import { capacitorGatewayRegistersNotifications, conflictingPushPlugin, notificationsCapacitorGatewayModules, notificationsCapacitorGateways, notificationsCapacitorSupportFiles, notificationsCapacitorTemplateSupportFiles, notificationsGameDependencies, notificationsMessagingFirebaseRange, notificationsMessagingPlugin, notificationsProviderId, notificationsShellPlugins, wireCapacitorGatewayNotifications, } from './notifications-starter.js';
8
+ import { capacitorGatewayRegistersNotifications, conflictingPushPlugin, notificationsCapacitorGatewayModules, notificationsCapacitorGateways, notificationsCapacitorSupportFiles, notificationsCapacitorTemplateSupportFiles, notificationsCapacitorTemplateSupportImporters, notificationsGameDependencies, notificationsMessagingFirebaseRange, notificationsMessagingPlugin, notificationsProviderId, notificationsShellPlugins, sourceReferencesRelativeModule, wireCapacitorGatewayNotifications, } from './notifications-starter.js';
9
9
  import { isNonPublicServiceHostname } from './production-target-readiness.js';
10
10
  import { assertAndroidSettingsAppProject, assertAndroidSettingsNoAppRemap, assertIosReleaseInfoPlistExpansion, assertIosReleasePackagingSettings, assertIosReleaseProductName, assertNativeShellIdentity, hasAndroidDisplayNameResourceOverride, hasAndroidManifestSourceSetOverride, hasAndroidResourceSourceSetOverride, hasGradleIdentityMutation, hasGradleTaskAction, maskGradleStrings, readIosAppTargetId, readIosReleaseInfoPlist, stripGradleComments, } from './native-shell-identity.js';
11
11
  const shellPath = 'apps/mobile-capacitor';
@@ -2796,9 +2796,29 @@ function planNotificationsProvider(input) {
2796
2796
  // Base-template modules they import, missing from a game an earlier release created.
2797
2797
  if (input.templateRoot !== undefined) {
2798
2798
  for (const file of notificationsCapacitorTemplateSupportFiles) {
2799
- if (readExisting(safeDestination(gameRoot, file)) === undefined) {
2800
- files.push({ path: file, content: starterFile(input.templateRoot, file) });
2799
+ if (readExisting(safeDestination(gameRoot, file)) !== undefined) {
2800
+ continue;
2801
2801
  }
2802
+ const imported = notificationsCapacitorTemplateSupportImporters[file];
2803
+ const importerSource = imported === undefined
2804
+ ? undefined
2805
+ : readExisting(safeDestination(gameRoot, imported.importer));
2806
+ // A game whose own importer does not import the module would never use it.
2807
+ if (imported !== undefined && importerSource !== undefined
2808
+ && !sourceReferencesRelativeModule(importerSource, path.posix.basename(file, '.ts'))) {
2809
+ warnings.push(imported.warning);
2810
+ continue;
2811
+ }
2812
+ // The file imports a kit export an older installed package lacks.
2813
+ const required = imported?.requires;
2814
+ if (required !== undefined
2815
+ && installedPackageLacksExport(gameRoot, required.packageName, required.exportName)) {
2816
+ const version = installedPackageVersion(gameRoot, required.packageName);
2817
+ throw new Error(`--providers notifications writes ${file}, which imports ${required.exportName} from `
2818
+ + `${required.packageName}; the game's ${required.packageName} ${version} does not export it. `
2819
+ + "Upgrade the game's kit packages (mpgd kit upgrade) first.");
2820
+ }
2821
+ files.push({ path: file, content: starterFile(input.templateRoot, file) });
2802
2822
  }
2803
2823
  }
2804
2824
  warnings.push('The notification settings screen is not added to an existing game; render one from '
@@ -2905,3 +2925,110 @@ function requireString(value, label) {
2905
2925
  function json(value) {
2906
2926
  return `${JSON.stringify(value, null, 2)}\n`;
2907
2927
  }
2928
+ /** Declaration files the export check reads at most: a guard against a pathological graph. */
2929
+ const maxDeclarationFilesChecked = 500;
2930
+ /** The declaration file names TypeScript pairs with a module specifier. */
2931
+ function declarationCandidates(base) {
2932
+ // A specifier that already names a declaration file is that file.
2933
+ if (/\.d\.[cm]?ts$/u.test(base)) {
2934
+ return [base];
2935
+ }
2936
+ const extension = path.extname(base);
2937
+ const stem = base.slice(0, base.length - extension.length);
2938
+ switch (extension) {
2939
+ case '.js':
2940
+ case '.ts':
2941
+ return [`${stem}.d.ts`];
2942
+ case '.mjs':
2943
+ case '.mts':
2944
+ return [`${stem}.d.mts`];
2945
+ case '.cjs':
2946
+ case '.cts':
2947
+ return [`${stem}.d.cts`];
2948
+ default:
2949
+ return [`${base}.d.ts`, path.join(base, 'index.d.ts')];
2950
+ }
2951
+ }
2952
+ /**
2953
+ * Whether the game's installed `packageName` provably lacks `exportName`:
2954
+ * its root type declarations, and every declaration file they re-export
2955
+ * from through relative specifiers, at any depth, never name it. Anything
2956
+ * the check cannot follow passes, and the game's build then decides, as for
2957
+ * the adapter's settings opener: an uninstalled or unreadable package, a
2958
+ * module it cannot read, or an `export *` from another package.
2959
+ */
2960
+ function installedPackageLacksExport(gameRoot, packageName, exportName) {
2961
+ // Only a plain identifier is searched for; anything else is not proof of absence.
2962
+ if (!/^[A-Za-z_$][\w$]*$/u.test(exportName)) {
2963
+ return false;
2964
+ }
2965
+ const packageRoot = path.join(gameRoot, 'node_modules', packageName);
2966
+ const manifest = readInstalledManifest(gameRoot, packageName);
2967
+ if (manifest === undefined) {
2968
+ return false;
2969
+ }
2970
+ const rootExport = typeof manifest.exports === 'object' && manifest.exports !== null
2971
+ ? manifest.exports['.']
2972
+ : undefined;
2973
+ const rootTypes = typeof rootExport === 'object' && rootExport !== null
2974
+ ? rootExport.types
2975
+ : undefined;
2976
+ const typesPath = typeof rootTypes === 'string' ? rootTypes : manifest.types;
2977
+ if (typeof typesPath !== 'string') {
2978
+ return false;
2979
+ }
2980
+ // Every declaration file the root re-exports from, followed through relative
2981
+ // `export … from` specifiers at any depth; the name is searched as text, so a
2982
+ // named re-export of it counts as well.
2983
+ const relativeFrom = /\bexport\b[^;]*?\bfrom\s*['"](\.{1,2}\/[^'"]+)['"]/gu;
2984
+ const packageStar = /\bexport\s*(?:type\s*)?\*\s*(?:as\s+[\w$]+\s*)?from\s*['"](?!\.{1,2}\/)[^'"]+['"]/u;
2985
+ const nameUsed = new RegExp(`(?<![\\w$])${exportName.replace(/\$/gu, '\\$')}(?![\\w$])`, 'u');
2986
+ const pending = [path.join(packageRoot, typesPath)];
2987
+ const visited = new Set();
2988
+ while (pending.length > 0) {
2989
+ const file = pending.pop() ?? '';
2990
+ if (visited.has(file)) {
2991
+ continue;
2992
+ }
2993
+ visited.add(file);
2994
+ if (visited.size > maxDeclarationFilesChecked) {
2995
+ return false;
2996
+ }
2997
+ let source;
2998
+ try {
2999
+ source = readExisting(file);
3000
+ }
3001
+ catch {
3002
+ source = undefined;
3003
+ }
3004
+ if (source === undefined || packageStar.test(source)) {
3005
+ // A module the check cannot read, or names from another package: do not refuse on a guess.
3006
+ return false;
3007
+ }
3008
+ if (nameUsed.test(source)) {
3009
+ return false;
3010
+ }
3011
+ for (const match of source.matchAll(relativeFrom)) {
3012
+ const candidates = declarationCandidates(path.join(path.dirname(file), match[1] ?? ''));
3013
+ pending.push(candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] ?? file);
3014
+ }
3015
+ }
3016
+ return true;
3017
+ }
3018
+ /** The game's installed `packageName` manifest, or `undefined` when it is missing or unreadable. */
3019
+ function readInstalledManifest(gameRoot, packageName) {
3020
+ try {
3021
+ const source = readExisting(path.join(gameRoot, 'node_modules', packageName, 'package.json'));
3022
+ return source === undefined
3023
+ ? undefined
3024
+ : parseJsonObject(source, `${packageName}/package.json`);
3025
+ }
3026
+ catch {
3027
+ return undefined;
3028
+ }
3029
+ }
3030
+ /** The installed package's version, for a message. */
3031
+ function installedPackageVersion(gameRoot, packageName) {
3032
+ const version = readInstalledManifest(gameRoot, packageName)?.version;
3033
+ return typeof version === 'string' ? version : 'unknown';
3034
+ }
@@ -0,0 +1,14 @@
1
+ export interface DevvitSdkVersion {
2
+ readonly name: string;
3
+ readonly declared: string;
4
+ readonly installed: string;
5
+ readonly latest: string;
6
+ readonly source?: 'adapter';
7
+ }
8
+ export interface CheckDevvitSdkOptions {
9
+ /** Test seam. Production looks up the npm `latest` dist-tag. */
10
+ readonly lookupLatest?: (name: string) => Promise<string>;
11
+ }
12
+ export declare function assertLatestDevvitSdk(entries: readonly DevvitSdkVersion[]): void;
13
+ /** Read-only pre-upload gate. Never install a new SDK between verified build and upload. */
14
+ export declare function checkDevvitSdk(project: string, options?: CheckDevvitSdkOptions): Promise<readonly DevvitSdkVersion[]>;
@@ -0,0 +1,112 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { readFile, realpath } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ const execFile = promisify(execFileCallback);
6
+ const stable = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
7
+ function isFirstPartyDevvitPackage(name) {
8
+ return name === 'devvit' || name.startsWith('@devvit/');
9
+ }
10
+ function devvitPackageNames(dependencies) {
11
+ return Object.keys(dependencies).filter(isFirstPartyDevvitPackage);
12
+ }
13
+ function record(value) {
14
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
15
+ throw new Error('Invalid package metadata.');
16
+ }
17
+ return value;
18
+ }
19
+ async function npmLatestStable(name, cwd) {
20
+ const { stdout } = await execFile(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['view', `${name}@latest`, 'version', '--json', '--registry=https://registry.npmjs.org'], {
21
+ cwd,
22
+ encoding: 'utf8',
23
+ timeout: 20_000,
24
+ maxBuffer: 16_384,
25
+ shell: process.platform === 'win32',
26
+ });
27
+ return requireStableVersion(name, JSON.parse(stdout));
28
+ }
29
+ function requireStableVersion(name, latest) {
30
+ if (typeof latest !== 'string' || !stable.test(latest)) {
31
+ throw new Error('Registry did not return a stable Devvit version: ' + name);
32
+ }
33
+ return latest;
34
+ }
35
+ export function assertLatestDevvitSdk(entries) {
36
+ if (!entries.some((e) => e.name === 'devvit' || e.name === '@devvit/cli') || !entries.some((e) => e.name === '@devvit/web' || e.name === '@devvit/public-api')) {
37
+ throw new Error('Devvit upload needs both an installed CLI and SDK in this project.');
38
+ }
39
+ for (const entry of entries) {
40
+ if (!stable.test(entry.latest) || (entry.source !== 'adapter' && entry.declared !== entry.latest) || entry.installed !== entry.latest) {
41
+ throw new Error(`${entry.name}: declared=${entry.declared}, installed=${entry.installed}, latest=${entry.latest}. Update exact SDK/CLI pins and lockfile, rebuild and test, then retry upload.`);
42
+ }
43
+ }
44
+ }
45
+ /** Read-only pre-upload gate. Never install a new SDK between verified build and upload. */
46
+ export async function checkDevvitSdk(project, options = {}) {
47
+ const manifestPath = path.resolve(project, 'package.json');
48
+ const manifest = record(JSON.parse(await readFile(manifestPath, 'utf8')));
49
+ const dependencies = {
50
+ ...(manifest.dependencies === undefined ? {} : record(manifest.dependencies)),
51
+ ...(manifest.devDependencies === undefined ? {} : record(manifest.devDependencies)),
52
+ };
53
+ async function installedPackage(from, name) {
54
+ let directory = from;
55
+ for (;;) {
56
+ const file = path.join(directory, 'node_modules', name, 'package.json');
57
+ try {
58
+ const metadata = record(JSON.parse(await readFile(file, 'utf8')));
59
+ if (metadata.name !== name || typeof metadata.version !== 'string') {
60
+ throw new Error('Invalid installed package: ' + name);
61
+ }
62
+ return { metadata, directory: path.dirname(await realpath(file)) };
63
+ }
64
+ catch (error) {
65
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
66
+ throw error;
67
+ }
68
+ }
69
+ const parent = path.dirname(directory);
70
+ if (parent === directory) {
71
+ throw new Error('Cannot resolve installed Devvit package: ' + name);
72
+ }
73
+ directory = parent;
74
+ }
75
+ }
76
+ const targetDirectory = path.dirname(manifestPath);
77
+ const subjects = devvitPackageNames(dependencies).map((name) => ({
78
+ name,
79
+ declared: dependencies[name],
80
+ directory: targetDirectory,
81
+ transitive: false,
82
+ }));
83
+ if (Object.hasOwn(dependencies, '@mpgd/adapter-devvit')) {
84
+ const adapter = await installedPackage(targetDirectory, '@mpgd/adapter-devvit');
85
+ const adapterDependencies = record(adapter.metadata.dependencies);
86
+ for (const name of devvitPackageNames(adapterDependencies)) {
87
+ subjects.push({
88
+ name,
89
+ declared: adapterDependencies[name],
90
+ directory: adapter.directory,
91
+ transitive: true,
92
+ });
93
+ }
94
+ }
95
+ const entries = await Promise.all(subjects.map(async ({ name, declared, directory, transitive }) => {
96
+ if (typeof declared !== 'string') {
97
+ throw new Error('Invalid Devvit dependency: ' + name);
98
+ }
99
+ const resolved = await installedPackage(directory, name);
100
+ const installed = resolved.metadata.version;
101
+ if (typeof installed !== 'string') {
102
+ throw new Error('Missing installed Devvit version: ' + name);
103
+ }
104
+ const latest = options.lookupLatest
105
+ ? requireStableVersion(name, await options.lookupLatest(name))
106
+ : await npmLatestStable(name, path.dirname(manifestPath));
107
+ // A consumer may use a reviewed exact override until the adapter release catches up.
108
+ return { name, declared, installed, latest, ...(transitive ? { source: 'adapter' } : {}) };
109
+ }));
110
+ assertLatestDevvitSdk(entries);
111
+ return entries;
112
+ }
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ import { runMicrosoftStorePackageGeneration } from './microsoft-store-package-ge
27
27
  import { defaultMpgdKitPath, initializeMicrosoftStoreStarter, listMicrosoftStoreInitializerTemplateFiles, prepareBaseGameTemplateFile, resolveMicrosoftStoreAdapterDependencyVersion, } from './microsoft-store-starter.js';
28
28
  import { applyNotificationsStarter, notificationsProviderId, readGameCreateProviders, } from './notifications-starter.js';
29
29
  import { applyKitUpgrade, planKitUpgrade } from './kit-upgrade.js';
30
+ import { checkDevvitSdk } from './devvit-sdk-check.js';
30
31
  import { applyToolingMigration, planToolingMigration } from './tooling-migration.js';
31
32
  import { runMicrosoftStoreSubmissionPreflight } from './microsoft-store-submission.js';
32
33
  import { defaultOfflinePlaytestArtifactDir, defaultOfflinePlaytestMaximumBytes, defaultOfflinePlaytestOutputDir, runOfflinePlaytestPackaging, } from './offline-playtest.js';
@@ -2525,6 +2526,18 @@ const kitCommand = defineI18n({
2525
2526
  ko: '현재 mpgd-kit 체크아웃을 점검합니다.',
2526
2527
  }),
2527
2528
  subCommands: {
2529
+ 'devvit-check': defineI18n({
2530
+ name: 'devvit-check',
2531
+ description: 'Require exact installed latest stable Devvit SDK and CLI before upload.',
2532
+ resource: commandResource({ en: 'Check latest stable Devvit SDK before upload.', ko: '업로드 전 Devvit 최신 안정 SDK를 확인합니다.' }),
2533
+ args: { project: { type: 'string', default: '.', description: 'Devvit target directory.' } },
2534
+ run: async (ctx) => {
2535
+ const entries = await checkDevvitSdk(readOptionalString(ctx.values.project) ?? '.');
2536
+ for (const entry of entries) {
2537
+ console.info(entry.name + '@' + entry.installed + ' (latest stable)');
2538
+ }
2539
+ },
2540
+ }),
2528
2541
  upgrade: defineI18n({
2529
2542
  name: 'upgrade',
2530
2543
  description: 'Plan or apply the latest published Kit package versions for a game.',
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitGitSha": "ed8837541cc3c5737c21638a596ba2dc121112da",
2
+ "kitGitSha": "dc28031ea2d85c442c084cd118cdd3fa2cecadba",
3
3
  "kitDirty": false,
4
- "packageVersion": "0.54.1"
4
+ "packageVersion": "0.56.0"
5
5
  }
@@ -6990,9 +6990,6 @@ var releaseBackoffMs = 60 * secondMs;
6990
6990
  // node_modules/.cache/mpgd-native-build/adapters/devvit/src/server/redis-notification-store.js
6991
6991
  var defaultPurgeLimit = Number.POSITIVE_INFINITY;
6992
6992
 
6993
- // node_modules/.cache/mpgd-native-build/adapters/devvit/src/notifications-contract.js
6994
- var devvitHostCodeCharacters = "[A-Za-z0-9_.:-]{1,128}";
6995
-
6996
6993
  // node_modules/.cache/mpgd-native-build/adapters/devvit/src/server/notifications-delivery.js
6997
6994
  var devvitNotificationProviderCodes = Object.freeze({
6998
6995
  deviceRecipient: "DEVVIT_DEVICE_RECIPIENT",
@@ -7023,13 +7020,17 @@ var devvitNotificationSchedulerTasks = Object.freeze({
7023
7020
  })
7024
7021
  });
7025
7022
 
7023
+ // node_modules/.cache/mpgd-native-build/packages/bridge/src/notifications.js
7024
+ var hostCodeCharacters = "[A-Za-z0-9_.:-]{1,128}";
7025
+ var hostCodeValue = new RegExp(`^${hostCodeCharacters}$`, "u");
7026
+ var hostCodeSuffix = new RegExp(`\\(host code (${hostCodeCharacters})\\)$`, "u");
7027
+
7026
7028
  // node_modules/.cache/mpgd-native-build/adapters/devvit/src/server/notifications-bridge.js
7027
7029
  var devvitNotificationHostCodes = Object.freeze({
7028
7030
  optInRefused: "DEVVIT_OPT_IN_REFUSED",
7029
7031
  optOutRefused: "DEVVIT_OPT_OUT_REFUSED",
7030
7032
  pluginFailed: "DEVVIT_NOTIFICATIONS_PLUGIN_FAILED"
7031
7033
  });
7032
- var hostCodeValue = new RegExp(`^${devvitHostCodeCharacters}$`, "u");
7033
7034
 
7034
7035
  // node_modules/.cache/mpgd-native-build/packages/cli/src/target-name.js
7035
7036
  var deploymentTargetNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
@@ -14899,8 +14900,16 @@ var notificationsCapacitorSupportFiles = Object.freeze([
14899
14900
  "docs/NOTIFICATIONS.md"
14900
14901
  ]);
14901
14902
  var notificationsCapacitorTemplateSupportFiles = Object.freeze([
14902
- "src/platform/serverSession.ts"
14903
+ "src/platform/serverSession.ts",
14904
+ "src/platform/developmentServerSession.ts"
14903
14905
  ]);
14906
+ var notificationsCapacitorTemplateSupportImporters = Object.freeze({
14907
+ "src/platform/developmentServerSession.ts": {
14908
+ importer: "src/platform/serverSession.ts",
14909
+ requires: { packageName: "@mpgd/game-services", exportName: "createDevelopmentServerSession" },
14910
+ warning: "src/platform/serverSession.ts predates development notification sessions (VITE_MPGD_DEV_NOTIFICATION_SESSION); to try notifications locally, copy serverSession.ts and developmentServerSession.ts from the starter template."
14911
+ }
14912
+ });
14904
14913
 
14905
14914
  // node_modules/.cache/mpgd-native-build/packages/cli/src/native-shell-identity.js
14906
14915
  function assertAndroidSettingsAppProject(source) {
@@ -61,9 +61,36 @@ export declare const notificationsCapacitorSupportFiles: readonly string[];
61
61
  /**
62
62
  * Files of the base game template the notifications modules import, which a
63
63
  * game created by an earlier CLI release may not have: written from the
64
- * template when missing, kept when the game has one.
64
+ * template when missing, kept when the game has one. A file listed in
65
+ * `notificationsCapacitorTemplateSupportImporters` is written only together
66
+ * with its importer.
65
67
  */
66
- export declare const notificationsCapacitorTemplateSupportFiles: readonly string[];
68
+ export declare const notificationsCapacitorTemplateSupportFiles: readonly ["src/platform/serverSession.ts", "src/platform/developmentServerSession.ts"];
69
+ /**
70
+ * Template support files only one other support file imports: the importer,
71
+ * and the warning for a game that keeps its own importer without importing
72
+ * the file (an earlier `serverSession.ts`). Such a game would never import the
73
+ * file, and its `@mpgd/game-services` may predate what the file imports, so
74
+ * the file is not written for it. An importer that imports it gets it back.
75
+ */
76
+ export declare const notificationsCapacitorTemplateSupportImporters: Readonly<Partial<Record<(typeof notificationsCapacitorTemplateSupportFiles)[number], {
77
+ readonly importer: (typeof notificationsCapacitorTemplateSupportFiles)[number];
78
+ readonly warning: string;
79
+ /**
80
+ * A kit export the file imports, which the game's installed package
81
+ * must have before the file is written (an older package lacks it).
82
+ */
83
+ readonly requires?: {
84
+ readonly packageName: string;
85
+ readonly exportName: string;
86
+ };
87
+ }>>>;
88
+ /**
89
+ * Whether `source` names the module `./<name>` (with or without `.js` or
90
+ * `.ts`) as a string outside comments: any static, side-effect, or dynamic
91
+ * import or re-export of it. A commented-out import does not count.
92
+ */
93
+ export declare function sourceReferencesRelativeModule(source: string, name: string): boolean;
67
94
  /** Whether a Capacitor gateway already registers a notifications provider. */
68
95
  export declare function capacitorGatewayRegistersNotifications(source: string): boolean;
69
96
  /**
@@ -479,11 +479,47 @@ export const notificationsCapacitorSupportFiles = Object.freeze([
479
479
  /**
480
480
  * Files of the base game template the notifications modules import, which a
481
481
  * game created by an earlier CLI release may not have: written from the
482
- * template when missing, kept when the game has one.
482
+ * template when missing, kept when the game has one. A file listed in
483
+ * `notificationsCapacitorTemplateSupportImporters` is written only together
484
+ * with its importer.
483
485
  */
484
486
  export const notificationsCapacitorTemplateSupportFiles = Object.freeze([
485
487
  'src/platform/serverSession.ts',
488
+ 'src/platform/developmentServerSession.ts',
486
489
  ]);
490
+ /**
491
+ * Template support files only one other support file imports: the importer,
492
+ * and the warning for a game that keeps its own importer without importing
493
+ * the file (an earlier `serverSession.ts`). Such a game would never import the
494
+ * file, and its `@mpgd/game-services` may predate what the file imports, so
495
+ * the file is not written for it. An importer that imports it gets it back.
496
+ */
497
+ export const notificationsCapacitorTemplateSupportImporters = Object.freeze({
498
+ 'src/platform/developmentServerSession.ts': {
499
+ importer: 'src/platform/serverSession.ts',
500
+ requires: { packageName: '@mpgd/game-services', exportName: 'createDevelopmentServerSession' },
501
+ warning: 'src/platform/serverSession.ts predates development notification sessions '
502
+ + '(VITE_MPGD_DEV_NOTIFICATION_SESSION); to try notifications locally, copy serverSession.ts and '
503
+ + 'developmentServerSession.ts from the starter template.',
504
+ },
505
+ });
506
+ /**
507
+ * Whether `source` names the module `./<name>` (with or without `.js` or
508
+ * `.ts`) as a string outside comments: any static, side-effect, or dynamic
509
+ * import or re-export of it. A commented-out import does not count.
510
+ */
511
+ export function sourceReferencesRelativeModule(source, name) {
512
+ const specifiers = new Set([`./${name}`, `./${name}.js`, `./${name}.ts`]);
513
+ let found = false;
514
+ // One pass over strings and comments, as in capacitorGatewayRegistersNotifications.
515
+ source.replace(/'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`|\/\/[^\n]*|\/\*[\s\S]*?\*\//gu, (token) => {
516
+ if (!token.startsWith('/') && specifiers.has(token.slice(1, -1))) {
517
+ found = true;
518
+ }
519
+ return token;
520
+ });
521
+ return found;
522
+ }
487
523
  /** Whether a Capacitor gateway already registers a notifications provider. */
488
524
  export function capacitorGatewayRegistersNotifications(source) {
489
525
  // One pass over strings and comments: neither registers anything, and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.54.1",
3
+ "version": "0.56.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.6",
74
- "@mpgd/platform": "0.27.1",
73
+ "@mpgd/catalog": "0.10.7",
75
74
  "@mpgd/phaser-assets": "0.9.1",
76
- "@mpgd/target-config": "0.23.6"
75
+ "@mpgd/target-config": "0.23.7",
76
+ "@mpgd/platform": "0.27.2"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@types/node": "^24.0.0",
@@ -81,18 +81,18 @@
81
81
  "@types/yauzl": "3.4.0",
82
82
  "ttsc": "0.32.1",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-browser": "0.11.6",
85
- "@mpgd/adapter-capacitor": "0.12.2",
86
- "@mpgd/adapter-ait": "0.20.2",
87
- "@mpgd/adapter-verse8": "0.7.6",
88
- "@mpgd/bridge": "0.14.3",
89
- "@mpgd/analytics": "0.4.15",
90
- "@mpgd/adapter-devvit": "0.13.4",
91
- "@mpgd/capacitor-play-billing": "0.3.6",
92
- "@mpgd/capacitor-storekit": "0.3.6",
93
- "@mpgd/game-runtime": "0.7.6",
94
- "@mpgd/game-services": "0.27.1",
95
- "@mpgd/i18n": "0.6.20",
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
96
  "@mpgd/lite-shell": "0.1.0"
97
97
  },
98
98
  "main": "./dist/index.js",
@@ -11,6 +11,7 @@ Preserve the generated JSON and Markdown in `artifacts/acceptance` or the target
11
11
  - Verify renderer teardown removes its own listeners and never updates a replacement view from an old promise.
12
12
  - Purchases and rewarded-ad callbacks never grant locally; verify authoritative ledger grants and durable reconciliation before release.
13
13
  - Keep unknown native outcomes and late recovery observers; closing one lease must not release another owner's presentation or audio.
14
+ - Verify the game sets its real server session with `setStarterServerSession` and does not rely on the development fallback (read only with `--providers notifications`): `VITE_MPGD_DEV_NOTIFICATION_SESSION` is local-only (its `mpgd-dev` header needs the Vite dev server, so a `pnpm build` never signs in with it), and deployed acceptance uses the identity service's sessions.
14
15
 
15
16
  ## Reddit Devvit
16
17
 
@@ -11,6 +11,7 @@ Read `AGENTS.md`, the agent manifest, and the `use-mpgd-kit` router before editi
11
11
  - Keep recovery above renderer lifetimes; reconcile existing operations on startup and resume without opening SDK UI.
12
12
  - Rewarded v2 needs an application-owned durable encrypted journal. An in-memory store is not recovery evidence.
13
13
  - Reserve safe-area geometry once and preserve feed scrolling and browser touch gestures.
14
+ - Set the server session with `setStarterServerSession` in `src/main.ts` before `bootstrap()` (a guest backend through `createGuestServerSession`). In a game created with `--providers notifications`, `VITE_MPGD_DEV_NOTIFICATION_SESSION` on the Vite dev server in the `development` mode is a local-only development session for notifications (`src/platform/developmentServerSession.ts`); it never reaches a build, commerce, or a deployed Worker.
14
15
 
15
16
  Run `pnpm dev`, `pnpm check`, `pnpm build`, and `pnpm accept`.
16
17
  Devvit inline and expanded entries both use the lite renderer. Keep optional engines behind explicit dynamic import boundaries and verify the inline budget.
@@ -74,10 +74,22 @@ game has both:
74
74
  identity service answers to the Worker's `authenticateNotificationSession`
75
75
  for this session's header, the same across token refreshes (see the kit's
76
76
  `docs/CLOUDFLARE_WORKER_DEPLOY.md`, "Sessions"). Do not pass a per-token
77
- or rotating id: a `GuestSessionCoordinator` view's `sessionId` may rotate
78
- on refresh, so it is not that id.
77
+ or rotating id. With a guest-session backend, pass
78
+ `createGuestServerSession(coordinator)` from `@mpgd/game-services`: it
79
+ answers the coordinator's `sessionFamilyId` (not a view's `sessionId`,
80
+ which may rotate on refresh), and refreshes the access token as needed
81
+ (an expired one before the request, one about to expire in the
82
+ background).
79
83
 
80
- Without either, it returns `undefined` and the settings screen asks only
84
+ ```ts
85
+ // src/main.ts, before bootstrap()
86
+ const guest = createGuestSessionCoordinator({ backend, credentials, installationId });
87
+ setStarterServerSession(createGuestServerSession(guest));
88
+ void guest.start();
89
+ ```
90
+
91
+ Without either (on the Vite dev server, a development session counts as a
92
+ session; see "Try it locally"), it returns `undefined` and the settings screen asks only
81
93
  for the OS or host permission (and withdraws a Devvit opt-in the game
82
94
  rendered). With them, the runtime registers the device for the signed-in
83
95
  player, keeps its registration state in the platform storage, and
@@ -102,3 +114,63 @@ Night consent adds to 광고성 정보 수신동의: the screen offers it only w
102
114
  advertising consent is granted, and turning advertising consent off also
103
115
  withdraws a granted night consent in the same call (two records, two result
104
116
  notices).
117
+
118
+ ## Try it locally
119
+
120
+ You can see the settings switches record consent before you have an
121
+ identity service, against the kit's game-services Worker on your machine:
122
+
123
+ 1. Fill in `advertisingSender` in `notifications/deployment.json` (the
124
+ Worker refuses a placeholder). In mpgd-kit, give the Worker this game's
125
+ catalog and deployment and the development sessions flag in
126
+ `apps/game-services-worker/.dev.vars` (local variables, never
127
+ committed), then run it with the memory store, the default in its
128
+ `wrangler.toml`:
129
+
130
+ ```sh
131
+ cd <mpgd-kit>
132
+ node - <game>/notifications/topics.json <game>/notifications/deployment.json \
133
+ > apps/game-services-worker/.dev.vars <<'EOF'
134
+ const fs = require('node:fs');
135
+ // One line of JSON in single quotes, with any ' written as \u0027.
136
+ const value = (file) =>
137
+ `'${JSON.stringify(JSON.parse(fs.readFileSync(file, 'utf8'))).replaceAll("'", '\\u0027')}'`;
138
+ console.log('MPGD_ALLOW_INSECURE_DEVELOPMENT_NOTIFICATION_SESSIONS=true');
139
+ console.log(`MPGD_NOTIFICATIONS_CATALOG=${value(process.argv[2])}`);
140
+ console.log(`MPGD_NOTIFICATIONS_DEPLOYMENT=${value(process.argv[3])}`);
141
+ EOF
142
+ pnpm --dir apps/game-services-worker dev --host 127.0.0.1 --port 8787
143
+ ```
144
+
145
+ The Worker logs once that sessions are not authenticated. To send real
146
+ pushes to a phone or an emulator, follow mpgd-kit's live test runbook
147
+ (`docs/NOTIFICATIONS_LIVE_TEST.md`) instead of adding the key here: it
148
+ runs this same Worker, with `pnpm notifications:live env` writing the
149
+ variables and the Firebase service-account key to a file outside the
150
+ checkout that you link as `.dev.vars`.
151
+ 2. Start this game's dev server with the Worker URL and a development
152
+ session, `<playerId>` or `<playerId>.<installationId>` (letters, digits,
153
+ `_`, `-`):
154
+
155
+ ```sh
156
+ VITE_MPGD_GAME_SERVICES_URL=http://127.0.0.1:8787 \
157
+ VITE_MPGD_DEV_NOTIFICATION_SESSION=player-1 \
158
+ pnpm dev
159
+ ```
160
+
161
+ `src/platform/developmentServerSession.ts` then signs the game in as that
162
+ player with `Authorization: Bearer mpgd-dev.<playerId>.<sessionId>.<installationId>`,
163
+ which the Worker accepts only with the flag. Anyone can write that header,
164
+ so it authenticates nothing: the session exists only on the Vite dev server
165
+ (`import.meta.env.DEV`) in the `development` mode, and the Worker refuses
166
+ the flag with its D1 store, ignores it once `GAME_SERVICES_NOTIFICATIONS_AUTH`
167
+ is bound, and takes development sessions only on a local host (`localhost`,
168
+ `127.0.0.1`, `[::1]`, `*.localhost`, or `10.0.2.2`, which the Android
169
+ emulator uses for your machine). That check reads the request's host, so
170
+ it protects a deployed Worker, not your network: keep the local Worker on
171
+ loopback, its default. Calling `setStarterServerSession`, even with `undefined`, turns it
172
+ off; the first call after the game used it warns, since the notifications
173
+ backend keeps the session it first read.
174
+ The development session is for the notifications backend only: the
175
+ commerce client keeps the session the game set with `setStarterServerSession`
176
+ (or none), so its requests never carry a development header.
@@ -90,7 +90,8 @@ export function createStarterWorkerNotificationBackend(
90
90
  if (session === undefined) {
91
91
  input.log(
92
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.',
93
+ + '(src/platform/serverSession.ts) before the game starts, or on the dev server set '
94
+ + 'VITE_MPGD_DEV_NOTIFICATION_SESSION (docs/NOTIFICATIONS.md). The settings screen shows no switch until then.',
94
95
  );
95
96
  return undefined;
96
97
  }
@@ -64,6 +64,16 @@ Verify safe-area geometry is reserved exactly once: CSS-padded containers must
64
64
  not also subtract `safeArea.contentBounds`, while full-bleed game surfaces use
65
65
  the snapshot bounds for persistent controls.
66
66
 
67
+ ## Server session
68
+
69
+ Verify the game sets its real server-backed session with `setStarterServerSession`
70
+ before `bootstrap()` (a guest backend through `createGuestServerSession`) and does
71
+ not rely on the development fallback, which only a game created with
72
+ `--providers notifications` reads. `VITE_MPGD_DEV_NOTIFICATION_SESSION` is local-only evidence:
73
+ a `pnpm build` never signs in with it (its `mpgd-dev` header needs the Vite dev
74
+ server), and acceptance against a deployed Worker uses the identity service's
75
+ sessions.
76
+
67
77
  ## Shared native presentation
68
78
 
69
79
  - Purchase, rewarded, and interstitial requests use the same game-owned scope.
@@ -49,6 +49,18 @@ the game-owned action coordinator and authoritative services, with an
49
49
  application-owned durable encrypted operation store. Keep recovery above scenes
50
50
  and reconcile existing operations on startup and resume without opening SDK UI.
51
51
 
52
+ ## Server session
53
+
54
+ Set the game's server-backed session with `setStarterServerSession`
55
+ (`src/platform/serverSession.ts`) before `bootstrap()`; with a guest backend use
56
+ `createGuestServerSession(coordinator)`, whose `sessionId` is the stable
57
+ session-family id. For local notifications work only (a game created with
58
+ `--providers notifications`), `VITE_MPGD_DEV_NOTIFICATION_SESSION` on the Vite
59
+ dev server in the `development` mode signs in with an unauthenticated
60
+ development session (`src/platform/developmentServerSession.ts`)
61
+ against a local Worker run with `MPGD_ALLOW_INSECURE_DEVELOPMENT_NOTIFICATION_SESSIONS`;
62
+ it never reaches a build, the commerce client, or a deployed Worker.
63
+
52
64
  ## ONE play server-owned monetization
53
65
 
54
66
  Specify issued placement/product IDs, the authenticated reward and checkout intent routes, durable SSV/intent/ledger stores, an encrypted client operation journal, and reconciliation plus atomic cancellation fencing. Keep monetization disabled until these ports are wired. Purchase/ad callbacks are evidence; backend grants precede consume/acknowledge. Separate confirmed checkout closure from financial completion. Plan real-app acceptance with ONEconsole credentials and hosting headers.
@@ -6,29 +6,30 @@
6
6
  "scripts": {
7
7
  "prepare:icon": "pnpm --dir ../.. icons:generate:devvit",
8
8
  "prepare:icon:production": "pnpm --dir ../.. icons:generate:devvit:production",
9
- "init": "pnpm run prepare:icon:production && devvit upload --config devvit.json",
10
- "init:copy-paste": "pnpm run prepare:icon:production && devvit upload --copy-paste --config devvit.json",
11
- "dev": "pnpm run prepare:icon && devvit playtest --config devvit.json",
9
+ "init": "pnpm run check:sdk && pnpm run prepare:icon:production && devvit upload --config devvit.json",
10
+ "init:copy-paste": "pnpm run check:sdk && pnpm run prepare:icon:production && devvit upload --copy-paste --config devvit.json",
11
+ "dev": "pnpm run check:sdk && pnpm run prepare:icon && devvit playtest --config devvit.json",
12
12
  "build": "pnpm --dir ../.. build:devvit",
13
13
  "whoami": "devvit whoami",
14
- "upload": "pnpm run prepare:icon:production && devvit upload --config devvit.json",
15
- "publish": "pnpm run prepare:icon:production && devvit publish --config devvit.json",
14
+ "upload": "pnpm run check:sdk && pnpm run prepare:icon:production && devvit upload --config devvit.json",
15
+ "publish": "pnpm run check:sdk && pnpm run prepare:icon:production && devvit publish --config devvit.json",
16
16
  "check": "ttsc --noEmit -p tsconfig.json",
17
17
  "lint": "ttsc --noEmit -p tsconfig.json",
18
18
  "format": "ttsc format",
19
- "fix": "ttsc fix"
19
+ "fix": "ttsc fix",
20
+ "check:sdk": "pnpm exec mpgd kit devvit-check --project ."
20
21
  },
21
22
  "dependencies": {
22
- "@devvit/web": "0.14.1",
23
+ "@devvit/web": "0.15.0",
23
24
  "@mpgd/adapter-devvit": "__MPGD_DEPENDENCY_VERSION_ADAPTER_DEVVIT__",
24
25
  "@mpgd/bridge": "__MPGD_DEPENDENCY_VERSION_BRIDGE__",
25
26
  "@mpgd/catalog": "__MPGD_DEPENDENCY_VERSION_CATALOG__"
26
27
  },
27
28
  "devDependencies": {
28
- "@devvit/start": "0.14.1",
29
+ "@devvit/start": "0.15.0",
29
30
  "@ttsc/unplugin": "0.32.1",
30
31
  "@types/node": "^24.0.0",
31
- "devvit": "0.14.1",
32
+ "devvit": "0.15.0",
32
33
  "ttsc": "0.32.1",
33
34
  "typescript": "7.0.2",
34
35
  "vite": "7.3.6"
@@ -19,4 +19,9 @@ interface ImportMetaEnv {
19
19
  readonly VITE_MPGD_GAME_SERVICES_URL?: string;
20
20
  readonly VITE_MPGD_GAME_SERVICES_TARGET?: string;
21
21
  readonly VITE_MPGD_GAME_SERVICES_TRANSPORT?: string;
22
+ /**
23
+ * Local development only: `<playerId>` or `<playerId>.<installationId>`
24
+ * for an unauthenticated notifications session (see `serverSession.ts`).
25
+ */
26
+ readonly VITE_MPGD_DEV_NOTIFICATION_SESSION?: string;
22
27
  }
@@ -0,0 +1,104 @@
1
+ import { createDevelopmentServerSession, type DevelopmentServerSession } from '@mpgd/game-services';
2
+
3
+ /**
4
+ * Local development only: an unauthenticated session for a game-services
5
+ * Worker run with `MPGD_ALLOW_INSECURE_DEVELOPMENT_NOTIFICATION_SESSIONS = "true"`
6
+ * (see docs/NOTIFICATIONS.md, "Try it locally"). On only when the Vite dev
7
+ * server runs (`dev`, `import.meta.env.DEV`), the mode is `development`, and
8
+ * `VITE_MPGD_DEV_NOTIFICATION_SESSION` is `<playerId>` or
9
+ * `<playerId>.<installationId>` (each 1 to 64 letters, digits, `_`, or `-`;
10
+ * the installation defaults to `dev-installation`). The session id is
11
+ * `dev-<installationId>`. Anyone can write such a header, so the Worker
12
+ * refuses the flag with its D1 store. Otherwise `undefined`; an invalid
13
+ * value logs why.
14
+ */
15
+ export function resolveStarterDevelopmentServerSession(env: {
16
+ readonly dev: boolean;
17
+ readonly mode: string;
18
+ readonly value: string | undefined;
19
+ }): DevelopmentServerSession | undefined {
20
+ const value = env.value?.trim();
21
+ // Only the development mode: a staging or release build that runs with
22
+ // NODE_ENV=development has `dev` set too.
23
+ if (!env.dev || env.mode !== 'development' || value === undefined || value === '') {
24
+ return undefined;
25
+ }
26
+ const [playerId = '', installationId = 'dev-installation', ...rest] = value.split('.');
27
+ try {
28
+ if (rest.length > 0) {
29
+ throw new Error('Expected <playerId> or <playerId>.<installationId>.');
30
+ }
31
+ const session = createDevelopmentServerSession({ mode: env.mode, playerId, installationId });
32
+ console.warn(
33
+ `[game-services] VITE_MPGD_DEV_NOTIFICATION_SESSION: signed in as ${playerId} with an `
34
+ + 'unauthenticated development session. Local development only.',
35
+ );
36
+ return session;
37
+ } catch (error) {
38
+ console.error('[game-services] VITE_MPGD_DEV_NOTIFICATION_SESSION is ignored.', error);
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * What `serverSession.ts` hands out, kept apart from `import.meta.env` so it
45
+ * can be tested: the game's own session once it called `set` (even with
46
+ * `undefined`), else the development session, read once. `once` marks the
47
+ * reader that keeps what it reads, the commerce client, which only ever
48
+ * gets the game's own session: the development session is for the
49
+ * notifications Worker, and a commerce backend with ingress authentication
50
+ * would refuse its header. A `set` that changes the session after a reader
51
+ * kept the old one warns: after the commerce client read it, or after the
52
+ * development session was handed out (a notifications backend keeps it).
53
+ */
54
+ export function createStarterServerSessionSlot<Session extends object>(input: {
55
+ /** The development session; called at most once. */
56
+ readonly development: () => Session | undefined;
57
+ readonly warn: (message: string) => void;
58
+ }): {
59
+ resolve(options?: { readonly once?: boolean }): Session | undefined;
60
+ set(session: Session | undefined): void;
61
+ } {
62
+ let session: Session | undefined;
63
+ let setByGame = false;
64
+ let readOnce = false;
65
+ let development: Session | null | undefined;
66
+ let developmentHandedOut = false;
67
+ return {
68
+ resolve(options = {}) {
69
+ if (options.once === true) {
70
+ readOnce = true;
71
+ }
72
+ // The commerce client never gets the development session.
73
+ if (setByGame || options.once === true) {
74
+ return session;
75
+ }
76
+ if (development === undefined) {
77
+ development = input.development() ?? null;
78
+ }
79
+ if (development === null) {
80
+ return undefined;
81
+ }
82
+ developmentHandedOut = true;
83
+ return development;
84
+ },
85
+ set(next) {
86
+ if (developmentHandedOut && !setByGame) {
87
+ // Even `undefined`: a notifications backend that kept the development session keeps sending its header.
88
+ input.warn(
89
+ '[game-services] setStarterServerSession ran after the development session '
90
+ + '(VITE_MPGD_DEV_NOTIFICATION_SESSION) was in use; a notifications backend created '
91
+ + 'already keeps it. Unset the variable, or call setStarterServerSession before bootstrap().',
92
+ );
93
+ }
94
+ if (readOnce && next !== undefined && next !== session) {
95
+ input.warn(
96
+ '[game-services] setStarterServerSession ran after the commerce client was created; '
97
+ + "it does not send this session's header. Call it before bootstrap().",
98
+ );
99
+ }
100
+ session = next;
101
+ setByGame = true;
102
+ },
103
+ };
104
+ }
@@ -1,19 +1,37 @@
1
1
  import type { GameServicesHeaderResolver } from '@mpgd/game-services';
2
2
 
3
+ import {
4
+ createStarterServerSessionSlot,
5
+ resolveStarterDevelopmentServerSession,
6
+ } from './developmentServerSession';
7
+
3
8
  /**
4
9
  * The game's server-backed session: who is signed in to the game backend,
5
10
  * and the `Authorization` header for that session. The starter has none, so
6
11
  * `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.
12
+ * `setStarterServerSession`. The commerce client then sends its header. In
13
+ * a game created with `--providers notifications`, the notifications
14
+ * runtime uses the same session and header for the game-services Worker's
15
+ * notification routes.
16
+ *
17
+ * With a guest-session backend, `createGuestServerSession` from
18
+ * `@mpgd/game-services` reads a `GuestSessionCoordinator` as this session:
19
+ *
20
+ * ```ts
21
+ * const guest = createGuestSessionCoordinator({ backend, credentials, installationId });
22
+ * setStarterServerSession(createGuestServerSession(guest));
23
+ * void guest.start();
24
+ * ```
25
+ *
26
+ * `sessionId` must be the stable session-family id: the same across token
27
+ * refreshes, and the session your identity service answers to the Worker's
28
+ * `authenticateNotificationSession` for this session's header. Do not pass
29
+ * a per-token or rotating id: `createGuestServerSession` answers the
30
+ * coordinator's `sessionFamilyId`, never a view's `sessionId`, which may
31
+ * rotate on refresh. With another session holder, pass its family id.
11
32
  *
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.
33
+ * For local development without an identity service, see
34
+ * `developmentServerSession.ts`.
17
35
  */
18
36
  export interface StarterServerSession {
19
37
  /**
@@ -29,22 +47,31 @@ export interface StarterServerSession {
29
47
  readonly getHeaders: GameServicesHeaderResolver;
30
48
  }
31
49
 
32
- let serverSession: StarterServerSession | undefined;
33
- /** Whether the commerce client read the session already: it reads it once. */
34
- let readOnce = false;
50
+ /** The game's session, or the development one (see `developmentServerSession.ts`). */
51
+ const slot = createStarterServerSessionSlot<StarterServerSession>({
52
+ // `import.meta.env.DEV` is false in a normal `vite build`, which drops this branch.
53
+ development: () =>
54
+ import.meta.env.DEV
55
+ ? resolveStarterDevelopmentServerSession({
56
+ dev: import.meta.env.DEV,
57
+ mode: import.meta.env.MODE,
58
+ value: import.meta.env.VITE_MPGD_DEV_NOTIFICATION_SESSION,
59
+ })
60
+ : undefined,
61
+ warn: (message) => console.warn(message),
62
+ });
35
63
 
36
64
  /**
37
65
  * 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.
66
+ * `setStarterServerSession`, unless a development session is on (see
67
+ * `developmentServerSession.ts`). `once` marks the reader that keeps what
68
+ * it reads, the commerce client, so a later change warns; it gets only the
69
+ * game's own session, never the development one.
40
70
  */
41
71
  export function resolveStarterServerSession(
42
72
  options: { readonly once?: boolean } = {},
43
73
  ): StarterServerSession | undefined {
44
- if (options.once === true) {
45
- readOnce = true;
46
- }
47
- return serverSession;
74
+ return slot.resolve(options);
48
75
  }
49
76
 
50
77
  /**
@@ -53,14 +80,10 @@ export function resolveStarterServerSession(
53
80
  * notification settings and opens read it until a backend exists. Its
54
81
  * `current()` may answer `undefined` until the player signs in. A session
55
82
  * set after the commerce client was created does not reach that client,
56
- * which this warns about.
83
+ * which this warns about. Any call, `undefined` included, turns the
84
+ * development session off; the first call after the development session
85
+ * was handed out warns, `undefined` included.
57
86
  */
58
87
  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;
88
+ slot.set(session);
66
89
  }