npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

supafuse

v1.0.1

Published

Cache, dedupe, and rate-limit supabase-js / PostgREST requests.

Readme

Supafuse

Cache, dedupe, and optionally rate-limit supabase-js / PostgREST requests.

Wraps createClient(). Query code stays the same; the guard intercepts the HTTP request PostgREST already built.

npm install supafuse @supabase/supabase-js
import { createGuardedClient } from "supafuse"

export const supabase = createGuardedClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

const { data, error } = await supabase
  .from("products")
  .select("*")
  .eq("category", "mobile")

createGuardedClient accepts the same third-argument options as createClient (auth, db, global, …) plus the guard options below.

Contents

Option tables, the 429 body, stats fields, and adapter interfaces: docs/configuration.md, docs/api.md.

What is intercepted

Only PostgREST (/rest/v1/...). Auth, Storage, Realtime, and Functions pass through unchanged.

Cached and deduped:

  • GET / HEAD selects on a table

Never cached:

  • inserts, updates, deletes, upserts
  • RPCs
  • service-role responses
  • authenticated reads, unless cache.cacheAuthenticated is true

Cache

Keys are built from method, path, sorted query string, PostgREST headers (Accept, Prefer, Range, schema profiles), request body hash, and an auth fingerprint. The raw JWT is never stored.

These are three separate entries:

supabase.from("products").select("*").eq("category", "mobile")
supabase.from("products").select("*").eq("category", "laptop")
supabase.from("products").select("*").eq("id", 4)

Anonymous reads are cached. User A and user B never share a cached body.

A successful insert, update, delete, or upsert on products from this client drops every cached products query. A failed write leaves the cache alone. Writes from other clients (Dashboard, another app) are only seen if Realtime invalidation is on.

createGuardedClient(url, key, {
  cache: {
    ttl: 30,
    maxEntries: 500,
    excludeTables: ["balances", "payments", "sessions"],
    cacheAuthenticated: false,
    staleWhileRevalidate: 0,
  },
})
  • includeTables — if set, only those tables are cached ("products" or "public.products").
  • excludeTables — those tables are never cached.
  • staleWhileRevalidate — extra seconds after TTL during which a stale body can be returned while one refresh runs in the background. Default 0 (off).
  • strict: true — adapter errors throw instead of falling through to the network.
  • cache: false — disable caching.

Default adapter is an in-memory LRU (maxEntries: 500).

Dedupe

If several callers fire the same request at the same time, one network call is made. The others wait and each get their own Response copy. Dedupe has no TTL.

createGuardedClient(url, key, { dedupe: false })

Browser vs server

| | Browser | Server (Route Handler, Server Action, API route, worker) | | --- | --- | --- | | Dedupe | Yes, this tab | Yes, this process | | Memory cache | Yes, this tab | Yes, this isolate | | Shared cache | No | Pass Redis / Upstash | | Rate limit | Only this tab; anyone can skip it | Shared if the store is Redis |

Put rate limits and shared cache on a server client. A limiter in the browser only disciplines that page; the console warns once if you enable protection there.

Configuration

createGuardedClient(url, key, {
  dedupe: true,
  cache: { /* CacheConfig */ },
  protection: { /* ProtectionConfig */ },
  hooks: { /* GuardHooks */ },
  debug: true,
  db: { schema: "public" },
})

Defaults: dedupe on, memory cache on, 30s TTL, mutation invalidation on, authenticated cache off, Realtime off, rate limits off, large-response warnings on (2 MB), no telemetry.

Full option list: docs/configuration.md.

Adapters

Memory is the default. For several server processes, pass Redis or Upstash.

import { createGuardedClient, RedisCacheAdapter, RedisRateLimitStore } from "supafuse"
import Redis from "ioredis"

const redis = new Redis(process.env.REDIS_URL)

export const supabase = createGuardedClient(url, key, {
  cache: {
    adapter: new RedisCacheAdapter(redis),
    cacheAuthenticated: true,
  },
  protection: {
    rateLimit: {
      requests: 100,
      window: "1m",
      store: new RedisRateLimitStore(redis),
    },
  },
})

Upstash over HTTP (REST URL + token, no Redis client required):

import { createGuardedClient, UpstashCacheAdapter } from "supafuse"

createGuardedClient(url, key, {
  cache: {
    adapter: new UpstashCacheAdapter({
      url: process.env.UPSTASH_REDIS_REST_URL!,
      token: process.env.UPSTASH_REDIS_REST_TOKEN!,
    }),
  },
})

RedisCacheAdapter / RedisRateLimitStore accept ioredis, node-redis, or any client with get / set / del (set operations optional, camelCase or snake_case). Key prefix defaults to sf (cache) and sf:rl (rate limit).

Custom adapters implement CacheAdapter: get, set, delete, and optionally deleteByTags, clear, inspect. If deleteByTags is missing, table invalidation is a no-op on that adapter.

inspect() lists entries for the memory adapter. Redis and Upstash return an empty list.

Rate limiting

Enable this on a server client.

createGuardedClient(url, key, {
  protection: {
    rateLimit: {
      requests: 100,
      window: "1m", // number of seconds, or "30s" | "1m" | "1h" | "500ms"
      scope: ["project", "user"], // also "ip", "table"
      count: "network", // or "all" (includes cache hits)
      store: new RedisRateLimitStore(redis),
    },
    budget: {
      maxRequestsPerMinute: 500,
      maxBytesPerHour: 100_000_000,
    },
    maxConcurrentRequests: 10,
    onExceeded: "429", // or "warn"
    warnOnLargeResponses: true,
    largeResponseWarningBytes: 2_000_000,
  },
})

By default only requests that would hit the network are counted. Cache hits and deduped waiters are not. Over the limit, supabase-js receives a 429 with Retry-After and code: "SUPAFUSE_RATE_LIMITED". Use onExceeded: "warn" while tuning; the request still goes through.

Default store is in-memory (per process). Byte budget needs a store with add and peek (MemoryRateLimitStore and RedisRateLimitStore both implement them).

If the store throws, the request is allowed unless protection.strict is true.

This limits traffic from your app. It does not stop callers that hit Supabase directly with the anon key.

Realtime invalidation

Set cache.realtimeInvalidation: true so this client drops local keys when Postgres emits INSERT/UPDATE/DELETE (including Dashboard edits). Redis is not required. The table must be in the supabase_realtime publication:

npx supafuse init --tables products,categories
# review supabase/migrations/*_supafuse_realtime.sql
# supabase db push
createGuardedClient(url, key, {
  cache: {
    realtimeInvalidation: true,
    // or:
    // realtimeInvalidation: {
    //   enabled: true,
    //   schemas: ["public"],
    //   tables: ["products", "categories"],
    // },
  },
})

true subscribes to the client's default schema (db.schema, else public). An empty tables array means every table in those schemas.

npx supafuse init writes a migration and supafuse.config.json. That JSON is a starter file; createGuardedClient does not load it. Copy the settings into your client options.

Call supabase.guard.disconnect() when the process is shutting down to drop Realtime channels.

Realtime and Redis are independent. With neither: same-client writes plus TTL.

supabase.guard

const stats = supabase.guard.stats()
const report = await supabase.guard.inspect()

await supabase.guard.invalidateTable("products")
await supabase.guard.invalidate(["table:public.products"])
await supabase.guard.clearCache()
supabase.guard.resetStats()
await supabase.guard.disconnect()

| Method | | | --- | --- | | stats() | Counters for this client (hits, misses, bytes, 429s, per-table). | | inspect() | Config snapshot, last 50 events, and cache entries (memory adapter only). | | invalidateTable(table, schema?) | Drop all cached queries for that table. | | invalidate(tags) | Drop keys matching cache tags. | | clearCache() | Empty the adapter if it implements clear. | | resetStats() | Zero counters and the event log. | | disconnect() | Stop Realtime subscriptions. |

estimatedBytesAvoided is a byte counter, not a bill. Field lists: docs/api.md.

Hooks

createGuardedClient(url, key, {
  hooks: {
    onCacheHit(event) {},
    onCacheMiss(event) {},
    onDedupHit(event) {},
    onRevalidate(event) {},
    onInvalidation(event) {},
    onLargeResponse(event) {},
    onRateLimited(event) {},
    onError(event) {},
  },
  debug: true,
})

debug: true logs HIT / MISS / DEDUPED / STALE / NETWORK / 429 to the console.

CLI

npx supafuse init [--schema public] [--tables products,categories] [--cwd .]

Writes supabase/migrations/<timestamp>_supafuse_realtime.sql and supafuse.config.json. Does not apply SQL.

Limitations

  • Browser rate limits are not security. RLS still has to be correct. Service-role responses are never cached.
  • Writes and RPCs always go through.
  • Invalidation is per table, not per filter or page.
  • Dashboard / other backends / unwrapped createClient() are only visible with Realtime, or if they share Redis and this package ran deleteByTags.
  • Serverless isolates each have their own Map. Pass a Redis adapter for a shared cache.
  • Auth, Storage, and Functions are passed through.
  • inspect() cannot list Redis / Upstash keys.

License

MIT