Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/websocket-solid-2-async.md
Original file line number Diff line number Diff line change
@@ -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<T>`**

Reactive `Accessor<T | undefined>` for the most recently received WebSocket message. Cleans up its event listener on owner disposal via `onCleanup`.

```ts
const message = createWSMessage<string>(ws);
return <p>{message()}</p>;
```

> 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<T>` — buffered `AsyncIterable` that never drops burst messages; works with `makeReconnectingWS`
- `createWSData<T>` — async memo over `wsMessageIterable`; suspends `<Loading>` until first message; integrates with `isPending` and `latest`
- `createWSStore<T>` — reactive store driven by WS messages as draft-mutation patches via `createStore(fn, seed)`
2 changes: 1 addition & 1 deletion packages/memo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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#<suspense>) the way you might expect — meaning that it won't activate any Suspense that is below place of creation.

Expand Down
9 changes: 9 additions & 0 deletions packages/websocket/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<T>`: 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
Expand Down
297 changes: 188 additions & 109 deletions packages/websocket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,148 +6,227 @@

[![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 WebSocket connections in Solid.

- `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
### Connection primitives

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`](#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

## How to use it
### Message primitives

- [`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 `<Loading>`, `isPending`, and `latest` *(planned)*
- [`createWSStore`](#createwsstore-planned) — reactive store driven by WS message patches *(planned)*

---

## Connection primitives

### `makeWS`

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 = createWS("ws://localhost:5000");
const state = createWSState(ws);
const states = ["Connecting", "Connected", "Disconnecting", "Disconnected"];
ws.send("it works");
createEffect(on(ws.message, msg => console.log(msg), { defer: true }));
return <p>Connection: {states[state()]}</p>;

const socket = makeHeartbeatWS(
makeReconnectingWS(`ws://${location.hostName}/api/ws`, undefined, { timeout: 500 }),
{ message: "👍" },
const ws = makeWS("ws://localhost:5000");
createEffect(
() => serverMessage(),
(msg) => ws.send(msg),
);
// with the primitives starting with `make...`, one needs to manually clean up:
socket.send("this will reconnect if connection fails");
onCleanup(() => ws.close());
```

### Definitions
### `createWS`

Same as `makeWS`, but registers `ws.close()` with `onCleanup`.

```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 = createWS("ws://localhost:5000");
createEffect(
() => serverMessage(),
(msg) => ws.send(msg),
);
```

If you want to use the messages as a signal, have a look at the [`event-listener`](../event-listener/README.md) package:
### `createWSState`

Returns a reactive `Accessor<0 | 1 | 2 | 3>` tracking the WebSocket's `readyState`.

```ts
import { createWS } from "@solid-primitives/websocket";
import { createEventSignal } from "@solid-primitives/event-listener";
const ws = createWS("ws://localhost:5000");
const state = createWSState(ws);
const labels = ["Connecting", "Open", "Closing", "Closed"] as const;

return <p>Status: {labels[state()]}</p>;
```

### `createWSMessage`

Returns a reactive `Accessor<T | undefined>` that holds the **most recently received** message. Starts as `undefined`.

```ts
const ws = createWS("ws://localhost:5000");
const messageEvent = createEventSignal(ws, "message");
const message = () => messageEvent().data;
const message = createWSMessage<string>(ws);

return <p>Last message: {message()}</p>;
```

Otherwise, you can simply use the message event to get message.data:
> **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
import { createStore } from "solid-js/store";
import { createReconnectingWS, WSMessage } from "@solid-primitives/websocket";
const ws = makeReconnectingWS("ws://localhost:5000", undefined, { delay: 3000, retries: Infinity });
createEffect(
() => serverMessage(),
(msg) => ws.send(msg),
);
onCleanup(() => ws.close());
```

### `createReconnectingWS`

Same as `makeReconnectingWS`, but closes on owner disposal.

const ws = createReconnectingWS("ws://localhost:5000");
const [messages, setMessages] = createStore<WSMessage[]>();
ws.addEventListener("message", (ev) => setMessages(messages.length, ev.data));
### `makeHeartbeatWS`

<For each={() => messages}>
{(message) => ...}
</For>
Wraps a `ReconnectingWebSocket` to send a periodic heartbeat. If no response arrives within `wait` ms the connection is force-reconnected.

```ts
const ws = makeHeartbeatWS(
createReconnectingWS("ws://localhost:5000"),
{ message: "ping", interval: 1000, wait: 1500 },
);
```

## Setting up a websocket server
---

## Async message primitives *(planned for next minor)*

These three primitives leverage Solid's async reactivity — `createMemo` with `AsyncIterable`, `<Loading>` boundaries, `isPending`, and `latest` — to provide a more powerful and correct model for WebSocket data.

### `wsMessageIterable` *(planned)*

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:
The foundational building block. Returns a buffered `AsyncIterable<T>` 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 { 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 }));
}
});
}),
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<string>(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.

**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.

### `createWSData` *(planned)*

An async memo wrapping `wsMessageIterable`. Suspends the nearest `<Loading>` boundary until the first message arrives; subsequent updates work with `isPending` and `latest`.

```tsx
const price = createWSData<Quote>(ws, { transform: JSON.parse });

return (
<Loading fallback={<p>Waiting for data…</p>}>
{/* isPending: true while the next tick is in-flight with a stale value showing */}
<p class={isPending(() => price()) ? "stale" : ""}>
Bid: {price().bid} / Ask: {price().ask}
</p>
</Loading>
);
```

Otherwise, in order to set up your own production-use websocket server, we recommend packages like
Comparison with `createWSMessage`:

| | `createWSMessage` | `createWSData` |
|---|---|---|
| Drops burst messages | Yes | No |
| Works with `<Loading>` | 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 <p>Users online: {appState.users.length}</p>;
```

---

## 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();

- nodejs: [`ws`](https://github.com/websockets/ws)
- rust: [`websocket`](https://docs.rs/websocket/latest/websocket/)
setOptimisticState(draft => { draft.loading = true; });

## Demo
ws.send(JSON.stringify({ ...payload, id }));

You may view a working example here:
https://primitives.solidjs.community/playground/websocket/
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
};

type ReconnectingWebSocket = WebSocket & {
reconnect: () => void;
send: ((msg: WSMessage) => void) & { before?: () => void };
};

type WSHeartbeatOptions = {
message?: WSMessage; // default: "ping"
interval?: number; // ms between heartbeats — default: 1000
wait?: number; // ms to wait for pong before reconnecting — default: 1500
};
```

## Changelog

Expand Down
Loading
Loading