Repository navigation
Expand file tree
/
Copy pathHttpServer.php
More file actions
156 lines (149 loc) · 7.05 KB
/
Copy pathHttpServer.php
File metadata and controls
156 lines (149 loc) · 7.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
<?php
declare(strict_types=1);
namespace Loom;
/**
* HTTP 宿主,对应 ASP.NET Core 的 Kestrel 加请求管道入口。每个请求打开一个 ServiceScope,在其中跑 HttpKernel,结束时释放作用域。
*
* /assets/ 路径直接交给 StaticFiles。HttpError 写成对应状态的问题 JSON,其它异常写成 500 并经 Diary 记为 http.exception。HEAD 不写正文。
*
* Origami 在请求虚拟机里执行回调,请求内对脚本对象的写入只对该请求可见;路由表和单例要在 run() 之前准备好。
*
* @example $app->services->get(HttpServer::class)->run();
*/
final class HttpServer
{
private StaticFiles $assets;
private \Loom\Filesystem\FilesystemPublicServer $publicFiles;
public function __construct(
private readonly ServiceProvider $services,
private readonly HttpKernel $kernel,
private readonly Network $network,
private readonly HttpServerOptions $options,
Config $config,
\Loom\Filesystem\Filesystem $filesystem,
) {
$this->assets = new StaticFiles($config->path('public'));
$this->publicFiles = new \Loom\Filesystem\FilesystemPublicServer($filesystem->servedDisks());
}
/**
* 校验地址和限制后阻塞监听。地址缺失或不是 host:port 时抛出 LogicException,限制不合法时抛出 InvalidArgumentException。
*/
public function run(): void
{
if (!isset($this->options->address) || $this->options->address === '') {
throw new \LogicException('HTTP options require address');
}
$address = parse_url('http://' . $this->options->address);
if (!is_array($address) || !isset($address['host'], $address['port'])) {
throw new \LogicException('HTTP address requires host:port');
}
$this->network->limits('websocket');
$limits = $this->network->limits('http');
$server = new \Net\Http\Server($address['host'], $address['port']);
$server->configure($limits);
$listener = $this;
$server->any(static function ($native, $writer) use ($listener): void {
$listener->handle($native, $writer);
});
echo 'Loom: http://' . $this->options->address . "\n";
\Loom\Native\serveHttp($server, $this->options->address, json_encode($limits, JSON_THROW_ON_ERROR));
}
/**
* 在新作用域里处理一次原生请求,响应写入 writer。
*
* @param mixed $native 原生 HTTP 请求。
* @param mixed $writer 原生响应写入器。
*/
public function handle(mixed $native, mixed $writer): void
{
$listener = $this;
$context = \Loom\Monitoring\MonitoringContext::http($native->method(), $native->path())
->withIncomingCorrelation(\Loom\Monitoring\HttpCorrelation::fromServer($_SERVER));
$this->services->withMonitoredScope($context, static function (ServiceScope $scope) use ($listener, $native, $writer): void {
$listener->respond($scope, $native, $writer);
});
}
/**
* @internal 由 handle() 在请求作用域里调用。
*/
public function respond(ServiceScope $scope, mixed $native, mixed $writer): void
{
$transport = new HttpTransport($this->network, $native, $writer);
$scope->get(RequestContext::class)->attach($transport);
$monitoring = $scope->tryGet(\Loom\Monitoring\Monitoring::class);
$started = microtime(true);
$status = 500;
$request = null;
try {
set_time_limit(10);
$body = file_get_contents('php://input');
if ($body === false) {
throw new HttpError(400, 'Cannot read request body');
}
$request = RequestFactory::fromNative($native, $_SERVER, $body, $this->network->limits('http')['maxBodyBytes']);
$request = $request->withAttribute('loom.filesystem', $scope->get(\Loom\Filesystem\Filesystem::class));
$monitoring?->recordHttpInput([
'method' => $request->method(),
'path' => $request->path(),
'body_bytes' => strlen($body),
]);
$public = $this->publicFiles->handle($request);
$response = $public
?? (str_starts_with($request->path(), '/assets/')
? $this->assets->handle($request)
: $this->kernel->handle($request, $scope));
$status = $response->toArray()['status'];
$monitoring?->recordHttpOutput([
'status' => $status,
'body_bytes' => strlen((string)$response->toArray()['body']),
'duration_ms' => round(max(0, microtime(true) - $started) * 1000, 3),
]);
$transport->send($response, $request->method() === 'HEAD');
} catch (HttpError $error) {
$status = $error->status;
$monitoring?->recordHttpOutput([
'status' => $status,
'duration_ms' => round(max(0, microtime(true) - $started) * 1000, 3),
]);
$transport->send(Response::json(['type' => 'about:blank', 'title' => $error->getMessage(), 'status' => $error->status], $error->status), $native->method() === 'HEAD');
} catch (\Throwable $error) {
$monitoring?->recordHttpOutput([
'status' => 500,
'duration_ms' => round(max(0, microtime(true) - $started) * 1000, 3),
]);
$correlation = $scope->tryGet(\Loom\Monitoring\CorrelationId::class)?->tryCurrent();
$exceptionFields = [
'exception_class' => $error::class,
'message' => $error->getMessage(),
'file' => $error->getFile(),
'line' => $error->getLine(),
'stack' => $error->getTraceAsString(),
'method' => $request?->method() ?? $native->method(),
'path' => $request?->path() ?? $native->path(),
'correlation_id' => $correlation,
];
try {
$diary = $scope->tryGet(\Loom\Logging\Diary::class);
if ($diary !== null) {
$diary->record('error', 'http.exception', $exceptionFields);
} else {
$stream = @fopen('php://stderr', 'w');
if ($stream !== false) {
try {
@fwrite(
$stream,
$exceptionFields['exception_class'] . ': ' . $exceptionFields['message'] . "\n",
);
} finally {
fclose($stream);
}
}
}
$monitoring?->recordEvent('http.exception', $exceptionFields);
} catch (\Throwable) {
// 异常日志失败不得阻止发送 500 响应。
}
$transport->send(Response::json(['type' => 'about:blank', 'title' => 'Internal Server Error', 'status' => 500], 500), $native->method() === 'HEAD');
}
}
}