Skip to content

Commit 864fc5a

Browse files
committed
Add team-aware dev bootstrap
1 parent 78827f1 commit 864fc5a

11 files changed

Lines changed: 1208 additions & 21 deletions

dev/bootstrap/start-dev.mjs

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
import { spawn } from "node:child_process";
2+
import { existsSync, readFileSync } from "node:fs";
3+
import fs from "node:fs/promises";
4+
import http from "node:http";
5+
import path from "node:path";
6+
import process from "node:process";
7+
import { fileURLToPath, pathToFileURL } from "node:url";
8+
import { resolveTeamPortConfig, supportedBootstrapTeamsMessage } from "./team-port-config.mjs";
9+
10+
const __filename = fileURLToPath(import.meta.url);
11+
const __dirname = path.dirname(__filename);
12+
const repoRoot = path.resolve(__dirname, "..", "..");
13+
const DEFAULT_ENV_FILE = ".env";
14+
15+
function parseEnvValue(value) {
16+
const trimmed = String(value || "").trim();
17+
if (
18+
(trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
19+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
20+
) {
21+
return trimmed.slice(1, -1);
22+
}
23+
return trimmed;
24+
}
25+
26+
function parseEnvLine(line) {
27+
const trimmed = String(line || "").trim();
28+
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
29+
return null;
30+
}
31+
const separatorIndex = trimmed.indexOf("=");
32+
const key = trimmed.slice(0, separatorIndex).trim();
33+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
34+
return null;
35+
}
36+
return {
37+
key,
38+
value: parseEnvValue(trimmed.slice(separatorIndex + 1)),
39+
};
40+
}
41+
42+
export function loadEnvironmentFile({
43+
env = process.env,
44+
envFile = path.resolve(repoRoot, DEFAULT_ENV_FILE),
45+
} = {}) {
46+
if (!existsSync(envFile)) {
47+
return Object.freeze({
48+
loaded: false,
49+
loadedKeys: [],
50+
path: envFile,
51+
});
52+
}
53+
const loadedKeys = [];
54+
readFileSync(envFile, "utf8").split(/\r?\n/).forEach((line) => {
55+
const parsed = parseEnvLine(line);
56+
if (!parsed || env[parsed.key] !== undefined) {
57+
return;
58+
}
59+
env[parsed.key] = parsed.value;
60+
loadedKeys.push(parsed.key);
61+
});
62+
return Object.freeze({
63+
loaded: true,
64+
loadedKeys: Object.freeze(loadedKeys),
65+
path: envFile,
66+
});
67+
}
68+
69+
export function applyTeamBootstrapEnvironment(env, teamConfig) {
70+
env.GAMEFOUNDRY_LOCAL_API_HOST = teamConfig.host;
71+
env.GAMEFOUNDRY_LOCAL_API_PORT = String(teamConfig.apiPort);
72+
env.GAMEFOUNDRY_SITE_URL = teamConfig.webUrl;
73+
env.GAMEFOUNDRY_API_URL = teamConfig.apiUrl;
74+
return env;
75+
}
76+
77+
export function parseBootstrapArgs(argv = []) {
78+
const options = {
79+
openBrowser: false,
80+
startApi: true,
81+
startWeb: true,
82+
team: "owner",
83+
};
84+
85+
for (let index = 0; index < argv.length; index += 1) {
86+
const argument = argv[index];
87+
if (argument === "--team") {
88+
const value = argv[index + 1];
89+
if (!value || value.startsWith("--")) {
90+
throw new Error(`Missing value for --team. Use --team ${supportedBootstrapTeamsMessage()}.`);
91+
}
92+
options.team = value;
93+
index += 1;
94+
} else if (argument.startsWith("--team=")) {
95+
options.team = argument.slice("--team=".length);
96+
} else if (argument === "--api-only") {
97+
options.startApi = true;
98+
options.startWeb = false;
99+
} else if (argument === "--web-only") {
100+
options.startApi = false;
101+
options.startWeb = true;
102+
} else if (argument === "--api") {
103+
options.startApi = true;
104+
} else if (argument === "--web") {
105+
options.startWeb = true;
106+
} else if (argument === "--no-api") {
107+
options.startApi = false;
108+
} else if (argument === "--no-web") {
109+
options.startWeb = false;
110+
} else if (argument === "--open") {
111+
options.openBrowser = true;
112+
} else if (argument === "--no-open") {
113+
options.openBrowser = false;
114+
} else if (argument === "--help" || argument === "-h") {
115+
options.help = true;
116+
} else {
117+
throw new Error(`Unknown bootstrap option "${argument}". Use --team ${supportedBootstrapTeamsMessage()}, --api-only, --web-only, or --open.`);
118+
}
119+
}
120+
121+
if (!options.help && !options.startApi && !options.startWeb) {
122+
throw new Error("Bootstrap must start at least one process. Use --api-only, --web-only, or the default combined startup.");
123+
}
124+
125+
return Object.freeze(options);
126+
}
127+
128+
function contentTypeForPath(filePath) {
129+
const extension = path.extname(filePath).toLowerCase();
130+
if (extension === ".html") return "text/html; charset=utf-8";
131+
if (extension === ".js" || extension === ".mjs") return "text/javascript; charset=utf-8";
132+
if (extension === ".json") return "application/json; charset=utf-8";
133+
if (extension === ".css") return "text/css; charset=utf-8";
134+
if (extension === ".svg") return "image/svg+xml";
135+
if (extension === ".png") return "image/png";
136+
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
137+
if (extension === ".webp") return "image/webp";
138+
if (extension === ".gif") return "image/gif";
139+
if (extension === ".wav") return "audio/wav";
140+
if (extension === ".mp3") return "audio/mpeg";
141+
if (extension === ".ogg") return "audio/ogg";
142+
if (extension === ".m4a") return "audio/mp4";
143+
if (extension === ".woff2") return "font/woff2";
144+
if (extension === ".woff") return "font/woff";
145+
if (extension === ".ttf") return "font/ttf";
146+
if (extension === ".otf") return "font/otf";
147+
if (extension === ".csv") return "text/csv; charset=utf-8";
148+
if (extension === ".txt") return "text/plain; charset=utf-8";
149+
return "application/octet-stream";
150+
}
151+
152+
function isInsideRoot(root, absolutePath) {
153+
const relativePath = path.relative(root, absolutePath);
154+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
155+
}
156+
157+
function resolveWebRoutePath(requestPath) {
158+
const normalizedPath = path.normalize(decodeURIComponent(requestPath || "/")).replace(/^(\.\.[/\\])+/, "");
159+
const webPath = normalizedPath.replace(/\\/g, "/");
160+
if (webPath === "/tools" || webPath.startsWith("/tools/")) {
161+
return `/toolbox${webPath.slice("/tools".length)}`;
162+
}
163+
return normalizedPath;
164+
}
165+
166+
export async function startStaticWebServer({
167+
host,
168+
port,
169+
root = repoRoot,
170+
} = {}) {
171+
const server = http.createServer(async (request, response) => {
172+
try {
173+
const requestUrl = new URL(request.url || "/", `http://${host}:${port}`);
174+
const routePath = resolveWebRoutePath(requestUrl.pathname);
175+
const absolutePath = path.resolve(root, `.${routePath}`);
176+
if (!isInsideRoot(root, absolutePath)) {
177+
response.statusCode = 403;
178+
response.end("Forbidden");
179+
return;
180+
}
181+
182+
let targetPath = absolutePath;
183+
const stat = await fs.stat(targetPath).catch(() => null);
184+
if (stat && stat.isDirectory()) {
185+
targetPath = path.join(targetPath, "index.html");
186+
}
187+
const responseContents = await fs.readFile(targetPath);
188+
response.statusCode = 200;
189+
response.setHeader("Content-Type", contentTypeForPath(targetPath));
190+
response.end(responseContents);
191+
} catch {
192+
response.statusCode = 404;
193+
response.end("Not Found");
194+
}
195+
});
196+
197+
await new Promise((resolve, reject) => {
198+
server.once("error", reject);
199+
server.listen(port, host, resolve);
200+
});
201+
202+
return Object.freeze({
203+
close: () => new Promise((resolve, reject) => {
204+
server.close((error) => {
205+
if (error) reject(error);
206+
else resolve();
207+
});
208+
}),
209+
server,
210+
url: `http://${host}:${port}`,
211+
});
212+
}
213+
214+
export function formatBootstrapDiagnostics({
215+
envLoad,
216+
openBrowser,
217+
startApi,
218+
startWeb,
219+
teamConfig,
220+
} = {}) {
221+
const envStatus = envLoad?.loaded
222+
? `${path.relative(repoRoot, envLoad.path).replace(/\\/g, "/")} loaded (${envLoad.loadedKeys.length} key(s) applied)`
223+
: `${DEFAULT_ENV_FILE} not found; using process environment`;
224+
return [
225+
"GameFoundry local bootstrap",
226+
`Team: ${teamConfig.team}`,
227+
`Web: ${startWeb ? `enabled ${teamConfig.webUrl}` : "disabled"}`,
228+
`API: ${startApi ? `enabled ${teamConfig.apiUrl}` : "disabled"}`,
229+
`Selected ports: web ${teamConfig.webPort}, api ${teamConfig.apiPort}`,
230+
`Environment: ${envStatus}`,
231+
`Browser launch: ${openBrowser ? "enabled" : "disabled"}`,
232+
];
233+
}
234+
235+
function startApiProcess({ env }) {
236+
return spawn(
237+
process.execPath,
238+
["--use-system-ca", "./dev/scripts/start-local-api-server.mjs"],
239+
{
240+
cwd: repoRoot,
241+
env,
242+
stdio: "inherit",
243+
}
244+
);
245+
}
246+
247+
function browserLaunchCommand(url) {
248+
if (process.platform === "win32") {
249+
return { args: ["/c", "start", "", url], command: "cmd" };
250+
}
251+
if (process.platform === "darwin") {
252+
return { args: [url], command: "open" };
253+
}
254+
return { args: [url], command: "xdg-open" };
255+
}
256+
257+
function openBrowser(url) {
258+
const launch = browserLaunchCommand(url);
259+
const child = spawn(launch.command, launch.args, {
260+
detached: true,
261+
stdio: "ignore",
262+
});
263+
child.unref();
264+
}
265+
266+
function printHelp() {
267+
console.log([
268+
"Usage: node ./dev/bootstrap/start-dev.mjs [--team owner|alpha|bravo|charlie|gamma|beta|default] [--api-only|--web-only] [--open]",
269+
"",
270+
"Default startup launches both the API runtime and static web server.",
271+
"Use --api-only for API-only startup or --web-only for web-only startup.",
272+
].join("\n"));
273+
}
274+
275+
export async function runBootstrap(argv = process.argv.slice(2), {
276+
env = process.env,
277+
startApi = startApiProcess,
278+
startWeb = startStaticWebServer,
279+
} = {}) {
280+
const options = parseBootstrapArgs(argv);
281+
if (options.help) {
282+
printHelp();
283+
return Object.freeze({ status: "help" });
284+
}
285+
286+
const teamConfig = resolveTeamPortConfig(options.team);
287+
const envLoad = loadEnvironmentFile({ env });
288+
const processEnv = applyTeamBootstrapEnvironment({ ...env }, teamConfig);
289+
290+
formatBootstrapDiagnostics({
291+
envLoad,
292+
openBrowser: options.openBrowser,
293+
startApi: options.startApi,
294+
startWeb: options.startWeb,
295+
teamConfig,
296+
}).forEach((line) => console.log(line));
297+
298+
let webServer = null;
299+
let apiProcess = null;
300+
let shuttingDown = false;
301+
302+
async function shutdown(exitCode = 0) {
303+
if (shuttingDown) {
304+
return;
305+
}
306+
shuttingDown = true;
307+
if (apiProcess && !apiProcess.killed) {
308+
apiProcess.kill("SIGTERM");
309+
}
310+
if (webServer) {
311+
await webServer.close();
312+
}
313+
process.exit(exitCode);
314+
}
315+
316+
if (options.startWeb) {
317+
webServer = await startWeb({
318+
host: teamConfig.host,
319+
port: teamConfig.webPort,
320+
root: repoRoot,
321+
});
322+
console.log(`GameFoundry static web server running at ${webServer.url}`);
323+
}
324+
325+
if (options.startApi) {
326+
apiProcess = startApi({ env: processEnv });
327+
apiProcess.once("exit", async (code, signal) => {
328+
if (shuttingDown) {
329+
return;
330+
}
331+
const exitReason = signal || (code ?? "unknown");
332+
console.error(`GameFoundry API process exited (${exitReason}).`);
333+
await shutdown(code || 1);
334+
});
335+
}
336+
337+
if (options.openBrowser) {
338+
openBrowser(options.startWeb ? teamConfig.webUrl : teamConfig.apiUrl);
339+
}
340+
341+
for (const signal of ["SIGINT", "SIGTERM"]) {
342+
process.once(signal, () => {
343+
shutdown(0).catch((error) => {
344+
console.error(error instanceof Error ? error.stack || error.message : String(error));
345+
process.exit(1);
346+
});
347+
});
348+
}
349+
350+
return Object.freeze({
351+
apiProcess,
352+
envLoad,
353+
teamConfig,
354+
webServer,
355+
});
356+
}
357+
358+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
359+
runBootstrap().catch((error) => {
360+
console.error(error instanceof Error ? error.message : String(error));
361+
process.exitCode = 1;
362+
});
363+
}

0 commit comments

Comments
 (0)