13 KiB
AGENTS.md — Working in twitter-client
This repository is a small Twitter/X client with a Kotlin/Ktor backend and a React/Vite frontend. It is intentionally minimal: read timeline, post/reply/like tweets, view mentions, and a placeholder notifications tab. The backend talks to the X API v2 using OAuth 1.0a and caches the home timeline in Redis.
Progressive disclosure note: This file focuses on non-obvious conventions, gotchas, and commands. Basic project structure is discoverable from
ls,package.json, andbuild.gradle.kts.
Project layout
twitter-client/
├── backend/ # Kotlin + Ktor 2.3.12, Gradle 8.9/8.10, JDK 21
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ ├── Dockerfile
│ └── src/main/kotlin/com/rmcampos/twitterclient/
│ ├── Application.kt # Main, DI by hand, installs plugins/routes
│ ├── auth/ # Session cookie auth plugin
│ ├── cache/ # Redis (Lettuce) wrapper
│ ├── client/ # XApiClient — OAuth 1.0a + response enrichment
│ └── routes/ # Ktor route modules
├── frontend/ # React 19 + TypeScript + Vite
│ ├── package.json
│ ├── vite.config.ts
│ ├── Dockerfile
│ └── src/
│ ├── api.ts # All backend fetch helpers
│ ├── types.ts # Domain types
│ ├── App.tsx # Session gate + tabs
│ └── components/
├── docker-compose.yml # Backend + Redis + frontend
├── Taskfile.yml # Docker shortcuts via `task`
├── doppler.yaml # Doppler project config
├── .env.example # Required env vars
└── README.md
There are no test files currently (backend/src/test does not exist, no *.test.ts in frontend). The backend build.gradle.kts still pulls in ktor-server-tests-jvm and kotlin-test-junit, but nothing uses them.
Essential commands
Backend
# Run locally (requires REDIS_URL and all X API env vars)
cd backend
./gradlew run
# Build fat JAR (used by Docker image)
./gradlew buildFatJar
# Standard Gradle build
./gradlew build
The backend is a plain Ktor application with application { mainClass.set("com.rmcampos.twitterclient.ApplicationKt") }. It runs on port 8080 and reads all configuration from environment variables; there is no application.conf or HOCON config.
Frontend
cd frontend
npm install
npm run dev # Vite dev server, port 5173
npm run build # tsc + vite build
npm run preview # Preview production build
npm run lint # ESLint (typescript-eslint + react-hooks + react-refresh)
The frontend expects VITE_API_URL at build time. In Docker it is injected via docker-compose.yml as VITE_API_URL=http://localhost:8080. For local dev, create frontend/.env with VITE_API_URL=http://localhost:8080.
Docker / full stack
The project uses Docker Compose with an external network twitter-network. You must create it once:
docker network create twitter-network
Then use task (Taskfile) or raw docker compose:
# Build images
task docker-build # doppler run -- docker compose build
# Start everything
task docker-up # doppler run -- docker compose up -d
# Stop
task docker-down # docker compose down
# Rebuild and restart
task docker-rebuild # doppler run -- docker compose up -d --build
All task commands that touch secrets are wrapped with doppler run --. If you do not use Doppler, set the environment variables from .env.example yourself and run docker compose directly.
Environment variables
Backend requires all of these at runtime:
| Variable | Purpose |
|---|---|
APP_PASSWORD |
Single shared password for the web UI login |
APP_SECRET |
HMAC-SHA256 key used to sign/verify the session cookie |
APP_CONSUMER_KEY |
X API OAuth 1.0a app consumer key |
APP_CONSUMER_KEY_SECRET |
X API OAuth 1.0a app consumer secret |
APP_ACCESS_TOKEN |
X API user access token |
APP_ACCESS_TOKEN_SECRET |
X API user access token secret |
REDIS_URL |
Redis URI, e.g. redis://localhost:6379 or redis://twitter_redis:6379 |
CORS_ALLOWED_ORIGIN |
Allowed CORS origin, e.g. http://localhost:5173 |
NOTIFICATION_DB_PATH |
SQLite file path, e.g. /data/notifications.db |
Frontend build-time:
| Variable | Purpose |
|---|---|
VITE_API_URL |
Base URL for all API calls, baked into the bundle |
See .env.example for a template. Do not commit real secrets.
Architecture and control/data flow
Backend
Application.kt bootstraps everything by hand:
- Installs
ContentNegotiationwithkotlinx.serializationJSON. - Installs
CORShardcoded forlocalhost:5173with credentials. - Creates
CacheService(REDIS_URL)andXApiClient(...). - Wires routes under
routing { }:loginRoutes()is outsideSessionAuthso/api/loginand/api/logoutare public.install(SessionAuth)guards everything after it.- Then
timelineRoutes,mentionsRoutes,tweetRoutes,userRoutes.
Authentication
- Login is a single shared password (
APP_PASSWORD). - On success the backend sets an
httpOnly,secure, 30-daysessioncookie containingHMACSHA256("authenticated", APP_SECRET). SessionAuthcompares the cookie to that same HMAC for every request except/api/loginand/api/logout.- If the cookie is missing/invalid, the plugin calls
respondRedirect("/login")and thenrespond(Unauthorized). The redirect is mostly irrelevant for a JSON API; the frontend treats401as logged-out.
X API client
XApiClient is the only place that talks to X. Key behaviors:
- OAuth 1.0a signing is implemented manually in
buildOAuth1Header/sign(backend/src/main/kotlin/.../client/XApiClient.kt:267). resolveUserId()caches the numeric user ID in Redis (x:user_id) and in memory. The/2/users/meendpoint accepts the aliasme, but timeline/mentions/like endpoints require the real numeric ID.getTimeline()andgetMentions()fetch 50 results and callenrichTweets()to flatten X’s{ data, includes }shape into{ data: [{ author, media, likes, is_reply, is_quote, is_retweet }] }.- Retweet text is truncated in the top-level tweet; the full text is pulled from
includes.tweetsvia thereferenced_tweets.idexpansion. - Media URLs are returned as plain strings; the frontend never renders them as
<img>tags. postTweet()throwsReplyNotAuthorizedExceptionwhen X returns HTTP 403 with body containingnot-authorized-for-resource. This is a platform restriction: X does not allow programmatic replies to posts that mention or quote the authenticated account (anti-spam, all tiers).likeTweet()posts to/2/users/{userId}/likes.
Caching
CacheServicewraps Lettuce with a manual host/port parser becausejava.net.URIand Lettuce’s parser cannot handle underscores in hostnames (e.g.twitter_redis). It also retries the Redis connection up to 10 times with 500ms backoff on startup.- Only the home timeline is cached (
timeline:home, TTL 10 minutes) with aMutexto prevent cache stampedes on a cold cache. - Posting a tweet or replying deletes
timeline:homeso the next load refreshes from X. - Mentions and user profile are not cached.
- User ID is cached persistently in Redis (
set, no TTL).
Frontend
App.tsxis the session gate. On mount it callsfetchTimeline();401means not logged in and showsLoginForm, success means logged in and showsHeader+Tabs+ active tab.- Tabs are
timeline,mentions,notifications.notificationsis a placeholder explaining the X API does not expose notifications. api.tscentralizes all fetch calls. Every authenticated request usescredentials: 'include'so the session cookie is sent.FeedrendersComposeBox+TweetList(filterable).Mentionsis its own loader wrappingTweetList(not filterable).TweetListsupports in-place reply via a toggle and aComposeBox, and aLikeButtonthat callslikeTweetthenonRetryto refresh the list.TweetTextlinkifieshttp(s)://,@mentions, and#hashtagswith regex/(https?:\/\/[^\s]+|@[\w_]+|#[\w_]+)/gand strips trailing punctuation from URLs.
Naming conventions and style
Kotlin
- Package:
com.rmcampos.twitterclient. - Route modules are extension functions on
Routenamed*Routes, e.g.fun Route.timelineRoutes(...). - Ktor plugin is a
valconstant:val SessionAuth = createRouteScopedPlugin(...). - No DI framework; dependencies are created in
main()and passed explicitly into route functions. - Exception messages are user-facing and returned directly in JSON error responses.
TypeScript / React
- Components are named functions with explicit prop interfaces, exported from
components/ComponentName.tsx. - CSS lives in a sibling
ComponentName.cssand is imported in the component. - Types are imported as
import { type Tweet } from '../types'. - API functions live in
api.tsand throwErrorwith backend message when available. - The project uses React 19 but does not enable the React Compiler.
Important gotchas
Backend
- No
application.conf: everything is env-driven. If a required env var is missing,requireNotNull(...)crashes on startup with a clear message. - CORS is hardcoded:
allowHost("localhost:5173"). If you change the frontend origin you must editApplication.ktand rebuild. - Session cookie is
secure=true: in local non-HTTPS dev the browser will not send the cookie unless you serve the frontend over HTTPS or change the cookie flags. The Docker setup does not use TLS, so local browser testing may require disabling secure flags or using a reverse proxy. - Redis hostname underscores:
CacheServiceparsesredisUrlmanually because standard URI parsers reject hostnames liketwitter_redis. Do not replace that parser withURI(redisUrl). - Timeline cache stampede guard: the
MutexinTimelineRoutesis per-route-instance, not distributed. It is sufficient for a single backend process. - Replies are restricted by X:
postTweetwith areplyToTweetIdmay throwReplyNotAuthorizedException. The route maps this to HTTP 403 with a friendly message. Do not try to work around this with different API tiers — it is a platform policy. - No tests: if you add tests, wire them in
backend/build.gradle.kts(dependencies already present) and add a frontend test runner if desired.
Frontend
VITE_API_URLis build-time only: Vite embeds it at build. Changing the env var after build has no effect. The Docker build passes it as anARG.- No runtime router: the app uses in-memory tabs, not React Router.
/loginis not a real route; the backend redirect to/loginjust lands on the same SPA. - Media is text-only:
mediaarrays contain URLs rendered as plain text paragraphs, not images. fetchTimelinedoubles as the session probe:App.tsxdecides logged-in state based on whether the timeline fetch succeeds or returns 401.- Liking does not optimistically update the count: it calls
likeTweetthenonRetry, which re-fetches the timeline.
Docker / deployment
- External network required:
docker-compose.ymldeclarestwitter-networkasexternal: true. Create it withdocker network create twitter-networkbefore firstdocker compose up. - Doppler dependency in Taskfile:
task docker-build,task docker-up, andtask docker-rebuildrundoppler run --. If Doppler is not configured, run the underlyingdocker composecommands directly with env vars exported. - Frontend Docker image builds then previews:
npm run previewserves the static build on port 5173. It is not a dev server with HMR. - README mentions Vercel: the top-level README only says to add
APP_PASSWORDandAPP_SECRETto the Vercel dashboard. There is no Vercel-specific config file in the repo.
How to add a feature
- Backend: add a route function under
backend/src/main/kotlin/.../routes/, add any client method needed inXApiClient, and register the route inApplication.ktafterinstall(SessionAuth)if it needs auth. - Frontend: add the API helper in
frontend/src/api.ts, add/update types infrontend/src/types.ts, add a component underfrontend/src/components/, and wire it intoApp.tsxor the appropriate tab. - Cache: if the new feature returns data that should be cached, follow the
timelineRoutespattern (TTL key, mutex, cache invalidation on mutations). If it is user-specific and immutable-ish, considerCacheService.setPersistentlikex:user_id. - Env vars: if you introduce new env vars, add them to
.env.example,docker-compose.yml, and this file.
Lint / typecheck
- Backend:
./gradlew buildruns Kotlin compilation. There is no explicit lint plugin (ktlint/detekt are not configured). - Frontend:
npm run lintruns ESLint.npm run buildruns TypeScript and Vite build together.
Dependencies of note
- Backend: Ktor 2.3.12 (Netty server, CIO client, CORS, content negotiation, kotlinx JSON), Lettuce 6.3.2, Logback 1.5.6, Kotlin 2.0.20, JDK 21.
- Frontend: React 19.2.7, Vite 8.1.1, TypeScript ~6.0.2, ESLint 10.6.0,
typescript-eslint8.62.0.