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.exe directly 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 = /aiserviceapp:// 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.json with outDir: ./dist
  • package.json has build script (tsc) and start script (node dist/index.js)
  • backend/dist/ already exists with compiled JS files
  • paths.ts already 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 whole node_modules/ folder needs to be bundled with the backend (or use a bundler like pkg to make a single binary — but that's more complex).
  • Puppeteer — Bundles a full Chromium browser (~200MB+) for PDF/screenshot generation. Big dependency to include.
  • .env files — Backend loads .env and .env.user for config/API keys.

What to do now

The backend is already set up for production — almost nothing to do:

  • tsc compiles to backend/dist/
  • paths.ts already handles dev vs compiled paths
  • electron/main.ts already runs node dist/index.js in 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 point
  • electron-builder as 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 .exe installer
    • npm run dist:mac — builds the macOS .dmg installer

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 uses process.resourcesPath/aiservice/ instead of the relative dev path. This is where electron-builder's extraResources puts the PyInstaller binary.
  • spawnExpressService() — Production path now uses app.getAppPath()/backend/ to find the compiled JS inside the packaged app. Also switched dev detection to app.isPackaged for clarity.
  • OUT_DIR — Now uses app.getAppPath()/frontend/out/ when packaged, so the app:// 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:

  1. Rebuild the AI service (if Python code changed):
    cd aiservice
    python build.py
    
  2. 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.