feat: Inertia.js adapter for Hypervel#366
Merged
binaryfire merged 83 commits into0.4from Apr 13, 2026
Merged
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Port of the official
inertiajs/inertia-laraveladapter, 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
InertiaStatevalue object stored inCoroutineContextunder 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$resolvedInstancefor near-zero resolution cost and each method call does oneCoroutineContext::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 - oneCoroutine::id()+ one array lookup per call.Gateway extension point
ResponseFactory::disableSsr()andwithoutSsr()continue delegating toapp(Gateway::class)rather than writing to state directly. This preserves the extension point - if someone swaps theGateway::classbinding with a custom implementation their customdisable()/except()logic is called. OnlyHttpGateway's implementations know aboutInertiaState.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 newPendingRequest, newHandlerStack, and newGuzzleHttp\Clienton every dispatch. That's three object allocations per request, plus a freshCurlFactorythat can't reuse curl handles from previous requests.This has been replaced with a single static
GuzzleHttp\Clientcached for the worker lifetime. Nobase_uri- full URLs are passed each call so both production and Vite hot mode work correctly. The client is configured withcookies => false(no state leakage between requests),http_errors => false(we handle status codes ourselves), and timeouts from config.This gives us:
CurlFactorykeeps curl handles warm across requests, letting libcurl reuse connections to the SSR sidecarThe
useTestingClient()static method allows tests to inject a GuzzleMockHandlerclient 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()didfile_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 7file_exists()calls per SSR dispatch to find the bundle file. Now computed once on first call.Both have
flushState()methods for test cleanup viaAfterEachTestSubscriber.Container fast path for zero-arg closures
Inertia resolves prop closures via
App::call(), which does a full DI resolution pipeline -ReflectionFunctionallocation, parameter inspection, dependency injection. For zero-parameter closures likefn () => 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 entireBoundMethodchain. UsesgetNumberOfParameters()(notgetNumberOfRequiredParameters()) because optional typed parameters still get DI-injected byBoundMethodand 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 viaINERTIA_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 onlyClosure, but Laravel acceptscallable. Widened tocallablewithClosure::fromCallable()for thebindTopath.Http\Client\Response::$decoded- was typedarraybutjson_decode()returnsmixed. Changed tomixedwith abool $hasDecodedflag to correctly cache empty arrays, scalars, and null without re-decoding.Http\Client\Response::decode()- return type wasarray|object|nullbutjson_decode()returns scalars too. Changed tomixed.AssertableJsonString::jsonSearchStrings()- typedstring $keybut receivesintfrom numeric array keys. Changed tostring|int.Contracts\Http\Kernel- addedgetMiddlewareGroups()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$baseUrlproperty derived fromconfig('app.url')at setUp time. Orchestra Testbench has this; our testbench was missing it.Tests
All upstream tests ported plus new Hypervel-specific tests:
InertiaStateis isolated between concurrent coroutinesflushState()resetSSR gateway tests use raw Guzzle
MockHandlerinstead ofHttp::fake()since the gateway now uses a dedicated Guzzle client.