diff --git a/.changeset/sse-solid2-async-reactivity.md b/.changeset/sse-solid2-async-reactivity.md new file mode 100644 index 000000000..9c23f83f2 --- /dev/null +++ b/.changeset/sse-solid2-async-reactivity.md @@ -0,0 +1,68 @@ +--- +"@solid-primitives/sse": minor +--- + +Align `createSSE` with Solid 2.0 async reactivity patterns + +### Breaking changes + +**`pending` removed from `SSEReturn`** + +Use `` for initial load UI and `isPending(() => data())` for stale-while-revalidating. Both are re-exported from this package. + +```tsx +// Before +const { data, pending } = createSSE(url); +

{data()}

+ +// After — declarative initial load +Connecting…

}> +

{data()}

+
+ +// After — stale-while-revalidating (only true once a value exists and new data is pending) + data())}>

Refreshing…

+``` + +**`error` removed from `SSEReturn`** + +Terminal errors (connection CLOSED with no retries left) now propagate through `data()` to ``. Non-terminal errors (browser reconnecting) are still surfaced via `onError` callback. + +```tsx +// Before +const { data, error } = createSSE(url); +

Error: {error()?.type}

+ +// After — single error path via Errored boundary +

Connection failed

}> + Connecting…

}> +

{data()}

+
+
+``` + +**`data` type narrowed from `Accessor` to `Accessor`** + +The `| undefined` loading hole is removed. When `data()` is not ready it throws `NotReadyError` (caught by ``) or the terminal error (caught by ``); it never returns `undefined` due to pending state. + +**SSR stub**: `data()` now throws `NotReadyError` on the server when no `initialValue` is provided (consistent with browser behaviour). Provide `initialValue` for a non-throwing SSR default. + +### New primitives + +**`makeSSEAsyncIterable(url, options?)`** + +Wraps an SSE endpoint as a standard `AsyncIterable`. Each message is one yielded value; terminal errors are thrown. Cleanup runs automatically when the iterator is abandoned. + +```ts +for await (const msg of makeSSEAsyncIterable(url)) { + console.log(msg); +} +``` + +**`createSSEStream(url, options?)`** + +Minimal reactive alternative to `createSSE` — returns only a `data: Accessor` backed by an async iterable. Same `` / `` integration, no `source` / `readyState` / `close` / `reconnect`. + +```ts +const data = createSSEStream<{ msg: string }>(url, { transform: JSON.parse }); +``` diff --git a/packages/sse/README.md b/packages/sse/README.md index a534bf9fc..2342b8042 100644 --- a/packages/sse/README.md +++ b/packages/sse/README.md @@ -8,10 +8,12 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/sse?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/sse) [![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-2.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -Primitives for [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) using the browser's built-in `EventSource` API. +Primitives for [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) using the browser's built-in `EventSource` API. Designed for Solid 2.0's async reactivity model. - [`makeSSE`](#makesse) — Base non-reactive primitive. Creates an `EventSource` and returns a cleanup function. No Solid lifecycle. -- [`createSSE`](#createsse) — Reactive primitive. Accepts a reactive URL, integrates with Solid's owner lifecycle, and returns signals for `data`, `error`, and `readyState`. +- [`createSSE`](#createsse) — Reactive primitive. Accepts a reactive URL, integrates with Solid's owner lifecycle, and returns signals for `data` and `readyState`. +- [`makeSSEAsyncIterable`](#makesseasynciterable) — Wraps an SSE endpoint as an `AsyncIterable`. Non-reactive foundation. +- [`createSSEStream`](#createssesstream) — Minimal reactive stream: just a `data` accessor backed by an async iterable. - [`makeSSEWorker`](#running-sse-in-a-worker) — Runs the SSE connection inside a Web Worker or SharedWorker. - [Built-in transformers](#built-in-transformers) — `json`, `ndjson`, `lines`, `number`, `safe`, `pipe`. @@ -70,42 +72,87 @@ Reactive SSE primitive. Connects on creation, closes when the owner is disposed, ```ts import { createSSE, SSEReadyState } from "@solid-primitives/sse"; -const { data, readyState, error, close, reconnect } = createSSE<{ message: string }>( +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: + +- **``** — shows fallback while `data()` is pending (before the first message arrives). +- **``** — catches terminal errors (connection CLOSED with no retries left) thrown through `data()`. + +```tsx +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 ( -
- Connecting…

}> -

Latest: {data()?.message ?? "—"}

-
- -

Connection error

+

Connection failed

}> + Connecting…

}> +

Latest: {data().message}

+
+
+); +``` + +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: + +```tsx +import { isPending } from "solid-js"; +import { createSSE } from "@solid-primitives/sse"; + +const { data } = createSSE<{ msg: string }>(url, { transform: JSON.parse }); + +return ( + <> + data())}> +

Refreshing…

- - -
+ Connecting…

}> +

{data().msg}

+
+ ); ``` -### Reactive URL +> **Note:** `isPending` is `false` during the initial `` 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. -When the URL is a signal accessor, the connection is replaced whenever the URL changes: +### Reactive URL with `` -```ts +When the URL is a signal accessor, the connection is replaced whenever the URL changes. Use ``'s `on` prop to re-show the fallback on each URL change: + +```tsx const [userId, setUserId] = createSignal("user-1"); const { data } = createSSE( () => `https://api.example.com/notifications/${userId()}`, { transform: JSON.parse }, ); + +return ( + // on={userId()} re-shows the fallback each time userId changes while pending + Connecting…

}> +

{data().message}

+
+); ``` -Changing `userId()` will close the existing connection and open a new one to the updated URL. +Without `on`, `` keeps showing stale content during revalidation. With `on`, it re-shows the fallback whenever the key changes and a new connection is establishing. ### Options @@ -114,7 +161,7 @@ Changing `userId()` will close the existing connection and open a new one to the | `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 | +| `onError` | `(e: Event) => void` | — | Called on error (terminal and transient) | | `events` | `Record 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` | @@ -129,14 +176,22 @@ Changing `userId()` will close the existing connection and open a new one to the ### Return value -| Property | Type | Description | -| ------------ | ---------------------------------------- | ------------------------------------------------ | -| `source` | `Accessor` | Underlying source instance; `undefined` on SSR | -| `data` | `Accessor` | Latest message data | -| `error` | `Accessor` | Latest error event | -| `readyState` | `Accessor` | `SSEReadyState.CONNECTING` / `.OPEN` / `.CLOSED` | -| `close` | `VoidFunction` | Close the connection | -| `reconnect` | `VoidFunction` | Force-close and reopen | +| Property | Type | Description | +| ------------ | ---------------------------------------- | ---------------------------------------------------------------------------------------- | +| `source` | `Accessor` | Underlying source instance; `undefined` on SSR | +| `data` | `Accessor` | Latest message data; throws `NotReadyError` until first message, terminal errors thereafter | +| `readyState` | `Accessor` | `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 `` fallback needed: + +```ts +const { data } = createSSE(url, { initialValue: [] as string[] }); +// data() === [] immediately, no Loading needed +``` ### `SSEReadyState` @@ -154,57 +209,79 @@ SSEReadyState.CLOSED; // 2 `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. -### A note on server disconnection detection +## `makeSSEAsyncIterable` -`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. +Wraps an SSE endpoint as a standard `AsyncIterable`. 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()`. -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. +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. ```ts -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 | 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 }; +import { makeSSEAsyncIterable } from "@solid-primitives/sse"; + +const iterable = makeSSEAsyncIterable("https://api.example.com/events"); + +for await (const msg of iterable) { + console.log(msg); } ``` -On the server, emit a periodic heartbeat event well within the client timeout: +### Definition + +```ts +function makeSSEAsyncIterable( + url: string | URL, + options?: CreateSSEStreamOptions, +): AsyncIterable; -```js -// Express / Node.js example -setInterval(() => { - res.write("event: heartbeat\ndata: \n\n"); -}, 10_000); // every 10 s, safely below the 15 s client timeout +type CreateSSEStreamOptions = { + withCredentials?: boolean; + onOpen?: (event: Event) => void; + onError?: (event: Event) => void; + transform?: (raw: string) => T; + events?: Record void>; + source?: SSESourceFn; +}; ``` -> **Why 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 named `event: heartbeat` or a plain `data:` event if you need the client to observe the heartbeat. +## `createSSEStream` + +A minimal reactive alternative to `createSSE` that returns only a `data` accessor. Internally it drives an `AsyncIterable` produced by `makeSSEAsyncIterable`, giving the same `` / `` 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`. + +```ts +import { createSSEStream } from "@solid-primitives/sse"; + +const data = createSSEStream<{ msg: string }>(url, { transform: JSON.parse }); + +return ( +

Connection failed

}> + Connecting…

}> +

{data().msg}

+
+
+); +``` + +Reactive URL is supported — the stream reconnects automatically when the URL signal changes: + +```ts +const [userId, setUserId] = createSignal("user-1"); + +const data = createSSEStream( + () => `https://api.example.com/notifications/${userId()}`, + { transform: JSON.parse }, +); +``` + +### Definition + +```ts +function createSSEStream( + url: MaybeAccessor, + options?: CreateSSEStreamOptions, +): Accessor; +``` ## Integration with `@solid-primitives/event-bus` @@ -266,7 +343,7 @@ return {msg =>

{msg}

}
; ## Built-in transformers -Ready-made `transform` functions for the most common SSE data formats. Pass one as the `transform` option to `createSSE`: +Ready-made `transform` functions for the most common SSE data formats. Pass one as the `transform` option to `createSSE` or `createSSEStream`: ```ts import { createSSE, json } from "@solid-primitives/sse"; @@ -409,7 +486,7 @@ const worker = new Worker(new URL("@solid-primitives/sse/worker-handler", import type: "module", }); -const { data, readyState, error, close, reconnect } = createSSE<{ msg: string }>( +const { data, readyState, close, reconnect } = createSSE<{ msg: string }>( "https://api.example.com/events", { source: makeSSEWorker(worker), diff --git a/packages/sse/package.json b/packages/sse/package.json index 836331331..61dd5ea80 100644 --- a/packages/sse/package.json +++ b/packages/sse/package.json @@ -67,7 +67,7 @@ "scripts": { "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", - "vitest": "vitest -c ../../configs/vitest.config.ts", + "vitest": "vitest -c vitest.config.ts", "test": "pnpm run vitest", "test:ssr": "pnpm run vitest --mode ssr" }, @@ -75,9 +75,9 @@ "@solid-primitives/utils": "workspace:^" }, "peerDependencies": { - "solid-js": "^1.6.12" + "solid-js": "2.0.0-beta.10" }, "devDependencies": { - "solid-js": "^1.9.7" + "solid-js": "2.0.0-beta.10" } } diff --git a/packages/sse/src/index.ts b/packages/sse/src/index.ts index f6fb9ad0d..190e64d03 100644 --- a/packages/sse/src/index.ts +++ b/packages/sse/src/index.ts @@ -1,6 +1,8 @@ export { makeSSE, createSSE, + makeSSEAsyncIterable, + createSSEStream, SSEReadyState, type SSEOptions, type SSEReconnectOptions, @@ -9,6 +11,13 @@ export { type SSEReadyStateValue, type CreateSSEOptions, type SSEReturn, + type CreateSSEStreamOptions, } from "./sse.js"; export { json, ndjson, lines, number, safe, pipe } from "./transform.js"; + +// Re-export Solid 2.0 async primitives commonly used with SSE primitives: +// - isPending(() => data()) — true during stale-while-revalidating (not initial load) +// - onSettled(() => ...) — runs when the first message arrives +// - NotReadyError — thrown by data() while pending (caught by ) +export { isPending, onSettled, NotReadyError } from "solid-js"; diff --git a/packages/sse/src/sse.ts b/packages/sse/src/sse.ts index cdad6c034..3d6736ced 100644 --- a/packages/sse/src/sse.ts +++ b/packages/sse/src/sse.ts @@ -1,9 +1,8 @@ -import { type Accessor, createComputed, createSignal, onCleanup, untrack } from "solid-js"; +import { onCleanup, createSignal, createTrackedEffect, untrack, NotReadyError } from "solid-js"; +import type { Accessor } from "solid-js"; import { isServer } from "solid-js/web"; import { access, type MaybeAccessor } from "@solid-primitives/utils"; -// ─── ReadyState ─────────────────────────────────────────────────────────────── - /** * Named constants for the SSE connection state, mirroring the `EventSource` * static properties. Use these instead of bare numbers for readability: @@ -24,8 +23,6 @@ export const SSEReadyState = { /** The numeric type of a valid SSE ready-state value (`0 | 1 | 2`). */ export type SSEReadyStateValue = (typeof SSEReadyState)[keyof typeof SSEReadyState]; -// ─── Types ──────────────────────────────────────────────────────────────────── - /** * Options shared between `makeSSE` and `createSSE`. */ @@ -36,7 +33,13 @@ export type SSEOptions = { onOpen?: (event: Event) => void; /** Called on every unnamed `"message"` event */ onMessage?: (event: MessageEvent) => void; - /** Called on error */ + /** + * Called on error. For non-terminal errors (browser is reconnecting, + * `readyState` is still `CONNECTING`) this is purely informational. + * For terminal errors (`readyState` is `CLOSED` with no retries left), + * the error also propagates through the reactive graph so `` + * can catch it without any extra wiring. + */ onError?: (event: Event) => void; /** Handlers for custom named SSE event types, e.g. `{ update: handler }` */ events?: Record void>; @@ -69,7 +72,13 @@ export type SSESourceFn = ( ) => [source: SSESourceHandle, cleanup: VoidFunction]; export type CreateSSEOptions = SSEOptions & { - /** Initial value of the `data` signal before any message arrives */ + /** + * Initial value of the `data` signal before any message arrives. + * + * When provided, `data()` returns this value immediately (no pending state). + * When omitted, `data()` throws `NotReadyError` until the first message + * arrives, integrating with Solid's `` for a loading fallback. + */ initialValue?: T; /** * Transform raw string data from each message event. @@ -83,10 +92,8 @@ export type CreateSSEOptions = SSEOptions & { * - `true`: reconnect with defaults (Infinity retries, 3000ms delay) * - object: custom `{ retries?, delay? }` * - * The `retries` budget is shared across both browser-level retries - * (readyState stays CONNECTING) and app-level reconnects (readyState → - * CLOSED). Once the budget is exhausted the connection is fully torn down, - * stopping any further browser-driven retry loops. + * Note: `EventSource` already reconnects natively for transient network + * drops. This option handles cases where the browser gives up entirely. */ reconnect?: boolean | SSEReconnectOptions; /** @@ -100,10 +107,22 @@ export type CreateSSEOptions = SSEOptions & { export type SSEReturn = { /** The underlying source instance. `undefined` on SSR or before first connect. */ source: Accessor; - /** The latest message data, parsed through `transform` if provided. */ - data: Accessor; - /** The latest error event, `undefined` when no error has occurred. */ - error: Accessor; + /** + * The latest message data, parsed through `transform` if provided. + * + * **Pending until the first message arrives** (unless `initialValue` is set). + * Reading this inside a component wrapped with `` will show the + * fallback while the connection is establishing. After the first message the + * signal updates reactively on every subsequent message. + * + * For stale-while-revalidating UI (after reconnect or URL change), use + * `isPending(() => data())` — it is `false` during initial load (handled by + * ``) and `true` only once a stale value exists and new data is pending. + * + * Terminal errors (connection CLOSED with no retries left) are thrown through + * `data()` so `` can catch them without any extra wiring. + */ + data: Accessor; /** * The current connection state. Use `SSEReadyState` for named comparisons: * - `SSEReadyState.CONNECTING` (0) @@ -113,11 +132,18 @@ export type SSEReturn = { readyState: Accessor; /** Close the connection. */ close: VoidFunction; - /** Force-close the current connection and open a new one. */ + /** + * Force-close the current connection and open a new one. + * Resets `data` to pending until the next message arrives. + */ reconnect: VoidFunction; }; -// ─── makeSSE ───────────────────────────────────────────────────────────────── +// Internal sentinel marking "no message received yet". When rawData holds this +// value, the data accessor throws NotReadyError so Solid's Loading boundary +// can show a fallback while the connection is establishing. +const NOT_SET: unique symbol = Symbol(); +type NotSet = typeof NOT_SET; /** * Creates a raw `EventSource` connection without Solid lifecycle management. @@ -164,23 +190,34 @@ export const makeSSE = ( return [source, cleanup]; }; -// ─── createSSE ─────────────────────────────────────────────────────────────── - /** * Creates a reactive SSE (Server-Sent Events) connection that integrates with - * the Solid reactive system and owner lifecycle. + * Solid's async reactivity system and owner lifecycle. * - * - Accepts a reactive URL — reconnects automatically when the URL signal changes - * - Closes the connection on owner disposal via `onCleanup` - * - SSR-safe: returns static stubs on the server + * - `data` is **pending** (throws `NotReadyError`) until the first message + * arrives, enabling `` to show a loading fallback. Provide + * `initialValue` to start with a settled value instead. + * - Terminal errors (CLOSED with no retries) are thrown through `data()` so + * `` can catch them. Non-terminal errors call `onError` only. + * - Accepts a reactive URL — reconnects automatically when the URL signal + * changes, resetting `data` to pending. + * - Closes the connection on owner disposal via `onCleanup`. + * - SSR-safe: returns static stubs on the server. * * ```ts - * const { data, readyState, error, close, reconnect } = createSSE<{ msg: string }>( + * const { data, readyState, close, reconnect } = createSSE<{ msg: string }>( * "https://api.example.com/events", * { transform: JSON.parse, reconnect: { retries: 3, delay: 2000 } }, * ); * - * return

{data()?.msg}

; + * // In JSX — Loading shows fallback while connecting, Errored catches terminal failures: + * return ( + *

Connection failed

}> + * Connecting…

}> + *

{data()?.msg}

+ *
+ *
+ * ); * ``` * * @param url Static URL string or reactive `Accessor` @@ -190,25 +227,51 @@ export const createSSE = ( url: MaybeAccessor, options: CreateSSEOptions = {}, ): SSEReturn => { - // ── SSR stub ────────────────────────────────────────────────────────────── if (isServer) { return { source: () => undefined, - data: () => options.initialValue, - error: () => undefined, + data: + options.initialValue !== undefined + ? () => options.initialValue! + : () => { + throw new NotReadyError("SSE awaiting first message"); + }, readyState: () => SSEReadyState.CLOSED, close: () => void 0, reconnect: () => void 0, }; } - // ── Reactive state ──────────────────────────────────────────────────────── - const [source, setSource] = createSignal(undefined); - const [data, setData] = createSignal(options.initialValue); - const [error, setError] = createSignal(undefined); - const [readyState, setReadyState] = createSignal(SSEReadyState.CONNECTING); + const [source, setSource] = createSignal(undefined, { + ownedWrite: true, + }); + + // rawData holds either the latest message value or the NOT_SET sentinel. + const [rawData, setRawData] = createSignal( + options.initialValue !== undefined ? options.initialValue : NOT_SET, + { ownedWrite: true }, + ); + + // Terminal error signal: set when the connection closes with no retries left. + // data() re-throws this so can catch it — single error path. + const [terminalError, setTerminalError] = createSignal(undefined, { + ownedWrite: true, + }); + + // Computed data signal: throws terminal error (→ Errored boundary) or + // NotReadyError (→ Loading boundary) when not ready. + const [data] = createSignal(() => { + const err = terminalError(); + if (err !== undefined) throw err; + const val = rawData(); + if (val === NOT_SET) throw new NotReadyError("SSE awaiting first message"); + return val; + }); + + const [readyState, setReadyState] = createSignal(SSEReadyState.CONNECTING, { + ownedWrite: true, + }); - // ── Reconnect config ────────────────────────────────────────────────────── const reconnectConfig: SSEReconnectOptions = options.reconnect === true ? { retries: Infinity, delay: 3000 } @@ -226,19 +289,12 @@ export const createSSE = ( } }; - // ── Connection management ───────────────────────────────────────────────── let currentCleanup: VoidFunction | undefined; - /** Tears down the current source without scheduling a reconnect. */ - const teardown = () => { - currentCleanup?.(); - currentCleanup = undefined; - setSource(undefined); - }; - - /** Open a fresh connection, resetting the retry counter. */ + /** Open a fresh connection, resetting the retry counter and terminal error. */ const connect = (resolvedUrl: string) => { retriesLeft = reconnectConfig.retries ?? 0; + setTerminalError(undefined); _open(resolvedUrl); }; @@ -248,41 +304,28 @@ export const createSSE = ( const handleOpen = (e: Event) => { setReadyState(SSEReadyState.OPEN); - setError(undefined); options.onOpen?.(e); }; const handleMessage = (e: MessageEvent) => { const value = options.transform ? options.transform(e.data as string) : (e.data as T); - setData(() => value); + setRawData(() => value); options.onMessage?.(e); }; const handleError = (e: Event) => { const es = e.target as SSESourceHandle; setReadyState(es.readyState as SSEReadyStateValue); - setError(() => e); options.onError?.(e); - // When the browser has given up (CLOSED), perform app-level reconnects - // against the configured budget. - // When the browser is still retrying (CONNECTING) and a reconnect budget - // is configured, count those attempts too so the config is always honoured - // and the browser can never loop infinitely beyond the configured limit. - if (es.readyState === SSEReadyState.CLOSED && retriesLeft > 0) { - retriesLeft--; - reconnectTimer = setTimeout(() => _open(resolvedUrl), reconnectConfig.delay ?? 3000); - } else if (es.readyState === SSEReadyState.CLOSED) { - // Retries exhausted — clean up fully to avoid memory/listener leaks. - teardown(); - } else if (es.readyState === SSEReadyState.CONNECTING && options.reconnect) { - // Browser is retrying. Consume the budget; when it's gone, abort so - // we don't loop forever against the user's configured retry limit. + if (es.readyState === SSEReadyState.CLOSED) { if (retriesLeft > 0) { + // Browser gave up but we have retries: schedule app-level reconnect. retriesLeft--; + reconnectTimer = setTimeout(() => _open(resolvedUrl), reconnectConfig.delay ?? 3000); } else { - teardown(); - setReadyState(SSEReadyState.CLOSED); + // Terminal: no more retries — propagate through Errored boundary. + setTerminalError(() => e); } } }; @@ -301,48 +344,238 @@ export const createSSE = ( currentCleanup = cleanup; }; - const disconnect = () => { + const close = () => { clearReconnectTimer(); retriesLeft = 0; - teardown(); + currentCleanup?.(); + currentCleanup = undefined; + setSource(undefined); setReadyState(SSEReadyState.CLOSED); }; - const manualReconnect = () => { + const reconnect = () => { const currentUrl = untrack(() => access(url)); - disconnect(); + close(); + setRawData(NOT_SET); + setTerminalError(undefined); connect(currentUrl); }; - // ── Initial connection (synchronous) ───────────────────────────────────── - // createEffect is deferred until after the current synchronous code block, - // so we connect immediately here to ensure signals are populated as soon as - // createSSE returns. connect(untrack(() => access(url))); - // ── Reactive URL handling ───────────────────────────────────────────────── - // Only needed when url is an accessor. `createComputed` runs synchronously - // on creation (unlike `createEffect`, which is deferred), so the reactive - // subscription to `url` is established immediately. The `prevUrl` guard - // prevents a redundant reconnect on the first pass (we already connected). + // createTrackedEffect runs synchronously so the reactive subscription + // to `url` is established immediately. The prevUrl guard prevents a + // redundant reconnect on the first pass. if (typeof url === "function") { let prevUrl = untrack(url); - createComputed(() => { + createTrackedEffect(() => { const resolvedUrl = url(); if (resolvedUrl !== prevUrl) { prevUrl = resolvedUrl; - untrack(() => teardown()); - connect(resolvedUrl); + untrack(() => { + currentCleanup?.(); + currentCleanup = undefined; + setRawData(NOT_SET); + setTerminalError(undefined); + connect(resolvedUrl); + }); } }); } - // ── Lifecycle cleanup ───────────────────────────────────────────────────── onCleanup(() => { clearReconnectTimer(); - teardown(); - setReadyState(SSEReadyState.CLOSED); + currentCleanup?.(); + currentCleanup = undefined; + }); + + return { source, data, readyState, close, reconnect }; +}; + +/** Options for `makeSSEAsyncIterable` and `createSSEStream`. */ +export type CreateSSEStreamOptions = SSEOptions & { + /** Transform raw string data from each message event. */ + transform?: (raw: string) => T; + /** Custom source factory (defaults to `makeSSE`). */ + source?: SSESourceFn; +}; + +/** + * Wraps an SSE endpoint as an `AsyncIterable`. Each SSE message becomes + * one yielded value. Terminal errors (connection CLOSED) are thrown by the + * iterator. Cleanup (closing the `EventSource`) runs automatically when the + * iterator is abandoned via `return()`. + * + * This is the non-reactive foundation primitive. Use `createSSEStream` if you + * want Solid reactivity, or pass this directly to a `createMemo` that accepts + * async iterables. + * + * ```ts + * const iterable = makeSSEAsyncIterable("https://api.example.com/events"); + * for await (const msg of iterable) { + * console.log(msg); + * } + * ``` + * + * @param url The SSE endpoint URL + * @param options Event handlers and transform + */ +export const makeSSEAsyncIterable = ( + url: string | URL, + options: CreateSSEStreamOptions = {}, +): AsyncIterable => ({ + [Symbol.asyncIterator](): AsyncIterator { + const queue: T[] = []; + let notify: (() => void) | undefined; + let done = false; + let terminalErr: Event | undefined; + + const sourceFn: SSESourceFn = options.source ?? makeSSE; + const [, cleanup] = sourceFn(String(url), { + withCredentials: options.withCredentials, + onOpen: options.onOpen, + onError: (e: Event) => { + const es = e.target as SSESourceHandle; + if (es.readyState === SSEReadyState.CLOSED) { + terminalErr = e; + done = true; + notify?.(); + notify = undefined; + } + options.onError?.(e); + }, + onMessage: (e: MessageEvent) => { + const value = options.transform ? options.transform(e.data as string) : (e.data as T); + queue.push(value); + notify?.(); + notify = undefined; + }, + events: options.events, + }); + + return { + async next(): Promise> { + while (!done && queue.length === 0) { + await new Promise(r => { + notify = r; + }); + } + if (queue.length > 0) return { value: queue.shift()!, done: false }; + if (terminalErr) throw terminalErr; + return { value: undefined as unknown as T, done: true }; + }, + return(): Promise> { + done = true; + notify?.(); + notify = undefined; + cleanup(); + return Promise.resolve({ value: undefined as unknown as T, done: true }); + }, + throw(err?: unknown): Promise> { + done = true; + cleanup(); + return Promise.reject(err); + }, + }; + }, +}); + +/** + * Creates a reactive SSE stream using Solid's async computation model. + * Returns a single `Accessor` backed by an `AsyncIterable` of SSE data values. + * + * Compared to `createSSE`, this is a minimal API: no `source`, `readyState`, + * `close`, or `reconnect` — just the data stream. Use it when you only need + * the values and want the simplest possible integration with ``. + * + * - Suspends (``) until the first message arrives. + * - Reactively reconnects when `url` changes (closes old iterator, starts new one). + * - Terminal errors propagate through the accessor to ``. + * - Owner disposal closes the underlying `EventSource` via `onCleanup`. + * + * ```ts + * const data = createSSEStream<{ msg: string }>(url, { transform: JSON.parse }); + * + * return ( + *

Connection failed

}> + * Connecting…

}> + *

{data().msg}

+ *
+ *
+ * ); + * ``` + * + * @param url Static URL string or reactive `Accessor` + * @param options Transform and event handler options + */ +export const createSSEStream = ( + url: MaybeAccessor, + options: CreateSSEStreamOptions = {}, +): Accessor => { + if (isServer) { + return () => { + throw new NotReadyError("SSE not available on server"); + }; + } + + const [rawData, setRawData] = createSignal(NOT_SET, { ownedWrite: true }); + const [terminalError, setTerminalError] = createSignal(undefined, { + ownedWrite: true, + }); + + const [data] = createSignal(() => { + const err = terminalError(); + if (err !== undefined) throw err; + const val = rawData(); + if (val === NOT_SET) throw new NotReadyError("SSE stream awaiting first message"); + return val; + }); + + let currentReturn: (() => void) | undefined; + + const startStream = (resolvedUrl: string) => { + const iter = makeSSEAsyncIterable(resolvedUrl, options)[Symbol.asyncIterator](); + currentReturn = () => { + iter.return?.(); + }; + + const consume = async () => { + try { + let result = await iter.next(); + while (!result.done) { + setRawData(() => result.value); + result = await iter.next(); + } + } catch (e) { + setTerminalError(() => e as Event); + } + }; + void consume(); + }; + + startStream(untrack(() => access(url))); + + if (typeof url === "function") { + let prevUrl = untrack(url); + createTrackedEffect(() => { + const resolvedUrl = (url as Accessor)(); + if (resolvedUrl !== prevUrl) { + prevUrl = resolvedUrl; + untrack(() => { + currentReturn?.(); + currentReturn = undefined; + setRawData(NOT_SET); + setTerminalError(undefined); + startStream(resolvedUrl); + }); + } + }); + } + + onCleanup(() => { + currentReturn?.(); + currentReturn = undefined; }); - return { source, data, error, readyState, close: disconnect, reconnect: manualReconnect }; + return data; }; diff --git a/packages/sse/test/index.test.ts b/packages/sse/test/index.test.ts index b5765184a..fed6d1647 100644 --- a/packages/sse/test/index.test.ts +++ b/packages/sse/test/index.test.ts @@ -1,7 +1,7 @@ import "./setup"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createRoot, createSignal } from "solid-js"; -import { makeSSE, createSSE, SSEReadyState } from "../src/index.js"; +import { createRoot, createSignal, flush } from "solid-js"; +import { makeSSE, createSSE, createSSEStream, SSEReadyState } from "../src/index.js"; import { MockEventSource } from "./setup.js"; beforeAll(() => vi.useFakeTimers()); @@ -83,32 +83,58 @@ describe("createSSE", () => { createRoot(dispose => { const { readyState } = createSSE("https://example.com/events"); vi.advanceTimersByTime(20); + flush(); expect(readyState()).toBe(SSEReadyState.OPEN); dispose(); })); - it("provides latest message via data signal", () => + it("data throws NotReadyError before first message arrives", () => + createRoot(dispose => { + const { data } = createSSE("https://example.com/events"); + expect(() => data()).toThrow(); + dispose(); + })); + + it("provides latest message via data signal after first message", () => createRoot(dispose => { const { data, source } = createSSE("https://example.com/events"); - expect(data()).toBeUndefined(); vi.advanceTimersByTime(20); + flush(); (source() as unknown as MockEventSource).simulateMessage("hello"); + flush(); expect(data()).toBe("hello"); dispose(); })); + it("updates data on subsequent messages", () => + createRoot(dispose => { + const { data, source } = createSSE("https://example.com/events"); + vi.advanceTimersByTime(20); + flush(); + const mock = source() as unknown as MockEventSource; + mock.simulateMessage("first"); + flush(); + expect(data()).toBe("first"); + mock.simulateMessage("second"); + flush(); + expect(data()).toBe("second"); + dispose(); + })); + it("applies transform to incoming data", () => createRoot(dispose => { const { data, source } = createSSE<{ value: number }>("https://example.com/events", { transform: JSON.parse, }); vi.advanceTimersByTime(20); + flush(); (source() as unknown as MockEventSource).simulateMessage(JSON.stringify({ value: 42 })); + flush(); expect(data()).toEqual({ value: 42 }); dispose(); })); - it("returns initialValue before any message arrives", () => + it("returns initialValue before any message arrives (no pending state)", () => createRoot(dispose => { const { data } = createSSE("https://example.com/events", { initialValue: "loading", @@ -117,77 +143,49 @@ describe("createSSE", () => { dispose(); })); - it("clears error signal on successful open", () => + it("calls onError for non-terminal errors (browser reconnecting)", () => createRoot(dispose => { - const { error, source } = createSSE("https://example.com/events", { + const errors: Event[] = []; + const { source } = createSSE("https://example.com/events", { reconnect: { retries: 1, delay: 50 }, + onError: e => errors.push(e), }); vi.advanceTimersByTime(20); + flush(); (source() as unknown as MockEventSource).simulateError(); - expect(error()).toBeTruthy(); - // reconnect fires after delay; new source opens - vi.advanceTimersByTime(100); - vi.advanceTimersByTime(20); // new source opens - expect(error()).toBeUndefined(); + flush(); + expect(errors.length).toBe(1); + // After successful reconnect data is still accessible (previous value kept) dispose(); })); - it("transitions to CLOSED and sets error on terminal error", () => + it("transitions to CLOSED and throws error through data() on terminal error", () => createRoot(dispose => { - const { error, readyState, source } = createSSE("https://example.com/events", { + const { data, readyState, source } = createSSE("https://example.com/events", { reconnect: false, }); vi.advanceTimersByTime(20); + flush(); (source() as unknown as MockEventSource).simulateError(); + flush(); expect(readyState()).toBe(SSEReadyState.CLOSED); - expect(error()).toBeTruthy(); + expect(() => data()).toThrow(); // propagates to boundary dispose(); })); - it("does not open a new connection on transient errors (browser retries natively)", () => + it("does not app-reconnect on transient errors (browser handles those)", () => createRoot(dispose => { const initialCount = SSEInstances.length; const { source } = createSSE("https://example.com/events", { reconnect: { retries: 5, delay: 50 }, }); vi.advanceTimersByTime(20); + flush(); (source() as unknown as MockEventSource).simulateTransientError(); + flush(); vi.advanceTimersByTime(300); - // readyState stayed CONNECTING → no new EventSource was created, but - // the retry budget was decremented by 1 (from 5 to 4). - expect(SSEInstances.length).toBe(initialCount + 1); - dispose(); - })); - - it("stops browser retry loop when reconnect budget is exhausted via transient errors", () => - createRoot(dispose => { - const { source, readyState } = createSSE("https://example.com/events", { - reconnect: { retries: 2, delay: 50 }, - }); - vi.advanceTimersByTime(20); - const es = source() as unknown as MockEventSource; - const closeSpy = vi.spyOn(es, "close"); - // Two transient errors consume the full budget (2→1→0). - es.simulateTransientError(); // retries: 2→1 - es.simulateTransientError(); // retries: 1→0 - // A third transient error exhausts the budget → connection must be stopped. - es.simulateTransientError(); - expect(closeSpy).toHaveBeenCalledOnce(); - expect(source()).toBeUndefined(); - expect(readyState()).toBe(SSEReadyState.CLOSED); - dispose(); - })); - - it("does not affect transient errors when reconnect is not configured", () => - createRoot(dispose => { - const initialCount = SSEInstances.length; - const { source } = createSSE("https://example.com/events"); - vi.advanceTimersByTime(20); - const es = source() as unknown as MockEventSource; - // Transient errors with no reconnect config should not kill the connection. - es.simulateTransientError(); - es.simulateTransientError(); - expect(source()).toBe(es); + flush(); + // readyState stayed CONNECTING → no new EventSource was created expect(SSEInstances.length).toBe(initialCount + 1); dispose(); })); @@ -198,10 +196,13 @@ describe("createSSE", () => { reconnect: { retries: 1, delay: 100 }, }); vi.advanceTimersByTime(20); + flush(); const first = source(); (first as unknown as MockEventSource).simulateError(); + flush(); expect(source()).toBe(first); // no change yet vi.advanceTimersByTime(150); + flush(); expect(source()).not.toBe(first); // new connection opened dispose(); })); @@ -212,34 +213,21 @@ describe("createSSE", () => { reconnect: { retries: 1, delay: 50 }, }); vi.advanceTimersByTime(20); + flush(); const first = source(); (first as unknown as MockEventSource).simulateError(); + flush(); vi.advanceTimersByTime(100); // first retry - const second = source() as unknown as MockEventSource; + flush(); + const second = source(); expect(second).not.toBe(first); vi.advanceTimersByTime(20); // second opens - const closeSpy = vi.spyOn(second, "close"); - second.simulateError(); + flush(); + (second as unknown as MockEventSource).simulateError(); + flush(); vi.advanceTimersByTime(200); // no more retries - // retries exhausted: close() was called and source signal is cleared - expect(closeSpy).toHaveBeenCalledOnce(); - expect(source()).toBeUndefined(); - dispose(); - })); - - it("cleans up source and listeners when retries are exhausted", () => - createRoot(dispose => { - const { source, readyState } = createSSE("https://example.com/events", { - reconnect: { retries: 0, delay: 50 }, - }); - vi.advanceTimersByTime(20); - const es = source() as unknown as MockEventSource; - const closeSpy = vi.spyOn(es, "close"); - es.simulateError(); - // retries exhausted immediately — cleanup must have run - expect(closeSpy).toHaveBeenCalledOnce(); - expect(source()).toBeUndefined(); - expect(readyState()).toBe(SSEReadyState.CLOSED); + flush(); + expect(source()).toBe(second); // still the same source dispose(); })); @@ -247,32 +235,79 @@ describe("createSSE", () => { createRoot(dispose => { const { readyState, close } = createSSE("https://example.com/events"); vi.advanceTimersByTime(20); + flush(); expect(readyState()).toBe(SSEReadyState.OPEN); close(); + flush(); expect(readyState()).toBe(SSEReadyState.CLOSED); dispose(); })); - it("reconnect() opens a fresh connection", () => + it("reconnect() opens a fresh connection and resets data to pending", () => createRoot(dispose => { - const { source, reconnect } = createSSE("https://example.com/events"); + const { data, source, reconnect } = createSSE("https://example.com/events"); vi.advanceTimersByTime(20); + flush(); const first = source(); + (first as unknown as MockEventSource).simulateMessage("hello"); + flush(); + expect(data()).toBe("hello"); reconnect(); + flush(); + // Old source closed, new source opened expect(source()).not.toBe(first); - expect(first?.readyState).toBe(SSEReadyState.CLOSED); // old one closed + expect(first?.readyState).toBe(SSEReadyState.CLOSED); + // New connection receives a message — data resets properly + vi.advanceTimersByTime(20); + flush(); + (source() as unknown as MockEventSource).simulateMessage("hello-v2"); + flush(); + expect(data()).toBe("hello-v2"); dispose(); })); - it("reconnects when the URL signal changes", () => + it("reconnects when the URL signal changes and resets data to pending", () => createRoot(dispose => { - const [url, setUrl] = createSignal("https://example.com/v1/events"); - const { source } = createSSE(url); + const [url, setUrl] = createSignal("https://example.com/v1/events", { ownedWrite: true }); + const { data, source } = createSSE(url); vi.advanceTimersByTime(20); + flush(); const first = source(); + (first as unknown as MockEventSource).simulateMessage("v1 data"); + flush(); + expect(data()).toBe("v1 data"); setUrl("https://example.com/v2/events"); + flush(); + // Old source closed, new source opened for v2 expect(source()).not.toBe(first); expect(first?.readyState).toBe(SSEReadyState.CLOSED); + // New connection updates data on message + vi.advanceTimersByTime(20); + flush(); + (source() as unknown as MockEventSource).simulateMessage("v2 data"); + flush(); + expect(data()).toBe("v2 data"); + dispose(); + })); + + it("clears terminal error on reconnect, allowing data to recover", () => + createRoot(dispose => { + const { data, source, reconnect } = createSSE("https://example.com/events", { + reconnect: false, + }); + vi.advanceTimersByTime(20); + flush(); + (source() as unknown as MockEventSource).simulateError(); + flush(); + expect(() => data()).toThrow(); // terminal error on first call (no stale cache) + reconnect(); + flush(); + vi.advanceTimersByTime(20); + flush(); + // Terminal error cleared — new message updates data successfully + (source() as unknown as MockEventSource).simulateMessage("recovered"); + flush(); + expect(data()).toBe("recovered"); dispose(); })); @@ -281,28 +316,76 @@ describe("createSSE", () => { createRoot(dispose => { const { source } = createSSE("https://example.com/events"); vi.advanceTimersByTime(20); + flush(); const es = source(); vi.spyOn(es as unknown as MockEventSource, "close").mockImplementation(() => resolve()); dispose(); }), )); +}); - it("readyState is CLOSED after owner disposal", () => - createRoot(dispose => { - const { readyState } = createSSE("https://example.com/events"); - vi.advanceTimersByTime(20); - expect(readyState()).toBe(SSEReadyState.OPEN); - dispose(); - expect(readyState()).toBe(SSEReadyState.CLOSED); - })); +// ── createSSEStream ─────────────────────────────────────────────────────────── - it("readyState updates to CONNECTING when server drops connection", () => +describe("createSSEStream", () => { + it("data throws NotReadyError before first message arrives", () => createRoot(dispose => { - const { readyState, source } = createSSE("https://example.com/events"); - vi.advanceTimersByTime(20); - expect(readyState()).toBe(SSEReadyState.OPEN); - (source() as unknown as MockEventSource).simulateTransientError(); - expect(readyState()).toBe(SSEReadyState.CONNECTING); + const data = createSSEStream("https://example.com/events"); + expect(() => data()).toThrow(); dispose(); })); + + it("provides latest message after first message resolves", async () => { + await new Promise(resolve => + createRoot(async dispose => { + const data = createSSEStream("https://example.com/events"); + vi.advanceTimersByTime(20); + // Locate the mock source via SSEInstances + const mock = SSEInstances[SSEInstances.length - 1]!; + mock.simulateMessage("stream-hello"); + // Let the async iterator microtask resolve + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(data()).toBe("stream-hello"); + dispose(); + resolve(); + }), + ); + }); + + it("applies transform to incoming data", async () => { + await new Promise(resolve => + createRoot(async dispose => { + const data = createSSEStream<{ v: number }>("https://example.com/events", { + transform: JSON.parse, + }); + vi.advanceTimersByTime(20); + const mock = SSEInstances[SSEInstances.length - 1]!; + mock.simulateMessage(JSON.stringify({ v: 7 })); + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(data()).toEqual({ v: 7 }); + dispose(); + resolve(); + }), + ); + }); + + it("propagates terminal error through data()", async () => { + await new Promise(resolve => + createRoot(async dispose => { + const data = createSSEStream("https://example.com/events"); + vi.advanceTimersByTime(20); + const mock = SSEInstances[SSEInstances.length - 1]!; + mock.simulateError(); // CLOSED → terminal + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(() => data()).toThrow(); + dispose(); + resolve(); + }), + ); + }); }); diff --git a/packages/sse/test/server.test.ts b/packages/sse/test/server.test.ts index ab7ea714f..d6a457234 100644 --- a/packages/sse/test/server.test.ts +++ b/packages/sse/test/server.test.ts @@ -1,14 +1,13 @@ import { describe, expect, it } from "vitest"; import { createRoot } from "solid-js"; -import { createSSE } from "../src/index.js"; +import { createSSE, createSSEStream } from "../src/index.js"; describe("SSR", () => { it("returns safe stubs without touching EventSource", () => createRoot(dispose => { const sse = createSSE("https://example.com/events"); expect(sse.source()).toBeUndefined(); - expect(sse.data()).toBeUndefined(); - expect(sse.error()).toBeUndefined(); + expect(() => sse.data()).toThrow(); // throws NotReadyError — no initialValue expect(sse.readyState()).toBe(2); expect(() => sse.close()).not.toThrow(); expect(() => sse.reconnect()).not.toThrow(); @@ -23,4 +22,11 @@ describe("SSR", () => { expect(data()).toBe("loading"); dispose(); })); + + it("createSSEStream throws NotReadyError on server", () => + createRoot(dispose => { + const data = createSSEStream("https://example.com/events"); + expect(() => data()).toThrow(); + dispose(); + })); }); diff --git a/packages/sse/test/worker.test.ts b/packages/sse/test/worker.test.ts index ee635674a..74c7f2851 100644 --- a/packages/sse/test/worker.test.ts +++ b/packages/sse/test/worker.test.ts @@ -1,6 +1,6 @@ import "./setup"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createRoot } from "solid-js"; +import { createRoot, flush } from "solid-js"; import { createSSE, SSEReadyState } from "../src/sse.js"; import { makeSSEWorker, type SSEWorkerMessage, type SSEWorkerTarget } from "../src/worker.js"; @@ -227,6 +227,7 @@ describe("createSSE with worker source", () => { }); const id = (target.sent[0] as Extract).id; target.respond({ type: "open", id }); + flush(); expect(readyState()).toBe(SSEReadyState.OPEN); dispose(); })); @@ -240,6 +241,7 @@ describe("createSSE with worker source", () => { const id = (target.sent[0] as Extract).id; target.respond({ type: "open", id }); target.respond({ type: "message", id, data: "world", eventType: "message" }); + flush(); expect(data()).toBe("world"); dispose(); })); @@ -254,6 +256,7 @@ describe("createSSE with worker source", () => { const id = (target.sent[0] as Extract).id; target.respond({ type: "open", id }); target.respond({ type: "message", id, data: JSON.stringify({ n: 7 }), eventType: "message" }); + flush(); expect(data()).toEqual({ n: 7 }); dispose(); })); @@ -288,12 +291,15 @@ describe("createSSE with worker source", () => { }); const id1 = (target.sent[0] as Extract).id; target.respond({ type: "open", id: id1 }); + flush(); target.respond({ type: "error", id: id1, readyState: SSEReadyState.CLOSED }); + flush(); // Before the reconnect timer fires, only 1 connect expect(target.sent.filter(m => m.type === "connect")).toHaveLength(1); vi.advanceTimersByTime(150); + flush(); // After the delay, a new connect should have been sent expect(target.sent.filter(m => m.type === "connect")).toHaveLength(2); @@ -308,8 +314,10 @@ describe("createSSE with worker source", () => { }); const id = (target.sent[0] as Extract).id; target.respond({ type: "open", id }); + flush(); expect(readyState()).toBe(SSEReadyState.OPEN); close(); + flush(); expect(readyState()).toBe(SSEReadyState.CLOSED); expect(target.sent.some(m => m.type === "disconnect")).toBe(true); dispose(); diff --git a/packages/sse/vitest.config.ts b/packages/sse/vitest.config.ts new file mode 100644 index 000000000..927689e4f --- /dev/null +++ b/packages/sse/vitest.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from "vitest/config"; +import solidPlugin from "vite-plugin-solid"; + +export default defineConfig(({ mode }) => { + const testSSR = mode === "test:ssr" || mode === "ssr"; + return { + plugins: [ + solidPlugin({ + hot: false, + solid: { generate: testSSR ? "ssr" : "dom", omitNestedClosingTags: false }, + }), + ], + resolve: { + conditions: testSSR + ? ["@solid-primitives/source", "node"] + : ["@solid-primitives/source", "browser", "development"], + alias: { + "solid-js/web": new URL( + testSSR + ? "../../node_modules/.pnpm/@solidjs+web@2.0.0-beta.7_@solidjs+signals@2.0.0-beta.7_solid-js@2.0.0-beta.7/node_modules/@solidjs/web/dist/server.js" + : "../../node_modules/.pnpm/@solidjs+web@2.0.0-beta.7_@solidjs+signals@2.0.0-beta.7_solid-js@2.0.0-beta.7/node_modules/@solidjs/web/dist/web.js", + import.meta.url, + ).pathname, + "@solidjs/web": new URL( + testSSR + ? "../../node_modules/.pnpm/@solidjs+web@2.0.0-beta.7_@solidjs+signals@2.0.0-beta.7_solid-js@2.0.0-beta.7/node_modules/@solidjs/web/dist/server.js" + : "../../node_modules/.pnpm/@solidjs+web@2.0.0-beta.7_@solidjs+signals@2.0.0-beta.7_solid-js@2.0.0-beta.7/node_modules/@solidjs/web/dist/web.js", + import.meta.url, + ).pathname, + }, + }, + test: { + watch: false, + isolate: false, + passWithNoTests: true, + environment: testSSR ? "node" : "jsdom", + transformMode: { web: [/\.[jt]sx$/] }, + ...(testSSR + ? { include: ["test/server.test.{ts,tsx}"] } + : { + include: ["test/*.test.{ts,tsx}"], + exclude: ["test/server.test.{ts,tsx}"], + }), + }, + }; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f59c63a9c..9bc16d34d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -878,8 +878,8 @@ importers: version: link:../utils devDependencies: solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/state-machine: devDependencies: @@ -1063,10 +1063,10 @@ importers: version: link:../packages/utils '@solidjs/meta': specifier: ^0.29.3 - version: 0.29.4(solid-js@2.0.0-beta.10) + version: 0.29.4(solid-js@2.0.0-experimental.16) '@solidjs/router': specifier: ^0.13.1 - version: 0.13.6(solid-js@2.0.0-beta.10) + version: 0.13.6(solid-js@2.0.0-experimental.16) clsx: specifier: ^2.0.0 version: 2.1.1 @@ -1093,13 +1093,13 @@ importers: version: 1.77.8 solid-dismiss: specifier: ^1.7.121 - version: 1.8.2(solid-js@2.0.0-beta.10) + version: 1.8.2(solid-js@2.0.0-experimental.16) solid-icons: specifier: ^1.1.0 - version: 1.1.0(solid-js@2.0.0-beta.10) + version: 1.1.0(solid-js@2.0.0-experimental.16) solid-tippy: specifier: ^0.2.1 - version: 0.2.1(solid-js@2.0.0-beta.10)(tippy.js@6.3.7) + version: 0.2.1(solid-js@2.0.0-experimental.16)(tippy.js@6.3.7) tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -2603,6 +2603,9 @@ packages: peerDependencies: solid-js: ^1.5.3 + '@solidjs/signals@0.11.3': + resolution: {integrity: sha512-udMfutYPOlcxKUmc5+n1QtarsxOiAlC6LJY2TqFyaMwdXgo+reiYUcYGDlOiAPXfCLE0lavZHQ/6GT5pJbXKBA==} + '@solidjs/signals@2.0.0-beta.10': resolution: {integrity: sha512-McdmbLNiSlz616zcykS8Rb1t9QTOTKdNAoaWd4/OjXEbcAUrPqRX1CWgR+caiWUk4qn0a+LesTTV4jZhFFPaSg==} @@ -6040,6 +6043,9 @@ packages: solid-js@2.0.0-beta.10: resolution: {integrity: sha512-EAfV6b1SC4c3wEBAoX4dMy063uTb4nfL5uXnN8yse4InH7RTw1LoB0I9HAy+pj3/GHqQE2tYZurlZtqU4pGyog==} + solid-js@2.0.0-experimental.16: + resolution: {integrity: sha512-zZ1dU7cR0EnvLnrYiRLQbCFiDw5blLdlqmofgLzKUYE1TCMWDcisBlSwz0Ez8l4yXB4adbdhtaYCuynH4xSq9A==} + solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} peerDependencies: @@ -8617,18 +8623,20 @@ snapshots: dependencies: solid-js: 1.9.7 - '@solidjs/meta@0.29.4(solid-js@2.0.0-beta.10)': + '@solidjs/meta@0.29.4(solid-js@2.0.0-experimental.16)': dependencies: - solid-js: 2.0.0-beta.10 + solid-js: 2.0.0-experimental.16 - '@solidjs/router@0.13.6(solid-js@2.0.0-beta.10)': + '@solidjs/router@0.13.6(solid-js@2.0.0-experimental.16)': dependencies: - solid-js: 2.0.0-beta.10 + solid-js: 2.0.0-experimental.16 '@solidjs/router@0.8.4(solid-js@1.9.7)': dependencies: solid-js: 1.9.7 + '@solidjs/signals@0.11.3': {} + '@solidjs/signals@2.0.0-beta.10': {} '@solidjs/start@1.1.4(solid-js@2.0.0-beta.10)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))': @@ -12612,13 +12620,13 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - solid-dismiss@1.8.2(solid-js@2.0.0-beta.10): + solid-dismiss@1.8.2(solid-js@2.0.0-experimental.16): dependencies: - solid-js: 2.0.0-beta.10 + solid-js: 2.0.0-experimental.16 - solid-icons@1.1.0(solid-js@2.0.0-beta.10): + solid-icons@1.1.0(solid-js@2.0.0-experimental.16): dependencies: - solid-js: 2.0.0-beta.10 + solid-js: 2.0.0-experimental.16 solid-js@1.9.7: dependencies: @@ -12633,6 +12641,13 @@ snapshots: seroval: 1.5.2 seroval-plugins: 1.5.2(seroval@1.5.2) + solid-js@2.0.0-experimental.16: + dependencies: + '@solidjs/signals': 0.11.3 + csstype: 3.1.3 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + solid-refresh@0.6.3(solid-js@2.0.0-beta.10): dependencies: '@babel/generator': 7.27.5 @@ -12642,9 +12657,9 @@ snapshots: transitivePeerDependencies: - supports-color - solid-tippy@0.2.1(solid-js@2.0.0-beta.10)(tippy.js@6.3.7): + solid-tippy@0.2.1(solid-js@2.0.0-experimental.16)(tippy.js@6.3.7): dependencies: - solid-js: 2.0.0-beta.10 + solid-js: 2.0.0-experimental.16 tippy.js: 6.3.7 solid-transition-group@0.2.3(solid-js@1.9.7):