diff --git a/src/Response.php b/src/Response.php index 36e375fc..a5c76d91 100644 --- a/src/Response.php +++ b/src/Response.php @@ -13,6 +13,7 @@ class Response extends EventEmitter implements WritableStreamInterface private $conn; private $headWritten = false; private $chunkedEncoding = true; + public $closeConnection = false; public function __construct(ConnectionInterface $conn) { @@ -64,6 +65,12 @@ public function writeHead($status = 200, array $headers = array()) $headers['Transfer-Encoding'] = 'chunked'; } + if ($this->closeConnection) { + $headers['Connection'] = 'close'; + } else { + $headers['Connection'] = 'keep-alive'; + } + $data = $this->formatHead($status, $headers); $this->conn->write($data); @@ -119,7 +126,9 @@ public function end($data = null) $this->emit('end'); $this->removeAllListeners(); - $this->conn->end(); + if ($this->closeConnection) { + $this->conn->end(); + } } public function close() @@ -133,6 +142,5 @@ public function close() $this->writable = false; $this->emit('close'); $this->removeAllListeners(); - $this->conn->close(); } } diff --git a/src/Server.php b/src/Server.php index e1abfd8e..37fa5469 100644 --- a/src/Server.php +++ b/src/Server.php @@ -6,54 +6,88 @@ use React\Socket\ServerInterface as SocketServerInterface; use React\Socket\ConnectionInterface; + +class ConnProxy { + public $id; + public $count; + public $conn; + public function __construct($id, ConnectionInterface $conn) + { + $this->id = $id; + $this->conn = $conn; + $this->count = 0; + } +} + /** @event request */ class Server extends EventEmitter implements ServerInterface { - private $io; - + const KEEPALIVE_MAX_REQUEST = 25; + private $connectionsCount = 0; public function __construct(SocketServerInterface $io) { - $this->io = $io; - - $this->io->on('connection', function (ConnectionInterface $conn) { - // TODO: http 1.1 keep-alive - // TODO: chunked transfer encoding (also for outgoing data) - // TODO: multipart parsing - - $parser = new RequestHeaderParser(); - $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $parser) { - // attach remote ip to the request as metadata - $request->remoteAddress = $conn->getRemoteAddress(); - - $this->handleRequest($conn, $request, $bodyBuffer); - - $conn->removeListener('data', array($parser, 'feed')); - $conn->on('end', function () use ($request) { - $request->emit('end'); - }); - $conn->on('data', function ($data) use ($request) { - $request->emit('data', array($data)); - }); - $request->on('pause', function () use ($conn) { - $conn->emit('pause'); - }); - $request->on('resume', function () use ($conn) { - $conn->emit('resume'); - }); + $io->on('connection', function (ConnectionInterface $conn) { + $this->connectionsCount += 1; + $this->handleConnection(new ConnProxy($this->connectionsCount, $conn)); + }); + } + + public function handleConnection(ConnProxy $connProxy) { + $conn = $connProxy->conn; + // TODO: chunked transfer encoding (also for outgoing data) + // TODO: multipart parsing + $parser = new RequestHeaderParser(); + $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $parser, $connProxy) { + // attach remote ip to the request as metadata + $request->remoteAddress = $conn->getRemoteAddress(); + + $conn->removeListener('data', array($parser, 'feed')); + $conn->on('end', function () use ($request) { + $request->emit('end'); + }); + $conn->on('data', function ($data) use ($request) { + $request->emit('data', array($data)); + }); + $request->on('pause', function () use ($conn) { + $conn->emit('pause'); + }); + $request->on('resume', function () use ($conn) { + $conn->emit('resume'); }); - $conn->on('data', array($parser, 'feed')); + $this->handleRequest($connProxy, $request, $bodyBuffer); }); + + $conn->on('data', array($parser, 'feed')); } - public function handleRequest(ConnectionInterface $conn, Request $request, $bodyBuffer) + public function handleRequest(ConnProxy $connProxy, Request $request, $bodyBuffer) { + $conn = $connProxy->conn; $response = new Response($conn); + + //Keepalive max requests + $connProxy->count += 1; + if ($connProxy->count >= self::KEEPALIVE_MAX_REQUEST) { + $response->closeConnection = true; + } + + $headers = $request->getHeaders(); + if (empty($headers['Connection']) || $headers['Connection'] == 'close') { + $response->closeConnection = true; + } + $response->on('close', array($request, 'close')); + //Handle keepalive + $response->on('end', function() use ($conn, $response, $connProxy) { + if (! $response->closeConnection) { + $this->handleConnection($connProxy); + } + }); + if (!$this->listeners('request')) { $response->end(); - return; } diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 7692fb83..0e3e4f50 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -12,6 +12,7 @@ public function testResponseShouldBeChunkedByDefault() $expected .= "HTTP/1.1 200 OK\r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: keep-alive\r\n"; $expected .= "\r\n"; $conn = $this->getMock('React\Socket\ConnectionInterface'); @@ -30,6 +31,7 @@ public function testResponseShouldNotBeChunkedWithContentLength() $expected .= "HTTP/1.1 200 OK\r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "Content-Length: 22\r\n"; + $expected .= "Connection: keep-alive\r\n"; $expected .= "\r\n"; $conn = $this->getMock('React\Socket\ConnectionInterface'); @@ -132,6 +134,7 @@ public function shouldRemoveNewlinesFromHeaders() $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "FooBar: BazQux\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: keep-alive\r\n"; $expected .= "\r\n"; $conn = $this->getMock('React\Socket\ConnectionInterface'); @@ -151,6 +154,7 @@ public function missingStatusCodeTextShouldResultInNumberOnlyStatus() $expected .= "HTTP/1.1 700 \r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: keep-alive\r\n"; $expected .= "\r\n"; $conn = $this->getMock('React\Socket\ConnectionInterface'); @@ -172,6 +176,7 @@ public function shouldAllowArrayHeaderValues() $expected .= "Set-Cookie: foo=bar\r\n"; $expected .= "Set-Cookie: bar=baz\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: keep-alive\r\n"; $expected .= "\r\n"; $conn = $this->getMock('React\Socket\ConnectionInterface'); @@ -191,6 +196,7 @@ public function shouldIgnoreHeadersWithNullValues() $expected .= "HTTP/1.1 200 OK\r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: keep-alive\r\n"; $expected .= "\r\n"; $conn = $this->getMock('React\Socket\ConnectionInterface'); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 65c13ccb..9d6dfd28 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -66,11 +66,79 @@ public function testResponseContainsPoweredByHeader() $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $conn->getData()); } - private function createGetRequest() + public function testKeepaliveCloseAfterMax() + { + $io = new ServerStub(); + + $server = new Server($io); + $server->on('request', function ($request, $response) { + $response->writeHead(); + $response->end(); + }); + + $conn = new ConnectionStub(); + $io->emit('connection', array($conn)); + + for ($i=0; $icreateGetRequest(false); + $conn->emit('data', array($data)); + $this->assertContains("\r\nConnection: keep-alive\r\n", $conn->getData()); + + if ($i < Server::KEEPALIVE_MAX_REQUEST - 1) { + $this->assertNotContains("\r\nConnection: close\r\n", $conn->getData()); + } else { + $this->assertContains("\r\nConnection: close\r\n", $conn->getData()); + } + } + } + + public function testKeepaliveClose() + { + $io = new ServerStub(); + + $server = new Server($io); + $server->on('request', function ($request, $response) { + $response->writeHead(); + $response->end(); + }); + + $conn = new ConnectionStub(); + $io->emit('connection', array($conn)); + + $data = $this->createGetRequest(true); + $conn->emit('data', array($data)); + + $this->assertContains("\r\nConnection: close\r\n", $conn->getData()); + } + + public function testKeepaliveKeep() + { + $io = new ServerStub(); + + $server = new Server($io); + $server->on('request', function ($request, $response) { + $response->writeHead(); + $response->end(); + }); + + $conn = new ConnectionStub(); + $io->emit('connection', array($conn)); + + $data = $this->createGetRequest(false); + $conn->emit('data', array($data)); + + $this->assertContains("\r\nConnection: keep-alive\r\n", $conn->getData()); + } + + private function createGetRequest($close = true) { $data = "GET / HTTP/1.1\r\n"; $data .= "Host: example.com:80\r\n"; - $data .= "Connection: close\r\n"; + if ($close) { + $data .= "Connection: close\r\n"; + } else { + $data .= "Connection: keep-alive\r\n"; + } $data .= "\r\n"; return $data;