Isomorphic effect runner with deduplication, rate limiting and persistent caching.
EffectRunner.iso-kv backend.pnpm install iso-effect
import { KV } from 'iso-kv'
import { createEffect, EffectRunner } from 'iso-effect'
import { z } from 'zod'
const getMetadata = createEffect(
{
name: 'getMetadata',
input: z.object({ id: z.string() }),
output: z.object({
description: z.string(),
value: z.bigint(),
}),
rateLimit: { calls: 5, per: 'second' },
cache: true,
},
async ({ input, context }) => {
const response = await fetch(`https://api.example.com/${input.id}`)
if (!response.ok) {
context.cache = false
throw new Error(`Metadata request failed: ${response.status}`)
}
context.log.info('Fetched metadata', { id: input.id })
return response.json()
}
)
const runner = new EffectRunner({
kv: new KV(),
})
const metadata = await runner.call(getMetadata, { id: 'token-1' })
Each EffectRunner keeps an in-memory map of completed effect outputs. After an
effect succeeds for a given input, calling the same effect with the same parsed
input on the same runner returns the memoized output immediately. The handler is
not executed again, and the persistent cache is not read again.
const runner = new EffectRunner()
await runner.call(getMetadata, { id: 'token-1' }) // runs the handler
await runner.call(getMetadata, { id: 'token-1' }) // returns from memory
This memory is scoped to the runner instance. Create a new EffectRunner to get
a fresh in-memory view, or call runner.clear() to drop memoized outputs. This
is separate from persistent iso-kv caching: runner memory lasts only as long as
the runner, while cache: true plus kv can survive across runners.
When several calls are made before the next await, iso-effect collects them
and processes the unique inputs together on the next microtask. This makes
same-input calls share one in-flight promise and lets the rate limiter apply to
the whole group of unique cache misses.
const results = await Promise.all([
runner.call(getMetadata, { id: 'a' }),
runner.call(getMetadata, { id: 'a' }), // shares the first call
runner.call(getMetadata, { id: 'b' }),
])
With rateLimit: { calls: 1, per: 'second' }, the first unique miss can run in
the current window and the next unique miss waits for the next window. Calls made
after an await are collected in a later microtask batch.
Set rateLimit to false or omit it to disable limiting. Use "second",
"minute", or a number of milliseconds as the window duration.
const limited = createEffect(
{
name: 'limited',
input: z.string(),
output: z.string(),
rateLimit: { calls: 10, per: 'minute' },
},
({ input }) => input
)
Caching is opt-in per effect with cache: true and requires an iso-kv
instance on the runner. Cached outputs are validated against the output schema
before reuse. Invalid cached entries are deleted and the effect is executed
again.
const runner = new EffectRunner({ kv: new KV() })
await runner.call(getMetadata, { id: 'token-1' })
Inside a handler, set context.cache = false to skip persistence for one
invocation:
const webhook = createEffect(
{
name: 'webhook',
input: z.string(),
output: z.boolean(),
cache: true,
},
async ({ input, context }) => {
const ok = await sendWebhook(input)
context.cache = ok
return ok
}
)
iso-effect is inspired by Envio HyperIndex's Effect API but is a standalone
library:
iso-kv instead of HyperIndex's Postgres effect cache tables.MIT © Hugo Dias