Final Steps: Bundling Everything Together
Final Steps: Bundling Everything Together
Wire up the Electron main process to spawn the correct Python backend depending on the environment.
Step 1: Update electron/main.ts
Add the spawnPythonService function to manage the Python AI service lifecycle. It detects dev vs production and spawns the appropriate process.
File: electron/main.ts
// ── Python Process Management ─────────────────────────────────
/**
* Spawns the Python AI service as a child process.
* The Python server runs on port 8000 and handles all AI/RAG operations.
*/
function spawnPythonService(): void {
// Kill any orphaned process from a previous run
killPortProcess(PYTHON_PORT);
const aiserviceDir = path.join(__dirname, '..', 'aiservice');
const isDev = !require('fs').existsSync(
path.join(__dirname, '..', 'frontend', 'out', 'index.html')
);
let command: string;
let args: string[];
let cwd: string;
if (isDev) {
// Dev mode: use the .venv Python with module import
command = process.platform === 'win32'
? path.join(aiserviceDir, '.venv', 'Scripts', 'python.exe')
: path.join(aiserviceDir, '.venv', 'bin', 'python3');
args = ['-m', 'api.main'];
cwd = aiserviceDir;
} else {
// Production: use the PyInstaller-built binary
const aiserviceDist = path.join(aiserviceDir, 'dist', 'aiservice');
command = process.platform === 'win32'
? path.join(aiserviceDist, 'aiservice.exe')
: path.join(aiserviceDist, 'aiservice');
args = [];
cwd = aiserviceDist;
}
pythonProcess = spawn(command, args, {
cwd,
env: {
...process.env,
PYTHONUNBUFFERED: '1',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
pythonProcess.stdout?.on('data', (data: Buffer) => {
console.log(`[Python AI] ${data.toString().trim()}`);
});
pythonProcess.stderr?.on('data', (data: Buffer) => {
console.error(`[Python AI Error] ${data.toString().trim()}`);
});
pythonProcess.on('close', (code: number | null) => {
console.log(`[Python AI] Process exited with code ${code}`);
pythonProcess = null;
});
pythonProcess.on('error', (err: Error) => {
console.error(`[Python AI] Failed to start: ${err.message}`);
pythonProcess = null;
});
}
How it works
spawnPythonService() has two branches:
- Dev mode: Uses
.venv/Scripts/python.exe -m api.main(same as before) - Production mode: Runs
aiservice/dist/aiservice/aiservice.exedirectly with no arguments
npm run desktop still works exactly the same since it uses the dev branch. The production branch only kicks in when frontend/out/index.html exists (i.e., when the app is packaged).
Step 2: Review frontend/api/client.ts
Verdict: No changes needed. The client is already set up correctly for packaged mode.
| Call type | In Electron | In browser |
|---|---|---|
| Express backend (CRUD, database) | client.ts detects window.electronAPI → IPC → api-request channel → main.ts forwards to localhost:3001 |
Falls back to fetch with API_BASE_URL = localhost:3001 |
| Python AI (non-streaming) | window.electronAPI.aiRequest() → IPC → ai-request channel → main.ts forwards to localhost:8000 |
Falls back to fetch with AI_BASE_URL = localhost:8000 |
| Python AI (streaming) | AI_STREAM_BASE = /aiservice → app:// protocol → main.ts proxies to localhost:8000 via net.fetch |
Hits localhost:8000 directly |
Everything routes through IPC in Electron, which then hits localhost. Since both Express and the AI service run on the same ports in packaged mode, no changes are needed.
Step 3: Build the Express Backend
Your electron/main.ts production branch runs the compiled backend:
command = process.execPath; // Electron's built-in Node.js
args = [compiledEntry]; // backend/dist/index.js
So you need backend/dist/index.js to exist — compile your TypeScript backend to JavaScript.
Already in place
tsconfig.jsonwithoutDir: ./distpackage.jsonhasbuildscript (tsc) andstartscript (node dist/index.js)backend/dist/already exists with compiled JS filespaths.tsalready handles dev vs compiled mode (__dirname.includes('/dist'))- Prisma client is generated in
node_modules/.prisma/client
Things to keep in mind for production bundling
- SQLite database — Prisma uses
file:../database/epic.db(relative path). In the packaged app, this path needs to resolve correctly, and the database file needs to be writable by the user. - Prisma client — Lives in
node_modules/.prisma/client. The wholenode_modules/folder needs to be bundled with the backend (or use a bundler likepkgto make a single binary — but that's more complex). - Puppeteer — Bundles a full Chromium browser (~200MB+) for PDF/screenshot generation. Big dependency to include.
.envfiles — Backend loads.envand.env.userfor config/API keys.
What to do now
The backend is already set up for production — almost nothing to do:
tsccompiles tobackend/dist/paths.tsalready handles dev vs compiled pathselectron/main.tsalready runsnode dist/index.jsin production mode
Just make sure dist/ is up to date with your latest code:
cd backend
npm run build
The concern: Unlike the Python service (which PyInstaller bundles into a standalone binary), the Express backend still needs node_modules/ at runtime. Electron ships with Node.js so it can run the JS — but you need to include backend/node_modules/ and backend/database/ in the final bundle.
Step 4: Configure electron-builder and Update package.json
File: electron-builder.yml
# ── Electron Builder Configuration for EPIC ────────────────────
# Build command: npx electron-builder --win (Windows)
# npx electron-builder --mac (macOS)
appId: com.epic.app
productName: EPIC
copyright: Copyright © 2026 EPIC
# ── Directories ────────────────────────────────────────────────
# Where electron-builder outputs installers (.exe, .dmg)
directories:
output: release
# ── Files to include in the app bundle ─────────────────────────
# Copied into the Electron app's resources/app/ directory.
# Everything else is excluded.
files:
- dist-electron/**/* # Compiled Electron main process + preload
- frontend/out/**/* # Frontend static export (served via app://)
- backend/dist/**/* # Backend compiled JS
- backend/node_modules/**/* # Backend runtime dependencies
- backend/package.json
- backend/prisma/**/*
- backend/database/**/*
- package.json # Root package.json (Electron needs "main" field)
# ── Extra resources (unpacked, outside app.asar) ───────────────
# Large binaries that must NOT be inside the asar archive.
# Accessed via process.resourcesPath at runtime.
extraResources:
- from: aiservice/dist/aiservice # PyInstaller-built AI service (~5+ GB)
to: aiservice
filter:
- "**/*"
- from: Guides # User-facing help guides
to: Guides
filter:
- "**/*"
# ── Windows ────────────────────────────────────────────────────
win:
target:
- target: msi
arch:
- x64
icon: electron/assets/icon.ico
# ── macOS ──────────────────────────────────────────────────────
mac:
target:
- target: dmg
arch:
- x64
- arm64
icon: electron/assets/icon.png
category: public.app-category.productivity
dmg:
contents:
- x: 130
y: 220
- x: 410
y: 220
type: link
path: /Applications
# ── asar ───────────────────────────────────────────────────────
# Pack app code into asar archive for faster loading,
# but exclude large files that need direct filesystem access
asar: true
asarUnpack:
- backend/node_modules/.prisma/**/*
- backend/node_modules/@prisma/**/*
File: Root package.json
{
"name": "epic",
"version": "1.0.0",
"private": true,
"main": "dist-electron/main.js",
"scripts": {
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"dev:backend": "cd backend && npm run dev",
"dev:frontend": "cd frontend && npm run dev",
"build:backend": "cd backend && npm run build",
"build:frontend": "cd frontend && npm run build:electron",
"build:electron": "tsc --project electron/tsconfig.json",
"desktop": "npm run build:backend && npm run build:frontend && npm run build:electron && electron dist-electron/main.js",
"desktop:dev": "npm run build:electron && electron dist-electron/main.js",
"install:all": "npm install && cd backend && npm install && cd ../frontend && npm install",
"pack": "npm run build:backend && npm run build:frontend && npm run build:electron && electron-builder --dir",
"dist:win": "npm run build:backend && npm run build:frontend && npm run build:electron && electron-builder --win",
"dist:mac": "npm run build:backend && npm run build:frontend && npm run build:electron && electron-builder --mac"
},
"devDependencies": {
"concurrently": "^8.2.2",
"cross-env": "^10.1.0",
"electron": "^40.4.0",
"electron-builder": "^26.0.0",
"typescript": "^5.9.3"
}
}
What's new:
"main": "dist-electron/main.js"— tells Electron the entry pointelectron-builderas a dev dependency- Three new scripts:
npm run pack— builds everything into a folder (for testing, no installer)npm run dist:win— builds the Windows.exeinstallernpm run dist:mac— builds the macOS.dmginstaller
Step 5: Update Production Paths in main.ts
Three areas in electron/main.ts need updated paths so they resolve correctly inside the packaged app. Dev mode is completely unchanged — all three still use the original path.join(__dirname, '..') paths.
What changed:
spawnPythonService()— Production path now usesprocess.resourcesPath/aiservice/instead of the relative dev path. This is where electron-builder'sextraResourcesputs the PyInstaller binary.spawnExpressService()— Production path now usesapp.getAppPath()/backend/to find the compiled JS inside the packaged app. Also switched dev detection toapp.isPackagedfor clarity.OUT_DIR— Now usesapp.getAppPath()/frontend/out/when packaged, so theapp://protocol serves files from the correct location.
Updated code in electron/main.ts
// ── Express Backend Process Management ────────────────────────
/**
* Spawns the Express backend as a child process.
* In dev mode, uses ts-node-dev for live reload.
* In production, runs the compiled JS from backend/dist/.
*/
function spawnExpressService(): void {
// Kill any orphaned process from a previous run
killPortProcess(EXPRESS_PORT);
const isDev = !app.isPackaged;
let command: string;
let args: string[];
let backendCwd: string;
if (isDev) {
// Dev mode: use ts-node-dev (same as npm run dev:backend)
const backendDir = path.join(__dirname, '..', 'backend');
const tsNodeDev = process.platform === 'win32'
? path.join(backendDir, 'node_modules', '.bin', 'ts-node-dev.cmd')
: path.join(backendDir, 'node_modules', '.bin', 'ts-node-dev');
command = tsNodeDev;
args = ['--respawn', '--transpile-only', 'index.ts'];
backendCwd = backendDir;
} else {
// Production: run compiled JS from inside the asar-unpacked app
const backendDir = path.join(app.getAppPath(), 'backend');
const compiledEntry = path.join(backendDir, 'dist', 'index.js');
command = process.execPath; // Use the same Node.js that Electron ships with
args = [compiledEntry];
backendCwd = backendDir;
}
expressProcess = spawn(command, args, {
cwd: backendCwd,
env: {
...process.env,
PORT: String(EXPRESS_PORT),
},
stdio: ['pipe', 'pipe', 'pipe'],
shell: true,
});
expressProcess.stdout?.on('data', (data: Buffer) => {
console.log(`[Express] ${data.toString().trim()}`);
});
expressProcess.stderr?.on('data', (data: Buffer) => {
console.error(`[Express Error] ${data.toString().trim()}`);
});
expressProcess.on('close', (code: number | null) => {
console.log(`[Express] Process exited with code ${code}`);
expressProcess = null;
});
expressProcess.on('error', (err: Error) => {
console.error(`[Express] Failed to start: ${err.message}`);
expressProcess = null;
});
}
// ── Python Process Management ─────────────────────────────────
/**
* Spawns the Python AI service as a child process.
* The Python server runs on port 8000 and handles all AI/RAG operations.
*/
function spawnPythonService(): void {
// Kill any orphaned process from a previous run
killPortProcess(PYTHON_PORT);
const aiserviceDir = path.join(__dirname, '..', 'aiservice');
const isDev = !require('fs').existsSync(
path.join(__dirname, '..', 'frontend', 'out', 'index.html')
);
let command: string;
let args: string[];
let cwd: string;
if (isDev) {
// Dev mode: use the .venv Python with module import
command = process.platform === 'win32'
? path.join(aiserviceDir, '.venv', 'Scripts', 'python.exe')
: path.join(aiserviceDir, '.venv', 'bin', 'python3');
args = ['-m', 'api.main'];
cwd = aiserviceDir;
} else {
// Production: use the PyInstaller-built binary from extraResources
const aiserviceDist = path.join(process.resourcesPath, 'aiservice');
command = process.platform === 'win32'
? path.join(aiserviceDist, 'aiservice.exe')
: path.join(aiserviceDist, 'aiservice');
args = [];
cwd = aiserviceDist;
}
pythonProcess = spawn(command, args, {
cwd,
env: {
...process.env,
PYTHONUNBUFFERED: '1',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
pythonProcess.stdout?.on('data', (data: Buffer) => {
console.log(`[Python AI] ${data.toString().trim()}`);
});
pythonProcess.stderr?.on('data', (data: Buffer) => {
console.error(`[Python AI Error] ${data.toString().trim()}`);
});
pythonProcess.on('close', (code: number | null) => {
console.log(`[Python AI] Process exited with code ${code}`);
pythonProcess = null;
});
pythonProcess.on('error', (err: Error) => {
console.error(`[Python AI] Failed to start: ${err.message}`);
pythonProcess = null;
});
}
// ── Service Health Polling ────────────────────────────────────
/**
* Waits for a service to be ready by polling its health endpoint.
*/
function waitForService(port: number, name: string, maxRetries = 30): Promise<void> {
return new Promise((resolve, reject) => {
let retries = 0;
let done = false;
const check = () => {
if (done) return;
const req = http.get(`http://localhost:${port}/api/health`, (res) => {
if (done) return;
if (res.statusCode === 200) {
done = true;
console.log(`[${name}] Service is ready on port ${port}`);
resolve();
} else {
retry();
}
});
req.on('error', () => {
if (!done) retry();
});
req.setTimeout(1000, () => {
req.destroy();
if (!done) retry();
});
};
const retry = () => {
if (done) return;
retries++;
if (retries >= maxRetries) {
done = true;
reject(new Error(`[${name}] Service failed to start after ${maxRetries} attempts`));
} else {
setTimeout(check, 1000);
}
};
check();
});
}
/**
* Notify the renderer about service status.
*/
function notifyRenderer(channel: string, data: unknown): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, data);
}
}
// ── Custom Protocol for Static Content (app://) ───────────────
const OUT_DIR = app.isPackaged
? path.join(app.getAppPath(), 'frontend', 'out')
: path.join(__dirname, '..', 'frontend', 'out');
Step 6: Build & Test the Installer
1. Install electron-builder
npm install
2. Before building: fix Windows symlink/signing issues
You'll likely hit errors on the first npm run pack. Fix with either of these:
Option A: Enable Developer Mode
Settings → System → For Developers → Developer Mode ON. This grants symlink creation privileges without needing admin.
Option B: Disable code signing
Since you're sharing unsigned builds with friends, skip code signing entirely. Update the win section in electron-builder.yml:
win:
target:
- target: nsis
arch:
- x64
icon: electron/assets/icon.ico
signAndEditExecutable: false
3. Test the folder layout
npm run pack
This builds everything into a folder (no installer) — good for verifying the layout is correct.
4. Test the pack
On the root folder go to the termial and type
.\release\win-unpacked\EPIC.exe
If something breaks, correct the error and rebuild:
- Rebuild the AI service (if Python code changed):
cd aiservice python build.py - Rebuild the app (front/back/electron — whatever changed):
npm run pack
5. Build the installer
npm run dist:win
6. Test end-to-end
Install and run the output .exe as a regular user would.
Shipping Updates
When you make changes and want a new installer: (Bundle Everything Again) Delete the release/folder and then run
npm run dist:win
You will get the installer (what I share): release/EPIC Setup X.X.X.exe That single file is what you share with friends or poeple for testing and/or what you make downloadable on your website
That's it — dist:win already chains build:backend, build:frontend, build:electron, then electron-builder --win. Share the new EPIC Setup 1.0.0.exe from the release/ folder.
For users: they can either uninstall the old version first or just run the new installer — NSIS usually overwrites the old installation automatically.