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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
573 lines · 14.0 KB Raw History

subscribeRepos WebSocket Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add com.atproto.sync.subscribeRepos WebSocket endpoint to the PDS using hexagonal architecture.

Architecture: Define a WebSocketPort interface in the core that abstracts WebSocket handling. Platform adapters (Cloudflare, Node.js) implement this port. The core PDS calls the port methods without knowing platform specifics.

Tech Stack: Cloudflare WebSocketPair + Durable Object hibernation, Node.js ws library, Vitest for e2e tests.


Task 1: Add WebSocketPort Types to ports.js

Files:

  • Modify: packages/core/ports.js:60-70

Step 1: Add WebSocketSender and WebSocketPort typedefs

Add before the final export {}:

/**
 * @typedef {Object} WebSocketSender
 * @property {(data: Uint8Array) => void} send - Send binary data to client
 * @property {() => void} close - Close the connection
 */

/**
 * @typedef {Object} WebSocketPort
 * @property {(request: Request) => boolean} isUpgrade - Check if request is WebSocket upgrade
 * @property {(request: Request, onConnect: (sender: WebSocketSender) => Promise<void>) => Response} upgrade - Upgrade connection and call onConnect with sender
 */

Step 2: Commit

git add packages/core/ports.js
git commit -m "feat: add WebSocketPort types to ports.js"

Task 2: Add handleSubscribeRepos to Core PDS

Files:

  • Modify: packages/core/pds.js:290-310 (constructor)
  • Modify: packages/core/pds.js:2900-2910 (routes)
  • Add handler method after sync handlers (~line 2600)

Step 1: Add webSocket to constructor

In constructor parameters and assignment (around line 290):

constructor({
  storage,
  blobs,
  jwtSecret,
  hostname,
  appviewUrl,
  appviewDid,
  relayUrl,
  password,
  webSocket,  // Add this
}) {
  this.storage = storage;
  this.blobs = blobs;
  this.jwtSecret = jwtSecret;
  this.hostname = hostname;
  this.appviewUrl = appviewUrl || BSKY_APPVIEW_URL;
  this.appviewDid = appviewDid;
  this.relayUrl = relayUrl;
  this.password = password;
  this.webSocket = webSocket;  // Add this
  this.lastCrawlNotify = 0;
  // ... rest
}

Step 2: Add formatFirehoseEvent method

Add after the existing sync handlers (around line 2600):

/**
 * Format event for firehose (AT Protocol frame format)
 * @param {{seq: number, evt: Uint8Array}} evt
 * @returns {Uint8Array}
 */
formatFirehoseEvent(evt) {
  // AT Protocol frame format: header + body
  const header = cborEncode({ op: 1, t: '#commit' });

  // Decode stored event to rebuild with proper CID encoding
  const evtData = cborDecode(evt.evt);

  // Build body with blocks included
  const body = cborEncodeDagCbor({
    seq: evt.seq,
    rebase: false,
    tooBig: false,
    repo: evtData.did,
    commit: evtData.commit ? { $link: evtData.commit } : null,
    rev: evtData.rev,
    since: null,
    blocks: evtData.blocks || new Uint8Array(0),
    ops: evtData.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;
}

Step 3: Add handleSubscribeRepos method

Add after formatFirehoseEvent:

/**
 * com.atproto.sync.subscribeRepos - WebSocket firehose
 * @param {Request} request
 * @param {URL} url
 * @returns {Response}
 */
handleSubscribeRepos(request, url) {
  // Check if WebSocket port is available
  if (!this.webSocket) {
    return Response.json(
      { error: 'NotImplemented', message: 'WebSocket not supported' },
      { status: 501 },
    );
  }

  // Check for WebSocket upgrade
  if (!this.webSocket.isUpgrade(request)) {
    return new Response('Expected WebSocket upgrade', { status: 426 });
  }

  const cursor = url.searchParams.get('cursor');

  // Upgrade connection and send historical events
  return this.webSocket.upgrade(request, async (sender) => {
    if (cursor) {
      const cursorNum = parseInt(cursor, 10);
      if (!isNaN(cursorNum)) {
        const { events } = await this.storage.getEvents(cursorNum, 1000);
        for (const evt of events) {
          sender.send(this.formatFirehoseEvent(evt));
        }
      }
    }
  });
}

Step 4: Add route to ROUTES object

Add to the Sync section of ROUTES (around line 2905):

'/xrpc/com.atproto.sync.subscribeRepos': {
  handler: PersonalDataServer.prototype.handleSubscribeRepos,
},

Step 5: Commit

git add packages/core/pds.js
git commit -m "feat: add handleSubscribeRepos WebSocket handler to core PDS"

Task 3: Add Cloudflare WebSocket Adapter

Files:

  • Modify: packages/cloudflare/index.js:500-540

Step 1: Add createCloudflareWebSocket factory function

Add before the PDSDurableObject class:

/**
 * Create WebSocket port for Cloudflare Durable Objects
 * @param {DurableObjectState} state
 * @returns {import('@pdsjs/core/ports.js').WebSocketPort}
 */
function createCloudflareWebSocket(state) {
  return {
    isUpgrade(request) {
      return request.headers.get('Upgrade') === 'websocket';
    },

    upgrade(request, onConnect) {
      const pair = new WebSocketPair();
      const [client, server] = Object.values(pair);

      // Accept for hibernation
      state.acceptWebSocket(server);

      // Call onConnect with sender interface
      onConnect({
        send(data) {
          try {
            server.send(data);
          } catch {
            // Client may have disconnected
          }
        },
        close() {
          try {
            server.close();
          } catch {
            // Already closed
          }
        },
      });

      return new Response(null, { status: 101, webSocket: client });
    },
  };
}

Step 2: Inject webSocket into PDS constructor

Modify the PDSDurableObject constructor:

constructor(state, env) {
  this.state = state;
  this.env = env;

  const storage = createD1Storage(state.storage.sql);
  const blobs = createR2Blobs(env.BLOBS || env.BLOB_BUCKET);
  const webSocket = createCloudflareWebSocket(state);  // Add this

  this.pds = new PersonalDataServer({
    storage,
    blobs,
    webSocket,  // Add this
    jwtSecret: env.JWT_SECRET,
    hostname: env.HOSTNAME,
    appviewUrl: env.APPVIEW_URL,
    appviewDid: env.APPVIEW_DID,
    relayUrl: env.RELAY_URL,
    password: env.PDS_PASSWORD,
  });
}

Step 3: Commit

git add packages/cloudflare/index.js
git commit -m "feat: add Cloudflare WebSocket adapter for subscribeRepos"

Task 4: Add Node.js WebSocket Adapter

Files:

  • Modify: packages/node/index.js
  • Modify: packages/node/package.json (add ws dependency)

Step 1: Add ws dependency

cd packages/node && npm install ws && cd ../..

Step 2: Add createNodeWebSocket factory and server upgrade handling

Add imports at top of packages/node/index.js:

import { WebSocketServer } from 'ws';

Add factory function before createServer:

/**
 * Create WebSocket port for Node.js
 * @param {WeakMap<Request, import('ws').WebSocket>} upgradeMap
 * @returns {import('@pdsjs/core/ports.js').WebSocketPort}
 */
function createNodeWebSocket(upgradeMap) {
  return {
    isUpgrade(request) {
      return upgradeMap.has(request);
    },

    upgrade(request, onConnect) {
      const ws = upgradeMap.get(request);
      if (!ws) {
        return new Response('WebSocket upgrade failed', { status: 500 });
      }

      onConnect({
        send(data) {
          if (ws.readyState === ws.OPEN) {
            ws.send(data);
          }
        },
        close() {
          ws.close();
        },
      });

      // Return 101 - actual upgrade handled by server
      return new Response(null, { status: 101 });
    },
  };
}

Step 3: Modify createServer to handle WebSocket upgrades

Update createServer function to create WebSocket infrastructure and handle upgrades:

export async function createServer({
  dbPath,
  blobsDir,
  jwtSecret,
  port = 3000,
  hostname,
  appviewUrl,
  appviewDid,
  relayUrl,
  password,
}) {
  const Database = (await import('better-sqlite3')).default;

  const db = new Database(dbPath);
  const storage = createBetterSqliteStorage(db);
  const blobs = createFsBlobs(blobsDir);

  // WebSocket infrastructure
  const upgradeMap = new WeakMap();
  const webSocket = createNodeWebSocket(upgradeMap);
  const wss = new WebSocketServer({ noServer: true });

  const pds = new PersonalDataServer({
    storage,
    blobs,
    webSocket,  // Add this
    jwtSecret,
    hostname,
    appviewUrl,
    appviewDid,
    relayUrl,
    password,
  });

  const server = createHttpServer(async (req, res) => {
    try {
      const request = nodeToWebRequest(req);
      const response = await pds.fetch(request);

      // Skip normal response for WebSocket upgrades
      if (response.status === 101 && upgradeMap.has(request)) {
        return; // WebSocket handled by upgrade event
      }

      await webToNodeResponse(response, res);
    } catch (error) {
      console.error('Server error:', error);
      res.statusCode = 500;
      res.setHeader('Content-Type', 'application/json');
      res.end(JSON.stringify({ error: 'InternalServerError', message: error.message }));
    }
  });

  // Handle WebSocket upgrades
  server.on('upgrade', (req, socket, head) => {
    if (req.url?.startsWith('/xrpc/com.atproto.sync.subscribeRepos')) {
      wss.handleUpgrade(req, socket, head, async (ws) => {
        const request = nodeToWebRequest(req);
        upgradeMap.set(request, ws);
        try {
          await pds.fetch(request);
        } finally {
          upgradeMap.delete(request);
        }
      });
    } else {
      socket.destroy();
    }
  });

  // ... rest of function unchanged

Step 4: Commit

git add packages/node/index.js packages/node/package.json
git commit -m "feat: add Node.js WebSocket adapter for subscribeRepos"

Task 5: Add E2E Tests for subscribeRepos

Files:

  • Modify: test/e2e.test.js

Step 1: Add WebSocket import and helper

Add at top of file with other imports:

import WebSocket from 'ws';

Step 2: Add subscribeRepos test suite

Add new describe block (after Foreign DID proxying tests):

describe('subscribeRepos WebSocket', () => {
  it('rejects non-WebSocket requests with 426', async () => {
    const res = await fetch(`${BASE}/xrpc/com.atproto.sync.subscribeRepos`);
    expect(res.status).toBe(426);
  });

  it('connects via WebSocket', async () => {
    const wsUrl = BASE.replace('http', 'ws');
    const ws = new WebSocket(`${wsUrl}/xrpc/com.atproto.sync.subscribeRepos`);

    await new Promise((resolve, reject) => {
      ws.on('open', () => {
        ws.close();
        resolve();
      });
      ws.on('error', reject);
      setTimeout(() => reject(new Error('WebSocket timeout')), 5000);
    });
  });

  it('receives events after cursor', async () => {
    // First create a record to generate an event
    const { status } = await jsonPost(
      '/xrpc/com.atproto.repo.createRecord',
      {
        repo: DID,
        collection: 'app.bsky.feed.post',
        record: { text: 'ws test', createdAt: new Date().toISOString() },
      },
      { Authorization: `Bearer ${token}` },
    );
    expect(status).toBe(200);

    // Get current commit to use as cursor baseline
    const commitRes = await fetch(
      `${BASE}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`,
    );
    const { rev } = await commitRes.json();
    expect(rev).toBeTruthy();

    // Connect with cursor=0 to get all events
    const wsUrl = BASE.replace('http', 'ws');
    const ws = new WebSocket(
      `${wsUrl}/xrpc/com.atproto.sync.subscribeRepos?cursor=0`,
    );

    const events = [];
    await new Promise((resolve, reject) => {
      ws.on('message', (data) => {
        events.push(data);
        // Close after receiving at least one event
        if (events.length >= 1) {
          ws.close();
          resolve();
        }
      });
      ws.on('error', reject);
      ws.on('open', () => {
        // Give it time to receive events, then close
        setTimeout(() => {
          ws.close();
          resolve();
        }, 2000);
      });
      setTimeout(() => reject(new Error('WebSocket timeout')), 10000);
    });

    // Should have received at least one event
    expect(events.length).toBeGreaterThan(0);
    // Events should be binary (Uint8Array/Buffer)
    expect(Buffer.isBuffer(events[0]) || events[0] instanceof Uint8Array).toBeTruthy();
  });
});

Step 3: Add ws as dev dependency to root package.json

npm install -D ws

Step 4: Run tests

npm run test:e2e:node
npm run test:e2e:cloudflare

Expected: All tests pass including new WebSocket tests.

Step 5: Commit

git add test/e2e.test.js package.json package-lock.json
git commit -m "test: add e2e tests for subscribeRepos WebSocket"

Task 6: Deploy and Verify Relay Sync

Step 1: Deploy to Cloudflare

npx wrangler deploy

Step 2: Request crawl from relay

curl -X POST 'https://bsky.network/xrpc/com.atproto.sync.requestCrawl' \
  -H 'Content-Type: application/json' \
  -d '{"hostname":"chad-pds.chad-53c.workers.dev"}'

Step 3: Verify relay can sync

# Wait a few seconds, then check
curl 'https://bsky.network/xrpc/com.atproto.sync.getLatestCommit?did=did:plc:c6vxslynzebnlk5kw2orx37o'

Expected: Returns matching cid and rev from your PDS.

Step 4: Test posting

Create a post via bsky.app and verify it appears in your feed.


Summary

TaskDescriptionCommit Message
1Add WebSocketPort typesfeat: add WebSocketPort types to ports.js
2Add handler to core PDSfeat: add handleSubscribeRepos WebSocket handler to core PDS
3Cloudflare adapterfeat: add Cloudflare WebSocket adapter for subscribeRepos
4Node.js adapterfeat: add Node.js WebSocket adapter for subscribeRepos
5E2E teststest: add e2e tests for subscribeRepos WebSocket
6Deploy & verify(no commit - verification only)