0.2.6-dev: merge claude/bw-l01 — host bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket permessage-deflate, lossless WebP 玩法说明 pages, traffic counters, content-hashed asset URLs with immutable caching, brotli q5) on top of the 0.2.5 release; its note becomes DESIGN §31 in docs/history/0.2.6.md, CHANGELOG 0.2.6-dev, version 0.2.6-dev; golden: unchanged
This commit is contained in:
commit
3d5d6b380d
47 files changed
+2325
-136
No files matched your search
@@ -1,3 +1,14 @@
|
||||
## 0.2.6-dev — 未发布
|
||||
|
||||
### 开服流量
|
||||
- 静音或把背景音乐音量调到 0 时不再下载背景音乐,重新打开声音后才下载当前该放的曲子(关音乐的玩家每局少下约 7–14 MB)。
|
||||
- 1 KB 以上的 WebSocket 消息用 permessage-deflate 压缩,一局单人对局的服务器消息实测从 173 KB 降到 60 KB。
|
||||
- 素材地址带内容哈希:浏览器把图片、模型和音频缓存一年,服务器更新、重新解压或 Docker 重建后只重新下载内容变了的文件;哈希存在 `.cache/asset-hashes.json`,`SP_ASSET_INDEX=0` 可关闭。
|
||||
- 支持 brotli 压缩(浏览器只在 HTTPS 页面和 localhost 上使用),`.skel` 和数据文件比 gzip 再小约四分之一。
|
||||
- 本地提取的「玩法说明」教程图另存无损 WebP(25.6 MB → 17.4 MB),看说明时只预载下一页。
|
||||
- 流量统计:`/healthz?traffic=1` 显示服务器发出的各类流量;「问题反馈」的诊断信息增加本页下载量。
|
||||
- 部署指南:固定访问地址、反代开 HTTP/2、Cloudflare 缓存规则。
|
||||
|
||||
## 0.2.5 — 2026-10-12
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# 卫戍协议:盟约 · Stronghold Protocol: Alliance
|
||||
|
||||
> **开发版(dev 分支):不稳定,请勿用于公开服务器**。当前为 0.2.6-dev,尚未发布;稳定版请使用 Releases 中的 0.2.5。
|
||||
|
||||
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -37,7 +39,7 @@ English summary: [below](#english).
|
||||
|
||||
- **独立模拟**(单人)与**同盟模拟**(1–4 人**合作**,没有 PvP;空位可以加 AI 队友)。
|
||||
- 服务器是一个 Node.js 程序,**战斗在各玩家的浏览器里模拟**(和官方一样),服务器只管经济与回合,一台低功耗小主机就能开服。
|
||||
- 当前版本 0.2.5:3D 棋盘按战场显示官方场景,新增「官方伤害数字」选项,首领每局随机配对,固定赠品和已展示的干员牌不再被库存阻断,并修复了 0.2.4 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
|
||||
- 当前开发版 0.2.6-dev(未发布):减轻开服电脑的流量——静音时不下载背景音乐、WebSocket 消息压缩、素材地址带内容哈希长期缓存、brotli 压缩。最近发布版 0.2.5:3D 棋盘按战场显示官方场景,新增「官方伤害数字」选项,首领每局随机配对,固定赠品和已展示的干员牌不再被库存阻断,并修复了 0.2.4 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
|
||||
|
||||
## 功能一览
|
||||
|
||||
@@ -120,6 +122,7 @@ npm start # 启动服务器:http://localhost:3000
|
||||
| `DEBUG` | 空 | 设为任意值输出详细日志 |
|
||||
| `SP_NO_BROWSER` | 空 | 设为 `1` 时启动脚本不自动打开浏览器 |
|
||||
| `SP_BOT_EMOTES` | 空 | AI 队友的对局表情回应(战斗、整备、合成、联防、收礼时即刻的 `m.emote` 广播);设为 `0` 时整体关闭 |
|
||||
| `SP_ASSET_INDEX` | 空 | 素材地址带内容哈希、浏览器长期缓存(0.2.6 起默认开启,哈希存在 `.cache/asset-hashes.json`);设为 `0` 时关闭,素材清单原样下发 |
|
||||
|
||||
设置方式:macOS / Linux `PORT=8080 npm start`;PowerShell `$env:PORT=8080; npm start`;cmd `set "PORT=8080" && npm start`。健康检查:`GET /healthz`。
|
||||
|
||||
|
||||
@@ -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`), `static.js` (the mounts), `media.js`, `files.js` (MIME, gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `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) |
|
||||
@@ -131,7 +131,8 @@ data/*.json ──────────┬─▶ server/data.js ────
|
||||
├─▶ public/js/data.js ──────────▶ the UI
|
||||
└─▶ public/js/battle/runner.js ─▶ the browser's battles (setSimData)
|
||||
data/i18n/en.json ──────▶ public/js/data.js ──────────▶ the English game texts
|
||||
public/assets/ ─────────▶ public/js/render/, public/js/audio.js (URLs listed in data/assets.json)
|
||||
public/assets/ ─────────▶ public/js/render/, public/js/audio.js (URLs listed in data/assets.json,
|
||||
served with content-hash segments: server/http/assetViews.js)
|
||||
```
|
||||
|
||||
- **Game data.** `tools/build-data.mjs` downloads the official zh_CN tables into `.cache/gamedata/` (or reads them
|
||||
|
||||
+35
-2
@@ -211,11 +211,11 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
|
||||
- **Fonts:** OTF/TTF files are converted to WOFF2 by a built-in encoder (`tools/assets/woff2.mjs`: Brotli with null transforms).
|
||||
- Its output was verified lossless against Google's reference `woff2` decoder.
|
||||
- `fonts.css` lists WOFF2 first and falls back to the original file.
|
||||
- Images stay PNG. WebP conversion is not done: it would need a native dependency. The local-client board textures are the exception: `tools/local-extract/extract.py` (Python, where Pillow is already a dependency) writes WebP copies of the 12 textures the 3D board downloads (`WEBP`: colour maps lossy at quality 95 with the alpha and the RGB under transparent texels kept, normal and data maps lossless) and `data/local-assets.json` lists the copies; the PNGs stay beside them for `tools/crop-board-atlas.mjs`. `extract.py --webp` adds the copies to an existing extraction without the client.
|
||||
- Images stay PNG. WebP conversion is not done: it would need a native dependency. The local-client board textures are the exception: `tools/local-extract/extract.py` (Python, where Pillow is already a dependency) writes WebP copies of the 12 textures the 3D board downloads (`WEBP`: colour maps lossy at quality 95 with the alpha and the RGB under transparent texels kept, normal and data maps lossless) and `data/local-assets.json` lists the copies; the PNGs stay beside them for `tools/crop-board-atlas.mjs`. The 19 local 玩法说明 pages get a WebP copy too (`GUIDE_WEBP_MODE`, lossless: 25.6 MB → 17.4 MB on the CN client's pages; lossy quality 90 would be 4.2 MB and waits for the maintainer's side-by-side look), listed in place of the PNG, which stays beside it; the mirror copies under `ui/guide/` stay PNG. `extract.py --webp` adds the copies to an existing extraction without the client.
|
||||
|
||||
## Manifest schema (`data/assets.json`)
|
||||
|
||||
All paths are URL paths relative to the site root, for example `/assets/char/avatar/char_002_amiya.png`. **Only entries whose files exist on disk are emitted.** When a key is missing, the client should use its fallback (research 07 §5.6).
|
||||
All paths are URL paths relative to the site root, for example `/assets/char/avatar/char_002_amiya.png` — in the committed file; a server hands them out with a content-hash segment ("The committed manifest and the served manifests" below). **Only entries whose files exist on disk are emitted.** When a key is missing, the client should use its fallback (research 07 §5.6).
|
||||
|
||||
```js
|
||||
{
|
||||
@@ -470,6 +470,39 @@ Other renderer rules from research 07 §5.4–5.5:
|
||||
- **Missing unit SFX:** use `audio.sfx.battle.enemyHit` or a WebAudio blip (research 07 §6.4).
|
||||
- **Bond icons:** if the real glyph ever fails, the stored file is the nation camp logo.
|
||||
|
||||
## The committed manifest and the served manifests
|
||||
|
||||
`data/assets.json` in git (and as `tools/fetch-assets.mjs` writes it) and `data/local-assets.json` (as
|
||||
`tools/local-extract` writes it) hold plain URLs. Since 0.2.6 (host bandwidth, layer 1) the server hands out a view of
|
||||
each (`server/http/assetIndex.js`, `server/http/assetViews.js`):
|
||||
|
||||
- Shortly after the start (1 s) the server hashes every file under `public/assets/` and `public/fonts/` in the
|
||||
background: SHA-256, the first 16 hex digits. The hashes are kept in `.cache/asset-hashes.json` by path + size +
|
||||
mtime, so a restart only stats the files and reads none of the unchanged ones (12,691 files / 724 MB on the
|
||||
maintainer's machine: 1.3 s for the first build, 0.25 s after that). A file whose size or mtime changed is hashed
|
||||
again; a re-extraction, `setup --force` or a Docker rebuild then costs the players only the files whose bytes changed.
|
||||
- `GET /data/assets.json` and `GET /data/local-assets.json` answer with every art URL carrying its file's hash as the
|
||||
first segment: `/assets/char/avatar/char_003_kalts.png` → `/assets/~398f32e2ff78d9e6/char/avatar/char_003_kalts.png`,
|
||||
`/fonts/~<hash>/…`. Audio keeps its `/assets/~<hash>/audio/….mp3` form; `public/js/media.js` asks for it as
|
||||
`/media/~<hash>/…` (the download-manager route, `shared/media.js`).
|
||||
- A Spine model's skeleton, atlas and pages carry one hash, the model's (SHA-256 of the files' hashes): pixi-spine finds
|
||||
the atlas by the skeleton's name and the pages next to the atlas, so the URLs share a folder. In `data/assets.json`
|
||||
every `{ skel, atlas, textures }` object is a model; in `data/local-assets.json` every `spine/…` group (the files a
|
||||
`spineLocal` record names).
|
||||
- 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 `-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
|
||||
query string survives none of that.
|
||||
- A URL the index has no hash for (the file is missing, or not hashed yet) stays plain; plain URLs work as before
|
||||
(`public, max-age=86400`, revalidated after a day).
|
||||
- Until the index is ready, with `SP_ASSET_INDEX=0`, or on a static host without this server, the manifests go out as
|
||||
committed. The views are rebuilt in memory when a manifest or the hashes change; neither file is ever written by the
|
||||
server, and the `hash` field is the committed one. The index lives in `.cache/` only and is never packaged.
|
||||
|
||||
## Verification
|
||||
|
||||
`node --test test/assets.test.js` covers the pure helpers: the resolver, the atlas normalizer, the format sniffers, WOFF2, audio banks, the plan id sets, the downloader against a fake network, and the self-heal of corrupt skeletons.
|
||||
|
||||
+12
-2
@@ -9,7 +9,7 @@
|
||||
|---|---|
|
||||
| 服务器 CPU | 战斗在各玩家浏览器里模拟(DESIGN §14),服务器只负责回合、经济和校验:**每个房间每个作战回合约 1 ms CPU**。AI 队友 / 掉线玩家的战场由服务器模拟:作战开始时 3 个 AI 战场在开发机上约 0.2–0.5 s CPU,小主机上可能要几秒(分成 8 ms 小片执行,不会卡住其他房间)。`SP_VERIFY=all` 会复算每个真人战场,CPU 明显增加,小主机建议保持 `off` 或 `sample`。 |
|
||||
| 服务器内存 | 空闲约 100 MB,每个进行中的对局再增加几 MB。 |
|
||||
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
|
||||
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测;0.2.6 起 1 KB 以上的 WebSocket 消息用 permessage-deflate 压缩,实测一局单人对局的服务器消息从 173 KB 降到 60 KB,约为原来的三分之一)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。0.2.6 起服务器启动后在后台给素材算内容哈希(第一次约 1–20 秒,看机器和硬盘;之后只检查大小和修改时间),下发的素材清单里每个地址带上哈希:浏览器把图片、模型和音频缓存一年,服务器更新、重新解压或 Docker 重建之后,玩家只重新下载内容真的变了的文件。哈希存在 `.cache/asset-hashes.json`,删掉会重新计算;目录不可写(例如只读的容器)时只在内存里算。 |
|
||||
| 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 710 MB。 |
|
||||
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
|
||||
|
||||
@@ -170,6 +170,8 @@ cloudflared tunnel --url http://localhost:3000
|
||||
|
||||
把输出的 `https://xxxx.trycloudflare.com` 发给朋友。页面是 https 时客户端自动改用 `wss://`,不需要任何配置;服务器会通过隧道转发的 `CF-Connecting-IP` 识别真实来源(`TRUST_PROXY=auto`)。临时隧道每次启动地址都不同,且没有可用性保证;需要固定地址请使用 Cloudflare 账号 + 自己域名的「命名隧道」。
|
||||
|
||||
**固定访问地址能省很多流量**:浏览器的缓存、设置、统计和断线令牌都按地址(域名或 IP + 端口)分开保存。地址一换,对玩家来说就是一个新网站:每个人进游戏都要把图片、模型和音频重新下载一遍(第一局约 20–50 MB),设置和本机统计也看不到了。临时隧道每次重启都换域名,所以固定朋友圈子长期开服时,建议用命名隧道、Tailscale(2.1)或固定的公网 IP / 局域网 IP(1.3),让大家一直用同一个地址。
|
||||
|
||||
### 2.3 路由器端口转发
|
||||
|
||||
仅当你有**公网 IPv4**(很多宽带是运营商级 NAT,没有公网 IP,此时请用 2.1 / 2.2):
|
||||
@@ -219,7 +221,11 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP/2**:客户端由数百个小脚本模块组成(0.2.0 起进入对局时约 340 个),隔着公网时建议让代理以 HTTP/2 提供页面:所有模块走同一条连接,远距离玩家首次进入对局明显更快。Caddy 默认就是 HTTP/2;Nginx 见上面的 `http2`。服务器本身只说 HTTP/1.1,局域网或本机游玩不受影响。
|
||||
**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 起,质量 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 的服务条款;只给自己人玩时不加这条规则也可以。
|
||||
|
||||
https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://同一域名/ws`;http 时用 `ws://`。服务器本身只提供 http,证书由代理 / 隧道负责。代理与服务器在同一台机器或内网时,`TRUST_PROXY=auto` 会信任它的 `X-Forwarded-For` / `X-Real-IP`;代理在公网另一台机器上时设 `TRUST_PROXY=1`(同时确保游戏端口只对代理开放)。
|
||||
|
||||
@@ -289,6 +295,8 @@ services:
|
||||
| 表情显示成默认图标、「玩法说明」只有文字要点 | 素材没下载完整:重新运行 `node tools/setup.mjs`(表情和教程图随其他素材一起从公开镜像下载,不需要客户端);缺失明细在 `.cache/assets-report.json` |
|
||||
| 本地提取失败 | 游戏照常运行,只是第 6 节表格里的几样换成替代样式。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` |
|
||||
| 3D 棋盘没出现 | 需要本地提取的棋盘贴图(`node tools/doctor.mjs` 会显示「3D 棋盘可用」),以及支持 WebGL2 的浏览器。没有客户端的服务器可以从同一版本的整合包复制本地素材(第 6 节) |
|
||||
| 素材地址里的 `~` 加一串字母数字 | `/assets/~<哈希>/…` 是 0.2.6 起的带版本地址(见第 0 节「网络」),浏览器可以长期缓存,属于正常现象。怀疑它导致问题时,可以用 `SP_ASSET_INDEX=0` 启动服务器临时关闭(清单改回普通地址),并请在 issue 里说明 |
|
||||
| 想知道开服电脑上传了多少流量 | 浏览器打开 `http://localhost:3000/healthz?traffic=1`(换成你的端口):`traffic.http` 是服务器启动以来各类内容(`assets` 图片和模型、`audio` 音频、`code` 页面和脚本、`data` 游戏数据和素材清单)发出的字节数和请求数(压缩后的实际字节,`notModified` 是只回了「没变」的 304),`traffic.ws` 是 WebSocket 的线上字节(`wireOut` / `wireIn`,压缩后)和压缩前的消息字节(`payloadOut` / `payloadIn`)。数字只在内存里,重启后从 0 开始。玩家那边:设置 →「问题反馈」复制的诊断里有一行 `traffic:`,是这个页面从打开起下载的字节数(`cached` 是浏览器缓存直接给的,不占流量;`external` 是其他网站,例如 Google 字体) |
|
||||
| 断线 | 同盟模拟 10 分钟内、独立模拟 24 小时内(`config.constants.singleReconnectTime`)用同一浏览器重新打开页面,自动回到原座位。同盟掉线期间按原阵容自动作战、到时自动准备(不会代为购买;想让 AI 代打请用「离开模拟 → 暂离(AI 托管)」);独立模拟不计时,等你回来 |
|
||||
|
||||
## 6. 本地客户端素材(可选)
|
||||
@@ -315,6 +323,8 @@ services:
|
||||
|
||||
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
|
||||
|
||||
**「玩法说明」教程图的下载量**(0.2.6 起):本地提取的 19 页教程图每页 1.2–1.5 MB,玩家每翻一页都要从开服的电脑下载。提取时同样给每页写一份无损 WebP,清单列 WebP,PNG 留在旁边;19 页合计从约 25.6 MB 降到约 17.4 MB,画面完全不变。看说明时只预载下一页(以前前后两页都预载)。已经提取过的同样用 `tools/local-extract/extract.py --webp` 补上。
|
||||
|
||||
## 7. 打包发布(维护者)
|
||||
|
||||
Releases 的 zip(完整包、精简包,0.2.1 起还有更新包)由 `tools/package.mjs` 生成,在**源码仓库**里运行(整合包里没有这个工具):
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ This is the **single source of truth** for every implementer. Research lives in
|
||||
|
||||
Language: player-facing text is **Simplified Chinese** by default, with an English switch since 0.2.0 (§25.2, docs/I18N.md: UI strings through `t('中文')`, game texts from the official Chinese data or the official EN client's). Code, comments and identifiers are English.
|
||||
|
||||
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. **0.2.5** (2026-10-12) fixed gifts and already displayed operator offers under the shared stock, random boss pairing, the official battlefield scenes on the 3D board (optional local art: the full bundle includes them when extracted, a missing scene keeps the metal board; §15, §30.21), the official damage-number option, the 驭械术师 / hit-tile / bot ports and the community fixes after 0.2.4 — §30, the state described by this document; its rules supersede the corresponding 0.2.4 ones. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
|
||||
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. **0.2.5** (2026-10-12) fixed gifts and already displayed operator offers under the shared stock, random boss pairing, the official battlefield scenes on the 3D board (optional local art: the full bundle includes them when extracted, a missing scene keeps the metal board; §15, §30.21), the official damage-number option, the 驭械术师 / hit-tile / bot ports and the community fixes after 0.2.4 — §30; its rules supersede the corresponding 0.2.4 ones. **0.2.6-dev** adds the host-bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket compression, content-hashed asset URLs with long caching, brotli) — §31, the current development state described by this document. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,3 +37,4 @@ normative lines it rewrote).
|
||||
| §28 | [history/0.2.3.md](history/0.2.3.md) | 0.2.3 — after the 0.2.2 release (2026-10-10) |
|
||||
| §29 | [history/0.2.4.md](history/0.2.4.md) | 0.2.4 — after the 0.2.3 release (2026-10-11) |
|
||||
| §30 | [history/0.2.5.md](history/0.2.5.md) | 0.2.5 — after the 0.2.4 release (2026-10-12) |
|
||||
| §31 | [history/0.2.6.md](history/0.2.6.md) | 0.2.6-dev — after the 0.2.5 release |
|
||||
+1
-1
@@ -184,7 +184,7 @@
|
||||
|
||||
标题画面页脚的「项目仓库」在新标签页打开本项目的 [GitHub 仓库](https://github.com/sganggs/Stronghold-Protocol)。
|
||||
|
||||
遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合,以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
|
||||
遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合、打开页面以来按类别统计的下载量(图片和模型、音频、代码、数据),以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
|
||||
|
||||
## 13. 统计(本机)
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ view.resize() ; view.destroy()
|
||||
|
||||
`createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it.
|
||||
|
||||
Asset URLs (0.2.6, host bandwidth layer 1): the client takes every art, model and audio URL from `data/assets.json` and `data/local-assets.json` and builds none itself, so the content-hash segment the server puts in front (`/assets/~<hash>/…`, docs/ASSETS.md "The committed manifest and the served manifests") needs nothing on this side: `validSpine` still sees `.skel`, pixi-spine finds the atlas and its pages under the same segment (a model shares one hash), audio is fetched as `/media/~<hash>/…`. The two URLs the client derives from another file's — the 3D board's `tiles.json` beside the atlas and the token death sound beside `charDie` — are served `no-cache` (the board) or drop the segment (`shared/assetPaths.js unhashedUrl`, the sound).
|
||||
|
||||
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done; a model whose pose cannot change — `SpineActor.poseHeld()`: a knocked-out operator in the held end of its Die clip, a frozen model — is neither re-posed nor redrawn, only its clock runs, until a new clip, a revive, a wind-up, a form's closing clip, a clipping switch or a zoom past the impostor's rescale threshold; the redeploy rings draw one shared rasterised label per number, `render/textures.js downLabel`, the same pixels as a Text of their own). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
|
||||
|
||||
---
|
||||
@@ -62,7 +64,7 @@ its board-space position and facing, including on a mirrored boss half. They can
|
||||
from this preview. The inspection affordance does not make the preview a combat unit; the battle sim still creates
|
||||
the selected variant at battle start (§30.22).
|
||||
|
||||
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15.
|
||||
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. While the music is silent — muted, or the BGM volume at 0 — no BGM is downloaded: the track the screen wants is remembered, a different one still playing stops, and the wanted track is fetched once the music is audible again (`AudioManager.bgmSilent`; about 14 MB a match for a player who turned the music off).
|
||||
|
||||
---
|
||||
|
||||
@@ -73,7 +75,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
|
||||
- `scene/<stageId>/` and `scene/tex/` — the official scene of a battlefield (§15 "Official battlefield scenes", DESIGN §30.21): `scene.json` (materials, key light, chunk records), `scene.bin` (the meshes in board space), the lightmap pages `lm<i>`, and the materials' albedo textures shared by the scenes. Read from the scene bundles `scenes/activities/<season>/level_<stage>/…`, which the client carries only while the season runs: `extract.py --only scene` takes them from the client or from `--scenes <dir>` / `.cache/official-scenes/*` (`tools/local-extract/fetch_scenes.py` downloads them from the official CDN's in-season version). The 0.2.5 full release zip includes the extracted files when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files.
|
||||
- `ui/{common,battle,outer}/*` — every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art).
|
||||
- `emoticon/<dir>/<picId>.png` — the 36 official battle emotes, 6 themes (`shared/constants.js → EMOTE_THEMES / EMOTES`, `emoteArtPath`; data/emotes.json); also downloaded from the mirror (§22.5).
|
||||
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5).
|
||||
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5). `extract.py` writes a lossless WebP copy beside each page and the manifest lists the copy (`GUIDE_WEBP_MODE`; 25.6 MB → 17.4 MB); the viewer preloads only the next page (`ui/guide.js guidePreloadUrl`), not the previous one too.
|
||||
- `projectiles/*` — battle projectile sprites.
|
||||
- `spine/enemy/<id>/` — enemy models no web dump carries (灼热 / 炽焰源石虫, `extract.py ENEMY_SPINES`): manifest `enemies[id].spineLocal` (metadata from the committed `tools/assets/local-enemy-spines.json`); drawn instead of the tinted web alias when every file is listed (§21.14).
|
||||
- `spine/token/<id>/` — token (summon) models no web dump carries (40: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼; `extract.py TOKEN_SPINES`, the Front renderer's skeleton of each battle token prefab): manifest `tokens[id].spineLocal` (metadata from the committed `tools/assets/local-token-spines.json`); drawn instead of the avatar diamond when every file is listed (docs/ASSETS.md "Token models from the local client").
|
||||
|
||||
@@ -4,7 +4,7 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
|
||||
## 8. Network protocol (shared/protocol.js is normative)
|
||||
|
||||
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
|
||||
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound (a compressed frame counts its inflated size); rate limit 40 msgs/s per connection (excess dropped with `error RATE`). Frames from 1 KB up are compressed with permessage-deflate (RFC 7692; `server/http/websocket.js WS_DEFLATE`: no server context takeover, a 2 KB server window, zlib memLevel 7 — about 72 KB of zlib state per connection); smaller frames go out plain, and a client that offers no compression, or caps the server window below 2 KB, gets plain frames. A measured solo session (rounds 3–5, `/healthz?traffic=1`): 173 KB of server frames → 60 KB on the wire.
|
||||
|
||||
### 8.1 Session & lobby
|
||||
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.setAiPicksLast {on}` (host, co-op, before the match — `ROOM_STARTED` during it, `BAD_TARGET` in a solo room; un-readies the other humans like a difficulty change; the match takes it as `opts.aiPicksLast`: humans draft before AI seats, off by default, no extra random draw) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries, ops?}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`; `ops` — 0.2.2 — the per-operator 潜能 / 练度 `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` of the 干员调配 roster and the 自选 owned pool, ≤ 256, checked strictly with the entries, `m.private.ops`; absent = none set: every operator 潜能 6, 精英2 Lv.60) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
|
||||
|
||||
+10
-4
@@ -42,12 +42,18 @@ server/
|
||||
index.js process entry: startServer() wires server/http/ (plain node:http + ws); boots when run directly
|
||||
http/
|
||||
config.js ROOT, served dirs, env (PORT, HOST, TRUST_PROXY, DEBUG), options handed to net.js / lobby.js
|
||||
websocket.js session wiring (SessionRegistry → Lobby → Network), WebSocket upgrade at /ws (64 KB frames)
|
||||
static.js mounts / → public, /data/, /shared/, /sim/ (.js only), the /data.js browser stand-in, path guards
|
||||
websocket.js session wiring (SessionRegistry → Lobby → Network), WebSocket upgrade at /ws (64 KB frames,
|
||||
permessage-deflate from 1 KB)
|
||||
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 / 304, cache policy, ranges
|
||||
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, then static files
|
||||
routes.js request listener: 414 / 400 / 405 guards, GET /healthz (?traffic=1: the counters), then static files
|
||||
traffic.js traffic counters: HTTP body bytes by category (assets / audio / code / data), WebSocket wire / payload
|
||||
assetIndex.js content hashes (SHA-256, 16 hex) of public/assets + public/fonts, background build, .cache/asset-hashes.json
|
||||
assetViews.js the served /data/assets.json + /data/local-assets.json: art URLs as /assets/~<hash>/… (immutable when it matches)
|
||||
common.js security headers, URL split, error page, JSON replies, bare 400 for unparseable requests
|
||||
boot.js boot banner (Local / LAN / tunnel URLs), port-in-use hint, graceful SIGINT / SIGTERM
|
||||
net.js session registry, send helpers, per-connection rate limit, message validation (uses shared/protocol.js)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# DESIGN §31 — 0.2.6-dev
|
||||
|
||||
Part of [DESIGN.md](../DESIGN.md). Development after the 0.2.5 release.
|
||||
|
||||
## 31. Host bandwidth, layers 0 and 1
|
||||
|
||||
Unreleased. The maintainer's decision of 2026-10-12 on the bandwidth study (its measurements: a new player's first match costs the
|
||||
host about 52 MB, audio ≈ 40 % of it; returning players re-download after a re-extraction, a `setup --force`, a Docker
|
||||
rebuild or a changed tunnel address): do layer 0 (small fixes) and layer 1 (content-hashed URLs, long caching, brotli)
|
||||
first. Later layers (WebP / Opus variants, an asset-only service worker, a desktop client mode, importing an asset pack in
|
||||
the browser) are agreed and not part of this note. Nothing here touches the battle sim, the protocol messages or the
|
||||
committed data.
|
||||
|
||||
### 31.1 Layer 0 — small fixes
|
||||
|
||||
- **No BGM while the music is silent.** Muted, or the BGM volume at 0, `AudioManager.playBgm` only remembers the wanted
|
||||
track (a different one still playing stops); `setVolumes` fetches and starts it once the music is audible again. SFX
|
||||
and voices were already gated. Normative line: design/client.md Audio.
|
||||
- **WebSocket permessage-deflate** (`server/http/websocket.js WS_DEFLATE`): frames from 1 KB, no server context
|
||||
takeover, a 2 KB server window, zlib memLevel 7 (about 72 KB of zlib state per connection). [ASSUMED] the parameters
|
||||
come from measured solo sessions (the server counters: 173 KB of frames → 60 KB on the wire; replaying 216 captured
|
||||
frames, windows above 2 KB gained nothing); co-op messages are the same kinds. A client that offers a narrower server
|
||||
window than 2 KB, which ws would refuse with a 400, connects uncompressed instead (`narrowDeflateOffer`). Normative
|
||||
line: design/network.md §8 Transport.
|
||||
- **玩法说明 pages as WebP.** `tools/local-extract/extract.py` writes a WebP copy beside each of the 19 local pages and
|
||||
the manifest lists it (`GUIDE_WEBP_MODE`); the guide preloads only the next page instead of both neighbours. The
|
||||
copies are lossless (25.6 MB → 17.4 MB on the CN client's pages). Lossy quality 90 would make them 4.2 MB; per the
|
||||
decision that tutorial pages may go lossy only after a side-by-side comparison, that switch (one constant, then
|
||||
`extract.py --webp`) is left to the maintainer. Normative lines: design/client.md §13, ASSETS.md Post-processing.
|
||||
- **Traffic counters.** `GET /healthz?traffic=1` adds `traffic` (HTTP body bytes and requests by category — assets,
|
||||
audio, code, data, other — and 304s; WebSocket wire bytes after compression and payload bytes before it) and
|
||||
`assetHashes` (the index state). The 问题反馈 diagnostics gain a `traffic:` line: the page's Resource Timing
|
||||
`transferSize` by the same categories (`shared/assetPaths.js trafficCategory`), other sites apart. Nothing is stored
|
||||
or uploaded.
|
||||
- **DEPLOY.md**: a stable address keeps every player's cache, settings and reconnect token (a quick tunnel's new domain
|
||||
is a new site to the browser); HTTP/2 through the proxy saves round trips, not bytes; browsers offer brotli on HTTPS
|
||||
pages and localhost only; a Cloudflare cache rule for `/assets/`, `/fonts/`, `/media/` (with the risk note).
|
||||
|
||||
### 31.2 Layer 1 — content-hashed URLs, long caching, brotli
|
||||
|
||||
- **Content-hash index** (`server/http/assetIndex.js`): 1 s after the start, SHA-256 (16 hex) of every file under
|
||||
`public/assets/` and `public/fonts/` (dot names skipped, linked folders followed once), computed off the main thread,
|
||||
cached in `.cache/asset-hashes.json` by path + size + mtime (12,691 files / 724 MB here: 1.3 s the first time, 0.25 s
|
||||
after). A file whose size or mtime differs from its record is never vouched for; serving it asks for a rescan (at most
|
||||
one per 10 s). [ASSUMED] the 1 s delay and the 10 s rescan gap. A name that differs from a file's only in case finds
|
||||
that file (the manifest's `/assets/module/wah-y.png` is `WAH-Y.png` on this machine). `SP_ASSET_INDEX=0` turns it off.
|
||||
- **Served manifests** (`server/http/assetViews.js`): `/data/assets.json` and `/data/local-assets.json` go out with every
|
||||
art URL carrying its file's hash as the first segment (`/assets/~<hash>/…`, `/fonts/~<hash>/…`; audio stays
|
||||
`/assets/~<hash>/audio/….mp3`). A Spine model — a `{ skel, atlas, textures }` object of data/assets.json, a `spine/…`
|
||||
group of data/local-assets.json — carries one hash for all its files, since pixi-spine finds the atlas and the pages
|
||||
next to the skeleton. `no-cache` with the view's own ETag; rebuilt in memory when a manifest or the hashes change; the
|
||||
committed files are never written. Until the index is ready, without it, or on a static host: the files as committed.
|
||||
- **The `~hash` routes**: `static.js` strips the segment for `/assets/`, `/fonts/` and `/media/`; a hash that names the
|
||||
file's current bytes (its own or its model's) answers `public, max-age=31536000, immutable`, any other the current
|
||||
file with `no-cache` (an old page keeps working). Every indexed file's ETag is its content hash, on plain URLs too.
|
||||
Path segments, never `?v=`: `validSpine`'s `/\.skel$/`, `SKILL_MODE_FILE`'s `.mp3`, pixi-spine's basename and folder
|
||||
resolution all need the URL to end in the file name.
|
||||
- **Media**: `public/js/media.js` turns `/assets/~<hash>/audio/x.mp3` into `/media/~<hash>/x`; `server/http/media.js`
|
||||
resolves it like `/media/x`. The token death sound derived next to `charDie` drops `charDie`'s hash (`unhashedUrl`).
|
||||
- **Brotli** (`server/http/files.js`): quality 5 on the fly with the same memory cache (one entry per file and
|
||||
encoding), for clients that name `br` at least as high as gzip; gzip for the rest (`*` alone never picks brotli
|
||||
[ASSUMED]); `-br` / `-gz` ETag suffixes. Measured against gzip-6 on this repository: `.skel` −27 %, `data/*.json`
|
||||
−26 %, vendor −14 %, `public/js` and `server/sim` −5 %, `.atlas` −7 %.
|
||||
|
||||
### 31.3 Measured sample session
|
||||
|
||||
Measured sample session (headless Chrome, a fresh profile, `test/e2e/fastServer.mjs` solo match from round 3 to the
|
||||
result after round 5, host bytes only, response headers included; the shop and the enemies vary between runs):
|
||||
|
||||
| session | code | data | assets | audio | WebSocket (server → client) | host total |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| before, music on | 3.16 MB | 0.67 MB | 9.0–9.7 MB | 7.9 MB | 163–179 KB (no compression) | 20.8–21.4 MB |
|
||||
| after, music on | 2.95 MB | 0.62 MB | 9.5 MB | 8.0 MB | 60 KB on the wire (173 KB payload) | 21.1 MB |
|
||||
| before, muted | 3.16 MB | 0.67 MB | 9.6 MB | 7.3 MB (BGM) | 165 KB | 20.7 MB |
|
||||
| after, muted | 2.95 MB | 0.62 MB | 9.5 MB | 0 | 57 KB on the wire | 13.1 MB |
|
||||
|
||||
A first visit with music on saves little (brotli on code and data, the WebSocket); a muted player saves the BGM. The
|
||||
caching gains are for returning players: within a day both versions answer from the browser cache, but after that the
|
||||
old version revalidated every asset of the session (≈ 340 requests here) and re-downloaded everything whose mtime had
|
||||
changed, while a hashed URL is never asked for again until its bytes change.
|
||||
|
||||
### 31.4 Open items and tests
|
||||
|
||||
Open: `/fonts/fonts.css` (linked by index.html) and the woff2 files it names keep plain URLs (content ETags only); the
|
||||
optional versioned vendor URLs (layer 1.5) are not done; lossy tutorial pages wait for the comparison.
|
||||
|
||||
Tests: `test/ui/audio.test.js` (BGM gating; hashed audio through `/media/~hash/`), `test/ws-compression.test.js`,
|
||||
`test/local-extract.test.js` (guide WebP), `test/ui/localArt.test.js` (next-page preload), `test/traffic.test.js`,
|
||||
`test/asset-paths.test.js`, `test/diag.test.js` (traffic line), `test/asset-index.test.js` (index, views, `~hash`
|
||||
routes), `test/lobby.test.js` (brotli negotiation, streamed brotli, content ETags), `test/media-url.test.js`,
|
||||
`test/content/tokens_summons.test.js`; the browser suites `test/render/browser.test.js` (Spine through hashed URLs,
|
||||
`RENDER_E2E=1`), `test/ui/bond-audio.e2e.test.js` (`SP_E2E=1`, the cue through `/media/~hash/`) and
|
||||
`test/ui/real.e2e.test.js` (`SP_REAL_E2E=1`: the co-op run loads Spine, audio and art through hashed URLs). The e2e
|
||||
servers wait for the asset index before the first page (`test/e2e/client.mjs startRealServer`, the in-process ones in
|
||||
their `before`), so the browser suites see the URLs a player sees. Golden: unchanged.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6-dev",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6-dev",
|
||||
"private": true,
|
||||
"description": "明日方舟「卫戍协议:盟约」非官方同人联机网页复刻(1–4 人合作,Node.js 服务器 + 浏览器客户端)",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
+27
-4
@@ -13,7 +13,9 @@
|
||||
// never block or warn; everything requested before that is remembered (BGM) or dropped (SFX).
|
||||
// - Channels: master → { bgm, sfx, voice } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
|
||||
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
|
||||
// keeps playing across phases (prep and combat share a track).
|
||||
// keeps playing across phases (prep and combat share a track). While the music is silent — muted, or the BGM volume
|
||||
// at 0 — no track is downloaded (≈ 14 MB a match): the wanted one is remembered, another one playing stops, and the
|
||||
// track the screen wants then starts once the music is audible again (`setVolumes`; host bandwidth, layer 0).
|
||||
// - 开战 BGM: `bgm.combatAlts` are the mode's own battle tracks (塞壬唱片 骑士之日 / 无畏者). The track is fixed per
|
||||
// round, not drawn: 无畏者 through rounds 1–7 and 骑士之日 from round 8 on (`combatTrackFor`, the official
|
||||
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
|
||||
@@ -68,6 +70,7 @@
|
||||
import { sanitizeVoiceOverrides, voiceLangFor } from './voicePrefs.js';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { mediaUrl } from './media.js';
|
||||
import { unhashedUrl } from '../../shared/assetPaths.js';
|
||||
|
||||
const MAX_VOICES = 8;
|
||||
const UNIT_COOLDOWN_MS = 160;
|
||||
@@ -191,11 +194,14 @@ export function unitSoundClass(info) {
|
||||
return 'char';
|
||||
}
|
||||
|
||||
/** URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie. */
|
||||
/**
|
||||
* URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie — without charDie's
|
||||
* content-hash segment, which names charDie's bytes, not this file's (the plain URL is cached for a day).
|
||||
*/
|
||||
function tokenDieUrl(manifest) {
|
||||
const b = manifest?.audio?.sfx?.battle;
|
||||
if (typeof b?.tokenDie === 'string') return b.tokenDie;
|
||||
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? b.charDie.replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
|
||||
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? unhashedUrl(b.charDie).replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -662,6 +668,7 @@ export class AudioManager {
|
||||
*/
|
||||
setVolumes(v) {
|
||||
const n = (x, d) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : d);
|
||||
const wasSilent = this.bgmSilent;
|
||||
this.volumes = {
|
||||
bgm: n(v?.bgm, this.volumes.bgm),
|
||||
sfx: n(v?.sfx, this.volumes.sfx),
|
||||
@@ -670,8 +677,13 @@ export class AudioManager {
|
||||
};
|
||||
this._applyVolumes();
|
||||
if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue();
|
||||
// the music became audible again: download and start the track the screen wants now (none was fetched meanwhile)
|
||||
if (wasSilent && !this.bgmSilent && this.ctx && this.wantBgm) { const k = this.wantBgm; this.wantBgm = null; this.playBgm(k); }
|
||||
}
|
||||
|
||||
/** Is the music silent — muted, or the BGM volume at 0? Then no BGM is downloaded (playBgm). */
|
||||
get bgmSilent() { return this.volumes.muted || this.volumes.bgm <= 0; }
|
||||
|
||||
/**
|
||||
* The voice dub (settings 语音语言): 'jp' plays `audio.voiceJp`, anything else `audio.voice` (中文, the default). The
|
||||
* line on air finishes in its own dub; the next one follows the setting.
|
||||
@@ -766,12 +778,23 @@ export class AudioManager {
|
||||
// ---- BGM ------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Switch BGM (null stops). Same loop URL ⇒ no restart.
|
||||
* Switch BGM (null stops). Same loop URL ⇒ no restart. Before the first gesture, or while the music is silent
|
||||
* (`bgmSilent`), the key is only remembered (`wantBgm`) and nothing is downloaded; a different track that is still
|
||||
* playing stops. `_unlock` / `setVolumes` start the remembered track once it can be heard.
|
||||
* @param {string|null} key see bgmKeyFor
|
||||
*/
|
||||
playBgm(key) {
|
||||
try {
|
||||
if (!this.ctx) { this.wantBgm = key; return; }
|
||||
if (this.bgmSilent) {
|
||||
this.wantBgm = key;
|
||||
const silentTrack = resolveBgm(this.getManifest(), key);
|
||||
if (this.bgm && silentTrack && this.bgm.loopUrl === silentTrack.loop) { this.bgm.key = key; return; }
|
||||
++this.bgmToken; // a start still waiting for its download stays silent too
|
||||
this._fadeOutBgm();
|
||||
return;
|
||||
}
|
||||
this.wantBgm = null;
|
||||
const track = resolveBgm(this.getManifest(), key);
|
||||
if (this.bgm && track && this.bgm.loopUrl === track.loop) { this.bgm.key = key; return; }
|
||||
if (!track && !this.bgm) return;
|
||||
|
||||
+82
-4
@@ -5,19 +5,23 @@
|
||||
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
|
||||
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
|
||||
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
|
||||
// tools replay.
|
||||
// tools replay. The report's `traffic:` line sums what this page downloaded (Resource Timing `transferSize`: bytes
|
||||
// on the wire, 0 for a response from the browser cache) by the categories of shared/assetPaths.js trafficCategory,
|
||||
// other sites apart as `external` (host bandwidth, layer 0; the server's own counters: /healthz?traffic=1).
|
||||
//
|
||||
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
|
||||
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
|
||||
// (a failed request or error push the player was shown)
|
||||
// errorLog() / errorCount() / clearErrorLog()
|
||||
// setBattleSource(fn) the battle runner registers how to read the battle on screen
|
||||
// installTrafficMeter() / recordResourceEntry(entry) / trafficSummary() / clearTraffic() the page's download totals
|
||||
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
|
||||
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
|
||||
//
|
||||
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
|
||||
|
||||
import { APP_VERSION } from '../../shared/constants.js';
|
||||
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
|
||||
|
||||
export const LOG_MAX = 40;
|
||||
export const STACK_LINES = 6;
|
||||
@@ -87,6 +91,78 @@ export const errorLog = () => log.map((e) => ({ ...e }));
|
||||
export const errorCount = () => total;
|
||||
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
|
||||
|
||||
// ---- traffic: what this page downloaded ---------------------------------------------------------------------
|
||||
|
||||
/** @type {Map<string, { requests: number, bytes: number, cached: number }>} category → totals */
|
||||
const traffic = new Map();
|
||||
let trafficMeter = null;
|
||||
|
||||
/**
|
||||
* Add one Resource Timing entry (a PerformanceResourceTiming, or the page's own navigation entry) to the totals: bytes
|
||||
* on the wire (`transferSize`; headers included, 0 when the browser cache answered — counted as `cached`). A same-origin
|
||||
* URL counts under trafficCategory(path), another site's under `external`; data: / blob: URLs are not downloads.
|
||||
* @param {{ name?: string, transferSize?: number, decodedBodySize?: number }} entry
|
||||
* @param {string} [origin] the page's origin
|
||||
*/
|
||||
export function recordResourceEntry(entry, origin = globalThis.location?.origin) {
|
||||
if (!entry || typeof entry.name !== 'string') return;
|
||||
let u;
|
||||
try { u = new URL(entry.name, origin || 'http://localhost'); } catch { return; }
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
|
||||
const cat = origin && u.origin !== origin ? 'external' : trafficCategory(u.pathname);
|
||||
let t = traffic.get(cat);
|
||||
if (!t) traffic.set(cat, (t = { requests: 0, bytes: 0, cached: 0 }));
|
||||
const bytes = Number(entry.transferSize) || 0;
|
||||
t.requests++;
|
||||
t.bytes += bytes;
|
||||
if (bytes === 0 && (Number(entry.decodedBodySize) || 0) > 0) t.cached++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start summing this page's downloads (main.js at boot): the entries already in the Resource Timing buffer, then
|
||||
* every new one (a PerformanceObserver also gets the entries a full buffer drops). False where unsupported.
|
||||
* @param {any} [win]
|
||||
*/
|
||||
export function installTrafficMeter(win = globalThis) {
|
||||
if (trafficMeter) return true;
|
||||
const PO = win?.PerformanceObserver;
|
||||
if (typeof PO !== 'function') return false;
|
||||
try {
|
||||
const origin = win.location?.origin;
|
||||
for (const e of win.performance?.getEntriesByType?.('navigation') || []) recordResourceEntry(e, origin);
|
||||
trafficMeter = new PO((list) => { for (const e of list.getEntries()) recordResourceEntry(e, origin); });
|
||||
trafficMeter.observe({ type: 'resource', buffered: true });
|
||||
return true;
|
||||
} catch {
|
||||
trafficMeter = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The totals so far: `{ [category]: { requests, bytes, cached } }` in report order, plus `total`. */
|
||||
export function trafficSummary() {
|
||||
const out = {};
|
||||
const total = { requests: 0, bytes: 0, cached: 0 };
|
||||
for (const cat of [...TRAFFIC_CATEGORIES, 'external']) {
|
||||
const t = traffic.get(cat);
|
||||
if (!t) continue;
|
||||
out[cat] = { ...t };
|
||||
if (cat !== 'external') { total.requests += t.requests; total.bytes += t.bytes; total.cached += t.cached; }
|
||||
}
|
||||
out.total = total;
|
||||
return out;
|
||||
}
|
||||
export function clearTraffic() { traffic.clear(); }
|
||||
|
||||
const sizeOf = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : n >= 1e3 ? `${Math.round(n / 1e3)} KB` : `${n} B`);
|
||||
/** `assets 9.7 MB (254, 41 cached) | … | host total 21.4 MB (869)` — the report's traffic line; null when nothing was recorded. */
|
||||
export function trafficLine(summary) {
|
||||
if (!summary || !summary.total || !(summary.total.requests > 0)) return null;
|
||||
const parts = Object.entries(summary).filter(([k]) => k !== 'total')
|
||||
.map(([k, t]) => `${k} ${sizeOf(t.bytes)} (${t.requests}${t.cached ? `, ${t.cached} cached` : ''})`);
|
||||
return `${parts.join(' | ')} | host total ${sizeOf(summary.total.bytes)} (${summary.total.requests})`;
|
||||
}
|
||||
|
||||
let battleSource = null;
|
||||
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
|
||||
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
|
||||
@@ -177,10 +253,10 @@ const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== fa
|
||||
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
|
||||
* prose and exact string values in the JSON.
|
||||
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
|
||||
* now?: number, pageStart?: number, version?: string }} input
|
||||
* now?: number, pageStart?: number, version?: string, traffic?: ReturnType<typeof trafficSummary> | null }} input
|
||||
*/
|
||||
export function buildDiagnostics(input) {
|
||||
const { state = {}, env = {}, settings = null, battle = null } = input;
|
||||
const { state = {}, env = {}, settings = null, battle = null, traffic: trafficTotals = null } = input;
|
||||
const entries = input.entries ?? errorLog();
|
||||
const now = input.now ?? Date.now();
|
||||
const lines = [];
|
||||
@@ -208,6 +284,8 @@ export function buildDiagnostics(input) {
|
||||
if (settings) {
|
||||
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`);
|
||||
}
|
||||
const tl = trafficLine(trafficTotals);
|
||||
if (tl) lines.push(`traffic: ${tl}`);
|
||||
const shown = entries.slice().reverse();
|
||||
const tot = input.total ?? total;
|
||||
const drop = input.dropped ?? dropped;
|
||||
@@ -265,5 +343,5 @@ export function browserEnv() {
|
||||
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
|
||||
*/
|
||||
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
|
||||
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
|
||||
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null, traffic: trafficSummary() });
|
||||
}
|
||||
+3
-1
@@ -55,7 +55,7 @@ import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/l
|
||||
import { startBuildGuard } from './ui/buildGuard.js';
|
||||
import { initLang, useLang, tickerText } from './ui/lang.js';
|
||||
import { t, N_, translateWire } from '../../shared/i18n.js';
|
||||
import { recordError } from './diag.js';
|
||||
import { recordError, installTrafficMeter } from './diag.js';
|
||||
|
||||
const RESTORE_GRACE_MS = 1500;
|
||||
const JOIN_DELAY_MS = 350;
|
||||
@@ -346,6 +346,8 @@ function installGlobalErrorHandlers() {
|
||||
|
||||
async function boot() {
|
||||
installGlobalErrorHandlers();
|
||||
// what this page downloads, by category, for the 问题反馈 report's traffic line (diag.js; nothing is sent anywhere)
|
||||
installTrafficMeter();
|
||||
// touch / hover / fullscreen classes, zoom-gesture blocking, rotation re-layout (ui/device.js, css/devices.css)
|
||||
installDeviceSupport();
|
||||
// A page restored from the back/forward cache has a dead socket and a stale token choice: start over.
|
||||
|
||||
+7
-2
@@ -11,10 +11,14 @@
|
||||
//
|
||||
// `server/http/media.js` resolves /media/… back to the real file under public/assets/audio and still answers with
|
||||
// `Content-Type: audio/mpeg` + range support; Web Audio sniffs the container, so the URL is all the same to it.
|
||||
// A served manifest's content-hashed URL keeps its segment (shared/assetPaths.js; host bandwidth, layer 1):
|
||||
//
|
||||
// /assets/~0123456789abcdef/audio/bgm/act1.mp3 → /media/~0123456789abcdef/bgm/act1
|
||||
// A host that does not implement /media/ keeps working: `audio.js` falls back to the original URL whenever the
|
||||
// /media/ response is unusable (a 404, or a 200 that is not audio at all — some static hosts answer a missing
|
||||
// path with the SPA's index.html).
|
||||
import { MEDIA_PREFIX, AUDIO_EXTS } from '../../shared/media.js';
|
||||
import { splitHashedPath } from '../../shared/assetPaths.js';
|
||||
|
||||
const AUDIO_PATH = /^\/assets\/audio\/(.+)$/i;
|
||||
|
||||
@@ -36,7 +40,8 @@ export function mediaUrl(url, origin = globalThis.location?.origin) {
|
||||
// Cross-origin (or absolute while the page origin is unknown) stays untouched: that host serves its own media.
|
||||
const absolute = /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//');
|
||||
if (origin ? u.origin !== origin : absolute) return url;
|
||||
const m = AUDIO_PATH.exec(u.pathname);
|
||||
const { hash, path: plain } = splitHashedPath(u.pathname);
|
||||
const m = AUDIO_PATH.exec(plain);
|
||||
if (!m) return url;
|
||||
let rest = m[1];
|
||||
const ext = AUDIO_EXTS.find((e) => rest.toLowerCase().endsWith(e));
|
||||
@@ -44,5 +49,5 @@ export function mediaUrl(url, origin = globalThis.location?.origin) {
|
||||
rest = rest.slice(0, -ext.length);
|
||||
const segments = rest.split('/');
|
||||
if (!rest || segments.some((s) => !s || s === '.' || s === '..' || s.startsWith('.'))) return url;
|
||||
return `${MEDIA_PREFIX}${rest}${u.search}`;
|
||||
return `${MEDIA_PREFIX}${hash ? `~${hash}/` : ''}${rest}${u.search}`;
|
||||
}
|
||||
+16
-3
@@ -3,7 +3,8 @@
|
||||
// downloads from the public mirror (data/assets.json → ui['guide/<key>']; GitHub issue #42) — data.js artUrls; a page
|
||||
// whose picture fails to load tries its next URL. The pages are stored squashed to 1024² (both copies) and are
|
||||
// displayed stretched back to 16:9. Three chapters (基础规则 / 调度手册 / 进阶图鉴), ‹ › buttons, ←/→ (A/D) keys, page
|
||||
// dots, thumbnails; Esc or the backdrop closes. Adjacent pages are preloaded.
|
||||
// dots, thumbnails; Esc or the backdrop closes. Only the next page is preloaded (a page is 0.9–1.5 MB; the one before
|
||||
// has usually just been seen, so preloading it too doubled the download — host bandwidth, layer 0).
|
||||
// With no page listed the viewer shows the official loading-screen tips (config.tips) instead, and so does a page none
|
||||
// of whose copies loads (`guideStage`: data/assets.json lists the downloaded pages but the files are not on disk — a
|
||||
// `git pull` and restart without setup, an older asset folder, a failed download the shrink guard kept); the chapter
|
||||
@@ -66,6 +67,19 @@ export function guideStage(page, failed) {
|
||||
return src ? { kind: 'image', src } : { kind: 'tips' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one page the viewer preloads while page `i` is shown: the next one (wrapping around), its first URL that has not
|
||||
* failed — null without pages. The previous page is not preloaded (see the header).
|
||||
* @param {Array<{ urls?: string[] }>} pages guidePages()
|
||||
* @param {number} i
|
||||
* @param {Set<string>} [failed]
|
||||
*/
|
||||
export function guidePreloadUrl(pages, i, failed) {
|
||||
const n = Array.isArray(pages) ? pages.length : 0;
|
||||
if (!n || n < 2) return null;
|
||||
return nextArtUrl(pages[(((i + 1) % n) + n) % n]?.urls, failed);
|
||||
}
|
||||
|
||||
/** Open/closed + current page. */
|
||||
export const guideStore = createStore({ open: false, page: 0 });
|
||||
|
||||
@@ -136,8 +150,7 @@ export function GuideHost() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !n) return;
|
||||
preload(nextArtUrl(pages[(i + 1) % n]?.urls, failed));
|
||||
preload(nextArtUrl(pages[(i + n - 1) % n]?.urls, failed));
|
||||
preload(guidePreloadUrl(pages, i, failed));
|
||||
}, [open, i, n, failed]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
// server/http/assetIndex.js — the content hashes of the served art (host bandwidth, layer 1; docs/ASSETS.md "The served
|
||||
// manifests"):
|
||||
//
|
||||
// * built in the background shortly after the start (ASSET_INDEX_DELAY_MS): every file under public/assets/ and
|
||||
// public/fonts/ (dot names skipped like the static server does, linked folders followed once), the first 16 hex
|
||||
// digits of the SHA-256 of its bytes (WebCrypto, off the main thread);
|
||||
// * remembered in .cache/asset-hashes.json by path + size + mtime: a restart only stats the files and reads none of
|
||||
// the unchanged ones again; a file whose size or mtime changed is hashed again (a re-extraction, `setup --force`,
|
||||
// a Docker rebuild then costs the players only the files whose bytes changed);
|
||||
// * never trusted beyond what it checked: `hashOf(rel, stat)` answers only while the file still has the size and
|
||||
// mtime it was hashed with, else null — the caller then serves the plain way (1-day cache, size-mtime ETag) and
|
||||
// the miss asks for a rescan (at most one every RESCAN_GAP_MS);
|
||||
// * models: lists of files that share one URL hash — a Spine skeleton, its atlas and its pages (pixi-spine finds the
|
||||
// atlas next to the skeleton and the pages next to the atlas, so all their URLs carry the same segment). A model's
|
||||
// hash is the SHA-256 of its members' hashes; assetViews.js hands the lists over (`setModels`), read from the
|
||||
// manifests. `urlHashOk` accepts a file's own hash or the hash of a model it belongs to.
|
||||
//
|
||||
// Rel paths are POSIX and relative to public/ ('assets/char/avatar/x.png', 'fonts/bender-regular.woff2'). A rel that
|
||||
// differs from a file's name only in case finds that file when no other name matches it that way (Windows and macOS
|
||||
// serve `wah-y.png` from `WAH-Y.png`; on Linux the request is a 404 anyway).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash, webcrypto } from 'node:crypto';
|
||||
import { ASSET_HASH_HEX } from '../../shared/assetPaths.js';
|
||||
import { noopLog } from './config.js';
|
||||
import { IMMUTABLE_CACHE } from './files.js';
|
||||
|
||||
/** Folders of public/ whose files are hashed. */
|
||||
export const ASSET_INDEX_ROOTS = Object.freeze(['assets', 'fonts']);
|
||||
/** The first build waits this long after the start (a server that is only started and stopped again — tests — reads nothing). */
|
||||
export const ASSET_INDEX_DELAY_MS = 1000;
|
||||
/** Shortest time between two rescans a stale or unknown file asks for. */
|
||||
export const RESCAN_GAP_MS = 10_000;
|
||||
/** Version of .cache/asset-hashes.json. */
|
||||
export const HASH_CACHE_VERSION = 1;
|
||||
const STAT_CONCURRENCY = 64;
|
||||
const HASH_CONCURRENCY = 4;
|
||||
|
||||
const hex = (bytes) => Buffer.from(bytes).toString('hex').slice(0, ASSET_HASH_HEX);
|
||||
|
||||
/** First ASSET_HASH_HEX hex digits of the SHA-256 of `data` (synchronous; model hashes, manifests, tests). */
|
||||
export function hash16(data) {
|
||||
return createHash('sha256').update(data).digest('hex').slice(0, ASSET_HASH_HEX);
|
||||
}
|
||||
|
||||
/** The same for a file's bytes, computed off the main thread (WebCrypto runs in libuv's thread pool). */
|
||||
async function digest16(buf) {
|
||||
return hex(await webcrypto.subtle.digest('SHA-256', buf));
|
||||
}
|
||||
|
||||
/** Run `fn` over `items` with at most `n` at a time; stops early when `stop()` says so. */
|
||||
async function eachLimit(items, n, fn, stop) {
|
||||
let i = 0;
|
||||
const worker = async () => {
|
||||
while (i < items.length && !stop()) {
|
||||
const item = items[i++];
|
||||
await fn(item);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
|
||||
}
|
||||
|
||||
/**
|
||||
* The asset index of one server.
|
||||
* @param {{ publicDir: string, roots?: readonly string[], cacheFile?: string | null, log?: object, delayMs?: number,
|
||||
* rescanGapMs?: number, now?: () => number }} opts
|
||||
* cacheFile: where the hashes persist (null: in memory only — a test's temporary public/)
|
||||
*/
|
||||
export function createAssetIndex({ publicDir, roots = ASSET_INDEX_ROOTS, cacheFile = null, log = noopLog, delayMs = ASSET_INDEX_DELAY_MS,
|
||||
rescanGapMs = RESCAN_GAP_MS, now = Date.now }) {
|
||||
const base = path.resolve(publicDir);
|
||||
/** @type {Map<string, { size: number, mtimeMs: number, hash: string }>} */
|
||||
let records = new Map();
|
||||
/** @type {Map<string, string | null>} lower-case rel → the rel (null: two names differ only in case) */
|
||||
let lower = new Map();
|
||||
/** @type {string[][]} */
|
||||
let modelLists = [];
|
||||
/** @type {Map<string, Set<string>>} rel → hashes of the models it belongs to */
|
||||
let modelOf = new Map();
|
||||
let generation = 0;
|
||||
let ready = false;
|
||||
let closed = false;
|
||||
let building = null; // the build in flight (a promise)
|
||||
let again = false; // another build was asked for while one ran
|
||||
let startTimer = null;
|
||||
let rescanTimer = null;
|
||||
let lastBuildAt = -Infinity;
|
||||
let readyResolve;
|
||||
const readyPromise = new Promise((r) => { readyResolve = r; });
|
||||
const listeners = new Set();
|
||||
const last = { files: 0, bytes: 0, hashed: 0, reused: 0, ms: 0, at: null, error: null };
|
||||
|
||||
/** The cache file's records ({} when absent, unreadable or another version). */
|
||||
function loadCache() {
|
||||
if (!cacheFile) return new Map();
|
||||
try {
|
||||
const doc = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
if (!doc || doc.version !== HASH_CACHE_VERSION || typeof doc.files !== 'object' || !doc.files) return new Map();
|
||||
const out = new Map();
|
||||
for (const [rel, v] of Object.entries(doc.files)) {
|
||||
if (Array.isArray(v) && Number.isFinite(v[0]) && Number.isFinite(v[1]) && typeof v[2] === 'string' && v[2].length === ASSET_HASH_HEX) {
|
||||
out.set(rel, { size: v[0], mtimeMs: v[1], hash: v[2] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the records (temp file + rename: a concurrent reader never sees half a file). Failures only log. */
|
||||
async function saveCache(map) {
|
||||
if (!cacheFile) return;
|
||||
const files = {};
|
||||
for (const [rel, r] of [...map].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) files[rel] = [r.size, r.mtimeMs, r.hash];
|
||||
const tmp = `${cacheFile}.${process.pid}.tmp`;
|
||||
try {
|
||||
await fsp.mkdir(path.dirname(cacheFile), { recursive: true });
|
||||
await fsp.writeFile(tmp, JSON.stringify({ version: HASH_CACHE_VERSION, algo: 'sha256/16', files }));
|
||||
await fsp.rename(tmp, cacheFile);
|
||||
} catch (e) {
|
||||
log.debug?.('[assets] hash cache not saved', e?.code || e?.message);
|
||||
fsp.rm(tmp, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Every file under the roots: [rel, abs] (dot names skipped, a folder reached twice — a link loop — walked once). */
|
||||
async function listFiles() {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const walk = async (abs, rel) => {
|
||||
let real;
|
||||
try { real = await fsp.realpath(abs); } catch { return; }
|
||||
if (seen.has(real)) return;
|
||||
seen.add(real);
|
||||
let ents;
|
||||
try { ents = await fsp.readdir(abs, { withFileTypes: true }); } catch { return; }
|
||||
for (const d of ents) {
|
||||
if (closed) return;
|
||||
if (d.name.startsWith('.')) continue;
|
||||
const childAbs = path.join(abs, d.name);
|
||||
const childRel = `${rel}/${d.name}`;
|
||||
if (d.isDirectory()) await walk(childAbs, childRel);
|
||||
else if (d.isFile()) out.push([childRel, childAbs]);
|
||||
else if (d.isSymbolicLink()) {
|
||||
const st = await fsp.stat(childAbs).catch(() => null);
|
||||
if (st?.isDirectory()) await walk(childAbs, childRel);
|
||||
else if (st?.isFile()) out.push([childRel, childAbs]);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const root of roots) await walk(path.join(base, root), root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Model hashes from the current records (a model with a member the index does not know has none). */
|
||||
function computeModels() {
|
||||
const next = new Map();
|
||||
for (const list of modelLists) {
|
||||
const h = modelHashOf(list);
|
||||
if (!h) continue;
|
||||
for (const rel of list) {
|
||||
const c = canonical(rel);
|
||||
let set = next.get(c);
|
||||
if (!set) next.set(c, (set = new Set()));
|
||||
set.add(h);
|
||||
}
|
||||
}
|
||||
modelOf = next;
|
||||
}
|
||||
|
||||
/** The indexed name of `rel`: itself, or the one file whose name differs from it only in case (else null). */
|
||||
function canonical(rel) {
|
||||
if (typeof rel !== 'string') return null;
|
||||
if (records.has(rel)) return rel;
|
||||
return lower.get(rel.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
function recordOf(rel) {
|
||||
const c = canonical(rel);
|
||||
return c ? records.get(c) : null;
|
||||
}
|
||||
|
||||
function modelHashOf(list) {
|
||||
if (!Array.isArray(list) || !list.length) return null;
|
||||
const parts = [];
|
||||
for (const rel of list) {
|
||||
const r = recordOf(rel);
|
||||
if (!r) return null;
|
||||
parts.push(r.hash);
|
||||
}
|
||||
return hash16(parts.join('\n'));
|
||||
}
|
||||
|
||||
/** One build; true when the records changed (a new generation). */
|
||||
async function build() {
|
||||
const t0 = now();
|
||||
const known = records.size ? records : loadCache();
|
||||
const next = new Map();
|
||||
let hashed = 0;
|
||||
let reused = 0;
|
||||
let bytes = 0;
|
||||
const files = await listFiles();
|
||||
const toHash = [];
|
||||
await eachLimit(files, STAT_CONCURRENCY, async ([rel, abs]) => {
|
||||
const st = await fsp.stat(abs).catch(() => null);
|
||||
if (!st || !st.isFile()) return;
|
||||
bytes += st.size;
|
||||
const k = known.get(rel);
|
||||
if (k && k.size === st.size && k.mtimeMs === st.mtimeMs) { next.set(rel, k); reused++; } else toHash.push([rel, abs, st]);
|
||||
}, () => closed);
|
||||
await eachLimit(toHash, HASH_CONCURRENCY, async ([rel, abs, st]) => {
|
||||
try {
|
||||
const buf = await fsp.readFile(abs);
|
||||
const after = await fsp.stat(abs);
|
||||
// changed while it was read: hashed again by the next build
|
||||
if (after.size !== st.size || after.mtimeMs !== st.mtimeMs || buf.length !== st.size) return;
|
||||
next.set(rel, { size: st.size, mtimeMs: st.mtimeMs, hash: await digest16(buf) });
|
||||
hashed++;
|
||||
} catch { /* vanished or unreadable: not in the index */ }
|
||||
}, () => closed);
|
||||
if (closed) return false;
|
||||
// commit: records, model hashes and the generation change together (one synchronous step)
|
||||
let changed = next.size !== records.size;
|
||||
if (!changed) for (const [rel, r] of next) { const o = records.get(rel); if (!o || o.hash !== r.hash || o.size !== r.size || o.mtimeMs !== r.mtimeMs) { changed = true; break; } }
|
||||
const cacheChanged = hashed > 0 || next.size !== known.size;
|
||||
records = next;
|
||||
lower = new Map();
|
||||
for (const rel of next.keys()) { const k = rel.toLowerCase(); lower.set(k, lower.has(k) ? null : rel); }
|
||||
if (changed) { generation++; computeModels(); }
|
||||
Object.assign(last, { files: next.size, bytes, hashed, reused, ms: now() - t0, at: new Date().toISOString(), error: null });
|
||||
lastBuildAt = now();
|
||||
if (cacheChanged) await saveCache(next);
|
||||
if (hashed) log.info?.(`[assets] content hashes: ${next.size} files, ${hashed} hashed, ${reused} unchanged (${Math.round(last.ms)} ms)`);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function markReady() {
|
||||
if (ready) return;
|
||||
ready = true;
|
||||
readyResolve();
|
||||
}
|
||||
|
||||
function notify() {
|
||||
for (const fn of [...listeners]) { try { fn(generation); } catch (e) { log.error?.('[assets] index listener failed', e); } }
|
||||
}
|
||||
|
||||
/** Build now (or once more after the build in flight). */
|
||||
function refresh() {
|
||||
if (closed) return Promise.resolve();
|
||||
if (building) { again = true; return building; }
|
||||
building = (async () => {
|
||||
try {
|
||||
do {
|
||||
again = false;
|
||||
const changed = await build();
|
||||
if (closed) break;
|
||||
markReady();
|
||||
if (changed) notify();
|
||||
} while (again && !closed);
|
||||
} catch (e) {
|
||||
last.error = e?.message || String(e);
|
||||
log.warn?.('[assets] content hash index failed', e?.message || e);
|
||||
} finally {
|
||||
building = null;
|
||||
if (!closed) markReady();
|
||||
}
|
||||
})();
|
||||
return building;
|
||||
}
|
||||
|
||||
return {
|
||||
/** Start the background build (after `delayMs`); returns at once. Idempotent. */
|
||||
start() {
|
||||
if (closed || startTimer || building || ready) return;
|
||||
if (!(delayMs > 0)) { refresh(); return; }
|
||||
startTimer = setTimeout(() => { startTimer = null; refresh(); }, delayMs);
|
||||
startTimer.unref?.();
|
||||
},
|
||||
refresh,
|
||||
/** Resolves once the first build is done (never rejects). */
|
||||
whenReady() { return readyPromise; },
|
||||
get ready() { return ready; },
|
||||
get generation() { return generation; },
|
||||
/** Files with a hash. */
|
||||
get size() { return records.size; },
|
||||
/**
|
||||
* The file's content hash while it still has the size and mtime it was hashed with (`stat` given), else null.
|
||||
* Without `stat`: the hash of the last build.
|
||||
* @param {string} rel @param {{ size: number, mtimeMs: number }} [stat]
|
||||
*/
|
||||
hashOf(rel, stat) {
|
||||
const r = recordOf(rel);
|
||||
if (!r) return null;
|
||||
if (stat && (stat.size !== r.size || stat.mtimeMs !== r.mtimeMs)) return null;
|
||||
return r.hash;
|
||||
},
|
||||
/** Does `urlHash` name exactly these bytes: the file's own hash, or the hash of a model it belongs to? */
|
||||
urlHashOk(rel, stat, urlHash) {
|
||||
if (typeof urlHash !== 'string') return false;
|
||||
const own = this.hashOf(rel, stat);
|
||||
if (!own) return false;
|
||||
return own === urlHash || !!modelOf.get(canonical(rel))?.has(urlHash);
|
||||
},
|
||||
/** The hash of a model (its members' hashes hashed), null while a member is unknown. */
|
||||
modelHash: (list) => modelHashOf(list),
|
||||
/** Replace the model lists (assetViews.js, from the manifests). */
|
||||
setModels(lists) {
|
||||
modelLists = Array.isArray(lists) ? lists.filter((l) => Array.isArray(l) && l.length) : [];
|
||||
computeModels();
|
||||
},
|
||||
/** A file was served that the index does not know, or no longer matches: rescan (at most one per rescanGapMs). */
|
||||
noteMiss() {
|
||||
if (closed || !ready || rescanTimer || building) return;
|
||||
const wait = Math.max(0, lastBuildAt + rescanGapMs - now());
|
||||
rescanTimer = setTimeout(() => { rescanTimer = null; refresh(); }, wait);
|
||||
rescanTimer.unref?.();
|
||||
},
|
||||
/** `fn(generation)` after a build changed the records; returns the unsubscribe. */
|
||||
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
||||
stats() {
|
||||
return { ready, building: !!building, generation, files: records.size, models: modelLists.length, last: { ...last } };
|
||||
},
|
||||
/** Stop: timers cleared, a build in flight abandoned. */
|
||||
close() {
|
||||
closed = true;
|
||||
if (startTimer) { clearTimeout(startTimer); startTimer = null; }
|
||||
if (rescanTimer) { clearTimeout(rescanTimer); rescanTimer = null; }
|
||||
readyResolve(); // a waiter is let go; `ready` stays as it was
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ETag source and cache policy of a file of the asset index's folders (public/assets, public/fonts), for
|
||||
* serveFile: its content hash when the index has it for these bytes (a miss asks for a rescan); for a URL with a hash
|
||||
* segment `immutable` when that hash names these bytes, else `no-cache`.
|
||||
* @param {ReturnType<typeof createAssetIndex> | null} assetIndex
|
||||
* @param {string} rel the file under public/ ('assets/…', POSIX)
|
||||
* @param {import('node:fs').Stats} stat
|
||||
* @param {string | null} urlHash the URL's hash segment
|
||||
* @returns {{ contentHash?: string | null, cacheControl?: string }}
|
||||
*/
|
||||
export function hashedFileOpts(assetIndex, rel, stat, urlHash) {
|
||||
if (!assetIndex) return urlHash ? { cacheControl: 'no-cache' } : {};
|
||||
const contentHash = assetIndex.hashOf(rel, stat);
|
||||
if (!contentHash) assetIndex.noteMiss(rel);
|
||||
if (!urlHash) return { contentHash };
|
||||
return { contentHash, cacheControl: contentHash && assetIndex.urlHashOk(rel, stat, urlHash) ? IMMUTABLE_CACHE : 'no-cache' };
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// server/http/assetViews.js — the served manifests (host bandwidth, layer 1; docs/ASSETS.md "The served manifests"):
|
||||
// data/assets.json and data/local-assets.json as this server hands them out, every art URL carrying the content hash
|
||||
// of its file as its first segment (shared/assetPaths.js) — `/assets/char/avatar/x.png` →
|
||||
// `/assets/~<hash>/char/avatar/x.png`, `/fonts/~<hash>/…` — so the browser keeps the file for a year and asks again
|
||||
// only when the bytes change. Audio keeps the `/assets/~<hash>/audio/….mp3` form: public/js/media.js turns it into
|
||||
// `/media/~<hash>/…` when it fetches it.
|
||||
//
|
||||
// * A Spine model's skeleton, atlas and pages carry ONE hash, that of the model (assetIndex.js models): pixi-spine
|
||||
// finds the atlas by the skeleton's name and the pages next to the atlas, so their URLs share a folder. A model is
|
||||
// every `{ skel, atlas, textures }` object of data/assets.json (chars' front / back, tokens, enemies) and every
|
||||
// `spine/…` group of data/local-assets.json (the files a `spineLocal` record names).
|
||||
// * A URL the index has no hash for (not on disk, not hashed yet) stays as it is.
|
||||
// * The committed data/assets.json is never written: a view is built in memory from the file and the index, again
|
||||
// when either changes (the file's size / mtime, the index's generation), and once after every index build, so the
|
||||
// index knows the model lists before any page asks.
|
||||
// * No index, or not ready yet: no view (`get` → null) and the files go out as they are.
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { hashedPath } from '../../shared/assetPaths.js';
|
||||
import { hash16 } from './assetIndex.js';
|
||||
import { noopLog } from './config.js';
|
||||
|
||||
/** The manifests that have a view, by their name under data/. */
|
||||
export const VIEW_MANIFESTS = Object.freeze(['assets.json', 'local-assets.json']);
|
||||
|
||||
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
const ART_URL = /^\/(?:assets|fonts)\/./;
|
||||
|
||||
/** URL path of a manifest → the index's rel path ('/assets/a%20b.png' → 'assets/a b.png'), or null. */
|
||||
export function relOf(url) {
|
||||
if (typeof url !== 'string' || !ART_URL.test(url) || /[?#]/.test(url)) return null;
|
||||
let p;
|
||||
try { p = decodeURIComponent(url); } catch { return null; }
|
||||
if (p.includes('\0') || p.includes('\\')) return null;
|
||||
const segs = p.slice(1).split('/');
|
||||
if (segs.some((s) => s === '' || s === '.' || s === '..')) return null;
|
||||
return segs.join('/');
|
||||
}
|
||||
|
||||
/** Is `o` a Spine object of data/assets.json (`{ skel: '/….skel', atlas, textures? }`)? */
|
||||
const isSpine = (o) => isObj(o) && typeof o.skel === 'string' && o.skel.startsWith('/') && /\.skel$/.test(o.skel);
|
||||
|
||||
/** The URLs of a Spine object: skeleton, atlas, pages (the `<skel name>.png` when it lists none). */
|
||||
function spineUrls(o) {
|
||||
const out = [o.skel];
|
||||
if (typeof o.atlas === 'string' && o.atlas) out.push(o.atlas);
|
||||
const pages = Array.isArray(o.textures) ? o.textures.filter((u) => typeof u === 'string' && u) : [];
|
||||
out.push(...(pages.length ? pages : [o.skel.replace(/\.skel$/, '.png')]));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The served copy of a manifest and its model lists. `index`: hashOf(rel) / modelHash(rels) of assetIndex.js.
|
||||
* @param {any} doc the parsed manifest (not changed)
|
||||
* @param {'assets.json'|'local-assets.json'} name
|
||||
* @param {{ hashOf: (rel: string) => string | null, modelHash: (rels: string[]) => string | null }} index
|
||||
* @returns {{ doc: any, models: string[][], hashed: number, plain: number }}
|
||||
*/
|
||||
export function rewriteManifest(doc, name, index) {
|
||||
const models = [];
|
||||
let hashed = 0;
|
||||
let plain = 0;
|
||||
const own = (url) => {
|
||||
const rel = relOf(url);
|
||||
const h = rel ? index.hashOf(rel) : null;
|
||||
if (h) { hashed++; return hashedPath(url, h); }
|
||||
if (rel) plain++;
|
||||
return url;
|
||||
};
|
||||
/** All URLs of one model with the model's hash (or all left plain when a member is unknown). */
|
||||
const model = (urls) => {
|
||||
const rels = urls.map(relOf);
|
||||
if (rels.some((r) => !r)) return null;
|
||||
models.push(rels);
|
||||
const h = index.modelHash(rels);
|
||||
if (!h) { plain += urls.length; return null; }
|
||||
hashed += urls.length;
|
||||
return h;
|
||||
};
|
||||
const walk = (v) => {
|
||||
if (typeof v === 'string') return ART_URL.test(v) ? own(v) : v;
|
||||
if (Array.isArray(v)) return v.map(walk);
|
||||
if (!isObj(v)) return v;
|
||||
if (name === 'assets.json' && isSpine(v)) {
|
||||
const h = model(spineUrls(v));
|
||||
const out = {};
|
||||
for (const [k, x] of Object.entries(v)) {
|
||||
if (h && k === 'skel') out[k] = hashedPath(x, h);
|
||||
else if (h && k === 'atlas' && typeof x === 'string') out[k] = hashedPath(x, h);
|
||||
else if (h && k === 'textures' && Array.isArray(x)) out[k] = x.map((u) => (typeof u === 'string' ? hashedPath(u, h) : u));
|
||||
else out[k] = walk(x);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const out = {};
|
||||
for (const [k, x] of Object.entries(v)) out[k] = walk(x);
|
||||
return out;
|
||||
};
|
||||
if (name === 'local-assets.json' && isObj(doc) && isObj(doc.groups)) {
|
||||
const groups = {};
|
||||
for (const [g, entries] of Object.entries(doc.groups)) {
|
||||
if (g.startsWith('spine/') && isObj(entries)) {
|
||||
const names = Object.keys(entries).filter((n) => isObj(entries[n]) && typeof entries[n].path === 'string').sort();
|
||||
const h = names.length ? model(names.map((n) => entries[n].path)) : null;
|
||||
groups[g] = {};
|
||||
for (const [n, e] of Object.entries(entries)) {
|
||||
groups[g][n] = h && isObj(e) && typeof e.path === 'string' ? { ...e, path: hashedPath(e.path, h) } : walk(e);
|
||||
}
|
||||
} else groups[g] = walk(entries);
|
||||
}
|
||||
return { doc: { ...walk({ ...doc, groups: null }), groups }, models, hashed, plain };
|
||||
}
|
||||
return { doc: walk(doc), models, hashed, plain };
|
||||
}
|
||||
|
||||
/**
|
||||
* The views of one server.
|
||||
* @param {{ dataDir: string, index: ReturnType<typeof import('./assetIndex.js').createAssetIndex>, log?: object }} opts
|
||||
*/
|
||||
export function createManifestViews({ dataDir, index, log = noopLog }) {
|
||||
const dir = path.resolve(dataDir);
|
||||
/** @type {Map<string, { key: string, view: { body: Buffer, hash: string, hashed: number, plain: number } | null, models: string[][] }>} */
|
||||
const cache = new Map();
|
||||
/** @type {Map<string, Promise<any>>} */
|
||||
const pending = new Map();
|
||||
const warned = new Set();
|
||||
|
||||
const pushModels = () => index.setModels([...cache.values()].flatMap((c) => c.models));
|
||||
|
||||
/** Build (or reuse) the view of `name`; null without the file, while the index is not ready or empty, or for a broken file. */
|
||||
async function build(name) {
|
||||
if (!index.ready || !index.size) return null;
|
||||
const file = path.join(dir, name);
|
||||
const st = await fsp.stat(file).catch(() => null);
|
||||
if (!st || !st.isFile()) {
|
||||
if (cache.delete(name)) pushModels();
|
||||
return null;
|
||||
}
|
||||
const key = `${index.generation}|${st.size}|${st.mtimeMs}`;
|
||||
const hit = cache.get(name);
|
||||
if (hit && hit.key === key) return hit.view;
|
||||
let doc;
|
||||
try {
|
||||
doc = JSON.parse(await fsp.readFile(file, 'utf8'));
|
||||
} catch (e) {
|
||||
if (!warned.has(name)) { warned.add(name); log.warn?.(`[assets] data/${name} unreadable (${e?.message || e}); served as it is`); }
|
||||
cache.set(name, { key, view: null, models: [] });
|
||||
pushModels();
|
||||
return null;
|
||||
}
|
||||
// one synchronous step: the view and the model lists the index checks a model's URLs against
|
||||
const { doc: out, models, hashed, plain } = rewriteManifest(doc, name, index);
|
||||
const body = Buffer.from(JSON.stringify(out));
|
||||
const view = { body, hash: hash16(body), hashed, plain };
|
||||
cache.set(name, { key, view, models });
|
||||
pushModels();
|
||||
return view;
|
||||
}
|
||||
|
||||
/** The view of `name` (one build at a time per manifest). */
|
||||
function get(name) {
|
||||
if (!VIEW_MANIFESTS.includes(name)) return Promise.resolve(null);
|
||||
const running = pending.get(name);
|
||||
if (running) return running;
|
||||
const p = build(name).catch((e) => { log.error?.('[assets] manifest view failed', e); return null; }).finally(() => pending.delete(name));
|
||||
pending.set(name, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
const rebuildAll = () => Promise.all(VIEW_MANIFESTS.map(get));
|
||||
// after every build that changed the hashes (and the first one): the views, and with them the index's model lists
|
||||
index.onChange(() => { rebuildAll(); });
|
||||
index.whenReady().then(() => rebuildAll(), () => {});
|
||||
|
||||
return {
|
||||
get,
|
||||
rebuildAll,
|
||||
stats() {
|
||||
const out = {};
|
||||
for (const [name, c] of cache) out[name] = c.view ? { bytes: c.view.body.length, hashed: c.view.hashed, plain: c.view.plain } : null;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
// * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private
|
||||
// peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy;
|
||||
// * DEBUG → the console logger's debug level;
|
||||
// * SP_ASSET_INDEX=0 (or the `assetIndex: false` option) → no content-hash index: plain asset URLs, size-mtime ETags
|
||||
// (assetIndex.js); the index of the default public/ persists in .cache/asset-hashes.json;
|
||||
// * the served directories (public/, data/, shared/ and the content packs' packs/ of this repository unless the
|
||||
// options name others), and which startServer() options are handed on to net.js Network and lobby.js Lobby.
|
||||
|
||||
@@ -64,6 +66,25 @@ export function serveDirs(opts) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The asset index options out of the startServer() options and the environment, or null for none: `assetIndex: false`
|
||||
* or SP_ASSET_INDEX=0 / off / false / no switches it off; `assetIndex: { cacheFile, delayMs }` overrides the defaults.
|
||||
* The hashes persist in .cache/asset-hashes.json only for the repository's own public/ (a test's temporary folder keeps
|
||||
* them in memory).
|
||||
* @param {{ assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number } }} opts
|
||||
* @param {string} publicDir
|
||||
* @returns {{ cacheFile: string | null, delayMs?: number } | null}
|
||||
*/
|
||||
export function assetIndexOptionsFrom(opts, publicDir) {
|
||||
if (opts.assetIndex === false) return null;
|
||||
const env = String(process.env.SP_ASSET_INDEX ?? '').trim().toLowerCase();
|
||||
if (opts.assetIndex == null && ['0', 'off', 'false', 'no'].includes(env)) return null;
|
||||
const o = typeof opts.assetIndex === 'object' && opts.assetIndex ? opts.assetIndex : {};
|
||||
const own = path.resolve(publicDir) === path.join(ROOT, 'public');
|
||||
const cacheFile = o.cacheFile !== undefined ? o.cacheFile : (own ? path.join(ROOT, '.cache', 'asset-hashes.json') : null);
|
||||
return { cacheFile, ...(Number.isFinite(o.delayMs) ? { delayMs: o.delayMs } : {}) };
|
||||
}
|
||||
|
||||
/** net.js Network options out of the startServer() options; `trustProxy` falls back to TRUST_PROXY. */
|
||||
export function netOptionsFrom(opts) {
|
||||
const netOptions = {};
|
||||
|
||||
+145
-37
@@ -1,9 +1,12 @@
|
||||
// server/http/files.js — one file on disk → an HTTP response (static.js and media.js decide which file):
|
||||
// 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; Cache-Control
|
||||
// (html & code/data: no-cache + revalidate; public/assets|fonts|vendor: 1 day; any `?v=` URL: immutable);
|
||||
// single byte-range requests (206/416, used by <audio>).
|
||||
// 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>).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
@@ -53,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 LONG_CACHE = 'public, max-age=86400'; // 1 day
|
||||
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
|
||||
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;
|
||||
@@ -82,20 +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) {
|
||||
const key = `${absPath}\0${stat.size}\0${stat.mtimeMs}`;
|
||||
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 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>} 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 fsp.readFile(absPath);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -111,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;
|
||||
@@ -190,20 +268,25 @@ function cacheControlFor(ext, mountName, segments, query) {
|
||||
return 'no-cache';
|
||||
}
|
||||
|
||||
export async function serveFile(req, res, absPath, stat, mountName, segments, query, gzipCache, log) {
|
||||
/**
|
||||
* One file → the response.
|
||||
* @param {{ contentHash?: string | null, cacheControl?: string | null }} [opts] contentHash: the file's content hash
|
||||
* (assetIndex.js) — the ETag then; cacheControl: overrides the policy of its path (a content-hashed URL)
|
||||
*/
|
||||
export async function serveFile(req, res, absPath, stat, mountName, segments, query, gzipCache, log, opts = {}) {
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
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 baseTag = `${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}`;
|
||||
const etag = `"${baseTag}${useGzip ? '-gz' : ''}"`;
|
||||
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}${encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : ''}"`;
|
||||
const lastModified = stat.mtime.toUTCString();
|
||||
const isHead = req.method === 'HEAD';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': type,
|
||||
'Cache-Control': cacheControlFor(ext, mountName, segments, query),
|
||||
'Cache-Control': opts.cacheControl || cacheControlFor(ext, mountName, segments, query),
|
||||
ETag: etag,
|
||||
'Last-Modified': lastModified,
|
||||
};
|
||||
@@ -215,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;
|
||||
}
|
||||
|
||||
@@ -253,6 +336,31 @@ export async function serveFile(req, res, absPath, stat, mountName, segments, qu
|
||||
await streamTo(fs.createReadStream(absPath, { start, end }), res, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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))) {
|
||||
res.writeHead(304, headers);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
let out = body;
|
||||
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);
|
||||
res.end(req.method === 'HEAD' ? undefined : out);
|
||||
}
|
||||
|
||||
async function streamTo(src, res, log, transform) {
|
||||
try {
|
||||
if (transform) await pipeline(src, transform, res);
|
||||
|
||||
+10
-3
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
import { AUDIO_EXTS } from '../../shared/media.js';
|
||||
import { sendError } from './common.js';
|
||||
import { serveFile } from './files.js';
|
||||
import { hashedFileOpts } from './assetIndex.js';
|
||||
|
||||
/**
|
||||
* Extension-less audio route: `/media/bgm/act1` → `public/assets/audio/bgm/act1.mp3`.
|
||||
@@ -15,8 +16,12 @@ import { serveFile } from './files.js';
|
||||
* Requests for the direct `/assets/audio/…` URLs keep working (they are the fallback for plain static hosts).
|
||||
* `MEDIA_PREFIX` / `AUDIO_EXTS` live in `shared/media.js`: the browser decides which URLs to rewrite with the
|
||||
* same two values, and they must not drift apart.
|
||||
* `/media/~<hash>/bgm/act1` (a served manifest's `/assets/~<hash>/audio/bgm/act1.mp3`, public/js/media.js): static.js
|
||||
* strips the segment and hands it over as `urlHash`; the answer is `immutable` when it names the resolved file's bytes
|
||||
* (assetIndex.js), else `no-cache`, and the ETag is the file's content hash when the index has it.
|
||||
* @param {{ urlHash?: string | null, assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null }} [hashing]
|
||||
*/
|
||||
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log) {
|
||||
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null } = {}) {
|
||||
const root = path.join(path.resolve(publicDir), 'assets', 'audio');
|
||||
const segments = String(rest || '').split('/').filter((s) => s.length > 0);
|
||||
if (!segments.length || rest.endsWith('/')) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
@@ -44,9 +49,11 @@ export async function serveMedia(req, res, rest, query, publicDir, gzipCache, lo
|
||||
} catch { continue; }
|
||||
if (!stat.isFile()) continue;
|
||||
// serveFile decides Content-Type from the resolved name (`.mp3` → audio/mpeg) — Range/ETag handling is shared.
|
||||
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day).
|
||||
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day),
|
||||
// or that of its content-hashed form.
|
||||
const rel = ['assets', 'audio', ...segments.slice(0, -1), stem + ext].join('/');
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await serveFile(req, res, absPath, stat, 'public', ['assets', 'audio', ...segments], query, gzipCache, log);
|
||||
await serveFile(req, res, absPath, stat, 'public', ['assets', 'audio', ...segments], query, gzipCache, log, hashedFileOpts(assetIndex, rel, stat, urlHash));
|
||||
return;
|
||||
}
|
||||
sendError(req, res, 404, '页面不存在 · Not found');
|
||||
|
||||
+20
-7
@@ -4,9 +4,11 @@
|
||||
// * a URL longer than 4096 characters → 414; one that does not parse → 400;
|
||||
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
|
||||
// * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions,
|
||||
// rooms, matches), never cached;
|
||||
// rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the
|
||||
// start, WebSocket wire and payload bytes);
|
||||
// * everything else → the static files (static.js).
|
||||
// A route that throws is logged and answers 500.
|
||||
// Every response's body bytes count in the traffic counters (traffic.js meter). A route that throws is logged and
|
||||
// answers 500.
|
||||
|
||||
import { PROTOCOL_VERSION, APP_VERSION } from '../../shared/constants.js';
|
||||
import { buildTag } from './buildTag.js';
|
||||
@@ -15,20 +17,30 @@ import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js';
|
||||
const MAX_URL_LENGTH = 4096;
|
||||
|
||||
/**
|
||||
* The GET /healthz body.
|
||||
* The GET /healthz body (`withTraffic`: plus the traffic counters and the asset index's state, `?traffic=1`).
|
||||
* @param {{ startedAt: number, network: import('../net.js').Network, registry: import('../net.js').SessionRegistry,
|
||||
* lobby: import('../lobby.js').Lobby }} health
|
||||
* lobby: import('../lobby.js').Lobby, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter>,
|
||||
* assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
|
||||
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} health
|
||||
* @param {boolean} [withTraffic]
|
||||
*/
|
||||
export function healthReport({ startedAt, network, registry, lobby }) {
|
||||
return {
|
||||
export function healthReport({ startedAt, network, registry, lobby, traffic, assetIndex, views }, withTraffic = false) {
|
||||
const report = {
|
||||
ok: true, version: PROTOCOL_VERSION, app: APP_VERSION, uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
||||
// the runtime the server is serving right now (public/js/ui/buildGuard.js): a page whose own build is
|
||||
// older than this reloads itself, so a deploy reaches clients that never reload
|
||||
build: buildTag(),
|
||||
sockets: network.connectionCount, sessions: registry.size, ...lobby.stats(),
|
||||
};
|
||||
if (withTraffic && traffic) report.traffic = traffic.snapshot();
|
||||
// content-hashed asset URLs (assetIndex.js / assetViews.js): off, building, or ready with how many files and URLs
|
||||
if (withTraffic) report.assetHashes = assetIndex ? { ...assetIndex.stats(), views: views ? views.stats() : null } : null;
|
||||
return report;
|
||||
}
|
||||
|
||||
/** Does the query ask for the traffic counters (`traffic`, `traffic=1`, `traffic=true`)? */
|
||||
const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.test(query || '');
|
||||
|
||||
/**
|
||||
* The request listener for `http.createServer`.
|
||||
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
||||
@@ -48,7 +60,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
|
||||
return;
|
||||
}
|
||||
if (parts.rawPath === '/healthz') {
|
||||
sendJson(req, res, 200, healthReport(health));
|
||||
sendJson(req, res, 200, healthReport(health, wantsTraffic(parts.query)));
|
||||
return;
|
||||
}
|
||||
await serveStatic(req, res, parts.rawPath, parts.query);
|
||||
@@ -56,6 +68,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
|
||||
|
||||
return (req, res) => {
|
||||
setSecurityHeaders(res);
|
||||
if (health.traffic) health.traffic.meter(res, splitUrl(req.url || '/')?.rawPath || '/');
|
||||
handleRequest(req, res).catch((e) => {
|
||||
log.error('[http] request failed', e);
|
||||
sendError(req, res, 500, '服务器内部错误 · Internal error');
|
||||
|
||||
+38
-9
@@ -15,16 +15,27 @@
|
||||
// /packs/index.json → the content packs of this server (server/packs.js: the language packs of
|
||||
// public/i18n/ and the pack folders of packs/, re-read when they change; never cached)
|
||||
// /packs/<id>/<file> → a file of a pack folder — only one its manifest names (server/packs.js servable)
|
||||
// /assets/~<hash>/…, /fonts/~<hash>/…, /media/~<hash>/… → the same files: the content-hash segment
|
||||
// (shared/assetPaths.js) is stripped. A hash that names the file's current bytes (assetIndex.js
|
||||
// urlHashOk: its own hash, or its Spine model's) answers `immutable` for a year; any other hash —
|
||||
// an old page, a URL derived from another file's — the current file with `no-cache`
|
||||
// /data/assets.json, /data/local-assets.json → the served manifests (assetViews.js: every art URL with its
|
||||
// content-hash segment; no-cache, the ETag of the view) once the asset index is ready, the files as
|
||||
// they are before that or without an index
|
||||
//
|
||||
// Traversal & dotfile protection, a directory without its trailing slash → 301, 404 page; an absent
|
||||
// data/local-assets.json is answered with an empty manifest. Files go out through files.js serveFile.
|
||||
// Files under public/assets/ and public/fonts/ get their content hash as ETag when the index has it (a file whose
|
||||
// size or mtime changed since asks for a rescan), others size + mtime. Traversal & dotfile protection, a directory
|
||||
// without its trailing slash → 301, 404 page; an absent data/local-assets.json is answered with an empty manifest.
|
||||
// Files go out through files.js serveFile.
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
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 } 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';
|
||||
import { PACKS_URL, PACK_INDEX_FILE } from '../../shared/packs.js';
|
||||
@@ -51,11 +62,15 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
|
||||
/**
|
||||
* Create the static request handler.
|
||||
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string,
|
||||
* packs?: ReturnType<typeof createPackRegistry>, log?: object }} dirs
|
||||
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs)
|
||||
* packs?: ReturnType<typeof createPackRegistry>, log?: object,
|
||||
* assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
|
||||
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} dirs
|
||||
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs);
|
||||
* assetIndex / views: the content hashes and the served manifests (none: plain URLs, size-mtime ETags)
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>}
|
||||
*/
|
||||
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog }) {
|
||||
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog,
|
||||
assetIndex = null, views = null }) {
|
||||
const registry = packs || createPackRegistry({ publicDir, dataDir, packsDir }, { log: /** @type {any} */ (log) });
|
||||
const mounts = [
|
||||
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
|
||||
@@ -66,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;
|
||||
@@ -75,6 +90,10 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
sendError(req, res, 400, '请求地址无效 · Bad request');
|
||||
return;
|
||||
}
|
||||
// /assets/~<hash>/x → /assets/x (the hash decides the cache policy below)
|
||||
const hashed = splitHashedPath(decoded);
|
||||
const urlHash = hashed.hash;
|
||||
decoded = hashed.path;
|
||||
if (decoded === '/data.js') {
|
||||
const headers = { 'Content-Type': MIME['.js'], 'Cache-Control': 'no-cache', ETag: shimTag, 'Content-Length': shimBody.length };
|
||||
if (isNotModified(req, shimTag, new Date(0))) { delete headers['Content-Length']; res.writeHead(304, headers); res.end(); return; }
|
||||
@@ -94,9 +113,17 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
}
|
||||
// Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3
|
||||
if (decoded.startsWith(MEDIA_PREFIX)) {
|
||||
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log);
|
||||
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex });
|
||||
return;
|
||||
}
|
||||
// The served manifests: every art URL with its content hash (assetViews.js), once the index is ready
|
||||
if (views && (decoded === '/data/assets.json' || decoded === '/data/local-assets.json')) {
|
||||
const view = await views.get(decoded.slice('/data/'.length));
|
||||
if (view) {
|
||||
await serveBuffer(req, res, { body: view.body, hash: view.hash, type: MIME['.json'], cacheControl: 'no-cache', key: `view:${decoded}`, cache: gzipCache });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Bare mount paths (e.g. "/data") → treat as the mount directory.
|
||||
const mount = mounts.find((m) => decoded.startsWith(m.prefix) || decoded === m.prefix.slice(0, -1)) || mounts[mounts.length - 1];
|
||||
const rest = decoded.length > mount.prefix.length ? decoded.slice(mount.prefix.length) : '';
|
||||
@@ -154,6 +181,8 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
}
|
||||
return;
|
||||
}
|
||||
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log);
|
||||
const indexed = mount.name === 'public' && segments.length > 1 && ASSET_INDEX_ROOTS.includes(segments[0]);
|
||||
const opts = indexed ? hashedFileOpts(assetIndex, segments.join('/'), stat, urlHash) : (urlHash ? { cacheControl: 'no-cache' } : {});
|
||||
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log, opts);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// server/http/traffic.js — what this server has sent and received since it started (host bandwidth, layer 0):
|
||||
//
|
||||
// * HTTP: per category of shared/assetPaths.js trafficCategory (assets / audio / code / data / other) the responses
|
||||
// and the body bytes actually written (after gzip / brotli; headers not counted), and how many were 304s;
|
||||
// * WebSocket: the connections, the bytes on the wire both ways (the sockets' own counters, after permessage-deflate,
|
||||
// frame headers included, the HTTP upgrade not) and the message payloads before compression — their ratio is what
|
||||
// the compression saves.
|
||||
//
|
||||
// routes.js meters every response (`meter`), websocket.js every socket (`wsOpen` / `wsClose` / `wsSent` / `wsReceived`);
|
||||
// `snapshot()` is the `traffic` object of GET /healthz?traffic=1 (docs/DEPLOY.md §5). Nothing is stored or sent
|
||||
// anywhere else; the counters start at zero with the process.
|
||||
|
||||
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
|
||||
|
||||
/** Byte length of a chunk handed to res.write / res.end (a string in `encoding`, utf8 by default). */
|
||||
function chunkBytes(chunk, encoding) {
|
||||
if (chunk == null || typeof chunk === 'function') return 0;
|
||||
if (typeof chunk === 'string') return Buffer.byteLength(chunk, typeof encoding === 'string' && Buffer.isEncoding(encoding) ? encoding : 'utf8');
|
||||
return typeof chunk.byteLength === 'number' ? chunk.byteLength : 0;
|
||||
}
|
||||
|
||||
/** Length of a ws 'message' payload (a Buffer, an ArrayBuffer or, fragmented, a Buffer list). */
|
||||
function payloadBytes(data) {
|
||||
if (Array.isArray(data)) return data.reduce((n, b) => n + payloadBytes(b), 0);
|
||||
return data && typeof data.byteLength === 'number' ? data.byteLength : typeof data === 'string' ? Buffer.byteLength(data) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A traffic counter for one server.
|
||||
* @param {{ now?: () => number }} [opts]
|
||||
*/
|
||||
export function createTrafficCounter({ now = Date.now } = {}) {
|
||||
const startedAt = now();
|
||||
const http = Object.fromEntries(TRAFFIC_CATEGORIES.map((c) => [c, { requests: 0, bytes: 0, notModified: 0 }]));
|
||||
const ws = { connections: 0, wireOut: 0, wireIn: 0, payloadOut: 0, payloadIn: 0, messagesOut: 0, messagesIn: 0 };
|
||||
/** @type {Map<import('node:net').Socket, { out: number, in: number }>} live sockets and their counters at the upgrade */
|
||||
const live = new Map();
|
||||
|
||||
/** One finished HTTP response. */
|
||||
function record(path, status, bytes) {
|
||||
const c = http[trafficCategory(path)] || http.other;
|
||||
c.requests++;
|
||||
c.bytes += bytes;
|
||||
if (status === 304) c.notModified++;
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Count the body bytes of `res` (every res.write / res.end) under the category of `path`, once the response is
|
||||
* done or its connection gone.
|
||||
* @param {import('node:http').ServerResponse} res
|
||||
* @param {string} path the request's URL path
|
||||
*/
|
||||
meter(res, path) {
|
||||
let bytes = 0;
|
||||
const { write, end } = res;
|
||||
res.write = function meteredWrite(chunk, encoding, cb) {
|
||||
bytes += chunkBytes(chunk, encoding);
|
||||
return write.call(this, chunk, encoding, cb);
|
||||
};
|
||||
res.end = function meteredEnd(chunk, encoding, cb) {
|
||||
bytes += chunkBytes(chunk, encoding);
|
||||
return end.call(this, chunk, encoding, cb);
|
||||
};
|
||||
res.once('close', () => record(path, res.statusCode, bytes));
|
||||
},
|
||||
/** A WebSocket connection opened on `socket` (after its upgrade answer was written). */
|
||||
wsOpen(socket) {
|
||||
ws.connections++;
|
||||
if (socket && !live.has(socket)) live.set(socket, { out: socket.bytesWritten || 0, in: socket.bytesRead || 0 });
|
||||
},
|
||||
/** …and closed: its wire bytes move to the totals. */
|
||||
wsClose(socket) {
|
||||
const base = live.get(socket);
|
||||
if (!base) return;
|
||||
live.delete(socket);
|
||||
ws.wireOut += Math.max(0, (socket.bytesWritten || 0) - base.out);
|
||||
ws.wireIn += Math.max(0, (socket.bytesRead || 0) - base.in);
|
||||
},
|
||||
/** A message handed to ws.send (its payload before compression). */
|
||||
wsSent(data) { ws.messagesOut++; ws.payloadOut += payloadBytes(data); },
|
||||
/** A message received (its payload after inflating). */
|
||||
wsReceived(data) { ws.messagesIn++; ws.payloadIn += payloadBytes(data); },
|
||||
/** The counters now (plain JSON). */
|
||||
snapshot() {
|
||||
let wireOut = ws.wireOut;
|
||||
let wireIn = ws.wireIn;
|
||||
for (const [socket, base] of live) {
|
||||
wireOut += Math.max(0, (socket.bytesWritten || 0) - base.out);
|
||||
wireIn += Math.max(0, (socket.bytesRead || 0) - base.in);
|
||||
}
|
||||
const byCat = Object.fromEntries(Object.entries(http).map(([k, v]) => [k, { ...v }]));
|
||||
const total = Object.values(http).reduce((t, v) => ({ requests: t.requests + v.requests, bytes: t.bytes + v.bytes }), { requests: 0, bytes: 0 });
|
||||
return {
|
||||
since: new Date(startedAt).toISOString(),
|
||||
seconds: Math.max(0, Math.round((now() - startedAt) / 1000)),
|
||||
http: { ...byCat, total },
|
||||
ws: { ...ws, open: live.size, wireOut, wireIn },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
//
|
||||
// * session wiring: SessionRegistry (reconnect tokens) → Lobby (rooms, server/lobby.js) → Network (the socket
|
||||
// protocol, server/net.js), built from the startServer() options (config.js decides which go where);
|
||||
// * WebSocket (ws) at /ws, maxPayload 64 KB, no per-message deflate → Network.handleConnection. Refused at the
|
||||
// upgrade: any other path 404; per-network socket limit for internet clients (maxConnectionsPerAddr, see net.js
|
||||
// * WebSocket (ws) at /ws, maxPayload 64 KB, permessage-deflate (WS_DEFLATE) → Network.handleConnection. Refused at
|
||||
// the upgrade: any other path 404; per-network socket limit for internet clients (maxConnectionsPerAddr, see net.js
|
||||
// clientAddress; local/LAN peers are exempt) 429; server full (maxConnections) or shutting down 503.
|
||||
|
||||
import { WebSocketServer } from 'ws';
|
||||
@@ -15,6 +15,38 @@ import { netOptionsFrom, lobbyOptionsFrom } from './config.js';
|
||||
/** Inbound WebSocket frame limit (DESIGN §8). */
|
||||
export const WS_MAX_PAYLOAD = 64 * 1024;
|
||||
|
||||
/**
|
||||
* permessage-deflate (RFC 7692) for the JSON text frames. A message from 1 KB up (m.public, m.private, b.start, welcome
|
||||
* …) is compressed on its own — no context is kept from one message to the next (`serverNoContextTakeover`) — with a
|
||||
* 2 KB window and zlib memLevel 7, so a connection holds about 72 KB of zlib state; smaller frames (pong, ok) go out
|
||||
* as they are. Measured on a solo session (rounds 3–5, the server's own counters, traffic.js): 173 KB of payload →
|
||||
* 60 KB on the wire; replaying a session's frames with larger windows gained nothing on these messages. Browsers
|
||||
* negotiate it by themselves; a client that offers none gets plain frames. The inflated size of a client frame still
|
||||
* counts against WS_MAX_PAYLOAD.
|
||||
*/
|
||||
export const WS_DEFLATE = Object.freeze({
|
||||
threshold: 1024,
|
||||
serverNoContextTakeover: true,
|
||||
serverMaxWindowBits: 11,
|
||||
zlibDeflateOptions: Object.freeze({ memLevel: 7 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Does every permessage-deflate offer of this `Sec-WebSocket-Extensions` header cap the server window below
|
||||
* WS_DEFLATE.serverMaxWindowBits? ws cannot accept such an offer and refuses the whole handshake (400), so the upgrade
|
||||
* drops the header instead and the connection goes uncompressed. No browser sends `server_max_window_bits`.
|
||||
* @param {string | string[] | undefined} header
|
||||
*/
|
||||
export function narrowDeflateOffer(header) {
|
||||
if (typeof header !== 'string') return false;
|
||||
const offers = header.split(',').map((s) => s.trim()).filter((s) => /^permessage-deflate\b/i.test(s));
|
||||
if (!offers.length) return false;
|
||||
return offers.every((offer) => {
|
||||
const m = /(?:^|;)\s*server_max_window_bits\s*=\s*"?(\d+)"?/i.exec(offer);
|
||||
return !!m && Number(m[1]) < WS_DEFLATE.serverMaxWindowBits;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The session stack of one server.
|
||||
* @param {{ MatchClass?: Function, seedFn?: () => number, [option: string]: any }} opts startServer() options
|
||||
@@ -30,15 +62,37 @@ export function createSessionStack(opts, { data, log }) {
|
||||
return { registry, lobby, network };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a socket in the traffic counters (traffic.js): its wire bytes from now until it closes, the payload of every
|
||||
* message it sends (ws.send wrapped) and receives.
|
||||
* @param {import('ws').WebSocket} ws
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @param {ReturnType<typeof import('./traffic.js').createTrafficCounter>} traffic
|
||||
*/
|
||||
function meterSocket(ws, req, traffic) {
|
||||
const socket = req.socket;
|
||||
traffic.wsOpen(socket);
|
||||
const send = ws.send;
|
||||
ws.send = function meteredSend(data, ...rest) {
|
||||
traffic.wsSent(data);
|
||||
return send.call(this, data, ...rest);
|
||||
};
|
||||
ws.on('message', (data) => traffic.wsReceived(data));
|
||||
ws.once('close', () => traffic.wsClose(socket));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the WebSocket endpoint /ws on `server` (its 'upgrade' event).
|
||||
* @param {import('node:http').Server} server
|
||||
* @param {{ network: Network, log: object }} deps
|
||||
* @param {{ network: Network, log: object, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter> }} deps
|
||||
* @returns {WebSocketServer}
|
||||
*/
|
||||
export function attachWebSocket(server, { network, log }) {
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: false, clientTracking: false });
|
||||
wss.on('connection', (ws, req) => network.handleConnection(ws, req));
|
||||
export function attachWebSocket(server, { network, log, traffic = null }) {
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: { ...WS_DEFLATE }, clientTracking: false });
|
||||
wss.on('connection', (ws, req) => {
|
||||
if (traffic) meterSocket(ws, req, traffic);
|
||||
network.handleConnection(ws, req);
|
||||
});
|
||||
wss.on('error', (e) => log.error('[ws] server error', e));
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
@@ -51,6 +105,7 @@ export function attachWebSocket(server, { network, log }) {
|
||||
const refused = network.admission(req);
|
||||
if (refused === 'per-address') { reject(429, 'Too Many Requests'); return; }
|
||||
if (refused) { reject(503, 'Service Unavailable'); return; }
|
||||
if (narrowDeflateOffer(req.headers['sec-websocket-extensions'])) delete req.headers['sec-websocket-extensions'];
|
||||
try {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
||||
} catch (e) {
|
||||
|
||||
+31
-11
@@ -3,14 +3,20 @@
|
||||
//
|
||||
// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, TRUST_PROXY auto, DEBUG),
|
||||
// which startServer() options go to net.js / lobby.js, the console logger
|
||||
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB;
|
||||
// refused at upgrade with 404 / 429 per network / 503)
|
||||
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB,
|
||||
// permessage-deflate from 1 KB; refused at upgrade with 404 / 429 per network / 503)
|
||||
// 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
|
||||
// (/assets/~<hash>/…, immutable when the hash matches); the committed files stay unchanged
|
||||
// http/buildTag.js the build tag of the served browser runtime (/healthz `build`, public/js/ui/buildGuard.js)
|
||||
// http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static
|
||||
// http/traffic.js the traffic counters: bytes sent per category (assets / audio / code / data) and on the
|
||||
// WebSocket, since the start (GET /healthz?traffic=1)
|
||||
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
|
||||
// http/boot.js a pending update package first (update.js: old files deleted, the install verified against
|
||||
// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
|
||||
@@ -24,20 +30,23 @@
|
||||
|
||||
import http from 'node:http';
|
||||
import { getData, loadData } from './data.js';
|
||||
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy } from './http/config.js';
|
||||
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy, assetIndexOptionsFrom } from './http/config.js';
|
||||
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';
|
||||
import { createAssetIndex } from './http/assetIndex.js';
|
||||
import { createManifestViews } from './http/assetViews.js';
|
||||
import { answerClientError } from './http/common.js';
|
||||
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,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,11 +59,14 @@ export {
|
||||
* ratePerSec?: number, rateBurst?: number, maxConnections?: number, maxRooms?: number,
|
||||
* maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number,
|
||||
* heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: number,
|
||||
* assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number },
|
||||
* }} [opts]
|
||||
* assetIndex: the content-hash index (default on; false or SP_ASSET_INDEX=0: off — config.js assetIndexOptionsFrom)
|
||||
* @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer,
|
||||
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
|
||||
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
|
||||
* close: () => Promise<void> }>}
|
||||
* traffic: ReturnType<typeof createTrafficCounter>, assetIndex: ReturnType<typeof createAssetIndex> | null,
|
||||
* views: ReturnType<typeof createManifestViews> | null, close: () => Promise<void> }>}
|
||||
*/
|
||||
export async function startServer(opts = {}) {
|
||||
const { port, host } = listenAddress(opts);
|
||||
@@ -67,15 +79,20 @@ export async function startServer(opts = {}) {
|
||||
// content packs (docs/PACKS.md): scanned now — the start log names them — and again whenever their folders change
|
||||
const packs = createPackRegistry({ publicDir, dataDir, packsDir }, { log });
|
||||
packs.refresh(true);
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log });
|
||||
// the content hashes of the art (built in the background once listening) and the manifests served with them
|
||||
const indexOptions = assetIndexOptionsFrom(opts, publicDir);
|
||||
const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null;
|
||||
const views = assetIndex ? createManifestViews({ dataDir, index: assetIndex, log }) : null;
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views });
|
||||
const startedAt = Date.now();
|
||||
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
|
||||
resetBuildTag();
|
||||
buildTag();
|
||||
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby }, log }));
|
||||
const traffic = createTrafficCounter();
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log }));
|
||||
server.on('clientError', answerClientError);
|
||||
const wss = attachWebSocket(server, { network, log });
|
||||
const wss = attachWebSocket(server, { network, log, traffic });
|
||||
|
||||
// The address actually bound. The default may fall back to IPv4; the returned host and url follow that.
|
||||
let boundHost;
|
||||
@@ -108,9 +125,11 @@ export async function startServer(opts = {}) {
|
||||
boundHost = bound;
|
||||
} catch (e) {
|
||||
network.close(); // stop heartbeat/sweep timers of the half-built server
|
||||
assetIndex?.close();
|
||||
throw e;
|
||||
}
|
||||
server.on('error', (e) => log.error('[http] server error', e));
|
||||
assetIndex?.start();
|
||||
|
||||
const addr = server.address();
|
||||
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
||||
@@ -122,6 +141,7 @@ export async function startServer(opts = {}) {
|
||||
closing = (async () => {
|
||||
try { lobby.shutdown('shutdown'); } catch (e) { log.error('[shutdown] lobby', e); }
|
||||
network.close();
|
||||
assetIndex?.close();
|
||||
await new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
server.closeIdleConnections?.();
|
||||
@@ -132,7 +152,7 @@ export async function startServer(opts = {}) {
|
||||
return closing;
|
||||
}
|
||||
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, close };
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, close };
|
||||
}
|
||||
|
||||
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Asset URL paths shared by the server and the browser (host bandwidth, docs/ASSETS.md "The served manifests"):
|
||||
//
|
||||
// * the content-hash segment. The server hands out data/assets.json and data/local-assets.json with every file URL
|
||||
// carrying the first 16 hex digits of the file's SHA-256 (of the model's files for a Spine model) as its first
|
||||
// segment — `/assets/char/avatar/x.png` → `/assets/~0123456789abcdef/char/avatar/x.png`, audio at
|
||||
// `/media/~<hash>/…` (shared/media.js) — so a browser may keep it for a year. The server strips the segment and
|
||||
// serves the file at the plain path (server/http/static.js, media.js). A path segment, never `?v=`: several
|
||||
// loaders look at the URL's ending (`.skel`, `.mp3`) and pixi-spine finds the atlas and its pages next to the
|
||||
// skeleton, which a query string would not survive.
|
||||
// * trafficCategory: the category a request path counts under in the traffic counters (server/http/traffic.js,
|
||||
// /healthz?traffic=1; public/js/diag.js for the browser's own totals).
|
||||
|
||||
/** Hex digits of a content hash in a URL. */
|
||||
export const ASSET_HASH_HEX = 16;
|
||||
|
||||
/** The content-hash segment: `~` + ASSET_HASH_HEX lower-case hex digits. */
|
||||
export const HASH_SEGMENT_RE = /^~([0-9a-f]{16})$/;
|
||||
|
||||
/** URL roots whose first segment may be a content hash. */
|
||||
export const HASHED_ROOTS = Object.freeze(['/assets/', '/fonts/', '/media/']);
|
||||
|
||||
/**
|
||||
* Split the content-hash segment off a URL path.
|
||||
* '/assets/~0123456789abcdef/x.png' → { hash: '0123456789abcdef', path: '/assets/x.png' }; any other path (no segment,
|
||||
* a malformed one, another root) → { hash: null, path } unchanged.
|
||||
* @param {string} path a URL path (no query)
|
||||
* @returns {{ hash: string | null, path: string }}
|
||||
*/
|
||||
export function splitHashedPath(path) {
|
||||
const p = String(path ?? '');
|
||||
for (const root of HASHED_ROOTS) {
|
||||
if (!p.startsWith(root)) continue;
|
||||
const slash = p.indexOf('/', root.length);
|
||||
if (slash < 0) break;
|
||||
const m = HASH_SEGMENT_RE.exec(p.slice(root.length, slash));
|
||||
return m ? { hash: m[1], path: root + p.slice(slash + 1) } : { hash: null, path: p };
|
||||
}
|
||||
return { hash: null, path: p };
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL path with the content-hash segment after its root ('/assets/x.png', 'abc…' → '/assets/~abc…/x.png'). A
|
||||
* path outside HASHED_ROOTS, one that already has a segment, or no valid hash → the path unchanged.
|
||||
* @param {string} path
|
||||
* @param {string | null | undefined} hash
|
||||
*/
|
||||
export function hashedPath(path, hash) {
|
||||
const p = String(path ?? '');
|
||||
if (typeof hash !== 'string' || !HASH_SEGMENT_RE.test(`~${hash}`)) return p;
|
||||
const root = HASHED_ROOTS.find((r) => p.startsWith(r));
|
||||
if (!root || p.length === root.length || splitHashedPath(p).hash) return p;
|
||||
return `${root}~${hash}/${p.slice(root.length)}`;
|
||||
}
|
||||
|
||||
/** The URL with its content-hash segment removed (a URL derived from a hashed one names another file). */
|
||||
export const unhashedUrl = (url) => (typeof url === 'string' ? splitHashedPath(url).path : url);
|
||||
|
||||
/** Traffic categories, in report order. */
|
||||
export const TRAFFIC_CATEGORIES = Object.freeze(['assets', 'audio', 'code', 'data', 'other']);
|
||||
|
||||
/**
|
||||
* The traffic category of a request path (the content-hash segment does not matter): `audio` (/media/,
|
||||
* /assets/audio/), `assets` (/assets/, /fonts/, /icons/, the art of a content pack), `data` (/data/*.json, the UI
|
||||
* language packs /i18n/, the pack index, other JSON), `code` (the page, scripts, styles: /, *.js, *.css, *.html,
|
||||
* /vendor/, /sim/, /shared/), `other` (/healthz, error pages for anything else).
|
||||
* @param {string} path a URL path (no query)
|
||||
* @returns {'assets'|'audio'|'code'|'data'|'other'}
|
||||
*/
|
||||
export function trafficCategory(path) {
|
||||
const p = splitHashedPath(path).path;
|
||||
if (p.startsWith('/media/') || p.startsWith('/assets/audio/')) return 'audio';
|
||||
if (p.startsWith('/assets/') || p.startsWith('/fonts/') || p.startsWith('/icons/')) return 'assets';
|
||||
if (p.startsWith('/packs/')) return /\.json$/i.test(p) ? 'data' : 'assets';
|
||||
if (p.startsWith('/data/') || p.startsWith('/i18n/') || /\.json$/i.test(p)) return 'data';
|
||||
if (p === '/' || p.endsWith('/') || /\.(?:m?js|css|html?|map|webmanifest)$/i.test(p)
|
||||
|| p.startsWith('/vendor/') || p.startsWith('/sim/') || p.startsWith('/shared/')) return 'code';
|
||||
return 'other';
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { N_ } from './i18n.js';
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
/** Release version shown to players (title screen, server banner, /healthz). Kept equal to package.json "version"
|
||||
* (test/version.test.js); PROTOCOL_VERSION above is the separate wire-format number. */
|
||||
export const APP_VERSION = '0.2.5';
|
||||
export const APP_VERSION = '0.2.6-dev';
|
||||
/** A development build (the public `dev` branch): the title screen, the boot banner and the README say so, and
|
||||
* tools/package.mjs refuses to build a release zip from it without --allow-dev. */
|
||||
export const DEV_BUILD = /-dev$/.test(APP_VERSION);
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@
|
||||
// /assets/audio/bgm/act1.mp3 → /media/bgm/act1
|
||||
//
|
||||
// — and `server/http/media.js` resolves it back to the real file under public/assets/audio, still answering with
|
||||
// `Content-Type: audio/mpeg`, Range support and the ETag of the file it resolved.
|
||||
// `Content-Type: audio/mpeg`, Range support and the ETag of the file it resolved. The content-hash segment of a served
|
||||
// manifest's URL (shared/assetPaths.js) comes along: `/assets/~<hash>/audio/bgm/act1.mp3` → `/media/~<hash>/bgm/act1`,
|
||||
// cached by the browser for a year while the hash names the file.
|
||||
//
|
||||
// Both sides import this module: the browser to decide which URLs to rewrite (`public/js/media.js`), the server to
|
||||
// decide which extensions a `/media/…` request may resolve to. A second copy of the list on one side would let them
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
// 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;
|
||||
// * 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-'));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const p = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, content);
|
||||
}
|
||||
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', () => {
|
||||
test('hashes every file under assets/ and fonts/ (dot names skipped, a linked folder followed), persists them, rehashes only what changed', async () => {
|
||||
const a = randomBytes(2000);
|
||||
const b = Buffer.from('atlas text\n'.repeat(50));
|
||||
const dir = tree({
|
||||
'public/assets/char/a.png': a, 'public/assets/spine/x/x.atlas': b, 'public/assets/.hidden': 'h', 'public/assets/.git/obj': 'o',
|
||||
'public/fonts/f.woff2': 'font', 'public/js/app.js': 'not indexed', 'elsewhere/linked/l.png': 'linked',
|
||||
});
|
||||
fs.symlinkSync(path.join(dir, 'elsewhere/linked'), path.join(dir, 'public/assets/link'), 'dir');
|
||||
const cacheFile = path.join(dir, '.cache/asset-hashes.json');
|
||||
try {
|
||||
const idx = createAssetIndex({ publicDir: path.join(dir, 'public'), cacheFile, delayMs: 0 });
|
||||
assert.equal(idx.ready, false);
|
||||
idx.start();
|
||||
await idx.whenReady();
|
||||
assert.equal(idx.ready, true);
|
||||
const st = (rel) => fs.statSync(path.join(dir, 'public', rel));
|
||||
assert.equal(idx.hashOf('assets/char/a.png', st('assets/char/a.png')), sha16(a));
|
||||
assert.equal(idx.hashOf('assets/spine/x/x.atlas'), sha16(b));
|
||||
assert.equal(idx.hashOf('fonts/f.woff2'), sha16(Buffer.from('font')));
|
||||
assert.equal(idx.hashOf('assets/link/l.png'), sha16(Buffer.from('linked')), 'a linked folder is followed');
|
||||
assert.equal(idx.hashOf('assets/.hidden'), null, 'dot names are never served, never hashed');
|
||||
assert.equal(idx.hashOf('assets/.git/obj'), null);
|
||||
assert.equal(idx.hashOf('js/app.js'), null, 'only the asset folders');
|
||||
assert.equal(idx.size, 4);
|
||||
assert.equal(idx.stats().last.hashed, 4);
|
||||
assert.equal(idx.hashOf('assets/CHAR/A.PNG'), sha16(a), 'a name that differs only in case finds the file');
|
||||
// a stat that no longer matches: no hash (the caller serves the plain way)
|
||||
assert.equal(idx.hashOf('assets/char/a.png', { size: 1, mtimeMs: st('assets/char/a.png').mtimeMs }), null);
|
||||
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
assert.equal(cached.version, 1);
|
||||
assert.deepEqual(cached.files['assets/char/a.png'], [a.length, st('assets/char/a.png').mtimeMs, sha16(a)]);
|
||||
idx.close();
|
||||
|
||||
// a restart: the cache answers for the unchanged files; a changed file is hashed again; a removed one is dropped
|
||||
const a2 = randomBytes(2100);
|
||||
fs.writeFileSync(path.join(dir, 'public/assets/char/a.png'), a2);
|
||||
fs.rmSync(path.join(dir, 'public/fonts/f.woff2'));
|
||||
const idx2 = createAssetIndex({ publicDir: path.join(dir, 'public'), cacheFile, delayMs: 0 });
|
||||
idx2.start();
|
||||
await idx2.whenReady();
|
||||
assert.equal(idx2.stats().last.hashed, 1, 'only the changed file was read');
|
||||
assert.equal(idx2.stats().last.reused, 2);
|
||||
assert.equal(idx2.hashOf('assets/char/a.png'), sha16(a2));
|
||||
assert.equal(idx2.hashOf('fonts/f.woff2'), null);
|
||||
assert.equal(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).files['fonts/f.woff2'], undefined);
|
||||
idx2.close();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('models: one hash for a skeleton, its atlas and pages; urlHashOk accepts the own or the model hash, nothing else', async () => {
|
||||
const dir = tree({ 'public/assets/s/x.skel': 'skel', 'public/assets/s/x.atlas': 'atlas', 'public/assets/s/x.png': 'png', 'public/assets/o.png': 'other' });
|
||||
try {
|
||||
const idx = createAssetIndex({ publicDir: path.join(dir, 'public'), delayMs: 0 });
|
||||
idx.start();
|
||||
await idx.whenReady();
|
||||
const trio = ['assets/s/x.skel', 'assets/s/x.atlas', 'assets/s/x.png'];
|
||||
const m = idx.modelHash(trio);
|
||||
assert.equal(m, hash16(['skel', 'atlas', 'png'].map((s) => sha16(Buffer.from(s))).join('\n')));
|
||||
assert.equal(idx.modelHash([...trio, 'assets/missing.png']), null, 'a member the index does not know: no model hash');
|
||||
const st = (rel) => fs.statSync(path.join(dir, 'public', rel));
|
||||
assert.equal(idx.urlHashOk('assets/s/x.atlas', st('assets/s/x.atlas'), m), false, 'not before the lists are known');
|
||||
idx.setModels([trio]);
|
||||
for (const rel of trio) assert.equal(idx.urlHashOk(rel, st(rel), m), true, rel);
|
||||
assert.equal(idx.urlHashOk('assets/s/x.png', st('assets/s/x.png'), sha16(Buffer.from('png'))), true, 'the own hash too');
|
||||
assert.equal(idx.urlHashOk('assets/o.png', st('assets/o.png'), m), false, 'not a member');
|
||||
assert.equal(idx.urlHashOk('assets/s/x.png', st('assets/s/x.png'), '0000000000000000'), false);
|
||||
assert.equal(idx.urlHashOk('assets/s/x.png', { size: 99, mtimeMs: 1 }, m), false, 'changed since: never immutable');
|
||||
idx.close();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a served file the index no longer matches asks for a rescan (rate-limited); the new hash follows', async () => {
|
||||
const dir = tree({ 'public/assets/a.png': 'one' });
|
||||
try {
|
||||
let changes = 0;
|
||||
const idx = createAssetIndex({ publicDir: path.join(dir, 'public'), delayMs: 0, rescanGapMs: 50 });
|
||||
idx.onChange(() => changes++);
|
||||
idx.start();
|
||||
await idx.whenReady();
|
||||
assert.equal(changes, 1);
|
||||
const gen = idx.generation;
|
||||
fs.writeFileSync(path.join(dir, 'public/assets/a.png'), 'two!');
|
||||
assert.equal(idx.hashOf('assets/a.png', fs.statSync(path.join(dir, 'public/assets/a.png'))), null);
|
||||
idx.noteMiss('assets/a.png');
|
||||
idx.noteMiss('assets/a.png');
|
||||
assert.ok(await until(() => idx.generation > gen), 'rescanned');
|
||||
assert.equal(idx.hashOf('assets/a.png'), sha16(Buffer.from('two!')));
|
||||
assert.equal(changes, 2, 'one rescan for both misses');
|
||||
idx.close();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// shared/assetPaths.js: the content-hash segment of asset URLs (split / insert / strip) and the traffic categories the
|
||||
// server's counters and the browser's diagnostics share (host bandwidth, layers 0 and 1).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { splitHashedPath, hashedPath, unhashedUrl, trafficCategory, TRAFFIC_CATEGORIES, ASSET_HASH_HEX, HASH_SEGMENT_RE } from '../shared/assetPaths.js';
|
||||
|
||||
const H = '0123456789abcdef';
|
||||
|
||||
test('the hash segment: split off, inserted after the root, removed again', () => {
|
||||
assert.equal(ASSET_HASH_HEX, 16);
|
||||
assert.ok(HASH_SEGMENT_RE.test(`~${H}`));
|
||||
assert.deepEqual(splitHashedPath(`/assets/~${H}/char/avatar/a.png`), { hash: H, path: '/assets/char/avatar/a.png' });
|
||||
assert.deepEqual(splitHashedPath(`/fonts/~${H}/f.woff2`), { hash: H, path: '/fonts/f.woff2' });
|
||||
assert.deepEqual(splitHashedPath(`/media/~${H}/bgm/a`), { hash: H, path: '/media/bgm/a' });
|
||||
for (const p of ['/assets/char/a.png', `/assets/~${H.toUpperCase()}/a.png`, `/assets/~${H}0/a.png`, `/assets/~${H}`, `/data/~${H}/x.json`,
|
||||
`/assets/x/~${H}/a.png`, '/assets/~zz/a.png', '']) {
|
||||
assert.deepEqual(splitHashedPath(p), { hash: null, path: p }, p);
|
||||
}
|
||||
assert.equal(hashedPath('/assets/char/a.png', H), `/assets/~${H}/char/a.png`);
|
||||
assert.equal(hashedPath('/media/bgm/a', H), `/media/~${H}/bgm/a`);
|
||||
assert.equal(hashedPath('/fonts/f.woff2', H), `/fonts/~${H}/f.woff2`);
|
||||
assert.equal(hashedPath('/data/assets.json', H), '/data/assets.json', 'not a hashed root');
|
||||
assert.equal(hashedPath('/assets/a.png', null), '/assets/a.png');
|
||||
assert.equal(hashedPath('/assets/a.png', 'nothex'), '/assets/a.png');
|
||||
assert.equal(hashedPath(`/assets/~${H}/a.png`, 'fedcba9876543210'), `/assets/~${H}/a.png`, 'never two segments');
|
||||
assert.equal(hashedPath('/assets/', H), '/assets/');
|
||||
assert.equal(unhashedUrl(`/assets/~${H}/audio/sfx/b_char_dead.mp3`), '/assets/audio/sfx/b_char_dead.mp3');
|
||||
assert.equal(unhashedUrl(null), null);
|
||||
});
|
||||
|
||||
test('traffic categories: audio, assets, code, data, other — the hash segment does not matter', () => {
|
||||
assert.deepEqual([...TRAFFIC_CATEGORIES], ['assets', 'audio', 'code', 'data', 'other']);
|
||||
const cases = {
|
||||
'/media/bgm/act1': 'audio', [`/media/~${H}/bgm/act1`]: 'audio', '/assets/audio/bgm/a.mp3': 'audio', [`/assets/~${H}/audio/sfx/a.mp3`]: 'audio',
|
||||
'/assets/char/avatar/a.png': 'assets', [`/assets/~${H}/spine/op/x/front/x.skel`]: 'assets', '/fonts/f.woff2': 'assets', '/icons/app.svg': 'assets',
|
||||
'/packs/foo/art/a.png': 'assets', '/packs/index.json': 'data', '/data/assets.json': 'data', '/data/i18n/en.json': 'data', '/i18n/en.json': 'data',
|
||||
'/manifest.json': 'data', '/': 'code', '/index.html': 'code', '/js/main.js': 'code', '/css/theme.css': 'code', '/vendor/pixi.min.js': 'code',
|
||||
'/sim/Battle.js': 'code', '/shared/protocol.js': 'code', '/data.js': 'code', '/healthz': 'other', '/favicon.ico': 'other',
|
||||
};
|
||||
for (const [p, cat] of Object.entries(cases)) assert.equal(trafficCategory(p), cat, p);
|
||||
});
|
||||
@@ -111,6 +111,11 @@ test('#4 audio classes (official ON_UNIT_DEAD/BORN defaults): only operators pla
|
||||
assert.equal(deathSfxUrl(manifest, bomb, { consumed: true }), null, 'used up ⇒ silent');
|
||||
assert.equal(deathSfxUrl(manifest, mapChar), SFX.battle.charDie, 'band map characters are operators');
|
||||
assert.equal(deathSfxUrl(manifest, crate), TOKEN_DEAD, 'act crate trap_1105 ⇒ b_char_tokendead');
|
||||
// a served manifest's charDie carries charDie's content hash (host bandwidth layer 1): the sound derived beside it
|
||||
// names another file, so it goes out without the segment (served plainly, cached a day) instead of a wrong hash
|
||||
const hashed = { ...manifest, audio: { ...manifest.audio, sfx: { ...SFX, battle: { ...SFX.battle, charDie: SFX.battle.charDie.replace(/^\/assets\//, '/assets/~0123456789abcdef/') } } } };
|
||||
assert.equal(deathSfxUrl(hashed, op), hashed.audio.sfx.battle.charDie);
|
||||
assert.equal(deathSfxUrl(hashed, tok), TOKEN_DEAD, 'b_char_tokendead without charDie\'s hash');
|
||||
assert.equal(deathSfxUrl(manifest, enemy), SFX.battle.enemyDie);
|
||||
assert.equal(deathSfxUrl(manifest, boss), SFX.battle.enemyDieHeavy);
|
||||
assert.equal(deathSfxUrl(manifest, op, { reason: 'retreat' }), null, 'a retreat is no knock-down');
|
||||
|
||||
+51
-1
@@ -5,7 +5,7 @@ import { test, describe, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
recordError, errorLog, errorCount, clearErrorLog, messageOf, stackOf, stripHosts, redact, redactValues, secretsOf, buildDiagnostics,
|
||||
setBattleSource, currentBattle, LOG_MAX, STACK_LINES,
|
||||
setBattleSource, currentBattle, LOG_MAX, STACK_LINES, recordResourceEntry, trafficSummary, trafficLine, clearTraffic, installTrafficMeter,
|
||||
} from '../public/js/diag.js';
|
||||
import { APP_VERSION } from '../shared/constants.js';
|
||||
|
||||
@@ -184,3 +184,53 @@ describe('the report', () => {
|
||||
setBattleSource(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page\'s traffic (host bandwidth layer 0)', () => {
|
||||
beforeEach(() => clearTraffic());
|
||||
const ORIGIN = 'http://192.168.1.5:3000';
|
||||
|
||||
test('Resource Timing entries sum by category: bytes on the wire, a cache hit counted as cached, other sites apart', () => {
|
||||
recordResourceEntry({ name: `${ORIGIN}/assets/~0123456789abcdef/spine/op/x/front/x.skel`, transferSize: 120000, decodedBodySize: 400000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/assets/char/avatar/a.png`, transferSize: 0, decodedBodySize: 50000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/media/~0123456789abcdef/bgm/m_bat`, transferSize: 2000000, decodedBodySize: 2000000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/js/main.js`, transferSize: 9000, decodedBodySize: 30000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/data/assets.json`, transferSize: 300, decodedBodySize: 1300000 }, ORIGIN);
|
||||
recordResourceEntry({ name: 'https://fonts.gstatic.com/s/x.woff2', transferSize: 50000 }, ORIGIN);
|
||||
recordResourceEntry({ name: 'data:image/png;base64,AAAA', transferSize: 0 }, ORIGIN);
|
||||
recordResourceEntry({ name: 'blob:http://192.168.1.5:3000/1234', transferSize: 0 }, ORIGIN);
|
||||
recordResourceEntry(null, ORIGIN);
|
||||
const t = trafficSummary();
|
||||
assert.deepEqual(t.assets, { requests: 2, bytes: 120000, cached: 1 });
|
||||
assert.deepEqual(t.audio, { requests: 1, bytes: 2000000, cached: 0 });
|
||||
assert.deepEqual(t.code, { requests: 1, bytes: 9000, cached: 0 });
|
||||
assert.deepEqual(t.data, { requests: 1, bytes: 300, cached: 0 }, 'a 304 costs its headers only');
|
||||
assert.deepEqual(t.external, { requests: 1, bytes: 50000, cached: 0 });
|
||||
assert.deepEqual(t.total, { requests: 5, bytes: 2129300, cached: 1 }, 'the host total leaves other sites out');
|
||||
assert.deepEqual(Object.keys(t), ['assets', 'audio', 'code', 'data', 'external', 'total']);
|
||||
assert.equal(trafficLine(t), 'assets 120 KB (2, 1 cached) | audio 2.0 MB (1) | code 9 KB (1) | data 300 B (1) | external 50 KB (1) | host total 2.1 MB (5)');
|
||||
});
|
||||
|
||||
test('the report carries the traffic line; nothing recorded → no line', () => {
|
||||
recordResourceEntry({ name: `${ORIGIN}/js/main.js`, transferSize: 1500 }, ORIGIN);
|
||||
const text = buildDiagnostics({ state: {}, now: 0, pageStart: 0, traffic: trafficSummary() });
|
||||
assert.match(text, /\ntraffic: code 2 KB \(1\) \| host total 2 KB \(1\)\n/);
|
||||
clearTraffic();
|
||||
assert.ok(!/traffic:/.test(buildDiagnostics({ state: {}, now: 0, pageStart: 0, traffic: trafficSummary() })));
|
||||
assert.ok(!/traffic:/.test(buildDiagnostics({ state: {}, now: 0, pageStart: 0 })));
|
||||
});
|
||||
|
||||
test('installTrafficMeter reads the navigation entry and observes the resources (buffered), once; false without the API', () => {
|
||||
let observed = null;
|
||||
class PO {
|
||||
constructor(fn) { this.fn = fn; }
|
||||
observe(o) { observed = o; this.fn({ getEntries: () => [{ name: `${ORIGIN}/vendor/pixi.min.js`, transferSize: 140000 }] }); }
|
||||
}
|
||||
const win = { PerformanceObserver: PO, location: { origin: ORIGIN }, performance: { getEntriesByType: (t) => (t === 'navigation' ? [{ name: `${ORIGIN}/`, transferSize: 7000 }] : []) } };
|
||||
assert.equal(installTrafficMeter({}), false);
|
||||
assert.equal(installTrafficMeter(win), true);
|
||||
assert.deepEqual(observed, { type: 'resource', buffered: true });
|
||||
assert.deepEqual(trafficSummary().code, { requests: 2, bytes: 147000, cached: 0 });
|
||||
assert.equal(installTrafficMeter(win), true, 'already installed');
|
||||
assert.equal(trafficSummary().code.requests, 2, 'not twice');
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,13 @@ export async function startRealServer(opts = {}) {
|
||||
if (Date.now() - t0 > 20000) { child.kill('SIGKILL'); throw new Error(`server did not start:\n${logs.join('\n')}`); }
|
||||
await sleep(150);
|
||||
}
|
||||
// the content-hash index (server/http/assetIndex.js, built ~1 s after the start): wait for it, so a page loads the
|
||||
// served manifest's /assets/~<hash>/… URLs like a player's would (none: SP_ASSET_INDEX=0); bounded, never fatal
|
||||
for (const t1 = Date.now(); Date.now() - t1 < 20000;) {
|
||||
const h = await fetch(`${base}/healthz?traffic=1`).then((r) => r.json(), () => null);
|
||||
if (!h || !h.assetHashes || h.assetHashes.ready) break;
|
||||
await sleep(150);
|
||||
}
|
||||
return {
|
||||
base, port, logs,
|
||||
// `{ hard: true }`: SIGKILL at once — a crash: no room.closed 'shutdown' reaches the clients
|
||||
|
||||
+59
-12
@@ -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));
|
||||
@@ -138,7 +141,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 () => {
|
||||
@@ -161,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);
|
||||
@@ -206,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 () => {
|
||||
@@ -225,10 +255,16 @@ 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)
|
||||
// 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');
|
||||
@@ -391,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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -209,6 +209,47 @@ print(json.dumps(e.prefab_node(go)))`);
|
||||
const raw = spawnSync(PY, ['-c', `import sys; sys.path.insert(0, ${JSON.stringify(TOOL)}); import extract as e, json; print(json.dumps(e.vec(type('V', (), {'x': -0.0, 'y': float('inf'), 'z': 1e-9})())))`], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(raw.stdout.trim(), '[0.0, 0, 0.0]');
|
||||
});
|
||||
|
||||
test('guide pages (host bandwidth layer 0): --webp writes a lossless WebP copy of every page PNG, the manifest lists it, the PNG stays',
|
||||
{ skip: spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status !== 0 && 'no Pillow with WebP' }, () => {
|
||||
const jobs = JSON.parse(spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV }).stdout);
|
||||
assert.deepEqual(jobs.guideWebp.sub, 'guide');
|
||||
assert.equal(jobs.guideWebp.mode, 'lossless', 'lossy pages wait for the maintainer\'s comparison');
|
||||
assert.ok(!jobs.webp.some((w) => w.sub === 'guide'), 'the board texture table is unchanged');
|
||||
const out = py(`
|
||||
import tempfile, pathlib
|
||||
from PIL import Image
|
||||
root = pathlib.Path(tempfile.mkdtemp())
|
||||
sub = root / 'guide'
|
||||
sub.mkdir()
|
||||
for i, name in enumerate(['autochess_home_1', 'autochess_shop_2']):
|
||||
img = Image.new('RGBA', (48, 32), (10, 20, 30, 255))
|
||||
for x in range(48):
|
||||
for y in range(32): img.putpixel((x, y), ((x * 5 + i * 40) % 256, (y * 7) % 256, (x * y) % 256, 255 if x % 7 else 254))
|
||||
img.save(sub / f'{name}.png')
|
||||
groups = {'guide': {n: {'path': f'/assets/local/guide/{n}.png', 'w': 48, 'h': 32, 'kind': 'Sprite'} for n in ['autochess_home_1', 'autochess_shop_2']},
|
||||
'ui/battle': {'x': {'path': '/assets/local/ui/battle/x.png', 'w': 2, 'h': 2, 'kind': 'Sprite'}}}
|
||||
groups['guide']['autochess_handbook_9'] = {'path': '/assets/local/guide/autochess_handbook_9.png', 'kind': 'Sprite'} # no PNG on disk
|
||||
mf = root / 'local-assets.json'
|
||||
mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 4, 'groups': groups}))
|
||||
code = e.webp_only(root, mf, lambda *a: None)
|
||||
doc = json.loads(mf.read_text())
|
||||
g = doc['groups']['guide']
|
||||
px = lambda p: list(Image.open(p).convert('RGBA').getdata())
|
||||
print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': {k: v.get('webp') for k, v in g.items()},
|
||||
'other': doc['groups']['ui/battle']['x']['path'],
|
||||
'same': all(px(sub / f'{n}.png') == px(sub / f'{n}.webp') for n in ['autochess_home_1', 'autochess_shop_2']),
|
||||
'png': all((sub / f'{n}.png').exists() for n in ['autochess_home_1', 'autochess_shop_2']),
|
||||
'again': e.run_webp(root, 'guide', doc['groups'], lambda *a: None)}))`);
|
||||
assert.equal(out.code, 0);
|
||||
const at = (n, ext) => `/assets/local/guide/${n}.${ext}`;
|
||||
assert.deepEqual(out.paths, { autochess_home_1: at('autochess_home_1', 'webp'), autochess_shop_2: at('autochess_shop_2', 'webp'), autochess_handbook_9: at('autochess_handbook_9', 'png') });
|
||||
assert.deepEqual(out.modes, { autochess_home_1: 'lossless', autochess_shop_2: 'lossless', autochess_handbook_9: null });
|
||||
assert.equal(out.other, '/assets/local/ui/battle/x.png', 'other groups keep their PNGs');
|
||||
assert.ok(out.same, 'lossless: the same pixels, alpha included');
|
||||
assert.ok(out.png, 'the PNG stays beside the copy');
|
||||
assert.equal(out.again, 2, 'a second run (another GUIDE_WEBP_MODE) rewrites the copies the manifest already lists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extracted board-scene files (when a local client was extracted)', { skip: !manifest && 'no data/local-assets.json' }, () => {
|
||||
|
||||
@@ -23,6 +23,16 @@ describe('mediaUrl: 音频地址去掉扩展名(躲开下载器嗅探)', ()
|
||||
assert.equal(mediaUrl('/assets/audio/x/A.MP3', ORIGIN), '/media/x/A', '大小写不敏感');
|
||||
});
|
||||
|
||||
test('服务器下发的清单带内容哈希段(/assets/~<hash>/audio/…):改写后保留这一段', () => {
|
||||
const H = '0123456789abcdef';
|
||||
assert.equal(mediaUrl(`/assets/~${H}/audio/bgm/m_sys_act1autochess_loop.mp3`, ORIGIN), `/media/~${H}/bgm/m_sys_act1autochess_loop`);
|
||||
assert.equal(mediaUrl(`${ORIGIN}/assets/~${H}/audio/sfx/a.mp3`, ORIGIN), `/media/~${H}/sfx/a`);
|
||||
assert.doesNotMatch(mediaUrl(`/assets/~${H}/audio/bgm/a.mp3`, ORIGIN), /\.(mp3|m4a|aac|ogg|oga|opus|wav)(\?|$)/i);
|
||||
// 不是合法的哈希段(大写、长度不对)就不是我们的音频地址,原样返回
|
||||
assert.equal(mediaUrl(`/assets/~${H.toUpperCase()}/audio/a.mp3`, ORIGIN), `/assets/~${H.toUpperCase()}/audio/a.mp3`);
|
||||
assert.equal(mediaUrl(`/assets/~${H}/char/a.png`, ORIGIN), `/assets/~${H}/char/a.png`, '图片不改写');
|
||||
});
|
||||
|
||||
test('改写后的地址里不再有任何媒体扩展名(下载器就是按这个嗅探的)', () => {
|
||||
const out = mediaUrl('/assets/audio/bgm/m_bat_vtlionk_loop.mp3', ORIGIN);
|
||||
assert.equal(out.startsWith(MEDIA_PREFIX), true);
|
||||
|
||||
@@ -23,6 +23,8 @@ describe('render engine in headless Chrome', { skip }, () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
// the content-hash index first: every page then loads the served manifest's /assets/~<hash>/… URLs (ASSETS.md)
|
||||
await srv.assetIndex?.whenReady();
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
@@ -73,6 +75,39 @@ describe('render engine in headless Chrome', { skip }, () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('Spine models load through the served manifest\'s content-hashed URLs, cached immutably (host bandwidth layer 1)', async (t) => {
|
||||
if (!srv.assetIndex) { t.skip('SP_ASSET_INDEX=0: no content hashes'); return; }
|
||||
const page = await browser.newPage();
|
||||
const spine = [];
|
||||
const problems = [];
|
||||
page.on('response', (r) => {
|
||||
const u = new URL(r.url());
|
||||
if (/\.(skel|atlas)$/.test(u.pathname) || (/\/spine\//.test(u.pathname) && /\.png$/.test(u.pathname))) spine.push({ path: u.pathname, status: r.status(), cache: r.headers()['cache-control'] });
|
||||
if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`);
|
||||
});
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
try {
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&t=18&panel=0`);
|
||||
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
|
||||
await wait(3500);
|
||||
const st = await page.evaluate(() => window.__demo.stats());
|
||||
assert.ok(st.units > 0);
|
||||
assert.equal(st.spine.failed, 0, 'no Spine load failures');
|
||||
assert.ok(spine.length >= 6, `Spine files requested: ${spine.length}`);
|
||||
for (const s of spine) {
|
||||
assert.match(s.path, /^\/assets\/~[0-9a-f]{16}\//, `${s.path}: a content-hashed URL`);
|
||||
assert.equal(s.status, 200, s.path);
|
||||
assert.equal(s.cache, 'public, max-age=31536000, immutable', `${s.path}: skeleton, atlas and pages share the model hash`);
|
||||
}
|
||||
// the atlas pixi-spine derived from each skeleton came under the skeleton's segment
|
||||
for (const sk of spine.filter((s) => s.path.endsWith('.skel'))) {
|
||||
assert.ok(spine.some((s) => s.path === sk.path.replace(/\.skel$/, '.atlas')), `${sk.path}: its atlas`);
|
||||
}
|
||||
assert.deepEqual(problems, []);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('Swire coins from a real snapshot remain separate from the elemental gauge', async () => {
|
||||
const { makeBattle } = await import('../helpers/battleHarness.js');
|
||||
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 10, col: 4, skillIndex: 1 }] });
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// The server's traffic counters (server/http/traffic.js, GET /healthz?traffic=1; host bandwidth layer 0): the body
|
||||
// bytes every HTTP response wrote, by category, 304s apart, and the WebSocket's wire and payload bytes both ways.
|
||||
import { test } 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 { randomBytes } from 'node:crypto';
|
||||
import { startServer } from '../server/index.js';
|
||||
import { createTrafficCounter } from '../server/http/traffic.js';
|
||||
import { TestClient } from './helpers/wsClient.js';
|
||||
|
||||
function get(port, p, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: '127.0.0.1', port, path: p, headers, 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();
|
||||
});
|
||||
}
|
||||
|
||||
test('HTTP: body bytes by category as written (compressed), 304s counted, /healthz reports them only when asked', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-traffic-'));
|
||||
const w = (rel, content) => { fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true }); fs.writeFileSync(path.join(tmp, rel), content); };
|
||||
const js = `// app\n${'export const x = 1;\n'.repeat(400)}`;
|
||||
const png = randomBytes(3000);
|
||||
const mp3 = randomBytes(5000);
|
||||
w('public/index.html', '<!doctype html><p>hi</p>');
|
||||
w('public/js/app.js', js);
|
||||
w('public/assets/img/a.png', png);
|
||||
w('public/assets/audio/bgm/a.mp3', mp3);
|
||||
w('data/config.json', JSON.stringify({ a: 1 }));
|
||||
w('shared/x.js', 'export const y = 2;');
|
||||
const 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') });
|
||||
try {
|
||||
const plain = await get(srv.port, '/health');
|
||||
assert.equal(plain.status, 404);
|
||||
const gz = await get(srv.port, '/js/app.js', { 'accept-encoding': 'gzip' });
|
||||
assert.ok(gz.body.length < js.length, 'compressed');
|
||||
await get(srv.port, '/js/app.js', { 'if-none-match': (await get(srv.port, '/js/app.js')).headers.etag });
|
||||
await get(srv.port, '/assets/img/a.png');
|
||||
await get(srv.port, '/media/bgm/a');
|
||||
await get(srv.port, '/data/config.json');
|
||||
const before = await get(srv.port, '/healthz');
|
||||
assert.equal(JSON.parse(before.body).traffic, undefined, 'plain /healthz stays as it was');
|
||||
const t = JSON.parse((await get(srv.port, '/healthz?traffic=1')).body).traffic;
|
||||
assert.equal(t.http.code.requests, 3);
|
||||
assert.equal(t.http.code.bytes, gz.body.length + js.length, 'what was written: the gzip body, the plain body, nothing for the 304');
|
||||
assert.equal(t.http.code.notModified, 1);
|
||||
assert.deepEqual(t.http.assets, { requests: 1, bytes: png.length, notModified: 0 });
|
||||
assert.deepEqual(t.http.audio, { requests: 1, bytes: mp3.length, notModified: 0 });
|
||||
assert.equal(t.http.data.requests, 1);
|
||||
assert.equal(t.http.other.requests, 2, 'the 404 page and the first /healthz');
|
||||
assert.ok(t.http.other.bytes > 0);
|
||||
assert.equal(t.http.total.requests, 8);
|
||||
assert.equal(typeof t.since, 'string');
|
||||
assert.equal(srv.traffic.snapshot().http.total.requests, 9, 'the traffic request itself once it has finished');
|
||||
} finally {
|
||||
await srv.close();
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('WebSocket: wire bytes both ways (after permessage-deflate) and the payloads, live and after the close', async () => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
try {
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
await c.hello('流量计数');
|
||||
const live = srv.traffic.snapshot().ws;
|
||||
assert.equal(live.connections, 1);
|
||||
assert.equal(live.open, 1);
|
||||
assert.ok(live.messagesOut >= 1 && live.payloadOut > 0, JSON.stringify(live));
|
||||
assert.ok(live.messagesIn >= 1 && live.payloadIn > 0);
|
||||
assert.ok(live.wireOut > 0 && live.wireIn > 0);
|
||||
await c.close();
|
||||
for (let i = 0; i < 50 && srv.traffic.snapshot().ws.open; i++) await new Promise((r) => setTimeout(r, 20));
|
||||
const done = srv.traffic.snapshot().ws;
|
||||
assert.equal(done.open, 0);
|
||||
assert.ok(done.wireOut >= live.wireOut, 'the closed socket\'s bytes stay in the totals');
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('the counter on its own: a string body in another encoding, an aborted response, the payload of a fragmented message', () => {
|
||||
const tc = createTrafficCounter({ now: () => 1000 });
|
||||
const listeners = {};
|
||||
const res = { statusCode: 200, write() { return true; }, end() {}, once(ev, fn) { listeners[ev] = fn; } };
|
||||
tc.meter(res, '/data/x.json');
|
||||
res.write('aGk=', 'base64');
|
||||
res.end('é');
|
||||
listeners.close();
|
||||
const s = tc.snapshot();
|
||||
assert.deepEqual(s.http.data, { requests: 1, bytes: 2 + 2, notModified: 0 });
|
||||
tc.wsSent('{"t":"pong"}');
|
||||
tc.wsReceived([Buffer.alloc(3), Buffer.alloc(4)]);
|
||||
const ws = tc.snapshot().ws;
|
||||
assert.equal(ws.payloadOut, 12);
|
||||
assert.equal(ws.payloadIn, 7);
|
||||
assert.equal(tc.snapshot().seconds, 0);
|
||||
});
|
||||
@@ -577,6 +577,63 @@ describe('AudioManager', () => {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
test('BGM is not downloaded while the music is muted or at volume 0; the wanted track starts once it is audible', async () => {
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
const urls = [];
|
||||
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
const tick = () => new Promise((r) => setTimeout(r, 10));
|
||||
const lobby = manifest.audio.bgm.lobby.loop;
|
||||
const prep = manifest.audio.bgm.prep.loop;
|
||||
assert.notEqual(lobby, prep, 'premise: two different loops');
|
||||
try {
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
|
||||
a.setVolumes({ muted: true });
|
||||
a.install();
|
||||
a.playBgm('lobby');
|
||||
fw.fire('pointerdown');
|
||||
await tick();
|
||||
assert.equal(a.unlocked, true);
|
||||
assert.equal(a.bgmSilent, true);
|
||||
assert.ok(!asked(urls, lobby) && !asked(urls, manifest.audio.bgm.lobby.intro), 'muted: nothing fetched at unlock');
|
||||
a.playBgm('prep');
|
||||
await tick();
|
||||
assert.ok(!asked(urls, prep), 'muted: a phase change fetches nothing');
|
||||
assert.equal(a.wantBgm, 'prep', 'the wanted track is remembered');
|
||||
a.setVolumes({ muted: false });
|
||||
await tick();
|
||||
assert.ok(asked(urls, prep), 'unmuted: the track the screen wants now is fetched');
|
||||
assert.ok(!asked(urls, lobby), 'never the track wanted before');
|
||||
assert.equal(a.bgm?.loopUrl, prep);
|
||||
|
||||
// BGM volume 0 is silent too: another track stops and is not fetched; SFX keep playing
|
||||
a.setVolumes({ bgm: 0 });
|
||||
a.playBgm('lobby');
|
||||
await tick();
|
||||
assert.ok(!asked(urls, lobby), 'BGM volume 0: no download');
|
||||
assert.equal(a.bgm, null, 'the other track stopped');
|
||||
a.sfx('buy');
|
||||
await tick();
|
||||
assert.ok(asked(urls, manifest.audio.sfx.ui.buy), 'SFX are not gated by the BGM volume');
|
||||
a.setVolumes({ bgm: 0.5 });
|
||||
await tick();
|
||||
assert.ok(asked(urls, lobby), 'audible again: fetched');
|
||||
assert.equal(a.bgm?.loopUrl, lobby);
|
||||
|
||||
// the loop that already plays keeps playing through a silent phase change, and is not fetched again
|
||||
a.setVolumes({ muted: true });
|
||||
const n = urls.length;
|
||||
const started = fw.made.started;
|
||||
a.playBgm('lobby');
|
||||
a.setVolumes({ muted: false });
|
||||
await tick();
|
||||
assert.equal(a.bgm?.loopUrl, lobby);
|
||||
assert.equal(urls.length, n, 'no new request');
|
||||
assert.equal(fw.made.started, started, 'no restart');
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
test('fetch failures are swallowed', async () => {
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
@@ -598,6 +655,27 @@ describe('AudioManager', () => {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
test('a served manifest\'s content-hashed audio URL is fetched as /media/~<hash>/…, falling back to the hashed .mp3 URL', async () => {
|
||||
const H = '0123456789abcdef';
|
||||
const raw = manifest.audio.bgm.prep.loop.replace(/^\/assets\//, `/assets/~${H}/`);
|
||||
const served = { ...manifest, audio: { ...manifest.audio, bgm: { ...manifest.audio.bgm, prep: { loop: raw } } } };
|
||||
const fw = fakeWindow();
|
||||
const urls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => { urls.push(u); return u.startsWith('/media/') ? { ok: false, status: 404 } : { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
try {
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => served });
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
a.playBgm('prep');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.deepEqual(urls, [raw.replace(/^\/assets\/(~[0-9a-f]{16})\/audio\/(.+)\.mp3$/, '/media/$1/$2'), raw]);
|
||||
assert.match(urls[0], new RegExp(`^/media/~${H}/bgm/`));
|
||||
assert.equal(a.bgm?.loopUrl, raw);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
test('音频先走无扩展名的 /media/ 路由;只有它 404 才回退到带扩展名的原地址', async () => {
|
||||
const raw = manifest.audio.bgm.prep.loop;
|
||||
const media = mediaUrl(raw);
|
||||
|
||||
@@ -11,7 +11,10 @@ test('buying Matterhorn sends two live layer cues through the real client; resyn
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.1, startRound: 2, kit: 0, shop: ['chess_char_1_02_a'] } });
|
||||
const c = new Client(P, srv.base, 'bond-audio', { prefix: 'bond-audio' });
|
||||
try {
|
||||
await c.open(); await c.enter('叠层音效测试');
|
||||
await c.open();
|
||||
const mediaHits = [];
|
||||
c.page.on('response', (r) => { const p = new URL(r.url()).pathname; if (p.startsWith('/media/')) mediaHits.push({ p, status: r.status(), cache: r.headers()['cache-control'] }); });
|
||||
await c.enter('叠层音效测试');
|
||||
await c.click('.mode-card', '独立模拟'); await c.click('.diff-card', '险境');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor(s => !!s.room, 'solo room');
|
||||
@@ -41,6 +44,15 @@ test('buying Matterhorn sends two live layer cues through the real client; resyn
|
||||
assert.deepEqual(result.frames.map(m => [m.bondId, m.add]).sort(), [['kjeragShip', 2], ['steadShip', 2]]);
|
||||
assert.equal(result.cues.length, 2); assert.equal(result.playing, true); assert.equal(result.state, 'running');
|
||||
assert.ok(result.cues.every(x => /act1autochess_b_ui_buffup/.test(x.url)));
|
||||
// host bandwidth layer 1: the served manifest names the cue with its content hash and the client fetches it through
|
||||
// the extension-less /media/~<hash>/… route, cached immutably (skipped with SP_ASSET_INDEX=0: plain URLs)
|
||||
if (result.cues[0].url.includes('/~')) {
|
||||
assert.ok(result.cues.every(x => /^\/assets\/~[0-9a-f]{16}\/audio\//.test(x.url)), JSON.stringify(result.cues));
|
||||
const hit = mediaHits.find(h => /^\/media\/~[0-9a-f]{16}\/.*act1autochess_b_ui_buffup$/.test(h.p));
|
||||
assert.ok(hit, mediaHits.map(h => h.p).join(' '));
|
||||
assert.equal(hit.status, 200);
|
||||
assert.equal(hit.cache, 'public, max-age=31536000, immutable');
|
||||
}
|
||||
const count = result.cues.length;
|
||||
// g.watch on own prep asks the real server for state again, without a new gain.
|
||||
await c.page.evaluate(() => globalThis.__SP__.net.request('g.watch', { fieldId: `n:${globalThis.__SP__.store.get().me.playerId}` }));
|
||||
|
||||
@@ -163,6 +163,19 @@ describe('emotes and 玩法说明 pages: local art first, then the mirror copy (
|
||||
assert.ok(!src.includes('该页面暂时无法显示'), 'no blank notice left');
|
||||
});
|
||||
|
||||
test('the guide preloads only the next page (host bandwidth layer 0): wraps around, skips a failed copy, never the previous page', async () => {
|
||||
const { guidePreloadUrl } = await import('../../public/js/ui/guide.js');
|
||||
const page = (k) => ({ urls: [`/assets/local/guide/${k}.webp`, `/assets/ui/guide/${k}.png`] });
|
||||
const pages = ['autochess_home_1', 'autochess_home_2', 'autochess_home_3'].map(page);
|
||||
assert.equal(guidePreloadUrl(pages, 0), '/assets/local/guide/autochess_home_2.webp');
|
||||
assert.equal(guidePreloadUrl(pages, 2), '/assets/local/guide/autochess_home_1.webp', 'wraps to the first page');
|
||||
assert.equal(guidePreloadUrl(pages, 1, new Set(['/assets/local/guide/autochess_home_3.webp'])), '/assets/ui/guide/autochess_home_3.png', 'the copy that has not failed');
|
||||
assert.equal(guidePreloadUrl([page('autochess_home_1')], 0), null, 'a single page needs no preload');
|
||||
assert.equal(guidePreloadUrl([], 0), null);
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/ui/guide.js'), 'utf8');
|
||||
assert.deepEqual(src.match(/^\s+preload\(.*$/gm).map((l) => l.trim()), ['preload(guidePreloadUrl(pages, i, failed));'], 'one preload call in the viewer');
|
||||
});
|
||||
|
||||
test('tools/assets/plan.mjs GUIDE_PAGES is the viewer\'s page list (GUIDE_CHAPTERS), in reading order', async () => {
|
||||
const { GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
|
||||
assert.deepEqual([...GUIDE_PAGES], GUIDE_CHAPTERS.flatMap((c) => c.pages.map(([key]) => key)));
|
||||
|
||||
@@ -38,7 +38,7 @@ const CHROME_ARGS = ['--no-sandbox', '--no-first-run', '--autoplay-policy=no-use
|
||||
*/
|
||||
class Client {
|
||||
constructor(puppeteer, base, label, { w = 1920, h = 1080 } = {}) {
|
||||
Object.assign(this, { puppeteer, base, label, w, h, problems: [], log: [], shots: 0 });
|
||||
Object.assign(this, { puppeteer, base, label, w, h, problems: [], log: [], shots: 0, hashed: { spine: 0, media: 0, other: 0 } });
|
||||
}
|
||||
|
||||
async open(query = '') {
|
||||
@@ -50,7 +50,14 @@ class Client {
|
||||
page.on('console', (m) => { if (m.type() === 'error') this.problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => this.problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => this.problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||||
page.on('response', (r) => { if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`); });
|
||||
page.on('response', (r) => {
|
||||
if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`);
|
||||
// the served manifest's content-hashed URLs (docs/ASSETS.md): Spine files, audio through /media/~<hash>/, the rest
|
||||
const p = new URL(r.url()).pathname;
|
||||
if (r.status() === 200 && /^\/(?:assets|media)\/~[0-9a-f]{16}\//.test(p)) {
|
||||
this.hashed[/\.(?:skel|atlas)$|\/spine\//.test(p) ? 'spine' : p.startsWith('/media/') ? 'media' : 'other']++;
|
||||
}
|
||||
});
|
||||
page.on('dialog', (d) => d.dismiss().catch(() => {}));
|
||||
await page.goto(`${this.base}/${query}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
|
||||
@@ -443,6 +450,8 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
// the content-hash index first, so the pages load the served manifests' /assets/~<hash>/… URLs like a player's
|
||||
await srv.assetIndex?.whenReady();
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
@@ -628,6 +637,10 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
|
||||
assert.ok(did.inspected && did.bond, 'detail panel + bond popup');
|
||||
assert.ok(did.emote, 'emote bubble reached the teammate');
|
||||
assert.ok(did.enemies >= 0, 'enemy drawer opened');
|
||||
if (srv.assetIndex) {
|
||||
host.note(`content-hashed responses: ${JSON.stringify(host.hashed)}`);
|
||||
assert.ok(host.hashed.spine > 0 && host.hashed.media > 0 && host.hashed.other > 0, `Spine, audio and art through hashed URLs: ${JSON.stringify(host.hashed)}`);
|
||||
}
|
||||
await assertClean(both);
|
||||
} finally {
|
||||
console.log([...host.log, ...guest.log].join('\n'));
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// WebSocket permessage-deflate (server/http/websocket.js WS_DEFLATE, host bandwidth layer 0): a browser-like client
|
||||
// negotiates it with no server context takeover and a 2 KB server window, a large JSON frame goes out compressed, a
|
||||
// frame under the 1 KB threshold and a client without the extension get plain frames, and an offer that caps the server
|
||||
// window below ours is answered without compression instead of a refused handshake.
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import WebSocket from 'ws';
|
||||
import { attachWebSocket, narrowDeflateOffer, WS_DEFLATE } from '../server/http/websocket.js';
|
||||
import { startServer } from '../server/index.js';
|
||||
|
||||
const BIG = JSON.stringify({ t: 'm.public', players: Array.from({ length: 200 }, (_, i) => ({ playerId: `p_${i % 4}`, name: '阿米娅', lp: 30, phase: 'PREP' })) });
|
||||
const SMALL = JSON.stringify({ t: 'pong', at: 12345 });
|
||||
|
||||
/** A bare http server whose /ws sends BIG then SMALL to every socket (a stand-in for net.js Network). */
|
||||
async function fakeServer() {
|
||||
const server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
|
||||
const network = {
|
||||
admission: () => null,
|
||||
handleConnection: (ws) => { ws.send(BIG); ws.send(SMALL); },
|
||||
};
|
||||
const wss = attachWebSocket(server, { network, log: { error() {} } });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
return { url: `ws://127.0.0.1:${server.address().port}/ws`, close: () => new Promise((r) => { wss.close(); server.close(() => r()); }) };
|
||||
}
|
||||
|
||||
/** Connect, collect two messages, report the server's Sec-WebSocket-Extensions answer and the bytes the socket read. */
|
||||
function receive(url, opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, opts);
|
||||
const msgs = [];
|
||||
let negotiated = '';
|
||||
ws.on('upgrade', (res) => { negotiated = res.headers['sec-websocket-extensions'] || ''; });
|
||||
ws.on('error', reject);
|
||||
ws.on('message', (data) => {
|
||||
msgs.push(String(data));
|
||||
if (msgs.length === 2) {
|
||||
const read = ws._socket.bytesRead;
|
||||
ws.close();
|
||||
resolve({ msgs, read, extensions: negotiated });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('WebSocket permessage-deflate', () => {
|
||||
let srv;
|
||||
before(async () => { srv = await fakeServer(); });
|
||||
after(async () => { await srv.close(); });
|
||||
|
||||
test('negotiated with no server context takeover and a small server window; a large frame is compressed', async () => {
|
||||
const r = await receive(srv.url);
|
||||
assert.match(r.extensions, /permessage-deflate/);
|
||||
assert.match(r.extensions, /server_no_context_takeover/);
|
||||
assert.match(r.extensions, new RegExp(`server_max_window_bits=${WS_DEFLATE.serverMaxWindowBits}`));
|
||||
assert.deepEqual(r.msgs, [BIG, SMALL], 'the payloads arrive intact');
|
||||
assert.ok(BIG.length > 10000);
|
||||
assert.ok(r.read < BIG.length / 4, `compressed on the wire: ${r.read} bytes read for ${BIG.length} of payload`);
|
||||
assert.equal(WS_DEFLATE.threshold, 1024);
|
||||
});
|
||||
|
||||
test('a client without the extension gets the same messages uncompressed', async () => {
|
||||
const r = await receive(srv.url, { perMessageDeflate: false });
|
||||
assert.equal(r.extensions, '');
|
||||
assert.deepEqual(r.msgs, [BIG, SMALL]);
|
||||
assert.ok(r.read > BIG.length + SMALL.length, `plain frames: ${r.read}`);
|
||||
});
|
||||
|
||||
test('an offer that caps the server window below ours connects uncompressed instead of a refused handshake', async () => {
|
||||
const r = await receive(srv.url, { perMessageDeflate: { serverMaxWindowBits: 9 } });
|
||||
assert.equal(r.extensions, '');
|
||||
assert.deepEqual(r.msgs, [BIG, SMALL]);
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=9'), true);
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=12; client_max_window_bits'), false);
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=9, permessage-deflate'), false, 'another offer is acceptable');
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; client_max_window_bits'), false);
|
||||
assert.equal(narrowDeflateOffer(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('the real server negotiates compression at /ws and keeps the 64 KB inflated frame limit', async () => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
try {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
|
||||
assert.match(ws.extensions, /permessage-deflate/);
|
||||
// a compressed client frame whose inflated size is over the limit closes the socket (1009), like a plain one
|
||||
const closed = new Promise((resolve) => ws.once('close', (code) => resolve(code)));
|
||||
ws.send(JSON.stringify({ t: 'hello', name: 'x'.repeat(70 * 1024) }));
|
||||
assert.equal(await closed, 1009);
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
@@ -32,8 +32,8 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
|
||||
DXT5nm in an older bundle: AG = XY, R = 1) and metallic/gloss with the smoothness in A; the board renderer needs
|
||||
an RGB normal map (Z rebuilt) and a roughness map in G (1 − smoothness; metalness B = 0), written next to the
|
||||
source as <name>_rgb.png / _rough.png
|
||||
- WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs;
|
||||
the manifest lists the copy
|
||||
- WebP copies of the board textures every player downloads with the 3D board (WEBP) and of the 19 guidebook pages
|
||||
(GUIDE_WEBP_*), written next to their PNGs; the manifest lists the copy
|
||||
- the official battlefield scenes (scenes.py SCENES: one prebuilt scene per 卫戍协议 battlefield) → scene/<stageId>/
|
||||
scene.json + scene.bin + lightmap pages and scene/tex/ (the materials' albedo textures). The scene bundles ship
|
||||
only while the season runs: they are read from the client when it has them, else from --scenes <dir> (default
|
||||
@@ -43,8 +43,9 @@ Usage:
|
||||
python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt
|
||||
.venv/bin/python tools/local-extract/extract.py [--game <AB root>] [--out public/assets/local] [--only <subdir prefix>]
|
||||
python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies)
|
||||
python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted —
|
||||
e.g. the local art copied from a release bundle; needs Pillow)
|
||||
python3 tools/local-extract/extract.py --webp (only the WebP copies of the board textures and the guide
|
||||
pages, from the PNGs already extracted — e.g. the local art
|
||||
copied from a release bundle; needs Pillow)
|
||||
.venv/bin/python tools/local-extract/extract.py --only scene [--scenes <dir of in-season bundles>]
|
||||
|
||||
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
|
||||
@@ -188,6 +189,19 @@ WEBP_OPTIONS = {
|
||||
'lossless': {'lossless': True, 'quality': 100, 'method': 6, 'exact': True},
|
||||
}
|
||||
|
||||
# The 19 玩法说明 pages (output subdir guide: 1024², opaque) get a WebP copy too — every page PNG of the group, each a
|
||||
# 1.2–1.5 MB download whenever a player opens the guide (public/js/ui/guide.js shows the local page first and preloads
|
||||
# the next one). Lossless by default: 25.6 MB → 17.4 MB on the CN client's pages. Lossy quality 90 would make them
|
||||
# 4.2 MB; the maintainer decides on that after a side-by-side comparison (DESIGN §31.1, docs/history/0.2.6.md),
|
||||
# and then only GUIDE_WEBP_MODE changes (`extract.py --webp` rewrites the copies). Method 4: method 6 spends about 8 s
|
||||
# a page for 0.4 % less.
|
||||
GUIDE_WEBP_SUB = 'guide'
|
||||
GUIDE_WEBP_MODE = 'lossless'
|
||||
GUIDE_WEBP_OPTIONS = {
|
||||
'lossless': {'lossless': True, 'quality': 100, 'method': 4, 'exact': True},
|
||||
'lossy': {'quality': 90, 'method': 4, 'alpha_quality': 100},
|
||||
}
|
||||
|
||||
# Shader bundles loaded beside every job that exports Materials, only so that the materials' shader references
|
||||
# (external CABs) resolve to a name in materials.json (e.g. Torappu/Scene/StandardDirectional); nothing is exported
|
||||
# from them.
|
||||
@@ -454,11 +468,22 @@ def run_derived(out_root, sub, manifest, log):
|
||||
return n
|
||||
|
||||
|
||||
def webp_targets(sub, manifest):
|
||||
"""(name, mode, Pillow options) of every WebP copy output subdir `sub` gets: its WEBP rows and, for the guide, each
|
||||
page of the group (an image entry: its path ends in .png, or in .webp after an earlier run)."""
|
||||
out = [(name, mode, WEBP_OPTIONS[mode]) for wsub, name, mode in WEBP if wsub == sub]
|
||||
if sub == GUIDE_WEBP_SUB:
|
||||
out += [(name, GUIDE_WEBP_MODE, GUIDE_WEBP_OPTIONS[GUIDE_WEBP_MODE])
|
||||
for name, entry in sorted(manifest.get(sub, {}).items())
|
||||
if isinstance(entry, dict) and str(entry.get('path', '')).endswith(('.png', '.webp'))]
|
||||
return out
|
||||
|
||||
|
||||
def run_webp(out_root, sub, manifest, log):
|
||||
"""Write the WEBP copies of output subdir `sub` from its PNGs and point their manifest entries at them."""
|
||||
"""Write the WebP copies of output subdir `sub` from its PNGs and point their manifest entries at them."""
|
||||
n = 0
|
||||
for wsub, name, mode in WEBP:
|
||||
entry = manifest.get(sub, {}).get(name) if wsub == sub else None
|
||||
for name, mode, options in webp_targets(sub, manifest):
|
||||
entry = manifest.get(sub, {}).get(name)
|
||||
png = Path(out_root) / sub / f'{name}.png'
|
||||
if not entry or not png.exists():
|
||||
continue
|
||||
@@ -466,7 +491,7 @@ def run_webp(out_root, sub, manifest, log):
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(png) as img:
|
||||
img.save(dst, 'WEBP', **WEBP_OPTIONS[mode])
|
||||
img.save(dst, 'WEBP', **options)
|
||||
except Exception as e: # no WebP support in this Pillow, or a broken PNG: the entry keeps the PNG
|
||||
log(f' warn webp {name}: {e}')
|
||||
continue
|
||||
@@ -486,7 +511,7 @@ def webp_only(out_root, manifest_path, log):
|
||||
if not isinstance(groups, dict):
|
||||
print(f'{manifest_path} has no groups. Extract the local art first.', file=sys.stderr)
|
||||
return 2
|
||||
n = sum(run_webp(out_root, sub, groups, log) for sub in dict.fromkeys(s for s, _, _ in WEBP))
|
||||
n = sum(run_webp(out_root, sub, groups, log) for sub in dict.fromkeys([*(s for s, _, _ in WEBP), GUIDE_WEBP_SUB]))
|
||||
Path(manifest_path).write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
|
||||
log(f'done: {n} WebP copies, manifest {manifest_path}')
|
||||
return 0 if n else 1
|
||||
@@ -846,8 +871,8 @@ def main():
|
||||
'searched after --game and before .cache/official-scenes/*)')
|
||||
ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit')
|
||||
ap.add_argument('--webp', action='store_true',
|
||||
help='only write the WebP copies of the board textures from the PNGs already under --out and list '
|
||||
'them in the manifest (needs Pillow, not the client)')
|
||||
help='only write the WebP copies of the board textures and the guide pages from the PNGs already '
|
||||
'under --out and list them in the manifest (needs Pillow, not the client)')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.print_jobs:
|
||||
@@ -857,6 +882,8 @@ def main():
|
||||
webp = [{'sub': sub, 'name': name, 'mode': mode} for sub, name, mode in WEBP]
|
||||
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
|
||||
'derived': derived, 'webp': webp,
|
||||
'guideWebp': {'sub': GUIDE_WEBP_SUB, 'mode': GUIDE_WEBP_MODE,
|
||||
'options': GUIDE_WEBP_OPTIONS[GUIDE_WEBP_MODE]},
|
||||
'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB, 'ids': ENEMY_SPINES},
|
||||
'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES},
|
||||
'scenes': {'sub': scenes.SCENE_SUB, 'tex': scenes.TEX_SUB,
|
||||
|
||||
Reference in new issue
Block a user