chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/readonly - Read-only PDS server for serving AT Protocol repositories
// Loads repositories from CAR files and serves read-only XRPC endpoints
import fs from 'node:fs';
import path from 'node:path';
import { createFsBlobs } from '@pdsjs/blobs-fs';
import { loadRepositoryFromCar, PersonalDataServer } from '@pdsjs/core';
import { createActorStorage, createSharedStorage } from '@pdsjs/storage-sqlite';
/**
* @typedef {import('@pdsjs/storage-sqlite/driver').SQLiteDatabase} SQLiteDatabase
*/
/**
* @typedef {Object} RepoInfo
* @property {string} did - Repository DID
* @property {string} commitCid - Commit CID
* @property {string} rev - Revision string
* @property {number} recordCount - Number of records
* @property {number} blockCount - Number of blocks
*/
/**
* @typedef {Object} MultiRepoManagerOptions
* @property {string} dataDir - Directory for SQLite databases
* @property {string} [blobsDir] - Optional directory for blobs
* @property {(did: string) => SQLiteDatabase} createDb - Database factory function
*/
/**
* Multi-repository manager for read-only PDS
* Manages per-DID SQLite databases with a routing layer
*/
export class MultiRepoManager {
/**
* @param {MultiRepoManagerOptions} options
*/
constructor({ dataDir, blobsDir, createDb }) {
/** @type {Map<string, {db: SQLiteDatabase, storage: import('@pdsjs/core/ports').ActorStoragePort}>} */
this.repos = new Map();
this.dataDir = dataDir;
this.blobsDir = blobsDir;
this.createDb = createDb;
/** @type {RepoInfo[]} */
this.loadedRepos = [];
}
/**
* Load a repository from a CAR file
* @param {string} carPath - Path to CAR file
* @returns {Promise<RepoInfo>}
*/
async loadCar(carPath) {
const carBytes = fs.readFileSync(carPath);
// Create temporary storage to extract DID first
const tempDb = this.createDb(':memory:');
const tempStorage = createActorStorage(tempDb);
// Load to get DID
const result = await loadRepositoryFromCar(
new Uint8Array(carBytes),
tempStorage,
);
const did = result.did;
// Check if already loaded
if (this.repos.has(did)) {
console.log(`Repository ${did} already loaded, skipping`);
return result;
}
// Create persistent database for this DID
const dbFileName = `${did.replace(/:/g, '_').replace(/\./g, '_')}.sqlite`;
const dbPath = path.join(this.dataDir, dbFileName);
const db = this.createDb(dbPath);
const storage = createActorStorage(db);
// Load repository into persistent storage
const loadResult = await loadRepositoryFromCar(
new Uint8Array(carBytes),
storage,
);
// Register
this.repos.set(did, { db, storage });
this.loadedRepos.push(loadResult);
console.log(
`Loaded repository ${did}: ${loadResult.recordCount} records, ${loadResult.blockCount} blocks`,
);
return loadResult;
}
/**
* Get storage for a specific DID
* @param {string} did
* @returns {import('@pdsjs/core/ports').ActorStoragePort | null}
*/
getStorage(did) {
const repo = this.repos.get(did);
return repo?.storage || null;
}
/**
* Get all loaded DIDs
* @returns {string[]}
*/
getLoadedDids() {
return Array.from(this.repos.keys());
}
/**
* Get info about all loaded repositories
* @returns {RepoInfo[]}
*/
getLoadedRepos() {
return this.loadedRepos;
}
}
/**
* @typedef {Object} ReadOnlyServerOptions
* @property {string} hostname - PDS hostname
* @property {string} [jwtSecret] - JWT secret (optional for read-only)
* @property {string} dataDir - Directory for SQLite databases
* @property {string} [blobsDir] - Optional directory for blobs
* @property {(did: string) => SQLiteDatabase} createDb - Database factory function
*/
/**
* Create a read-only PDS server with multi-repo support
* @param {ReadOnlyServerOptions} options
* @returns {{ server: PersonalDataServer, manager: MultiRepoManager, loadCar: (path: string) => Promise<RepoInfo> }}
*/
export function createReadOnlyServer(options) {
const {
hostname,
jwtSecret = 'readonly-jwt-secret',
dataDir,
blobsDir,
createDb,
} = options;
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Create multi-repo manager
const manager = new MultiRepoManager({ dataDir, blobsDir, createDb });
// Create a shared storage database
const sharedDb = createDb(path.join(dataDir, 'shared.sqlite'));
const sharedStorage = createSharedStorage(sharedDb);
// Create blob storage if configured
const blobs = blobsDir ? createFsBlobs(blobsDir) : createNullBlobStorage();
// Create a routing actor storage that delegates to the correct repo
const routingStorage = createRoutingStorage(manager);
// Create the PDS in read-only mode
const server = new PersonalDataServer({
actorStorage: routingStorage,
sharedStorage,
blobs,
jwtSecret,
hostname,
readOnly: true,
});
return {
server,
manager,
loadCar: (carPath) => manager.loadCar(carPath),
};
}
/**
* Create a routing storage that delegates to the correct repo based on DID
* @param {MultiRepoManager} manager
* @returns {import('@pdsjs/core/ports').ActorStoragePort}
*/
function createRoutingStorage(manager) {
// Default to first loaded repo for single-repo compatibility
const getDefaultStorage = () => {
const dids = manager.getLoadedDids();
if (dids.length === 0) return null;
return manager.getStorage(dids[0]);
};
return {
async getRecord(uri) {
// Extract DID from uri: at://did:xxx/collection/rkey
const did = uri.replace('at://', '').split('/')[0];
const storage = manager.getStorage(did) || getDefaultStorage();
return storage?.getRecord(uri) || null;
},
async listRecords(collection, cursor, limit) {
// For listRecords, use default storage
const storage = getDefaultStorage();
return (
storage?.listRecords(collection, cursor, limit) || {
records: [],
cursor: null,
}
);
},
async listAllRecords() {
const storage = getDefaultStorage();
return storage?.listAllRecords() || [];
},
async putRecord() {
throw new Error('Read-only: putRecord not allowed');
},
async deleteRecord() {
throw new Error('Read-only: deleteRecord not allowed');
},
async getBlock(cid) {
// Try all repos
for (const did of manager.getLoadedDids()) {
const storage = manager.getStorage(did);
const block = await storage?.getBlock(cid);
if (block) return block;
}
return null;
},
async putBlock() {
throw new Error('Read-only: putBlock not allowed');
},
async getLatestCommit() {
const storage = getDefaultStorage();
return storage?.getLatestCommit() || null;
},
async putCommit() {
throw new Error('Read-only: putCommit not allowed');
},
async getEvents(cursor, limit) {
const storage = getDefaultStorage();
return storage?.getEvents(cursor, limit) || { events: [], cursor: 0 };
},
async putEvent() {
throw new Error('Read-only: putEvent not allowed');
},
async getLatestSeq() {
const storage = getDefaultStorage();
return storage?.getLatestSeq() ?? 0;
},
async getBlob(cid) {
const storage = getDefaultStorage();
return storage?.getBlob(cid) || null;
},
async putBlob() {
throw new Error('Read-only: putBlob not allowed');
},
async deleteBlob() {
throw new Error('Read-only: deleteBlob not allowed');
},
async listBlobs(cursor, limit) {
const storage = getDefaultStorage();
return storage?.listBlobs(cursor, limit) || { cids: [], cursor: null };
},
async listBlobDetails(cursor, limit, options) {
const storage = getDefaultStorage();
return (
storage?.listBlobDetails?.(cursor, limit, options) || {
blobs: [],
cursor: null,
}
);
},
async getBlobFacets() {
const storage = getDefaultStorage();
return (
storage?.getBlobFacets?.() || {
total: { count: 0, bytes: 0 },
unused: { count: 0, bytes: 0 },
kinds: [],
apps: [],
}
);
},
async getOrphanedBlobs() {
return [];
},
async linkBlobToRecord() {
throw new Error('Read-only: linkBlobToRecord not allowed');
},
async listBlobRecordUris() {
// A backup snapshot carries no link table; with no space records either,
// every blob it holds is public.
return [];
},
async unlinkBlobsFromRecord() {
throw new Error('Read-only: unlinkBlobsFromRecord not allowed');
},
async getDid() {
const storage = getDefaultStorage();
return storage?.getDid() || null;
},
async setDid() {
throw new Error('Read-only: setDid not allowed');
},
async getHandle() {
const storage = getDefaultStorage();
return storage?.getHandle() || null;
},
async getAccountStatus() {
return 'active';
},
async setAccountStatus() {
throw new Error('Read-only: setAccountStatus not allowed');
},
async getEmail() {
// A CAR mirror carries repo contents, never account state like this
return null;
},
async setEmail() {
throw new Error('Read-only: setEmail not allowed');
},
async getAccountPassword() {
return null;
},
async setAccountPassword() {
throw new Error('Read-only: setAccountPassword not allowed');
},
async getPlcToken() {
return null;
},
async setPlcToken() {
throw new Error('Read-only: setPlcToken not allowed');
},
async setHandle() {
throw new Error('Read-only: setHandle not allowed');
},
async getPrivateKey() {
return null; // No private key in read-only mode
},
async setPrivateKey() {
throw new Error('Read-only: setPrivateKey not allowed');
},
async getKemKey() {
return null;
},
async setKemKey() {
throw new Error('Read-only: setKemKey not allowed');
},
async getPreferences() {
const storage = getDefaultStorage();
return storage?.getPreferences() || [];
},
async setPreferences() {
throw new Error('Read-only: setPreferences not allowed');
},
};
}
/**
* Create a null blob storage that returns empty results
* @returns {import('@pdsjs/core/ports').BlobPort}
*/
function createNullBlobStorage() {
return {
async put() {
throw new Error('Read-only: put not allowed');
},
async get() {
return null;
},
async delete() {
throw new Error('Read-only: delete not allowed');
},
};
}
export { loadRepositoryFromCar } from '@pdsjs/core';
export { buildRepositoryCar } from './build-car.js';