Repository navigation
fix: honor HTTP negotiation and method contracts #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
c4b4749
fix: honor HTTP negotiation and method contracts
github-actions[bot] d83b819
ci: trigger validation for Worker HTTP fix
Nagi-Inaba 4a7bd8a
fix: honor Accept media parameters
Nagi-Inaba 3b5a32c
test: cover media parameters and q=0 fallback
Nagi-Inaba 2827511
test: execute HTTP helper coverage in Worker verify
Nagi-Inaba b640258
docs: record Worker HTTP negotiation decision
Nagi-Inaba 8f1dce9
docs: define Worker HTTP verification scope
Nagi-Inaba 459d9fc
fix: rank parameterized media ranges by specificity
Nagi-Inaba f6b0b18
test: cover parameter specificity precedence
Nagi-Inaba 21ae724
docs: record successful Worker HTTP verification
Nagi-Inaba bb02b06
merge: sync latest main into worker HTTP contract branch
Nagi-Inaba File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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を再実行する。 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
| } | ||
|
|
||
| export function allowedMethods(api: boolean): string { | ||
| return api ? "GET, HEAD, OPTIONS" : "GET, HEAD"; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.