Skip to content

Commit 8f3bbd9

Browse files
jasnelladdaleax
authored andcommitted
http2: add range support for respondWith{File|FD}
* respondWithFD now supports optional statCheck * respondWithFD and respondWithFile both support offset/length for range requests * Fix linting nits following most recent update Backport-PR-URL: #14813 Backport-Reviewed-By: Anna Henningsen <anna@addaleax.net> Backport-Reviewed-By: Timothy Gu <timothygu99@gmail.com> PR-URL: #14239 Reviewed-By: Anna Henningsen <anna@addaleax.net> Reviewed-By: Colin Ihrig <cjihrig@gmail.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent 61696f1 commit 8f3bbd9

33 files changed

+350
-72
lines changed

doc/api/http2.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -998,13 +998,17 @@ server.on('stream', (stream) => {
998998
});
999999
```
10001000

1001-
#### http2stream.respondWithFD(fd[, headers])
1001+
#### http2stream.respondWithFD(fd[, headers[, options]])
10021002
<!-- YAML
10031003
added: REPLACEME
10041004
-->
10051005

10061006
* `fd` {number} A readable file descriptor
10071007
* `headers` {[Headers Object][]}
1008+
* `options` {Object}
1009+
* `statCheck` {Function}
1010+
* `offset` {number} The offset position at which to begin reading
1011+
* `length` {number} The amount of data from the fd to send
10081012

10091013
Initiates a response whose data is read from the given file descriptor. No
10101014
validation is performed on the given file descriptor. If an error occurs while
@@ -1034,6 +1038,16 @@ server.on('stream', (stream) => {
10341038
server.on('close', () => fs.closeSync(fd));
10351039
```
10361040

1041+
The optional `options.statCheck` function may be specified to give user code
1042+
an opportunity to set additional content headers based on the `fs.Stat` details
1043+
of the given fd. If the `statCheck` function is provided, the
1044+
`http2stream.respondWithFD()` method will perform an `fs.fstat()` call to
1045+
collect details on the provided file descriptor.
1046+
1047+
The `offset` and `length` options may be used to limit the response to a
1048+
specific range subset. This can be used, for instance, to support HTTP Range
1049+
requests.
1050+
10371051
#### http2stream.respondWithFile(path[, headers[, options]])
10381052
<!-- YAML
10391053
added: REPLACEME
@@ -1043,6 +1057,8 @@ added: REPLACEME
10431057
* `headers` {[Headers Object][]}
10441058
* `options` {Object}
10451059
* `statCheck` {Function}
1060+
* `offset` {number} The offset position at which to begin reading
1061+
* `length` {number} The amount of data from the fd to send
10461062

10471063
Sends a regular file as the response. The `path` must specify a regular file
10481064
or an `'error'` event will be emitted on the `Http2Stream` object.
@@ -1096,6 +1112,10 @@ server.on('stream', (stream) => {
10961112

10971113
The `content-length` header field will be automatically set.
10981114

1115+
The `offset` and `length` options may be used to limit the response to a
1116+
specific range subset. This can be used, for instance, to support HTTP Range
1117+
requests.
1118+
10991119
### Class: Http2Server
11001120
<!-- YAML
11011121
added: REPLACEME

lib/internal/http2/compat.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ class Http2ServerResponse extends Stream {
452452
stream.once('finish', cb);
453453
}
454454

455-
this[kBeginSend]({endStream: true});
455+
this[kBeginSend]({ endStream: true });
456456

457457
if (stream !== undefined) {
458458
stream.end();

lib/internal/http2/core.js

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,7 +1541,7 @@ function processHeaders(headers) {
15411541
return headers;
15421542
}
15431543

1544-
function processRespondWithFD(fd, headers) {
1544+
function processRespondWithFD(fd, headers, offset = 0, length = -1) {
15451545
const session = this[kSession];
15461546
const state = this[kState];
15471547
state.headersSent = true;
@@ -1551,7 +1551,7 @@ function processRespondWithFD(fd, headers) {
15511551

15521552
const handle = session[kHandle];
15531553
const ret =
1554-
handle.submitFile(this[kID], fd, headers);
1554+
handle.submitFile(this[kID], fd, headers, offset, length);
15551555
let err;
15561556
switch (ret) {
15571557
case NGHTTP2_ERR_NOMEM:
@@ -1575,26 +1575,71 @@ function doSendFD(session, options, fd, headers, err, stat) {
15751575
process.nextTick(() => this.emit('error', err));
15761576
return;
15771577
}
1578+
1579+
const statOptions = {
1580+
offset: options.offset !== undefined ? options.offset : 0,
1581+
length: options.length !== undefined ? options.length : -1
1582+
};
1583+
1584+
if (typeof options.statCheck === 'function' &&
1585+
options.statCheck.call(this, stat, headers, statOptions) === false) {
1586+
return;
1587+
}
1588+
1589+
const headersList = mapToHeaders(headers,
1590+
assertValidPseudoHeaderResponse);
1591+
if (!Array.isArray(headersList)) {
1592+
process.nextTick(() => this.emit('error', headersList));
1593+
}
1594+
1595+
processRespondWithFD.call(this, fd, headersList,
1596+
statOptions.offset,
1597+
statOptions.length);
1598+
}
1599+
1600+
function doSendFileFD(session, options, fd, headers, err, stat) {
1601+
if (this.destroyed || session.destroyed) {
1602+
abort(this);
1603+
return;
1604+
}
1605+
if (err) {
1606+
process.nextTick(() => this.emit('error', err));
1607+
return;
1608+
}
15781609
if (!stat.isFile()) {
15791610
err = new errors.Error('ERR_HTTP2_SEND_FILE');
15801611
process.nextTick(() => this.emit('error', err));
15811612
return;
15821613
}
15831614

1615+
const statOptions = {
1616+
offset: options.offset !== undefined ? options.offset : 0,
1617+
length: options.length !== undefined ? options.length : -1
1618+
};
1619+
15841620
// Set the content-length by default
1585-
headers[HTTP2_HEADER_CONTENT_LENGTH] = stat.size;
15861621
if (typeof options.statCheck === 'function' &&
15871622
options.statCheck.call(this, stat, headers) === false) {
15881623
return;
15891624
}
15901625

1626+
statOptions.length =
1627+
statOptions.length < 0 ? stat.size - (+statOptions.offset) :
1628+
Math.min(stat.size - (+statOptions.offset),
1629+
statOptions.length);
1630+
1631+
if (headers[HTTP2_HEADER_CONTENT_LENGTH] === undefined)
1632+
headers[HTTP2_HEADER_CONTENT_LENGTH] = statOptions.length;
1633+
15911634
const headersList = mapToHeaders(headers,
15921635
assertValidPseudoHeaderResponse);
15931636
if (!Array.isArray(headersList)) {
1594-
throw headersList;
1637+
process.nextTick(() => this.emit('error', headersList));
15951638
}
15961639

1597-
processRespondWithFD.call(this, fd, headersList);
1640+
processRespondWithFD.call(this, fd, headersList,
1641+
options.offset,
1642+
options.length);
15981643
}
15991644

16001645
function afterOpen(session, options, headers, err, fd) {
@@ -1609,7 +1654,7 @@ function afterOpen(session, options, headers, err, fd) {
16091654
}
16101655
state.fd = fd;
16111656

1612-
fs.fstat(fd, doSendFD.bind(this, session, options, fd, headers));
1657+
fs.fstat(fd, doSendFileFD.bind(this, session, options, fd, headers));
16131658
}
16141659

16151660

@@ -1786,12 +1831,12 @@ class ServerHttp2Stream extends Http2Stream {
17861831
}
17871832

17881833
// Initiate a response using an open FD. Note that there are fewer
1789-
// protections with this approach. For one, the fd is not validated.
1790-
// In respondWithFile, the file is checked to make sure it is a
1834+
// protections with this approach. For one, the fd is not validated by
1835+
// default. In respondWithFile, the file is checked to make sure it is a
17911836
// regular file, here the fd is passed directly. If the underlying
17921837
// mechanism is not able to read from the fd, then the stream will be
17931838
// reset with an error code.
1794-
respondWithFD(fd, headers) {
1839+
respondWithFD(fd, headers, options) {
17951840
const session = this[kSession];
17961841
if (this.destroyed)
17971842
throw new errors.Error('ERR_HTTP2_INVALID_STREAM');
@@ -1803,6 +1848,26 @@ class ServerHttp2Stream extends Http2Stream {
18031848
if (state.headersSent)
18041849
throw new errors.Error('ERR_HTTP2_HEADERS_SENT');
18051850

1851+
assertIsObject(options, 'options');
1852+
options = Object.assign(Object.create(null), options);
1853+
1854+
if (options.offset !== undefined && typeof options.offset !== 'number')
1855+
throw new errors.TypeError('ERR_INVALID_OPT_VALUE',
1856+
'offset',
1857+
options.offset);
1858+
1859+
if (options.length !== undefined && typeof options.length !== 'number')
1860+
throw new errors.TypeError('ERR_INVALID_OPT_VALUE',
1861+
'length',
1862+
options.length);
1863+
1864+
if (options.statCheck !== undefined &&
1865+
typeof options.statCheck !== 'function') {
1866+
throw new errors.TypeError('ERR_INVALID_OPT_VALUE',
1867+
'statCheck',
1868+
options.statCheck);
1869+
}
1870+
18061871
if (typeof fd !== 'number')
18071872
throw new errors.TypeError('ERR_INVALID_ARG_TYPE',
18081873
'fd', 'number');
@@ -1816,13 +1881,20 @@ class ServerHttp2Stream extends Http2Stream {
18161881
throw new errors.Error('ERR_HTTP2_PAYLOAD_FORBIDDEN', statusCode);
18171882
}
18181883

1884+
if (options.statCheck !== undefined) {
1885+
fs.fstat(fd, doSendFD.bind(this, session, options, fd, headers));
1886+
return;
1887+
}
1888+
18191889
const headersList = mapToHeaders(headers,
18201890
assertValidPseudoHeaderResponse);
18211891
if (!Array.isArray(headersList)) {
1822-
throw headersList;
1892+
process.nextTick(() => this.emit('error', headersList));
18231893
}
18241894

1825-
processRespondWithFD.call(this, fd, headersList);
1895+
processRespondWithFD.call(this, fd, headersList,
1896+
options.offset,
1897+
options.length);
18261898
}
18271899

18281900
// Initiate a file response on this Http2Stream. The path is passed to
@@ -1847,6 +1919,16 @@ class ServerHttp2Stream extends Http2Stream {
18471919
assertIsObject(options, 'options');
18481920
options = Object.assign(Object.create(null), options);
18491921

1922+
if (options.offset !== undefined && typeof options.offset !== 'number')
1923+
throw new errors.TypeError('ERR_INVALID_OPT_VALUE',
1924+
'offset',
1925+
options.offset);
1926+
1927+
if (options.length !== undefined && typeof options.length !== 'number')
1928+
throw new errors.TypeError('ERR_INVALID_OPT_VALUE',
1929+
'length',
1930+
options.length);
1931+
18501932
if (options.statCheck !== undefined &&
18511933
typeof options.statCheck !== 'function') {
18521934
throw new errors.TypeError('ERR_INVALID_OPT_VALUE',

src/node_http2.cc

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,9 @@ void Http2Session::SubmitResponse(const FunctionCallbackInfo<Value>& args) {
604604
void Http2Session::SubmitFile(const FunctionCallbackInfo<Value>& args) {
605605
CHECK(args[0]->IsNumber()); // Stream ID
606606
CHECK(args[1]->IsNumber()); // File Descriptor
607-
CHECK(args[2]->IsArray()); // Headers
607+
CHECK(args[2]->IsArray()); // Headers
608+
CHECK(args[3]->IsNumber()); // Offset
609+
CHECK(args[4]->IsNumber()); // Length
608610

609611
Http2Session* session;
610612
Nghttp2Stream* stream;
@@ -618,6 +620,11 @@ void Http2Session::SubmitFile(const FunctionCallbackInfo<Value>& args) {
618620
int fd = args[1]->Int32Value(context).ToChecked();
619621
Local<Array> headers = args[2].As<Array>();
620622

623+
int64_t offset = args[3]->IntegerValue(context).ToChecked();
624+
int64_t length = args[4]->IntegerValue(context).ToChecked();
625+
626+
CHECK_GE(offset, 0);
627+
621628
DEBUG_HTTP2("Http2Session: submitting file %d for stream %d: headers: %d, "
622629
"end-stream: %d\n", fd, id, headers->Length());
623630

@@ -627,7 +634,8 @@ void Http2Session::SubmitFile(const FunctionCallbackInfo<Value>& args) {
627634

628635
Headers list(isolate, context, headers);
629636

630-
args.GetReturnValue().Set(stream->SubmitFile(fd, *list, list.length()));
637+
args.GetReturnValue().Set(stream->SubmitFile(fd, *list, list.length(),
638+
offset, length));
631639
}
632640

633641
void Http2Session::SendHeaders(const FunctionCallbackInfo<Value>& args) {

src/node_http2_core-inl.h

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,10 @@ inline int Nghttp2Stream::SubmitResponse(nghttp2_nv* nva,
429429
}
430430

431431
// Initiate a response that contains data read from a file descriptor.
432-
inline int Nghttp2Stream::SubmitFile(int fd, nghttp2_nv* nva, size_t len) {
432+
inline int Nghttp2Stream::SubmitFile(int fd,
433+
nghttp2_nv* nva, size_t len,
434+
int64_t offset,
435+
int64_t length) {
433436
CHECK_GT(len, 0);
434437
CHECK_GT(fd, 0);
435438
DEBUG_HTTP2("Nghttp2Stream %d: submitting file\n", id_);
@@ -438,6 +441,9 @@ inline int Nghttp2Stream::SubmitFile(int fd, nghttp2_nv* nva, size_t len) {
438441
prov.source.fd = fd;
439442
prov.read_callback = Nghttp2Session::OnStreamReadFD;
440443

444+
if (offset > 0) fd_offset_ = offset;
445+
if (length > -1) fd_length_ = length;
446+
441447
return nghttp2_submit_response(session_->session(), id_,
442448
nva, len, &prov);
443449
}

src/node_http2_core.cc

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,28 +180,36 @@ ssize_t Nghttp2Session::OnStreamReadFD(nghttp2_session* session,
180180

181181
int fd = source->fd;
182182
int64_t offset = stream->fd_offset_;
183-
ssize_t numchars;
183+
ssize_t numchars = 0;
184+
185+
if (stream->fd_length_ >= 0 &&
186+
stream->fd_length_ < static_cast<int64_t>(length))
187+
length = stream->fd_length_;
184188

185189
uv_buf_t data;
186190
data.base = reinterpret_cast<char*>(buf);
187191
data.len = length;
188192

189193
uv_fs_t read_req;
190-
numchars = uv_fs_read(handle->loop_,
191-
&read_req,
192-
fd, &data, 1,
193-
offset, nullptr);
194-
uv_fs_req_cleanup(&read_req);
194+
195+
if (length > 0) {
196+
numchars = uv_fs_read(handle->loop_,
197+
&read_req,
198+
fd, &data, 1,
199+
offset, nullptr);
200+
uv_fs_req_cleanup(&read_req);
201+
}
195202

196203
// Close the stream with an error if reading fails
197204
if (numchars < 0)
198205
return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
199206

200207
// Update the read offset for the next read
201208
stream->fd_offset_ += numchars;
209+
stream->fd_length_ -= numchars;
202210

203211
// if numchars < length, assume that we are done.
204-
if (static_cast<size_t>(numchars) < length) {
212+
if (static_cast<size_t>(numchars) < length || length <= 0) {
205213
DEBUG_HTTP2("Nghttp2Session %d: no more data for stream %d\n",
206214
handle->session_type_, id);
207215
*flags |= NGHTTP2_DATA_FLAG_EOF;

src/node_http2_core.h

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,10 @@ class Nghttp2Stream {
310310
bool emptyPayload = false);
311311

312312
// Send data read from a file descriptor as the response on this stream.
313-
inline int SubmitFile(int fd, nghttp2_nv* nva, size_t len);
313+
inline int SubmitFile(int fd,
314+
nghttp2_nv* nva, size_t len,
315+
int64_t offset,
316+
int64_t length);
314317

315318
// Submit informational headers for this stream
316319
inline int SubmitInfo(nghttp2_nv* nva, size_t len);
@@ -420,7 +423,8 @@ class Nghttp2Stream {
420423
nghttp2_stream_write_queue* queue_tail_ = nullptr;
421424
unsigned int queue_head_index_ = 0;
422425
size_t queue_head_offset_ = 0;
423-
size_t fd_offset_ = 0;
426+
int64_t fd_offset_ = 0;
427+
int64_t fd_length_ = -1;
424428

425429
// The Current Headers block... As headers are received for this stream,
426430
// they are temporarily stored here until the OnFrameReceived is called

0 commit comments

Comments
 (0)