UNPKG

send-stream

Version:

Streaming file serving library with Range and conditional-GET support from file system or any streaming sources.

256 lines 7.87 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.randomBytes = exports.StreamRange = void 0; exports.statsToEtag = statsToEtag; exports.millisecondsToUTCString = millisecondsToUTCString; exports.isRangeFresh = isRangeFresh; exports.contentRange = contentRange; exports.acceptEncodings = acceptEncodings; exports.getFreshStatus = getFreshStatus; const node_crypto_1 = require("node:crypto"); const node_util_1 = require("node:util"); /** * Range */ class StreamRange { /** * StreamRange constructor * @param start - start index * @param end - end index */ constructor(start, end) { Object.defineProperty(this, "start", { enumerable: true, configurable: true, writable: true, value: start }); Object.defineProperty(this, "end", { enumerable: true, configurable: true, writable: true, value: end }); } } exports.StreamRange = StreamRange; /** * Random bytes function returing promise */ exports.randomBytes = (0, node_util_1.promisify)(node_crypto_1.randomBytes); /** * Transform stats to etag * @param size - file size * @param mtimeMs - modification time in milliseconds * @param contentEncoding - content encoding * @param weak - generate weak etag * @returns etag */ function statsToEtag(size, mtimeMs, contentEncoding, weak) { const suffix = contentEncoding ? `-${contentEncoding}` : ''; return `${weak ? 'W/' : ''}"${size.toString(16)}-${Math.floor(mtimeMs * 1000).toString(16)}${suffix}"`; } /** * Convert milliseconds to utc string * @param timeMs - time in milliseconds * @returns utc string */ function millisecondsToUTCString(timeMs) { return new Date(timeMs).toUTCString(); } /** * Test if etag is weak * @param etag - etag * @returns true if weak */ function isWeakEtag(etag) { return etag.startsWith('W/"'); } /** * Test if etag is strong * @param etag - etag * @returns true if strong */ function isStrongEtag(etag) { return etag.startsWith('"'); } /** * Get opaque etag (remove weak part) * @param etag - etag * @returns opaque etag */ function opaqueEtag(etag) { if (isWeakEtag(etag)) { return etag.slice(2); } return etag; } /** * Compare etag with weak validation * @param a - etag a * @param b - etag b * @returns true if match */ function weakEtagMatch(a, b) { return opaqueEtag(a) === opaqueEtag(b); } /** * Compare etag with strong validation * @param a - etag a * @param b - etag b * @returns true if match */ function strongEtagMatch(a, b) { return isStrongEtag(a) && isStrongEtag(b) && a === b; } /** * Check if range is fresh * @param requestHeaders - request headers * @param etag - etag response header * @param lastModified - last modified response header * @returns true if range fresh */ function isRangeFresh(requestHeaders, etag, lastModified) { const { 'if-range': ifRange } = requestHeaders; if (!ifRange) { return true; } // If-Range as etag if (isStrongEtag(ifRange)) { return etag ? ifRange === etag : false; } // If-Range as modified date if (!lastModified) { return false; } return Date.parse(lastModified) === Date.parse(ifRange); } /** * Format content-range header * @param rangeType - type of range * @param size - total size * @param range - range to use (empty = *) * @returns content-range header */ function contentRange(rangeType, size, range) { const rangeStr = range ? `${range.start}-${range.end}` : '*'; return `${rangeType} ${rangeStr}/${size}`; } /** * Parse multiple value header * @param header - header to parse * @returns splitted headers */ function parseMultiValueHeader(header) { const splitted = header.split(',').map(value => value.trim()); while (splitted.length > 0 && splitted[0] === '') { splitted.shift(); } return splitted; } /** * Get accepted content encodings * @template T - encoding object type * @param acceptEncoding - Accept-Encoding header value * @param encodingPreferences - order of preference * @param identityEncodingPreference - identity encoding preference * @returns accepted content encodings */ function acceptEncodings(acceptEncoding, encodingPreferences, identityEncodingPreference) { if (!acceptEncoding) { return [['identity', identityEncodingPreference]]; } const values = parseMultiValueHeader(acceptEncoding); if (values.length === 0) { return [['identity', identityEncodingPreference]]; } const result = new Map(); for (const value of values) { // eslint-disable-next-line @stylistic/max-len, sonarjs/regex-complexity const match = /^(?<rawEncoding>[-!#$%&'*+.^_`|~A-Za-z0-9]+)(?:[ \t]*;[ \t]*q=(?<weightOption>0(?:\.\d{1,3})?|1(?:\.0{1,3})?))?$/u .exec(value); if (!match || !match.groups) { return [['identity', identityEncodingPreference]]; } const { groups: { rawEncoding, weightOption } } = match; let encoding = rawEncoding.toLowerCase(); if (encoding === 'x-gzip') { encoding = 'gzip'; } else if (encoding === 'x-compress') { encoding = 'compress'; } const weight = weightOption ? Number(weightOption) : 1; if (encoding === '*') { for (const [prefEnc, pref] of encodingPreferences) { if (!result.has(prefEnc)) { result.set(prefEnc, { ...pref, weight }); } } } else { const pref = encodingPreferences.get(encoding); if (pref) { result.set(encoding, { ...pref, weight }); } } } if (result.size === 0) { return [['identity', identityEncodingPreference]]; } const identity = result.get('identity'); if (identity === undefined) { result.set('identity', { ...identityEncodingPreference, weight: -1 }); } const resultEntries = [...result.entries()].filter(([, { weight }]) => weight !== 0); resultEntries.sort(([, { weight: aWeight, order: aOrder }], [, { weight: bWeight, order: bOrder }]) => { let diff = bWeight - aWeight; if (diff === 0) { diff = aOrder - bOrder; } return diff; }); return resultEntries; } /** * Get fresh status (ETag, Last-Modified handling) * @param isGetOrHead - http method is GET or HEAD * @param requestHeaders - request headers * @param etag - etag response header * @param lastModified - last modified response header * @returns status code */ function getFreshStatus(isGetOrHead, requestHeaders, etag, lastModified) { const { 'if-match': ifMatch, 'if-none-match': ifNoneMatch, 'if-modified-since': ifModifiedSince, 'if-unmodified-since': ifUnmodifiedSince, } = requestHeaders; if (ifMatch) { if (!etag || (ifMatch !== '*' && !parseMultiValueHeader(ifMatch) .some(ifMatchEtag => strongEtagMatch(ifMatchEtag, etag)))) { return 412; } } else if (ifUnmodifiedSince && lastModified && Date.parse(lastModified) > Date.parse(ifUnmodifiedSince)) { return 412; } if (ifNoneMatch) { if (etag && (ifNoneMatch === '*' || parseMultiValueHeader(ifNoneMatch) .some(ifMatchEtag => weakEtagMatch(ifMatchEtag, etag)))) { return isGetOrHead ? 304 : 412; } } else if (ifModifiedSince && lastModified && Date.parse(lastModified) <= Date.parse(ifModifiedSince) && isGetOrHead) { return 304; } return 200; } //# sourceMappingURL=utils.js.map