johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// What a page shows, read from knotless-serve, typed by the lexicons'
// schemas under lexicons/, which `npm run lex` makes into src/lib/lexicons.
import { error, redirect } from '@sveltejs/kit';
import { browser } from '$app/env';
import type {
AtIdentifierString,
AtUriString,
InferInput,
InferMethodInputBody,
InferMethodOutputBody,
LexErrorData,
Procedure,
Query
} from '@atproto/lex';
import type * as getLastCommits from '#lib/lexicons/dev/knotless/getLastCommits.ts';
import type * as getRepo from '#lib/lexicons/dev/knotless/getRepo.ts';
import type * as listBranches from '#lib/lexicons/dev/knotless/listBranches.ts';
import type * as listTags from '#lib/lexicons/dev/knotless/listTags.ts';
import { urlPath } from '#lib/format.ts';
type Load = {
fetch: typeof fetch;
setHeaders: (headers: Record<string, string>) => void;
url: URL;
};
/** What the query `Q` answers. */
export type Answer<Q extends Query> = InferMethodOutputBody<Q, never>;
/** A query's parameters as its address has them: an array's values each under its name. */
function searched(params: object): URLSearchParams {
const query = new URLSearchParams();
for (const [k, v] of Object.entries(params))
for (const one of v === undefined ? [] : [v].flat()) query.append(k, String(one));
return query;
}
/** What the query `Q` is asked with, its defaults left to the server. */
export type Asked<Q extends Query> = InferInput<Q['parameters']>;
/**
* What the XRPC query `Q`, by its NSID `nsid`, answers, for this page,
* given `params`. The page is cached as the answer is, and a failure is
* the page's, but for a thing not there when the page can do `missing` it,
* as null.
*/
export async function xrpc<Q extends Query>(
load: Load,
nsid: Q['nsid'],
params: Asked<Q>,
missing: true
): Promise<Answer<Q> | null>;
export async function xrpc<Q extends Query>(
load: Load,
nsid: Q['nsid'],
params: Asked<Q>,
missing?: false
): Promise<Answer<Q>>;
export async function xrpc<Q extends Query>(
{ fetch, setHeaders }: Load,
nsid: Q['nsid'],
params: Asked<Q>,
missing = false
): Promise<Answer<Q> | null> {
const query = searched(params);
const q = query.size ? `?${query}` : '';
// Asked again each time, past an answer a browser kept from when answers
// at a commit hash were sent as immutable.
const res = await fetch(`/xrpc/${nsid}${q}`, { cache: 'no-cache' });
if (!res.ok) {
const failure: Partial<LexErrorData> | null = await res.json().catch(() => null);
// An XRPC method says a thing is not there by the name of its failure.
const status = failure?.error?.endsWith('NotFound') ? 404 : res.status;
if (status === 404 && missing) return null;
error(status, failure?.message ?? res.statusText);
}
// An answer for one reader alone makes the whole page theirs alone, as
// the page hook marks it; a page asks several, so it is not set here.
const cache = res.headers.get('cache-control');
if (cache && !/\bprivate\b/.test(cache)) setHeaders({ 'cache-control': cache });
return res.json();
}
/**
* The repository the page's path names, and what the XRPC query `Q`, by
* its NSID `nsid`, answers of it given `params`, as `xrpc` has them. A
* repository renamed, or moved to another account, sends the page on to
* the same path under the name it has now.
*/
export async function repoPage<Q extends Query>(
load: Load & { params: { authority: string; repo: string } },
nsid: Q['nsid'],
params: Omit<Asked<Q>, 'repo'> = {} as Omit<Asked<Q>, 'repo'>
): Promise<{ repo: Answer<typeof getRepo.main>; answer: Answer<Q> }> {
const { authority, repo: name } = load.params;
const uri: AtUriString = `at://${authority as AtIdentifierString}/dev.knotless.repo/${name}`;
// The page is cached as the query's answer is, not the repository's.
const quiet = { ...load, setHeaders: () => {} };
const [repo, answer] = await Promise.all([
xrpc<typeof getRepo.main>(quiet, 'dev.knotless.getRepo', { repo: uri }),
xrpc<Q>(load, nsid, { repo: uri, ...params } as Asked<Q>)
]);
const { did, handle } = repo.owner;
const owner = authority === did || authority === handle ? authority : handle;
if (owner !== authority || repo.name !== name) {
const rest = load.url.pathname.split('/').slice(3);
redirect(
307,
`${['', urlPath(owner), urlPath(repo.name), ...rest].join('/')}${load.url.search}`
);
}
return { repo, answer };
}
/**
* The names of the branches of the repository the page's path names, the
* default first, as many as one page of `listBranches` holds: none from
* a server that does not answer it.
*/
export async function branches(
load: Load & { params: { authority: string; repo: string } }
): Promise<string[]> {
const { authority, repo: name } = load.params;
const uri: AtUriString = `at://${authority as AtIdentifierString}/dev.knotless.repo/${name}`;
// The page is cached as its own query's answer is.
const quiet = { ...load, setHeaders: () => {} };
const answer = await xrpc<typeof listBranches.main>(
quiet,
'dev.knotless.listBranches',
{ repo: uri, limit: 1000 },
true
);
return answer?.branches.map((b) => b.name) ?? [];
}
/**
* The names of the tags of the repository the page's path names, newest
* first, as many as one page of `listTags` holds: none from a server that
* does not answer it.
*/
export async function tags(
load: Load & { params: { authority: string; repo: string } }
): Promise<string[]> {
const { authority, repo: name } = load.params;
const uri: AtUriString = `at://${authority as AtIdentifierString}/dev.knotless.repo/${name}`;
const quiet = { ...load, setHeaders: () => {} };
const answer = await xrpc<typeof listTags.main>(
quiet,
'dev.knotless.listTags',
{ repo: uri, limit: 1000 },
true
);
return answer?.tags.map((t) => t.name) ?? [];
}
/** Bytes as atproto's JSON writes them, `{"$bytes": base64}`; anything else as it is. */
function lexJson(_: string, value: unknown): unknown {
if (!(value instanceof Uint8Array)) return value;
let text = '';
for (const b of value) text += String.fromCharCode(b);
return { $bytes: btoa(text) };
}
/**
* What the XRPC procedure `P`, by its NSID `nsid`, answers `input` with,
* asked from the page as whoever is logged in to it; or, failing, why, in
* the server's words.
*/
export async function call<P extends Procedure>(
nsid: P['nsid'],
input: InferMethodInputBody<P, never>
): Promise<InferMethodOutputBody<P, never>> {
const res = await fetch(`/xrpc/${nsid}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input, lexJson)
});
if (!res.ok) {
const failure: Partial<LexErrorData> | null = await res.json().catch(() => null);
throw new Error(failure?.message ?? res.statusText);
}
return res.json();
}
/**
* What the XRPC procedure `P`, by its NSID `nsid`, answers `body` with,
* sent as it is, of its own type, rather than as JSON: as `call` asks,
* with `params` in its query.
*/
export async function upload<P extends Procedure>(
nsid: P['nsid'],
body: Blob,
params: Record<string, string> = {}
): Promise<InferMethodOutputBody<P, never>> {
const query = new URLSearchParams(params).toString();
const res = await fetch(`/xrpc/${nsid}${query ? `?${query}` : ''}`, {
method: 'POST',
headers: { 'content-type': body.type || 'application/octet-stream' },
body
});
if (!res.ok) {
const failure: Partial<LexErrorData> | null = await res.json().catch(() => null);
throw new Error(failure?.message ?? res.statusText);
}
return res.json();
}
/** What `getLastCommits` answers of a directory: null when it cannot. */
export type LastCommits = Answer<typeof getLastCommits.main> | null;
/** How long a page drawn on the server waits for its directory's last commits. */
const LAST_COMMITS_WAIT = 250;
/**
* The newest commit to change each entry of the directory at `ref` (which
* may run on into its path) of the repository the page's path names. Its
* first walk of a long history takes a while, so a page waits for it only
* a moment on the server, where it is most often kept already, and not at
* all in the browser: past that, it is drawn without them, and they are
* filled in as they come. In a list, so that a promise stays one.
*/
export async function lastCommits(
load: Load & { params: { authority: string; repo: string } },
ref?: string
): Promise<[LastCommits | Promise<LastCommits>]> {
const { authority, repo: name } = load.params;
const uri: AtUriString = `at://${authority as AtIdentifierString}/dev.knotless.repo/${name}`;
// The page is cached as its own query's answer is.
const quiet = { ...load, setHeaders: () => {} };
const asked = xrpc<typeof getLastCommits.main>(
quiet,
'dev.knotless.getLastCommits',
{ repo: uri, ref },
true
).catch(() => null);
if (browser) return [asked];
const late = new Promise<'late'>((done) => setTimeout(() => done('late'), LAST_COMMITS_WAIT));
const first = await Promise.race([asked, late]);
return [first === 'late' ? asked : first];
}
/**
* What the XRPC query `Q`, by its NSID `nsid`, answers `params` with,
* asked from the page as whoever is logged in to it, after it is drawn;
* or, failing, why, in the server's words.
*/
export async function ask<Q extends Query>(nsid: Q['nsid'], params: Asked<Q>): Promise<Answer<Q>> {
const res = await fetch(`/xrpc/${nsid}?${searched(params)}`, { cache: 'no-cache' });
if (!res.ok) {
const failure: Partial<LexErrorData> | null = await res.json().catch(() => null);
throw new Error(failure?.message ?? res.statusText);
}
return res.json();
}
/**
* Whether `owner`, by handle, has a repository named `name`, as this
* server's index has them: true taken, false free, null when it cannot
* say. A name a repository had before, which leads to it under the name it
* has now, is free: a new repository takes it over.
*/
export async function nameTaken(
owner: string,
name: string,
signal?: AbortSignal
): Promise<boolean | null> {
const repo = `at://${owner}/dev.knotless.repo/${name}`;
const res = await fetch(`/xrpc/dev.knotless.getRepo?${searched({ repo })}`, {
cache: 'no-cache',
signal
}).catch(() => null);
if (!res) return null;
if (!res.ok) {
const failure: Partial<LexErrorData> | null = await res.json().catch(() => null);
return failure?.error === 'NotFound' ? false : null;
}
const view: Answer<typeof getRepo.main> | null = await res.json().catch(() => null);
if (!view) return null;
return view.name === name && view.owner.handle === owner;
}