// 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) => void; url: URL; }; /** What the query `Q` answers. */ export type Answer = InferMethodOutputBody; /** 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 = InferInput; /** * 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( load: Load, nsid: Q['nsid'], params: Asked, missing: true ): Promise | null>; export async function xrpc( load: Load, nsid: Q['nsid'], params: Asked, missing?: false ): Promise>; export async function xrpc( { fetch, setHeaders }: Load, nsid: Q['nsid'], params: Asked, missing = false ): Promise | 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 | 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( load: Load & { params: { authority: string; repo: string } }, nsid: Q['nsid'], params: Omit, 'repo'> = {} as Omit, 'repo'> ): Promise<{ repo: Answer; answer: Answer }> { 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(quiet, 'dev.knotless.getRepo', { repo: uri }), xrpc(load, nsid, { repo: uri, ...params } as Asked) ]); 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 { 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( 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 { 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( 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

( nsid: P['nsid'], input: InferMethodInputBody ): Promise> { const res = await fetch(`/xrpc/${nsid}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input, lexJson) }); if (!res.ok) { const failure: Partial | 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

( nsid: P['nsid'], body: Blob, params: Record = {} ): Promise> { 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 | 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 | 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]> { 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( 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(nsid: Q['nsid'], params: Asked): Promise> { const res = await fetch(`/xrpc/${nsid}?${searched(params)}`, { cache: 'no-cache' }); if (!res.ok) { const failure: Partial | 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 { 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 | null = await res.json().catch(() => null); return failure?.error === 'NotFound' ? false : null; } const view: Answer | null = await res.json().catch(() => null); if (!view) return null; return view.name === name && view.owner.handle === owner; }