diff --git a/docs/decisions/0010-worker-http-negotiation.md b/docs/decisions/0010-worker-http-negotiation.md new file mode 100644 index 0000000..0c4e64a --- /dev/null +++ b/docs/decisions/0010-worker-http-negotiation.md @@ -0,0 +1,27 @@ +# ADR 0010: Worker HTTP representation negotiation + +## Status + +Accepted for PR #38. + +## Context + +The optional Cloudflare Worker serves pre-generated HTML and Markdown representations from the same public export. The route must select a representation from the request `Accept` header without treating a mere substring match as authoritative, and method responses must advertise only methods actually implemented by the route class. + +## Decision + +- HTML remains the default representation when `Accept` is absent, unsupported, tied, or explicitly rejects every representation currently offered. +- `q=0` makes that matching media range unacceptable; it is never preferred over a positive-quality alternative. +- Media-range parameters that appear before `q` constrain matching. A range such as `text/html;level=1` does not match the plain HTML representation. Parameters after `q` are accept extensions and do not constrain representation matching. +- Both generated text representations are UTF-8, so `charset=utf-8` is a matching media parameter. +- Specific media types outrank type wildcards, which outrank `*/*`; quality and original header order break ties within the same specificity. +- API routes advertise `GET, HEAD, OPTIONS`; non-API page routes advertise `GET, HEAD` and reject `OPTIONS` with 405. +- Negotiated page responses retain `Vary: Accept`. + +## Consequences + +The Worker performs deterministic local header parsing only. It does not inspect R2 contents to decide representation and does not add a 406 response path in this change. The fallback-to-HTML behavior is therefore an explicit compatibility contract rather than an inference from malformed or unsupported `Accept` values. + +## Verification + +The behavior is covered by helper tests and Worker integration tests, including quality values, wildcards, `q=0`, pre-`q` media parameters, post-`q` accept extensions, UTF-8 charset matching, negotiated content type, and route-specific `Allow` headers. Measured hosted-CI evidence is recorded in `docs/verification/2026-08-22-worker-http-contract.md`. diff --git a/docs/verification/2026-08-22-worker-http-contract.md b/docs/verification/2026-08-22-worker-http-contract.md new file mode 100644 index 0000000..b34f3f1 --- /dev/null +++ b/docs/verification/2026-08-22-worker-http-contract.md @@ -0,0 +1,39 @@ +# Worker HTTP contract verification — 2026-08-22 + +対象PR: #38 + +## 検証対象 + +- `Accept`のmedia range、wildcard、quality valueを解釈する。 +- `q=0`を選択対象から除外する。 +- `q`より前のmedia parameterを表現選択条件として照合する。 +- `q`より後のaccept extensionを表現選択条件にしない。 +- UTF-8 charset parameterを生成表現と照合する。 +- 同一media typeでは、matching parameter数が多いrangeをよりspecificとして扱う。 +- APIと非API routeで実装済みmethodだけを`Allow`に返す。 +- helper testがWorkerの通常verifyに実際に含まれる。 + +## 期待する回帰条件 + +- `text/html;level=1;q=1, text/markdown;q=0.5`は、`level=1`を持たないHTMLではなくMarkdownを選ぶ。 +- `text/html;charset=UTF-8;q=1, text/markdown;q=0.5`はHTMLを選ぶ。 +- `text/html;q=0.8, text/html;charset=utf-8;q=0.4, text/markdown;q=0.6`は、よりspecificなHTML rangeの`q=0.4`を採用しMarkdownを選ぶ。 +- `text/html;q=0;level=1, text/markdown;q=0.5`では`level=1`をaccept extensionとして扱いMarkdownを選ぶ。 +- HTMLとMarkdownがともに`q=0`、または対応外media typeだけの場合は互換契約としてHTMLへfallbackする。 +- API routeの`Allow`は`GET, HEAD, OPTIONS`、通常page routeは`GET, HEAD`とする。 + +## Hosted CI evidence + +実装・回帰test・ADRを含むcommit `f6b0b18bb4df1a05c9490a92a1436d0dce99f612`をGitHub Actions CI run `32566260740`(run #250)で検証した。 + +- Cloudflare Worker on Node 22: success。 +- Worker Vitest: `2 passed` test files、`46 passed` tests。内訳は`http.spec.ts` 15件、`worker.spec.ts` 31件。 +- WebMCP Vitest: `1 passed` test file、`3 passed` tests。 +- Worker type generation check、TypeScript typecheck、oxlintが成功。lintは0 warnings / 0 errors。 +- Wrangler dry-run buildが成功。 +- `npm audit --audit-level=high`: 0 vulnerabilities。 +- Python 3.12 / 3.14のUbuntu、Windows、macOS、installed-wheel 3 OS、synthetic determinism 3 OSとcross-OS compareを含むCI全jobが成功。 +- CI failure: 0。 +- Worker test skip: 0。 + +この後のcommitは上記測定結果を固定する文書変更のみであり、Worker production codeと回帰testは変更しない。最終マージ前に、この文書変更を含むheadでも必須CIを再実行する。 diff --git a/worker/src/http.ts b/worker/src/http.ts new file mode 100644 index 0000000..836c75e --- /dev/null +++ b/worker/src/http.ts @@ -0,0 +1,124 @@ +export type PageFormat = "html" | "markdown"; + +interface MediaRange { + type: string; + subtype: string; + parameters: Readonly>; + quality: number; + order: number; +} + +interface Representation { + type: string; + subtype: string; + parameters: Readonly>; +} + +const REPRESENTATIONS: Readonly> = { + html: { type: "text", subtype: "html", parameters: { charset: "utf-8" } }, + markdown: { type: "text", subtype: "markdown", parameters: { charset: "utf-8" } }, +}; + +function unquote(raw: string): string { + const value = raw.trim(); + return value.length >= 2 && value.startsWith('"') && value.endsWith('"') + ? value.slice(1, -1) + : value; +} + +function parseQuality(raw: string): number { + const parsed = Number(unquote(raw)); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0; +} + +function parseAccept(value: string): MediaRange[] { + const ranges: MediaRange[] = []; + for (const [order, rawRange] of value.split(",").entries()) { + const [rawMediaType, ...rawParameters] = rawRange.split(";"); + const [rawType, rawSubtype, ...extra] = (rawMediaType ?? "") + .trim() + .toLowerCase() + .split("/"); + if (rawType === undefined || rawSubtype === undefined || extra.length > 0) { + continue; + } + + const parameters: Record = {}; + let quality = 1; + let sawQuality = false; + for (const rawParameter of rawParameters) { + const separator = rawParameter.indexOf("="); + const rawName = separator < 0 ? rawParameter : rawParameter.slice(0, separator); + const rawValue = separator < 0 ? "" : rawParameter.slice(separator + 1); + const name = rawName.trim().toLowerCase(); + if (name === "") continue; + if (!sawQuality && name === "q") { + quality = rawValue === "" ? 0 : parseQuality(rawValue); + sawQuality = true; + continue; + } + if (!sawQuality) { + const parameterValue = unquote(rawValue); + parameters[name] = name === "charset" ? parameterValue.toLowerCase() : parameterValue; + } + // Parameters after q are accept extensions and do not constrain the media range. + } + ranges.push({ type: rawType, subtype: rawSubtype, parameters, quality, order }); + } + return ranges; +} + +function parametersMatch(range: MediaRange, representation: Representation): boolean { + return Object.entries(range.parameters).every( + ([name, value]) => representation.parameters[name] === value, + ); +} + +function matchSpecificity(range: MediaRange, representation: Representation): number { + if (!parametersMatch(range, representation)) return -1; + const parameterSpecificity = Object.keys(range.parameters).length; + if (range.type === representation.type && range.subtype === representation.subtype) { + return 2000 + parameterSpecificity; + } + if (range.type === representation.type && range.subtype === "*") { + return 1000 + parameterSpecificity; + } + if (range.type === "*" && range.subtype === "*") { + return parameterSpecificity; + } + return -1; +} + +function representationQuality(ranges: MediaRange[], format: PageFormat): number { + const representation = REPRESENTATIONS[format]; + let selected: MediaRange | null = null; + let selectedSpecificity = -1; + for (const range of ranges) { + const specificity = matchSpecificity(range, representation); + if (specificity < 0) continue; + if ( + selected === null || + specificity > selectedSpecificity || + (specificity === selectedSpecificity && range.quality > selected.quality) || + (specificity === selectedSpecificity && + range.quality === selected.quality && + range.order < selected.order) + ) { + selected = range; + selectedSpecificity = specificity; + } + } + return selected?.quality ?? 0; +} + +export function selectPageFormat(accept: string): PageFormat { + if (accept.trim() === "") return "html"; + const ranges = parseAccept(accept); + const htmlQuality = representationQuality(ranges, "html"); + const markdownQuality = representationQuality(ranges, "markdown"); + return markdownQuality > htmlQuality ? "markdown" : "html"; +} + +export function allowedMethods(api: boolean): string { + return api ? "GET, HEAD, OPTIONS" : "GET, HEAD"; +} diff --git a/worker/src/responses.ts b/worker/src/responses.ts index cfa60b6..f808e73 100644 --- a/worker/src/responses.ts +++ b/worker/src/responses.ts @@ -1,4 +1,5 @@ import { PublicError } from "./errors"; +import { allowedMethods } from "./http"; const CONTENT_SIGNAL = "search=yes, ai-input=yes, ai-train=no"; const CSP = [ @@ -80,7 +81,7 @@ export function errorResponse(error: PublicError, r2Reads = 0): Response { export function methodNotAllowed(api: boolean): Response { const headers = new Headers({ - Allow: "GET, HEAD, OPTIONS", + Allow: allowedMethods(api), "Content-Type": api ? "application/json; charset=utf-8" : "text/plain; charset=utf-8", }); applyOptions(headers, { api, cache: "none" }); diff --git a/worker/src/routes.ts b/worker/src/routes.ts index e5902de..8664cf0 100644 --- a/worker/src/routes.ts +++ b/worker/src/routes.ts @@ -1,5 +1,6 @@ import { getDocument, lookupClassification } from "./api"; import { PublicError, unavailable } from "./errors"; +import { selectPageFormat } from "./http"; import { cleanEdition, decodePathSegment, @@ -73,7 +74,7 @@ function pageObjectRoute(pathname: string, env: Env, accept: string): ResolvedRo return null; } const kind = segments[1]; - const wantsMarkdown = accept.toLowerCase().includes("text/markdown"); + const wantsMarkdown = selectPageFormat(accept) === "markdown"; const suffix = wantsMarkdown ? "md" : "html"; let objectSuffix: string; let chunk: string; diff --git a/worker/test/http.spec.ts b/worker/test/http.spec.ts new file mode 100644 index 0000000..46d28a6 --- /dev/null +++ b/worker/test/http.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { allowedMethods, selectPageFormat } from "../src/http"; + +describe("HTTP representation helpers", () => { + it.each([ + ["", "html"], + ["*/*", "html"], + ["text/*", "html"], + ["text/markdown", "markdown"], + ["TEXT/MARKDOWN ; Q=1", "markdown"], + ["text/html;q=1, text/markdown;q=0", "html"], + ["text/html;q=0.5, text/markdown;q=1", "markdown"], + ["text/*;q=1, text/markdown;q=0", "html"], + ["text/html;level=1;q=1, text/markdown;q=0.5", "markdown"], + ["text/html;charset=UTF-8;q=1, text/markdown;q=0.5", "html"], + ["text/html;q=0.8, text/html;charset=utf-8;q=0.4, text/markdown;q=0.6", "markdown"], + ["text/html;q=0;level=1, text/markdown;q=0.5", "markdown"], + ["text/html;q=0, text/markdown;q=0", "html"], + ["application/json", "html"], + ] as const)("selects %s as %s", (accept, expected) => { + expect(selectPageFormat(accept)).toBe(expected); + }); + + it("reports only methods implemented for each route class", () => { + expect(allowedMethods(true)).toBe("GET, HEAD, OPTIONS"); + expect(allowedMethods(false)).toBe("GET, HEAD"); + }); +}); diff --git a/worker/test/worker.spec.ts b/worker/test/worker.spec.ts index 2dae1ec..c2c266b 100644 --- a/worker/test/worker.spec.ts +++ b/worker/test/worker.spec.ts @@ -225,12 +225,20 @@ describe("static and negotiated routes", () => { const markdown = await request("/ja/classification/G06F3", { headers: { Accept: "text/markdown" }, }); + const rejectedMarkdown = await request("/ja/classification/G06F3", { + headers: { Accept: "text/html;q=1, text/markdown;q=0" }, + }); + const preferredMarkdown = await request("/ja/classification/G06F3", { + headers: { Accept: "text/html;q=0.5, text/markdown;q=1" }, + }); expect(html.status).toBe(200); expect(html.headers.get("Content-Type")).toContain("text/html"); expect(html.headers.get("Vary")).toContain("Accept"); expect(await html.text()).toContain("FI interaction 公式定義"); expect(markdown.headers.get("Content-Type")).toContain("text/markdown"); + expect(rejectedMarkdown.headers.get("Content-Type")).toContain("text/html"); + expect(preferredMarkdown.headers.get("Content-Type")).toContain("text/markdown"); const markdownBody = await markdown.text(); expect(markdownBody).toContain("# FI G06F3/048"); expect(markdownBody).toContain("合成テストデータを変換して作成しています"); @@ -291,11 +299,14 @@ describe("HTTP policy", () => { it("handles preflight, methods, CORS, and security headers", async () => { const options = await request("/api/v1/lookup", { method: "OPTIONS" }); const post = await request("/api/v1/lookup", { method: "POST" }); + const staticOptions = await request("/ja/classification/G06F3", { method: "OPTIONS" }); expect(options.status).toBe(204); expect(options.headers.get("Access-Control-Allow-Origin")).toBe("*"); expect(post.status).toBe(405); - expect(post.headers.get("Allow")).toContain("GET"); + expect(post.headers.get("Allow")).toBe("GET, HEAD, OPTIONS"); + expect(staticOptions.status).toBe(405); + expect(staticOptions.headers.get("Allow")).toBe("GET, HEAD"); expect(post.headers.get("Content-Security-Policy")).toContain("default-src 'none'"); expect(post.headers.get("Strict-Transport-Security")).toContain("max-age=31536000"); expect(post.headers.get("X-Content-Type-Options")).toBe("nosniff"); diff --git a/worker/vitest.config.ts b/worker/vitest.config.ts index 60b4b67..af3ff19 100644 --- a/worker/vitest.config.ts +++ b/worker/vitest.config.ts @@ -15,6 +15,6 @@ export default defineConfig({ }), ], test: { - include: ["test/worker.spec.ts"], + include: ["test/worker.spec.ts", "test/http.spec.ts"], }, });