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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
264 lines · 7.6 KB Raw History
// Firehose tests - frame format per event type, and the subscribe handshake
import { describe, expect, it } from 'vitest';
import { createFirehoseHandlers } from '../packages/core/src/handlers/xrpc-firehose.js';
import { cborDecode, cborEncodeDagCbor } from '../packages/core/src/repo.js';

const DID = 'did:plc:firehosetestaccount2';
const COMMIT_CID =
  'bafyreiald5e2ib3gzzgreu7vp2dsrjpphhehnlh46lrk53mw7z2na5eoiq';

/**
 * @param {{events?: Array<Object>, withSocket?: boolean, isUpgrade?: boolean}} [options]
 */
function createFirehose({
  events = [],
  withSocket = true,
  isUpgrade = true,
} = {}) {
  /** @type {Uint8Array[]} */
  const sent = [];
  /** @type {Array<{cursor: number, limit: number}>} */
  const queries = [];

  const webSocket = {
    isUpgrade: () => isUpgrade,
    upgrade: (
      /** @type {Request} */ _request,
      /** @type {(sender: {send: (b: Uint8Array) => void}) => Promise<void>} */ onOpen,
    ) => {
      // A real port answers 101, which Response refuses to construct here.
      onOpen({ send: (b) => sent.push(b) });
      return new Response(null, { headers: { 'x-upgraded': 'yes' } });
    },
  };

  const firehose = createFirehoseHandlers({
    actorStorage: /** @type {any} */ ({
      async getEvents(
        /** @type {number} */ cursor,
        /** @type {number} */ limit,
      ) {
        queries.push({ cursor, limit });
        return { events };
      },
    }),
    webSocket: withSocket ? /** @type {any} */ (webSocket) : undefined,
  });
  return { firehose, sent, queries };
}

/** The two dag-cbor values a frame is made of, read back out. */
function readFrame(/** @type {Uint8Array} */ frame) {
  // cborDecode reads the first value; the body follows it in the same buffer.
  const header = cborDecode(frame);
  // Re-encode the header to learn its length, then decode the remainder.
  let offset = 1;
  while (offset < frame.length) {
    try {
      const body = cborDecode(frame.slice(offset));
      if (body && typeof body === 'object' && 'seq' in body) {
        return { header, body };
      }
    } catch {
      // keep scanning
    }
    offset += 1;
  }
  throw new Error('no body found in frame');
}

/** @param {Object} evtData */
function storedEvent(evtData, seq = 1) {
  return { seq, commit_cid: COMMIT_CID, evt: cborEncodeDagCbor(evtData) };
}

const SUBSCRIBE = '/xrpc/com.atproto.sync.subscribeRepos';

/**
 * @param {ReturnType<typeof createFirehoseHandlers>} firehose
 * @param {string} query
 */
function call(firehose, query = '') {
  const route = firehose.routes[SUBSCRIBE];
  const url = new URL(`https://pds.example.com${SUBSCRIBE}${query}`);
  const request = new Request(url);
  const handler =
    /** @type {(request: Request, url: URL, auth: null) => Response} */ (
      route.handler
    );
  return handler(request, url, null);
}

describe('formatFirehoseEvent', () => {
  it('frames a commit with its ops and cid', () => {
    const { firehose } = createFirehose();
    const frame = firehose.formatFirehoseEvent(
      storedEvent({
        did: DID,
        rev: 'rev1',
        time: '2026-01-01T00:00:00.000Z',
        blocks: new Uint8Array([1, 2, 3]),
        ops: [
          {
            action: 'create',
            path: 'app.bsky.feed.post/abc',
            cid: { $link: COMMIT_CID },
          },
        ],
      }),
    );

    const { header, body } = readFrame(frame);
    expect(header).toEqual({ op: 1, t: '#commit' });
    expect(body.seq).toBe(1);
    expect(body.repo).toBe(DID);
    expect(body.rev).toBe('rev1');
    expect(body.ops).toHaveLength(1);
    expect(body.ops[0].action).toBe('create');
    expect(body.rebase).toBe(false);
    expect(body.tooBig).toBe(false);
    expect(body.blobs).toEqual([]);
  });

  it('carries a null cid for a delete', () => {
    const { firehose } = createFirehose();
    const frame = firehose.formatFirehoseEvent(
      storedEvent({
        did: DID,
        rev: 'rev2',
        time: '2026-01-01T00:00:00.000Z',
        ops: [{ action: 'delete', path: 'app.bsky.feed.post/abc', cid: null }],
      }),
    );

    const { body } = readFrame(frame);
    expect(body.ops[0].cid).toBe(null);
  });

  it('frames an identity event with its handle', () => {
    const { firehose } = createFirehose();
    const frame = firehose.formatFirehoseEvent(
      storedEvent({
        type: 'identity',
        did: DID,
        time: '2026-01-01T00:00:00.000Z',
        handle: 'alice.example.com',
      }),
    );

    const { header, body } = readFrame(frame);
    expect(header).toEqual({ op: 1, t: '#identity' });
    expect(body.did).toBe(DID);
    expect(body.handle).toBe('alice.example.com');
  });

  it('omits a handle an identity event does not carry', () => {
    const { firehose } = createFirehose();
    const frame = firehose.formatFirehoseEvent(
      storedEvent({
        type: 'identity',
        did: DID,
        time: '2026-01-01T00:00:00.000Z',
      }),
    );

    const { body } = readFrame(frame);
    expect('handle' in body).toBe(false);
  });

  it('frames an account event with its active flag and status', () => {
    const { firehose } = createFirehose();
    const frame = firehose.formatFirehoseEvent(
      storedEvent({
        type: 'account',
        did: DID,
        time: '2026-01-01T00:00:00.000Z',
        active: false,
        status: 'deactivated',
      }),
    );

    const { header, body } = readFrame(frame);
    expect(header).toEqual({ op: 1, t: '#account' });
    expect(body.active).toBe(false);
    expect(body.status).toBe('deactivated');
  });

  it('frames a sync event with its blocks and rev', () => {
    const { firehose } = createFirehose();
    const frame = firehose.formatFirehoseEvent(
      storedEvent({
        type: 'sync',
        did: DID,
        time: '2026-01-01T00:00:00.000Z',
        blocks: new Uint8Array([9, 8]),
        rev: 'rev3',
      }),
    );

    const { header, body } = readFrame(frame);
    expect(header).toEqual({ op: 1, t: '#sync' });
    expect(body.rev).toBe('rev3');
    expect(body.blocks).toEqual(new Uint8Array([9, 8]));
  });
});

describe('subscribeRepos', () => {
  it('is a 501 with no websocket port', () => {
    const { firehose } = createFirehose({ withSocket: false });
    const response = call(firehose);

    expect(response.status).toBe(501);
  });

  it('is a 426 without an upgrade header', () => {
    const { firehose } = createFirehose({ isUpgrade: false });
    const response = call(firehose);

    expect(response.status).toBe(426);
  });

  it('refuses a cursor that is not a number', () => {
    const { firehose } = createFirehose();
    const response = call(firehose, '?cursor=abc');

    expect(response.status).toBe(400);
  });

  it('upgrades and replays the events after the cursor', async () => {
    const { firehose, sent, queries } = createFirehose({
      events: [
        storedEvent(
          {
            did: DID,
            rev: 'rev1',
            time: '2026-01-01T00:00:00.000Z',
            ops: [],
          },
          5,
        ),
      ],
    });

    const response = call(firehose, '?cursor=4');
    expect(response.headers.get('x-upgraded')).toBe('yes');
    // The upgrade callback runs on its own, so wait a turn for the replay.
    await new Promise((r) => setTimeout(r, 0));

    expect(queries).toEqual([{ cursor: 4, limit: 1000 }]);
    expect(sent).toHaveLength(1);
    expect(readFrame(sent[0]).body.seq).toBe(5);
  });

  it('sends no history without a cursor', async () => {
    const { firehose, sent, queries } = createFirehose({
      events: [storedEvent({ did: DID, rev: 'r', time: 't', ops: [] })],
    });

    call(firehose);
    await new Promise((r) => setTimeout(r, 0));

    expect(queries).toHaveLength(0);
    expect(sent).toHaveLength(0);
  });
});