diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 97ae5fdb..ca7a9bfb 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -99,12 +99,12 @@ public function start(): void return; } - $this->socket = $server = SocketHttpServer::createForDirectAccess(new NullLogger()); + $this->socket = $server = SocketHttpServer::createForDirectAccess(new NullLogger); $server->expose("{$this->host}:{$this->port}"); $server->start( new ClosureRequestHandler($this->handleRequest(...)), - new DefaultErrorHandler(), + new DefaultErrorHandler, ); } @@ -323,27 +323,29 @@ private function asset(string $filepath): Response return new Response(404); } - $mimeTypes = new MimeTypes(); + $mimeTypes = new MimeTypes; $contentType = $mimeTypes->getMimeTypes(pathinfo($filepath, PATHINFO_EXTENSION)); $contentType = $contentType[0] ?? 'application/octet-stream'; if (str_ends_with($filepath, '.js')) { - $temporaryStream = fopen('php://temp', 'r+'); - assert($temporaryStream !== false, 'Failed to open temporary stream.'); + // Use file_get_contents instead of fread() because fread() is + // limited to 8192 bytes when running inside Amp's event loop. + fclose($file); + $rawContent = file_get_contents($filepath); - // @phpstan-ignore-next-line - $temporaryContent = fread($file, (int) filesize($filepath)); + assert($rawContent !== false, 'Failed to read file content.'); - assert($temporaryContent !== false, 'Failed to open temporary stream.'); + $content = $this->rewriteAssetUrl($rawContent); - $content = $this->rewriteAssetUrl($temporaryContent); + // Build the Response without passing headers to the constructor, + // because the constructor calls setBody() first (which auto-sets + // content-length for string bodies) then setHeaders() (which + // clears ALL headers and only keeps what was passed in). + $response = new Response(200, [], $content); + $response->setHeader('Content-Type', $contentType); - fwrite($temporaryStream, $content); - - rewind($temporaryStream); - - $file = $temporaryStream; + return $response; } return new Response(200, [ diff --git a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index 7491d6a6..5622fbf0 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -2,7 +2,10 @@ declare(strict_types=1); +use Amp\ByteStream\ReadableBuffer; +use Amp\ByteStream\ReadableResourceStream; use Illuminate\Http\Request; +use Pest\Browser\Drivers\LaravelHttpServer; use function Pest\Laravel\withServerVariables; use function Pest\Laravel\withUnencryptedCookie; @@ -21,6 +24,57 @@ ->assertDontSee('http://localhost'); }); +it('serves JS assets as string body instead of stream', function (): void { + // Regression test: the old implementation used fread() + ReadableResourceStream + // which could truncate JS files at 8192 bytes inside Amp's event loop. + // The fix uses file_get_contents() and returns the content as a string body + // (ReadableBuffer) instead of a stream (ReadableResourceStream). + $path = public_path('string-body-test.js'); + @file_put_contents($path, "console.log('ok');"); + + $server = new LaravelHttpServer('127.0.0.1', 0); + + $method = new ReflectionMethod($server, 'asset'); + + $response = $method->invoke($server, $path); + + $body = $response->getBody(); + + expect($body)->toBeInstanceOf(ReadableBuffer::class) + ->and($body)->not->toBeInstanceOf(ReadableResourceStream::class); +}); + +it('sets content-length header on JS asset responses', function (): void { + // Regression test: the old implementation used ReadableResourceStream which + // caused Response::setBody() to remove the content-length header. + $jsContent = "console.log('content-length test');"; + $path = public_path('cl-test.js'); + @file_put_contents($path, $jsContent); + + $server = new LaravelHttpServer('127.0.0.1', 0); + + $method = new ReflectionMethod($server, 'asset'); + + $response = $method->invoke($server, $path); + + expect($response->getHeader('content-length'))->toBe((string) strlen($jsContent)) + ->and($response->getHeader('content-type'))->toBe('text/javascript'); +}); + +it('serves large JS files completely', function (): void { + // Regression test: fread() can return fewer bytes than requested inside + // Amp's event loop (limited to 8192 per read). Verify files >8KB are served intact. + $padding = str_repeat("// padding line to exceed 8192 bytes\n", 300); + $jsContent = $padding."window.__pest_large_file_marker = 'COMPLETE';"; + + @file_put_contents(public_path('large.js'), $jsContent); + + Route::get('/large-js-test', fn (): string => ''); + + visit('/large-js-test') + ->assertScript('window.__pest_large_file_marker', 'COMPLETE'); +}); + it('includes cookies set in the test', function (): void { Route::get('/cookies', fn (Request $request): array => $request->cookies->all());