Files

13 KiB
Raw Permalink Blame History

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, and build.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:

  1. Installs ContentNegotiation with kotlinx.serialization JSON.
  2. Installs CORS hardcoded for localhost:5173 with credentials.
  3. Creates CacheService(REDIS_URL) and XApiClient(...).
  4. Wires routes under routing { }:
    • loginRoutes() is outside SessionAuth so /api/login and /api/logout are 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-day session cookie containing HMACSHA256("authenticated", APP_SECRET).
  • SessionAuth compares the cookie to that same HMAC for every request except /api/login and /api/logout.
  • If the cookie is missing/invalid, the plugin calls respondRedirect("/login") and then respond(Unauthorized). The redirect is mostly irrelevant for a JSON API; the frontend treats 401 as 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/me endpoint accepts the alias me, but timeline/mentions/like endpoints require the real numeric ID.
  • getTimeline() and getMentions() fetch 50 results and call enrichTweets() to flatten Xs { 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.tweets via the referenced_tweets.id expansion.
  • Media URLs are returned as plain strings; the frontend never renders them as <img> tags.
  • postTweet() throws ReplyNotAuthorizedException when X returns HTTP 403 with body containing not-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

  • CacheService wraps Lettuce with a manual host/port parser because java.net.URI and Lettuces 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 a Mutex to prevent cache stampedes on a cold cache.
  • Posting a tweet or replying deletes timeline:home so 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.tsx is the session gate. On mount it calls fetchTimeline(); 401 means not logged in and shows LoginForm, success means logged in and shows Header + Tabs + active tab.
  • Tabs are timeline, mentions, notifications. notifications is a placeholder explaining the X API does not expose notifications.
  • api.ts centralizes all fetch calls. Every authenticated request uses credentials: 'include' so the session cookie is sent.
  • Feed renders ComposeBox + TweetList(filterable).
  • Mentions is its own loader wrapping TweetList (not filterable).
  • TweetList supports in-place reply via a toggle and a ComposeBox, and a LikeButton that calls likeTweet then onRetry to refresh the list.
  • TweetText linkifies http(s)://, @mentions, and #hashtags with regex /(https?:\/\/[^\s]+|@[\w_]+|#[\w_]+)/g and strips trailing punctuation from URLs.

Naming conventions and style

Kotlin

  • Package: com.rmcampos.twitterclient.
  • Route modules are extension functions on Route named *Routes, e.g. fun Route.timelineRoutes(...).
  • Ktor plugin is a val constant: 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.css and is imported in the component.
  • Types are imported as import { type Tweet } from '../types'.
  • API functions live in api.ts and throw Error with backend message when available.
  • The project uses React 19 but does not enable the React Compiler.

Important gotchas

Backend

  1. No application.conf: everything is env-driven. If a required env var is missing, requireNotNull(...) crashes on startup with a clear message.
  2. CORS is hardcoded: allowHost("localhost:5173"). If you change the frontend origin you must edit Application.kt and rebuild.
  3. 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.
  4. Redis hostname underscores: CacheService parses redisUrl manually because standard URI parsers reject hostnames like twitter_redis. Do not replace that parser with URI(redisUrl).
  5. Timeline cache stampede guard: the Mutex in TimelineRoutes is per-route-instance, not distributed. It is sufficient for a single backend process.
  6. Replies are restricted by X: postTweet with a replyToTweetId may throw ReplyNotAuthorizedException. 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.
  7. 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

  1. VITE_API_URL is build-time only: Vite embeds it at build. Changing the env var after build has no effect. The Docker build passes it as an ARG.
  2. No runtime router: the app uses in-memory tabs, not React Router. /login is not a real route; the backend redirect to /login just lands on the same SPA.
  3. Media is text-only: media arrays contain URLs rendered as plain text paragraphs, not images.
  4. fetchTimeline doubles as the session probe: App.tsx decides logged-in state based on whether the timeline fetch succeeds or returns 401.
  5. Liking does not optimistically update the count: it calls likeTweet then onRetry, which re-fetches the timeline.

Docker / deployment

  1. External network required: docker-compose.yml declares twitter-network as external: true. Create it with docker network create twitter-network before first docker compose up.
  2. Doppler dependency in Taskfile: task docker-build, task docker-up, and task docker-rebuild run doppler run --. If Doppler is not configured, run the underlying docker compose commands directly with env vars exported.
  3. Frontend Docker image builds then previews: npm run preview serves the static build on port 5173. It is not a dev server with HMR.
  4. README mentions Vercel: the top-level README only says to add APP_PASSWORD and APP_SECRET to the Vercel dashboard. There is no Vercel-specific config file in the repo.

How to add a feature

  1. Backend: add a route function under backend/src/main/kotlin/.../routes/, add any client method needed in XApiClient, and register the route in Application.kt after install(SessionAuth) if it needs auth.
  2. Frontend: add the API helper in frontend/src/api.ts, add/update types in frontend/src/types.ts, add a component under frontend/src/components/, and wire it into App.tsx or the appropriate tab.
  3. Cache: if the new feature returns data that should be cached, follow the timelineRoutes pattern (TTL key, mutex, cache invalidation on mutations). If it is user-specific and immutable-ish, consider CacheService.setPersistent like x:user_id.
  4. Env vars: if you introduce new env vars, add them to .env.example, docker-compose.yml, and this file.

Lint / typecheck

  • Backend: ./gradlew build runs Kotlin compilation. There is no explicit lint plugin (ktlint/detekt are not configured).
  • Frontend: npm run lint runs ESLint. npm run build runs 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-eslint 8.62.0.