chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* A client for the dev.pdsjs.git methods, for a page or a tool reading a
* repository's server. One call per method, each answering the view its
* lexicon in api-lexicon.js describes; a refusal throws an XrpcError
* carrying the name the server gave.
*
* describe() asks community.lexicon.service.describe once and keeps the
* answer, so serves() tells a server that predates a method, or runs with
* the file browser off, from one that answers it.
*
* This module must stay free of node-only imports; it runs in the browser.
*/
import { GIT_METHODS } from './api-lexicon.js';
import { XrpcError } from './xrpc-error.js';
export { XrpcError };
/**
* @typedef {Object} RepoView
* @property {string} name
* @property {string} uri
* @property {string} cid - the record version, which a read of the repository names to be answered immutably
* @property {string|null} space
* @property {boolean} spaceOwned
* @property {string|null} description
* @property {string|null} defaultBranch
* @property {Array<{name: string, sha: string}>} refs
* @property {number} branches
* @property {number} tags
* @property {number} bundles
* @property {number} parts
* @property {number} size
* @property {string|null} createdAt
* @property {string|null} updatedAt
* @property {string[]} protectedBranches
* @property {string|null} runner
* @property {Array<{registry: string, name: string}>} packages
* @property {string} cloneUrl
* @property {string|null} httpCloneUrl
* @property {number} [openPulls] - open pull requests, present only when the listing asked for a summary
* @property {number} [openIssues] - open issues, present only when the listing asked for a summary
* @property {number[]} [activity] - recent commit dates for the gauge, present only when the listing asked for a summary
*/
/**
* @typedef {Object} TreeEntry
* @property {string} name
* @property {'dir'|'file'|'symlink'|'submodule'} type
* @property {string} mode
* @property {string} sha
* @property {number|null} size
*/
/**
* @typedef {Object} TreeView
* @property {string} ref
* @property {string} sha
* @property {string} path
* @property {TreeEntry[]} entries
*/
/**
* @typedef {Object} FileView
* @property {string} ref
* @property {string} path
* @property {number} size
* @property {string} mode
* @property {string|null} mediaType
* @property {boolean} binary
* @property {boolean} truncated
* @property {string|null} text
*/
/**
* @typedef {Object} CommitView
* @property {string} sha
* @property {string[]} parents
* @property {string} author
* @property {number} authoredAt
* @property {number} committedAt
* @property {string} message
*/
/**
* @typedef {Object} DiffLine
* @property {'context'|'add'|'delete'} type
* @property {string} text
* @property {number|null} oldLine
* @property {number|null} newLine
*/
/**
* @typedef {Object} DiffHunk
* @property {number} oldStart
* @property {number} oldLines
* @property {number} newStart
* @property {number} newLines
* @property {DiffLine[]} lines
*/
/**
* @typedef {Object} FileDiff
* @property {string} path
* @property {'added'|'deleted'|'modified'} status
* @property {string|null} oldMode
* @property {string|null} newMode
* @property {number|null} oldSize
* @property {number|null} newSize
* @property {boolean} binary
* @property {string|null} mediaType
* @property {DiffHunk[]|null} hunks
* @property {string|null} oldText
* @property {string|null} newText
* @property {number} additions
* @property {number} deletions
*/
/**
* @typedef {CommitView & {files: FileDiff[]}} CommitDetail
*/
/**
* @typedef {Object} CheckView
* @property {string} ref
* @property {string} workflow
* @property {{uri: string, cid: string}} [subject]
* @property {{uri: string, cid: string}} [check]
* @property {string} sha
* @property {'running'|'success'|'failure'|'error'} status
* @property {string} startedAt
* @property {string} [finishedAt]
*/
/**
* @typedef {Object} ProfileView
* @property {string} did
* @property {string} handle
* @property {string} displayName
* @property {string} avatar
*/
/**
* @typedef {Object} ClaimView
* @property {string} name
* @property {string} email
* @property {string} label
* @property {string} did
* @property {string} avatar
* @property {ProfileView|null} profile
*/
/**
* The parameters a read of one repository takes. `cid` names the record
* version the caller read: current, the answer caches immutably; stale, the
* server answers RepoNotFound.
* @typedef {Object} RepoParams
* @property {string} repo - repository name, the record key
* @property {string} [space] - the space holding it; needs the account session
* @property {string} [cid]
* @property {string} [from] - the account whose copy to read, for a branch in a collaborator's or a fork's copy
*/
/** @typedef {RepoParams & {ref?: string}} RefParams */
/** @typedef {RefParams & {path?: string}} PathParams */
/** @typedef {import('./pull-requests.js').Contributor} ContributorView */
/** @typedef {import('./pull-requests.js').PullRequest} PullRequestView */
const DESCRIBE = 'community.lexicon.service.describe';
/**
* @param {Response} res
* @returns {Promise<never>}
*/
async function refused(res) {
/** @type {{error?: string, message?: string}} */
let body = {};
try {
body = /** @type {{error?: string, message?: string}} */ (await res.json());
} catch {
// A body that is not JSON carries no name.
}
throw new XrpcError(
res.status,
body.error ?? (res.status === 501 ? 'MethodNotImplemented' : 'Unknown'),
body.message ?? `request failed with status ${res.status}`,
);
}
export class GitClient {
/**
* @param {Object} options
* @param {string} options.service - the PDS base URL
* @param {typeof fetch} [options.fetch] - what carries the request; the global by default
* @param {Record<string, string>} [options.headers] - sent with every request, an Authorization say
*/
constructor({ service, fetch: carrier, headers = {} }) {
this.service = service.replace(/\/+$/, '');
this.headers = headers;
/** @type {typeof fetch} */
this.fetch = carrier ?? ((input, init) => globalThis.fetch(input, init));
/** @type {Promise<Set<string>>|null} */
this.methods = null;
}
/**
* The methods the server serves, asked once. A server that cannot answer
* describe reads as serving none, which sends a caller to its fallback
* rather than to a failure.
* @returns {Promise<Set<string>>}
*/
describe() {
this.methods ??= (async () => {
try {
const res = await this.fetch(`${this.service}/xrpc/${DESCRIBE}`, {
headers: this.headers,
});
if (!res.ok) return new Set();
const { methods } =
/** @type {{methods?: Array<{value?: unknown}>}} */ (
await res.json()
);
return new Set(
(methods ?? [])
.map((entry) => entry.value)
.filter((value) => typeof value === 'string'),
);
} catch {
return new Set();
}
})();
return this.methods;
}
/**
* Whether the server answers a method, by its short name (`getTree`) or
* its NSID.
* @param {string} method
*/
async serves(method) {
const nsid = method.includes('.') ? method : `dev.pdsjs.git.${method}`;
return (await this.describe()).has(nsid);
}
/**
* Whether the server answers every read of the bundle chain.
* @returns {Promise<boolean>}
*/
async servesReads() {
const served = await this.describe();
return GIT_METHODS.every(
(nsid) =>
!READS.has(nsid.slice('dev.pdsjs.git.'.length)) || served.has(nsid),
);
}
/**
* @param {string} method - short name
* @param {Record<string, string|number|undefined>} [params]
* @returns {Promise<Response>}
*/
async get(method, params = {}) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== '') query.set(key, String(value));
}
const suffix = query.size ? `?${query}` : '';
const res = await this.fetch(
`${this.service}/xrpc/dev.pdsjs.git.${method}${suffix}`,
{ headers: this.headers },
);
if (!res.ok) await refused(res);
return res;
}
/**
* @param {string} method - short name
* @param {Record<string, string|number|undefined>} [params]
* @returns {Promise<any>}
*/
async query(method, params) {
return (await this.get(method, params)).json();
}
/**
* @param {string} method - short name
* @param {unknown} body
* @returns {Promise<any>}
*/
async procedure(method, body) {
const res = await this.fetch(
`${this.service}/xrpc/dev.pdsjs.git.${method}`,
{
method: 'POST',
headers: { ...this.headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
},
);
if (!res.ok) await refused(res);
return res.json();
}
/**
* @param {{summary?: boolean}} [params] - summary asks the server for each
* public repository's open pull request and issue counts and its recent
* commit dates
* @returns {Promise<{httpEnabled: boolean, repos: RepoView[]}>}
*/
listRepos(params) {
return this.query(
'listRepos',
params?.summary ? { summary: 'true' } : undefined,
);
}
/** @returns {Promise<{owner: ProfileView|null, claims: ClaimView[]}>} */
getAuthors() {
return this.query('getAuthors');
}
/**
* @param {{repo: string, space?: string}} params
* @returns {Promise<RepoView>}
*/
getRepo(params) {
return this.query('getRepo', params);
}
/**
* @param {PathParams} params
* @returns {Promise<TreeView>}
*/
getTree(params) {
return this.query('getTree', params);
}
/**
* @param {RefParams} params
* @returns {Promise<{ref: string, files: Array<{path: string, size: number}>, truncated: boolean}>}
*/
listFiles(params) {
return this.query('listFiles', params);
}
/**
* @param {PathParams & {path: string}} params
* @returns {Promise<FileView>}
*/
getFile(params) {
return this.query('getFile', params);
}
/**
* The file's bytes, as a Response whose Content-Type names an image's
* media type, text/plain for text, and an octet stream otherwise.
* @param {PathParams & {path: string}} params
* @returns {Promise<Response>}
*/
getRaw(params) {
return this.get('getRaw', params);
}
/**
* @param {RefParams & {limit?: number, range?: string, path?: string}} params - `range` as base..tip lists the commits across it in place of a ref; `path` narrows the history to the commits that changed it
* @returns {Promise<{ref: string, commits: CommitView[], truncated: boolean}>}
*/
listCommits(params) {
return this.query('listCommits', params);
}
/**
* @param {RepoParams & {sha: string}} params
* @returns {Promise<CommitDetail>}
*/
getCommit(params) {
return this.query('getCommit', params);
}
/**
* @param {PathParams} params
* @returns {Promise<{commit: CommitView|null}>}
*/
getLastCommit(params) {
return this.query('getLastCommit', params);
}
/**
* @param {{repo: string, space?: string}} params
* @returns {Promise<{runner: string|null, service?: string, unreachable?: boolean, checks: CheckView[]}>}
*/
listChecks(params) {
return this.query('listChecks', params);
}
/**
* @param {{repo: string, space?: string}} params
* @returns {Promise<{runner: string|null, service?: string, unreachable?: boolean, runs: Array<Record<string, unknown> & {rkey?: string, sha: string, status: string, startedAt: string}>}>}
*/
listCheckRuns(params) {
return this.query('listCheckRuns', params);
}
/**
* @param {{repo: string, space?: string}} params
* @returns {Promise<{open: PullRequestView[], merged: PullRequestView[], collaborators: ContributorView[], reviews: Array<[string, Array<Record<string, unknown>>]>, checks: Array<[string, Array<Record<string, unknown>>]>, configured: boolean}>}
*/
listPulls(params) {
return this.query('listPulls', params);
}
/**
* @param {{repo: string, space?: string}} params
* @returns {Promise<{statements: Array<Record<string, unknown> & {uri: string, cid: string, by: ContributorView, indexed?: boolean}>}>}
*/
listIssues(params) {
return this.query('listIssues', params);
}
/**
* @param {{name: string, description?: string, space?: string, type?: string, skey?: string}} body
* @returns {Promise<{name: string, space: string|null}>}
*/
createRepo(body) {
return this.procedure('createRepo', body);
}
/**
* @param {{name: string, space?: string, description?: string, defaultBranch?: string, protectedBranches?: string[], runner?: string|null, packages?: Array<{registry: string, name: string}>}} body
* @returns {Promise<{name: string, space: string|null, protectedBranches?: string[], runner?: string|null, packages?: Array<{registry: string, name: string}>}>}
*/
updateSettings(body) {
return this.procedure('updateSettings', body);
}
/**
* @param {{name: string, ref: string}} body
* @returns {Promise<{runner: string, ref: string}>}
*/
requestCheck(body) {
return this.procedure('requestCheck', body);
}
/**
* @param {{name: string, did: string, ref: string, mode?: 'merge'|'squash',
* handle?: string, subject?: string, body?: string}} body
* @returns {Promise<{sha: string, ref: string, mode: string, objects: number}>}
*/
mergePull(body) {
return this.procedure('mergePull', body);
}
}
/** The methods that read the bundle chain, registered only with a browser. */
const READS = new Set([
'getTree',
'listFiles',
'getFile',
'getRaw',
'listCommits',
'getCommit',
'getLastCommit',
]);