Skip to content

Commit 7cd0fe6

Browse files
feat: add electron mailbox demo example
1 parent 4f63386 commit 7cd0fe6

29 files changed

Lines changed: 4373 additions & 0 deletions

.github/workflows/codspeed.yml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,63 @@ jobs:
7474
pnpm turbo run bench --filter=@codspeed/benchmark.js-plugin
7575
pnpm --workspace-concurrency 1 -r bench-tinybench
7676
pnpm --workspace-concurrency 1 -r bench-vitest
77+
78+
electron-e2e:
79+
name: Run electron inbox e2e
80+
runs-on: codspeed-macro
81+
permissions:
82+
contents: read
83+
id-token: write
84+
steps:
85+
- name: Checkout
86+
uses: actions/checkout@v6
87+
with:
88+
fetch-depth: 0
89+
submodules: true
90+
91+
- name: Set up pnpm
92+
uses: pnpm/action-setup@v4
93+
94+
# The example links @codspeed/playwright to packages/playwright via the
95+
# `link:` protocol, so the in-repo plugin must be built (under the repo's
96+
# own Node version) before the example installs and resolves the symlink.
97+
- name: Set up Node.js (repo)
98+
uses: actions/setup-node@v6
99+
with:
100+
node-version-file: .nvmrc
101+
cache: pnpm
102+
103+
- name: Install repo dependencies
104+
run: pnpm install --frozen-lockfile --prefer-offline
105+
106+
- name: Build @codspeed/playwright
107+
run: pnpm turbo run build --filter=@codspeed/playwright
108+
109+
- name: Set up Node.js (example)
110+
uses: actions/setup-node@v6
111+
with:
112+
node-version-file: examples/with-electron-and-walltime/package.json
113+
cache: pnpm
114+
cache-dependency-path: examples/with-electron-and-walltime/pnpm-lock.yaml
115+
116+
- name: Install dependencies
117+
working-directory: examples/with-electron-and-walltime
118+
run: pnpm install --frozen-lockfile
119+
120+
- name: Build electron app
121+
working-directory: examples/with-electron-and-walltime
122+
run: pnpm --filter @mail-client-demo/electron build
123+
124+
- name: Install Playwright browsers
125+
working-directory: examples/with-electron-and-walltime
126+
run: pnpm --filter @mail-client-demo/electron exec playwright install --with-deps chromium
127+
128+
- name: Run electron inbox e2e under codspeed
129+
uses: CodSpeedHQ/action@runner-alpha
130+
env:
131+
CODSPEED_WALLTIME_PROFILER: "samply"
132+
with:
133+
working-directory: examples/with-electron-and-walltime
134+
run: xvfb-run -a pnpm bench:electron
135+
mode: walltime
136+
runner-version: 4.16.2-alpha.2
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
node_modules/
2+
dist/
3+
out/
4+
.codspeed/
5+
*.log
6+
.DS_Store
7+
.vscode/
8+
.idea/
9+
coverage/
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# mail-client benchmarks
2+
3+
A TypeScript mail-client backend-for-frontend with a small Electron client on top, instrumented with [CodSpeed](https://codspeed.io) so the performance-sensitive surface (search, threading, bulk mutations, persistence) is benchmarked in CI.
4+
5+
## Why
6+
7+
Mail clients have a strict performance bar — every interaction needs to feel instant. This repo isolates the data-and-state layer of such a client, benchmarks its hot paths in walltime and memory mode, and exercises the same code from an Electron renderer so the user-facing impact of a code change is visible alongside the CodSpeed numbers.
8+
9+
## Stack
10+
11+
- **TypeScript** on Node.js `24.11.1` (pinned via `package.json` `engines`).
12+
- **pnpm workspaces** — model, electron app, and benches live as separate packages.
13+
- Two benchmark-authoring paths, both with first-class CodSpeed support:
14+
- **vitest** `bench()` + **`@codspeed/vitest-plugin`** — declarative, sits next to test suites.
15+
- **tinybench** + **`@codspeed/tinybench-plugin`** — standalone scripts, fine-grained control.
16+
- **electron-vite** + **vanilla TS** for the client — minimal renderer code, straightforward to profile.
17+
18+
## Layout
19+
20+
```
21+
packages/
22+
model/ @mail-client-demo/model
23+
src/ Email, Inbox, seed generator, store + persistence
24+
bench/ vitest benchmarks
25+
apps/
26+
electron/ @mail-client-demo/electron
27+
src/main/ Electron main process — owns the AppState + IPC
28+
src/preload/ contextBridge for the renderer
29+
src/renderer/ vanilla TS inbox UI
30+
```
31+
32+
## Run
33+
34+
```bash
35+
pnpm install
36+
pnpm electron # launch the Electron client (dev mode)
37+
pnpm bench # local wall-clock benches
38+
pnpm bench:codspeed # instrumented run (walltime), uploads to CodSpeed
39+
pnpm typecheck # typecheck all workspace packages
40+
```
41+
42+
## CI
43+
44+
This example is benchmarked in CI by the repository-root
45+
`.github/workflows/codspeed-electron-example.yml` workflow, which runs the
46+
Electron inbox e2e on the CodSpeed macro runner, with OIDC auth (no static token
47+
required).
48+
49+
To benchmark the model package (walltime/memory) locally, use the `bench` and
50+
`bench:codspeed` scripts described above.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { resolve } from "node:path";
2+
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
3+
4+
export default defineConfig({
5+
main: {
6+
plugins: [externalizeDepsPlugin({ exclude: ["@mail-client-demo/model"] })],
7+
build: {
8+
rollupOptions: {
9+
input: { index: resolve(import.meta.dirname, "src/main/index.ts") },
10+
output: {
11+
format: "es",
12+
},
13+
},
14+
},
15+
},
16+
preload: {
17+
plugins: [externalizeDepsPlugin()],
18+
build: {
19+
rollupOptions: {
20+
input: { index: resolve(import.meta.dirname, "src/preload/index.ts") },
21+
},
22+
},
23+
},
24+
renderer: {
25+
root: resolve(import.meta.dirname, "src/renderer"),
26+
build: {
27+
rollupOptions: {
28+
input: { index: resolve(import.meta.dirname, "src/renderer/index.html") },
29+
},
30+
},
31+
},
32+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@mail-client-demo/electron",
3+
"version": "0.1.0",
4+
"private": true,
5+
"description": "Electron mail-client that renders the @mail-client-demo/model inbox.",
6+
"type": "module",
7+
"main": "out/main/index.js",
8+
"scripts": {
9+
"dev": "electron-vite dev",
10+
"build": "electron-vite build",
11+
"preview": "electron-vite preview",
12+
"typecheck": "tsc --noEmit",
13+
"bench": "node test/inbox.e2e.ts"
14+
},
15+
"dependencies": {
16+
"@mail-client-demo/model": "workspace:*"
17+
},
18+
"devDependencies": {
19+
"@codspeed/playwright": "link:../../../../packages/playwright",
20+
"@types/node": "^25.9.1",
21+
"electron": "^42.2.0",
22+
"electron-vite": "^5.0.0",
23+
"playwright": "^1.60.0",
24+
"tsx": "^4.22.3",
25+
"vite": "^7.3.3"
26+
}
27+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { dirname, join } from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
import type { Action, AppState, EmailId } from "@mail-client-demo/model";
4+
import {
5+
generateEmails,
6+
Inbox,
7+
makeInitialState,
8+
openDatabase,
9+
reduce,
10+
syncStateToDisk,
11+
} from "@mail-client-demo/model";
12+
import { app, BrowserWindow, ipcMain } from "electron";
13+
14+
const __dirname = dirname(fileURLToPath(import.meta.url));
15+
16+
// Big enough that the naive implementations FEEL slow:
17+
// buildThreads (O(n²)) ~1.5–2 s
18+
// sync (full JSON snapshot of 50k emails) ~150–300 ms PER ACTION
19+
// bulkArchive of 500 ids ~150–250 ms
20+
// Every IPC mutation runs through store.dispatch -> reduce -> syncStateToDisk,
21+
// so the sync cost is paid on every keystroke / button click and every UI
22+
// interaction feels the lag. Optimization will collapse all of it.
23+
const EMAIL_COUNT = 50_000;
24+
25+
let state: AppState | null = null;
26+
let db: ReturnType<typeof openDatabase> | null = null;
27+
28+
interface Timed<T> {
29+
result: T;
30+
durationMs: number;
31+
}
32+
33+
function timed<T>(fn: () => T): Timed<T> {
34+
const t0 = performance.now();
35+
const result = fn();
36+
return { result, durationMs: performance.now() - t0 };
37+
}
38+
39+
function dispatch(action: Action): { durationMs: number; syncMs: number } {
40+
const t0 = performance.now();
41+
if (!state || !db) return { durationMs: 0, syncMs: 0 };
42+
state = reduce(state, action);
43+
const sync = syncStateToDisk(state, db);
44+
return {
45+
durationMs: performance.now() - t0,
46+
syncMs: sync.durationMs,
47+
};
48+
}
49+
50+
/**
51+
* Sync the current state to disk. Called from every read-only IPC handler so
52+
* the audit-trail file stays current with each user interaction (and so the
53+
* naive full-snapshot cost is felt on every UI op, not just mutations).
54+
*/
55+
function syncCurrentState(): number {
56+
if (!state || !db) return 0;
57+
return syncStateToDisk(state, db).durationMs;
58+
}
59+
60+
function getInbox(): Inbox {
61+
return new Inbox(state ? state.emails : []);
62+
}
63+
64+
function createWindow() {
65+
const win = new BrowserWindow({
66+
width: 1200,
67+
height: 800,
68+
title: "Inbox",
69+
backgroundColor: "#0f1419",
70+
webPreferences: {
71+
preload: join(__dirname, "../preload/index.mjs"),
72+
contextIsolation: true,
73+
sandbox: false,
74+
},
75+
});
76+
77+
if (process.env.ELECTRON_RENDERER_URL) {
78+
win.loadURL(process.env.ELECTRON_RENDERER_URL);
79+
} else {
80+
win.loadFile(join(__dirname, "../renderer/index.html"));
81+
}
82+
}
83+
84+
app.whenReady().then(() => {
85+
const seedStart = performance.now();
86+
const emails = generateEmails({ count: EMAIL_COUNT });
87+
state = makeInitialState(emails);
88+
const dbPath = join(app.getPath("userData"), "state.json");
89+
db = openDatabase(dbPath);
90+
console.log(
91+
`[main] seeded ${EMAIL_COUNT} emails in ${(performance.now() - seedStart).toFixed(1)} ms`,
92+
);
93+
console.log(`[main] persistence path: ${dbPath}`);
94+
95+
// Initial sync so disk reflects the seeded state.
96+
const initialSync = syncStateToDisk(state, db);
97+
console.log(`[main] initial sync: ${initialSync.durationMs.toFixed(1)} ms`);
98+
99+
ipcMain.handle("inbox:list", () => {
100+
const t = timed(() => getInbox().sortByDate(getInbox().visible()).slice(0, 200));
101+
const syncMs = syncCurrentState();
102+
return { ...t, syncMs };
103+
});
104+
105+
ipcMain.handle("inbox:search", (_evt, query: string) => {
106+
const t = timed(() => getInbox().sortByDate(getInbox().search(query)).slice(0, 50));
107+
const syncMs = syncCurrentState();
108+
return { ...t, syncMs };
109+
});
110+
111+
ipcMain.handle("inbox:threads", () => {
112+
const t = timed(() => {
113+
const threads = getInbox().buildThreads();
114+
threads.sort((a, b) => b.lastReceivedAt - a.lastReceivedAt);
115+
return threads.slice(0, 50);
116+
});
117+
const syncMs = syncCurrentState();
118+
return { ...t, syncMs };
119+
});
120+
121+
ipcMain.handle("inbox:archive", (_evt, ids: EmailId[]) => {
122+
const { durationMs, syncMs } = dispatch({ type: "ARCHIVE", ids, at: Date.now() });
123+
return { result: ids.length, durationMs, syncMs };
124+
});
125+
126+
ipcMain.handle("inbox:stats", () => {
127+
if (!state) return { total: 0, visible: 0, pendingOps: 0 };
128+
const inbox = getInbox();
129+
return {
130+
total: state.emails.length,
131+
visible: inbox.visible().length,
132+
pendingOps: state.pendingOps.length,
133+
};
134+
});
135+
136+
createWindow();
137+
});
138+
139+
app.on("window-all-closed", () => {
140+
if (process.platform !== "darwin") app.quit();
141+
});
142+
143+
app.on("activate", () => {
144+
if (BrowserWindow.getAllWindows().length === 0) createWindow();
145+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Email, EmailId, Thread } from "@mail-client-demo/model";
2+
import { contextBridge, ipcRenderer } from "electron";
3+
4+
export interface TimedWithSync<T> {
5+
result: T;
6+
durationMs: number;
7+
syncMs: number;
8+
}
9+
10+
export interface ArchiveResult {
11+
result: number;
12+
durationMs: number;
13+
syncMs: number;
14+
}
15+
16+
export interface InboxStats {
17+
total: number;
18+
visible: number;
19+
pendingOps: number;
20+
}
21+
22+
const api = {
23+
list: (): Promise<TimedWithSync<Email[]>> => ipcRenderer.invoke("inbox:list"),
24+
search: (query: string): Promise<TimedWithSync<Email[]>> =>
25+
ipcRenderer.invoke("inbox:search", query),
26+
threads: (): Promise<TimedWithSync<Thread[]>> => ipcRenderer.invoke("inbox:threads"),
27+
archive: (ids: EmailId[]): Promise<ArchiveResult> => ipcRenderer.invoke("inbox:archive", ids),
28+
stats: (): Promise<InboxStats> => ipcRenderer.invoke("inbox:stats"),
29+
};
30+
31+
contextBridge.exposeInMainWorld("inbox", api);
32+
33+
export type InboxBridge = typeof api;

0 commit comments

Comments
 (0)