// util.MIMEType / util.MIMEParams: the MIME-type parsing algorithm from the Fetch/MIME // Sniffing specs, as Node's lib/internal/mime.js implements it (reimplemented here from // its documented behavior, not copied). Parameters parse lazily, on first read. function invalidMimeSyntax(production: string, str: string, invalidIndex: number): TypeError { const at = invalidIndex !== -1 ? ` at ${invalidIndex}` : ""; return Object.assign( new TypeError(`The MIME syntax for a ${production} in "${str}" is invalid${at}`), { code: "ERR_INVALID_MIME_SYNTAX" }, ); } const NOT_HTTP_TOKEN = /[^!#$%&'*+\-.^_`|~A-Za-z0-9]/; const NOT_HTTP_QUOTED_STRING = /[^\t -~\u0080-ΓΏ]/; const NOT_LEADING_WHITESPACE = /[^\r\n\t ]|$/; const TRAILING_WHITESPACE = /[\r\n\t ]*$/; function toAsciiLower(str: string): string { let out = ""; for (const ch of str) out += ch >= "A" && ch <= "Z" ? ch.toLowerCase() : ch; return out; } function search(str: string, re: RegExp): number { const m = re.exec(str); return m ? m.index : -1; } function parseTypeAndSubtype(str: string): [type: string, subtype: string, position: number] { let position = search(str, NOT_LEADING_WHITESPACE); const typeEnd = str.indexOf("/", position); const rawType = typeEnd === -1 ? str.slice(position) : str.slice(position, typeEnd); const badType = search(rawType, NOT_HTTP_TOKEN); if (rawType === "" || badType !== -1 || typeEnd === -1) throw invalidMimeSyntax("type", str, badType); position = typeEnd + 1; const type = toAsciiLower(rawType); const subtypeEnd = str.indexOf(";", position); const rawSubtype = subtypeEnd === -1 ? str.slice(position) : str.slice(position, subtypeEnd); position += rawSubtype.length + (subtypeEnd === -1 ? 0 : 1); const trimmedSubtype = rawSubtype.slice(0, search(rawSubtype, TRAILING_WHITESPACE)); const badSubtype = search(trimmedSubtype, NOT_HTTP_TOKEN); if (trimmedSubtype === "" || badSubtype !== -1) throw invalidMimeSyntax("subtype", str, badSubtype); return [type, toAsciiLower(trimmedSubtype), position]; } function removeBackslashes(str: string): string { let out = ""; let i = 0; for (; i < str.length - 1; i++) { if (str[i] === "\\") { i++; out += str[i]; } else { out += str[i]; } } if (i === str.length - 1) out += str[i]; return out; } function encodeParamValue(value: string): string { if (value.length === 0) return '""'; if (search(value, NOT_HTTP_TOKEN) === -1) return value; let escaped = ""; for (const ch of value) escaped += ch === '"' || ch === "\\" ? `\\${ch}` : ch; return `"${escaped}"`; } export class MIMEParams { #data = new Map(); #string: string | null = null; #parsed = true; static instantiate(str: string): MIMEParams { const p = new MIMEParams(); p.#string = str; p.#parsed = false; return p; } #parse(): void { if (this.#parsed) return; this.#parsed = true; const str = this.#string ?? ""; let position = 0; const end = search(str.slice(position), TRAILING_WHITESPACE) + position; while (position < end) { position += search(str.slice(position), NOT_LEADING_WHITESPACE); const nameEnd = search(str.slice(position), /[;=]|$/) + position; const name = toAsciiLower(str.slice(position, nameEnd)); position = nameEnd; if (position < end) { const ch = str[position]!; position += 1; if (ch === ";") continue; } if (position >= end) break; let value: string; if (str[position] === '"') { position += 1; const rest = str.slice(position); const m = /^(?:([\\]$)|[\\][\s\S]|[^"])*(?:(")|$)/u.exec(rest)!; position += m[0].length; const inside = m[1] || m[2] ? m[0].slice(0, -1) : m[0]; value = removeBackslashes(inside); if (m[1]) value += "\\"; } else { const valueEnd = str.indexOf(";", position); const raw = valueEnd === -1 ? str.slice(position) : str.slice(position, valueEnd); position += raw.length; const trimmed = raw.slice(0, search(raw, TRAILING_WHITESPACE)); if (trimmed === "") continue; value = trimmed; } if (name !== "" && search(name, NOT_HTTP_TOKEN) === -1 && search(value, NOT_HTTP_QUOTED_STRING) === -1 && !this.#data.has(name)) { this.#data.set(name, value); } position++; } } delete(name: string): void { this.#parse(); this.#data.delete(name); } get(name: string): string | null { this.#parse(); return this.#data.has(name) ? this.#data.get(name)! : null; } has(name: string): boolean { this.#parse(); return this.#data.has(name); } set(name: unknown, value: unknown): void { this.#parse(); const n = `${name}`; const v = `${value}`; const badName = search(n, NOT_HTTP_TOKEN); if (n.length === 0 || badName !== -1) throw invalidMimeSyntax("parameter name", n, badName); const badValue = search(v, NOT_HTTP_QUOTED_STRING); if (badValue !== -1) throw invalidMimeSyntax("parameter value", v, badValue); this.#data.set(n, v); } *entries(): IterableIterator<[string, string]> { this.#parse(); yield* this.#data.entries(); } *keys(): IterableIterator { this.#parse(); yield* this.#data.keys(); } *values(): IterableIterator { this.#parse(); yield* this.#data.values(); } toString(): string { this.#parse(); let out = ""; for (const [key, value] of this.#data) { if (out.length) out += ";"; out += `${key}=${encodeParamValue(value)}`; } return out; } toJSON(): string { return this.toString(); } [Symbol.iterator](): IterableIterator<[string, string]> { return this.entries(); } } export class MIMEType { #type: string; #subtype: string; #params: MIMEParams; constructor(input: unknown) { const str = `${input}`; const [type, subtype, rest] = parseTypeAndSubtype(str); this.#type = type; this.#subtype = subtype; this.#params = MIMEParams.instantiate(str.slice(rest)); } get type(): string { return this.#type; } set type(v: unknown) { const s = `${v}`; const bad = search(s, NOT_HTTP_TOKEN); if (s.length === 0 || bad !== -1) throw invalidMimeSyntax("type", s, bad); this.#type = toAsciiLower(s); } get subtype(): string { return this.#subtype; } set subtype(v: unknown) { const s = `${v}`; const bad = search(s, NOT_HTTP_TOKEN); if (s.length === 0 || bad !== -1) throw invalidMimeSyntax("subtype", s, bad); this.#subtype = toAsciiLower(s); } get essence(): string { return `${this.#type}/${this.#subtype}`; } get params(): MIMEParams { return this.#params; } toString(): string { const paramStr = this.#params.toString(); return paramStr.length ? `${this.#type}/${this.#subtype};${paramStr}` : `${this.#type}/${this.#subtype}`; } toJSON(): string { return this.toString(); } }