Skip to content

isnogudus/rail.js

Repository files navigation

rail.js

A small workflow library for JavaScript. Express business logic as a graph of named nodes with explicit named outputs, validated at build time and traced at runtime.

  • Plain ES modules + JSDoc — no runtime dependencies, no build step
  • Async end to end
  • Five atomic builders (atom, nstep, step, pass, fail), three group builders (activity, nrail, railway, plus the concurrent parallel), and one wrapper (pin)
  • Eager build-time validation: every builder fully validates its result before returning. Errors raise RailBuildError at the offending builder call.
  • Cycles in the wire graph are valid — retry / poll / iteration patterns model as normal graph topology, bounded by the step budget.
  • Position-local state (local) on every node, hierarchical for group nodes (local.children[subName], local.branches[branchName]).
  • Mermaid render for documentation and debugging.
  • Pluggable logger and synchronous tracer (begin / end events) for live observation.
  • Cooperative cancellation (opts.signal) and a hard kill switch (opts.killSignal).
  • Runs unchanged in Node 22+, modern browsers, and QuickJS — cancellation gracefully degrades when AbortController is absent from the host environment.
  • Custom node kinds are first-class via __rail_type__: 'node' and the exported invokeNode extension API.

The full specification lives in docs/rail-spec.md; the deployed site at https://isnogudus.github.io/rail.js/ includes a rendered spec page and live demos for Railway, n-Rail, and Activity. The CHANGELOG.md documents version-to-version changes. This README is the practical guide.

Install

From npm

npm install @isnogudus/rail.js

From a GitHub release (browser, no bundler)

Each tagged release attaches two pre-built ESM bundles as release assets. Import them directly in a browser:

<script type="module">
  // ~23 KB minified, ~7 KB gzipped
  import { railway, flow } from
    'https://github.com/isnogudus/rail.js/releases/download/v0.3.0/rail.min.js';

  const wf = railway((r) => {
    r.step('hello', async (ctx) => { ctx.greeted = true; });
  });

  const r = await flow('demo', wf).run({});
  console.log(r.exit, r.ctx);
</script>

Use rail.js instead of rail.min.js for the readable build (~44 KB) when debugging. Pin to a specific tag (v0.3.0) to lock the version.

Each release also attaches pre-compressed variants for self-hosting: rail.js.gz, rail.js.br, rail.min.js.gz, rail.min.js.br. Drop them into your CDN folder and serve with Content-Encoding: gzip / br to skip server-side compression. (Don't <script>-import a .gz / .br URL directly — the browser will not decompress.)

From a CDN

<script type="importmap">
  {
    "imports": {
      "rail.js": "https://cdn.jsdelivr.net/gh/isnogudus/rail.js@v0.3.0/rail.js"
    }
  }
</script>
<script type="module">
  import { railway, flow } from 'rail.js';
</script>

ESM only. Engines: Node 22+, modern browsers, QuickJS. Cancellation needs AbortController / AbortSignal — present in Node ≥ 19 and modern browsers; absent in vanilla QuickJS. The library detects this and runs without cancellation in that case: runInfo.signal is undefined, opts.killSignal has no effect, and parallel sibling-aborts are no-ops. Everything else (atomic builders, activity, nrail, railway, parallel-with-merge, cycles, local state, tracer, logger) is engine-independent.

A smoke test exists at test-quickjs.js; run it with npm run test:qjs if you have a qjs binary on PATH.

Quick start

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

const greet = activity((a) => {
  a.entry('in');
  a.addNode('say', step(async (ctx) => {
    ctx.message = `hello, ${ctx.name}`;
  }));
  a.exit('done');
  a.wire('.in',          'say.success');
  a.wire('say.success',  '.done');
  a.wire('say.failure',  '.done');
});

const result = await flow('greet', greet).run({ name: 'world' });
// → { exit: 'done', ctx: { name: 'world', message: 'hello, world' }, trace: [...] }

For more, see:

Concepts at a glance

Concept Has Built via
Node __rail_type__: 'node', __rail_kind__, inputs, outputs, _invoke atom/nstep/step/pass/fail, pin, parallel
Activity ('activity') sub-nodes connected by wires activity(builder), nrail(builderFn), railway(builderFn)
Parallel ('parallel') branches + optional merge node parallel(branches, merge?)
Pin ('pin') a single inner node, transparent in the trace pin(node, entry)
Flow a top-level node + a name flow(name, node)
Term Meaning
Wire A directed connection from a source endpoint (entry or sub-node output) to a target endpoint (exit or sub-node input). Written as a string: '.entry', '.exit', 'subName.port'.
ctx The user-domain context. Flows by reference; user functions mutate it in place. parallel produces a { branchName: branchCtx } aggregate.
local Per-position mutable storage; hierarchical for group nodes. local._cycles is the invocation count at the position.
runInfo Read-only context for atomic user functions: { signal, flowName, traceEntry }.

Public API

import {
  // Atomic builders (§3)
  atom, nstep, step, pass, fail,
  // User-function-level catch wrapper (§11)
  catchTo,
  // Wrapper (§4)
  pin,
  // Group builders (§5–§7)
  activity, nrail, railway,
  // Concurrent group (§8)
  parallel,
  // Flow (§9)
  flow,
  // Utilities (§10)
  isRailNode,
  // Extension API for custom kinds (§2, §15.3)
  invokeNode,
  // Errors (§12)
  RailError, RailBuildError, RailRuntimeError, RailAggregateError,
} from '@isnogudus/rail.js';

Atomic builders

// Primitive: user function returns the exit name as a string.
const myAtom = atom(async (ctx, local, runInfo) => {
  ctx.touched = true;
  return 'ok';
}, { outputs: ['ok', 'retry'] });

// String-or-array convenience + nullish-return for single-output.
const audit = nstep(async (ctx) => { /* no return needed */ }, 'in', 'ok');

// Railway success/failure pattern — throws routed to 'failure'.
const validate = step(async (ctx) => { if (!ctx.id) throw new Error('missing'); });

// Best-effort: throws caught and routed to the only exit.
const log    = pass(async (ctx) => { /* never affects control flow */ });
const report = fail(async (ctx) => { /* best-effort on the failure rail */ });

step, pass, and fail are built on catchTo (§11): they wrap the user function with a catch that sets ctx._error and routes to a fixed exit. RailError and RailAggregateError are always re-thrown — they terminate the run.

Activity (activity(builder))

Wires are addressed by string references:

String Meaning
'.entry' the activity's own entry named entry
'.exit' the activity's own exit named exit
'name.port' sub-node name's port port (input or output, depending on whether the string is used as the target or the source)
const wf = activity((a) => {
  a.entry('in');
  a.exit('done');
  a.addNode('validate', step(validateFn));
  a.addNode('send',     step(sendFn));

  a.wire('.in',              'validate.success');
  a.wire('validate.success', 'send.success');
  a.wire('send.success',     '.done');
  a.wire('validate.failure', '.done');
  a.wire('send.failure',     '.done');
});

Every builder method validates eagerly; the activity walks the assembled graph once at the end of construction and raises RailBuildError on missing wires, unreachable sub-nodes, etc. The closure must be synchronous (ASYNC_BUILDER otherwise) and the builder reference is sealed after the closure returns (SEALED).

n-Rail (nrail(builderFn)) and Railway (railway(builderFn))

nrail is a convenience factory for pipelines with multiple parallel outcome tracks ("rails"). Steps consume and produce named rails; the builder maintains a build-time Live-Set of open wires. railway is a thin wrapper over nrail for two-track success/failure pipelines with automatic catchTo wrapping.

const orderPipeline = nrail((r) => {
  r.entry('main');
  r.step('validate', validateFn, 'main', ['main', 'fail']);
  r.step('charge',   chargeFn,   'main', ['main', 'retry', 'fail']);
  r.step('logRetry', logRetryFn, 'retry', 'fail');
  r.step('cleanup',  cleanupFn,  'fail', 'fail');
});

const sendMessage = railway((r) => {
  r.step('validate', async (ctx) => { if (!ctx.body) throw new Error('body'); });
  r.step('encrypt',  async (ctx, local, runInfo) => { /* ... */ });
  r.fail('logError', async (ctx) => { console.error(ctx._error); });
});

Parallel (parallel(branches, merge?))

Runs branches concurrently. Each branch receives a shallow copy of ctx and its own local slot. After all branches resolve, the parallel mutates the incoming ctx in place to the aggregated { branchName: branchCtx } shape. Without a merge node, the parallel exits at 'out'; with one, the merge node's outputs become the parallel's outputs.

const enrich = parallel({
  profile: fetchProfile,
  orders:  fetchOrders,
}, mergeResults);                       // optional merge node

If any branch rejects, sibling branches see runInfo.signal.aborted via the combined signal and may exit cooperatively. After Promise.allSettled collects all branches, the parallel throws a RailAggregateError (single class regardless of how many branches failed) with branchErrors keyed by branch name. The merge node is not invoked when any branch fails.

Cycles and local

Cycles in the wire graph are first-class:

const retrier = activity((a) => {
  a.entry('in');
  a.addNode('fetch', atom(async (ctx, local) => {
    local.attempts = (local.attempts ?? 0) + 1;
    try { ctx.data = await fetch(ctx.url); return 'ok'; }
    catch (err) { return local.attempts < 3 ? 'retry' : 'giveUp'; }
  }, { outputs: ['ok', 'retry', 'giveUp'] }));
  a.exit('done');
  a.exit('failed');
  a.wire('.in',           'fetch.in');
  a.wire('fetch.ok',      '.done');
  a.wire('fetch.retry',   'fetch.in');   // cycle wire
  a.wire('fetch.giveUp',  '.failed');
});

The position's local persists across cycles within a single flow.run(...). local._cycles (written by invokeNode) counts the invocations at this position. The run-global maxSteps budget (default 1000) bounds total node invocations.

Errors

RailError
├── RailBuildError      INVALID_NAME, NOT_A_NODE, UNUSED_PORT, …
├── RailRuntimeError    UNHANDLED_THROW, UNKNOWN_OUTPUT_AT_RUNTIME,
│                       STEP_BUDGET_EXCEEDED, KILLED, INTERNAL
└── RailAggregateError  PARALLEL_BRANCH_FAILED  (branchErrors, errors[])

Use err instanceof RailError for the single membership test that covers every library-produced error, single or aggregate. Library errors do not carry the run trace or the ctx — register a tracer if you need post-mortem state inspection.

Cancellation

const ctrl = new AbortController();
const result = await flow('myflow', wf).run({}, {
  signal:     ctrl.signal,        // cooperative — exposed as runInfo.signal
  killSignal: ctrl.signal,        // enforcing — invokeNode throws KILLED
});

runInfo.signal is the combined signal (caller's signal ∪ caller's killSignal ∪ library's internal abort for parallel sibling failure). Only the raw killSignal triggers RailRuntimeError(KILLED).

Tracer and logger

await flow('f', node).run(ctx, {
  logger: (entry) => console.log(entry.path.join('.'), '->', entry.exit),
  tracer: (entry, event) => bus.emit(event, entry),  // 'begin' | 'end'
  loggerErrorPolicy: 'throw',     // default
  tracerErrorPolicy: 'swallow',   // default
});

The logger runs once per successfully completed step (after the tracer's 'end'). The tracer runs synchronously at 'begin' and 'end'; pin is trace-transparent and emits nothing. Steps that throw a library error have no 'end' event — their TraceEntry remains unfilled and the throw propagates.

Mermaid

const m1 = flow('myflow', node).toMermaid({ direction: 'LR' });
const m2 = wf.toMermaid('myActivity');           // directly on an activity node

Sub-activities render as nested subgraphs; parallel renders as a 'parallel'-labelled subgraph containing its branches and merge. pin is transparent in the diagram.

Distribution

Pure ES module, single entry point (./rail.js). Internal modules under rail/ are implementation detail.

{
  "type": "module",
  "main": "./rail.js",
  "exports": { ".": "./rail.js" }
}

No build step, no bundler, no TypeScript. Modern bundlers (Vite, esbuild, webpack) consume the source directly.

Scripts

Script What it does
npm test Vitest run — 135 unit + integration tests against Node.
npm run test:watch Vitest in watch mode.
npm run test:qjs Smoke-test against vanilla QuickJS (qjs --std test-quickjs.js). 50 assertions covering markers, trace shape, all builders, parallel + merge, aggregate-error, step budget, and the QuickJS-specific runInfo.signal === undefined contract. Requires qjs on PATH.
npm run example Run example.js — overview demo (railway, parallel + merge, sub-pin, custom merge ctx).
npm run examples Run every file in examples/ sequentially via run-all.js.
npm run build Produce dist/rail.js (~44 KB) and dist/rail.min.js (~23 KB / ~7 KB gzip) via esbuild. Used for release-asset bundles (see the workflow).
npm run site:sync Copy the current rail.js + rail/ + docs/rail-spec.md into site/lib/ and site/rail-spec.md so the deployed site reflects local sources.
npm run site:dev Serve site/ on http://localhost:8080 via python3 -m http.server.

License

MIT

About

JS Workflow Library

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors