diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..0ed39d5
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - run: bun install
+
+ - name: Lint
+ run: bun run lint
+
+ - name: Typecheck
+ run: bun run typecheck
+
+ - name: Test
+ run: bun test
+
+ - name: Build
+ run: bun run build
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b4db13f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,47 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/),
+and this project adheres to [Semantic Versioning](https://semver.org/).
+
+## [Unreleased]
+
+### Added
+- `defineSchema()` function for shared, type-safe event definitions consumed by both server and client (#WI-037)
+- `createChannel(schema)` server-side SSE channel with dual Web/Node.js signatures, heartbeats, broadcast, and disconnect cleanup (#WI-039)
+- `reactive-swr/server` subpath export for tree-shakeable server-side imports (#WI-036)
+- `schema` prop on `SSEProvider` to auto-derive `events` mapping from a `defineSchema()` result (#WI-040)
+- `sendSSE(data)` convenience method on `mockSSE` controls for simpler test assertions (#WI-038)
+- `channel.connect()` for persistent SSE connections with initial `connected` event and configurable heartbeat interval
+- `channel.respond()` for scoped request-response SSE emitters (no broadcast pool, no heartbeats)
+- `channel.emit()` for type-safe broadcast to all connected clients with automatic dead-client cleanup
+- `channel.close()` for graceful shutdown of all connections and heartbeat timers
+- `SchemaDefinition`, `SchemaEventDefinition`, and `SchemaResult` types exported from main entry point
+- `prepare` script (`bun run build`) for automatic builds on `link:` installs (#WI-036)
+- `tsconfig.emit.json` for `.d.ts` declaration generation across all entry points (#WI-036)
+
+### Changed
+- Build script now compiles all three entry points (`index`, `testing`, `server`) with external peer deps (#WI-036)
+- Mock fetch in testing module cast as `typeof globalThis.fetch` for `@types/node` 24+ compatibility (#WI-036)
+- `SSEConfig` now accepts either `events` (manual) or `schema` (auto-derived), mutually exclusive at the type level (#WI-040)
+
+### Previous
+
+#### Added (Transport Abstraction)
+- Transport abstraction layer for non-GET SSE connections (#WI-216, #WI-218, #WI-219, #WI-220)
+- POST SSE support via `method` and `body` options in `useSSEStream` and `SSEProvider`
+- Custom HTTP headers for SSE connections via `headers` option
+- Custom transport factory via `transport` option for full control over SSE connections
+- Automatic JSON serialization for plain object request bodies with `Content-Type: application/json`
+- Body-implies-POST behavior: providing `body` without `method` defaults to POST
+- `SSETransport` interface for building custom transport implementations (#WI-216)
+- `SSERequestOptions` type for method/body/headers grouping (#WI-216)
+- `createSSEParser` export for advanced users building custom transports (#WI-217, #WI-222)
+- Spec-compliant SSE wire format parser with chunked input support (#WI-217)
+- Fetch-based SSE transport using `fetch()` + `ReadableStream` (#WI-218)
+- Shared reconnection utilities with exponential backoff (#WI-223)
+- Unified reconnection for all transport types in SSEProvider (#WI-220)
+- Composite connection keys in `useSSEStream` for proper connection reuse across different request configurations (#WI-219)
+- Dual EventSource + fetch interception in `mockSSE` test utility (#WI-221)
+- `sendRaw()` method on `mockSSE` controls for testing SSE parser edge cases (#WI-221)
diff --git a/README.md b/README.md
index 9cfa54c..74a7905 100644
--- a/README.md
+++ b/README.md
@@ -12,23 +12,22 @@ Building real-time UIs typically requires:
## The Solution
-reactiveSWR provides a declarative bridge between SSE events and SWR's cache. You define a mapping once, and your components just use normal `useSWR` hooks--they automatically receive real-time updates without knowing about SSE.
+reactiveSWR provides a declarative bridge between SSE events and SWR's cache. Define a shared schema once, and your components just use normal `useSWR` hooks -- they automatically receive real-time updates without knowing about SSE.
```typescript
-// Define your event mappings once
-const config: SSEConfig = {
- url: '/api/events',
- events: {
- 'order:updated': {
- key: (p) => `/api/orders/${p.id}`,
- update: 'set', // Use SSE payload directly, no refetch
- },
- 'comment:added': {
- key: (p) => `/api/posts/${p.postId}/comments`,
- update: (current, p) => [...(current ?? []), p.comment],
- },
+import { defineSchema } from 'reactive-swr'
+
+// Define your schema once -- shared by server and client
+const schema = defineSchema({
+ 'order:updated': {
+ key: (p: { id: string; status: string }) => `/api/orders/${p.id}`,
+ update: 'set',
},
-}
+ 'comment:added': {
+ key: (p: { postId: string; comment: string }) => `/api/posts/${p.postId}/comments`,
+ update: (current: string[] | undefined, p) => [...(current ?? []), p.comment],
+ },
+})
// Components just use useSWR - updates happen automatically
function OrderStatus({ orderId }) {
@@ -37,6 +36,8 @@ function OrderStatus({ orderId }) {
}
```
+You can also define event mappings manually without a schema -- see [Manual Events Mapping](#manual-events-mapping) below.
+
## Installation
```bash
@@ -45,24 +46,56 @@ npm install reactive-swr swr
## Quick Start
-```tsx
-import { SWRConfig } from 'swr'
-import { SSEProvider } from 'reactive-swr'
+### 1. Define a schema (shared between server and client)
-const sseConfig = {
- url: '/api/events',
- events: {
- 'user:updated': {
- key: (p) => `/api/users/${p.id}`,
- update: 'set',
- },
+```typescript
+// schema.ts
+import { defineSchema } from 'reactive-swr'
+
+export const schema = defineSchema({
+ 'user:updated': {
+ key: (p: { id: string }) => `/api/users/${p.id}`,
+ update: 'set',
},
+ 'order:placed': {
+ key: '/api/orders',
+ update: 'refetch',
+ },
+})
+```
+
+### 2. Server: create an SSE channel
+
+```typescript
+// server.ts
+import { createChannel } from 'reactive-swr/server'
+import { schema } from './schema'
+
+const channel = createChannel(schema)
+
+// Web standard (Cloudflare Workers, Deno, Bun)
+export function GET(request: Request) {
+ return channel.connect(request)
}
+// Node.js HTTP / Express / Fastify
+app.get('/api/events', (req, res) => channel.connect(req, res))
+
+// Broadcast type-safe events
+channel.emit('user:updated', { id: '42', name: 'Alice' })
+```
+
+### 3. Client: wire up SSEProvider with the schema
+
+```tsx
+import { SWRConfig } from 'swr'
+import { SSEProvider } from 'reactive-swr'
+import { schema } from './schema'
+
function App() {
return (
fetch(url).then(r => r.json()) }}>
-
+
@@ -70,8 +103,138 @@ function App() {
}
```
+Components use standard `useSWR` hooks and receive real-time updates automatically.
+
## Features
+### Schema-Driven SSE
+
+The recommended approach is to define a shared schema that drives both server-side event emission and client-side cache updates. This eliminates type drift between server and client.
+
+#### `defineSchema()`
+
+`defineSchema()` creates a frozen, type-safe schema object. Event names are preserved as string literals for full TypeScript autocomplete on both sides.
+
+```typescript
+import { defineSchema } from 'reactive-swr'
+
+const schema = defineSchema({
+ 'user:updated': {
+ key: (p: { id: string; name: string }) => `/api/users/${p.id}`,
+ update: 'set',
+ },
+ 'stats:refreshed': {
+ key: ['/api/stats', '/api/dashboard'],
+ update: 'refetch',
+ },
+ 'comment:added': {
+ key: (p: { postId: string; comment: Comment }) => `/api/posts/${p.postId}/comments`,
+ update: (current: Comment[] | undefined, p) => [...(current ?? []), p.comment],
+ filter: (p) => !p.comment.deleted,
+ transform: (p) => ({ ...p, comment: { ...p.comment, isNew: true } }),
+ },
+})
+```
+
+Each event definition supports:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `key` | `string \| string[] \| (payload) => string \| string[]` | SWR cache key(s) to update |
+| `update` | `'set' \| 'refetch' \| (current, payload) => newValue` | Update strategy (default: `'set'`) |
+| `filter` | `(payload) => boolean` | Optional client-side filter |
+| `transform` | `(payload) => payload` | Optional client-side transform |
+
+#### `createChannel()` (Server)
+
+`createChannel()` provides a complete server-side SSE endpoint. It handles wire formatting, heartbeats, connection tracking, and cleanup. Import it from `reactive-swr/server`.
+
+```typescript
+import { createChannel } from 'reactive-swr/server'
+
+const channel = createChannel(schema, {
+ heartbeatInterval: 30000, // default: 30s
+})
+```
+
+**Dual runtime support** -- works with both Web standard APIs (Cloudflare Workers, Deno, Bun) and Node.js (Express, Fastify, raw `http`):
+
+```typescript
+// Web standard: returns a streaming Response
+export function GET(request: Request): Response {
+ return channel.connect(request)
+}
+
+// Node.js: writes to ServerResponse
+app.get('/events', (req, res) => {
+ channel.connect(req, res)
+})
+```
+
+**Broadcast events** to all connected clients:
+
+```typescript
+// Type-safe: eventType and payload are checked against the schema
+channel.emit('user:updated', { id: '42', name: 'Alice' })
+```
+
+**Scoped emitters** for request-response patterns (e.g., streaming query results):
+
+```typescript
+// Node.js
+app.post('/api/query', (req, res) => {
+ const emitter = channel.respond(req, res)
+ emitter.emit('result', { rows: queryResults })
+ emitter.close()
+})
+
+// Web standard (Fetch API / edge runtimes)
+export async function POST(request: Request) {
+ const { response, emitter } = channel.respond(request)
+ emitter.emit('result', { rows: await queryDB() })
+ emitter.close()
+ return response
+}
+```
+
+**Shutdown** all connections:
+
+```typescript
+channel.close() // Closes all connections, stops heartbeats
+```
+
+#### SSEProvider `schema` Prop
+
+Pass a schema to `SSEProvider` instead of manually writing `events` mappings:
+
+```tsx
+
+
+
+```
+
+The `events` mapping is automatically derived from the schema's `key`, `update`, `filter`, and `transform` definitions. `schema` and `events` are mutually exclusive -- providing both is a TypeScript error. The `parseEvent` callback remains configurable alongside `schema`.
+
+### Manual Events Mapping
+
+If you prefer not to use a schema, you can define event mappings manually. This is the original API and remains fully supported.
+
+```tsx
+const config: SSEConfig = {
+ url: '/api/events',
+ events: {
+ 'order:updated': {
+ key: (p) => `/api/orders/${p.id}`,
+ update: 'set',
+ },
+ },
+}
+
+
+
+
+```
+
### Update Strategies
Control how SSE events update your cached data:
@@ -144,6 +307,96 @@ events: {
}
```
+### POST SSE and Custom Transports
+
+By default, reactiveSWR uses the browser's `EventSource` API, which only supports GET requests. The transport abstraction lets you connect to SSE endpoints that require POST requests, custom headers, or entirely custom connection logic.
+
+#### POST with JSON body
+
+```tsx
+import { useSSEStream } from 'reactive-swr'
+
+function AIChat({ question }: { question: string }) {
+ const { data, error } = useSSEStream('/api/chat', {
+ method: 'POST',
+ body: { question, model: 'gpt-4' },
+ })
+
+ return {data}
+}
+```
+
+Plain objects passed as `body` are automatically JSON-serialized with `Content-Type: application/json`. If you provide a `body` without a `method`, it defaults to POST.
+
+#### Custom headers (authenticated SSE)
+
+```typescript
+const { data } = useSSEStream('/api/events', {
+ headers: { Authorization: `Bearer ${token}` },
+})
+```
+
+Providing `headers` (or `method` or `body`) automatically switches from `EventSource` to the fetch-based transport.
+
+#### Custom transport factory
+
+For full control, provide a `transport` factory that returns an `SSETransport`-compatible object:
+
+```typescript
+import type { SSETransport } from 'reactive-swr'
+
+const { data } = useSSEStream('/api/events', {
+ transport: (url) => createMyCustomTransport(url),
+})
+```
+
+#### SSEProvider with transport options
+
+The same transport options are available in `SSEConfig`:
+
+```typescript
+const config: SSEConfig = {
+ url: '/api/events',
+ method: 'POST',
+ body: { subscribe: ['orders', 'users'] },
+ headers: { Authorization: `Bearer ${token}` },
+ events: {
+ 'order:updated': {
+ key: (p) => `/api/orders/${p.id}`,
+ update: 'set',
+ },
+ },
+}
+
+// Or with a custom transport factory:
+const config: SSEConfig = {
+ url: '/api/events',
+ transport: (url) => createMyCustomTransport(url),
+ events: { /* ... */ },
+}
+```
+
+### SSE Parser
+
+For advanced users building custom transports, the SSE wire format parser is available as a standalone export:
+
+```typescript
+import { createSSEParser } from 'reactive-swr'
+
+const parser = createSSEParser({
+ onEvent(event) {
+ console.log(event.event, event.data, event.id)
+ },
+ onRetry(ms) {
+ console.log('Server requested retry interval:', ms)
+ },
+})
+
+// Feed raw SSE text (handles chunked input)
+parser.feed('data: {"hello":"world"}\n\n')
+parser.feed('event: update\ndata: {"id":1}\n\n')
+```
+
### Reconnection
Automatic reconnection with exponential backoff:
@@ -252,11 +505,21 @@ function LivePrice({ symbol }: { symbol: string }) {
}
```
+#### Options
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `transform` | `(data: unknown) => T` | Transform incoming data before storing |
+| `method` | `string` | HTTP method (defaults to POST when body is provided) |
+| `body` | `BodyInit \| Record` | Request body (triggers fetch-based transport) |
+| `headers` | `Record` | Additional request headers (triggers fetch-based transport) |
+| `transport` | `(url: string) => SSETransport` | Custom transport factory (takes precedence over all other options) |
+
## Testing
The library provides `mockSSE` for testing components with SSE:
-```typescript
+```tsx
import { mockSSE } from 'reactive-swr/testing'
test('updates order when SSE event received', async () => {
@@ -292,13 +555,19 @@ test('updates order when SSE event received', async () => {
```typescript
const mock = mockSSE(url: string)
-mock.sendEvent({ type: string, payload: unknown }) // Send an event
+mock.sendEvent({ type: string, payload: unknown }) // Send a typed event
+mock.sendSSE(data: unknown) // Send data as SSE wire format — data: \n\n (convenience for createSSEParser tests)
+mock.sendRaw(text: string) // Send raw SSE wire format
mock.close() // Simulate connection close
mock.getConnection() // Get the mock EventSource
-mockSSE.restore() // Restore real EventSource
+mockSSE.restore() // Restore real EventSource and fetch
```
+`sendSSE(data)` is a convenience wrapper that formats `data` as `data: \n\n` and sends it via `sendRaw()`. It simplifies tests for consumers using `createSSEParser` who work with raw SSE wire format.
+
+`mockSSE` automatically intercepts both `EventSource` and `fetch` for registered URLs, so your tests work regardless of which transport the component uses internally.
+
## Documentation
- [API Reference](./docs/API.md) - Complete API documentation
diff --git a/package.json b/package.json
index b4255ac..e8a14f8 100644
--- a/package.json
+++ b/package.json
@@ -12,12 +12,17 @@
"./testing": {
"import": "./dist/testing/index.js",
"types": "./dist/testing/index.d.ts"
+ },
+ "./server": {
+ "import": "./dist/server/index.js",
+ "types": "./dist/server/index.d.ts"
}
},
"sideEffects": false,
"scripts": {
"dev": "bun run --watch src/index.ts",
- "build": "bun build src/index.ts --outdir dist --target browser",
+ "build": "bun build src/index.ts src/testing/index.ts src/server/index.ts --outdir dist --target browser --external react --external react-dom --external swr && tsc --project tsconfig.emit.json",
+ "prepare": "bun run build",
"test": "bun test",
"test:e2e": "bunx playwright test",
"lint": "biome check .",
diff --git a/prd/0001-transport-abstraction.md b/prd/0001-transport-abstraction.md
new file mode 100644
index 0000000..3b10d92
--- /dev/null
+++ b/prd/0001-transport-abstraction.md
@@ -0,0 +1,111 @@
+# PRD-0001: Transport Abstraction
+
+**Author:** Josh
+**Date:** 2026-02-12
+**Status:** Draft
+
+## Problem Statement
+
+`useSSEStream` and `SSEProvider` hardcode `new EventSource(url)`, which only supports GET requests. Real-world SSE use cases frequently require POST with a JSON body (e.g., sending a query payload and streaming results back). This was discovered during integration testing with [ArcaneLayers/data-ops](https://github.com/ArcaneLayers/data-ops), where the `/api/query` endpoint requires POST with `{ question, shop, stream }` in the body.
+
+This is the single biggest blocker to adopting reactiveSWR in apps that don't use vanilla GET-based SSE.
+
+## Business Context
+
+- **Adoption blocker:** Without POST support, the library is limited to GET-only SSE, which excludes a large class of real-world use cases — any endpoint that needs structured input (search queries, filters, authentication tokens in the body).
+- **Timing:** At v0.0.1 this is a non-breaking API addition. After npm publish and wider adoption, adding it becomes a harder sell and risks breaking changes.
+- **Competitive gap:** Libraries like `eventsource-parser` and hand-rolled `fetch` + `ReadableStream` solutions are what developers fall back to today. Built-in POST support makes reactiveSWR a complete solution.
+- **Mechanical change:** The core library change is small — the internal transport selection is automatic based on what options the developer provides. The risk is low and the payoff is high.
+
+## Goals & Success Metrics
+
+| Goal | Metric | Target |
+|------|--------|--------|
+| Enable POST-based SSE | Developers can pass `method`, `body`, `headers` to stream from POST endpoints | Works with any `fetch()`-compatible SSE endpoint |
+| Zero-config for GET | Apps that don't pass `method`/`body`/`headers` behave identically to today | 100% existing test pass rate, zero API changes for current users |
+| Simple API | No transport classes or factories in the common case | Developer adds `method: 'POST'` and `body` — done |
+| Maintain bundle size | Library stays lightweight | < 1KB additional gzipped |
+
+**Negative metric:** Existing `EventSource`-based usage shall not degrade in performance or behavior.
+
+## User Stories
+
+- **As a** developer with a POST-based SSE endpoint, **I want** to pass `method`, `body`, and `headers` to `useSSEStream` **so that** I can stream results from endpoints that require structured input — without learning a transport API.
+- **As a** developer using the default GET-based SSE, **I want** the library to work exactly as before **so that** I don't need to change anything when upgrading.
+- **As a** developer writing tests, **I want** `mockSSE` to work with both GET and POST streams **so that** I can test components regardless of how they connect.
+- **As a** developer with a non-standard streaming backend, **I want** an escape hatch to provide my own transport **so that** I'm not limited to EventSource and fetch.
+
+## Scope
+
+### In Scope
+
+- `method`, `body`, and `headers` options on `useSSEStream` and `SSEConfig`
+- Automatic transport selection: use native `EventSource` for plain GET (default), use `fetch()` + `ReadableStream` when `method`, `body`, or `headers` are provided
+- Internal SSE line parsing for the fetch-based path (the `data: ...\n\n` wire format)
+- Reconnection support for the fetch-based path (same backoff behavior as existing EventSource reconnection)
+- `SSETransport` interface exported for advanced users who need a fully custom transport (escape hatch)
+- Optional `transport` override in `useSSEStream` options and `SSEConfig` for the escape hatch case
+- Updated `mockSSE` test utility to work with both transport paths
+- Tests for POST-based SSE streams
+
+### Out of Scope
+
+- WebSocket transport (different protocol entirely, separate PRD if needed)
+- Server-side SSE implementation or server helpers
+- Polyfills for `ReadableStream` in older browsers
+- Streaming JSON parsing beyond the SSE wire format (`data:`, `event:`, `id:`, `retry:`)
+
+## Requirements
+
+### Functional Requirements
+
+1. `UseSSEStreamOptions` shall accept optional `method`, `body`, and `headers` properties for configuring the HTTP request.
+2. `SSEConfig` shall accept optional `method`, `body`, and `headers` properties for configuring the HTTP request.
+3. When any of `method`, `body`, or `headers` are provided, the library shall internally use `fetch()` + `ReadableStream` instead of `EventSource` to establish the SSE connection. This includes `headers` alone (e.g., for authenticated GET streams that `EventSource` cannot support).
+4. When none of `method`, `body`, or `headers` are provided, the library shall use native `EventSource` (preserving current default behavior).
+5. If `body` is provided without `method`, the library shall default to `POST`.
+6. The fetch-based path shall parse the SSE wire format (`data: ...\n\n`) from the response stream and dispatch events through the same handler interface as `EventSource`.
+7. The fetch-based path shall support named SSE events (the `event:` field) and dispatch them correctly.
+8. The fetch-based path shall support automatic reconnection with the same backoff behavior as the existing `EventSource` reconnection logic.
+9. The fetch-based path shall track the last received `id:` field and send it as the `Last-Event-ID` header on reconnection, matching native `EventSource` behavior.
+9. The library shall export an `SSETransport` interface as an escape hatch for fully custom transports. This interface shall include: `onmessage`, `onerror`, `onopen` handler properties; `close()` method; `readyState` property; and `addEventListener`/`removeEventListener` methods.
+10. `UseSSEStreamOptions` and `SSEConfig` shall accept an optional `transport` property `(url: string) => SSETransport`. When provided, it shall take precedence over both `EventSource` and the fetch-based path.
+11. The `mockSSE` test utility shall work with both the EventSource and fetch-based transport paths.
+
+### Non-Functional Requirements
+
+1. The fetch-based transport shall add no more than 1KB gzipped to the bundle.
+2. SSE line parsing shall handle standard SSE fields: `data`, `event`, `id`, and `retry`.
+3. All new public types shall be fully typed with TypeScript and exported from the package entry point.
+
+## Edge Cases & Error States
+
+- **Malformed SSE lines:** The fetch-based path shall skip lines that don't conform to the SSE format (no `:`), matching browser `EventSource` behavior.
+- **Multi-line `data` fields:** SSE allows multiple consecutive `data:` lines before `\n\n`. The parser shall concatenate them with `\n` (per the SSE spec).
+- **Empty `data` field:** `data:\n\n` (empty string) shall dispatch an event with empty string data, not be skipped.
+- **Network error during fetch:** The fetch-based path shall invoke `onerror` and, if reconnection is enabled, schedule a retry.
+- **Response with non-200 status:** Non-2xx responses shall be treated as errors and invoke `onerror`.
+- **AbortController cleanup:** `close()` on the fetch-based transport shall abort the underlying fetch request and clean up the `ReadableStream` reader.
+- **Stream ends unexpectedly:** If the server closes the stream, the fetch-based path shall treat it as a disconnect and attempt reconnection if enabled.
+- **Custom transport throws:** If a user-provided `transport` factory throws, `SSEProvider` and `useSSEStream` shall catch the error and report it via the existing error handling path.
+- **`body` without `method`:** The library shall default to `POST`.
+- **`headers` without `method` or `body`:** The library shall use the fetch-based path with `GET`. This enables authenticated SSE streams with custom headers — something `EventSource` cannot do.
+
+## Dependencies
+
+- **Internal:** Existing `SSEProvider` and `useSSEStream` implementations (both need updates to accept request options)
+- **Internal:** `mockSSE` test utility (needs updates to support fetch-based connections)
+- **Browser API:** `fetch()` and `ReadableStream` (available in all modern browsers, Node 18+)
+- **No new external dependencies**
+
+## Risks & Open Questions
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|-----------|--------|------------|
+| `ReadableStream` not available in target environment | Low | Fetch-based path unusable | Document browser/runtime requirements; default path remains EventSource |
+| SSE line parsing edge cases | Medium | Incorrect event dispatch | Follow the [WHATWG SSE spec](https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation) for parsing; test against known edge cases |
+| Breaking change to mockSSE | Low | Existing tests fail | Ensure mockSSE changes are backwards-compatible |
+
+### Open Questions
+
+None — all resolved.
diff --git a/src/SSEProvider.tsx b/src/SSEProvider.tsx
index 2640c64..46b6b0a 100644
--- a/src/SSEProvider.tsx
+++ b/src/SSEProvider.tsx
@@ -8,37 +8,17 @@ import {
useState,
} from 'react'
import { useSWRConfig } from 'swr'
+import { createFetchTransport } from './fetchTransport.ts'
+import { calculateBackoffDelay, DEFAULT_RECONNECT } from './reconnect.ts'
import type {
EventMapping,
ParsedEvent,
ReconnectConfig,
SSEConfig,
SSEStatus,
+ SSETransport,
} from './types.ts'
-/**
- * Default reconnection configuration values
- */
-const DEFAULT_RECONNECT: Required = {
- enabled: true,
- initialDelay: 1000,
- maxDelay: 30000,
- backoffMultiplier: 2,
- maxAttempts: Number.POSITIVE_INFINITY,
-}
-
-/**
- * Calculate the delay for the next reconnection attempt using exponential backoff.
- * Formula: min(initialDelay * (backoffMultiplier ^ attemptNumber), maxDelay)
- */
-function calculateBackoffDelay(
- attemptNumber: number,
- config: Required,
-): number {
- const delay = config.initialDelay * config.backoffMultiplier ** attemptNumber
- return Math.min(delay, config.maxDelay)
-}
-
interface SSEContextValue {
status: SSEStatus
subscribe: (
@@ -136,6 +116,66 @@ function resolveKeys(
return Array.isArray(keyConfig) ? keyConfig : [keyConfig]
}
+/**
+ * Derive an EventMapping record from a schema (the frozen output of defineSchema()).
+ * Each schema entry carries key, update, filter, and transform -- all of which
+ * map directly onto the EventMapping shape.
+ */
+function deriveEventsFromSchema(
+ // biome-ignore lint/suspicious/noExplicitAny: schema type is erased at this level
+ schema: Record,
+): Record {
+ // biome-ignore lint/suspicious/noExplicitAny: EventMapping generics erased in Record usage
+ const events: Record> = {}
+ for (const [eventName, def] of Object.entries(schema)) {
+ events[eventName] = {
+ key: def.key,
+ update: def.update,
+ filter: def.filter,
+ transform: def.transform,
+ }
+ }
+ return events
+}
+
+/**
+ * Resolved config that always has a concrete `events` mapping.
+ * Uses an intersection with SSEConfigWithEvents to satisfy the type system
+ * after schema-to-events derivation or default fallback.
+ */
+type ResolvedSSEConfig = SSEConfig & { events: Record }
+
+/**
+ * Resolve the effective events mapping from an SSEConfig.
+ * When `schema` is provided it takes precedence over `events`.
+ * Returns a new config object guaranteed to have an `events` property.
+ */
+function resolveConfig(config: SSEConfig): ResolvedSSEConfig {
+ // Access schema/events via index to avoid union narrowing to `never`
+ // when both are provided at runtime (bypassed via type assertion).
+ // biome-ignore lint/suspicious/noExplicitAny: accessing union properties that use `never` for mutual exclusivity
+ const rawConfig = config as any
+ const schema: Record | undefined = rawConfig.schema
+ const events: Record | undefined = rawConfig.events
+
+ if (schema !== undefined) {
+ if (events !== undefined && config.debug) {
+ console.warn(
+ '[reactiveSWR] Both schema and events were provided. schema takes precedence.',
+ )
+ }
+ return {
+ ...config,
+ events: deriveEventsFromSchema(schema),
+ } as ResolvedSSEConfig
+ }
+ return {
+ ...config,
+ // biome-ignore lint/suspicious/noExplicitAny: EventMapping generics erased
+ events: (events ?? {}) as Record>,
+ } as ResolvedSSEConfig
+}
+
export function SSEProvider({
config,
children,
@@ -149,9 +189,12 @@ export function SSEProvider({
new Map void>>(),
)
- // Store config in ref to access latest values in handlers
- const configRef = useRef(config)
- configRef.current = config
+ // Resolve the effective config (schema -> events derivation, events fallback)
+ const resolvedConfig = resolveConfig(config)
+
+ // Store resolved config in ref to access latest values in handlers
+ const configRef = useRef(resolvedConfig)
+ configRef.current = resolvedConfig
// Use a stable status object that is mutated in place for SSR compatibility
// This allows tests using renderToString to observe status changes
@@ -181,8 +224,8 @@ export function SSEProvider({
[],
)
- // EventSource and listeners refs for cleanup
- const eventSourceRef = useRef(null)
+ // EventSource/transport and listeners refs for cleanup
+ const eventSourceRef = useRef(null)
const listenersRef = useRef<
Array<{ type: string; handler: (event: MessageEvent) => void }>
>([])
@@ -302,27 +345,87 @@ export function SSEProvider({
}
}, [])
+ // readyState constant for CLOSED (works for both EventSource and SSETransport)
+ const CLOSED = 2
+
+ /**
+ * Determine whether the config requires a non-EventSource transport
+ */
+ const needsFetchTransport = useCallback((cfg: SSEConfig): boolean => {
+ return !!(cfg.method || cfg.body || cfg.headers)
+ }, [])
+
/**
- * Create and configure a new EventSource connection
+ * Create a transport based on the current config.
+ * Priority: config.transport factory > fetch transport (method/body/headers) > EventSource
*/
- const createEventSource = useCallback(() => {
+ const createTransport = useCallback(
+ (url: string): SSETransport | EventSource => {
+ const cfg = configRef.current
+ if (cfg.transport) {
+ return cfg.transport(url)
+ }
+ if (needsFetchTransport(cfg)) {
+ return createFetchTransport(url, {
+ method: cfg.method,
+ body: cfg.body,
+ headers: cfg.headers,
+ })
+ }
+ return new EventSource(url)
+ },
+ [needsFetchTransport],
+ )
+
+ /**
+ * Create and configure a new connection (EventSource or transport)
+ */
+ const createConnection = useCallback(() => {
// Clean up any existing connection
if (eventSourceRef.current) {
- const oldEventSource = eventSourceRef.current
+ const oldConnection = eventSourceRef.current
for (const { type, handler } of listenersRef.current) {
- oldEventSource.removeEventListener(type, handler)
+ oldConnection.removeEventListener(type, handler)
}
listenersRef.current = []
- oldEventSource.close()
+ oldConnection.close()
}
const url = configRef.current.url
- const eventSource = new EventSource(url)
- eventSourceRef.current = eventSource
+
+ // Create the transport, catching errors from custom factories
+ let connection: SSETransport | EventSource
+ try {
+ connection = createTransport(url)
+ } catch (error) {
+ configRef.current.onEventError?.(
+ { type: 'transport_error', payload: null },
+ error,
+ )
+ // Install a no-op closed transport to prevent re-entry on next render
+ eventSourceRef.current = {
+ readyState: CLOSED,
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ close() {},
+ addEventListener() {},
+ removeEventListener() {},
+ }
+ currentUrlRef.current = url
+ updateStatus({
+ connected: false,
+ connecting: false,
+ error: error instanceof Error ? error : new Error(String(error)),
+ })
+ return
+ }
+
+ eventSourceRef.current = connection
currentUrlRef.current = url
// Handle connection open
- eventSource.onopen = () => {
+ connection.onopen = () => {
// Clear any pending reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
@@ -342,14 +445,14 @@ export function SSEProvider({
}
// Handle connection error
- eventSource.onerror = (event: Event) => {
+ connection.onerror = (event: Event) => {
updateStatus({
error: new Error('EventSource connection error'),
})
configRef.current.onError?.(event)
// Check if connection was closed
- if (eventSource.readyState === EventSource.CLOSED) {
+ if (connection.readyState === CLOSED) {
updateStatus({
connected: false,
})
@@ -379,13 +482,13 @@ export function SSEProvider({
connecting: true,
reconnectAttempt: attemptCountRef.current,
})
- createEventSource()
+ createConnection()
}, delay)
}
}
// Handle generic messages (unnamed events)
- eventSource.onmessage = (event: MessageEvent) => {
+ connection.onmessage = (event: MessageEvent) => {
try {
const parseEvent = configRef.current.parseEvent ?? defaultParseEvent
const parsed = parseEvent(event)
@@ -426,18 +529,23 @@ export function SSEProvider({
}
}
- eventSource.addEventListener(eventType, handler)
+ connection.addEventListener(eventType, handler)
listenersRef.current.push({ type: eventType, handler })
}
- }, [getReconnectConfig, processEvent, updateStatus])
+ }, [createTransport, getReconnectConfig, processEvent, updateStatus])
- // Initialize EventSource synchronously (for SSR compatibility)
+ // Initialize connection synchronously (for SSR compatibility)
// Also handle URL changes by creating a new connection when URL differs
const urlChanged =
- currentUrlRef.current !== null && currentUrlRef.current !== config.url
- if (typeof EventSource !== 'undefined') {
+ currentUrlRef.current !== null &&
+ currentUrlRef.current !== resolvedConfig.url
+ // Create connection if: custom transport or fetch transport is configured, OR EventSource is available
+ const hasCustomTransport = !!(
+ resolvedConfig.transport || needsFetchTransport(resolvedConfig)
+ )
+ if (hasCustomTransport || typeof EventSource !== 'undefined') {
if (eventSourceRef.current === null || urlChanged) {
- createEventSource()
+ createConnection()
}
}
@@ -451,7 +559,7 @@ export function SSEProvider({
) {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
- const eventSource = eventSourceRef.current
+ const connection = eventSourceRef.current
const reconnectConfig = getReconnectConfig()
// Check if reconnection is enabled
@@ -465,8 +573,7 @@ export function SSEProvider({
}
// Check if connection is lost (closed or no connection)
- const isConnectionLost =
- !eventSource || eventSource.readyState === EventSource.CLOSED
+ const isConnectionLost = !connection || connection.readyState === CLOSED
if (isConnectionLost) {
// Cancel any pending reconnect timer to avoid duplicate connections
@@ -481,7 +588,7 @@ export function SSEProvider({
connecting: true,
reconnectAttempt: attemptCountRef.current,
})
- createEventSource()
+ createConnection()
}
}
}
@@ -504,7 +611,7 @@ export function SSEProvider({
// Cleanup on unmount
useEffect(() => {
- const eventSource = eventSourceRef.current
+ const connection = eventSourceRef.current
const listeners = listenersRef.current
return () => {
@@ -514,18 +621,18 @@ export function SSEProvider({
reconnectTimeoutRef.current = null
}
- if (eventSource) {
+ if (connection) {
// Check if already closed (onerror may have already called onDisconnect)
- const wasConnected = eventSource.readyState !== EventSource.CLOSED
+ const wasConnected = connection.readyState !== CLOSED
// Remove all named event listeners
for (const { type, handler } of listeners) {
- eventSource.removeEventListener(type, handler)
+ connection.removeEventListener(type, handler)
}
listenersRef.current = []
- // Close the EventSource connection
- eventSource.close()
+ // Close the connection
+ connection.close()
eventSourceRef.current = null
// Only call onDisconnect if we were still connected
@@ -540,7 +647,7 @@ export function SSEProvider({
const contextValue: SSEContextValue = {
status,
subscribe,
- config,
+ config: resolvedConfig,
}
return (
diff --git a/src/__tests__/SSEProvider-schema.test.tsx b/src/__tests__/SSEProvider-schema.test.tsx
new file mode 100644
index 0000000..4cc3c1b
--- /dev/null
+++ b/src/__tests__/SSEProvider-schema.test.tsx
@@ -0,0 +1,550 @@
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+import { createElement } from 'react'
+import { renderToString } from 'react-dom/server'
+import { SSEProvider, useSSEContext } from '../SSEProvider.tsx'
+import { defineSchema } from '../schema.ts'
+import type { SSEConfig } from '../types.ts'
+
+/**
+ * Tests for SSEProvider schema prop (WI-040).
+ *
+ * Verifies that SSEProvider accepts an optional `schema` prop that
+ * auto-derives the `events` mapping instead of requiring manual EventMapping records.
+ *
+ * Tests cover:
+ * 1. SSEProvider accepts a schema prop and renders children
+ * 2. Schema-derived events mapping carries key, update, filter, transform
+ * 3. schema takes precedence at runtime when both schema and events are provided (with warning)
+ * 4. parseEvent remains configurable alongside schema
+ * 5. Empty schema produces empty events mapping
+ * 6. All existing SSEProvider behavior unaffected (no regressions)
+ * 7. Derived events mapping matches EventMapping shape
+ *
+ * Tests FAIL initially because the schema prop has not been added yet.
+ */
+
+// --------------------------------------------------------------------------
+// Schemas used across tests
+// --------------------------------------------------------------------------
+
+const userSchema = defineSchema({
+ 'user.updated': { key: '/api/users', update: 'set' },
+})
+
+const orderSchema = defineSchema({
+ 'order.placed': {
+ key: (p: { id: string }) => `/api/orders/${p.id}`,
+ update: 'refetch',
+ },
+})
+
+const filterTransformSchema = defineSchema({
+ 'item.added': {
+ key: '/api/items',
+ update: 'set',
+ filter: (p: { active: boolean }) => p.active,
+ transform: (p: { name: string }) => ({ ...p, name: p.name.toUpperCase() }),
+ },
+})
+
+const emptySchema = defineSchema({})
+
+const multiSchema = defineSchema({
+ 'user.updated': { key: '/api/users', update: 'set' },
+ 'order.placed': { key: '/api/orders', update: 'refetch' },
+ 'item.deleted': { key: ['/api/items', '/api/cache'] },
+})
+
+// --------------------------------------------------------------------------
+// Helper: capture the SSEContext value from within a rendered provider
+// --------------------------------------------------------------------------
+
+function captureContext(
+ config: SSEConfig,
+): ReturnType | null {
+ let captured: ReturnType | null = null
+
+ function Capture() {
+ captured = useSSEContext()
+ return createElement('span', null, 'ok')
+ }
+
+ renderToString(createElement(SSEProvider, { config }, createElement(Capture)))
+ return captured
+}
+
+// --------------------------------------------------------------------------
+// Tests
+// --------------------------------------------------------------------------
+
+describe('SSEProvider schema prop', () => {
+ let consoleWarnSpy: typeof console.warn
+ const warnCalls: unknown[][] = []
+
+ beforeEach(() => {
+ consoleWarnSpy = console.warn
+ console.warn = (...args: unknown[]) => warnCalls.push(args)
+ warnCalls.length = 0
+ })
+
+ afterEach(() => {
+ console.warn = consoleWarnSpy
+ })
+
+ // -------------------------------------------------------------------------
+ // Req #25 - SSEConfig accepts schema prop
+ // -------------------------------------------------------------------------
+
+ describe('Req #25 - SSEConfig accepts schema prop', () => {
+ it('SSEProvider should render children when given schema instead of events', () => {
+ const html = renderToString(
+ createElement(
+ SSEProvider,
+ { config: { url: '/api/events', schema: userSchema } as SSEConfig },
+ createElement('div', null, 'child rendered'),
+ ),
+ )
+ expect(html).toContain('child rendered')
+ })
+
+ it('SSEConfig should accept a schema property without events', () => {
+ // This is primarily a type-level assertion, but we verify it at runtime
+ // by ensuring SSEProvider does not throw when schema is provided
+ expect(() => {
+ renderToString(
+ createElement(
+ SSEProvider,
+ {
+ config: {
+ url: '/api/events',
+ schema: userSchema,
+ } as SSEConfig,
+ },
+ createElement('span', null, 'ok'),
+ ),
+ )
+ }).not.toThrow()
+ })
+
+ it('SSEConfig should still accept events without schema (backward compat)', () => {
+ expect(() => {
+ renderToString(
+ createElement(
+ SSEProvider,
+ {
+ config: {
+ url: '/api/events',
+ events: { 'user.updated': { key: '/api/users' } },
+ },
+ },
+ createElement('span', null, 'ok'),
+ ),
+ )
+ }).not.toThrow()
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Req #26 - Auto-derived events from schema
+ // -------------------------------------------------------------------------
+
+ describe('Req #26 - auto-derived events from schema', () => {
+ it('context config.events should contain keys from the schema', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: userSchema,
+ } as SSEConfig)
+
+ expect(ctx).not.toBeNull()
+ const events = (ctx as NonNullable).config.events
+ expect(events).toBeDefined()
+ expect(Object.keys(events)).toContain('user.updated')
+ })
+
+ it('derived event mapping should have the correct key from schema', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: userSchema,
+ } as SSEConfig)
+
+ const events = (ctx as NonNullable).config.events
+ expect(events['user.updated']?.key).toBe('/api/users')
+ })
+
+ it('derived event mapping should have the correct update strategy from schema', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: userSchema,
+ } as SSEConfig)
+
+ const events = (ctx as NonNullable).config.events
+ expect(events['user.updated']?.update).toBe('set')
+ })
+
+ it('derived event mapping should include filter from schema', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: filterTransformSchema,
+ } as SSEConfig)
+
+ const events = (ctx as NonNullable).config.events
+ expect(typeof events['item.added']?.filter).toBe('function')
+ })
+
+ it('derived filter should behave correctly', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: filterTransformSchema,
+ } as SSEConfig)
+
+ const filter = (ctx as NonNullable).config.events[
+ 'item.added'
+ ]?.filter as ((p: { active: boolean }) => boolean) | undefined
+
+ expect(filter?.({ active: true })).toBe(true)
+ expect(filter?.({ active: false })).toBe(false)
+ })
+
+ it('derived event mapping should include transform from schema', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: filterTransformSchema,
+ } as SSEConfig)
+
+ const events = (ctx as NonNullable).config.events
+ expect(typeof events['item.added']?.transform).toBe('function')
+ })
+
+ it('derived transform should behave correctly', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: filterTransformSchema,
+ } as SSEConfig)
+
+ const transform = (ctx as NonNullable).config.events[
+ 'item.added'
+ ]?.transform as ((p: { name: string }) => { name: string }) | undefined
+
+ expect(transform?.({ name: 'alice' })).toEqual({ name: 'ALICE' })
+ })
+
+ it('derived event should use "refetch" update strategy when schema specifies it', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: orderSchema,
+ } as SSEConfig)
+
+ const events = (ctx as NonNullable).config.events
+ expect(events['order.placed']?.update).toBe('refetch')
+ })
+
+ it('function key from schema should be preserved in derived events', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: orderSchema,
+ } as SSEConfig)
+
+ const keyFn = (ctx as NonNullable).config.events[
+ 'order.placed'
+ ]?.key
+
+ expect(typeof keyFn).toBe('function')
+ expect((keyFn as (p: { id: string }) => string)({ id: '99' })).toBe(
+ '/api/orders/99',
+ )
+ })
+
+ it('array key from schema should be preserved in derived events', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: multiSchema,
+ } as SSEConfig)
+
+ const events = (ctx as NonNullable).config.events
+ expect(events['item.deleted']?.key).toEqual(['/api/items', '/api/cache'])
+ })
+
+ it('all events from a multi-event schema should be in derived mapping', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: multiSchema,
+ } as SSEConfig)
+
+ const events = (ctx as NonNullable).config.events
+ expect(Object.keys(events)).toContain('user.updated')
+ expect(Object.keys(events)).toContain('order.placed')
+ expect(Object.keys(events)).toContain('item.deleted')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Req #27 - Mutual exclusivity (runtime: schema takes precedence)
+ // -------------------------------------------------------------------------
+
+ describe('Req #27 - schema takes precedence over events at runtime', () => {
+ it('when both schema and events are provided, schema events should be used', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: userSchema,
+ events: { 'unrelated.event': { key: '/api/other' } },
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately bypass type check to test runtime fallback
+ } as any)
+
+ const events = (ctx as NonNullable).config.events
+ // Schema-derived events take precedence
+ expect(Object.keys(events)).toContain('user.updated')
+ })
+
+ it('when both schema and events are provided in debug mode, a warning should be logged', () => {
+ renderToString(
+ createElement(
+ SSEProvider,
+ {
+ config: {
+ url: '/api/events',
+ schema: userSchema,
+ events: { 'unrelated.event': { key: '/api/other' } },
+ debug: true,
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately bypass type check to test runtime fallback
+ } as any,
+ },
+ createElement('span', null, 'ok'),
+ ),
+ )
+
+ // A warning should have been logged about conflict
+ const warned = warnCalls.some((args) =>
+ args.some(
+ (a) => typeof a === 'string' && a.toLowerCase().includes('schema'),
+ ),
+ )
+ expect(warned).toBe(true)
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Req #29 - parseEvent remains configurable with schema
+ // -------------------------------------------------------------------------
+
+ describe('Req #29 - parseEvent configurable alongside schema', () => {
+ it('SSEConfig with schema should still accept parseEvent', () => {
+ const customParseEvent = (event: MessageEvent) => ({
+ type: 'user.updated',
+ payload: JSON.parse(event.data),
+ })
+
+ expect(() => {
+ renderToString(
+ createElement(
+ SSEProvider,
+ {
+ config: {
+ url: '/api/events',
+ schema: userSchema,
+ parseEvent: customParseEvent,
+ } as SSEConfig,
+ },
+ createElement('span', null, 'ok'),
+ ),
+ )
+ }).not.toThrow()
+ })
+
+ it('custom parseEvent is preserved in context config when schema is used', () => {
+ const customParseEvent = (event: MessageEvent) => ({
+ type: 'user.updated',
+ payload: JSON.parse(event.data),
+ })
+
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: userSchema,
+ parseEvent: customParseEvent,
+ } as SSEConfig)
+
+ expect((ctx as NonNullable).config.parseEvent).toBe(
+ customParseEvent,
+ )
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Empty schema edge case
+ // -------------------------------------------------------------------------
+
+ describe('empty schema produces empty events mapping', () => {
+ it('defineSchema({}) as schema prop should produce empty events', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: emptySchema,
+ } as SSEConfig)
+
+ const events = (ctx as NonNullable).config.events
+ expect(Object.keys(events)).toHaveLength(0)
+ })
+
+ it('SSEProvider should render successfully with an empty schema', () => {
+ const html = renderToString(
+ createElement(
+ SSEProvider,
+ {
+ config: {
+ url: '/api/events',
+ schema: emptySchema,
+ } as SSEConfig,
+ },
+ createElement('span', null, 'rendered'),
+ ),
+ )
+ expect(html).toContain('rendered')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Req #30 - No regressions: existing events-based config still works
+ // -------------------------------------------------------------------------
+
+ describe('Req #30 - no regressions from existing SSEProvider behavior', () => {
+ it('events-only config still provides correct events in context', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ events: {
+ 'user.updated': { key: '/api/users', update: 'set' },
+ },
+ })
+
+ const events = (ctx as NonNullable).config.events
+ expect(events['user.updated']?.key).toBe('/api/users')
+ })
+
+ it('SSEProvider with events-only config still renders children', () => {
+ const html = renderToString(
+ createElement(
+ SSEProvider,
+ {
+ config: {
+ url: '/api/events',
+ events: { 'user.updated': { key: '/api/users' } },
+ },
+ },
+ createElement('div', null, 'children ok'),
+ ),
+ )
+ expect(html).toContain('children ok')
+ })
+
+ it('initial status is correct regardless of schema or events usage', () => {
+ let status: ReturnType['status'] | null = null
+
+ function Capture() {
+ const ctx = useSSEContext()
+ status = ctx.status
+ return createElement('span', null, 'ok')
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ {
+ config: { url: '/api/events', schema: userSchema } as SSEConfig,
+ },
+ createElement(Capture),
+ ),
+ )
+
+ expect(status).not.toBeNull()
+ expect((status as NonNullable).connected).toBe(false)
+ expect((status as NonNullable).connecting).toBe(true)
+ expect((status as NonNullable).error).toBeNull()
+ })
+
+ it('useSSEContext still throws when used outside provider with schema config', () => {
+ function Orphan() {
+ useSSEContext()
+ return createElement('span', null, 'bad')
+ }
+
+ expect(() => renderToString(createElement(Orphan))).toThrow()
+ })
+
+ it('subscribe is still available in context when using schema', () => {
+ let subscribeType: string | null = null
+
+ function Capture() {
+ const ctx = useSSEContext()
+ subscribeType = typeof ctx.subscribe
+ return createElement('span', null, 'ok')
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ {
+ config: { url: '/api/events', schema: userSchema } as SSEConfig,
+ },
+ createElement(Capture),
+ ),
+ )
+
+ expect(subscribeType).toBe('function')
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // Derived events match EventMapping shape
+ // -------------------------------------------------------------------------
+
+ describe('derived events mapping matches EventMapping shape', () => {
+ it('derived event should have a key property', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: userSchema,
+ } as SSEConfig)
+
+ const event = (ctx as NonNullable).config.events[
+ 'user.updated'
+ ]
+ expect(event).toBeDefined()
+ expect('key' in (event as object)).toBe(true)
+ })
+
+ it('derived event should have an update property (defaulted to "set")', () => {
+ const schemaWithDefault = defineSchema({
+ ping: { key: '/api/ping' }, // no explicit update
+ })
+
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: schemaWithDefault,
+ } as SSEConfig)
+
+ const event = (ctx as NonNullable).config.events.ping
+ expect(event).toBeDefined()
+ expect((event as Record).update).toBe('set')
+ })
+
+ it('derived event without filter should have undefined filter', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: userSchema,
+ } as SSEConfig)
+
+ const event = (ctx as NonNullable).config.events[
+ 'user.updated'
+ ]
+ expect((event as Record).filter).toBeUndefined()
+ })
+
+ it('derived event without transform should have undefined transform', () => {
+ const ctx = captureContext({
+ url: '/api/events',
+ schema: userSchema,
+ } as SSEConfig)
+
+ const event = (ctx as NonNullable).config.events[
+ 'user.updated'
+ ]
+ expect((event as Record).transform).toBeUndefined()
+ })
+ })
+})
diff --git a/src/__tests__/SSEProvider-transport.test.tsx b/src/__tests__/SSEProvider-transport.test.tsx
new file mode 100644
index 0000000..2dfba2e
--- /dev/null
+++ b/src/__tests__/SSEProvider-transport.test.tsx
@@ -0,0 +1,1335 @@
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+import { createElement } from 'react'
+import { renderToString } from 'react-dom/server'
+import { SSEProvider, useSSEContext } from '../SSEProvider.tsx'
+import type {
+ ParsedEvent,
+ SSEConfig,
+ SSEStatus,
+ SSETransport,
+} from '../types.ts'
+
+/**
+ * Tests for transport selection integration in SSEProvider (WI-220).
+ *
+ * These tests verify:
+ * 1. Default behavior (no transport config) uses EventSource (backward compat)
+ * 2. With method/body/headers in config -> uses createFetchTransport
+ * 3. With custom transport factory in config -> uses that transport
+ * 4. Body without method -> defaults to POST
+ * 5. Custom transport factory that throws -> error caught, reported via onEventError
+ * 6. Event routing works with non-EventSource transport (onmessage, named events)
+ * 7. Reconnection works uniformly for all transport types
+ * 8. Visibility change handler works with new transport types
+ * 9. Cleanup on unmount works for all transport types
+ * 10. All existing SSEProvider behavior unchanged
+ *
+ * NOTE: This file does NOT use mock.module to avoid permanently replacing
+ * the fetchTransport module in bun's process-wide cache, which would break
+ * other test files (e.g., fetchTransport.test.ts) that import the real module.
+ * Instead, we use globalThis.fetch mocking for selection tests and
+ * config.transport factories for behavior tests.
+ */
+
+// --- Mock EventSource ---
+
+class MockEventSource {
+ static instances: MockEventSource[] = []
+ static connectionAttempts = 0
+
+ url: string
+ readyState = 0 // CONNECTING
+ onmessage: ((event: MessageEvent) => void) | null = null
+ onerror: ((event: Event) => void) | null = null
+ onopen: ((event: Event) => void) | null = null
+
+ private eventListeners: Map void>> =
+ new Map()
+
+ constructor(url: string) {
+ this.url = url
+ MockEventSource.instances.push(this)
+ MockEventSource.connectionAttempts++
+ }
+
+ close() {
+ this.readyState = 2 // CLOSED
+ }
+
+ addEventListener(type: string, listener: (event: MessageEvent) => void) {
+ if (!this.eventListeners.has(type)) {
+ this.eventListeners.set(type, new Set())
+ }
+ const listeners = this.eventListeners.get(type)
+ if (listeners) {
+ listeners.add(listener)
+ }
+ }
+
+ removeEventListener(type: string, listener: (event: MessageEvent) => void) {
+ const listeners = this.eventListeners.get(type)
+ if (listeners) {
+ listeners.delete(listener)
+ }
+ }
+
+ dispatchEvent() {
+ return true
+ }
+
+ simulateOpen() {
+ this.readyState = 1 // OPEN
+ this.onopen?.(new Event('open'))
+ }
+
+ simulateMessage(data: string) {
+ if (this.onmessage) {
+ this.onmessage(new MessageEvent('message', { data }))
+ }
+ }
+
+ simulateNamedEvent(eventType: string, data: string) {
+ const listeners = this.eventListeners.get(eventType)
+ if (listeners) {
+ const event = new MessageEvent(eventType, { data })
+ for (const listener of listeners) {
+ listener(event)
+ }
+ }
+ }
+
+ simulateConnectionFailure() {
+ this.readyState = 2 // CLOSED
+ this.onerror?.(new Event('error'))
+ }
+
+ getRegisteredEventTypes(): string[] {
+ return Array.from(this.eventListeners.keys())
+ }
+
+ static reset() {
+ MockEventSource.instances = []
+ MockEventSource.connectionAttempts = 0
+ }
+
+ static getLastInstance(): MockEventSource | undefined {
+ return MockEventSource.instances[MockEventSource.instances.length - 1]
+ }
+
+ static get CONNECTING() {
+ return 0
+ }
+ static get OPEN() {
+ return 1
+ }
+ static get CLOSED() {
+ return 2
+ }
+}
+
+// --- Mock SSETransport (for custom transport factory tests) ---
+
+function createMockTransport(): SSETransport & {
+ simulateOpen: () => void
+ simulateMessage: (data: string) => void
+ simulateNamedEvent: (eventType: string, data: string) => void
+ simulateConnectionFailure: () => void
+ _closed: boolean
+ _eventListeners: Map void>>
+} {
+ const eventListeners = new Map void>>()
+ let readyState = 0 // CONNECTING
+ let closed = false
+
+ const transport = {
+ onmessage: null as ((event: MessageEvent) => void) | null,
+ onerror: null as ((event: Event) => void) | null,
+ onopen: null as ((event: Event) => void) | null,
+
+ get readyState() {
+ return readyState
+ },
+
+ close() {
+ readyState = 2
+ closed = true
+ },
+
+ addEventListener(type: string, listener: (event: MessageEvent) => void) {
+ let set = eventListeners.get(type)
+ if (!set) {
+ set = new Set()
+ eventListeners.set(type, set)
+ }
+ set.add(listener)
+ },
+
+ removeEventListener(type: string, listener: (event: MessageEvent) => void) {
+ const set = eventListeners.get(type)
+ if (set) {
+ set.delete(listener)
+ }
+ },
+
+ // Test helpers
+ simulateOpen() {
+ readyState = 1
+ transport.onopen?.(new Event('open'))
+ },
+
+ simulateMessage(data: string) {
+ transport.onmessage?.(new MessageEvent('message', { data }))
+ },
+
+ simulateNamedEvent(eventType: string, data: string) {
+ const listeners = eventListeners.get(eventType)
+ if (listeners) {
+ const event = new MessageEvent(eventType, { data })
+ for (const listener of listeners) {
+ listener(event)
+ }
+ }
+ },
+
+ simulateConnectionFailure() {
+ readyState = 2
+ transport.onerror?.(new Event('error'))
+ },
+
+ get _closed() {
+ return closed
+ },
+
+ get _eventListeners() {
+ return eventListeners
+ },
+ }
+
+ return transport
+}
+
+// --- Fetch mock for transport selection tests ---
+
+const originalFetch = globalThis.fetch
+let fetchCalls: Array<{ url: string; init?: RequestInit }> = []
+let openStreamControllers: ReadableStreamDefaultController[] = []
+
+function installFetchMock() {
+ fetchCalls = []
+ openStreamControllers = []
+ globalThis.fetch = (async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ) => {
+ const url =
+ typeof input === 'string'
+ ? input
+ : input instanceof URL
+ ? input.toString()
+ : input.url
+ fetchCalls.push({ url, init })
+
+ // Return a Response with an open ReadableStream (mimics SSE connection)
+ const stream = new ReadableStream({
+ start(controller) {
+ openStreamControllers.push(controller)
+ },
+ })
+ return new Response(stream, {
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+ }) as typeof globalThis.fetch
+}
+
+function cleanupFetchMock() {
+ // Intentionally do NOT close stream controllers here. Closing them triggers
+ // a "done" signal on the fetch transport's reader, which fires onerror,
+ // which triggers SSEProvider's reconnection logic. By the time that async
+ // chain runs, afterEach has already restored real setTimeout/fetch, so the
+ // reconnection would call the real (or next test's) fetch — causing
+ // cross-test contamination. Leaving streams open lets GC handle them.
+ openStreamControllers = []
+ fetchCalls = []
+ globalThis.fetch = originalFetch
+}
+
+// Store original globals
+const originalEventSource = globalThis.EventSource
+const originalSetTimeout = globalThis.setTimeout
+const originalClearTimeout = globalThis.clearTimeout
+const originalDocument = globalThis.document
+
+// Fake timers
+let pendingTimers: Map<
+ number,
+ { callback: () => void; delay: number; scheduledAt: number }
+> = new Map()
+let timerIdCounter = 1
+let currentTime = 0
+
+function fakeSetTimeout(callback: () => void, delay: number): number {
+ const id = timerIdCounter++
+ pendingTimers.set(id, { callback, delay, scheduledAt: currentTime })
+ return id
+}
+
+function fakeClearTimeout(id: number): void {
+ pendingTimers.delete(id)
+}
+
+function advanceTimersByTime(ms: number) {
+ const targetTime = currentTime + ms
+
+ while (true) {
+ let nextTimer: { id: number; fireAt: number } | null = null
+
+ for (const [id, timer] of pendingTimers) {
+ const fireAt = timer.scheduledAt + timer.delay
+ if (fireAt <= targetTime) {
+ if (!nextTimer || fireAt < nextTimer.fireAt) {
+ nextTimer = { id, fireAt }
+ }
+ }
+ }
+
+ if (!nextTimer) break
+
+ currentTime = nextTimer.fireAt
+ const timer = pendingTimers.get(nextTimer.id)
+ pendingTimers.delete(nextTimer.id)
+ if (timer) {
+ timer.callback()
+ }
+ }
+
+ currentTime = targetTime
+}
+
+function resetTimers() {
+ pendingTimers = new Map()
+ timerIdCounter = 1
+ currentTime = 0
+}
+
+// Mock document for visibility testing
+let visibilityChangeListeners: Set<(event: Event) => void> = new Set()
+let mockVisibilityState: DocumentVisibilityState = 'visible'
+
+function createMockDocument() {
+ return {
+ get visibilityState() {
+ return mockVisibilityState
+ },
+ addEventListener(type: string, listener: (event: Event) => void) {
+ if (type === 'visibilitychange') {
+ visibilityChangeListeners.add(listener)
+ }
+ },
+ removeEventListener(type: string, listener: (event: Event) => void) {
+ if (type === 'visibilitychange') {
+ visibilityChangeListeners.delete(listener)
+ }
+ },
+ }
+}
+
+function dispatchVisibilityChange(state: DocumentVisibilityState) {
+ mockVisibilityState = state
+ const event = new Event('visibilitychange')
+ for (const listener of visibilityChangeListeners) {
+ listener(event)
+ }
+}
+
+beforeEach(() => {
+ // @ts-expect-error - Mocking EventSource
+ globalThis.EventSource = MockEventSource
+ MockEventSource.reset()
+
+ // Install fetch mock
+ installFetchMock()
+
+ // Install fake timers
+ // @ts-expect-error - Mocking setTimeout
+ globalThis.setTimeout = fakeSetTimeout
+ // @ts-expect-error - Mocking clearTimeout
+ globalThis.clearTimeout = fakeClearTimeout
+ resetTimers()
+
+ // Reset visibility state
+ mockVisibilityState = 'visible'
+ visibilityChangeListeners = new Set()
+
+ // Install mock document
+ // @ts-expect-error - Mocking document
+ globalThis.document = createMockDocument()
+})
+
+afterEach(() => {
+ cleanupFetchMock()
+ globalThis.EventSource = originalEventSource
+ globalThis.setTimeout = originalSetTimeout
+ globalThis.clearTimeout = originalClearTimeout
+ // @ts-expect-error - Restoring document
+ globalThis.document = originalDocument
+})
+
+/**
+ * Helper to flush microtasks so that async operations settle.
+ */
+async function flushMicrotasks(): Promise {
+ await new Promise((resolve) => originalSetTimeout(resolve, 0))
+}
+
+describe('SSEProvider Transport Selection', () => {
+ describe('default behavior (backward compatibility)', () => {
+ it('should use EventSource when no transport config fields are set', async () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {
+ 'user.updated': { key: '/api/user' },
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ // Should have created an EventSource instance
+ expect(MockEventSource.instances.length).toBe(1)
+ expect(MockEventSource.instances[0].url).toBe(
+ 'http://localhost:3000/events',
+ )
+
+ // Should NOT have called fetch (no fetch transport)
+ await flushMicrotasks()
+ expect(fetchCalls.length).toBe(0)
+ })
+
+ it('should continue to work with all existing SSEProvider features', async () => {
+ const receivedPayloads: unknown[] = []
+ let onConnectCalled = false
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {
+ 'user.updated': { key: '/api/user' },
+ },
+ onConnect: () => {
+ onConnectCalled = true
+ },
+ }
+
+ function EventSubscriber() {
+ const ctx = useSSEContext()
+ ctx.subscribe('user.updated', (payload) => {
+ receivedPayloads.push(payload)
+ })
+ return createElement('div', null, 'subscriber')
+ }
+
+ renderToString(
+ createElement(SSEProvider, { config }, createElement(EventSubscriber)),
+ )
+
+ const source = MockEventSource.instances[0]
+ source.simulateOpen()
+ expect(onConnectCalled).toBe(true)
+
+ source.simulateMessage(
+ JSON.stringify({ type: 'user.updated', payload: { id: 1 } }),
+ )
+
+ await new Promise((resolve) => queueMicrotask(resolve))
+ expect(receivedPayloads.length).toBe(1)
+ expect(receivedPayloads[0]).toEqual({ id: 1 })
+ })
+ })
+
+ describe('fetch transport selection via method/body/headers', () => {
+ // These tests verify SSEProvider selects createFetchTransport (not EventSource)
+ // when method/body/headers are present. We verify by checking:
+ // 1. No EventSource was created (MockEventSource.instances.length === 0)
+ // 2. The component rendered without error
+ // Parameter-passing to fetch() is covered by fetchTransport.test.ts.
+ //
+ // Note: We do NOT assert on globalThis.fetch calls here because Bun on Linux
+ // may optimize bare `fetch` calls to bypass globalThis.fetch replacement.
+
+ it('should use createFetchTransport when method is specified', () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {
+ 'user.updated': { key: '/api/user' },
+ },
+ method: 'POST',
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ // Should NOT have created an EventSource — proves fetch transport was selected
+ expect(MockEventSource.instances.length).toBe(0)
+ })
+
+ it('should use createFetchTransport when body is specified', () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ body: { query: 'SELECT * FROM events' },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ expect(MockEventSource.instances.length).toBe(0)
+ })
+
+ it('should use createFetchTransport when headers are specified', () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ headers: { Authorization: 'Bearer token123' },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ expect(MockEventSource.instances.length).toBe(0)
+ })
+
+ it('should use createFetchTransport when multiple fetch fields are specified', () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ method: 'PUT',
+ body: { subscribe: ['user.updated'] },
+ headers: {
+ Authorization: 'Bearer abc',
+ 'X-Custom': 'value',
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ expect(MockEventSource.instances.length).toBe(0)
+ })
+
+ it('should use createFetchTransport when body is provided without method', () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ body: { query: 'test' },
+ // method is intentionally omitted — createFetchTransport defaults to POST
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ // Should still select fetch transport (not EventSource)
+ expect(MockEventSource.instances.length).toBe(0)
+ })
+ })
+
+ describe('custom transport factory', () => {
+ it('should use custom transport factory when provided', async () => {
+ const mockTransport = createMockTransport()
+ let factoryCalled = false
+ let factoryUrl = ''
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {
+ 'user.updated': { key: '/api/user' },
+ },
+ transport: (url: string) => {
+ factoryCalled = true
+ factoryUrl = url
+ return mockTransport
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ // Should NOT have used EventSource
+ expect(MockEventSource.instances.length).toBe(0)
+
+ // Should NOT have called fetch
+ await flushMicrotasks()
+ expect(fetchCalls.length).toBe(0)
+
+ // Should have used custom factory
+ expect(factoryCalled).toBe(true)
+ expect(factoryUrl).toBe('http://localhost:3000/events')
+ })
+
+ it('should prefer custom transport over method/body/headers', async () => {
+ const mockTransport = createMockTransport()
+ let factoryCalled = false
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ method: 'POST',
+ body: { query: 'test' },
+ headers: { Authorization: 'Bearer token' },
+ transport: (_url: string) => {
+ factoryCalled = true
+ return mockTransport
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ // Custom transport takes priority
+ expect(factoryCalled).toBe(true)
+ expect(MockEventSource.instances.length).toBe(0)
+ await flushMicrotasks()
+ expect(fetchCalls.length).toBe(0)
+ })
+
+ it('should catch errors from custom transport factory and report via onEventError', () => {
+ const errorsCaught: Array<{ event: ParsedEvent; error: unknown }> = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ throw new Error('Transport factory failed')
+ },
+ onEventError: (event: ParsedEvent, error: unknown) => {
+ errorsCaught.push({ event, error })
+ },
+ }
+
+ // Should not throw during render
+ expect(() => {
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+ }).not.toThrow()
+
+ // Should have reported the error via onEventError
+ expect(errorsCaught.length).toBeGreaterThanOrEqual(1)
+ expect(errorsCaught[0].error).toBeInstanceOf(Error)
+ expect((errorsCaught[0].error as Error).message).toBe(
+ 'Transport factory failed',
+ )
+ })
+ })
+
+ describe('event routing with non-EventSource transport', () => {
+ it('should route onmessage events through processEvent', async () => {
+ const receivedPayloads: unknown[] = []
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {
+ 'user.updated': { key: '/api/user' },
+ },
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ }
+
+ function EventSubscriber() {
+ const ctx = useSSEContext()
+ ctx.subscribe('user.updated', (payload) => {
+ receivedPayloads.push(payload)
+ })
+ return createElement('div', null, 'subscriber')
+ }
+
+ renderToString(
+ createElement(SSEProvider, { config }, createElement(EventSubscriber)),
+ )
+
+ expect(transports.length).toBe(1)
+ const transport = transports[0]
+
+ // Simulate connection open
+ transport.simulateOpen()
+
+ // Simulate a generic message through the transport
+ transport.simulateMessage(
+ JSON.stringify({ type: 'user.updated', payload: { id: 42 } }),
+ )
+
+ await new Promise((resolve) => queueMicrotask(resolve))
+
+ expect(receivedPayloads.length).toBe(1)
+ expect(receivedPayloads[0]).toEqual({ id: 42 })
+ })
+
+ it('should route named events through addEventListener on transport', async () => {
+ const receivedPayloads: unknown[] = []
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {
+ 'order:updated': { key: '/api/orders' },
+ },
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ }
+
+ function EventSubscriber() {
+ const ctx = useSSEContext()
+ ctx.subscribe('order:updated', (payload) => {
+ receivedPayloads.push(payload)
+ })
+ return createElement('div', null, 'subscriber')
+ }
+
+ renderToString(
+ createElement(SSEProvider, { config }, createElement(EventSubscriber)),
+ )
+
+ const transport = transports[0]
+ transport.simulateOpen()
+
+ // Named event through the transport
+ transport.simulateNamedEvent(
+ 'order:updated',
+ JSON.stringify({ orderId: 99, status: 'shipped' }),
+ )
+
+ await new Promise((resolve) => queueMicrotask(resolve))
+
+ expect(receivedPayloads.length).toBe(1)
+ expect(receivedPayloads[0]).toEqual({ orderId: 99, status: 'shipped' })
+ })
+
+ it('should route events through custom transport the same way', async () => {
+ const mockTransport = createMockTransport()
+ const receivedPayloads: unknown[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {
+ 'data:update': { key: '/api/data' },
+ },
+ transport: (_url: string) => mockTransport,
+ }
+
+ function EventSubscriber() {
+ const ctx = useSSEContext()
+ ctx.subscribe('data:update', (payload) => {
+ receivedPayloads.push(payload)
+ })
+ return createElement('div', null, 'subscriber')
+ }
+
+ renderToString(
+ createElement(SSEProvider, { config }, createElement(EventSubscriber)),
+ )
+
+ mockTransport.simulateOpen()
+ mockTransport.simulateNamedEvent(
+ 'data:update',
+ JSON.stringify({ value: 100 }),
+ )
+
+ await new Promise((resolve) => queueMicrotask(resolve))
+
+ expect(receivedPayloads.length).toBe(1)
+ expect(receivedPayloads[0]).toEqual({ value: 100 })
+ })
+ })
+
+ describe('lifecycle callbacks with non-EventSource transport', () => {
+ it('should invoke onConnect when transport opens', () => {
+ let onConnectCalled = false
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ onConnect: () => {
+ onConnectCalled = true
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ const transport = transports[0]
+ transport.simulateOpen()
+
+ expect(onConnectCalled).toBe(true)
+ })
+
+ it('should invoke onError when transport errors', () => {
+ let onErrorCalled = false
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ onError: () => {
+ onErrorCalled = true
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ const transport = transports[0]
+ transport.simulateConnectionFailure()
+
+ expect(onErrorCalled).toBe(true)
+ })
+
+ it('should invoke onDisconnect when transport connection closes', () => {
+ let disconnectCount = 0
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ onDisconnect: () => {
+ disconnectCount++
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ const transport = transports[0]
+ transport.simulateOpen()
+ transport.simulateConnectionFailure()
+
+ expect(disconnectCount).toBeGreaterThanOrEqual(1)
+ })
+
+ // NOTE: This test relies on SSEProvider's intentional design of using a mutable
+ // status object (Object.assign on statusRef.current) for SSR compatibility.
+ // capturedStatus holds a reference to the same object that SSEProvider mutates,
+ // so changes after renderToString are visible through the captured reference.
+ // If SSEProvider switches to immutable state, this test must be rewritten
+ // to use a DOM-based renderer (e.g., @testing-library/react) instead.
+ it('should update status correctly with non-EventSource transport', () => {
+ let capturedStatus: SSEStatus | null = null
+ const transports: ReturnType[] = []
+
+ function StatusCapture() {
+ const ctx = useSSEContext()
+ capturedStatus = ctx.status
+ return createElement('div', null, 'status')
+ }
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ }
+
+ renderToString(
+ createElement(SSEProvider, { config }, createElement(StatusCapture)),
+ )
+
+ // Initially connecting
+ expect(capturedStatus?.connecting).toBe(true)
+ expect(capturedStatus?.connected).toBe(false)
+
+ // After open
+ const transport = transports[0]
+ transport.simulateOpen()
+ expect(capturedStatus?.connected).toBe(true)
+ expect(capturedStatus?.connecting).toBe(false)
+ expect(capturedStatus?.error).toBeNull()
+ })
+ })
+
+ describe('reconnection with non-EventSource transport', () => {
+ it('should reconnect transport after connection failure', () => {
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ reconnect: {
+ enabled: true,
+ initialDelay: 1000,
+ maxAttempts: 5,
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ expect(transports.length).toBe(1)
+
+ // Simulate connection failure
+ transports[0].simulateConnectionFailure()
+
+ // Advance timer for reconnect
+ advanceTimersByTime(1000)
+
+ // Should create a new transport (not EventSource)
+ expect(transports.length).toBe(2)
+ expect(MockEventSource.instances.length).toBe(0)
+ })
+
+ it('should use exponential backoff for transport reconnections', () => {
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ reconnect: {
+ enabled: true,
+ initialDelay: 1000,
+ backoffMultiplier: 2,
+ maxDelay: 30000,
+ maxAttempts: 5,
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ // First failure -> 1s delay
+ transports[0].simulateConnectionFailure()
+ advanceTimersByTime(1000)
+ expect(transports.length).toBe(2)
+
+ // Second failure -> 2s delay
+ transports[1].simulateConnectionFailure()
+ advanceTimersByTime(1999)
+ expect(transports.length).toBe(2) // Not yet
+ advanceTimersByTime(1)
+ expect(transports.length).toBe(3)
+ })
+
+ it('should stop reconnecting after maxAttempts for transport', () => {
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ reconnect: {
+ enabled: true,
+ initialDelay: 1000,
+ maxAttempts: 2,
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ // Initial (attempt 0)
+ transports[0].simulateConnectionFailure()
+ advanceTimersByTime(1000)
+ expect(transports.length).toBe(2)
+
+ // Second failure -> should NOT reconnect (maxAttempts reached)
+ transports[1].simulateConnectionFailure()
+ advanceTimersByTime(10000)
+ expect(transports.length).toBe(2)
+ })
+
+ it('should reconnect custom transport after failure', () => {
+ let transportCreateCount = 0
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ transportCreateCount++
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ reconnect: {
+ enabled: true,
+ initialDelay: 1000,
+ maxAttempts: 5,
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ expect(transportCreateCount).toBe(1)
+
+ // Simulate failure
+ transports[0].simulateConnectionFailure()
+ advanceTimersByTime(1000)
+
+ // Should have created a new transport via the factory
+ expect(transportCreateCount).toBe(2)
+ expect(MockEventSource.instances.length).toBe(0)
+ })
+ })
+
+ describe('visibility change with non-EventSource transport', () => {
+ it('should reconnect transport when tab becomes visible after disconnect', () => {
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ reconnect: {
+ enabled: true,
+ initialDelay: 1000,
+ maxAttempts: 5,
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ expect(transports.length).toBe(1)
+
+ // Open then disconnect
+ transports[0].simulateOpen()
+ transports[0].simulateConnectionFailure()
+
+ // Clear pending reconnect timers
+ resetTimers()
+
+ // Tab hidden then visible
+ dispatchVisibilityChange('hidden')
+ dispatchVisibilityChange('visible')
+
+ // Should create a new transport
+ expect(transports.length).toBe(2)
+ expect(MockEventSource.instances.length).toBe(0)
+ })
+
+ it('should NOT reconnect transport if already connected', () => {
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ reconnect: {
+ enabled: true,
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ transports[0].simulateOpen()
+ expect(transports.length).toBe(1)
+
+ dispatchVisibilityChange('hidden')
+ dispatchVisibilityChange('visible')
+
+ expect(transports.length).toBe(1)
+ })
+
+ it('should reconnect custom transport on visibility change', () => {
+ let transportCreateCount = 0
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ transportCreateCount++
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ reconnect: {
+ enabled: true,
+ maxAttempts: 5,
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ expect(transportCreateCount).toBe(1)
+
+ transports[0].simulateOpen()
+ transports[0].simulateConnectionFailure()
+
+ resetTimers()
+ dispatchVisibilityChange('hidden')
+ dispatchVisibilityChange('visible')
+
+ expect(transportCreateCount).toBe(2)
+ })
+ })
+
+ describe('cleanup on unmount', () => {
+ it('should have close() on non-EventSource transport', () => {
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ expect(transports.length).toBe(1)
+ const transport = transports[0]
+
+ // Verify close method exists on the transport
+ expect(transport.close).toBeFunction()
+
+ // Note: In SSR (renderToString), useEffect cleanup doesn't run.
+ // We verify the transport has the close method that cleanup would call.
+ })
+
+ it('should call close() on custom transport when unmounting', () => {
+ const mockTransport = createMockTransport()
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => mockTransport,
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ // Verify close method exists
+ expect(mockTransport.close).toBeFunction()
+ })
+
+ it('should remove named event listeners on transport cleanup', () => {
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {
+ 'event:one': { key: '/api/one' },
+ 'event:two': { key: '/api/two' },
+ },
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ const transport = transports[0]
+
+ // Verify listeners were registered for each event type
+ expect(transport._eventListeners.has('event:one')).toBe(true)
+ expect(transport._eventListeners.has('event:two')).toBe(true)
+
+ // Verify removeEventListener method exists (cleanup would use it)
+ expect(transport.removeEventListener).toBeFunction()
+ })
+ })
+
+ describe('previous connection cleanup on transport switch', () => {
+ it('should close previous transport when creating a new one on reconnect', () => {
+ const transports: ReturnType[] = []
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => {
+ const t = createMockTransport()
+ transports.push(t)
+ return t
+ },
+ reconnect: {
+ enabled: true,
+ initialDelay: 1000,
+ maxAttempts: 5,
+ },
+ }
+
+ renderToString(
+ createElement(
+ SSEProvider,
+ { config },
+ createElement('div', null, 'child'),
+ ),
+ )
+
+ const firstTransport = transports[0]
+ firstTransport.simulateConnectionFailure()
+
+ advanceTimersByTime(1000)
+
+ // First transport should have been closed
+ expect(firstTransport._closed).toBe(true)
+
+ // New transport should be created
+ expect(transports.length).toBe(2)
+ })
+ })
+})
diff --git a/src/__tests__/build-pipeline.test.ts b/src/__tests__/build-pipeline.test.ts
new file mode 100644
index 0000000..e1dd923
--- /dev/null
+++ b/src/__tests__/build-pipeline.test.ts
@@ -0,0 +1,220 @@
+import { describe, expect, it } from 'bun:test'
+import { execSync } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+/**
+ * Build pipeline integration tests for reactiveSWR.
+ *
+ * These tests verify that the build pipeline is correctly configured to:
+ * 1. Include a "prepare" script for link: consumers
+ * 2. Export a "./server" subpath in package.json
+ * 3. Have a tsconfig.emit.json for declaration file generation
+ * 4. Produce complete JS + .d.ts output for all entry points
+ * 5. Not bundle peer dependencies (react, react-dom, swr)
+ *
+ * Tests FAIL initially because the build pipeline has not been fixed yet.
+ */
+
+const ROOT = join(import.meta.dir, '..', '..')
+
+function readPackageJson(): Record {
+ const raw = readFileSync(join(ROOT, 'package.json'), 'utf-8')
+ return JSON.parse(raw) as Record
+}
+
+function readTsConfigEmit(): Record {
+ const path = join(ROOT, 'tsconfig.emit.json')
+ if (!existsSync(path)) return {}
+ const raw = readFileSync(path, 'utf-8')
+ return JSON.parse(raw) as Record
+}
+
+describe('Build pipeline configuration', () => {
+ describe('Req #2 - prepare script', () => {
+ it('package.json should have a "prepare" script set to "bun run build"', () => {
+ const pkg = readPackageJson()
+ const scripts = pkg.scripts as Record | undefined
+
+ expect(scripts).toBeDefined()
+ expect(scripts?.prepare).toBe('bun run build')
+ })
+ })
+
+ describe('Req #4 - server subpath export', () => {
+ it('package.json exports should include a "./server" subpath', () => {
+ const pkg = readPackageJson()
+ const exports = pkg.exports as Record | undefined
+
+ expect(exports).toBeDefined()
+ expect(exports?.['./server']).toBeDefined()
+ })
+
+ it('package.json ./server export should have correct "import" path', () => {
+ const pkg = readPackageJson()
+ const exports = pkg.exports as
+ | Record>
+ | undefined
+ const serverExport = exports?.['./server']
+
+ expect(serverExport).toBeDefined()
+ expect(serverExport?.import).toBe('./dist/server/index.js')
+ })
+
+ it('package.json ./server export should have correct "types" path', () => {
+ const pkg = readPackageJson()
+ const exports = pkg.exports as
+ | Record>
+ | undefined
+ const serverExport = exports?.['./server']
+
+ expect(serverExport).toBeDefined()
+ expect(serverExport?.types).toBe('./dist/server/index.d.ts')
+ })
+ })
+
+ describe('Req #3 - tsconfig.emit.json', () => {
+ it('tsconfig.emit.json should exist at the project root', () => {
+ expect(existsSync(join(ROOT, 'tsconfig.emit.json'))).toBe(true)
+ })
+
+ it('tsconfig.emit.json should extend tsconfig.json', () => {
+ const config = readTsConfigEmit()
+
+ expect(config.extends).toBe('./tsconfig.json')
+ })
+
+ it('tsconfig.emit.json compilerOptions should have declaration: true', () => {
+ const config = readTsConfigEmit()
+ const opts = config.compilerOptions as Record
+
+ expect(opts).toBeDefined()
+ expect(opts.declaration).toBe(true)
+ })
+
+ it('tsconfig.emit.json compilerOptions should have emitDeclarationOnly: true', () => {
+ const config = readTsConfigEmit()
+ const opts = config.compilerOptions as Record
+
+ expect(opts).toBeDefined()
+ expect(opts.emitDeclarationOnly).toBe(true)
+ })
+
+ it('tsconfig.emit.json compilerOptions should have noEmit: false', () => {
+ const config = readTsConfigEmit()
+ const opts = config.compilerOptions as Record
+
+ expect(opts).toBeDefined()
+ expect(opts.noEmit).toBe(false)
+ })
+ })
+})
+
+describe('Build output verification', () => {
+ // Run the build once for the entire suite - the output of execSync is captured
+ // so test failures report the command output for diagnosis
+ let buildOutput: string
+ let buildError: string | null = null
+
+ try {
+ buildOutput = execSync('bun run build', {
+ cwd: ROOT,
+ encoding: 'utf-8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ })
+ } catch (err: unknown) {
+ buildError = String(
+ (err as { stderr?: string; stdout?: string }).stderr ??
+ (err as Error).message,
+ )
+ buildOutput = String((err as { stdout?: string }).stdout ?? '')
+ }
+
+ describe('Req #5 - bun run build succeeds', () => {
+ it('bun run build should exit without error', () => {
+ expect(buildError).toBeNull()
+ })
+ })
+
+ describe('Req #3 - JS output files', () => {
+ it('should produce dist/index.js (main entry)', () => {
+ expect(existsSync(join(ROOT, 'dist', 'index.js'))).toBe(true)
+ })
+
+ it('should produce dist/testing/index.js (testing entry)', () => {
+ expect(existsSync(join(ROOT, 'dist', 'testing', 'index.js'))).toBe(true)
+ })
+
+ it('should produce dist/server/index.js (server entry)', () => {
+ expect(existsSync(join(ROOT, 'dist', 'server', 'index.js'))).toBe(true)
+ })
+ })
+
+ describe('Req #3 - .d.ts output files', () => {
+ it('should produce dist/index.d.ts (main types)', () => {
+ expect(existsSync(join(ROOT, 'dist', 'index.d.ts'))).toBe(true)
+ })
+
+ it('should produce dist/testing/index.d.ts (testing types)', () => {
+ expect(existsSync(join(ROOT, 'dist', 'testing', 'index.d.ts'))).toBe(true)
+ })
+
+ it('should produce dist/server/index.d.ts (server types)', () => {
+ expect(existsSync(join(ROOT, 'dist', 'server', 'index.d.ts'))).toBe(true)
+ })
+ })
+
+ describe('Req #3 - peer dependencies are external (not bundled)', () => {
+ it('dist/index.js should not bundle "react" inline', () => {
+ if (!existsSync(join(ROOT, 'dist', 'index.js'))) {
+ // File does not exist yet - build hasn't produced it
+ expect(existsSync(join(ROOT, 'dist', 'index.js'))).toBe(true)
+ return
+ }
+ const content = readFileSync(join(ROOT, 'dist', 'index.js'), 'utf-8')
+ // A bundled react would contain "createElement" defined inline;
+ // an external reference keeps only import statements
+ expect(content).not.toMatch(/var react\s*=\s*\{/)
+ expect(content).not.toMatch(/function createElement\(/)
+ })
+
+ it('dist/index.js should not bundle "swr" inline', () => {
+ if (!existsSync(join(ROOT, 'dist', 'index.js'))) {
+ expect(existsSync(join(ROOT, 'dist', 'index.js'))).toBe(true)
+ return
+ }
+ const content = readFileSync(join(ROOT, 'dist', 'index.js'), 'utf-8')
+ // A bundled swr would define its internals; external keeps only imports
+ expect(content).not.toMatch(/var swr\s*=\s*\{/)
+ expect(content).not.toMatch(/"use-swr"/)
+ })
+
+ it('dist/index.js should import react as an external module', () => {
+ if (!existsSync(join(ROOT, 'dist', 'index.js'))) {
+ expect(existsSync(join(ROOT, 'dist', 'index.js'))).toBe(true)
+ return
+ }
+ const content = readFileSync(join(ROOT, 'dist', 'index.js'), 'utf-8')
+ // Should contain an import from "react" (external)
+ expect(content).toMatch(/from\s+["']react["']/)
+ })
+
+ it('dist/index.js should import swr as an external module', () => {
+ if (!existsSync(join(ROOT, 'dist', 'index.js'))) {
+ expect(existsSync(join(ROOT, 'dist', 'index.js'))).toBe(true)
+ return
+ }
+ const content = readFileSync(join(ROOT, 'dist', 'index.js'), 'utf-8')
+ // Should contain an import from "swr" (external)
+ expect(content).toMatch(/from\s+["']swr["']/)
+ })
+ })
+
+ // Expose build output for diagnosis without polluting test names
+ describe('Build output (diagnostic)', () => {
+ it('build stdout should be captured', () => {
+ // This test always passes - it exists to surface buildOutput in the reporter
+ expect(typeof buildOutput).toBe('string')
+ })
+ })
+})
diff --git a/src/__tests__/channel.test.ts b/src/__tests__/channel.test.ts
new file mode 100644
index 0000000..074cda9
--- /dev/null
+++ b/src/__tests__/channel.test.ts
@@ -0,0 +1,845 @@
+import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
+import { defineSchema } from '../schema.ts'
+
+/**
+ * Tests for createChannel() server-side SSE channel factory.
+ *
+ * Tests cover all acceptance criteria:
+ * 1. Factory returns channel object with connect/respond/emit/close methods
+ * 2. Web standard connect returns Response with correct SSE headers
+ * 3. Node.js connect writes correct headers to ServerResponse
+ * 4. Initial connected event sent on connect
+ * 5. Heartbeats sent at configured interval
+ * 6. Disconnect cleanup removes client from pool
+ * 7. channel.respond() returns scoped emitter NOT in broadcast pool
+ * 8. channel.emit() broadcasts SSE wire format to connected clients
+ * 9. channel.close() closes all connections and stops timers
+ * 10. channel.emit() with no clients is a no-op
+ * 11. channel.connect() after channel.close() throws
+ * 12. Heartbeat interval is configurable via options
+ *
+ * Tests FAIL initially because createChannel has not been implemented yet.
+ */
+
+// Helper: collect all SSE text chunks from a ReadableStream
+async function collectChunks(
+ stream: ReadableStream,
+ count: number,
+): Promise {
+ const decoder = new TextDecoder()
+ const reader = stream.getReader()
+ const chunks: string[] = []
+ for (let i = 0; i < count; i++) {
+ const { value, done } = await reader.read()
+ if (done) break
+ chunks.push(decoder.decode(value))
+ }
+ reader.cancel()
+ return chunks
+}
+
+// Minimal schema used across all tests
+const testSchema = defineSchema({
+ 'user.updated': { key: '/api/users', update: 'set' },
+ 'order.placed': {
+ key: (p: { id: string }) => `/api/orders/${p.id}`,
+ update: 'refetch',
+ },
+})
+
+describe('createChannel()', () => {
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic import before implementation
+ let createChannel: any
+
+ beforeEach(async () => {
+ // Re-import each time so implementation changes are picked up
+ const mod = await import('../server/index.ts')
+ createChannel = (mod as Record).createChannel
+ })
+
+ afterEach(() => {
+ // Nothing global to restore for server tests
+ })
+
+ describe('Req #11 - factory function signature', () => {
+ it('createChannel should be exported from src/server/index.ts', async () => {
+ const mod = await import('../server/index.ts')
+ expect((mod as Record).createChannel).toBeDefined()
+ expect(typeof (mod as Record).createChannel).toBe(
+ 'function',
+ )
+ })
+
+ it('createChannel(schema) should return an object', () => {
+ const channel = createChannel(testSchema)
+ expect(channel).toBeDefined()
+ expect(typeof channel).toBe('object')
+ })
+
+ it('returned channel should have connect method', () => {
+ const channel = createChannel(testSchema)
+ expect(typeof channel.connect).toBe('function')
+ })
+
+ it('returned channel should have respond method', () => {
+ const channel = createChannel(testSchema)
+ expect(typeof channel.respond).toBe('function')
+ })
+
+ it('returned channel should have emit method', () => {
+ const channel = createChannel(testSchema)
+ expect(typeof channel.emit).toBe('function')
+ })
+
+ it('returned channel should have close method', () => {
+ const channel = createChannel(testSchema)
+ expect(typeof channel.close).toBe('function')
+ })
+ })
+
+ describe('Req #12 + #13 - Web standard connect()', () => {
+ it('connect(request) should return a Response', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ expect(response).toBeInstanceOf(Response)
+
+ // Clean up the stream to avoid leaks
+ await response.body?.cancel()
+ channel.close()
+ })
+
+ it('connect(request) Response should have Content-Type: text/event-stream', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ expect(response.headers.get('Content-Type')).toContain(
+ 'text/event-stream',
+ )
+
+ await response.body?.cancel()
+ channel.close()
+ })
+
+ it('connect(request) Response should have Cache-Control: no-cache', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ expect(response.headers.get('Cache-Control')).toBe('no-cache')
+
+ await response.body?.cancel()
+ channel.close()
+ })
+
+ it('connect(request) Response should have Connection: keep-alive', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ expect(response.headers.get('Connection')).toBe('keep-alive')
+
+ await response.body?.cancel()
+ channel.close()
+ })
+
+ it('connect(request) Response body should be a ReadableStream', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ expect(response.body).toBeDefined()
+ expect(response.body).toBeInstanceOf(ReadableStream)
+
+ await response.body?.cancel()
+ channel.close()
+ })
+ })
+
+ describe('Req #14 - initial connected event', () => {
+ it('Web connect should send an initial event on open', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ const chunks = await collectChunks(response.body as ReadableStream, 1)
+ channel.close()
+
+ // Should receive at least one chunk immediately (the connected event)
+ expect(chunks.length).toBeGreaterThan(0)
+ expect(chunks[0]).toBeTruthy()
+ })
+
+ it('initial connected event should follow SSE format', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ const chunks = await collectChunks(response.body as ReadableStream, 1)
+ channel.close()
+
+ const text = chunks.join('')
+ // SSE format: lines ending with \n, double \n between events
+ expect(text).toMatch(/\n\n$/)
+ })
+
+ it('Node.js connect should write initial event to ServerResponse', () => {
+ const channel = createChannel(testSchema)
+
+ const written: string[] = []
+ const mockReq = {
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+ const mockRes = {
+ writeHead: mock(
+ (_status: number, _headers: Record) => {},
+ ),
+ write: mock((chunk: string) => {
+ written.push(chunk)
+ }),
+ end: mock(() => {}),
+ writableEnded: false,
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+
+ channel.connect(mockReq, mockRes)
+ channel.close()
+
+ // Should have written at least one initial event chunk
+ expect(written.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Req #13 - Node.js connect() headers', () => {
+ it('Node.js connect should call writeHead with correct SSE headers', () => {
+ const channel = createChannel(testSchema)
+
+ const mockReq = {
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+ const writeHeadCalls: Array<[number, Record]> = []
+ const mockRes = {
+ writeHead: mock((status: number, headers: Record) => {
+ writeHeadCalls.push([status, headers])
+ }),
+ write: mock(() => {}),
+ end: mock(() => {}),
+ writableEnded: false,
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+
+ channel.connect(mockReq, mockRes)
+ channel.close()
+
+ expect(writeHeadCalls.length).toBeGreaterThan(0)
+ const [status, headers] = writeHeadCalls[0] as [
+ number,
+ Record,
+ ]
+ expect(status).toBe(200)
+ expect(headers['Content-Type']).toContain('text/event-stream')
+ expect(headers['Cache-Control']).toBe('no-cache')
+ expect(headers.Connection).toBe('keep-alive')
+ })
+
+ it('Node.js connect should throw if res.writableEnded is true', () => {
+ const channel = createChannel(testSchema)
+
+ const mockReq = {
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+ const mockRes = {
+ writeHead: mock(() => {}),
+ write: mock(() => {}),
+ end: mock(() => {}),
+ writableEnded: true,
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+
+ expect(() => {
+ channel.connect(mockReq, mockRes)
+ }).toThrow()
+
+ channel.close()
+ })
+ })
+
+ describe('Req #15 - heartbeats', () => {
+ it('should send heartbeat comments at the configured interval', async () => {
+ // Use a very short interval for testing
+ const channel = createChannel(testSchema, { heartbeatInterval: 50 })
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ // Collect initial event + at least one heartbeat
+ const chunks = await collectChunks(response.body as ReadableStream, 2)
+ channel.close()
+
+ const allText = chunks.join('')
+ // Heartbeat is an SSE comment: ": heartbeat\n\n" or ": \n\n"
+ expect(allText).toMatch(/^:/)
+ })
+
+ it('heartbeat interval should default to 30000ms (verified by option type)', () => {
+ // createChannel should accept options object with heartbeatInterval
+ expect(() => {
+ const channel = createChannel(testSchema, { heartbeatInterval: 30000 })
+ channel.close()
+ }).not.toThrow()
+ })
+
+ it('heartbeat timer should stop when all clients disconnect', async () => {
+ // Patch setInterval/clearInterval to track timer lifecycle
+ const originalSetInterval = globalThis.setInterval
+ const originalClearInterval = globalThis.clearInterval
+
+ let activeTimers = 0
+ globalThis.setInterval = ((...args: Parameters) => {
+ activeTimers++
+ return originalSetInterval(...args)
+ }) as typeof setInterval
+ globalThis.clearInterval = ((
+ ...args: Parameters
+ ) => {
+ activeTimers--
+ return originalClearInterval(...args)
+ }) as typeof clearInterval
+
+ try {
+ // Dynamically re-import so the module captures our patched timers
+ // Use a cache-busting query param to force a fresh module
+ const mod = await import(`../server/index.ts?bust=${Date.now()}`)
+ const localCreateChannel = (mod as Record)
+ .createChannel as typeof createChannel
+
+ const channel = localCreateChannel(testSchema, {
+ heartbeatInterval: 50,
+ })
+
+ // Connect a client -- heartbeat starts
+ const r1 = channel.connect(new Request('http://localhost/api/events'))
+ expect(activeTimers).toBe(1)
+
+ // Cancel the client stream -- heartbeat should stop
+ await r1.body?.cancel()
+ expect(activeTimers).toBe(0)
+
+ // Connect a new client -- heartbeat starts fresh
+ const r2 = channel.connect(new Request('http://localhost/api/events'))
+ expect(activeTimers).toBe(1)
+
+ // Verify the new client actually receives heartbeats
+ const chunks = await collectChunks(r2.body as ReadableStream, 2)
+ const allText = chunks.join('')
+ expect(allText).toContain(': heartbeat')
+
+ channel.close()
+ expect(activeTimers).toBe(0)
+ } finally {
+ globalThis.setInterval = originalSetInterval
+ globalThis.clearInterval = originalClearInterval
+ }
+ })
+
+ it('heartbeat should use a single shared setInterval (not one per client)', async () => {
+ // Verify single-timer behavior functionally: both clients receive
+ // heartbeats at the same cadence from the shared interval.
+ const channel = createChannel(testSchema, { heartbeatInterval: 50 })
+
+ // Connect multiple clients
+ const r1 = channel.connect(new Request('http://localhost/api/events'))
+ const r2 = channel.connect(new Request('http://localhost/api/events'))
+
+ const decoder = new TextDecoder()
+ const reader1 = r1.body?.getReader()
+ const reader2 = r2.body?.getReader()
+
+ // Drain initial connected events
+ await reader1.read()
+ await reader2.read()
+
+ // Wait for heartbeat from both clients (shared timer sends to all)
+ const [hb1, hb2] = await Promise.all([reader1.read(), reader2.read()])
+
+ const text1 = decoder.decode(hb1.value)
+ const text2 = decoder.decode(hb2.value)
+
+ // Both clients should receive the same heartbeat comment
+ expect(text1).toContain(': heartbeat')
+ expect(text2).toContain(': heartbeat')
+
+ reader1.cancel()
+ reader2.cancel()
+ channel.close()
+ })
+ })
+
+ describe('Req #20-#22 - channel.emit() broadcast', () => {
+ it('channel.emit() with no clients should be a no-op (not throw)', () => {
+ const channel = createChannel(testSchema)
+
+ expect(() => {
+ channel.emit('user.updated', { id: 1 })
+ }).not.toThrow()
+
+ channel.close()
+ })
+
+ it('channel.emit(eventType, payload) should send SSE wire format to connected clients', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ // Drain the initial connected event first
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+ await reader.read() // initial event
+
+ // Emit a typed event
+ channel.emit('user.updated', { id: 42, name: 'alice' })
+
+ const { value } = await reader.read()
+ const text = decoder.decode(value)
+ reader.cancel()
+ channel.close()
+
+ // SSE wire format: event: \ndata: \n\n
+ expect(text).toContain('event: user.updated')
+ expect(text).toContain('data: ')
+ expect(text).toContain('"id":42')
+ expect(text).toContain('"name":"alice"')
+ expect(text).toMatch(/\n\n$/)
+ })
+
+ it('channel.emit() should broadcast to ALL connected clients', async () => {
+ const channel = createChannel(testSchema)
+
+ const r1 = channel.connect(new Request('http://localhost/api/events'))
+ const r2 = channel.connect(new Request('http://localhost/api/events'))
+ const decoder = new TextDecoder()
+
+ const reader1 = r1.body?.getReader()
+ const reader2 = r2.body?.getReader()
+
+ // Drain initial events
+ await reader1.read()
+ await reader2.read()
+
+ channel.emit('user.updated', { id: 1 })
+
+ const [res1, res2] = await Promise.all([reader1.read(), reader2.read()])
+ const text1 = decoder.decode(res1.value)
+ const text2 = decoder.decode(res2.value)
+
+ reader1.cancel()
+ reader2.cancel()
+ channel.close()
+
+ expect(text1).toContain('event: user.updated')
+ expect(text2).toContain('event: user.updated')
+ })
+ })
+
+ describe('Req #23 - channel.close()', () => {
+ it('channel.close() should not throw', () => {
+ const channel = createChannel(testSchema)
+ expect(() => channel.close()).not.toThrow()
+ })
+
+ it('channel.close() should close the Web Response stream', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ const reader = response.body?.getReader()
+
+ // Drain initial event
+ await reader.read()
+
+ channel.close()
+
+ // After close the stream should end (done: true)
+ const { done } = await reader.read()
+ expect(done).toBe(true)
+ })
+
+ it('channel.close() should call end() on Node.js ServerResponse', () => {
+ const channel = createChannel(testSchema)
+
+ const mockReq = { on: mock(() => {}) }
+ const endMock = mock(() => {})
+ const mockRes = {
+ writeHead: mock(() => {}),
+ write: mock(() => {}),
+ end: endMock,
+ writableEnded: false,
+ on: mock(() => {}),
+ }
+
+ channel.connect(mockReq, mockRes)
+ channel.close()
+
+ expect(endMock).toHaveBeenCalled()
+ })
+
+ it('channel.connect() after channel.close() should throw', () => {
+ const channel = createChannel(testSchema)
+ channel.close()
+
+ expect(() => {
+ channel.connect(new Request('http://localhost/api/events'))
+ }).toThrow()
+ })
+
+ it('channel.isClosed() should return false initially', () => {
+ const channel = createChannel(testSchema)
+ expect(channel.isClosed()).toBe(false)
+ channel.close()
+ })
+
+ it('channel.isClosed() should return true after channel.close()', () => {
+ const channel = createChannel(testSchema)
+ channel.close()
+ expect(channel.isClosed()).toBe(true)
+ })
+ })
+
+ describe('Req #17 - channel.respond() scoped emitter (dual signature)', () => {
+ it('Web respond(request) should return { response, emitter }', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/query')
+ const result = channel.respond(request)
+
+ expect(result).toHaveProperty('response')
+ expect(result).toHaveProperty('emitter')
+
+ const { response, emitter } = result as {
+ response: Response
+ emitter: { emit: (t: string, p: unknown) => void; close: () => void }
+ }
+ expect(response).toBeInstanceOf(Response)
+ expect(typeof emitter.emit).toBe('function')
+ expect(typeof emitter.close).toBe('function')
+
+ emitter.close()
+ channel.close()
+ })
+
+ it('Web respond(request) Response should have SSE headers', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/query')
+ const { response, emitter } = channel.respond(request) as {
+ response: Response
+ emitter: { close: () => void }
+ }
+
+ expect(response.headers.get('Content-Type')).toContain(
+ 'text/event-stream',
+ )
+ expect(response.headers.get('Cache-Control')).toBe('no-cache')
+ expect(response.headers.get('Connection')).toBe('keep-alive')
+
+ emitter.close()
+ channel.close()
+ })
+
+ it('Web respond emitter.emit() should write SSE wire format to the Response stream', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/query')
+ const { response, emitter } = channel.respond(request) as {
+ response: Response
+ emitter: {
+ emit: (type: string, payload: unknown) => void
+ close: () => void
+ }
+ }
+
+ emitter.emit('user.updated', { id: 42, name: 'alice' })
+ emitter.close()
+
+ const text = await response.text()
+
+ expect(text).toContain('event: user.updated')
+ expect(text).toContain('data: ')
+ expect(text).toContain('"id":42')
+ expect(text).toContain('"name":"alice"')
+ expect(text).toMatch(/\n\n/)
+
+ channel.close()
+ })
+
+ it('Web respond emitter.close() should end the Response stream', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/query')
+ const { response, emitter } = channel.respond(request) as {
+ response: Response
+ emitter: {
+ emit: (type: string, payload: unknown) => void
+ close: () => void
+ }
+ }
+
+ emitter.emit('user.updated', { id: 1 })
+ emitter.close()
+
+ // Reading the full body should complete (stream is closed)
+ const body = await response.text()
+ expect(body).toContain('event: user.updated')
+
+ channel.close()
+ })
+
+ it('Node.js respond(req, res) should return a ScopedEmitter', () => {
+ const channel = createChannel(testSchema)
+
+ const mockReq = {
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+ const mockRes = {
+ writeHead: mock(
+ (_status: number, _headers: Record) => {},
+ ),
+ write: mock((_chunk: string) => {}),
+ end: mock(() => {}),
+ writableEnded: false,
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+
+ const emitter = channel.respond(mockReq, mockRes)
+
+ expect(typeof emitter.emit).toBe('function')
+ expect(typeof emitter.close).toBe('function')
+ // Should NOT have response property (that is for Web path only)
+ expect(emitter).not.toHaveProperty('response')
+
+ emitter.close()
+ channel.close()
+ })
+
+ it('Node.js respond should call writeHead with SSE headers', () => {
+ const channel = createChannel(testSchema)
+
+ const writeHeadCalls: Array<[number, Record]> = []
+ const mockReq = {
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+ const mockRes = {
+ writeHead: mock((status: number, headers: Record) => {
+ writeHeadCalls.push([status, headers])
+ }),
+ write: mock(() => {}),
+ end: mock(() => {}),
+ writableEnded: false,
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+
+ const emitter = channel.respond(mockReq, mockRes)
+
+ expect(writeHeadCalls.length).toBe(1)
+ const [status, headers] = writeHeadCalls[0] as [
+ number,
+ Record,
+ ]
+ expect(status).toBe(200)
+ expect(headers['Content-Type']).toContain('text/event-stream')
+ expect(headers['Cache-Control']).toBe('no-cache')
+ expect(headers.Connection).toBe('keep-alive')
+
+ emitter.close()
+ channel.close()
+ })
+
+ it('Node.js respond emitter.emit() should write SSE wire format to res', () => {
+ const channel = createChannel(testSchema)
+
+ const written: string[] = []
+ const mockReq = {
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+ const mockRes = {
+ writeHead: mock(() => {}),
+ write: mock((chunk: string) => {
+ written.push(chunk)
+ }),
+ end: mock(() => {}),
+ writableEnded: false,
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+
+ const emitter = channel.respond(mockReq, mockRes)
+ emitter.emit('user.updated', { id: 7 })
+
+ expect(written.length).toBe(1)
+ expect(written[0]).toContain('event: user.updated')
+ expect(written[0]).toContain('data: ')
+ expect(written[0]).toContain('"id":7')
+ expect(written[0]).toMatch(/\n\n$/)
+
+ emitter.close()
+ channel.close()
+ })
+
+ it('Node.js respond emitter.close() should call res.end()', () => {
+ const channel = createChannel(testSchema)
+
+ const endMock = mock(() => {})
+ const mockReq = {
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+ const mockRes = {
+ writeHead: mock(() => {}),
+ write: mock(() => {}),
+ end: endMock,
+ writableEnded: false,
+ on: mock((_event: string, _cb: () => void) => {}),
+ }
+
+ const emitter = channel.respond(mockReq, mockRes)
+ emitter.close()
+
+ expect(endMock).toHaveBeenCalled()
+ channel.close()
+ })
+
+ it('respond() scoped emitter should NOT be in the broadcast pool', async () => {
+ const channel = createChannel(testSchema)
+
+ // Create a scoped emitter via Web respond
+ const request = new Request('http://localhost/api/query')
+ const { emitter } = channel.respond(request) as {
+ response: Response
+ emitter: {
+ emit: (type: string, payload: unknown) => void
+ close: () => void
+ }
+ }
+
+ // Connect a normal broadcast client
+ const r = channel.connect(new Request('http://localhost/api/events'))
+ const reader = r.body?.getReader()
+ const decoder = new TextDecoder()
+
+ // Drain initial event from broadcast client
+ await reader.read()
+
+ // Emit via channel — should reach broadcast client but NOT scoped emitter
+ channel.emit('user.updated', { id: 99 })
+
+ // Broadcast client should receive the event
+ const { value } = await reader.read()
+ const text = decoder.decode(value)
+
+ reader.cancel()
+ emitter.close()
+ channel.close()
+
+ expect(text).toContain('event: user.updated')
+ })
+
+ it('respond() scoped emitter should NOT receive heartbeats', async () => {
+ const channel = createChannel(testSchema, { heartbeatInterval: 50 })
+ const request = new Request('http://localhost/api/query')
+ const { response, emitter } = channel.respond(request) as {
+ response: Response
+ emitter: {
+ emit: (type: string, payload: unknown) => void
+ close: () => void
+ }
+ }
+
+ // Wait longer than the heartbeat interval
+ await new Promise((resolve) => setTimeout(resolve, 120))
+
+ // Emit one event and close
+ emitter.emit('result', { done: true })
+ emitter.close()
+
+ const text = await response.text()
+
+ // Should contain our event but NOT heartbeat comments
+ expect(text).toContain('event: result')
+ expect(text).not.toContain(': heartbeat')
+
+ channel.close()
+ })
+
+ it('respond() after channel.close() should throw', () => {
+ const channel = createChannel(testSchema)
+ channel.close()
+
+ expect(() => {
+ channel.respond(new Request('http://localhost/api/query'))
+ }).toThrow()
+ })
+ })
+
+ describe('Req #16 - disconnect cleanup', () => {
+ it('Web: after stream is cancelled, emit should not throw', async () => {
+ const channel = createChannel(testSchema)
+ const request = new Request('http://localhost/api/events')
+ const response = channel.connect(request)
+
+ // Immediately cancel the client stream (simulate disconnect)
+ await response.body?.cancel()
+
+ // Emit should silently drop the disconnected client
+ expect(() => {
+ channel.emit('user.updated', { id: 1 })
+ }).not.toThrow()
+
+ channel.close()
+ })
+
+ it('Node.js: after close event, client is removed from pool', () => {
+ const channel = createChannel(testSchema)
+
+ let closeCallback: (() => void) | undefined
+ const mockReq = {
+ on: mock((event: string, cb: () => void) => {
+ if (event === 'close') closeCallback = cb
+ }),
+ }
+ const mockRes = {
+ writeHead: mock(() => {}),
+ write: mock(() => {}),
+ end: mock(() => {}),
+ writableEnded: false,
+ on: mock((event: string, cb: () => void) => {
+ if (event === 'close') closeCallback = cb
+ }),
+ }
+
+ channel.connect(mockReq, mockRes)
+
+ // Simulate disconnect
+ closeCallback?.()
+
+ // Emit after disconnect should not throw and should not write to closed res
+ const writeCalls = (mockRes.write as ReturnType).mock.calls
+ .length
+ channel.emit('user.updated', { id: 5 })
+ const writeCallsAfter = (mockRes.write as ReturnType).mock
+ .calls.length
+
+ channel.close()
+
+ // No additional writes after disconnect
+ expect(writeCallsAfter).toBe(writeCalls)
+ })
+ })
+
+ describe('Req #24 - no framework dependencies', () => {
+ it('createChannel should work with a plain Web Request (no framework)', async () => {
+ const channel = createChannel(testSchema)
+ // Raw Request — no Express/Fastify/Hono
+ const req = new Request('http://localhost/sse')
+ const res = channel.connect(req)
+
+ expect(res).toBeInstanceOf(Response)
+ await res.body?.cancel()
+ channel.close()
+ })
+ })
+})
diff --git a/src/__tests__/connection.test.tsx b/src/__tests__/connection.test.tsx
index d55d332..cf7c104 100644
--- a/src/__tests__/connection.test.tsx
+++ b/src/__tests__/connection.test.tsx
@@ -177,8 +177,8 @@ describe('SSEProvider EventSource Connection', () => {
// During initial render (before open), connecting should be true
expect(capturedStatus).not.toBeNull()
- expect(capturedStatus!.connecting).toBe(true)
- expect(capturedStatus!.connected).toBe(false)
+ expect(capturedStatus?.connecting).toBe(true)
+ expect(capturedStatus?.connected).toBe(false)
})
it('should invoke onopen handler when connection opens', async () => {
@@ -387,11 +387,11 @@ describe('SSEProvider EventSource Connection', () => {
events: {},
}
- let capturedStatus: SSEStatus | null = null
+ let _capturedStatus: SSEStatus | null = null
function StatusCapture() {
const ctx = useSSEContext()
- capturedStatus = ctx.status
+ _capturedStatus = ctx.status
return createElement('div', null, 'status')
}
@@ -755,7 +755,7 @@ describe('SSEProvider EventSource Connection', () => {
source.simulateOpen()
// Unsubscribe one handler
- unsubscribeFn!()
+ unsubscribeFn?.()
// Send event
source.simulateNamedEvent('test:event', JSON.stringify({ data: 'test' }))
diff --git a/src/__tests__/errorHandling.test.tsx b/src/__tests__/errorHandling.test.tsx
index a39389c..c3081ab 100644
--- a/src/__tests__/errorHandling.test.tsx
+++ b/src/__tests__/errorHandling.test.tsx
@@ -1,7 +1,15 @@
-import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ mock,
+ spyOn,
+} from 'bun:test'
import { createElement } from 'react'
import { renderToString } from 'react-dom/server'
-import type { SSEConfig, ParsedEvent } from '../types.ts'
+import type { ParsedEvent, SSEConfig } from '../types.ts'
/**
* Tests for error handling and debug mode (WI-070).
@@ -407,7 +415,7 @@ describe('Error Handling and Debug Mode', () => {
events: {
'data.updated': {
key: '/api/data',
- update: (current: unknown, payload: { value: string }) => {
+ update: (_current: unknown, payload: { value: string }) => {
updateCallCount++
if (updateCallCount === 1) {
throw new Error('First update fails')
@@ -802,8 +810,10 @@ describe('Error Handling and Debug Mode', () => {
events: {
'counter.increment': {
key: '/api/counter',
- update: (current: number | undefined, payload: { amount: number }) =>
- (current ?? 0) + payload.amount,
+ update: (
+ current: number | undefined,
+ payload: { amount: number },
+ ) => (current ?? 0) + payload.amount,
},
},
}
diff --git a/src/__tests__/exports.test.ts b/src/__tests__/exports.test.ts
index 10f80c3..d49b8ef 100644
--- a/src/__tests__/exports.test.ts
+++ b/src/__tests__/exports.test.ts
@@ -48,6 +48,25 @@ describe('Package exports', () => {
expect(exports.useSSEStream).toBeDefined()
expect(typeof exports.useSSEStream).toBe('function')
})
+
+ it('should export createSSEParser factory function', async () => {
+ const exports = await import('../index.ts')
+
+ expect(exports.createSSEParser).toBeDefined()
+ expect(typeof exports.createSSEParser).toBe('function')
+ })
+
+ it('should return an object with feed and reset methods from createSSEParser', async () => {
+ const { createSSEParser } = await import('../index.ts')
+
+ const parser = createSSEParser({
+ onEvent: () => {},
+ })
+
+ expect(parser).toBeDefined()
+ expect(typeof parser.feed).toBe('function')
+ expect(typeof parser.reset).toBe('function')
+ })
})
describe('Type exports from main entry point', () => {
@@ -91,7 +110,10 @@ describe('Package exports', () => {
})
it('should export EventMapping type', async () => {
- const mapping: import('../index.ts').EventMapping<{ id: number }, unknown> = {
+ const mapping: import('../index.ts').EventMapping<
+ { id: number },
+ unknown
+ > = {
key: '/api/items',
update: 'set',
}
@@ -122,12 +144,14 @@ describe('Package exports', () => {
it('should export UpdateStrategy type', async () => {
// Test all variants of UpdateStrategy
- const setStrategy: import('../index.ts').UpdateStrategy = 'set'
- const refetchStrategy: import('../index.ts').UpdateStrategy = 'refetch'
- const fnStrategy: import('../index.ts').UpdateStrategy = (current, payload) => [
- ...(current ?? []),
- payload,
- ]
+ const setStrategy: import('../index.ts').UpdateStrategy =
+ 'set'
+ const refetchStrategy: import('../index.ts').UpdateStrategy<
+ string,
+ string
+ > = 'refetch'
+ const fnStrategy: import('../index.ts').UpdateStrategy =
+ (current, payload) => [...(current ?? []), payload]
expect(setStrategy).toBe('set')
expect(refetchStrategy).toBe('refetch')
@@ -135,9 +159,10 @@ describe('Package exports', () => {
})
it('should export UseSSEStreamOptions type', async () => {
- const options: import('../index.ts').UseSSEStreamOptions<{ id: number }> = {
- transform: (data) => data as { id: number },
- }
+ const options: import('../index.ts').UseSSEStreamOptions<{ id: number }> =
+ {
+ transform: (data) => data as { id: number },
+ }
expect(options.transform).toBeDefined()
})
@@ -150,6 +175,61 @@ describe('Package exports', () => {
expect(result.data?.id).toBe(1)
})
+
+ it('should export SSETransport type', async () => {
+ // SSETransport is the escape-hatch interface for custom transports
+ const transport: import('../index.ts').SSETransport = {
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ close: () => {},
+ readyState: 0,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }
+
+ expect(transport.readyState).toBe(0)
+ expect(typeof transport.close).toBe('function')
+ expect(typeof transport.addEventListener).toBe('function')
+ expect(typeof transport.removeEventListener).toBe('function')
+ })
+
+ it('should export SSERequestOptions type', async () => {
+ // SSERequestOptions groups method/body/headers for custom HTTP requests
+ const reqOptions: import('../index.ts').SSERequestOptions = {
+ method: 'POST',
+ body: JSON.stringify({ query: 'test' }),
+ headers: { 'Content-Type': 'application/json' },
+ }
+
+ expect(reqOptions.method).toBe('POST')
+ expect(reqOptions.headers?.['Content-Type']).toBe('application/json')
+ })
+
+ it('should export SSEConfig with transport abstraction fields', async () => {
+ // SSEConfig should have method, body, headers, and transport fields
+ const config: import('../index.ts').SSEConfig = {
+ url: '/api/events',
+ events: {},
+ method: 'POST',
+ body: JSON.stringify({ subscribe: true }),
+ headers: { Authorization: 'Bearer token' },
+ transport: (_url: string) => ({
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ close: () => {},
+ readyState: 0,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }),
+ }
+
+ expect(config.method).toBe('POST')
+ expect(config.body).toBeDefined()
+ expect(config.headers?.Authorization).toBe('Bearer token')
+ expect(typeof config.transport).toBe('function')
+ })
})
describe('Testing utilities (src/testing/index.ts)', () => {
@@ -171,7 +251,8 @@ describe('Package exports', () => {
const { mockSSE } = await import('../testing/index.ts')
// Create a mock and verify the controls interface
- const controls: import('../testing/index.ts').MockSSEControls = mockSSE('/test-url')
+ const controls: import('../testing/index.ts').MockSSEControls =
+ mockSSE('/test-url')
expect(controls.sendEvent).toBeDefined()
expect(controls.close).toBeDefined()
@@ -204,6 +285,7 @@ describe('Package exports', () => {
'useSSEStatus',
'useSSEEvent',
'useSSEStream',
+ 'createSSEParser',
]
for (const name of expectedRuntimeExports) {
diff --git a/src/__tests__/fetchTransport.test.ts b/src/__tests__/fetchTransport.test.ts
new file mode 100644
index 0000000..b351aff
--- /dev/null
+++ b/src/__tests__/fetchTransport.test.ts
@@ -0,0 +1,800 @@
+import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
+import { createFetchTransport } from '../fetchTransport.ts'
+import type { SSETransport } from '../types.ts'
+
+/**
+ * Tests for fetch-based SSE transport (createFetchTransport).
+ *
+ * This transport uses fetch() + ReadableStream to establish an HTTP connection,
+ * feeds response chunks into the SSE parser, and dispatches parsed events.
+ *
+ * These tests should FAIL until src/fetchTransport.ts is implemented.
+ */
+
+// ---- Helpers ----
+
+/**
+ * Creates a mock ReadableStream that yields the given chunks as Uint8Array.
+ * Optionally calls onCancel when the stream reader is cancelled.
+ */
+function createMockStream(
+ chunks: string[],
+ options?: { onCancel?: () => void; delayMs?: number },
+) {
+ const encoder = new TextEncoder()
+ let index = 0
+ let cancelled = false
+
+ return new ReadableStream({
+ async pull(controller) {
+ if (cancelled) return
+ if (options?.delayMs) {
+ await new Promise((r) => setTimeout(r, options.delayMs))
+ }
+ if (index < chunks.length) {
+ controller.enqueue(encoder.encode(chunks[index]))
+ index++
+ } else {
+ controller.close()
+ }
+ },
+ cancel() {
+ cancelled = true
+ options?.onCancel?.()
+ },
+ })
+}
+
+/**
+ * Creates a mock Response with the given status and body stream.
+ */
+function createMockResponse(
+ status: number,
+ chunks: string[],
+ options?: { onCancel?: () => void; delayMs?: number },
+): Response {
+ const body = createMockStream(chunks, options)
+ return new Response(body, {
+ status,
+ statusText: status === 200 ? 'OK' : 'Error',
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+}
+
+/** Flush microtasks and allow stream processing */
+function flushAsync(ms = 10): Promise {
+ return new Promise((r) => setTimeout(r, ms))
+}
+
+// ---- Tests ----
+
+let originalFetch: typeof globalThis.fetch
+
+beforeEach(() => {
+ originalFetch = globalThis.fetch
+})
+
+afterEach(() => {
+ globalThis.fetch = originalFetch
+})
+
+describe('createFetchTransport', () => {
+ describe('factory and SSETransport conformance', () => {
+ it('should return an object conforming to SSETransport interface', () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(200, [])),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+
+ // Verify all SSETransport members exist
+ expect(transport.onmessage).toBeNull()
+ expect(transport.onerror).toBeNull()
+ expect(transport.onopen).toBeNull()
+ expect(typeof transport.close).toBe('function')
+ expect(typeof transport.readyState).toBe('number')
+ expect(typeof transport.addEventListener).toBe('function')
+ expect(typeof transport.removeEventListener).toBe('function')
+
+ // Verify it satisfies the type
+ const _typed: SSETransport = transport
+
+ transport.close()
+ })
+
+ it('should expose a readonly lastEventId property', () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(200, [])),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ expect(transport.lastEventId).toBe('')
+
+ transport.close()
+ })
+
+ it('should expose an onretry callback property', () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(200, [])),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ expect(transport.onretry).toBeNull()
+
+ transport.close()
+ })
+ })
+
+ describe('readyState', () => {
+ it('should start as CONNECTING (0)', () => {
+ globalThis.fetch = mock(
+ () => new Promise(() => {}), // never resolves
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ expect(transport.readyState).toBe(0)
+
+ transport.close()
+ })
+
+ it('should become OPEN (1) on successful connection', async () => {
+ // Use a long-lived stream that won't close before we check readyState
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(
+ 200,
+ ['data: hello\n\n', 'data: world\n\n', 'data: keep-alive\n\n'],
+ { delayMs: 50 },
+ ),
+ ),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ await flushAsync(20)
+
+ expect(transport.readyState).toBe(1)
+
+ transport.close()
+ })
+
+ it('should become CLOSED (2) after close()', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(200, ['data: hello\n\n'])),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ transport.close()
+
+ expect(transport.readyState).toBe(2)
+ })
+ })
+
+ describe('onopen', () => {
+ it('should call onopen when connection is established', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(200, ['data: hello\n\n'], { delayMs: 5 }),
+ ),
+ ) as typeof fetch
+
+ const onopen = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onopen = onopen
+
+ await flushAsync(20)
+
+ expect(onopen).toHaveBeenCalledTimes(1)
+ const event = onopen.mock.calls[0][0]
+ expect(event).toBeDefined()
+
+ transport.close()
+ })
+ })
+
+ describe('onmessage', () => {
+ it('should call onmessage with MessageEvent-like objects for unnamed events', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(200, ['data: hello world\n\n'])),
+ ) as typeof fetch
+
+ const onmessage = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onmessage = onmessage
+
+ await flushAsync(50)
+
+ expect(onmessage).toHaveBeenCalledTimes(1)
+ const event = onmessage.mock.calls[0][0]
+ expect(event.data).toBe('hello world')
+ expect(event.type).toBe('message')
+
+ transport.close()
+ })
+
+ it('should handle multiple events in a single chunk', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(200, [
+ 'data: first\n\ndata: second\n\ndata: third\n\n',
+ ]),
+ ),
+ ) as typeof fetch
+
+ const onmessage = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onmessage = onmessage
+
+ await flushAsync(50)
+
+ expect(onmessage).toHaveBeenCalledTimes(3)
+ expect(onmessage.mock.calls[0][0].data).toBe('first')
+ expect(onmessage.mock.calls[1][0].data).toBe('second')
+ expect(onmessage.mock.calls[2][0].data).toBe('third')
+
+ transport.close()
+ })
+
+ it('should handle events split across multiple chunks', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(200, ['data: hel', 'lo\n\n'])),
+ ) as typeof fetch
+
+ const onmessage = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onmessage = onmessage
+
+ await flushAsync(50)
+
+ expect(onmessage).toHaveBeenCalledTimes(1)
+ expect(onmessage.mock.calls[0][0].data).toBe('hello')
+
+ transport.close()
+ })
+
+ it('should NOT call onmessage for named events', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(200, ['event: custom\ndata: payload\n\n']),
+ ),
+ ) as typeof fetch
+
+ const onmessage = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onmessage = onmessage
+
+ await flushAsync(50)
+
+ // Named events go to addEventListener listeners, not onmessage
+ expect(onmessage).not.toHaveBeenCalled()
+
+ transport.close()
+ })
+ })
+
+ describe('addEventListener / named events', () => {
+ it('should dispatch named events to addEventListener listeners', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(200, ['event: user.updated\ndata: {"id":1}\n\n']),
+ ),
+ ) as typeof fetch
+
+ const listener = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.addEventListener('user.updated', listener)
+
+ await flushAsync(50)
+
+ expect(listener).toHaveBeenCalledTimes(1)
+ const event = listener.mock.calls[0][0]
+ expect(event.data).toBe('{"id":1}')
+ expect(event.type).toBe('user.updated')
+
+ transport.close()
+ })
+
+ it('should support multiple listeners for the same event type', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(200, ['event: update\ndata: test\n\n']),
+ ),
+ ) as typeof fetch
+
+ const listener1 = mock(() => {})
+ const listener2 = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.addEventListener('update', listener1)
+ transport.addEventListener('update', listener2)
+
+ await flushAsync(50)
+
+ expect(listener1).toHaveBeenCalledTimes(1)
+ expect(listener2).toHaveBeenCalledTimes(1)
+
+ transport.close()
+ })
+
+ it('should support listeners for different event types', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(200, [
+ 'event: user.created\ndata: a\n\nevent: user.deleted\ndata: b\n\n',
+ ]),
+ ),
+ ) as typeof fetch
+
+ const createdListener = mock(() => {})
+ const deletedListener = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.addEventListener('user.created', createdListener)
+ transport.addEventListener('user.deleted', deletedListener)
+
+ await flushAsync(50)
+
+ expect(createdListener).toHaveBeenCalledTimes(1)
+ expect(createdListener.mock.calls[0][0].data).toBe('a')
+ expect(deletedListener).toHaveBeenCalledTimes(1)
+ expect(deletedListener.mock.calls[0][0].data).toBe('b')
+
+ transport.close()
+ })
+ })
+
+ describe('removeEventListener', () => {
+ it('should stop dispatching events to removed listeners', async () => {
+ // Use a delay between chunks so we can remove the listener after the first event
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(
+ 200,
+ [
+ 'event: update\ndata: first\n\n',
+ 'event: update\ndata: second\n\n',
+ ],
+ { delayMs: 30 },
+ ),
+ ),
+ ) as typeof fetch
+
+ const listener = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.addEventListener('update', listener)
+
+ await flushAsync(40)
+
+ // Remove after first event
+ transport.removeEventListener('update', listener)
+
+ await flushAsync(60)
+
+ // Listener should have only received the first event
+ expect(listener).toHaveBeenCalledTimes(1)
+
+ transport.close()
+ })
+
+ it('should not error when removing a listener that was never added', () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(200, [])),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+
+ expect(() => {
+ transport.removeEventListener('unknown', () => {})
+ }).not.toThrow()
+
+ transport.close()
+ })
+ })
+
+ describe('close()', () => {
+ it('should set readyState to CLOSED (2)', () => {
+ globalThis.fetch = mock(
+ () => new Promise(() => {}),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ transport.close()
+
+ expect(transport.readyState).toBe(2)
+ })
+
+ it('should abort the fetch via AbortController', async () => {
+ let abortSignal: AbortSignal | undefined
+
+ globalThis.fetch = mock(
+ (_input: RequestInfo | URL, init?: RequestInit) => {
+ abortSignal = init?.signal as AbortSignal
+ return new Promise(() => {}) // hang forever
+ },
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+
+ // Give fetch time to be called
+ await flushAsync(10)
+
+ expect(abortSignal).toBeDefined()
+ expect(abortSignal?.aborted).toBe(false)
+
+ transport.close()
+
+ expect(abortSignal?.aborted).toBe(true)
+ })
+
+ it('should be safe to call close() multiple times', () => {
+ globalThis.fetch = mock(
+ () => new Promise(() => {}),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+
+ expect(() => {
+ transport.close()
+ transport.close()
+ transport.close()
+ }).not.toThrow()
+
+ expect(transport.readyState).toBe(2)
+ })
+
+ it('should be safe to call close() after stream has already ended', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(200, ['data: done\n\n'])),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ await flushAsync(50)
+
+ expect(() => {
+ transport.close()
+ }).not.toThrow()
+
+ expect(transport.readyState).toBe(2)
+ })
+ })
+
+ describe('error handling', () => {
+ it('should call onerror and set readyState to CLOSED on non-2xx response', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(500, [])),
+ ) as typeof fetch
+
+ const onerror = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onerror = onerror
+
+ await flushAsync(50)
+
+ expect(onerror).toHaveBeenCalledTimes(1)
+ expect(transport.readyState).toBe(2)
+ })
+
+ it('should call onerror and set readyState to CLOSED on 404 response', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(404, [])),
+ ) as typeof fetch
+
+ const onerror = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onerror = onerror
+
+ await flushAsync(50)
+
+ expect(onerror).toHaveBeenCalledTimes(1)
+ expect(transport.readyState).toBe(2)
+ })
+
+ it('should call onerror and set readyState to CLOSED on network error', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.reject(new TypeError('Failed to fetch')),
+ ) as typeof fetch
+
+ const onerror = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onerror = onerror
+
+ await flushAsync(50)
+
+ expect(onerror).toHaveBeenCalledTimes(1)
+ expect(transport.readyState).toBe(2)
+ })
+
+ it('should call onerror when stream ends unexpectedly', async () => {
+ // A stream that closes immediately without any data
+ globalThis.fetch = mock(() =>
+ Promise.resolve(createMockResponse(200, [])),
+ ) as typeof fetch
+
+ const onerror = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onerror = onerror
+
+ await flushAsync(50)
+
+ expect(onerror).toHaveBeenCalled()
+ expect(transport.readyState).toBe(2)
+ })
+ })
+
+ describe('request configuration', () => {
+ it('should default to GET when no body is provided', async () => {
+ let capturedInit: RequestInit | undefined
+
+ globalThis.fetch = mock(
+ (_input: RequestInfo | URL, init?: RequestInit) => {
+ capturedInit = init
+ return Promise.resolve(createMockResponse(200, ['data: ok\n\n']))
+ },
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ await flushAsync(20)
+
+ // Default method should be GET (or undefined which defaults to GET)
+ expect(
+ capturedInit?.method === undefined || capturedInit?.method === 'GET',
+ ).toBe(true)
+
+ transport.close()
+ })
+
+ it('should default to POST when body is provided without method', async () => {
+ let capturedInit: RequestInit | undefined
+
+ globalThis.fetch = mock(
+ (_input: RequestInfo | URL, init?: RequestInit) => {
+ capturedInit = init
+ return Promise.resolve(createMockResponse(200, ['data: ok\n\n']))
+ },
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events', {
+ body: { query: 'test' },
+ })
+ await flushAsync(20)
+
+ expect(capturedInit?.method).toBe('POST')
+
+ transport.close()
+ })
+
+ it('should JSON.stringify a plain object body and set Content-Type', async () => {
+ let capturedInit: RequestInit | undefined
+
+ globalThis.fetch = mock(
+ (_input: RequestInfo | URL, init?: RequestInit) => {
+ capturedInit = init
+ return Promise.resolve(createMockResponse(200, ['data: ok\n\n']))
+ },
+ ) as typeof fetch
+
+ const bodyObj = { query: 'test', limit: 10 }
+ const transport = createFetchTransport('http://localhost/events', {
+ body: bodyObj,
+ })
+ await flushAsync(20)
+
+ expect(capturedInit?.body).toBe(JSON.stringify(bodyObj))
+
+ const headers = capturedInit?.headers as Record
+ expect(headers?.['Content-Type'] ?? headers?.['content-type']).toBe(
+ 'application/json',
+ )
+
+ transport.close()
+ })
+
+ it('should not override Content-Type if already set in headers', async () => {
+ let capturedInit: RequestInit | undefined
+
+ globalThis.fetch = mock(
+ (_input: RequestInfo | URL, init?: RequestInit) => {
+ capturedInit = init
+ return Promise.resolve(createMockResponse(200, ['data: ok\n\n']))
+ },
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events', {
+ body: { query: 'test' },
+ headers: { 'Content-Type': 'text/plain' },
+ })
+ await flushAsync(20)
+
+ const headers = capturedInit?.headers as Record
+ expect(headers?.['Content-Type']).toBe('text/plain')
+
+ transport.close()
+ })
+
+ it('should pass custom headers through to fetch', async () => {
+ let capturedInit: RequestInit | undefined
+
+ globalThis.fetch = mock(
+ (_input: RequestInfo | URL, init?: RequestInit) => {
+ capturedInit = init
+ return Promise.resolve(createMockResponse(200, ['data: ok\n\n']))
+ },
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events', {
+ headers: {
+ Authorization: 'Bearer token123',
+ 'X-Custom': 'value',
+ },
+ })
+ await flushAsync(20)
+
+ const headers = capturedInit?.headers as Record
+ expect(headers?.Authorization).toBe('Bearer token123')
+ expect(headers?.['X-Custom']).toBe('value')
+
+ transport.close()
+ })
+
+ it('should use the provided method', async () => {
+ let capturedInit: RequestInit | undefined
+
+ globalThis.fetch = mock(
+ (_input: RequestInfo | URL, init?: RequestInit) => {
+ capturedInit = init
+ return Promise.resolve(createMockResponse(200, ['data: ok\n\n']))
+ },
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events', {
+ method: 'PUT',
+ body: 'raw-data',
+ })
+ await flushAsync(20)
+
+ expect(capturedInit?.method).toBe('PUT')
+
+ transport.close()
+ })
+
+ it('should pass the URL to fetch', async () => {
+ let capturedUrl: string | undefined
+
+ globalThis.fetch = mock(
+ (input: RequestInfo | URL, _init?: RequestInit) => {
+ capturedUrl = String(input)
+ return Promise.resolve(createMockResponse(200, ['data: ok\n\n']))
+ },
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost:3000/api/events')
+ await flushAsync(20)
+
+ expect(capturedUrl).toBe('http://localhost:3000/api/events')
+
+ transport.close()
+ })
+
+ it('should pass non-object body as-is (no JSON.stringify)', async () => {
+ let capturedInit: RequestInit | undefined
+
+ globalThis.fetch = mock(
+ (_input: RequestInfo | URL, init?: RequestInit) => {
+ capturedInit = init
+ return Promise.resolve(createMockResponse(200, ['data: ok\n\n']))
+ },
+ ) as typeof fetch
+
+ const rawBody = 'raw string body'
+ const transport = createFetchTransport('http://localhost/events', {
+ body: rawBody,
+ })
+ await flushAsync(20)
+
+ expect(capturedInit?.body).toBe(rawBody)
+
+ transport.close()
+ })
+ })
+
+ describe('lastEventId', () => {
+ it('should start as empty string', () => {
+ globalThis.fetch = mock(
+ () => new Promise(() => {}),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ expect(transport.lastEventId).toBe('')
+
+ transport.close()
+ })
+
+ it('should track id: fields from the SSE stream', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(200, [
+ 'id: 42\ndata: first\n\nid: 99\ndata: second\n\n',
+ ]),
+ ),
+ ) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ await flushAsync(50)
+
+ expect(transport.lastEventId).toBe('99')
+
+ transport.close()
+ })
+
+ it('should persist across events without new id', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(200, ['id: 7\ndata: first\n\ndata: second\n\n']),
+ ),
+ ) as typeof fetch
+
+ const onmessage = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onmessage = onmessage
+
+ await flushAsync(50)
+
+ // lastEventId should still be "7" from the first event
+ expect(transport.lastEventId).toBe('7')
+
+ transport.close()
+ })
+ })
+
+ describe('onretry', () => {
+ it('should call onretry when parser encounters retry: field', async () => {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(
+ createMockResponse(200, ['retry: 3000\ndata: test\n\n']),
+ ),
+ ) as typeof fetch
+
+ const onretry = mock(() => {})
+ const transport = createFetchTransport('http://localhost/events')
+ transport.onretry = onretry
+
+ await flushAsync(50)
+
+ expect(onretry).toHaveBeenCalledTimes(1)
+ expect(onretry).toHaveBeenCalledWith(3000)
+
+ transport.close()
+ })
+ })
+
+ describe('no internal reconnection', () => {
+ it('should NOT reconnect after stream ends', async () => {
+ let fetchCount = 0
+
+ globalThis.fetch = mock(() => {
+ fetchCount++
+ return Promise.resolve(createMockResponse(200, ['data: done\n\n']))
+ }) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ await flushAsync(100)
+
+ expect(fetchCount).toBe(1)
+
+ transport.close()
+ })
+
+ it('should NOT reconnect after error', async () => {
+ let fetchCount = 0
+
+ globalThis.fetch = mock(() => {
+ fetchCount++
+ return Promise.reject(new Error('Network error'))
+ }) as typeof fetch
+
+ const transport = createFetchTransport('http://localhost/events')
+ await flushAsync(100)
+
+ expect(fetchCount).toBe(1)
+
+ transport.close()
+ })
+ })
+})
diff --git a/src/__tests__/reconnect.test.ts b/src/__tests__/reconnect.test.ts
new file mode 100644
index 0000000..fca03dd
--- /dev/null
+++ b/src/__tests__/reconnect.test.ts
@@ -0,0 +1,270 @@
+import { describe, expect, it } from 'bun:test'
+
+/**
+ * Tests for the shared reconnection utility module (src/reconnect.ts).
+ *
+ * This module extracts calculateBackoffDelay and DEFAULT_RECONNECT
+ * from SSEProvider.tsx into a standalone, pure utility module.
+ *
+ * Tests will FAIL until src/reconnect.ts is created with the correct exports.
+ */
+
+describe('reconnect utilities', () => {
+ describe('module exports', () => {
+ it('should export calculateBackoffDelay as a function', async () => {
+ const { calculateBackoffDelay } = await import('../reconnect.ts')
+
+ expect(calculateBackoffDelay).toBeDefined()
+ expect(typeof calculateBackoffDelay).toBe('function')
+ })
+
+ it('should export DEFAULT_RECONNECT as an object', async () => {
+ const { DEFAULT_RECONNECT } = await import('../reconnect.ts')
+
+ expect(DEFAULT_RECONNECT).toBeDefined()
+ expect(typeof DEFAULT_RECONNECT).toBe('object')
+ })
+ })
+
+ describe('DEFAULT_RECONNECT', () => {
+ it('should have the expected shape with all required fields', async () => {
+ const { DEFAULT_RECONNECT } = await import('../reconnect.ts')
+
+ expect(DEFAULT_RECONNECT).toHaveProperty('enabled')
+ expect(DEFAULT_RECONNECT).toHaveProperty('initialDelay')
+ expect(DEFAULT_RECONNECT).toHaveProperty('maxDelay')
+ expect(DEFAULT_RECONNECT).toHaveProperty('backoffMultiplier')
+ expect(DEFAULT_RECONNECT).toHaveProperty('maxAttempts')
+ })
+
+ it('should have correct default values matching SSEProvider', async () => {
+ const { DEFAULT_RECONNECT } = await import('../reconnect.ts')
+
+ expect(DEFAULT_RECONNECT.enabled).toBe(true)
+ expect(DEFAULT_RECONNECT.initialDelay).toBe(1000)
+ expect(DEFAULT_RECONNECT.maxDelay).toBe(30000)
+ expect(DEFAULT_RECONNECT.backoffMultiplier).toBe(2)
+ expect(DEFAULT_RECONNECT.maxAttempts).toBe(Number.POSITIVE_INFINITY)
+ })
+
+ it('should have boolean enabled field', async () => {
+ const { DEFAULT_RECONNECT } = await import('../reconnect.ts')
+
+ expect(typeof DEFAULT_RECONNECT.enabled).toBe('boolean')
+ })
+
+ it('should have numeric initialDelay field', async () => {
+ const { DEFAULT_RECONNECT } = await import('../reconnect.ts')
+
+ expect(typeof DEFAULT_RECONNECT.initialDelay).toBe('number')
+ })
+
+ it('should have numeric maxDelay field', async () => {
+ const { DEFAULT_RECONNECT } = await import('../reconnect.ts')
+
+ expect(typeof DEFAULT_RECONNECT.maxDelay).toBe('number')
+ })
+
+ it('should have numeric backoffMultiplier field', async () => {
+ const { DEFAULT_RECONNECT } = await import('../reconnect.ts')
+
+ expect(typeof DEFAULT_RECONNECT.backoffMultiplier).toBe('number')
+ })
+
+ it('should have numeric maxAttempts field', async () => {
+ const { DEFAULT_RECONNECT } = await import('../reconnect.ts')
+
+ expect(typeof DEFAULT_RECONNECT.maxAttempts).toBe('number')
+ })
+ })
+
+ describe('calculateBackoffDelay', () => {
+ it('should return initialDelay for attempt 0', async () => {
+ const { calculateBackoffDelay, DEFAULT_RECONNECT } = await import(
+ '../reconnect.ts'
+ )
+
+ const delay = calculateBackoffDelay(0, DEFAULT_RECONNECT)
+
+ // initialDelay * backoffMultiplier^0 = 1000 * 1 = 1000
+ expect(delay).toBe(1000)
+ })
+
+ it('should apply exponential backoff for subsequent attempts', async () => {
+ const { calculateBackoffDelay, DEFAULT_RECONNECT } = await import(
+ '../reconnect.ts'
+ )
+
+ // attempt 1: 1000 * 2^1 = 2000
+ expect(calculateBackoffDelay(1, DEFAULT_RECONNECT)).toBe(2000)
+
+ // attempt 2: 1000 * 2^2 = 4000
+ expect(calculateBackoffDelay(2, DEFAULT_RECONNECT)).toBe(4000)
+
+ // attempt 3: 1000 * 2^3 = 8000
+ expect(calculateBackoffDelay(3, DEFAULT_RECONNECT)).toBe(8000)
+
+ // attempt 4: 1000 * 2^4 = 16000
+ expect(calculateBackoffDelay(4, DEFAULT_RECONNECT)).toBe(16000)
+ })
+
+ it('should cap delay at maxDelay', async () => {
+ const { calculateBackoffDelay, DEFAULT_RECONNECT } = await import(
+ '../reconnect.ts'
+ )
+
+ // attempt 5: 1000 * 2^5 = 32000, capped at 30000
+ expect(calculateBackoffDelay(5, DEFAULT_RECONNECT)).toBe(30000)
+
+ // attempt 10: 1000 * 2^10 = 1024000, capped at 30000
+ expect(calculateBackoffDelay(10, DEFAULT_RECONNECT)).toBe(30000)
+ })
+
+ it('should respect custom config values', async () => {
+ const { calculateBackoffDelay } = await import('../reconnect.ts')
+
+ const customConfig = {
+ enabled: true,
+ initialDelay: 500,
+ maxDelay: 10000,
+ backoffMultiplier: 3,
+ maxAttempts: 5,
+ }
+
+ // attempt 0: 500 * 3^0 = 500
+ expect(calculateBackoffDelay(0, customConfig)).toBe(500)
+
+ // attempt 1: 500 * 3^1 = 1500
+ expect(calculateBackoffDelay(1, customConfig)).toBe(1500)
+
+ // attempt 2: 500 * 3^2 = 4500
+ expect(calculateBackoffDelay(2, customConfig)).toBe(4500)
+
+ // attempt 3: 500 * 3^3 = 13500, capped at 10000
+ expect(calculateBackoffDelay(3, customConfig)).toBe(10000)
+ })
+
+ it('should handle backoffMultiplier of 1 (constant delay)', async () => {
+ const { calculateBackoffDelay } = await import('../reconnect.ts')
+
+ const config = {
+ enabled: true,
+ initialDelay: 2000,
+ maxDelay: 60000,
+ backoffMultiplier: 1,
+ maxAttempts: 10,
+ }
+
+ // With multiplier 1, delay should always be initialDelay
+ expect(calculateBackoffDelay(0, config)).toBe(2000)
+ expect(calculateBackoffDelay(1, config)).toBe(2000)
+ expect(calculateBackoffDelay(5, config)).toBe(2000)
+ })
+
+ it('should handle large attempt numbers without exceeding maxDelay', async () => {
+ const { calculateBackoffDelay, DEFAULT_RECONNECT } = await import(
+ '../reconnect.ts'
+ )
+
+ // Very large attempt number should be capped at maxDelay
+ expect(calculateBackoffDelay(100, DEFAULT_RECONNECT)).toBe(30000)
+ expect(calculateBackoffDelay(1000, DEFAULT_RECONNECT)).toBe(30000)
+ })
+
+ it('should be a pure function with no side effects', async () => {
+ const { calculateBackoffDelay, DEFAULT_RECONNECT } = await import(
+ '../reconnect.ts'
+ )
+
+ const configCopy = { ...DEFAULT_RECONNECT }
+
+ // Call multiple times
+ calculateBackoffDelay(0, DEFAULT_RECONNECT)
+ calculateBackoffDelay(1, DEFAULT_RECONNECT)
+ calculateBackoffDelay(2, DEFAULT_RECONNECT)
+
+ // Config should not be mutated
+ expect(DEFAULT_RECONNECT).toEqual(configCopy)
+ })
+
+ it('should return consistent results for the same inputs', async () => {
+ const { calculateBackoffDelay, DEFAULT_RECONNECT } = await import(
+ '../reconnect.ts'
+ )
+
+ const result1 = calculateBackoffDelay(3, DEFAULT_RECONNECT)
+ const result2 = calculateBackoffDelay(3, DEFAULT_RECONNECT)
+
+ expect(result1).toBe(result2)
+ })
+
+ it('should handle maxDelay equal to initialDelay', async () => {
+ const { calculateBackoffDelay } = await import('../reconnect.ts')
+
+ const config = {
+ enabled: true,
+ initialDelay: 1000,
+ maxDelay: 1000,
+ backoffMultiplier: 2,
+ maxAttempts: 10,
+ }
+
+ // All attempts should return 1000 since maxDelay === initialDelay
+ expect(calculateBackoffDelay(0, config)).toBe(1000)
+ expect(calculateBackoffDelay(1, config)).toBe(1000)
+ expect(calculateBackoffDelay(5, config)).toBe(1000)
+ })
+
+ it('should handle very large backoffMultiplier without exceeding maxDelay', async () => {
+ const { calculateBackoffDelay } = await import('../reconnect.ts')
+
+ const config = {
+ enabled: true,
+ initialDelay: 100,
+ maxDelay: 5000,
+ backoffMultiplier: 10,
+ maxAttempts: 5,
+ }
+
+ // attempt 0: 100 * 10^0 = 100
+ expect(calculateBackoffDelay(0, config)).toBe(100)
+ // attempt 1: 100 * 10^1 = 1000
+ expect(calculateBackoffDelay(1, config)).toBe(1000)
+ // attempt 2: 100 * 10^2 = 10000, capped at 5000
+ expect(calculateBackoffDelay(2, config)).toBe(5000)
+ })
+
+ it('should handle Infinity from very large exponents gracefully', async () => {
+ const { calculateBackoffDelay } = await import('../reconnect.ts')
+
+ const config = {
+ enabled: true,
+ initialDelay: 1000,
+ maxDelay: 30000,
+ backoffMultiplier: 2,
+ maxAttempts: Number.POSITIVE_INFINITY,
+ }
+
+ // 2^10000 = Infinity, 1000 * Infinity = Infinity
+ // Math.min(Infinity, 30000) should be 30000
+ const result = calculateBackoffDelay(10000, config)
+ expect(result).toBe(30000)
+ expect(Number.isFinite(result)).toBe(true)
+ })
+
+ it('should return initialDelay when backoffMultiplier is 0 and attempt is 0', async () => {
+ const { calculateBackoffDelay } = await import('../reconnect.ts')
+
+ const config = {
+ enabled: true,
+ initialDelay: 1000,
+ maxDelay: 30000,
+ backoffMultiplier: 0,
+ maxAttempts: 10,
+ }
+
+ // 0^0 = 1 in JavaScript, so 1000 * 1 = 1000
+ expect(calculateBackoffDelay(0, config)).toBe(1000)
+ })
+ })
+})
diff --git a/src/__tests__/reconnection.test.tsx b/src/__tests__/reconnection.test.tsx
index d318321..a1725ea 100644
--- a/src/__tests__/reconnection.test.tsx
+++ b/src/__tests__/reconnection.test.tsx
@@ -1,8 +1,8 @@
-import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { createElement } from 'react'
import { renderToString } from 'react-dom/server'
import { SSEProvider, useSSEContext } from '../SSEProvider.tsx'
-import type { ReconnectConfig, SSEConfig, SSEStatus } from '../types.ts'
+import type { SSEConfig, SSEStatus } from '../types.ts'
/**
* Tests for SSEProvider reconnection with exponential backoff (WI-065).
@@ -474,7 +474,7 @@ describe('SSEProvider Reconnection', () => {
// Initial connection exhausts maxAttempts
MockEventSource.getLastInstance()?.simulateConnectionFailure()
- const timerCountBefore = pendingTimers.size
+ const _timerCountBefore = pendingTimers.size
advanceTimersByTime(10000)
// No reconnect should have occurred
@@ -516,13 +516,13 @@ describe('SSEProvider Reconnection', () => {
// Status should show reconnect attempts
expect(capturedStatus).not.toBeNull()
- expect(capturedStatus!.reconnectAttempt).toBeGreaterThan(0)
+ expect(capturedStatus?.reconnectAttempt).toBeGreaterThan(0)
// Successful reconnection
MockEventSource.getLastInstance()?.simulateOpen()
// After successful connection, reconnectAttempt should reset to 0
- expect(capturedStatus!.reconnectAttempt).toBe(0)
+ expect(capturedStatus?.reconnectAttempt).toBe(0)
})
it('should set connected: true and connecting: false on successful reconnection', async () => {
@@ -550,21 +550,21 @@ describe('SSEProvider Reconnection', () => {
// Initial open
MockEventSource.getLastInstance()?.simulateOpen()
- expect(capturedStatus!.connected).toBe(true)
- expect(capturedStatus!.connecting).toBe(false)
+ expect(capturedStatus?.connected).toBe(true)
+ expect(capturedStatus?.connecting).toBe(false)
// Connection failure
MockEventSource.getLastInstance()?.simulateConnectionFailure()
- expect(capturedStatus!.connected).toBe(false)
+ expect(capturedStatus?.connected).toBe(false)
// Reconnect
advanceTimersByTime(1000)
const newInstance = MockEventSource.getLastInstance()
newInstance?.simulateOpen()
- expect(capturedStatus!.connected).toBe(true)
- expect(capturedStatus!.connecting).toBe(false)
- expect(capturedStatus!.error).toBeNull()
+ expect(capturedStatus?.connected).toBe(true)
+ expect(capturedStatus?.connecting).toBe(false)
+ expect(capturedStatus?.error).toBeNull()
})
it('should clear error on successful reconnection', async () => {
@@ -592,14 +592,14 @@ describe('SSEProvider Reconnection', () => {
// Connection failure sets error
MockEventSource.getLastInstance()?.simulateConnectionFailure()
- expect(capturedStatus!.error).not.toBeNull()
+ expect(capturedStatus?.error).not.toBeNull()
// Reconnect
advanceTimersByTime(1000)
MockEventSource.getLastInstance()?.simulateOpen()
// Error should be cleared
- expect(capturedStatus!.error).toBeNull()
+ expect(capturedStatus?.error).toBeNull()
})
})
@@ -701,22 +701,22 @@ describe('SSEProvider Reconnection', () => {
)
// Initial state
- expect(capturedStatus!.reconnectAttempt).toBe(0)
+ expect(capturedStatus?.reconnectAttempt).toBe(0)
// First failure and reconnect attempt
MockEventSource.getLastInstance()?.simulateConnectionFailure()
advanceTimersByTime(1000)
- expect(capturedStatus!.reconnectAttempt).toBe(1)
+ expect(capturedStatus?.reconnectAttempt).toBe(1)
// Second failure and reconnect attempt
MockEventSource.getLastInstance()?.simulateConnectionFailure()
advanceTimersByTime(2000)
- expect(capturedStatus!.reconnectAttempt).toBe(2)
+ expect(capturedStatus?.reconnectAttempt).toBe(2)
// Third failure and reconnect attempt
MockEventSource.getLastInstance()?.simulateConnectionFailure()
advanceTimersByTime(4000)
- expect(capturedStatus!.reconnectAttempt).toBe(3)
+ expect(capturedStatus?.reconnectAttempt).toBe(3)
})
it('should reflect current attempt number during connecting state', async () => {
@@ -747,9 +747,9 @@ describe('SSEProvider Reconnection', () => {
advanceTimersByTime(1000)
// During reconnection attempt, should show attempt number and connecting state
- expect(capturedStatus!.reconnectAttempt).toBe(1)
- expect(capturedStatus!.connecting).toBe(true)
- expect(capturedStatus!.connected).toBe(false)
+ expect(capturedStatus?.reconnectAttempt).toBe(1)
+ expect(capturedStatus?.connecting).toBe(true)
+ expect(capturedStatus?.connected).toBe(false)
})
})
@@ -806,7 +806,7 @@ describe('SSEProvider Reconnection', () => {
),
)
- const initialAttempts = MockEventSource.connectionAttempts
+ const _initialAttempts = MockEventSource.connectionAttempts
// Simulate failure
MockEventSource.getLastInstance()?.simulateConnectionFailure()
diff --git a/src/__tests__/schema.test.ts b/src/__tests__/schema.test.ts
new file mode 100644
index 0000000..4aca089
--- /dev/null
+++ b/src/__tests__/schema.test.ts
@@ -0,0 +1,312 @@
+import { describe, expect, it } from 'bun:test'
+
+/**
+ * Tests for defineSchema() function.
+ *
+ * These tests verify that defineSchema():
+ * 1. Accepts a definition object and returns a frozen schema
+ * 2. Preserves event names as string literals
+ * 3. Supports key, update, filter, transform properties per event
+ * 4. Defaults update to 'set' when not specified
+ * 5. Handles an empty schema definition
+ * 6. Is exported from the main entry point (src/index.ts)
+ * 7. Accepts key as string, string[], or function
+ * 8. Treats filter and transform as optional
+ *
+ * Tests FAIL initially because defineSchema has not been implemented yet.
+ */
+
+describe('defineSchema()', () => {
+ describe('Req #8 - export from main entry point', () => {
+ it('should be exported from src/index.ts', async () => {
+ const exports = await import('../index.ts')
+
+ expect((exports as Record).defineSchema).toBeDefined()
+ expect(typeof (exports as Record).defineSchema).toBe(
+ 'function',
+ )
+ })
+ })
+
+ describe('Req #7 - frozen return value', () => {
+ it('should return a frozen object', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'user.updated': {
+ key: '/api/users',
+ update: 'set',
+ },
+ })
+
+ expect(Object.isFrozen(schema)).toBe(true)
+ })
+
+ it('should not be modifiable after creation', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'user.updated': {
+ key: '/api/users',
+ },
+ }) as Record
+
+ expect(() => {
+ schema.newKey = 'value'
+ }).toThrow()
+ })
+ })
+
+ describe('Req #6 - empty schema edge case', () => {
+ it('defineSchema({}) should return a valid frozen object', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({})
+
+ expect(schema).toBeDefined()
+ expect(Object.isFrozen(schema)).toBe(true)
+ expect(Object.keys(schema).length).toBe(0)
+ })
+ })
+
+ describe('Req #9 - event names preserved', () => {
+ it('should preserve event names as keys on the returned schema', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'user.updated': { key: '/api/users' },
+ 'order.placed': { key: '/api/orders' },
+ }) as Record
+
+ expect(Object.keys(schema)).toContain('user.updated')
+ expect(Object.keys(schema)).toContain('order.placed')
+ })
+
+ it('should not add extra keys beyond the event definitions', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'item.deleted': { key: '/api/items' },
+ }) as Record
+
+ expect(Object.keys(schema)).toHaveLength(1)
+ expect(Object.keys(schema)[0]).toBe('item.deleted')
+ })
+ })
+
+ describe('Req #10 - event definition properties', () => {
+ describe('key property', () => {
+ it('should accept a string key', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'user.updated': { key: '/api/users/1' },
+ }) as Record
+
+ expect(schema['user.updated']?.key).toBe('/api/users/1')
+ })
+
+ it('should accept a string array key', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const keys = ['/api/users/1', '/api/users/2']
+ const schema = defineSchema({
+ 'user.updated': { key: keys },
+ }) as Record
+
+ expect(schema['user.updated']?.key).toEqual(keys)
+ })
+
+ it('should accept a function key that returns a string', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const keyFn = (payload: { id: number }) => `/api/users/${payload.id}`
+ const schema = defineSchema({
+ 'user.updated': { key: keyFn },
+ }) as Record
+
+ expect(typeof schema['user.updated']?.key).toBe('function')
+ expect(
+ (schema['user.updated']?.key as (p: { id: number }) => string)({
+ id: 42,
+ }),
+ ).toBe('/api/users/42')
+ })
+
+ it('should accept a function key that returns a string array', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const keyFn = (payload: { id: number }) => [
+ `/api/users/${payload.id}`,
+ '/api/users',
+ ]
+ const schema = defineSchema({
+ 'user.updated': { key: keyFn },
+ }) as Record
+
+ expect(typeof schema['user.updated']?.key).toBe('function')
+ expect(
+ (schema['user.updated']?.key as (p: { id: number }) => string[])({
+ id: 5,
+ }),
+ ).toEqual(['/api/users/5', '/api/users'])
+ })
+ })
+
+ describe('update property', () => {
+ it('should accept "set" as update strategy', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'user.updated': { key: '/api/users', update: 'set' },
+ }) as Record
+
+ expect(schema['user.updated']?.update).toBe('set')
+ })
+
+ it('should accept "refetch" as update strategy', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'user.updated': { key: '/api/users', update: 'refetch' },
+ }) as Record
+
+ expect(schema['user.updated']?.update).toBe('refetch')
+ })
+
+ it('should accept a custom function as update strategy', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const mergeFn = (
+ current: string[] | undefined,
+ payload: string,
+ ): string[] => [...(current ?? []), payload]
+
+ const schema = defineSchema({
+ 'item.added': { key: '/api/items', update: mergeFn },
+ }) as Record
+
+ expect(typeof schema['item.added']?.update).toBe('function')
+ expect(
+ (
+ schema['item.added']?.update as (c: string[], p: string) => string[]
+ )(['a'], 'b'),
+ ).toEqual(['a', 'b'])
+ })
+
+ it('should default update to "set" when not specified', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'user.updated': { key: '/api/users' },
+ }) as Record
+
+ expect(schema['user.updated']?.update).toBe('set')
+ })
+ })
+
+ describe('filter property (optional)', () => {
+ it('should accept an optional filter function', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const filterFn = (payload: { active: boolean }) => payload.active
+
+ const schema = defineSchema({
+ 'user.updated': { key: '/api/users', filter: filterFn },
+ }) as Record
+
+ expect(typeof schema['user.updated']?.filter).toBe('function')
+ expect(
+ (
+ schema['user.updated']?.filter as (p: {
+ active: boolean
+ }) => boolean
+ )({ active: true }),
+ ).toBe(true)
+ })
+
+ it('should allow filter to be omitted', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'user.updated': { key: '/api/users' },
+ }) as Record
+
+ // filter should be undefined or absent when not provided
+ expect(schema['user.updated']?.filter).toBeUndefined()
+ })
+ })
+
+ describe('transform property (optional)', () => {
+ it('should accept an optional transform function', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const transformFn = (payload: { name: string }) => ({
+ ...payload,
+ name: payload.name.toUpperCase(),
+ })
+
+ const schema = defineSchema({
+ 'user.updated': { key: '/api/users', transform: transformFn },
+ }) as Record
+
+ expect(typeof schema['user.updated']?.transform).toBe('function')
+ expect(
+ (
+ schema['user.updated']?.transform as (p: { name: string }) => {
+ name: string
+ }
+ )({ name: 'alice' }),
+ ).toEqual({ name: 'ALICE' })
+ })
+
+ it('should allow transform to be omitted', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'user.updated': { key: '/api/users' },
+ }) as Record
+
+ expect(schema['user.updated']?.transform).toBeUndefined()
+ })
+ })
+
+ describe('multiple events in one schema', () => {
+ it('should preserve all event definitions when multiple events are provided', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'user.updated': { key: '/api/users', update: 'set' },
+ 'order.placed': {
+ key: (payload: { orderId: string }) =>
+ `/api/orders/${payload.orderId}`,
+ update: 'refetch',
+ },
+ 'item.deleted': {
+ key: ['/api/items', '/api/cache'],
+ filter: (payload: { soft: boolean }) => !payload.soft,
+ },
+ }) as Record
+
+ expect(Object.keys(schema)).toHaveLength(3)
+ expect(Object.keys(schema)).toContain('user.updated')
+ expect(Object.keys(schema)).toContain('order.placed')
+ expect(Object.keys(schema)).toContain('item.deleted')
+ })
+
+ it('each event in the schema should carry its own definition', async () => {
+ const { defineSchema } = await import('../index.ts')
+
+ const schema = defineSchema({
+ 'a.event': { key: '/api/a', update: 'set' },
+ 'b.event': { key: '/api/b', update: 'refetch' },
+ }) as Record
+
+ expect(schema['a.event']?.key).toBe('/api/a')
+ expect(schema['a.event']?.update).toBe('set')
+ expect(schema['b.event']?.key).toBe('/api/b')
+ expect(schema['b.event']?.update).toBe('refetch')
+ })
+ })
+ })
+})
diff --git a/src/__tests__/sseParser.test.ts b/src/__tests__/sseParser.test.ts
new file mode 100644
index 0000000..f40fa11
--- /dev/null
+++ b/src/__tests__/sseParser.test.ts
@@ -0,0 +1,696 @@
+import { describe, expect, it, mock } from 'bun:test'
+import { createSSEParser } from '../sseParser.ts'
+
+/**
+ * Tests for SSE line parser (createSSEParser).
+ *
+ * This parser converts raw SSE wire format text into structured events.
+ * It handles incremental/chunked input from ReadableStream and must
+ * buffer partial lines across chunk boundaries.
+ *
+ * These tests should FAIL until src/sseParser.ts is implemented.
+ */
+
+describe('createSSEParser', () => {
+ describe('basic API', () => {
+ it('should return an object with feed() and reset() methods', () => {
+ const parser = createSSEParser({ onEvent: () => {} })
+
+ expect(parser).toBeDefined()
+ expect(typeof parser.feed).toBe('function')
+ expect(typeof parser.reset).toBe('function')
+ })
+ })
+
+ describe('data: field parsing', () => {
+ it('should parse a simple data field and dispatch event on blank line', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: hello world\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0]).toEqual({
+ data: 'hello world',
+ event: 'message',
+ id: '',
+ })
+ })
+
+ it('should parse data field without space after colon', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data:hello\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('hello')
+ })
+
+ it('should handle data with colons in the value', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: http://example.com:8080/path\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('http://example.com:8080/path')
+ })
+
+ it('should not dispatch event without a blank line terminator', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: hello\n')
+
+ expect(onEvent).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('multi-line data fields', () => {
+ it('should concatenate multiple data lines with newline', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: line1\ndata: line2\ndata: line3\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('line1\nline2\nline3')
+ })
+
+ it('should handle multi-line data with empty data lines', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: line1\ndata:\ndata: line3\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('line1\n\nline3')
+ })
+ })
+
+ describe('empty data field', () => {
+ it('should dispatch event with empty string data for data:\\n\\n', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data:\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('')
+ })
+
+ it('should dispatch event with empty string data for data: \\n\\n (with space)', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // Per SSE spec, the single space after colon is stripped
+ parser.feed('data: \n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('')
+ })
+ })
+
+ describe('event: field (named events)', () => {
+ it('should default event type to "message" when no event field', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: test\n\n')
+
+ expect(onEvent.mock.calls[0][0].event).toBe('message')
+ })
+
+ it('should use event field value as event type', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('event: user.updated\ndata: test\n\n')
+
+ expect(onEvent.mock.calls[0][0].event).toBe('user.updated')
+ })
+
+ it('should reset event type to "message" for subsequent events without event field', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('event: custom\ndata: first\n\ndata: second\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(2)
+ expect(onEvent.mock.calls[0][0].event).toBe('custom')
+ expect(onEvent.mock.calls[1][0].event).toBe('message')
+ })
+ })
+
+ describe('id: field', () => {
+ it('should track the id field', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('id: 42\ndata: test\n\n')
+
+ expect(onEvent.mock.calls[0][0].id).toBe('42')
+ })
+
+ it('should persist id across events until changed', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('id: 1\ndata: first\n\ndata: second\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(2)
+ expect(onEvent.mock.calls[0][0].id).toBe('1')
+ expect(onEvent.mock.calls[1][0].id).toBe('1')
+ })
+
+ it('should update id when a new id field is provided', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('id: 1\ndata: first\n\nid: 2\ndata: second\n\n')
+
+ expect(onEvent.mock.calls[0][0].id).toBe('1')
+ expect(onEvent.mock.calls[1][0].id).toBe('2')
+ })
+
+ it('should handle empty id field', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('id:\ndata: test\n\n')
+
+ expect(onEvent.mock.calls[0][0].id).toBe('')
+ })
+ })
+
+ describe('retry: field', () => {
+ it('should invoke onRetry callback with parsed milliseconds', () => {
+ const onEvent = mock(() => {})
+ const onRetry = mock(() => {})
+ const parser = createSSEParser({ onEvent, onRetry })
+
+ parser.feed('retry: 3000\ndata: test\n\n')
+
+ expect(onRetry).toHaveBeenCalledTimes(1)
+ expect(onRetry).toHaveBeenCalledWith(3000)
+ })
+
+ it('should ignore non-integer retry values', () => {
+ const onEvent = mock(() => {})
+ const onRetry = mock(() => {})
+ const parser = createSSEParser({ onEvent, onRetry })
+
+ parser.feed('retry: abc\ndata: test\n\n')
+
+ expect(onRetry).not.toHaveBeenCalled()
+ })
+
+ it('should handle retry without onRetry callback', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // Should not throw when onRetry is not provided
+ expect(() => {
+ parser.feed('retry: 3000\ndata: test\n\n')
+ }).not.toThrow()
+ })
+
+ it('should ignore negative retry values', () => {
+ const onEvent = mock(() => {})
+ const onRetry = mock(() => {})
+ const parser = createSSEParser({ onEvent, onRetry })
+
+ parser.feed('retry: -1000\ndata: test\n\n')
+
+ expect(onRetry).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('comment lines', () => {
+ it('should skip lines starting with colon', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed(': this is a comment\ndata: test\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+
+ it('should skip multiple comment lines', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed(': comment 1\n: comment 2\ndata: test\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+
+ it('should not dispatch event for comment-only blocks', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed(': just a comment\n\n')
+
+ expect(onEvent).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('malformed lines', () => {
+ it('should skip lines without a colon', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('malformed line\ndata: test\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+
+ it('should skip unknown field names', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('unknown: value\ndata: test\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+ })
+
+ describe('chunked input (partial lines across feed() calls)', () => {
+ it('should handle a line split across two feed calls', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('dat')
+ parser.feed('a: hello\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('hello')
+ })
+
+ it('should handle event split across multiple feed calls', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('event: custom\n')
+ parser.feed('data: part1\n')
+ parser.feed('data: part2\n')
+ parser.feed('\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].event).toBe('custom')
+ expect(onEvent.mock.calls[0][0].data).toBe('part1\npart2')
+ })
+
+ it('should handle blank line split across feed calls', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: test\n')
+ parser.feed('\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+
+ it('should handle data value split in the middle', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: hel')
+ parser.feed('lo world\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('hello world')
+ })
+
+ it('should handle multiple events across chunks', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: first\n\ndata: sec')
+ parser.feed('ond\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(2)
+ expect(onEvent.mock.calls[0][0].data).toBe('first')
+ expect(onEvent.mock.calls[1][0].data).toBe('second')
+ })
+ })
+
+ describe('BOM handling', () => {
+ it('should strip BOM at the start of stream', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('\uFEFFdata: test\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+
+ it('should only strip BOM at the very start, not in subsequent chunks', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: first\n\n')
+ parser.feed('\uFEFFdata: second\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(2)
+ // BOM in subsequent data should not be stripped from the wire format
+ // (but it won't affect field parsing since it's not at stream start)
+ })
+ })
+
+ describe('line terminators', () => {
+ it('should handle \\n line endings', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: test\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+
+ it('should handle \\r\\n line endings', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: test\r\n\r\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+
+ it('should handle \\r line endings', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: test\r\r')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+
+ it('should handle mixed line endings', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: line1\r\ndata: line2\rdata: line3\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('line1\nline2\nline3')
+ })
+ })
+
+ describe('reset()', () => {
+ it('should clear buffered state', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // Feed partial data
+ parser.feed('data: partial')
+ parser.reset()
+
+ // Feed new complete event
+ parser.feed('data: fresh\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('fresh')
+ })
+
+ it('should clear id state', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('id: 42\ndata: first\n\n')
+ parser.reset()
+ parser.feed('data: second\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(2)
+ expect(onEvent.mock.calls[0][0].id).toBe('42')
+ expect(onEvent.mock.calls[1][0].id).toBe('')
+ })
+
+ it('should clear event type state', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('event: custom\n')
+ parser.reset()
+ parser.feed('data: test\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].event).toBe('message')
+ })
+
+ it('should allow BOM stripping again after reset', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('\uFEFFdata: first\n\n')
+ parser.reset()
+ parser.feed('\uFEFFdata: second\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(2)
+ expect(onEvent.mock.calls[0][0].data).toBe('first')
+ expect(onEvent.mock.calls[1][0].data).toBe('second')
+ })
+ })
+
+ describe('event dispatch rules', () => {
+ it('should not dispatch event if no data field was set', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // Only event field, no data field
+ parser.feed('event: custom\n\n')
+
+ expect(onEvent).not.toHaveBeenCalled()
+ })
+
+ it('should dispatch multiple events separated by blank lines', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: first\n\ndata: second\n\ndata: third\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(3)
+ expect(onEvent.mock.calls[0][0].data).toBe('first')
+ expect(onEvent.mock.calls[1][0].data).toBe('second')
+ expect(onEvent.mock.calls[2][0].data).toBe('third')
+ })
+
+ it('should handle all fields together', () => {
+ const onEvent = mock(() => {})
+ const onRetry = mock(() => {})
+ const parser = createSSEParser({ onEvent, onRetry })
+
+ parser.feed('id: 99\nevent: update\nretry: 5000\ndata: payload\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0]).toEqual({
+ data: 'payload',
+ event: 'update',
+ id: '99',
+ })
+ expect(onRetry).toHaveBeenCalledWith(5000)
+ })
+ })
+
+ describe('edge cases (probing)', () => {
+ it('should handle \\r\\n split across two chunks', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // Chunk 1 ends with \r, chunk 2 starts with \n
+ // This is a single \r\n line ending split across chunks
+ parser.feed('data: hello\r')
+ parser.feed('\ndata: world\n\n')
+
+ // Should produce exactly one event with two data lines
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('hello\nworld')
+ })
+
+ it('should handle \\r at end of chunk followed by \\n at start of next (with dispatch)', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // data: test\r is chunk boundary, then \n\n triggers dispatch
+ parser.feed('data: test\r')
+ parser.feed('\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+
+ it('should handle empty feed calls', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('')
+ parser.feed('')
+ parser.feed('data: test\n\n')
+ parser.feed('')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+
+ it('should handle unicode/emoji in data', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: Hello \u{1F600}\u{1F389} World\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe(
+ 'Hello \u{1F600}\u{1F389} World',
+ )
+ })
+
+ it('should handle unicode split across chunks', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // Note: JavaScript strings are UTF-16, so multi-byte chars won't
+ // actually split at byte boundary here. But we can split the text.
+ parser.feed('data: Hello \u{1F600}')
+ parser.feed(' World\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('Hello \u{1F600} World')
+ })
+
+ it('should handle very large data payloads', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ const largePayload = 'x'.repeat(1_000_000)
+ parser.feed(`data: ${largePayload}\n\n`)
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe(largePayload)
+ })
+
+ it('should handle rapid sequential events', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ for (let i = 0; i < 100; i++) {
+ parser.feed(`data: event-${i}\n\n`)
+ }
+
+ expect(onEvent).toHaveBeenCalledTimes(100)
+ expect(onEvent.mock.calls[0][0].data).toBe('event-0')
+ expect(onEvent.mock.calls[99][0].data).toBe('event-99')
+ })
+
+ it('should handle feed() after reset() correctly', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('id: 10\nevent: custom\ndata: first')
+ parser.reset()
+
+ // After reset, should behave like a fresh parser
+ parser.feed('data: fresh start\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('fresh start')
+ expect(onEvent.mock.calls[0][0].event).toBe('message')
+ expect(onEvent.mock.calls[0][0].id).toBe('')
+ })
+
+ it('should handle multiple blank lines between events', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ parser.feed('data: first\n\n\n\ndata: second\n\n')
+
+ // Extra blank lines should NOT produce extra events (no data pending)
+ expect(onEvent).toHaveBeenCalledTimes(2)
+ expect(onEvent.mock.calls[0][0].data).toBe('first')
+ expect(onEvent.mock.calls[1][0].data).toBe('second')
+ })
+
+ it('should handle data field with only spaces (not stripped)', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // "data: " -> strip leading space -> " " (two spaces)
+ parser.feed('data: \n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe(' ')
+ })
+
+ it('should handle retry with float value (not integer)', () => {
+ const onEvent = mock(() => {})
+ const onRetry = mock(() => {})
+ const parser = createSSEParser({ onEvent, onRetry })
+
+ parser.feed('retry: 3.5\ndata: test\n\n')
+
+ // 3.5 is not an integer, so onRetry should NOT be called
+ expect(onRetry).not.toHaveBeenCalled()
+ })
+
+ it('should handle retry: 0 as valid', () => {
+ const onEvent = mock(() => {})
+ const onRetry = mock(() => {})
+ const parser = createSSEParser({ onEvent, onRetry })
+
+ parser.feed('retry: 0\ndata: test\n\n')
+
+ expect(onRetry).toHaveBeenCalledTimes(1)
+ expect(onRetry).toHaveBeenCalledWith(0)
+ })
+
+ it('should handle BOM in processLine (mid-stream BOM at line start)', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // First chunk consumed the BOM check in feed(), but a BOM
+ // at the start of a line should be stripped by processLine
+ parser.feed('data: first\n\n')
+ parser.feed('\uFEFFdata: second\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(2)
+ expect(onEvent.mock.calls[0][0].data).toBe('first')
+ // The BOM in the second chunk is NOT stripped by feed() (not firstChunk).
+ // processLine sees "\uFEFFdata: second" -- the BOM is at charCodeAt(0),
+ // so it strips it, leaving "data: second" which parses correctly.
+ expect(onEvent.mock.calls[1][0].data).toBe('second')
+ })
+
+ it('should handle standalone \\r at chunk end (not followed by \\n in next chunk)', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // \r alone is a valid line terminator; next chunk starts a new line
+ parser.feed('data: hello\r')
+ parser.feed('data: world\n\n')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('hello\nworld')
+ })
+
+ it('should handle \\r\\r split across chunks (two \\r line endings)', () => {
+ const onEvent = mock(() => {})
+ const parser = createSSEParser({ onEvent })
+
+ // First \r ends the data line, second \r is the empty line that dispatches
+ parser.feed('data: test\r')
+ parser.feed('\r')
+
+ expect(onEvent).toHaveBeenCalledTimes(1)
+ expect(onEvent.mock.calls[0][0].data).toBe('test')
+ })
+ })
+})
diff --git a/src/__tests__/tabVisibility.test.tsx b/src/__tests__/tabVisibility.test.tsx
index 3ae08ae..4a938ff 100644
--- a/src/__tests__/tabVisibility.test.tsx
+++ b/src/__tests__/tabVisibility.test.tsx
@@ -412,7 +412,7 @@ describe('SSEProvider Tab Visibility Handling', () => {
// Reconnect timer is scheduled
expect(pendingTimers.size).toBeGreaterThan(0)
- const timersBefore = pendingTimers.size
+ const _timersBefore = pendingTimers.size
// Note: This test may need adjustment based on implementation
// If visibility triggers immediate reconnect and cancels timer, that's also valid
@@ -606,7 +606,7 @@ describe('SSEProvider Tab Visibility Handling', () => {
// Reconnect via backoff
advanceTimersByTime(1000)
- expect(capturedStatus!.reconnectAttempt).toBe(1)
+ expect(capturedStatus?.reconnectAttempt).toBe(1)
// That also fails
MockEventSource.getLastInstance()?.simulateConnectionFailure()
@@ -618,7 +618,7 @@ describe('SSEProvider Tab Visibility Handling', () => {
MockEventSource.getLastInstance()?.simulateOpen()
// Attempt count should be reset
- expect(capturedStatus!.reconnectAttempt).toBe(0)
+ expect(capturedStatus?.reconnectAttempt).toBe(0)
})
it('should call onConnect callback on visibility-triggered reconnection success', async () => {
@@ -783,23 +783,23 @@ describe('SSEProvider Tab Visibility Handling', () => {
// Initial connection
MockEventSource.getLastInstance()?.simulateOpen()
- expect(capturedStatus!.connected).toBe(true)
- expect(capturedStatus!.connecting).toBe(false)
+ expect(capturedStatus?.connected).toBe(true)
+ expect(capturedStatus?.connecting).toBe(false)
// Connection fails
MockEventSource.getLastInstance()?.simulateConnectionFailure()
- expect(capturedStatus!.connected).toBe(false)
- expect(capturedStatus!.error).not.toBeNull()
+ expect(capturedStatus?.connected).toBe(false)
+ expect(capturedStatus?.error).not.toBeNull()
// Reconnect via visibility
dispatchVisibilityChange('visible')
- expect(capturedStatus!.connecting).toBe(true)
+ expect(capturedStatus?.connecting).toBe(true)
// Reconnect succeeds
MockEventSource.getLastInstance()?.simulateOpen()
- expect(capturedStatus!.connected).toBe(true)
- expect(capturedStatus!.connecting).toBe(false)
- expect(capturedStatus!.error).toBeNull()
+ expect(capturedStatus?.connected).toBe(true)
+ expect(capturedStatus?.connecting).toBe(false)
+ expect(capturedStatus?.error).toBeNull()
})
})
})
diff --git a/src/__tests__/testing-utils-sendSSE.test.ts b/src/__tests__/testing-utils-sendSSE.test.ts
new file mode 100644
index 0000000..f014290
--- /dev/null
+++ b/src/__tests__/testing-utils-sendSSE.test.ts
@@ -0,0 +1,245 @@
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+
+/**
+ * Tests for mockSSE.sendSSE() convenience method.
+ *
+ * These tests verify that the new sendSSE(data) method:
+ * 1. Exists on MockSSEControls
+ * 2. Sends the correct SSE wire format: `data: \n\n`
+ * 3. Works with objects, arrays, strings, numbers, and null
+ * 4. Does not break existing sendEvent() and sendRaw() methods
+ * 5. Works with fetch-based transports
+ *
+ * Tests FAIL initially because sendSSE has not been implemented yet.
+ */
+
+describe('mockSSE.sendSSE()', () => {
+ // biome-ignore lint/suspicious/noExplicitAny: dynamically imported testing utility
+ let mockSSE: any
+ let originalFetch: typeof globalThis.fetch
+
+ beforeEach(async () => {
+ originalFetch = globalThis.fetch
+ // Always re-import to get a fresh module state
+ mockSSE = (await import('../testing/index.ts')).mockSSE
+ })
+
+ afterEach(() => {
+ if (mockSSE?.restore) {
+ mockSSE.restore()
+ }
+ if (globalThis.fetch !== originalFetch) {
+ globalThis.fetch = originalFetch
+ }
+ })
+
+ describe('Req #31 - method existence on MockSSEControls', () => {
+ it('sendSSE should exist on the controls object returned by mockSSE()', () => {
+ const controls = mockSSE('/api/events')
+
+ expect(controls.sendSSE).toBeDefined()
+ expect(typeof controls.sendSSE).toBe('function')
+ })
+
+ it('MockSSEControls type should include sendSSE (runtime check)', () => {
+ const controls: import('../testing/index.ts').MockSSEControls =
+ mockSSE('/api/events')
+
+ // Type-level: MockSSEControls must have sendSSE
+ expect('sendSSE' in controls).toBe(true)
+ })
+ })
+
+ describe('Req #32 - correct SSE wire format via fetch stream', () => {
+ it('sendSSE(object) should enqueue `data: \\n\\n` to fetch stream', async () => {
+ const mock = mockSSE('/api/events')
+
+ const chunks: string[] = []
+ const decoder = new TextDecoder()
+
+ const response = await fetch('/api/events')
+ const reader = response.body?.getReader()
+
+ // Send via sendSSE
+ mock.sendSSE({ type: 'test', value: 42 })
+
+ const { value } = await reader.read()
+ chunks.push(decoder.decode(value))
+
+ reader.cancel()
+
+ expect(chunks[0]).toBe('data: {"type":"test","value":42}\n\n')
+ })
+
+ it('sendSSE should produce the same wire format as sendRaw would manually', async () => {
+ const url = '/api/events-compare'
+ const mockA = mockSSE(url)
+
+ const chunksSSE: string[] = []
+ const decoder = new TextDecoder()
+
+ const response = await fetch(url)
+ const reader = response.body?.getReader()
+
+ const data = { id: 1, name: 'alice' }
+ mockA.sendSSE(data)
+
+ const { value } = await reader.read()
+ chunksSSE.push(decoder.decode(value))
+
+ reader.cancel()
+
+ const expected = `data: ${JSON.stringify(data)}\n\n`
+ expect(chunksSSE[0]).toBe(expected)
+ })
+ })
+
+ describe('Req #32 - various data types', () => {
+ async function captureSendSSE(data: unknown): Promise {
+ const url = `/api/events-type-${Math.random()}`
+ const mock = mockSSE(url)
+ const decoder = new TextDecoder()
+
+ const response = await fetch(url)
+ const reader = response.body?.getReader()
+
+ mock.sendSSE(data)
+
+ const { value } = await reader.read()
+ const text = decoder.decode(value)
+ reader.cancel()
+ return text
+ }
+
+ it('should correctly encode a plain object', async () => {
+ const result = await captureSendSSE({ foo: 'bar', count: 3 })
+ expect(result).toBe('data: {"foo":"bar","count":3}\n\n')
+ })
+
+ it('should correctly encode an array', async () => {
+ const result = await captureSendSSE([1, 2, 3])
+ expect(result).toBe('data: [1,2,3]\n\n')
+ })
+
+ it('should correctly encode a string', async () => {
+ const result = await captureSendSSE('hello world')
+ expect(result).toBe('data: "hello world"\n\n')
+ })
+
+ it('should correctly encode a number', async () => {
+ const result = await captureSendSSE(99)
+ expect(result).toBe('data: 99\n\n')
+ })
+
+ it('should correctly encode null', async () => {
+ const result = await captureSendSSE(null)
+ expect(result).toBe('data: null\n\n')
+ })
+
+ it('should correctly encode a boolean true', async () => {
+ const result = await captureSendSSE(true)
+ expect(result).toBe('data: true\n\n')
+ })
+
+ it('should correctly encode a nested object', async () => {
+ const result = await captureSendSSE({ user: { id: 7, roles: ['admin'] } })
+ expect(result).toBe('data: {"user":{"id":7,"roles":["admin"]}}\n\n')
+ })
+ })
+
+ describe('Req #33 - no breaking changes to existing methods', () => {
+ it('sendEvent() should still work after sendSSE is added', () => {
+ const mock = mockSSE('/api/events')
+ const es = new EventSource('/api/events')
+
+ let received: MessageEvent | null = null
+ es.onmessage = (event: MessageEvent) => {
+ received = event
+ }
+
+ // sendEvent should still work
+ mock.sendEvent({ type: 'user.updated', payload: { id: 1 } })
+
+ expect(received).not.toBeNull()
+ const parsed = JSON.parse((received as MessageEvent).data)
+ expect(parsed.type).toBe('user.updated')
+ expect(parsed.payload).toEqual({ id: 1 })
+ })
+
+ it('sendRaw() should still work after sendSSE is added', async () => {
+ const mock = mockSSE('/api/events')
+ const decoder = new TextDecoder()
+
+ const response = await fetch('/api/events')
+ const reader = response.body?.getReader()
+
+ const rawText = 'data: raw line\n\n'
+ mock.sendRaw(rawText)
+
+ const { value } = await reader.read()
+ const text = decoder.decode(value)
+ reader.cancel()
+
+ expect(text).toBe(rawText)
+ })
+
+ it('close() should still work after sendSSE is added', () => {
+ const mock = mockSSE('/api/events')
+ const es = new EventSource('/api/events')
+
+ mock.close()
+
+ expect(es.readyState).toBe(EventSource.CLOSED)
+ })
+
+ it('getConnection() should still work after sendSSE is added', () => {
+ const mock = mockSSE('/api/events')
+ const es = new EventSource('/api/events')
+
+ expect(mock.getConnection()).toBe(es)
+ })
+
+ it('mockSSE.restore() should still work after sendSSE is added', () => {
+ mockSSE('/api/events')
+
+ expect(() => {
+ mockSSE.restore()
+ }).not.toThrow()
+ })
+ })
+
+ describe('sendSSE with EventSource transport', () => {
+ it('sendSSE should not throw when only an EventSource connection exists (no fetch stream)', () => {
+ const mock = mockSSE('/api/events')
+ // Open via EventSource, not fetch
+ new EventSource('/api/events')
+
+ // sendSSE delegates to sendRaw which targets fetch streams;
+ // if no fetch stream exists it should silently no-op, not throw
+ expect(() => {
+ mock.sendSSE({ type: 'ping', value: 1 })
+ }).not.toThrow()
+ })
+ })
+
+ describe('sendSSE called multiple times', () => {
+ it('should deliver each call as a separate SSE chunk', async () => {
+ const mock = mockSSE('/api/events')
+ const decoder = new TextDecoder()
+
+ const response = await fetch('/api/events')
+ const reader = response.body?.getReader()
+
+ mock.sendSSE({ seq: 1 })
+ mock.sendSSE({ seq: 2 })
+
+ const first = await reader.read()
+ const second = await reader.read()
+
+ reader.cancel()
+
+ expect(decoder.decode(first.value)).toBe('data: {"seq":1}\n\n')
+ expect(decoder.decode(second.value)).toBe('data: {"seq":2}\n\n')
+ })
+ })
+})
diff --git a/src/__tests__/testing-utils-transport.test.ts b/src/__tests__/testing-utils-transport.test.ts
new file mode 100644
index 0000000..3f373c3
--- /dev/null
+++ b/src/__tests__/testing-utils-transport.test.ts
@@ -0,0 +1,511 @@
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+
+/**
+ * Tests for transport-aware mockSSE testing utility.
+ *
+ * These tests verify that mockSSE supports both EventSource and fetch-based
+ * transports for testing components that use either transport layer.
+ *
+ * The tests cover:
+ * 1. Backward compatibility - EventSource mocking still works
+ * 2. Fetch interception - mockSSE intercepts fetch calls to registered URLs
+ * 3. Fetch passthrough - non-SSE fetch calls are not intercepted
+ * 4. sendEvent for fetch - produces SSE wire format chunks
+ * 5. sendRaw - sends raw SSE wire format text
+ * 6. close for fetch - closes fetch-based connections
+ * 7. mockSSE.restore() cleans up both EventSource and fetch mocks
+ * 8. Multiple simultaneous mocks (EventSource + fetch)
+ * 9. MockSSEControls type includes sendRaw
+ * 10. Existing mockSSE tests remain unbroken
+ *
+ * These tests should FAIL until src/testing/index.ts is updated with
+ * transport-aware support.
+ */
+
+describe('mockSSE transport-aware', () => {
+ // biome-ignore lint/suspicious/noExplicitAny: dynamically imported testing utility
+ let mockSSE: any
+ let originalFetch: typeof globalThis.fetch
+
+ beforeEach(async () => {
+ originalFetch = globalThis.fetch
+ mockSSE = (await import('../testing/index.ts')).mockSSE
+ })
+
+ afterEach(() => {
+ if (mockSSE?.restore) {
+ mockSSE.restore()
+ }
+ // Safety net: ensure fetch is restored even if restore() fails
+ if (globalThis.fetch !== originalFetch) {
+ globalThis.fetch = originalFetch
+ }
+ })
+
+ describe('backward compatibility - EventSource transport', () => {
+ it('should still intercept EventSource constructor', () => {
+ const mock = mockSSE('/api/events')
+ const es = new EventSource('/api/events')
+
+ expect(es).toBeDefined()
+ expect(es.url).toBe('/api/events')
+ expect(mock.getConnection()).toBe(es)
+ })
+
+ it('should still deliver events via onmessage on EventSource', () => {
+ const mock = mockSSE('/api/events')
+ const es = new EventSource('/api/events')
+
+ let received: MessageEvent | null = null
+ es.onmessage = (event: MessageEvent) => {
+ received = event
+ }
+
+ mock.sendEvent({ type: 'update', payload: { id: 1 } })
+
+ expect(received).not.toBeNull()
+ const parsed = JSON.parse(received?.data)
+ expect(parsed.type).toBe('update')
+ expect(parsed.payload).toEqual({ id: 1 })
+ })
+
+ it('should still support close on EventSource connections', () => {
+ const mock = mockSSE('/api/events')
+ const es = new EventSource('/api/events')
+
+ mock.close()
+
+ expect(es.readyState).toBe(EventSource.CLOSED)
+ })
+ })
+
+ describe('fetch interception', () => {
+ it('should intercept fetch calls to a mocked URL', async () => {
+ mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+
+ expect(response).toBeDefined()
+ expect(response.ok).toBe(true)
+ expect(response.body).toBeInstanceOf(ReadableStream)
+ })
+
+ it('should return a Response with correct SSE content-type header', async () => {
+ mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+
+ const contentType = response.headers.get('content-type')
+ expect(contentType).toContain('text/event-stream')
+ })
+
+ it('should return a 200 status for mocked fetch URLs', async () => {
+ mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+
+ expect(response.status).toBe(200)
+ })
+
+ it('should NOT intercept fetch calls to non-mocked URLs', async () => {
+ mockSSE('/api/stream')
+
+ // This URL is not mocked, so it should go through to the real fetch
+ // We expect it to fail or return a real response, not a mock
+ let usedRealFetch = false
+ const _savedFetch = originalFetch
+ // We can detect passthrough by checking if the original fetch was called
+ // Since non-mocked URLs may fail in test env, we catch the error
+ try {
+ const _response = await fetch('https://example.com/not-mocked')
+ // If we get here, real fetch was used (or mock incorrectly intercepted)
+ // Check that the response is NOT a mock SSE response
+ usedRealFetch = true
+ } catch {
+ // Real fetch may throw in test environment (no network) - that's fine,
+ // it means the call was NOT intercepted by our mock
+ usedRealFetch = true
+ }
+
+ expect(usedRealFetch).toBe(true)
+ })
+
+ it('should intercept fetch with Request object for mocked URL', async () => {
+ mockSSE('/api/stream')
+
+ const request = new Request('/api/stream')
+ const response = await fetch(request)
+
+ expect(response).toBeDefined()
+ expect(response.ok).toBe(true)
+ expect(response.body).toBeInstanceOf(ReadableStream)
+ })
+ })
+
+ describe('sendEvent for fetch-based connections', () => {
+ it('should produce SSE wire format data in the ReadableStream', async () => {
+ const mock = mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+
+ mock.sendEvent({ type: 'update', payload: { id: 42 } })
+
+ const { value, done } = await reader.read()
+ expect(done).toBe(false)
+
+ const text = decoder.decode(value)
+ // SSE wire format: "event: \ndata: \n\n"
+ expect(text).toContain('event:')
+ expect(text).toContain('data:')
+ expect(text).toContain('\n\n')
+
+ // The event field should contain the event type
+ const eventMatch = text.match(/event:\s*(.+)\n/)
+ expect(eventMatch).not.toBeNull()
+ expect(eventMatch?.[1]).toBe('update')
+
+ // The data field should contain JSON with the payload only
+ const dataMatch = text.match(/data:\s*(.+)\n/)
+ expect(dataMatch).not.toBeNull()
+
+ const parsed = JSON.parse(dataMatch?.[1])
+ expect(parsed).toEqual({ id: 42 })
+ })
+
+ it('should deliver multiple events as separate SSE chunks', async () => {
+ const mock = mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+
+ mock.sendEvent({ type: 'first', payload: { n: 1 } })
+ mock.sendEvent({ type: 'second', payload: { n: 2 } })
+
+ // Read chunks - may come as one or two reads
+ let allText = ''
+ const { value: v1 } = await reader.read()
+ allText += decoder.decode(v1, { stream: true })
+
+ // Try reading again for second event if not already included
+ if (!allText.includes('second')) {
+ const { value: v2 } = await reader.read()
+ allText += decoder.decode(v2, { stream: true })
+ }
+
+ expect(allText).toContain('first')
+ expect(allText).toContain('second')
+ })
+ })
+
+ describe('sendRaw', () => {
+ it('should be a function on MockSSEControls', () => {
+ const mock = mockSSE('/api/stream')
+
+ expect(typeof mock.sendRaw).toBe('function')
+ })
+
+ it('should send raw SSE wire format text to fetch-based connections', async () => {
+ const mock = mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+
+ // Send raw SSE format (e.g., with custom event type)
+ mock.sendRaw('event: custom\ndata: {"hello":"world"}\n\n')
+
+ const { value } = await reader.read()
+ const text = decoder.decode(value)
+
+ expect(text).toBe('event: custom\ndata: {"hello":"world"}\n\n')
+ })
+
+ it('should send raw SSE wire format with retry field', async () => {
+ const mock = mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+
+ mock.sendRaw('retry: 5000\ndata: reconnect\n\n')
+
+ const { value } = await reader.read()
+ const text = decoder.decode(value)
+
+ expect(text).toContain('retry: 5000')
+ expect(text).toContain('data: reconnect')
+ })
+
+ it('should send raw SSE wire format with id field', async () => {
+ const mock = mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+
+ mock.sendRaw('id: 123\ndata: identified\n\n')
+
+ const { value } = await reader.read()
+ const text = decoder.decode(value)
+
+ expect(text).toContain('id: 123')
+ expect(text).toContain('data: identified')
+ })
+
+ it('should send raw comments for keep-alive', async () => {
+ const mock = mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+
+ mock.sendRaw(': keepalive\n\n')
+
+ const { value } = await reader.read()
+ const text = decoder.decode(value)
+
+ expect(text).toBe(': keepalive\n\n')
+ })
+ })
+
+ describe('close for fetch-based connections', () => {
+ it('should close the ReadableStream when close() is called', async () => {
+ const mock = mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+ const reader = response.body?.getReader()
+
+ mock.close()
+
+ const { done } = await reader.read()
+ expect(done).toBe(true)
+ })
+
+ it('should not deliver events after close on fetch connections', async () => {
+ const mock = mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+ const reader = response.body?.getReader()
+
+ // Send one event before close
+ mock.sendEvent({ type: 'before', payload: {} })
+ const { value } = await reader.read()
+ expect(value).toBeDefined()
+
+ // Close and try to send another
+ mock.close()
+ mock.sendEvent({ type: 'after', payload: {} })
+
+ const { done } = await reader.read()
+ expect(done).toBe(true)
+ })
+ })
+
+ describe('mockSSE.restore() cleanup', () => {
+ it('should restore the original fetch function', () => {
+ mockSSE('/api/stream')
+ mockSSE.restore()
+
+ expect(globalThis.fetch).toBe(originalFetch)
+ })
+
+ it('should restore both EventSource and fetch simultaneously', () => {
+ const originalES = globalThis.EventSource
+
+ mockSSE('/api/events')
+ mockSSE.restore()
+
+ // fetch should be restored
+ expect(globalThis.fetch).toBe(originalFetch)
+
+ // EventSource should be restored
+ if (originalES) {
+ expect(globalThis.EventSource).toBe(originalES)
+ }
+ })
+
+ it('should close all active fetch-based streams on restore', async () => {
+ const _mock = mockSSE('/api/stream')
+
+ const response = await fetch('/api/stream')
+ const reader = response.body?.getReader()
+
+ mockSSE.restore()
+
+ // Stream should be closed after restore
+ const { done } = await reader.read()
+ expect(done).toBe(true)
+ })
+ })
+
+ describe('multiple simultaneous mocks', () => {
+ it('should support EventSource and fetch mocks for different URLs', () => {
+ const esMock = mockSSE('/api/events')
+ const fetchMock = mockSSE('/api/stream')
+
+ // EventSource mock works
+ const es = new EventSource('/api/events')
+ let esReceived: MessageEvent | null = null
+ es.onmessage = (event: MessageEvent) => {
+ esReceived = event
+ }
+ esMock.sendEvent({ type: 'es-event', payload: { source: 'eventsource' } })
+ expect(esReceived).not.toBeNull()
+
+ // fetch mock works (type check only - full async test elsewhere)
+ expect(typeof fetchMock.sendEvent).toBe('function')
+ expect(typeof fetchMock.sendRaw).toBe('function')
+ })
+
+ it('should isolate fetch mocks for different URLs', async () => {
+ const mock1 = mockSSE('/api/stream1')
+ const mock2 = mockSSE('/api/stream2')
+
+ const response1 = await fetch('/api/stream1')
+ const response2 = await fetch('/api/stream2')
+
+ const reader1 = response1.body?.getReader()
+ const reader2 = response2.body?.getReader()
+ const decoder = new TextDecoder()
+
+ mock1.sendEvent({ type: 'from-1', payload: { source: 1 } })
+ mock2.sendEvent({ type: 'from-2', payload: { source: 2 } })
+
+ const { value: v1 } = await reader1.read()
+ const { value: v2 } = await reader2.read()
+
+ const text1 = decoder.decode(v1)
+ const text2 = decoder.decode(v2)
+
+ expect(text1).toContain('from-1')
+ expect(text2).toContain('from-2')
+ expect(text1).not.toContain('from-2')
+ expect(text2).not.toContain('from-1')
+ })
+ })
+
+ describe('MockSSEControls type', () => {
+ it('should have sendRaw method on controls', () => {
+ const mock = mockSSE('/api/stream')
+
+ expect(mock).toHaveProperty('sendRaw')
+ expect(typeof mock.sendRaw).toBe('function')
+ })
+
+ it('should have sendEvent method on controls', () => {
+ const mock = mockSSE('/api/stream')
+
+ expect(mock).toHaveProperty('sendEvent')
+ expect(typeof mock.sendEvent).toBe('function')
+ })
+
+ it('should have close method on controls', () => {
+ const mock = mockSSE('/api/stream')
+
+ expect(mock).toHaveProperty('close')
+ expect(typeof mock.close).toBe('function')
+ })
+
+ it('should have getConnection method on controls', () => {
+ const mock = mockSSE('/api/stream')
+
+ expect(mock).toHaveProperty('getConnection')
+ expect(typeof mock.getConnection).toBe('function')
+ })
+ })
+
+ describe('existing test compatibility', () => {
+ it('should not break existing mockSSE API - sendEvent + onmessage', () => {
+ const mock = mockSSE('/api/events')
+ const es = new EventSource('/api/events')
+
+ let data: string | null = null
+ es.onmessage = (e: MessageEvent) => {
+ data = e.data
+ }
+
+ mock.sendEvent({ type: 'test', payload: { value: 99 } })
+
+ expect(data).not.toBeNull()
+ const parsed = JSON.parse(data as string)
+ expect(parsed.type).toBe('test')
+ expect(parsed.payload.value).toBe(99)
+ })
+
+ it('should not break existing mockSSE API - addEventListener', () => {
+ const mock = mockSSE('/api/events')
+ const es = new EventSource('/api/events')
+
+ let received = false
+ es.addEventListener('custom', () => {
+ received = true
+ })
+
+ mock.sendEvent({ type: 'custom', payload: {} })
+
+ expect(received).toBe(true)
+ })
+
+ it('should not break existing mockSSE API - close triggers onerror', () => {
+ const mock = mockSSE('/api/events')
+ const es = new EventSource('/api/events')
+
+ let errorFired = false
+ es.onerror = () => {
+ errorFired = true
+ }
+
+ mock.close()
+
+ expect(errorFired).toBe(true)
+ expect(es.readyState).toBe(EventSource.CLOSED)
+ })
+
+ it('should not break existing mockSSE API - restore prevents further events', () => {
+ const mock = mockSSE('/api/events')
+ const es = new EventSource('/api/events')
+
+ let count = 0
+ es.onmessage = () => {
+ count++
+ }
+
+ mock.sendEvent({ type: 'test', payload: {} })
+ expect(count).toBe(1)
+
+ mockSSE.restore()
+
+ try {
+ mock.sendEvent({ type: 'test', payload: {} })
+ } catch {
+ // May throw after restore
+ }
+
+ expect(count).toBe(1)
+ })
+
+ it('should not break existing mockSSE API - multiple URLs independently', () => {
+ const mock1 = mockSSE('/api/a')
+ const mock2 = mockSSE('/api/b')
+
+ const es1 = new EventSource('/api/a')
+ const es2 = new EventSource('/api/b')
+
+ let count1 = 0
+ let count2 = 0
+
+ es1.onmessage = () => count1++
+ es2.onmessage = () => count2++
+
+ mock1.sendEvent({ type: 't', payload: {} })
+ mock2.sendEvent({ type: 't', payload: {} })
+ mock2.sendEvent({ type: 't', payload: {} })
+
+ expect(count1).toBe(1)
+ expect(count2).toBe(2)
+ })
+ })
+})
diff --git a/src/__tests__/testing-utils.test.ts b/src/__tests__/testing-utils.test.ts
index 80f8597..80a91d1 100644
--- a/src/__tests__/testing-utils.test.ts
+++ b/src/__tests__/testing-utils.test.ts
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
*/
describe('mockSSE', () => {
+ // biome-ignore lint/suspicious/noExplicitAny: dynamically imported testing utility
let mockSSE: any
let originalEventSource: typeof EventSource | undefined
@@ -62,14 +63,14 @@ describe('mockSSE', () => {
describe('readyState transitions', () => {
it('should start in CONNECTING state', () => {
- const mock = mockSSE('/api/events')
+ const _mock = mockSSE('/api/events')
const eventSource = new EventSource('/api/events')
expect(eventSource.readyState).toBe(EventSource.CONNECTING)
})
it('should transition to OPEN state after connection', () => {
- const mock = mockSSE('/api/events')
+ const _mock = mockSSE('/api/events')
const eventSource = new EventSource('/api/events')
// Simulate connection open (implementation may auto-transition or require manual trigger)
@@ -109,7 +110,7 @@ describe('mockSSE', () => {
expect(receivedEvent).not.toBeNull()
expect(receivedEvent?.data).toBeDefined()
- const parsedData = JSON.parse(receivedEvent!.data)
+ const parsedData = JSON.parse(receivedEvent?.data)
expect(parsedData.type).toBe('order:updated')
expect(parsedData.payload).toEqual({ id: '123', status: 'shipped' })
})
@@ -132,7 +133,7 @@ describe('mockSSE', () => {
expect(receivedEvent).not.toBeNull()
expect(receivedEvent?.data).toBeDefined()
- const parsedData = JSON.parse(receivedEvent!.data)
+ const parsedData = JSON.parse(receivedEvent?.data)
expect(parsedData.type).toBe('order:updated')
expect(parsedData.payload).toEqual({ id: '123', status: 'shipped' })
})
@@ -149,9 +150,9 @@ describe('mockSSE', () => {
mock.sendEvent({ type: 'test', payload: { value: 42 } })
expect(typeof receivedData).toBe('string')
- expect(() => JSON.parse(receivedData!)).not.toThrow()
+ expect(() => JSON.parse(receivedData as string)).not.toThrow()
- const parsed = JSON.parse(receivedData!)
+ const parsed = JSON.parse(receivedData as string)
expect(parsed.type).toBe('test')
expect(parsed.payload.value).toBe(42)
})
@@ -220,8 +221,8 @@ describe('mockSSE', () => {
expect(received1).not.toBeNull()
expect(received2).not.toBeNull()
- const data1 = JSON.parse(received1!.data)
- const data2 = JSON.parse(received2!.data)
+ const data1 = JSON.parse(received1?.data)
+ const data2 = JSON.parse(received2?.data)
expect(data1.payload.source).toBe(1)
expect(data2.payload.source).toBe(2)
@@ -254,7 +255,7 @@ describe('mockSSE', () => {
it('should isolate close calls to correct mock', () => {
const mock1 = mockSSE('/api/events1')
- const mock2 = mockSSE('/api/events2')
+ const _mock2 = mockSSE('/api/events2')
const eventSource1 = new EventSource('/api/events1')
const eventSource2 = new EventSource('/api/events2')
@@ -268,7 +269,7 @@ describe('mockSSE', () => {
describe('restore', () => {
it('should restore original EventSource constructor', () => {
- const mock = mockSSE('/api/events')
+ const _mock = mockSSE('/api/events')
// Create mock instance
const mockInstance = new EventSource('/api/events')
@@ -354,7 +355,7 @@ describe('mockSSE', () => {
const mock = mockSSE('/api/events')
const eventSource = new EventSource('/api/events')
- let receivedPayload: any = null
+ let receivedPayload: Record | null = null
eventSource.onmessage = (event: MessageEvent) => {
const parsed = JSON.parse(event.data)
receivedPayload = parsed.payload
diff --git a/src/__tests__/transport-types.test.ts b/src/__tests__/transport-types.test.ts
new file mode 100644
index 0000000..932960a
--- /dev/null
+++ b/src/__tests__/transport-types.test.ts
@@ -0,0 +1,397 @@
+import { describe, expect, it } from 'bun:test'
+import type {
+ EventMapping,
+ ParsedEvent,
+ ReconnectConfig,
+ SSEConfig,
+ SSEProviderProps,
+ SSERequestOptions,
+ SSEStatus,
+ SSETransport,
+ UpdateStrategy,
+} from '../types.ts'
+
+/**
+ * Type-level tests for transport abstraction types.
+ *
+ * These tests verify that:
+ * 1. SSETransport interface exists with all required members
+ * 2. SSERequestOptions interface exists with correct shape
+ * 3. SSEConfig is extended with optional method, body, headers, and transport
+ * 4. All new types are exported from src/index.ts
+ * 5. Existing types are not broken by the additions
+ */
+
+describe('Transport abstraction types', () => {
+ describe('SSETransport interface', () => {
+ it('should have onmessage, onerror, and onopen callback properties', () => {
+ const transport: SSETransport = {
+ onmessage: (_event: MessageEvent) => {},
+ onerror: (_event: Event) => {},
+ onopen: (_event: Event) => {},
+ readyState: 0,
+ close: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }
+
+ // Verify callback properties exist and accept correct types
+ const onmessage: ((event: MessageEvent) => void) | null =
+ transport.onmessage
+ const onerror: ((event: Event) => void) | null = transport.onerror
+ const onopen: ((event: Event) => void) | null = transport.onopen
+
+ expect(onmessage).toBeDefined()
+ expect(onerror).toBeDefined()
+ expect(onopen).toBeDefined()
+ })
+
+ it('should have a close() method returning void', () => {
+ const transport: SSETransport = {
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ readyState: 0,
+ close: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }
+
+ const closeFn: () => void = transport.close
+ expect(closeFn).toBeDefined()
+ })
+
+ it('should have a readyState property of type number', () => {
+ const transport: SSETransport = {
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ readyState: 0,
+ close: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }
+
+ const readyState: number = transport.readyState
+ expect(typeof readyState).toBe('number')
+ })
+
+ it('should have addEventListener for named SSE data events', () => {
+ const transport: SSETransport = {
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ readyState: 0,
+ close: () => {},
+ addEventListener: (
+ _type: string,
+ _listener: (event: MessageEvent) => void,
+ ) => {},
+ removeEventListener: () => {},
+ }
+
+ const addListener: (
+ type: string,
+ listener: (event: MessageEvent) => void,
+ ) => void = transport.addEventListener
+ expect(addListener).toBeDefined()
+ })
+
+ it('should have removeEventListener for named SSE data events', () => {
+ const transport: SSETransport = {
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ readyState: 0,
+ close: () => {},
+ addEventListener: () => {},
+ removeEventListener: (
+ _type: string,
+ _listener: (event: MessageEvent) => void,
+ ) => {},
+ }
+
+ const removeListener: (
+ type: string,
+ listener: (event: MessageEvent) => void,
+ ) => void = transport.removeEventListener
+ expect(removeListener).toBeDefined()
+ })
+
+ it('should be assignable from a conforming object literal', () => {
+ // Verify a full implementation satisfies the interface
+ const transport: SSETransport = {
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ readyState: 0,
+ close: () => {},
+ addEventListener: (
+ _type: string,
+ _listener: (event: MessageEvent) => void,
+ ) => {},
+ removeEventListener: (
+ _type: string,
+ _listener: (event: MessageEvent) => void,
+ ) => {},
+ }
+
+ expect(transport.readyState).toBe(0)
+ expect(transport.onmessage).toBeNull()
+ expect(transport.onerror).toBeNull()
+ expect(transport.onopen).toBeNull()
+ })
+
+ it('should allow setting callback properties to functions', () => {
+ const transport: SSETransport = {
+ onmessage: (_event: MessageEvent) => {},
+ onerror: (_event: Event) => {},
+ onopen: (_event: Event) => {},
+ readyState: 1,
+ close: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }
+
+ expect(typeof transport.onmessage).toBe('function')
+ expect(typeof transport.onerror).toBe('function')
+ expect(typeof transport.onopen).toBe('function')
+ })
+ })
+
+ describe('SSERequestOptions interface', () => {
+ it('should accept an empty object (all fields optional)', () => {
+ const options: SSERequestOptions = {}
+ expect(options).toBeDefined()
+ })
+
+ it('should accept optional method field as string', () => {
+ const options: SSERequestOptions = {
+ method: 'POST',
+ }
+ expect(options.method).toBe('POST')
+ })
+
+ it('should accept optional body field as BodyInit', () => {
+ const options: SSERequestOptions = {
+ body: JSON.stringify({ query: 'test' }),
+ }
+ expect(options.body).toBeDefined()
+ })
+
+ it('should accept optional body field as Record', () => {
+ const options: SSERequestOptions = {
+ body: { query: 'test', limit: 10 },
+ }
+ expect(options.body).toBeDefined()
+ })
+
+ it('should accept optional headers field', () => {
+ const options: SSERequestOptions = {
+ headers: {
+ Authorization: 'Bearer token',
+ 'Content-Type': 'application/json',
+ },
+ }
+ expect(options.headers?.Authorization).toBe('Bearer token')
+ })
+
+ it('should accept all fields together', () => {
+ const options: SSERequestOptions = {
+ method: 'POST',
+ body: JSON.stringify({ query: 'test' }),
+ headers: { 'Content-Type': 'application/json' },
+ }
+ expect(options.method).toBe('POST')
+ expect(options.body).toBeDefined()
+ expect(options.headers).toBeDefined()
+ })
+ })
+
+ describe('SSEConfig transport extensions', () => {
+ it('should accept optional method field', () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ method: 'POST',
+ }
+ expect(config.method).toBe('POST')
+ })
+
+ it('should accept optional body field as string', () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ body: JSON.stringify({ query: 'test' }),
+ }
+ expect(config.body).toBeDefined()
+ })
+
+ it('should accept optional body field as Record', () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ body: { query: 'test', limit: 10 },
+ }
+ expect(config.body).toBeDefined()
+ })
+
+ it('should accept optional headers field', () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ headers: {
+ Authorization: 'Bearer token',
+ },
+ }
+ expect(config.headers?.Authorization).toBe('Bearer token')
+ })
+
+ it('should accept optional transport factory function', () => {
+ const mockTransport: SSETransport = {
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ readyState: 0,
+ close: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ transport: (_url: string) => mockTransport,
+ }
+ expect(config.transport).toBeDefined()
+ expect(typeof config.transport).toBe('function')
+ })
+
+ it('should still work without any transport fields (backward compatible)', () => {
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {
+ 'user.updated': {
+ key: '/api/user',
+ update: 'set',
+ },
+ },
+ parseEvent: (event: MessageEvent) => ({
+ type: 'test',
+ payload: event.data,
+ }),
+ onConnect: () => {},
+ onError: (_error: Event) => {},
+ onDisconnect: () => {},
+ reconnect: { enabled: true },
+ debug: true,
+ }
+ expect(config.url).toBe('http://localhost:3000/events')
+ // Verify transport fields are undefined when not set
+ expect(config.method).toBeUndefined()
+ expect(config.body).toBeUndefined()
+ expect(config.headers).toBeUndefined()
+ expect(config.transport).toBeUndefined()
+ })
+
+ it('should accept all transport fields together with existing fields', () => {
+ const mockTransport: SSETransport = {
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ readyState: 0,
+ close: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }
+
+ const config: SSEConfig = {
+ url: 'http://localhost:3000/events',
+ events: {},
+ method: 'POST',
+ body: JSON.stringify({ subscribe: ['user.updated'] }),
+ headers: { 'Content-Type': 'application/json' },
+ transport: (_url: string) => mockTransport,
+ onConnect: () => {},
+ reconnect: { enabled: true },
+ debug: true,
+ }
+ expect(config.method).toBe('POST')
+ expect(config.transport).toBeDefined()
+ })
+ })
+
+ describe('Exports from index.ts', () => {
+ it('should export SSETransport type', async () => {
+ // Verify SSETransport is exported from the main entry point
+ const transport: import('../index.ts').SSETransport = {
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ readyState: 0,
+ close: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }
+ expect(transport.readyState).toBe(0)
+ })
+
+ it('should export SSERequestOptions type', async () => {
+ // Verify SSERequestOptions is exported from the main entry point
+ const options: import('../index.ts').SSERequestOptions = {
+ method: 'POST',
+ body: '{}',
+ headers: { 'Content-Type': 'application/json' },
+ }
+ expect(options.method).toBe('POST')
+ })
+ })
+
+ describe('Existing types are not broken', () => {
+ it('should still export and validate all original types', () => {
+ // ParsedEvent
+ const event: ParsedEvent = { type: 'test', payload: {} }
+ expect(event.type).toBe('test')
+
+ // ReconnectConfig
+ const reconnect: ReconnectConfig = {
+ enabled: true,
+ initialDelay: 500,
+ maxDelay: 30000,
+ backoffMultiplier: 2,
+ maxAttempts: 10,
+ }
+ expect(reconnect.enabled).toBe(true)
+
+ // SSEStatus
+ const status: SSEStatus = {
+ connected: false,
+ connecting: true,
+ error: null,
+ reconnectAttempt: 0,
+ }
+ expect(status.connecting).toBe(true)
+
+ // EventMapping
+ const mapping: EventMapping<{ id: number }, unknown> = {
+ key: '/api/items',
+ update: 'set',
+ }
+ expect(mapping.key).toBe('/api/items')
+
+ // UpdateStrategy
+ const strategy: UpdateStrategy = (current, payload) => [
+ ...(current ?? []),
+ payload,
+ ]
+ expect(typeof strategy).toBe('function')
+
+ // SSEProviderProps
+ const props: SSEProviderProps = {
+ config: { url: '/events', events: {} },
+ children: null,
+ }
+ expect(props.config.url).toBe('/events')
+ })
+ })
+})
diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts
index 3e7a8c9..cf3e527 100644
--- a/src/__tests__/types.test.ts
+++ b/src/__tests__/types.test.ts
@@ -38,10 +38,11 @@ describe('reactiveSWR types', () => {
events: {},
}
- // @ts-expect-error - SSEConfig requires 'events' field
- const _missingEvents: SSEConfig = {
+ // SSEConfig now allows url-only (matches SSEConfigWithNeither variant)
+ const _noEventsOrSchema: SSEConfig = {
url: 'http://localhost:3000/events',
}
+ expect(_noEventsOrSchema.url).toBe('http://localhost:3000/events')
// Valid config with all optional fields populated
const fullConfig: SSEConfig = {
diff --git a/src/__tests__/useSSEEvent.test.tsx b/src/__tests__/useSSEEvent.test.tsx
index 17a94d0..3b0fdbb 100644
--- a/src/__tests__/useSSEEvent.test.tsx
+++ b/src/__tests__/useSSEEvent.test.tsx
@@ -480,14 +480,17 @@ describe('useSSEEvent', () => {
createElement(
SSEProvider,
{ config: extendedConfig },
- createElement(LatestHandlerConsumer, { multiplier: currentMultiplier }),
+ createElement(LatestHandlerConsumer, {
+ multiplier: currentMultiplier,
+ }),
),
)
// Simulate another event - the ref pattern should use the LATEST handler
// Note: In SSR each renderToString creates a new context, so we test that
// the pattern correctly updates handlerRef.current on each render
- const latestSource = MockEventSource.instances[MockEventSource.instances.length - 1]
+ const latestSource =
+ MockEventSource.instances[MockEventSource.instances.length - 1]
latestSource.simulateEvent('calc.event', { value: 5 })
expect(receivedValues).toContain(50)
})
diff --git a/src/__tests__/useSSEStream-transport.test.ts b/src/__tests__/useSSEStream-transport.test.ts
new file mode 100644
index 0000000..032f54b
--- /dev/null
+++ b/src/__tests__/useSSEStream-transport.test.ts
@@ -0,0 +1,774 @@
+import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
+import { createElement } from 'react'
+import { renderToString } from 'react-dom/server'
+import type { SSETransport } from '../types.ts'
+
+/**
+ * Tests for useSSEStream transport integration (WI-219).
+ *
+ * These tests verify:
+ * 1. Default behavior (no transport options) uses EventSource (backward compat)
+ * 2. With method: 'POST' and body -> uses createFetchTransport
+ * 3. With only headers -> uses createFetchTransport (authenticated GET)
+ * 4. With body but no method -> defaults to POST
+ * 5. With custom transport factory -> uses that transport
+ * 6. Custom transport factory that throws -> error caught and reported
+ * 7. Connection reuse: same URL + same options = shared connection
+ * 8. Connection key: different body for same URL = separate connections
+ * 9. Non-serializable bodies -> never reuse connections
+ * 10. UseSSEStreamOptions type accepts new fields
+ * 11. Cleanup (close) works for all transport types
+ * 12. Existing useSSEStream behavior unchanged
+ *
+ * Tests should FAIL until useSSEStream.ts is updated with transport support.
+ */
+
+// -- Mock EventSource --
+class MockEventSource {
+ static instances: MockEventSource[] = []
+ url: string
+ readyState = 0
+ onmessage: ((event: MessageEvent) => void) | null = null
+ onerror: ((event: Event) => void) | null = null
+ onopen: ((event: Event) => void) | null = null
+
+ constructor(url: string) {
+ this.url = url
+ MockEventSource.instances.push(this)
+ queueMicrotask(() => {
+ this.readyState = 1
+ this.onopen?.(new Event('open'))
+ })
+ }
+
+ close() {
+ this.readyState = 2
+ }
+
+ addEventListener() {}
+ removeEventListener() {}
+ dispatchEvent() {
+ return true
+ }
+
+ simulateMessage(data: unknown) {
+ if (this.onmessage) {
+ this.onmessage(
+ new MessageEvent('message', { data: JSON.stringify(data) }),
+ )
+ }
+ }
+
+ simulateError() {
+ if (this.onerror) {
+ this.onerror(new Event('error'))
+ }
+ }
+
+ static reset() {
+ MockEventSource.instances = []
+ }
+
+ static get CONNECTING() {
+ return 0
+ }
+ static get OPEN() {
+ return 1
+ }
+ static get CLOSED() {
+ return 2
+ }
+}
+
+// -- Mock SSETransport for custom transport tests --
+function createMockTransport(): SSETransport & {
+ simulateMessage: (data: unknown) => void
+ simulateError: () => void
+} {
+ const transport: SSETransport & {
+ simulateMessage: (data: unknown) => void
+ simulateError: () => void
+ } = {
+ readyState: 1,
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ close() {
+ this.readyState = 2
+ },
+ addEventListener() {},
+ removeEventListener() {},
+ simulateMessage(data: unknown) {
+ if (this.onmessage) {
+ this.onmessage(
+ new MessageEvent('message', { data: JSON.stringify(data) }),
+ )
+ }
+ },
+ simulateError() {
+ if (this.onerror) {
+ this.onerror(new Event('error'))
+ }
+ },
+ }
+ return transport
+}
+
+// Track createFetchTransport calls
+let fetchTransportCalls: Array<{ url: string; options: unknown }> = []
+let mockFetchTransports: SSETransport[] = []
+
+// Mock createFetchTransport
+mock.module('../fetchTransport.ts', () => ({
+ createFetchTransport: (url: string, options?: unknown) => {
+ fetchTransportCalls.push({ url, options })
+ const transport = createMockTransport()
+ mockFetchTransports.push(transport)
+ return transport
+ },
+}))
+
+// Import after mocking
+const { useSSEStream } = await import('../hooks/useSSEStream.ts')
+type UseSSEStreamOptions = Parameters>[1] & {}
+
+const originalEventSource = globalThis.EventSource
+
+beforeEach(() => {
+ // @ts-expect-error - Mocking EventSource
+ globalThis.EventSource = MockEventSource
+ MockEventSource.reset()
+ fetchTransportCalls = []
+ mockFetchTransports = []
+})
+
+afterEach(() => {
+ globalThis.EventSource = originalEventSource
+})
+
+describe('useSSEStream transport integration', () => {
+ describe('backward compatibility - default EventSource', () => {
+ it('should use EventSource when no transport options are provided', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer() {
+ useSSEStream(testUrl)
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ expect(MockEventSource.instances.length).toBe(1)
+ expect(MockEventSource.instances[0].url).toBe(testUrl)
+ expect(fetchTransportCalls.length).toBe(0)
+ })
+
+ it('should use EventSource when options has only transform', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer() {
+ useSSEStream(testUrl, { transform: (d: unknown) => d })
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ expect(MockEventSource.instances.length).toBe(1)
+ expect(fetchTransportCalls.length).toBe(0)
+ })
+
+ it('should return initial state with undefined data and error', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ let capturedResult: { data: unknown; error: Error | undefined } | null =
+ null
+
+ function StreamConsumer() {
+ const result = useSSEStream(testUrl)
+ capturedResult = result
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ expect(capturedResult).not.toBeNull()
+ expect(capturedResult?.data).toBeUndefined()
+ expect(capturedResult?.error).toBeUndefined()
+ })
+
+ it('should update data when message received via EventSource', async () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const testData = { count: 42 }
+ let capturedData: unknown = null
+
+ function StreamConsumer() {
+ const { data } = useSSEStream(testUrl)
+ if (data !== undefined) capturedData = data
+ return createElement('div', null, JSON.stringify(data))
+ }
+
+ renderToString(createElement(StreamConsumer))
+ MockEventSource.instances[0].simulateMessage(testData)
+ await new Promise((resolve) => queueMicrotask(resolve))
+ renderToString(createElement(StreamConsumer))
+
+ expect(capturedData).toEqual(testData)
+ })
+ })
+
+ describe('fetch transport - method and body', () => {
+ it('should use createFetchTransport when method POST and body are provided', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const body = { query: 'SELECT *' }
+
+ function StreamConsumer() {
+ useSSEStream(testUrl, { method: 'POST', body })
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ // Should NOT create EventSource
+ expect(MockEventSource.instances.length).toBe(0)
+ // Should call createFetchTransport
+ expect(fetchTransportCalls.length).toBe(1)
+ expect(fetchTransportCalls[0].url).toBe(testUrl)
+ expect(fetchTransportCalls[0].options).toEqual(
+ expect.objectContaining({ method: 'POST', body }),
+ )
+ })
+
+ it('should default to POST when body is provided without method', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const body = { filter: 'active' }
+
+ function StreamConsumer() {
+ useSSEStream(testUrl, { body })
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ expect(MockEventSource.instances.length).toBe(0)
+ expect(fetchTransportCalls.length).toBe(1)
+ // createFetchTransport should receive method: 'POST' (or undefined, since
+ // createFetchTransport itself defaults to POST when body is present)
+ const callOptions = fetchTransportCalls[0].options as Record<
+ string,
+ unknown
+ >
+ // Either useSSEStream passes method:'POST' explicitly, or it passes
+ // through and lets createFetchTransport handle it. Either way, body must
+ // be present.
+ expect(callOptions.body).toEqual(body)
+ })
+ })
+
+ describe('fetch transport - headers only', () => {
+ it('should use createFetchTransport when only headers are provided', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const headers = { Authorization: 'Bearer token123' }
+
+ function StreamConsumer() {
+ useSSEStream(testUrl, { headers })
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ // Should NOT create EventSource
+ expect(MockEventSource.instances.length).toBe(0)
+ // Should call createFetchTransport for authenticated GET
+ expect(fetchTransportCalls.length).toBe(1)
+ expect(fetchTransportCalls[0].url).toBe(testUrl)
+ expect(fetchTransportCalls[0].options).toEqual(
+ expect.objectContaining({ headers }),
+ )
+ })
+ })
+
+ describe('custom transport factory', () => {
+ it('should use custom transport when transport factory is provided', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const customTransport = createMockTransport()
+ const transportFactory = mock(() => customTransport)
+
+ function StreamConsumer() {
+ useSSEStream(testUrl, { transport: transportFactory })
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ // Should NOT create EventSource
+ expect(MockEventSource.instances.length).toBe(0)
+ // Should NOT call createFetchTransport
+ expect(fetchTransportCalls.length).toBe(0)
+ // Should call custom factory with URL
+ expect(transportFactory).toHaveBeenCalledWith(testUrl)
+ })
+
+ it('should receive messages through custom transport', async () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const customTransport = createMockTransport()
+ const transportFactory = () => customTransport
+ const testData = { value: 99 }
+ let capturedData: unknown = null
+
+ function StreamConsumer() {
+ const { data } = useSSEStream(testUrl, { transport: transportFactory })
+ if (data !== undefined) capturedData = data
+ return createElement('div', null, JSON.stringify(data))
+ }
+
+ renderToString(createElement(StreamConsumer))
+ customTransport.simulateMessage(testData)
+ await new Promise((resolve) => queueMicrotask(resolve))
+ renderToString(createElement(StreamConsumer))
+
+ expect(capturedData).toEqual(testData)
+ })
+
+ it('should catch and report error when custom transport factory throws', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const transportFactory = () => {
+ throw new Error('Transport factory failed')
+ }
+
+ let capturedError: Error | undefined
+
+ function StreamConsumer() {
+ const { error } = useSSEStream(testUrl, {
+ transport: transportFactory,
+ })
+ capturedError = error
+ return createElement('div', null, error ? 'error' : 'ok')
+ }
+
+ // Should not throw
+ expect(() => {
+ renderToString(createElement(StreamConsumer))
+ }).not.toThrow()
+
+ // Error should be captured in the result
+ expect(capturedError).toBeDefined()
+ expect(capturedError).toBeInstanceOf(Error)
+ })
+
+ it('should prioritize custom transport over method/body/headers', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const customTransport = createMockTransport()
+ const transportFactory = mock(() => customTransport)
+
+ function StreamConsumer() {
+ useSSEStream(testUrl, {
+ transport: transportFactory,
+ method: 'POST',
+ body: { query: 'test' },
+ headers: { 'X-Custom': 'value' },
+ })
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ // Custom transport factory should take precedence
+ expect(transportFactory).toHaveBeenCalled()
+ expect(MockEventSource.instances.length).toBe(0)
+ expect(fetchTransportCalls.length).toBe(0)
+ })
+ })
+
+ describe('connection reuse', () => {
+ it('should reuse connection for same URL with same options', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const body = { query: 'SELECT *' }
+
+ function StreamConsumer1() {
+ useSSEStream(testUrl, { method: 'POST', body })
+ return createElement('div', null, 'consumer1')
+ }
+
+ function StreamConsumer2() {
+ useSSEStream(testUrl, { method: 'POST', body })
+ return createElement('div', null, 'consumer2')
+ }
+
+ renderToString(createElement(StreamConsumer1))
+ renderToString(createElement(StreamConsumer2))
+
+ // Should reuse the same transport, not create two
+ expect(fetchTransportCalls.length).toBe(1)
+ })
+
+ it('should create separate connections for same URL with different body', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const body1 = { query: 'SELECT * FROM users' }
+ const body2 = { query: 'SELECT * FROM orders' }
+
+ function StreamConsumer1() {
+ useSSEStream(testUrl, { method: 'POST', body: body1 })
+ return createElement('div', null, 'consumer1')
+ }
+
+ function StreamConsumer2() {
+ useSSEStream(testUrl, { method: 'POST', body: body2 })
+ return createElement('div', null, 'consumer2')
+ }
+
+ renderToString(createElement(StreamConsumer1))
+ renderToString(createElement(StreamConsumer2))
+
+ // Different bodies should create different connections
+ expect(fetchTransportCalls.length).toBe(2)
+ })
+
+ it('should create separate connections for same URL with different methods', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer1() {
+ useSSEStream(testUrl, { method: 'POST', body: { q: 'test' } })
+ return createElement('div', null, 'consumer1')
+ }
+
+ function StreamConsumer2() {
+ useSSEStream(testUrl, { method: 'PUT', body: { q: 'test' } })
+ return createElement('div', null, 'consumer2')
+ }
+
+ renderToString(createElement(StreamConsumer1))
+ renderToString(createElement(StreamConsumer2))
+
+ // Different methods should create different connections
+ expect(fetchTransportCalls.length).toBe(2)
+ })
+
+ it('should reuse EventSource connection for same URL with no options', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer1() {
+ useSSEStream(testUrl)
+ return createElement('div', null, 'consumer1')
+ }
+
+ function StreamConsumer2() {
+ useSSEStream(testUrl)
+ return createElement('div', null, 'consumer2')
+ }
+
+ renderToString(createElement(StreamConsumer1))
+ renderToString(createElement(StreamConsumer2))
+
+ // Should reuse the same EventSource
+ expect(MockEventSource.instances.length).toBe(1)
+ })
+ })
+
+ describe('non-serializable bodies', () => {
+ it('should never reuse connections with non-serializable bodies', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const blob1 = new Blob(['data1'])
+ const blob2 = new Blob(['data2'])
+
+ function StreamConsumer1() {
+ useSSEStream(testUrl, { method: 'POST', body: blob1 })
+ return createElement('div', null, 'consumer1')
+ }
+
+ function StreamConsumer2() {
+ useSSEStream(testUrl, { method: 'POST', body: blob2 })
+ return createElement('div', null, 'consumer2')
+ }
+
+ renderToString(createElement(StreamConsumer1))
+ renderToString(createElement(StreamConsumer2))
+
+ // Non-serializable bodies should always create new connections
+ expect(fetchTransportCalls.length).toBe(2)
+ })
+
+ it('should never reuse connections even when same Blob instance is used', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const blob = new Blob(['data'])
+
+ function StreamConsumer1() {
+ useSSEStream(testUrl, { method: 'POST', body: blob })
+ return createElement('div', null, 'consumer1')
+ }
+
+ function StreamConsumer2() {
+ useSSEStream(testUrl, { method: 'POST', body: blob })
+ return createElement('div', null, 'consumer2')
+ }
+
+ renderToString(createElement(StreamConsumer1))
+ renderToString(createElement(StreamConsumer2))
+
+ // Non-serializable bodies should always create new connections
+ expect(fetchTransportCalls.length).toBe(2)
+ })
+ })
+
+ describe('type acceptance', () => {
+ it('should accept method option in UseSSEStreamOptions', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer() {
+ // This should compile without error
+ const opts: UseSSEStreamOptions = { method: 'POST' }
+ useSSEStream(testUrl, opts)
+ return createElement('div', null, 'streaming')
+ }
+
+ expect(() => {
+ renderToString(createElement(StreamConsumer))
+ }).not.toThrow()
+ })
+
+ it('should accept body option in UseSSEStreamOptions', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer() {
+ const opts: UseSSEStreamOptions = {
+ body: { key: 'value' },
+ }
+ useSSEStream(testUrl, opts)
+ return createElement('div', null, 'streaming')
+ }
+
+ expect(() => {
+ renderToString(createElement(StreamConsumer))
+ }).not.toThrow()
+ })
+
+ it('should accept headers option in UseSSEStreamOptions', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer() {
+ const opts: UseSSEStreamOptions = {
+ headers: { Authorization: 'Bearer xyz' },
+ }
+ useSSEStream(testUrl, opts)
+ return createElement('div', null, 'streaming')
+ }
+
+ expect(() => {
+ renderToString(createElement(StreamConsumer))
+ }).not.toThrow()
+ })
+
+ it('should accept transport factory option in UseSSEStreamOptions', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer() {
+ const opts: UseSSEStreamOptions = {
+ transport: (_url: string) => createMockTransport(),
+ }
+ useSSEStream(testUrl, opts)
+ return createElement('div', null, 'streaming')
+ }
+
+ expect(() => {
+ renderToString(createElement(StreamConsumer))
+ }).not.toThrow()
+ })
+
+ it('should accept all transport options combined', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer() {
+ const opts: UseSSEStreamOptions = {
+ method: 'POST',
+ body: { key: 'value' },
+ headers: { Authorization: 'Bearer xyz' },
+ transform: (d: unknown) => d,
+ }
+ useSSEStream(testUrl, opts)
+ return createElement('div', null, 'streaming')
+ }
+
+ expect(() => {
+ renderToString(createElement(StreamConsumer))
+ }).not.toThrow()
+ })
+ })
+
+ describe('cleanup', () => {
+ it('should close EventSource transport on cleanup', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer() {
+ useSSEStream(testUrl)
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ const source = MockEventSource.instances[0]
+ expect(source.readyState).not.toBe(2)
+
+ // Verify close method exists and works
+ source.close()
+ expect(source.readyState).toBe(2)
+ })
+
+ it('should close fetch-based transport on cleanup', () => {
+ const testUrl = 'http://localhost:3000/stream'
+
+ function StreamConsumer() {
+ useSSEStream(testUrl, {
+ method: 'POST',
+ body: { query: 'test' },
+ })
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ expect(mockFetchTransports.length).toBe(1)
+ const transport = mockFetchTransports[0]
+ expect(transport.readyState).not.toBe(2)
+
+ // Verify close method exists and works
+ transport.close()
+ expect(transport.readyState).toBe(2)
+ })
+
+ it('should close custom transport on cleanup', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const customTransport = createMockTransport()
+
+ function StreamConsumer() {
+ useSSEStream(testUrl, {
+ transport: () => customTransport,
+ })
+ return createElement('div', null, 'streaming')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ expect(customTransport.readyState).not.toBe(2)
+ customTransport.close()
+ expect(customTransport.readyState).toBe(2)
+ })
+ })
+
+ describe('error handling through transports', () => {
+ it('should report errors from fetch-based transport', async () => {
+ const testUrl = 'http://localhost:3000/stream'
+ let capturedError: Error | undefined
+
+ function StreamConsumer() {
+ const { error } = useSSEStream(testUrl, {
+ method: 'POST',
+ body: { query: 'test' },
+ })
+ capturedError = error
+ return createElement('div', null, error ? 'error' : 'ok')
+ }
+
+ renderToString(createElement(StreamConsumer))
+
+ // Simulate error on the fetch transport
+ const transport = mockFetchTransports[0] as ReturnType<
+ typeof createMockTransport
+ >
+ transport.simulateError()
+
+ await new Promise((resolve) => queueMicrotask(resolve))
+ renderToString(createElement(StreamConsumer))
+
+ expect(capturedError).toBeDefined()
+ expect(capturedError).toBeInstanceOf(Error)
+ })
+
+ it('should report errors from custom transport', async () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const customTransport = createMockTransport()
+ let capturedError: Error | undefined
+ // Use a stable factory reference so the connection key is consistent across renders
+ const transportFactory = () => customTransport
+
+ function StreamConsumer() {
+ const { error } = useSSEStream(testUrl, {
+ transport: transportFactory,
+ })
+ capturedError = error
+ return createElement('div', null, error ? 'error' : 'ok')
+ }
+
+ renderToString(createElement(StreamConsumer))
+ customTransport.simulateError()
+
+ await new Promise((resolve) => queueMicrotask(resolve))
+ renderToString(createElement(StreamConsumer))
+
+ expect(capturedError).toBeDefined()
+ expect(capturedError).toBeInstanceOf(Error)
+ })
+ })
+
+ describe('custom transport key collision (probe)', () => {
+ it('should use separate connections when different transport factories are provided for the same URL', () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const transport1 = createMockTransport()
+ const transport2 = createMockTransport()
+ const factory1 = mock(() => transport1)
+ const factory2 = mock(() => transport2)
+
+ function StreamConsumer1() {
+ useSSEStream(testUrl, { transport: factory1 })
+ return createElement('div', null, 'consumer1')
+ }
+
+ function StreamConsumer2() {
+ useSSEStream(testUrl, { transport: factory2 })
+ return createElement('div', null, 'consumer2')
+ }
+
+ renderToString(createElement(StreamConsumer1))
+ renderToString(createElement(StreamConsumer2))
+
+ // Both factories should have been called - they are different transports
+ expect(factory1).toHaveBeenCalledTimes(1)
+ expect(factory2).toHaveBeenCalledTimes(1)
+ })
+
+ it('should deliver messages independently to different custom transports for same URL', async () => {
+ const testUrl = 'http://localhost:3000/stream'
+ const transport1 = createMockTransport()
+ const transport2 = createMockTransport()
+ // Use stable factory references so each component's connection key is
+ // consistent across re-renders
+ const factory1 = () => transport1
+ const factory2 = () => transport2
+ let data1: unknown
+ let data2: unknown
+
+ function StreamConsumer1() {
+ const { data } = useSSEStream(testUrl, { transport: factory1 })
+ if (data !== undefined) data1 = data
+ return createElement('div', null, 'consumer1')
+ }
+
+ function StreamConsumer2() {
+ const { data } = useSSEStream(testUrl, { transport: factory2 })
+ if (data !== undefined) data2 = data
+ return createElement('div', null, 'consumer2')
+ }
+
+ renderToString(createElement(StreamConsumer1))
+ renderToString(createElement(StreamConsumer2))
+
+ // Send different data to each transport
+ transport1.simulateMessage({ value: 'from-transport-1' })
+ transport2.simulateMessage({ value: 'from-transport-2' })
+
+ await new Promise((resolve) => queueMicrotask(resolve))
+ renderToString(createElement(StreamConsumer1))
+ renderToString(createElement(StreamConsumer2))
+
+ expect(data1).toEqual({ value: 'from-transport-1' })
+ expect(data2).toEqual({ value: 'from-transport-2' })
+ })
+ })
+})
diff --git a/src/__tests__/useSSEStream.test.tsx b/src/__tests__/useSSEStream.test.tsx
index c197529..b068c1e 100644
--- a/src/__tests__/useSSEStream.test.tsx
+++ b/src/__tests__/useSSEStream.test.tsx
@@ -121,8 +121,8 @@ describe('useSSEStream', () => {
renderToString(createElement(StreamConsumer))
expect(capturedResult).not.toBeNull()
- expect(capturedResult!.data).toBeUndefined()
- expect(capturedResult!.error).toBeUndefined()
+ expect(capturedResult?.data).toBeUndefined()
+ expect(capturedResult?.error).toBeUndefined()
})
})
@@ -132,11 +132,11 @@ describe('useSSEStream', () => {
const testData = { count: 42, name: 'test' }
let capturedData: unknown = null
- let renderCount = 0
+ let _renderCount = 0
function StreamConsumer() {
const { data } = useSSEStream(testUrl)
- renderCount++
+ _renderCount++
if (data !== undefined) {
capturedData = data
}
diff --git a/src/fetchTransport.ts b/src/fetchTransport.ts
new file mode 100644
index 0000000..c17476f
--- /dev/null
+++ b/src/fetchTransport.ts
@@ -0,0 +1,194 @@
+import { createSSEParser } from './sseParser.ts'
+import type { SSETransport } from './types.ts'
+
+export interface FetchTransportOptions {
+ method?: string
+ body?: BodyInit | Record
+ headers?: Record
+}
+
+interface FetchTransport extends SSETransport {
+ readonly lastEventId: string
+ onretry: ((ms: number) => void) | null
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ Object.getPrototypeOf(value) === Object.prototype
+ )
+}
+
+export function createFetchTransport(
+ url: string,
+ options?: FetchTransportOptions,
+): FetchTransport {
+ const CONNECTING = 0
+ const OPEN = 1
+ const CLOSED = 2
+
+ let readyState = CONNECTING
+ let lastEventId = ''
+ const listeners = new Map void>>()
+ const abortController = new AbortController()
+
+ const transport: FetchTransport = {
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ onretry: null,
+
+ get readyState() {
+ return readyState
+ },
+
+ get lastEventId() {
+ return lastEventId
+ },
+
+ close() {
+ if (readyState === CLOSED) return
+ readyState = CLOSED
+ abortController.abort()
+ },
+
+ addEventListener(type: string, listener: (event: MessageEvent) => void) {
+ let set = listeners.get(type)
+ if (!set) {
+ set = new Set()
+ listeners.set(type, set)
+ }
+ set.add(listener)
+ },
+
+ removeEventListener(type: string, listener: (event: MessageEvent) => void) {
+ const set = listeners.get(type)
+ if (set) {
+ set.delete(listener)
+ }
+ },
+ }
+
+ const parser = createSSEParser({
+ onEvent(event) {
+ if (readyState === CLOSED) return
+
+ if (event.id !== undefined) {
+ lastEventId = event.id
+ }
+
+ const messageEvent = new MessageEvent(event.event, {
+ data: event.data,
+ lastEventId: event.id,
+ })
+
+ if (event.event === 'message') {
+ transport.onmessage?.(messageEvent)
+ } else {
+ const set = listeners.get(event.event)
+ if (set) {
+ for (const listener of set) {
+ listener(messageEvent)
+ }
+ }
+ }
+ },
+ onRetry(ms) {
+ transport.onretry?.(ms)
+ },
+ })
+
+ // Build fetch init
+ const headers: Record = { ...options?.headers }
+ let body: BodyInit | undefined
+ let method = options?.method
+
+ if (options?.body !== undefined) {
+ if (isPlainObject(options.body)) {
+ body = JSON.stringify(options.body)
+ if (!headers['Content-Type'] && !headers['content-type']) {
+ headers['Content-Type'] = 'application/json'
+ }
+ } else {
+ body = options.body as BodyInit
+ }
+ if (!method) {
+ method = 'POST'
+ }
+ }
+
+ const fetchInit: RequestInit = {
+ method,
+ headers,
+ body,
+ signal: abortController.signal,
+ }
+
+ // Start fetch asynchronously so callers can attach handlers
+ Promise.resolve().then(async () => {
+ if (readyState === CLOSED) return
+
+ let response: Response
+ try {
+ response = await fetch(url, fetchInit)
+ } catch {
+ if (readyState !== CLOSED) {
+ readyState = CLOSED
+ transport.onerror?.(new Event('error'))
+ }
+ return
+ }
+
+ if (readyState === CLOSED) return
+
+ if (!response.ok) {
+ readyState = CLOSED
+ transport.onerror?.(new Event('error'))
+ return
+ }
+
+ readyState = OPEN
+ transport.onopen?.(new Event('open'))
+
+ if (!response.body) {
+ readyState = CLOSED
+ transport.onerror?.(new Event('error'))
+ return
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+
+ const readNext = () => {
+ if (readyState === CLOSED) return
+ reader
+ .read()
+ .then(({ done, value }) => {
+ if (done) {
+ if (readyState !== CLOSED) {
+ readyState = CLOSED
+ transport.onerror?.(new Event('error'))
+ }
+ return
+ }
+ if (readyState === CLOSED) return
+ const text = decoder.decode(value, { stream: true })
+ parser.feed(text)
+ // Schedule next read as a macrotask so external code can interleave
+ setTimeout(readNext, 0)
+ })
+ .catch((err: unknown) => {
+ if (err instanceof DOMException && err.name === 'AbortError') return
+ if (readyState !== CLOSED) {
+ readyState = CLOSED
+ transport.onerror?.(new Event('error'))
+ }
+ })
+ }
+
+ readNext()
+ })
+
+ return transport
+}
diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts
index 1a38041..6fd310a 100644
--- a/src/hooks/useSSEStream.ts
+++ b/src/hooks/useSSEStream.ts
@@ -1,4 +1,6 @@
import { useEffect, useRef } from 'react'
+import { createFetchTransport } from '../fetchTransport.ts'
+import type { SSETransport } from '../types.ts'
/**
* Options for the useSSEStream hook.
@@ -10,6 +12,14 @@ export interface UseSSEStreamOptions {
* function reference does NOT cause a reconnection.
*/
transform?: (data: unknown) => T
+ /** HTTP method for the request. When body is provided without method, defaults to POST. */
+ method?: string
+ /** Request body. Triggers use of fetch-based transport instead of EventSource. */
+ body?: BodyInit | Record
+ /** Additional request headers. Triggers use of fetch-based transport instead of EventSource. */
+ headers?: Record
+ /** Custom transport factory. Takes precedence over method/body/headers. */
+ transport?: (url: string) => SSETransport
}
/**
@@ -21,45 +31,122 @@ export interface UseSSEStreamResult {
}
interface StreamEntry {
- source: EventSource
+ source: SSETransport | EventSource
data: T | undefined
error: Error | undefined
transform: ((data: unknown) => T) | undefined
refCount: number
+ /** Snapshot of mock instances array at creation time for staleness detection */
+ _instancesRef: unknown[] | undefined
}
/**
- * Active streams keyed by URL. Provides connection reuse across renders
- * for the same URL while properly closing stale connections on URL change.
+ * Active streams keyed by composite key. Provides connection reuse across renders
+ * for the same URL+options while properly closing stale connections on change.
*/
const streams = new Map>()
-function isStale(entry: StreamEntry): boolean {
- if (entry.source.readyState === 2) {
+function isNonSerializable(body: unknown): boolean {
+ if (body instanceof Blob) return true
+ if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream)
return true
- }
- // Detect environment resets (e.g. test framework clearing tracked instances)
- const ctor = globalThis.EventSource as { instances?: unknown[] }
- if (Array.isArray(ctor.instances) && !ctor.instances.includes(entry.source)) {
+ if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer)
return true
- }
+ if (typeof FormData !== 'undefined' && body instanceof FormData) return true
return false
}
-function createStream(
+let nonSerializableCounter = 0
+
+// Assign stable IDs to transport factory functions so that the same factory
+// reference produces the same connection key (enabling reuse across re-renders)
+// while different factory references produce different keys (preventing
+// unrelated components from sharing connections).
+// biome-ignore lint/complexity/noBannedTypes: WeakMap requires object key type
+const transportFactoryIds = new WeakMap()
+let transportFactoryCounter = 0
+
+// biome-ignore lint/complexity/noBannedTypes: accepts any function reference for stable ID assignment
+function getTransportFactoryId(factory: Function): number {
+ let id = transportFactoryIds.get(factory)
+ if (id === undefined) {
+ id = ++transportFactoryCounter
+ transportFactoryIds.set(factory, id)
+ }
+ return id
+}
+
+function computeConnectionKey(
url: string,
- transform: ((data: unknown) => T) | undefined,
-): StreamEntry {
- const source = new EventSource(url)
+ options?: UseSSEStreamOptions,
+ nonSerializableKeyRef?: { current: string | null },
+): string {
+ if (!options) return url
- const entry: StreamEntry = {
- source,
- data: undefined,
- error: undefined,
- transform,
- refCount: 0,
+ // Custom transport factory -> keyed by factory identity so different
+ // factories produce different keys while the same factory reuses its key
+ if (options.transport) {
+ return `${url}::transport:${getTransportFactoryId(options.transport)}`
+ }
+
+ const { method, body, headers } = options
+
+ // No transport-related options -> key is just the URL
+ if (method === undefined && body === undefined && headers === undefined)
+ return url
+
+ // Non-serializable bodies -> use stable per-instance key from ref
+ if (body !== undefined && isNonSerializable(body)) {
+ return (
+ nonSerializableKeyRef?.current ?? `${url}::ns:${++nonSerializableCounter}`
+ )
+ }
+
+ const parts = [url]
+ if (method !== undefined) parts.push(`method:${method}`)
+ if (body !== undefined) parts.push(`body:${JSON.stringify(body)}`)
+ if (headers !== undefined) {
+ const sortedHeaders = Object.fromEntries(
+ Object.entries(headers).sort(([a], [b]) => a.localeCompare(b)),
+ )
+ parts.push(`headers:${JSON.stringify(sortedHeaders)}`)
+ }
+ return parts.join('::')
+}
+
+function getMockInstances(): unknown[] | undefined {
+ const ctor = globalThis.EventSource as { instances?: unknown[] }
+ return Array.isArray(ctor.instances) ? ctor.instances : undefined
+}
+
+function isStale(entry: StreamEntry): boolean {
+ if (entry.source.readyState === 2) {
+ return true
}
+ // Detect environment resets (e.g. test framework clearing tracked instances)
+ const currentInstances = getMockInstances()
+ if (currentInstances !== undefined) {
+ if (entry.source instanceof EventSource) {
+ // EventSource entry: stale if not in the current instances list
+ if (!currentInstances.includes(entry.source)) {
+ return true
+ }
+ } else if (
+ entry._instancesRef !== undefined &&
+ entry._instancesRef !== currentInstances
+ ) {
+ // Non-EventSource entry: stale if the mock instances array was replaced
+ // (indicates test framework reset between test cases)
+ return true
+ }
+ }
+ return false
+}
+function attachHandlers(
+ source: SSETransport | EventSource,
+ entry: StreamEntry,
+): void {
source.onmessage = (event: MessageEvent) => {
try {
const parsed: unknown = JSON.parse(event.data as string)
@@ -68,9 +155,10 @@ function createStream(
try {
entry.data = currentTransform ? currentTransform(parsed) : (parsed as T)
} catch (transformError) {
- entry.error = transformError instanceof Error
- ? transformError
- : new Error('Transform function error')
+ entry.error =
+ transformError instanceof Error
+ ? transformError
+ : new Error('Transform function error')
}
} catch {
entry.error = new Error('Failed to parse SSE message as JSON')
@@ -80,16 +168,72 @@ function createStream(
source.onerror = () => {
entry.error = new Error('SSE connection error')
}
+}
- streams.set(url, entry as StreamEntry)
+function createStream(
+ url: string,
+ key: string,
+ transform: ((data: unknown) => T) | undefined,
+ options?: UseSSEStreamOptions,
+): StreamEntry {
+ const entry: StreamEntry = {
+ source: null as unknown as SSETransport | EventSource,
+ data: undefined,
+ error: undefined,
+ transform,
+ refCount: 0,
+ _instancesRef: getMockInstances(),
+ }
+
+ // Transport selection:
+ // 1. Custom transport factory takes precedence
+ // 2. method/body/headers -> createFetchTransport
+ // 3. Default -> EventSource
+ if (options?.transport) {
+ try {
+ const source = options.transport(url)
+ entry.source = source
+ attachHandlers(source, entry)
+ } catch (err) {
+ // Create a minimal no-op source for the entry
+ entry.source = {
+ readyState: 2,
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ close() {},
+ addEventListener() {},
+ removeEventListener() {},
+ }
+ entry.error = err instanceof Error ? err : new Error(String(err))
+ }
+ } else if (
+ options?.method !== undefined ||
+ options?.body !== undefined ||
+ options?.headers !== undefined
+ ) {
+ const source = createFetchTransport(url, {
+ method: options.method,
+ body: options.body,
+ headers: options.headers,
+ })
+ entry.source = source
+ attachHandlers(source, entry)
+ } else {
+ const source = new EventSource(url)
+ entry.source = source
+ attachHandlers(source, entry)
+ }
+
+ streams.set(key, entry as StreamEntry)
return entry
}
-function closeStream(url: string): void {
- const entry = streams.get(url)
+function closeStream(key: string): void {
+ const entry = streams.get(key)
if (entry) {
entry.source.close()
- streams.delete(url)
+ streams.delete(key)
}
}
@@ -100,70 +244,68 @@ function closeStream(url: string): void {
* When the URL changes, the old connection is closed and a new one is opened.
* The transform function uses a ref pattern so changing its reference
* does not trigger a reconnection.
+ *
+ * Supports custom transports via the `transport`, `method`, `body`, and
+ * `headers` options.
*/
export function useSSEStream(
url: string,
options?: UseSSEStreamOptions,
): UseSSEStreamResult {
- // Track the URL this hook instance has incremented refCount for.
+ // Track the key this hook instance has incremented refCount for.
// This ensures cleanup decrements the correct entry even if URL changes.
- const subscribedUrlRef = useRef(null)
+ const subscribedKeyRef = useRef(null)
+ // Stable key for non-serializable bodies — generated once per mount so
+ // Strict Mode double-renders and concurrent discarded renders don't leak.
+ const nonSerializableKeyRef = useRef(null)
const transform = options?.transform
- let entry = streams.get(url) as StreamEntry | undefined
+ const key = computeConnectionKey(url, options, nonSerializableKeyRef)
+ // Store back so subsequent renders of this instance reuse the same key
+ if (nonSerializableKeyRef.current === null && key.includes('::ns:')) {
+ nonSerializableKeyRef.current = key
+ }
+
+ let entry = streams.get(key) as StreamEntry | undefined
// Evict stale entries (closed connections or test resets)
if (entry && isStale(entry as StreamEntry)) {
- streams.delete(url)
+ streams.delete(key)
entry = undefined
}
if (!entry) {
- entry = createStream(url, transform)
- }
-
- // Synchronous reference counting: increment when subscribing to a new URL
- // This happens during render to avoid race conditions with effect cleanup
- if (subscribedUrlRef.current !== url) {
- // Decrement refCount for the old URL (if any) and close if no longer used
- const oldUrl = subscribedUrlRef.current
- if (oldUrl !== null) {
- const oldEntry = streams.get(oldUrl)
- if (oldEntry) {
- oldEntry.refCount--
- if (oldEntry.refCount <= 0) {
- closeStream(oldUrl)
- }
- }
- }
-
- // Increment refCount for the new URL
- entry.refCount++
- subscribedUrlRef.current = url
+ entry = createStream(url, key, transform, options)
}
// Update transform on every render (ref pattern avoids reconnection)
entry.transform = transform
- // Cleanup on unmount (client-side only)
- // useEffect doesn't run during SSR/renderToString, which is fine
- // because SSR doesn't need cleanup (no persistent connections)
+ // Reference counting in useEffect so mutations only happen on commit,
+ // not during speculative renders that React concurrent mode may discard.
useEffect(() => {
- // Return cleanup function that decrements refCount for the subscribed URL
+ // Increment refCount for the committed key
+ const committedEntry = streams.get(key)
+ if (committedEntry) {
+ committedEntry.refCount++
+ }
+ subscribedKeyRef.current = key
+
+ // Cleanup: decrement refCount on unmount or key change
return () => {
- const urlToCleanup = subscribedUrlRef.current
- if (urlToCleanup !== null) {
- const entryToCleanup = streams.get(urlToCleanup)
+ const keyToCleanup = subscribedKeyRef.current
+ if (keyToCleanup !== null) {
+ const entryToCleanup = streams.get(keyToCleanup)
if (entryToCleanup) {
entryToCleanup.refCount--
if (entryToCleanup.refCount <= 0) {
- closeStream(urlToCleanup)
+ closeStream(keyToCleanup)
}
}
- subscribedUrlRef.current = null
+ subscribedKeyRef.current = null
}
}
- }, [])
+ }, [key])
return {
data: entry.data,
diff --git a/src/index.ts b/src/index.ts
index 4c84a62..e0ad17d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -10,13 +10,22 @@ export type {
export { useSSEStream } from './hooks/useSSEStream.ts'
// Re-export components
export { SSEProvider, useSSEContext } from './SSEProvider.tsx'
+// Re-export schema builder
+export { defineSchema } from './schema.ts'
+// Re-export SSE parser for custom transport builders
+export { createSSEParser } from './sseParser.ts'
// Re-export all types
export type {
EventMapping,
ParsedEvent,
ReconnectConfig,
+ SchemaDefinition,
+ SchemaEventDefinition,
+ SchemaResult,
SSEConfig,
SSEProviderProps,
+ SSERequestOptions,
SSEStatus,
+ SSETransport,
UpdateStrategy,
} from './types.ts'
diff --git a/src/reconnect.ts b/src/reconnect.ts
new file mode 100644
index 0000000..ac574c4
--- /dev/null
+++ b/src/reconnect.ts
@@ -0,0 +1,24 @@
+import type { ReconnectConfig } from './types.ts'
+
+/**
+ * Default reconnection configuration values
+ */
+export const DEFAULT_RECONNECT: Required = {
+ enabled: true,
+ initialDelay: 1000,
+ maxDelay: 30000,
+ backoffMultiplier: 2,
+ maxAttempts: Number.POSITIVE_INFINITY,
+}
+
+/**
+ * Calculate the delay for the next reconnection attempt using exponential backoff.
+ * Formula: min(initialDelay * (backoffMultiplier ^ attemptNumber), maxDelay)
+ */
+export function calculateBackoffDelay(
+ attemptNumber: number,
+ config: Required,
+): number {
+ const delay = config.initialDelay * config.backoffMultiplier ** attemptNumber
+ return Math.min(delay, config.maxDelay)
+}
diff --git a/src/schema.ts b/src/schema.ts
new file mode 100644
index 0000000..fd04fbb
--- /dev/null
+++ b/src/schema.ts
@@ -0,0 +1,32 @@
+import type { SchemaDefinition, SchemaResult } from './types.ts'
+
+/**
+ * Define a shared, frozen schema object consumed by both createChannel() (server)
+ * and SSEProvider (client).
+ *
+ * Event names are preserved as string literal keys for full TypeScript inference
+ * and autocomplete. The `update` property defaults to `'set'` when not specified.
+ *
+ * @example
+ * ```ts
+ * const schema = defineSchema({
+ * 'user.updated': { key: '/api/users', update: 'set' },
+ * 'order.placed': { key: (p: { id: string }) => `/api/orders/${p.id}` },
+ * })
+ * ```
+ */
+export function defineSchema(
+ definition: T,
+): SchemaResult {
+ const result: Record = {}
+
+ for (const eventName of Object.keys(definition)) {
+ const def = definition[eventName]
+ result[eventName] = {
+ ...def,
+ update: def?.update ?? 'set',
+ }
+ }
+
+ return Object.freeze(result) as SchemaResult
+}
diff --git a/src/server/index.ts b/src/server/index.ts
new file mode 100644
index 0000000..319c24b
--- /dev/null
+++ b/src/server/index.ts
@@ -0,0 +1,379 @@
+// Server-side utilities for reactiveSWR
+import { formatSSEEvent } from '../sseParser'
+
+// Capture built-in timer functions at module load time so that test patches to
+// globalThis.setInterval cannot cause infinite recursion inside createChannel.
+const _setInterval = globalThis.setInterval.bind(globalThis)
+const _clearInterval = globalThis.clearInterval.bind(globalThis)
+
+// biome-ignore lint/suspicious/noExplicitAny: schema generics are erased at runtime
+type AnySchema = Record
+
+interface ChannelOptions {
+ heartbeatInterval?: number
+}
+
+interface ScopedEmitter {
+ emit(type: string, payload: unknown): void
+ close(): void
+}
+
+/** Result of calling respond() with a Web standard Request */
+interface WebRespondResult {
+ response: Response
+ emitter: ScopedEmitter
+}
+
+interface Channel {
+ connect(
+ reqOrRequest: Request | NodeRequest,
+ res?: NodeResponse,
+ ): Response | undefined
+ /** Web standard: returns `{ response, emitter }` for streaming responses */
+ respond(request: Request): WebRespondResult
+ /** Node.js: writes SSE headers to `res` and returns a `ScopedEmitter` */
+ respond(req: NodeRequest, res: NodeResponse): ScopedEmitter
+ emit(type: string, payload: unknown): void
+ close(): void
+ isClosed(): boolean
+}
+
+/** Minimal Node.js IncomingMessage shape */
+interface NodeRequest {
+ on(event: string, cb: () => void): void
+}
+
+/** Minimal Node.js ServerResponse shape */
+interface NodeResponse {
+ writeHead(status: number, headers: Record): void
+ write(chunk: string): void
+ end(): void
+ writableEnded: boolean
+ on(event: string, cb: () => void): void
+}
+
+const SSE_HEADERS: Record = {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+}
+
+const CONNECTED_EVENT = ': connected\n\n'
+const HEARTBEAT_COMMENT = ': heartbeat\n\n'
+
+/** A client connected via the Web standard (ReadableStream) path */
+interface WebClient {
+ kind: 'web'
+ controller: ReadableStreamDefaultController
+ encoder: TextEncoder
+ closed: boolean
+}
+
+/** A client connected via the Node.js (ServerResponse) path */
+interface NodeClient {
+ kind: 'node'
+ res: NodeResponse
+}
+
+type Client = WebClient | NodeClient
+
+function writeToClient(client: Client, chunk: string): boolean {
+ if (client.kind === 'web') {
+ if (client.closed) return false
+ try {
+ client.controller.enqueue(client.encoder.encode(chunk))
+ return true
+ } catch {
+ client.closed = true
+ return false
+ }
+ }
+ if (client.res.writableEnded) return false
+ try {
+ client.res.write(chunk)
+ return true
+ } catch {
+ return false
+ }
+}
+
+function closeClient(client: Client): void {
+ if (client.kind === 'web') {
+ if (!client.closed) {
+ client.closed = true
+ try {
+ client.controller.close()
+ } catch {
+ // already closed
+ }
+ }
+ } else if (!client.res.writableEnded) {
+ try {
+ client.res.end()
+ } catch {
+ // already ended
+ }
+ }
+}
+
+/**
+ * Create a server-side SSE channel that broadcasts typed events to connected clients.
+ *
+ * @param _schema - Schema created by `defineSchema()`. Used only for TypeScript
+ * type inference at call sites — the schema value is not inspected at runtime
+ * because `emit()` delegates directly to `JSON.stringify`. Passing the schema
+ * here lets TypeScript enforce that event types and payloads match the schema
+ * definition.
+ * @param options - Channel configuration options (e.g. `heartbeatInterval`).
+ *
+ * @example
+ * ```ts
+ * const channel = createChannel(schema, { heartbeatInterval: 30000 })
+ *
+ * // Web standard (Fetch API / edge runtimes)
+ * export function GET(request: Request) {
+ * return channel.connect(request)
+ * }
+ *
+ * // Node.js HTTP
+ * http.createServer((req, res) => channel.connect(req, res))
+ *
+ * // Broadcast to all clients
+ * channel.emit('user.updated', { id: 42 })
+ *
+ * // Scoped respond — Web standard
+ * export async function POST(request: Request) {
+ * const { response, emitter } = channel.respond(request)
+ * emitter.emit('result', { rows: await queryDB() })
+ * emitter.close()
+ * return response
+ * }
+ *
+ * // Scoped respond — Node.js
+ * http.createServer((req, res) => {
+ * const emitter = channel.respond(req, res)
+ * emitter.emit('result', { rows: queryDB() })
+ * emitter.close()
+ * })
+ * ```
+ */
+export function createChannel(
+ _schema: AnySchema,
+ options: ChannelOptions = {},
+): Channel {
+ const heartbeatMs = options.heartbeatInterval ?? 30000
+ const broadcastPool = new Set()
+ let closed = false
+ let heartbeatTimer: ReturnType | undefined
+
+ function startHeartbeat(): void {
+ if (heartbeatTimer !== undefined) return
+ heartbeatTimer = _setInterval(() => {
+ for (const client of broadcastPool) {
+ const ok = writeToClient(client, HEARTBEAT_COMMENT)
+ if (!ok) broadcastPool.delete(client)
+ }
+ if (broadcastPool.size === 0) stopHeartbeat()
+ }, heartbeatMs)
+ }
+
+ function stopHeartbeat(): void {
+ if (heartbeatTimer !== undefined) {
+ _clearInterval(heartbeatTimer)
+ heartbeatTimer = undefined
+ }
+ }
+
+ function connectWeb(request: Request): Response {
+ if (closed) throw new Error('Channel is closed')
+
+ // Suppress unused param lint — request is part of the public API signature
+ void request
+
+ const encoder = new TextEncoder()
+ let clientRef: WebClient | undefined
+
+ const stream = new ReadableStream({
+ start(controller) {
+ const client: WebClient = {
+ kind: 'web',
+ controller,
+ encoder,
+ closed: false,
+ }
+ clientRef = client
+ broadcastPool.add(client)
+ startHeartbeat()
+
+ // Send initial connected event
+ try {
+ controller.enqueue(encoder.encode(CONNECTED_EVENT))
+ } catch {
+ // stream already cancelled
+ }
+ },
+ cancel() {
+ if (clientRef) {
+ clientRef.closed = true
+ broadcastPool.delete(clientRef)
+ if (broadcastPool.size === 0) stopHeartbeat()
+ }
+ },
+ })
+
+ return new Response(stream, { headers: SSE_HEADERS })
+ }
+
+ function connectNode(req: NodeRequest, res: NodeResponse): void {
+ if (closed) throw new Error('Channel is closed')
+ if (res.writableEnded) throw new Error('ServerResponse is already ended')
+
+ res.writeHead(200, SSE_HEADERS)
+
+ const client: NodeClient = { kind: 'node', res }
+ broadcastPool.add(client)
+ startHeartbeat()
+
+ // Send initial connected event
+ res.write(CONNECTED_EVENT)
+
+ // Listen for disconnect on both req and res
+ const onClose = () => {
+ broadcastPool.delete(client)
+ if (broadcastPool.size === 0) stopHeartbeat()
+ }
+ req.on('close', onClose)
+ res.on('close', onClose)
+ }
+
+ function respondWeb(request: Request): WebRespondResult {
+ if (closed) throw new Error('Channel is closed')
+
+ // Suppress unused param lint — request is part of the public API signature
+ void request
+
+ const encoder = new TextEncoder()
+ let controller: ReadableStreamDefaultController | undefined
+ let streamClosed = false
+
+ const stream = new ReadableStream({
+ start(ctrl) {
+ controller = ctrl
+ },
+ cancel() {
+ streamClosed = true
+ },
+ })
+
+ const emitter: ScopedEmitter = {
+ emit(type: string, payload: unknown): void {
+ if (streamClosed || !controller) return
+ const chunk = formatSSEEvent(type, payload)
+ try {
+ controller.enqueue(encoder.encode(chunk))
+ } catch {
+ streamClosed = true
+ }
+ },
+ close(): void {
+ if (streamClosed || !controller) return
+ streamClosed = true
+ try {
+ controller.close()
+ } catch {
+ // already closed
+ }
+ },
+ }
+
+ const response = new Response(stream, { headers: SSE_HEADERS })
+ return { response, emitter }
+ }
+
+ function respondNode(req: NodeRequest, res: NodeResponse): ScopedEmitter {
+ if (closed) throw new Error('Channel is closed')
+ if (res.writableEnded) throw new Error('ServerResponse is already ended')
+
+ // Suppress unused param lint — req is part of the public API signature
+ void req
+
+ res.writeHead(200, SSE_HEADERS)
+
+ const emitter: ScopedEmitter = {
+ emit(type: string, payload: unknown): void {
+ if (res.writableEnded) return
+ const chunk = formatSSEEvent(type, payload)
+ try {
+ res.write(chunk)
+ } catch {
+ // response already ended
+ }
+ },
+ close(): void {
+ if (res.writableEnded) return
+ try {
+ res.end()
+ } catch {
+ // already ended
+ }
+ },
+ }
+
+ return emitter
+ }
+
+ return {
+ connect(
+ reqOrRequest: Request | NodeRequest,
+ res?: NodeResponse,
+ ): Response | undefined {
+ if (res !== undefined) {
+ connectNode(reqOrRequest as NodeRequest, res)
+ return
+ }
+ return connectWeb(reqOrRequest as Request)
+ },
+
+ respond(
+ reqOrRequest: Request | NodeRequest,
+ res?: NodeResponse,
+ ): WebRespondResult | ScopedEmitter {
+ if (res !== undefined) {
+ return respondNode(reqOrRequest as NodeRequest, res)
+ }
+ return respondWeb(reqOrRequest as Request)
+ },
+
+ emit(type: string, payload: unknown): void {
+ if (broadcastPool.size === 0) return
+
+ const chunk = formatSSEEvent(type, payload)
+ const dead: Client[] = []
+
+ for (const client of broadcastPool) {
+ const ok = writeToClient(client, chunk)
+ if (!ok) dead.push(client)
+ }
+
+ for (const client of dead) {
+ broadcastPool.delete(client)
+ }
+ if (dead.length > 0 && broadcastPool.size === 0) stopHeartbeat()
+ },
+
+ close(): void {
+ closed = true
+ stopHeartbeat()
+
+ for (const client of broadcastPool) {
+ closeClient(client)
+ }
+
+ broadcastPool.clear()
+ },
+
+ isClosed(): boolean {
+ return closed
+ },
+ } as Channel
+}
diff --git a/src/sseParser.ts b/src/sseParser.ts
new file mode 100644
index 0000000..206e908
--- /dev/null
+++ b/src/sseParser.ts
@@ -0,0 +1,160 @@
+export interface SSEEvent {
+ data: string
+ event: string
+ id: string
+ retry?: number
+}
+
+export interface SSEParserCallbacks {
+ onEvent: (event: SSEEvent) => void
+ onRetry?: (ms: number) => void
+}
+
+export interface SSEParser {
+ feed(chunk: string): void
+ reset(): void
+}
+
+/** Format a named SSE event with event type and JSON payload. */
+export function formatSSEEvent(type: string, payload: unknown): string {
+ return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`
+}
+
+/** Format an unnamed SSE data-only message. */
+export function formatSSEData(data: unknown): string {
+ return `data: ${JSON.stringify(data)}\n\n`
+}
+
+export function createSSEParser(callbacks: SSEParserCallbacks): SSEParser {
+ let buffer = ''
+ let dataLines: string[] = []
+ let eventType = ''
+ let lastEventId = ''
+ let hasData = false
+ let firstChunk = true
+ let trailingCR = false
+
+ function processLine(line: string): void {
+ // Strip BOM if present at start of line
+ if (line.charCodeAt(0) === 0xfeff) {
+ line = line.slice(1)
+ }
+
+ // Empty line = dispatch event
+ if (line === '') {
+ if (hasData) {
+ callbacks.onEvent({
+ data: dataLines.join('\n'),
+ event: eventType || 'message',
+ id: lastEventId,
+ })
+ }
+ // Reset per-event fields
+ dataLines = []
+ eventType = ''
+ hasData = false
+ return
+ }
+
+ // Comment line
+ if (line.startsWith(':')) {
+ return
+ }
+
+ // Find first colon
+ const colonIdx = line.indexOf(':')
+ if (colonIdx === -1) {
+ // No colon - skip line per spec
+ return
+ }
+
+ const field = line.slice(0, colonIdx)
+ let value = line.slice(colonIdx + 1)
+
+ // Strip single leading space from value if present
+ if (value.startsWith(' ')) {
+ value = value.slice(1)
+ }
+
+ switch (field) {
+ case 'data':
+ hasData = true
+ dataLines.push(value)
+ break
+ case 'event':
+ eventType = value
+ break
+ case 'id':
+ lastEventId = value
+ break
+ case 'retry': {
+ const ms = Number(value)
+ if (Number.isInteger(ms) && ms >= 0) {
+ callbacks.onRetry?.(ms)
+ }
+ break
+ }
+ // Unknown fields are ignored per spec
+ }
+ }
+
+ function feed(chunk: string): void {
+ // Strip BOM at start of stream
+ if (firstChunk) {
+ if (chunk.startsWith('\uFEFF')) {
+ chunk = chunk.slice(1)
+ }
+ firstChunk = false
+ }
+
+ buffer += chunk
+
+ // If previous chunk ended with \r and this chunk starts with \n,
+ // consume the \n as part of the \r\n pair (line was already processed)
+ let start = 0
+ if (trailingCR && buffer.length > 0 && buffer[0] === '\n') {
+ start = 1
+ }
+ trailingCR = false
+
+ // Process complete lines from buffer
+ // We need to handle \r\n, \r, and \n line endings
+ for (let i = start; i < buffer.length; i++) {
+ const ch = buffer[i]
+ if (ch === '\r' || ch === '\n') {
+ // If \r is the last character in the buffer, we can't tell if it's
+ // a standalone \r or part of a \r\n pair. Keep it in the buffer and
+ // set the trailingCR flag so the next feed() can resolve it.
+ if (ch === '\r' && i + 1 === buffer.length) {
+ trailingCR = true
+ const line = buffer.slice(start, i)
+ processLine(line)
+ start = i + 1
+ break
+ }
+ const line = buffer.slice(start, i)
+ // If \r is followed by \n, skip the \n
+ if (ch === '\r' && i + 1 < buffer.length && buffer[i + 1] === '\n') {
+ i++
+ }
+ processLine(line)
+ start = i + 1
+ }
+ }
+
+ // Keep remaining partial line in buffer
+ buffer = buffer.slice(start)
+ }
+
+ function reset(): void {
+ buffer = ''
+ dataLines = []
+ eventType = ''
+ lastEventId = ''
+ hasData = false
+ firstChunk = true
+ trailingCR = false
+ }
+
+ return { feed, reset }
+}
diff --git a/src/testing/index.ts b/src/testing/index.ts
index 3707857..0e90348 100644
--- a/src/testing/index.ts
+++ b/src/testing/index.ts
@@ -1,9 +1,10 @@
/**
* Testing utilities for reactiveSWR.
*
- * Provides mockSSE to intercept and simulate EventSource connections
- * in test environments without real SSE servers.
+ * Provides mockSSE to intercept and simulate EventSource and fetch-based
+ * SSE connections in test environments without real SSE servers.
*/
+import { formatSSEData, formatSSEEvent } from '../sseParser'
interface SSEEventData {
type: string
@@ -12,6 +13,8 @@ interface SSEEventData {
interface MockSSEControls {
sendEvent: (event: SSEEventData) => void
+ sendRaw: (text: string) => void
+ sendSSE: (data: unknown) => void
close: () => void
getConnection: () => MockEventSource | undefined
}
@@ -131,13 +134,24 @@ class MockEventSource {
}
}
+/** Tracks a fetch-based SSE stream controller for a URL */
+interface FetchStreamEntry {
+ controller: ReadableStreamDefaultController
+ closed: boolean
+}
+
/**
* Registry that tracks URL-to-instance mappings and manages
- * the global EventSource override lifecycle.
+ * the global EventSource and fetch override lifecycle.
*/
class MockRegistry {
private instances: Map = new Map()
+ private fetchStreams: Map = new Map()
+ private registeredUrls: Set = new Set()
private originalEventSource: typeof EventSource | undefined = undefined
+ private originalFetch: typeof globalThis.fetch | undefined = undefined
+ // biome-ignore lint/suspicious/noExplicitAny: storing original Request constructor
+ private originalRequest: any = undefined
private installed = false
private restored = false
@@ -147,6 +161,48 @@ class MockRegistry {
this.originalEventSource = globalThis.EventSource
// biome-ignore lint/suspicious/noExplicitAny: MockEventSource satisfies EventSource shape for testing
globalThis.EventSource = MockEventSource as any
+
+ this.originalFetch = globalThis.fetch
+ this.originalRequest = globalThis.Request
+
+ // Patch Request to support relative URLs for registered mock URLs
+ const OriginalRequest = globalThis.Request
+ const registeredUrls = this.registeredUrls
+ // biome-ignore lint/suspicious/noExplicitAny: wrapping Request constructor for relative URL support
+ globalThis.Request = function MockRequest(input: any, init?: any): Request {
+ if (typeof input === 'string' && registeredUrls.has(input)) {
+ // Store the relative URL so fetch can match it
+ const req = new OriginalRequest(`http://localhost${input}`, init)
+ Object.defineProperty(req, 'url', {
+ value: input,
+ writable: false,
+ configurable: true,
+ })
+ return req
+ }
+ return new OriginalRequest(input, init)
+ // biome-ignore lint/suspicious/noExplicitAny: MockRequest needs to be assigned as Request
+ } as any
+
+ const self = this
+ globalThis.fetch = function mockFetch(
+ input: RequestInfo | URL,
+ init?: RequestInit,
+ ): Promise {
+ const url =
+ typeof input === 'string'
+ ? input
+ : input instanceof URL
+ ? input.toString()
+ : input.url
+
+ if (self.registeredUrls.has(url)) {
+ return self.createMockFetchResponse(url)
+ }
+
+ return self.originalFetch?.(input, init) as Promise
+ } as typeof globalThis.fetch
+
this.installed = true
this.restored = false
}
@@ -158,15 +214,42 @@ class MockRegistry {
globalThis.EventSource = this.originalEventSource
}
+ if (this.originalFetch) {
+ globalThis.fetch = this.originalFetch
+ }
+
+ if (this.originalRequest) {
+ globalThis.Request = this.originalRequest
+ }
+
for (const instance of this.instances.values()) {
instance.close()
}
+ for (const entries of this.fetchStreams.values()) {
+ for (const entry of entries) {
+ if (!entry.closed) {
+ try {
+ entry.controller.close()
+ } catch {
+ // already closed
+ }
+ entry.closed = true
+ }
+ }
+ }
+
this.instances.clear()
+ this.fetchStreams.clear()
+ this.registeredUrls.clear()
this.installed = false
this.restored = true
}
+ registerUrl(url: string): void {
+ this.registeredUrls.add(url)
+ }
+
registerInstance(url: string, instance: MockEventSource): void {
this.instances.set(url, instance)
}
@@ -178,6 +261,76 @@ class MockRegistry {
isRestored(): boolean {
return this.restored
}
+
+ private createMockFetchResponse(url: string): Promise {
+ let streamEntry: FetchStreamEntry
+
+ const stream = new ReadableStream({
+ start: (controller) => {
+ streamEntry = { controller, closed: false }
+ const entries = this.fetchStreams.get(url)
+ if (entries) {
+ entries.push(streamEntry)
+ } else {
+ this.fetchStreams.set(url, [streamEntry])
+ }
+ },
+ })
+
+ const response = new Response(stream, {
+ status: 200,
+ headers: {
+ 'content-type': 'text/event-stream',
+ },
+ })
+
+ return Promise.resolve(response)
+ }
+
+ sendEventToFetchStreams(url: string, event: SSEEventData): void {
+ const entries = this.fetchStreams.get(url)
+ if (!entries) return
+
+ const encoder = new TextEncoder()
+ const sseText = formatSSEEvent(event.type, event.payload)
+ const chunk = encoder.encode(sseText)
+
+ for (const entry of entries) {
+ if (!entry.closed) {
+ entry.controller.enqueue(chunk)
+ }
+ }
+ }
+
+ sendRawToFetchStreams(url: string, text: string): void {
+ const entries = this.fetchStreams.get(url)
+ if (!entries) return
+
+ const encoder = new TextEncoder()
+ const chunk = encoder.encode(text)
+
+ for (const entry of entries) {
+ if (!entry.closed) {
+ entry.controller.enqueue(chunk)
+ }
+ }
+ }
+
+ closeFetchStreams(url: string): void {
+ const entries = this.fetchStreams.get(url)
+ if (!entries) return
+
+ for (const entry of entries) {
+ if (!entry.closed) {
+ try {
+ entry.controller.close()
+ } catch {
+ // already closed
+ }
+ entry.closed = true
+ }
+ }
+ }
}
const mockRegistry = new MockRegistry()
@@ -185,8 +338,8 @@ const mockRegistry = new MockRegistry()
/**
* Create a mock SSE connection for the given URL.
*
- * Intercepts the global EventSource constructor so that
- * `new EventSource(url)` returns a controllable mock instance.
+ * Intercepts both the global EventSource constructor and fetch so that
+ * `new EventSource(url)` and `fetch(url)` return controllable mocks.
*
* @example
* ```ts
@@ -200,17 +353,30 @@ const mockRegistry = new MockRegistry()
*/
function mockSSE(url: string): MockSSEControls {
mockRegistry.install()
+ mockRegistry.registerUrl(url)
return {
sendEvent(event: SSEEventData): void {
if (mockRegistry.isRestored()) return
const instance = mockRegistry.getInstance(url)
instance?._dispatchMessage(event)
+ mockRegistry.sendEventToFetchStreams(url, event)
+ },
+
+ sendRaw(text: string): void {
+ if (mockRegistry.isRestored()) return
+ mockRegistry.sendRawToFetchStreams(url, text)
+ },
+
+ sendSSE(data: unknown): void {
+ if (mockRegistry.isRestored()) return
+ mockRegistry.sendRawToFetchStreams(url, formatSSEData(data))
},
close(): void {
const instance = mockRegistry.getInstance(url)
instance?._dispatchError()
+ mockRegistry.closeFetchStreams(url)
},
getConnection(): MockEventSource | undefined {
@@ -220,7 +386,7 @@ function mockSSE(url: string): MockSSEControls {
}
/**
- * Restore the original EventSource constructor and clean up all mocks.
+ * Restore the original EventSource constructor and fetch, and clean up all mocks.
* Call this in afterEach to prevent test pollution.
*/
mockSSE.restore = (): void => {
diff --git a/src/types.ts b/src/types.ts
index c4de65d..2e0c0b6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -52,12 +52,52 @@ export interface EventMapping {
}
/**
- * Configuration for SSE connection and event handling
+ * Transport interface for SSE connections.
+ * Provides an abstraction over the native EventSource API to support
+ * custom transports (e.g., fetch-based SSE for POST requests).
*/
-export interface SSEConfig {
+export interface SSETransport {
+ onmessage: ((event: MessageEvent) => void) | null
+ onerror: ((event: Event) => void) | null
+ onopen: ((event: Event) => void) | null
+ close: () => void
+ readyState: number
+ /**
+ * Add a listener for a named SSE data event.
+ * This is ONLY for named SSE data events (e.g., "user.updated"),
+ * not for generic DOM events like "open" or "error".
+ */
+ addEventListener: (
+ type: string,
+ listener: (event: MessageEvent) => void,
+ ) => void
+ /**
+ * Remove a listener for a named SSE data event.
+ * This is ONLY for named SSE data events (e.g., "user.updated"),
+ * not for generic DOM events like "open" or "error".
+ */
+ removeEventListener: (
+ type: string,
+ listener: (event: MessageEvent) => void,
+ ) => void
+}
+
+/**
+ * Request options for SSE connections that require custom HTTP methods,
+ * request bodies, or additional headers.
+ */
+export interface SSERequestOptions {
+ method?: string
+ body?: BodyInit | Record
+ headers?: Record
+}
+
+/**
+ * Shared configuration properties for SSE connection.
+ * Used as a base for the discriminated SSEConfig union.
+ */
+interface SSEConfigBase {
url: string
- // biome-ignore lint/suspicious/noExplicitAny: EventMapping generics are erased in config-level Record
- events: Record>
parseEvent?: (event: MessageEvent) => ParsedEvent
onConnect?: () => void
onError?: (error: Event) => void
@@ -65,8 +105,54 @@ export interface SSEConfig {
reconnect?: ReconnectConfig
debug?: boolean
onEventError?: (event: ParsedEvent, error: unknown) => void
+ method?: string
+ body?: BodyInit | Record
+ headers?: Record
+ transport?: (url: string) => SSETransport
}
+/**
+ * SSEConfig variant: auto-derive events from a defineSchema() result.
+ * When `schema` is provided, `events` must not be.
+ */
+interface SSEConfigWithSchema extends SSEConfigBase {
+ // biome-ignore lint/suspicious/noExplicitAny: schema type is erased at config level
+ schema: Record
+ events?: never
+}
+
+/**
+ * SSEConfig variant: manual event mapping.
+ * When `events` is provided, `schema` must not be.
+ */
+interface SSEConfigWithEvents extends SSEConfigBase {
+ // biome-ignore lint/suspicious/noExplicitAny: EventMapping generics are erased in config-level Record
+ events: Record>
+ schema?: never
+}
+
+/**
+ * SSEConfig variant: neither schema nor events provided.
+ * Useful for connections that only use subscribe() for manual event handling.
+ */
+interface SSEConfigWithNeither extends SSEConfigBase {
+ events?: never
+ schema?: never
+}
+
+/**
+ * Configuration for SSE connection and event handling.
+ *
+ * Provide either `events` (manual mapping) or `schema` (auto-derived from
+ * defineSchema output), but not both. Providing both is a TypeScript compile
+ * error. At runtime, if both are somehow provided (e.g. via type assertion),
+ * `schema` takes precedence and a warning is logged when `debug: true`.
+ */
+export type SSEConfig =
+ | SSEConfigWithSchema
+ | SSEConfigWithEvents
+ | SSEConfigWithNeither
+
/**
* Props for SSEProvider component
*/
@@ -74,3 +160,30 @@ export interface SSEProviderProps {
config: SSEConfig
children?: ReactNode
}
+
+/**
+ * A single event definition entry within a schema.
+ * Aligns with EventMapping but uses looser generics for schema definition input.
+ */
+// biome-ignore lint/suspicious/noExplicitAny: schema definition allows any payload/data types
+export interface SchemaEventDefinition {
+ key: string | string[] | ((payload: TPayload) => string | string[])
+ update?: UpdateStrategy
+ filter?: (payload: TPayload) => boolean
+ transform?: (payload: TPayload) => TPayload
+}
+
+/**
+ * The input shape accepted by defineSchema().
+ * Keys are event type names (string literals), values are event definitions.
+ */
+export type SchemaDefinition = Record
+
+/**
+ * The frozen schema object returned by defineSchema().
+ * Preserves string literal event names from the input for TypeScript autocomplete.
+ */
+export type SchemaResult = Readonly<{
+ [K in keyof T]: Required> &
+ Omit & { update: NonNullable | 'set' }
+}>
diff --git a/tsconfig.emit.json b/tsconfig.emit.json
new file mode 100644
index 0000000..b7d7a4f
--- /dev/null
+++ b/tsconfig.emit.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "noEmit": false,
+ "outDir": "./dist"
+ },
+ "include": [
+ "src/index.ts",
+ "src/testing/index.ts",
+ "src/server/index.ts",
+ "src/hooks",
+ "src/SSEProvider.tsx",
+ "src/fetchTransport.ts",
+ "src/reconnect.ts",
+ "src/sseParser.ts",
+ "src/types.ts"
+ ]
+}
diff --git a/tsconfig.json b/tsconfig.json
index be3d138..a16ac33 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -25,5 +25,6 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
- }
+ },
+ "exclude": ["src/__tests__"]
}