Skip to content

feat: Inertia.js adapter for Hypervel#366

Merged
binaryfire merged 83 commits into0.4from
feat/inertia
Apr 13, 2026
Merged

feat: Inertia.js adapter for Hypervel#366
binaryfire merged 83 commits into0.4from
feat/inertia

Conversation

@binaryfire
Copy link
Copy Markdown
Collaborator

@binaryfire binaryfire commented Apr 13, 2026

Port of the official inertiajs/inertia-laravel adapter, redesigned for Swoole's long-running worker model. The entire request state model, SSR transport, and caching strategy have been rethought for coroutine-safe and high-concurrency.

Swoole safety

Per-request state isolation via coroutine Context

The upstream adapter stores mutable per-request state on singleton service classes - shared props, root view, asset version, SSR flags, etc. In Laravel's per-request lifecycle this works fine. In Swoole, singletons persist across requests and that state leaks between concurrent coroutines.

In our adapter, all per-request state lives in a single InertiaState value object stored in CoroutineContext under one key. ResponseFactory, HttpGateway, and the SSR dispatch logic all read/write from this state object. The service classes themselves are stateless singletons - the facade caches them in $resolvedInstance for near-zero resolution cost and each method call does one CoroutineContext::getOrSet() to access the coroutine-local state.

I considered scoped() bindings and request attributes as alternatives. scoped() breaks with facade caching (the first coroutine's instance gets cached process-globally in $resolvedInstance). Request attributes add unnecessary indirection through the container and ParameterBag. Direct Context access is the most performant option - one Coroutine::id() + one array lookup per call.

Gateway extension point

ResponseFactory::disableSsr() and withoutSsr() continue delegating to app(Gateway::class) rather than writing to state directly. This preserves the extension point - if someone swaps the Gateway::class binding with a custom implementation their custom disable()/except() logic is called. Only HttpGateway's implementations know about InertiaState.

Performance optimisations

Dedicated raw Guzzle client for SSR dispatch

The SSR render path is the hottest code in the adapter - it runs on every page load when SSR is enabled. The upstream uses Http::post() which creates a new PendingRequest, new HandlerStack, and new GuzzleHttp\Client on every dispatch. That's three object allocations per request, plus a fresh CurlFactory that can't reuse curl handles from previous requests.

This has been replaced with a single static GuzzleHttp\Client cached for the worker lifetime. No base_uri - full URLs are passed each call so both production and Vite hot mode work correctly. The client is configured with cookies => false (no state leakage between requests), http_errors => false (we handle status codes ourselves), and timeouts from config.

This gives us:

  • No per-request transport setup allocations (PendingRequest, HandlerStack, Client, CurlFactory)
  • Guzzle's internal CurlFactory keeps curl handles warm across requests, letting libcurl reuse connections to the SSR sidecar
  • No Http facade middleware overhead on the hot path

The useTestingClient() static method allows tests to inject a Guzzle MockHandler client without touching the production code path.

Worker-lifetime static caching

Two filesystem operations that happen on every request in upstream are now cached for the worker lifetime:

  • Asset version hashing - Middleware::version() did file_exists() + hash_file() on the Vite manifest on every request. The manifest doesn't change between deploys (workers restart on deploy), so the result is computed once and cached.

  • SSR bundle detection - BundleDetector::detect() checked up to 7 file_exists() calls per SSR dispatch to find the bundle file. Now computed once on first call.

Both have flushState() methods for test cleanup via AfterEachTestSubscriber.

Container fast path for zero-arg closures

Inertia resolves prop closures via App::call(), which does a full DI resolution pipeline - ReflectionFunction allocation, parameter inspection, dependency injection. For zero-parameter closures like fn () => Auth::user() (the most common case for Inertia props) this is unnecessary overhead.

Added a fast path in Container::call() that detects zero-parameter closures and calls them directly, skipping the entire BoundMethod chain. Uses getNumberOfParameters() (not getNumberOfRequiredParameters()) because optional typed parameters still get DI-injected by BoundMethod and skipping that would change behavior.

This benefits all zero-arg closures across the framework, not just Inertia - event listeners, queue closures, route actions etc.

SSR circuit breaker

When the SSR server goes down, every request still attempts an HTTP call, waits for the timeout, fails, and falls back to client-side rendering. At 20k req/sec, that's 20k failed HTTP calls per second, each consuming a coroutine for the timeout duration.

Added a per-worker circuit breaker: after the first failure, SSR is skipped for a configurable backoff period (default 5 seconds). This is a static property on HttpGateway - process-global, not per-coroutine - so one failure protects the entire worker. The backoff is configurable via INERTIA_SSR_BACKOFF.

SSR timeouts

The upstream Http::post() used no explicit timeouts. Added configurable connect and read timeouts (defaults: 2s connect, 5s read) to prevent coroutines from hanging indefinitely on an unresponsive SSR server.

Other fixes found during porting

These are bugs or missing features in the existing Hypervel codebase that were exposed by the Inertia test suite:

  • BladeCompiler::directive() - accepted only Closure, but Laravel accepts callable. Widened to callable with Closure::fromCallable() for the bindTo path.
  • Http\Client\Response::$decoded - was typed array but json_decode() returns mixed. Changed to mixed with a bool $hasDecoded flag to correctly cache empty arrays, scalars, and null without re-decoding.
  • Http\Client\Response::decode() - return type was array|object|null but json_decode() returns scalars too. Changed to mixed.
  • AssertableJsonString::jsonSearchStrings() - typed string $key but receives int from numeric array keys. Changed to string|int.
  • Contracts\Http\Kernel - added getMiddlewareGroups() to the interface. The concrete kernel has it, packages need it, and accepting the interface while requiring the concrete is a design smell.
  • Testbench\TestCase - added $baseUrl property derived from config('app.url') at setUp time. Orchestra Testbench has this; our testbench was missing it.

Tests

All upstream tests ported plus new Hypervel-specific tests:

  • Coroutine isolation - verifies InertiaState is isolated between concurrent coroutines
  • SSR circuit breaker - verifies backoff behavior and flushState() reset
  • SSR client reuse - verifies the static Guzzle client is memoized
  • Cookie safety - verifies no cookie leakage between SSR requests
  • Timeout config - verifies configured timeouts reach the Guzzle client
  • Version caching - verifies worker-lifetime caching and flush
  • Bundle detection caching - same pattern

SSR gateway tests use raw Guzzle MockHandler instead of Http::fake() since the gateway now uses a dedicated Guzzle client.

Packages like Inertia need to inspect middleware groups to resolve
the correct middleware for exception rendering. The method exists on
the concrete Kernel but was missing from the contract.
The parameter was incorrectly narrowed to Closure. Laravel accepts
any callable, and packages like Inertia register directives using
static method arrays like [Directive::class, 'compile'].
The decoded JSON cache used a truthy check and array type, which
broke for valid falsy values (empty arrays, 0, false) and scalar
JSON responses. Changed to mixed property with explicit boolean
flag to track decode state.
Cover empty array caching, scalar value caching, null caching for
invalid JSON, and decodeUsing() cache invalidation.
Numeric array keys from assertJsonMissing(['value']) are int, not
string. The parameter was incorrectly narrowed to string only.
Derived from config('app.url') after app bootstrap. Provides the
base application URL for tests that need to construct full URLs
in assertions, matching Orchestra Testbench's convention.
Add PSR-4 autoload, helpers.php to files, replace entry, and
service provider to extra.hypervel.providers.
All request-scoped Inertia state lives here instead of on singleton
service classes. Stored in CoroutineContext under a single key for
complete isolation between concurrent requests.
Stateless gateway with per-request state in InertiaState Context.
Includes SSR timeouts, worker-local circuit breaker, and direct
event dispatch (avoids double construction).
@binaryfire binaryfire merged commit 16966c1 into 0.4 Apr 13, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant