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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
714 lines · 23.7 KB Raw History
import {
  createRootRouteWithContext,
  createRoute,
  createRouter,
} from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import { App } from '#/app.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { PageNotice } from '#/components/molecules/page-notice.jsx';
import { RepoPageSkeleton } from '#/components/molecules/repo-page-skeleton.jsx';
import { loadAccount, primeOnce } from '#/lib/account.js';
import { loadAuthors } from '#/lib/authors.js';
import { loadChecks } from '#/lib/checks.js';
import {
  loadPulls,
  PULL_COMMIT_LIMIT,
  parsePullPath,
  pullCommits,
  pullStatements,
} from '#/lib/collab.js';
import {
  account,
  defaultRef,
  isEmpty,
  loadRepoSummary,
  readChange,
  repoRecord,
  spaceOf,
  splitRefPath,
} from '#/lib/git.js';
import { loadIssueStatements } from '#/lib/issues.js';
import { useNavigate, useRepoRecord, useSpace } from '#/lib/navigation.jsx';
import { clearRenewal, completeSignIn } from '#/lib/oauth.js';
import { loadPackages } from '#/lib/packages.js';
import { useSession } from '#/lib/session.jsx';
import { publishedPackages } from '#/lib/settings.js';
import {
  directoryKey,
  fileKey,
  historyKey,
  logKey,
  readDirectory,
  readHistory,
  readLog,
  readSource,
} from '#/lib/source.js';
import { CheckPage } from '#/pages/check.jsx';
import { ChecksPage } from '#/pages/checks.jsx';
import { CommitPage } from '#/pages/commit.jsx';
import { CommitsPage } from '#/pages/commits.jsx';
import { FilePage } from '#/pages/file.jsx';
import { IssuePage } from '#/pages/issue.jsx';
import { NewIssuePage } from '#/pages/issue-new.jsx';
import { IssuesPage } from '#/pages/issues.jsx';
import { PackagesPage } from '#/pages/packages.jsx';
import { PullPage } from '#/pages/pull.jsx';
import { PullsPage } from '#/pages/pulls.jsx';
import { NewRepoPage } from '#/pages/repo-new.jsx';
import { ReposPage } from '#/pages/repos.jsx';
import { SettingsPage } from '#/pages/settings.jsx';
import { TreePage } from '#/pages/tree.jsx';

/**
 * The routes, and what each one reads before it renders.
 *
 * A route's loader warms the query the page then subscribes to, under the
 * same key, so the page renders with the answer in hand and a link starts
 * the read when the pointer settles on it. The page keeps its own query:
 * that is what re-renders it when a publish invalidates the key.
 *
 * The account gate stays in the shell. The last visit's account and
 * repository rows are read synchronously there, so a loader below can ask
 * the server for a repository on the first frame.
 */

/** @typedef {{queryClient: import('@tanstack/react-query').QueryClient}} RouterContext */

const rootRoute = createRootRouteWithContext()({
  component: App,
  // Every loader below asks the account's server, so the server has to be
  // known first. Primed from the last visit, that is immediate and the fresh
  // discovery runs behind the paint; on a first visit the routes wait for it.
  beforeLoad: async ({ context }) => {
    const query = {
      queryKey: ['account'],
      queryFn: () => loadAccount(context.queryClient),
    };
    if (primeOnce()) {
      void context.queryClient.prefetchQuery(query);
      return;
    }
    await context.queryClient.ensureQueryData(query);
  },
});

/**
 * Where the authorization server sends the browser back. The code is spent
 * once, so the address bar is cleaned before anything else renders: a reload
 * of this URL would otherwise present a code the server has already refused.
 */
function CallbackPage() {
  const navigate = useNavigate();
  const { setSession } = useSession();
  const [error, setError] = useState(null);

  // The exchange consumes the pending sign-in the moment it starts, so it
  // must run once however many times the effect fires. StrictMode fires it
  // twice in development, and a second run would find nothing pending and
  // report a failure over a sign-in that succeeded. The ref survives the
  // simulated remount; a cleanup-cancelled flag would not, and would also
  // cancel the first run's redirect.
  const ran = useRef(false);
  useEffect(() => {
    if (ran.current) return;
    ran.current = true;
    // Read from the address bar rather than from a prop: the code is spent
    // once and this effect must not re-run, so it depends on nothing a render
    // makes anew.
    completeSignIn(new URLSearchParams(window.location.search))
      .then(({ session, returnTo }) => {
        clearRenewal();
        setSession(session);
        navigate(returnTo);
      })
      .catch((err) => setError(err.message));
  }, [navigate, setSession]);

  if (error) {
    return (
      <PageNotice tone="error">
        {error}
        <Link href="/" className="mt-3 block text-[13px]">
          Start again
        </Link>
      </PageNotice>
    );
  }
  return (
    <div className="min-h-0 flex-1 overflow-y-auto px-(--gutter)">
      <Skeleton className="mx-auto mt-16 h-24 max-w-md" />
    </div>
  );
}

/**
 * Warm one query. What a loader does: the page subscribes to the same key.
 * @param {RouterContext} context
 * @param {unknown[]} queryKey
 * @param {() => Promise<unknown>} queryFn
 */
const warm = ({ queryClient }, queryKey, queryFn) =>
  queryClient.ensureQueryData({ queryKey, queryFn });

/**
 * Start one query without waiting on it. The route renders at once and the
 * page's own query, on the same key, shows its placeholder and streams the
 * answer in. A loader that computes from the result still awaits; one that
 * only fills the page's cache does not.
 * @param {RouterContext} context
 * @param {unknown[]} queryKey
 * @param {() => Promise<unknown>} queryFn
 */
const prefetch = ({ queryClient }, queryKey, queryFn) =>
  void queryClient.prefetchQuery({ queryKey, queryFn });

/**
 * The DID whose copy an address names, where it names one, and the rest.
 * A commit on a contributor's branch is addressed by whose copy holds it,
 * so this account's own commits keep the shorter address they always had.
 * @param {string} splat
 */
function copyOf(splat) {
  const parts = splat.split('/').filter(Boolean);
  const from = parts[0]?.startsWith('did:') ? parts[0] : '';
  return { from, rest: from ? parts.slice(1) : parts };
}

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  loader: ({ context }) => prefetch(context, ['repo-summary'], loadRepoSummary),
  component: ReposPage,
});

const callbackRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/callback',
  component: CallbackPage,
});

/** Creating a repository, a page of its own like the file-issue one. */
const newRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/new',
  component: NewRepoPage,
});

/**
 * One repository. Every section below reads its refs out of the record, and
 * a name the account does not hold has no sections. A private repository is
 * addressed by its name like any other; the listing says which space holds
 * it, and every read below takes the space from there.
 */
const repoRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/$repo',
  loader: ({ context }) => prefetch(context, ['authors'], loadAuthors),
});

/**
 * What a directory listing shows: its entries and the ref's history, which
 * the latest-commit line and the gauges read. An empty repository has
 * neither and asks for nothing.
 * @param {RouterContext} context
 * @param {string} repo @param {string} refName @param {string} path
 * @param {string} space
 */
function warmTree(context, repo, refName, path, space) {
  const record = repoRecord(repo, space);
  if (!record || isEmpty(record)) return;
  prefetch(context, directoryKey(repo, refName, path, space), () =>
    readDirectory(repo, refName, path, space),
  );
  prefetch(context, historyKey(repo, refName, space), () =>
    readHistory(repo, refName, space),
  );
}

const treeIndexRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    const record = repoRecord(params.repo, space);
    if (!record) return;
    warmTree(context, params.repo, defaultRef(record), '', space);
  },
  component: function TreeIndex() {
    const { repo } = treeIndexRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return (
      <TreePage
        repo={repo}
        refName={defaultRef(record)}
        path=""
        space={space}
      />
    );
  },
});

/**
 * A ref name may hold slashes, so where the ref ends and the path begins is
 * read against the repository's own refs.
 */
const treeRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/tree/$',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    const record = repoRecord(params.repo, space);
    if (!record) return;
    const { ref, path } = splitRefPath(record, params._splat ?? '');
    warmTree(context, params.repo, ref, path, space);
  },
  component: function Tree() {
    const { repo, _splat } = treeRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    const { ref, path } = splitRefPath(record, _splat ?? '');
    return <TreePage repo={repo} refName={ref} path={path} space={space} />;
  },
});

const blobRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/blob/$',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    const record = repoRecord(params.repo, space);
    if (!record) return;
    const { ref, path } = splitRefPath(record, params._splat ?? '');
    prefetch(context, fileKey(params.repo, ref, path, space), () =>
      readSource(params.repo, ref, path, space),
    );
  },
  component: function Blob() {
    const { repo, _splat } = blobRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    const { ref, path } = splitRefPath(record, _splat ?? '');
    return <FilePage repo={repo} refName={ref} path={path} space={space} />;
  },
});

/**
 * A history: a ref's, or the commits across a range, in this account's copy
 * or another's. A segment carrying `..` is a range rather than a branch,
 * since git forbids `..` in a ref name.
 */
const commitsRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/commits/$',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    const record = repoRecord(params.repo, space);
    if (!record || isEmpty(record)) return;
    const { from, rest } = copyOf(params._splat ?? '');
    const tail = rest.join('/');
    const split = tail.includes('..') ? null : splitRefPath(record, tail);
    const what = split
      ? { refName: split.ref, path: split.path }
      : { range: tail, from: from || undefined };
    prefetch(
      context,
      logKey(
        params.repo,
        what.refName,
        what.range,
        what.from,
        space,
        what.path,
      ),
      () => readLog(params.repo, { ...what, limit: PULL_COMMIT_LIMIT, space }),
    );
  },
  component: function Commits() {
    const { repo, _splat } = commitsRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    const { from, rest } = copyOf(_splat ?? '');
    const tail = rest.join('/');
    if (tail.includes('..')) {
      return <CommitsPage repo={repo} range={tail} from={from} space={space} />;
    }
    const { ref, path } = splitRefPath(record, tail);
    return <CommitsPage repo={repo} refName={ref} path={path} space={space} />;
  },
});

const commitsIndexRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/commits',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    const record = repoRecord(params.repo, space);
    if (!record || isEmpty(record)) return;
    const refName = splitRefPath(record, '').ref;
    prefetch(
      context,
      logKey(params.repo, refName, undefined, undefined, space),
      () => readLog(params.repo, { refName, limit: PULL_COMMIT_LIMIT, space }),
    );
  },
  component: function CommitsIndex() {
    const { repo } = commitsIndexRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return (
      <CommitsPage
        repo={repo}
        refName={splitRefPath(record, '').ref}
        space={space}
      />
    );
  },
});

const commitRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/commit/$',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    if (!repoRecord(params.repo, space)) return;
    if (isEmpty(repoRecord(params.repo, space))) return;
    const { from, rest } = copyOf(params._splat ?? '');
    const sha = rest.join('/');
    prefetch(context, ['commit', params.repo, sha, from, space], () =>
      readChange(params.repo, sha, from, space),
    );
  },
  component: function Commit() {
    const { repo, _splat } = commitRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    const { from, rest } = copyOf(_splat ?? '');
    return (
      <CommitPage repo={repo} sha={rest.join('/')} from={from} space={space} />
    );
  },
});

const checksRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/checks',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    prefetch(context, ['checks', params.repo, space], () =>
      loadChecks(params.repo, space),
    );
  },
  component: function Checks() {
    const { repo } = checksRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <ChecksPage repo={repo} space={space} />;
  },
});

const checkRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/checks/$rkey',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    prefetch(context, ['checks', params.repo, space], () =>
      loadChecks(params.repo, space),
    );
  },
  component: function Check() {
    const { repo, rkey } = checkRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <CheckPage repo={repo} rkey={rkey} space={space} />;
  },
});

const pullsRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/pulls',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    prefetch(context, ['pulls', params.repo, space], () =>
      loadPulls(params.repo, space),
    );
  },
  component: function Pulls() {
    const { repo } = pullsRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <PullsPage repo={repo} tab="" space={space} />;
  },
});

const pullsTabRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/pulls/$tab',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    prefetch(context, ['pulls', params.repo, space], () =>
      loadPulls(params.repo, space),
    );
  },
  component: function PullsTab() {
    const { repo, tab } = pullsTabRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <PullsPage repo={repo} tab={tab} space={space} />;
  },
});

/**
 * A pull request is addressed by the account whose copy holds the work and
 * the branch, so the address survives the rebases that move its commits.
 * parsePullPath reads that address, beside the diffHref that writes it.
 *
 * The list names the pull request, so the loader reads the list and then
 * warms what its page shows: the diff across it, its commits, and the
 * statements about it, together rather than one after the other. None of it
 * holds the render: the page paints its placeholder and each answer lands
 * in it, the way every other route streams.
 */
const pullRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/pull/$',
  loader: ({ context, params }) => {
    const { repo } = params;
    const space = spaceOf(params.repo);
    const { did, branch, commit } = parsePullPath(
      (params._splat ?? '').split('/').filter(Boolean),
    );
    void warm(context, ['pulls', repo, space], () => loadPulls(repo, space))
      .then((listed) => {
        const pulls = /** @type {import('#/lib/collab.js').Pulls} */ (listed);
        const pull = [...pulls.open, ...pulls.merged].find(
          (one) => one.author.did === did && one.branch === branch,
        );
        if (!pull) return;
        const from = pull.author.did === account.did ? '' : pull.author.did;
        const sha =
          commit || (pull.base ? `${pull.base}..${pull.sha}` : pull.sha);
        prefetch(context, ['commit', repo, sha, from, space], () =>
          readChange(repo, sha, from, space),
        );
        if (!commit) {
          prefetch(context, ['commits', repo, '', sha, from, space], () =>
            pullCommits(repo, pull.base, pull.sha, from, 50),
          );
        }
        prefetch(context, ['statements', repo, space], () =>
          pullStatements(repo, space),
        );
      })
      .catch(() => {
        // The page's own query reports the failure where the reader is.
      });
  },
  component: function Pull() {
    const { repo, _splat } = pullRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    const { did, branch, tab, commit } = parsePullPath(
      (_splat ?? '').split('/').filter(Boolean),
    );
    return (
      <PullPage
        repo={repo}
        did={did}
        branch={branch}
        tab={tab}
        commit={commit}
        space={space}
      />
    );
  },
});

const issuesRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/issues',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    prefetch(context, ['issues', params.repo, space], () =>
      loadIssueStatements(params.repo, space),
    );
  },
  component: function Issues() {
    const { repo } = issuesRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <IssuesPage repo={repo} tab="" space={space} />;
  },
});

/**
 * Filing is a page of its own. A DID is never the word `new`, so this and a
 * thread cannot be mistaken for each other; it is listed before the tab
 * route so the word is not read as a tab.
 */
const issueNewRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/issues/new',
  component: function IssueNew() {
    const { repo } = issueNewRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <NewIssuePage repo={repo} space={space} />;
  },
});

const issuesTabRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/issues/$tab',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    prefetch(context, ['issues', params.repo, space], () =>
      loadIssueStatements(params.repo, space),
    );
  },
  component: function IssuesTab() {
    const { repo, tab } = issuesTabRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <IssuesPage repo={repo} tab={tab} space={space} />;
  },
});

/**
 * An issue thread is addressed by its opener: whose repo holds it, and the
 * record key there. The pull requests are read too, for the ones whose
 * commits claim to fix it.
 */
const issueRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/issues/$did/$rkey',
  loader: ({ context, params }) => {
    const space = spaceOf(params.repo);
    prefetch(context, ['issues', params.repo, space], () =>
      loadIssueStatements(params.repo, space),
    );
    prefetch(context, ['pulls', params.repo, space], () =>
      loadPulls(params.repo, space),
    );
  },
  component: function Issue() {
    const { repo, did, rkey } = issueRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <IssuePage repo={repo} did={did} rkey={rkey} space={space} />;
  },
});

const packagesRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/packages',
  loader: ({ context, params }) =>
    prefetch(context, ['packages', params.repo], () =>
      loadPackages(params.repo),
    ),
  component: function Packages() {
    const { repo } = packagesRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <PackagesPage repo={repo} registry="" space={space} />;
  },
});

const packagesTabRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/packages/$registry',
  loader: ({ context, params }) =>
    prefetch(context, ['packages', params.repo], () =>
      loadPackages(params.repo),
    ),
  component: function PackagesTab() {
    const { repo, registry } = packagesTabRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <PackagesPage repo={repo} registry={registry} space={space} />;
  },
});

/**
 * The repository's own settings. Every field is already in hand from the
 * listing, so the loader reads only what the form offers to link: the
 * packages this account publishes.
 */
const settingsRoute = createRoute({
  getParentRoute: () => repoRoute,
  path: '/settings',
  loader: ({ context }) =>
    prefetch(context, ['published-packages'], publishedPackages),
  component: function Settings() {
    const { repo } = settingsRoute.useParams();
    const space = useSpace();
    const { record, settled } = useRepoRecord(repo, space);
    if (!record) return settled ? <NoSuchRepository /> : <RepoPageSkeleton />;
    return <SettingsPage repo={repo} space={space} />;
  },
});

function NoSuchRepository() {
  return <PageNotice>No such repository.</PageNotice>;
}

const routeTree = rootRoute.addChildren([
  indexRoute,
  newRoute,
  callbackRoute,
  repoRoute.addChildren([
    treeIndexRoute,
    treeRoute,
    blobRoute,
    commitsIndexRoute,
    commitsRoute,
    commitRoute,
    checksRoute,
    checkRoute,
    pullsRoute,
    pullsTabRoute,
    pullRoute,
    issuesRoute,
    issueNewRoute,
    issuesTabRoute,
    issueRoute,
    packagesRoute,
    packagesTabRoute,
    settingsRoute,
  ]),
]);

/**
 * @param {import('@tanstack/react-query').QueryClient} queryClient
 */
export function createAppRouter(queryClient) {
  return createRouter({
    routeTree,
    context: { queryClient },
    // A link's page is read when the pointer settles on it. The query
    // client says whether the answer is fresh, so the router never re-reads
    // what the client already holds.
    defaultPreload: 'intent',
    defaultPreloadStaleTime: 0,
    scrollRestoration: false,
  });
}