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 single http.ServeMux with h2c (HTTP/2 cleartext).
  • CORS is applied per-handler using connectrpc.com/cors allowed headers.
  • Listens on API_ADDR (default :9000).
  • /health returns a static {"status":"ok"} liveness payload; /info returns {"service","commit","build_time"}, with commit/build_time injected at build time via -ldflags (GIT_HASH/BUILD_TIME build args in the Dockerfile, wired up in CI).
  • OpenTelemetry is initialized via github.com/holmes89/archaea/telemetry (service name ibis); requests are wrapped in otelhttp.NewHandler and 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: routes PathPrefix('/schemas.ibis.services.v1') to h2c://ibis:9000 (preserves HTTP/2 for ConnectRPC).
  • file-server.yaml: routes PathPrefix('/nes') to http://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:

  1. 0000001_game.up.sql โ€” creates the game table.
  2. 0000002_instance.up.sql โ€” creates the instance table.

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 โ€” GameService wraps game CRUD. On creation it derives the blob path as /<platform>/<uuid>, uploads the binary, then persists metadata.
  • lib/ibis/instance โ€” InstanceService wraps instance CRUD. UpdateInstance fetches the current record, replaces the state bytes, 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

  1. Browser navigates to /games/:id.
  2. GetGame component calls GameService.GetGame over ConnectRPC through Traefik on port 8080.
  3. The API retrieves the game row from PostgreSQL and returns path (/nes/<uuid>).
  4. The component calls NesLoad(canvasId, "http://localhost:8080/nes/<uuid>").
  5. Traefik routes the GET to Nginx, which reads the file from the shared ./data volume and streams the ROM bytes.
  6. @holmes89/nes-js loads the ROM, sets up display/audio, and starts the emulation loop.

Request lifecycle: upload a game (CLI)

  1. ibis create game prompts for title, platform, and path.
  2. Binary is read from disk.
  3. GameService.CreateGame is called with the protobuf message carrying both metadata and binary bytes.
  4. The API uploads the binary to the blob store at /<platform>/<uuid>.
  5. 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.