Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions lib/modarchive/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
// without poisoning the cache. No retries — failures are surfaced
// immediately so the chart pane can offer a Retry button.

const USER_AGENT =
"CoolModFiles (+https://github.com/no42-org/CoolModFiles)";
const USER_AGENT = "CoolModFiles (+https://github.com/no42-org/CoolModFiles)";

export async function fetchHtml(url: string): Promise<string> {
export async function fetchHtml(
url: string,
signal?: AbortSignal
): Promise<string> {
const res = await fetch(url, {
method: "GET",
signal,
redirect: "follow",
headers: {
"User-Agent": USER_AGENT,
Expand Down
102 changes: 102 additions & 0 deletions lib/modarchive/latest-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright 2026 Ronny Trommer <ronny@no42.org>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const rss = (id: number) =>
`<item><link>https://modarchive.org/index.php?request=view_by_moduleid&amp;query=${id}</link>` +
`<enclosure url="https://api.modarchive.org/downloads.php?moduleid=${id}#x.mod"/></item>`;

async function load() {
vi.resetModules();
return (await import("./latest-id")).getLatestId;
}

describe("getLatestId", () => {
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});

it("returns the fallback without waiting for the feed", async () => {
fetchMock.mockReturnValue(new Promise(() => {}));
const getLatestId = await load();
expect(getLatestId(42)).toBe(42);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("serves the fetched id once the refresh lands, without refetching", async () => {
fetchMock.mockResolvedValue(new Response(rss(200123)));
const getLatestId = await load();
getLatestId(42);
await vi.waitFor(() => expect(getLatestId(42)).toBe(200123));
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("starts only one refresh while one is in flight", async () => {
fetchMock.mockReturnValue(new Promise(() => {}));
const getLatestId = await load();
getLatestId(42);
getLatestId(42);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("keeps the fallback when the feed fails and retries after a minute", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
fetchMock.mockRejectedValueOnce(new Error("down"));
fetchMock.mockResolvedValueOnce(new Response(rss(300)));
const getLatestId = await load();
getLatestId(42);
await new Promise((r) => setTimeout(r, 10));
expect(getLatestId(42)).toBe(42);
expect(fetchMock).toHaveBeenCalledTimes(1);
vi.setSystemTime(Date.now() + 61 * 1000);
getLatestId(42);
await vi.waitFor(() => expect(getLatestId(42)).toBe(300));
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("keeps the fallback on a non-2xx response", async () => {
fetchMock.mockResolvedValue(new Response(rss(500), { status: 503 }));
const getLatestId = await load();
getLatestId(42);
await new Promise((r) => setTimeout(r, 10));
expect(getLatestId(42)).toBe(42);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("recovers when fetch throws synchronously", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
fetchMock.mockImplementationOnce(() => {
throw new Error("sync");
});
fetchMock.mockResolvedValueOnce(new Response(rss(400)));
const getLatestId = await load();
getLatestId(42);
await new Promise((r) => setTimeout(r, 10));
vi.setSystemTime(Date.now() + 61 * 1000);
getLatestId(42);
await vi.waitFor(() => expect(getLatestId(42)).toBe(400));
});

it("refreshes again after the TTL expires", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
fetchMock.mockResolvedValueOnce(new Response(rss(100)));
fetchMock.mockResolvedValueOnce(new Response(rss(101)));
const getLatestId = await load();
getLatestId(42);
await vi.waitFor(() => expect(getLatestId(42)).toBe(100));
vi.setSystemTime(Date.now() + 61 * 60 * 1000);
getLatestId(42);
await vi.waitFor(() => expect(getLatestId(42)).toBe(101));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
44 changes: 44 additions & 0 deletions lib/modarchive/latest-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2026 Ronny Trommer <ronny@no42.org>
* SPDX-License-Identifier: GPL-3.0-or-later
*/

// Newest modarchive.org module id, used as the upper bound for random
// picks. The RSS feed takes several seconds to answer, so the page never
// awaits it: callers get the cached value (or the fallback before the
// first fetch lands) and a stale value triggers a background refresh.
// Assumes a long-lived server process (the Docker standalone deploy);
// a serverless runtime may freeze before the refresh completes.

import { fetchHtml } from "./fetch";

const RSS_URL = "https://modarchive.org/rss.php?request=uploads";
const TTL_MS = 60 * 60 * 1000;
const RETRY_MS = 60 * 1000;
const TIMEOUT_MS = 10_000;

let cached: number | undefined;
let nextCheckAt = 0;
let refreshing: Promise<void> | undefined;

async function refresh(): Promise<void> {
try {
const rss = await fetchHtml(RSS_URL, AbortSignal.timeout(TIMEOUT_MS));
const id = Number(rss.split("downloads.php?moduleid=")[1].split("#")[0]);
if (!Number.isInteger(id) || id <= 0) throw new Error(`bad id ${id}`);
cached = id;
nextCheckAt = Date.now() + TTL_MS;
} catch {
// Keep the previous value; retry sooner than a full TTL.
nextCheckAt = Date.now() + RETRY_MS;
}
}

export function getLatestId(fallback: number): number {
if (!refreshing && Date.now() >= nextCheckAt) {
refreshing = refresh().finally(() => {
refreshing = undefined;
});
}
return cached ?? fallback;
}
17 changes: 6 additions & 11 deletions pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
/*
* Copyright 2026 Ronny Trommer <ronny@no42.org>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import React from "react";
import Head from "next/head";
import type { GetServerSideProps } from "next";

import Player from "../components/Player";
import Footer from "../components/Footer";
import { modArchive, library, type Source } from "../components/sources";
import { getLatestId } from "../lib/modarchive/latest-id";
import {
getRandomInt,
getRandomFromArray,
Expand Down Expand Up @@ -111,17 +116,7 @@ export const getServerSideProps: GetServerSideProps<IndexProps> = async ({
backSideContent = "";
}

let latestId: number;
try {
const rss_req = await fetch(
"https://modarchive.org/rss.php?request=uploads",
{ method: "GET" }
);
const rss = await rss_req.text();
latestId = Number(rss.split("downloads.php?moduleid=")[1].split("#")[0]);
} catch {
latestId = RANDOM_MAX;
}
const latestId = getLatestId(RANDOM_MAX);

let initialSource: Source | null = null;
if (query.source === "modarchive" && query.id) {
Expand Down
Loading