Skip to main content
Solid Primitives 2

Primitives for Server-Sent Events (SSE) using the browser's EventSource API.

StageCategoryVersionLast UpdatedDemo
3Network1.0.0-next.2 (next)Aug 12, 2026Demo →
Terminal window
npm i @solid-primitives/sse@next

Primitives 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 an EventSource and returns a cleanup function. No Solid lifecycle.
  • createSSE — Reactive primitive. Accepts a reactive URL, integrates with Solid's owner lifecycle, and returns signals for data and readyState.
  • makeSSEAsyncIterable — Wraps an SSE endpoint as an AsyncIterable<T>. Non-reactive foundation.
  • createSSEStream — Minimal reactive stream: just a data accessor backed by an async iterable.
  • makeSSEWorker — Runs the SSE connection inside a Web Worker or SharedWorker.
  • Built-in transformersjson, 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 while data() is pending (before the first message arrives).
  • <Errored> — catches terminal errors (connection CLOSED with no retries left) thrown through data().
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: isPending is false during the initial <Loading> fallback (no stale value yet). It becomes true only 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

OptionTypeDefaultDescription
withCredentialsbooleanfalseSend credentials with the request
onOpen(e: Event) => voidCalled when the connection opens
onMessage(e: MessageEvent) => voidCalled on each unnamed message event
onError(e: Event) => voidCalled on error (terminal and transient)
eventsRecord<string, (e: MessageEvent) => void>Handlers for named SSE event types
initialValueTundefinedInitial value of the data signal
transform(raw: string) => TidentityParse raw string data, e.g. JSON.parse
reconnectboolean | SSEReconnectOptionsfalseApp-level reconnect on terminal errors

SSEReconnectOptions:

OptionTypeDefaultDescription
retriesnumberInfinityMax reconnect attempts
delaynumber3000Milliseconds between attempts

Return value

PropertyTypeDescription
sourceAccessor<SSESourceHandle | undefined>Underlying source instance; undefined on SSR
dataAccessor<T>Latest message data; throws NotReadyError until first message, terminal errors thereafter
readyStateAccessor<SSEReadyState>SSEReadyState.CONNECTING / .OPEN / .CLOSED
closeVoidFunctionClose the connection
reconnectVoidFunctionForce-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 needed

SSEReadyState

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; // 2

A 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 });
TransformerDescription
jsonParse data as a single JSON value
ndjsonParse newline-delimited JSON into an array
linesSplit data into a string[] by newline
numberParse data as a number via Number()
safeFault-tolerant wrapper — returns fallback instead of throwing
pipeCompose 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() === 42

Note: 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 throwing
const { data } = createSSE<MyEvent>(url, { transform: safe(json) });
// With an explicit fallback value
const { 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" 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 ?? ""),
});

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 EventSource connection 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-handler pointing 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 URLnew 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:

  1. Creates a plain EventTarget with a readyState property and a close() method, satisfying the SSESourceHandle interface without needing a real EventSource.
  2. Posts a connect message to the Worker. The Worker script (worker-handler) creates a real EventSource there and posts open / message / error events back via postMessage.
  3. The message listener on the main thread forwards those events to createSSE's callbacks and dispatches them on the EventTarget so any direct addEventListener calls also work.
  4. createSSE's reactive machinery — signals, reconnect timer, URL tracking, onCleanup — runs on the main thread as normal; it just receives events via postMessage instead of directly from a real EventSource.

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.

Solid Primitives 2High-quality reactive primitives for building applications in Solid2
Community
githubdiscord