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

@isnogudus/rail.js

v0.3.0

Published

Small workflow library: explicit, validated graphs of named steps. No dependencies, no persistence. Runs in modern Node and browsers.

Readme

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/[email protected]/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