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 @@
-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...
}
+
Send Message
+
+ );
+}
+```
+
+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 (
+
+ );
+}
+```
+
+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 (
+
+ );
+}
+
+// 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 @@
-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
+
+
+
+
+
{{ data }}
+
Connecting...
+
Generating...
+
Send Message
+
+
+```
+
+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
+
+
+
+ {{ 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:
+
+```vue
+
+
+
+ {{ data }}
+
+```
+
+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
+
+
+
+
+
+
Connecting...
+
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 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: