From 6bd9aa4a2255eefcbf88accbfc0d35bd8e3fbf95 Mon Sep 17 00:00:00 2001 From: Elia <83713217+eliahilse@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:15:54 +0200 Subject: [PATCH] feat: HTTP negotiation, codec registry, and cross-language interop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three conformant implementations could not, until now, serve a single request. The site advertised transparent JSON fallback and per-request negotiation; both were described in the spec and absent from the code. spec/negotiation-v1.md defines the exchange. A client advertises the fingerprints it holds in Hyperfly-Accept, most preferred first; a server serves the first one it can and otherwise answers JSON with a Hyperfly-Offer naming an artifact the client could fetch. Steady state costs no extra round trip, a cold client bootstraps in one, and every response carries Vary: Hyperfly-Accept because the same URL yields either representation and a shared cache would otherwise hand one peer's binary to a peer that cannot read it. CodecRegistry is the piece that makes profiles operable rather than theoretical. Retraining changes the fingerprint, so a deployment holding one codec per route turns every rollout into a cutover in which in-flight clients fall back to JSON until the fleet converges — the incentive being never to retrain, which quietly defeats the feature. Holding the outgoing codec beside the incoming one makes rotation a transition, and letting the client's preference decide lets it migrate itself without the server tracking who holds what. Artifacts are served from .well-known and are content-addressed, so a hit is immutable and cacheable forever while a miss is a 404 rather than an error. A client derives its codec from the parsed artifact and verifies the fingerprint it computes equals the one it requested, so a server cannot induce it to hash bytes it has not understood. Request bodies get 415 rather than a guess, because a body already sent has no safe fallback. apps/interop is the demonstration the golden vectors imply but cannot give: a Bun server and a Python client walking the whole protocol over real HTTP. The client starts empty, is offered an artifact, fetches it, checks it, and then reads 258 bytes where it first received 2447 — with both representations carrying an identical value. CI now runs it on every push. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_hf1 --- .github/workflows/ci.yml | 31 ++++ apps/interop/README.md | 21 +++ apps/interop/client.py | 60 ++++++++ apps/interop/eslint.config.js | 4 + apps/interop/package.json | 22 +++ apps/interop/server.ts | 62 ++++++++ apps/interop/tsconfig.json | 9 ++ bun.lock | 17 +++ packages/hyperfly/package.json | 14 +- packages/hyperfly/src/http.ts | 214 +++++++++++++++++++++++++++ packages/hyperfly/src/index.ts | 1 + packages/hyperfly/src/registry.ts | 58 ++++++++ packages/hyperfly/test/http.test.ts | 216 ++++++++++++++++++++++++++++ python/src/hyperfly/__init__.py | 2 + python/src/hyperfly/http.py | 130 +++++++++++++++++ python/src/hyperfly/registry.py | 52 +++++++ python/tests/test_http.py | 123 ++++++++++++++++ spec/negotiation-v1.md | 135 +++++++++++++++++ turbo.json | 5 +- 19 files changed, 1173 insertions(+), 3 deletions(-) create mode 100644 apps/interop/README.md create mode 100644 apps/interop/client.py create mode 100644 apps/interop/eslint.config.js create mode 100644 apps/interop/package.json create mode 100644 apps/interop/server.ts create mode 100644 apps/interop/tsconfig.json create mode 100644 packages/hyperfly/src/http.ts create mode 100644 packages/hyperfly/src/registry.ts create mode 100644 packages/hyperfly/test/http.test.ts create mode 100644 python/src/hyperfly/http.py create mode 100644 python/src/hyperfly/registry.py create mode 100644 python/tests/test_http.py create mode 100644 spec/negotiation-v1.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbfd454..a5f2cf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,34 @@ jobs: - uses: dtolnay/rust-toolchain@stable - run: cargo test --manifest-path rust/Cargo.toml + + interop: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: bun install --frozen-lockfile + + - run: bun run build --filter=hyperfly + + - run: pip install ./python + + # a TypeScript server and a Python client over the real protocol: the proof + # the golden vectors imply but cannot demonstrate + - name: cross-language interop + run: | + bun apps/interop/server.ts & + for i in $(seq 1 30); do + curl -sf http://127.0.0.1:8787/fingerprints > /dev/null && break + sleep 1 + done + python apps/interop/client.py diff --git a/apps/interop/README.md b/apps/interop/README.md new file mode 100644 index 0000000..0995f28 --- /dev/null +++ b/apps/interop/README.md @@ -0,0 +1,21 @@ +# interop + +A TypeScript server and a Python client speaking `spec/negotiation-v1.md` over real +HTTP. The golden vectors prove the three implementations agree on bytes; this proves +two of them agree on a conversation. + +```bash +bun run build --filter=hyperfly +bun apps/interop/server.ts & +PYTHONPATH=python/src python3 apps/interop/client.py +``` + +The client starts holding nothing, so the exchange walks the whole protocol: + +1. it asks with no `Hyperfly-Accept` and gets JSON plus a `Hyperfly-Offer` +2. it fetches that artifact from `.well-known`, derives the codec from the parsed + content, and checks the fingerprint it computed equals the one it asked for +3. it asks again advertising that fingerprint and gets binary +4. it asserts the decoded value equals the JSON the server sent in step 1 + +CI runs this on every push. diff --git a/apps/interop/client.py b/apps/interop/client.py new file mode 100644 index 0000000..378617a --- /dev/null +++ b/apps/interop/client.py @@ -0,0 +1,60 @@ +"""A Python client speaking the negotiation protocol against the TypeScript server. + +It starts with no artifacts, is offered one, fetches it, verifies the fingerprint it +derives matches the one it asked for, and only then speaks binary. +""" + +from __future__ import annotations + +import json +import sys +import urllib.request + +from hyperfly import CodecRegistry, compile_ir +from hyperfly.http import accept_header, decode_response + +BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8787" + + +def get(path: str, accept: str | None = None) -> tuple[int, dict[str, str], bytes]: + request = urllib.request.Request(BASE + path) + if accept: + request.add_header("Hyperfly-Accept", accept) + with urllib.request.urlopen(request) as response: + return response.status, {k.lower(): v for k, v in response.headers.items()}, response.read() + + +def main() -> int: + registry = CodecRegistry() + + status, headers, body = get("/v1/events") + kind, value = decode_response(headers.get("content-type"), body, registry) + assert kind == "json", kind + offered = headers.get("hyperfly-offer") + assert offered, "server should offer an artifact to a client that has none" + json_bytes = len(body) + print(f"1. no artifacts -> {kind}, {json_bytes} B, offered {offered[:8]}") + + _, _, artifact_text = get(f"/.well-known/hyperfly/{offered}") + artifact = json.loads(artifact_text) + codec = compile_ir(artifact["ir"], plan=artifact["plan"]["layout"], profile=artifact.get("profile") and { + "version": 1, + "shared": artifact["profile"], + }) + assert codec.fingerprint == offered, "a client must verify what it was handed" + registry.add(codec) + print(f"2. fetched artifact, derived fingerprint matches: {codec.fingerprint[:8]}") + + status, headers, body = get("/v1/events", accept_header([codec.fingerprint])) + kind, decoded = decode_response(headers.get("content-type"), body, registry) + assert kind == "hyperfly", kind + assert headers.get("vary") == "Hyperfly-Accept" + print(f"3. binary -> {len(body)} B ({json_bytes / len(body):.1f}x smaller than the JSON)") + + assert decoded == value, "the two representations must carry the same value" + print("4. binary value equals the JSON value the server sent first") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/interop/eslint.config.js b/apps/interop/eslint.config.js new file mode 100644 index 0000000..60e4377 --- /dev/null +++ b/apps/interop/eslint.config.js @@ -0,0 +1,4 @@ +import { config } from "@repo/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default config; diff --git a/apps/interop/package.json b/apps/interop/package.json new file mode 100644 index 0000000..49a15da --- /dev/null +++ b/apps/interop/package.json @@ -0,0 +1,22 @@ +{ + "name": "@hyperfly/interop", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "serve": "bun server.ts", + "check-types": "tsc --noEmit", + "lint": "eslint --max-warnings 0" + }, + "dependencies": { + "hyperfly": "workspace:*", + "zod": "^4.0.0" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/typescript-config": "*", + "@types/bun": "^1.2.0", + "eslint": "^9.39.1", + "typescript": "5.9.2" + } +} diff --git a/apps/interop/server.ts b/apps/interop/server.ts new file mode 100644 index 0000000..4dd95a8 --- /dev/null +++ b/apps/interop/server.ts @@ -0,0 +1,62 @@ +/** + * A TypeScript server speaking the negotiation protocol. Paired with client.py, this + * is the end-to-end proof the golden vectors imply but cannot demonstrate: two + * independent implementations agreeing over a real HTTP exchange. + */ +import { z } from "zod"; +import { CodecRegistry, train } from "hyperfly"; +import { compile, toIR } from "hyperfly/zod"; +import { discovery, respond } from "hyperfly/http"; + +const EventResponse = z.object({ + route: z.literal("events"), + cursor: z.string().nullable(), + events: z.array( + z.object({ + id: z.string(), + type: z.enum(["user.login", "file.upload", "billing.charge"]), + actorEmail: z.string(), + ok: z.boolean(), + at: z.number().int().min(0), + }), + ), +}); + +const ACTORS = ["ada@acme.io", "grace@acme.io", "linus@globex.com"]; +const sample = (n: number) => ({ + route: "events" as const, + cursor: null, + events: Array.from({ length: n }, (_, i) => ({ + id: `evt_${i.toString(16).padStart(6, "0")}`, + type: (["user.login", "file.upload", "billing.charge"] as const)[i % 3]!, + actorEmail: ACTORS[i % ACTORS.length]!, + ok: i % 7 !== 0, + at: 1755000000000 + i * 1000, + })), +}); + +const profile = train(toIR(EventResponse), Array.from({ length: 20 }, (_, i) => sample(10 + i))); +const registry = new CodecRegistry([ + compile(EventResponse, { plan: "columnar" }) as never, + compile(EventResponse, { plan: "columnar", profile }) as never, +]); + +const port = Number(process.env.PORT ?? 8787); +Bun.serve({ + port, + fetch(request) { + const artifact = discovery(request, registry); + if (artifact) return artifact; + + const url = new URL(request.url); + if (url.pathname === "/v1/events") { + return respond(request, sample(24), registry); + } + if (url.pathname === "/fingerprints") { + return Response.json(registry.fingerprints); + } + return new Response("not found", { status: 404 }); + }, +}); + +console.log(`interop server on :${port} — ${registry.size} codecs`); diff --git a/apps/interop/tsconfig.json b/apps/interop/tsconfig.json new file mode 100644 index 0000000..e21266b --- /dev/null +++ b/apps/interop/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "noEmit": true, + "module": "Preserve", + "moduleResolution": "bundler" + }, + "include": ["*.ts"] +} diff --git a/bun.lock b/bun.lock index 5e4ff1c..b51b4f4 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,21 @@ "typescript": "5.9.2", }, }, + "apps/interop": { + "name": "@hyperfly/interop", + "version": "0.0.0", + "dependencies": { + "hyperfly": "workspace:*", + "zod": "^4.0.0", + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/typescript-config": "*", + "@types/bun": "^1.2.0", + "eslint": "^9.39.1", + "typescript": "5.9.2", + }, + }, "apps/web": { "name": "@hyperfly/web", "version": "0.1.0", @@ -323,6 +338,8 @@ "@hyperfly/bench": ["@hyperfly/bench@workspace:apps/bench"], + "@hyperfly/interop": ["@hyperfly/interop@workspace:apps/interop"], + "@hyperfly/lb": ["@hyperfly/lb@workspace:packages/lb"], "@hyperfly/web": ["@hyperfly/web@workspace:apps/web"], diff --git a/packages/hyperfly/package.json b/packages/hyperfly/package.json index fb3f243..c4d33a1 100644 --- a/packages/hyperfly/package.json +++ b/packages/hyperfly/package.json @@ -17,9 +17,17 @@ "./zod": { "types": "./dist/zod.d.ts", "import": "./dist/zod.js" + }, + "./http": { + "types": "./dist/http.d.ts", + "import": "./dist/http.js" } }, - "files": ["dist", "LICENSE", "NOTICE"], + "files": [ + "dist", + "LICENSE", + "NOTICE" + ], "scripts": { "build": "tsc -p tsconfig.build.json", "check-types": "tsc --noEmit", @@ -30,7 +38,9 @@ "zod": "^4.0.0" }, "peerDependenciesMeta": { - "zod": { "optional": true } + "zod": { + "optional": true + } }, "devDependencies": { "@repo/eslint-config": "*", diff --git a/packages/hyperfly/src/http.ts b/packages/hyperfly/src/http.ts new file mode 100644 index 0000000..e491381 --- /dev/null +++ b/packages/hyperfly/src/http.ts @@ -0,0 +1,214 @@ +import type { Codec } from "./codec.js"; +import type { CodecRegistry } from "./registry.js"; + +export const HYPERFLY_MEDIA_TYPE = "application/vnd.hyperfly"; +export const ACCEPT_HEADER = "hyperfly-accept"; +export const CODEC_HEADER = "hyperfly-codec"; +export const OFFER_HEADER = "hyperfly-offer"; +export const WELL_KNOWN_PREFIX = "/.well-known/hyperfly/"; + +/** Negotiation §6: client-controlled input, so parsing is bounded. */ +const MAX_ACCEPTED = 32; +const FINGERPRINT = /^[0-9a-f]{32}$/; + +/** + * Fingerprints the client says it can decode, in its order of preference. + * Malformed entries are dropped rather than failing the request. + */ +export function parseAccept(header: string | null | undefined): string[] { + if (!header) return []; + const out: string[] = []; + for (const part of header.split(",")) { + if (out.length >= MAX_ACCEPTED) break; + const value = part.trim().toLowerCase(); + if (FINGERPRINT.test(value) && !out.includes(value)) out.push(value); + } + return out; +} + +export type Negotiation = + | { kind: "hyperfly"; codec: Codec; headers: Record } + | { kind: "json"; headers: Record }; + +export interface NegotiateOptions { + /** Named in Hyperfly-Offer when falling back, so a client can upgrade itself. */ + offer?: string; + /** An operator switch or a load shed: fall back without consulting the registry. */ + enabled?: boolean; +} + +/** + * Decide how to answer one request. Framework-agnostic on purpose: everything a + * server needs is the Hyperfly-Accept header and a registry. + * + * Vary is always set, because the same URL yields either representation and a + * shared cache would otherwise hand one peer's binary to a peer that cannot read it. + */ +export function negotiate( + accept: string | null | undefined, + registry: CodecRegistry, + options: NegotiateOptions = {}, +): Negotiation { + const vary = { Vary: "Hyperfly-Accept" }; + + if (options.enabled === false) { + return { kind: "json", headers: { ...vary, "Content-Type": "application/json" } }; + } + + const codec = registry.select(parseAccept(accept)); + if (codec) { + return { + kind: "hyperfly", + codec: codec as unknown as Codec, + headers: { + ...vary, + "Content-Type": HYPERFLY_MEDIA_TYPE, + "Hyperfly-Codec": codec.fingerprint, + }, + }; + } + + const offer = options.offer ?? registry.fingerprints[0]; + return { + kind: "json", + headers: { + ...vary, + "Content-Type": "application/json", + ...(offer ? { "Hyperfly-Offer": offer } : {}), + }, + }; +} + +export interface Encoded { + body: Uint8Array | string; + headers: Record; +} + +/** Encode one value according to a negotiation decision. */ +export function encodeFor(decision: Negotiation, value: T): Encoded { + if (decision.kind === "hyperfly") { + return { body: decision.codec.encode(value), headers: decision.headers }; + } + return { body: JSON.stringify(value), headers: decision.headers }; +} + +export interface WellKnownResponse { + status: number; + body: string; + headers: Record; +} + +/** + * Serve `.well-known/hyperfly/{fingerprint}` (negotiation §3). Artifacts are + * content-addressed, so a hit is immutable and cacheable forever; a miss is a 404 + * and not an error — the client simply stays on JSON. + */ +export function serveArtifact(pathname: string, registry: CodecRegistry): WellKnownResponse | undefined { + if (!pathname.startsWith(WELL_KNOWN_PREFIX)) return undefined; + const fingerprint = pathname.slice(WELL_KNOWN_PREFIX.length).toLowerCase(); + if (!FINGERPRINT.test(fingerprint)) { + return { status: 404, body: "", headers: { "Cache-Control": "no-store" } }; + } + const artifact = registry.artifact(fingerprint); + if (!artifact) { + return { status: 404, body: "", headers: { "Cache-Control": "no-store" } }; + } + return { + status: 200, + body: artifact, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }; +} + +/** The header a client sends. Preference order is the caller's. */ +export function acceptHeader(fingerprints: readonly string[]): string { + return fingerprints.join(", "); +} + +/** + * Decode a response body according to what the server said it sent. A codec the + * client does not hold is not an error the client can recover from by guessing, + * so it is reported as a miss and the caller re-requests as JSON. + */ +export function decodeResponse( + contentType: string | null | undefined, + body: Uint8Array | string, + registry: CodecRegistry, +): { kind: "json"; value: T } | { kind: "hyperfly"; value: T } | { kind: "unknown-codec"; fingerprint: string } { + const isHyperfly = (contentType ?? "").toLowerCase().startsWith(HYPERFLY_MEDIA_TYPE); + if (!isHyperfly) { + const text = typeof body === "string" ? body : new TextDecoder().decode(body); + return { kind: "json", value: JSON.parse(text) as T }; + } + const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body; + let fingerprint = ""; + for (let i = 3; i < 19 && i < bytes.length; i++) fingerprint += bytes[i]!.toString(16).padStart(2, "0"); + const codec = registry.get(fingerprint); + if (!codec) return { kind: "unknown-codec", fingerprint }; + return { kind: "hyperfly", value: codec.decode(bytes) as T }; +} + +/** + * Fetch-API glue. One function covers Hono, Cloudflare Workers, Bun.serve, Deno and + * Next route handlers, because they all speak Request and Response. + */ +export interface FetchHandlerOptions extends NegotiateOptions { + /** Also answer .well-known artifact discovery. On by default (negotiation §3). */ + discovery?: boolean; + status?: number; + headers?: Record; +} + +/** Answer one request with `value`, in binary when the client can read it. */ +export function respond( + request: Request, + value: T, + registry: CodecRegistry, + options: FetchHandlerOptions = {}, +): Response { + const decision = negotiate(request.headers.get(ACCEPT_HEADER), registry, options); + const { body, headers } = encodeFor(decision, value); + return new Response(body as BodyInit, { + status: options.status ?? 200, + headers: { ...headers, ...options.headers }, + }); +} + +/** + * Artifact discovery as a Response, or undefined when the path is not ours — so a + * caller can chain it ahead of its own router in one line. + */ +export function discovery(request: Request, registry: CodecRegistry): Response | undefined { + const served = serveArtifact(new URL(request.url).pathname, registry); + if (!served) return undefined; + return new Response(served.body || null, { status: served.status, headers: served.headers }); +} + +/** Read a hyperfly or JSON request body (negotiation §4). */ +export async function readBody( + request: Request, + registry: CodecRegistry, +): Promise<{ ok: true; value: T } | { ok: false; response: Response }> { + const contentType = request.headers.get("content-type"); + if (!(contentType ?? "").toLowerCase().startsWith(HYPERFLY_MEDIA_TYPE)) { + return { ok: true, value: (await request.json()) as T }; + } + const bytes = new Uint8Array(await request.arrayBuffer()); + const decoded = decodeResponse(contentType, bytes, registry); + if (decoded.kind === "unknown-codec") { + // a body already sent in a format we cannot read has no safe fallback + return { + ok: false, + response: new Response(null, { + status: 415, + headers: { + ...(registry.fingerprints[0] ? { "Hyperfly-Offer": registry.fingerprints[0] } : {}), + }, + }), + }; + } + return { ok: true, value: decoded.value }; +} diff --git a/packages/hyperfly/src/index.ts b/packages/hyperfly/src/index.ts index ca825e7..f513841 100644 --- a/packages/hyperfly/src/index.ts +++ b/packages/hyperfly/src/index.ts @@ -3,6 +3,7 @@ export { defaultPackHooks } from "./pack.js"; export { serializeArtifact, serializeNode, fingerprintOf, toHex, type PlanLayout } from "./canonical.js"; export { columnarEligible } from "./columnar.js"; export { train, type TrainOptions } from "./train.js"; +export { CodecRegistry } from "./registry.js"; export { enumerateColumns, validateProfile, diff --git a/packages/hyperfly/src/registry.ts b/packages/hyperfly/src/registry.ts new file mode 100644 index 0000000..08a1f90 --- /dev/null +++ b/packages/hyperfly/src/registry.ts @@ -0,0 +1,58 @@ +import type { Codec } from "./codec.js"; + +/** + * Codecs keyed by fingerprint. Rotation is why this exists: retraining a profile + * produces a new fingerprint, so a deployment that holds only one codec per route + * turns every rollout into a cutover in which in-flight clients fall back to JSON. + * Holding the outgoing codec alongside the incoming one makes that a transition. + */ +export class CodecRegistry { + private readonly byFingerprint = new Map>(); + + constructor(codecs: readonly Codec[] = []) { + for (const codec of codecs) this.add(codec); + } + + add(codec: Codec): this { + this.byFingerprint.set(codec.fingerprint, codec); + return this; + } + + remove(fingerprint: string): boolean { + return this.byFingerprint.delete(fingerprint); + } + + get(fingerprint: string): Codec | undefined { + return this.byFingerprint.get(fingerprint); + } + + has(fingerprint: string): boolean { + return this.byFingerprint.has(fingerprint); + } + + get fingerprints(): string[] { + return [...this.byFingerprint.keys()]; + } + + get size(): number { + return this.byFingerprint.size; + } + + /** The artifact text for a fingerprint, for `.well-known` discovery (negotiation §3). */ + artifact(fingerprint: string): string | undefined { + return this.byFingerprint.get(fingerprint)?.artifact; + } + + /** + * The client's preference wins: the first fingerprint it lists that we can serve. + * That is what lets a client migrate itself during a rotation without the server + * tracking who holds what. + */ + select(accepted: readonly string[]): Codec | undefined { + for (const fingerprint of accepted) { + const codec = this.byFingerprint.get(fingerprint); + if (codec) return codec; + } + return undefined; + } +} diff --git a/packages/hyperfly/test/http.test.ts b/packages/hyperfly/test/http.test.ts new file mode 100644 index 0000000..a5b0b77 --- /dev/null +++ b/packages/hyperfly/test/http.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; +import { CodecRegistry, type IRNode } from "../src/index.js"; +import { compileIR } from "../src/index.js"; +import { + ACCEPT_HEADER, + HYPERFLY_MEDIA_TYPE, + acceptHeader, + decodeResponse, + encodeFor, + negotiate, + parseAccept, + serveArtifact, + respond, + discovery, + readBody, +} from "../src/http.js"; +import { compile } from "../src/zod.js"; + +const Schema = z.object({ id: z.string(), n: z.number().int().min(0) }); +const codecA = compile(Schema); +const codecB = compile(Schema, { plan: "columnar" }); +const value = { id: "abc", n: 7 }; + +describe("parseAccept", () => { + test("keeps order, lowercases, drops junk and duplicates", () => { + const a = "A".repeat(32); + const parsed = parseAccept(`${a}, not-a-fingerprint, ${a.toLowerCase()}, ${"b".repeat(32)}`); + expect(parsed).toEqual(["a".repeat(32), "b".repeat(32)]); + }); + + test("absent or empty header yields nothing", () => { + expect(parseAccept(null)).toEqual([]); + expect(parseAccept("")).toEqual([]); + }); + + test("a hostile header is bounded, not fatal", () => { + const flood = Array.from({ length: 5000 }, (_, i) => i.toString(16).padStart(32, "0")).join(","); + expect(parseAccept(flood).length).toBeLessThanOrEqual(32); + }); +}); + +describe("negotiate", () => { + const registry = new CodecRegistry([codecA as never]); + + test("serves binary when the client holds the codec", () => { + const decision = negotiate(acceptHeader([codecA.fingerprint]), registry); + expect(decision.kind).toBe("hyperfly"); + expect(decision.headers["Content-Type"]).toBe(HYPERFLY_MEDIA_TYPE); + expect(decision.headers["Hyperfly-Codec"]).toBe(codecA.fingerprint); + }); + + test("falls back to JSON and offers an upgrade when nothing matches", () => { + const decision = negotiate(acceptHeader(["f".repeat(32)]), registry); + expect(decision.kind).toBe("json"); + expect(decision.headers["Hyperfly-Offer"]).toBe(codecA.fingerprint); + }); + + test("a client that says nothing gets JSON", () => { + expect(negotiate(undefined, registry).kind).toBe("json"); + }); + + test("always varies, so a cache cannot cross-serve representations", () => { + for (const accept of [acceptHeader([codecA.fingerprint]), undefined]) { + expect(negotiate(accept, registry).headers["Vary"]).toBe("Hyperfly-Accept"); + } + }); + + test("an operator switch falls back without consulting the registry", () => { + const decision = negotiate(acceptHeader([codecA.fingerprint]), registry, { enabled: false }); + expect(decision.kind).toBe("json"); + }); + + test("client preference decides, which is what makes rotation work", () => { + const both = new CodecRegistry([codecA as never, codecB as never]); + expect(negotiate(acceptHeader([codecB.fingerprint, codecA.fingerprint]), both)).toMatchObject({ + headers: { "Hyperfly-Codec": codecB.fingerprint }, + }); + expect(negotiate(acceptHeader([codecA.fingerprint, codecB.fingerprint]), both)).toMatchObject({ + headers: { "Hyperfly-Codec": codecA.fingerprint }, + }); + }); +}); + +describe("round trip over the protocol", () => { + const registry = new CodecRegistry([codecA as never]); + + test("binary out, binary in", () => { + const decision = negotiate(acceptHeader([codecA.fingerprint]), registry); + const { body, headers } = encodeFor(decision, value); + const decoded = decodeResponse(headers["Content-Type"], body, registry); + expect(decoded).toEqual({ kind: "hyperfly", value }); + }); + + test("json out, json in", () => { + const decision = negotiate(undefined, registry); + const { body, headers } = encodeFor(decision, value); + expect(decodeResponse(headers["Content-Type"], body, registry)).toEqual({ kind: "json", value }); + }); + + test("a client without the codec reports a miss rather than guessing", () => { + const decision = negotiate(acceptHeader([codecA.fingerprint]), registry); + const { body, headers } = encodeFor(decision, value); + const bare = new CodecRegistry(); + expect(decodeResponse(headers["Content-Type"], body, bare)).toEqual({ + kind: "unknown-codec", + fingerprint: codecA.fingerprint, + }); + }); +}); + +describe("artifact discovery", () => { + const registry = new CodecRegistry([codecA as never]); + + test("serves the canonical artifact, immutably", () => { + const res = serveArtifact(`/.well-known/hyperfly/${codecA.fingerprint}`, registry)!; + expect(res.status).toBe(200); + expect(res.body).toBe(codecA.artifact); + expect(res.headers["Cache-Control"]).toContain("immutable"); + }); + + test("a client can bootstrap from the artifact and then speak binary", () => { + const res = serveArtifact(`/.well-known/hyperfly/${codecA.fingerprint}`, registry)!; + // the client derives its own codec from the parsed artifact, never trusting the text + const parsed = JSON.parse(res.body) as { plan: { layout: string }; ir: IRNode }; + const rebuilt = compileIR(parsed.ir, { plan: parsed.plan.layout as "row" }); + expect(rebuilt.fingerprint).toBe(codecA.fingerprint); + expect(rebuilt.decode(codecA.encode(value as never))).toEqual(value as never); + }); + + test("unknown and malformed fingerprints are 404, not errors", () => { + expect(serveArtifact(`/.well-known/hyperfly/${"f".repeat(32)}`, registry)!.status).toBe(404); + expect(serveArtifact("/.well-known/hyperfly/nope", registry)!.status).toBe(404); + }); + + test("unrelated paths are not ours", () => { + expect(serveArtifact("/v1/events", registry)).toBeUndefined(); + }); +}); + +describe("rotation", () => { + test("holding both codecs keeps every client served during a rollout", () => { + const registry = new CodecRegistry([codecA as never]); + const oldClient = acceptHeader([codecA.fingerprint]); + const newClient = acceptHeader([codecB.fingerprint, codecA.fingerprint]); + + expect(negotiate(newClient, registry).kind).toBe("hyperfly"); + registry.add(codecB as never); + expect(negotiate(newClient, registry)).toMatchObject({ headers: { "Hyperfly-Codec": codecB.fingerprint } }); + expect(negotiate(oldClient, registry)).toMatchObject({ headers: { "Hyperfly-Codec": codecA.fingerprint } }); + + registry.remove(codecA.fingerprint); + expect(negotiate(newClient, registry)).toMatchObject({ headers: { "Hyperfly-Codec": codecB.fingerprint } }); + expect(negotiate(oldClient, registry).kind).toBe("json"); + }); +}); + +describe("header name constants", () => { + test("are the lowercase forms a fetch Headers lookup uses", () => { + expect(ACCEPT_HEADER).toBe("hyperfly-accept"); + }); +}); + +describe("fetch adapter", () => { + const registry = new CodecRegistry([codecA as never]); + const url = "https://example.test/v1/thing"; + + test("responds in binary to a client that holds the codec", async () => { + const request = new Request(url, { headers: { "Hyperfly-Accept": codecA.fingerprint } }); + const response = respond(request, value, registry); + expect(response.headers.get("content-type")).toBe(HYPERFLY_MEDIA_TYPE); + expect(response.headers.get("vary")).toBe("Hyperfly-Accept"); + const bytes = new Uint8Array(await response.arrayBuffer()); + expect(codecA.decode(bytes as never)).toEqual(value as never); + }); + + test("responds in JSON to a plain client", async () => { + const response = respond(new Request(url), value, registry); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json()).toEqual(value); + }); + + test("discovery answers its own paths and declines others", async () => { + const hit = discovery(new Request(`https://example.test/.well-known/hyperfly/${codecA.fingerprint}`), registry); + expect(hit!.status).toBe(200); + expect(await hit!.text()).toBe(codecA.artifact); + expect(discovery(new Request(url), registry)).toBeUndefined(); + }); + + test("reads a binary request body, and refuses one it cannot read", async () => { + const encoded = codecA.encode(value as never); + const ok = await readBody( + new Request(url, { method: "POST", body: encoded as BodyInit, headers: { "Content-Type": HYPERFLY_MEDIA_TYPE } }), + registry, + ); + expect(ok).toEqual({ ok: true, value }); + + const stranger = await readBody( + new Request(url, { method: "POST", body: codecB.encode(value as never) as BodyInit, headers: { "Content-Type": HYPERFLY_MEDIA_TYPE } }), + registry, + ); + expect(stranger.ok).toBe(false); + if (!stranger.ok) { + expect(stranger.response.status).toBe(415); + expect(stranger.response.headers.get("hyperfly-offer")).toBe(codecA.fingerprint); + } + }); + + test("a JSON request body still works", async () => { + const result = await readBody( + new Request(url, { method: "POST", body: JSON.stringify(value), headers: { "Content-Type": "application/json" } }), + registry, + ); + expect(result).toEqual({ ok: true, value }); + }); +}); diff --git a/python/src/hyperfly/__init__.py b/python/src/hyperfly/__init__.py index 8f405fd..68e9cfb 100644 --- a/python/src/hyperfly/__init__.py +++ b/python/src/hyperfly/__init__.py @@ -1,3 +1,4 @@ +from .registry import CodecRegistry from ._codec import Codec, HEADER_SIZE, MAGIC, WIRE_VERSION, compile_ir from ._ir import fingerprint_of, serialize_artifact, serialize_node, validate_ir from ._wire import ( @@ -14,6 +15,7 @@ __all__ = [ "Codec", + "CodecRegistry", "DEFAULT_LIMITS", "DecodeError", "EncodeError", diff --git a/python/src/hyperfly/http.py b/python/src/hyperfly/http.py new file mode 100644 index 0000000..cc1ee07 --- /dev/null +++ b/python/src/hyperfly/http.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Any + +from ._codec import Codec +from .registry import CodecRegistry + +HYPERFLY_MEDIA_TYPE = "application/vnd.hyperfly" +ACCEPT_HEADER = "hyperfly-accept" +CODEC_HEADER = "hyperfly-codec" +OFFER_HEADER = "hyperfly-offer" +WELL_KNOWN_PREFIX = "/.well-known/hyperfly/" + +_MAX_ACCEPTED = 32 +_FINGERPRINT = re.compile(r"^[0-9a-f]{32}$") + + +def parse_accept(header: str | None) -> list[str]: + """Fingerprints the client can decode, in its order of preference. + + Client-controlled input, so parsing is bounded and malformed entries are dropped + rather than failing the request (negotiation section 6). + """ + if not header: + return [] + out: list[str] = [] + for part in header.split(","): + if len(out) >= _MAX_ACCEPTED: + break + value = part.strip().lower() + if _FINGERPRINT.match(value) and value not in out: + out.append(value) + return out + + +@dataclass(frozen=True) +class Negotiation: + kind: str + headers: dict[str, str] + codec: Codec | None = None + + +def negotiate( + accept: str | None, + registry: CodecRegistry, + *, + offer: str | None = None, + enabled: bool = True, +) -> Negotiation: + """Decide how to answer one request. + + Vary is always set: the same URL yields either representation, and a shared cache + would otherwise hand one peer's binary to a peer that cannot read it. + """ + vary = {"Vary": "Hyperfly-Accept"} + + if not enabled: + return Negotiation("json", {**vary, "Content-Type": "application/json"}) + + codec = registry.select(parse_accept(accept)) + if codec is not None: + return Negotiation( + "hyperfly", + {**vary, "Content-Type": HYPERFLY_MEDIA_TYPE, "Hyperfly-Codec": codec.fingerprint}, + codec, + ) + + chosen = offer or (registry.fingerprints[0] if registry.fingerprints else None) + headers = {**vary, "Content-Type": "application/json"} + if chosen: + headers["Hyperfly-Offer"] = chosen + return Negotiation("json", headers) + + +def encode_for(decision: Negotiation, value: Any) -> tuple[bytes, dict[str, str]]: + if decision.kind == "hyperfly" and decision.codec is not None: + return decision.codec.encode(value), decision.headers + return json.dumps(value, separators=(",", ":")).encode("utf-8"), decision.headers + + +@dataclass(frozen=True) +class ArtifactResponse: + status: int + body: str + headers: dict[str, str] = field(default_factory=dict) + + +def serve_artifact(pathname: str, registry: CodecRegistry) -> ArtifactResponse | None: + """Serve .well-known artifact discovery, or None when the path is not ours. + + Artifacts are content-addressed, so a hit is immutable and cacheable forever; a + miss is a 404 and not an error, because the client simply stays on JSON. + """ + if not pathname.startswith(WELL_KNOWN_PREFIX): + return None + fingerprint = pathname[len(WELL_KNOWN_PREFIX) :].lower() + if not _FINGERPRINT.match(fingerprint): + return ArtifactResponse(404, "", {"Cache-Control": "no-store"}) + artifact = registry.artifact(fingerprint) + if artifact is None: + return ArtifactResponse(404, "", {"Cache-Control": "no-store"}) + return ArtifactResponse( + 200, + artifact, + {"Content-Type": "application/json", "Cache-Control": "public, max-age=31536000, immutable"}, + ) + + +def accept_header(fingerprints: list[str]) -> str: + return ", ".join(fingerprints) + + +def decode_response(content_type: str | None, body: bytes | str, registry: CodecRegistry) -> tuple[str, Any]: + """Returns (kind, value) where kind is 'json', 'hyperfly', or 'unknown-codec'. + + A codec the client does not hold is not something it can recover from by guessing, + so it is reported rather than attempted. + """ + if not (content_type or "").lower().startswith(HYPERFLY_MEDIA_TYPE): + text = body.decode("utf-8") if isinstance(body, bytes) else body + return "json", json.loads(text) + data = body.encode("utf-8") if isinstance(body, str) else body + fingerprint = data[3:19].hex() + codec = registry.get(fingerprint) + if codec is None: + return "unknown-codec", fingerprint + return "hyperfly", codec.decode(data) diff --git a/python/src/hyperfly/registry.py b/python/src/hyperfly/registry.py new file mode 100644 index 0000000..0178d35 --- /dev/null +++ b/python/src/hyperfly/registry.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Iterable + +from ._codec import Codec + + +class CodecRegistry: + """Codecs keyed by fingerprint. + + Rotation is why this exists: retraining a profile produces a new fingerprint, so a + deployment holding only one codec per route turns every rollout into a cutover in + which in-flight clients fall back to JSON. Holding the outgoing codec alongside the + incoming one makes that a transition instead. + """ + + def __init__(self, codecs: Iterable[Codec] = ()) -> None: + self._by_fingerprint: dict[str, Codec] = {} + for codec in codecs: + self.add(codec) + + def add(self, codec: Codec) -> "CodecRegistry": + self._by_fingerprint[codec.fingerprint] = codec + return self + + def remove(self, fingerprint: str) -> bool: + return self._by_fingerprint.pop(fingerprint, None) is not None + + def get(self, fingerprint: str) -> Codec | None: + return self._by_fingerprint.get(fingerprint) + + def __contains__(self, fingerprint: object) -> bool: + return fingerprint in self._by_fingerprint + + def __len__(self) -> int: + return len(self._by_fingerprint) + + @property + def fingerprints(self) -> list[str]: + return list(self._by_fingerprint) + + def artifact(self, fingerprint: str) -> str | None: + codec = self._by_fingerprint.get(fingerprint) + return codec.artifact if codec else None + + def select(self, accepted: Iterable[str]) -> Codec | None: + """The client's preference wins, which is what lets it migrate itself.""" + for fingerprint in accepted: + codec = self._by_fingerprint.get(fingerprint) + if codec is not None: + return codec + return None diff --git a/python/tests/test_http.py b/python/tests/test_http.py new file mode 100644 index 0000000..557f67a --- /dev/null +++ b/python/tests/test_http.py @@ -0,0 +1,123 @@ +import pytest + +from hyperfly import CodecRegistry, compile_ir +from hyperfly.http import ( + HYPERFLY_MEDIA_TYPE, + accept_header, + decode_response, + encode_for, + negotiate, + parse_accept, + serve_artifact, +) + +IR = {"kind": "struct", "fields": [{"name": "id", "type": {"kind": "string"}}]} +IR_B = {"kind": "struct", "fields": [{"name": "id", "type": {"kind": "string"}}, {"name": "n", "type": {"kind": "int"}}]} +CODEC_A = compile_ir(IR) +CODEC_B = compile_ir(IR_B) +VALUE = {"id": "abc"} + + +def test_parse_accept_keeps_order_drops_junk_and_duplicates(): + a = "a" * 32 + b = "b" * 32 + assert parse_accept(f"{a.upper()}, nope, {a}, {b}") == [a, b] + assert parse_accept(None) == [] + assert parse_accept("") == [] + + +def test_parse_accept_is_bounded_against_a_hostile_header(): + flood = ",".join(format(i, "032x") for i in range(5000)) + assert len(parse_accept(flood)) <= 32 + + +def test_binary_when_the_client_holds_the_codec(): + registry = CodecRegistry([CODEC_A]) + decision = negotiate(accept_header([CODEC_A.fingerprint]), registry) + assert decision.kind == "hyperfly" + assert decision.headers["Content-Type"] == HYPERFLY_MEDIA_TYPE + assert decision.headers["Hyperfly-Codec"] == CODEC_A.fingerprint + + +def test_json_fallback_offers_an_upgrade(): + registry = CodecRegistry([CODEC_A]) + decision = negotiate(accept_header(["f" * 32]), registry) + assert decision.kind == "json" + assert decision.headers["Hyperfly-Offer"] == CODEC_A.fingerprint + + +def test_every_response_varies_so_caches_cannot_cross_serve(): + registry = CodecRegistry([CODEC_A]) + for accept in (accept_header([CODEC_A.fingerprint]), None): + assert negotiate(accept, registry).headers["Vary"] == "Hyperfly-Accept" + + +def test_operator_switch_falls_back_without_consulting_the_registry(): + registry = CodecRegistry([CODEC_A]) + assert negotiate(accept_header([CODEC_A.fingerprint]), registry, enabled=False).kind == "json" + + +def test_client_preference_decides(): + registry = CodecRegistry([CODEC_A, CODEC_B]) + first = negotiate(accept_header([CODEC_B.fingerprint, CODEC_A.fingerprint]), registry) + second = negotiate(accept_header([CODEC_A.fingerprint, CODEC_B.fingerprint]), registry) + assert first.headers["Hyperfly-Codec"] == CODEC_B.fingerprint + assert second.headers["Hyperfly-Codec"] == CODEC_A.fingerprint + + +@pytest.mark.parametrize("accept", [None, "x"]) +def test_json_round_trip(accept): + registry = CodecRegistry([CODEC_A]) + body, headers = encode_for(negotiate(accept, registry), VALUE) + assert decode_response(headers["Content-Type"], body, registry) == ("json", VALUE) + + +def test_binary_round_trip(): + registry = CodecRegistry([CODEC_A]) + body, headers = encode_for(negotiate(accept_header([CODEC_A.fingerprint]), registry), VALUE) + assert decode_response(headers["Content-Type"], body, registry) == ("hyperfly", VALUE) + + +def test_a_client_without_the_codec_reports_a_miss(): + registry = CodecRegistry([CODEC_A]) + body, headers = encode_for(negotiate(accept_header([CODEC_A.fingerprint]), registry), VALUE) + kind, fingerprint = decode_response(headers["Content-Type"], body, CodecRegistry()) + assert kind == "unknown-codec" + assert fingerprint == CODEC_A.fingerprint + + +def test_artifact_discovery(): + registry = CodecRegistry([CODEC_A]) + hit = serve_artifact(f"/.well-known/hyperfly/{CODEC_A.fingerprint}", registry) + assert hit.status == 200 + assert hit.body == CODEC_A.artifact + assert "immutable" in hit.headers["Cache-Control"] + assert serve_artifact(f"/.well-known/hyperfly/{'f' * 32}", registry).status == 404 + assert serve_artifact("/.well-known/hyperfly/nope", registry).status == 404 + assert serve_artifact("/v1/events", registry) is None + + +def test_a_client_can_bootstrap_from_a_served_artifact(): + import json + + registry = CodecRegistry([CODEC_A]) + served = serve_artifact(f"/.well-known/hyperfly/{CODEC_A.fingerprint}", registry) + parsed = json.loads(served.body) + rebuilt = compile_ir(parsed["ir"], plan=parsed["plan"]["layout"]) + assert rebuilt.fingerprint == CODEC_A.fingerprint + assert rebuilt.decode(CODEC_A.encode(VALUE)) == VALUE + + +def test_rotation_keeps_every_client_served(): + registry = CodecRegistry([CODEC_A]) + old_client = accept_header([CODEC_A.fingerprint]) + new_client = accept_header([CODEC_B.fingerprint, CODEC_A.fingerprint]) + + assert negotiate(new_client, registry).kind == "hyperfly" + registry.add(CODEC_B) + assert negotiate(new_client, registry).headers["Hyperfly-Codec"] == CODEC_B.fingerprint + assert negotiate(old_client, registry).headers["Hyperfly-Codec"] == CODEC_A.fingerprint + + registry.remove(CODEC_A.fingerprint) + assert negotiate(new_client, registry).headers["Hyperfly-Codec"] == CODEC_B.fingerprint + assert negotiate(old_client, registry).kind == "json" diff --git a/spec/negotiation-v1.md b/spec/negotiation-v1.md new file mode 100644 index 0000000..0fb37a0 --- /dev/null +++ b/spec/negotiation-v1.md @@ -0,0 +1,135 @@ +# Hyperfly negotiation — v1 + +Status: draft. Defines how a client and server agree to exchange hyperfly bytes +instead of JSON over HTTP, and how a client obtains an artifact it does not yet +hold. The wire format (`spec/wire-v0.md`) is unchanged by this document. + +The governing rule is inherited from the format: a peer decodes only an +artifact it holds, identified by fingerprint. Negotiation exists so that fact +is established *before* any bytes are sent, and so the failure mode is JSON +rather than an error. + +## 1. Steady state + +A client that holds one or more artifacts advertises their fingerprints: + +``` +GET /v1/events +Accept: application/vnd.hyperfly, application/json +Hyperfly-Accept: a65108e20d19c19ff525ddf4789d5ba1, 123c921f0dccfe8d25f976757766c4e1 +``` + +`Hyperfly-Accept` is a comma-separated list of 32-character lowercase hex +fingerprints, most preferred first. A server MUST ignore entries it does not +recognize and MUST ignore malformed entries rather than failing the request. + +If the server holds a codec whose fingerprint appears in the list, it MAY +answer in binary: + +``` +200 OK +Content-Type: application/vnd.hyperfly +Hyperfly-Codec: a65108e20d19c19ff525ddf4789d5ba1 +Vary: Hyperfly-Accept +``` + +The body is the envelope from wire-v0 §2. The fingerprint is already inside it; +`Hyperfly-Codec` repeats it so a proxy or a log can see it without parsing the +body. + +Servers MUST select the first entry in the client's list that they can serve, +so preference belongs to the client. This matters during rotation: a client +that lists a new profile before an old one moves itself over without the server +tracking who has what. + +## 2. Fallback + +The server answers JSON when any of these hold, and none of them is an error: + +- the request carries no `Hyperfly-Accept`, +- no advertised fingerprint matches a codec the server holds, +- the server chooses not to (load, a disabled route, an operator switch). + +``` +200 OK +Content-Type: application/json +Hyperfly-Offer: a65108e20d19c19ff525ddf4789d5ba1 +Vary: Hyperfly-Accept +``` + +`Hyperfly-Offer` is optional and advisory: it names an artifact the server +would have used, so a client can fetch it (§3) and upgrade itself. A client +MUST NOT treat its presence as a requirement, and a server MUST behave +identically whether or not the client acts on it. + +Because the same URL can yield either representation, a response that varies +on the header MUST carry `Vary: Hyperfly-Accept`, or a shared cache will serve +one peer's binary to another peer that cannot read it. + +## 3. Artifact discovery + +A server that offers binary responses SHOULD expose its artifacts: + +``` +GET /.well-known/hyperfly/a65108e20d19c19ff525ddf4789d5ba1 +``` + +``` +200 OK +Content-Type: application/json +Cache-Control: public, max-age=31536000, immutable +``` + +The body is the canonical artifact text (wire-v0 §5, plan §6.3). Artifacts are +content-addressed, so the response is immutable and indefinitely cacheable: a +different artifact is a different URL by construction. + +A client MUST verify that the fingerprint of the text it received equals the +one it asked for, and MUST reject a mismatch. It MUST derive the artifact from +the parsed content rather than trusting the received text (wire-v0 §7), so a +server cannot induce a client to hash bytes it has not understood. + +Unknown fingerprint: `404`. That is not an error condition for the protocol — +the client simply continues in JSON. + +## 4. Requests + +A client MAY send hyperfly in a request body under the same rules, reversed: + +``` +POST /v1/orders +Content-Type: application/vnd.hyperfly +Hyperfly-Codec: 3f9c1a... +``` + +A server that does not hold that fingerprint MUST answer `415 Unsupported +Media Type` with `Hyperfly-Offer` naming what it does hold, rather than +guessing. Unlike responses, there is no safe fallback for a body already sent +in a format the peer cannot read. + +## 5. Rotation + +Retraining a profile produces a new fingerprint (plan §6.4). A deployment +rotates without a cliff by holding both: + +1. The server registers the new codec alongside the old one. Both fingerprints + resolve; responses continue in whichever the client asks for. +2. Clients pick up the new artifact — from `Hyperfly-Offer`, from a build, or + from a scheduled fetch — and list it first. +3. When traffic on the old fingerprint reaches zero, the server drops it. + +A server that holds only one codec per route makes rotation a hard cutover: +every in-flight client falls back to JSON until it updates. Holding two is +what makes the fallback a transition rather than an outage. + +## 6. Security + +- `Hyperfly-Accept` is client-controlled input. A server MUST bound the number + of entries it parses (32 is ample) and MUST NOT allocate per unrecognized + entry. +- A fingerprint is not a secret, but it does identify a schema and a trained + dictionary. A server MUST NOT serve an artifact belonging to one tenant to + another, and `.well-known` discovery MUST apply the same authorization as + the routes the artifact describes. +- Whether a value is dictionary-coded is observable in response length (plan + §6.6). Negotiation does not change that; it only makes it opt-in per client. diff --git a/turbo.json b/turbo.json index 45c84d4..dfa15de 100644 --- a/turbo.json +++ b/turbo.json @@ -41,5 +41,8 @@ ], "outputs": [] } - } + }, + "globalEnv": [ + "PORT" + ] }