structured-field-values
Version:
Implementation of Structured Field Values from IETF httpbis RFC8941
1,567 lines (1,516 loc) • 48.7 kB
JavaScript
;`use strict`
/**
* Tagged Template Literal for Error message
* @param {TemplateStringsArray} strings
* @param {...any} keys
* @returns string
*/
function err(strings, ...keys) {
keys = keys.map((key) => {
if (Array.isArray(key)) return JSON.stringify(key)
if (key instanceof Map) return "Map{}"
if (key instanceof Set) return "Set{}"
if (typeof key === "object") return JSON.stringify(key)
return String(key)
})
const result = strings.map((string, i) => {
return [string, keys.at(i)]
})
return result.flat().join("")
}
export class Item {
/**
* @property {BareItem} value
* @property {Parameters} params
*/
constructor(value, params = null) {
if (Array.isArray(value)) {
value = value.map((v) => {
if (v instanceof Item) return v
return new Item(v)
})
}
this.value = value
this.params = params
}
}
/////////////////////////
// public interface
/////////////////////////
// 4.1. Serializing Structured Fields
//
// 1. If the structure is a Dictionary or List and its value is empty
// (i.e., it has no members), do not serialize the field at all
// (i.e., omit both the field-name and field-value).
//
// 2. If the structure is a List, let output_string be the result of
// running Serializing a List (Section 4.1.1) with the structure.
//
// 3. Else if the structure is a Dictionary, let output_string be the
// result of running Serializing a Dictionary (Section 4.1.2) with
// the structure.
//
// 4. Else if the structure is an Item, let output_string be the result
// of running Serializing an Item (Section 4.1.3) with the
// structure.
//
// 5. Else, fail serialization.
//
// 6. Return output_string converted into an array of bytes, using
// ASCII encoding [RFC0020].
/**
* @param {Item} value
* @returns {string}
*/
export function encodeItem(value) {
return serializeItem(value)
}
/**
* @param {MemberList} value
* @returns {string}
*/
export function encodeList(value) {
return serializeList(value)
}
/**
* @param {Dictionary} value
* @returns {string}
*/
export function encodeDict(value) {
return serializeDict(value)
}
// 4.2. Parsing Structured Fields
//
// 1. Convert input_bytes into an ASCII string input_string; if
// conversion fails, fail parsing.
//
// 2. Discard any leading SP characters from input_string.
//
// 3. If field_type is "list", let output be the result of running
// Parsing a List (Section 4.2.1) with input_string.
//
// 4. If field_type is "dictionary", let output be the result of
// running Parsing a Dictionary (Section 4.2.2) with input_string.
//
// 5. If field_type is "item", let output be the result of running
// Parsing an Item (Section 4.2.3) with input_string.
//
// 6. Discard any leading SP characters from input_string.
//
// 7. If input_string is not empty, fail parsing.
//
// 8. Otherwise, return output.
/**
* @param {string} input
* @returns {Item}
*/
export function decodeItem(input) {
try {
const { input_string, value } = parseItem(input.trim())
if (input_string !== "") throw new Error(err`failed to parse "${input_string}" as Item`)
return value
} catch (cause) {
throw new Error(err`failed to parse "${input}" as Item`, { cause })
}
}
/**
* @param {string} input
* @returns {MemberList}
*/
export function decodeList(input) {
try {
const { input_string, value } = parseList(input.trim())
if (input_string !== "") throw new Error(err`failed to parse "${input_string}" as List`)
return value
} catch (cause) {
throw new Error(err`failed to parse "${input}" as List`, { cause })
}
}
/**
* @param {string} input
* @returns {Dictionary}
*/
export function decodeDict(input) {
try {
const { input_string, value } = parseDictionary(input.trim())
if (input_string !== "") throw new Error(err`failed to parse "${input_string}" as Dict`)
return value
} catch (cause) {
throw new Error(err`failed to parse "${input}" as Dict`, { cause })
}
}
// 4.1.1. Serializing a List
//
// Given an array of (member_value, parameters) tuples as input_list,
// return an ASCII string suitable for use in a HTTP field value.
//
// 1. Let output be an empty string.
//
// 2. For each (member_value, parameters) of input_list:
//
// 1. If member_value is an array, append the result of running
// Serializing an Inner List (Section 4.1.1.1) with
// (member_value, parameters) to output.
//
// 2. Otherwise, append the result of running Serializing an Item
// (Section 4.1.3) with (member_value, parameters) to output.
//
// 3. If more member_values remain in input_list:
//
// 1. Append "," to output.
//
// 2. Append a single SP to output.
//
// 3. Return output.
/**
* @param {MemberList} list
* @return {string}
*/
export function serializeList(list) {
if (Array.isArray(list) === false) throw new Error(err`failed to serialize "${list}" as List`)
return list
.map((item) => {
if (item instanceof Item === false) item = new Item(item)
if (Array.isArray(item.value)) {
return serializeInnerList(item)
}
return serializeItem(item)
})
.join(", ")
}
// 4.1.1.1. Serializing an Inner List
//
// Given an array of (member_value, parameters) tuples as inner_list,
// and parameters as list_parameters, return an ASCII string suitable
// for use in a HTTP field value.
//
// 1. Let output be the string "(".
//
// 2. For each (member_value, parameters) of inner_list:
//
// 1. Append the result of running Serializing an Item
// (Section 4.1.3) with (member_value, parameters) to output.
//
// 2. If more values remain in inner_list, append a single SP to
// output.
//
// 3. Append ")" to output.
//
// 4. Append the result of running Serializing Parameters
// (Section 4.1.1.2) with list_parameters to output.
//
// 5. Return output.
/**
* @param {Object} value
* @return {string}
*/
export function serializeInnerList(value) {
return `(${value.value.map(serializeItem).join(" ")})${serializeParams(value.params)}`
}
// 4.1.1.2. Serializing Parameters
//
// Given an ordered Dictionary as input_parameters (each member having a
// param_name and a param_value), return an ASCII string suitable for
// use in a HTTP field value.
//
// 1. Let output be an empty string.
//
// 2. For each param_name with a value of param_value in
// input_parameters:
//
// 1. Append ";" to output.
//
// 2. Append the result of running Serializing a Key
// (Section 4.1.1.3) with param_name to output.
//
// 3. If param_value is not Boolean true:
//
// 1. Append "=" to output.
//
// 2. Append the result of running Serializing a bare Item
// (Section 4.1.3.1) with param_value to output.
//
// 3. Return output.
/**
* @param {Object} params
* @return {string}
*/
export function serializeParams(params) {
if (params === null) return ""
return Object.entries(params)
.map(([key, value]) => {
if (value === true) return `;${serializeKey(key)}` // omit true
return `;${serializeKey(key)}=${serializeBareItem(value)}`
})
.join("")
}
// 4.1.1.3. Serializing a Key
//
// Given a key as input_key, return an ASCII string suitable for use in
// a HTTP field value.
//
// 1. Convert input_key into a sequence of ASCII characters; if
// conversion fails, fail serialization.
//
// 2. If input_key contains characters not in lcalpha, DIGIT, "_", "-",
// ".", or "*" fail serialization.
//
// 3. If the first character of input_key is not lcalpha or "*", fail
// serialization.
//
// 4. Let output be an empty string.
//
// 5. Append input_key to output.
//
// 6. Return output.
/**
* @param {string} value
* @return {string}
*/
export function serializeKey(value) {
if (/^[a-z\*][a-z0-9\-\_\.\*]*$/.test(value) === false) {
throw new Error(err`failed to serialize "${value}" as Key`)
}
return value
}
// 4.1.2. Serializing a Dictionary
//
// Given an ordered Dictionary as input_dictionary (each member having a
// member_name and a tuple value of (member_value, parameters)), return
// an ASCII string suitable for use in a HTTP field value.
//
// 1. Let output be an empty string.
//
// 2. For each member_name with a value of (member_value, parameters)
// in input_dictionary:
//
// 1. Append the result of running Serializing a Key
// (Section 4.1.1.3) with member's member_name to output.
//
// 2. If member_value is Boolean true:
//
// 1. Append the result of running Serializing Parameters
// (Section 4.1.1.2) with parameters to output.
//
// 3. Otherwise:
//
// 1. Append "=" to output.
//
// 2. If member_value is an array, append the result of running
// Serializing an Inner List (Section 4.1.1.1) with
// (member_value, parameters) to output.
//
// 3. Otherwise, append the result of running Serializing an
// Item (Section 4.1.3) with (member_value, parameters) to
// output.
//
// 4. If more members remain in input_dictionary:
//
// 1. Append "," to output.
//
// 2. Append a single SP to output.
//
// 3. Return output.
/**
* @param {Dictionary} dict
* @return {string}
*/
export function serializeDict(dict) {
if (typeof dict !== "object") throw new Error(err`failed to serialize "${dict}" as Dict`)
const entries = dict instanceof Map ? dict.entries() : Object.entries(dict)
return Array.from(entries)
.map(([key, item]) => {
if (item instanceof Item === false) item = new Item(item)
let output = serializeKey(key)
if (item.value === true) {
output += serializeParams(item.params)
} else {
output += "="
if (Array.isArray(item.value)) {
output += serializeInnerList(item)
} else {
output += serializeItem(item)
}
}
return output
})
.join(", ")
}
// 4.1.3. Serializing an Item
//
// Given an Item as bare_item and Parameters as item_parameters, return
// an ASCII string suitable for use in a HTTP field value.
//
// 1. Let output be an empty string.
//
// 2. Append the result of running Serializing a Bare Item
// Section 4.1.3.1 with bare_item to output.
//
// 3. Append the result of running Serializing Parameters
// Section 4.1.1.2 with item_parameters to output.
//
// 4. Return output.
/**
* @param {Item} value
* @return {string}
*/
export function serializeItem(value) {
if (value instanceof Item) {
return `${serializeBareItem(value.value)}${serializeParams(value.params)}`
} else {
return serializeBareItem(value)
}
}
// 4.1.3.1. Serializing a Bare Item
//
// Given an Item as input_item, return an ASCII string suitable for use
// in a HTTP field value.
//
// 1. If input_item is an Integer, return the result of running
// Serializing an Integer (Section 4.1.4) with input_item.
//
// 2. If input_item is a Decimal, return the result of running
// Serializing a Decimal (Section 4.1.5) with input_item.
//
// 3. If input_item is a String, return the result of running
// Serializing a String (Section 4.1.6) with input_item.
//
// 4. If input_item is a Token, return the result of running
// Serializing a Token (Section 4.1.7) with input_item.
//
// 5. If input_item is a Boolean, return the result of running
// Serializing a Boolean (Section 4.1.9) with input_item.
//
// 6. If input_item is a Byte Sequence, return the result of running
// Serializing a Byte Sequence (Section 4.1.8) with input_item.
//
// 7. If input_item is a Date, return the result of running Serializing
// a Date (Section 4.1.10) with input_item.
//
// 8. Otherwise, fail serialization.
/**
* @param {any} value
* @return {string}
*/
export function serializeBareItem(value) {
switch (typeof value) {
case "number":
if (!Number.isFinite(value)) {
throw new Error(err`failed to serialize "${value}" as Bare Item`)
}
if (Number.isInteger(value)) {
return serializeInteger(value)
}
return serializeDecimal(value)
case "string":
return serializeString(value)
case "symbol":
return serializeToken(value)
case "boolean":
return serializeBoolean(value)
case "object":
if (value instanceof Date) {
return serializeDate(value)
}
if (value instanceof Uint8Array) {
return serializeByteSequence(value)
}
default:
// fail
throw new Error(err`failed to serialize "${value}" as Bare Item`)
}
}
// 4.1.4. Serializing an Integer
//
// Given an Integer as input_integer, return an ASCII string suitable
// for use in a HTTP field value.
//
// 1. If input_integer is not an integer in the range of
// -999,999,999,999,999 to 999,999,999,999,999 inclusive, fail
// serialization.
//
// 2. Let output be an empty string.
//
// 3. If input_integer is less than (but not equal to) 0, append "-" to
// output.
//
// 4. Append input_integer's numeric value represented in base 10 using
// only decimal digits to output.
//
// 5. Return output.
/**
* @param {number} value
* @return {string}
*/
export function serializeInteger(value) {
if (value < -999_999_999_999_999n || 999_999_999_999_999n < value) throw new Error(err`failed to serialize "${value}" as Integer`)
return value.toString()
}
// 4.1.5. Serializing a Decimal
//
// Given a decimal number as input_decimal, return an ASCII string
// suitable for use in a HTTP field value.
//
// 1. If input_decimal is not a decimal number, fail serialization.
//
// 2. If input_decimal has more than three significant digits to the
// right of the decimal point, round it to three decimal places,
// rounding the final digit to the nearest value, or to the even
// value if it is equidistant.
//
// 3. If input_decimal has more than 12 significant digits to the left
// of the decimal point after rounding, fail serialization.
//
// 4. Let output be an empty string.
//
// 5. If input_decimal is less than (but not equal to) 0, append "-"
// to output.
//
// 6. Append input_decimal's integer component represented in base 10
// (using only decimal digits) to output; if it is zero, append
// "0".
//
// 7. Append "." to output.
//
// 8. If input_decimal's fractional component is zero, append "0" to
// output.
//
// 9. Otherwise, append the significant digits of input_decimal's
// fractional component represented in base 10 (using only decimal
// digits) to output.
//
// 10. Return output.
/**
* @param {number} value
* @return {string}
*/
export function serializeDecimal(value) {
const roundedValue = roundToEven(value, 3) // round to 3 decimal places
if (Math.floor(Math.abs(roundedValue)).toString().length > 12) throw new Error(err`failed to serialize "${value}" as Decimal`)
const stringValue = roundedValue.toString()
return stringValue.includes(".") ? stringValue : `${stringValue}.0`
}
/**
* This implements the rounding procedure described in step 2 of the "Serializing a Decimal" specification.
* This rounding style is known as "even rounding", "banker's rounding", or "commercial rounding".
*
* @param {number} value
* @param {number} precision - decimal places to round to
* @return {number}
*/
function roundToEven(value, precision) {
if (value < 0) {
return -roundToEven(-value, precision)
}
const decimalShift = Math.pow(10, precision)
const isEquidistant = Math.abs(((value * decimalShift) % 1) - 0.5) < Number.EPSILON
if (isEquidistant) {
// If the tail of the decimal place is 'equidistant' we round to the nearest even value
const flooredValue = Math.floor(value * decimalShift)
return (flooredValue % 2 === 0 ? flooredValue : flooredValue + 1) / decimalShift
} else {
// Otherwise, proceed as normal
return Math.round(value * decimalShift) / decimalShift
}
}
// 4.1.6. Serializing a String
//
// Given a String as input_string, return an ASCII string suitable for
// use in a HTTP field value.
//
// 1. Convert input_string into a sequence of ASCII characters; if
// conversion fails, fail serialization.
//
// 2. If input_string contains characters in the range %x00-1f or %x7f
// (i.e., not in VCHAR or SP), fail serialization.
//
// 3. Let output be the string DQUOTE.
//
// 4. For each character char in input_string:
//
// 1. If char is "\" or DQUOTE:
//
// 1. Append "\" to output.
//
// 2. Append char to output.
//
// 5. Append DQUOTE to output.
//
// 6. Return output.
/**
* @param {string} value
* @return {string}
*/
export function serializeString(value) {
if (/[\x00-\x1f\x7f]+/.test(value)) throw new Error(err`failed to serialize "${value}" as string`)
return `"${value.replace(/\\/g, `\\\\`).replace(/"/g, `\\\"`)}"`
}
// 4.1.7. Serializing a Token
//
// Given a Token as input_token, return an ASCII string suitable for use
// in a HTTP field value.
//
// 1. Convert input_token into a sequence of ASCII characters; if
// conversion fails, fail serialization.
//
// 2. If the first character of input_token is not ALPHA or "*", or the
// remaining portion contains a character not in tchar, ":" or "/",
// fail serialization.
//
// 3. Let output be an empty string.
//
// 4. Append input_token to output.
//
// 5. Return output.
/**
* @param {symbol} token
* @return {string}
*/
export function serializeToken(token) {
/** @type {string} */
const value = Symbol.keyFor(token)
if (/^([a-zA-Z\*])([\!\#\$\%\&\'\*\+\-\.\^\_\`\|\~\w\:\/]*)$/.test(value) === false) {
throw new Error(err`failed to serialize "${value}" as token`)
}
return value
}
// 4.1.8. Serializing a Byte Sequence
//
// Given a Byte Sequence as input_bytes, return an ASCII string suitable
// for use in a HTTP field value.
//
// 1. If input_bytes is not a sequence of bytes, fail serialization.
//
// 2. Let output be an empty string.
//
// 3. Append ":" to output.
//
// 4. Append the result of base64-encoding input_bytes as per
// [RFC4648], Section 4, taking account of the requirements below.
//
// 5. Append ":" to output.
//
// 6. Return output.
//
// The encoded data is required to be padded with "=", as per [RFC4648],
// Section 3.2.
//
// Likewise, encoded data SHOULD have pad bits set to zero, as per
// [RFC4648], Section 3.5, unless it is not possible to do so due to
// implementation constraints.
/**
* @param {Uint8Array} value
* @return {string}
*/
export function serializeByteSequence(value) {
if (ArrayBuffer.isView(value) === false) throw new Error(err`failed to serialize "${value}" as Byte Sequence`)
return `:${base64encode(value)}:`
}
// 4.1.9. Serializing a Boolean
//
// Given a Boolean as input_boolean, return an ASCII string suitable for
// use in a HTTP field value.
//
// 1. If input_boolean is not a boolean, fail serialization.
//
// 2. Let output be an empty string.
//
// 3. Append "?" to output.
//
// 4. If input_boolean is true, append "1" to output.
//
// 5. If input_boolean is false, append "0" to output.
//
// 6. Return output.
/**
* @param {boolean} value
* @return {string}
*/
export function serializeBoolean(value) {
if (typeof value !== "boolean") throw new Error(err`failed to serialize "${value}" as boolean`)
return value ? "?1" : "?0"
}
// 4.1.10. Serializing a Date
//
// Given a Date as input_integer, return an ASCII string suitable for
// use in an HTTP field value.
// 1. Let output be "@".
// 2. Append to output the result of running Serializing an Integer
// with input_date (Section 4.1.4).
// 3. Return output.
/**
* @param {Date} value
* @return {string}
*/
export function serializeDate(value) {
const input_date = value.getTime() / 1000
return `@${serializeInteger(input_date)}`
}
// 4.2.1. Parsing a List
//
// Given an ASCII string as input_string, return an array of
// (item_or_inner_list, parameters) tuples. input_string is modified to
// remove the parsed value.
//
// 1. Let members be an empty array.
//
// 2. While input_string is not empty:
//
// 1. Append the result of running Parsing an Item or Inner List
// (Section 4.2.1.1) with input_string to members.
//
// 2. Discard any leading OWS characters from input_string.
//
// 3. If input_string is empty, return members.
//
// 4. Consume the first character of input_string; if it is not
// ",", fail parsing.
//
// 5. Discard any leading OWS characters from input_string.
//
// 6. If input_string is empty, there is a trailing comma; fail
// parsing.
//
// 3. No structured data has been found; return members (which is
// empty).
/**
* @typedef {Array.<Item|InnerList>} MemberList
*
* @typedef {Object} ParsedList
* @property {MemberList} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedList}
*/
export function parseList(input_string) {
/** @type {MemberList} */
const members = []
while (input_string.length > 0) {
/** @type {ParsedItemOrInnerList} */
const parsedItemOrInnerList = parseItemOrInnerList(input_string)
members.push(parsedItemOrInnerList.value)
input_string = parsedItemOrInnerList.input_string.trim()
if (input_string.length === 0) return { input_string, value: members }
if (input_string[0] !== ",") throw new Error(err`failed to parse "${input_string}" as List`)
input_string = input_string.substring(1).trim()
if (input_string.length === 0 || input_string[0] === ",") throw new Error(err`failed to parse "${input_string}" as List`)
}
return {
value: members,
input_string
}
}
// 4.2.1.1. Parsing an Item or Inner List
//
// Given an ASCII string as input_string, return the tuple
// (item_or_inner_list, parameters), where item_or_inner_list can be
// either a single bare item, or an array of (bare_item, parameters)
// tuples. input_string is modified to remove the parsed value.
//
// 1. If the first character of input_string is "(", return the result
// of running Parsing an Inner List (Section 4.2.1.2) with
// input_string.
//
// 2. Return the result of running Parsing an Item (Section 4.2.3) with
// input_string.
/**
* @typedef {ParsedItem|ParsedInnerList} ParsedItemOrInnerList
*
* @param {string} input_string
* @return {ParsedItemOrInnerList}
*/
export function parseItemOrInnerList(input_string) {
if (input_string[0] === "(") {
return parseInnerList(input_string)
}
return parseItem(input_string)
}
// 4.2.1.2. Parsing an Inner List
//
// Given an ASCII string as input_string, return the tuple (inner_list,
// parameters), where inner_list is an array of (bare_item, parameters)
// tuples. input_string is modified to remove the parsed value.
//
// 1. Consume the first character of input_string; if it is not "(",
// fail parsing.
//
// 2. Let inner_list be an empty array.
//
// 3. While input_string is not empty:
//
// 1. Discard any leading SP characters from input_string.
//
// 2. If the first character of input_string is ")":
//
// 1. Consume the first character of input_string.
//
// 2. Let parameters be the result of running Parsing
// Parameters (Section 4.2.3.2) with input_string.
//
// 3. Return the tuple (inner_list, parameters).
//
// 3. Let item be the result of running Parsing an Item
// (Section 4.2.3) with input_string.
//
// 4. Append item to inner_list.
//
// 5. If the first character of input_string is not SP or ")", fail
// parsing.
//
// 4. The end of the inner list was not found; fail parsing.
/**
* @typedef {Array.<Item>} ItemList
*
* @typedef {{value: ItemList, params: Parameters}} InnerList
*
* @typedef {Object} ParsedInnerList
* @property {InnerList} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedInnerList}
*/
export function parseInnerList(input_string) {
if (input_string[0] !== "(") throw new Error(err`failed to parse "${input_string}" as Inner List`)
input_string = input_string.substring(1)
/** @type {ItemList} */
const inner_list = []
while (input_string.length > 0) {
input_string = input_string.trim()
if (input_string[0] === ")") {
input_string = input_string.substring(1)
const parsedParameters = parseParameters(input_string)
return {
value: new Item(inner_list, parsedParameters.value),
input_string: parsedParameters.input_string
}
}
/** @type {ParsedItem} */
const parsedItem = parseItem(input_string)
inner_list.push(parsedItem.value)
input_string = parsedItem.input_string
if (input_string[0] !== " " && input_string[0] !== ")") throw new Error(err`failed to parse "${input_string}" as Inner List`)
}
throw new Error(err`failed to parse "${input_string}" as Inner List`)
}
// 4.2.2. Parsing a Dictionary
//
// Given an ASCII string as input_string, return an ordered map whose
// values are (item_or_inner_list, parameters) tuples. input_string is
// modified to remove the parsed value.
//
// 1. Let dictionary be an empty, ordered map.
//
// 2. While input_string is not empty:
//
// 1. Let this_key be the result of running Parsing a Key
// (Section 4.2.3.3) with input_string.
//
// 2. If the first character of input_string is "=":
//
// 1. Consume the first character of input_string.
//
// 2. Let member be the result of running Parsing an Item or
// Inner List (Section 4.2.1.1) with input_string.
//
// 3. Otherwise:
//
// 1. Let value be Boolean true.
//
// 2. Let parameters be the result of running Parsing
// Parameters Section 4.2.3.2 with input_string.
//
// 3. Let member be the tuple (value, parameters).
//
// 4. Add name this_key with value member to dictionary. If
// dictionary already contains a name this_key (comparing
// character-for-character), overwrite its value.
//
// 5. Discard any leading OWS characters from input_string.
//
// 6. If input_string is empty, return dictionary.
//
// 7. Consume the first character of input_string; if it is not
// ",", fail parsing.
//
// 8. Discard any leading OWS characters from input_string.
//
// 9. If input_string is empty, there is a trailing comma; fail
// parsing.
//
// 3. No structured data has been found; return dictionary (which is
// empty).
//
// Note that when duplicate Dictionary keys are encountered, this has
// the effect of ignoring all but the last instance.
/**
* @typedef {Object.<string, Item|InnerList>|Map} Dictionary
*
* @typedef {Object} ParsedDictionary
* @property {Dictionary} value
* @property {string} input_string
*
* @param {string} input_string
* @param {Object?} option TODO: not fully supported yet
* @return {ParsedDictionary}
*/
export function parseDictionary(input_string, option = {}) {
/** @type {Array.<[Key, Item|InnerList]>} */
const value = [] // ordered map
/**
* @param {Array.<[Key, Item|InnerList]>} entries
* @return {Dictionary}
*/
function toDict(entries) {
if (option?.use_map === true) return new Map(entries)
return Object.fromEntries(entries)
}
while (input_string.length > 0) {
/** @type {Item|InnerList} */
let member
/** @type {ParsedKey} */
const parsedKey = parseKey(input_string)
/** @type {Key} */
const this_key = parsedKey.value
input_string = parsedKey.input_string
if (input_string[0] === "=") {
/** @type {ParsedItemOrInnerList} */
const parsedItemOrInnerList = parseItemOrInnerList(input_string.substring(1))
member = parsedItemOrInnerList.value
input_string = parsedItemOrInnerList.input_string
} else {
/** @type {ParsedParameters} */
const parsedParameters = parseParameters(input_string)
member = new Item(true, parsedParameters.value)
input_string = parsedParameters.input_string
}
value.push([this_key, member])
input_string = input_string.trim()
if (input_string.length === 0) return { input_string, value: toDict(value) }
if (input_string[0] !== ",") throw new Error(err`failed to parse "${input_string}" as Dict`)
input_string = input_string.substring(1).trim()
if (input_string.length === 0 || input_string[0] === ",") throw new Error(err`failed to parse "${input_string}" as Dict`)
}
return {
value: toDict(value),
input_string
}
}
// 4.2.3. Parsing an Item
//
// Given an ASCII string as input_string, return a (bare_item,
// parameters) tuple. input_string is modified to remove the parsed
// value.
//
// 1. Let bare_item be the result of running Parsing a Bare Item
// (Section 4.2.3.1) with input_string.
//
// 2. Let parameters be the result of running Parsing Parameters
// (Section 4.2.3.2) with input_string.
//
// 3. Return the tuple (bare_item, parameters).
/**
* @typedef {Object} ParsedItem
* @property {Item} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedItem}
*/
export function parseItem(input_string) {
const parsedBareItem = parseBareItem(input_string)
const value = parsedBareItem.value
input_string = parsedBareItem.input_string
const parsedParameters = parseParameters(input_string)
const params = parsedParameters.value
input_string = parsedParameters.input_string
/** @type {Item} */
const item = new Item(value, params)
return {
value: item,
input_string
}
}
// 4.2.3.1. Parsing a Bare Item
//
// Given an ASCII string as input_string, return a bare Item.
// input_string is modified to remove the parsed value.
//
// 1. If the first character of input_string is a "-" or a DIGIT,
// return the result of running Parsing an Integer or Decimal
// (Section 4.2.4) with input_string.
//
// 2. If the first character of input_string is a DQUOTE, return the
// result of running Parsing a String (Section 4.2.5) with
// input_string.
//
// 3. If the first character of input_string is ":", return the result
// of running Parsing a Byte Sequence (Section 4.2.7) with
// input_string.
//
// 4. If the first character of input_string is "?", return the result
// of running Parsing a Boolean (Section 4.2.8) with input_string.
//
// 5. If the first character of input_string is an ALPHA or "*", return
// the result of running Parsing a Token (Section 4.2.6) with
// input_string.
//
// 6. If the first character of input_string is "@", return the result
// of running Parsing a Date (Section 4.2.9) with input_string.
//
// 7. Otherwise, the item type is unrecognized; fail parsing.
/**
* @typedef {ParsedString|ParsedByteSequence|ParsedBoolean|ParsedIntegerOrDecimal|ParsedToken|ParsedDate} ParsedBareItem
*
* @param {string} input_string
* @return {ParsedBareItem}
*/
export function parseBareItem(input_string) {
const first = input_string[0]
if (first === `"`) {
return parseString(input_string)
}
if (/^[\-0-9]/.test(first)) {
return parseIntegerOrDecimal(input_string)
}
if (first === `?`) {
return parseBoolean(input_string)
}
if (first === `:`) {
return parseByteSequence(input_string)
}
if (/^[a-zA-Z\*]/.test(first)) {
return parseToken(input_string)
}
if (first === `@`) {
return parseDate(input_string)
}
throw new Error(err`failed to parse "${input_string}" as Bare Item`)
}
// 4.2.3.2. Parsing Parameters
//
// Given an ASCII string as input_string, return an ordered map whose
// values are bare Items. input_string is modified to remove the parsed
// value.
//
// 1. Let parameters be an empty, ordered map.
//
// 2. While input_string is not empty:
//
// 1. If the first character of input_string is not ";", exit the
// loop.
//
// 2. Consume a ";" character from the beginning of input_string.
//
// 3. Discard any leading SP characters from input_string.
//
// 4. let param_name be the result of running Parsing a Key
// (Section 4.2.3.3) with input_string.
//
// 5. Let param_value be Boolean true.
//
// 6. If the first character of input_string is "=":
//
// 1. Consume the "=" character at the beginning of
// input_string.
//
// 2. Let param_value be the result of running Parsing a Bare
// Item (Section 4.2.3.1) with input_string.
//
// 7. Append key param_name with value param_value to parameters.
// If parameters already contains a name param_name (comparing
// character-for-character), overwrite its value.
//
// 3. Return parameters.
//
// Note that when duplicate Parameter keys are encountered, this has the
// effect of ignoring all but the last instance.
/**
* @typedef {string | Uint8Array | boolean | number | symbol | Date} BareItem
*
* @typedef {Object.<Key, BareItem>} Parameters
*
* @typedef {Object} ParsedParameters
* @property {Parameters} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedParameters}
*/
export function parseParameters(input_string) {
/**
* null by default for easy to detect parameter existence.
* @type {Parameters}
*/
let parameters = null
while (input_string.length > 0) {
if (input_string[0] !== ";") break
input_string = input_string.substring(1).trim()
const parsedKey = parseKey(input_string)
const param_name = parsedKey.value
/** @type {BareItem} */
let param_value = true
input_string = parsedKey.input_string
if (input_string[0] === "=") {
input_string = input_string.substring(1)
const parsedBareItem = parseBareItem(input_string)
param_value = parsedBareItem.value
input_string = parsedBareItem.input_string
}
// initialize as object when params exists
if (parameters === null) parameters = {}
// override if param_name exists
parameters[param_name] = param_value
}
return {
value: parameters,
input_string
}
}
// 4.2.3.3. Parsing a Key
//
// Given an ASCII string as input_string, return a key. input_string is
// modified to remove the parsed value.
//
// 1. If the first character of input_string is not lcalpha or "*",
// fail parsing.
//
// 2. Let output_string be an empty string.
//
// 3. While input_string is not empty:
//
// 1. If the first character of input_string is not one of lcalpha,
// DIGIT, "_", "-", ".", or "*", return output_string.
//
// 2. Let char be the result of consuming the first character of
// input_string.
//
// 3. Append char to output_string.
//
// 4. Return output_string.
/**
* @typedef {string} Key
*
* @typedef {Object} ParsedKey
* @property {Key} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedKey}
*/
export function parseKey(input_string) {
let i = 0
if (/^[a-z\*]$/.test(input_string[i]) === false) {
throw new Error(err`failed to parse "${input_string}" as Key`)
}
/** @type {Key} */
let output_string = ""
while (input_string.length > i) {
if (/^[a-z0-9\_\-\.\*]$/.test(input_string[i]) === false) {
return {
value: output_string,
input_string: input_string.substring(i)
}
}
output_string += input_string[i]
i++
}
return {
value: output_string,
input_string: input_string.substring(i)
}
}
// 4.2.4. Parsing an Integer or Decimal
//
// Given an ASCII string as input_string, return an Integer or Decimal.
// input_string is modified to remove the parsed value.
//
// NOTE: This algorithm parses both Integers (Section 3.3.1) and
// Decimals (Section 3.3.2), and returns the corresponding structure.
//
// 1. Let type be "integer".
//
// 2. Let sign be 1.
//
// 3. Let input_number be an empty string.
//
// 4. If the first character of input_string is "-", consume it and
// set sign to -1.
//
// 5. If input_string is empty, there is an empty integer; fail
// parsing.
//
// 6. If the first character of input_string is not a DIGIT, fail
// parsing.
//
// 7. While input_string is not empty:
//
// 1. Let char be the result of consuming the first character of
// input_string.
//
// 2. If char is a DIGIT, append it to input_number.
//
// 3. Else, if type is "integer" and char is ".":
//
// 1. If input_number contains more than 12 characters, fail
// parsing.
//
// 2. Otherwise, append char to input_number and set type to
// "decimal".
//
// 4. Otherwise, prepend char to input_string, and exit the loop.
//
// 5. If type is "integer" and input_number contains more than 15
// characters, fail parsing.
//
// 6. If type is "decimal" and input_number contains more than 16
// characters, fail parsing.
//
// 8. If type is "integer":
//
// 1. Parse input_number as an integer and let output_number be
// the product of the result and sign.
//
// 2. If output_number is outside the range -999,999,999,999,999
// to 999,999,999,999,999 inclusive, fail parsing.
//
// 9. Otherwise:
//
// 1. If the final character of input_number is ".", fail parsing.
//
// 2. If the number of characters after "." in input_number is
// greater than three, fail parsing.
//
// 3. Parse input_number as a decimal number and let output_number
// be the product of the result and sign.
//
// 10. Return output_number.
/**
* @typedef {Object} ParsedIntegerOrDecimal
* @property {number} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedIntegerOrDecimal}
*/
export function parseIntegerOrDecimal(input_string) {
const orig_string = input_string
let sign = 1
let input_number = ""
let output_number
let i = 0
if (input_string[i] === "-") {
sign = -1
input_string = input_string.substring(1)
}
if (input_string.length <= 0) throw new Error(`failed to parse "${orig_string}" as Integer or Decimal`)
const re_integer = /^(\d+)?/g
const result_integer = re_integer.exec(input_string)
if (result_integer[0].length === 0) throw new Error(`failed to parse "${orig_string}" as Integer or Decimal`)
input_number += result_integer[1]
input_string = input_string.substring(re_integer.lastIndex)
if (input_string[0] === ".") {
// decimal
if (input_number.length > 12) throw new Error(`failed to parse "${orig_string}" as Integer or Decimal`)
const re_decimal = /^(\.\d+)?/g
const result_decimal = re_decimal.exec(input_string)
input_string = input_string.substring(re_decimal.lastIndex)
// 9.2. If the number of characters after "." in input_number is greater than three, fail parsing.
if (result_decimal[0].length === 0 || result_decimal[1].length > 4) throw new Error(`failed to parse "${orig_string}" as Integer or Decimal`)
input_number += result_decimal[1]
// 7.6. If type is "decimal" and input_number contains more than 16 characters, fail parsing.
if (input_number.length > 16) throw new Error(`failed to parse "${orig_string}" as Integer or Decimal`)
output_number = parseFloat(input_number) * sign
} else {
// integer
// 7.5. If type is "integer" and input_number contains more than 15 characters, fail parsing.
if (input_number.length > 15) throw new Error(`failed to parse "${orig_string}" as Integer or Decimal`)
output_number = parseInt(input_number) * sign
if (output_number < -999999999999999n || 999999999999999n < output_number)
throw new Error(`failed to parse "${input_number}" as Integer or Decimal`)
}
return {
value: output_number,
input_string
}
}
// 4.2.5. Parsing a String
//
// Given an ASCII string as input_string, return an unquoted String.
// input_string is modified to remove the parsed value.
//
// 1. Let output_string be an empty string.
//
// 2. If the first character of input_string is not DQUOTE, fail
// parsing.
//
// 3. Discard the first character of input_string.
//
// 4. While input_string is not empty:
//
// 1. Let char be the result of consuming the first character of
// input_string.
//
// 2. If char is a backslash ("\"):
//
// 1. If input_string is now empty, fail parsing.
//
// 2. Let next_char be the result of consuming the first
// character of input_string.
//
// 3. If next_char is not DQUOTE or "\", fail parsing.
//
// 4. Append next_char to output_string.
//
// 3. Else, if char is DQUOTE, return output_string.
//
// 4. Else, if char is in the range %x00-1f or %x7f (i.e., is not
// in VCHAR or SP), fail parsing.
//
// 5. Else, append char to output_string.
//
// 5. Reached the end of input_string without finding a closing DQUOTE;
// fail parsing.
/**
* @typedef {Object} ParsedString
* @property {string} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedString}
*/
export function parseString(input_string) {
let output_string = ""
let i = 0
if (input_string[i] !== `"`) {
throw new Error(`failed to parse "${input_string}" as String`)
}
i++
while (input_string.length > i) {
// console.log(i, input_string[i], output_string)
if (input_string[i] === `\\`) {
if (input_string.length <= i + 1) {
throw new Error(`failed to parse "${input_string}" as String`)
}
i++
if (input_string[i] !== `"` && input_string[i] !== `\\`) {
throw new Error(`failed to parse "${input_string}" as String`)
}
output_string += input_string[i]
} else if (input_string[i] === `"`) {
return {
value: output_string,
input_string: input_string.substring(++i)
}
} else if (/[\x00-\x1f\x7f]+/.test(input_string[i])) {
throw new Error(`failed to parse "${input_string}" as String`)
} else {
output_string += input_string[i]
}
i++
}
throw new Error(`failed to parse "${input_string}" as String`)
}
// 4.2.6. Parsing a Token
//
// Given an ASCII string as input_string, return a Token. input_string
// is modified to remove the parsed value.
//
// 1. If the first character of input_string is not ALPHA or "*", fail
// parsing.
//
// 2. Let output_string be an empty string.
//
// 3. While input_string is not empty:
//
// 1. If the first character of input_string is not in tchar, ":"
// or "/", return output_string.
//
// 2. Let char be the result of consuming the first character of
// input_string.
//
// 3. Append char to output_string.
//
// 4. Return output_string.
/**
* @typedef {Object} ParsedToken
* @property {symbol} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedToken}
*/
export function parseToken(input_string) {
if (/^[a-zA-Z\*]$/.test(input_string[0]) === false) {
throw new Error(`failed to parse "${input_string}" as Token`)
}
const re = /^([\!\#\$\%\&\'\*\+\-\.\^\_\`\|\~\w\:\/]+)/g
const output_string = re.exec(input_string)[1]
input_string = input_string.substring(re.lastIndex)
return {
value: Symbol.for(output_string),
input_string
}
}
// 4.2.7. Parsing a Byte Sequence
//
// Given an ASCII string as input_string, return a Byte Sequence.
// input_string is modified to remove the parsed value.
//
// 1. If the first character of input_string is not ":", fail parsing.
//
// 2. Discard the first character of input_string.
//
// 3. If there is not a ":" character before the end of input_string,
// fail parsing.
//
// 4. Let b64_content be the result of consuming content of
// input_string up to but not including the first instance of the
// character ":".
//
// 5. Consume the ":" character at the beginning of input_string.
//
// 6. If b64_content contains a character not included in ALPHA, DIGIT,
// "+", "/" and "=", fail parsing.
//
// 7. Let binary_content be the result of Base 64 Decoding [RFC4648]
// b64_content, synthesizing padding if necessary (note the
// requirements about recipient behavior below).
//
// 8. Return binary_content.
//
// Because some implementations of base64 do not allow rejection of
// encoded data that is not properly "=" padded (see [RFC4648],
// Section 3.2), parsers SHOULD NOT fail when "=" padding is not
// present, unless they cannot be configured to do so.
//
// Because some implementations of base64 do not allow rejection of
// encoded data that has non-zero pad bits (see [RFC4648], Section 3.5),
// parsers SHOULD NOT fail when non-zero pad bits are present, unless
// they cannot be configured to do so.
//
// This specification does not relax the requirements in [RFC4648],
// Section 3.1 and 3.3; therefore, parsers MUST fail on characters
// outside the base64 alphabet, and on line feeds in encoded data.
/**
* @typedef {Object} ParsedByteSequence
* @property {Uint8Array} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedByteSequence}
*/
export function parseByteSequence(input_string) {
if (input_string[0] !== ":") throw new Error(`failed to parse "${input_string}" as Byte Sequence`)
input_string = input_string.substring(1)
if (input_string.includes(":") === false) throw new Error(`failed to parse "${input_string}" as Byte Sequence`)
const re = /(^.*?)(:)/g
const b64_content = re.exec(input_string)[1]
input_string = input_string.substring(re.lastIndex)
// pass b64_content char check step 6
const binary_content = base64decode(b64_content)
return {
value: binary_content,
input_string
}
}
// 4.2.8. Parsing a Boolean
//
// Given an ASCII string as input_string, return a Boolean. input_string
// is modified to remove the parsed value.
//
// 1. If the first character of input_string is not "?", fail parsing.
//
// 2. Discard the first character of input_string.
//
// 3. If the first character of input_string matches "1", discard the
// first character, and return true.
//
// 4. If the first character of input_string matches "0", discard the
// first character, and return false.
//
// 5. No value has matched; fail parsing.
/**
* @typedef {Object} ParsedBoolean
* @property {boolean} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedBoolean}
*/
export function parseBoolean(input_string) {
let i = 0
if (input_string[i] !== "?") {
throw new Error(`failed to parse "${input_string}" as Boolean`)
}
i++
if (input_string[i] === "1") {
return {
value: true,
input_string: input_string.substring(++i)
}
}
if (input_string[i] === "0") {
return {
value: false,
input_string: input_string.substring(++i)
}
}
throw new Error(`failed to parse "${input_string}" as Boolean`)
}
// 4.2.9. Parsing a Date
//
// Given an ASCII string as input_string, return a Date. input_string is
// modified to remove the parsed value.
//
// 1. If the first character of input_string is not "@", fail parsing.
//
// 2. Discard the first character of input_string.
//
// 3. Let output_date be the result of running Parsing an Integer or
// Decimal (Section 4.2.4) with input_string.
//
// 4. If output_date is a Decimal, fail parsing.
//
// 5. Return output_date.
/**
* @typedef {Object} ParsedDate
* @property {Date} value
* @property {string} input_string
*
* @param {string} input_string
* @return {ParsedDate}
*/
export function parseDate(input_string) {
let i = 0
if (input_string[i] !== "@") {
throw new Error(`failed to parse "${input_string}" as Date`)
}
i++
const output_date = parseIntegerOrDecimal(input_string.substring(i))
if (Number.isInteger(output_date.value) === false) {
throw new Error(`failed to parse "${input_string}" as Date`)
}
return {
value: new Date(output_date.value * 1000),
input_string: output_date.input_string
}
}
/////////////////////////
// base64 utility
/////////////////////////
/**
* @param {string} str
* @return {Uint8Array}
*/
export function base64decode(str) {
return new Uint8Array([...atob(str)].map((a) => a.charCodeAt(0)))
}
/**
* @param {Uint8Array} binary
* @return {string}
*/
export function base64encode(binary) {
return btoa(String.fromCharCode(...binary))
}