kaedeasset記事 · Notes霧走町 流氷に眠る

画面のファイルは毎回「変わっていないか」を聞かせ、曲と動画は範囲で配る

Screen files are revalidated every time; music and video are served by range

 D16-4-T32-static-files-conditional-range・技術・日本語入力でプレイする仕組み

配る仕組みHTTP conditional requestsETag (weak validator)304 Not ModifiedHTTP range requests206 Partial Content416 Range Not SatisfiableRFC 9110claude自然言語処理

ブラウザ版の画面のファイルは、毎回サーバに「変わっていないか」を聞かせ、変わっていなければ 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 と比べると、外れている所があります。どれも今日の実測で確かめました。

  1. If-None-Match を文字列の完全一致で見ています。RFC は弱い比較を求めるので、W/ を外した同じ札にも 304 を返すべきですが、うちは 200 を返します。札を並べた形("x", W/"…")と * も見ていません
  2. HEAD に 405 を返します。RFC 9110 §9.1 は、汎用のサーバは GET と HEAD を必ず持つ、と書いています。GET でも POST でもない要求を一律に 405 にしている行のせいです
  3. If-Range を見ません。古い札を付けた範囲要求にも、今の版の部分を 206 で返します
  4. Last-Modified を送らず、If-Modified-Since も見ません(§8.8.2.1 は、更新時刻が決まるなら送るべき、と書いています)
  5. 複数区間は無視して全体を 200 で返します。これは RFC が許している振る舞いです
  6. 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 の道で届いています。『霧走町 流氷に眠る』の第一章はブラウザで無料で遊べます。プロフィールのリンクから。

#claude #自然言語処理