structured-headers
Version:
Implementation of Structured Field Values for HTTP (RFC9651, RFC8941)
417 lines • 13.7 kB
JavaScript
import { Token } from './token.js';
import { isAscii, base64ToArrayBuffer } from './util.js';
import { DisplayString } from './displaystring.js';
export function parseDictionary(input) {
const parser = new Parser(input);
return parser.parseDictionary();
}
export function parseList(input) {
const parser = new Parser(input);
return parser.parseList();
}
export function parseItem(input) {
const parser = new Parser(input);
return parser.parseItem();
}
export class ParseError extends Error {
constructor(position, message) {
super(`Parse error: ${message} at offset ${position}`);
}
}
export default class Parser {
constructor(input) {
this.input = input;
this.pos = 0;
}
parseDictionary() {
this.skipWS();
const dictionary = new Map();
while (!this.eof()) {
const thisKey = this.parseKey();
let member;
if (this.lookChar() === '=') {
this.pos++;
member = this.parseItemOrInnerList();
}
else {
member = [true, this.parseParameters()];
}
dictionary.set(thisKey, member);
this.skipOWS();
if (this.eof()) {
return dictionary;
}
this.expectChar(',');
this.pos++;
this.skipOWS();
if (this.eof()) {
throw new ParseError(this.pos, 'Dictionary contained a trailing comma');
}
}
return dictionary;
}
parseList() {
this.skipWS();
const members = [];
while (!this.eof()) {
members.push(this.parseItemOrInnerList());
this.skipOWS();
if (this.eof()) {
return members;
}
this.expectChar(',');
this.pos++;
this.skipOWS();
if (this.eof()) {
throw new ParseError(this.pos, 'A list may not end with a trailing comma');
}
}
return members;
}
parseItem(standaloneItem = true) {
if (standaloneItem)
this.skipWS();
const result = [
this.parseBareItem(),
this.parseParameters()
];
if (standaloneItem)
this.checkTrail();
return result;
}
parseItemOrInnerList() {
if (this.lookChar() === '(') {
return this.parseInnerList();
}
else {
return this.parseItem(false);
}
}
parseInnerList() {
this.expectChar('(');
this.pos++;
const innerList = [];
while (!this.eof()) {
this.skipWS();
if (this.lookChar() === ')') {
this.pos++;
return [
innerList,
this.parseParameters()
];
}
innerList.push(this.parseItem(false));
const nextChar = this.lookChar();
if (nextChar !== ' ' && nextChar !== ')') {
throw new ParseError(this.pos, 'Expected a whitespace or ) after every item in an inner list');
}
}
throw new ParseError(this.pos, 'Could not find end of inner list');
}
parseBareItem() {
const char = this.lookChar();
if (char === undefined) {
throw new ParseError(this.pos, 'Unexpected end of string');
}
if (char.match(/^[-0-9]/)) {
return this.parseIntegerOrDecimal();
}
if (char === '"') {
return this.parseString();
}
if (char.match(/^[A-Za-z*]/)) {
return this.parseToken();
}
if (char === ':') {
return this.parseByteSequence();
}
if (char === '?') {
return this.parseBoolean();
}
if (char === '@') {
return this.parseDate();
}
if (char === '%') {
return this.parseDisplayString();
}
throw new ParseError(this.pos, 'Unexpected input');
}
parseParameters() {
const parameters = new Map();
while (!this.eof()) {
const char = this.lookChar();
if (char !== ';') {
break;
}
this.pos++;
this.skipWS();
const key = this.parseKey();
let value = true;
if (this.lookChar() === '=') {
this.pos++;
value = this.parseBareItem();
}
parameters.set(key, value);
}
return parameters;
}
parseIntegerOrDecimal() {
let type = 'integer';
let sign = 1;
let inputNumber = '';
if (this.lookChar() === '-') {
sign = -1;
this.pos++;
}
// The spec wants this check but it's unreachable code.
//if (this.eof()) {
// throw new ParseError(this.pos, 'Empty integer');
//}
if (!isDigit(this.lookChar())) {
throw new ParseError(this.pos, 'Expected a digit (0-9)');
}
while (!this.eof()) {
const char = this.getChar();
if (isDigit(char)) {
inputNumber += char;
}
else if (type === 'integer' && char === '.') {
if (inputNumber.length > 12) {
throw new ParseError(this.pos, 'Exceeded maximum decimal length');
}
inputNumber += '.';
type = 'decimal';
}
else {
// We need to 'prepend' the character, so it's just a rewind
this.pos--;
break;
}
if (type === 'integer' && inputNumber.length > 15) {
throw new ParseError(this.pos, 'Exceeded maximum integer length');
}
if (type === 'decimal' && inputNumber.length > 16) {
throw new ParseError(this.pos, 'Exceeded maximum decimal length');
}
}
if (type === 'integer') {
return parseInt(inputNumber, 10) * sign;
}
else {
if (inputNumber.endsWith('.')) {
throw new ParseError(this.pos, 'Decimal cannot end on a period');
}
if (inputNumber.split('.')[1].length > 3) {
throw new ParseError(this.pos, 'Number of digits after the decimal point cannot exceed 3');
}
return parseFloat(inputNumber) * sign;
}
}
parseString() {
let outputString = '';
this.expectChar('"');
this.pos++;
while (!this.eof()) {
const char = this.getChar();
if (char === '\\') {
if (this.eof()) {
throw new ParseError(this.pos, 'Unexpected end of input');
}
const nextChar = this.getChar();
if (nextChar !== '\\' && nextChar !== '"') {
throw new ParseError(this.pos, 'A backslash must be followed by another backslash or double quote');
}
outputString += nextChar;
}
else if (char === '"') {
return outputString;
}
else if (!isAscii(char)) {
throw new ParseError(this.pos, 'Strings must be in the ASCII range');
}
else {
outputString += char;
}
}
throw new ParseError(this.pos, 'Unexpected end of input');
}
parseDisplayString() {
const chars = this.getChars(2);
if (chars !== '%"') {
throw new ParseError(this.pos, 'Unexpected character. Display strings should start with %=');
}
const result = [];
while (!this.eof()) {
const char = this.getChar();
if (char.charCodeAt(0) <= 0x1F || (char.charCodeAt(0) >= 0x7F && char.charCodeAt(0) <= 0xFF)) {
throw new ParseError(this.pos, 'Invalid char found in DisplayString. Did you forget to escape?');
}
if (char === '%') {
const hexChars = this.getChars(2);
if (/^[0-9a-f]{2}$/.test(hexChars)) {
result.push(parseInt(hexChars, 16));
}
else {
throw new ParseError(this.pos, `Unexpected sequence after % in DispalyString: "${hexChars}". Note that hexidecimals must be lowercase`);
}
continue;
}
if (char === '"') {
const textDecoder = new TextDecoder('utf-8', {
fatal: true
});
try {
return new DisplayString(textDecoder.decode(new Uint8Array(result)));
}
catch {
throw new ParseError(this.pos, 'Fatal error decoding UTF-8 sequence in Display String');
}
}
result.push(char.charCodeAt(0));
}
throw new ParseError(this.pos, 'Unexpected end of input');
}
parseToken() {
// The specification wants this check, but it's an unreachable code block.
// if (!/^[A-Za-z*]/.test(this.lookChar())) {
// throw new ParseError(this.pos, 'A token must begin with an asterisk or letter (A-Z, a-z)');
//}
let outputString = '';
while (!this.eof()) {
const char = this.lookChar();
if (char === undefined || !/^[:/!#$%&'*+\-.^_`|~A-Za-z0-9]$/.test(char)) {
return new Token(outputString);
}
outputString += this.getChar();
}
return new Token(outputString);
}
parseByteSequence() {
this.expectChar(':');
this.pos++;
const endPos = this.input.indexOf(':', this.pos);
if (endPos === -1) {
throw new ParseError(this.pos, 'Could not find a closing ":" character to mark end of Byte Sequence');
}
const b64Content = this.input.substring(this.pos, endPos);
this.pos += b64Content.length + 1;
if (!/^[A-Za-z0-9+/=]*$/.test(b64Content)) {
throw new ParseError(this.pos, 'ByteSequence does not contain a valid base64 string');
}
try {
return base64ToArrayBuffer(b64Content);
}
catch {
throw new ParseError(this.pos, 'ByteSequence does not contain a valid base64 string');
}
}
parseBoolean() {
this.expectChar('?');
this.pos++;
const char = this.getChar();
if (char === '1') {
return true;
}
if (char === '0') {
return false;
}
throw new ParseError(this.pos, 'Unexpected character. Expected a "1" or a "0"');
}
parseDate() {
this.expectChar('@');
this.pos++;
let sign = 1;
let inputNumber = '';
if (this.lookChar() === '-') {
sign = -1;
this.pos++;
}
if (!isDigit(this.lookChar())) {
throw new ParseError(this.pos, 'Expected a digit (0-9)');
}
while (!this.eof()) {
const char = this.getChar();
if (isDigit(char)) {
inputNumber += char;
}
else {
throw new ParseError(this.pos, 'Expected a digit (0-9), whitespace or EOL');
}
}
return new Date(parseInt(inputNumber, 10) * sign * 1000);
}
parseKey() {
if (!this.lookChar()?.match(/^[a-z*]/)) {
throw new ParseError(this.pos, 'A key must begin with an asterisk or letter (a-z)');
}
let outputString = '';
while (!this.eof()) {
const char = this.lookChar();
if (char === undefined || !/^[a-z0-9_\-.*]$/.test(char)) {
return outputString;
}
outputString += this.getChar();
}
return outputString;
}
/**
* Looks at the next character without advancing the cursor.
*
* Returns undefined if we were at the end of the string.
*/
lookChar() {
return this.input[this.pos];
}
/**
* Checks if the next character is 'char', and fail otherwise.
*/
expectChar(char) {
if (this.lookChar() !== char) {
throw new ParseError(this.pos, `Expected ${char}`);
}
}
getChar() {
return this.input[this.pos++];
}
getChars(count) {
const result = this.input.substr(this.pos, count);
this.pos += count;
return result;
}
eof() {
return this.pos >= this.input.length;
}
// Advances the pointer to skip all whitespace.
skipOWS() {
while (true) {
const c = this.input.substr(this.pos, 1);
if (c === ' ' || c === '\t') {
this.pos++;
}
else {
break;
}
}
}
// Advances the pointer to skip all spaces
skipWS() {
while (this.lookChar() === ' ') {
this.pos++;
}
}
// At the end of parsing, we need to make sure there are no bytes after the
// header except whitespace.
checkTrail() {
this.skipWS();
if (!this.eof()) {
throw new ParseError(this.pos, 'Unexpected characters at end of input');
}
}
}
const isDigitRegex = /^[0-9]$/;
function isDigit(char) {
if (char === undefined)
return false;
return isDigitRegex.test(char);
}
//# sourceMappingURL=parser.js.map