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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
281 lines · 10.2 KB Raw History
// 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;
}