From d12e5a5b872c251c3ad79ce5a7588f5724a160e6 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:04:33 -0400 Subject: [PATCH 1/3] Initial implementation for 2.0 --- packages/websocket/CHANGELOG.md | 9 ++ packages/websocket/README.md | 181 +++++++++++--------------- packages/websocket/package.json | 7 +- packages/websocket/src/index.ts | 72 +++++++--- packages/websocket/test/index.test.ts | 33 +++++ 5 files changed, 176 insertions(+), 126 deletions(-) diff --git a/packages/websocket/CHANGELOG.md b/packages/websocket/CHANGELOG.md index d123f3f90..04bb848db 100644 --- a/packages/websocket/CHANGELOG.md +++ b/packages/websocket/CHANGELOG.md @@ -1,5 +1,14 @@ # @solid-primitives/websocket +## 2.0.0-beta.0 + +### Major Changes + +- Upgrade to Solid.js 2.0 (`^2.0.0-beta.7`). +- `createWSState`: signal now uses `ownedWrite: true` to suppress dev-mode warnings when `ws.close()` is called from within a component or effect. +- New primitive `createWSMessage`: reactive signal containing the latest received WebSocket message. Cleans up its event listener automatically on owner disposal. +- Updated all JSDoc examples to use the Solid 2.0 split `createEffect(compute, effect)` form. + ## 1.3.2 ### Patch Changes diff --git a/packages/websocket/README.md b/packages/websocket/README.md index 44ccd0248..b5e40b924 100644 --- a/packages/websocket/README.md +++ b/packages/websocket/README.md @@ -6,148 +6,113 @@ [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -Primitive to help establish, maintain and operate a websocket connection. +Primitives to help establish, maintain, and operate a WebSocket connection. -- `makeWS` - sets up a web socket connection with a buffered send -- `createWS` - sets up a web socket connection that disconnects on cleanup -- `createWSState` - creates a reactive signal containing the readyState of a websocket -- `makeReconnectingWS` - sets up a web socket connection that reconnects if involuntarily closed -- `createReconnectingWS` - sets up a reconnecting web socket connection that disconnects on cleanup -- `makeHeartbeatWS` - wraps a reconnecting web socket to send a heart beat and reconnect if the answer fails - -All of them return a WebSocket instance extended with a `message` prop containing an accessor for the last received message for convenience and the ability to receive messages to send before the connection is opened. +- `makeWS` - sets up a WebSocket connection with a buffered send (manual cleanup) +- `createWS` - sets up a WebSocket connection that closes on owner disposal +- `createWSState` - reactive signal for the WebSocket's `readyState` +- `createWSMessage` - reactive signal containing the latest received message +- `makeReconnectingWS` - WebSocket that reconnects automatically on involuntary close (manual cleanup) +- `createReconnectingWS` - reconnecting WebSocket that closes on owner disposal +- `makeHeartbeatWS` - wraps a reconnecting WebSocket with a heartbeat/pong watchdog ## How to use it +### Basic connection with reactive state and messages + ```ts const ws = createWS("ws://localhost:5000"); const state = createWSState(ws); -const states = ["Connecting", "Connected", "Disconnecting", "Disconnected"]; +const message = createWSMessage(ws); + +const states = ["Connecting", "Open", "Closing", "Closed"] as const; + ws.send("it works"); -createEffect(on(ws.message, msg => console.log(msg), { defer: true })); -return

Connection: {states[state()]}

; -const socket = makeHeartbeatWS( - makeReconnectingWS(`ws://${location.hostName}/api/ws`, undefined, { timeout: 500 }), - { message: "πŸ‘" }, +// Solid 2.0: createEffect takes separate compute and effect callbacks +createEffect( + () => message(), + (msg) => msg !== undefined && console.log("received:", msg), ); -// with the primitives starting with `make...`, one needs to manually clean up: -socket.send("this will reconnect if connection fails"); + +return

Connection: {states[state()]}

; ``` -### Definitions +### Heartbeat + reconnecting WebSocket ```ts -/** Arguments of the primitives */ -type WSProps = [url: string, protocols?: string | string[]]; -type WSMessage = string | ArrayBufferLike | ArrayBufferView | Blob; -type WSReadyState = WebSocket.CONNECTING | WebSocket.OPEN | WebSocket.CLOSING | WebSocket.CLOSED; -type WSEventMap = { - close: CloseEvent; - error: Event; - message: MessageEvent; - open: Event; -}; -type ReconnectingWebSocket = WebSocket & { - reconnect: () => void; - // ws.send.before is meant to be used by heartbeat - send: ((msg: WSMessage) => void) & { before: () => void }; -}; -type WSHeartbeatOptions = { - /** - * Heartbeat message being sent to the server in order to validate the connection - * @default "ping" - */ - message?: WSMessage; - /** - * The time between messages being sent in milliseconds - * @default 1000 - */ - interval?: number; - /** - * The time after the heartbeat message being sent to wait for the next message in milliseconds - * @default 1500 - */ - wait?: number; -}; +const ws = makeHeartbeatWS( + makeReconnectingWS(`ws://${location.hostname}/api/ws`, undefined, { delay: 500 }), + { message: "ping" }, +); + +createEffect( + () => serverMessage(), + (msg) => ws.send(msg), +); + +// Primitives starting with `make` require manual cleanup: +onCleanup(() => ws.close()); ``` -If you want to use the messages as a signal, have a look at the [`event-listener`](../event-listener/README.md) package: +### Reacting to each new message ```ts -import { createWS } from "@solid-primitives/websocket"; -import { createEventSignal } from "@solid-primitives/event-listener"; - const ws = createWS("ws://localhost:5000"); -const messageEvent = createEventSignal(ws, "message"); -const message = () => messageEvent().data; +const message = createWSMessage<{ type: string; payload: unknown }>(ws); + +// Split-effect form: compute phase tracks the signal, effect phase does the work +createEffect( + () => message(), + (msg) => { + if (msg?.type === "update") handleUpdate(msg.payload); + }, +); ``` -Otherwise, you can simply use the message event to get message.data: +### Accumulating messages into a store ```ts import { createStore } from "solid-js/store"; -import { createReconnectingWS, WSMessage } from "@solid-primitives/websocket"; +import { createReconnectingWS, type WSMessage } from "@solid-primitives/websocket"; const ws = createReconnectingWS("ws://localhost:5000"); -const [messages, setMessages] = createStore(); -ws.addEventListener("message", (ev) => setMessages(messages.length, ev.data)); - - messages}> - {(message) => ...} - -``` +const [messages, setMessages] = createStore([]); -## Setting up a websocket server +ws.addEventListener("message", (ev) => setMessages(prev => [...prev, ev.data])); -While you can use this primitive with solid-start, it already provides a package for websockets that handles both the server and the client side: - -```ts -import { createWebSocketServer } from "solid-start/websocket"; -import server$ from "solid-start/server"; - -const pingPong = createWebSocketServer( - server$(function (webSocket) { - webSocket.addEventListener("message", async msg => { - try { - // Parse the incoming message - let incomingMessage = JSON.parse(msg.data); - console.log(incomingMessage); - - switch (incomingMessage.type) { - case "ping": - webSocket.send( - JSON.stringify([ - { - type: "pong", - data: { - id: incomingMessage.data.id, - time: Date.now(), - }, - }, - ]), - ); - break; - } - } catch (err: any) { - // Report any exceptions directly back to the client. As with our handleErrors() this - // probably isn't what you'd want to do in production, but it's convenient when testing. - webSocket.send(JSON.stringify({ error: err.stack })); - } - }); - }), +return ( + + {(msg) =>

{String(msg)}

} +
); ``` -Otherwise, in order to set up your own production-use websocket server, we recommend packages like +## Definitions + +```ts +type WSMessage = string | ArrayBufferLike | ArrayBufferView | Blob; -- nodejs: [`ws`](https://github.com/websockets/ws) -- rust: [`websocket`](https://docs.rs/websocket/latest/websocket/) +type WSReconnectOptions = { + delay?: number; // ms between reconnect attempts (default: 3000) + retries?: number; // max reconnect attempts (default: Infinity) +}; -## Demo +type ReconnectingWebSocket = WebSocket & { + reconnect: () => void; + // ws.send.before is used internally by makeHeartbeatWS + send: ((msg: WSMessage) => void) & { before?: () => void }; +}; -You may view a working example here: -https://primitives.solidjs.community/playground/websocket/ +type WSHeartbeatOptions = { + /** Heartbeat message sent to validate the connection. Default: "ping" */ + message?: WSMessage; + /** Interval between heartbeat messages in ms. Default: 1000 */ + interval?: number; + /** Time to wait for a response before reconnecting in ms. Default: 1500 */ + wait?: number; +}; +``` ## Changelog diff --git a/packages/websocket/package.json b/packages/websocket/package.json index 7e3607734..f5eeb8547 100644 --- a/packages/websocket/package.json +++ b/packages/websocket/package.json @@ -1,6 +1,6 @@ { "name": "@solid-primitives/websocket", - "version": "1.3.2", + "version": "2.0.0-beta.0", "description": "Primitive to create a web socket connection", "author": "David Di Biase ", "contributors": [ @@ -19,6 +19,7 @@ "makeWS", "createWS", "createWSState", + "createWSMessage", "makeReconnectingWS", "createReconnectingWS", "makeHeartbeatWS" @@ -55,10 +56,10 @@ "primitives" ], "peerDependencies": { - "solid-js": "^1.6.12" + "solid-js": "^2.0.0-beta.7" }, "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "solid-js": "2.0.0-beta.7" } } diff --git a/packages/websocket/src/index.ts b/packages/websocket/src/index.ts index e8946e7b9..733c3a3d6 100644 --- a/packages/websocket/src/index.ts +++ b/packages/websocket/src/index.ts @@ -3,10 +3,13 @@ import { type Accessor, onCleanup, createSignal } from "solid-js"; export type WSMessage = string | ArrayBufferLike | ArrayBufferView | Blob; /** - * opens a web socket connection with a queued send + * Opens a web socket connection with a queued send. * ```ts * const ws = makeWS("ws://localhost:5000"); - * createEffect(() => ws.send(serverMessage())); + * createEffect( + * () => serverMessage(), + * (msg) => ws.send(msg), + * ); * onCleanup(() => ws.close()); * ``` * Will not throw if you attempt to send messages before the connection opened; instead, it will enqueue the message to be sent when the connection opens. @@ -28,10 +31,13 @@ export const makeWS = ( }; /** - * opens a web socket connection with a queued send that closes on cleanup + * Opens a web socket connection with a queued send that closes on cleanup. * ```ts - * const ws = makeWS("ws://localhost:5000"); - * createEffect(() => ws.send(serverMessage())); + * const ws = createWS("ws://localhost:5000"); + * createEffect( + * () => serverMessage(), + * (msg) => ws.send(msg), + * ); * ``` * Will not throw if you attempt to send messages before the connection opened; instead, it will enqueue the message to be sent when the connection opens. */ @@ -42,12 +48,12 @@ export const createWS = (url: string, protocols?: string | string[]): WebSocket }; /** - * Returns a reactive state signal for the web socket's readyState: + * Returns a reactive signal for the WebSocket's `readyState`: * - * WebSocket.CONNECTING = 0 - * WebSocket.OPEN = 1 - * WebSocket.CLOSING = 2 - * WebSocket.CLOSED = 3 + * - `0` β€” CONNECTING + * - `1` β€” OPEN + * - `2` β€” CLOSING + * - `3` β€” CLOSED * * ```ts * const ws = createWS('ws://localhost:5000'); @@ -57,7 +63,10 @@ export const createWS = (url: string, protocols?: string | string[]): WebSocket * ``` */ export const createWSState = (ws: WebSocket): Accessor<0 | 1 | 2 | 3> => { - const [state, setState] = createSignal(ws.readyState as 0 | 1 | 2 | 3); + // ownedWrite: true β€” setState may be called from ws.close(), which the user + // could invoke inside a component or effect. This suppresses the dev-mode + // owned-scope write warning for this intentionally internal signal. + const [state, setState] = createSignal(ws.readyState as 0 | 1 | 2 | 3, { ownedWrite: true }); const _close = ws.close.bind(ws); ws.addEventListener("open", () => setState(1)); ws.close = (...args) => { @@ -68,6 +77,33 @@ export const createWSState = (ws: WebSocket): Accessor<0 | 1 | 2 | 3> => { return state; }; +/** + * Returns a reactive signal containing the latest message received from the WebSocket. + * Starts as `undefined` until the first message arrives. + * + * ```ts + * const ws = createWS("ws://localhost:5000"); + * const message = createWSMessage(ws); + * return

Last message: {message()}

; + * ``` + * + * The signal updates on every incoming message. Pair it with `createEffect` to + * react to each new value: + * ```ts + * createEffect( + * () => message(), + * (msg) => msg !== undefined && console.log("received:", msg), + * ); + * ``` + */ +export const createWSMessage = (ws: WebSocket): Accessor => { + const [message, setMessage] = createSignal(undefined); + const handler = (e: MessageEvent) => setMessage(() => e.data as T); + ws.addEventListener("message", handler); + onCleanup(() => ws.removeEventListener("message", handler)); + return message; +}; + export type WSReconnectOptions = { delay?: number; retries?: number; @@ -83,7 +119,10 @@ export type ReconnectingWebSocket = WebSocket & { * Returns a WebSocket-like object that under the hood opens new connections on disconnect: * ```ts * const ws = makeReconnectingWS("ws:localhost:5000"); - * createEffect(() => ws.send(serverMessage())); + * createEffect( + * () => serverMessage(), + * (msg) => ws.send(msg), + * ); * onCleanup(() => ws.close()); * ``` * Will not throw if you attempt to send messages before the connection opened; instead, it will enqueue the message to be sent when the connection opens. @@ -148,8 +187,11 @@ export const makeReconnectingWS = ( /** * Returns a WebSocket-like object that under the hood opens new connections on disconnect and closes on cleanup: * ```ts - * const ws = makeReconnectingWS("ws:localhost:5000"); - * createEffect(() => ws.send(serverMessage())); + * const ws = createReconnectingWS("ws:localhost:5000"); + * createEffect( + * () => serverMessage(), + * (msg) => ws.send(msg), + * ); * ``` * Will not throw if you attempt to send messages before the connection opened; instead, it will enqueue the message to be sent when the connection opens. */ @@ -178,7 +220,7 @@ export type WSHeartbeatOptions = { }; /** - * Wraps a reconnecting WebSocket to send a heartbeat to check the connection + * Wraps a reconnecting WebSocket to send a heartbeat to check the connection. * ```ts * const ws = makeHeartbeatWS(createReconnectingWS('ws://localhost:5000')) * ``` diff --git a/packages/websocket/test/index.test.ts b/packages/websocket/test/index.test.ts index 0a240c2cd..5314d3eb3 100644 --- a/packages/websocket/test/index.test.ts +++ b/packages/websocket/test/index.test.ts @@ -4,6 +4,7 @@ import { createRoot } from "solid-js"; import { createWS, createWSState, + createWSMessage, createReconnectingWS, makeReconnectingWS, makeHeartbeatWS, @@ -73,6 +74,38 @@ describe("createWSState", () => { })); }); +describe("createWSMessage", () => { + it("is undefined before any messages arrive", () => + createRoot(dispose => { + const ws = createWS("ws://localhost:5000"); + const message = createWSMessage(ws); + expect(message()).toBeUndefined(); + dispose(); + })); + + it("reflects the latest received message", () => + createRoot(dispose => { + const ws = createWS("ws://localhost:5000"); + const message = createWSMessage(ws); + vi.advanceTimersByTime(20); // wait for open + ws.dispatchEvent(new MessageEvent("message", { data: "hello" })); + expect(message()).toBe("hello"); + ws.dispatchEvent(new MessageEvent("message", { data: "world" })); + expect(message()).toBe("world"); + dispose(); + })); + + it("removes the event listener on disposal", () => + createRoot(dispose => { + const ws = makeWS("ws://localhost:5000"); + const spy = vi.spyOn(ws, "removeEventListener"); + createWSMessage(ws); + dispose(); + expect(spy).toHaveBeenCalledWith("message", expect.any(Function)); + ws.close(); + })); +}); + describe("makeReconnectingWS", () => { it("reconnects after being closed by external circumstances", () => { const ws = makeReconnectingWS("ws://localhost:5000", undefined, { delay: 100 }); From e8ef648303e1b1f953cfa952e2c3900294462139 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:28:12 -0400 Subject: [PATCH 2/3] Better alignment with 2.0 capabilities --- .changeset/websocket-solid-2-async.md | 32 ++++ packages/memo/README.md | 2 +- packages/websocket/README.md | 224 +++++++++++++++++++------- 3 files changed, 202 insertions(+), 56 deletions(-) create mode 100644 .changeset/websocket-solid-2-async.md diff --git a/.changeset/websocket-solid-2-async.md b/.changeset/websocket-solid-2-async.md new file mode 100644 index 000000000..c7a971f09 --- /dev/null +++ b/.changeset/websocket-solid-2-async.md @@ -0,0 +1,32 @@ +--- +"@solid-primitives/websocket": major +--- + +Upgrade to Solid.js 2.0 (`^2.0.0-beta.7`) and add async-reactive message primitives. + +**Breaking changes** + +- Peer dependency is now `solid-js@^2.0.0-beta.7`. All `createEffect` examples in docs now use the Solid 2.0 split form: `createEffect(compute, effect)`. + +**New: `createWSMessage`** + +Reactive `Accessor` for the most recently received WebSocket message. Cleans up its event listener on owner disposal via `onCleanup`. + +```ts +const message = createWSMessage(ws); +return

{message()}

; +``` + +> Note: uses a signal internally, so under burst conditions only the final message before a flush is tracked by effects. For every-message processing, use the planned `wsMessageIterable` / `createWSData` primitives. + +**`createWSState` signal fix** + +Internal signal now uses `{ ownedWrite: true }` to suppress the Solid 2.0 dev-mode `SIGNAL_WRITE_IN_OWNED_SCOPE` diagnostic, which would fire if `ws.close()` is called from inside a component body or reactive computation. + +**Planned for next minor: async message primitives** + +The following are designed and documented but not yet implemented, based on Solid 2.0's `createMemo(AsyncIterable)` model: + +- `wsMessageIterable` β€” buffered `AsyncIterable` that never drops burst messages; works with `makeReconnectingWS` +- `createWSData` β€” async memo over `wsMessageIterable`; suspends `` until first message; integrates with `isPending` and `latest` +- `createWSStore` β€” reactive store driven by WS messages as draft-mutation patches via `createStore(fn, seed)` diff --git a/packages/memo/README.md b/packages/memo/README.md index d3e5bdb6a..8d164fddf 100644 --- a/packages/memo/README.md +++ b/packages/memo/README.md @@ -156,7 +156,7 @@ The lazy memo, as it is implemented now, doesn't allow for setting a `equals` fu ### Not ownerless -Lazy memos in Solid 2.0 will be ownerless β€” the reactive context of the callback will depend of the place of read, not creation. +Lazy memos in Solid will be ownerless β€” the reactive context of the callback will depend of the place of read, not creation. This implementation will always execute it's callback with the context of owner it was created under. So ti won't work with [Suspense](https://www.solidjs.com/docs/latest/api#) the way you might expect β€” meaning that it won't activate any Suspense that is below place of creation. diff --git a/packages/websocket/README.md b/packages/websocket/README.md index b5e40b924..a1ea25834 100644 --- a/packages/websocket/README.md +++ b/packages/websocket/README.md @@ -6,111 +6,225 @@ [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -Primitives to help establish, maintain, and operate a WebSocket connection. +Primitives to help establish, maintain, and operate WebSocket connections in Solid. -- `makeWS` - sets up a WebSocket connection with a buffered send (manual cleanup) -- `createWS` - sets up a WebSocket connection that closes on owner disposal -- `createWSState` - reactive signal for the WebSocket's `readyState` -- `createWSMessage` - reactive signal containing the latest received message -- `makeReconnectingWS` - WebSocket that reconnects automatically on involuntary close (manual cleanup) -- `createReconnectingWS` - reconnecting WebSocket that closes on owner disposal -- `makeHeartbeatWS` - wraps a reconnecting WebSocket with a heartbeat/pong watchdog +### Connection primitives -## How to use it +- [`makeWS`](#makews) β€” raw WebSocket with a buffered send queue (manual cleanup) +- [`createWS`](#createws) β€” same, but closes on owner disposal +- [`createWSState`](#createwsstate) β€” reactive `readyState` signal (`0`–`3`) +- [`makeReconnectingWS`](#makereconnectingws) β€” auto-reconnects on involuntary close (manual cleanup) +- [`createReconnectingWS`](#createreconnectingws) β€” same, but closes on owner disposal +- [`makeHeartbeatWS`](#makeheartbeatws) β€” wraps a reconnecting WS with a heartbeat/pong watchdog -### Basic connection with reactive state and messages +### Message primitives -```ts -const ws = createWS("ws://localhost:5000"); -const state = createWSState(ws); -const message = createWSMessage(ws); +- [`createWSMessage`](#createwsmessage) β€” reactive signal for the **latest** received message +- [`wsMessageIterable`](#wsmessageiterable-planned) β€” buffered `AsyncIterable` over WS messages *(planned)* +- [`createWSData`](#createwsdata-planned) β€” async memo compatible with ``, `isPending`, and `latest` *(planned)* +- [`createWSStore`](#createwsstore-planned) β€” reactive store driven by WS message patches *(planned)* + +--- -const states = ["Connecting", "Open", "Closing", "Closed"] as const; +## Connection primitives -ws.send("it works"); +### `makeWS` -// Solid 2.0: createEffect takes separate compute and effect callbacks +Sets up a WebSocket with a buffered send queue. Messages sent before the connection opens are queued and flushed on `open`. Does **not** close on cleanup β€” use `createWS` for that. + +```ts +const ws = makeWS("ws://localhost:5000"); createEffect( - () => message(), - (msg) => msg !== undefined && console.log("received:", msg), + () => serverMessage(), + (msg) => ws.send(msg), ); - -return

Connection: {states[state()]}

; +onCleanup(() => ws.close()); ``` -### Heartbeat + reconnecting WebSocket +### `createWS` -```ts -const ws = makeHeartbeatWS( - makeReconnectingWS(`ws://${location.hostname}/api/ws`, undefined, { delay: 500 }), - { message: "ping" }, -); +Same as `makeWS`, but registers `ws.close()` with `onCleanup`. +```ts +const ws = createWS("ws://localhost:5000"); createEffect( () => serverMessage(), (msg) => ws.send(msg), ); +``` -// Primitives starting with `make` require manual cleanup: -onCleanup(() => ws.close()); +### `createWSState` + +Returns a reactive `Accessor<0 | 1 | 2 | 3>` tracking the WebSocket's `readyState`. + +```ts +const ws = createWS("ws://localhost:5000"); +const state = createWSState(ws); +const labels = ["Connecting", "Open", "Closing", "Closed"] as const; + +return

Status: {labels[state()]}

; ``` -### Reacting to each new message +### `createWSMessage` + +Returns a reactive `Accessor` that holds the **most recently received** message. Starts as `undefined`. ```ts const ws = createWS("ws://localhost:5000"); -const message = createWSMessage<{ type: string; payload: unknown }>(ws); +const message = createWSMessage(ws); -// Split-effect form: compute phase tracks the signal, effect phase does the work +return

Last message: {message()}

; +``` + +> **Note β€” "latest wins" semantics.** `createWSMessage` uses a signal internally. In Solid 2.0, signal writes are batched: if two messages arrive before the reactive flush, only the second is seen by effects. This is fine for "current state" displays, but if your protocol can burst messages and you need to process every one, use [`wsMessageIterable`](#wsmessageiterable-planned) or [`createWSData`](#createwsdata-planned) instead. + +### `makeReconnectingWS` + +Returns a `WebSocket`-shaped proxy that transparently opens a new underlying connection whenever the server closes it involuntarily. + +```ts +const ws = makeReconnectingWS("ws://localhost:5000", undefined, { delay: 3000, retries: Infinity }); createEffect( - () => message(), - (msg) => { - if (msg?.type === "update") handleUpdate(msg.payload); - }, + () => serverMessage(), + (msg) => ws.send(msg), ); +onCleanup(() => ws.close()); ``` -### Accumulating messages into a store +### `createReconnectingWS` + +Same as `makeReconnectingWS`, but closes on owner disposal. + +### `makeHeartbeatWS` + +Wraps a `ReconnectingWebSocket` to send a periodic heartbeat. If no response arrives within `wait` ms the connection is force-reconnected. ```ts -import { createStore } from "solid-js/store"; -import { createReconnectingWS, type WSMessage } from "@solid-primitives/websocket"; +const ws = makeHeartbeatWS( + createReconnectingWS("ws://localhost:5000"), + { message: "ping", interval: 1000, wait: 1500 }, +); +``` + +--- + +## Async message primitives *(planned for next minor)* + +These three primitives leverage Solid 2.0's async reactivity β€” `createMemo` with `AsyncIterable`, `` boundaries, `isPending`, and `latest` β€” to provide a more powerful and correct model for WebSocket data. + +### `wsMessageIterable` *(planned)* + +The foundational building block. Returns a buffered `AsyncIterable` over a WebSocket's message stream. Cleanup (`ws.removeEventListener`) happens automatically when the iterator is returned (Solid calls `it.return()` on memo disposal). + +```ts +import { wsMessageIterable } from "@solid-primitives/websocket"; + +// Compose freely with any Solid 2.0 async primitive: +const latestQuote = createMemo(async function* () { + for await (const raw of wsMessageIterable(ws)) { + yield JSON.parse(raw) as Quote; + } +}); +``` + +Works correctly with `makeReconnectingWS` β€” event listeners are re-attached to each new underlying connection, so the iterable survives reconnects transparently. -const ws = createReconnectingWS("ws://localhost:5000"); -const [messages, setMessages] = createStore([]); +**Why this doesn't drop messages:** Unlike `createWSMessage`, each yielded value triggers its own `flush()` inside the Solid runtime. Messages that arrive while an earlier one is being processed are buffered and drained synchronously, so no message is skipped by reactive effects. -ws.addEventListener("message", (ev) => setMessages(prev => [...prev, ev.data])); +### `createWSData` *(planned)* + +An async memo wrapping `wsMessageIterable`. Suspends the nearest `` boundary until the first message arrives; subsequent updates work with `isPending` and `latest`. + +```tsx +const price = createWSData(ws, { transform: JSON.parse }); return ( - - {(msg) =>

{String(msg)}

} -
+ Waiting for data…

}> + {/* isPending: true while the next tick is in-flight with a stale value showing */} +

price()) ? "stale" : ""}> + Bid: {price().bid} / Ask: {price().ask} +

+
); ``` -## Definitions +Comparison with `createWSMessage`: + +| | `createWSMessage` | `createWSData` | +|---|---|---| +| Drops burst messages | Yes | No | +| Works with `` | No | Yes | +| `isPending()` support | No | Yes | +| `latest()` support | No | Yes | +| Returns `undefined` before first message | Yes | No β€” throws (suspends) | +| Best for | Simple last-value display | State-source WS, real-time feeds | + +### `createWSStore` *(planned)* + +A reactive store driven by WebSocket messages as incremental patches. Uses Solid `createStore(fn, seed)` form β€” each message is applied as a draft mutation. + +```tsx +const [appState] = createWSStore(ws, { + initial: { users: [], status: "connecting" }, + patch(draft, msg) { + Object.assign(draft, JSON.parse(msg)); + }, +}); + +return

Users online: {appState.users.length}

; +``` + +--- + +## Composing with `action` (request/response pattern) + +For protocols with correlated request/response over a shared WebSocket, Solid `action` is used: + +```ts +const queryServer = action(function* (payload: RequestPayload) { + const id = crypto.randomUUID(); + + setOptimisticState(draft => { draft.loading = true; }); + + ws.send(JSON.stringify({ ...payload, id })); + + const response: ResponsePayload = yield new Promise(resolve => { + const handler = (e: MessageEvent) => { + const msg = JSON.parse(e.data); + if (msg.id === id) { + ws.removeEventListener("message", handler); + resolve(msg); + } + }; + ws.addEventListener("message", handler); + }); + + refresh(() => serverData()); + return response; +}); +``` + +--- + +## Type reference ```ts type WSMessage = string | ArrayBufferLike | ArrayBufferView | Blob; type WSReconnectOptions = { - delay?: number; // ms between reconnect attempts (default: 3000) - retries?: number; // max reconnect attempts (default: Infinity) + delay?: number; // ms between reconnect attempts β€” default: 3000 + retries?: number; // max reconnect attempts β€” default: Infinity }; type ReconnectingWebSocket = WebSocket & { reconnect: () => void; - // ws.send.before is used internally by makeHeartbeatWS send: ((msg: WSMessage) => void) & { before?: () => void }; }; type WSHeartbeatOptions = { - /** Heartbeat message sent to validate the connection. Default: "ping" */ - message?: WSMessage; - /** Interval between heartbeat messages in ms. Default: 1000 */ - interval?: number; - /** Time to wait for a response before reconnecting in ms. Default: 1500 */ - wait?: number; + message?: WSMessage; // default: "ping" + interval?: number; // ms between heartbeats β€” default: 1000 + wait?: number; // ms to wait for pong before reconnecting β€” default: 1500 }; ``` From 4dde890f0f414a3e51a74878829ed5a0acba59fc Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:36:31 -0400 Subject: [PATCH 3/3] Minor adjustment --- packages/websocket/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/websocket/README.md b/packages/websocket/README.md index a1ea25834..5050d0739 100644 --- a/packages/websocket/README.md +++ b/packages/websocket/README.md @@ -110,7 +110,7 @@ const ws = makeHeartbeatWS( ## Async message primitives *(planned for next minor)* -These three primitives leverage Solid 2.0's async reactivity β€” `createMemo` with `AsyncIterable`, `` boundaries, `isPending`, and `latest` β€” to provide a more powerful and correct model for WebSocket data. +These three primitives leverage Solid's async reactivity β€” `createMemo` with `AsyncIterable`, `` boundaries, `isPending`, and `latest` β€” to provide a more powerful and correct model for WebSocket data. ### `wsMessageIterable` *(planned)*