~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
319 lines · 9.2 KB Raw History
#!/usr/bin/env node

// @pdsjs/readonly CLI - Run a read-only PDS server from CAR files

import fs from 'node:fs';
import { createServer } from 'node:http';
import path from 'node:path';
import Database from 'better-sqlite3';
import { createReadOnlyServer } from './index.js';

/**
 * Parse command line arguments
 * @returns {{ carFiles: string[], blobsDir: string | null, port: number, hostname: string, dataDir: string }}
 */
function parseArgs() {
  const args = process.argv.slice(2);
  /** @type {string[]} */
  const carFiles = [];
  let blobsDir = null;
  let port = 3000;
  let hostname = 'localhost';
  let dataDir = './pds-data';

  for (let i = 0; i < args.length; i++) {
    const arg = args[i];

    if (arg === '--car' || arg === '-c') {
      const value = args[++i];
      if (!value) {
        console.error('Error: --car requires a path');
        process.exit(1);
      }
      // Support glob patterns (Node.js 22+)
      if (value.includes('*')) {
        try {
          // Use dynamic import for glob (Node.js 22+)
          const { globSync } = fs;
          if (typeof globSync === 'function') {
            carFiles.push(...globSync(value));
          } else {
            console.error(
              'Warning: Glob patterns not supported in this Node.js version, please specify files individually',
            );
            carFiles.push(value);
          }
        } catch {
          console.error(
            'Warning: Glob patterns not supported, please specify files individually',
          );
          carFiles.push(value);
        }
      } else {
        carFiles.push(value);
      }
    } else if (arg === '--blobs' || arg === '-b') {
      blobsDir = args[++i];
      if (!blobsDir) {
        console.error('Error: --blobs requires a path');
        process.exit(1);
      }
    } else if (arg === '--port' || arg === '-p') {
      port = parseInt(args[++i], 10);
      if (Number.isNaN(port)) {
        console.error('Error: --port requires a number');
        process.exit(1);
      }
    } else if (arg === '--hostname' || arg === '-H') {
      hostname = args[++i];
      if (!hostname) {
        console.error('Error: --hostname requires a value');
        process.exit(1);
      }
    } else if (arg === '--data-dir' || arg === '-d') {
      dataDir = args[++i];
      if (!dataDir) {
        console.error('Error: --data-dir requires a path');
        process.exit(1);
      }
    } else if (arg === '--help' || arg === '-h') {
      printHelp();
      process.exit(0);
    } else if (arg === '--version' || arg === '-v') {
      const pkg = JSON.parse(
        fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'),
      );
      console.log(pkg.version);
      process.exit(0);
    } else if (arg.startsWith('-')) {
      console.error(`Unknown option: ${arg}`);
      printHelp();
      process.exit(1);
    } else {
      // Positional arguments treated as CAR files
      carFiles.push(arg);
    }
  }

  // Check for CAR_DIR environment variable
  const carDir = process.env.PDS_CAR_DIR;
  if (carDir && carFiles.length === 0) {
    const files = fs.readdirSync(carDir).filter((f) => f.endsWith('.car'));
    carFiles.push(...files.map((f) => path.join(carDir, f)));
  }

  // Check for BLOBS_DIR environment variable
  if (!blobsDir && process.env.PDS_BLOBS_DIR) {
    blobsDir = process.env.PDS_BLOBS_DIR;
  }

  // Check for PORT environment variable
  if (process.env.PDS_PORT) {
    port = parseInt(process.env.PDS_PORT, 10);
  }

  // Check for HOSTNAME environment variable
  if (process.env.PDS_HOSTNAME) {
    hostname = process.env.PDS_HOSTNAME;
  }

  return { carFiles, blobsDir, port, hostname, dataDir };
}

function printHelp() {
  console.log(`
pds-readonly - Run a read-only PDS server from CAR files

Usage:
  pds-readonly [options] [car-files...]

Options:
  --car, -c <path>       Path to CAR file (can be specified multiple times)
  --blobs, -b <path>     Path to blobs directory (structure: blobs/{did}/{shard}/{cid})
  --port, -p <number>    Port to listen on (default: 3000)
  --hostname, -H <host>  PDS hostname for DID resolution (default: localhost)
  --data-dir, -d <path>  Directory for SQLite databases (default: ./pds-data)
  --help, -h             Show this help message
  --version, -v          Show version

Environment Variables:
  PDS_PORT               Port to listen on
  PDS_CAR_DIR            Directory containing CAR files
  PDS_BLOBS_DIR          Blobs directory path
  PDS_HOSTNAME           PDS hostname

Examples:
  # Load single repository
  pds-readonly --car ./repo.car --port 3000

  # Load multiple repositories
  pds-readonly --car ./alice.car --car ./bob.car

  # With blobs support
  pds-readonly --car ./repo.car --blobs ./blobs

  # Using environment variables
  PDS_CAR_DIR=./repos PDS_PORT=8080 pds-readonly
`);
}

async function main() {
  const { carFiles, blobsDir, port, hostname, dataDir } = parseArgs();

  if (carFiles.length === 0) {
    console.error('Error: At least one CAR file is required');
    console.error('Use --help for usage information');
    process.exit(1);
  }

  // Validate CAR files exist
  for (const carFile of carFiles) {
    if (!fs.existsSync(carFile)) {
      console.error(`Error: CAR file not found: ${carFile}`);
      process.exit(1);
    }
  }

  // Validate blobs directory if specified
  if (blobsDir && !fs.existsSync(blobsDir)) {
    console.warn(`Warning: Blobs directory not found: ${blobsDir}`);
    console.warn('Blob requests will return 404');
  }

  console.log('Starting read-only PDS server...');
  console.log(`Data directory: ${dataDir}`);
  console.log(`Hostname: ${hostname}`);
  if (blobsDir) {
    console.log(`Blobs directory: ${blobsDir}`);
  }

  // Create the read-only server
  const { server, loadCar } = createReadOnlyServer({
    hostname,
    dataDir,
    blobsDir: blobsDir || undefined,
    createDb: (dbPath) => new Database(dbPath),
  });

  // Load all CAR files
  console.log(`\nLoading ${carFiles.length} CAR file(s)...`);
  for (const carFile of carFiles) {
    try {
      console.log(`Loading: ${carFile}`);
      await loadCar(carFile);
    } catch (err) {
      console.error(`Error loading ${carFile}:`, err.message);
      process.exit(1);
    }
  }

  // Create HTTP server
  const httpServer = createServer(async (req, res) => {
    try {
      // Convert Node.js request to Web Request
      const url = new URL(req.url || '/', `http://${hostname}:${port}`);
      const headers = new Headers();
      for (const [key, value] of Object.entries(req.headers)) {
        if (value) {
          headers.set(key, Array.isArray(value) ? value.join(', ') : value);
        }
      }

      const body =
        req.method !== 'GET' && req.method !== 'HEAD'
          ? await readBody(req)
          : undefined;

      const request = new Request(url.toString(), {
        method: req.method,
        headers,
        body,
      });

      // Handle request
      const response = await server.fetch(request);

      // Convert Web Response to Node.js response
      res.statusCode = response.status;
      for (const [key, value] of response.headers) {
        res.setHeader(key, value);
      }

      if (response.body) {
        const reader = response.body.getReader();
        const pump = async () => {
          const { done, value } = await reader.read();
          if (done) {
            res.end();
            return;
          }
          res.write(value);
          await pump();
        };
        await pump();
      } else {
        const text = await response.text();
        res.end(text);
      }
    } catch (err) {
      console.error('Request error:', err);
      res.statusCode = 500;
      res.end(
        JSON.stringify({ error: 'InternalServerError', message: err.message }),
      );
    }
  });

  httpServer.listen(port, () => {
    console.log(`\nRead-only PDS server running at http://localhost:${port}`);
    console.log('\nAvailable endpoints:');
    console.log(`  GET  /xrpc/com.atproto.sync.listRepos`);
    console.log(`  GET  /xrpc/com.atproto.sync.getRepo?did=<did>`);
    console.log(
      `  GET  /xrpc/com.atproto.repo.listRecords?repo=<did>&collection=<nsid>`,
    );
    console.log(
      `  GET  /xrpc/com.atproto.repo.getRecord?repo=<did>&collection=<nsid>&rkey=<rkey>`,
    );
    console.log('\nWrite operations return 401 (AuthenticationRequired)');
  });

  // Handle shutdown
  process.on('SIGINT', () => {
    console.log('\nShutting down...');
    httpServer.close();
    process.exit(0);
  });

  process.on('SIGTERM', () => {
    console.log('\nShutting down...');
    httpServer.close();
    process.exit(0);
  });
}

/**
 * Read request body as Uint8Array
 * @param {import('node:http').IncomingMessage} req
 * @returns {Promise<Uint8Array<ArrayBuffer>>}
 */
function readBody(req) {
  return new Promise((resolve, reject) => {
    /** @type {Buffer[]} */
    const chunks = [];
    req.on('data', (chunk) => chunks.push(chunk));
    req.on('end', () => {
      const buffer = Buffer.concat(chunks);
      const arrayBuffer = /** @type {ArrayBuffer} */ (buffer.buffer);
      resolve(
        new Uint8Array(arrayBuffer, buffer.byteOffset, buffer.byteLength),
      );
    });
    req.on('error', reject);
  });
}

main().catch((err) => {
  console.error('Fatal error:', err);
  process.exit(1);
});