0.2.6-dev: brotli (quality 5) on the fly with the existing memory cache for clients that name br, gzip for the rest; separate ETags per encoding (measured against gzip-6: .skel −27 %, data JSON −26 %, vendor −14 %, scripts −5 %) — host bandwidth layer 1.4; golden: unchanged
This commit is contained in:
1 parent
35b09bb63b
commit
0bb16fd616
8 files changed
+173
-59
No files matched your search
@@ -77,7 +77,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|
||||
| path | what |
|
||||
|---|---|
|
||||
| `server/index.js` | the process entry (`npm start`); `startServer()` wires `server/http/` |
|
||||
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`, permessage-deflate), `static.js` (the mounts, the `~<hash>` asset routes), `media.js`, `files.js` (MIME, gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `traffic.js` (bytes sent by category and on the WebSocket, `/healthz?traffic=1`), `assetIndex.js` (the content hashes of `public/assets/` and `public/fonts/`, cached in `.cache/asset-hashes.json`), `assetViews.js` (the served `/data/assets.json` and `/data/local-assets.json`: every art URL with its hash segment; ASSETS.md), `common.js`, `boot.js` (a pending update package first, banner, shutdown) |
|
||||
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`, permessage-deflate), `static.js` (the mounts, the `~<hash>` asset routes), `media.js`, `files.js` (MIME, brotli / gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `traffic.js` (bytes sent by category and on the WebSocket, `/healthz?traffic=1`), `assetIndex.js` (the content hashes of `public/assets/` and `public/fonts/`, cached in `.cache/asset-hashes.json`), `assetViews.js` (the served `/data/assets.json` and `/data/local-assets.json`: every art URL with its hash segment; ASSETS.md), `common.js`, `boot.js` (a pending update package first, banner, shutdown) |
|
||||
| `server/net.js` | sessions and reconnect tokens, rate limits, message validation |
|
||||
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
|
||||
| `server/data.js` | loads `data/*.json` once (frozen) |
|
||||
|
||||
+1
-1
@@ -492,7 +492,7 @@ each (`server/http/assetIndex.js`, `server/http/assetViews.js`):
|
||||
- The server strips the segment and serves the file at the plain path. When the segment names the file's current bytes
|
||||
(its own hash, or its model's) the answer is `Cache-Control: public, max-age=31536000, immutable`; any other segment —
|
||||
a page loaded before an update, a URL derived from another file's — gets the current file with `no-cache`, so an old
|
||||
page keeps working. The ETag of every indexed file is its content hash (`"398f32e2ff78d9e6"`, plus `-gz` when
|
||||
page keeps working. The ETag of every indexed file is its content hash (`"398f32e2ff78d9e6"`, plus `-br` / `-gz` when
|
||||
compressed), on the plain URL too.
|
||||
- A path segment, never `?v=`: `assets.js validSpine` matches `/\.skel$/`, `audio.js SKILL_MODE_FILE` the `.mp3` ending,
|
||||
pixi-spine takes the atlas name from the skeleton's basename and resolves the pages against the atlas's folder — a
|
||||
|
||||
+1
-1
@@ -223,7 +223,7 @@ server {
|
||||
|
||||
**HTTP/2**:客户端由数百个小脚本模块组成(0.2.0 起进入对局时约 340 个),隔着公网时建议让代理以 HTTP/2 提供页面:所有模块走同一条连接,远距离玩家首次进入对局明显更快。Caddy 默认就是 HTTP/2;Nginx 见上面的 `http2`。服务器本身只说 HTTP/1.1,局域网或本机游玩不受影响。HTTP/2 省的是往返等待,不减少字节数。
|
||||
|
||||
**压缩**:服务器自己压缩脚本、样式、数据和 Spine 的 `.skel` / `.atlas`:浏览器支持 brotli 时用 brotli(0.2.6 起,比 gzip 更小,`.skel` 小得最多),否则用 gzip;图片和音频本来就是压缩格式,原样发送。浏览器只在 HTTPS 页面(以及 `localhost`)上声明支持 brotli,所以通过代理或隧道的 https 地址访问时用 brotli,局域网里的 `http://192.168.x.x:3000` 这类地址用 gzip。代理不需要另开压缩(Caddy 的 `encode`、Nginx 的 `gzip` 遇到已经压缩的响应会跳过)。
|
||||
**压缩**:服务器自己压缩脚本、样式、数据和 Spine 的 `.skel` / `.atlas`:浏览器支持 brotli 时用 brotli(0.2.6 起,质量 5,比 gzip 快且更小:实测 `.skel` 小约 27%、游戏数据 JSON 小约 26%、前端库小约 14%、脚本小约 5%),否则用 gzip;图片和音频本来就是压缩格式,原样发送。浏览器只在 HTTPS 页面(以及 `localhost`)上声明支持 brotli,所以通过代理或隧道的 https 地址访问时用 brotli,局域网里的 `http://192.168.x.x:3000` 这类地址用 gzip。代理不需要另开压缩(Caddy 的 `encode`、Nginx 的 `gzip` 遇到已经压缩的响应会跳过)。
|
||||
|
||||
**Cloudflare 缓存**(命名隧道或自己的域名经过 Cloudflare 时):Cloudflare 默认只缓存常见扩展名,`.skel`、`.atlas` 和没有扩展名的音频地址 `/media/…` 都不在其中,每个请求都会回到开服的电脑。在 Cloudflare 控制台的「缓存规则」里加一条:主机名是你的游戏域名、且 URI 路径以 `/assets/`、`/fonts/` 或 `/media/` 开头时「符合缓存条件」,边缘 TTL 选「使用源站的 Cache-Control」。0.2.6 起素材清单里的地址带内容哈希(`/assets/~<哈希>/…`),服务器给这类地址一年的 `immutable`,换了内容的文件会换地址,所以长期缓存是安全的;`/data/`、页面和脚本不要加进规则(它们要随服务器更新)。注意:边缘公开缓存官方素材会增加被投诉的风险,大量媒体文件也要留意 Cloudflare 的服务条款;只给自己人玩时不加这条规则也可以。
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ server/
|
||||
static.js mounts / → public, /data/, /shared/, /sim/ (.js only), the /data.js browser stand-in, path guards,
|
||||
the ~<hash> segment of /assets/ /fonts/ /media/ URLs stripped (immutable when it names the file)
|
||||
media.js /media/… extension-less audio → public/assets/audio
|
||||
files.js one file → response: MIME, gzip (.skel/.atlas/.json/.js/.css …), ETag (content hash when known) / 304,
|
||||
files.js one file → response: MIME, brotli q5 / gzip (.skel/.atlas/.json/.js/.css …), ETag (content hash when known) / 304,
|
||||
cache policy, ranges; serveBuffer for the served manifests
|
||||
buildTag.js build tag of the served browser runtime (/healthz build, public/js/ui/buildGuard.js)
|
||||
routes.js request listener: 414 / 400 / 405 guards, GET /healthz (?traffic=1: the counters), then static files
|
||||
|
||||
+111
-39
@@ -1,8 +1,9 @@
|
||||
// server/http/files.js — one file on disk (serveFile) or one body in memory (serveBuffer, the served manifests of
|
||||
// assetViews.js) → an HTTP response (static.js and media.js decide which file):
|
||||
// MIME types incl. .mjs/.js text/javascript, .skel application/octet-stream, .atlas text/plain;
|
||||
// gzip for text-like types, .skel and uncompressed fonts when the client accepts it (small files are
|
||||
// compressed once and cached in memory); strong ETag + Last-Modified with 304s — the ETag is the content hash
|
||||
// brotli (quality 5) for text-like types, .skel and uncompressed fonts when the client accepts it, else gzip
|
||||
// (level 6) — browsers offer brotli on HTTPS pages and localhost only (small files are compressed once per
|
||||
// encoding and cached in memory); strong ETag + Last-Modified with 304s — the ETag is the content hash
|
||||
// when the caller has one (assetIndex.js), else size + mtime; Cache-Control (html & code/data: no-cache +
|
||||
// revalidate; public/assets|fonts|vendor: 1 day; a URL whose content-hash segment matches the file (static.js,
|
||||
// media.js): immutable for a year; any `?v=` URL: immutable); single byte-range requests (206/416, used by <audio>).
|
||||
@@ -55,28 +56,60 @@ export const MIME = Object.freeze({
|
||||
'.ttf': 'font/ttf',
|
||||
});
|
||||
|
||||
/** Extensions worth gzipping (text-like, .skel, uncompressed fonts). */
|
||||
/** Extensions worth compressing (text-like, .skel, uncompressed fonts). */
|
||||
export const COMPRESSIBLE = new Set([
|
||||
'.html', '.htm', '.js', '.mjs', '.css', '.json', '.map', '.webmanifest', '.txt', '.md', '.csv', '.xml',
|
||||
'.atlas', '.skel', '.bin', '.wasm', '.svg', '.ico', '.otf', '.ttf', '.wav',
|
||||
]);
|
||||
|
||||
/** Types brotli compresses in its text mode (the rest of COMPRESSIBLE: generic mode). */
|
||||
const TEXT_LIKE = new Set(['.html', '.htm', '.js', '.mjs', '.css', '.json', '.map', '.webmanifest', '.txt', '.md', '.csv', '.xml', '.atlas', '.svg']);
|
||||
|
||||
const GZIP_MIN_BYTES = 512;
|
||||
const GZIP_CACHE_MAX_FILE = 8 << 20; // larger files are gzip-streamed on the fly
|
||||
const GZIP_CACHE_MAX_TOTAL = 96 << 20;
|
||||
// Asset URLs carry no content hash yet, and tools/fetch-assets.mjs / tools/vendor.mjs can rewrite files in
|
||||
// place (atlas + png + skel must stay consistent), so "long" is one day; revalidation after that is a cheap 304.
|
||||
const GZIP_CACHE_MAX_FILE = 8 << 20; // larger files are compressed as a stream on the fly
|
||||
const GZIP_CACHE_MAX_TOTAL = 96 << 20; // both encodings together
|
||||
/**
|
||||
* Brotli quality of the on-the-fly compression: faster than gzip-6 and smaller — measured on this repository against
|
||||
* gzip-6: .skel −27 %, data/*.json −26 %, vendor −14 %, public/js and server/sim −5 %, .atlas −7 %. Quality 11 would gain
|
||||
* another ~10 % at ~100× the time, so it is not done per request.
|
||||
*/
|
||||
export const BROTLI_QUALITY = 5;
|
||||
// A plain asset URL (no content-hash segment) names a file tools/fetch-assets.mjs / tools/vendor.mjs can rewrite in
|
||||
// place (atlas + png + skel must stay consistent), so "long" is one day; revalidation after that is a cheap 304. A
|
||||
// content-hashed URL whose hash names the file is immutable (static.js, media.js).
|
||||
export const LONG_CACHE = 'public, max-age=86400'; // 1 day
|
||||
export const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
|
||||
const LONG_CACHE_DIRS = ['assets', 'fonts', 'vendor']; // first path segment under public/
|
||||
|
||||
const gzipAsync = promisify(zlib.gzip);
|
||||
const brotliAsync = promisify(zlib.brotliCompress);
|
||||
|
||||
/** zlib options of an encoding for a body of `size` bytes of type `ext`. */
|
||||
function brotliParams(ext, size) {
|
||||
return {
|
||||
params: {
|
||||
[zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
|
||||
[zlib.constants.BROTLI_PARAM_MODE]: TEXT_LIKE.has(ext) ? zlib.constants.BROTLI_MODE_TEXT : zlib.constants.BROTLI_MODE_GENERIC,
|
||||
...(size > 0 ? { [zlib.constants.BROTLI_PARAM_SIZE_HINT]: size } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** `raw` compressed with `encoding` ('br' | 'gzip'). */
|
||||
function compress(raw, encoding, ext) {
|
||||
return encoding === 'br' ? brotliAsync(raw, brotliParams(ext, raw.length)) : gzipAsync(raw, { level: 6 });
|
||||
}
|
||||
|
||||
/** A streaming compressor for a file too large for the cache. */
|
||||
function compressStream(encoding, ext, size) {
|
||||
return encoding === 'br' ? zlib.createBrotliCompress(brotliParams(ext, size)) : zlib.createGzip();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// gzip cache (LRU by bytes)
|
||||
// compression cache (LRU by bytes; one entry per file and encoding)
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
export class GzipCache {
|
||||
export class CompressCache {
|
||||
constructor(maxTotal = GZIP_CACHE_MAX_TOTAL) {
|
||||
this.maxTotal = maxTotal;
|
||||
this.total = 0;
|
||||
@@ -84,24 +117,27 @@ export class GzipCache {
|
||||
/** @type {Map<string, Promise<Buffer>>} */ this.inflight = new Map();
|
||||
}
|
||||
|
||||
/** @returns {Promise<Buffer>} gzip of the file identified by (path, size, mtime) */
|
||||
get(absPath, stat) {
|
||||
return this.compressed(`${absPath}\0${stat.size}\0${stat.mtimeMs}`, () => fsp.readFile(absPath));
|
||||
/** @returns {Promise<Buffer>} the file identified by (path, size, mtime) compressed with `encoding` ('gzip' by default) */
|
||||
get(absPath, stat, encoding = 'gzip') {
|
||||
return this.compressed(`${absPath}\0${stat.size}\0${stat.mtimeMs}`, () => fsp.readFile(absPath), encoding, path.extname(absPath).toLowerCase());
|
||||
}
|
||||
|
||||
/** @returns {Promise<Buffer>} gzip of the body `read()` returns, cached under `key` (one compression per key at a time) */
|
||||
compressed(key, read) {
|
||||
const hit = this.map.get(key);
|
||||
if (hit) { this.map.delete(key); this.map.set(key, hit); return Promise.resolve(hit); }
|
||||
const pending = this.inflight.get(key);
|
||||
/**
|
||||
* @returns {Promise<Buffer>} the body `read()` returns compressed with `encoding`, cached under `key` + encoding (one
|
||||
* compression per entry at a time)
|
||||
*/
|
||||
compressed(key, read, encoding = 'gzip', ext = '') {
|
||||
const k = `${encoding}\0${key}`;
|
||||
const hit = this.map.get(k);
|
||||
if (hit) { this.map.delete(k); this.map.set(k, hit); return Promise.resolve(hit); }
|
||||
const pending = this.inflight.get(k);
|
||||
if (pending) return pending;
|
||||
const p = (async () => {
|
||||
const raw = await read();
|
||||
const gz = await gzipAsync(raw, { level: 6 });
|
||||
this.store(key, gz);
|
||||
return gz;
|
||||
})().finally(() => this.inflight.delete(key));
|
||||
this.inflight.set(key, p);
|
||||
const out = await compress(await read(), encoding, ext);
|
||||
this.store(k, out);
|
||||
return out;
|
||||
})().finally(() => this.inflight.delete(k));
|
||||
this.inflight.set(k, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -117,10 +153,46 @@ export class GzipCache {
|
||||
}
|
||||
}
|
||||
|
||||
/** The cache's former name (it held gzip only). */
|
||||
export const GzipCache = CompressCache;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Accept-Encoding → { token: q } (lower-case tokens; a malformed q counts as 0). */
|
||||
function acceptedEncodings(header) {
|
||||
const out = {};
|
||||
if (!header || typeof header !== 'string') return out;
|
||||
for (const part of header.split(',')) {
|
||||
const [token, ...params] = part.trim().toLowerCase().split(';');
|
||||
if (!token) continue;
|
||||
let q = 1;
|
||||
for (const p of params) {
|
||||
const m = /^\s*q=([0-9.]+)\s*$/.exec(p);
|
||||
if (m) q = Number(m[1]);
|
||||
}
|
||||
out[token.trim()] = Number.isFinite(q) ? q : 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The content coding to answer with: 'br' when the client names brotli (q > 0) at least as high as gzip, else 'gzip'
|
||||
* when it accepts gzip (or `*`), else null (identity). `*` alone never picks brotli. Browsers name `br` on HTTPS pages
|
||||
* and localhost only, so a LAN `http://` address gets gzip.
|
||||
* @param {string | undefined} header
|
||||
* @returns {'br' | 'gzip' | null}
|
||||
*/
|
||||
export function pickEncoding(header) {
|
||||
const acc = acceptedEncodings(header);
|
||||
const brQ = acc.br ?? 0;
|
||||
const gzQ = acc.gzip ?? acc['x-gzip'] ?? acc['*'] ?? 0;
|
||||
if (brQ > 0 && brQ >= gzQ) return 'br';
|
||||
if (gzQ > 0) return 'gzip';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Does the Accept-Encoding header allow gzip (q > 0)? @param {string | undefined} header */
|
||||
export function acceptsGzip(header) {
|
||||
if (!header || typeof header !== 'string') return false;
|
||||
@@ -206,9 +278,9 @@ export async function serveFile(req, res, absPath, stat, mountName, segments, qu
|
||||
const type = MIME[ext] || 'application/octet-stream';
|
||||
const compressible = COMPRESSIBLE.has(ext);
|
||||
const rangeHeader = req.headers.range;
|
||||
const useGzip = compressible && stat.size >= GZIP_MIN_BYTES && !rangeHeader && acceptsGzip(req.headers['accept-encoding']);
|
||||
const encoding = compressible && stat.size >= GZIP_MIN_BYTES && !rangeHeader ? pickEncoding(req.headers['accept-encoding']) : null;
|
||||
const baseTag = opts.contentHash || `${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}`;
|
||||
const etag = `"${baseTag}${useGzip ? '-gz' : ''}"`;
|
||||
const etag = `"${baseTag}${encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : ''}"`;
|
||||
const lastModified = stat.mtime.toUTCString();
|
||||
const isHead = req.method === 'HEAD';
|
||||
|
||||
@@ -226,18 +298,18 @@ export async function serveFile(req, res, absPath, stat, mountName, segments, qu
|
||||
return;
|
||||
}
|
||||
|
||||
if (useGzip) {
|
||||
headers['Content-Encoding'] = 'gzip';
|
||||
if (encoding) {
|
||||
headers['Content-Encoding'] = encoding;
|
||||
if (stat.size <= GZIP_CACHE_MAX_FILE) {
|
||||
const gz = await gzipCache.get(absPath, stat);
|
||||
headers['Content-Length'] = gz.length;
|
||||
const body = await gzipCache.get(absPath, stat, encoding);
|
||||
headers['Content-Length'] = body.length;
|
||||
res.writeHead(200, headers);
|
||||
res.end(isHead ? undefined : gz);
|
||||
res.end(isHead ? undefined : body);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, headers);
|
||||
if (isHead) { res.end(); return; }
|
||||
await streamTo(fs.createReadStream(absPath), res, log, zlib.createGzip());
|
||||
await streamTo(fs.createReadStream(absPath), res, log, compressStream(encoding, ext, stat.size));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -265,13 +337,13 @@ export async function serveFile(req, res, absPath, stat, mountName, segments, qu
|
||||
}
|
||||
|
||||
/**
|
||||
* One body in memory → the response (the served manifests): its ETag from `hash`, gzip when accepted (cached under
|
||||
* `key`), 304 on a matching If-None-Match, no ranges.
|
||||
* @param {{ body: Buffer, hash: string, type: string, cacheControl: string, key: string, cache: GzipCache }} o
|
||||
* One body in memory → the response (the served manifests): its ETag from `hash`, brotli or gzip when accepted (cached
|
||||
* under `key` + `hash`), 304 on a matching If-None-Match, no ranges.
|
||||
* @param {{ body: Buffer, hash: string, type: string, cacheControl: string, key: string, cache: CompressCache }} o
|
||||
*/
|
||||
export async function serveBuffer(req, res, { body, hash, type, cacheControl, key, cache }) {
|
||||
const useGzip = body.length >= GZIP_MIN_BYTES && acceptsGzip(req.headers['accept-encoding']);
|
||||
const etag = `"${hash}${useGzip ? '-gz' : ''}"`;
|
||||
const encoding = body.length >= GZIP_MIN_BYTES ? pickEncoding(req.headers['accept-encoding']) : null;
|
||||
const etag = `"${hash}${encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : ''}"`;
|
||||
const headers = { 'Content-Type': type, 'Cache-Control': cacheControl, ETag: etag, Vary: 'Accept-Encoding' };
|
||||
const inm = req.headers['if-none-match'];
|
||||
if (typeof inm === 'string' && (inm.trim() === '*' || inm.split(',').some((t) => stripWeak(t) === etag))) {
|
||||
@@ -280,9 +352,9 @@ export async function serveBuffer(req, res, { body, hash, type, cacheControl, ke
|
||||
return;
|
||||
}
|
||||
let out = body;
|
||||
if (useGzip) {
|
||||
out = await cache.compressed(`${key}\0${hash}`, async () => body);
|
||||
headers['Content-Encoding'] = 'gzip';
|
||||
if (encoding) {
|
||||
out = await cache.compressed(`${key}\0${hash}`, async () => body, encoding, path.extname(key).toLowerCase());
|
||||
headers['Content-Encoding'] = encoding;
|
||||
}
|
||||
headers['Content-Length'] = out.length;
|
||||
res.writeHead(200, headers);
|
||||
|
||||
@@ -34,7 +34,7 @@ import { MEDIA_PREFIX } from '../../shared/media.js';
|
||||
import { splitHashedPath } from '../../shared/assetPaths.js';
|
||||
import { ROOT, noopLog } from './config.js';
|
||||
import { sendError, sendJson } from './common.js';
|
||||
import { MIME, GzipCache, isNotModified, serveFile, serveBuffer } from './files.js';
|
||||
import { MIME, CompressCache, isNotModified, serveFile, serveBuffer } from './files.js';
|
||||
import { ASSET_INDEX_ROOTS, hashedFileOpts } from './assetIndex.js';
|
||||
import { serveMedia } from './media.js';
|
||||
import { createPackRegistry } from '../packs.js';
|
||||
@@ -81,7 +81,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
];
|
||||
const shimBody = Buffer.from(DATA_SHIM_JS);
|
||||
const shimTag = `"shim-${shimBody.length.toString(16)}"`;
|
||||
const gzipCache = new GzipCache();
|
||||
const gzipCache = new CompressCache(); // brotli and gzip bodies (files.js)
|
||||
|
||||
return async function serveStatic(req, res, rawPath, query) {
|
||||
let decoded;
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@
|
||||
// http/static.js the static mounts (/ → public/, /data/, /shared/, /sim/ `.js` only), the /data.js browser stand-in,
|
||||
// the content packs (/packs/index.json, /packs/<id>/<file> — the registry is packs.js)
|
||||
// http/media.js /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 (audio addressed without its extension)
|
||||
// http/files.js one file → response: MIME, gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
|
||||
// http/files.js one file → response: MIME, brotli / gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
|
||||
// http/assetIndex.js the content hashes of public/assets and public/fonts (built in the background, cached in
|
||||
// .cache/asset-hashes.json by path + size + mtime; Spine models share one hash)
|
||||
// http/assetViews.js the served /data/assets.json and /data/local-assets.json: every art URL with its hash segment
|
||||
@@ -34,7 +34,7 @@ import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustP
|
||||
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
|
||||
import { DATA_SHIM_JS, createStaticHandler } from './http/static.js';
|
||||
import { createPackRegistry } from './packs.js';
|
||||
import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
|
||||
import { MIME, COMPRESSIBLE, acceptsGzip, pickEncoding, parseRange } from './http/files.js';
|
||||
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
|
||||
import { createRequestHandler } from './http/routes.js';
|
||||
import { createTrafficCounter } from './http/traffic.js';
|
||||
@@ -46,7 +46,7 @@ import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
|
||||
// The public API of this module (tests and tools import it from here); the code lives in ./http/.
|
||||
export {
|
||||
ROOT, WS_MAX_PAYLOAD, DATA_SHIM_JS, MIME, COMPRESSIBLE, BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag,
|
||||
acceptsGzip, parseRange, createStaticHandler, lanUrls, parseTrustProxy,
|
||||
acceptsGzip, pickEncoding, parseRange, createStaticHandler, lanUrls, parseTrustProxy,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+53
-11
@@ -11,7 +11,7 @@ import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { startServer, parseRange, acceptsGzip, parseTrustProxy } from '../server/index.js';
|
||||
import { startServer, parseRange, acceptsGzip, pickEncoding, parseTrustProxy } from '../server/index.js';
|
||||
import { loadData, lookup, getChess, getBond, getBand, getMode, getConfig, INDEXED_FILES } from '../server/data.js';
|
||||
import * as dataModule from '../server/data.js';
|
||||
import { CODE_ALPHABET, BOT_NAMES } from '../server/lobby.js';
|
||||
@@ -109,6 +109,8 @@ describe('static http server', () => {
|
||||
const js = `// app\n${'export const x = 1;\n'.repeat(200)}`;
|
||||
const mp3 = randomBytes(5000);
|
||||
const skel = Buffer.from('skel'.repeat(400));
|
||||
// over the 8 MB compression cache limit: compressed as a stream
|
||||
const big = `// big\n${Array.from({ length: 360000 }, (_, i) => `export const v${i} = ${i % 97};`).join('\n')}\n`;
|
||||
|
||||
before(async () => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-static-'));
|
||||
@@ -127,6 +129,7 @@ describe('static http server', () => {
|
||||
w('public/assets/spine/x.skel', skel);
|
||||
w('public/assets/spine/x.atlas', 'x.png\nsize: 64,64\n'.repeat(40));
|
||||
w('public/assets/img/a.png', randomBytes(32));
|
||||
w('public/js/huge.js', big);
|
||||
w('public/assets/img/a.webp', randomBytes(32));
|
||||
w('public/fonts/f.woff2', randomBytes(32));
|
||||
w('public/fonts/f.ttf', randomBytes(32));
|
||||
@@ -164,17 +167,20 @@ describe('static http server', () => {
|
||||
assert.equal(redirect.headers.location, '/sub/?x=1');
|
||||
});
|
||||
|
||||
test('javascript: correct MIME, gzip when accepted, identity otherwise', async () => {
|
||||
const gz = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate, br' } });
|
||||
assert.equal(gz.status, 200);
|
||||
assert.equal(gz.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
assert.equal(gz.headers['content-encoding'], 'gzip');
|
||||
assert.equal(gz.headers.vary, 'Accept-Encoding');
|
||||
assert.equal(Number(gz.headers['content-length']), gz.body.length);
|
||||
test('javascript: correct MIME, brotli when named, gzip when accepted, identity otherwise', async () => {
|
||||
const br = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate, br' } });
|
||||
assert.equal(br.status, 200);
|
||||
assert.equal(br.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
assert.equal(br.headers['content-encoding'], 'br', 'a browser on an HTTPS page (or localhost) names br');
|
||||
assert.equal(br.headers.vary, 'Accept-Encoding');
|
||||
assert.equal(Number(br.headers['content-length']), br.body.length);
|
||||
assert.equal(zlib.brotliDecompressSync(br.body).toString(), js);
|
||||
const gz = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate' } });
|
||||
assert.equal(gz.headers['content-encoding'], 'gzip', 'a LAN http:// page: gzip');
|
||||
assert.equal(zlib.gunzipSync(gz.body).toString(), js);
|
||||
// cached second response is identical
|
||||
const gz2 = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } });
|
||||
assert.deepEqual(gz2.body, gz.body);
|
||||
// cached second responses are identical
|
||||
assert.deepEqual((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } })).body, gz.body);
|
||||
assert.deepEqual((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br' } })).body, br.body);
|
||||
|
||||
const plain = await httpReq(srv.port, '/js/app.js');
|
||||
assert.equal(plain.headers['content-encoding'], undefined);
|
||||
@@ -209,6 +215,27 @@ describe('static http server', () => {
|
||||
}
|
||||
const skelGz = await httpReq(srv.port, '/assets/spine/x.skel', { headers: { 'accept-encoding': 'gzip' } });
|
||||
assert.deepEqual(zlib.gunzipSync(skelGz.body), skel);
|
||||
const skelBr = await httpReq(srv.port, '/assets/spine/x.skel', { headers: { 'accept-encoding': 'gzip, br' } });
|
||||
assert.equal(skelBr.headers['content-encoding'], 'br');
|
||||
assert.deepEqual(zlib.brotliDecompressSync(skelBr.body), skel);
|
||||
// media is never compressed, whatever the client names
|
||||
assert.equal((await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { 'accept-encoding': 'br, gzip' } })).headers['content-encoding'], undefined);
|
||||
assert.equal((await httpReq(srv.port, '/assets/img/a.png', { headers: { 'accept-encoding': 'br' } })).headers['content-encoding'], undefined);
|
||||
});
|
||||
|
||||
test('a file over the compression cache limit is compressed as a stream (brotli and gzip); HEAD sends no body', async () => {
|
||||
const br = await httpReq(srv.port, '/js/huge.js', { headers: { 'accept-encoding': 'br' } });
|
||||
assert.equal(br.headers['content-encoding'], 'br');
|
||||
assert.equal(br.headers['content-length'], undefined, 'streamed: no length up front');
|
||||
assert.equal(zlib.brotliDecompressSync(br.body).toString(), big);
|
||||
assert.ok(br.body.length < big.length / 4);
|
||||
const gz = await httpReq(srv.port, '/js/huge.js', { headers: { 'accept-encoding': 'gzip' } });
|
||||
assert.equal(gz.headers['content-encoding'], 'gzip');
|
||||
assert.equal(zlib.gunzipSync(gz.body).toString(), big);
|
||||
const head = await httpReq(srv.port, '/js/huge.js', { method: 'HEAD', headers: { 'accept-encoding': 'br' } });
|
||||
assert.equal(head.status, 200);
|
||||
assert.equal(head.body.length, 0);
|
||||
assert.equal(head.headers['content-encoding'], 'br');
|
||||
});
|
||||
|
||||
test('ETag / Last-Modified revalidation and Cache-Control policy', async () => {
|
||||
@@ -228,6 +255,10 @@ describe('static http server', () => {
|
||||
assert.equal(stale.status, 200);
|
||||
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');
|
||||
const brEtag = (await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br' } })).headers.etag;
|
||||
assert.equal(brEtag, etag.replace(/"$/, '-br"'), 'so has the brotli variant');
|
||||
assert.equal((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br', 'if-none-match': brEtag } })).status, 304);
|
||||
assert.equal((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip', 'if-none-match': brEtag } })).status, 200, 'not the gzip variant');
|
||||
|
||||
// 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
|
||||
@@ -396,6 +427,17 @@ describe('static http server', () => {
|
||||
assert.equal(acceptsGzip('*;q=0'), false);
|
||||
assert.equal(acceptsGzip('br'), false);
|
||||
assert.equal(acceptsGzip(undefined), false);
|
||||
// brotli when named at least as high as gzip; `*` alone never picks it
|
||||
assert.equal(pickEncoding('gzip, deflate, br, zstd'), 'br');
|
||||
assert.equal(pickEncoding('gzip, deflate'), 'gzip');
|
||||
assert.equal(pickEncoding('br;q=0, gzip'), 'gzip');
|
||||
assert.equal(pickEncoding('br;q=0.5, gzip'), 'gzip');
|
||||
assert.equal(pickEncoding('gzip;q=0.5, br'), 'br');
|
||||
assert.equal(pickEncoding('br'), 'br');
|
||||
assert.equal(pickEncoding('*'), 'gzip');
|
||||
assert.equal(pickEncoding('*;q=0'), null);
|
||||
assert.equal(pickEncoding('identity'), null);
|
||||
assert.equal(pickEncoding(undefined), null);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user