8.9 KiB
Development
Working on go-bananas.
Requirements
| Tool | Version | Why |
|---|---|---|
| Go | 1.27 or later | Static binaries, net/http routing primitives |
| Node.js | 22 or later | Vite 8 requires it |
| Docker | 24 or later | BuildKit for the multi-stage image |
| Helm | 3.14 or later | Chart validation and helm lint --strict |
Optional, installed by make tools: air for hot reload, golangci-lint for
linting, sqlc if you add a query.
Setup
git clone https://github.com/urpi/go-bananas
cd go-bananas
make install # Go modules, admin UI dependencies, SDK dependencies
make tools # air, golangci-lint, sqlc
Running
make dev-all # API on :8080, admin UI on :5173 with a proxy (builds the SDK first)
Open http://localhost:5173. The Vite dev server proxies /api, /rt and /auth
to the Go process, so the session cookie works exactly as it will in production.
Same-origin matters: a cross-origin setup would not authenticate at all, and the
problem would look like a bug rather than a topology difference.
The admin UI is itself a consumer of the runtime: the header theme selector and the
light/dark toggle are the shipped controls, loaded from /rt/v1/components.js, which
the API serves from sdk/dist (Vite proxies /rt to it). make dev-all builds
the SDK up front for exactly this reason — an unbuilt sdk/dist means those controls
never define and the switcher silently falls back.
Other entry points:
make dev # the API with hot reload (air); serves web/dist and sdk/dist, so `make build` first
make dev-api # the API alone, no hot reload; serves the runtime SDK, Vite serves the UI
make dev-web # the admin UI alone (its /api and /rt proxy to :8080)
make run # build everything, run the real binary
Trying the theme preference locally
The theme switcher in the header writes to the account, not just to the browser, so the selection follows you to another browser and survives a re-login. To see that end to end without a second browser:
# 1. Start the stack and sign in. `make dev-all` defaults to AUTH_MODE=dev, so the
# dev-user query parameter is enough to be somebody:
# http://localhost:5173/?dev-user=designer@example.test
# 2. Pick a published theme and a scheme in the header switcher. That is a PUT to
# /api/v1/me/preferences.
# 3. Read back what the server decided, which is the authoritative value:
curl -s localhost:8080/api/v1/me/preferences \
-H 'Cookie: go-bananas-session=...' | jq
# 4. Confirm it arrives with the session, which is how the UI gets it on first paint:
curl -s localhost:8080/api/v1/auth/me \
-H 'Cookie: go-bananas-session=...' | jq .preferences
# 5. Prove it is account state rather than cookie state by clearing local storage,
# or by asking in a second browser profile: the theme you chose is still selected.
Two things are deliberately not local state:
- The theme is a preference, not a session claim. The token comes from the identity
provider, so this service cannot put anything in it;
/api/v1/me/preferencesis the contract instead. localStorageis still read on the first render. A server round trip cannot happen before paint, and waiting for one flashes the wrong theme on every load. The stored preference is adopted as soon as the session arrives, and a change writes both places, so the next paint is correct even offline.
Pointing it at a draft theme returns 400, and pointing it at a theme that has since been
archived reads back as no choice. Both are covered in docs/api.md.
Layout
cmd/go-bananas/ main(): signals, exit codes, nothing else
internal/
app/ dependency injection and the process lifecycle
auth/ OIDC, sessions, roles
config/ environment parsing and validation
domain/ entities, invariants, sentinel errors
httpapi/ routing, middleware, handlers
logging/ slog configuration
seed/ the built-in themes
store/ SQLite: migrations and repositories
tokenengine/ pure logic: colour, validation, rendering
store/migrations/ embedded SQL, applied at start-up
web/ the administration UI (React, Vite, Tailwind)
sdk/ the runtime SDKs, served from /rt/v1
deploy/helm/go-bananas/ the chart
docs/ this, plus the ADRs
See architecture.md for the dependency rule and standards.md for the conventions. Read both before a substantial change.
Tests
make test # everything
make test-go # Go, with the race detector
make test-web # the admin UI
make test-sdk # the runtime SDK
make test-cover # Go, with a coverage profile
make bench # the token engine benchmarks
Every test run in CI is go test -race. A data race in a cache or a store is a
production incident waiting for load.
The pre-merge gate
make verify # check + helm lint
This is exactly what CI runs. A pull request that does not pass it will not merge.
Things that will surprise you
The admin UI consumes the runtime. It loads themes from /rt/v1 and maps the
variables onto Tailwind's namespaces. Switching the UI to the "Light" or "Dark" mode
in the top bar loads a published theme's real stylesheet. A change to the token engine
that breaks delivery breaks the editor, immediately.
Publishing bumps the version, and the version is the cache key. Every mutation
does, not only publication. If you change store.Update, check whether the version
needs to move.
History is append-only. A rollback restores a snapshot as a new version. There is no code path that removes a version, by design: an application deployed against version 7 must never have version 7 taken away.
The built-in themes are reconciled, not overwritten. internal/seed compares the
stored version with the seed version. An edited built-in theme is recognised by its
higher version and left alone.
Validation gates publication, not editing. A draft can be incomplete. That is what makes building a theme up incrementally practical.
Common changes
Adding a token group
- Add a
GroupSpectotokenengine.Groups. - Add the tokens to the built-in themes in
internal/seed. - Run
make test-go. The seed tests validate every built-in theme and check WCAG contrast for body text on every surface, so an inaccessible colour fails the build.
Adding an API endpoint
- The handler in
internal/httpapi. No business logic; call the store, encode the result. - The route in
internal/httpapi/router.go, with the role it requires. - The type in
web/src/lib/types.tsand the client function inweb/src/lib/api.ts. - The documentation in
docs/api.md. - Tests for each branch and each authorisation decision.
Adding a configuration variable
- The field and default in
internal/config. - The parsing in
loadFrom. - A validation rule in
Validateif it can be wrong. - The environment variable in the chart's
configmap.yamlandvalues.yaml, and invalues.schema.json. - The table in
docs/deployment.md.
A variable with no validation is a variable that will be set to something wrong.
Changing the runtime contract
Add, do not change. /rt/v1 is a contract with applications that have no way to
upgrade in step with you. Adding an optional query parameter or a response field is
safe. Changing the meaning of an existing one is not.
Conventions
Enforced, not suggested. make check is the same command CI runs.
| Go formatting | gofmt, make fmt-check |
| Go lint | golangci-lint, make lint-go |
| Go imports | gci, configured in .golangci.yml |
| TypeScript | tsc --build, strict |
| Frontend lint | eslint --max-warnings 0 |
| Frontend format | prettier, including the Tailwind plugin |
| Chart | helm lint --strict plus values.schema.json |
Read standards.md before contributing. It is the difference between a change that fits and one that has to be rewritten.
Troubleshooting
Port 8080 in use. make dev-api with BANANAS_HTTP_ADDR=:8081, or stop whatever
holds the port.
The admin UI cannot reach the API. The Vite proxy targets
http://localhost:8080. If the API is elsewhere, edit server.proxy in
vite.config.ts.
npm ci fails. A stale node_modules. make clean-cache.
go build fails after a dependency change. make tidy, then make install.
A test is flaky. Do not re-run it and hope. Find the ordering dependency. The most
common cause here is module-level state in a frontend test: use vi.resetModules()
and a fresh module instance.
The chart will not render. Read the error. Every chart failure names the value to change — that is deliberate, and the alternative is a failure at run time instead.