-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add WebSocket client implementation #6
Merged
Merged
Changes from 17 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
d38f432
Add WebSocket client implementation
thekid 0fb7663
Set host
thekid e03768d
Add tests for WebSocket client implementation
thekid a1db52d
Yield CLOSE opcode instead of handling them
thekid 91fe303
Simplify handling CLOSE opcode
thekid d990960
Adjust tests to socket not being closed by Connection
thekid 533df0c
Restore PHP < 7.4 compatibility
thekid ab58633
Handle Opcodes::TEXT first
thekid dccaaf2
Do not answer PONGs
thekid eb573db
Test close()
thekid 65591c6
Add connected()
thekid 9cbd98e
QA: WS
thekid c03e6bc
Optimize case when no listener is provided
thekid bbbf810
Test path() accessor
thekid 6addec8
Test origin() accessor
thekid 57cdbfb
Test listener integration
thekid 06527ab
Add close code and reason to Listener
thekid 85547a2
QA: WS
thekid 5fa2eb6
Change receive to return a single message
thekid 0bfa2b1
Remove superfluous close() call, the connection does this for us
thekid 054f1cc
Add tests for server errors
thekid f6daa84
Lowercase headers
thekid 4ea9432
Include body in unexpected response error
thekid 7565e5f
Add client-side code
thekid File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,223 @@ | ||
<?php namespace websocket; | ||
|
||
use lang\{Closeable, Throwable}; | ||
use peer\{Socket, CryptoSocket, ProtocolException}; | ||
use util\Bytes; | ||
use websocket\protocol\{Connection, Handshake, Opcodes}; | ||
|
||
/** | ||
* WebSocket implementation | ||
* | ||
* @test websocket.unittest.WebSocketTest | ||
*/ | ||
class WebSocket implements Closeable { | ||
private $socket, $path, $origin; | ||
private $conn= null; | ||
private $listener= null; | ||
private $random= 'random_bytes'; | ||
|
||
/** | ||
* Creates a new instance | ||
* | ||
* @param peer.Socket|string $endpoint, e.g. "wss://example.com" | ||
* @param string $origin | ||
*/ | ||
public function __construct($endpoint, $origin= 'localhost') { | ||
if ($endpoint instanceof Socket) { | ||
$this->socket= $endpoint; | ||
$this->path= '/'; | ||
} else { | ||
$url= parse_url($endpoint); | ||
if ('wss' === $url['scheme']) { | ||
$this->socket= new CryptoSocket($url['host'], $url['port'] ?? 443); | ||
$this->socket->cryptoImpl= STREAM_CRYPTO_METHOD_ANY_CLIENT; | ||
} else { | ||
$this->socket= new Socket($url['host'], $url['port'] ?? 80); | ||
} | ||
$this->path= $url['path'] ?? '/'; | ||
} | ||
$this->origin= $origin; | ||
} | ||
|
||
/** @return peer.Socket */ | ||
public function socket() { return $this->socket; } | ||
|
||
/** @return string */ | ||
public function path() { return $this->path; } | ||
|
||
/** @return string */ | ||
public function origin() { return $this->origin; } | ||
|
||
/** @return bool */ | ||
public function connected() { return $this->socket->isConnected(); } | ||
|
||
/** @param function(int): string */ | ||
public function random($function) { | ||
$this->random= $function; | ||
} | ||
|
||
/** | ||
* Attach listener | ||
* | ||
* @param websocket.Listener $listener | ||
* @return self | ||
*/ | ||
public function listening(Listener $listener) { | ||
$this->listener= $listener; | ||
return $this; | ||
} | ||
|
||
/** | ||
* Connects to websocket endpoint and performs handshake | ||
* | ||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-WebSocket-Accept | ||
* @param [:string|string[]] $headers | ||
* @throws peer.ProtocolException | ||
* @return void | ||
*/ | ||
public function connect($headers= []) { | ||
if ($this->socket->isConnected()) return; | ||
|
||
$key= base64_encode(($this->random)(16)); | ||
$headers+= ['Host' => $this->socket->host, 'Origin' => $this->origin]; | ||
$this->socket->connect(); | ||
$this->socket->write( | ||
"GET {$this->path} HTTP/1.1\r\n". | ||
"Upgrade: websocket\r\n". | ||
"Sec-WebSocket-Key: {$key}\r\n". | ||
"Sec-WebSocket-Version: 13\r\n". | ||
"Connection: Upgrade\r\n" | ||
); | ||
foreach ($headers as $name => $values) { | ||
foreach ((array)$values as $value) { | ||
$this->socket->write("{$name}: {$value}\r\n"); | ||
} | ||
} | ||
$this->socket->write("\r\n"); | ||
|
||
sscanf($this->socket->readLine(), "HTTP/%s %d %[^\r]", $version, $status, $message); | ||
if (101 !== $status) { | ||
$this->socket->close(); | ||
throw new ProtocolException('Unexpected response '.$status.' '.$message); | ||
} | ||
|
||
$headers= []; | ||
while ($line= $this->socket->readLine()) { | ||
sscanf($line, "%[^:]: %[^\r]", $header, $value); | ||
$headers[$header][]= $value; | ||
} | ||
|
||
$accept= $headers['Sec-Websocket-Accept'][0] ?? ''; | ||
$expect= base64_encode(sha1($key.Handshake::GUID, true)); | ||
if ($accept !== $expect) { | ||
$this->socket->close(); | ||
throw new ProtocolException('Accept key mismatch, have '.$accept.', expect '.$expect); | ||
} | ||
|
||
$this->socket->setTimeout(600.0); | ||
$this->conn= new Connection( | ||
$this->socket, | ||
(int)$this->socket->getHandle(), | ||
$this->listener, | ||
$this->path, | ||
$headers | ||
); | ||
$this->conn->open(); | ||
} | ||
|
||
/** | ||
* Sends a ping | ||
* | ||
* @param string $payload | ||
* @return void | ||
* @throws peer.ProtocolException | ||
*/ | ||
public function ping($payload= '') { | ||
if (!$this->socket->isConnected()) throw new ProtocolException('Not connected'); | ||
|
||
$this->conn->message(Opcodes::PING, $payload, ($this->random)(4)); | ||
} | ||
|
||
/** | ||
* Sends a message | ||
* | ||
* @param util.Bytes|string $message | ||
* @return void | ||
* @throws peer.ProtocolException | ||
*/ | ||
public function send($message) { | ||
if (!$this->socket->isConnected()) throw new ProtocolException('Not connected'); | ||
|
||
if ($message instanceof Bytes) { | ||
$this->conn->message(Opcodes::BINARY, $message, ($this->random)(4)); | ||
} else { | ||
$this->conn->message(Opcodes::TEXT, $message, ($this->random)(4)); | ||
} | ||
} | ||
|
||
/** | ||
* Receive messages, handling PING and CLOSE | ||
* | ||
* @return iterable | ||
* @throws peer.ProtocolException | ||
*/ | ||
public function receive($timeout= null) { | ||
if (!$this->socket->isConnected()) throw new ProtocolException('Not connected'); | ||
|
||
if (null !== $timeout && !$this->socket->canRead($timeout)) return; | ||
foreach ($this->conn->receive() as $opcode => $packet) { | ||
switch ($opcode) { | ||
case Opcodes::TEXT: | ||
$this->conn->on($packet); | ||
yield $packet; | ||
break; | ||
|
||
case Opcodes::BINARY: | ||
$message= new Bytes($packet); | ||
$this->conn->on($message); | ||
yield $message; | ||
break; | ||
|
||
case Opcodes::PING: | ||
$this->conn->message(Opcodes::PONG, $packet, ($this->random)(4)); | ||
break; | ||
|
||
case Opcodes::PONG: // Do not answer PONGs | ||
break; | ||
|
||
case Opcodes::CLOSE: | ||
$close= unpack('ncode/a*reason', $packet); | ||
$this->conn->close($close['code'], $close['reason']); | ||
$this->socket->close(); | ||
|
||
// 1000 is a normal close, all others indicate an error | ||
if (1000 === $close['code']) return; | ||
throw new ProtocolException('Connection closed (#'.$close['code'].'): '.$close['reason']); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Closes connection | ||
* | ||
* @param int $code | ||
* @param string $reason | ||
* @return void | ||
*/ | ||
public function close($code= 1000, $reason= '') { | ||
if (!$this->socket->isConnected()) return; | ||
|
||
try { | ||
$this->conn->message(Opcodes::CLOSE, pack('na*', $code, $reason), ($this->random)(4)); | ||
} catch (Throwable $ignored) { | ||
// ... | ||
} | ||
$this->conn->close($code, $reason); | ||
$this->socket->close(); | ||
} | ||
|
||
/** Destructor - ensures connection is closed */ | ||
public function __destruct() { | ||
$this->close(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a BC break - all
Listener
implementations with a close() method need to be adapted!There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See thekid/crews@3f47d65