Skip to content

feat(sdk): migrate SSE auth from ?token=JWT to Fetch EventSource #203

Description

@EricAndrechek

Move SSE auth off the ?token={JWT} query param to a Fetch-based EventSource implementation that attaches an Authorization header.

Tasks

  • Fetch-based EventSource attaches Authorization: Bearer …
  • Confirm the terminal/cURL flow still works
  • Deprecate the ?token={jwt} path (warn now, remove in a later major)

(WebSocket removal already shipped in #190.)

Part of #194

Activity

  1. self-assigned this
    on Jun 2, 2026
  2. added
    enhancementNew feature or request
    area/apiHTTP handlers, routing, middleware
    area/sdkTypeScript SDK (clients/ts/)
    securitySecurity-sensitive issue or fix
    on Jun 2, 2026
  3. moved this from Backlog to Ready in WaveHouse Task Boardon Jun 2, 2026
  4. moved this from Ready to Backlog in WaveHouse Task Boardon Jun 9, 2026
  5. added
    area/streamingSSE / live-query delivery path (/v1/stream)
    area/authAuthentication: tokens, JWT/JWKS, keys, token expiry/revocation
    on Aug 3, 2026
  6. EricAndrechek commented on Aug 12, 2026

    @EricAndrechek
    MemberAuthor

    Context from #456 (options.fetch) — findings that bear on this issue

    #456 adds options.fetch to ClientOptions, letting a consumer supply the HTTP implementation. It is REST-only by construction, so it does not touch this issue's surface — but the two overlap enough that it is worth writing down what was learned, so whoever picks this up can build on it rather than rediscover it.

    I have no strong opinion on which direction this issue should take — the SSE-side tradeoffs (reconnect semantics, browser support policy, ?token= deprecation timing) likely have context I don't have. What follows is findings and options, not a recommendation.

    What #456 establishes

    • ClientOptions.fetch?: FetchLike, threaded through HttpContext to the single REST chokepoint (request() in http.ts). Every namespace inherits it; retries go through it.
    • The exported type is deliberately narrower than typeof fetch:
      export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
      A string URL is all the SDK ever passes. This accepts strictly more implementations than typeof fetch does — the global fetch still assigns, and so does hand-written (url: string, init?: RequestInit) => Promise<Response> middleware, which typeof fetch rejects on parameter contravariance.
    • SSE is explicitly documented as exempt. stream/sse.ts constructs new EventSource(url.toString()) (sse.ts:80) and never touches fetch, so the override cannot reach it. .liveQuery()'s initial backfill is an ordinary REST call and does go through the override; only the live connection is exempt. This asymmetry is documented on the SDK page.

    Whatever lands here decides whether that carve-out shrinks or stays.

    Finding: the eventsource polyfill already supports a fetch override

    The eventsource npm package added a fetch option in v4.1.0, which both attaches arbitrary headers and lets a caller inject a fetch implementation:

    new EventSource(url, {
      fetch: (input, init) =>
        fetch(input, { ...init, headers: { ...init.headers, Authorization: 'Bearer …' } }),
    })

    It also exposes Symbol.for('eventsource.supports-fetch-override') so a consumer can feature-detect support before relying on it.

    Relevant detail: this repo already depends on eventsource@4.1.0 (tests/e2e/sdk/polyfills.ts, resolved at eventsource@4.1.0 in the lockfile), and the SDK docs already instruct Node users to polyfill with it.

    The catch: this only helps where the polyfill is in play. Browsers use native EventSource, which supports neither custom headers nor a fetch override — only withCredentials. That is the whole reason ?token= exists today (sse.ts:63-68).

    Two shapes this could take

    Option A — use the polyfill's fetch override.
    Pass a fetch (and/or headers) into the EventSource constructor when the implementation supports it, detected via the symbol above.

    Option B — implement SSE over fetch + ReadableStream (the @microsoft/fetch-event-source approach).
    Stop using EventSource entirely; parse the event stream off a fetch response body.

    • Works in browsers and Node alike, so Authorization headers work everywhere and ?token= can actually be retired.
    • Composes with feat(sdk)!: add options.headers, fetchOptions, and fetch #456: streams would flow through options.fetch like everything else, closing the documented carve-out.
    • You inherit what native EventSource was doing for free, and this is the part worth scoping carefully:
      • Auto-reconnect with backoff. sse.ts:16 currently notes "Native EventSource auto-reconnects", and status transitions are derived from readyState (sse.ts:106-110) — all of that would need reimplementing.
      • Last-Event-ID resumption. The server prefers the Last-Event-ID header for gap-fill (internal/api/stream.go:86-89), and native EventSource sets it automatically on reconnect. A fetch-based transport must track the last event id and resend it itself, or resumption silently regresses.
      • This interacts with SSE: emit periodic keepalive heartbeats so idle streams survive proxy timeouts #226's keepalive fix — see docs/.../reverse-proxy.mdx:124, where quiet-stream drops were masked in browsers precisely because EventSource auto-reconnected and gap-filled via Last-Event-ID. Losing that would surface those drops to browser consumers.

    Option C — a hybrid: native EventSource when no header/fetch customization is requested, fetch-based transport only when it is. Preserves today's battle-tested browser path by default, at the cost of two code paths to maintain and test.

    If a fetch-based transport is chosen

    options.fetch will not apply to streams automatically — the new transport has to thread ctx.options.fetch through explicitly (it is on HttpContext.options.fetch already). It is a small change once the transport is fetch-based, but it is a deliberate one. If that happens, the SSE carve-out currently documented in docs/src/content/docs/sdk/index.mdx and in the ClientOptions.fetch TSDoc needs updating in the same PR.

    Ecosystem note

    The seam is not unusual: Supabase configures its realtime transport separately (realtime?: RealtimeClientOptions) and its global.fetch does not cover it either. So "the fetch override doesn't reach the streaming transport" is the norm in this space, not a WaveHouse wart — worth knowing when weighing how much Option B's cost is worth.

    Sequencing

    #456 is open and does not block this. #269 is being rescoped so that headers / fetchOptions / fetch land on the REST path there, and the streaming half is tracked here — so this issue owns the answer to "can a header-gated or cookie-authenticated origin be streamed from?"

    Both #456 and this will touch the streaming config surface, so whichever merges second wants a git merge origin/main first.

    — Claude Opus 5, via Claude Code

  7. moved this from Backlog to In progress in WaveHouse Task Boardon Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area/apiHTTP handlers, routing, middlewarearea/authAuthentication: tokens, JWT/JWKS, keys, token expiry/revocationarea/docsDocumentation, site/, READMEarea/sdkTypeScript SDK (clients/ts/)area/streamingSSE / live-query delivery path (/v1/stream)breaking-changeBreaking change to public API, CLI, or configenhancementNew feature or requestsecuritySecurity-sensitive issue or fix

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions