Primitives for Server-Sent Events using the browser's built-in EventSource API.
makeSSE— Base non-reactive primitive. Creates anEventSourceand returns a cleanup function. No Solid lifecycle.createSSE— Reactive primitive. Accepts a reactive URL, integrates with Solid's owner lifecycle, and returns signals fordata,error, andreadyState.makeSSEWorker— Runs the SSE connection inside a Web Worker or SharedWorker.- Built-in transformers —
json,ndjson,lines,number,safe,pipe.
npm install @solid-primitives/sse
# or
pnpm add @solid-primitives/sseCreates a raw EventSource connection without any Solid lifecycle management. Event handlers are attached immediately. You are responsible for calling the returned cleanup function.
This is the foundation primitive — createSSE uses it internally.
import { makeSSE } from "@solid-primitives/sse";
const [source, cleanup] = makeSSE("https://api.example.com/events", {
onOpen: () => console.log("Connected"),
onMessage: e => console.log("Message:", e.data),
onError: e => console.error("Error:", e),
events: {
// Named SSE event types (server sends `event: update`)
update: e => console.log("Update:", e.data),
},
});
// When done:
cleanup();function makeSSE(
url: string | URL,
options?: SSEOptions,
): [source: EventSource, cleanup: VoidFunction];
type SSEOptions = {
withCredentials?: boolean;
onOpen?: (event: Event) => void;
onMessage?: (event: MessageEvent) => void;
onError?: (event: Event) => void;
events?: Record<string, (event: MessageEvent) => void>;
};Reactive SSE primitive. Connects on creation, closes when the owner is disposed, and reacts to URL changes.
import { createSSE, SSEReadyState } from "@solid-primitives/sse";
const { data, readyState, error, close, reconnect } = createSSE<{ message: string }>(
"https://api.example.com/events",
{
transform: JSON.parse,
reconnect: { retries: 3, delay: 2000 },
},
);
return (
<div>
<Show when={readyState() === SSEReadyState.OPEN} fallback={<p>Connecting…</p>}>
<p>Latest: {data()?.message ?? "—"}</p>
</Show>
<Show when={error()}>
<p style="color:red">Connection error</p>
</Show>
<button onClick={close}>Disconnect</button>
<button onClick={reconnect}>Reconnect</button>
</div>
);When the URL is a signal accessor, the connection is replaced whenever the URL changes:
const [userId, setUserId] = createSignal("user-1");
const { data } = createSSE<Notification>(
() => `https://api.example.com/notifications/${userId()}`,
{ transform: JSON.parse },
);Changing userId() will close the existing connection and open a new one to the updated URL.
| Option | Type | Default | Description |
|---|---|---|---|
withCredentials |
boolean |
false |
Send credentials with the request |
onOpen |
(e: Event) => void |
— | Called when the connection opens |
onMessage |
(e: MessageEvent) => void |
— | Called on each unnamed message event |
onError |
(e: Event) => void |
— | Called on error |
events |
Record<string, (e: MessageEvent) => void> |
— | Handlers for named SSE event types |
initialValue |
T |
undefined |
Initial value of the data signal |
transform |
(raw: string) => T |
identity | Parse raw string data, e.g. JSON.parse |
reconnect |
boolean | SSEReconnectOptions |
false |
App-level reconnect on terminal errors |
SSEReconnectOptions:
| Option | Type | Default | Description |
|---|---|---|---|
retries |
number |
Infinity |
Max reconnect attempts |
delay |
number |
3000 |
Milliseconds between attempts |
| Property | Type | Description |
|---|---|---|
source |
Accessor<SSESourceHandle | undefined> |
Underlying source instance; undefined on SSR |
data |
Accessor<T | undefined> |
Latest message data |
error |
Accessor<Event | undefined> |
Latest error event |
readyState |
Accessor<SSEReadyState> |
SSEReadyState.CONNECTING / .OPEN / .CLOSED |
close |
VoidFunction |
Close the connection |
reconnect |
VoidFunction |
Force-close and reopen |
Named constants for the connection state, exported as a plain object so they are tree-shakeable and work with every bundler:
import { SSEReadyState } from "@solid-primitives/sse";
SSEReadyState.CONNECTING; // 0
SSEReadyState.OPEN; // 1
SSEReadyState.CLOSED; // 2EventSource has native browser-level reconnection built in. For transient network drops the browser automatically retries. The reconnect option in createSSE is for application-level reconnection — it fires only when readyState becomes SSEReadyState.CLOSED, meaning the browser has given up entirely. You generally do not need reconnect: true for normal usage.
EventSource does not reliably detect when a server silently stops responding. If the server process crashes or the network path is severed without a proper TCP close handshake, the browser never fires an error event and readyState stays OPEN indefinitely — the connection looks healthy even though no messages will ever arrive.
The only robust workaround is application-level heartbeats: the server sends a lightweight event at a fixed interval, and the client starts a timer that triggers a reconnect if no heartbeat is received within the expected window.
import { createSSE } from "@solid-primitives/sse";
import { onCleanup } from "solid-js";
const HEARTBEAT_TIMEOUT_MS = 15_000; // reconnect if silent for 15 s
function createSSEWithHeartbeat(url: string) {
let timer: ReturnType<typeof setTimeout> | undefined;
const { reconnect, ...rest } = createSSE(url, {
// The server emits `event: heartbeat\ndata: \n\n` every ~10 s.
// Any regular message also resets the timer.
events: { heartbeat: resetTimer },
onMessage: resetTimer,
reconnect: true,
});
function resetTimer() {
clearTimeout(timer);
timer = setTimeout(() => {
// No heartbeat received — assume the server is gone.
reconnect();
}, HEARTBEAT_TIMEOUT_MS);
}
onCleanup(() => {
clearTimeout(timer);
timer = undefined;
});
resetTimer(); // arm the first timeout immediately
return { reconnect, ...rest };
}On the server, emit a periodic heartbeat event well within the client timeout:
// Express / Node.js example
setInterval(() => {
res.write("event: heartbeat\ndata: \n\n");
}, 10_000); // every 10 s, safely below the 15 s client timeoutWhy SSE comment lines are not enough — SSE comment lines (e.g.
: keep-alive) reset the browser's internal TCP idle timer but are not exposed to JavaScript listeners. Use a namedevent: heartbeator a plaindata:event if you need the client to observe the heartbeat.
Because bus.emit matches the (event: MessageEvent) => void shape of onMessage, you can wire them directly:
import { createSSE } from "@solid-primitives/sse";
import { createEventBus } from "@solid-primitives/event-bus";
const bus = createEventBus<string>();
createSSE("https://api.example.com/events", {
onMessage: e => bus.emit(e.data),
});
bus.listen(msg => console.log("received:", msg));For streams that use multiple named event types:
import { createSSE } from "@solid-primitives/sse";
import { createEventBus, createEventHub } from "@solid-primitives/event-bus";
type OrderEvent = { id: string; total: number };
type InventoryEvent = { sku: string; qty: number };
const hub = createEventHub({
order: createEventBus<OrderEvent>(),
inventory: createEventBus<InventoryEvent>(),
});
createSSE("https://api.example.com/stream", {
events: {
order: e => hub.emit("order", JSON.parse(e.data)),
inventory: e => hub.emit("inventory", JSON.parse(e.data)),
},
});
hub.on("order", event => console.log("New order:", event));import { createSSE } from "@solid-primitives/sse";
import { createStore } from "solid-js/store";
const [messages, setMessages] = createStore<string[]>([]);
createSSE("https://api.example.com/events", {
onMessage: e => setMessages(msgs => [...msgs, e.data]),
});
return <For each={messages}>{msg => <p>{msg}</p>}</For>;Ready-made transform functions for the most common SSE data formats. Pass one as the transform option to createSSE:
import { createSSE, json } from "@solid-primitives/sse";
const { data } = createSSE<{ status: string }>(url, { transform: json });| Transformer | Description |
|---|---|
json |
Parse data as a single JSON value |
ndjson |
Parse newline-delimited JSON into an array |
lines |
Split data into a string[] by newline |
number |
Parse data as a number via Number() |
safe |
Fault-tolerant wrapper — returns fallback instead of throwing |
pipe |
Compose two transforms into one |
Parse the message data as a single JSON value. Equivalent to JSON.parse but named for consistency with the other transformers.
import { createSSE, json } from "@solid-primitives/sse";
const { data } = createSSE<{ status: string; ts: number }>(url, { transform: json });
// data() === { status: "ok", ts: 1718000000 }Parse the message data as newline-delimited JSON (NDJSON / JSON Lines). Each non-empty line is parsed as a separate JSON value and the transformer returns an array.
Use this when the server batches multiple objects into one SSE event:
data: {"id":1,"type":"tick"}
data: {"id":2,"type":"tick"}
import { createSSE, ndjson } from "@solid-primitives/sse";
const { data } = createSSE<TickEvent[]>(url, { transform: ndjson });
// data() === [{ id: 1, type: "tick" }, { id: 2, type: "tick" }]Split the message data into individual lines, returning a string[]. Empty lines are filtered out. Useful for multi-line text events that are not JSON.
import { createSSE, lines } from "@solid-primitives/sse";
const { data } = createSSE<string[]>(url, { transform: lines });
// data() === ["line one", "line two"]Parse the message data as a number using Number() semantics. Handy for streams that emit counters, progress percentages, sensor readings, or prices.
import { createSSE, number } from "@solid-primitives/sse";
const { data } = createSSE<number>(url, { transform: number });
// data() === 42Note: follows Number() coercion — an empty string becomes 0 and non-numeric strings become NaN.
Wraps any transform in a try/catch. When the inner transform throws, safe returns fallback instead of propagating the error. This keeps the stream alive across malformed events.
import { createSSE, json, number, safe } from "@solid-primitives/sse";
// Returns undefined on a bad event instead of throwing
const { data } = createSSE<MyEvent>(url, { transform: safe(json) });
// With an explicit fallback value
const { data } = createSSE<number>(url, { transform: safe(number, 0) });Composes two transforms into one: the output of a is passed as the input of b. Useful for building custom transforms from existing primitives without writing anonymous functions.
import { createSSE, ndjson, json, safe, pipe } from "@solid-primitives/sse";
// Parse NDJSON then keep only "tick" rows
type RawEvent = { type: string };
const { data } = createSSE<RawEvent[]>(url, {
transform: pipe(ndjson<RawEvent>, rows => rows.filter(r => r.type === "tick")),
});
// Safe JSON with a post-processing step
const { data } = createSSE<string>(url, {
transform: pipe(safe(json<{ label: string }>), ev => ev?.label ?? ""),
});@solid-primitives/sse ships a makeSSEWorker adapter that moves the EventSource connection into a Web Worker or a SharedWorker. The reactive API you get back from createSSE is identical — data, readyState, reconnect, etc. work exactly as documented above.
- High-frequency streams — parsing and dispatching many events per second on the main thread can cause jank. Moving the connection to a Worker keeps that work off the UI thread.
- SharedWorker — if multiple tabs in the same origin connect to the same SSE endpoint, a SharedWorker lets them share a single Worker process (though each tab still gets its own
EventSourceconnection inside the worker).
For typical usage — a handful of events per second — the standard createSSE is simpler and sufficient.
import { makeSSEWorker } from "@solid-primitives/sse/worker";You also need the companion handler script that runs inside the Worker:
import "@solid-primitives/sse/worker-handler";To get the correct URL for the handler at runtime you have a few options depending on your setup:
- Bundler (Vite, Webpack, Rollup, etc.) — use
new URL(…, import.meta.url). The bundler resolves the specifier to the output asset path at build time. See the Vite static asset docs for details; other bundlers work the same way. - Import maps (no bundler) — add an entry for
@solid-primitives/sse/worker-handlerpointing to the CDN or local path of the file, then use a plain string URL:new Worker("/path/to/worker-handler.js", { type: "module" }). - Node / Deno / Bun with a file URL —
new URL("./node_modules/@solid-primitives/sse/dist/worker-handler.js", import.meta.url)works if you reference the built output directly.
import { createSSE } from "@solid-primitives/sse";
import { makeSSEWorker } from "@solid-primitives/sse/worker";
const worker = new Worker(new URL("@solid-primitives/sse/worker-handler", import.meta.url), {
type: "module",
});
const { data, readyState, error, close, reconnect } = createSSE<{ msg: string }>(
"https://api.example.com/events",
{
source: makeSSEWorker(worker),
transform: JSON.parse,
reconnect: { retries: 3, delay: 2000 },
},
);That's the only change compared to a standard createSSE call — pass source: makeSSEWorker(worker) and everything else stays the same.
A SharedWorker is shared across all tabs on the same origin. Pass sw.port (a MessagePort) in place of the Worker instance:
import { createSSE } from "@solid-primitives/sse";
import { makeSSEWorker } from "@solid-primitives/sse/worker";
const sw = new SharedWorker(new URL("@solid-primitives/sse/worker-handler", import.meta.url), {
type: "module",
});
sw.port.start(); // required to activate a MessagePort
const { data } = createSSE("https://api.example.com/events", {
source: makeSSEWorker(sw.port),
});makeSSEWorker accepts anything that satisfies SSEWorkerTarget — both Worker and MessagePort do.
makeSSEWorker(target) returns an SSESourceFn, the same factory interface that createSSE uses internally. When createSSE opens a connection it calls this factory instead of the default makeSSE, which:
- Creates a plain
EventTargetwith areadyStateproperty and aclose()method, satisfying theSSESourceHandleinterface without needing a realEventSource. - Posts a
connectmessage to the Worker. The Worker script (worker-handler) creates a realEventSourcethere and postsopen/message/errorevents back viapostMessage. - The message listener on the main thread forwards those events to
createSSE's callbacks and dispatches them on theEventTargetso any directaddEventListenercalls also work. createSSE's reactive machinery — signals, reconnect timer, URL tracking,onCleanup— runs on the main thread as normal; it just receives events viapostMessageinstead of directly from a realEventSource.
// @solid-primitives/sse/worker
function makeSSEWorker(target: SSEWorkerTarget): SSESourceFn;
/** Accepted by makeSSEWorker — satisfied by both Worker and SharedWorker.port */
type SSEWorkerTarget = {
postMessage(data: SSEWorkerMessage): void;
addEventListener(type: "message", listener: (e: MessageEvent<SSEWorkerMessage>) => void): void;
removeEventListener(type: "message", listener: (e: MessageEvent<SSEWorkerMessage>) => void): void;
};
/** Messages exchanged between the main thread and the Worker */
type SSEWorkerMessage =
| { type: "connect"; id: string; url: string; withCredentials?: boolean; events?: string[] }
| { type: "disconnect"; id: string }
| { type: "open"; id: string }
| { type: "message"; id: string; data: string; eventType: string }
| { type: "error"; id: string; readyState: SSEReadyStateValue };See CHANGELOG.md.