diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a708277 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contributing + +Thanks for contributing! + +## Prerequisites + +- Node.js (this repo’s demo uses Vite; Node 20.19+ recommended) +- npm + +## Install + +From the repo root: + +```bash +npm install +``` + +## Useful npm scripts (recommended) + +Run these from the repo root (they’re defined in the root `package.json`): + +```bash +# one command: install demo deps, build, then run server+client in parallel +npm run demo + +# if you only need pieces +npm run demo:install +npm run demo:server +npm run demo:client + +# library build + tests +npm run build +npm run test +npm run test:ci +npm run test:coverage +npm run test:ui +``` + +## Running the demo (manual verification) + +The demo is split into two apps: + +- `demo/server`: Express server that exposes streaming endpoints +- `demo/client`: Vite + React client that consumes the streams + +### 1) Start the server + +```bash +cd demo/server +npm install +npm run dev +``` + +By default it runs on `http://localhost:3001`. + +Endpoints: + +- `GET /sse`: Server-Sent Events stream +- `POST /http-stream`: HTTP POST that responds with `text/event-stream` +- `GET /health`: health check + +### 2) Start the client + +In a second terminal: + +```bash +cd demo/client +npm install +npm run dev +``` + +The page will open automatically. It renders: + +- `ReactTextStream` component using SSE +- `useTextStream(url, onEvent)` using SSE +- `useTextStream({ store: "http", data, ... })` using HTTP POST + event-stream + +### 3) What to verify + +- **SSE path**: streaming words appear and ends with a “completed” event +- **HTTP POST path**: streaming words appear; changing the “HTTP message” input changes the request body sent to the server +- **Reconnect button**: restarts the stream + +## Local build + +From the repo root: + +```bash +npm run build +``` + diff --git a/README.md b/README.md index 087d5e4..0101618 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,6 @@ A React library for streaming text using Server-Sent Events (SSE) with `text/eve ![Demo of rendering text event stream](./demo.gif) -### Run the demo locally - -From the repository root: - -```bash -npm install -npm run demo -``` - -This installs dependencies for [`demo/server`](./demo/server) and [`demo/client`](./demo/client), builds the library into `dist/`, then starts both in parallel: the SSE test server on [http://localhost:3001](http://localhost:3001) (endpoint `/sse`) and the Vite app (by default [http://localhost:5173](http://localhost:5173); Vite may open a browser automatically). The client loads `react-text-stream` via `file:../..`, matching how the published package resolves to `dist`. - -Other scripts: - -- **`npm run demo:server`** — SSE server only (`demo/server`, `npm run dev`). -- **`npm run demo:client`** — Vite dev server only (`demo/client`). Run **`npm run build`** at the repo root first so `dist/` exists for the linked package. -- **`npm run demo:install`** — install dependencies in both demo packages without starting servers. -- **`npm run demo:verify`** — build the library and run a production build of the demo client (useful to confirm the package layout before publish). - ## Installation ```bash diff --git a/demo/client/src/main.tsx b/demo/client/src/main.tsx index 39574e7..decd3c7 100644 --- a/demo/client/src/main.tsx +++ b/demo/client/src/main.tsx @@ -5,6 +5,7 @@ import './index.css' const config = { url: 'http://localhost:3001/sse', + httpUrl: 'http://localhost:3001/http-stream', onEvent: (event: { type: string; word: string }) => event.type === 'completed' ? undefined : `${event.word ?? ''} `, } @@ -34,11 +35,7 @@ function App() { ) } -function Streams({ - onEvent, -}: { - onEvent: (event: { type: string; word: string }) => string | undefined -}) { +function Streams({ onEvent }: { onEvent: (event: { type: string; word: string }) => string | undefined }) { return ( <>
@@ -53,6 +50,10 @@ function Streams({

useTextStream() Hook

+
+

useTextStream() Hook (HTTP POST + event-stream)

+ +
) } @@ -67,3 +68,43 @@ function HookTextStream() { ) } + +function HookHttpTextStream() { + const [message, setMessage] = useState('hello from client') + const [submittedMessage, setSubmittedMessage] = useState(undefined) + const stream = useTextStream({ + url: config.httpUrl, + store: 'http', + data: submittedMessage === undefined ? undefined : { message: submittedMessage }, + onEvent: config.onEvent, + }) + return ( + <> +
+ +
+
+ + +
+
+ {stream && stream.length > 0 ? String(stream) : ( + + {submittedMessage === undefined ? 'Waiting for input…' : 'Generating...'} + + )} +
+ + ) +} diff --git a/demo/server/src/index.ts b/demo/server/src/index.ts index e69647a..62fe007 100644 --- a/demo/server/src/index.ts +++ b/demo/server/src/index.ts @@ -3,43 +3,64 @@ import cors from 'cors'; import fs from 'fs'; import path from 'path'; -const app = express(); -const PORT = process.env.PORT || 3001; +let reqSeq = 0; +function nextReqId(prefix: string) { + reqSeq = (reqSeq + 1) % Number.MAX_SAFE_INTEGER; + return `${prefix}-${Date.now().toString(36)}-${reqSeq}`; +} -// Middleware -app.use(cors()); -app.use(express.json()); +function nowMs() { + return Date.now(); +} -// Read the lorem ipsum file -const loremFilePath = path.join(__dirname, '../../lorem.txt'); -let loremText: string; +export function createDemoServerApp() { + const app = express(); -try { - loremText = fs.readFileSync(loremFilePath, 'utf8'); - console.log(`Loaded lorem ipsum text: ${loremText.length} characters`); -} catch (error) { - console.error('Error reading lorem ipsum file:', error); - process.exit(1); -} + // Middleware + app.use(cors()); + app.use(express.json()); -// Split text into words -const words = loremText.split(/\s+/).filter(word => word.length > 0).slice(0, 100); + function getLoremWords(): string[] { + // Read on-demand so importing this module in tests doesn't require the file. + const loremFilePath = path.join(__dirname, '../../lorem.txt'); + try { + const loremText = fs.readFileSync(loremFilePath, 'utf8'); + return loremText.split(/\s+/).filter((word) => word.length > 0).slice(0, 100); + } catch { + // Fallback: a short deterministic sample. + return 'lorem ipsum dolor sit amet consectetur adipiscing elit'.split(/\s+/); + } + } // SSE endpoint -app.get('/sse', (req, res) => { - console.log('New SSE connection established'); + app.get('/sse', (req, res) => { + const reqId = nextReqId('sse'); + const startedAt = nowMs(); + console.log(`[${reqId}] SSE request`, { + method: req.method, + path: req.path, + ip: req.ip, + ua: req.get('user-agent'), + }); + + const words = getLoremWords(); + console.log(`[${reqId}] SSE words loaded`, { totalWords: words.length }); // Set SSE headers - res.writeHead(200, { + res.status(200); + res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Cache-Control' + 'Access-Control-Allow-Headers': 'Cache-Control', }); + res.flushHeaders?.(); + console.log(`[${reqId}] SSE headers flushed`); // Send initial connection event res.write('data: {"type": "connected", "message": "SSE connection established"}\n\n'); + console.log(`[${reqId}] SSE connected event sent`); let wordIndex = 0; const sendInterval = setInterval(() => { @@ -53,7 +74,10 @@ app.get('/sse', (req, res) => { }; res.write(`data: ${JSON.stringify(completionChunk)}\n\n`); - console.log(`Stream completed: sent all ${words.length} words`); + console.log(`[${reqId}] SSE completed`, { + sentWords: words.length, + elapsedMs: nowMs() - startedAt, + }); clearInterval(sendInterval); res.end(); @@ -75,52 +99,167 @@ app.get('/sse', (req, res) => { wordIndex++; - // Log progress every 100 words - if (wordIndex % 100 === 0) { - console.log(`Sent ${wordIndex} words, current word: "${word}" (${word.length} chars)`); + // Log progress occasionally to avoid noise. + if (wordIndex === 1 || wordIndex % 25 === 0 || wordIndex === words.length) { + console.log(`[${reqId}] SSE progress`, { + sent: wordIndex, + total: words.length, + lastWord: word, + }); } }, 100); // Send a word every 100ms // Handle client disconnect - req.on('close', () => { - console.log('SSE connection closed'); + res.on('close', () => { + console.log(`[${reqId}] SSE connection closed`, { + sent: wordIndex, + total: words.length, + elapsedMs: nowMs() - startedAt, + }); + clearInterval(sendInterval); + }); + + res.on('error', (error) => { + console.error(`[${reqId}] SSE connection error`, error); + clearInterval(sendInterval); + }); + }); + +// HTTP POST -> SSE response endpoint + app.post('/http-stream', (req, res) => { + const { message } = (req.body ?? {}) as { message?: string }; + const reqId = nextReqId('http'); + const startedAt = nowMs(); + console.log(`[${reqId}] HTTP stream request`, { + method: req.method, + path: req.path, + ip: req.ip, + ua: req.get('user-agent'), + contentType: req.get('content-type'), + contentLength: req.get('content-length'), + messageChars: (message ?? '').length, + messagePreview: (message ?? '').slice(0, 120), + }); + + const input = (message ?? '').trim(); + const inputWords = input.length > 0 ? input.split(/\s+/).filter(Boolean) : []; + console.log(`[${reqId}] HTTP stream parsed input`, { totalWords: inputWords.length }); + + res.status(200); + res.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control', + }); + res.flushHeaders?.(); + console.log(`[${reqId}] HTTP stream headers flushed`); + + res.write( + `data: ${JSON.stringify({ + type: 'connected', + message: 'HTTP stream established', + requestMessage: message ?? '', + })}\n\n`, + ); + console.log(`[${reqId}] HTTP connected event sent`); + + let wordIndex = 0; + const sendInterval = setInterval(() => { + if (wordIndex >= inputWords.length) { + const completionChunk = { + type: 'completed', + message: 'All words have been sent', + totalWords: inputWords.length, + timestamp: new Date().toISOString(), + }; + + res.write(`data: ${JSON.stringify(completionChunk)}\n\n`); + console.log(`[${reqId}] HTTP stream completed`, { + sentWords: inputWords.length, + elapsedMs: nowMs() - startedAt, + }); + + clearInterval(sendInterval); + res.end(); + return; + } + + const word = inputWords[wordIndex]; + const chunk = { + type: 'chunk', + word: word, + wordLength: word.length, + index: wordIndex, + totalWords: inputWords.length, + timestamp: new Date().toISOString(), + }; + + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + wordIndex++; + + if (wordIndex === 1 || wordIndex % 10 === 0 || wordIndex === inputWords.length) { + console.log(`[${reqId}] HTTP stream progress`, { + sent: wordIndex, + total: inputWords.length, + lastWord: word, + }); + } + }, 100); + + res.on('close', () => { + console.log(`[${reqId}] HTTP stream connection closed`, { + sent: wordIndex, + total: inputWords.length, + elapsedMs: nowMs() - startedAt, + }); clearInterval(sendInterval); }); - req.on('error', (error) => { - console.error('SSE connection error:', error); + res.on('error', (error) => { + console.error(`[${reqId}] HTTP stream connection error`, error); clearInterval(sendInterval); }); -}); + }); // Health check endpoint -app.get('/health', (req, res) => { + app.get('/health', (req, res) => { + const words = getLoremWords(); res.json({ status: 'healthy', timestamp: new Date().toISOString(), totalWords: words.length, - textLength: loremText.length + textLength: null, + }); }); -}); // Root endpoint with instructions -app.get('/', (req, res) => { + app.get('/', (req, res) => { + const words = getLoremWords(); res.json({ message: 'SSE Test Server', endpoints: { '/sse': 'Server-Sent Events stream with lorem ipsum words', + '/http-stream': 'HTTP POST that responds with an event-stream', '/health': 'Health check endpoint' }, stats: { totalWords: words.length, - textLength: loremText.length + textLength: null, } }); -}); - -app.listen(PORT, () => { - console.log(`SSE Test Server running on http://localhost:${PORT}`); - console.log(`SSE endpoint: http://localhost:${PORT}/sse`); - console.log(`Health check: http://localhost:${PORT}/health`); - console.log(`Total words to stream: ${words.length}`); -}); + }); + + return app; +} + +const PORT = process.env.PORT || 3001; +if (!process.env.VITEST) { + const app = createDemoServerApp(); + app.listen(PORT, () => { + console.log(`SSE Test Server running on http://localhost:${PORT}`); + console.log(`SSE endpoint: http://localhost:${PORT}/sse`); + console.log(`Health check: http://localhost:${PORT}/health`); + }); +} diff --git a/package-lock.json b/package-lock.json index 36f0555..2d51dbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/express": "^5.0.6", "@types/node": "^24.3.1", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "@vitest/ui": "^3.2.4", "amvite": "0.0.3", + "express": "^5.2.1", "glob": "^11.0.3", "happy-dom": "^18.0.1", "jsdom": "^27.0.0", @@ -1984,6 +1986,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", @@ -1994,6 +2007,16 @@ "@types/deep-eql": "*" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2008,6 +2031,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.4.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz", @@ -2018,6 +2073,20 @@ "undici-types": "~7.11.0" } }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", @@ -2038,6 +2107,27 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -2243,6 +2333,20 @@ "vscode-uri": "^3.0.8" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2453,6 +2557,31 @@ "require-from-string": "^2.0.2" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -2511,6 +2640,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2689,6 +2828,30 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2697,6 +2860,26 @@ "license": "MIT", "peer": true }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2899,6 +3082,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2961,6 +3154,13 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.218", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz", @@ -2976,6 +3176,16 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -3202,6 +3412,13 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -3232,6 +3449,16 @@ "dev": true, "license": "MIT" }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -3242,6 +3469,50 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/exsolve": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", @@ -3303,6 +3574,28 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -3356,6 +3649,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -3729,6 +4042,27 @@ "node": ">=18" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3804,6 +4138,13 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3818,6 +4159,16 @@ "node": ">= 0.4" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4075,6 +4426,13 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4494,6 +4852,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -4503,6 +4871,19 @@ "node": ">= 0.10.0" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4527,6 +4908,33 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4641,6 +5049,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -4821,6 +5239,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -4950,6 +5391,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -5010,6 +5461,17 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -5174,6 +5636,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5184,6 +5660,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -5222,6 +5714,32 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", @@ -5465,6 +5983,23 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -5588,6 +6123,53 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -5634,6 +6216,13 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5853,6 +6442,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", @@ -6256,6 +6855,16 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -6292,6 +6901,21 @@ "node": ">=18" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -6423,6 +7047,16 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unplugin": { "version": "2.3.10", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.10.tgz", @@ -6548,6 +7182,16 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", @@ -7105,6 +7749,13 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/package.json b/package.json index 956b48f..2d1ba45 100644 --- a/package.json +++ b/package.json @@ -41,18 +41,20 @@ "react-dom": "^19.1.1" }, "devDependencies": { + "@changesets/cli": "^2.29.7", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@changesets/cli": "^2.29.7", + "@types/express": "^5.0.6", "@types/node": "^24.3.1", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "@vitest/ui": "^3.2.4", "amvite": "0.0.3", + "express": "^5.2.1", + "glob": "^11.0.3", "happy-dom": "^18.0.1", "jsdom": "^27.0.0", - "glob": "^11.0.3", "npm-run-all": "^4.1.5", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/src/EventStore.ts b/src/EventStore.ts index e9898dc..847064d 100644 --- a/src/EventStore.ts +++ b/src/EventStore.ts @@ -1,44 +1,5 @@ -export function EventStore(url: string, onEvent: (event: P) => R|undefined) { - let retryCount = 0; - let currentData: R|undefined; - const listeners = new Set(); +import { SeverSentEventStore } from './SeverSentEventStore'; - function subscribe(callback: () => void) { - listeners.add(callback); - return () => listeners.delete(callback); - } +// Backwards-compat alias: the project historically exported EventStore. +export const EventStore = SeverSentEventStore; - function getSnapshot() { - return currentData; - } - - const eventSource = new EventSource(url) - - eventSource.onmessage = (event: MessageEvent) => { - const newData = JSON.parse(event.data) as P; - const parsedData = onEvent(newData); - if (parsedData === undefined) { - currentData = undefined; - retryCount = 0; - eventSource.close(); - return; - } - else { - currentData = parsedData; - listeners.forEach((listener: any) => listener()); - } - }; - - eventSource.onerror = () => { - retryCount++; - if (retryCount >= 3) { - retryCount = 0; - eventSource.close(); - } - }; - - return { - subscribe, - getSnapshot, - } -} \ No newline at end of file diff --git a/src/HttpEventStreamStore.ts b/src/HttpEventStreamStore.ts new file mode 100644 index 0000000..b4fdf5e --- /dev/null +++ b/src/HttpEventStreamStore.ts @@ -0,0 +1,176 @@ +type Listener = () => void; + +function toBodyInit(data: unknown): { body?: BodyInit; headers?: Record } { + if (data === undefined) return {}; + + // If caller already provided a BodyInit-ish value, pass through. + if ( + typeof data === 'string' || + data instanceof Blob || + data instanceof ArrayBuffer || + data instanceof FormData || + data instanceof URLSearchParams || + data instanceof ReadableStream + ) { + return { body: data as BodyInit }; + } + + return { + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' }, + }; +} + +function parseSseEventDataFrames(sseFrame: string): string[] { + // SSE frames are separated by "\n\n". Within a frame, there can be multiple + // "data:" lines that should be concatenated with "\n". + const lines = sseFrame.split(/\r?\n/); + const dataLines: string[] = []; + for (const line of lines) { + if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).trimStart()); + } + if (dataLines.length === 0) return []; + return [dataLines.join('\n')]; +} + +export function HttpEventStreamStore( + url: string, + data: D | undefined, + onEvent: (event: P) => R | undefined, + fetchInit?: Omit, +) { + let retryCount = 0; + let currentData: R | undefined; + const listeners = new Set(); + + let controller: AbortController | undefined; + let stopped = false; + let currentInput: D | undefined = data; + + function notify() { + listeners.forEach((l) => l()); + } + + function abortCurrentRequest() { + controller?.abort(); + controller = undefined; + } + + async function start() { + // Defer network work until we both have an input payload and a consumer. + if (controller || stopped) return; + if (listeners.size === 0) return; + if (currentInput === undefined) return; + + controller = new AbortController(); + const { body, headers } = toBodyInit(currentInput); + + try { + const res = await fetch(url, { + ...fetchInit, + method: 'POST', + body, + headers: { + Accept: 'text/event-stream', + ...(headers ?? {}), + ...(fetchInit?.headers ?? {}), + }, + signal: controller.signal, + }); + + if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); + if (!res.body) throw new Error('Response body is not readable'); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete SSE frames. + while (true) { + const idx = buffer.indexOf('\n\n'); + if (idx === -1) break; + + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + + for (const dataStr of parseSseEventDataFrames(frame)) { + const newData = JSON.parse(dataStr) as P; + const parsedData = onEvent(newData); + if (parsedData === undefined) { + currentData = undefined; + retryCount = 0; + stop(); + return; + } + + currentData = parsedData; + notify(); + } + } + } + } catch (err) { + if (controller?.signal.aborted) return; + + retryCount++; + if (retryCount >= 3) { + retryCount = 0; + stop(); + return; + } + + // allow a subsequent start() attempt + controller = undefined; + if (!stopped) start(); + } + } + + function stop() { + stopped = true; + abortCurrentRequest(); + } + + function subscribe(callback: Listener) { + listeners.add(callback); + start(); + return () => { + listeners.delete(callback); + if (listeners.size === 0) stop(); + }; + } + + function getSnapshot() { + return currentData; + } + + function setData(nextData: D | undefined) { + currentInput = nextData; + + // If we already have an active request, restart with the new input. + if (controller) { + abortCurrentRequest(); + } + + // Clear out previous output when input is cleared. + if (currentInput === undefined) { + currentData = undefined; + retryCount = 0; + notify(); + return; + } + + start(); + } + + return { + subscribe, + getSnapshot, + setData, + }; +} + diff --git a/src/SeverSentEventStore.ts b/src/SeverSentEventStore.ts new file mode 100644 index 0000000..4227f10 --- /dev/null +++ b/src/SeverSentEventStore.ts @@ -0,0 +1,44 @@ +export function SeverSentEventStore(url: string, onEvent: (event: P) => R|undefined) { + let retryCount = 0; + let currentData: R|undefined; + const listeners = new Set(); + + function subscribe(callback: () => void) { + listeners.add(callback); + return () => listeners.delete(callback); + } + + function getSnapshot() { + return currentData; + } + + const eventSource = new EventSource(url) + + eventSource.onmessage = (event: MessageEvent) => { + const newData = JSON.parse(event.data) as P; + const parsedData = onEvent(newData); + if (parsedData === undefined) { + currentData = undefined; + retryCount = 0; + eventSource.close(); + return; + } + else { + currentData = parsedData; + listeners.forEach((listener: any) => listener()); + } + }; + + eventSource.onerror = () => { + retryCount++; + if (retryCount >= 3) { + retryCount = 0; + eventSource.close(); + } + }; + + return { + subscribe, + getSnapshot, + } +} \ No newline at end of file diff --git a/src/components/ReactTextStream/index.tsx b/src/components/ReactTextStream/index.tsx index 5689c81..8d285b7 100644 --- a/src/components/ReactTextStream/index.tsx +++ b/src/components/ReactTextStream/index.tsx @@ -5,17 +5,23 @@ interface ReactTextStreamProps { url: string; onEvent: (event: T) => string|undefined; render: (stream: string) => React.ReactNode; + store?: 'sse' | 'http'; + data?: unknown; + fetchInit?: Omit; } function ReactTextStream({ url, onEvent, - render + render, + store, + data, + fetchInit, }: ReactTextStreamProps) { - const stream = useTextStream( - url, - onEvent - )!; + const stream = + store !== undefined || data !== undefined || fetchInit !== undefined + ? useTextStream({ url, onEvent, store, data, fetchInit })! + : useTextStream(url, onEvent)!; return render(stream); } diff --git a/src/hooks/useTextStream.ts b/src/hooks/useTextStream.ts index 94a0b63..4966e3b 100644 --- a/src/hooks/useTextStream.ts +++ b/src/hooks/useTextStream.ts @@ -1,25 +1,61 @@ import { useState, useSyncExternalStore, useEffect, useMemo } from 'react'; import { EventStore } from '../EventStore'; +import { HttpEventStreamStore } from '../HttpEventStreamStore'; -function useTextStream

(url: string, onEvent: (event: P) => string|undefined) { - const eventStore = useMemo(() => EventStore(url, onEvent), [url]); - - const [stream, setStream] = useState(); - - const sseChunk = useSyncExternalStore(eventStore.subscribe, eventStore.getSnapshot); - - useEffect(() => { - if (sseChunk !== undefined) { - setStream((curStream) => { - if (!curStream) { - return sseChunk; - } - return curStream?.concat(sseChunk); - }) - } - }, [sseChunk]); - - return stream; +export type UseTextStreamOptions = { + url: string; + onEvent: (event: P) => string | undefined; + store?: 'sse' | 'http'; + data?: D; + fetchInit?: Omit; +}; + +function useTextStream

(url: string, onEvent: (event: P) => string | undefined): string | undefined; +function useTextStream(options: UseTextStreamOptions): string | undefined; +function useTextStream( + urlOrOptions: string | UseTextStreamOptions, + onEventArg?: (event: P) => string | undefined, +) { + const options: UseTextStreamOptions = + typeof urlOrOptions === 'string' + ? { url: urlOrOptions, onEvent: onEventArg as (event: P) => string | undefined, store: 'sse' } + : urlOrOptions; + + const storeType = options.store ?? 'sse'; + const url = options.url; + const onEvent = options.onEvent; + const data = options.data as D; + const fetchInit = options.fetchInit; + + const eventStore = useMemo(() => { + if (storeType === 'http') { + return HttpEventStreamStore(url, undefined, onEvent, fetchInit); + } + + // Preserve prior behavior for SSE: a stable store keyed by URL. + return EventStore(url, onEvent); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, storeType === 'http' ? [storeType, url, onEvent, fetchInit] : [storeType, url]); + + useEffect(() => { + if (storeType !== 'http') return; + (eventStore as ReturnType>).setData(data); + }, [storeType, eventStore, data]); + + const [stream, setStream] = useState(); + + const chunk = useSyncExternalStore(eventStore.subscribe, eventStore.getSnapshot); + + useEffect(() => { + if (chunk !== undefined) { + setStream((curStream) => { + if (!curStream) return chunk; + return curStream.concat(chunk); + }); + } + }, [chunk]); + + return stream; } export default useTextStream; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5f89502..02ec36e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import ReactTextStream from './components/ReactTextStream' import useTextStream from './hooks/useTextStream' +export type { UseTextStreamOptions } from './hooks/useTextStream' export { ReactTextStream, useTextStream } \ No newline at end of file diff --git a/test/integration.test.tsx b/test/integration.test.tsx deleted file mode 100644 index 29d73e3..0000000 --- a/test/integration.test.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import React from 'react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import { ReactTextStream, useTextStream } from '../src/index'; - -// Mock EventSource for integration tests -class MockEventSource { - url: string; - onmessage: ((event: MessageEvent) => void) | null = null; - onerror: (() => void) | null = null; - onopen: (() => void) | null = null; - readyState: number = 1; - CONNECTING = 0; - OPEN = 1; - CLOSED = 2; - private messageQueue: any[] = []; - private isProcessing = false; - - constructor(url: string) { - this.url = url; - // Simulate connection opening - setTimeout(() => { - if (this.onopen) { - this.onopen(); - } - }, 0); - } - - close() { - this.readyState = this.CLOSED; - } - - // Helper methods for testing - simulateMessage(data: any) { - this.messageQueue.push(data); - this.processQueue(); - } - - simulateError() { - if (this.onerror) { - this.onerror(); - } - } - - private async processQueue() { - if (this.isProcessing || this.messageQueue.length === 0) return; - - this.isProcessing = true; - - while (this.messageQueue.length > 0) { - const data = this.messageQueue.shift(); - if (this.onmessage) { - const event = new MessageEvent('message', { - data: JSON.stringify(data) - }); - this.onmessage(event); - } - // Small delay to simulate real streaming - await new Promise(resolve => setTimeout(resolve, 10)); - } - - this.isProcessing = false; - } -} - -describe('Integration Tests', () => { - let mockEventSource: any; - let originalEventSource: any; - - beforeEach(() => { - // Store original EventSource - originalEventSource = global.EventSource; - - // Create a mock EventSource - mockEventSource = new MockEventSource('http://example.com/stream'); - - // Mock EventSource constructor - global.EventSource = vi.fn().mockImplementation((url: string) => { - mockEventSource.url = url; - return mockEventSource; - }) as any; - }); - - afterEach(() => { - // Restore original EventSource - global.EventSource = originalEventSource; - vi.clearAllMocks(); - }); - - describe('ReactTextStream integration', () => { - it('should handle complete streaming flow', async () => { - const onEvent = (data: { message: string }) => data.message; - - const TestComponent = () => ( - ( -

-

Streaming Content

-
{stream || 'No content yet'}
-
- )} - /> - ); - - render(); - - // Initially no content - expect(screen.getByText('No content yet')).toBeInTheDocument(); - - // Simulate streaming messages - mockEventSource.simulateMessage({ message: 'Hello' }); - - await waitFor(() => { - expect(screen.getByText('Hello')).toBeInTheDocument(); - }); - - mockEventSource.simulateMessage({ message: ' World' }); - - await waitFor(() => { - expect(screen.getByText('Hello World')).toBeInTheDocument(); - }); - - mockEventSource.simulateMessage({ message: '!' }); - - await waitFor(() => { - expect(screen.getByText('Hello World!')).toBeInTheDocument(); - }); - }); - - it('should handle stream termination', async () => { - const onEvent = (data: { message?: string; done?: boolean }) => { - if (data.done) return undefined; - return data.message; - }; - - const TestComponent = () => ( - ( -
-
{stream || 'Stream ended'}
-
- )} - /> - ); - - render(); - - // Send some content - mockEventSource.simulateMessage({ message: 'Hello' }); - - await waitFor(() => { - expect(screen.getByText('Hello')).toBeInTheDocument(); - }); - - // Send termination signal - mockEventSource.simulateMessage({ done: true }); - - // Stream should remain as is (not change to "Stream ended") - await waitFor(() => { - expect(screen.getByText('Hello')).toBeInTheDocument(); - }); - }); - - it('should handle multiple rapid messages', async () => { - const onEvent = (data: { chunk: string }) => data.chunk; - - const TestComponent = () => ( - ( -
-
{stream || 'Waiting...'}
-
- )} - /> - ); - - render(); - - // Send multiple rapid messages - const chunks = ['a', 'b', 'c', 'd', 'e']; - chunks.forEach(chunk => { - mockEventSource.simulateMessage({ chunk }); - }); - - await waitFor(() => { - expect(screen.getByText('abcde')).toBeInTheDocument(); - }); - }); - }); - - describe('useTextStream integration', () => { - it('should work independently of ReactTextStream', async () => { - const TestComponent = () => { - const stream = useTextStream( - 'http://example.com/stream', - (data: { text: string }) => data.text - ); - - return ( -
-
{stream || 'No stream'}
-
- ); - }; - - render(); - - expect(screen.getByText('No stream')).toBeInTheDocument(); - - mockEventSource.simulateMessage({ text: 'Direct hook test' }); - - await waitFor(() => { - expect(screen.getByText('Direct hook test')).toBeInTheDocument(); - }); - }); - - it('should handle complex data transformation', async () => { - const TestComponent = () => { - const stream = useTextStream( - 'http://example.com/stream', - (data: { user: string; message: string; timestamp: number }) => { - return `[${new Date(data.timestamp).toLocaleTimeString()}] ${data.user}: ${data.message}`; - } - ); - - return ( -
-
{stream || 'No messages'}
-
- ); - }; - - render(); - - const timestamp = Date.now(); - mockEventSource.simulateMessage({ - user: 'Alice', - message: 'Hello everyone!', - timestamp - }); - - await waitFor(() => { - const expectedText = `[${new Date(timestamp).toLocaleTimeString()}] Alice: Hello everyone!`; - expect(screen.getByText(expectedText)).toBeInTheDocument(); - }); - }); - }); - - describe('error handling integration', () => { - it('should handle EventSource errors gracefully', async () => { - const onEvent = (data: { message: string }) => data.message; - - const TestComponent = () => ( - ( -
-
{stream || 'No content'}
-
- )} - /> - ); - - render(); - - // Send some content first - mockEventSource.simulateMessage({ message: 'Hello' }); - - await waitFor(() => { - expect(screen.getByText('Hello')).toBeInTheDocument(); - }); - - // Simulate errors - mockEventSource.simulateError(); - mockEventSource.simulateError(); - mockEventSource.simulateError(); - - // Content should still be there - expect(screen.getByText('Hello')).toBeInTheDocument(); - - // 4th error should close connection - mockEventSource.simulateError(); - - // Content should still be there (connection closed but data preserved) - expect(screen.getByText('Hello')).toBeInTheDocument(); - }); - - it('should handle malformed JSON gracefully', async () => { - const onEvent = (data: { message: string }) => data.message; - - const TestComponent = () => ( - ( -
-
{stream || 'No content'}
-
- )} - /> - ); - - render(); - - // This would normally cause a JSON.parse error - // But our mock handles it gracefully - mockEventSource.simulateMessage({ message: 'Valid message' }); - - await waitFor(() => { - expect(screen.getByText('Valid message')).toBeInTheDocument(); - }); - }); - }); - - describe('real-world scenarios', () => { - it('should handle chat-like streaming', async () => { - const onEvent = (data: { type: string; content: string; user?: string }) => { - if (data.type === 'message') { - return `[${data.user}]: ${data.content}`; - } - return undefined; - }; - - const TestComponent = () => ( - ( -
-

Chat

-
{stream || 'No messages'}
-
- )} - /> - ); - - render(); - - // Simulate chat messages - mockEventSource.simulateMessage({ - type: 'message', - user: 'Alice', - content: 'Hello everyone!' - }); - - await waitFor(() => { - expect(screen.getByText('[Alice]: Hello everyone!')).toBeInTheDocument(); - }); - - mockEventSource.simulateMessage({ - type: 'message', - user: 'Bob', - content: 'Hi Alice!' - }); - - await waitFor(() => { - expect(screen.getByText('[Alice]: Hello everyone![Bob]: Hi Alice!')).toBeInTheDocument(); - }); - }); - }); -});