mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-27 02:34:36 +00:00
05e2bc9f0a
## Description: This reworks asset delivery and cacheability across the app and moves non-bundled public resources onto immutable, content-hashed URLs. Vite bundle outputs continue to live under `/assets/**` and remain content-hashed by Vite. Public resources that were previously fetched from stable paths in `resources/` now go through a custom hashed namespace under `/_assets/**`, backed by a generated asset manifest that is available to the server, browser, and worker runtime. In parallel, the root app shell is now cacheable shared HTML instead of request-time `no-store` HTML. Dynamic and live routes remain explicitly uncached. ## Why - Improve browser and Cloudflare cacheability for static assets. - Remove query-string and release-version cache busting for runtime-fetched assets. - Allow unchanged public assets to keep the same URL across releases. - Reduce avoidable work on `/` by serving a shared app shell instead of rendering HTML on every request. - Make cache behavior explicit instead of relying on mixed framework defaults and file-extension heuristics. ## What Changed ### 1. Content-hashed public asset pipeline - Added a build-time public asset manifest and hashing pipeline for non-Vite resources. - Production now emits hashed public assets under `/_assets/**`. - Added runtime manifest loading for Node so server-rendered paths resolve against built hashed files instead of rebuilding from source at runtime. - Emitted the runtime asset manifest as an ESM module for server consumption. Result: - `/assets/**` = Vite-managed hashed bundle outputs - `/_assets/**` = custom content-hashed public resources ### 2. Runtime asset URL migration - Added a shared `assetUrl(...)` resolution path. - Migrated runtime references away from query-string versioning and stable source paths. - Updated browser, worker, and server-side rendering paths to resolve through the asset manifest. - Moved map manifests, map binaries, thumbnails, sprites, sounds, fonts, flags, icons, screenshots, and other runtime-fetched resources onto hashed URLs. ### 3. Map and preview fixes - Fixed directory and per-file map asset resolution so map manifest and binary fetches resolve to the correct hashed URLs. - Updated preview metadata and map thumbnail paths to use the hashed asset namespace. - Fixed runtime manifest loading in prod after deployment. ### 4. Explicit cache policies - Added explicit immutable cache headers for: - `/assets/**` - `/_assets/**` - worker-prefixed equivalents under `/wN/...` - Added explicit `no-store` headers for live and dynamic APIs. - Removed the old `/api/env` bootstrap request and baked `gameEnv` into the HTML bootstrap instead. ### 5. Cacheable root app shell - Refactored the root HTML path to serve a shared app shell with: - `Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400` - `/` and the SPA fallback now serve shared cacheable HTML instead of request-time `no-store` rendering. - `/game/:id` remains dynamic and `no-store`, but now reuses the shared shell before injecting preview tags. ### 6. Matchmaking instance handling - Because the app shell is now cacheable, `INSTANCE_ID` was removed from shared HTML. - Added `/api/instance` as a temporary `no-store` runtime lookup used only by matchmaking. - This preserves correctness with the current random-per-boot `INSTANCE_ID` model while keeping `/` cacheable, but it is not the intended long-term design. ## Behavior Changes ### Asset URL contract Production URLs for non-Vite public resources now change from stable paths such as: - `/maps/...` - `/images/...` - `/manifest.json` to content-hashed paths under: - `/_assets/...` Examples: - `/_assets/maps/<map>/manifest.<hash>.json` - `/_assets/images/Favicon.<hash>.svg` ### Bootstrap/config - `/api/env` is removed. - `gameEnv` is now bootstrapped from HTML. ### HTML caching - `/` and the SPA fallback are now cacheable shared HTML. - `/game/:id` remains dynamic. ## Cache Matrix After This Branch - `/_assets/**`: `public, max-age=31536000, immutable` - `/assets/**`: `public, max-age=31536000, immutable` - live `/api/**`: explicit `no-store` - `/api/health`: explicit `no-store` - `/api/instance`: explicit `no-store` - `/game/:id`: explicit `no-store` - `/` and SPA fallback: `public, max-age=0, s-maxage=300, stale-while-revalidate=86400` ## Notes / Tradeoffs - `/api/instance` is a temporary compromise. It exists because `INSTANCE_ID` is currently random per boot, which is not safe to embed into cacheable shared HTML. - The current matchmaking flow still asks the client to provide `instance_id` during `matchmaking/join`. That is functional, but it is the wrong ownership boundary: instance selection should be handled by the matchmaking service, not by the browser. - The cleaner end-state would be: - make `matchmaking/join` stop requiring `instance_id` from the client, and let the matchmaking service select a healthy instance from worker check-ins - This branch makes the origin behavior edge-cache-friendly, but Cloudflare still needs matching cache rules if HTML itself should be cached at the edge. ## Validation Verified during development with: - `npx tsc --noEmit` - `node node_modules\\vite\\bin\\vite.js build` - `node node_modules\\vitest\\vitest.mjs run tests/server/RenderHtml.test.ts tests/server/NoStoreHeaders.test.ts tests/server/StaticAssetCache.test.ts tests/core/configuration/ConfigLoader.test.ts` Additional targeted tests added: - `tests/AssetUrls.test.ts` - `tests/core/game/FetchGameMapLoader.test.ts` - `tests/core/configuration/ConfigLoader.test.ts` - `tests/server/NoStoreHeaders.test.ts` - `tests/server/StaticAssetCache.test.ts` - `tests/server/RenderHtml.test.ts` ## Known Existing Warnings The production build still reports pre-existing warnings that are not addressed by this branch: - inconsistent JSON import attributes for `resources/countries.json` - inconsistent JSON import attributes for `resources/QuickChat.json` - large chunk warnings from Vite ## Rollout Notes - Cache rules should treat `/_assets/**` and `/assets/**` as immutable. - Cloudflare will still classify HTML as dynamic after deploy unless matching edge cache rules are configured for it. ## Follow-ups - Remove `/api/instance` by changing `matchmaking/join` so the server selects the target instance, or by making `INSTANCE_ID` deploy-stable if the current contract must remain. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME
313 lines
9.1 KiB
TypeScript
313 lines
9.1 KiB
TypeScript
import { html, LitElement } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
import { UserMeResponse } from "../core/ApiSchemas";
|
|
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
|
import { getUserMe, hasLinkedAccount } from "./Api";
|
|
import { getPlayToken } from "./Auth";
|
|
import { BaseModal } from "./components/BaseModal";
|
|
import "./components/Difficulties";
|
|
import "./components/PatternButton";
|
|
import { modalHeader } from "./components/ui/ModalHeader";
|
|
import { JoinLobbyEvent } from "./Main";
|
|
import { translateText } from "./Utils";
|
|
|
|
@customElement("matchmaking-modal")
|
|
export class MatchmakingModal extends BaseModal {
|
|
private static instanceIdPromise: Promise<string> | null = null;
|
|
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
|
|
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
@state() private connected = false;
|
|
@state() private socket: WebSocket | null = null;
|
|
@state() private gameID: string | null = null;
|
|
private elo: number | string = "...";
|
|
|
|
constructor() {
|
|
super();
|
|
this.id = "page-matchmaking";
|
|
}
|
|
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
|
|
render() {
|
|
const eloDisplay = html`
|
|
<p class="text-center mt-2 mb-4 text-white/60">
|
|
${translateText("matchmaking_modal.elo", { elo: this.elo })}
|
|
</p>
|
|
`;
|
|
|
|
const content = html`
|
|
<div class="${this.modalContainerClass}">
|
|
${modalHeader({
|
|
title: translateText("matchmaking_modal.title"),
|
|
onBack: () => this.close(),
|
|
ariaLabel: translateText("common.back"),
|
|
})}
|
|
<div class="flex-1 flex flex-col items-center justify-center gap-6 p-6">
|
|
${eloDisplay} ${this.renderInner()}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
if (this.inline) {
|
|
return content;
|
|
}
|
|
|
|
return html`
|
|
<o-modal
|
|
id="matchmaking-modal"
|
|
title="${translateText("matchmaking_modal.title")}"
|
|
hideCloseButton
|
|
hideHeader
|
|
>
|
|
${content}
|
|
</o-modal>
|
|
`;
|
|
}
|
|
|
|
private renderInner() {
|
|
if (!this.connected) {
|
|
return this.renderLoadingSpinner(
|
|
translateText("matchmaking_modal.connecting"),
|
|
"blue",
|
|
);
|
|
}
|
|
if (this.gameID === null) {
|
|
return this.renderLoadingSpinner(
|
|
translateText("matchmaking_modal.searching"),
|
|
"green",
|
|
);
|
|
} else {
|
|
return this.renderLoadingSpinner(
|
|
translateText("matchmaking_modal.waiting_for_game"),
|
|
"yellow",
|
|
);
|
|
}
|
|
}
|
|
|
|
private async connect() {
|
|
const config = await getServerConfigFromClient();
|
|
const instanceId = await MatchmakingModal.getInstanceId();
|
|
|
|
this.socket = new WebSocket(
|
|
`${config.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(instanceId)}`,
|
|
);
|
|
this.socket.onopen = async () => {
|
|
console.log("Connected to matchmaking server");
|
|
this.connectTimeout = setTimeout(async () => {
|
|
if (this.socket?.readyState !== WebSocket.OPEN) {
|
|
console.warn("[Matchmaking] socket not ready");
|
|
return;
|
|
}
|
|
// Set a delay so the user can see the "connecting" message,
|
|
// otherwise the "searching" message will be shown immediately.
|
|
// Also wait so people who back out immediately aren't added
|
|
// to the matchmaking queue.
|
|
this.socket.send(
|
|
JSON.stringify({
|
|
type: "join",
|
|
jwt: await getPlayToken(),
|
|
}),
|
|
);
|
|
this.connected = true;
|
|
this.requestUpdate();
|
|
}, 2000);
|
|
};
|
|
this.socket.onmessage = (event) => {
|
|
console.log(event.data);
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "match-assignment") {
|
|
this.socket?.close();
|
|
console.log(`matchmaking: got game ID: ${data.gameId}`);
|
|
this.gameID = data.gameId;
|
|
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
|
|
}
|
|
};
|
|
this.socket.onerror = (event: ErrorEvent) => {
|
|
console.error("WebSocket error occurred:", event);
|
|
};
|
|
this.socket.onclose = () => {
|
|
console.log("Matchmaking server closed connection");
|
|
};
|
|
}
|
|
|
|
private static async getInstanceId(): Promise<string> {
|
|
MatchmakingModal.instanceIdPromise ??= fetch("/api/instance", {
|
|
cache: "no-store",
|
|
})
|
|
.then(async (response) => {
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to load instance id: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
|
|
const data = (await response.json()) as { instanceId?: string };
|
|
if (!data.instanceId) {
|
|
throw new Error("Missing instance id");
|
|
}
|
|
|
|
return data.instanceId;
|
|
})
|
|
.catch((error: unknown) => {
|
|
MatchmakingModal.instanceIdPromise = null;
|
|
throw error;
|
|
});
|
|
|
|
return MatchmakingModal.instanceIdPromise;
|
|
}
|
|
|
|
protected async onOpen(): Promise<void> {
|
|
const userMe = await getUserMe();
|
|
// Early return if modal was closed during async operation
|
|
if (!this.isModalOpen) {
|
|
return;
|
|
}
|
|
|
|
const isLoggedIn =
|
|
userMe &&
|
|
userMe.user &&
|
|
(userMe.user.discord !== undefined || userMe.user.email !== undefined);
|
|
if (!isLoggedIn) {
|
|
window.dispatchEvent(
|
|
new CustomEvent("show-message", {
|
|
detail: {
|
|
message: translateText("matchmaking_button.must_login"),
|
|
color: "red",
|
|
duration: 3000,
|
|
},
|
|
}),
|
|
);
|
|
this.close();
|
|
window.showPage?.("page-account");
|
|
return;
|
|
}
|
|
|
|
this.elo =
|
|
userMe.player.leaderboard?.oneVone?.elo ??
|
|
translateText("matchmaking_modal.no_elo");
|
|
|
|
this.connected = false;
|
|
this.gameID = null;
|
|
this.connect();
|
|
}
|
|
|
|
protected onClose(): void {
|
|
this.connected = false;
|
|
this.socket?.close();
|
|
if (this.connectTimeout) {
|
|
clearTimeout(this.connectTimeout);
|
|
this.connectTimeout = null;
|
|
}
|
|
if (this.gameCheckInterval) {
|
|
clearInterval(this.gameCheckInterval);
|
|
this.gameCheckInterval = null;
|
|
}
|
|
}
|
|
|
|
private async checkGame() {
|
|
if (this.gameID === null) {
|
|
return;
|
|
}
|
|
const config = await getServerConfigFromClient();
|
|
const url = `/${config.workerPath(this.gameID)}/api/game/${this.gameID}/exists`;
|
|
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
const gameInfo = await response.json();
|
|
|
|
if (response.status !== 200) {
|
|
console.error(`Error checking game ${this.gameID}: ${response.status}`);
|
|
return;
|
|
}
|
|
|
|
if (!gameInfo.exists) {
|
|
console.info(`Game ${this.gameID} does not exist or hasn't started yet`);
|
|
return;
|
|
}
|
|
|
|
if (this.gameCheckInterval) {
|
|
clearInterval(this.gameCheckInterval);
|
|
this.gameCheckInterval = null;
|
|
}
|
|
|
|
this.dispatchEvent(
|
|
new CustomEvent("join-lobby", {
|
|
detail: {
|
|
gameID: this.gameID,
|
|
source: "matchmaking",
|
|
} as JoinLobbyEvent,
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
@customElement("matchmaking-button")
|
|
export class MatchmakingButton extends LitElement {
|
|
@state() private isLoggedIn = false;
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
async connectedCallback() {
|
|
super.connectedCallback();
|
|
// Listen for user authentication changes
|
|
document.addEventListener("userMeResponse", (event: Event) => {
|
|
const customEvent = event as CustomEvent;
|
|
if (customEvent.detail) {
|
|
const userMeResponse = customEvent.detail as UserMeResponse | false;
|
|
this.isLoggedIn = hasLinkedAccount(userMeResponse);
|
|
}
|
|
});
|
|
}
|
|
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
|
|
render() {
|
|
return this.isLoggedIn
|
|
? html`
|
|
<button
|
|
@click="${this.handleLoggedInClick}"
|
|
class="no-crazygames w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center group overflow-hidden relative"
|
|
title="${translateText("matchmaking_modal.title")}"
|
|
>
|
|
<span class="relative z-10 text-2xl">
|
|
${translateText("matchmaking_button.play_ranked")}
|
|
</span>
|
|
<span
|
|
class="relative z-10 text-xs font-medium text-purple-100 opacity-90 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
${translateText("matchmaking_button.description")}
|
|
</span>
|
|
</button>
|
|
`
|
|
: html`
|
|
<button
|
|
@click="${this.handleLoggedOutClick}"
|
|
class="no-crazygames w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center overflow-hidden relative cursor-pointer"
|
|
>
|
|
<span class="relative z-10 text-2xl">
|
|
${translateText("matchmaking_button.login_required")}
|
|
</span>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
private handleLoggedInClick() {
|
|
document.dispatchEvent(new CustomEvent("open-matchmaking"));
|
|
}
|
|
|
|
private handleLoggedOutClick() {
|
|
window.showPage?.("page-account");
|
|
}
|
|
}
|