Mango Developer Documentation
Everything you need to understand, extend, and contribute to Mango — a native MongoDB GUI compiled from TypeScript to native code with Perry.
Overview
Mango is a cross-platform MongoDB GUI that compiles TypeScript directly to native machine code. There is no Electron, no JVM, and no web view at runtime. The result is a ~7 MB binary that starts in under a second and uses less than 100 MB of RAM.
The key technologies behind Mango:
- Perry — a TypeScript-to-native compiler that uses SWC for parsing and Cranelift for code generation
- TypeScript — the entire codebase is standard TypeScript (ES2022 target)
- MongoDB Driver — the official
mongodbnpm package (v7.1.0) for database connectivity - SQLite — local storage for connection profiles and user preferences via
better-sqlite3 - Native UI — Perry compiles to AppKit (macOS), UIKit (iOS), Android Views, GTK4 (Linux), Win32 (Windows)
Architecture
Mango follows a simple layered architecture with three distinct concerns:
┌─────────────────────────────────────────────┐
│ UI Layer src/app.ts │
│ Perry widgets, screens, navigation, state │
├─────────────────────────────────────────────┤
│ Theme Layer src/theme/ │
│ Colors (light/dark), typography, spacing │
├─────────────────────────────────────────────┤
│ Data Layer src/data/ │
│ ConnectionStore, MongoClient, Preferences │
├─────────────────────────────────────────────┤
│ Perry Runtime perry/ui, perry/system │
│ Native widgets, keychain, platform APIs │
└─────────────────────────────────────────────┘
On native platforms (macOS, iOS, Android, Linux, Windows), the app talks directly to MongoDB via the driver. On the web, a Fastify server proxies MongoDB operations over WebSocket:
// Native platforms
App.ts → MongoClient → mongodb driver → TCP → MongoDB
// Web platform
Browser → WebSocket → Fastify (serve.ts) → MongoClient → MongoDB
Project Structure
app.ts — Main UI: all screens, navigation, state (1770 lines)
serve.ts — Fastify web server + WebSocket proxy
data/
connection-store.ts — SQLite CRUD for connection profiles
mongo-client.ts — MongoDB driver wrapper
web-mongo-client.ts — WebSocket client for web mode
preferences.ts — User preferences with SQLite + in-memory cache
database.ts — SQLite singleton factory
telemetry.ts — Optional analytics events
theme/
colors.ts — Light & dark theme color definitions
typography.ts — Platform-specific font families & sizes
tests/
preload.ts — Bun test mocks for Perry and MongoDB
connection-store.test.ts
mongo-client.test.ts
preferences.test.ts
perry.toml — Perry build config: targets, icons, entitlements
package.json — Dependencies and scripts
tsconfig.json — TypeScript config (ES2022, strict)
bunfig.toml — Bun test runner config
Building & Running
Prerequisites
Install dependencies
npm install
Development (compile & run with hot reload)
perry compile src/app.ts -o Mango
Production build
# Build for the current platform
perry build
# Build for a specific target
perry build --target macos
perry build --target ios
perry build --target android
perry build --target linux
perry build --target windows
perry build --target web
Type checking
perry check
Run tests
# All tests
bun test
# Single test file
bun test tests/connection-store.test.ts
Web mode
For the web target, Mango runs as a Fastify HTTP server that serves static assets and proxies MongoDB operations over WebSocket:
# Start the web server
perry compile src/serve.ts -o MangoWeb
./MangoWeb --host 0.0.0.0 --port 3000
# Transient mode (demo, no persistence)
./MangoWeb --transient
What is Perry?
Perry is a TypeScript-to-native compiler. It parses TypeScript using SWC (Speedy Web Compiler), then generates native machine code via Cranelift. The result is a self-contained binary for each platform — no runtime, no interpreter, no bundled V8.
Perry handles:
- Compilation: TypeScript → AST (SWC) → Machine code (Cranelift)
- Platform abstraction: A single widget API that maps to native controls per platform
- Dead code elimination: Platform-specific branches are stripped at compile time
- System APIs: Keychain access, dark mode detection, device idiom checks
perry.toml configuration
The perry.toml file at the project root controls all build settings:
[project]
name = "Mango"
entry = "src/app.ts"
build_number = 57
[targets]
platforms = ["macos", "ios", "android", "linux", "windows", "web"]
[macos]
distribution = ["appstore", "direct"]
entitlements = ["sandbox", "network-client"]
[build]
opt_level = 2
UI Widgets
Perry provides an imperative widget API. There is no JSX, no virtual DOM, and no declarative binding. You create widgets, configure them with function calls, and add them to layout stacks.
Available widgets
| Widget | Import | Description |
|---|---|---|
VStack | perry/ui | Vertical stack layout |
HStack | perry/ui | Horizontal stack layout |
Text | perry/ui | Label / text display |
TextField | perry/ui | Single-line text input |
TextArea | perry/ui | Multi-line text input |
Button | perry/ui | Clickable button |
ScrollView | perry/ui | Scrollable container |
ImageFile | perry/ui | Image display |
Divider | perry/ui | Visual separator |
Spacer | perry/ui | Flexible empty space |
Creating widgets
import { VStack, HStack, Text, Button, TextField } from "perry/ui";
// Create a vertical layout
const container = new VStack();
// Add a text label
const title = new Text("Hello, Mango");
container.addArrangedSubview(title);
// Add a button with a click handler
const btn = new Button("Connect", () => {
handleConnect();
});
container.addArrangedSubview(btn);
// Add a text field
const input = new TextField("Enter hostname...");
container.addArrangedSubview(input);
Styling & Layout
All styling is done through imperative function calls — there is no CSS. Colors are RGBA values (0.0–1.0 range), and layout is controlled through stack distribution and alignment.
Styling functions
import {
textSetFontSize,
widgetSetBackgroundColor,
setCornerRadius,
setPadding,
stackSetDistribution,
stackSetAlignment,
setSpacing
} from "perry/ui";
// Font sizing
textSetFontSize(title, 24);
// Background color (RGBA, 0.0–1.0)
widgetSetBackgroundColor(card, 1.0, 1.0, 1.0, 1.0);
// Rounded corners
setCornerRadius(card, 12);
// Padding (top, right, bottom, left)
setPadding(card, 16, 20, 16, 20);
// Stack layout
stackSetDistribution(container, 0); // 0 = fill
stackSetAlignment(container, 0); // 0 = leading
setSpacing(container, 8);
Platform Detection
Perry exposes a compile-time constant __platform__ that resolves to an integer. Since this is a compile-time constant, Cranelift eliminates dead branches — native builds never include web-only code and vice versa.
// __platform__ values
0 = macOS
1 = iOS
2 = Android
3 = Windows
4 = Linux
5 = Web
// Usage in code
declare const __platform__: number;
if (__platform__ === 5) {
// Web-only code — stripped from native builds
import { WebMongoClient } from "./data/web-mongo-client";
} else {
// Native-only code — stripped from web builds
import { MongoClient } from "./data/mongo-client";
}
The perry-styling module also provides named constants:
import { isMacOS, isIOS, isAndroid, isWindows, isLinux, isWeb } from "perry-styling";
System APIs
Perry provides platform-native system APIs through the perry/system module:
| API | Description |
|---|---|
isDarkMode() | Returns true if the OS is in dark mode |
keychainSave(key, value) | Store a secret in the platform keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) |
keychainGet(key) | Retrieve a secret from the keychain |
getDeviceIdiom() | Returns the device type (phone, tablet, desktop) |
import { isDarkMode, keychainSave, keychainGet } from "perry/system";
// Determine theme
const dark = isDarkMode();
const bg = dark ? colors.dark.background : colors.light.background;
// Store password securely
await keychainSave("mango-conn-abc123", password);
// Retrieve password
const pw = await keychainGet("mango-conn-abc123");
app.ts — The Main File
src/app.ts is the single-file entry point for the entire UI. At ~1770 lines, it contains all screens, navigation logic, widget construction, and event handling. This is intentional — Perry apps favor flat, imperative code over deep component hierarchies.
High-level structure
// 1. Imports
import { VStack, HStack, ... } from "perry/ui";
import { ConnectionStore } from "./data/connection-store";
import { MongoClient } from "./data/mongo-client";
// 2. Global state variables
let connectionIds: string[] = [];
let connectionNames: string[] = [];
let currentScreen = 0;
// 3. Screen builders
function buildConnectionList() { ... }
function buildBrowser() { ... }
function buildEditor() { ... }
// 4. Event handlers
function handleConnect() { ... }
function runQuery() { ... }
// 5. App initialization
buildConnectionList();
Screens & Navigation
Mango uses indexed screen-based navigation. Each screen is a distinct view built by a dedicated function:
| Screen | Function | Description |
|---|---|---|
| Connection List | buildConnectionList() | Manage saved connections — create, edit, delete, connect |
| Database Browser | buildBrowser() | Tree navigation of databases and collections, query bar, results |
| Document Editor | buildEditor() | View/edit document JSON with syntax highlighting |
| Edit Modal | showEditView() | Full-screen modal for creating, editing, or duplicating documents |
Navigation between screens is done by toggling visibility of top-level containers and updating the currentScreen index.
State Management
Mango uses module-level variables for all state. There is no Redux, no signals, and no observable patterns — just plain TypeScript variables that are read and mutated directly.
// Connection list state
let connectionIds: string[] = [];
let connectionNames: string[] = [];
let connectionUris: string[] = [];
// Form state for connection editing
let formName = "";
let formHost = "localhost";
let formPort = "27017";
// Query state
let currentFilter = "{}";
let currentSort = "{}";
let currentProjection = "{}";
When state changes, you manually call widget update functions to refresh the UI:
// Update a text widget after state changes
title.setText(`Documents (${results.length})`);
// Rebuild a list by removing and re-adding children
listContainer.removeAllArrangedSubviews();
for (const doc of results) {
listContainer.addArrangedSubview(buildDocRow(doc));
}
Theme System
Mango's theme is defined in src/theme/colors.ts as two color palettes (light and dark) and in src/theme/typography.ts for platform-specific fonts.
Color palette
Colors use an RGBA interface with values from 0.0 to 1.0:
interface RGBA {
r: number; g: number; b: number; a: number;
}
const light = {
background: { r: 1.0, g: 0.973, b: 0.941, a: 1.0 }, // #FFF8F0 Cream
surface: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, // White
text: { r: 0.169, g: 0.176, b: 0.259, a: 1.0 }, // #2B2D42 Charcoal
accent: { r: 1.0, g: 0.624, b: 0.110, a: 1.0 }, // #FF9F1C Mango Orange
};
const dark = {
background: { r: 0.169, g: 0.176, b: 0.259, a: 1.0 }, // #2B2D42 Charcoal
surface: { r: 0.22, g: 0.23, b: 0.31, a: 1.0 }, // Dark Gray
text: { r: 0.91, g: 0.91, b: 0.93, a: 1.0 }, // Light Gray
accent: { r: 1.0, g: 0.624, b: 0.110, a: 1.0 }, // Mango Orange (same)
};
Typography
Fonts are selected per platform at compile time:
| Platform | UI Font | Mono Font |
|---|---|---|
| macOS / iOS | .AppleSystemUIFont | SF Mono |
| Windows | Segoe UI | Cascadia Code |
| Android | Roboto | Roboto Mono |
| Linux | Ubuntu | JetBrains Mono |
| Web | system-ui | ui-monospace |
Font sizes are defined as named constants:
const FontSize = {
XS: 11,
Small: 13,
Body: 15,
Large: 18,
XL: 32,
};
ConnectionStore
src/data/connection-store.ts manages saved connection profiles in SQLite. It handles creation, retrieval, updates, and deletion of connection configs, with secure password storage through the platform keychain.
Connection model
Each connection profile stores:
- Name — user-friendly display label
- Mode —
host(host + port) oruri(connection string) - Host & Port — for host mode (defaults:
localhost:27017) - URI — full MongoDB connection string
- Auth mechanism — SCRAM-SHA-256, SCRAM-SHA-1, or X.509
- TLS/SSL — with optional custom CA certificate
- Default database — optional database to select on connect
Password storage
Passwords are never stored in SQLite. On native platforms, they go into the OS keychain. On web, localStorage is used as a fallback.
// Native: save to platform keychain
await keychainSave(`mango-${connectionId}`, password);
// Web fallback: localStorage
localStorage.setItem(`mango-pw-${connectionId}`, password);
SQLite schema
CREATE TABLE connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
mode TEXT DEFAULT 'host',
host TEXT DEFAULT 'localhost',
port INTEGER DEFAULT 27017,
uri TEXT,
auth TEXT,
tls INTEGER DEFAULT 0,
ca_cert TEXT,
default_db TEXT,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE app_state (
key TEXT PRIMARY KEY,
value TEXT
);
Core API
import { ConnectionStore } from "./data/connection-store";
const store = new ConnectionStore();
// Create
const id = store.createConnection({ name, host, port, ... });
// Read
const all = store.getAllConnections();
const one = store.getConnection(id);
// Update
store.updateConnection(id, { name: "Production" });
// Delete
store.deleteConnection(id);
// Build a connection URI from a saved profile
const uri = store.buildConnectionUri(id);
MongoClient
src/data/mongo-client.ts is a thin wrapper around the official MongoDB Node.js driver. It provides the core operations that Mango needs and tracks connection state and latency.
Core methods
| Method | Description |
|---|---|
connect(uri) | Establish connection, validate with ping |
disconnect() | Close the connection |
listDatabases() | List all databases with sizes |
listCollections(db) | List all collections in a database |
query(db, coll, opts) | Find documents with filter/sort/projection/limit/skip |
insertDocument(db, coll, doc) | Insert a new document |
updateDocument(db, coll, filter, update) | Update a document with $set |
deleteDocument(db, coll, filter) | Delete a single document by filter |
duplicateDocument(db, coll, doc) | Clone a document (strips _id) |
getCollectionStats(db, coll) | Collection metadata (count, size, avg doc size) |
listIndexes(db, coll) | Index list with keys and sizes |
Query options
interface QueryOptions {
filter?: object; // MongoDB filter document, e.g. { status: "active" }
sort?: object; // Sort spec, e.g. { createdAt: -1 }
projection?: object; // Field projection, e.g. { name: 1, email: 1 }
limit?: number; // Max documents to return (default from preferences)
skip?: number; // Pagination offset
}
Usage
import { MongoClient } from "./data/mongo-client";
const client = new MongoClient();
// Connect
await client.connect("mongodb://localhost:27017");
// List databases
const dbs = await client.listDatabases();
// Query with filter and sort
const docs = await client.query("mydb", "users", {
filter: { active: true },
sort: { createdAt: -1 },
limit: 50
});
// Insert a document
await client.insertDocument("mydb", "users", {
name: "Alice",
email: "alice@example.com"
});
PreferencesStore
src/data/preferences.ts persists user settings in SQLite with an in-memory cache for fast reads.
Available preferences
| Key | Type | Default | Description |
|---|---|---|---|
theme | string | "system" | Color theme: "system", "light", or "dark" |
pageSize | number | 20 | Documents per query page: 20, 50, 100, or 500 |
expandDocumentsByDefault | boolean | false | Auto-expand JSON documents in results |
showCollectionStats | boolean | false | Show doc count & size in sidebar |
analyticsEnabled | boolean | true | Opt-in/out of anonymous telemetry |
lastConnectionId | string? | null | Auto-select last-used connection on launch |
sidebarWidth | number | 250 | Width of the database sidebar in pixels |
Usage
import { PreferencesStore } from "./data/preferences";
const prefs = new PreferencesStore();
// Read (returns from in-memory cache)
const theme = prefs.get("theme"); // "system"
const pageSize = prefs.get("pageSize"); // 20
// Write (updates cache + SQLite)
prefs.set("theme", "dark");
prefs.set("pageSize", 100);
Web Mode & WebSocket Proxy
When compiled for the web target, Mango can't use the native MongoDB driver directly (browsers lack TCP socket access). Instead, a Fastify server (src/serve.ts) acts as a proxy:
Server (serve.ts)
// serve.ts starts a Fastify HTTP server
// Serves static assets (HTML, JS, CSS) over HTTP
// Proxies MongoDB operations over WebSocket at /ws
// Supported WebSocket messages:
{
action: "connect", // → MongoClient.connect(uri)
action: "disconnect", // → MongoClient.disconnect()
action: "listDatabases", // → MongoClient.listDatabases()
action: "listCollections", // → MongoClient.listCollections(db)
action: "find", // → MongoClient.query(db, coll, opts)
action: "insertOne", // → MongoClient.insertDocument(...)
action: "updateOne", // → MongoClient.updateDocument(...)
action: "deleteOne", // → MongoClient.deleteDocument(...)
}
Client (web-mongo-client.ts)
The web-side client mirrors the MongoClient API but sends operations over WebSocket instead of TCP:
import { WebMongoClient } from "./data/web-mongo-client";
// Same API as MongoClient — the app code doesn't need to know
// whether it's running natively or in a browser
const client = new WebMongoClient();
await client.connect(uri);
const dbs = await client.listDatabases();
CLI options for serve.ts
| Flag | Default | Description |
|---|---|---|
--host | localhost | Bind address |
--port | 3000 | Listen port |
--transient | false | Demo mode — no data persistence |
--html | (auto) | Path to static HTML assets |
Telemetry
src/data/telemetry.ts provides optional, anonymous event tracking via the Chirp API. It respects the user's analyticsEnabled preference and is fully try-catch wrapped — a telemetry failure will never crash the app.
Tracked events
| Event | When | Dimensions |
|---|---|---|
app_launch | App starts | platform, version |
connect | User connects to MongoDB | platform, version |
query | User runs a query | platform, version |
Adding Features
Here's a walkthrough of adding a new feature to Mango — for example, an aggregation pipeline builder.
Step 1: Add the data operation
Add a new method to src/data/mongo-client.ts:
async aggregate(
dbName: string,
collName: string,
pipeline: object[]
): Promise<object[]> {
const db = this.client.db(dbName);
const coll = db.collection(collName);
return await coll.aggregate(pipeline).toArray();
}
Step 2: Add the WebSocket action (for web mode)
In src/serve.ts, add a handler for the new action:
case "aggregate":
const results = await client.aggregate(
msg.dbName, msg.collName, msg.pipeline
);
ws.send(JSON.stringify({ action: "aggregate", results }));
break;
Mirror this in src/data/web-mongo-client.ts so the web client can call the same API.
Step 3: Build the UI
In src/app.ts, create a new function for the aggregation screen:
function buildAggregationView() {
const container = new VStack();
setPadding(container, 16, 16, 16, 16);
// Pipeline stage editor
const editor = new TextArea('[{ "$match": {} }]');
container.addArrangedSubview(editor);
// Run button
const runBtn = new Button("Run Pipeline", async () => {
const pipeline = JSON.parse(editor.content);
const results = await client.aggregate(currentDb, currentColl, pipeline);
displayResults(results);
});
container.addArrangedSubview(runBtn);
return container;
}
Step 4: Write tests
Add a test in tests/ following the existing patterns.
Adding Screens
To add a new screen to Mango:
- Create the builder function — e.g.,
buildSettingsScreen()inapp.ts - Assign a screen index — add a new constant for navigation
- Add navigation — add a button or menu item that sets
currentScreenand shows the new container - Wire up the back button — ensure the user can return to the previous screen
// Define a new screen index
const SCREEN_SETTINGS = 3;
// Builder function
function buildSettingsScreen() {
const root = new VStack();
// Back button
const back = new Button("Back", () => {
navigateTo(SCREEN_BROWSER);
});
root.addArrangedSubview(back);
// ... settings UI ...
return root;
}
// Navigate to it
function navigateTo(screen: number) {
// Hide current screen, show target screen
currentScreen = screen;
}
New Database Operations
To add a new MongoDB operation to Mango, you need to update three files:
| File | What to add |
|---|---|
src/data/mongo-client.ts | New method using the mongodb driver |
src/data/web-mongo-client.ts | Matching method that sends via WebSocket |
src/serve.ts | New WebSocket action handler in the switch block |
MongoClient and WebMongoClient must have identical method signatures so that app.ts can use either one depending on the platform, without conditional logic.
Custom Themes
To create a new color theme:
- Open
src/theme/colors.ts - Create a new
ThemeColorsobject following the light/dark pattern - Add a new theme option in
PreferencesStore - Update the theme selection logic in
app.ts
// Example: adding a "solarized" theme to colors.ts
export const solarized: ThemeColors = {
background: { r: 0.0, g: 0.169, b: 0.212, a: 1.0 }, // #002b36
surface: { r: 0.027, g: 0.212, b: 0.259, a: 1.0 }, // #073642
text: { r: 0.514, g: 0.580, b: 0.588, a: 1.0 }, // #839496
accent: { r: 0.710, g: 0.537, b: 0.0, a: 1.0 }, // #b58900
// ... remaining color keys
};
Testing
Mango uses Bun as its test runner with a preload script that mocks Perry and platform APIs.
Test setup
tests/preload.ts sets up mocks before any test file runs:
- Perry UI mocks — stub implementations for
VStack,Button,Text, etc. - Perry system mocks — fake
isDarkMode(),keychainSave(),keychainGet() - SQLite mock — in-memory SQLite via Bun's
bun:sqlitemodule
Writing a test
import { describe, it, expect, beforeEach } from "bun:test";
import { resetDatabase } from "./mocks/reset-database";
import { ConnectionStore } from "../src/data/connection-store";
describe("ConnectionStore", () => {
let store: ConnectionStore;
beforeEach(() => {
resetDatabase();
store = new ConnectionStore();
});
it("creates and retrieves a connection", () => {
const id = store.createConnection({
name: "Test",
host: "localhost",
port: 27017
});
const conn = store.getConnection(id);
expect(conn.name).toBe("Test");
});
});
Integration tests
Tests in tests/mongo-client.test.ts run against a real MongoDB server. They are automatically skipped if MongoDB is not available at localhost:27017. To run them:
# Start MongoDB locally, then:
bun test tests/mongo-client.test.ts