chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/** @param {string|null|undefined} iso @returns {string} */
export function day(iso) {
return iso ? iso.slice(0, 10) : '';
}
/** @param {string|null|undefined} iso @returns {string} */
export function dateTime(iso) {
if (!iso) return '';
const parsed = new Date(iso);
if (Number.isNaN(parsed.getTime())) return iso;
return parsed.toLocaleString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
/** @param {string} scope @returns {string[]} */
export function scopeTokens(scope) {
return (scope || 'atproto').split(/\s+/).filter(Boolean);
}
/** @param {number|null|undefined} n @returns {string} */
export function num(n) {
return (n ?? 0).toLocaleString('en-US');
}
/** Human-readable bytes like "1.5 MB". @param {number|null|undefined} n */
export function bytes(n) {
if (n == null) return '';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = n;
let unit = 0;
do {
value /= 1024;
unit += 1;
} while (value >= 1024 && unit < units.length);
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit - 1]}`;
}
/** A short relative time like "12m ago". @param {string|null} iso */
export function timeAgo(iso) {
if (!iso) return '';
const seconds = Math.max(
0,
Math.floor((Date.now() - Date.parse(iso)) / 1000),
);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
/**
* The registrable-ish domain behind an NSID, for a favicon lookup: the first
* two authority segments, reversed. `app.bsky.feed.post` -> `bsky.app`.
* @param {string} nsid @returns {string}
*/
export function nsidDomain(nsid) {
const parts = String(nsid).split('.');
return parts.length < 2 ? String(nsid) : `${parts[1]}.${parts[0]}`;
}
// Tails that say nothing on their own, so the category before them is kept.
const GENERIC_TAIL = new Set([
'item',
'items',
'record',
'records',
'entry',
'entries',
'object',
'objects',
'thing',
'things',
'def',
'definition',
'decl',
'declaration',
]);
/**
* A plain-language noun for a record type, from its NSID, so a person reads
* "a post" rather than "app.bsky.feed.post". Normally the last segment carries
* the meaning (`…feed.post` -> "post"); when it is a generic tail the category
* before it comes along (`social.grain.gallery.item` -> "gallery item"), unless
* that would only reach back into the authority.
* @param {string} nsid @returns {string}
*/
export function recordNoun(nsid) {
const parts = String(nsid).split('.');
const humanize = (/** @type {string} */ s) =>
s.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
const last = parts[parts.length - 1] || String(nsid);
if (GENERIC_TAIL.has(last.toLowerCase()) && parts.length >= 4) {
return `${humanize(parts[parts.length - 2])} ${humanize(last)}`;
}
return humanize(last);
}
/**
* "a" or "an" to fit the word that follows.
* @param {string} word @returns {string}
*/
export function article(word) {
return /^[aeiou]/i.test(word) ? 'an' : 'a';
}
/**
* Naive English plural for a record noun: "gallery" -> "galleries",
* "photo" -> "photos", "gallery item" -> "gallery items".
* @param {string} noun @returns {string}
*/
export function pluralNoun(noun) {
if (/[^aeiou]y$/.test(noun)) return `${noun.slice(0, -1)}ies`;
if (/(s|x|z|ch|sh)$/.test(noun)) return `${noun}es`;
return `${noun}s`;
}
/**
* Pick the variant of a translated string that best fits the reader's
* languages: exact locale, then bare language, then any region of the same
* language, then the untagged base (English by lexicon convention).
* @param {string|null} base
* @param {Record<string, string>} [byLang]
* @returns {string|null}
*/
export function pickLangString(base, byLang = {}) {
const locales =
typeof navigator === 'undefined' ? [] : navigator.languages || [];
for (const locale of locales) {
if (byLang[locale]) return byLang[locale];
const lang = locale.split('-')[0];
if (byLang[lang]) return byLang[lang];
// The untagged base is English by lexicon convention, so it is the
// English candidate at English's position in the reader's order. Without
// this, a translation in any later-preferred language would beat it.
if (lang === 'en' && base != null) return base;
const sameLang = Object.keys(byLang).find(
(key) => key.split('-')[0] === lang,
);
if (sameLang) return byLang[sameLang];
}
return base;
}
/**
* Fold a repo's collections into app-like buckets keyed by their authority
* domain, summing record counts and keeping each collection under its app.
* Built straight from /overview's collections. Sorted by record count, most
* first, and so are the collections within each bucket.
* @param {{name: string, count: number}[]} collections
* @returns {{domain: string, count: number, collections: {name: string, count: number}[]}[]}
*/
export function groupByApp(collections) {
/** @type {Map<string, {domain: string, count: number, collections: {name: string, count: number}[]}>} */
const map = new Map();
for (const c of collections || []) {
const domain = nsidDomain(c.name);
const group = map.get(domain) || { domain, count: 0, collections: [] };
group.count += c.count;
group.collections.push(c);
map.set(domain, group);
}
for (const group of map.values()) {
group.collections.sort((a, b) => b.count - a.count);
}
return [...map.values()].sort((a, b) => b.count - a.count);
}
/**
* Fold an overview's per-collection creation histogram into one series for an
* app, by summing every collection under the app's domain. `recordActivity` is
* the `recordActivity` block from /overview: `{ buckets, series }`, where each
* series is bucket counts oldest to newest across a fixed window the server
* fixed. Returns the app's summed counts, or null when it had no records
* created in the window (so the row can skip the sparkline entirely). A row
* that absorbed other domains' records passes them as `folded` so their
* activity counts too.
* @param {{buckets?: number, series?: Record<string, number[]>}|null|undefined} recordActivity
* @param {string} domain
* @param {string[]} [folded]
* @returns {number[]|null}
*/
export function appActivitySeries(recordActivity, domain, folded = []) {
const series = recordActivity?.series;
const buckets = recordActivity?.buckets || 0;
if (!series || !buckets) return null;
let out = null;
for (const [name, counts] of Object.entries(series)) {
const owner = nsidDomain(name);
if (owner !== domain && !folded.includes(owner)) continue;
if (!out) out = new Array(buckets).fill(0);
for (let i = 0; i < buckets; i++) out[i] += counts[i] || 0;
}
return out;
}
/** The host an OAuth client is identified by, for its name and favicon. */
/** @param {string} clientId @returns {string} */
export function clientHost(clientId) {
try {
return new URL(clientId).host;
} catch {
return clientId;
}
}
/**
* The NSIDs a session's granular scope tokens name: `repo:sh.tangled.repo`
* and `rpc:sh.tangled.ci.triggerPipeline?aud=*` both name an `sh.tangled.*`
* NSID, and the query forms `repo?collection=…` and `rpc?lxm=…` count the
* same way. A generic grant (`atproto`, `transition:generic`) names none,
* and a bare wildcard says nothing about an authority, so neither
* contributes.
* @param {string} scope @returns {string[]}
*/
export function scopeNsids(scope) {
/** @type {string[]} */
const out = [];
const keep = (/** @type {string} */ raw) => {
const nsid = raw.replace(/\.\*$/, '');
if (nsid !== '*' && nsid.includes('.')) out.push(nsid);
};
for (const token of scopeTokens(scope)) {
const path = /^(?:repo|rpc):([^?]+)/.exec(token);
if (path) {
keep(decodeURIComponent(path[1]));
continue;
}
const query = /^(?:repo|rpc)\?(.+)$/.exec(token);
if (!query) continue;
const params = new URLSearchParams(query[1]);
for (const name of [
...params.getAll('collection'),
...params.getAll('lxm'),
]) {
keep(name);
}
}
return out;
}
/**
* The name an app goes by once its TLD is dropped — `tangled.sh` and
* `tangled.org` both read "tangled". Approximate on purpose (no public-suffix
* list): the segment left of the last one, which is where registrable app
* domains carry their name.
* @param {string} domain @returns {string}
*/
function appLabel(domain) {
const parts = String(domain).toLowerCase().split('.');
return parts.length >= 2 ? parts[parts.length - 2] : (parts[0] ?? '');
}
/**
* Pairs of Apps rows that look like one app split by a rebrand: it signs in
* from its new domain while its records keep the NSID authority of the old
* one, so one row holds only sessions and the other only records. The
* signature is deliberately narrow — that complement plus a shared name
* label — so a look-alike domain the user once authorized is never folded
* into a trusted row on its own say-so. A session that announces granular
* scopes must actually cover the record row's authority, or the pair is
* dropped: a third-party client writing another app's lexicons is not that
* app. Pairs already combined or turned down are skipped. Each offer folds
* `from` into `to`, the name the app is known by: a client that is just a
* subdomain of the record authority folds into the apex (api.currents.is
* into currents.is), while a rebrand keeps the live client host on top
* (tangled.sh folds into tangled.org).
* @param {{domain: string, count: number, sessions: {scope?: string}[]}[]} apps
* @param {{aliases?: Record<string, string>, dismissedPairs?: string[]}} [prefs]
* @returns {{from: string, to: string}[]}
*/
export function combineOffers(apps, prefs = {}) {
const dismissed = new Set(prefs.dismissedPairs || []);
const aliased = new Set(Object.keys(prefs.aliases || {}));
const sessionOnly = apps.filter((a) => a.sessions.length > 0 && !a.count);
const recordOnly = apps.filter((a) => a.count > 0 && !a.sessions.length);
const offers = [];
for (const sessionRow of sessionOnly) {
for (const recordRow of recordOnly) {
if (appLabel(recordRow.domain) !== appLabel(sessionRow.domain)) continue;
const named = sessionRow.sessions.flatMap((s) =>
scopeNsids(s.scope || ''),
);
if (
named.length &&
!named.some((n) => nsidDomain(n) === recordRow.domain)
) {
continue;
}
const offer = sessionRow.domain.endsWith(`.${recordRow.domain}`)
? { from: sessionRow.domain, to: recordRow.domain }
: { from: recordRow.domain, to: sessionRow.domain };
if (aliased.has(offer.from)) continue;
if (dismissed.has(`${offer.from}>${offer.to}`)) continue;
offers.push(offer);
}
}
return offers;
}
/**
* One row per app, keyed by domain, joining what an app has stored (its
* collections, from /overview) with the access it holds (its OAuth sessions,
* from /apps). One app can hold several sessions at once, one per device or
* reauthorization, so they are collected rather than overwritten. An app can
* appear with data but no session, or the reverse. Rows sort by when the app
* was last active — the newest of its session accesses, its record writes and
* its writes to the spaces it manages — and fall back to record count when no
* side has a time.
* Rows the user combined (`aliases`, from-domain to to-domain) fold together:
* the from row's records move under the to row, which remembers the fold in
* `folded` so screens can say where its records really live.
* @param {{name: string, count: number, lastRecordAt?: number|null}[]} collections
* @param {{clientId: string, scope: string, authorizedAt: string|null, lastAccessedAt: string|null, sessionId: string|null}[]} [oauthApps]
* @param {Record<string, string>} [aliases]
* @param {{spaceType: string, collections?: {collection: string, lastRecordAt?: number|null}[]}[]} [spaces]
* @returns {{domain: string, count: number, collections: {name: string, count: number, lastRecordAt?: number|null}[], sessions: object[], spaces: object[], folded?: string[]}[]}
*/
export function mergeApps(
collections,
oauthApps = [],
aliases = {},
spaces = [],
) {
const map = new Map();
for (const group of groupByApp(collections)) {
map.set(group.domain, { ...group, sessions: [] });
}
for (const app of oauthApps) {
const domain = clientHost(app.clientId);
const entry = map.get(domain) || {
domain,
count: 0,
collections: [],
sessions: [],
};
entry.sessions.push(app);
map.set(domain, entry);
}
for (const [from, to] of Object.entries(aliases || {})) {
const source = map.get(from);
if (!source || from === to) continue;
const target = map.get(to) || {
domain: to,
count: 0,
collections: [],
sessions: [],
};
target.count += source.count;
target.collections = [...target.collections, ...source.collections].sort(
(a, b) => b.count - a.count,
);
target.sessions = [...target.sessions, ...source.sessions];
target.folded = [...(target.folded || []), from];
map.delete(from);
map.set(to, target);
}
const rows = [...map.values()];
for (const row of rows) {
row.spaces = spacesForApp(spaces, [row.domain, ...(row.folded || [])]);
}
return rows.sort(
(a, b) =>
(Date.parse(lastActiveAt(b) ?? '') || 0) -
(Date.parse(lastActiveAt(a) ?? '') || 0) || b.count - a.count,
);
}
/**
* The spaces one app manages, out of every space the account holds: a space
* belongs to the app whose lexicon defines its type, or one of its permissioned
* collections.
* @template {{spaceType: string, collections?: {collection: string}[]}} T
* @param {T[]|null|undefined} spaces
* @param {string[]} domains - the app's domain, and any folded into it
* @returns {T[]}
*/
export function spacesForApp(spaces, domains) {
return (spaces || []).filter(
(space) =>
domains.includes(nsidDomain(space.spaceType)) ||
(space.collections || []).some((collection) =>
domains.includes(nsidDomain(collection.collection)),
),
);
}
/**
* The most recent time an app touched the account, as an ISO string, or null
* when nothing is known: the newest of its OAuth sessions' access times, its
* collections' latest record writes, and the latest writes to the spaces it
* manages. So a data-only app reads as active by when it last wrote, not only
* apps that hold a session, and an app that keeps its data in a shared space
* reads as active by that. Used both to order the Apps list and to label each
* row.
* @param {{sessions?: {lastAccessedAt?: string|null}[], collections?: {lastRecordAt?: number|null}[], spaces?: {collections?: {lastRecordAt?: number|null}[]}[]}} app
* @returns {string|null}
*/
export function lastActiveAt(app) {
let newestMs = 0;
for (const session of app.sessions || []) {
const at = Date.parse(session.lastAccessedAt ?? '');
if (at > newestMs) newestMs = at;
}
const collections = [
...(app.collections || []),
...(app.spaces || []).flatMap((space) => space.collections || []),
];
for (const collection of collections) {
const at = collection.lastRecordAt;
if (typeof at === 'number' && at > newestMs) newestMs = at;
}
return newestMs ? new Date(newestMs).toISOString() : null;
}