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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
180 lines · 5.6 KB Raw History
#!/usr/bin/env node
/**
 * git-host: a git forge served from a PDS reached over the wire.
 *
 *   git-host --pds https://pds.example.com --account alice.example.com
 *
 * The PDS stays the data store. This process serves git smart HTTP, the
 * file browser, the dev.pdsjs.git.* methods and the browser app, indexes
 * collaboration records off Jetstream, and catches up from the archive on
 * boot when replay is on.
 */

import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { parseArgs } from 'node:util';
import { zstdDecompressSync } from 'node:zlib';
import { backfill, watch } from '@pdsjs/appview';
import { createAppViewStore } from '@pdsjs/appview/store';
import {
  GIT_ISSUE_COLLECTION,
  GIT_PULL_COLLECTION,
  GIT_REPO_COLLECTION,
  GIT_REVIEW_COLLECTION,
} from '@pdsjs/git';
import { createForgeAuth } from './auth.js';
import { requestContext } from './context.js';
import { createRemoteAccount } from './remote.js';
import { createGitHostServer } from './server.js';

/** The collections the forge's reads are about. */
const WATCHED_COLLECTIONS = [
  GIT_REPO_COLLECTION,
  GIT_PULL_COLLECTION,
  GIT_REVIEW_COLLECTION,
  GIT_ISSUE_COLLECTION,
  'dev.pdsjs.git.offer',
];

/**
 * The SQLite driver, whichever is installed: better-sqlite3 first, node's
 * own module otherwise (node 22.5 and up).
 * @param {string} path
 * @returns {Promise<import('@pdsjs/storage-sqlite/driver').SQLiteDatabase>}
 */
async function openDatabase(path) {
  try {
    const { default: Database } = await import('better-sqlite3');
    return new Database(path);
  } catch {
    const { DatabaseSync } = await import('node:sqlite');
    return new DatabaseSync(path);
  }
}

const { values } = parseArgs({
  options: {
    pds: { type: 'string' },
    account: { type: 'string' },
    db: { type: 'string', default: 'git-appview.sqlite' },
    port: { type: 'string', default: '8046' },
    origin: { type: 'string' },
    ui: { type: 'string' },
    'plc-url': { type: 'string', default: 'https://plc.directory' },
    jetstream: {
      type: 'string',
      default: 'wss://jetstream.us-east.bsky.network',
    },
    'jetstream-api-key': {
      type: 'string',
      default: process.env.JETSTREAM_API_KEY,
    },
    replay: { type: 'boolean', default: false },
    'list-service': { type: 'string', default: 'https://bsky.network' },
    'no-watch': { type: 'boolean', default: false },
    'no-backfill': { type: 'boolean', default: false },
    help: { type: 'boolean', default: false },
  },
});

if (values.help || !values.pds || !values.account) {
  console.log(`usage: git-host --pds <url> --account <handle-or-did> [options]

  --db <path>          the index database (default ./git-appview.sqlite)
  --port <n>           the port to serve on (default 8046)
  --origin <url>       the origin requests are read as carrying
  --ui <dir>           the browser app's build, served at /
  --plc-url <url>      the directory DIDs resolve against
  --jetstream <url>    the jetstream to index from (default a v2 instance)
  --jetstream-api-key  the archive's bearer token, for replay on a metered
                       instance; also read from JETSTREAM_API_KEY
  --replay             catch up from the jetstream archive on boot instead
                       of walking the relay; implies --no-backfill
  --list-service <url> the relay the backfill lists repos from
  --no-watch           skip the jetstream tail
  --no-backfill        skip the first-boot historical walk
`);
  process.exit(values.help ? 0 : 1);
}

const notice = (/** @type {string} */ message) =>
  console.error(`[git-host] ${message}`);

const dbPath = join(values.db);
mkdirSync(dirname(dbPath), { recursive: true });
const db = await openDatabase(dbPath);
const store = createAppViewStore(db);

const account = createRemoteAccount({
  service: values.pds,
  account: values.account,
  // Writes carry the requesting account's own token, which the dispatch
  // placed in this request's context.
  getToken: async () => requestContext.getStore()?.token ?? null,
});

// The login round trip needs a stable origin: the client_id names it. An
// origin-less deployment serves reads only.
const auth = values.origin
  ? createForgeAuth({
      origin: values.origin,
      accountDid: await account.getDid(),
      service: values.pds,
      plcUrl: values['plc-url'],
      onNotice: notice,
    })
  : undefined;

const host = createGitHostServer({
  account,
  store,
  auth,
  origin: values.origin,
  uiDir: values.ui,
  plcUrl: values['plc-url'],
  onNotice: notice,
});

await host.listen(Number(values.port));
notice(`serving ${values.account} from ${values.pds} on :${values.port}`);

const controller = new AbortController();
for (const signal of ['SIGINT', 'SIGTERM']) {
  process.on(signal, () => {
    controller.abort();
    void host.close().then(() => process.exit(0));
  });
}

// Replay replaces the relay walk: the archive is the same history, asked
// of the jetstream itself instead of every account's PDS.
const replay = values.replay || Boolean(values['jetstream-api-key']);

if (!values['no-backfill'] && !replay) {
  void backfill({
    store,
    collections: WATCHED_COLLECTIONS,
    listService: values['list-service'],
    plcUrl: values['plc-url'],
    signal: controller.signal,
    onNotice: notice,
  });
}

if (!values['no-watch']) {
  void watch({
    store,
    service: values.jetstream,
    collections: WATCHED_COLLECTIONS,
    replay: replay
      ? {
          service: values.jetstream,
          apiKey: values['jetstream-api-key'],
          decompress: async (bytes) =>
            new Uint8Array(zstdDecompressSync(Buffer.from(bytes))),
        }
      : undefined,
    signal: controller.signal,
    onNotice: notice,
  });
}