http: better support for CONNECT method.
authorkoichik <koichik@improvement.jp>
Mon, 9 Jan 2012 02:51:06 +0000 (03:51 +0100)
committerkoichik <koichik@improvement.jp>
Mon, 9 Jan 2012 02:51:06 +0000 (03:51 +0100)
Introduces 'connect' event on both client (http.ClientRequest) and
server (http.Server).

Refs: #2259, #2474.
Fixes #1576.

doc/api/http.markdown
lib/http.js
test/simple/test-http-connect.js [new file with mode: 0644]

index 57f13a4..c9a2dc0 100644 (file)
@@ -66,6 +66,24 @@ request body.
 Note that when this event is emitted and handled, the `request` event will
 not be emitted.
 
+### Event: 'connect'
+
+`function (request, socket, head) { }`
+
+Emitted each time a client requests a http CONNECT method. If this event isn't
+listened for, then clients requesting a CONNECT method will have their
+connections closed.
+
+* `request` is the arguments for the http request, as it is in the request
+  event.
+* `socket` is the network socket between the server and client.
+* `head` is an instance of Buffer, the first packet of the tunneling stream,
+  this may be empty.
+
+After this event is emitted, the request's socket will not have a `data`
+event listener, meaning you will need to bind to it in order to handle data
+sent to the server on that socket.
+
 ### Event: 'upgrade'
 
 `function (request, socket, head) { }`
@@ -74,9 +92,11 @@ Emitted each time a client requests a http upgrade. If this event isn't
 listened for, then clients requesting an upgrade will have their connections
 closed.
 
-* `request` is the arguments for the http request, as it is in the request event.
+* `request` is the arguments for the http request, as it is in the request
+  event.
 * `socket` is the network socket between the server and client.
-* `head` is an instance of Buffer, the first packet of the upgraded stream, this may be empty.
+* `head` is an instance of Buffer, the first packet of the upgraded stream,
+  this may be empty.
 
 After this event is emitted, the request's socket will not have a `data`
 event listener, meaning you will need to bind to it in order to handle data
@@ -593,6 +613,69 @@ Options:
 
 Emitted after a socket is assigned to this request.
 
+### Event: 'connect'
+
+`function (response, socket, head) { }`
+
+Emitted each time a server responds to a request with a CONNECT method. If this
+event isn't being listened for, clients receiving a CONNECT method will have
+their connections closed.
+
+A client server pair that show you how to listen for the `connect` event.
+
+    var http = require('http');
+    var net = require('net');
+    var url = require('url');
+
+    // Create an HTTP tunneling proxy
+    var proxy = http.createServer(function (req, res) {
+      res.writeHead(200, {'Content-Type': 'text/plain'});
+      res.end('okay');
+    });
+    proxy.on('connect', function(req, cltSocket, head) {
+      // connect to an origin server
+      var srvUrl = url.parse('http://' + req.url);
+      var srvSocket = net.connect(srvUrl.port, srvUrl.hostname, function() {
+        cltSocket.write('HTTP/1.1 200 Connection Established\r\n' +
+                        'Proxy-agent: Node-Proxy\r\n' +
+                        '\r\n');
+        srvSocket.write(head);
+        srvSocket.pipe(cltSocket);
+        cltSocket.pipe(srvSocket);
+      });
+    });
+
+    // now that proxy is running
+    proxy.listen(1337, '127.0.0.1', function() {
+
+      // make a request to a tunneling proxy
+      var options = {
+        port: 1337,
+        host: '127.0.0.1',
+        method: 'CONNECT',
+        path: 'www.google.com:80'
+      };
+
+      var req = http.request(options);
+      req.end();
+
+      req.on('connect', function(res, socket, head) {
+        console.log('got connected!');
+
+        // make a request over an HTTP tunnel
+        socket.write('GET / HTTP/1.1\r\n' +
+                     'Host: www.google.com:80\r\n' +
+                     'Connection: close\r\n' +
+                     '\r\n');
+        socket.on('data', function(chunk) {
+          console.log(chunk.toString());
+        });
+        socket.on('end', function() {
+          proxy.close();
+        });
+      });
+    });
+
 ### Event: 'upgrade'
 
 `function (response, socket, head) { }`
@@ -601,25 +684,22 @@ Emitted each time a server responds to a request with an upgrade. If this
 event isn't being listened for, clients receiving an upgrade header will have
 their connections closed.
 
-A client server pair that show you how to listen for the `upgrade` event using `http.getAgent`:
+A client server pair that show you how to listen for the `upgrade` event.
 
     var http = require('http');
-    var net = require('net');
 
     // Create an HTTP server
     var srv = http.createServer(function (req, res) {
       res.writeHead(200, {'Content-Type': 'text/plain'});
       res.end('okay');
     });
-    srv.on('upgrade', function(req, socket, upgradeHead) {
+    srv.on('upgrade', function(req, socket, head) {
       socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +
                    'Upgrade: WebSocket\r\n' +
                    'Connection: Upgrade\r\n' +
-                   '\r\n\r\n');
+                   '\r\n');
 
-      socket.ondata = function(data, start, end) {
-        socket.write(data.toString('utf8', start, end), 'utf8'); // echo back
-      };
+      socket.pipe(socket); // echo back
     });
 
     // now that server is running
index a3ade73..badf1b3 100644 (file)
@@ -95,15 +95,16 @@ var parsers = new FreeList('parsers', 1000, function() {
 
     parser.incoming.upgrade = info.upgrade;
 
-    var isHeadResponse = false;
+    var skipBody = false; // response to HEAD or CONNECT
 
     if (!info.upgrade) {
-      // For upgraded connections, we'll emit this after parser.execute
+      // For upgraded connections and CONNECT method request,
+      // we'll emit this after parser.execute
       // so that we can capture the first part of the new protocol
-      isHeadResponse = parser.onIncoming(parser.incoming, info.shouldKeepAlive);
+      skipBody = parser.onIncoming(parser.incoming, info.shouldKeepAlive);
     }
 
-    return isHeadResponse;
+    return skipBody;
   };
 
   parser.onBody = function(b, start, len) {
@@ -1072,7 +1073,7 @@ function ClientRequest(options, cb) {
                    new Buffer(options.auth).toString('base64'));
   }
 
-  if (method === 'GET' || method === 'HEAD') {
+  if (method === 'GET' || method === 'HEAD' || method === 'CONNECT') {
     self.useChunkedEncodingByDefault = false;
   } else {
     self.useChunkedEncodingByDefault = true;
@@ -1174,22 +1175,26 @@ ClientRequest.prototype.onSocket = function(socket) {
         debug('parse error');
         socket.destroy(ret);
       } else if (parser.incoming && parser.incoming.upgrade) {
+        // Upgrade or CONNECT
         var bytesParsed = ret;
-        socket.ondata = null;
-        socket.onend = null;
-
         var res = parser.incoming;
         req.res = res;
 
+        socket.ondata = null;
+        socket.onend = null;
+        parser.finish();
+        parsers.free(parser);
+
         // This is start + byteParsed
-        var upgradeHead = d.slice(start + bytesParsed, end);
-        if (req.listeners('upgrade').length) {
-          // Emit 'upgrade' on the Agent.
-          req.upgraded = true;
-          req.emit('upgrade', res, socket, upgradeHead);
+        var bodyHead = d.slice(start + bytesParsed, end);
+
+        var eventName = req.method === 'CONNECT' ? 'connect' : 'upgrade';
+        if (req.listeners(eventName).length) {
+          req.upgradeOrConnect = true;
+          req.emit(eventName, res, socket, bodyHead);
           socket.emit('agentRemove');
         } else {
-          // Got upgrade header, but have no handler.
+          // Got Upgrade header or CONNECT method, but have no handler.
           socket.destroy();
         }
       }
@@ -1235,6 +1240,12 @@ ClientRequest.prototype.onSocket = function(socket) {
       }
       req.res = res;
 
+      // Responses to CONNECT request is handled as Upgrade.
+      if (req.method === 'CONNECT') {
+        res.upgrade = true;
+        return true; // skip body
+      }
+
       // Responses to HEAD requests are crazy.
       // HEAD responses aren't allowed to have an entity-body
       // but *can* have a content-length which actually corresponds
@@ -1250,7 +1261,8 @@ ClientRequest.prototype.onSocket = function(socket) {
         return true;
       }
 
-      if (req.shouldKeepAlive && res.headers.connection !== 'keep-alive' && !req.upgraded) {
+      if (req.shouldKeepAlive && res.headers.connection !== 'keep-alive' &&
+          !req.upgradeOrConnect) {
         // Server MUST respond with Connection:keep-alive for us to enable it.
         // If we've been upgraded (via WebSockets) we also shouldn't try to
         // keep the connection open.
@@ -1400,6 +1412,14 @@ function connectionListener(socket) {
     // abort socket._httpMessage ?
   }
 
+  function serverSocketCloseListener() {
+    debug('server socket close');
+    // unref the parser for easy gc
+    parsers.free(parser);
+
+    abortIncoming();
+  }
+
   debug('SERVER new http connection');
 
   httpSocketSetup(socket);
@@ -1424,19 +1444,24 @@ function connectionListener(socket) {
       debug('parse error');
       socket.destroy(ret);
     } else if (parser.incoming && parser.incoming.upgrade) {
+      // Upgrade or CONNECT
       var bytesParsed = ret;
+      var req = parser.incoming;
+
       socket.ondata = null;
       socket.onend = null;
-
-      var req = parser.incoming;
+      socket.removeListener('close', serverSocketCloseListener);
+      parser.finish();
+      parsers.free(parser);
 
       // This is start + byteParsed
-      var upgradeHead = d.slice(start + bytesParsed, end);
+      var bodyHead = d.slice(start + bytesParsed, end);
 
-      if (self.listeners('upgrade').length) {
-        self.emit('upgrade', req, req.socket, upgradeHead);
+      var eventName = req.method === 'CONNECT' ? 'connect' : 'upgrade';
+      if (self.listeners(eventName).length) {
+        self.emit(eventName, req, req.socket, bodyHead);
       } else {
-        // Got upgrade header, but have no handler.
+        // Got upgrade header or CONNECT method, but have no handler.
         socket.destroy();
       }
     }
@@ -1463,13 +1488,7 @@ function connectionListener(socket) {
     }
   };
 
-  socket.addListener('close', function() {
-    debug('server socket close');
-    // unref the parser for easy gc
-    parsers.free(parser);
-
-    abortIncoming();
-  });
+  socket.addListener('close', serverSocketCloseListener);
 
   // The following callback is issued after the headers have been read on a
   // new message. In this callback we setup the response object and pass it
diff --git a/test/simple/test-http-connect.js b/test/simple/test-http-connect.js
new file mode 100644 (file)
index 0000000..64b9714
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+var common = require('../common');
+var assert = require('assert');
+var http = require('http');
+
+var serverGotConnect = false;
+var clientGotConnect = false;
+
+var server = http.createServer(function(req, res) {
+  assert(false);
+});
+server.on('connect', function(req, socket, firstBodyChunk) {
+  assert.equal(req.method, 'CONNECT');
+  assert.equal(req.url, 'google.com:443');
+  common.debug('Server got CONNECT request');
+  serverGotConnect = true;
+
+  socket.write('HTTP/1.1 200 Connection established\r\n\r\n');
+
+  var data = firstBodyChunk.toString();
+  socket.on('data', function(buf) {
+    data += buf.toString();
+  });
+  socket.on('end', function() {
+    socket.end(data);
+  });
+});
+server.listen(common.PORT, function() {
+  var req = http.request({
+    port: common.PORT,
+    method: 'CONNECT',
+    path: 'google.com:443'
+  }, function(res) {
+    assert(false);
+  });
+  req.on('connect', function(res, socket, firstBodyChunk) {
+    common.debug('Client got CONNECT request');
+    clientGotConnect = true;
+
+    var data = firstBodyChunk.toString();
+    socket.on('data', function(buf) {
+      data += buf.toString();
+    });
+    socket.on('end', function() {
+      assert.equal(data, 'HeadBody');
+      server.close();
+    });
+    socket.write('Body');
+    socket.end();
+  });
+
+  // It is legal for the client to send some data intended for the server
+  // before the "200 Connection established" (or any other success or
+  // error code) is received.
+  req.write('Head');
+  req.end();
+});
+
+process.on('exit', function() {
+  assert.ok(serverGotConnect);
+  assert.ok(clientGotConnect);
+
+  // Make sure this request got removed from the pool.
+  var name = 'localhost:' + common.PORT;
+  assert(!http.globalAgent.sockets.hasOwnProperty(name));
+  assert(!http.globalAgent.requests.hasOwnProperty(name));
+});