http2: handle 100-continue flow & writeContinue

Adds an implementation for writeContinue based on the h2 spec & the
existing http implementation. ClientHttp2Stream now also emits
a continue event when it receives headers with :status 100.
Includes two test cases for default server continue behaviour and
for the exposed checkContinue listener.

PR-URL: https://github.com/nodejs/node/pull/15039
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
This commit is contained in:
Anatoli Papirovski 2017-08-30 10:12:24 -04:00 committed by James M Snell
parent 0f7c06eb2d
commit ad3d2ce68a
5 changed files with 223 additions and 5 deletions

View File

@ -849,6 +849,15 @@ used exclusively on HTTP/2 Clients. `Http2Stream` instances on the client
provide events such as `'response'` and `'push'` that are only relevant on provide events such as `'response'` and `'push'` that are only relevant on
the client. the client.
#### Event: 'continue'
<!-- YAML
added: REPLACEME
-->
Emitted when the server sends a `100 Continue` status, usually because
the request contained `Expect: 100-continue`. This is an instruction that
the client should send the request body.
#### Event: 'headers' #### Event: 'headers'
<!-- YAML <!-- YAML
added: v8.4.0 added: v8.4.0
@ -1306,6 +1315,28 @@ added: v8.4.0
The `'timeout'` event is emitted when there is no activity on the Server for The `'timeout'` event is emitted when there is no activity on the Server for
a given number of milliseconds set using `http2server.setTimeout()`. a given number of milliseconds set using `http2server.setTimeout()`.
#### Event: 'checkContinue'
<!-- YAML
added: REPLACEME
-->
* `request` {http2.Http2ServerRequest}
* `response` {http2.Http2ServerResponse}
If a [`'request'`][] listener is registered or [`'http2.createServer()'`][] is
supplied a callback function, the `'checkContinue'` event is emitted each time
a request with an HTTP `Expect: 100-continue` is received. If this event is
not listened for, the server will automatically respond with a status
`100 Continue` as appropriate.
Handling this event involves calling [`response.writeContinue()`][] if the client
should continue to send the request body, or generating an appropriate HTTP
response (e.g. 400 Bad Request) if the client should not continue to send the
request body.
Note that when this event is emitted and handled, the [`'request'`][] event will
not be emitted.
### Class: Http2SecureServer ### Class: Http2SecureServer
<!-- YAML <!-- YAML
added: v8.4.0 added: v8.4.0
@ -1389,6 +1420,28 @@ per session. See the [Compatibility API](compatiblity-api).
added: v8.4.0 added: v8.4.0
--> -->
#### Event: 'checkContinue'
<!-- YAML
added: REPLACEME
-->
* `request` {http2.Http2ServerRequest}
* `response` {http2.Http2ServerResponse}
If a [`'request'`][] listener is registered or [`'http2.createSecureServer()'`][]
is supplied a callback function, the `'checkContinue'` event is emitted each
time a request with an HTTP `Expect: 100-continue` is received. If this event
is not listened for, the server will automatically respond with a status
`100 Continue` as appropriate.
Handling this event involves calling [`response.writeContinue()`][] if the client
should continue to send the request body, or generating an appropriate HTTP
response (e.g. 400 Bad Request) if the client should not continue to send the
request body.
Note that when this event is emitted and handled, the [`'request'`][] event will
not be emitted.
### http2.createServer(options[, onRequestHandler]) ### http2.createServer(options[, onRequestHandler])
<!-- YAML <!-- YAML
added: v8.4.0 added: v8.4.0
@ -2537,8 +2590,9 @@ buffer. Returns `false` if all or part of the data was queued in user memory.
added: v8.4.0 added: v8.4.0
--> -->
Throws an error as the `'continue'` flow is not current implemented. Added for Sends a status `100 Continue` to the client, indicating that the request body
parity with [HTTP/1](). should be sent. See the [`'checkContinue'`][] event on `Http2Server` and
`Http2SecureServer`.
### response.writeHead(statusCode[, statusMessage][, headers]) ### response.writeHead(statusCode[, statusMessage][, headers])
<!-- YAML <!-- YAML
@ -2618,6 +2672,7 @@ if the stream is closed.
[Settings Object]: #http2_settings_object [Settings Object]: #http2_settings_object
[Using options.selectPadding]: #http2_using_options_selectpadding [Using options.selectPadding]: #http2_using_options_selectpadding
[Writable Stream]: stream.html#stream_writable_streams [Writable Stream]: stream.html#stream_writable_streams
[`'checkContinue'`]: #http2_event_checkcontinue
[`'request'`]: #http2_event_request [`'request'`]: #http2_event_request
[`'unknownProtocol'`]: #http2_event_unknownprotocol [`'unknownProtocol'`]: #http2_event_unknownprotocol
[`ClientHttp2Stream`]: #http2_class_clienthttp2stream [`ClientHttp2Stream`]: #http2_class_clienthttp2stream
@ -2628,7 +2683,9 @@ if the stream is closed.
[`ServerRequest`]: #http2_class_server_request [`ServerRequest`]: #http2_class_server_request
[`TypeError`]: errors.html#errors_class_typeerror [`TypeError`]: errors.html#errors_class_typeerror
[`http2.SecureServer`]: #http2_class_http2secureserver [`http2.SecureServer`]: #http2_class_http2secureserver
[`http2.createSecureServer()`]: #http2_createsecureserver_options_onrequesthandler
[`http2.Server`]: #http2_class_http2server [`http2.Server`]: #http2_class_http2server
[`http2.createServer()`]: #http2_createserver_options_onrequesthandler
[`net.Socket`]: net.html#net_class_net_socket [`net.Socket`]: net.html#net_class_net_socket
[`request.socket.getPeerCertificate()`]: tls.html#tls_tlssocket_getpeercertificate_detailed [`request.socket.getPeerCertificate()`]: tls.html#tls_tlssocket_getpeercertificate_detailed
[`response.end()`]: #http2_response_end_data_encoding_callback [`response.end()`]: #http2_response_end_data_encoding_callback
@ -2636,6 +2693,7 @@ if the stream is closed.
[`response.socket`]: #http2_response_socket [`response.socket`]: #http2_response_socket
[`response.write()`]: #http2_response_write_chunk_encoding_callback [`response.write()`]: #http2_response_write_chunk_encoding_callback
[`response.write(data, encoding)`]: http.html#http_response_write_chunk_encoding_callback [`response.write(data, encoding)`]: http.html#http_response_write_chunk_encoding_callback
[`response.writeContinue()`]: #http2_response_writecontinue
[`response.writeHead()`]: #http2_response_writehead_statuscode_statusmessage_headers [`response.writeHead()`]: #http2_response_writehead_statuscode_statusmessage_headers
[`stream.pushStream()`]: #http2_stream-pushstream [`stream.pushStream()`]: #http2_stream-pushstream
[`tls.TLSSocket`]: tls.html#tls_class_tls_tlssocket [`tls.TLSSocket`]: tls.html#tls_class_tls_tlssocket

View File

@ -529,9 +529,14 @@ class Http2ServerResponse extends Stream {
this.emit('finish'); this.emit('finish');
} }
// TODO doesn't support callbacks
writeContinue() { writeContinue() {
// TODO mcollina check what is the continue flow const stream = this[kStream];
throw new Error('not implemented yet'); if (stream === undefined) return false;
this[kStream].additionalHeaders({
[constants.HTTP2_HEADER_STATUS]: constants.HTTP_STATUS_CONTINUE
});
return true;
} }
} }
@ -556,7 +561,7 @@ function onServerStream(stream, headers, flags) {
if (server.listenerCount('checkContinue')) { if (server.listenerCount('checkContinue')) {
server.emit('checkContinue', request, response); server.emit('checkContinue', request, response);
} else { } else {
response.sendContinue(); response.writeContinue();
server.emit('request', request, response); server.emit('request', request, response);
} }
} else if (server.listenerCount('checkExpectation')) { } else if (server.listenerCount('checkExpectation')) {

View File

@ -120,6 +120,7 @@ const {
HTTP2_METHOD_HEAD, HTTP2_METHOD_HEAD,
HTTP2_METHOD_CONNECT, HTTP2_METHOD_CONNECT,
HTTP_STATUS_CONTINUE,
HTTP_STATUS_CONTENT_RESET, HTTP_STATUS_CONTENT_RESET,
HTTP_STATUS_OK, HTTP_STATUS_OK,
HTTP_STATUS_NO_CONTENT, HTTP_STATUS_NO_CONTENT,
@ -2113,10 +2114,17 @@ class ClientHttp2Stream extends Http2Stream {
this[kState].headersSent = true; this[kState].headersSent = true;
if (id !== undefined) if (id !== undefined)
this[kInit](id); this[kInit](id);
this.on('headers', handleHeaderContinue);
debug(`[${sessionName(session[kType])}] clienthttp2stream created`); debug(`[${sessionName(session[kType])}] clienthttp2stream created`);
} }
} }
function handleHeaderContinue(headers) {
if (headers[HTTP2_HEADER_STATUS] === HTTP_STATUS_CONTINUE) {
this.emit('continue');
}
}
const setTimeout = { const setTimeout = {
configurable: true, configurable: true,
enumerable: true, enumerable: true,

View File

@ -0,0 +1,81 @@
// Flags: --expose-http2
'use strict';
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
const assert = require('assert');
const http2 = require('http2');
const testResBody = 'other stuff!\n';
// Checks the full 100-continue flow from client sending 'expect: 100-continue'
// through server receiving it, triggering 'checkContinue' custom handler,
// writing the rest of the request to finally the client receiving to.
function handler(req, res) {
console.error('Server sent full response');
res.writeHead(200, {
'content-type': 'text/plain',
'abcd': '1'
});
res.end(testResBody);
}
const server = http2.createServer(
common.mustNotCall('Full request received before 100 Continue')
);
server.on('checkContinue', common.mustCall((req, res) => {
console.error('Server received Expect: 100-continue');
res.writeContinue();
// timeout so that we allow the client to receive continue first
setTimeout(
common.mustCall(() => handler(req, res)),
common.platformTimeout(100)
);
}));
server.listen(0);
server.on('listening', common.mustCall(() => {
let body = '';
const port = server.address().port;
const client = http2.connect(`http://localhost:${port}`);
const req = client.request({
':method': 'POST',
':path': '/world',
expect: '100-continue'
});
console.error('Client sent request');
let gotContinue = false;
req.on('continue', common.mustCall(() => {
console.error('Client received 100-continue');
gotContinue = true;
}));
req.on('response', common.mustCall((headers) => {
console.error('Client received response headers');
assert.strictEqual(gotContinue, true);
assert.strictEqual(headers[':status'], 200);
assert.strictEqual(headers['abcd'], '1');
}));
req.setEncoding('utf-8');
req.on('data', common.mustCall((chunk) => { body += chunk; }));
req.on('end', common.mustCall(() => {
console.error('Client received full response');
assert.strictEqual(body, testResBody);
client.destroy();
server.close();
}));
}));

View File

@ -0,0 +1,66 @@
// Flags: --expose-http2
'use strict';
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
const assert = require('assert');
const http2 = require('http2');
const testResBody = 'other stuff!\n';
// Checks the full 100-continue flow from client sending 'expect: 100-continue'
// through server receiving it, sending back :status 100, writing the rest of
// the request to finally the client receiving to.
const server = http2.createServer();
let sentResponse = false;
server.on('request', common.mustCall((req, res) => {
console.error('Server sent full response');
res.end(testResBody);
sentResponse = true;
}));
server.listen(0);
server.on('listening', common.mustCall(() => {
let body = '';
const port = server.address().port;
const client = http2.connect(`http://localhost:${port}`);
const req = client.request({
':method': 'POST',
':path': '/world',
expect: '100-continue'
});
console.error('Client sent request');
let gotContinue = false;
req.on('continue', common.mustCall(() => {
console.error('Client received 100-continue');
gotContinue = true;
}));
req.on('response', common.mustCall((headers) => {
console.error('Client received response headers');
assert.strictEqual(gotContinue, true);
assert.strictEqual(sentResponse, true);
assert.strictEqual(headers[':status'], 200);
}));
req.setEncoding('utf8');
req.on('data', common.mustCall((chunk) => { body += chunk; }));
req.on('end', common.mustCall(() => {
console.error('Client received full response');
assert.strictEqual(body, testResBody);
client.destroy();
server.close();
}));
}));