Skip to content
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

Websockets support #121

Open
wants to merge 59 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
de8f9ac
Integrate websocket support
thekid Jan 12, 2025
0a3bee0
Add dependency on xp-forge/websockets
thekid Jan 12, 2025
b061fae
Fix HTTP protocol test
thekid Jan 12, 2025
2fd7308
Fix integration test
thekid Jan 12, 2025
8d54e89
Add test for WebSocket routing handler
thekid Jan 12, 2025
f6ce581
Bump dependency on xp-forge/websockets to 4.1+
thekid Jan 12, 2025
260aca2
Implement integration tests for websockets
thekid Jan 12, 2025
ee8fa87
Remove PHP 7.0 and 7.1 compatibility
thekid Jan 12, 2025
274bc84
Refactor logging API
thekid Jan 12, 2025
ebfb5dd
Prevent exception inside handleDisconnect() and handleError()
thekid Jan 12, 2025
d85e29f
Remove sequential server, it does not support multiple connections
thekid Jan 12, 2025
feee4e4
Remove test for sequential server
thekid Jan 12, 2025
70db24d
Fix logging
thekid Jan 12, 2025
a0e258a
Yield all other events after "connection"
thekid Jan 12, 2025
abfc4d5
Run the PHP development webserver as a backend, forwarding requests t…
thekid Jan 18, 2025
a314a06
Test binary messages
thekid Jan 18, 2025
d013905
QA: Imports
thekid Jan 18, 2025
db560b2
Add test for EventSource implementation
thekid Jan 18, 2025
0ea9a10
Simplify test, add new variation
thekid Jan 18, 2025
933349e
Fix logging
thekid Jan 18, 2025
b8a57df
Fix reference to undefined property
thekid Jan 18, 2025
001e056
Return an empty string when reading after end of chunked data
thekid Jan 18, 2025
b03894e
Correctly end chunked output stream
thekid Jan 18, 2025
57bf8cf
QA: Remove unused helper
thekid Jan 18, 2025
f7ab152
Extract common parts from the implementation of WS<->SSE translation
thekid Jan 18, 2025
04ad79b
Add tests for WS<->SSE translation
thekid Jan 18, 2025
af6f908
Test handling of backend errors when translating SSE to WebSockets
thekid Jan 18, 2025
0b88cf0
Test "close" message when translating SSE to WebSockets
thekid Jan 18, 2025
5d88f89
Test unexpected events when translating SSE to WebSockets
thekid Jan 18, 2025
7139ed6
Pass websocket ID via (non-standard) header "Sec-WebSocket-Id"
thekid Jan 18, 2025
4e47393
Test invoking websocket with invalid utf-8
thekid Jan 18, 2025
f5d8504
Fix chunked transfer-encoding in requests and responses
thekid Jan 18, 2025
d4c3b59
Make ReadLength consistent with ReadChunks on EOF handling
thekid Jan 18, 2025
d3350b9
Make compatible with xp-forge/uri 2.0-RELEASE
thekid Jan 18, 2025
b9c69dc
Stacktrace appears in hints, omit printing to STDERR
thekid Jan 18, 2025
9f9fe9f
Forward synchronously, the PHP development webserver can only handle …
thekid Jan 18, 2025
470db1c
Simplify code by using the URI::resource() accessor
thekid Jan 19, 2025
98cdcdf
Prevent double-flush
thekid Jan 19, 2025
3e4a6b0
Test translating text and binary messages to SSE
thekid Jan 19, 2025
7edfaca
Show backend port during server startup
thekid Jan 19, 2025
9d0f80e
Rename WsProtocol -> WebsocketProtocol for naming consistency
thekid Jan 19, 2025
170e39b
Add tests for web.io.EventSink
thekid Jan 19, 2025
f409f0c
Rename TranslateMessages -> ForwardMessages
thekid Jan 19, 2025
ee3a34b
Consistently use "resource" instead of "uri" in logging API
thekid Jan 19, 2025
9d2bd54
Test implicit and explicit text messages
thekid Jan 19, 2025
9d97940
Fix integration tests
thekid Jan 19, 2025
e24aedd
QA: Apidoc types
thekid Jan 19, 2025
3ba8c75
Fix "Cannot unpack Traversable with string keys" (PHP 7.4, 8.0)
thekid Jan 19, 2025
1460267
Rename "uri" to "resource" in web.io.Input
thekid Jan 19, 2025
d06d384
Ensure socket to backend is closed
thekid Jan 19, 2025
bb27996
Move HttpProtocolTest into dedicated package
thekid Jan 19, 2025
c2fd23b
Add tests for WebsocketProtocol
thekid Jan 19, 2025
7e1d2fa
Test ping and close handling
thekid Jan 19, 2025
29e4d7d
Simplify test code by extracting common code to instance variable
thekid Jan 20, 2025
11428a4
Test logging messages
thekid Jan 25, 2025
5490ca6
Remove compatibility with older xp-framework/networking libraries
thekid Jan 26, 2025
d0baa95
Consistently spell `WebSocket`, step 1
thekid Jan 26, 2025
8ae6e95
Consistently spell `WebSocket`, step 2
thekid Jan 26, 2025
f195e9c
Consistently spell `WebSocket`, step 3
thekid Jan 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php-versions: ['7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
os: [ubuntu-latest, windows-latest]

steps:
Expand Down
5 changes: 5 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ Web change log

## ?.?.? / ????-??-??

## 5.0.0 / ????-??-??

* **Heads up:** Dropped support for PHP < 7.4, see xp-framework/rfc#343
(@thekid)

## 4.5.2 / 2025-01-05

* Fixed server to write warnings when not being able to send HTTP headers
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Web applications for the XP Framework
[![Build status on GitHub](https://github.com/xp-forge/web/workflows/Tests/badge.svg)](https://github.com/xp-forge/web/actions)
[![XP Framework Module](https://raw.githubusercontent.com/xp-framework/web/master/static/xp-framework-badge.png)](https://github.com/xp-framework/core)
[![BSD Licence](https://raw.githubusercontent.com/xp-framework/web/master/static/licence-bsd.png)](https://github.com/xp-framework/core/blob/master/LICENCE.md)
[![Requires PHP 7.0+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-7_0plus.svg)](http://php.net/)
[![Requires PHP 7.4+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-7_4plus.svg)](http://php.net/)
[![Supports PHP 8.0+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-8_0plus.svg)](http://php.net/)
[![Latest Stable Version](https://poser.pugx.org/xp-forge/web/version.svg)](https://packagist.org/packages/xp-forge/web)

Expand Down
7 changes: 4 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
"keywords": ["module", "xp"],
"require" : {
"xp-framework/core": "^12.0 | ^11.0 | ^10.0",
"xp-framework/networking": "^10.1 | ^9.3",
"xp-forge/uri": "^3.0 | ^2.0 | ^1.4",
"php": ">=7.0.0"
"xp-framework/networking": "^10.1",
"xp-forge/uri": "^3.1",
"xp-forge/websockets": "^4.1",
"php": ">=7.4.0"
},
"require-dev" : {
"xp-framework/test": "^2.0 | ^1.0"
Expand Down
44 changes: 39 additions & 5 deletions src/it/php/web/unittest/IntegrationTest.class.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
<?php namespace web\unittest;

use test\{Assert, After, Test, Values};
use peer\ProtocolException;
use test\{Assert, After, Expect, Test, Values};
use util\Bytes;
use websocket\WebSocket;

#[StartServer(TestingServer::class)]
class IntegrationTest {
const FORM_URLENCODED = 'application/x-www-form-urlencoded';
const FORM_URLENCODED= 'application/x-www-form-urlencoded';

private $server;

Expand All @@ -13,9 +16,10 @@ public function __construct($server) {
$this->server= $server;
}

#[After]
public function shutdown() {
$this->server->shutdown();
/** @return iterable */
private function messages() {
yield ['Test', 'Echo: Test'];
yield [new Bytes([8, 15]), new Bytes([47, 11, 8, 15])];
}

/**
Expand Down Expand Up @@ -161,4 +165,34 @@ public function with_large_cookie($length) {
$r= $this->send('GET', '/cookie', '1.0', ['Cookie' => $header]);
Assert::equals((string)strlen($header), $r['body']);
}

#[Test, Values(from: 'messages')]
public function websocket_message($input, $output) {
try {
$ws= new WebSocket($this->server->connection, '/ws');
$ws->connect();
$ws->send($input);
$result= $ws->receive();
} finally {
$ws->close();
}
Assert::equals($output, $result);
}

#[Test, Expect(class: ProtocolException::class, message: 'Connection closed (#1007): Not valid utf-8')]
public function invalid_utf8_passed_to_websocket_text_message() {
try {
$ws= new WebSocket($this->server->connection, '/ws');
$ws->connect();
$ws->send("\xfc");
$ws->receive();
} finally {
$ws->close();
}
}

#[After]
public function shutdown() {
$this->server->shutdown();
}
}
13 changes: 11 additions & 2 deletions src/it/php/web/unittest/TestingApplication.class.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
<?php namespace web\unittest;

use Throwable;
use lang\XPClass;
use test\Assert;
use util\Bytes;
use web\handler\WebSocket;
use web\{Application, Error};

class TestingApplication extends Application {

/** @return var */
public function routes() {
return [
'/ws' => new WebSocket(function($conn, $payload) {
if ($payload instanceof Bytes) {
$conn->send(new Bytes("\057\013{$payload}"));
} else {
$conn->send('Echo: '.$payload);
}
}),
'/status/420' => function($req, $res) {
$res->answer(420, $req->param('message') ?? 'Enhance your calm');
$res->send('Answered with status 420', 'text/plain');
Expand All @@ -20,7 +29,7 @@ public function routes() {
},
'/raise/exception' => function($req, $res) {
$class= XPClass::forName(basename($req->uri()->path()));
if ($class->isSubclassOf(\Throwable::class)) throw $class->newInstance('Raised');
if ($class->isSubclassOf(Throwable::class)) throw $class->newInstance('Raised');

// A non-exception class was passed!
$res->answer(200, 'No error');
Expand Down
8 changes: 6 additions & 2 deletions src/it/php/web/unittest/TestingServer.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use peer\server\AsyncServer;
use util\cmd\Console;
use web\{Environment, Logging};
use xp\web\srv\HttpProtocol;
use xp\web\srv\{Protocol, HttpProtocol, WebSocketProtocol};

/**
* Socket server used by integration tests.
Expand All @@ -23,10 +23,14 @@ class TestingServer {
public static function main(array $args) {
$application= new TestingApplication(new Environment('test', '.', '.', '.', [], null));
$socket= new ServerSocket('127.0.0.1', $args[0] ?? 0);
$log= new Logging(null);

$s= new AsyncServer();
try {
$s->listen($socket, HttpProtocol::executing($application, new Logging(null)));
$s->listen($socket, Protocol::multiplex()
->serving('http', new HttpProtocol($application, $log))
->serving('websocket', new WebSocketProtocol(null, $log))
);
$s->init();
Console::writeLinef('+ Service %s:%d', $socket->host, $socket->port);
$s->service();
Expand Down
11 changes: 9 additions & 2 deletions src/main/php/web/Application.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,14 @@ public function install($arg) {
*
* @param web.Request $request
* @param web.Response $response
* @return var
* @return iterable
*/
public function service($request, $response) {
$seen= [];

// Handle dispatching
dispatch: $result= $this->routing()->handle($request, $response);
$return= null;
if ($result instanceof Traversable) {
foreach ($result as $kind => $argument) {
if ('dispatch' === $kind) {
Expand All @@ -95,10 +96,16 @@ public function service($request, $response) {
throw new Error(508, 'Internal redirect loop caused by dispatch to '.$argument);
}
goto dispatch;
} else if ('connection' === $kind) {
$response->header('Connection', 'upgrade');
$response->header('Upgrade', $argument[0]);
$return= $argument;
} else {
yield $kind => $argument;
}
yield $kind => $argument;
}
}
return $return;
}

/** @return string */
Expand Down
24 changes: 21 additions & 3 deletions src/main/php/web/Logging.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,33 @@ public function tee($sink) {
}

/**
* Writes a log entry
* Writes an HTTP exchange to the log
*
* @param web.Request $response
* @param web.Response $response
* @param [:var] $hints Optional hints
* @return void
*/
public function log($request, $response, $hints= []) {
$this->sink && $this->sink->log($request, $response, $hints);
public function exchange($request, $response, $hints= []) {
$this->sink && $this->sink->log(
$response->status(),
$request->method(),
$request->uri()->resource(),
$response->trace + $hints
);
}

/**
* Writes a log entry
*
* @param string $status
* @param string $method
* @param string $resource
* @param [:var] $hints Optional hints
* @return void
*/
public function log($status, $method, $resource, $hints= []) {
$this->sink && $this->sink->log($status, $method, $resource, $hints);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/main/php/web/Request.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function __construct(Input $input) {
}

$this->method= $input->method();
$this->uri= (new URI($input->scheme().'://'.$this->header('Host', 'localhost').$input->uri()))->canonicalize();
$this->uri= (new URI($input->scheme().'://'.$this->header('Host', 'localhost').$input->resource()))->canonicalize();
$this->input= $input;
}

Expand Down
70 changes: 70 additions & 0 deletions src/main/php/web/handler/WebSocket.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php namespace web\handler;

use web\Handler;
use web\io\EventSink;
use websocket\Listeners;

/**
* WebSocket handler used for routing websocket handshake requests
*
* @test web.unittest.handler.WebSocketTest
* @see https://www.rfc-editor.org/rfc/rfc6455
*/
class WebSocket implements Handler {
const GUID= '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

private $listener;

/** @param function(websocket.protocol.Connection, string|util.Bytes): var|websocket.Listener $listener */
public function __construct($listener) {
$this->listener= Listeners::cast($listener);
}

/**
* Handles a request
*
* @param web.Request $request
* @param web.Response $response
* @return var
*/
public function handle($request, $response) {
switch ($version= (int)$request->header('Sec-WebSocket-Version')) {
case 13: // RFC 6455
$key= $request->header('Sec-WebSocket-Key');
$response->answer(101);
$response->header('Sec-WebSocket-Accept', base64_encode(sha1($key.self::GUID, true)));
foreach ($this->listener->protocols ?? [] as $protocol) {
$response->header('Sec-WebSocket-Protocol', $protocol, true);
}
break;

case 9: // Reserved version, use for WS <-> SSE translation
$response->answer(200);
$response->header('Content-Type', 'text/event-stream');
$response->header('Transfer-Encoding', 'chunked');
$response->trace('websocket', $request->header('Sec-WebSocket-Id'));

$events= new EventSink($request, $response);
foreach ($events->receive() as $message) {
$this->listener->message($events, $message);
}
return;

case 0:
$response->answer(426);
$response->send('This service requires use of the WebSocket protocol', 'text/plain');
return;

default:
$response->answer(400);
$response->send('This service does not support WebSocket version '.$version, 'text/plain');
return;
}

yield 'connection' => ['websocket', [
'path' => $request->uri()->resource(),
'headers' => $request->headers(),
'listener' => $this->listener,
]];
}
}
61 changes: 61 additions & 0 deletions src/main/php/web/io/EventSink.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php namespace web\io;

use io\streams\Streams;
use lang\IllegalStateException;
use util\Bytes;
use websocket\protocol\{Opcodes, Connection};

/** @test web.unittest.io.EventSinkTest */
class EventSink extends Connection {
private $request, $out;

/**
* Creates a new event sink
*
* @param web.Request $request
* @param web.Response $response
*/
public function __construct($request, $response) {
$this->request= $request;
$this->out= $response->stream();
parent::__construct(null, null, null, $request->uri()->resource(), $request->headers());
}

/**
* Receives messages
*
* @return iterable
*/
public function receive() {
switch ($mime= $this->request->header('Content-Type')) {
case 'text/plain': yield Opcodes::TEXT => Streams::readAll($this->request->stream()); break;
case 'application/octet-stream': yield Opcodes::BINARY => new Bytes(Streams::readAll($this->request->stream())); break;
default: throw new IllegalStateException('Unexpected content type '.$mime);
}
}

/**
* Sends a websocket message
*
* @param string|util.Bytes $message
* @return void
*/
public function send($message) {
if ($message instanceof Bytes) {
$this->out->write("event: bytes\ndata: ".addcslashes($message, "\r\n")."\n\n");
} else {
$this->out->write("data: ".addcslashes($message, "\r\n")."\n\n");
}
}

/**
* Closes the websocket connection
*
* @param int $code
* @param string $reason
* @return void
*/
public function close($code= 1000, $reason= '') {
$this->out->write("event: close\ndata: ".$code.':'.addcslashes($reason, "\r\n")."\n\n");
}
}
Loading
Loading