diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index f4176095..795de807 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -52,4 +52,5 @@ jobs: ./packages/node-cache/coverage/lcov.info, \ ./packages/memoize/coverage/lcov.info, \ ./packages/memory/coverage/lcov.info, \ + ./packages/net/coverage/lcov.info, \ ./packages/utils/coverage/lcov.info diff --git a/packages/net/LICENSE b/packages/net/LICENSE new file mode 100644 index 00000000..9d6198a9 --- /dev/null +++ b/packages/net/LICENSE @@ -0,0 +1,19 @@ +MIT License & © Jared Wray + +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. diff --git a/packages/net/README.md b/packages/net/README.md new file mode 100644 index 00000000..25188d4e --- /dev/null +++ b/packages/net/README.md @@ -0,0 +1,43 @@ +[Cacheable](https://github.com/jaredwray/cacheable) + +> High Performance Network Caching for Node.js with fetch, request, http 1.1, and http 2 support + +[![codecov](https://codecov.io/gh/jaredwray/cacheable/graph/badge.svg?token=lWZ9OBQ7GM)](https://codecov.io/gh/jaredwray/cacheable) +[![tests](https://github.com/jaredwray/cacheable/actions/workflows/tests.yml/badge.svg)](https://github.com/jaredwray/cacheable/actions/workflows/tests.yml) +[![npm](https://img.shields.io/npm/dm/@cacheable/net.svg)](https://www.npmjs.com/package/@cacheable/net) +[![npm](https://img.shields.io/npm/v/@cacheable/net.svg)](https://www.npmjs.com/package/@cacheable/net) +[![license](https://img.shields.io/github/license/jaredwray/cacheable)](https://github.com/jaredwray/cacheable/blob/main/LICENSE) + + +Features: +* `fetch` from [undici](https://github.com/nodejs/undici) cache enabled via `cacheable` +* `fetch` quick helpers such as `get`, `post`, `put`, and `delete` for easier development +* `request` from [undici](https://github.com/nodejs/undici) cache enabled via `cacheable` +* HTTP/1.1 and HTTP/2 caching support via Node.js `http` and `https` modules +* [RFC 7234](http://httpwg.org/specs/rfc7234.html) compliant HTTP caching for native Node.js HTTP/HTTPS requests +* Drop in replacement for `http` `https`, `fetch` modules with caching enabled +* DNS caching for `dns.lookup` and `dns.resolve` methods via `cacheable` +* WHOIS caching for `whois.lookup` method via `cacheable` +* Advanced key generation via built in hashing and custom key generation functions +* Benchmarks for performance comparison +* All the features of [cacheable](https://npmjs.com/package/cacheable) - layered caching, LRU, expiration, hooks, backed by Keyv, and more! +* Highly Tested and Maintained on a regular basis with a focus on performance and reliability + +# Table of Contents +* [Getting Started](#getting-started) +* [How to Contribute](#how-to-contribute) +* [License and Copyright](#license-and-copyright) + +# Getting Started + +```bash +npm install @cacheable/net +``` + + +# How to Contribute + +You can contribute by forking the repo and submitting a pull request. Please make sure to add tests and update the documentation. To learn more about how to contribute go to our main README [https://github.com/jaredwray/cacheable](https://github.com/jaredwray/cacheable). This will talk about how to `Open a Pull Request`, `Ask a Question`, or `Post an Issue`. + +# License and Copyright +[MIT © Jared Wray](./LICENSE) diff --git a/packages/net/package.json b/packages/net/package.json new file mode 100644 index 00000000..09bd9f36 --- /dev/null +++ b/packages/net/package.json @@ -0,0 +1,77 @@ +{ + "name": "@cacheable/net", + "version": "1.0.0", + "description": "High Performance Network Caching for Node.js with fetch, request, http 1.1, and http 2 support", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.cjs", + "import": "./dist/index.js" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jaredwray/cacheable.git", + "directory": "packages/cacheable" + }, + "author": "Jared Wray ", + "license": "MIT", + "private": false, + "scripts": { + "build": "rimraf ./dist && tsup src/index.ts --format cjs,esm --dts --clean", + "prepublish": "pnpm build", + "test": "xo --fix && vitest run --coverage", + "test:ci": "xo && vitest run --coverage", + "clean": "rimraf ./dist ./coverage ./node_modules" + }, + "devDependencies": { + "@faker-js/faker": "^9.9.0", + "@types/eslint": "^9.6.1", + "@types/node": "^24.1.0", + "@vitest/coverage-v8": "^3.2.4", + "rimraf": "^6.0.1", + "tsup": "^8.5.0", + "typescript": "^5.8.3", + "vitest": "^3.2.4", + "xo": "^1.2.1" + }, + "dependencies": { + "cacheable": "workspace:^", + "hookified": "^1.10.0", + "undici": "^7.13.0" + }, + "keywords": [ + "cacheable", + "http caching", + "fetch caching", + "request caching", + "http 1.1 caching", + "http 2 caching", + "dns caching", + "whois caching", + "high performance", + "layer 1 caching", + "layer 2 caching", + "distributed caching", + "keyv", + "expiration", + "CacheableMemory", + "distributed sync", + "secondary store", + "primary store", + "cache statistics", + "layered caching", + "fault tolerant", + "in-memory cache", + "distributed cache", + "lru", + "multi-tier cache" + ], + "files": [ + "dist", + "LICENSE" + ] +} diff --git a/packages/net/src/fetch.ts b/packages/net/src/fetch.ts new file mode 100644 index 00000000..d6c157bb --- /dev/null +++ b/packages/net/src/fetch.ts @@ -0,0 +1,38 @@ +import {type Cacheable, type CacheableOptions} from 'cacheable'; +import {fetch as undiciFetch, type RequestInit, type Response as UndiciResponse} from 'undici'; + +export type FetchOptions = Omit & { + cache: Cacheable; +}; + +/** + * Fetch data from a URL with optional request options. + * @param {string} url The URL to fetch. + * @param {FetchOptions} options Optional request options. The `cacheable` property is required and should be an + * instance of `Cacheable` or a `CacheableOptions` object. + * @returns {Promise} The response from the fetch. + */ +export async function fetch(url: string, options: FetchOptions): Promise { + if (!options.cache) { + throw new Error('Fetch options must include a cache instance or options.'); + } + + const fetchOptions: RequestInit = { + ...options, + cache: 'no-cache', + }; + + return options.cache.getOrSet(url, async () => { + // Perform the fetch operation + const response = await undiciFetch(url, fetchOptions); + /* c8 ignore next 3 */ + if (!response.ok) { + throw new Error(`Fetch failed with status ${response.status}`); + } + + return response; + }) as Promise; +} + +export type Response = UndiciResponse; +export type {RequestInit as FetchRequestInit} from 'undici'; diff --git a/packages/net/src/index.ts b/packages/net/src/index.ts new file mode 100644 index 00000000..6b18973f --- /dev/null +++ b/packages/net/src/index.ts @@ -0,0 +1,50 @@ +import {Hookified, type HookifiedOptions} from 'hookified'; +import {Cacheable, type CacheableOptions} from 'cacheable'; +import { + fetch, type FetchOptions, type Response as FetchResponse, type FetchRequestInit, +} from './fetch.js'; + +export type CacheableNetOptions = { + cache?: Cacheable | CacheableOptions; +} & HookifiedOptions; + +export class CacheableNet extends Hookified { + private _cache: Cacheable = new Cacheable(); + + constructor(options?: CacheableNetOptions) { + super(options); + + if (options?.cache) { + this._cache = options.cache instanceof Cacheable ? options.cache : new Cacheable(options.cache); + } + } + + public get cache(): Cacheable { + return this._cache; + } + + public set cache(value: Cacheable) { + this._cache = value; + } + + /** + * Fetch data from a URL with optional request options. Will use the cache that is already set in the instance. + * @param {string} url The URL to fetch. + * @param {FetchRequestInit} options Optional request options. + * @returns {Promise} The response from the fetch. + */ + public async fetch(url: string, options?: FetchRequestInit): Promise { + const fetchOptions: FetchOptions = { + ...options, + cache: this._cache, + }; + + return fetch(url, fetchOptions); + } +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const Net = CacheableNet; +export { + fetch, type FetchOptions, type Response as FetchResponse, type FetchRequestInit, +} from './fetch.js'; diff --git a/packages/net/test/fetch.test.ts b/packages/net/test/fetch.test.ts new file mode 100644 index 00000000..87313258 --- /dev/null +++ b/packages/net/test/fetch.test.ts @@ -0,0 +1,40 @@ +import process from 'node:process'; +import {describe, test, expect} from 'vitest'; +import {faker} from '@faker-js/faker'; +import {Cacheable} from 'cacheable'; +import {fetch, type FetchOptions} from '../src/fetch.js'; + +const testUrl = process.env.TEST_URL ?? 'https://mockhttp.org'; +const testTimeout = 10_000; // 10 seconds + +describe('Fetch', () => { + test('should fetch data successfully', async () => { + const url = `${testUrl}/get`; + const options: FetchOptions = { + method: 'GET', + cache: new Cacheable(), + }; + const response = await fetch(url, options); + expect(response).toBeDefined(); + }, testTimeout); + + test('should fetch data successfully from cache', async () => { + const cache = new Cacheable({stats: true}); + const url = `${testUrl}/get`; + const options: FetchOptions = { + method: 'GET', + cache, + }; + const response = await fetch(url, options); + expect(response).toBeDefined(); + }, testTimeout); + + test('should throw an error if cache is not provided', async () => { + const url = `${testUrl}/get`; + const options: FetchOptions = { + method: 'GET', + cache: undefined as unknown as Cacheable, // Force error + }; + await expect(fetch(url, options)).rejects.toThrow('Fetch options must include a cache instance or options.'); + }, testTimeout); +}); diff --git a/packages/net/test/index.test.ts b/packages/net/test/index.test.ts new file mode 100644 index 00000000..3c301c28 --- /dev/null +++ b/packages/net/test/index.test.ts @@ -0,0 +1,75 @@ + +import process from 'node:process'; +import {describe, test, expect} from 'vitest'; +import {faker} from '@faker-js/faker'; +import {Cacheable} from 'cacheable'; +import { + CacheableNet, fetch, Net, type CacheableNetOptions, type FetchOptions, +} from '../src/index.js'; + +const testUrl = process.env.TEST_URL ?? 'https://mockhttp.org'; +const testTimeout = 10_000; // 10 seconds + +describe('Cacheable Net', () => { + test('should create an instance of CacheableNet', () => { + const net = new CacheableNet(); + expect(net).toBeInstanceOf(CacheableNet); + }); + + test('should create an instance of Net', () => { + const net = new Net(); + expect(net).toBeInstanceOf(CacheableNet); + }); + + test('should create an instance with cache instance', async () => { + const cacheOptions: CacheableNetOptions = { + cache: new Cacheable({ttl: '1h'}), + }; + const net = new CacheableNet(cacheOptions); + expect(net.cache).toBeInstanceOf(Cacheable); + expect(net.cache.ttl).toBe('1h'); + + // Do a quick test to ensure the cache is working + const data = {key: faker.string.uuid(), value: faker.string.alpha(10)}; + await net.cache.set(data.key, data.value); + const cachedValue = await net.cache.get(data.key); + expect(cachedValue).toBe(data.value); + }); + + test('should create an instance with custom cache options', () => { + const cacheOptions: CacheableNetOptions = { + cache: { + ttl: '2h', + }, + }; + const net = new Net(cacheOptions); + expect(net.cache).toBeInstanceOf(Cacheable); + expect(net.cache.ttl).toBe('2h'); + + // Set a new cache instance + const newCache = new Cacheable({ttl: '3h'}); + net.cache = newCache; + expect(net.cache).toBe(newCache); + }); + + test('should fetch data using fetch method', async () => { + const cache = new Cacheable(); + const url = `${testUrl}/get`; + const options: FetchOptions = { + method: 'GET', + cache, + }; + const response = await fetch(url, options); + expect(response).toBeDefined(); + }); + + test('should fetch data using CacheableNet fetch method', async () => { + const net = new Net(); + const url = `${testUrl}/get`; + const options = { + method: 'GET', + }; + const response = await net.fetch(url, options); + expect(response).toBeDefined(); + }, testTimeout); +}); diff --git a/packages/net/tsconfig.json b/packages/net/tsconfig.json new file mode 100644 index 00000000..d6359664 --- /dev/null +++ b/packages/net/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + "baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */ + + /* Emit */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + "outDir": "./dist", /* Specify an output folder for all emitted files. */ + + /* Interop Constraints */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + + /* Completeness */ + "skipLibCheck": true, /* Skip type checking all .d.ts files. */ + "lib": [ + "ESNext", "DOM" + ] + } +} \ No newline at end of file diff --git a/packages/net/vitest.config.ts b/packages/net/vitest.config.ts new file mode 100644 index 00000000..155743cb --- /dev/null +++ b/packages/net/vitest.config.ts @@ -0,0 +1,17 @@ +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + slowTestThreshold: 750, + coverage: { + reporter: ['json', 'text', 'lcov'], + exclude: [ + 'test', + 'src/cacheable-item-types.ts', + 'vitest.config.ts', + 'dist', + 'node_modules', + ], + }, + }, +});