Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f4748dc
Raise the Guzzle 7 floor to 7.15.2
binaryfire Oct 3, 2026
ecc4b98
Declare direct collections dependencies
binaryfire Oct 3, 2026
e988e8b
Port the remaining Inertia SSR gateway tests
binaryfire Oct 3, 2026
8c63ef6
Align the Inertia SSR state isolation test with upstream
binaryfire Oct 3, 2026
9a94266
Match upstream member order in SsrException
binaryfire Oct 3, 2026
f25a6f2
Add Inertia DevTools support
binaryfire Oct 3, 2026
09bd5b0
Normalize HTTP client request data without extra walks or caller muta…
binaryfire Oct 3, 2026
5f9df98
Resolve Inertia props nested in JsonSerializable objects
binaryfire Oct 3, 2026
d6924e7
Load deferred props from groups named after global functions
binaryfire Oct 3, 2026
4c43569
Escape HTML tags in the initial Inertia page JSON
binaryfire Oct 3, 2026
3da0ea2
Document RequestException on the HTTP client's verb methods
binaryfire Oct 3, 2026
066cc4e
Configure Inertia SSR requests through the HTTP client
binaryfire Oct 3, 2026
7c8a55c
Redact Inertia DevTools URLs without rewriting them
binaryfire Oct 3, 2026
4d0c2c5
Surface Inertia DevTools index failures and limit tabless entries
binaryfire Oct 3, 2026
c5f445f
Keep Inertia DevTools share sources with the shared props
binaryfire Oct 3, 2026
c677fd7
Show Inertia match-on props as deep merges only when they merge
binaryfire Oct 3, 2026
a2fa588
Add read-only sessions
binaryfire Oct 3, 2026
1826b22
Sync the test client's session with read-only requests and redirects
binaryfire Oct 3, 2026
08f2dd8
Read the session without saving it on Inertia DevTools entry routes
binaryfire Oct 3, 2026
ca67efb
Assert once-shared middleware keys record no share source
binaryfire Oct 3, 2026
9c924a5
Inject the DevTools id before a closing body tag of any case
binaryfire Oct 3, 2026
893f648
Write DevTools entry files under the index lock
binaryfire Oct 3, 2026
63e9c30
Record DevTools pages and entry structure as they were sent
binaryfire Oct 3, 2026
9f0ed14
Pin the prune interval in the DevTools prune test
binaryfire Oct 3, 2026
16cd5c5
Note that the DevTools gate does not limit recording
binaryfire Oct 3, 2026
7f3f772
Redact DevTools entry URLs whole under a configured key
binaryfire Oct 3, 2026
ea40c98
Drop a stale Content-Length after adding the DevTools tag
binaryfire Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
3 changes: 2 additions & 1 deletion src/api-client/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/broadcasting/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/concurrency/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"require": {
"php": "^8.4",
"hypervel/collections": "^0.4",
"hypervel/console": "^0.4",
"hypervel/container": "^0.4",
"hypervel/context": "^0.4",
Expand Down
2 changes: 1 addition & 1 deletion src/console/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/contracts/src/Session/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
31 changes: 31 additions & 0 deletions src/docs/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<a name="inertia-starter-kits"></a>
### Starter Kits

Expand Down
20 changes: 20 additions & 0 deletions src/docs/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -458,6 +459,25 @@ Route::post('/profile', function () {
})->block();
```

<a name="read-only-sessions"></a>
## 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();
```

<a name="configuring-the-session-cookie"></a>
## Configuring the Session Cookie

Expand Down
15 changes: 14 additions & 1 deletion src/docs/vite.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion src/foundation/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/foundation/src/Http/Middleware/PreventRequestForgery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Expand Down
28 changes: 17 additions & 11 deletions src/foundation/src/Testing/Concerns/MakesHttpRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Read-only requests still copy session-backed authentication state into the test parent, so Auth::logout() can persist in the test even though the session was not saved. Preserve the parent state for session-backed guards when synchronizing a read-only request.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/foundation/src/Testing/Concerns/MakesHttpRequests.php, line 577:

<comment>Read-only requests still copy session-backed authentication state into the test parent, so `Auth::logout()` can persist in the test even though the session was not saved. Preserve the parent state for session-backed guards when synchronizing a read-only request.</comment>

<file context>
@@ -570,10 +572,14 @@ protected function syncRequestContextToParent(Request $request): void
-        );
+        // 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),
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping this as is. The test client matches Laravel here: guard state carries across a test's requests whether or not the session was saved, so a logout makes later requests guests. Only session data depends on whether the session is saved. Logging in or out on a read-only route can't persist anything in a real app, so it isn't a pattern the test client needs to model.

$synchronizer->syncSnapshotToParent(
$this->sessionContextSnapshot($request),
$this->sessionContextKeys()
);
}

$synchronizer->syncContextKeysToParent($this->authenticationContextKeys());
}
Expand Down
1 change: 1 addition & 0 deletions src/grpc/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/http/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 10 additions & 5 deletions src/http/src/Client/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,37 +226,42 @@ 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(),
default => throw new InvalidArgumentException('HTTP fake response header values must be scalar, null, Hypervel Stringable, or arrays of scalar, null, or Hypervel Stringable values.'),
};
}

$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(),
default => throw new InvalidArgumentException('HTTP fake response header values must be scalar, null, Hypervel Stringable, or arrays of scalar, null, or Hypervel Stringable values.'),
};
}

return $headers;
return $normalized;
}

/**
Expand Down
Loading
Loading