@roster-lock/match-agent 0.1.0 → 0.2.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.
Files changed (107) hide show
  1. package/client/dist/assets/index-DeYshswm.js +125 -0
  2. package/client/dist/assets/index-gxAuIfGQ.css +1 -0
  3. package/client/dist/index.html +13 -0
  4. package/client/index.html +12 -0
  5. package/client/node_modules/.bin/terser +17 -0
  6. package/client/node_modules/.bin/tsc +17 -0
  7. package/client/node_modules/.bin/vite +17 -0
  8. package/client/node_modules/.vite/deps/@rjsf_core.js +6609 -0
  9. package/client/node_modules/.vite/deps/@rjsf_core.js.map +7 -0
  10. package/client/node_modules/.vite/deps/@rjsf_validator-ajv8.js +13 -0
  11. package/client/node_modules/.vite/deps/@rjsf_validator-ajv8.js.map +7 -0
  12. package/client/node_modules/.vite/deps/@roster-lock_ts-client.js +419 -0
  13. package/client/node_modules/.vite/deps/@roster-lock_ts-client.js.map +7 -0
  14. package/client/node_modules/.vite/deps/@roster-lock_types.js +5 -0
  15. package/client/node_modules/.vite/deps/@roster-lock_types.js.map +7 -0
  16. package/client/node_modules/.vite/deps/@roster-lock_utils.js +5 -0
  17. package/client/node_modules/.vite/deps/@roster-lock_utils.js.map +7 -0
  18. package/client/node_modules/.vite/deps/_metadata.json +97 -0
  19. package/client/node_modules/.vite/deps/chunk-4D2L7OBZ.js +16039 -0
  20. package/client/node_modules/.vite/deps/chunk-4D2L7OBZ.js.map +7 -0
  21. package/client/node_modules/.vite/deps/chunk-5KLROPUR.js +292 -0
  22. package/client/node_modules/.vite/deps/chunk-5KLROPUR.js.map +7 -0
  23. package/client/node_modules/.vite/deps/chunk-77TXW5ZT.js +280 -0
  24. package/client/node_modules/.vite/deps/chunk-77TXW5ZT.js.map +7 -0
  25. package/client/node_modules/.vite/deps/chunk-I55GDNGV.js +1004 -0
  26. package/client/node_modules/.vite/deps/chunk-I55GDNGV.js.map +7 -0
  27. package/client/node_modules/.vite/deps/chunk-IIDO6PKU.js +2337 -0
  28. package/client/node_modules/.vite/deps/chunk-IIDO6PKU.js.map +7 -0
  29. package/client/node_modules/.vite/deps/chunk-NE7PXY4V.js +466 -0
  30. package/client/node_modules/.vite/deps/chunk-NE7PXY4V.js.map +7 -0
  31. package/client/node_modules/.vite/deps/chunk-WMTNK64S.js +52 -0
  32. package/client/node_modules/.vite/deps/chunk-WMTNK64S.js.map +7 -0
  33. package/client/node_modules/.vite/deps/package.json +3 -0
  34. package/client/node_modules/.vite/deps/react-dom.js +6 -0
  35. package/client/node_modules/.vite/deps/react-dom.js.map +7 -0
  36. package/client/node_modules/.vite/deps/react-dom_client.js +20217 -0
  37. package/client/node_modules/.vite/deps/react-dom_client.js.map +7 -0
  38. package/client/node_modules/.vite/deps/react-router-dom.js +14702 -0
  39. package/client/node_modules/.vite/deps/react-router-dom.js.map +7 -0
  40. package/client/node_modules/.vite/deps/react.js +5 -0
  41. package/client/node_modules/.vite/deps/react.js.map +7 -0
  42. package/client/node_modules/.vite/deps/react_jsx-dev-runtime.js +278 -0
  43. package/client/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +7 -0
  44. package/client/node_modules/.vite/deps/react_jsx-runtime.js +6 -0
  45. package/client/node_modules/.vite/deps/react_jsx-runtime.js.map +7 -0
  46. package/client/node_modules/.vite/deps_temp_ab51ebe9/package.json +3 -0
  47. package/client/package.json +30 -0
  48. package/client/src/App.tsx +56 -0
  49. package/client/src/NavBar.tsx +13 -0
  50. package/client/src/api/matchAgent.ts +159 -0
  51. package/client/src/bridge/hostBridge.ts +149 -0
  52. package/client/src/components/GameLauncherSettingsForm.tsx +140 -0
  53. package/client/src/components/InstallGameLauncherLightbox.tsx +31 -0
  54. package/client/src/components/Lightbox.tsx +15 -0
  55. package/client/src/components/RequireConnection.tsx +9 -0
  56. package/client/src/components/Selection/PieceCard.tsx +36 -0
  57. package/client/src/components/Selection/PieceTypeSection.tsx +112 -0
  58. package/client/src/components/Selection/PieceTypeTabs.tsx +31 -0
  59. package/client/src/components/Selection/PlayerSelectionPanel.tsx +40 -0
  60. package/client/src/components/Selection/index.tsx +147 -0
  61. package/client/src/components/Selection/selectionPlan.ts +75 -0
  62. package/client/src/context/DownloadSessionContext.tsx +48 -0
  63. package/client/src/context/IdentityContext.tsx +53 -0
  64. package/client/src/context/JoinSettingsContext.tsx +83 -0
  65. package/client/src/context/MatchAgentContext.tsx +85 -0
  66. package/client/src/hooks/useCursorInput.ts +55 -0
  67. package/client/src/hooks/useGamepads.ts +31 -0
  68. package/client/src/main.tsx +10 -0
  69. package/client/src/pages/Connect/index.tsx +46 -0
  70. package/client/src/pages/Download/index.tsx +175 -0
  71. package/client/src/pages/Game/index.tsx +65 -0
  72. package/client/src/pages/GameLauncher/Detail.tsx +12 -0
  73. package/client/src/pages/GameLauncher/index.tsx +32 -0
  74. package/client/src/pages/JoinSettings/index.tsx +58 -0
  75. package/client/src/pages/MatchMaking/index.tsx +190 -0
  76. package/client/src/styles/global.css +355 -0
  77. package/client/tsconfig.json +17 -0
  78. package/client/tsconfig.tsbuildinfo +1 -0
  79. package/client/vite.config.ts +33 -0
  80. package/dist/config/manage.js.map +1 -1
  81. package/dist/handle-room/version-1/game-launcher.js +181 -0
  82. package/dist/handle-room/version-1/game-launcher.js.map +1 -0
  83. package/dist/handle-room/version-1/index.js +15 -2
  84. package/dist/handle-room/version-1/index.js.map +1 -1
  85. package/dist/handle-room/version-1/piece-sort.js +1 -0
  86. package/dist/handle-room/version-1/piece-sort.js.map +1 -1
  87. package/dist/index.js +7 -4
  88. package/dist/index.js.map +1 -1
  89. package/dist/serve-client.js +88 -0
  90. package/dist/serve-client.js.map +1 -0
  91. package/dist/server.js +1 -1
  92. package/dist/server.js.map +1 -1
  93. package/package.json +12 -6
  94. package/src/config/manage.ts +5 -1
  95. package/src/handle-room/version-1/game-launcher.ts +263 -0
  96. package/src/handle-room/version-1/globals/types.ts +24 -0
  97. package/src/handle-room/version-1/index.ts +23 -3
  98. package/src/handle-room/version-1/piece-sort.ts +1 -1
  99. package/src/index.ts +25 -5
  100. package/src/serve-client.ts +100 -0
  101. package/src/server.ts +1 -1
  102. package/tests/integration/game-launcher-list.test.ts +77 -0
  103. package/tests/integration/game-launcher-settings.test.ts +138 -0
  104. package/tests/integration/game-launcher-start.test.ts +81 -0
  105. package/tests/integration/helpers/fakePluginRuntime.ts +26 -1
  106. package/tests/integration/helpers/server.ts +8 -2
  107. package/src/index.old.ts +0 -71
@@ -0,0 +1,263 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { ConnectionSetup, StartGameArgs, PlatformTarget } from "@roster-lock/types";
3
+ import { GameLauncherLocalSettings } from "@roster-lock/plugin-runtime";
4
+ import { jsonBody, HTTPRequestHandler, HTTPError } from "../../utils/http-router";
5
+ import { V1Env } from "./globals/types";
6
+ import { castLockConfig } from "./piece-sort";
7
+ import z, { ZodType } from "zod";
8
+
9
+ // One listing endpoint, mirroring /piece/sort-list/available - every installed
10
+ // game-launcher plugin's full AvailableGameLauncher record in one response, rather
11
+ // than splitting connection-mode support and gameConfigSchema into separate
12
+ // per-plugin routes. Both are small, static JSON already computed by
13
+ // listAvailable() from the plugin's own module fields, so there's no
14
+ // fetch-cost reason to make a client ask for them separately, and nothing
15
+ // else in this router splits a plugin's metadata that way either.
16
+ export const listAvailableGameLaunchers: HTTPRequestHandler = async function(
17
+ this: V1Env, { res }
18
+ ){
19
+ const available = await this.pluginRuntime.gameLauncher.listAvailable();
20
+ res.writeHead(200, { "Content-Type": "application/json" });
21
+ res.end(JSON.stringify(available));
22
+ }
23
+
24
+ function requirePluginName(routeInfo: { params: Record<string, string | undefined> }): string {
25
+ const pluginName = routeInfo.params.pluginName;
26
+ if(!pluginName) throw new HTTPError(400, "Missing pluginName");
27
+ return pluginName;
28
+ }
29
+
30
+ async function requireBinaryLocation(env: V1Env, pluginName: string): Promise<string> {
31
+ const settings = await env.pluginRuntime.gameLauncher.getLocalSettings(pluginName);
32
+ if(!settings.binaryLocation){
33
+ throw new HTTPError(400, `Game launcher "${pluginName}" has no binaryLocation configured`);
34
+ }
35
+ return settings.binaryLocation;
36
+ }
37
+
38
+ // See docs/v2/binary-location.md - every GameLauncherPlugin function that
39
+ // resolves a concrete binary out of a (possibly multi-platform) binaryLocation
40
+ // bundle takes a required target, with no implicit "current host" default at
41
+ // that layer. match-agent is what decides the target value: it defaults to
42
+ // its own process.platform/process.arch (the ordinary case - a client asking
43
+ // match-agent to run/inspect the game it's about to run on this same host),
44
+ // but a caller can override either via query params for the deliberate
45
+ // exception (e.g. forcing a win32-x64 build under Wine on a Linux host).
46
+ function resolveTarget(routeInfo: { url: URL }): PlatformTarget {
47
+ const platform = routeInfo.url.searchParams.get("platform");
48
+ const arch = routeInfo.url.searchParams.get("arch");
49
+ return {
50
+ platform: (platform || process.platform) as PlatformTarget["platform"],
51
+ arch: (arch || process.arch) as PlatformTarget["arch"],
52
+ };
53
+ }
54
+
55
+ export const getGameLauncherSettings: HTTPRequestHandler = async function(
56
+ this: V1Env, { res }, routeInfo
57
+ ){
58
+ const pluginName = requirePluginName(routeInfo);
59
+ const settings = await this.pluginRuntime.gameLauncher.getLocalSettings(pluginName);
60
+ res.writeHead(200, { "Content-Type": "application/json" });
61
+ res.end(JSON.stringify(settings));
62
+ }
63
+
64
+ const localSettingsBodySchema: ZodType<GameLauncherLocalSettings> = z.object({
65
+ binaryLocation: z.string().optional(),
66
+ localConfig: z.unknown().optional(),
67
+ }).strict();
68
+
69
+ export const setGameLauncherSettings: HTTPRequestHandler = async function(
70
+ this: V1Env, { req, res }, routeInfo
71
+ ){
72
+ const pluginName = requirePluginName(routeInfo);
73
+ const body = await jsonBody(req);
74
+ const parseResult = localSettingsBodySchema.safeParse(body);
75
+ if(!parseResult.success) throw new HTTPError(400, "Bad Form", parseResult.error);
76
+
77
+ await this.pluginRuntime.gameLauncher.setLocalSettings(pluginName, parseResult.data);
78
+ res.writeHead(200, { "Content-Type": "application/json" });
79
+ res.end(JSON.stringify({ ok: true }));
80
+ }
81
+
82
+ // Installs the plugin *package* itself (registry/manifest-based, via
83
+ // PluginManager - distinct from updateBinary below, which fetches a newer
84
+ // engine binary for a plugin that's already installed).
85
+ export const installGameLauncherPlugin: HTTPRequestHandler = async function(
86
+ this: V1Env, { res }, routeInfo
87
+ ){
88
+ const pluginName = requirePluginName(routeInfo);
89
+
90
+ try {
91
+ await this.pluginRuntime.installPlugin(pluginName);
92
+ } catch(e){
93
+ throw new HTTPError(400, (e as Error).message);
94
+ }
95
+
96
+ res.writeHead(200, { "Content-Type": "application/json" });
97
+ res.end(JSON.stringify({ ok: true }));
98
+ }
99
+
100
+ export const getGameLauncherVersion: HTTPRequestHandler = async function(
101
+ this: V1Env, { res }, routeInfo
102
+ ){
103
+ const pluginName = requirePluginName(routeInfo);
104
+ const binaryLocation = await requireBinaryLocation(this, pluginName);
105
+ const target = resolveTarget(routeInfo);
106
+
107
+ const [local, supported] = await Promise.all([
108
+ this.pluginRuntime.gameLauncher.getLocalVersion(pluginName, binaryLocation, target),
109
+ this.pluginRuntime.gameLauncher.getSupportedVersion(pluginName, binaryLocation),
110
+ ]);
111
+
112
+ res.writeHead(200, { "Content-Type": "application/json" });
113
+ res.end(JSON.stringify({ local, supported }));
114
+ }
115
+
116
+ export const validateGameLauncherBinaryLocation: HTTPRequestHandler = async function(
117
+ this: V1Env, { res }, routeInfo
118
+ ){
119
+ const pluginName = requirePluginName(routeInfo);
120
+ const binaryLocation = await requireBinaryLocation(this, pluginName);
121
+ const target = resolveTarget(routeInfo);
122
+
123
+ const result = await this.pluginRuntime.gameLauncher.validateBinaryLocation(pluginName, binaryLocation, target);
124
+
125
+ res.writeHead(200, { "Content-Type": "application/json" });
126
+ res.end(JSON.stringify(result));
127
+ }
128
+
129
+ export const updateGameLauncherBinary: HTTPRequestHandler = async function(
130
+ this: V1Env, { res }, routeInfo
131
+ ){
132
+ const pluginName = requirePluginName(routeInfo);
133
+ const binaryLocation = await requireBinaryLocation(this, pluginName);
134
+ const target = resolveTarget(routeInfo);
135
+
136
+ try {
137
+ await this.pluginRuntime.gameLauncher.updateBinary(pluginName, binaryLocation, target);
138
+ } catch(e){
139
+ // updateBinary throws a plain Error when a plugin doesn't declare one at
140
+ // all (see GameLauncher.updateBinary) - that's a client-facing 400 ("this
141
+ // runner can't be updated in-app"), not a server fault.
142
+ throw new HTTPError(400, (e as Error).message);
143
+ }
144
+
145
+ res.writeHead(200, { "Content-Type": "application/json" });
146
+ res.end(JSON.stringify({ ok: true }));
147
+ }
148
+
149
+ const coordinatorAddressSchema = z.object({ host: z.string(), port: z.number() }).strict();
150
+
151
+ // This is what a caller sends match-agent, not what the plugin ends up
152
+ // with - direct-tcp still carries a coordinator address here.
153
+ // GameLauncher.startGame (plugin-runtime) resolves it into a ConnectionConfig
154
+ // before ever invoking a plugin, so a plugin never has to know a coordinator
155
+ // exists at all.
156
+ const connectionSetupSchema: ZodType<ConnectionSetup> = z.union([
157
+ z.object({
158
+ type: z.literal("direct-tcp"), party: z.literal("host"), port: z.number(), coordinator: coordinatorAddressSchema,
159
+ }).strict(),
160
+ z.object({
161
+ type: z.literal("direct-tcp"), party: z.literal("client"), port: z.number(), coordinator: coordinatorAddressSchema,
162
+ }).strict(),
163
+ z.object({ type: z.literal("room"), version: z.string(), url: z.string() }).strict(),
164
+ z.object({ type: z.literal("internal") }).strict(),
165
+ ]);
166
+
167
+ // Everything StartGameArgs needs except currentMachine.privateKeyFile (a
168
+ // match-agent-managed temp file, not something a caller ever supplies) and
169
+ // matchAgent (match-agent knows its own port/authCode - trusting a caller's
170
+ // claim about either would let it point a plugin at a different match-agent
171
+ // entirely). currentMachine's raw privateKey *is* client-supplied here: only
172
+ // the browser holds it, and GameLauncher.startGame is what turns it into the
173
+ // restrictive-permission temp file plugins actually receive.
174
+ type StartGameBody = {
175
+ connectionConfig: ConnectionSetup,
176
+ currentMachine: { machineId: string, publicKey: string, privateKey: string },
177
+ allMachines: unknown,
178
+ selectionResult: unknown,
179
+ rosterConfig: unknown,
180
+ gameConfig: unknown,
181
+ relayRoomId: string,
182
+ }
183
+
184
+ const startGameBodySchema: ZodType<StartGameBody> = z.object({
185
+ connectionConfig: connectionSetupSchema,
186
+ currentMachine: z.object({
187
+ machineId: z.string(), publicKey: z.string(), privateKey: z.string(),
188
+ }).strict(),
189
+ allMachines: z.unknown(),
190
+ selectionResult: z.unknown(),
191
+ rosterConfig: z.unknown(),
192
+ gameConfig: z.unknown(),
193
+ relayRoomId: z.string(),
194
+ }).strict();
195
+
196
+ export const startGameLauncher: HTTPRequestHandler = async function(
197
+ this: V1Env, { req, res }, routeInfo
198
+ ){
199
+ const pluginName = requirePluginName(routeInfo);
200
+ const binaryLocation = await requireBinaryLocation(this, pluginName);
201
+ const target = resolveTarget(routeInfo);
202
+
203
+ const body = await jsonBody(req);
204
+ const parseResult = startGameBodySchema.safeParse(body);
205
+ if(!parseResult.success) throw new HTTPError(400, "Bad Form", parseResult.error);
206
+ const { connectionConfig, currentMachine, allMachines, selectionResult, gameConfig, relayRoomId } = parseResult.data;
207
+ const rosterConfig = castLockConfig(parseResult.data.rosterConfig);
208
+
209
+ const handle = await this.pluginRuntime.gameLauncher.startGame(pluginName, binaryLocation, target, connectionConfig, {
210
+ currentMachine,
211
+ allMachines: allMachines as StartGameArgs<unknown>["allMachines"],
212
+ selectionResult: selectionResult as StartGameArgs<unknown>["selectionResult"],
213
+ rosterConfig,
214
+ gameConfig,
215
+ relayRoomId,
216
+ matchAgent: { port: this.matchAgent.getPort(), authCode: this.matchAgent.authCode },
217
+ });
218
+
219
+ const handleId = randomUUID();
220
+ this.processHandles.set(handleId, { pluginName, handle });
221
+
222
+ res.writeHead(200, { "Content-Type": "application/json" });
223
+ res.end(JSON.stringify({ handleId }));
224
+ }
225
+
226
+ export const getGameProcessStatus: HTTPRequestHandler = async function(
227
+ this: V1Env, { res }, routeInfo
228
+ ){
229
+ const handleId = routeInfo.params.handleId;
230
+ const entry = handleId ? this.processHandles.get(handleId) : undefined;
231
+ if(!entry) throw new HTTPError(404, "Unknown process handle");
232
+
233
+ res.writeHead(200, { "Content-Type": "application/json" });
234
+ res.end(JSON.stringify({ exited: entry.handle.exited }));
235
+ }
236
+
237
+ // Every process this match-agent has started, across every game-launcher
238
+ // plugin - backs pages/Game in match-agent-client, which shows one row per
239
+ // entry regardless of which plugin launched it.
240
+ export const listGameProcesses: HTTPRequestHandler = async function(
241
+ this: V1Env, { res }
242
+ ){
243
+ const processes = Array.from(this.processHandles.entries()).map(([handleId, entry]) => ({
244
+ handleId, pluginName: entry.pluginName, exited: entry.handle.exited,
245
+ }));
246
+ res.writeHead(200, { "Content-Type": "application/json" });
247
+ res.end(JSON.stringify(processes));
248
+ }
249
+
250
+ // Best-effort, same as GameProcessHandle.stop() itself - see that type's own
251
+ // docs on why a plugin may not always be able to actually stop the game.
252
+ export const stopGameProcess: HTTPRequestHandler = async function(
253
+ this: V1Env, { res }, routeInfo
254
+ ){
255
+ const handleId = routeInfo.params.handleId;
256
+ const entry = handleId ? this.processHandles.get(handleId) : undefined;
257
+ if(!entry) throw new HTTPError(404, "Unknown process handle");
258
+
259
+ await entry.handle.stop();
260
+
261
+ res.writeHead(200, { "Content-Type": "application/json" });
262
+ res.end(JSON.stringify({ ok: true }));
263
+ }
@@ -1,7 +1,31 @@
1
1
 
2
2
  import { IFolderDB } from "./FolderDB";
3
3
  import { PluginManager } from "@roster-lock/plugin-runtime";
4
+ import { GameProcessHandle } from "@roster-lock/types";
5
+
6
+ // Known upfront (CLI --port) rather than read off a listening socket, so it's
7
+ // a plain value in the common case - getPort() only needs to be lazy for
8
+ // tests that bind an ephemeral port (0) and only learn the real one after
9
+ // server.listen() resolves.
10
+ export type MatchAgentSelfInfo = {
11
+ authCode: string,
12
+ getPort: () => number,
13
+ }
14
+
15
+ // pluginName is kept alongside the handle (not just the handleId key) so a
16
+ // process can be listed/identified without a caller having to already know
17
+ // which plugin started it - see game-launcher.ts's listGameProcesses.
18
+ export type ProcessHandleEntry = {
19
+ pluginName: string,
20
+ handle: GameProcessHandle,
21
+ }
22
+
4
23
  export type V1Env = {
5
24
  fileDB: IFolderDB,
6
25
  pluginRuntime: PluginManager,
26
+ matchAgent: MatchAgentSelfInfo,
27
+ // In-memory only - a match-agent restart loses track of processes it
28
+ // started before the restart (same best-effort tradeoff as GameProcessHandle
29
+ // itself; there's no persistent process-supervision story here).
30
+ processHandles: Map<string, ProcessHandleEntry>,
7
31
  }
@@ -7,11 +7,17 @@ import { httpHandler } from "./room-handler-bridge/http-handler";
7
7
  import { getFilesOfAsset, getPieceFileContents } from "./file-routes";
8
8
  import { ensurePieceDownloaded, ensurePieceDownloadedWs, listDownloadedPiecesDirect } from "./select";
9
9
  import { listAvailableSortPlugins, sortListPlugin, gameComplete } from "./piece-sort";
10
- import { IFolderDB, V1Env } from "./globals";
10
+ import {
11
+ listAvailableGameLaunchers, getGameLauncherSettings, setGameLauncherSettings, getGameLauncherVersion,
12
+ validateGameLauncherBinaryLocation, updateGameLauncherBinary, startGameLauncher, getGameProcessStatus,
13
+ installGameLauncherPlugin, listGameProcesses, stopGameProcess,
14
+ } from "./game-launcher";
15
+ import { IFolderDB, V1Env, MatchAgentSelfInfo, ProcessHandleEntry } from "./globals";
11
16
  import { PluginManager } from "@roster-lock/plugin-runtime";
12
17
 
13
- export const createV1Routers = (fileDB: IFolderDB, pluginRuntime: PluginManager)=>{
14
- const env: V1Env = { fileDB, pluginRuntime };
18
+ export const createV1Routers = (fileDB: IFolderDB, pluginRuntime: PluginManager, matchAgent: MatchAgentSelfInfo)=>{
19
+ const processHandles = new Map<string, ProcessHandleEntry>();
20
+ const env: V1Env = { fileDB, pluginRuntime, matchAgent, processHandles };
15
21
  const httpRouter = new HTTPRouter();
16
22
  const wsRouter = new WebSocketRouter()
17
23
 
@@ -23,6 +29,20 @@ export const createV1Routers = (fileDB: IFolderDB, pluginRuntime: PluginManager)
23
29
  httpRouter.get("/piece/sort-list/available", listAvailableSortPlugins.bind(env));
24
30
  httpRouter.post("/piece/sort-list/plugin/:pluginName", sortListPlugin.bind(env));
25
31
  httpRouter.post("/game-complete", gameComplete.bind(env));
32
+ httpRouter.get("/game-launcher/available", listAvailableGameLaunchers.bind(env));
33
+ httpRouter.post("/game-launcher/:pluginName/install", installGameLauncherPlugin.bind(env));
34
+ httpRouter.get("/game-launcher/:pluginName/settings", getGameLauncherSettings.bind(env));
35
+ httpRouter.put("/game-launcher/:pluginName/settings", setGameLauncherSettings.bind(env));
36
+ httpRouter.get("/game-launcher/:pluginName/version", getGameLauncherVersion.bind(env));
37
+ httpRouter.get("/game-launcher/:pluginName/validate", validateGameLauncherBinaryLocation.bind(env));
38
+ httpRouter.post("/game-launcher/:pluginName/update", updateGameLauncherBinary.bind(env));
39
+ httpRouter.post("/game-launcher/:pluginName/start", startGameLauncher.bind(env));
40
+ httpRouter.get("/game-launcher/:pluginName/process/:handleId", getGameProcessStatus.bind(env));
41
+ httpRouter.post("/game-launcher/:pluginName/process/:handleId/stop", stopGameProcess.bind(env));
42
+ // Plugin-agnostic, unlike the routes above - lists every process this
43
+ // match-agent has started across every plugin (see pages/Game in
44
+ // match-agent-client), not just one plugin's.
45
+ httpRouter.get("/game-launcher/processes", listGameProcesses.bind(env));
26
46
 
27
47
 
28
48
  wsRouter.mount("/sync-dl", wsHandler.bind(env));
@@ -6,7 +6,7 @@ import { UserSelectionSchema } from "./schema/selected";
6
6
  import { V1Env } from "./globals/types";
7
7
  import z, { ZodType } from "zod";
8
8
 
9
- function castLockConfig(uncasted: unknown): RosterLockV1Config {
9
+ export function castLockConfig(uncasted: unknown): RosterLockV1Config {
10
10
  const casted = ROSTERLOCK_V1_CASTER_JSONSCHEMA.safeCast(uncasted, true);
11
11
  if(!casted.valid) throw new HTTPError(400, "Invalid lockConfig", casted.error);
12
12
  return casted.value;
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { generateAuthCode, authMiddleware, validateAuthCode } from "./authentica
11
11
  import { corsMiddleware } from "./cors";
12
12
  import { createEditorV1Router } from "./editor-support";
13
13
  import { MatchAgentServer } from "./server";
14
+ import { createDevClientHandler, createStaticClientHandler } from "./serve-client";
14
15
  import { program } from "commander";
15
16
  import { createV1Routers } from "./handle-room/version-1";
16
17
  import { getSQLite3FolderDB } from "./handle-room/version-1/globals/FolderDB";
@@ -55,8 +56,13 @@ program
55
56
  "config file to read/persist the auth code and folders from " +
56
57
  "(defaults to a config next to this executable, falling back to the home directory)"
57
58
  )
59
+ .option(
60
+ "--dev",
61
+ "serve the match-agent-client from its Vite dev server (live source, HMR) instead of the prebuilt client/dist",
62
+ false
63
+ )
58
64
  .action(async (options: {
59
- port: string, authCode?: string, pieceFolder?: string, pluginFolder?: string, configFile?: string,
65
+ port: string, authCode?: string, pieceFolder?: string, pluginFolder?: string, configFile?: string, dev: boolean,
60
66
  }) => {
61
67
  const port = Number(options.port);
62
68
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
@@ -84,7 +90,7 @@ program
84
90
  await mkdir(pieceFolder, { recursive: true });
85
91
  await mkdir(pluginFolder, { recursive: true });
86
92
 
87
- startServer(port, authCode, pluginFolder, pieceFolder);
93
+ startServer(port, authCode, pluginFolder, pieceFolder, { dev: options.dev });
88
94
  });
89
95
 
90
96
  program
@@ -203,7 +209,10 @@ if (require.main === module) {
203
209
  program.parse();
204
210
  }
205
211
 
206
- export async function startServer(port: number, authCode: string, pluginFolder: string, piecesFolder: string){
212
+ export async function startServer(
213
+ port: number, authCode: string, pluginFolder: string, piecesFolder: string,
214
+ options: { dev?: boolean } = {}
215
+ ){
207
216
  const pluginRuntime = await PluginManager.create(pluginFolder);
208
217
  const fileDB = getSQLite3FolderDB(piecesFolder, pluginRuntime);
209
218
 
@@ -211,13 +220,15 @@ export async function startServer(port: number, authCode: string, pluginFolder:
211
220
  // "{/*path}" = path-to-regexp v8 catch-all; a bare "/" only matches the
212
221
  // root exactly, which would skip CORS on every real route.
213
222
  server.httpRouter.use("{/*path}", corsMiddleware);
214
- server.httpRouter.get("/", ({ res })=>{
223
+ server.httpRouter.get("/health", ({ res })=>{
215
224
  res.writeHead(200, { "Content-Type": "application/json" });
216
225
  res.end(JSON.stringify({ "hello": "world" }));
217
226
  });
218
227
  server.httpRouter.post("/validate-authcode", validateAuthCode(authCode))
219
228
 
220
- const { httpRouter: v1HttpRouter, wsRouter: v1WsRouter } = createV1Routers(fileDB, pluginRuntime);
229
+ const { httpRouter: v1HttpRouter, wsRouter: v1WsRouter } = createV1Routers(
230
+ fileDB, pluginRuntime, { authCode, getPort: () => port }
231
+ );
221
232
  server.httpRouter.use("/v1", authMiddleware(authCode), v1HttpRouter);
222
233
  server.wsRouter.use("/v1", authMiddleware(authCode), v1WsRouter);
223
234
 
@@ -225,6 +236,15 @@ export async function startServer(port: number, authCode: string, pluginFolder:
225
236
  // room protocol (/v1) can move to /v2 without dragging the editor API along.
226
237
  server.httpRouter.use("/editor/v1", authMiddleware(authCode), createEditorV1Router(pluginRuntime, fileDB));
227
238
 
239
+ // Mounted last so it only ever sees requests the routes above didn't
240
+ // claim - match-agent-client stays Vite's, this just puts it behind the
241
+ // same origin/port as the API instead of a separately-run `vite dev`.
242
+ // .all (not .use) - .use's prefix-mount semantics strip the matched
243
+ // portion off the pathname for nested routing, which for a "{/*path}"
244
+ // catch-all is the entire path, leaving the client handler unable to
245
+ // tell one route from another.
246
+ server.httpRouter.all("{/*path}", options.dev ? createDevClientHandler() : createStaticClientHandler());
247
+
228
248
  server.listen(port, () => {
229
249
  console.log(`match-agent listening on port ${port}`);
230
250
  });
@@ -0,0 +1,100 @@
1
+
2
+ import { createReadStream } from "node:fs";
3
+ import { stat as fsStat, readFile } from "node:fs/promises";
4
+ import { join, resolve, normalize, sep } from "node:path";
5
+ import { lookup as mimeLookup } from "mime-types";
6
+ import { HTTPRequestHandler } from "./utils/http-router";
7
+
8
+ // dist/serve-client.js and src/serve-client.ts sit at the same depth under
9
+ // match-agent/, so this resolves the same way whether running compiled or
10
+ // straight from source.
11
+ export const CLIENT_DIR = resolve(__dirname, "../client");
12
+ export const CLIENT_DIST_DIR = join(CLIENT_DIR, "dist");
13
+
14
+ // Routes the client's SPA fallback must not swallow.
15
+ const API_PATHS = ["/v1", "/editor/v1", "/validate-authcode", "/health"];
16
+ function isApiPath(pathname: string): boolean {
17
+ return API_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`));
18
+ }
19
+
20
+ // vite is ESM-only, dynamically imported so the dependency stays optional
21
+ // for the prebuilt/static path. The type is inferred from that import
22
+ // rather than named ("vite"'s CJS type stub - what a "require"-resolved
23
+ // static `import type` would hit from this CommonJS package - is a bare
24
+ // `any`; only its real ESM types describe ViteDevServer).
25
+ function createViteDevServer(){
26
+ return import("vite").then(({ createServer }) => createServer({
27
+ root: CLIENT_DIR,
28
+ server: { middlewareMode: true },
29
+ appType: "custom",
30
+ }));
31
+ }
32
+
33
+ // Vite dev server is expensive to boot (it crawls the client's deps) and
34
+ // middleware mode expects one long-lived instance, so it's created once and
35
+ // reused across requests rather than per-request.
36
+ let viteServerPromise: ReturnType<typeof createViteDevServer> | undefined;
37
+ function getViteDevServer(){
38
+ if(!viteServerPromise) viteServerPromise = createViteDevServer();
39
+ return viteServerPromise;
40
+ }
41
+
42
+ // Live-source mode: proxies through Vite's own dev middleware (transforms,
43
+ // HMR websocket upgrade, etc.) so the client keeps building from its Vite
44
+ // config instead of a separately-built bundle.
45
+ export function createDevClientHandler(): HTTPRequestHandler {
46
+ return async ({ req, res }, { url }, next) => {
47
+ if(isApiPath(url.pathname)) return next();
48
+ const vite = await getViteDevServer();
49
+ vite.middlewares(req, res, async (err?: unknown) => {
50
+ if(err) return next(err);
51
+ if((req.method || "GET").toUpperCase() !== "GET") return next();
52
+ try {
53
+ const rawHtml = await readFile(join(CLIENT_DIR, "index.html"), "utf-8");
54
+ const html = await vite.transformIndexHtml(url.pathname, rawHtml);
55
+ res.writeHead(200, { "Content-Type": "text/html" });
56
+ res.end(html);
57
+ } catch(e){
58
+ next(e);
59
+ }
60
+ });
61
+ };
62
+ }
63
+
64
+ // Prebuilt mode: serves client/dist as static files, falling back to
65
+ // index.html for any unmatched GET so client-side routes resolve on refresh.
66
+ export function createStaticClientHandler(distDir: string = CLIENT_DIST_DIR): HTTPRequestHandler {
67
+ return async ({ req, res }, { url }, next) => {
68
+ if(isApiPath(url.pathname)) return next();
69
+ if((req.method || "GET").toUpperCase() !== "GET") return next();
70
+
71
+ let requestedPath: string;
72
+ try {
73
+ requestedPath = normalize(join(distDir, decodeURIComponent(url.pathname)));
74
+ } catch(e){
75
+ return next(e);
76
+ }
77
+ if(requestedPath !== distDir && !requestedPath.startsWith(distDir + sep)){
78
+ res.writeHead(400, { "Content-Type": "application/json" });
79
+ res.end(JSON.stringify({ error: "Bad path" }));
80
+ return;
81
+ }
82
+
83
+ const filePath = (await resolveExistingFile(requestedPath)) ?? join(distDir, "index.html");
84
+ try {
85
+ res.writeHead(200, { "Content-Type": mimeLookup(filePath) || "application/octet-stream" });
86
+ createReadStream(filePath).pipe(res);
87
+ } catch(e){
88
+ next(e);
89
+ }
90
+ };
91
+ }
92
+
93
+ async function resolveExistingFile(path: string): Promise<string | undefined> {
94
+ try {
95
+ const stat = await fsStat(path);
96
+ return stat.isFile() ? path : undefined;
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
package/src/server.ts CHANGED
@@ -33,7 +33,7 @@ export class MatchAgentServer {
33
33
  });
34
34
  this.httpServer.on('request', (req, res)=>{
35
35
  this.httpRouter.handleRequest({ req, res }, (err: unknown)=>{
36
- debug && console.log("HTTP Error:", err);
36
+ debug && console.log("HTTP Error:", req.method, req.url, err);
37
37
  if(res.writableEnded){
38
38
  debug && console.warn("Router threw error and response ended")
39
39
  return;
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { startTestServer, TestServer } from "./helpers/server";
3
+ import { makeTempFolder, cleanupFolder } from "./helpers/piece";
4
+ import { createFixturePluginDir } from "./helpers/plugin-dir";
5
+
6
+ describe("GET /v1/game-launcher/available", () => {
7
+ const cleanups: Array<() => Promise<void> | void> = [];
8
+ afterEach(async () => {
9
+ while (cleanups.length) await cleanups.pop()!();
10
+ });
11
+
12
+ function get(server: TestServer) {
13
+ return fetch(`${server.httpUrl}/v1/game-launcher/available`, {
14
+ headers: { Authorization: `Bearer ${server.authCode}` },
15
+ });
16
+ }
17
+
18
+ it("returns [] when no game-launcher plugins are installed", async () => {
19
+ const folder = await makeTempFolder();
20
+ cleanups.push(() => cleanupFolder(folder));
21
+ const server = await startTestServer(folder);
22
+ cleanups.push(() => server.close());
23
+
24
+ const res = await get(server);
25
+ expect(res.status).toBe(200);
26
+ expect(await res.json()).toEqual([]);
27
+ });
28
+
29
+ it("lists an installed game-launcher plugin with its connection/schema/engineSha metadata", async () => {
30
+ // "private": true on ikemen-go's package.json means it can't be resolved
31
+ // via npm - installPlugin takes a local folder path directly instead
32
+ // (arborist installs it same as `npm install <path>`), which is how this
33
+ // fixture, and every dl-*/piece-selection-sort-* fixture before it, gets
34
+ // installed for tests without publishing anything.
35
+ const fixture = await createFixturePluginDir(["@roster-lock/game-launcher-ikemen-go"]);
36
+ cleanups.push(fixture.cleanup);
37
+
38
+ const folder = await makeTempFolder();
39
+ cleanups.push(() => cleanupFolder(folder));
40
+ const server = await startTestServer(folder, undefined, fixture.pluginDir);
41
+ cleanups.push(() => server.close());
42
+
43
+ const res = await get(server);
44
+ expect(res.status).toBe(200);
45
+ const body = await res.json() as Array<Record<string, unknown>>;
46
+ expect(body).toHaveLength(1);
47
+
48
+ const [runner] = body;
49
+ expect(runner.pluginName).toBe("@roster-lock/game-launcher-ikemen-go");
50
+ expect(runner.publicInfo).toMatchObject({ title: expect.any(String), description: expect.any(String) });
51
+ expect(runner.supportedConnectionModes).toEqual(["direct-tcp"]);
52
+ expect(runner.supportedRoomVersions).toBeUndefined();
53
+ // A real sha256 hex digest, not a placeholder - proves engineSha survived
54
+ // the plugin's own module boundary (install -> import -> JSON response),
55
+ // not just that the field exists.
56
+ expect(runner.engineSha).toMatch(/^[0-9a-f]{64}$/);
57
+ expect(runner.gameConfigSchema).toMatchObject({ type: "object" });
58
+ expect(runner.localConfigSchema).toEqual({});
59
+ });
60
+
61
+ it("ignores plugins of other types", async () => {
62
+ const fixture = await createFixturePluginDir([
63
+ "@roster-lock/piece-selection-sort-most-used-locally",
64
+ "@roster-lock/dl-protocol-http",
65
+ ]);
66
+ cleanups.push(fixture.cleanup);
67
+
68
+ const folder = await makeTempFolder();
69
+ cleanups.push(() => cleanupFolder(folder));
70
+ const server = await startTestServer(folder, undefined, fixture.pluginDir);
71
+ cleanups.push(() => server.close());
72
+
73
+ const res = await get(server);
74
+ expect(res.status).toBe(200);
75
+ expect(await res.json()).toEqual([]);
76
+ });
77
+ });