~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
238 lines · 6.1 KB Raw History
// @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++;
}