chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Turning what the endpoints return into what a page shows.
import { tidToMs } from '@pdsjs/core/repo';
import { parseSpaceUri } from '@pdsjs/spaces/uri';
import {
ACCESS_LABELS,
accessKind,
allowedOf,
isPublic,
POLICY_LABELS,
policyKind,
} from '#/lib/policy.js';
/**
* The two parts of a space URI a heading shows: the type it declares and the
* key that names this one.
* @param {string} uri
* @returns {{spaceDid: string, spaceType: string, skey: string}}
*/
export function spaceParts(uri) {
const { spaceDid, spaceType, skey } = parseSpaceUri(uri);
return { spaceDid, spaceType, skey };
}
/**
* The registrable-ish domain behind an NSID: the first two authority segments,
* reversed. `my.bulletin.board` -> `bulletin.my`.
*
* Two segments rather than the whole authority, because that is the domain the
* app answers on: `app.bsky.feed.post` belongs to `bsky.app`, not to
* `feed.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]}`;
}
/**
* A DID short enough for a line of prose, keeping both ends so two DIDs from
* one directory still read apart.
* @param {string} did
* @returns {string}
*/
export function shortDid(did) {
if (did.length <= 24) return did;
return `${did.slice(0, 16)}…${did.slice(-6)}`;
}
/**
* When a record key says it was written, or null for a key that is not a TID.
* @param {string} rkey
* @returns {Date|null}
*/
export function rkeyTime(rkey) {
const ms = tidToMs(rkey);
return ms === null ? null : new Date(ms);
}
/** @type {Array<[string, number]>} */
const UNITS = [
['year', 31536000000],
['month', 2592000000],
['day', 86400000],
['hour', 3600000],
['minute', 60000],
];
/**
* How long ago something happened, in the coarsest unit that still says
* something. Anything under a minute reads as "just now".
* @param {Date|string|number|null|undefined} when
* @returns {string}
*/
export function ago(when) {
if (!when) return '';
const then = when instanceof Date ? when : new Date(when);
const elapsed = Date.now() - then.getTime();
if (Number.isNaN(elapsed)) return '';
if (elapsed < 60000) return 'just now';
for (const [unit, size] of UNITS) {
if (elapsed >= size) {
const count = Math.floor(elapsed / Number(size));
return `${count} ${unit}${count === 1 ? '' : 's'} ago`;
}
}
return 'just now';
}
/**
* How long something has left, or that it has none. Seconds are a unit here and
* not in `ago`, because a delegation token lives sixty of them.
* @param {Date|string|number|null|undefined} when
* @returns {string}
*/
export function until(when) {
if (!when) return '';
const then = when instanceof Date ? when : new Date(when);
const left = then.getTime() - Date.now();
if (Number.isNaN(left)) return '';
if (left <= 0) return 'expired';
for (const [unit, size] of [...UNITS, ['second', 1000]]) {
if (left >= Number(size)) {
const count = Math.floor(left / Number(size));
return `in ${count} ${unit}${count === 1 ? '' : 's'}`;
}
}
return 'in under a second';
}
/**
* @param {number} bytes
* @returns {string}
*/
export function fileSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Base64 bytes as hex, which is how a commit hash and a MAC are compared by
* eye against another implementation's output.
* @param {{$bytes?: string}|null|undefined} value
* @returns {string}
*/
export function bytesToHex(value) {
if (!value?.$bytes) return '';
const padded = value.$bytes.replace(/-/g, '+').replace(/_/g, '/');
try {
const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '='));
return Array.from(binary, (character) =>
character.charCodeAt(0).toString(16).padStart(2, '0'),
).join('');
} catch {
return value.$bytes;
}
}
/**
* Records grouped by collection, each group counted and stamped with its most
* recent write. This is the space's shape, which no endpoint reports directly.
*
* @param {Array<{collection: string, rkey: string}>} records
* @returns {Array<{collection: string, count: number, lastWriteAt: number|null}>}
*/
export function collectionsOf(records) {
/** @type {Map<string, {count: number, lastWriteAt: number|null}>} */
const groups = new Map();
for (const record of records) {
const group = groups.get(record.collection) || {
count: 0,
lastWriteAt: null,
};
group.count += 1;
const at = tidToMs(record.rkey);
if (at !== null && at > (group.lastWriteAt || 0)) group.lastWriteAt = at;
groups.set(record.collection, group);
}
return [...groups]
.map(([collection, group]) => ({ collection, ...group }))
.sort((a, b) => a.collection.localeCompare(b.collection));
}
/**
* Which policy governs a space, as the page names it.
* @param {any} config
* @returns {string}
*/
export function policyLabel(config) {
const kind = policyKind(config);
return kind ? POLICY_LABELS[kind] : 'Unknown';
}
/**
* Who the policy admits for one kind of access.
* @param {any} config
* @param {'read'|'write'} access
* @returns {string}
*/
export function accessLabel(config, access) {
const kind = policyKind(config);
if (kind === 'memberspace') {
if (isPublic(config, access)) return 'Anyone';
return access === 'read' ? 'Members who may read' : 'Members who may write';
}
return kind ? POLICY_LABELS[kind] : 'Unknown';
}
/**
* The clients a space admits, as one phrase.
* @param {any} config
* @returns {string}
*/
export function appAccessLabel(config) {
if (accessKind(config) !== 'allowList') return ACCESS_LABELS.open;
const count = allowedOf(config).length;
return count === 1
? '1 client on an allow list'
: `${count} clients on an allow list`;
}