├── .gitignore ├── pnpm-workspace.yaml ├── .prettierrc ├── packages ├── react │ ├── src │ │ ├── index.ts │ │ ├── types.ts │ │ ├── streams │ │ │ ├── store.ts │ │ │ └── dispatch.ts │ │ └── hooks │ │ │ ├── use-event-stream.ts │ │ │ └── use-stream.ts │ ├── vitest.config.js │ ├── tsconfig.json │ ├── vite.config.ts │ ├── eslint.config.mjs │ ├── package.json │ ├── tests │ │ ├── mock.ts │ │ ├── use-event-stream.test.ts │ │ └── use-stream.test.ts │ └── README.md └── vue │ ├── src │ ├── index.ts │ ├── types.ts │ ├── streams │ │ ├── store.ts │ │ └── dispatch.ts │ └── composables │ │ ├── useEventStream.ts │ │ └── useStream.ts │ ├── vitest.config.js │ ├── tsconfig.json │ ├── vite.config.ts │ ├── eslint.config.mjs │ ├── package.json │ ├── tests │ ├── mock.ts │ ├── useEventStream.test.ts │ └── useStream.test.ts │ └── README.md ├── .github ├── SUPPORT.md ├── CODE_OF_CONDUCT.md ├── workflows │ ├── issues.yml │ ├── pull-requests.yml │ ├── update-changelog.yml │ ├── tests.yml │ ├── coding-standards.yml │ └── publish.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── config.yml │ └── 1_Bug_report.yml ├── CONTRIBUTING.md └── SECURITY.md ├── .vscode └── tasks.json ├── README.md ├── package.json ├── INSTALL.md ├── release.sh └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | export { useEventStream } from "./hooks/use-event-stream"; 2 | export { useJsonStream, useStream } from "./hooks/use-stream"; 3 | -------------------------------------------------------------------------------- /packages/vue/src/index.ts: -------------------------------------------------------------------------------- 1 | export { useEventStream } from "./composables/useEventStream"; 2 | export { useJsonStream, useStream } from "./composables/useStream"; 3 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support Questions 2 | 3 | The Laravel support guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions#support-questions). 4 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | The Laravel Code of Conduct can be found in the [Laravel documentation](https://laravel.com/docs/contributions#code-of-conduct). 4 | -------------------------------------------------------------------------------- /.github/workflows/issues.yml: -------------------------------------------------------------------------------- 1 | name: issues 2 | 3 | on: 4 | issues: 5 | types: [labeled] 6 | 7 | permissions: 8 | issues: write 9 | 10 | jobs: 11 | help-wanted: 12 | uses: laravel/.github/.github/workflows/issues.yml@main 13 | -------------------------------------------------------------------------------- /packages/react/vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "jsdom", 6 | globals: true, 7 | setupFiles: ["./tests/mock.ts"], 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/vue/vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "jsdom", 6 | globals: true, 7 | setupFiles: ["./tests/mock.ts"], 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /.github/workflows/pull-requests.yml: -------------------------------------------------------------------------------- 1 | name: pull requests 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened] 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | uneditable: 12 | uses: laravel/.github/.github/workflows/pull-requests.yml@main 13 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yml: -------------------------------------------------------------------------------- 1 | name: update changelog 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | permissions: {} 8 | 9 | jobs: 10 | update: 11 | permissions: 12 | contents: write 13 | uses: laravel/.github/.github/workflows/update-changelog.yml@main 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/vue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "esModuleInterop": true, 7 | "declaration": true, 8 | "outDir": "./dist", 9 | "strict": true 10 | }, 11 | "include": ["src/**/*"], 12 | "exclude": ["node_modules", "dist"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "esModuleInterop": true, 7 | "declaration": true, 8 | "outDir": "./dist", 9 | "strict": true 10 | }, 11 | "include": ["src/**/*", "tests/use-stream.test.ts"], 12 | "exclude": ["node_modules", "dist"] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "path": ".", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | }, 12 | "problemMatcher": [], 13 | "label": "pnpm: build all packages", 14 | "detail": "vite build && FORMAT=iife vite build" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature request 4 | url: https://github.com/laravel/stream/pulls 5 | about: "For ideas or feature requests, send in a pull request" 6 | - name: Support Questions & Other 7 | url: https://laravel.com/docs/contributions#support-questions 8 | about: "This repository is only for reporting bugs. If you have a question or need help using the library, click:" 9 | - name: Documentation issue 10 | url: https://github.com/laravel/docs 11 | about: For documentation issues, open a pull request at the laravel/docs repository 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Stream 2 | 3 |

4 | Build Status 5 |

6 | 7 | Easily streams in your React and Vue applications. 8 | 9 | This monorepo contains two packages: 10 | 11 | - [@laravel/stream-react](https://www.npmjs.com/package/@laravel/stream-react) ([Documentation](./packages/react/README.md)) 12 | - [@laravel/stream-vue](https://www.npmjs.com/package/@laravel/stream-vue) ([Documentation](./packages/vue/README.md)) 13 | 14 | ## License 15 | 16 | Laravel Stream is open-sourced software licensed under the [MIT license](LICENSE.md). 17 | -------------------------------------------------------------------------------- /packages/vue/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | dts({ 8 | insertTypesEntry: true, 9 | rollupTypes: true, 10 | include: ["src/**/*.ts"], 11 | }), 12 | ], 13 | build: { 14 | lib: { 15 | entry: resolve(__dirname, "src/index.ts"), 16 | name: "LaravelStreamVue", 17 | fileName: (format) => `index.${format}.js`, 18 | }, 19 | rollupOptions: { 20 | external: ["vue"], 21 | output: { 22 | globals: { 23 | vue: "Vue", 24 | }, 25 | }, 26 | }, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | schedule: 9 | - cron: "0 0 * * *" 10 | 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v4 21 | with: 22 | version: 10 23 | 24 | - name: Install dependencies 25 | run: pnpm install 26 | 27 | - name: Build 28 | run: pnpm build 29 | 30 | - name: ESLint 31 | run: pnpm run lint 32 | 33 | - name: Execute tests 34 | run: pnpm test 35 | -------------------------------------------------------------------------------- /packages/react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | dts({ 8 | insertTypesEntry: true, 9 | rollupTypes: true, 10 | include: ["src/**/*.ts"], 11 | }), 12 | ], 13 | build: { 14 | lib: { 15 | entry: resolve(__dirname, "src/index.ts"), 16 | name: "LaravelStreamReact", 17 | fileName: (format) => `index.${format}.js`, 18 | }, 19 | rollupOptions: { 20 | external: ["react"], 21 | output: { 22 | globals: { 23 | react: "React", 24 | }, 25 | }, 26 | }, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/coding-standards.yml: -------------------------------------------------------------------------------- 1 | name: fix code styling 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | format: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@v4 22 | with: 23 | version: 10 24 | 25 | - name: Install dependencies 26 | run: pnpm install 27 | 28 | - name: Format code 29 | run: pnpm run format 30 | 31 | - name: Commit linted files 32 | uses: stefanzweifel/git-auto-commit-action@v5 33 | with: 34 | commit_message: "Fix code styling" 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Laravel streaming hooks for React and Vue", 3 | "keywords": [ 4 | "laravel", 5 | "stream", 6 | "use-stream", 7 | "server-sent-events", 8 | "sse", 9 | "react", 10 | "vue", 11 | "hooks", 12 | "composables" 13 | ], 14 | "author": "Taylor Otwell", 15 | "license": "MIT", 16 | "homepage": "https://github.com/laravel/stream#readme", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/laravel/stream.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/laravel/stream/issues" 23 | }, 24 | "scripts": { 25 | "test": "pnpm -r --if-present run test", 26 | "build": "pnpm -r --if-present run build", 27 | "lint": "pnpm -r --if-present run lint", 28 | "format": "pnpm -r --if-present run format" 29 | }, 30 | "devDependencies": { 31 | "prettier": "^3.5.3" 32 | }, 33 | "dependencies": { 34 | "nanoid": "^5.1.5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | The Laravel contributing guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 4 | 5 | ## Installation 6 | 7 | This monorepo contains two packages: 8 | 9 | - [@laravel/stream-react](https://www.npmjs.com/package/@laravel/stream-react) 10 | - [@laravel/stream-vue](https://www.npmjs.com/package/@laravel/stream-vue) 11 | 12 | [pnpm](https://pnpm.io/) is used to manage dependencies, the repo is set up as a workspace. Each package lives under `packages/*` 13 | 14 | From the root directory, install dependencies for all packages: 15 | 16 | ```bash 17 | pnpm i 18 | ``` 19 | 20 | ## Running Tests 21 | 22 | Tests are written with [Vitest](https://vitest.dev/). 23 | 24 | To run all tests, from the root directory: 25 | 26 | ```bash 27 | pnpm run test 28 | ``` 29 | 30 | To run tests for an individual package: 31 | 32 | ```bash 33 | cd packages/react 34 | pnpm run test 35 | ``` 36 | 37 | ## Publishing 38 | 39 | This section is really for the benefit of the core maintainers. From the root directory: 40 | 41 | ```bash 42 | ./release 43 | ``` 44 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Packages 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | permissions: 8 | id-token: write # Required for OIDC 9 | contents: read 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | adapter: ["react", "vue"] 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Install pnpm 22 | uses: pnpm/action-setup@v3 23 | with: 24 | version: 10 25 | 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: "20" 29 | registry-url: "https://registry.npmjs.org" 30 | cache: pnpm 31 | 32 | # Ensure npm 11.5.1 or later is installed 33 | - name: Update npm 34 | run: npm install -g npm@latest 35 | 36 | - name: Install dependencies 37 | run: pnpm install 38 | 39 | - name: "Publish ${{ matrix.adapter }} to npm" 40 | run: cd ./packages/${{ matrix.adapter }} && pnpm run build && pnpm run release 41 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Local Development and Testing 2 | 3 | To test this package locally in another project, follow these steps: 4 | 5 | ## Option 1: Using npm link 6 | 7 | 1. In this package directory, run: 8 | 9 | ```bash 10 | # Build the package first 11 | npm run build 12 | 13 | # Create a global link 14 | npm link 15 | ``` 16 | 17 | 2. In your project that wants to use this package, run: 18 | 19 | ```bash 20 | # Link to the global package 21 | npm link laravel-use-stream 22 | ``` 23 | 24 | 3. Now you can import it in your project: 25 | 26 | ```js 27 | import { useEventStream } from "laravel-use-stream/react"; 28 | // or 29 | import { useEventStream } from "laravel-use-stream/vue"; 30 | ``` 31 | 32 | ## Option 2: Install from local directory 33 | 34 | 1. In this package directory, run: 35 | 36 | ```bash 37 | # Build the package first 38 | npm run build 39 | ``` 40 | 41 | 2. In your project, install the package directly from the local directory: 42 | 43 | ```bash 44 | npm install /path/to/laravel-use-stream 45 | ``` 46 | 47 | ## Troubleshooting 48 | 49 | If you're still having issues: 50 | 51 | 1. Make sure TypeScript can find the types by adding to your project's tsconfig.json: 52 | 53 | ```json 54 | { 55 | "compilerOptions": { 56 | "paths": { 57 | "laravel-use-stream/*": ["./node_modules/laravel-use-stream/dist/*"] 58 | } 59 | } 60 | } 61 | ``` 62 | 63 | 2. If using a bundler like webpack or vite, you might need to configure it to resolve the package correctly. 64 | 65 | 3. Check that the package.json in this package has the correct "exports" configuration. 66 | -------------------------------------------------------------------------------- /packages/react/src/types.ts: -------------------------------------------------------------------------------- 1 | export type EventStreamOptions = { 2 | eventName?: string | string[]; 3 | endSignal?: string; 4 | glue?: string; 5 | replace?: boolean; 6 | onMessage?: (event: MessageEvent) => void; 7 | onComplete?: () => void; 8 | onError?: (error: Event) => void; 9 | }; 10 | 11 | export type EventStreamResult = { 12 | message: string; 13 | messageParts: string[]; 14 | close: (resetMessage?: boolean) => void; 15 | clearMessage: () => void; 16 | }; 17 | 18 | export type StreamOptions = {}> = { 19 | id?: string; 20 | initialInput?: TSendBody; 21 | headers?: Record; 22 | csrfToken?: string; 23 | json?: boolean; 24 | credentials?: RequestCredentials; 25 | onResponse?: (response: Response) => void; 26 | onData?: (data: string) => void; 27 | onCancel?: () => void; 28 | onFinish?: () => void; 29 | onError?: (error: Error) => void; 30 | onBeforeSend?: (request: RequestInit) => RequestInit | boolean | void; 31 | }; 32 | 33 | export type Callback = 34 | | "onData" 35 | | "onError" 36 | | "onFinish" 37 | | "onResponse" 38 | | "onCancel" 39 | | "onBeforeSend"; 40 | 41 | export type RequiredCallbacks = Required>; 42 | 43 | export type StreamMeta = { 44 | controller: AbortController; 45 | data: string; 46 | isFetching: boolean; 47 | isStreaming: boolean; 48 | jsonData: TJsonData | null; 49 | }; 50 | 51 | export type StreamListenerCallback = (stream: StreamMeta) => void; 52 | -------------------------------------------------------------------------------- /packages/vue/src/types.ts: -------------------------------------------------------------------------------- 1 | import { type Ref } from "vue"; 2 | 3 | export type EventStreamOptions = { 4 | eventName?: string | string[]; 5 | endSignal?: string; 6 | glue?: string; 7 | replace?: boolean; 8 | onMessage?: (event: MessageEvent) => void; 9 | onComplete?: () => void; 10 | onError?: (error: Event) => void; 11 | }; 12 | 13 | export type EventStreamResult = { 14 | message: Readonly>; 15 | messageParts: Readonly>; 16 | close: (resetMessage?: boolean) => void; 17 | clearMessage: () => void; 18 | }; 19 | 20 | export type StreamOptions = {}> = { 21 | id?: string; 22 | initialInput?: TSendBody; 23 | headers?: Record; 24 | csrfToken?: string; 25 | json?: boolean; 26 | credentials?: RequestCredentials; 27 | onResponse?: (response: Response) => void; 28 | onData?: (data: string) => void; 29 | onCancel?: () => void; 30 | onFinish?: () => void; 31 | onError?: (error: Error) => void; 32 | onBeforeSend?: (request: RequestInit) => boolean | RequestInit | void; 33 | }; 34 | 35 | export type Callback = 36 | | "onData" 37 | | "onError" 38 | | "onFinish" 39 | | "onResponse" 40 | | "onCancel" 41 | | "onBeforeSend"; 42 | 43 | export type RequiredCallbacks = Required>; 44 | 45 | export type StreamMeta = { 46 | controller: AbortController; 47 | data: string; 48 | isFetching: boolean; 49 | isStreaming: boolean; 50 | jsonData: TJsonData | null; 51 | }; 52 | 53 | export type StreamListenerCallback = ( 54 | stream: StreamMeta, 55 | ) => void; 56 | -------------------------------------------------------------------------------- /packages/vue/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tsPlugin from "@typescript-eslint/eslint-plugin"; 2 | import tsParser from "@typescript-eslint/parser"; 3 | 4 | const config = [ 5 | { 6 | ignores: ["dist/**/*"], 7 | files: ["src/**/*.ts"], 8 | languageOptions: { 9 | parser: tsParser, // Use the imported parser object 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | project: "./tsconfig.json", // Path to your TypeScript configuration file 14 | }, 15 | }, 16 | plugins: { 17 | "@typescript-eslint": tsPlugin, 18 | }, 19 | rules: { 20 | ...tsPlugin.configs.recommended.rules, 21 | ...tsPlugin.configs["recommended-requiring-type-checking"].rules, 22 | "@typescript-eslint/ban-types": "off", 23 | "@typescript-eslint/no-empty-object-type": "off", 24 | "@typescript-eslint/no-explicit-any": "off", 25 | "@typescript-eslint/no-floating-promises": "error", 26 | "@typescript-eslint/no-unsafe-argument": "warn", 27 | "@typescript-eslint/no-unsafe-assignment": "warn", 28 | "@typescript-eslint/no-unsafe-call": "warn", 29 | "@typescript-eslint/no-unsafe-function-type": "off", 30 | "@typescript-eslint/no-unsafe-member-access": "warn", 31 | "@typescript-eslint/no-unsafe-return": "warn", 32 | "@typescript-eslint/no-unused-vars": [ 33 | "warn", 34 | { argsIgnorePattern: "^_" }, 35 | ], 36 | "no-console": "warn", 37 | "prefer-const": "off", 38 | }, 39 | }, 40 | ]; 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /packages/react/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tsPlugin from "@typescript-eslint/eslint-plugin"; 2 | import tsParser from "@typescript-eslint/parser"; 3 | 4 | const config = [ 5 | { 6 | ignores: ["dist/**/*"], 7 | files: ["src/**/*.ts"], 8 | languageOptions: { 9 | parser: tsParser, // Use the imported parser object 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | project: "./tsconfig.json", // Path to your TypeScript configuration file 14 | }, 15 | }, 16 | plugins: { 17 | "@typescript-eslint": tsPlugin, 18 | }, 19 | rules: { 20 | ...tsPlugin.configs.recommended.rules, 21 | ...tsPlugin.configs["recommended-requiring-type-checking"].rules, 22 | "@typescript-eslint/ban-types": "off", 23 | "@typescript-eslint/no-empty-object-type": "off", 24 | "@typescript-eslint/no-explicit-any": "off", 25 | "@typescript-eslint/no-floating-promises": "error", 26 | "@typescript-eslint/no-unsafe-argument": "warn", 27 | "@typescript-eslint/no-unsafe-assignment": "warn", 28 | "@typescript-eslint/no-unsafe-call": "warn", 29 | "@typescript-eslint/no-unsafe-function-type": "off", 30 | "@typescript-eslint/no-unsafe-member-access": "warn", 31 | "@typescript-eslint/no-unsafe-return": "warn", 32 | "@typescript-eslint/no-unused-vars": [ 33 | "warn", 34 | { argsIgnorePattern: "^_" }, 35 | ], 36 | "no-console": "warn", 37 | "prefer-const": "off", 38 | }, 39 | }, 40 | ]; 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /packages/react/src/streams/store.ts: -------------------------------------------------------------------------------- 1 | import { StreamListenerCallback, StreamMeta } from "../types"; 2 | 3 | const streams = new Map>(); 4 | const listeners = new Map(); 5 | 6 | export const resolveStream = ( 7 | id: string, 8 | ): StreamMeta => { 9 | const stream = streams.get(id) as StreamMeta | undefined; 10 | 11 | if (stream) { 12 | return stream; 13 | } 14 | 15 | const newStream: StreamMeta = { 16 | controller: new AbortController(), 17 | data: "", 18 | isFetching: false, 19 | isStreaming: false, 20 | jsonData: null as TJsonData, 21 | }; 22 | 23 | streams.set(id, newStream); 24 | 25 | return newStream; 26 | }; 27 | 28 | export const resolveListener = (id: string) => { 29 | if (!listeners.has(id)) { 30 | listeners.set(id, []); 31 | } 32 | 33 | return listeners.get(id)!; 34 | }; 35 | 36 | export const hasListeners = (id: string) => { 37 | return listeners.has(id) && listeners.get(id)?.length; 38 | }; 39 | 40 | export const addListener = (id: string, listener: StreamListenerCallback) => { 41 | resolveListener(id).push(listener); 42 | 43 | return () => { 44 | listeners.set( 45 | id, 46 | resolveListener(id).filter((l) => l !== listener), 47 | ); 48 | 49 | if (!hasListeners(id)) { 50 | streams.delete(id); 51 | listeners.delete(id); 52 | } 53 | }; 54 | }; 55 | 56 | export const update = ( 57 | id: string, 58 | params: Partial>, 59 | ) => { 60 | streams.set(id, { 61 | ...resolveStream(id), 62 | ...params, 63 | }); 64 | 65 | const updatedStream = resolveStream(id); 66 | 67 | listeners.get(id)?.forEach((listener) => listener(updatedStream)); 68 | }; 69 | -------------------------------------------------------------------------------- /packages/vue/src/streams/store.ts: -------------------------------------------------------------------------------- 1 | import { StreamListenerCallback, StreamMeta } from "../types"; 2 | 3 | const streams = new Map>(); 4 | const listeners = new Map(); 5 | 6 | export const resolveStream = ( 7 | id: string, 8 | ): StreamMeta => { 9 | const stream = streams.get(id) as StreamMeta | undefined; 10 | 11 | if (stream) { 12 | return stream; 13 | } 14 | 15 | const newStream: StreamMeta = { 16 | controller: new AbortController(), 17 | data: "", 18 | isFetching: false, 19 | isStreaming: false, 20 | jsonData: null as TJsonData, 21 | }; 22 | 23 | streams.set(id, newStream); 24 | 25 | return newStream; 26 | }; 27 | 28 | export const resolveListener = (id: string) => { 29 | if (!listeners.has(id)) { 30 | listeners.set(id, []); 31 | } 32 | 33 | return listeners.get(id)!; 34 | }; 35 | 36 | export const hasListeners = (id: string) => { 37 | return listeners.has(id) && listeners.get(id)?.length; 38 | }; 39 | 40 | export const addListener = (id: string, listener: StreamListenerCallback) => { 41 | resolveListener(id).push(listener); 42 | 43 | return () => { 44 | listeners.set( 45 | id, 46 | resolveListener(id).filter((l) => l !== listener), 47 | ); 48 | 49 | if (!hasListeners(id)) { 50 | streams.delete(id); 51 | listeners.delete(id); 52 | } 53 | }; 54 | }; 55 | 56 | export const update = ( 57 | id: string, 58 | params: Partial>, 59 | ) => { 60 | streams.set(id, { 61 | ...resolveStream(id), 62 | ...params, 63 | }); 64 | 65 | const updatedStream = resolveStream(id); 66 | 67 | listeners.get(id)?.forEach((listener) => listener(updatedStream)); 68 | }; 69 | -------------------------------------------------------------------------------- /packages/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@laravel/stream-vue", 3 | "version": "0.3.10", 4 | "description": "Laravel streaming hooks for Vue", 5 | "keywords": [ 6 | "laravel", 7 | "stream", 8 | "use-stream", 9 | "server-sent-events", 10 | "sse", 11 | "vue", 12 | "composables" 13 | ], 14 | "homepage": "https://github.com/laravel/stream/tree/main/packages/vue#readme", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/laravel/stream.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/laravel/stream/issues" 21 | }, 22 | "license": "MIT", 23 | "author": { 24 | "name": "Taylor Otwell" 25 | }, 26 | "type": "module", 27 | "main": "dist/index.umd.js", 28 | "module": "dist/index.es.js", 29 | "types": "dist/index.d.ts", 30 | "exports": { 31 | ".": { 32 | "types": "./dist/index.d.ts", 33 | "import": "./dist/index.es.js", 34 | "require": "./dist/index.umd.js" 35 | } 36 | }, 37 | "files": [ 38 | "dist" 39 | ], 40 | "scripts": { 41 | "build": "vite build", 42 | "lint": "eslint --config eslint.config.mjs \"src/**/*.ts\"", 43 | "prepublish": "pnpm run build", 44 | "release": "vitest --run && pnpm publish --no-git-checks", 45 | "test": "vitest", 46 | "format": "prettier --write ." 47 | }, 48 | "devDependencies": { 49 | "@types/node": "^22.14.0", 50 | "@typescript-eslint/eslint-plugin": "^8.21.0", 51 | "@typescript-eslint/parser": "^8.21.0", 52 | "@vitejs/plugin-vue": "^5.0.0", 53 | "eslint": "^9.0.0", 54 | "jsdom": "^26.0.0", 55 | "msw": "^2.8.2", 56 | "prettier": "^3.5.3", 57 | "typescript": "^5.3.0", 58 | "vite": "^5.4.19", 59 | "vite-plugin-dts": "^4.5.3", 60 | "vitest": "^3.1.1" 61 | }, 62 | "peerDependencies": { 63 | "vue": "^3.3.0" 64 | }, 65 | "dependencies": { 66 | "nanoid": "^5.1.5" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@laravel/stream-react", 3 | "version": "0.3.10", 4 | "description": "Laravel streaming hooks for React", 5 | "keywords": [ 6 | "laravel", 7 | "stream", 8 | "use-stream", 9 | "server-sent-events", 10 | "sse", 11 | "react", 12 | "hooks" 13 | ], 14 | "homepage": "https://github.com/laravel/stream/tree/main/packages/react#readme", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/laravel/stream.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/laravel/stream/issues" 21 | }, 22 | "license": "MIT", 23 | "author": { 24 | "name": "Taylor Otwell" 25 | }, 26 | "type": "module", 27 | "main": "dist/index.umd.js", 28 | "module": "dist/index.es.js", 29 | "types": "dist/index.d.ts", 30 | "exports": { 31 | ".": { 32 | "types": "./dist/index.d.ts", 33 | "import": "./dist/index.es.js", 34 | "require": "./dist/index.umd.js" 35 | } 36 | }, 37 | "files": [ 38 | "dist" 39 | ], 40 | "scripts": { 41 | "build": "vite build", 42 | "lint": "eslint --config eslint.config.mjs \"src/**/*.ts\"", 43 | "prepublish": "pnpm run build", 44 | "release": "vitest --run && pnpm publish --no-git-checks", 45 | "test": "vitest", 46 | "format": "prettier --write ." 47 | }, 48 | "devDependencies": { 49 | "@testing-library/dom": "^10.4.0", 50 | "@testing-library/react": "^16.3.0", 51 | "@types/node": "^22.14.0", 52 | "@types/react": "^19.1.0", 53 | "@typescript-eslint/eslint-plugin": "^8.21.0", 54 | "@typescript-eslint/parser": "^8.21.0", 55 | "@vitejs/plugin-vue": "^5.0.0", 56 | "eslint": "^9.0.0", 57 | "jsdom": "^26.0.0", 58 | "msw": "^2.8.2", 59 | "prettier": "^3.5.3", 60 | "typescript": "^5.3.0", 61 | "vite": "^5.1.0", 62 | "vite-plugin-dts": "^4.5.3", 63 | "vitest": "^3.1.1" 64 | }, 65 | "peerDependencies": { 66 | "react": "^18.0.0 || ^19.0.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/react/tests/mock.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | /** 4 | * Creates a unified EventSource mock for testing 5 | * Includes both basic functionality and error handling 6 | * 7 | * @returns {Object} Mock implementation with event handler and error handler access 8 | */ 9 | const createEventSourceMock = () => { 10 | const mockAddEventListener = vi.fn((eventType, handler) => { 11 | if (!eventHandlers[eventType]) { 12 | eventHandlers[eventType] = []; 13 | } 14 | 15 | eventHandlers[eventType].push(handler); 16 | }); 17 | 18 | const mockRemoveEventListener = vi.fn((eventType, handler) => { 19 | if (eventHandlers[eventType]) { 20 | eventHandlers[eventType] = eventHandlers[eventType].filter( 21 | (h) => h !== handler, 22 | ); 23 | } 24 | }); 25 | 26 | const mockClose = vi.fn(); 27 | 28 | const eventHandlers: Record void>> = {}; 29 | let onCompleteHandler: null | (() => void) = null; 30 | let onErrorHandler: null | ((error: any) => void) = null; 31 | 32 | const onComplete = vi.fn((cb) => { 33 | onCompleteHandler = cb; 34 | }); 35 | 36 | const onError = vi.fn((cb) => { 37 | onErrorHandler = cb; 38 | }); 39 | 40 | vi.stubGlobal( 41 | "EventSource", 42 | vi.fn().mockImplementation(() => ({ 43 | addEventListener: mockAddEventListener, 44 | removeEventListener: mockRemoveEventListener, 45 | close: mockClose, 46 | set onerror(handler) { 47 | // Legacy: for direct assignment, not used in new API 48 | onErrorHandler = handler; 49 | }, 50 | })), 51 | ); 52 | 53 | return { 54 | addEventListener: mockAddEventListener, 55 | removeEventListener: mockRemoveEventListener, 56 | close: mockClose, 57 | // New handlers for test access 58 | onComplete, 59 | onError, 60 | triggerComplete: () => { 61 | if (onCompleteHandler) { 62 | onCompleteHandler(); 63 | } 64 | }, 65 | triggerError: (err = new Error("EventSource connection error")) => { 66 | if (onErrorHandler) { 67 | onErrorHandler(err); 68 | } 69 | }, 70 | triggerEvent: (eventType: string, event: any) => { 71 | if (eventHandlers[eventType]) { 72 | eventHandlers[eventType].forEach((handler) => handler(event)); 73 | } 74 | }, 75 | }; 76 | }; 77 | 78 | createEventSourceMock(); 79 | 80 | global.createEventSourceMock = createEventSourceMock; 81 | -------------------------------------------------------------------------------- /packages/vue/tests/mock.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | /** 4 | * Creates a unified EventSource mock for testing 5 | * Includes both basic functionality and error handling 6 | * 7 | * @returns {Object} Mock implementation with event handler and error handler access 8 | */ 9 | const createEventSourceMock = () => { 10 | const mockAddEventListener = vi.fn((eventType, handler) => { 11 | if (!eventHandlers[eventType]) { 12 | eventHandlers[eventType] = []; 13 | } 14 | 15 | eventHandlers[eventType].push(handler); 16 | }); 17 | 18 | const mockRemoveEventListener = vi.fn((eventType, handler) => { 19 | if (eventHandlers[eventType]) { 20 | eventHandlers[eventType] = eventHandlers[eventType].filter( 21 | (h) => h !== handler, 22 | ); 23 | } 24 | }); 25 | 26 | const mockClose = vi.fn(); 27 | 28 | const eventHandlers: Record void>> = {}; 29 | let onCompleteHandler: null | (() => void) = null; 30 | let onErrorHandler: null | ((error: any) => void) = null; 31 | 32 | const onComplete = vi.fn((cb) => { 33 | onCompleteHandler = cb; 34 | }); 35 | 36 | const onError = vi.fn((cb) => { 37 | onErrorHandler = cb; 38 | }); 39 | 40 | vi.stubGlobal( 41 | "EventSource", 42 | vi.fn().mockImplementation(() => ({ 43 | addEventListener: mockAddEventListener, 44 | removeEventListener: mockRemoveEventListener, 45 | close: mockClose, 46 | set onerror(handler) { 47 | // Legacy: for direct assignment, not used in new API 48 | onErrorHandler = handler; 49 | }, 50 | })), 51 | ); 52 | 53 | return { 54 | addEventListener: mockAddEventListener, 55 | removeEventListener: mockRemoveEventListener, 56 | close: mockClose, 57 | // New handlers for test access 58 | onComplete, 59 | onError, 60 | triggerComplete: () => { 61 | if (onCompleteHandler) { 62 | onCompleteHandler(); 63 | } 64 | }, 65 | triggerError: (err = new Error("EventSource connection error")) => { 66 | if (onErrorHandler) { 67 | onErrorHandler(err); 68 | } 69 | }, 70 | triggerEvent: (eventType: string, event: any) => { 71 | if (eventHandlers[eventType]) { 72 | eventHandlers[eventType].forEach((handler) => handler(event)); 73 | } 74 | }, 75 | }; 76 | }; 77 | 78 | createEventSourceMock(); 79 | 80 | global.createEventSourceMock = createEventSourceMock; 81 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1_Bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: "Report something that's broken." 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: "Please read [our full contribution guide](https://laravel.com/docs/contributions#bug-reports) before submitting bug reports. If you notice improper DocBlock, PHPStan, or IDE warnings while using Laravel, do not create a GitHub issue. Instead, please submit a pull request to fix the problem." 7 | - type: input 8 | attributes: 9 | label: Stream Version 10 | description: Provide the Stream version that you are using. 11 | placeholder: 1.15.0 12 | validations: 13 | required: true 14 | - type: choice 15 | attributes: 16 | label: Framework 17 | description: Select the framework you are using. 18 | options: 19 | - label: React 20 | value: react 21 | - label: Vue 22 | value: vue 23 | validations: 24 | required: true 25 | - type: input 26 | attributes: 27 | label: Laravel Version 28 | description: Provide the Laravel version that you are using. [Please ensure it is still supported.](https://laravel.com/docs/releases#support-policy) 29 | placeholder: 10.4.1 30 | validations: 31 | required: true 32 | - type: input 33 | attributes: 34 | label: PHP Version 35 | description: Provide the PHP version that you are using. 36 | placeholder: 8.1.4 37 | validations: 38 | required: true 39 | - type: input 40 | attributes: 41 | label: NPM Version 42 | description: Provide the NPM version that you are using. 43 | placeholder: 7.2.3 44 | validations: 45 | required: true 46 | - type: input 47 | attributes: 48 | label: Database Driver & Version 49 | description: If applicable, provide the database driver and version you are using. 50 | placeholder: "MySQL 8.0.31 for macOS 13.0 on arm64 (Homebrew)" 51 | validations: 52 | required: false 53 | - type: textarea 54 | attributes: 55 | label: Description 56 | description: Provide a detailed description of the issue you are facing. 57 | validations: 58 | required: true 59 | - type: textarea 60 | attributes: 61 | label: Steps To Reproduce 62 | description: Provide detailed steps to reproduce your issue. If necessary, please provide a GitHub repository to demonstrate your issue using `laravel new bug-report --github="--public"`. 63 | validations: 64 | required: true 65 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | REPO="laravel/stream" 5 | BRANCH="main" 6 | 7 | # Ensure we are on correct branch and the working tree is clean 8 | CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) 9 | if [ "$CURRENT_BRANCH" != "$BRANCH" ]; then 10 | echo "Error: must be on $BRANCH branch (current: $CURRENT_BRANCH)" >&2 11 | exit 1 12 | fi 13 | 14 | if [ -n "$(git status --porcelain)" ]; then 15 | echo "Error: working tree is not clean. Commit or stash changes before releasing." >&2 16 | git status --porcelain 17 | exit 1 18 | fi 19 | 20 | get_current_version() { 21 | local package_json=$1 22 | if [ -f "$package_json" ]; then 23 | grep '"version":' "$package_json" | cut -d\" -f4 24 | else 25 | echo "Error: package.json not found at $package_json" 26 | exit 1 27 | fi 28 | } 29 | 30 | get_package_name() { 31 | local package_json=$1 32 | if [ -f "$package_json" ]; then 33 | grep '"name":' "$package_json" | cut -d\" -f4 34 | else 35 | echo "Error: package.json not found at $package_json" 36 | exit 1 37 | fi 38 | } 39 | 40 | update_version() { 41 | local package_dir=$1 42 | local version_type=$2 43 | 44 | case $version_type in 45 | "patch") 46 | pnpm version patch --no-git-tag-version 47 | ;; 48 | "minor") 49 | pnpm version minor --no-git-tag-version 50 | ;; 51 | "major") 52 | pnpm version major --no-git-tag-version 53 | ;; 54 | *) 55 | echo "Invalid version type. Please choose patch/minor/major" 56 | exit 1 57 | ;; 58 | esac 59 | } 60 | 61 | if [ -n "$(git status --porcelain)" ]; then 62 | echo "Error: There are uncommitted changes in the working directory" 63 | echo "Please commit or stash these changes before proceeding" 64 | exit 1 65 | fi 66 | 67 | git pull 68 | 69 | ROOT_PACKAGE_JSON="packages/react/package.json" 70 | CURRENT_VERSION=$(get_current_version "$ROOT_PACKAGE_JSON") 71 | echo "" 72 | echo "Current version: $CURRENT_VERSION" 73 | echo "" 74 | 75 | echo "Select version bump type:" 76 | echo "1) patch (bug fixes)" 77 | echo "2) minor (new features)" 78 | echo "3) major (breaking changes)" 79 | echo 80 | 81 | read -p "Enter your choice (1-3): " choice 82 | 83 | case $choice in 84 | 1) 85 | RELEASE_TYPE="patch" 86 | ;; 87 | 2) 88 | RELEASE_TYPE="minor" 89 | ;; 90 | 3) 91 | RELEASE_TYPE="major" 92 | ;; 93 | *) 94 | echo "❌ Invalid choice. Exiting." 95 | exit 1 96 | ;; 97 | esac 98 | 99 | for package_dir in packages/*; do 100 | if [ -d "$package_dir" ]; then 101 | echo "Updating version for $package_dir" 102 | 103 | cd $package_dir 104 | 105 | update_version "$package_dir" "$RELEASE_TYPE" 106 | 107 | cd ../.. 108 | 109 | echo "" 110 | fi 111 | done 112 | 113 | NEW_VERSION=$(get_current_version "$ROOT_PACKAGE_JSON") 114 | TAG="v$NEW_VERSION" 115 | 116 | echo "Updating lock file..." 117 | pnpm i 118 | echo "" 119 | 120 | echo "Staging package.json files..." 121 | git add "**/package.json" 122 | echo "" 123 | 124 | git commit -m "$TAG" 125 | git tag -a "$TAG" -m "$TAG" 126 | git push 127 | git push --tags 128 | 129 | gh release create "$TAG" --generate-notes 130 | 131 | echo "" 132 | echo "✅ Release $TAG completed successfully, publishing kicked off in CI." 133 | echo "🔗 https://github.com/$REPO/releases/tag/$TAG" 134 | -------------------------------------------------------------------------------- /packages/vue/src/composables/useEventStream.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MaybeRefOrGetter, 3 | onMounted, 4 | onUnmounted, 5 | readonly, 6 | ref, 7 | toRef, 8 | watch, 9 | } from "vue"; 10 | import { EventStreamOptions, EventStreamResult } from "../types"; 11 | 12 | const dataPrefix = "data: "; 13 | 14 | /** 15 | * Composable for handling server-sent events (SSE) streams 16 | * 17 | * @param url - The URL to connect to for the EventSource 18 | * @param options - Options for the stream 19 | * 20 | * @link https://laravel.com/docs/responses#event-streams 21 | * 22 | * @returns StreamResult object containing the accumulated response, close, and reset functions 23 | */ 24 | export const useEventStream = ( 25 | url: MaybeRefOrGetter, 26 | { 27 | eventName = "update", 28 | endSignal = "", 29 | glue = " ", 30 | replace = false, 31 | onMessage = () => null, 32 | onComplete = () => null, 33 | onError = () => null, 34 | }: EventStreamOptions = {}, 35 | ): EventStreamResult => { 36 | const urlRef = toRef(url); 37 | const message = ref(""); 38 | const messageParts = ref([]); 39 | const eventNames = Array.isArray(eventName) ? eventName : [eventName]; 40 | 41 | let source: EventSource | null = null; 42 | 43 | const resetMessageState = () => { 44 | message.value = ""; 45 | messageParts.value = []; 46 | }; 47 | 48 | const closeConnection = (resetMessage: boolean = false) => { 49 | eventNames.forEach((eventName) => { 50 | source?.removeEventListener(eventName, handleMessage); 51 | }); 52 | source?.close(); 53 | source = null; 54 | 55 | if (resetMessage) { 56 | resetMessageState(); 57 | } 58 | }; 59 | 60 | const handleMessage = (event: MessageEvent) => { 61 | if ([endSignal, `${dataPrefix}${endSignal}`].includes(event.data)) { 62 | closeConnection(); 63 | onComplete(); 64 | return; 65 | } 66 | 67 | if (replace) { 68 | resetMessageState(); 69 | } 70 | 71 | messageParts.value.push( 72 | event.data.startsWith(dataPrefix) 73 | ? event.data.substring(dataPrefix.length) 74 | : event.data, 75 | ); 76 | 77 | message.value = messageParts.value.join(glue); 78 | 79 | onMessage(event); 80 | }; 81 | 82 | const handleError = (e: Event) => { 83 | onError(e); 84 | closeConnection(); 85 | }; 86 | 87 | const setupConnection = () => { 88 | resetMessageState(); 89 | 90 | source = new EventSource(urlRef.value); 91 | 92 | eventNames.forEach((eventName) => { 93 | source!.addEventListener(eventName, handleMessage); 94 | }); 95 | source.addEventListener("error", handleError); 96 | }; 97 | 98 | onMounted(() => { 99 | setupConnection(); 100 | }); 101 | 102 | onUnmounted(() => { 103 | closeConnection(); 104 | }); 105 | 106 | watch(urlRef, (newUrl: string, oldUrl: string) => { 107 | if (newUrl !== oldUrl) { 108 | closeConnection(); 109 | setupConnection(); 110 | } 111 | }); 112 | 113 | return { 114 | message: readonly(message), 115 | messageParts: readonly(messageParts), 116 | close: closeConnection, 117 | clearMessage: resetMessageState, 118 | }; 119 | }; 120 | 121 | export default useEventStream; 122 | -------------------------------------------------------------------------------- /packages/react/src/hooks/use-event-stream.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 | import { EventStreamOptions, EventStreamResult } from "../types"; 3 | 4 | const dataPrefix = "data: "; 5 | 6 | /** 7 | * Hook for handling server-sent event (SSE) streams 8 | * 9 | * @param url - The URL to connect to for the EventSource 10 | * @param options - Options for the stream 11 | * 12 | * @link https://laravel.com/docs/responses#event-streams 13 | * 14 | * @returns StreamResult object containing the accumulated response, close, and reset functions 15 | */ 16 | export const useEventStream = ( 17 | url: string, 18 | { 19 | eventName = "update", 20 | endSignal = "", 21 | glue = " ", 22 | replace = false, 23 | onMessage = () => null, 24 | onComplete = () => null, 25 | onError = () => null, 26 | }: EventStreamOptions = {}, 27 | ): EventStreamResult => { 28 | const sourceRef = useRef(null); 29 | const messagePartsRef = useRef([]); 30 | const eventNames = useMemo( 31 | () => (Array.isArray(eventName) ? eventName : [eventName]), 32 | Array.isArray(eventName) ? eventName : [eventName], 33 | ); 34 | 35 | const [message, setMessage] = useState(""); 36 | const [messageParts, setMessageParts] = useState([]); 37 | 38 | const resetMessageState = useCallback(() => { 39 | messagePartsRef.current = []; 40 | setMessage(""); 41 | setMessageParts([]); 42 | }, []); 43 | 44 | const handleMessage = useCallback( 45 | (event: MessageEvent) => { 46 | if ([endSignal, `${dataPrefix}${endSignal}`].includes(event.data)) { 47 | closeConnection(); 48 | onComplete(); 49 | 50 | return; 51 | } 52 | 53 | if (replace) { 54 | resetMessageState(); 55 | } 56 | 57 | messagePartsRef.current.push( 58 | event.data.startsWith(dataPrefix) 59 | ? event.data.substring(dataPrefix.length) 60 | : event.data, 61 | ); 62 | 63 | setMessage(messagePartsRef.current.join(glue)); 64 | setMessageParts(messagePartsRef.current); 65 | 66 | onMessage(event); 67 | }, 68 | [eventNames, glue], 69 | ); 70 | 71 | const handleError = useCallback((error: Event) => { 72 | onError(error); 73 | closeConnection(); 74 | }, []); 75 | 76 | const closeConnection = useCallback((resetMessage: boolean = false) => { 77 | eventNames.forEach((name) => { 78 | sourceRef.current?.removeEventListener(name, handleMessage); 79 | }); 80 | sourceRef.current?.removeEventListener("error", handleError); 81 | sourceRef.current?.close(); 82 | sourceRef.current = null; 83 | 84 | if (resetMessage) { 85 | resetMessageState(); 86 | } 87 | }, []); 88 | 89 | useEffect(() => { 90 | resetMessageState(); 91 | 92 | sourceRef.current = new EventSource(url); 93 | 94 | eventNames.forEach((name) => { 95 | sourceRef.current?.addEventListener(name, handleMessage); 96 | }); 97 | sourceRef.current.addEventListener("error", handleError); 98 | 99 | return closeConnection; 100 | }, [url, eventNames, handleMessage, handleError, resetMessageState]); 101 | 102 | return { 103 | message, 104 | messageParts, 105 | close: closeConnection, 106 | clearMessage: resetMessageState, 107 | }; 108 | }; 109 | -------------------------------------------------------------------------------- /packages/react/src/streams/dispatch.ts: -------------------------------------------------------------------------------- 1 | import { Callback, RequiredCallbacks, StreamOptions } from "../types"; 2 | 3 | const callbacks = new Map< 4 | string, 5 | { 6 | onData: RequiredCallbacks["onData"][]; 7 | onError: RequiredCallbacks["onError"][]; 8 | onFinish: RequiredCallbacks["onFinish"][]; 9 | onResponse: RequiredCallbacks["onResponse"][]; 10 | onCancel: RequiredCallbacks["onCancel"][]; 11 | onBeforeSend: RequiredCallbacks["onBeforeSend"][]; 12 | } 13 | >(); 14 | 15 | export const addCallbacks = (id: string, options: StreamOptions) => { 16 | if (!callbacks.has(id)) { 17 | callbacks.set(id, { 18 | onData: [], 19 | onError: [], 20 | onFinish: [], 21 | onResponse: [], 22 | onCancel: [], 23 | onBeforeSend: [], 24 | }); 25 | } 26 | 27 | const streamCallbacks = callbacks.get(id)!; 28 | 29 | if (options.onData) { 30 | streamCallbacks.onData.push(options.onData); 31 | } 32 | 33 | if (options.onError) { 34 | streamCallbacks.onError.push(options.onError); 35 | } 36 | 37 | if (options.onFinish) { 38 | streamCallbacks.onFinish.push(options.onFinish); 39 | } 40 | 41 | if (options.onResponse) { 42 | streamCallbacks.onResponse.push(options.onResponse); 43 | } 44 | 45 | if (options.onCancel) { 46 | streamCallbacks.onCancel.push(options.onCancel); 47 | } 48 | 49 | if (options.onBeforeSend) { 50 | streamCallbacks.onBeforeSend.push(options.onBeforeSend); 51 | } 52 | 53 | return () => { 54 | removeCallbacks(id, options); 55 | }; 56 | }; 57 | 58 | const dispatchCallbacks = ( 59 | id: string, 60 | callback: Callback, 61 | ...args: unknown[] 62 | ): any[] => { 63 | const streamCallbacks = callbacks.get(id); 64 | 65 | if (!streamCallbacks) { 66 | return []; 67 | } 68 | 69 | // @ts-expect-error Any args 70 | return streamCallbacks[callback].map((cb) => cb(...args)); 71 | }; 72 | 73 | export const onFinish = (id: string) => { 74 | dispatchCallbacks(id, "onFinish"); 75 | }; 76 | 77 | export const onError = (id: string, error: Error) => { 78 | dispatchCallbacks(id, "onError", error); 79 | }; 80 | 81 | export const onResponse = (id: string, response: Response) => { 82 | dispatchCallbacks(id, "onResponse", response); 83 | }; 84 | 85 | export const onCancel = (id: string) => { 86 | dispatchCallbacks(id, "onCancel"); 87 | }; 88 | 89 | export const onData = (id: string, data: string) => { 90 | dispatchCallbacks(id, "onData", data); 91 | }; 92 | 93 | export const onBeforeSend = (id: string, request: RequestInit) => { 94 | const results = dispatchCallbacks(id, "onBeforeSend", request) as ( 95 | | boolean 96 | | RequestInit 97 | | void 98 | )[]; 99 | 100 | for (const result of results) { 101 | if (result === false) { 102 | return false; 103 | } 104 | 105 | if (result !== null && typeof result === "object") { 106 | return result; 107 | } 108 | } 109 | 110 | return null; 111 | }; 112 | 113 | export const removeCallbacks = (id: string, options: StreamOptions) => { 114 | const streamCallbacks = callbacks.get(id); 115 | 116 | if (!streamCallbacks) { 117 | return; 118 | } 119 | 120 | if (options.onData) { 121 | streamCallbacks.onData = streamCallbacks.onData.filter( 122 | (cb) => cb !== options.onData, 123 | ); 124 | } 125 | 126 | if (options.onError) { 127 | streamCallbacks.onError = streamCallbacks.onError.filter( 128 | (cb) => cb !== options.onError, 129 | ); 130 | } 131 | 132 | if (options.onFinish) { 133 | streamCallbacks.onFinish = streamCallbacks.onFinish.filter( 134 | (cb) => cb !== options.onFinish, 135 | ); 136 | } 137 | 138 | if (options.onResponse) { 139 | streamCallbacks.onResponse = streamCallbacks.onResponse.filter( 140 | (cb) => cb !== options.onResponse, 141 | ); 142 | } 143 | 144 | if (options.onCancel) { 145 | streamCallbacks.onCancel = streamCallbacks.onCancel.filter( 146 | (cb) => cb !== options.onCancel, 147 | ); 148 | } 149 | 150 | if (options.onBeforeSend) { 151 | streamCallbacks.onBeforeSend = streamCallbacks.onBeforeSend.filter( 152 | (cb) => cb !== options.onBeforeSend, 153 | ); 154 | } 155 | }; 156 | -------------------------------------------------------------------------------- /packages/vue/src/streams/dispatch.ts: -------------------------------------------------------------------------------- 1 | import { Callback, RequiredCallbacks, StreamOptions } from "../types"; 2 | 3 | const callbacks = new Map< 4 | string, 5 | { 6 | onData: RequiredCallbacks["onData"][]; 7 | onError: RequiredCallbacks["onError"][]; 8 | onFinish: RequiredCallbacks["onFinish"][]; 9 | onResponse: RequiredCallbacks["onResponse"][]; 10 | onCancel: RequiredCallbacks["onCancel"][]; 11 | onBeforeSend: RequiredCallbacks["onBeforeSend"][]; 12 | } 13 | >(); 14 | 15 | export const addCallbacks = (id: string, options: StreamOptions) => { 16 | if (!callbacks.has(id)) { 17 | callbacks.set(id, { 18 | onData: [], 19 | onError: [], 20 | onFinish: [], 21 | onResponse: [], 22 | onCancel: [], 23 | onBeforeSend: [], 24 | }); 25 | } 26 | 27 | const streamCallbacks = callbacks.get(id)!; 28 | 29 | if (options.onData) { 30 | streamCallbacks.onData.push(options.onData); 31 | } 32 | 33 | if (options.onError) { 34 | streamCallbacks.onError.push(options.onError); 35 | } 36 | 37 | if (options.onFinish) { 38 | streamCallbacks.onFinish.push(options.onFinish); 39 | } 40 | 41 | if (options.onResponse) { 42 | streamCallbacks.onResponse.push(options.onResponse); 43 | } 44 | 45 | if (options.onCancel) { 46 | streamCallbacks.onCancel.push(options.onCancel); 47 | } 48 | 49 | if (options.onBeforeSend) { 50 | streamCallbacks.onBeforeSend.push(options.onBeforeSend); 51 | } 52 | 53 | return () => { 54 | removeCallbacks(id, options); 55 | }; 56 | }; 57 | 58 | const dispatchCallbacks = ( 59 | id: string, 60 | callback: Callback, 61 | ...args: unknown[] 62 | ): any[] => { 63 | const streamCallbacks = callbacks.get(id); 64 | 65 | if (!streamCallbacks) { 66 | return []; 67 | } 68 | 69 | // @ts-expect-error Any args 70 | return streamCallbacks[callback].map((cb) => cb(...args)); 71 | }; 72 | 73 | export const onFinish = (id: string) => { 74 | dispatchCallbacks(id, "onFinish"); 75 | }; 76 | 77 | export const onError = (id: string, error: Error) => { 78 | dispatchCallbacks(id, "onError", error); 79 | }; 80 | 81 | export const onResponse = (id: string, response: Response) => { 82 | dispatchCallbacks(id, "onResponse", response); 83 | }; 84 | 85 | export const onCancel = (id: string) => { 86 | dispatchCallbacks(id, "onCancel"); 87 | }; 88 | 89 | export const onData = (id: string, data: string) => { 90 | dispatchCallbacks(id, "onData", data); 91 | }; 92 | 93 | export const onBeforeSend = (id: string, request: RequestInit) => { 94 | const results = dispatchCallbacks(id, "onBeforeSend", request) as ( 95 | | boolean 96 | | RequestInit 97 | | void 98 | )[]; 99 | 100 | for (const result of results) { 101 | if (result === false) { 102 | return false; 103 | } 104 | 105 | if (result !== null && typeof result === "object") { 106 | return result; 107 | } 108 | } 109 | 110 | return null; 111 | }; 112 | 113 | export const removeCallbacks = (id: string, options: StreamOptions) => { 114 | const streamCallbacks = callbacks.get(id); 115 | 116 | if (!streamCallbacks) { 117 | return; 118 | } 119 | 120 | if (options.onData) { 121 | streamCallbacks.onData = streamCallbacks.onData.filter( 122 | (cb) => cb !== options.onData, 123 | ); 124 | } 125 | 126 | if (options.onError) { 127 | streamCallbacks.onError = streamCallbacks.onError.filter( 128 | (cb) => cb !== options.onError, 129 | ); 130 | } 131 | 132 | if (options.onFinish) { 133 | streamCallbacks.onFinish = streamCallbacks.onFinish.filter( 134 | (cb) => cb !== options.onFinish, 135 | ); 136 | } 137 | 138 | if (options.onResponse) { 139 | streamCallbacks.onResponse = streamCallbacks.onResponse.filter( 140 | (cb) => cb !== options.onResponse, 141 | ); 142 | } 143 | 144 | if (options.onCancel) { 145 | streamCallbacks.onCancel = streamCallbacks.onCancel.filter( 146 | (cb) => cb !== options.onCancel, 147 | ); 148 | } 149 | 150 | if (options.onBeforeSend) { 151 | streamCallbacks.onBeforeSend = streamCallbacks.onBeforeSend.filter( 152 | (cb) => cb !== options.onBeforeSend, 153 | ); 154 | } 155 | }; 156 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | **PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).** 4 | 5 | ## Supported Versions 6 | 7 | Only the latest major version receives security fixes. 8 | 9 | ## Reporting a Vulnerability 10 | 11 | If you discover a security vulnerability within Laravel, please send an email to Taylor Otwell at taylor@laravel.com. All security vulnerabilities will be promptly addressed. 12 | 13 | ### Public PGP Key 14 | 15 | ``` 16 | -----BEGIN PGP PUBLIC KEY BLOCK----- 17 | Version: OpenPGP v2.0.8 18 | Comment: Report Security Vulnerabilities to taylor@laravel.com 19 | 20 | xsFNBFugFSQBEACxEKhIY9IoJzcouVTIYKJfWFGvwFgbRjQWBiH3QdHId5vCrbWo 21 | s2l+4Rv03gMG+yHLJ3rWElnNdRaNdQv59+lShrZF7Bvu7Zvc0mMNmFOM/mQ/K2Lt 22 | OK/8bh6iwNNbEuyOhNQlarEy/w8hF8Yf55hBeu/rajGtcyURJDloQ/vNzcx4RWGK 23 | G3CLr8ka7zPYIjIFUvHLt27mcYFF9F4/G7b4HKpn75ICKC4vPoQSaYNAHlHQBLFb 24 | Jg/WPl93SySHLugU5F58sICs+fBZadXYQG5dWmbaF5OWB1K2XgRs45BQaBzf/8oS 25 | qq0scN8wVhAdBeYlVFf0ImDOxGlZ2suLK1BKJboR6zCIkBAwufKss4NV1R9KSUMv 26 | YGn3mq13PGme0QoIkvQkua5VjTwWfQx7wFDxZ3VQSsjIlbVyRL/Ac/hq71eAmiIR 27 | t6ZMNMPFpuSwBfYimrXqqb4EOffrfsTzRenG1Cxm4jNZRzX/6P4an7F/euoqXeXZ 28 | h37TiC7df+eHKcBI4mL+qOW4ibBqg8WwWPJ+jvuhANyQpRVzf3NNEOwJYCNbQPM/ 29 | PbqYvMruAH+gg7uyu9u0jX3o/yLSxJMV7kF4x/SCDuBKIxSSUI4cgbjIlnxWLXZC 30 | wl7KW4xAKkerO3wgIPnxNfxQoiYiEKA1c3PShWRA0wHIMt3rVRJxwGM4CwARAQAB 31 | zRJ0YXlsb3JAbGFyYXZlbC5jb23CwXAEEwEKABoFAlugFSQCGy8DCwkHAxUKCAIe 32 | AQIXgAIZAQAKCRDKAI7r/Ml7Zo0SD/9zwu9K87rbqXbvZ3TVu7TnN+z7mPvVBzl+ 33 | SFEK360TYq8a4GosghZuGm4aNEyZ90CeUjPQwc5fHwa26tIwqgLRppsG21B/mZwu 34 | 0m8c5RaBFRFX/mCTEjlpvBkOwMJZ8f05nNdaktq6W98DbMN03neUwnpWlNSLeoNI 35 | u4KYZmJopNFLEax5WGaaDpmqD1J+WDr/aPHx39MUAg2ZVuC3Gj/IjYZbD1nCh0xD 36 | a09uDODje8a9uG33cKRBcKKPRLZjWEt5SWReLx0vsTuqJSWhCybHRBl9BQTc/JJR 37 | gJu5V4X3f1IYMTNRm9GggxcXrlOAiDCjE2J8ZTUt0cSxedQFnNyGfKxe/l94oTFP 38 | wwFHbdKhsSDZ1OyxPNIY5OHlMfMvvJaNbOw0xPPAEutPwr1aqX9sbgPeeiJwAdyw 39 | mPw2x/wNQvKJITRv6atw56TtLxSevQIZGPHCYTSlsIoi9jqh9/6vfq2ruMDYItCq 40 | +8uzei6TyH6w+fUpp/uFmcwZdrDwgNVqW+Ptu+pD2WmthqESF8UEQVoOv7OPgA5E 41 | ofOMaeH2ND74r2UgcXjPxZuUp1RkhHE2jJeiuLtbvOgrWwv3KOaatyEbVl+zHA1e 42 | 1RHdJRJRPK+S7YThxxduqfOBX7E03arbbhHdS1HKhPwMc2e0hNnQDoNxQcv0GQp4 43 | 2Y6UyCRaus7ATQRboBUkAQgA0h5j3EO2HNvp8YuT1t/VF00uUwbQaz2LIoZogqgC 44 | 14Eb77diuIPM9MnuG7bEOnNtPVMFXxI5UYBIlzhLMxf7pfbrsoR4lq7Ld+7KMzdm 45 | eREqJRgUNfjZhtRZ9Z+jiFPr8AGpYxwmJk4v387uQGh1GC9JCc3CCLJoI62I9t/1 46 | K2b25KiOzW/FVZ/vYFj1WbISRd5GqS8SEFh4ifU79LUlJ/nEsFv4JxAXN9RqjU0e 47 | H4S/m1Nb24UCtYAv1JKymcf5O0G7kOzvI0w06uKxk0hNwspjDcOebD8Vv9IdYtGl 48 | 0bn7PpBlVO1Az3s8s6Xoyyw+9Us+VLNtVka3fcrdaV/n0wARAQABwsKEBBgBCgAP 49 | BQJboBUkBQkPCZwAAhsuASkJEMoAjuv8yXtmwF0gBBkBCgAGBQJboBUkAAoJEA1I 50 | 8aTLtYHmjpIH/A1ZKwTGetHFokJxsd2omvbqv+VtpAjnUbvZEi5g3yZXn+dHJV+K 51 | UR/DNlfGxLWEcY6datJ3ziNzzD5u8zcPp2CqeTlCxOky8F74FjEL9tN/EqUbvvmR 52 | td2LXsSFjHnLJRK5lYfZ3rnjKA5AjqC9MttILBovY2rI7lyVt67kbS3hMHi8AZl8 53 | EgihnHRJxGZjEUxyTxcB13nhfjAvxQq58LOj5754Rpe9ePSKbT8DNMjHbGpLrESz 54 | cmyn0VzDMLfxg8AA9uQFMwdlKqve7yRZXzeqvy08AatUpJaL7DsS4LKOItwvBub6 55 | tHbCE3mqrUw5lSNyUahO3vOcMAHnF7fd4W++eA//WIQKnPX5t3CwCedKn8Qkb3Ow 56 | oj8xUNl2T6kEtQJnO85lKBFXaMOUykopu6uB9EEXEr0ShdunOKX/UdDbkv46F2AB 57 | 7TtltDSLB6s/QeHExSb8Jo3qra86JkDUutWdJxV7DYFUttBga8I0GqdPu4yRRoc/ 58 | 0irVXsdDY9q7jz6l7fw8mSeJR96C0Puhk70t4M1Vg/tu/ONRarXQW7fJ8kl21PcD 59 | UKNWWa242gji/+GLRI8AIpGMsBiX7pHhqmMMth3u7+ne5BZGGJz0uX+CzWboOHyq 60 | kWgfY4a62t3hM0vwnUkl/D7VgSGy4LiKQrapd3LvU2uuEfFsMu3CDicZBRXPqoXj 61 | PBjkkPKhwUTNlwEQrGF3QsZhNe0M9ptM2fC34qtxZtMIMB2NLvE4S621rmQ05oQv 62 | sT0B9WgUL3GYRKdx700+ojHEuwZ79bcLgo1dezvkfPtu/++2CXtieFthDlWHy8x5 63 | XJJjI1pDfGO+BgX0rS3QrQEYlF/uPQynKwxe6cGI62eZ0ug0hNrPvKEcfMLVqBQv 64 | w4VH6iGp9yNKMUOgAECLCs4YCxK+Eka9Prq/Gh4wuqjWiX8m66z8YvKf27sFL3fR 65 | OwGaz3LsnRSxbk/8oSiZuOVLfn44XRcxsHebteZat23lwD93oq54rtKnlJgmZHJY 66 | 4vMgk1jpS4laGnvhZj7OwE0EW6AVJAEIAKJSrUvXRyK3XQnLp3Kfj82uj0St8Dt2 67 | h8BMeVbrAbg38wCN8XQZzVR9+bRZRR+aCzpKSqwhEQVtH7gdKgfdNdGNhG2DFAVk 68 | SihMhQz190FKttUZgwY00enzD7uaaA5VwNAZzRIr8skwiASB7UoO+lIhrAYgcQCA 69 | LpwCSMrUNB3gY1IVa2xi9FljEbS2uMABfOsTfl7z4L4T4DRv/ovDf+ihyZOXsXiH 70 | RVoUTIpN8ZILCZiiKubE1sMj4fSQwCs06UyDy17HbOG5/dO9awR/LHW53O3nZCxE 71 | JbCqr5iHa2MdHMC12+luxWJKD9DbVB01LiiPZCTkuKUDswCyi7otpVEAEQEAAcLC 72 | hAQYAQoADwUCW6AVJAUJDwmcAAIbLgEpCRDKAI7r/Ml7ZsBdIAQZAQoABgUCW6AV 73 | JAAKCRDxrCjKN7eORjt2B/9EnKVJ9lwB1JwXcQp6bZgJ21r6ghyXBssv24N9UF+v 74 | 5QDz/tuSkTsKK1UoBrBDEinF/xTP2z+xXIeyP4c3mthMHsYdMl7AaGpcCwVJiL62 75 | fZvd+AiYNX3C+Bepwnwoziyhx4uPaqoezSEMD8G2WQftt6Gqttmm0Di5RVysCECF 76 | EyhkHwvCcbpXb5Qq+4XFzCUyaIZuGpe+oeO7U8B1CzOC16hEUu0Uhbk09Xt6dSbS 77 | ZERoxFjrGU+6bk424MkZkKvNS8FdTN2s3kQuHoNmhbMY+fRzKX5JNrcQ4dQQufiB 78 | zFcc2Ba0JVU0nYAMftTeT5ALakhwSqr3AcdD2avJZp3EYfYP/3smPGTeg1cDJV3E 79 | WIlCtSlhbwviUjvWEWJUE+n9MjhoUNU0TJtHIliUYUajKMG/At5wJZTXJaKVUx32 80 | UCWr4ioKfSzlbp1ngBuFlvU7LgZRcKbBZWvKj/KRYpxpfvPyPElmegCjAk6oiZYV 81 | LOV+jFfnMkk9PnR91ZZfTNx/bK+BwjOnO+g7oE8V2g2bA90vHdeSUHR52SnaVN/b 82 | 9ytt07R0f+YtyKojuPmlNsbyAaUYUtJ1o+XNCwdVxzarYEuUabhAfDiVTu9n8wTr 83 | YVvnriSFOjNvOY9wdLAa56n7/qM8bzuGpoBS5SilXgJvITvQfWPvg7I9C3QhwK1S 84 | F6B1uquQGbBSze2wlnMbKXmhyGLlv9XpOqpkkejQo3o58B+Sqj4B8DuYixSjoknr 85 | pRbj8gqgqBKlcpf1wD5X9qCrl9vq19asVOHaKhiFZGxZIVbBpBOdvAKaMj4p/uln 86 | yklN3YFIfgmGPYbL0elvXVn7XfvwSV1mCQV5LtMbLHsFf0VsA16UsG8A/tLWtwgt 87 | 0antzftRHXb+DI4qr+qEYKFkv9F3oCOXyH4QBhPA42EzKqhMXByEkEK9bu6skioL 88 | mHhDQ7yHjTWcxstqQjkUQ0T/IF9ls+Sm5u7rVXEifpyI7MCb+76kSCDawesvInKt 89 | WBGOG/qJGDlNiqBYYt2xNqzHCJoC 90 | =zXOv 91 | -----END PGP PUBLIC KEY BLOCK----- 92 | ``` 93 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [Unreleased](https://github.com/laravel/stream/compare/v0.3.10...main) 6 | 7 | ## [v0.3.10](https://github.com/laravel/stream/compare/v0.3.9...v0.3.10) - 2025-12-02 8 | 9 | ### What's Changed 10 | 11 | * Prevent destroy errors by checking if method exists by [@tomas-ug](https://github.com/tomas-ug) in https://github.com/laravel/stream/pull/20 12 | * [Vue Feature]: Support reactive URLs in useStream by [@ayrtonandino](https://github.com/ayrtonandino) in https://github.com/laravel/stream/pull/21 13 | 14 | ### New Contributors 15 | 16 | * [@tomas-ug](https://github.com/tomas-ug) made their first contribution in https://github.com/laravel/stream/pull/20 17 | * [@ayrtonandino](https://github.com/ayrtonandino) made their first contribution in https://github.com/laravel/stream/pull/21 18 | 19 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.3.9...v0.3.10 20 | 21 | ## [v0.3.9](https://github.com/laravel/stream/compare/v0.3.8...v0.3.9) - 2025-09-22 22 | 23 | ### What's Changed 24 | 25 | * Fix PNPM publishing by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/19 26 | 27 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.3.8...v0.3.9 28 | 29 | ## [v0.3.8](https://github.com/laravel/stream/compare/v0.3.7...v0.3.8) - 2025-09-22 30 | 31 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.3.7...v0.3.8 32 | 33 | ## [v0.3.7](https://github.com/laravel/stream/compare/v0.3.6...v0.3.7) - 2025-09-22 34 | 35 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.3.6...v0.3.7 36 | 37 | - Fixed Trusted Publishing 38 | 39 | ## [v0.3.6](https://github.com/laravel/stream/compare/v0.3.5...v0.3.6) - 2025-09-22 40 | 41 | ### What's Changed 42 | 43 | * Publish packages in CI by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/17 44 | * Update release script by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/18 45 | 46 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.3.5...v0.3.6 47 | 48 | ## [v0.3.5](https://github.com/laravel/stream/compare/v0.3.4...v0.3.5) - 2025-05-27 49 | 50 | ### What's Changed 51 | 52 | * Add generic type for the `send` method by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/16 53 | 54 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.3.4...v0.3.5 55 | 56 | ## [v0.3.4](https://github.com/laravel/stream/compare/v0.3.3...v0.3.4) - 2025-05-23 57 | 58 | ### What's Changed 59 | 60 | * Callback syncing by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/14 61 | * Add `onBeforeSend` to `useStream` by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/15 62 | 63 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.3.3...v0.3.4 64 | 65 | ## [v0.3.3](https://github.com/laravel/stream/compare/v0.3.2...v0.3.3) - 2025-05-22 66 | 67 | ### What's Changed 68 | 69 | * Add credentials option to `useStream` by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/13 70 | 71 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.3.2...v0.3.3 72 | 73 | ## [v0.3.2](https://github.com/laravel/stream/compare/v0.3.1...v0.3.2) - 2025-05-22 74 | 75 | ### What's Changed 76 | 77 | * rawData -> strData for useJsonStream by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/12 78 | 79 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.3.1...v0.3.2 80 | 81 | ## [v0.3.1](https://github.com/laravel/stream/compare/v0.3.0...v0.3.1) - 2025-05-20 82 | 83 | ### What's Changed 84 | 85 | * Cancel on unmount + `clearData` method by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/9 86 | * useJsonStream hook by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/10 87 | 88 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.3.0...v0.3.1 89 | 90 | ## [v0.3.0](https://github.com/laravel/stream/compare/v0.2.1...v0.3.0) - 2025-05-19 91 | 92 | ### What's Changed 93 | 94 | * useStream Beta by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/8 95 | 96 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.2.1...v0.3.0 97 | 98 | ## [v0.2.1](https://github.com/laravel/stream/compare/v0.2.0...v0.2.1) - 2025-05-08 99 | 100 | ### What's Changed 101 | 102 | * Add option to replace instead of append by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/5 103 | * Fix formatting by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/6 104 | * Listen for multiple events by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/stream/pull/7 105 | 106 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.2.0...v0.2.1 107 | 108 | ## [v0.2.0](https://github.com/laravel/stream/compare/v0.1.0...v0.2.0) - 2025-04-07 109 | 110 | ## What's Changed 111 | 112 | - useStream -> useEventStream by @joetannenbaum in https://github.com/laravel/stream/pull/4 113 | 114 | **Full Changelog**: https://github.com/laravel/stream/compare/v0.1.0...v0.2.0 115 | 116 | ## [v0.1.0](https://github.com/laravel/stream/releases/tag/v0.1.0) - 2025-04-06 117 | 118 | Initial release! 119 | -------------------------------------------------------------------------------- /packages/vue/src/composables/useStream.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import { 3 | MaybeRefOrGetter, 4 | onMounted, 5 | onUnmounted, 6 | readonly, 7 | Ref, 8 | ref, 9 | toRef, 10 | watch, 11 | } from "vue"; 12 | import { 13 | addCallbacks, 14 | onBeforeSend, 15 | onCancel, 16 | onData, 17 | onError, 18 | onFinish, 19 | onResponse, 20 | } from "../streams/dispatch"; 21 | import { 22 | addListener, 23 | hasListeners, 24 | resolveStream, 25 | update, 26 | } from "../streams/store"; 27 | import { StreamMeta, StreamOptions } from "../types"; 28 | 29 | export const useStream = < 30 | TSendBody extends Record = {}, 31 | TJsonData = null, 32 | >( 33 | url: MaybeRefOrGetter, 34 | options: StreamOptions = {}, 35 | ): { 36 | data: Readonly>; 37 | jsonData: Readonly; 38 | isFetching: Readonly>; 39 | isStreaming: Readonly>; 40 | id: string; 41 | send: (body?: TSendBody) => void; 42 | cancel: () => void; 43 | clearData: () => void; 44 | } => { 45 | const urlRef = toRef(url); 46 | const id = options.id ?? nanoid(); 47 | const stream = ref>(resolveStream(id)); 48 | const headers = (() => { 49 | const headers: HeadersInit = { 50 | "Content-Type": "application/json", 51 | "X-STREAM-ID": id, 52 | }; 53 | 54 | const csrfToken = 55 | options.csrfToken ?? 56 | document 57 | .querySelector('meta[name="csrf-token"]') 58 | ?.getAttribute("content"); 59 | 60 | if (csrfToken) { 61 | headers["X-CSRF-TOKEN"] = csrfToken; 62 | } 63 | 64 | return headers; 65 | })(); 66 | 67 | const data = ref(stream.value.data); 68 | const jsonData = ref(stream.value.jsonData); 69 | const isFetching = ref(stream.value.isFetching); 70 | const isStreaming = ref(stream.value.isStreaming); 71 | 72 | let stopListening: () => void; 73 | let removeCallbacks: () => void; 74 | 75 | const updateStream = (params: Partial>) => { 76 | update(id, params); 77 | }; 78 | 79 | const cancel = () => { 80 | stream.value.controller.abort(); 81 | 82 | if (isFetching || isStreaming) { 83 | onCancel(id); 84 | } 85 | 86 | updateStream({ 87 | isFetching: false, 88 | isStreaming: false, 89 | }); 90 | }; 91 | 92 | const makeRequest = (body?: TSendBody) => { 93 | const controller = new AbortController(); 94 | 95 | const request: RequestInit = { 96 | method: "POST", 97 | signal: controller.signal, 98 | headers: { 99 | ...headers, 100 | ...(options.headers ?? {}), 101 | }, 102 | body: JSON.stringify(body ?? {}), 103 | credentials: options.credentials ?? "same-origin", 104 | }; 105 | 106 | const modifiedRequest = onBeforeSend(id, request); 107 | 108 | if (modifiedRequest === false) { 109 | return; 110 | } 111 | 112 | updateStream({ 113 | isFetching: true, 114 | controller, 115 | }); 116 | 117 | fetch(urlRef.value, modifiedRequest ?? request) 118 | .then(async (response) => { 119 | if (!response.ok) { 120 | const error = await response.text(); 121 | throw new Error(error); 122 | } 123 | 124 | if (!response.body) { 125 | throw new Error( 126 | "ReadableStream not yet supported in this browser.", 127 | ); 128 | } 129 | 130 | onResponse(id, response); 131 | 132 | updateStream({ 133 | isFetching: false, 134 | isStreaming: true, 135 | }); 136 | 137 | return read(response.body.getReader()); 138 | }) 139 | .catch((error: Error) => { 140 | updateStream({ 141 | isFetching: false, 142 | isStreaming: false, 143 | }); 144 | 145 | onError(id, error); 146 | onFinish(id); 147 | }); 148 | }; 149 | 150 | const send = (body?: TSendBody) => { 151 | cancel(); 152 | makeRequest(body); 153 | clearData(); 154 | }; 155 | 156 | const clearData = () => { 157 | updateStream({ 158 | data: "", 159 | jsonData: null, 160 | }); 161 | }; 162 | 163 | const read = async ( 164 | reader: ReadableStreamDefaultReader, 165 | str = "", 166 | ): Promise => { 167 | return reader.read().then(({ done, value }) => { 168 | const incomingStr = new TextDecoder("utf-8").decode(value); 169 | const newData = str + incomingStr; 170 | 171 | onData(id, incomingStr); 172 | 173 | const streamParams: Partial> = { 174 | data: newData, 175 | }; 176 | 177 | if (!done) { 178 | updateStream(streamParams); 179 | 180 | return read(reader, newData); 181 | } 182 | 183 | streamParams.isStreaming = false; 184 | 185 | if (options.json) { 186 | try { 187 | streamParams.jsonData = JSON.parse(newData) as TJsonData; 188 | } catch (error) { 189 | onError(id, error as Error); 190 | } 191 | } 192 | 193 | updateStream(streamParams); 194 | 195 | onFinish(id); 196 | 197 | return ""; 198 | }); 199 | }; 200 | 201 | onMounted(() => { 202 | stopListening = addListener(id, (streamUpdate) => { 203 | stream.value = resolveStream(id); 204 | isFetching.value = streamUpdate.isFetching; 205 | isStreaming.value = streamUpdate.isStreaming; 206 | data.value = streamUpdate.data; 207 | jsonData.value = streamUpdate.jsonData; 208 | }); 209 | 210 | removeCallbacks = addCallbacks(id, options); 211 | 212 | window.addEventListener("beforeunload", cancel); 213 | 214 | if (options.initialInput) { 215 | makeRequest(options.initialInput); 216 | } 217 | }); 218 | 219 | onUnmounted(() => { 220 | if (stopListening) { 221 | stopListening(); 222 | } 223 | 224 | if (removeCallbacks) { 225 | removeCallbacks(); 226 | } 227 | 228 | window.removeEventListener("beforeunload", cancel); 229 | 230 | if (!hasListeners(id)) { 231 | cancel(); 232 | } 233 | }); 234 | 235 | watch(urlRef, (newUrl: string, oldUrl: string) => { 236 | if (newUrl !== oldUrl) { 237 | cancel(); 238 | clearData(); 239 | } 240 | }); 241 | 242 | return { 243 | data: readonly(data), 244 | jsonData: readonly(jsonData) as Readonly, 245 | isFetching: readonly(isFetching), 246 | isStreaming: readonly(isStreaming), 247 | id, 248 | send, 249 | cancel, 250 | clearData, 251 | }; 252 | }; 253 | 254 | export const useJsonStream = < 255 | TJsonData = null, 256 | TSendBody extends Record = {}, 257 | >( 258 | url: MaybeRefOrGetter, 259 | options: Omit, "json"> = {}, 260 | ) => { 261 | const { jsonData, data, ...rest } = useStream(url, { 262 | ...options, 263 | json: true, 264 | }); 265 | 266 | return { data: jsonData, strData: data, ...rest }; 267 | }; 268 | -------------------------------------------------------------------------------- /packages/react/src/hooks/use-stream.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import { useCallback, useEffect, useRef, useState } from "react"; 3 | import { 4 | addCallbacks, 5 | onBeforeSend, 6 | onCancel, 7 | onData, 8 | onError, 9 | onFinish, 10 | onResponse, 11 | } from "../streams/dispatch"; 12 | import { 13 | addListener, 14 | hasListeners, 15 | resolveStream, 16 | update, 17 | } from "../streams/store"; 18 | import { StreamMeta, StreamOptions } from "../types"; 19 | 20 | export const useStream = < 21 | TSendBody extends Record = {}, 22 | TJsonData = null, 23 | >( 24 | url: string, 25 | options: StreamOptions = {}, 26 | ) => { 27 | const id = useRef(options.id ?? nanoid()); 28 | const stream = useRef(resolveStream(id.current)); 29 | const headers = useRef( 30 | (() => { 31 | const headers: HeadersInit = { 32 | "Content-Type": "application/json", 33 | "X-STREAM-ID": id.current, 34 | }; 35 | 36 | const csrfToken = 37 | options.csrfToken ?? 38 | document 39 | .querySelector('meta[name="csrf-token"]') 40 | ?.getAttribute("content"); 41 | 42 | if (csrfToken) { 43 | headers["X-CSRF-TOKEN"] = csrfToken; 44 | } 45 | 46 | return headers; 47 | })(), 48 | ); 49 | 50 | const [data, setData] = useState(stream.current.data); 51 | const [jsonData, setJsonData] = useState( 52 | stream.current.jsonData, 53 | ); 54 | const [isFetching, setIsFetching] = useState(stream.current.isFetching); 55 | const [isStreaming, setIsStreaming] = useState(stream.current.isStreaming); 56 | 57 | const updateStream = useCallback( 58 | (params: Partial>) => { 59 | update(id.current, params); 60 | }, 61 | [], 62 | ); 63 | 64 | const cancel = useCallback(() => { 65 | stream.current.controller.abort(); 66 | 67 | if (isFetching || isStreaming) { 68 | onCancel(id.current); 69 | } 70 | 71 | updateStream({ 72 | isFetching: false, 73 | isStreaming: false, 74 | }); 75 | }, [isFetching, isStreaming]); 76 | 77 | const clearData = useCallback(() => { 78 | updateStream({ 79 | data: "", 80 | jsonData: null, 81 | }); 82 | }, []); 83 | 84 | const makeRequest = useCallback( 85 | (body?: TSendBody) => { 86 | const controller = new AbortController(); 87 | 88 | const request: RequestInit = { 89 | method: "POST", 90 | signal: controller.signal, 91 | headers: { 92 | ...headers.current, 93 | ...(options.headers ?? {}), 94 | }, 95 | body: JSON.stringify(body ?? {}), 96 | credentials: options.credentials ?? "same-origin", 97 | }; 98 | 99 | const modifiedRequest = onBeforeSend(id.current, request); 100 | 101 | if (modifiedRequest === false) { 102 | return; 103 | } 104 | 105 | updateStream({ 106 | isFetching: true, 107 | controller, 108 | }); 109 | 110 | fetch(url, modifiedRequest ?? request) 111 | .then(async (response) => { 112 | if (!response.ok) { 113 | const error = await response.text(); 114 | throw new Error(error); 115 | } 116 | 117 | if (!response.body) { 118 | throw new Error( 119 | "ReadableStream not yet supported in this browser.", 120 | ); 121 | } 122 | 123 | onResponse(id.current, response); 124 | 125 | updateStream({ 126 | isFetching: false, 127 | isStreaming: true, 128 | }); 129 | 130 | return read(response.body.getReader()); 131 | }) 132 | .catch((error: Error) => { 133 | updateStream({ 134 | isFetching: false, 135 | isStreaming: false, 136 | }); 137 | 138 | onError(id.current, error); 139 | onFinish(id.current); 140 | }); 141 | }, 142 | [url], 143 | ); 144 | 145 | const send = useCallback((body: TSendBody) => { 146 | cancel(); 147 | makeRequest(body); 148 | clearData(); 149 | }, []); 150 | 151 | const read = useCallback( 152 | ( 153 | reader: ReadableStreamDefaultReader, 154 | str = "", 155 | ): Promise => { 156 | return reader.read().then(({ done, value }) => { 157 | const incomingStr = new TextDecoder("utf-8").decode(value); 158 | const newData = str + incomingStr; 159 | 160 | onData(id.current, incomingStr); 161 | 162 | const streamParams: Partial> = { 163 | data: newData, 164 | }; 165 | 166 | if (!done) { 167 | updateStream(streamParams); 168 | 169 | return read(reader, newData); 170 | } 171 | 172 | streamParams.isStreaming = false; 173 | 174 | if (options.json) { 175 | try { 176 | streamParams.jsonData = JSON.parse( 177 | newData, 178 | ) as TJsonData; 179 | } catch (error) { 180 | onError(id.current, error as Error); 181 | } 182 | } 183 | 184 | updateStream(streamParams); 185 | 186 | onFinish(id.current); 187 | 188 | return ""; 189 | }); 190 | }, 191 | [], 192 | ); 193 | 194 | useEffect(() => { 195 | const stopListening = addListener( 196 | id.current, 197 | (streamUpdate: StreamMeta) => { 198 | stream.current = resolveStream(id.current); 199 | setIsFetching(streamUpdate.isFetching); 200 | setIsStreaming(streamUpdate.isStreaming); 201 | setData(streamUpdate.data); 202 | setJsonData(streamUpdate.jsonData); 203 | }, 204 | ); 205 | 206 | return () => { 207 | stopListening(); 208 | 209 | if (!hasListeners(id.current)) { 210 | cancel(); 211 | } 212 | }; 213 | }, []); 214 | 215 | useEffect(() => { 216 | const remove = addCallbacks(id.current, options); 217 | 218 | return () => { 219 | remove(); 220 | }; 221 | }, [options]); 222 | 223 | useEffect(() => { 224 | window.addEventListener("beforeunload", cancel); 225 | 226 | return () => { 227 | window.removeEventListener("beforeunload", cancel); 228 | }; 229 | }, [cancel]); 230 | 231 | useEffect(() => { 232 | if (options.initialInput) { 233 | makeRequest(options.initialInput); 234 | } 235 | }, []); 236 | 237 | return { 238 | data, 239 | jsonData, 240 | isFetching, 241 | isStreaming, 242 | id: id.current, 243 | send, 244 | cancel, 245 | clearData, 246 | }; 247 | }; 248 | 249 | export const useJsonStream = < 250 | TJsonData = null, 251 | TSendBody extends Record = {}, 252 | >( 253 | url: string, 254 | options: Omit, "json"> = {}, 255 | ) => { 256 | const { jsonData, data, ...rest } = useStream(url, { 257 | ...options, 258 | json: true, 259 | }); 260 | 261 | return { data: jsonData, strData: data, ...rest }; 262 | }; 263 | -------------------------------------------------------------------------------- /packages/vue/tests/useEventStream.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, test, vi } from "vitest"; 2 | import { createApp } from "vue"; 3 | import { useEventStream } from "../src/composables/useEventStream"; 4 | 5 | function withSetup(composable) { 6 | let result; 7 | 8 | const app = createApp({ 9 | setup() { 10 | result = composable(); 11 | return () => {}; 12 | }, 13 | }); 14 | 15 | app.mount(document.createElement("div")); 16 | 17 | return [result, app]; 18 | } 19 | 20 | describe("useEventStream", () => { 21 | let mocks; 22 | 23 | beforeEach(() => { 24 | vi.clearAllMocks(); 25 | vi.resetModules(); 26 | mocks = global.createEventSourceMock(); 27 | }); 28 | 29 | test("useEventStream initializes with default values", () => { 30 | const [result] = withSetup(() => useEventStream("/stream")); 31 | 32 | expect(result.message.value).toBe(""); 33 | expect(result.messageParts.value).toEqual([]); 34 | expect(typeof result.clearMessage).toBe("function"); 35 | expect(typeof result.close).toBe("function"); 36 | }); 37 | 38 | it("processes incoming messages correctly", async () => { 39 | const [result] = withSetup(() => useEventStream("/stream")); 40 | 41 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 42 | 43 | eventHandler({ data: "Hello" }); 44 | 45 | expect(result.message.value).toBe("Hello"); 46 | expect(result.messageParts.value).toEqual(["Hello"]); 47 | 48 | eventHandler({ data: "World" }); 49 | 50 | expect(result.message.value).toBe("Hello World"); 51 | expect(result.messageParts.value).toEqual(["Hello", "World"]); 52 | }); 53 | 54 | it("processes incoming messages correctly with replace option", async () => { 55 | const [result] = withSetup(() => 56 | useEventStream("/stream", { replace: true }), 57 | ); 58 | 59 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 60 | 61 | eventHandler({ data: "Hello" }); 62 | 63 | expect(result.message.value).toBe("Hello"); 64 | expect(result.messageParts.value).toEqual(["Hello"]); 65 | 66 | eventHandler({ data: "World" }); 67 | 68 | expect(result.message.value).toBe("World"); 69 | expect(result.messageParts.value).toEqual(["World"]); 70 | }); 71 | 72 | it("can clear the message", async () => { 73 | const [result] = withSetup(() => useEventStream("/stream")); 74 | 75 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 76 | 77 | eventHandler({ data: "Hello" }); 78 | eventHandler({ data: "World" }); 79 | 80 | expect(result.message.value).toBe("Hello World"); 81 | expect(result.messageParts.value).toEqual(["Hello", "World"]); 82 | 83 | result.clearMessage(); 84 | 85 | expect(result.message.value).toBe(""); 86 | expect(result.messageParts.value).toEqual([]); 87 | }); 88 | 89 | it("can close the stream manually", async () => { 90 | const onCompleteMock = vi.fn(); 91 | const [result] = withSetup(() => 92 | useEventStream("/stream", { onComplete: onCompleteMock }), 93 | ); 94 | 95 | result.close(); 96 | 97 | expect(mocks.close).toHaveBeenCalled(); 98 | expect(onCompleteMock).not.toHaveBeenCalled(); 99 | }); 100 | 101 | it("can handle custom glue", async () => { 102 | const [result] = withSetup(() => 103 | useEventStream("/stream", { glue: "|" }), 104 | ); 105 | 106 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 107 | 108 | eventHandler({ data: "Hello" }); 109 | expect(result.message.value).toBe("Hello"); 110 | expect(result.messageParts.value).toEqual(["Hello"]); 111 | 112 | eventHandler({ data: "World" }); 113 | expect(result.message.value).toBe("Hello|World"); 114 | expect(result.messageParts.value).toEqual(["Hello", "World"]); 115 | }); 116 | 117 | it("handles end signal correctly", async () => { 118 | const onCompleteMock = vi.fn(); 119 | const [result] = withSetup(() => 120 | useEventStream("/stream", { onComplete: onCompleteMock }), 121 | ); 122 | 123 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 124 | eventHandler({ data: "" }); 125 | 126 | expect(mocks.close).toHaveBeenCalled(); 127 | expect(onCompleteMock).toHaveBeenCalled(); 128 | }); 129 | 130 | test.each([{ endSignal: "WE DONE" }, { endSignal: "data: WE DONE" }])( 131 | "handles custom end signal correctly ($endSignal)", 132 | async ({ endSignal }) => { 133 | const onCompleteMock = vi.fn(); 134 | const [result] = withSetup(() => 135 | useEventStream("/stream", { 136 | onComplete: onCompleteMock, 137 | endSignal: "WE DONE", 138 | }), 139 | ); 140 | 141 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 142 | eventHandler({ data: endSignal }); 143 | 144 | expect(mocks.close).toHaveBeenCalled(); 145 | expect(onCompleteMock).toHaveBeenCalled(); 146 | }, 147 | ); 148 | 149 | it("handles errors correctly", async () => { 150 | const onErrorMock = vi.fn(); 151 | const [result] = withSetup(() => 152 | useEventStream("/stream", { onError: onErrorMock }), 153 | ); 154 | 155 | const errorHandler = mocks.addEventListener.mock.calls[1][1]; 156 | const testError = new Error("EventSource connection error"); 157 | 158 | errorHandler(testError); 159 | 160 | expect(onErrorMock).toHaveBeenCalled(); 161 | const errorArg = onErrorMock.mock.calls[0][0]; 162 | expect(errorArg).toBeInstanceOf(Error); 163 | expect(errorArg.message).toBe("EventSource connection error"); 164 | expect(mocks.close).toHaveBeenCalled(); 165 | }); 166 | 167 | it("onMessage callback is called with incoming messages", async () => { 168 | const onMessageMock = vi.fn(); 169 | const [result] = withSetup(() => 170 | useEventStream("/stream", { 171 | onMessage: onMessageMock, 172 | }), 173 | ); 174 | 175 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 176 | const testEvent = { data: "Test message" }; 177 | 178 | eventHandler(testEvent); 179 | 180 | expect(onMessageMock).toHaveBeenCalledWith(testEvent); 181 | }); 182 | 183 | it("cleans up EventSource on unmount", async () => { 184 | const [result, app] = withSetup(() => useEventStream("/stream")); 185 | 186 | app.unmount(); 187 | 188 | expect(mocks.close).toHaveBeenCalled(); 189 | expect(mocks.removeEventListener).toHaveBeenCalled(); 190 | }); 191 | 192 | it("reconnects when URL changes", async () => { 193 | const mockClose = vi.fn(); 194 | let eventSourceCount = 0; 195 | 196 | vi.stubGlobal( 197 | "EventSource", 198 | vi.fn().mockImplementation(() => { 199 | eventSourceCount++; 200 | return { 201 | addEventListener: vi.fn(), 202 | removeEventListener: vi.fn(), 203 | close: mockClose, 204 | }; 205 | }), 206 | ); 207 | 208 | const [result, app] = withSetup(() => useEventStream("/stream1")); 209 | 210 | expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(1); 211 | 212 | app.unmount(); 213 | 214 | const [newResult, newApp] = withSetup(() => useEventStream("/stream2")); 215 | 216 | expect(mockClose).toHaveBeenCalled(); 217 | expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(2); 218 | expect(vi.mocked(EventSource)).toHaveBeenLastCalledWith("/stream2"); 219 | }); 220 | 221 | it("can handle multiple events", async () => { 222 | const onMessageMock = vi.fn(); 223 | withSetup(() => 224 | useEventStream("/stream", { 225 | onMessage: onMessageMock, 226 | eventName: ["message", "customEvent"], 227 | }), 228 | ); 229 | 230 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 231 | const testEvent1 = { data: "Test message", type: "message" }; 232 | const testEvent2 = { data: "Test custom event", type: "customEvent" }; 233 | 234 | eventHandler(testEvent1); 235 | eventHandler(testEvent2); 236 | 237 | expect(onMessageMock).toHaveBeenCalledWith(testEvent1); 238 | expect(onMessageMock).toHaveBeenCalledWith(testEvent2); 239 | }); 240 | 241 | it("will ignore events we are not listening to", async () => { 242 | const onMessageMock = vi.fn(); 243 | withSetup(() => 244 | useEventStream("/stream", { 245 | onMessage: onMessageMock, 246 | eventName: ["message", "customEvent"], 247 | }), 248 | ); 249 | 250 | const testEvent1 = { data: "Test message", type: "message" }; 251 | const testEvent2 = { data: "Test custom event", type: "customEvent" }; 252 | const ignoredEvent = { data: "Ignored event", type: "ignoredEvent" }; 253 | 254 | mocks.triggerEvent("message", testEvent1); 255 | mocks.triggerEvent("customEvent", testEvent2); 256 | mocks.triggerEvent("ignoredEvent", ignoredEvent); 257 | 258 | expect(onMessageMock).toHaveBeenCalledWith(testEvent1); 259 | expect(onMessageMock).toHaveBeenCalledWith(testEvent2); 260 | expect(onMessageMock).not.toHaveBeenCalledWith(ignoredEvent); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /packages/vue/README.md: -------------------------------------------------------------------------------- 1 | # Laravel Stream for Vue 2 | 3 |

4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 8 |

9 | 10 | Easily consume streams in your Vue application. 11 | 12 | ## Installation 13 | 14 | ```bash 15 | npm install @laravel/stream-vue 16 | ``` 17 | 18 | ## Streaming Responses 19 | 20 | > [!IMPORTANT] 21 | > 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). 22 | 23 | The `useStream` hook allows you to seamlessly consume [streamed responses](https://laravel.com/docs/responses#streamed-responses) in your Vue application. 24 | 25 | Provide your stream URL and the hook will automatically update `data` with the concatenated response as data is returned from your server: 26 | 27 | ```vue 28 | 39 | 40 | 48 | ``` 49 | 50 | 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. 51 | 52 | The second argument given to `useStream` is an options object that you may use to customize the stream consumption behavior: 53 | 54 | ```ts 55 | type StreamOptions = { 56 | id?: string; 57 | initialInput?: Record; 58 | headers?: Record; 59 | csrfToken?: string; 60 | json?: boolean; 61 | credentials?: RequestCredentials; 62 | onResponse?: (response: Response) => void; 63 | onData?: (data: string) => void; 64 | onCancel?: () => void; 65 | onFinish?: () => void; 66 | onError?: (error: Error) => void; 67 | onBeforeSend?: (request: RequestInit) => boolean | RequestInit | void; 68 | }; 69 | ``` 70 | 71 | `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. 72 | 73 | `onData` is called as each chunk is received, the current chunk is passed to the callback. 74 | 75 | `onFinish` is called when a stream has finished and when an error is thrown during the fetch/read cycle. 76 | 77 | `onBeforeSend` is called right before sending the request to the server and receives the `RequestInit` object as an argument. Returning `false` from this callback cancels the request, returning a [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) object will override the existing `RequestInit` object. 78 | 79 | 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: 80 | 81 | ```vue 82 | 91 | 92 | 95 | ``` 96 | 97 | To cancel a stream manually, you may use the `cancel` method returned from the hook: 98 | 99 | ```vue 100 | 105 | 106 | 112 | ``` 113 | 114 | 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. 115 | 116 | When consuming the same stream from multiple components, you can read and write to the stream by providing your own `id`: 117 | 118 | ```vue 119 | 120 | 126 | 127 | 133 | ``` 134 | 135 | ```vue 136 | 137 | 146 | 147 | 153 | ``` 154 | 155 | The `useJsonStream` hook is identical to the `useStream` hook except that it will attempt to parse the data as JSON once it has finished streaming: 156 | 157 | ```vue 158 | 175 | 176 | 186 | ``` 187 | 188 | ## Event Streams (SSE) 189 | 190 | The `useEventStream` hook allows you to seamlessly consume [Server-Sent Events (SSE)](https://laravel.com/docs/responses#event-streams) in your Vue application. 191 | 192 | Provide your stream URL and the hook will automatically update the `message` with the concatenated response as messages are returned from your server: 193 | 194 | ```vue 195 | 200 | 201 | 204 | ``` 205 | 206 | You also have access to the array of message parts: 207 | 208 | ```vue 209 | 214 | 215 | 222 | ``` 223 | 224 | If you'd like to listen to multiple events: 225 | 226 | ```vue 227 | 241 | ``` 242 | 243 | The second parameter is an options object where all properties are optional (defaults are shown below): 244 | 245 | ```vue 246 | 265 | ``` 266 | 267 | You can close the connection manually by using the returned `close` function: 268 | 269 | ```vue 270 | 282 | 283 | 286 | ``` 287 | 288 | The `clearMessage` function may be used to clear the message content that has been received so far: 289 | 290 | ```vue 291 | 303 | 304 | 307 | ``` 308 | 309 | ## License 310 | 311 | Laravel Stream is open-sourced software licensed under the [MIT license](LICENSE.md). 312 | -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 | # Laravel Stream for React 2 | 3 |

4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 8 |

9 | 10 | Easily consume streams in your React application. 11 | 12 | ## Installation 13 | 14 | ```bash 15 | npm install @laravel/stream-react 16 | ``` 17 | 18 | ## Streaming Responses 19 | 20 | > [!IMPORTANT] 21 | > 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). 22 | 23 | The `useStream` hook allows you to seamlessly consume [streamed responses](https://laravel.com/docs/responses#streamed-responses) in your React application. 24 | 25 | Provide your stream URL and the hook will automatically update `data` with the concatenated response as data is returned from your server: 26 | 27 | ```tsx 28 | import { useStream } from "@laravel/stream-react"; 29 | 30 | function App() { 31 | const { data, isFetching, isStreaming, send } = useStream("chat"); 32 | 33 | const sendMessage = () => { 34 | send({ 35 | message: `Current timestamp: ${Date.now()}`, 36 | }); 37 | }; 38 | 39 | return ( 40 |
41 |
{data}
42 | {isFetching &&
Connecting...
} 43 | {isStreaming &&
Generating...
} 44 | 45 |
46 | ); 47 | } 48 | ``` 49 | 50 | 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. 51 | 52 | The second argument given to `useStream` is an options object that you may use to customize the stream consumption behavior: 53 | 54 | ```ts 55 | type StreamOptions = { 56 | id?: string; 57 | initialInput?: Record; 58 | headers?: Record; 59 | csrfToken?: string; 60 | json?: boolean; 61 | credentials?: RequestCredentials; 62 | onResponse?: (response: Response) => void; 63 | onData?: (data: string) => void; 64 | onCancel?: () => void; 65 | onFinish?: () => void; 66 | onError?: (error: Error) => void; 67 | onBeforeSend?: (request: RequestInit) => boolean | RequestInit | void; 68 | }; 69 | ``` 70 | 71 | `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. 72 | 73 | `onData` is called as each chunk is received, the current chunk is passed to the callback. 74 | 75 | `onFinish` is called when a stream has finished and when an error is thrown during the fetch/read cycle. 76 | 77 | `onBeforeSend` is called right before sending the request to the server and receives the `RequestInit` object as an argument. Returning `false` from this callback cancels the request, returning a [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) object will override the existing `RequestInit` object. 78 | 79 | 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: 80 | 81 | ```tsx 82 | import { useStream } from "@laravel/stream-react"; 83 | 84 | function App() { 85 | const { data } = useStream("chat", { 86 | initialInput: { 87 | message: "Introduce yourself.", 88 | }, 89 | }); 90 | 91 | return
{data}
; 92 | } 93 | ``` 94 | 95 | To cancel a stream manually, you may use the `cancel` method returned from the hook: 96 | 97 | ```tsx 98 | import { useStream } from "@laravel/stream-react"; 99 | 100 | function App() { 101 | const { data, cancel } = useStream("chat"); 102 | 103 | return ( 104 |
105 |
{data}
106 | 107 |
108 | ); 109 | } 110 | ``` 111 | 112 | 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. 113 | 114 | When consuming the same stream from multiple components, you can read and write to the stream by providing your own `id`: 115 | 116 | ```tsx 117 | // App.tsx 118 | import { useStream } from "@laravel/stream-react"; 119 | 120 | function App() { 121 | const { data, id } = useStream("chat"); 122 | 123 | return ( 124 |
125 |
{data}
126 | 127 |
128 | ); 129 | } 130 | 131 | // StreamStatus.tsx 132 | import { useStream } from "@laravel/stream-react"; 133 | 134 | function StreamStatus({ id }) { 135 | const { isFetching, isStreaming } = useStream("chat", { id }); 136 | 137 | return ( 138 |
139 | {isFetching &&
Connecting...
} 140 | {isStreaming &&
Generating...
} 141 |
142 | ); 143 | } 144 | ``` 145 | 146 | The `useJsonStream` hook is identical to the `useStream` hook except that it will attempt to parse the data as JSON once it has finished streaming: 147 | 148 | ```tsx 149 | import { useJsonStream } from "@laravel/stream-react"; 150 | 151 | type User = { 152 | id: number; 153 | name: string; 154 | email: string; 155 | }; 156 | 157 | function App() { 158 | const { data, send } = useJsonStream<{ users: User[] }>("users"); 159 | 160 | const loadUsers = () => { 161 | send({ 162 | query: "taylor", 163 | }); 164 | }; 165 | 166 | return ( 167 |
168 |
    169 | {data?.users.map((user) => ( 170 |
  • 171 | {user.id}: {user.name} 172 |
  • 173 | ))} 174 |
175 | 176 |
177 | ); 178 | } 179 | ``` 180 | 181 | ## Event Streams (SSE) 182 | 183 | The `useEventStream` hook allows you to seamlessly consume [Server-Sent Events (SSE)](https://laravel.com/docs/responses#event-streams) in your React application. 184 | 185 | Provide your stream URL and the hook will automatically update `message` with the concatenated response as messages are returned from your server: 186 | 187 | ```tsx 188 | import { useEventStream } from "@laravel/stream-react"; 189 | 190 | function App() { 191 | const { message } = useEventStream("/stream"); 192 | 193 | return
{message}
; 194 | } 195 | ``` 196 | 197 | You also have access to the array of message parts: 198 | 199 | ```tsx 200 | import { useEventStream } from "@laravel/stream-react"; 201 | 202 | function App() { 203 | const { messageParts } = useEventStream("/stream"); 204 | 205 | return ( 206 |
    207 | {messageParts.forEach((message) => ( 208 |
  • {message}
  • 209 | ))} 210 |
211 | ); 212 | } 213 | ``` 214 | 215 | If you'd like to listen to multiple events: 216 | 217 | ```tsx 218 | import { useEventStream } from "@laravel/stream-react"; 219 | 220 | function App() { 221 | useEventStream("/stream", { 222 | eventName: ["update", "create"], 223 | onMessage: (event) => { 224 | if (event.type === "update") { 225 | // Handle update 226 | } else { 227 | // Handle create 228 | } 229 | }, 230 | }); 231 | 232 | return null; 233 | } 234 | ``` 235 | 236 | The second parameter is an options object where all properties are optional (defaults are shown below): 237 | 238 | ```tsx 239 | import { useEventStream } from "@laravel/stream-react"; 240 | 241 | function App() { 242 | const { message } = useEventStream("/stream", { 243 | event: "update", 244 | onMessage: (message) => { 245 | // 246 | }, 247 | onError: (error) => { 248 | // 249 | }, 250 | onComplete: () => { 251 | // 252 | }, 253 | endSignal: "", 254 | glue: " ", 255 | replace: false, 256 | }); 257 | 258 | return
{message}
; 259 | } 260 | ``` 261 | 262 | You can close the connection manually by using the returned `close` function: 263 | 264 | ```tsx 265 | import { useEventStream } from "@laravel/stream-react"; 266 | import { useEffect } from "react"; 267 | 268 | function App() { 269 | const { message, close } = useEventStream("/stream"); 270 | 271 | useEffect(() => { 272 | setTimeout(() => { 273 | close(); 274 | }, 3000); 275 | }, []); 276 | 277 | return
{message}
; 278 | } 279 | ``` 280 | 281 | The `clearMessage` function may be used to clear the message content that has been received so far: 282 | 283 | ```tsx 284 | import { useEventStream } from "@laravel/stream-react"; 285 | import { useEffect } from "react"; 286 | 287 | function App() { 288 | const { message, clearMessage } = useEventStream("/stream"); 289 | 290 | useEffect(() => { 291 | setTimeout(() => { 292 | clearMessage(); 293 | }, 3000); 294 | }, []); 295 | 296 | return
{message}
; 297 | } 298 | ``` 299 | 300 | ## License 301 | 302 | Laravel Stream is open-sourced software licensed under the [MIT license](LICENSE.md). 303 | -------------------------------------------------------------------------------- /packages/react/tests/use-event-stream.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from "@testing-library/react"; 2 | import { beforeEach, describe, expect, it, test, vi } from "vitest"; 3 | import { useEventStream } from "../src/hooks/use-event-stream"; 4 | 5 | describe("useEventStream", () => { 6 | let mocks; 7 | 8 | beforeEach(() => { 9 | vi.clearAllMocks(); 10 | vi.resetModules(); 11 | 12 | mocks = global.createEventSourceMock(); 13 | }); 14 | 15 | test("useEventStream initializes with default values", () => { 16 | const { result } = renderHook(() => useEventStream("/stream")); 17 | 18 | expect(result.current.message).toBe(""); 19 | expect(result.current.messageParts).toEqual([]); 20 | expect(typeof result.current.clearMessage).toBe("function"); 21 | expect(typeof result.current.close).toBe("function"); 22 | }); 23 | 24 | it("processes incoming messages correctly", async () => { 25 | const result = renderHook(() => useEventStream("/stream")).result; 26 | 27 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 28 | 29 | act(() => { 30 | eventHandler({ data: "Hello" }); 31 | }); 32 | 33 | expect(result.current.message).toBe("Hello"); 34 | expect(result.current.messageParts).toEqual(["Hello"]); 35 | 36 | act(() => { 37 | eventHandler({ data: "World" }); 38 | }); 39 | 40 | expect(result.current.message).toBe("Hello World"); 41 | expect(result.current.messageParts).toEqual(["Hello", "World"]); 42 | }); 43 | 44 | it("processes incoming messages correctly with replace option", async () => { 45 | const result = renderHook(() => 46 | useEventStream("/stream", { replace: true }), 47 | ).result; 48 | 49 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 50 | 51 | act(() => { 52 | eventHandler({ data: "Hello" }); 53 | }); 54 | 55 | expect(result.current.message).toBe("Hello"); 56 | expect(result.current.messageParts).toEqual(["Hello"]); 57 | 58 | act(() => { 59 | eventHandler({ data: "World" }); 60 | }); 61 | 62 | expect(result.current.message).toBe("World"); 63 | expect(result.current.messageParts).toEqual(["World"]); 64 | }); 65 | 66 | it("can clear the message", async () => { 67 | const result = renderHook(() => useEventStream("/stream")).result; 68 | 69 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 70 | 71 | act(() => { 72 | eventHandler({ data: "Hello" }); 73 | }); 74 | 75 | expect(result.current.message).toBe("Hello"); 76 | expect(result.current.messageParts).toEqual(["Hello"]); 77 | 78 | act(() => { 79 | eventHandler({ data: "World" }); 80 | }); 81 | 82 | expect(result.current.message).toBe("Hello World"); 83 | expect(result.current.messageParts).toEqual(["Hello", "World"]); 84 | 85 | act(() => { 86 | result.current.clearMessage(); 87 | }); 88 | 89 | expect(result.current.message).toBe(""); 90 | expect(result.current.messageParts).toEqual([]); 91 | }); 92 | 93 | it("can close the stream manually", async () => { 94 | const onCompleteMock = vi.fn(); 95 | const result = renderHook(() => 96 | useEventStream("/stream", { 97 | onComplete: onCompleteMock, 98 | }), 99 | ).result; 100 | 101 | act(() => { 102 | result.current.close(); 103 | }); 104 | 105 | expect(mocks.close).toHaveBeenCalled(); 106 | expect(onCompleteMock).not.toHaveBeenCalled(); 107 | }); 108 | 109 | it("can handle custom glue", async () => { 110 | const result = renderHook(() => 111 | useEventStream("/stream", { 112 | glue: "|", 113 | }), 114 | ).result; 115 | 116 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 117 | 118 | act(() => { 119 | eventHandler({ data: "Hello" }); 120 | }); 121 | 122 | expect(result.current.message).toBe("Hello"); 123 | expect(result.current.messageParts).toEqual(["Hello"]); 124 | 125 | act(() => { 126 | eventHandler({ data: "World" }); 127 | }); 128 | 129 | expect(result.current.message).toBe("Hello|World"); 130 | expect(result.current.messageParts).toEqual(["Hello", "World"]); 131 | }); 132 | 133 | it("handles end signal correctly", async () => { 134 | const onCompleteMock = vi.fn(); 135 | 136 | renderHook(() => 137 | useEventStream("/stream", { 138 | onComplete: onCompleteMock, 139 | }), 140 | ).result; 141 | 142 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 143 | 144 | act(() => { 145 | eventHandler({ data: "" }); 146 | }); 147 | 148 | expect(mocks.close).toHaveBeenCalled(); 149 | expect(onCompleteMock).toHaveBeenCalled(); 150 | }); 151 | 152 | test.each([{ endSignal: "WE DONE" }, { endSignal: "data: WE DONE" }])( 153 | "handles custom end signal correctly ($endSignal)", 154 | async ({ endSignal }) => { 155 | const onCompleteMock = vi.fn(); 156 | 157 | renderHook(() => 158 | useEventStream("/stream", { 159 | onComplete: onCompleteMock, 160 | endSignal: "WE DONE", 161 | }), 162 | ).result; 163 | 164 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 165 | 166 | act(() => { 167 | eventHandler({ data: endSignal }); 168 | }); 169 | 170 | expect(mocks.close).toHaveBeenCalled(); 171 | expect(onCompleteMock).toHaveBeenCalled(); 172 | }, 173 | ); 174 | 175 | it("handles errors correctly", async () => { 176 | const onErrorMock = vi.fn(); 177 | 178 | renderHook(() => 179 | useEventStream("/stream", { 180 | onError: onErrorMock, 181 | }), 182 | ).result; 183 | 184 | const errorHandler = mocks.addEventListener.mock.calls[1][1]; 185 | const testError = new Error("EventSource connection error"); 186 | 187 | act(() => { 188 | errorHandler(testError); 189 | }); 190 | 191 | expect(onErrorMock).toHaveBeenCalled(); 192 | const errorArg = onErrorMock.mock.calls[0][0]; 193 | expect(errorArg).toBeInstanceOf(Error); 194 | expect(errorArg.message).toBe("EventSource connection error"); 195 | expect(mocks.close).toHaveBeenCalled(); 196 | }); 197 | 198 | it("onMessage callback is called with incoming messages", async () => { 199 | const onMessageMock = vi.fn(); 200 | 201 | renderHook(() => 202 | useEventStream("/stream", { 203 | onMessage: onMessageMock, 204 | }), 205 | ).result; 206 | 207 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 208 | const testEvent = { data: "Test message" }; 209 | 210 | act(() => { 211 | eventHandler(testEvent); 212 | }); 213 | 214 | expect(onMessageMock).toHaveBeenCalledWith(testEvent); 215 | }); 216 | 217 | it("can handle multiple events", async () => { 218 | const onMessageMock = vi.fn(); 219 | 220 | renderHook(() => 221 | useEventStream("/stream", { 222 | onMessage: onMessageMock, 223 | eventName: ["message", "customEvent"], 224 | }), 225 | ).result; 226 | 227 | const eventHandler = mocks.addEventListener.mock.calls[0][1]; 228 | const testEvent1 = { data: "Test message", type: "message" }; 229 | const testEvent2 = { data: "Test custom event", type: "customEvent" }; 230 | 231 | act(() => { 232 | eventHandler(testEvent1); 233 | eventHandler(testEvent2); 234 | }); 235 | 236 | expect(onMessageMock).toHaveBeenCalledWith(testEvent1); 237 | expect(onMessageMock).toHaveBeenCalledWith(testEvent2); 238 | }); 239 | 240 | it("will ignore events we are not listening to", async () => { 241 | const onMessageMock = vi.fn(); 242 | 243 | renderHook(() => 244 | useEventStream("/stream", { 245 | onMessage: onMessageMock, 246 | eventName: ["message", "customEvent"], 247 | }), 248 | ).result; 249 | 250 | const testEvent1 = { data: "Test message", type: "message" }; 251 | const testEvent2 = { data: "Test custom event", type: "customEvent" }; 252 | const ignoredEvent = { data: "Ignored event", type: "ignoredEvent" }; 253 | 254 | act(() => { 255 | mocks.triggerEvent("message", testEvent1); 256 | mocks.triggerEvent("customEvent", testEvent2); 257 | mocks.triggerEvent("ignoredEvent", ignoredEvent); 258 | }); 259 | 260 | expect(onMessageMock).toHaveBeenCalledWith(testEvent1); 261 | expect(onMessageMock).toHaveBeenCalledWith(testEvent2); 262 | expect(onMessageMock).not.toHaveBeenCalledWith(ignoredEvent); 263 | }); 264 | 265 | it("cleans up EventSource on unmount", async () => { 266 | const result = renderHook(() => useEventStream("/stream")); 267 | 268 | result.unmount(); 269 | 270 | expect(mocks.close).toHaveBeenCalled(); 271 | expect(mocks.removeEventListener).toHaveBeenCalledTimes(2); 272 | }); 273 | 274 | it("reconnects when URL changes", async () => { 275 | const mockClose = vi.fn(); 276 | let eventSourceCount = 0; 277 | 278 | vi.stubGlobal( 279 | "EventSource", 280 | vi.fn().mockImplementation(() => { 281 | eventSourceCount++; 282 | return { 283 | addEventListener: vi.fn(), 284 | removeEventListener: vi.fn(), 285 | close: mockClose, 286 | }; 287 | }), 288 | ); 289 | 290 | const { rerender } = renderHook((props) => useEventStream(props.url), { 291 | initialProps: { url: "/stream1" }, 292 | }); 293 | 294 | expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(1); 295 | 296 | rerender({ url: "/stream2" }); 297 | 298 | expect(mockClose).toHaveBeenCalled(); 299 | expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(2); 300 | expect(vi.mocked(EventSource)).toHaveBeenLastCalledWith("/stream2"); 301 | }); 302 | }); 303 | -------------------------------------------------------------------------------- /packages/react/tests/use-stream.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook, waitFor } from "@testing-library/react"; 2 | import { delay, http, HttpResponse } from "msw"; 3 | import { setupServer } from "msw/node"; 4 | import { 5 | afterAll, 6 | afterEach, 7 | beforeAll, 8 | describe, 9 | expect, 10 | it, 11 | vi, 12 | } from "vitest"; 13 | import { useJsonStream, useStream } from "../src/hooks/use-stream"; 14 | 15 | describe("useStream", () => { 16 | const url = "/chat"; 17 | const response = async (duration = 20) => { 18 | await delay(duration); 19 | 20 | return new HttpResponse( 21 | new ReadableStream({ 22 | async start(controller) { 23 | await delay(duration); 24 | controller.enqueue(new TextEncoder().encode("chunk1")); 25 | 26 | await delay(duration); 27 | controller.enqueue(new TextEncoder().encode("chunk2")); 28 | controller.close(); 29 | }, 30 | }), 31 | { 32 | status: 200, 33 | headers: { 34 | "Content-Type": "text/event-stream", 35 | }, 36 | }, 37 | ); 38 | }; 39 | 40 | const server = setupServer( 41 | http.post(url, async () => { 42 | return await response(); 43 | }), 44 | ); 45 | 46 | beforeAll(() => server.listen()); 47 | afterEach(() => { 48 | vi.clearAllMocks(); 49 | server.resetHandlers(); 50 | }); 51 | afterAll(() => server.close()); 52 | 53 | it("should initialize with default values", () => { 54 | const { result } = renderHook(() => useStream(url)); 55 | 56 | expect(result.current.data).toBe(""); 57 | expect(result.current.isFetching).toBe(false); 58 | expect(result.current.isStreaming).toBe(false); 59 | expect(result.current.id).toBeDefined(); 60 | expect(result.current.id).toBeTypeOf("string"); 61 | }); 62 | 63 | it("should make a request with initial input", async () => { 64 | const initialInput = { test: "data" }; 65 | 66 | const { result } = await act(async () => { 67 | return renderHook(() => useStream(url, { initialInput })); 68 | }); 69 | 70 | await waitFor(() => expect(result.current.isFetching).toBe(true)); 71 | await waitFor(() => expect(result.current.isFetching).toBe(false)); 72 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 73 | await waitFor(() => expect(result.current.data).toBe("chunk1")); 74 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 75 | 76 | expect(result.current.isStreaming).toBe(false); 77 | expect(result.current.data).toBe("chunk1chunk2"); 78 | 79 | await act(() => { 80 | result.current.clearData(); 81 | }); 82 | 83 | expect(result.current.data).toBe(""); 84 | }); 85 | 86 | it("can send data back to the endpoint", async () => { 87 | const payload = { test: "data" }; 88 | let capturedBody: any; 89 | const onCancel = vi.fn(); 90 | 91 | server.use( 92 | http.post(url, async ({ request }) => { 93 | capturedBody = await request.json(); 94 | return response(); 95 | }), 96 | ); 97 | 98 | const { result } = renderHook(() => useStream(url, { onCancel })); 99 | 100 | act(() => { 101 | result.current.send(payload); 102 | }); 103 | 104 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 105 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 106 | 107 | expect(capturedBody).toEqual(payload); 108 | expect(result.current.data).toBe("chunk1chunk2"); 109 | expect(result.current.isStreaming).toBe(false); 110 | expect(onCancel).not.toHaveBeenCalled(); 111 | }); 112 | 113 | it("will trigger the onResponse callback", async () => { 114 | const payload = { test: "data" }; 115 | const onResponse = vi.fn(); 116 | 117 | const { result } = renderHook(() => 118 | useStream(url, { 119 | onResponse, 120 | }), 121 | ); 122 | 123 | act(() => { 124 | result.current.send(payload); 125 | }); 126 | 127 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 128 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 129 | 130 | expect(onResponse).toHaveBeenCalled(); 131 | }); 132 | 133 | it("will trigger the onFinish callback", async () => { 134 | const payload = { test: "data" }; 135 | const onFinish = vi.fn(); 136 | 137 | const { result } = renderHook(() => 138 | useStream(url, { 139 | onFinish, 140 | }), 141 | ); 142 | 143 | act(() => { 144 | result.current.send(payload); 145 | }); 146 | 147 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 148 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 149 | 150 | expect(onFinish).toHaveBeenCalled(); 151 | }); 152 | 153 | it("will trigger the onBeforeSend callback", async () => { 154 | const payload = { test: "data" }; 155 | const onBeforeSend = vi.fn(); 156 | 157 | const { result } = renderHook(() => 158 | useStream(url, { 159 | onBeforeSend, 160 | }), 161 | ); 162 | 163 | act(() => { 164 | result.current.send(payload); 165 | }); 166 | 167 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 168 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 169 | 170 | expect(onBeforeSend).toHaveBeenCalled(); 171 | }); 172 | 173 | it("can cancel via the onBeforeSend callback", async () => { 174 | const payload = { test: "data" }; 175 | const onBeforeSend = vi.fn(() => false); 176 | let requested = false; 177 | 178 | server.use( 179 | http.post(url, async () => { 180 | requested = true; 181 | return response(); 182 | }), 183 | ); 184 | 185 | const { result } = renderHook(() => 186 | useStream(url, { 187 | onBeforeSend, 188 | }), 189 | ); 190 | 191 | act(() => { 192 | result.current.send(payload); 193 | }); 194 | 195 | expect(onBeforeSend).toHaveBeenCalled(); 196 | expect(requested).toBe(false); 197 | }); 198 | 199 | it("can modify the request via the onBeforeSend callback", async () => { 200 | const payload = { test: "data" }; 201 | const onBeforeSend = vi.fn((request) => ({ 202 | ...request, 203 | body: JSON.stringify({ modified: true }), 204 | })); 205 | let capturedBody; 206 | 207 | server.use( 208 | http.post(url, async ({ request }) => { 209 | capturedBody = await request.json(); 210 | return response(); 211 | }), 212 | ); 213 | 214 | const { result } = renderHook(() => 215 | useStream(url, { 216 | onBeforeSend, 217 | }), 218 | ); 219 | 220 | act(() => { 221 | result.current.send(payload); 222 | }); 223 | 224 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 225 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 226 | 227 | expect(onBeforeSend).toHaveBeenCalled(); 228 | expect(capturedBody).toEqual({ modified: true }); 229 | }); 230 | 231 | it("will trigger the onData callback", async () => { 232 | const payload = { test: "data" }; 233 | const onData = vi.fn(); 234 | 235 | const { result } = renderHook(() => 236 | useStream(url, { 237 | onData, 238 | }), 239 | ); 240 | 241 | act(() => { 242 | result.current.send(payload); 243 | }); 244 | 245 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 246 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 247 | 248 | expect(onData).toHaveBeenCalledWith("chunk1"); 249 | expect(onData).toHaveBeenCalledWith("chunk2"); 250 | }); 251 | 252 | it("should handle errors correctly", async () => { 253 | const errorMessage = "Serve error"; 254 | server.use( 255 | http.post(url, async () => { 256 | return new HttpResponse(errorMessage, { 257 | status: 500, 258 | headers: { 259 | "Content-Type": "application/json", 260 | }, 261 | }); 262 | }), 263 | ); 264 | 265 | const onError = vi.fn(); 266 | const onFinish = vi.fn(); 267 | const { result } = renderHook(() => 268 | useStream(url, { onError, onFinish }), 269 | ); 270 | 271 | act(() => { 272 | result.current.send({ test: "data" }); 273 | }); 274 | 275 | await waitFor(() => expect(result.current.isFetching).toBe(true)); 276 | await waitFor(() => expect(result.current.isFetching).toBe(false)); 277 | 278 | expect(onError).toHaveBeenCalledWith(new Error(errorMessage)); 279 | expect(onFinish).toHaveBeenCalled(); 280 | expect(result.current.isFetching).toBe(false); 281 | expect(result.current.isStreaming).toBe(false); 282 | }); 283 | 284 | it("should handle network errors correctly", async () => { 285 | server.use( 286 | http.post(url, async () => { 287 | return HttpResponse.error(); 288 | }), 289 | ); 290 | 291 | const onError = vi.fn(); 292 | const onFinish = vi.fn(); 293 | const { result } = renderHook(() => 294 | useStream(url, { onError, onFinish }), 295 | ); 296 | 297 | await act(() => { 298 | result.current.send({ test: "data" }); 299 | }); 300 | 301 | expect(onError).toHaveBeenCalled(); 302 | expect(onFinish).toHaveBeenCalled(); 303 | expect(result.current.isFetching).toBe(false); 304 | expect(result.current.isStreaming).toBe(false); 305 | }); 306 | 307 | it("should stop streaming when stop is called", async () => { 308 | const onCancel = vi.fn(); 309 | const { result } = renderHook(() => useStream(url, { onCancel })); 310 | 311 | act(() => { 312 | result.current.send({ test: "data" }); 313 | }); 314 | 315 | await waitFor(() => expect(result.current.data).toBe("chunk1")); 316 | act(() => { 317 | result.current.cancel(); 318 | }); 319 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 320 | 321 | expect(result.current.isStreaming).toBe(false); 322 | expect(result.current.data).toBe("chunk1"); 323 | expect(onCancel).toHaveBeenCalled(); 324 | }); 325 | 326 | it("should handle custom headers", async () => { 327 | const customHeaders = { "X-Custom-Header": "test" }; 328 | let capturedHeaders: any; 329 | 330 | server.use( 331 | http.post(url, async ({ request }) => { 332 | capturedHeaders = request.headers; 333 | return response(); 334 | }), 335 | ); 336 | 337 | const { result } = renderHook(() => 338 | useStream(url, { headers: customHeaders }), 339 | ); 340 | 341 | await act(() => { 342 | result.current.send({ test: "data" }); 343 | }); 344 | 345 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 346 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 347 | expect(capturedHeaders.get("X-Custom-Header")).toBe( 348 | customHeaders["X-Custom-Header"], 349 | ); 350 | expect(capturedHeaders.get("Content-Type")).toBe("application/json"); 351 | }); 352 | 353 | it("should handle CSRF token from meta tag", async () => { 354 | const csrfToken = "test-csrf-token"; 355 | const metaTag = document.createElement("meta"); 356 | metaTag.setAttribute("name", "csrf-token"); 357 | metaTag.setAttribute("content", csrfToken); 358 | document.head.appendChild(metaTag); 359 | let capturedHeaders: any; 360 | 361 | server.use( 362 | http.post(url, async ({ request }) => { 363 | capturedHeaders = request.headers; 364 | return response(); 365 | }), 366 | ); 367 | 368 | const { result } = renderHook(() => useStream(url)); 369 | 370 | await act(() => { 371 | result.current.send({ test: "data" }); 372 | }); 373 | 374 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 375 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 376 | 377 | document.head.removeChild(metaTag); 378 | expect(capturedHeaders.get("X-CSRF-TOKEN")).toBe(csrfToken); 379 | expect(capturedHeaders.get("Content-Type")).toBe("application/json"); 380 | }); 381 | 382 | it("should handle CSRF token from passed option", async () => { 383 | const csrfToken = "test-csrf-token"; 384 | let capturedHeaders: any; 385 | 386 | server.use( 387 | http.post(url, async ({ request }) => { 388 | capturedHeaders = request.headers; 389 | return response(); 390 | }), 391 | ); 392 | 393 | const { result } = renderHook(() => useStream(url, { csrfToken })); 394 | 395 | await act(() => { 396 | result.current.send({ test: "data" }); 397 | }); 398 | 399 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 400 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 401 | 402 | expect(capturedHeaders.get("X-CSRF-TOKEN")).toBe(csrfToken); 403 | expect(capturedHeaders.get("Content-Type")).toBe("application/json"); 404 | }); 405 | 406 | it("will generate unique ids for streams", async () => { 407 | const { result } = renderHook(() => useStream(url)); 408 | const { result: result2 } = renderHook(() => useStream(url)); 409 | 410 | expect(result.current.id).toBeTypeOf("string"); 411 | expect(result2.current.id).toBeTypeOf("string"); 412 | expect(result.current.id).not.toBe(result2.current.id); 413 | }); 414 | 415 | it("will sync streams with the same id", async () => { 416 | const payload = { test: "data" }; 417 | const id = "test-stream-id"; 418 | const onFinish = vi.fn(); 419 | let capturedHeaders: any; 420 | 421 | server.use( 422 | http.post(url, async ({ request }) => { 423 | capturedHeaders = request.headers; 424 | return response(); 425 | }), 426 | ); 427 | 428 | const { result } = renderHook(() => useStream(url, { id })); 429 | const { result: result2 } = renderHook(() => 430 | useStream(url, { id, onFinish }), 431 | ); 432 | 433 | await act(() => { 434 | result.current.send(payload); 435 | }); 436 | 437 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 438 | await waitFor(() => expect(result2.current.isStreaming).toBe(true)); 439 | await waitFor(() => expect(result.current.data).toBe("chunk1")); 440 | await waitFor(() => expect(result2.current.data).toBe("chunk1")); 441 | await waitFor(() => expect(result.current.data).toBe("chunk1chunk2")); 442 | await waitFor(() => expect(result2.current.data).toBe("chunk1chunk2")); 443 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 444 | await waitFor(() => expect(result2.current.isStreaming).toBe(false)); 445 | 446 | expect(result.current.isStreaming).toBe(false); 447 | expect(result2.current.isStreaming).toBe(false); 448 | 449 | expect(result.current.data).toBe("chunk1chunk2"); 450 | expect(result2.current.data).toBe("chunk1chunk2"); 451 | 452 | expect(capturedHeaders.get("X-STREAM-ID")).toBe(id); 453 | 454 | expect(onFinish).toHaveBeenCalled(); 455 | }); 456 | 457 | it.skip("should cancel stream when component unmounts", async () => { 458 | const onCancel = vi.fn(); 459 | const { unmount, result } = renderHook(() => 460 | useStream(url, { onCancel }), 461 | ); 462 | 463 | await act(() => { 464 | result.current.send({ 465 | test: "ok", 466 | }); 467 | }); 468 | 469 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 470 | 471 | unmount(); 472 | 473 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 474 | 475 | expect(onCancel).toHaveBeenCalled(); 476 | }); 477 | 478 | it("should parse JSON data when json option is true", async () => { 479 | const jsonData = { test: "data", value: 123 }; 480 | 481 | server.use( 482 | http.post(url, async () => { 483 | return new HttpResponse( 484 | new ReadableStream({ 485 | async start(controller) { 486 | await delay(20); 487 | controller.enqueue( 488 | new TextEncoder().encode('{"test":"data",'), 489 | ); 490 | 491 | await delay(20); 492 | controller.enqueue( 493 | new TextEncoder().encode('"value":123}'), 494 | ); 495 | 496 | controller.close(); 497 | }, 498 | }), 499 | { 500 | status: 200, 501 | headers: { 502 | "Content-Type": "application/json", 503 | }, 504 | }, 505 | ); 506 | }), 507 | ); 508 | 509 | const { result } = renderHook(() => useStream(url, { json: true })); 510 | 511 | await act(() => { 512 | result.current.send({}); 513 | }); 514 | 515 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 516 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 517 | 518 | expect(result.current.data).toEqual(JSON.stringify(jsonData)); 519 | expect(result.current.jsonData).toEqual(jsonData); 520 | }); 521 | 522 | it("should handle JSON parsing errors", async () => { 523 | const invalidJson = "{invalid json}"; 524 | const onError = vi.fn(); 525 | 526 | server.use( 527 | http.post(url, async () => { 528 | return new HttpResponse( 529 | new ReadableStream({ 530 | async start(controller) { 531 | await delay(20); 532 | controller.enqueue( 533 | new TextEncoder().encode(invalidJson), 534 | ); 535 | controller.close(); 536 | }, 537 | }), 538 | { 539 | status: 200, 540 | headers: { 541 | "Content-Type": "application/json", 542 | }, 543 | }, 544 | ); 545 | }), 546 | ); 547 | 548 | const { result } = renderHook(() => 549 | useStream(url, { json: true, onError }), 550 | ); 551 | 552 | await act(() => { 553 | result.current.send({}); 554 | }); 555 | 556 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 557 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 558 | 559 | expect(onError).toHaveBeenCalled(); 560 | 561 | expect(result.current.data).toBe(invalidJson); 562 | expect(result.current.jsonData).toBeNull(); 563 | }); 564 | 565 | it("should parse JSON data when json option is true (useJsonStream)", async () => { 566 | const jsonData = { test: "data", value: 123 }; 567 | 568 | server.use( 569 | http.post(url, async () => { 570 | return new HttpResponse( 571 | new ReadableStream({ 572 | async start(controller) { 573 | await delay(20); 574 | controller.enqueue( 575 | new TextEncoder().encode('{"test":"data",'), 576 | ); 577 | 578 | await delay(20); 579 | controller.enqueue( 580 | new TextEncoder().encode('"value":123}'), 581 | ); 582 | 583 | controller.close(); 584 | }, 585 | }), 586 | { 587 | status: 200, 588 | headers: { 589 | "Content-Type": "application/json", 590 | }, 591 | }, 592 | ); 593 | }), 594 | ); 595 | 596 | const { result } = renderHook(() => useJsonStream(url)); 597 | 598 | await act(() => { 599 | result.current.send({}); 600 | }); 601 | 602 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 603 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 604 | 605 | expect(result.current.data).toEqual(jsonData); 606 | expect(result.current.strData).toEqual(JSON.stringify(jsonData)); 607 | }); 608 | 609 | it("should handle JSON parsing errors (useJsonStream)", async () => { 610 | const invalidJson = "{invalid json}"; 611 | const onError = vi.fn(); 612 | 613 | server.use( 614 | http.post(url, async () => { 615 | return new HttpResponse( 616 | new ReadableStream({ 617 | async start(controller) { 618 | await delay(20); 619 | controller.enqueue( 620 | new TextEncoder().encode(invalidJson), 621 | ); 622 | controller.close(); 623 | }, 624 | }), 625 | { 626 | status: 200, 627 | headers: { 628 | "Content-Type": "application/json", 629 | }, 630 | }, 631 | ); 632 | }), 633 | ); 634 | 635 | const { result } = renderHook(() => useJsonStream(url, { onError })); 636 | 637 | await act(() => { 638 | result.current.send({}); 639 | }); 640 | 641 | await waitFor(() => expect(result.current.isStreaming).toBe(true)); 642 | await waitFor(() => expect(result.current.isStreaming).toBe(false)); 643 | 644 | expect(onError).toHaveBeenCalled(); 645 | 646 | expect(result.current.data).toBeNull(); 647 | expect(result.current.strData).toBe(invalidJson); 648 | }); 649 | }); 650 | -------------------------------------------------------------------------------- /packages/vue/tests/useStream.test.ts: -------------------------------------------------------------------------------- 1 | import { delay, http, HttpResponse } from "msw"; 2 | import { setupServer } from "msw/node"; 3 | import { 4 | afterAll, 5 | afterEach, 6 | beforeAll, 7 | beforeEach, 8 | describe, 9 | expect, 10 | it, 11 | vi, 12 | } from "vitest"; 13 | import { App, createApp, ref } from "vue"; 14 | import { useJsonStream, useStream } from "../src/composables/useStream"; 15 | 16 | function withSetup(composable: () => T): [T, App] { 17 | let result; 18 | 19 | const app = createApp({ 20 | setup() { 21 | result = composable(); 22 | return () => {}; 23 | }, 24 | }); 25 | 26 | app.mount(document.createElement("div")); 27 | 28 | return [result as T, app]; 29 | } 30 | 31 | describe("useStream", () => { 32 | const url = "/stream"; 33 | const response = async (duration = 20) => { 34 | await delay(duration); 35 | 36 | return new HttpResponse( 37 | new ReadableStream({ 38 | async start(controller) { 39 | await delay(duration); 40 | controller.enqueue(new TextEncoder().encode("chunk1")); 41 | 42 | await delay(duration); 43 | controller.enqueue(new TextEncoder().encode("chunk2")); 44 | controller.close(); 45 | }, 46 | }), 47 | { 48 | status: 200, 49 | headers: { 50 | "Content-Type": "text/event-stream", 51 | }, 52 | }, 53 | ); 54 | }; 55 | 56 | const server = setupServer( 57 | http.post(url, async () => { 58 | return await response(); 59 | }), 60 | ); 61 | 62 | beforeAll(() => server.listen()); 63 | afterEach(() => { 64 | vi.clearAllMocks(); 65 | server.resetHandlers(); 66 | }); 67 | afterAll(() => server.close()); 68 | 69 | it("initializes with default values", () => { 70 | const [result] = withSetup(() => useStream(url)); 71 | 72 | expect(result.data.value).toBe(""); 73 | expect(result.isFetching.value).toBe(false); 74 | expect(result.isStreaming.value).toBe(false); 75 | expect(result.id).toBeDefined(); 76 | expect(result.id).toBeTypeOf("string"); 77 | }); 78 | 79 | it("makes a request with initial input", async () => { 80 | const initialInput = { test: "data" }; 81 | 82 | const [result] = withSetup(() => useStream(url, { initialInput })); 83 | 84 | await vi.waitFor(() => expect(result.isFetching.value).toBe(true)); 85 | await vi.waitFor(() => expect(result.isFetching.value).toBe(false)); 86 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 87 | await vi.waitFor(() => expect(result.data.value).toBe("chunk1")); 88 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 89 | 90 | expect(result.data.value).toBe("chunk1chunk2"); 91 | 92 | result.clearData(); 93 | 94 | expect(result.data.value).toBe(""); 95 | }); 96 | 97 | it("can send data to the endpoint", async () => { 98 | const payload = { test: "data" }; 99 | let capturedBody: any; 100 | 101 | server.use( 102 | http.post(url, async ({ request }) => { 103 | capturedBody = await request.json(); 104 | return response(); 105 | }), 106 | ); 107 | 108 | const [result] = withSetup(() => useStream(url)); 109 | 110 | result.send(payload); 111 | 112 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 113 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 114 | 115 | expect(capturedBody).toEqual(payload); 116 | expect(result.data.value).toBe("chunk1chunk2"); 117 | }); 118 | 119 | it("triggers onResponse callback", async () => { 120 | const onResponse = vi.fn(); 121 | 122 | const [result] = withSetup(() => useStream(url, { onResponse })); 123 | 124 | result.send({ test: "data" }); 125 | 126 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 127 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 128 | 129 | expect(onResponse).toHaveBeenCalled(); 130 | }); 131 | 132 | it("triggers onFinish callback", async () => { 133 | const onFinish = vi.fn(); 134 | 135 | const [result] = withSetup(() => useStream(url, { onFinish })); 136 | 137 | result.send({ test: "data" }); 138 | 139 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 140 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 141 | 142 | expect(onFinish).toHaveBeenCalled(); 143 | }); 144 | 145 | it("triggers onBeforeSend callback", async () => { 146 | const onBeforeSend = vi.fn(); 147 | 148 | const [result] = withSetup(() => useStream(url, { onBeforeSend })); 149 | 150 | result.send({ test: "data" }); 151 | 152 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 153 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 154 | 155 | expect(onBeforeSend).toHaveBeenCalled(); 156 | }); 157 | 158 | it("can cancel a call via onBeforeSend callback", async () => { 159 | const onBeforeSend = vi.fn(() => false); 160 | let requested = false; 161 | 162 | server.use( 163 | http.post(url, async () => { 164 | requested = true; 165 | return response(); 166 | }), 167 | ); 168 | 169 | const [result] = withSetup(() => useStream(url, { onBeforeSend })); 170 | 171 | result.send({ test: "data" }); 172 | 173 | expect(onBeforeSend).toHaveBeenCalled(); 174 | expect(requested).toBe(false); 175 | }); 176 | 177 | it("can modify a request via onBeforeSend callback", async () => { 178 | const onBeforeSend = vi.fn((request) => ({ 179 | ...request, 180 | body: JSON.stringify({ modified: true }), 181 | })); 182 | 183 | let capturedBody; 184 | 185 | server.use( 186 | http.post(url, async ({ request }) => { 187 | capturedBody = await request.json(); 188 | return response(); 189 | }), 190 | ); 191 | 192 | const [result] = withSetup(() => useStream(url, { onBeforeSend })); 193 | 194 | result.send({ test: "data" }); 195 | 196 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 197 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 198 | 199 | expect(onBeforeSend).toHaveBeenCalled(); 200 | expect(capturedBody).toEqual({ modified: true }); 201 | }); 202 | 203 | it("triggers onData callback with chunks", async () => { 204 | const onData = vi.fn(); 205 | 206 | const [result] = withSetup(() => useStream(url, { onData })); 207 | 208 | result.send({ test: "data" }); 209 | 210 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 211 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 212 | 213 | expect(onData).toHaveBeenCalledWith("chunk1"); 214 | expect(onData).toHaveBeenCalledWith("chunk2"); 215 | }); 216 | 217 | it("handles errors correctly", async () => { 218 | const errorMessage = "Server error"; 219 | server.use( 220 | http.post(url, async () => { 221 | return new HttpResponse(errorMessage, { 222 | status: 500, 223 | headers: { 224 | "Content-Type": "application/json", 225 | }, 226 | }); 227 | }), 228 | ); 229 | 230 | const onError = vi.fn(); 231 | const onFinish = vi.fn(); 232 | const [result] = withSetup(() => useStream(url, { onError, onFinish })); 233 | 234 | result.send({ test: "data" }); 235 | 236 | await vi.waitFor(() => expect(result.isFetching.value).toBe(true)); 237 | await vi.waitFor(() => expect(result.isFetching.value).toBe(false)); 238 | 239 | expect(onError).toHaveBeenCalledWith(new Error(errorMessage)); 240 | expect(onFinish).toHaveBeenCalled(); 241 | expect(result.isFetching.value).toBe(false); 242 | expect(result.isStreaming.value).toBe(false); 243 | }); 244 | 245 | it("can cancel the stream", async () => { 246 | const onCancel = vi.fn(); 247 | const [result] = withSetup(() => useStream(url, { onCancel })); 248 | 249 | result.send({ test: "data" }); 250 | await vi.waitFor(() => expect(result.data.value).toBe("chunk1")); 251 | 252 | result.cancel(); 253 | 254 | expect(result.isStreaming.value).toBe(false); 255 | expect(onCancel).toHaveBeenCalled(); 256 | }); 257 | 258 | it("handles CSRF token from meta tag", async () => { 259 | const csrfToken = "test-csrf-token"; 260 | const metaTag = document.createElement("meta"); 261 | metaTag.setAttribute("name", "csrf-token"); 262 | metaTag.setAttribute("content", csrfToken); 263 | document.head.appendChild(metaTag); 264 | 265 | let capturedHeaders: any; 266 | 267 | server.use( 268 | http.post(url, async ({ request }) => { 269 | capturedHeaders = request.headers; 270 | return response(); 271 | }), 272 | ); 273 | 274 | const [result] = withSetup(() => useStream(url)); 275 | 276 | result.send({ test: "data" }); 277 | 278 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 279 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 280 | 281 | document.head.removeChild(metaTag); 282 | expect(capturedHeaders.get("X-CSRF-TOKEN")).toBe(csrfToken); 283 | expect(capturedHeaders.get("Content-Type")).toBe("application/json"); 284 | }); 285 | 286 | it("handles CSRF token from options", async () => { 287 | const csrfToken = "test-csrf-token"; 288 | let capturedHeaders: any; 289 | 290 | server.use( 291 | http.post(url, async ({ request }) => { 292 | capturedHeaders = request.headers; 293 | return response(); 294 | }), 295 | ); 296 | 297 | const [result] = withSetup(() => useStream(url, { csrfToken })); 298 | 299 | result.send({ test: "data" }); 300 | 301 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 302 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 303 | 304 | expect(capturedHeaders.get("X-CSRF-TOKEN")).toBe(csrfToken); 305 | expect(capturedHeaders.get("Content-Type")).toBe("application/json"); 306 | }); 307 | 308 | it("generates unique ids for streams", () => { 309 | const [result1] = withSetup(() => useStream(url)); 310 | const [result2] = withSetup(() => useStream(url)); 311 | 312 | expect(result1.id).toBeTypeOf("string"); 313 | expect(result2.id).toBeTypeOf("string"); 314 | expect(result1.id).not.toBe(result2.id); 315 | }); 316 | 317 | it("syncs streams with the same id", async () => { 318 | const id = "test-stream-id"; 319 | const onFinish = vi.fn(); 320 | let capturedHeaders: any; 321 | 322 | server.use( 323 | http.post(url, async ({ request }) => { 324 | capturedHeaders = request.headers; 325 | return response(); 326 | }), 327 | ); 328 | 329 | const [result1] = withSetup(() => useStream(url, { id })); 330 | const [result2] = withSetup(() => useStream(url, { id, onFinish })); 331 | 332 | result1.send({ test: "data" }); 333 | 334 | await vi.waitFor(() => expect(result1.isStreaming.value).toBe(true)); 335 | await vi.waitFor(() => expect(result2.isStreaming.value).toBe(true)); 336 | await vi.waitFor(() => expect(result1.data.value).toBe("chunk1")); 337 | await vi.waitFor(() => expect(result2.data.value).toBe("chunk1")); 338 | await vi.waitFor(() => expect(result1.data.value).toBe("chunk1chunk2")); 339 | await vi.waitFor(() => expect(result2.data.value).toBe("chunk1chunk2")); 340 | await vi.waitFor(() => expect(result1.isStreaming.value).toBe(false)); 341 | await vi.waitFor(() => expect(result2.isStreaming.value).toBe(false)); 342 | 343 | expect(result1.data.value).toBe("chunk1chunk2"); 344 | expect(result2.data.value).toBe("chunk1chunk2"); 345 | 346 | expect(capturedHeaders.get("X-STREAM-ID")).toBe(id); 347 | 348 | expect(onFinish).toHaveBeenCalled(); 349 | }); 350 | 351 | it("parses JSON data when json option is true", async () => { 352 | const jsonData = { test: "data", value: 123 }; 353 | 354 | server.use( 355 | http.post(url, async () => { 356 | await delay(20); 357 | 358 | return new HttpResponse( 359 | new ReadableStream({ 360 | async start(controller) { 361 | await delay(20); 362 | controller.enqueue( 363 | new TextEncoder().encode('{"test":"data",'), 364 | ); 365 | 366 | await delay(20); 367 | controller.enqueue( 368 | new TextEncoder().encode('"value":123}'), 369 | ); 370 | 371 | controller.close(); 372 | }, 373 | }), 374 | { 375 | status: 200, 376 | headers: { 377 | "Content-Type": "application/json", 378 | }, 379 | }, 380 | ); 381 | }), 382 | ); 383 | 384 | const [result] = withSetup(() => useStream(url, { json: true })); 385 | 386 | result.send(); 387 | 388 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 389 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 390 | 391 | expect(result.data.value).toBe(JSON.stringify(jsonData)); 392 | expect(result.jsonData.value).toEqual(jsonData); 393 | }); 394 | 395 | it("handles JSON parsing errors", async () => { 396 | const invalidJson = "{invalid json}"; 397 | const onError = vi.fn(); 398 | 399 | server.use( 400 | http.post(url, async () => { 401 | await delay(50); 402 | 403 | return new HttpResponse( 404 | new ReadableStream({ 405 | async start(controller) { 406 | await delay(50); 407 | controller.enqueue( 408 | new TextEncoder().encode(invalidJson), 409 | ); 410 | controller.close(); 411 | }, 412 | }), 413 | { 414 | status: 200, 415 | headers: { 416 | "Content-Type": "application/json", 417 | }, 418 | }, 419 | ); 420 | }), 421 | ); 422 | 423 | const [result] = withSetup(() => 424 | useStream(url, { json: true, onError }), 425 | ); 426 | 427 | result.send(); 428 | 429 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 430 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 431 | 432 | expect(onError).toHaveBeenCalled(); 433 | expect(result.data.value).toBe(invalidJson); 434 | expect(result.jsonData.value).toBeNull(); 435 | }); 436 | 437 | it("parses JSON data when json option is true (useJsonStream)", async () => { 438 | const jsonData = { test: "data", value: 123 }; 439 | 440 | server.use( 441 | http.post(url, async () => { 442 | await delay(20); 443 | 444 | return new HttpResponse( 445 | new ReadableStream({ 446 | async start(controller) { 447 | await delay(20); 448 | controller.enqueue( 449 | new TextEncoder().encode('{"test":"data",'), 450 | ); 451 | 452 | await delay(20); 453 | controller.enqueue( 454 | new TextEncoder().encode('"value":123}'), 455 | ); 456 | 457 | controller.close(); 458 | }, 459 | }), 460 | { 461 | status: 200, 462 | headers: { 463 | "Content-Type": "application/json", 464 | }, 465 | }, 466 | ); 467 | }), 468 | ); 469 | 470 | const [result] = withSetup(() => useJsonStream(url)); 471 | 472 | result.send(); 473 | 474 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 475 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 476 | 477 | expect(result.data.value).toEqual(jsonData); 478 | expect(result.strData.value).toBe(JSON.stringify(jsonData)); 479 | }); 480 | 481 | it("handles JSON parsing errors (useJsonStream)", async () => { 482 | const invalidJson = "{invalid json}"; 483 | const onError = vi.fn(); 484 | 485 | server.use( 486 | http.post(url, async () => { 487 | await delay(50); 488 | 489 | return new HttpResponse( 490 | new ReadableStream({ 491 | async start(controller) { 492 | await delay(50); 493 | controller.enqueue( 494 | new TextEncoder().encode(invalidJson), 495 | ); 496 | controller.close(); 497 | }, 498 | }), 499 | { 500 | status: 200, 501 | headers: { 502 | "Content-Type": "application/json", 503 | }, 504 | }, 505 | ); 506 | }), 507 | ); 508 | 509 | const [result] = withSetup(() => useJsonStream(url, { onError })); 510 | 511 | result.send(); 512 | 513 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 514 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(false)); 515 | 516 | expect(onError).toHaveBeenCalled(); 517 | expect(result.data.value).toBeNull(); 518 | expect(result.strData.value).toBe(invalidJson); 519 | }); 520 | 521 | describe("url reactivity", () => { 522 | const jsonData = [ 523 | { api: "/stream/1", data: { test: "data1", value: 123 } }, 524 | { api: "/stream/2", data: { test: "data2", value: 456 } }, 525 | ]; 526 | 527 | beforeEach(() => 528 | server.use( 529 | http.post(jsonData[0].api, async () => { 530 | await delay(20); 531 | 532 | return new HttpResponse( 533 | new ReadableStream({ 534 | async start(controller) { 535 | await delay(20); 536 | controller.enqueue( 537 | new TextEncoder().encode( 538 | '{"test":"data1",', 539 | ), 540 | ); 541 | 542 | await delay(20); 543 | controller.enqueue( 544 | new TextEncoder().encode('"value":123}'), 545 | ); 546 | 547 | controller.close(); 548 | }, 549 | }), 550 | { 551 | status: 200, 552 | headers: { 553 | "Content-Type": "application/json", 554 | }, 555 | }, 556 | ); 557 | }), 558 | http.post(jsonData[1].api, async () => { 559 | await delay(20); 560 | 561 | return new HttpResponse( 562 | new ReadableStream({ 563 | async start(controller) { 564 | await delay(20); 565 | controller.enqueue( 566 | new TextEncoder().encode( 567 | '{"test":"data2",', 568 | ), 569 | ); 570 | 571 | await delay(20); 572 | controller.enqueue( 573 | new TextEncoder().encode('"value":456}'), 574 | ); 575 | 576 | controller.close(); 577 | }, 578 | }), 579 | { 580 | status: 200, 581 | headers: { 582 | "Content-Type": "application/json", 583 | }, 584 | }, 585 | ); 586 | }), 587 | ), 588 | ); 589 | 590 | it("reacts when url is a ref", async () => { 591 | const urlRef = ref(jsonData[0].api); 592 | 593 | const [result] = withSetup(() => useStream(urlRef, { json: true })); 594 | 595 | result.send(); 596 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 597 | await vi.waitFor(() => 598 | expect(result.isStreaming.value).toBe(false), 599 | ); 600 | await vi.waitFor(() => 601 | expect(result.data.value).toBe( 602 | JSON.stringify(jsonData[0].data), 603 | ), 604 | ); 605 | 606 | urlRef.value = jsonData[1].api; 607 | 608 | await delay(20); 609 | 610 | await vi.waitFor(() => expect(result.data.value).toBe("")); 611 | 612 | result.send(); 613 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 614 | await vi.waitFor(() => 615 | expect(result.isStreaming.value).toBe(false), 616 | ); 617 | 618 | expect(result.data.value).toBe(JSON.stringify(jsonData[1].data)); 619 | }); 620 | 621 | it("reacts when url is a ref (useJsonStream)", async () => { 622 | const urlRef = ref(jsonData[0].api); 623 | 624 | const [result] = withSetup(() => useJsonStream(urlRef)); 625 | 626 | result.send(); 627 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 628 | await vi.waitFor(() => 629 | expect(result.isStreaming.value).toBe(false), 630 | ); 631 | await vi.waitFor(() => 632 | expect(result.data.value).toEqual(jsonData[0].data), 633 | ); 634 | 635 | urlRef.value = jsonData[1].api; 636 | 637 | await delay(20); 638 | 639 | await vi.waitFor(() => expect(result.data.value).toBeNull()); 640 | 641 | result.send(); 642 | await vi.waitFor(() => expect(result.isStreaming.value).toBe(true)); 643 | await vi.waitFor(() => 644 | expect(result.isStreaming.value).toBe(false), 645 | ); 646 | 647 | expect(result.data.value).toEqual(jsonData[1].data); 648 | }); 649 | }); 650 | }); 651 | --------------------------------------------------------------------------------