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
JavaScript
;
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