feat(vault-ws): live-query WebSocket binding with DO hibernation — Phase 1 (0.0.8-rc.27) - #80
Conversation
…se 1) (0.0.8-rc.27)
Add a WebSocket transport for live-query subscriptions alongside the existing
SSE binding — same `GET /api/subscribe?<query>` URL, `Upgrade: websocket`
selects WS. On Cloudflare, the socket is a Hibernatable WebSocket: an
idle-but-open connection evicts the per-vault DO (~$0 idle) and a write
transparently wakes it to push. SSE is the untouched fallback.
Phase 0 (contract): workers/vault/docs/live-query-ws.md (message schemas, auth
handshake, close codes 4400/4401/4403/4408, ping/pong, chunked snapshot with a
`done` flag) + a shared frame-corpus fixture both the SSE and WS parity tests
import.
Phase 1 (build):
- index.ts: forward a WS upgrade (101) UNMODIFIED — `withCors` does
`new Response(res.body, res)` which can't reconstruct a 101 nor carry the
webSocket. SSE + REST paths unchanged.
- auth.ts: extract `authenticateVaultToken(token, env, vaultName)` (request path
is now a thin wrapper, byte-identical responses); surface JWT `exp`/`jti` on
AuthResult; add `vaultVerbRank` for the re-auth narrow check.
- subscriptions.ts: sink seam `write(frame)` → `send(event, data)`; `SseSink`
(bytes UNCHANGED) + `WsSink` (`{type, ...data}`); one manager, both transports.
- live/ws-subscribe.ts: pure helpers — close codes, versioned per-socket
attachment (RAW query string), query validation (same rejects as SSE),
pending-attachment build + 15KB serialize guard, chunked snapshot, first-message
parsing, a best-effort revocation tracker.
- vault-do.ts: WS upgrade → acceptWebSocket + pending attachment; first-message
auth (`{type:auth,token}`) → ready + snapshot; re-auth (narrow-or-equal);
`ensureSubscriptionsRehydrated()` at every wake entry point; sweep-before-
dispatch (exp/revocation/pending-timeout); `setWebSocketAutoResponse(ping/pong)`
(no server keepalive timer, no 15-min cap for WS). Transcription alarm ownership
untouched.
Tests: ws-subscribe (13) — handshake, auth close codes, chunked snapshot,
cap-503, scope intersection, WS⇄SSE byte-parity, exp-sweep, and a
simulated-eviction harness (drop the in-memory manager, deliver a REST write,
assert the push survives with no missed event / no re-snapshot). Plus
live-frame-parity (21). Vault suite 211+1todo → 245+1todo; typecheck clean;
deploy bundle inlines core, no residual bun: import.
Contract: Decisions/2026-07-04-live-query-ws-hibernation. NOT merged — gated on
the staging DO-duration cost proof.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
- scripts/cost-proof-ws-hibernation.ts: provisions two staging vaults, holds an SSE stream on one and an idle WebSocket on the other, and exercises the hibernation round-trip (idle → ping/pong via auto-response without waking → wake-on-write push). Prints the two vault names for `wrangler tail` correlation. The definitive per-vault DO duration (GB-s) needs an analytics-scoped token (durableObjectsPeriodicGroups); the wrangler OAuth token lacks that scope, so the harness proves the round-trip + the invocation pattern. - ws-subscribe test: an alarm-driven wake rehydrates WS subs from attachments — pins that the transcription alarm's wake path re-registers live sockets (they survive), and that a pending alarm does not break the WS binding. Vault suite 245+1todo → 246+1todo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
…wer nits Reviewer nits on PR #80, folded into the same rc.27: NIT 1 (drift guard): cross-transport byte-equality tests for the FULL 400 (search/near) + 503 (cap) bodies — the duplicated strings in subscribe.ts (SSE) and ws-subscribe.ts (WS) are now diffed byte-for-byte across both real transports, not just by `.code`. Makes the PR's "parity test guards the shared 400 bodies" claim literally true. NIT 2 (pending-socket cap self-heal) — surfaced a REAL gap: `getWebSockets()` also returns CLOSING sockets (readyState 2), including ones the sweep just closed on the same wake, so the raw-length cap check kept the cap "full" of already-departing sockets and the self-heal did NOT fire. Fix: count only LIVE (readyState CONNECTING/OPEN) sockets toward the cap (`liveSocketCount`), so a `sweepWsSockets` that closes stale pending sockets frees their slots immediately. Added a test (fill cap with pending-past-deadline → sweep → a new upgrade passes) + a doc paragraph on the pre-auth cap behavior + self-heal. Vault suite 246+1todo → 250+1todo; typecheck clean; verify:bundle OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
|
Reviewer nits folded (same rc.27, no version bump) — commit NIT 1 (drift guard): added cross-transport byte-equality tests for the FULL 400 ( NIT 2 (pending-socket cap self-heal) — surfaced a REAL gap, small source fix required: the assumed self-heal did NOT fire. Gates: vault |
Phase 1 of the WS-hibernation migration (decision
Decisions/2026-07-04-live-query-ws-hibernation, planWork/ws-hibernation-migration). Adds a WebSocket transport for live-query subscriptions alongside the existing SSE binding, with Cloudflare Durable Object hibernation so an idle-but-open live tab evicts the per-vault DO ($0 idle) instead of pinning it awake ($4/mo per always-open SSE tab). NOT for merge — gated on the cost proof (see below); orchestrator reviews.The design, as built
One contract, two transports. Same URL
GET /vault/<name>/api/subscribe?<query>; anUpgrade: websocketheader selects WS, else SSE. The SSE handler is untouched (byte-for-byte wire output) and remains the fallback. Full contract:workers/vault/docs/live-query-ws.md.index.ts— a WS upgrade (101) is forwarded unmodified;withCorsdoesnew Response(res.body, res)which cannot reconstruct a 101 nor carry thewebSocket. SSE + REST unchanged.auth.ts— extractedauthenticateVaultToken(token, env, vaultName)(the request path is now a thin wrapper with byte-identical 401/403 bodies); surfaced JWTexp/jtionAuthResult; addedvaultVerbRankfor the re-auth narrow check.subscriptions.ts— sink seamwrite(frame)→send(event, data);SseSink(bytes UNCHANGED) +WsSink({type, ...data}); oneSubscriptionManager, both transports.live/ws-subscribe.ts(new) — pure helpers: close codes (4400/4401/4403/4408), the versioned per-socket attachment (stores the RAW query string), query validation (same rejects/codes as SSE), pending-attachment build + a ~15 KB serialize guard (under workerd's 16 KB), chunked snapshot with adoneflag, first-message parsing, a best-effort revocation tracker.vault-do.ts— WS upgrade →acceptWebSocket+ pending attachment (cap viagetWebSockets().length→ 503); first-message auth ({type:auth,token}) → ready + snapshot; re-auth (narrow-or-equal scope, widen → 4403, no re-snapshot);ensureSubscriptionsRehydrated()at every wake entry point (fetch/webSocketMessage/webSocketClose/webSocketError/alarm), guarded once-per-warm-DO; sweep-before-dispatch (exp → 4401, confirmed-revoked → 4401, pending-timeout → 4408);setWebSocketAutoResponse(ping/pong)— no server keepalive timer, and the 15-min lifetime cap is gone for WS. Transcription alarm ownership untouched.THE COST PROOF (the gate)
Deployed to staging (
deploy-staging.sh, both workers) and ranscripts/cost-proof-ws-hibernation.ts— two fresh staging vaults, one holding an SSE stream, one holding an idle WebSocket, over the same 45s window, withwrangler tailcapturing the invocation pattern.Functional hibernation round-trip (measured on staging):
wrangler tailinvocation pattern (router-level):Honest limit (needs Aaron): the definitive per-vault DO duration (GB-s) comes from Cloudflare GraphQL
durableObjectsPeriodicGroups, which needs an analytics-scoped API token. The wrangler OAuth token on this box hasworkers/d1/workers_tailbut no analytics scope, so the exact GB-s delta is not measured here. The architectural claim (SSE holds an open response = continuous DO wall-clock; idle WS hibernates = ~0 duration) is confirmed by the tail invocation pattern + the round-trip, but the hard number is a follow-up once an analytics token is available.Transcription-alarm ↔ hibernation finding: a pending DO alarm does not pin the DO awake — Cloudflare wakes it at the scheduled time, so an idle-WS vault with a scheduled far-future transcription alarm still hibernates in between. When the alarm fires,
alarm()runsensureSubscriptionsRehydrated()first, so live sockets are re-registered and survive — pinned deterministically by a test (alarm-wake coexistence: evict →alarm()→ WS sub count 0→1 → a later write still pushes). A fully-live far-future-alarm eviction measurement shares the analytics-token gate above.Gates (literal)
workers/vault):bun run typecheckclean;bun x vitest run→ 246 passed | 1 todo (247) (baseline 211 + 1 todo; +35: 21 frame-parity/unit + 14 ws-subscribe incl. handshake, auth close codes, chunked-snapshot chunking, cap-503, scope intersection, WS⇄SSE byte-parity, exp-sweep, alarm-coexistence, and the simulated-eviction harness).verify:bundleOK — core inlined, no residualbun:import (564 KiB).workers/identity): untouched (369 baseline).smoke-staging.ts: PASSED, 0 failures (exit 0) — includes the SSE snapshot path, so the SSE binding still works end-to-end post-refactor.Deviations from the plan (+ why)
event:name into atypediscriminator (a WS message has no event-name framing) and snapshots carry adonechunk flag. The inner payload (note/id/notes) serializes byte-for-byte identically across both transports; that is the load-bearing invariant, pinned by the shared frame-corpus fixture (test/fixtures/live-frame-corpus.ts) + the parity tests. The doc states this exactly.ENVIRONMENT=testfor miniflare hermeticity; the tracker is unit-tested with an injected fetcher.getWebSockets().length(both 100). Converges as SSE is retired (Phase 5).ws-subscribe.tsrather than extracted fromsubscribe.ts, to keep the SSE handler's logic byte-untouched; a parity test guards the shared 400 bodies.🤖 Generated with Claude Code
https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB