diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7d14e72431f..802e893e7f2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -33,6 +33,8 @@ This serves two purposes: - Added method `Asset::exists()` has to check if a media file exists. - Added a `Hyde::assets()` method to get all media file instances in the site. - Added new `npm run build` command for compiling frontend assets with Vite +- Added a Vite HMR support for the realtime compiler in https://github.com/hydephp/develop/pull/2016 +- Added Vite facade in https://github.com/hydephp/develop/pull/2016 ### Changed @@ -131,6 +133,7 @@ This serves two purposes: #### Realtime Compiler - Simplified the asset file locator to only serve files from the media source directory in https://github.com/hydephp/develop/pull/2012 +- Added Vite HMR support in https://github.com/hydephp/develop/pull/2016 ### Upgrade Guide diff --git a/_ide_helper.php b/_ide_helper.php index a838afac059..88203364b33 100644 --- a/_ide_helper.php +++ b/_ide_helper.php @@ -37,6 +37,7 @@ class Asset extends \Hyde\Facades\Asset {} class Author extends \Hyde\Facades\Author {} class Features extends \Hyde\Facades\Features {} class Config extends \Hyde\Facades\Config {} +class Vite extends \Hyde\Facades\Vite {} /** @mixin \Illuminate\Filesystem\Filesystem */ class Filesystem extends \Hyde\Facades\Filesystem {} class DataCollection extends \Hyde\Support\DataCollection {} diff --git a/app/config.php b/app/config.php index aed2519bbce..26156665112 100644 --- a/app/config.php +++ b/app/config.php @@ -92,6 +92,7 @@ 'Hyde' => Hyde\Hyde::class, 'Site' => \Hyde\Facades\Site::class, 'Meta' => \Hyde\Facades\Meta::class, + 'Vite' => \Hyde\Facades\Vite::class, 'Asset' => \Hyde\Facades\Asset::class, 'Author' => \Hyde\Facades\Author::class, 'HydeFront' => \Hyde\Facades\HydeFront::class, diff --git a/docs/extensions/realtime-compiler.md b/docs/extensions/realtime-compiler.md index 2af60006a0e..fbe487c1390 100644 --- a/docs/extensions/realtime-compiler.md +++ b/docs/extensions/realtime-compiler.md @@ -30,6 +30,11 @@ This will start a local development server at `http://localhost:8080` - `--pretty-urls=`: Enable pretty URLs. (Overrides config setting) - `--play-cdn=`: Enable the Tailwind Play CDN. (Overrides config setting) - `--open=false`: Open the site preview in the browser. +- `--vite`: Enable Vite for Hot Module Replacement (HMR). + +### Vite Integration + +By adding the `--vite` option, the serve command will initiate Vite's development server alongside the Hyde Realtime Compiler. This setup enables Hot Module Replacement (HMR), allowing for instant updates to your site as you make changes to your assets. ### Configuration diff --git a/docs/getting-started/console-commands.md b/docs/getting-started/console-commands.md index 2be9c7de878..f92f196e73b 100644 --- a/docs/getting-started/console-commands.md +++ b/docs/getting-started/console-commands.md @@ -101,7 +101,7 @@ Run the static site builder for a single file ```bash -php hyde serve [--host [HOST]] [--port [PORT]] +php hyde serve [--host [HOST]] [--port [PORT]] [--vite] ``` Start the realtime compiler server. diff --git a/packages/framework/resources/views/layouts/scripts.blade.php b/packages/framework/resources/views/layouts/scripts.blade.php index 9e121211b45..8f2a93343e3 100644 --- a/packages/framework/resources/views/layouts/scripts.blade.php +++ b/packages/framework/resources/views/layouts/scripts.blade.php @@ -1,5 +1,7 @@ {{-- The compiled Vite scripts --}} -@if(Asset::exists('app.js')) +@if(Vite::running()) + {{ Vite::assets(['resources/assets/app.js']) }} +@elseif(Asset::exists('app.js')) @endif diff --git a/packages/framework/resources/views/layouts/styles.blade.php b/packages/framework/resources/views/layouts/styles.blade.php index 8bf4d5bcc55..cf374c04c99 100644 --- a/packages/framework/resources/views/layouts/styles.blade.php +++ b/packages/framework/resources/views/layouts/styles.blade.php @@ -2,17 +2,22 @@ {{-- The compiled Tailwind/App styles --}} -@if(config('hyde.load_app_styles_from_cdn', false)) - -@elseif(Asset::exists('app.css')) - -@endif +@if(Vite::running()) + {{ Vite::assets(['resources/assets/app.css']) }} +@else + @if(config('hyde.load_app_styles_from_cdn', false)) + + @elseif(Asset::exists('app.css')) + + @endif + -{{-- Dynamic TailwindCSS Play CDN --}} -@if(config('hyde.use_play_cdn', false)) - - - + {{-- Dynamic TailwindCSS Play CDN --}} + @if(config('hyde.use_play_cdn', false)) + + + + @endif @endif {{-- Add any extra styles to include after the others --}} diff --git a/packages/framework/src/Console/Commands/ServeCommand.php b/packages/framework/src/Console/Commands/ServeCommand.php index bcb8e11a192..c998aba44ce 100644 --- a/packages/framework/src/Console/Commands/ServeCommand.php +++ b/packages/framework/src/Console/Commands/ServeCommand.php @@ -7,7 +7,9 @@ use Closure; use Hyde\Hyde; use Hyde\Facades\Config; +use Illuminate\Contracts\Process\InvokedProcess; use Illuminate\Support\Arr; +use Illuminate\Support\Sleep; use InvalidArgumentException; use Hyde\Console\Concerns\Command; use Hyde\RealtimeCompiler\ConsoleOutput; @@ -35,6 +37,7 @@ class ServeCommand extends Command {--pretty-urls= : Enable pretty URLs. (Overrides config setting)} {--play-cdn= : Enable the Tailwind Play CDN. (Overrides config setting)} {--open=false : Open the site preview in the browser.} + {--vite : Enable Vite for Hot Module Replacement (HMR)} '; /** @var string */ @@ -42,6 +45,9 @@ class ServeCommand extends Command protected ConsoleOutput $console; + protected InvokedProcess $server; + protected InvokedProcess $vite; + public function safeHandle(): int { $this->configureOutput(); @@ -51,11 +57,29 @@ public function safeHandle(): int $this->openInBrowser((string) $this->option('open')); } - $this->runServerProcess(sprintf('php -S %s:%d %s', + $command = sprintf('php -S %s:%d %s', $this->getHostSelection(), $this->getPortSelection(), $this->getExecutablePath() - )); + ); + + if ($this->option('vite')) { + $this->runViteProcess(); + } + + $this->runServerProcess($command); + + while ($this->server->running()) { + if (isset($this->vite) && $this->vite->running()) { + $output = $this->vite->latestOutput(); + + if ($output) { + $this->output->write($output); + } + } + + Sleep::usleep(100000); // 100ms + } return Command::SUCCESS; } @@ -77,7 +101,7 @@ protected function getExecutablePath(): string protected function runServerProcess(string $command): void { - Process::forever()->env($this->getEnvironmentVariables())->run($command, $this->getOutputHandler()); + $this->server = Process::forever()->env($this->getEnvironmentVariables())->start($command, $this->getOutputHandler()); } protected function getEnvironmentVariables(): array @@ -88,6 +112,7 @@ protected function getEnvironmentVariables(): array 'HYDE_SERVER_DASHBOARD' => $this->parseEnvironmentOption('dashboard'), 'HYDE_PRETTY_URLS' => $this->parseEnvironmentOption('pretty-urls'), 'HYDE_PLAY_CDN' => $this->parseEnvironmentOption('play-cdn'), + 'HYDE_SERVER_VITE' => $this->option('vite') ? 'enabled' : null, ]); } @@ -169,4 +194,29 @@ protected function getOpenCommand(string $osFamily): ?string default => null }; } + + protected function runViteProcess(): void + { + if (! $this->isPortAvailable(5173)) { + throw new InvalidArgumentException( + 'Unable to start Vite server: Port 5173 is already in use. '. + 'Please stop any other Vite processes and try again.' + ); + } + + $this->vite = Process::forever()->start('npm run dev'); + } + + /** @experimental This feature may be removed before the final release. */ + protected function isPortAvailable(int $port): bool + { + $socket = @fsockopen('localhost', $port, $errno, $errstr, 1); + if ($socket !== false) { + fclose($socket); + + return false; + } + + return true; + } } diff --git a/packages/framework/src/Facades/Vite.php b/packages/framework/src/Facades/Vite.php new file mode 100644 index 00000000000..b778f928789 --- /dev/null +++ b/packages/framework/src/Facades/Vite.php @@ -0,0 +1,52 @@ + false); // Todo: This warning surpressor does not work on Windows + $server = fsockopen('localhost', 5173, $errno, $errstr, 0.1); + restore_error_handler(); + + if ($server) { + fclose($server); + + return true; + } + + return false; + } + + public static function assets(array $paths): HtmlString + { + $html = sprintf(''); + + foreach ($paths as $path) { + if (str_ends_with($path, '.css')) { + $html .= sprintf('', $path); + } + + if (str_ends_with($path, '.js')) { + $html .= sprintf('', $path); + } + } + + return new HtmlString($html); + } +} diff --git a/packages/framework/tests/Feature/Commands/ServeCommandTest.php b/packages/framework/tests/Feature/Commands/ServeCommandTest.php index 2744c673fb0..1f26744c908 100644 --- a/packages/framework/tests/Feature/Commands/ServeCommandTest.php +++ b/packages/framework/tests/Feature/Commands/ServeCommandTest.php @@ -7,6 +7,7 @@ use Closure; use Hyde\Hyde; use Hyde\Testing\TestCase; +use Illuminate\Contracts\Process\InvokedProcess; use Illuminate\Support\Facades\Process; use TypeError; @@ -138,6 +139,11 @@ public function testHydeServeCommandWithInvalidConfigValue() public function testHydeServeCommandPassesThroughProcessOutput() { + $mockProcess = mock(InvokedProcess::class); + $mockProcess->shouldReceive('running') + ->once() + ->andReturn(false); + Process::shouldReceive('forever') ->once() ->withNoArgs() @@ -148,14 +154,14 @@ public function testHydeServeCommandPassesThroughProcessOutput() ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false]) ->andReturnSelf(); - Process::shouldReceive('run') + Process::shouldReceive('start') ->once() ->withArgs(function (string $command, Closure $handle) { $handle('type', 'foo'); return $command === "php -S localhost:8080 {$this->binaryPath()}"; }) - ->andReturnSelf(); + ->andReturn($mockProcess); $this->artisan('serve --no-ansi') ->expectsOutput('Starting the HydeRC server... Use Ctrl+C to stop') @@ -174,6 +180,102 @@ public function testWithFancyOutput() Process::assertRan("php -S localhost:8080 {$this->binaryPath()}"); } + public function testHydeServeCommandWithViteOption() + { + $mockViteProcess = mock(InvokedProcess::class); + $mockViteProcess->shouldReceive('running') + ->once() + ->andReturn(true); + $mockViteProcess->shouldReceive('latestOutput') + ->once() + ->andReturn('vite latest output'); + + $mockServerProcess = mock(InvokedProcess::class); + $mockServerProcess->shouldReceive('running') + ->times(2) + ->andReturn(true, false); + + Process::shouldReceive('forever') + ->twice() + ->withNoArgs() + ->andReturnSelf(); + + Process::shouldReceive('env') + ->once() + ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false, 'HYDE_SERVER_VITE' => 'enabled']) + ->andReturnSelf(); + + Process::shouldReceive('start') + ->once() + ->with('npm run dev') + ->andReturn($mockViteProcess); + + Process::shouldReceive('start') + ->once() + ->withArgs(function (string $command, Closure $output) { + $output('stdout', 'server output'); + + return $command === "php -S localhost:8080 {$this->binaryPath()}"; + }) + ->andReturn($mockServerProcess); + + $this->artisan('serve --no-ansi --vite') + ->expectsOutput('Starting the HydeRC server... Use Ctrl+C to stop') + ->expectsOutput('server output') + ->expectsOutput('vite latest output') + ->assertExitCode(0); + } + + public function testHydeServeCommandWithViteOptionButViteNotRunning() + { + $mockViteProcess = mock(InvokedProcess::class); + $mockViteProcess->shouldReceive('running') + ->once() + ->andReturn(false); + + $mockServerProcess = mock(InvokedProcess::class); + $mockServerProcess->shouldReceive('running') + ->times(2) + ->andReturn(true, false); + + Process::shouldReceive('forever') + ->twice() + ->withNoArgs() + ->andReturnSelf(); + + Process::shouldReceive('env') + ->once() + ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false, 'HYDE_SERVER_VITE' => 'enabled']) + ->andReturnSelf(); + + Process::shouldReceive('start') + ->once() + ->with('npm run dev') + ->andReturn($mockViteProcess); + + Process::shouldReceive('start') + ->once() + ->withArgs(function (string $command, Closure $handle) { + return $command === "php -S localhost:8080 {$this->binaryPath()}"; + }) + ->andReturn($mockServerProcess); + + $this->artisan('serve --no-ansi --vite') + ->expectsOutput('Starting the HydeRC server... Use Ctrl+C to stop') + ->assertExitCode(0); + } + + public function testHydeServeCommandWithViteOptionThrowsWhenPortIsInUse() + { + $socket = stream_socket_server('tcp://127.0.0.1:5173'); + + $this->artisan('serve --vite') + ->expectsOutputToContain('Unable to start Vite server: Port 5173 is already in use') + ->assertExitCode(1); + + stream_socket_shutdown($socket, STREAM_SHUT_RDWR); + } + protected function binaryPath(): string { return Hyde::path('vendor/hyde/realtime-compiler/bin/server.php'); diff --git a/packages/framework/tests/Unit/Facades/ViteFacadeTest.php b/packages/framework/tests/Unit/Facades/ViteFacadeTest.php new file mode 100644 index 00000000000..d056f0e3dc0 --- /dev/null +++ b/packages/framework/tests/Unit/Facades/ViteFacadeTest.php @@ -0,0 +1,77 @@ +assertTrue(Vite::running()); + + putenv('HYDE_SERVER_VITE'); + } + + public function testRunningReturnsTrueWhenViteServerIsAccessible() + { + // Create a mock server to simulate Vite + $server = stream_socket_server('tcp://localhost:5173'); + + $this->assertTrue(Vite::running()); + + stream_socket_shutdown($server, STREAM_SHUT_RDWR); + } + + public function testRunningReturnsFalseWhenViteServerIsNotAccessible() + { + $this->assertFalse(Vite::running()); + } + + public function testAssetsMethodGeneratesCorrectHtmlForJavaScriptFiles() + { + $html = Vite::assets(['resources/js/app.js']); + + $expected = '' + .''; + + $this->assertSame($expected, (string) $html); + } + + public function testAssetsMethodGeneratesCorrectHtmlForCssFiles() + { + $html = Vite::assets(['resources/css/app.css']); + + $expected = '' + .''; + + $this->assertSame($expected, (string) $html); + } + + public function testAssetsMethodGeneratesCorrectHtmlForMultipleFiles() + { + $html = Vite::assets([ + 'resources/js/app.js', + 'resources/css/app.css', + 'resources/js/other.js', + ]); + + $expected = '' + .'' + .'' + .''; + + $this->assertSame($expected, (string) $html); + } + + public function testAssetsMethodReturnsHtmlString() + { + $this->assertInstanceOf(\Illuminate\Support\HtmlString::class, Vite::assets([])); + } +} diff --git a/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php b/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php index f76d70e35ee..9df671ec114 100644 --- a/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php +++ b/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php @@ -335,6 +335,29 @@ public function testGetOpenCommandForUnknownOS() $this->assertNull($this->getMock()->getOpenCommand('UnknownOS')); } + public function testViteOptionPropagatesToEnvironmentVariables() + { + $command = $this->getMock(['vite' => true]); + $this->assertSame('enabled', $command->getEnvironmentVariables()['HYDE_SERVER_VITE']); + + $command = $this->getMock(['vite' => false]); + $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_SERVER_VITE'])); + + $command = $this->getMock(); + $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_SERVER_VITE'])); + } + + public function testWithViteArgument() + { + HydeKernel::setInstance(new HydeKernel()); + + $command = $this->getViteServeCommandMock(['vite' => true]); + + $command->safeHandle(); + + $this->assertTrue($command->viteProcessStarted); + } + protected function getTestRunnerBinary(): string { return match (PHP_OS_FAMILY) { @@ -367,6 +390,8 @@ protected function printStartMessage(): void protected function runServerProcess(string $command): void { + $this->server = Mockery::mock(\Illuminate\Contracts\Process\InvokedProcess::class); + $this->server->shouldReceive('running')->once()->andReturn(false); } protected function openInBrowser(string $path = '/'): void @@ -376,6 +401,38 @@ protected function openInBrowser(string $path = '/'): void } }; } + + protected function getViteServeCommandMock(array $arguments): ServeCommandMock + { + return new class($arguments) extends ServeCommandMock + { + public bool $viteProcessStarted = false; + + // Void unrelated methods + protected function configureOutput(): void + { + } + + protected function printStartMessage(): void + { + } + + protected function runServerProcess(string $command): void + { + $this->server = Mockery::mock(\Illuminate\Contracts\Process\InvokedProcess::class); + $this->server->shouldReceive('running')->once()->andReturn(false); + } + + protected function runViteProcess(): void + { + $this->viteProcessStarted = true; + } + + protected function openInBrowser(string $path = '/'): void + { + } + }; + } } /** diff --git a/packages/realtime-compiler/resources/vite-index-page.html b/packages/realtime-compiler/resources/vite-index-page.html new file mode 100644 index 00000000000..aee5828f9c3 --- /dev/null +++ b/packages/realtime-compiler/resources/vite-index-page.html @@ -0,0 +1,66 @@ + + +
+ + + +This is the Vite development server that provides Hot Module Replacement for your HydePHP site.
+Your HydePHP site is running on the Realtime Compiler server.
+The main development server for your HydePHP site.
+Access the realtime compiler dashboard to monitor your site.
+
+ Your HydePHP site is running at:
+ http://localhost:8080
+
Want to learn more about HydePHP?
++ Read the docs → +
+