Hyper's Architecture: Navigating an Electron Terminal Emulator Codebase
Prerequisites
- ›Basic Electron knowledge (main vs renderer process)
- ›Familiarity with webpack concepts
- ›TypeScript fundamentals
Hyper's Architecture: Navigating an Electron Terminal Emulator Codebase
Hyper is a terminal emulator that treats itself as a web app first and a terminal second. Built on Electron, it renders a full terminal experience using React, Redux, and xterm.js — while exposing nearly every surface to third-party plugins. If you've ever wondered what happens when you push the Electron model to its limits for something as performance-critical as a terminal, Hyper is the codebase to study.
In this first article, we'll map the entire project: three distinct processes, a surprisingly flat directory structure, and a build tool that uses webpack in some genuinely unusual ways — including a null-loader trick that makes webpack do nothing except copy files.
Three-Process Architecture Overview
Hyper runs as three separate executables, each with its own entry point and build target:
- Main Process (
app/) — The Electron main process that manages windows, PTY sessions, menus, config file watching, plugin installation, and auto-updates. - Renderer Process (
lib/) — The Electron renderer that runs React/Redux, renders the xterm.js terminal, handles keyboard shortcuts, and manages the split-pane UI. - Command-line tool (
cli/) — A standalone Node.js binary (hyper) for managing plugins and launching the app from the command line.
flowchart TD
subgraph "Main Process (app/)"
A[BrowserWindow Manager] --> B[PTY Sessions via node-pty]
A --> C[Config Watcher]
A --> D[Plugin Installer]
A --> E[Menu System]
end
subgraph "Renderer Process (lib/)"
F[React + Redux] --> G[xterm.js Terminal]
F --> H[Split Pane UI]
F --> I[Keyboard Shortcuts]
end
subgraph "CLI (cli/)"
J[Plugin Management]
K[App Launcher]
end
A <-->|"RPC over IPC"| F
J -->|"Edits hyper.json"| C
What makes Hyper's architecture distinctive is that the main process acts as a service locator — the app object is extended at runtime with properties like config, plugins, createWindow, and getLastFocusedWindow. You can see this pattern right at the top of the main entry point:
The app object becomes a shared context passed to plugins and internal subsystems alike, carrying references to config, plugin management, and window tracking.
Directory Structure Walkthrough
Hyper's repository is flat by design. There's no monorepo tooling — just three top-level directories mapping to the three processes, plus shared type definitions:
| Directory | Process | Purpose |
|---|---|---|
app/ |
Main | Electron main process: windows, sessions, config, plugins, menus |
app/config/ |
Main | Config loading, migration, paths, JSON schema |
app/ui/ |
Main | Window creation, context menus |
app/plugins/ |
Main | Extension point definitions, yarn-based installation |
app/menus/ |
Main | Platform-specific application menus |
lib/ |
Renderer | React/Redux app: components, containers, actions, reducers |
lib/components/ |
Renderer | React components: Term, TermGroup, Header, Tabs |
lib/containers/ |
Renderer | Redux-connected container components |
lib/store/ |
Renderer | Redux store configuration, middleware |
lib/actions/ |
Renderer | Redux action creators with the "effects" pattern |
lib/reducers/ |
Renderer | Redux reducers: ui, sessions, termGroups |
lib/utils/ |
Renderer | RPC client, plugin loading, config access |
cli/ |
CLI | Standalone plugin management tool |
typings/ |
Shared | TypeScript type definitions for IPC, config, state |
Tip: The
typings/directory is the best starting point for understanding Hyper's data model. Files liketypings/common.d.tsdefine every IPC event, andtypings/config.d.tsdefines the complete configuration shape. These are the contracts that tie all three processes together.
The main process TypeScript is compiled separately by tsc (not webpack), while the renderer is bundled by webpack into target/renderer/bundle.js. This split is intentional: the main process runs in Node.js and doesn't need bundling, while the renderer needs it for V8 snapshots.
The Build System: Three Webpack Configs
The single webpack.config.ts exports an array of three configurations. Each one serves a fundamentally different purpose.
Config 1: hyper-app — The Null-Loader Trick
This is the most unusual webpack configuration you'll encounter. It compiles nothing. Every .ts and .js file is processed through null-loader, which discards all source code. The output file is literally called ignore_this.js.
The entire purpose of this config is to run CopyWebpackPlugin — copying HTML files, JSON configs, keymaps, static assets, and patches into the target/ directory. Webpack is being used purely as a build orchestrator for file copying.
Why not use a simple shell script? Because this hooks into the same webpack -w watch mode used during development, ensuring that when you edit an HTML template or keymap JSON, it's automatically copied to the target directory.
Config 2: hyper — The Renderer Bundle
The renderer config is a standard electron-renderer target with one major twist: an enormous externals block listing ~30 dependencies. Each external maps to a require("./node_modules/...") path:
externals: {
react: 'require("./node_modules/react/index.js")',
redux: 'require("./node_modules/redux/lib/redux.js")',
xterm: 'require("./node_modules/xterm/lib/xterm.js")',
// ... 25+ more
}
This means these modules are not bundled into bundle.js. Instead, they're loaded at runtime from the node_modules directory inside the app. This is the foundation of Hyper's V8 snapshot optimization.
Config 3: hyper-cli — The CLI
A straightforward Node.js target that bundles the CLI tool into bin/cli.js. It uses babel-loader for TypeScript and includes a shebang-loader to handle the rc package's executable scripts.
flowchart LR
subgraph "webpack.config.ts"
A["hyper-app\n(null-loader + CopyPlugin)"] -->|"→ target/"| D[HTML + JSON + Static]
B["hyper\n(babel-loader + externals)"] -->|"→ target/renderer/"| E[bundle.js]
C["hyper-cli\n(babel-loader)"] -->|"→ bin/"| F[cli.js]
end
G["tsc --build"] -->|"→ target/"| H[Main process JS]
V8 Snapshots for Startup Performance
Terminal emulators live and die by perceived startup speed. Hyper addresses this with V8 snapshots — pre-compiled heap snapshots that let Electron skip the parse-and-compile phase for heavy dependencies.
The postinstall script in package.json orchestrates this:
yarn run v8-snapshot && webpack --config-name hyper-app && electron-builder install-app-deps
The V8 snapshot pipeline works in three stages:
sequenceDiagram
participant P as postinstall
participant M as mksnapshot
participant W as webpack
participant E as Electron
P->>M: Run electron-mksnapshot
M->>M: Pre-compile node_modules into snapshot blob
P->>W: Build renderer bundle with externals
Note over W: Dependencies excluded from bundle<br/>They'll come from the snapshot
P->>E: Copy snapshot blobs to app directory
E->>E: On startup, load snapshot instead of parsing JS
The externals in the renderer webpack config are the key connection point. By excluding React, Redux, xterm.js, and dozens of other libraries from the webpack bundle, Hyper ensures they're loaded from the V8 snapshot at startup rather than parsed from JavaScript source files. The unusual require("./node_modules/...") paths in the externals ensure the modules resolve correctly from within the snapshot context.
Main Process Boot Sequence
The main process entry point at app/index.ts has a carefully ordered boot sequence. The first few lines handle the earliest-possible concerns before any imports run:
The boot order is:
- CLI flag handling (lines 5–12): If
--helpor--versionis passed, print info and exit immediately. No Electron startup needed. - @electron/remote initialization (lines 16–17): Must happen before any BrowserWindow is created. This enables the renderer to call main-process APIs synchronously (used heavily by the plugin system).
- Config setup (lines 21–22): Loads
hyper.json, starts the chokidar file watcher, and checks for deprecated CSS.
sequenceDiagram
participant OS as OS
participant M as Main Process
participant W as BrowserWindow
participant R as Renderer
OS->>M: Launch Electron
M->>M: Check CLI flags (--help, --version)
M->>M: Initialize @electron/remote
M->>M: config.setup() — load + watch hyper.json
M->>M: Extend app object (config, plugins, getWindows)
M->>M: Wait for app.ready event
M->>M: Install dev extensions (if dev mode)
M->>W: createWindow() — BrowserWindow + RPC + sessions
M->>W: loadURL(index.html)
W->>R: Renderer starts
M->>M: Set up menu, plugins.onApp, auto-updater
M->>M: Register SSH protocol handler
After app.ready fires, the createWindow function is defined inline and immediately called to create the first window. It's also attached to app.createWindow so plugins can call it later. Notice how window positioning cascades: each new window is offset 34px from the last focused window, with bounds checking to prevent windows from appearing off-screen.
Renderer Process Boot Sequence
The renderer entry at lib/index.tsx starts with the V8 snapshot utility, then immediately creates the Redux store and exposes core objects globally:
Four objects are attached to window via Object.defineProperty: store, rpc, config, and plugins. This is the renderer's equivalent of the main process's service locator pattern — plugins running in the renderer can access these globals to interact with the system.
The renderer then goes through approximately 30 RPC event registrations:
Each rpc.on(...) handler dispatches a Redux action. The 'ready' event (line 72) fires when the RPC channel is established, triggering the initial Redux state setup. The 'session data' event (line 81) is the most performance-critical — it extracts a UUID from the first 36 characters of the data string and dispatches it for xterm rendering.
Finally, the React app is mounted:
root.render(
<Provider store={store_}>
<HyperContainer />
</Provider>
);
Tip: When debugging Hyper, the global
window.storeis your best friend. Open DevTools in a running Hyper instance and runstore.getState()to inspect the full Redux state tree — sessions, term groups, UI config, all of it.
What's Next
With the architecture mapped out, we know where everything lives and how the three processes relate to each other. But the most interesting part of Hyper's design is the glue between those processes — the typed RPC system that carries terminal data, session lifecycle events, and commands across the Electron IPC boundary. In the next article, we'll trace the complete lifecycle of a terminal session, from PTY creation through data batching to xterm rendering.