// @pdsjs/deno - Deno HTTP server adapter
///
import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { secp256k1 } from '@noble/curves/secp256k1';
import { createDenoBlobs } from '@pdsjs/blobs-deno';
import { PersonalDataServer } from '@pdsjs/core';
import { createVerifier } from '@pdsjs/core/verify';
import {
createCachedDidResolver,
LexiconResolver,
} from '@pdsjs/lexicon-resolver';
import { createActorStorage, createSharedStorage } from '@pdsjs/storage-sqlite';
/** How often extension background work is checked for anything due. */
const EXTENSION_TICK_MS = 10 * 1000;
/**
* node:crypto behind the core Hasher interface, for streamed blob uploads.
* @returns {import('@pdsjs/core/ports.js').Hasher}
*/
function createDenoHasher() {
const hash = createHash('sha256');
return {
/** @param {Uint8Array} chunk */
update(chunk) {
hash.update(chunk);
},
digest() {
return new Uint8Array(hash.digest());
},
};
}
/**
* Verify a secp256k1 signature the way the reference stack does: SHA-256 the
* data, then require a compact, low-S signature. Malformed input returns false
* rather than throwing. This is the injected half of the signature verifier —
* P-256 lives in @pdsjs/core/verify; secp256k1 needs @noble/curves, which core
* deliberately does not depend on.
*
* @param {Uint8Array} publicKey - compressed 33-byte point from a did:key
* @param {Uint8Array} data - the signed bytes
* @param {Uint8Array} sig - 64-byte compact r||s
* @returns {Promise}
*/
async function secp256k1Verify(publicKey, data, sig) {
try {
const hash = new Uint8Array(
await crypto.subtle.digest('SHA-256', /** @type {BufferSource} */ (data)),
);
return secp256k1.verify(sig, hash, publicKey, {
format: 'compact',
lowS: true,
});
} catch {
return false;
}
}
/**
* Sign with secp256k1 account keys — the curve the reference PDS gives every
* account, and the one some third-party services (Go feed generators) are
* alone able to verify. WebCrypto cannot sign this curve, so core takes the
* implementation as a port, mirroring the verifier above.
*
* @type {import('@pdsjs/core/ports').Secp256k1SignerPort}
*/
const k256Signer = {
generatePrivateKey: () => secp256k1.utils.randomPrivateKey(),
getPublicKey: (privateKey, compressed = true) =>
secp256k1.getPublicKey(privateKey, compressed),
async sign(privateKey, data) {
const hash = new Uint8Array(
await crypto.subtle.digest('SHA-256', /** @type {BufferSource} */ (data)),
);
// Low-S, compact r||s: what atproto verifies and what secp256k1Verify
// above requires.
return secp256k1.sign(hash, privateKey, { lowS: true }).toCompactRawBytes();
},
};
/**
* Create WebSocket port for Deno
* @returns {import('@pdsjs/core/ports.js').WebSocketPort}
*/
function createWebSocketPort() {
/** @type {Set} */
const clients = new Set();
return {
isUpgrade(request) {
return request.headers.get('upgrade')?.toLowerCase() === 'websocket';
},
upgrade(request, onConnect) {
const upgrade = Deno.upgradeWebSocket(request);
const { socket, response } = upgrade;
clients.add(socket);
socket.onclose = () => clients.delete(socket);
socket.onerror = () => clients.delete(socket);
socket.onopen = () => {
onConnect({
send(data) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
}
},
close() {
socket.close();
},
});
};
return response;
},
broadcast(data) {
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(data);
} catch {
// Client may have disconnected
}
}
}
},
};
}
/**
* @typedef {Object} PdsServer
* @property {PersonalDataServer} pds - The PDS instance
* @property {Deno.HttpServer | null} server - HTTP server (null until listen() called)
* @property {DatabaseSync} db - SQLite database instance
* @property {() => Promise} listen - Start server
* @property {() => Promise} close - Close server
*/
/**
* Create Deno PDS server
* @param {Object} options
* @param {string} options.dbPath - Path to SQLite database
* @param {string} [options.blobsDir] - Directory for blob storage (used if blobs not provided)
* @param {import('@pdsjs/core/ports').BlobPort} [options.blobs] - Custom blob storage adapter
* @param {string} options.jwtSecret - JWT signing secret
* @param {number} [options.port=3000] - Server port
* @param {string} [options.hostname] - PDS hostname
* @param {string} [options.appviewUrl] - AppView URL for proxying
* @param {string} [options.appviewDid] - AppView DID for service auth
* @param {string} [options.relayUrl] - Relay URL for firehose notifications
* @param {string} [options.plcUrl] - PLC directory URL for identity operations
* @param {string} [options.password] - Password for createSession
* @param {import('@pdsjs/core/ports').LexiconResolverPort} [options.lexiconResolver] - Lexicon resolver for record validation
* @param {import('@pdsjs/core/ports').BackupTargetPort} [options.backupTarget] - Where scheduled backups land (createFsBackupTarget from the blobs-fs package, or createS3BackupTarget from blobs-s3). Absent, the backups page reports backups as unavailable.
* @param {{sites?: {domain?: string, apexName?: string, queries?: boolean}}} [options.experimental] - Feature experiments, exempt from semver: anything in this group may change shape or disappear in any release, including patches. `sites` (requires the sites package, an optional peer dependency): `domain` is the apex static sites serve under (dev.pdsjs.site.deploy records + blobs) and setting it enables serving; `apexName` is the site served at the apex (default "home"); `queries` serves dev.pdsjs.query.run over this repo's dev.pdsjs.query.def records.
* @returns {Promise}
*/
export async function createServer({
dbPath,
blobsDir,
blobs: blobsArg,
jwtSecret,
port = 3000,
hostname,
appviewUrl,
appviewDid,
relayUrl,
plcUrl,
password,
lexiconResolver,
backupTarget,
experimental = {},
}) {
const { sites = {} } = experimental;
const {
domain: siteDomain,
apexName: siteApexName,
queries: enableQueries,
} = sites;
const db = new DatabaseSync(dbPath);
const actorStorage = createActorStorage(db);
const sharedStorage = createSharedStorage(db);
if (!blobsArg && !blobsDir) {
throw new Error('Either blobs or blobsDir must be provided');
}
const blobs = blobsArg ?? createDenoBlobs(/** @type {string} */ (blobsDir));
const webSocket = createWebSocketPort();
const resolver = lexiconResolver ?? new LexiconResolver();
// Signature verification for createAccount (migration service tokens). P-256
// is built into core; secp256k1 — the curve the reference PDS gives every
// account — is supplied here via @noble/curves. The DID resolver returns full
// documents and honours this server's PLC directory.
const verifier = createVerifier({ secp256k1: secp256k1Verify });
// One resolver for the whole server, built once so its cache spans requests.
const didResolver = createCachedDidResolver({ plcUrl });
// Feature packages on the extension seam. Git is on whenever the package,
// an optional peer, is installed: branch protection and the repositories
// page need no option.
/** @type {import('@pdsjs/core/ports').ExtensionFactory[]} */
const extensions = [];
// Static sites and apps: the installs listing needs no option, a site
// domain turns serving on.
try {
const { createSitesExtension } = await import('@pdsjs/sites/extension');
extensions.push(
createSitesExtension({
domain: siteDomain,
apexName: siteApexName,
queries: enableQueries,
}),
);
} catch {
// Optional peer not installed
}
try {
const { createGitExtension } = await import('@pdsjs/git/extension');
extensions.push(createGitExtension());
} catch {
// Optional peer not installed
}
const pds = new PersonalDataServer({
actorStorage,
sharedStorage,
blobs,
webSocket,
jwtSecret,
hasher: createDenoHasher,
hostname,
appviewUrl,
appviewDid,
relayUrl,
plcUrl,
password,
lexiconResolver: resolver,
verifier,
didResolver,
k256Signer,
backupTarget,
extensions,
});
/** @type {Deno.HttpServer | null} */
let server = null;
/** @type {ReturnType | null} */
let cleanupInterval = null;
/** @type {ReturnType | null} */
let tickInterval = null;
return {
pds,
get server() {
return server;
},
db,
/**
* Start listening on configured port
* @returns {Promise}
*/
listen() {
return new Promise((resolve) => {
server = Deno.serve(
{
port,
onListen: () => {
console.log(`PDS listening on http://localhost:${port}`);
resolve(server);
},
},
(request) => pds.fetch(request),
);
// Start maintenance interval (every hour): cleanup, then a backup
// if due
cleanupInterval = setInterval(
async () => {
try {
await pds.runBlobCleanup();
await sharedStorage.cleanupExpiredDpopJtis();
await pds.maybeAnnounceIdentity();
await pds.maybeBackfillKemKey();
await pds.maybeRunScheduledBackup();
} catch (e) {
console.error('Maintenance error:', e);
}
},
60 * 60 * 1000,
);
// An account created before KEM keys gets one now rather than in an
// hour.
pds.maybeBackfillKemKey();
// Extension background work, such as retrying space write notices.
tickInterval = setInterval(() => {
pds.runExtensionTicks().catch((e) => console.error('Tick error:', e));
}, EXTENSION_TICK_MS);
});
},
/**
* Close server and cleanup
* @returns {Promise}
*/
async close() {
if (cleanupInterval) {
clearInterval(cleanupInterval);
}
if (tickInterval) {
clearInterval(tickInterval);
}
if (server) {
await server.shutdown();
}
db.close();
},
};
}