From 6b81cb7ffa06f5cd938d6f4bb4f6aee0d331c110 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 16 May 2025 13:20:09 -0400 Subject: [PATCH 1/9] wip --- package.json | 3 + packages/react/src/hooks/use-event-stream.ts | 6 +- packages/react/src/hooks/use-stream.ts | 216 +++++++++++++++++++ packages/react/src/index.ts | 1 + packages/react/src/types.ts | 25 ++- packages/vue/src/composables/useStream.ts | 134 ++++++++++++ pnpm-lock.yaml | 11 + 7 files changed, 391 insertions(+), 5 deletions(-) create mode 100644 packages/react/src/hooks/use-stream.ts create mode 100644 packages/vue/src/composables/useStream.ts diff --git a/package.json b/package.json index 9c02d3f..c278eb7 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,8 @@ }, "devDependencies": { "prettier": "^3.5.3" + }, + "dependencies": { + "nanoid": "^5.1.5" } } diff --git a/packages/react/src/hooks/use-event-stream.ts b/packages/react/src/hooks/use-event-stream.ts index 7ac5f66..ae25ac4 100644 --- a/packages/react/src/hooks/use-event-stream.ts +++ b/packages/react/src/hooks/use-event-stream.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Options, StreamResult } from "../types"; +import { EventStreamOptions, EventStreamResponse } from "../types"; const dataPrefix = "data: "; @@ -23,8 +23,8 @@ export const useEventStream = ( onMessage = () => null, onComplete = () => null, onError = () => null, - }: Options = {}, -): StreamResult => { + }: EventStreamOptions = {}, +): EventStreamResponse => { const sourceRef = useRef(null); const messagePartsRef = useRef([]); const eventNames = useMemo( diff --git a/packages/react/src/hooks/use-stream.ts b/packages/react/src/hooks/use-stream.ts new file mode 100644 index 0000000..06706e4 --- /dev/null +++ b/packages/react/src/hooks/use-stream.ts @@ -0,0 +1,216 @@ +import { nanoid } from "nanoid"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { StreamListenerCallback, StreamMeta, StreamOptions } from "../types"; + +const streams = new Map(); +const listeners = new Map(); + +const resolveStream = (id: string) => { + const stream = streams.get(id); + + if (stream) { + return stream; + } + + streams.set(id, { + controller: new AbortController(), + data: "", + isFetching: false, + isStreaming: false, + }); + + return streams.get(id)!; +}; + +const resolveListener = (id: string) => { + if (!listeners.has(id)) { + listeners.set(id, []); + } + + return listeners.get(id)!; +}; + +const addListener = (id: string, listener: StreamListenerCallback) => { + resolveListener(id).push(listener); + + return () => { + listeners.set( + id, + resolveListener(id).filter((l) => l !== listener), + ); + }; +}; + +export const useStream = (url: string, options: StreamOptions = {}) => { + const id = useRef(options.id ?? nanoid()); + const stream = useRef(resolveStream(id.current)); + const headers = useRef( + (() => { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + const csrfToken = + options.csrfToken ?? + document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute("content"); + + if (csrfToken) { + headers["X-CSRF-TOKEN"] = csrfToken; + } + + return headers; + })(), + ); + + const [data, setData] = useState(stream.current.data); + const [isLoading, setIsLoading] = useState(stream.current.isFetching); + const [isStreaming, setIsStreaming] = useState(stream.current.isStreaming); + + const updateStream = useCallback((params: Partial) => { + streams.set(id.current, { + ...resolveStream(id.current), + ...params, + }); + + listeners + .get(id.current) + ?.forEach((listener) => listener(streams.get(id.current)!)); + }, []); + + const stop = useCallback(() => { + stream.current.controller.abort(); + + updateStream({ + isFetching: false, + isStreaming: false, + }); + }, []); + + const makeRequest = useCallback( + (body: Record = {}) => { + updateStream({ + isFetching: true, + controller: new AbortController(), + }); + + fetch(url, { + method: "POST", + signal: stream.current.controller.signal, + headers: { + ...headers.current, + ...(options.headers ?? {}), + }, + body: JSON.stringify(body), + }) + .then((response) => { + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + if (!response.body) { + throw new Error( + "ReadableStream not yet supported in this browser.", + ); + } + + options.onResponse?.(response); + + updateStream({ + isFetching: false, + isStreaming: true, + }); + + return read(response.body.getReader()); + }) + .catch((error) => { + updateStream({ + isFetching: false, + isStreaming: false, + }); + + if (error?.name === "AbortError") { + options.onCancel?.(); + } else { + options.onError?.(error); + } + + options.onFinish?.(); + }); + }, + [url], + ); + + const send = useCallback((body: Record) => { + stop(); + makeRequest(body); + updateStream({ + data: "", + }); + }, []); + + const read = useCallback( + ( + reader: ReadableStreamDefaultReader, + str = "", + ): Promise => { + return reader.read().then(({ done, value }) => { + const newData = str + new TextDecoder().decode(value); + + options.onData?.(str); + + if (done) { + updateStream({ + data: newData, + isStreaming: false, + }); + + options.onFinish?.(); + + return ""; + } + + updateStream({ + data: newData, + }); + + return read(reader, newData); + }); + }, + [], + ); + + useEffect(() => { + const stopListening = addListener(id.current, (stream: StreamMeta) => { + setIsLoading(stream.isFetching); + setIsStreaming(stream.isStreaming); + setData(stream.data); + }); + + return () => { + stopListening(); + }; + }, []); + + useEffect(() => { + window.addEventListener("beforeunload", stop); + }, [stop]); + + useEffect(() => { + if (options.initialInput) { + makeRequest(options.initialInput); + } + }, []); + + return { + data, + loading: isLoading, + streaming: isStreaming, + id: id.current, + send, + stop, + }; +}; + +export default useStream; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2970880..5443974 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1 +1,2 @@ export { useEventStream } from "./hooks/use-event-stream"; +export { useStream } from "./hooks/use-stream"; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 923db7f..c225161 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,4 +1,4 @@ -export type Options = { +export type EventStreamOptions = { eventName?: string | string[]; endSignal?: string; glue?: string; @@ -8,9 +8,30 @@ export type Options = { onError?: (error: Event) => void; }; -export type StreamResult = { +export type EventStreamResult = { message: string; messageParts: string[]; close: (resetMessage?: boolean) => void; clearMessage: () => void; }; + +export type StreamOptions = { + id?: string; + initialInput?: Record; + headers?: Record; + csrfToken?: string; + onResponse?: (response: Response) => void; + onData?: (data: string) => void; + onCancel?: () => void; + onFinish?: () => void; + onError?: (error: Error) => void; +}; + +export type StreamMeta = { + controller: AbortController; + data: string; + isFetching: boolean; + isStreaming: boolean; +}; + +export type StreamListenerCallback = (stream: StreamMeta) => void; diff --git a/packages/vue/src/composables/useStream.ts b/packages/vue/src/composables/useStream.ts new file mode 100644 index 0000000..f60df2d --- /dev/null +++ b/packages/vue/src/composables/useStream.ts @@ -0,0 +1,134 @@ +import { onMounted, onUnmounted, readonly, ref, watch } from "vue"; + +interface StreamOptions { + method?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: HeadersInit; + body?: BodyInit; + onMessage?: (data: any) => void; + onComplete?: () => void; + onError?: (error: any) => void; +} + +interface StreamResult { + data: Readonly; + close: () => void; + clearData: () => void; +} + +/** + * Composable for handling fetch-based streams + * + * @param url - The URL to fetch from + * @param options - Options for the stream including fetch options and callbacks + * + * @returns StreamResult object containing the stream data and control functions + */ +export const useStream = ( + url: string, + { + method = "GET", + headers = {}, + body, + onMessage = () => null, + onComplete = () => null, + onError = () => null, + }: StreamOptions = {}, +): StreamResult => { + const data = ref(null); + let controller: AbortController | null = null; + let reader: ReadableStreamDefaultReader | null = null; + + const resetData = () => { + data.value = null; + }; + + const closeConnection = (reset: boolean = false) => { + reader?.cancel(); + controller?.abort(); + reader = null; + controller = null; + + if (reset) { + resetData(); + } + }; + + const setupConnection = async () => { + resetData(); + controller = new AbortController(); + + console.log("Setting up connection to:", url); + + try { + const response = await fetch(url, { + method, + headers, + body, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + if (!response.body) { + throw new Error("ReadableStream not supported"); + } + + reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + onComplete(); + break; + } + + const chunk = decoder.decode(value, { stream: true }); + try { + const parsedData = JSON.parse(chunk); + data.value = parsedData; + onMessage(parsedData); + } catch { + data.value = chunk; + onMessage(chunk); + } + } + } catch (error: unknown) { + if (error instanceof Error && error.name === "AbortError") { + return; + } + onError(error); + } finally { + closeConnection(); + } + }; + + onMounted(() => { + void setupConnection(); + }); + + onUnmounted(() => { + closeConnection(); + }); + + watch( + () => url, + (newUrl: string, oldUrl: string) => { + if (newUrl !== oldUrl) { + closeConnection(); + void setupConnection(); + } + }, + ); + + return { + data: readonly(data), + close: closeConnection, + clearData: resetData, + }; +}; + +export default useStream; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d373e77..574ffcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + nanoid: + specifier: ^5.1.5 + version: 5.1.5 devDependencies: prettier: specifier: ^3.5.3 @@ -1325,6 +1329,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.5: + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3114,6 +3123,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.5: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} From 44e977e4091e9823e4ae5e04b9838e33c956413d Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 16 May 2025 13:32:22 -0400 Subject: [PATCH 2/9] wip --- packages/react/src/hooks/use-event-stream.ts | 4 ++-- packages/react/src/hooks/use-stream.ts | 24 ++++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/react/src/hooks/use-event-stream.ts b/packages/react/src/hooks/use-event-stream.ts index ae25ac4..e62c3e2 100644 --- a/packages/react/src/hooks/use-event-stream.ts +++ b/packages/react/src/hooks/use-event-stream.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { EventStreamOptions, EventStreamResponse } from "../types"; +import { EventStreamOptions, EventStreamResult } from "../types"; const dataPrefix = "data: "; @@ -24,7 +24,7 @@ export const useEventStream = ( onComplete = () => null, onError = () => null, }: EventStreamOptions = {}, -): EventStreamResponse => { +): EventStreamResult => { const sourceRef = useRef(null); const messagePartsRef = useRef([]); const eventNames = useMemo( diff --git a/packages/react/src/hooks/use-stream.ts b/packages/react/src/hooks/use-stream.ts index 06706e4..4cae8ce 100644 --- a/packages/react/src/hooks/use-stream.ts +++ b/packages/react/src/hooks/use-stream.ts @@ -90,14 +90,16 @@ export const useStream = (url: string, options: StreamOptions = {}) => { const makeRequest = useCallback( (body: Record = {}) => { + const controller = new AbortController(); + updateStream({ isFetching: true, - controller: new AbortController(), + controller, }); fetch(url, { method: "POST", - signal: stream.current.controller.signal, + signal: controller.signal, headers: { ...headers.current, ...(options.headers ?? {}), @@ -182,11 +184,15 @@ export const useStream = (url: string, options: StreamOptions = {}) => { ); useEffect(() => { - const stopListening = addListener(id.current, (stream: StreamMeta) => { - setIsLoading(stream.isFetching); - setIsStreaming(stream.isStreaming); - setData(stream.data); - }); + const stopListening = addListener( + id.current, + (streamUpdate: StreamMeta) => { + stream.current = resolveStream(id.current); + setIsLoading(streamUpdate.isFetching); + setIsStreaming(streamUpdate.isStreaming); + setData(streamUpdate.data); + }, + ); return () => { stopListening(); @@ -205,8 +211,8 @@ export const useStream = (url: string, options: StreamOptions = {}) => { return { data, - loading: isLoading, - streaming: isStreaming, + isLoading, + isStreaming, id: id.current, send, stop, From 5324d5100dd78c4c94bb2c8be617087f680135a7 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 16 May 2025 14:20:34 -0400 Subject: [PATCH 3/9] Update use-stream.ts --- packages/react/src/hooks/use-stream.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react/src/hooks/use-stream.ts b/packages/react/src/hooks/use-stream.ts index 4cae8ce..8e2fd4f 100644 --- a/packages/react/src/hooks/use-stream.ts +++ b/packages/react/src/hooks/use-stream.ts @@ -5,7 +5,7 @@ import { StreamListenerCallback, StreamMeta, StreamOptions } from "../types"; const streams = new Map(); const listeners = new Map(); -const resolveStream = (id: string) => { +const resolveStream = (id: string): StreamMeta => { const stream = streams.get(id); if (stream) { @@ -74,9 +74,11 @@ export const useStream = (url: string, options: StreamOptions = {}) => { ...params, }); + const updatedStream = resolveStream(id.current); + listeners .get(id.current) - ?.forEach((listener) => listener(streams.get(id.current)!)); + ?.forEach((listener) => listener(updatedStream)); }, []); const stop = useCallback(() => { @@ -126,13 +128,13 @@ export const useStream = (url: string, options: StreamOptions = {}) => { return read(response.body.getReader()); }) - .catch((error) => { + .catch((error: Error) => { updateStream({ isFetching: false, isStreaming: false, }); - if (error?.name === "AbortError") { + if (error.name === "AbortError") { options.onCancel?.(); } else { options.onError?.(error); From 8636d7058793cd4df3148fbcd39f510f4cbb66d1 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 16 May 2025 16:33:33 -0400 Subject: [PATCH 4/9] we've got tests --- packages/react/package.json | 3 +- packages/react/src/hooks/use-stream.ts | 11 +- packages/react/tests/use-stream.test.ts | 258 ++++++++++++++++ packages/react/tsconfig.json | 2 +- pnpm-lock.yaml | 394 +++++++++++++++++++++++- 5 files changed, 656 insertions(+), 12 deletions(-) create mode 100644 packages/react/tests/use-stream.test.ts diff --git a/packages/react/package.json b/packages/react/package.json index 1b35456..2d3ae37 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -55,10 +55,11 @@ "@vitejs/plugin-vue": "^5.0.0", "eslint": "^9.0.0", "jsdom": "^26.0.0", + "msw": "^2.8.2", "prettier": "^3.5.3", "typescript": "^5.3.0", - "vite-plugin-dts": "^4.5.3", "vite": "^5.1.0", + "vite-plugin-dts": "^4.5.3", "vitest": "^3.1.1" }, "peerDependencies": { diff --git a/packages/react/src/hooks/use-stream.ts b/packages/react/src/hooks/use-stream.ts index 8e2fd4f..958ee6f 100644 --- a/packages/react/src/hooks/use-stream.ts +++ b/packages/react/src/hooks/use-stream.ts @@ -65,7 +65,7 @@ export const useStream = (url: string, options: StreamOptions = {}) => { ); const [data, setData] = useState(stream.current.data); - const [isLoading, setIsLoading] = useState(stream.current.isFetching); + const [isFetching, setIsFetching] = useState(stream.current.isFetching); const [isStreaming, setIsStreaming] = useState(stream.current.isStreaming); const updateStream = useCallback((params: Partial) => { @@ -108,9 +108,10 @@ export const useStream = (url: string, options: StreamOptions = {}) => { }, body: JSON.stringify(body), }) - .then((response) => { + .then(async (response) => { if (!response.ok) { - throw new Error("Network response was not ok"); + const error = await response.text(); + throw new Error(error); } if (!response.body) { @@ -190,7 +191,7 @@ export const useStream = (url: string, options: StreamOptions = {}) => { id.current, (streamUpdate: StreamMeta) => { stream.current = resolveStream(id.current); - setIsLoading(streamUpdate.isFetching); + setIsFetching(streamUpdate.isFetching); setIsStreaming(streamUpdate.isStreaming); setData(streamUpdate.data); }, @@ -213,7 +214,7 @@ export const useStream = (url: string, options: StreamOptions = {}) => { return { data, - isLoading, + isFetching, isStreaming, id: id.current, send, diff --git a/packages/react/tests/use-stream.test.ts b/packages/react/tests/use-stream.test.ts new file mode 100644 index 0000000..3dfbd31 --- /dev/null +++ b/packages/react/tests/use-stream.test.ts @@ -0,0 +1,258 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { delay, http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { useStream } from "../src/hooks/use-stream"; + +describe("useStream", () => { + const url = "/chat"; + const response = async (duration = 20) => { + await delay(duration); + + return new HttpResponse( + new ReadableStream({ + async start(controller) { + await delay(duration); + controller.enqueue(new TextEncoder().encode("chunk1")); + + await delay(duration); + controller.enqueue(new TextEncoder().encode("chunk2")); + controller.close(); + }, + }), + { + status: 200, + headers: { + "Content-Type": "text/event-stream", + }, + }, + ); + }; + + const server = setupServer( + http.post(url, async () => { + return await response(); + }), + ); + + beforeAll(() => server.listen()); + afterEach(() => { + vi.clearAllMocks(); + server.resetHandlers(); + }); + afterAll(() => server.close()); + + it("should initialize with default values", () => { + const { result } = renderHook(() => useStream(url)); + + expect(result.current.data).toBe(""); + expect(result.current.isFetching).toBe(false); + expect(result.current.isStreaming).toBe(false); + expect(result.current.id).toBeDefined(); + expect(result.current.id).toBeTypeOf("string"); + }); + + it("should make a request with initial input", async () => { + const initialInput = { test: "data" }; + + const { result } = await act(async () => { + return renderHook(() => useStream(url, { initialInput })); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + await waitFor(() => expect(result.current.data).toBe("chunk1")); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(result.current.isStreaming).toBe(false); + expect(result.current.data).toBe("chunk1chunk2"); + }); + + it("can send data back to the endpoint", async () => { + const payload = { test: "data" }; + let capturedBody: any; + + server.use( + http.post(url, async ({ request }) => { + capturedBody = await request.json(); + return response(); + }), + ); + + const { result } = renderHook(() => useStream(url)); + + act(() => { + result.current.send(payload); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(capturedBody).toEqual(payload); + expect(result.current.data).toBe("chunk1chunk2"); + expect(result.current.isStreaming).toBe(false); + }); + + it("should handle errors correctly", async () => { + const errorMessage = "Serve error"; + server.use( + http.post(url, async () => { + return new HttpResponse(errorMessage, { + status: 500, + headers: { + "Content-Type": "application/json", + }, + }); + }), + ); + + const onError = vi.fn(); + const onFinish = vi.fn(); + const { result } = renderHook(() => + useStream(url, { onError, onFinish }), + ); + + act(() => { + result.current.send({ test: "data" }); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(onError).toHaveBeenCalledWith(new Error(errorMessage)); + expect(onFinish).toHaveBeenCalled(); + expect(result.current.isFetching).toBe(false); + expect(result.current.isStreaming).toBe(false); + }); + + it("should handle network errors correctly", async () => { + server.use( + http.post(url, async () => { + return HttpResponse.error(); + }), + ); + + const onError = vi.fn(); + const onFinish = vi.fn(); + const { result } = renderHook(() => + useStream(url, { onError, onFinish }), + ); + + await act(() => { + result.current.send({ test: "data" }); + }); + + expect(onError).toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalled(); + expect(result.current.isFetching).toBe(false); + expect(result.current.isStreaming).toBe(false); + }); + + it("should stop streaming when stop is called", async () => { + const { result } = renderHook(() => useStream(url)); + + act(() => { + result.current.send({ test: "data" }); + }); + + await waitFor(() => expect(result.current.data).toBe("chunk1")); + act(() => { + result.current.stop(); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(result.current.isStreaming).toBe(false); + expect(result.current.data).toBe("chunk1"); + }); + + it("should handle custom headers", async () => { + const customHeaders = { "X-Custom-Header": "test" }; + let capturedHeaders: any; + + server.use( + http.post(url, async ({ request }) => { + capturedHeaders = request.headers; + return response(); + }), + ); + + const { result } = renderHook(() => + useStream(url, { headers: customHeaders }), + ); + + await act(() => { + result.current.send({ test: "data" }); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + expect(capturedHeaders.get("X-Custom-Header")).toBe( + customHeaders["X-Custom-Header"], + ); + expect(capturedHeaders.get("Content-Type")).toBe("application/json"); + }); + + it("should handle CSRF token from meta tag", async () => { + const csrfToken = "test-csrf-token"; + const metaTag = document.createElement("meta"); + metaTag.setAttribute("name", "csrf-token"); + metaTag.setAttribute("content", csrfToken); + document.head.appendChild(metaTag); + + let capturedHeaders: any; + + server.use( + http.post(url, async ({ request }) => { + capturedHeaders = request.headers; + return response(); + }), + ); + + const { result } = renderHook(() => useStream(url)); + + await act(() => { + result.current.send({ test: "data" }); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + document.head.removeChild(metaTag); + expect(capturedHeaders.get("X-CSRF-TOKEN")).toBe(csrfToken); + expect(capturedHeaders.get("Content-Type")).toBe("application/json"); + }); + + it("should handle CSRF token from passed option", async () => { + const csrfToken = "test-csrf-token"; + + let capturedHeaders: any; + + server.use( + http.post(url, async ({ request }) => { + capturedHeaders = request.headers; + return response(); + }), + ); + + const { result } = renderHook(() => useStream(url, { csrfToken })); + + await act(() => { + result.current.send({ test: "data" }); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(capturedHeaders.get("X-CSRF-TOKEN")).toBe(csrfToken); + expect(capturedHeaders.get("Content-Type")).toBe("application/json"); + }); +}); diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 01f2bd3..1461592 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -8,6 +8,6 @@ "outDir": "./dist", "strict": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "tests/use-stream.test.ts"], "exclude": ["node_modules", "dist"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 574ffcb..6bbe72b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: jsdom: specifier: ^26.0.0 version: 26.1.0 + msw: + specifier: ^2.8.2 + version: 2.8.2(@types/node@22.15.3)(typescript@5.8.3) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -63,7 +66,7 @@ importers: version: 4.5.3(@types/node@22.15.3)(rollup@4.40.1)(typescript@5.8.3)(vite@5.4.19(@types/node@22.15.3)) vitest: specifier: ^3.1.1 - version: 3.1.2(@types/node@22.15.3)(jsdom@26.1.0) + version: 3.1.2(@types/node@22.15.3)(jsdom@26.1.0)(msw@2.8.2(@types/node@22.15.3)(typescript@5.8.3)) packages/vue: dependencies: @@ -103,7 +106,7 @@ importers: version: 4.5.3(@types/node@22.15.3)(rollup@4.40.1)(typescript@5.8.3)(vite@5.4.19(@types/node@22.15.3)) vitest: specifier: ^3.1.1 - version: 3.1.2(@types/node@22.15.3)(jsdom@26.1.0) + version: 3.1.2(@types/node@22.15.3)(jsdom@26.1.0)(msw@2.8.2(@types/node@22.15.3)(typescript@5.8.3)) packages: @@ -135,6 +138,15 @@ packages: resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} + '@bundled-es-modules/cookie@2.0.1': + resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} + + '@bundled-es-modules/statuses@1.0.1': + resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + + '@bundled-es-modules/tough-cookie@0.1.6': + resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} + '@csstools/color-helpers@5.0.2': resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} engines: {node: '>=18'} @@ -359,6 +371,37 @@ packages: resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} engines: {node: '>=18.18'} + '@inquirer/confirm@5.1.10': + resolution: {integrity: sha512-FxbQ9giWxUWKUk2O5XZ6PduVnH2CZ/fmMKMBkH71MHJvWr7WL5AHKevhzF1L5uYWB2P548o1RzVxrNd3dpmk6g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.1.11': + resolution: {integrity: sha512-BXwI/MCqdtAhzNQlBEFE7CEflhPkl/BqvAuV/aK6lW3DClIfYVDWPP/kXuXHtBWC7/EEbNqd/1BGq2BGBBnuxw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.11': + resolution: {integrity: sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.6': + resolution: {integrity: sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} @@ -379,6 +422,10 @@ packages: resolution: {integrity: sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==} engines: {node: '>=18'} + '@mswjs/interceptors@0.37.6': + resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==} + engines: {node: '>=18'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -391,6 +438,15 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@rollup/pluginutils@5.1.4': resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==} engines: {node: '>=14.0.0'} @@ -547,6 +603,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -559,6 +618,12 @@ packages: '@types/react@19.1.2': resolution: {integrity: sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==} + '@types/statuses@2.0.5': + resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@typescript-eslint/eslint-plugin@8.32.0': resolution: {integrity: sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -737,6 +802,10 @@ packages: alien-signals@0.4.14: resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -811,6 +880,14 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -905,6 +982,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -937,6 +1017,10 @@ packages: engines: {node: '>=12'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -1088,6 +1172,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1118,6 +1206,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql@16.11.0: + resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1134,6 +1226,9 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -1185,10 +1280,17 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -1321,9 +1423,23 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.8.2: + resolution: {integrity: sha512-ugu8RBgUj6//RD0utqDDPdS+QIs36BKYkDAM6u59hcMVtFM4PM0vW4l3G1R+1uCWP2EWFUG8reT/gPXVEtx7/w==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1363,6 +1479,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -1396,6 +1515,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.2.0: resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} engines: {node: '>=16'} @@ -1449,6 +1571,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1460,6 +1585,9 @@ packages: quansync@0.2.10: resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -1483,10 +1611,17 @@ packages: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1576,6 +1711,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1597,10 +1736,21 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1657,6 +1807,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -1675,6 +1829,14 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -1695,6 +1857,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -1706,6 +1872,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -1828,6 +1997,14 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -1850,13 +2027,29 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors-cjs@2.1.2: + resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} + engines: {node: '>=18'} + zod-to-json-schema@3.24.5: resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} peerDependencies: @@ -1896,6 +2089,19 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@bundled-es-modules/cookie@2.0.1': + dependencies: + cookie: 0.7.2 + + '@bundled-es-modules/statuses@1.0.1': + dependencies: + statuses: 2.0.1 + + '@bundled-es-modules/tough-cookie@0.1.6': + dependencies: + '@types/tough-cookie': 4.0.5 + tough-cookie: 4.1.4 + '@csstools/color-helpers@5.0.2': {} '@csstools/css-calc@2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': @@ -2042,6 +2248,32 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} + '@inquirer/confirm@5.1.10(@types/node@22.15.3)': + dependencies: + '@inquirer/core': 10.1.11(@types/node@22.15.3) + '@inquirer/type': 3.0.6(@types/node@22.15.3) + optionalDependencies: + '@types/node': 22.15.3 + + '@inquirer/core@10.1.11(@types/node@22.15.3)': + dependencies: + '@inquirer/figures': 1.0.11 + '@inquirer/type': 3.0.6(@types/node@22.15.3) + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 22.15.3 + + '@inquirer/figures@1.0.11': {} + + '@inquirer/type@3.0.6(@types/node@22.15.3)': + optionalDependencies: + '@types/node': 22.15.3 + '@jridgewell/sourcemap-codec@1.5.0': {} '@microsoft/api-extractor-model@7.30.6(@types/node@22.15.3)': @@ -2094,6 +2326,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@mswjs/interceptors@0.37.6': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2106,6 +2347,15 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@rollup/pluginutils@5.1.4(rollup@4.40.1)': dependencies: '@types/estree': 1.0.7 @@ -2232,6 +2482,8 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/cookie@0.6.0': {} + '@types/estree@1.0.7': {} '@types/json-schema@7.0.15': {} @@ -2244,6 +2496,10 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/statuses@2.0.5': {} + + '@types/tough-cookie@4.0.5': {} + '@typescript-eslint/eslint-plugin@8.32.0(@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.8.3))(eslint@9.26.0)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -2333,12 +2589,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.2(vite@5.4.19(@types/node@22.15.3))': + '@vitest/mocker@3.1.2(msw@2.8.2(@types/node@22.15.3)(typescript@5.8.3))(vite@5.4.19(@types/node@22.15.3))': dependencies: '@vitest/spy': 3.1.2 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: + msw: 2.8.2(@types/node@22.15.3)(typescript@5.8.3) vite: 5.4.19(@types/node@22.15.3) '@vitest/pretty-format@3.1.2': @@ -2494,6 +2751,10 @@ snapshots: alien-signals@0.4.14: {} + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -2574,6 +2835,14 @@ snapshots: check-error@2.1.1: {} + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2647,6 +2916,8 @@ snapshots: ee-first@1.1.1: {} + emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} entities@4.5.0: {} @@ -2689,6 +2960,8 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + escalade@3.2.0: {} + escape-html@1.0.3: {} escape-string-regexp@4.0.0: {} @@ -2884,6 +3157,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2918,6 +3193,8 @@ snapshots: graphemer@1.4.0: {} + graphql@16.11.0: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -2928,6 +3205,8 @@ snapshots: he@1.2.0: {} + headers-polyfill@4.0.3: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -2979,10 +3258,14 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-node-process@1.2.0: {} + is-number@7.0.0: {} is-potential-custom-element-name@1.0.1: {} @@ -3119,8 +3402,35 @@ snapshots: ms@2.1.3: {} + msw@2.8.2(@types/node@22.15.3)(typescript@5.8.3): + dependencies: + '@bundled-es-modules/cookie': 2.0.1 + '@bundled-es-modules/statuses': 1.0.1 + '@bundled-es-modules/tough-cookie': 0.1.6 + '@inquirer/confirm': 5.1.10(@types/node@22.15.3) + '@mswjs/interceptors': 0.37.6 + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.6.0 + '@types/statuses': 2.0.5 + graphql: 16.11.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + strict-event-emitter: 0.5.1 + type-fest: 4.41.0 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@types/node' + muggle-string@0.4.1: {} + mute-stream@2.0.0: {} + nanoid@3.3.11: {} nanoid@5.1.5: {} @@ -3152,6 +3462,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + outvariant@1.4.3: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -3178,6 +3490,8 @@ snapshots: path-parse@1.0.7: {} + path-to-regexp@6.3.0: {} + path-to-regexp@8.2.0: {} pathe@2.0.3: {} @@ -3225,6 +3539,10 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + punycode@2.3.1: {} qs@6.14.0: @@ -3233,6 +3551,8 @@ snapshots: quansync@0.2.10: {} + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} range-parser@1.2.1: {} @@ -3253,8 +3573,12 @@ snapshots: react@19.1.0: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + requires-port@1.0.0: {} + resolve-from@4.0.0: {} resolve@1.22.10: @@ -3386,6 +3710,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} source-map@0.6.1: {} @@ -3398,8 +3724,20 @@ snapshots: std-env@3.9.0: {} + strict-event-emitter@0.5.1: {} + string-argv@0.3.2: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -3441,6 +3779,13 @@ snapshots: toidentifier@1.0.1: {} + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -3457,6 +3802,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -3471,6 +3820,8 @@ snapshots: undici-types@6.21.0: {} + universalify@0.2.0: {} + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -3479,6 +3830,11 @@ snapshots: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + vary@1.1.2: {} vite-node@3.1.2(@types/node@22.15.3): @@ -3527,10 +3883,10 @@ snapshots: '@types/node': 22.15.3 fsevents: 2.3.3 - vitest@3.1.2(@types/node@22.15.3)(jsdom@26.1.0): + vitest@3.1.2(@types/node@22.15.3)(jsdom@26.1.0)(msw@2.8.2(@types/node@22.15.3)(typescript@5.8.3)): dependencies: '@vitest/expect': 3.1.2 - '@vitest/mocker': 3.1.2(vite@5.4.19(@types/node@22.15.3)) + '@vitest/mocker': 3.1.2(msw@2.8.2(@types/node@22.15.3)(typescript@5.8.3))(vite@5.4.19(@types/node@22.15.3)) '@vitest/pretty-format': 3.1.2 '@vitest/runner': 3.1.2 '@vitest/snapshot': 3.1.2 @@ -3604,6 +3960,18 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} ws@8.18.1: {} @@ -3612,10 +3980,26 @@ snapshots: xmlchars@2.2.0: {} + y18n@5.0.8: {} + yallist@4.0.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.2: {} + zod-to-json-schema@3.24.5(zod@3.24.4): dependencies: zod: 3.24.4 From 69fef4a73c1328ad90b0a90027b1a7cf45734916 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 16 May 2025 16:55:39 -0400 Subject: [PATCH 5/9] more tests --- packages/react/src/hooks/use-stream.ts | 18 ++-- packages/react/tests/use-stream.test.ts | 107 +++++++++++++++++++++++- 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/packages/react/src/hooks/use-stream.ts b/packages/react/src/hooks/use-stream.ts index 958ee6f..6886e89 100644 --- a/packages/react/src/hooks/use-stream.ts +++ b/packages/react/src/hooks/use-stream.ts @@ -84,11 +84,15 @@ export const useStream = (url: string, options: StreamOptions = {}) => { const stop = useCallback(() => { stream.current.controller.abort(); + if (isFetching || isStreaming) { + options.onCancel?.(); + } + updateStream({ isFetching: false, isStreaming: false, }); - }, []); + }, [isFetching, isStreaming]); const makeRequest = useCallback( (body: Record = {}) => { @@ -135,12 +139,7 @@ export const useStream = (url: string, options: StreamOptions = {}) => { isStreaming: false, }); - if (error.name === "AbortError") { - options.onCancel?.(); - } else { - options.onError?.(error); - } - + options.onError?.(error); options.onFinish?.(); }); }, @@ -161,9 +160,10 @@ export const useStream = (url: string, options: StreamOptions = {}) => { str = "", ): Promise => { return reader.read().then(({ done, value }) => { - const newData = str + new TextDecoder().decode(value); + const incomingStr = new TextDecoder("utf-8").decode(value); + const newData = str + incomingStr; - options.onData?.(str); + options.onData?.(incomingStr); if (done) { updateStream({ diff --git a/packages/react/tests/use-stream.test.ts b/packages/react/tests/use-stream.test.ts index 3dfbd31..ea69efd 100644 --- a/packages/react/tests/use-stream.test.ts +++ b/packages/react/tests/use-stream.test.ts @@ -80,6 +80,7 @@ describe("useStream", () => { it("can send data back to the endpoint", async () => { const payload = { test: "data" }; let capturedBody: any; + const onCancel = vi.fn(); server.use( http.post(url, async ({ request }) => { @@ -88,7 +89,7 @@ describe("useStream", () => { }), ); - const { result } = renderHook(() => useStream(url)); + const { result } = renderHook(() => useStream(url, { onCancel })); act(() => { result.current.send(payload); @@ -100,6 +101,68 @@ describe("useStream", () => { expect(capturedBody).toEqual(payload); expect(result.current.data).toBe("chunk1chunk2"); expect(result.current.isStreaming).toBe(false); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("will trigger the onResponse callback", async () => { + const payload = { test: "data" }; + const onResponse = vi.fn(); + + const { result } = renderHook(() => + useStream(url, { + onResponse, + }), + ); + + act(() => { + result.current.send(payload); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(onResponse).toHaveBeenCalled(); + }); + + it("will trigger the onFinish callback", async () => { + const payload = { test: "data" }; + const onFinish = vi.fn(); + + const { result } = renderHook(() => + useStream(url, { + onFinish, + }), + ); + + act(() => { + result.current.send(payload); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(onFinish).toHaveBeenCalled(); + }); + + it("will trigger the onData callback", async () => { + const payload = { test: "data" }; + const onData = vi.fn(); + + const { result } = renderHook(() => + useStream(url, { + onData, + }), + ); + + act(() => { + result.current.send(payload); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(onData).toHaveBeenCalledWith("chunk1"); + expect(onData).toHaveBeenCalledWith("chunk2"); }); it("should handle errors correctly", async () => { @@ -158,7 +221,8 @@ describe("useStream", () => { }); it("should stop streaming when stop is called", async () => { - const { result } = renderHook(() => useStream(url)); + const onCancel = vi.fn(); + const { result } = renderHook(() => useStream(url, { onCancel })); act(() => { result.current.send({ test: "data" }); @@ -172,6 +236,7 @@ describe("useStream", () => { expect(result.current.isStreaming).toBe(false); expect(result.current.data).toBe("chunk1"); + expect(onCancel).toHaveBeenCalled(); }); it("should handle custom headers", async () => { @@ -255,4 +320,42 @@ describe("useStream", () => { expect(capturedHeaders.get("X-CSRF-TOKEN")).toBe(csrfToken); expect(capturedHeaders.get("Content-Type")).toBe("application/json"); }); + + it("will generate unique ids for streams", async () => { + const { result } = renderHook(() => useStream(url)); + + const { result: result2 } = renderHook(() => useStream(url)); + + expect(result.current.id).toBeTypeOf("string"); + expect(result2.current.id).toBeTypeOf("string"); + expect(result.current.id).not.toBe(result2.current.id); + }); + + it("will sync streams with the same id", async () => { + const payload = { test: "data" }; + const id = "test-stream-id"; + + const { result } = renderHook(() => useStream(url, { id })); + + const { result: result2 } = renderHook(() => useStream(url, { id })); + + await act(() => { + result.current.send(payload); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + await waitFor(() => expect(result2.current.isStreaming).toBe(true)); + await waitFor(() => expect(result.current.data).toBe("chunk1")); + await waitFor(() => expect(result2.current.data).toBe("chunk1")); + await waitFor(() => expect(result.current.data).toBe("chunk1chunk2")); + await waitFor(() => expect(result2.current.data).toBe("chunk1chunk2")); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + await waitFor(() => expect(result2.current.isStreaming).toBe(false)); + + expect(result.current.isStreaming).toBe(false); + expect(result2.current.isStreaming).toBe(false); + + expect(result.current.data).toBe("chunk1chunk2"); + expect(result2.current.data).toBe("chunk1chunk2"); + }); }); From 62cf533aec41f30ab5f22a8c1d157e0dd9a0a819 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Mon, 19 May 2025 10:17:41 -0400 Subject: [PATCH 6/9] vue + tests --- packages/react/src/hooks/use-stream.ts | 17 +- packages/react/tests/use-stream.test.ts | 16 +- packages/vue/package.json | 4 + .../vue/src/composables/useEventStream.ts | 6 +- packages/vue/src/composables/useStream.ts | 288 +++++++++++------- packages/vue/src/types.ts | 25 +- packages/vue/tests/useStream.test.ts | 284 +++++++++++++++++ pnpm-lock.yaml | 6 + 8 files changed, 523 insertions(+), 123 deletions(-) create mode 100644 packages/vue/tests/useStream.test.ts diff --git a/packages/react/src/hooks/use-stream.ts b/packages/react/src/hooks/use-stream.ts index 6886e89..de848b6 100644 --- a/packages/react/src/hooks/use-stream.ts +++ b/packages/react/src/hooks/use-stream.ts @@ -48,6 +48,7 @@ export const useStream = (url: string, options: StreamOptions = {}) => { (() => { const headers: HeadersInit = { "Content-Type": "application/json", + "X-STREAM-ID": id.current, }; const csrfToken = @@ -81,7 +82,7 @@ export const useStream = (url: string, options: StreamOptions = {}) => { ?.forEach((listener) => listener(updatedStream)); }, []); - const stop = useCallback(() => { + const cancel = useCallback(() => { stream.current.controller.abort(); if (isFetching || isStreaming) { @@ -147,7 +148,7 @@ export const useStream = (url: string, options: StreamOptions = {}) => { ); const send = useCallback((body: Record) => { - stop(); + cancel(); makeRequest(body); updateStream({ data: "", @@ -203,8 +204,12 @@ export const useStream = (url: string, options: StreamOptions = {}) => { }, []); useEffect(() => { - window.addEventListener("beforeunload", stop); - }, [stop]); + window.addEventListener("beforeunload", cancel); + + return () => { + window.removeEventListener("beforeunload", cancel); + }; + }, [cancel]); useEffect(() => { if (options.initialInput) { @@ -218,8 +223,6 @@ export const useStream = (url: string, options: StreamOptions = {}) => { isStreaming, id: id.current, send, - stop, + cancel, }; }; - -export default useStream; diff --git a/packages/react/tests/use-stream.test.ts b/packages/react/tests/use-stream.test.ts index ea69efd..77402d4 100644 --- a/packages/react/tests/use-stream.test.ts +++ b/packages/react/tests/use-stream.test.ts @@ -230,7 +230,7 @@ describe("useStream", () => { await waitFor(() => expect(result.current.data).toBe("chunk1")); act(() => { - result.current.stop(); + result.current.cancel(); }); await waitFor(() => expect(result.current.isStreaming).toBe(false)); @@ -272,7 +272,6 @@ describe("useStream", () => { metaTag.setAttribute("name", "csrf-token"); metaTag.setAttribute("content", csrfToken); document.head.appendChild(metaTag); - let capturedHeaders: any; server.use( @@ -298,7 +297,6 @@ describe("useStream", () => { it("should handle CSRF token from passed option", async () => { const csrfToken = "test-csrf-token"; - let capturedHeaders: any; server.use( @@ -323,7 +321,6 @@ describe("useStream", () => { it("will generate unique ids for streams", async () => { const { result } = renderHook(() => useStream(url)); - const { result: result2 } = renderHook(() => useStream(url)); expect(result.current.id).toBeTypeOf("string"); @@ -334,9 +331,16 @@ describe("useStream", () => { it("will sync streams with the same id", async () => { const payload = { test: "data" }; const id = "test-stream-id"; + let capturedHeaders: any; - const { result } = renderHook(() => useStream(url, { id })); + server.use( + http.post(url, async ({ request }) => { + capturedHeaders = request.headers; + return response(); + }), + ); + const { result } = renderHook(() => useStream(url, { id })); const { result: result2 } = renderHook(() => useStream(url, { id })); await act(() => { @@ -357,5 +361,7 @@ describe("useStream", () => { expect(result.current.data).toBe("chunk1chunk2"); expect(result2.current.data).toBe("chunk1chunk2"); + + expect(capturedHeaders.get("X-STREAM-ID")).toBe(id); }); }); diff --git a/packages/vue/package.json b/packages/vue/package.json index a82f243..d3ded26 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -52,6 +52,7 @@ "@vitejs/plugin-vue": "^5.0.0", "eslint": "^9.0.0", "jsdom": "^26.0.0", + "msw": "^2.8.2", "prettier": "^3.5.3", "typescript": "^5.3.0", "vite": "^5.4.19", @@ -60,5 +61,8 @@ }, "peerDependencies": { "vue": "^3.0.0" + }, + "dependencies": { + "nanoid": "^5.1.5" } } diff --git a/packages/vue/src/composables/useEventStream.ts b/packages/vue/src/composables/useEventStream.ts index 24d7124..bed0b24 100644 --- a/packages/vue/src/composables/useEventStream.ts +++ b/packages/vue/src/composables/useEventStream.ts @@ -1,5 +1,5 @@ import { onMounted, onUnmounted, readonly, ref, watch } from "vue"; -import { Options, StreamResult } from "../types"; +import { EventStreamOptions, EventStreamResult } from "../types"; const dataPrefix = "data: "; @@ -23,8 +23,8 @@ export const useEventStream = ( onMessage = () => null, onComplete = () => null, onError = () => null, - }: Options = {}, -): StreamResult => { + }: EventStreamOptions = {}, +): EventStreamResult => { const message = ref(""); const messageParts = ref([]); const eventNames = Array.isArray(eventName) ? eventName : [eventName]; diff --git a/packages/vue/src/composables/useStream.ts b/packages/vue/src/composables/useStream.ts index f60df2d..9ab2d9b 100644 --- a/packages/vue/src/composables/useStream.ts +++ b/packages/vue/src/composables/useStream.ts @@ -1,134 +1,210 @@ -import { onMounted, onUnmounted, readonly, ref, watch } from "vue"; - -interface StreamOptions { - method?: "GET" | "POST" | "PUT" | "DELETE"; - headers?: HeadersInit; - body?: BodyInit; - onMessage?: (data: any) => void; - onComplete?: () => void; - onError?: (error: any) => void; -} - -interface StreamResult { - data: Readonly; - close: () => void; - clearData: () => void; -} - -/** - * Composable for handling fetch-based streams - * - * @param url - The URL to fetch from - * @param options - Options for the stream including fetch options and callbacks - * - * @returns StreamResult object containing the stream data and control functions - */ -export const useStream = ( - url: string, - { - method = "GET", - headers = {}, - body, - onMessage = () => null, - onComplete = () => null, - onError = () => null, - }: StreamOptions = {}, -): StreamResult => { - const data = ref(null); - let controller: AbortController | null = null; - let reader: ReadableStreamDefaultReader | null = null; - - const resetData = () => { - data.value = null; +import { nanoid } from "nanoid"; +import { onMounted, onUnmounted, readonly, ref } from "vue"; +import { StreamListenerCallback, StreamMeta, StreamOptions } from "../types"; + +const streams = new Map(); +const listeners = new Map(); + +const resolveStream = (id: string): StreamMeta => { + const stream = streams.get(id); + + if (stream) { + return stream; + } + + streams.set(id, { + controller: new AbortController(), + data: "", + isFetching: false, + isStreaming: false, + }); + + return streams.get(id)!; +}; + +const resolveListener = (id: string) => { + if (!listeners.has(id)) { + listeners.set(id, []); + } + + return listeners.get(id)!; +}; + +const addListener = (id: string, listener: StreamListenerCallback) => { + resolveListener(id).push(listener); + + return () => { + listeners.set( + id, + resolveListener(id).filter((l) => l !== listener), + ); }; +}; + +export const useStream = (url: string, options: StreamOptions = {}) => { + const id = options.id ?? nanoid(); + const stream = ref(resolveStream(id)); + const headers = (() => { + const headers: HeadersInit = { + "Content-Type": "application/json", + "X-STREAM-ID": id, + }; + + const csrfToken = + options.csrfToken ?? + document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute("content"); + + if (csrfToken) { + headers["X-CSRF-TOKEN"] = csrfToken; + } + + return headers; + })(); + + const data = ref(stream.value.data); + const isFetching = ref(stream.value.isFetching); + const isStreaming = ref(stream.value.isStreaming); + + let stopListening: () => void; - const closeConnection = (reset: boolean = false) => { - reader?.cancel(); - controller?.abort(); - reader = null; - controller = null; + const updateStream = (params: Partial) => { + streams.set(id, { + ...resolveStream(id), + ...params, + }); - if (reset) { - resetData(); + const updatedStream = resolveStream(id); + + listeners.get(id)?.forEach((listener) => listener(updatedStream)); + }; + + const cancel = () => { + stream.value.controller.abort(); + + if (isFetching || isStreaming) { + options.onCancel?.(); } + + updateStream({ + isFetching: false, + isStreaming: false, + }); }; - const setupConnection = async () => { - resetData(); - controller = new AbortController(); + const makeRequest = (body: Record = {}) => { + const controller = new AbortController(); + + updateStream({ + isFetching: true, + controller, + }); + + fetch(url, { + method: "POST", + signal: controller.signal, + headers: { + ...headers, + ...(options.headers ?? {}), + }, + body: JSON.stringify(body), + }) + .then(async (response) => { + if (!response.ok) { + const error = await response.text(); + throw new Error(error); + } + + if (!response.body) { + throw new Error( + "ReadableStream not yet supported in this browser.", + ); + } - console.log("Setting up connection to:", url); + options.onResponse?.(response); - try { - const response = await fetch(url, { - method, - headers, - body, - signal: controller.signal, + updateStream({ + isFetching: false, + isStreaming: true, + }); + + return read(response.body.getReader()); + }) + .catch((error: Error) => { + updateStream({ + isFetching: false, + isStreaming: false, + }); + + options.onError?.(error); + options.onFinish?.(); }); + }; - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + const send = (body: Record) => { + cancel(); + makeRequest(body); + updateStream({ + data: "", + }); + }; - if (!response.body) { - throw new Error("ReadableStream not supported"); - } + const read = ( + reader: ReadableStreamDefaultReader, + str = "", + ): Promise => { + return reader.read().then(({ done, value }) => { + const incomingStr = new TextDecoder("utf-8").decode(value); + const newData = str + incomingStr; - reader = response.body.getReader(); - const decoder = new TextDecoder(); + options.onData?.(incomingStr); - while (true) { - const { done, value } = await reader.read(); + if (done) { + updateStream({ + data: newData, + isStreaming: false, + }); - if (done) { - onComplete(); - break; - } + options.onFinish?.(); - const chunk = decoder.decode(value, { stream: true }); - try { - const parsedData = JSON.parse(chunk); - data.value = parsedData; - onMessage(parsedData); - } catch { - data.value = chunk; - onMessage(chunk); - } + return ""; } - } catch (error: unknown) { - if (error instanceof Error && error.name === "AbortError") { - return; - } - onError(error); - } finally { - closeConnection(); - } + + updateStream({ + data: newData, + }); + + return read(reader, newData); + }); }; onMounted(() => { - void setupConnection(); + stopListening = addListener(id, (streamUpdate: StreamMeta) => { + stream.value = resolveStream(id); + isFetching.value = streamUpdate.isFetching; + isStreaming.value = streamUpdate.isStreaming; + data.value = streamUpdate.data; + }); + + window.addEventListener("beforeunload", cancel); + + if (options.initialInput) { + makeRequest(options.initialInput); + } }); onUnmounted(() => { - closeConnection(); + stopListening(); + window.removeEventListener("beforeunload", cancel); }); - watch( - () => url, - (newUrl: string, oldUrl: string) => { - if (newUrl !== oldUrl) { - closeConnection(); - void setupConnection(); - } - }, - ); - return { data: readonly(data), - close: closeConnection, - clearData: resetData, + isFetching: readonly(isFetching), + isStreaming: readonly(isStreaming), + id, + send, + cancel, }; }; - -export default useStream; diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 7c1762b..bd0ed5c 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -1,6 +1,6 @@ import { type Ref } from "vue"; -export type Options = { +export type EventStreamOptions = { eventName?: string | string[]; endSignal?: string; glue?: string; @@ -10,9 +10,30 @@ export type Options = { onError?: (error: Event) => void; }; -export type StreamResult = { +export type EventStreamResult = { message: Readonly>; messageParts: Readonly>; close: (resetMessage?: boolean) => void; clearMessage: () => void; }; + +export type StreamOptions = { + id?: string; + initialInput?: Record; + headers?: Record; + csrfToken?: string; + onResponse?: (response: Response) => void; + onData?: (data: string) => void; + onCancel?: () => void; + onFinish?: () => void; + onError?: (error: Error) => void; +}; + +export type StreamMeta = { + controller: AbortController; + data: string; + isFetching: boolean; + isStreaming: boolean; +}; + +export type StreamListenerCallback = (stream: StreamMeta) => void; diff --git a/packages/vue/tests/useStream.test.ts b/packages/vue/tests/useStream.test.ts new file mode 100644 index 0000000..9f6f8fb --- /dev/null +++ b/packages/vue/tests/useStream.test.ts @@ -0,0 +1,284 @@ +import { delay, http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { createApp } from "vue"; +import { useStream } from "../src/composables/useStream"; + +function withSetup(composable) { + let result; + + const app = createApp({ + setup() { + result = composable(); + return () => {}; + }, + }); + + app.mount(document.createElement("div")); + + return [result, app]; +} + +describe("useStream", () => { + const url = "/stream"; + const response = async (duration = 20) => { + await delay(duration); + + return new HttpResponse( + new ReadableStream({ + async start(controller) { + await delay(duration); + controller.enqueue(new TextEncoder().encode("chunk1")); + + await delay(duration); + controller.enqueue(new TextEncoder().encode("chunk2")); + controller.close(); + }, + }), + { + status: 200, + headers: { + "Content-Type": "text/event-stream", + }, + }, + ); + }; + + const server = setupServer( + http.post(url, async () => { + return await response(); + }), + ); + + beforeAll(() => server.listen()); + afterEach(() => { + vi.clearAllMocks(); + server.resetHandlers(); + }); + afterAll(() => server.close()); + + it("initializes with default values", () => { + const [result] = withSetup(() => useStream(url)); + + expect(result.data.value).toBe(""); + expect(result.isFetching.value).toBe(false); + expect(result.isStreaming.value).toBe(false); + expect(result.id).toBeDefined(); + expect(result.id).toBeTypeOf("string"); + }); + + it("makes a request with initial input", async () => { + const initialInput = { test: "data" }; + + const [result] = withSetup(() => useStream(url, { initialInput })); + + await vi.waitFor(() => expect(result.isFetching.value).toBe(true)); + await vi.waitFor(() => expect(result.isFetching.value).toBe(false)); + await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); + await vi.waitFor(() => expect(result.data.value).toBe("chunk1")); + await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); + + expect(result.data.value).toBe("chunk1chunk2"); + }); + + it("can send data to the endpoint", async () => { + const payload = { test: "data" }; + let capturedBody: any; + + server.use( + http.post(url, async ({ request }) => { + capturedBody = await request.json(); + return response(); + }), + ); + + const [result] = withSetup(() => useStream(url)); + + result.send(payload); + + await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); + await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); + + expect(capturedBody).toEqual(payload); + expect(result.data.value).toBe("chunk1chunk2"); + }); + + it("triggers onResponse callback", async () => { + const onResponse = vi.fn(); + + const [result] = withSetup(() => useStream(url, { onResponse })); + + result.send({ test: "data" }); + + await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); + await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); + + expect(onResponse).toHaveBeenCalled(); + }); + + it("triggers onFinish callback", async () => { + const onFinish = vi.fn(); + + const [result] = withSetup(() => useStream(url, { onFinish })); + + result.send({ test: "data" }); + + await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); + await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); + + expect(onFinish).toHaveBeenCalled(); + }); + + it("triggers onData callback with chunks", async () => { + const onData = vi.fn(); + + const [result] = withSetup(() => useStream(url, { onData })); + + result.send({ test: "data" }); + + await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); + await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); + + expect(onData).toHaveBeenCalledWith("chunk1"); + expect(onData).toHaveBeenCalledWith("chunk2"); + }); + + it("handles errors correctly", async () => { + const errorMessage = "Server error"; + server.use( + http.post(url, async () => { + return new HttpResponse(errorMessage, { + status: 500, + headers: { + "Content-Type": "application/json", + }, + }); + }), + ); + + const onError = vi.fn(); + const onFinish = vi.fn(); + const [result] = withSetup(() => useStream(url, { onError, onFinish })); + + result.send({ test: "data" }); + + await vi.waitFor(() => expect(result.isFetching.value).toBe(true)); + await vi.waitFor(() => expect(result.isFetching.value).toBe(false)); + + expect(onError).toHaveBeenCalledWith(new Error(errorMessage)); + expect(onFinish).toHaveBeenCalled(); + expect(result.isFetching.value).toBe(false); + expect(result.isStreaming.value).toBe(false); + }); + + it("can cancel the stream", async () => { + const onCancel = vi.fn(); + const [result] = withSetup(() => useStream(url, { onCancel })); + + result.send({ test: "data" }); + await vi.waitFor(() => expect(result.data.value).toBe("chunk1")); + + result.cancel(); + + expect(result.isStreaming.value).toBe(false); + expect(onCancel).toHaveBeenCalled(); + }); + + it("handles CSRF token from meta tag", async () => { + const csrfToken = "test-csrf-token"; + const metaTag = document.createElement("meta"); + metaTag.setAttribute("name", "csrf-token"); + metaTag.setAttribute("content", csrfToken); + document.head.appendChild(metaTag); + + let capturedHeaders: any; + + server.use( + http.post(url, async ({ request }) => { + capturedHeaders = request.headers; + return response(); + }), + ); + + const [result] = withSetup(() => useStream(url)); + + result.send({ test: "data" }); + + await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); + await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); + + document.head.removeChild(metaTag); + expect(capturedHeaders.get("X-CSRF-TOKEN")).toBe(csrfToken); + expect(capturedHeaders.get("Content-Type")).toBe("application/json"); + }); + + it("handles CSRF token from options", async () => { + const csrfToken = "test-csrf-token"; + let capturedHeaders: any; + + server.use( + http.post(url, async ({ request }) => { + capturedHeaders = request.headers; + return response(); + }), + ); + + const [result] = withSetup(() => useStream(url, { csrfToken })); + + result.send({ test: "data" }); + + await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); + await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); + + expect(capturedHeaders.get("X-CSRF-TOKEN")).toBe(csrfToken); + expect(capturedHeaders.get("Content-Type")).toBe("application/json"); + }); + + it("generates unique ids for streams", () => { + const [result1] = withSetup(() => useStream(url)); + const [result2] = withSetup(() => useStream(url)); + + expect(result1.id).toBeTypeOf("string"); + expect(result2.id).toBeTypeOf("string"); + expect(result1.id).not.toBe(result2.id); + }); + + it("syncs streams with the same id", async () => { + const id = "test-stream-id"; + let capturedHeaders: any; + + server.use( + http.post(url, async ({ request }) => { + capturedHeaders = request.headers; + return response(); + }), + ); + + const [result1] = withSetup(() => useStream(url, { id })); + const [result2] = withSetup(() => useStream(url, { id })); + + result1.send({ test: "data" }); + + await vi.waitFor(() => expect(result1.isStreaming.value).toBe(true)); + await vi.waitFor(() => expect(result2.isStreaming.value).toBe(true)); + await vi.waitFor(() => expect(result1.data.value).toBe("chunk1")); + await vi.waitFor(() => expect(result2.data.value).toBe("chunk1")); + await vi.waitFor(() => expect(result1.data.value).toBe("chunk1chunk2")); + await vi.waitFor(() => expect(result2.data.value).toBe("chunk1chunk2")); + await vi.waitFor(() => expect(result1.isStreaming.value).toBe(false)); + await vi.waitFor(() => expect(result2.isStreaming.value).toBe(false)); + + expect(result1.data.value).toBe("chunk1chunk2"); + expect(result2.data.value).toBe("chunk1chunk2"); + + expect(capturedHeaders.get("X-STREAM-ID")).toBe(id); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bbe72b..3abc853 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: packages/vue: dependencies: + nanoid: + specifier: ^5.1.5 + version: 5.1.5 vue: specifier: ^3.0.0 version: 3.5.13(typescript@5.8.3) @@ -92,6 +95,9 @@ importers: jsdom: specifier: ^26.0.0 version: 26.1.0 + msw: + specifier: ^2.8.2 + version: 2.8.2(@types/node@22.15.3)(typescript@5.8.3) prettier: specifier: ^3.5.3 version: 3.5.3 From e3be7b79575811126b2a2f7e1095a6cc71fb979b Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Mon, 19 May 2025 13:13:12 -0400 Subject: [PATCH 7/9] docs --- packages/react/README.md | 134 +++++++++++++++++++++++++++++++++++- packages/vue/README.md | 143 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 272 insertions(+), 5 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 6478767..06c7755 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -7,7 +7,7 @@ License

-Easily consume [Server-Sent Events (SSE)](https://laravel.com/docs/responses#event-streams) in your React application. +Easily consume streams in your React application. ## Installation @@ -15,9 +15,137 @@ Easily consume [Server-Sent Events (SSE)](https://laravel.com/docs/responses#eve npm install @laravel/stream-react ``` -## Usage +## Streaming Responses -Provide your stream URL and the hook will automatically update the `message` with the concatenated response as messages are returned from your server: +The `useStream` hook allows you to seamlessly consume [streamed responses](https://laravel.com/docs/responses#streamed-responses) in your React application. + +Provide your stream URL and the hook will automatically update `data` with the concatenated response as data is returned from your server: + +```tsx +import { useStream } from "@laravel/stream-react"; + +function App() { + const { data, isFetching, isStreaming, send } = useStream("chat"); + + const sendMessage = () => { + send({ + message: `Current timestamp: ${Date.now()}`, + }); + }; + + return ( +
+
{data}
+ {isFetching &&
Connecting...
} + {isStreaming &&
Generating...
} + +
+ ); +} +``` + +When sending data back to the stream, the active connection to the stream is canceled before sending the new data. All requests are sent as JSON `POST` requests. + +The second argument given to `useStream` is an options object that you may use to customize the stream consumption behavior. The default values for this object are shown below: + +```tsx +import { useStream } from "@laravel/stream-react"; + +function App() { + const { data } = useStream("chat", { + id: undefined, + initialInput: undefined, + headers: undefined, + csrfToken: undefined, + onResponse: (response: Response) => void, + onData: (data: string) => void, + onCancel: () => void, + onFinish: () => void, + onError: (error: Error) => void, + }); + + return
{data}
; +} +``` + +`onResponse` is triggered after a successful initial response from the stream and the raw [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) is passed to the callback. + +`onData` is called as each chunk is received, the current chunk is passed to the callback. + +`onFinish` is called when a stream has finished and when an error is thrown during the fetch/read cycle. + +By default, a request is not made the to stream on initialization. You may pass an initial payload to the stream by using the `initialInput` option: + +```tsx +import { useStream } from "@laravel/stream-react"; + +function App() { + const { data } = useStream("chat", { + initialInput: { + message: "Introduce yourself.", + }, + }); + + return
{data}
; +} +``` + +To cancel a stream manually, you may use the `cancel` method returned from the hook: + +```tsx +import { useStream } from "@laravel/stream-react"; + +function App() { + const { data, cancel } = useStream("chat"); + + return ( +
+
{data}
+ +
+ ); +} +``` + +Each time the `useStream` hook is used, a random `id` is generated to identify the stream. This is sent back to the server with each request in the `X-STREAM-ID` header. + +When consuming the same stream from multiple components, you can read and write to the stream by providing your own `id`: + +```tsx +// App.tsx +import { useStream } from "@laravel/stream-react"; + +function App() { + const { data, id } = useStream("chat"); + + return ( +
+
{data}
+ +
+ ); +} + +// StreamStatus.tsx +import { useStream } from "@laravel/stream-react"; + +function StreamStatus({ id }) { + const { isFetching, isStreaming } = useStream("chat", { id }); + + return ( +
+ {isFetching &&
Connecting...
} + {isStreaming &&
Generating...
} +
+ ); +} +``` + +## Event Streams (SSE) + +The `useEventStream` hook allows you to seamlessly consume [Server-Sent Events (SSE)](https://laravel.com/docs/responses#event-streams) in your React application. + +Provide your stream URL and the hook will automatically update `message` with the concatenated response as messages are returned from your server: ```tsx import { useEventStream } from "@laravel/stream-react"; diff --git a/packages/vue/README.md b/packages/vue/README.md index da8484c..f245a4f 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -7,7 +7,7 @@ License

-Easily consume [Server-Sent Events (SSE)](https://laravel.com/docs/responses#event-streams) in your Vue application. +Easily consume streams in your Vue application. ## Installation @@ -15,7 +15,146 @@ Easily consume [Server-Sent Events (SSE)](https://laravel.com/docs/responses#eve npm install @laravel/stream-vue ``` -## Usage +## Streaming Responses + +The `useStream` hook allows you to seamlessly consume [streamed responses](https://laravel.com/docs/responses#streamed-responses) in your Vue application. + +Provide your stream URL and the hook will automatically update `data` with the concatenated response as data is returned from your server: + +```vue + + + +``` + +When sending data back to the stream, the active connection to the stream is canceled before sending the new data. All requests are sent as JSON `POST` requests. + +The second argument given to `useStream` is an options object that you may use to customize the stream consumption behavior. The default values for this object are shown below: + +```vue + + + +``` + +`onResponse` is triggered after a successful initial response from the stream and the raw [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) is passed to the callback. + +`onData` is called as each chunk is received, the current chunk is passed to the callback. + +`onFinish` is called when a stream has finished and when an error is thrown during the fetch/read cycle. + +By default, a request is not made the to stream on initialization. You may pass an initial payload to the stream by using the `initialInput` option: + +```vue + + + +``` + +To cancel a stream manually, you may use the `cancel` method returned from the hook: + +```vue + + + +``` + +Each time the `useStream` hook is used, a random `id` is generated to identify the stream. This is sent back to the server with each request in the `X-STREAM-ID` header. + +When consuming the same stream from multiple components, you can read and write to the stream by providing your own `id`: + +```vue + + + + +``` + +```vue + + + + +``` + +## Event Streams (SSE) + +The `useEventStream` hook allows you to seamlessly consume [Server-Sent Events (SSE)](https://laravel.com/docs/responses#event-streams) in your Vue application. Provide your stream URL and the hook will automatically update the `message` with the concatenated response as messages are returned from your server: From 536f34835190275b415fbd2c1f9aee86ef5bfaea Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Mon, 19 May 2025 13:21:38 -0400 Subject: [PATCH 8/9] Update index.ts --- packages/vue/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 34c773b..c738d59 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1 +1,2 @@ export { useEventStream } from "./composables/useEventStream"; +export { useStream } from "./composables/useStream"; From 5398f99094f08e28323950528b1c8fa4073c1611 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Mon, 19 May 2025 13:50:59 -0400 Subject: [PATCH 9/9] beta message --- packages/react/README.md | 3 +++ packages/vue/README.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/react/README.md b/packages/react/README.md index 06c7755..103f04a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -17,6 +17,9 @@ npm install @laravel/stream-react ## Streaming Responses +> [!IMPORTANT] +> The `useStream` hook is currently in Beta, the API is subject to change prior to the v1.0.0 release. All notable changes will be documented in the [changelog](./CHANGELOG.md). + The `useStream` hook allows you to seamlessly consume [streamed responses](https://laravel.com/docs/responses#streamed-responses) in your React application. Provide your stream URL and the hook will automatically update `data` with the concatenated response as data is returned from your server: diff --git a/packages/vue/README.md b/packages/vue/README.md index f245a4f..e471336 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -17,6 +17,9 @@ npm install @laravel/stream-vue ## Streaming Responses +> [!IMPORTANT] +> The `useStream` hook is currently in Beta, the API is subject to change prior to the v1.0.0 release. All notable changes will be documented in the [changelog](./CHANGELOG.md). + The `useStream` hook allows you to seamlessly consume [streamed responses](https://laravel.com/docs/responses#streamed-responses) in your Vue application. Provide your stream URL and the hook will automatically update `data` with the concatenated response as data is returned from your server: