bridge

tcmts.dev.devenv.bridge · #pyhuzza6 · mjs file

// Stand-ins, fed only by the real PDS, for the two services dev-env doesn't have:
// - Jetstream (v2's network.bsky.jetstream.subscribeEvents, xrpc.v1.json frames): the PDS's own firehose
//   (com.atproto.sync.subscribeRepos), decoded by @atproto/sync (commits unchecked: its resolver won't fetch an http PLC;
//   the registry checks each record against its signed repository itself), then sent as Jetstream's JSON;
// - a relay's com.atproto.sync.listReposByCollection (collectiondir's): from the PDS's listRepos and describeRepo.
import { Firehose } from '@atproto/sync'
import { IdResolver } from '@atproto/identity'
import { lexToJson } from '@atproto/lex-json'
import { createHash } from 'node:crypto'
import http from 'node:http'
import fs from 'node:fs'

const envFile = new URL('./env.json', import.meta.url)
const env = JSON.parse(fs.readFileSync(envFile, 'utf8'))
const stream = []
const listening = new Set()
const frame = (text) => {
  const data = Buffer.from(text)
  const n = data.length
  const head = n < 126 ? Buffer.from([0x81, n]) : n < 65536 ? Buffer.from([0x81, 126, n >> 8, n & 0xff]) : (() => { const b = Buffer.alloc(10); b[0] = 0x81; b[1] = 127; b.writeBigUInt64BE(BigInt(n), 2); return b })()
  return Buffer.concat([head, data])
}
const firehose = new Firehose({
  idResolver: new IdResolver({ plcUrl: env.plc }),
  service: env.pds.replace(/^http/, 'ws'),
  getCursor: () => 0,
  // (the PLC is http here, which @atproto/sync's resolver refuses: commits pass unchecked, and the registry checks each record itself)
  unauthenticatedCommits: true,
  excludeIdentity: true, excludeAccount: true, excludeSync: true,
  onError: (e) => console.error('firehose:', e.message, e.cause?.message ?? e.cause, e.cause?.stack?.split('\n').slice(0,4).join(' / ')),
  handleEvent: (e) => {
    if (!('collection' in e) || !e.collection.startsWith('codes.tcm.') && e.collection !== 'app.bsky.graph.follow') return
    const p = {
      $type: 'network.bsky.jetstream.subscribeEvents#commit', seq: e.seq, did: e.did, time: e.time, witnessedAt: e.time,
      rev: e.rev, operation: e.event, collection: e.collection, rkey: e.rkey,
      ...(e.event === 'delete' ? {} : { record: lexToJson(e.record), cid: e.cid.toString() }),
    }
    stream.push(p)
    console.log(`event ${p.seq} ${p.operation} ${p.did} ${p.collection}/${p.rkey}`)
    for (const l of listening) if (l.wants(p)) l.socket.write(frame(JSON.stringify({ $type: 'message', payload: p })))
  },
})
firehose.start()

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://x')
  if (url.pathname === '/xrpc/com.atproto.sync.listReposByCollection') {
    const want = url.searchParams.get('collection')
    const repos = []
    const list = await (await fetch(`${env.pds}/xrpc/com.atproto.sync.listRepos?limit=1000`)).json()
    for (const r of list.repos) {
      const d = await (await fetch(`${env.pds}/xrpc/com.atproto.repo.describeRepo?repo=${r.did}`)).json()
      if (d.collections?.includes(want)) repos.push({ did: r.did })
    }
    res.writeHead(200, { 'content-type': 'application/json' })
    return res.end(JSON.stringify({ repos }))
  }
  res.writeHead(404).end()
})
server.on('upgrade', (req, socket) => {
  const url = new URL(req.url, 'http://x')
  if (url.pathname !== '/xrpc/network.bsky.jetstream.subscribeEvents') return socket.destroy()
  const accept = createHash('sha1').update(`${req.headers['sec-websocket-key']}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64')
  const asked = String(req.headers['sec-websocket-protocol'] ?? '').split(',').map((x) => x.trim())
  socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n${asked.includes('xrpc.v1.json') ? 'Sec-WebSocket-Protocol: xrpc.v1.json\r\n' : ''}\r\n`)
  const collections = url.searchParams.getAll('collections')
  const cursor = Number(url.searchParams.get('cursor') ?? NaN)
  const wants = (p) => !collections.length || collections.some((c) => c.endsWith('.*') ? p.collection.startsWith(c.slice(0, -1)) : p.collection === c)
  const since = (p) => cursor >= 1e15 ? Date.parse(p.time) * 1000 >= cursor : p.seq > cursor
  console.log(`subscriber: cursor ${url.searchParams.get('cursor')} ${collections}`)
  if (!Number.isNaN(cursor)) for (const p of stream) if (wants(p) && since(p)) socket.write(frame(JSON.stringify({ $type: 'message', payload: p })))
  const l = { socket, wants }
  listening.add(l)
  socket.on('data', (b) => {
    const op = b[0] & 0x0f
    if (op === 0x8) socket.end(Buffer.from([0x88, 0]))
    else if (op === 0x9) { const len = b[1] & 0x7f; const mask = b.subarray(2, 6); const data = Buffer.from(b.subarray(6, 6 + len).map((x, i) => x ^ mask[i % 4])); socket.write(Buffer.concat([Buffer.from([0x8a, data.length]), data])) }
  })
  socket.on('close', () => listening.delete(l))
  socket.on('error', () => listening.delete(l))
})
server.listen(0, '127.0.0.1', () => {
  const now = JSON.parse(fs.readFileSync(envFile, 'utf8'))
  now.bridge = `http://127.0.0.1:${server.address().port}`
  fs.writeFileSync(envFile, JSON.stringify(now, null, 2))
  console.log(`bridge at ${now.bridge}`)
})

used by