@mpgd/cli 0.7.0 → 0.8.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.
package/dist/index.js CHANGED
@@ -120,8 +120,6 @@ const entryCommand = defineI18n({
120
120
  ko: 'mpgd-kit 스타터와 타깃 워크플로우를 관리합니다.',
121
121
  }),
122
122
  run: () => {
123
- console.log('Use a sub-command: game, target, kit.');
124
- console.log('Run "pnpm mpgd --help" for available commands.');
125
123
  },
126
124
  });
127
125
  const gameCommand = defineI18n({
@@ -420,12 +418,10 @@ const gameCommand = defineI18n({
420
418
  }),
421
419
  ])),
422
420
  run: () => {
423
- console.log('Use "mpgd game icons generate <game>", verify, or inspect.');
424
421
  },
425
422
  }),
426
423
  },
427
424
  run: () => {
428
- console.log('Use "mpgd game create <directory>", "mpgd game accept <game>", or "mpgd game icons generate <game>".');
429
425
  },
430
426
  });
431
427
  const defaultLegalDir = 'legal';
@@ -553,12 +549,9 @@ function acceptGame(input) {
553
549
  MPGD_RELEASE_MANIFEST_FILE: releaseManifestFile,
554
550
  },
555
551
  });
556
- console.log(`[mpgd:accept] JSON report: ${result.jsonFile}`);
557
- console.log(`[mpgd:accept] Markdown report: ${result.markdownFile}`);
558
552
  if (result.report.status === 'failed') {
559
553
  throw new Error(`Game acceptance failed. Report: ${result.markdownFile}`);
560
554
  }
561
- console.log('[mpgd:accept] passed');
562
555
  }
563
556
  function assertGameAcceptanceRoot(gameRoot) {
564
557
  const resolved = path.resolve(gameRoot);
@@ -642,7 +635,6 @@ const legalCommand = defineI18n({
642
635
  ...resolveLegalCommandOptions(ctx.values),
643
636
  check: false,
644
637
  });
645
- console.log(`Built legal site: ${result.outDir}`);
646
638
  },
647
639
  }),
648
640
  check: defineI18n({
@@ -658,12 +650,10 @@ const legalCommand = defineI18n({
658
650
  ...resolveLegalCommandOptions(ctx.values),
659
651
  check: true,
660
652
  });
661
- console.log(`Legal site checked: ${result.outDir}`);
662
653
  },
663
654
  }),
664
655
  },
665
656
  run: () => {
666
- console.log('Use "mpgd legal build" or "mpgd legal check".');
667
657
  },
668
658
  });
669
659
  const targetCommand = defineI18n({
@@ -872,7 +862,6 @@ const targetCommand = defineI18n({
872
862
  }),
873
863
  },
874
864
  run: () => {
875
- console.log('Use "mpgd target build <target>" or "mpgd target smoke <target>".');
876
865
  },
877
866
  });
878
867
  const kitCommand = defineI18n({
@@ -891,17 +880,10 @@ const kitCommand = defineI18n({
891
880
  ko: 'CLI, kit, template, target wrapper 상태를 출력합니다.',
892
881
  }),
893
882
  run: () => {
894
- console.log(`cli package: ${packageRoot}`);
895
- console.log(`mpgd-kit: ${detectedKitRoot ?? 'not detected'}`);
896
- console.log(`cli template: ${existsSync(gameTemplateDir) ? 'ok' : 'missing'}`);
897
- console.log(`mobile wrapper: ${kitFileStatus('apps/mobile-capacitor/package.json')}`);
898
- console.log(`ait wrapper: ${kitFileStatus('apps/target-ait/package.json')}`);
899
- console.log(`devvit wrapper: ${kitFileStatus('apps/target-devvit/package.json')}`);
900
883
  },
901
884
  }),
902
885
  },
903
886
  run: () => {
904
- console.log('Use "mpgd kit doctor".');
905
887
  },
906
888
  });
907
889
  function commandResource(description, args = {}) {
@@ -1116,9 +1098,7 @@ function createGameApp(input) {
1116
1098
  });
1117
1099
  const files = collectTemplateFiles(gameTemplateDir);
1118
1100
  if (input.dryRun) {
1119
- console.log(`Would create ${path.relative(process.cwd(), appDir) || appDir}:`);
1120
1101
  for (const file of files) {
1121
- console.log(`- ${path.join(path.relative(process.cwd(), appDir), file.relativePath)}`);
1122
1102
  }
1123
1103
  return;
1124
1104
  }
@@ -1127,20 +1107,6 @@ function createGameApp(input) {
1127
1107
  mkdirSync(path.dirname(outputFile), { recursive: true });
1128
1108
  writeFileSync(outputFile, renderTemplate(file.content, context));
1129
1109
  }
1130
- console.log(`Created ${appDir}`);
1131
- console.log('Next steps:');
1132
- console.log(` cd ${appDir}`);
1133
- console.log(' pnpm install -w');
1134
- console.log(' pnpm dev');
1135
- console.log('Target builds require an mpgd-kit checkout:');
1136
- console.log([
1137
- ' mpgd target build-all',
1138
- `--targets-file ${path.join(appDir, 'mpgd.targets.json')}`,
1139
- `--targets ${recommendedMatrixTargets}`,
1140
- '--profile staging',
1141
- '--ait-variant wrapper',
1142
- '--kit-path <path-to-mpgd-kit>',
1143
- ].join(' '));
1144
1110
  }
1145
1111
  function assertValidGameName(name) {
1146
1112
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
@@ -1492,7 +1458,6 @@ function runTargetCommand(input) {
1492
1458
  const args = input.action === 'build'
1493
1459
  ? ['build:target', target, profile]
1494
1460
  : ['smoke:target', target];
1495
- console.log(`[mpgd] ${input.action} ${target}`);
1496
1461
  runPnpm(args, env);
1497
1462
  }
1498
1463
  function runTargetMatrix(input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -47,20 +47,20 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/node": "^24.0.0",
50
- "ttsc": "0.16.9",
51
- "typescript": "7.0.1-rc",
52
- "@mpgd/adapter-ait": "0.4.1",
53
- "@mpgd/adapter-devvit": "0.5.0",
54
- "@mpgd/analytics": "0.3.4",
55
- "@mpgd/adapter-browser": "0.4.1",
56
- "@mpgd/adapter-capacitor": "0.4.1",
57
- "@mpgd/bridge": "0.5.0",
58
- "@mpgd/catalog": "0.3.4",
59
- "@mpgd/game-services": "0.5.0",
60
- "@mpgd/i18n": "0.5.0",
61
- "@mpgd/platform": "0.5.0",
62
- "@mpgd/phaser-assets": "0.4.0",
63
- "@mpgd/target-config": "0.6.0"
50
+ "ttsc": "0.18.4",
51
+ "typescript": "7.0.2",
52
+ "@mpgd/adapter-ait": "0.4.2",
53
+ "@mpgd/adapter-browser": "0.4.2",
54
+ "@mpgd/adapter-capacitor": "0.4.2",
55
+ "@mpgd/adapter-devvit": "0.6.0",
56
+ "@mpgd/analytics": "0.3.5",
57
+ "@mpgd/bridge": "0.6.0",
58
+ "@mpgd/game-services": "0.6.0",
59
+ "@mpgd/catalog": "0.3.5",
60
+ "@mpgd/i18n": "0.5.1",
61
+ "@mpgd/phaser-assets": "0.4.1",
62
+ "@mpgd/platform": "0.5.1",
63
+ "@mpgd/target-config": "0.6.1"
64
64
  },
65
65
  "main": "./dist/index.js",
66
66
  "types": "./dist/index.d.ts",
@@ -19,9 +19,9 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "@cloudflare/workers-types": "^5.20260707.1",
22
- "@ttsc/unplugin": "0.16.9",
23
- "ttsc": "0.16.9",
24
- "typescript": "7.0.1-rc",
22
+ "@ttsc/unplugin": "0.18.4",
23
+ "ttsc": "0.18.4",
24
+ "typescript": "7.0.2",
25
25
  "vite": "^8.1.3",
26
26
  "wrangler": "^4.107.0"
27
27
  }
@@ -25,16 +25,31 @@ available and the CLI asks for a manual code. After that, use
25
25
  `pnpm devvit:playtest`, `pnpm devvit:upload`, and `pnpm devvit:publish` from the
26
26
  game root.
27
27
 
28
- The client bundle is copied into `dist/client` by the mpgd target build. The server bridge is
29
- compiled to `dist/server/index.cjs` and keeps Devvit SDK imports out of Phaser scenes.
30
- The game root pins `@mpgd/cli`, so these commands use the same CLI version as the generated
31
- starter's other `@mpgd/*` dependencies.
28
+ The official `@devvit/start/vite` plugin builds the game client into
29
+ `dist/client` and the CommonJS server bridge into `dist/server/index.cjs` in one
30
+ pass. The mpgd target build wraps that unified build with release provenance and
31
+ effective-target evidence while keeping Devvit SDK imports out of Phaser scenes.
32
+ The game root pins `@mpgd/cli`, so these commands use the same CLI version as the
33
+ generated starter's other `@mpgd/*` dependencies.
34
+
35
+ The bridge endpoint at `/api/mpgd/rpc` uses the direct oRPC Node HTTP adapter
36
+ from `@mpgd/bridge/orpc/node`; Express, Hono, and Fetch request conversion are
37
+ not required. Devvit-owned menu, scheduler, trigger, and form callbacks remain
38
+ thin `/internal/...` routes and delegate to shared service functions. oRPC
39
+ Publisher helpers can broadcast results after a task completes, but they do not
40
+ replace the `devvit.json` scheduler endpoint and should not use process-local
41
+ memory when delivery must cross instances. A thin `/api/mpgd/bridge` JSON route
42
+ remains available for callers that explicitly configure the adapter's legacy
43
+ `endpoint` option; new traffic uses oRPC by default.
32
44
 
33
45
  The default post entry uses `index.html` for a lightweight inline preview. Its
34
46
  Play button requests the `game` entrypoint, which loads the separate
35
47
  `game.html` Phaser document. Keep inline UI free of game runtime imports so the
36
48
  card remains lightweight before expansion.
37
49
 
50
+ `devvit playtest` runs the official unified Vite build in watch mode, so a
51
+ separate client/server watcher or staging prebuild is not required.
52
+
38
53
  ## Durable Post Operations
39
54
 
40
55
  Use `src/server/postOperationStore.ts` with `@mpgd/adapter-devvit/server` for a
@@ -59,3 +74,24 @@ first success, duplicate calls, interruption,
59
74
  response loss, lease expiry with concurrent reconciliation, malformed durable
60
75
  state, invalid launch metadata, and cross-scope key isolation before enabling a
61
76
  scheduled or user-triggered publication flow.
77
+
78
+ ## AI-assisted Devvit development
79
+
80
+ Devvit provides an optional MCP server for targeted documentation search and
81
+ deployed-app log inspection. See the official
82
+ [Devvit AI Tools guide](https://developers.reddit.com/docs/guides/ai), then
83
+ register it once in the developer's global Codex configuration:
84
+
85
+ ```sh
86
+ codex mcp add devvit -- npx -y @devvit/mcp
87
+ ```
88
+
89
+ Restart Codex or begin a new task after registering it. Use `devvit_search`
90
+ before broad Devvit documentation exploration. Use the experimental
91
+ `devvit_logs` tool for a deployed app and subreddit when diagnosing production
92
+ or playtest behavior, and confirm its findings with local tests and
93
+ `pnpm devvit:playtest`.
94
+
95
+ The MCP server is an agent-side aid only. It is not required by the generated
96
+ game build, must not be bundled into target artifacts, and does not replace
97
+ Devvit CLI authentication or release verification.
@@ -41,7 +41,7 @@
41
41
  ]
42
42
  },
43
43
  "scripts": {
44
- "dev": "pnpm dev:prepare && pnpm dev:watch",
44
+ "dev": "vite build --mode staging --watch",
45
45
  "build": "pnpm build"
46
46
  }
47
47
  }
@@ -9,12 +9,7 @@
9
9
  "init": "pnpm run prepare:icon:production && devvit upload --config devvit.json",
10
10
  "init:copy-paste": "pnpm run prepare:icon:production && devvit upload --copy-paste --config devvit.json",
11
11
  "dev": "pnpm run prepare:icon && devvit playtest --config devvit.json",
12
- "dev:prepare": "pnpm --dir ../.. build:devvit:staging",
13
- "dev:client": "APP_TARGET=reddit APP_VERSION=0.0.0-dev BUILD_ID=devvit-watch pnpm --dir ../.. exec vite build --mode staging --watch --outDir apps/target-devvit/dist/client",
14
- "dev:server": "vite build --config vite.server.config.ts --watch",
15
- "dev:watch": "concurrently --kill-others --success first \"pnpm run dev:client\" \"pnpm run dev:server\"",
16
12
  "build": "pnpm --dir ../.. build:devvit",
17
- "build:server": "vite build --config vite.server.config.ts",
18
13
  "whoami": "devvit whoami",
19
14
  "upload": "pnpm run prepare:icon:production && devvit upload --config devvit.json",
20
15
  "publish": "pnpm run prepare:icon:production && devvit publish --config devvit.json",
@@ -24,20 +19,17 @@
24
19
  "fix": "ttsc fix"
25
20
  },
26
21
  "dependencies": {
27
- "@devvit/web": "0.13.6",
22
+ "@devvit/web": "0.13.7",
28
23
  "@mpgd/adapter-devvit": "__MPGD_DEPENDENCY_VERSION_ADAPTER_DEVVIT__",
29
- "@mpgd/bridge": "__MPGD_DEPENDENCY_VERSION_BRIDGE__",
30
- "express": "^5.2.1",
31
- "helmet": "^8.1.0"
24
+ "@mpgd/bridge": "__MPGD_DEPENDENCY_VERSION_BRIDGE__"
32
25
  },
33
26
  "devDependencies": {
34
- "@ttsc/unplugin": "0.16.9",
35
- "@types/express": "^5.0.6",
27
+ "@devvit/start": "0.13.7",
28
+ "@ttsc/unplugin": "0.18.4",
36
29
  "@types/node": "^24.0.0",
37
- "concurrently": "10.0.3",
38
- "devvit": "0.13.6",
39
- "ttsc": "0.16.9",
40
- "typescript": "7.0.1-rc",
30
+ "devvit": "0.13.7",
31
+ "ttsc": "0.18.4",
32
+ "typescript": "7.0.2",
41
33
  "vite": "^8.1.3"
42
34
  }
43
35
  }
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="theme-color" content="#0f172a" />
7
+ <title>__GAME_TITLE__</title>
8
+ </head>
9
+ <body>
10
+ <div id="game"></div>
11
+ <script type="module" src="./game.ts"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,3 @@
1
+ await import('../../../../src/gameEntry');
2
+
3
+ export {};
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="theme-color" content="#0f172a" />
7
+ <title>__GAME_TITLE__</title>
8
+ </head>
9
+ <body>
10
+ <div id="game"></div>
11
+ <script type="module" src="./preview.ts"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,3 @@
1
+ await import('../../../../src/entry');
2
+
3
+ export {};
@@ -1,3 +1,5 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+
1
3
  import { createServer, context, getServerPort, reddit, redis } from '@devvit/web/server';
2
4
  import type { UiResponse } from '@devvit/web/shared';
3
5
  import {
@@ -7,17 +9,16 @@ import {
7
9
  type BridgeResponse,
8
10
  } from '@mpgd/bridge';
9
11
  import {
10
- createBridgeRpcFetchHandler,
11
12
  createBridgeRpcRouter,
12
13
  defaultBridgeRpcEndpoint,
13
14
  } from '@mpgd/bridge/orpc';
14
- import express, { type Request as ExpressRequest, type Response as ExpressResponse } from 'express';
15
- import helmet from 'helmet';
15
+ import { createBridgeRpcNodeHandler } from '@mpgd/bridge/orpc/node';
16
16
 
17
- const app = express();
18
17
  const redisKeyComponentPattern = /^[A-Za-z0-9:_-]{1,128}$/;
19
18
  const maxStorageKeyLength = 128;
20
19
  const maxEncodedStorageKeyLength = 384;
20
+ const maxRequestBodySize = 1_000_000;
21
+ const legacyBridgeEndpoint = '/api/mpgd/bridge';
21
22
  const leaderboardUpdateMaxAttempts = 3;
22
23
  const leaderboardBackoffBaseMs = 25;
23
24
  const leaderboardLockTtlMs = 2_000;
@@ -25,51 +26,125 @@ const leaderboardLockTtlSeconds = Math.ceil(leaderboardLockTtlMs / 1_000);
25
26
  const leaderboardLockRetryBudgetMs = leaderboardLockTtlSeconds * 1_000;
26
27
  const gameName = '__GAME_NAME__';
27
28
  const gameTitle = __GAME_TITLE_TS_LITERAL__;
28
- const expressManagedResponseHeaders = new Set([
29
- 'connection',
30
- 'content-encoding',
31
- 'content-length',
32
- 'transfer-encoding',
33
- ]);
34
-
35
- app.disable('x-powered-by');
36
- app.use(helmet());
37
-
38
- const bridgeRpcFetchHandler = createBridgeRpcFetchHandler(
29
+ const bridgeRpcHandler = createBridgeRpcNodeHandler(
39
30
  createBridgeRpcRouter(handleBridgeRequest),
31
+ {
32
+ maxBodySize: maxRequestBodySize,
33
+ prefix: defaultBridgeRpcEndpoint,
34
+ },
40
35
  );
41
36
 
42
- app.use(defaultBridgeRpcEndpoint, express.raw({ type: '*/*', limit: '1mb' }), async (
43
- request: ExpressRequest,
44
- response: ExpressResponse,
45
- ): Promise<void> => {
37
+ async function handleHttpRequest(
38
+ request: IncomingMessage,
39
+ response: ServerResponse,
40
+ ): Promise<void> {
41
+ setResponseSecurityHeaders(response);
42
+
43
+ if (request.method === 'POST' && requestPathname(request) === '/internal/menu/create-post') {
44
+ try {
45
+ await drainRequestBody(request, maxRequestBodySize);
46
+ } catch (error) {
47
+ if (!(error instanceof RequestBodyTooLargeError)) {
48
+ throw error;
49
+ }
50
+
51
+ discardOversizedRequestBody(request, response);
52
+ sendJson(response, 413, {
53
+ error: 'REQUEST_BODY_TOO_LARGE',
54
+ });
55
+ return;
56
+ }
57
+
58
+ await handleCreatePostMenu(response);
59
+ return;
60
+ }
61
+
62
+ if (request.method === 'POST' && requestPathname(request) === legacyBridgeEndpoint) {
63
+ await handleLegacyBridgeRequest(request, response);
64
+ return;
65
+ }
66
+
67
+ if (await bridgeRpcHandler(request, response)) {
68
+ return;
69
+ }
70
+
71
+ sendJson(response, 404, {
72
+ error: 'NOT_FOUND',
73
+ });
74
+ }
75
+
76
+ async function handleLegacyBridgeRequest(
77
+ request: IncomingMessage,
78
+ response: ServerResponse,
79
+ ): Promise<void> {
80
+ let body: unknown;
81
+
82
+ try {
83
+ body = await readJsonRequestBody(request, maxRequestBodySize);
84
+ } catch (error) {
85
+ const responseError = describeLegacyBridgeBodyError(error);
86
+
87
+ if (error instanceof RequestBodyTooLargeError) {
88
+ discardOversizedRequestBody(request, response);
89
+ }
90
+
91
+ if (responseError.retryable) {
92
+ console.error(`devvit legacy bridge body read failed: ${errorMessage(error)}`, error);
93
+ }
94
+
95
+ sendJson(
96
+ response,
97
+ responseError.status,
98
+ createBridgeError(
99
+ 'unknown',
100
+ responseError.code,
101
+ responseError.message,
102
+ responseError.retryable,
103
+ ),
104
+ );
105
+ return;
106
+ }
107
+
108
+ let bridgeRequest: BridgeRequest;
109
+
110
+ try {
111
+ bridgeRequest = assertBridgeRequest(body);
112
+ } catch (error) {
113
+ console.warn(`devvit legacy bridge validation failed: ${errorMessage(error)}`);
114
+ sendJson(
115
+ response,
116
+ 400,
117
+ createBridgeError(
118
+ requestIdFromBody(body),
119
+ 'INVALID_BRIDGE_REQUEST',
120
+ 'Invalid bridge request.',
121
+ ),
122
+ );
123
+ return;
124
+ }
125
+
46
126
  try {
47
- const fetchResponse = await bridgeRpcFetchHandler(expressRequestToFetchRequest(request));
48
- await sendFetchResponse(response, fetchResponse);
127
+ sendJson(response, 200, await handleBridgeRequest(bridgeRequest));
49
128
  } catch (error) {
50
- const message = error instanceof Error ? error.message : 'Devvit oRPC request failed.';
51
- console.error(`devvit oRPC internal error: ${message}`, error);
52
- response.status(500).json(
129
+ console.error(`devvit legacy bridge request failed: ${errorMessage(error)}`, error);
130
+ sendJson(
131
+ response,
132
+ 500,
53
133
  createBridgeError(
54
- requestIdFromBody(request.body),
134
+ bridgeRequest.id,
55
135
  'DEVVIT_BRIDGE_INTERNAL_ERROR',
56
136
  'Devvit bridge request failed.',
57
137
  true,
58
138
  ),
59
139
  );
60
140
  }
61
- });
62
-
63
- app.use(express.json({ limit: '1mb' }));
141
+ }
64
142
 
65
- app.post('/internal/menu/create-post', async (
66
- _request: ExpressRequest,
67
- response: ExpressResponse,
68
- ): Promise<void> => {
143
+ async function handleCreatePostMenu(response: ServerResponse): Promise<void> {
69
144
  const subredditName = currentSubredditName();
70
145
 
71
146
  if (subredditName === undefined) {
72
- response.status(200).json({
147
+ sendJson(response, 200, {
73
148
  showToast: {
74
149
  text: 'Could not resolve the target subreddit for this menu action.',
75
150
  appearance: 'neutral',
@@ -92,7 +167,7 @@ app.post('/internal/menu/create-post', async (
92
167
  },
93
168
  });
94
169
 
95
- response.status(200).json({
170
+ sendJson(response, 200, {
96
171
  showToast: {
97
172
  text: `Created ${gameTitle} post ${post.id}.`,
98
173
  appearance: 'success',
@@ -100,53 +175,30 @@ app.post('/internal/menu/create-post', async (
100
175
  } satisfies UiResponse);
101
176
  } catch (error) {
102
177
  console.error(`devvit custom post creation failed: ${errorMessage(error)}`, error);
103
- response.status(200).json({
178
+ sendJson(response, 200, {
104
179
  showToast: {
105
180
  text: `Could not create the ${gameTitle} post.`,
106
181
  appearance: 'neutral',
107
182
  },
108
183
  } satisfies UiResponse);
109
184
  }
110
- });
111
-
112
- app.post('/api/mpgd/bridge', async (
113
- request: ExpressRequest,
114
- response: ExpressResponse,
115
- ): Promise<void> => {
116
- let bridgeRequest: BridgeRequest;
185
+ }
117
186
 
118
- try {
119
- bridgeRequest = assertBridgeRequest(request.body);
120
- } catch (error) {
121
- response.status(400).json(
122
- createBridgeError(
123
- requestIdFromBody(request.body),
124
- 'INVALID_BRIDGE_REQUEST',
125
- error instanceof Error ? error.message : 'Invalid bridge request.',
126
- ),
127
- );
128
- return;
129
- }
187
+ const server = createServer((request, response) => {
188
+ void handleHttpRequest(request, response).catch((error: unknown) => {
189
+ console.error(`devvit server request failed: ${errorMessage(error)}`, error);
130
190
 
131
- try {
132
- const bridgeResponse = await handleBridgeRequest(bridgeRequest);
133
- response.status(200).json(bridgeResponse);
134
- } catch (error) {
135
- const message = error instanceof Error ? error.message : 'Devvit bridge request failed.';
136
- console.error(`devvit bridge internal error: ${message}`, error);
191
+ if (response.headersSent) {
192
+ response.end();
193
+ return;
194
+ }
137
195
 
138
- response.status(500).json(
139
- createBridgeError(
140
- bridgeRequest.id,
141
- 'DEVVIT_BRIDGE_INTERNAL_ERROR',
142
- 'Devvit bridge request failed.',
143
- true,
144
- ),
145
- );
146
- }
196
+ sendJson(response, 500, {
197
+ error: 'DEVVIT_SERVER_INTERNAL_ERROR',
198
+ });
199
+ });
147
200
  });
148
201
 
149
- const server = createServer(app);
150
202
  const port = getServerPort();
151
203
 
152
204
  server.on('error', (error) => {
@@ -387,15 +439,6 @@ async function saveStorage(input: BridgeRequest): Promise<BridgeResponse> {
387
439
  });
388
440
  }
389
441
 
390
- function requestIdFromBody(input: unknown): string {
391
- if (typeof input !== 'object' || input === null) {
392
- return 'unknown';
393
- }
394
-
395
- const candidate = input as { readonly id?: unknown };
396
- return typeof candidate.id === 'string' ? candidate.id : 'unknown';
397
- }
398
-
399
442
  function ok(input: BridgeRequest, data: unknown): BridgeResponse {
400
443
  return {
401
444
  id: input.id,
@@ -641,61 +684,130 @@ function optionalObjectPayload(payload: unknown): Record<string, unknown> {
641
684
  return payload as Record<string, unknown>;
642
685
  }
643
686
 
644
- function expressRequestToFetchRequest(request: ExpressRequest): Request {
645
- const headers = new Headers();
687
+ function requestPathname(request: IncomingMessage): string {
688
+ return new URL(request.url ?? '/', 'http://localhost').pathname;
689
+ }
646
690
 
647
- for (const [key, value] of Object.entries(request.headers)) {
648
- if (typeof value === 'string') {
649
- headers.set(key, value);
650
- } else if (Array.isArray(value)) {
651
- headers.set(key, value.join(', '));
652
- }
691
+ class RequestBodyTooLargeError extends Error {}
692
+
693
+ function describeLegacyBridgeBodyError(error: unknown): {
694
+ readonly status: number;
695
+ readonly code: string;
696
+ readonly message: string;
697
+ readonly retryable: boolean;
698
+ } {
699
+ if (error instanceof RequestBodyTooLargeError) {
700
+ return {
701
+ status: 413,
702
+ code: 'BRIDGE_REQUEST_TOO_LARGE',
703
+ message: 'Bridge request body is too large.',
704
+ retryable: false,
705
+ };
706
+ }
707
+
708
+ if (error instanceof SyntaxError) {
709
+ return {
710
+ status: 400,
711
+ code: 'INVALID_BRIDGE_REQUEST',
712
+ message: 'Bridge request body is not valid JSON.',
713
+ retryable: false,
714
+ };
653
715
  }
654
716
 
655
- const init: RequestInit = {
656
- method: request.method,
657
- headers,
717
+ return {
718
+ status: 500,
719
+ code: 'DEVVIT_BRIDGE_INTERNAL_ERROR',
720
+ message: 'Bridge request body could not be read.',
721
+ retryable: true,
658
722
  };
723
+ }
724
+
725
+ async function* readRequestBodyChunks(
726
+ request: IncomingMessage,
727
+ maxBodySize: number,
728
+ ): AsyncGenerator<Buffer, void, undefined> {
729
+ assertContentLengthWithinLimit(request, maxBodySize);
730
+
731
+ let bodySize = 0;
659
732
 
660
- if (request.method !== 'GET' && request.method !== 'HEAD') {
661
- init.body = requestBodyToBodyInit(request.body);
733
+ for await (const chunk of request) {
734
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
735
+ bodySize += buffer.byteLength;
662
736
 
663
- if (!headers.has('content-type') && !(request.body instanceof Uint8Array)) {
664
- headers.set('content-type', 'application/json');
737
+ if (bodySize > maxBodySize) {
738
+ throw new RequestBodyTooLargeError();
665
739
  }
740
+
741
+ yield buffer;
742
+ }
743
+ }
744
+
745
+ async function drainRequestBody(
746
+ request: IncomingMessage,
747
+ maxBodySize: number,
748
+ ): Promise<void> {
749
+ for await (const _chunk of readRequestBodyChunks(request, maxBodySize)) {
750
+ // Drain the bounded request stream without retaining its contents.
666
751
  }
752
+ }
753
+
754
+ async function readJsonRequestBody(
755
+ request: IncomingMessage,
756
+ maxBodySize: number,
757
+ ): Promise<unknown> {
758
+ const chunks: Buffer[] = [];
667
759
 
668
- return new Request(expressRequestUrl(request), init);
760
+ for await (const chunk of readRequestBodyChunks(request, maxBodySize)) {
761
+ chunks.push(chunk);
762
+ }
763
+
764
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
669
765
  }
670
766
 
671
- function requestBodyToBodyInit(input: unknown): BodyInit {
672
- if (input instanceof Uint8Array) {
673
- const body = new Uint8Array(input.byteLength);
674
- body.set(input);
767
+ function assertContentLengthWithinLimit(
768
+ request: IncomingMessage,
769
+ maxBodySize: number,
770
+ ): void {
771
+ const contentLength = request.headers['content-length'];
675
772
 
676
- return body.buffer as ArrayBuffer;
773
+ if (
774
+ contentLength !== undefined
775
+ && Number.isFinite(Number(contentLength))
776
+ && Number(contentLength) > maxBodySize
777
+ ) {
778
+ throw new RequestBodyTooLargeError();
677
779
  }
780
+ }
678
781
 
679
- return JSON.stringify(input ?? null);
782
+ function discardOversizedRequestBody(
783
+ request: IncomingMessage,
784
+ response: ServerResponse,
785
+ ): void {
786
+ response.setHeader('connection', 'close');
787
+ request.resume();
680
788
  }
681
789
 
682
- function expressRequestUrl(request: ExpressRequest): string {
683
- const host = request.get('host') ?? 'localhost';
684
- const protocol = request.protocol || 'https';
790
+ function requestIdFromBody(input: unknown): string {
791
+ if (typeof input !== 'object' || input === null || !('id' in input)) {
792
+ return 'unknown';
793
+ }
685
794
 
686
- return `${protocol}://${host}${request.originalUrl}`;
795
+ const id = (input as { readonly id?: unknown }).id;
796
+ return typeof id === 'string' && id.length > 0 ? id : 'unknown';
687
797
  }
688
798
 
689
- async function sendFetchResponse(
690
- response: ExpressResponse,
691
- fetchResponse: Response,
692
- ): Promise<void> {
693
- fetchResponse.headers.forEach((value: string, key: string) => {
694
- if (!expressManagedResponseHeaders.has(key.toLowerCase())) {
695
- response.setHeader(key, value);
696
- }
697
- });
799
+ function setResponseSecurityHeaders(response: ServerResponse): void {
800
+ response.setHeader('cache-control', 'no-store');
801
+ response.setHeader('content-security-policy', "default-src 'none'; frame-ancestors 'none'");
802
+ response.setHeader('referrer-policy', 'no-referrer');
803
+ response.setHeader('x-content-type-options', 'nosniff');
804
+ response.setHeader('x-frame-options', 'DENY');
805
+ }
698
806
 
699
- response.status(fetchResponse.status);
700
- response.send(await fetchResponse.text());
807
+ function sendJson(response: ServerResponse, status: number, body: unknown): void {
808
+ const payload = JSON.stringify(body);
809
+ response.statusCode = status;
810
+ response.setHeader('content-length', Buffer.byteLength(payload));
811
+ response.setHeader('content-type', 'application/json; charset=utf-8');
812
+ response.end(payload);
701
813
  }
@@ -2,8 +2,13 @@
2
2
  "extends": "../../tsconfig.json",
3
3
  "compilerOptions": {
4
4
  "allowSyntheticDefaultImports": true,
5
- "types": ["node"],
5
+ "types": ["node", "vite/client"],
6
6
  "noEmit": true
7
7
  },
8
- "include": ["src/**/*.ts", "vite.server.config.ts"]
8
+ "include": [
9
+ "src/**/*.ts",
10
+ "vite.config.ts",
11
+ "../../src/**/*.ts",
12
+ "../../vite.shared.ts"
13
+ ]
9
14
  }
@@ -0,0 +1,41 @@
1
+ import { resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import { devvit } from '@devvit/start/vite';
5
+ import { defineConfig } from 'vite';
6
+
7
+ import { createGameViteSharedConfig } from '../../vite.shared';
8
+
9
+ const wrapperRoot = fileURLToPath(new URL('.', import.meta.url));
10
+ const gameRoot = resolve(wrapperRoot, '../..');
11
+
12
+ export default defineConfig(({ mode }) => {
13
+ const shared = createGameViteSharedConfig({
14
+ appTarget: 'reddit',
15
+ configTarget: 'reddit',
16
+ gameRoot,
17
+ mode,
18
+ platformTargetsFile: resolve(gameRoot, 'mpgd.targets.json'),
19
+ project: resolve(wrapperRoot, 'tsconfig.json'),
20
+ });
21
+
22
+ return {
23
+ ...shared,
24
+ publicDir: resolve(gameRoot, 'public'),
25
+ plugins: [
26
+ ...(shared.plugins ?? []),
27
+ devvit({
28
+ client: {
29
+ build: {
30
+ sourcemap: mode !== 'production',
31
+ },
32
+ },
33
+ server: {
34
+ build: {
35
+ sourcemap: mode !== 'production',
36
+ },
37
+ },
38
+ }),
39
+ ],
40
+ };
41
+ });
@@ -43,6 +43,7 @@
43
43
  },
44
44
  "reddit": {
45
45
  "kind": "devvit-web",
46
+ "buildStrategy": "devvit-vite",
46
47
  "gameApp": ".",
47
48
  "wrapperApp": "apps/target-devvit",
48
49
  "adapter": "devvit",
@@ -73,10 +73,10 @@
73
73
  "@ait-co/console-cli": "^0.1.45",
74
74
  "@cloudflare/workers-types": "^5.20260707.1",
75
75
  "@mpgd/cli": "__MPGD_DEPENDENCY_VERSION_CLI__",
76
- "@ttsc/unplugin": "0.16.9",
76
+ "@ttsc/unplugin": "0.18.4",
77
77
  "@types/node": "^24.0.0",
78
- "ttsc": "0.16.9",
79
- "typescript": "7.0.1-rc",
78
+ "ttsc": "0.18.4",
79
+ "typescript": "7.0.2",
80
80
  "vite": "^8.1.3",
81
81
  "wrangler": "^4.107.0"
82
82
  }
@@ -1,53 +1,27 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
1
  import { resolve } from 'node:path';
3
2
 
4
- import ttsc from '@ttsc/unplugin/vite';
5
3
  import { defineConfig } from 'vite';
6
4
 
7
- interface RuntimePlatformTargetMetadata {
8
- readonly kind: string;
9
- readonly adapter: string;
10
- readonly integrations?: Record<string, unknown>;
11
- }
5
+ import { createGameViteSharedConfig } from './vite.shared';
6
+
7
+ export { resolveBuildGatewayModule } from './vite.shared';
8
+
9
+ const gameRoot = process.cwd();
12
10
 
13
11
  export default defineConfig(({ mode }) => {
14
- const isProduction = mode === 'production';
15
- const platformTarget = readRuntimePlatformTarget();
16
12
  const appTarget = process.env.APP_TARGET ?? 'browser';
17
13
  const isDevvitBuild = appTarget === 'reddit';
18
- const buildGatewayModule = resolveBuildGatewayModule({
19
- target: appTarget,
20
- debug: !isProduction,
21
- buildId: process.env.BUILD_ID ?? 'local',
22
- });
23
14
 
24
15
  return {
25
- base: './',
26
- plugins: [
27
- ttsc({
28
- project: 'tsconfig.json',
29
- plugins: false,
30
- }),
31
- ],
32
- resolve: {
33
- alias: {
34
- ...createCatalogAliases(),
35
- '#mpgd-platform-gateway': resolve(buildGatewayModule),
36
- },
37
- },
38
- define: {
39
- __APP_TARGET__: JSON.stringify(appTarget),
40
- __MPGD_CONFIG_TARGET__: JSON.stringify(process.env.MPGD_CONFIG_TARGET ?? ''),
41
- __MPGD_PLATFORM_TARGET__:
42
- platformTarget === undefined ? 'undefined' : JSON.stringify(platformTarget),
43
- __APP_VERSION__: JSON.stringify(process.env.APP_VERSION ?? '0.0.0-dev'),
44
- __BUILD_ID__: JSON.stringify(process.env.BUILD_ID ?? 'local'),
45
- __SOURCE_GIT_SHA__: JSON.stringify(process.env.MPGD_SOURCE_GIT_SHA ?? 'uncommitted'),
46
- __DEBUG_BUILD__: JSON.stringify(!isProduction),
47
- },
16
+ ...createGameViteSharedConfig({
17
+ appTarget,
18
+ gameRoot,
19
+ mode,
20
+ project: resolve(gameRoot, 'tsconfig.json'),
21
+ }),
48
22
  build: {
49
23
  target: 'es2022',
50
- sourcemap: !isProduction,
24
+ sourcemap: mode !== 'production',
51
25
  outDir: 'dist',
52
26
  assetsDir: 'assets',
53
27
  emptyOutDir: true,
@@ -55,8 +29,8 @@ export default defineConfig(({ mode }) => {
55
29
  ...(isDevvitBuild
56
30
  ? {
57
31
  input: {
58
- preview: resolve('index.html'),
59
- game: resolve('game.html'),
32
+ preview: resolve(gameRoot, 'index.html'),
33
+ game: resolve(gameRoot, 'game.html'),
60
34
  },
61
35
  }
62
36
  : {}),
@@ -69,128 +43,3 @@ export default defineConfig(({ mode }) => {
69
43
  },
70
44
  };
71
45
  });
72
-
73
- export function resolveBuildGatewayModule(input: {
74
- readonly target: string;
75
- readonly debug: boolean;
76
- readonly buildId: string;
77
- }): string {
78
- switch (input.target) {
79
- case 'android':
80
- return 'src/platform/buildGateways/capacitorAndroid.ts';
81
- case 'ios':
82
- return 'src/platform/buildGateways/capacitorIos.ts';
83
- case 'ait':
84
- return input.debug
85
- ? 'src/platform/buildGateways/aitSandbox.ts'
86
- : 'src/platform/buildGateways/ait.ts';
87
- case 'reddit':
88
- return input.debug && input.buildId === 'devvit-sandbox'
89
- ? 'src/platform/buildGateways/redditSandbox.ts'
90
- : 'src/platform/buildGateways/reddit.ts';
91
- default:
92
- return 'src/platform/buildGateways/browser.ts';
93
- }
94
- }
95
-
96
- function createCatalogAliases(): Record<string, string> {
97
- const productCatalogFile = readConfiguredPath(process.env.MPGD_PRODUCT_CATALOG_FILE);
98
- const adPlacementsFile = readConfiguredPath(process.env.MPGD_AD_PLACEMENTS_FILE);
99
-
100
- if ((productCatalogFile === undefined) !== (adPlacementsFile === undefined)) {
101
- throw new Error(
102
- 'MPGD_PRODUCT_CATALOG_FILE and MPGD_AD_PLACEMENTS_FILE must be configured together.',
103
- );
104
- }
105
-
106
- if (productCatalogFile === undefined || adPlacementsFile === undefined) {
107
- return {};
108
- }
109
-
110
- return {
111
- '@mpgd/catalog/catalog.json': resolveCatalogPath(productCatalogFile, adPlacementsFile),
112
- '@mpgd/catalog/placements.json': resolveCatalogPath(adPlacementsFile, productCatalogFile),
113
- };
114
- }
115
-
116
- function readConfiguredPath(value: string | undefined): string | undefined {
117
- const normalized = value?.trim();
118
- return normalized === undefined || normalized.length === 0 ? undefined : normalized;
119
- }
120
-
121
- function readRuntimePlatformTarget(): RuntimePlatformTargetMetadata | undefined {
122
- const targetsFile = readConfiguredPath(process.env.MPGD_PLATFORM_TARGETS_FILE);
123
- const configTarget = readConfiguredPath(process.env.MPGD_CONFIG_TARGET);
124
-
125
- if (targetsFile === undefined || configTarget === undefined) {
126
- return undefined;
127
- }
128
-
129
- const resolvedTargetsFile = resolve(targetsFile);
130
- let parsed: unknown;
131
-
132
- try {
133
- parsed = JSON.parse(readFileSync(resolvedTargetsFile, 'utf8'));
134
- } catch (error) {
135
- throw new Error(
136
- `Failed to read or parse MPGD_PLATFORM_TARGETS_FILE at ${resolvedTargetsFile}: ${formatError(error)}`,
137
- );
138
- }
139
-
140
- if (!isRecord(parsed) || !isRecord(parsed.targets)) {
141
- throw new Error('MPGD_PLATFORM_TARGETS_FILE must contain a targets object.');
142
- }
143
-
144
- const target = parsed.targets[configTarget];
145
-
146
- if (!isRecord(target)) {
147
- throw new Error(`Missing platform target metadata for ${configTarget}.`);
148
- }
149
-
150
- if (typeof target.kind !== 'string' || typeof target.adapter !== 'string') {
151
- throw new Error(`Platform target ${configTarget} must define kind and adapter.`);
152
- }
153
-
154
- if (target.integrations !== undefined && !isRecord(target.integrations)) {
155
- throw new Error(`Platform target ${configTarget} integrations must be an object.`);
156
- }
157
-
158
- return {
159
- kind: target.kind,
160
- adapter: target.adapter,
161
- ...(target.integrations === undefined ? {} : { integrations: target.integrations }),
162
- };
163
- }
164
-
165
- function isRecord(input: unknown): input is Record<string, unknown> {
166
- return typeof input === 'object' && input !== null && !Array.isArray(input);
167
- }
168
-
169
- function formatError(error: unknown): string {
170
- return error instanceof Error ? error.message : String(error);
171
- }
172
-
173
- function resolveCatalogPath(path: string, pairedPath: string): string {
174
- return resolve(resolveCatalogBaseDir(path, pairedPath), path);
175
- }
176
-
177
- function resolveCatalogBaseDir(path: string, pairedPath: string): string {
178
- const fallbackBaseDir = process.cwd();
179
- const candidates = [
180
- fallbackBaseDir,
181
- readConfiguredPath(process.env.INIT_CWD),
182
- readConfiguredPath(process.env.PWD),
183
- ];
184
-
185
- for (const candidate of candidates) {
186
- if (
187
- candidate !== undefined
188
- && existsSync(resolve(candidate, path))
189
- && existsSync(resolve(candidate, pairedPath))
190
- ) {
191
- return candidate;
192
- }
193
- }
194
-
195
- return fallbackBaseDir;
196
- }
@@ -0,0 +1,217 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import ttsc from '@ttsc/unplugin/vite';
5
+ import type { UserConfig } from 'vite';
6
+
7
+ interface RuntimePlatformTargetMetadata {
8
+ readonly kind: string;
9
+ readonly adapter: string;
10
+ readonly integrations?: Record<string, unknown>;
11
+ }
12
+
13
+ const devvitSandboxBuildId = 'devvit-sandbox';
14
+
15
+ export interface CreateGameViteSharedConfigInput {
16
+ readonly appTarget?: string;
17
+ readonly configTarget?: string;
18
+ readonly gameRoot: string;
19
+ readonly mode: string;
20
+ readonly platformTargetsFile?: string;
21
+ readonly project: string;
22
+ readonly productCatalogFile?: string;
23
+ readonly adPlacementsFile?: string;
24
+ }
25
+
26
+ export function createGameViteSharedConfig(
27
+ input: CreateGameViteSharedConfigInput,
28
+ ): UserConfig {
29
+ const isProduction = input.mode === 'production';
30
+ const appTarget = input.appTarget ?? process.env.APP_TARGET ?? 'browser';
31
+ const configTarget = input.configTarget ?? process.env.MPGD_CONFIG_TARGET ?? '';
32
+ const platformTarget = readRuntimePlatformTarget(
33
+ input.platformTargetsFile ?? process.env.MPGD_PLATFORM_TARGETS_FILE,
34
+ configTarget,
35
+ );
36
+ const buildGatewayModule = resolveBuildGatewayModule({
37
+ target: appTarget,
38
+ debug: !isProduction,
39
+ buildId: process.env.BUILD_ID ?? 'local',
40
+ });
41
+
42
+ return {
43
+ base: './',
44
+ plugins: [
45
+ ttsc({
46
+ project: input.project,
47
+ plugins: false,
48
+ }),
49
+ ],
50
+ resolve: {
51
+ alias: {
52
+ ...createCatalogAliases({
53
+ gameRoot: input.gameRoot,
54
+ ...(input.productCatalogFile === undefined
55
+ ? {}
56
+ : { productCatalogFile: input.productCatalogFile }),
57
+ ...(input.adPlacementsFile === undefined
58
+ ? {}
59
+ : { adPlacementsFile: input.adPlacementsFile }),
60
+ }),
61
+ '#mpgd-platform-gateway': resolve(input.gameRoot, buildGatewayModule),
62
+ },
63
+ },
64
+ define: {
65
+ __APP_TARGET__: JSON.stringify(appTarget),
66
+ __MPGD_CONFIG_TARGET__: JSON.stringify(configTarget),
67
+ __MPGD_PLATFORM_TARGET__:
68
+ platformTarget === undefined ? 'undefined' : JSON.stringify(platformTarget),
69
+ __APP_VERSION__: JSON.stringify(process.env.APP_VERSION ?? '0.0.0-dev'),
70
+ __BUILD_ID__: JSON.stringify(process.env.BUILD_ID ?? 'local'),
71
+ __SOURCE_GIT_SHA__: JSON.stringify(process.env.MPGD_SOURCE_GIT_SHA ?? 'uncommitted'),
72
+ __DEBUG_BUILD__: JSON.stringify(!isProduction),
73
+ },
74
+ };
75
+ }
76
+
77
+ export function resolveBuildGatewayModule(input: {
78
+ readonly target: string;
79
+ readonly debug: boolean;
80
+ readonly buildId: string;
81
+ }): string {
82
+ switch (input.target) {
83
+ case 'android':
84
+ return 'src/platform/buildGateways/capacitorAndroid.ts';
85
+ case 'ios':
86
+ return 'src/platform/buildGateways/capacitorIos.ts';
87
+ case 'ait':
88
+ return input.debug
89
+ ? 'src/platform/buildGateways/aitSandbox.ts'
90
+ : 'src/platform/buildGateways/ait.ts';
91
+ case 'reddit':
92
+ return input.debug && input.buildId === devvitSandboxBuildId
93
+ ? 'src/platform/buildGateways/redditSandbox.ts'
94
+ : 'src/platform/buildGateways/reddit.ts';
95
+ default:
96
+ return 'src/platform/buildGateways/browser.ts';
97
+ }
98
+ }
99
+
100
+ function createCatalogAliases(input: {
101
+ readonly gameRoot: string;
102
+ readonly productCatalogFile?: string;
103
+ readonly adPlacementsFile?: string;
104
+ }): Record<string, string> {
105
+ const productCatalogFile = readConfiguredPath(
106
+ input.productCatalogFile ?? process.env.MPGD_PRODUCT_CATALOG_FILE,
107
+ );
108
+ const adPlacementsFile = readConfiguredPath(
109
+ input.adPlacementsFile ?? process.env.MPGD_AD_PLACEMENTS_FILE,
110
+ );
111
+
112
+ if ((productCatalogFile === undefined) !== (adPlacementsFile === undefined)) {
113
+ throw new Error(
114
+ 'productCatalogFile and adPlacementsFile '
115
+ + '(MPGD_PRODUCT_CATALOG_FILE / MPGD_AD_PLACEMENTS_FILE) must be configured together.',
116
+ );
117
+ }
118
+
119
+ if (productCatalogFile === undefined || adPlacementsFile === undefined) {
120
+ return {};
121
+ }
122
+
123
+ const catalogBaseDir = resolveCatalogBaseDir(
124
+ productCatalogFile,
125
+ adPlacementsFile,
126
+ input.gameRoot,
127
+ );
128
+
129
+ return {
130
+ '@mpgd/catalog/catalog.json': resolve(catalogBaseDir, productCatalogFile),
131
+ '@mpgd/catalog/placements.json': resolve(catalogBaseDir, adPlacementsFile),
132
+ };
133
+ }
134
+
135
+ function readConfiguredPath(value: string | undefined): string | undefined {
136
+ const normalized = value?.trim();
137
+ return normalized === undefined || normalized.length === 0 ? undefined : normalized;
138
+ }
139
+
140
+ function readRuntimePlatformTarget(
141
+ targetsFileInput: string | undefined,
142
+ configTarget: string,
143
+ ): RuntimePlatformTargetMetadata | undefined {
144
+ const targetsFile = readConfiguredPath(targetsFileInput);
145
+
146
+ if (targetsFile === undefined || configTarget.length === 0) {
147
+ return undefined;
148
+ }
149
+
150
+ const resolvedTargetsFile = resolve(targetsFile);
151
+ let parsed: unknown;
152
+
153
+ try {
154
+ parsed = JSON.parse(readFileSync(resolvedTargetsFile, 'utf8'));
155
+ } catch (error) {
156
+ throw new Error(
157
+ `Failed to read or parse MPGD_PLATFORM_TARGETS_FILE at ${resolvedTargetsFile}: ${formatError(error)}`,
158
+ );
159
+ }
160
+
161
+ if (!isRecord(parsed) || !isRecord(parsed.targets)) {
162
+ throw new Error('MPGD_PLATFORM_TARGETS_FILE must contain a targets object.');
163
+ }
164
+
165
+ const target = parsed.targets[configTarget];
166
+
167
+ if (!isRecord(target)) {
168
+ throw new Error(`Missing platform target metadata for ${configTarget}.`);
169
+ }
170
+
171
+ if (typeof target.kind !== 'string' || typeof target.adapter !== 'string') {
172
+ throw new Error(`Platform target ${configTarget} must define kind and adapter.`);
173
+ }
174
+
175
+ if (target.integrations !== undefined && !isRecord(target.integrations)) {
176
+ throw new Error(`Platform target ${configTarget} integrations must be an object.`);
177
+ }
178
+
179
+ return {
180
+ kind: target.kind,
181
+ adapter: target.adapter,
182
+ ...(target.integrations === undefined ? {} : { integrations: target.integrations }),
183
+ };
184
+ }
185
+
186
+ function isRecord(input: unknown): input is Record<string, unknown> {
187
+ return typeof input === 'object' && input !== null && !Array.isArray(input);
188
+ }
189
+
190
+ function formatError(error: unknown): string {
191
+ return error instanceof Error ? error.message : String(error);
192
+ }
193
+
194
+ function resolveCatalogBaseDir(path: string, pairedPath: string, gameRoot: string): string {
195
+ const candidates = [
196
+ gameRoot,
197
+ process.cwd(),
198
+ readConfiguredPath(process.env.INIT_CWD),
199
+ readConfiguredPath(process.env.PWD),
200
+ ];
201
+
202
+ for (const candidate of candidates) {
203
+ if (
204
+ candidate !== undefined
205
+ && existsSync(resolve(candidate, path))
206
+ && existsSync(resolve(candidate, pairedPath))
207
+ ) {
208
+ return candidate;
209
+ }
210
+ }
211
+
212
+ console.warn(
213
+ `Could not locate catalog files (${path}, ${pairedPath}) in any expected directory; `
214
+ + `falling back to game root ${gameRoot}.`,
215
+ );
216
+ return gameRoot;
217
+ }
@@ -1,37 +0,0 @@
1
- import { builtinModules } from 'node:module';
2
-
3
- import ttsc from '@ttsc/unplugin/vite';
4
- import { defineConfig } from 'vite';
5
-
6
- const externalBuiltins = [
7
- ...builtinModules,
8
- ...builtinModules.map((moduleName) => `node:${moduleName}`),
9
- ];
10
-
11
- export default defineConfig({
12
- plugins: [
13
- ttsc({
14
- project: 'tsconfig.json',
15
- plugins: false,
16
- }),
17
- ],
18
- ssr: {
19
- noExternal: true,
20
- },
21
- build: {
22
- ssr: 'src/server/index.ts',
23
- outDir: 'dist/server',
24
- target: 'node22',
25
- minify: 'esbuild',
26
- sourcemap: false,
27
- emptyOutDir: true,
28
- rollupOptions: {
29
- external: externalBuiltins,
30
- output: {
31
- format: 'cjs',
32
- entryFileNames: 'index.cjs',
33
- codeSplitting: false,
34
- },
35
- },
36
- },
37
- });