diff --git a/server.ts b/server.ts index f578eef..eecff9b 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,5 @@ import { serve } from "bun" +import { runtimeConfig } from "./server/config" import { initDb } from "./server/db" import { registerOptions, @@ -13,8 +14,10 @@ import index from "./index.html" await initDb() const server = serve({ - port: 5174, + hostname: runtimeConfig.host, + port: runtimeConfig.port, routes: { + "/healthz": () => Response.json({ ok: true }), "/*": index, "/api/auth/register/options": { POST: registerOptions }, diff --git a/server/auth.ts b/server/auth.ts index af16322..b146726 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -4,6 +4,7 @@ import { generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server"; +import { runtimeConfig, resolveOrigin, resolveRpId } from "./config"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import type { RegistrationResponseJSON, @@ -37,16 +38,6 @@ setInterval(() => { } }, 60_000); -function getRpId(req: Request): string { - const url = new URL(req.url); - return url.hostname; -} - -function getOrigin(req: Request): string { - const url = new URL(req.url); - return url.origin; -} - function getCookie(req: Request, name: string): string | undefined { const cookies = req.headers.get("cookie"); if (!cookies) return undefined; @@ -55,11 +46,11 @@ function getCookie(req: Request, name: string): string | undefined { } function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string { - return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`; + return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${runtimeConfig.sessionCookieSecure ? "; Secure" : ""}`; } function challengeCookie(key: string): string { - return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120`; + return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120${runtimeConfig.sessionCookieSecure ? "; Secure" : ""}`; } function clearChallengeCookie(): string { @@ -86,7 +77,7 @@ export async function registerOptions(req: Request): Promise { // Create user const user = await createUser(username); - const rpID = getRpId(req); + const rpID = resolveRpId(req); const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID, @@ -131,8 +122,8 @@ export async function registerVerify(req: Request): Promise { } challenges.delete(challengeKey); - const rpID = getRpId(req); - const origin = getOrigin(req); + const rpID = resolveRpId(req); + const origin = resolveOrigin(req); let verification; try { @@ -178,7 +169,7 @@ export async function registerVerify(req: Request): Promise { } export async function loginOptions(req: Request): Promise { - const rpID = getRpId(req); + const rpID = resolveRpId(req); const options = await generateAuthenticationOptions({ rpID, @@ -221,8 +212,8 @@ export async function loginVerify(req: Request): Promise { return Response.json({ error: "Unknown credential" }, { status: 400 }); } - const rpID = getRpId(req); - const origin = getOrigin(req); + const rpID = resolveRpId(req); + const origin = resolveOrigin(req); let verification; try { diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 0000000..cd7456b --- /dev/null +++ b/server/config.ts @@ -0,0 +1,53 @@ +const DEFAULT_PORT = 5174 +const DEFAULT_SQLITE_PATH = './leo-typing.db' + +function parsePort(value: string | undefined): number { + if (!value) return DEFAULT_PORT + + const port = Number.parseInt(value, 10) + if (Number.isNaN(port) || port < 1 || port > 65535) { + throw new Error(`Invalid PORT value: ${value}`) + } + + return port +} + +function parseBoolean(value: string | undefined, fallback: boolean): boolean { + if (!value) return fallback + + switch (value.toLowerCase()) { + case '1': + case 'true': + case 'yes': + case 'on': + return true + case '0': + case 'false': + case 'no': + case 'off': + return false + default: + throw new Error(`Invalid boolean value: ${value}`) + } +} + +const appOrigin = process.env.APP_ORIGIN ? new URL(process.env.APP_ORIGIN) : null + +export const runtimeConfig = { + host: process.env.HOST || '127.0.0.1', + port: parsePort(process.env.PORT), + sqlitePath: process.env.SQLITE_PATH || DEFAULT_SQLITE_PATH, + appOrigin, + sessionCookieSecure: parseBoolean( + process.env.SESSION_COOKIE_SECURE, + appOrigin?.protocol === 'https:', + ), +} + +export function resolveOrigin(req: Request): string { + return runtimeConfig.appOrigin?.origin || new URL(req.url).origin +} + +export function resolveRpId(req: Request): string { + return runtimeConfig.appOrigin?.hostname || new URL(req.url).hostname +} diff --git a/server/db.ts b/server/db.ts index 524501c..6cdef0e 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,8 +1,9 @@ import { SQL } from "bun" +import { runtimeConfig } from "./config" const db = new SQL({ adapter: "sqlite", - filename: "./leo-typing.db", + filename: runtimeConfig.sqlitePath, create: true, strict: true, }) diff --git a/src/components/GameMode.tsx b/src/components/GameMode.tsx index 53cfc1f..643e056 100644 --- a/src/components/GameMode.tsx +++ b/src/components/GameMode.tsx @@ -89,8 +89,13 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { const livesRef = useRef(5) const gameOverRef = useRef(false) - livesRef.current = lives - gameOverRef.current = gameOver + useEffect(() => { + livesRef.current = lives + }, [lives]) + + useEffect(() => { + gameOverRef.current = gameOver + }, [gameOver]) const spawnWord = useCallback(() => { const difficulty = difficultyRef.current diff --git a/src/components/MissileGame.tsx b/src/components/MissileGame.tsx index c265b8e..6b0bafc 100644 --- a/src/components/MissileGame.tsx +++ b/src/components/MissileGame.tsx @@ -811,7 +811,7 @@ export function MissileGame({ highScore, onGameOver }: Props) { } } // Wrong key: do nothing (no penalty for wrong chars, just no progress) - }, [started, gameOver, startGame, triggerShake]) + }, [started, gameOver, startGame]) return (
diff --git a/src/hooks/useTypingEngine.ts b/src/hooks/useTypingEngine.ts index 02ba3fa..e091d9a 100644 --- a/src/hooks/useTypingEngine.ts +++ b/src/hooks/useTypingEngine.ts @@ -26,7 +26,10 @@ export function useTypingEngine( const startTimeRef = useRef(null) const [wpm, setWpm] = useState(0) const onCompleteRef = useRef(onComplete) - onCompleteRef.current = onComplete + + useEffect(() => { + onCompleteRef.current = onComplete + }, [onComplete]) const accuracy = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100