@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 +0 -35
- package/package.json +15 -15
- package/templates/phaser-game/apps/target-cloudflare-pages/package.json +3 -3
- package/templates/phaser-game/apps/target-devvit/README.md +40 -4
- package/templates/phaser-game/apps/target-devvit/devvit.json +1 -1
- package/templates/phaser-game/apps/target-devvit/package.json +7 -15
- package/templates/phaser-game/apps/target-devvit/src/client/game.html +13 -0
- package/templates/phaser-game/apps/target-devvit/src/client/game.ts +3 -0
- package/templates/phaser-game/apps/target-devvit/src/client/index.html +13 -0
- package/templates/phaser-game/apps/target-devvit/src/client/preview.ts +3 -0
- package/templates/phaser-game/apps/target-devvit/src/server/index.ts +228 -116
- package/templates/phaser-game/apps/target-devvit/tsconfig.json +7 -2
- package/templates/phaser-game/apps/target-devvit/vite.config.ts +41 -0
- package/templates/phaser-game/mpgd.targets.json +1 -0
- package/templates/phaser-game/package.json +3 -3
- package/templates/phaser-game/vite.config.ts +14 -165
- package/templates/phaser-game/vite.shared.ts +217 -0
- package/templates/phaser-game/apps/target-devvit/vite.server.config.ts +0 -37
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.
|
|
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.
|
|
51
|
-
"typescript": "7.0.
|
|
52
|
-
"@mpgd/adapter-ait": "0.4.
|
|
53
|
-
"@mpgd/adapter-
|
|
54
|
-
"@mpgd/
|
|
55
|
-
"@mpgd/adapter-
|
|
56
|
-
"@mpgd/
|
|
57
|
-
"@mpgd/bridge": "0.
|
|
58
|
-
"@mpgd/
|
|
59
|
-
"@mpgd/
|
|
60
|
-
"@mpgd/i18n": "0.5.
|
|
61
|
-
"@mpgd/
|
|
62
|
-
"@mpgd/
|
|
63
|
-
"@mpgd/target-config": "0.6.
|
|
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.
|
|
23
|
-
"ttsc": "0.
|
|
24
|
-
"typescript": "7.0.
|
|
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
|
|
29
|
-
|
|
30
|
-
The
|
|
31
|
-
|
|
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.
|
|
@@ -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.
|
|
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
|
-
"@
|
|
35
|
-
"@
|
|
27
|
+
"@devvit/start": "0.13.7",
|
|
28
|
+
"@ttsc/unplugin": "0.18.4",
|
|
36
29
|
"@types/node": "^24.0.0",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
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,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>
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
43
|
-
request:
|
|
44
|
-
response:
|
|
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
|
-
|
|
48
|
-
await sendFetchResponse(response, fetchResponse);
|
|
127
|
+
sendJson(response, 200, await handleBridgeRequest(bridgeRequest));
|
|
49
128
|
} catch (error) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
129
|
+
console.error(`devvit legacy bridge request failed: ${errorMessage(error)}`, error);
|
|
130
|
+
sendJson(
|
|
131
|
+
response,
|
|
132
|
+
500,
|
|
53
133
|
createBridgeError(
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
645
|
-
|
|
687
|
+
function requestPathname(request: IncomingMessage): string {
|
|
688
|
+
return new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
689
|
+
}
|
|
646
690
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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 (
|
|
664
|
-
|
|
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
|
-
|
|
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
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
767
|
+
function assertContentLengthWithinLimit(
|
|
768
|
+
request: IncomingMessage,
|
|
769
|
+
maxBodySize: number,
|
|
770
|
+
): void {
|
|
771
|
+
const contentLength = request.headers['content-length'];
|
|
675
772
|
|
|
676
|
-
|
|
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
|
-
|
|
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
|
|
683
|
-
|
|
684
|
-
|
|
790
|
+
function requestIdFromBody(input: unknown): string {
|
|
791
|
+
if (typeof input !== 'object' || input === null || !('id' in input)) {
|
|
792
|
+
return 'unknown';
|
|
793
|
+
}
|
|
685
794
|
|
|
686
|
-
|
|
795
|
+
const id = (input as { readonly id?: unknown }).id;
|
|
796
|
+
return typeof id === 'string' && id.length > 0 ? id : 'unknown';
|
|
687
797
|
}
|
|
688
798
|
|
|
689
|
-
|
|
690
|
-
response
|
|
691
|
-
|
|
692
|
-
)
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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": [
|
|
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
|
+
});
|
|
@@ -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.
|
|
76
|
+
"@ttsc/unplugin": "0.18.4",
|
|
77
77
|
"@types/node": "^24.0.0",
|
|
78
|
-
"ttsc": "0.
|
|
79
|
-
"typescript": "7.0.
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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:
|
|
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
|
-
});
|