├── .gitignore ├── demo.gif ├── config └── serve_livereload.php ├── views └── script.blade.php ├── src ├── Commands │ ├── ServeHttpCommand.php │ ├── ServeCommand.php │ └── ServeWebSocketsCommand.php ├── ResponseServiceProvider.php ├── Injector.php ├── Socket.php ├── Middleware │ └── InjectScriptsMiddleware.php ├── Watcher.php └── CommandServiceProvider.php ├── phpunit.xml.dist ├── LICENSE ├── composer.json ├── readme.md └── tests └── InjectScriptsToResponseTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | composer.lock 3 | .phpunit.result.cache 4 | vendor/ -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangnokia/laravel-serve-livereload/HEAD/demo.gif -------------------------------------------------------------------------------- /config/serve_livereload.php: -------------------------------------------------------------------------------- 1 | ['/app', '/public', '/config', '/routes', '/resources'], 5 | ]; 6 | -------------------------------------------------------------------------------- /views/script.blade.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/Commands/ServeHttpCommand.php: -------------------------------------------------------------------------------- 1 | loadViewsFrom(__DIR__.'/../views/', 'serve_livereload'); 14 | 15 | $this->app->make(Kernel::class) 16 | ->prependMiddlewareToGroup('web', InjectScriptsMiddleware::class); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Injector.php: -------------------------------------------------------------------------------- 1 | tag or not. 12 | * 13 | * @param string $content 14 | * 15 | * @return string 16 | */ 17 | public function injectScripts($content) 18 | { 19 | $content = (string) view('serve_livereload::script', [ 20 | 'host' => '127.0.0.1', 21 | 'port' => ServeWebSocketsCommand::port(), 22 | ]).$content; 23 | 24 | return $content; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Socket.php: -------------------------------------------------------------------------------- 1 | attach($conn); 20 | } 21 | 22 | public function onClose(ConnectionInterface $conn) 23 | { 24 | self::$clients->detach($conn); 25 | } 26 | 27 | public function onError(ConnectionInterface $conn, \Exception $e) 28 | { 29 | } 30 | 31 | public function onMessage(ConnectionInterface $from, $msg) 32 | { 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Middleware/InjectScriptsMiddleware.php: -------------------------------------------------------------------------------- 1 | getMethod() === Request::METHOD_GET 21 | && Str::startsWith($response->headers->get('Content-Type'), 'text/html') 22 | && !$request->isXmlHttpRequest() 23 | && !$response instanceof JsonResponse 24 | ) { 25 | $response->setContent( 26 | (new Injector())->injectScripts($response->getContent()) 27 | ); 28 | } 29 | 30 | return $response; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nguyen Viet 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bangnokia/laravel-serve-livereload", 3 | "description": "Bring livereload to laravel artisan serve command", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "bangnokia", 8 | "email": "bangnokia@gmail.com" 9 | } 10 | ], 11 | "require": { 12 | "illuminate/console": "^6.0|^7.0|^8", 13 | "cboden/ratchet": "^0.4", 14 | "symfony/process": "^4.4|^5.0", 15 | "yosymfony/resource-watcher": "^2.0" 16 | }, 17 | "require-dev": { 18 | "orchestra/testbench": "^5.0" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "BangNokia\\ServeLiveReload\\": "src/" 23 | } 24 | }, 25 | "autoload-dev": { 26 | "psr-4": { 27 | "BangNokia\\ServeLiveReload\\Tests\\": "tests/" 28 | } 29 | }, 30 | "minimum-stability": "dev", 31 | "extra": { 32 | "laravel": { 33 | "providers": [ 34 | "BangNokia\\ServeLiveReload\\CommandServiceProvider", 35 | "BangNokia\\ServeLiveReload\\ResponseServiceProvider" 36 | ] 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Watcher.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 21 | 22 | $this->finder = $finder; 23 | } 24 | 25 | public function startWatching(\Closure $callback) 26 | { 27 | $watcher = new ResourceWatcher( 28 | new ResourceCacheMemory(), 29 | $this->finder, 30 | new Crc32ContentHash() 31 | ); 32 | 33 | $this->loop->addPeriodicTimer(0.5, function () use ($watcher, $callback) { 34 | Cache::put('serve_websockets_running', true, 5); 35 | if ($watcher->findChanges()->hasChanges()) { 36 | call_user_func($callback); 37 | } 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/CommandServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton('command.serve', function () { 17 | return new ServeCommand(); 18 | }); 19 | 20 | // register new commands 21 | $this->commands([ 22 | ServeCommand::class, 23 | ServeHttpCommand::class, 24 | ServeWebSocketsCommand::class, 25 | ]); 26 | 27 | $this->mergeConfigFrom(__DIR__.'/../config/serve_livereload.php', 'serve_livereload'); 28 | } 29 | 30 | public function boot() 31 | { 32 | $this->publishes([ 33 | __DIR__.'/../config/serve_livereload.php' => config_path('serve_livereload.php'), 34 | ]); 35 | } 36 | 37 | public function provides() 38 | { 39 | return ['command.serve']; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Laravel Serve LiveReload 2 | 3 | 4 | >**This package is daed, please use the new [Vite integrated](https://laravel.com/docs/9.x/vite#introduction) instead** 5 | 6 | 7 | This package integrates into default `artisan serve` command an WebSockets server for live reloading the application when any file changed. How it works, checkout my [blog post](https://daudau.cc/posts/laravel-live-reload-without-javascript) for explaination if you don't like digging into source code. 8 | 9 | ![Laravel serve livereload](demo.gif) 10 | 11 | ## Installation 12 | 13 | For laravel 8, please use version ^1.x, and below use version 0.x 14 | 15 | `composer require bangnokia/laravel-serve-livereload --dev` 16 | 17 | 18 | 19 | ## Usage 20 | 21 | Open terminal and run `php artisan serve` 22 | 23 | This package works even when you use custom vhost such as `valet` or `laragon` 24 | 25 | ## Configuration 26 | 27 | By default, this package looking for files changes in these directories: 28 | 29 | ``` 30 | /app 31 | /public 32 | /config 33 | /routes 34 | /resources 35 | ``` 36 | 37 | If you want to customize the watched forlders, you can publish the configuration file by this commmand: 38 | 39 | ```bash 40 | php artisan vendor:publish --provider="BangNokia\ServeLiveReload\CommandServiceProvider" 41 | ``` 42 | 43 | and then you can config what you want in the `config/serve_livereload.php`. 44 | 45 | -------------------------------------------------------------------------------- /src/Commands/ServeCommand.php: -------------------------------------------------------------------------------- 1 | find(false); 22 | $artisanPath = base_path('artisan'); 23 | 24 | $processes = [ 25 | $httpProcess = new Process(array_merge([$phpBinaryPath, $artisanPath, 'serve:http'], $this->serveOptions())), 26 | $socketProcess = new Process([$phpBinaryPath, $artisanPath, 'serve:websockets']), 27 | ]; 28 | 29 | while (count($processes)) { 30 | /* @var \Symfony\Component\Process\Process $process */ 31 | foreach ($processes as $i => $process) { 32 | if (!$process->isStarted()) { 33 | $process->setTimeout(null); 34 | $process->start(); 35 | continue; 36 | } 37 | 38 | if ($info = trim($process->getIncrementalOutput())) { 39 | $this->info($info); 40 | } 41 | 42 | if ($error = trim($process->getIncrementalErrorOutput())) { 43 | $this->line($error); 44 | } 45 | 46 | if (!$process->isRunning()) { 47 | unset($processes[$i]); 48 | } 49 | } 50 | sleep(1); 51 | } 52 | } 53 | 54 | public function serveOptions() 55 | { 56 | return collect($this->options()) 57 | ->filter(function ($option) { 58 | return $option; 59 | })->map(function ($value, $key) { 60 | if (is_bool($value)) { 61 | return "--{$key}"; 62 | } 63 | 64 | return "--{$key}={$value}"; 65 | })->values()->toArray(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/InjectScriptsToResponseTest.php: -------------------------------------------------------------------------------- 1 | router = Route::middleware('web'); 20 | } 21 | 22 | public function getPackageProviders($app) 23 | { 24 | return [ 25 | \BangNokia\ServeLiveReload\ResponseServiceProvider::class, 26 | ]; 27 | } 28 | 29 | protected function resolveApplicationHttpKernel($app) 30 | { 31 | parent::resolveApplicationHttpKernel($app); 32 | $app->make(\Illuminate\Contracts\Http\Kernel::class); 33 | } 34 | 35 | public function test_it_inject_scripts_to_get_request() 36 | { 37 | $this->router->get('/_test/html', function () { 38 | return 'html'; 39 | }); 40 | 41 | $this->get('/_test/html') 42 | ->assertSee('ws://127.0.0.1:'.ServeWebSocketsCommand::port()); 43 | } 44 | 45 | public function test_it_doesnt_inject_script_to_post_request() 46 | { 47 | $this->router->post('/_test/html', function () { 48 | return 'html'; 49 | }); 50 | 51 | $this->post('/_test/html') 52 | ->assertSee('html') 53 | ->assertDontSee('ws://127.0.0.1:'.ServeWebSocketsCommand::port()); 54 | } 55 | 56 | public function test_it_doesnt_inject_scripts_to_json_response() 57 | { 58 | $this->router->get('/_test/json', function () { 59 | return ['name' => 'John Doe']; 60 | }); 61 | $this->router->get('/_test/json2', function () { 62 | return response()->json(['name' => 'John Doe2']); 63 | }); 64 | 65 | $this->get('/_test/json') 66 | ->assertJson(['name' => 'John Doe']); 67 | 68 | $this->get('/_test/json2') 69 | ->assertJson(['name' => 'John Doe2']); 70 | } 71 | 72 | public function test_it_doesnt_inject_script_to_ajax_response() 73 | { 74 | $this->router->get('/_test/html', function () { 75 | return 'html string'; 76 | }); 77 | 78 | $this->get('/_test/html', ['HTTP_X-Requested-With' => 'XMLHttpRequest']) 79 | ->assertSee('html string') 80 | ->assertDontSee('ws://127.0.0.1:'.ServeWebSocketsCommand::port()); 81 | } 82 | 83 | public function tearDown(): void 84 | { 85 | parent::tearDown(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Commands/ServeWebSocketsCommand.php: -------------------------------------------------------------------------------- 1 | loop = LoopFactory::create(); 57 | 58 | $this->loop->futureTick(function () { 59 | $this->line('Websockets server started: ws://127.0.0.1:'.self::port()); 60 | }); 61 | 62 | $this->startWatcher(); 63 | 64 | $this->startServer(); 65 | } 66 | 67 | protected function startWatcher() 68 | { 69 | $finder = (new Finder())->files()->in($this->dirs()); 70 | 71 | (new Watcher($this->loop, $finder))->startWatching(function () { 72 | collect(Socket::$clients)->map(function (ConnectionInterface $client) { 73 | $client->send('reload'); 74 | }); 75 | }); 76 | } 77 | 78 | protected function startServer() 79 | { 80 | try { 81 | $this->server = new IoServer( 82 | new HttpServer(new WsServer(new Socket())), 83 | new Reactor('127.0.0.1:'.self::port(), $this->loop), 84 | $this->loop 85 | ); 86 | 87 | $this->server->run(); 88 | } catch (\Exception $exception) { 89 | if (self::$offset < 10) { 90 | self::$offset++; 91 | $this->startServer(); 92 | } 93 | } 94 | } 95 | 96 | protected function dirs() 97 | { 98 | return array_map(function ($dir) { 99 | return base_path('/'.$dir); 100 | }, config('serve_livereload.dirs')); 101 | } 102 | 103 | public static function port() 104 | { 105 | return self::$port + self::$offset; 106 | } 107 | } 108 | --------------------------------------------------------------------------------