From 050891503d9ff5eff86115c7002a904680ebb228 Mon Sep 17 00:00:00 2001 From: snakajima Date: Tue, 6 Oct 2026 04:24:37 -0700 Subject: [PATCH 1/2] 0.3.1: keep an avatar URL's query on every request loadAvatar("https://host/ani/avatar.json?v=1") used to drop the query from the package's other files, and a folder URL with a query became ".../ani?v=1/". The package folder now comes from the URL's path, and the query (a version, a signature) is sent with every request. Found in review of receptron/mulmocast-cli#1598. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- src/avatar.ts | 9 +++++++-- tests/avatar.test.ts | 15 +++++++++++++++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 404ad91..cf736d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "avatarscript", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "avatarscript", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "dependencies": { "kuromoji": "^0.1.2", diff --git a/package.json b/package.json index 5cc1651..6a13e77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "avatarscript", - "version": "0.3.0", + "version": "0.3.1", "description": "Turn an avatar and text into a lip-synced, acted video.", "keywords": [ "avatar", diff --git a/src/avatar.ts b/src/avatar.ts index 8445123..55d703c 100644 --- a/src/avatar.ts +++ b/src/avatar.ts @@ -78,9 +78,14 @@ function diskStore(dir: string): Store { function urlStore(url: string, cacheDir: string): Store { const parsed = new URL(url); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error(`${url}: only http and https avatar URLs are supported`); - const folder = parsed.pathname.endsWith("/avatar.json") ? new URL(".", parsed).href : parsed.href.replace(/\/?$/, "/"); + // locations are kept without the query; the source's query (a version, a signature) goes with every request + const folderUrl = new URL(parsed); + folderUrl.search = folderUrl.hash = ""; + folderUrl.pathname = parsed.pathname.endsWith("/avatar.json") ? parsed.pathname.slice(0, -"avatar.json".length) : parsed.pathname.replace(/\/?$/, "/"); + const folder = folderUrl.href; const download = async (location: string, kind: "json" | "png") => { - const response = await fetch(location, { signal: AbortSignal.timeout(120_000) }); + const request = Object.assign(new URL(location), { search: parsed.search }); + const response = await fetch(request, { signal: AbortSignal.timeout(120_000) }); if (response.status === 404) return undefined; if (!response.ok) throw new Error(`${location}: HTTP ${response.status}`); if (Number(response.headers.get("content-length") ?? 0) > MAX_DOWNLOAD) throw new Error(`${location}: larger than ${MAX_DOWNLOAD} bytes`); diff --git a/tests/avatar.test.ts b/tests/avatar.test.ts index 45ae118..06e94ca 100644 --- a/tests/avatar.test.ts +++ b/tests/avatar.test.ts @@ -9,6 +9,7 @@ import { loadAvatar } from "../src/avatar.ts"; // Serves avatars/ani over HTTP, with per-test overrides, and counts requests per path. const ANI = resolve(import.meta.dirname, "../avatars/ani"); const requests: string[] = []; +const queries: string[] = []; const overrides = new Map(); let server: Server; let base: string; @@ -19,6 +20,7 @@ beforeAll(async () => { server = createServer((req, res) => { const path = decodeURIComponent((req.url ?? "/").split("?")[0]); requests.push(path); + queries.push((req.url ?? "").split("?")[1] ?? ""); const override = overrides.get(path); if (override === null) return void res.writeHead(404).end(); if (override !== undefined) return void res.end(override); @@ -39,6 +41,7 @@ afterAll(async () => { const fresh = () => { requests.length = 0; + queries.length = 0; overrides.clear(); }; @@ -84,6 +87,18 @@ describe("loadAvatar", () => { expect(avatar.drawnMouths).toBe(true); }); + it("keeps the URL's query (a version, a signature) on every request", async () => { + fresh(); + const local = await loadAvatar(ANI); + const viaManifest = await loadAvatar(`${base}/ani/avatar.json?v=1`, { cacheDir: join(cacheDir, "query") }); + expect(viaManifest.assets).toEqual(local.assets); + expect(new Set(queries)).toEqual(new Set(["v=1"])); + fresh(); + await loadAvatar(`${base}/ani?v=2`, { cacheDir: join(cacheDir, "query") }); + expect(requests).toContain("/ani/avatar.json"); + expect(new Set(queries)).toEqual(new Set(["v=2"])); + }); + it("refuses files outside the avatar's folder", async () => { fresh(); const manifest = JSON.parse(await readFile(join(ANI, "avatar.json"), "utf8")) as Record; From d7225daaa5338a3ab9f87072f8afb28e77e0eb05 Mon Sep 17 00:00:00 2001 From: snakajima Date: Tue, 6 Oct 2026 04:33:47 -0700 Subject: [PATCH 2/2] Review: cache per URL query, and keep an asset's own query Images cached for ?v=1 were reused for ?v=2; the query is now part of the cache identity. A manifest asset with its own query (rig.json?token=abc) keeps it; the source's query is added only to files without one. Co-Authored-By: Claude Opus 5.5 --- src/avatar.ts | 11 +++++++---- tests/avatar.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/avatar.ts b/src/avatar.ts index 55d703c..91f92ef 100644 --- a/src/avatar.ts +++ b/src/avatar.ts @@ -78,13 +78,15 @@ function diskStore(dir: string): Store { function urlStore(url: string, cacheDir: string): Store { const parsed = new URL(url); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error(`${url}: only http and https avatar URLs are supported`); - // locations are kept without the query; the source's query (a version, a signature) goes with every request + // locations are kept without the source's query, which (a version, a signature) goes with every + // request for a file that has no query of its own const folderUrl = new URL(parsed); folderUrl.search = folderUrl.hash = ""; folderUrl.pathname = parsed.pathname.endsWith("/avatar.json") ? parsed.pathname.slice(0, -"avatar.json".length) : parsed.pathname.replace(/\/?$/, "/"); const folder = folderUrl.href; const download = async (location: string, kind: "json" | "png") => { - const request = Object.assign(new URL(location), { search: parsed.search }); + const request = new URL(location); + if (!request.search) request.search = parsed.search; const response = await fetch(request, { signal: AbortSignal.timeout(120_000) }); if (response.status === 404) return undefined; if (!response.ok) throw new Error(`${location}: HTTP ${response.status}`); @@ -100,8 +102,9 @@ function urlStore(url: string, cacheDir: string): Store { contains: (root, location) => location.startsWith(root.replace(/\/?$/, "/")), async read(location, kind, cacheKey) { if (!cacheKey) return download(location, kind); - // cached per package and per listing that names the file, so a rebuilt avatar is fetched again - const file = join(cacheDir, hash(folder), cacheKey, hash(location) + "." + kind); + // cached per package (and its query: another version) and per listing that names the file, so + // a rebuilt avatar is fetched again + const file = join(cacheDir, hash(folder + parsed.search), cacheKey, hash(location) + "." + kind); const cached = await readFile(file).catch(() => undefined); if (cached) return cached; const body = await download(location, kind); diff --git a/tests/avatar.test.ts b/tests/avatar.test.ts index 06e94ca..a9e5a71 100644 --- a/tests/avatar.test.ts +++ b/tests/avatar.test.ts @@ -96,9 +96,21 @@ describe("loadAvatar", () => { fresh(); await loadAvatar(`${base}/ani?v=2`, { cacheDir: join(cacheDir, "query") }); expect(requests).toContain("/ani/avatar.json"); + // another version is another cache entry: its images are fetched, not taken from v=1 + expect(requests).toContain("/ani/built/base.png"); expect(new Set(queries)).toEqual(new Set(["v=2"])); }); + it("keeps an asset's own query", async () => { + fresh(); + const manifest = JSON.parse(await readFile(join(ANI, "avatar.json"), "utf8")) as { assets: Record }; + overrides.set("/ani/avatar.json", JSON.stringify({ ...manifest, assets: { ...manifest.assets, rig: "rig.json?token=abc" } })); + overrides.set("/ani/rig.json", await readFile(join(ANI, "rig.json"), "utf8")); + await loadAvatar(`${base}/ani/?v=3`, { cacheDir }); + expect(queries[requests.indexOf("/ani/rig.json")]).toBe("token=abc"); + expect(queries[requests.indexOf("/ani/built/layers.json")]).toBe("v=3"); + }); + it("refuses files outside the avatar's folder", async () => { fresh(); const manifest = JSON.parse(await readFile(join(ANI, "avatar.json"), "utf8")) as Record;