Skip to content

Commit 575250d

Browse files
committed
Copy SymfonyHttpServer from LaravelHttpServer
1 parent da70fce commit 575250d

1 file changed

Lines changed: 349 additions & 0 deletions

File tree

src/Drivers/SymfonyHttpServer.php

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pest\Browser\Drivers;
6+
7+
use Amp\ByteStream\ReadableResourceStream;
8+
use Amp\Http\Server\DefaultErrorHandler;
9+
use Amp\Http\Server\HttpServer as AmpHttpServer;
10+
use Amp\Http\Server\HttpServerStatus;
11+
use Amp\Http\Server\Request as AmpRequest;
12+
use Amp\Http\Server\RequestHandler\ClosureRequestHandler;
13+
use Amp\Http\Server\Response;
14+
use Amp\Http\Server\SocketHttpServer;
15+
use Illuminate\Contracts\Debug\ExceptionHandler;
16+
use Illuminate\Contracts\Http\Kernel as HttpKernel;
17+
use Illuminate\Foundation\Testing\Concerns\WithoutExceptionHandlingHandler;
18+
use Illuminate\Http\Request;
19+
use Illuminate\Routing\UrlGenerator;
20+
use Illuminate\Support\Uri;
21+
use Pest\Browser\Contracts\HttpServer;
22+
use Pest\Browser\Exceptions\ServerNotFoundException;
23+
use Pest\Browser\Execution;
24+
use Pest\Browser\GlobalState;
25+
use Psr\Log\NullLogger;
26+
use Symfony\Component\Mime\MimeTypes;
27+
use Throwable;
28+
29+
/**
30+
* @internal
31+
*
32+
* @codeCoverageIgnore
33+
*/
34+
final class SymfonyHttpServer implements HttpServer
35+
{
36+
/**
37+
* The underlying socket server instance, if any.
38+
*/
39+
private ?AmpHttpServer $socket = null;
40+
41+
/**
42+
* The original asset URL, if set.
43+
*/
44+
private ?string $originalAssetUrl = null;
45+
46+
/**
47+
* The last throwable that occurred during the server's execution.
48+
*/
49+
private ?Throwable $lastThrowable = null;
50+
51+
/**
52+
* Creates a new laravel http server instance.
53+
*/
54+
public function __construct(
55+
public readonly string $host,
56+
public readonly int $port,
57+
) {
58+
//
59+
}
60+
61+
/**
62+
* Destroy the server instance and stop listening for incoming connections.
63+
*/
64+
public function __destruct()
65+
{
66+
// @codeCoverageIgnoreStart
67+
// $this->stop();
68+
}
69+
70+
/**
71+
* Rewrite the given URL to match the server's host and port.
72+
*/
73+
public function rewrite(string $url): string
74+
{
75+
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
76+
$url = mb_ltrim($url, '/');
77+
78+
$url = '/'.$url;
79+
}
80+
81+
$parts = parse_url($url);
82+
$queryParameters = [];
83+
$path = $parts['path'] ?? '/';
84+
parse_str($parts['query'] ?? '', $queryParameters);
85+
86+
return (string) Uri::of($this->url())
87+
->withPath($path)
88+
->withQuery($queryParameters);
89+
}
90+
91+
/**
92+
* Start the server and listen for incoming connections.
93+
*/
94+
public function start(): void
95+
{
96+
if ($this->socket instanceof AmpHttpServer) {
97+
return;
98+
}
99+
100+
$this->socket = $server = SocketHttpServer::createForDirectAccess(new NullLogger());
101+
102+
$server->expose("{$this->host}:{$this->port}");
103+
$server->start(
104+
new ClosureRequestHandler($this->handleRequest(...)),
105+
new DefaultErrorHandler(),
106+
);
107+
}
108+
109+
/**
110+
* Stop the server and close all connections.
111+
*/
112+
public function stop(): void
113+
{
114+
// @codeCoverageIgnoreStart
115+
if ($this->socket instanceof AmpHttpServer) {
116+
$this->flush();
117+
118+
if ($this->socket instanceof AmpHttpServer) {
119+
if (in_array($this->socket->getStatus(), [HttpServerStatus::Starting, HttpServerStatus::Started], true)) {
120+
$this->socket->stop();
121+
}
122+
123+
$this->socket = null;
124+
}
125+
}
126+
}
127+
128+
/**
129+
* Flush pending requests and close all connections.
130+
*/
131+
public function flush(): void
132+
{
133+
if (! $this->socket instanceof AmpHttpServer) {
134+
return;
135+
}
136+
137+
Execution::instance()->tick();
138+
139+
$this->lastThrowable = null;
140+
}
141+
142+
/**
143+
* Bootstrap the server and set the application URL.
144+
*/
145+
public function bootstrap(): void
146+
{
147+
$this->start();
148+
149+
$url = $this->url();
150+
151+
config(['app.url' => $url]);
152+
153+
config(['cors.paths' => ['*']]);
154+
155+
if (app()->bound('url')) {
156+
$urlGenerator = app('url');
157+
158+
assert($urlGenerator instanceof UrlGenerator);
159+
160+
$this->setOriginalAssetUrl($urlGenerator->asset(''));
161+
162+
$urlGenerator->useOrigin($url);
163+
$urlGenerator->useAssetOrigin($url);
164+
$urlGenerator->forceScheme('http');
165+
}
166+
}
167+
168+
/**
169+
* Get the last throwable that occurred during the server's execution.
170+
*/
171+
public function lastThrowable(): ?Throwable
172+
{
173+
return $this->lastThrowable;
174+
}
175+
176+
/**
177+
* Throws the last throwable if it should be thrown.
178+
*
179+
* @throws Throwable
180+
*/
181+
public function throwLastThrowableIfNeeded(): void
182+
{
183+
if (! $this->lastThrowable instanceof Throwable) {
184+
return;
185+
}
186+
187+
$exceptionHandler = app(ExceptionHandler::class);
188+
189+
if ($exceptionHandler instanceof WithoutExceptionHandlingHandler) {
190+
throw $this->lastThrowable;
191+
}
192+
}
193+
194+
/**
195+
* Get the public path for the given path.
196+
*/
197+
private function url(): string
198+
{
199+
if (! $this->socket instanceof AmpHttpServer) {
200+
throw new ServerNotFoundException('The HTTP server is not running.');
201+
}
202+
203+
return sprintf('http://%s:%d', $this->host, $this->port);
204+
}
205+
206+
/**
207+
* Sets the original asset URL.
208+
*/
209+
private function setOriginalAssetUrl(string $url): void
210+
{
211+
$this->originalAssetUrl = mb_rtrim($url, '/');
212+
}
213+
214+
/**
215+
* Handle the incoming request and return a response.
216+
*/
217+
private function handleRequest(AmpRequest $request): Response
218+
{
219+
GlobalState::flush();
220+
221+
if (Execution::instance()->isWaiting() === false) {
222+
Execution::instance()->tick();
223+
}
224+
225+
$uri = $request->getUri();
226+
$path = in_array($uri->getPath(), ['', '0'], true) ? '/' : $uri->getPath();
227+
$query = $uri->getQuery() ?? ''; // @phpstan-ignore-line
228+
$fullPath = $path.($query !== '' ? '?'.$query : '');
229+
$absoluteUrl = mb_rtrim($this->url(), '/').$fullPath;
230+
231+
$filepath = public_path($path);
232+
if (file_exists($filepath) && ! is_dir($filepath)) {
233+
return $this->asset($filepath);
234+
}
235+
236+
$kernel = app()->make(HttpKernel::class);
237+
238+
$contentType = $request->getHeader('content-type') ?? '';
239+
$method = mb_strtoupper($request->getMethod());
240+
$rawBody = (string) $request->getBody();
241+
$parameters = [];
242+
if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'application/x-www-form-urlencoded')) {
243+
parse_str($rawBody, $parameters);
244+
}
245+
246+
$symfonyRequest = Request::create(
247+
$absoluteUrl,
248+
$method,
249+
$parameters,
250+
$request->getCookies(),
251+
[], // @TODO files...
252+
[], // @TODO server variables...
253+
$rawBody
254+
);
255+
256+
$symfonyRequest->headers->add($request->getHeaders());
257+
258+
$debug = config('app.debug');
259+
260+
try {
261+
config(['app.debug' => false]);
262+
263+
$response = $kernel->handle($laravelRequest = Request::createFromBase($symfonyRequest));
264+
} catch (Throwable $e) {
265+
$this->lastThrowable = $e;
266+
267+
throw $e;
268+
} finally {
269+
config(['app.debug' => $debug]);
270+
}
271+
272+
$kernel->terminate($laravelRequest, $response);
273+
274+
if (property_exists($response, 'exception') && $response->exception !== null) {
275+
assert($response->exception instanceof Throwable);
276+
277+
$this->lastThrowable = $response->exception;
278+
}
279+
280+
$content = $response->getContent();
281+
282+
if ($content === false) {
283+
try {
284+
ob_start();
285+
$response->sendContent();
286+
} finally {
287+
// @phpstan-ignore-next-line
288+
$content = mb_trim(ob_get_clean());
289+
}
290+
}
291+
292+
return new Response(
293+
$response->getStatusCode(),
294+
$response->headers->all(), // @phpstan-ignore-line
295+
$content,
296+
);
297+
}
298+
299+
/**
300+
* Return an asset response.
301+
*/
302+
private function asset(string $filepath): Response
303+
{
304+
$file = fopen($filepath, 'r');
305+
306+
if ($file === false) {
307+
return new Response(404);
308+
}
309+
310+
$mimeTypes = new MimeTypes();
311+
$contentType = $mimeTypes->getMimeTypes(pathinfo($filepath, PATHINFO_EXTENSION));
312+
313+
$contentType = $contentType[0] ?? 'application/octet-stream';
314+
315+
if (str_ends_with($filepath, '.js')) {
316+
$temporaryStream = fopen('php://temp', 'r+');
317+
assert($temporaryStream !== false, 'Failed to open temporary stream.');
318+
319+
// @phpstan-ignore-next-line
320+
$temporaryContent = fread($file, (int) filesize($filepath));
321+
322+
assert($temporaryContent !== false, 'Failed to open temporary stream.');
323+
324+
$content = $this->rewriteAssetUrl($temporaryContent);
325+
326+
fwrite($temporaryStream, $content);
327+
328+
rewind($temporaryStream);
329+
330+
$file = $temporaryStream;
331+
}
332+
333+
return new Response(200, [
334+
'Content-Type' => $contentType,
335+
], new ReadableResourceStream($file));
336+
}
337+
338+
/**
339+
* Rewrite the asset URL in the given content.
340+
*/
341+
private function rewriteAssetUrl(string $content): string
342+
{
343+
if ($this->originalAssetUrl === null) {
344+
return $content;
345+
}
346+
347+
return str_replace($this->originalAssetUrl, $this->url(), $content);
348+
}
349+
}

0 commit comments

Comments
 (0)