├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config ├── broadcasting.php └── wave.php ├── phpstan.neon.rector.dist ├── rector.php ├── routes └── routes.php ├── src ├── BroadcastManagerExtended.php ├── BroadcastingUserIdentifier.php ├── Console │ └── Commands │ │ ├── BroadcastingInstallCommand.php │ │ ├── ConfigPublishCommand.php │ │ ├── ServeCommand.php │ │ └── SsePingCommand.php ├── Events │ ├── ClientEvent.php │ ├── PresenceChannelJoinEvent.php │ ├── PresenceChannelLeaveEvent.php │ ├── SseConnectionClosedEvent.php │ └── SsePingEvent.php ├── Http │ ├── Controllers │ │ ├── PresenceChannelUsersController.php │ │ ├── SendWhisper.php │ │ └── WaveConnection.php │ └── Middleware │ │ └── PingConnections.php ├── Listeners │ └── RemoveStoredConnectionListener.php ├── PresenceChannelEvent.php ├── PresenceChannelEventHandler.php ├── RedisSubscriber.php ├── ServerSentEventSubscriber.php ├── Sse │ ├── EventFactory.php │ ├── ServerSentEvent.php │ └── ServerSentEventStream.php ├── Storage │ ├── BroadcastEventHistory.php │ ├── BroadcastEventHistoryRedisStream.php │ ├── BroadcastingEvent.php │ ├── PresenceChannelUsersRedisRepository.php │ └── PresenceChannelUsersRepository.php ├── WaveServiceProvider.php └── helpers.php └── stubs ├── echo-bootstrap-js.stub └── echo-js.stub /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-wave` will be documented in this file. 4 | 5 | ## [Unreleased](https://github.com/qruto/laravel-wave/compare/0.10.1...main) 6 | 7 | ## [0.10.1](https://github.com/qruto/laravel-wave/compare/0.10.0...0.10.1) - 2025-03-21 8 | 9 | fixed version constraints 10 | 11 | ## [0.10.0](https://github.com/qruto/laravel-wave/compare/0.9.1...0.10.0) - 2025-03-15 12 | 13 | - Laravel 12 support 14 | - drop PHP 8.1 support 15 | 16 | ## [0.9.1](https://github.com/qruto/laravel-wave/compare/0.9.0...0.9.1) - 2024-04-13 17 | 18 | - Fixed channels route file copy #63 19 | 20 | ## [0.9.0](https://github.com/qruto/laravel-wave/compare/0.8.2...0.9.0) - 2024-03-25 21 | 22 | - Laravel 11 and `php artisan install:broadcasting` command support 23 | - increase max. available connections prompt for `php artisan serve` command 24 | 25 | ## [0.8.2](https://github.com/qruto/laravel-wave/compare/0.8.1...0.8.2) - 2024-03-12 26 | 27 | - Fixed working with `predis/predis` 28 | 29 | ## [0.8.1](https://github.com/qruto/laravel-wave/compare/0.8.0...0.8.1) - 2023-12-15 30 | 31 | Fixed route caching 32 | 33 | ## [0.8.0](https://github.com/qruto/laravel-wave/compare/0.7.1...0.8.0) - 2023-12-14 34 | 35 | - **Compatibility Testing with Laravel Octane**: Conducted thorough testing with [Laravel Octane](https://laravel.com/docs/10.x/octane), ensuring seamless integration and robust performance under the high-throughput, long-lived application scenarios that Octane facilitates. 36 | - **Enhanced Efficiency for Presence Channels**: Re-engineered the data structure, significantly boosting the efficiency of data storage and retrieval processes for presence channels. 37 | - **Migration to Redis Streams**: Transitioned the event history storage mechanism to [Redis Streams](https://redis.io/docs/data-types/streams). This change leverages Redis's advanced capabilities for more robust and scalable event stream resume. 38 | - **Atomic Operations with Lua Scripts**: Integrated Lua scripts for atomic operations in Redis. This enhancement not only accelerates performance but also fortifies data integrity and effectively addresses potential race conditions. 39 | - **Streamlined Naming Conventions**: Simplified the namespace and service provider's name for greater ease of use and clarity. The service provider has been renamed from `LaravelWave` to `Wave`. 40 | 41 | ## [0.7.1](https://github.com/qruto/laravel-wave/compare/0.7.0...0.7.1) - 2023-07-20 42 | 43 | Various bug fixes. 44 | 45 | Improved presence channel users synchronization. 46 | 47 | ## [0.7.0](https://github.com/qruto/laravel-wave/compare/0.6.1...0.7.0) - 2023-06-09 48 | 49 | ### 🎛️ Take a full control. 50 | 51 | Migrated from the [legacy `EventSource`](https://github.com/whatwg/html/issues/2177#issuecomment-267270198) to state-of-the-art [@microsoft/fetch-event-source](https://github.com/Azure/fetch-event-source) based on [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) 💪. 52 | 53 | ### What's new 54 | 55 | - **Support for Custom Authentication Headers**: As of [Echo 1.14.0](https://github.com/laravel/echo/releases/tag/v1.14.0), you can personalize your auth headers. Thanks to @ezequidias for the inspiration in https://github.com/qruto/laravel-wave/discussions/20 56 | - **Debug Mode**: Idea from https://github.com/qruto/laravel-wave-js/discussions/14 57 | - **[`retry`](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#retry) Field Support**: We've added support for the `retry` field for setup reconnection time after connection close. 58 | - **Intelligent Connection Management with `pauseInactive`:** This feature taps into the [Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API) to close connections when the document is hidden (like when a user minimizes the window), and auto-retries with the last event ID when it becomes visible again. This optimizes your server load. 59 | - **Custom CSRF Token Support**: Craft your CSRF tokens as you see fit. 60 | - **Full Customizability for Request Options**: You now have the power to tailor any [Request option](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#options) to your needs. 61 | 62 | Check out all [Available Options → ⚙️](https://github.com/qruto/laravel-wave#client-options) 63 | 64 | #### Fixed 65 | 66 | - **Enhanced Error Handling**: Our `.error(...)` callbacks are now fully operational. 67 | - **Persistent Leave Presence Channel Request**: With the new `keepalive` option, your leave presence channel requests will be sent even if a user closes their browser. 68 | 69 | ## [0.6.1](https://github.com/qruto/laravel-wave/compare/0.6.0...0.6.1) - 2023-04-27 70 | 71 | Fixed route caching with double naming conflict https://github.com/qruto/laravel-wave/issues/15 72 | 73 | ## [0.6.0](https://github.com/qruto/laravel-wave/compare/0.5.2...0.6.0) - 2023-03-07 74 | 75 | Laravel 10 support 76 | 77 | ## [0.5.2](https://github.com/qruto/laravel-wave/compare/0.5.1...0.5.2) - 2022-08-16 78 | 79 | Required php version dropped to 8.0 ⬇️ 80 | 81 | ## [0.5.1](https://github.com/qruto/laravel-wave/compare/0.5.0...0.5.1) - 2022-08-04 82 | 83 | 🤖 Automated ping events triggered by Wave connection requests. 84 | 85 | ## [0.5.0](https://github.com/qruto/laravel-wave/compare/3cacf22...0.5.0) - 2022-08-01 86 | 87 | First release 🎉 Works well in the home environment, but should be battle tested before **1.0**. 88 | 89 | Checkout ➡️ [README](https://github.com/qruto/laravel-wave/blob/main/README.md). 90 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) slavarazum 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 8 | 13 | Laravel Wave Logo 18 | 19 |

20 | 21 |

Bring live to your application

22 | 23 |

24 | Build Status 25 | Styles check 26 | Types check 27 | Refactor code 28 | Total Downloads 29 | Latest Stable Version 30 |

31 | 32 |

33 | 34 | 38 | 42 | Laravel Wave Demo 47 | 48 |

49 | 50 | # Introduction 51 | 52 | Unlock the power of 53 | Laravel's [broadcasting system](https://laravel.com/docs/master/broadcasting) 54 | with **Wave**. Imagine that real-time server broadcasting is possible over 55 | native HTTP without any ~WebSockets~ setup. 56 | Meet the 57 | **[Server-sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)** 🛜 58 | Works seamlessly with Laravel's default `redis` broadcasting driver and 59 | supports [Laravel Echo](https://github.com/laravel/echo). 60 | 61 | Experience it live with our [demo streaming tweets](https://wave.qruto.dev) 🐤. 62 | 63 | > Server-Sent Events (**SSE**) is specially tuned for real-time server-to-client 64 | > communication. 65 | 66 | _Compatible 67 | with_ [](https://laravel.com/docs/11.x/broadcasting) 68 | [](https://laravel.com/docs/11.x/octane) 69 | [](https://herd.laravel.com) 70 | 71 | ## 🌟 Key Features 72 | 73 | **⚡ Works with native Redis Driver**: Wave seamlessly integrates with Laravel's 74 | default `redis` broadcasting driver, ensuring efficient real-time data transfer. 75 | 76 | **🔄 Resume From Last**: Connection drops? No problem! Wave intelligently resumes 77 | the event stream from the last event, ensuring no crucial data is lost in 78 | transit. 79 | 80 | **🟢 Live Models**: With a simple interface that respects Laravel's native 81 | conventions 82 | for [Model Events Broadcasting](https://laravel.com/docs/master/broadcasting#model-broadcasting) 83 | and [Broadcast Notifications](https://laravel.com/docs/master/notifications#broadcast-notifications), 84 | Wave turbocharges your application with real-time updates.e 85 | 86 | **🍃 Resource-Friendly Broadcasting with `pauseInactive`**: This feature 87 | maximizes resource efficiency by closing the data stream when user inactive ( 88 | such as when the user minimizes the browser) and automatically reopens it upon 89 | resumption of visibility. Turned off by default. 90 | 91 | **🎛️️ Full Requests Control**: Wave hands you the reins over connection and 92 | authentication requests, granting you the freedom to shape your broadcasting 93 | setup to your exact requirements. 94 | 95 | ## Installation 96 | 97 | ### Laravel 11 or higher 98 | 99 | Install the package via Composer at first, then install broadcasting setup: 100 | 101 | ```bash 102 | composer require qruto/laravel-wave 103 | php artisan install:broadcasting 104 | ``` 105 | 106 | ### Laravel 10 or lower 107 | 108 | Install **Wave** on both server and client sides using Composer and npm: 109 | 110 | ```bash 111 | composer require qruto/laravel-wave 112 | npm install laravel-wave 113 | ``` 114 | 115 | Then, set your `.env` file to use the `redis` broadcasting driver: 116 | 117 | ```ini 118 | BROADCAST_DRIVER = redis 119 | ``` 120 | 121 | ## Usage 122 | 123 | After installing **Wave**, your server is ready to broadcast events. 124 | You can use it with **Echo** as usual or try `Wave` model API to work with 125 | predefined Eloquent events. 126 | 127 | In Laravel 11 or higher, after `install:broadcasting`, you will find: 128 | 129 | - broadcasting channel authorization file in `routes/channels.php` 130 | - broadcasting configuration file in `config/broadcasting.php` 131 | - echo instance in `resources/echo.js` 132 | - _(optional)_ **Wave** configuration file in `config/wave.php` 133 | 134 | ### Manual usage 135 | 136 | Import Laravel Echo with `WaveConnector` and pass it to the broadcaster option: 137 | 138 | ```javascript 139 | import Echo from 'laravel-echo'; 140 | 141 | import { WaveConnector } from 'laravel-wave'; 142 | 143 | window.Echo = new Echo({broadcaster: WaveConnector}); 144 | ``` 145 | 146 |
147 | 148 | For Laravel 10 or lower, locate Echo connection configuration in 149 | resources/js/bootstrap.js file. 150 | 151 | 152 | ```diff 153 | - import Echo from 'laravel-echo'; 154 | 155 | - import Pusher from 'pusher-js'; 156 | - window.Pusher = Pusher; 157 | 158 | - window.Echo = new Echo({ 159 | - broadcaster: 'pusher', 160 | - key: import.meta.env.VITE_PUSHER_APP_KEY, 161 | - wsHost: import.meta.env.VITE_PUSHER_HOST ?? `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`, 162 | - wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80, 163 | - wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443, 164 | - forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https', 165 | - enabledTransports: ['ws', 'wss'], 166 | - }); 167 | + import Echo from 'laravel-echo'; 168 | 169 | + import { WaveConnector } from 'laravel-wave'; 170 | 171 | + window.Echo = new Echo({ broadcaster: WaveConnector }); 172 | ``` 173 | 174 |
175 | 176 | Use Echo as you typically would. 177 | 178 | 📞 [Receiving Broadcasts](https://laravel.com/docs/master/broadcasting#receiving-broadcasts) 179 | documentation. 180 | 181 | ### Use Live Eloquent Models 182 | 183 | With native conventions 184 | of [Model Events Broadcasting](https://laravel.com/docs/master/broadcasting#model-broadcasting) 185 | and [Broadcast Notifications](https://laravel.com/docs/master/notifications#broadcast-notifications) 186 | you can use 187 | **Wave** models to receive model events and notifications. 188 | 189 | ```javascript 190 | import { Wave } from 'laravel-wave'; 191 | 192 | window.Wave = new Wave(); 193 | 194 | wave.model('User', '1') 195 | .notification('team.invite', (notification) => { 196 | console.log(notification); 197 | }) 198 | .updated((user) => console.log('user updated', user)) 199 | .deleted((user) => console.log('user deleted', user)) 200 | .trashed((user) => console.log('user trashed', user)) 201 | .restored((user) => console.log('user restored', user)) 202 | .updated('Team', (team) => console.log('team updated', team)); 203 | ``` 204 | 205 | Start by calling the `model` method on the `Wave` instance with the model name 206 | and key. 207 | 208 | By default, Wave prefixes model names with `App.Models` namespace. You can 209 | customize this with the `namespace` option: 210 | 211 | ```javascript 212 | window.Wave = new Wave({namespace: 'App.Path.Models'}); 213 | ``` 214 | 215 | 📄 [Check out full Laravel Broadcasting documentation](https://laravel.com/docs/11.x/broadcasting) 216 | 217 | ## Configuration 218 | 219 | ### Client Options 220 | 221 | These options can be passed to the `Wave` or `Echo` instance: 222 | 223 | | Name | Type | Default | Description | 224 | |---------------|----------------------------------------------------------------------------------------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------| 225 | | endpoint | _string_ | `/wave` | Primary SSE connection route. | 226 | | namespace | _string_ | `App.Events` | Namespace of events to listen for. | 227 | | auth.headers | _object_ | `{}` | Additional authentication headers. | 228 | | authEndpoint | _string?_ | `/broadcasting/auth` | Authentication endpoint. | 229 | | csrfToken | _string?_ | `undefined` or `string` | CSRF token, defaults from `XSRF-TOKEN` cookie. | 230 | | bearerToken | _string?_ | `undefined` | Bearer tokenfor authentication. | 231 | | request | _[Request](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#options)?_ | `undefined` | Custom settings for connection and authentication requests. | 232 | | pauseInactive | _boolean_ | `false` | If `true`, closes connection when the page is hidden and reopens when visible. | 233 | | debug | _boolean_ | `false` | Toggles debug mode. If set to `true`, provides detailed event logs in the console, helping with event diagnosis and troubleshooting. | 234 | 235 | ```javascript 236 | new Echo({ 237 | broadcaster: WaveConnector, 238 | endpoint: '/sse-endpoint', 239 | bearerToken: 'bearer-token', 240 | //... 241 | }); 242 | 243 | // or 244 | 245 | new Wave({ 246 | authEndpoint: '/custom-broadcasting/auth', 247 | csrfToken: 'csrf-token', 248 | }) 249 | ``` 250 | 251 | ### Server Options 252 | 253 | You can publish the Wave configuration file with: 254 | 255 | ```bash 256 | php artisan vendor:publish --tag="wave-config" 257 | ``` 258 | 259 | Here are the contents of the published configuration file: 260 | 261 | ```php 262 | return [ 263 | 264 | /* 265 | |-------------------------------------------------------------------------- 266 | | Resume Lifetime 267 | |-------------------------------------------------------------------------- 268 | | 269 | | Define how long (in seconds) you wish an event stream to persist so it 270 | | can be resumed after a reconnect. The connection automatically 271 | | re-establishes with every closed response. 272 | | 273 | | * Requires a cache driver to be configured. 274 | | 275 | */ 276 | 'resume_lifetime' => 60, 277 | 278 | /* 279 | |-------------------------------------------------------------------------- 280 | | Reconnection Time 281 | |-------------------------------------------------------------------------- 282 | | 283 | | This value determines how long (in milliseconds) to wait before 284 | | attempting a reconnect to the server after a connection has been lost. 285 | | By default, the client attempts to reconnect immediately. For more 286 | | information, please refer to the Mozilla developer's guide on event 287 | | stream format. 288 | | https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format 289 | | 290 | */ 291 | 'retry' => null, 292 | 293 | /* 294 | |-------------------------------------------------------------------------- 295 | | Ping 296 | |-------------------------------------------------------------------------- 297 | | 298 | | A ping event is automatically sent on every SSE connection request if the 299 | | last event occurred before the set `frequency` value (in seconds). This 300 | | ensures the connection remains persistent. 301 | | 302 | | By setting the `eager_env` option, a ping event will be sent with each 303 | | request. This is useful for development or for applications that do not 304 | | frequently expect events. The `eager_env` option can be set as an `array` or `null`. 305 | | 306 | | For manual control of the ping event with the `sse:ping` command, you can 307 | | disable this option. 308 | | 309 | */ 310 | 'ping' => [ 311 | 'enable' => true, 312 | 'frequency' => 30, 313 | 'eager_env' => 'local', // null or array 314 | ], 315 | 316 | /* 317 | |-------------------------------------------------------------------------- 318 | | Routes Path 319 | |-------------------------------------------------------------------------- 320 | | 321 | | This path is used to register the necessary routes for establishing the 322 | | Wave connection, storing presence channel users, and handling simple whisper events. 323 | | 324 | */ 325 | 'path' => 'wave', 326 | 327 | /* 328 | |-------------------------------------------------------------------------- 329 | | Route Middleware 330 | |-------------------------------------------------------------------------- 331 | | 332 | | Define which middleware Wave should assign to the routes that it registers. 333 | | You may modify these middleware as needed. However, the default value is 334 | | typically sufficient. 335 | | 336 | */ 337 | 'middleware' => [ 338 | 'web', 339 | ], 340 | 341 | /* 342 | |-------------------------------------------------------------------------- 343 | | Auth & Guard 344 | |-------------------------------------------------------------------------- 345 | | 346 | | Define the default authentication middleware and guard type for 347 | | authenticating users for presence channels and whisper events. 348 | | 349 | */ 350 | 'auth_middleware' => 'auth', 351 | 352 | 'guard' => 'web', 353 | 354 | ]; 355 | ``` 356 | 357 | ## Persistent Connection with Nginx + PHP FPM 358 | 359 | Wave is designed to automatically reconnect after a request timeout. 360 | During reconnection, you won't lose any events because Wave stores event history 361 | for one minute by default 362 | and resumes it. You can adjust the duration of event history storage by 363 | modifying the `resume_lifetime` value 364 | in the config file. 365 | 366 | However, if you want to maintain a persistent connection, let's configure your 367 | web server. 368 | 369 | ### `fastcgi_read_timeout` 370 | 371 | By default, the `fastcgi_read_timeout` value is `60s` for Nginx + PHP FastCGI 372 | server setup. 373 | 374 | #### Option 1. Without changing the `fastcgi_read_timeout` value 375 | 376 | > Ensure that the interval between events pushed into Wave connection is shorter 377 | > than the read timeout value 378 | 379 | To enhance the certainty of events occurring more frequently than the standard 380 | timeout, 381 | Wave attempts to send a ping event with each Server-Sent Events (SSE) connection 382 | request, 383 | provided that the previous event occurred prior to the `ping.frequency` 384 | configuration value. 385 | 386 | If your application doesn't expect many real-time connections, 387 | specify the list of environments in which a ping event will be sent 388 | with each Wave connection. By default, this is set to `local`. 389 | 390 | #### Option 2. Manual ping control 391 | 392 | To ensure accurate frequency sending a ping event: 393 | 394 | 1. Disable automatic sending by changing the `ping.enable` config value 395 | to `false` 396 | 2. Use the `sse:ping` command to manually send a single ping or operate at an 397 | interval 398 | 399 | Run the command with the `--interval` option to send a ping event at a specified 400 | interval in seconds, 401 | for example let's send a ping event every `30s`: 402 | 403 | ```bash 404 | php artisan sse:ping --interval=30 405 | ``` 406 | 407 | So, every `30s`, the command will send a ping event to all active connections 408 | and 409 | ensure that the connection remains persistent, because the frequency of sending 410 | events is less than `60s`. 411 | 412 | Alternatively, use the 413 | Laravel [Tasks scheduler](https://laravel.com/docs/master/scheduling#introduction) 414 | to send a ping event every minute or more often 415 | if `fastcgi_read_timeout` value is greater than `60s`: 416 | 417 | ```php 418 | protected function schedule(Schedule $schedule) 419 | { 420 | $schedule->command('sse:ping')->everyMinute(); 421 | } 422 | ``` 423 | 424 | ### `request_terminate_timeout` 425 | 426 | Some platforms, such as [Laravel Forge](https://forge.laravel.com), configure 427 | the PHP FPM pool with `request_terminate_timeout = 60`, terminating all requests 428 | after `60s`. 429 | 430 | You can disable this in the `/etc/php/8.1/fpm/pool.d/www.conf` config file: 431 | 432 | ```ini 433 | request_terminate_timeout = 0 434 | ``` 435 | 436 | or you can configure a separate pool for the SSE connection. 437 | 438 | ## Future Plans 439 | 440 | 📍 Local broadcasting driver 441 | 442 | 📥 📤 two ways live models syncing 443 | 444 | 📡 Something awesome with opened live abilities... 445 | 446 | ## Testing 447 | 448 | ```bash 449 | composer test 450 | ``` 451 | 452 | ## Support 453 | 454 | In light of recent events in Ukraine, my life has taken an unexpected turn. 455 | Since February 24th, I've lost my commercial work, my permanent residence, 456 | and my ability to plan for the future. 457 | 458 | During these challenging times, I derive strength and purpose from creating 459 | open source projects, such as Wave. 460 | 461 | [![support me](https://raw.githubusercontent.com/slavarazum/slavarazum/main/support-banner.png)](https://github.com/sponsors/qruto) 462 | 463 | I welcome you to visit 464 | my [GitHub Sponsorships profile](https://github.com/sponsors/qruto). 465 | There, you can discover more about my current work, future ambitions, and 466 | aspirations. 467 | Every ⭐ you give brings joy to my day, and your sponsorship can make 468 | a profound difference in my ability to continue creating. 469 | 470 | I'm truly grateful for your support, whether it's a shout-out or a heartfelt " 471 | thank you". 472 | 473 | 💳 [Help directly](https://revolut.me/slavarazum). 474 | 475 | ## Changelog 476 | 477 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed 478 | recently. 479 | 480 | ## Contributing 481 | 482 | Please 483 | see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) 484 | for details. 485 | 486 | ## Security Vulnerabilities 487 | 488 | Please review [our security policy](../../security/policy) on how to report 489 | security vulnerabilities. 490 | 491 | ## Credits 492 | 493 | - [Slava Razum](https://github.com/slavarazum) 494 | - [All Contributors](../../contributors) 495 | 496 | Package template based 497 | on [Spatie Laravel Skeleton](https://github.com/spatie/package-skeleton-laravel). 498 | 499 | ## License 500 | 501 | The MIT License (MIT). Please see [License File](LICENSE.md) for more 502 | information. 503 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qruto/laravel-wave", 3 | "description": "Painless Laravel Broadcasting with SSE.", 4 | "keywords": [ 5 | "qruto", 6 | "laravel", 7 | "laravel-wave", 8 | "php", 9 | "sse", 10 | "server sent events", 11 | "event source", 12 | "realtime", 13 | "live-update" 14 | ], 15 | "homepage": "https://github.com/qruto/laravel-wave", 16 | "license": "MIT", 17 | "support": { 18 | "issues": "https://github.com/qruto/laravel-wave/issues", 19 | "source": "https://github.com/qruto/laravel-wave" 20 | }, 21 | "authors": [ 22 | { 23 | "name": "Slava Razum", 24 | "email": "razum@qruto.to", 25 | "role": "Developer" 26 | } 27 | ], 28 | "require": { 29 | "php": "^8.2", 30 | "illuminate/broadcasting": "^10.0|^11.0.6|^12.0", 31 | "illuminate/console": "^10.0|^11.0.6|^12.0", 32 | "illuminate/contracts": "^10|^11.0.6|^12.0", 33 | "illuminate/http": "^10.0|^11.0.6|^12.0", 34 | "illuminate/queue": "^10.0|^11.0.6|^12.0", 35 | "illuminate/routing": "^10.0|^11.0.6|^12.0", 36 | "laravel/prompts": "^0.1.15|^0.2.0|^0.3.0", 37 | "spatie/laravel-package-tools": "^1.19.0" 38 | }, 39 | "require-dev": { 40 | "driftingly/rector-laravel": "^1.2|^2.0", 41 | "larastan/larastan": "^2.0|^3.0", 42 | "laravel/pint": "^1.20", 43 | "m6web/redis-mock": "v5.6", 44 | "nunomaduro/collision": "^7.10|^8.1", 45 | "orchestra/testbench": "^8.22|^9.0|^10.0", 46 | "pestphp/pest": "^2.0|^3.0", 47 | "pestphp/pest-plugin-laravel": "^2.0|^3.0", 48 | "pestphp/pest-plugin-watch": "^2.0|^3.0", 49 | "phpstan/extension-installer": "^1.3|^2.0", 50 | "phpstan/phpstan-deprecation-rules": "^1.1|^2.0", 51 | "phpstan/phpstan-phpunit": "^1.3|^2.0", 52 | "spatie/laravel-ray": "^1.39" 53 | }, 54 | "autoload": { 55 | "psr-4": { 56 | "Qruto\\Wave\\": "src" 57 | }, 58 | "files": [ 59 | "src/helpers.php" 60 | ] 61 | }, 62 | "autoload-dev": { 63 | "psr-4": { 64 | "Qruto\\Wave\\Tests\\": "tests" 65 | } 66 | }, 67 | "scripts": { 68 | "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", 69 | "lint": "pint -v", 70 | "refactor": "rector --debug", 71 | "test-coverage": "pest --coverage --colors=always", 72 | "test:refactor": "rector --dry-run", 73 | "test:types": "phpstan analyse --ansi --memory-limit=-1", 74 | "test:unit": "pest --colors=always", 75 | "test:lint": "pint --test -v", 76 | "test": [ 77 | "@test:lint", 78 | "@test:refactor", 79 | "@test:unit" 80 | ], 81 | "fix": [ 82 | "@refactor", 83 | "@lint" 84 | ] 85 | }, 86 | "config": { 87 | "sort-packages": true, 88 | "allow-plugins": { 89 | "pestphp/pest-plugin": true, 90 | "phpstan/extension-installer": true 91 | } 92 | }, 93 | "extra": { 94 | "laravel": { 95 | "providers": [ 96 | "Qruto\\Wave\\WaveServiceProvider" 97 | ] 98 | } 99 | }, 100 | "minimum-stability": "dev", 101 | "prefer-stable": true 102 | } 103 | -------------------------------------------------------------------------------- /config/broadcasting.php: -------------------------------------------------------------------------------- 1 | env('BROADCAST_CONNECTION', 'null'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Broadcast Connections 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the broadcast connections that will be used 26 | | to broadcast events to other systems or over WebSockets. Samples of 27 | | each available type of connection are provided inside this array. 28 | | 29 | */ 30 | 31 | 'connections' => [ 32 | 33 | 'redis' => [ 34 | 'driver' => 'redis', 35 | 'connection' => 'default', 36 | ], 37 | 38 | 'pusher' => [ 39 | 'driver' => 'pusher', 40 | 'key' => env('PUSHER_APP_KEY'), 41 | 'secret' => env('PUSHER_APP_SECRET'), 42 | 'app_id' => env('PUSHER_APP_ID'), 43 | 'options' => [ 44 | 'cluster' => env('PUSHER_APP_CLUSTER'), 45 | 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', 46 | 'port' => env('PUSHER_PORT', 443), 47 | 'scheme' => env('PUSHER_SCHEME', 'https'), 48 | 'encrypted' => true, 49 | 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', 50 | ], 51 | 'client_options' => [ 52 | // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html 53 | ], 54 | ], 55 | 56 | 'ably' => [ 57 | 'driver' => 'ably', 58 | 'key' => env('ABLY_KEY'), 59 | ], 60 | 61 | 'log' => [ 62 | 'driver' => 'log', 63 | ], 64 | 65 | 'null' => [ 66 | 'driver' => 'null', 67 | ], 68 | 69 | ], 70 | 71 | ]; 72 | -------------------------------------------------------------------------------- /config/wave.php: -------------------------------------------------------------------------------- 1 | 60, 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Reconnection Time 22 | |-------------------------------------------------------------------------- 23 | | 24 | | This value determines how long (in milliseconds) to wait before 25 | | attempting a reconnect to the server after a connection has been lost. 26 | | By default, the client attempts to reconnect immediately. For more 27 | | information, please refer to the Mozilla developer's guide on event 28 | | stream format. 29 | | https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format 30 | | 31 | */ 32 | 'retry' => null, 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | Ping 37 | |-------------------------------------------------------------------------- 38 | | 39 | | A ping event is automatically sent on every SSE connection request if the 40 | | last event occurred before the set `frequency` value (in seconds). This 41 | | ensures the connection remains persistent. 42 | | 43 | | By setting the `eager_env` option, a ping event will be sent with each 44 | | request. This is useful for development or for applications that do not 45 | | frequently expect events. The `eager_env` option can be set as an `array` or `null`. 46 | | 47 | | For manual control of the ping event with the `sse:ping` command, you can 48 | | disable this option. 49 | | 50 | */ 51 | 'ping' => [ 52 | 'enable' => true, 53 | 'frequency' => 30, 54 | 'eager_env' => 'local', // null or array 55 | ], 56 | 57 | /* 58 | |-------------------------------------------------------------------------- 59 | | Routes Path 60 | |-------------------------------------------------------------------------- 61 | | 62 | | This path is used to register the necessary routes for establishing the 63 | | Wave connection, storing presence channel users, and handling simple whisper events. 64 | | 65 | */ 66 | 'path' => 'wave', 67 | 68 | /* 69 | |-------------------------------------------------------------------------- 70 | | Route Middleware 71 | |-------------------------------------------------------------------------- 72 | | 73 | | Define which middleware Wave should assign to the routes that it registers. 74 | | You may modify these middleware as needed. However, the default value is 75 | | typically sufficient. 76 | | 77 | */ 78 | 'middleware' => [ 79 | 'web', 80 | ], 81 | 82 | /* 83 | |-------------------------------------------------------------------------- 84 | | Auth & Guard 85 | |-------------------------------------------------------------------------- 86 | | 87 | | Define the default authentication middleware and guard type for 88 | | authenticating users for presence channels and whisper events. 89 | | 90 | */ 91 | 'auth_middleware' => 'auth', 92 | 93 | 'guard' => 'web', 94 | 95 | ]; 96 | -------------------------------------------------------------------------------- /phpstan.neon.rector.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | level: 4 6 | paths: 7 | - src 8 | - config 9 | tmpDir: build/phpstan 10 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([ 15 | __DIR__.'/src', 16 | ]); 17 | 18 | // register a single rule 19 | $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); 20 | 21 | // define sets of rules 22 | $rectorConfig->sets([ 23 | LevelSetList::UP_TO_PHP_80, 24 | SetList::CODE_QUALITY, 25 | SetList::DEAD_CODE, 26 | SetList::EARLY_RETURN, 27 | LaravelSetList::LARAVEL_CODE_QUALITY, 28 | LaravelSetList::LARAVEL_COLLECTION, 29 | LaravelLevelSetList::UP_TO_LARAVEL_110, 30 | // SetList::CODING_STYLE, 31 | // SetList::ACTION_INJECTION_TO_CONSTRUCTOR_INJECTION, 32 | // SetList::PRIVATIZATION, 33 | // SetList::TYPE_DECLARATION, 34 | ]); 35 | 36 | $rectorConfig->skip([ 37 | RemoveUnusedPromotedPropertyRector::class => [ 38 | __DIR__.'/src/Sse/ServerSentEvent.php', 39 | ], 40 | ]); 41 | 42 | $rectorConfig->phpstanConfig(__DIR__.'/phpstan.neon.rector.dist'); 43 | }; 44 | -------------------------------------------------------------------------------- /routes/routes.php: -------------------------------------------------------------------------------- 1 | config('wave.path', 'wave'), 10 | 'as' => 'wave.', 11 | 'middleware' => config('wave.middleware', ['web']), 12 | ], function () { 13 | Route::get('/', WaveConnection::class)->name('connection'); 14 | 15 | Route::group([ 16 | 'middleware' => [config('wave.auth_middleware', 'auth').':'.config('wave.guard')], 17 | ], function () { 18 | Route::get('presence-channel-users', [PresenceChannelUsersController::class, 'index'])->name('presence-channel-users'); 19 | Route::post('presence-channel-users', [PresenceChannelUsersController::class, 'store']); 20 | Route::delete('presence-channel-users', [PresenceChannelUsersController::class, 'destroy']); 21 | 22 | Route::post('whisper', SendWhisper::class)->name('whisper'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/BroadcastManagerExtended.php: -------------------------------------------------------------------------------- 1 | app->make(BroadcastEventHistory::class), $this->app->make('redis'), $config['connection'] ?? null, $this->app['config']->get('database.redis.options.prefix', '')) extends RedisBroadcaster 16 | { 17 | public function __construct( 18 | // TODO: make readonly after update minimum required PHP version 19 | private BroadcastEventHistory $history, 20 | Redis $redis, 21 | $connection = null, 22 | $prefix = '' 23 | ) { 24 | parent::__construct($redis, $connection, $prefix); 25 | } 26 | 27 | public function broadcast( 28 | array $channels, 29 | $event, 30 | array $payload = [] 31 | ) { 32 | foreach ( 33 | EventFactory::fromBroadcastEvent( 34 | $channels, 35 | $event, 36 | $payload 37 | ) as $item 38 | ) { 39 | $id = $this->history->pushEvent($item); 40 | 41 | $payload['broadcast_event_id'] = $id; 42 | } 43 | 44 | parent::broadcast($channels, $event, $payload); 45 | } 46 | }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/BroadcastingUserIdentifier.php: -------------------------------------------------------------------------------- 1 | getAuthIdentifierForBroadcasting() 13 | : $user->getAuthIdentifier(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Console/Commands/BroadcastingInstallCommand.php: -------------------------------------------------------------------------------- 1 | output->write($this->title()); 21 | 22 | $enableRedisBroadcastingDriver = confirm( 23 | 'Would you like to enable the Redis broadcasting driver in .env?', 24 | default: true, 25 | ); 26 | 27 | $installNodeDependencies = confirm( 28 | 'Install and build the Node dependencies required for broadcasting?', 29 | default: true 30 | ); 31 | 32 | $publishConfiguration = confirm( 33 | 'Publish the Wave configuration file?', 34 | default: false, 35 | ); 36 | 37 | $this->askToStarRepository(); 38 | 39 | $this->call('config:publish', ['name' => 'broadcasting']); 40 | 41 | // Install channel routes file... 42 | if (file_exists($broadcastingRoutesPath = $this->laravel->basePath('routes/channels.php')) && 43 | ! $this->option('force')) { 44 | $this->components->error('Broadcasting routes file already exists.'); 45 | } else { 46 | $this->components->info("Published 'channels' route file."); 47 | 48 | $relativeBroadcastingRoutesStub = 'laravel/framework/src/Illuminate/Foundation/Console/stubs/broadcasting-routes.stub'; 49 | 50 | if (file_exists(__DIR__.'/../../../../../'.$relativeBroadcastingRoutesStub)) { 51 | File::copy( 52 | __DIR__.'/../../../../../'.$relativeBroadcastingRoutesStub, 53 | $broadcastingRoutesPath 54 | ); 55 | } else { 56 | File::copy(__DIR__.'/../../../vendor/'.$relativeBroadcastingRoutesStub, 57 | $broadcastingRoutesPath); 58 | } 59 | } 60 | 61 | $this->uncommentChannelsRoutesFile(); 62 | $this->enableBroadcastServiceProvider(); 63 | 64 | // Install bootstrapping... 65 | if (! file_exists($echoScriptPath = $this->laravel->resourcePath('js/echo.js'))) { 66 | File::copy(__DIR__.'/../../../stubs/echo-js.stub', $echoScriptPath); 67 | } 68 | 69 | if (file_exists($bootstrapScriptPath = $this->laravel->resourcePath('js/bootstrap.js'))) { 70 | $bootstrapScript = file_get_contents( 71 | $bootstrapScriptPath 72 | ); 73 | 74 | if (! str_contains($bootstrapScript, './echo')) { 75 | File::append( 76 | $bootstrapScriptPath, 77 | PHP_EOL.file_get_contents(__DIR__.'/../../../stubs/echo-bootstrap-js.stub') 78 | ); 79 | } 80 | } 81 | 82 | if ($enableRedisBroadcastingDriver) { 83 | $this->updateBroadcastingDriver(); 84 | } 85 | 86 | if ($installNodeDependencies) { 87 | $this->installNodeDependencies(); 88 | } 89 | 90 | if ($publishConfiguration) { 91 | $this->publishConfiguration(); 92 | } 93 | 94 | return Command::SUCCESS; 95 | } 96 | 97 | /** {@inheritdoc} */ 98 | protected function installNodeDependencies() 99 | { 100 | $this->components->info('Installing and building Node dependencies.'); 101 | 102 | if (file_exists(base_path('pnpm-lock.yaml'))) { 103 | $commands = [ 104 | 'pnpm add --save-dev laravel-echo laravel-wave', 105 | 'pnpm run build', 106 | ]; 107 | } elseif (file_exists(base_path('yarn.lock'))) { 108 | $commands = [ 109 | 'yarn add --dev laravel-echo laravel-wave', 110 | 'yarn run build', 111 | ]; 112 | } else { 113 | $commands = [ 114 | 'npm install --save-dev laravel-echo laravel-wave', 115 | 'npm run build', 116 | ]; 117 | } 118 | 119 | $command = Process::command(implode(' && ', $commands)) 120 | ->path(base_path()); 121 | 122 | if (! windows_os() && SymfonyProcess::isTtySupported()) { 123 | $command->tty(); 124 | } 125 | 126 | if ($command->run()->failed()) { 127 | $this->components->warn( 128 | "Node dependency installation failed. Please run the following commands manually: \n\n" 129 | .implode(' && ', $commands) 130 | ); 131 | } else { 132 | $this->components->info('Node dependencies installed successfully.'); 133 | } 134 | } 135 | 136 | /** 137 | * Update the configured broadcasting driver. 138 | */ 139 | protected function updateBroadcastingDriver(): void 140 | { 141 | if (File::missing($env = app()->environmentFile())) { 142 | return; 143 | } 144 | 145 | File::put( 146 | $env, 147 | Str::of(File::get($env))->replaceMatches( 148 | '/(BROADCAST_(?:DRIVER|CONNECTION))=\w*/', 149 | fn (array $matches) => $matches[1].'=redis' 150 | )->value() 151 | ); 152 | } 153 | 154 | protected function publishConfiguration(): void 155 | { 156 | $this->callSilently('vendor:publish', [ 157 | '--provider' => WaveServiceProvider::class, 158 | '--tag' => 'wave-config', 159 | ]); 160 | } 161 | 162 | protected function askToStarRepository() 163 | { 164 | if (! confirm( 165 | 'Star Wave repo on GitHub during installation?', 166 | default: ! $this->option('no-interaction'), 167 | hint: 'Your yellow star contributes to the package development ⭐' 168 | )) { 169 | return; 170 | } 171 | 172 | $repoUrl = 'https://github.com/qruto/laravel-wave'; 173 | 174 | if (PHP_OS_FAMILY === 'Darwin') { 175 | exec("open {$repoUrl}"); 176 | } 177 | if (PHP_OS_FAMILY === 'Windows') { 178 | exec("start {$repoUrl}"); 179 | } 180 | if (PHP_OS_FAMILY === 'Linux') { 181 | exec("xdg-open {$repoUrl}"); 182 | } 183 | } 184 | 185 | private function title() 186 | { 187 | return PHP_EOL.<<<'TITLE' 188 | 189 | ____ _ _ _ 190 | | __ ) _ __ ___ __ _ __| | ___ __ _ ___| |_(_)_ __ __ _ 191 | | _ \| '__/ _ \ / _` |/ _` |/ __/ _` / __| __| | '_ \ / _` | 192 | | |_) | | | (_) | (_| | (_| | (_| (_| \__ \ |_| | | | | (_| | 193 | |____/|_| _\___/_\__,_|\__,_|\___\__,_|___/\__|_|_| |_|\__, | 194 | __ _(_) |_| |__ \ \ / /_ ___ _____ |___/ 195 | \ \ /\ / / | __| '_ \ \ \ /\ / / _` \ \ / / _ \ 196 | \ V V /| | |_| | | | \ V V / (_| |\ V / __/ 197 | \_/\_/ |_|\__|_| |_| \_/\_/ \__,_| \_/ \___| 198 | 199 | TITLE.PHP_EOL.PHP_EOL; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/Console/Commands/ConfigPublishCommand.php: -------------------------------------------------------------------------------- 1 | realpath(__DIR__.'/../../../config/broadcasting.php'), 18 | ] + parent::getBaseConfigurationFiles(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Console/Commands/ServeCommand.php: -------------------------------------------------------------------------------- 1 | getLaravel()->getProviders(WaveServiceProvider::class) !== []) { 24 | $workersAmount = (int) select( 25 | label: 'Looks like you are using Server-sent Events (SSE) for broadcasting. Set the preferable amount of workers.', 26 | options: ['leave 1 (default)', '10', '20'], 27 | default: 10, 28 | hint: 'The number of workers determines the maximum number of concurrent connections.', 29 | ); 30 | 31 | $_ENV['PHP_CLI_SERVER_WORKERS'] = $workersAmount; 32 | putenv("PHP_CLI_SERVER_WORKERS={$workersAmount}"); 33 | 34 | if (confirm('Do you want to save preferable amount to the .env file?', false)) { 35 | $file = base_path('.env'); 36 | 37 | if (file_exists($file)) { 38 | file_put_contents( 39 | $file, 40 | (str_ends_with(file_get_contents($file), PHP_EOL) ? '' : PHP_EOL). 41 | PHP_EOL."PHP_CLI_SERVER_WORKERS=$workersAmount".PHP_EOL, 42 | FILE_APPEND 43 | ); 44 | } 45 | } 46 | } 47 | 48 | return parent::handle(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Console/Commands/SsePingCommand.php: -------------------------------------------------------------------------------- 1 | option('interval'); 27 | 28 | if ($interval !== 0) { 29 | while (true) { 30 | event(new SsePingEvent); 31 | 32 | $this->components->twoColumnDetail(''.now().' SSE Wave Connections', 'PINGED'); 33 | 34 | sleep($interval); 35 | } 36 | } else { 37 | $this->components->twoColumnDetail(''.now().' SSE Wave Connections', 'PINGED'); 38 | 39 | event(new SsePingEvent); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Events/ClientEvent.php: -------------------------------------------------------------------------------- 1 | channel); 33 | 34 | return $channelName->startsWith('presence-') 35 | ? new PresenceChannel($channelName->after('presence-')) 36 | : new PrivateChannel($channelName->after('private-')); 37 | } 38 | 39 | public function broadcastWith() 40 | { 41 | return is_scalar($this->data) ? [$this->data] : $this->data; 42 | } 43 | 44 | public function broadcastAs(): string 45 | { 46 | return 'client-'.$this->name; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Events/PresenceChannelJoinEvent.php: -------------------------------------------------------------------------------- 1 | channel); 36 | } 37 | 38 | public function broadcastAs(): string 39 | { 40 | return 'join'; 41 | } 42 | 43 | /** 44 | * Get the data to broadcast. 45 | * 46 | * @return array 47 | */ 48 | public function broadcastWith(): array 49 | { 50 | return $this->userInfo; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Events/PresenceChannelLeaveEvent.php: -------------------------------------------------------------------------------- 1 | channel); 32 | } 33 | 34 | public function broadcastAs(): string 35 | { 36 | return 'leave'; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Events/SseConnectionClosedEvent.php: -------------------------------------------------------------------------------- 1 | middleware((function ($request, $next) { 23 | $this->userInfo = json_decode(Broadcast::auth($request), true, 512, JSON_THROW_ON_ERROR)['channel_data']['user_info']; 24 | 25 | if ($request->has('socket_id')) { 26 | $request->headers->set('X-Socket-Id', $request->socket_id); 27 | } 28 | 29 | return $next($request); 30 | })); 31 | } 32 | 33 | public function index() 34 | { 35 | return response()->json($this->repository->getUsers(request()->channel_name)); 36 | } 37 | 38 | public function store(Request $request) 39 | { 40 | if ($this->repository->join($request->channel_name, $request->user(), $this->userInfo, Broadcast::socket($request))) { 41 | broadcast(new PresenceChannelJoinEvent($this->userKey($request->user()), $this->userInfo, Str::after($request->channel_name, 'presence-')))->toOthers(); 42 | } 43 | 44 | return response()->json([ 45 | '_token' => csrf_token(), 46 | 'users' => $this->repository->getUsers($request->channel_name), 47 | ]); 48 | } 49 | 50 | public function destroy(Request $request) 51 | { 52 | if ($this->repository->leave($request->channel_name, $request->user(), Broadcast::socket($request))) { 53 | broadcast(new PresenceChannelLeaveEvent($request->user()->getAuthIdentifierForBroadcasting(), $this->userInfo, Str::after($request->channel_name, 'presence-')))->toOthers(); 54 | } 55 | 56 | return response()->json($this->repository->getUsers($request->channel_name)); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Http/Controllers/SendWhisper.php: -------------------------------------------------------------------------------- 1 | validate([ 13 | 'channel_name' => ['required', 'string', 'starts_with:private-,presence-'], 14 | 'event_name' => 'required|string', 15 | 'data' => 'required', 16 | ]); 17 | 18 | broadcast(new ClientEvent(request()->event_name, request()->channel_name, request('data')))->toOthers(); 19 | 20 | return response()->noContent(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Http/Controllers/WaveConnection.php: -------------------------------------------------------------------------------- 1 | middleware(PingConnections::class); 16 | } 17 | 18 | public function __invoke(Request $request, ServerSentEventStream $responseFactory) 19 | { 20 | return $responseFactory->toResponse($request); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Http/Middleware/PingConnections.php: -------------------------------------------------------------------------------- 1 | shouldSendPing()) { 23 | event(new SsePingEvent); 24 | } 25 | 26 | return $next($request); 27 | } 28 | 29 | protected function shouldSendPing(): bool 30 | { 31 | if (! config('wave.ping.enable', true)) { 32 | return false; 33 | } 34 | 35 | if (app()->environment(config('wave.ping.eager_env', 'local'))) { 36 | return true; 37 | } 38 | 39 | return now()->getTimestamp() - $this->eventHistory->lastEventTimestamp() > config('wave.ping.frequency', 30); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Listeners/RemoveStoredConnectionListener.php: -------------------------------------------------------------------------------- 1 | user instanceof \Illuminate\Contracts\Auth\Authenticatable) { 17 | return; 18 | } 19 | 20 | $fullyExitedChannels = $this->store->removeConnection($event->user, $event->connectionId); 21 | 22 | foreach ($fullyExitedChannels as $exitInfo) { 23 | broadcast(new PresenceChannelLeaveEvent($event->user->getAuthIdentifierForBroadcasting(), $exitInfo['user_info'], Str::after($exitInfo['channel'], 'presence-')))->toOthers(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/PresenceChannelEvent.php: -------------------------------------------------------------------------------- 1 | name === 'leave' && $this->fromPresenceChannel($event); 13 | } 14 | 15 | public function isSelfLeaveEvent(BroadcastingEvent $event, Authenticatable $user): bool 16 | { 17 | return $this->isLeaveEvent($event, $user) && $event->data['userId'] == $user->getAuthIdentifierForBroadcasting(); 18 | } 19 | 20 | public function fromPresenceChannel(BroadcastingEvent $event): bool 21 | { 22 | return str_starts_with($event->channel, 'presence-'); 23 | } 24 | 25 | public function formatLeaveEventForSending(BroadcastingEvent $event) 26 | { 27 | $event->data = $event->data['userInfo']; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/RedisSubscriber.php: -------------------------------------------------------------------------------- 1 | user(), $socket)); 22 | } 23 | 24 | $connection->disconnect(); 25 | }); 26 | 27 | $connection->psubscribe('*', $onMessage); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ServerSentEventSubscriber.php: -------------------------------------------------------------------------------- 1 | $event, 'data' => $data] = is_array($message) ? 14 | $message : json_decode($message, true, 512, JSON_THROW_ON_ERROR); 15 | 16 | $id = Arr::pull($data, 'broadcast_event_id'); 17 | $socket = Arr::pull($data, 'socket'); 18 | 19 | return new BroadcastingEvent( 20 | static::removeRedisPrefixFromChannel($channel), 21 | $event, 22 | $data, 23 | $id, 24 | $socket, 25 | ); 26 | } 27 | 28 | public static function create( 29 | string $channel, 30 | string $event, 31 | string $data, 32 | ?string $socket, 33 | ): BroadcastingEvent { 34 | return new BroadcastingEvent( 35 | $channel, 36 | $event, 37 | $data, 38 | null, 39 | $socket, 40 | ); 41 | } 42 | 43 | public static function fromBroadcastEvent(array $channels, $event, array &$payload = []) 44 | { 45 | $events = []; 46 | 47 | foreach ($channels as $channel) { 48 | $events[] = new BroadcastingEvent( 49 | $channel, 50 | $event, 51 | $payload, 52 | null, 53 | $payload['socket'], 54 | ); 55 | } 56 | 57 | return $events; 58 | } 59 | 60 | protected static function generateId(): string 61 | { 62 | return (string) Str::ulid(); 63 | } 64 | 65 | protected static function removeRedisPrefixFromChannel(string $pattern): string 66 | { 67 | return Str::after($pattern, config('database.redis.options.prefix', '')); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Sse/ServerSentEvent.php: -------------------------------------------------------------------------------- 1 | data = $data; 19 | 20 | return $this; 21 | } 22 | 23 | public function setId(string $id): self 24 | { 25 | $this->id = $id; 26 | 27 | return $this; 28 | } 29 | 30 | public function setRetry(int $retry): self 31 | { 32 | $this->retry = $retry; 33 | 34 | return $this; 35 | } 36 | 37 | private function propertyString(string $property): string 38 | { 39 | return "$property: ".$this->$property.PHP_EOL; 40 | } 41 | 42 | public function __toString(): string 43 | { 44 | $event = $this->propertyString('event'); 45 | 46 | if ($this->retry) { 47 | $event .= $this->propertyString('retry'); 48 | } 49 | 50 | $event .= $this->propertyString('data'); 51 | 52 | if ($this->id) { 53 | $event .= $this->propertyString('id'); 54 | } 55 | 56 | return $event.PHP_EOL; 57 | } 58 | 59 | public function __invoke() 60 | { 61 | echo $this; 62 | 63 | if (ob_get_level() !== 0) { 64 | ob_flush(); 65 | } 66 | 67 | flush(); 68 | } 69 | 70 | public function echo(): void 71 | { 72 | $this(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Sse/ServerSentEventStream.php: -------------------------------------------------------------------------------- 1 | */ 26 | protected const HEADERS = [ 27 | 'Content-Type' => 'text/event-stream', 28 | 'Connection' => 'keep-alive', 29 | 'Cache-Control' => 'no-cache, no-store, must-revalidate, pre-check=0, post-check=0', 30 | 'X-Accel-Buffering' => 'no', 31 | ]; 32 | 33 | public function __construct( 34 | protected ServerSentEventSubscriber $eventSubscriber, 35 | protected ResponseFactory $responseFactory, 36 | protected PresenceChannelUsersRedisRepository $store, 37 | protected BroadcastEventHistory $eventsHistory, 38 | protected PresenceChannelEvent $presenceChannelEvent, 39 | protected ConfigRepository $config 40 | ) {} 41 | 42 | public function toResponse($request) 43 | { 44 | $this->disableTimeouts(); 45 | 46 | $lastSocket = Broadcast::socket($request); 47 | 48 | $newSocket = $this->generateConnectionId(); 49 | 50 | $request->headers->set('X-Socket-Id', $newSocket); 51 | 52 | return $this->responseFactory->stream(function () use ( 53 | $request, 54 | $lastSocket, 55 | $newSocket 56 | ) { 57 | if ($request->hasHeader('Last-Event-Id')) { 58 | $missedEvents = $this->eventsHistory->getEventsFrom($request->header('Last-Event-Id')); 59 | 60 | $missedEvents 61 | // TODO: except system channel 62 | ->filter( 63 | fn ( 64 | BroadcastingEvent $event 65 | ) => $event->channel !== 'general' 66 | ) 67 | ->each($this->eventHandler($request, $lastSocket)); 68 | } 69 | 70 | // TODO: change general channel name 71 | tap( 72 | EventFactory::create( 73 | 'general', 74 | 'connected', 75 | $newSocket, 76 | $newSocket 77 | ), 78 | function (BroadcastingEvent $event) { 79 | $this->eventsHistory->pushEvent($event); 80 | 81 | $event->send(); 82 | } 83 | ); 84 | 85 | $this->eventSubscriber->start(function ( 86 | string $message, 87 | string $channel 88 | ) use ($request, $newSocket) { 89 | $this->eventHandler( 90 | $request, 91 | $newSocket 92 | )(EventFactory::fromRedisMessage($message, $channel)); 93 | }, $request, $newSocket); 94 | }, Response::HTTP_OK, self::HEADERS + ['X-Socket-Id' => $newSocket]); 95 | } 96 | 97 | protected function eventHandler(Request $request, ?string $socket): Closure 98 | { 99 | return function (BroadcastingEvent $event) use ($request, $socket) { 100 | if ($this->needsAuth($event->channel)) { 101 | try { 102 | $this->authChannel($event->channel, $request); 103 | } catch (AccessDeniedHttpException) { 104 | return; 105 | } 106 | } 107 | 108 | if ($this->shouldNotSend($event, $socket, $request->user())) { 109 | return; 110 | } 111 | 112 | if ( 113 | $request->user() 114 | && $this->presenceChannelEvent->isLeaveEvent($event, $request->user()) 115 | ) { 116 | $this->presenceChannelEvent->formatLeaveEventForSending($event); 117 | } 118 | 119 | $event->send(); 120 | }; 121 | } 122 | 123 | protected function authChannel(string $channel, Request $request): void 124 | { 125 | Broadcast::auth($request->merge([ 126 | 'channel_name' => $channel, 127 | ])); 128 | } 129 | 130 | protected function needsAuth(string $channel): bool 131 | { 132 | return str_starts_with( 133 | $channel, 134 | 'private-' 135 | ) || str_starts_with($channel, 'presence-'); 136 | } 137 | 138 | protected function shouldNotSend( 139 | BroadcastingEvent $event, 140 | ?string $socket, 141 | ?Authenticatable $user 142 | ): bool { 143 | if (! $socket) { 144 | return false; 145 | } 146 | 147 | if ($user instanceof Authenticatable && $this->presenceChannelEvent->isSelfLeaveEvent($event, 148 | $user)) { 149 | return true; 150 | } 151 | 152 | return $event->socket === $socket; 153 | } 154 | 155 | private function disableTimeouts(): void 156 | { 157 | ini_set('default_socket_timeout', -1); 158 | set_time_limit(0); 159 | } 160 | 161 | private function generateConnectionId(): string 162 | { 163 | return sprintf( 164 | '%d.%d', 165 | random_int(1, 1_000_000_000), 166 | random_int(1, 1_000_000_000) 167 | ); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Storage/BroadcastEventHistory.php: -------------------------------------------------------------------------------- 1 | db = Redis::connection(config('broadcasting.connections.redis.connection')); 24 | $this->lifetime = $config->get('wave.resume_lifetime', 60); 25 | } 26 | 27 | public function getEventsFrom(string $id): Collection 28 | { 29 | [$timestamp, $sequence] = explode('-', $id); 30 | $sequence = (int) $sequence + 1; 31 | 32 | return collect($this->db->xRange( 33 | 'broadcasted_events', 34 | $timestamp.'-'.$sequence, 35 | '+' 36 | ))->map(function ($event, $id) { 37 | $event['data'] = json_decode( 38 | $event['data'], 39 | true, 40 | 512, 41 | JSON_THROW_ON_ERROR 42 | ); 43 | 44 | return new BroadcastingEvent(...['id' => $id] + $event); 45 | })->values(); 46 | } 47 | 48 | public function lastEventTimestamp(): int 49 | { 50 | $keys = array_keys($this->db->xRevRange( 51 | 'broadcasted_events', 52 | '-', 53 | '+', 54 | 1 55 | )); 56 | 57 | return $keys === [] ? 0 : explode('-', reset($keys))[0]; 58 | } 59 | 60 | public function pushEvent(BroadcastingEvent $event) 61 | { 62 | $this->removeOldEvents(); 63 | 64 | $eventData = get_object_vars($event); 65 | $eventData['data'] = json_encode( 66 | $eventData['data'], 67 | JSON_THROW_ON_ERROR 68 | ); 69 | 70 | if ($this->db instanceof PredisConnection) { 71 | // @phpstan-ignore argument.type 72 | $id = $this->db->xAdd('broadcasted_events', $eventData, '*'); // @phpstan-ignore argument.type 73 | } else { 74 | $id = $this->db->xAdd('broadcasted_events', '*', $eventData); 75 | } 76 | 77 | $event->id = $id; 78 | 79 | return $id; 80 | } 81 | 82 | public function removeOldEvents() 83 | { 84 | // Calculate the threshold timestamp. Events older than this should be removed. 85 | $thresholdTimestamp = now()->subSeconds($this->lifetime)->getPreciseTimestamp(3); 86 | 87 | // Fetch all events up to the threshold 88 | $oldEvents = $this->db->xRange( 89 | 'broadcasted_events', 90 | '-', 91 | $thresholdTimestamp.'-0' 92 | ); 93 | 94 | if ($oldEvents === []) { 95 | return; 96 | } 97 | 98 | $this->db->xDel('broadcasted_events', array_keys($oldEvents)); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Storage/BroadcastingEvent.php: -------------------------------------------------------------------------------- 1 | channel, $this->name), 22 | is_array($this->data) ? json_encode($this->data, JSON_THROW_ON_ERROR) : $this->data, 23 | $this->id, 24 | config('wave.retry', null), 25 | ))(); 26 | } 27 | 28 | public static function fake(array $attributes = []): self 29 | { 30 | return new self( 31 | channel: $attributes['channel'] ?? fake()->word, 32 | name: $attributes['event'] ?? fake()->word, 33 | id: null, 34 | data: $attributes['data'] ?? ['message' => fake()->sentence], 35 | socket: $attributes['socket'] ?? fake()->randomNumber(6, true).'.'.fake()->randomNumber(6, true), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Storage/PresenceChannelUsersRedisRepository.php: -------------------------------------------------------------------------------- 1 | db = Redis::connection(config('broadcasting.connections.redis.connection')); 21 | } 22 | 23 | protected function channelMemberKey(string $channel, string ...$suffixes): string 24 | { 25 | return implode(':', \array_merge(['broadcasting_channels', $channel], $suffixes)); 26 | } 27 | 28 | protected function userChannelsKey(Authenticatable $user): string 29 | { 30 | return implode(':', ['broadcasting_channels', $this->userKey($user), 'user_channels']); 31 | } 32 | 33 | protected function serialize(array $value): string 34 | { 35 | return json_encode($value, JSON_THROW_ON_ERROR); 36 | } 37 | 38 | private function unserialize(string $value) 39 | { 40 | return json_decode($value, true, 512, JSON_THROW_ON_ERROR); 41 | } 42 | 43 | public function join(string $channel, Authenticatable $user, array $userInfo, string $connectionId): bool 44 | { 45 | $userKey = $this->userKey($user); 46 | $usersHashKey = $this->channelMemberKey($channel, 'users'); 47 | $socketsSetKey = $this->channelMemberKey($channel, $userKey, 'user_sockets'); 48 | $userChannelsKey = $this->userChannelsKey($user); 49 | 50 | $luaScript = <<<'LUA' 51 | local firstJoin = redis.call('hexists', KEYS[1], ARGV[1]) == 0 52 | redis.call('sadd', KEYS[2], ARGV[3]) 53 | redis.call('hset', KEYS[1], ARGV[1], ARGV[2]) 54 | redis.call('sadd', KEYS[3], ARGV[4]) 55 | return firstJoin and 1 or 0 56 | LUA; 57 | 58 | return $this->db->eval( 59 | $luaScript, 60 | 3, 61 | $usersHashKey, $socketsSetKey, $userChannelsKey, 62 | $userKey, $this->serialize($userInfo), $connectionId, $channel 63 | ); 64 | } 65 | 66 | public function leave(string $channel, Authenticatable $user, string $connectionId): bool 67 | { 68 | $userKey = $this->userKey($user); 69 | $socketsSetKey = $this->channelMemberKey($channel, $userKey, 'user_sockets'); 70 | $usersHashKey = $this->channelMemberKey($channel, 'users'); 71 | $userChannelsKey = $this->userChannelsKey($user); 72 | 73 | $luaScript = <<<'LUA' 74 | if redis.call('sismember', KEYS[1], ARGV[1]) == 1 then 75 | redis.call('srem', KEYS[1], ARGV[1]) 76 | if redis.call('scard', KEYS[1]) == 0 then 77 | redis.call('srem', KEYS[3], ARGV[3]) 78 | redis.call('hdel', KEYS[2], ARGV[2]) 79 | return 1 80 | end 81 | end 82 | return 0 83 | LUA; 84 | 85 | return $this->db->eval( 86 | $luaScript, 87 | 3, 88 | $socketsSetKey, $usersHashKey, $userChannelsKey, 89 | $connectionId, $userKey, $channel 90 | ); 91 | } 92 | 93 | public function getUsers(string $channel): array 94 | { 95 | return collect($this->db->hgetall($this->channelMemberKey($channel, 'users'))) 96 | ->map(fn ($userInfo) => $this->unserialize($userInfo)) 97 | ->values() 98 | ->toArray(); 99 | } 100 | 101 | public function removeConnection(Authenticatable $user, string $connectionId): array 102 | { 103 | return collect($this->db->smembers($this->userChannelsKey($user))) 104 | ->map(function ($channel) use ($user, $connectionId) { 105 | $userInfo = $this->unserialize($this->db->hget( 106 | $this->channelMemberKey($channel, 'users'), 107 | $this->userKey($user) 108 | )); 109 | 110 | if ($this->leave($channel, $user, $connectionId)) { 111 | return [ 112 | 'channel' => $channel, 113 | 'user_info' => $userInfo, 114 | ]; 115 | } 116 | 117 | return null; 118 | })->filter()->values()->toArray(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Storage/PresenceChannelUsersRepository.php: -------------------------------------------------------------------------------- 1 | name('laravel-wave') 31 | ->hasConfigFile() 32 | ->hasRoute('routes') 33 | ->hasCommand(SsePingCommand::class) 34 | ->hasCommand(ServeCommand::class); 35 | 36 | if (laravel11OrHigher()) { 37 | $package 38 | ->hasCommand(ConfigPublishCommand::class) 39 | ->hasCommand(BroadcastingInstallCommand::class); 40 | } 41 | } 42 | 43 | public function registeringPackage() 44 | { 45 | $redisConnectionName = config('broadcasting.connections.redis.connection'); 46 | 47 | config()->set("database.redis.$redisConnectionName-subscription", config("database.redis.$redisConnectionName")); 48 | 49 | $this->app->bind(BroadcastEventHistory::class, BroadcastEventHistoryRedisStream::class); 50 | $this->app->bind(PresenceChannelEvent::class, PresenceChannelEventHandler::class); 51 | 52 | $this->app->extend(BroadcastManager::class, fn ($service, $app) => new BroadcastManagerExtended($app)); 53 | 54 | $this->app->bind(ServerSentEventSubscriber::class, RedisSubscriber::class); 55 | $this->app->bind(PresenceChannelUsersRepository::class, PresenceChannelUsersRedisRepository::class); 56 | } 57 | 58 | public function bootingPackage() 59 | { 60 | Event::listen( 61 | SseConnectionClosedEvent::class, 62 | [RemoveStoredConnectionListener::class, 'handle'] 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | = 11; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /stubs/echo-bootstrap-js.stub: -------------------------------------------------------------------------------- 1 | /** 2 | * Echo exposes an expressive API for subscribing to channels and listening 3 | * for events that are broadcast by Laravel. Echo and event broadcasting 4 | * allow your team to quickly build robust real-time web applications. 5 | */ 6 | 7 | import './echo'; 8 | -------------------------------------------------------------------------------- /stubs/echo-js.stub: -------------------------------------------------------------------------------- 1 | import Echo from 'laravel-echo'; 2 | 3 | import { WaveConnector } from 'laravel-wave'; 4 | 5 | window.Echo = new Echo({ 6 | broadcaster: WaveConnector, 7 | }); 8 | --------------------------------------------------------------------------------