Back to Happy

Portable Single-Binary Distribution

docs/plans/portable-binary.md

35.5 KB
Original Source

Portable Single-Binary Distribution

Overview

Create a portable, self-contained distribution of happy-server as a single Bun-compiled binary. It runs without Redis (already has in-memory event bus), uses PGlite for embedded PostgreSQL, and local filesystem for file storage. CLI provides happy-server migrate and happy-server serve commands.

Context

  • Event bus: Already 100% in-memory (eventRouter.ts). Redis is only used for redis.ping() health check — zero actual pub/sub usage. @socket.io/redis-streams-adapter is a dependency but never imported in source code.
  • Database: Prisma with PostgreSQL. pglite-prisma-adapter provides a Prisma driver adapter for PGlite. Requires driverAdapters preview feature in schema.
  • File storage: S3/Minio used for image uploads (avatar uploads via GitHub connect). Used in uploadImage.ts, files.ts, referenced in eventRouter.ts, accountRoutes.ts, type.ts.
  • Migrations: 36 SQL migration files in prisma/migrations/. PGlite adapter doesn't support prisma migrate CLI, so we apply SQL files directly via PGlite.
  • Bun+PGlite compile limitation: There's a known Bun issue where PGlite WASM files can't be embedded in --compile output. Workaround: copy postgres.data/postgres.wasm files next to binary.

Development Approach

  • Complete each task fully before moving to the next
  • Make small, focused changes
  • Minimal changes to existing code — prefer conditional paths over rewrites
  • Test by running the standalone binary after build

Implementation Steps

Task 1: Add PGlite + adapter dependencies

  • Add @electric-sql/pglite, pglite-prisma-adapter to happy-server dependencies
  • Add driverAdapters to previewFeatures in prisma/schema.prisma generator block
  • Run prisma generate to regenerate client with adapter support
  • Verify existing server still works (no breaking changes from preview feature)

Task 2: Make database layer PGlite-aware

  • Modify sources/storage/db.ts to conditionally use PGlite when PGLITE_DIR env var is set
    • If PGLITE_DIR is set: create PGlite instance with that dir, wrap in PrismaPGlite adapter, pass to new PrismaClient({ adapter })
    • If not set: use existing new PrismaClient() (connects via DATABASE_URL as before)
  • Export a getPGlite() function for direct SQL access (needed by migration command)

Task 3: Make Redis optional

  • In main.ts, make redis.ping() conditional — only if REDIS_URL env var is set
  • Skip redis import when not needed (dynamic import or guard)

Task 4: Replace S3 with local filesystem storage

  • Modify sources/storage/files.ts:
    • If S3 env vars are set: use existing Minio client (no change)
    • If not: use local filesystem under DATA_DIR/files/ directory
  • Modify sources/storage/uploadImage.ts:
    • Replace s3client.putObject with conditional: S3 or fs.writeFile to local path
    • Replace resolveImageUrl to return local file-serving URL when in local mode
  • Add a static file serving route in API for local files (e.g., /files/*)

Task 5: Create CLI entry point with migrate command

  • Create sources/standalone.ts as the portable entry point:
    • Parse process.argv for subcommands: migrate, serve
    • migrate: initialize PGlite directly, read all prisma/migrations/*/migration.sql files in order, execute them via PGlite SQL, track applied migrations
    • serve: call existing main() logic
    • No args or --help: print usage
  • Embed migration SQL files at build time (Bun can import text files)

Task 6: Add Bun build configuration

  • Add build:standalone script to package.json: bun build ./sources/standalone.ts --compile --outfile happy-server
  • Handle PGlite WASM files: add a post-build step to copy postgres data files next to the binary
  • Test the build: bun run build:standalone
  • Test: ./happy-server migrate creates and migrates a PGlite database
  • Test: ./happy-server serve starts the server with PGlite

Task 7: Verify end-to-end

  • Build the binary
  • Run ./happy-server migrate — verify database is created in ./data/
  • Run ./happy-server serve — verify server starts, health endpoint responds
  • Verify no Redis connection attempted
  • Verify files can be stored/served locally

Technical Details

PGlite initialization:

typescript
import { PGlite } from '@electric-sql/pglite';
import { PrismaPGlite } from 'pglite-prisma-adapter';
const pglite = new PGlite(process.env.PGLITE_DIR || './data/pglite');
const adapter = new PrismaPGlite(pglite);
const db = new PrismaClient({ adapter });

Migration approach:

  • Read SQL files from prisma/migrations/ sorted by directory name (timestamp order)
  • Create a _prisma_migrations table to track applied migrations
  • For each unapplied migration: execute SQL, record in tracking table
  • This mirrors what prisma migrate deploy does

Data directory structure:

./data/               (or $DATA_DIR)
  pglite/             # PGlite database files
  files/              # Uploaded files (replaces S3)
    public/users/...  # Same path structure as S3

CLI usage:

happy-server migrate    # Apply database migrations
happy-server serve      # Start the server

Post-Completion

  • Document env vars for portable mode (PGLITE_DIR, DATA_DIR, HANDY_MASTER_SECRET)
  • Test on Linux for cross-platform binary (Bun cross-compile)
  • Consider adding happy-server init command for first-time setup