From 45d397221e5056a49e9aeca6aa5e0f677210a087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Xalambr=C3=AD?= Date: Thu, 30 Jan 2025 00:48:42 -0800 Subject: [PATCH] Add code --- README.md | 37 +++++++++++++++++++-- bun.lockb | Bin 36681 -> 37044 bytes package.json | 24 ++++++++------ src/index.test.ts | 36 +++++++++++++++++--- src/index.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 161 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f8ff96c..ef79a36 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,39 @@ -# package +# batcher -A template to create new packages. +A simpler batcher for any async function + +## Installation + +```bash +npm install @edgefirst-dev/batcher +``` + +## Usage + +```typescript +import { Batcher } from "@edgefirst-dev/batcher"; + +// Configure the time window for the batcher +// The batcher will call the async function only once for the same key in this time window +let batcher = new Batcher(10); + +async function asyncFunction(): Promise { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { value: "ok" }; +} + +let [result1, result2] = await Promise.all([ + batcher.call(["my", "custom", "key"], asyncFunction), + batcher.call(["my", "custom", "key"], asyncFunction), +]); + +console.log(result1 === result2); // true +``` + +The batcher will call the async function only once for the same key, and return the same promise for all calls with the same key. This way, if the function returns an object all calls will resolve with the same object that can be compared by reference. + +> [!TIP] +> Use this batcher in Remix or React Router applications to batch async function calls in your loaders so you can avoid multiple queries or fetches for the same data. ## What to do after cloning this repository diff --git a/bun.lockb b/bun.lockb index ff2e49e525749c34c3ed1e04c5fa85068f3c95d5..52c292b5adcde7b98c81697c509d6866c1c0b039 100755 GIT binary patch delta 6862 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 { - expect(() => doSomething()).toThrowError("Not implemented yet"); +test("calls the function once per key", async () => { + let fn = mock(); + let batcher = new Batcher(10); + + let times = Math.floor(Math.random() * 100) + 1; + + await Promise.all( + Array.from({ length: times }).map(() => { + return batcher.call(["key"], fn); + }), + ); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +test("caches results and return the same value", async () => { + let batcher = new Batcher(10); + + let [value1, value2] = await Promise.all([ + batcher.call(["key"], async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return { key: "value" }; + }), + batcher.call(["key"], async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return { key: "value" }; + }), + ]); + + expect(value1).toBe(value2); }); diff --git a/src/index.ts b/src/index.ts index 45b7145..624b005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,82 @@ -export function doSomething() { - throw new Error("Not implemented yet"); +import type { Jsonifiable } from "type-fest"; + +/** + * This is our batcher implementation. It is a class that allows us to batch + * function calls that are identical within a certain time window. This is + * useful for reducing API calls to external services, for example. + * + * The Batcher class has a cache property that stores the results of the + * function calls. It also has a timeouts property that stores the timeouts + * for each function call. The batchWindow property is the time window in + * milliseconds that we use to batch the function calls. + * + * The call method takes an array of values as key and an async function fn. + * It converts the key to a string and stores it in the cache. If the cache + * already has the key, it returns the cached value. Otherwise, it creates a + * new promise and sets a timeout for the function call. When the timeout + * expires, the function is called and the result is resolved. If an error + * occurs, the promise is rejected. Finally, the timeout is removed from the + * timeouts property. + * + * @example + * let batcher = new Batcher(10); + * let [value1, value2] = await Promise.all([ + * batcher.call(["key"], async () => { + * await new Promise((resolve) => setTimeout(resolve, 5)); + * return { key: "value" } + * }), + * batcher.call(["key"], async () => { + * await new Promise((resolve) => setTimeout(resolve, 5)); + * return { key: "value" } + * }), + * ]) + * console.log(value1 === value2); // true + */ +export class Batcher { + protected readonly cache = new Map>(); + protected readonly timeouts = new Map(); + + /** + * Creates a new instance of the Batcher. + * @param batchWindow The time window (in milliseconds) to batch function calls. + */ + constructor(protected batchWindow?: number) {} + + /** + * Calls a function with batching, ensuring multiple identical calls within a time window execute only once. + * @template TArgs The argument types. + * @template TResult The return type. + * @param fn The async function to batch. + * @param key An array of values used for deduplication. + * @returns A promise that resolves with the function result. + */ + call( + key: Key[], + fn: () => Promise, + ): Promise { + let cacheKey = JSON.stringify(key); + + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey) as Promise; + } + + let promise = new Promise((resolve, reject) => { + let timeout = setTimeout(async () => { + try { + let result = await fn(); + resolve(result); + } catch (error) { + reject(error); + } finally { + this.timeouts.delete(cacheKey); + } + }, this.batchWindow); + + this.timeouts.set(cacheKey, timeout); + }); + + this.cache.set(cacheKey, promise); + + return promise; + } }