Primitives for Server-Sent Events (SSE) using the browser's EventSource API.
| Stage | Category | Version | Last Updated | Demo |
|---|---|---|---|---|
| 3 | Network | 1.0.0-next.2 (next) | Aug 12, 2026 | Demo → |
npm i @solid-primitives/sse@nextPrimitives for Server-Sent Events using the browser's built-in EventSource API. Designed for Solid 2.0's async reactivity model.
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 fordataandreadyState.makeSSEAsyncIterable— Wraps an SSE endpoint as anAsyncIterable<T>. Non-reactive foundation.createSSEStream— Minimal reactive stream: just adataaccessor backed by an async iterable.makeSSEWorker— Runs the SSE connection inside a Web Worker or SharedWorker.- Built-in transformers —
json,ndjson,lines,number,safe,pipe.
makeSSE
Creates 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();Definition
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>;};createSSE
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, close, reconnect } = createSSE<{ message: string }>( "https://api.example.com/events", { transform: JSON.parse, reconnect: { retries: 3, delay: 2000 }, },);Loading and error boundaries
data() integrates with Solid 2.0's async reactivity:
<Loading>— shows fallback whiledata()is pending (before the first message arrives).<Errored>— catches terminal errors (connection CLOSED with no retries left) thrown throughdata().
import { Loading, Errored } from "solid-js";import { createSSE } from "@solid-primitives/sse";
const { data, close, reconnect } = createSSE<{ message: string }>( "https://api.example.com/events", { transform: JSON.parse },);
return ( <Errored fallback={err => <p style="color:red">Connection failed</p>}> <Loading fallback={<p>Connecting…</p>}> <p>Latest: {data().message}</p> </Loading> </Errored>);Non-terminal errors (while the browser is reconnecting automatically) are surfaced via the onError callback only — they don't interrupt the reactive graph.
Stale-while-revalidating with isPending
After the first message has arrived, subsequent reconnects (URL change, reconnect() call) put the connection back into a pending state. Use isPending from Solid to show a subtle "refreshing" indicator without replacing the whole subtree:
import { isPending } from "solid-js";import { createSSE } from "@solid-primitives/sse";
const { data } = createSSE<{ msg: string }>(url, { transform: JSON.parse });
return ( <> <Show when={isPending(() => data())}> <p>Refreshing…</p> </Show> <Loading fallback={<p>Connecting…</p>}> <p>{data().msg}</p> </Loading> </>);Note:
isPendingisfalseduring the initial<Loading>fallback (no stale value yet). It becomestrueonly when a stale value exists and new data is pending — i.e., after a URL change or reconnect.
Reactive URL with <Loading on=…>
When the URL is a signal accessor, the connection is replaced whenever the URL changes. Use <Loading>'s on prop to re-show the fallback on each URL change:
const [userId, setUserId] = createSignal("user-1");
const { data } = createSSE<Notification>( () => `https://api.example.com/notifications/${userId()}`, { transform: JSON.parse },);
return ( // on={userId()} re-shows the fallback each time userId changes while pending <Loading on={userId()} fallback={<p>Connecting…</p>}> <p>{data().message}</p> </Loading>);Without on, <Loading> keeps showing stale content during revalidation. With on, it re-shows the fallback whenever the key changes and a new connection is establishing.
Options
| 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 (terminal and transient) |
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 |
Return value
| Property | Type | Description |
|---|---|---|
source | Accessor<SSESourceHandle | undefined> | Underlying source instance; undefined on SSR |
data | Accessor<T> | Latest message data; throws NotReadyError until first message, terminal errors thereafter |
readyState | Accessor<SSEReadyState> | SSEReadyState.CONNECTING / .OPEN / .CLOSED |
close | VoidFunction | Close the connection |
reconnect | VoidFunction | Force-close and reopen; resets data to pending |
Initial value
Provide initialValue to skip the pending state entirely — data() returns it immediately with no <Loading> fallback needed:
const { data } = createSSE(url, { initialValue: [] as string[] });// data() === [] immediately, no Loading neededSSEReadyState
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; // 0SSEReadyState.OPEN; // 1SSEReadyState.CLOSED; // 2A note on reconnection
EventSource 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.
makeSSEAsyncIterable
Wraps an SSE endpoint as a standard AsyncIterable<T>. Each SSE message becomes one yielded value; terminal errors (connection CLOSED) are thrown by the iterator. Cleanup runs automatically when the iterator is abandoned via return().
Use this as a non-reactive building block: integrate it with a for await…of loop, pass it to your own createMemo, or compose it with other async utilities.
import { makeSSEAsyncIterable } from "@solid-primitives/sse";
const iterable = makeSSEAsyncIterable<string>("https://api.example.com/events");
for await (const msg of iterable) { console.log(msg);}Definition
function makeSSEAsyncIterable<T = string>( url: string | URL, options?: CreateSSEStreamOptions<T>,): AsyncIterable<T>;
type CreateSSEStreamOptions<T> = { withCredentials?: boolean; onOpen?: (event: Event) => void; onError?: (event: Event) => void; transform?: (raw: string) => T; events?: Record<string, (event: MessageEvent) => void>; source?: SSESourceFn;};createSSEStream
A minimal reactive alternative to createSSE that returns only a data accessor. Internally it drives an AsyncIterable produced by makeSSEAsyncIterable, giving the same <Loading> / <Errored> integration with less API surface.
Use this when you only need the stream values and don't need access to source, readyState, close, or reconnect.
import { createSSEStream } from "@solid-primitives/sse";
const data = createSSEStream<{ msg: string }>(url, { transform: JSON.parse });
return ( <Errored fallback={err => <p>Connection failed</p>}> <Loading fallback={<p>Connecting…</p>}> <p>{data().msg}</p> </Loading> </Errored>);Reactive URL is supported — the stream reconnects automatically when the URL signal changes:
const [userId, setUserId] = createSignal("user-1");
const data = createSSEStream<Notification>( () => `https://api.example.com/notifications/${userId()}`, { transform: JSON.parse },);Definition
function createSSEStream<T = string>( url: MaybeAccessor<string>, options?: CreateSSEStreamOptions<T>,): Accessor<T>;Integration with @solid-primitives/event-bus
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));Multi-channel SSE with createEventHub
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));Building a reactive message list
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>;Built-in transformers
Ready-made transform functions for the most common SSE data formats. Pass one as the transform option to createSSE or createSSEStream:
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 |
json
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 }ndjson
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" }]lines
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"]number
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.
safe(transform, fallback?)
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 throwingconst { data } = createSSE<MyEvent>(url, { transform: safe(json) });
// With an explicit fallback valueconst { data } = createSSE<number>(url, { transform: safe(number, 0) });pipe(a, b)
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" rowstype 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 stepconst { data } = createSSE<string>(url, { transform: pipe(safe(json<{ label: string }>), ev => ev?.label ?? ""),});Running SSE in a Worker
@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.
When to use this
- 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.
Setup
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.
Dedicated Worker
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, 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.
SharedWorker
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.
How it works
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.
Type reference
// @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 };Changelog
See CHANGELOG.md.