Skip to content

src+demo: add support for HTTP event stream#5

Merged
amerani merged 4 commits into
mainfrom
ex2
Apr 26, 2026
Merged

src+demo: add support for HTTP event stream#5
amerani merged 4 commits into
mainfrom
ex2

Conversation

@amerani

@amerani amerani commented Apr 26, 2026

Copy link
Copy Markdown
Owner

No description provided.

@amerani amerani requested review from camelmonk and Copilot April 26, 2026 01:22
@amerani amerani merged commit 11be84e into main Apr 26, 2026
3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the library to support consuming text/event-stream over an HTTP fetch() (POST) in addition to browser EventSource SSE, and updates the demo server/client to showcase the new mode.

Changes:

  • Added an HttpEventStreamStore implementation and a new useTextStream options overload ({ store: 'http', data, fetchInit, ... }).
  • Refactored the existing SSE store into a separate module and kept EventStore as a backwards-compatible alias.
  • Updated the demo server/client to provide and consume a POST-based event-stream endpoint; moved demo-running docs from README into CONTRIBUTING.

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/hooks/useTextStream.ts Adds options overload + wires hook to either SSE EventStore or new HTTP store.
src/HttpEventStreamStore.ts New fetch-based streaming store that parses SSE-style frames from a response body.
src/SeverSentEventStore.ts Extracts prior SSE EventStore logic into a new module (with a naming typo).
src/EventStore.ts Re-exports EventStore as an alias to the extracted SSE store.
src/components/ReactTextStream/index.tsx Exposes store/data/fetchInit props and forwards to the hook overload.
src/index.ts Exports UseTextStreamOptions type for consumers.
demo/server/src/index.ts Adds /http-stream endpoint and refactors server app creation for test-friendliness.
demo/client/src/main.tsx Adds a UI section demonstrating the HTTP POST + event-stream mode.
test/integration.test.tsx Removes the prior integration test suite.
README.md Removes local demo instructions (moved to CONTRIBUTING).
CONTRIBUTING.md Adds contributor/demo workflow documentation.
package.json / package-lock.json Adds dev dependencies (notably express/types) used for local/demo tooling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +88 to +91
while (true) {
const { done, value } = await reader.read();
if (done) break;

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the response stream ends naturally (reader.read() returns done: true), start() exits the try without clearing controller. Since start() begins with if (controller || stopped) return;, a completed request can block any future restart attempts unless something else aborts/clears the controller. Consider clearing controller (and possibly resetting retryCount) when the stream finishes normally.

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +56
controller?.abort();
controller = undefined;

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

abortCurrentRequest() aborts and then sets controller = undefined. When an in-flight request is aborted (e.g., due to setData() updating input), the catch block later can’t reliably detect the abort via controller?.signal.aborted, so it will increment retryCount and may stop the stream after a few normal data changes. Keep a stable reference to the AbortController/signal for the lifetime of start() (or detect AbortError via err) so abort-driven restarts don’t count as failures.

Suggested change
controller?.abort();
controller = undefined;
// Keep the controller reference stable until the in-flight request finishes
// unwinding so downstream error handling can still observe that it was
// intentionally aborted.
controller?.abort();

Copilot uses AI. Check for mistakes.
data instanceof ArrayBuffer ||
data instanceof FormData ||
data instanceof URLSearchParams ||
data instanceof ReadableStream

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data instanceof ReadableStream will throw a ReferenceError in runtimes where ReadableStream isn’t defined (older browsers / some test environments). Guard with typeof ReadableStream !== 'undefined' (and similarly for other globals if needed) before using instanceof on them.

Suggested change
data instanceof ReadableStream
(typeof ReadableStream !== 'undefined' && data instanceof ReadableStream)

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +57
if (chunk !== undefined) {
setStream((curStream) => {
if (!curStream) return chunk;
return curStream.concat(chunk);
});
}
}, [chunk]);

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpEventStreamStore.setData() explicitly clears currentData and calls notify() when input is cleared, but this hook never clears its accumulated stream state when the store snapshot becomes undefined (it only appends when chunk !== undefined). This makes it impossible to implement a true reset/clear for the HTTP mode (and the demo’s Reset button will keep showing the old stream). Handle an undefined snapshot by resetting the accumulated stream when appropriate (e.g., when storeType === 'http' and input is cleared).

Suggested change
if (chunk !== undefined) {
setStream((curStream) => {
if (!curStream) return chunk;
return curStream.concat(chunk);
});
}
}, [chunk]);
if (chunk === undefined) {
if (storeType === 'http') {
setStream(undefined);
}
return;
}
setStream((curStream) => {
if (!curStream) return chunk;
return curStream.concat(chunk);
});
}, [chunk, storeType]);

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +57
useEffect(() => {
if (storeType !== 'http') return;
(eventStore as ReturnType<typeof HttpEventStreamStore<P, string, D>>).setData(data);
}, [storeType, eventStore, data]);

const [stream, setStream] = useState<string>();

const chunk = useSyncExternalStore(eventStore.subscribe, eventStore.getSnapshot);

useEffect(() => {
if (chunk !== undefined) {
setStream((curStream) => {
if (!curStream) return chunk;
return curStream.concat(chunk);
});
}
}, [chunk]);

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no tests covering the new options overload (useTextStream({ store: 'http', data, fetchInit, ... })) or HttpEventStreamStore behavior (restarts on data change, reset on data cleared, CRLF framing, abort handling). Since this repo already has comprehensive Vitest coverage for useTextStream, please add/extend tests to exercise the HTTP store path and the new overload signature.

Suggested change
useEffect(() => {
if (storeType !== 'http') return;
(eventStore as ReturnType<typeof HttpEventStreamStore<P, string, D>>).setData(data);
}, [storeType, eventStore, data]);
const [stream, setStream] = useState<string>();
const chunk = useSyncExternalStore(eventStore.subscribe, eventStore.getSnapshot);
useEffect(() => {
if (chunk !== undefined) {
setStream((curStream) => {
if (!curStream) return chunk;
return curStream.concat(chunk);
});
}
}, [chunk]);
const [stream, setStream] = useState<string>();
useEffect(() => {
if (storeType !== 'http') return;
setStream(undefined);
(eventStore as ReturnType<typeof HttpEventStreamStore<P, string, D>>).setData(data);
}, [storeType, eventStore, data]);
const chunk = useSyncExternalStore(eventStore.subscribe, eventStore.getSnapshot);
useEffect(() => {
if (chunk === undefined) {
if (storeType === 'http') {
setStream(undefined);
}
return;
}
setStream((curStream) => {
if (!curStream) return chunk;
return curStream.concat(chunk);
});
}, [chunk, storeType]);

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +9
const listeners = new Set();

function subscribe(callback: () => void) {
listeners.add(callback);
return () => listeners.delete(callback);
}

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

listeners is currently an untyped Set, and later listeners are invoked with (listener: any) => listener(). This loses type safety and can mask incorrect listener usage. Prefer const listeners = new Set<() => void>() and iterate without any.

Copilot uses AI. Check for mistakes.
Comment on lines +133 to +136
function stop() {
stopped = true;
abortCurrentRequest();
}

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stop() sets stopped = true, which permanently disables future start() calls. Since start() calls stop() when onEvent returns undefined (used as a normal completion signal), the store can’t be restarted for a new setData() after a stream completes (and also can’t restart after all listeners briefly unsubscribe). Consider making stop() non-terminal (abort current request but allow future start()), or resetting stopped when new input is set.

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +100
const idx = buffer.indexOf('\n\n');
if (idx === -1) break;

const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Frame detection only searches for "\n\n" (buffer.indexOf('\n\n')). SSE responses are also commonly delimited by CRLF ("\r\n\r\n"), in which case this loop will never find a frame boundary and the stream will stall. Consider handling both delimiters (or normalizing newlines) when scanning buffer.

Suggested change
const idx = buffer.indexOf('\n\n');
if (idx === -1) break;
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const lfIdx = buffer.indexOf('\n\n');
const crlfIdx = buffer.indexOf('\r\n\r\n');
let idx = -1;
let delimiterLength = 0;
if (lfIdx !== -1 && (crlfIdx === -1 || lfIdx < crlfIdx)) {
idx = lfIdx;
delimiterLength = 2;
} else if (crlfIdx !== -1) {
idx = crlfIdx;
delimiterLength = 4;
}
if (idx === -1) break;
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + delimiterLength);

Copilot uses AI. Check for mistakes.
buffer = buffer.slice(idx + 2);

for (const dataStr of parseSseEventDataFrames(frame)) {
const newData = JSON.parse(dataStr) as P;

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.parse(dataStr) inside the streaming loop will throw on any non-JSON data: payload (or partial/invalid frames) and is currently handled the same as a network error. If the API expects JSON-only, consider surfacing a clearer error or skipping malformed events so a single bad frame doesn’t tear down the stream after 3 retries.

Suggested change
const newData = JSON.parse(dataStr) as P;
let newData: P;
try {
newData = JSON.parse(dataStr) as P;
} catch {
continue;
}

Copilot uses AI. Check for mistakes.
Comment thread src/EventStore.ts
Comment on lines +1 to +4
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;

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you rename SeverSentEventStore/src/SeverSentEventStore.ts to fix the typo, update this import/export alias accordingly so the historical EventStore export continues to work without a breaking change.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants