diff --git a/.editorconfig b/.editorconfig index 6537ca467..d7841906c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,3 +13,6 @@ trim_trailing_whitespace = false [*.{yml,yaml}] indent_size = 2 + +[Caddyfile] +indent_style = tab diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a138e3f2..4f4f750b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Fix passing invalid connection session id to `Swoole\Http\Response::create()` by [@smortexa](https://github.com/smortexa) in https://github.com/laravel/octane/pull/737 - Fix missing mode config by [@sy-records](https://github.com/sy-records) in https://github.com/laravel/octane/pull/740 -- Add “raw” type in handleStream method for custom json in stdout by [@mphamid](https://github.com/mphamid) in https://github.com/laravel/octane/pull/742 +- Add `raw` type in handleStream method for custom json in stdout by [@mphamid](https://github.com/mphamid) in https://github.com/laravel/octane/pull/742 ## [v2.0.5](https://github.com/laravel/octane/compare/v2.0.4...v2.0.5) - 2023-08-08 diff --git a/README.md b/README.md index a9ca98123..83bc3efe4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ## Introduction -Laravel Octane supercharges your application's performance by serving your application using high-powered application servers, including [Open Swoole](https://openswoole.com), [Swoole](https://github.com/swoole/swoole-src), and [RoadRunner](https://roadrunner.dev). Octane boots your application once, keeps it in memory, and then feeds it requests at supersonic speeds. +Laravel Octane supercharges your application's performance by serving your application using high-powered application servers, including [FrankenPHP](https://frankenphp.dev), [Open Swoole](https://openswoole.com), [Swoole](https://github.com/swoole/swoole-src), and [RoadRunner](https://roadrunner.dev). Octane boots your application once, keeps it in memory, and then feeds it requests at supersonic speeds. ## Official Documentation diff --git a/bin/bootstrap.php b/bin/bootstrap.php index 06043984a..e870b8a0f 100644 --- a/bin/bootstrap.php +++ b/bin/bootstrap.php @@ -1,6 +1,7 @@ boot(); + + [$request, $context] = $frankenPhpClient->marshalRequest(new RequestContext()); + + $worker->handle($request, $context); + } catch (Throwable $e) { + if ($worker) { + report($e); + } + + $response = new Response( + 'Internal Server Error', + 500, + [ + 'Status' => '500 Internal Server Error', + 'Content-Type' => 'text/plain', + ], + ); + + $response->send(); + + Stream::shutdown($e); + } + }; + + while ($requestCount < $maxRequests && frankenphp_handle_request($handleRequest)) { + $requestCount++; + } +} finally { + $worker?->terminate(); +} diff --git a/composer.json b/composer.json index 3558cc59e..10674cf02 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "laravel/octane", "description": "Supercharge your Laravel application's performance.", - "keywords": ["laravel", "octane", "roadrunner", "swoole"], + "keywords": ["laravel", "octane", "roadrunner", "swoole", "frankenphp"], "license": "MIT", "support": { "issues": "https://github.com/laravel/octane/issues", diff --git a/config/octane.php b/config/octane.php index 818ee7036..8b578aef6 100644 --- a/config/octane.php +++ b/config/octane.php @@ -32,7 +32,7 @@ | when starting, restarting, or stopping your server via the CLI. You | are free to change this to the supported server of your choosing. | - | Supported: "roadrunner", "swoole" + | Supported: "roadrunner", "swoole", "frankenphp" | */ @@ -135,22 +135,6 @@ // ], - /* - |-------------------------------------------------------------------------- - | Octane Cache Table - |-------------------------------------------------------------------------- - | - | While using Swoole, you may leverage the Octane cache, which is powered - | by a Swoole table. You may set the maximum number of rows as well as - | the number of bytes per row using the configuration options below. - | - */ - - 'cache' => [ - 'rows' => 1000, - 'bytes' => 10000, - ], - /* |-------------------------------------------------------------------------- | Octane Swoole Tables @@ -169,6 +153,22 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Octane Swoole Cache Table + |-------------------------------------------------------------------------- + | + | While using Swoole, you may leverage the Octane cache, which is powered + | by a Swoole table. You may set the maximum number of rows as well as + | the number of bytes per row using the configuration options below. + | + */ + + 'cache' => [ + 'rows' => 1000, + 'bytes' => 10000, + ], + /* |-------------------------------------------------------------------------- | File Watching diff --git a/src/ApplicationGateway.php b/src/ApplicationGateway.php index 2b0cbf591..21dd42841 100644 --- a/src/ApplicationGateway.php +++ b/src/ApplicationGateway.php @@ -25,6 +25,8 @@ public function __construct(protected Application $app, protected Application $s */ public function handle(Request $request): Response { + $request->enableHttpMethodParameterOverride(); + $this->dispatchEvent($this->sandbox, new RequestReceived($this->app, $this->sandbox, $request)); if (Octane::hasRouteFor($request->getMethod(), '/'.$request->path())) { diff --git a/src/Commands/Concerns/InstallsFrankenPhpDependencies.php b/src/Commands/Concerns/InstallsFrankenPhpDependencies.php new file mode 100644 index 000000000..0ba4f92d9 --- /dev/null +++ b/src/Commands/Concerns/InstallsFrankenPhpDependencies.php @@ -0,0 +1,163 @@ +findFrankenPhpBinary())) { + return $frankenphpBinary; + } + + if ($this->confirm('Unable to locate FrankenPHP binary. Should Octane download the binary for your operating system?', true)) { + $this->downloadFrankenPhpBinary(); + } + + return base_path('frankenphp'); + } + + /** + * Download the latest version of the FrankenPHP binary. + * + * @return bool + */ + protected function downloadFrankenPhpBinary() + { + $arch = php_uname('m'); + + $assetName = match (true) { + PHP_OS_FAMILY === 'Linux' && $arch === 'x86_64' => 'frankenphp-linux-x86_64', + PHP_OS_FAMILY === 'Darwin' => "frankenphp-mac-$arch", + default => null, + }; + + if ($assetName === null) { + $this->error('FrankenPHP binaries are currently only available for Linux (x86_64) and macOS. Other systems should use the Docker images or compile FrankenPHP manually.'); + + return false; + } + + $assets = Http::accept('application/vnd.github+json') + ->withHeaders(['X-GitHub-Api-Version' => '2022-11-28']) + ->get('https://api.github.com/repos/dunglas/frankenphp/releases/latest')['assets']; + + foreach ($assets as $asset) { + if ($asset['name'] !== $assetName) { + continue; + } + + $path = base_path('frankenphp'); + + $progressBar = null; + + (new Client)->get( + $asset['browser_download_url'], + [ + 'sink' => $path, + 'progress' => function ($downloadTotal, $downloadedBytes) use (&$progressBar) { + if ($downloadTotal === 0) { + return; + } + + if ($progressBar === null) { + $progressBar = $this->output->createProgressBar($downloadTotal); + $progressBar->start($downloadTotal, $downloadedBytes); + + return; + } + + $progressBar->setProgress($downloadedBytes); + }, + ] + ); + + chmod($path, 0755); + + $progressBar->finish(); + + $this->newLine(); + + return $path; + } + + $this->error('FrankenPHP asset not found.'); + + return $path; + } + + /** + * Ensure the installed FrankenPHP binary meets Octane's requirements. + * + * @param string $frakenPhpBinary + * @return void + */ + protected function ensureFrankenPhpBinaryMeetsRequirements($frakenPhpBinary) + { + $version = tap(new Process([$frakenPhpBinary, '--version'], base_path())) + ->run() + ->getOutput(); + + $version = explode(' ', $version)[1] ?? null; + + if ($version === null) { + return $this->warn( + 'Unable to determine the current FrankenPHP binary version. Please report this issue: https://github.com/laravel/octane/issues/new.', + ); + } + + if (version_compare($version, $this->requiredFrankenPhpVersion, '>=')) { + return; + } + + $this->warn("Your FrankenPHP binary version ($version) may be incompatible with Octane."); + + if ($this->confirm('Should Octane download the latest FrankenPHP binary version for your operating system?', true)) { + rename($frakenPhpBinary, "$frakenPhpBinary.backup"); + + try { + $this->downloadFrankenPhpBinary(); + } catch (Throwable $e) { + report($e); + + rename("$frakenPhpBinary.backup", $frakenPhpBinary); + + return $this->warn('Unable to download FrankenPHP binary. The HTTP request exception has been logged.'); + } + + unlink("$frakenPhpBinary.backup"); + } + } +} diff --git a/src/Commands/Concerns/InstallsRoadRunnerDependencies.php b/src/Commands/Concerns/InstallsRoadRunnerDependencies.php index e4ebf9bb4..a93dc0e7a 100644 --- a/src/Commands/Concerns/InstallsRoadRunnerDependencies.php +++ b/src/Commands/Concerns/InstallsRoadRunnerDependencies.php @@ -21,7 +21,7 @@ trait InstallsRoadRunnerDependencies * * @var string */ - protected $requiredVersion = '2023.3.0'; + protected $requiredRoadRunnerVersion = '2023.3.0'; /** * Determine if RoadRunner is installed. @@ -131,7 +131,7 @@ protected function ensureRoadRunnerBinaryMeetsRequirements($roadRunnerBinary) $version = explode(' ', $version)[2]; - if (version_compare($version, $this->requiredVersion, '>=')) { + if (version_compare($version, $this->requiredRoadRunnerVersion, '>=')) { return; } diff --git a/src/Commands/Concerns/InteractsWithIO.php b/src/Commands/Concerns/InteractsWithIO.php index 40790f612..8e95c6f67 100644 --- a/src/Commands/Concerns/InteractsWithIO.php +++ b/src/Commands/Concerns/InteractsWithIO.php @@ -7,6 +7,7 @@ use Laravel\Octane\Exceptions\DdException; use Laravel\Octane\Exceptions\ServerShutdownException; use Laravel\Octane\Exceptions\WorkerException; +use Laravel\Octane\Octane; use Laravel\Octane\WorkerExceptionInspector; use NunoMaduro\Collision\Writer; use Symfony\Component\VarDumper\VarDumper; @@ -34,6 +35,7 @@ trait InteractsWithIO 'worker destroyed', '[INFO] RoadRunner server started; version:', '[INFO] sdnotify: not notified', + 'exiting; byeee!!', ]; /** @@ -46,7 +48,7 @@ public function raw($string) { if (! Str::startsWith($string, $this->ignoreMessages)) { $this->output instanceof OutputStyle - ? fwrite(STDERR, $string."\n") + ? Octane::writeError($string) : $this->output->writeln($string); } } diff --git a/src/Commands/Concerns/InteractsWithServers.php b/src/Commands/Concerns/InteractsWithServers.php index f1adc8598..98593901f 100644 --- a/src/Commands/Concerns/InteractsWithServers.php +++ b/src/Commands/Concerns/InteractsWithServers.php @@ -100,7 +100,7 @@ protected function writeServerRunningMessage() $this->output->writeln([ '', - ' Local: http://'.$this->getHost().':'.$this->getPort().' ', + ' Local: '.($this->hasOption('https') && $this->option('https') ? 'https://' : 'http://').$this->getHost().':'.$this->getPort().' ', '', ' Press Ctrl+C to stop the server', '', @@ -114,10 +114,14 @@ protected function writeServerRunningMessage() */ protected function getServerOutput($server) { - return tap([ + $output = [ $server->getIncrementalOutput(), $server->getIncrementalErrorOutput(), - ], fn () => $server->clearOutput()->clearErrorOutput()); + ]; + + $server->clearOutput()->clearErrorOutput(); + + return $output; } /** diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php index cc3434f25..af42168a0 100644 --- a/src/Commands/InstallCommand.php +++ b/src/Commands/InstallCommand.php @@ -8,7 +8,8 @@ class InstallCommand extends Command { - use Concerns\InstallsRoadRunnerDependencies; + use Concerns\InstallsFrankenPhpDependencies, + Concerns\InstallsRoadRunnerDependencies; /** * The command's signature. @@ -34,12 +35,13 @@ public function handle() { $server = $this->option('server') ?: $this->choice( 'Which application server you would like to use?', - ['roadrunner', 'swoole'], + ['roadrunner', 'swoole', 'frankenphp'], ); return (int) ! tap(match ($server) { 'swoole' => $this->installSwooleServer(), 'roadrunner' => $this->installRoadRunnerServer(), + 'frankenphp' => $this->installFrankenPhpServer(), default => $this->invalidServer($server), }, function ($installed) use ($server) { if ($installed) { @@ -116,6 +118,36 @@ public function installSwooleServer() return true; } + /** + * Install the FrankenPHP server. + * + * @return bool + */ + public function installFrankenPhpServer() + { + if (! $this->confirm("FrankenPHP's Octane integration is in beta and should be used with caution in production. Do you wish to continue?")) { + return false; + } + + $gitIgnorePath = base_path('.gitignore'); + + if (File::exists($gitIgnorePath)) { + $contents = File::get($gitIgnorePath); + + $filesToAppend = collect(['frankenphp', 'frankenphp-worker.php']) + ->filter(fn ($file) => ! str_contains($contents, $file.PHP_EOL)) + ->implode(PHP_EOL); + + if ($filesToAppend !== '') { + File::append($gitIgnorePath, PHP_EOL.$filesToAppend.PHP_EOL); + } + } + + $this->ensureFrankenPhpWorkerIsInstalled(); + + return $this->ensureFrankenPhpBinaryIsInstalled(); + } + /** * Inform the user that the server type is invalid. * diff --git a/src/Commands/ReloadCommand.php b/src/Commands/ReloadCommand.php index 95b0ccb61..6e5783d28 100644 --- a/src/Commands/ReloadCommand.php +++ b/src/Commands/ReloadCommand.php @@ -2,6 +2,7 @@ namespace Laravel\Octane\Commands; +use Laravel\Octane\FrankenPhp\ServerProcessInspector as FrankenPhpServerProcessInspector; use Laravel\Octane\RoadRunner\ServerProcessInspector as RoadRunnerServerProcessInspector; use Laravel\Octane\Swoole\ServerProcessInspector as SwooleServerProcessInspector; @@ -33,6 +34,7 @@ public function handle() return match ($server) { 'swoole' => $this->reloadSwooleServer(), 'roadrunner' => $this->reloadRoadRunnerServer(), + 'frankenphp' => $this->reloadFrankenPhpServer(), default => $this->invalidServer($server), }; } @@ -81,6 +83,28 @@ protected function reloadRoadRunnerServer() return 0; } + /** + * Reload the FrankenPHP server for Octane. + * + * @return int + */ + protected function reloadFrankenPhpServer() + { + $inspector = app(FrankenPhpServerProcessInspector::class); + + if (! $inspector->serverIsRunning()) { + $this->error('Octane server is not running.'); + + return 1; + } + + $this->info('Reloading workers...'); + + $inspector->reloadServer(); + + return 0; + } + /** * Inform the user that the server type is invalid. * diff --git a/src/Commands/StartCommand.php b/src/Commands/StartCommand.php index 87b1e2634..7e255b36f 100644 --- a/src/Commands/StartCommand.php +++ b/src/Commands/StartCommand.php @@ -23,6 +23,8 @@ class StartCommand extends Command implements SignalableCommandInterface {--task-workers=auto : The number of task workers that should be available to handle tasks} {--max-requests=500 : The number of requests to process before reloading the server} {--rr-config= : The path to the RoadRunner .rr.yaml file} + {--caddyfile= : The path to the FrankenPHP Caddyfile file} + {--https : Enable HTTPS, HTTP/2, and HTTP/3, and automatically generate and renew certificates [FrankenPHP only]} {--watch : Automatically reload the server when the application is modified} {--poll : Use file system polling while watching in order to watch files over a network} {--log-level= : Log messages at or above the specified log level}'; @@ -46,6 +48,7 @@ public function handle() return match ($server) { 'swoole' => $this->startSwooleServer(), 'roadrunner' => $this->startRoadRunnerServer(), + 'frankenphp' => $this->startFrankenPhpServer(), default => $this->invalidServer($server), }; } @@ -89,6 +92,26 @@ protected function startRoadRunnerServer() ]); } + /** + * Start the FrankenPHP server for Octane. + * + * @return int + */ + protected function startFrankenPhpServer() + { + return $this->call('octane:frankenphp', [ + '--host' => $this->getHost(), + '--port' => $this->getPort(), + '--workers' => $this->option('workers'), + '--max-requests' => $this->option('max-requests'), + '--caddyfile' => $this->option('caddyfile'), + '--https' => $this->option('https'), + '--watch' => $this->option('watch'), + '--poll' => $this->option('poll'), + '--log-level' => $this->option('log-level'), + ]); + } + /** * Inform the user that the server type is invalid. * diff --git a/src/Commands/StartFrankenPhpCommand.php b/src/Commands/StartFrankenPhpCommand.php new file mode 100644 index 000000000..ce03115cc --- /dev/null +++ b/src/Commands/StartFrankenPhpCommand.php @@ -0,0 +1,263 @@ +ensureFrankenPhpWorkerIsInstalled(); + + $frankenphpBinary = $this->ensureFrankenPhpBinaryIsInstalled(); + + if ($inspector->serverIsRunning()) { + $this->error('FrankenPHP server is already running.'); + + return 1; + } + + $this->ensureFrankenPhpBinaryMeetsRequirements($frankenphpBinary); + + $this->writeServerStateFile($serverStateFile); + + $this->forgetEnvironmentVariables(); + + $host = $this->option('host'); + + $process = tap(new Process([ + $frankenphpBinary, + 'run', + '-c', $this->configPath(), + ], base_path(), [ + 'APP_ENV' => app()->environment(), + 'APP_BASE_PATH' => base_path(), + 'APP_PUBLIC_PATH' => public_path(), + 'LARAVEL_OCTANE' => 1, + 'MAX_REQUESTS' => $this->option('max-requests'), + 'CADDY_SERVER_LOG_LEVEL' => $this->option('log-level') ?: (app()->environment('local') ? 'INFO' : 'WARN'), + 'CADDY_SERVER_LOGGER' => 'json', + 'CADDY_SERVER_SERVER_NAME' => ($this->option('https') ? 'https://' : 'http://')."$host:".$this->getPort(), + 'CADDY_SERVER_WORKER_COUNT' => $this->workerCount() ?: '', + 'CADDY_SERVER_EXTRA_DIRECTIVES' => $this->buildMercureConfig(), + ])); + + $server = $process->start(); + + $serverStateFile->writeProcessId($server->getPid()); + + return $this->runServer($server, $inspector, 'frankenphp'); + } + + /** + * Get the path to the FrankenPHP configuration file. + * + * @return string + */ + protected function configPath() + { + $path = $this->option('caddyfile') ?: __DIR__.'/stubs/Caddyfile'; + + $path = realpath($path); + + if (! $path) { + throw new InvalidArgumentException('Unable to locate specified configuration file.'); + } + + return $path; + } + + /** + * Generate the Mercure configuration snippet to include in the Caddyfile. + * + * @return string + */ + protected function buildMercureConfig() + { + if (! $mercure = (config('octane')['mercure'] ?? false)) { + return ''; + } + + $config = 'mercure {'; + + foreach ($mercure as $key => $value) { + if ($value === false) { + continue; + } + + if ($value === true) { + $config .= "\n\t\t\t$key"; + + continue; + } + + $config .= "\n\t\t\t$key $value"; + } + + return "$config\n\t\t}"; + } + + /** + * Write the FrankenPHP server state file. + * + * @return void + */ + protected function writeServerStateFile( + ServerStateFile $serverStateFile + ) { + $serverStateFile->writeState([ + 'appName' => config('app.name', 'Laravel'), + 'host' => $this->getHost(), + 'port' => $this->getPort(), + 'workers' => $this->workerCount(), + 'maxRequests' => $this->option('max-requests'), + 'octaneConfig' => config('octane'), + ]); + } + + /** + * Get the number of workers that should be started. + * + * @return int + */ + protected function workerCount() + { + return $this->option('workers') === 'auto' + ? 0 + : $this->option('workers'); + } + + /** + * Write the server process output to the console. + * + * @param \Symfony\Component\Process\Process $server + * @return void + */ + protected function writeServerOutput($server) + { + [$_, $errorOutput] = $this->getServerOutput($server); + + $errorOutput = Str::of($errorOutput) + ->explode("\n") + ->filter() + ->values(); + + if ($this->option('log-level') !== null) { + return $errorOutput->each(fn ($output) => $this->raw($output)); + } + + $errorOutput->each(function ($output) { + if (! is_array($debug = json_decode($output, true))) { + return $this->info($output); + } + + if (is_array($stream = json_decode($debug['msg'], true))) { + return $this->handleStream($stream); + } + + if ($debug['msg'] == 'handled request') { + if (! $this->laravel->isLocal()) { + return; + } + + [ + 'duration' => $duration, + 'request' => [ + 'method' => $method, + 'uri' => $url, + ], + 'status' => $statusCode, + 'request' => $request, + ] = $debug; + + if (str_starts_with($url, '/.well-known/mercure')) { + return; + } + + return $this->requestInfo([ + 'method' => $method, + 'url' => $url, + 'statusCode' => $statusCode, + 'duration' => (float) $duration * 1000, + ]); + } + + if ($debug['level'] === 'warn') { + return $this->warn($debug['msg']); + } + + if ($debug['level'] !== 'info') { + return $this->error($debug['msg']); + } + }); + } + + /** + * {@inheritDoc} + */ + protected function writeServerRunningMessage() + { + if ($this->option('log-level') === null) { + $this->baseWriteServerRunningMessage(); + } + } + + /** + * Stop the server. + * + * @return void + */ + protected function stopServer() + { + $this->callSilent('octane:stop', [ + '--server' => 'frankenphp', + ]); + } +} diff --git a/src/Commands/StatusCommand.php b/src/Commands/StatusCommand.php index 1ad05897b..c2b60bf09 100644 --- a/src/Commands/StatusCommand.php +++ b/src/Commands/StatusCommand.php @@ -2,6 +2,7 @@ namespace Laravel\Octane\Commands; +use Laravel\Octane\FrankenPhp\ServerProcessInspector as FrankenPhpServerProcessInspector; use Laravel\Octane\RoadRunner\ServerProcessInspector as RoadRunnerServerProcessInspector; use Laravel\Octane\Swoole\ServerProcessInspector as SwooleServerProcessInspector; @@ -33,6 +34,7 @@ public function handle() $isRunning = match ($server) { 'swoole' => $this->isSwooleServerRunning(), 'roadrunner' => $this->isRoadRunnerServerRunning(), + 'frankenphp' => $this->isFrankenPhpServerRunning(), default => $this->invalidServer($server), }; @@ -65,6 +67,17 @@ protected function isRoadRunnerServerRunning() ->serverIsRunning(); } + /** + * Check if the FrankenPHP server is running. + * + * @return bool + */ + protected function isFrankenPhpServerRunning() + { + return app(FrankenPhpServerProcessInspector::class) + ->serverIsRunning(); + } + /** * Inform the user that the server type is invalid. * diff --git a/src/Commands/StopCommand.php b/src/Commands/StopCommand.php index ed305b6cf..465f15f26 100644 --- a/src/Commands/StopCommand.php +++ b/src/Commands/StopCommand.php @@ -2,6 +2,8 @@ namespace Laravel\Octane\Commands; +use Laravel\Octane\FrankenPhp\ServerProcessInspector as FrankenPhpProcessInspector; +use Laravel\Octane\FrankenPhp\ServerStateFile as FrankenPhpStateFile; use Laravel\Octane\RoadRunner\ServerProcessInspector as RoadRunnerServerProcessInspector; use Laravel\Octane\RoadRunner\ServerStateFile as RoadRunnerServerStateFile; use Laravel\Octane\Swoole\ServerProcessInspector as SwooleServerProcessInspector; @@ -35,6 +37,7 @@ public function handle() return match ($server) { 'swoole' => $this->stopSwooleServer(), 'roadrunner' => $this->stopRoadRunnerServer(), + 'frankenphp' => $this->stopFrankenPhpServer(), default => $this->invalidServer($server), }; } @@ -95,6 +98,32 @@ protected function stopRoadRunnerServer() return 0; } + /** + * Stop the FrankenPHP server for Octane. + * + * @return int + */ + protected function stopFrankenPhpServer() + { + $inspector = app(FrankenPhpProcessInspector::class); + + if (! $inspector->serverIsRunning()) { + app(FrankenPhpStateFile::class)->delete(); + + $this->error('FrankenPHP server is not running.'); + + return 1; + } + + $this->info('Stopping server...'); + + $inspector->stopServer(); + + app(FrankenPhpStateFile::class)->delete(); + + return 0; + } + /** * Inform the user that the server type is invalid. * diff --git a/src/Commands/stubs/Caddyfile b/src/Commands/stubs/Caddyfile new file mode 100644 index 000000000..bb25bfd80 --- /dev/null +++ b/src/Commands/stubs/Caddyfile @@ -0,0 +1,37 @@ +{ + {$CADDY_GLOBAL_OPTIONS} + + frankenphp { + worker {$APP_PUBLIC_PATH}/frankenphp-worker.php {$CADDY_SERVER_WORKER_COUNT} + } +} + +{$CADDY_SERVER_SERVER_NAME} { + log { + level {$CADDY_SERVER_LOG_LEVEL} + + # Redact the authorization query parameter that can be set by Mercure... + format filter { + wrap {$CADDY_SERVER_LOGGER} + fields { + uri query { + replace authorization REDACTED + } + } + } + } + + route { + root * {$APP_PUBLIC_PATH} + encode zstd gzip + + # Mercure configuration is injected here... + {$CADDY_SERVER_EXTRA_DIRECTIVES} + + php_server { + index frankenphp-worker.php + # Required for the public/storage/ directory... + resolve_root_symlink + } + } +} diff --git a/src/Commands/stubs/frankenphp-worker.php b/src/Commands/stubs/frankenphp-worker.php new file mode 100755 index 000000000..1ac1c2352 --- /dev/null +++ b/src/Commands/stubs/frankenphp-worker.php @@ -0,0 +1,3 @@ +find('frankenphp', null, [base_path()]); + } +} diff --git a/src/FrankenPhp/FrankenPhpClient.php b/src/FrankenPhp/FrankenPhpClient.php new file mode 100644 index 000000000..b5a69f995 --- /dev/null +++ b/src/FrankenPhp/FrankenPhpClient.php @@ -0,0 +1,52 @@ +response->send(); + } + + /** + * Send an error message to the server. + */ + public function error(Throwable $e, Application $app, Request $request, RequestContext $context): void + { + $response = new Response( + Octane::formatExceptionForClient($e, $app->make('config')->get('app.debug')), + 500, + [ + 'Status' => '500 Internal Server Error', + 'Content-Type' => 'text/plain', + ], + ); + + $response->send(); + } +} diff --git a/src/FrankenPhp/ServerProcessInspector.php b/src/FrankenPhp/ServerProcessInspector.php new file mode 100644 index 000000000..35164da3f --- /dev/null +++ b/src/FrankenPhp/ServerProcessInspector.php @@ -0,0 +1,57 @@ +successful(); + } catch (ConnectionException $_) { + return false; + } + } + + /** + * Reload the FrankenPHP workers. + */ + public function reloadServer(): void + { + try { + Http::withBody(Http::get(self::FRANKENPHP_CONFIG_URL)->body(), 'application/json') + ->withHeaders(['Cache-Control' => 'must-revalidate']) + ->patch(self::FRANKENPHP_CONFIG_URL); + } catch (ConnectionException $_) { + // + } + } + + /** + * Stop the FrankenPHP server. + */ + public function stopServer(): bool + { + try { + return Http::post(self::ADMIN_URL.'/stop')->successful(); + } catch (ConnectionException $_) { + return false; + } + } +} diff --git a/src/FrankenPhp/ServerStateFile.php b/src/FrankenPhp/ServerStateFile.php new file mode 100644 index 000000000..9f822a467 --- /dev/null +++ b/src/FrankenPhp/ServerStateFile.php @@ -0,0 +1,77 @@ +path) + ? json_decode(file_get_contents($this->path), true) + : []; + + return [ + 'masterProcessId' => $state['masterProcessId'] ?? null, + 'state' => $state['state'] ?? [], + ]; + } + + /** + * Write the given process ID to the server state file. + */ + public function writeProcessId(int $masterProcessId): void + { + if (! is_writable($this->path) && ! is_writable(dirname($this->path))) { + throw new RuntimeException('Unable to write to process ID file.'); + } + + file_put_contents($this->path, json_encode( + array_merge($this->read(), ['masterProcessId' => $masterProcessId]), + JSON_PRETTY_PRINT + )); + } + + /** + * Write the given state array to the server state file. + */ + public function writeState(array $newState): void + { + if (! is_writable($this->path) && ! is_writable(dirname($this->path))) { + throw new RuntimeException('Unable to write to process ID file.'); + } + + file_put_contents($this->path, json_encode( + array_merge($this->read(), ['state' => $newState]), + JSON_PRETTY_PRINT + )); + } + + /** + * Delete the process ID file. + */ + public function delete(): bool + { + if (is_writable($this->path)) { + return unlink($this->path); + } + + return false; + } + + /** + * Get the path to the process ID file. + */ + public function path(): string + { + return $this->path; + } +} diff --git a/src/Octane.php b/src/Octane.php index a318f3b0a..11028a3a3 100644 --- a/src/Octane.php +++ b/src/Octane.php @@ -40,4 +40,18 @@ public static function formatExceptionForClient(Throwable $e, bool $debug = fals { return $debug ? (string) $e : 'Internal server error.'; } + + /** + * Write an error message to STDERR or to the SAPI logger if not in CLI mode. + */ + public static function writeError(string $message): void + { + if (defined('STDERR')) { + fwrite(STDERR, $message.PHP_EOL); + + return; + } + + error_log($message, 4); + } } diff --git a/src/OctaneServiceProvider.php b/src/OctaneServiceProvider.php index 595779e0f..4785822fe 100644 --- a/src/OctaneServiceProvider.php +++ b/src/OctaneServiceProvider.php @@ -18,6 +18,8 @@ use Laravel\Octane\Exceptions\TaskException; use Laravel\Octane\Exceptions\TaskTimeoutException; use Laravel\Octane\Facades\Octane as OctaneFacade; +use Laravel\Octane\FrankenPhp\ServerProcessInspector as FrankenPhpServerProcessInspector; +use Laravel\Octane\FrankenPhp\ServerStateFile as FrankenPhpServerStateFile; use Laravel\Octane\RoadRunner\ServerProcessInspector as RoadRunnerServerProcessInspector; use Laravel\Octane\RoadRunner\ServerStateFile as RoadRunnerServerStateFile; use Laravel\Octane\Swoole\ServerProcessInspector as SwooleServerProcessInspector; @@ -71,6 +73,19 @@ public function register() )); }); + $this->app->bind(FrankenPhpServerProcessInspector::class, function ($app) { + return new FrankenPhpServerProcessInspector( + $app->make(FrankenPhpServerStateFile::class) + ); + }); + + $this->app->bind(FrankenPhpServerStateFile::class, function ($app) { + return new FrankenPhpServerStateFile($app['config']->get( + 'octane.state_file', + storage_path('logs/octane-server-state.json') + )); + }); + $this->app->bind(DispatchesCoroutines::class, function ($app) { return class_exists('Swoole\Http\Server') ? new SwooleCoroutineDispatcher($app->bound('Swoole\Http\Server')) @@ -170,6 +185,7 @@ protected function registerCommands() Commands\StartCommand::class, Commands\StartRoadRunnerCommand::class, Commands\StartSwooleCommand::class, + Commands\StartFrankenPhpCommand::class, Commands\ReloadCommand::class, Commands\StatusCommand::class, Commands\StopCommand::class, diff --git a/src/Stream.php b/src/Stream.php index 939f821ef..b91ab01c1 100644 --- a/src/Stream.php +++ b/src/Stream.php @@ -35,7 +35,7 @@ public static function throwable(Throwable $throwable) ? collect($throwable->getTrace())->whereNotNull('file')->first() : null; - fwrite(STDERR, json_encode([ + Octane::writeError(json_encode([ 'type' => 'throwable', 'class' => $throwable::class, 'code' => $throwable->getCode(), @@ -43,7 +43,7 @@ public static function throwable(Throwable $throwable) 'line' => $fallbackTrace['line'] ?? (int) $throwable->getLine(), 'message' => $throwable->getMessage(), 'trace' => array_slice($throwable->getTrace(), 0, 2), - ])."\n"); + ])); } /** @@ -53,7 +53,7 @@ public static function throwable(Throwable $throwable) */ public static function shutdown(Throwable $throwable) { - fwrite(STDERR, json_encode([ + Octane::writeError(json_encode([ 'type' => 'shutdown', 'class' => $throwable::class, 'code' => $throwable->getCode(), @@ -61,6 +61,6 @@ public static function shutdown(Throwable $throwable) 'line' => $throwable->getLine(), 'message' => $throwable->getMessage(), 'trace' => array_slice($throwable->getTrace(), 0, 2), - ])."\n"); + ])); } } diff --git a/tests/FrankenPhpClientTest.php b/tests/FrankenPhpClientTest.php new file mode 100644 index 000000000..772ce6acd --- /dev/null +++ b/tests/FrankenPhpClientTest.php @@ -0,0 +1,31 @@ +marshalRequest($requestContext); + $this->assertInstanceOf(Request::class, $marshaledRequest[0]); + $this->assertSame($requestContext, $marshaledRequest[1]); + } + + /** + * @doesNotPerformAssertions @test + */ + public function test_response() + { + $response = \Mockery::mock(Response::class); + $response->shouldReceive('send'); + + (new FrankenPhpClient())->respond(new RequestContext(), new OctaneResponse($response)); + } +} diff --git a/tests/FrankenPhpServerStateFileTest.php b/tests/FrankenPhpServerStateFileTest.php new file mode 100644 index 000000000..d59079fd1 --- /dev/null +++ b/tests/FrankenPhpServerStateFileTest.php @@ -0,0 +1,30 @@ +assertEquals($path, $stateFile->path()); + + $stateFile->delete(); + + $state = $stateFile->read(); + $this->assertEquals(['masterProcessId' => null, 'state' => []], $state); + + $stateFile->writeProcessId(1); + $stateFile->writeState(['name' => 'Taylor']); + $state = $stateFile->read(); + $this->assertEquals(['masterProcessId' => 1, 'state' => ['name' => 'Taylor']], $state); + + $stateFile->delete(); + $state = $stateFile->read(); + $this->assertEquals(['masterProcessId' => null, 'state' => []], $state); + } +}