0.2.6-dev: /data/assets.json and /data/local-assets.json are served with content-hashed URLs (/assets/~<hash>/…, one hash per Spine model, the committed files unchanged); the ~hash routes of /assets/ /fonts/ /media/ answer immutable when the hash names the file and the current file with no-cache otherwise; content ETags — host bandwidth layer 1.2; golden: unchanged
This commit is contained in:
1 parent
521dd984e7
commit
893d17e725
12 files changed
+571
-34
No files matched your search
+234
-2
@@ -1,15 +1,23 @@
|
||||
// Content-hashed asset URLs (host bandwidth, layer 1; docs/ASSETS.md "The served manifests"):
|
||||
// * server/http/assetIndex.js — SHA-256 (16 hex) of every file under public/assets and public/fonts, cached by path +
|
||||
// size + mtime in a JSON file, never trusted for a file whose size or mtime changed; Spine models share one hash.
|
||||
import { test, describe } from 'node:test';
|
||||
// size + mtime in a JSON file, never trusted for a file whose size or mtime changed; Spine models share one hash;
|
||||
// * server/http/assetViews.js — the served data/assets.json / data/local-assets.json with every art URL carrying its
|
||||
// hash segment, the committed files untouched;
|
||||
// * the ~hash routes of static.js / media.js — the segment stripped, `immutable` only when it names the file's bytes,
|
||||
// the current file with `no-cache` otherwise; content ETags; the plain URLs as before.
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { createAssetIndex, hash16 } from '../server/http/assetIndex.js';
|
||||
import { rewriteManifest, relOf } from '../server/http/assetViews.js';
|
||||
import { startServer } from '../server/index.js';
|
||||
|
||||
const sha16 = (buf) => createHash('sha256').update(buf).digest('hex').slice(0, 16);
|
||||
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
||||
|
||||
function tree(files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-hash-'));
|
||||
@@ -21,6 +29,18 @@ function tree(files) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
function get(port, p, headers = {}, method = 'GET') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: '127.0.0.1', port, path: p, headers, method, agent: false }, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const until = async (fn, ms = 3000) => { for (const t0 = Date.now(); Date.now() - t0 < ms;) { if (await fn()) return true; await new Promise((r) => setTimeout(r, 20)); } return false; };
|
||||
|
||||
describe('the asset index', () => {
|
||||
@@ -122,3 +142,215 @@ describe('the asset index', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the served manifests (rewriteManifest)', () => {
|
||||
const H = { '/assets/char/a.png': 'aaaaaaaaaaaaaaaa', '/assets/audio/bgm/x.mp3': 'bbbbbbbbbbbbbbbb', '/fonts/f.woff2': 'cccccccccccccccc',
|
||||
'/assets/spine/x/x.skel': '1111111111111111', '/assets/spine/x/x.atlas': '2222222222222222', '/assets/spine/x/x.png': '3333333333333333',
|
||||
'/assets/local/spine/token/t/t.skel': '4444444444444444', '/assets/local/spine/token/t/t.atlas': '5555555555555555',
|
||||
'/assets/local/spine/token/t/t.png': '6666666666666666', '/assets/local/ui/battle/b.png': '7777777777777777' };
|
||||
const index = {
|
||||
hashOf: (rel) => H[`/${rel}`] || null,
|
||||
modelHash: (rels) => (rels.every((r) => H[`/${r}`]) ? hash16(rels.map((r) => H[`/${r}`]).join('\n')) : null),
|
||||
};
|
||||
|
||||
test('data/assets.json: own hashes, one model hash per Spine object, unknown files and names left alone, the input untouched', () => {
|
||||
const doc = {
|
||||
version: 1, hash: 'a1b2c3d4e5f6', generator: 'tools/fetch-assets.mjs',
|
||||
chars: { c: { avatar: '/assets/char/a.png', portrait: '/assets/char/missing.png',
|
||||
spine: { front: { skel: '/assets/spine/x/x.skel', atlas: '/assets/spine/x/x.atlas', textures: ['/assets/spine/x/x.png'], pma: false, anims: { idle: 'Idle' } } } } },
|
||||
enemies: { e: { icon: '/assets/char/a.png', spineLocal: { group: 'spine/enemy/e', skel: 'e.skel', atlas: 'e.atlas', textures: ['e.png'] } } },
|
||||
audio: { bgm: { lobby: { loop: '/assets/audio/bgm/x.mp3' } } },
|
||||
fonts: { css: '/fonts/fonts.css', faces: { f: { woff2: '/fonts/f.woff2', family: 'F' } } },
|
||||
};
|
||||
const copy = JSON.parse(JSON.stringify(doc));
|
||||
const r = rewriteManifest(doc, 'assets.json', index);
|
||||
assert.deepEqual(doc, copy, 'the committed manifest object is not changed');
|
||||
const m = index.modelHash(['assets/spine/x/x.skel', 'assets/spine/x/x.atlas', 'assets/spine/x/x.png']);
|
||||
assert.equal(r.doc.chars.c.avatar, '/assets/~aaaaaaaaaaaaaaaa/char/a.png');
|
||||
assert.equal(r.doc.chars.c.portrait, '/assets/char/missing.png', 'no hash: the plain URL');
|
||||
assert.deepEqual(r.doc.chars.c.spine.front, { skel: `/assets/~${m}/spine/x/x.skel`, atlas: `/assets/~${m}/spine/x/x.atlas`, textures: [`/assets/~${m}/spine/x/x.png`], pma: false, anims: { idle: 'Idle' } });
|
||||
assert.deepEqual(r.doc.enemies.e.spineLocal, copy.enemies.e.spineLocal, 'spineLocal holds names, not URLs');
|
||||
assert.equal(r.doc.audio.bgm.lobby.loop, '/assets/~bbbbbbbbbbbbbbbb/audio/bgm/x.mp3', 'audio keeps /assets/…/audio/….mp3 (media.js makes it /media/~hash/…)');
|
||||
assert.equal(r.doc.fonts.faces.f.woff2, '/fonts/~cccccccccccccccc/f.woff2');
|
||||
assert.equal(r.doc.fonts.css, '/fonts/fonts.css');
|
||||
assert.equal(r.doc.hash, 'a1b2c3d4e5f6');
|
||||
assert.deepEqual(r.models, [['assets/spine/x/x.skel', 'assets/spine/x/x.atlas', 'assets/spine/x/x.png']]);
|
||||
assert.equal(r.hashed, 7);
|
||||
assert.equal(r.plain, 2);
|
||||
});
|
||||
|
||||
test('data/local-assets.json: a spine/ group is one model, other groups own hashes', () => {
|
||||
const doc = { version: 1, source: 'local-client', count: 4, groups: {
|
||||
'spine/token/t': { 't.atlas': { path: '/assets/local/spine/token/t/t.atlas', kind: 'TextAsset' }, 't.png': { path: '/assets/local/spine/token/t/t.png', w: 4, h: 4 }, 't.skel': { path: '/assets/local/spine/token/t/t.skel' } },
|
||||
'ui/battle': { b: { path: '/assets/local/ui/battle/b.png', w: 2, h: 2, kind: 'Sprite' } },
|
||||
} };
|
||||
const r = rewriteManifest(doc, 'local-assets.json', index);
|
||||
const m = index.modelHash(['assets/local/spine/token/t/t.atlas', 'assets/local/spine/token/t/t.png', 'assets/local/spine/token/t/t.skel']);
|
||||
assert.ok(m);
|
||||
for (const [n, e] of Object.entries(r.doc.groups['spine/token/t'])) assert.equal(e.path, doc.groups['spine/token/t'][n].path.replace('/assets/', `/assets/~${m}/`), n);
|
||||
assert.equal(r.doc.groups['spine/token/t']['t.png'].w, 4);
|
||||
assert.deepEqual(r.doc.groups['ui/battle'].b, { path: '/assets/~7777777777777777/local/ui/battle/b.png', w: 2, h: 2, kind: 'Sprite' });
|
||||
assert.deepEqual([r.doc.version, r.doc.source, r.doc.count], [1, 'local-client', 4]);
|
||||
assert.equal(r.models.length, 1);
|
||||
});
|
||||
|
||||
test('relOf: URL path → index path, refusing what the static server refuses', () => {
|
||||
assert.equal(relOf('/assets/a%20b.png'), 'assets/a b.png');
|
||||
assert.equal(relOf('/fonts/f.woff2'), 'fonts/f.woff2');
|
||||
for (const u of ['/data/x.json', '/assets/../x', '/assets//x', '/assets/x?v=1', '/assets/%E0%A4%A', 'assets/x', null]) assert.equal(relOf(u), null, String(u));
|
||||
});
|
||||
});
|
||||
|
||||
describe('content-hashed URLs through a real server', () => {
|
||||
let srv;
|
||||
let dir;
|
||||
const png = randomBytes(3000);
|
||||
const mp3 = randomBytes(6000);
|
||||
const skel = Buffer.from('skel-binary'.repeat(80));
|
||||
const atlas = Buffer.from('x.png\nsize: 64,64\n'.repeat(30));
|
||||
const page = randomBytes(1500);
|
||||
const manifest = {
|
||||
version: 1, hash: 'abcdefabcdef',
|
||||
chars: { c: { avatar: '/assets/char/a.png', spine: { front: { skel: '/assets/spine/x/x.skel', atlas: '/assets/spine/x/x.atlas', textures: ['/assets/spine/x/x.png'], anims: { idle: 'Idle' } } } } },
|
||||
audio: { bgm: { lobby: { loop: '/assets/audio/bgm/lobby.mp3' } } },
|
||||
fonts: { css: '/fonts/fonts.css', faces: { f: { woff2: '/fonts/f.woff2' } } },
|
||||
stats: { note: 'a manifest large enough to be compressed '.repeat(40) },
|
||||
};
|
||||
const manifestText = JSON.stringify(manifest, null, 2);
|
||||
|
||||
before(async () => {
|
||||
dir = tree({
|
||||
'secret.txt': 'TOP-SECRET-CONTENT',
|
||||
'public/index.html': '<p>hi</p>', 'public/assets/char/a.png': png, 'public/assets/audio/bgm/lobby.mp3': mp3,
|
||||
'public/assets/spine/x/x.skel': skel, 'public/assets/spine/x/x.atlas': atlas, 'public/assets/spine/x/x.png': page,
|
||||
'public/assets/spine/x/tiles.json': '{"t":1}', 'public/fonts/f.woff2': randomBytes(500), 'public/fonts/fonts.css': '@font-face{}',
|
||||
'data/assets.json': manifestText, 'data/config.json': '{}', 'shared/x.js': 'export const y = 2;',
|
||||
});
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(dir, 'public'), dataDir: path.join(dir, 'data'),
|
||||
sharedDir: path.join(dir, 'shared'), assetIndex: { delayMs: 0 } });
|
||||
await srv.assetIndex.whenReady();
|
||||
});
|
||||
after(async () => {
|
||||
await srv?.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const view = async () => JSON.parse((await get(srv.port, '/data/assets.json')).body);
|
||||
|
||||
test('/data/assets.json is served with hashed URLs (no-cache, its own ETag, 304, gzip, HEAD); the committed file stays as it was', async () => {
|
||||
const r = await get(srv.port, '/data/assets.json');
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.equal(r.headers['cache-control'], 'no-cache');
|
||||
assert.match(r.headers.etag, /^"[0-9a-f]{16}"$/);
|
||||
const v = JSON.parse(r.body);
|
||||
assert.equal(v.chars.c.avatar, `/assets/~${sha16(png)}/char/a.png`);
|
||||
assert.equal(v.audio.bgm.lobby.loop, `/assets/~${sha16(mp3)}/audio/bgm/lobby.mp3`);
|
||||
assert.match(v.fonts.faces.f.woff2, /^\/fonts\/~[0-9a-f]{16}\/f\.woff2$/);
|
||||
const m = hash16([skel, atlas, page].map(sha16).join('\n'));
|
||||
assert.deepEqual([v.chars.c.spine.front.skel, v.chars.c.spine.front.atlas, v.chars.c.spine.front.textures[0]],
|
||||
[`/assets/~${m}/spine/x/x.skel`, `/assets/~${m}/spine/x/x.atlas`, `/assets/~${m}/spine/x/x.png`]);
|
||||
assert.equal((await get(srv.port, '/data/assets.json', { 'if-none-match': r.headers.etag })).status, 304);
|
||||
const gz = await get(srv.port, '/data/assets.json', { 'accept-encoding': 'gzip' });
|
||||
assert.equal(gz.headers['content-encoding'], 'gzip');
|
||||
assert.notEqual(gz.headers.etag, r.headers.etag);
|
||||
const head = await get(srv.port, '/data/assets.json', {}, 'HEAD');
|
||||
assert.equal(head.status, 200);
|
||||
assert.equal(head.body.length, 0);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'data/assets.json'), 'utf8'), manifestText, 'never written');
|
||||
assert.equal((await get(srv.port, '/data/local-assets.json')).status, 200, 'an absent local manifest is still the empty stand-in');
|
||||
assert.deepEqual(JSON.parse((await get(srv.port, '/data/local-assets.json')).body).groups, {});
|
||||
});
|
||||
|
||||
test('a hashed URL: immutable with a content ETag when the hash names the file; the current file with no-cache otherwise; plain URLs as before', async () => {
|
||||
const v = await view();
|
||||
const ok = await get(srv.port, v.chars.c.avatar);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(ok.body, png);
|
||||
assert.equal(ok.headers['cache-control'], IMMUTABLE);
|
||||
assert.equal(ok.headers.etag, `"${sha16(png)}"`);
|
||||
const old = await get(srv.port, '/assets/~0000000000000000/char/a.png');
|
||||
assert.deepEqual(old.body, png, 'an old page still gets the file');
|
||||
assert.equal(old.headers['cache-control'], 'no-cache');
|
||||
const plain = await get(srv.port, '/assets/char/a.png');
|
||||
assert.equal(plain.headers['cache-control'], 'public, max-age=86400');
|
||||
assert.equal(plain.headers.etag, `"${sha16(png)}"`, 'a content ETag on the plain URL too (an mtime change alone is a 304)');
|
||||
assert.equal((await get(srv.port, '/assets/char/a.png', { 'if-none-match': `"${sha16(png)}"` })).status, 304);
|
||||
const font = await get(srv.port, v.fonts.faces.f.woff2);
|
||||
assert.equal(font.headers['cache-control'], IMMUTABLE);
|
||||
const query = await get(srv.port, '/assets/char/a.png?v=1');
|
||||
assert.match(query.headers['cache-control'], /immutable/, 'the old ?v= rule is kept');
|
||||
});
|
||||
|
||||
test('a Spine model: skeleton, atlas, page and the atlas derived from the skeleton URL are immutable under the model hash; a file derived beside them is no-cache', async () => {
|
||||
const sp = (await view()).chars.c.spine.front;
|
||||
for (const u of [sp.skel, sp.atlas, sp.textures[0], sp.skel.replace(/\.skel$/, '.atlas')]) {
|
||||
const r = await get(srv.port, u);
|
||||
assert.equal(r.status, 200, u);
|
||||
assert.equal(r.headers['cache-control'], IMMUTABLE, u);
|
||||
}
|
||||
const sk = await get(srv.port, sp.skel);
|
||||
assert.deepEqual(sk.body, skel);
|
||||
assert.equal(sk.headers.etag, `"${sha16(skel)}"`, 'the ETag is the file\'s own content hash');
|
||||
const tiles = await get(srv.port, sp.skel.replace(/[^/]+$/, 'tiles.json'));
|
||||
assert.equal(tiles.status, 200);
|
||||
assert.equal(tiles.headers['cache-control'], 'no-cache', 'not a member of the model');
|
||||
});
|
||||
|
||||
test('audio: /media/~hash/… resolves like /media/…, immutable when the hash matches, ranges and ETags as before', async () => {
|
||||
const loop = (await view()).audio.bgm.lobby.loop;
|
||||
const media = loop.replace(/^\/assets\/(~[0-9a-f]{16})\/audio\/(.+)\.mp3$/, '/media/$1/$2');
|
||||
assert.match(media, /^\/media\/~[0-9a-f]{16}\/bgm\/lobby$/);
|
||||
const r = await get(srv.port, media);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.headers['content-type'], 'audio/mpeg');
|
||||
assert.equal(r.headers['cache-control'], IMMUTABLE);
|
||||
assert.equal(r.headers.etag, `"${sha16(mp3)}"`);
|
||||
assert.deepEqual(r.body, mp3);
|
||||
const range = await get(srv.port, media, { range: 'bytes=0-9' });
|
||||
assert.equal(range.status, 206);
|
||||
assert.deepEqual(range.body, mp3.subarray(0, 10));
|
||||
assert.equal((await get(srv.port, loop)).headers['cache-control'], IMMUTABLE, 'the .mp3 fallback URL too');
|
||||
assert.equal((await get(srv.port, '/media/~0000000000000000/bgm/lobby')).headers['cache-control'], 'no-cache');
|
||||
assert.equal((await get(srv.port, '/media/bgm/lobby')).headers['cache-control'], 'public, max-age=86400');
|
||||
});
|
||||
|
||||
test('the segment never opens a way out of the served folders', async () => {
|
||||
const h = sha16(png);
|
||||
for (const p of [`/assets/~${h}/../../secret.txt`, `/assets/~${h}/%2e%2e/%2e%2e/secret.txt`, `/assets/~${h}/..%2f..%2fsecret.txt`,
|
||||
`/media/~${h}/..%2f..%2f..%2fsecret.txt`, `/fonts/~${h}/../../secret.txt`, `/assets/~${h}/.hidden`, `/assets/~${h}`, '/assets/~/char/a.png']) {
|
||||
const r = await get(srv.port, p);
|
||||
assert.ok([301, 400, 403, 404].includes(r.status), `${p} → ${r.status}`);
|
||||
assert.ok(!r.body.toString().includes('TOP-SECRET'), `${p} leaked`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a file replaced on disk: its old hashed URL turns no-cache at once, and after the rescan the view names the new bytes', async () => {
|
||||
const before = (await view()).chars.c.avatar;
|
||||
const next = randomBytes(3100);
|
||||
fs.writeFileSync(path.join(dir, 'public/assets/char/a.png'), next);
|
||||
const stale = await get(srv.port, before);
|
||||
assert.deepEqual(stale.body, next, 'the current file');
|
||||
assert.equal(stale.headers['cache-control'], 'no-cache', 'the index no longer vouches for these bytes');
|
||||
// the miss asked for a rescan (RESCAN_GAP_MS after the last build) — trigger it now instead of waiting
|
||||
await srv.assetIndex.refresh();
|
||||
assert.ok(await until(async () => (await view()).chars.c.avatar === `/assets/~${sha16(next)}/char/a.png`), 'the view follows');
|
||||
assert.equal((await get(srv.port, `/assets/~${sha16(next)}/char/a.png`)).headers['cache-control'], IMMUTABLE);
|
||||
});
|
||||
});
|
||||
|
||||
test('without the index (assetIndex: false) the manifest goes out as committed and a hashed URL is served no-cache', async () => {
|
||||
const dir = tree({ 'public/assets/a.png': 'png', 'data/assets.json': JSON.stringify({ chars: { c: { avatar: '/assets/a.png' } } }) });
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(dir, 'public'), dataDir: path.join(dir, 'data'), assetIndex: false });
|
||||
try {
|
||||
assert.equal(srv.assetIndex, null);
|
||||
assert.equal(JSON.parse((await get(srv.port, '/data/assets.json')).body).chars.c.avatar, '/assets/a.png');
|
||||
const r = await get(srv.port, `/assets/~${sha16(Buffer.from('png'))}/a.png`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.headers['cache-control'], 'no-cache');
|
||||
assert.match(r.headers.etag, /^"[0-9a-f]+-[0-9a-f]+"$/, 'size-mtime ETag');
|
||||
} finally {
|
||||
await srv.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+6
-1
@@ -138,7 +138,10 @@ describe('static http server', () => {
|
||||
srv = await startServer({
|
||||
port: 0, host: '127.0.0.1', quiet: true,
|
||||
publicDir: path.join(tmp, 'public'), dataDir: path.join(tmp, 'data'), sharedDir: path.join(tmp, 'shared'),
|
||||
assetIndex: { delayMs: 0 },
|
||||
});
|
||||
// the content hashes of public/assets + public/fonts (the ETags of those files): ready before the first request
|
||||
await srv.assetIndex.whenReady();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
@@ -226,9 +229,11 @@ describe('static http server', () => {
|
||||
const gzEtag = (await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } })).headers.etag;
|
||||
assert.notEqual(gzEtag, etag, 'gzip variant has its own ETag');
|
||||
|
||||
// unversioned art/fonts/vendor: cached for a day (files can be rewritten in place by the asset tools)
|
||||
// unversioned art/fonts/vendor: cached for a day (files can be rewritten in place by the asset tools); the art and
|
||||
// fonts carry their content hash as ETag (server/http/assetIndex.js), so a rewrite with the same bytes is a 304
|
||||
const asset = await httpReq(srv.port, '/assets/img/a.png');
|
||||
assert.equal(asset.headers['cache-control'], 'public, max-age=86400');
|
||||
assert.match(asset.headers.etag, /^"[0-9a-f]{16}"$/);
|
||||
const font = await httpReq(srv.port, '/fonts/f.woff2');
|
||||
assert.equal(font.headers['cache-control'], 'public, max-age=86400');
|
||||
const assetV = await httpReq(srv.port, '/assets/img/a.png?v=abc');
|
||||
|
||||
Reference in new issue
Block a user