From 07f501a67a1a921586cecfbca6a29f430326971c Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:50:20 +0000 Subject: [PATCH] Add ValueCache and fetchFactorioVersions --- .gitignore | 3 + packages/lib/index.ts | 2 + packages/lib/src/ValueCache.ts | 64 +++++++++ packages/lib/src/external/FactorioVersions.ts | 65 +++++++++ packages/lib/src/external/index.ts | 5 + test/lib/ValueCache.test.js | 127 ++++++++++++++++++ test/lib/external/FactorioVersions.test.js | 122 +++++++++++++++++ 7 files changed, 388 insertions(+) create mode 100644 packages/lib/src/ValueCache.ts create mode 100644 packages/lib/src/external/FactorioVersions.ts create mode 100644 packages/lib/src/external/index.ts create mode 100644 test/lib/ValueCache.test.js create mode 100644 test/lib/external/FactorioVersions.test.js diff --git a/.gitignore b/.gitignore index eb984bd0..1ed48979 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ dist/ node_modules Factorio* +!*Factorio*.ts +!*Factorio*.tsx +!*Factorio*.js /factorio !/test/file/factorio !/test/lib/factorio diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 36e0d492..4f92d7c4 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -9,6 +9,7 @@ export * from "./src/config"; export * from "./src/data"; export * from "./src/database"; export * from "./src/errors"; +export * from "./src/external"; export * from "./src/factorio"; export * from "./src/file_ops"; export * from "./src/hash"; @@ -35,3 +36,4 @@ export * from "./src/rce_ops"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as ModStore } from "./src/ModStore"; export { default as RateLimiter } from "./src/RateLimiter"; +export { default as ValueCache } from "./src/ValueCache"; diff --git a/packages/lib/src/ValueCache.ts b/packages/lib/src/ValueCache.ts new file mode 100644 index 00000000..5b8947de --- /dev/null +++ b/packages/lib/src/ValueCache.ts @@ -0,0 +1,64 @@ + +/** + * Represents an async function which can fetch a value for a ValueCache + */ +export interface ValueCacheFetch { + (): Promise; +} + +/** + * In-memory single value cache + * + * Stores the last time a value was fetched and measures staleness + * Concurrent calls will share the same in-flight fetch + */ +export default class ValueCache { + private ongoingFetch: Promise | null = null; + private timestamp: number | null = null; + private value: T | null = null; // Only valid when timestamp is not null + + /** + * Create a new in memory single value cache + * + * @param fetch A function that fetches and returns a fresh value + */ + constructor( + private readonly fetch: ValueCacheFetch, + ) {} + + /** + * Get the cached value, will fetch a fresh value if stale or missing + * + * @param maxAgeMs Maximum allowed cache age in milliseconds. + * Defaults to 0, meaning it will always fetch a new value. + * @throws TypeError if maxAgeMs is invalid + * @throws Propagates errors from underlying fetch method + */ + get(maxAgeMs: number = 0): Promise { + if (maxAgeMs < 0) { + throw new TypeError("maxAgeMs must be a non-negative number"); + } + + const now = Date.now(); + + if (this.timestamp && now - this.timestamp < maxAgeMs) { + return Promise.resolve(this.value as T); + } + + if (this.ongoingFetch) { + return this.ongoingFetch; + } + + this.ongoingFetch = this.fetch() + .then(value => { + this.timestamp = Date.now(); + this.value = value; + return value; + }) + .finally(() => { + this.ongoingFetch = null; + }); + + return this.ongoingFetch; + } +} diff --git a/packages/lib/src/external/FactorioVersions.ts b/packages/lib/src/external/FactorioVersions.ts new file mode 100644 index 00000000..9760460b --- /dev/null +++ b/packages/lib/src/external/FactorioVersions.ts @@ -0,0 +1,65 @@ +import { FullVersion, isFullVersion } from "../data"; + +const ARCHIVE_URL = "https://factorio.com/download/archive"; + +/** + * Represents the parsed result of a factorio version + */ +export type ExternalFactorioVersion = { + stable: boolean; + version: FullVersion; + downloadUrl: string; +}; + +/** + * Fetch all factorio versions that support headless + * + * @returns A list of all factorio versions and their download URL + * @throws Network errors and non-ok status responses + */ +export async function fetchFactorioVersions(): Promise { + let res; + + try { + res = await fetch(ARCHIVE_URL); + } catch (err) { + throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: ${err}`); + } + + if (!res.ok) { + throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: HTTP ${res.status} ${res.statusText}`); + } + + const html = await res.text(); + return parseFactorioVersions(html); +} + +/** + * Parse all versions from the factorio archive webpage + * + * @param html The HTML content of the archive page + * @returns A list of all factorio versions and their download URL + */ +function parseFactorioVersions(html: string): ExternalFactorioVersion[] { + const versions = new Map(); + + // Match 2.0.72 + // eslint-disable-next-line max-len + const anchorRegex = /]*class="[^"]*version-button-(?stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?\d+\.\d+.\d+)"[^>]*>/g; + + let match: RegExpExecArray | null; + while ((match = anchorRegex.exec(html)) !== null) { + const kind = match.groups?.kind; + const version = match.groups?.version; + + if (kind && version && isFullVersion(version)) { + versions.set(version, { + version: version, + stable: kind === "stable", + downloadUrl: `${ARCHIVE_URL}/${version}`, + }); + } + } + + return [...versions.values()]; +} diff --git a/packages/lib/src/external/index.ts b/packages/lib/src/external/index.ts new file mode 100644 index 00000000..92cc1f2e --- /dev/null +++ b/packages/lib/src/external/index.ts @@ -0,0 +1,5 @@ +/** + * Library for interfacing with external apis + * @module lib/external + */ +export * from "./FactorioVersions"; diff --git a/test/lib/ValueCache.test.js b/test/lib/ValueCache.test.js new file mode 100644 index 00000000..b75094c3 --- /dev/null +++ b/test/lib/ValueCache.test.js @@ -0,0 +1,127 @@ +const assert = require("assert").strict; +const { ValueCache, wait } = require("@clusterio/lib"); + +describe("ValueCache", function () { + describe("constructor", function () { + it("constructs with a fetch function", function() { + const fetcher = async () => 42; + const cache = new ValueCache(fetcher); + assert.ok(cache); + }); + }); + + describe("get", function() { + it("throws TypeError for negative maxAgeMs", async function() { + const fetcher = async () => 42; + const cache = new ValueCache(fetcher); + + assert.throws(() => cache.get(-1), err => { + assert.equal(err instanceof TypeError, true); + assert.match(String(err.message), /maxAgeMs/); + return true; + }); + }); + it("uses a default of 0 for maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(); + const v2 = await cache.get(); + + assert.equal(v1, 1); + assert.equal(v2, 2); + assert.equal(callCount, 2); + }); + it("returns cached value when within maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(1000); + const v2 = await cache.get(1000); + + assert.equal(v1, 1); + assert.equal(v2, 1); + assert.equal(callCount, 1); + }); + it("refreshes the cache when older than maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(50); + const v2 = await cache.get(1000); // Use cached value + await wait(100); + const v3 = await cache.get(50); // Stale after 100ms wait + const v4 = await cache.get(1000); // Use cached value + + assert.equal(v1, 1); + assert.equal(v2, 1); + assert.equal(v3, 2); + assert.equal(v4, 2); + assert.equal(callCount, 2); + }); + it("shares in-flight fetch between concurrent callers", async function() { + let callCount = 0; + let resolveFetcher; + const fetcher = () => ( + new Promise(resolve => { + callCount += 1; + resolveFetcher = resolve; + }) + ); + + const cache = new ValueCache(fetcher); + + const p1 = cache.get(1000); + const p2 = cache.get(1000); + + assert.equal(callCount, 1); + + resolveFetcher(42); + const [v1, v2] = await Promise.all([p1, p2]); + + assert.equal(v1, 42); + assert.equal(v2, 42); + assert.equal(callCount, 1); + }); + it("propagates errors from the fetcher and clears ongoing fetch", async function() { + let callCount = 0; + const error = new Error("fetch failed"); + const fetcher = async () => { + callCount += 1; + throw error; + }; + + const cache = new ValueCache(fetcher); + + await assert.rejects(cache.get(), err => { + assert.equal(err, error); + return true; + }); + + assert.equal(callCount, 1); + + // After a failure, a new attempt to fetch should be made + await assert.rejects(cache.get(), err => { + assert.equal(err, error); + return true; + }); + + assert.equal(callCount, 2); + }); + }); +}); diff --git a/test/lib/external/FactorioVersions.test.js b/test/lib/external/FactorioVersions.test.js new file mode 100644 index 00000000..30e4f3ae --- /dev/null +++ b/test/lib/external/FactorioVersions.test.js @@ -0,0 +1,122 @@ +const assert = require("assert").strict; +const { readFile } = require("fs-extra"); + +const { fetchFactorioVersions } = require("@clusterio/lib"); +const { slowTest } = require("../../integration"); + +describe("FactorioVersions", function() { + describe("fetchFactorioVersions", function() { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = undefined; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + function mockFetch(html) { + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + text: async () => html, + }; + + global.fetch = async () => mockResponse; + } + + it("fetches and parsed versions with stable / experimental flags", async function() { + mockFetch(` + + + + 2.0.73 + + + + + 2.0.72 + + + + `); + + const versions = await fetchFactorioVersions(); + assert.deepEqual(versions, [ + { stable: false, version: "2.0.73", downloadUrl: "https://factorio.com/download/archive/2.0.73" }, + { stable: true, version: "2.0.72", downloadUrl: "https://factorio.com/download/archive/2.0.72" }, + ]); + }); + it("deduplicates versions by last occurrence", async function() { + mockFetch(` + + + + 2.0.73 + + + + + 2.0.73 + + + + `); + + const versions = await fetchFactorioVersions(); + assert.deepEqual(versions, [ + { stable: true, version: "2.0.73", downloadUrl: "https://factorio.com/download/archive/2.0.73" }, + ]); + }); + it("thorws on HTTP error status", async function() { + const mockResponse = { + ok: false, + status: 500, + statusText: "Server Error", + text: async () => "", + }; + + global.fetch = async () => mockResponse; + + await assert.rejects(fetchFactorioVersions(), /HTTP 500 Server Error/); + }); + it("thorws on network error status", async function() { + global.fetch = async () => { + throw new Error("Network Error"); + }; + + await assert.rejects(fetchFactorioVersions(), /Network Error/); + }); + it("parses versions from saved html file", async function() { + mockFetch(await readFile("test/file/html/factorio-download-archive.html")); + + const versions = await fetchFactorioVersions(); + assert.equal(versions.length, 111); + assert.equal(versions.filter(v => v.stable).length, 53); + assert.equal(versions.filter(v => !v.stable).length, 58); + }); + it("parses versions from live api", async function() { + slowTest(this); + global.fetch = originalFetch; + + const versions = await fetchFactorioVersions(); + assert.ok(versions.length >= 111); + assert.ok(versions.filter(v => v.stable).length >= 53); + assert.ok(versions.filter(v => !v.stable).length >= 58); + }); + }); +});