A visual workflow builder with a simulated execution engine — 100% client-side, zero backend.
https://github.com/user-attachments/assets/demo-placeholder
⬇ Replace this with your 10-second GIF — see Recording the demo
FlowForge lets you compose automation workflows on an infinite canvas: drag nodes (webhook trigger, AI prompt transformer, conditional branch, HTTP request), wire them into a directed graph, configure each step live, then hit Run and watch execution flow through the graph node-by-node with a payload inspector.
There is no server. Every hard problem here — cycle prevention, undo/redo, execution scheduling, schema validation — is solved in the browser, in TypeScript, with tests.
- Drag-and-drop canvas (@xyflow/react) with 4 custom node types and per-type config panels that update without re-rendering sibling nodes
- DFS cycle prevention — circular connections are blocked at drag time, with the offending path surfaced in the UI (
b → a → b) - Time-travel debugging — hand-written command-pattern history engine; every mutation (add, move, connect, edit, delete) is one atomic undo step; consecutive config edits coalesce into a single step
- Execution simulator — event-driven runner animates idle → running → success/failed/skipped states, routes branches by evaluated conditions, supports fail-fast and mid-run cancellation
- Payload inspector — pretty-printed input/error/output for any executed node
- Import/Export + autosave — versioned Zod schemas gate every byte entering the canvas; LocalStorage persistence survives refreshes
Two Zustand stores with deliberately separated lifecycles:
flowchart LR
subgraph UI["React components"]
Canvas["Canvas · Nodes"]
Panels["Config panel<br/>Inspector"]
Toolbar["Toolbar"]
end
subgraph FS["useFlowStore · document domain"]
Graph[("nodes · edges<br/>selection")]
History["Command history<br/>past ⟷ future"]
end
subgraph ES["useExecutionStore · runtime domain"]
Status["phase · nodeStatuses"]
Payloads[("inputs · outputs<br/>errors")]
end
Runner["WorkflowRunner<br/>(event callbacks)"]
Toolbar -- "commit(command)" --> History
History -- "do()/undo()" --> Graph
Canvas -- "onNodesChange/onConnect" --> Graph
Graph -- "snapshot" --> Runner
Runner -- "events" --> Status
Status --> Payloads
Graph -- "memoized selectors" --> Canvas
Payloads -- "selectors" --> Panels
Key decisions:
| Decision | Why |
|---|---|
| React Flow is a dumb renderer | The store owns nodes/edges; RF changes are applied through it. One source of truth. |
| Commands over snapshots | Each history entry closes over only what changed ({ do, undo }), so memory stays flat and deletes are trivially reversible |
| Separate execution store | Run state mutates constantly during a run; keeping it out of the document store keeps undo semantics clean |
| Memoized custom nodes | Editing one node's config replaces exactly one node object → React.memo blocks every other node from re-rendering |
Before any edge source → target is committed, we ask one question:
can target already reach source?
export function createsCycle(adjacency: AdjacencyList, sourceId: string, targetId: string): CycleReport {
if (sourceId === targetId) return { hasCycle: true, cyclePath: [sourceId, targetId] }
const pathBack = findsPath(adjacency, targetId, sourceId)
if (!pathBack) return NO_CYCLE
return { hasCycle: true, cyclePath: [sourceId, ...pathBack] }
}findsPath is depth-first search with backtracking and a visited set —
O(V + E) time, O(V) space, and it returns the actual path so the UI can show
Cycle blocked · b → a → b instead of a generic error.
The same module also exports findCycle, a three-color DFS (white/grey/black)
used to validate whole imported documents — because a file can contain cycles
even though the editor can never create them:
| Function | Runs when | Complexity |
|---|---|---|
createsCycle |
every connection attempt (incremental guard) | O(V+E) worst case |
findCycle |
import / schema parsing (full-graph audit) | O(V+E) |
Full source: src/lib/validation/dag.ts ·
tests: dag.test.ts
| Layer | Choice |
|---|---|
| Framework | Vite · React 19 · TypeScript (strict) |
| Canvas | @xyflow/react v12 |
| State | Zustand (+ hand-written temporal command engine) |
| Validation | Zod v4 (discriminated unions, versioned envelopes) |
| Styling | Tailwind CSS v4 · shadcn/ui · Radix primitives · lucide icons |
| Testing | Vitest · Testing Library · jsdom |
npm install
npm run dev # http://localhost:5173
npm test # 78 unit/integration tests
npm run build # typecheck + production bundleTests are written against the modules that carry logic, not against pixels:
lib/validation/dag.test.ts— self-loops, back-edges across chains, diamonds, disconnected cyclic subgraphsstores/flow-store-history.test.ts— coalescing, atomic delete+restore, redo-stack invalidation, click-without-movelib/engine/runner.test.ts— branch routing both ways, fail-fast ordering, fan-out dedup, cancellation gates, cyclic-input survivallib/persistence/io.test.ts— round-trips, malformed configs with named paths, duplicate ids, ghost edges, imported cycleslib/persistence/storage.test.ts— debounce timing, corrupt payloads, schema drift
src/
├── components/
│ ├── canvas/ # React Flow wrapper, palette, notices
│ ├── nodes/ # BaseNodeShell + 4 typed nodes
│ ├── panels/ # Config panel, payload inspector
│ ├── toolbar/ # Import/export controls
│ └── ui/ # shadcn primitives
├── lib/
│ ├── engine/ # WorkflowRunner + payload math
│ ├── history/ # Command-pattern temporal engine
│ ├── persistence/ # Zod schema, IO, localStorage
│ └── validation/ # DAG utilities (DFS)
├── stores/ # useFlowStore, useExecutionStore
├── types/ # Node registry & contracts
└── hooks/ # Shortcuts, persistence
- Build this storyboard first: (drag trigger → wire branch → run → cycle-block attempt → Ctrl+Z) — 10 seconds, no dead air
- Record at 1200×700 with ScreenToGif (Windows), Kap (macOS) or
ffmpeg - Export as
docs/demo.gif, keep it under ~4 MB (ffmpeg -i in.gif -vf "fps=12,scale=900:-1" out.gif) - Swap the placeholder embed above for

Static SPA — deploys anywhere:
- Vercel: import repo → framework auto-detected → done
- Netlify: build
npm run build, publishdist - GitHub Pages:
vite build --base=/<repo-name>/and publishdist
CI (.github/workflows/ci.yml) type-checks, builds and runs the full suite on every push and PR.