durable-fn
v1.0.2
Published
Durable functions in under 300 bytes — pure JavaScript, zero dependencies, no infrastructure required.
Readme
durable-fn
Pause a function mid-run, resume it on another request or machine on-demand. Good for pipeline engines, IVRs, and checkout flows — all in under 300 bytes.
Write the flow as an ordinary function and mark where it waits with suspend; it resumes by
replaying the results so far. The whole state is a string[] you keep wherever you like — a row, a
cookie, a Redis key. No runtime, no storage engine, no dependencies.
Getting started
durable(fn) returns a function you call with the log so far. It replays your generator over it,
fast-forwards the steps already resolved, and stops at the next suspend — handing you the value
it's waiting on, and whether it's done.
import durable from 'durable-fn';
const call = durable(function* (suspend) {
const dept = yield* suspend(() => 'Press 1 for sales, 2 for support.');
if (dept === '1') {
const size = yield* suspend(() => 'Press 1 if you have fewer than 50 seats, 2 otherwise.');
return size === '2' ? 'Connecting you to enterprise sales…' : 'Connecting you to sales…';
}
const acct = yield* suspend(() => 'Enter your account number.');
return `Pulling up account ${acct}…`;
});
// Call it with the log so far; it resumes by replaying it.
call([]).value; // 'Press 1 for sales, 2 for support.'
call(['1']).value; // 'Press 1 if you have fewer than 50 seats, 2 otherwise.'
call(['1', '2']).value; // 'Connecting you to enterprise sales…'
call(['2']).value; // 'Enter your account number.'
call(['2', '8842']).value; // 'Pulling up account 8842…'dept is an ordinary local — the next step depends on it, and it's still in scope when the flow
finishes, even though each call is a separate run. No state machine, no transition table, just
branches you read top to bottom. suspend marks where it pauses; the reply becomes that step's
result on the next call, and done tells you whether value is what it needs next or the final return.
It's fully typed — no annotations, no casts. Whatever your steps yield and return is inferred, and
the result is a discriminated union on done: if (step.done) narrows value to the return type,
otherwise to what a step yields. Those yielded values can be discriminated unions of their own that
you switch on.
In a handler
The log is just a string[]. Persist it per session and hand it back next time. That's the only
state; nothing lives in memory between calls.
app.post('/call/:id', (req, res) => {
const events = store.load(req.params.id); // the answers so far
if (req.body.digit != null) events.push(String(req.body.digit));
const step = call(events);
store.save(req.params.id, events);
res.json(step); // { value, done }
});Because the entire state is the replayed log, the same session resumes on any request, process, or machine — even after a crash or deploy.
Build an engine on it
durable-fn is the replay kernel, not the engine. It's the same trick Temporal
and Azure Durable Functions
run on — pause a function, replay a log to resume — with the engine left to you. So a pipeline or
workflow engine is a thin layer on top: a step is a suspend your runner performs and logs; a sub-flow is just yield*; a job that
runs for days on another machine suspends until its completion callback lands (a later request, a
different process) and replay picks up from there. Concurrency, timers, retries, and distribution are
yours to add — durable-fn only handles the pause and resume.
test/pipeline.test.ts is a
worked example: a render pipeline that preps locally, dispatches multi-day jobs to a farm, suspends on
each, and resumes across separate invocations.
Good to know
Keep replays deterministic. Reading
Date.now(),Math.random(), or a mutating object inside the body would come out different on each replay and fall out of sync. Have the driver supply such values as answers instead — seetest/determinism.test.tsfor the pattern.Sync or async.
durableaccepts afunction*or anasync function*. A sync body returns{ value, done }; an async one returnsPromise<{ value, done }>— so you canawaita lookup between prompts.The log is the position. The flow's place is nothing but the replay of
events, so the array you persist is the entire cursor — editing it before a call decides where the flow resumes.Version before reordering steps. The log is positional — answer #1, answer #2, … with no names attached — so inserting or reordering a
suspendreinterprets an old log. Fine within a single run; for long-lived flows that outlive a deploy, version the body (or drain in-flight ones) before you change the order of steps.
