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__"] }