feat: WebSocket support via functional events#82
Conversation
Implements WebSocket support as two new ServiceWorker functional events (websocketmessage, websocketclose) following the ExtendableEvent pattern. Users call event.upgradeWebSocket() in fetch handlers — no Response is created (status 101 is invalid in Node.js and semantically wrong). Pool signals upgrades via WebSocketUpgradeResult discriminated union instead of monkeypatching Response objects. Platform adapters (Node.js with ws package, Bun with native WebSocket) handle the actual upgrade handshake. Direct mode uses createDirectModePool() adapter for the same interface without message passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests call pool.handleRequest() which now returns Response | WebSocketUpgradeResult. Cast to Response since these tests only make HTTP requests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FetchEvent.upgradeWebSocket() behavior (responded, kGetUpgradeResult, UUID, data) - WebSocketMessageEvent/WebSocketCloseEvent construction - ShovelWebSocketClient send/close via relay - ShovelClients WebSocket tracking (register, remove, get, matchAll) - Event dispatch through registration (websocketmessage, websocketclose) - dispatchFetchEvent returns null response for upgrades - createDirectModePool upgrade flow and message relay - ServiceWorkerPool WebSocket upgrade (real worker process) - Pool message relay (echo), binary messages, concurrent connections - Fix stale dispatchRequest assertions in e2e-direct-mode tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… slicing P1: Node adapter was destroying the socket when pool.handleRequest() returned a Response instead of an upgrade result (e.g. 401/403 auth rejection). Now writes the HTTP response so clients see the status/body. P2: Bun adapter forwarded message.buffer directly for binary WebSocket frames, ignoring byteOffset/byteLength of the typed array view. This corrupts binary messages when the view doesn't cover the full backing buffer. Now slices to the correct range. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…pgrade header P2: upgradeWebSocket() now throws if respondWith() or upgradeWebSocket() was already called, preventing silent overwrite of the response path. P2: Bun adapter now checks the Upgrade header case-insensitively, so "WebSocket", "WEBSOCKET", etc. all trigger the upgrade path per HTTP spec. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…, Bun upgrade failure P2: Send websocketclose event to worker before removing connection state during shutdown/reload, so app cleanup handlers run. P2: Direct-mode dispatch callbacks now catch and log errors from user websocketmessage/websocketclose handlers instead of surfacing unhandled rejections. P2: Bun adapter checks server.upgrade() return value — on failure, cleans up worker-side state and returns a 500 response instead of leaving a phantom connection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Direct-mode WebSocket clients now carry their own relay instead of using the process-global relay, so multiple in-process pools don't stomp each other's send()/close() routing. Simplified #closeWorkerWebSockets to let the adapter's normal close path deliver websocketclose while ownership is still tracked, instead of posting to a worker that's about to be terminated. Added regression test for per-pool relay isolation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…TP error on upgrade failure P1: Pass WebSocket relay to ShovelFetchEvent via wsRelay option so clients created by upgradeWebSocket() can send()/close() immediately inside the fetch handler, before dispatchFetchEvent returns. P1: Buffer outbound ws:send/ws:close messages in Node and Bun adapters when the real WebSocket hasn't completed handshake yet. Flush when handleUpgrade completes (Node) or open fires (Bun). P2: Node upgrade error handler now writes a proper HTTP error response (using HTTPError.toResponse) instead of destroying the socket, so clients see the intended status code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: When a fetch handler calls upgradeWebSocket() but the request isn't a WebSocket upgrade (no Upgrade header), the pool returns a WebSocketUpgradeResult through the normal HTTP path. Both Node and Bun adapters now detect this and return 426 Upgrade Required instead of crashing on the cast to Response. P3: Both adapters hardcoded wasClean=true for all close events. Now derived from the close code — 1006 (abnormal closure / no close frame) reports wasClean=false, all others report true. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: All four generated direct-mode templates (Node/Bun × index/platform) now check for WebSocketUpgradeResult and return 426 Upgrade Required when a fetch handler calls upgradeWebSocket() on a non-WebSocket request. P2: Augment Clients.get() return type in globals.d.ts to include WebSocketClient, so typed code can access WebSocket clients without manual casting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1: Worker-initiated client.close() sends ws:close to the pool, which was immediately deleting the connectionID from #wsConnections. The adapter's close callback then called sendWebSocketClose() but couldn't find the owning worker, so websocketclose never dispatched back. Now the ws:close handler only closes the real socket — sendWebSocketClose() (called by the adapter's close callback) handles both dispatching the event and cleaning up #wsConnections. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: If handleUpgrade() throws after pool.handleRequest() already registered a WebSocket connection, the catch block now sends sendWebSocketClose(1006) to clean up the worker-side clients registry and #wsConnections, preventing phantom connections. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: When a WebSocket handshake takes longer than requestTimeout, the pending request is removed before ws:upgrade arrives. The upgrade was silently dropped, leaving the worker with a phantom connection. Now sends ws:close back to the worker to clean up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: When upgradeWebSocket() fires on a plain HTTP request, all code paths (listen handlers + generated direct-mode templates) now call sendWebSocketClose() before returning 426, preventing leaked connection state in ShovelClients and #wsConnections. P3: upgradeWebSocket() now checks kCanExtend() and throws InvalidStateError if called after dispatch phase ends, matching respondWith() contract. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…per connection P2: dispatchRequest() now throws an explicit error when the fetch handler calls upgradeWebSocket(), instead of silently returning null through a Response-typed API. Callers must use dispatchFetchEvent() for upgrade support. P2: WebSocket message dispatch is now serialized per connection using a promise chain, in both direct-mode (createDirectModePool) and worker message loop (startWorkerMessageLoop). Back-to-back frames on the same connection are processed in order even with async handlers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1: startWorkerMessageLoop was calling dispatchRequest() which now throws on WebSocket upgrades. Switched to dispatchFetchEvent() so multi-worker mode correctly handles upgradeWebSocket() and sends ws:upgrade back to the pool. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cloudflare adapter now handles upgradeWebSocket() using Cloudflare's native WebSocketPair API. The server-side socket is wired to dispatch websocketmessage/websocketclose events, and the client's send/close relay forwards to the real socket. Also updated dispatchFetchEvent() to accept a pre-built ShovelFetchEvent (or subclass like CloudflareFetchEvent) so platform-specific event properties (env bindings, platformWaitUntil) are preserved. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: Pass WebSocket relay into CloudflareFetchEvent via wsRelay option
so upgradeWebSocket() returns a client with working send()/close()
immediately inside the fetch handler.
P2: Register upgraded WebSocket clients with ShovelClients so
self.clients.get()/matchAll({type: "websocket"}) work on Cloudflare.
P2: Add per-connection dispatch queue for message ordering parity
with Node/Bun adapters.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
WebSocketPair is the wrong abstraction — the whole point of functional events is surviving hibernation/restarts. Cloudflare WebSocket support needs proper design work (likely Durable Object integration), not a quick WebSocketPair shim. Reverting to main's Cloudflare runtime. On Cloudflare, calling upgradeWebSocket() will throw via dispatchRequest(), which is the correct behavior for an unsupported platform feature. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements Cloudflare WebSocket support using the correct abstraction — the Durable Object Hibernation API (acceptWebSocket, webSocketMessage, webSocketClose) instead of WebSocketPair closures that don't survive hibernation. Architecture: - Worker routes Upgrade: websocket requests to ShovelWebSocketDO when env.SHOVEL_WS binding is present - DO initializes Shovel runtime, dispatches fetch events, uses ctx.acceptWebSocket() for hibernation-aware upgrades - After hibernation wake-up, runtime re-initializes and client state is reconstructed from ws.deserializeAttachment() - Per-connection dispatch queues for ordered message delivery - ShovelWebSocketDO in separate file (websocket-do.ts) to avoid importing cloudflare:workers outside workerd Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…a persistence
Rename websocketmessage → wsmessage, websocketclose → wsclose.
Shorter names following the dblclick/auxclick precedent for abbreviated
standard-style event names.
P1: Cloudflare DO now buffers messages sent during the fetch handler
(e.g. client.send("welcome")) and flushes after the WebSocketPair is
wired up, matching Node/Bun behavior.
P1: Cloudflare DO re-serializes ws attachment after each message
dispatch so client.data mutations persist across events and survive
hibernation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1: Cloudflare DO no longer re-runs runLifecycle() — the Worker's createFetchHandler already activated the registration before forwarding. Re-running threw "ServiceWorker must be installed before activation". P2: Bun binary WebSocket frames can arrive as raw ArrayBuffer (not just typed array views). Now checks instanceof ArrayBuffer first and only slices for typed array views. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… in envStorage P1: After hibernation wake-up, the module is re-evaluated with a fresh registration that hasn't been activated. Now checks registration.ready and only runs lifecycle when needed (skips on initial request where Worker already activated). P1: webSocketMessage/webSocketClose now wrap dispatch in envStorage.run() so env-backed APIs (directories, etc.) work in WebSocket handlers, matching the fetch path behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…DO fetch
P1: After hibernation wake-up, rehydrate all existing WebSocket
connections from ctx.getWebSockets() into self.clients, so matchAll
and get see all live connections (not just the one that triggered
the event).
P2: Add platformWaitUntil hook to CloudflareFetchEvent in the DO's
fetch handler, so event.waitUntil() work during upgrade isn't dropped
when the 101 response returns.
Note: Multi-worker self.clients.matchAll({type: "websocket"}) is
worker-local by design (same as standard ServiceWorker Clients API).
Cross-worker client enumeration would need pool-level relay protocol.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
45a4fbd to
25b0c84
Compare
…inding P1: webSocketMessage/webSocketClose now return the dispatch promise chain so Cloudflare keeps the handler alive for async work and hibernation data persistence. P2: WebSocket upgrade requests without the SHOVEL_WS Durable Object binding now return 501 with a clear configuration error message instead of falling through to dispatchRequest() which throws. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tchAll
P1: Cloudflare adapter only intercepts WebSocket upgrades when SHOVEL_WS
binding is configured. Without it, requests pass through to user code
for manual WebSocketPair handling — no breaking change for existing apps.
P2: clients.matchAll() and matchAll({type: "worker"}) now include
WebSocket clients since their type is "worker". Previously they were
only returned for the nonstandard {type: "websocket"} query.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: matchAll() without type now returns empty again (window clients per
spec). WebSocket clients only returned for {type: "websocket"} or
{type: "all"}.
P2: Each Cloudflare WebSocket connection gets its own Durable Object via
newUniqueId() instead of all sharing one "default" instance. Hibernation
keeps idle DOs cheap; this avoids single-object CPU/memory bottleneck.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…n dispatch queue P1: Without SHOVEL_WS binding, Cloudflare adapter now falls back to WebSocketPair (non-hibernation) instead of crashing via dispatchRequest. Works for dev/local and apps that don't need hibernation. P1: DO message handler now reconstructs ShovelWebSocketClient INSIDE the dispatch queue chain (after previous handler completes), so client.data mutations from back-to-back messages don't get overwritten by stale attachment data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… registration The WebSocketPair fallback path (without SHOVEL_WS binding) now has parity with the DO/Node/Bun paths: - wsRelay passed to event so send()/close() work in fetch handler - Per-connection dispatch queues for message ordering - Register/remove WebSocket clients in self.clients - Buffer early messages until socket is wired up Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: The DO isolate has its own module state, so the worker's setBroadcastChannelBackend call doesn't carry over. Now configures the PubSub backend in #ensureRuntime when SHOVEL_PUBSUB is available. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: The dev/Miniflare handleRequest path re-wrapped responses into new Response(), dropping the nonstandard webSocket property needed for WebSocket upgrades. Now passes through the original response when webSocket is present. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: serializeAttachment() now catches non-cloneable client.data and falls back to null with a warning, instead of crashing the upgrade or message handler. P2: reloadWorkers/terminate now yield to the event loop after closing WebSocket connections, giving close callbacks time to deliver wsclose events to workers before shutdown begins. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…l to after wsclose P2: upgradeWebSocket() now throws if the request doesn't have Upgrade: websocket header, preventing cross-platform behavior differences where some adapters return 426 and others try to upgrade. P2: Direct-mode sendWebSocketClose removes the client from self.clients in the finally block (after dispatch) instead of before, so wsclose listeners can still see the connection via clients.get(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1: Removed automatic interception of WebSocket requests when SHOVEL_WS is configured. The DO is exported for explicit use — users route to it from their fetch handler when they want hibernation. Default path uses WebSocketPair fallback, preserving existing manual WebSocket code. P1: DO no longer runs install/activate lifecycle. With per-connection DOs, lifecycle would run once per connection (migrations, cache warming repeated). The worker already ran lifecycle; the DO only needs event handlers which are re-registered on module re-evaluation. P2: Existing manual WebSocketPair routes no longer broken by adding the SHOVEL_WS binding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This reverts commit 00e62bf.
Cloudflare WebSocket requires SHOVEL_WS Durable Object binding. No WebSocketPair fallback — hibernation is the only supported path. DO marks registration as activated without re-running lifecycle, avoiding duplicate migrations/cache warming per connection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
workerd occasionally fails to spawn on CI runners (child_process connect error). Added createMiniflare() helper with 3 retries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
90f6e4f to
80f2925
Compare
The ShovelWebSocketDO import pulls in cloudflare:workers which crashes workerd on Miniflare when no DO bindings are configured. Users who want hibernation WebSocket import and re-export the DO class from their own code alongside their wrangler.toml config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The DO class must be exported from the worker bundle for Cloudflare to instantiate it. The earlier Miniflare crash was the flaky workerd spawn issue (fixed by retry helper), not the cloudflare:workers import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e-during-upgrade
Two regressions found by Codex review:
P1: ws:upgrade postMessage included client.data which throws DataCloneError
for non-structured-cloneable values (functions, symbols, class instances).
The supervisor never reads this field — removed it.
P2: Node adapter registered ws.on("close") after flushPending(), so a
buffered close frame during upgrade would miss the listener. Moved
flushPending() after all listener registrations.
Also fixes direct-mode close-during-upgrade: added onUpgrade callback to
ShovelFetchEventInit so clients are registered immediately when
upgradeWebSocket() is called, not after dispatchFetchEvent returns.
Adds regression tests for both issues.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…al to after wsclose
Two more Codex review findings:
P1: Cloudflare upgrade without SHOVEL_WS binding fell through to
dispatchRequest() which throws on upgrades → opaque 500. Now uses
dispatchFetchEvent() and returns 426 with a clear error message.
P2: Pooled worker mode removed the client from self.clients before
dispatching wsclose, so handlers couldn't see the disconnecting client
via clients.get/matchAll. Moved removal to finally{} after dispatch,
matching direct-mode and Cloudflare DO behavior.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…l connections Per-connection DOs via newUniqueId() meant self.clients.matchAll() only saw one connection per DO instance. Switch to idFromName() with a fixed name so all WebSocket connections share one DO and client lookup works the same as Node/Bun. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… mode If upgradeWebSocket() was called but the fetch handler then threw, the client stayed registered in ShovelClients — visible via clients.get() and matchAll() despite the upgrade never completing. Now wrapped in try/catch to remove the phantom registration on error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
If client.data becomes non-structured-cloneable after a wsmessage handler, serializeAttachment fails silently — leaving the previous attachment intact. On hibernation wake-up this resurrects stale data. Now nulls out the attachment on failure so rehydration gets null instead of incorrect old state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bun's child_process.spawn fails with "connect ENOENT" on the 4th stdio pipe (control-fd=3) when multiple workerd processes spawn concurrently. Root cause is a Bun runtime bug with socketpair creation under load. Fix: exclude cloudflare-*.test.* from parallel bun test via bunfig.toml pathIgnorePatterns, then run them sequentially via the test script. Also adds explanatory comment in the retry helper. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pathIgnorePatterns in bunfig.toml applies to explicit file arguments too, so "bun test ./test/cloudflare-build.test.js" returned "no test files matched" on CI. Override it on the CLI for the sequential runs, keeping the wpt exclusion intact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closing in favor of a redesignAfter extensive design discussion, we're not merging this PR. The core issue: it shoehorns WebSockets into the ServiceWorker Design principle we landed on
This preserves the option for future standards to evolve without conflicting with Shovel, keeps the "portable web standards" promise honest, and surgically contains the invention to surfaces we own. New direction (sketch)Three new event types on "websocketupgrade" -> WebSocketUpgradeEvent extends ExtendableEvent
"websocketmessage" -> WebSocketMessageEvent extends ExtendableEvent
"websocketclose" -> WebSocketCloseEvent extends ExtendableEventAll interaction happens via methods on these new classes. Users never see a No touches to What's preservedThe commits on The Cloudflare DO hibernation plumbing in Next stepNew PR against a fresh branch, starting from |
Running multiple cloudflare-*.test.* files concurrently in the same \`bun test\` invocation triggers intermittent workerd failures: \`error: disconnected: miniposix::write(fd, pos, size): Broken pipe; fd = 3\` (workerd control socket write failing during bun+miniflare setup) Workaround carried over from PR #82 (commit 1f154fd): - bunfig.toml: exclude \`**/cloudflare-*.test.*\` from the default walk - package.json: run each cloudflare-*.test.js in its own \`bun test\` invocation, sequentially, via the root \`test\` script No code changes — purely test orchestration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
websocketmessageandwebsocketcloseas new ServiceWorker functional events (ExtendableEvent subclasses)FetchEvent.upgradeWebSocket()upgrades connections — no HTTP Response created (101 is invalid in Node.js)WebSocketUpgradeResultinstead of monkeypatched Response objectswspackage with lazy loading; Bun uses nativeBun.serveWebSocket supportcreateDirectModePool()provides pool-compatible interface for single-worker deploymentsWebSocketClient, event types,upgradeWebSocketon FetchEvent)User-facing API
Test plan
🤖 Generated with Claude Code