@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.
- package/client/dist/assets/index-DeYshswm.js +125 -0
- package/client/dist/assets/index-gxAuIfGQ.css +1 -0
- package/client/dist/index.html +13 -0
- package/client/index.html +12 -0
- package/client/node_modules/.bin/terser +17 -0
- package/client/node_modules/.bin/tsc +17 -0
- package/client/node_modules/.bin/vite +17 -0
- package/client/node_modules/.vite/deps/@rjsf_core.js +6609 -0
- package/client/node_modules/.vite/deps/@rjsf_core.js.map +7 -0
- package/client/node_modules/.vite/deps/@rjsf_validator-ajv8.js +13 -0
- package/client/node_modules/.vite/deps/@rjsf_validator-ajv8.js.map +7 -0
- package/client/node_modules/.vite/deps/@roster-lock_ts-client.js +419 -0
- package/client/node_modules/.vite/deps/@roster-lock_ts-client.js.map +7 -0
- package/client/node_modules/.vite/deps/@roster-lock_types.js +5 -0
- package/client/node_modules/.vite/deps/@roster-lock_types.js.map +7 -0
- package/client/node_modules/.vite/deps/@roster-lock_utils.js +5 -0
- package/client/node_modules/.vite/deps/@roster-lock_utils.js.map +7 -0
- package/client/node_modules/.vite/deps/_metadata.json +97 -0
- package/client/node_modules/.vite/deps/chunk-4D2L7OBZ.js +16039 -0
- package/client/node_modules/.vite/deps/chunk-4D2L7OBZ.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-5KLROPUR.js +292 -0
- package/client/node_modules/.vite/deps/chunk-5KLROPUR.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-77TXW5ZT.js +280 -0
- package/client/node_modules/.vite/deps/chunk-77TXW5ZT.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-I55GDNGV.js +1004 -0
- package/client/node_modules/.vite/deps/chunk-I55GDNGV.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-IIDO6PKU.js +2337 -0
- package/client/node_modules/.vite/deps/chunk-IIDO6PKU.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-NE7PXY4V.js +466 -0
- package/client/node_modules/.vite/deps/chunk-NE7PXY4V.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-WMTNK64S.js +52 -0
- package/client/node_modules/.vite/deps/chunk-WMTNK64S.js.map +7 -0
- package/client/node_modules/.vite/deps/package.json +3 -0
- package/client/node_modules/.vite/deps/react-dom.js +6 -0
- package/client/node_modules/.vite/deps/react-dom.js.map +7 -0
- package/client/node_modules/.vite/deps/react-dom_client.js +20217 -0
- package/client/node_modules/.vite/deps/react-dom_client.js.map +7 -0
- package/client/node_modules/.vite/deps/react-router-dom.js +14702 -0
- package/client/node_modules/.vite/deps/react-router-dom.js.map +7 -0
- package/client/node_modules/.vite/deps/react.js +5 -0
- package/client/node_modules/.vite/deps/react.js.map +7 -0
- package/client/node_modules/.vite/deps/react_jsx-dev-runtime.js +278 -0
- package/client/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +7 -0
- package/client/node_modules/.vite/deps/react_jsx-runtime.js +6 -0
- package/client/node_modules/.vite/deps/react_jsx-runtime.js.map +7 -0
- package/client/node_modules/.vite/deps_temp_ab51ebe9/package.json +3 -0
- package/client/package.json +30 -0
- package/client/src/App.tsx +56 -0
- package/client/src/NavBar.tsx +13 -0
- package/client/src/api/matchAgent.ts +159 -0
- package/client/src/bridge/hostBridge.ts +149 -0
- package/client/src/components/GameLauncherSettingsForm.tsx +140 -0
- package/client/src/components/InstallGameLauncherLightbox.tsx +31 -0
- package/client/src/components/Lightbox.tsx +15 -0
- package/client/src/components/RequireConnection.tsx +9 -0
- package/client/src/components/Selection/PieceCard.tsx +36 -0
- package/client/src/components/Selection/PieceTypeSection.tsx +112 -0
- package/client/src/components/Selection/PieceTypeTabs.tsx +31 -0
- package/client/src/components/Selection/PlayerSelectionPanel.tsx +40 -0
- package/client/src/components/Selection/index.tsx +147 -0
- package/client/src/components/Selection/selectionPlan.ts +75 -0
- package/client/src/context/DownloadSessionContext.tsx +48 -0
- package/client/src/context/IdentityContext.tsx +53 -0
- package/client/src/context/JoinSettingsContext.tsx +83 -0
- package/client/src/context/MatchAgentContext.tsx +85 -0
- package/client/src/hooks/useCursorInput.ts +55 -0
- package/client/src/hooks/useGamepads.ts +31 -0
- package/client/src/main.tsx +10 -0
- package/client/src/pages/Connect/index.tsx +46 -0
- package/client/src/pages/Download/index.tsx +175 -0
- package/client/src/pages/Game/index.tsx +65 -0
- package/client/src/pages/GameLauncher/Detail.tsx +12 -0
- package/client/src/pages/GameLauncher/index.tsx +32 -0
- package/client/src/pages/JoinSettings/index.tsx +58 -0
- package/client/src/pages/MatchMaking/index.tsx +190 -0
- package/client/src/styles/global.css +355 -0
- package/client/tsconfig.json +17 -0
- package/client/tsconfig.tsbuildinfo +1 -0
- package/client/vite.config.ts +33 -0
- package/dist/config/manage.js.map +1 -1
- package/dist/handle-room/version-1/game-launcher.js +181 -0
- package/dist/handle-room/version-1/game-launcher.js.map +1 -0
- package/dist/handle-room/version-1/index.js +15 -2
- package/dist/handle-room/version-1/index.js.map +1 -1
- package/dist/handle-room/version-1/piece-sort.js +1 -0
- package/dist/handle-room/version-1/piece-sort.js.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/serve-client.js +88 -0
- package/dist/serve-client.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/package.json +12 -6
- package/src/config/manage.ts +5 -1
- package/src/handle-room/version-1/game-launcher.ts +263 -0
- package/src/handle-room/version-1/globals/types.ts +24 -0
- package/src/handle-room/version-1/index.ts +23 -3
- package/src/handle-room/version-1/piece-sort.ts +1 -1
- package/src/index.ts +25 -5
- package/src/serve-client.ts +100 -0
- package/src/server.ts +1 -1
- package/tests/integration/game-launcher-list.test.ts +77 -0
- package/tests/integration/game-launcher-settings.test.ts +138 -0
- package/tests/integration/game-launcher-start.test.ts +81 -0
- package/tests/integration/helpers/fakePluginRuntime.ts +26 -1
- package/tests/integration/helpers/server.ts +8 -2
- 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 {
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
});
|