2026-02-26 14:39:04 +08:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Coroutine\Server;
|
|
|
|
|
|
|
|
|
|
use Kiri\Di\Inject\Container;
|
|
|
|
|
use Kiri\Router\Constrict\ConstrictRequest;
|
|
|
|
|
use Swoole\Coroutine\Http\Server;
|
|
|
|
|
use Swoole\Http\Request;
|
|
|
|
|
use Swoole\Http\Response;
|
|
|
|
|
use Swoole\WebSocket\CloseFrame;
|
|
|
|
|
use Throwable;
|
|
|
|
|
|
2026-04-04 10:29:39 +08:00
|
|
|
abstract class Websocket
|
2026-02-26 14:39:04 +08:00
|
|
|
{
|
|
|
|
|
#[Container(Transport::class)]
|
|
|
|
|
public Transport $transport;
|
|
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
public Config $config;
|
2026-02-26 14:39:04 +08:00
|
|
|
|
2026-04-04 10:29:39 +08:00
|
|
|
public function start(Config $config): void
|
2026-02-26 14:48:00 +08:00
|
|
|
{
|
2026-04-04 10:29:39 +08:00
|
|
|
$this->config = $config;
|
2026-04-17 14:41:13 +08:00
|
|
|
$this->configureRuntime();
|
2026-04-04 10:29:39 +08:00
|
|
|
$this->onStart();
|
2026-04-17 14:41:13 +08:00
|
|
|
|
2026-04-04 10:29:39 +08:00
|
|
|
$server = new Server($config->host, $config->port, false);
|
2026-04-17 14:41:13 +08:00
|
|
|
$this->configureServer($server);
|
|
|
|
|
|
|
|
|
|
$server->handle($config->normalizePath($config->websocketPath), fn(Request $request, Response $ws) => $this->handler($request, $ws));
|
|
|
|
|
if ($config->exposeOnlineLists) {
|
|
|
|
|
$server->handle($config->normalizePath($config->onlineListPath), fn(Request $request, Response $ws) => $this->getLists($request, $ws));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 10:29:39 +08:00
|
|
|
echo 'websocket server start at ' . $config->host . ':' . $config->port . PHP_EOL;
|
|
|
|
|
$server->start();
|
2026-02-26 14:48:00 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 10:29:39 +08:00
|
|
|
public function getLists(Request $request, Response $ws): void
|
2026-02-26 14:39:04 +08:00
|
|
|
{
|
|
|
|
|
try {
|
2026-04-17 14:41:13 +08:00
|
|
|
if (!$this->config->exposeOnlineLists) {
|
|
|
|
|
throw new \RuntimeException('Not found.', 404);
|
2026-02-26 14:39:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
$psr7Request = ConstrictRequest::builder($request);
|
|
|
|
|
if (!$this->authorizeOnlineListRequest($psr7Request)) {
|
|
|
|
|
throw new \RuntimeException('Unauthorized.', 401);
|
2026-02-26 14:39:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
$payload = json_encode($this->transport->getLists(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
|
if ($payload === false) {
|
|
|
|
|
$payload = '[]';
|
2026-04-04 10:29:39 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
@$ws->header('Content-Type', 'application/json; charset=utf-8');
|
|
|
|
|
$ws->end($payload);
|
|
|
|
|
} catch (Throwable $throwable) {
|
|
|
|
|
$this->throwable($ws, $throwable, false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function handler(Request $request, Response $ws): void
|
|
|
|
|
{
|
|
|
|
|
$upgraded = false;
|
|
|
|
|
$userId = null;
|
|
|
|
|
$fd = null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$psr7Request = $this->authorizeConnection($request);
|
|
|
|
|
|
2026-04-04 10:29:39 +08:00
|
|
|
if (!$ws->upgrade()) {
|
2026-04-17 14:41:13 +08:00
|
|
|
throw new \RuntimeException('Connection upgrade to websocket failed.', 500);
|
2026-02-26 14:39:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
$upgraded = true;
|
2026-04-04 10:29:39 +08:00
|
|
|
$userId = $psr7Request->getAuthority()->getUniqueId();
|
2026-04-17 14:41:13 +08:00
|
|
|
|
|
|
|
|
$connection = new Struct($psr7Request->getAuthority(), $request, $ws);
|
|
|
|
|
$fd = $connection->fd;
|
|
|
|
|
|
|
|
|
|
$this->transport->add($userId, $connection);
|
|
|
|
|
defer(function () use ($userId, $fd) {
|
|
|
|
|
$this->transport->remove($userId, $fd);
|
2026-04-04 10:29:39 +08:00
|
|
|
});
|
2026-02-26 14:39:04 +08:00
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
@$ws->push((string)$userId);
|
2026-04-04 10:29:39 +08:00
|
|
|
|
|
|
|
|
$this->onBeforeMessageLoop();
|
2026-02-26 14:39:04 +08:00
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
$frame = $ws->recv();
|
2026-04-17 14:41:13 +08:00
|
|
|
if ($frame === '' || $frame === false || $frame === null) {
|
2026-02-26 14:39:04 +08:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
if ($frame instanceof CloseFrame) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$payload = (string)($frame->data ?? '');
|
|
|
|
|
if ($payload === 'close') {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->assertPayloadSize($payload);
|
|
|
|
|
$this->transport->touch($userId, $fd);
|
|
|
|
|
$this->onMessage($psr7Request, $payload);
|
2026-02-26 14:39:04 +08:00
|
|
|
}
|
|
|
|
|
} catch (Throwable $throwable) {
|
2026-04-17 14:41:13 +08:00
|
|
|
$this->throwable($ws, $throwable, $upgraded);
|
2026-02-26 14:39:04 +08:00
|
|
|
} finally {
|
2026-04-17 14:41:13 +08:00
|
|
|
if ($userId !== null) {
|
2026-04-04 10:29:39 +08:00
|
|
|
$this->onDisconnect($userId);
|
2026-02-26 14:39:04 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 10:29:39 +08:00
|
|
|
abstract public function onBeforeMessageLoop(): void;
|
|
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
protected function authorizeConnection(Request $request): ConstrictRequest
|
|
|
|
|
{
|
|
|
|
|
$psr7Request = ConstrictRequest::builder($request);
|
|
|
|
|
if (!$psr7Request->hasQuery($this->config->authKey)) {
|
|
|
|
|
throw new \RuntimeException('Missing auth parameter.', 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$this->onConnected($psr7Request) || $psr7Request->getAuthority() === null) {
|
|
|
|
|
throw new \RuntimeException('Unauthorized.', 401);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $psr7Request;
|
|
|
|
|
}
|
2026-04-04 10:29:39 +08:00
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
protected function authorizeOnlineListRequest(ConstrictRequest $request): bool
|
2026-04-04 10:29:39 +08:00
|
|
|
{
|
2026-04-17 14:41:13 +08:00
|
|
|
if (!$request->hasQuery($this->config->authKey)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-04-04 10:29:39 +08:00
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
return $this->canViewOnlineLists($request) && $request->getAuthority() !== null;
|
|
|
|
|
}
|
2026-04-04 10:29:39 +08:00
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
protected function canViewOnlineLists(ConstrictRequest $request): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->onConnected($request);
|
2026-04-04 10:29:39 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
private function configureRuntime(): void
|
|
|
|
|
{
|
|
|
|
|
if ($this->config->maxCoroutine > 0 && class_exists(\Swoole\Coroutine::class)) {
|
|
|
|
|
\Swoole\Coroutine::set([
|
|
|
|
|
'max_coroutine' => $this->config->maxCoroutine,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-26 14:44:26 +08:00
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
private function configureServer(Server $server): void
|
|
|
|
|
{
|
|
|
|
|
if (!method_exists($server, 'set')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-26 14:44:26 +08:00
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
$settings = [];
|
|
|
|
|
if ($this->config->maxPackageSize > 0) {
|
|
|
|
|
$settings['package_max_length'] = $this->config->maxPackageSize;
|
|
|
|
|
}
|
|
|
|
|
if ($this->config->maxFrameSize > 0) {
|
|
|
|
|
$settings['websocket_max_frame_size'] = $this->config->maxFrameSize;
|
|
|
|
|
}
|
2026-02-26 14:39:04 +08:00
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
if ($settings !== []) {
|
|
|
|
|
$server->set($settings);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-26 14:39:04 +08:00
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
private function assertPayloadSize(string $payload): void
|
|
|
|
|
{
|
|
|
|
|
$size = strlen($payload);
|
2026-02-26 14:39:04 +08:00
|
|
|
|
2026-04-17 14:41:13 +08:00
|
|
|
if ($this->config->maxPackageSize > 0 && $size > $this->config->maxPackageSize) {
|
|
|
|
|
throw new \RuntimeException('Payload too large.', 413);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($this->config->maxFrameSize > 0 && $size > $this->config->maxFrameSize) {
|
|
|
|
|
throw new \RuntimeException('Frame too large.', 413);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function throwable(Response $ws, Throwable $throwable, bool $upgraded): void
|
|
|
|
|
{
|
|
|
|
|
$status = $throwable->getCode();
|
|
|
|
|
if (!in_array($status, [400, 401, 403, 404, 409, 413, 500], true)) {
|
|
|
|
|
$status = 500;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$message = match ($status) {
|
|
|
|
|
400 => 'Bad Request',
|
|
|
|
|
401 => 'Unauthorized',
|
|
|
|
|
403 => 'Forbidden',
|
|
|
|
|
404 => 'Not Found',
|
|
|
|
|
409 => 'Conflict',
|
|
|
|
|
413 => 'Payload Too Large',
|
|
|
|
|
default => 'Internal Server Error',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
error_log(sprintf(
|
|
|
|
|
'[%s] %s in %s:%d',
|
|
|
|
|
static::class,
|
|
|
|
|
$throwable->getMessage(),
|
|
|
|
|
$throwable->getFile(),
|
|
|
|
|
$throwable->getLine(),
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
if ($upgraded) {
|
|
|
|
|
@$ws->close();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@$ws->setStatusCode($status);
|
|
|
|
|
$ws->end($message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
abstract public function onConnected(ConstrictRequest $request): bool;
|
|
|
|
|
|
|
|
|
|
abstract public function onStart(): void;
|
2026-02-26 14:39:04 +08:00
|
|
|
|
2026-04-04 10:29:39 +08:00
|
|
|
abstract public function onMessage(ConstrictRequest $psr7Request, string $frame): void;
|
2026-02-26 14:39:04 +08:00
|
|
|
|
2026-04-04 10:29:39 +08:00
|
|
|
abstract public function onDisconnect(int $userId): void;
|
2026-02-26 14:39:04 +08:00
|
|
|
}
|