Files
tdkpin/tdkpin-rs/web/storage.js
T
ddidderr 9c5b033c0a fix(web): use relative path for high-score API endpoint
Change the high-score endpoint URL in storage.js from "/api/highscores" to
"./api/highscores". When serving the web build from a subpath rather than the
domain root, an absolute path sends fetch requests to the domain root instead
of the nested application path. Using a relative URL ensures requests resolve
relative to the active document path while still working when hosted at root.

Test Plan:
- `node --check tdkpin-rs/web/storage.js` -- passed
- `npx prettier --check tdkpin-rs/web/storage.js` -- passed
- `just test` -- passed (138 game tests, 3 highscore-server tests)
- `just clippy` -- passed
- `git diff --cached --check` -- passed
2026-08-29 20:08:15 +02:00

194 lines
5.6 KiB
JavaScript

"use strict";
(function registerStoragePlugin() {
const storageKey = "tdkpin.save.v1";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const highScoreApi = "./api/highscores";
const highScoreRetryInitialDelayMs = 250;
const highScoreRetryMaxDelayMs = 30_000;
let lastRevision = 0;
let lastHighScoreRevision = 0;
let highScoreRequestInFlight = false;
let highScoreGeneration = 0;
let highScoreSubmissionWarningShown = false;
let highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
let highScoreRetryAt = 0;
function browserStorage() {
try {
return window.localStorage;
} catch (_error) {
return null;
}
}
function sendSavedDataToRust() {
wasm_exports.tdkpin_browser_storage_clear();
const storage = browserStorage();
let saved = null;
try {
saved = storage?.getItem(storageKey);
} catch (_error) {
saved = null;
}
if (saved !== null && saved !== undefined) {
for (const byte of encoder.encode(saved)) {
wasm_exports.tdkpin_browser_storage_push(byte);
}
}
wasm_exports.tdkpin_browser_storage_finish();
}
function flushRustSave() {
const revision = wasm_exports.tdkpin_browser_storage_save_revision();
if (revision === lastRevision) {
return;
}
const bytes = new Uint8Array(
wasm_exports.tdkpin_browser_storage_save_length(),
);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = wasm_exports.tdkpin_browser_storage_save_byte(index);
}
const storage = browserStorage();
try {
storage?.setItem(storageKey, decoder.decode(bytes));
} catch (_error) {
// Private browsing or a full quota should not stop the game loop.
}
wasm_exports.tdkpin_browser_storage_save_ack();
lastRevision = revision;
}
function deliverHighScores(json) {
wasm_exports.tdkpin_browser_high_scores_clear();
for (const byte of encoder.encode(json)) {
wasm_exports.tdkpin_browser_high_scores_push(byte);
}
wasm_exports.tdkpin_browser_high_scores_finish();
}
async function fetchHighScores() {
const generation = ++highScoreGeneration;
try {
const response = await fetch(highScoreApi, {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`high-score request failed (${response.status})`);
}
const json = await response.text();
if (generation === highScoreGeneration) {
deliverHighScores(json);
}
} catch (error) {
console.warn("shared high scores unavailable; using local scores", error);
}
}
function retryAfterDelay(response) {
if (response.status !== 429 && response.status !== 503) {
return null;
}
const value = response.headers.get("Retry-After")?.trim();
if (!value) {
return null;
}
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) {
const delay = seconds * 1000;
return Number.isFinite(delay) ? delay : null;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp)
? Math.max(0, timestamp - Date.now())
: null;
}
function scheduleHighScoreRetry(retryAfterMs) {
const jitteredDelay = highScoreRetryDelayMs * (0.5 + Math.random());
const delay =
retryAfterMs === null ? jitteredDelay : retryAfterMs + jitteredDelay;
highScoreRetryAt = Date.now() + delay;
highScoreRetryDelayMs = Math.min(
highScoreRetryDelayMs * 2,
highScoreRetryMaxDelayMs,
);
}
function resetHighScoreRetry() {
highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
highScoreRetryAt = 0;
}
async function flushHighScoreSubmission() {
if (highScoreRequestInFlight) {
return;
}
if (Date.now() < highScoreRetryAt) {
return;
}
const revision = wasm_exports.tdkpin_browser_high_score_revision();
if (revision === lastHighScoreRevision) {
return;
}
const bytes = new Uint8Array(
wasm_exports.tdkpin_browser_high_score_length(),
);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = wasm_exports.tdkpin_browser_high_score_byte(index);
}
highScoreRequestInFlight = true;
let retryAfterMs = null;
try {
const response = await fetch(highScoreApi, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: decoder.decode(bytes),
keepalive: true,
});
if (!response.ok) {
retryAfterMs = retryAfterDelay(response);
throw new Error(`high-score submission failed (${response.status})`);
}
const json = await response.text();
highScoreGeneration += 1;
deliverHighScores(json);
wasm_exports.tdkpin_browser_high_score_ack(revision);
lastHighScoreRevision = revision;
highScoreSubmissionWarningShown = false;
resetHighScoreRetry();
} catch (error) {
scheduleHighScoreRetry(retryAfterMs);
if (!highScoreSubmissionWarningShown) {
console.warn("shared high-score submission failed; will retry", error);
highScoreSubmissionWarningShown = true;
}
} finally {
highScoreRequestInFlight = false;
}
}
function onInit() {
sendSavedDataToRust();
lastRevision = wasm_exports.tdkpin_browser_storage_save_revision();
void fetchHighScores();
window.setInterval(flushRustSave, 50);
window.setInterval(() => void flushHighScoreSubmission(), 50);
window.addEventListener("beforeunload", flushRustSave);
}
miniquad_add_plugin({
name: "tdkpin_storage",
on_init: onInit,
version: 1,
});
})();