diff --git a/composer.json b/composer.json index 7a9905febb..2fdc01eb7e 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "fruitcake/php-cors": "^1.3", "google/common-protos": "^4.14", "google/protobuf": "^5.35", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "guzzlehttp/psr7": "^2.13 || ^3.1", "guzzlehttp/uri-template": "^1.0 || ^2.0", diff --git a/docs/todo.md b/docs/todo.md index 157f7e42c2..28ff6026a0 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -35,6 +35,10 @@ - Investigate FTP support with Swoole's built-in coroutine FTP implementation. It supplies `ftp_*` functions but is not discoverable as `ext-ftp`, so Composer rejects `league/flysystem-ftp` and `RequiresPhpExtension('ftp')` skips the driver test. Resolve normal development and production installation without bypassing dependency checks, then add the adapter to root `require-dev`, use a test requirement that accepts either FTP implementation, and update the installation guidance. Basic transfers through Hypervel and Flysystem have been verified inside a Swoole coroutine. +## HTTP Client + +- Once the HTTP client can optionally use Swoole's coroutine HTTP client as its transport instead of curl, explore and benchmark that transport for Inertia SSR requests. Each SSR render posts the page JSON to a local SSR server through the `inertia-ssr` connection (`HttpGateway::CONNECTION`), which makes it a good candidate for the alternative transport. Compare the curl and Swoole transports on that connection against a local SSR server under concurrent load, measuring latency, throughput, client CPU, memory and connection reuse, and use the Swoole transport for the SSR connection if it is a clear improvement. + ## HTTP Server - Require a Swoole release that resets signal-listener state in forked server workers before releasing Hypervel 0.4. In Swoole 6.2.3 and earlier, a worker forked after the manager calls `Process::signal()` inherits the listener count, so `Coroutine\System::waitSignal()` fails in it. Hypervel's SIGINT shutdown handling registers a manager callback in both server modes, so after a reload, `max_request` recycling or a crash restart, replacement workers stop receiving configured signal handlers and Artisan traps. Once a fixed release is verified, raise the `ext-swoole` constraint and remove the version skip from `ShutdownOnInterruptListenerTest::testReplacementWorkersKeepTheirSignalHandlers()`. diff --git a/src/api-client/composer.json b/src/api-client/composer.json index 527df76328..4534e06cc0 100644 --- a/src/api-client/composer.json +++ b/src/api-client/composer.json @@ -25,9 +25,10 @@ ], "require": { "php": "^8.4", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/psr7": "^2.13 || ^3.1", "psr/http-message": "^2.0", + "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", diff --git a/src/broadcasting/composer.json b/src/broadcasting/composer.json index a7bd3af30c..db6b02dba7 100644 --- a/src/broadcasting/composer.json +++ b/src/broadcasting/composer.json @@ -25,7 +25,7 @@ }, "require": { "php": "^8.4", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "hypervel/bus": "^0.4", "hypervel/collections": "^0.4", "hypervel/connection-pool": "^0.4", diff --git a/src/concurrency/composer.json b/src/concurrency/composer.json index f819964e28..0d0e6cad6c 100644 --- a/src/concurrency/composer.json +++ b/src/concurrency/composer.json @@ -26,6 +26,7 @@ }, "require": { "php": "^8.4", + "hypervel/collections": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", diff --git a/src/console/composer.json b/src/console/composer.json index 4df72643cf..07f4cdc960 100644 --- a/src/console/composer.json +++ b/src/console/composer.json @@ -31,7 +31,7 @@ "ext-posix": "*", "ext-swoole": "^6.2.2", "dragonmantank/cron-expression": "^3.4", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "hypervel/bus": "^0.4", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/contracts/src/Session/Session.php b/src/contracts/src/Session/Session.php index 59032d689c..d8c7e058d6 100644 --- a/src/contracts/src/Session/Session.php +++ b/src/contracts/src/Session/Session.php @@ -150,6 +150,16 @@ public function migrate(bool $destroy = false): bool; */ public function isStarted(): bool; + /** + * Mark the session as read-only for the current request. + */ + public function markAsReadOnly(): void; + + /** + * Determine if the session is read-only for the current request. + */ + public function isReadOnly(): bool; + /** * Get the previous URL from the session. */ diff --git a/src/docs/frontend.md b/src/docs/frontend.md index ee205f6b51..dd625d179e 100644 --- a/src/docs/frontend.md +++ b/src/docs/frontend.md @@ -112,6 +112,37 @@ As you can see, Inertia allows you to leverage the full power of React, Svelte, If you're concerned about diving into Inertia because your application requires server-side rendering, don't worry. Inertia offers [server-side rendering support](https://inertiajs.com/server-side-rendering). And, when deploying your application via [SonicStack](https://sonicstack.io), it's a breeze to ensure that Inertia's server-side rendering process is always running. +#### DevTools + +[Inertia DevTools](https://inertiajs.com/docs/devtools) is a browser extension that records every Inertia visit and displays it in a dedicated DevTools panel, showing which props each visit returned, whether they were deferred or merged, the request and response headers, and which route and controller handled it. There is no separate package to install: Hypervel's Inertia adapter includes the recorder, so you only need the browser extension and the Inertia client-side adapter at `^3.6`. + +The recorder is enabled automatically in your local environment. You may set the `INERTIA_DEVTOOLS_ENABLED` environment variable to override that default: + +```ini +INERTIA_DEVTOOLS_ENABLED=false +``` + +Entries are written to `storage/inertia-devtools` and pruned automatically. Before an entry is stored, the values of sensitive keys are redacted from props, request data, JSON bodies, and URL query strings, and sensitive headers are redacted entirely. Other request and response bodies, such as HTML or plain text, are stored as they were sent, so you may exclude any paths whose responses contain secrets. You may adjust the storage, redaction, and excluded paths under the `devtools` key of your application's `config/inertia.php` configuration file. + +To allow access outside your local environment, define a gate and reference it using the `INERTIA_DEVTOOLS_GATE` environment variable: + +```php +use Hypervel\Support\Facades\Gate; + +Gate::define('viewInertiaDevTools', function ($user) { + return $user->isAdmin(); +}); +``` + +```ini +INERTIA_DEVTOOLS_ENABLED=true +INERTIA_DEVTOOLS_GATE=viewInertiaDevTools +``` + +Your local environment is always allowed, so a gate can never lock you out of DevTools while you work locally. + +The gate only controls who may view entries. Requests are recorded no matter who makes them, so only enable the recorder outside your local environment when untrusted visitors can't reach the application. + ### Starter Kits diff --git a/src/docs/session.md b/src/docs/session.md index 1e9b348793..6e0b69da41 100644 --- a/src/docs/session.md +++ b/src/docs/session.md @@ -12,6 +12,7 @@ - [Managing User Sessions](#managing-user-sessions) - [Session Cache](#session-cache) - [Session Blocking](#session-blocking) +- [Read-Only Sessions](#read-only-sessions) - [Configuring the Session Cookie](#configuring-the-session-cookie) - [Adding Custom Session Drivers](#adding-custom-session-drivers) - [Implementing the Driver](#implementing-the-driver) @@ -458,6 +459,25 @@ Route::post('/profile', function () { })->block(); ``` + +## Read-Only Sessions + +Some routes only need to read the session, such as endpoints your frontend polls in the background while the user works in your application. Since the entire session is saved at the end of each request, a request like this can overwrite data that a concurrent request saved in the meantime, and it ages the session's flash data. To prevent this, you may chain the `readOnlySession` method onto the route definition: + +```php +Route::get('/notifications/unread', function () { + // ... +})->readOnlySession(); +``` + +The session is started as usual, so the route can read session data and authenticate the user. However, the session is not saved when the request finishes, and neither the session cookie nor the `XSRF-TOKEN` cookie is added to the response. Changes made to the session are available until the request ends, and regenerating or invalidating the session ID does not delete the stored session. The request is also not recorded as the session's previous URL. + +You may also make the current request's session read-only from your route or controller using the `markAsReadOnly` method: + +```php +$request->session()->markAsReadOnly(); +``` + ## Configuring the Session Cookie diff --git a/src/docs/vite.md b/src/docs/vite.md index 2e2678d982..9389998fb5 100644 --- a/src/docs/vite.md +++ b/src/docs/vite.md @@ -891,7 +891,20 @@ php artisan inertia:start-ssr --runtime=bun You may also configure the runtime using the `INERTIA_SSR_RUNTIME` environment variable. Runtime values may be executable names or absolute paths. -The `hot_url` option within your application's `inertia.ssr` configuration may be used to specify the SSR server URL while Vite is running. This option may also be configured using the `INERTIA_SSR_HOT_URL` environment variable. The `connect_timeout` and `timeout` options control how long Hypervel waits for the SSR server, while the `backoff` option determines how long a worker skips SSR after a connection failure or malformed response. +The `hot_url` option within your application's `inertia.ssr` configuration may be used to specify the SSR server URL while Vite is running. This option may also be configured using the `INERTIA_SSR_HOT_URL` environment variable. The `connect_timeout` and `timeout` options control how many seconds Hypervel waits for the SSR server; you may set either option to `null` to use the HTTP client's global timeout instead. The `backoff` option determines how long a worker skips SSR after a connection failure or malformed response. + +For more control, you may use the `Inertia::configureSsrRequestUsing` method, typically from a service provider. The closure receives the `PendingRequest` before it is sent, so you may add retries, headers, or any other option supported by Hypervel's [HTTP client](/docs/{{version}}/http-client): + +```php +use Hypervel\Http\Client\PendingRequest; +use Hypervel\Inertia\Inertia; + +Inertia::configureSsrRequestUsing(function (PendingRequest $request) { + $request->timeout(3)->retry(2); +}); +``` + +The closure also applies to the health check and shutdown requests sent by the `inertia:check-ssr` and `inertia:stop-ssr` commands. Since SSR requests are sent using the HTTP client, `Http::fake` and `Http::preventStrayRequests` apply to them in your tests. When an SSR render request fails, Hypervel renders the page on the client and dispatches a `Hypervel\Inertia\Ssr\SsrRenderFailed` event. To throw an exception instead, enable the `throw_on_error` option within your application's `inertia.ssr` configuration. diff --git a/src/foundation/composer.json b/src/foundation/composer.json index c2629c199f..512db498a8 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -28,7 +28,7 @@ "ext-filter": "*", "ext-posix": "*", "brick/math": "^1.0", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "laravel/serializable-closure": "^2.0.11", "league/flysystem": "^3.25.1", "league/uri": "^7.5.1", diff --git a/src/foundation/src/Http/Middleware/PreventRequestForgery.php b/src/foundation/src/Http/Middleware/PreventRequestForgery.php index 8172f438f6..d6fbc46807 100644 --- a/src/foundation/src/Http/Middleware/PreventRequestForgery.php +++ b/src/foundation/src/Http/Middleware/PreventRequestForgery.php @@ -80,7 +80,8 @@ public function handle(Request $request, Closure $next): Response || $this->tokensMatch($request) ) { return tap($next($request), function ($response) use ($request) { - if ($this->shouldAddXsrfTokenCookie()) { + // A read-only session's token is never saved, so the browser keeps its current cookie. + if ($this->shouldAddXsrfTokenCookie() && ! $request->session()->isReadOnly()) { $this->addCookieToResponse($request, $response); } }); diff --git a/src/foundation/src/Testing/Concerns/MakesHttpRequests.php b/src/foundation/src/Testing/Concerns/MakesHttpRequests.php index 5cc369da71..959f11f160 100644 --- a/src/foundation/src/Testing/Concerns/MakesHttpRequests.php +++ b/src/foundation/src/Testing/Concerns/MakesHttpRequests.php @@ -483,7 +483,7 @@ public function call( array $server = [], ?string $content = null ): TestResponse { - return $this->getWaiter()->wait(function () use ($method, $uri, $parameters, $cookies, $files, $server, $content) { + $response = $this->getWaiter()->wait(function () use ($method, $uri, $parameters, $cookies, $files, $server, $content): TestResponse { $kernel = $this->app->make(HttpKernel::class); $files = array_merge($files, $this->extractFilesFromDataArray($parameters)); @@ -539,14 +539,16 @@ public function call( $this->syncRequestContextToParent($request); - $response = $this->createTestResponse($response, $request); + return $this->createTestResponse($response, $request); + }, 10.0, copyContext: true); - if ($this->followRedirects) { - $response = $this->followRedirects($response); - } + // Follow redirects from the test coroutine, so each followed request syncs its + // session, authentication and request state back to the test. + if ($this->followRedirects) { + return $this->followRedirects($response); + } - return $response; - }, 10.0, copyContext: true); + return $response; } /** @@ -570,10 +572,14 @@ protected function syncRequestContextToParent(Request $request): void { $synchronizer = new RequestContextSynchronizer; - $synchronizer->syncSnapshotToParent( - $this->sessionContextSnapshot($request), - $this->sessionContextKeys() - ); + // A read-only session is never saved, so the test keeps the session it had before + // the request, just as the next real request would load the unchanged stored session. + if (! $request->hasSession() || ! $request->session()->isReadOnly()) { + $synchronizer->syncSnapshotToParent( + $this->sessionContextSnapshot($request), + $this->sessionContextKeys() + ); + } $synchronizer->syncContextKeysToParent($this->authenticationContextKeys()); } diff --git a/src/grpc/composer.json b/src/grpc/composer.json index 5838abacbf..bd3bb00d43 100644 --- a/src/grpc/composer.json +++ b/src/grpc/composer.json @@ -36,6 +36,7 @@ "ext-zlib": "*", "google/common-protos": "^4.14", "google/protobuf": "^5.35", + "hypervel/collections": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", diff --git a/src/http/composer.json b/src/http/composer.json index 7483ba70fa..55eec81310 100644 --- a/src/http/composer.json +++ b/src/http/composer.json @@ -27,7 +27,7 @@ "php": "^8.4", "ext-filter": "*", "fruitcake/php-cors": "^1.3", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "guzzlehttp/psr7": "^2.13 || ^3.1", "guzzlehttp/uri-template": "^1.0 || ^2.0", diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php index 1006dd993d..b1bb8d71b6 100644 --- a/src/http/src/Client/Factory.php +++ b/src/http/src/Client/Factory.php @@ -226,16 +226,21 @@ public static function psr7Response( */ protected static function normalizeResponseHeaders(array $headers): array { + $normalized = []; + + // Fresh arrays never write through, or keep, references in the caller's data. foreach ($headers as $name => $value) { if (is_array($value)) { if ($value === []) { - $headers[$name] = ''; + $normalized[$name] = ''; continue; } + $normalizedValue = []; + foreach ($value as $key => $item) { - $value[$key] = match (true) { + $normalizedValue[$key] = match (true) { $item === null => '', is_scalar($item) => static::normalizeScalarString($item), $item instanceof Stringable => $item->toString(), @@ -243,12 +248,12 @@ protected static function normalizeResponseHeaders(array $headers): array }; } - $headers[$name] = $value; + $normalized[$name] = $normalizedValue; continue; } - $headers[$name] = match (true) { + $normalized[$name] = match (true) { $value === null => '', is_scalar($value) => static::normalizeScalarString($value), $value instanceof Stringable => $value->toString(), @@ -256,7 +261,7 @@ protected static function normalizeResponseHeaders(array $headers): array }; } - return $headers; + return $normalized; } /** diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 25ecc69d82..0f5430b1b8 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -797,6 +797,7 @@ public function dd(): static * @phpstan-return (TAsync is false ? Response : PromiseInterface) * * @throws ConnectionException + * @throws HttpRequestException * @throws InvalidArgumentException */ public function get(string $url, Arrayable|array|JsonSerializable|string|null $query = null): PromiseInterface|Response @@ -816,6 +817,7 @@ public function get(string $url, Arrayable|array|JsonSerializable|string|null $q * @phpstan-return (TAsync is false ? Response : PromiseInterface) * * @throws ConnectionException + * @throws HttpRequestException * @throws InvalidArgumentException */ public function head(string $url, Arrayable|array|JsonSerializable|string|null $query = null): PromiseInterface|Response @@ -835,6 +837,7 @@ public function head(string $url, Arrayable|array|JsonSerializable|string|null $ * @phpstan-return (TAsync is false ? Response : PromiseInterface) * * @throws ConnectionException + * @throws HttpRequestException * @throws InvalidArgumentException */ public function query(string $url, Arrayable|array|JsonSerializable $data = []): PromiseInterface|Response @@ -850,6 +853,7 @@ public function query(string $url, Arrayable|array|JsonSerializable $data = []): * @phpstan-return (TAsync is false ? Response : PromiseInterface) * * @throws ConnectionException + * @throws HttpRequestException * @throws InvalidArgumentException */ public function post(string $url, Arrayable|array|JsonSerializable $data = []): PromiseInterface|Response @@ -865,6 +869,7 @@ public function post(string $url, Arrayable|array|JsonSerializable $data = []): * @phpstan-return (TAsync is false ? Response : PromiseInterface) * * @throws ConnectionException + * @throws HttpRequestException * @throws InvalidArgumentException */ public function patch(string $url, Arrayable|array|JsonSerializable $data = []): PromiseInterface|Response @@ -880,6 +885,7 @@ public function patch(string $url, Arrayable|array|JsonSerializable $data = []): * @phpstan-return (TAsync is false ? Response : PromiseInterface) * * @throws ConnectionException + * @throws HttpRequestException * @throws InvalidArgumentException */ public function put(string $url, Arrayable|array|JsonSerializable $data = []): PromiseInterface|Response @@ -895,6 +901,7 @@ public function put(string $url, Arrayable|array|JsonSerializable $data = []): P * @phpstan-return (TAsync is false ? Response : PromiseInterface) * * @throws ConnectionException + * @throws HttpRequestException * @throws InvalidArgumentException */ public function delete(string $url, Arrayable|array|JsonSerializable $data = []): PromiseInterface|Response @@ -1348,6 +1355,11 @@ protected function normalizeRequestOptions(array $options): array continue; } + // parseRequestData() has already normalized the logical request data into fresh arrays. + if ($key === self::DATA_OPTION) { + continue; + } + $options[$key] = $this->normalizeRequestOptionValue($value); } @@ -1359,15 +1371,18 @@ protected function normalizeRequestOptions(array $options): array */ protected function normalizeHeaderValues(array $headers): array { + $normalized = []; + + // A fresh array never writes through, or keeps, references in the caller's data. foreach ($headers as $name => $value) { if (! is_string($name)) { throw new InvalidArgumentException('HTTP header names must be strings.'); } - $headers[$name] = $this->normalizeHeaderValue($value); + $normalized[$name] = $this->normalizeHeaderValue($value); } - return $headers; + return $normalized; } /** @@ -1382,8 +1397,11 @@ protected function normalizeHeaderValue(mixed $value): string|array return ''; } + $normalized = []; + + // A fresh array never writes through, or keeps, references in the caller's data. foreach ($value as $key => $item) { - $value[$key] = match (true) { + $normalized[$key] = match (true) { $item === null => '', is_scalar($item) => $this->normalizeScalarString($item), $item instanceof Stringable => $item->toString(), @@ -1391,7 +1409,7 @@ protected function normalizeHeaderValue(mixed $value): string|array }; } - return $value; + return $normalized; } return match (true) { @@ -1423,33 +1441,40 @@ protected function normalizeNonFiniteFloatValues(array $values): array */ protected function normalizeMultipartOption(array $multipart): array { + $normalized = []; + + // Fresh arrays never write through, or keep, references in the caller's data. foreach ($multipart as $index => $part) { if (! is_array($part)) { - $multipart[$index] = $this->normalizeRequestOptionValue($part); + $normalized[$index] = $this->normalizeRequestOptionValue($part); continue; } + $normalizedPart = []; + foreach ($part as $key => $value) { if ($key === 'headers' && is_array($value)) { + $normalizedPart[$key] = $value; + continue; } - $part[$key] = $this->normalizeStructuredDataValue($value); + $normalizedPart[$key] = $this->normalizeStructuredDataValue($value); if ($key === 'contents') { - if (is_array($part[$key])) { - $part[$key] = $this->normalizeNonFiniteFloatValues($part[$key]); - } elseif (is_float($part[$key]) && ! is_finite($part[$key])) { - $part[$key] = $this->normalizeScalarString($part[$key]); + if (is_array($normalizedPart[$key])) { + $normalizedPart[$key] = $this->normalizeNonFiniteFloatValues($normalizedPart[$key]); + } elseif (is_float($normalizedPart[$key]) && ! is_finite($normalizedPart[$key])) { + $normalizedPart[$key] = $this->normalizeScalarString($normalizedPart[$key]); } } } - $multipart[$index] = $part; + $normalized[$index] = $normalizedPart; } - return $this->normalizeMultipartHeaders($multipart); + return $this->normalizeMultipartHeaders($normalized); } /** @@ -1461,8 +1486,11 @@ protected function normalizeMultipartHeaders(array $multipart): array { foreach ($multipart as $index => $part) { if (is_array($part) && isset($part['headers']) && is_array($part['headers'])) { + $headers = []; + + // A fresh array never writes through, or keeps, references in the caller's data. foreach ($part['headers'] as $name => $value) { - $multipart[$index]['headers'][$name] = match (true) { + $headers[$name] = match (true) { $value === [] => '', $value === null => '', is_scalar($value) => $this->normalizeScalarString($value), @@ -1470,6 +1498,8 @@ protected function normalizeMultipartHeaders(array $multipart): array default => throw new InvalidArgumentException('Multipart header values must be scalar, null, or Hypervel Stringable.'), }; } + + $multipart[$index]['headers'] = $headers; } } @@ -1481,8 +1511,20 @@ protected function normalizeMultipartHeaders(array $multipart): array */ protected function normalizeRequestOptionValue(mixed $value): mixed { + if (is_array($value)) { + $normalized = []; + + // A fresh array never writes through, or keeps, references in the caller's data. + foreach ($value as $key => $item) { + $normalized[$key] = is_array($item) || is_object($item) + ? $this->normalizeRequestOptionValue($item) + : $item; + } + + return $normalized; + } + return match (true) { - is_array($value) => array_map(fn ($item) => $this->normalizeRequestOptionValue($item), $value), $value instanceof Stringable => $value->toString(), $value instanceof JsonSerializable => $value, $value instanceof Arrayable => $this->normalizeRequestOptionValue($value->toArray()), @@ -1511,8 +1553,20 @@ protected function normalizeQuery(mixed $query): array|string|null */ protected function normalizeStructuredDataValue(mixed $value): mixed { + if (is_array($value)) { + $normalized = []; + + // A fresh array never writes through, or keeps, references in the caller's data. + foreach ($value as $key => $item) { + $normalized[$key] = is_array($item) || is_object($item) + ? $this->normalizeStructuredDataValue($item) + : $item; + } + + return $normalized; + } + return match (true) { - is_array($value) => array_map(fn ($item) => $this->normalizeStructuredDataValue($item), $value), $value instanceof Stringable => $value->toString(), $value instanceof JsonSerializable => $this->normalizeStructuredDataValue($value->jsonSerialize()), $value instanceof Arrayable => $this->normalizeStructuredDataValue($value->toArray()), diff --git a/src/inertia/README.md b/src/inertia/README.md index f95b0f4a58..b49f9d539c 100644 --- a/src/inertia/README.md +++ b/src/inertia/README.md @@ -4,6 +4,6 @@ The Inertia.js server-side adapter for Hypervel, providing middleware, response ## Differences From Laravel -Hypervel sends SSR requests using a dedicated reusable HTTP client. Therefore, `Http::fake()` and `Http::preventStrayRequests()` do not intercept SSR requests. Tests may replace the SSR client using `Hypervel\Inertia\Ssr\HttpGateway::useTestingClient()`. +SSR requests default to a 2-second connect timeout and a 5-second total timeout, configured by the `inertia.ssr.connect_timeout` and `inertia.ssr.timeout` options, so pages fall back to client-side rendering quickly when the SSR server is slow. Laravel's adapter uses the HTTP client's global timeouts unless `inertia.ssr.timeout` is set. Set either option to `null` to use the global timeout instead. Ported from: https://github.com/inertiajs/inertia-laravel diff --git a/src/inertia/composer.json b/src/inertia/composer.json index f7d6add297..bdc5ef1229 100644 --- a/src/inertia/composer.json +++ b/src/inertia/composer.json @@ -33,15 +33,16 @@ }, "require": { "php": "^8.4", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "symfony/console": "^8.1.2", "symfony/http-foundation": "^8.1", "symfony/process": "^8.1", + "hypervel/collections": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/filesystem": "^0.4", "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", diff --git a/src/inertia/config/inertia.php b/src/inertia/config/inertia.php index e25b770849..7c558c1aeb 100644 --- a/src/inertia/config/inertia.php +++ b/src/inertia/config/inertia.php @@ -2,6 +2,8 @@ declare(strict_types=1); +$devtoolsEnabled = env('INERTIA_DEVTOOLS_ENABLED'); + return [ /* |-------------------------------------------------------------------------- @@ -41,14 +43,16 @@ | SSR Timeouts |-------------------------------------------------------------------------- | - | Configure connection and read timeouts for SSR requests. These prevent - | coroutines from hanging indefinitely when the SSR server is unresponsive. + | Configure the connection and total timeouts for SSR requests, in seconds. + | Short timeouts let pages fall back to client-side rendering quickly when + | the SSR server is slow or unresponsive. Set either option to null to use + | the HTTP client's global timeout instead. | */ - 'connect_timeout' => (int) env('INERTIA_SSR_CONNECT_TIMEOUT', 2), + 'connect_timeout' => ($timeout = env('INERTIA_SSR_CONNECT_TIMEOUT', 2)) === null ? null : (float) $timeout, - 'timeout' => (int) env('INERTIA_SSR_TIMEOUT', 5), + 'timeout' => ($timeout = env('INERTIA_SSR_TIMEOUT', 5)) === null ? null : (float) $timeout, /* |-------------------------------------------------------------------------- @@ -159,4 +163,61 @@ 'history' => [ 'encrypt' => (bool) env('INERTIA_ENCRYPT_HISTORY', false), ], + + /* + |-------------------------------------------------------------------------- + | DevTools + |-------------------------------------------------------------------------- + | + | Records one entry per request to disk so the DevTools Chrome extension may + | read it back over HTTP. When `enabled` is null, recording is limited to + | your local environment. Omitted DevTools members use the defaults shown + | below. See https://inertiajs.com/docs/devtools for the gate and storage + | options. + | + */ + + 'devtools' => [ + 'enabled' => $devtoolsEnabled === null ? null : (bool) $devtoolsEnabled, + + 'except' => ['telescope*', 'horizon*', '_inertia/devtools*'], + + 'storage' => [ + 'path' => storage_path('inertia-devtools'), + + 'ttl' => (int) env('INERTIA_DEVTOOLS_TTL_HOURS', 24), + + 'prune_interval' => (int) env('INERTIA_DEVTOOLS_PRUNE_INTERVAL_SECONDS', 300), + + 'limit' => (int) env('INERTIA_DEVTOOLS_LIMIT', 100), + ], + + 'middleware' => ['web'], + + 'gate' => env('INERTIA_DEVTOOLS_GATE'), + + 'redact' => [ + 'keys' => [ + 'password', + 'password_confirmation', + 'current_password', + 'token', + '_token', + 'access_token', + 'refresh_token', + 'secret', + 'client_secret', + 'api_key', + ], + + 'headers' => [ + 'cookie', + 'set-cookie', + 'authorization', + 'proxy-authorization', + 'x-xsrf-token', + 'x-csrf-token', + ], + ], + ], ]; diff --git a/src/inertia/src/Commands/StopSsr.php b/src/inertia/src/Commands/StopSsr.php index 7cbcd65825..c53cd80e3a 100644 --- a/src/inertia/src/Commands/StopSsr.php +++ b/src/inertia/src/Commands/StopSsr.php @@ -4,8 +4,8 @@ namespace Hypervel\Inertia\Commands; -use GuzzleHttp\Exception\TransferException; use Hypervel\Console\Command; +use Hypervel\Http\Client\ConnectionException; use Hypervel\Inertia\Ssr\HttpGateway; use Symfony\Component\Console\Attribute\AsCommand; @@ -39,7 +39,7 @@ public function handle(HttpGateway $gateway): int return self::FAILURE; } - } catch (TransferException) { + } catch (ConnectionException) { // The official shutdown endpoint terminates after a verified health // response and may close the connection without sending a response. } diff --git a/src/inertia/src/DevTools/Collector.php b/src/inertia/src/DevTools/Collector.php new file mode 100644 index 0000000000..e58ca46817 --- /dev/null +++ b/src/inertia/src/DevTools/Collector.php @@ -0,0 +1,321 @@ +> */ + protected array $props = []; + + /** @var null|array{file: string, line: int} */ + protected ?array $renderSource = null; + + /** @var array */ + protected array $shareSources = []; + + /** @var null|array{name: null|string, uri: string, method: string} */ + protected ?array $route = null; + + /** @var array */ + protected array $sharedKeys = []; + + protected ?string $routeAction = null; + + /** @var null|array{file: string, line: int} */ + protected ?array $actionSource = null; + + protected ?string $componentPath = null; + + /** + * The resolved page props, kept nested so values may be plucked per prop path. + * + * @var array + */ + protected array $resolvedProps = []; + + /** + * Create a new collector instance. + */ + public function __construct(protected string $component, protected SourceLocator $sourceLocator) + { + } + + /** + * Set the source location of the render call. + */ + public function setRenderSource(?string $file, ?int $line): void + { + if ($file !== null && $line !== null) { + $this->renderSource = ['file' => $file, 'line' => $line]; + } + } + + /** + * Set the source locations of the shared prop keys. + * + * @param array $sources + */ + public function setShareSources(array $sources): void + { + $this->shareSources = $sources; + } + + /** + * Set the route that rendered the page. + */ + public function setRoute(?string $name, string $uri, string $method): void + { + $this->route = [ + 'name' => $name, + 'uri' => $uri, + 'method' => $method, + ]; + } + + /** + * Set which top-level prop keys came from shared props. + * + * @param array $keys + */ + public function setSharedKeys(array $keys): void + { + $this->sharedKeys = $keys; + } + + /** + * Record a prop's Inertia wrapper type, defer group, and extended metadata. + */ + public function addProp( + string $path, + ?PropType $inertiaType = null, + ?string $deferGroup = null, + bool $reset = false, + bool $once = false, + ?string $mergeDirection = null, + bool $deepMerge = false, + bool $rescued = false, + ): void { + $this->props[$path] = [ + 'shared' => in_array($path, $this->sharedKeys, true), + 'inertiaType' => $inertiaType?->value, + ]; + + if ($deferGroup !== null) { + $this->props[$path]['deferGroup'] = $deferGroup; + } + + if (isset($this->shareSources[$path])) { + $this->props[$path]['shareSource'] = $this->shareSources[$path]; + } + + if ($reset) { + $this->props[$path]['reset'] = true; + } + + if ($once) { + $this->props[$path]['once'] = true; + } + + if ($mergeDirection !== null) { + $this->props[$path]['mergeDirection'] = $mergeDirection; + } + + if ($deepMerge) { + $this->props[$path]['deepMerge'] = true; + } + + if ($rescued) { + $this->props[$path]['rescued'] = true; + } + } + + /** + * Store the route action and resolve its source location. + */ + public function setRouteAction(?string $action, mixed $uses = null): void + { + if ($action === null) { + return; + } + + $this->routeAction = $action; + + $this->actionSource = $this->sourceLocator->resolveActionSource($action, $uses); + } + + /** + * Store the resolved file path of the frontend component. + */ + public function setComponentPath(?string $path): void + { + if ($path !== null) { + $this->componentPath = $path; + } + } + + /** + * Store the resolved page props. Values are plucked per prop path when the entry + * is built, so no work is spent flattening props that get pruned. + * + * @param array $props + */ + public function setResolvedProps(array $props): void + { + $this->resolvedProps = $props; + } + + /** + * Scan the render source file to find the line number of each non-shared prop key. + */ + protected function resolveRenderPropLines(): void + { + if ($this->renderSource === null) { + return; + } + + $renderProps = collect($this->props) + ->reject(fn (array $info): bool => $info['shared'] || isset($info['shareSource'])) + ->keys() + ->all(); + + if ($renderProps === []) { + return; + } + + foreach ($renderProps as $propKey) { + $line = $this->sourceLocator->findPropKeyLine( + $this->renderSource['file'], + $this->renderSource['line'], + (string) $propKey, + ); + + if ($line !== null) { + $this->props[$propKey]['renderSource'] = [ + 'file' => $this->renderSource['file'], + 'line' => $line, + ]; + } + } + } + + /** + * Drop deep prop paths that carry no devtools metadata. Every top-level prop is + * kept so nothing disappears from the tree; nested values are rendered from the + * recorded prop values rather than one metadata row per leaf. + * + * @return array> + */ + protected function pruneProps(): array + { + return collect($this->props) + ->filter(fn (array $meta, string $path): bool => ! str_contains($path, '.') || $this->propHasMetadata($meta)) + ->all(); + } + + /** + * Determine if the prop carries metadata beyond its default shape. + * + * @param array $meta + */ + protected function propHasMetadata(array $meta): bool + { + return $meta['shared'] === true + || $meta['inertiaType'] !== null + || count($meta) > 2; + } + + /** + * Pluck the value backing each metadata node. Nested objects are stored once under + * their prop path rather than duplicated as the parent object and every exploded + * child path (e.g. `auth.user` alongside `auth.user.id`, `auth.user.name`). + * + * @param array $paths + * @return array + */ + protected function extractPropValues(array $paths): array + { + if ($this->resolvedProps === []) { + return []; + } + + $normalized = $this->normalizePropValues($this->resolvedProps); + $values = []; + + foreach ($paths as $path) { + if (Arr::has($normalized, $path)) { + $values[$path] = Arr::get($normalized, $path); + } + } + + return $values; + } + + /** + * Cast the resolved props to plain arrays and scalars so recorded values match the + * JSON the client received. + * + * @param array $props + * @return array + */ + protected function normalizePropValues(array $props): array + { + $encoded = json_encode($props); + + if (! is_string($encoded)) { + return $props; + } + + return json_decode($encoded, true); + } + + /** + * Assemble the final devtools metadata array. + * + * @return array + */ + public function build(): array + { + $this->resolveRenderPropLines(); + + $props = $this->pruneProps(); + + $result = [ + 'schemaVersion' => 1, + 'props' => $props, + 'component' => $this->component, + ]; + + if ($this->renderSource !== null) { + $result['renderSource'] = $this->renderSource; + } + + if ($this->route !== null) { + $result['route'] = $this->route; + } + + if ($this->routeAction !== null) { + $result['route']['action'] = $this->routeAction; + } + + if ($this->actionSource !== null) { + $result['route']['actionSource'] = $this->actionSource; + } + + if ($this->componentPath !== null) { + $result['componentPath'] = $this->componentPath; + } + + $propValues = $this->extractPropValues(array_keys($props)); + + if ($propValues !== []) { + $result['propValues'] = $propValues; + } + + return $result; + } +} diff --git a/src/inertia/src/DevTools/Data/IncomingEntry.php b/src/inertia/src/DevTools/Data/IncomingEntry.php new file mode 100644 index 0000000000..f4050364d2 --- /dev/null +++ b/src/inertia/src/DevTools/Data/IncomingEntry.php @@ -0,0 +1,96 @@ + */ + public array $http = ['requestHeaders' => [], 'responseHeaders' => [], 'requestBody' => null, 'responseBody' => null]; + + /** @var array */ + public array $props = []; + + /** @var array */ + public array $propValues = []; + + /** @var array{name: ?string, uri: string, action: ?string, actionSource?: array{file: string, line: int}} */ + public array $route = ['name' => null, 'uri' => '', 'action' => null]; + + /** @var null|array{file: string, line: int} */ + public ?array $renderSource = null; + + public ?string $componentPath = null; + + /** + * Create a new incoming entry instance. + */ + public function __construct(?string $id = null) + { + $this->id = $id ?? (string) Str::ulid(); + $this->utime = microtime(true); + $this->timestamp = CarbonImmutable::createFromTimestampMs((int) ($this->utime * 1000), 'UTC')->format('Y-m-d\TH:i:s.v\Z'); + } + + /** + * Get the entry as a storable array. + * + * @return array + */ + public function toArray(): array + { + return [ + '__meta' => [ + 'id' => $this->id, + 'tabUuid' => $this->tabUuid, + 'batchId' => $this->batchId, + 'timestamp' => $this->timestamp, + 'utime' => $this->utime, + 'method' => $this->method, + 'url' => $this->url, + 'component' => $this->component, + 'requestType' => $this->requestType->value, + 'status' => $this->status, + 'redirectLocation' => $this->redirectLocation, + 'serverTimingMs' => $this->serverTimingMs, + 'visitId' => $this->visitId, + ], + 'http' => $this->http, + 'props' => $this->props, + 'propValues' => $this->propValues, + 'route' => $this->route, + 'renderSource' => $this->renderSource, + 'componentPath' => $this->componentPath, + ]; + } +} diff --git a/src/inertia/src/DevTools/Data/PropType.php b/src/inertia/src/DevTools/Data/PropType.php new file mode 100644 index 0000000000..b25fa5e925 --- /dev/null +++ b/src/inertia/src/DevTools/Data/PropType.php @@ -0,0 +1,15 @@ + + */ + public const array DEFAULT_EXCEPT = ['telescope*', 'horizon*', '_inertia/devtools*']; + + /** + * The prop and body keys redacted when the configuration omits them. + * + * @var array + */ + public const array DEFAULT_REDACT_KEYS = [ + 'password', + 'password_confirmation', + 'current_password', + 'token', + '_token', + 'access_token', + 'refresh_token', + 'secret', + 'client_secret', + 'api_key', + ]; + + /** + * The headers redacted when the configuration omits them. + * + * @var array + */ + public const array DEFAULT_REDACT_HEADERS = [ + 'cookie', + 'set-cookie', + 'authorization', + 'proxy-authorization', + 'x-xsrf-token', + 'x-csrf-token', + ]; + + /** + * Determine if DevTools recording is enabled. + */ + public static function enabled(): bool + { + $configured = config('inertia.devtools.enabled'); + + if ($configured === null) { + return app()->environment('local'); + } + + return (bool) $configured; + } + + /** + * The recorder to report the request lifecycle to, or null when nothing should be + * recorded. Pass the request wherever one is in hand so excluded paths skip the work. + */ + public static function recorder(?Request $request = null): ?RequestRecorder + { + return static::enabledForRequest($request) ? app(RequestRecorder::class) : null; + } + + /** + * Whether the given request should be recorded, defaulting to the current one so callers + * without a request in scope do not have to resolve it themselves. + */ + public static function enabledForRequest(?Request $request = null): bool + { + if (! static::enabled()) { + return false; + } + + // Read without the typed config helper: a misconfigured value would throw, and this + // runs inside the app's own request, where recording must never be the thing that + // breaks the response. + $patterns = array_values(array_filter(Arr::wrap(config('inertia.devtools.except', self::DEFAULT_EXCEPT)), 'is_string')); + + return $patterns === [] || ! ($request ?? request())->is(...$patterns); + } +} diff --git a/src/inertia/src/DevTools/DevToolsHeader.php b/src/inertia/src/DevTools/DevToolsHeader.php new file mode 100644 index 0000000000..0c3b4e9835 --- /dev/null +++ b/src/inertia/src/DevTools/DevToolsHeader.php @@ -0,0 +1,69 @@ +header($header); + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/inertia/src/DevTools/DevToolsServiceProvider.php b/src/inertia/src/DevTools/DevToolsServiceProvider.php new file mode 100644 index 0000000000..b5c0f70c6e --- /dev/null +++ b/src/inertia/src/DevTools/DevToolsServiceProvider.php @@ -0,0 +1,78 @@ +app->scoped(EntryStore::class, fn (): EntryStore => new EntryStore); + + $this->app->scoped(SourceLocator::class, fn (): SourceLocator => new SourceLocator); + + // Scoped rather than auto-singleton: the builder holds the request's source locator. + $this->app->scoped(IncomingEntryBuilder::class); + + $this->app->singleton(EntriesRepository::class, function (): EntriesRepository { + return new EntriesRepository( + path: config()->string('inertia.devtools.storage.path', storage_path('inertia-devtools')), + autoPruneHours: config()->integer('inertia.devtools.storage.ttl', 24), + ); + }); + + // Scoped: the recorder holds per-request collection state across the lifecycle + // callbacks. It self-disables (every method no-ops) when devtools is off. + $this->app->scoped(RequestRecorder::class, fn (): RequestRecorder => new RequestRecorder); + } + + /** + * Boot the service provider. + */ + public function boot(): void + { + if (! DevTools::enabled()) { + return; + } + + // Only requests are recorded, so the entry is flushed once the request has been handled, + // before the response is sent, so the extension can fetch it when the headers arrive. + $this->app->make('events')->listen(RequestHandled::class, function (): void { + $this->app->make(EntryStore::class)->flush($this->app->make(EntriesRepository::class)); + }); + + // The extension fetches entries while the app's own requests are in flight, so the entry + // routes read the session without saving it. Saving would overwrite session data those + // requests save, age the flash data a redirect is about to read, and record the entry + // URL as the app's previous URL. This replaces upstream's PreserveFlashData and + // PreventPreviousUrlTracking middleware, which covered only the last two. + Route::middleware([...$this->routeMiddleware(), Authorize::class]) + ->prefix('_inertia/devtools') + ->group(function (): void { + Route::get('entries', [EntriesController::class, 'index'])->readOnlySession(); + Route::get('entries/{id}', [EntriesController::class, 'show'])->readOnlySession(); + }); + } + + /** + * The middleware the entry endpoints run before they are authorized. Defaults to the + * `web` group so the gate may authorize the user from the session it starts. + * + * @return array + */ + protected function routeMiddleware(): array + { + return Arr::wrap($this->app->make('config')->get('inertia.devtools.middleware', ['web'])); + } +} diff --git a/src/inertia/src/DevTools/EntriesRepository.php b/src/inertia/src/DevTools/EntriesRepository.php new file mode 100644 index 0000000000..395446d6ae --- /dev/null +++ b/src/inertia/src/DevTools/EntriesRepository.php @@ -0,0 +1,436 @@ + $data + */ + public function save(string $id, array $data): void + { + if (! $this->isValidEntryId($id)) { + throw new InvalidArgumentException('Invalid Inertia DevTools entry id.'); + } + + $encoded = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $meta = $this->normalizeIndexMeta($data['__meta'] ?? []); + + // The entry file is written under the index lock, so a save that cannot open or lock the + // index leaves no file behind that the index would never list or prune. + $this->mutateIndex(function (array $index) use ($id, $encoded, $meta): array { + // Filesystem::replace writes to a temp file and renames it into place, so an interrupted + // write never leaves a half-written entry: readers see the previous file or the new one. + $this->files->replace($this->filePath($id), $encoded); + + $index[$id] = $meta; + + return $index; + }); + } + + /** + * Get the payload of the given entry. + * + * @return null|array + */ + public function get(string $id): ?array + { + if (! $this->isValidEntryId($id)) { + return null; + } + + $file = $this->filePath($id); + + if (! $this->files->exists($file)) { + return null; + } + + $decoded = $this->readJsonFile($file); + + return is_array($decoded) ? $decoded : null; + } + + /** + * All recorded entry metadata, newest first. + * + * @return array> + */ + public function all(): array + { + return collect($this->readIndex()) + ->sortByDesc(fn (array $meta): string => (string) ($meta['id'] ?? '')) + ->values() + ->all(); + } + + /** + * Delete the entries recorded more than the given number of hours ago. + */ + public function prune(int $hours): void + { + if (! $this->files->isDirectory($this->path)) { + return; + } + + $cutoff = microtime(true) - ($hours * 3600); + + $expired = collect($this->readIndex()) + ->filter(fn (array $meta): bool => ($meta['utime'] ?? 0) < $cutoff) + ->keys() + ->all(); + + $this->deleteEntries($expired); + } + + /** + * Delete all but the given number of newest entries for the tab. Entries recorded without + * a tab, such as initial page loads and requests made without the extension, are limited + * as one group. + */ + public function enforceTabLimit(?string $tabUuid, int $limit): void + { + if ($limit <= 0 || ! $this->files->isDirectory($this->path)) { + return; + } + + // Keep the newest $limit entries for the tab; delete everything older. + $drop = collect($this->readIndex()) + ->where('tabUuid', $tabUuid) + ->sortByDesc('id') + ->slice($limit) + ->keys() + ->all(); + + $this->deleteEntries($drop); + } + + /** + * Prune expired entries when the prune interval has elapsed. + */ + public function pruneIfDue(): void + { + $intervalSeconds = config()->integer('inertia.devtools.storage.prune_interval', 300); + + if ($intervalSeconds <= 0) { + $this->prune($this->autoPruneHours); + + return; + } + + $this->ensureDirectory(); + + $lastPrunedAt = $this->readLastPrunedAt(); + + if ($lastPrunedAt !== null && (time() - $lastPrunedAt) < $intervalSeconds) { + return; + } + + $this->prune($this->autoPruneHours); + $this->writeLastPrunedAt(time()); + } + + /** + * Ensure the storage directory and its .gitignore exist. + */ + protected function ensureDirectory(): void + { + $this->files->ensureDirectoryExists($this->path, 0700); + + $gitignore = $this->path . DIRECTORY_SEPARATOR . '.gitignore'; + + if ($this->files->missing($gitignore)) { + $this->files->put($gitignore, "*\n"); + } + } + + /** + * Get the file path of the given entry. + */ + protected function filePath(string $id): string + { + return $this->path . DIRECTORY_SEPARATOR . $id . '.json'; + } + + /** + * Determine if the given entry id is valid. + */ + protected function isValidEntryId(string $id): bool + { + return Str::isUlid($id); + } + + /** + * Get the path of the index file. + */ + protected function indexPath(): string + { + return $this->path . DIRECTORY_SEPARATOR . self::INDEX_FILE; + } + + /** + * Get the path of the file recording the last prune time. + */ + protected function lastPrunePath(): string + { + return $this->path . DIRECTORY_SEPARATOR . self::LAST_PRUNE_FILE; + } + + /** + * Normalize an entry's __meta for storage in the index. The full meta is kept so + * the list endpoint may filter and render without reading every entry file; the + * fields the storage layer relies on are coerced to known types. + * + * @param array $meta + * @return array + */ + protected function normalizeIndexMeta(array $meta): array + { + return array_merge($meta, [ + 'id' => (string) ($meta['id'] ?? ''), + 'tabUuid' => isset($meta['tabUuid']) && is_string($meta['tabUuid']) && $meta['tabUuid'] !== '' ? $meta['tabUuid'] : null, + 'utime' => isset($meta['utime']) ? (float) $meta['utime'] : microtime(true), + ]); + } + + /** + * Read the entry index, rebuilding it when it is missing or corrupt. + * + * @return array> + */ + protected function readIndex(): array + { + // A missing or corrupt index (e.g. a write interrupted mid-rewrite) must not lose the + // entries: the per-entry files are the source of truth, so rebuild the index from them. + if ($this->files->missing($this->indexPath())) { + return $this->rebuildIndexFromFiles(); + } + + $decoded = $this->readJsonFile($this->indexPath()); + + if (! is_array($decoded)) { + return $this->rebuildIndexFromFiles(); + } + + return collect($decoded) + ->filter(fn (mixed $meta): bool => is_array($meta)) + ->map(fn (array $meta): array => $this->normalizeIndexMeta($meta)) + ->all(); + } + + /** + * Rebuild the index from the per-entry files. + * + * @return array> + */ + protected function rebuildIndexFromFiles(): array + { + $index = []; + + // Rebuild under the index lock, where mutateIndex() reseeds a missing or corrupt index + // from the entry files, so recovery cannot overwrite an entry saved since the read. + $this->mutateIndex(function (array $current) use (&$index): array { + return $index = $current; + }); + + return $index; + } + + /** + * Scan the per-entry files and build the index map straight from their `__meta`. + * + * @return array> + */ + protected function metaFromFiles(): array + { + return collect($this->jsonFiles()) + ->map(fn (string $file): ?array => $this->readMeta($file)) + ->filter(fn (?array $meta): bool => $meta !== null && (string) ($meta['id'] ?? '') !== '') + ->keyBy(fn (array $meta): string => (string) $meta['id']) + ->map(fn (array $meta): array => $this->normalizeIndexMeta($meta)) + ->all(); + } + + /** + * Apply the given change to the index while holding its exclusive lock. + * + * @param callable(array>): array> $mutator + */ + protected function mutateIndex(callable $mutator): void + { + $this->ensureDirectory(); + + // An index that cannot be updated would silently drop every entry saved since, so the + // failure reaches the entry store's breaker and log. + $handle = @fopen($this->indexPath(), 'c+'); + + if ($handle === false) { + throw new RuntimeException("Unable to open the Inertia DevTools index [{$this->indexPath()}]."); + } + + try { + if (! flock($handle, LOCK_EX)) { + throw new RuntimeException("Unable to lock the Inertia DevTools index [{$this->indexPath()}]."); + } + + $contents = stream_get_contents($handle); + $decoded = is_string($contents) && $contents !== '' ? json_decode($contents, true) : null; + // A missing, empty or corrupt on-disk index would otherwise be treated as empty, so this + // rewrite would drop every prior entry's meta. Reseed from the entry files before applying + // the change. + $index = is_array($decoded) ? $decoded : $this->metaFromFiles(); + $index = $mutator($index); + + rewind($handle); + ftruncate($handle, 0); + fwrite($handle, (string) json_encode($index, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + fflush($handle); + flock($handle, LOCK_UN); + } finally { + fclose($handle); + } + } + + /** + * Delete entry files and remove them from the index in a single rewrite. + * + * @param array $ids + */ + protected function deleteEntries(array $ids): void + { + if ($ids === []) { + return; + } + + foreach ($ids as $id) { + if ($this->isValidEntryId($id)) { + $this->files->delete($this->filePath($id)); + } + } + + $this->mutateIndex(function (array $index) use ($ids): array { + foreach ($ids as $id) { + unset($index[$id]); + } + + return $index; + }); + } + + /** + * Read the metadata from the given entry file. + * + * @return null|array + */ + protected function readMeta(string $file): ?array + { + $decoded = $this->readJsonFile($file); + + if (! is_array($decoded) || ! isset($decoded['__meta']) || ! is_array($decoded['__meta'])) { + return null; + } + + return $decoded['__meta']; + } + + /** + * Read the time of the last prune. + */ + protected function readLastPrunedAt(): ?int + { + $contents = $this->readLockedTextFile($this->lastPrunePath()); + + if ($contents === null || ! ctype_digit(trim($contents))) { + return null; + } + + return (int) trim($contents); + } + + /** + * Record the time of the last prune. + */ + protected function writeLastPrunedAt(int $timestamp): void + { + $this->files->put($this->lastPrunePath(), (string) $timestamp, lock: true); + } + + /** + * Read and decode the given JSON file. + * + * @return null|array + */ + protected function readJsonFile(string $file): ?array + { + $contents = $this->readLockedTextFile($file); + + if ($contents === null) { + return null; + } + + $decoded = json_decode($contents, true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * Read the given file under a shared lock. + */ + protected function readLockedTextFile(string $file): ?string + { + if ($this->files->missing($file)) { + return null; + } + + try { + // Filesystem::get with a lock takes a shared (LOCK_SH) read lock. + return $this->files->get($file, lock: true); + } catch (Throwable) { + return null; + } + } + + /** + * Get the paths of the per-entry JSON files. + * + * @return iterable + */ + protected function jsonFiles(): iterable + { + if (! $this->files->isDirectory($this->path)) { + return; + } + + foreach ($this->files->files($this->path) as $file) { + if ($file->getExtension() !== 'json' || $file->getFilename() === self::INDEX_FILE) { + continue; + } + + yield $file->getPathname(); + } + } +} diff --git a/src/inertia/src/DevTools/EntryStore.php b/src/inertia/src/DevTools/EntryStore.php new file mode 100644 index 0000000000..0653c09a6e --- /dev/null +++ b/src/inertia/src/DevTools/EntryStore.php @@ -0,0 +1,114 @@ +pending = $entry; + } + + /** + * Get the pending entry. + */ + public function current(): ?IncomingEntry + { + return $this->pending; + } + + /** + * Discard the pending entry. + */ + public function reset(): void + { + $this->pending = null; + } + + /** + * Persist the pending entry and prune expired entries. + */ + public function flush(EntriesRepository $repo): void + { + $entry = $this->pending; + + if ($entry === null) { + return; + } + + $this->pending = null; + + if (static::$suppressedUntil !== null && microtime(true) < static::$suppressedUntil) { + return; + } + + try { + $repo->save($entry->id, $this->redactSensitiveStoragePayload($entry->toArray())); + + $limit = config()->integer('inertia.devtools.storage.limit', 100); + + if ($limit > 0) { + $repo->enforceTabLimit($entry->tabUuid, $limit); + } + + // Pruning shares the storage directory, so a storage failure here must trip the + // same breaker rather than break the response. + $repo->pruneIfDue(); + + static::$suppressedUntil = null; + } catch (Throwable $e) { + $firstFailure = static::$suppressedUntil === null; + + static::$suppressedUntil = microtime(true) + self::SUPPRESS_SECONDS; + + if ($firstFailure) { + try { + Log::warning('Inertia DevTools: failed to persist entry: ' . $e->getMessage()); + } catch (Throwable) { + // The log often shares the failing storage, such as a full disk, and + // recording must still never break the response. + } + } + } + } + + /** + * Reset the circuit breaker so recording resumes immediately. + * + * Tests only. The breaker is shared by every request in the worker, so a reset during a + * request resumes writes for all of them while storage may still be failing. + */ + public static function resetCircuitBreaker(): void + { + static::$suppressedUntil = null; + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::resetCircuitBreaker(); + } +} diff --git a/src/inertia/src/DevTools/Http/Authorize.php b/src/inertia/src/DevTools/Http/Authorize.php new file mode 100644 index 0000000000..85953c90f9 --- /dev/null +++ b/src/inertia/src/DevTools/Http/Authorize.php @@ -0,0 +1,41 @@ +allows($request)) { + return $next($request); + } + + return response()->json(['message' => 'Forbidden.'], 403); + } + + /** + * The local environment is always allowed, since a failing gate would lock a developer + * out of their own devtools. Everywhere else access is granted only by the configured + * gate, which decides for the authenticated user. + */ + protected function allows(Request $request): bool + { + if (app()->environment('local')) { + return true; + } + + $gate = config('inertia.devtools.gate'); + + return is_string($gate) && $gate !== '' && Gate::forUser($request->user())->check($gate); + } +} diff --git a/src/inertia/src/DevTools/Http/EntriesController.php b/src/inertia/src/DevTools/Http/EntriesController.php new file mode 100644 index 0000000000..a5c05cae9a --- /dev/null +++ b/src/inertia/src/DevTools/Http/EntriesController.php @@ -0,0 +1,70 @@ +> + */ + public function index(Request $request): Collection + { + $component = $request->query('component'); + $include = $this->typeList($request->query('type')); + $exclude = $this->typeList($request->query('exclude')); + $offset = max(0, (int) $request->query('offset', '0')); + $limit = $request->query('limit'); + + return collect($this->repository->all()) + ->when(is_string($component) && $component !== '', fn (Collection $entries): Collection => $entries->where('component', $component)) + ->when($include !== [], fn (Collection $entries): Collection => $entries->whereIn('requestType', $include)) + ->when($exclude !== [], fn (Collection $entries): Collection => $entries->whereNotIn('requestType', $exclude)) + ->when($offset > 0, fn (Collection $entries): Collection => $entries->slice($offset)) + ->when(is_numeric($limit), fn (Collection $entries): Collection => $entries->take(max(1, (int) $limit))) + ->values(); + } + + /** + * Show the given recorded entry. + * + * @return array + */ + public function show(string $id): array + { + if (! Str::isUlid($id)) { + abort(404, 'Not found.'); + } + + return $this->repository->get($id) ?? abort(404, 'Not found.'); + } + + /** + * Parse a comma-separated request-type query value into a list. + * + * @return array + */ + protected function typeList(mixed $value): array + { + if (! is_string($value) || $value === '') { + return []; + } + + return array_values(array_filter(array_map('trim', explode(',', $value)))); + } +} diff --git a/src/inertia/src/DevTools/IncomingEntryBuilder.php b/src/inertia/src/DevTools/IncomingEntryBuilder.php new file mode 100644 index 0000000000..6d9647afc8 --- /dev/null +++ b/src/inertia/src/DevTools/IncomingEntryBuilder.php @@ -0,0 +1,587 @@ +tabUuid = DevToolsHeader::read($request, DevToolsHeader::DEVTOOLS_TAB); + $entry->batchId = $batchId; + $entry->visitId = DevToolsHeader::read($request, DevToolsHeader::DEVTOOLS_VISIT); + $entry->method = $request->getMethod(); + $entry->url = $request->fullUrl(); + $entry->status = $response->getStatusCode(); + $entry->requestType = $this->resolveRequestType($request, $response, $isPrefetch); + $entry->redirectLocation = $this->resolveRedirectLocation($response); + $entry->serverTimingMs = $this->elapsedMs($request); + + $entry->http = [ + 'requestHeaders' => $this->redactHeaders($request->headers->all()), + 'responseHeaders' => $this->redactHeaders($response->headers->all()), + 'requestBody' => $this->captureRequestBody($request), + 'responseBody' => $this->captureResponseBody($request, $response), + ]; + + $this->mergeCollectorPayload($entry, $request); + + if ($this->routeIsEmpty($entry->route)) { + $entry->route = $this->routeFromRequest($request) ?? $entry->route; + } + + if ($entry->renderSource === null) { + $entry->renderSource = $this->renderSourceFromRoute($request); + } + + return $entry; + } + + /** + * Merge the collector payload recorded while rendering into the entry. + */ + protected function mergeCollectorPayload(IncomingEntry $entry, Request $request): void + { + $payload = $request->attributes->get(RequestAttribute::PAYLOAD); + + if (! is_array($payload)) { + return; + } + + $entry->component = $this->stringValue($payload, 'component') ?? $entry->component; + $entry->props = $this->arrayValue($payload, 'props') ?? $entry->props; + $entry->componentPath = $this->stringValue($payload, 'componentPath') ?? $entry->componentPath; + + $propValues = $this->arrayValue($payload, 'propValues'); + + if ($propValues !== null) { + $entry->propValues = $this->sanitizeForJson($this->redactPropValues($propValues)); + } + + $entry->route = $this->routePayload($payload) ?? $entry->route; + $entry->renderSource = $this->renderSourcePayload($payload) ?? $entry->renderSource; + } + + /** + * Redact the recorded prop values. + * + * Nested props are recorded under their dotted path, so a value is redacted when any + * segment of its path is a sensitive key. + * + * @param array $propValues + * @return array + */ + protected function redactPropValues(array $propValues): array + { + $keys = $this->normalizeSensitiveKeys($this->redactKeys()); + $redacted = $this->redact($propValues, $keys); + + foreach (array_keys($redacted) as $path) { + if (array_intersect(explode('.', strtolower((string) $path)), $keys) !== []) { + $redacted[$path] = self::REDACTED; + } + } + + return $redacted; + } + + /** + * Get the given payload value when it is a string. + * + * @param array $payload + */ + protected function stringValue(array $payload, string $key): ?string + { + $value = $payload[$key] ?? null; + + return is_string($value) ? $value : null; + } + + /** + * Get the given payload value when it is an array. + * + * @param array $payload + * @return null|array + */ + protected function arrayValue(array $payload, string $key): ?array + { + $value = $payload[$key] ?? null; + + return is_array($value) ? $value : null; + } + + /** + * Get the normalized route from the collector payload. + * + * @param array $payload + * @return null|array{name: ?string, uri: string, action: ?string, actionSource?: array{file: string, line: int}} + */ + protected function routePayload(array $payload): ?array + { + $route = $this->arrayValue($payload, 'route'); + + if ($route === null) { + return null; + } + + $normalized = [ + 'name' => $route['name'] ?? null, + 'uri' => (string) ($route['uri'] ?? ''), + 'action' => $route['action'] ?? null, + ]; + + $actionSource = $this->arrayValue($route, 'actionSource'); + + if ($actionSource !== null) { + $normalized['actionSource'] = [ + 'file' => (string) ($actionSource['file'] ?? ''), + 'line' => (int) ($actionSource['line'] ?? 0), + ]; + } + + return $normalized; + } + + /** + * Get the normalized render source from the collector payload. + * + * @param array $payload + * @return null|array{file: string, line: int} + */ + protected function renderSourcePayload(array $payload): ?array + { + $renderSource = $this->arrayValue($payload, 'renderSource'); + + if ($renderSource === null) { + return null; + } + + return [ + 'file' => (string) ($renderSource['file'] ?? ''), + 'line' => (int) ($renderSource['line'] ?? 0), + ]; + } + + /** + * Resolve the kind of request the entry records. + */ + protected function resolveRequestType(Request $request, SymfonyResponse $response, ?bool $isPrefetch = null): RequestType + { + $isPrefetch ??= $request->prefetch(); + + if ($request->header(Header::PRECOGNITION)) { + return RequestType::Precognition; + } + + if (! $request->header(Header::INERTIA)) { + return $this->renderedInertiaPage($request) ? RequestType::Initial : RequestType::Http; + } + + if ($request->header(DevToolsHeader::DEVTOOLS_DEFERRED)) { + return RequestType::Deferred; + } + + if ($request->header(DevToolsHeader::DEVTOOLS_POLL)) { + return RequestType::Poll; + } + + if ($request->header(Header::PARTIAL_COMPONENT)) { + return RequestType::Partial; + } + + if ($isPrefetch) { + return RequestType::Prefetch; + } + + return RequestType::Navigate; + } + + /** + * A non-Inertia request that rendered an Inertia page (component present in the + * collector payload) is the app's initial page load. One that rendered no Inertia + * page is a plain HTTP request the app serves alongside Inertia. + */ + protected function renderedInertiaPage(Request $request): bool + { + $payload = $request->attributes->get(RequestAttribute::PAYLOAD); + + return is_array($payload) + && isset($payload['component']) + && is_string($payload['component']) + && $payload['component'] !== ''; + } + + /** + * Resolve the location the response redirects to. + */ + protected function resolveRedirectLocation(SymfonyResponse $response): ?string + { + $inertiaLocation = $response->headers->get(Header::LOCATION); + + if (is_string($inertiaLocation) && $inertiaLocation !== '') { + return $inertiaLocation; + } + + $status = $response->getStatusCode(); + + if ($status < 300 || $status >= 400) { + return null; + } + + $location = $response->headers->get('Location'); + + return is_string($location) && $location !== '' ? $location : null; + } + + /** + * Capture the request body. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + protected function captureRequestBody(Request $request): array + { + $writeMethod = in_array($request->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true); + + if ($writeMethod && ! $request->header(Header::INERTIA)) { + return ['status' => 'omitted', 'reason' => 'non-inertia-request']; + } + + $redactKeys = $this->redactKeys(); + + if ($request->isJson()) { + $body = (array) $request->json()->all(); + + return $body === [] ? ['status' => 'empty'] : $this->captureBodyValue($this->redact($body, $redactKeys)); + } + + $input = $request->all(); + + if ($input !== []) { + return $this->captureBodyValue($this->redact($this->summarizeUploads($input), $redactKeys)); + } + + return $this->captureBodyString($request->getContent() ?: null); + } + + /** + * Capture the response body. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + protected function captureResponseBody(Request $request, SymfonyResponse $response): array + { + $payload = $request->attributes->get(RequestAttribute::PAYLOAD); + + if (is_array($payload) && array_key_exists('responseBody', $payload)) { + return $this->captureInertiaResponseBody($payload['responseBody']); + } + + return $this->captureRawResponseBody($response); + } + + /** + * Capture the page object of an Inertia response. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + protected function captureInertiaResponseBody(mixed $responseBody): array + { + if (is_string($responseBody)) { + return $this->captureBodyString($responseBody); + } + + if (is_array($responseBody)) { + return $this->captureBodyValue($this->redact($this->normalizeResponseBody($responseBody), $this->redactKeys())); + } + + if ($responseBody === null) { + return ['status' => 'empty']; + } + + return $this->captureBodyValue($responseBody); + } + + /** + * Cast the page object to the JSON the client received. Resolved props still hold live + * values here (a model, a date, the always-shared `errors` object), and the storage + * pass would replace those object leaves with a marker. + * + * @param array $responseBody + * @return array + */ + protected function normalizeResponseBody(array $responseBody): array + { + $encoded = json_encode($responseBody); + + if (! is_string($encoded)) { + return $responseBody; + } + + $decoded = json_decode($encoded, true); + + return is_array($decoded) ? $decoded : $responseBody; + } + + /** + * Capture the body of a non-Inertia response (plain JSON/text endpoints the app + * serves alongside Inertia). Binary, streamed, and oversized bodies are omitted. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + protected function captureRawResponseBody(SymfonyResponse $response): array + { + $contentType = strtolower((string) $response->headers->get('Content-Type', '')); + + if (! $this->isTextualContentType($contentType)) { + return ['status' => 'omitted', 'reason' => 'non-textual']; + } + + $content = $response->getContent(); + + if ($content === false) { + return ['status' => 'omitted', 'reason' => 'streamed']; + } + + if ($content === '') { + return ['status' => 'empty']; + } + + if (strlen($content) > self::RAW_BODY_LIMIT) { + return ['status' => 'omitted', 'reason' => 'too-large']; + } + + if (str_contains($contentType, 'json')) { + $decoded = json_decode($content, true); + + if (is_array($decoded)) { + return $this->captureBodyValue($this->redact($decoded, $this->redactKeys())); + } + } + + return $this->captureBodyString($content); + } + + /** + * Determine if the content type is textual. + */ + protected function isTextualContentType(string $contentType): bool + { + foreach (['json', 'text/', 'xml', 'javascript'] as $needle) { + if (str_contains($contentType, $needle)) { + return true; + } + } + + return false; + } + + /** + * Capture the given body value when it can be encoded. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + protected function captureBodyValue(mixed $value): array + { + if (! $this->isEncodable($value)) { + return ['status' => 'omitted', 'reason' => 'unserializable']; + } + + return ['status' => 'present', 'value' => $value]; + } + + /** + * Confirm a value survives JSON encoding with the flags the repository persists it + * with. An un-encodable value would otherwise throw at save and drop the entry. + */ + protected function isEncodable(mixed $value): bool + { + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !== false; + } + + /** + * Replace individual leaf values that cannot be JSON encoded with a marker, keeping + * the surrounding structure intact. Prop values are shared verbatim by the app, so a + * single non-UTF-8 attribute must not fail the whole entry at save. + * + * @param array $data + * @return array + */ + protected function sanitizeForJson(array $data): array + { + return collect($data) + ->map(function (mixed $value): mixed { + if (is_array($value)) { + return $this->sanitizeForJson($value); + } + + return $this->isEncodable($value) ? $value : '[UNSERIALIZABLE]'; + }) + ->all(); + } + + /** + * Capture the given body string when it is valid UTF-8. + * + * @return array{status: string, value?: string, reason?: string} + */ + protected function captureBodyString(?string $body): array + { + if ($body === null || $body === '') { + return ['status' => 'empty']; + } + + if (! mb_check_encoding($body, 'UTF-8')) { + return ['status' => 'omitted', 'reason' => 'binary']; + } + + return ['status' => 'present', 'value' => $body]; + } + + /** + * Replace uploaded files with a lightweight summary so binary contents are never + * serialized into an entry. + * + * @param array $input + * @return array + */ + protected function summarizeUploads(array $input): array + { + return collect($input) + ->map(function (mixed $value): mixed { + if ($value instanceof UploadedFile) { + return $this->summarizeUpload($value); + } + + return is_array($value) ? $this->summarizeUploads($value) : $value; + }) + ->all(); + } + + /** + * Summarize the given uploaded file. + * + * @return array{name: ?string, size: null|false|int, mimeType: ?string} + */ + protected function summarizeUpload(UploadedFile $file): array + { + return [ + 'name' => $file->getClientOriginalName(), + 'size' => $file->isValid() ? $file->getSize() : null, + 'mimeType' => $file->getClientMimeType(), + ]; + } + + /** + * Resolve the render source for route-defined renders (Route::inertia), which have + * no user-space render call site. The definition location is captured onto the route + * defaults by the `Route::inertia()` macro when the route is registered. + * + * @return null|array{file: string, line: int} + */ + protected function renderSourceFromRoute(Request $request): ?array + { + $source = $request->route()?->defaults[DevTools::RENDER_SOURCE_KEY] ?? null; + + if (! is_array($source) || ! isset($source['file'], $source['line'])) { + return null; + } + + return ['file' => (string) $source['file'], 'line' => (int) $source['line']]; + } + + /** + * Determine if the route carries no information. + * + * @param array{name: ?string, uri: string, action: ?string, actionSource?: array{file: string, line: int}} $route + */ + protected function routeIsEmpty(array $route): bool + { + return ($route['name'] ?? null) === null + && $route['uri'] === '' + && ($route['action'] ?? null) === null + && ! array_key_exists('actionSource', $route); + } + + /** + * Get the normalized route from the request. + * + * @return null|array{name: ?string, uri: string, action: ?string, actionSource?: array{file: string, line: int}} + */ + protected function routeFromRequest(Request $request): ?array + { + $route = $request->route(); + + if ($route === null) { + return null; + } + + $normalized = [ + 'name' => $route->getName(), + 'uri' => '/' . ltrim($route->uri(), '/'), + 'action' => $route->getActionName(), + ]; + + $actionSource = $this->sourceLocator->resolveActionSource( + $route->getActionName(), + $route->getAction('uses') + ); + + if ($actionSource !== null) { + $normalized['actionSource'] = $actionSource; + } + + return $normalized; + } + + /** + * Get the milliseconds elapsed since the request started. + */ + protected function elapsedMs(Request $request): float + { + $start = $request->attributes->get(RequestAttribute::START); + + if (! is_int($start)) { + return 0.0; + } + + return (hrtime(true) - $start) / 1_000_000; + } + + /** + * Get the keys whose values are redacted. + * + * @return array + */ + protected function redactKeys(): array + { + return config()->array('inertia.devtools.redact.keys', DevTools::DEFAULT_REDACT_KEYS); + } +} diff --git a/src/inertia/src/DevTools/PropClassifier.php b/src/inertia/src/DevTools/PropClassifier.php new file mode 100644 index 0000000000..529273c2fd --- /dev/null +++ b/src/inertia/src/DevTools/PropClassifier.php @@ -0,0 +1,136 @@ +isDeferredRequest($request); + + return [ + 'inertiaType' => $this->classifyInertiaWrapper($prop, $isDeferredDelivery), + 'deferGroup' => $this->deferGroup($prop, $isDeferredDelivery), + 'reset' => in_array($path, $this->parseDevToolsHeader($request, Header::RESET), true), + 'once' => $prop instanceof Onceable && $prop->shouldResolveOnce(), + 'mergeDirection' => $this->mergeDirection($prop), + 'deepMerge' => $this->isDeepMerge($prop), + ]; + } + + /** + * Determine if the request loads deferred props. + */ + protected function isDeferredRequest(Request $request): bool + { + return (bool) $request->header(DevToolsHeader::DEVTOOLS_DEFERRED); + } + + /** + * The defer group applies to a genuinely deferred DeferProp, and to other deferrable props + * (e.g. ScrollProp) as before. A DeferProp reloaded outside a deferred request carries none. + */ + protected function deferGroup(mixed $prop, bool $isDeferredDelivery): ?string + { + if (! $prop instanceof Deferrable || ! $prop->shouldDefer()) { + return null; + } + + if ($prop instanceof DeferProp && ! $isDeferredDelivery) { + return null; + } + + return $prop->group(); + } + + /** + * A prop is a deep merge when it deep-merges nested data (`->deepMerge()`) or matches + * array items on a key (`->matchOn()`) to upsert them rather than blindly appending. + * Matching has no effect unless the prop merges. + */ + protected function isDeepMerge(mixed $prop): bool + { + return $prop instanceof Mergeable + && $prop->shouldMerge() + && ($prop->shouldDeepMerge() || count($prop->matchesOn()) > 0); + } + + /** + * Resolve how a merge/scroll prop combines with existing client data. Direction is read + * from the prop wrapper (not the page-object arrays) so it survives deep merges, which + * the page object records only under `deepMergeProps` without a direction. + */ + protected function mergeDirection(mixed $prop): ?string + { + if (! $prop instanceof Mergeable || ! $prop->shouldMerge()) { + return null; + } + + $prependsNested = count($prop->prependsAtPaths()) > 0; + $appendsNested = count($prop->appendsAtPaths()) > 0; + + if ($prop->prependsAtRoot() || ($prependsNested && ! $appendsNested)) { + return 'prepend'; + } + + return 'append'; + } + + /** + * Classify an Inertia prop wrapper instance into a stable token the extension + * renders as a type pill. + */ + protected function classifyInertiaWrapper(mixed $prop, bool $isDeferredDelivery): ?PropType + { + return match (true) { + $prop instanceof AlwaysProp => PropType::Always, + $prop instanceof DeferProp => $isDeferredDelivery ? PropType::Defer : null, + $prop instanceof OptionalProp => PropType::Optional, + $prop instanceof MergeProp => PropType::Merge, + $prop instanceof ScrollProp => PropType::Scroll, + $prop instanceof OnceProp => PropType::Once, + default => null, + }; + } + + /** + * Parse a comma-separated request header into a list. + * + * @return array + */ + protected function parseDevToolsHeader(Request $request, string $key): array + { + return array_filter( + explode(',', (string) $request->header($key, '')), + fn (string $value): bool => $value !== '', + ); + } +} diff --git a/src/inertia/src/DevTools/RedactsSensitiveData.php b/src/inertia/src/DevTools/RedactsSensitiveData.php new file mode 100644 index 0000000000..bbce904cf7 --- /dev/null +++ b/src/inertia/src/DevTools/RedactsSensitiveData.php @@ -0,0 +1,234 @@ + $data + * @param array $keys + * @return array + */ + protected function redact(array $data, array $keys): array + { + $lowered = $this->normalizeSensitiveKeys($keys); + + if ($lowered === []) { + return $data; + } + + return $this->redactRecursive($data, $lowered); + } + + /** + * Final storage pass for entry payloads. Earlier builders redact known request + * surfaces, but this keeps persisted entries private if a future collector path + * adds sensitive data before save. + * + * @param array $payload + * @return array + */ + protected function redactSensitiveStoragePayload(array $payload): array + { + $keys = config()->array('inertia.devtools.redact.keys', DevTools::DEFAULT_REDACT_KEYS); + + // Keys are redacted only within application values. A configured key such as id or name + // can also name part of the entry's own structure: its metadata, route and source + // details, the props map (each prop's metadata, keyed by prop path) and each captured + // body's status. The metadata's URLs are request data, so the URL pass below still covers them. + $payload = array_replace($payload, $this->redact( + Arr::except($payload, ['__meta', 'props', 'route', 'renderSource', 'componentPath', 'http']), + $keys, + )); + + foreach (['requestBody', 'responseBody'] as $body) { + if (is_array($payload['http'][$body]['value'] ?? null)) { + $payload['http'][$body]['value'] = $this->redact($payload['http'][$body]['value'], $keys); + } + } + + foreach (['requestHeaders', 'responseHeaders'] as $bag) { + if (is_array($payload['http'][$bag] ?? null)) { + $payload['http'][$bag] = $this->redactHeaders($this->redact($payload['http'][$bag], $keys)); + } + } + + return $this->sanitizeForJsonEncoding(array_replace($payload, $this->redactUrls(Arr::except($payload, 'props'), $keys))); + } + + /** + * Redact the values of the given lowercase keys at every depth. + * + * @param array $data + * @param array $loweredKeys + * @return array + */ + protected function redactRecursive(array $data, array $loweredKeys): array + { + return collect($data) + ->map(function (mixed $value, int|string $key) use ($loweredKeys): mixed { + if (is_string($key) && in_array(strtolower($key), $loweredKeys, true)) { + return self::REDACTED; + } + + return is_array($value) ? $this->redactRecursive($value, $loweredKeys) : $value; + }) + ->all(); + } + + /** + * Flatten the given headers, redacting the sensitive ones and the sensitive query + * parameters of URL headers. + * + * @param array $headers + * @return array + */ + protected function redactHeaders(array $headers): array + { + $sensitive = $this->normalizeSensitiveKeys(config()->array('inertia.devtools.redact.headers', DevTools::DEFAULT_REDACT_HEADERS)); + $keys = config()->array('inertia.devtools.redact.keys', DevTools::DEFAULT_REDACT_KEYS); + + return collect($headers) + ->map(function (mixed $value, int|string $name) use ($sensitive, $keys): string { + $header = strtolower((string) $name); + + if (in_array($header, $sensitive, true)) { + return self::REDACTED; + } + + $value = is_array($value) ? implode(', ', $value) : (string) $value; + + return in_array($header, self::URL_HEADERS, true) ? $this->redactUrl($value, $keys) : $value; + }) + ->all(); + } + + /** + * Normalize the configured sensitive keys to unique lowercase strings. + * + * @param array $keys + * @return array + */ + protected function normalizeSensitiveKeys(array $keys): array + { + $normalized = []; + + foreach ($keys as $key) { + if (! is_string($key) || $key === '') { + continue; + } + + $normalized[] = strtolower($key); + } + + return array_values(array_unique($normalized)); + } + + /** + * Redact sensitive query parameters from the URLs in the data. + * + * @param array $data + * @param array $keys + * @return array + */ + protected function redactUrls(array $data, array $keys): array + { + $lowered = $this->normalizeSensitiveKeys($keys); + + if ($lowered === []) { + return $data; + } + + return collect($data) + ->map(function (mixed $value, int|string $key) use ($lowered): mixed { + if (is_array($value)) { + return $this->redactUrls($value, $lowered); + } + + if (is_string($value) && is_string($key) && in_array(strtolower($key), ['url', 'redirectlocation'], true)) { + // A URL is request data, so one stored under a configured key is redacted whole. + return in_array(strtolower($key), $lowered, true) ? self::REDACTED : $this->redactUrl($value, $lowered); + } + + return $value; + }) + ->all(); + } + + /** + * Redact the values of sensitive query parameters. A parameter matches when its decoded + * name or any of its bracketed segments (e.g. `filter[secret]`) is a sensitive key. The + * query is not parsed and rebuilt, so every other byte of the URL is kept as recorded, + * and relative or malformed URLs are redacted the same way. + * + * @param array $keys + */ + protected function redactUrl(string $url, array $keys): string + { + $lowered = $this->normalizeSensitiveKeys($keys); + [$beforeFragment, $fragment] = array_pad(explode('#', $url, 2), 2, null); + + if ($lowered === [] || ! str_contains($beforeFragment, '?')) { + return $url; + } + + [$location, $query] = explode('?', $beforeFragment, 2); + + $parameters = array_map(function (string $parameter) use ($lowered): string { + [$name, $value] = array_pad(explode('=', $parameter, 2), 2, null); + $segments = preg_split('/[\[\]]+/', strtolower(urldecode($name)), -1, PREG_SPLIT_NO_EMPTY) ?: []; + + return $value !== null && array_intersect($segments, $lowered) !== [] + ? $name . '=' . rawurlencode(self::REDACTED) + : $parameter; + }, explode('&', $query)); + + return $location . '?' . implode('&', $parameters) . ($fragment === null ? '' : '#' . $fragment); + } + + /** + * Replace leaf values that cannot be stored as JSON with a marker. + * + * @param array $data + * @return array + */ + protected function sanitizeForJsonEncoding(array $data): array + { + return collect($data) + ->map(function (mixed $value): mixed { + if (is_array($value)) { + return $this->sanitizeForJsonEncoding($value); + } + + return $this->isJsonEncodable($value) ? $value : self::UNSERIALIZABLE; + }) + ->all(); + } + + /** + * Determine if the value can be stored as JSON. + */ + protected function isJsonEncodable(mixed $value): bool + { + if (is_object($value) || is_resource($value)) { + return false; + } + + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !== false; + } +} diff --git a/src/inertia/src/DevTools/RequestAttribute.php b/src/inertia/src/DevTools/RequestAttribute.php new file mode 100644 index 0000000000..a533b8d449 --- /dev/null +++ b/src/inertia/src/DevTools/RequestAttribute.php @@ -0,0 +1,25 @@ +attributes) during + * the devtools request lifecycle. Unlike DevToolsHeader, these never leave the process: + * they carry state between the recorder, the collector, and the entry builder. + */ +class RequestAttribute +{ + /** + * High-resolution start time (hrtime) stamped when the request begins, used to + * compute the entry's server timing. + */ + public const string START = 'inertia_devtools_start'; + + /** + * The collector payload (component, props, propValues, route, sources) captured + * during rendering and merged into the entry. + */ + public const string PAYLOAD = 'inertia_devtools_payload'; +} diff --git a/src/inertia/src/DevTools/RequestRecorder.php b/src/inertia/src/DevTools/RequestRecorder.php new file mode 100644 index 0000000000..5c699674a6 --- /dev/null +++ b/src/inertia/src/DevTools/RequestRecorder.php @@ -0,0 +1,385 @@ +attributes->set(RequestAttribute::START, hrtime(true)); + } + + /** + * Scan the share() method source to resolve per-key line numbers. + * + * @param array $shared + */ + public function sharedPropsResolved(object $middleware, array $shared): void + { + if (! DevTools::enabledForRequest()) { + return; + } + + $reflection = new ReflectionMethod($middleware, 'share'); + $locator = app(SourceLocator::class); + $fallback = $locator->shareSourceFallback($reflection); + + if ($fallback === null) { + return; + } + + foreach (array_keys($shared) as $key) { + $key = (string) $key; + + $this->setShareSource([$key], $locator->resolveShareSource($reflection, $key) ?? $fallback); + } + } + + /** + * Capture the call site of a share() call and associate it with the given prop keys. + * + * @param array $keys + */ + public function propsShared(array $keys): void + { + if (! DevTools::enabled()) { + return; + } + + $locator = app(SourceLocator::class); + $source = $locator->captureCallerSource(); + + if ($source === null) { + return; + } + + $state = InertiaState::current(); + + foreach ($keys as $key) { + $key = (string) $key; + + $state->shareSources[$key] = [ + 'file' => $source['file'], + 'line' => $locator->findPropKeyLine($source['file'], $source['line'], $key) ?? $source['line'], + ]; + } + } + + /** + * Start collecting the page being rendered. + */ + public function pageRendering(string $component, Response $response): void + { + if (! DevTools::enabled()) { + return; + } + + $locator = app(SourceLocator::class); + $renderSource = $locator->captureCallerSource(); + $collector = new Collector($component, $locator); + + if ($renderSource !== null) { + $collector->setRenderSource($renderSource['file'], $renderSource['line']); + } + + $collector->setShareSources(InertiaState::current()->shareSources); + + $this->collector = $collector; + } + + /** + * Mark which of the page's props are shared, once shared property providers have been + * expanded into the props they provide. + * + * @param array $shared + */ + public function sharedPropsExpanded(array $shared): void + { + $this->collector?->setSharedKeys($this->topLevelSharedKeys($shared)); + } + + /** + * Record a resolved prop. + */ + public function propResolved(string $path, mixed $prop): void + { + $this->recordProp($path, $prop); + } + + /** + * Record a deferred prop whose resolver threw but was rescued (`Inertia::defer(rescue: true)`). + * It is skipped from the response props before normal metadata collection, so it needs its + * own hook: capture the defer type/group and flag it rescued, with no resolved value. + */ + public function propRescued(string $path, mixed $prop): void + { + $this->recordProp($path, $prop, rescued: true); + } + + /** + * Classify the prop and add it to the collector. + */ + protected function recordProp(string $path, mixed $prop, bool $rescued = false): void + { + if ($this->collector === null) { + return; + } + + $meta = app(PropClassifier::class)->classifyResolved($path, $prop, app(Request::class)); + + $this->collector->addProp( + $path, + $meta['inertiaType'], + $meta['deferGroup'], + $meta['reset'], + $meta['once'], + $meta['mergeDirection'], + $meta['deepMerge'], + rescued: $rescued, + ); + } + + /** + * Store the collected payload for the rendered page on the request. + * + * @param array $page + * @param array $resolvedProps + */ + public function pageRendered(Request $request, array $page, array $resolvedProps): void + { + if ($this->collector === null) { + return; + } + + $collector = $this->collector; + + if ($route = $request->route()) { + $collector->setRoute( + $route->getName(), + '/' . ltrim($route->uri(), '/'), + $request->method(), + ); + + $collector->setRouteAction($route->getActionName(), $route->getAction('uses')); + } + + try { + $collector->setComponentPath(App::make('inertia.view-finder')->find($page['component'])); + } catch (Throwable) { + } + + $collector->setResolvedProps($resolvedProps); + + $payload = $collector->build(); + $payload['responseBody'] = $page; + + $request->attributes->set(RequestAttribute::PAYLOAD, $payload); + } + + /** + * Record the response sent for the request. + */ + public function respondedWith(Request $request, SymfonyResponse $response): void + { + if (! DevTools::enabledForRequest($request)) { + return; + } + + try { + $this->recordResponse($request, $response); + } catch (Throwable) { + // Recording is a passive observer: a malformed request, an unserializable + // prop, or a misconfigured redact list must never turn the user's response + // into a 500, so the failure is swallowed and the entry dropped. + } + } + + /** + * Stamp the DevTools headers on the response and record its entry. + */ + protected function recordResponse(Request $request, SymfonyResponse $response): void + { + // The middleware may replace a rendered page, as on a version change, so the page's + // payload only describes an Inertia request's response while it is still the page. + if ($request->header(Header::INERTIA) && ! $response->headers->has(Header::INERTIA)) { + $request->attributes->remove(RequestAttribute::PAYLOAD); + } + + $id = (string) Str::ulid(); + $isPrefetch = $request->prefetch(); + [$batchId, $parentOut] = $this->resolveLineage($request, $id); + + $basePath = $request->getBaseUrl(); + + $response->headers->set(DevToolsHeader::DEVTOOLS_ID, $id); + $response->headers->set(DevToolsHeader::DEVTOOLS_OUTGOING_PARENT, $parentOut); + + if ($basePath !== '') { + $response->headers->set(DevToolsHeader::DEVTOOLS_BASE_PATH, $basePath); + } + + if ($this->isInitialHtmlResponse($request, $response)) { + $this->injectDevToolsIdTag($response, $id, $basePath); + } + + $entry = app(IncomingEntryBuilder::class)->build($request, $response, $id, $batchId, $isPrefetch); + + app(EntryStore::class)->record($entry); + } + + /** + * Set the source location for the given shared prop keys. + * + * @param array $keys + * @param array{file: string, line: int} $source + */ + protected function setShareSource(array $keys, array $source): void + { + $state = InertiaState::current(); + + foreach ($keys as $key) { + $state->shareSources[(string) $key] = $source; + } + } + + /** + * Compute the top-level keys of the currently-shared props for DevTools + * shared-vs-render annotations. + * + * @param array $sharedProps + * @return array + */ + protected function topLevelSharedKeys(array $sharedProps): array + { + return collect(array_keys($sharedProps)) + ->map(function (mixed $key): string { + $key = (string) $key; + + return str_contains($key, '.') ? strstr($key, '.', true) : $key; + }) + ->unique() + ->values() + ->all(); + } + + /** + * Whether this response is the initial load of an Inertia page. A plain HTML page is + * left alone: the extension reads the tag as devtools being enabled there, and would + * warn about a missing interceptor registry that was never going to appear. + */ + protected function isInitialHtmlResponse(Request $request, SymfonyResponse $response): bool + { + if ($request->header(Header::INERTIA)) { + return false; + } + + if (! $response->isOk()) { + return false; + } + + $payload = $request->attributes->get(RequestAttribute::PAYLOAD); + + if (! is_array($payload) || ($payload['component'] ?? null) === null) { + return false; + } + + $contentType = (string) $response->headers->get('Content-Type', ''); + + return str_contains(strtolower($contentType), 'text/html'); + } + + /** + * Render the entry id into the page, along with the path the app is mounted on. A panel + * that attaches after the initial load has only the DOM to read, and the entry endpoint + * lives under that same path. + */ + protected function injectDevToolsIdTag(SymfonyResponse $response, string $id, string $basePath): void + { + $content = $response->getContent(); + + if (! is_string($content)) { + return; + } + + // HTML tag names are case-insensitive, so a root view may close its body as . + $closingBodyPosition = strripos($content, ''); + + if ($closingBodyPosition === false) { + return; + } + + $basePathAttribute = $basePath === '' + ? '' + : ' data-inertia-devtools-base-path="' . e($basePath) . '"'; + + $tag = ''; + + // Hypervel's setContent() replaces the response's original value with the string it is + // given. For an Inertia page that value is the root view, which is where the testing + // assertions read the page object from, so it is put back. + $original = $response instanceof HttpResponse ? $response->original : null; + + $response->setContent(substr_replace($content, $tag, $closingBodyPosition, 0)); + + if ($response instanceof HttpResponse) { + $response->original = $original; + } + + // The tag lengthens the page, so a length the application set for it would cut it short. + $response->headers->remove('Content-Length'); + } + + /** + * Resolve the batch root id sent back to the client. + */ + protected function resolveOutgoingParentId(bool $isPrefetch, string $id, ?string $batchId): string + { + if ($isPrefetch) { + return $id; + } + + return $batchId ?? $id; + } + + /** + * Resolve the entry's batch id and the outgoing parent id. + * + * @return array{0: ?string, 1: string} + */ + protected function resolveLineage(Request $request, string $id): array + { + $isPrefetch = $request->prefetch(); + $batchId = $request->header(Header::INERTIA) + ? DevToolsHeader::read($request, DevToolsHeader::DEVTOOLS_INCOMING_PARENT) + : null; + + return [$batchId, $this->resolveOutgoingParentId($isPrefetch, $id, $batchId)]; + } +} diff --git a/src/inertia/src/DevTools/SourceLocator.php b/src/inertia/src/DevTools/SourceLocator.php new file mode 100644 index 0000000000..92b1921e57 --- /dev/null +++ b/src/inertia/src/DevTools/SourceLocator.php @@ -0,0 +1,277 @@ +> */ + protected array $fileCache = []; + + /** + * Find the first caller frame outside of this package, the framework and vendor code. + * + * @return null|array{file: string, line: int} + */ + public function captureCallerSource(): ?array + { + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20); + $srcDir = dirname(__DIR__) . DIRECTORY_SEPARATOR; + $frameworkDir = $this->frameworkDirectory(); + + foreach ($backtrace as $frame) { + if (($frame['class'] ?? null) === Controller::class && $frame['function'] === '__invoke') { + return null; + } + + if (! isset($frame['file'], $frame['line'])) { + continue; + } + + if (str_starts_with($frame['file'], $srcDir)) { + continue; + } + + if ($frameworkDir !== null && str_starts_with($frame['file'], $frameworkDir)) { + continue; + } + + if (str_contains($frame['file'], DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR)) { + continue; + } + + return ['file' => $frame['file'], 'line' => $frame['line']]; + } + + return null; + } + + /** + * Get the directory holding the framework packages, when this package is installed among them. + * + * In the components repository or a path-linked checkout, framework frames such as facades, + * macros, the router and middleware are outside vendor and would be reported as the caller. + */ + protected function frameworkDirectory(): ?string + { + $directory = dirname(__DIR__, 3) . DIRECTORY_SEPARATOR; + $facade = (new ReflectionClass(Facade::class))->getFileName(); + + return $facade !== false && str_starts_with($facade, $directory) ? $directory : null; + } + + /** + * Resolve the source location of a shared prop key by scanning the share() method. + * + * @return null|array{file: string, line: int} + */ + public function resolveShareSource(ReflectionMethod $reflection, string $key): ?array + { + $file = $reflection->getDeclaringClass()->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + + if (! $file || ! $startLine || ! $endLine) { + return null; + } + + $line = $this->findPropKeyLine($file, $startLine, $key, $endLine); + + if ($line !== null) { + return ['file' => $file, 'line' => $line]; + } + + if (! $this->methodBodyContains($reflection, 'parent::share(')) { + return null; + } + + $parent = $reflection->getDeclaringClass()->getParentClass(); + + if ($parent === false || ! $parent->hasMethod('share')) { + return null; + } + + return $this->resolveShareSource($parent->getMethod('share'), $key); + } + + /** + * Determine if the method body contains the given text. + */ + public function methodBodyContains(ReflectionMethod $reflection, string $needle): bool + { + $file = $reflection->getDeclaringClass()->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + + if (! $file || ! $startLine || ! $endLine) { + return false; + } + + $lines = $this->readSourceLines($file); + if ($lines === null) { + return false; + } + + for ($i = $startLine - 1; $i < min($endLine, count($lines)); ++$i) { + if (str_contains($lines[$i], $needle)) { + return true; + } + } + + return false; + } + + /** + * Resolve the first plausible line of a share() method body, used as a fallback + * when a specific prop key line cannot be found. + * + * @return null|array{file: string, line: int} + */ + public function shareSourceFallback(ReflectionMethod $reflection): ?array + { + $file = $reflection->getDeclaringClass()->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + + if (! $file || ! $startLine || ! $endLine) { + return null; + } + + $lines = $this->readSourceLines($file); + if ($lines === null) { + return null; + } + + for ($i = $startLine - 1; $i < min($endLine, count($lines)); ++$i) { + if (str_contains($lines[$i], 'function') || str_contains($lines[$i], 'return [')) { + return ['file' => $file, 'line' => $i + 1]; + } + } + + return ['file' => $file, 'line' => $startLine]; + } + + /** + * Read the lines of the given source file. + * + * @return null|array + */ + public function readSourceLines(?string $file): ?array + { + if (! $file) { + return null; + } + + if (array_key_exists($file, $this->fileCache)) { + return $this->fileCache[$file]; + } + + if (! is_file($file) || ! is_readable($file)) { + return $this->fileCache[$file] = null; + } + + $lines = file($file); + + return $this->fileCache[$file] = ($lines === false ? null : $lines); + } + + /** + * Find the source line where a prop key is defined in an array literal. + */ + public function findPropKeyLine(string $file, int $startLine, string $key, ?int $endLine = null): ?int + { + $lines = $this->readSourceLines($file); + + if ($lines === null) { + return null; + } + + $maxScan = min($endLine ?? $startLine + 100, count($lines)); + $pattern = "/['\"]" . preg_quote($key, '/') . "['\"]\\s*=>/"; + + for ($i = $startLine - 1; $i < $maxScan; ++$i) { + if (preg_match($pattern, $lines[$i])) { + return $i + 1; + } + } + + return null; + } + + /** + * Resolve the route action source location when possible. + * + * @return null|array{file: string, line: int} + */ + public function resolveActionSource(?string $action, mixed $uses = null): ?array + { + if ($action === null) { + return null; + } + + try { + $reflection = self::reflectAction($action, $uses); + + if ($reflection === null) { + return null; + } + + $file = $reflection->getFileName(); + $line = $reflection->getStartLine(); + + if ($file && $line) { + return ['file' => $file, 'line' => $line]; + } + } catch (Throwable) { + return null; + } + + return null; + } + + /** + * Reflect a route action, covering closures, controller methods, array callables, + * and invokable controllers. + */ + protected static function reflectAction(string $action, mixed $uses): ?ReflectionFunctionAbstract + { + if ($uses instanceof Closure) { + return new ReflectionFunction($uses); + } + + if (is_array($uses) && count($uses) === 2) { + return new ReflectionMethod($uses[0], $uses[1]); + } + + if (str_contains($action, '@')) { + [$class, $method] = explode('@', $action, 2); + + return new ReflectionMethod($class, $method); + } + + if (is_object($uses) && method_exists($uses, '__invoke')) { + return new ReflectionMethod($uses, '__invoke'); + } + + if (class_exists($action) && method_exists($action, '__invoke')) { + return new ReflectionMethod($action, '__invoke'); + } + + return null; + } +} diff --git a/src/inertia/src/Directive.php b/src/inertia/src/Directive.php index feae0809a3..ede3142979 100644 --- a/src/inertia/src/Directive.php +++ b/src/inertia/src/Directive.php @@ -24,7 +24,7 @@ public static function compile(string $expression = ''): string if ($__inertiaSsrResponse) { echo $__inertiaSsrResponse->body; } else { - ?>
'; diff --git a/src/inertia/src/Inertia.php b/src/inertia/src/Inertia.php index 9c0006bc74..fd2f50b2fa 100644 --- a/src/inertia/src/Inertia.php +++ b/src/inertia/src/Inertia.php @@ -8,8 +8,9 @@ /** * @method static \Hypervel\Inertia\AlwaysProp always(mixed $value) - * @method static \Symfony\Component\HttpFoundation\RedirectResponse back(int $status = 302, array $headers = [], mixed $fallback = false) + * @method static \Hypervel\Http\RedirectResponse back(int $status = 302, array $headers = [], string|bool $fallback = false) * @method static void clearHistory() + * @method static void configureSsrRequestUsing(\Closure|null $callback = null) * @method static \Hypervel\Inertia\MergeProp deepMerge(mixed $value) * @method static \Hypervel\Inertia\DeferProp defer(callable $callback, string $group = 'default', bool $rescue = false) * @method static void disableSsr(\Closure|bool $condition = true) diff --git a/src/inertia/src/InertiaServiceProvider.php b/src/inertia/src/InertiaServiceProvider.php index 1346991991..0d09071813 100644 --- a/src/inertia/src/InertiaServiceProvider.php +++ b/src/inertia/src/InertiaServiceProvider.php @@ -4,17 +4,22 @@ namespace Hypervel\Inertia; +use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Http\Kernel as HttpKernelContract; +use Hypervel\Http\Client\Factory as HttpFactory; use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request; +use Hypervel\Inertia\DevTools\DevTools; +use Hypervel\Inertia\DevTools\DevToolsServiceProvider; +use Hypervel\Inertia\DevTools\SourceLocator; use Hypervel\Inertia\Ssr\Gateway; use Hypervel\Inertia\Ssr\HttpGateway; use Hypervel\Inertia\Support\Header; use Hypervel\Inertia\Testing\TestResponseMacros; use Hypervel\Routing\Router; -use Hypervel\Support\Facades\Blade; use Hypervel\Support\ServiceProvider; use Hypervel\Testing\TestResponse; +use Hypervel\View\Compilers\BladeCompiler; use Hypervel\View\FileViewFinder; use LogicException; @@ -42,6 +47,7 @@ public function register(): void $this->registerRouterMacro(); $this->registerTestingMacros(); $this->registerMiddleware(); + $this->app->register(DevToolsServiceProvider::class); $this->app->singleton('inertia.view-finder', function ($app) { $config = $app->make('config'); @@ -57,11 +63,17 @@ public function register(): void /** * Boot the service provider. */ - public function boot(): void + public function boot(HttpFactory $http, Repository $config): void { $this->registerConsoleCommands(); $this->pushRedirectMiddleware(); + // A null timeout is left out so the HTTP client's global options apply. + $http->registerConnection(HttpGateway::CONNECTION, array_filter([ + 'connect_timeout' => $config->get('inertia.ssr.connect_timeout', 2.0), + 'timeout' => $config->get('inertia.ssr.timeout', 5.0), + ], fn (mixed $timeout): bool => $timeout !== null)); + $this->publishes([ __DIR__ . '/../config/inertia.php' => config_path('inertia.php'), ]); @@ -82,8 +94,8 @@ protected function pushRedirectMiddleware(): void */ protected function registerBladeComponents(): void { - $this->callAfterResolving('blade.compiler', function () { - Blade::componentNamespace('Hypervel\Inertia\View\Components', 'inertia'); + $this->callAfterResolving('blade.compiler', function (BladeCompiler $blade): void { + $blade->componentNamespace('Hypervel\Inertia\View\Components', 'inertia'); }); } @@ -150,9 +162,19 @@ protected function registerRouterMacro(): void * @param array $props */ Router::macro('inertia', function ($uri, $component, $props = []) { - return $this->match(['GET', 'HEAD'], $uri, '\\' . Controller::class) + $route = $this->match(['GET', 'HEAD'], $uri, '\\' . Controller::class) ->defaults('component', $component) ->defaults('props', $props); + + if (DevTools::enabled()) { + $source = app(SourceLocator::class)->captureCallerSource(); + + if ($source !== null) { + $route->defaults(DevTools::RENDER_SOURCE_KEY, $source); + } + } + + return $route; }); } diff --git a/src/inertia/src/InertiaState.php b/src/inertia/src/InertiaState.php index 9b8f4c3960..c526c438e6 100644 --- a/src/inertia/src/InertiaState.php +++ b/src/inertia/src/InertiaState.php @@ -36,6 +36,14 @@ class InertiaState implements ReplicableContext */ public array $sharedProps = []; + /** + * The source locations of the shared properties, recorded for DevTools. Kept beside the + * shared properties so props shared during boot carry their sources into each request. + * + * @var array + */ + public array $shareSources = []; + /** * The asset version resolver or value. */ @@ -89,6 +97,11 @@ class InertiaState implements ReplicableContext */ public array $ssrExcludedPaths = []; + /** + * The callback that configures the HTTP request sent to the SSR server. + */ + public ?Closure $ssrRequestConfigurator = null; + /** * Get the current Inertia state. */ diff --git a/src/inertia/src/Middleware.php b/src/inertia/src/Middleware.php index 38a257411d..9c7b483388 100644 --- a/src/inertia/src/Middleware.php +++ b/src/inertia/src/Middleware.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Http\Request; +use Hypervel\Inertia\DevTools\DevTools; use Hypervel\Inertia\Ssr\ExcludesSsrPaths; use Hypervel\Inertia\Ssr\Gateway; use Hypervel\Inertia\Support\Header; @@ -125,11 +126,19 @@ public function urlResolver(): ?Closure */ public function handle(Request $request, Closure $next): Response { + $recorder = DevTools::recorder($request); + + $recorder?->requestStarted($request); + Inertia::version(function () use ($request) { return $this->version($request); }); - Inertia::share($this->share($request)); + $shared = $this->share($request); + + Inertia::share($shared); + + $recorder?->sharedPropsResolved($this, $shared); foreach ($this->shareOnce($request) as $key => $value) { if ($value instanceof OnceProp) { @@ -160,6 +169,8 @@ public function handle(Request $request, Closure $next): Response if (! $request->header(Header::INERTIA)) { $this->addInertiaVaryHeader($response); + $recorder?->respondedWith($request, $response); + return $response; } @@ -181,6 +192,8 @@ public function handle(Request $request, Closure $next): Response $this->addInertiaVaryHeader($response); + $recorder?->respondedWith($request, $response); + return $response; } diff --git a/src/inertia/src/PropsResolver.php b/src/inertia/src/PropsResolver.php index 679b00b199..3d5a742e45 100644 --- a/src/inertia/src/PropsResolver.php +++ b/src/inertia/src/PropsResolver.php @@ -9,9 +9,12 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Responsable; use Hypervel\Http\Request; +use Hypervel\Inertia\DevTools\DevTools; +use Hypervel\Inertia\DevTools\RequestRecorder; use Hypervel\Inertia\Support\Header; use Hypervel\Support\Arr; use Hypervel\Support\Facades\App; +use JsonSerializable; use Throwable; class PropsResolver @@ -129,6 +132,13 @@ class PropsResolver */ protected array $sharedPropKeys = []; + /** + * The devtools recorder, resolved only while recording is active. It stays null when + * devtools is disabled so the per-prop resolution loop never touches it, keeping the + * hot path free of recorder calls, container lookups, and prop classification. + */ + protected ?RequestRecorder $recorder = null; + /** * Create a new props resolver instance. */ @@ -143,6 +153,8 @@ public function __construct(Request $request, string $component) $this->except = $this->parseHeader(Header::PARTIAL_EXCEPT); $this->resetProps = $this->parseHeader(Header::RESET) ?? []; $this->loadedOnceProps = $this->parseHeader(Header::EXCEPT_ONCE_PROPS) ?? []; + + $this->recorder = DevTools::recorder($request); } /** @@ -172,6 +184,8 @@ protected function resolveSharedProps(array $shared): array { $resolved = $this->resolvePropertyProviders($shared); + $this->recorder?->sharedPropsExpanded($resolved); + if (! config()->boolean('inertia.expose_shared_prop_keys')) { return $resolved; } @@ -268,6 +282,8 @@ protected function resolveProps(array $props, string $prefix = '', bool $parentW $value = $this->resolveValue($prop, $path, $props); if (in_array($path, $this->rescuedProps, true)) { + $this->recorder?->propRescued($path, $prop); + continue; } @@ -287,6 +303,7 @@ protected function resolveProps(array $props, string $prefix = '', bool $parentW } $this->collectMetadata($prop, $path); + $this->recorder?->propResolved($path, $prop); // When the resolved value is an array, we recurse into it. If the // original prop was not already an array (e.g. a closure that @@ -475,6 +492,10 @@ protected function resolveValue(mixed $value, string $path, array $siblings): mi } } + if ($value instanceof JsonSerializable) { + $value = $value->jsonSerialize(); + } + return $value; } catch (Throwable $e) { if (! $shouldRescue) { diff --git a/src/inertia/src/Response.php b/src/inertia/src/Response.php index 84f1b71749..b3b55568fe 100644 --- a/src/inertia/src/Response.php +++ b/src/inertia/src/Response.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Support\Responsable; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; +use Hypervel\Inertia\DevTools\DevTools; use Hypervel\Inertia\Support\Header; use Hypervel\Inertia\Support\SessionKey; use Hypervel\Support\Facades\App; @@ -188,12 +189,18 @@ public function toResponse(Request $request): SymfonyResponse ); if ($request->header(Header::INERTIA)) { - return new JsonResponse($page, 200, [Header::INERTIA => 'true']); + $response = new JsonResponse($page, 200, [Header::INERTIA => 'true']); + } else { + InertiaState::current()->page = $page; + + $response = ResponseFactory::view($this->rootView, ['page' => $page] + $this->viewData); } - InertiaState::current()->page = $page; + // Recorded once the response exists, so a page whose root view or JSON encoding fails + // is not recorded as the error response that replaces it. + DevTools::recorder($request)?->pageRendered($request, $page, $resolvedProps); - return ResponseFactory::view($this->rootView, ['page' => $page] + $this->viewData); + return $response; } /** diff --git a/src/inertia/src/ResponseFactory.php b/src/inertia/src/ResponseFactory.php index b74a0abeae..5fc64c6e7a 100644 --- a/src/inertia/src/ResponseFactory.php +++ b/src/inertia/src/ResponseFactory.php @@ -10,7 +10,10 @@ use Hypervel\Contracts\Http\Kernel; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Foundation\Exceptions\Handler as ExceptionHandler; +use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request as HttpRequest; +use Hypervel\Inertia\DevTools\DevTools; +use Hypervel\Inertia\Ssr\ConfiguresSsrRequests; use Hypervel\Inertia\Ssr\DisablesSsr; use Hypervel\Inertia\Ssr\ExcludesSsrPaths; use Hypervel\Inertia\Ssr\Gateway; @@ -25,7 +28,7 @@ use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; use LogicException; -use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use UnitEnum; @@ -64,12 +67,16 @@ public function share(mixed $key, mixed $value = null): void if (is_array($key)) { $state->sharedProps = array_merge($state->sharedProps, $key); + DevTools::recorder()?->propsShared(array_keys($key)); } elseif ($key instanceof Arrayable) { - $state->sharedProps = array_merge($state->sharedProps, $key->toArray()); + $resolved = $key->toArray(); + $state->sharedProps = array_merge($state->sharedProps, $resolved); + DevTools::recorder()?->propsShared(array_keys($resolved)); } elseif ($key instanceof ProvidesInertiaProperties) { $state->sharedProps = array_merge($state->sharedProps, [$key]); } else { Arr::set($state->sharedProps, $key, $value); + DevTools::recorder()?->propsShared([(string) $key]); } } @@ -94,7 +101,10 @@ public function getShared(?string $key = null, mixed $default = null): mixed */ public function flushShared(): void { - $this->state()->sharedProps = []; + $state = $this->state(); + + $state->sharedProps = []; + $state->shareSources = []; } /** @@ -189,6 +199,20 @@ public function withoutSsr(array|string $paths): void $gateway->except($paths); } + /** + * Configure the HTTP request that is sent to the SSR server. + */ + public function configureSsrRequestUsing(?Closure $callback = null): void + { + $gateway = app(Gateway::class); + + if (! $gateway instanceof ConfiguresSsrRequests) { + throw new LogicException('The configured SSR gateway does not support configuring server-side rendering requests.'); + } + + $gateway->configureRequestUsing($callback); + } + /** * Create an optional property. */ @@ -319,7 +343,7 @@ public function render(mixed $component, mixed $props = []): Response $state = $this->state(); - return new Response( + $response = new Response( $component, $state->sharedProps, $props, @@ -328,18 +352,22 @@ public function render(mixed $component, mixed $props = []): Response $state->encryptHistory ?? config()->boolean('inertia.history.encrypt', false), $state->urlResolver, ); + + DevTools::recorder()?->pageRendering($component, $response); + + return $response; } /** * Create an Inertia location response. */ - public function location(string|RedirectResponse $url): SymfonyResponse + public function location(string|SymfonyRedirectResponse $url): SymfonyResponse { if (Request::inertia()) { - return BaseResponse::make('', 409, [Header::LOCATION => $url instanceof RedirectResponse ? $url->getTargetUrl() : $url]); + return BaseResponse::make('', 409, [Header::LOCATION => $url instanceof SymfonyRedirectResponse ? $url->getTargetUrl() : $url]); } - return $url instanceof RedirectResponse ? $url : Redirect::away($url); + return $url instanceof SymfonyRedirectResponse ? $url : Redirect::away($url); } /** @@ -415,7 +443,7 @@ public function flash(BackedEnum|UnitEnum|string|array $key, mixed $value = null * * @param array $headers */ - public function back(int $status = 302, array $headers = [], mixed $fallback = false): RedirectResponse + public function back(int $status = 302, array $headers = [], bool|string $fallback = false): RedirectResponse { return Redirect::back($status, $headers, $fallback); } diff --git a/src/inertia/src/Ssr/ConfiguresSsrRequests.php b/src/inertia/src/Ssr/ConfiguresSsrRequests.php new file mode 100644 index 0000000000..b2cc924069 --- /dev/null +++ b/src/inertia/src/Ssr/ConfiguresSsrRequests.php @@ -0,0 +1,15 @@ + config()->integer('inertia.ssr.connect_timeout', 2), - 'timeout' => config()->integer('inertia.ssr.timeout', 5), - 'cookies' => false, - 'http_errors' => false, - ]); - } - - /** - * Set a Guzzle client for testing purposes. - * - * Tests only. The client persists in a static property for the worker - * lifetime and is used by every SSR dispatch on this worker. - */ - public static function useTestingClient(?ClientInterface $client): void - { - self::$testingClient = $client; - } - /** * Dispatch the Inertia page to the SSR engine via HTTP. * @@ -109,47 +73,55 @@ public function dispatch(array $page, ?Request $request = null): ?Response return null; } + $pendingRequest = $this->pendingRequest(); + try { - $response = $this->ssrClient()->request('POST', $url, [ - 'json' => $page, + $response = $pendingRequest->post($url, $page); + } catch (RequestException $e) { + // A configured retry() or throw() raises the failed response, which + // still carries the SSR server's error details. + $response = $e->response; + } catch (ConnectionException $e) { + $this->armTransportBackoff(); + $this->handleSsrFailure($page, [ + 'error' => $e->getMessage(), + 'type' => 'connection', ]); - self::$ssrUnavailableUntil = null; - if ($response->getStatusCode() >= 400) { - $decoded = json_decode((string) $response->getBody(), true); - $structured = is_array($decoded); + return null; + } - if (! $structured) { - $this->armTransportBackoff(); - } + self::$ssrUnavailableUntil = null; - $this->handleSsrFailure($page, $structured ? $decoded : null); + if ($response->failed()) { + // Decode SSR bodies directly: Response::json() applies the HTTP client's + // global decoding flags, which could make a malformed body throw + // instead of falling back to client-side rendering. + $decoded = json_decode($response->body(), true); + $structured = is_array($decoded); - return null; + if (! $structured) { + $this->armTransportBackoff(); } - $data = json_decode((string) $response->getBody(), true); + $this->handleSsrFailure($page, $structured ? $decoded : null); - if (! $this->isValidSsrResponse($data)) { - $this->armTransportBackoff(); - $this->handleSsrFailure($page, ['error' => 'Invalid SSR response.']); + return null; + } - return null; - } + $data = json_decode($response->body(), true); - return new Response( - implode("\n", $data['head']), - $data['body'], - ); - } catch (TransferException $e) { + if (! $this->isValidSsrResponse($data)) { $this->armTransportBackoff(); - $this->handleSsrFailure($page, [ - 'error' => $e->getMessage(), - 'type' => 'connection', - ]); + $this->handleSsrFailure($page, ['error' => 'Invalid SSR response.']); return null; } + + return new Response( + implode("\n", $data['head']), + $data['body'], + ); } /** @@ -184,6 +156,29 @@ public function getExcludedPaths(): array return $this->state()->ssrExcludedPaths; } + /** + * Configure the HTTP request that is sent to the SSR server. + */ + public function configureRequestUsing(?Closure $callback = null): void + { + $this->state()->ssrRequestConfigurator = $callback; + } + + /** + * Create the pending HTTP request for the SSR server. + */ + protected function pendingRequest(): PendingRequest + { + $request = Http::connection(self::CONNECTION); + $configurator = $this->state()->ssrRequestConfigurator; + + if ($configurator === null) { + return $request; + } + + return $configurator($request) ?? $request; + } + /** * Handle an SSR rendering failure. * @@ -246,11 +241,11 @@ protected function ssrIsEnabled(Request $request): bool */ public function isHealthy(): bool { - try { - $response = $this->ssrClient()->request('GET', $this->getProductionUrl('/health')); + $pendingRequest = $this->pendingRequest(); - return $response->getStatusCode() >= 200 && $response->getStatusCode() < 300; - } catch (TransferException) { + try { + return $pendingRequest->get($this->getProductionUrl('/health'))->successful(); + } catch (HttpClientException) { return false; } } @@ -258,17 +253,17 @@ public function isHealthy(): bool /** * Shut down the SSR server. * - * @throws TransferException + * @throws ConnectionException */ public function shutdown(): bool { - $response = $this->ssrClient()->request( - 'GET', - $this->getProductionUrl('/shutdown'), - ); + $pendingRequest = $this->pendingRequest(); - return $response->getStatusCode() >= 200 - && $response->getStatusCode() < 300; + try { + return $pendingRequest->get($this->getProductionUrl('/shutdown'))->successful(); + } catch (RequestException) { + return false; + } } /** @@ -361,7 +356,5 @@ private function stringOrNull(mixed $value): ?string public static function flushState(): void { self::$ssrUnavailableUntil = null; - self::$ssrClient = null; - self::$testingClient = null; } } diff --git a/src/inertia/src/Ssr/SsrException.php b/src/inertia/src/Ssr/SsrException.php index 8c6bae31de..e89242d08d 100644 --- a/src/inertia/src/Ssr/SsrException.php +++ b/src/inertia/src/Ssr/SsrException.php @@ -8,11 +8,6 @@ class SsrException extends Exception { - /** - * The SSR render failed event containing error details. - */ - public ?SsrRenderFailed $event = null; - /** * Create a new SSR exception from a render failure event. */ @@ -34,6 +29,11 @@ public static function fromEvent(SsrRenderFailed $event): self return $exception; } + /** + * The SSR render failed event containing error details. + */ + public ?SsrRenderFailed $event = null; + /** * Get the component that failed to render. */ diff --git a/src/inertia/src/Support/Header.php b/src/inertia/src/Support/Header.php index 70cde2de5b..9cbd48ed46 100644 --- a/src/inertia/src/Support/Header.php +++ b/src/inertia/src/Support/Header.php @@ -36,6 +36,11 @@ class Header */ public const string PARTIAL_COMPONENT = 'X-Inertia-Partial-Component'; + /** + * Header for Hypervel Precognition validation requests. + */ + public const string PRECOGNITION = 'Precognition'; + /** * Header specifying which props to include in partial reloads. */ diff --git a/src/inertia/src/Testing/AssertableInertia.php b/src/inertia/src/Testing/AssertableInertia.php index 81c33628d9..07edb85b3c 100644 --- a/src/inertia/src/Testing/AssertableInertia.php +++ b/src/inertia/src/Testing/AssertableInertia.php @@ -139,9 +139,9 @@ public function version(string $value): self */ public function loadDeferredProps(Closure|array|string $groupsOrCallback, ?Closure $callback = null): self { - $callback = is_callable($groupsOrCallback) ? $groupsOrCallback : $callback; + $callback = $groupsOrCallback instanceof Closure ? $groupsOrCallback : $callback; - $groups = is_callable($groupsOrCallback) ? array_keys($this->deferredProps) : Arr::wrap($groupsOrCallback); + $groups = $groupsOrCallback instanceof Closure ? array_keys($this->deferredProps) : Arr::wrap($groupsOrCallback); $props = collect($groups)->flatMap(function ($group) { return $this->deferredProps[$group] ?? []; diff --git a/src/inertia/src/View/Components/App.php b/src/inertia/src/View/Components/App.php index 8b72452d0e..7cf5d6ce13 100644 --- a/src/inertia/src/View/Components/App.php +++ b/src/inertia/src/View/Components/App.php @@ -24,7 +24,7 @@ public function __construct( $this->response = $state->dispatchSsr(); $this->pageJson = $this->response === null - ? json_encode($state->page, JSON_THROW_ON_ERROR) + ? json_encode($state->page, JSON_HEX_TAG | JSON_THROW_ON_ERROR) : ''; } diff --git a/src/notifications/composer.json b/src/notifications/composer.json index 1172fd2fc8..e29f208d5f 100644 --- a/src/notifications/composer.json +++ b/src/notifications/composer.json @@ -26,7 +26,7 @@ "require": { "php": "^8.4", "ext-mbstring": "*", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "hypervel/broadcasting": "^0.4", "hypervel/bus": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/object-pool/composer.json b/src/object-pool/composer.json index 9ec0ac3614..de1501b43d 100644 --- a/src/object-pool/composer.json +++ b/src/object-pool/composer.json @@ -25,6 +25,7 @@ ], "require": { "php": "^8.4", + "hypervel/collections": "^0.4", "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", "hypervel/coordinator": "^0.4", diff --git a/src/opentelemetry/composer.json b/src/opentelemetry/composer.json index ff22180f59..2e60951a68 100644 --- a/src/opentelemetry/composer.json +++ b/src/opentelemetry/composer.json @@ -35,7 +35,7 @@ "ext-filter": "*", "ext-mbstring": "*", "ext-swoole": "^6.2.2", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "hypervel/cache": "^0.4", "hypervel/config": "^0.4", diff --git a/src/routing/src/AbstractRouteCollection.php b/src/routing/src/AbstractRouteCollection.php index b89a20dcc3..de6cd683d4 100644 --- a/src/routing/src/AbstractRouteCollection.php +++ b/src/routing/src/AbstractRouteCollection.php @@ -145,6 +145,7 @@ public function compile(): array 'bindingFields' => $route->bindingFields(), 'lockSeconds' => $route->locksFor(), 'waitSeconds' => $route->waitsFor(), + 'readOnlySession' => $route->hasReadOnlySession(), 'withTrashed' => $route->allowsTrashedBindings(), ]; } diff --git a/src/routing/src/CompiledRouteCollection.php b/src/routing/src/CompiledRouteCollection.php index f77dd79d71..9302efcd0f 100644 --- a/src/routing/src/CompiledRouteCollection.php +++ b/src/routing/src/CompiledRouteCollection.php @@ -613,6 +613,7 @@ protected function newRoute(array $attributes): Route ->setWheres($attributes['wheres']) ->setBindingFields($attributes['bindingFields']) ->block($attributes['lockSeconds'] ?? null, $attributes['waitSeconds'] ?? null) + ->readOnlySession($attributes['readOnlySession']) ->withTrashed($attributes['withTrashed'] ?? false); } diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index 22666297f4..c049d116ba 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -123,6 +123,11 @@ class Route */ protected ?int $waitSeconds = null; + /** + * Indicates if the route should use a read-only session. + */ + protected bool $readOnlySession = false; + /** * The computed gathered middleware. * @@ -1446,6 +1451,24 @@ public function waitsFor(): ?int return $this->waitSeconds; } + /** + * Specify that the route should read the session without saving it. + */ + public function readOnlySession(bool $readOnly = true): static + { + $this->readOnlySession = $readOnly; + + return $this; + } + + /** + * Determine if the route uses a read-only session. + */ + public function hasReadOnlySession(): bool + { + return $this->readOnlySession; + } + /** * Add metadata to the route. */ diff --git a/src/saloon/composer.json b/src/saloon/composer.json index 4d512bda5e..5445024de7 100644 --- a/src/saloon/composer.json +++ b/src/saloon/composer.json @@ -36,7 +36,7 @@ "ext-filter": "*", "ext-mbstring": "*", "ext-simplexml": "*", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/psr7": "^2.13 || ^3.1", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/scout/composer.json b/src/scout/composer.json index 949e5e7969..4ce5c2de55 100644 --- a/src/scout/composer.json +++ b/src/scout/composer.json @@ -37,7 +37,7 @@ "require": { "php": "^8.4", "ext-filter": "*", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/config": "^0.4", diff --git a/src/sentry/composer.json b/src/sentry/composer.json index fe42294c46..c2747b62e4 100644 --- a/src/sentry/composer.json +++ b/src/sentry/composer.json @@ -31,7 +31,7 @@ "require": { "php": "^8.4", "ext-filter": "*", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "hypervel/auth": "^0.4", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/session/src/Middleware/StartSession.php b/src/session/src/Middleware/StartSession.php index dfbfd314a0..bdb465a969 100644 --- a/src/session/src/Middleware/StartSession.php +++ b/src/session/src/Middleware/StartSession.php @@ -139,6 +139,10 @@ protected function startSession(Request $request, Session $session): Session $session->setRequestOnHandler($request); $session->start(); + + if ($request->route() instanceof Route && $request->route()->hasReadOnlySession()) { + $session->markAsReadOnly(); + } }); } @@ -162,7 +166,7 @@ protected function collectGarbage(Session $session): void // Here we will see if this request hits the garbage collection lottery by hitting // the odds needed to perform garbage collection on any given request. If we do // hit it, we'll call this handler to let it delete all the expired sessions. - if ($this->configHitsLottery($config)) { + if (! $session->isReadOnly() && $this->configHitsLottery($config)) { $session->getHandler()->gc($this->getSessionLifetimeInSeconds()); } } @@ -184,7 +188,8 @@ protected function storeCurrentUrl(Request $request, Session $session): void && $request->route() instanceof Route && ! $request->ajax() && ! $request->prefetch() - && ! $request->isPrecognitive()) { + && ! $request->isPrecognitive() + && ! $session->isReadOnly()) { $session->setPreviousUrl($request->fullUrl()); if (method_exists($session, 'setPreviousRoute')) { @@ -198,7 +203,8 @@ protected function storeCurrentUrl(Request $request, Session $session): void */ protected function addCookieToResponse(Request $request, Response $response, Session $session): void { - if ($this->sessionIsPersistent($config = $this->manager->getSessionConfig())) { + // The ID of a read-only session is never saved, so the browser keeps its current cookie. + if (! $session->isReadOnly() && $this->sessionIsPersistent($config = $this->manager->getSessionConfig())) { $cookieConfig = $this->resolveSessionCookieConfig($request, $config); $response->headers->setCookie(new Cookie( diff --git a/src/session/src/Store.php b/src/session/src/Store.php index ca984352a6..bc757c2ae4 100644 --- a/src/session/src/Store.php +++ b/src/session/src/Store.php @@ -54,6 +54,11 @@ class Store implements Session */ public const string ID_CONTEXT_KEY_PREFIX = '__session.store.id.'; + /** + * Context key for whether the session is read-only. + */ + protected const string READ_ONLY_CONTEXT_KEY_PREFIX = '__session.store.read_only.'; + /** * The supported session serialization strategies. */ @@ -79,6 +84,11 @@ class Store implements Session */ protected readonly string $idContextKey; + /** + * The context key for whether this session is read-only. + */ + protected readonly string $readOnlyContextKey; + /** * Create a new session instance. * @@ -105,9 +115,11 @@ public function __construct( $this->startedContextKey = self::STARTED_CONTEXT_KEY_PREFIX . $suffix; $this->attributesContextKey = self::ATTRIBUTES_CONTEXT_KEY_PREFIX . $suffix; $this->idContextKey = self::ID_CONTEXT_KEY_PREFIX . $suffix; + $this->readOnlyContextKey = self::READ_ONLY_CONTEXT_KEY_PREFIX . $suffix; CoroutineContext::set($this->startedContextKey, false); CoroutineContext::set($this->attributesContextKey, []); + CoroutineContext::set($this->readOnlyContextKey, false); $this->setId($id); } @@ -117,6 +129,10 @@ public function __construct( */ public function start(): bool { + // Read-only applies to the request that marks it, so a later request handled in the + // same coroutine starts with a writable session. + CoroutineContext::set($this->readOnlyContextKey, false); + $this->loadSession(); if (! $this->has('_token')) { @@ -222,6 +238,10 @@ protected function marshalErrorBagIn(array $attributes): array */ public function save(): void { + if ($this->isReadOnly()) { + return; + } + // Publish the aged attributes only after the handler commits, so a failed // write leaves the live flash data and error bag intact for the retry. $attributes = $this->ageFlashDataIn($this->getAttributes()); @@ -614,7 +634,7 @@ public function regenerate(bool $destroy = false): bool */ public function migrate(bool $destroy = false): bool { - if ($destroy) { + if ($destroy && ! $this->isReadOnly()) { $this->handler->destroy($this->getId()); } @@ -633,6 +653,26 @@ public function isStarted(): bool return CoroutineContext::get($this->startedContextKey, false); } + /** + * Mark the session as read-only for the current request. + * + * A read-only session is not saved, and regenerating its ID does not destroy the stored + * session, so the request cannot overwrite session data saved by concurrent requests. + * Changes made during the request remain available until it ends. + */ + public function markAsReadOnly(): void + { + CoroutineContext::set($this->readOnlyContextKey, true); + } + + /** + * Determine if the session is read-only for the current request. + */ + public function isReadOnly(): bool + { + return CoroutineContext::get($this->readOnlyContextKey, false); + } + /** * Get the name of the session. */ diff --git a/src/socialite/composer.json b/src/socialite/composer.json index 2686e02b1d..1ed7762b8a 100644 --- a/src/socialite/composer.json +++ b/src/socialite/composer.json @@ -32,7 +32,7 @@ "php": "^8.4", "ext-filter": "*", "firebase/php-jwt": "^7.0", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "psr/http-message": "^2.0", "hypervel/collections": "^0.4", "hypervel/context": "^0.4", diff --git a/src/support/src/Facades/Session.php b/src/support/src/Facades/Session.php index 70f5ba80d5..4a95a7b288 100644 --- a/src/support/src/Facades/Session.php +++ b/src/support/src/Facades/Session.php @@ -46,10 +46,12 @@ * @method static string id() * @method static int|float increment(\UnitEnum|string $key, int $amount = 1) * @method static bool invalidate() + * @method static bool isReadOnly() * @method static bool isStarted() * @method static bool isValidId(string|null $id) * @method static void keep(mixed $keys = null) * @method static void macro(string $name, callable|object $macro) + * @method static void markAsReadOnly() * @method static bool migrate(bool $destroy = false) * @method static bool missing(\UnitEnum|array|string $key) * @method static void mixin(object $mixin, bool $replace = true) diff --git a/src/telescope/composer.json b/src/telescope/composer.json index f16f48d16a..fa2b70e936 100644 --- a/src/telescope/composer.json +++ b/src/telescope/composer.json @@ -26,7 +26,7 @@ "php": "^8.4", "ext-mbstring": "*", "ext-pdo": "*", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "hypervel/auth": "^0.4", "hypervel/broadcasting": "^0.4", diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 86c1b3ab15..3bbdeb7b9a 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -381,6 +381,7 @@ protected function flushImageState(): void */ protected function flushInertiaState(): void { + $this->callIfExists(\Hypervel\Inertia\DevTools\EntryStore::class, 'flushState'); $this->callIfExists(\Hypervel\Inertia\Middleware::class, 'flushState'); $this->callIfExists(\Hypervel\Inertia\Response::class, 'flushState'); $this->callIfExists(\Hypervel\Inertia\ResponseFactory::class, 'flushState'); diff --git a/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php b/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php index 1e6ac1281d..af80f29e33 100644 --- a/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php +++ b/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php @@ -10,6 +10,7 @@ use Hypervel\Foundation\Http\Middleware\HandlePrecognitiveRequests; use Hypervel\Foundation\Testing\Concerns\MakesHttpRequests; use Hypervel\Foundation\Testing\Stubs\FakeMiddleware; +use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request; use Hypervel\Http\Response; use Hypervel\HttpServer\Events\RequestHandled; @@ -428,6 +429,30 @@ public function testRequestWithoutSessionClearsPriorSessionContext(): void } } + public function testReadOnlySessionChangesDoNotCarryIntoTheNextRequest(): void + { + $router = $this->app->make(Router::class); + $router->get('/read-only', function (): string { + session()->put('name', 'Taylor'); + session()->regenerate(); + + return 'read-only'; + })->middleware('web')->readOnlySession(); + $router->get('/writable', fn (): string => session('name', 'absent'))->middleware('web'); + + $this->withSession(['team' => 'Hypervel']); + $sessionId = session()->getId(); + + $this->get('/read-only')->assertOk(); + + $this->assertSame($sessionId, session()->getId()); + + $this->get('/writable')->assertContent('absent'); + + $stored = json_decode($this->app->make('session')->driver()->getHandler()->read(session()->getId()), true); + $this->assertSame('Hypervel', $stored['team']); + } + public function testAssertSessionHasErrors() { $this->app->instance('session.store', $store = new Store('test-session', new ArraySessionHandler(1))); @@ -577,7 +602,7 @@ public function testFollowingRedirectsTerminatesInExpectedOrder() }; $router->get('from', function () { - return new \Hypervel\Http\RedirectResponse('http://localhost/to'); + return new RedirectResponse('http://localhost/to'); })->middleware(TerminatingMiddleware::class); $router->get('to', function () { @@ -589,6 +614,18 @@ public function testFollowingRedirectsTerminatesInExpectedOrder() $this->assertEquals(['from', 'to'], $callOrder); } + public function testFollowingRedirectsSyncsTheFinalSessionToParentCoroutine(): void + { + $router = $this->app->make(Router::class); + $router->post('/save', fn (): RedirectResponse => redirect('/saved')->with('status', 'saved'))->middleware('web'); + $router->get('/saved', fn (): string => session('status', 'absent'))->middleware('web'); + + $this->followingRedirects() + ->post('/save') + ->assertContent('saved') + ->assertSessionMissing('status'); + } + public function testQuerySendsRequestBodyUsingQueryMethod(): void { $router = $this->app->make(Registrar::class); diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 7dc7288ff1..d5ec1be7a3 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -174,6 +174,22 @@ public function testFakeResponseHeaderValuesNormalizeNonFiniteFloats(): void $this->assertSame(['NAN', 'INF', '-INF'], $response->getHeader('X-Multiple')); } + public function testFakeResponseHeaderNormalizationLeavesReferencedCallerValuesUnchanged(): void + { + $header = new Stringable('single'); + $item = new Stringable('item'); + + $response = $this->factory::response('OK', 200, [ + 'X-Single' => &$header, + 'X-Multiple' => ['first', &$item], + ])->wait(); + + $this->assertInstanceOf(Stringable::class, $header); + $this->assertInstanceOf(Stringable::class, $item); + $this->assertSame(['single'], $response->getHeader('X-Single')); + $this->assertSame(['first', 'item'], $response->getHeader('X-Multiple')); + } + #[DataProvider('invalidFakeResponseHeaderValuesProvider')] public function testInvalidFakeResponseHeaderValuesAreRejected(mixed $value): void { @@ -1360,6 +1376,28 @@ public function jsonSerialize(): mixed }); } + public function testStructuredDataNormalizationLeavesReferencedCallerDataUnchanged(): void + { + $this->factory->fake(); + + $name = new Stringable('Alice'); + $email = 'alice@example.com'; + $data = ['user' => ['name' => &$name, 'email' => &$email]]; + + $this->factory->post('http://foo.com/json', $data); + + $this->assertInstanceOf(Stringable::class, $name); + + // Changing the caller's variables afterwards must not reach the captured request data. + $name = 'Changed'; + $email = 'changed@example.com'; + + $this->factory->assertSent(function (Request $request): bool { + return $request->body() === '{"user":{"name":"Alice","email":"alice@example.com"}}' + && $request['user'] === ['name' => 'Alice', 'email' => 'alice@example.com']; + }); + } + public function testCanSendJsonDataWithStringable(): void { $this->factory->fake(); @@ -1413,6 +1451,27 @@ public function testHeaderValuesAreSerialized(): void }); } + public function testHeaderNormalizationLeavesReferencedCallerValuesUnchanged(): void + { + $this->factory->fake(); + + $header = new Stringable('single'); + $item = new Stringable('item'); + + $this->factory->withHeaders([ + 'X-Single' => &$header, + 'X-Multiple' => ['first', &$item], + ])->get('http://foo.com/get'); + + $this->assertInstanceOf(Stringable::class, $header); + $this->assertInstanceOf(Stringable::class, $item); + + $this->factory->assertSent(function (Request $request): bool { + return $request->hasHeader('X-Single', 'single') + && $request->hasHeader('X-Multiple', ['first', 'item']); + }); + } + #[DataProvider('invalidHeaderValuesProvider')] public function testInvalidHeaderValuesAreRejected(mixed $value): void { @@ -1879,6 +1938,30 @@ public function testMultipartHeaderValuesAreSerialized(): void }); } + public function testMultipartNormalizationLeavesReferencedCallerValuesUnchanged(): void + { + $this->factory->fake(); + + $contents = new Stringable('original'); + $header = new Stringable('header'); + + $this->factory->asMultipart()->post('http://foo.com/multipart', [ + ['name' => 'text', 'contents' => &$contents, 'headers' => ['X-Part' => &$header]], + ]); + + $this->assertInstanceOf(Stringable::class, $contents); + $this->assertInstanceOf(Stringable::class, $header); + + // Changing the caller's variables afterwards must not reach the captured request data. + $contents = 'changed'; + $header = 'changed'; + + $this->factory->assertSent(function (Request $request): bool { + return $request[0]['contents'] === 'original' + && $request[0]['headers']['X-Part'] === 'header'; + }); + } + #[DataProvider('invalidMultipartHeaderValuesProvider')] public function testInvalidMultipartHeaderValuesAreRejected(mixed $value): void { diff --git a/tests/Inertia/Commands/StopSsrTest.php b/tests/Inertia/Commands/StopSsrTest.php index 17818b6a49..c9a5ff7516 100644 --- a/tests/Inertia/Commands/StopSsrTest.php +++ b/tests/Inertia/Commands/StopSsrTest.php @@ -4,44 +4,61 @@ namespace Hypervel\Tests\Inertia\Commands; -use GuzzleHttp\Exception\ConnectException; -use GuzzleHttp\Psr7\Request; +use Hypervel\Http\Client\Request; use Hypervel\Inertia\Ssr\HttpGateway; +use Hypervel\Support\Facades\Http; use Hypervel\Tests\Inertia\TestCase; -use Mockery as m; class StopSsrTest extends TestCase { + protected string $healthUrl; + + protected string $shutdownUrl; + + protected function setUp(): void + { + parent::setUp(); + + $gateway = app(HttpGateway::class); + $this->healthUrl = $gateway->getProductionUrl('/health'); + $this->shutdownUrl = $gateway->getProductionUrl('/shutdown'); + + Http::preventStrayRequests(); + } + public function testFailsWhenTheSsrServerIsUnhealthy(): void { - $gateway = m::mock(HttpGateway::class); - $gateway->shouldReceive('isHealthy')->once()->andReturn(false); - $gateway->shouldNotReceive('shutdown'); - $this->app->instance(HttpGateway::class, $gateway); + Http::fake([ + $this->healthUrl => Http::response(status: 500), + ]); $this->artisan('inertia:stop-ssr') ->expectsOutput('Unable to connect to Inertia SSR server.') ->assertExitCode(1); + + Http::assertSentCount(1); } public function testSucceedsWhenTheSsrServerStops(): void { - $gateway = m::mock(HttpGateway::class); - $gateway->shouldReceive('isHealthy')->once()->andReturn(true); - $gateway->shouldReceive('shutdown')->once()->andReturn(true); - $this->app->instance(HttpGateway::class, $gateway); + Http::fake([ + $this->healthUrl => Http::response(status: 200), + $this->shutdownUrl => Http::response(status: 200), + ]); $this->artisan('inertia:stop-ssr') ->expectsOutput('Inertia SSR server stopped.') ->assertExitCode(0); + + Http::assertSent(fn (Request $request): bool => $request->url() === $this->shutdownUrl); } public function testFailsWhenTheSsrServerRefusesToStop(): void { - $gateway = m::mock(HttpGateway::class); - $gateway->shouldReceive('isHealthy')->once()->andReturn(true); - $gateway->shouldReceive('shutdown')->once()->andReturn(false); - $this->app->instance(HttpGateway::class, $gateway); + Http::fake([ + $this->healthUrl => Http::response(status: 200), + $this->shutdownUrl => Http::response(status: 500), + ]); $this->artisan('inertia:stop-ssr') ->expectsOutput('Inertia SSR server refused to stop.') @@ -50,13 +67,10 @@ public function testFailsWhenTheSsrServerRefusesToStop(): void public function testAcceptsAResponseLessCloseAfterTheHealthCheck(): void { - $gateway = m::mock(HttpGateway::class); - $gateway->shouldReceive('isHealthy')->once()->andReturn(true); - $gateway->shouldReceive('shutdown')->once()->andThrow(new ConnectException( - 'Connection closed', - new Request('GET', 'http://localhost:13714/shutdown'), - )); - $this->app->instance(HttpGateway::class, $gateway); + Http::fake([ + $this->healthUrl => Http::response(status: 200), + $this->shutdownUrl => Http::failedConnection('Connection closed'), + ]); $this->artisan('inertia:stop-ssr') ->expectsOutput('Inertia SSR server stopped.') diff --git a/tests/Inertia/ComponentTest.php b/tests/Inertia/ComponentTest.php index d01b7b41b1..90cb9331f2 100644 --- a/tests/Inertia/ComponentTest.php +++ b/tests/Inertia/ComponentTest.php @@ -75,6 +75,18 @@ public function testAppComponentRendersClientSideDivWhenSsrIsDisabled(): void $this->assertStringContainsString('data-page="app"', $rendered); } + public function testAppComponentEscapesHtmlTagsInThePageData(): void + { + Config::set(['inertia.ssr.enabled' => false]); + + $page = ['component' => 'Foo/Bar', 'props' => ['foo' => '