├── .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 |
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 |
5 |
6 |
7 |
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 |
41 |
42 |
{{ data }}
43 |
Connecting...
44 |
Generating...
45 |
Send Message
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 | ```vue
82 |
91 |
92 |
93 | {{ data }}
94 |
95 | ```
96 |
97 | To cancel a stream manually, you may use the `cancel` method returned from the hook:
98 |
99 | ```vue
100 |
105 |
106 |
107 |
108 |
{{ data }}
109 |
Cancel
110 |
111 |
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 |
128 |
129 |
{{ data }}
130 |
131 |
132 |
133 | ```
134 |
135 | ```vue
136 |
137 |
146 |
147 |
148 |
149 |
Connecting...
150 |
Generating...
151 |
152 |
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 |
177 |
178 |
179 |
180 | {{ user.id }}: {{ user.name }}
181 |
182 |
183 |
Load Users
184 |
185 |
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 |
202 | {{ message }}
203 |
204 | ```
205 |
206 | You also have access to the array of message parts:
207 |
208 | ```vue
209 |
214 |
215 |
216 |
217 |
218 | {{ message }}
219 |
220 |
221 |
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 |
284 | {{ message }}
285 |
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 |
305 | {{ message }}
306 |
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 |
5 |
6 |
7 |
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 |
Send Message
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 |
Cancel
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 |
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 |
Load Users
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 |
--------------------------------------------------------------------------------