How Nodeterm Handles Project Serialization and Deserialization: A Deep Dive into the Save System
Nodeterm uses a two-layer persistence architecture where the renderer extracts a pure state snapshot and the core writes it atomically to disk, ensuring crash-safe, cross-platform project files.
Nodeterm treats every project as a self-contained canvas that can be saved, reopened, or moved between machines. The architecture separates concerns between the renderer process (which manages the live React Flow UI state) and the core process (which handles durable disk I/O and schema migrations). This article examines the complete project serialization and deserialization pipeline implemented in the eneskirca/nodeterm repository.
The Two-Layer Architecture
The serialization pipeline is divided into two complementary layers that communicate through well-defined interfaces.
| Layer | Responsibility | Main Source File |
|---|---|---|
| Renderer state | Holds the live React Flow representation of nodes, viewport, and UI-only flags. Extracts a pure snapshot when the user switches projects or quits. | src/renderer/state/workspace.ts |
| Core persistence | Writes the snapshot to disk as a project file (.nodeterm/project.json) and reads it back, rebuilding in-memory data structures. Uses atomic helpers to guarantee durability. |
src/core/workspace-store.ts and atomic write helpers in src/core/fs-atomic.ts |
Serializing Projects
When the user selects a different project or the app closes, the renderer initiates a save operation that strips transient data and delegates durable storage to the core.
Preparing Renderer State for Persistence
The commitActiveToStore() function in the renderer orchestrates the extraction of serializable state.
- It accesses
workspace.state.nodes(the React Flow node array) and passes it tonodeStatesToFlow()– a pure function that drops every transient field (initialCommand,respawnNonce,remote, etc.) and converts mutableNodeStateobjects into a stable shape. - It appends project-wide metadata including
viewport,defaultAccountId,collapsedItems, and the optionalproject.id. - It returns a plain JSON object matching the schema v3 (
ProjectFileV3type) before handing it to the core store.
// src/renderer/state/workspace.ts – simplified
function commitActiveToStore() {
const raw = nodeStatesToFlow(workspace.state.nodes);
const project = {
...raw,
viewport: workspace.state.viewport,
collapsedItems: settings.sidebarCollapsedItems,
// …other meta fields
};
workspaceStore.save(project);
}
Atomic Cross-Platform Writes
The workspaceStore.save() method in src/core/workspace-store.ts handles durable persistence. It serializes the object with JSON.stringify(project, null, 2) and calls writeFileAtomic() from src/core/fs-atomic.ts. This helper writes to a temporary file first, then performs an atomic rename operation. This pattern prevents data corruption from crashes, concurrent saves, and Windows-specific "file-in-use" (EPERM) errors.
// src/core/workspace-store.ts (excerpt)
async function save(project: ProjectFileV3) {
const content = JSON.stringify(project, null, 2);
await writeFileAtomic(projectPath(project.id), content);
}
Deserializing Projects
Loading a project reverses the pipeline: the core reads the file, migrates legacy formats if necessary, and the renderer reconstructs the live UI state.
Reading and Migrating Legacy Formats
When opening a project via "Open folder", "Open recent", or app startup, the core store executes three steps:
- Reads the JSON file using
readFileAtomic. - Parses the content and runs version migration via
migrateV2toV3IfNeeded()if the file uses the old v2 format. This upgrades legacy fields liketags:['claude']to the currentagentIdshape. - Returns a
ProjectFileV3object to the renderer.
// src/core/workspace-store.ts (excerpt)
async function load(id: string): Promise<ProjectFileV3> {
const raw = await readFileAtomic(projectPath(id));
const parsed = JSON.parse(raw);
return migrateV2toV3IfNeeded(parsed);
}
Rehydrating the React Flow Canvas
The renderer receives the deserialized project object and uses flowToNodeStates() to transform plain node descriptors back into React Flow-compatible objects. During reconstruction:
- Missing fields receive defaults (e.g.,
kind: 'terminal'for legacy nodes). - UI-only data that never touched disk (current selection, temporary flags) is reapplied.
// src/renderer/state/workspace.ts (excerpt)
function loadProject(projectData: ProjectFileV3) {
const nodes = flowToNodeStates(projectData.nodes);
// restore viewport, settings, etc.
workspace.setState({ nodes, viewport: projectData.viewport });
}
Critical Invariants and Guarantees
The Nodeterm source code enforces several strict invariants to ensure data integrity:
| Invariant | Where Enforced |
|---|---|
| Transient fields are never persisted | nodeStatesToFlow() in workspace.ts drops initialCommand, respawnNonce, and remote |
| Atomic writes on all platforms | writeFileAtomic() in fs-atomic.ts prevents Windows EPERM and POSIX race conditions (validated by fs-atomic.guard.test.ts) |
| Automatic legacy migration | migrateV2toV3IfNeeded() in workspace-store.ts upgrades v2 projects to v3 on load |
| Serialization consistency | Pure functions nodeStatesToFlow and flowToNodeStates are shared between local saves and collaborative canvas streams (src/shared/canvas-publish.ts) |
These guarantees enable projects to be hot-switched between tabs without losing state, cold-restored after machine reboots, and shared via Git using the human-readable .nodeterm/project.json file.
Summary
- Nodeterm project serialization relies on a strict separation between renderer state extraction and core atomic I/O.
- The
nodeStatesToFlow()function ensures only stable data reaches the disk by filtering transient runtime fields. - Atomic file writes via
writeFileAtomic()prevent corruption across Windows, macOS, and Linux. - Automatic migration logic upgrades legacy v2 schemas to v3 transparently when loading.
- Deserialization uses
flowToNodeStates()to reconstruct React Flow nodes while reapplying UI-specific ephemeral state.
Frequently Asked Questions
How does Nodeterm prevent data loss during crashes?
Nodeterm uses atomic file operations implemented in src/core/fs-atomic.ts. The writeFileAtomic() function writes content to a temporary file first, then performs an atomic filesystem rename. This ensures that the existing project file is only replaced once the new data is fully written to disk, preventing partial writes or corruption if the app crashes mid-save.
What happens when opening an old Nodeterm project?
The load() function in src/core/workspace-store.ts automatically detects v2 schemas and runs migrateV2toV3IfNeeded(). This migrates deprecated fields like tags:['claude'] to the modern agentId format, ensuring backward compatibility without manual user intervention.
Which project data is not saved to disk?
Transient runtime fields including initialCommand, respawnNonce, and remote are intentionally stripped by nodeStatesToFlow() during serialization. These represent live session state that should not persist across restarts. UI-only flags like temporary selection states are also excluded from the JSON file but reapplied during deserialization.
Can Nodeterm projects be version controlled?
Yes. The .nodeterm/project.json file is a plain, human-readable JSON document with stable schema versioning. Because it uses deterministic serialization with JSON.stringify(project, null, 2), it integrates cleanly with Git and other version control systems, allowing teams to share terminal workspace configurations alongside source code.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →