(undefined)
+ const stream = useTextStream({
+ url: config.httpUrl,
+ store: 'http',
+ data: submittedMessage === undefined ? undefined : { message: submittedMessage },
+ onEvent: config.onEvent,
+ })
+ return (
+ <>
+
+
+ HTTP message:{' '}
+ setMessage(e.target.value)}
+ style={{ width: 320 }}
+ />
+
+
+
+ setSubmittedMessage(message)}>
+ Start HTTP stream
+
+ setSubmittedMessage(undefined)} style={{ marginLeft: 8 }}>
+ Reset
+
+
+
+ {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();
- });
- });
- });
-});