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/README.md b/packages/react/README.md index 6478767..103f04a 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,140 @@ 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: +> [!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: + +```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/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-event-stream.ts b/packages/react/src/hooks/use-event-stream.ts index 7ac5f66..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 { 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 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..de848b6 --- /dev/null +++ b/packages/react/src/hooks/use-stream.ts @@ -0,0 +1,228 @@ +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): 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 = useRef(options.id ?? nanoid()); + const stream = useRef(resolveStream(id.current)); + const headers = useRef( + (() => { + const headers: HeadersInit = { + "Content-Type": "application/json", + "X-STREAM-ID": id.current, + }; + + 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 [isFetching, setIsFetching] = useState(stream.current.isFetching); + const [isStreaming, setIsStreaming] = useState(stream.current.isStreaming); + + const updateStream = useCallback((params: Partial) => { + streams.set(id.current, { + ...resolveStream(id.current), + ...params, + }); + + const updatedStream = resolveStream(id.current); + + listeners + .get(id.current) + ?.forEach((listener) => listener(updatedStream)); + }, []); + + const cancel = useCallback(() => { + stream.current.controller.abort(); + + if (isFetching || isStreaming) { + options.onCancel?.(); + } + + updateStream({ + isFetching: false, + isStreaming: false, + }); + }, [isFetching, isStreaming]); + + const makeRequest = useCallback( + (body: Record = {}) => { + const controller = new AbortController(); + + updateStream({ + isFetching: true, + controller, + }); + + fetch(url, { + method: "POST", + signal: controller.signal, + headers: { + ...headers.current, + ...(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.", + ); + } + + options.onResponse?.(response); + + updateStream({ + isFetching: false, + isStreaming: true, + }); + + return read(response.body.getReader()); + }) + .catch((error: Error) => { + updateStream({ + isFetching: false, + isStreaming: false, + }); + + options.onError?.(error); + options.onFinish?.(); + }); + }, + [url], + ); + + const send = useCallback((body: Record) => { + cancel(); + makeRequest(body); + updateStream({ + data: "", + }); + }, []); + + const read = useCallback( + ( + reader: ReadableStreamDefaultReader, + str = "", + ): Promise => { + return reader.read().then(({ done, value }) => { + const incomingStr = new TextDecoder("utf-8").decode(value); + const newData = str + incomingStr; + + options.onData?.(incomingStr); + + if (done) { + updateStream({ + data: newData, + isStreaming: false, + }); + + options.onFinish?.(); + + return ""; + } + + updateStream({ + data: newData, + }); + + return read(reader, newData); + }); + }, + [], + ); + + useEffect(() => { + const stopListening = addListener( + id.current, + (streamUpdate: StreamMeta) => { + stream.current = resolveStream(id.current); + setIsFetching(streamUpdate.isFetching); + setIsStreaming(streamUpdate.isStreaming); + setData(streamUpdate.data); + }, + ); + + return () => { + stopListening(); + }; + }, []); + + useEffect(() => { + window.addEventListener("beforeunload", cancel); + + return () => { + window.removeEventListener("beforeunload", cancel); + }; + }, [cancel]); + + useEffect(() => { + if (options.initialInput) { + makeRequest(options.initialInput); + } + }, []); + + return { + data, + isFetching, + isStreaming, + id: id.current, + send, + cancel, + }; +}; 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/react/tests/use-stream.test.ts b/packages/react/tests/use-stream.test.ts new file mode 100644 index 0000000..77402d4 --- /dev/null +++ b/packages/react/tests/use-stream.test.ts @@ -0,0 +1,367 @@ +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; + const onCancel = vi.fn(); + + server.use( + http.post(url, async ({ request }) => { + capturedBody = await request.json(); + return response(); + }), + ); + + const { result } = renderHook(() => useStream(url, { onCancel })); + + 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); + 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 () => { + 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 onCancel = vi.fn(); + const { result } = renderHook(() => useStream(url, { onCancel })); + + act(() => { + result.current.send({ test: "data" }); + }); + + await waitFor(() => expect(result.current.data).toBe("chunk1")); + act(() => { + result.current.cancel(); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(result.current.isStreaming).toBe(false); + expect(result.current.data).toBe("chunk1"); + expect(onCancel).toHaveBeenCalled(); + }); + + 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"); + }); + + 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"; + let capturedHeaders: any; + + 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(() => { + 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"); + + expect(capturedHeaders.get("X-STREAM-ID")).toBe(id); + }); +}); 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/packages/vue/README.md b/packages/vue/README.md index da8484c..e471336 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,149 @@ Easily consume [Server-Sent Events (SSE)](https://laravel.com/docs/responses#eve npm install @laravel/stream-vue ``` -## Usage +## 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: + +```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: 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 new file mode 100644 index 0000000..9ab2d9b --- /dev/null +++ b/packages/vue/src/composables/useStream.ts @@ -0,0 +1,210 @@ +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 updateStream = (params: Partial) => { + streams.set(id, { + ...resolveStream(id), + ...params, + }); + + 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 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.", + ); + } + + options.onResponse?.(response); + + updateStream({ + isFetching: false, + isStreaming: true, + }); + + return read(response.body.getReader()); + }) + .catch((error: Error) => { + updateStream({ + isFetching: false, + isStreaming: false, + }); + + options.onError?.(error); + options.onFinish?.(); + }); + }; + + const send = (body: Record) => { + cancel(); + makeRequest(body); + updateStream({ + data: "", + }); + }; + + const read = ( + reader: ReadableStreamDefaultReader, + str = "", + ): Promise => { + return reader.read().then(({ done, value }) => { + const incomingStr = new TextDecoder("utf-8").decode(value); + const newData = str + incomingStr; + + options.onData?.(incomingStr); + + if (done) { + updateStream({ + data: newData, + isStreaming: false, + }); + + options.onFinish?.(); + + return ""; + } + + updateStream({ + data: newData, + }); + + return read(reader, newData); + }); + }; + + onMounted(() => { + 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(() => { + stopListening(); + window.removeEventListener("beforeunload", cancel); + }); + + return { + data: readonly(data), + isFetching: readonly(isFetching), + isStreaming: readonly(isStreaming), + id, + send, + cancel, + }; +}; 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"; 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 d373e77..3abc853 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 @@ -45,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 @@ -59,10 +66,13 @@ 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: + nanoid: + specifier: ^5.1.5 + version: 5.1.5 vue: specifier: ^3.0.0 version: 3.5.13(typescript@5.8.3) @@ -85,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 @@ -99,7 +112,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: @@ -131,6 +144,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'} @@ -355,6 +377,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==} @@ -375,6 +428,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'} @@ -387,6 +444,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'} @@ -543,6 +609,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==} @@ -555,6 +624,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} @@ -733,6 +808,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'} @@ -807,6 +886,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'} @@ -901,6 +988,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'} @@ -933,6 +1023,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==} @@ -1084,6 +1178,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'} @@ -1114,6 +1212,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'} @@ -1130,6 +1232,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'} @@ -1181,10 +1286,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'} @@ -1317,14 +1429,33 @@ 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} 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==} @@ -1354,6 +1485,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'} @@ -1387,6 +1521,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'} @@ -1440,6 +1577,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'} @@ -1451,6 +1591,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==} @@ -1474,10 +1617,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'} @@ -1567,6 +1717,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'} @@ -1588,10 +1742,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'} @@ -1648,6 +1813,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'} @@ -1666,6 +1835,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'} @@ -1686,6 +1863,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'} @@ -1697,6 +1878,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'} @@ -1819,6 +2003,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==} @@ -1841,13 +2033,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: @@ -1887,6 +2095,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)': @@ -2033,6 +2254,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)': @@ -2085,6 +2332,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 @@ -2097,6 +2353,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 @@ -2223,6 +2488,8 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/cookie@0.6.0': {} + '@types/estree@1.0.7': {} '@types/json-schema@7.0.15': {} @@ -2235,6 +2502,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 @@ -2324,12 +2595,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': @@ -2485,6 +2757,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: @@ -2565,6 +2841,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 @@ -2638,6 +2922,8 @@ snapshots: ee-first@1.1.1: {} + emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} entities@4.5.0: {} @@ -2680,6 +2966,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: {} @@ -2875,6 +3163,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 @@ -2909,6 +3199,8 @@ snapshots: graphemer@1.4.0: {} + graphql@16.11.0: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -2919,6 +3211,8 @@ snapshots: he@1.2.0: {} + headers-polyfill@4.0.3: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -2970,10 +3264,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: {} @@ -3110,10 +3408,39 @@ 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: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -3141,6 +3468,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 @@ -3167,6 +3496,8 @@ snapshots: path-parse@1.0.7: {} + path-to-regexp@6.3.0: {} + path-to-regexp@8.2.0: {} pathe@2.0.3: {} @@ -3214,6 +3545,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: @@ -3222,6 +3557,8 @@ snapshots: quansync@0.2.10: {} + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} range-parser@1.2.1: {} @@ -3242,8 +3579,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: @@ -3375,6 +3716,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} source-map@0.6.1: {} @@ -3387,8 +3730,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: @@ -3430,6 +3785,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 @@ -3446,6 +3808,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 @@ -3460,6 +3826,8 @@ snapshots: undici-types@6.21.0: {} + universalify@0.2.0: {} + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -3468,6 +3836,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): @@ -3516,10 +3889,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 @@ -3593,6 +3966,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: {} @@ -3601,10 +3986,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