UNPKG

http-fields

Version:

Modern JavaScript library for parsing and serializing HTTP Structured Field Values (RFC 8941 & RFC 9651)

845 lines (725 loc) 22.6 kB
/** * RFC 9651 Structured Field Values for HTTP - Enhanced JavaScript Implementation * Translates between structured header strings and JSON * Now supports RFC 9651 features: Dates and Display Strings */ "use strict"; // Utility functions const isAsciiPrintable = (char) => { const code = char.charCodeAt(0); return code >= 0x20 && code <= 0x7e; }; const isLcAlpha = (char) => { const code = char.charCodeAt(0); return code >= 0x61 && code <= 0x7a; // a-z }; const isAlpha = (char) => { const code = char.charCodeAt(0); return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); // A-Z or a-z }; const isDigit = (char) => { const code = char.charCodeAt(0); return code >= 0x30 && code <= 0x39; // 0-9 }; const isLcHexDigit = (char) => { const code = char.charCodeAt(0); return (code >= 0x30 && code <= 0x39) || (code >= 0x61 && code <= 0x66); // 0-9, a-f }; const isTchar = (char) => { return "!#$%&'*+-.^_`|~".includes(char) || isAlpha(char) || isDigit(char); }; const skipOWS = (input) => { while (input.length > 0 && (input[0] === " " || input[0] === "\t")) { input.splice(0, 1); } }; const skipSP = (input) => { while (input.length > 0 && input[0] === " ") { input.splice(0, 1); } }; // Parser functions const parseList = (inputString) => { const input = Array.from(inputString); const members = []; skipOWS(input); while (input.length > 0) { const [member, params] = parseListMember(input); members.push({ value: member, parameters: params }); skipOWS(input); if (input.length > 0 && input[0] === ",") { input.shift(); // consume comma skipOWS(input); if (input.length === 0) { throw new Error("Unexpected end of input after comma"); } } else { break; } } if (input.length > 0) { throw new Error("Unexpected characters at end of list"); } return members; }; const parseListMember = (input) => { if (input[0] === "(") { return parseInnerList(input); } else { return parseItem(input); } }; const parseInnerList = (input) => { if (input[0] !== "(") { throw new Error('Expected "(" at start of inner list'); } input.shift(); // consume '(' const innerList = []; skipSP(input); while (input.length > 0 && input[0] !== ")") { const [item, params] = parseItem(input); innerList.push({ value: item, parameters: params }); if (input.length === 0) { throw new Error("Unexpected end of input in inner list"); } if (input[0] === ")") { break; } if (input[0] !== " ") { throw new Error("Expected space between inner list items"); } // Skip one or more spaces while (input.length > 0 && input[0] === " ") { input.shift(); } } if (input.length === 0 || input[0] !== ")") { throw new Error('Expected ")" at end of inner list'); } input.shift(); // consume ')' const params = parseParameters(input); return [innerList, params]; }; const parseDictionary = (inputString) => { const input = Array.from(inputString); const dict = {}; // Only skip spaces at the beginning, not tabs (keys cannot start with tabs) skipSP(input); while (input.length > 0) { const key = parseKey(input); let value, params; if (input.length > 0 && input[0] === "=") { input.shift(); // consume '=' const [memberValue, memberParams] = parseListMember(input); value = memberValue; params = memberParams; } else { // Boolean true with parameters value = true; params = parseParameters(input); } dict[key] = { value, parameters: params }; skipOWS(input); if (input.length > 0 && input[0] === ",") { input.shift(); // consume comma skipOWS(input); if (input.length === 0) { throw new Error("Unexpected end of input after comma"); } // After comma whitespace, don't allow tabs before next key if (input.length > 0 && input[0] === "\t") { throw new Error("Tab character not allowed before dictionary key"); } } else { break; } } if (input.length > 0) { throw new Error("Unexpected characters at end of dictionary"); } return dict; }; const parseItem = (input) => { const bareItem = parseBareItem(input); const params = parseParameters(input); return [bareItem, params]; }; const parseBareItem = (input) => { if (input.length === 0) { throw new Error("Unexpected end of input"); } const firstChar = input[0]; if (firstChar === '"') { return parseString(input); } else if (firstChar === ":") { return parseBinary(input); } else if (firstChar === "?") { return parseBoolean(input); } else if (firstChar === "@") { return parseDate(input); } else if (firstChar === "%") { return parseDisplayString(input); } else if (isDigit(firstChar) || firstChar === "-") { return parseNumber(input); } else if (isAlpha(firstChar) || firstChar === "*") { return parseToken(input); } else { throw new Error(`Unexpected character: ${firstChar}`); } }; const parseParameters = (input) => { const params = {}; while (input.length > 0 && input[0] === ";") { input.shift(); // consume ';' skipSP(input); const key = parseKey(input); let value = true; // default value for parameters if (input.length > 0 && input[0] === "=") { input.shift(); // consume '=' value = parseBareItem(input); } params[key] = value; } return params; }; const parseKey = (input) => { if (input.length === 0) { throw new Error("Unexpected end of input"); } if (!isLcAlpha(input[0]) && input[0] !== "*") { throw new Error('Key must start with lowercase letter or "*"'); } let key = ""; while (input.length > 0) { const char = input[0]; if ( isLcAlpha(char) || isDigit(char) || char === "_" || char === "-" || char === "." || char === "*" ) { key += char; input.shift(); } else { break; } } if (key.length === 0) { throw new Error("Empty key"); } return key; }; const parseNumber = (input) => { let sign = 1; let num = ""; let type = "integer"; if (input[0] === "-") { sign = -1; input.shift(); } if (input.length === 0 || !isDigit(input[0])) { throw new Error("Expected digit after minus sign"); } // Parse integer part while (input.length > 0 && isDigit(input[0])) { num += input.shift(); } if (num.length === 0) { throw new Error("Expected digits"); } if (num.length > 15) { throw new Error("Integer too large"); } // RFC 8941: Check for invalid leading zero patterns in decimals // This will be checked later if we find a decimal point // Check for decimal point if (input.length > 0 && input[0] === ".") { type = "decimal"; input.shift(); // consume '.' // RFC 8941: Reject decimal numbers with excessive leading zeros // "000000000000000.0" should fail (15 zeros + decimal) if (num.length >= 15 && num === "0".repeat(15)) { throw new Error("Too many leading zeros in decimal number"); } if (input.length === 0 || !isDigit(input[0])) { throw new Error("Expected digit after decimal point"); } let fractionalPart = ""; while (input.length > 0 && isDigit(input[0])) { fractionalPart += input.shift(); } if (fractionalPart.length > 3) { throw new Error("Too many fractional digits"); } num += "." + fractionalPart; } if (type === "integer") { const value = sign * parseInt(num, 10); if (value < -999999999999999 || value > 999999999999999) { throw new Error("Integer out of range"); } // Normalize -0 to 0 as per RFC 8941 return value === 0 ? 0 : value; } else { const value = sign * parseFloat(num); if (Math.abs(value) >= 1000000000000) { throw new Error("Decimal out of range"); } // Normalize -0 to 0 as per RFC 8941 return value === 0 ? 0 : value; } }; const parseString = (input) => { if (input[0] !== '"') { throw new Error("Expected quote at start of string"); } input.shift(); // consume opening quote let str = ""; while (input.length > 0) { const char = input.shift(); if (char === '"') { return str; } else if (char === "\\") { if (input.length === 0) { throw new Error("Unexpected end of input in string escape"); } const escapedChar = input.shift(); if (escapedChar === '"' || escapedChar === "\\") { str += escapedChar; } else { throw new Error(`Invalid escape sequence: \\${escapedChar}`); } } else if (isAsciiPrintable(char) && char !== '"' && char !== "\\") { str += char; } else { throw new Error(`Invalid character in string: ${char}`); } } throw new Error("Unterminated string"); }; const parseToken = (input) => { if (input.length === 0) { throw new Error("Unexpected end of input"); } if (!isAlpha(input[0]) && input[0] !== "*") { throw new Error('Token must start with letter or "*"'); } let token = ""; while (input.length > 0) { const char = input[0]; if (isTchar(char) || char === ":" || char === "/") { token += char; input.shift(); } else { break; } } return { type: "token", value: token }; }; const parseBinary = (input) => { if (input[0] !== ":") { throw new Error('Expected ":" at start of binary'); } input.shift(); // consume opening ':' let encoded = ""; while (input.length > 0 && input[0] !== ":") { const char = input.shift(); if ( isAlpha(char) || isDigit(char) || char === "+" || char === "/" || char === "=" ) { encoded += char; } else { throw new Error(`Invalid base64 character: ${char}`); } } if (input.length === 0 || input[0] !== ":") { throw new Error('Expected ":" at end of binary'); } input.shift(); // consume closing ':' try { const decoded = atob(encoded); return { type: "binary", value: encoded, decoded }; } catch (e) { throw new Error("Invalid base64 encoding"); } }; const parseBoolean = (input) => { if (input[0] !== "?") { throw new Error('Expected "?" at start of boolean'); } input.shift(); // consume '?' if (input.length === 0) { throw new Error('Unexpected end of input after "?"'); } const value = input.shift(); if (value === "1") { return true; } else if (value === "0") { return false; } else { throw new Error(`Invalid boolean value: ${value}`); } }; // RFC 9651: Date parsing const parseDate = (input) => { if (input[0] !== "@") { throw new Error('Expected "@" at start of date'); } input.shift(); // consume '@' // Parse the integer timestamp const timestamp = parseNumber(input); if (!Number.isInteger(timestamp)) { throw new Error("Date timestamp must be an integer"); } // Validate date range (years 1 to 9999) if (timestamp < -62135596800 || timestamp > 253402214400) { throw new Error("Date out of supported range"); } return { type: "date", value: new Date(timestamp * 1000) }; }; // RFC 9651: Display String parsing const parseDisplayString = (input) => { if (input.length < 2 || input[0] !== "%" || input[1] !== '"') { throw new Error( 'Expected "%" followed by quote at start of display string' ); } input.shift(); // consume '%' input.shift(); // consume opening quote const byteArray = []; while (input.length > 0) { const char = input.shift(); if (char === '"') { // End of display string, decode UTF-8 try { const decoder = new TextDecoder("utf-8", { fatal: true }); const uint8Array = new Uint8Array(byteArray); const unicode = decoder.decode(uint8Array); return { type: "displaystring", value: unicode }; } catch (e) { throw new Error("Invalid UTF-8 in display string"); } } else if (char === "%") { // Percent-encoded byte if (input.length < 2) { throw new Error("Incomplete percent encoding"); } const hex1 = input.shift(); const hex2 = input.shift(); if (!isLcHexDigit(hex1) || !isLcHexDigit(hex2)) { throw new Error("Invalid hex digits in percent encoding"); } const byte = parseInt(hex1 + hex2, 16); byteArray.push(byte); } else if (isAsciiPrintable(char) && char !== '"' && char !== "%") { // Regular ASCII character byteArray.push(char.charCodeAt(0)); } else { throw new Error(`Invalid character in display string: ${char}`); } } throw new Error("Unterminated display string"); }; // Serializer functions const serializeList = (list) => { return list .map((member) => { const serializedValue = Array.isArray(member.value) ? serializeInnerList(member.value, member.parameters || {}) : serializeBareItem(member.value) + serializeParameters(member.parameters || {}); return serializedValue; }) .join(", "); }; const serializeInnerList = (innerList, listParams = {}) => { const items = innerList .map( (item) => serializeBareItem(item.value) + serializeParameters(item.parameters || {}) ) .join(" "); return `(${items})${serializeParameters(listParams)}`; }; // Validation function for dictionary keys during serialization const validateKey = (key) => { if (typeof key !== "string" || key.length === 0) { throw new Error("Dictionary key must be a non-empty string"); } // Check first character: must be lowercase letter or '*' if (!isLcAlpha(key[0]) && key[0] !== "*") { throw new Error('Dictionary key must start with lowercase letter or "*"'); } // Check all characters: must be valid key characters for (let i = 0; i < key.length; i++) { const char = key[i]; if ( !isLcAlpha(char) && !isDigit(char) && char !== "_" && char !== "-" && char !== "." && char !== "*" ) { throw new Error( `Invalid character in dictionary key: "${char}" (0x${char .charCodeAt(0) .toString(16) .padStart(2, "0")})` ); } } }; const serializeDictionary = (dict) => { return Object.entries(dict) .map(([key, member]) => { // Validate the key before serializing validateKey(key); if (member.value === true) { return key + serializeParameters(member.parameters || {}); } else { const serializedValue = Array.isArray(member.value) ? serializeInnerList(member.value, {}) : serializeBareItem(member.value); return ( key + "=" + serializedValue + serializeParameters(member.parameters || {}) ); } }) .join(", "); }; const serializeItem = (value, params = {}) => { return serializeBareItem(value) + serializeParameters(params); }; const serializeBareItem = (item) => { if (typeof item === "number") { if (Number.isInteger(item)) { // Validate integer range if (item < -999999999999999 || item > 999999999999999) { throw new Error("Integer out of serializable range"); } return item.toString(); } else { // Validate decimal range if (Math.abs(item) >= 1000000000000) { throw new Error("Decimal out of serializable range"); } // Handle JavaScript rounding - use custom rounding to match RFC const rounded = Math.round(item * 1000) / 1000; return rounded.toFixed(3).replace(/\.?0+$/, ""); } } else if (typeof item === "string") { // Validate string doesn't contain control characters that aren't properly escapable for (let i = 0; i < item.length; i++) { const char = item[i]; const code = char.charCodeAt(0); // All control characters (0x00-0x1F and 0x7F) should fail serialization if (code < 0x20 || code === 0x7f) { throw new Error( `String contains invalid control character: 0x${code .toString(16) .padStart(2, "0")}` ); } } return `"${item.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } else if (typeof item === "boolean") { return item ? "?1" : "?0"; } else if (item && item.type === "token") { // Validate token characters const tokenValue = item.value; if (typeof tokenValue !== "string" || tokenValue.length === 0) { throw new Error("Token value must be a non-empty string"); } // Check first character if (!isAlpha(tokenValue[0]) && tokenValue[0] !== "*") { throw new Error('Token must start with alphabetic character or "*"'); } // Check all characters for (let i = 0; i < tokenValue.length; i++) { const char = tokenValue[i]; if (!isTchar(char) && char !== ":" && char !== "/") { throw new Error( `Invalid character in token: "${char}" (0x${char .charCodeAt(0) .toString(16) .padStart(2, "0")})` ); } } return tokenValue; } else if (item && item.type === "binary") { return `:${item.value}:`; } else if (item && item.type === "date") { const timestamp = Math.floor(item.value.getTime() / 1000); return `@${timestamp}`; } else if (item && item.type === "displaystring") { // Encode as UTF-8 and percent-encode special characters const encoder = new TextEncoder(); const bytes = encoder.encode(item.value); let encoded = '%"'; for (const byte of bytes) { if (byte === 0x25 || byte === 0x22 || byte < 0x20 || byte > 0x7e) { // Percent-encode encoded += `%${byte.toString(16).padStart(2, "0")}`; } else { // Regular ASCII character encoded += String.fromCharCode(byte); } } encoded += '"'; return encoded; } else { throw new Error(`Unsupported item type: ${typeof item}`); } }; const serializeParameters = (params) => { return Object.entries(params) .map(([key, value]) => { // Validate parameter key validateKey(key); if (value === true) { return `;${key}`; } else { return `;${key}=${serializeBareItem(value)}`; } }) .join(""); }; /** * Parse a structured field string into JSON * @param {string} fieldValue - The HTTP field value * @param {'list'|'dictionary'|'item'} fieldType - Type: 'list', 'dictionary', or 'item' * @returns {any} Parsed structure */ export const parse = (fieldValue, fieldType) => { if (typeof fieldValue !== "string") { throw new Error("Field value must be a string"); } switch (fieldType) { case "list": return parseList(fieldValue); case "dictionary": return parseDictionary(fieldValue); case "item": const input = Array.from(fieldValue); skipSP(input); // Skip leading spaces (not tabs for items) const [value, params] = parseItem(input); skipSP(input); // Skip trailing spaces (not tabs for items) if (input.length > 0) { throw new Error(`Unexpected characters at end: ${input.join("")}`); } return { value, parameters: params }; default: throw new Error('Field type must be "list", "dictionary", or "item"'); } }; /** * Serialize a JSON structure to a structured field string * @param {any} data - The data structure to serialize * @param {'list'|'dictionary'|'item'} fieldType - Type: 'list', 'dictionary', or 'item' * @returns {string} Serialized field value */ export const serialize = (data, fieldType) => { switch (fieldType) { case "list": if (!Array.isArray(data)) { throw new Error("List data must be an array"); } return serializeList(data); case "dictionary": if (typeof data !== "object" || data === null || Array.isArray(data)) { throw new Error("Dictionary data must be an object"); } return serializeDictionary(data); case "item": if (typeof data !== "object" || data === null) { throw new Error( "Item data must be an object with value and parameters" ); } return serializeItem(data.value, data.parameters); default: throw new Error('Field type must be "list", "dictionary", or "item"'); } }; /** * Create a token value * @param {string} value - Token string * @returns {{type: 'token', value: string}} Token object */ export const token = (value) => ({ type: "token", value }); /** * Create a binary value * @param {string} base64Value - Base64 encoded string * @returns {{type: 'binary', value: string}} Binary object */ export const binary = (base64Value) => ({ type: "binary", value: base64Value }); /** * Create a date value (RFC 9651) * @param {Date} dateValue - JavaScript Date object * @returns {{type: 'date', value: Date}} Date object */ export const date = (dateValue) => ({ type: "date", value: dateValue }); /** * Create a display string value (RFC 9651) * @param {string} unicodeValue - Unicode string * @returns {{type: 'displaystring', value: string}} Display string object */ export const displayString = (unicodeValue) => ({ type: "displaystring", value: unicodeValue, }); console.log("# RFC 8941 Structured Fields - HTTP Headers Examples\n"); // Utility class for working with structured headers export class StructuredHeaders { #headers; constructor(headers = new Headers()) { this.#headers = headers instanceof Headers ? headers : new Headers(headers); } get headers() { return this.#headers; } // Get and parse a structured header getStructured(name, type) { const value = this.headers.get(name); if (!value) return null; try { return parse(value, type); } catch (error) { console.warn(`Invalid structured header ${name}:`, error.message); return null; } } // Set a structured header setStructured(name, data, type) { try { const value = serialize(data, type); this.headers.set(name, value); } catch (error) { console.error(`Failed to serialize header ${name}:`, error.message); } } // Get the underlying Headers object toHeaders() { return this.#headers; } // Print all headers in markdown format printMarkdown() { console.log("```http"); for (const [name, value] of this.#headers.entries()) { console.log(`${name}: ${value}`); } console.log("```\n"); } }