├── infection.json.dist ├── .php-cs-fixer.dist.php ├── test-autobahn ├── config │ └── fuzzingclient.json ├── server.php └── report.php ├── src ├── WebsocketClientFactory.php ├── Internal │ ├── UpgradeErrorHandler.php │ └── SendQueue.php ├── AllowOriginAcceptor.php ├── WebsocketClientHandler.php ├── WebsocketAcceptor.php ├── Rfc6455ClientFactory.php ├── WebsocketClientGateway.php ├── Rfc6455Acceptor.php ├── WebsocketGateway.php └── Websocket.php ├── composer-require-check.json ├── psalm.xml ├── LICENSE ├── composer.json ├── examples ├── broadcast-server │ ├── server.php │ └── public │ │ └── index.html └── stackexchange-questions │ ├── server.php │ └── public │ └── index.html ├── .github └── workflows │ └── ci.yml └── README.md /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 5, 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "logs": { 9 | "text": "infection.log" 10 | } 11 | } -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | getFinder() 5 | ->in(__DIR__ . '/examples') 6 | ->in(__DIR__ . '/src') 7 | ->in(__DIR__ . '/test') 8 | ->in(__DIR__ . '/test-autobahn'); 9 | 10 | $config->setCacheFile(__DIR__ . '/.php_cs.cache'); 11 | 12 | return $config; 13 | -------------------------------------------------------------------------------- /test-autobahn/config/fuzzingclient.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": [ 3 | { 4 | "agent": "amphp-websocket-server", 5 | "url": "ws://127.0.0.1:9001/" 6 | } 7 | ], 8 | "outdir": "./reports/servers", 9 | "cases": [ 10 | "*" 11 | ], 12 | "exclude-cases": [ 13 | "12.2.*", 14 | "12.3.*", 15 | "12.4.*", 16 | "12.5.*", 17 | "13.2.*", 18 | "13.3.*", 19 | "13.4.*", 20 | "13.5.*", 21 | "13.6.*", 22 | "13.7.*" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/WebsocketClientFactory.php: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Internal/UpgradeErrorHandler.php: -------------------------------------------------------------------------------- 1 | 'text/plain; charset=utf-8', 'connection' => 'close'], 23 | body: \sprintf('%d %s', $status, $reason ?? HttpStatus::getReason($status)), 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2023 amphp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/AllowOriginAcceptor.php: -------------------------------------------------------------------------------- 1 | $allowOrigins 19 | */ 20 | public function __construct( 21 | private readonly array $allowOrigins, 22 | private readonly ErrorHandler $errorHandler = new Internal\UpgradeErrorHandler(), 23 | private readonly WebsocketAcceptor $acceptor = new Rfc6455Acceptor(), 24 | ) { 25 | } 26 | 27 | public function handleHandshake(Request $request): Response 28 | { 29 | if (!\in_array($request->getHeader('origin'), $this->allowOrigins, true)) { 30 | return $this->errorHandler->handleError(HttpStatus::FORBIDDEN, 'Origin forbidden', $request); 31 | } 32 | 33 | return $this->acceptor->handleHandshake($request); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/WebsocketClientHandler.php: -------------------------------------------------------------------------------- 1 | receive()) { 18 | * $payload = $message->buffer(); 19 | * $client->sendText('Message of length ' . strlen($payload) . ' received'); 20 | * } 21 | * ``` 22 | * 23 | * @param WebsocketClient $client The websocket client connection. 24 | * @param Request $request The HTTP request that instigated the connection. 25 | * @param Response $response The HTTP response sent to client to accept the connection. 26 | */ 27 | public function handleClient(WebsocketClient $client, Request $request, Response $response): void; 28 | } 29 | -------------------------------------------------------------------------------- /src/WebsocketAcceptor.php: -------------------------------------------------------------------------------- 1 | receive()) { 32 | if ($message->isBinary()) { 33 | $client->sendBinary($message->buffer()); 34 | } else { 35 | $client->sendText($message->buffer()); 36 | } 37 | } 38 | } 39 | }, 40 | clientFactory: new Rfc6455ClientFactory( 41 | heartbeatQueue: null, 42 | rateLimit: null, 43 | parserFactory: new Rfc6455ParserFactory( 44 | validateUtf8: true, 45 | messageSizeLimit: \PHP_INT_MAX, 46 | frameSizeLimit: \PHP_INT_MAX, 47 | ), 48 | ), 49 | ); 50 | 51 | $server->expose(new Socket\InternetAddress("127.0.0.1", 9001)); 52 | 53 | $server->start($websocket, new DefaultErrorHandler()); 54 | 55 | $input = Amp\ByteStream\getStdin()->read(); 56 | 57 | $server->stop(); 58 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amphp/websocket-server", 3 | "homepage": "https://github.com/amphp/websocket-server", 4 | "description": "Websocket server for Amp's HTTP server.", 5 | "keywords": [ 6 | "http", 7 | "server", 8 | "websocket" 9 | ], 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Daniel Lowrey", 14 | "email": "rdlowrey@php.net" 15 | }, 16 | { 17 | "name": "Bob Weinand" 18 | }, 19 | { 20 | "name": "Niklas Keller", 21 | "email": "me@kelunik.com" 22 | }, 23 | { 24 | "name": "Aaron Piotrowski", 25 | "email": "aaron@trowski.com" 26 | } 27 | ], 28 | "require": { 29 | "php": ">=8.1", 30 | "amphp/amp": "^3", 31 | "amphp/byte-stream": "^2.1", 32 | "amphp/http": "^2.1", 33 | "amphp/http-server": "^3.2", 34 | "amphp/socket": "^2.2", 35 | "amphp/websocket": "^2", 36 | "psr/log": "^1|^2|^3", 37 | "revolt/event-loop": "^1" 38 | }, 39 | "require-dev": { 40 | "amphp/http-client": "^5", 41 | "amphp/http-server-static-content": "^2", 42 | "amphp/http-server-router": "^2", 43 | "amphp/log": "^2", 44 | "amphp/php-cs-fixer-config": "^2", 45 | "amphp/phpunit-util": "^3", 46 | "amphp/websocket-client": "^2", 47 | "league/climate": "^3", 48 | "phpunit/phpunit": "^9", 49 | "psalm/phar": "^5.18", 50 | "colinodell/psr-testlogger": "^1.2" 51 | }, 52 | "suggest": { 53 | "ext-zlib": "Required for compression" 54 | }, 55 | "autoload": { 56 | "psr-4": { 57 | "Amp\\Websocket\\Server\\": "src" 58 | } 59 | }, 60 | "autoload-dev": { 61 | "psr-4": { 62 | "Amp\\Websocket\\Server\\": "test" 63 | } 64 | }, 65 | "scripts": { 66 | "check": [ 67 | "@code-style", 68 | "@test" 69 | ], 70 | "code-style": "php-cs-fixer fix -v --diff", 71 | "test": "@php -dzend.assertions=1 -dassert.exception=1 ./vendor/bin/phpunit --coverage-text" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test-autobahn/report.php: -------------------------------------------------------------------------------- 1 | red("Could not find autobahn test results json file"); 11 | exit(1); 12 | } 13 | 14 | $report = file_get_contents(REPORT_PATH); 15 | $report = json_decode($report, true); 16 | 17 | if (!isset($report["amphp-websocket-server"])) { 18 | $climate->red("Could not find result set for amphp-websocket-server"); 19 | exit(1); 20 | } 21 | 22 | $report = $report["amphp-websocket-server"]; 23 | 24 | uksort($report, 'version_compare'); 25 | 26 | $climate->out("Autobahn test report:"); 27 | 28 | $passed = 0; 29 | $nonstrict = 0; 30 | $failed = 0; 31 | $total = 0; 32 | 33 | foreach ($report as $testNumber => $result) { 34 | $message = sprintf("%9s: %s ", $testNumber, $result["behavior"]); 35 | 36 | switch ($result["behavior"]) { 37 | case "OK": 38 | $passed++; 39 | $climate->green($message); 40 | break; 41 | 42 | case "NON-STRICT": 43 | $nonstrict++; 44 | $climate->yellow($message); 45 | break; 46 | 47 | case "FAILED": 48 | $failed++; 49 | $climate->red($message); 50 | break; 51 | 52 | case "UNIMPLEMENTED": 53 | $total++; 54 | $climate->darkGray($message); 55 | break; 56 | 57 | default: 58 | $total++; 59 | $climate->blue($message); 60 | break; 61 | } 62 | } 63 | 64 | $climate->br(); 65 | 66 | $other = $total; 67 | $total += $passed + $nonstrict + $failed; 68 | $counts = sprintf( 69 | "%d Total / %d Passed / %d Non-strict / %d Failed / %d Unimplemented or Informational", 70 | $total, 71 | $passed, 72 | $nonstrict, 73 | $failed, 74 | $other 75 | ); 76 | 77 | if ($failed) { 78 | $climate->backgroundRed()->black(sprintf(" Tests failed: %s ", $counts)); 79 | } elseif ($nonstrict) { 80 | $climate->backgroundYellow()->black(sprintf(" Tests passed: %s ", $counts)); 81 | } else { 82 | $climate->backgroundGreen()->black(sprintf(" Tests passed: %s ", $counts)); 83 | } 84 | 85 | exit($failed === 0 ? 0 : 1); 86 | -------------------------------------------------------------------------------- /examples/broadcast-server/server.php: -------------------------------------------------------------------------------- 1 | setFormatter(new ConsoleFormatter); 29 | $logger = new Logger('server'); 30 | $logger->pushHandler($logHandler); 31 | 32 | $server = SocketHttpServer::createForDirectAccess($logger); 33 | 34 | $server->expose(new Socket\InternetAddress('127.0.0.1', 1337)); 35 | $server->expose(new Socket\InternetAddress('[::1]', 1337)); 36 | 37 | $acceptor = new AllowOriginAcceptor( 38 | ['http://localhost:1337', 'http://127.0.0.1:1337', 'http://[::1]:1337'], 39 | ); 40 | 41 | $clientHandler = new class implements WebsocketClientHandler { 42 | public function __construct( 43 | private readonly WebsocketGateway $gateway = new WebsocketClientGateway(), 44 | ) { 45 | } 46 | 47 | public function handleClient(WebsocketClient $client, Request $request, Response $response): void 48 | { 49 | $this->gateway->addClient($client); 50 | 51 | while ($message = $client->receive()) { 52 | $this->gateway->broadcastText(sprintf('%d: %s', $client->getId(), (string) $message))->ignore(); 53 | } 54 | } 55 | }; 56 | 57 | $websocket = new Websocket( 58 | httpServer: $server, 59 | logger: $logger, 60 | acceptor: $acceptor, 61 | clientHandler: $clientHandler, 62 | compressionFactory: new Rfc7692CompressionFactory(), 63 | ); 64 | 65 | $errorHandler = new DefaultErrorHandler(); 66 | 67 | $router = new Router($server, $logger, $errorHandler); 68 | $router->addRoute('GET', '/broadcast', $websocket); 69 | $router->setFallback(new DocumentRoot($server, $errorHandler, __DIR__ . '/public')); 70 | 71 | $server->start($router, $errorHandler); 72 | 73 | // Await SIGINT or SIGTERM to be received. 74 | $signal = Amp\trapSignal([\SIGINT, \SIGTERM]); 75 | 76 | $logger->info(sprintf("Received signal %d, stopping HTTP server", $signal)); 77 | 78 | $server->stop(); 79 | -------------------------------------------------------------------------------- /src/Internal/SendQueue.php: -------------------------------------------------------------------------------- 1 | */ 20 | private \SplQueue $writeQueue; 21 | 22 | /** @var Suspension|null */ 23 | private ?Suspension $suspension = null; 24 | 25 | public function __construct(WebsocketClient $client) 26 | { 27 | $this->writeQueue = $writeQueue = new \SplQueue; 28 | 29 | $suspension = &$this->suspension; 30 | EventLoop::queue(static function () use ($writeQueue, $client, &$suspension): void { 31 | while (!$client->isClosed()) { 32 | if ($writeQueue->isEmpty()) { 33 | $suspension = EventLoop::getSuspension(); 34 | if (!$suspension->suspend()) { 35 | return; 36 | } 37 | } 38 | 39 | self::dequeue($writeQueue, $client); 40 | } 41 | }); 42 | } 43 | 44 | private static function dequeue(\SplQueue $writeQueue, WebsocketClient $client): void 45 | { 46 | while (!$writeQueue->isEmpty() && !$client->isClosed()) { 47 | /** 48 | * @var DeferredFuture $deferredFuture 49 | * @var string $data 50 | * @var bool $binary 51 | */ 52 | [$deferredFuture, $data, $binary] = $writeQueue->dequeue(); 53 | 54 | try { 55 | $binary ? $client->sendBinary($data) : $client->sendText($data); 56 | $deferredFuture->complete(); 57 | } catch (\Throwable $exception) { 58 | $deferredFuture->error($exception); 59 | while (!$writeQueue->isEmpty()) { 60 | [$deferredFuture] = $writeQueue->dequeue(); 61 | $deferredFuture->error($exception); 62 | } 63 | return; 64 | } 65 | } 66 | } 67 | 68 | public function __destruct() 69 | { 70 | $this->suspension?->resume(false); 71 | $this->suspension = null; 72 | } 73 | 74 | /** 75 | * @return Future 76 | */ 77 | public function send(string $data, bool $binary): Future 78 | { 79 | $deferredFuture = new DeferredFuture(); 80 | $this->writeQueue->enqueue([$deferredFuture, $data, $binary]); 81 | $this->suspension?->resume(true); 82 | $this->suspension = null; 83 | 84 | return $deferredFuture->getFuture(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Rfc6455ClientFactory.php: -------------------------------------------------------------------------------- 1 | getResource(); 47 | 48 | // Setting via stream API doesn't work and TLS streams are not supported 49 | // once TLS is enabled 50 | $isNodelayChangeSupported = \is_resource($socketResource) 51 | && !isset(\stream_get_meta_data($socketResource)["crypto"]) 52 | && \extension_loaded('sockets') 53 | && \defined('TCP_NODELAY'); 54 | 55 | if ($isNodelayChangeSupported && ($sock = \socket_import_stream($socketResource))) { 56 | \set_error_handler(static fn () => true); 57 | try { 58 | // error suppression for sockets which don't support the option 59 | \socket_set_option($sock, \SOL_TCP, \TCP_NODELAY, 1); 60 | } finally { 61 | \restore_error_handler(); 62 | } 63 | } 64 | } 65 | 66 | return new Rfc6455Client( 67 | socket: $socket, 68 | masked: false, 69 | parserFactory: $this->parserFactory, 70 | compressionContext: $compressionContext, 71 | heartbeatQueue: $this->heartbeatQueue, 72 | rateLimit: $this->rateLimit, 73 | frameSplitThreshold: $this->frameSplitThreshold, 74 | closePeriod: $this->closePeriod, 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/WebsocketClientGateway.php: -------------------------------------------------------------------------------- 1 | Indexed by client ID. */ 17 | private array $clients = []; 18 | 19 | /** @var array Senders indexed by client ID. */ 20 | private array $senders = []; 21 | 22 | public function addClient(WebsocketClient $client): void 23 | { 24 | $id = $client->getId(); 25 | $this->clients[$id] = $client; 26 | $this->senders[$id] = new Internal\SendQueue($client); 27 | 28 | $client->onClose(function () use ($id): void { 29 | unset($this->clients[$id], $this->senders[$id]); 30 | }); 31 | } 32 | 33 | public function broadcastText(string $data, array $excludedClientIds = []): Future 34 | { 35 | return $this->broadcastData($data, false, $excludedClientIds); 36 | } 37 | 38 | public function broadcastBinary(string $data, array $excludedClientIds = []): Future 39 | { 40 | return $this->broadcastData($data, true, $excludedClientIds); 41 | } 42 | 43 | private function broadcastData(string $data, bool $binary, array $excludedClientIds = []): Future 44 | { 45 | $exclusionLookup = \array_flip($excludedClientIds); 46 | 47 | $futures = []; 48 | foreach ($this->senders as $id => $sender) { 49 | if (isset($exclusionLookup[$id])) { 50 | continue; 51 | } 52 | $futures[$id] = $sender->send($data, $binary); 53 | } 54 | 55 | return async(Future\awaitAll(...), $futures); 56 | } 57 | 58 | public function multicastText(string $data, array $clientIds): Future 59 | { 60 | return $this->multicastData($data, false, $clientIds); 61 | } 62 | 63 | public function multicastBinary(string $data, array $clientIds): Future 64 | { 65 | return $this->multicastData($data, true, $clientIds); 66 | } 67 | 68 | private function multicastData(string $data, bool $binary, array $clientIds): Future 69 | { 70 | $futures = []; 71 | foreach ($clientIds as $id) { 72 | $sender = $this->senders[$id] ?? null; 73 | if (!$sender) { 74 | continue; 75 | } 76 | $futures[$id] = $sender->send($data, $binary); 77 | } 78 | 79 | return async(Future\awaitAll(...), $futures); 80 | } 81 | 82 | public function sendText(string $data, int $clientId): Future 83 | { 84 | return $this->sendData($data, false, $clientId); 85 | } 86 | 87 | public function sendBinary(string $data, int $clientId): Future 88 | { 89 | return $this->sendData($data, true, $clientId); 90 | } 91 | 92 | private function sendData(string $data, bool $binary, int $clientId): Future 93 | { 94 | $sender = $this->senders[$clientId] ?? null; 95 | if (!$sender) { 96 | return Future::complete(); 97 | } 98 | 99 | return $sender->send($data, $binary); 100 | } 101 | 102 | public function getClients(): array 103 | { 104 | return $this->clients; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /examples/broadcast-server/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Broadcast Example 6 | 77 | 78 | 79 |
    80 | 81 | 113 | 114 | -------------------------------------------------------------------------------- /src/Rfc6455Acceptor.php: -------------------------------------------------------------------------------- 1 | getMethod() !== 'GET') { 25 | $response = $this->errorHandler->handleError(HttpStatus::METHOD_NOT_ALLOWED, request: $request); 26 | $response->setHeader('allow', 'GET'); 27 | return $response; 28 | } 29 | 30 | if ($request->getProtocolVersion() !== '1.1') { 31 | $response = $this->errorHandler->handleError(HttpStatus::HTTP_VERSION_NOT_SUPPORTED, request: $request); 32 | $response->setHeader('upgrade', 'websocket'); 33 | return $response; 34 | } 35 | 36 | if ('' !== $request->getBody()->buffer()) { 37 | return $this->errorHandler->handleError(HttpStatus::BAD_REQUEST, request: $request); 38 | } 39 | 40 | $hasUpgradeWebsocket = false; 41 | foreach ($request->getHeaderArray('upgrade') as $value) { 42 | if (\strcasecmp($value, 'websocket') === 0) { 43 | $hasUpgradeWebsocket = true; 44 | break; 45 | } 46 | } 47 | if (!$hasUpgradeWebsocket) { 48 | $response = $this->errorHandler->handleError(HttpStatus::UPGRADE_REQUIRED, request: $request); 49 | $response->setHeader('upgrade', 'websocket'); 50 | return $response; 51 | } 52 | 53 | $hasConnectionUpgrade = false; 54 | foreach ($request->getHeaderArray('connection') as $value) { 55 | $values = \array_map('trim', \explode(',', $value)); 56 | 57 | foreach ($values as $token) { 58 | if (\strcasecmp($token, 'upgrade') === 0) { 59 | $hasConnectionUpgrade = true; 60 | break; 61 | } 62 | } 63 | } 64 | 65 | if (!$hasConnectionUpgrade) { 66 | $reason = 'Bad Request: "Connection: Upgrade" header required'; 67 | $response = $this->errorHandler->handleError(HttpStatus::UPGRADE_REQUIRED, $reason, $request); 68 | $response->setHeader('upgrade', 'websocket'); 69 | return $response; 70 | } 71 | 72 | if (!$acceptKey = $request->getHeader('sec-websocket-key')) { 73 | $reason = 'Bad Request: "Sec-Websocket-Key" header required'; 74 | return $this->errorHandler->handleError(HttpStatus::BAD_REQUEST, $reason, $request); 75 | } 76 | 77 | if (!\in_array('13', $request->getHeaderArray('sec-websocket-version'), true)) { 78 | $reason = 'Bad Request: Requested Websocket version unavailable'; 79 | $response = $this->errorHandler->handleError(HttpStatus::BAD_REQUEST, $reason, $request); 80 | $response->setHeader('sec-websocket-version', '13'); 81 | return $response; 82 | } 83 | 84 | return new Response(HttpStatus::SWITCHING_PROTOCOLS, [ 85 | 'connection' => 'upgrade', 86 | 'upgrade' => 'websocket', 87 | 'sec-websocket-accept' => generateAcceptFromKey($acceptKey), 88 | ]); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | strategy: 10 | matrix: 11 | include: 12 | - operating-system: 'ubuntu-latest' 13 | php-version: '8.1' 14 | 15 | - operating-system: 'ubuntu-latest' 16 | php-version: '8.2' 17 | 18 | - operating-system: 'windows-latest' 19 | php-version: '8.2' 20 | job-description: 'on Windows' 21 | 22 | - operating-system: 'macos-latest' 23 | php-version: '8.2' 24 | job-description: 'on macOS' 25 | 26 | name: PHP ${{ matrix.php-version }} ${{ matrix.job-description }} 27 | 28 | runs-on: ${{ matrix.operating-system }} 29 | 30 | steps: 31 | - name: Set git to use LF 32 | run: | 33 | git config --global core.autocrlf false 34 | git config --global core.eol lf 35 | 36 | - name: Checkout code 37 | uses: actions/checkout@v3 38 | 39 | - name: Setup PHP 40 | uses: shivammathur/setup-php@v2 41 | with: 42 | php-version: ${{ matrix.php-version }} 43 | 44 | - name: Get Composer cache directory 45 | id: composer-cache 46 | run: echo "dir=$(composer config cache-dir)" >> $GITHUB_OUTPUT 47 | shell: bash 48 | 49 | - name: Cache dependencies 50 | uses: actions/cache@v3 51 | with: 52 | path: ${{ steps.composer-cache.outputs.dir }} 53 | key: composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}-${{ matrix.composer-flags }} 54 | restore-keys: | 55 | composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}- 56 | composer-${{ runner.os }}-${{ matrix.php-version }}- 57 | composer-${{ runner.os }}- 58 | composer- 59 | 60 | - name: Install dependencies 61 | uses: nick-invision/retry@v2 62 | with: 63 | timeout_minutes: 5 64 | max_attempts: 5 65 | retry_wait_seconds: 30 66 | command: | 67 | composer update --optimize-autoloader --no-interaction --no-progress ${{ matrix.composer-flags }} 68 | composer info -D 69 | 70 | - name: Run tests 71 | run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} 72 | 73 | - name: Run static analysis 74 | run: vendor/bin/psalm.phar 75 | 76 | - name: Run style fixer 77 | env: 78 | PHP_CS_FIXER_IGNORE_ENV: 1 79 | run: vendor/bin/php-cs-fixer --diff --dry-run -v fix 80 | if: runner.os != 'Windows' 81 | 82 | - name: Install composer-require-checker 83 | run: php -r 'file_put_contents("composer-require-checker.phar", file_get_contents("https://github.com/maglnet/ComposerRequireChecker/releases/download/3.7.0/composer-require-checker.phar"));' 84 | if: runner.os != 'Windows' && matrix.composer-require-checker-version != 'none' 85 | 86 | - name: Run composer-require-checker 87 | run: php composer-require-checker.phar check composer.json --config-file $PWD/composer-require-check.json 88 | if: runner.os != 'Windows' && matrix.composer-require-checker-version != 'none' 89 | 90 | - name: Autobahn 91 | if: runner.os == 'Linux' && matrix.php-version == '8.2' 92 | shell: 'script -q -e -c "bash {0}"' 93 | run: | 94 | docker run -ti -d --rm -v ${PWD}:/app --net="host" --name amp-websocket-server php:8.2-cli php /app/test-autobahn/server.php 95 | docker run -ti --rm -v ${PWD}/test-autobahn/config:/config -v ${PWD}/test-autobahn/reports:/reports --net="host" --name fuzzingclient crossbario/autobahn-testsuite wstest -m fuzzingclient -s config/fuzzingclient.json 96 | php test-autobahn/report.php 97 | 98 | - name: Archive autobahn artifacts 99 | if: always() 100 | uses: actions/upload-artifact@v3 101 | with: 102 | name: autobahn-results 103 | retention-days: 1 104 | path: | 105 | test-autobahn/reports -------------------------------------------------------------------------------- /examples/stackexchange-questions/server.php: -------------------------------------------------------------------------------- 1 | setFormatter(new ConsoleFormatter); 32 | $logger = new Logger('server'); 33 | $logger->pushHandler($logHandler); 34 | 35 | $server = SocketHttpServer::createForDirectAccess($logger); 36 | 37 | $server->expose(new Socket\InternetAddress('127.0.0.1', 1337)); 38 | $server->expose(new Socket\InternetAddress('[::1]', 1337)); 39 | 40 | $errorHandler = new DefaultErrorHandler(); 41 | 42 | $acceptor = new AllowOriginAcceptor( 43 | ['http://localhost:1337', 'http://127.0.0.1:1337', 'http://[::1]:1337'], 44 | ); 45 | 46 | $clientHandler = new class($server) implements WebsocketClientHandler { 47 | private ?string $watcher = null; 48 | private ?int $newestQuestion = null; 49 | 50 | public function __construct( 51 | HttpServer $server, 52 | private readonly WebsocketGateway $gateway = new WebsocketClientGateway(), 53 | ) { 54 | $server->onStart($this->onStart(...)); 55 | $server->onStop($this->onStop(...)); 56 | } 57 | 58 | private function onStart(): void 59 | { 60 | $client = HttpClientBuilder::buildDefault(); 61 | $this->watcher = EventLoop::repeat(10, function () use ($client): void { 62 | $response = $client->request( 63 | new ClientRequest('https://api.stackexchange.com/2.2/questions?order=desc&sort=activity&site=stackoverflow') 64 | ); 65 | $json = $response->getBody()->buffer(); 66 | 67 | $data = json_decode($json, true); 68 | 69 | if (!isset($data['items'])) { 70 | return; 71 | } 72 | 73 | foreach (array_reverse($data['items']) as $question) { 74 | if ($this->newestQuestion === null || $question['question_id'] > $this->newestQuestion) { 75 | $this->newestQuestion = $question['question_id']; 76 | $this->gateway->broadcastText(json_encode($question))->ignore(); 77 | } 78 | } 79 | }); 80 | } 81 | 82 | private function onStop(): void 83 | { 84 | if ($this->watcher) { 85 | EventLoop::cancel($this->watcher); 86 | } 87 | } 88 | 89 | public function handleClient(WebsocketClient $client, Request $request, Response $response): void 90 | { 91 | $this->gateway->addClient($client); 92 | 93 | while ($client->receive()) { 94 | // Messages received on the connection are ignored and discarded. 95 | } 96 | } 97 | }; 98 | 99 | $websocket = new Websocket( 100 | httpServer: $server, 101 | logger: $logger, 102 | acceptor: $acceptor, 103 | clientHandler: $clientHandler, 104 | ); 105 | 106 | $router = new Router($server, $logger, $errorHandler); 107 | $router->addRoute('GET', '/live', $websocket); 108 | $router->setFallback(new DocumentRoot($server, $errorHandler, __DIR__ . '/public')); 109 | 110 | $server->start($router, $errorHandler); 111 | 112 | // Await SIGINT or SIGTERM to be received. 113 | $signal = Amp\trapSignal([\SIGINT, \SIGTERM]); 114 | 115 | $logger->info(sprintf("Received signal %d, stopping HTTP server", $signal)); 116 | 117 | $server->stop(); 118 | -------------------------------------------------------------------------------- /examples/stackexchange-questions/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Stack Overflow Streaming Example 6 | 119 | 120 | 121 |
      122 |
      123 |

      We don't have any questions, yet.

      124 |

      Please wait a few moments.

      125 |
      126 | 174 | 175 | -------------------------------------------------------------------------------- /src/WebsocketGateway.php: -------------------------------------------------------------------------------- 1 | , array}> Completes once the message has been sent to all 30 | * clients. The completion value is an array containing two arrays: an array of exceptions indexed by client ID of 31 | * sends that failed and an array with keys corresponding to client IDs of successful sends. 32 | * Note it is generally undesirable to await this future in a coroutine. 33 | * 34 | * @see Future\awaitAll() Completion array corresponds to the return of this function. 35 | */ 36 | public function broadcastText(string $data, array $excludedClientIds = []): Future; 37 | 38 | /** 39 | * Send a binary message to all clients (except those given in the optional array). 40 | * 41 | * @param string $data Data to send. 42 | * @param int[] $excludedClientIds List of IDs to exclude from the broadcast. 43 | * 44 | * @return Future, array}> Completes once the message has been sent to all 45 | * clients. The completion value is an array containing two arrays: an array of exceptions indexed by client ID of 46 | * sends that failed and an array with keys corresponding to client IDs of successful sends. 47 | * Note it is generally undesirable to await this future in a coroutine. 48 | * 49 | * @see Future\awaitAll() Completion array corresponds to the return of this function. 50 | */ 51 | public function broadcastBinary(string $data, array $excludedClientIds = []): Future; 52 | 53 | /** 54 | * Send a UTF-8 text message to a set of clients. 55 | * 56 | * @param string $data Data to send. 57 | * @param int[] $clientIds Array of client IDs. 58 | * 59 | * @return Future, array}> Completes once the message has been sent to all 60 | * clients. The completion value is an array containing two arrays: an array of exceptions indexed by client ID of 61 | * sends that failed and an array with keys corresponding to client IDs of successful sends. 62 | * Note it is generally undesirable to await this future in a coroutine. 63 | * 64 | * @see Future\awaitAll() Completion array corresponds to the return of this function. 65 | */ 66 | public function multicastText(string $data, array $clientIds): Future; 67 | 68 | /** 69 | * Send a binary message to a set of clients. 70 | * 71 | * @param string $data Data to send. 72 | * @param int[] $clientIds Array of client IDs. 73 | * 74 | * @return Future, array}> Completes once the message has been sent to all 75 | * clients. The completion value is an array containing two arrays: an array of exceptions indexed by client ID of 76 | * sends that failed and an array with keys corresponding to client IDs of successful sends. 77 | * Note it is generally undesirable to await this future in a coroutine. 78 | * 79 | * @see Future\awaitAll() Completion array corresponds to the return of this function. 80 | */ 81 | public function multicastBinary(string $data, array $clientIds): Future; 82 | 83 | /** 84 | * Send a UTF-8 text data to a single client, returning a future immediately instead of waiting to return until the 85 | * data is sent as {@see WebsocketClient::send()}. This method guarantees ordering with broadcast or multicast 86 | * messages. 87 | * 88 | * @return Future 89 | */ 90 | public function sendText(string $data, int $clientId): Future; 91 | 92 | /** 93 | * Send binary data to a single client, returning a future immediately instead of waiting to return until the data 94 | * is sent as {@see WebsocketClient::sendBinary()}. This method guarantees ordering with broadcast or multicast 95 | * messages. 96 | * 97 | * @return Future 98 | */ 99 | public function sendBinary(string $data, int $clientId): Future; 100 | 101 | /** 102 | * @return array Array of {@see WebsocketClient} objects currently connected to this endpoint 103 | * indexed by their IDs. 104 | */ 105 | public function getClients(): array; 106 | 107 | /** 108 | * Add a client to this Gateway. 109 | */ 110 | public function addClient(WebsocketClient $client): void; 111 | } 112 | -------------------------------------------------------------------------------- /src/Websocket.php: -------------------------------------------------------------------------------- 1 | */ 32 | private \WeakMap $clients; 33 | 34 | /** 35 | * @param WebsocketCompressionContextFactory|null $compressionFactory Use {@see Rfc7692CompressionFactory} (or your 36 | * own implementation) to enable compression or use `null` (default) to disable compression. 37 | */ 38 | public function __construct( 39 | HttpServer $httpServer, 40 | private readonly PsrLogger $logger, 41 | private readonly WebsocketAcceptor $acceptor, 42 | private readonly WebsocketClientHandler $clientHandler, 43 | private readonly ?WebsocketCompressionContextFactory $compressionFactory = null, 44 | private readonly WebsocketClientFactory $clientFactory = new Rfc6455ClientFactory(), 45 | ) { 46 | /** @psalm-suppress PropertyTypeCoercion */ 47 | $this->clients = new \WeakMap(); 48 | 49 | $httpServer->onStop($this->onStop(...)); 50 | } 51 | 52 | public function handleRequest(Request $request): Response 53 | { 54 | $response = $this->acceptor->handleHandshake($request); 55 | 56 | if ($response->getStatus() !== HttpStatus::SWITCHING_PROTOCOLS) { 57 | $response->removeHeader('sec-websocket-accept'); 58 | $response->setHeader('connection', 'close'); 59 | 60 | return $response; 61 | } 62 | 63 | $compressionContext = $this->negotiateCompression($request, $response); 64 | 65 | $response->upgrade(fn (UpgradedSocket $socket) => $this->reapClient( 66 | socket: $socket, 67 | request: $request, 68 | response: $response, 69 | compressionContext: $compressionContext, 70 | )); 71 | 72 | return $response; 73 | } 74 | 75 | private function negotiateCompression(Request $request, Response $response): ?WebsocketCompressionContext 76 | { 77 | if (!$this->compressionFactory) { 78 | return null; 79 | } 80 | 81 | $extensions = Http\splitHeader($request, 'sec-websocket-extensions') ?? []; 82 | foreach ($extensions as $extension) { 83 | if ($compressionContext = $this->compressionFactory->fromClientHeader($extension, $headerLine)) { 84 | \assert(\is_string($headerLine), 'Compression context returned without header line'); 85 | 86 | $existingHeader = $response->getHeader('sec-websocket-extensions'); 87 | if ($existingHeader) { 88 | $headerLine = $existingHeader . ', ' . $headerLine; 89 | } 90 | 91 | $response->setHeader('sec-websocket-extensions', $headerLine); 92 | 93 | return $compressionContext; 94 | } 95 | } 96 | 97 | return null; 98 | } 99 | 100 | private function reapClient( 101 | UpgradedSocket $socket, 102 | Request $request, 103 | Response $response, 104 | ?WebsocketCompressionContext $compressionContext, 105 | ): void { 106 | $client = $this->clientFactory->createClient($request, $response, $socket, $compressionContext); 107 | 108 | /** @psalm-suppress RedundantCondition */ 109 | \assert($this->logger->debug(\sprintf( 110 | 'Upgraded %s #%d to websocket connection #%d', 111 | $socket->getRemoteAddress()->toString(), 112 | $socket->getClient()->getId(), 113 | $client->getId(), 114 | )) || true); 115 | 116 | $this->clients[$client] = true; 117 | 118 | EventLoop::queue($this->handleClient(...), $client, $request, $response); 119 | } 120 | 121 | private function handleClient(WebsocketClient $client, Request $request, Response $response): void 122 | { 123 | $client->onClose(function (int $clientId, WebsocketCloseInfo $closeInfo): void { 124 | /** @psalm-suppress RedundantCondition */ 125 | \assert($this->logger->debug(\sprintf( 126 | 'Closed websocket connection #%d (code: %d) %s', 127 | $clientId, 128 | $closeInfo->getCode(), 129 | $closeInfo->getReason(), 130 | )) || true); 131 | 132 | if (!$closeInfo->isByPeer()) { 133 | return; 134 | } 135 | 136 | switch ($closeInfo->getCode()) { 137 | case WebsocketCloseCode::PROTOCOL_ERROR: 138 | case WebsocketCloseCode::UNACCEPTABLE_TYPE: 139 | case WebsocketCloseCode::POLICY_VIOLATION: 140 | case WebsocketCloseCode::INCONSISTENT_FRAME_DATA_TYPE: 141 | case WebsocketCloseCode::MESSAGE_TOO_LARGE: 142 | case WebsocketCloseCode::EXPECTED_EXTENSION_MISSING: 143 | case WebsocketCloseCode::BAD_GATEWAY: 144 | $this->logger->notice(\sprintf( 145 | 'Client initiated websocket close reporting error (code: %d) %s', 146 | $closeInfo->getCode(), 147 | $closeInfo->getReason(), 148 | )); 149 | } 150 | }); 151 | 152 | try { 153 | $this->clientHandler->handleClient($client, $request, $response); 154 | } catch (\Throwable $exception) { 155 | if ($exception instanceof WebsocketClosedException && $client->isClosed()) { 156 | // Ignore WebsocketClosedException thrown from closing the client while streaming a message. 157 | return; 158 | } 159 | 160 | $this->logger->error( 161 | \sprintf( 162 | "Unexpected %s thrown from %s::handleClient(), closing websocket connection from %s.", 163 | $exception::class, 164 | $this->clientHandler::class, 165 | $client->getRemoteAddress()->toString(), 166 | ), 167 | ['exception' => $exception], 168 | ); 169 | 170 | $client->close(WebsocketCloseCode::UNEXPECTED_SERVER_ERROR, 'Internal server error, aborting'); 171 | return; 172 | } 173 | 174 | if (!$client->isClosed()) { 175 | $client->close(WebsocketCloseCode::NORMAL_CLOSE, 'Closing connection'); 176 | } 177 | } 178 | 179 | private function onStop(): void 180 | { 181 | $futures = []; 182 | foreach ($this->clients as $client => $unused) { 183 | $futures[] = async($client->close(...), WebsocketCloseCode::GOING_AWAY, 'Server shutting down'); 184 | } 185 | 186 | /** @psalm-suppress PropertyTypeCoercion */ 187 | $this->clients = new \WeakMap(); 188 | 189 | Future\awaitAll($futures); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # amphp/websocket-server 2 | 3 | AMPHP is a collection of event-driven libraries for PHP designed with fibers and concurrency in mind. 4 | This library provides a [`RequestHandler`](https://amphp.org/http-server/classes/request-handler) to easily handle WebSocket connections using [`amphp/http-server`](https://github.com/amphp/http-server). 5 | 6 | ## Requirements 7 | 8 | - PHP 8.1+ 9 | 10 | ## Installation 11 | 12 | This package can be installed as a [Composer](https://getcomposer.org) dependency. 13 | 14 | ``` 15 | composer require amphp/websocket-server 16 | ``` 17 | 18 | ## Documentation 19 | 20 | The primary component of this library is the `Websocket` class, an implementation of the `RequestHandler` interface from [`amphp/http-server`](https://github.com/amphp/http-server). Endpoints using the `Websocket` request handler will upgrade incoming requests to a WebSocket connection. 21 | 22 | Creating a `Websocket` endpoint requires the user to specify a number of parameters: 23 | - The `Amp\Http\Server\HttpServer` instance which will be used 24 | - A [PSR-3](https://www.php-fig.org/psr/psr-3/) logger instance 25 | - A `WebsocketAcceptor` to accept client connections 26 | - A `WebsocketClientHandler` to handle client connections once accepted 27 | - An optional `WebsocketCompressionContextFactory` if compression should be enabled on the server 28 | - An optional `WebsocketClientFactory` if custom logic is needed when creating `WebsocketClient` instances 29 | 30 | ### Accepting Client Connections 31 | 32 | Accepting client connections is performed by an instance of `WebsocketAcceptor`. This library provides two implementations: 33 | - `Rfc6455Acceptor`: Accepts client connections based on [RFC6455](https://datatracker.ietf.org/doc/html/rfc6455) with no further restrictions. 34 | - `AllowOriginAcceptor`: Requires the `"Origin"` header of the HTTP request to match one of the allowed origins provided to the constructor. Accepting the connection is then delegated to another `WebsocketAcceptor` implementation (`Rfc6455Acceptor` by default). 35 | 36 | ### Handling Client Connections 37 | 38 | Once established, a WebSocket connection is handled by an implementation of `WebsocketClientHandler`. Your application logic will be within an implementation of this interface. 39 | 40 | `WebsocketClientHandler` has a single method which must be implemented, `handleClient()`. 41 | 42 | ```php 43 | public function handleClient( 44 | WebsocketClient $client, 45 | Request $request, 46 | Response $response, 47 | ): void; 48 | ``` 49 | 50 | After accepting a client connection, `WebsocketClientHandler::handleClient()` is invoked with the `WebsocketClient` instance, as well as the `Request` and `Response` instances which were used to establish the connection. 51 | 52 | This method should not return until the client connection should be closed. Exceptions should not be thrown from this method. Any exception thrown will close the connection with an `UNEXPECTED_SERVER_ERROR` error code (1011) and forward the exception to the HTTP server logger. There is one exception to this: `WebsocketClosedException`, which is thrown when receiving or sending a message to a connection fails due to the connection being closed. If `WebsocketClosedException` is thrown from `handleClient()`, the exception is ignored. 53 | 54 | ### Gateways 55 | 56 | A `WebsocketGateway` provides a means of collecting WebSocket clients into related groups to allow broadcasting a single message efficiently (and asynchronously) to multiple clients. `WebsocketClientGateway` provided by this library may be used by one or more client handlers to group clients from one or more endpoints (or multiple may be used on a single endpoint if desired). See the [example server](#example-server) below for basic usage of a gateway in a client handler. Clients added to the gateway are automatically removed when the client connection is closed. 57 | 58 | ### Compression 59 | 60 | Message compression may optionally be enabled on individual WebSocket endpoints by passing an instance of `WebsocketCompressionContextFactory` to the `Websocket` constructor. Currently, the only implementation available is `Rfc7692CompressionFactory` which implements compression based on [RFC-7692](https://datatracker.ietf.org/doc/html/rfc7692). 61 | 62 | ### Example Server 63 | 64 | The server below creates a simple WebSocket endpoint which broadcasts all received messages to all other connected clients. [`amphp/http-server-router`](https://github.com/amphp/http-server-router) and [`amphp/http-server-static-content`](https://github.com/amphp/http-server-static-content) are used to attach the `Websocket` handler to a specific route and to serve static files from the `/public` directory if the route is not defined in the router. 65 | 66 | ```php 67 | setFormatter(new ConsoleFormatter()); 95 | $logger = new Logger('server'); 96 | $logger->pushHandler($logHandler); 97 | 98 | $server = SocketHttpServer::createForDirectAccess($logger); 99 | 100 | $server->expose(new Socket\InternetAddress('127.0.0.1', 1337)); 101 | $server->expose(new Socket\InternetAddress('[::1]', 1337)); 102 | 103 | $errorHandler = new DefaultErrorHandler(); 104 | 105 | $acceptor = new AllowOriginAcceptor( 106 | ['http://localhost:1337', 'http://127.0.0.1:1337', 'http://[::1]:1337'], 107 | ); 108 | 109 | $clientHandler = new class implements WebsocketClientHandler { 110 | public function __construct( 111 | private readonly WebsocketGateway $gateway = new WebsocketClientGateway(), 112 | ) { 113 | } 114 | 115 | public function handleClient( 116 | WebsocketClient $client, 117 | Request $request, 118 | Response $response, 119 | ): void { 120 | $this->gateway->addClient($client); 121 | 122 | foreach ($client as $message) { 123 | $this->gateway->broadcastText(sprintf( 124 | '%d: %s', 125 | $client->getId(), 126 | (string) $message, 127 | )); 128 | } 129 | } 130 | }; 131 | 132 | $websocket = new Websocket($server, $logger, $acceptor, $clientHandler); 133 | 134 | $router = new Router($server, $logger, $errorHandler); 135 | $router->addRoute('GET', '/broadcast', $websocket); 136 | $router->setFallback(new DocumentRoot($server, $errorHandler, __DIR__ . '/public')); 137 | 138 | $server->start($router, $errorHandler); 139 | 140 | // Await SIGINT or SIGTERM to be received. 141 | $signal = trapSignal([SIGINT, SIGTERM]); 142 | 143 | $logger->info(sprintf("Received signal %d, stopping HTTP server", $signal)); 144 | 145 | $server->stop(); 146 | ``` 147 | 148 | ## Versioning 149 | 150 | `amphp/websocket-server` follows the [semver](http://semver.org/) semantic versioning specification like all other `amphp` packages. 151 | 152 | ## Security 153 | 154 | If you discover any security related issues, please use the private security issue reporter instead of using the public issue tracker. 155 | 156 | ## License 157 | 158 | The MIT License (MIT). Please see [`LICENSE`](./LICENSE) for more information. 159 | --------------------------------------------------------------------------------