Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.1.2"
".": "0.1.1"
}
9 changes: 0 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,6 @@ All notable changes to `dancycodes/hyper` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.2](https://github.com/dancycodes/hyper/compare/v0.1.1...v0.1.2) (2025-10-24)


### Bug Fixes

* Consider forgetting locked signals in HyperResponse forget method ([bcd8162](https://github.com/dancycodes/hyper/commit/bcd81629db06d2fd5ae6a4f53071993b599e1069))
* Improve CSRF and error handling in hyper.js ([6908aca](https://github.com/dancycodes/hyper/commit/6908acac4a63ccb127d1c6ba8d26f83d07e28fca))
* Update attribute syntax and refactor hyper.js internals to match Datastar RC6 ([54a8b3f](https://github.com/dancycodes/hyper/commit/54a8b3fbe661a66431a4670c6f1f91c02784d433))

## [0.1.1](https://github.com/dancycodes/hyper/compare/v0.1.0...v0.1.1) (2025-10-21)


Expand Down
24 changes: 16 additions & 8 deletions src/Http/HyperRedirect.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public function withErrors(mixed $errors): self
*/
public function toResponse($request = null): \Symfony\Component\HttpFoundation\StreamedResponse
{
// Flash session data with guaranteed write completion
// Persist session flash data with dual-aging compensation
if (!empty($this->flashData)) {
if (!session()->isStarted()) {
session()->start();
Expand All @@ -128,11 +128,14 @@ public function toResponse($request = null): \Symfony\Component\HttpFoundation\S
session()->flash((string) $key, $value);
}

// Force immediate session write and close to ensure data persists
// This prevents race conditions where redirect occurs before flash
// data is fully committed to session storage
// Persist flash data to storage, aging from _flash.new to _flash.old
session()->save();
session_write_close();

// Compensate for dual session save by reflashing data back to _flash.new
// StartSession middleware's terminate() will execute final save(), properly
// aging flash data for consumption by the next request. Without reflash(),
// terminate() would age data twice (_flash.old to deleted)
session()->reflash();
}

$safeUrl = json_encode($this->url, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
Expand Down Expand Up @@ -300,7 +303,7 @@ public function intended(string $default = '/'): self
*/
public function forceReload(bool $forceReload = false): \Symfony\Component\HttpFoundation\StreamedResponse
{
// Flash session data with guaranteed write completion
// Persist session flash data with dual-aging compensation
if (!empty($this->flashData)) {
if (!session()->isStarted()) {
session()->start();
Expand All @@ -310,9 +313,14 @@ public function forceReload(bool $forceReload = false): \Symfony\Component\HttpF
session()->flash((string) $key, $value);
}

// Force immediate session write and close to ensure data persists
// Persist flash data to storage, aging from _flash.new to _flash.old
session()->save();
session_write_close();

// Compensate for dual session save by reflashing data back to _flash.new
// StartSession middleware's terminate() will execute final save(), properly
// aging flash data for consumption by the next request. Without reflash(),
// terminate() would age data twice (_flash.old to deleted)
session()->reflash();
}

$reloadParam = $forceReload ? 'true' : 'false';
Expand Down
231 changes: 231 additions & 0 deletions src/Http/Middleware/ConvertRedirectsForDatastar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<?php

namespace Dancycodes\Hyper\Http\Middleware;

use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
* Convert Redirects For Datastar Middleware
*
* Automatically intercepts standard Laravel redirect responses in Datastar requests and
* converts them into Hyper-compatible SSE responses with client-side JavaScript redirects.
* This middleware resolves session flash data persistence issues that occur when fetch API's
* automatic redirect following consumes flash data before the intended page receives it.
*
* Session Flash Data Persistence Challenge:
*
* Standard Laravel redirects with Datastar requests create a double-navigation scenario:
* - Initial fetch request receives 302 redirect response with flash data
* - Fetch API automatically follows redirect, consuming flash data in process
* - responseInterceptor detects redirect and triggers window.location navigation
* - Final destination page receives empty flash data as it was already consumed
*
* Middleware Resolution Strategy:
*
* This middleware converts redirect responses (301, 302, 303, 307, 308) into Server-Sent
* Events responses containing JavaScript that executes client-side navigation via
* window.location. This prevents fetch's automatic redirect following, ensuring flash data
* persists correctly for the destination page. The middleware uses session()->reflash() to
* preserve flash data through Laravel's session aging mechanism, accounting for the dual
* save() calls from both response generation and StartSession middleware's terminate method.
*
* The middleware registers globally via HyperServiceProvider, activating only for requests
* with the Datastar-Request header and skipping responses already processed by HyperRedirect.
* No application-level configuration is required for operation.
*
* @see \Dancycodes\Hyper\Http\HyperRedirect
* @see \Dancycodes\Hyper\HyperServiceProvider
*/
class ConvertRedirectsForDatastar
{
/**
* Handle an incoming request
*
* Processes responses from the application pipeline, intercepting redirect responses
* for Datastar requests and converting them to SSE responses with JavaScript navigation.
* Non-Datastar requests, non-redirect responses, and already-processed Hyper responses
* pass through unchanged.
*/
public function handle(Request $request, Closure $next): Response
{
/** @var \Symfony\Component\HttpFoundation\Response $response */
$response = $next($request);

// Pass through non-Datastar requests unchanged
if (!$request->hasHeader('Datastar-Request')) {
return $response;
}

// Skip responses already processed by HyperRedirect to prevent double-conversion
if ($response->headers->has('X-Hyper-Response')) {
return $response;
}

// Pass through non-redirect responses unchanged
if (!$this->isRedirect($response)) {
return $response;
}

// Convert redirect to Hyper SSE response with JavaScript navigation
return $this->convertToHyperRedirect($response, $request);
}

/**
* Determine if response represents a redirect
*
* Checks for RedirectResponse instance or HTTP status codes indicating redirects
* (201 Created with Location, 301 Moved Permanently, 302 Found, 303 See Other,
* 307 Temporary Redirect, 308 Permanent Redirect).
*
* @return bool True if response is a redirect, false otherwise
*/
protected function isRedirect(Response $response): bool
{
return $response instanceof RedirectResponse ||
in_array($response->getStatusCode(), [201, 301, 302, 303, 307, 308]);
}

/**
* Convert redirect response to Hyper SSE response with JavaScript navigation
*
* Transforms a standard HTTP redirect response into a Server-Sent Events response
* containing JavaScript that performs client-side navigation. This approach prevents
* fetch API's automatic redirect following, ensuring session flash data persists
* correctly through the navigation cycle.
*
* The method generates a JavaScript snippet using setTimeout with 200ms delay (matching
* HyperRedirect timing) to allow session persistence operations to complete before
* navigation executes. Flash data preservation is handled via session()->reflash() to
* account for Laravel's dual session save mechanism during request termination.
*
* @param \Symfony\Component\HttpFoundation\Response $response Original redirect response
* @param \Illuminate\Http\Request $request Current request instance
*
* @return \Symfony\Component\HttpFoundation\StreamedResponse SSE response with navigation script
*/
protected function convertToHyperRedirect(Response $response, Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
{
$redirectUrl = $this->getRedirectUrl($response);

// Build SSE response with JavaScript navigation script
$hyperResponse = app('hyper.response');
$safeUrl = json_encode($redirectUrl, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$script = "setTimeout(() => window.location.href = {$safeUrl}, 200)";

/** @var \Symfony\Component\HttpFoundation\StreamedResponse $hyperStreamedResponse */
$hyperStreamedResponse = $hyperResponse
->js($script, ['autoRemove' => true])
->toResponse($request);

// Preserve session flash data and attach session cookie to new response
$this->preserveFlashData($hyperStreamedResponse);

// Transfer cookies from original response to maintain state (CSRF tokens, etc.)
foreach ($response->headers->getCookies() as $cookie) {
$hyperStreamedResponse->headers->setCookie($cookie);
}

return $hyperStreamedResponse;
}

/**
* Extract target URL from redirect response
*
* Attempts to retrieve redirect destination URL from Location header or
* RedirectResponse::getTargetUrl(). Falls back to current request URL if
* redirect target cannot be determined from response.
*
* @param \Symfony\Component\HttpFoundation\Response $response Redirect response to process
*
* @return string Target URL for navigation
*/
protected function getRedirectUrl(Response $response): string
{
// Prefer Location header as primary source for redirect URL
$location = $response->headers->get('Location');
if ($location) {
return $location;
}

// Extract from RedirectResponse instance if available
if ($response instanceof RedirectResponse) {
return $response->getTargetUrl();
}

// Fallback to current URL when redirect target cannot be determined
return request()->url();
}

/**
* Preserve session flash data through navigation cycle
*
* Manages session flash data persistence by utilizing Laravel's reflash() method to
* counteract the dual aging mechanism inherent in Datastar redirect handling. When
* redirect()->with() executes, it ages flash data from _flash.new to _flash.old.
* Without intervention, StartSession middleware's terminate() method would age the
* data a second time (_flash.old to deleted), rendering it unavailable to the
* destination page.
*
* The reflash() call moves flash data back to _flash.new in memory, allowing
* terminate()'s save() operation to properly age it for consumption by the next
* request. This method also manually creates and attaches the session cookie to
* the new SSE response, as the response object bypasses Laravel's standard cookie
* attachment mechanisms.
*
* @param \Symfony\Component\HttpFoundation\StreamedResponse $response SSE response requiring session cookie
*/
protected function preserveFlashData(\Symfony\Component\HttpFoundation\StreamedResponse $response): void
{
if (!session()->isStarted()) {
session()->start();
}

// Move flash data from _flash.old back to _flash.new to prevent double-aging
// This ensures terminate() method properly ages data for next request availability
session()->reflash();

// Manually attach session cookie as SSE response bypasses standard cookie handling
// StartSession middleware's terminate() will persist session data to storage
$sessionCookie = $this->createSessionCookie();
$response->headers->setCookie($sessionCookie);
}

/**
* Create session cookie from current session state and configuration
*
* Constructs a session cookie using Laravel's cookie() helper with configuration
* values from config/session.php. This manual cookie creation is necessary because
* the SSE response object doesn't participate in Laravel's standard session cookie
* attachment process that occurs in StartSession middleware.
*
* @return \Symfony\Component\HttpFoundation\Cookie Session cookie with current session ID
*/
protected function createSessionCookie(): \Symfony\Component\HttpFoundation\Cookie
{
/** @var array<string, mixed> $config */
$config = config('session', []);

$name = is_string($config['cookie'] ?? null) && $config['cookie'] !== '' ? $config['cookie'] : 'laravel_session';
$lifetime = is_int($config['lifetime'] ?? null) ? $config['lifetime'] : 120;
$path = is_string($config['path'] ?? null) ? $config['path'] : '/';
$domain = is_string($config['domain'] ?? null) ? $config['domain'] : null;
$secure = is_bool($config['secure'] ?? null) ? $config['secure'] : false;
$httpOnly = is_bool($config['http_only'] ?? null) ? $config['http_only'] : true;
$sameSite = is_string($config['same_site'] ?? null) ? $config['same_site'] : 'lax';

return cookie(
$name,
session()->getId(),
$lifetime,
$path,
$domain,
$secure,
$httpOnly,
false,
$sameSite
);
}
}
23 changes: 23 additions & 0 deletions src/HyperServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ public function boot(): void
$this->registerBase64ValidationRules();
$this->registerRouteDiscovery();
$this->registerSignalsDirective();
$this->registerRedirectConversionMiddleware();
}

/**
Expand Down Expand Up @@ -562,4 +563,26 @@ protected function registerSignalsDirective(): void
return "<?php echo app('hyper.signals.directive')->render({$rewrittenExpression}); ?>";
});
}

/**
* Register middleware to automatically convert redirects for Datastar requests
*
* Registers the ConvertRedirectsForDatastar middleware globally to intercept
* standard Laravel redirect responses and convert them into Hyper-style SSE
* responses for Datastar requests. This prevents the double-navigation issue
* where flash data is consumed during fetch API's automatic redirect following.
*
* This middleware runs for ALL requests but only modifies responses for
* Datastar requests that result in redirects, ensuring zero impact on normal
* HTTP request/response cycles.
*/
protected function registerRedirectConversionMiddleware(): void
{
/** @var \Illuminate\Contracts\Http\Kernel $kernel */
$kernel = $this->app->make(\Illuminate\Contracts\Http\Kernel::class);

// Push middleware to the global middleware stack
// This ensures it runs for all requests and can intercept responses
$kernel->pushMiddleware(\Dancycodes\Hyper\Http\Middleware\ConvertRedirectsForDatastar::class);
}
}
Loading
Loading