├── .github ├── FUNDING.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── bump.yml │ ├── ci.yml │ ├── dependabot.yml │ ├── docs.yml │ └── publish.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── biome.json ├── bun.lockb ├── package.json ├── scripts └── exports.ts ├── src ├── index.test.ts └── index.ts ├── tsconfig.json └── typedoc.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: sergiodxa 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | reviewers: 10 | - "sergiodxa" 11 | assignees: 12 | - "sergiodxa" 13 | 14 | - package-ecosystem: bun 15 | directory: / 16 | schedule: 17 | interval: "weekly" 18 | reviewers: 19 | - "sergiodxa" 20 | assignees: 21 | - "sergiodxa" 22 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: New Features 4 | labels: 5 | - enhancement 6 | - title: Documentation Changes 7 | labels: 8 | - documentation 9 | - title: Bug Fixes 10 | labels: 11 | - bug 12 | - title: Example 13 | labels: 14 | - example 15 | - title: Deprecations 16 | labels: 17 | - deprecated 18 | - title: Other Changes 19 | labels: 20 | - "*" 21 | -------------------------------------------------------------------------------- /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Type of version to bump" 8 | required: true 9 | type: choice 10 | options: 11 | - major 12 | - minor 13 | - patch 14 | 15 | jobs: 16 | bump-version: 17 | name: Bump version 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | ssh-key: ${{ secrets.DEPLOY_KEY }} 23 | 24 | - uses: oven-sh/setup-bun@v2 25 | - run: bun install --frozen-lockfile 26 | 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version: "lts/*" 30 | 31 | - run: | 32 | git config user.name 'Sergio Xalambrí' 33 | git config user.email 'hello@sergiodxa.com' 34 | 35 | - run: npm version ${{ github.event.inputs.version }} 36 | - run: bun run quality:fix 37 | - run: git push origin main --follow-tags 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: oven-sh/setup-bun@v2 12 | - run: bun install --frozen-lockfile 13 | - run: bun run build 14 | 15 | typecheck: 16 | name: Typechecker 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: oven-sh/setup-bun@v2 21 | - run: bun install --frozen-lockfile 22 | - run: bun run typecheck 23 | 24 | quality: 25 | name: Code Quality 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: oven-sh/setup-bun@v2 30 | - run: bun install --frozen-lockfile 31 | - run: bun run quality 32 | 33 | test: 34 | name: Tests 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: oven-sh/setup-bun@v2 39 | - run: bun install --frozen-lockfile 40 | - run: bun test 41 | 42 | exports: 43 | name: Verify Exports 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: oven-sh/setup-bun@v2 48 | - run: bun install --frozen-lockfile 49 | - run: bun run exports 50 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Enable auto-merge for Dependabot PRs 2 | 3 | on: 4 | pull_request: 5 | types: opened 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.actor == 'dependabot[bot]' }} 15 | steps: 16 | - id: metadata 17 | uses: dependabot/fetch-metadata@v2 18 | with: 19 | github-token: "${{ secrets.GITHUB_TOKEN }}" 20 | 21 | - run: gh pr merge --auto --squash "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "docs" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: oven-sh/setup-bun@v2 26 | - run: bun install --frozen-lockfile 27 | - run: bunx typedoc 28 | - uses: actions/configure-pages@v5 29 | - uses: actions/upload-pages-artifact@v3 30 | with: 31 | path: "./docs" 32 | - uses: actions/deploy-pages@v4 33 | id: deployment 34 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | name: "Publish to npm" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: oven-sh/setup-bun@v2 14 | - run: bun install --frozen-lockfile 15 | - run: bun run build 16 | - run: bun run exports 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: "lts/*" 21 | registry-url: https://registry.npmjs.org/ 22 | 23 | - run: npm publish --access public 24 | env: 25 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /coverage 3 | /docs 4 | /node_modules -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "biomejs.biome" 4 | }, 5 | "[javascriptreact]": { 6 | "editor.defaultFormatter": "biomejs.biome" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "biomejs.biome" 10 | }, 11 | "[jsonc]": { 12 | "editor.defaultFormatter": "biomejs.biome" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "biomejs.biome" 16 | }, 17 | "[typescriptreact]": { 18 | "editor.defaultFormatter": "biomejs.biome" 19 | }, 20 | "editor.codeActionsOnSave": { 21 | "source.organizeImports.biome": "explicit", 22 | "quickfix.biome": "explicit" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | ## Setup 4 | 5 | Run `bun install` to install the dependencies. 6 | 7 | Run the tests with `bun test`. 8 | 9 | Run the code quality checker with `bun run quality`. 10 | 11 | Run the typechecker with `bun run typecheck`. 12 | 13 | Run the exports checker with `bun run exports`. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sergio Xalambrí 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # batcher 2 | 3 | A simpler batcher for any async function 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install @edgefirst-dev/batcher 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```typescript 14 | import { Batcher } from "@edgefirst-dev/batcher"; 15 | 16 | // Configure the time window for the batcher 17 | // The batcher will call the async function only once for the same key in this time window 18 | let batcher = new Batcher(); 19 | 20 | async function asyncFunction(): Promise { 21 | await new Promise((resolve) => setTimeout(resolve, 1000)); 22 | return { value: "ok" }; 23 | } 24 | 25 | let [result1, result2] = await Promise.all([ 26 | batcher.call(["my", "custom", "key"], asyncFunction), 27 | batcher.call(["my", "custom", "key"], asyncFunction), 28 | ]); 29 | 30 | console.log(result1 === result2); // true 31 | ``` 32 | 33 | 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. 34 | 35 | > [!TIP] 36 | > 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. 37 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "correctness": { 11 | "useHookAtTopLevel": "error" 12 | }, 13 | "performance": { 14 | "noBarrelFile": "error", 15 | "noReExportAll": "error" 16 | }, 17 | "style": { 18 | "noDefaultExport": "error", 19 | "noNegationElse": "error", 20 | "useConst": "off", 21 | "useExportType": "off", 22 | "useImportType": "off" 23 | }, 24 | "suspicious": { 25 | "noConsoleLog": "warn", 26 | "noEmptyBlockStatements": "warn", 27 | "noSkippedTests": "error" 28 | } 29 | } 30 | }, 31 | "formatter": { "enabled": true }, 32 | "vcs": { 33 | "enabled": true, 34 | "clientKind": "git", 35 | "defaultBranch": "main", 36 | "useIgnoreFile": true 37 | }, 38 | "overrides": [ 39 | { 40 | "include": ["**/*.md"], 41 | "formatter": { "indentStyle": "tab" } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edgefirst-dev/batcher/4e0a3b25bc5bb8f4b245e0ab907ba4ee1559b53f/bun.lockb -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@edgefirst-dev/batcher", 3 | "version": "1.0.1", 4 | "description": "A simpler batcher for any async function", 5 | "license": "MIT", 6 | "funding": [ 7 | "https://github.com/sponsors/sergiodxa" 8 | ], 9 | "author": { 10 | "name": "Sergio Xalambrí", 11 | "email": "hello+oss@sergiodxa.com", 12 | "url": "https://sergiodxa.com" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/edgefirst-dev/batcher" 17 | }, 18 | "homepage": "https://edgefirst-dev.github.io/batcher", 19 | "bugs": { 20 | "url": "https://github.com/edgefirst-dev/batcher/issues" 21 | }, 22 | "scripts": { 23 | "build": "tsc", 24 | "typecheck": "tsc --noEmit", 25 | "quality": "biome check .", 26 | "quality:fix": "biome check . --write --unsafe", 27 | "exports": "bun run ./scripts/exports.ts" 28 | }, 29 | "sideEffects": false, 30 | "type": "module", 31 | "engines": { 32 | "node": ">=20.0.0" 33 | }, 34 | "files": [ 35 | "build", 36 | "package.json", 37 | "README.md" 38 | ], 39 | "exports": { 40 | ".": "./build/index.js", 41 | "./package.json": "./package.json" 42 | }, 43 | "dependencies": { 44 | "type-fest": "^4.33.0" 45 | }, 46 | "peerDependencies": {}, 47 | "devDependencies": { 48 | "@arethetypeswrong/cli": "^0.18.1", 49 | "@biomejs/biome": "^1.9.4", 50 | "@total-typescript/tsconfig": "^1.0.4", 51 | "@types/bun": "^1.2.1", 52 | "consola": "^3.4.0", 53 | "typedoc": "^0.28.0", 54 | "typedoc-plugin-mdn-links": "^5.0.1", 55 | "typescript": "^5.7.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/exports.ts: -------------------------------------------------------------------------------- 1 | async function main() { 2 | let proc = Bun.spawn([ 3 | "bunx", 4 | "attw", 5 | "-f", 6 | "table-flipped", 7 | "--no-emoji", 8 | "--no-color", 9 | "--pack", 10 | ]); 11 | 12 | let text = await new Response(proc.stdout).text(); 13 | 14 | let entrypointLines = text 15 | .slice(text.indexOf('"remix-i18next/')) 16 | .split("\n") 17 | .filter(Boolean) 18 | .filter((line) => !line.includes("─")) 19 | .map((line) => 20 | line 21 | .replaceAll(/[^\d "()/A-Za-z│-]/g, "") 22 | .replaceAll("90m│39m", "│") 23 | .replaceAll(/^│/g, "") 24 | .replaceAll(/│$/g, ""), 25 | ); 26 | 27 | let pkg = await Bun.file("package.json").json(); 28 | let entrypoints = entrypointLines.map((entrypointLine) => { 29 | let [entrypoint, ...resolutionColumns] = entrypointLine.split("│"); 30 | if (!entrypoint) throw new Error("Entrypoint not found"); 31 | if (!resolutionColumns[2]) throw new Error("ESM resolution not found"); 32 | if (!resolutionColumns[3]) throw new Error("Bundler resolution not found"); 33 | return { 34 | entrypoint: entrypoint.replace(pkg.name, ".").trim(), 35 | esm: resolutionColumns[2].trim(), 36 | bundler: resolutionColumns[3].trim(), 37 | }; 38 | }); 39 | 40 | let entrypointsWithProblems = entrypoints.filter( 41 | (item) => item.esm.includes("fail") || item.bundler.includes("fail"), 42 | ); 43 | 44 | if (entrypointsWithProblems.length > 0) { 45 | console.error("Entrypoints with problems:"); 46 | process.exit(1); 47 | } 48 | } 49 | 50 | await main().catch((error) => { 51 | console.error(error); 52 | process.exit(1); 53 | }); 54 | 55 | export {}; 56 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, mock, test } from "bun:test"; 2 | 3 | import { Batcher } from "."; 4 | 5 | test("calls the function once per key", async () => { 6 | let fn = mock().mockImplementation(() => Promise.resolve()); 7 | let batcher = new Batcher(); 8 | 9 | let times = Math.floor(Math.random() * 100) + 1; 10 | 11 | await Promise.all( 12 | Array.from({ length: times }).map(() => { 13 | return batcher.call(["key"], fn); 14 | }), 15 | ); 16 | 17 | expect(fn).toHaveBeenCalledTimes(1); 18 | }); 19 | 20 | test("calls the function once per key with different keys", async () => { 21 | let fn = mock().mockImplementation(() => Promise.resolve()); 22 | let batcher = new Batcher(); 23 | 24 | let times = Math.floor(Math.random() * 100) + 1; 25 | 26 | await Promise.all( 27 | Array.from({ length: times }).map((_, index) => { 28 | return batcher.call([index], fn); 29 | }), 30 | ); 31 | 32 | expect(fn).toHaveBeenCalledTimes(times); 33 | }); 34 | 35 | test("caches results and return the same value", async () => { 36 | let batcher = new Batcher(); 37 | 38 | let [value1, value2] = await Promise.all([ 39 | batcher.call(["key"], async () => { 40 | await new Promise((resolve) => setTimeout(resolve, 5)); 41 | return { key: "value" }; 42 | }), 43 | batcher.call(["key"], async () => { 44 | await new Promise((resolve) => setTimeout(resolve, 5)); 45 | return { key: "value" }; 46 | }), 47 | ]); 48 | 49 | expect(value1).toBe(value2); 50 | }); 51 | 52 | test("real world scenario", async () => { 53 | let batcher = new Batcher(); 54 | 55 | let result = { key: "value" as const }; 56 | let fn = mock<() => Promise>().mockImplementation(() => { 57 | return new Promise((resolve) => setTimeout(resolve, 5, result)); 58 | }); 59 | 60 | async function loader1(batcher: Batcher) { 61 | await batcher.call(["key"], fn); 62 | } 63 | 64 | async function loader2(batcher: Batcher) { 65 | await batcher.call(["key"], fn); 66 | await batcher.call(["key"], fn); 67 | } 68 | 69 | async function loader3(batcher: Batcher) { 70 | await Promise.all([ 71 | batcher.call(["key"], fn), 72 | batcher.call(["key"], fn), 73 | batcher.call(["key"], fn), 74 | ]); 75 | } 76 | 77 | await Promise.all([loader1(batcher), loader2(batcher), loader3(batcher)]); 78 | 79 | expect(fn).toHaveBeenCalledTimes(1); 80 | }); 81 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Jsonifiable } from "type-fest"; 2 | 3 | /** 4 | * A class that allows us to batch function calls that are identical within a 5 | * certain time window. This is useful for reducing API calls to external 6 | * services, for example. 7 | * 8 | * The Batcher class has an internal `cache` that stores the results of the 9 | * function calls. 10 | * 11 | * The call method takes an array of values as key and an async function fn. 12 | * It converts the key to a string and stores it in the cache. If the cache 13 | * already has the key, it returns the cached value. Otherwise, it creates a 14 | * new promise and sets a timeout for the function call. When the timeout 15 | * expires, the function is called and the result is resolved. If an error 16 | * occurs, the promise is rejected. Finally, the timeout is removed from the 17 | * timeouts property. 18 | * 19 | * @example 20 | * let batcher = new Batcher(); 21 | * let [value1, value2] = await Promise.all([ 22 | * batcher.call(["key"], async () => { 23 | * await new Promise((resolve) => setTimeout(resolve, 5)); 24 | * return { key: "value" } 25 | * }), 26 | * batcher.call(["key"], async () => { 27 | * await new Promise((resolve) => setTimeout(resolve, 5)); 28 | * return { key: "value" } 29 | * }), 30 | * ]) 31 | * console.log(value1 === value2); // true 32 | */ 33 | export class Batcher { 34 | protected readonly cache = new Map>(); 35 | 36 | /** 37 | * Calls a function with batching, ensuring multiple identical calls within a time window execute only once. 38 | * @template TArgs The argument types. 39 | * @template TResult The return type. 40 | * @param fn The async function to batch. 41 | * @param key An array of values used for deduplication. 42 | * @returns A promise that resolves with the function result. 43 | */ 44 | call( 45 | key: Key[], 46 | fn: () => Promise, 47 | ): Promise { 48 | let cacheKey = JSON.stringify(key); 49 | 50 | if (this.cache.has(cacheKey)) { 51 | return this.cache.get(cacheKey) as Promise; 52 | } 53 | 54 | let promise = fn(); 55 | 56 | this.cache.set(cacheKey, promise); 57 | 58 | return promise; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* Change to `@total-typescript/tsconfig/tsc/dom/library` for DOM usage */ 3 | "extends": "@total-typescript/tsconfig/tsc/no-dom/library", 4 | "include": ["src/**/*"], 5 | "exclude": ["src/**/*.test.*"], 6 | "compilerOptions": { 7 | "outDir": "./build" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "includeVersion": true, 4 | "entryPoints": ["./src/index.ts"], 5 | "out": "docs", 6 | "json": "docs/index.json", 7 | "cleanOutputDir": true, 8 | "plugin": ["typedoc-plugin-mdn-links"], 9 | "categorizeByGroup": false 10 | } 11 | --------------------------------------------------------------------------------