Skip to content

feat(sdk): allow custom request headers (or a fetch override) for header-gated origins #269

Closed

Description

@EricAndrechek

Area: sdk — friction (DX) / gap · found via WaveHouse-Stats dogfooding

Expected: point the SDK at a WaveHouse fronted by a header-gated proxy (Cloudflare Access service token, mTLS sidecar, auth gateway) by attaching arbitrary per-request headers — or by supplying a custom fetch.

Actual: ClientConfig exposes only baseURL, auth (which sets just Authorization: Bearer …), and options.maxRetries. No headers option, no fetch injection point — so the SDK can't carry headers like CF-Access-Client-*.

Impact (Stats): initially blocked them (Worker writes went through a CF-Access-gated origin the SDK couldn't satisfy). They've since moved ingest to a public JWT-gated endpoint, so it no longer blocks them — but it still bites any deployment fronting WaveHouse behind a header-gated proxy. A defense-in-depth gate shouldn't force a consumer off the SDK.

Additional consumers named later: cookie-authenticated origins, where the blocker isn't a missing header but that fetch defaults to credentials: "same-origin", so a cross-origin browser request sends no cookie at all.

Related: SDK epic #194; SSE-auth migration #203; base path in baseURL #428 (a deployment behind a BFF generally needs both).


From WaveHouse-Stats WAVEHOUSE-FEEDBACK.md dogfooding (dev 71c2dc4), 2026-06-04.


Scope — all three knobs land together in #456

Rescoped 2026-08-12. #456 already implements the fetch override; headers and fetchOptions are being folded into that same PR so the HTTP-customization surface lands as one coherent API rather than in three increments.

  • options.fetch — supply the HTTP implementation. Exported as FetchLike, deliberately narrower than typeof fetch (a string URL is all the SDK passes), which accepts strictly more implementations than the wide type does. REST path only, by construction. (implemented in feat(sdk)!: add options.headers, fetchOptions, and fetch #456)
  • headers — arbitrary headers on every request. The ergonomic answer for CF-Access and friends; a fetch override is a power tool that shouldn't be the only route to a static header.
  • fetchOptions — a RequestInit merged into every request. Covers credentials: "include" for the cookie case above, plus undici dispatcher, mode, cache, etc., without forcing a full fetch wrapper.

Prior art driving the shape

SDK Options Fetch type
Supabase global.fetch, global.headers type Fetch = typeof fetch
Anthropic fetch, fetchOptions, defaultHeaders, defaultQuery (input: string | URL | Request, init?) => Promise<Response>
OpenAI fetch, fetchOptions same (both Stainless-generated)

fetchOptions is the established name for "extra RequestInit on every request" — OpenAI's own documented undici-proxy example is new OpenAI({ fetch, fetchOptions: { dispatcher: proxyAgent } }). Adopting it is cheaper than inventing a credentials knob that solves one case.

Open design questions

  1. Naming/nesting. Supabase nests under global: {}; Anthropic/OpenAI keep options flat. WaveHouse currently nests transport config under options: {} (maxRetries, fetch). Do headers / fetchOptions sit under options for consistency, or at the top of ClientConfig next to auth?
  2. Static vs dynamic headers. Record<string, string> alone can't carry a rotating service token. A () => Record<string,string> | Promise<…> form can, but inherits the per-call cost question in feat(sdk): define the auth() callback contract — per-request cost, caching, retry staleness #458.
  3. Precedence. When auth, headers, and fetchOptions.headers all set Authorization, which wins? Needs to be defined and tested, not emergent. Anthropic's convention is that per-request options override client defaults, and an explicit null removes a default header.
  4. Whether headers reaches SSE. Native EventSource cannot set headers at all — see below.

Out of scope — split out

Activity

  1. EricAndrechek commented on Aug 5, 2026

    @EricAndrechek
    MemberAuthor

    Two more consumers for this, plus one gap the current scope wouldn't close.

    Cookie-authenticated origins need credentials, not just headers. Where WaveHouse sits behind an app server that authenticates with a session cookie, the blocker isn't a missing header — it's that fetch defaults to credentials: "same-origin", so a cross-origin request from a browser sends no cookie at all. The fetch override proposed here does solve it (the wrapper can set credentials: "include"), but it's worth naming the case explicitly, since "custom headers" alone wouldn't — and a plain credentials?: RequestCredentials passthrough may be the simpler knob for people who need only that.

    The SSE transport wouldn't be covered. stream/sse.ts uses new EventSource(url) (sse.ts:79) and never touches fetch, so a fetch override leaves streaming unauthenticated on exactly the deployments this issue is about. EventSource takes { withCredentials: true } for the cookie case, and it can't set headers at all — which the SDK already works around via ?token= (sse.ts:62-65). Whatever lands here should say what the streaming transport does, or it'll be half a fix that looks complete until someone opens a subscription.

    Minor, adjacent: ctx.auth() is awaited on every request, and again on every retry attempt (http.ts:45-50), with no caching or in-flight dedupe. That's free when auth returns a value from memory, but it's a network round-trip per request for anyone whose callback fetches or mints a token — a natural pattern for precisely the proxied/BFF deployments above. Either a documented expectation ("cache it yourself, this is called per request") or built-in dedupe with a short TTL would help; right now it's silent.

    Related: #428 (base path in baseURL) — the other half of making the SDK usable behind a proxy. A deployment behind a BFF generally needs both.

  2. EricAndrechek commented on Aug 12, 2026

    @EricAndrechek
    MemberAuthor

    Rescoped the issue body (2026-08-12) rather than appending, since the shape of the work changed rather than grew.

    What changed: #456 was opened as just the fetch override. It's now taking headers and fetchOptions too, so the whole HTTP-customization surface lands as one API instead of three increments that each half-answer the header-gated-origin case.

    What moved out:

    Prior art is now in the body, because it should drive the API shape: Supabase ships global.fetch + global.headers; Anthropic and OpenAI ship fetch + fetchOptions + defaultHeaders. fetchOptions is the established name for "extra RequestInit on every request", and it subsumes the credentials: "include" case from the comment above more cleanly than a bespoke credentials knob would.

    Four design questions are listed in the body and are not settled — nesting (options.headers vs top-level, given Supabase nests and Anthropic doesn't), static vs dynamic headers, precedence when auth/headers/fetchOptions.headers collide, and how the REST-only limit gets documented. Those get decided before code.

    — Claude Opus 5, via Claude Code

  3. moved this from Backlog to In progress in WaveHouse Task Boardon Aug 12, 2026
  4. added a commit that references this issue on Aug 12, 2026
    ea4fbf6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/sdkTypeScript SDK (clients/ts/)breaking-changeBreaking change to public API, CLI, or configenhancementNew feature or request

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions