Vayu App Architecture¶
The Vayu Manager is an Electron-based desktop application built with React and TypeScript. It provides a user interface for designing API requests, executing them, and running load tests. The app communicates with the Vayu Engine (a C++ daemon) via HTTP on localhost:9876.
High-Level Architecture¶
┌─────────────────────────────────────────────────────────┐
│ Electron Main Process │
│ ┌──────────────────────────────────────────────────┐ │
│ │ main.ts │ │
│ │ - Creates BrowserWindow │ │
│ │ - Manages EngineSidecar lifecycle │ │
│ │ - Handles app lifecycle events │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ sidecar.ts │ │
│ │ - Spawns/manages C++ engine process │ │
│ │ - Monitors engine health │ │
│ │ - Handles binary path resolution │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
│ IPC (preload.js)
▼
┌─────────────────────────────────────────────────────────┐
│ Electron Renderer Process (React) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ App.tsx │ │
│ │ - Root component │ │
│ │ - Initializes health checks │ │
│ │ - Prefetches data │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Shell.tsx │ │
│ │ - Main layout (sidebar + content) │ │
│ │ - Routes to screens based on state │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Components │ │
│ │ - RequestBuilder, LoadTestDashboard, etc. │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Services Layer │ │
│ │ - api.ts: HTTP client for engine API │ │
│ │ - sse-client.ts: Server-Sent Events │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ State Management │ │
│ │ - Zustand stores (UI state) │ │
│ │ - TanStack Query (server state) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
│ HTTP/SSE (localhost:9876)
▼
┌─────────────────────────────────────────────────────────┐
│ Vayu Engine (C++ Daemon) │
│ - HTTP Server (cpp-httplib) │
│ - Request Execution (libcurl) │
│ - Load Testing │
│ - SQLite Database │
└─────────────────────────────────────────────────────────┘
Core Components¶
Electron Main Process (electron/main.ts)¶
The main process is responsible for:
- Window Management: Creates and manages the Electron
BrowserWindow. ItswebPreferenceshold the renderer's security posture - no Node integration, context isolation on, and Chromium's spellchecker off, so no field in the request builder draws underlines and no dictionary is fetched over the network - Engine Lifecycle: Starts and stops the C++ engine via
EngineSidecar - App Lifecycle: Handles app ready, window close, and quit events. A close or quit that would stop a running inbox, mock server or mock issuer is intercepted first and names what it stops, so a window-scoped service is never taken silently (
service-stop-guard.ts, issue #1363) - Context Menu: Composes the right-click menu from Chromium's
context-menuparams and the target the renderer announces (context-menu.ts, issue #1359) - Notifications: Shows or refuses an OS notification for what finished while the user was elsewhere, and reports the platform's willingness to the settings row (
notify.ts, issue #1358) - Process Footprint: Sizes libuv's threadpool to one worker before any other module loads (
threadpool-size.ts). On Windows each worker commits its 8 MB stack, and nothing here hands the pool more than one file at a time; the MCP port is bound at launch with the SDK behind it loaded by the first request (mcp/listener.ts)
Key Responsibilities: - Spawns the engine binary as a child process - Monitors engine health and restarts if needed - Ensures graceful shutdown (stops engine before quitting)
Engine Sidecar (electron/sidecar.ts)¶
The EngineSidecar class manages the C++ engine process:
- Binary Resolution: Locates the engine binary (dev vs production paths)
- Process Management: Spawns, monitors, and terminates the engine process
- Health Checking: Polls
/healthon a ramped interval - 50ms doubling to a 500ms ceiling - so a healthy engine is caught in tens of milliseconds instead of paying a flat poll quantum, while the 45-second budget it spends against a live child is unchanged. It still gives up the moment the spawned child exits rather than spending the rest of that budget against a dead port, and that failure carries the exit code/signal and the engine's last stderr lines. A child still alive when the budget runs out raisesEngineNotReadyErrorinstead, which the launch path treats as "not yet" rather than as fatal - Build guidance: A missing binary names
python build.py -eanddocs/building.md- the single build entry point, not per-platform scripts - Port Management: Checks if port 9876 is available or if engine is already running
- Ownership: Tracks whether the engine was spawned or adopted (already
running at startup). An adopted engine is owned just as fully -
isRunning()reports it,restart()really replaces it, and quit shuts it down by PID. See Ownership model - System seam: The process/port/clock calls sit behind a
SidecarSysteminterface (defaultSidecarSystemin production), sosidecar.test.tscan drive adoption, shutdown and the restart-versus-quit race without real engines or 45-second health waits
Development vs Production:
- Development: Binary at ../engine/build/vayu-engine (or Debug/vayu-engine.exe on Windows)
- Production: Binary at resources/bin/vayu-engine (packaged with Electron app)
React Application (src/)¶
The React app follows a component-based architecture:
Application Structure¶
src/
├── components/ # Shared UI components
│ ├── layout/ # Shell, TitleBar, TabStrip, ActivityRail, Drawer, Dock, ContextBar, ContextRail
│ ├── shared/ # Cross-feature shared components
│ └── ui/ # UI primitives (Radix UI)
├── lib/ # Shared libraries
│ ├── graphql/ # GraphQL support: diagnostics, introspection, schema cache, Monaco providers, variables JSON Schema, explorer tree + insertion
│ ├── monaco-setup.ts # Monaco entry composition + local-bundle config + GraphQL provider registration (loaded on the first editor mount)
│ ├── monaco-loader.ts # The lazy boundary in front of it: ensureMonaco() / useLoadedMonaco()
│ ├── monaco-api.ts # The Monaco surface that composition yields, and what it leaves out
│ └── utils.ts # General utilities (cn, etc.)
├── modules/ # Feature modules
│ ├── request-builder/ # API request editor and execution
│ ├── dashboard/ # Load test metrics and visualization
│ ├── history/ # Run history and reports
│ ├── collections/ # Collections and requests tree
│ ├── variables/ # Environment and variable editors
│ ├── settings/ # App settings
│ └── welcome/ # Onboarding screen
├── stores/ # Cross-cutting Zustand stores (UI state)
│ ├── tabs-store.ts # Active tab state (determines main content)
│ ├── layout-store.ts # Drawer/sidebar visibility
│ ├── session-store.ts # Session and user info
│ ├── engine-store.ts # Engine health and connectivity
│ ├── dashboard-store.ts # Live metrics and test state
│ ├── response-store.ts # Response viewer state
│ ├── save-store.ts # Auto-save orchestration
│ └── import-modal-store.ts # Import dialog state
├── queries/ # TanStack Query hooks (server state)
├── hooks/ # Custom React hooks
├── services/ # API client, SSE client, HTTP client
├── types/ # TypeScript type definitions
└── config/ # Configuration (API endpoints, metrics thresholds)
State Management¶
The app uses a dual-state management approach:
- Zustand Stores (
stores/andlib/): UI state, navigation, temporary data tabs-store.ts: Active tab state; determines which feature module renders in the main content arealayout-store.ts: Drawer and sidebar visibility/statesession-store.ts: Active environment id (mirrored from the engine) and the last-used collectionengine-store.ts: Engine health, connectivity statusdashboard-store.ts: Load test metrics (retained by time window, seestate-management.md), streaming stateresponse-store.ts: The last response per request id - status, headers, body, script results. LRU-bounded at twiceMAX_OPEN_TABS, since each entry holds a body plus its raw copyclient-settings-store.ts: Renderer preferences (editor, charts, auto-save, notifications)toast-store.ts: The transient notification queuesave-store.ts: Auto-save orchestration and progressimport-modal-store.ts: Import dialog visibility and statelib/graphql/schema-cache.ts: Introspected GraphQL schemas, keyed by resolved endpoint URL + collection + environment + a digest of the resolved credentials (so an upstream auth or variable edit is a different entry, not a stale hit). LRU-bounded, and a failed refresh keeps the last good schemalib/graphql/explorer-store.ts: The schema explorer's view - whether the pane is open, and per schema identity the search text, expanded rows, scroll position and whether descriptions are shown in full. Read-only over the schema cache: the explorer renders whatever that store holds and triggers no introspection of its own beyond its Refresh button-
Module-local stores (e.g.,
modules/collections/collections-store.ts) co-locate with their feature -
TanStack Query (
queries/): Server state, caching, synchronization - Collections, Requests, Environments, Globals
- Runs, Health checks
- Automatic caching, refetching, and optimistic updates
The context bar's section registry¶
The right-hand context bar renders a list rather than a component tree it owns:
components/layout/context-bar/registry.ts holds one ordered array of
{ id, title, appliesTo(tab), useRelevance?(tab), Component }, and both the bar
(what to draw) and the Dock's toggle (whether the button has anything to light up
for) read appliesTo through the same sectionsForTab / contextBarHasContent
pair. Keeping those two answers in one place is the point: they were a hardcoded
tab type in one file and a return null in another, and they drifted.
appliesTo stays a pure, synchronous function of the tab alone: the Dock's
toggle calls it on every render, including while the bar is closed, so it can
never read a query.
useRelevance is a second, orthogonal function a section can opt into, asked
only by the bar and only while it is open: whether the section has anything to
say about this request, once its own data is in, rather than just this tab
type. See docs/app/COMPONENTS.md for the three verdicts it can return and why
the question was split off appliesTo rather than widening it (#1310).
A section is a leaf component over the ordinary query layer - no bar-wide shared
state - and is mounted only while its section is expanded, so a collapsed
section registers no queries; its useRelevance hook is the one thing that still
runs collapsed, and only for a query its section already makes. That is what
makes it safe for the bar to stay open on every tab the registry has entries
for - request, collection and run. See docs/app/COMPONENTS.md for the
sections themselves.
Services Layer¶
api.ts: HTTP client wrapper for all engine API endpoints- Transforms between frontend (snake_case) and backend (camelCase) formats
-
Handles error transformation and user-friendly messages
-
codegen/: Snippet generation (curl, JS fetch, Python requests, HTTPie, PowerShell) - the outbound half of the symmetryservices/curl/opens by parsing curl in. Pure functions over aSnippetRequest, fedPOST /compose's output, so a generated snippet is what Vayu would actually send rather than the template it was written as -
sse-client.ts: Server-Sent Events client for a run's live stream - Connects to
/runs/:runId/live(replayable tick topic - no attach race) - No custom reconnect loop: the engine sends an explicit
completeevent, soCLOSEDis terminal and transient errors are left to the browser'sEventSourceretry - Two event types, one client and one stream. A load run publishes
metricsticks; a scenario (collection) run publishesstepevents, on the same ring with the same monotonic ids, and both end withcomplete. Thesteplistener is registered only when a caller passesonStep- a load run never emits one, so an unconditional listener would be dead wiring.parseStepEventnarrows the payload and drops a malformed one rather than defaulting it: the step list keys on(iteration, stepIndex), so a defaulted0:0would collide with the real first step's row rather than merely say nothing. - One client, so a new run takes it from the old one - and the old one is told.
connectdisplaces whoever held the socket and invokes that subscriber'sonSupersededbefore opening the new stream (issue #1417). It is not a variant ofonClose: a close means the run ended and the subscriber converges on the stored report, where a takeover means only that nobody is watching a run the engine is still executing. A subscriber that hangs up on itself (disconnect), or whose stream ended on the engine'scompleteframe, is not superseded by the next run to start. Nor is a service superseded by itself: each one ends the run it is replacing and disconnects before connecting the next -LoadTestServicethroughstopMonitoring,ScenarioRunServiceinline at the top ofstartMonitoring- so the hand-off only ever reaches a different subscriber. Skip that and the service is handed its own newly started run, and gives up the wake lock, the indicator claim and the run id it took two lines earlier -
Metrics go to
dashboard-store(vialoadTestService); steps go toscenario-run-store(viascenarioRunService) -
http-client.ts: Low-level fetch wrapper - Request/response transformation
- Error handling and timeout management
- Base URL configuration
Custom Hooks¶
useEngine(): Compose (POST /compose), execute, and stopuseVariableResolver(): Resolve{{variables}}in strings/objects for previews (the engine resolves what is sent)useSaveManager(): Auto-save orchestration with debouncinguseEntityDraft(): The manual counterpart - draft,isDirty, reset, for editors that save on a button
Data Flow¶
Request Execution Flow¶
- User clicks "Send" in RequestBuilder
useEngine().composeRequest()sends the editor state toPOST /compose; the engine resolves{{variables}}andinheritauth and returns an execute-ready payload- Request is transformed to backend format (camelCase)
apiService.executeRequest()sends the composed payload toPOST /execute- Response is transformed back to frontend format (snake_case)
- Response is displayed in ResponseViewer
Load Test Flow¶
- User configures load test and clicks "Start Load Test"
- The composed request plus the load config go to
POST /runsviaapiService.startLoadTest() - Engine returns
runId useDashboardStore().startRun()initializes dashboard stateloadTestService.startMonitoring(runId)connects to the/runs/:runId/liveSSE endpoint- Metrics stream in real-time and update dashboard
- When test completes, final report is fetched via
GET /runs/:id/report
Collection Run Flow¶
- User picks Run collection in a collection row's ⋯ menu and confirms
RunCollectionDialog(Recursive, Iterations) useStartScenarioRunMutation()sends ascenarioblock to the samePOST /runs; the engine resolves the whole plan first, so an empty collection or a step that will not compose comes back as a400with no run created- Engine returns
runId(202), and the dialog attachesscenarioRunService.startMonitoring(runId)and opens the run's tab stepevents stream intoscenario-run-storeandScenarioRunViewrenders them live- On
completethe service invalidates the run and fetchesGET /runs/:id/report; the view switches to the stored per-step rows, which are the ones carrying an exchange to expand
Variable Resolution Flow¶
Variables are resolved with priority: Bound data row (while one is picked) > Environment > Collection > Global
The engine owns resolution for anything that is sent (POST /compose). The
renderer's useVariableResolver() is a preview of the same rules - tab titles,
the variable popover, unresolved-token painting, and, where a caller passes a
picked row (boundRow), the bind itself - pinned to the engine's by a
cross-language conformance fixture. See variable-resolution.md.
useVariableResolver()fetches globals, collections, and environments- Builds a flat map with resolution priority
resolveString()replaces{{variableName}}with resolved values- Used for previews in RequestBuilder, never as the payload
Build System¶
Development¶
- Vite: Dev server on port 5173 with HMR
- TypeScript: Type checking and compilation
- Electron: Runs renderer process, connects to Vite dev server
Production¶
- Vite Build: Bundles React app to
dist/ - Electron Builder: Packages app with engine binary
- Platform Targets: macOS (DMG), Windows (NSIS), Linux (AppImage/Deb)
Key Technologies¶
- React 19: UI framework
- TypeScript: Type safety - 7 compiles and checks, 5.9 stays installed for the
typescript-eslintparser (see building) - Electron 44: Desktop app framework
- Zustand: Lightweight state management
- TanStack Query: Server state and caching
- Radix UI: Accessible component primitives
- Tailwind CSS: Utility-first styling
- Monaco Editor: Code editing - scripts, JSON body, and GraphQL (with syntax diagnostics, autocomplete, hover, and formatting via
graphql-language-service);{{variable}}tokens are coloured and explained on hover in the body and GraphQL editors too - uPlot: Charts for metrics visualization (all dashboard/history charts centralize on one Canvas primitive; see
modules/dashboard/components/charts/uplot/) - Vite: Build tool and dev server
Electron Preload Bridge¶
The preload script (electron/preload.ts) exposes a minimal, context-isolated API bridge via window.electronAPI:
- Engine Management:
restartEngine()for engine lifecycle control. Engine liveness is not on this bridge - the renderer pollsGET /healthdirectly (src/queries/health.ts), so there is one answer rather than two - Theme Management:
getTheme(),setTheme(),onThemeChanged()for OS theme synchronization - Window Controls:
windowMinimize(),windowMaximize(),windowClose(),windowIsMaximized(),onWindowMaximized()for custom titlebar - Auto-update: Listeners for
onUpdateAvailable(),onUpdateDownloaded(), plusrestartToInstallUpdate(),openReleasePage() - Menu Integration:
onOpenSettings()to receive open-settings commands from the app menu;windowAppMenu(point)asks the main process to pop the installed application menu at the title-bar icon, which is the only route to it on Windows and Linux - a frameless window draws no menu bar there, so the templatecreateMenuinstalls was accelerators and nothing else (issue #1361). The decision (which platform, which point) iselectron/app-menu.ts; macOS is refused, its menu bar being the same template already drawn - Navigation History:
onNavigateHistory()delivers amenu:navigatestep, sent by the View menu's Back/Forward items, the mouse's back/forward buttons where the OS reports them asapp-command, and the macOS three-finger swipe (issue #1245) - Context Menu:
setContextTarget()announces what the pointer landed on over thecontext-menu:targetchannel, sent synchronously (the app's onlysendSync) so it pairs with the native menu event that follows;onContextMenuCommand()delivers thecontext-menu:commandevents for the two offers only the renderer can run - importing a pasted curl/wget command and opening a variable's popover (issue #1359) - Platform & Paths:
platformconstant andgetAppPaths()for OS and directory detection - Graceful Shutdown:
onBeforeQuit()to allow the renderer to flush state (saves, pending requests) before app termination - Running Services:
setRunningServices(services), one-way over theservices:runningchannel, publishes what the engine is currently holding for this window - inboxes by port, mock servers by the collection they serve, mock issuers by port. The main process cannot read the queries this is derived from, and a question asked at close time would land on the gesture the user is already waiting on, so the snapshot is pushed on every change instead.electron/service-stop-guard.tsowns the dialog's wording and the platform rule; the renderer side ismodules/services/useRunningServices.ts, whose list the Dock's indicator is now counted from so the two cannot disagree (issue #1363) - Wake Lock:
holdWakeLock(reason)/releaseWakeLock(token)keep the machine from suspending under a streaming run (issue #1357). Ref-counted inelectron/power-save.ts; the renderer side isservices/wake-lock.ts, one keyed holder both run services call. A run hands its key back on every path that stops it being watched, the takeover by the other service's run included -sseClienttells the displaced service, which releases there (issue #1417) - so no key outlives the session that took it.onHostSuspended()/onHostResumed()report a sleep the lock could not prevent, whichuseHostSleepRecorderrecords against the run - Notifications:
showNotification(),notificationAvailability(),sendTestNotification(),onNotificationActivated()over thenotify:*channels, for the OS notifications a run finishing, an engine dropping out or an update landing raise while the user is elsewhere. The renderer side isservices/notify.ts, which reads the opt-in and decides what is worth saying; a click is opened by theuseNotificationActivationhook.sendTestNotification()is the Settings preview: the one path that ignores both the focus check and the opt-in, and the only way to learn whether this build can post at all without waiting for a run to end (issue #1358). A capture landing on a webhook inbox raises one too, behind a second per-inbox toggle and a coalescing window, since it is the one kind whose rate the app does not set (issue #1388) - Run Progress:
setRunProgress(update), one-way over theruns:progresschannel, mirrors a live run's progress onto the Windows taskbar button and the macOS Dock icon; Linux paints nothing, Electron 44 having dropped Unity launcher support andBrowserWindow.setProgressBarnow covering Windows and macOS only.electron/run-progress.tsowns the platform rules; the renderer side isservices/run-progress.ts, which decides which run the one indicator is for - the SSE client is a singleton, so a secondstartMonitoringsupersedes the first run's stream, and that run's own calls are then ignored rather than painting on a bar it no longer owns (issue #1362). The displaced service is told it lost the stream and gives its claim up there (issue #1417). That release names the run it speaks for, which in a takeover makes it a no-op: the incoming run claims the indicator before it connects, so the bar is transferred rather than cleared and the displaced run finds a claim it no longer holds. It is there so the rule holds at the service level instead of resting on the order the other service happens to use, and the claim guard still stands behind it, for the batched flush that lands after the hand-off and for a run of the same kind as the one that took over. The claim is one run, not one kind of run: a run takes the indicator by id when the renderer starts watching it, and every report, failure and stop names the run it speaks for, so neither a second run of the same kind nor a superseded run's last buffered flush can repaint the bar of the run being watched (issue #1405)
Security Considerations¶
- Context Isolation: Enabled in Electron (renderer cannot access Node.js APIs)
- No Node Integration: Renderer runs in isolated context
- Preload Script: Minimal IPC bridge through
contextBridge.exposeInMainWorld() - Window Navigation: The main window refuses any navigation that is not the app's own document and denies
window.open(electron/window-navigation.ts). The preload re-runs on whatever the window navigates to, so this is what keepswindow.electronAPIoff a third-party origin. The app's own document is the entryfile:URL in production and the dev server's origin in development, where a Vite full reload is a real navigation. Outbound links go to the user's browser through the scheme-validatedopenExternalUrlIPC instead. There is no CSP - Local Communication: Engine runs on localhost only (127.0.0.1:9876)
Performance Optimizations¶
- Code Splitting: every chunk boundary here is a dynamic import -
vite.config.tsdeclares no manual chunk groups, because thereact-vendorandchartsgroups it used to declare were measured against rolldown's own chunking and moved nothing, and a group added for monaco actively pulled the 3.7MB editor chunk back onto the startup path as amodulepreload(#1147).Shellmounts every tab surface exceptRequestBuilderthroughReact.lazybehind one Suspense boundary; Monaco (lib/monaco-loader.ts), the markdown pipeline (ui/markdown-renderer.tsx), the GraphQL body pane and the context bar's GraphQL section each sit behind their own boundary - the last two are what keepgraphqlandgraphql-language-serviceoff the startup path, since both are reached from surfaces that are otherwise eager. The entry chunk is what the window needs in order to appear - everything else arrives with the tab, editor or description that wants it (#1146) - Monaco loads with the first editor, not at startup: nothing imports
lib/monaco-setup.tsstatically.ensureMonaco()brings it in when aCodeEditormounts, which is also what keepsloader.config({ monaco })ahead of@monaco-editor/react'sloader.init()- aninit()that runs first fetches Monaco from the jsDelivr CDN instead of using the bundled copy. The app-level completion providers subscribe throughuseLoadedMonaco(), which never triggers the load. That file also composes the Monaco entry rather than importing the package root: the editor core (features/register.all, which is what carries the find, folding, suggest and hover widgets -editoralone is the API surface with no contributions), the JSON and TypeScript language services the app drives, and one Monarch grammar per language id it can open. Those are monaco 0.56's entry points; 0.56 put the package behind anexportsmap, so theesm/vs/...paths the composition used to spell - andedcore.main, which no longer exists - stopped resolving (#1342). The CSS and HTML language services are the two it leaves out, since they pulled 1.7MB ofcss.workerandhtml.workerinto every installer that nothing could reach; their grammars stay, so an HTML or CSS response body is still highlighted, it just no longer carries language-service validation or completions (#1147) - Query Caching: TanStack Query caches server responses
- Optimistic Updates: UI updates immediately, syncs with server
- Debounced Saves: Auto-save waits for user to stop typing
- Metrics Retention: Dashboard store trims live ticks to the configured window (
liveWindowSeconds, 5m default) with amaxRetainedTicksbackstop - a memory bound, not a rendering one - Metrics Bucketing: Charts collapse ticks into
chartBucketSecondsbuckets (0.5s default) before uPlot sees them, so a full window reaches the canvas as a few thousand points - Throttled SSE commits: Every tick is buffered; store commits are throttled to
METRICS_UI_THROTTLE_MS