chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/spaces/structured-fields - the subset of HTTP structured fields
// (RFC 9651) the space headers use: Dictionaries of strings, integers and byte
// sequences, and inner lists of strings with parameters.
/**
* A Token, kept apart from a String so `type=atproto-space-repo` does not pass
* for `type="atproto-space-repo"`.
*/
export class SfToken {
/** @param {string} value */
constructor(value) {
this.value = value;
}
}
/** @typedef {string|number|boolean|Uint8Array|SfToken} SfBare */
/** @typedef {{value: SfBare, params: Map<string, SfBare>}} SfItem */
/**
* @typedef {{kind: 'item', value: SfBare, params: Map<string, SfBare>}
* | {kind: 'innerList', items: SfItem[], params: Map<string, SfBare>}} SfMember
*/
/**
* Parse a Dictionary. Members of every key are kept, so a request carrying
* several signatures still yields the one a caller asks for.
*
* @param {string} input
* @returns {Map<string, SfMember>}
*/
export function parseDictionary(input) {
const p = { s: input, i: 0 };
/** @type {Map<string, SfMember>} */
const out = new Map();
skipSpaces(p);
if (p.i === p.s.length) return out;
for (;;) {
const key = parseKey(p);
/** @type {SfMember} */
let member;
if (p.s[p.i] === '=') {
p.i++;
member = parseMember(p);
} else {
member = { kind: 'item', value: true, params: parseParams(p) };
}
out.set(key, member);
skipOws(p);
if (p.i === p.s.length) return out;
if (p.s[p.i] !== ',') throw new Error('expected ","');
p.i++;
skipOws(p);
if (p.i === p.s.length) throw new Error('trailing ","');
}
}
/**
* Serialize a Dictionary of bare items without parameters.
* @param {Array<[string, SfBare]>} members
* @returns {string}
*/
export function serializeDictionary(members) {
return members
.map(([key, value]) =>
value === true ? key : `${key}=${serializeBare(value)}`,
)
.join(', ');
}
/**
* Serialize an inner list and its parameters, canonically.
* @param {SfItem[]} items
* @param {Map<string, SfBare>} params
* @returns {string}
*/
export function serializeInnerList(items, params) {
const list = items
.map((item) => serializeBare(item.value) + serializeParams(item.params))
.join(' ');
return `(${list})${serializeParams(params)}`;
}
/**
* @param {Map<string, SfBare>} params
* @returns {string}
*/
function serializeParams(params) {
let out = '';
for (const [key, value] of params) {
out += value === true ? `;${key}` : `;${key}=${serializeBare(value)}`;
}
return out;
}
/**
* @param {SfBare} value
* @returns {string}
*/
export function serializeBare(value) {
if (typeof value === 'string') {
return `"${value.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
}
if (typeof value === 'number') return String(value);
if (typeof value === 'boolean') return value ? '?1' : '?0';
if (value instanceof SfToken) return value.value;
return `:${toPaddedBase64(value)}:`;
}
/**
* A byte sequence is serialized as padded base64 (RFC 9651 §4.1.8).
* @param {Uint8Array} bytes
* @returns {string}
*/
export function toPaddedBase64(bytes) {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
/**
* @param {{s: string, i: number}} p
* @returns {SfMember}
*/
function parseMember(p) {
if (p.s[p.i] === '(') {
p.i++;
/** @type {SfItem[]} */
const items = [];
for (;;) {
skipSpaces(p);
if (p.s[p.i] === ')') {
p.i++;
return { kind: 'innerList', items, params: parseParams(p) };
}
const value = parseBare(p);
items.push({ value, params: parseParams(p) });
if (p.s[p.i] !== ' ' && p.s[p.i] !== ')') {
throw new Error('expected " " or ")"');
}
}
}
const value = parseBare(p);
return { kind: 'item', value, params: parseParams(p) };
}
/**
* @param {{s: string, i: number}} p
* @returns {Map<string, SfBare>}
*/
function parseParams(p) {
/** @type {Map<string, SfBare>} */
const params = new Map();
while (p.s[p.i] === ';') {
p.i++;
skipSpaces(p);
const key = parseKey(p);
/** @type {SfBare} */
let value = true;
if (p.s[p.i] === '=') {
p.i++;
value = parseBare(p);
}
params.set(key, value);
}
return params;
}
/**
* @param {{s: string, i: number}} p
* @returns {SfBare}
*/
function parseBare(p) {
const c = p.s[p.i];
if (c === '"') {
p.i++;
let out = '';
for (;;) {
if (p.i >= p.s.length) throw new Error('unterminated string');
const ch = p.s[p.i++];
if (ch === '\\') {
const next = p.s[p.i++];
if (next !== '"' && next !== '\\') throw new Error('bad escape');
out += next;
} else if (ch === '"') {
return out;
} else {
const code = ch.charCodeAt(0);
if (code < 0x20 || code > 0x7e) throw new Error('bad string char');
out += ch;
}
}
}
if (c === ':') {
const end = p.s.indexOf(':', p.i + 1);
if (end < 0) throw new Error('unterminated byte sequence');
const b64 = p.s.slice(p.i + 1, end);
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(b64)) throw new Error('bad base64');
p.i = end + 1;
return Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
}
if (c === '?') {
const v = p.s[p.i + 1];
if (v !== '0' && v !== '1') throw new Error('bad boolean');
p.i += 2;
return v === '1';
}
const num = /^-?\d{1,15}(\.\d{1,3})?/.exec(p.s.slice(p.i));
if (num) {
p.i += num[0].length;
return Number(num[0]);
}
const token = /^[A-Za-z*][!#$%&'*+\-.^_`|~0-9A-Za-z:/]*/.exec(p.s.slice(p.i));
if (token) {
p.i += token[0].length;
return new SfToken(token[0]);
}
throw new Error('unexpected character');
}
/**
* @param {{s: string, i: number}} p
* @returns {string}
*/
function parseKey(p) {
const key = /^[a-z*][a-z0-9_\-.*]*/.exec(p.s.slice(p.i));
if (!key) throw new Error('expected a key');
p.i += key[0].length;
return key[0];
}
/** @param {{s: string, i: number}} p */
function skipSpaces(p) {
while (p.s[p.i] === ' ') p.i++;
}
/** @param {{s: string, i: number}} p */
function skipOws(p) {
while (p.s[p.i] === ' ' || p.s[p.i] === '\t') p.i++;
}