Workflow library · Zero deps · v0.3.0

Workflows as explicit graphs.

A small JavaScript library for business logic expressed as named nodes, named outputs, and visible wires. Build-time validated, async end to end, Mermaid-friendly — and not a single runtime dependency.

0runtime deps
3group builders
JSDocno TS build
ESMnode + browser
railway('sendMessage')
01 / Problem

Nested try/catch hides the path through your logic.

“Send a message” sounds simple. In practice it’s validation, encryption, network errors, retries, cleanup. In imperative JavaScript that becomes a nest of try/catch blocks where, three refactors in, nobody can tell which path leads where. rail.js turns that nest into a named graph — and the most readable form is railway(...).

Before — imperative
async function sendMessage(ctx) {
  if (!ctx.roomId) {
    return { ok: false, error: 'INVALID' };
  }
  let encrypted;
  try {
    encrypted = await encrypt(ctx);
  } catch (e) {
    if (e.name === 'NoKeysError')
      return { ok: false, error: 'NO_KEYS' };
    throw e;
  }
  try {
    await fetch(ctx.url, { body: encrypted });
    return { ok: true };
  } catch (e) {
    if (e.name === 'NetworkError')
      return { ok: false, error: 'NET_5xx' };
    throw e;
  }
}
After — railway pipeline
const sendMessage = railway((r) => {
  r.step('validate', async (ctx) => {
    if (!ctx.roomId) throw new Error('INVALID');
  });

  r.step('encrypt', async (ctx) => {
    ctx.payload = await encrypt(ctx);
  });

  r.step('send', async (ctx, _local, runInfo) => {
    await fetch(ctx.url,
      { body: ctx.payload, signal: runInfo.signal });
  });

  r.fail('report', async (ctx) => {
    log.error(ctx._error);
  });
});

Every r.step catches its own throw and routes it to the failure rail.

The library’s single exception-handling mechanism is catchTo — a user-function-level wrapper. railway applies it automatically. r.step ends at success on normal return and at failure on caught throw, with the thrown error placed on ctx._error for the downstream r.fail to inspect.

railway(builder)
→ activity('success' / 'failure')
02 / Concepts

Three group builders. One node interface.

Start with railway for the two-track success/failure pattern. Move to nrail when you have more than two outcome tracks (retry, cleanup, audit). Reach for activity when the topology is irregular and you want full control over every wire.

railway(builderFn)

Railway

Trailblazer-style two-track pipeline. Three builder methods (r.step / r.pass / r.fail) all wrap the user function with catchTo — throw routes to the matching exit with ctx._error set.

railway((r) => {
  r.step('validate', fn);
  r.step('send',     fn);
  r.fail('cleanup',  fn);
})
nrail(builderFn)

n-Rail

n parallel outcome tracks. Each step declares the rails it consumes and produces; the builder keeps a build-time Live-Set of open wires. Labels and links provide forward jumps and loops.

nrail((r) => {
  r.entry('main');
  r.step('try', fn,    'main', ['main', 'retry']);
  r.step('cleanup', fn, 'retry', 'main');
})
activity(builderFn)

Activity

Arbitrary graph topology. You declare entries, exits, sub-nodes and every wire as a string reference — the most general form, and the one railway / nrail compile down to.

activity((a) => {
  a.entry('in'); a.exit('done');
  a.addNode('s', step(fn));
  a.wire('.in', 's.success');
  /* ... */
})

Building blocks

atomic builders

atom · nstep · step · pass · fail

Five factories for the leaf nodes — from the primitive atom(fn, { outputs }) through nstep (string-or-array convenience) to the railway-rail factories step, pass, fail with built-in throw routing. All produce __rail_kind__: 'atom'.

parallel(branches, merge?)

Concurrent fan-out

Runs branches concurrently via Promise.allSettled. Each branch gets a shallow-copied ctx and its own combined abort signal. After all resolve, ctx becomes { branchName: branchCtx } — an optional merge node can collapse that into a domain shape.

pin(node, entry)

Wrapper

Fixes one of a multi-entry node's inputs so it can be used wherever a single-input node is required (Flow top-level, Parallel branches). Trace-transparent — no TraceEntry, no local slot.

A flow holds one node and runs it.

flow(name, node) is the only stateful boundary — and it's actually stateless. Each run(ctx?, opts?) allocates a fresh runState in its own closure, so the same flow object can be invoked concurrently any number of times, with its own logger, tracer, and signals per call.

flow.run(ctx?, opts?)
→ { exit, ctx, trace }
03 / Features

Small surface, sharp edges where they matter.

Build-time validation

Structural bugs surface at the builder call site, not in the third production incident. Every builder fully validates its result before returning.

Synchronous tracer

Two events per node — 'begin' and 'end', a clean pair per successful step. The TraceEntry carries path, kind, cycle, and snapshots of ctx and local.

Parallel without magic

parallel({ a, b }) runs branches concurrently with Promise.allSettled and an internal abort linkage on the combined cancellation signal.

Mermaid render

flow.toMermaid() produces a flowchart string ready to embed in markdown, READMEs, or live renderers like this page.

Cancellation

Cooperative signal exposed to steps as runInfo.signal, plus a hard killSignal the runner checks between nodes. Standard AbortSignal on both.

Plain ESM, cross-engine

No bundler, no TypeScript pipeline, no runtime dependencies. Runs unchanged in Node 22+, modern browsers, and QuickJS. Cancellation is opt-in: when the host lacks AbortController, signals are skipped and everything else just works.

Rail-graphs are monadic pipelines, just more honest.

A linear railway with success / failure is structurally StateT over Either — the railway-oriented programming of Trailblazer or Result-chains in Rust/Swift/F#. rail.js generalises in two directions: outputs can be n-ary (nrail), and the graph is statically inspectable rather than hidden in .bind().

Result<T,E>
StateT <Either>
Trailblazer Activity
→ rail.js
04 / Validation

Bugs caught at build time, not in production.

Every built-in builder validates its result before returning. There is no separate check() call to remember — a node value handed back from any builder is ready to use.

A Eager per-operation INVALID_NAME · NOT_A_NODE · MULTIPLE_OUTGOING_WIRES
B Whole-graph walk MISSING_NODES · UNUSED_PORT · UNREACHABLE_NODE
C Sealing & async guards SEALED · ASYNC_BUILDER

Eager. Each builder method (a.wire, r.step, …) raises RailBuildError at the call site, with a stack trace pointing to the offending line.

Whole-graph. At the end of activity / nrail / railway a single identity-memoised walk catches missing wires, unreachable sub-nodes, and any sub-node output that has no outgoing wire.

Convergence is intentional. Multiple wires ending at the same target endpoint are allowed — “no matter which path led here, continue from this node.”

Sealed after build. The builder object is inert once the closure returns; any captured reference raises RailBuildError(SEALED) on use.

05 / Live · Railway

Success on one rail, failure on the other.

The sendMessage pipeline from above is a real railway(...) running in your browser. Throws from any r.step route to failure with the original error on ctx._error; an r.fail on the failure rail then inspects it.

flow('sendMessage').run(ctx)
Input
Trace
Result
06 / Live · n-Rail

Multi-track outcomes — and a loop via label/link.

An order pipeline with two n-Rail-specific features. First, per-rail convergence: every upstream fail output wires automatically into cleanup.fail — no explicit wires needed. Second, a retry loop via label + link: r.label('charge', 'main') anchors a point in the main rail, and r.link('charge', 'retry') sends the retry rail back to that anchor. The loop spans three nodes (chargeAttemptshouldRetrychargechargeAttempt), bounded by local.retries.

flow('orderPipeline').run(ctx)
Input
Trace
Result
07 / Live · Activity

A backward edge across multiple nodes.

An approval workflow: submit → review, and review branches to publish, revise, or the .rejected exit. On revise.ok, control flows back into review.in — a cycle that spans three nodes. review.in converges from two sources (the initial submit and subsequent revise iterations); review.local.reviews counts the revisions across the single run. n-Rail can express this with a label on review and a link from the changes rail; activity writes the backward edge directly.

flow('approval').run(ctx)
Input
Trace
Result
08 / Parallel · merge

Concurrent branches, one decision point.

parallel({ profile, orders }, merge) runs both branches concurrently. Each branch receives a shallow-copied ctx and produces its own. The optional merge node receives the aggregated { branchName: branchCtx } ctx and chooses the parallel's exit; without a merge, the parallel exits at 'out'.

flow('enrich').run(ctx)
Input
Trace
Result
09 / Install

Drop it into a page, or install it from npm.

Every tagged release attaches a pre-built ESM bundle as a release asset — single file, single URL, no build step on your side. For Node and bundler-based setups, install from npm. For CDN-style import-map setups, pull a tag through jsDelivr.

browser · zero build

Release asset

~23 KB minified, ~7 KB gzipped, ~6 KB brotli. Pinned per tag. .gz + .br variants attached for self-hosting.

<script type="module">
  import { railway, flow } from
    'https://github.com/isnogudus/rail.js'
  + '/releases/download/v0.3.0/rail.min.js';
</script>
browser · CDN

jsDelivr from tag

Reads the published source tree at a Git tag. Multiple modules, one per file.

<script type="importmap">
  { "imports": {
      "rail.js": "https://cdn.jsdelivr.net/gh/"
      + "isnogudus/rail.js@v0.3.0/rail.js"
    } }
</script>
node · bundler

From npm

For Node, Vite, esbuild, webpack — the canonical install.

$ npm install @isnogudus/rail.js

import { railway, flow }
  from '@isnogudus/rail.js';