├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Buffer.php ├── Factory.php ├── Socket.php └── SocketInterface.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.10.0 (2024-09-06) 4 | 5 | * Feature: Improve PHP 8.4+ support by avoiding implicitly nullable type declarations. 6 | (#59 by @clue) 7 | 8 | * Feature: Full PHP 8.3 compatibility. 9 | (#57 by @clue) 10 | 11 | ## 1.9.0 (2022-12-05) 12 | 13 | * Feature: Add support for PHP 8.1 and PHP 8.2. 14 | (#44 by @SimonFrings and #51 by @WyriHaximus) 15 | 16 | * Feature: Forward compatibility with upcoming Promise v3. 17 | (#33 by @WyriHaximus) 18 | 19 | * Feature / Fix: Improve error reporting when custom error handler is used. 20 | (#49 by @clue) 21 | 22 | * Feature: Avoid dependency on `ext-filter`. 23 | (#45 by @clue) 24 | 25 | * Improve documentation and examples and update to use new reactphp/async package. 26 | (#50 by @nhedger, #53 by @dinooo13 and #47, #54 and #56 by @SimonFrings) 27 | 28 | * Improve test suite and report failed assertions. 29 | (#48 by @SimonFrings and #55 by @clue) 30 | 31 | ## 1.8.0 (2021-07-11) 32 | 33 | A major new feature release, see [**release announcement**](https://clue.engineering/2021/announcing-reactphp-default-loop). 34 | 35 | * Feature: Simplify usage by supporting new [default loop](https://reactphp.org/event-loop/#loop). 36 | (#42 by @clue) 37 | 38 | ```php 39 | // old (still supported) 40 | $factory = new React\Datagram\Factory($loop); 41 | 42 | // new (using default loop) 43 | $factory = new React\Datagram\Factory(); 44 | ``` 45 | 46 | ## 1.7.0 (2021-06-25) 47 | 48 | * Feature: Support falling back to multiple DNS servers from DNS config. 49 | (#41 by @clue) 50 | 51 | When using the `Factory`, it will now use all DNS servers configured on your 52 | system. If you have multiple DNS servers configured and connectivity to the 53 | primary DNS server is broken, it will now fall back to your other DNS 54 | servers, thus providing improved connectivity and redundancy for broken DNS 55 | configurations. 56 | 57 | ## 1.6.0 (2021-02-12) 58 | 59 | * Feature: Support PHP 8 (socket address of closed socket should be null). 60 | (#39 by @clue) 61 | 62 | * Improve test suite and add `.gitattributes` to exclude dev files from exports. 63 | Run tests on PHPUnit 9, switch to GitHub actions and clean up test suite. 64 | (#30, #31 and #38 by @clue, #34 by @reedy, #35 by @WyriHaximus and #37 by @SimonFrings) 65 | 66 | ## 1.5.0 (2019-07-10) 67 | 68 | * Feature: Forward compatibility with upcoming stable DNS component. 69 | (#29 by @clue) 70 | 71 | * Prefix all global functions calls with \ to skip the look up and resolve process and go straight to the global function. 72 | (#28 by @WyriHaximus) 73 | 74 | * Improve test suite to also test against PHP 7.1 and 7.2. 75 | (#25 by @andreybolonin) 76 | 77 | ## 1.4.0 (2018-02-28) 78 | 79 | * Feature: Update DNS dependency to support loading system default DNS 80 | nameserver config on all supported platforms 81 | (`/etc/resolv.conf` on Unix/Linux/Mac/Docker/WSL and WMIC on Windows) 82 | (#23 by @clue) 83 | 84 | This means that connecting to hosts that are managed by a local DNS server, 85 | such as a corporate DNS server or when using Docker containers, will now 86 | work as expected across all platforms with no changes required: 87 | 88 | ```php 89 | $factory = new Factory($loop); 90 | $factory->createClient('intranet.example:5353'); 91 | ``` 92 | 93 | * Improve README 94 | (#22 by @jsor) 95 | 96 | ## 1.3.0 (2017-09-25) 97 | 98 | * Feature: Always use `Resolver` with default DNS to match Socket component 99 | and update DNS dependency to support hosts file on all platforms 100 | (#19 and #20 by @clue) 101 | 102 | This means that connecting to hosts such as `localhost` (and for example 103 | those used for Docker containers) will now work as expected across all 104 | platforms with no changes required: 105 | 106 | ```php 107 | $factory = new Factory($loop); 108 | $factory->createClient('localhost:5353'); 109 | ``` 110 | 111 | ## 1.2.0 (2017-08-09) 112 | 113 | * Feature: Target evenement 3.0 a long side 2.0 and 1.0 114 | (#16 by @WyriHaximus) 115 | 116 | * Feature: Forward compatibility with EventLoop v1.0 and v0.5 117 | (#18 by @clue) 118 | 119 | * Improve test suite by updating Travis build config so new defaults do not break the build 120 | (#17 by @clue) 121 | 122 | ## 1.1.1 (2017-01-23) 123 | 124 | * Fix: Properly format IPv6 addresses and return `null` for unknown addresses 125 | (#14 by @clue) 126 | 127 | * Fix: Skip IPv6 tests if not supported by the system 128 | (#15 by @clue) 129 | 130 | ## 1.1.0 (2016-03-19) 131 | 132 | * Feature: Support promise cancellation (cancellation of underlying DNS lookup) 133 | (#12 by @clue) 134 | 135 | * Fix: Fix error reporting when trying to create invalid sockets 136 | (#11 by @clue) 137 | 138 | * Improve test suite and update dependencies 139 | (#7, #8 by @clue) 140 | 141 | ## 1.0.1 (2015-11-13) 142 | 143 | * Fix: Correct formatting for remote peer address of incoming datagrams when using IPv6 144 | (#6 by @WyriHaximus) 145 | 146 | * Improve test suite for different PHP versions 147 | 148 | ## 1.0.0 (2014-10-23) 149 | 150 | * Initial tagged release 151 | 152 | > This project has been migrated over from [clue/datagram](https://github.com/clue/php-datagram) 153 | > which has originally been released in January 2013. 154 | > Upgrading from clue/datagram v0.5.0? Use namespace `React\Datagram` instead of `Datagram` and you're ready to go! 155 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Christian Lück, Cees-Jan Kiewiet, Jan Sorgalla, Chris Boden 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 furnished 10 | 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Datagram 2 | 3 | [![CI status](https://github.com/reactphp/datagram/actions/workflows/ci.yml/badge.svg)](https://github.com/reactphp/datagram/actions) 4 | [![installs on Packagist](https://img.shields.io/packagist/dt/react/datagram?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/react/datagram) 5 | 6 | Event-driven UDP datagram socket client and server for [ReactPHP](https://reactphp.org). 7 | 8 | ## Quickstart example 9 | 10 | Once [installed](#install), you can use the following code to connect to an UDP server listening on 11 | `localhost:1234` and send and receive UDP datagrams: 12 | 13 | ```php 14 | $factory = new React\Datagram\Factory(); 15 | 16 | $factory->createClient('localhost:1234')->then(function (React\Datagram\Socket $client) { 17 | $client->send('first'); 18 | 19 | $client->on('message', function($message, $serverAddress, $client) { 20 | echo 'received "' . $message . '" from ' . $serverAddress. PHP_EOL; 21 | }); 22 | }); 23 | ``` 24 | 25 | See also the [examples](examples). 26 | 27 | ## Usage 28 | 29 | This library's API is modelled after node.js's API for 30 | [UDP / Datagram Sockets (dgram.Socket)](https://nodejs.org/api/dgram.html). 31 | 32 | ## Install 33 | 34 | The recommended way to install this library is [through Composer](https://getcomposer.org/). 35 | [New to Composer?](https://getcomposer.org/doc/00-intro.md) 36 | 37 | This project follows [SemVer](https://semver.org/). 38 | This will install the latest supported version: 39 | 40 | ```bash 41 | composer require react/datagram:^1.10 42 | ``` 43 | 44 | See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. 45 | 46 | This project aims to run on any platform and thus does not require any PHP 47 | extensions and supports running on legacy PHP 5.3 through current PHP 8+ and 48 | HHVM. 49 | It's *highly recommended to use PHP 7+* for this project. 50 | 51 | ## Tests 52 | 53 | To run the test suite, you first need to clone this repo and then install all 54 | dependencies [through Composer](https://getcomposer.org/): 55 | 56 | ```bash 57 | composer install 58 | ``` 59 | 60 | To run the test suite, go to the project root and run: 61 | 62 | ```bash 63 | vendor/bin/phpunit 64 | ``` 65 | 66 | ## License 67 | 68 | MIT, see [LICENSE file](LICENSE). 69 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react/datagram", 3 | "description": "Event-driven UDP datagram socket client and server for ReactPHP", 4 | "keywords": ["udp", "datagram", "dgram", "socket", "client", "server", "ReactPHP", "async"], 5 | "homepage": "https://github.com/reactphp/datagram", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Christian Lück", 10 | "homepage": "https://clue.engineering/", 11 | "email": "christian@clue.engineering" 12 | }, 13 | { 14 | "name": "Cees-Jan Kiewiet", 15 | "homepage": "https://wyrihaximus.net/", 16 | "email": "reactphp@ceesjankiewiet.nl" 17 | }, 18 | { 19 | "name": "Jan Sorgalla", 20 | "homepage": "https://sorgalla.com/", 21 | "email": "jsorgalla@gmail.com" 22 | }, 23 | { 24 | "name": "Chris Boden", 25 | "homepage": "https://cboden.dev/", 26 | "email": "cboden@gmail.com" 27 | } 28 | ], 29 | "require": { 30 | "php": ">=5.3", 31 | "evenement/evenement": "^3.0 || ^2.0 || ^1.0", 32 | "react/dns": "^1.13", 33 | "react/event-loop": "^1.2", 34 | "react/promise": "^3.2 || ^2.1 || ^1.2" 35 | }, 36 | "require-dev": { 37 | "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", 38 | "react/async": "^4.3 || ^3 || ^2" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "React\\Datagram\\": "src/" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "React\\Tests\\Datagram\\": "tests/" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Buffer.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 21 | $this->socket = $socket; 22 | } 23 | 24 | public function send($data, $remoteAddress = null) 25 | { 26 | if ($this->writable === false) { 27 | return; 28 | } 29 | 30 | $this->outgoing []= array($data, $remoteAddress); 31 | 32 | if (!$this->listening) { 33 | $this->handleResume(); 34 | $this->listening = true; 35 | } 36 | } 37 | 38 | public function onWritable() 39 | { 40 | list($data, $remoteAddress) = \array_shift($this->outgoing); 41 | 42 | try { 43 | $this->handleWrite($data, $remoteAddress); 44 | } 45 | catch (Exception $e) { 46 | $this->emit('error', array($e, $this)); 47 | } 48 | 49 | if (!$this->outgoing) { 50 | if ($this->listening) { 51 | $this->handlePause(); 52 | $this->listening = false; 53 | } 54 | 55 | if (!$this->writable) { 56 | $this->close(); 57 | } 58 | } 59 | } 60 | 61 | public function close() 62 | { 63 | if ($this->socket === false) { 64 | return; 65 | } 66 | 67 | $this->emit('close', array($this)); 68 | 69 | if ($this->listening) { 70 | $this->handlePause(); 71 | $this->listening = false; 72 | } 73 | 74 | $this->writable = false; 75 | $this->socket = false; 76 | $this->outgoing = array(); 77 | $this->removeAllListeners(); 78 | } 79 | 80 | public function end() 81 | { 82 | $this->writable = false; 83 | 84 | if (!$this->outgoing) { 85 | $this->close(); 86 | } 87 | } 88 | 89 | protected function handlePause() 90 | { 91 | $this->loop->removeWriteStream($this->socket); 92 | } 93 | 94 | protected function handleResume() 95 | { 96 | $this->loop->addWriteStream($this->socket, array($this, 'onWritable')); 97 | } 98 | 99 | protected function handleWrite($data, $remoteAddress) 100 | { 101 | $errstr = ''; 102 | \set_error_handler(function ($_, $error) use (&$errstr) { 103 | // Match errstr from PHP's warning message. 104 | // stream_socket_sendto(): Message too long\n 105 | $errstr = \trim($error); 106 | }); 107 | 108 | if ($remoteAddress === null) { 109 | // do not use fwrite() as it obeys the stream buffer size and 110 | // packets are not to be split at 8kb 111 | $ret = \stream_socket_sendto($this->socket, $data); 112 | } else { 113 | $ret = \stream_socket_sendto($this->socket, $data, 0, $remoteAddress); 114 | } 115 | 116 | \restore_error_handler(); 117 | 118 | if ($ret < 0 || $ret === false) { 119 | throw new Exception('Unable to send packet: ' . $errstr); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Factory.php: -------------------------------------------------------------------------------- 1 | nameservers) { 46 | $config->nameservers[] = '8.8.8.8'; // @codeCoverageIgnore 47 | } 48 | 49 | $factory = new DnsFactory(); 50 | $resolver = $factory->create($config, $loop); 51 | } 52 | 53 | $this->loop = $loop; 54 | $this->resolver = $resolver; 55 | } 56 | 57 | public function createClient($address) 58 | { 59 | $loop = $this->loop; 60 | 61 | return $this->resolveAddress($address)->then(function ($address) use ($loop) { 62 | $socket = @\stream_socket_client($address, $errno, $errstr); 63 | if (!$socket) { 64 | throw new Exception('Unable to create client socket: ' . $errstr, $errno); 65 | } 66 | 67 | return new Socket($loop, $socket); 68 | }); 69 | } 70 | 71 | public function createServer($address) 72 | { 73 | $loop = $this->loop; 74 | 75 | return $this->resolveAddress($address)->then(function ($address) use ($loop) { 76 | $socket = @\stream_socket_server($address, $errno, $errstr, \STREAM_SERVER_BIND); 77 | if (!$socket) { 78 | throw new Exception('Unable to create server socket: ' . $errstr, $errno); 79 | } 80 | 81 | return new Socket($loop, $socket); 82 | }); 83 | } 84 | 85 | protected function resolveAddress($address) 86 | { 87 | if (\strpos($address, '://') === false) { 88 | $address = 'udp://' . $address; 89 | } 90 | 91 | // parse_url() does not accept null ports (random port assignment) => manually remove 92 | $nullport = false; 93 | if (\substr($address, -2) === ':0') { 94 | $address = \substr($address, 0, -2); 95 | $nullport = true; 96 | } 97 | 98 | $parts = \parse_url($address); 99 | 100 | if (!$parts || !isset($parts['host'])) { 101 | return Promise\resolve($address); 102 | } 103 | 104 | if ($nullport) { 105 | $parts['port'] = 0; 106 | } 107 | 108 | // remove square brackets for IPv6 addresses 109 | $host = \trim($parts['host'], '[]'); 110 | 111 | return $this->resolveHost($host)->then(function ($host) use ($parts) { 112 | $address = $parts['scheme'] . '://'; 113 | 114 | if (isset($parts['port']) && \strpos($host, ':') !== false) { 115 | // enclose IPv6 address in square brackets if a port will be appended 116 | $host = '[' . $host . ']'; 117 | } 118 | 119 | $address .= $host; 120 | 121 | if (isset($parts['port'])) { 122 | $address .= ':' . $parts['port']; 123 | } 124 | 125 | return $address; 126 | }); 127 | } 128 | 129 | protected function resolveHost($host) 130 | { 131 | // there's no need to resolve if the host is already given as an IP address 132 | if (@\inet_pton($host) !== false) { 133 | return Promise\resolve($host); 134 | } 135 | 136 | $promise = $this->resolver->resolve($host); 137 | 138 | // wrap DNS lookup in order to control cancellation behavior 139 | return new Promise\Promise( 140 | function ($resolve, $reject) use ($promise) { 141 | // forward promise resolution 142 | $promise->then($resolve, $reject); 143 | }, 144 | function ($_, $reject) use ($promise) { 145 | // reject with custom message once cancelled 146 | $reject(new \RuntimeException('Cancelled creating socket during DNS lookup')); 147 | 148 | // (try to) cancel pending DNS lookup, otherwise ignoring its results 149 | if (\method_exists($promise, 'cancel')) { 150 | $promise->cancel(); 151 | } 152 | } 153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Socket.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 30 | $this->socket = $socket; 31 | 32 | if ($buffer === null) { 33 | $buffer = new Buffer($loop, $socket); 34 | } 35 | $this->buffer = $buffer; 36 | 37 | $that = $this; 38 | $this->buffer->on('error', function ($error) use ($that) { 39 | $that->emit('error', array($error, $that)); 40 | }); 41 | $this->buffer->on('close', array($this, 'close')); 42 | 43 | $this->resume(); 44 | } 45 | 46 | public function getLocalAddress() 47 | { 48 | if ($this->socket === false) { 49 | return null; 50 | } 51 | 52 | return $this->sanitizeAddress(@\stream_socket_get_name($this->socket, false)); 53 | } 54 | 55 | public function getRemoteAddress() 56 | { 57 | if ($this->socket === false) { 58 | return null; 59 | } 60 | 61 | return $this->sanitizeAddress(@\stream_socket_get_name($this->socket, true)); 62 | } 63 | 64 | public function send($data, $remoteAddress = null) 65 | { 66 | $this->buffer->send($data, $remoteAddress); 67 | } 68 | 69 | public function pause() 70 | { 71 | $this->loop->removeReadStream($this->socket); 72 | } 73 | 74 | public function resume() 75 | { 76 | if ($this->socket !== false) { 77 | $this->loop->addReadStream($this->socket, array($this, 'onReceive')); 78 | } 79 | } 80 | 81 | public function onReceive() 82 | { 83 | try { 84 | $data = $this->handleReceive($peer); 85 | } 86 | catch (Exception $e) { 87 | // emit error message and local socket 88 | $this->emit('error', array($e, $this)); 89 | return; 90 | } 91 | 92 | $this->emit('message', array($data, $peer, $this)); 93 | } 94 | 95 | public function close() 96 | { 97 | if ($this->socket === false) { 98 | return; 99 | } 100 | 101 | $this->emit('close', array($this)); 102 | $this->pause(); 103 | 104 | $this->handleClose(); 105 | $this->socket = false; 106 | $this->buffer->close(); 107 | 108 | $this->removeAllListeners(); 109 | } 110 | 111 | public function end() 112 | { 113 | $this->buffer->end(); 114 | } 115 | 116 | private function sanitizeAddress($address) 117 | { 118 | if ($address === false) { 119 | return null; 120 | } 121 | 122 | // check if this is an IPv6 address which includes multiple colons but no square brackets (PHP < 7.3) 123 | $pos = \strrpos($address, ':'); 124 | if ($pos !== false && \strpos($address, ':') < $pos && \substr($address, 0, 1) !== '[') { 125 | $address = '[' . \substr($address, 0, $pos) . ']:' . \substr($address, $pos + 1); // @codeCoverageIgnore 126 | } 127 | return $address; 128 | } 129 | 130 | protected function handleReceive(&$peerAddress) 131 | { 132 | $data = \stream_socket_recvfrom($this->socket, $this->bufferSize, 0, $peerAddress); 133 | 134 | if ($data === false) { 135 | // receiving data failed => remote side rejected one of our packets 136 | // due to the nature of UDP, there's no way to tell which one exactly 137 | // $peer is not filled either 138 | 139 | throw new Exception('Invalid message'); 140 | } 141 | 142 | $peerAddress = $this->sanitizeAddress($peerAddress); 143 | 144 | return $data; 145 | } 146 | 147 | protected function handleClose() 148 | { 149 | \fclose($this->socket); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/SocketInterface.php: -------------------------------------------------------------------------------- 1 |