Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/decisions/0010-worker-http-negotiation.md
Original file line number Diff line number Diff line change
@@ -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`.
39 changes: 39 additions & 0 deletions docs/verification/2026-08-22-worker-http-contract.md
Original file line number Diff line number Diff line change
@@ -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を再実行する。
124 changes: 124 additions & 0 deletions worker/src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
export type PageFormat = "html" | "markdown";

interface MediaRange {
type: string;
subtype: string;
parameters: Readonly<Record<string, string>>;
quality: number;
order: number;
}

interface Representation {
type: string;
subtype: string;
parameters: Readonly<Record<string, string>>;
}

const REPRESENTATIONS: Readonly<Record<PageFormat, Representation>> = {
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<string, string> = {};
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";
Comment thread
Nagi-Inaba marked this conversation as resolved.
}

export function allowedMethods(api: boolean): string {
return api ? "GET, HEAD, OPTIONS" : "GET, HEAD";
}
3 changes: 2 additions & 1 deletion worker/src/responses.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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" });
Expand Down
3 changes: 2 additions & 1 deletion worker/src/routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getDocument, lookupClassification } from "./api";
import { PublicError, unavailable } from "./errors";
import { selectPageFormat } from "./http";
import {
cleanEdition,
decodePathSegment,
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions worker/test/http.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
13 changes: 12 additions & 1 deletion worker/test/worker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("合成テストデータを変換して作成しています");
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion worker/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ export default defineConfig({
}),
],
test: {
include: ["test/worker.spec.ts"],
include: ["test/worker.spec.ts", "test/http.spec.ts"],
},
});