./api/main.ts/index.html), policed by mod.test.ts; deno doc has no module index of its own beyond all_symbols.html, which lists every module by path with its summary. mod.ts is now a prose overview of the three parts and their layers, linking that list once, and mod.test.ts is gone. Re-exporting everything from mod.ts, as deno doc's tutorial does for a library, was tried and doesn't fit: export * drops the 18 routers (default exports) and makes 17 names ambiguous, and deno 2.9 renders export * as namespaces with the root's module doc and nested ones empty, losing every module doc. A router's page showed one line: its endpoint list sat on the default symbol, whose summary was cut at the colon introducing it; the list is now the module doc of all 18 routers, and the router's JSDoc says so. 22 summaries ended in a colon (the routers, BandcampPlayer, migration 0016, useDumpListSync, usePlaylistListSync) and read cut short wherever deno doc shows a first paragraph alone; they end in a full stop, doc-lint.ts now fails on a colon summary, and the README states the rule. No behaviour changes; 204 backend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gerbeur
A small invite-only social platform for sharing links and files. Users can post URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote, comment, follow each other, build playlists, and search content. A real-time WebSocket layer handles live presence, vote counts, and notifications. The UI is localized (English and French) and ships multiple visual themes.
Stack
| Layer | Technology |
|---|---|
| Runtime | Deno 2.x |
| API | Oak (HTTP + WebSocket) |
| Database | SQLite via node:sqlite |
| Frontend | React 19 + Vite 8 |
Development
Prerequisites
- Deno 2.x
Setup
cp .env.example .env
# Edit .env: set GERBEUR_JWT_SECRET to the output of: openssl rand -hex 32
Run
deno task dev
This starts both the API server (port 8000, with file watching) and the Vite dev server (port 3000) concurrently.
Open http://localhost:3000. On first run a default admin / admin account is created — change the password immediately.
Environment variables
See .env.example for the full list with descriptions. Key variables:
| Variable | Description | Default |
|---|---|---|
GERBEUR_JWT_SECRET |
JWT signing secret — required, generate with openssl rand -hex 32 |
— |
GERBEUR_PUBLIC_URL |
Public-facing URL of the server (no trailing slash) — used for CORS, WebSocket origin, email links, OG URLs | http://localhost:GERBEUR_PORT |
GERBEUR_PORT |
Internal port Oak listens on | 8000 |
GERBEUR_LISTEN_HOST |
Network interface Oak binds to; use 127.0.0.1 to restrict to loopback |
0.0.0.0 |
GERBEUR_FRONTEND_URL |
Frontend base URL for email links and CORS; auto-added to allowed origins — the only variable needed when the frontend runs on a separate host | GERBEUR_PUBLIC_URL |
GERBEUR_ALLOWED_ORIGINS |
Comma-separated extra origins for CORS/WebSocket; PUBLIC_URL and FRONTEND_URL are always included — typically only needed in dev for the Vite server |
"" (empty) |
GERBEUR_SITE_NAME |
Site name used in the browser tab, app header, OG meta tags, and emails | gerbeur |
GERBEUR_SMTPS_URL |
SMTPS connection URL for outgoing email (smtps://user:pass@host:465) |
unset |
GERBEUR_FROM_EMAIL |
Sender address for outgoing emails — required when GERBEUR_SMTPS_URL is set |
unset |
GERBEUR_WELCOME_EMAIL_BODY |
Markdown body for the account-creation welcome email; supports {{username}} and {{site_name}} |
built-in template |
VITE_API_PROTOCOL |
API protocol baked into the frontend bundle (see Production) | http |
VITE_API_HOSTNAME |
API hostname baked into the frontend bundle | localhost |
VITE_API_PORT |
API port baked into the frontend bundle | 8000 |
Production
Docker (recommended)
The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no VITE_API_* build args needed. Set GERBEUR_PUBLIC_URL to the externally-visible URL; it is automatically allowed for HTTP/WebSocket requests.
docker build -t gerbeur .
docker build -t gerbeur .
docker run -d \
-p 8000:8000 \
-v gerbeur-db:/app/api/sql \
-v gerbeur-uploads:/app/api/uploads \
-e GERBEUR_JWT_SECRET=$(openssl rand -hex 32) \
-e GERBEUR_PUBLIC_URL=https://example.com \
-e GERBEUR_SITE_NAME=mysite \
--name gerbeur \
gerbeur
The two volumes are required for persistence:
gerbeur-db— SQLite database (api/sql/gerbeur.db), initialized automatically on first rungerbeur-uploads— user-uploaded files (api/uploads/)
Separate API and frontend (optional)
If you need to run the API on a different host than the frontend, pass the API location as build args so it gets baked into the frontend bundle. In that setup, add the frontend origin to GERBEUR_ALLOWED_ORIGINS so cross-origin HTTP/WebSocket requests are accepted:
docker build \
--build-arg VITE_API_PROTOCOL=https \
--build-arg VITE_API_HOSTNAME=api.example.com \
--build-arg VITE_API_PORT=443 \
-t gerbeur .
Reverse proxy
Put a reverse proxy (nginx, Caddy, …) in front of the container to handle TLS. Forward everything to port 8000. Example Caddyfile:
example.com {
reverse_proxy localhost:8000
}
Project structure
shared/ # Imported by both halves: API and socket types, limits, permissions
test/ # Tests of shared/ (Deno): test/<module>.test.ts
api/
main.ts # Entry point — Oak application, middleware, routes
config.ts # Environment variables
middleware/ # Oak middleware: errors, auth, OG tags
routes/ # One router per URL prefix: HTTP + WebSocket, no SQL
services/ # Business logic and every SQL query
remote/ # Everything that fetches from other sites
providers/ # Link metadata providers (YouTube, SoundCloud, Bandcamp, …)
model/ # Per table: row type, row guard, row ↔ API mappers
validators/ # Per domain: request body guards
lib/ # Helpers with no SQL and no outbound fetch: JWT, slugs, files, …
db/
connection.ts # The database handle and transaction()
startup.ts # Housekeeping run when the database opens
schema.sql # Database schema
init.ts # First-run database initialisation
migrate.ts # Migration runner
migrations/ # Versioned schema migrations
test/ # Backend tests (Deno): test/<layer>/<module>.test.ts
sql/
gerbeur.db # SQLite database (not committed)
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed)
src/ # React frontend (Vite)
config/ # API base URL, feed tabs, player mode
pages/ # One component per route, nothing else
components/ # React components only (.tsx)
contexts/ # FooContext.ts + FooProvider.tsx pairs
hooks/ # Every hook (useFoo.ts)
utils/ # Every non-React module: pure helpers, storage, API glue
test/ # Frontend tests (Vitest): test/<layer>/<domain>/<Module>.test.tsx
locales/ # Lingui message catalogues (en, fr)
themes/ # Per-theme CSS files
i18n.ts # Lingui runtime setup
public/ # Static assets (favicon, manifest, service worker)
Whatever crosses the wire is defined once, in shared/: the API envelope
(shared/api.ts), each domain's types and request bodies (shared/dump.ts,
shared/user.ts, …), the socket protocol (shared/ws.ts), the validation
limits and the role permissions. The server sends Dates; the client reads
Wire<T>, the same type with every Date an ISO string, and turns it back with
src/utils/shared/deserialize.ts. shared/ imports nothing from either half.
The backend is split by layer, one kind of module per folder, and each file is
named after its domain: the dumps are routes/dumps.ts,
services/dump-service.ts, model/dump.ts and validators/dump.ts. Routes
parse and answer, services hold the logic and the SQL, remote/ is the only
place that fetches from other sites. Tests mirror the source one file per
module, so the tests of services/chat-service.ts are in
test/services/chat-service.test.ts.
All three parts — api/, shared/ and src/ — are documented for deno doc,
the same way in every file. Each module opens with a /** … @module */ block
saying what it is for. Every export has a JSDoc comment, and so does every field
of an exported interface; a comment that describes a declaration is always /** */, and // is only for notes on the code inside. Functions open with a verb
("Returns…", "Checks…", "Renders…" for a component), everything else with a noun
phrase. Parameters are named in the prose, in backticks. {@link} points at
symbols the file already has in scope. A function that can throw to its caller
says so with @throws: for an APIException, the statuses and when. A router's
module doc lists its endpoints, one line each: method and path, who may call it,
what it does; a page's JSDoc names its route and who may see it. The first
paragraph of a doc is its summary, which the reference shows alone in lists, so
it is a sentence that stands on its own: it never ends in a colon introducing
what follows.
In the frontend, every exported function states its return type, so the
reference can show it: a component returns React.JSX.Element (or | null), a
hook returning several things returns a documented Use<Name>Result interface.
A component that takes props takes an exported <Name>Props interface, each
prop documented. React's types are used through the global React namespace
rather than a default import of react, which deno doc can't follow.
deno task doc builds the HTML reference into docs/ from every module of
api/, shared/ and src/ but the tests (and the generated src/locales/).
Its index page is the module doc of mod.ts at the root, which introduces the
repository's three parts and their layers in prose; deno doc builds the list
of every module itself, grouped by path. The docs workflow publishes it on
every push to main that touches them. deno task doc:lint checks the rules
the linter can see, and that no summary ends in a colon, through doc-lint.ts.
deno doc doesn't document npm packages, so it takes a type imported from one
(react-hook-form's FieldValues, say) for a private one, and those references
alone are let through.
Each of the frontend's components/, contexts/, hooks/ and utils/ is
split into the same domain folders — auth, category, chat, comment,
dump, editor, feed, form, layout, player, playlist, profile,
social, theme, ws — plus shared/ for what belongs to no single domain.
So the chat lives in components/chat/, contexts/chat/, hooks/chat/ and
utils/chat/, and its tests in test/<same path>. Files are imported directly
(no index.ts barrels), and everything uses named exports.