diff --git a/runtime/node/nest/shims/content-type/index.ts b/runtime/node/nest/shims/content-type/index.ts new file mode 100644 index 0000000..a733c4c --- /dev/null +++ b/runtime/node/nest/shims/content-type/index.ts @@ -0,0 +1,171 @@ + +const TEXT_REGEXP = /^[\u0009\u0020-\u007e\u0080-\u00ff]*$/ +const TOKEN_REGEXP = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/ +const QUOTE_REGEXP = /[\\"]/g +const TYPE_REGEXP = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+\/[!#$%&'*+.^_`|~0-9A-Za-z-]+$/ + +export interface ContentType { + type: string + index: number + parameters: ContentTypeParameters +} + +export interface ContentTypeParameters { + [name: string]: string +} + +export interface ContentTypeInput { + type?: string + parameters?: ContentTypeParameters +} + +export interface ParseOptions { + parameters?: boolean + comma?: boolean + start?: number +} + +export function format(obj: ContentTypeInput): string { + const type = obj.type + const parameters = obj.parameters + + if (!type || !TYPE_REGEXP.test(type)) { + throw new TypeError(`Invalid type: ${type}`) + } + + let result = type + + if (parameters) { + for (const param of Object.keys(parameters)) { + if (!TOKEN_REGEXP.test(param)) { + throw new TypeError(`Invalid parameter name: ${param}`) + } + result += `; ${param}=${qstring(parameters[param])}` + } + } + + return result +} + +const SP = 32 +const HTAB = 9 +const SEMI = 59 +const EQ = 61 +const DQUOTE = 34 +const BSLASH = 92 +const COMMA = 44 +const NO_STOP_CHAR = 65_536 + +export function parse(header: string, options?: ParseOptions): ContentType { + const stopChar = options?.comma === true ? COMMA : NO_STOP_CHAR + const len = header.length + let index = skipOWS(header, options?.start ?? 0, len) + + const valueStart = index + index = skipValue(header, index, len, stopChar) + const valueEnd = trailingOWS(header, valueStart, index) + const type = header.slice(valueStart, valueEnd).toLowerCase() + + if (options?.parameters === false) { + return { type, index, parameters: {} } + } + + return parseParameters(header, type, index, len, stopChar) +} + +function parseParameters(header: string, type: string, index: number, len: number, stopChar: number): ContentType { + const parameters: ContentTypeParameters = {} + + parameter: while (index < len) { + if (header.charCodeAt(index) === stopChar) break + + index = skipOWS(header, index + 1, len) + + const keyStart = index + + while (index < len) { + const code = header.charCodeAt(index) + if (code === stopChar) break parameter + + if (code === SEMI) continue parameter + + if (code === EQ) { + const keyEnd = trailingOWS(header, keyStart, index) + const key = header.slice(keyStart, keyEnd).toLowerCase() + + index = skipOWS(header, index + 1, len) + + if (index < len && header.charCodeAt(index) === DQUOTE) { + index++ + + let value = '' + while (index < len) { + const code = header.charCodeAt(index++) + if (code === DQUOTE) { + index = skipValue(header, index, len, stopChar) + if (!Object.hasOwn(parameters, key)) parameters[key] = value + break + } + + if (code === BSLASH && index < len) { + value += header[index++] + continue + } + + value += String.fromCharCode(code) + } + + continue parameter + } + + const valueStart = index + index = skipValue(header, index, len, stopChar) + + if (!Object.hasOwn(parameters, key)) { + const valueEnd = trailingOWS(header, valueStart, index) + parameters[key] = header.slice(valueStart, valueEnd) + } + + continue parameter + } + + index++ + } + } + + return { type, index, parameters } +} + +function skipValue(str: string, index: number, len: number, stopChar: number): number { + while (index < len) { + const code = str.charCodeAt(index) + if (code === SEMI || code === stopChar) break + index++ + } + return index +} + +function skipOWS(header: string, index: number, len: number): number { + while (index < len) { + const char = header.charCodeAt(index) + if (char !== SP && char !== HTAB) break + index++ + } + return index +} + +function trailingOWS(header: string, start: number, end: number): number { + while (end > start) { + const char = header.charCodeAt(end - 1) + if (char !== SP && char !== HTAB) break + end-- + } + return end +} + +function qstring(str: string): string { + if (TOKEN_REGEXP.test(str)) return str + if (TEXT_REGEXP.test(str)) return `"${str.replace(QUOTE_REGEXP, '\\$&')}"` + + throw new TypeError(`Invalid parameter value: ${str}`) +} diff --git a/runtime/node/nest/shims/cookie-signature/index.ts b/runtime/node/nest/shims/cookie-signature/index.ts new file mode 100644 index 0000000..e222c30 --- /dev/null +++ b/runtime/node/nest/shims/cookie-signature/index.ts @@ -0,0 +1,17 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +export function sign(val: string, secret: string | Uint8Array): string { + if (typeof val !== 'string') throw new TypeError('Cookie value must be provided as a string.') + if (secret == null) throw new TypeError('Secret key must be provided.') + return val + '.' + createHmac('sha256', secret).update(val).digest('base64').replace(/=+$/, '') +} + +export function unsign(input: string, secret: string | Uint8Array): string | false { + if (typeof input !== 'string') throw new TypeError('Signed cookie string must be provided.') + if (secret == null) throw new TypeError('Secret key must be provided.') + const tentativeValue = input.slice(0, input.lastIndexOf('.')) + const expectedInput = sign(tentativeValue, secret) + const expectedBuffer = Buffer.from(expectedInput) + const inputBuffer = Buffer.from(input) + return expectedBuffer.length === inputBuffer.length && timingSafeEqual(expectedBuffer, inputBuffer) ? tentativeValue : false +} diff --git a/runtime/node/nest/shims/cookie/index.ts b/runtime/node/nest/shims/cookie/index.ts new file mode 100644 index 0000000..525af6e --- /dev/null +++ b/runtime/node/nest/shims/cookie/index.ts @@ -0,0 +1,179 @@ + +export interface ParseOptions { + decode?: (value: string) => string +} + +export interface SerializeOptions { + encode?: (value: string) => string + maxAge?: number + domain?: string + path?: string + expires?: Date + httpOnly?: boolean + secure?: boolean + partitioned?: boolean + priority?: string + sameSite?: boolean | string +} + +const cookieNameRegExp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/ + +const cookieValueRegExp = /^("?)[\u0021\u0023-\u002B\u002D-\u003A\u003C-\u005B\u005D-\u007E]*\1$/ + +const domainValueRegExp = /^([.]?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i + +const pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/ + +export function parse(str: string, opt?: ParseOptions): { [name: string]: string } { + if (typeof str !== 'string') throw new TypeError('argument str must be a string') + + const obj: { [name: string]: string } = {} + const len = str.length + if (len < 2) return obj + + const dec = (opt && opt.decode) || decode + let index = 0 + let eqIdx = 0 + let endIdx = 0 + + do { + eqIdx = str.indexOf('=', index) + if (eqIdx === -1) break + + endIdx = str.indexOf(';', index) + + if (endIdx === -1) { + endIdx = len + } else if (eqIdx > endIdx) { + index = str.lastIndexOf(';', eqIdx - 1) + 1 + continue + } + + const keyStartIdx = startIndex(str, index, eqIdx) + const keyEndIdx = endIndex(str, eqIdx, keyStartIdx) + const key = str.slice(keyStartIdx, keyEndIdx) + + if (!Object.hasOwn(obj, key)) { + let valStartIdx = startIndex(str, eqIdx + 1, endIdx) + let valEndIdx = endIndex(str, endIdx, valStartIdx) + + if (str.charCodeAt(valStartIdx) === 0x22 && str.charCodeAt(valEndIdx - 1) === 0x22) { + valStartIdx++ + valEndIdx-- + } + + const val = str.slice(valStartIdx, valEndIdx) + obj[key] = tryDecode(val, dec) + } + + index = endIdx + 1 + } while (index < len) + + return obj +} + +function startIndex(str: string, index: number, max: number): number { + do { + const code = str.charCodeAt(index) + if (code !== 0x20 && code !== 0x09) return index + } while (++index < max) + return max +} + +function endIndex(str: string, index: number, min: number): number { + while (index > min) { + const code = str.charCodeAt(--index) + if (code !== 0x20 && code !== 0x09) return index + 1 + } + return min +} + +export function serialize(name: string, val: string, opt?: SerializeOptions): string { + const enc = (opt && opt.encode) || encodeURIComponent + + if (typeof enc !== 'function') throw new TypeError('option encode is invalid') + if (!cookieNameRegExp.test(name)) throw new TypeError('argument name is invalid') + + const value = enc(val) + if (!cookieValueRegExp.test(value)) throw new TypeError('argument val is invalid') + + let str = name + '=' + value + if (!opt) return str + + if (opt.maxAge != null) { + const maxAge = Math.floor(opt.maxAge) + if (!isFinite(maxAge)) throw new TypeError('option maxAge is invalid') + str += '; Max-Age=' + maxAge + } + + if (opt.domain) { + if (!domainValueRegExp.test(opt.domain)) throw new TypeError('option domain is invalid') + str += '; Domain=' + opt.domain + } + + if (opt.path) { + if (!pathValueRegExp.test(opt.path)) throw new TypeError('option path is invalid') + str += '; Path=' + opt.path + } + + if (opt.expires) { + const expires = opt.expires + if (!(expires instanceof Date) || isNaN(expires.valueOf())) throw new TypeError('option expires is invalid') + str += '; Expires=' + expires.toUTCString() + } + + if (opt.httpOnly) str += '; HttpOnly' + if (opt.secure) str += '; Secure' + if (opt.partitioned) str += '; Partitioned' + + if (opt.priority) { + const priority = typeof opt.priority === 'string' ? opt.priority.toLowerCase() : opt.priority + switch (priority) { + case 'low': + str += '; Priority=Low' + break + case 'medium': + str += '; Priority=Medium' + break + case 'high': + str += '; Priority=High' + break + default: + throw new TypeError('option priority is invalid') + } + } + + if (opt.sameSite) { + const sameSite = typeof opt.sameSite === 'string' ? opt.sameSite.toLowerCase() : opt.sameSite + switch (sameSite) { + case true: + str += '; SameSite=Strict' + break + case 'lax': + str += '; SameSite=Lax' + break + case 'strict': + str += '; SameSite=Strict' + break + case 'none': + str += '; SameSite=None' + break + default: + throw new TypeError('option sameSite is invalid') + } + } + + return str +} + +function decode(str: string): string { + return str.indexOf('%') !== -1 ? decodeURIComponent(str) : str +} + +function tryDecode(str: string, decode: (value: string) => string): string { + try { + return decode(str) + } catch { + return str + } +} diff --git a/runtime/node/nest/shims/encodeurl/index.ts b/runtime/node/nest/shims/encodeurl/index.ts new file mode 100644 index 0000000..cf29c65 --- /dev/null +++ b/runtime/node/nest/shims/encodeurl/index.ts @@ -0,0 +1,12 @@ + +const ENCODE_CHARS_REGEXP = /(?:[^\x21\x23-\x3B\x3D\x3F-\x5F\x61-\x7A\x7C\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g + +const UNMATCHED_SURROGATE_PAIR_REGEXP = /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g + +const UNMATCHED_SURROGATE_PAIR_REPLACE = '$1\uFFFD$2' + +export default function encodeUrl(url: string): string { + return url + .replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE) + .replace(ENCODE_CHARS_REGEXP, (match: string) => encodeURI(match)) +} diff --git a/runtime/node/nest/shims/escape-html/index.ts b/runtime/node/nest/shims/escape-html/index.ts new file mode 100644 index 0000000..182d33b --- /dev/null +++ b/runtime/node/nest/shims/escape-html/index.ts @@ -0,0 +1,41 @@ + +const matchHtmlRegExp = /["'&<>]/ + +export default function escapeHtml(string: string): string { + const match = matchHtmlRegExp.exec(string) + if (match === null) return string + + let escape = '' + let html = '' + let index = 0 + let lastIndex = 0 + + for (index = match.index; index < string.length; index++) { + switch (string.charCodeAt(index)) { + case 34: + escape = '"' + break + case 38: + escape = '&' + break + case 39: + escape = ''' + break + case 60: + escape = '<' + break + case 62: + escape = '>' + break + default: + continue + } + + if (lastIndex !== index) html += string.substring(lastIndex, index) + + lastIndex = index + 1 + html += escape + } + + return lastIndex !== index ? html + string.substring(lastIndex, index) : html +} diff --git a/runtime/node/nest/shims/etag/index.ts b/runtime/node/nest/shims/etag/index.ts new file mode 100644 index 0000000..3309d53 --- /dev/null +++ b/runtime/node/nest/shims/etag/index.ts @@ -0,0 +1,28 @@ +import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' +import { Stats } from 'node:fs' + +export interface EtagOptions { + weak?: boolean +} + +function entityTag(entity: string | Buffer): string { + if (entity.length === 0) return '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"' + const sha1 = createHash('sha1') + const hash = (typeof entity === 'string' ? sha1.update(entity, 'utf8') : sha1.update(entity)).digest('base64').substring(0, 27) + const len = typeof entity === 'string' ? Buffer.byteLength(entity, 'utf8') : entity.length + return '"' + len.toString(16) + '-' + hash + '"' +} + +function statTag(stats: Stats): string { + const mtime = stats.mtime.getTime().toString(16) + const size = stats.size.toString(16) + return '"' + size + '-' + mtime + '"' +} + +export default function etag(entity: string | Buffer | Stats, options?: EtagOptions): string { + const isStats = entity instanceof Stats + const weak = options !== undefined && typeof options.weak === 'boolean' ? options.weak : isStats + const tag = entity instanceof Stats ? statTag(entity) : entityTag(entity) + return weak ? 'W/' + tag : tag +} diff --git a/runtime/node/nest/shims/fast-querystring/index.ts b/runtime/node/nest/shims/fast-querystring/index.ts new file mode 100644 index 0000000..648e545 --- /dev/null +++ b/runtime/node/nest/shims/fast-querystring/index.ts @@ -0,0 +1,310 @@ + +export interface ParsedQuery { + [key: string]: string | string[] +} + +export type StringifyValue = string | number | boolean | bigint +export type StringifyField = StringifyValue | readonly StringifyValue[] | null | undefined + +export interface StringifyInput { + [key: string]: StringifyField +} + +const UTF8_ACCEPT = 12 +const UTF8_REJECT = 0 +// prettier-ignore +const UTF8_DATA: number[] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, + 10, 9, 9, 9, 11, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 12, 0, 0, 0, 0, 24, 36, 48, 60, 72, 84, 96, + 0, 12, 12, 12, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 24, 24, 24, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 24, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 48, 48, 48, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 48, 48, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 48, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + + 0x7f, 0x3f, 0x3f, 0x3f, 0x00, 0x1f, 0x0f, 0x0f, 0x0f, 0x07, 0x07, 0x07 +] + +function hexCodeToInt(code: number, shift: number): number { + if (code >= 48 && code <= 57) return (code - 48) << shift + if (code >= 97 && code <= 102) return (code - 87) << shift + if (code >= 65 && code <= 70) return (code - 55) << shift + return 255 +} + +export function decodeURIComponent(uri: string): string | null { + let percentPosition = uri.indexOf('%') + if (percentPosition === -1) return uri + + const length = uri.length + let decoded = '' + let last = 0 + let codepoint = 0 + let startOfOctets = percentPosition + let state = UTF8_ACCEPT + + while (percentPosition > -1 && percentPosition < length) { + const high = hexCodeToInt(uri.charCodeAt(percentPosition + 1), 4) + const low = hexCodeToInt(uri.charCodeAt(percentPosition + 2), 0) + const byte = high | low + const type = UTF8_DATA[byte] + state = UTF8_DATA[256 + state + type] + codepoint = (codepoint << 6) | (byte & UTF8_DATA[364 + type]) + + if (state === UTF8_ACCEPT) { + decoded += uri.slice(last, startOfOctets) + decoded += codepoint <= 0xffff ? String.fromCharCode(codepoint) : String.fromCharCode(0xd7c0 + (codepoint >> 10), 0xdc00 + (codepoint & 0x3ff)) + codepoint = 0 + last = percentPosition + 3 + percentPosition = startOfOctets = uri.indexOf('%', last) + } else if (state === UTF8_REJECT) { + return null + } else { + percentPosition += 3 + if (percentPosition < length && uri.charCodeAt(percentPosition) === 37) continue + return null + } + } + + return decoded + uri.slice(last) +} + +const plusRegex = /\+/g + +function decodeOrKeep(text: string): string { + const decoded = decodeURIComponent(text) + return decoded === null || decoded === '' ? text : decoded +} + +export function parse(input: string): ParsedQuery { + const result: ParsedQuery = {} + + const inputLength = input.length + let key = '' + let value = '' + let startingIndex = -1 + let equalityIndex = -1 + let shouldDecodeKey = false + let shouldDecodeValue = false + let keyHasPlus = false + let valueHasPlus = false + let hasBothKeyValuePair = false + let c = 0 + + for (let i = 0; i < inputLength + 1; i++) { + c = i !== inputLength ? input.charCodeAt(i) : 38 + + if (c === 38) { + hasBothKeyValuePair = equalityIndex > startingIndex + + if (!hasBothKeyValuePair) { + equalityIndex = i + } + + key = input.slice(startingIndex + 1, equalityIndex) + + if ((hasBothKeyValuePair || key.length > 0) && key !== '__proto__') { + if (keyHasPlus) { + key = key.replace(plusRegex, ' ') + } + + if (shouldDecodeKey) { + key = decodeOrKeep(key) + } + + if (hasBothKeyValuePair) { + value = input.slice(equalityIndex + 1, i) + + if (valueHasPlus) { + value = value.replace(plusRegex, ' ') + } + + if (shouldDecodeValue) { + value = decodeOrKeep(value) + } + } + const currentValue: string | string[] | undefined = Object.hasOwn(result, key) ? result[key] : undefined + + if (currentValue === undefined) { + result[key] = value + } else if (Array.isArray(currentValue)) { + currentValue.push(value) + } else { + result[key] = [currentValue, value] + } + } + + value = '' + startingIndex = i + equalityIndex = i + shouldDecodeKey = false + shouldDecodeValue = false + keyHasPlus = false + valueHasPlus = false + } + else if (c === 61) { + if (equalityIndex <= startingIndex) { + equalityIndex = i + } + else { + shouldDecodeValue = true + } + } + else if (c === 43) { + if (equalityIndex > startingIndex) { + valueHasPlus = true + } else { + keyHasPlus = true + } + } + else if (c === 37) { + if (equalityIndex > startingIndex) { + shouldDecodeValue = true + } else { + shouldDecodeKey = true + } + } + } + + return result +} + +const hexTable: string[] = [] +for (let i = 0; i < 256; i++) hexTable.push('%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase()) + +// prettier-ignore +const noEscape: number[] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0 +] + +export function encodeString(str: string): string { + const len = str.length + if (len === 0) return '' + + let out = '' + let lastPos = 0 + let i = 0 + + outer: for (; i < len; i++) { + let c = str.charCodeAt(i) + + while (c < 0x80) { + if (noEscape[c] !== 1) { + if (lastPos < i) out += str.slice(lastPos, i) + lastPos = i + 1 + out += hexTable[c] + } + + if (++i === len) break outer + + c = str.charCodeAt(i) + } + + if (lastPos < i) out += str.slice(lastPos, i) + + if (c < 0x800) { + lastPos = i + 1 + out += hexTable[0xc0 | (c >> 6)] + hexTable[0x80 | (c & 0x3f)] + continue + } + if (c < 0xd800 || c >= 0xe000) { + lastPos = i + 1 + out += hexTable[0xe0 | (c >> 12)] + hexTable[0x80 | ((c >> 6) & 0x3f)] + hexTable[0x80 | (c & 0x3f)] + continue + } + ++i + + if (i >= len) { + throw new Error('URI malformed') + } + + const c2 = str.charCodeAt(i) & 0x3ff + + lastPos = i + 1 + c = 0x10000 + (((c & 0x3ff) << 10) | c2) + out += hexTable[0xf0 | (c >> 18)] + hexTable[0x80 | ((c >> 12) & 0x3f)] + hexTable[0x80 | ((c >> 6) & 0x3f)] + hexTable[0x80 | (c & 0x3f)] + } + if (lastPos === 0) return str + if (lastPos < len) return out + str.slice(lastPos) + return out +} + +function getAsPrimitive(value: StringifyValue | null | undefined): string { + if (typeof value === 'string') { + return encodeString(value) + } else if (typeof value === 'bigint') { + return value.toString() + } else if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } else if (typeof value === 'number' && Number.isFinite(value)) { + return value < 1e21 ? '' + value : encodeString('' + value) + } + + return '' +} + +function isValueList(value: StringifyField): value is readonly StringifyValue[] { + return Array.isArray(value) +} + +export function stringify(input: StringifyInput): string { + let result = '' + + const separator = '&' + const keys = Object.keys(input) + const keyLength = keys.length + let valueLength = 0 + + for (let i = 0; i < keyLength; i++) { + const key = keys[i] + const value = input[key] + const encodedKey = encodeString(key) + '=' + + if (i) { + result += separator + } + + if (isValueList(value)) { + valueLength = value.length + for (let j = 0; j < valueLength; j++) { + if (j) { + result += separator + } + + result += encodedKey + result += getAsPrimitive(value[j]) + } + } else { + result += encodedKey + result += getAsPrimitive(value) + } + } + + return result +} diff --git a/runtime/node/nest/shims/find-my-way/index.ts b/runtime/node/nest/shims/find-my-way/index.ts new file mode 100644 index 0000000..ccf8f0c --- /dev/null +++ b/runtime/node/nest/shims/find-my-way/index.ts @@ -0,0 +1,1730 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import { parse as parseQuerystring } from '../fast-querystring/index' +import isRegexSafe from '../safe-regex2/index' +import { safeDecodeURI, safeDecodeURIComponent, safeDecodeURINativeScan, safeDecodeURICharScan, MIN_NATIVE_SCAN_LENGTH } from './lib/url-sanitizer' + +export type HTTPMethod = + | 'ACL' + | 'BIND' + | 'CHECKOUT' + | 'CONNECT' + | 'COPY' + | 'DELETE' + | 'GET' + | 'HEAD' + | 'LINK' + | 'LOCK' + | 'M-SEARCH' + | 'MERGE' + | 'MKACTIVITY' + | 'MKCALENDAR' + | 'MKCOL' + | 'MOVE' + | 'NOTIFY' + | 'OPTIONS' + | 'PATCH' + | 'POST' + | 'PROPFIND' + | 'PROPPATCH' + | 'PURGE' + | 'PUT' + | 'QUERY' + | 'REBIND' + | 'REPORT' + | 'SEARCH' + | 'SOURCE' + | 'SUBSCRIBE' + | 'TRACE' + | 'UNBIND' + | 'UNLINK' + | 'UNLOCK' + | 'UNSUBSCRIBE' + +export const httpMethods: readonly HTTPMethod[] = [ + 'ACL', 'BIND', 'CHECKOUT', 'CONNECT', 'COPY', 'DELETE', + 'GET', 'HEAD', 'LINK', 'LOCK', 'M-SEARCH', 'MERGE', + 'MKACTIVITY', 'MKCALENDAR', 'MKCOL', 'MOVE', 'NOTIFY', 'OPTIONS', + 'PATCH', 'POST', 'PROPFIND', 'PROPPATCH', 'PURGE', 'PUT', 'QUERY', + 'REBIND', 'REPORT', 'SEARCH', 'SOURCE', 'SUBSCRIBE', 'TRACE', + 'UNBIND', 'UNLINK', 'UNLOCK', 'UNSUBSCRIBE' +] + +export type Req = IncomingMessage +export type Res = ServerResponse + +export interface Params { + [name: string]: string | undefined +} + +export interface SearchParams { + [name: string]: string | string[] +} + +export type QuerystringParser = (query: string) => SearchParams + +export type Handler = (req: Req, res: Res, params: Params, store: Store | null, searchParams: SearchParams) => void + +export type Done = (err: Error | null) => void + +const constraintsEqual = (left: Constraints, right: Constraints): boolean => { + const names = Object.keys(left) + if (names.length !== Object.keys(right).length) return false + for (const name of names) { + if (!Object.hasOwn(right, name)) return false + const a = left[name] + const b = right[name] + if (a instanceof RegExp && b instanceof RegExp) { + if (a.source !== b.source || a.flags !== b.flags) return false + } else if (a !== b) { + return false + } + } + return true +} + +export type ConstraintValue = string | number | boolean | RegExp | null + +export interface Constraints { + [name: string]: ConstraintValue +} + +export type DerivedValue = string | number | boolean | undefined + +export interface DerivedConstraints { + [name: string]: DerivedValue +} + +type HeaderValue = string | string[] | undefined +const headerValue = (value: HeaderValue): DerivedValue => (Array.isArray(value) ? value.join(', ') : value) + +export interface ConstraintStorage { + get(value: ConstraintValue | DerivedValue): number | null | undefined + set(value: ConstraintValue, handlers: number): void +} + +export type DeriveDone = (err: Error | null, value?: DerivedValue) => void + +export interface ConstraintStrategy { + name: string + mustMatchWhenDerived?: boolean + storage(): ConstraintStorage + validate?(value: ConstraintValue): void + deriveConstraint?(req: Req): DerivedValue + deriveConstraintAsync?(req: Req, done: DeriveDone): void +} + +export interface ConstraintStrategies { + [name: string]: ConstraintStrategy +} + +export interface RouteOptions { + constraints?: Constraints +} + +export interface Route { + method: string + path: string + pattern: string + params: string[] + opts: RouteOptions + handler: Handler + store: Store | null +} + +export interface FindResult { + handler: Handler + params: Params + store: Store | null + searchParams: SearchParams +} + +export interface FindRouteResult { + handler: Handler + store: Store | null + params: string[] +} + +export type PrettyMetaValue = string | number | boolean | null | undefined | RegExp | PrettyMetaValue[] + +export interface PrettyMeta { + [key: string]: PrettyMetaValue +} + +export interface PrettyPrintOptions { + method?: HTTPMethod + commonPrefix?: boolean + includeMeta?: boolean | string[] +} + +export type DefaultRoute = (req: Req, res: Res) => void +export type PathRoute = (path: string, req: Req, res: Res) => void + +export interface Config { + ignoreTrailingSlash?: boolean + ignoreDuplicateSlashes?: boolean + allowUnsafeRegex?: boolean + caseSensitive?: boolean + maxParamLength?: number + useSemicolonDelimiter?: boolean + querystringParser?: QuerystringParser + defaultRoute?: DefaultRoute + onBadUrl?: PathRoute + onMaxParamLength?: PathRoute + constraints?: ConstraintStrategies + buildPrettyMeta?: (route: Route) => PrettyMeta +} + +export class AssertionError extends Error { + readonly code = 'ERR_ASSERTION' + constructor(message: string) { + super(message) + this.name = 'AssertionError' + } +} + +const assert = (condition: boolean, message: string): void => { + if (!condition) throw new AssertionError(message) +} + +const OPTIONAL_PARAM_REGEXP = /(\/:[^/()]*?)\?(\/?)/ +const REMOVE_DUPLICATE_SLASHES_REGEXP = /\/\/+/g +const REGEXP_SPECIAL = '.*+?^${}()|[]\\' + +class SemVerStore implements ConstraintStorage { + private readonly store = new Map() + private maxMajor = 0 + private readonly maxMinors = new Map() + private readonly maxPatches = new Map() + + set(version: ConstraintValue, store: number): void { + if (typeof version !== 'string') throw new TypeError('Version should be a string') + const parts = version.split('.') + const majorText = parts[0] ?? '' + if (Number.isNaN(Number(majorText))) throw new TypeError('Major version must be a numeric value') + const major = Number(majorText) + const minor = Number(parts[1]) || 0 + const patch = Number(parts[2]) || 0 + + if (major >= this.maxMajor) { + this.maxMajor = major + this.store.set('x', store) + this.store.set('*', store) + this.store.set('x.x', store) + this.store.set('x.x.x', store) + } + if (minor >= (this.maxMinors.get(major) ?? 0)) { + this.maxMinors.set(major, minor) + this.store.set(`${major}.x`, store) + this.store.set(`${major}.x.x`, store) + } + if (patch >= (this.maxPatches.get(`${major}.${minor}`) ?? 0)) { + this.maxPatches.set(`${major}.${minor}`, patch) + this.store.set(`${major}.${minor}.x`, store) + } + this.store.set(`${major}.${minor}.${patch}`, store) + } + + get(version: ConstraintValue | DerivedValue): number | undefined { + return typeof version === 'string' ? this.store.get(version) : undefined + } +} + +const acceptVersionStrategy: ConstraintStrategy = { + name: 'version', + mustMatchWhenDerived: true, + storage: () => new SemVerStore(), + validate(value: ConstraintValue): void { + assert(typeof value === 'string', 'Version should be a string') + }, + deriveConstraint: (req: Req): DerivedValue => headerValue(req.headers['accept-version']) +} + +interface RegexHost { + host: RegExp + value: number +} + +const withoutGlobalFlags = (flags: string): string => { + let kept = '' + for (const flag of flags) if (flag !== 'g' && flag !== 'y') kept += flag + return kept +} + +class HostStorage implements ConstraintStorage { + private readonly hosts = new Map() + private readonly regexHosts: RegexHost[] = [] + private readonly regexCache = new Map() + + get(host: ConstraintValue | DerivedValue): number | undefined { + const exact = typeof host === 'string' ? this.hosts.get(host) : undefined + if (exact) return exact + if (this.regexHosts.length === 0) return undefined + const key = String(host) + if (this.regexCache.has(key)) return this.regexCache.get(key) + for (const regex of this.regexHosts) { + if (regex.host.test(key)) { + this.regexCache.set(key, regex.value) + return regex.value + } + } + this.regexCache.set(key, undefined) + return undefined + } + + set(host: ConstraintValue, value: number): void { + if (host instanceof RegExp) { + const safeRegex = new RegExp(host.source, withoutGlobalFlags(host.flags)) + this.regexHosts.push({ host: safeRegex, value }) + this.regexCache.clear() + } else { + this.hosts.set(String(host), value) + } + } +} + +const acceptHostStrategy: ConstraintStrategy = { + name: 'host', + mustMatchWhenDerived: false, + storage: () => new HostStorage(), + validate(value: ConstraintValue): void { + assert(typeof value === 'string' || value instanceof RegExp, 'Host should be a string or a RegExp') + }, + deriveConstraint: (req: Req): DerivedValue => headerValue(req.headers.host || req.headers[':authority']) +} + +class MethodStorage implements ConstraintStorage { + private readonly handlers = new Map() + get(type: ConstraintValue | DerivedValue): number | null { + return this.handlers.get(String(type)) ?? null + } + set(type: ConstraintValue, store: number): void { + this.handlers.set(String(type), store) + } +} + +const httpMethodStrategy: ConstraintStrategy = { + name: '__fmw_internal_strategy_merged_tree_http_method__', + storage: () => new MethodStorage(), + deriveConstraint: (req: Req): DerivedValue => req.method, + mustMatchWhenDerived: true +} + +interface RegisteredStrategy { + strategy: ConstraintStrategy + isCustom: boolean + isAsync: boolean +} + +class Constrainer { + readonly strategies = new Map() + readonly strategiesInUse = new Set() + readonly asyncStrategiesInUse = new Set() + + constructor(customStrategies: ConstraintStrategies | undefined) { + this.strategies.set('version', { strategy: acceptVersionStrategy, isCustom: false, isAsync: false }) + this.strategies.set('host', { strategy: acceptHostStrategy, isCustom: false, isAsync: false }) + if (customStrategies) for (const strategy of Object.values(customStrategies)) this.addConstraintStrategy(strategy) + } + + isStrategyUsed(strategyName: string): boolean { + return this.strategiesInUse.has(strategyName) || this.asyncStrategiesInUse.has(strategyName) + } + + hasConstraintStrategy(strategyName: string): boolean { + const registered = this.strategies.get(strategyName) + if (registered !== undefined) return registered.isCustom || this.isStrategyUsed(strategyName) + return false + } + + addConstraintStrategy(strategy: ConstraintStrategy): void { + assert(typeof strategy.name === 'string' && strategy.name !== '', 'strategy.name is required.') + assert(typeof strategy.storage === 'function', 'strategy.storage function is required.') + const isAsync = typeof strategy.deriveConstraintAsync === 'function' + assert(isAsync || typeof strategy.deriveConstraint === 'function', 'strategy.deriveConstraint function is required.') + + const existing = this.strategies.get(strategy.name) + if (existing !== undefined && existing.isCustom) throw new Error(`There already exists a custom constraint with the name ${strategy.name}.`) + if (this.isStrategyUsed(strategy.name)) throw new Error(`There already exists a route with ${strategy.name} constraint.`) + + this.strategies.set(strategy.name, { strategy, isCustom: true, isAsync }) + if (strategy.mustMatchWhenDerived) this.noteUsage([strategy.name]) + } + + deriveConstraints(req: Req): DerivedConstraints | undefined { + return this.deriveSyncConstraints(req) + } + + deriveConstraintsAsync(req: Req, done: (err: Error | null, constraints?: DerivedConstraints) => void): void { + this.deriveAsyncConstraints(this.deriveSyncConstraints(req), req, done) + } + + private deriveSyncConstraints(req: Req): DerivedConstraints | undefined { + if (this.strategiesInUse.size === 0) return undefined + const derived: DerivedConstraints = {} + for (const key of this.strategiesInUse) { + const registered = this.strategies.get(key) + if (registered === undefined) continue + if (!registered.isCustom) { + if (key === 'version') derived.version = headerValue(req.headers['accept-version']) + else derived.host = headerValue(req.headers.host || req.headers[':authority']) + } else if (registered.strategy.deriveConstraint) { + derived[registered.strategy.name] = registered.strategy.deriveConstraint(req) + } + } + return derived + } + + noteUsage(constraintNames: string[]): void { + for (const key of constraintNames) { + const registered = this.strategies.get(key) + if (registered === undefined) continue + if (registered.isAsync) this.asyncStrategiesInUse.add(key) + else this.strategiesInUse.add(key) + } + } + + newStoreForConstraint(constraint: string): ConstraintStorage { + const registered = this.strategies.get(constraint) + if (registered === undefined) throw new Error(`No strategy registered for constraint key ${constraint}`) + return registered.strategy.storage() + } + + validateConstraints(constraints: Constraints): void { + for (const key of Object.keys(constraints)) { + const value = constraints[key] + if (value === undefined) throw new Error("Can't pass an undefined constraint value, must pass null or no key at all") + const registered = this.strategies.get(key) + if (registered === undefined) throw new Error(`No strategy registered for constraint key ${key}`) + if (registered.strategy.validate) registered.strategy.validate(value) + } + } + + private deriveAsyncConstraints( + constraints: DerivedConstraints | undefined, + req: Req, + done: (err: Error | null, constraints?: DerivedConstraints) => void + ): void { + let asyncConstraintsCount = this.asyncStrategiesInUse.size + if (asyncConstraintsCount === 0) { + done(null, constraints) + return + } + let errored = false + const derived: DerivedConstraints = constraints ?? {} + for (const key of this.asyncStrategiesInUse) { + const registered = this.strategies.get(key) + if (registered === undefined || !registered.strategy.deriveConstraintAsync) continue + registered.strategy.deriveConstraintAsync(req, (err: Error | null, constraintValue?: DerivedValue): void => { + if (errored) return + if (err !== null) { + errored = true + done(err) + return + } + derived[key] = constraintValue + if (--asyncConstraintsCount === 0) done(null, derived) + }) + } + } +} + +interface HandlerSlot { + params: string[] + constraints: Constraints + handler: Handler + store: Store | null +} + +const createParamsObject = (names: string[], values: string[]): Params => { + const params: Params = {} + for (let i = 0; i < names.length; i++) params[names[i] ?? ''] = values[i] + return params +} + +class HandlerStorage { + unconstrainedHandler: HandlerSlot | null = null + readonly constraints: string[] = [] + readonly handlers: HandlerSlot[] = [] + private readonly constrainedHandlerStores = new Map() + private readonly masks: number[] = [] + private readonly mustMatch: boolean[] = [] + private readonly absentMustMatch: string[] = [] + private compiled = false + + getMatchingHandler(derivedConstraints: DerivedConstraints | undefined): HandlerSlot | null { + if (derivedConstraints === undefined) return this.unconstrainedHandler + return this.getHandlerMatchingConstraints(derivedConstraints) + } + + addHandler(constrainer: Constrainer, route: Route): void { + const constraints = route.opts.constraints ?? {} + const handlerObject: HandlerSlot = { params: route.params, constraints, handler: route.handler, store: route.store || null } + + const constraintsNames = Object.keys(constraints) + if (constraintsNames.length === 0) this.unconstrainedHandler = handlerObject + + for (const constraint of constraintsNames) { + if (!this.constraints.includes(constraint)) { + if (constraint === 'version') this.constraints.unshift(constraint) + else this.constraints.push(constraint) + } + } + + const isMergedTree = constraintsNames.includes(httpMethodStrategy.name) + if (!isMergedTree && this.handlers.length >= 31) { + throw new Error('find-my-way supports a maximum of 31 route handlers per node when there are constraints, limit reached') + } + + this.handlers.push(handlerObject) + this.handlers.sort((a, b) => Object.keys(a.constraints).length - Object.keys(b.constraints).length) + + if (!isMergedTree) this.compileGetHandlerMatchingConstraints(constrainer) + } + + private getHandlerMatchingConstraints(derivedConstraints: DerivedConstraints): HandlerSlot | null { + if (!this.compiled) return null + let candidates = (1 << this.handlers.length) - 1 + for (let i = 0; i < this.constraints.length; i++) { + const constraint = this.constraints[i] ?? '' + const mask = this.masks[i] ?? 0 + const value = derivedConstraints[constraint] + if (value === undefined) { + candidates &= mask + } else { + const matches = this.constrainedHandlerStores.get(constraint)?.get(value) || 0 + candidates &= this.mustMatch[i] ? matches : matches | mask + } + if (candidates === 0) return null + } + for (const constraint of this.absentMustMatch) if (derivedConstraints[constraint] !== undefined) return null + return this.handlers[31 - Math.clz32(candidates)] ?? null + } + + private compileGetHandlerMatchingConstraints(constrainer: Constrainer): void { + this.constrainedHandlerStores.clear() + this.masks.length = 0 + this.mustMatch.length = 0 + this.absentMustMatch.length = 0 + + for (const constraint of this.constraints) { + const store = constrainer.newStoreForConstraint(constraint) + this.constrainedHandlerStores.set(constraint, store) + let mask = 0 + for (let i = 0; i < this.handlers.length; i++) { + const constraintValue = this.handlers[i]?.constraints[constraint] + if (constraintValue !== undefined) { + let indexes = store.get(constraintValue) || 0 + indexes |= 1 << i + store.set(constraintValue, indexes) + mask |= 1 << i + } + } + this.masks.push(~mask) + this.mustMatch.push(constrainer.strategies.get(constraint)?.strategy.mustMatchWhenDerived === true) + } + + for (const [name, registered] of constrainer.strategies) { + if (registered.strategy.mustMatchWhenDerived && !this.constraints.includes(name)) this.absentMustMatch.push(name) + } + this.compiled = true + } +} + +interface BrotherState { + paramsCount: number + brotherPathIndex: number + brotherNode: TreeNode +} + +type TreeNode = StaticNode | ParametricNode | WildcardNode + +class Node { + isLeafNode = false + routes: Route[] | null = null + handlerStorage: HandlerStorage | null = null + + addRoute(route: Route, constrainer: Constrainer): void { + if (this.routes === null) this.routes = [] + if (this.handlerStorage === null) this.handlerStorage = new HandlerStorage() + this.isLeafNode = true + this.routes.push(route) + this.handlerStorage.addHandler(constrainer, route) + } +} + +class ParentNode extends Node { + readonly staticChildrenCharCodes: number[] = [] + readonly staticChildrenNodes: StaticNode[] = [] + + setStaticChild(label: string, node: StaticNode): void { + const charCode = label.charCodeAt(0) + const index = this.staticChildrenCharCodes.indexOf(charCode) + if (index === -1) { + this.staticChildrenCharCodes.push(charCode) + this.staticChildrenNodes.push(node) + } else { + this.staticChildrenNodes[index] = node + } + } + + findStaticMatchingChild(path: string, pathIndex: number): StaticNode | null { + const charCode = path.charCodeAt(pathIndex) + const charCodes = this.staticChildrenCharCodes + for (let i = 0; i < charCodes.length; i++) { + if (charCodes[i] === charCode) { + const staticChild = this.staticChildrenNodes[i] + if (staticChild !== undefined && staticChild.matchPrefix(path, pathIndex)) return staticChild + return null + } + } + return null + } + + getStaticChild(path: string, pathIndex = 0): ParentNode | null { + if (path.length === pathIndex) return this + const staticChild = this.findStaticMatchingChild(path, pathIndex) + if (staticChild) return staticChild.getStaticChild(path, pathIndex + staticChild.prefix.length) + return null + } + + createStaticChild(path: string): ParentNode { + if (path.length === 0) return this + + const childIndex = this.staticChildrenCharCodes.indexOf(path.charCodeAt(0)) + let staticChild = childIndex === -1 ? undefined : this.staticChildrenNodes[childIndex] + if (staticChild) { + let i = 1 + for (; i < staticChild.prefix.length; i++) { + if (path.charCodeAt(i) !== staticChild.prefix.charCodeAt(i)) { + staticChild = staticChild.split(this, i) + break + } + } + return staticChild.createStaticChild(path.slice(i)) + } + + const node = new StaticNode(path) + this.setStaticChild(path.charAt(0), node) + return node + } +} + +class StaticNode extends ParentNode { + prefix: string + wildcardChild: WildcardNode | null = null + readonly parametricChildren: ParametricNode[] = [] + + constructor(prefix: string) { + super() + this.prefix = prefix + } + + matchPrefix(path: string, pathIndex: number): boolean { + const prefix = this.prefix + for (let i = 1; i < prefix.length; i++) if (path.charCodeAt(pathIndex + i) !== prefix.charCodeAt(i)) return false + return true + } + + getParametricChild(regex: RegExp | null): ParametricNode | null { + const regexpSource = regex === null ? null : regex.source + for (const child of this.parametricChildren) { + const childRegexSource = child.regex === null ? null : child.regex.source + if (childRegexSource === regexpSource) return child + } + return null + } + + createParametricChild(regex: RegExp | null, staticSuffix: string | null, nodePath: string): ParametricNode { + const existing = this.getParametricChild(regex) + if (existing) { + existing.nodePaths.add(nodePath) + return existing + } + + const parametricChild = new ParametricNode(regex, staticSuffix, nodePath) + this.parametricChildren.push(parametricChild) + this.parametricChildren.sort((child1, child2) => { + if (!child1.isRegex) return 1 + if (!child2.isRegex) return -1 + if (child1.staticSuffix === null) return 1 + if (child2.staticSuffix === null) return -1 + if (child2.staticSuffix.endsWith(child1.staticSuffix)) return 1 + if (child1.staticSuffix.endsWith(child2.staticSuffix)) return -1 + return 0 + }) + return parametricChild + } + + getWildcardChild(): WildcardNode | null { + return this.wildcardChild + } + + createWildcardChild(): WildcardNode { + this.wildcardChild = this.wildcardChild ?? new WildcardNode() + return this.wildcardChild + } + + split(parentNode: ParentNode, length: number): StaticNode { + const parentPrefix = this.prefix.slice(0, length) + const childPrefix = this.prefix.slice(length) + + this.prefix = childPrefix + + const staticNode = new StaticNode(parentPrefix) + staticNode.setStaticChild(childPrefix.charAt(0), this) + parentNode.setStaticChild(parentPrefix.charAt(0), staticNode) + return staticNode + } + + getNextNode(path: string, pathIndex: number, nodeStack: BrotherState[], paramsCount: number): TreeNode | null { + let node: TreeNode | null = this.findStaticMatchingChild(path, pathIndex) + let parametricBrotherNodeIndex = 0 + + if (node === null) { + if (this.parametricChildren.length === 0) return this.wildcardChild + node = this.parametricChildren[0] ?? null + parametricBrotherNodeIndex = 1 + } + + if (this.wildcardChild !== null) nodeStack.push({ paramsCount, brotherPathIndex: pathIndex, brotherNode: this.wildcardChild }) + + for (let i = this.parametricChildren.length - 1; i >= parametricBrotherNodeIndex; i--) { + const brother = this.parametricChildren[i] + if (brother !== undefined) nodeStack.push({ paramsCount, brotherPathIndex: pathIndex, brotherNode: brother }) + } + return node + } +} + +class ParametricNode extends ParentNode { + readonly isRegex: boolean + readonly regex: RegExp | null + readonly staticSuffix: string | null + readonly nodePaths: Set + + constructor(regex: RegExp | null, staticSuffix: string | null, nodePath: string) { + super() + this.isRegex = regex !== null + this.regex = regex + this.staticSuffix = staticSuffix + this.nodePaths = new Set([nodePath]) + } + + getNextNode(path: string, pathIndex: number, _nodeStack: BrotherState[], _paramsCount: number): TreeNode | null { + return this.findStaticMatchingChild(path, pathIndex) + } +} + +class WildcardNode extends Node { + getNextNode(_path: string, _pathIndex: number, _nodeStack: BrotherState[], _paramsCount: number): TreeNode | null { + return null + } +} + +interface PrintTree { + readonly children: Map + data: string +} + +interface PrintableRoute { + method: string + constraints: Constraints + metaData: { [key: string]: string } +} + +const printObjectTree = (tree: PrintTree, parentPrefix = ''): string => { + let text = '' + const keys = [...tree.children.keys()] + for (let i = 0; i < keys.length; i++) { + const key = keys[i] ?? '' + const value = tree.children.get(key) + if (value === undefined) continue + const isLast = i === keys.length - 1 + const nodePrefix = isLast ? '└── ' : '├── ' + const childPrefix = isLast ? ' ' : '│ ' + const prefixedNodeData = value.data.replaceAll('\n', '\n' + parentPrefix + childPrefix) + text += parentPrefix + nodePrefix + key + prefixedNodeData + '\n' + text += printObjectTree(value, parentPrefix + childPrefix) + } + return text +} + +const parseMeta = (meta: PrettyMetaValue): PrettyMetaValue => { + if (Array.isArray(meta)) return meta.map((m) => parseMeta(m)) + if (meta instanceof RegExp) return meta.toString() + return meta +} + +const stringifyMeta = (meta: PrettyMetaValue): string => JSON.stringify(parseMeta(meta)) + +const serializeConstraints = (constraints: Constraints): string => { + const parts: string[] = [] + for (const key of Object.keys(constraints)) { + const value = constraints[key] + const serialized = value instanceof RegExp ? JSON.stringify(value.toString()) : JSON.stringify(value) + parts.push(`${JSON.stringify(key)}:${serialized}`) + } + return `{${parts.join(',')}}` +} + +const metaDataEqual = (left: { [key: string]: string }, right: { [key: string]: string }): boolean => { + const keys = Object.keys(left) + if (keys.length !== Object.keys(right).length) return false + for (const key of keys) { + if (!Object.hasOwn(right, key) || left[key] !== right[key]) return false + } + return true +} + +const serializeRoute = (route: PrintableRoute): string => { + let serializedRoute = ` (${route.method})` + if (Object.keys(route.constraints).length !== 0) serializedRoute += ' ' + serializeConstraints(route.constraints) + for (const key of Object.keys(route.metaData)) serializedRoute += `\n• (${key}) ${route.metaData[key]}` + return serializedRoute +} + +const mergeSimilarRoutes = (routes: PrintableRoute[]): PrintableRoute[] => { + const merged: PrintableRoute[] = [] + for (const route of routes) { + let found = false + for (const nodeRoute of merged) { + if (constraintsEqual(route.constraints, nodeRoute.constraints) && metaDataEqual(route.metaData, nodeRoute.metaData)) { + nodeRoute.method += ', ' + route.method + found = true + break + } + } + if (!found) merged.push(route) + } + return merged +} + +const escapeRegExp = (text: string): string => { + let escaped = '' + for (const char of text) escaped += REGEXP_SPECIAL.includes(char) ? '\\' + char : char + return escaped +} + +const getPathFromAbsoluteUrl = (url: string): string | null => { + const schemeEnd = url.indexOf('://') + if (schemeEnd === -1) return url + + const scheme = url.slice(0, schemeEnd).toLowerCase() + if (scheme !== 'http' && scheme !== 'https') return url + + const authorityStart = schemeEnd + 3 + let authorityEnd = url.length + + const pathStart = url.indexOf('/', authorityStart) + if (pathStart !== -1) authorityEnd = pathStart + + const queryStart = url.indexOf('?', authorityStart) + if (queryStart !== -1 && queryStart < authorityEnd) authorityEnd = queryStart + + if (url.indexOf('#', authorityStart) !== -1 || authorityEnd === authorityStart) return null + + try { + const parsed = new URL(url) + if (parsed.protocol !== `${scheme}:` || parsed.host.length === 0) return null + } catch { + return null + } + + if (authorityEnd === url.length) return '/' + if (authorityEnd === queryStart) return '/' + url.slice(queryStart) + return url.slice(pathStart) +} + +export function removeDuplicateSlashes(path: string): string { + return path.indexOf('//') !== -1 ? path.replace(REMOVE_DUPLICATE_SLASHES_REGEXP, '/') : path +} + +export function trimLastSlash(path: string): string { + if (path.length > 1 && path.charCodeAt(path.length - 1) === 47) return path.slice(0, -1) + return path +} + +export function sanitizeUrlPath(url: string, useSemicolonDelimiter = false): string { + const decoded = safeDecodeURI(url, useSemicolonDelimiter) + if (decoded.shouldDecodeParam) return safeDecodeURIComponent(decoded.path) + return decoded.path +} + +const trimRegExpStartAndEnd = (regexString: string): string => { + if (regexString.charCodeAt(1) === 94) regexString = regexString.slice(0, 1) + regexString.slice(2) + if (regexString.charCodeAt(regexString.length - 2) === 36) { + regexString = regexString.slice(0, regexString.length - 2) + regexString.slice(regexString.length - 1) + } + return regexString +} + +const getClosingParenthensePosition = (path: string, idx: number): number => { + let parentheses = 1 + while (idx < path.length) { + idx++ + if (path.charCodeAt(idx) === 92) { + idx++ + continue + } + if (path.charCodeAt(idx) === 41) parentheses-- + else if (path.charCodeAt(idx) === 40) parentheses++ + if (!parentheses) return idx + } + throw new TypeError('Invalid regexp expression in "' + path + '"') +} + +const defaultQuerystringParser: QuerystringParser = (query: string): SearchParams => { + if (query.length === 0) return {} + const parsed: SearchParams = parseQuerystring(query) + return parsed +} + +const defaultBuildPrettyMeta = (): PrettyMeta => ({}) + +interface OptionalParamSplit { + pathFull: string + pathOptional: string +} + +const splitOptionalParam = (path: string): OptionalParamSplit | null => { + const match = OPTIONAL_PARAM_REGEXP.exec(path) + if (match === null) return null + assert(path.length === match.index + match[0].length, 'Optional Parameter needs to be the last parameter of the path') + const head = path.slice(0, match.index) + const param = match[1] ?? '' + const tail = match[2] ?? '' + return { pathFull: head + param + tail, pathOptional: head + tail || '/' } +} + +export class Router { + private readonly opts: Config + readonly defaultRoute: DefaultRoute | null + readonly onBadUrl: PathRoute | null + readonly onMaxParamLength: PathRoute | null + readonly buildPrettyMeta: (route: Route) => PrettyMeta + readonly querystringParser: QuerystringParser + readonly caseSensitive: boolean + readonly ignoreTrailingSlash: boolean + readonly ignoreDuplicateSlashes: boolean + readonly maxParamLength: number + readonly allowUnsafeRegex: boolean + readonly useSemicolonDelimiter: boolean + readonly constrainer: Constrainer + routes: Route[] = [] + trees = new Map>() + private treeGET: StaticNode | null = null + + constructor(opts: Config = {}) { + this.opts = opts + this.defaultRoute = opts.defaultRoute ?? null + this.onBadUrl = opts.onBadUrl ?? null + this.buildPrettyMeta = opts.buildPrettyMeta ?? defaultBuildPrettyMeta + this.querystringParser = opts.querystringParser ?? defaultQuerystringParser + this.caseSensitive = opts.caseSensitive === undefined ? true : opts.caseSensitive + this.ignoreTrailingSlash = opts.ignoreTrailingSlash || false + this.ignoreDuplicateSlashes = opts.ignoreDuplicateSlashes || false + this.maxParamLength = opts.maxParamLength || 100 + this.onMaxParamLength = opts.onMaxParamLength ?? null + this.allowUnsafeRegex = opts.allowUnsafeRegex || false + this.constrainer = new Constrainer(opts.constraints) + this.useSemicolonDelimiter = opts.useSemicolonDelimiter || false + } + + on(method: HTTPMethod | HTTPMethod[], path: string, handler: Handler): void + on(method: HTTPMethod | HTTPMethod[], path: string, opts: RouteOptions, handler: Handler): void + on(method: HTTPMethod | HTTPMethod[], path: string, handler: Handler, store: Store): void + on(method: HTTPMethod | HTTPMethod[], path: string, opts: RouteOptions, handler: Handler, store: Store): void + on(method: HTTPMethod | HTTPMethod[], path: string, optsOrHandler: RouteOptions | Handler, handlerOrStore?: Handler | Store, store?: Store): void { + if (typeof optsOrHandler === 'function') { + this.addRoute(method, path, {}, optsOrHandler, handlerOrStore === undefined ? null : (handlerOrStore as Store)) + } else { + assert(typeof handlerOrStore === 'function', 'Handler should be a function') + this.addRoute(method, path, optsOrHandler, handlerOrStore as Handler, store === undefined ? null : store) + } + } + + private addRoute(method: HTTPMethod | HTTPMethod[], path: string, opts: RouteOptions, handler: Handler, store: Store | null): void { + assert(typeof path === 'string', 'Path should be a string') + assert(path.length > 0, 'The path could not be empty') + assert(path[0] === '/' || path[0] === '*', 'The first character of a path should be `/` or `*`') + assert(typeof handler === 'function', 'Handler should be a function') + + const optional = splitOptionalParam(path) + if (optional !== null) { + this.addRoute(method, optional.pathFull, opts, handler, store) + this.addRoute(method, optional.pathOptional, opts, handler, store) + return + } + + if (this.ignoreDuplicateSlashes) path = removeDuplicateSlashes(path) + if (this.ignoreTrailingSlash) path = trimLastSlash(path) + + const methods: HTTPMethod[] = Array.isArray(method) ? method : [method] + for (const one of methods) { + assert(typeof one === 'string', 'Method should be a string') + assert(httpMethods.includes(one), `Method '${one}' is not an http method.`) + this.registerRoute(one, path, opts, handler, store) + } + } + + private registerRoute(method: string, path: string, opts: RouteOptions, handler: Handler, store: Store | null): void { + let constraints: Constraints = {} + if (opts.constraints !== undefined) { + assert(typeof opts.constraints === 'object' && opts.constraints !== null, 'Constraints should be an object') + if (Object.keys(opts.constraints).length !== 0) constraints = opts.constraints + } + + this.constrainer.validateConstraints(constraints) + this.constrainer.noteUsage(Object.keys(constraints)) + + let tree = this.trees.get(method) + if (tree === undefined) { + tree = new StaticNode('/') + this.trees.set(method, tree) + } + + let pattern = path + if (pattern === '*' && tree.prefix.length !== 0) { + const currentRoot = tree + tree = new StaticNode('') + tree.setStaticChild('/', currentRoot) + this.trees.set(method, tree) + } + + let currentNode: ParentNode = tree + if (method === 'GET') this.treeGET = tree + let parentNodePathIndex = tree.prefix.length + + const params: string[] = [] + for (let i = 0; i <= pattern.length; i++) { + if (pattern.charCodeAt(i) === 58 && pattern.charCodeAt(i + 1) === 58) { + i++ + continue + } + + const isParametricNode = pattern.charCodeAt(i) === 58 && pattern.charCodeAt(i + 1) !== 58 + const isWildcardNode = pattern.charCodeAt(i) === 42 + + if (isParametricNode || isWildcardNode || (i === pattern.length && i !== parentNodePathIndex)) { + let staticNodePath = pattern.slice(parentNodePathIndex, i) + if (!this.caseSensitive) staticNodePath = staticNodePath.toLowerCase() + staticNodePath = staticNodePath.replaceAll('::', ':') + staticNodePath = staticNodePath.replaceAll('%', '%25') + currentNode = currentNode.createStaticChild(staticNodePath) + } + + if (isParametricNode) { + let isRegexNode = false + let isParamSafe = true + let backtrack = '' + const regexps: string[] = [] + let nodePatternParts = '' + + let lastParamStartIndex = i + 1 + for (let j = lastParamStartIndex; ; j++) { + const charCode = pattern.charCodeAt(j) + + const isRegexParam = charCode === 40 + const isStaticPart = charCode === 45 || charCode === 46 + const isEndOfNode = charCode === 47 || j === pattern.length + + if (isRegexParam || isStaticPart || isEndOfNode) { + const paramName = pattern.slice(lastParamStartIndex, j) + params.push(paramName) + + isRegexNode = isRegexNode || isRegexParam || isStaticPart + + if (isRegexParam) { + const endOfRegexIndex = getClosingParenthensePosition(pattern, j) + const regexString = pattern.slice(j, endOfRegexIndex + 1) + + if (!this.allowUnsafeRegex) assert(isRegexSafe(new RegExp(regexString)), `The regex '${regexString}' is not safe!`) + + regexps.push(trimRegExpStartAndEnd(regexString)) + + j = endOfRegexIndex + 1 + isParamSafe = true + } else { + regexps.push(isParamSafe ? '(.*?)' : `(${backtrack}|(?:(?!${backtrack}).)*)`) + isParamSafe = false + } + + const staticPartStartIndex = j + for (; j < pattern.length; j++) { + const staticCharCode = pattern.charCodeAt(j) + if (staticCharCode === 47) break + if (staticCharCode === 58) { + const nextCharCode = pattern.charCodeAt(j + 1) + if (nextCharCode === 58) j++ + else break + } + } + + let staticPart = pattern.slice(staticPartStartIndex, j) + if (staticPart) { + staticPart = staticPart.replaceAll('::', ':') + staticPart = staticPart.replaceAll('%', '%25') + backtrack = escapeRegExp(staticPart) + regexps.push(backtrack) + } + + lastParamStartIndex = j + 1 + nodePatternParts += '()' + staticPart + + if (isEndOfNode || pattern.charCodeAt(j) === 47 || j === pattern.length) { + const nodePattern = isRegexNode ? nodePatternParts : staticPart + const nodePath = pattern.slice(i, j) + + pattern = pattern.slice(0, i + 1) + nodePattern + pattern.slice(j) + i += nodePattern.length + + const regex = isRegexNode ? new RegExp('^' + regexps.join('') + '$') : null + if (!(currentNode instanceof StaticNode)) throw new AssertionError('Parametric node must follow a static node') + currentNode = currentNode.createParametricChild(regex, staticPart || null, nodePath) + parentNodePathIndex = i + 1 + break + } + } + } + } else if (isWildcardNode) { + params.push('*') + if (!(currentNode instanceof StaticNode)) throw new AssertionError('Wildcard node must follow a static node') + const wildcard: WildcardNode = currentNode.createWildcardChild() + parentNodePathIndex = i + 1 + + if (i !== pattern.length - 1) throw new Error('Wildcard must be the last character in the route') + + if (!this.caseSensitive) pattern = pattern.toLowerCase() + if (pattern === '*') pattern = '/*' + this.pushRoute(method, path, pattern, params, opts, constraints, handler, store, wildcard) + return + } + } + + if (!this.caseSensitive) pattern = pattern.toLowerCase() + if (pattern === '*') pattern = '/*' + this.pushRoute(method, path, pattern, params, opts, constraints, handler, store, currentNode) + } + + private pushRoute( + method: string, + path: string, + pattern: string, + params: string[], + opts: RouteOptions, + constraints: Constraints, + handler: Handler, + store: Store | null, + node: Node + ): void { + for (const existRoute of this.routes) { + const routeConstraints = existRoute.opts.constraints ?? {} + if (existRoute.method === method && existRoute.pattern === pattern && constraintsEqual(routeConstraints, constraints)) { + throw new Error(`Method '${method}' already declared for route '${pattern}' with constraints '${serializeConstraintsJson(constraints)}'`) + } + } + const route: Route = { method, path, pattern, params, opts, handler, store } + this.routes.push(route) + node.addRoute(route, this.constrainer) + } + + hasRoute(method: HTTPMethod, path: string, constraints?: Constraints): boolean { + return this.findRoute(method, path, constraints) !== null + } + + findRoute(method: HTTPMethod, path: string, constraints: Constraints = {}): FindRouteResult | null { + const tree = this.trees.get(method) + if (tree === undefined) return null + + let pattern = path + let currentNode: ParentNode | null = tree + let parentNodePathIndex = tree.prefix.length + + for (let i = 0; i <= pattern.length; i++) { + if (pattern.charCodeAt(i) === 58 && pattern.charCodeAt(i + 1) === 58) { + i++ + continue + } + + const isParametricNode = pattern.charCodeAt(i) === 58 && pattern.charCodeAt(i + 1) !== 58 + const isWildcardNode = pattern.charCodeAt(i) === 42 + + if (isParametricNode || isWildcardNode || (i === pattern.length && i !== parentNodePathIndex)) { + let staticNodePath = pattern.slice(parentNodePathIndex, i) + if (!this.caseSensitive) staticNodePath = staticNodePath.toLowerCase() + staticNodePath = staticNodePath.replaceAll('::', ':') + staticNodePath = staticNodePath.replaceAll('%', '%25') + currentNode = currentNode.getStaticChild(staticNodePath) + if (currentNode === null) return null + } + + if (isParametricNode) { + let isRegexNode = false + let isParamSafe = true + let backtrack = '' + const regexps: string[] = [] + let nodePatternParts = '' + + let lastParamStartIndex = i + 1 + for (let j = lastParamStartIndex; ; j++) { + const charCode = pattern.charCodeAt(j) + + const isRegexParam = charCode === 40 + const isStaticPart = charCode === 45 || charCode === 46 + const isEndOfNode = charCode === 47 || j === pattern.length + + if (isRegexParam || isStaticPart || isEndOfNode) { + isRegexNode = isRegexNode || isRegexParam || isStaticPart + + if (isRegexParam) { + const endOfRegexIndex = getClosingParenthensePosition(pattern, j) + const regexString = pattern.slice(j, endOfRegexIndex + 1) + + if (!this.allowUnsafeRegex) assert(isRegexSafe(new RegExp(regexString)), `The regex '${regexString}' is not safe!`) + + regexps.push(trimRegExpStartAndEnd(regexString)) + + j = endOfRegexIndex + 1 + isParamSafe = true + } else { + regexps.push(isParamSafe ? '(.*?)' : `(${backtrack}|(?:(?!${backtrack}).)*)`) + isParamSafe = false + } + + const staticPartStartIndex = j + for (; j < pattern.length; j++) { + const staticCharCode = pattern.charCodeAt(j) + if (staticCharCode === 47) break + if (staticCharCode === 58) { + const nextCharCode = pattern.charCodeAt(j + 1) + if (nextCharCode === 58) j++ + else break + } + } + + let staticPart = pattern.slice(staticPartStartIndex, j) + if (staticPart) { + staticPart = staticPart.replaceAll('::', ':') + staticPart = staticPart.replaceAll('%', '%25') + backtrack = escapeRegExp(staticPart) + regexps.push(backtrack) + } + + lastParamStartIndex = j + 1 + nodePatternParts += '()' + staticPart + + if (isEndOfNode || pattern.charCodeAt(j) === 47 || j === pattern.length) { + const nodePattern = isRegexNode ? nodePatternParts : staticPart + + pattern = pattern.slice(0, i + 1) + nodePattern + pattern.slice(j) + i += nodePattern.length + + const regex = isRegexNode ? new RegExp('^' + regexps.join('') + '$') : null + if (!(currentNode instanceof StaticNode)) return null + currentNode = currentNode.getParametricChild(regex) + if (currentNode === null) return null + parentNodePathIndex = i + 1 + break + } + } + } + } else if (isWildcardNode) { + if (!(currentNode instanceof StaticNode) || currentNode.getWildcardChild() === null) return null + parentNodePathIndex = i + 1 + if (i !== pattern.length - 1) throw new Error('Wildcard must be the last character in the route') + break + } + } + + if (!this.caseSensitive) pattern = pattern.toLowerCase() + + for (const existRoute of this.routes) { + const routeConstraints = existRoute.opts.constraints ?? {} + if (existRoute.method === method && existRoute.pattern === pattern && constraintsEqual(routeConstraints, constraints)) { + return { handler: existRoute.handler, store: existRoute.store, params: existRoute.params } + } + } + return null + } + + hasConstraintStrategy(strategyName: string): boolean { + return this.constrainer.hasConstraintStrategy(strategyName) + } + + addConstraintStrategy(strategy: ConstraintStrategy): void { + this.constrainer.addConstraintStrategy(strategy) + this.rebuild(this.routes) + } + + reset(): void { + this.trees = new Map>() + this.treeGET = null + this.routes = [] + } + + off(method: HTTPMethod | HTTPMethod[], path: string, constraints?: Constraints): void { + assert(typeof path === 'string', 'Path should be a string') + assert(path.length > 0, 'The path could not be empty') + assert(path[0] === '/' || path[0] === '*', 'The first character of a path should be `/` or `*`') + assert( + constraints === undefined || (typeof constraints === 'object' && !Array.isArray(constraints) && constraints !== null), + 'Constraints should be an object or undefined.' + ) + + const optional = splitOptionalParam(path) + if (optional !== null) { + this.off(method, optional.pathFull, constraints) + this.off(method, optional.pathOptional, constraints) + return + } + + if (this.ignoreDuplicateSlashes) path = removeDuplicateSlashes(path) + if (this.ignoreTrailingSlash) path = trimLastSlash(path) + + const methods: HTTPMethod[] = Array.isArray(method) ? method : [method] + for (const one of methods) this.offOne(one, path, constraints) + } + + private offOne(method: HTTPMethod, path: string, constraints: Constraints | undefined): void { + assert(typeof method === 'string', 'Method should be a string') + assert(httpMethods.includes(method), `Method '${method}' is not an http method.`) + + const newRoutes = this.routes.filter((route) => { + const differs = method !== route.method || path !== route.path + if (constraints === undefined) return differs + return differs || !constraintsEqual(constraints, route.opts.constraints ?? {}) + }) + this.rebuild(newRoutes) + } + + lookup(req: Req, res: Res, done?: Done): void { + if (done === undefined) { + const constraints = this.constrainer.deriveConstraints(req) + const handle = this.find(req.method ?? '', req.url ?? '', constraints) + this.callHandler(handle, req, res) + return + } + + this.constrainer.deriveConstraintsAsync(req, (err: Error | null, constraints?: DerivedConstraints): void => { + if (err !== null) { + done(err) + return + } + try { + const handle = this.find(req.method ?? '', req.url ?? '', constraints) + this.callHandler(handle, req, res) + done(null) + } catch (error) { + done(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + callHandler(handle: FindResult | null, req: Req, res: Res): void { + if (handle === null) { + this.runDefaultRoute(req, res) + return + } + handle.handler(req, res, handle.params, handle.store, handle.searchParams) + } + + find(method: string, path: string, derivedConstraints?: DerivedConstraints): FindResult | null { + const tree: StaticNode | null = method === 'GET' ? this.treeGET : (this.trees.get(method) ?? null) + if (tree === null) return null + let currentNode: TreeNode = tree + + if (path.charCodeAt(0) !== 47) { + const absolutePath = getPathFromAbsoluteUrl(path) + if (absolutePath === null) return this.badUrl(path) + path = absolutePath + } + + if (this.ignoreDuplicateSlashes) path = removeDuplicateSlashes(path) + + let querystring: string + let shouldDecodeParam: boolean + try { + const sanitizedUrl = + path.length >= MIN_NATIVE_SCAN_LENGTH + ? safeDecodeURINativeScan(path, this.useSemicolonDelimiter) + : safeDecodeURICharScan(path, this.useSemicolonDelimiter, 1) + path = sanitizedUrl.path + querystring = sanitizedUrl.querystring + shouldDecodeParam = sanitizedUrl.shouldDecodeParam + } catch { + return this.badUrl(path) + } + + if (this.ignoreTrailingSlash) path = trimLastSlash(path) + + const originPath = path + if (this.caseSensitive === false) path = path.toLowerCase() + + const maxParamLength = this.maxParamLength + + let pathIndex = tree.prefix.length + const params: string[] = [] + const pathLen = path.length + + const brothersNodesStack: BrotherState[] = [] + let maxParamLengthExceeded = false + + while (true) { + if (pathIndex === pathLen && currentNode.isLeafNode && currentNode.handlerStorage !== null) { + const handle = currentNode.handlerStorage.getMatchingHandler(derivedConstraints) + if (handle !== null) { + return { + handler: handle.handler, + store: handle.store, + params: createParamsObject(handle.params, params), + searchParams: this.querystringParser(querystring) + } + } + } + + let node: TreeNode | null = currentNode.getNextNode(path, pathIndex, brothersNodesStack, params.length) + + if (node === null) { + const brotherNodeState = brothersNodesStack.pop() + if (brotherNodeState === undefined) { + if (maxParamLengthExceeded && this.onMaxParamLength) return this.maxParamLengthResult(originPath) + return null + } + pathIndex = brotherNodeState.brotherPathIndex + params.splice(brotherNodeState.paramsCount) + node = brotherNodeState.brotherNode + } + + currentNode = node + + while (true) { + if (currentNode instanceof StaticNode) { + pathIndex += currentNode.prefix.length + break + } + + if (currentNode instanceof WildcardNode) { + let param = originPath.slice(pathIndex) + if (shouldDecodeParam) param = safeDecodeURIComponent(param) + params.push(param) + pathIndex = pathLen + break + } + + let paramEndIndex = originPath.indexOf('/', pathIndex) + if (paramEndIndex === -1) paramEndIndex = pathLen + + let param = originPath.slice(pathIndex, paramEndIndex) + if (shouldDecodeParam) param = safeDecodeURIComponent(param) + + if (currentNode.regex !== null) { + const matchedParameters = currentNode.regex.exec(param) + if (matchedParameters === null) { + const brotherNodeState = brothersNodesStack.pop() + if (brotherNodeState === undefined) { + if (maxParamLengthExceeded && this.onMaxParamLength) return this.maxParamLengthResult(originPath) + return null + } + pathIndex = brotherNodeState.brotherPathIndex + params.splice(brotherNodeState.paramsCount) + currentNode = brotherNodeState.brotherNode + continue + } + + let regexMaxParamLengthExceeded = false + for (let i = 1; i < matchedParameters.length; i++) { + const matchedParam = matchedParameters[i] ?? '' + if (matchedParam.length > maxParamLength) { + regexMaxParamLengthExceeded = true + break + } + } + + if (regexMaxParamLengthExceeded) { + maxParamLengthExceeded = true + const brotherNodeState = brothersNodesStack.pop() + if (brotherNodeState === undefined) { + if (this.onMaxParamLength) return this.maxParamLengthResult(originPath) + return null + } + pathIndex = brotherNodeState.brotherPathIndex + params.splice(brotherNodeState.paramsCount) + currentNode = brotherNodeState.brotherNode + continue + } + + for (let i = 1; i < matchedParameters.length; i++) params.push(matchedParameters[i] ?? '') + } else { + if (param.length > maxParamLength) { + maxParamLengthExceeded = true + const brotherNodeState = brothersNodesStack.pop() + if (brotherNodeState === undefined) { + if (this.onMaxParamLength) return this.maxParamLengthResult(originPath) + return null + } + pathIndex = brotherNodeState.brotherPathIndex + params.splice(brotherNodeState.paramsCount) + currentNode = brotherNodeState.brotherNode + continue + } + params.push(param) + } + + pathIndex = paramEndIndex + break + } + } + } + + private rebuild(routes: Route[]): void { + this.reset() + for (const route of routes) this.registerRoute(route.method, route.path, route.opts, route.handler, route.store) + } + + private runDefaultRoute(req: Req, res: Res): void { + if (this.defaultRoute !== null) { + this.defaultRoute(req, res) + } else { + res.statusCode = 404 + res.end() + } + } + + private badUrl(path: string): FindResult | null { + if (this.onBadUrl === null) return null + const onBadUrl = this.onBadUrl + return { handler: (req: Req, res: Res): void => onBadUrl(path, req, res), params: {}, store: null, searchParams: {} } + } + + private maxParamLengthResult(path: string): FindResult | null { + if (this.onMaxParamLength === null) return null + const onMaxParamLength = this.onMaxParamLength + return { handler: (req: Req, res: Res): void => onMaxParamLength(path, req, res), params: {}, store: null, searchParams: {} } + } + + prettyPrint(options: PrettyPrintOptions = {}): string { + const method = options.method + let tree: StaticNode | undefined + let merged = false + if (method === undefined) { + const constraints: ConstraintStrategies = {} + for (const [name, registered] of this.constrainer.strategies) if (registered.isCustom) constraints[name] = registered.strategy + constraints[httpMethodStrategy.name] = httpMethodStrategy + + const mergedRouter = new Router({ ...this.opts, constraints }) + for (const route of this.routes) { + const routeConstraints: Constraints = { ...route.opts.constraints } + routeConstraints[httpMethodStrategy.name] = route.method + mergedRouter.registerRoute('MERGED', route.path, { constraints: routeConstraints }, route.handler, route.store) + } + tree = mergedRouter.trees.get('MERGED') + merged = true + } else { + tree = this.trees.get(method) + } + + if (tree === undefined) return '(empty tree)' + const root: PrintTree = { children: new Map(), data: '' } + this.buildObjectTree(tree, root, tree.prefix, options, merged) + return printObjectTree(root) + } + + private routeMetaData(route: Route, options: PrettyPrintOptions): { [key: string]: string } { + const filtered: { [key: string]: string } = {} + if (!options.includeMeta) return filtered + const metaDataObject = this.buildPrettyMeta(route) + const includeMetaKeys = Array.isArray(options.includeMeta) ? options.includeMeta : Object.keys(metaDataObject) + for (const metaKey of includeMetaKeys) { + if (!Object.hasOwn(metaDataObject, metaKey)) continue + const metaValue = metaDataObject[metaKey] + if (metaValue !== undefined && metaValue !== null) filtered[metaKey] = stringifyMeta(metaValue) + } + return filtered + } + + private serializeNode(node: Node, prefix: string, options: PrettyPrintOptions, merged: boolean): string { + let routes: PrintableRoute[] = (node.routes ?? []).map((route) => { + const routeConstraints = route.opts.constraints ?? {} + const constraints: Constraints = {} + let method = route.method + for (const key of Object.keys(routeConstraints)) { + const value = routeConstraints[key] + if (value === undefined) continue + if (merged && key === httpMethodStrategy.name) method = String(value) + else constraints[key] = value + } + return { method, constraints, metaData: this.routeMetaData(route, options) } + }) + if (merged) routes = mergeSimilarRoutes(routes) + return routes.map(serializeRoute).join(`\n${prefix}`) + } + + private buildObjectTree(node: TreeNode, tree: PrintTree, prefix: string, options: PrettyPrintOptions, merged: boolean): void { + if (node.isLeafNode || options.commonPrefix !== false) { + prefix = prefix || '(empty root node)' + const child: PrintTree = { children: new Map(), data: '' } + tree.children.set(prefix, child) + tree = child + if (node.isLeafNode) tree.data = this.serializeNode(node, prefix, options, merged) + prefix = '' + } + + if (node instanceof WildcardNode) return + for (const child of node.staticChildrenNodes) this.buildObjectTree(child, tree, prefix + child.prefix, options, merged) + if (node instanceof StaticNode) { + for (const child of node.parametricChildren) { + const childPrefix = [...child.nodePaths].join('|') + this.buildObjectTree(child, tree, prefix + childPrefix, options, merged) + } + if (node.wildcardChild) this.buildObjectTree(node.wildcardChild, tree, '*', options, merged) + } + } + + all(path: string, handler: Handler, store?: Store): void { + this.shorthand([...httpMethods], path, handler, store) + } + + private shorthand(method: HTTPMethod | HTTPMethod[], path: string, handler: Handler, store: Store | undefined): void { + if (store === undefined) this.on(method, path, handler) + else this.on(method, path, handler, store) + } + + acl(path: string, handler: Handler, store?: Store): void { + this.shorthand('ACL', path, handler, store) + } + bind(path: string, handler: Handler, store?: Store): void { + this.shorthand('BIND', path, handler, store) + } + checkout(path: string, handler: Handler, store?: Store): void { + this.shorthand('CHECKOUT', path, handler, store) + } + connect(path: string, handler: Handler, store?: Store): void { + this.shorthand('CONNECT', path, handler, store) + } + copy(path: string, handler: Handler, store?: Store): void { + this.shorthand('COPY', path, handler, store) + } + delete(path: string, handler: Handler, store?: Store): void { + this.shorthand('DELETE', path, handler, store) + } + get(path: string, handler: Handler, store?: Store): void { + this.shorthand('GET', path, handler, store) + } + head(path: string, handler: Handler, store?: Store): void { + this.shorthand('HEAD', path, handler, store) + } + link(path: string, handler: Handler, store?: Store): void { + this.shorthand('LINK', path, handler, store) + } + lock(path: string, handler: Handler, store?: Store): void { + this.shorthand('LOCK', path, handler, store) + } + 'm-search'(path: string, handler: Handler, store?: Store): void { + this.shorthand('M-SEARCH', path, handler, store) + } + merge(path: string, handler: Handler, store?: Store): void { + this.shorthand('MERGE', path, handler, store) + } + mkactivity(path: string, handler: Handler, store?: Store): void { + this.shorthand('MKACTIVITY', path, handler, store) + } + mkcalendar(path: string, handler: Handler, store?: Store): void { + this.shorthand('MKCALENDAR', path, handler, store) + } + mkcol(path: string, handler: Handler, store?: Store): void { + this.shorthand('MKCOL', path, handler, store) + } + move(path: string, handler: Handler, store?: Store): void { + this.shorthand('MOVE', path, handler, store) + } + notify(path: string, handler: Handler, store?: Store): void { + this.shorthand('NOTIFY', path, handler, store) + } + options(path: string, handler: Handler, store?: Store): void { + this.shorthand('OPTIONS', path, handler, store) + } + patch(path: string, handler: Handler, store?: Store): void { + this.shorthand('PATCH', path, handler, store) + } + post(path: string, handler: Handler, store?: Store): void { + this.shorthand('POST', path, handler, store) + } + propfind(path: string, handler: Handler, store?: Store): void { + this.shorthand('PROPFIND', path, handler, store) + } + proppatch(path: string, handler: Handler, store?: Store): void { + this.shorthand('PROPPATCH', path, handler, store) + } + purge(path: string, handler: Handler, store?: Store): void { + this.shorthand('PURGE', path, handler, store) + } + put(path: string, handler: Handler, store?: Store): void { + this.shorthand('PUT', path, handler, store) + } + query(path: string, handler: Handler, store?: Store): void { + this.shorthand('QUERY', path, handler, store) + } + rebind(path: string, handler: Handler, store?: Store): void { + this.shorthand('REBIND', path, handler, store) + } + report(path: string, handler: Handler, store?: Store): void { + this.shorthand('REPORT', path, handler, store) + } + search(path: string, handler: Handler, store?: Store): void { + this.shorthand('SEARCH', path, handler, store) + } + source(path: string, handler: Handler, store?: Store): void { + this.shorthand('SOURCE', path, handler, store) + } + subscribe(path: string, handler: Handler, store?: Store): void { + this.shorthand('SUBSCRIBE', path, handler, store) + } + trace(path: string, handler: Handler, store?: Store): void { + this.shorthand('TRACE', path, handler, store) + } + unbind(path: string, handler: Handler, store?: Store): void { + this.shorthand('UNBIND', path, handler, store) + } + unlink(path: string, handler: Handler, store?: Store): void { + this.shorthand('UNLINK', path, handler, store) + } + unlock(path: string, handler: Handler, store?: Store): void { + this.shorthand('UNLOCK', path, handler, store) + } + unsubscribe(path: string, handler: Handler, store?: Store): void { + this.shorthand('UNSUBSCRIBE', path, handler, store) + } +} + +const serializeConstraintsJson = (constraints: Constraints): string => { + const parts: string[] = [] + for (const key of Object.keys(constraints)) { + const value = constraints[key] + parts.push(`${JSON.stringify(key)}:${value instanceof RegExp ? '{}' : JSON.stringify(value)}`) + } + return `{${parts.join(',')}}` +} + +function FindMyWay(config?: Config): Router { + return new Router(config) +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +namespace FindMyWay { + export type Instance = Router + export type HTTPMethod = HTTPMethodName + export type Handler = RouteHandler + export type FindResult = FindResultOf + export type Config = ConfigOf + export type ConstraintStrategy = ConstraintStrategyOf + export type RouteOptions = RouteOptionsOf +} + +type HTTPMethodName = HTTPMethod +type RouteHandler = Handler +type FindResultOf = FindResult +type ConfigOf = Config +type ConstraintStrategyOf = ConstraintStrategy +type RouteOptionsOf = RouteOptions + +export default FindMyWay diff --git a/runtime/node/nest/shims/find-my-way/lib/url-sanitizer.ts b/runtime/node/nest/shims/find-my-way/lib/url-sanitizer.ts new file mode 100644 index 0000000..43afe66 --- /dev/null +++ b/runtime/node/nest/shims/find-my-way/lib/url-sanitizer.ts @@ -0,0 +1,125 @@ + +const DECODE_COMPONENT_TABLE = new Uint8Array(768) +const DECODE_COMPONENT_PAIRS: [string, string][] = [ + ['%23', '#'], ['%24', '$'], ['%25', '%'], ['%26', '&'], + ['%2B', '+'], ['%2b', '+'], ['%2C', ','], ['%2c', ','], ['%2F', '/'], ['%2f', '/'], + ['%3A', ':'], ['%3a', ':'], ['%3B', ';'], ['%3b', ';'], + ['%3D', '='], ['%3d', '='], ['%3F', '?'], ['%3f', '?'], + ['%40', '@'] +] +for (const pair of DECODE_COMPONENT_PAIRS) { + const encoded = pair[0] + const char = pair[1] + DECODE_COMPONENT_TABLE[((encoded.charCodeAt(1) - 50) << 8) | encoded.charCodeAt(2)] = char.charCodeAt(0) +} + +function decodeComponentCharCode(highCharCode: number, lowCharCode: number): number { + if (highCharCode < 50 || highCharCode > 52 || lowCharCode > 255) return 0 + return DECODE_COMPONENT_TABLE[((highCharCode - 50) << 8) | lowCharCode] +} + +function decodeComponentChar(highCharCode: number, lowCharCode: number): string | null { + const charCode = decodeComponentCharCode(highCharCode, lowCharCode) + if (charCode === 0) return null + return String.fromCharCode(charCode) +} + +export const MIN_NATIVE_SCAN_LENGTH = 12 + +export interface SanitizedUrl { + path: string + querystring: string + shouldDecodeParam: boolean +} + +export function safeDecodeURI(path: string, useSemicolonDelimiter: boolean): SanitizedUrl { + if (path.length >= MIN_NATIVE_SCAN_LENGTH) return safeDecodeURINativeScan(path, useSemicolonDelimiter) + return safeDecodeURICharScan(path, useSemicolonDelimiter, 1) +} + +export function safeDecodeURINativeScan(path: string, useSemicolonDelimiter: boolean): SanitizedUrl { + const percentIndex = path.indexOf('%', 1) + + let delimIndex = path.indexOf('?', 1) + const hashIndex = path.indexOf('#', 1) + if (hashIndex !== -1 && (delimIndex === -1 || hashIndex < delimIndex)) delimIndex = hashIndex + if (useSemicolonDelimiter) { + const semicolonIndex = path.indexOf(';', 1) + if (semicolonIndex !== -1 && (delimIndex === -1 || semicolonIndex < delimIndex)) delimIndex = semicolonIndex + } + + if (percentIndex === -1 || (delimIndex !== -1 && percentIndex > delimIndex)) { + if (delimIndex === -1) return { path, querystring: '', shouldDecodeParam: false } + return { path: path.slice(0, delimIndex), querystring: path.slice(delimIndex + 1), shouldDecodeParam: false } + } + + return safeDecodeURIPercentScan(path, useSemicolonDelimiter, percentIndex) +} + +export function safeDecodeURICharScan(path: string, useSemicolonDelimiter: boolean, startIndex: number): SanitizedUrl { + for (let i = startIndex; i < path.length; i++) { + const charCode = path.charCodeAt(i) + if (charCode === 37) { + return safeDecodeURIPercentScan(path, useSemicolonDelimiter, i) + } else if (charCode === 63 || charCode === 35 || (charCode === 59 && useSemicolonDelimiter)) { + return { path: path.slice(0, i), querystring: path.slice(i + 1), shouldDecodeParam: false } + } + } + return { path, querystring: '', shouldDecodeParam: false } +} + +function reencodePercentChar(path: string, index: number): string { + return path.slice(0, index + 1) + '25' + path.slice(index + 1) +} + +function safeDecodeURIPercentScan(path: string, useSemicolonDelimiter: boolean, startIndex: number): SanitizedUrl { + let shouldDecode = false + let shouldDecodeParam = false + let querystring = '' + + for (let i = startIndex; i < path.length; i++) { + const charCode = path.charCodeAt(i) + if (charCode === 37) { + const componentCharCode = decodeComponentCharCode(path.charCodeAt(i + 1), path.charCodeAt(i + 2)) + if (componentCharCode === 0) { + shouldDecode = true + } else { + shouldDecodeParam = true + if (componentCharCode === 37) { + shouldDecode = true + path = reencodePercentChar(path, i) + i += 2 + } + i += 2 + } + } else if (charCode === 63 || charCode === 35 || (charCode === 59 && useSemicolonDelimiter)) { + querystring = path.slice(i + 1) + path = path.slice(0, i) + break + } + } + const decodedPath = shouldDecode ? decodeURI(path) : path + return { path: decodedPath, querystring, shouldDecodeParam } +} + +export function safeDecodeURIComponent(uriComponent: string): string { + const startIndex = uriComponent.indexOf('%') + if (startIndex === -1) return uriComponent + + let decoded = '' + let lastIndex = startIndex + + for (let i = startIndex; i < uriComponent.length; i++) { + if (uriComponent.charCodeAt(i) === 37) { + const highCharCode = uriComponent.charCodeAt(i + 1) + const lowCharCode = uriComponent.charCodeAt(i + 2) + const decodedChar = decodeComponentChar(highCharCode, lowCharCode) + decoded += uriComponent.slice(lastIndex, i) + decodedChar + lastIndex = i + 3 + } + } + return uriComponent.slice(0, startIndex) + decoded + uriComponent.slice(lastIndex) +} + +const urlSanitizer = { safeDecodeURI, safeDecodeURIComponent, safeDecodeURINativeScan, safeDecodeURICharScan, MIN_NATIVE_SCAN_LENGTH } +export default urlSanitizer diff --git a/runtime/node/nest/shims/fresh/index.ts b/runtime/node/nest/shims/fresh/index.ts new file mode 100644 index 0000000..703dd67 --- /dev/null +++ b/runtime/node/nest/shims/fresh/index.ts @@ -0,0 +1,72 @@ + +export interface FreshRequestHeaders { + 'if-modified-since'?: string | undefined + 'if-none-match'?: string | undefined + 'cache-control'?: string | undefined +} + +export interface FreshResponseHeaders { + etag?: string | undefined + 'last-modified'?: string | undefined +} + +const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/ + +export default function fresh(reqHeaders: FreshRequestHeaders, resHeaders: FreshResponseHeaders): boolean { + const modifiedSince = reqHeaders['if-modified-since'] + const noneMatch = reqHeaders['if-none-match'] + + if (!modifiedSince && !noneMatch) return false + + const cacheControl = reqHeaders['cache-control'] + if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) return false + + if (noneMatch) { + if (noneMatch === '*') return true + const etag = resHeaders.etag + if (!etag) return false + const matches = parseTokenList(noneMatch) + for (let i = 0; i < matches.length; i++) { + const match = matches[i] + if (match === etag || match === 'W/' + etag || 'W/' + match === etag) return true + } + return false + } + + if (modifiedSince) { + const lastModified = resHeaders['last-modified'] + const modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince)) + if (modifiedStale) return false + } + + return true +} + +function parseHttpDate(date: string): number { + if (date === '') return NaN + return Date.parse(date) +} + +function parseTokenList(str: string): string[] { + let end = 0 + const list: string[] = [] + let start = 0 + + for (let i = 0, len = str.length; i < len; i++) { + switch (str.charCodeAt(i)) { + case 0x20: + if (start === end) start = end = i + 1 + break + case 0x2c: + list.push(str.substring(start, end)) + start = end = i + 1 + break + default: + end = i + 1 + break + } + } + + list.push(str.substring(start, end)) + return list +} diff --git a/runtime/node/nest/shims/http-errors/index.ts b/runtime/node/nest/shims/http-errors/index.ts new file mode 100644 index 0000000..0a3f626 --- /dev/null +++ b/runtime/node/nest/shims/http-errors/index.ts @@ -0,0 +1,153 @@ +import { statusMessage } from '../statuses/index' + +export interface HttpErrorHeaders { + [name: string]: string +} + +export interface HttpErrorProperties { + expose?: boolean + headers?: HttpErrorHeaders +} + +export class HttpError extends Error { + status: number + statusCode: number + expose: boolean + headers: HttpErrorHeaders | undefined + code: string | undefined + syscall: string | undefined + errno: number | undefined + path: string | undefined + original: Error | undefined + + constructor(status: number, message: string, name: string, expose: boolean) { + super(message) + this.name = name + this.status = status + this.statusCode = status + this.expose = expose + this.headers = undefined + this.code = undefined + this.syscall = undefined + this.errno = undefined + this.path = undefined + this.original = undefined + } +} + +export function isHttpError(value: unknown): value is HttpError { + return value instanceof HttpError +} + +function codeClass(status: number): number { + return Number(String(status).charAt(0) + '00') +} + +function toIdentifier(str: string): string { + return str + .split(' ') + .map((token) => token.slice(0, 1).toUpperCase() + token.slice(1)) + .join('') + .replace(/[^ _0-9a-z]/gi, '') +} + +function toClassName(name: string): string { + return name.slice(-5) === 'Error' ? name : name + 'Error' +} + +function hasConstructor(status: number): boolean { + const cls = codeClass(status) + return (cls === 400 || cls === 500) && statusMessage(status) !== undefined +} + +function constructorStatus(status: number): number | undefined { + if (hasConstructor(status)) return status + const cls = codeClass(status) + return hasConstructor(cls) ? cls : undefined +} + +type CreateErrorArgument = number | string | Error | HttpErrorProperties + +class CreateErrorArguments { + error: Error | undefined = undefined + message: string | undefined = undefined + status = 500 + properties: HttpErrorProperties = {} + + consume(argument: CreateErrorArgument | undefined, index: number): void { + if (argument === undefined) return + if (typeof argument === 'number') { + if (index !== 0) throw new TypeError('argument #' + (index + 1) + ' unsupported type number') + this.status = argument + } else if (typeof argument === 'string') { + this.message = argument + } else if (argument instanceof Error) { + this.error = argument + if (argument instanceof HttpError) this.status = argument.status || argument.statusCode || this.status + } else { + this.properties = argument + } + } +} + +function wrapError(original: Error, status: number): HttpError { + const wrapped = new HttpError(status, original.message, original.name, status < 500) + wrapped.original = original + if (original instanceof HttpError) { + wrapped.headers = original.headers + wrapped.code = original.code + wrapped.syscall = original.syscall + wrapped.errno = original.errno + wrapped.path = original.path + wrapped.original = original.original ?? original + return wrapped + } + if ('code' in original && typeof original.code === 'string') wrapped.code = original.code + if ('syscall' in original && typeof original.syscall === 'string') wrapped.syscall = original.syscall + if ('errno' in original && typeof original.errno === 'number') wrapped.errno = original.errno + if ('path' in original && typeof original.path === 'string') wrapped.path = original.path + return wrapped +} + +function createError(): HttpError +function createError(status: number, message?: string, properties?: HttpErrorProperties): HttpError +function createError(status: number, properties: HttpErrorProperties): HttpError +function createError(status: number, error: Error, properties?: HttpErrorProperties): HttpError +function createError(error: Error, properties?: HttpErrorProperties): HttpError +function createError(message: string, properties?: HttpErrorProperties): HttpError +function createError(properties: HttpErrorProperties): HttpError +function createError(first?: CreateErrorArgument, second?: CreateErrorArgument, third?: HttpErrorProperties): HttpError { + const args = new CreateErrorArguments() + args.consume(first, 0) + args.consume(second, 1) + args.consume(third, 2) + + let status = args.status + if (statusMessage(status) === undefined && (status < 400 || status >= 600)) status = 500 + + const ctor = constructorStatus(status) + let error: HttpError + if (args.error === undefined) { + let message: string + let name: string + if (ctor !== undefined) { + message = args.message !== undefined ? args.message : (statusMessage(ctor) ?? '') + name = toClassName(toIdentifier(statusMessage(ctor) ?? '')) + } else { + message = args.message || statusMessage(status) || '' + name = 'Error' + } + error = new HttpError(status, message, name, status < 500) + } else if (args.error instanceof HttpError && args.error.status === status) { + error = args.error + } else { + error = wrapError(args.error, status) + } + + const properties = args.properties + if (properties.expose !== undefined) error.expose = properties.expose + if (properties.headers !== undefined) error.headers = properties.headers + return error +} + +export default createError diff --git a/runtime/node/nest/shims/media-typer/index.ts b/runtime/node/nest/shims/media-typer/index.ts new file mode 100644 index 0000000..27b15d2 --- /dev/null +++ b/runtime/node/nest/shims/media-typer/index.ts @@ -0,0 +1,56 @@ +const SUBTYPE_NAME_REGEXP = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.-]{0,126}$/ +const TYPE_NAME_REGEXP = /^[A-Za-z0-9][A-Za-z0-9!#$&^_-]{0,126}$/ +const TYPE_REGEXP = /^ *([A-Za-z0-9][A-Za-z0-9!#$&^_-]{0,126})\/([A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}) *$/ + +export interface MediaTypeLike { + type: string + subtype: string + suffix?: string +} + +export class MediaType implements MediaTypeLike { + type: string + subtype: string + suffix: string | undefined + + constructor(type: string, subtype: string, suffix: string | undefined) { + this.type = type + this.subtype = subtype + this.suffix = suffix + } +} + +export function format(obj: MediaTypeLike): string { + if (!obj || typeof obj !== 'object') throw new TypeError('argument obj is required') + const subtype = obj.subtype + const suffix = obj.suffix + const type = obj.type + if (!type || !TYPE_NAME_REGEXP.test(type)) throw new TypeError('invalid type') + if (!subtype || !SUBTYPE_NAME_REGEXP.test(subtype)) throw new TypeError('invalid subtype') + let string = type + '/' + subtype + if (suffix) { + if (!TYPE_NAME_REGEXP.test(suffix)) throw new TypeError('invalid suffix') + string += '+' + suffix + } + return string +} + +export function test(string: string): boolean { + if (typeof string !== 'string') throw new TypeError('argument string is required to be a string') + return TYPE_REGEXP.test(string.toLowerCase()) +} + +export function parse(string: string): MediaType { + if (typeof string !== 'string') throw new TypeError('argument string is required to be a string') + const match = TYPE_REGEXP.exec(string.toLowerCase()) + if (!match) throw new TypeError('invalid media type') + const type = match[1] + let subtype = match[2] + let suffix: string | undefined + const index = subtype.lastIndexOf('+') + if (index !== -1) { + suffix = subtype.slice(index + 1) + subtype = subtype.slice(0, index) + } + return new MediaType(type, subtype, suffix) +} diff --git a/runtime/node/nest/shims/mime-db/db.generated.ts b/runtime/node/nest/shims/mime-db/db.generated.ts new file mode 100644 index 0000000..b8c50af --- /dev/null +++ b/runtime/node/nest/shims/mime-db/db.generated.ts @@ -0,0 +1,2317 @@ +// Generated by tools/nest/generate-mime-db.mjs from mime-db 1.54.0, with +// mime-types 3.0.2's extension tie-break (mimeScore). DO NOT EDIT; +// re-run the generator (`npm run generate` in tools/nest) after changing the pin. +// +// Only what the three callers in the vendored platform-node sources can ask +// for: NodeResponse.type(ext), type-is' short-name lookup and send's +// Content-Type by file extension. Types without extensions are not listed +// because no lookup can reach them. + +export const MIME_DB_VERSION = '1.54.0' + +/** Extension (without the dot, lower case) to its preferred media type: mime-types' `types`. */ +export const extensionTypes: { [extension: string]: string } = { + '123': 'application/vnd.lotus-1-2-3', + '1km': 'application/vnd.1000minds.decision-model+xml', + '210': 'model/step', + '3dml': 'text/vnd.in3d.3dml', + '3ds': 'image/x-3ds', + '3g2': 'video/3gpp2', + '3gp': 'video/3gpp', + '3gpp': 'video/3gpp', + '3mf': 'model/3mf', + '7z': 'application/x-7z-compressed', + 'aab': 'application/x-authorware-bin', + 'aac': 'audio/aac', + 'aam': 'application/x-authorware-map', + 'aas': 'application/x-authorware-seg', + 'abw': 'application/x-abiword', + 'ac': 'application/pkix-attr-cert', + 'acc': 'application/vnd.americandynamics.acc', + 'ace': 'application/x-ace-compressed', + 'acu': 'application/vnd.acucobol', + 'acutc': 'application/vnd.acucorp', + 'adp': 'audio/adpcm', + 'adts': 'audio/aac', + 'aep': 'application/vnd.audiograph', + 'afm': 'application/x-font-type1', + 'afp': 'application/vnd.ibm.modcap', + 'age': 'application/vnd.age', + 'ahead': 'application/vnd.ahead.space', + 'ai': 'application/postscript', + 'aif': 'audio/x-aiff', + 'aifc': 'audio/x-aiff', + 'aiff': 'audio/x-aiff', + 'air': 'application/vnd.adobe.air-application-installer-package+zip', + 'ait': 'application/vnd.dvb.ait', + 'ami': 'application/vnd.amiga.ami', + 'aml': 'application/automationml-aml+xml', + 'amlx': 'application/automationml-amlx+zip', + 'amr': 'audio/amr', + 'apk': 'application/vnd.android.package-archive', + 'apng': 'image/apng', + 'appcache': 'text/cache-manifest', + 'appinstaller': 'application/appinstaller', + 'application': 'application/x-ms-application', + 'appx': 'application/appx', + 'appxbundle': 'application/appxbundle', + 'apr': 'application/vnd.lotus-approach', + 'arc': 'application/x-freearc', + 'arj': 'application/x-arj', + 'asc': 'application/pgp-keys', + 'asf': 'video/x-ms-asf', + 'asm': 'text/x-asm', + 'aso': 'application/vnd.accpac.simply.aso', + 'asx': 'video/x-ms-asf', + 'atc': 'application/vnd.acucorp', + 'atom': 'application/atom+xml', + 'atomcat': 'application/atomcat+xml', + 'atomdeleted': 'application/atomdeleted+xml', + 'atomsvc': 'application/atomsvc+xml', + 'atx': 'application/vnd.antix.game-component', + 'au': 'audio/basic', + 'avci': 'image/avci', + 'avcs': 'image/avcs', + 'avi': 'video/x-msvideo', + 'avif': 'image/avif', + 'aw': 'application/applixware', + 'azf': 'application/vnd.airzip.filesecure.azf', + 'azs': 'application/vnd.airzip.filesecure.azs', + 'azv': 'image/vnd.airzip.accelerator.azv', + 'azw': 'application/vnd.amazon.ebook', + 'b16': 'image/vnd.pco.b16', + 'bary': 'model/vnd.bary', + 'bat': 'application/x-msdownload', + 'bcpio': 'application/x-bcpio', + 'bdf': 'application/x-font-bdf', + 'bdm': 'application/vnd.syncml.dm+wbxml', + 'bdo': 'application/vnd.nato.bindingdataobject+xml', + 'bdoc': 'application/bdoc', + 'bed': 'application/vnd.realvnc.bed', + 'bh2': 'application/vnd.fujitsu.oasysprs', + 'bin': 'application/octet-stream', + 'blb': 'application/x-blorb', + 'blend': 'application/x-blender', + 'blorb': 'application/x-blorb', + 'bmi': 'application/vnd.bmi', + 'bmml': 'application/vnd.balsamiq.bmml+xml', + 'bmp': 'image/bmp', + 'book': 'application/vnd.framemaker', + 'box': 'application/vnd.previewsystems.box', + 'boz': 'application/x-bzip2', + 'bpk': 'application/octet-stream', + 'brush': 'application/vnd.procreate.brush', + 'brushset': 'application/vnd.procrate.brushset', + 'bsp': 'model/vnd.valve.source.compiled-map', + 'btf': 'image/prs.btif', + 'btif': 'image/prs.btif', + 'buffer': 'application/octet-stream', + 'bz': 'application/x-bzip', + 'bz2': 'application/x-bzip2', + 'c': 'text/x-c', + 'c11amc': 'application/vnd.cluetrust.cartomobile-config', + 'c11amz': 'application/vnd.cluetrust.cartomobile-config-pkg', + 'c4d': 'application/vnd.clonk.c4group', + 'c4f': 'application/vnd.clonk.c4group', + 'c4g': 'application/vnd.clonk.c4group', + 'c4p': 'application/vnd.clonk.c4group', + 'c4u': 'application/vnd.clonk.c4group', + 'cab': 'application/vnd.ms-cab-compressed', + 'caf': 'audio/x-caf', + 'cap': 'application/vnd.tcpdump.pcap', + 'car': 'application/vnd.curl.car', + 'cat': 'application/vnd.ms-pki.seccat', + 'cb7': 'application/x-cbr', + 'cba': 'application/x-cbr', + 'cbr': 'application/x-cbr', + 'cbt': 'application/x-cbr', + 'cbz': 'application/x-cbr', + 'cc': 'text/x-c', + 'cco': 'application/x-cocoa', + 'cct': 'application/x-director', + 'ccxml': 'application/ccxml+xml', + 'cdbcmsg': 'application/vnd.contact.cmsg', + 'cdf': 'application/x-netcdf', + 'cdfx': 'application/cdfx+xml', + 'cdkey': 'application/vnd.mediastation.cdkey', + 'cdmia': 'application/cdmi-capability', + 'cdmic': 'application/cdmi-container', + 'cdmid': 'application/cdmi-domain', + 'cdmio': 'application/cdmi-object', + 'cdmiq': 'application/cdmi-queue', + 'cdx': 'chemical/x-cdx', + 'cdxml': 'application/vnd.chemdraw+xml', + 'cdy': 'application/vnd.cinderella', + 'cer': 'application/pkix-cert', + 'cfs': 'application/x-cfs-compressed', + 'cgm': 'image/cgm', + 'chat': 'application/x-chat', + 'chm': 'application/vnd.ms-htmlhelp', + 'chrt': 'application/vnd.kde.kchart', + 'cif': 'chemical/x-cif', + 'cii': 'application/vnd.anser-web-certificate-issue-initiation', + 'cil': 'application/vnd.ms-artgalry', + 'cjs': 'application/node', + 'cla': 'application/vnd.claymore', + 'class': 'application/java-vm', + 'cld': 'model/vnd.cld', + 'clkk': 'application/vnd.crick.clicker.keyboard', + 'clkp': 'application/vnd.crick.clicker.palette', + 'clkt': 'application/vnd.crick.clicker.template', + 'clkw': 'application/vnd.crick.clicker.wordbank', + 'clkx': 'application/vnd.crick.clicker', + 'clp': 'application/x-msclip', + 'cmc': 'application/vnd.cosmocaller', + 'cmdf': 'chemical/x-cmdf', + 'cml': 'chemical/x-cml', + 'cmp': 'application/vnd.yellowriver-custom-menu', + 'cmx': 'image/x-cmx', + 'cod': 'application/vnd.rim.cod', + 'coffee': 'text/coffeescript', + 'com': 'application/x-msdownload', + 'conf': 'text/plain', + 'cpio': 'application/x-cpio', + 'cpl': 'application/cpl+xml', + 'cpp': 'text/x-c', + 'cpt': 'application/mac-compactpro', + 'crd': 'application/x-mscardfile', + 'crl': 'application/pkix-crl', + 'crt': 'application/x-x509-ca-cert', + 'crx': 'application/x-chrome-extension', + 'cryptonote': 'application/vnd.rig.cryptonote', + 'csh': 'application/x-csh', + 'csl': 'application/vnd.citationstyles.style+xml', + 'csml': 'chemical/x-csml', + 'csp': 'application/vnd.commonspace', + 'css': 'text/css', + 'cst': 'application/x-director', + 'csv': 'text/csv', + 'cu': 'application/cu-seeme', + 'curl': 'text/vnd.curl', + 'cwl': 'application/cwl', + 'cww': 'application/prs.cww', + 'cxt': 'application/x-director', + 'cxx': 'text/x-c', + 'dae': 'model/vnd.collada+xml', + 'daf': 'application/vnd.mobius.daf', + 'dart': 'application/vnd.dart', + 'dataless': 'application/vnd.fdsn.seed', + 'davmount': 'application/davmount+xml', + 'dbf': 'application/vnd.dbf', + 'dbk': 'application/docbook+xml', + 'dcm': 'application/dicom', + 'dcmp': 'application/vnd.dcmp+xml', + 'dcr': 'application/x-director', + 'dcurl': 'text/vnd.curl.dcurl', + 'dd2': 'application/vnd.oma.dd2+xml', + 'ddd': 'application/vnd.fujixerox.ddd', + 'ddf': 'application/vnd.syncml.dmddf+xml', + 'dds': 'image/vnd.ms-dds', + 'deb': 'application/x-debian-package', + 'def': 'text/plain', + 'deploy': 'application/octet-stream', + 'der': 'application/x-x509-ca-cert', + 'dfac': 'application/vnd.dreamfactory', + 'dgc': 'application/x-dgc-compressed', + 'dib': 'image/bmp', + 'dic': 'text/x-c', + 'dir': 'application/x-director', + 'dis': 'application/vnd.mobius.dis', + 'disposition-notification': 'message/disposition-notification', + 'dist': 'application/octet-stream', + 'distz': 'application/octet-stream', + 'djv': 'image/vnd.djvu', + 'djvu': 'image/vnd.djvu', + 'dll': 'application/x-msdownload', + 'dmg': 'application/x-apple-diskimage', + 'dmp': 'application/vnd.tcpdump.pcap', + 'dms': 'application/octet-stream', + 'dna': 'application/vnd.dna', + 'dng': 'image/x-adobe-dng', + 'doc': 'application/msword', + 'docm': 'application/vnd.ms-word.document.macroenabled.12', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'dot': 'application/msword', + 'dotm': 'application/vnd.ms-word.template.macroenabled.12', + 'dotx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + 'dp': 'application/vnd.osgi.dp', + 'dpg': 'application/vnd.dpgraph', + 'dpx': 'image/dpx', + 'dra': 'audio/vnd.dra', + 'drle': 'image/dicom-rle', + 'drm': 'application/vnd.procreate.dream', + 'dsc': 'text/prs.lines.tag', + 'dssc': 'application/dssc+der', + 'dtb': 'application/x-dtbook+xml', + 'dtd': 'application/xml-dtd', + 'dts': 'audio/vnd.dts', + 'dtshd': 'audio/vnd.dts.hd', + 'dump': 'application/octet-stream', + 'dvb': 'video/vnd.dvb.file', + 'dvi': 'application/x-dvi', + 'dwd': 'application/atsc-dwd+xml', + 'dwf': 'model/vnd.dwf', + 'dwg': 'image/vnd.dwg', + 'dxf': 'image/vnd.dxf', + 'dxp': 'application/vnd.spotfire.dxp', + 'dxr': 'application/x-director', + 'ear': 'application/java-archive', + 'ecelp4800': 'audio/vnd.nuera.ecelp4800', + 'ecelp7470': 'audio/vnd.nuera.ecelp7470', + 'ecelp9600': 'audio/vnd.nuera.ecelp9600', + 'ecma': 'application/ecmascript', + 'edm': 'application/vnd.novadigm.edm', + 'edx': 'application/vnd.novadigm.edx', + 'efif': 'application/vnd.picsel', + 'ei6': 'application/vnd.pg.osasli', + 'elc': 'application/octet-stream', + 'emf': 'image/emf', + 'eml': 'message/rfc822', + 'emma': 'application/emma+xml', + 'emotionml': 'application/emotionml+xml', + 'emz': 'application/x-msmetafile', + 'eol': 'audio/vnd.digital-winds', + 'eot': 'application/vnd.ms-fontobject', + 'eps': 'application/postscript', + 'epub': 'application/epub+zip', + 'es3': 'application/vnd.eszigno3+xml', + 'esa': 'application/vnd.osgi.subsystem', + 'esf': 'application/vnd.epson.esf', + 'et3': 'application/vnd.eszigno3+xml', + 'etx': 'text/x-setext', + 'eva': 'application/x-eva', + 'evy': 'application/x-envoy', + 'exe': 'application/x-msdos-program', + 'exi': 'application/exi', + 'exp': 'application/express', + 'exr': 'image/aces', + 'ext': 'application/vnd.novadigm.ext', + 'ez': 'application/andrew-inset', + 'ez2': 'application/vnd.ezpix-album', + 'ez3': 'application/vnd.ezpix-package', + 'f': 'text/x-fortran', + 'f4v': 'video/x-f4v', + 'f77': 'text/x-fortran', + 'f90': 'text/x-fortran', + 'fbs': 'image/vnd.fastbidsheet', + 'fbx': 'application/vnd.autodesk.fbx', + 'fcdt': 'application/vnd.adobe.formscentral.fcdt', + 'fcs': 'application/vnd.isac.fcs', + 'fdf': 'application/fdf', + 'fdt': 'application/fdt+xml', + 'fe_launch': 'application/vnd.denovo.fcselayout-link', + 'fg5': 'application/vnd.fujitsu.oasysgp', + 'fgd': 'application/x-director', + 'fh': 'image/x-freehand', + 'fh4': 'image/x-freehand', + 'fh5': 'image/x-freehand', + 'fh7': 'image/x-freehand', + 'fhc': 'image/x-freehand', + 'fig': 'application/x-xfig', + 'fits': 'image/fits', + 'flac': 'audio/x-flac', + 'fli': 'video/x-fli', + 'flo': 'application/vnd.micrografx.flo', + 'flv': 'video/x-flv', + 'flw': 'application/vnd.kde.kivio', + 'flx': 'text/vnd.fmi.flexstor', + 'fly': 'text/vnd.fly', + 'fm': 'application/vnd.framemaker', + 'fnc': 'application/vnd.frogans.fnc', + 'fo': 'application/vnd.software602.filler.form+xml', + 'for': 'text/x-fortran', + 'fpx': 'image/vnd.fpx', + 'frame': 'application/vnd.framemaker', + 'fsc': 'application/vnd.fsc.weblaunch', + 'fst': 'image/vnd.fst', + 'ftc': 'application/vnd.fluxtime.clip', + 'fti': 'application/vnd.anser-web-funds-transfer-initiation', + 'fvt': 'video/vnd.fvt', + 'fxp': 'application/vnd.adobe.fxp', + 'fxpl': 'application/vnd.adobe.fxp', + 'fzs': 'application/vnd.fuzzysheet', + 'g2w': 'application/vnd.geoplan', + 'g3': 'image/g3fax', + 'g3w': 'application/vnd.geospace', + 'gac': 'application/vnd.groove-account', + 'gam': 'application/x-tads', + 'gbr': 'application/rpki-ghostbusters', + 'gca': 'application/x-gca-compressed', + 'gdl': 'model/vnd.gdl', + 'gdoc': 'application/vnd.google-apps.document', + 'gdraw': 'application/vnd.google-apps.drawing', + 'ged': 'text/vnd.familysearch.gedcom', + 'geo': 'application/vnd.dynageo', + 'geojson': 'application/geo+json', + 'gex': 'application/vnd.geometry-explorer', + 'gform': 'application/vnd.google-apps.form', + 'ggb': 'application/vnd.geogebra.file', + 'ggs': 'application/vnd.geogebra.slides', + 'ggt': 'application/vnd.geogebra.tool', + 'ghf': 'application/vnd.groove-help', + 'gif': 'image/gif', + 'gim': 'application/vnd.groove-identity-message', + 'gjam': 'application/vnd.google-apps.jam', + 'glb': 'model/gltf-binary', + 'gltf': 'model/gltf+json', + 'gmap': 'application/vnd.google-apps.map', + 'gml': 'application/gml+xml', + 'gmx': 'application/vnd.gmx', + 'gnumeric': 'application/x-gnumeric', + 'gph': 'application/vnd.flographit', + 'gpx': 'application/gpx+xml', + 'gqf': 'application/vnd.grafeq', + 'gqs': 'application/vnd.grafeq', + 'gram': 'application/srgs', + 'gramps': 'application/x-gramps-xml', + 'gre': 'application/vnd.geometry-explorer', + 'grv': 'application/vnd.groove-injector', + 'grxml': 'application/srgs+xml', + 'gscript': 'application/vnd.google-apps.script', + 'gsf': 'application/x-font-ghostscript', + 'gsheet': 'application/vnd.google-apps.spreadsheet', + 'gsite': 'application/vnd.google-apps.site', + 'gslides': 'application/vnd.google-apps.presentation', + 'gtar': 'application/x-gtar', + 'gtm': 'application/vnd.groove-tool-message', + 'gtw': 'model/vnd.gtw', + 'gv': 'text/vnd.graphviz', + 'gxf': 'application/gxf', + 'gxt': 'application/vnd.geonext', + 'gz': 'application/gzip', + 'h': 'text/x-c', + 'h261': 'video/h261', + 'h263': 'video/h263', + 'h264': 'video/h264', + 'hal': 'application/vnd.hal+xml', + 'hbci': 'application/vnd.hbci', + 'hbs': 'text/x-handlebars-template', + 'hdd': 'application/x-virtualbox-hdd', + 'hdf': 'application/x-hdf', + 'heic': 'image/heic', + 'heics': 'image/heic-sequence', + 'heif': 'image/heif', + 'heifs': 'image/heif-sequence', + 'hej2': 'image/hej2k', + 'held': 'application/atsc-held+xml', + 'hh': 'text/x-c', + 'hjson': 'application/hjson', + 'hlp': 'application/winhlp', + 'hpgl': 'application/vnd.hp-hpgl', + 'hpid': 'application/vnd.hp-hpid', + 'hps': 'application/vnd.hp-hps', + 'hqx': 'application/mac-binhex40', + 'htc': 'text/x-component', + 'htke': 'application/vnd.kenameaapp', + 'htm': 'text/html', + 'html': 'text/html', + 'hvd': 'application/vnd.yamaha.hv-dic', + 'hvp': 'application/vnd.yamaha.hv-voice', + 'hvs': 'application/vnd.yamaha.hv-script', + 'i2g': 'application/vnd.intergeo', + 'icc': 'application/vnd.iccprofile', + 'ice': 'x-conference/x-cooltalk', + 'icm': 'application/vnd.iccprofile', + 'ico': 'image/vnd.microsoft.icon', + 'ics': 'text/calendar', + 'ief': 'image/ief', + 'ifb': 'text/calendar', + 'ifm': 'application/vnd.shana.informed.formdata', + 'iges': 'model/iges', + 'igl': 'application/vnd.igloader', + 'igm': 'application/vnd.insors.igm', + 'igs': 'model/iges', + 'igx': 'application/vnd.micrografx.igx', + 'iif': 'application/vnd.shana.informed.interchange', + 'img': 'application/octet-stream', + 'imp': 'application/vnd.accpac.simply.imp', + 'ims': 'application/vnd.ms-ims', + 'in': 'text/plain', + 'ini': 'text/plain', + 'ink': 'application/inkml+xml', + 'inkml': 'application/inkml+xml', + 'install': 'application/x-install-instructions', + 'iota': 'application/vnd.astraea-software.iota', + 'ipfix': 'application/ipfix', + 'ipk': 'application/vnd.shana.informed.package', + 'ipynb': 'application/x-ipynb+json', + 'irm': 'application/vnd.ibm.rights-management', + 'irp': 'application/vnd.irepository.package+xml', + 'iso': 'application/x-iso9660-image', + 'itp': 'application/vnd.shana.informed.formtemplate', + 'its': 'application/its+xml', + 'ivp': 'application/vnd.immervision-ivp', + 'ivu': 'application/vnd.immervision-ivu', + 'jad': 'text/vnd.sun.j2me.app-descriptor', + 'jade': 'text/jade', + 'jaii': 'image/jaii', + 'jais': 'image/jais', + 'jam': 'application/vnd.jam', + 'jar': 'application/java-archive', + 'jardiff': 'application/x-java-archive-diff', + 'java': 'text/x-java-source', + 'jfif': 'image/pjpeg', + 'jhc': 'image/jphc', + 'jisp': 'application/vnd.jisp', + 'jls': 'image/jls', + 'jlt': 'application/vnd.hp-jlyt', + 'jng': 'image/x-jng', + 'jnlp': 'application/x-java-jnlp-file', + 'joda': 'application/vnd.joost.joda-archive', + 'jp2': 'image/jp2', + 'jpe': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'jpf': 'image/jpx', + 'jpg': 'image/jpeg', + 'jpg2': 'image/jp2', + 'jpgm': 'image/jpm', + 'jpgv': 'video/jpeg', + 'jph': 'image/jph', + 'jpm': 'image/jpm', + 'jpx': 'image/jpx', + 'js': 'text/javascript', + 'json': 'application/json', + 'json5': 'application/json5', + 'jsonld': 'application/ld+json', + 'jsonml': 'application/jsonml+json', + 'jsx': 'text/jsx', + 'jt': 'model/jt', + 'jxl': 'image/jxl', + 'jxr': 'image/jxr', + 'jxra': 'image/jxra', + 'jxrs': 'image/jxrs', + 'jxs': 'image/jxs', + 'jxsc': 'image/jxsc', + 'jxsi': 'image/jxsi', + 'jxss': 'image/jxss', + 'kar': 'audio/midi', + 'karbon': 'application/vnd.kde.karbon', + 'kdbx': 'application/x-keepass2', + 'key': 'application/vnd.apple.keynote', + 'kfo': 'application/vnd.kde.kformula', + 'kia': 'application/vnd.kidspiration', + 'kml': 'application/vnd.google-earth.kml+xml', + 'kmz': 'application/vnd.google-earth.kmz', + 'kne': 'application/vnd.kinar', + 'knp': 'application/vnd.kinar', + 'kon': 'application/vnd.kde.kontour', + 'kpr': 'application/vnd.kde.kpresenter', + 'kpt': 'application/vnd.kde.kpresenter', + 'kpxx': 'application/vnd.ds-keypoint', + 'ksp': 'application/vnd.kde.kspread', + 'ktr': 'application/vnd.kahootz', + 'ktx': 'image/ktx', + 'ktx2': 'image/ktx2', + 'ktz': 'application/vnd.kahootz', + 'kwd': 'application/vnd.kde.kword', + 'kwt': 'application/vnd.kde.kword', + 'lasxml': 'application/vnd.las.las+xml', + 'latex': 'application/x-latex', + 'lbd': 'application/vnd.llamagraphics.life-balance.desktop', + 'lbe': 'application/vnd.llamagraphics.life-balance.exchange+xml', + 'les': 'application/vnd.hhe.lesson-player', + 'less': 'text/less', + 'lgr': 'application/lgr+xml', + 'lha': 'application/x-lzh-compressed', + 'link66': 'application/vnd.route66.link66+xml', + 'list': 'text/plain', + 'list3820': 'application/vnd.ibm.modcap', + 'listafp': 'application/vnd.ibm.modcap', + 'litcoffee': 'text/coffeescript', + 'lnk': 'application/x-ms-shortcut', + 'log': 'text/plain', + 'lostxml': 'application/lost+xml', + 'lottie': 'application/zip+dotlottie', + 'lrf': 'application/octet-stream', + 'lrm': 'application/vnd.ms-lrm', + 'ltf': 'application/vnd.frogans.ltf', + 'lua': 'text/x-lua', + 'luac': 'application/x-lua-bytecode', + 'lvp': 'audio/vnd.lucent.voice', + 'lwp': 'application/vnd.lotus-wordpro', + 'lzh': 'application/x-lzh-compressed', + 'm13': 'application/x-msmediaview', + 'm14': 'application/x-msmediaview', + 'm1v': 'video/mpeg', + 'm21': 'application/mp21', + 'm2a': 'audio/mpeg', + 'm2t': 'video/mp2t', + 'm2ts': 'video/mp2t', + 'm2v': 'video/mpeg', + 'm3a': 'audio/mpeg', + 'm3u': 'audio/x-mpegurl', + 'm3u8': 'application/vnd.apple.mpegurl', + 'm4a': 'audio/mp4', + 'm4b': 'audio/mp4', + 'm4p': 'application/mp4', + 'm4s': 'video/iso.segment', + 'm4u': 'video/vnd.mpegurl', + 'm4v': 'video/x-m4v', + 'ma': 'application/mathematica', + 'mads': 'application/mads+xml', + 'maei': 'application/mmt-aei+xml', + 'mag': 'application/vnd.ecowin.chart', + 'maker': 'application/vnd.framemaker', + 'man': 'text/troff', + 'manifest': 'text/cache-manifest', + 'map': 'application/json', + 'mar': 'application/octet-stream', + 'markdown': 'text/markdown', + 'mathml': 'application/mathml+xml', + 'mb': 'application/mathematica', + 'mbk': 'application/vnd.mobius.mbk', + 'mbox': 'application/mbox', + 'mc1': 'application/vnd.medcalcdata', + 'mcd': 'application/vnd.mcd', + 'mcurl': 'text/vnd.curl.mcurl', + 'md': 'text/markdown', + 'mdb': 'application/x-msaccess', + 'mdi': 'image/vnd.ms-modi', + 'mdx': 'text/mdx', + 'me': 'text/troff', + 'mesh': 'model/mesh', + 'meta4': 'application/metalink4+xml', + 'metalink': 'application/metalink+xml', + 'mets': 'application/mets+xml', + 'mfm': 'application/vnd.mfmp', + 'mft': 'application/rpki-manifest', + 'mgp': 'application/vnd.osgeo.mapguide.package', + 'mgz': 'application/vnd.proteus.magazine', + 'mht': 'message/rfc822', + 'mhtml': 'message/rfc822', + 'mid': 'audio/midi', + 'midi': 'audio/midi', + 'mie': 'application/x-mie', + 'mif': 'application/vnd.mif', + 'mime': 'message/rfc822', + 'mj2': 'video/mj2', + 'mjp2': 'video/mj2', + 'mjs': 'text/javascript', + 'mk3d': 'video/x-matroska', + 'mka': 'audio/x-matroska', + 'mkd': 'text/x-markdown', + 'mks': 'video/x-matroska', + 'mkv': 'video/x-matroska', + 'mlp': 'application/vnd.dolby.mlp', + 'mmd': 'application/vnd.chipnuts.karaoke-mmd', + 'mmf': 'application/vnd.smaf', + 'mml': 'text/mathml', + 'mmr': 'image/vnd.fujixerox.edmics-mmr', + 'mng': 'video/x-mng', + 'mny': 'application/x-msmoney', + 'mobi': 'application/x-mobipocket-ebook', + 'mods': 'application/mods+xml', + 'mov': 'video/quicktime', + 'movie': 'video/x-sgi-movie', + 'mp2': 'audio/mpeg', + 'mp21': 'application/mp21', + 'mp2a': 'audio/mpeg', + 'mp3': 'audio/mpeg', + 'mp4': 'video/mp4', + 'mp4a': 'audio/mp4', + 'mp4s': 'application/mp4', + 'mp4v': 'video/mp4', + 'mpc': 'application/vnd.mophun.certificate', + 'mpd': 'application/dash+xml', + 'mpe': 'video/mpeg', + 'mpeg': 'video/mpeg', + 'mpf': 'application/media-policy-dataset+xml', + 'mpg': 'video/mpeg', + 'mpg4': 'video/mp4', + 'mpga': 'audio/mpeg', + 'mpkg': 'application/vnd.apple.installer+xml', + 'mpm': 'application/vnd.blueice.multipass', + 'mpn': 'application/vnd.mophun.application', + 'mpp': 'application/dash-patch+xml', + 'mpt': 'application/vnd.ms-project', + 'mpy': 'application/vnd.ibm.minipay', + 'mqy': 'application/vnd.mobius.mqy', + 'mrc': 'application/marc', + 'mrcx': 'application/marcxml+xml', + 'ms': 'text/troff', + 'mscml': 'application/mediaservercontrol+xml', + 'mseed': 'application/vnd.fdsn.mseed', + 'mseq': 'application/vnd.mseq', + 'msf': 'application/vnd.epson.msf', + 'msg': 'application/vnd.ms-outlook', + 'msh': 'model/mesh', + 'msi': 'application/x-msdownload', + 'msix': 'application/msix', + 'msixbundle': 'application/msixbundle', + 'msl': 'application/vnd.mobius.msl', + 'msm': 'application/octet-stream', + 'msp': 'application/octet-stream', + 'msty': 'application/vnd.muvee.style', + 'mtl': 'model/mtl', + 'mts': 'video/mp2t', + 'mus': 'application/vnd.musician', + 'musd': 'application/mmt-usd+xml', + 'musicxml': 'application/vnd.recordare.musicxml+xml', + 'mvb': 'application/x-msmediaview', + 'mvt': 'application/vnd.mapbox-vector-tile', + 'mwf': 'application/vnd.mfer', + 'mxf': 'application/mxf', + 'mxl': 'application/vnd.recordare.musicxml', + 'mxmf': 'audio/mobile-xmf', + 'mxml': 'application/xv+xml', + 'mxs': 'application/vnd.triscape.mxs', + 'mxu': 'video/vnd.mpegurl', + 'n-gage': 'application/vnd.nokia.n-gage.symbian.install', + 'n3': 'text/n3', + 'nb': 'application/mathematica', + 'nbp': 'application/vnd.wolfram.player', + 'nc': 'application/x-netcdf', + 'ncx': 'application/x-dtbncx+xml', + 'nfo': 'text/x-nfo', + 'ngdat': 'application/vnd.nokia.n-gage.data', + 'nitf': 'application/vnd.nitf', + 'nlu': 'application/vnd.neurolanguage.nlu', + 'nml': 'application/vnd.enliven', + 'nnd': 'application/vnd.noblenet-directory', + 'nns': 'application/vnd.noblenet-sealer', + 'nnw': 'application/vnd.noblenet-web', + 'npx': 'image/vnd.net-fpx', + 'nq': 'application/n-quads', + 'nsc': 'application/x-conference', + 'nsf': 'application/vnd.lotus-notes', + 'nt': 'application/n-triples', + 'ntf': 'application/vnd.nitf', + 'numbers': 'application/vnd.apple.numbers', + 'nzb': 'application/x-nzb', + 'oa2': 'application/vnd.fujitsu.oasys2', + 'oa3': 'application/vnd.fujitsu.oasys3', + 'oas': 'application/vnd.fujitsu.oasys', + 'obd': 'application/x-msbinder', + 'obgx': 'application/vnd.openblox.game+xml', + 'obj': 'model/obj', + 'oda': 'application/oda', + 'odb': 'application/vnd.oasis.opendocument.database', + 'odc': 'application/vnd.oasis.opendocument.chart', + 'odf': 'application/vnd.oasis.opendocument.formula', + 'odft': 'application/vnd.oasis.opendocument.formula-template', + 'odg': 'application/vnd.oasis.opendocument.graphics', + 'odi': 'application/vnd.oasis.opendocument.image', + 'odm': 'application/vnd.oasis.opendocument.text-master', + 'odp': 'application/vnd.oasis.opendocument.presentation', + 'ods': 'application/vnd.oasis.opendocument.spreadsheet', + 'odt': 'application/vnd.oasis.opendocument.text', + 'oga': 'audio/ogg', + 'ogex': 'model/vnd.opengex', + 'ogg': 'audio/ogg', + 'ogv': 'video/ogg', + 'ogx': 'application/ogg', + 'omdoc': 'application/omdoc+xml', + 'one': 'application/onenote', + 'onea': 'application/onenote', + 'onepkg': 'application/onenote', + 'onetmp': 'application/onenote', + 'onetoc': 'application/onenote', + 'onetoc2': 'application/onenote', + 'opf': 'application/oebps-package+xml', + 'opml': 'text/x-opml', + 'oprc': 'application/vnd.palm', + 'opus': 'audio/ogg', + 'org': 'application/vnd.lotus-organizer', + 'osf': 'application/vnd.yamaha.openscoreformat', + 'osfpvg': 'application/vnd.yamaha.openscoreformat.osfpvg+xml', + 'osm': 'application/vnd.openstreetmap.data+xml', + 'otc': 'application/vnd.oasis.opendocument.chart-template', + 'otf': 'font/otf', + 'otg': 'application/vnd.oasis.opendocument.graphics-template', + 'oth': 'application/vnd.oasis.opendocument.text-web', + 'oti': 'application/vnd.oasis.opendocument.image-template', + 'otp': 'application/vnd.oasis.opendocument.presentation-template', + 'ots': 'application/vnd.oasis.opendocument.spreadsheet-template', + 'ott': 'application/vnd.oasis.opendocument.text-template', + 'ova': 'application/x-virtualbox-ova', + 'ovf': 'application/x-virtualbox-ovf', + 'owl': 'application/rdf+xml', + 'oxps': 'application/oxps', + 'oxt': 'application/vnd.openofficeorg.extension', + 'p': 'text/x-pascal', + 'p10': 'application/pkcs10', + 'p12': 'application/x-pkcs12', + 'p21': 'model/step', + 'p7b': 'application/x-pkcs7-certificates', + 'p7c': 'application/pkcs7-mime', + 'p7m': 'application/pkcs7-mime', + 'p7r': 'application/x-pkcs7-certreqresp', + 'p7s': 'application/pkcs7-signature', + 'p8': 'application/pkcs8', + 'pac': 'application/x-ns-proxy-autoconfig', + 'pages': 'application/vnd.apple.pages', + 'pas': 'text/x-pascal', + 'paw': 'application/vnd.pawaafile', + 'pbd': 'application/vnd.powerbuilder6', + 'pbm': 'image/x-portable-bitmap', + 'pcap': 'application/vnd.tcpdump.pcap', + 'pcf': 'application/x-font-pcf', + 'pcl': 'application/vnd.hp-pcl', + 'pclxl': 'application/vnd.hp-pclxl', + 'pct': 'image/x-pict', + 'pcurl': 'application/vnd.curl.pcurl', + 'pcx': 'image/vnd.zbrush.pcx', + 'pdb': 'application/vnd.palm', + 'pde': 'text/x-processing', + 'pdf': 'application/pdf', + 'pem': 'application/x-x509-ca-cert', + 'pfa': 'application/x-font-type1', + 'pfb': 'application/x-font-type1', + 'pfm': 'application/x-font-type1', + 'pfr': 'application/font-tdpfr', + 'pfx': 'application/x-pkcs12', + 'pgm': 'image/x-portable-graymap', + 'pgn': 'application/x-chess-pgn', + 'pgp': 'application/pgp-encrypted', + 'php': 'application/x-httpd-php', + 'pic': 'image/x-pict', + 'pkg': 'application/octet-stream', + 'pki': 'application/pkixcmp', + 'pkipath': 'application/pkix-pkipath', + 'pkpass': 'application/vnd.apple.pkpass', + 'pl': 'application/x-perl', + 'plb': 'application/vnd.3gpp.pic-bw-large', + 'plc': 'application/vnd.mobius.plc', + 'plf': 'application/vnd.pocketlearn', + 'pls': 'application/pls+xml', + 'pm': 'application/x-perl', + 'pml': 'application/vnd.ctc-posml', + 'png': 'image/png', + 'pnm': 'image/x-portable-anymap', + 'portpkg': 'application/vnd.macports.portpkg', + 'pot': 'application/vnd.ms-powerpoint', + 'potm': 'application/vnd.ms-powerpoint.template.macroenabled.12', + 'potx': 'application/vnd.openxmlformats-officedocument.presentationml.template', + 'ppam': 'application/vnd.ms-powerpoint.addin.macroenabled.12', + 'ppd': 'application/vnd.cups-ppd', + 'ppm': 'image/x-portable-pixmap', + 'pps': 'application/vnd.ms-powerpoint', + 'ppsm': 'application/vnd.ms-powerpoint.slideshow.macroenabled.12', + 'ppsx': 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptm': 'application/vnd.ms-powerpoint.presentation.macroenabled.12', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'pqa': 'application/vnd.palm', + 'prc': 'model/prc', + 'pre': 'application/vnd.lotus-freelance', + 'prf': 'application/pics-rules', + 'provx': 'application/provenance+xml', + 'ps': 'application/postscript', + 'psb': 'application/vnd.3gpp.pic-bw-small', + 'psd': 'image/vnd.adobe.photoshop', + 'psf': 'application/x-font-linux-psf', + 'pskcxml': 'application/pskc+xml', + 'pti': 'image/prs.pti', + 'ptid': 'application/vnd.pvi.ptid1', + 'pub': 'application/x-mspublisher', + 'pvb': 'application/vnd.3gpp.pic-bw-var', + 'pwn': 'application/vnd.3m.post-it-notes', + 'pya': 'audio/vnd.ms-playready.media.pya', + 'pyo': 'model/vnd.pytha.pyox', + 'pyox': 'model/vnd.pytha.pyox', + 'pyv': 'video/vnd.ms-playready.media.pyv', + 'qam': 'application/vnd.epson.quickanime', + 'qbo': 'application/vnd.intu.qbo', + 'qfx': 'application/vnd.intu.qfx', + 'qps': 'application/vnd.publishare-delta-tree', + 'qt': 'video/quicktime', + 'qwd': 'application/vnd.quark.quarkxpress', + 'qwt': 'application/vnd.quark.quarkxpress', + 'qxb': 'application/vnd.quark.quarkxpress', + 'qxd': 'application/vnd.quark.quarkxpress', + 'qxl': 'application/vnd.quark.quarkxpress', + 'qxt': 'application/vnd.quark.quarkxpress', + 'ra': 'audio/x-pn-realaudio', + 'ram': 'audio/x-pn-realaudio', + 'raml': 'application/raml+yaml', + 'rapd': 'application/route-apd+xml', + 'rar': 'application/vnd.rar', + 'ras': 'image/x-cmu-raster', + 'rcprofile': 'application/vnd.ipunplugged.rcprofile', + 'rdf': 'application/rdf+xml', + 'rdz': 'application/vnd.data-vision.rdz', + 'relo': 'application/p2p-overlay+xml', + 'rep': 'application/vnd.businessobjects', + 'res': 'application/x-dtbresource+xml', + 'rgb': 'image/x-rgb', + 'rif': 'application/reginfo+xml', + 'rip': 'audio/vnd.rip', + 'ris': 'application/x-research-info-systems', + 'rl': 'application/resource-lists+xml', + 'rlc': 'image/vnd.fujixerox.edmics-rlc', + 'rld': 'application/resource-lists-diff+xml', + 'rm': 'application/vnd.rn-realmedia', + 'rmi': 'audio/midi', + 'rmp': 'audio/x-pn-realaudio-plugin', + 'rms': 'application/vnd.jcp.javame.midlet-rms', + 'rmvb': 'application/vnd.rn-realmedia-vbr', + 'rnc': 'application/relax-ng-compact-syntax', + 'rng': 'application/xml', + 'roa': 'application/rpki-roa', + 'roff': 'text/troff', + 'rp9': 'application/vnd.cloanto.rp9', + 'rpm': 'application/x-redhat-package-manager', + 'rpss': 'application/vnd.nokia.radio-presets', + 'rpst': 'application/vnd.nokia.radio-preset', + 'rq': 'application/sparql-query', + 'rs': 'application/rls-services+xml', + 'rsat': 'application/atsc-rsat+xml', + 'rsd': 'application/rsd+xml', + 'rsheet': 'application/urc-ressheet+xml', + 'rss': 'application/rss+xml', + 'rtf': 'application/rtf', + 'rtx': 'text/richtext', + 'run': 'application/x-makeself', + 'rusd': 'application/route-usd+xml', + 's': 'text/x-asm', + 's3m': 'audio/s3m', + 'saf': 'application/vnd.yamaha.smaf-audio', + 'sass': 'text/x-sass', + 'sbml': 'application/sbml+xml', + 'sc': 'application/vnd.ibm.secure-container', + 'scd': 'application/x-msschedule', + 'scm': 'application/vnd.lotus-screencam', + 'scq': 'application/scvp-cv-request', + 'scs': 'application/scvp-cv-response', + 'scss': 'text/x-scss', + 'scurl': 'text/vnd.curl.scurl', + 'sda': 'application/vnd.stardivision.draw', + 'sdc': 'application/vnd.stardivision.calc', + 'sdd': 'application/vnd.stardivision.impress', + 'sdkd': 'application/vnd.solent.sdkm+xml', + 'sdkm': 'application/vnd.solent.sdkm+xml', + 'sdp': 'application/sdp', + 'sdw': 'application/vnd.stardivision.writer', + 'sea': 'application/x-sea', + 'see': 'application/vnd.seemail', + 'seed': 'application/vnd.fdsn.seed', + 'sema': 'application/vnd.sema', + 'semd': 'application/vnd.semd', + 'semf': 'application/vnd.semf', + 'senmlx': 'application/senml+xml', + 'sensmlx': 'application/sensml+xml', + 'ser': 'application/java-serialized-object', + 'setpay': 'application/set-payment-initiation', + 'setreg': 'application/set-registration-initiation', + 'sfd-hdstx': 'application/vnd.hydrostatix.sof-data', + 'sfs': 'application/vnd.spotfire.sfs', + 'sfv': 'text/x-sfv', + 'sgi': 'image/sgi', + 'sgl': 'application/vnd.stardivision.writer-global', + 'sgm': 'text/sgml', + 'sgml': 'text/sgml', + 'sh': 'application/x-sh', + 'shar': 'application/x-shar', + 'shex': 'text/shex', + 'shf': 'application/shf+xml', + 'shtml': 'text/html', + 'sid': 'image/x-mrsid-image', + 'sieve': 'application/sieve', + 'sig': 'application/pgp-signature', + 'sil': 'audio/silk', + 'silo': 'model/mesh', + 'sis': 'application/vnd.symbian.install', + 'sisx': 'application/vnd.symbian.install', + 'sit': 'application/x-stuffit', + 'sitx': 'application/x-stuffitx', + 'siv': 'application/sieve', + 'skd': 'application/vnd.koan', + 'skm': 'application/vnd.koan', + 'skp': 'application/vnd.koan', + 'skt': 'application/vnd.koan', + 'sldm': 'application/vnd.ms-powerpoint.slide.macroenabled.12', + 'sldx': 'application/vnd.openxmlformats-officedocument.presentationml.slide', + 'slim': 'text/slim', + 'slm': 'text/slim', + 'sls': 'application/route-s-tsid+xml', + 'slt': 'application/vnd.epson.salt', + 'sm': 'application/vnd.stepmania.stepchart', + 'smf': 'application/vnd.stardivision.math', + 'smi': 'application/smil+xml', + 'smil': 'application/smil+xml', + 'smv': 'video/x-smv', + 'smzip': 'application/vnd.stepmania.package', + 'snd': 'audio/basic', + 'snf': 'application/x-font-snf', + 'so': 'application/octet-stream', + 'spc': 'application/x-pkcs7-certificates', + 'spdx': 'text/spdx', + 'spf': 'application/vnd.yamaha.smaf-phrase', + 'spl': 'application/x-futuresplash', + 'spot': 'text/vnd.in3d.spot', + 'spp': 'application/scvp-vp-response', + 'spq': 'application/scvp-vp-request', + 'spx': 'audio/ogg', + 'sql': 'application/sql', + 'src': 'application/x-wais-source', + 'srt': 'application/x-subrip', + 'sru': 'application/sru+xml', + 'srx': 'application/sparql-results+xml', + 'ssdl': 'application/ssdl+xml', + 'sse': 'application/vnd.kodak-descriptor', + 'ssf': 'application/vnd.epson.ssf', + 'ssml': 'application/ssml+xml', + 'st': 'application/vnd.sailingtracker.track', + 'stc': 'application/vnd.sun.xml.calc.template', + 'std': 'application/vnd.sun.xml.draw.template', + 'step': 'model/step', + 'stf': 'application/vnd.wt.stf', + 'sti': 'application/vnd.sun.xml.impress.template', + 'stk': 'application/hyperstudio', + 'stl': 'model/stl', + 'stp': 'model/step', + 'stpnc': 'model/step', + 'stpx': 'model/step+xml', + 'stpxz': 'model/step-xml+zip', + 'stpz': 'model/step+zip', + 'str': 'application/vnd.pg.format', + 'stw': 'application/vnd.sun.xml.writer.template', + 'styl': 'text/stylus', + 'stylus': 'text/stylus', + 'sub': 'text/vnd.dvb.subtitle', + 'sus': 'application/vnd.sus-calendar', + 'susp': 'application/vnd.sus-calendar', + 'sv4cpio': 'application/x-sv4cpio', + 'sv4crc': 'application/x-sv4crc', + 'svc': 'application/vnd.dvb.service', + 'svd': 'application/vnd.svd', + 'svg': 'image/svg+xml', + 'svgz': 'image/svg+xml', + 'swa': 'application/x-director', + 'swf': 'application/x-shockwave-flash', + 'swi': 'application/vnd.aristanetworks.swi', + 'swidtag': 'application/swid+xml', + 'sxc': 'application/vnd.sun.xml.calc', + 'sxd': 'application/vnd.sun.xml.draw', + 'sxg': 'application/vnd.sun.xml.writer.global', + 'sxi': 'application/vnd.sun.xml.impress', + 'sxm': 'application/vnd.sun.xml.math', + 'sxw': 'application/vnd.sun.xml.writer', + 't': 'text/troff', + 't3': 'application/x-t3vm-image', + 't38': 'image/t38', + 'taglet': 'application/vnd.mynfc', + 'tao': 'application/vnd.tao.intent-module-archive', + 'tap': 'image/vnd.tencent.tap', + 'tar': 'application/x-tar', + 'tcap': 'application/vnd.3gpp2.tcap', + 'tcl': 'application/x-tcl', + 'td': 'application/urc-targetdesc+xml', + 'teacher': 'application/vnd.smart.teacher', + 'tei': 'application/tei+xml', + 'teicorpus': 'application/tei+xml', + 'tex': 'application/x-tex', + 'texi': 'application/x-texinfo', + 'texinfo': 'application/x-texinfo', + 'text': 'text/plain', + 'tfi': 'application/thraud+xml', + 'tfm': 'application/x-tex-tfm', + 'tfx': 'image/tiff-fx', + 'tga': 'image/x-tga', + 'thmx': 'application/vnd.ms-officetheme', + 'tif': 'image/tiff', + 'tiff': 'image/tiff', + 'tk': 'application/x-tcl', + 'tmo': 'application/vnd.tmobile-livetv', + 'toml': 'application/toml', + 'torrent': 'application/x-bittorrent', + 'tpl': 'application/vnd.groove-tool-template', + 'tpt': 'application/vnd.trid.tpt', + 'tr': 'text/troff', + 'tra': 'application/vnd.trueapp', + 'trig': 'application/trig', + 'trm': 'application/x-msterminal', + 'ts': 'video/mp2t', + 'tsd': 'application/timestamped-data', + 'tsv': 'text/tab-separated-values', + 'ttc': 'font/collection', + 'ttf': 'font/ttf', + 'ttl': 'text/turtle', + 'ttml': 'application/ttml+xml', + 'twd': 'application/vnd.simtech-mindmapper', + 'twds': 'application/vnd.simtech-mindmapper', + 'txd': 'application/vnd.genomatix.tuxedo', + 'txf': 'application/vnd.mobius.txf', + 'txt': 'text/plain', + 'u32': 'application/x-authorware-bin', + 'u3d': 'model/u3d', + 'u8dsn': 'message/global-delivery-status', + 'u8hdr': 'message/global-headers', + 'u8mdn': 'message/global-disposition-notification', + 'u8msg': 'message/global', + 'ubj': 'application/ubjson', + 'udeb': 'application/x-debian-package', + 'ufd': 'application/vnd.ufdl', + 'ufdl': 'application/vnd.ufdl', + 'ulx': 'application/x-glulx', + 'umj': 'application/vnd.umajin', + 'unityweb': 'application/vnd.unity', + 'uo': 'application/vnd.uoml+xml', + 'uoml': 'application/vnd.uoml+xml', + 'uri': 'text/uri-list', + 'uris': 'text/uri-list', + 'urls': 'text/uri-list', + 'usda': 'model/vnd.usda', + 'usdz': 'model/vnd.usdz+zip', + 'ustar': 'application/x-ustar', + 'utz': 'application/vnd.uiq.theme', + 'uu': 'text/x-uuencode', + 'uva': 'audio/vnd.dece.audio', + 'uvd': 'application/vnd.dece.data', + 'uvf': 'application/vnd.dece.data', + 'uvg': 'image/vnd.dece.graphic', + 'uvh': 'video/vnd.dece.hd', + 'uvi': 'image/vnd.dece.graphic', + 'uvm': 'video/vnd.dece.mobile', + 'uvp': 'video/vnd.dece.pd', + 'uvs': 'video/vnd.dece.sd', + 'uvt': 'application/vnd.dece.ttml+xml', + 'uvu': 'video/vnd.uvvu.mp4', + 'uvv': 'video/vnd.dece.video', + 'uvva': 'audio/vnd.dece.audio', + 'uvvd': 'application/vnd.dece.data', + 'uvvf': 'application/vnd.dece.data', + 'uvvg': 'image/vnd.dece.graphic', + 'uvvh': 'video/vnd.dece.hd', + 'uvvi': 'image/vnd.dece.graphic', + 'uvvm': 'video/vnd.dece.mobile', + 'uvvp': 'video/vnd.dece.pd', + 'uvvs': 'video/vnd.dece.sd', + 'uvvt': 'application/vnd.dece.ttml+xml', + 'uvvu': 'video/vnd.uvvu.mp4', + 'uvvv': 'video/vnd.dece.video', + 'uvvx': 'application/vnd.dece.unspecified', + 'uvvz': 'application/vnd.dece.zip', + 'uvx': 'application/vnd.dece.unspecified', + 'uvz': 'application/vnd.dece.zip', + 'vbox': 'application/x-virtualbox-vbox', + 'vbox-extpack': 'application/x-virtualbox-vbox-extpack', + 'vcard': 'text/vcard', + 'vcd': 'application/x-cdlink', + 'vcf': 'text/x-vcard', + 'vcg': 'application/vnd.groove-vcard', + 'vcs': 'text/x-vcalendar', + 'vcx': 'application/vnd.vcx', + 'vdi': 'application/x-virtualbox-vdi', + 'vds': 'model/vnd.sap.vds', + 'vdx': 'application/vnd.ms-visio.viewer', + 'vhd': 'application/x-virtualbox-vhd', + 'vis': 'application/vnd.visionary', + 'viv': 'video/vnd.vivo', + 'vmdk': 'application/x-virtualbox-vmdk', + 'vob': 'video/x-ms-vob', + 'vor': 'application/vnd.stardivision.writer', + 'vox': 'application/x-authorware-bin', + 'vrml': 'model/vrml', + 'vsd': 'application/vnd.visio', + 'vsdx': 'application/vnd.visio', + 'vsf': 'application/vnd.vsf', + 'vss': 'application/vnd.visio', + 'vst': 'application/vnd.visio', + 'vsw': 'application/vnd.visio', + 'vtf': 'image/vnd.valve.source.texture', + 'vtt': 'text/vtt', + 'vtu': 'model/vnd.vtu', + 'vtx': 'application/vnd.visio', + 'vxml': 'application/voicexml+xml', + 'w3d': 'application/x-director', + 'wad': 'application/x-doom', + 'wadl': 'application/vnd.sun.wadl+xml', + 'war': 'application/java-archive', + 'wasm': 'application/wasm', + 'wav': 'audio/wav', + 'wax': 'audio/x-ms-wax', + 'wbmp': 'image/vnd.wap.wbmp', + 'wbs': 'application/vnd.criticaltools.wbs+xml', + 'wbxml': 'application/vnd.wap.wbxml', + 'wcm': 'application/vnd.ms-works', + 'wdb': 'application/vnd.ms-works', + 'wdp': 'image/vnd.ms-photo', + 'weba': 'audio/webm', + 'webapp': 'application/x-web-app-manifest+json', + 'webm': 'video/webm', + 'webmanifest': 'application/manifest+json', + 'webp': 'image/webp', + 'wg': 'application/vnd.pmi.widget', + 'wgsl': 'text/wgsl', + 'wgt': 'application/widget', + 'wif': 'application/watcherinfo+xml', + 'wks': 'application/vnd.ms-works', + 'wm': 'video/x-ms-wm', + 'wma': 'audio/x-ms-wma', + 'wmd': 'application/x-ms-wmd', + 'wmf': 'image/wmf', + 'wml': 'text/vnd.wap.wml', + 'wmlc': 'application/vnd.wap.wmlc', + 'wmls': 'text/vnd.wap.wmlscript', + 'wmlsc': 'application/vnd.wap.wmlscriptc', + 'wmv': 'video/x-ms-wmv', + 'wmx': 'video/x-ms-wmx', + 'wmz': 'application/x-ms-wmz', + 'woff': 'font/woff', + 'woff2': 'font/woff2', + 'wpd': 'application/vnd.wordperfect', + 'wpl': 'application/vnd.ms-wpl', + 'wps': 'application/vnd.ms-works', + 'wqd': 'application/vnd.wqd', + 'wri': 'application/x-mswrite', + 'wrl': 'model/vrml', + 'wsc': 'message/vnd.wfa.wsc', + 'wsdl': 'application/wsdl+xml', + 'wspolicy': 'application/wspolicy+xml', + 'wtb': 'application/vnd.webturbo', + 'wvx': 'video/x-ms-wvx', + 'x32': 'application/x-authorware-bin', + 'x3d': 'model/x3d+xml', + 'x3db': 'model/x3d+fastinfoset', + 'x3dbz': 'model/x3d+binary', + 'x3dv': 'model/x3d-vrml', + 'x3dvz': 'model/x3d+vrml', + 'x3dz': 'model/x3d+xml', + 'x_b': 'model/vnd.parasolid.transmit.binary', + 'x_t': 'model/vnd.parasolid.transmit.text', + 'xaml': 'application/xaml+xml', + 'xap': 'application/x-silverlight-app', + 'xar': 'application/vnd.xara', + 'xav': 'application/xcap-att+xml', + 'xbap': 'application/x-ms-xbap', + 'xbd': 'application/vnd.fujixerox.docuworks.binder', + 'xbm': 'image/x-xbitmap', + 'xca': 'application/xcap-caps+xml', + 'xcs': 'application/calendar+xml', + 'xdcf': 'application/vnd.gov.sk.xmldatacontainer+xml', + 'xdf': 'application/xcap-diff+xml', + 'xdm': 'application/vnd.syncml.dm+xml', + 'xdp': 'application/vnd.adobe.xdp+xml', + 'xdssc': 'application/dssc+xml', + 'xdw': 'application/vnd.fujixerox.docuworks', + 'xel': 'application/xcap-el+xml', + 'xenc': 'application/xenc+xml', + 'xer': 'application/patch-ops-error+xml', + 'xfdf': 'application/xfdf', + 'xfdl': 'application/vnd.xfdl', + 'xht': 'application/xhtml+xml', + 'xhtm': 'application/vnd.pwg-xhtml-print+xml', + 'xhtml': 'application/xhtml+xml', + 'xhvml': 'application/xv+xml', + 'xif': 'image/vnd.xiff', + 'xla': 'application/vnd.ms-excel', + 'xlam': 'application/vnd.ms-excel.addin.macroenabled.12', + 'xlc': 'application/vnd.ms-excel', + 'xlf': 'application/xliff+xml', + 'xlm': 'application/vnd.ms-excel', + 'xls': 'application/vnd.ms-excel', + 'xlsb': 'application/vnd.ms-excel.sheet.binary.macroenabled.12', + 'xlsm': 'application/vnd.ms-excel.sheet.macroenabled.12', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'xlt': 'application/vnd.ms-excel', + 'xltm': 'application/vnd.ms-excel.template.macroenabled.12', + 'xltx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + 'xlw': 'application/vnd.ms-excel', + 'xm': 'audio/xm', + 'xml': 'application/xml', + 'xns': 'application/xcap-ns+xml', + 'xo': 'application/vnd.olpc-sugar', + 'xop': 'application/xop+xml', + 'xpi': 'application/x-xpinstall', + 'xpl': 'application/xproc+xml', + 'xpm': 'image/x-xpixmap', + 'xpr': 'application/vnd.is-xpr', + 'xps': 'application/vnd.ms-xpsdocument', + 'xpw': 'application/vnd.intercon.formnet', + 'xpx': 'application/vnd.intercon.formnet', + 'xsd': 'application/xml', + 'xsf': 'application/prs.xsf+xml', + 'xsl': 'application/xml', + 'xslt': 'application/xslt+xml', + 'xsm': 'application/vnd.syncml+xml', + 'xspf': 'application/xspf+xml', + 'xul': 'application/vnd.mozilla.xul+xml', + 'xvm': 'application/xv+xml', + 'xvml': 'application/xv+xml', + 'xwd': 'image/x-xwindowdump', + 'xyz': 'chemical/x-xyz', + 'xz': 'application/x-xz', + 'yaml': 'text/yaml', + 'yang': 'application/yang', + 'yin': 'application/yin+xml', + 'yml': 'text/yaml', + 'ymp': 'text/x-suse-ymp', + 'z1': 'application/x-zmachine', + 'z2': 'application/x-zmachine', + 'z3': 'application/x-zmachine', + 'z4': 'application/x-zmachine', + 'z5': 'application/x-zmachine', + 'z6': 'application/x-zmachine', + 'z7': 'application/x-zmachine', + 'z8': 'application/x-zmachine', + 'zaz': 'application/vnd.zzazz.deck+xml', + 'zip': 'application/zip', + 'zir': 'application/vnd.zul', + 'zirz': 'application/vnd.zul', + 'zmm': 'application/vnd.handheld-entertainment+xml', +} + +/** Media type to its extensions, in mime-db order: mime-types' `extensions`. */ +export const typeExtensions: { [type: string]: string[] } = { + 'application/andrew-inset': ['ez'], + 'application/appinstaller': ['appinstaller'], + 'application/applixware': ['aw'], + 'application/appx': ['appx'], + 'application/appxbundle': ['appxbundle'], + 'application/atom+xml': ['atom'], + 'application/atomcat+xml': ['atomcat'], + 'application/atomdeleted+xml': ['atomdeleted'], + 'application/atomsvc+xml': ['atomsvc'], + 'application/atsc-dwd+xml': ['dwd'], + 'application/atsc-held+xml': ['held'], + 'application/atsc-rsat+xml': ['rsat'], + 'application/automationml-aml+xml': ['aml'], + 'application/automationml-amlx+zip': ['amlx'], + 'application/bdoc': ['bdoc'], + 'application/calendar+xml': ['xcs'], + 'application/ccxml+xml': ['ccxml'], + 'application/cdfx+xml': ['cdfx'], + 'application/cdmi-capability': ['cdmia'], + 'application/cdmi-container': ['cdmic'], + 'application/cdmi-domain': ['cdmid'], + 'application/cdmi-object': ['cdmio'], + 'application/cdmi-queue': ['cdmiq'], + 'application/cpl+xml': ['cpl'], + 'application/cu-seeme': ['cu'], + 'application/cwl': ['cwl'], + 'application/dash+xml': ['mpd'], + 'application/dash-patch+xml': ['mpp'], + 'application/davmount+xml': ['davmount'], + 'application/dicom': ['dcm'], + 'application/docbook+xml': ['dbk'], + 'application/dssc+der': ['dssc'], + 'application/dssc+xml': ['xdssc'], + 'application/ecmascript': ['ecma'], + 'application/emma+xml': ['emma'], + 'application/emotionml+xml': ['emotionml'], + 'application/epub+zip': ['epub'], + 'application/exi': ['exi'], + 'application/express': ['exp'], + 'application/fdf': ['fdf'], + 'application/fdt+xml': ['fdt'], + 'application/font-tdpfr': ['pfr'], + 'application/geo+json': ['geojson'], + 'application/gml+xml': ['gml'], + 'application/gpx+xml': ['gpx'], + 'application/gxf': ['gxf'], + 'application/gzip': ['gz'], + 'application/hjson': ['hjson'], + 'application/hyperstudio': ['stk'], + 'application/inkml+xml': ['ink', 'inkml'], + 'application/ipfix': ['ipfix'], + 'application/its+xml': ['its'], + 'application/java-archive': ['jar', 'war', 'ear'], + 'application/java-serialized-object': ['ser'], + 'application/java-vm': ['class'], + 'application/javascript': ['js'], + 'application/json': ['json', 'map'], + 'application/json5': ['json5'], + 'application/jsonml+json': ['jsonml'], + 'application/ld+json': ['jsonld'], + 'application/lgr+xml': ['lgr'], + 'application/lost+xml': ['lostxml'], + 'application/mac-binhex40': ['hqx'], + 'application/mac-compactpro': ['cpt'], + 'application/mads+xml': ['mads'], + 'application/manifest+json': ['webmanifest'], + 'application/marc': ['mrc'], + 'application/marcxml+xml': ['mrcx'], + 'application/mathematica': ['ma', 'nb', 'mb'], + 'application/mathml+xml': ['mathml'], + 'application/mbox': ['mbox'], + 'application/media-policy-dataset+xml': ['mpf'], + 'application/mediaservercontrol+xml': ['mscml'], + 'application/metalink+xml': ['metalink'], + 'application/metalink4+xml': ['meta4'], + 'application/mets+xml': ['mets'], + 'application/mmt-aei+xml': ['maei'], + 'application/mmt-usd+xml': ['musd'], + 'application/mods+xml': ['mods'], + 'application/mp21': ['m21', 'mp21'], + 'application/mp4': ['mp4', 'mpg4', 'mp4s', 'm4p'], + 'application/msix': ['msix'], + 'application/msixbundle': ['msixbundle'], + 'application/msword': ['doc', 'dot'], + 'application/mxf': ['mxf'], + 'application/n-quads': ['nq'], + 'application/n-triples': ['nt'], + 'application/node': ['cjs'], + 'application/octet-stream': ['bin', 'dms', 'lrf', 'mar', 'so', 'dist', 'distz', 'pkg', 'bpk', 'dump', 'elc', 'deploy', 'exe', 'dll', 'deb', 'dmg', 'iso', 'img', 'msi', 'msp', 'msm', 'buffer'], + 'application/oda': ['oda'], + 'application/oebps-package+xml': ['opf'], + 'application/ogg': ['ogx'], + 'application/omdoc+xml': ['omdoc'], + 'application/onenote': ['onetoc', 'onetoc2', 'onetmp', 'onepkg', 'one', 'onea'], + 'application/oxps': ['oxps'], + 'application/p2p-overlay+xml': ['relo'], + 'application/patch-ops-error+xml': ['xer'], + 'application/pdf': ['pdf'], + 'application/pgp-encrypted': ['pgp'], + 'application/pgp-keys': ['asc'], + 'application/pgp-signature': ['sig', 'asc'], + 'application/pics-rules': ['prf'], + 'application/pkcs10': ['p10'], + 'application/pkcs7-mime': ['p7m', 'p7c'], + 'application/pkcs7-signature': ['p7s'], + 'application/pkcs8': ['p8'], + 'application/pkix-attr-cert': ['ac'], + 'application/pkix-cert': ['cer'], + 'application/pkix-crl': ['crl'], + 'application/pkix-pkipath': ['pkipath'], + 'application/pkixcmp': ['pki'], + 'application/pls+xml': ['pls'], + 'application/postscript': ['ai', 'eps', 'ps'], + 'application/provenance+xml': ['provx'], + 'application/prs.cww': ['cww'], + 'application/prs.xsf+xml': ['xsf'], + 'application/pskc+xml': ['pskcxml'], + 'application/raml+yaml': ['raml'], + 'application/rdf+xml': ['rdf', 'owl'], + 'application/reginfo+xml': ['rif'], + 'application/relax-ng-compact-syntax': ['rnc'], + 'application/resource-lists+xml': ['rl'], + 'application/resource-lists-diff+xml': ['rld'], + 'application/rls-services+xml': ['rs'], + 'application/route-apd+xml': ['rapd'], + 'application/route-s-tsid+xml': ['sls'], + 'application/route-usd+xml': ['rusd'], + 'application/rpki-ghostbusters': ['gbr'], + 'application/rpki-manifest': ['mft'], + 'application/rpki-roa': ['roa'], + 'application/rsd+xml': ['rsd'], + 'application/rss+xml': ['rss'], + 'application/rtf': ['rtf'], + 'application/sbml+xml': ['sbml'], + 'application/scvp-cv-request': ['scq'], + 'application/scvp-cv-response': ['scs'], + 'application/scvp-vp-request': ['spq'], + 'application/scvp-vp-response': ['spp'], + 'application/sdp': ['sdp'], + 'application/senml+xml': ['senmlx'], + 'application/sensml+xml': ['sensmlx'], + 'application/set-payment-initiation': ['setpay'], + 'application/set-registration-initiation': ['setreg'], + 'application/shf+xml': ['shf'], + 'application/sieve': ['siv', 'sieve'], + 'application/smil+xml': ['smi', 'smil'], + 'application/sparql-query': ['rq'], + 'application/sparql-results+xml': ['srx'], + 'application/sql': ['sql'], + 'application/srgs': ['gram'], + 'application/srgs+xml': ['grxml'], + 'application/sru+xml': ['sru'], + 'application/ssdl+xml': ['ssdl'], + 'application/ssml+xml': ['ssml'], + 'application/swid+xml': ['swidtag'], + 'application/tei+xml': ['tei', 'teicorpus'], + 'application/thraud+xml': ['tfi'], + 'application/timestamped-data': ['tsd'], + 'application/toml': ['toml'], + 'application/trig': ['trig'], + 'application/ttml+xml': ['ttml'], + 'application/ubjson': ['ubj'], + 'application/urc-ressheet+xml': ['rsheet'], + 'application/urc-targetdesc+xml': ['td'], + 'application/vnd.1000minds.decision-model+xml': ['1km'], + 'application/vnd.3gpp.pic-bw-large': ['plb'], + 'application/vnd.3gpp.pic-bw-small': ['psb'], + 'application/vnd.3gpp.pic-bw-var': ['pvb'], + 'application/vnd.3gpp2.tcap': ['tcap'], + 'application/vnd.3m.post-it-notes': ['pwn'], + 'application/vnd.accpac.simply.aso': ['aso'], + 'application/vnd.accpac.simply.imp': ['imp'], + 'application/vnd.acucobol': ['acu'], + 'application/vnd.acucorp': ['atc', 'acutc'], + 'application/vnd.adobe.air-application-installer-package+zip': ['air'], + 'application/vnd.adobe.formscentral.fcdt': ['fcdt'], + 'application/vnd.adobe.fxp': ['fxp', 'fxpl'], + 'application/vnd.adobe.xdp+xml': ['xdp'], + 'application/vnd.adobe.xfdf': ['xfdf'], + 'application/vnd.age': ['age'], + 'application/vnd.ahead.space': ['ahead'], + 'application/vnd.airzip.filesecure.azf': ['azf'], + 'application/vnd.airzip.filesecure.azs': ['azs'], + 'application/vnd.amazon.ebook': ['azw'], + 'application/vnd.americandynamics.acc': ['acc'], + 'application/vnd.amiga.ami': ['ami'], + 'application/vnd.android.package-archive': ['apk'], + 'application/vnd.anser-web-certificate-issue-initiation': ['cii'], + 'application/vnd.anser-web-funds-transfer-initiation': ['fti'], + 'application/vnd.antix.game-component': ['atx'], + 'application/vnd.apple.installer+xml': ['mpkg'], + 'application/vnd.apple.keynote': ['key'], + 'application/vnd.apple.mpegurl': ['m3u8'], + 'application/vnd.apple.numbers': ['numbers'], + 'application/vnd.apple.pages': ['pages'], + 'application/vnd.apple.pkpass': ['pkpass'], + 'application/vnd.aristanetworks.swi': ['swi'], + 'application/vnd.astraea-software.iota': ['iota'], + 'application/vnd.audiograph': ['aep'], + 'application/vnd.autodesk.fbx': ['fbx'], + 'application/vnd.balsamiq.bmml+xml': ['bmml'], + 'application/vnd.blueice.multipass': ['mpm'], + 'application/vnd.bmi': ['bmi'], + 'application/vnd.businessobjects': ['rep'], + 'application/vnd.chemdraw+xml': ['cdxml'], + 'application/vnd.chipnuts.karaoke-mmd': ['mmd'], + 'application/vnd.cinderella': ['cdy'], + 'application/vnd.citationstyles.style+xml': ['csl'], + 'application/vnd.claymore': ['cla'], + 'application/vnd.cloanto.rp9': ['rp9'], + 'application/vnd.clonk.c4group': ['c4g', 'c4d', 'c4f', 'c4p', 'c4u'], + 'application/vnd.cluetrust.cartomobile-config': ['c11amc'], + 'application/vnd.cluetrust.cartomobile-config-pkg': ['c11amz'], + 'application/vnd.commonspace': ['csp'], + 'application/vnd.contact.cmsg': ['cdbcmsg'], + 'application/vnd.cosmocaller': ['cmc'], + 'application/vnd.crick.clicker': ['clkx'], + 'application/vnd.crick.clicker.keyboard': ['clkk'], + 'application/vnd.crick.clicker.palette': ['clkp'], + 'application/vnd.crick.clicker.template': ['clkt'], + 'application/vnd.crick.clicker.wordbank': ['clkw'], + 'application/vnd.criticaltools.wbs+xml': ['wbs'], + 'application/vnd.ctc-posml': ['pml'], + 'application/vnd.cups-ppd': ['ppd'], + 'application/vnd.curl.car': ['car'], + 'application/vnd.curl.pcurl': ['pcurl'], + 'application/vnd.dart': ['dart'], + 'application/vnd.data-vision.rdz': ['rdz'], + 'application/vnd.dbf': ['dbf'], + 'application/vnd.dcmp+xml': ['dcmp'], + 'application/vnd.dece.data': ['uvf', 'uvvf', 'uvd', 'uvvd'], + 'application/vnd.dece.ttml+xml': ['uvt', 'uvvt'], + 'application/vnd.dece.unspecified': ['uvx', 'uvvx'], + 'application/vnd.dece.zip': ['uvz', 'uvvz'], + 'application/vnd.denovo.fcselayout-link': ['fe_launch'], + 'application/vnd.dna': ['dna'], + 'application/vnd.dolby.mlp': ['mlp'], + 'application/vnd.dpgraph': ['dpg'], + 'application/vnd.dreamfactory': ['dfac'], + 'application/vnd.ds-keypoint': ['kpxx'], + 'application/vnd.dvb.ait': ['ait'], + 'application/vnd.dvb.service': ['svc'], + 'application/vnd.dynageo': ['geo'], + 'application/vnd.ecowin.chart': ['mag'], + 'application/vnd.enliven': ['nml'], + 'application/vnd.epson.esf': ['esf'], + 'application/vnd.epson.msf': ['msf'], + 'application/vnd.epson.quickanime': ['qam'], + 'application/vnd.epson.salt': ['slt'], + 'application/vnd.epson.ssf': ['ssf'], + 'application/vnd.eszigno3+xml': ['es3', 'et3'], + 'application/vnd.ezpix-album': ['ez2'], + 'application/vnd.ezpix-package': ['ez3'], + 'application/vnd.fdf': ['fdf'], + 'application/vnd.fdsn.mseed': ['mseed'], + 'application/vnd.fdsn.seed': ['seed', 'dataless'], + 'application/vnd.flographit': ['gph'], + 'application/vnd.fluxtime.clip': ['ftc'], + 'application/vnd.framemaker': ['fm', 'frame', 'maker', 'book'], + 'application/vnd.frogans.fnc': ['fnc'], + 'application/vnd.frogans.ltf': ['ltf'], + 'application/vnd.fsc.weblaunch': ['fsc'], + 'application/vnd.fujitsu.oasys': ['oas'], + 'application/vnd.fujitsu.oasys2': ['oa2'], + 'application/vnd.fujitsu.oasys3': ['oa3'], + 'application/vnd.fujitsu.oasysgp': ['fg5'], + 'application/vnd.fujitsu.oasysprs': ['bh2'], + 'application/vnd.fujixerox.ddd': ['ddd'], + 'application/vnd.fujixerox.docuworks': ['xdw'], + 'application/vnd.fujixerox.docuworks.binder': ['xbd'], + 'application/vnd.fuzzysheet': ['fzs'], + 'application/vnd.genomatix.tuxedo': ['txd'], + 'application/vnd.geogebra.file': ['ggb'], + 'application/vnd.geogebra.slides': ['ggs'], + 'application/vnd.geogebra.tool': ['ggt'], + 'application/vnd.geometry-explorer': ['gex', 'gre'], + 'application/vnd.geonext': ['gxt'], + 'application/vnd.geoplan': ['g2w'], + 'application/vnd.geospace': ['g3w'], + 'application/vnd.gmx': ['gmx'], + 'application/vnd.google-apps.document': ['gdoc'], + 'application/vnd.google-apps.drawing': ['gdraw'], + 'application/vnd.google-apps.form': ['gform'], + 'application/vnd.google-apps.jam': ['gjam'], + 'application/vnd.google-apps.map': ['gmap'], + 'application/vnd.google-apps.presentation': ['gslides'], + 'application/vnd.google-apps.script': ['gscript'], + 'application/vnd.google-apps.site': ['gsite'], + 'application/vnd.google-apps.spreadsheet': ['gsheet'], + 'application/vnd.google-earth.kml+xml': ['kml'], + 'application/vnd.google-earth.kmz': ['kmz'], + 'application/vnd.gov.sk.xmldatacontainer+xml': ['xdcf'], + 'application/vnd.grafeq': ['gqf', 'gqs'], + 'application/vnd.groove-account': ['gac'], + 'application/vnd.groove-help': ['ghf'], + 'application/vnd.groove-identity-message': ['gim'], + 'application/vnd.groove-injector': ['grv'], + 'application/vnd.groove-tool-message': ['gtm'], + 'application/vnd.groove-tool-template': ['tpl'], + 'application/vnd.groove-vcard': ['vcg'], + 'application/vnd.hal+xml': ['hal'], + 'application/vnd.handheld-entertainment+xml': ['zmm'], + 'application/vnd.hbci': ['hbci'], + 'application/vnd.hhe.lesson-player': ['les'], + 'application/vnd.hp-hpgl': ['hpgl'], + 'application/vnd.hp-hpid': ['hpid'], + 'application/vnd.hp-hps': ['hps'], + 'application/vnd.hp-jlyt': ['jlt'], + 'application/vnd.hp-pcl': ['pcl'], + 'application/vnd.hp-pclxl': ['pclxl'], + 'application/vnd.hydrostatix.sof-data': ['sfd-hdstx'], + 'application/vnd.ibm.minipay': ['mpy'], + 'application/vnd.ibm.modcap': ['afp', 'listafp', 'list3820'], + 'application/vnd.ibm.rights-management': ['irm'], + 'application/vnd.ibm.secure-container': ['sc'], + 'application/vnd.iccprofile': ['icc', 'icm'], + 'application/vnd.igloader': ['igl'], + 'application/vnd.immervision-ivp': ['ivp'], + 'application/vnd.immervision-ivu': ['ivu'], + 'application/vnd.insors.igm': ['igm'], + 'application/vnd.intercon.formnet': ['xpw', 'xpx'], + 'application/vnd.intergeo': ['i2g'], + 'application/vnd.intu.qbo': ['qbo'], + 'application/vnd.intu.qfx': ['qfx'], + 'application/vnd.ipunplugged.rcprofile': ['rcprofile'], + 'application/vnd.irepository.package+xml': ['irp'], + 'application/vnd.is-xpr': ['xpr'], + 'application/vnd.isac.fcs': ['fcs'], + 'application/vnd.jam': ['jam'], + 'application/vnd.jcp.javame.midlet-rms': ['rms'], + 'application/vnd.jisp': ['jisp'], + 'application/vnd.joost.joda-archive': ['joda'], + 'application/vnd.kahootz': ['ktz', 'ktr'], + 'application/vnd.kde.karbon': ['karbon'], + 'application/vnd.kde.kchart': ['chrt'], + 'application/vnd.kde.kformula': ['kfo'], + 'application/vnd.kde.kivio': ['flw'], + 'application/vnd.kde.kontour': ['kon'], + 'application/vnd.kde.kpresenter': ['kpr', 'kpt'], + 'application/vnd.kde.kspread': ['ksp'], + 'application/vnd.kde.kword': ['kwd', 'kwt'], + 'application/vnd.kenameaapp': ['htke'], + 'application/vnd.kidspiration': ['kia'], + 'application/vnd.kinar': ['kne', 'knp'], + 'application/vnd.koan': ['skp', 'skd', 'skt', 'skm'], + 'application/vnd.kodak-descriptor': ['sse'], + 'application/vnd.las.las+xml': ['lasxml'], + 'application/vnd.llamagraphics.life-balance.desktop': ['lbd'], + 'application/vnd.llamagraphics.life-balance.exchange+xml': ['lbe'], + 'application/vnd.lotus-1-2-3': ['123'], + 'application/vnd.lotus-approach': ['apr'], + 'application/vnd.lotus-freelance': ['pre'], + 'application/vnd.lotus-notes': ['nsf'], + 'application/vnd.lotus-organizer': ['org'], + 'application/vnd.lotus-screencam': ['scm'], + 'application/vnd.lotus-wordpro': ['lwp'], + 'application/vnd.macports.portpkg': ['portpkg'], + 'application/vnd.mapbox-vector-tile': ['mvt'], + 'application/vnd.mcd': ['mcd'], + 'application/vnd.medcalcdata': ['mc1'], + 'application/vnd.mediastation.cdkey': ['cdkey'], + 'application/vnd.mfer': ['mwf'], + 'application/vnd.mfmp': ['mfm'], + 'application/vnd.micrografx.flo': ['flo'], + 'application/vnd.micrografx.igx': ['igx'], + 'application/vnd.mif': ['mif'], + 'application/vnd.mobius.daf': ['daf'], + 'application/vnd.mobius.dis': ['dis'], + 'application/vnd.mobius.mbk': ['mbk'], + 'application/vnd.mobius.mqy': ['mqy'], + 'application/vnd.mobius.msl': ['msl'], + 'application/vnd.mobius.plc': ['plc'], + 'application/vnd.mobius.txf': ['txf'], + 'application/vnd.mophun.application': ['mpn'], + 'application/vnd.mophun.certificate': ['mpc'], + 'application/vnd.mozilla.xul+xml': ['xul'], + 'application/vnd.ms-artgalry': ['cil'], + 'application/vnd.ms-cab-compressed': ['cab'], + 'application/vnd.ms-excel': ['xls', 'xlm', 'xla', 'xlc', 'xlt', 'xlw'], + 'application/vnd.ms-excel.addin.macroenabled.12': ['xlam'], + 'application/vnd.ms-excel.sheet.binary.macroenabled.12': ['xlsb'], + 'application/vnd.ms-excel.sheet.macroenabled.12': ['xlsm'], + 'application/vnd.ms-excel.template.macroenabled.12': ['xltm'], + 'application/vnd.ms-fontobject': ['eot'], + 'application/vnd.ms-htmlhelp': ['chm'], + 'application/vnd.ms-ims': ['ims'], + 'application/vnd.ms-lrm': ['lrm'], + 'application/vnd.ms-officetheme': ['thmx'], + 'application/vnd.ms-outlook': ['msg'], + 'application/vnd.ms-pki.seccat': ['cat'], + 'application/vnd.ms-pki.stl': ['stl'], + 'application/vnd.ms-powerpoint': ['ppt', 'pps', 'pot'], + 'application/vnd.ms-powerpoint.addin.macroenabled.12': ['ppam'], + 'application/vnd.ms-powerpoint.presentation.macroenabled.12': ['pptm'], + 'application/vnd.ms-powerpoint.slide.macroenabled.12': ['sldm'], + 'application/vnd.ms-powerpoint.slideshow.macroenabled.12': ['ppsm'], + 'application/vnd.ms-powerpoint.template.macroenabled.12': ['potm'], + 'application/vnd.ms-project': ['mpp', 'mpt'], + 'application/vnd.ms-visio.viewer': ['vdx'], + 'application/vnd.ms-word.document.macroenabled.12': ['docm'], + 'application/vnd.ms-word.template.macroenabled.12': ['dotm'], + 'application/vnd.ms-works': ['wps', 'wks', 'wcm', 'wdb'], + 'application/vnd.ms-wpl': ['wpl'], + 'application/vnd.ms-xpsdocument': ['xps'], + 'application/vnd.mseq': ['mseq'], + 'application/vnd.musician': ['mus'], + 'application/vnd.muvee.style': ['msty'], + 'application/vnd.mynfc': ['taglet'], + 'application/vnd.nato.bindingdataobject+xml': ['bdo'], + 'application/vnd.neurolanguage.nlu': ['nlu'], + 'application/vnd.nitf': ['ntf', 'nitf'], + 'application/vnd.noblenet-directory': ['nnd'], + 'application/vnd.noblenet-sealer': ['nns'], + 'application/vnd.noblenet-web': ['nnw'], + 'application/vnd.nokia.n-gage.ac+xml': ['ac'], + 'application/vnd.nokia.n-gage.data': ['ngdat'], + 'application/vnd.nokia.n-gage.symbian.install': ['n-gage'], + 'application/vnd.nokia.radio-preset': ['rpst'], + 'application/vnd.nokia.radio-presets': ['rpss'], + 'application/vnd.novadigm.edm': ['edm'], + 'application/vnd.novadigm.edx': ['edx'], + 'application/vnd.novadigm.ext': ['ext'], + 'application/vnd.oasis.opendocument.chart': ['odc'], + 'application/vnd.oasis.opendocument.chart-template': ['otc'], + 'application/vnd.oasis.opendocument.database': ['odb'], + 'application/vnd.oasis.opendocument.formula': ['odf'], + 'application/vnd.oasis.opendocument.formula-template': ['odft'], + 'application/vnd.oasis.opendocument.graphics': ['odg'], + 'application/vnd.oasis.opendocument.graphics-template': ['otg'], + 'application/vnd.oasis.opendocument.image': ['odi'], + 'application/vnd.oasis.opendocument.image-template': ['oti'], + 'application/vnd.oasis.opendocument.presentation': ['odp'], + 'application/vnd.oasis.opendocument.presentation-template': ['otp'], + 'application/vnd.oasis.opendocument.spreadsheet': ['ods'], + 'application/vnd.oasis.opendocument.spreadsheet-template': ['ots'], + 'application/vnd.oasis.opendocument.text': ['odt'], + 'application/vnd.oasis.opendocument.text-master': ['odm'], + 'application/vnd.oasis.opendocument.text-template': ['ott'], + 'application/vnd.oasis.opendocument.text-web': ['oth'], + 'application/vnd.olpc-sugar': ['xo'], + 'application/vnd.oma.dd2+xml': ['dd2'], + 'application/vnd.openblox.game+xml': ['obgx'], + 'application/vnd.openofficeorg.extension': ['oxt'], + 'application/vnd.openstreetmap.data+xml': ['osm'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'], + 'application/vnd.openxmlformats-officedocument.presentationml.slide': ['sldx'], + 'application/vnd.openxmlformats-officedocument.presentationml.slideshow': ['ppsx'], + 'application/vnd.openxmlformats-officedocument.presentationml.template': ['potx'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template': ['xltx'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': ['dotx'], + 'application/vnd.osgeo.mapguide.package': ['mgp'], + 'application/vnd.osgi.dp': ['dp'], + 'application/vnd.osgi.subsystem': ['esa'], + 'application/vnd.palm': ['pdb', 'pqa', 'oprc'], + 'application/vnd.pawaafile': ['paw'], + 'application/vnd.pg.format': ['str'], + 'application/vnd.pg.osasli': ['ei6'], + 'application/vnd.picsel': ['efif'], + 'application/vnd.pmi.widget': ['wg'], + 'application/vnd.pocketlearn': ['plf'], + 'application/vnd.powerbuilder6': ['pbd'], + 'application/vnd.previewsystems.box': ['box'], + 'application/vnd.procrate.brushset': ['brushset'], + 'application/vnd.procreate.brush': ['brush'], + 'application/vnd.procreate.dream': ['drm'], + 'application/vnd.proteus.magazine': ['mgz'], + 'application/vnd.publishare-delta-tree': ['qps'], + 'application/vnd.pvi.ptid1': ['ptid'], + 'application/vnd.pwg-xhtml-print+xml': ['xhtm'], + 'application/vnd.quark.quarkxpress': ['qxd', 'qxt', 'qwd', 'qwt', 'qxl', 'qxb'], + 'application/vnd.rar': ['rar'], + 'application/vnd.realvnc.bed': ['bed'], + 'application/vnd.recordare.musicxml': ['mxl'], + 'application/vnd.recordare.musicxml+xml': ['musicxml'], + 'application/vnd.rig.cryptonote': ['cryptonote'], + 'application/vnd.rim.cod': ['cod'], + 'application/vnd.rn-realmedia': ['rm'], + 'application/vnd.rn-realmedia-vbr': ['rmvb'], + 'application/vnd.route66.link66+xml': ['link66'], + 'application/vnd.sailingtracker.track': ['st'], + 'application/vnd.seemail': ['see'], + 'application/vnd.sema': ['sema'], + 'application/vnd.semd': ['semd'], + 'application/vnd.semf': ['semf'], + 'application/vnd.shana.informed.formdata': ['ifm'], + 'application/vnd.shana.informed.formtemplate': ['itp'], + 'application/vnd.shana.informed.interchange': ['iif'], + 'application/vnd.shana.informed.package': ['ipk'], + 'application/vnd.simtech-mindmapper': ['twd', 'twds'], + 'application/vnd.smaf': ['mmf'], + 'application/vnd.smart.teacher': ['teacher'], + 'application/vnd.software602.filler.form+xml': ['fo'], + 'application/vnd.solent.sdkm+xml': ['sdkm', 'sdkd'], + 'application/vnd.spotfire.dxp': ['dxp'], + 'application/vnd.spotfire.sfs': ['sfs'], + 'application/vnd.stardivision.calc': ['sdc'], + 'application/vnd.stardivision.draw': ['sda'], + 'application/vnd.stardivision.impress': ['sdd'], + 'application/vnd.stardivision.math': ['smf'], + 'application/vnd.stardivision.writer': ['sdw', 'vor'], + 'application/vnd.stardivision.writer-global': ['sgl'], + 'application/vnd.stepmania.package': ['smzip'], + 'application/vnd.stepmania.stepchart': ['sm'], + 'application/vnd.sun.wadl+xml': ['wadl'], + 'application/vnd.sun.xml.calc': ['sxc'], + 'application/vnd.sun.xml.calc.template': ['stc'], + 'application/vnd.sun.xml.draw': ['sxd'], + 'application/vnd.sun.xml.draw.template': ['std'], + 'application/vnd.sun.xml.impress': ['sxi'], + 'application/vnd.sun.xml.impress.template': ['sti'], + 'application/vnd.sun.xml.math': ['sxm'], + 'application/vnd.sun.xml.writer': ['sxw'], + 'application/vnd.sun.xml.writer.global': ['sxg'], + 'application/vnd.sun.xml.writer.template': ['stw'], + 'application/vnd.sus-calendar': ['sus', 'susp'], + 'application/vnd.svd': ['svd'], + 'application/vnd.symbian.install': ['sis', 'sisx'], + 'application/vnd.syncml+xml': ['xsm'], + 'application/vnd.syncml.dm+wbxml': ['bdm'], + 'application/vnd.syncml.dm+xml': ['xdm'], + 'application/vnd.syncml.dmddf+xml': ['ddf'], + 'application/vnd.tao.intent-module-archive': ['tao'], + 'application/vnd.tcpdump.pcap': ['pcap', 'cap', 'dmp'], + 'application/vnd.tmobile-livetv': ['tmo'], + 'application/vnd.trid.tpt': ['tpt'], + 'application/vnd.triscape.mxs': ['mxs'], + 'application/vnd.trueapp': ['tra'], + 'application/vnd.ufdl': ['ufd', 'ufdl'], + 'application/vnd.uiq.theme': ['utz'], + 'application/vnd.umajin': ['umj'], + 'application/vnd.unity': ['unityweb'], + 'application/vnd.uoml+xml': ['uoml', 'uo'], + 'application/vnd.vcx': ['vcx'], + 'application/vnd.visio': ['vsd', 'vst', 'vss', 'vsw', 'vsdx', 'vtx'], + 'application/vnd.visionary': ['vis'], + 'application/vnd.vsf': ['vsf'], + 'application/vnd.wap.wbxml': ['wbxml'], + 'application/vnd.wap.wmlc': ['wmlc'], + 'application/vnd.wap.wmlscriptc': ['wmlsc'], + 'application/vnd.webturbo': ['wtb'], + 'application/vnd.wolfram.player': ['nbp'], + 'application/vnd.wordperfect': ['wpd'], + 'application/vnd.wqd': ['wqd'], + 'application/vnd.wt.stf': ['stf'], + 'application/vnd.xara': ['xar'], + 'application/vnd.xfdl': ['xfdl'], + 'application/vnd.yamaha.hv-dic': ['hvd'], + 'application/vnd.yamaha.hv-script': ['hvs'], + 'application/vnd.yamaha.hv-voice': ['hvp'], + 'application/vnd.yamaha.openscoreformat': ['osf'], + 'application/vnd.yamaha.openscoreformat.osfpvg+xml': ['osfpvg'], + 'application/vnd.yamaha.smaf-audio': ['saf'], + 'application/vnd.yamaha.smaf-phrase': ['spf'], + 'application/vnd.yellowriver-custom-menu': ['cmp'], + 'application/vnd.zul': ['zir', 'zirz'], + 'application/vnd.zzazz.deck+xml': ['zaz'], + 'application/voicexml+xml': ['vxml'], + 'application/wasm': ['wasm'], + 'application/watcherinfo+xml': ['wif'], + 'application/widget': ['wgt'], + 'application/winhlp': ['hlp'], + 'application/wsdl+xml': ['wsdl'], + 'application/wspolicy+xml': ['wspolicy'], + 'application/x-7z-compressed': ['7z'], + 'application/x-abiword': ['abw'], + 'application/x-ace-compressed': ['ace'], + 'application/x-apple-diskimage': ['dmg'], + 'application/x-arj': ['arj'], + 'application/x-authorware-bin': ['aab', 'x32', 'u32', 'vox'], + 'application/x-authorware-map': ['aam'], + 'application/x-authorware-seg': ['aas'], + 'application/x-bcpio': ['bcpio'], + 'application/x-bdoc': ['bdoc'], + 'application/x-bittorrent': ['torrent'], + 'application/x-blender': ['blend'], + 'application/x-blorb': ['blb', 'blorb'], + 'application/x-bzip': ['bz'], + 'application/x-bzip2': ['bz2', 'boz'], + 'application/x-cbr': ['cbr', 'cba', 'cbt', 'cbz', 'cb7'], + 'application/x-cdlink': ['vcd'], + 'application/x-cfs-compressed': ['cfs'], + 'application/x-chat': ['chat'], + 'application/x-chess-pgn': ['pgn'], + 'application/x-chrome-extension': ['crx'], + 'application/x-cocoa': ['cco'], + 'application/x-compressed': ['rar'], + 'application/x-conference': ['nsc'], + 'application/x-cpio': ['cpio'], + 'application/x-csh': ['csh'], + 'application/x-debian-package': ['deb', 'udeb'], + 'application/x-dgc-compressed': ['dgc'], + 'application/x-director': ['dir', 'dcr', 'dxr', 'cst', 'cct', 'cxt', 'w3d', 'fgd', 'swa'], + 'application/x-doom': ['wad'], + 'application/x-dtbncx+xml': ['ncx'], + 'application/x-dtbook+xml': ['dtb'], + 'application/x-dtbresource+xml': ['res'], + 'application/x-dvi': ['dvi'], + 'application/x-envoy': ['evy'], + 'application/x-eva': ['eva'], + 'application/x-font-bdf': ['bdf'], + 'application/x-font-ghostscript': ['gsf'], + 'application/x-font-linux-psf': ['psf'], + 'application/x-font-pcf': ['pcf'], + 'application/x-font-snf': ['snf'], + 'application/x-font-type1': ['pfa', 'pfb', 'pfm', 'afm'], + 'application/x-freearc': ['arc'], + 'application/x-futuresplash': ['spl'], + 'application/x-gca-compressed': ['gca'], + 'application/x-glulx': ['ulx'], + 'application/x-gnumeric': ['gnumeric'], + 'application/x-gramps-xml': ['gramps'], + 'application/x-gtar': ['gtar'], + 'application/x-hdf': ['hdf'], + 'application/x-httpd-php': ['php'], + 'application/x-install-instructions': ['install'], + 'application/x-ipynb+json': ['ipynb'], + 'application/x-iso9660-image': ['iso'], + 'application/x-iwork-keynote-sffkey': ['key'], + 'application/x-iwork-numbers-sffnumbers': ['numbers'], + 'application/x-iwork-pages-sffpages': ['pages'], + 'application/x-java-archive-diff': ['jardiff'], + 'application/x-java-jnlp-file': ['jnlp'], + 'application/x-keepass2': ['kdbx'], + 'application/x-latex': ['latex'], + 'application/x-lua-bytecode': ['luac'], + 'application/x-lzh-compressed': ['lzh', 'lha'], + 'application/x-makeself': ['run'], + 'application/x-mie': ['mie'], + 'application/x-mobipocket-ebook': ['prc', 'mobi'], + 'application/x-ms-application': ['application'], + 'application/x-ms-shortcut': ['lnk'], + 'application/x-ms-wmd': ['wmd'], + 'application/x-ms-wmz': ['wmz'], + 'application/x-ms-xbap': ['xbap'], + 'application/x-msaccess': ['mdb'], + 'application/x-msbinder': ['obd'], + 'application/x-mscardfile': ['crd'], + 'application/x-msclip': ['clp'], + 'application/x-msdos-program': ['exe'], + 'application/x-msdownload': ['exe', 'dll', 'com', 'bat', 'msi'], + 'application/x-msmediaview': ['mvb', 'm13', 'm14'], + 'application/x-msmetafile': ['wmf', 'wmz', 'emf', 'emz'], + 'application/x-msmoney': ['mny'], + 'application/x-mspublisher': ['pub'], + 'application/x-msschedule': ['scd'], + 'application/x-msterminal': ['trm'], + 'application/x-mswrite': ['wri'], + 'application/x-netcdf': ['nc', 'cdf'], + 'application/x-ns-proxy-autoconfig': ['pac'], + 'application/x-nzb': ['nzb'], + 'application/x-perl': ['pl', 'pm'], + 'application/x-pilot': ['prc', 'pdb'], + 'application/x-pkcs12': ['p12', 'pfx'], + 'application/x-pkcs7-certificates': ['p7b', 'spc'], + 'application/x-pkcs7-certreqresp': ['p7r'], + 'application/x-rar-compressed': ['rar'], + 'application/x-redhat-package-manager': ['rpm'], + 'application/x-research-info-systems': ['ris'], + 'application/x-sea': ['sea'], + 'application/x-sh': ['sh'], + 'application/x-shar': ['shar'], + 'application/x-shockwave-flash': ['swf'], + 'application/x-silverlight-app': ['xap'], + 'application/x-sql': ['sql'], + 'application/x-stuffit': ['sit'], + 'application/x-stuffitx': ['sitx'], + 'application/x-subrip': ['srt'], + 'application/x-sv4cpio': ['sv4cpio'], + 'application/x-sv4crc': ['sv4crc'], + 'application/x-t3vm-image': ['t3'], + 'application/x-tads': ['gam'], + 'application/x-tar': ['tar'], + 'application/x-tcl': ['tcl', 'tk'], + 'application/x-tex': ['tex'], + 'application/x-tex-tfm': ['tfm'], + 'application/x-texinfo': ['texinfo', 'texi'], + 'application/x-tgif': ['obj'], + 'application/x-ustar': ['ustar'], + 'application/x-virtualbox-hdd': ['hdd'], + 'application/x-virtualbox-ova': ['ova'], + 'application/x-virtualbox-ovf': ['ovf'], + 'application/x-virtualbox-vbox': ['vbox'], + 'application/x-virtualbox-vbox-extpack': ['vbox-extpack'], + 'application/x-virtualbox-vdi': ['vdi'], + 'application/x-virtualbox-vhd': ['vhd'], + 'application/x-virtualbox-vmdk': ['vmdk'], + 'application/x-wais-source': ['src'], + 'application/x-web-app-manifest+json': ['webapp'], + 'application/x-x509-ca-cert': ['der', 'crt', 'pem'], + 'application/x-xfig': ['fig'], + 'application/x-xliff+xml': ['xlf'], + 'application/x-xpinstall': ['xpi'], + 'application/x-xz': ['xz'], + 'application/x-zip-compressed': ['zip'], + 'application/x-zmachine': ['z1', 'z2', 'z3', 'z4', 'z5', 'z6', 'z7', 'z8'], + 'application/xaml+xml': ['xaml'], + 'application/xcap-att+xml': ['xav'], + 'application/xcap-caps+xml': ['xca'], + 'application/xcap-diff+xml': ['xdf'], + 'application/xcap-el+xml': ['xel'], + 'application/xcap-ns+xml': ['xns'], + 'application/xenc+xml': ['xenc'], + 'application/xfdf': ['xfdf'], + 'application/xhtml+xml': ['xhtml', 'xht'], + 'application/xliff+xml': ['xlf'], + 'application/xml': ['xml', 'xsl', 'xsd', 'rng'], + 'application/xml-dtd': ['dtd'], + 'application/xop+xml': ['xop'], + 'application/xproc+xml': ['xpl'], + 'application/xslt+xml': ['xsl', 'xslt'], + 'application/xspf+xml': ['xspf'], + 'application/xv+xml': ['mxml', 'xhvml', 'xvml', 'xvm'], + 'application/yang': ['yang'], + 'application/yin+xml': ['yin'], + 'application/zip': ['zip'], + 'application/zip+dotlottie': ['lottie'], + 'audio/3gpp': ['3gpp'], + 'audio/aac': ['adts', 'aac'], + 'audio/adpcm': ['adp'], + 'audio/amr': ['amr'], + 'audio/basic': ['au', 'snd'], + 'audio/midi': ['mid', 'midi', 'kar', 'rmi'], + 'audio/mobile-xmf': ['mxmf'], + 'audio/mp3': ['mp3'], + 'audio/mp4': ['m4a', 'mp4a', 'm4b'], + 'audio/mpeg': ['mpga', 'mp2', 'mp2a', 'mp3', 'm2a', 'm3a'], + 'audio/ogg': ['oga', 'ogg', 'spx', 'opus'], + 'audio/s3m': ['s3m'], + 'audio/silk': ['sil'], + 'audio/vnd.dece.audio': ['uva', 'uvva'], + 'audio/vnd.digital-winds': ['eol'], + 'audio/vnd.dra': ['dra'], + 'audio/vnd.dts': ['dts'], + 'audio/vnd.dts.hd': ['dtshd'], + 'audio/vnd.lucent.voice': ['lvp'], + 'audio/vnd.ms-playready.media.pya': ['pya'], + 'audio/vnd.nuera.ecelp4800': ['ecelp4800'], + 'audio/vnd.nuera.ecelp7470': ['ecelp7470'], + 'audio/vnd.nuera.ecelp9600': ['ecelp9600'], + 'audio/vnd.rip': ['rip'], + 'audio/wav': ['wav'], + 'audio/wave': ['wav'], + 'audio/webm': ['weba'], + 'audio/x-aac': ['aac'], + 'audio/x-aiff': ['aif', 'aiff', 'aifc'], + 'audio/x-caf': ['caf'], + 'audio/x-flac': ['flac'], + 'audio/x-m4a': ['m4a'], + 'audio/x-matroska': ['mka'], + 'audio/x-mpegurl': ['m3u'], + 'audio/x-ms-wax': ['wax'], + 'audio/x-ms-wma': ['wma'], + 'audio/x-pn-realaudio': ['ram', 'ra'], + 'audio/x-pn-realaudio-plugin': ['rmp'], + 'audio/x-realaudio': ['ra'], + 'audio/x-wav': ['wav'], + 'audio/xm': ['xm'], + 'chemical/x-cdx': ['cdx'], + 'chemical/x-cif': ['cif'], + 'chemical/x-cmdf': ['cmdf'], + 'chemical/x-cml': ['cml'], + 'chemical/x-csml': ['csml'], + 'chemical/x-xyz': ['xyz'], + 'font/collection': ['ttc'], + 'font/otf': ['otf'], + 'font/ttf': ['ttf'], + 'font/woff': ['woff'], + 'font/woff2': ['woff2'], + 'image/aces': ['exr'], + 'image/apng': ['apng'], + 'image/avci': ['avci'], + 'image/avcs': ['avcs'], + 'image/avif': ['avif'], + 'image/bmp': ['bmp', 'dib'], + 'image/cgm': ['cgm'], + 'image/dicom-rle': ['drle'], + 'image/dpx': ['dpx'], + 'image/emf': ['emf'], + 'image/fits': ['fits'], + 'image/g3fax': ['g3'], + 'image/gif': ['gif'], + 'image/heic': ['heic'], + 'image/heic-sequence': ['heics'], + 'image/heif': ['heif'], + 'image/heif-sequence': ['heifs'], + 'image/hej2k': ['hej2'], + 'image/ief': ['ief'], + 'image/jaii': ['jaii'], + 'image/jais': ['jais'], + 'image/jls': ['jls'], + 'image/jp2': ['jp2', 'jpg2'], + 'image/jpeg': ['jpg', 'jpeg', 'jpe'], + 'image/jph': ['jph'], + 'image/jphc': ['jhc'], + 'image/jpm': ['jpm', 'jpgm'], + 'image/jpx': ['jpx', 'jpf'], + 'image/jxl': ['jxl'], + 'image/jxr': ['jxr'], + 'image/jxra': ['jxra'], + 'image/jxrs': ['jxrs'], + 'image/jxs': ['jxs'], + 'image/jxsc': ['jxsc'], + 'image/jxsi': ['jxsi'], + 'image/jxss': ['jxss'], + 'image/ktx': ['ktx'], + 'image/ktx2': ['ktx2'], + 'image/pjpeg': ['jfif'], + 'image/png': ['png'], + 'image/prs.btif': ['btif', 'btf'], + 'image/prs.pti': ['pti'], + 'image/sgi': ['sgi'], + 'image/svg+xml': ['svg', 'svgz'], + 'image/t38': ['t38'], + 'image/tiff': ['tif', 'tiff'], + 'image/tiff-fx': ['tfx'], + 'image/vnd.adobe.photoshop': ['psd'], + 'image/vnd.airzip.accelerator.azv': ['azv'], + 'image/vnd.dece.graphic': ['uvi', 'uvvi', 'uvg', 'uvvg'], + 'image/vnd.djvu': ['djvu', 'djv'], + 'image/vnd.dvb.subtitle': ['sub'], + 'image/vnd.dwg': ['dwg'], + 'image/vnd.dxf': ['dxf'], + 'image/vnd.fastbidsheet': ['fbs'], + 'image/vnd.fpx': ['fpx'], + 'image/vnd.fst': ['fst'], + 'image/vnd.fujixerox.edmics-mmr': ['mmr'], + 'image/vnd.fujixerox.edmics-rlc': ['rlc'], + 'image/vnd.microsoft.icon': ['ico'], + 'image/vnd.ms-dds': ['dds'], + 'image/vnd.ms-modi': ['mdi'], + 'image/vnd.ms-photo': ['wdp'], + 'image/vnd.net-fpx': ['npx'], + 'image/vnd.pco.b16': ['b16'], + 'image/vnd.tencent.tap': ['tap'], + 'image/vnd.valve.source.texture': ['vtf'], + 'image/vnd.wap.wbmp': ['wbmp'], + 'image/vnd.xiff': ['xif'], + 'image/vnd.zbrush.pcx': ['pcx'], + 'image/webp': ['webp'], + 'image/wmf': ['wmf'], + 'image/x-3ds': ['3ds'], + 'image/x-adobe-dng': ['dng'], + 'image/x-cmu-raster': ['ras'], + 'image/x-cmx': ['cmx'], + 'image/x-freehand': ['fh', 'fhc', 'fh4', 'fh5', 'fh7'], + 'image/x-icon': ['ico'], + 'image/x-jng': ['jng'], + 'image/x-mrsid-image': ['sid'], + 'image/x-ms-bmp': ['bmp'], + 'image/x-pcx': ['pcx'], + 'image/x-pict': ['pic', 'pct'], + 'image/x-portable-anymap': ['pnm'], + 'image/x-portable-bitmap': ['pbm'], + 'image/x-portable-graymap': ['pgm'], + 'image/x-portable-pixmap': ['ppm'], + 'image/x-rgb': ['rgb'], + 'image/x-tga': ['tga'], + 'image/x-xbitmap': ['xbm'], + 'image/x-xpixmap': ['xpm'], + 'image/x-xwindowdump': ['xwd'], + 'message/disposition-notification': ['disposition-notification'], + 'message/global': ['u8msg'], + 'message/global-delivery-status': ['u8dsn'], + 'message/global-disposition-notification': ['u8mdn'], + 'message/global-headers': ['u8hdr'], + 'message/rfc822': ['eml', 'mime', 'mht', 'mhtml'], + 'message/vnd.wfa.wsc': ['wsc'], + 'model/3mf': ['3mf'], + 'model/gltf+json': ['gltf'], + 'model/gltf-binary': ['glb'], + 'model/iges': ['igs', 'iges'], + 'model/jt': ['jt'], + 'model/mesh': ['msh', 'mesh', 'silo'], + 'model/mtl': ['mtl'], + 'model/obj': ['obj'], + 'model/prc': ['prc'], + 'model/step': ['step', 'stp', 'stpnc', 'p21', '210'], + 'model/step+xml': ['stpx'], + 'model/step+zip': ['stpz'], + 'model/step-xml+zip': ['stpxz'], + 'model/stl': ['stl'], + 'model/u3d': ['u3d'], + 'model/vnd.bary': ['bary'], + 'model/vnd.cld': ['cld'], + 'model/vnd.collada+xml': ['dae'], + 'model/vnd.dwf': ['dwf'], + 'model/vnd.gdl': ['gdl'], + 'model/vnd.gtw': ['gtw'], + 'model/vnd.mts': ['mts'], + 'model/vnd.opengex': ['ogex'], + 'model/vnd.parasolid.transmit.binary': ['x_b'], + 'model/vnd.parasolid.transmit.text': ['x_t'], + 'model/vnd.pytha.pyox': ['pyo', 'pyox'], + 'model/vnd.sap.vds': ['vds'], + 'model/vnd.usda': ['usda'], + 'model/vnd.usdz+zip': ['usdz'], + 'model/vnd.valve.source.compiled-map': ['bsp'], + 'model/vnd.vtu': ['vtu'], + 'model/vrml': ['wrl', 'vrml'], + 'model/x3d+binary': ['x3db', 'x3dbz'], + 'model/x3d+fastinfoset': ['x3db'], + 'model/x3d+vrml': ['x3dv', 'x3dvz'], + 'model/x3d+xml': ['x3d', 'x3dz'], + 'model/x3d-vrml': ['x3dv'], + 'text/cache-manifest': ['appcache', 'manifest'], + 'text/calendar': ['ics', 'ifb'], + 'text/coffeescript': ['coffee', 'litcoffee'], + 'text/css': ['css'], + 'text/csv': ['csv'], + 'text/html': ['html', 'htm', 'shtml'], + 'text/jade': ['jade'], + 'text/javascript': ['js', 'mjs'], + 'text/jsx': ['jsx'], + 'text/less': ['less'], + 'text/markdown': ['md', 'markdown'], + 'text/mathml': ['mml'], + 'text/mdx': ['mdx'], + 'text/n3': ['n3'], + 'text/plain': ['txt', 'text', 'conf', 'def', 'list', 'log', 'in', 'ini'], + 'text/prs.lines.tag': ['dsc'], + 'text/richtext': ['rtx'], + 'text/rtf': ['rtf'], + 'text/sgml': ['sgml', 'sgm'], + 'text/shex': ['shex'], + 'text/slim': ['slim', 'slm'], + 'text/spdx': ['spdx'], + 'text/stylus': ['stylus', 'styl'], + 'text/tab-separated-values': ['tsv'], + 'text/troff': ['t', 'tr', 'roff', 'man', 'me', 'ms'], + 'text/turtle': ['ttl'], + 'text/uri-list': ['uri', 'uris', 'urls'], + 'text/vcard': ['vcard'], + 'text/vnd.curl': ['curl'], + 'text/vnd.curl.dcurl': ['dcurl'], + 'text/vnd.curl.mcurl': ['mcurl'], + 'text/vnd.curl.scurl': ['scurl'], + 'text/vnd.dvb.subtitle': ['sub'], + 'text/vnd.familysearch.gedcom': ['ged'], + 'text/vnd.fly': ['fly'], + 'text/vnd.fmi.flexstor': ['flx'], + 'text/vnd.graphviz': ['gv'], + 'text/vnd.in3d.3dml': ['3dml'], + 'text/vnd.in3d.spot': ['spot'], + 'text/vnd.sun.j2me.app-descriptor': ['jad'], + 'text/vnd.wap.wml': ['wml'], + 'text/vnd.wap.wmlscript': ['wmls'], + 'text/vtt': ['vtt'], + 'text/wgsl': ['wgsl'], + 'text/x-asm': ['s', 'asm'], + 'text/x-c': ['c', 'cc', 'cxx', 'cpp', 'h', 'hh', 'dic'], + 'text/x-component': ['htc'], + 'text/x-fortran': ['f', 'for', 'f77', 'f90'], + 'text/x-handlebars-template': ['hbs'], + 'text/x-java-source': ['java'], + 'text/x-lua': ['lua'], + 'text/x-markdown': ['mkd'], + 'text/x-nfo': ['nfo'], + 'text/x-opml': ['opml'], + 'text/x-org': ['org'], + 'text/x-pascal': ['p', 'pas'], + 'text/x-processing': ['pde'], + 'text/x-sass': ['sass'], + 'text/x-scss': ['scss'], + 'text/x-setext': ['etx'], + 'text/x-sfv': ['sfv'], + 'text/x-suse-ymp': ['ymp'], + 'text/x-uuencode': ['uu'], + 'text/x-vcalendar': ['vcs'], + 'text/x-vcard': ['vcf'], + 'text/xml': ['xml'], + 'text/yaml': ['yaml', 'yml'], + 'video/3gpp': ['3gp', '3gpp'], + 'video/3gpp2': ['3g2'], + 'video/h261': ['h261'], + 'video/h263': ['h263'], + 'video/h264': ['h264'], + 'video/iso.segment': ['m4s'], + 'video/jpeg': ['jpgv'], + 'video/jpm': ['jpm', 'jpgm'], + 'video/mj2': ['mj2', 'mjp2'], + 'video/mp2t': ['ts', 'm2t', 'm2ts', 'mts'], + 'video/mp4': ['mp4', 'mp4v', 'mpg4'], + 'video/mpeg': ['mpeg', 'mpg', 'mpe', 'm1v', 'm2v'], + 'video/ogg': ['ogv'], + 'video/quicktime': ['qt', 'mov'], + 'video/vnd.dece.hd': ['uvh', 'uvvh'], + 'video/vnd.dece.mobile': ['uvm', 'uvvm'], + 'video/vnd.dece.pd': ['uvp', 'uvvp'], + 'video/vnd.dece.sd': ['uvs', 'uvvs'], + 'video/vnd.dece.video': ['uvv', 'uvvv'], + 'video/vnd.dvb.file': ['dvb'], + 'video/vnd.fvt': ['fvt'], + 'video/vnd.mpegurl': ['mxu', 'm4u'], + 'video/vnd.ms-playready.media.pyv': ['pyv'], + 'video/vnd.uvvu.mp4': ['uvu', 'uvvu'], + 'video/vnd.vivo': ['viv'], + 'video/webm': ['webm'], + 'video/x-f4v': ['f4v'], + 'video/x-fli': ['fli'], + 'video/x-flv': ['flv'], + 'video/x-m4v': ['m4v'], + 'video/x-matroska': ['mkv', 'mk3d', 'mks'], + 'video/x-mng': ['mng'], + 'video/x-ms-asf': ['asf', 'asx'], + 'video/x-ms-vob': ['vob'], + 'video/x-ms-wm': ['wm'], + 'video/x-ms-wmv': ['wmv'], + 'video/x-ms-wmx': ['wmx'], + 'video/x-ms-wvx': ['wvx'], + 'video/x-msvideo': ['avi'], + 'video/x-sgi-movie': ['movie'], + 'video/x-smv': ['smv'], + 'x-conference/x-cooltalk': ['ice'], +} + +/** Media types for which mime-db states a default charset. */ +export const typeCharsets: { [type: string]: string } = { + 'application/3gpdash-qoe-report+xml': 'UTF-8', + 'application/beep+xml': 'UTF-8', + 'application/cda+xml': 'UTF-8', + 'application/elm+json': 'UTF-8', + 'application/emergencycalldata.cap+xml': 'UTF-8', + 'application/fhir+json': 'UTF-8', + 'application/fhir+xml': 'UTF-8', + 'application/hl7v2+xml': 'UTF-8', + 'application/im-iscomposing+xml': 'UTF-8', + 'application/javascript': 'UTF-8', + 'application/json': 'UTF-8', + 'application/manifest+json': 'UTF-8', + 'application/msc-ivr+xml': 'UTF-8', + 'application/msc-mixer+xml': 'UTF-8', + 'application/news-checkgroups': 'US-ASCII', + 'application/news-groupinfo': 'US-ASCII', + 'application/pidf+xml': 'UTF-8', + 'application/pidf-diff+xml': 'UTF-8', + 'application/poc-settings+xml': 'UTF-8', + 'application/prs.cyn': '7-BIT', + 'application/vnd.fdsn.stationxml+xml': 'XML-BASED', + 'application/vnd.omads-email+xml': 'UTF-8', + 'application/vnd.omads-file+xml': 'UTF-8', + 'application/vnd.omads-folder+xml': 'UTF-8', + 'application/vnd.syncml+xml': 'UTF-8', + 'application/vnd.syncml.dm+wbxml': 'UTF-8', + 'application/vnd.syncml.dm+xml': 'UTF-8', + 'application/vnd.syncml.dmddf+xml': 'UTF-8', + 'application/vnd.syncml.dmtnds+xml': 'UTF-8', + 'application/vnd.wap.wbxml': 'UTF-8', + 'text/css': 'UTF-8', + 'text/javascript': 'UTF-8', + 'text/n3': 'UTF-8', + 'text/parameters': 'UTF-8', + 'text/provenance-notation': 'UTF-8', + 'text/turtle': 'UTF-8', + 'text/vnd.debian.copyright': 'UTF-8', + 'text/vnd.esmertec.theme-descriptor': 'UTF-8', + 'text/vnd.sun.j2me.app-descriptor': 'UTF-8', + 'text/vnd.trolltech.linguist': 'UTF-8', + 'text/vtt': 'UTF-8', +} diff --git a/runtime/node/nest/shims/mime-types/index.ts b/runtime/node/nest/shims/mime-types/index.ts new file mode 100644 index 0000000..c138ee5 --- /dev/null +++ b/runtime/node/nest/shims/mime-types/index.ts @@ -0,0 +1,50 @@ +import { extname } from 'node:path' +import { extensionTypes, typeExtensions, typeCharsets } from '../mime-db/db.generated' + +const EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/ +const TEXT_TYPE_REGEXP = /^text\//i + +export const types: { [extension: string]: string } = extensionTypes +export const extensions: { [type: string]: string[] } = typeExtensions + +export function charset(type: string): string | false { + if (!type || typeof type !== 'string') return false + const match = EXTRACT_TYPE_REGEXP.exec(type) + if (match) { + const key = match[1].toLowerCase() + if (Object.hasOwn(typeCharsets, key) && typeCharsets[key] !== '') return typeCharsets[key] + if (TEXT_TYPE_REGEXP.test(match[1])) return 'UTF-8' + } + return false +} + +export const charsets = { lookup: charset } + +export function contentType(str: string): string | false { + if (!str || typeof str !== 'string') return false + const mime = str.indexOf('/') === -1 ? lookup(str) : str + if (mime === false) return false + let value = mime + if (value.indexOf('charset') === -1) { + const found = charset(value) + if (found !== false) value += '; charset=' + found.toLowerCase() + } + return value +} + +export function extension(type: string): string | false { + if (!type || typeof type !== 'string') return false + const match = EXTRACT_TYPE_REGEXP.exec(type) + if (!match) return false + const key = match[1].toLowerCase() + if (!Object.hasOwn(typeExtensions, key)) return false + const exts = typeExtensions[key] + return exts.length === 0 ? false : exts[0] +} + +export function lookup(path: string): string | false { + if (!path || typeof path !== 'string') return false + const ext = extname('x.' + path).toLowerCase().slice(1) + if (!ext) return false + return Object.hasOwn(extensionTypes, ext) ? extensionTypes[ext] : false +} diff --git a/runtime/node/nest/shims/ms/index.ts b/runtime/node/nest/shims/ms/index.ts new file mode 100644 index 0000000..17f6557 --- /dev/null +++ b/runtime/node/nest/shims/ms/index.ts @@ -0,0 +1,100 @@ + +const s = 1000 +const m = s * 60 +const h = m * 60 +const d = h * 24 +const w = d * 7 +const y = d * 365.25 + +export interface MsOptions { + long?: boolean +} + +const DURATION_REGEXP = + /^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i + +function parse(str: string): number | undefined { + if (str.length > 100) return undefined + const match = DURATION_REGEXP.exec(str) + if (match === null) return undefined + const n = parseFloat(match[1]) + const type = (match[2] || 'ms').toLowerCase() + switch (type) { + case 'years': + case 'year': + case 'yrs': + case 'yr': + case 'y': + return n * y + case 'weeks': + case 'week': + case 'w': + return n * w + case 'days': + case 'day': + case 'd': + return n * d + case 'hours': + case 'hour': + case 'hrs': + case 'hr': + case 'h': + return n * h + case 'minutes': + case 'minute': + case 'mins': + case 'min': + case 'm': + return n * m + case 'seconds': + case 'second': + case 'secs': + case 'sec': + case 's': + return n * s + case 'milliseconds': + case 'millisecond': + case 'msecs': + case 'msec': + case 'ms': + return n + default: + return undefined + } +} + +function fmtShort(milliseconds: number): string { + const msAbs = Math.abs(milliseconds) + if (msAbs >= d) return Math.round(milliseconds / d) + 'd' + if (msAbs >= h) return Math.round(milliseconds / h) + 'h' + if (msAbs >= m) return Math.round(milliseconds / m) + 'm' + if (msAbs >= s) return Math.round(milliseconds / s) + 's' + return milliseconds + 'ms' +} + +function fmtLong(milliseconds: number): string { + const msAbs = Math.abs(milliseconds) + if (msAbs >= d) return plural(milliseconds, msAbs, d, 'day') + if (msAbs >= h) return plural(milliseconds, msAbs, h, 'hour') + if (msAbs >= m) return plural(milliseconds, msAbs, m, 'minute') + if (msAbs >= s) return plural(milliseconds, msAbs, s, 'second') + return milliseconds + ' ms' +} + +function plural(milliseconds: number, msAbs: number, n: number, name: string): string { + const isPlural = msAbs >= n * 1.5 + return Math.round(milliseconds / n) + ' ' + name + (isPlural ? 's' : '') +} + +function ms(value: string, options?: MsOptions): number | undefined +function ms(value: number, options?: MsOptions): string +function ms(value: string | number, options: MsOptions = {}): string | number | undefined { + if (typeof value === 'string') { + if (value.length > 0) return parse(value) + } else if (isFinite(value)) { + return options.long ? fmtLong(value) : fmtShort(value) + } + throw new Error('val is not a non-empty string or a valid number. val=' + JSON.stringify(value)) +} + +export default ms diff --git a/runtime/node/nest/shims/on-finished/index.ts b/runtime/node/nest/shims/on-finished/index.ts new file mode 100644 index 0000000..4b430ed --- /dev/null +++ b/runtime/node/nest/shims/on-finished/index.ts @@ -0,0 +1,103 @@ +import { ServerResponse, type IncomingMessage } from 'node:http' + +export type Message = IncomingMessage | ServerResponse +export type FinishedListener = (err: unknown, msg: T) => void + +interface Emitter { + on(name: string, fn: (...args: unknown[]) => void): unknown + removeListener(name: string, fn: (...args: unknown[]) => void): unknown +} + +class FirstListener { + private readonly cleanups: { ee: Emitter; event: string; fn: (...args: unknown[]) => void }[] = [] + private done: (err: unknown) => void + + constructor(sources: { ee: Emitter; events: string[] }[], done: (err: unknown) => void) { + this.done = done + for (const source of sources) { + const ee = source.ee + for (const event of source.events) { + const fn = (...args: unknown[]) => this.fire(event === 'error' ? args[0] : null) + ee.on(event, fn) + this.cleanups.push({ ee, event, fn }) + } + } + } + + private fire(err: unknown): void { + this.cancel() + this.done(err) + } + + cancel(): void { + for (const x of this.cleanups) x.ee.removeListener(x.event, x.fn) + } +} + +interface Attached { + queue: ((err: unknown) => void)[] | null +} + +const attachedListeners = new WeakMap() + +export function isFinished(msg: Message): boolean | undefined { + const socket = msg.socket + if (msg instanceof ServerResponse) { + return Boolean(msg.finished || (socket && !socket.writable)) + } + if (typeof msg.complete === 'boolean') { + return Boolean(!socket || !socket.readable || (msg.complete && !msg.readable)) + } + return undefined +} + +export default function onFinished(msg: T, listener: FinishedListener): T { + if (isFinished(msg) !== false) { + setImmediate(() => listener(null, msg)) + return msg + } + attachListener(msg, (err) => listener(err, msg)) + return msg +} + +function attachListener(msg: Message, listener: (err: unknown) => void): void { + let attached = attachedListeners.get(msg) + if (attached === undefined || attached.queue === null) { + const created: Attached = { queue: [] } + attachedListeners.set(msg, created) + attachFinishedListener(msg, (err) => { + if (attachedListeners.get(msg) === created) attachedListeners.delete(msg) + const queue = created.queue + if (queue === null) return + created.queue = null + for (const fn of queue) fn(err) + }) + attached = created + } + attached.queue!.push(listener) +} + +function attachFinishedListener(msg: Message, callback: (err: unknown) => void): void { + let finished = false + let eeMsg: FirstListener + let eeSocket: FirstListener + const onFinish = (error: unknown) => { + eeMsg.cancel() + eeSocket.cancel() + finished = true + callback(error) + } + eeMsg = eeSocket = new FirstListener([{ ee: msg, events: ['end', 'finish'] }], onFinish) + const onSocket = (...args: unknown[]) => { + const socket = args[0] + msg.removeListener('socket', onSocket) + if (finished) return + if (eeMsg !== eeSocket) return + if (socket !== null && typeof socket === 'object') eeSocket = new FirstListener([{ ee: socket as Emitter, events: ['error', 'close'] }], onFinish) + } + if (msg.socket) { + onSocket(msg.socket) + return + } + msg.on('socket', onSocket) +} diff --git a/runtime/node/nest/shims/path-to-regexp/index.ts b/runtime/node/nest/shims/path-to-regexp/index.ts new file mode 100644 index 0000000..a0750b2 --- /dev/null +++ b/runtime/node/nest/shims/path-to-regexp/index.ts @@ -0,0 +1,543 @@ + +const DEFAULT_DELIMITER = '/' +const NOOP_VALUE = (value: string): string => value +const ID_START = /^[$_A-Za-z]$/ +const ID_CONTINUE = /^[$‌‍0-9A-Za-z_]$/ +const ID = /^[$_A-Za-z][$‌‍0-9A-Za-z_]*$/ + +export type Encode = (value: string) => string + +export type Decode = (value: string) => string + +export interface ParseOptions { + encodePath?: Encode +} + +export interface PathToRegexpOptions { + end?: boolean + trailing?: boolean + sensitive?: boolean + delimiter?: string +} + +export interface MatchOptions extends PathToRegexpOptions { + decode?: Decode | false +} + +export interface CompileOptions { + encode?: Encode | false + delimiter?: string +} + +export interface CompileAndParseOptions extends CompileOptions, ParseOptions {} + +export interface MatchAndParseOptions extends MatchOptions, ParseOptions {} + +export interface PathToRegexpAndParseOptions extends PathToRegexpOptions, ParseOptions {} + +function escapeText(str: string): string { + return str.replace(/[{}()\[\]+?!:*\\]/g, '\\$&') +} + +function escape(str: string): string { + return str.replace(/[.+*?^${}()[\]|/\\]/g, '\\$&') +} + +export interface Text { + type: 'text' + value: string +} + +export interface Parameter { + type: 'param' + name: string +} + +export interface Wildcard { + type: 'wildcard' + name: string +} + +export interface Group { + type: 'group' + tokens: Token[] +} + +export type Key = Parameter | Wildcard + +export type Keys = Key[] + +export type Token = Text | Parameter | Wildcard | Group + +export type FlatToken = Text | Parameter | Wildcard + +export class TokenData { + readonly tokens: Token[] + readonly originalPath: string | undefined + + constructor(tokens: Token[], originalPath?: string) { + this.tokens = tokens + this.originalPath = originalPath + } +} + +export class PathError extends TypeError { + readonly originalPath: string | undefined + + constructor(message: string, originalPath: string | undefined) { + let text = message + if (originalPath) text += `: ${originalPath}` + text += '; visit https://git.new/pathToRegexpError for info' + super(text) + this.originalPath = originalPath + } +} + +function codePoints(str: string): string[] { + const chars: string[] = [] + let index = 0 + while (index < str.length) { + const code = str.codePointAt(index) ?? 0 + const width = code > 0xffff ? 2 : 1 + chars.push(str.slice(index, index + width)) + index += width + } + return chars +} + +export function parse(str: string, options: ParseOptions = {}): TokenData { + const encodePath = options.encodePath ?? NOOP_VALUE + const chars = codePoints(str) + let index = 0 + + function consumeUntil(end: string): Token[] { + const output: Token[] = [] + let path = '' + + function writePath(): void { + if (!path) return + output.push({ type: 'text', value: encodePath(path) }) + path = '' + } + + while (index < chars.length) { + const value = chars[index++] + + if (value === end) { + writePath() + return output + } + + if (value === '\\') { + if (index === chars.length) { + throw new PathError(`Unexpected end after \\ at index ${index}`, str) + } + + path += chars[index++] + continue + } + + if (value === ':' || value === '*') { + let name = '' + + if (index < chars.length && ID_START.test(chars[index])) { + do { + name += chars[index++] + } while (index < chars.length && ID_CONTINUE.test(chars[index])) + } else if (index < chars.length && chars[index] === '"') { + let quoteStart = index + + while (index < chars.length) { + if (chars[++index] === '"') { + index++ + quoteStart = 0 + break + } + + if (chars[index] === '\\') index++ + + if (index < chars.length) name += chars[index] + } + + if (quoteStart) { + throw new PathError(`Unterminated quote at index ${quoteStart}`, str) + } + } + + if (!name) { + throw new PathError(`Missing parameter name at index ${index}`, str) + } + + writePath() + if (value === ':') output.push({ type: 'param', name }) + else output.push({ type: 'wildcard', name }) + continue + } + + if (value === '{') { + writePath() + output.push({ type: 'group', tokens: consumeUntil('}') }) + continue + } + + if (value === '}' || value === '(' || value === ')' || value === '[' || value === ']' || value === '+' || value === '?' || value === '!') { + throw new PathError(`Unexpected ${value} at index ${index - 1}`, str) + } + + path += value + } + + if (end) { + throw new PathError(`Unexpected end at index ${index}, expected ${end}`, str) + } + + writePath() + return output + } + + return new TokenData(consumeUntil(''), str) +} + +export interface ParamData { + [name: string]: string | string[] | undefined +} + +export type PathFunction = (data?: ParamData) => string + +export function compile(path: Path, options: CompileAndParseOptions = {}): PathFunction { + const encode = options.encode === undefined ? encodeURIComponent : options.encode + const delimiter = options.delimiter ?? DEFAULT_DELIMITER + const data = toTokenData(path, options) + const fn = tokensToFunction(data.tokens, delimiter, encode) + + return function build(params: ParamData = {}): string { + const missing: string[] = [] + const result = fn(params, missing) + + if (missing.length) { + throw new TypeError(`Missing parameters: ${missing.join(', ')}`) + } + + return result + } +} + +type TokenEncoder = (data: ParamData, missing: string[]) => string + +function tokensToFunction(tokens: Token[], delimiter: string, encode: Encode | false): TokenEncoder { + const encoders = tokens.map((token) => tokenToFunction(token, delimiter, encode)) + + return (data: ParamData, missing: string[]) => { + let result = '' + + for (const encoder of encoders) { + result += encoder(data, missing) + } + + return result + } +} + +function isList(value: string | string[]): value is string[] { + return Array.isArray(value) +} + +function tokenToFunction(token: Token, delimiter: string, encode: Encode | false): TokenEncoder { + if (token.type === 'text') { + const text = token.value + return () => text + } + + if (token.type === 'group') { + const fn = tokensToFunction(token.tokens, delimiter, encode) + + return (data, missing) => { + const len = missing.length + const value = fn(data, missing) + if (missing.length === len) return value + + missing.length = len + return '' + } + } + + const encodeValue = encode || NOOP_VALUE + const name = token.name + + if (token.type === 'wildcard' && encode !== false) { + return (data, missing) => { + const value = data[name] + if (value == null) { + missing.push(name) + return '' + } + + if (!isList(value) || value.length === 0) { + throw new TypeError(`Expected "${name}" to be a non-empty array`) + } + + let result = '' + + for (let i = 0; i < value.length; i++) { + if (typeof value[i] !== 'string') { + throw new TypeError(`Expected "${name}/${i}" to be a string`) + } + + if (i > 0) result += delimiter + result += encodeValue(value[i]) + } + + return result + } + } + + return (data, missing) => { + const value = data[name] + if (value == null) { + missing.push(name) + return '' + } + + if (typeof value !== 'string') { + throw new TypeError(`Expected "${name}" to be a string`) + } + + return encodeValue(value) + } +} + +type Decoder = (value: string) => string | string[] + +export interface MatchParams { + [name: string]: string | string[] +} + +export interface MatchResult { + path: string + params: MatchParams +} + +export type Match = false | MatchResult + +export type MatchFunction = (path: string) => Match + +export type Path = string | TokenData + +function toTokenData(path: Path, options: ParseOptions): TokenData { + return typeof path === 'string' ? parse(path, options) : path +} + +export function match(path: Path | Path[], options: MatchAndParseOptions = {}): MatchFunction { + const decoding = options.decode !== false + const decode: Decode = options.decode === undefined ? decodeURIComponent : options.decode === false ? NOOP_VALUE : options.decode + const delimiter = options.delimiter ?? DEFAULT_DELIMITER + const { regexp, keys } = pathToRegexp(path, options) + + const decoders: Decoder[] = keys.map((key): Decoder => { + if (!decoding) return (value: string): string | string[] => value + if (key.type === 'param') return (value: string): string | string[] => decode(value) + return (value: string): string | string[] => value.split(delimiter).map(decode) + }) + + return function matcher(input: string): Match { + const m = regexp.exec(input) + if (!m) return false + + const matched = m[0] + const params: MatchParams = {} + + for (let i = 1; i < m.length; i++) { + const captured = m[i] + if (captured === undefined) continue + + const key = keys[i - 1] + const decoder = decoders[i - 1] + params[key.name] = decoder(captured) + } + + return { path: matched, params } + } +} + +export interface PathRegExp { + regexp: RegExp + keys: Keys +} + +export function pathToRegexp(path: Path | Path[], options: PathToRegexpAndParseOptions = {}): PathRegExp { + const delimiter = options.delimiter ?? DEFAULT_DELIMITER + const end = options.end ?? true + const sensitive = options.sensitive ?? false + const trailing = options.trailing ?? true + const keys: Keys = [] + let source = '' + let combinations = 0 + + function processOne(single: Path): void { + const data = toTokenData(single, options) + flatten(data.tokens, 0, [], (tokens) => { + if (combinations >= 256) { + throw new PathError('Too many path combinations', data.originalPath) + } + + if (combinations > 0) source += '|' + source += toRegExpSource(tokens, delimiter, keys, data.originalPath) + combinations++ + }) + } + + if (Array.isArray(path)) { + for (const p of path) processOne(p) + } else { + processOne(path) + } + + let pattern = `^(?:${source})` + if (trailing) pattern += '(?:' + escape(delimiter) + '$)?' + pattern += end ? '$' : '(?=' + escape(delimiter) + '|$)' + + return { regexp: new RegExp(pattern, sensitive ? '' : 'i'), keys } +} + +function flatten(tokens: Token[], index: number, result: FlatToken[], callback: (result: FlatToken[]) => void): void { + while (index < tokens.length) { + const token = tokens[index++] + + if (token.type === 'group') { + const len = result.length + flatten(token.tokens, 0, result, (seq) => flatten(tokens, index, seq, callback)) + result.length = len + continue + } + + result.push(token) + } + + callback(result) +} + +function toRegExpSource(tokens: FlatToken[], delimiter: string, keys: Keys, originalPath: string | undefined): string { + let result = '' + let backtrack = '' + let wildcardBacktrack = '' + let prevCaptureType = 0 + let hasSegmentCapture = 0 + let index = 0 + + function hasWildcardInSegment(from: number): boolean { + while (from < tokens.length) { + const token = tokens[from++] + if (token.type === 'wildcard') return true + if (token.type === 'text') { + if (token.value.includes(delimiter)) break + } + } + return false + } + + function peekText(from: number): string { + let text = '' + while (from < tokens.length) { + const token = tokens[from++] + if (token.type !== 'text') break + text += token.value + } + return text + } + + while (index < tokens.length) { + const token = tokens[index++] + + if (token.type === 'text') { + result += escape(token.value) + backtrack += token.value + if (prevCaptureType === 2) wildcardBacktrack += token.value + if (token.value.includes(delimiter)) hasSegmentCapture = 0 + continue + } + + if (prevCaptureType && !backtrack) { + throw new PathError(`Missing text before "${token.name}" ${token.type}`, originalPath) + } + + if (token.type === 'param') { + result += + hasSegmentCapture & 2 + ? `(${negate(delimiter, backtrack)}+)` + : hasWildcardInSegment(index) + ? `(${negate(delimiter, peekText(index))}+)` + : hasSegmentCapture & 1 + ? `(${negate(delimiter, backtrack)}+|${escape(backtrack)})` + : `(${negate(delimiter, '')}+)` + + prevCaptureType = 1 + hasSegmentCapture |= 1 + } else { + result += + hasSegmentCapture & 2 + ? `(${negate(backtrack, '')}+)` + : wildcardBacktrack + ? `(${negate(wildcardBacktrack, '')}+|${negate(delimiter, '')}+)` + : `([^]+)` + + wildcardBacktrack = '' + prevCaptureType = 2 + hasSegmentCapture |= 2 + } + + keys.push(token) + backtrack = '' + } + + return result +} + +function negate(a: string, b: string): string { + if (b.length > a.length) return negate(b, a) + + if (a === b) b = '' + if (b.length > 1) return `(?:(?!${escape(a)}|${escape(b)})[^])` + if (a.length > 1) return `(?:(?!${escape(a)})[^${escape(b)}])` + return `[^${escape(a + b)}]` +} + +function stringifyTokens(tokens: Token[], index: number): string { + let value = '' + + while (index < tokens.length) { + const token = tokens[index++] + + if (token.type === 'text') { + value += escapeText(token.value) + continue + } + + if (token.type === 'group') { + value += '{' + stringifyTokens(token.tokens, 0) + '}' + continue + } + + if (token.type === 'param') { + value += ':' + stringifyName(token.name, index < tokens.length ? tokens[index] : undefined) + continue + } + + value += '*' + stringifyName(token.name, index < tokens.length ? tokens[index] : undefined) + } + + return value +} + +export function stringify(data: TokenData): string { + return stringifyTokens(data.tokens, 0) +} + +function stringifyName(name: string, next: Token | undefined): string { + if (!ID.test(name)) return JSON.stringify(name) + + if (next !== undefined && next.type === 'text' && ID_CONTINUE.test(next.value.charAt(0))) { + return JSON.stringify(name) + } + + return name +} diff --git a/runtime/node/nest/shims/qs/index.ts b/runtime/node/nest/shims/qs/index.ts new file mode 100644 index 0000000..3e376e6 --- /dev/null +++ b/runtime/node/nest/shims/qs/index.ts @@ -0,0 +1,458 @@ + +export type Charset = 'utf-8' | 'iso-8859-1' + +export type DefaultDecoder = (str: string, defaultDecoder: undefined, charset?: Charset) => string + +export type Decoder = (str: string, defaultDecoder: DefaultDecoder, charset: Charset, type: 'key' | 'value') => string | null + +export interface ParseOptions { + allowPrototypes?: boolean + arrayLimit?: number + depth?: number | false + charset?: Charset + charsetSentinel?: boolean + interpretNumericEntities?: boolean + decoder?: Decoder + parameterLimit?: number + strictDepth?: boolean +} + +export type ParsedValue = string | null | ParsedQs | ParsedElement[] +export type ParsedElement = ParsedValue | undefined +export interface ParsedQs { + [key: string]: ParsedValue | undefined +} + +interface Options { + allowPrototypes: boolean + arrayLimit: number + depth: number + charset: Charset + charsetSentinel: boolean + interpretNumericEntities: boolean + decoder: Decoder | undefined + parameterLimit: number + strictDepth: boolean +} + +export const OBJECT_PROTOTYPE_KEYS: readonly string[] = [ + 'constructor', '__defineGetter__', '__defineSetter__', 'hasOwnProperty', '__lookupGetter__', '__lookupSetter__', + 'isPrototypeOf', 'propertyIsEnumerable', 'toString', 'valueOf', '__proto__', 'toLocaleString' +] +const isObjectPrototypeKey = (key: string): boolean => OBJECT_PROTOTYPE_KEYS.indexOf(key) !== -1 + +export const defaultDecoder: DefaultDecoder = (str, _defaultDecoder, charset) => { + const strWithoutPlus = str.replace(/\+/g, ' ') + if (charset === 'iso-8859-1') { + return strWithoutPlus.replace(/%[0-9a-f]{2}/gi, (sequence) => String.fromCharCode(parseInt(sequence.slice(1), 16))) + } + try { + return decodeURIComponent(strWithoutPlus) + } catch { + return strWithoutPlus + } +} + +const defaults: Options = { + allowPrototypes: false, + arrayLimit: 20, + depth: 5, + charset: 'utf-8', + charsetSentinel: false, + interpretNumericEntities: false, + decoder: undefined, + parameterLimit: 1000, + strictDepth: false +} + +const overflowIndexes = new WeakMap() +const markOverflow = (obj: ParsedQs, maxIndex: number): ParsedQs => { + overflowIndexes.set(obj, maxIndex) + return obj +} +const isOverflow = (obj: object): boolean => overflowIndexes.has(obj) +const getMaxIndex = (obj: object): number => overflowIndexes.get(obj) ?? -1 +const setMaxIndex = (obj: object, maxIndex: number): void => { + overflowIndexes.set(obj, maxIndex) +} + +const isPlainObject = (value: ParsedElement): value is ParsedQs => value !== null && typeof value === 'object' && !Array.isArray(value) + +const interpretNumericEntities = (str: string): string => + str.replace(/&#(\d+);/g, (_match, numberStr: string) => String.fromCharCode(parseInt(numberStr, 10))) + +const isoSentinel = 'utf8=%26%2310003%3B' + +const charsetSentinel = 'utf8=%E2%9C%93' + +const arrayToObject = (source: ParsedElement[]): ParsedQs => { + const obj: ParsedQs = {} + for (let i = 0; i < source.length; ++i) { + const element = source[i] + if (element !== undefined) obj[String(i)] = element + } + return obj +} + +const concatValues = (a: ParsedElement, b: ParsedElement): ParsedElement[] => { + const result: ParsedElement[] = [] + if (Array.isArray(a)) for (const element of a) result.push(element) + else result.push(a) + if (Array.isArray(b)) for (const element of b) result.push(element) + else result.push(b) + return result +} + +const combine = (a: ParsedElement, b: ParsedElement, arrayLimit: number): ParsedValue => { + if (isPlainObject(a) && isOverflow(a)) { + const newIndex = getMaxIndex(a) + 1 + a[String(newIndex)] = b + setMaxIndex(a, newIndex) + return a + } + const result = concatValues(a, b) + if (result.length > arrayLimit) return markOverflow(arrayToObject(result), result.length - 1) + return result +} + +const setElement = (target: ParsedElement[], index: number, element: ParsedElement): void => { + while (target.length < index) target.push(undefined) + if (index === target.length) target.push(element) + else target[index] = element +} + +const splitParameters = (str: string, limit: number | undefined): string[] => { + if (limit === undefined) return str.split('&') + const parts: string[] = [] + let start = 0 + while (parts.length < limit) { + const end = str.indexOf('&', start) + if (end === -1) { + parts.push(str.slice(start)) + break + } + parts.push(str.slice(start, end)) + start = end + 1 + } + return parts +} + +const decodeWith = (options: Options, str: string, charset: Charset, type: 'key' | 'value'): string | null => + options.decoder === undefined ? defaultDecoder(str, undefined, charset) : options.decoder(str, defaultDecoder, charset, type) + +const parseValues = (str: string, options: Options): Map => { + const obj = new Map() + + const cleanStr = str.replace(/%5B/gi, '[').replace(/%5D/gi, ']') + + const limit = options.parameterLimit === Infinity ? undefined : options.parameterLimit + const parts = splitParameters(cleanStr, limit) + + let skipIndex = -1 + let i: number + + let charset = options.charset + if (options.charsetSentinel) { + for (i = 0; i < parts.length; ++i) { + if (parts[i].indexOf('utf8=') === 0) { + if (parts[i] === charsetSentinel) charset = 'utf-8' + else if (parts[i] === isoSentinel) charset = 'iso-8859-1' + skipIndex = i + break + } + } + } + + for (i = 0; i < parts.length; ++i) { + if (i === skipIndex) continue + const part = parts[i] + + const bracketEqualsPos = part.indexOf(']=') + const pos = bracketEqualsPos === -1 ? part.indexOf('=') : bracketEqualsPos + 1 + + let key: string | null + let val: ParsedValue = '' + if (pos === -1) { + key = decodeWith(options, part, charset, 'key') + val = '' + } else { + key = decodeWith(options, part.slice(0, pos), charset, 'key') + if (key !== null) { + val = decodeWith(options, part.slice(pos + 1), charset, 'value') + } + } + + if (val && options.interpretNumericEntities && charset === 'iso-8859-1') { + val = interpretNumericEntities(String(val)) + } + + if (key !== null) { + if (obj.has(key)) { + obj.set(key, combine(obj.get(key), val, options.arrayLimit)) + } else { + obj.set(key, val) + } + } + } + + return obj +} + +const parseObject = (chain: string[], val: ParsedValue, options: Options): ParsedValue => { + let leaf: ParsedValue = val + + for (let i = chain.length - 1; i >= 0; --i) { + let obj: ParsedValue + const root = chain[i] + + if (root === '[]') { + obj = isPlainObject(leaf) && isOverflow(leaf) ? leaf : combine([], leaf, options.arrayLimit) + } else { + const cleanRoot = root.charAt(0) === '[' && root.charAt(root.length - 1) === ']' ? root.slice(1, -1) : root + const decodedRoot = cleanRoot + const index = parseInt(decodedRoot, 10) + const isValidArrayIndex = !isNaN(index) && root !== decodedRoot && String(index) === decodedRoot && index >= 0 + if (isValidArrayIndex && index < options.arrayLimit) { + const arr: ParsedElement[] = [] + setElement(arr, index, leaf) + obj = arr + } else if (isValidArrayIndex) { + const overflow: ParsedQs = {} + overflow[String(index)] = leaf + obj = markOverflow(overflow, index) + } else { + const record: ParsedQs = {} + if (decodedRoot !== '__proto__') record[decodedRoot] = leaf + obj = record + } + } + + leaf = obj + } + + return leaf +} + +const splitKeyIntoSegments = (key: string, options: Options): string[] | undefined => { + if (options.depth <= 0) { + if (isObjectPrototypeKey(key) && !options.allowPrototypes) return undefined + return [key] + } + + const segments: string[] = [] + + const first = key.indexOf('[') + const parent = first >= 0 ? key.slice(0, first) : key + if (parent) { + if (isObjectPrototypeKey(parent) && !options.allowPrototypes) return undefined + segments.push(parent) + } + + const n = key.length + let open = first + let collected = 0 + + while (open >= 0 && collected < options.depth) { + let level = 1 + let i = open + 1 + let close = -1 + + while (i < n && close < 0) { + const cu = key.charCodeAt(i) + if (cu === 0x5b) { + level += 1 + } else if (cu === 0x5d) { + level -= 1 + if (level === 0) close = i + } + i += 1 + } + + if (close < 0) { + segments.push('[' + key.slice(open) + ']') + return segments + } + + const seg = key.slice(open, close + 1) + const content = seg.slice(1, -1) + if (isObjectPrototypeKey(content) && !options.allowPrototypes) return undefined + + segments.push(seg) + collected += 1 + + open = key.indexOf('[', close + 1) + } + + if (open >= 0) { + if (options.strictDepth === true) { + throw new RangeError('Input depth exceeded depth option of ' + options.depth + ' and strictDepth is true') + } + segments.push('[' + key.slice(open) + ']') + } + + return segments +} + +const parseKeys = (givenKey: string, val: ParsedValue, options: Options): ParsedValue | undefined => { + if (!givenKey) return undefined + const keys = splitKeyIntoSegments(givenKey, options) + if (!keys) return undefined + return parseObject(keys, val, options) +} + +const mergeIntoObject = (target: ParsedQs, source: ParsedQs | ParsedElement[], options: Options): ParsedQs => { + const sourceIsOverflow = isOverflow(source) + for (const key of Object.keys(source)) { + const value: ParsedElement = Array.isArray(source) ? source[Number(key)] : source[key] + if (value === undefined) continue + const existing = target[key] + if (existing !== undefined && Object.hasOwn(target, key)) target[key] = merge(existing, value, options) + else target[key] = value + if (sourceIsOverflow && !isOverflow(target)) markOverflow(target, getMaxIndex(source)) + if (isOverflow(target)) { + const keyNum = parseInt(key, 10) + if (String(keyNum) === key && keyNum >= 0 && keyNum > getMaxIndex(target)) setMaxIndex(target, keyNum) + } + } + return target +} + +const merge = (target: ParsedValue, source: ParsedValue | undefined, options: Options): ParsedValue => { + if (!source) return target + + if (typeof source !== 'object') { + if (Array.isArray(target)) { + const nextIndex = target.length + if (nextIndex >= options.arrayLimit) return markOverflow(arrayToObject(concatValues(target, source)), nextIndex) + target.push(source) + } else if (isPlainObject(target)) { + if (isOverflow(target)) { + const newIndex = getMaxIndex(target) + 1 + target[String(newIndex)] = source + setMaxIndex(target, newIndex) + } else { + return [target, source] + } + } else { + return [target, source] + } + return target + } + + if (!target || typeof target !== 'object') { + if (isPlainObject(source) && isOverflow(source)) { + const result: ParsedQs = { 0: target } + for (const oldKey of Object.keys(source)) result[String(parseInt(oldKey, 10) + 1)] = source[oldKey] + return markOverflow(result, getMaxIndex(source) + 1) + } + const combined = concatValues(target, source) + if (combined.length > options.arrayLimit) return markOverflow(arrayToObject(combined), combined.length - 1) + return combined + } + + if (Array.isArray(target) && Array.isArray(source)) { + for (let i = 0; i < source.length; i++) { + const item = source[i] + if (item === undefined) continue + const targetItem = target[i] + if (i < target.length && targetItem !== undefined) { + if (typeof targetItem === 'object' && typeof item === 'object' && item !== null) { + target[i] = merge(targetItem, item, options) + } else { + target.push(item) + } + } else { + setElement(target, i, item) + } + } + if (target.length > options.arrayLimit) return markOverflow(arrayToObject(target), target.length - 1) + return target + } + + const mergeTarget: ParsedQs = Array.isArray(target) ? arrayToObject(target) : target + return mergeIntoObject(mergeTarget, source, options) +} + +type Container = ParsedQs | ParsedElement[] +const readAt = (container: Container, key: string): ParsedElement => (Array.isArray(container) ? container[Number(key)] : container[key]) +const writeAt = (container: Container, key: string, value: ParsedElement): void => { + if (Array.isArray(container)) container[Number(key)] = value + else container[key] = value +} + +const compact = (value: ParsedQs): ParsedQs => { + const root: ParsedQs = { o: value } + const queue: { obj: Container; prop: string }[] = [{ obj: root, prop: 'o' }] + + for (let i = 0; i < queue.length; ++i) { + const item = queue[i] + const obj = readAt(item.obj, item.prop) + if (obj === null || obj === undefined || typeof obj !== 'object') continue + for (const key of Object.keys(obj)) { + const val = readAt(obj, key) + if (typeof val === 'object' && val !== null) queue.push({ obj, prop: key }) + } + } + + while (queue.length > 1) { + const item = queue.pop()! + const obj = readAt(item.obj, item.prop) + if (Array.isArray(obj)) { + const compacted: ParsedElement[] = [] + for (let j = 0; j < obj.length; ++j) { + if (obj[j] !== undefined) compacted.push(obj[j]) + } + writeAt(item.obj, item.prop, compacted) + } + } + return value +} + +const normalizeParseOptions = (opts?: ParseOptions): Options => { + if (!opts) return defaults + + if (typeof opts.decoder !== 'undefined' && typeof opts.decoder !== 'function') { + throw new TypeError('Decoder has to be a function.') + } + if (typeof opts.charset !== 'undefined' && opts.charset !== 'utf-8' && opts.charset !== 'iso-8859-1') { + throw new TypeError('The charset option must be either utf-8, iso-8859-1, or undefined') + } + const charset: Charset = typeof opts.charset === 'undefined' ? defaults.charset : opts.charset + + return { + allowPrototypes: typeof opts.allowPrototypes === 'boolean' ? opts.allowPrototypes : defaults.allowPrototypes, + arrayLimit: typeof opts.arrayLimit === 'number' ? opts.arrayLimit : defaults.arrayLimit, + charset, + charsetSentinel: typeof opts.charsetSentinel === 'boolean' ? opts.charsetSentinel : defaults.charsetSentinel, + decoder: typeof opts.decoder === 'function' ? opts.decoder : defaults.decoder, + depth: typeof opts.depth === 'number' || opts.depth === false ? Number(opts.depth) : defaults.depth, + interpretNumericEntities: + typeof opts.interpretNumericEntities === 'boolean' ? opts.interpretNumericEntities : defaults.interpretNumericEntities, + parameterLimit: typeof opts.parameterLimit === 'number' ? opts.parameterLimit : defaults.parameterLimit, + strictDepth: typeof opts.strictDepth === 'boolean' ? opts.strictDepth : defaults.strictDepth + } +} + +export function parse(str: string, opts?: ParseOptions): ParsedQs { + const options = normalizeParseOptions(opts) + + if (str === '' || str === null || typeof str === 'undefined') return {} + + const tempObj = parseValues(str, options) + let obj: ParsedQs = {} + + for (const [key, value] of tempObj) { + const newObj = parseKeys(key, value, options) + if (newObj === undefined) continue + if (typeof newObj !== 'object' || newObj === null) { + obj = mergeIntoObject(obj, { [key]: newObj }, options) + } else { + obj = mergeIntoObject(obj, newObj, options) + } + } + + return compact(obj) +} + +const qs = { parse } +export default qs diff --git a/runtime/node/nest/shims/range-parser/index.ts b/runtime/node/nest/shims/range-parser/index.ts new file mode 100644 index 0000000..2dbed48 --- /dev/null +++ b/runtime/node/nest/shims/range-parser/index.ts @@ -0,0 +1,96 @@ + +export interface Range { + start: number + end: number +} + +export interface Ranges { + type: string + ranges: Range[] +} + +export interface RangeParserOptions { + combine?: boolean +} + +export const UNSATISFIABLE = -1 +export const MALFORMED = -2 + +interface IndexedRange { + start: number + end: number + index: number +} + +export default function parseRange(size: number, str: string, options?: RangeParserOptions): Ranges | -1 | -2 { + const index = str.indexOf('=') + if (index === -1) return -2 + + const arr = str.slice(index + 1).split(',') + const ranges: Range[] = [] + const type = str.slice(0, index) + + for (let i = 0; i < arr.length; i++) { + const indexOf = arr[i].indexOf('-') + if (indexOf === -1) return -2 + + const startStr = arr[i].slice(0, indexOf).trim() + const endStr = arr[i].slice(indexOf + 1).trim() + + let start = parsePos(startStr) + let end = parsePos(endStr) + + if (startStr.length === 0) { + start = size - end + end = size - 1 + } else if (endStr.length === 0) { + end = size - 1 + } + + if (end > size - 1) end = size - 1 + + if (Number.isNaN(start) || Number.isNaN(end)) return -2 + + if (start > end || start < 0) continue + + ranges.push({ start, end }) + } + + if (ranges.length < 1) return -1 + + return { type, ranges: options !== undefined && options.combine === true ? combineRanges(ranges) : ranges } +} + +function parsePos(str: string): number { + if (/^\d+$/.test(str)) return Number(str) + return NaN +} + +function combineRanges(ranges: Range[]): Range[] { + const ordered: IndexedRange[] = ranges.map((range, index) => ({ start: range.start, end: range.end, index })).sort(sortByRangeStart) + + let j = 0 + for (let i = 1; i < ordered.length; i++) { + const range = ordered[i] + const current = ordered[j] + + if (range.start > current.end + 1) { + ordered[++j] = range + } else if (range.end > current.end) { + current.end = range.end + current.index = Math.min(current.index, range.index) + } + } + + ordered.length = j + 1 + + return ordered.sort(sortByRangeIndex).map((range) => ({ start: range.start, end: range.end })) +} + +function sortByRangeIndex(a: IndexedRange, b: IndexedRange): number { + return a.index - b.index +} + +function sortByRangeStart(a: IndexedRange, b: IndexedRange): number { + return a.start - b.start +} diff --git a/runtime/node/nest/shims/ret/index.ts b/runtime/node/nest/shims/ret/index.ts new file mode 100644 index 0000000..89715e1 --- /dev/null +++ b/runtime/node/nest/shims/ret/index.ts @@ -0,0 +1,412 @@ + +export const types = { + ROOT: 0, + GROUP: 1, + POSITION: 2, + SET: 3, + RANGE: 4, + REPETITION: 5, + REFERENCE: 6, + CHAR: 7 +} as const + +export interface Root { + type: typeof types.ROOT + stack?: Token[] + options?: Token[][] + flags?: string[] +} + +export interface Group { + type: typeof types.GROUP + stack?: Token[] + options?: Token[][] + remember: boolean + followedBy?: boolean + notFollowedBy?: boolean + lookBehind?: boolean + name?: string +} + +export interface SetToken { + type: typeof types.SET + set: SetTokens + not: boolean +} + +export interface Range { + type: typeof types.RANGE + from: number + to: number +} + +export interface Repetition { + type: typeof types.REPETITION + min: number + max: number + value: Token +} + +export type PositionValue = '$' | '^' | 'b' | 'B' + +export interface Position { + type: typeof types.POSITION + value: PositionValue +} + +export interface Reference { + type: typeof types.REFERENCE + value: number +} + +export interface Char { + type: typeof types.CHAR + value: number +} + +export type Token = Group | Position | SetToken | Range | Repetition | Reference | Char +export type Tokens = Root | Token +export type SetTokens = (Range | Char | SetToken)[] + +type Clause = Root | Group + +const INTS = (): SetTokens => [{ type: types.RANGE, from: 48, to: 57 }] +const WORDS = (): SetTokens => [ + { type: types.CHAR, value: 95 }, + { type: types.RANGE, from: 97, to: 122 }, + { type: types.RANGE, from: 65, to: 90 }, + { type: types.RANGE, from: 48, to: 57 } +] +const WHITESPACE = (): SetTokens => [ + { type: types.CHAR, value: 9 }, + { type: types.CHAR, value: 10 }, + { type: types.CHAR, value: 11 }, + { type: types.CHAR, value: 12 }, + { type: types.CHAR, value: 13 }, + { type: types.CHAR, value: 32 }, + { type: types.CHAR, value: 160 }, + { type: types.CHAR, value: 5760 }, + { type: types.RANGE, from: 8192, to: 8202 }, + { type: types.CHAR, value: 8232 }, + { type: types.CHAR, value: 8233 }, + { type: types.CHAR, value: 8239 }, + { type: types.CHAR, value: 8287 }, + { type: types.CHAR, value: 12288 }, + { type: types.CHAR, value: 65279 } +] +const NOTANYCHAR = (): SetTokens => [ + { type: types.CHAR, value: 10 }, + { type: types.CHAR, value: 13 }, + { type: types.CHAR, value: 8232 }, + { type: types.CHAR, value: 8233 } +] + +export const words = (): SetToken => ({ type: types.SET, set: WORDS(), not: false }) +export const notWords = (): SetToken => ({ type: types.SET, set: WORDS(), not: true }) +export const ints = (): SetToken => ({ type: types.SET, set: INTS(), not: false }) +export const notInts = (): SetToken => ({ type: types.SET, set: INTS(), not: true }) +export const whitespace = (): SetToken => ({ type: types.SET, set: WHITESPACE(), not: false }) +export const notWhitespace = (): SetToken => ({ type: types.SET, set: WHITESPACE(), not: true }) +export const anyChar = (): SetToken => ({ type: types.SET, set: NOTANYCHAR(), not: true }) + +const CTRL = '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^ ?' +const SPECIAL_REGEX_CHAR = /[[\]{}^$.|?*+()]/ +const CHARS_REGEX = /(\[\\b\])|(\\)?\\(?:u([A-F0-9]{4})|x([A-F0-9]{2})|c([@A-Z[\\\]^?])|([0tnvfr]))/g + +const escapeCode = (eslsh: string): number => { + switch (eslsh) { + case '0': + return 0 + case 't': + return 9 + case 'n': + return 10 + case 'v': + return 11 + case 'f': + return 12 + default: + return 13 + } +} + +const charReplacement = (rs: RegExpExecArray): string => { + const s = rs[0] + const b = rs[1] + const lbs = rs[2] + const a16 = rs[3] + const b16 = rs[4] + const dctrl = rs[5] + const eslsh = rs[6] + if (lbs) return s + const code = b ? 8 : a16 ? parseInt(a16, 16) : b16 ? parseInt(b16, 16) : dctrl ? CTRL.indexOf(dctrl) : escapeCode(eslsh ?? '') + const c = String.fromCharCode(code) + return SPECIAL_REGEX_CHAR.test(c) ? `\\${c}` : c +} + +export function strToChars(str: string): string { + const regexp = new RegExp(CHARS_REGEX.source, 'g') + let result = '' + let last = 0 + let rs: RegExpExecArray | null + while ((rs = regexp.exec(str)) !== null) { + result += str.slice(last, rs.index) + charReplacement(rs) + last = rs.index + rs[0].length + if (rs[0].length === 0) regexp.lastIndex++ + } + return result + str.slice(last) +} + +const CLASS_REGEX = /\\(?:(w)|(d)|(s)|(W)|(D)|(S))|((?:(?:\\)(.)|([^\]\\]))-(((?:\\)])|(((?:\\)?([^\]])))))|(\])|(?:\\)?([^])/g + +export function tokenizeClass(str: string, regexpStr: string): [SetTokens, number] { + const tokens: SetTokens = [] + const regexp = new RegExp(CLASS_REGEX.source, 'g') + let rs: RegExpExecArray | null + while ((rs = regexp.exec(str)) !== null) { + if (rs[1]) tokens.push(words()) + else if (rs[2]) tokens.push(ints()) + else if (rs[3]) tokens.push(whitespace()) + else if (rs[4]) tokens.push(notWords()) + else if (rs[5]) tokens.push(notInts()) + else if (rs[6]) tokens.push(notWhitespace()) + else if (rs[7]) { + const from = rs[8] || rs[9] || '' + const to = rs[10] ?? '' + tokens.push({ type: types.RANGE, from: from.charCodeAt(0), to: to.charCodeAt(to.length - 1) }) + } else if (rs[16]) tokens.push({ type: types.CHAR, value: rs[16].charCodeAt(0) }) + else return [tokens, regexp.lastIndex] + } + throw new SyntaxError(`Invalid regular expression: /${regexpStr}/: Unterminated character class`) +} + +const captureGroupFirstChar = /^[a-zA-Z_$]$/i +const captureGroupChars = /^[a-zA-Z0-9_$]$/i +const digit = /\d/ + +interface QueuedReference { + reference: Reference + stack: Token[] + index: number +} + +const popValue = (stack: Token[], regexpStr: string, col: number): Token => { + const value = stack.pop() + if (value === undefined) throw new SyntaxError(`Invalid regular expression: /${regexpStr}/: Nothing to repeat at column ${col - 1}`) + return value +} + +const REPETITION_RANGE = /^(\d+)(,(\d+)?)?\}/ + +export function tokenizer(regexpStr: string): Root { + let i = 0 + let c: string + const start: Root = { type: types.ROOT, stack: [] } + let lastGroup: Clause = start + let last: Token[] = start.stack ?? [] + const groupStack: Clause[] = [] + const referenceQueue: QueuedReference[] = [] + let groupCount = 0 + + const str = strToChars(regexpStr) + + while (i < str.length) { + switch ((c = str[i++] ?? '')) { + case '\\': + if (i === str.length) throw new SyntaxError(`Invalid regular expression: /${regexpStr}/: \\ at end of pattern`) + switch ((c = str[i++] ?? '')) { + case 'b': + last.push({ type: types.POSITION, value: 'b' }) + break + case 'B': + last.push({ type: types.POSITION, value: 'B' }) + break + case 'w': + last.push(words()) + break + case 'W': + last.push(notWords()) + break + case 'd': + last.push(ints()) + break + case 'D': + last.push(notInts()) + break + case 's': + last.push(whitespace()) + break + case 'S': + last.push(notWhitespace()) + break + default: + if (digit.test(c)) { + let digits = c + while (i < str.length && digit.test(str[i] ?? '')) digits += str[i++] + const reference: Reference = { type: types.REFERENCE, value: parseInt(digits, 10) } + last.push(reference) + referenceQueue.push({ reference, stack: last, index: last.length - 1 }) + } else { + last.push({ type: types.CHAR, value: c.charCodeAt(0) }) + } + } + break + + case '^': + last.push({ type: types.POSITION, value: '^' }) + break + case '$': + last.push({ type: types.POSITION, value: '$' }) + break + + case '[': { + let not: boolean + if (str[i] === '^') { + not = true + i++ + } else { + not = false + } + const classTokens = tokenizeClass(str.slice(i), regexpStr) + i += classTokens[1] + last.push({ type: types.SET, set: classTokens[0], not }) + break + } + + case '.': + last.push(anyChar()) + break + + case '(': { + const group: Group = { type: types.GROUP, stack: [], remember: true } + if (str[i] === '?') { + c = str[i + 1] ?? '' + i += 2 + if (c === '=') { + group.followedBy = true + group.remember = false + } else if (c === '!') { + group.notFollowedBy = true + group.remember = false + } else if (c === '<') { + let name = '' + if (captureGroupFirstChar.test(str[i] ?? '')) { + name += str[i] + i++ + } else { + throw new SyntaxError( + `Invalid regular expression: /${regexpStr}/: Invalid capture group name, character '${str[i]}' after '<' at column ${i + 1}` + ) + } + while (i < str.length && captureGroupChars.test(str[i] ?? '')) { + name += str[i] + i++ + } + if (!name) { + throw new SyntaxError( + `Invalid regular expression: /${regexpStr}/: Invalid capture group name, character '${str[i]}' after '<' at column ${i + 1}` + ) + } + if (str[i] !== '>') { + throw new SyntaxError( + `Invalid regular expression: /${regexpStr}/: Unclosed capture group name, expected '>', found '${str[i]}' at column ${i + 1}` + ) + } + group.name = name + i++ + } else if (c === ':') { + group.remember = false + } else { + throw new SyntaxError(`Invalid regular expression: /${regexpStr}/: Invalid group, character '${c}' after '?' at column ${i - 1}`) + } + } else { + groupCount += 1 + } + last.push(group) + groupStack.push(lastGroup) + lastGroup = group + last = group.stack ?? [] + break + } + + case ')': { + const previous = groupStack.pop() + if (previous === undefined) throw new SyntaxError(`Invalid regular expression: /${regexpStr}/: Unmatched ) at column ${i - 1}`) + lastGroup = previous + const options = lastGroup.options + last = options ? (options[options.length - 1] ?? []) : (lastGroup.stack ?? []) + break + } + + case '|': { + if (!lastGroup.options) { + lastGroup.options = [lastGroup.stack ?? []] + lastGroup.stack = undefined + } + const stack: Token[] = [] + lastGroup.options.push(stack) + last = stack + break + } + + case '{': { + const rs = REPETITION_RANGE.exec(str.slice(i)) + if (rs !== null) { + const min = parseInt(rs[1] ?? '', 10) + const max = rs[2] ? (rs[3] ? parseInt(rs[3], 10) : Infinity) : min + const value = popValue(last, regexpStr, i) + i += rs[0].length + last.push({ type: types.REPETITION, min, max, value }) + } else { + last.push({ type: types.CHAR, value: 123 }) + } + break + } + case '?': + last.push({ type: types.REPETITION, min: 0, max: 1, value: popValue(last, regexpStr, i) }) + break + case '+': + last.push({ type: types.REPETITION, min: 1, max: Infinity, value: popValue(last, regexpStr, i) }) + break + case '*': + last.push({ type: types.REPETITION, min: 0, max: Infinity, value: popValue(last, regexpStr, i) }) + break + + default: + last.push({ type: types.CHAR, value: c.charCodeAt(0) }) + } + } + + if (groupStack.length !== 0) throw new SyntaxError(`Invalid regular expression: /${regexpStr}/: Unterminated group`) + + updateReferences(referenceQueue, groupCount) + return start +} + +const OCTAL = /^[0-7]+$/ + +function updateReferences(referenceQueue: QueuedReference[], groupCount: number): void { + for (const elem of referenceQueue.reverse()) { + if (groupCount >= elem.reference.value) continue + const valueString = elem.reference.value.toString() + const replacement: Char = { type: types.CHAR, value: parseInt(valueString, 8) } + elem.stack[elem.index] = replacement + if (!OCTAL.test(valueString)) { + let i = 0 + while (valueString[i] !== '8' && valueString[i] !== '9') i += 1 + if (i === 0) { + replacement.value = valueString.charCodeAt(0) + i += 1 + } else { + replacement.value = parseInt(valueString.slice(0, i), 8) + } + if (valueString.length > i) { + const tail = elem.stack.splice(elem.index + 1) + for (const char of valueString.slice(i)) elem.stack.push({ type: types.CHAR, value: char.charCodeAt(0) }) + for (const token of tail) elem.stack.push(token) + } + } + } +} + +export default tokenizer diff --git a/runtime/node/nest/shims/safe-regex2/index.ts b/runtime/node/nest/shims/safe-regex2/index.ts new file mode 100644 index 0000000..7471b1b --- /dev/null +++ b/runtime/node/nest/shims/safe-regex2/index.ts @@ -0,0 +1,48 @@ +import { tokenizer, types, type Root, type Token } from '../ret/index' + +export interface SafeRegexOptions { + limit?: number +} + +interface WalkState { + reps: number + limit: number +} + +const walkStack = (stack: Token[], state: WalkState, starHeight: number): boolean => { + for (const token of stack) if (!walk(token, state, starHeight)) return false + return true +} + +const walkOptions = (options: Token[][] | undefined, state: WalkState, starHeight: number): boolean => { + if (!options) return true + for (const option of options) if (!walkStack(option, state, starHeight)) return false + return true +} + +function walk(node: Root | Token, state: WalkState, starHeight: number): boolean { + if (node.type === types.REPETITION) { + starHeight++ + state.reps++ + if (starHeight > 1) return false + if (state.reps > state.limit) return false + } + + const clause = node.type === types.ROOT || node.type === types.GROUP ? node : node.type === types.REPETITION && node.value.type === types.GROUP ? node.value : null + if (clause === null) return true + if (!walkOptions(clause.options, state, starHeight)) return false + if (!clause.stack) return true + return walkStack(clause.stack, state, starHeight) +} + +export function safeRegex(re: RegExp | string, options?: SafeRegexOptions): boolean { + const state: WalkState = { reps: 0, limit: options?.limit ?? 25 } + const source = typeof re === 'string' ? re : re.source + try { + return walk(tokenizer(source), state, 0) + } catch { + return false + } +} + +export default safeRegex diff --git a/runtime/node/nest/shims/secure-json-parse/index.ts b/runtime/node/nest/shims/secure-json-parse/index.ts new file mode 100644 index 0000000..9181b89 --- /dev/null +++ b/runtime/node/nest/shims/secure-json-parse/index.ts @@ -0,0 +1,131 @@ + +import type { ParsedElement, ParsedQs } from '../qs/index' + +export type PoisoningAction = 'error' | 'remove' | 'ignore' + +export interface ParseOptions { + protoAction?: PoisoningAction + constructorAction?: PoisoningAction + safe?: boolean +} + +export type ScanOptions = ParseOptions + +export type Reviver = (key: string, value: unknown) => unknown + +const suspectProtoRx = /"(?:_|\\u005[Ff])(?:_|\\u005[Ff])(?:p|\\u0070)(?:r|\\u0072)(?:o|\\u006[Ff])(?:t|\\u0074)(?:o|\\u006[Ff])(?:_|\\u005[Ff])(?:_|\\u005[Ff])"\s*:/ +const suspectConstructorRx = /"(?:c|\\u0063)(?:o|\\u006[Ff])(?:n|\\u006[Ee])(?:s|\\u0073)(?:t|\\u0074)(?:r|\\u0072)(?:u|\\u0075)(?:c|\\u0063)(?:t|\\u0074)(?:o|\\u006[Ff])(?:r|\\u0072)"\s*:/ + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' +} + +function parseInternal(text: string | Buffer, reviver?: Reviver | null | ParseOptions, options?: ParseOptions): unknown { + let reviverFn: Reviver | undefined + if (reviver !== null && typeof reviver === 'object') { + if (options == null) options = reviver + } else if (typeof reviver === 'function') { + reviverFn = reviver + } + let source: string = Buffer.isBuffer(text) ? text.toString() : text + if (source && source.charCodeAt(0) === 0xfeff) source = source.slice(1) + const obj: unknown = reviverFn === undefined ? JSON.parse(source) : JSON.parse(source, reviverFn) + if (!isObject(obj)) return obj + + const protoAction: PoisoningAction = (options && options.protoAction) || 'error' + const constructorAction: PoisoningAction = (options && options.constructorAction) || 'error' + if (protoAction === 'ignore' && constructorAction === 'ignore') return obj + if (protoAction !== 'ignore' && constructorAction !== 'ignore') { + if (suspectProtoRx.test(source) === false && suspectConstructorRx.test(source) === false) return obj + } else if (protoAction !== 'ignore' && constructorAction === 'ignore') { + if (suspectProtoRx.test(source) === false) return obj + } else { + if (suspectConstructorRx.test(source) === false) return obj + } + return scan(obj, { protoAction, constructorAction, safe: options && options.safe }) +} + +export function scan(obj: T, options?: ScanOptions): T | null { + const protoAction: PoisoningAction = (options && options.protoAction) || 'error' + const constructorAction: PoisoningAction = (options && options.constructorAction) || 'error' + const safe = options && options.safe + let next: Record[] = [obj as Record] + while (next.length) { + const nodes = next + next = [] + for (const node of nodes) { + if (protoAction !== 'ignore' && Object.hasOwn(node, '__proto__')) { + if (safe === true) return null + else if (protoAction === 'error') throw new SyntaxError('Object contains forbidden prototype property') + delete node['__proto__'] + } + if (constructorAction !== 'ignore' && Object.hasOwn(node, 'constructor')) { + const ctor = node['constructor'] + if (isObject(ctor) && Object.hasOwn(ctor, 'prototype')) { + if (safe === true) return null + else if (constructorAction === 'error') throw new SyntaxError('Object contains forbidden prototype property') + delete node['constructor'] + } + } + for (const key in node) { + const value = node[key] + if (isObject(value)) next.push(value) + } + } + } + return obj +} + +export function scanQuery(obj: ParsedQs, options?: ScanOptions): ParsedQs | null { + const protoAction: PoisoningAction = (options && options.protoAction) || 'error' + const constructorAction: PoisoningAction = (options && options.constructorAction) || 'error' + const safe = options && options.safe + let next: ParsedQs[] = [obj] + while (next.length) { + const nodes = next + next = [] + for (const node of nodes) { + if (protoAction !== 'ignore' && Object.hasOwn(node, '__proto__')) { + if (safe === true) return null + else if (protoAction === 'error') throw new SyntaxError('Object contains forbidden prototype property') + delete node['__proto__'] + } + if (constructorAction !== 'ignore' && Object.hasOwn(node, 'constructor')) { + const ctor = node['constructor'] + if (ctor !== null && ctor !== undefined && typeof ctor === 'object' && !Array.isArray(ctor) && Object.hasOwn(ctor, 'prototype')) { + if (safe === true) return null + else if (constructorAction === 'error') throw new SyntaxError('Object contains forbidden prototype property') + delete node['constructor'] + } + } + for (const key in node) pushQueryChildren(node[key], next) + } + } + return obj +} + +function pushQueryChildren(value: ParsedElement, next: ParsedQs[]): void { + if (value === null || value === undefined || typeof value !== 'object') return + if (Array.isArray(value)) { + for (const element of value) pushQueryChildren(element, next) + } else { + next.push(value) + } +} + +export function parse(text: string | Buffer, options?: ParseOptions): unknown +export function parse(text: string | Buffer, reviver?: Reviver | null, options?: ParseOptions): unknown +export function parse(text: string | Buffer, reviver?: Reviver | null | ParseOptions, options?: ParseOptions): unknown { + return parseInternal(text, reviver, options) +} + +export function safeParse(text: string | Buffer, reviver?: Reviver | null): unknown { + try { + return parseInternal(text, reviver, { safe: true }) + } catch { + return undefined + } +} + +const secureJson = { parse, safeParse, scan } +export default secureJson diff --git a/runtime/node/nest/shims/send/index.ts b/runtime/node/nest/shims/send/index.ts new file mode 100644 index 0000000..b47df2b --- /dev/null +++ b/runtime/node/nest/shims/send/index.ts @@ -0,0 +1,647 @@ +import { Buffer } from 'node:buffer' +import { createReadStream, stat, type ReadStream, type Stats } from 'node:fs' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { extname, join, normalize, resolve, sep } from 'node:path' +import encodeUrl from '../encodeurl/index' +import escapeHtml from '../escape-html/index' +import etag from '../etag/index' +import fresh from '../fresh/index' +import createError, { HttpError, type HttpErrorProperties } from '../http-errors/index' +import { contentType } from '../mime-types/index' +import ms from '../ms/index' +import onFinished from '../on-finished/index' +import parseRange, { type Range } from '../range-parser/index' +import { statusMessage } from '../statuses/index' + +const BYTES_RANGE_REGEXP = /^ *bytes=/ + +const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 + +const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ + +export type DotfilesPolicy = 'allow' | 'deny' | 'ignore' + +export interface SendOptions { + acceptRanges?: boolean + cacheControl?: boolean + dotfiles?: DotfilesPolicy + end?: number + etag?: boolean + extensions?: readonly string[] | string | false + immutable?: boolean + index?: readonly string[] | string | false + lastModified?: boolean + maxAge?: string | number + root?: string + start?: number +} + +interface SystemError extends Error { + code?: string | undefined +} + +export type DirectoryListener = (res: ServerResponse, path: string) => void +export type EndListener = () => void +export type ErrorListener = (error: HttpError) => void +export type FileListener = (path: string, stats: Stats) => void +export type HeadersListener = (res: ServerResponse, path: string, stats: Stats) => void +export type StreamListener = (stream: ReadStream) => void + +export type SendEvent = 'directory' | 'end' | 'error' | 'file' | 'headers' | 'stream' +type SendListener = DirectoryListener | EndListener | ErrorListener | FileListener | HeadersListener | StreamListener + +export class SendStream { + readonly req: IncomingMessage + readonly path: string + readonly options: SendOptions + private res: ServerResponse | undefined = undefined + + private readonly acceptRanges: boolean + private readonly cacheControl: boolean + private readonly etagEnabled: boolean + private readonly dotfiles: DotfilesPolicy + private readonly extensions: string[] + private readonly immutable: boolean + private readonly index: string[] + private readonly lastModified: boolean + private readonly maxage: number + private readonly root: string | null + + private readonly directoryListeners: DirectoryListener[] = [] + private readonly endListeners: EndListener[] = [] + private readonly errorListeners: ErrorListener[] = [] + private readonly fileListeners: FileListener[] = [] + private readonly headersListeners: HeadersListener[] = [] + private readonly streamListeners: StreamListener[] = [] + + constructor(req: IncomingMessage, path: string, options: SendOptions = {}) { + this.options = options + this.path = path + this.req = req + + this.acceptRanges = options.acceptRanges !== undefined ? options.acceptRanges : true + this.cacheControl = options.cacheControl !== undefined ? options.cacheControl : true + this.etagEnabled = options.etag !== undefined ? options.etag : true + this.dotfiles = options.dotfiles !== undefined ? options.dotfiles : 'ignore' + this.extensions = normalizeList(options.extensions, []) + this.immutable = options.immutable !== undefined ? options.immutable : false + this.index = normalizeList(options.index, ['index.html']) + this.lastModified = options.lastModified !== undefined ? options.lastModified : true + + const maxAge = options.maxAge + let maxage: number + if (typeof maxAge === 'string') maxage = maxAge === '' ? NaN : (ms(maxAge) ?? NaN) + else maxage = maxAge === undefined ? NaN : maxAge + this.maxage = Number.isNaN(maxage) ? 0 : Math.min(Math.max(0, maxage), MAX_MAXAGE) + + this.root = options.root !== undefined && options.root !== '' ? resolve(options.root) : null + } + + on(event: 'directory', listener: DirectoryListener): this + on(event: 'end', listener: EndListener): this + on(event: 'error', listener: ErrorListener): this + on(event: 'file', listener: FileListener): this + on(event: 'headers', listener: HeadersListener): this + on(event: 'stream', listener: StreamListener): this + on(event: SendEvent, listener: SendListener): this { + switch (event) { + case 'directory': + this.directoryListeners.push(listener as DirectoryListener) + break + case 'end': + this.endListeners.push(listener as EndListener) + break + case 'error': + this.errorListeners.push(listener as ErrorListener) + break + case 'file': + this.fileListeners.push(listener as FileListener) + break + case 'headers': + this.headersListeners.push(listener as HeadersListener) + break + case 'stream': + this.streamListeners.push(listener as StreamListener) + break + } + return this + } + + pipe(res: ServerResponse): ServerResponse { + const root = this.root + this.res = res + + const decoded = decodePath(this.path) + if (decoded === null) { + this.fail(400) + return res + } + + if (decoded.indexOf('\0') !== -1) { + this.fail(400) + return res + } + + let path = decoded + let parts: string[] + if (root !== null) { + if (path !== '') path = normalize('.' + sep + path) + + if (UP_PATH_REGEXP.test(path)) { + this.fail(403) + return res + } + + parts = path.split(sep) + + path = normalize(join(root, path)) + } else { + if (UP_PATH_REGEXP.test(path)) { + this.fail(403) + return res + } + + parts = normalize(path).split(sep) + + path = resolve(path) + } + + if (containsDotFile(parts)) { + switch (this.dotfiles) { + case 'allow': + break + case 'deny': + this.fail(403) + return res + case 'ignore': + this.fail(404) + return res + } + } + + if (this.index.length > 0 && this.hasTrailingSlash()) { + this.sendIndex(path, 0, null) + return res + } + + this.sendFile(path) + return res + } + + private response(): ServerResponse { + if (this.res === undefined) throw new Error('send: the stream has not been piped to a response') + return this.res + } + + private fail(status: number, cause?: Error, properties?: HttpErrorProperties): void { + if (this.errorListeners.length > 0) { + const error = + cause !== undefined + ? createError(status, cause, { expose: false }) + : properties !== undefined + ? createError(status, properties) + : createError(status) + for (const listener of this.errorListeners.slice()) listener(error) + return + } + + const res = this.response() + const msg = statusMessage(status) ?? String(status) + const doc = createHtmlDocument('Error', escapeHtml(msg)) + + for (const name of res.getHeaderNames()) res.removeHeader(name) + + const headers = properties !== undefined ? properties.headers : cause instanceof HttpError ? cause.headers : undefined + if (headers !== undefined) { + for (const name of Object.keys(headers)) res.setHeader(name, headers[name]) + } + + res.statusCode = status + res.setHeader('Content-Type', 'text/html; charset=UTF-8') + res.setHeader('Content-Length', Buffer.byteLength(doc)) + res.setHeader('Content-Security-Policy', "default-src 'none'") + res.setHeader('X-Content-Type-Options', 'nosniff') + res.end(doc) + } + + private hasTrailingSlash(): boolean { + return this.path.endsWith('/') + } + + private isConditionalGET(): boolean { + const headers = this.req.headers + return Boolean(headers['if-match'] || headers['if-unmodified-since'] || headers['if-none-match'] || headers['if-modified-since']) + } + + private isPreconditionFailure(): boolean { + const req = this.req + const res = this.response() + + const match = req.headers['if-match'] + if (match) { + const etagValue = headerText(res.getHeader('ETag')) + return ( + etagValue === '' || + (match !== '*' && + parseTokenList(match).every((token) => token !== etagValue && token !== 'W/' + etagValue && 'W/' + token !== etagValue)) + ) + } + + const unmodifiedSince = parseHttpDate(headerText(req.headers['if-unmodified-since'])) + if (!Number.isNaN(unmodifiedSince)) { + const lastModified = parseHttpDate(headerText(res.getHeader('Last-Modified'))) + return Number.isNaN(lastModified) || lastModified > unmodifiedSince + } + + return false + } + + private removeContentHeaderFields(): void { + const res = this.response() + res.removeHeader('Content-Encoding') + res.removeHeader('Content-Language') + res.removeHeader('Content-Length') + res.removeHeader('Content-Range') + res.removeHeader('Content-Type') + } + + private notModified(): void { + const res = this.response() + this.removeContentHeaderFields() + res.statusCode = 304 + res.end() + } + + private headersAlreadySent(): void { + this.fail(500, new Error("Can't set headers after they are sent.")) + } + + private isCachable(): boolean { + const statusCode = this.response().statusCode + return (statusCode >= 200 && statusCode < 300) || statusCode === 304 + } + + private onStatError(error: SystemError): void { + switch (error.code) { + case 'ENAMETOOLONG': + case 'ENOENT': + case 'ENOTDIR': + this.fail(404, error) + break + default: + this.fail(500, error) + break + } + } + + private isFresh(): boolean { + const res = this.response() + const headers = this.req.headers + return fresh({ + 'if-modified-since': headerText(headers['if-modified-since']), + 'if-none-match': headerText(headers['if-none-match']), + 'cache-control': headerText(headers['cache-control']) + }, { + etag: headerText(res.getHeader('ETag')), + 'last-modified': headerText(res.getHeader('Last-Modified')) + }) + } + + private isRangeFresh(): boolean { + const ifRange = headerText(this.req.headers['if-range']) + if (ifRange === '') return true + + if (ifRange.indexOf('"') !== -1) { + const etagValue = headerText(this.response().getHeader('ETag')) + return etagValue !== '' && ifRange.indexOf(etagValue) !== -1 + } + + const lastModified = headerText(this.response().getHeader('Last-Modified')) + return parseHttpDate(lastModified) <= parseHttpDate(ifRange) + } + + private redirect(path: string): void { + const res = this.response() + + if (this.directoryListeners.length > 0) { + for (const listener of this.directoryListeners.slice()) listener(res, path) + return + } + + if (this.hasTrailingSlash()) { + this.fail(403) + return + } + + const loc = encodeUrl(collapseLeadingSlashes(this.path + '/')) + const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + escapeHtml(loc)) + + res.statusCode = 301 + res.setHeader('Content-Type', 'text/html; charset=UTF-8') + res.setHeader('Content-Length', Buffer.byteLength(doc)) + res.setHeader('Content-Security-Policy', "default-src 'none'") + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('Location', loc) + res.end(doc) + } + + private send(path: string, stats: Stats): void { + const res = this.response() + const req = this.req + const options = this.options + let len = stats.size + let offset = options.start ?? 0 + + if (res.headersSent) { + this.headersAlreadySent() + return + } + + this.writeHeaders(path, stats) + + this.type(path) + + if (this.isConditionalGET()) { + if (this.isPreconditionFailure()) { + this.fail(412) + return + } + + if (this.isCachable() && this.isFresh()) { + this.notModified() + return + } + } + + len = Math.max(0, len - offset) + if (options.end !== undefined) { + const bytes = options.end - offset + 1 + if (len > bytes) len = bytes + } + + const rangeHeader = req.headers.range + if (this.acceptRanges && rangeHeader !== undefined && BYTES_RANGE_REGEXP.test(rangeHeader)) { + let ranges = parseRange(len, rangeHeader, { combine: true }) + + if (!this.isRangeFresh()) ranges = -2 + + if (ranges === -1) { + res.setHeader('Content-Range', contentRange('bytes', len)) + + this.fail(416, undefined, { headers: { 'Content-Range': headerText(res.getHeader('Content-Range')) } }) + return + } + + if (typeof ranges !== 'number' && ranges.ranges.length === 1) { + const range = ranges.ranges[0] + + res.statusCode = 206 + res.setHeader('Content-Range', contentRange('bytes', len, range)) + + offset += range.start + len = range.end - range.start + 1 + } + } + + res.setHeader('Content-Length', len) + + if (req.method === 'HEAD') { + res.end() + return + } + + this.stream(path, offset, Math.max(offset, offset + len - 1)) + } + + private sendFile(path: string): void { + stat(path, (err, stats) => { + const pathEndsWithSep = path.endsWith(sep) + if (err !== null) { + if (err.code === 'ENOENT' && extname(path) === '' && !pathEndsWithSep) { + this.sendWithExtension(path, 0, err) + return + } + this.onStatError(err) + return + } + if (stats === undefined) return + if (stats.isDirectory()) { + this.redirect(path) + return + } + if (pathEndsWithSep) { + this.fail(404) + return + } + this.emitFile(path, stats) + this.send(path, stats) + }) + } + + private sendWithExtension(path: string, index: number, error: SystemError | null): void { + if (index >= this.extensions.length) { + if (error !== null) this.onStatError(error) + else this.fail(404) + return + } + + const candidate = path + '.' + this.extensions[index] + stat(candidate, (err, stats) => { + if (err !== null) { + this.sendWithExtension(path, index + 1, err) + return + } + if (stats === undefined || stats.isDirectory()) { + this.sendWithExtension(path, index + 1, null) + return + } + this.emitFile(candidate, stats) + this.send(candidate, stats) + }) + } + + private sendIndex(path: string, index: number, error: SystemError | null): void { + if (index >= this.index.length) { + if (error !== null) this.onStatError(error) + else this.fail(404) + return + } + + const candidate = join(path, this.index[index]) + stat(candidate, (err, stats) => { + if (err !== null) { + this.sendIndex(path, index + 1, err) + return + } + if (stats === undefined || stats.isDirectory()) { + this.sendIndex(path, index + 1, null) + return + } + this.emitFile(candidate, stats) + this.send(candidate, stats) + }) + } + + private emitFile(path: string, stats: Stats): void { + for (const listener of this.fileListeners.slice()) listener(path, stats) + } + + private stream(path: string, start: number, end: number): void { + const res = this.response() + + const stream = createReadStream(path, { start, end }) + for (const listener of this.streamListeners.slice()) listener(stream) + stream.pipe(res) + + const cleanup = () => { + stream.destroy() + } + + onFinished(res, cleanup) + + stream.on('error', (error: Error) => { + cleanup() + + this.onStatError(error) + }) + + stream.on('end', () => { + for (const listener of this.endListeners.slice()) listener() + }) + } + + private type(path: string): void { + const res = this.response() + if (headerText(res.getHeader('Content-Type')) !== '') return + const type = contentType(extname(path)) + res.setHeader('Content-Type', type === false ? 'application/octet-stream' : type) + } + + private writeHeaders(path: string, stats: Stats): void { + const res = this.response() + + for (const listener of this.headersListeners.slice()) listener(res, path, stats) + + if (this.acceptRanges && headerText(res.getHeader('Accept-Ranges')) === '') { + res.setHeader('Accept-Ranges', 'bytes') + } + + if (this.cacheControl && headerText(res.getHeader('Cache-Control')) === '') { + let cacheControl = 'public, max-age=' + Math.floor(this.maxage / 1000) + if (this.immutable) cacheControl += ', immutable' + res.setHeader('Cache-Control', cacheControl) + } + + if (this.lastModified && headerText(res.getHeader('Last-Modified')) === '') { + res.setHeader('Last-Modified', stats.mtime.toUTCString()) + } + + if (this.etagEnabled && headerText(res.getHeader('ETag')) === '') { + res.setHeader('ETag', etag(stats)) + } + } +} + +function collapseLeadingSlashes(str: string): string { + let i = 0 + for (; i < str.length; i++) { + if (str[i] !== '/') break + } + return i > 1 ? '/' + str.slice(i) : str +} + +function containsDotFile(parts: string[]): boolean { + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (part.length > 1 && part.startsWith('.')) return true + } + return false +} + +function contentRange(type: string, size: number, range?: Range): string { + return type + ' ' + (range !== undefined ? range.start + '-' + range.end : '*') + '/' + size +} + +function createHtmlDocument(title: string, body: string): string { + return ( + '\n' + + '\n' + + '\n' + + '\n' + + '' + + title + + '\n' + + '\n' + + '\n' + + '
' +
+    body +
+    '
\n' + + '\n' + + '\n' + ) +} + +function decodePath(path: string): string | null { + try { + return decodeURIComponent(path) + } catch { + return null + } +} + +function headerText(value: string | number | readonly string[] | undefined): string { + if (value === undefined) return '' + if (typeof value === 'string') return value + if (typeof value === 'number') return String(value) + return value.join(', ') +} + +function normalizeList(value: readonly string[] | string | false | undefined, fallback: string[]): string[] { + if (value === undefined) return fallback + if (value === false) return [] + if (typeof value === 'string') return [value] + return value.slice() +} + +function parseHttpDate(date: string): number { + if (date === '') return NaN + return Date.parse(date) +} + +function parseTokenList(str: string): string[] { + let end = 0 + const list: string[] = [] + let start = 0 + + for (let i = 0, len = str.length; i < len; i++) { + switch (str.charCodeAt(i)) { + case 0x20: + if (start === end) start = end = i + 1 + break + case 0x2c: + if (start !== end) list.push(str.substring(start, end)) + start = end = i + 1 + break + default: + end = i + 1 + break + } + } + + if (start !== end) list.push(str.substring(start, end)) + return list +} + +function send(req: IncomingMessage, path: string, options?: SendOptions): SendStream { + return new SendStream(req, path, options) +} + +type SendOptionsShape = SendOptions +type SendStreamInstance = SendStream + +namespace send { + export type SendOptions = SendOptionsShape + export type SendStream = SendStreamInstance +} + +export default send diff --git a/runtime/node/nest/shims/statuses/index.ts b/runtime/node/nest/shims/statuses/index.ts new file mode 100644 index 0000000..94a33d8 --- /dev/null +++ b/runtime/node/nest/shims/statuses/index.ts @@ -0,0 +1,59 @@ +import { STATUS_CODES } from 'node:http' + +export const message: { [code: string]: string } = {} +export const code: { [message: string]: number } = {} +export const codes: number[] = [] + +for (const key of Object.keys(STATUS_CODES)) { + const text = STATUS_CODES[key] + if (text === undefined) continue + message[key] = text + code[text.toLowerCase()] = Number(key) + codes.push(Number(key)) +} + +export const redirect: { [code: string]: boolean } = { + '300': true, + '301': true, + '302': true, + '303': true, + '305': true, + '307': true, + '308': true +} + +export const empty: { [code: string]: boolean } = { + '204': true, + '205': true, + '304': true +} + +export const retry: { [code: string]: boolean } = { + '502': true, + '503': true, + '504': true +} + +export function statusMessage(statusCode: number): string | undefined { + const key = String(statusCode) + return Object.hasOwn(message, key) ? message[key] : undefined +} + +function getStatusCode(text: string): number { + const msg = text.toLowerCase() + if (!Object.hasOwn(code, msg)) throw new Error('invalid status message: "' + text + '"') + return code[msg] +} + +function getStatusMessage(statusCode: number): string { + const text = statusMessage(statusCode) + if (text === undefined) throw new Error('invalid status code: ' + statusCode) + return text +} + +export default function status(value: number | string): string | number { + if (typeof value === 'number') return getStatusMessage(value) + const n = parseInt(value, 10) + if (!Number.isNaN(n)) return getStatusMessage(n) + return getStatusCode(value) +} diff --git a/runtime/node/nest/shims/type-is/index.ts b/runtime/node/nest/shims/type-is/index.ts new file mode 100644 index 0000000..45d9076 --- /dev/null +++ b/runtime/node/nest/shims/type-is/index.ts @@ -0,0 +1,76 @@ +import { parse as parseContentType } from '../content-type/index' +import { test as isMediaType } from '../media-typer/index' +import { lookup as mimeLookup } from '../mime-types/index' + +export interface RequestLike { + headers: RequestHeaders +} + +export interface RequestHeaders { + [name: string]: string | undefined +} + +export default function typeofrequest(req: RequestLike, types?: string | string[], ...rest: string[]): string | false | null { + if (!hasBody(req)) return null + const list = rest.length > 0 ? [types, ...rest] : types + return is(req.headers['content-type'], list) +} + +export function is(value: string | undefined, types?: string | string[] | (string | string[] | undefined)[], ...rest: string[]): string | false { + const val = normalizeType(value) + if (!val) return false + let candidates: (string | string[] | undefined)[] | undefined + if (types !== undefined && !Array.isArray(types)) candidates = [types, ...rest] + else candidates = types + if (!candidates || !candidates.length) return val + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue + if (mimeMatch(normalize(candidate), val)) { + return candidate[0] === '+' || candidate.indexOf('*') !== -1 ? val : candidate + } + } + return false +} + +export function hasBody(req: RequestLike): boolean { + if (req.headers['transfer-encoding'] !== undefined) return true + const length = req.headers['content-length'] + if (typeof length === 'string') return !isNaN(Number(length)) + return false +} + +export function normalize(type: string): string | false { + if (typeof type !== 'string') return false + switch (type) { + case 'urlencoded': + return 'application/x-www-form-urlencoded' + case 'multipart': + return 'multipart/*' + } + if (type[0] === '+') return '*/*' + type + return type.indexOf('/') === -1 ? mimeLookup(type) : type +} + +export function match(expected: string | false, actual: string): boolean { + return mimeMatch(expected, actual) +} + +function mimeMatch(expected: string | false, actual: string): boolean { + if (expected === false) return false + const actualParts = actual.split('/') + const expectedParts = expected.split('/') + if (actualParts.length !== 2 || expectedParts.length !== 2) return false + if (expectedParts[0] !== '*' && expectedParts[0] !== actualParts[0]) return false + if (expectedParts[1].slice(0, 2) === '*+') { + return expectedParts[1].length <= actualParts[1].length + 1 && + expectedParts[1].slice(1) === actualParts[1].slice(1 - expectedParts[1].length) + } + if (expectedParts[1] !== '*' && expectedParts[1] !== actualParts[1]) return false + return true +} + +function normalizeType(value: string | undefined): string | null { + if (!value) return null + const type = parseContentType(value, { parameters: false }).type + return isMediaType(type) ? type : null +} diff --git a/scripts/build.mjs b/scripts/build.mjs index 29e1b63..395b5d4 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -315,10 +315,45 @@ function builtinModules() { // framework. The package specifier below is the one mapping that names it. // `GEA_NODE_COMPAT_PACKAGE_ADAPTER=1` compiles the package instead, so the // two adapters can be measured against each other on one compiler. +const nestDir = path.join(builtinsDir, 'nest') +const nestAnsweredPackages = () => { + const entries = [] + const walk = (directory, visit) => { + if (!fs.existsSync(directory)) return + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) { + const file = path.join(directory, entry.name) + if (entry.isDirectory()) walk(file, visit) + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) visit(file) + } + } + const vendored = path.join(nestDir, 'platform-node') + walk(vendored, (file) => { + const relative = path.relative(vendored, file).slice(0, -'.ts'.length) + entries.push([`@nestjs/platform-node/${relative}`, file], [`@nestjs/platform-node/${relative}.js`, file]) + }) + const shims = path.join(nestDir, 'shims') + if (fs.existsSync(shims)) { + for (const scope of fs.readdirSync(shims, { withFileTypes: true }).filter((entry) => entry.isDirectory()).sort((left, right) => left.name.localeCompare(right.name))) { + const packages = scope.name.startsWith('@') + ? fs.readdirSync(path.join(shims, scope.name), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => `${scope.name}/${entry.name}`) + : [scope.name] + for (const name of packages) { + const root = path.join(shims, name) + walk(root, (file) => { + const relative = path.relative(root, file).slice(0, -'.ts'.length) + if (relative === 'index') entries.push([name, file]) + else entries.push([`${name}/${relative}.js`, file], [`${name}/${relative}`, file]) + }) + } + } + } + return entries +} const answeredPackages = () => new Map([ ['whatwg-url', path.join(builtinsDir, 'whatwg-url.ts')], - ...(process.env.GEA_NODE_COMPAT_PACKAGE_ADAPTER === '1' ? [] : [['@hono/node-server', path.join(builtinsDir, 'node-server-adapter.ts')]]) + ...(process.env.GEA_NODE_COMPAT_PACKAGE_ADAPTER === '1' ? [] : [['@hono/node-server', path.join(builtinsDir, 'node-server-adapter.ts')]]), + ...nestAnsweredPackages() ]) const whatwgGlobalNames = new Set(['Response', 'Request', 'Headers', 'URL', 'URLSearchParams', 'FormData']) const targetModulePathKeys = new Set([ diff --git a/scripts/builtin-modules.mjs b/scripts/builtin-modules.mjs index dd0ab87..11fc15a 100644 --- a/scripts/builtin-modules.mjs +++ b/scripts/builtin-modules.mjs @@ -47,7 +47,7 @@ function collectModules(root, skipRootDirectories = new Set()) { */ export function resolveBuiltinModules(builtinsDir, facadesDir) { const facades = collectModules(facadesDir) - const implementations = collectModules(builtinsDir, new Set(['generated'])) + const implementations = collectModules(builtinsDir, new Set(['generated', 'nest'])) const names = [...new Set([...facades.keys(), ...implementations.keys()])].sort() const modules = new Map() for (const name of names) { diff --git a/tools/nest/generate-mime-db.mjs b/tools/nest/generate-mime-db.mjs new file mode 100644 index 0000000..4f3a52a --- /dev/null +++ b/tools/nest/generate-mime-db.mjs @@ -0,0 +1,75 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const db = require('mime-db') +const { version: dbVersion } = require('mime-db/package.json') +const { version: typesVersion } = require('mime-types/package.json') +const mimeScore = require('mime-types/mimeScore') + +const output = path.resolve(import.meta.dirname, '../../runtime/node/nest/shims/mime-db/db.generated.ts') + +const extensionTypes = new Map() +const typeExtensions = new Map() +const typeCharsets = new Map() +for (const type of Object.keys(db).sort()) { + const entry = db[type] + if (entry.charset) typeCharsets.set(type, entry.charset) + const extensions = entry.extensions + if (!extensions || extensions.length === 0) continue + typeExtensions.set(type, [...extensions]) + for (const extension of extensions) { + const current = extensionTypes.get(extension) + const currentScore = current ? mimeScore(current, db[current].source) : 0 + const nextScore = mimeScore(type, db[type].source) + extensionTypes.set(extension, currentScore > nextScore ? current : type) + } +} + +const quote = (value) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` +const record = (map, render) => + [...map.entries()] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, value]) => ` ${quote(key)}: ${render(value)},`) + .join('\n') + +const text = `// Generated by tools/nest/generate-mime-db.mjs from mime-db ${dbVersion}, with +// mime-types ${typesVersion}'s extension tie-break (mimeScore). DO NOT EDIT; +// re-run the generator (\`npm run generate\` in tools/nest) after changing the pin. +// +// Only what the three callers in the vendored platform-node sources can ask +// for: NodeResponse.type(ext), type-is' short-name lookup and send's +// Content-Type by file extension. Types without extensions are not listed +// because no lookup can reach them. + +export const MIME_DB_VERSION = ${quote(dbVersion)} + +/** Extension (without the dot, lower case) to its preferred media type: mime-types' \`types\`. */ +export const extensionTypes: { [extension: string]: string } = { +${record(extensionTypes, quote)} +} + +/** Media type to its extensions, in mime-db order: mime-types' \`extensions\`. */ +export const typeExtensions: { [type: string]: string[] } = { +${record(typeExtensions, (list) => `[${list.map(quote).join(', ')}]`)} +} + +/** Media types for which mime-db states a default charset. */ +export const typeCharsets: { [type: string]: string } = { +${record(typeCharsets, quote)} +} +` + +if (process.argv.includes('--check')) { + const current = fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : null + if (current !== text) { + console.error(`${path.relative(process.cwd(), output)} is out of date; run \`npm run generate\` in tools/nest`) + process.exit(1) + } + console.log(`${path.relative(process.cwd(), output)} is current (mime-db ${dbVersion})`) +} else { + fs.mkdirSync(path.dirname(output), { recursive: true }) + fs.writeFileSync(output, text) + console.log(`wrote ${path.relative(process.cwd(), output)}: ${extensionTypes.size} extensions, ${typeExtensions.size} types, ${typeCharsets.size} charsets`) +} diff --git a/tools/nest/package-lock.json b/tools/nest/package-lock.json new file mode 100644 index 0000000..2652f8f --- /dev/null +++ b/tools/nest/package-lock.json @@ -0,0 +1,677 @@ +{ + "name": "@geastack/nest-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@geastack/nest-tools", + "dependencies": { + "content-type": "2.1.0", + "cookie": "0.7.2", + "cookie-signature": "1.2.2", + "fast-decode-uri-component": "1.0.1", + "fast-querystring": "1.1.2", + "find-my-way": "9.9.0", + "media-typer": "1.1.1", + "mime-db": "1.54.0", + "mime-types": "3.0.2", + "on-finished": "2.4.1", + "path-to-regexp": "8.4.2", + "qs": "6.15.3", + "secure-json-parse": "4.1.0", + "send": "1.2.1", + "type-is": "2.1.0" + }, + "devDependencies": { + "@types/node": "24.19.2", + "typescript": "5.9.3" + } + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", + "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tools/nest/package.json b/tools/nest/package.json new file mode 100644 index 0000000..2bfdd48 --- /dev/null +++ b/tools/nest/package.json @@ -0,0 +1,34 @@ +{ + "name": "@geastack/nest-tools", + "private": true, + "type": "module", + "description": "Generators and parity tests for runtime/node/nest: the typed shims that replace the JavaScript dependencies of @nestjs/platform-node's vendored sources.", + "scripts": { + "generate": "node generate-mime-db.mjs", + "check": "node generate-mime-db.mjs --check && tsc -p tsconfig.shims.json && node test-shims.mjs", + "test:shims": "node test-shims.mjs", + "test:probe": "node test-probe.mjs", + "typecheck": "tsc -p tsconfig.shims.json" + }, + "dependencies": { + "content-type": "2.1.0", + "cookie": "0.7.2", + "cookie-signature": "1.2.2", + "fast-decode-uri-component": "1.0.1", + "fast-querystring": "1.1.2", + "find-my-way": "9.9.0", + "media-typer": "1.1.1", + "mime-db": "1.54.0", + "mime-types": "3.0.2", + "on-finished": "2.4.1", + "path-to-regexp": "8.4.2", + "qs": "6.15.3", + "secure-json-parse": "4.1.0", + "send": "1.2.1", + "type-is": "2.1.0" + }, + "devDependencies": { + "@types/node": "24.19.2", + "typescript": "5.9.3" + } +} diff --git a/tools/nest/parity/content-type.mjs b/tools/nest/parity/content-type.mjs new file mode 100644 index 0000000..1ac2a57 --- /dev/null +++ b/tools/nest/parity/content-type.mjs @@ -0,0 +1,79 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const result = (fn) => { + try { + return { value: fn() } + } catch (error) { + return { error: `${error.name}: ${error.message}` } + } + } + const plain = (r) => (r.value === undefined ? r : { value: { ...r.value, parameters: { ...r.value.parameters } } }) + const both = (label, call) => { + assert.deepEqual(plain(result(() => call(shim))), plain(result(() => call(original))), label) + count++ + } + + const headers = [ + 'text/html', + 'TEXT/HTML', + ' text/html ', + 'text/html; charset=utf-8', + 'text/html;charset=utf-8', + 'text/html ; charset = utf-8 ', + 'text/html; charset=UTF-8; foo=bar', + 'text/html; charset="utf-8"', + 'text/html; charset="ut\\"f-8"', + 'text/html; charset="a\\\\b"', + 'text/html; charset="unterminated', + 'text/html; charset=utf-8; charset=iso-8859-1', + 'text/html; CHARSET=utf-8', + 'text/html; charset', + 'text/html; =utf-8', + 'text/html; charset=; foo=bar', + 'text/html;;; charset=utf-8', + 'text/html; charset="utf-8" junk; foo=bar', + 'text/html; charset=utf-8, text/plain', + 'text/html, text/plain; q=0.5', + 'multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW', + 'application/json;charset=utf-8;q=0.9', + '', + ';', + '; charset=utf-8', + 'text/html;\tcharset=utf-8', + 'text/html; charset=\t utf-8 \t', + 'text/html; a=1; b="2"; c=3' + ] + const optionSets = [undefined, {}, { parameters: false }, { comma: true }, { comma: true, parameters: false }, { start: 5 }, { start: 0, comma: true }] + for (const header of headers) { + for (const options of optionSets) { + both(`parse(${JSON.stringify(header)}, ${JSON.stringify(options)})`, (m) => m.parse(header, options)) + } + } + + const inputs = [ + { type: 'text/html' }, + { type: 'text/html', parameters: {} }, + { type: 'text/html', parameters: { charset: 'utf-8' } }, + { type: 'text/html', parameters: { charset: 'utf-8', foo: 'bar baz' } }, + { type: 'text/html', parameters: { foo: 'a"b' } }, + { type: 'text/html', parameters: { foo: 'a\\b' } }, + { type: 'text/html', parameters: { foo: '' } }, + { type: 'text/html', parameters: { foo: 'tab\there' } }, + { type: 'text/html', parameters: { foo: 'new\nline' } }, + { type: 'text/html', parameters: { foo: 'é' } }, + { type: 'text/html', parameters: { foo: 'Ā' } }, + { type: 'text/html', parameters: { 'bad name': 'x' } }, + { type: 'text/html', parameters: { 'b@d': 'x' } }, + { type: 'text/html', parameters: { ok: 'x', 'bad name': 'y' } }, + { type: 'text' }, + { type: 'text/' }, + { type: 'text/ht ml' }, + { type: '' }, + {}, + { type: 'TEXT/HTML', parameters: { Charset: 'UTF-8' } } + ] + for (const input of inputs) { + both(`format(${JSON.stringify(input)})`, (m) => m.format(input)) + } + return count +} diff --git a/tools/nest/parity/cookie-signature.mjs b/tools/nest/parity/cookie-signature.mjs new file mode 100644 index 0000000..a432961 --- /dev/null +++ b/tools/nest/parity/cookie-signature.mjs @@ -0,0 +1,26 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const both = (label, call) => { + const results = [shim, original].map((m) => { try { return { value: call(m) } } catch (error) { return { error: `${error.constructor.name}: ${error.message}` } } }) + assert.deepEqual(results[0], results[1], label) + count++ + } + const secrets = ['tobiiscool', '', 'ünï', Buffer.from('bytes'), Buffer.alloc(0)] + const values = ['hello', '', 'a.b.c', 'with=equals', 'ünïcode', 'x'.repeat(500)] + for (const secret of secrets) for (const value of values) { + both(`sign ${JSON.stringify(value)}`, (m) => m.sign(value, secret)) + const signed = original.sign(value, secret) + both(`unsign ok ${JSON.stringify(value)}`, (m) => m.unsign(signed, secret)) + both(`unsign wrong secret`, (m) => m.unsign(signed, 'other')) + both(`unsign tampered`, (m) => m.unsign(signed.slice(0, -1) + (signed.endsWith('A') ? 'B' : 'A'), secret)) + both(`unsign tampered value`, (m) => m.unsign('z' + signed, secret)) + } + both('unsign no dot', (m) => m.unsign('nodot', 'secret')) + both('unsign empty', (m) => m.unsign('', 'secret')) + both('sign non-string', (m) => m.sign(42, 'secret')) + both('sign no secret', (m) => m.sign('v', undefined)) + both('sign null secret', (m) => m.sign('v', null)) + both('unsign non-string', (m) => m.unsign(42, 'secret')) + both('unsign no secret', (m) => m.unsign('v.x', undefined)) + return count +} diff --git a/tools/nest/parity/cookie.mjs b/tools/nest/parity/cookie.mjs new file mode 100644 index 0000000..881969c --- /dev/null +++ b/tools/nest/parity/cookie.mjs @@ -0,0 +1,43 @@ +const same = (assert, fn, args, label) => { + const [shimResult, originalResult] = args.map((f) => { + try { return { value: f() } } catch (error) { return { error: `${error.constructor.name}: ${error.message}` } } + }) + assert.deepEqual(shimResult, originalResult, label) +} + +export async function run({ shim, original, assert }) { + let count = 0 + const check = (label, call) => { + same(assert, null, [() => call(shim), () => call(original)], label) + count++ + } + const parseInputs = [ + '', 'a', 'a=b', 'a=b; c=d', 'a=b;c=d', ' a = b ;c=d', 'a="b"', 'a="b', 'a=b=c', 'a=%20b', 'a=%E0%A4%A', 'a=b; a=c', + '=b', ';=b', 'a; b=c', 'foo=bar; equation=E%3Dmc%5E2', 'a=\tb\t;c=d', 'x=1;', '; a=b', 'a=b;;c=d', 'a=b=;c', 'a==b', + '__proto__=x; constructor=y', 'a=b; a', 'a', 'a=', 'a="";b=1', 'a=" b "', 'k=v; q="1;2"' + ] + for (const input of parseInputs) check(`parse(${JSON.stringify(input)})`, (m) => m.parse(input)) + check('parse non-string', (m) => m.parse(42)) + check('parse custom decode', (m) => m.parse('a=%41%42', { decode: (v) => v.toLowerCase() })) + check('parse decode throws', (m) => m.parse('a=%', { decode: () => { throw new Error('boom') } })) + + const serializeCases = [ + ['a', 'b'], ['a', ''], ['a', 'b c'], ['a', '"quoted"'], ['a', 'ünïcode'], ['a', 'x;y'], + ['a b', 'c'], ['', 'c'], ['a=', 'c'], ['a', 'b', { maxAge: 10 }], ['a', 'b', { maxAge: 10.9 }], ['a', 'b', { maxAge: -1 }], + ['a', 'b', { maxAge: Infinity }], ['a', 'b', { maxAge: NaN }], ['a', 'b', { maxAge: null }], ['a', 'b', { maxAge: 0 }], + ['a', 'b', { domain: 'example.com' }], ['a', 'b', { domain: '.example.com' }], ['a', 'b', { domain: 'ex ample.com' }], + ['a', 'b', { domain: '' }], ['a', 'b', { path: '/' }], ['a', 'b', { path: '/a;b' }], ['a', 'b', { path: '' }], + ['a', 'b', { expires: new Date(0) }], ['a', 'b', { expires: new Date(NaN) }], ['a', 'b', { expires: '2020' }], + ['a', 'b', { expires: null }], ['a', 'b', { httpOnly: true }], ['a', 'b', { httpOnly: false }], ['a', 'b', { secure: true }], + ['a', 'b', { partitioned: true }], ['a', 'b', { priority: 'low' }], ['a', 'b', { priority: 'MEDIUM' }], ['a', 'b', { priority: 'high' }], + ['a', 'b', { priority: 'urgent' }], ['a', 'b', { priority: '' }], ['a', 'b', { sameSite: true }], ['a', 'b', { sameSite: 'lax' }], + ['a', 'b', { sameSite: 'Strict' }], ['a', 'b', { sameSite: 'none' }], ['a', 'b', { sameSite: 'other' }], ['a', 'b', { sameSite: false }], + ['a', 'b', { encode: (v) => v.toUpperCase() }], ['a', 'b', { encode: (v) => v + ';' }], ['a', 'b', { encode: 'nope' }], + ['a', 'b', { maxAge: 3600, domain: 'example.com', path: '/x', expires: new Date(Date.UTC(2030, 0, 1)), httpOnly: true, secure: true, partitioned: true, priority: 'high', sameSite: 'strict' }], + ['a', 'b', {}], ['a', 'b', undefined] + ] + for (const [name, value, options] of serializeCases) { + check(`serialize(${JSON.stringify(name)}, ${JSON.stringify(value)}, ${JSON.stringify(options)})`, (m) => m.serialize(name, value, options)) + } + return count +} diff --git a/tools/nest/parity/encodeurl.mjs b/tools/nest/parity/encodeurl.mjs new file mode 100644 index 0000000..4d793e1 --- /dev/null +++ b/tools/nest/parity/encodeurl.mjs @@ -0,0 +1,15 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const both = (str) => { + assert.equal(shim.default(str), original(str), `encodeUrl(${JSON.stringify(str)})`) + count++ + } + const strings = [ + '', '/', '/foo/bar', '/foo bar', '/foo%20bar', '/foo%zzbar', '/foo%2', '/foo%', '/foo%%20', '/path?a=1&b=2#frag', '/héllo', '/日本語', '/😀', '/\uD83D', '/\uDE00', '/😀', + '/\uD83Dx', 'x\uDE00', '\uD83D', '\uDE00', '/a😀b\uD83Dc', 'http://example.com/path with spaces', 'http://example.com/%E6%97%A5', '/foo"bar', "/foo'bar", '/foo', + '/foo\\bar', '/foo`bar', '/foo{bar}', '/foo|bar', '/foo^bar', '/foo[bar]', '/foo~bar', '/foo-bar_baz.qux', '/foo\nbar', '/foo\tbar', '/foo\u0000bar', '/foo%00bar', '//double//slash/', + '/nested/', '/a/b/../c', '/%E2%82%AC', '/€', '%', '%%', '%2', '%2G', '%20%20', '/foo%E2%82', '/foo%E2%82%AC', 'ftp://[::1]/path', '/a?b=c d&e=f%20g', '/a#b c' + ] + for (const str of strings) both(str) + return count +} diff --git a/tools/nest/parity/escape-html.mjs b/tools/nest/parity/escape-html.mjs new file mode 100644 index 0000000..95058d1 --- /dev/null +++ b/tools/nest/parity/escape-html.mjs @@ -0,0 +1,13 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const both = (str) => { + assert.equal(shim.default(str), original(str), `escapeHtml(${JSON.stringify(str)})`) + count++ + } + const strings = [ + '', 'plain text', '"', '&', "'", '<', '>', '', "it's & that's", 'a < b > c', '&', '&&&&', '""""', '<<>>', 'x"y&z\'wu', + 'héllo & "ünïcode"', '😀 & "x"', 'no escape here!', '\n<\n>\n', 'tab\t<\t>', '/path/to/file?a=1&b=2', 'Redirecting to /foo/', '<', 'end<', ' { + assert.equal(shim.default(...args), original(...args), label) + count++ + } + + const strings = ['', 'a', 'hello world', 'héllo wörld', '😀 emoji', 'x'.repeat(10000), '\u0000\u0001', 'line\nbreak', JSON.stringify({ a: 1, b: [1, 2, 3] })] + for (const str of strings) { + both(`etag(${JSON.stringify(str.slice(0, 20))})`, str) + both(`etag(${JSON.stringify(str.slice(0, 20))}, {weak: true})`, str, { weak: true }) + both(`etag(${JSON.stringify(str.slice(0, 20))}, {weak: false})`, str, { weak: false }) + both(`etag(Buffer ${JSON.stringify(str.slice(0, 20))})`, Buffer.from(str, 'utf8')) + both(`etag(Buffer ${JSON.stringify(str.slice(0, 20))}, {weak: true})`, Buffer.from(str, 'utf8'), { weak: true }) + } + both('etag(Buffer.alloc(0))', Buffer.alloc(0)) + both('etag(binary Buffer)', Buffer.from([0, 255, 128, 1, 2, 3])) + both('etag(latin1 Buffer)', Buffer.from('héllo', 'latin1')) + + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'etag-parity-')) + try { + const files = { 'empty.txt': '', 'hello.txt': 'hello world', 'big.bin': 'z'.repeat(123456) } + for (const [name, content] of Object.entries(files)) { + const file = path.join(directory, name) + fs.writeFileSync(file, content) + const stats = fs.statSync(file) + both(`etag(stat ${name})`, stats) + both(`etag(stat ${name}, {weak: false})`, stats, { weak: false }) + both(`etag(stat ${name}, {weak: true})`, stats, { weak: true }) + both(`etag(stat ${name}, {})`, stats, {}) + both(`etag(stat ${name}, {weak: undefined})`, stats, { weak: undefined }) + } + const stats = fs.statSync(directory) + both('etag(stat directory)', stats) + const old = fs.statSync(path.join(directory, 'hello.txt')) + old.mtime = new Date(-86400000) + both('etag(stat negative mtime)', old) + old.mtime = new Date(1234567890123) + old.size = 0xabcdef + both('etag(stat set mtime/size)', old) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } + + return count +} diff --git a/tools/nest/parity/fast-querystring.mjs b/tools/nest/parity/fast-querystring.mjs new file mode 100644 index 0000000..ac9b44b --- /dev/null +++ b/tools/nest/parity/fast-querystring.mjs @@ -0,0 +1,43 @@ +export async function run({ shim, original, require, assert }) { + const decodeOriginal = require('fast-decode-uri-component') + let count = 0 + const both = (label, call) => { + const results = [shim, original].map((m) => { + try { + return { value: JSON.parse(JSON.stringify(call(m))) } + } catch (error) { + return { error: `${error.constructor.name}: ${error.message}` } + } + }) + assert.deepEqual(results[0], results[1], label) + count++ + } + const inputs = [ + '', 'a', 'a=', '=a', '=', '&', '&&', 'a=b', 'a=b&c=d', 'a=b&a=c', 'a=b&a=c&a=d', 'a&a=b', 'a=b&a', 'a=&a=', 'a==b', 'a=b=c', 'a=b&&c=d', + '&a=b', 'a=b&', 'a+b=c+d', 'a=b+c', '+=+', 'a%20b=c%20d', 'a=%E2%9C%93', 'a=%C3%A9', 'a=%E9', 'a=%zz', 'a=%', 'a=%2', 'a=%25', 'a=%2525', + 'a%5B%5D=1&a%5B%5D=2', '%61=%62', 'a=%F0%9F%98%80', 'a=%ff%fe', 'a=%C0%80', 'a=%ED%A0%80', 'a=%E2%82', 'a=%E2%82%ACx', 'a=b%', 'a=b%2', + 'a=b%20', 'a=%20%20', '%=%', '%25=%25', 'a=b&c', 'a&b&c', 'a=1&b=2&c=3&a=4', 'x[y]=1&x[z]=2', 'a=b;c=d', '?a=b', 'a=b#c', 'a=%2B&b=+', + 'ключ=значение', 'a=😀', '😀=a', 'a=%F0%9F%98%80&a=%F0%9F%98%80', 'a=b&a=c&b=d&a=e', 'constructor=1', '__proto__[x]=1', 'a[__proto__]=1', 'a=null', 'a=undefined', + 'a=%E2%9C%93&b=%26&c=%3D', 'a%3Db=c', 'a=b%26c%3Dd', 'a=%7B%22x%22%3A1%7D', 'q=hello+world&page=2&tags=x&tags=y&tags=z' + ] + for (const input of inputs) both(`parse ${JSON.stringify(input)}`, (m) => m.parse(input)) + assert.deepEqual(Object.keys(shim.parse('__proto__=1&a=b')), ['a']) + assert.deepEqual(Object.keys(original.parse('__proto__=1&a=b')), ['__proto__', 'a']) + assert.equal(Object.getPrototypeOf(shim.parse('__proto__=1')), Object.prototype) + count++ + const decodeInputs = ['', 'abc', '%41', '%41%42', 'a%20b', '%E2%9C%93', '%F0%9F%98%80', '%C3%A9', '%E9', '%zz', '%', '%2', 'a%', '%C0%80', '%ED%A0%80', '%E2%82', '%E2%82%AC', '%e2%9c%93', '%2541', '%%41', '%41%'] + for (const input of decodeInputs) { + assert.equal(shim.decodeURIComponent(input), decodeOriginal(input), `decodeURIComponent ${JSON.stringify(input)}`) + count++ + } + const objects = [ + {}, { a: 'b' }, { a: 'b', c: 'd' }, { a: ['b', 'c'] }, { a: [] }, { a: '' }, { a: 1 }, { a: 0 }, { a: -1.5 }, { a: 1e21 }, { a: 1e22 }, { a: Infinity }, { a: NaN }, + { a: true }, { a: false }, { a: null }, { a: undefined }, { a: 10n }, { a: ['x', 1, true, null, undefined, 2n] }, { 'a b': 'c d' }, { 'a&b': 'c=d' }, + { a: 'é' }, { a: '😀' }, { '😀': '😀' }, { a: '!-._~\'()*' }, { a: 'a+b' }, { a: '%' }, { 'ключ': 'значение' }, { a: 'b', c: ['d', 'e'], f: 'g' } + ] + for (const object of objects) both(`stringify ${JSON.stringify(object, (k, v) => (typeof v === 'bigint' ? `${v}n` : v))}`, (m) => m.stringify(object)) + assert.throws(() => shim.encodeString('a\ud800'), /URI malformed/) + assert.throws(() => original.stringify({ a: 'a\ud800' }), /URI malformed/) + count++ + return count +} diff --git a/tools/nest/parity/find-my-way.mjs b/tools/nest/parity/find-my-way.mjs new file mode 100644 index 0000000..6b6dcf6 --- /dev/null +++ b/tools/nest/parity/find-my-way.mjs @@ -0,0 +1,514 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const factories = [shim.default, original] + const outcome = (call) => { + try { + return { value: call() } + } catch (error) { + return { error: `${error.constructor.name}: ${error.message}` } + } + } + const normResult = (r) => (r === null || r === undefined ? r : { handler: r.handler.name || '(anonymous)', params: { ...r.params }, store: r.store ?? null, searchParams: { ...r.searchParams } }) + const normRoute = (r) => (r === null ? r : { handler: r.handler.name || '(anonymous)', store: r.store ?? null, params: [...r.params] }) + const both = (label, call) => { + const results = factories.map((factory) => outcome(() => call(factory))) + assert.deepEqual(results[0], results[1], label) + count++ + } + const bothAsync = async (label, call) => { + const results = [] + for (const factory of factories) { + try { + results.push({ value: await call(factory) }) + } catch (error) { + results.push({ error: `${error.constructor.name}: ${error.message}` }) + } + } + assert.deepEqual(results[0], results[1], label) + count++ + } + function handlerA() {} + function handlerB() {} + function handlerC() {} + function handlerD() {} + + const scenarios = [ + { + name: 'static and parametric', + routes: [ + ['GET', '/', handlerA, { r: 'root' }], + ['GET', '/users', handlerB, { r: 'users' }], + ['GET', '/users/:id', handlerC, { r: 'user' }], + ['GET', '/users/:id/posts/:postId', handlerD, { r: 'post' }], + ['POST', '/users', handlerA, { r: 'create' }], + ['GET', '/user', handlerB], + ['GET', '/usual', handlerC], + ['GET', '/a/b/c', handlerD], + ['GET', '/a/:b/c', handlerA], + ['GET', '/a/:b/:c', handlerB], + ['GET', '/a/*', handlerC], + ['HEAD', '/users', handlerD, { r: 'head' }] + ], + lookups: [ + ['GET', '/'], ['GET', '/users'], ['GET', '/users/'], ['GET', '/users/42'], ['GET', '/users/42/posts/7'], ['GET', '/users/42/posts'], ['GET', '/users//profile'], ['GET', '/users/'], + ['POST', '/users'], ['POST', '/users/42'], ['GET', '/user'], ['GET', '/usual'], ['GET', '/usu'], ['GET', '/a/b/c'], ['GET', '/a/x/c'], ['GET', '/a/x/y'], ['GET', '/a/x/y/z'], ['GET', '/a/b/c/d'], ['GET', '/a/'], + ['GET', '/a'], ['HEAD', '/users'], ['DELETE', '/users'], ['GET', '/users?x=1&y=2&y=3'], ['GET', '/users/42?a=b'], ['GET', '/users/%E2%9C%93'], ['GET', '/users/a%2Fb'], ['GET', '/users/a%20b'], ['GET', '/users/%'], + ['GET', '/users/%zz'], ['GET', '/USERS'], ['GET', 'http://example.com/users'], ['GET', 'http://example.com'], ['GET', 'http://example.com?x=1'], ['GET', 'https://example.com/users/1#frag'], ['GET', 'ftp://example.com/users'], ['GET', 'users'], + ['GET', '/users/' + 'x'.repeat(100)], ['GET', '/users/' + 'x'.repeat(101)], ['GET', '/a/' + 'x'.repeat(200)], ['GET', '/users;a=b'], ['GET', '/users/1;a=b'], ['GET', '/users/caf%C3%A9'], ['GET', '/a//b/c'], ['GET', '//users'], ['GET', '/users#frag'], ['GET', '/users?'] + ] + }, + { + name: 'regex, multi-param and suffix nodes', + routes: [ + ['GET', '/items/:id(\\d+)', handlerA, 'num'], + ['GET', '/items/:slug', handlerB, 'slug'], + ['GET', '/files/:name.:ext', handlerC, 'file'], + ['GET', '/files/:name', handlerD, 'plainfile'], + ['GET', '/range/:from-:to', handlerA, 'range'], + ['GET', '/date/:year(\\d{4})-:month(\\d{2})-:day(\\d{2})', handlerB, 'date'], + ['GET', '/pre:id', handlerC, 'prefixed'], + ['GET', '/hex/:h([a-f0-9]+)$', handlerD, 'hex'], + ['GET', '/anchored/:x(^\\d+$)', handlerA, 'anchored'], + ['GET', '/mixed/:a(\\d+)-:b', handlerB, 'mixed'], + ['GET', '/suffix/:a.json', handlerC, 'json'], + ['GET', '/suffix/:a.xml', handlerD, 'xml'], + ['GET', '/suffix/:a.tar.gz', handlerA, 'targz'], + ['GET', '/dotted/:a.:b.:c', handlerB, 'dotted'], + ['GET', '/escaped/::literal', handlerC, 'colon'], + ['GET', '/escaped/:x::y', handlerD, 'paramcolon'], + ['GET', '/percent/%', handlerA, 'percent'], + ['GET', '/percent/%25', handlerB, 'percent25'], + ['GET', '/group/:g((?:a|b)+)', handlerC, 'group'] + ], + lookups: [ + ['GET', '/items/42'], ['GET', '/items/abc'], ['GET', '/items/42abc'], ['GET', '/items/'], ['GET', '/files/readme.md'], ['GET', '/files/readme'], ['GET', '/files/a.b.c'], ['GET', '/files/.md'], ['GET', '/files/readme.'], + ['GET', '/range/1-9'], ['GET', '/range/1-2-3'], ['GET', '/range/-'], ['GET', '/range/a'], ['GET', '/date/2024-01-31'], ['GET', '/date/2024-1-31'], ['GET', '/date/20240131'], ['GET', '/pre123'], ['GET', '/pre'], + ['GET', '/hex/deadbeef'], ['GET', '/hex/xyz'], ['GET', '/anchored/123'], ['GET', '/anchored/12a'], ['GET', '/mixed/12-ab'], ['GET', '/mixed/ab-12'], ['GET', '/mixed/12-a-b'], ['GET', '/suffix/a.json'], ['GET', '/suffix/a.xml'], + ['GET', '/suffix/a.tar.gz'], ['GET', '/suffix/a.b.json'], ['GET', '/suffix/a.gz'], ['GET', '/dotted/a.b.c'], ['GET', '/dotted/a.b'], ['GET', '/dotted/a.b.c.d'], ['GET', '/escaped/:literal'], ['GET', '/escaped/x:y'], ['GET', '/escaped/x'], + ['GET', '/escaped/%3Aliteral'], ['GET', '/percent/%'], ['GET', '/percent/%25'], ['GET', '/percent/%2525'], ['GET', '/group/abab'], ['GET', '/group/abc'], ['GET', '/items/%31%32'], ['GET', '/files/a%2Eb'], ['GET', '/range/1%2D2'], + ['GET', '/items/' + '1'.repeat(101)], ['GET', '/files/' + 'a'.repeat(101) + '.md'], ['GET', '/range/' + 'a'.repeat(60) + '-' + 'b'.repeat(60)] + ] + }, + { + name: 'wildcard and root wildcard', + routes: [ + ['GET', '*', handlerA, 'all'], + ['GET', '/static/*', handlerB, 'static'], + ['GET', '/static/index.html', handlerC, 'index'], + ['GET', '/static/:file.css', handlerD, 'css'], + ['POST', '/*', handlerA, 'postall'], + ['GET', '/deep/:a/*', handlerB, 'deep'] + ], + lookups: [['GET', '/'], ['GET', '/anything/at/all'], ['GET', '/static/'], ['GET', '/static/a/b.js'], ['GET', '/static/index.html'], ['GET', '/static/site.css'], ['GET', '/static/a/site.css'], ['POST', '/'], ['POST', '/x'], ['GET', '/deep/1/2/3'], ['GET', '/deep/1/'], ['GET', '/deep/1'], ['GET', '/static/%E2%9C%93/x'], ['GET', 'http://h/static/x']] + }, + { + name: 'optional parameters', + routes: [ + ['GET', '/opt/:a?', handlerA, 'opt'], + ['GET', '/opt2/:a/:b?', handlerB, 'opt2'], + ['GET', '/:root?', handlerC, 'rootopt'], + ['GET', '/trail/:a?/', handlerD, 'trail'] + ], + lookups: [['GET', '/opt'], ['GET', '/opt/'], ['GET', '/opt/x'], ['GET', '/opt2/1'], ['GET', '/opt2/1/2'], ['GET', '/opt2/1/'], ['GET', '/'], ['GET', '/x'], ['GET', '/trail'], ['GET', '/trail/'], ['GET', '/trail/x'], ['GET', '/trail/x/']] + } + ] + + const register = (router, routes) => { + for (const [method, path, handler, store] of routes) { + if (store === undefined) router.on(method, path, handler) + else router.on(method, path, handler, store) + } + } + + const optionSets = [ + {}, { ignoreTrailingSlash: true }, { ignoreDuplicateSlashes: true }, { ignoreTrailingSlash: true, ignoreDuplicateSlashes: true }, { caseSensitive: false }, { maxParamLength: 5 }, { maxParamLength: 5, onMaxParamLength: function tooLong() {} }, + { onBadUrl: function badUrl() {} }, { useSemicolonDelimiter: true }, { querystringParser: function custom(q) { return { raw: q } } }, { allowUnsafeRegex: true } + ] + for (const scenario of scenarios) { + for (const options of optionSets) { + const label = `${scenario.name} ${JSON.stringify(Object.keys(options))}` + both(`${label}: registration`, (factory) => { + const router = factory(options) + register(router, scenario.routes) + return router.routes.map((r) => ({ method: r.method, path: r.path, pattern: r.pattern, params: r.params, constraints: r.opts.constraints ?? null, store: r.store ?? null })) + }) + for (const [method, url] of scenario.lookups) { + both(`${label}: find(${method} ${url})`, (factory) => { + const router = factory(options) + register(router, scenario.routes) + return normResult(router.find(method, url)) + }) + } + both(`${label}: prettyPrint`, (factory) => { + const router = factory(options) + register(router, scenario.routes) + return [router.prettyPrint(), router.prettyPrint({ commonPrefix: false }), router.prettyPrint({ method: 'GET' }), router.prettyPrint({ method: 'POST', commonPrefix: false }), router.prettyPrint({ method: 'PATCH' })] + }) + both(`${label}: findRoute/hasRoute`, (factory) => { + const router = factory(options) + register(router, scenario.routes) + return scenario.routes.map(([method, path]) => [normRoute(router.findRoute(method, path)), router.hasRoute(method, path), router.hasRoute(method, path + '/x'), router.hasRoute('PUT', path)]) + }) + both(`${label}: off each then find`, (factory) => { + const out = [] + for (const [method, path] of scenario.routes) { + const router = factory(options) + register(router, scenario.routes) + router.off(method, path) + out.push(router.routes.map((r) => r.pattern), scenario.lookups.map(([m, u]) => normResult(router.find(m, u)))) + } + return out + }) + } + } + + both('onBadUrl result', (factory) => { + const router = factory({ onBadUrl: function badUrl() {} }) + router.on('GET', '/a/:b', handlerA) + const r = router.find('GET', '/a/%zz') + return r && { handler: typeof r.handler, params: { ...r.params }, store: r.store, searchParams: { ...r.searchParams } } + }) + both('onMaxParamLength result', (factory) => { + const router = factory({ maxParamLength: 3, onMaxParamLength: function tooLong() {} }) + router.on('GET', '/a/:b', handlerA, { s: 1 }) + router.on('GET', '/a/:b(\\d+)', handlerB, { s: 2 }) + router.on('GET', '/c/:d-:e', handlerC, { s: 3 }) + return ['/a/xxxx', '/a/1234', '/a/xx', '/c/xxxx-y', '/c/x-y'].map((p) => { + const r = router.find('GET', p) + return r && { handler: typeof r.handler, params: { ...r.params }, store: r.store, searchParams: { ...r.searchParams } } + }) + }) + + const statics = (factory) => (factory === original ? original : shim) + for (const p of ['/', '/a//b', '//', '/a/', '/a/b/', 'a/', '/a%2Fb', '/a%20b', '/a;b', '/caf%C3%A9', '/%', '/a///b//c/']) { + both(`sanitizeUrlPath(${p})`, (factory) => [statics(factory).sanitizeUrlPath(p), statics(factory).sanitizeUrlPath(p, true)]) + both(`removeDuplicateSlashes(${p})`, (factory) => statics(factory).removeDuplicateSlashes(p)) + both(`trimLastSlash(${p})`, (factory) => statics(factory).trimLastSlash(p)) + } + + const badRegistrations = [ + ['GET', ''], ['GET', 'users'], ['FETCH', '/a'], ['GET', '/a/*/b'], ['GET', '/a/:b?/c'], ['GET', '/a/:b(\\d+'], ['GET', '/a/:b((a+)+)'], ['GET', '/a/:b((a+)+)', { allowUnsafeRegex: true }], [['GET', 'POST'], '/ok'], [['GET', 'NOPE'], '/a'], + ['GET', '/a', { constraints: 'x' }], ['GET', '/a', { constraints: { version: 1 } }], ['GET', '/a', { constraints: { host: 1 } }], ['GET', '/a', { constraints: { unknown: 'x' } }], ['GET', '/a', { constraints: { version: undefined } }], ['GET', '/a', { constraints: { version: 'x.y' } }], ['GET', '/a', { constraints: { host: /x/g } }] + ] + for (const [method, path, optsOrConfig] of badRegistrations) { + both(`on(${JSON.stringify(method)}, ${JSON.stringify(path)}, ${JSON.stringify(optsOrConfig)})`, (factory) => { + const config = optsOrConfig && 'allowUnsafeRegex' in optsOrConfig ? optsOrConfig : {} + const opts = optsOrConfig && 'constraints' in optsOrConfig ? optsOrConfig : null + const router = factory(config) + if (opts) router.on(method, path, opts, handlerA) + else router.on(method, path, handlerA) + return router.routes.map((r) => [r.method, r.pattern]) + }) + } + both('handler not a function', (factory) => factory().on('GET', '/a', {}, 'nope')) + both('handler not a function (3 args)', (factory) => factory().on('GET', '/a', 'nope')) + both('duplicate route', (factory) => { + const router = factory() + router.on('GET', '/a/:b', handlerA) + router.on('GET', '/a/:c', handlerB) + }) + both('duplicate route with constraints', (factory) => { + const router = factory() + router.on('GET', '/a', { constraints: { host: 'x', version: '1.0.0' } }, handlerA) + router.on('GET', '/a', { constraints: { version: '1.0.0', host: 'x' } }, handlerB) + }) + both('duplicate route with regex constraint', (factory) => { + const router = factory() + router.on('GET', '/a', { constraints: { host: /x/ } }, handlerA) + router.on('GET', '/a', { constraints: { host: /x/ } }, handlerB) + }) + both('same path different constraints is fine', (factory) => { + const router = factory() + router.on('GET', '/a', { constraints: { host: 'x' } }, handlerA) + router.on('GET', '/a', { constraints: { host: 'y' } }, handlerB) + router.on('GET', '/a', handlerC) + return router.routes.length + }) + both('31 handler limit', (factory) => { + const router = factory() + for (let i = 0; i < 32; i++) router.on('GET', '/a', { constraints: { host: `h${i}` } }, handlerA, i) + }) + both('31 handler limit reached exactly', (factory) => { + const router = factory() + for (let i = 0; i < 31; i++) router.on('GET', '/a', { constraints: { host: `h${i}` } }, handlerA, i) + return ['h0', 'h15', 'h30', 'h31'].map((host) => normResult(router.find('GET', '/a', { host }))) + }) + both('off validation', (factory) => factory().off('GET', 'nope')) + both('off constraints validation', (factory) => factory().off('GET', '/a', [])) + both('off unknown method', (factory) => factory().off('NOPE', '/a')) + both('off with optional param', (factory) => { + const router = factory() + router.on('GET', '/a/:b?', handlerA) + router.off('GET', '/a/:b?') + return router.routes.length + }) + both('off with constraints only removes that one', (factory) => { + const router = factory() + router.on('GET', '/a', { constraints: { host: 'x' } }, handlerA, 1) + router.on('GET', '/a', { constraints: { host: 'y' } }, handlerB, 2) + router.on('GET', '/a', handlerC, 3) + router.off('GET', '/a', { host: 'x' }) + const withY = router.routes.map((r) => r.store) + router.off('GET', '/a') + return [withY, router.routes.length] + }) + both('off array of methods and ignoreTrailingSlash', (factory) => { + const router = factory({ ignoreTrailingSlash: true }) + router.on(['GET', 'POST'], '/a/', handlerA) + router.on('PUT', '/a', handlerB) + router.off(['GET', 'PUT'], '/a') + return router.routes.map((r) => [r.method, r.path, r.pattern]) + }) + both('reset', (factory) => { + const router = factory() + router.on('GET', '/a', handlerA) + router.reset() + return [router.routes.length, router.find('GET', '/a'), router.prettyPrint()] + }) + + const constrained = (factory, config = {}) => { + const router = factory(config) + router.on('GET', '/v', { constraints: { version: '1.0.0' } }, handlerA, 'v1.0.0') + router.on('GET', '/v', { constraints: { version: '1.2.0' } }, handlerB, 'v1.2.0') + router.on('GET', '/v', { constraints: { version: '1.2.3' } }, handlerC, 'v1.2.3') + router.on('GET', '/v', { constraints: { version: '2.0.0' } }, handlerD, 'v2.0.0') + router.on('GET', '/v', { constraints: { version: '2.1.0' } }, handlerA, 'v2.1.0') + router.on('GET', '/v', { constraints: { version: '10.0.0' } }, handlerB, 'v10') + router.on('GET', '/h', { constraints: { host: 'fastify.dev' } }, handlerA, 'dev') + router.on('GET', '/h', { constraints: { host: 'fastify.io' } }, handlerB, 'io') + router.on('GET', '/h', { constraints: { host: /.+\.example\.com$/ } }, handlerC, 'regex') + router.on('GET', '/h', handlerD, 'nohost') + router.on('GET', '/hv', { constraints: { host: 'fastify.dev', version: '1.0.0' } }, handlerA, 'dev1') + router.on('GET', '/hv', { constraints: { host: 'fastify.dev', version: '2.0.0' } }, handlerB, 'dev2') + router.on('GET', '/hv', { constraints: { host: 'fastify.io' } }, handlerC, 'io') + router.on('GET', '/hv', { constraints: { version: '3.0.0' } }, handlerD, 'v3') + router.on('GET', '/plain', handlerA, 'plain') + router.on('GET', '/onlyv', { constraints: { version: '1.0.0' } }, handlerA, 'onlyv') + router.on('GET', '/p/:x', { constraints: { host: 'a' } }, handlerA, 'pa') + router.on('GET', '/p/:x', { constraints: { host: 'b' } }, handlerB, 'pb') + router.on('GET', '/p/:x(\\d+)', handlerC, 'pnum') + router.on('GET', '/p/*', handlerD, 'pwild') + return router + } + const versions = [undefined, '1.0.0', '1.2.0', '1.2.3', '1.2.x', '1.x', 'x', '*', '1.x.x', '2.x', '2.1.x', '10.x', '3.0.0', '1.2.4', '0.0.0', 'x.x', 'x.x.x', '1.2', '1', '2.0.x', 'junk'] + const hosts = [undefined, 'fastify.dev', 'fastify.io', 'a.example.com', 'b.example.com', 'example.com', 'other', 'a', 'b', ''] + for (const version of versions) { + for (const host of hosts) { + const derived = {} + if (version !== undefined) derived.version = version + if (host !== undefined) derived.host = host + both(`constraints ${JSON.stringify(derived)}`, (factory) => { + const router = constrained(factory) + return ['/v', '/h', '/hv', '/plain', '/onlyv', '/p/12', '/p/ab', '/p/a/b'].map((p) => normResult(router.find('GET', p, derived))) + }) + } + } + both('constraints with undefined object', (factory) => { + const router = constrained(factory) + return ['/v', '/h', '/plain'].map((p) => normResult(router.find('GET', p))) + }) + both('constrained prettyPrint', (factory) => { + const router = constrained(factory) + return [router.prettyPrint(), router.prettyPrint({ method: 'GET' }), router.prettyPrint({ commonPrefix: false })] + }) + both('constrained findRoute', (factory) => { + const router = constrained(factory) + return [ + normRoute(router.findRoute('GET', '/v', { version: '1.2.0' })), normRoute(router.findRoute('GET', '/v')), normRoute(router.findRoute('GET', '/h', { host: /.+\.example\.com$/ })), normRoute(router.findRoute('GET', '/h')), + router.hasRoute('GET', '/hv', { version: '1.0.0', host: 'fastify.dev' }), router.hasRoute('GET', '/hv', { host: 'fastify.dev', version: '1.0.0' }), router.hasRoute('GET', '/hv', { host: 'fastify.dev' }), router.hasRoute('GET', '/p/:y(\\d+)'), router.hasRoute('GET', '/p/:x(\\d+)') + ] + }) + both('constrained off', (factory) => { + const router = constrained(factory) + router.off('GET', '/v', { version: '1.2.0' }) + router.off('GET', '/h') + return [router.routes.map((r) => r.store), normResult(router.find('GET', '/v', { version: '1.2.x' })), normResult(router.find('GET', '/h', { host: 'fastify.dev' }))] + }) + both('version store maxima', (factory) => { + const router = factory() + router.on('GET', '/v', { constraints: { version: '2.0.0' } }, handlerA, 'v2') + router.on('GET', '/v', { constraints: { version: '1.0.0' } }, handlerB, 'v1') + router.on('GET', '/v', { constraints: { version: '1.5.0' } }, handlerC, 'v1.5') + router.on('GET', '/v', { constraints: { version: '1.5' } }, handlerD, 'v1.5short') + return ['x', '1.x', '1.5.x', '1.0.x', '2.x', '1.5.0', '1.5'].map((version) => normResult(router.find('GET', '/v', { version }))) + }) + + const makeStorage = () => { + const store = new Map() + return { get: (v) => store.get(v) || null, set: (v, h) => store.set(v, h) } + } + const secretSync = { name: 'secret', storage: makeStorage, deriveConstraint: (req) => req.headers['x-secret'], validate(v) { if (typeof v !== 'string') throw new Error('secret must be a string') } } + const mustSync = { name: 'tenant', mustMatchWhenDerived: true, storage: makeStorage, deriveConstraint: (req) => req.headers['x-tenant'] } + const asyncStrategy = (factory) => + factory === original + ? { name: 'async', storage: makeStorage, deriveConstraint: (req, _ctx, done) => setTimeout(() => (req.headers['x-fail'] ? done(new Error('boom')) : done(null, req.headers['x-async'])), 1) } + : { name: 'async', storage: makeStorage, deriveConstraintAsync: (req, done) => setTimeout(() => (req.headers['x-fail'] ? done(new Error('boom')) : done(null, req.headers['x-async'])), 1) } + const custom = (factory, useAsync) => { + const constraints = { secret: secretSync, tenant: mustSync } + if (useAsync) constraints.async = asyncStrategy(factory) + const router = factory({ constraints }) + router.on('GET', '/s', { constraints: { secret: 'alpha' } }, handlerA, 'alpha') + router.on('GET', '/s', { constraints: { secret: 'beta' } }, handlerB, 'beta') + router.on('GET', '/s', handlerC, 'open') + router.on('GET', '/t', { constraints: { tenant: 'acme' } }, handlerA, 'acme') + router.on('GET', '/t', handlerB, 'notenant') + router.on('GET', '/st', { constraints: { secret: 'alpha', tenant: 'acme' } }, handlerA, 'alpha-acme') + router.on('GET', '/st', { constraints: { secret: 'alpha' } }, handlerB, 'alpha-any') + router.on('GET', '/st', { constraints: { host: 'h', secret: 'beta', version: '1.0.0' } }, handlerC, 'everything') + if (useAsync) router.on('GET', '/a', { constraints: { async: 'yes' } }, handlerA, 'async-yes') + if (useAsync) router.on('GET', '/a', handlerB, 'async-no') + return router + } + const derivedSets = [{}, { secret: 'alpha' }, { secret: 'beta' }, { secret: 'gamma' }, { tenant: 'acme' }, { tenant: 'other' }, { secret: 'alpha', tenant: 'acme' }, { secret: 'alpha', tenant: 'other' }, { secret: 'beta', host: 'h', version: '1.0.0' }, { secret: 'beta', host: 'h', version: '1.x' }, { secret: 'beta', host: 'x', version: '1.0.0' }] + for (const derived of derivedSets) { + both(`custom constraints ${JSON.stringify(derived)}`, (factory) => { + const router = custom(factory, false) + return ['/s', '/t', '/st'].map((p) => normResult(router.find('GET', p, derived))) + }) + } + both('custom strategy validate', (factory) => custom(factory, false).on('GET', '/x', { constraints: { secret: 1 } }, handlerA)) + both('custom strategy registration errors', (factory) => { + const errors = [] + const attempts = [{}, { name: '' }, { name: 'x' }, { name: 'x', storage: makeStorage }, { name: 'secret', storage: makeStorage, deriveConstraint: () => 'x' }, { name: 'version', storage: makeStorage, deriveConstraint: () => 'x' }, { name: 'host', storage: makeStorage, deriveConstraint: () => 'x' }] + for (const attempt of attempts) { + const router = custom(factory, false) + router.on('GET', '/usesversion', { constraints: { version: '1.0.0' } }, handlerA) + errors.push(outcome(() => router.addConstraintStrategy(attempt))) + } + return errors + }) + both('custom host strategy before use is fine', (factory) => { + const router = factory() + router.addConstraintStrategy({ name: 'host', storage: makeStorage, deriveConstraint: (req) => req.headers['x-host'] }) + router.on('GET', '/h', { constraints: { host: 'custom' } }, handlerA, 'custom') + return [router.hasConstraintStrategy('host'), router.hasConstraintStrategy('version'), router.hasConstraintStrategy('nope'), normResult(router.find('GET', '/h', { host: 'custom' })), router.prettyPrint()] + }) + both('hasConstraintStrategy', (factory) => { + const router = custom(factory, false) + return ['secret', 'tenant', 'version', 'host', 'async', 'nope'].map((n) => router.hasConstraintStrategy(n)) + }) + both('addConstraintStrategy after routes rebuilds', (factory) => { + const router = factory() + router.on('GET', '/a', handlerA, 'plain') + router.addConstraintStrategy(secretSync) + router.on('GET', '/a', { constraints: { secret: 'alpha' } }, handlerB, 'alpha') + return [normResult(router.find('GET', '/a', { secret: 'alpha' })), normResult(router.find('GET', '/a', {})), normResult(router.find('GET', '/a'))] + }) + both('custom prettyPrint', (factory) => { + const router = custom(factory, false) + return [router.prettyPrint(), router.prettyPrint({ commonPrefix: false }), router.prettyPrint({ method: 'GET' })] + }) + + const makeReq = (method, url, headers = {}) => ({ method, url, headers }) + const makeRes = () => ({ statusCode: 200, ended: false, end() { this.ended = true } }) + const recording = (factory, useAsync) => { + const calls = [] + const rec = (name) => { + const fn = function (req, res, params, store, searchParams) { + calls.push({ name, url: req.url, params: { ...params }, store: store ?? null, searchParams: { ...searchParams } }) + } + Object.defineProperty(fn, 'name', { value: name }) + return fn + } + const router = factory({ constraints: useAsync ? { async: asyncStrategy(factory) } : {}, defaultRoute: (req, res) => { calls.push({ name: 'default', url: req.url }); res.statusCode = 404 } }) + router.on('GET', '/a/:id', rec('a'), 'storeA') + router.on('GET', '/v', { constraints: { version: '1.0.0' } }, rec('v1'), 'v1') + router.on('GET', '/v', { constraints: { version: '2.0.0' } }, rec('v2'), 'v2') + router.on('GET', '/h', { constraints: { host: 'fastify.dev' } }, rec('hdev'), 'hdev') + router.on('GET', '/h', rec('hany'), 'hany') + router.on('GET', '/plain', rec('plain')) + if (useAsync) router.on('GET', '/as', { constraints: { async: 'yes' } }, rec('as-yes'), 'yes') + if (useAsync) router.on('GET', '/as', rec('as-no'), 'no') + return { router, calls } + } + const requests = [ + makeReq('GET', '/a/1?x=1'), makeReq('GET', '/plain'), makeReq('GET', '/missing'), makeReq('POST', '/plain'), makeReq('GET', '/v', { 'accept-version': '1.x' }), makeReq('GET', '/v', { 'accept-version': '2.0.0' }), makeReq('GET', '/v'), + makeReq('GET', '/v', { 'accept-version': '3.0.0' }), makeReq('GET', '/h', { host: 'fastify.dev' }), makeReq('GET', '/h', { host: 'other' }), makeReq('GET', '/h'), makeReq('GET', '/h', { ':authority': 'fastify.dev' }), makeReq('GET', '/a/%zz'), makeReq(undefined, '/plain'), makeReq('GET', 'http://fastify.dev/plain') + ] + for (const req of requests) { + both(`lookup ${req.method} ${req.url} ${JSON.stringify(req.headers)}`, (factory) => { + const { router, calls } = recording(factory, false) + const res = makeRes() + router.lookup({ ...req }, res) + return { calls, statusCode: res.statusCode, ended: res.ended } + }) + } + both('lookup without defaultRoute ends with 404', (factory) => { + const router = factory() + const res = makeRes() + router.lookup(makeReq('GET', '/nope'), res) + return { statusCode: res.statusCode, ended: res.ended } + }) + both('lookup with onBadUrl', (factory) => { + const seen = [] + const router = factory({ onBadUrl: (path, req, res) => { seen.push(path); res.statusCode = 400 } }) + router.on('GET', '/a/:id', handlerA) + const res = makeRes() + router.lookup(makeReq('GET', '/a/%zz'), res) + router.lookup(makeReq('GET', 'nope'), res) + return { seen, statusCode: res.statusCode } + }) + both('lookup with onMaxParamLength', (factory) => { + const seen = [] + const router = factory({ maxParamLength: 2, onMaxParamLength: (path, req, res) => { seen.push(path); res.statusCode = 414 } }) + router.on('GET', '/a/:id', handlerA) + const res = makeRes() + router.lookup(makeReq('GET', '/a/xyz'), res) + return { seen, statusCode: res.statusCode } + }) + for (const req of [makeReq('GET', '/as', { 'x-async': 'yes' }), makeReq('GET', '/as', { 'x-async': 'no' }), makeReq('GET', '/as'), makeReq('GET', '/plain'), makeReq('GET', '/v', { 'accept-version': '1.0.0' }), makeReq('GET', '/as', { 'x-fail': '1' }), makeReq('GET', '/missing')]) { + await bothAsync(`async lookup ${req.url} ${JSON.stringify(req.headers)}`, async (factory) => { + const { router, calls } = recording(factory, true) + const res = makeRes() + const err = await new Promise((resolve) => router.lookup({ ...req }, res, (e) => resolve(e ? `${e.constructor.name}: ${e.message}` : null))) + return { err, calls, statusCode: res.statusCode } + }) + } + both('async strategy find without derived', (factory) => { + const { router } = recording(factory, true) + return [normResult(router.find('GET', '/as')), normResult(router.find('GET', '/as', { async: 'yes' })), normResult(router.find('GET', '/as', {}))] + }) + + both('prettyPrint includeMeta', (factory) => { + const router = factory({ buildPrettyMeta: (route) => ({ store: route.store ?? null, path: route.path, re: /x/g, list: [1, 'a', /y/], nothing: null, skip: undefined, nested: ['a', ['b']] }) }) + router.on('GET', '/a/:id', handlerA, { kind: 'A' }) + router.on('POST', '/a/:id', handlerB, { kind: 'B' }) + router.on('GET', '/a/:id', { constraints: { host: 'x' } }, handlerC, { kind: 'C' }) + router.on('GET', '/b', handlerD) + router.on(['PUT', 'PATCH'], '/b', handlerD) + router.on('GET', '/c/*', handlerA, { kind: 'wild' }) + router.on('GET', '/d/:x(\\d+)-:y', handlerB, { kind: 'regex' }) + router.on('GET', '/d/:x', handlerC, { kind: 'param' }) + return [ + router.prettyPrint({ includeMeta: true }), router.prettyPrint({ includeMeta: ['store', 'path'] }), router.prettyPrint({ includeMeta: ['missing'] }), router.prettyPrint({ includeMeta: true, commonPrefix: false }), + router.prettyPrint({ method: 'GET', includeMeta: true }), router.prettyPrint({ method: 'GET', includeMeta: ['list'], commonPrefix: false }), router.prettyPrint() + ] + }) + both('prettyPrint merges methods with same constraints and meta', (factory) => { + const router = factory({ buildPrettyMeta: (route) => ({ s: route.store ?? null }) }) + router.on('GET', '/x', handlerA, 1) + router.on('POST', '/x', handlerB, 1) + router.on('PUT', '/x', handlerC, 2) + router.on('DELETE', '/x', { constraints: { version: '1.0.0' } }, handlerD, 1) + router.on('HEAD', '/x', { constraints: { version: '1.0.0' } }, handlerD, 1) + return [router.prettyPrint({ includeMeta: true }), router.prettyPrint()] + }) + both('prettyPrint root wildcard and empty root', (factory) => { + const router = factory() + router.on('GET', '*', handlerA) + router.on('GET', '/a', handlerB) + const empty = factory() + return [router.prettyPrint(), router.prettyPrint({ method: 'GET' }), router.prettyPrint({ commonPrefix: false }), empty.prettyPrint(), empty.prettyPrint({ method: 'GET' })] + }) + + both('verb shorthands', (factory) => { + const router = factory() + const verbs = ['acl', 'bind', 'checkout', 'connect', 'copy', 'delete', 'get', 'head', 'link', 'lock', 'm-search', 'merge', 'mkactivity', 'mkcalendar', 'mkcol', 'move', 'notify', 'options', 'patch', 'post', 'propfind', 'proppatch', 'purge', 'put', 'query', 'rebind', 'report', 'search', 'source', 'subscribe', 'trace', 'unbind', 'unlink', 'unlock', 'unsubscribe'] + for (const verb of verbs) router[verb]('/' + verb, handlerA, verb) + router.all('/all', handlerB, 'all') + router.get('/nostore', handlerC) + return [router.routes.map((r) => [r.method, r.path, r.store ?? null]), verbs.map((verb) => normResult(router.find(verb.toUpperCase(), '/' + verb))), normResult(router.find('QUERY', '/all')), normResult(router.find('GET', '/nostore'))] + }) + + return count +} diff --git a/tools/nest/parity/find-my-way__lib__url-sanitizer.mjs b/tools/nest/parity/find-my-way__lib__url-sanitizer.mjs new file mode 100644 index 0000000..155463c --- /dev/null +++ b/tools/nest/parity/find-my-way__lib__url-sanitizer.mjs @@ -0,0 +1,28 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const both = (label, call) => { + const results = [shim, original].map((m) => { try { return { value: call(m) } } catch (error) { return { error: `${error.constructor.name}: ${error.message}` } } }) + assert.deepEqual(results[0], results[1], label) + count++ + } + assert.equal(shim.MIN_NATIVE_SCAN_LENGTH, original.MIN_NATIVE_SCAN_LENGTH); count++ + const paths = [ + '/', '/a', '/a/b/c', '/a?b=c', '/a#frag', '/a?b#c', '/a#b?c', '/a;jsessionid=1', '/a;b?c', '/a%20b', '/a%2Fb', '/a%2fb', '/a%25b', '/a%2525b', + '/a%23b', '/a%24b', '/a%26b', '/a%2Bb', '/a%2Cb', '/a%3Ab', '/a%3Bb', '/a%3Db', '/a%3Fb', '/a%40b', '/a%C3%A9', '/a%E2%9C%93', '/%', '/%2', '/%zz', '/a%', + '/a%20b?c=%20d', '/caf%C3%A9/na%C3%AFve/thing', '/users/%3A1/posts', '/x?%2F', '/a%2Fb/longer/path/here', '/longer/path/without/encoding', '/longer/path/with/%20/encoding', + '/longer/path/with/%2F/encoding?q=1', '/longer/path/with/%25/encoding', '/longer/path/with/%2525/encoding', '/longer/path/with/%2F%25%23/encoding', '/longer/path/?q=%2F', + '/longer/path/#frag%2F', '/longer/path/;sess%2F', '/short%2F;x', '/%2F%2F%2F', '/a%E0%A4%A', '/%C3%A9', '/%E9', '/%ZZ', '//a', '/a//b', '/a/b/', '/a?', '/a#', '/a;', '?a=b', '#x', ';x', + '/%25%32%30', '/%2%2F', '/a%2Fb%zz', '/a%2fb%2Fc%2Fd%2Fe%2Ff%2Fg%2Fh', '/' + 'x'.repeat(50) + '%2F' + 'y'.repeat(50), '/a%2Fb?c%2Fd#e%2Ff', '/a%3F?b%3F', '/a%23#b%23' + ] + for (const p of paths) { + for (const semicolon of [false, true]) { + both(`safeDecodeURI(${JSON.stringify(p)}, ${semicolon})`, (m) => m.safeDecodeURI(p, semicolon)) + both(`safeDecodeURICharScan(${JSON.stringify(p)}, ${semicolon})`, (m) => m.safeDecodeURICharScan(p, semicolon, 1)) + both(`safeDecodeURINativeScan(${JSON.stringify(p)}, ${semicolon})`, (m) => m.safeDecodeURINativeScan(p, semicolon)) + } + } + const components = ['a', 'a%2Fb', 'a%2fb', 'a%23%24%25%26%2B%2C%2F%3A%3B%3D%3F%40', '%2F', 'a%2F', '%2Fa', 'a%2F%2Fb', 'a%', 'a%2', 'a%zz', '%', '', 'no-encoding', 'a%25b', 'a%E2%9C%93b', 'a%20b', 'a%2Fb%20c%2Fd', '%2F%20%2F'] + for (const c of components) both(`safeDecodeURIComponent(${JSON.stringify(c)})`, (m) => m.safeDecodeURIComponent(c)) + both('default export shape', (m) => Object.keys(m.default ?? m).sort()) + return count +} diff --git a/tools/nest/parity/fresh.mjs b/tools/nest/parity/fresh.mjs new file mode 100644 index 0000000..b1f836b --- /dev/null +++ b/tools/nest/parity/fresh.mjs @@ -0,0 +1,55 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const both = (label, req, res) => { + assert.equal(shim.default(req, res), original(req, res), label) + count++ + } + + const etag = '"foo"' + const weak = 'W/"foo"' + const lm = 'Sat, 01 Jan 2000 00:00:00 GMT' + const later = 'Sat, 01 Jan 2000 01:00:00 GMT' + const earlier = 'Fri, 31 Dec 1999 23:00:00 GMT' + + const requests = { + none: {}, + inmMatch: { 'if-none-match': etag }, + inmWeak: { 'if-none-match': weak }, + inmOther: { 'if-none-match': '"bar"' }, + inmStar: { 'if-none-match': '*' }, + inmList: { 'if-none-match': '"bar", "foo"' }, + inmListSpaces: { 'if-none-match': ' "bar" , "foo" ' }, + inmListNoSpace: { 'if-none-match': '"bar","foo"' }, + inmEmpty: { 'if-none-match': '' }, + inmTrailingComma: { 'if-none-match': '"foo",' }, + inmLeadingComma: { 'if-none-match': ',"foo"' }, + imsEqual: { 'if-modified-since': lm }, + imsLater: { 'if-modified-since': later }, + imsEarlier: { 'if-modified-since': earlier }, + imsInvalid: { 'if-modified-since': 'not a date' }, + imsEmpty: { 'if-modified-since': '' }, + bothMatch: { 'if-none-match': etag, 'if-modified-since': earlier }, + bothStale: { 'if-none-match': '"bar"', 'if-modified-since': later }, + noCache: { 'if-none-match': etag, 'cache-control': 'no-cache' }, + noCacheList: { 'if-none-match': etag, 'cache-control': 'max-age=0, no-cache' }, + noCacheSpaces: { 'if-none-match': etag, 'cache-control': ' no-cache ' }, + noCacheOther: { 'if-none-match': etag, 'cache-control': 'no-cache-x' }, + noCacheQuoted: { 'if-none-match': etag, 'cache-control': 'no-store' }, + noCacheIms: { 'if-modified-since': later, 'cache-control': 'no-cache' } + } + const responses = { + none: {}, + etag: { etag }, + weak: { etag: weak }, + lm: { 'last-modified': lm }, + both: { etag, 'last-modified': lm }, + emptyEtag: { etag: '', 'last-modified': lm }, + undefinedEtag: { etag: undefined, 'last-modified': lm }, + invalidLm: { etag, 'last-modified': 'garbage' } + } + for (const [reqName, req] of Object.entries(requests)) { + for (const [resName, res] of Object.entries(responses)) both(`fresh(${reqName}, ${resName})`, req, res) + } + + return count +} diff --git a/tools/nest/parity/http-errors.mjs b/tools/nest/parity/http-errors.mjs new file mode 100644 index 0000000..7d7ae63 --- /dev/null +++ b/tools/nest/parity/http-errors.mjs @@ -0,0 +1,103 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const shape = (error) => ({ + name: error.name, + message: error.message, + status: error.status, + statusCode: error.statusCode, + expose: error.expose, + headers: error.headers ?? null, + code: error.code ?? null, + syscall: error.syscall ?? null, + errno: error.errno ?? null, + path: error.path ?? null, + isError: error instanceof Error + }) + const outcome = (call) => { + try { + return { value: shape(call()) } + } catch (error) { + return { error: `${error.constructor.name}: ${error.message}` } + } + } + const both = (label, make) => { + assert.deepEqual(outcome(() => make(shim.default)), outcome(() => make(original)), label) + count++ + } + + const systemError = () => Object.assign(new Error("ENOENT: no such file or directory, stat '/x'"), { code: 'ENOENT', errno: -2, syscall: 'stat', path: '/x' }) + const typeError = () => new TypeError('bad type') + const headers = { 'Content-Range': 'bytes */1000' } + + both('createError()', (create) => create()) + for (const status of [400, 401, 403, 404, 405, 409, 410, 412, 416, 418, 422, 429, 431, 451, 500, 501, 502, 503, 504, 505, 511]) { + both(`createError(${status})`, (create) => create(status)) + both(`createError(${status}, 'msg')`, (create) => create(status, 'custom message')) + both(`createError(${status}, '')`, (create) => create(status, '')) + both(`createError(${status}, {expose})`, (create) => create(status, { expose: !(status < 500) })) + both(`createError(${status}, {headers})`, (create) => create(status, { headers })) + both(`createError(${status}, 'msg', {headers})`, (create) => create(status, 'custom message', { headers })) + both(`createError(${status}, systemError)`, (create) => create(status, systemError())) + both(`createError(${status}, systemError, {expose: false})`, (create) => create(status, systemError(), { expose: false })) + both(`createError(${status}, typeError)`, (create) => create(status, typeError())) + } + for (const status of [499, 599, 450, 509, 599, 200, 201, 204, 301, 302, 304, 100, 101]) { + both(`createError(${status})`, (create) => create(status)) + both(`createError(${status}, 'msg')`, (create) => create(status, 'custom message')) + both(`createError(${status}, systemError)`, (create) => create(status, systemError())) + } + for (const status of [0, 99, 199, 299, 399, 600, 601, 999, -1, 1000]) { + both(`createError(${status})`, (create) => create(status)) + both(`createError(${status}, 'msg')`, (create) => create(status, 'custom message')) + } + both("createError('msg')", (create) => create('only a message')) + both("createError('msg', {headers})", (create) => create('only a message', { headers })) + both('createError({headers})', (create) => create({ headers })) + both('createError({expose: true})', (create) => create({ expose: true })) + both('createError(systemError)', (create) => create(systemError())) + both('createError(systemError, {headers})', (create) => create(systemError(), { headers })) + both('createError(typeError)', (create) => create(typeError())) + both("createError(400, 'a', 'b')", (create) => create(400, 'a', 'b')) + + const sameInstance = (create) => { + const first = create(404) + const second = create(404, first) + assert.equal(second, first, 'same instance for a matching status') + return second + } + both('createError(404, createError(404))', sameInstance) + both('createError(createError(404))', (create) => create(create(404))) + both('createError(500, createError(404))', (create) => create(500, create(404))) + both('createError(createError(404), {expose: false})', (create) => create(create(404), { expose: false })) + both('createError(404, createError(403, {headers}))', (create) => create(404, create(403, { headers }))) + both("createError(404, createError(404, 'msg'))", (create) => create(404, create(404, 'custom message'))) + both('createError(createError(404, systemError))', (create) => create(create(404, systemError()))) + + for (const [label, value, sameInstanceOf] of [ + ['createError(404)', (create) => create(404), true], + ['createError(500)', (create) => create(500), true], + ['createError(404, systemError)', (create) => create(404, systemError()), false], + ['createError(200)', (create) => create(200), false], + ['new Error', () => new Error('x'), true], + ['decorated Error', () => Object.assign(new Error('x'), { expose: true, status: 404, statusCode: 404 }), false], + ['null', () => null, true], + ['string', () => 'x', true], + ['{}', () => ({}), true] + ]) { + const shimError = value(shim.default) + const originalError = value(original) + if (sameInstanceOf) { + assert.equal(shim.isHttpError(shimError), original.isHttpError(originalError), `isHttpError(${label})`) + assert.equal(shimError instanceof shim.HttpError, originalError instanceof original.HttpError, `${label} instanceof HttpError`) + } else { + assert.equal(shim.isHttpError(shimError), shimError instanceof shim.HttpError, `isHttpError(${label}) is instanceof here`) + assert.equal(original.isHttpError(originalError), true, `the package duck-types ${label}`) + } + count += 2 + } + assert.throws(() => new original.HttpError(), TypeError, 'the package HttpError is abstract') + assert.ok(shim.default(404) instanceof Error && shim.default(404) instanceof shim.HttpError) + count += 2 + + return count +} diff --git a/tools/nest/parity/media-typer.mjs b/tools/nest/parity/media-typer.mjs new file mode 100644 index 0000000..a4ef7ae --- /dev/null +++ b/tools/nest/parity/media-typer.mjs @@ -0,0 +1,28 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const both = (label, call) => { + const results = [shim, original].map((m) => { try { return { value: JSON.parse(JSON.stringify(call(m))) } } catch (error) { return { error: `${error.constructor.name}: ${error.message}` } } }) + assert.deepEqual(results[0], results[1], label) + count++ + } + const strings = [ + 'text/html', 'TEXT/HTML', ' text/html ', 'application/json', 'application/vnd.api+json', 'image/svg+xml', 'a/b+c+d', + 'text/html; charset=utf-8', 'text', 'text/', '/html', 'text//html', 'text/ht ml', 'te xt/html', '*/*', 'text/*', + 'a-b/c.d_e!f#g$h&i^j', '0/0', 'x/' + 'y'.repeat(127), 'x/' + 'y'.repeat(128), '', 'application/x-www-form-urlencoded', + 'multipart/form-data', 'text/plain+', 'text/+json', 'a/b+', '-a/b', 'a/-b' + ] + for (const s of strings) { + both(`test(${JSON.stringify(s)})`, (m) => m.test(s)) + both(`parse(${JSON.stringify(s)})`, (m) => m.parse(s)) + } + both('test non-string', (m) => m.test(1)) + both('parse non-string', (m) => m.parse(null)) + const objects = [ + { type: 'text', subtype: 'html' }, { type: 'application', subtype: 'vnd.api', suffix: 'json' }, { type: 'text', subtype: 'ht ml' }, + { type: 'te xt', subtype: 'html' }, { type: 'text' }, { subtype: 'html' }, { type: 'text', subtype: 'html', suffix: 'x y' }, + { type: 'text', subtype: 'html', suffix: '' }, { type: 'a.b', subtype: 'c' }, { type: 'a', subtype: 'b.c' }, null, 'text/html', 42 + ] + for (const o of objects) both(`format(${JSON.stringify(o)})`, (m) => m.format(o)) + both('format(parse(x))', (m) => m.format(m.parse('Application/VND.API+JSON'))) + return count +} diff --git a/tools/nest/parity/mime-types.mjs b/tools/nest/parity/mime-types.mjs new file mode 100644 index 0000000..4866b0c --- /dev/null +++ b/tools/nest/parity/mime-types.mjs @@ -0,0 +1,30 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const sorted = (map) => Object.entries(map).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + assert.deepEqual(sorted(shim.types), sorted(original.types), 'types map') + assert.deepEqual(sorted(shim.extensions), sorted(original.extensions), 'extensions map') + count += 2 + const both = (label, call) => { + assert.deepEqual(call(shim), call(original), label) + count++ + } + const fns = ['lookup', 'contentType', 'charset', 'extension'] + const inputs = new Set([ + ...Object.keys(original.types), ...Object.keys(original.extensions), + ...Object.keys(original.types).map((e) => '.' + e), ...Object.keys(original.types).map((e) => 'dir/file.' + e), + ...Object.keys(original.extensions).map((t) => t + '; charset=utf-8'), ...Object.keys(original.extensions).map((t) => t.toUpperCase()), + 'text/html; charset=iso-8859-1', 'TEXT/HTML', 'application/json', 'application/octet-stream', 'text/x-unknown', 'x/y', + 'json', 'JSON', '.JSON', 'file', 'file.', '.', '..', '', 'a.b.c.json', '/path/to/file.JS', 'no-such-ext', 'C:\\dir\\x.txt', 'a/b', 'a/b/c.md', + ' text/html', 'text/html ;x=1', 'text/html;', 'text/', '/html', 'text', 'application/vnd.ms-excel', + 'application/x-www-form-urlencoded', 'multipart/form-data; boundary=x', 'image/svg+xml', 'application/javascript', 'text/javascript', + 'application/wasm', 'font/woff2', + 'constructor', '.constructor', 'x.constructor', '__proto__', 'x.__proto__', 'toString', 'hasOwnProperty', 'a/constructor', 'constructor/x', 'text/constructor', + 'application/__proto__', 'valueOf', 'x.valueOf', 'a/toString; charset=utf-8', 'application/x-font-ttf', 'video/mp4', 'audio/ogg', 'markdown', 'md', 'mjs', 'cjs', 'ts', 'tsx', 'map' + ]) + for (const fn of fns) { + for (const input of inputs) both(`${fn}(${JSON.stringify(input)})`, (m) => m[fn](input)) + for (const bad of [undefined, null, 42, {}, [], true]) both(`${fn}(${String(bad)})`, (m) => m[fn](bad)) + } + both('charsets.lookup', (m) => m.charsets.lookup('text/plain')) + return count +} diff --git a/tools/nest/parity/ms.mjs b/tools/nest/parity/ms.mjs new file mode 100644 index 0000000..849c270 --- /dev/null +++ b/tools/nest/parity/ms.mjs @@ -0,0 +1,34 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const outcome = (call) => { + try { + return { value: call() } + } catch (error) { + return { error: `${error.constructor.name}: ${error.message}` } + } + } + const both = (label, ...args) => { + assert.deepEqual(outcome(() => shim.default(...args)), outcome(() => original(...args)), label) + count++ + } + + const strings = [ + '100', '1ms', '1 ms', '2ms', '1s', '1 s', '1sec', '1 secs', '1second', '2 seconds', '1m', '1 min', '1mins', '1 minute', '2 minutes', '1h', '1 hr', '1hrs', '1 hour', '2 hours', + '1d', '1 day', '2 days', '1w', '1 week', '2 weeks', '1y', '1 yr', '1yrs', '1 year', '2 years', '1.5h', '.5h', '-1h', '-.5d', '-100', '1.5 days', '1 DAY', '1D', '10H', '1 Weeks', + '0', '0ms', '1x', 'one hour', '1 hours ago', ' 1h', '1h ', '1h30m', 'ms', 's', 'h', '1e3', '1,000', '', 'a'.repeat(101), '1' + 'h'.repeat(100), '1'.repeat(101), + '5 milliseconds', '5 msecs', '5msec', '5 millisecond' + ] + for (const str of strings) both(`ms(${JSON.stringify(str)})`, str) + + const numbers = [ + 0, 1, 999, 1000, 1001, 1500, 59999, 60000, 90000, 3599999, 3600000, 5400000, 86399999, 86400000, 129600000, 172800000, 604800000, 31557600000, + -1, -1000, -90000, -3600000, -86400000, 0.5, 1234.5678, 1e12, Infinity, -Infinity, NaN + ] + for (const n of numbers) { + both(`ms(${n})`, n) + both(`ms(${n}, {long: true})`, n, { long: true }) + both(`ms(${n}, {long: false})`, n, { long: false }) + } + + return count +} diff --git a/tools/nest/parity/on-finished.mjs b/tools/nest/parity/on-finished.mjs new file mode 100644 index 0000000..7722b4a --- /dev/null +++ b/tools/nest/parity/on-finished.mjs @@ -0,0 +1,65 @@ +import http from 'node:http' +import { once } from 'node:events' + +const sequence = async (mod, assert) => { + const onFinished = mod.default ?? mod + const log = [] + const server = http.createServer((req, res) => { + log.push(['req isFinished at start', mod.isFinished(req)]) + log.push(['res isFinished at start', mod.isFinished(res)]) + onFinished(res, (err, msg) => log.push(['res listener 1', err, msg === res])) + onFinished(res, (err, msg) => log.push(['res listener 2', err, msg === res])) + onFinished(req, (err, msg) => { + log.push(['req listener', err, msg === req, mod.isFinished(req)]) + onFinished(req, (err2) => log.push(['req late listener', err2, mod.isFinished(req)])) + log.push(['after late attach (sync)']) + }) + req.on('data', () => {}) + req.on('end', () => { + res.setHeader('x-a', '1') + res.end('done', () => { + log.push(['res end callback', mod.isFinished(res)]) + onFinished(res, (err) => log.push(['res late listener', err, mod.isFinished(res)])) + log.push(['after res late attach (sync)']) + }) + }) + }) + server.listen(0) + await once(server, 'listening') + const { port } = server.address() + const request = http.request({ port, method: 'POST', path: '/', headers: { 'content-length': '5' } }) + request.end('hello') + const [response] = await once(request, 'response') + response.resume() + await once(response, 'end') + await new Promise((resolve) => setTimeout(resolve, 50)) + server.close() + await once(server, 'close') + assert.equal(mod.isFinished({}), undefined) + assert.equal(mod.isFinished(new http.ServerResponse(new http.IncomingMessage(null))), false) + return log +} + +export async function run({ shim, original, assert }) { + const [a, b] = [await sequence(shim, assert), await sequence(original, assert)] + assert.deepEqual(a, b, 'event log') + for (const mod of [shim, original]) { + const onFinished = mod.default ?? mod + let calls = [] + const server = http.createServer((req, res) => { + onFinished(res, (err) => calls.push(err ?? null)) + onFinished(req, (err) => calls.push(err ?? null)) + res.write('partial') + setTimeout(() => req.socket.destroy(), 5) + }) + server.listen(0) + await once(server, 'listening') + const request = http.get({ port: server.address().port, path: '/' }) + request.on('error', () => {}) + await new Promise((resolve) => setTimeout(resolve, 80)) + server.close() + await once(server, 'close') + assert.deepEqual(calls, [null, null], `${mod === shim ? 'shim' : 'original'} socket-close calls`) + } + return a.length + 3 +} diff --git a/tools/nest/parity/path-to-regexp.mjs b/tools/nest/parity/path-to-regexp.mjs new file mode 100644 index 0000000..784dce0 --- /dev/null +++ b/tools/nest/parity/path-to-regexp.mjs @@ -0,0 +1,180 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const result = (fn) => { + try { + return { value: fn() } + } catch (error) { + return { error: `${error.name}: ${error.message}`, originalPath: error.originalPath } + } + } + const json = (value) => JSON.parse(JSON.stringify(value)) + const both = (label, call) => { + assert.deepEqual(json(result(() => call(shim))), json(result(() => call(original))), label) + count++ + } + + const paths = [ + '/', + '', + '/users', + '/users/:id', + '/users/:id/posts/:postId', + '/users/:id.json', + '/users/:id-:other', + '/users/:id:other', + '/files/*path', + '/files/*path.txt', + '/:a/*b', + '/*a/:b', + '/:a*b', + '/users{/:id}', + '/users{/:id}{/:action}', + '/a{b{c}}', + '{/:a}{/:b}', + '/users/:"my-id"', + '/users/:"quoted \\"name\\""', + '/users/:"",', + '/users/:', + '/users/*', + '/users/\\:id', + '/a\\{b\\}', + '/a\\\\b', + '/trailing\\', + '/users/:id(', + '/users/:id)', + '/users/:id?', + '/users/:id+', + '/users/[id]', + '/users/:id!', + '/users/{unclosed', + '/users/}', + '/:id/:id', + '/café/:id', + '/:$id/:_id/:id9', + '/emoji/😀/:id', + '/:a:b', + '/:a-:b/*c' + ] + + for (const path of paths) { + both(`parse(${JSON.stringify(path)})`, (m) => m.parse(path)) + both(`parse(${JSON.stringify(path)}, encodePath)`, (m) => m.parse(path, { encodePath: (s) => s.toUpperCase() })) + both(`stringify(parse(${JSON.stringify(path)}))`, (m) => m.stringify(m.parse(path))) + both(`pathToRegexp(${JSON.stringify(path)})`, (m) => { + const { regexp, keys } = m.pathToRegexp(path) + return { source: regexp.source, flags: regexp.flags, keys } + }) + } + + const optionSets = [ + {}, + { end: false }, + { trailing: false }, + { sensitive: true }, + { end: false, trailing: false, sensitive: true }, + { delimiter: '.' }, + { delimiter: '/.' }, + { decode: false }, + { decode: (s) => s.toUpperCase() } + ] + const inputs = [ + '/', + '/users', + '/users/', + '/users/42', + '/users/42/', + '/users/42/posts/7', + '/users/42.json', + '/users/a-b', + '/users/a%20b', + '/users/a%2Fb', + '/users/%E2%9C%93', + '/users/%', + '/USERS/42', + '/files/a/b/c.txt', + '/files/a%2Fb/c', + '/files/', + '/users/42/extra', + 'users/42', + '/a/b', + '/a.b.c', + '/café/9', + '/emoji/😀/1' + ] + const matchPaths = [ + '/', + '/users', + '/users/:id', + '/users/:id/posts/:postId', + '/users/:id.json', + '/users/:id-:other', + '/files/*path', + '/files/*path.txt', + '/users{/:id}', + '/users{/:id}{/:action}', + '/:a/*b', + '/café/:id', + '/emoji/😀/:id', + ['/users/:id', '/people/:id'], + '/a.:ext', + '/:a.:b.:c' + ] + for (const path of matchPaths) { + for (const options of optionSets) { + const label = `match(${JSON.stringify(path)}, ${options.decode ? 'decode' : JSON.stringify(options)})` + both(label, (m) => { + const matcher = m.match(path, options) + return inputs.map((input) => { + const r = result(() => matcher(input)) + return r.value === undefined || r.value === false ? r : { path: r.value.path, params: { ...r.value.params } } + }) + }) + } + } + + both('match route-table options', (m) => { + const matcher = m.match('/users/:id{/:rest}', { decode: decodeURIComponent, end: true, sensitive: true }) + return inputs.map((input) => { + const r = result(() => matcher(input)) + return r.value === undefined || r.value === false ? r : { path: r.value.path, params: { ...r.value.params } } + }) + }) + + const compiled = [ + ['/users/:id', [{ id: '42' }, { id: 'a b' }, { id: 'a/b' }, {}, { id: 42 }, { id: ['a'] }, undefined]], + ['/files/*path', [{ path: ['a', 'b'] }, { path: [] }, { path: 'a' }, { path: ['a', 1] }, {}]], + ['/users{/:id}', [{ id: '42' }, {}, { id: 'x y' }]], + ['/users{/:id}{/:action}', [{ id: '1', action: 'edit' }, { action: 'edit' }, { id: '1' }]], + ['/café/:id', [{ id: 'é' }]], + ['/a\\:b/:c', [{ c: '1' }]] + ] + const compileOptionSets = [{}, { encode: false }, { encode: (s) => s.toUpperCase() }, { delimiter: '.' }] + for (const [path, datas] of compiled) { + for (const options of compileOptionSets) { + both(`compile(${JSON.stringify(path)}, ${options.encode ? 'encode' : JSON.stringify(options)})`, (m) => { + const fn = m.compile(path, options) + return datas.map((data) => result(() => fn(data))) + }) + } + } + + both('match(TokenData)', (m) => { + const matcher = m.match(m.parse('/users/:id')) + return inputs.map((input) => result(() => matcher(input))) + }) + both('compile(TokenData)', (m) => m.compile(m.parse('/users/:id'))({ id: '7' })) + + both('pathToRegexp too many combinations', (m) => { + const r = result(() => m.pathToRegexp('{/:a}{/:b}{/:c}{/:d}{/:e}{/:f}{/:g}{/:h}{/:i}')) + return r.value === undefined ? r : 'ok' + }) + + assert.deepEqual(json(original.parse('/:név').tokens), [{ type: 'text', value: '/' }, { type: 'param', name: 'név' }]) + assert.deepEqual(json(shim.parse('/:név').tokens), [{ type: 'text', value: '/' }, { type: 'param', name: 'n' }, { type: 'text', value: 'év' }]) + assert.deepEqual(json(shim.parse('/:"név"').tokens), json(original.parse('/:"név"').tokens)) + assert.equal(shim.stringify(shim.parse('/:"név"')), '/:"név"') + assert.equal(original.stringify(original.parse('/:"név"')), '/:név') + count++ + + return count +} diff --git a/tools/nest/parity/qs.mjs b/tools/nest/parity/qs.mjs new file mode 100644 index 0000000..f61d227 --- /dev/null +++ b/tools/nest/parity/qs.mjs @@ -0,0 +1,51 @@ +export async function run({ shim, original, assert }) { + let count = 0 + assert.deepEqual([...shim.OBJECT_PROTOTYPE_KEYS].sort(), Object.getOwnPropertyNames(Object.prototype).sort(), 'Object.prototype own keys') + count++ + const normalize = (value) => JSON.parse(JSON.stringify(value, (k, v) => (v === undefined ? '' : v))) + const both = (label, call) => { + const results = [shim, original].map((m) => { try { return { value: normalize(call(m)) } } catch (error) { return { error: `${error.constructor.name}: ${error.message}` } } }) + assert.deepEqual(results[0], results[1], label) + count++ + } + const inputs = [ + '', 'a', 'a=', 'a=b', 'a=b&c=d', 'a=b&a=c', 'a=b&a=c&a=d', 'a[]=b&a[]=c', 'a[0]=b&a[1]=c', 'a[1]=b&a[0]=c', 'a[1]=b', 'a[3]=b&a[1]=c', + 'a[b]=c', 'a[b][c]=d', 'a[b][c][d][e][f]=g', 'a[b][c][d][e][f][g]=h', 'a[b]=c&a[d]=e', 'a[b]=c&a=d', 'a=d&a[b]=c', 'a[]=b&a[c]=d', 'a[c]=d&a[]=b', + 'a[b][]=c&a[b][]=d', 'a[0][b]=c&a[1][b]=d', 'a[0][b]=c&a[0][d]=e', 'a[0]=b&a=c', 'a=b&a[0]=c', 'a[20]=x', 'a[19]=x', 'a[21]=x&a[0]=y', 'a[]=1&a[]=2&a[]=3', + 'a[b]=1&a[b]=2', 'a[b][]=1&a[b]=2', 'a.b=c', 'a%5Bb%5D=c', 'a%5bb%5d=c', 'a[=b', 'a]=b', 'a[[]b=c', 'a[b=c', '[a]=b', '[]=a', '[]=a&[]=b', 'a[b]]=c', + 'a=%E2%9C%93', 'a=%26%2310003%3B', 'utf8=%E2%9C%93&a=%C3%A9', 'utf8=%26%2310003%3B&a=%C3%A9', 'a=%C3%A9', 'a=%E9', 'a=%zz', 'a=b+c', 'a+b=c', + 'a=☺', 'a=%26%239786%3B', '__proto__[x]=1', 'a[__proto__][x]=1', 'constructor[prototype][x]=1', 'hasOwnProperty=1', 'a[hasOwnProperty]=1', + 'toString=1&a=2', 'a[toString]=1', '__proto__=1', 'a[valueOf][b]=1', 'a=b&c=d&e=f&g=h&i=j', 'a[]=b&a[]=c&a[0]=d', 'a[1]=b&a[]=c', 'a[x]=1&a[2]=2', + 'a=1&a=2&a=3&a=4&a=5&a=6&a=7&a=8&a=9&a=10&a=11&a=12&a=13&a=14&a=15&a=16&a=17&a=18&a=19&a=20&a=21&a=22', + 'a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6&a[]=7&a[]=8&a[]=9&a[]=10&a[]=11&a[]=12&a[]=13&a[]=14&a[]=15&a[]=16&a[]=17&a[]=18&a[]=19&a[]=20&a[]=21&a[]=22', + 'a[0]=1&a[1]=2&a[2]=3&a[3]=4&a[4]=5&a[5]=6&a[6]=7&a[7]=8&a[8]=9&a[9]=10&a[10]=11&a[11]=12&a[12]=13&a[13]=14&a[14]=15&a[15]=16&a[16]=17&a[17]=18&a[18]=19&a[19]=20&a[20]=21&a[21]=22', + 'a[25]=x&a[]=y', 'a[]=y&a[25]=x', 'a[25]=x&a=y', 'a=y&a[25]=x', 'a[25][b]=x&a[25][c]=y', 'a[0]=x&a[25]=y&a[1]=z', 'a[-1]=x', 'a[01]=x', 'a[1.5]=x', 'a[ 1]=x', + '?a=b', 'a=b;c=d', 'a=b&&c=d', '&a=b', 'a=b&', '=', '&', '==', 'a==b', 'a=b=c', 'a[b]=c=d', 'a[]=', 'a[b]=', 'a[]', 'a[b]', + 'a[b][c]=1&a[b][d]=2&a[e]=3&f=4', 'x[y][z][]=1&x[y][z][]=2&x[y][w]=3', 'a[0][0]=1&a[0][1]=2&a[1][0]=3', + 'a%5B%5D=1&a%5B%5D=2', 'a%5B0%5D=1', 'a%20b=c%20d', 'a=%F0%9F%98%80', 'a=%ff%fe', 'a[]=%25', 'a=%2520' + ] + const optionSets = [ + {}, { allowPrototypes: true }, { arrayLimit: 0 }, { arrayLimit: 1 }, { arrayLimit: 3 }, { arrayLimit: 100 }, { depth: 0 }, { depth: 1 }, { depth: 2 }, { depth: 10 }, { depth: false }, + { depth: 1, strictDepth: true }, { depth: 2, strictDepth: true }, { depth: 0, strictDepth: true }, { depth: 5, strictDepth: true }, { strictDepth: true }, + { charset: 'iso-8859-1' }, { charset: 'iso-8859-1', interpretNumericEntities: true }, { charset: 'utf-8', interpretNumericEntities: true }, { charset: 'utf-8', charsetSentinel: true }, + { charset: 'iso-8859-1', charsetSentinel: true }, { charsetSentinel: true, interpretNumericEntities: true }, { charset: 'iso-8859-1', charsetSentinel: true, interpretNumericEntities: true }, + { parameterLimit: 1 }, { parameterLimit: 2 }, { parameterLimit: 3 }, { parameterLimit: Infinity }, { parameterLimit: 1, charsetSentinel: true }, + { decoder: (s) => s.toUpperCase() }, { decoder: (s, d, cs) => d(s, d, cs) + '!' }, { decoder: (s, d, cs, type) => (type === 'key' ? s : d(s, d, cs)) }, + { decoder: (s, d, cs, type) => (type === 'key' && s === 'a' ? null : d(s, d, cs)) }, { decoder: (s, d, cs, type) => (type === 'value' ? null : s) }, + { allowPrototypes: true, arrayLimit: 100, depth: 10, charset: 'utf-8', charsetSentinel: true, interpretNumericEntities: true, parameterLimit: 1000, strictDepth: true }, + { allowPrototypes: false, arrayLimit: 100, depth: 10, charset: 'iso-8859-1', charsetSentinel: true, interpretNumericEntities: true, parameterLimit: 1000, strictDepth: true }, + { allowPrototypes: true, arrayLimit: 20, depth: 32, parameterLimit: 1000, strictDepth: true, charset: 'utf-8', charsetSentinel: false, interpretNumericEntities: false } + ] + for (const input of inputs) { + for (const options of optionSets) { + both(`parse(${JSON.stringify(input)}, ${JSON.stringify(options, (k, v) => (typeof v === 'function' ? v.toString() : v))})`, (m) => m.parse(input, options)) + both(`default.parse(${JSON.stringify(input)})`, (m) => (m.default ?? m).parse(input, options)) + } + both(`parse(${JSON.stringify(input)}) no options`, (m) => m.parse(input)) + } + both('bad decoder', (m) => m.parse('a=b', { decoder: 'x' })) + both('bad charset', (m) => m.parse('a=b', { charset: 'latin1' })) + both('null input', (m) => m.parse(null)) + both('undefined input', (m) => m.parse(undefined)) + return count +} diff --git a/tools/nest/parity/range-parser.mjs b/tools/nest/parity/range-parser.mjs new file mode 100644 index 0000000..0b0aefa --- /dev/null +++ b/tools/nest/parity/range-parser.mjs @@ -0,0 +1,31 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const normalize = (result) => (typeof result === 'number' ? result : { type: result.type, ranges: result.ranges ?? [...result] }) + const normalizeOriginal = (result) => (typeof result === 'number' ? result : { type: result.type, ranges: result.map((r) => ({ start: r.start, end: r.end })) }) + const both = (size, str, options) => { + const label = `parseRange(${size}, ${JSON.stringify(str)}${options ? ', ' + JSON.stringify(options) : ''})` + assert.deepEqual(normalize(shim.default(size, str, options)), normalizeOriginal(original(size, str, options)), label) + count++ + } + + const headers = [ + 'bytes=0-499', 'bytes=0-', 'bytes=-500', 'bytes=500-', 'bytes=0-0', 'bytes=999-', 'bytes=1000-', 'bytes=-0', 'bytes=-1', 'bytes=-1000', 'bytes=-1001', 'bytes=0-1000', 'bytes=0-999', + 'bytes=500-499', 'bytes=0-499,500-999', 'bytes=0-499, 500-999', 'bytes=0-499,1000-1999', 'bytes=0-4,6-9', 'bytes=0-4,3-9', 'bytes=0-4,5-9', 'bytes=5-9,0-4', 'bytes=0-9,2-3', + 'bytes=2-3,0-9', 'bytes=0-1,3-4,6-7,2-2,5-5', 'bytes=-5,0-4', 'bytes=0-4,-5', 'bytes=1-2,1-2', 'bytes=0-4,abc', 'bytes=abc', 'bytes=0', 'bytes=', 'bytes', '', 'bytes=-', + 'bytes=1-2-3', 'bytes= 0-4', 'bytes=0 - 4', 'bytes=0-4 ', 'bytes=0.5-4', 'bytes=0-4.5', 'bytes=-0-4', 'bytes=+1-4', 'bytes=1e1-20', 'items=0-4', 'x=0-4,6-9', 'bytes=0-4,,6-9', + 'bytes=1-', 'bytes=1-0', 'bytes=0-0,0-0', 'bytes=0-4,2-6,8-9', 'bytes=8-9,0-4,2-6', 'bytes=0-1,2-3,5-6', 'bytes=0-1,3-4,2-2', 'bytes=0-9999999999999', 'bytes=9999999999999-', + 'bytes=-9999999999999', 'BYTES=0-4', 'bytes=0-4;foo', 'bytes=0x0-4' + ] + const sizes = [0, 1, 10, 1000, 1001] + for (const size of sizes) { + for (const str of headers) { + both(size, str) + both(size, str, { combine: true }) + both(size, str, { combine: false }) + both(size, str, {}) + } + } + both(1000, 'bytes=0-4', undefined) + + return count +} diff --git a/tools/nest/parity/ret.mjs b/tools/nest/parity/ret.mjs new file mode 100644 index 0000000..3b3abba --- /dev/null +++ b/tools/nest/parity/ret.mjs @@ -0,0 +1,39 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const outcome = (call) => { + try { + return { value: JSON.parse(JSON.stringify(call())) } + } catch (error) { + return { error: `${error.constructor.name}: ${error.message}` } + } + } + const both = (label, call) => { + assert.deepEqual(outcome(() => call(shim)), outcome(() => call(original)), label) + count++ + } + + for (const name of Object.keys(shim.types)) assert.equal(shim.types[name], original.types[name], `types.${name}`) + assert.deepEqual(Object.keys(shim.types).sort(), Object.keys(original.types).filter((k) => Number.isNaN(Number(k))).sort()) + count++ + assert.equal(shim.default, shim.tokenizer) + count++ + + const sources = [ + '', 'a', 'abc', '.', '..', 'a.b', '\\.', '\\/', '\\\\', '\\d', '\\D', '\\w', '\\W', '\\s', '\\S', '\\b', '\\B', '^', '$', '^abc$', '^$', + '\\d+', '\\d*', '\\d?', '\\d{3}', '\\d{3,}', '\\d{3,5}', '\\d{0,1}', 'a{2}b{3,}c{1,2}', 'a*?', 'a+?', 'a??', 'a{2,3}?', 'a{,2}', 'a{2', 'a{}', 'a{x}', + '[a-z]', '[a-z]+', '[A-Za-z0-9_-]', '[^abc]', '[^a-z]', '[]', '[^]', '[a-]', '[-a]', '[a-b-c]', '[\\]]', '[\\[]', '[.]', '[\\d]', '[\\D\\w\\W\\s\\S]', '[\\b]', '[a\\-z]', '[\\^]', '[\\\\]', + '[\\u0041-\\u005A]', '[\\x41]', '[\\cA]', '\\u0041', '\\x41', '\\cA', '\\c@', '\\c[', '\\c?', '\\0', '\\t\\n\\v\\f\\r', '[\\t\\n]', '\\u00e9', '\\uD83D\\uDE00', 'caf\\u00e9', + '(a)', '(ab)', '(a)(b)', '((a)(b))', '(a(b(c)))', '(?:a)', '(?:ab)+', '(?=a)', '(?!a)', '(?!a)b', '(?<=a)', '(?a)', '(?Pa)', '(?i)a', + 'a|b', 'a|b|c', '(a|b)', '(a|b|c)+', '(a|(b|c))', '(|a)', '(a|)', '|', 'a||b', '^(a|b)$', '(.*?)', '(.+)', '(.*?)-(.*?)', '(.*?)\\.(.*?)', '(?:(?!-).)*', '(-|(?:(?!-).)*)', + '\\1', '(a)\\1', '(a)(b)\\2', '(a)\\2', '\\10', '(a)\\0', '\\k', 'a\\', '\\(', '\\)', '\\[', '\\]', '\\{', '\\}', '\\|', '\\*', '\\+', '\\?', '\\^', '\\$', + '(', ')', '(()', '())', '*', '+', '?', '*a', 'a**', 'a++', 'a*+', '{1}', 'a{1}{2}', '[a', '[a-', 'a]', '^*', '$+', '(?:)', '(?:*)', '(*)', '(|*)', '\\b*', '\\B+', + '[a-z]{1,3}', '[0-9]{4}-[0-9]{2}-[0-9]{2}', '^(\\d{4})-(\\d{2})$', '^[a-f0-9]{32}$', '^v\\d+(\\.\\d+)*$', '^\\w+@\\w+\\.\\w+$', '^(?:[a-z]+-)*[a-z]+$', '(\\d+)\\.(\\d+)', + '/users/:id', ':id', '::', '*', '/*', '/a/*', 'ab\\u00e9+', '[^/]+', '[^/]*', '(?:[^/]+)', 'x{10}y{20}z{30}', '(a+)+', '(a|a)+', '(a*)*', '(a*)+', '((a+)+)+' + ] + for (const source of sources) both(`tokenizer(${JSON.stringify(source)})`, (m) => (m === shim ? m.tokenizer(source) : m(source))) + + const regexps = [/\d+/, /[a-z]+/i, /^\/?$/, /(.*?)/, /A/, /\x41/, /[A-Z]/, /(a|b)c/, /\bword\b/, /\\/, /\//, /[/]/, /\p{L}/u, /(?\d{4})/] + for (const regexp of regexps) both(`tokenizer(${regexp}.source)`, (m) => (m === shim ? m.tokenizer(regexp.source) : m(regexp.source))) + + return count +} diff --git a/tools/nest/parity/safe-regex2.mjs b/tools/nest/parity/safe-regex2.mjs new file mode 100644 index 0000000..5a77e72 --- /dev/null +++ b/tools/nest/parity/safe-regex2.mjs @@ -0,0 +1,33 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const both = (label, call) => { + assert.deepEqual(call(shim), call(original), label) + count++ + } + + assert.equal(shim.default, shim.safeRegex) + count++ + + const sources = [ + '', 'a', 'abc', '\\d+', '\\d*', '[a-z]+', '(.*?)', '(.+)', '^\\d{3}$', '^[a-f0-9]{32}$', '(\\d+)\\.(\\d+)', '(a|b)+', '(a|b)*', '(?:(?!-).)*', '(-|(?:(?!-).)*)', + '(a+)+', '(a*)*', '(a|a)+', '((a+)+)+', '(a|aa)+', '(\\w+\\s?)*', '(.*a){10}', '(.*a){26}', '(x+x+)+y', '^(([a-z])+.)+[A-Z]([a-z])+$', '(a+){2}', '(?:a+)+', + 'a{2,3}', 'a{2,3}b{4}', 'a*b*c*', 'a+b+c+', 'a?b?c?', 'a*?b+?', '(a)(b)(c)', '(a)+(b)+', '[a-z]*[0-9]*', + 'a*'.repeat(10), 'a*'.repeat(25), 'a*'.repeat(26), 'a*'.repeat(30), '(a*)'.repeat(5), 'a+b*c?d{2}e{3,}', '(a+)(b+)(c+)(d+)', + '(', ')', '*', '+a', 'a**', '[a', 'a{2', '\\', '(?:', '(a|)', '(|)', '\\1', '(a)\\1+', '\\k', '(?a)\\k', '.*', '.*.*', '(.*)*', '(.*)+', '(.*?)+', '(?=a+)+', '(?!a+)+', + '[^/]+', '(?:[^/]+)+', '[^/]*', '^/users/(\\d+)$', '^v(\\d+)(?:\\.(\\d+))*$', '^(\\d{4})-(\\d{2})-(\\d{2})$', '^\\w+@\\w+\\.\\w+$', '^(?:[a-z]+-)*[a-z]+$', '\\u0041+', '[\\u0041-\\u005a]+' + ] + for (const source of sources) { + both(`safeRegex(${JSON.stringify(source)})`, (m) => m.safeRegex(source)) + both(`default(${JSON.stringify(source)})`, (m) => m.default(source)) + for (const limit of [0, 1, 2, 5, 10, 25, 100]) both(`safeRegex(${JSON.stringify(source)}, {limit: ${limit}})`, (m) => m.safeRegex(source, { limit })) + both(`safeRegex(${JSON.stringify(source)}, {})`, (m) => m.safeRegex(source, {})) + } + + const regexps = [/\d+/, /[a-z]+/i, /(a+)+/, /(.*?)/, /(a|b)+/g, /^\/?$/, /(\d+)\.(\d+)/, /(x+x+)+y/, /\p{L}+/u, /(?\d{4})/, /a*b*c*/, /(a*)*/] + for (const regexp of regexps) { + both(`safeRegex(${regexp})`, (m) => m.safeRegex(regexp)) + both(`safeRegex(${regexp}, {limit: 1})`, (m) => m.safeRegex(regexp, { limit: 1 })) + } + + return count +} diff --git a/tools/nest/parity/secure-json-parse.mjs b/tools/nest/parity/secure-json-parse.mjs new file mode 100644 index 0000000..4fff3f9 --- /dev/null +++ b/tools/nest/parity/secure-json-parse.mjs @@ -0,0 +1,42 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const both = (label, call) => { + const results = [shim, original].map((m) => { try { return { value: call(m) } } catch (error) { return { error: `${error.constructor.name}: ${error.message}` } } }) + assert.deepEqual(results[0], results[1], label) + count++ + } + const texts = [ + '{"a":1}', '[1,2,{"b":null}]', '"str"', '1', 'null', 'true', '', 'not json', '{"a":', '\uFEFF{"a":1}', + '{"__proto__":{"x":1}}', '{"a":{"__proto__":{"x":1}}}', '[{"__proto__":1}]', '{"\\u005f_proto__":1}', '{"__proto__" :1}', '{"__proto__x":1}', + '{"constructor":{"prototype":{"x":1}}}', '{"constructor":{"x":1}}', '{"constructor":1}', '{"a":[{"constructor":{"prototype":1}}]}', + '{"\\u0063onstructor":{"prototype":1}}', '{"constructor":{"prototype":1},"__proto__":1}', '{"x":"__proto__"}', '{"x":"\\"__proto__\\":"}', + '{"deep":{"deeper":{"deepest":{"__proto__":{}}}}}', '{"arr":[[[{"constructor":{"prototype":[]}}]]]}' + ] + const actions = [undefined, 'error', 'remove', 'ignore'] + for (const text of texts) { + for (const protoAction of actions) for (const constructorAction of actions) { + const options = {} + if (protoAction) options.protoAction = protoAction + if (constructorAction) options.constructorAction = constructorAction + both(`parse(${text}, ${JSON.stringify(options)})`, (m) => m.parse(text, options)) + both(`parse(${text}, null, ${JSON.stringify(options)})`, (m) => m.parse(text, null, options)) + both(`default.parse(${text}, ${JSON.stringify(options)})`, (m) => (m.default ?? m).parse(text, options)) + both(`parse(Buffer ${text})`, (m) => m.parse(Buffer.from(text), options)) + both(`parse safe ${text}`, (m) => m.parse(text, { ...options, safe: true })) + } + both(`parse reviver ${text}`, (m) => m.parse(text, (k, v) => (typeof v === 'number' ? v * 2 : v))) + both(`parse reviver+options ${text}`, (m) => m.parse(text, (k, v) => v, { protoAction: 'remove', constructorAction: 'remove' })) + both(`safeParse ${text}`, (m) => m.safeParse(text)) + both(`safeParse reviver ${text}`, (m) => m.safeParse(text, (k, v) => v)) + let parsed + try { parsed = JSON.parse(text) } catch { continue } + if (parsed === null || typeof parsed !== 'object') continue + for (const protoAction of actions) for (const constructorAction of actions) { + const options = { protoAction, constructorAction } + both(`scan ${text} ${JSON.stringify(options)}`, (m) => m.scan(JSON.parse(text), options)) + both(`scan safe ${text} ${JSON.stringify(options)}`, (m) => m.scan(JSON.parse(text), { ...options, safe: true })) + } + both(`scan default ${text}`, (m) => m.scan(JSON.parse(text))) + } + return count +} diff --git a/tools/nest/parity/send.mjs b/tools/nest/parity/send.mjs new file mode 100644 index 0000000..d24b730 --- /dev/null +++ b/tools/nest/parity/send.mjs @@ -0,0 +1,289 @@ +import fs from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' + +const IGNORED_HEADERS = new Set(['date', 'connection', 'keep-alive']) + +export async function run({ shim, original, assert }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'send-parity-')) + const write = (relative, content) => { + const file = path.join(root, relative) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, content) + } + write('hello.txt', 'hello world') + write('index.html', '

root index

') + write('nested/index.html', '

nested index

') + write('nested/page.html', '

page

') + write('nested/deeper/index.htm', 'deeper htm index') + write('.hidden', 'secret') + write('.dot/inside.txt', 'inside a dot directory') + write('noext', 'no extension') + write('withext.txt', 'found through extensions') + write('withext.html', '

html alternative

') + write('empty-dir/.keep', '') + write('file.unknownext', 'unknown type') + write('empty.txt', '') + write('bin.dat', Buffer.from(Array.from({ length: 1000 }, (_, i) => i % 251))) + write('space name.txt', 'a file with a space') + write('unicode-é.txt', 'unicode name') + const absolute = (relative) => path.join(root, relative) + + const cases = [] + const add = (name, spec) => cases.push({ name, method: 'GET', headers: {}, options: { root }, ...spec }) + + add('hello', { path: '/hello.txt' }) + add('html', { path: '/index.html' }) + add('unknown ext', { path: '/file.unknownext' }) + add('empty file', { path: '/empty.txt' }) + add('binary', { path: '/bin.dat' }) + add('space in name', { path: '/space%20name.txt' }) + add('unicode name', { path: '/unicode-%C3%A9.txt' }) + add('absolute path, no root', { path: absolute('hello.txt'), options: {} }) + add('absolute path, explicit root undefined', { path: absolute('hello.txt'), options: { root: undefined } }) + add('empty path with root', { path: '', options: { root } }) + add('HEAD', { path: '/hello.txt', method: 'HEAD' }) + add('HEAD missing', { path: '/missing.txt', method: 'HEAD' }) + add('preset content type', { path: '/hello.txt', presetHeaders: { 'Content-Type': 'application/x-custom' } }) + add('preset cache headers', { path: '/hello.txt', presetHeaders: { 'Cache-Control': 'private', 'Last-Modified': 'Mon, 01 Jan 2001 00:00:00 GMT', ETag: '"preset"', 'Accept-Ranges': 'none' } }) + + add('bad percent', { path: '/%zz' }) + add('null byte', { path: '/hello%00.txt' }) + add('null byte raw', { path: '/hello\0.txt' }) + add('dot dot', { path: '/../hello.txt' }) + add('dot dot inside', { path: '/nested/../hello.txt' }) + add('dot dot encoded', { path: '/%2e%2e/hello.txt' }) + add('dot dot no root', { path: absolute('nested/../hello.txt'), options: {} }) + add('dot dot no root leading', { path: '../' + path.basename(root) + '/hello.txt', options: {} }) + add('missing', { path: '/missing.txt' }) + add('missing no ext', { path: '/missing' }) + add('missing directory', { path: '/missing/' }) + add('file as directory', { path: '/hello.txt/' }) + add('file as parent', { path: '/hello.txt/child' }) + add('long name', { path: '/' + 'a'.repeat(300) }) + + for (const dotfiles of [undefined, 'ignore', 'allow', 'deny']) { + const label = dotfiles ?? 'default' + add(`dotfile ${label}`, { path: '/.hidden', options: { root, dotfiles } }) + add(`dot directory ${label}`, { path: '/.dot/inside.txt', options: { root, dotfiles } }) + add(`dot directory ${label} no root`, { path: absolute('.dot/inside.txt'), options: { dotfiles } }) + add(`dot index ${label}`, { path: '/.dot/', options: { root, dotfiles, index: 'inside.txt' } }) + } + add('dotfile in root path, with root', { path: '/hello.txt', options: { root: absolute('.dot/..') } }) + + add('root index', { path: '/' }) + add('nested index', { path: '/nested/' }) + add('nested no slash', { path: '/nested' }) + add('nested no slash, double leading', { path: '//nested' }) + add('nested no slash, triple leading', { path: '///nested' }) + add('nested no slash, query-ish', { path: '/nested%3Fa=1' }) + add('nested no slash with directory listener', { path: '/nested', listenDirectory: true }) + add('nested slash with directory listener', { path: '/nested/', listenDirectory: true, options: { root, index: false } }) + add('empty dir index', { path: '/empty-dir/' }) + add('deeper index custom', { path: '/nested/deeper/', options: { root, index: ['index.html', 'index.htm'] } }) + add('deeper index single', { path: '/nested/deeper/', options: { root, index: 'index.htm' } }) + add('index false slash', { path: '/nested/', options: { root, index: false } }) + add('index false root', { path: '/', options: { root, index: false } }) + add('index empty list', { path: '/nested/', options: { root, index: [] } }) + add('index on file path', { path: '/hello.txt/', options: { root, index: 'index.html' } }) + add('index pointing at directory', { path: '/', options: { root, index: ['nested', 'index.html'] } }) + add('index missing then found', { path: '/', options: { root, index: ['nope.html', 'index.html'] } }) + + add('extensions found txt', { path: '/withext', options: { root, extensions: ['txt', 'html'] } }) + add('extensions found html', { path: '/withext', options: { root, extensions: ['html', 'txt'] } }) + add('extensions single string', { path: '/withext', options: { root, extensions: 'html' } }) + add('extensions none match', { path: '/withext', options: { root, extensions: ['md', 'json'] } }) + add('extensions false', { path: '/withext', options: { root, extensions: false } }) + add('extensions with existing file', { path: '/noext', options: { root, extensions: ['txt'] } }) + add('extensions with extension in path', { path: '/withext.md', options: { root, extensions: ['txt'] } }) + add('extensions directory candidate', { path: '/nested', options: { root, extensions: ['txt'] } }) + add('extensions trailing slash', { path: '/withext/', options: { root, extensions: ['txt'], index: false } }) + + add('maxAge string', { path: '/hello.txt', options: { root, maxAge: '1d' } }) + add('maxAge number', { path: '/hello.txt', options: { root, maxAge: 3600000 } }) + add('maxAge fractional', { path: '/hello.txt', options: { root, maxAge: 1500 } }) + add('maxAge over a year', { path: '/hello.txt', options: { root, maxAge: '10y' } }) + add('maxAge negative', { path: '/hello.txt', options: { root, maxAge: -5000 } }) + add('maxAge garbage', { path: '/hello.txt', options: { root, maxAge: 'soon' } }) + add('maxAge empty', { path: '/hello.txt', options: { root, maxAge: '' } }) + add('maxAge zero', { path: '/hello.txt', options: { root, maxAge: 0 } }) + add('immutable', { path: '/hello.txt', options: { root, maxAge: '1h', immutable: true } }) + add('cacheControl false', { path: '/hello.txt', options: { root, cacheControl: false, maxAge: '1h', immutable: true } }) + add('etag false', { path: '/hello.txt', options: { root, etag: false } }) + add('lastModified false', { path: '/hello.txt', options: { root, lastModified: false } }) + add('acceptRanges false', { path: '/hello.txt', options: { root, acceptRanges: false } }) + add('acceptRanges false with range', { path: '/bin.dat', options: { root, acceptRanges: false }, headers: { range: 'bytes=0-4' } }) + add('start', { path: '/hello.txt', options: { root, start: 6 } }) + add('start end', { path: '/hello.txt', options: { root, start: 2, end: 6 } }) + add('end only', { path: '/hello.txt', options: { root, end: 4 } }) + add('end past size', { path: '/hello.txt', options: { root, end: 100 } }) + add('start past size', { path: '/hello.txt', options: { root, start: 100 } }) + add('start end with range', { path: '/bin.dat', options: { root, start: 100, end: 299 }, headers: { range: 'bytes=0-9' } }) + add('start end with suffix range', { path: '/bin.dat', options: { root, start: 100, end: 299 }, headers: { range: 'bytes=-10' } }) + + for (const range of ['bytes=0-4', 'bytes=-5', 'bytes=5-', 'bytes=0-0', 'bytes=999-', 'bytes=0-999', 'bytes=0-1500', 'bytes=1000-', 'bytes=2000-3000', 'bytes=-0', 'bytes=0-4,6-9', 'bytes=0-4,3-9', 'bytes=0-4,5-9', 'bytes=abc', 'bytes=', 'bytes', 'items=0-4', ' bytes=0-4', 'bytes=0-4, 2000-', 'bytes=2000-,0-4']) { + add(`range ${range}`, { path: '/bin.dat', headers: { range } }) + add(`range HEAD ${range}`, { path: '/bin.dat', method: 'HEAD', headers: { range } }) + } + add('range empty file', { path: '/empty.txt', headers: { range: 'bytes=0-4' } }) + add('range unsatisfiable with error listener', { path: '/bin.dat', headers: { range: 'bytes=2000-' }, listenError: true }) + add('range on missing', { path: '/missing.dat', headers: { range: 'bytes=0-4' } }) + + add('error listener missing', { path: '/missing.txt', listenError: true }) + add('error listener missing no ext', { path: '/missing', listenError: true, options: { root, extensions: ['txt'] } }) + add('error listener file as parent', { path: '/hello.txt/child', listenError: true }) + add('error listener dotfile', { path: '/.hidden', listenError: true }) + add('error listener dotfile deny', { path: '/.hidden', listenError: true, options: { root, dotfiles: 'deny' } }) + add('error listener dot dot', { path: '/../x', listenError: true }) + add('error listener bad percent', { path: '/%zz', listenError: true }) + add('error listener directory slash no index', { path: '/nested/', listenError: true, options: { root, index: false } }) + add('error listener empty dir', { path: '/empty-dir/', listenError: true }) + add('error listener with directory listener', { path: '/nested', listenError: true, listenDirectory: true }) + + const servers = { shim: await serve(shim.default, cases, root), original: await serve(original, cases, root) } + try { + let count = 0 + const compare = async (index) => { + const [fromShim, fromOriginal] = await Promise.all([request(servers.shim, cases[index], index), request(servers.original, cases[index], index)]) + assert.deepEqual(fromShim, fromOriginal, `case ${cases[index].name}`) + count++ + } + for (let index = 0; index < cases.length; index++) await compare(index) + + const probe = await request(servers.original, cases[0], 0) + const etag = probe.headers.etag + const lastModified = probe.headers['last-modified'] + const earlier = new Date(Date.parse(lastModified) - 3600000).toUTCString() + const later = new Date(Date.parse(lastModified) + 3600000).toUTCString() + const strong = etag.replace(/^W\//, '') + const conditional = [ + ['inm match', { 'if-none-match': etag }], + ['inm strong', { 'if-none-match': strong }], + ['inm star', { 'if-none-match': '*' }], + ['inm other', { 'if-none-match': '"other"' }], + ['inm list', { 'if-none-match': '"other", ' + etag }], + ['inm no-cache', { 'if-none-match': etag, 'cache-control': 'no-cache' }], + ['ims equal', { 'if-modified-since': lastModified }], + ['ims later', { 'if-modified-since': later }], + ['ims earlier', { 'if-modified-since': earlier }], + ['ims garbage', { 'if-modified-since': 'garbage' }], + ['inm other with ims equal', { 'if-none-match': '"other"', 'if-modified-since': lastModified }], + ['if-match match', { 'if-match': etag }], + ['if-match strong', { 'if-match': strong }], + ['if-match star', { 'if-match': '*' }], + ['if-match other', { 'if-match': '"other"' }], + ['if-match list', { 'if-match': '"other", ' + etag }], + ['if-match with inm', { 'if-match': '"other"', 'if-none-match': etag }], + ['ius equal', { 'if-unmodified-since': lastModified }], + ['ius later', { 'if-unmodified-since': later }], + ['ius earlier', { 'if-unmodified-since': earlier }], + ['ius garbage', { 'if-unmodified-since': 'garbage' }], + ['ius earlier with inm', { 'if-unmodified-since': earlier, 'if-none-match': etag }] + ] + const conditionalCases = [] + for (const [name, headers] of conditional) { + conditionalCases.push({ name: `conditional ${name}`, method: 'GET', headers, path: '/hello.txt', options: { root } }) + conditionalCases.push({ name: `conditional HEAD ${name}`, method: 'HEAD', headers, path: '/hello.txt', options: { root } }) + conditionalCases.push({ name: `conditional ${name} etag off`, method: 'GET', headers, path: '/hello.txt', options: { root, etag: false } }) + conditionalCases.push({ name: `conditional ${name} lastModified off`, method: 'GET', headers, path: '/hello.txt', options: { root, lastModified: false } }) + conditionalCases.push({ name: `conditional ${name} missing`, method: 'GET', headers, path: '/missing.txt', options: { root } }) + } + const binIndex = cases.findIndex((spec) => spec.name === 'binary') + const binProbe = await request(servers.original, cases[binIndex], binIndex) + const binEtag = binProbe.headers.etag + const binLastModified = binProbe.headers['last-modified'] + const binEarlier = new Date(Date.parse(binLastModified) - 3600000).toUTCString() + const binLater = new Date(Date.parse(binLastModified) + 3600000).toUTCString() + for (const [name, ifRange] of [ + ['etag match', binEtag], + ['etag other', '"other"'], + ['etag list containing', '"other", ' + binEtag], + ['date equal', binLastModified], + ['date later', binLater], + ['date earlier', binEarlier], + ['garbage', 'garbage'], + ['empty', ''] + ]) { + conditionalCases.push({ name: `if-range ${name}`, method: 'GET', headers: { range: 'bytes=0-9', 'if-range': ifRange }, path: '/bin.dat', options: { root } }) + conditionalCases.push({ name: `if-range ${name} unsatisfiable`, method: 'GET', headers: { range: 'bytes=5000-', 'if-range': ifRange }, path: '/bin.dat', options: { root } }) + conditionalCases.push({ name: `if-range ${name} lastModified off`, method: 'GET', headers: { range: 'bytes=0-9', 'if-range': ifRange }, path: '/bin.dat', options: { root, lastModified: false } }) + } + cases.push(...conditionalCases) + for (let index = cases.length - conditionalCases.length; index < cases.length; index++) await compare(index) + + return count + } finally { + servers.shim.server.close() + servers.original.server.close() + fs.rmSync(root, { recursive: true, force: true }) + } +} + +function serve(impl, cases, root) { + const relative = (file) => path.relative(root, file) + const events = new Map() + const server = http.createServer((req, res) => { + const index = Number(req.headers['x-case']) + const spec = cases[index] + const recorded = [] + events.set(index, recorded) + if (spec.presetHeaders) for (const [name, value] of Object.entries(spec.presetHeaders)) res.setHeader(name, value) + const stream = impl(req, spec.path, spec.options) + if (spec.listenError) { + stream.on('error', (error) => { + res.statusCode = 599 + res.setHeader( + 'x-error', + JSON.stringify({ + name: error.name, + message: error.message.split(root).join(''), + status: error.status, + statusCode: error.statusCode, + expose: error.expose, + code: error.code ?? null, + syscall: error.syscall ?? null, + headers: error.headers ?? null + }) + ) + res.end() + }) + } + if (spec.listenDirectory) { + stream.on('directory', (response, directory) => { + response.statusCode = 200 + response.end('directory:' + relative(directory)) + }) + } + stream.on('headers', (response, file, stats) => response.setHeader('x-headers-event', `${relative(file)}:${stats.size}`)) + stream.on('file', (file, stats) => recorded.push(`file:${relative(file)}:${stats.size}`)) + stream.on('stream', (readable) => recorded.push(`stream:${typeof readable.pipe}`)) + stream.on('end', () => recorded.push('end')) + stream.pipe(res) + }) + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port, events })) + }) +} + +function request(target, spec, index) { + return new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port: target.port, method: spec.method, path: '/', headers: { ...spec.headers, 'x-case': String(index) } }, + (res) => { + const chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('end', () => { + setImmediate(() => { + const headers = {} + for (const [name, value] of Object.entries(res.headers)) if (!IGNORED_HEADERS.has(name)) headers[name] = value + resolve({ status: res.statusCode, headers, body: Buffer.concat(chunks).toString('base64'), events: target.events.get(index) ?? [] }) + }) + }) + res.on('error', reject) + } + ) + req.on('error', reject) + req.end() + }) +} diff --git a/tools/nest/parity/statuses.mjs b/tools/nest/parity/statuses.mjs new file mode 100644 index 0000000..33cf514 --- /dev/null +++ b/tools/nest/parity/statuses.mjs @@ -0,0 +1,39 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const outcome = (call) => { + try { + return { value: call() } + } catch (error) { + return { error: `${error.constructor.name}: ${error.message}` } + } + } + const both = (label, call) => { + assert.deepEqual(outcome(() => call(shim.default)), outcome(() => call(original)), label) + count++ + } + + const stringKeys = (object) => Object.fromEntries(Object.entries(object).map(([key, value]) => [String(key), value])) + assert.deepEqual(stringKeys(shim.message), stringKeys(original.message), 'message') + assert.deepEqual(shim.code, original.code, 'code') + assert.deepEqual(shim.codes, original.codes, 'codes') + assert.deepEqual(stringKeys(shim.redirect), stringKeys(original.redirect), 'redirect') + assert.deepEqual(stringKeys(shim.empty), stringKeys(original.empty), 'empty') + assert.deepEqual(stringKeys(shim.retry), stringKeys(original.retry), 'retry') + count += 6 + for (const code of original.codes) { + assert.equal(shim.statusMessage(code), original.message[code], `statusMessage(${code})`) + count++ + } + for (const code of [0, 99, 100, 199, 299, 399, 499, 599, 600, 1000, -1, NaN, 404.5]) { + assert.equal(shim.statusMessage(code), original.message[code], `statusMessage(${code})`) + count++ + } + + const values = [ + ...original.codes, 0, 99, 600, 1000, -404, 404.5, NaN, '404', '200', '0', '999', ' 404', '404abc', '4.04', 'Not Found', 'not found', 'NOT FOUND', 'Not Found', 'OK', 'ok', + "I'm a Teapot", "i'm a teapot", 'Internal Server Error', 'Teapot', '', 'abc', 'Found', 'Moved Permanently', 'Unknown' + ] + for (const value of values) both(`status(${JSON.stringify(value)})`, (status) => status(value)) + + return count +} diff --git a/tools/nest/parity/type-is.mjs b/tools/nest/parity/type-is.mjs new file mode 100644 index 0000000..bce19f3 --- /dev/null +++ b/tools/nest/parity/type-is.mjs @@ -0,0 +1,39 @@ +export async function run({ shim, original, assert }) { + let count = 0 + const both = (label, call) => { + const results = [shim, original].map((m) => { try { return { value: call(m) } } catch (error) { return { error: `${error.constructor.name}: ${error.message}` } } }) + assert.deepEqual(results[0], results[1], label) + count++ + } + const headerSets = [ + {}, { 'content-length': '0' }, { 'content-length': '12' }, { 'content-length': 'abc' }, { 'content-length': '' }, { 'content-length': ' 1 ' }, + { 'transfer-encoding': 'chunked' }, { 'transfer-encoding': '' }, { 'content-length': ['1', '2'] }, { 'content-length': ['x'] } + ] + const contentTypes = [ + undefined, '', 'application/json', 'application/json; charset=utf-8', 'APPLICATION/JSON', 'application/vnd.api+json', 'text/html', + 'text/plain', 'text/x-custom', 'application/x-www-form-urlencoded', 'multipart/form-data; boundary=abc', 'image/svg+xml', 'application/ld+json', + 'application/octet-stream', 'text/html;;', 'text/html; charset', 'text/ht ml', 'nonsense', 'text/', 'a/b+c+d', '*/*', 'application/json , text/plain' + ] + const candidateLists = [ + [], ['json'], ['application/json'], ['+json'], ['*/json'], ['*/*'], ['text/*'], ['urlencoded'], ['multipart'], ['html'], ['application/*'], + ['json', 'urlencoded'], ['text/*', '+json'], ['*/*+json'], ['application/*+json'], ['bogus'], ['text/html', 'application/json'], ['*/'], ['a/b/c'], + [['json', 'urlencoded']], [['json'], 'html'], [undefined], ['+xml'], ['application/vnd.api+json'] + ] + for (const headers of headerSets) { + for (const ct of contentTypes) { + const req = { headers: ct === undefined ? { ...headers } : { ...headers, 'content-type': ct } } + both(`hasBody ${JSON.stringify(req.headers)}`, (m) => m.hasBody(req)) + for (const candidates of candidateLists) { + const label = `typeofrequest(${JSON.stringify(req.headers)}, ${JSON.stringify(candidates)})` + both(label + ' spread', (m) => (m.default ?? m)(req, ...candidates)) + both(label + ' array', (m) => (m.default ?? m)(req, candidates)) + both(label + ' is(header)', (m) => m.is(req.headers['content-type'], candidates)) + } + } + } + for (const type of ['json', 'urlencoded', 'multipart', '+json', 'text/html', 'html', 'bogus', '', 'JSON', 42, undefined]) both(`normalize(${JSON.stringify(type)})`, (m) => m.normalize(type)) + for (const [e, a] of [['text/*', 'text/html'], ['*/*+json', 'application/vnd.api+json'], ['*/*+json', 'application/json'], ['*/*+json', 'application/n'], ['application/json', 'application/json'], [false, 'text/html'], ['a/b/c', 'a/b'], ['*/json', 'text/json'], ['*+json', 'application/json']]) { + both(`match(${e}, ${a})`, (m) => m.match(e, a)) + } + return count +} diff --git a/tools/nest/test-shims.mjs b/tools/nest/test-shims.mjs new file mode 100644 index 0000000..464779c --- /dev/null +++ b/tools/nest/test-shims.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import ts from 'typescript' + +const require = createRequire(import.meta.url) +const root = path.resolve(import.meta.dirname, '../..') +const shimsDir = path.join(root, 'runtime/node/nest/shims') +const parityDir = path.join(import.meta.dirname, 'parity') +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'nest-shims-')) + +const walk = (directory, visit) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name) + if (entry.isDirectory()) walk(file, visit) + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) visit(file) + } +} + +const transpileAll = () => { + const compilerOptions = { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + verbatimModuleSyntax: false, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove + } + walk(shimsDir, (file) => { + const relative = path.relative(shimsDir, file) + const source = fs.readFileSync(file, 'utf8') + const { outputText, diagnostics } = ts.transpileModule(source, { compilerOptions, fileName: file, reportDiagnostics: true }) + assert.equal(diagnostics?.length ?? 0, 0, `${relative}: ${diagnostics?.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')).join('\n')}`) + const rewritten = outputText.replace(/(from\s+|import\()\s*(['"])([^'"]+)\2/g, (whole, lead, q, specifier) => { + if (specifier.startsWith('node:')) return whole + if (/^\.{1,2}\//.test(specifier)) return /\.[cm]?js$/.test(specifier) ? whole : `${lead}${q}${specifier}.js${q}` + assert.fail(`${relative}: imports the npm package ${specifier}; shims import other shims by relative path`) + }) + const target = path.join(scratch, relative.replace(/\.ts$/, '.js')) + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, rewritten) + }) +} + +const shimNames = () => { + const names = [] + walk(shimsDir, (file) => { + if (file.endsWith('.generated.ts')) return + const relative = path.relative(shimsDir, file).replace(/\.ts$/, '') + names.push(relative.endsWith('/index') ? relative.slice(0, -'/index'.length) : relative) + }) + return names.sort() +} + +const selected = process.argv.slice(2) +transpileAll() +let ran = 0 +let failed = 0 +for (const name of shimNames()) { + if (selected.length > 0 && !selected.includes(name)) continue + const table = path.join(parityDir, `${name.replace(/\//g, '__')}.mjs`) + if (!fs.existsSync(table)) { + console.log(`skip ${name}: no parity table at tools/nest/parity/${path.basename(table)}`) + continue + } + const moduleFile = fs.existsSync(path.join(scratch, name, 'index.js')) ? path.join(scratch, name, 'index.js') : path.join(scratch, `${name}.js`) + const shim = await import(pathToFileURL(moduleFile).href) + const original = require(moduleFile.endsWith('/index.js') ? name : `${name}.js`) + const { run } = await import(pathToFileURL(table).href) + try { + const count = await run({ shim, original, require, assert }) + ran++ + console.log(`ok ${name}: ${count ?? 'all'} cases`) + } catch (error) { + failed++ + console.log(`FAIL ${name}: ${error?.stack ?? error}`) + } +} +fs.rmSync(scratch, { recursive: true, force: true }) +console.log(`${ran} shim table(s) passed, ${failed} failed`) +if (failed > 0) process.exit(1) diff --git a/tools/nest/tsconfig.shims.json b/tools/nest/tsconfig.shims.json new file mode 100644 index 0000000..72c4893 --- /dev/null +++ b/tools/nest/tsconfig.shims.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "types": [ + "node" + ], + "skipLibCheck": true, + "allowImportingTsExtensions": true + }, + "include": [ + "../../runtime/node/nest/shims/**/*.ts" + ] +}