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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
154 lines · 5.3 KB Raw History
// @pdsjs/spaces/notify - telling a space's syncers that a repo advanced.
//
// A space's writes are never broadcast, which is the point of one. So a service
// that wants to follow a space registers with the authority through
// registerNotify, and the authority forwards each write notice it learns of. A
// syncer that receives one holds a session and can read; the notice itself
// carries no records, only the repo rev reached and the space revision the
// authority gave the update.

import { LtHash } from './lthash.js';
import { NOTICE_TIMEOUT_MS } from './outbox.js';
import { recordWriterUpdate } from './sequence.js';
import { createServiceAuth } from './service-auth.js';
import { parseSpaceUri } from './uri.js';

/**
 * Forward a write notice to every service registered for this space.
 *
 * Best effort, and deliberately so: sync correctness rests on comparing set
 * hashes, so a syncer that misses a notice finds out on the next one or on its
 * own next walk. One unreachable recipient must not fail the others, and none of
 * them may fail the write that produced the notice.
 *
 * The write handler runs this after its response through the host's
 * `background`, so no recipient delays the write.
 *
 * @param {Object} ctx
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
 * @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
 * @param {typeof fetch} [ctx.fetch]
 * @param {Object} notice
 * @param {string} notice.authorityDid - the space's authority, which signs
 * @param {string} notice.space
 * @param {string} notice.repo - the account whose repo advanced
 * @param {string} notice.repoRev
 * @param {Uint8Array} notice.data - the repo's commit data after the write
 * @param {string} notice.spaceRev
 * @param {string|null} notice.prevSpaceRev - null for the space's first update
 * @returns {Promise<void>}
 */
export async function forwardToSyncers(
  ctx,
  { authorityDid, space, repo, repoRev, data, spaceRev, prevSpaceRev },
) {
  let recipients;
  try {
    recipients = await ctx.spaceStorage.listCredentialRecipients(space);
  } catch {
    return;
  }
  if (recipients.length === 0) return;

  const body = JSON.stringify({
    space,
    repo,
    repoRev,
    data: toJsonBytes(data),
    spaceRev,
    ...(prevSpaceRev ? { prevSpaceRev } : {}),
  });
  const doFetch = ctx.fetch ?? fetch;
  const signer = await ctx.getSigner();

  await Promise.all(
    recipients.map(async (recipient) => {
      try {
        const token = await createServiceAuth({
          iss: authorityDid,
          aud: recipient.serviceDid,
          lxm: 'com.atproto.space.notifyWrite',
          signer,
        });
        await doFetch(
          `${recipient.serviceEndpoint}/xrpc/com.atproto.space.notifyWrite`,
          {
            method: 'POST',
            headers: {
              'content-type': 'application/json',
              authorization: `Bearer ${token}`,
            },
            body,
            signal: AbortSignal.timeout(NOTICE_TIMEOUT_MS),
          },
        );
      } catch {
        // Best effort.
      }
    }),
  );
}

/**
 * @param {Uint8Array} bytes
 * @returns {{$bytes: string}}
 */
function toJsonBytes(bytes) {
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return { $bytes: btoa(binary).replace(/=+$/, '') };
}

/**
 * Build the function that tells a space's authority this repo advanced, so it
 * can keep the writer set that listRepos enumerates and notify syncers.
 *
 * The function never fails a write, since the commit has already happened.
 *
 * @param {Object} ctx
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
 * @param {() => Promise<string|null>} ctx.getDid
 * @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
 * @param {typeof fetch} [ctx.fetch]
 * @param {(work: Promise<unknown>) => void} [ctx.background]
 * @param {ReturnType<typeof import('./outbox.js').createNotifyOutbox>} outbox
 * @returns {(space: string, commit: {rev: string, setHash: Uint8Array}) => Promise<void>}
 */
export function createWriteNotifier(ctx, outbox) {
  return async (space, commit) => {
    try {
      const { spaceDid } = parseSpaceUri(space);
      const did = await ctx.getDid();
      if (!did) return;
      const data = await new LtHash(commit.setHash).digest();
      // Writing into our own space: this server is both the repo host and the
      // authority, so it does what the authority does on receiving the
      // notification. That is both halves. A syncer registered for this space
      // is owed the notice however the write reached it, and a Durable Object
      // cannot post to itself.
      if (spaceDid === did) {
        const sequenced = await recordWriterUpdate(ctx.spaceStorage, {
          space,
          did,
          repoRev: commit.rev,
          data,
        });
        if (!sequenced) return;
        const forwarded = forwardToSyncers(ctx, {
          authorityDid: did,
          space,
          repo: did,
          repoRev: commit.rev,
          data,
          ...sequenced,
        });
        if (ctx.background) ctx.background(forwarded);
        else await forwarded;
        return;
      }
      await outbox.deliver(space, { rev: commit.rev, data });
    } catch {
      // The outbox holds the notice when delivery can be retried.
    }
  };
}