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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 12 additions & 4 deletions src/avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,16 @@ 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 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 response = await fetch(location, { signal: AbortSignal.timeout(120_000) });
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}`);
if (Number(response.headers.get("content-length") ?? 0) > MAX_DOWNLOAD) throw new Error(`${location}: larger than ${MAX_DOWNLOAD} bytes`);
Expand All @@ -95,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);
Expand Down
27 changes: 27 additions & 0 deletions tests/avatar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | null>();
let server: Server;
let base: string;
Expand All @@ -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);
Expand All @@ -39,6 +41,7 @@ afterAll(async () => {

const fresh = () => {
requests.length = 0;
queries.length = 0;
overrides.clear();
};

Expand Down Expand Up @@ -84,6 +87,30 @@ 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");
// 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<string, string> };
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<string, unknown>;
Expand Down
Loading