Architecture
Overview
Ibis is a multi-process system deployed via Docker Compose. The API server is a single stateless Go binary. State is split across two backing stores: a PostgreSQL database for relational metadata and a blob store for ROM binaries. An optional Kafka consumer bridges events from external build pipelines.
Browser
โ
โโ ConnectRPC (HTTP/2, port 8080 via Traefik) โโโบ ibis API (:9000)
โ โ
โโ HTTP GET /nes/<path> โโโบ Traefik โโโบ Nginx (:80) โ
โโโ PostgreSQL
โโโ Blob store (fileblob / S3)
โโโ Kafka (optional)
Components
API server (cmd/api/main.go)
- Written in Go, compiled to a static binary.
- Serves two ConnectRPC services (
GameService,InstanceService) over a singlehttp.ServeMuxwith h2c (HTTP/2 cleartext). - CORS is applied per-handler using
connectrpc.com/corsallowed headers. - Listens on
API_ADDR(default:9000). /healthreturns a static{"status":"ok"}liveness payload;/inforeturns{"service","commit","build_time"}, withcommit/build_timeinjected at build time via-ldflags(GIT_HASH/BUILD_TIMEbuild args in the Dockerfile, wired up in CI).- OpenTelemetry is initialized via
github.com/holmes89/archaea/telemetry(service nameibis); requests are wrapped inotelhttp.NewHandlerand a logging middleware logs method/path/trace ID/span ID per request. Telemetry is a graceful noop if no OTel collector is configured โ startup only logs a warning and continues.
Traefik reverse proxy
Traefik sits in front of all services. Dynamic configuration in traefik.d/:
service-ibis.yaml: routesPathPrefix('/schemas.ibis.services.v1')toh2c://ibis:9000(preserves HTTP/2 for ConnectRPC).file-server.yaml: routesPathPrefix('/nes')tohttp://nginx:80(ROM binary downloads).
Traefik exposes port 8080 (HTTP), 9000 (gRPC passthrough), and 9090 (Prometheus metrics).
Nginx file server
Serves the ./data volume at the root with a permissive Access-Control-Allow-Origin: * header so browsers can fetch ROM binaries cross-origin.
PostgreSQL + Goose migrations
lib/repo/sql/local implements both repository interfaces against a PostgreSQL database. Two embedded SQL migrations are applied automatically at startup:
0000001_game.up.sqlโ creates thegametable.0000002_instance.up.sqlโ creates theinstancetable.
Squirrel is used to build parameterised SQL queries.
Blob storage (lib/bucket)
The Bucket interface abstracts upload, download, delete, and exists operations. The only implementation is S3Bucket backed by gocloud.dev/blob. The URL scheme determines the driver:
file://./data/โ local filesystem (development).s3://bucket-name?...โ AWS S3 or any S3-compatible store (e.g., MinIO).
Kafka listener (lib/listener)
GameBuiltListener consumes *goblinsharkv1.GameBuilt protobuf events โ the message type published by Goblin Shark, an external build pipeline that produces playable ROMs/artifacts. It uses github.com/holmes89/archaea/kafka’s generic Consumer[T] (built on franz-go) in consumer group ibis-game-built; archaea derives the topic name from the fully-qualified Go type of the message (goblinsharkv1.GameBuilt), not a hand-picked string. On receiving an event it maps the event fields (GameUuid, Name, Path, GameType, BuiltAt) onto an ibisv1.Game and upserts it into PostgreSQL via the same GameRepository interface used by the API โ auto-registering games without a manual CLI upload. It is started as a goroutine in the API server process and is optional: if KAFKA_BROKERS is not set the server starts without it.
Domain services
Two domain packages follow the same layered pattern:
grpc handler โ service (business logic) โ repository interface
โ
sql/local (PostgreSQL)
lib/ibis/gameโGameServicewraps game CRUD. On creation it derives the blob path as/<platform>/<uuid>, uploads the binary, then persists metadata.lib/ibis/instanceโInstanceServicewraps instance CRUD.UpdateInstancefetches the current record, replaces thestatebytes, and persists.
CLI (cmd/)
A Cobra CLI that connects to the API over plain gRPC with insecure credentials. Authentication uses Firebase Identity Toolkit: ibis login calls the signInWithPassword REST endpoint and stores the returned ID token in cli.yaml. Subsequent commands attach the token as a Bearer authorization gRPC metadata header. Interactive forms use promptui with a generic reflection-based Form[T] struct.
Frontend (ui/)
A Vite + React 18 SPA using Mantine for UI components. The ui/lib directory is also published as a standalone npm library exposing reusable ConnectRPC clients and React components. The application uses React Router v6 with three routes. Games are displayed in a Mantine Table; clicking a title navigates to the emulator view which renders a <canvas> and boots @holmes89/nes-js.
Request lifecycle: play a game
- Browser navigates to
/games/:id. GetGamecomponent callsGameService.GetGameover ConnectRPC through Traefik on port8080.- The API retrieves the game row from PostgreSQL and returns
path(/nes/<uuid>). - The component calls
NesLoad(canvasId, "http://localhost:8080/nes/<uuid>"). - Traefik routes the GET to Nginx, which reads the file from the shared
./datavolume and streams the ROM bytes. @holmes89/nes-jsloads the ROM, sets up display/audio, and starts the emulation loop.
Request lifecycle: upload a game (CLI)
ibis create gameprompts for title, platform, and path.- Binary is read from disk.
GameService.CreateGameis called with the protobuf message carrying both metadata and binary bytes.- The API uploads the binary to the blob store at
/<platform>/<uuid>. - Metadata is inserted into PostgreSQL.
Deployment considerations
- The API server is stateless; multiple replicas can run behind a load balancer as long as they share the same PostgreSQL instance and blob store.
- The Kafka consumer runs inside the API process; running multiple replicas with the same consumer group ID means Kafka will distribute partitions, which is safe.
- The Dockerfile produces a minimal
scratch-based image. CA certificates are copied from the build stage to support TLS connections to S3.