A minimal AT Protocol Personal Data Server written in JavaScript.
atproto
pds
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});