chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/core/handlers/xrpc-firehose - com.atproto.sync.subscribeRepos.
//
// A subscriber gets a websocket and every commit after the cursor it asked for,
// each as an AT Protocol frame: a header, then the event body, both dag-cbor.
import {
CID,
cborDecode,
cborEncode,
cborEncodeDagCbor,
cidToBytes,
} from '../repo.js';
import { parseNumericCursor } from '../validation.js';
/**
* @typedef {Object} FirehoseContext
* @property {import('../ports.js').ActorStoragePort} actorStorage
* @property {import('../ports.js').WebSocketPort} [webSocket] - Absent, subscribeRepos is not served
*/
/**
* @param {FirehoseContext} ctx
* @returns {{
* formatFirehoseEvent: (evt: {seq: number, commit_cid: string, evt: Uint8Array}) => Uint8Array,
* routes: import('../pds.js').Routes,
* }}
*/
export function createFirehoseHandlers(ctx) {
const { actorStorage, webSocket } = ctx;
/**
* Format event for firehose (AT Protocol frame format)
* @param {{seq: number, commit_cid: string, evt: Uint8Array}} evt
* @returns {Uint8Array}
*/
function formatFirehoseEvent(evt) {
// Decode stored event to get ops, blocks, rev, and time
const evtData = cborDecode(evt.evt);
// Typed (non-commit) events: #identity, #account, #sync
if (evtData.type && evtData.type !== 'commit') {
const typedHeader = cborEncode({ op: 1, t: `#${evtData.type}` });
/** @type {Record<string, *>} */
const typedBody = { seq: evt.seq, did: evtData.did, time: evtData.time };
if (evtData.type === 'identity') {
if (evtData.handle != null) typedBody.handle = evtData.handle;
} else if (evtData.type === 'account') {
typedBody.active = evtData.active;
if (evtData.status != null) typedBody.status = evtData.status;
} else if (evtData.type === 'sync') {
typedBody.blocks = evtData.blocks || new Uint8Array(0);
typedBody.rev = evtData.rev;
}
const encodedBody = cborEncodeDagCbor(typedBody);
const typedFrame = new Uint8Array(
typedHeader.length + encodedBody.length,
);
typedFrame.set(typedHeader, 0);
typedFrame.set(encodedBody, typedHeader.length);
return typedFrame;
}
// AT Protocol frame format: header + body
const header = cborEncode({ op: 1, t: '#commit' });
// Transform ops to wrap CIDs in CID class for DAG-CBOR tag 42 encoding
// Stored events have cid as { $link: cidString } or null
const ops = (evtData.ops || []).map(
(
/** @type {{action: string, path: string, cid?: {$link: string}|null, prev?: {$link: string}}} */ op,
) => ({
...op,
cid: op.cid?.$link ? new CID(cidToBytes(op.cid.$link)) : null,
...(op.prev?.$link ? { prev: new CID(cidToBytes(op.prev.$link)) } : {}),
}),
);
// Build body with blocks included
// CIDs must be wrapped in CID class for proper DAG-CBOR tag 42 encoding
// Use commit_cid from database row (not from decoded event) for reliability
const body = cborEncodeDagCbor({
seq: evt.seq,
rebase: false,
tooBig: false,
repo: evtData.did,
commit: new CID(cidToBytes(evt.commit_cid)),
rev: evtData.rev,
since: evtData.since ?? null,
...(evtData.prevData
? { prevData: new CID(cidToBytes(evtData.prevData)) }
: {}),
blocks: evtData.blocks || new Uint8Array(0),
ops,
blobs: [],
time: evtData.time,
});
// Combine header and body
const frame = new Uint8Array(header.length + body.length);
frame.set(header, 0);
frame.set(body, header.length);
return frame;
}
/**
* com.atproto.sync.subscribeRepos - WebSocket firehose
* @param {Request} request
* @param {URL} url
* @returns {Response}
*/
function handleSubscribeRepos(request, url) {
// Check if WebSocket port is available
if (!webSocket) {
return Response.json(
{ error: 'NotImplemented', message: 'WebSocket not supported' },
{ status: 501 },
);
}
// Check for WebSocket upgrade
if (!webSocket.isUpgrade(request)) {
return new Response('Expected WebSocket upgrade', { status: 426 });
}
// Validate cursor before upgrading
const cursorParam = url.searchParams.get('cursor');
const parsedCursor = parseNumericCursor(cursorParam);
if (parsedCursor && !parsedCursor.valid) {
return Response.json(
{ error: 'InvalidRequest', message: parsedCursor.error },
{ status: 400 },
);
}
// Upgrade connection and send historical events
return webSocket.upgrade(request, async (sender) => {
if (parsedCursor?.valid) {
const { events } = await actorStorage.getEvents(
parsedCursor.value,
1000,
);
for (const evt of events) {
sender.send(formatFirehoseEvent(evt));
}
}
});
}
return {
formatFirehoseEvent,
routes: {
'/xrpc/com.atproto.sync.subscribeRepos': {
handler: handleSubscribeRepos,
},
},
};
}