Skip to content

Commit

Permalink
feat: support timeout on fetch requests; doc and test
Browse files Browse the repository at this point in the history
  • Loading branch information
stefan-guggisberg committed Feb 3, 2020
1 parent c03b483 commit ce6c1ce
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 42 deletions.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@

> Library for making transparent HTTP/1(.1) and HTTP/2 requests.
Based on [fetch-h2](https://github.com/grantila/fetch-h2).
`helix-fetch` is based on [fetch-h2](https://github.com/grantila/fetch-h2). `helix-fetch` in general adheres to the [Fetch API Specification](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), implementing a subset of the API. However, there are some notable deviations:

* `Response.body` is not implemented. Use `Response.readable()` instead.
* `Response.blob()` is not implemented. Use `Response.buffer()` instead.
* `Response.formData()` is not implemented.
* The following `fetch()` options are ignored since `helix-fetch` doesn't have the concept of web pages: `mode`, `referrer` and `referrerPolicy`.

`helix-fetch` also supports the following extensions:

* `Response.buffer()` returns a Node.js `Buffer`.
* The `body` that can be sent in a `Request` can also be a `Readable` Node.js stream, a `Buffer` or a string.
* `fetch()` has an extra option, `json` that can be used instead of `body` to send an object that will be JSON stringified. The appropriate content-type will be set if it isn't already.
* `fetch()` has an extra option, `timeout` which is a timeout in milliseconds before the request should be aborted and the returned promise thereby rejected (with a `TimeoutError`).
* The `Response` object has an extra property `httpVersion` which is either `1` or `2` (numbers), depending on what was negotiated with the server.
* `Response.headers.raw()` returns the headers as a plain object.

## Features

Expand Down Expand Up @@ -31,6 +45,20 @@ $ npm install @adobe/helix-fetch

## Usage Examples

### Access Response Headers and other Meta data

```javascript
const { fetch } = require('helix-fetch');

const resp = await fetch('https://httpbin.org/get');
console.log(resp.ok);
console.log(resp.status);
console.log(resp.statusText);
console.log(resp.method);
console.log(resp.headers.raw());
console.log(resp.headers.get('content-type'));
```

### Fetch JSON

```javascript
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const { EventEmitter } = require('events');
const {
context,
Request,
TimeoutError,
} = require('fetch-h2');
const LRU = require('lru-cache');

Expand Down Expand Up @@ -103,8 +104,10 @@ const wrappedFetch = async (url, options = DEFAULT_FETCH_OPTIONS) => {
}

// fetch
const request = new Request(url, { ...opts, mode: 'no-cors', allowForbiddenHeaders: true });
const response = await ctx.fetch(request);
const fetchOptions = { ...opts, mode: 'no-cors', allowForbiddenHeaders: true };
const request = new Request(url, fetchOptions);
// workaround for https://github.com/grantila/fetch-h2/issues/84
const response = await ctx.fetch(request, fetchOptions);

return opts.cache !== 'no-store' ? cacheResponse(request, response) : decoratedResponse(response);
};
Expand Down Expand Up @@ -139,3 +142,8 @@ module.exports.clearCache = () => ctx.cache.reset();
*/
module.exports.disconnectAll = () => ctx.disconnectAll();
// module.exports.disconnect = (url) => ctx.disconnect(url);

/**
* Error thrown when a request timed out.
*/
module.exports.TimeoutError = TimeoutError;
34 changes: 13 additions & 21 deletions src/response.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,15 @@ class CacheableResponse {
}
}

/**
* Return a Node Readable stream instead of a Fetch API ReadableStream.
* (deviation from spec)
*/
get body() {
const stream = new PassThrough();
stream.end(this._body);
return stream;
}

/**
* Return a Node Readable stream.
* (extension)
*/
async readable() {
await this._ensureBodyConsumed();
return this.body;
const stream = new PassThrough();
stream.end(this._body);
return stream;
}

/**
Expand Down Expand Up @@ -134,20 +126,20 @@ const cacheableResponse = async (res) => {

/**
* Decorates the Fetch API Response instance with the same extensions
* as CacheableResponse but without interfering/buffering the body.
* as CacheableResponse but without interfering with/buffering the body.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Body
*
* @param {Response} res
*/
const decoratedResponse = async (res) => {
const body = await res.readable();
return {
...res,
headers: decorateHeaders(res.headers),
body,
buffer: async () => getStream.buffer(body),
};
};
const decoratedResponse = async (res) => ({
...res,
headers: decorateHeaders(res.headers),
readable: async () => res.readable(),
text: async () => res.text(),
json: async () => res.json(),
arrayBuffer: async () => res.arrayBuffer(),
buffer: async () => getStream.buffer(await res.readable()),
});

module.exports = { cacheableResponse, decoratedResponse };
102 changes: 85 additions & 17 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const parseCacheControl = require('parse-cache-control');
const { WritableStreamBuffer } = require('stream-buffers');

const {
fetch, onPush, offPush, disconnectAll, clearCache,
fetch, onPush, offPush, disconnectAll, clearCache, TimeoutError,
} = require('../src/index.js');

const WOKEUP = 'woke up!';
Expand All @@ -50,18 +50,6 @@ describe('Fetch Tests', () => {
assert.equal(resp.httpVersion, 2);
});

it('response.readable() is a readable stream', async () => {
const resp = await fetch('https://httpbin.org/status/200');
assert.equal(resp.status, 200);
assert(isStream.readable(await resp.readable()));
});

it('response.body is a readable stream', async () => {
const resp = await fetch('https://httpbin.org/status/200');
assert.equal(resp.status, 200);
assert(isStream.readable(resp.body));
});

it('fetch supports json response body', async () => {
const resp = await fetch('https://httpbin.org/json');
assert.equal(resp.status, 200);
Expand Down Expand Up @@ -91,8 +79,7 @@ describe('Fetch Tests', () => {
});
assert.equal(resp.status, 200);
assert.equal(resp.headers.get('content-type'), contentType);
// const imageStream = await resp.readable();
const imageStream = resp.body;
const imageStream = await resp.readable();
assert(isStream.readable(imageStream));

const finished = util.promisify(stream.finished);
Expand Down Expand Up @@ -220,12 +207,78 @@ describe('Fetch Tests', () => {
// make sure it's not delivered from cache
assert(!resp.fromCache);

// buffer()
const buf = await resp.buffer();
assert(Buffer.isBuffer(buf));
const contentLength = resp.headers.raw()['content-length'];
assert.equal(buf.length, contentLength);
});

it('readable() works on un-cached response', async () => {
const url = 'https://httpbin.org/image/jpeg';
// send initial request with no-store directive
let resp = await fetch(url, { cache: 'no-store' });
assert.equal(resp.status, 200);
// re-send request
resp = await fetch(url, { cache: 'no-store' });
assert.equal(resp.status, 200);
// make sure it's not delivered from cache
assert(!resp.fromCache);

// body
assert(isStream.readable(await resp.readable()));
});

it('text() works on un-cached response', async () => {
const url = 'https://httpbin.org/get';
// send initial request with no-store directive
let resp = await fetch(url, { cache: 'no-store' });
assert.equal(resp.status, 200);
// re-send request
resp = await fetch(url, { cache: 'no-store' });
assert.equal(resp.status, 200);
// make sure it's not delivered from cache
assert(!resp.fromCache);

// text()
assert.doesNotReject(() => resp.text());
});

it('arrayBuffer() works on un-cached response', async () => {
const url = 'https://httpbin.org/get';
// send initial request with no-store directive
let resp = await fetch(url, { cache: 'no-store' });
assert.equal(resp.status, 200);
// re-send request
resp = await fetch(url, { cache: 'no-store' });
assert.equal(resp.status, 200);
const contentLength = resp.headers.raw()['content-length'];
// make sure it's not delivered from cache
assert(!resp.fromCache);

// arrayBuffer()
const arrBuf = await resp.arrayBuffer();
assert(arrBuf !== null && arrBuf instanceof ArrayBuffer);
assert.equal(arrBuf.byteLength, contentLength);
});

it('json() works on un-cached response', async () => {
const url = 'https://httpbin.org/get';
// send initial request with no-store directive
let resp = await fetch(url, { cache: 'no-store' });
assert.equal(resp.status, 200);
// re-send request
resp = await fetch(url, { cache: 'no-store' });
assert.equal(resp.status, 200);
// make sure it's not delivered from cache
assert(!resp.fromCache);

// json()
assert.equal(resp.headers.raw()['content-type'], 'application/json');
const json = await resp.json();
assert.equal(json.url, url);
});

it('body accessor methods work on cached response', async () => {
const url = 'https://httpbin.org/cache/60';
// send initial request, priming cache
Expand Down Expand Up @@ -303,7 +356,7 @@ describe('Fetch Tests', () => {
// see https://nghttp2.org/blog/2015/02/10/nghttp2-dot-org-enabled-http2-server-push/
fetch('https://nghttp2.org', { cache: 'no-store' }),
// resolves with either WOKEUP or the url of the pushed resource
Promise.race([sleep(2000), receivedPush()]),
Promise.race([sleep(3000), receivedPush()]),
]);
assert.equal(resp.httpVersion, 2);
assert.equal(resp.status, 200);
Expand All @@ -312,10 +365,25 @@ describe('Fetch Tests', () => {
// re-trigger push
[resp, result] = await Promise.all([
fetch('https://nghttp2.org', { cache: 'no-store' }),
Promise.race([sleep(2000), receivedPush()]),
Promise.race([sleep(3000), receivedPush()]),
]);
assert.equal(resp.httpVersion, 2);
assert.equal(resp.status, 200);
assert.notEqual(result, WOKEUP);
});

// eslint-disable-next-line func-names
it('timeout works', async function () {
this.timeout(5000);
const ts0 = Date.now();
try {
// the server responds with a 2 second delay, the timeout is set to 1 second.
await fetch('https://httpbin.org/delay/2', { cache: 'no-store', timeout: 1000 });
assert.fail();
} catch (err) {
assert(err instanceof TimeoutError);
}
const ts1 = Date.now();
assert((ts1 - ts0) < 2000);
});
});

0 comments on commit ce6c1ce

Please sign in to comment.