diff --git a/composer.json b/composer.json index ba934e3c..5992f3a8 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "illuminate/console": "^11.0", "illuminate/container": "^11.0", "illuminate/contracts": "^11.0", + "illuminate/cookie": "^11.0", "illuminate/database": "^11.0", "illuminate/encryption": "^11.0", "illuminate/events": "^11.0", diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index 47d8ec45..4148a6b7 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -45,7 +45,7 @@ class Application extends Container implements ApplicationContract, CachesConfig * * @var string */ - const VERSION = '11.15.0'; + const VERSION = '11.34.2'; /** * The base path for the Laravel installation. @@ -759,7 +759,9 @@ public function isProduction() */ public function detectEnvironment(Closure $callback) { - $args = $_SERVER['argv'] ?? null; + $args = $this->runningInConsole() && isset($_SERVER['argv']) + ? $_SERVER['argv'] + : null; return $this['env'] = (new EnvironmentDetector)->detect($callback, $args); } @@ -1426,7 +1428,7 @@ public function terminate() /** * Get the service providers that have been loaded. * - * @return array + * @return array */ public function getLoadedProviders() { @@ -1465,6 +1467,17 @@ public function setDeferredServices(array $services) $this->deferredServices = $services; } + /** + * Determine if the given service is a deferred service. + * + * @param string $service + * @return bool + */ + public function isDeferredService($service) + { + return isset($this->deferredServices[$service]); + } + /** * Add an array of services to the application's deferred services. * @@ -1477,14 +1490,16 @@ public function addDeferredServices(array $services) } /** - * Determine if the given service is a deferred service. + * Remove an array of services from the application's deferred services. * - * @param string $service - * @return bool + * @param array $services + * @return void */ - public function isDeferredService($service) + public function removeDeferredServices(array $services) { - return isset($this->deferredServices[$service]); + foreach ($services as $service) { + unset($this->deferredServices[$service]); + } } /** diff --git a/src/Illuminate/Foundation/Auth/Access/Authorizable.php b/src/Illuminate/Foundation/Auth/Access/Authorizable.php index d8cf50db..d9a7d022 100644 --- a/src/Illuminate/Foundation/Auth/Access/Authorizable.php +++ b/src/Illuminate/Foundation/Auth/Access/Authorizable.php @@ -9,7 +9,7 @@ trait Authorizable /** * Determine if the entity has the given abilities. * - * @param iterable|string $abilities + * @param iterable|\BackedEnum|string $abilities * @param array|mixed $arguments * @return bool */ @@ -21,7 +21,7 @@ public function can($abilities, $arguments = []) /** * Determine if the entity has any of the given abilities. * - * @param iterable|string $abilities + * @param iterable|\BackedEnum|string $abilities * @param array|mixed $arguments * @return bool */ @@ -33,7 +33,7 @@ public function canAny($abilities, $arguments = []) /** * Determine if the entity does not have the given abilities. * - * @param iterable|string $abilities + * @param iterable|\BackedEnum|string $abilities * @param array|mixed $arguments * @return bool */ @@ -45,7 +45,7 @@ public function cant($abilities, $arguments = []) /** * Determine if the entity does not have the given abilities. * - * @param iterable|string $abilities + * @param iterable|\BackedEnum|string $abilities * @param array|mixed $arguments * @return bool */ diff --git a/src/Illuminate/Foundation/Auth/Access/AuthorizesRequests.php b/src/Illuminate/Foundation/Auth/Access/AuthorizesRequests.php index 19dc1641..adabdcec 100644 --- a/src/Illuminate/Foundation/Auth/Access/AuthorizesRequests.php +++ b/src/Illuminate/Foundation/Auth/Access/AuthorizesRequests.php @@ -5,6 +5,8 @@ use Illuminate\Contracts\Auth\Access\Gate; use Illuminate\Support\Str; +use function Illuminate\Support\enum_value; + trait AuthorizesRequests { /** @@ -49,6 +51,8 @@ public function authorizeForUser($user, $ability, $arguments = []) */ protected function parseAbilityAndArguments($ability, $arguments) { + $ability = enum_value($ability); + if (is_string($ability) && ! str_contains($ability, '\\')) { return [$ability, $arguments]; } diff --git a/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php b/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php index 90bc446d..d5dd1f27 100644 --- a/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php +++ b/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php @@ -8,7 +8,9 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Log\LogManager; use Illuminate\Support\Env; +use Monolog\Formatter\JsonFormatter; use Monolog\Handler\NullHandler; +use Monolog\Handler\SocketHandler; use PHPUnit\Runner\ErrorHandler; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\ErrorHandler\Error\FatalError; @@ -53,6 +55,10 @@ public function bootstrap(Application $app) if (! $app->environment('testing')) { ini_set('display_errors', 'Off'); } + + if (laravel_cloud()) { + $this->configureCloudSocketLogChannel($app); + } } /** @@ -245,6 +251,25 @@ protected function fatalErrorFromPhpError(array $error, $traceOffset = null) return new FatalError($error['message'], 0, $error, $traceOffset); } + /** + * Configure the Laravel Cloud socket log channel. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @return void + */ + protected function configureCloudSocketLogChannel(Application $app) + { + $app['config']->set('logging.channels.laravel-cloud-socket', [ + 'driver' => 'monolog', + 'handler' => SocketHandler::class, + 'formatter' => JsonFormatter::class, + 'with' => [ + 'connectionString' => $_ENV['LARAVEL_CLOUD_LOG_SOCKET'] ?? '127.0.0.1:8765', + 'persistent' => true, + ], + ]); + } + /** * Forward a method call to the given method if an application instance exists. * diff --git a/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php b/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php index a1bc7460..2fa429f8 100644 --- a/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php +++ b/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php @@ -93,7 +93,7 @@ protected function loadConfigurationFiles(Application $app, RepositoryContract $ */ protected function loadConfigurationFile(RepositoryContract $repository, $name, $path, array $base) { - $config = require $path; + $config = (fn () => require $path)(); if (isset($base[$name])) { $config = array_merge($base[$name], $config); diff --git a/src/Illuminate/Foundation/Bus/PendingChain.php b/src/Illuminate/Foundation/Bus/PendingChain.php index 2fb14990..3e6f8729 100644 --- a/src/Illuminate/Foundation/Bus/PendingChain.php +++ b/src/Illuminate/Foundation/Bus/PendingChain.php @@ -8,6 +8,8 @@ use Illuminate\Support\Traits\Conditionable; use Laravel\SerializableClosure\SerializableClosure; +use function Illuminate\Support\enum_value; + class PendingChain { use Conditionable; @@ -83,12 +85,12 @@ public function onConnection($connection) /** * Set the desired queue for the job. * - * @param string|null $queue + * @param \BackedEnum|string|null $queue * @return $this */ public function onQueue($queue) { - $this->queue = $queue; + $this->queue = enum_value($queue); return $this; } diff --git a/src/Illuminate/Foundation/Bus/PendingDispatch.php b/src/Illuminate/Foundation/Bus/PendingDispatch.php index 1e514e5b..97a339a0 100644 --- a/src/Illuminate/Foundation/Bus/PendingDispatch.php +++ b/src/Illuminate/Foundation/Bus/PendingDispatch.php @@ -38,7 +38,7 @@ public function __construct($job) /** * Set the desired connection for the job. * - * @param string|null $connection + * @param \BackedEnum|string|null $connection * @return $this */ public function onConnection($connection) @@ -51,7 +51,7 @@ public function onConnection($connection) /** * Set the desired queue for the job. * - * @param string|null $queue + * @param \BackedEnum|string|null $queue * @return $this */ public function onQueue($queue) @@ -64,7 +64,7 @@ public function onQueue($queue) /** * Set the desired connection for the chain. * - * @param string|null $connection + * @param \BackedEnum|string|null $connection * @return $this */ public function allOnConnection($connection) @@ -77,7 +77,7 @@ public function allOnConnection($connection) /** * Set the desired queue for the chain. * - * @param string|null $queue + * @param \BackedEnum|string|null $queue * @return $this */ public function allOnQueue($queue) @@ -100,6 +100,18 @@ public function delay($delay) return $this; } + /** + * Set the delay for the job to zero seconds. + * + * @return $this + */ + public function withoutDelay() + { + $this->job->withoutDelay(); + + return $this; + } + /** * Indicate that the job should be dispatched after all database transactions have committed. * diff --git a/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php b/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php index 218e5210..db63a095 100644 --- a/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php +++ b/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php @@ -27,6 +27,13 @@ class ApplicationBuilder */ protected array $pendingProviders = []; + /** + * Any additional routing callbacks that should be invoked while registering routes. + * + * @var array + */ + protected array $additionalRoutingCallbacks = []; + /** * The Folio / page middleware that have been defined by the user. * @@ -203,10 +210,24 @@ protected function buildRoutingCallback(array|string|null $web, } if (is_string($health)) { - Route::middleware('web')->get($health, function () { - Event::dispatch(new DiagnosingHealth); + Route::get($health, function () { + $exception = null; + + try { + Event::dispatch(new DiagnosingHealth); + } catch (\Throwable $e) { + if (app()->hasDebugModeEnabled()) { + throw $e; + } + + report($e); - return View::file(__DIR__.'/../resources/health-up.blade.php'); + $exception = $e->getMessage(); + } + + return response(View::file(__DIR__.'/../resources/health-up.blade.php', [ + 'exception' => $exception, + ]), status: $exception ? 500 : 200); }); } @@ -222,6 +243,10 @@ protected function buildRoutingCallback(array|string|null $web, } } + foreach ($this->additionalRoutingCallbacks as $callback) { + $callback(); + } + if (is_string($pages) && realpath($pages) !== false && class_exists(Folio::class)) { @@ -258,6 +283,18 @@ public function withMiddleware(?callable $callback = null) if ($priorities = $middleware->getMiddlewarePriority()) { $kernel->setMiddlewarePriority($priorities); } + + if ($priorityAppends = $middleware->getMiddlewarePriorityAppends()) { + foreach ($priorityAppends as $newMiddleware => $after) { + $kernel->addToMiddlewarePriorityAfter($after, $newMiddleware); + } + } + + if ($priorityPrepends = $middleware->getMiddlewarePriorityPrepends()) { + foreach ($priorityPrepends as $newMiddleware => $before) { + $kernel->addToMiddlewarePriorityBefore($before, $newMiddleware); + } + } }); return $this; @@ -300,6 +337,8 @@ protected function withCommandRouting(array $paths) $this->app->afterResolving(ConsoleKernel::class, function ($kernel) use ($paths) { $this->app->booted(fn () => $kernel->addCommandRoutePaths($paths)); }); + + return $this; } /** diff --git a/src/Illuminate/Foundation/Configuration/Middleware.php b/src/Illuminate/Foundation/Configuration/Middleware.php index 30e60ed9..52cf908d 100644 --- a/src/Illuminate/Foundation/Configuration/Middleware.php +++ b/src/Illuminate/Foundation/Configuration/Middleware.php @@ -145,6 +145,20 @@ class Middleware */ protected $priority = []; + /** + * The middleware to prepend to the middleware priority definition. + * + * @var array + */ + protected $prependPriority = []; + + /** + * The middleware to append to the middleware priority definition. + * + * @var array + */ + protected $appendPriority = []; + /** * Prepend middleware to the application's global middleware stack. * @@ -400,6 +414,34 @@ public function priority(array $priority) return $this; } + /** + * Prepend middleware to the priority middleware. + * + * @param array|string $before + * @param string $prepend + * @return $this + */ + public function prependToPriorityList($before, $prepend) + { + $this->prependPriority[$prepend] = $before; + + return $this; + } + + /** + * Append middleware to the priority middleware. + * + * @param array|string $after + * @param string $append + * @return $this + */ + public function appendToPriorityList($after, $append) + { + $this->appendPriority[$append] = $after; + + return $this; + } + /** * Get the global middleware. * @@ -408,6 +450,7 @@ public function priority(array $priority) public function getGlobalMiddleware() { $middleware = $this->global ?: array_values(array_filter([ + \Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks::class, $this->trustHosts ? \Illuminate\Http\Middleware\TrustHosts::class : null, \Illuminate\Http\Middleware\TrustProxies::class, \Illuminate\Http\Middleware\HandleCors::class, @@ -765,4 +808,24 @@ public function getMiddlewarePriority() { return $this->priority; } + + /** + * Get the middleware to prepend to the middleware priority definition. + * + * @return array + */ + public function getMiddlewarePriorityPrepends() + { + return $this->prependPriority; + } + + /** + * Get the middleware to append to the middleware priority definition. + * + * @return array + */ + public function getMiddlewarePriorityAppends() + { + return $this->appendPriority; + } } diff --git a/src/Illuminate/Foundation/Console/ApiInstallCommand.php b/src/Illuminate/Foundation/Console/ApiInstallCommand.php index dda72efc..d8ab378c 100644 --- a/src/Illuminate/Foundation/Console/ApiInstallCommand.php +++ b/src/Illuminate/Foundation/Console/ApiInstallCommand.php @@ -6,7 +6,8 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Process; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Process\PhpExecutableFinder; + +use function Illuminate\Support\php_binary; #[AsCommand(name: 'install:api')] class ApiInstallCommand extends Command @@ -65,7 +66,7 @@ public function handle() if ($this->option('passport')) { Process::run(array_filter([ - (new PhpExecutableFinder())->find(false) ?: 'php', + php_binary(), defined('ARTISAN_BINARY') ? ARTISAN_BINARY : 'artisan', 'passport:install', $this->confirm('Would you like to use UUIDs for all client IDs?') ? '--uuids' : null, @@ -130,7 +131,7 @@ protected function installSanctum() if (! $migrationPublished) { Process::run([ - (new PhpExecutableFinder())->find(false) ?: 'php', + php_binary(), defined('ARTISAN_BINARY') ? ARTISAN_BINARY : 'artisan', 'vendor:publish', '--provider', diff --git a/src/Illuminate/Foundation/Console/BroadcastingInstallCommand.php b/src/Illuminate/Foundation/Console/BroadcastingInstallCommand.php index 9802ce10..4dd9cc8d 100644 --- a/src/Illuminate/Foundation/Console/BroadcastingInstallCommand.php +++ b/src/Illuminate/Foundation/Console/BroadcastingInstallCommand.php @@ -7,8 +7,8 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Process; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Process\PhpExecutableFinder; +use function Illuminate\Support\php_binary; use function Laravel\Prompts\confirm; #[AsCommand(name: 'install:broadcasting')] @@ -151,13 +151,11 @@ protected function installReverb() } $this->requireComposerPackages($this->option('composer'), [ - 'laravel/reverb:@beta', + 'laravel/reverb:^1.0', ]); - $php = (new PhpExecutableFinder())->find(false) ?: 'php'; - Process::run([ - $php, + php_binary(), defined('ARTISAN_BINARY') ? ARTISAN_BINARY : 'artisan', 'reverb:install', ]); diff --git a/src/Illuminate/Foundation/Console/ComponentMakeCommand.php b/src/Illuminate/Foundation/Console/ComponentMakeCommand.php index 709c5360..4c2f2eac 100644 --- a/src/Illuminate/Foundation/Console/ComponentMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ComponentMakeCommand.php @@ -5,6 +5,7 @@ use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; use Illuminate\Foundation\Inspiring; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -63,7 +64,7 @@ public function handle() protected function writeView() { $path = $this->viewPath( - str_replace('.', '/', 'components.'.$this->getView()).'.blade.php' + str_replace('.', '/', $this->getView()).'.blade.php' ); if (! $this->files->isDirectory(dirname($path))) { @@ -104,24 +105,33 @@ protected function buildClass($name) return str_replace( ['DummyView', '{{ view }}'], - 'view(\'components.'.$this->getView().'\')', + 'view(\''.$this->getView().'\')', parent::buildClass($name) ); } /** - * Get the view name relative to the components directory. + * Get the view name relative to the view path. * * @return string view */ protected function getView() { - $name = str_replace('\\', '/', $this->argument('name')); + $segments = explode('/', str_replace('\\', '/', $this->argument('name'))); - return collect(explode('/', $name)) - ->map(function ($part) { - return Str::kebab($part); - }) + $name = array_pop($segments); + + $path = is_string($this->option('path')) + ? explode('/', trim($this->option('path'), '/')) + : [ + 'components', + ...$segments, + ]; + + $path[] = $name; + + return (new Collection($path)) + ->map(fn ($segment) => Str::kebab($segment)) ->implode('.'); } @@ -167,9 +177,10 @@ protected function getDefaultNamespace($rootNamespace) protected function getOptions() { return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the component already exists'], ['inline', null, InputOption::VALUE_NONE, 'Create a component that renders an inline view'], ['view', null, InputOption::VALUE_NONE, 'Create an anonymous component with only a view'], + ['path', null, InputOption::VALUE_REQUIRED, 'The location where the component view should be created'], + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the component already exists'], ]; } } diff --git a/src/Illuminate/Foundation/Console/ConfigShowCommand.php b/src/Illuminate/Foundation/Console/ConfigShowCommand.php index 2c571214..d3dd580e 100644 --- a/src/Illuminate/Foundation/Console/ConfigShowCommand.php +++ b/src/Illuminate/Foundation/Console/ConfigShowCommand.php @@ -33,9 +33,7 @@ public function handle() $config = $this->argument('config'); if (! config()->has($config)) { - $this->components->error("Configuration file or key {$config} does not exist."); - - return Command::FAILURE; + $this->fail("Configuration file or key {$config} does not exist."); } $this->newLine(); diff --git a/src/Illuminate/Foundation/Console/DownCommand.php b/src/Illuminate/Foundation/Console/DownCommand.php index 10d7dbfd..86b2c284 100644 --- a/src/Illuminate/Foundation/Console/DownCommand.php +++ b/src/Illuminate/Foundation/Console/DownCommand.php @@ -87,7 +87,7 @@ protected function getDownFilePayload() 'retry' => $this->getRetryTime(), 'refresh' => $this->option('refresh'), 'secret' => $this->getSecret(), - 'status' => (int) $this->option('status', 503), + 'status' => (int) ($this->option('status') ?? 503), 'template' => $this->option('render') ? $this->prerenderView() : null, ]; } diff --git a/src/Illuminate/Foundation/Console/EnvironmentDecryptCommand.php b/src/Illuminate/Foundation/Console/EnvironmentDecryptCommand.php index a173388f..37829f0d 100644 --- a/src/Illuminate/Foundation/Console/EnvironmentDecryptCommand.php +++ b/src/Illuminate/Foundation/Console/EnvironmentDecryptCommand.php @@ -10,6 +10,8 @@ use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; +use function Laravel\Prompts\password; + #[AsCommand(name: 'env:decrypt')] class EnvironmentDecryptCommand extends Command { @@ -62,10 +64,12 @@ public function handle() { $key = $this->option('key') ?: Env::get('LARAVEL_ENV_ENCRYPTION_KEY'); - if (! $key) { - $this->components->error('A decryption key is required.'); + if (! $key && $this->input->isInteractive()) { + $key = password('What is the decryption key?'); + } - return Command::FAILURE; + if (! $key) { + $this->fail('A decryption key is required.'); } $cipher = $this->option('cipher') ?: 'AES-256-CBC'; @@ -79,21 +83,15 @@ public function handle() $outputFile = $this->outputFilePath(); if (Str::endsWith($outputFile, '.encrypted')) { - $this->components->error('Invalid filename.'); - - return Command::FAILURE; + $this->fail('Invalid filename.'); } if (! $this->files->exists($encryptedFile)) { - $this->components->error('Encrypted environment file not found.'); - - return Command::FAILURE; + $this->fail('Encrypted environment file not found.'); } if ($this->files->exists($outputFile) && ! $this->option('force')) { - $this->components->error('Environment file already exists.'); - - return Command::FAILURE; + $this->fail('Environment file already exists.'); } try { @@ -104,9 +102,7 @@ public function handle() $encrypter->decrypt($this->files->get($encryptedFile)) ); } catch (Exception $e) { - $this->components->error($e->getMessage()); - - return Command::FAILURE; + $this->fail($e->getMessage()); } $this->components->info('Environment successfully decrypted.'); diff --git a/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php b/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php index d4e7f6aa..03cafa97 100644 --- a/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php +++ b/src/Illuminate/Foundation/Console/EnvironmentEncryptCommand.php @@ -9,6 +9,9 @@ use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; +use function Laravel\Prompts\password; +use function Laravel\Prompts\select; + #[AsCommand(name: 'env:encrypt')] class EnvironmentEncryptCommand extends Command { @@ -62,6 +65,21 @@ public function handle() $key = $this->option('key'); + if (! $key && $this->input->isInteractive()) { + $ask = select( + label: 'What encryption key would you like to use?', + options: [ + 'generate' => 'Generate a random encryption key', + 'ask' => 'Provide an encryption key', + ], + default: 'generate' + ); + + if ($ask == 'ask') { + $key = password('What is the encryption key?'); + } + } + $keyPassed = $key !== null; $environmentFile = $this->option('env') @@ -75,15 +93,11 @@ public function handle() } if (! $this->files->exists($environmentFile)) { - $this->components->error('Environment file not found.'); - - return Command::FAILURE; + $this->fail('Environment file not found.'); } if ($this->files->exists($encryptedFile) && ! $this->option('force')) { - $this->components->error('Encrypted environment file already exists.'); - - return Command::FAILURE; + $this->fail('Encrypted environment file already exists.'); } try { @@ -94,9 +108,7 @@ public function handle() $encrypter->encrypt($this->files->get($environmentFile)) ); } catch (Exception $e) { - $this->components->error($e->getMessage()); - - return Command::FAILURE; + $this->fail($e->getMessage()); } if ($this->option('prune')) { diff --git a/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php b/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php index bcd82ab0..a38b11c7 100644 --- a/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ExceptionMakeCommand.php @@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use function Laravel\Prompts\{confirm}; +use function Laravel\Prompts\confirm; #[AsCommand(name: 'make:exception')] class ExceptionMakeCommand extends GeneratorCommand diff --git a/src/Illuminate/Foundation/Console/InteractsWithComposerPackages.php b/src/Illuminate/Foundation/Console/InteractsWithComposerPackages.php index ebd8b7e7..4628f294 100644 --- a/src/Illuminate/Foundation/Console/InteractsWithComposerPackages.php +++ b/src/Illuminate/Foundation/Console/InteractsWithComposerPackages.php @@ -2,9 +2,10 @@ namespace Illuminate\Foundation\Console; -use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; +use function Illuminate\Support\php_binary; + trait InteractsWithComposerPackages { /** @@ -39,6 +40,6 @@ protected function requireComposerPackages(string $composer, array $packages) */ protected function phpBinary() { - return (new PhpExecutableFinder())->find(false) ?: 'php'; + return php_binary(); } } diff --git a/src/Illuminate/Foundation/Console/JobMiddlewareMakeCommand.php b/src/Illuminate/Foundation/Console/JobMiddlewareMakeCommand.php new file mode 100644 index 00000000..b3ccf8e4 --- /dev/null +++ b/src/Illuminate/Foundation/Console/JobMiddlewareMakeCommand.php @@ -0,0 +1,81 @@ +resolveStubPath('/stubs/job.middleware.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; + } + + /** + * Get the default namespace for the class. + * + * @param string $rootNamespace + * @return string + */ + protected function getDefaultNamespace($rootNamespace) + { + return $rootNamespace.'\Jobs\Middleware'; + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the job middleware already exists'], + ]; + } +} diff --git a/src/Illuminate/Foundation/Console/Kernel.php b/src/Illuminate/Foundation/Console/Kernel.php index 16fc49d8..eb9405ba 100644 --- a/src/Illuminate/Foundation/Console/Kernel.php +++ b/src/Illuminate/Foundation/Console/Kernel.php @@ -14,6 +14,7 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Foundation\Events\Terminating; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Env; @@ -212,6 +213,8 @@ public function handle($input, $output = null) */ public function terminate($input, $status) { + $this->events->dispatch(new Terminating); + $this->app->terminate(); if ($this->commandStartedAt === null) { diff --git a/src/Illuminate/Foundation/Console/ListenerMakeCommand.php b/src/Illuminate/Foundation/Console/ListenerMakeCommand.php index 31bef173..d5589ece 100644 --- a/src/Illuminate/Foundation/Console/ListenerMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ListenerMakeCommand.php @@ -104,7 +104,7 @@ protected function getStub() */ protected function alreadyExists($rawName) { - return class_exists($rawName); + return class_exists($this->qualifyClass($rawName)); } /** diff --git a/src/Illuminate/Foundation/Console/MailMakeCommand.php b/src/Illuminate/Foundation/Console/MailMakeCommand.php index 2ec59a15..d6bf70f7 100644 --- a/src/Illuminate/Foundation/Console/MailMakeCommand.php +++ b/src/Illuminate/Foundation/Console/MailMakeCommand.php @@ -70,6 +70,10 @@ protected function writeMarkdownTemplate() str_replace('.', '/', $this->getView()).'.blade.php' ); + if ($this->files->exists($path)) { + return $this->components->error(sprintf('%s [%s] already exists.', 'Markdown view', $path)); + } + $this->files->ensureDirectoryExists(dirname($path)); $this->files->put($path, file_get_contents(__DIR__.'/stubs/markdown.stub')); @@ -88,6 +92,10 @@ protected function writeView() str_replace('.', '/', $this->getView()).'.blade.php' ); + if ($this->files->exists($path)) { + return $this->components->error(sprintf('%s [%s] already exists.', 'View', $path)); + } + $this->files->ensureDirectoryExists(dirname($path)); $stub = str_replace( diff --git a/src/Illuminate/Foundation/Console/ModelMakeCommand.php b/src/Illuminate/Foundation/Console/ModelMakeCommand.php index f4b4bd66..146332ab 100644 --- a/src/Illuminate/Foundation/Console/ModelMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ModelMakeCommand.php @@ -72,6 +72,8 @@ public function handle() if ($this->option('controller') || $this->option('resource') || $this->option('api')) { $this->createController(); + } elseif ($this->option('requests')) { + $this->createFormRequests(); } if ($this->option('policy')) { @@ -148,6 +150,24 @@ protected function createController() ])); } + /** + * Create the form requests for the model. + * + * @return void + */ + protected function createFormRequests() + { + $request = Str::studly(class_basename($this->argument('name'))); + + $this->call('make:request', [ + 'name' => "Store{$request}Request", + ]); + + $this->call('make:request', [ + 'name' => "Update{$request}Request", + ]); + } + /** * Create a policy file for the model. * @@ -205,6 +225,53 @@ protected function getDefaultNamespace($rootNamespace) return is_dir(app_path('Models')) ? $rootNamespace.'\\Models' : $rootNamespace; } + /** + * Build the class with the given name. + * + * @param string $name + * @return string + * + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + */ + protected function buildClass($name) + { + $replace = $this->buildFactoryReplacements(); + + return str_replace( + array_keys($replace), array_values($replace), parent::buildClass($name) + ); + } + + /** + * Build the replacements for a factory. + * + * @return array + */ + protected function buildFactoryReplacements() + { + $replacements = []; + + if ($this->option('factory') || $this->option('all')) { + $modelPath = str($this->argument('name'))->studly()->replace('/', '\\')->toString(); + + $factoryNamespace = '\\Database\\Factories\\'.$modelPath.'Factory'; + + $factoryCode = << */ + use HasFactory; + EOT; + + $replacements['{{ factory }}'] = $factoryCode; + $replacements['{{ factoryImport }}'] = 'use Illuminate\Database\Eloquent\Factories\HasFactory;'; + } else { + $replacements['{{ factory }}'] = '//'; + $replacements["{{ factoryImport }}\n"] = ''; + $replacements["{{ factoryImport }}\r\n"] = ''; + } + + return $replacements; + } + /** * Get the console command options. * diff --git a/src/Illuminate/Foundation/Console/NotificationMakeCommand.php b/src/Illuminate/Foundation/Console/NotificationMakeCommand.php index d9352655..d6105af9 100644 --- a/src/Illuminate/Foundation/Console/NotificationMakeCommand.php +++ b/src/Illuminate/Foundation/Console/NotificationMakeCommand.php @@ -4,8 +4,14 @@ use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; +use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +use function Laravel\Prompts\confirm; +use function Laravel\Prompts\text; #[AsCommand(name: 'make:notification')] class NotificationMakeCommand extends GeneratorCommand @@ -122,6 +128,33 @@ protected function getDefaultNamespace($rootNamespace) return $rootNamespace.'\Notifications'; } + /** + * Perform actions after the user was prompted for missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + if ($this->didReceiveOptions($input)) { + return; + } + + $wantsMarkdownView = confirm('Would you like to create a markdown view?'); + + if ($wantsMarkdownView) { + $defaultMarkdownView = collect(explode('/', str_replace('\\', '/', $this->argument('name')))) + ->map(fn ($path) => Str::kebab($path)) + ->prepend('mail') + ->implode('.'); + + $markdownView = text('What should the markdown view be named?', default: $defaultMarkdownView); + + $input->setOption('markdown', $markdownView); + } + } + /** * Get the console command options. * diff --git a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php index 02c9d5ab..ea90627b 100644 --- a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php @@ -3,6 +3,7 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; +use Illuminate\Support\ServiceProvider; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'optimize:clear')] @@ -31,15 +32,28 @@ public function handle() { $this->components->info('Clearing cached bootstrap files.'); - collect([ - 'cache' => fn () => $this->callSilent('cache:clear') == 0, - 'compiled' => fn () => $this->callSilent('clear-compiled') == 0, - 'config' => fn () => $this->callSilent('config:clear') == 0, - 'events' => fn () => $this->callSilent('event:clear') == 0, - 'routes' => fn () => $this->callSilent('route:clear') == 0, - 'views' => fn () => $this->callSilent('view:clear') == 0, - ])->each(fn ($task, $description) => $this->components->task($description, $task)); + foreach ($this->getOptimizeClearTasks() as $description => $command) { + $this->components->task($description, fn () => $this->callSilently($command) == 0); + } $this->newLine(); } + + /** + * Get the commands that should be run to clear the "optimization" files. + * + * @return array + */ + public function getOptimizeClearTasks() + { + return [ + 'cache' => 'cache:clear', + 'compiled' => 'clear-compiled', + 'config' => 'config:clear', + 'events' => 'event:clear', + 'routes' => 'route:clear', + 'views' => 'view:clear', + ...ServiceProvider::$optimizeClearCommands, + ]; + } } diff --git a/src/Illuminate/Foundation/Console/OptimizeCommand.php b/src/Illuminate/Foundation/Console/OptimizeCommand.php index 0bcf3e97..60c97621 100644 --- a/src/Illuminate/Foundation/Console/OptimizeCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeCommand.php @@ -3,6 +3,7 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; +use Illuminate\Support\ServiceProvider; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'optimize')] @@ -31,13 +32,26 @@ public function handle() { $this->components->info('Caching framework bootstrap, configuration, and metadata.'); - collect([ - 'config' => fn () => $this->callSilent('config:cache') == 0, - 'events' => fn () => $this->callSilent('event:cache') == 0, - 'routes' => fn () => $this->callSilent('route:cache') == 0, - 'views' => fn () => $this->callSilent('view:cache') == 0, - ])->each(fn ($task, $description) => $this->components->task($description, $task)); + foreach ($this->getOptimizeTasks() as $description => $command) { + $this->components->task($description, fn () => $this->callSilently($command) == 0); + } $this->newLine(); } + + /** + * Get the commands that should be run to optimize the framework. + * + * @return array + */ + protected function getOptimizeTasks() + { + return [ + 'config' => 'config:cache', + 'events' => 'event:cache', + 'routes' => 'route:cache', + 'views' => 'view:cache', + ...ServiceProvider::$optimizeCommands, + ]; + } } diff --git a/src/Illuminate/Foundation/Console/ServeCommand.php b/src/Illuminate/Foundation/Console/ServeCommand.php index ae204bf2..a0ca24f0 100644 --- a/src/Illuminate/Foundation/Console/ServeCommand.php +++ b/src/Illuminate/Foundation/Console/ServeCommand.php @@ -8,9 +8,9 @@ use Illuminate\Support\InteractsWithTime; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; +use function Illuminate\Support\php_binary; use function Termwind\terminal; #[AsCommand(name: 'serve')] @@ -178,7 +178,7 @@ protected function serverCommand() : __DIR__.'/../resources/server.php'; return [ - (new PhpExecutableFinder)->find(false), + php_binary(), '-S', $this->host().':'.$this->port(), $server, @@ -291,7 +291,7 @@ protected function flushOutputBuffer() } if (str($line)->contains(' Accepted')) { - $requestPort = $this->getRequestPortFromLine($line); + $requestPort = static::getRequestPortFromLine($line); $this->requestsPool[$requestPort] = [ $this->getDateFromLine($line), @@ -299,15 +299,15 @@ protected function flushOutputBuffer() microtime(true), ]; } elseif (str($line)->contains([' [200]: GET '])) { - $requestPort = $this->getRequestPortFromLine($line); + $requestPort = static::getRequestPortFromLine($line); $this->requestsPool[$requestPort][1] = trim(explode('[200]: GET', $line)[1]); } elseif (str($line)->contains('URI:')) { - $requestPort = $this->getRequestPortFromLine($line); + $requestPort = static::getRequestPortFromLine($line); $this->requestsPool[$requestPort][1] = trim(explode('URI: ', $line)[1]); } elseif (str($line)->contains(' Closing')) { - $requestPort = $this->getRequestPortFromLine($line); + $requestPort = static::getRequestPortFromLine($line); if (empty($this->requestsPool[$requestPort])) { $this->requestsPool[$requestPort] = [ @@ -357,7 +357,7 @@ protected function flushOutputBuffer() */ protected function getDateFromLine($line) { - $regex = env('PHP_CLI_SERVER_WORKERS', 1) > 1 + $regex = ! windows_os() && env('PHP_CLI_SERVER_WORKERS', 1) > 1 ? '/^\[\d+]\s\[([a-zA-Z0-9: ]+)\]/' : '/^\[([^\]]+)\]/'; @@ -374,11 +374,15 @@ protected function getDateFromLine($line) * @param string $line * @return int */ - protected function getRequestPortFromLine($line) + public static function getRequestPortFromLine($line) { - preg_match('/:(\d+)\s(?:(?:\w+$)|(?:\[.*))/', $line, $matches); + preg_match('/(\[\w+\s\w+\s\d+\s[\d:]+\s\d{4}\]\s)?:(\d+)\s(?:(?:\w+$)|(?:\[.*))/', $line, $matches); - return (int) $matches[1]; + if (! isset($matches[2])) { + throw new \InvalidArgumentException("Failed to extract the request port. Ensure the log line contains a valid port: {$line}"); + } + + return (int) $matches[2]; } /** diff --git a/src/Illuminate/Foundation/Console/VendorPublishCommand.php b/src/Illuminate/Foundation/Console/VendorPublishCommand.php index ca7dc0d0..3921c9ca 100644 --- a/src/Illuminate/Foundation/Console/VendorPublishCommand.php +++ b/src/Illuminate/Foundation/Console/VendorPublishCommand.php @@ -31,7 +31,7 @@ class VendorPublishCommand extends Command /** * The provider to publish. * - * @var string + * @var string|null */ protected $provider = null; @@ -309,7 +309,7 @@ protected function publishDirectory($from, $to) */ protected function moveManagedFiles($from, $manager) { - foreach ($manager->listContents('from://', true) as $file) { + foreach ($manager->listContents('from://', true)->sortByPath() as $file) { $path = Str::after($file['path'], 'from://'); if ( diff --git a/src/Illuminate/Foundation/Console/ViewMakeCommand.php b/src/Illuminate/Foundation/Console/ViewMakeCommand.php index 3f8e4da6..8c138a80 100644 --- a/src/Illuminate/Foundation/Console/ViewMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ViewMakeCommand.php @@ -77,7 +77,7 @@ protected function getNameInput() { $name = trim($this->argument('name')); - $name = str_replace(['\\', '.'], '/', $this->argument('name')); + $name = str_replace(['\\', '.'], '/', $name); return $name; } diff --git a/src/Illuminate/Foundation/Console/stubs/job.middleware.stub b/src/Illuminate/Foundation/Console/stubs/job.middleware.stub new file mode 100644 index 00000000..391e8eb6 --- /dev/null +++ b/src/Illuminate/Foundation/Console/stubs/job.middleware.stub @@ -0,0 +1,18 @@ +getRealPath()), DIRECTORY_SEPARATOR); - return str_replace( + return ucfirst(Str::camel(str_replace( [DIRECTORY_SEPARATOR, ucfirst(basename(app()->path())).'\\'], ['\\', app()->getNamespace()], ucfirst(Str::replaceLast('.php', '', $class)) - ); + ))); } /** diff --git a/src/Illuminate/Foundation/Events/Terminating.php b/src/Illuminate/Foundation/Events/Terminating.php new file mode 100644 index 00000000..a74a21e0 --- /dev/null +++ b/src/Illuminate/Foundation/Events/Terminating.php @@ -0,0 +1,8 @@ +levels, fn ($level, $type) => $e instanceof $type, LogLevel::ERROR - ); + $level = $this->mapLogLevel($e); $context = $this->buildExceptionContext($e); @@ -401,6 +402,10 @@ protected function shouldntReport(Throwable $e) return true; } + if ($e instanceof ShouldntReport) { + return true; + } + $dontReport = array_merge($this->dontReport, $this->internalDontReport); if (! is_null(Arr::first($dontReport, fn ($type) => $e instanceof $type))) { @@ -633,6 +638,7 @@ protected function prepareException(Throwable $e) $e instanceof AuthorizationException && ! $e->hasStatus() => new AccessDeniedHttpException($e->getMessage(), $e), $e instanceof TokenMismatchException => new HttpException(419, $e->getMessage(), $e), $e instanceof RequestExceptionInterface => new BadRequestHttpException('Bad request.', $e), + $e instanceof RecordNotFoundException => new NotFoundHttpException('Not found.', $e), $e instanceof RecordsNotFoundException => new NotFoundHttpException('Not found.', $e), default => $e, }; @@ -1042,6 +1048,19 @@ protected function isHttpException(Throwable $e) return $e instanceof HttpExceptionInterface; } + /** + * Map the exception to a log level. + * + * @param \Throwable $e + * @return \Psr\Log\LogLevel::* + */ + protected function mapLogLevel(Throwable $e) + { + return Arr::first( + $this->levels, fn ($level, $type) => $e instanceof $type, LogLevel::ERROR + ); + } + /** * Create a new logger instance. * diff --git a/src/Illuminate/Foundation/Exceptions/Renderer/Frame.php b/src/Illuminate/Foundation/Exceptions/Renderer/Frame.php index 8553e0f8..ac331d7a 100644 --- a/src/Illuminate/Foundation/Exceptions/Renderer/Frame.php +++ b/src/Illuminate/Foundation/Exceptions/Renderer/Frame.php @@ -106,6 +106,10 @@ public function file() */ public function line() { + if (! is_file($this->frame['file']) || ! is_readable($this->frame['file'])) { + return 0; + } + $maxLines = count(file($this->frame['file']) ?: []); return $this->frame['line'] > $maxLines ? 1 : $this->frame['line']; @@ -131,6 +135,10 @@ public function callable() */ public function snippet() { + if (! is_file($this->frame['file']) || ! is_readable($this->frame['file'])) { + return ''; + } + $contents = file($this->frame['file']) ?: []; $start = max($this->line() - 6, 0); diff --git a/src/Illuminate/Foundation/Exceptions/Renderer/Mappers/BladeMapper.php b/src/Illuminate/Foundation/Exceptions/Renderer/Mappers/BladeMapper.php index f0a540e9..8a4da94a 100644 --- a/src/Illuminate/Foundation/Exceptions/Renderer/Mappers/BladeMapper.php +++ b/src/Illuminate/Foundation/Exceptions/Renderer/Mappers/BladeMapper.php @@ -124,14 +124,11 @@ protected function getKnownPaths() if (! $compilerEngineReflection->hasProperty('lastCompiled') && $compilerEngineReflection->hasProperty('engine')) { $compilerEngine = $compilerEngineReflection->getProperty('engine'); - $compilerEngine->setAccessible(true); $compilerEngine = $compilerEngine->getValue($bladeCompilerEngine); $lastCompiled = new ReflectionProperty($compilerEngine, 'lastCompiled'); - $lastCompiled->setAccessible(true); $lastCompiled = $lastCompiled->getValue($compilerEngine); } else { $lastCompiled = $compilerEngineReflection->getProperty('lastCompiled'); - $lastCompiled->setAccessible(true); $lastCompiled = $lastCompiled->getValue($bladeCompilerEngine); } diff --git a/src/Illuminate/Foundation/Exceptions/Renderer/Renderer.php b/src/Illuminate/Foundation/Exceptions/Renderer/Renderer.php index 823e893d..d6770f2e 100644 --- a/src/Illuminate/Foundation/Exceptions/Renderer/Renderer.php +++ b/src/Illuminate/Foundation/Exceptions/Renderer/Renderer.php @@ -67,7 +67,7 @@ public function __construct( Listener $listener, HtmlErrorRenderer $htmlErrorRenderer, BladeMapper $bladeMapper, - string $basePath + string $basePath, ) { $this->viewFactory = $viewFactory; $this->listener = $listener; diff --git a/src/Illuminate/Foundation/Http/Kernel.php b/src/Illuminate/Foundation/Http/Kernel.php index 79d5a6d5..90c9fb01 100644 --- a/src/Illuminate/Foundation/Http/Kernel.php +++ b/src/Illuminate/Foundation/Http/Kernel.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Http\Kernel as KernelContract; +use Illuminate\Foundation\Events\Terminating; use Illuminate\Foundation\Http\Events\RequestHandled; use Illuminate\Routing\Pipeline; use Illuminate\Routing\Router; @@ -210,6 +211,8 @@ protected function dispatchToRouter() */ public function terminate($request, $response) { + $this->app['events']->dispatch(new Terminating); + $this->terminateMiddleware($request, $response); $this->app->terminate(); @@ -448,6 +451,69 @@ public function appendToMiddlewarePriority($middleware) return $this; } + /** + * Add the given middleware to the middleware priority list before other middleware. + * + * @param array|string $before + * @param string $middleware + * @return $this + */ + public function addToMiddlewarePriorityBefore($before, $middleware) + { + return $this->addToMiddlewarePriorityRelative($before, $middleware, after: false); + } + + /** + * Add the given middleware to the middleware priority list after other middleware. + * + * @param array|string $after + * @param string $middleware + * @return $this + */ + public function addToMiddlewarePriorityAfter($after, $middleware) + { + return $this->addToMiddlewarePriorityRelative($after, $middleware); + } + + /** + * Add the given middleware to the middleware priority list relative to other middleware. + * + * @param string|array $existing + * @param string $middleware + * @param bool $after + * @return $this + */ + protected function addToMiddlewarePriorityRelative($existing, $middleware, $after = true) + { + if (! in_array($middleware, $this->middlewarePriority)) { + $index = $after ? 0 : count($this->middlewarePriority); + + foreach ((array) $existing as $existingMiddleware) { + if (in_array($existingMiddleware, $this->middlewarePriority)) { + $middlewareIndex = array_search($existingMiddleware, $this->middlewarePriority); + + if ($after && $middlewareIndex > $index) { + $index = $middlewareIndex + 1; + } elseif ($after === false && $middlewareIndex < $index) { + $index = $middlewareIndex; + } + } + } + + if ($index === 0 && $after === false) { + array_unshift($this->middlewarePriority, $middleware); + } elseif (($after && $index === 0) || $index === count($this->middlewarePriority)) { + $this->middlewarePriority[] = $middleware; + } else { + array_splice($this->middlewarePriority, $index, 0, $middleware); + } + } + + $this->syncMiddlewareToRouter(); + + return $this; + } + /** * Sync the current state of the middleware to the router. * diff --git a/src/Illuminate/Foundation/Http/Middleware/InvokeDeferredCallbacks.php b/src/Illuminate/Foundation/Http/Middleware/InvokeDeferredCallbacks.php new file mode 100644 index 00000000..9e31e026 --- /dev/null +++ b/src/Illuminate/Foundation/Http/Middleware/InvokeDeferredCallbacks.php @@ -0,0 +1,38 @@ +make(DeferredCallbackCollection::class) + ->invokeWhen(fn ($callback) => $response->getStatusCode() < 400 || $callback->always); + } +} diff --git a/src/Illuminate/Foundation/Mix.php b/src/Illuminate/Foundation/Mix.php index f06deb95..c465247a 100644 --- a/src/Illuminate/Foundation/Mix.php +++ b/src/Illuminate/Foundation/Mix.php @@ -2,7 +2,6 @@ namespace Illuminate\Foundation; -use Exception; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; @@ -15,7 +14,7 @@ class Mix * @param string $manifestDirectory * @return \Illuminate\Support\HtmlString|string * - * @throws \Illuminate\Foundation\MixManifestNotFoundException + * @throws \Illuminate\Foundation\MixManifestNotFoundException|\Illuminate\Foundation\MixFileNotFoundException */ public function __invoke($path, $manifestDirectory = '') { @@ -58,7 +57,7 @@ public function __invoke($path, $manifestDirectory = '') $manifest = $manifests[$manifestPath]; if (! isset($manifest[$path])) { - $exception = new Exception("Unable to locate Mix file: {$path}."); + $exception = new MixFileNotFoundException("Unable to locate Mix file: {$path}."); if (! app('config')->get('app.debug')) { report($exception); diff --git a/src/Illuminate/Foundation/MixFileNotFoundException.php b/src/Illuminate/Foundation/MixFileNotFoundException.php new file mode 100644 index 00000000..4e0ea741 --- /dev/null +++ b/src/Illuminate/Foundation/MixFileNotFoundException.php @@ -0,0 +1,10 @@ + EventCacheCommand::class, 'EventClear' => EventClearCommand::class, 'EventList' => EventListCommand::class, + 'InvokeSerializedClosure' => InvokeSerializedClosureCommand::class, 'KeyGenerate' => KeyGenerateCommand::class, 'Optimize' => OptimizeCommand::class, 'OptimizeClear' => OptimizeClearCommand::class, @@ -197,6 +200,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'FactoryMake' => FactoryMakeCommand::class, 'InterfaceMake' => InterfaceMakeCommand::class, 'JobMake' => JobMakeCommand::class, + 'JobMiddlewareMake' => JobMiddlewareMakeCommand::class, 'LangPublish' => LangPublishCommand::class, 'ListenerMake' => ListenerMakeCommand::class, 'MailMake' => MailMakeCommand::class, @@ -504,6 +508,18 @@ protected function registerJobMakeCommand() }); } + /** + * Register the command. + * + * @return void + */ + protected function registerJobMiddlewareMakeCommand() + { + $this->app->singleton(JobMiddlewareMakeCommand::class, function ($app) { + return new JobMiddlewareMakeCommand($app['files']); + }); + } + /** * Register the command. * diff --git a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php index a1976ac9..c137442a 100644 --- a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php @@ -2,6 +2,7 @@ namespace Illuminate\Foundation\Providers; +use Illuminate\Console\Events\CommandFinished; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Contracts\Console\Kernel as ConsoleKernel; use Illuminate\Contracts\Container\Container; @@ -22,7 +23,9 @@ use Illuminate\Http\Client\Factory as HttpFactory; use Illuminate\Http\Request; use Illuminate\Log\Events\MessageLogged; +use Illuminate\Queue\Events\JobAttempted; use Illuminate\Support\AggregateServiceProvider; +use Illuminate\Support\Defer\DeferredCallbackCollection; use Illuminate\Support\Facades\URL; use Illuminate\Testing\LoggedExceptionCollection; use Illuminate\Testing\ParallelTestingServiceProvider; @@ -86,6 +89,7 @@ public function register() $this->registerDumper(); $this->registerRequestValidation(); $this->registerRequestSignatureValidation(); + $this->registerDeferHandler(); $this->registerExceptionTracking(); $this->registerExceptionRenderer(); $this->registerMaintenanceModeManager(); @@ -135,8 +139,6 @@ public function registerDumper() * Register the "validate" macro on the request. * * @return void - * - * @throws \Illuminate\Validation\ValidationException */ public function registerRequestValidation() { @@ -186,6 +188,26 @@ public function registerRequestSignatureValidation() }); } + /** + * Register the "defer" function termination handler. + * + * @return void + */ + protected function registerDeferHandler() + { + $this->app->scoped(DeferredCallbackCollection::class); + + $this->app['events']->listen(function (CommandFinished $event) { + app(DeferredCallbackCollection::class)->invokeWhen(fn ($callback) => app()->runningInConsole() && ($event->exitCode === 0 || $callback->always) + ); + }); + + $this->app['events']->listen(function (JobAttempted $event) { + app(DeferredCallbackCollection::class)->invokeWhen(fn ($callback) => $event->connectionName !== 'sync' && ($event->successful() || $callback->always) + ); + }); + } + /** * Register an event listener to track logged exceptions. * @@ -217,6 +239,8 @@ protected function registerExceptionTracking() */ protected function registerExceptionRenderer() { + $this->loadViewsFrom(__DIR__.'/../Exceptions/views', 'laravel-exceptions'); + if (! $this->app->hasDebugModeEnabled()) { return; } diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php index b4aad547..c63a3316 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php @@ -5,6 +5,7 @@ use Closure; use Illuminate\Foundation\Mix; use Illuminate\Foundation\Vite; +use Illuminate\Support\Defer\DeferredCallbackCollection; use Illuminate\Support\Facades\Facade; use Illuminate\Support\HtmlString; use Mockery; @@ -25,6 +26,13 @@ trait InteractsWithContainer */ protected $originalMix; + /** + * The original deferred callbacks collection. + * + * @var \Illuminate\Support\Defer\DeferredCallbackCollection|null + */ + protected $originalDeferredCallbacksCollection; + /** * Register an instance of an object in the container. * @@ -234,4 +242,40 @@ protected function withMix() return $this; } + + /** + * Execute deferred functions immediately. + * + * @return $this + */ + protected function withoutDefer() + { + if ($this->originalDeferredCallbacksCollection == null) { + $this->originalDeferredCallbacksCollection = $this->app->make(DeferredCallbackCollection::class); + } + + $this->swap(DeferredCallbackCollection::class, new class extends DeferredCallbackCollection + { + public function offsetSet(mixed $offset, mixed $value): void + { + $value(); + } + }); + + return $this; + } + + /** + * Restore deferred functions. + * + * @return $this + */ + protected function withDefer() + { + if ($this->originalDeferredCallbacksCollection) { + $this->app->instance(DeferredCallbackCollection::class, $this->originalDeferredCallbacksCollection); + } + + return $this; + } } diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php index 141504fd..c4744b17 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php @@ -24,8 +24,15 @@ trait InteractsWithDatabase * @param string|null $connection * @return $this */ - protected function assertDatabaseHas($table, array $data, $connection = null) + protected function assertDatabaseHas($table, array $data = [], $connection = null) { + if ($table instanceof Model) { + $data = [ + $table->getKeyName() => $table->getKey(), + ...$data, + ]; + } + $this->assertThat( $this->getTable($table), new HasInDatabase($this->getConnection($connection, $table), $data) ); @@ -41,8 +48,15 @@ protected function assertDatabaseHas($table, array $data, $connection = null) * @param string|null $connection * @return $this */ - protected function assertDatabaseMissing($table, array $data, $connection = null) + protected function assertDatabaseMissing($table, array $data = [], $connection = null) { + if ($table instanceof Model) { + $data = [ + $table->getKeyName() => $table->getKey(), + ...$data, + ]; + } + $constraint = new ReverseConstraint( new HasInDatabase($this->getConnection($connection, $table), $data) ); @@ -157,11 +171,7 @@ protected function assertNotSoftDeleted($table, array $data = [], $connection = */ protected function assertModelExists($model) { - return $this->assertDatabaseHas( - $model->getTable(), - [$model->getKeyName() => $model->getKey()], - $model->getConnectionName() - ); + return $this->assertDatabaseHas($model); } /** @@ -172,11 +182,7 @@ protected function assertModelExists($model) */ protected function assertModelMissing($model) { - return $this->assertDatabaseMissing( - $model->getTable(), - [$model->getKeyName() => $model->getKey()], - $model->getConnectionName() - ); + return $this->assertDatabaseMissing($model); } /** @@ -199,8 +205,8 @@ public function expectsDatabaseQueryCount($expected, $connection = null) $this->beforeApplicationDestroyed(function () use (&$actual, $expected, $connectionInstance) { $this->assertSame( - $actual, $expected, + $actual, "Expected {$expected} database queries on the [{$connectionInstance->getName()}] connection. {$actual} occurred." ); }); @@ -225,9 +231,10 @@ protected function isSoftDeletableModel($model) * Cast a JSON string to a database compatible type. * * @param array|object|string $value + * @param string|null $connection * @return \Illuminate\Contracts\Database\Query\Expression */ - public function castAsJson($value) + public function castAsJson($value, $connection = null) { if ($value instanceof Jsonable) { $value = $value->toJson(); @@ -235,10 +242,12 @@ public function castAsJson($value) $value = json_encode($value); } - $value = DB::connection()->getPdo()->quote($value); + $db = DB::connection($connection); - return DB::raw( - DB::connection()->getQueryGrammar()->compileJsonValueCast($value) + $value = $db->getPdo()->quote($value); + + return $db->raw( + $db->getQueryGrammar()->compileJsonValueCast($value) ); } @@ -266,6 +275,10 @@ protected function getConnection($connection = null, $table = null) */ protected function getTable($table) { + if ($table instanceof Model) { + return $table->getTable(); + } + return $this->newModelFor($table)?->getTable() ?: $table; } @@ -277,6 +290,10 @@ protected function getTable($table) */ protected function getTableConnection($table) { + if ($table instanceof Model) { + return $table->getConnectionName(); + } + return $this->newModelFor($table)?->getConnectionName(); } diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php index 67aac9d9..cb20d976 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php @@ -5,6 +5,7 @@ use Closure; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Support\Testing\Fakes\ExceptionHandlerFake; +use Illuminate\Support\Traits\ReflectsClosures; use Illuminate\Testing\Assert; use Illuminate\Validation\ValidationException; use Symfony\Component\Console\Application as ConsoleApplication; @@ -13,6 +14,8 @@ trait InteractsWithExceptionHandling { + use ReflectsClosures; + /** * The original exception handler. * @@ -169,18 +172,22 @@ public function renderForConsole($output, Throwable $e) * Assert that the given callback throws an exception with the given message when invoked. * * @param \Closure $test - * @param class-string<\Throwable> $expectedClass + * @param \Closure|class-string<\Throwable> $expectedClass * @param string|null $expectedMessage * @return $this */ - protected function assertThrows(Closure $test, string $expectedClass = Throwable::class, ?string $expectedMessage = null) + protected function assertThrows(Closure $test, string|Closure $expectedClass = Throwable::class, ?string $expectedMessage = null) { + [$expectedClass, $expectedClassCallback] = $expectedClass instanceof Closure + ? [$this->firstClosureParameterType($expectedClass), $expectedClass] + : [$expectedClass, null]; + try { $test(); $thrown = false; } catch (Throwable $exception) { - $thrown = $exception instanceof $expectedClass; + $thrown = $exception instanceof $expectedClass && ($expectedClassCallback === null || $expectedClassCallback($exception)); $actualMessage = $exception->getMessage(); } diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php index 3d210cd7..ed058498 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php @@ -21,6 +21,7 @@ use Illuminate\Foundation\Testing\WithoutMiddleware; use Illuminate\Http\Middleware\TrustHosts; use Illuminate\Http\Middleware\TrustProxies; +use Illuminate\Queue\Console\WorkCommand; use Illuminate\Queue\Queue; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Facade; @@ -178,6 +179,7 @@ protected function tearDownTheTestEnvironment(): void TrustProxies::flushState(); TrustHosts::flushState(); ValidateCsrfToken::flushState(); + WorkCommand::flushState(); if ($this->callbackException) { throw $this->callbackException; diff --git a/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php b/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php index 78344769..8ecc6a78 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php +++ b/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php @@ -2,6 +2,7 @@ namespace Illuminate\Foundation\Testing\Concerns; +use BackedEnum; use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Cookie\CookieValuePrefix; use Illuminate\Http\Request; @@ -90,6 +91,34 @@ public function withHeader(string $name, string $value) return $this; } + /** + * Remove a header from the request. + * + * @param string $name + * @return $this + */ + public function withoutHeader(string $name) + { + unset($this->defaultHeaders[$name]); + + return $this; + } + + /** + * Remove headers from the request. + * + * @param array $headers + * @return $this + */ + public function withoutHeaders(array $headers) + { + foreach ($headers as $name) { + $this->withoutHeader($name); + } + + return $this; + } + /** * Add an authorization token for the request. * @@ -121,9 +150,7 @@ public function withBasicAuth(string $username, string $password) */ public function withoutToken() { - unset($this->defaultHeaders['Authorization']); - - return $this; + return $this->withoutHeader('Authorization'); } /** @@ -305,11 +332,11 @@ public function from(string $url) /** * Set the referer header and previous URL session value from a given route in order to simulate a previous request. * - * @param string $name + * @param \BackedEnum|string $name * @param mixed $parameters * @return $this */ - public function fromRoute(string $name, $parameters = []) + public function fromRoute(BackedEnum|string $name, $parameters = []) { return $this->from($this->app['url']->route($name, $parameters)); } diff --git a/src/Illuminate/Foundation/Testing/DatabaseTransactions.php b/src/Illuminate/Foundation/Testing/DatabaseTransactions.php index 83a686f3..f84a23fe 100644 --- a/src/Illuminate/Foundation/Testing/DatabaseTransactions.php +++ b/src/Illuminate/Foundation/Testing/DatabaseTransactions.php @@ -13,9 +13,11 @@ public function beginDatabaseTransaction() { $database = $this->app->make('db'); - $this->app->instance('db.transactions', $transactionsManager = new DatabaseTransactionsManager); + $connections = $this->connectionsToTransact(); - foreach ($this->connectionsToTransact() as $name) { + $this->app->instance('db.transactions', $transactionsManager = new DatabaseTransactionsManager($connections)); + + foreach ($connections as $name) { $connection = $database->connection($name); $connection->setTransactionManager($transactionsManager); $dispatcher = $connection->getEventDispatcher(); diff --git a/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php b/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php index bc545048..7ee71d7b 100644 --- a/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php +++ b/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php @@ -6,6 +6,24 @@ class DatabaseTransactionsManager extends BaseManager { + /** + * The names of the connections transacting during tests. + */ + protected array $connectionsTransacting; + + /** + * Create a new database transaction manager instance. + * + * @param array $connectionsTransacting + * @return void + */ + public function __construct(array $connectionsTransacting) + { + parent::__construct(); + + $this->connectionsTransacting = $connectionsTransacting; + } + /** * Register a transaction callback. * @@ -31,7 +49,7 @@ public function addCallback($callback) */ public function callbackApplicableTransactions() { - return $this->pendingTransactions->skip(1)->values(); + return $this->pendingTransactions->skip(count($this->connectionsTransacting))->values(); } /** diff --git a/src/Illuminate/Foundation/Testing/RefreshDatabase.php b/src/Illuminate/Foundation/Testing/RefreshDatabase.php index 4c4e084a..03d8d558 100644 --- a/src/Illuminate/Foundation/Testing/RefreshDatabase.php +++ b/src/Illuminate/Foundation/Testing/RefreshDatabase.php @@ -82,9 +82,11 @@ public function beginDatabaseTransaction() { $database = $this->app->make('db'); - $this->app->instance('db.transactions', $transactionsManager = new DatabaseTransactionsManager); + $connections = $this->connectionsToTransact(); - foreach ($this->connectionsToTransact() as $name) { + $this->app->instance('db.transactions', $transactionsManager = new DatabaseTransactionsManager($connections)); + + foreach ($connections as $name) { $connection = $database->connection($name); $connection->setTransactionManager($transactionsManager); diff --git a/src/Illuminate/Foundation/Testing/Wormhole.php b/src/Illuminate/Foundation/Testing/Wormhole.php index 54fe0fa0..beac013a 100644 --- a/src/Illuminate/Foundation/Testing/Wormhole.php +++ b/src/Illuminate/Foundation/Testing/Wormhole.php @@ -24,6 +24,30 @@ public function __construct($value) $this->value = $value; } + /** + * Travel forward the given number of microseconds. + * + * @param callable|null $callback + * @return mixed + */ + public function microsecond($callback = null) + { + return $this->microseconds($callback); + } + + /** + * Travel forward the given number of microseconds. + * + * @param callable|null $callback + * @return mixed + */ + public function microseconds($callback = null) + { + Carbon::setTestNow(Carbon::now()->addMicroseconds($this->value)); + + return $this->handleCallback($callback); + } + /** * Travel forward the given number of milliseconds. * diff --git a/src/Illuminate/Foundation/Vite.php b/src/Illuminate/Foundation/Vite.php index 97e79b6d..52d4a40d 100644 --- a/src/Illuminate/Foundation/Vite.php +++ b/src/Illuminate/Foundation/Vite.php @@ -2,10 +2,10 @@ namespace Illuminate\Foundation; -use Exception; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Support\Collection; use Illuminate\Support\HtmlString; +use Illuminate\Support\Js; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; @@ -97,6 +97,27 @@ class Vite implements Htmlable */ protected static $manifests = []; + /** + * The prefetching strategy to use. + * + * @var null|'waterfall'|'aggressive' + */ + protected $prefetchStrategy = null; + + /** + * The number of assets to load concurrently when using the "waterfall" strategy. + * + * @var int + */ + protected $prefetchConcurrently = 3; + + /** + * The name of the event that should trigger prefetching. The event must be dispatched on the `window`. + * + * @var string + */ + protected $prefetchEvent = 'load'; + /** * Get the preloaded assets. * @@ -154,6 +175,20 @@ public function withEntryPoints($entryPoints) return $this; } + /** + * Merge additional Vite entry points with the current set. + * + * @param array $entryPoints + * @return $this + */ + public function mergeEntryPoints($entryPoints) + { + return $this->withEntryPoints(array_unique([ + ...$this->entryPoints, + ...$entryPoints, + ])); + } + /** * Set the filename for the manifest file. * @@ -267,6 +302,62 @@ public function usePreloadTagAttributes($attributes) return $this; } + /** + * Eagerly prefetch assets. + * + * @param int|null $concurrency + * @param string $event + * @return $this + */ + public function prefetch($concurrency = null, $event = 'load') + { + $this->prefetchEvent = $event; + + return $concurrency === null + ? $this->usePrefetchStrategy('aggressive') + : $this->usePrefetchStrategy('waterfall', ['concurrency' => $concurrency]); + } + + /** + * Use the "waterfall" prefetching strategy. + * + * @return $this + */ + public function useWaterfallPrefetching(?int $concurrency = null) + { + return $this->usePrefetchStrategy('waterfall', [ + 'concurrency' => $concurrency ?? $this->prefetchConcurrently, + ]); + } + + /** + * Use the "aggressive" prefetching strategy. + * + * @return $this + */ + public function useAggressivePrefetching() + { + return $this->usePrefetchStrategy('aggressive'); + } + + /** + * Set the prefetching strategy. + * + * @param 'waterfall'|'aggressive'|null $strategy + * @param array $config + * @return $this + */ + public function usePrefetchStrategy($strategy, $config = []) + { + $this->prefetchStrategy = $strategy; + + if ($strategy === 'waterfall') { + $this->prefetchConcurrently = $config['concurrency'] ?? $this->prefetchConcurrently; + } + + return $this; + } + /** * Generate Vite tags for an entrypoint. * @@ -364,7 +455,122 @@ public function __invoke($entrypoints, $buildDirectory = null) ->sortByDesc(fn ($args) => $this->isCssPath($args[1])) ->map(fn ($args) => $this->makePreloadTagForChunk(...$args)); - return new HtmlString($preloads->join('').$stylesheets->join('').$scripts->join('')); + $base = $preloads->join('').$stylesheets->join('').$scripts->join(''); + + if ($this->prefetchStrategy === null || $this->isRunningHot()) { + return new HtmlString($base); + } + + $discoveredImports = []; + + return collect($entrypoints) + ->flatMap(fn ($entrypoint) => collect($manifest[$entrypoint]['dynamicImports'] ?? []) + ->map(fn ($import) => $manifest[$import]) + ->filter(fn ($chunk) => str_ends_with($chunk['file'], '.js') || str_ends_with($chunk['file'], '.css')) + ->flatMap($f = function ($chunk) use (&$f, $manifest, &$discoveredImports) { + return collect([...$chunk['imports'] ?? [], ...$chunk['dynamicImports'] ?? []]) + ->reject(function ($import) use (&$discoveredImports) { + if (isset($discoveredImports[$import])) { + return true; + } + + return ! $discoveredImports[$import] = true; + }) + ->reduce( + fn ($chunks, $import) => $chunks->merge( + $f($manifest[$import]) + ), collect([$chunk])) + ->merge(collect($chunk['css'] ?? [])->map( + fn ($css) => collect($manifest)->first(fn ($chunk) => $chunk['file'] === $css) ?? [ + 'file' => $css, + ], + )); + }) + ->map(function ($chunk) use ($buildDirectory, $manifest) { + return collect([ + ...$this->resolvePreloadTagAttributes( + $chunk['src'] ?? null, + $url = $this->assetPath("{$buildDirectory}/{$chunk['file']}"), + $chunk, + $manifest, + ), + 'rel' => 'prefetch', + 'fetchpriority' => 'low', + 'href' => $url, + ])->reject( + fn ($value) => in_array($value, [null, false], true) + )->mapWithKeys(fn ($value, $key) => [ + $key = (is_int($key) ? $value : $key) => $value === true ? $key : $value, + ])->all(); + }) + ->reject(fn ($attributes) => isset($this->preloadedAssets[$attributes['href']]))) + ->unique('href') + ->values() + ->pipe(fn ($assets) => with(Js::from($assets), fn ($assets) => match ($this->prefetchStrategy) { + 'waterfall' => new HtmlString($base.<<nonceAttribute()}> + window.addEventListener('{$this->prefetchEvent}', () => window.setTimeout(() => { + const makeLink = (asset) => { + const link = document.createElement('link') + + Object.keys(asset).forEach((attribute) => { + link.setAttribute(attribute, asset[attribute]) + }) + + return link + } + + const loadNext = (assets, count) => window.setTimeout(() => { + if (count > assets.length) { + count = assets.length + + if (count === 0) { + return + } + } + + const fragment = new DocumentFragment + + while (count > 0) { + const link = makeLink(assets.shift()) + fragment.append(link) + count-- + + if (assets.length) { + link.onload = () => loadNext(assets, 1) + link.onerror = () => loadNext(assets, 1) + } + } + + document.head.append(fragment) + }) + + loadNext({$assets}, {$this->prefetchConcurrently}) + })) + + HTML), + 'aggressive' => new HtmlString($base.<<nonceAttribute()}> + window.addEventListener('{$this->prefetchEvent}', () => window.setTimeout(() => { + const makeLink = (asset) => { + const link = document.createElement('link') + + Object.keys(asset).forEach((attribute) => { + link.setAttribute(attribute, asset[attribute]) + }) + + return link + } + + const fragment = new DocumentFragment; + {$assets}.forEach((asset) => fragment.append(makeLink(asset))) + document.head.append(fragment) + })) + + HTML), + })); } /** @@ -682,7 +888,7 @@ public function asset($asset, $buildDirectory = null) * @param string|null $buildDirectory * @return string * - * @throws \Exception + * @throws \Illuminate\Foundation\ViteException */ public function content($asset, $buildDirectory = null) { @@ -693,7 +899,7 @@ public function content($asset, $buildDirectory = null) $path = public_path($buildDirectory.'/'.$chunk['file']); if (! is_file($path) || ! file_exists($path)) { - throw new Exception("Unable to locate file from Vite manifest: {$path}."); + throw new ViteException("Unable to locate file from Vite manifest: {$path}."); } return file_get_contents($path); @@ -773,17 +979,31 @@ public function manifestHash($buildDirectory = null) * @param string $file * @return array * - * @throws \Exception + * @throws \Illuminate\Foundation\ViteException */ protected function chunk($manifest, $file) { if (! isset($manifest[$file])) { - throw new Exception("Unable to locate file in Vite manifest: {$file}."); + throw new ViteException("Unable to locate file in Vite manifest: {$file}."); } return $manifest[$file]; } + /** + * Get the nonce attribute for the prefetch script tags. + * + * @return \Illuminate\Support\HtmlString + */ + protected function nonceAttribute() + { + if ($this->cspNonce() === null) { + return new HtmlString(''); + } + + return new HtmlString(' nonce="'.$this->cspNonce().'"'); + } + /** * Determine if the HMR server is running. * diff --git a/src/Illuminate/Foundation/ViteException.php b/src/Illuminate/Foundation/ViteException.php new file mode 100644 index 00000000..984736e2 --- /dev/null +++ b/src/Illuminate/Foundation/ViteException.php @@ -0,0 +1,10 @@ +