画面のファイルは毎回「変わっていないか」を聞かせ、曲と動画は範囲で配る
Screen files are revalidated every time; music and video are served by range
D16-4-T32-static-files-conditional-range・技術・日本語入力でプレイする仕組み
ブラウザ版の画面のファイルは、毎回サーバに「変わっていないか」を聞かせ、変わっていなければ 304 で本文を送りません。
曲と動画は Range で頼まれた所だけを 206 で返します。どちらも RFC 9110 の仕組みで、Node.js の標準の http だけで 40 行ほどです。
ブラウザ版のサーバ(server.mjs)は、ゲームの 1 手の口(T30・T31)のほかに、画面そのものも配っています。
HTML・JavaScript・画面の JSON・背景の絵・書体、案内ページの画像と動画、タイトルの曲。今日組んだもので 48 ファイル、28.7 MB でした。
その配り方の話です。
この回の位置と、前後の回#
区切り: T30〜T33 配る仕組み(4 回のうち 3 回目)
先に読むとよい回: T30(状態を持たないサーバ)・T31(1 秒 2 手のレート制限)
次に読む回: T33(更新は 1 コマンド)
名前 — HTTP conditional requests#
- 正式名
- HTTP conditional requests(条件つき要求)と HTTP range requests(範囲要求)
- 使う部品
- ETag(weak validator・弱い検証子)・If-None-Match・304 Not Modified/Range・206 Partial Content・416 Range Not Satisfiable・Content-Range
- 出所
- RFC 9110 HTTP Semantics(R. Fielding・M. Nottingham・J. Reschke 編, 2022 年 6 月)。§8.8(検証子)・§13(条件つき要求)・§14(範囲要求)・§15(状態コード)。
一般の解説 — HTTP conditional requests#
HTTP conditional requests(条件つき要求)とは、検証子(ETag や Last-Modified)を付けて「変わっていなければ本文は要らない」と頼む要求で、HTTP range requests(範囲要求)とは、表現の一部のバイトだけを頼む要求です(どちらも RFC 9110 の仕組み)。
- 検証子と条件つき要求
サーバは返す表現に「版の札」を付けます。更新時刻の Last-Modified と、中身を表す不透明な文字列の ETag です。
ブラウザはキャッシュに残した表現を使う前に、札を付けて聞き直します。If-None-Match: <札> は「この札と違うときだけ本文をくれ」という意味で、同じなら 304 Not Modified が本文なしで返ります。
ETag には強い札と弱い札があります。
強い札は、表現のバイト列が変われば必ず変わる札。
弱い札は W/ を前に付けた札で、「意味として同じなら同じ札でよい」。RFC 9110 は、更新時刻のような値は、1 秒の中で 2 回書き換わりうるなら弱い検証子だと書いています(§8.8.1)。
比べ方も 2 つ決まっています(§8.8.3.2)。
強い比較: 両方とも弱くなく、札の文字列が 1 文字ずつ同じ。
弱い比較: W/ が付いているかどうかに関係なく、札の文字列が同じ。
If-None-Match は弱い比較を使わなければいけません(§13.1.2)。GET と HEAD で一致すれば 304 です。
Cache-Control: no-cache は「キャッシュしてよいが、使う前に必ず聞き直せ」です。名前に反して「キャッシュするな」ではありません(そちらは no-store)。
- 範囲要求
Range: bytes=0-1023 のように頼むと、サーバはその部分だけを 206 Partial Content と Content-Range: bytes 0-1023/<全体> で返します。
形は 3 つあります。a-b(a から b まで)、a-(a から最後まで)、-n(最後の n バイト)。カンマで並べれば複数の区間も頼め、そのときは multipart/byteranges で返します。
満たせない範囲(全体より後ろから始まる等)には 416 を返し、Content-Range: bytes */<全体> を付けるべき、とされています(§14.2・§14.4)。
サーバは Range を無視して、全体を 200 で返してもかまいません(§14.2)。
範囲要求の典型は、動画のシークと、途中で切れたダウンロードの続きです。
続きを頼むときは If-Range に札を付けます。札が変わっていなければ部分を、変わっていれば全体を返す、という意味で、別の版のバイト列が継ぎはぎになるのを防ぎます(§13.1.5)。
よく知られた弱点は二つです。
更新時刻を札にすると、時計の精度(多くは 1 秒)より細かい書き換えを見逃す。
大きさと更新時刻から作る札は、複数台のサーバで同じファイルの札が揃わない(置いた時刻が違う)。
うちの実装 — HTTP conditional requests#
静的ファイルを出す関数は、ほぼ全部を写します(server.mjs の serveStatic)。
function serveStatic(cfg, req, res, urlPath) {
let p;
try { p = decodeURIComponent(urlPath); } catch { return send(res, 400, { ok: false, error: 'bad path' }); }
if (p.includes('\0')) return send(res, 400, { ok: false, error: 'bad path' });
p = path.posix.normalize('/' + p);
if (p.endsWith('/')) p += 'index.html';
const file = path.join(cfg.site, ...p.split('/').filter((s) => s.length > 0));
if (file !== cfg.site && !file.startsWith(cfg.site + path.sep)) return send(res, 404, { ok: false, error: 'not found' });
const type = STATIC_TYPES[path.extname(file).toLowerCase()];
if (!type) return send(res, 404, { ok: false, error: 'not found' });
fs.stat(file, (err, st) => {
if (err || !st.isFile()) return send(res, 404, { ok: false, error: 'not found' });
const etag = `W/"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`;
if (req.headers['if-none-match'] === etag) { res.writeHead(304, { etag, 'cache-control': 'no-cache' }); return res.end(); }
const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? '');
if (m && (m[1] || m[2])) {
let start = m[1] ? Number(m[1]) : Math.max(0, st.size - Number(m[2]));
let end = m[1] && m[2] ? Math.min(Number(m[2]), st.size - 1) : st.size - 1;
if (start > end || start >= st.size) { res.writeHead(416, { 'content-range': `bytes */${st.size}` }); return res.end(); }
res.writeHead(206, { 'content-type': type, 'content-length': end - start + 1, 'content-range': `bytes ${start}-${end}/${st.size}`,
'accept-ranges': 'bytes', etag, 'cache-control': 'no-cache' });
return fs.createReadStream(file, { start, end }).on('error', () => res.destroy()).pipe(res);
}
res.writeHead(200, { 'content-type': type, 'content-length': st.size, etag, 'cache-control': 'no-cache', 'accept-ranges': 'bytes' });
fs.createReadStream(file).on('error', () => res.destroy()).pipe(res);
});
}
決めたことを順に。
- 置き場の外へ出さない。URL をデコードし、NUL を拒み、POSIX の normalize で .. を潰してから、OS のパスにして「--site の中か」を前方一致で確かめます。外なら 404(400 ではなく 404 にして、外に何があるかを言わない)
- 拡張子の許可リスト。html・js・css・json・png・webp・ico・ttf・svg・txt・mp3・mp4 だけを配り、ほかは置いてあっても 404 です。編集器が残す .bak や、うっかり置いたファイルが出ません
- --site には、作品フォルダではなく build-site.mjs が組んだ出来上がりだけを渡します。意図の辞書や台本は「答えそのもの」なので、画面の側には置きません(判定に要るデータは adv-sim だけが読みます)
- 札は「大きさ(16 進)- 更新時刻のミリ秒(16 進)」に W/ を付けた弱い札。中身のハッシュは計算しません
- cache-control は全部 no-cache。毎回聞き直させます。更新を配った直後に、古い app.js と新しい画面の JSON が混ざるのを避けるためです
- Range は 1 区間だけ。bytes=a-b・a-・-n の 3 つの形を読み、全体より後ろから始まれば 416 と Content-Range: bytes */<全体>
範囲要求を入れたのは、10 月 10 日にタイトルの曲を足したときです。Safari は音を範囲指定で取り、答えないと鳴らしませんでした。その後、案内ページのプレイ動画も同じ道で配っています。
ゲームの 1 手の口(/api/*)の返事は、逆に no-store です。毎手違うので、キャッシュに置く意味がありません。
実測(今日・Windows の手元)#
手元で server.mjs を立て、画面を組んで、要求を送りました。
GET play/app.js → 200 58,751 B etag W/"e57f-1a17c501bf6" no-cache
If-None-Match: W/"e57f-1a17c501bf6" → 304 0 B
If-None-Match: "e57f-1a17c501bf6" → 200(W/ を外した同じ札)
GET play/audio/title.mp3(6,589,222 B)
Range: bytes=0-1023 → 206 bytes 0-1023/6589222 1,024 B
Range: bytes=-100 → 206 bytes 6589122-6589221/6589222
Range: bytes=1000- → 206 6,588,222 B
Range: bytes=999999999- → 416 bytes */6589222
Range: bytes=0-10,20-30 → 200 全体
GET landing/trailer.mp4(7,493,387 B)
Range: bytes=0-65535 → 206 65,536 B
GET /../server.mjs・/%2e%2e/server.mjs・play/app.js.bak → 404
HEAD play/app.js → 405
ブラウザがふつうに使う道(同じ札での 304、1 区間の 206、満たせない範囲の 416、外への 404)は全部、期待どおりでした。
煙試験(smoke.mjs)も、同じ札での 304、曲の 206、外と知らない拡張子の 404 を毎回見ています。
名前どおりでない所 — HTTP conditional requests#
RFC 9110 と比べると、外れている所があります。どれも今日の実測で確かめました。
- If-None-Match を文字列の完全一致で見ています。RFC は弱い比較を求めるので、W/ を外した同じ札にも 304 を返すべきですが、うちは 200 を返します。札を並べた形("x", W/"…")と * も見ていません
- HEAD に 405 を返します。RFC 9110 §9.1 は、汎用のサーバは GET と HEAD を必ず持つ、と書いています。GET でも POST でもない要求を一律に 405 にしている行のせいです
- If-Range を見ません。古い札を付けた範囲要求にも、今の版の部分を 206 で返します
- Last-Modified を送らず、If-Modified-Since も見ません(§8.8.2.1 は、更新時刻が決まるなら送るべき、と書いています)
- 複数区間は無視して全体を 200 で返します。これは RFC が許している振る舞いです
- last-pos が first-pos より小さい範囲(bytes=5-2)は 416 にしています。RFC は「不正な範囲は無視しても拒否してもよい」なので、許される側です
1〜4 は、ブラウザが実際に送る要求では困っていません。ブラウザは受け取った札をそのまま返し、画面を開くのに HEAD は送りません。If-Range の違いが表に出るのは、曲や動画を取っている途中で更新を配り、中身が差し替わったときだけです。
それでも「RFC 9110 の条件つき要求を実装した」とは言えません。言えるのは「ブラウザがふつうに使う 304 と 206 を返す」までです。
弱点と、採らなかった案 — HTTP conditional requests#
- 札が大きさと更新時刻なので、同じ大きさのまま、同じミリ秒の中で書き換えると同じ札になります。配る物は更新のたびに丸ごと置き直す(T33)ので、ここで困ったことはありません
- 更新時刻を札に入れているので、中身が同じでも置き直せば札が変わり、全員が 1 回取り直します。更新のたびに 28.7 MB を全員が取り直すわけではなく、取り直すのはそのとき開いた頁が使うファイルだけです
- no-cache なので、頁を開くたびに、ファイルの数だけ往復が出ます(304 でも往復はある)。画面の JSON と絵で数十本です
- 圧縮は server.mjs ではしていません。前に立つ Caddy が gzip をかけます。今日の数字は Caddy を通していない手元の値です
採らなかった案:
- ファイル名に中身のハッシュを入れて(app.3f2a.js)、長い max-age で配る。キャッシュの定番ですが、画面の JSON・絵・書体・図の全部の参照を組み替える工程が要る。いまの規模では no-cache の往復の方が安い
- Caddy の file_server に静的ファイルを任せる。こちらの方が RFC に忠実で速いはずですが、画面とゲームの口を同じオリジン・同じ置き場の約束(--site の出来上がりだけ)で 1 か所にまとめておきたかった。戻すなら --site を外すだけで済むようにしてあります
- Cloudflare Pages など別の所に画面を置く。最初の設計はそうでしたが、CORS の設定先と配置先が 1 つずつ増えるので、同じオリジンから出すことにしました
検索語#
RFC 9110 conditional requests If-None-Match weak comparison
HTTP range requests 206 416 Content-Range
ETag weak validator W/
Cache-Control no-cache vs no-store
案内ページのプレイ動画も、この 206 の道で届いています。『霧走町 流氷に眠る』の第一章はブラウザで無料で遊べます。プロフィールのリンクから。