// @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}} SfItem */ /** * @typedef {{kind: 'item', value: SfBare, params: Map} * | {kind: 'innerList', items: SfItem[], params: Map}} 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} */ export function parseDictionary(input) { const p = { s: input, i: 0 }; /** @type {Map} */ 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} 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} 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} */ function parseParams(p) { /** @type {Map} */ 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++; }