A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
at main 302 lines 9.1 kB view raw
1#!/usr/bin/env node 2// @pds/readonly CLI - Run a read-only PDS server from CAR files 3 4import { createServer } from 'node:http'; 5import { createReadOnlyServer } from './index.js'; 6import path from 'node:path'; 7import fs from 'node:fs'; 8import Database from 'better-sqlite3'; 9 10/** 11 * Parse command line arguments 12 * @returns {{ carFiles: string[], blobsDir: string | null, port: number, hostname: string, dataDir: string }} 13 */ 14function parseArgs() { 15 const args = process.argv.slice(2); 16 /** @type {string[]} */ 17 const carFiles = []; 18 let blobsDir = null; 19 let port = 3000; 20 let hostname = 'localhost'; 21 let dataDir = './pds-data'; 22 23 for (let i = 0; i < args.length; i++) { 24 const arg = args[i]; 25 26 if (arg === '--car' || arg === '-c') { 27 const value = args[++i]; 28 if (!value) { 29 console.error('Error: --car requires a path'); 30 process.exit(1); 31 } 32 // Support glob patterns (Node.js 22+) 33 if (value.includes('*')) { 34 try { 35 // Use dynamic import for glob (Node.js 22+) 36 const { globSync } = fs; 37 if (typeof globSync === 'function') { 38 carFiles.push(...globSync(value)); 39 } else { 40 console.error('Warning: Glob patterns not supported in this Node.js version, please specify files individually'); 41 carFiles.push(value); 42 } 43 } catch { 44 console.error('Warning: Glob patterns not supported, please specify files individually'); 45 carFiles.push(value); 46 } 47 } else { 48 carFiles.push(value); 49 } 50 } else if (arg === '--blobs' || arg === '-b') { 51 blobsDir = args[++i]; 52 if (!blobsDir) { 53 console.error('Error: --blobs requires a path'); 54 process.exit(1); 55 } 56 } else if (arg === '--port' || arg === '-p') { 57 port = parseInt(args[++i], 10); 58 if (isNaN(port)) { 59 console.error('Error: --port requires a number'); 60 process.exit(1); 61 } 62 } else if (arg === '--hostname' || arg === '-H') { 63 hostname = args[++i]; 64 if (!hostname) { 65 console.error('Error: --hostname requires a value'); 66 process.exit(1); 67 } 68 } else if (arg === '--data-dir' || arg === '-d') { 69 dataDir = args[++i]; 70 if (!dataDir) { 71 console.error('Error: --data-dir requires a path'); 72 process.exit(1); 73 } 74 } else if (arg === '--help' || arg === '-h') { 75 printHelp(); 76 process.exit(0); 77 } else if (arg === '--version' || arg === '-v') { 78 const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8')); 79 console.log(pkg.version); 80 process.exit(0); 81 } else if (arg.startsWith('-')) { 82 console.error(`Unknown option: ${arg}`); 83 printHelp(); 84 process.exit(1); 85 } else { 86 // Positional arguments treated as CAR files 87 carFiles.push(arg); 88 } 89 } 90 91 // Check for CAR_DIR environment variable 92 const carDir = process.env.PDS_CAR_DIR; 93 if (carDir && carFiles.length === 0) { 94 const files = fs.readdirSync(carDir).filter((f) => f.endsWith('.car')); 95 carFiles.push(...files.map((f) => path.join(carDir, f))); 96 } 97 98 // Check for BLOBS_DIR environment variable 99 if (!blobsDir && process.env.PDS_BLOBS_DIR) { 100 blobsDir = process.env.PDS_BLOBS_DIR; 101 } 102 103 // Check for PORT environment variable 104 if (process.env.PDS_PORT) { 105 port = parseInt(process.env.PDS_PORT, 10); 106 } 107 108 // Check for HOSTNAME environment variable 109 if (process.env.PDS_HOSTNAME) { 110 hostname = process.env.PDS_HOSTNAME; 111 } 112 113 return { carFiles, blobsDir, port, hostname, dataDir }; 114} 115 116function printHelp() { 117 console.log(` 118pds-readonly - Run a read-only PDS server from CAR files 119 120Usage: 121 pds-readonly [options] [car-files...] 122 123Options: 124 --car, -c <path> Path to CAR file (can be specified multiple times) 125 --blobs, -b <path> Path to blobs directory (structure: blobs/{did}/{shard}/{cid}) 126 --port, -p <number> Port to listen on (default: 3000) 127 --hostname, -H <host> PDS hostname for DID resolution (default: localhost) 128 --data-dir, -d <path> Directory for SQLite databases (default: ./pds-data) 129 --help, -h Show this help message 130 --version, -v Show version 131 132Environment Variables: 133 PDS_PORT Port to listen on 134 PDS_CAR_DIR Directory containing CAR files 135 PDS_BLOBS_DIR Blobs directory path 136 PDS_HOSTNAME PDS hostname 137 138Examples: 139 # Load single repository 140 pds-readonly --car ./repo.car --port 3000 141 142 # Load multiple repositories 143 pds-readonly --car ./alice.car --car ./bob.car 144 145 # With blobs support 146 pds-readonly --car ./repo.car --blobs ./blobs 147 148 # Using environment variables 149 PDS_CAR_DIR=./repos PDS_PORT=8080 pds-readonly 150`); 151} 152 153async function main() { 154 const { carFiles, blobsDir, port, hostname, dataDir } = parseArgs(); 155 156 if (carFiles.length === 0) { 157 console.error('Error: At least one CAR file is required'); 158 console.error('Use --help for usage information'); 159 process.exit(1); 160 } 161 162 // Validate CAR files exist 163 for (const carFile of carFiles) { 164 if (!fs.existsSync(carFile)) { 165 console.error(`Error: CAR file not found: ${carFile}`); 166 process.exit(1); 167 } 168 } 169 170 // Validate blobs directory if specified 171 if (blobsDir && !fs.existsSync(blobsDir)) { 172 console.warn(`Warning: Blobs directory not found: ${blobsDir}`); 173 console.warn('Blob requests will return 404'); 174 } 175 176 console.log('Starting read-only PDS server...'); 177 console.log(`Data directory: ${dataDir}`); 178 console.log(`Hostname: ${hostname}`); 179 if (blobsDir) { 180 console.log(`Blobs directory: ${blobsDir}`); 181 } 182 183 // Create the read-only server 184 const { server, loadCar } = createReadOnlyServer({ 185 hostname, 186 dataDir, 187 blobsDir: blobsDir || undefined, 188 createDb: (dbPath) => new Database(dbPath), 189 }); 190 191 // Load all CAR files 192 console.log(`\nLoading ${carFiles.length} CAR file(s)...`); 193 for (const carFile of carFiles) { 194 try { 195 console.log(`Loading: ${carFile}`); 196 await loadCar(carFile); 197 } catch (err) { 198 console.error(`Error loading ${carFile}:`, err.message); 199 process.exit(1); 200 } 201 } 202 203 // Create HTTP server 204 const httpServer = createServer(async (req, res) => { 205 try { 206 // Convert Node.js request to Web Request 207 const url = new URL(req.url || '/', `http://${hostname}:${port}`); 208 const headers = new Headers(); 209 for (const [key, value] of Object.entries(req.headers)) { 210 if (value) { 211 headers.set(key, Array.isArray(value) ? value.join(', ') : value); 212 } 213 } 214 215 const body = req.method !== 'GET' && req.method !== 'HEAD' 216 ? await readBody(req) 217 : undefined; 218 219 const request = new Request(url.toString(), { 220 method: req.method, 221 headers, 222 body, 223 }); 224 225 // Handle request 226 const response = await server.fetch(request); 227 228 // Convert Web Response to Node.js response 229 res.statusCode = response.status; 230 for (const [key, value] of response.headers) { 231 res.setHeader(key, value); 232 } 233 234 if (response.body) { 235 const reader = response.body.getReader(); 236 const pump = async () => { 237 const { done, value } = await reader.read(); 238 if (done) { 239 res.end(); 240 return; 241 } 242 res.write(value); 243 await pump(); 244 }; 245 await pump(); 246 } else { 247 const text = await response.text(); 248 res.end(text); 249 } 250 } catch (err) { 251 console.error('Request error:', err); 252 res.statusCode = 500; 253 res.end(JSON.stringify({ error: 'InternalServerError', message: err.message })); 254 } 255 }); 256 257 httpServer.listen(port, () => { 258 console.log(`\nRead-only PDS server running at http://localhost:${port}`); 259 console.log('\nAvailable endpoints:'); 260 console.log(` GET /xrpc/com.atproto.sync.listRepos`); 261 console.log(` GET /xrpc/com.atproto.sync.getRepo?did=<did>`); 262 console.log(` GET /xrpc/com.atproto.repo.listRecords?repo=<did>&collection=<nsid>`); 263 console.log(` GET /xrpc/com.atproto.repo.getRecord?repo=<did>&collection=<nsid>&rkey=<rkey>`); 264 console.log('\nWrite operations return 401 (AuthenticationRequired)'); 265 }); 266 267 // Handle shutdown 268 process.on('SIGINT', () => { 269 console.log('\nShutting down...'); 270 httpServer.close(); 271 process.exit(0); 272 }); 273 274 process.on('SIGTERM', () => { 275 console.log('\nShutting down...'); 276 httpServer.close(); 277 process.exit(0); 278 }); 279} 280 281/** 282 * Read request body as Uint8Array 283 * @param {import('node:http').IncomingMessage} req 284 * @returns {Promise<Uint8Array>} 285 */ 286function readBody(req) { 287 return new Promise((resolve, reject) => { 288 /** @type {Buffer[]} */ 289 const chunks = []; 290 req.on('data', (chunk) => chunks.push(chunk)); 291 req.on('end', () => { 292 const buffer = Buffer.concat(chunks); 293 resolve(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)); 294 }); 295 req.on('error', reject); 296 }); 297} 298 299main().catch((err) => { 300 console.error('Fatal error:', err); 301 process.exit(1); 302});