pax_global_header00006660000000000000000000000064140601215030014502gustar00rootroot0000000000000052 comment=635f3698f9eaa3fe93e46d82f398efd970852de7 cacheable-request-7.0.2/000077500000000000000000000000001406012150300150655ustar00rootroot00000000000000cacheable-request-7.0.2/.gitattributes000066400000000000000000000000231406012150300177530ustar00rootroot00000000000000* text=auto eol=lf cacheable-request-7.0.2/.github/000077500000000000000000000000001406012150300164255ustar00rootroot00000000000000cacheable-request-7.0.2/.github/FUNDING.yml000066400000000000000000000004651406012150300202470ustar00rootroot00000000000000github: - 'lukechilds' custom: - 'https://blockstream.info/address/1LukeQU5jwebXbMLDVydeH4vFSobRV9rkj' - 'https://blockstream.info/address/3Luke2qRn5iLj4NiFrvLBu2jaEj7JeMR6w' - 'https://blockstream.info/address/bc1qlukeyq0c69v97uss68fet26kjkcsrymd2kv6d4' - 'https://tippin.me/@lukechilds' cacheable-request-7.0.2/.gitignore000066400000000000000000000021301406012150300170510ustar00rootroot00000000000000test/testdb.sqlite ## Node # Don't commit lockfiles package-lock.json yarn.lock # Logs logs *.log npm-debug.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules jspm_packages # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history ## OS X *.DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk cacheable-request-7.0.2/.npmrc000066400000000000000000000000231406012150300162000ustar00rootroot00000000000000package-lock=false cacheable-request-7.0.2/.travis.yml000066400000000000000000000002241406012150300171740ustar00rootroot00000000000000language: node_js node_js: - '12' - '10' - '8' script: npm test after_success: npm run coverage notifications: email: on_success: never cacheable-request-7.0.2/LICENSE000066400000000000000000000020541406012150300160730ustar00rootroot00000000000000MIT License Copyright (c) 2017 Luke Childs 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. cacheable-request-7.0.2/README.md000066400000000000000000000166421406012150300163550ustar00rootroot00000000000000# cacheable-request > Wrap native HTTP requests with RFC compliant cache support [![Build Status](https://travis-ci.org/lukechilds/cacheable-request.svg?branch=master)](https://travis-ci.org/lukechilds/cacheable-request) [![Coverage Status](https://coveralls.io/repos/github/lukechilds/cacheable-request/badge.svg?branch=master)](https://coveralls.io/github/lukechilds/cacheable-request?branch=master) [![npm](https://img.shields.io/npm/dm/cacheable-request.svg)](https://www.npmjs.com/package/cacheable-request) [![npm](https://img.shields.io/npm/v/cacheable-request.svg)](https://www.npmjs.com/package/cacheable-request) [RFC 7234](http://httpwg.org/specs/rfc7234.html) compliant HTTP caching for native Node.js HTTP/HTTPS requests. Caching works out of the box in memory or is easily pluggable with a wide range of storage adapters. **Note:** This is a low level wrapper around the core HTTP modules, it's not a high level request library. ## Features - Only stores cacheable responses as defined by RFC 7234 - Fresh cache entries are served directly from cache - Stale cache entries are revalidated with `If-None-Match`/`If-Modified-Since` headers - 304 responses from revalidation requests use cached body - Updates `Age` header on cached responses - Can completely bypass cache on a per request basis - In memory cache by default - Official support for Redis, MongoDB, SQLite, PostgreSQL and MySQL storage adapters - Easily plug in your own or third-party storage adapters - If DB connection fails, cache is automatically bypassed ([disabled by default](#optsautomaticfailover)) - Adds cache support to any existing HTTP code with minimal changes - Uses [http-cache-semantics](https://github.com/pornel/http-cache-semantics) internally for HTTP RFC 7234 compliance ## Install ```shell npm install cacheable-request ``` ## Usage ```js const http = require('http'); const CacheableRequest = require('cacheable-request'); // Then instead of const req = http.request('http://example.com', cb); req.end(); // You can do const cacheableRequest = new CacheableRequest(http.request); const cacheReq = cacheableRequest('http://example.com', cb); cacheReq.on('request', req => req.end()); // Future requests to 'example.com' will be returned from cache if still valid // You pass in any other http.request API compatible method to be wrapped with cache support: const cacheableRequest = new CacheableRequest(https.request); const cacheableRequest = new CacheableRequest(electron.net); ``` ## Storage Adapters `cacheable-request` uses [Keyv](https://github.com/lukechilds/keyv) to support a wide range of storage adapters. For example, to use Redis as a cache backend, you just need to install the official Redis Keyv storage adapter: ``` npm install @keyv/redis ``` And then you can pass `CacheableRequest` your connection string: ```js const cacheableRequest = new CacheableRequest(http.request, 'redis://user:pass@localhost:6379'); ``` [View all official Keyv storage adapters.](https://github.com/lukechilds/keyv#official-storage-adapters) Keyv also supports anything that follows the Map API so it's easy to write your own storage adapter or use a third-party solution. e.g The following are all valid storage adapters ```js const storageAdapter = new Map(); // or const storageAdapter = require('./my-storage-adapter'); // or const QuickLRU = require('quick-lru'); const storageAdapter = new QuickLRU({ maxSize: 1000 }); const cacheableRequest = new CacheableRequest(http.request, storageAdapter); ``` View the [Keyv docs](https://github.com/lukechilds/keyv) for more information on how to use storage adapters. ## API ### new cacheableRequest(request, [storageAdapter]) Returns the provided request function wrapped with cache support. #### request Type: `function` Request function to wrap with cache support. Should be [`http.request`](https://nodejs.org/api/http.html#http_http_request_options_callback) or a similar API compatible request function. #### storageAdapter Type: `Keyv storage adapter`
Default: `new Map()` A [Keyv](https://github.com/lukechilds/keyv) storage adapter instance, or connection string if using with an official Keyv storage adapter. ### Instance #### cacheableRequest(opts, [cb]) Returns an event emitter. ##### opts Type: `object`, `string` - Any of the default request functions options. - Any [`http-cache-semantics`](https://github.com/kornelski/http-cache-semantics#constructor-options) options. - Any of the following: ###### opts.cache Type: `boolean`
Default: `true` If the cache should be used. Setting this to false will completely bypass the cache for the current request. ###### opts.strictTtl Type: `boolean`
Default: `false` If set to `true` once a cached resource has expired it is deleted and will have to be re-requested. If set to `false` (default), after a cached resource's TTL expires it is kept in the cache and will be revalidated on the next request with `If-None-Match`/`If-Modified-Since` headers. ###### opts.maxTtl Type: `number`
Default: `undefined` Limits TTL. The `number` represents milliseconds. ###### opts.automaticFailover Type: `boolean`
Default: `false` When set to `true`, if the DB connection fails we will automatically fallback to a network request. DB errors will still be emitted to notify you of the problem even though the request callback may succeed. ###### opts.forceRefresh Type: `boolean`
Default: `false` Forces refreshing the cache. If the response could be retrieved from the cache, it will perform a new request and override the cache instead. ##### cb Type: `function` The callback function which will receive the response as an argument. The response can be either a [Node.js HTTP response stream](https://nodejs.org/api/http.html#http_class_http_incomingmessage) or a [responselike object](https://github.com/lukechilds/responselike). The response will also have a `fromCache` property set with a boolean value. ##### .on('request', request) `request` event to get the request object of the request. **Note:** This event will only fire if an HTTP request is actually made, not when a response is retrieved from cache. However, you should always handle the `request` event to end the request and handle any potential request errors. ##### .on('response', response) `response` event to get the response object from the HTTP request or cache. ##### .on('error', error) `error` event emitted in case of an error with the cache. Errors emitted here will be an instance of `CacheableRequest.RequestError` or `CacheableRequest.CacheError`. You will only ever receive a `RequestError` if the request function throws (normally caused by invalid user input). Normal request errors should be handled inside the `request` event. To properly handle all error scenarios you should use the following pattern: ```js cacheableRequest('example.com', cb) .on('error', err => { if (err instanceof CacheableRequest.CacheError) { handleCacheError(err); // Cache error } else if (err instanceof CacheableRequest.RequestError) { handleRequestError(err); // Request function thrown } }) .on('request', req => { req.on('error', handleRequestError); // Request error emitted req.end(); }); ``` **Note:** Database connection errors are emitted here, however `cacheable-request` will attempt to re-request the resource and bypass the cache on a connection error. Therefore a database connection error doesn't necessarily mean the request won't be fulfilled. ## License MIT © Luke Childs cacheable-request-7.0.2/package.json000066400000000000000000000022101406012150300173460ustar00rootroot00000000000000{ "name": "cacheable-request", "version": "7.0.2", "description": "Wrap native HTTP requests with RFC compliant cache support", "license": "MIT", "repository": "lukechilds/cacheable-request", "author": "Luke Childs (http://lukechilds.co.uk)", "main": "src/index.js", "engines": { "node": ">=8" }, "scripts": { "test": "xo && nyc ava", "coverage": "nyc report --reporter=text-lcov | coveralls" }, "files": [ "src" ], "keywords": [ "HTTP", "HTTPS", "cache", "caching", "layer", "cacheable", "RFC 7234", "RFC", "7234", "compliant" ], "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" }, "devDependencies": { "@keyv/sqlite": "^2.0.0", "ava": "^1.1.0", "coveralls": "^3.0.0", "create-test-server": "3.0.0", "delay": "^4.0.0", "eslint-config-xo-lukechilds": "^1.0.0", "nyc": "^14.1.1", "pify": "^4.0.0", "sqlite3": "^4.0.2", "this": "^1.0.2", "xo": "^0.23.0" }, "xo": { "extends": "xo-lukechilds" } } cacheable-request-7.0.2/src/000077500000000000000000000000001406012150300156545ustar00rootroot00000000000000cacheable-request-7.0.2/src/index.js000066400000000000000000000154301406012150300173240ustar00rootroot00000000000000'use strict'; const EventEmitter = require('events'); const urlLib = require('url'); const normalizeUrl = require('normalize-url'); const getStream = require('get-stream'); const CachePolicy = require('http-cache-semantics'); const Response = require('responselike'); const lowercaseKeys = require('lowercase-keys'); const cloneResponse = require('clone-response'); const Keyv = require('keyv'); class CacheableRequest { constructor(request, cacheAdapter) { if (typeof request !== 'function') { throw new TypeError('Parameter `request` must be a function'); } this.cache = new Keyv({ uri: typeof cacheAdapter === 'string' && cacheAdapter, store: typeof cacheAdapter !== 'string' && cacheAdapter, namespace: 'cacheable-request' }); return this.createCacheableRequest(request); } createCacheableRequest(request) { return (opts, cb) => { let url; if (typeof opts === 'string') { url = normalizeUrlObject(urlLib.parse(opts)); opts = {}; } else if (opts instanceof urlLib.URL) { url = normalizeUrlObject(urlLib.parse(opts.toString())); opts = {}; } else { const [pathname, ...searchParts] = (opts.path || '').split('?'); const search = searchParts.length > 0 ? `?${searchParts.join('?')}` : ''; url = normalizeUrlObject({ ...opts, pathname, search }); } opts = { headers: {}, method: 'GET', cache: true, strictTtl: false, automaticFailover: false, ...opts, ...urlObjectToRequestOptions(url) }; opts.headers = lowercaseKeys(opts.headers); const ee = new EventEmitter(); const normalizedUrlString = normalizeUrl( urlLib.format(url), { stripWWW: false, removeTrailingSlash: false, stripAuthentication: false } ); const key = `${opts.method}:${normalizedUrlString}`; let revalidate = false; let madeRequest = false; const makeRequest = opts => { madeRequest = true; let requestErrored = false; let requestErrorCallback; const requestErrorPromise = new Promise(resolve => { requestErrorCallback = () => { if (!requestErrored) { requestErrored = true; resolve(); } }; }); const handler = response => { if (revalidate && !opts.forceRefresh) { response.status = response.statusCode; const revalidatedPolicy = CachePolicy.fromObject(revalidate.cachePolicy).revalidatedPolicy(opts, response); if (!revalidatedPolicy.modified) { const headers = revalidatedPolicy.policy.responseHeaders(); response = new Response(revalidate.statusCode, headers, revalidate.body, revalidate.url); response.cachePolicy = revalidatedPolicy.policy; response.fromCache = true; } } if (!response.fromCache) { response.cachePolicy = new CachePolicy(opts, response, opts); response.fromCache = false; } let clonedResponse; if (opts.cache && response.cachePolicy.storable()) { clonedResponse = cloneResponse(response); (async () => { try { const bodyPromise = getStream.buffer(response); await Promise.race([ requestErrorPromise, new Promise(resolve => response.once('end', resolve)) ]); if (requestErrored) { return; } const body = await bodyPromise; const value = { cachePolicy: response.cachePolicy.toObject(), url: response.url, statusCode: response.fromCache ? revalidate.statusCode : response.statusCode, body }; let ttl = opts.strictTtl ? response.cachePolicy.timeToLive() : undefined; if (opts.maxTtl) { ttl = ttl ? Math.min(ttl, opts.maxTtl) : opts.maxTtl; } await this.cache.set(key, value, ttl); } catch (error) { ee.emit('error', new CacheableRequest.CacheError(error)); } })(); } else if (opts.cache && revalidate) { (async () => { try { await this.cache.delete(key); } catch (error) { ee.emit('error', new CacheableRequest.CacheError(error)); } })(); } ee.emit('response', clonedResponse || response); if (typeof cb === 'function') { cb(clonedResponse || response); } }; try { const req = request(opts, handler); req.once('error', requestErrorCallback); req.once('abort', requestErrorCallback); ee.emit('request', req); } catch (error) { ee.emit('error', new CacheableRequest.RequestError(error)); } }; (async () => { const get = async opts => { await Promise.resolve(); const cacheEntry = opts.cache ? await this.cache.get(key) : undefined; if (typeof cacheEntry === 'undefined') { return makeRequest(opts); } const policy = CachePolicy.fromObject(cacheEntry.cachePolicy); if (policy.satisfiesWithoutRevalidation(opts) && !opts.forceRefresh) { const headers = policy.responseHeaders(); const response = new Response(cacheEntry.statusCode, headers, cacheEntry.body, cacheEntry.url); response.cachePolicy = policy; response.fromCache = true; ee.emit('response', response); if (typeof cb === 'function') { cb(response); } } else { revalidate = cacheEntry; opts.headers = policy.revalidationHeaders(opts); makeRequest(opts); } }; const errorHandler = error => ee.emit('error', new CacheableRequest.CacheError(error)); this.cache.once('error', errorHandler); ee.on('response', () => this.cache.removeListener('error', errorHandler)); try { await get(opts); } catch (error) { if (opts.automaticFailover && !madeRequest) { makeRequest(opts); } ee.emit('error', new CacheableRequest.CacheError(error)); } })(); return ee; }; } } function urlObjectToRequestOptions(url) { const options = { ...url }; options.path = `${url.pathname || '/'}${url.search || ''}`; delete options.pathname; delete options.search; return options; } function normalizeUrlObject(url) { // If url was parsed by url.parse or new URL: // - hostname will be set // - host will be hostname[:port] // - port will be set if it was explicit in the parsed string // Otherwise, url was from request options: // - hostname or host may be set // - host shall not have port encoded return { protocol: url.protocol, auth: url.auth, hostname: url.hostname || url.host || 'localhost', port: url.port, pathname: url.pathname, search: url.search }; } CacheableRequest.RequestError = class extends Error { constructor(error) { super(error.message); this.name = 'RequestError'; Object.assign(this, error); } }; CacheableRequest.CacheError = class extends Error { constructor(error) { super(error.message); this.name = 'CacheError'; Object.assign(this, error); } }; module.exports = CacheableRequest; cacheable-request-7.0.2/test/000077500000000000000000000000001406012150300160445ustar00rootroot00000000000000cacheable-request-7.0.2/test/cache.js000066400000000000000000000402071406012150300174500ustar00rootroot00000000000000import { request } from 'http'; import url from 'url'; import util from 'util'; import test from 'ava'; import getStream from 'get-stream'; import createTestServer from 'create-test-server'; import delay from 'delay'; import sqlite3 from 'sqlite3'; import pify from 'pify'; import CacheableRequest from 'this'; let s; // Promisify cacheableRequest const promisify = cacheableRequest => opts => new Promise((resolve, reject) => { cacheableRequest(opts, async response => { const body = await getStream(response); response.body = body; // Give the cache time to update await delay(100); resolve(response); }) .on('request', req => req.end()) .once('error', reject); }); test.before('setup', async () => { s = await createTestServer(); let noStoreIndex = 0; s.get('/no-store', (req, res) => { noStoreIndex++; res.setHeader('Cache-Control', 'public, no-cache, no-store'); res.end(noStoreIndex.toString()); }); let cacheIndex = 0; s.get('/cache', (req, res) => { cacheIndex++; res.setHeader('Cache-Control', 'public, max-age=60'); res.end(cacheIndex.toString()); }); s.get('/last-modified', (req, res) => { res.setHeader('Cache-Control', 'public, max-age=0'); res.setHeader('Last-Modified', 'Wed, 21 Oct 2015 07:28:00 GMT'); let responseBody = 'last-modified'; if (req.headers['if-modified-since'] === 'Wed, 21 Oct 2015 07:28:00 GMT') { res.statusCode = 304; responseBody = null; } res.end(responseBody); }); let calledFirstError = false; s.get('/first-error', (req, res) => { if (calledFirstError) { res.end('ok'); return; } calledFirstError = true; res.statusCode = 502; res.end('received 502'); }); s.get('/etag', (req, res) => { res.setHeader('Cache-Control', 'public, max-age=0'); res.setHeader('ETag', '33a64df551425fcc55e4d42a148795d9f25f89d4'); let responseBody = 'etag'; if (req.headers['if-none-match'] === '33a64df551425fcc55e4d42a148795d9f25f89d4') { res.statusCode = 304; responseBody = null; } res.end(responseBody); }); s.get('/revalidate-modified', (req, res) => { res.setHeader('Cache-Control', 'public, max-age=0'); res.setHeader('ETag', '33a64df551425fcc55e4d42a148795d9f25f89d4'); let responseBody = 'revalidate-modified'; if (req.headers['if-none-match'] === '33a64df551425fcc55e4d42a148795d9f25f89d4') { res.setHeader('ETag', '0000000000000000000000000000000000'); responseBody = 'new-body'; } res.end(responseBody); }); let cacheThenNoStoreIndex = 0; s.get('/cache-then-no-store-on-revalidate', (req, res) => { const cc = cacheThenNoStoreIndex === 0 ? 'public, max-age=0' : 'public, no-cache, no-store'; cacheThenNoStoreIndex++; res.setHeader('Cache-Control', cc); res.end('cache-then-no-store-on-revalidate'); }); s.get('/echo', (req, res) => { const { headers, query, path, originalUrl, body } = req; res.json({ headers, query, path, originalUrl, body }); }); }); test('Non cacheable responses are not cached', async t => { const endpoint = '/no-store'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const firstResponseInt = Number((await cacheableRequestHelper(s.url + endpoint)).body); const secondResponseInt = Number((await cacheableRequestHelper(s.url + endpoint)).body); t.is(cache.size, 0); t.true(firstResponseInt < secondResponseInt); }); test('Cacheable responses are cached', async t => { const endpoint = '/cache'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const firstResponse = await cacheableRequestHelper(s.url + endpoint); const secondResponse = await cacheableRequestHelper(s.url + endpoint); t.is(cache.size, 1); t.is(firstResponse.body, secondResponse.body); }); test('Cacheable responses have unique cache key', async t => { const endpoint = '/cache'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const firstResponse = await cacheableRequestHelper(s.url + endpoint + '?foo'); const secondResponse = await cacheableRequestHelper(s.url + endpoint + '?bar'); t.is(cache.size, 2); t.not(firstResponse.body, secondResponse.body); }); async function testCacheKey(t, input, expected) { const expectKey = `cacheable-request:${expected}`; const okMessage = `OK ${expectKey}`; const cache = { get(key) { t.is(key, expectKey); throw new Error(okMessage); } }; const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); await t.throwsAsync( cacheableRequestHelper(input), CacheableRequest.CacheError, okMessage ); } testCacheKey.title = (providedTitle, input) => util.format( 'Cache key is http.request compatible for arg %s(%j)%s', input.constructor.name, input, providedTitle ? ` (${providedTitle})` : '' ); test( testCacheKey, 'http://www.example.com', 'GET:http://www.example.com' ); test( 'strips default path', testCacheKey, 'http://www.example.com/', 'GET:http://www.example.com' ); test( 'keeps trailing /', testCacheKey, 'http://www.example.com/test/', 'GET:http://www.example.com/test/' ); test( testCacheKey, new url.URL('http://www.example.com'), 'GET:http://www.example.com' ); test( 'no requried properties', testCacheKey, {}, 'GET:http://localhost' ); test( testCacheKey, { protocol: 'http:', host: 'www.example.com', port: 80, path: '/' }, 'GET:http://www.example.com' ); test( testCacheKey, { hostname: 'www.example.com', port: 80, path: '/' }, 'GET:http://www.example.com' ); test( testCacheKey, { hostname: 'www.example.com', port: 8080, path: '/' }, 'GET:http://www.example.com:8080' ); test( testCacheKey, { host: 'www.example.com' }, 'GET:http://www.example.com' ); test( 'hostname over host', testCacheKey, { host: 'www.example.com', hostname: 'xyz.example.com' }, 'GET:http://xyz.example.com' ); test( 'hostname defaults to localhost', testCacheKey, { path: '/' }, 'GET:http://localhost' ); test( 'ignores pathname', testCacheKey, { path: '/foo', pathname: '/bar' }, 'GET:http://localhost/foo' ); test( 'ignores search', testCacheKey, { path: '/?foo=bar', search: '?bar=baz' }, 'GET:http://localhost/?foo=bar' ); test( 'ignores query', testCacheKey, { path: '/?foo=bar', query: { bar: 'baz' } }, 'GET:http://localhost/?foo=bar' ); test( testCacheKey, { auth: 'user:pass' }, 'GET:http://user:pass@localhost' ); test( testCacheKey, { method: 'POST' }, 'POST:http://localhost' ); test('request options path query is passed through', async t => { const cacheableRequest = new CacheableRequest(request); const cacheableRequestHelper = promisify(cacheableRequest); const argString = `${s.url}/echo?foo=bar`; const argURL = new url.URL(argString); const urlObject = url.parse(argString); const argOptions = { hostname: urlObject.hostname, port: urlObject.port, path: urlObject.path }; const inputs = [argString, argURL, argOptions]; for (const input of inputs) { // eslint-disable-next-line no-await-in-loop const response = await cacheableRequestHelper(input); const body = JSON.parse(response.body); const message = util.format( 'when request arg is %s(%j)', input.constructor.name, input ); t.is(body.query.foo, 'bar', message); } }); test('Setting opts.cache to false bypasses cache for a single request', async t => { const endpoint = '/cache'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const opts = url.parse(s.url + endpoint); const optsNoCache = { cache: false, ...opts }; const firstResponse = await cacheableRequestHelper(opts); const secondResponse = await cacheableRequestHelper(opts); const thirdResponse = await cacheableRequestHelper(optsNoCache); const fourthResponse = await cacheableRequestHelper(opts); t.false(firstResponse.fromCache); t.true(secondResponse.fromCache); t.false(thirdResponse.fromCache); t.true(fourthResponse.fromCache); }); test('TTL is passed to cache', async t => { const endpoint = '/cache'; const store = new Map(); const cache = { get: store.get.bind(store), set: (key, value, ttl) => { t.is(typeof ttl, 'number'); t.true(ttl > 0); return store.set(key, value, ttl); }, delete: store.delete.bind(store) }; const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const opts = { strictTtl: true, ...url.parse(s.url + endpoint) }; t.plan(2); await cacheableRequestHelper(opts); }); test('TTL is not passed to cache if strictTtl is false', async t => { const endpoint = '/cache'; const store = new Map(); const cache = { get: store.get.bind(store), set: (key, value, ttl) => { t.true(typeof ttl === 'undefined'); return store.set(key, value, ttl); }, delete: store.delete.bind(store) }; const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const opts = { strictTtl: false, ...url.parse(s.url + endpoint) }; t.plan(1); await cacheableRequestHelper(opts); }); test('Setting opts.maxTtl will limit the TTL', async t => { const endpoint = '/cache'; const store = new Map(); const cache = { get: store.get.bind(store), set: (key, value, ttl) => { t.is(ttl, 1000); return store.set(key, value, ttl); }, delete: store.delete.bind(store) }; const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const opts = { ...url.parse(s.url + endpoint), maxTtl: 1000 }; t.plan(1); await cacheableRequestHelper(opts); }); test('Setting opts.maxTtl when opts.strictTtl is true will use opts.maxTtl if it\'s smaller', async t => { const endpoint = '/cache'; const store = new Map(); const cache = { get: store.get.bind(store), set: (key, value, ttl) => { t.true(ttl === 1000); return store.set(key, value, ttl); }, delete: store.delete.bind(store) }; const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const opts = { ...url.parse(s.url + endpoint), strictTtl: true, maxTtl: 1000 }; t.plan(1); await cacheableRequestHelper(opts); }); test('Setting opts.maxTtl when opts.strictTtl is true will use remote TTL if it\'s smaller', async t => { const endpoint = '/cache'; const store = new Map(); const cache = { get: store.get.bind(store), set: (key, value, ttl) => { t.true(ttl < 100000); return store.set(key, value, ttl); }, delete: store.delete.bind(store) }; const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const opts = { ...url.parse(s.url + endpoint), strictTtl: true, maxTtl: 100000 }; t.plan(1); await cacheableRequestHelper(opts); }); test('Stale cache entries with Last-Modified headers are revalidated', async t => { const endpoint = '/last-modified'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const firstResponse = await cacheableRequestHelper(s.url + endpoint); const secondResponse = await cacheableRequestHelper(s.url + endpoint); t.is(cache.size, 1); t.is(firstResponse.statusCode, 200); t.is(secondResponse.statusCode, 200); t.false(firstResponse.fromCache); t.true(secondResponse.fromCache); t.is(firstResponse.body, 'last-modified'); t.is(firstResponse.body, secondResponse.body); }); test('Stale cache entries with ETag headers are revalidated', async t => { const endpoint = '/etag'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const firstResponse = await cacheableRequestHelper(s.url + endpoint); const secondResponse = await cacheableRequestHelper(s.url + endpoint); t.is(cache.size, 1); t.is(firstResponse.statusCode, 200); t.is(secondResponse.statusCode, 200); t.false(firstResponse.fromCache); t.true(secondResponse.fromCache); t.is(firstResponse.body, 'etag'); t.is(firstResponse.body, secondResponse.body); }); test('Stale cache entries that can\'t be revalidate are deleted from cache', async t => { const endpoint = '/cache-then-no-store-on-revalidate'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const firstResponse = await cacheableRequestHelper(s.url + endpoint); t.is(cache.size, 1); const secondResponse = await cacheableRequestHelper(s.url + endpoint); t.is(cache.size, 0); t.is(firstResponse.statusCode, 200); t.is(secondResponse.statusCode, 200); t.is(firstResponse.body, 'cache-then-no-store-on-revalidate'); t.is(firstResponse.body, secondResponse.body); }); test('Response objects have fromCache property set correctly', async t => { const endpoint = '/cache'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const response = await cacheableRequestHelper(s.url + endpoint); const cachedResponse = await cacheableRequestHelper(s.url + endpoint); t.false(response.fromCache); t.true(cachedResponse.fromCache); }); test('Revalidated responses that are modified are passed through', async t => { const endpoint = '/revalidate-modified'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const firstResponse = await cacheableRequestHelper(s.url + endpoint); const secondResponse = await cacheableRequestHelper(s.url + endpoint); t.is(firstResponse.statusCode, 200); t.is(secondResponse.statusCode, 200); t.is(firstResponse.body, 'revalidate-modified'); t.is(secondResponse.body, 'new-body'); }); test('Undefined callback parameter inside cache logic is handled', async t => { const endpoint = '/cache'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); await cacheableRequestHelper(s.url + endpoint); cacheableRequest(s.url + endpoint); await delay(500); t.pass(); }); test('Keyv cache adapters load via connection uri', async t => { const endpoint = '/cache'; const cacheableRequest = new CacheableRequest(request, 'sqlite://test/testdb.sqlite'); const cacheableRequestHelper = promisify(cacheableRequest); const db = new sqlite3.Database('test/testdb.sqlite'); const query = pify(db.all.bind(db)); const firstResponse = await cacheableRequestHelper(s.url + endpoint); await delay(1000); const secondResponse = await cacheableRequestHelper(s.url + endpoint); const cacheResult = await query(`SELECT * FROM keyv WHERE "key" = "cacheable-request:GET:${s.url + endpoint}"`); t.false(firstResponse.fromCache); t.true(secondResponse.fromCache); t.is(cacheResult.length, 1); await query('DELETE FROM keyv'); }); test('ability to force refresh', async t => { const endpoint = '/cache'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const opts = url.parse(s.url + endpoint); const firstResponse = await cacheableRequestHelper(opts); const secondResponse = await cacheableRequestHelper({ ...opts, forceRefresh: true }); const thirdResponse = await cacheableRequestHelper(opts); t.not(firstResponse.body, secondResponse.body); t.is(secondResponse.body, thirdResponse.body); }); test('checks status codes when comparing cache & response', async t => { const endpoint = '/first-error'; const cache = new Map(); const cacheableRequest = new CacheableRequest(request, cache); const cacheableRequestHelper = promisify(cacheableRequest); const opts = url.parse(s.url + endpoint); const firstResponse = await cacheableRequestHelper(opts); const secondResponse = await cacheableRequestHelper(opts); t.is(firstResponse.body, 'received 502'); t.is(secondResponse.body, 'ok'); }); test.after('cleanup', async () => { await s.close(); }); cacheable-request-7.0.2/test/cacheable-request-class.js000066400000000000000000000011761406012150300230670ustar00rootroot00000000000000import { request } from 'http'; import test from 'ava'; import CacheableRequest from 'this'; test('CacheableRequest is a function', t => { t.is(typeof CacheableRequest, 'function'); }); test('CacheableRequest cannot be invoked without \'new\'', t => { t.throws(() => CacheableRequest(request)); // eslint-disable-line new-cap t.notThrows(() => new CacheableRequest(request)); }); test('CacheableRequest throws TypeError if request fn isn\'t passed in', t => { const error = t.throws(() => { new CacheableRequest(); // eslint-disable-line no-new }, TypeError); t.is(error.message, 'Parameter `request` must be a function'); }); cacheable-request-7.0.2/test/cacheable-request-instance.js000066400000000000000000000210421406012150300235600ustar00rootroot00000000000000import EventEmitter from 'events'; import { request } from 'http'; import url from 'url'; import test from 'ava'; import createTestServer from 'create-test-server'; import getStream from 'get-stream'; import CacheableRequest from 'this'; let s; test.before('setup', async () => { s = await createTestServer(); s.get('/', (req, res) => { res.setHeader('cache-control', 'max-age=60'); res.end('hi'); }); }); test('cacheableRequest is a function', t => { const cacheableRequest = new CacheableRequest(request); t.is(typeof cacheableRequest, 'function'); }); test.cb('cacheableRequest returns an event emitter', t => { const cacheableRequest = new CacheableRequest(request); const returnValue = cacheableRequest(url.parse(s.url), () => t.end()).on('request', req => req.end()); t.true(returnValue instanceof EventEmitter); }); test.cb('cacheableRequest passes requests through if no cache option is set', t => { const cacheableRequest = new CacheableRequest(request); cacheableRequest(url.parse(s.url), async response => { const body = await getStream(response); t.is(body, 'hi'); t.end(); }).on('request', req => req.end()); }); test.cb('cacheableRequest accepts url as string', t => { const cacheableRequest = new CacheableRequest(request); cacheableRequest(s.url, async response => { const body = await getStream(response); t.is(body, 'hi'); t.end(); }).on('request', req => req.end()); }); test.cb('cacheableRequest accepts url as URL', t => { const cacheableRequest = new CacheableRequest(request); cacheableRequest(new url.URL(s.url), async response => { const body = await getStream(response); t.is(body, 'hi'); t.end(); }).on('request', req => req.end()); }); test.cb('cacheableRequest handles no callback parameter', t => { const cacheableRequest = new CacheableRequest(request); cacheableRequest(url.parse(s.url)).on('request', req => { req.end(); req.on('response', response => { t.is(response.statusCode, 200); t.end(); }); }); }); test.cb('cacheableRequest emits response event for network responses', t => { const cacheableRequest = new CacheableRequest(request); cacheableRequest(url.parse(s.url)) .on('request', req => req.end()) .on('response', response => { t.false(response.fromCache); t.end(); }); }); test.cb('cacheableRequest emits response event for cached responses', t => { const cacheableRequest = new CacheableRequest(request); const cache = new Map(); const opts = Object.assign(url.parse(s.url), { cache }); cacheableRequest(opts, () => { // This needs to happen in next tick so cache entry has time to be stored setImmediate(() => { cacheableRequest(opts) .on('request', req => req.end()) .on('response', response => { t.true(response.fromCache); t.end(); }); }); }).on('request', req => req.end()); }); test.cb('cacheableRequest emits CacheError if cache adapter connection errors', t => { const cacheableRequest = new CacheableRequest(request, 'sqlite://non/existent/database.sqlite'); cacheableRequest(url.parse(s.url)) .on('error', err => { t.true(err instanceof CacheableRequest.CacheError); t.is(err.code, 'SQLITE_CANTOPEN'); t.end(); }) .on('request', req => req.end()); }); test.cb('cacheableRequest emits CacheError if cache.get errors', t => { const errMessage = 'Fail'; const store = new Map(); const cache = { get: () => { throw new Error(errMessage); }, set: store.set.bind(store), delete: store.delete.bind(store) }; const cacheableRequest = new CacheableRequest(request, cache); cacheableRequest(url.parse(s.url)) .on('error', err => { t.true(err instanceof CacheableRequest.CacheError); t.is(err.message, errMessage); t.end(); }) .on('request', req => req.end()); }); test.cb('cacheableRequest emits CacheError if cache.set errors', t => { const errMessage = 'Fail'; const store = new Map(); const cache = { get: store.get.bind(store), set: () => { throw new Error(errMessage); }, delete: store.delete.bind(store) }; const cacheableRequest = new CacheableRequest(request, cache); cacheableRequest(url.parse(s.url)) .on('error', err => { t.true(err instanceof CacheableRequest.CacheError); t.is(err.message, errMessage); t.end(); }) .on('request', req => req.end()); }); test.cb('cacheableRequest emits CacheError if cache.delete errors', t => { const errMessage = 'Fail'; const store = new Map(); const cache = { get: store.get.bind(store), set: store.set.bind(store), delete: () => { throw new Error(errMessage); } }; const cacheableRequest = new CacheableRequest(request, cache); (async () => { let i = 0; const s = await createTestServer(); s.get('/', (req, res) => { const cc = i === 0 ? 'public, max-age=0' : 'public, no-cache, no-store'; i++; res.setHeader('Cache-Control', cc); res.end('hi'); }); cacheableRequest(s.url, () => { // This needs to happen in next tick so cache entry has time to be stored setImmediate(() => { cacheableRequest(s.url) .on('error', async err => { t.true(err instanceof CacheableRequest.CacheError); t.is(err.message, errMessage); await s.close(); t.end(); }) .on('request', req => req.end()); }); }).on('request', req => req.end()); })(); }); test.cb('cacheableRequest emits RequestError if request function throws', t => { const cacheableRequest = new CacheableRequest(request); const opts = url.parse(s.url); opts.headers = { invalid: '💣' }; cacheableRequest(opts) .on('error', err => { t.true(err instanceof CacheableRequest.RequestError); t.end(); }) .on('request', req => req.end()); }); test.cb('cacheableRequest does not cache response if request is aborted before receiving first byte of response', t => { /* eslint-disable max-nested-callbacks */ // eslint-disable-next-line promise/prefer-await-to-then createTestServer().then(s => { s.get('/delay-start', (req, res) => { setTimeout(() => { res.setHeader('cache-control', 'max-age=60'); res.end('hi'); }, 50); }); const cacheableRequest = new CacheableRequest(request); const opts = url.parse(s.url); opts.path = '/delay-start'; cacheableRequest(opts) .on('request', req => { req.end(); setTimeout(() => { req.abort(); }, 20); setTimeout(() => { cacheableRequest(opts, async response => { t.is(response.fromCache, false); const body = await getStream(response); t.is(body, 'hi'); t.end(); }).on('request', req => req.end()); }, 100); }); }); /* eslint-enable max-nested-callbacks */ }); test.cb('cacheableRequest does not cache response if request is aborted after receiving part of the response', t => { /* eslint-disable max-nested-callbacks */ // eslint-disable-next-line promise/prefer-await-to-then createTestServer().then(s => { s.get('/delay-partial', (req, res) => { res.setHeader('cache-control', 'max-age=60'); res.write('h'); setTimeout(() => { res.end('i'); }, 50); }); const cacheableRequest = new CacheableRequest(request); const opts = url.parse(s.url); opts.path = '/delay-partial'; cacheableRequest(opts) .on('request', req => { req.end(); setTimeout(() => { req.abort(); }, 20); setTimeout(() => { cacheableRequest(opts, async response => { t.is(response.fromCache, false); const body = await getStream(response); t.is(body, 'hi'); t.end(); }).on('request', req => req.end()); }, 100); }); }); /* eslint-enable max-nested-callbacks */ }); test.cb('cacheableRequest makes request even if initial DB connection fails (when opts.automaticFailover is enabled)', t => { const cacheableRequest = new CacheableRequest(request, 'sqlite://non/existent/database.sqlite'); const opts = url.parse(s.url); opts.automaticFailover = true; cacheableRequest(opts, res => { t.is(res.statusCode, 200); t.end(); }) .on('error', () => {}) .on('request', req => req.end()); }); test.cb('cacheableRequest makes request even if current DB connection fails (when opts.automaticFailover is enabled)', t => { /* eslint-disable unicorn/error-message */ const cache = { get: () => { throw new Error(); }, set: () => { throw new Error(); }, delete: () => { throw new Error(); } }; /* eslint-enable unicorn/error-message */ const cacheableRequest = new CacheableRequest(request, cache); const opts = url.parse(s.url); opts.automaticFailover = true; cacheableRequest(opts, res => { t.is(res.statusCode, 200); t.end(); }) .on('error', () => {}) .on('request', req => req.end()); }); test.after('cleanup', async () => { await s.close(); });