A third party ATProto appview
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

Configurable backfill

+186 -134
+37 -6
.env.example
··· 10 10 # psql -U postgres -c "CREATE DATABASE atproto;" 11 11 DATABASE_URL=postgresql://postgres:password@localhost:5432/atproto 12 12 13 + # Redis connection string for caching and metrics 14 + # Format: redis://host:port 15 + # Default: redis://localhost:6379 16 + REDIS_URL=redis://localhost:6379 17 + 13 18 # ============================================ 14 19 # REQUIRED: Security 15 20 # ============================================ ··· 24 29 # Default: wss://bsky.network 25 30 RELAY_URL=wss://bsky.network 26 31 27 - # Enable historical backfill (DANGEROUS - will consume massive resources) 28 - # WARNING: Do NOT enable this in production without proper resource planning 29 - # Backfill will attempt to download and process ALL historical data from the network 30 - # This can take days/weeks and requires significant disk space and memory 31 - # Default: false 32 - ENABLE_BACKFILL=false 32 + # Historical backfill configuration (in days) 33 + # 0 = disabled (no backfill) 34 + # >0 = backfill X days of historical data 35 + # WARNING: Resource-intensive! Each day of backfill can take hours and significant disk space 36 + # Recommended: Start with 1-7 days for testing 37 + # Default: 0 (disabled) 38 + BACKFILL_DAYS=0 39 + 40 + # Data retention configuration (in days) 41 + # 0 = keep all data forever 42 + # >0 = automatically prune posts/likes/reposts older than X days (runs daily) 43 + # Note: User profiles and follows are never pruned (preserves social graph) 44 + # Recommended: 30-90 days for production to manage disk usage 45 + # Default: 0 (keep forever) 46 + DATA_RETENTION_DAYS=0 33 47 34 48 # ============================================ 35 49 # OPTIONAL: AppView Identity ··· 62 76 # Node environment 63 77 # Options: development, production 64 78 NODE_ENV=production 79 + 80 + # Database connection pool size 81 + # Higher values support more concurrent connections 82 + # Default: 32 (docker-compose uses 100 for production) 83 + DB_POOL_SIZE=32 84 + 85 + # Maximum concurrent event processing operations 86 + # Balances throughput vs memory usage 87 + # Default: 80 88 + MAX_CONCURRENT_OPS=80 89 + 90 + # ============================================ 91 + # OPTIONAL: Dashboard Authentication 92 + # ============================================ 93 + # Password for dashboard access (leave blank for no authentication) 94 + # Recommended: Set a strong password in production 95 + DASHBOARD_PASSWORD=
+2 -1
Dockerfile
··· 48 48 ENV REDIS_URL=redis://localhost:6379 49 49 ENV PORT=5000 50 50 ENV APPVIEW_DID=did:web:appview.local 51 - ENV ENABLE_BACKFILL=false 51 + ENV BACKFILL_DAYS=0 52 + ENV DATA_RETENTION_DAYS=0 52 53 ENV DB_POOL_SIZE=32 53 54 ENV MAX_CONCURRENT_OPS=80 54 55 ENV NODE_OPTIONS="--max-old-space-size=2048"
+88 -116
README.md
··· 40 40 41 41 ### Prerequisites 42 42 - PostgreSQL database 43 + - Redis (for caching and metrics) 43 44 - Node.js 20+ 44 45 - (Optional) Domain for `did:web` identifier 45 46 ··· 47 48 48 49 1. **Clone and Install** 49 50 ```bash 50 - git clone <your-repo> 51 - cd at-protocol-appview 51 + git clone <your-repo> PublicAppView 52 + cd PublicAppView 52 53 npm install 53 54 ``` 54 55 ··· 74 75 75 76 ### Building the Docker Image 76 77 77 - 1. **Create a Dockerfile** 78 - ```dockerfile 79 - FROM node:20-alpine AS builder 78 + The Dockerfile is already included in the repository. It uses a multi-stage build with: 79 + - Node.js 20-slim base image 80 + - PM2 cluster mode for multi-worker deployment 81 + - Automatic database migrations on startup 82 + - Health checks and production optimizations 80 83 81 - WORKDIR /app 82 - 83 - # Copy package files 84 - COPY package*.json ./ 85 - 86 - # Install all dependencies (including devDependencies for build) 87 - RUN npm ci 88 - 89 - # Copy source code 90 - COPY . . 91 - 92 - # Build the application 93 - RUN npm run build 94 - 95 - # Production stage 96 - FROM node:20-alpine 97 - 98 - WORKDIR /app 99 - 100 - # Copy package files 101 - COPY package*.json ./ 102 - 103 - # Install only production dependencies 104 - RUN npm ci --production 105 - 106 - # Copy built application from builder 107 - COPY --from=builder /app/dist ./dist 108 - COPY --from=builder /app/client/dist ./client/dist 109 - 110 - # Expose port 111 - EXPOSE 5000 112 - 113 - # Start the application 114 - CMD ["npm", "start"] 115 - ``` 116 - 117 - 2. **Build the Image** 84 + **Build the Image** 118 85 ```bash 119 - docker build -t at-protocol-appview . 86 + # Build all services (use --no-cache for clean build) 87 + sudo docker-compose build --no-cache 88 + 89 + # Or build without cache flag 90 + sudo docker-compose build 120 91 ``` 121 92 122 93 ### Running with Docker 123 94 124 - **Basic Run** 95 + **Basic Run (requires external PostgreSQL and Redis)** 125 96 ```bash 126 97 docker run -d \ 127 - --name appview \ 98 + --name at-protocol-appview \ 128 99 -p 5000:5000 \ 129 100 -e DATABASE_URL="postgresql://user:pass@host:5432/dbname" \ 101 + -e REDIS_URL="redis://host:6379" \ 130 102 -e SESSION_SECRET="your-secure-secret" \ 131 103 -e NODE_ENV="production" \ 132 104 at-protocol-appview 133 105 ``` 134 106 135 - **With Docker Compose** 136 - 137 - Create `docker-compose.yml`: 138 - ```yaml 139 - version: '3.8' 107 + **With Docker Compose (Recommended)** 140 108 141 - services: 142 - appview: 143 - build: . 144 - ports: 145 - - "5000:5000" 146 - environment: 147 - DATABASE_URL: postgresql://user:pass@postgres:5432/appview 148 - SESSION_SECRET: ${SESSION_SECRET} 149 - NODE_ENV: production 150 - APPVIEW_DID: did:web:your-domain.com 151 - restart: unless-stopped 152 - depends_on: 153 - - postgres 154 - healthcheck: 155 - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:5000/health"] 156 - interval: 10s 157 - timeout: 5s 158 - retries: 3 159 - 160 - postgres: 161 - image: postgres:16-alpine 162 - environment: 163 - POSTGRES_DB: appview 164 - POSTGRES_USER: user 165 - POSTGRES_PASSWORD: pass 166 - volumes: 167 - - postgres_data:/var/lib/postgresql/data 168 - restart: unless-stopped 169 - 170 - volumes: 171 - postgres_data: 172 - ``` 109 + A complete `docker-compose.yml` is included in the repository with: 110 + - **Redis** service (in-memory caching and metrics) 111 + - **PostgreSQL** service (database with production tuning) 112 + - **App** service (AppView with all dependencies) 173 113 174 - Then run: 114 + Start all services: 175 115 ```bash 176 116 docker-compose up -d 177 117 ``` 178 118 179 - ### Monitoring Docker Container 119 + The docker-compose setup includes: 120 + - PostgreSQL 14 with 5000 max connections and 20GB shared buffers 121 + - Redis 7 with 8GB memory and LRU eviction 122 + - Health checks for all services 123 + - Automatic dependency ordering 124 + - Volume persistence for database 180 125 181 - **View Container Status** 126 + ### Monitoring Docker Services 127 + 128 + **View Service Status** 182 129 ```bash 183 - # Check if container is running 184 - docker ps | grep appview 130 + # Check all services 131 + docker-compose ps 185 132 186 - # View detailed container info 187 - docker inspect appview 133 + # View detailed service info 134 + docker-compose logs 188 135 189 136 # Check resource usage 190 - docker stats appview 137 + docker stats 191 138 ``` 192 139 193 140 **View Logs** 194 141 ```bash 195 - # Follow logs in real-time 196 - docker logs -f appview 142 + # Follow all logs in real-time 143 + docker-compose logs -f 144 + 145 + # Follow specific service logs 146 + docker-compose logs -f app 147 + docker-compose logs -f db 148 + docker-compose logs -f redis 197 149 198 150 # View last 100 lines 199 - docker logs --tail 100 appview 151 + docker-compose logs --tail 100 app 200 152 201 - # View logs with timestamps 202 - docker logs -t appview 153 + # Or use container names directly 154 + docker logs -f publicappview-app-1 155 + docker logs --tail 100 publicappview-db-1 203 156 ``` 204 157 205 158 **Health Checks** 206 159 ```bash 207 - # Check health status 208 - docker inspect --format='{{.State.Health.Status}}' appview 160 + # Check service health 161 + docker-compose ps 209 162 210 163 # Manual health check 211 164 curl http://localhost:5000/health 212 165 curl http://localhost:5000/ready 213 166 214 - # View health check logs 215 - docker inspect --format='{{json .State.Health}}' appview | jq 167 + # View health status 168 + docker inspect --format='{{.State.Health.Status}}' publicappview-app-1 216 169 ``` 217 170 218 171 **Performance Monitoring** 219 172 ```bash 220 - # Real-time stats (CPU, memory, network) 221 - docker stats appview --no-stream 173 + # Real-time stats (all services) 174 + docker stats 222 175 223 - # Export metrics 224 - docker stats appview --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}" 176 + # Specific containers 177 + docker stats publicappview-app-1 publicappview-db-1 publicappview-redis-1 225 178 ``` 226 179 227 - **Container Management** 180 + **Service Management** 228 181 ```bash 229 - # Stop container 230 - docker stop appview 182 + # Stop all services 183 + docker-compose stop 231 184 232 - # Start container 233 - docker start appview 185 + # Start all services 186 + docker-compose start 187 + 188 + # Restart all services 189 + docker-compose restart 190 + 191 + # Restart specific service 192 + docker-compose restart app 234 193 235 - # Restart container 236 - docker restart appview 194 + # Remove all services 195 + docker-compose down 237 196 238 - # Remove container 239 - docker rm -f appview 197 + # Remove with volumes (WARNING: deletes data) 198 + docker-compose down -v 240 199 ``` 241 200 242 201 **Entering the Container** 243 202 ```bash 244 - # Open shell in running container 245 - docker exec -it appview sh 203 + # Open shell in app container 204 + docker-compose exec app sh 246 205 247 206 # Run commands in container 248 - docker exec appview npm run db:push 207 + docker-compose exec app npm run db:push 208 + 209 + # Or use container name directly (note: Docker Compose uses directory name as prefix) 210 + docker exec -it publicappview-app-1 sh 249 211 ``` 250 212 213 + **Note:** Docker Compose creates container names using the pattern `{directory-name}-{service-name}-{number}`. Since this repo is cloned to `PublicAppView`, the actual container names are: 214 + - `publicappview-app-1` (main AppView service) 215 + - `publicappview-db-1` (PostgreSQL database) 216 + - `publicappview-redis-1` (Redis cache) 217 + 251 218 ## Environment Variables 252 219 253 220 ### Required 254 221 - `DATABASE_URL`: PostgreSQL connection string 222 + - `REDIS_URL`: Redis connection string (default: `redis://localhost:6379`) 255 223 - `SESSION_SECRET`: JWT secret (generate with `openssl rand -base64 32`) 256 224 257 225 ### Optional ··· 259 227 - `APPVIEW_DID`: DID for this AppView instance (default: `did:web:appview.local`) 260 228 - `PORT`: Server port (default: `5000`) 261 229 - `NODE_ENV`: Environment mode (`development` or `production`) 262 - - `ENABLE_BACKFILL`: Enable historical backfill (**NOT recommended**, default: `false`) 230 + - `BACKFILL_DAYS`: Historical backfill in days (0=disabled, >0=backfill X days, default: `0`) 231 + - `DATA_RETENTION_DAYS`: Auto-prune old data (0=keep forever, >0=prune after X days, default: `0`) 232 + - `DASHBOARD_PASSWORD`: Optional password for dashboard authentication 233 + - `DB_POOL_SIZE`: Database connection pool size (default: `32`) 234 + - `MAX_CONCURRENT_OPS`: Max concurrent event processing (default: `80`) 263 235 264 236 ## Production Deployment 265 237
+2 -1
docker-compose.yml
··· 43 43 - SESSION_SECRET=${SESSION_SECRET:-change-this-to-a-random-secret-in-production} 44 44 - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-} 45 45 - APPVIEW_DID=${APPVIEW_DID:-did:web:appview.local} 46 - - ENABLE_BACKFILL=${ENABLE_BACKFILL:-false} 46 + - BACKFILL_DAYS=${BACKFILL_DAYS:-0} 47 + - DATA_RETENTION_DAYS=${DATA_RETENTION_DAYS:-0} 47 48 - DB_POOL_SIZE=100 48 49 - MAX_CONCURRENT_OPS=80 49 50 - PORT=5000
+51 -9
server/services/backfill.ts
··· 30 30 private readonly BATCH_SIZE = 100; // Process in batches for memory efficiency 31 31 private readonly PROGRESS_SAVE_INTERVAL = 1000; // Save progress every 1000 events 32 32 private readonly MAX_EVENTS_PER_RUN = 100000; // Limit for safety 33 + private readonly backfillDays: number; 34 + private cutoffDate: Date | null = null; 33 35 34 36 constructor( 35 37 private relayUrl: string = process.env.RELAY_URL || "wss://bsky.network" 36 - ) {} 38 + ) { 39 + // 0 or not set = backfill disabled, >0 = backfill X days of historical data 40 + const backfillDaysRaw = parseInt(process.env.BACKFILL_DAYS || "0"); 41 + this.backfillDays = !isNaN(backfillDaysRaw) && backfillDaysRaw >= 0 ? backfillDaysRaw : 0; 42 + 43 + if (process.env.BACKFILL_DAYS && isNaN(backfillDaysRaw)) { 44 + console.warn(`[BACKFILL] Invalid BACKFILL_DAYS value "${process.env.BACKFILL_DAYS}" - using default (0)`); 45 + } 46 + } 37 47 38 48 async start(startCursor?: string): Promise<void> { 39 49 if (this.isRunning) { 40 50 throw new Error("Backfill is already running"); 41 51 } 42 52 43 - if (process.env.ENABLE_BACKFILL !== "true") { 44 - console.warn("[BACKFILL] Backfill is disabled. Set ENABLE_BACKFILL=true to enable."); 53 + if (this.backfillDays === 0) { 54 + console.log("[BACKFILL] Backfill is disabled (BACKFILL_DAYS=0)"); 45 55 return; 46 56 } 47 57 48 - console.log("[BACKFILL] Starting historical backfill..."); 49 - logCollector.info("Starting historical backfill", { startCursor }); 58 + // Calculate cutoff date - only backfill data from the last X days 59 + this.cutoffDate = new Date(); 60 + this.cutoffDate.setDate(this.cutoffDate.getDate() - this.backfillDays); 61 + 62 + console.log(`[BACKFILL] Starting historical backfill for last ${this.backfillDays} days (since ${this.cutoffDate.toISOString()})...`); 63 + logCollector.info("Starting historical backfill", { 64 + startCursor, 65 + backfillDays: this.backfillDays, 66 + cutoffDate: this.cutoffDate.toISOString() 67 + }); 50 68 51 69 this.isRunning = true; 52 70 this.progress = { ··· 103 121 this.progress.currentCursor = String((commit as any).seq); 104 122 } 105 123 124 + // Check if any record is older than cutoff date (stop backfilling if so) 125 + if (this.cutoffDate) { 126 + for (const op of commit.ops) { 127 + if (op.action !== 'delete' && 'record' in op && op.record) { 128 + const record = op.record as any; 129 + if (record.createdAt) { 130 + const recordDate = new Date(record.createdAt); 131 + if (recordDate < this.cutoffDate) { 132 + console.log(`[BACKFILL] Reached cutoff date (${this.cutoffDate.toISOString()}). Stopping backfill.`); 133 + logCollector.info("Backfill reached cutoff date", { 134 + recordDate: recordDate.toISOString(), 135 + cutoffDate: this.cutoffDate.toISOString(), 136 + eventsProcessed: this.progress.eventsProcessed 137 + }); 138 + await this.stop(); 139 + resolve(); 140 + return; 141 + } 142 + } 143 + } 144 + } 145 + } 146 + 106 147 const event = { 107 148 repo: commit.repo, 108 149 ops: commit.ops.map((op) => { ··· 206 247 207 248 if (this.client) { 208 249 try { 209 - this.client.removeAllListeners(); 210 - // @ts-ignore 211 - if (typeof this.client.close === 'function') { 212 - this.client.close(); 250 + if (typeof (this.client as any).removeAllListeners === 'function') { 251 + (this.client as any).removeAllListeners(); 252 + } 253 + if (typeof (this.client as any).close === 'function') { 254 + (this.client as any).close(); 213 255 } 214 256 } catch (error) { 215 257 console.error("[BACKFILL] Error closing client:", error);
+6 -1
server/services/data-pruning.ts
··· 10 10 11 11 constructor() { 12 12 // 0 = keep forever, >0 = prune after X days 13 - this.retentionDays = parseInt(process.env.DATA_RETENTION_DAYS || "0"); 13 + const retentionDaysRaw = parseInt(process.env.DATA_RETENTION_DAYS || "0"); 14 + this.retentionDays = !isNaN(retentionDaysRaw) && retentionDaysRaw >= 0 ? retentionDaysRaw : 0; 15 + 16 + if (process.env.DATA_RETENTION_DAYS && isNaN(retentionDaysRaw)) { 17 + console.warn(`[DATA_PRUNING] Invalid DATA_RETENTION_DAYS value "${process.env.DATA_RETENTION_DAYS}" - using default (0)`); 18 + } 14 19 15 20 if (this.retentionDays > 0) { 16 21 console.log(`[DATA_PRUNING] Enabled - will prune content older than ${this.retentionDays} days`);