Read OSS

Hyper's Architecture: Navigating an Electron Terminal Emulator Codebase

Intermediate

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:

  1. Main Process (app/) — The Electron main process that manages windows, PTY sessions, menus, config file watching, plugin installation, and auto-updates.
  2. Renderer Process (lib/) — The Electron renderer that runs React/Redux, renders the xterm.js terminal, handles keyboard shortcuts, and manages the split-pane UI.
  3. 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:

app/index.ts#L42-L56

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 like typings/common.d.ts define every IPC event, and typings/config.d.ts defines 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

webpack.config.ts#L10-L69

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

webpack.config.ts#L72-L154

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

webpack.config.ts#L155-L193

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:

app/index.ts#L1-L22

The boot order is:

  1. CLI flag handling (lines 5–12): If --help or --version is passed, print info and exit immediately. No Electron startup needed.
  2. @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).
  3. 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:

lib/index.tsx#L1-L35

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:

lib/index.tsx#L72-L234

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.store is your best friend. Open DevTools in a running Hyper instance and run store.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.