send-stream
Version:
Streaming file serving library with Range and conditional-GET support from file system or any streaming sources.
630 lines • 28.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Storage = void 0;
const tslib_1 = require("tslib");
const node_http_1 = require("node:http");
const node_http2_1 = require("node:http2");
const node_stream_1 = require("node:stream");
const node_zlib_1 = require("node:zlib");
const content_disposition_1 = tslib_1.__importDefault(require("content-disposition"));
const mime_types_1 = require("mime-types");
const range_parser_1 = tslib_1.__importDefault(require("range-parser"));
const compressible_1 = tslib_1.__importDefault(require("compressible"));
const response_1 = require("./response");
const streams_1 = require("./streams");
const utils_1 = require("./utils");
const errors_1 = require("./errors");
const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD'];
const DEFAULT_MAX_RANGES = 200;
/**
* send-stream storage base class
* @template Reference - reference type
* @template AttachedData - attached data type
*/
class Storage {
/**
* Create storage
* @param opts - storage options
*/
constructor(opts = {}) {
/**
* Default mime type or false
*/
Object.defineProperty(this, "defaultMimeType", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Max ranges for multiple range GET requests
*/
Object.defineProperty(this, "maxRanges", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Produces week tags when true
*/
Object.defineProperty(this, "weakEtags", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Mime type lookup function
*/
Object.defineProperty(this, "mimeTypeLookup", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Mime type default charset function
*/
Object.defineProperty(this, "mimeTypeDefaultCharset", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Dynamic compression preferences or false
*/
Object.defineProperty(this, "dynamicCompression", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Mime type compressible function
*/
Object.defineProperty(this, "mimeTypeCompressible", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Minimum length to produce compressed content
*/
Object.defineProperty(this, "dynamicCompressionMinLength", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.mimeTypeLookup = opts.mimeTypeLookup ?? mime_types_1.lookup;
this.mimeTypeDefaultCharset = opts.mimeTypeDefaultCharset ?? mime_types_1.charset;
if (opts.dynamicCompression) {
const encodingPreferences = opts.dynamicCompression === true
? new Map([['br', { order: 0 }], ['gzip', { order: 1 }], ['identity', { order: 2 }]])
: new Map(opts.dynamicCompression.map((name, order) => [name, { order }]));
let identityEncodingPreference = encodingPreferences.get('identity');
if (!identityEncodingPreference) {
identityEncodingPreference = { order: encodingPreferences.size };
encodingPreferences.set('identity', identityEncodingPreference);
}
this.dynamicCompression = { encodingPreferences, identityEncodingPreference };
}
else {
this.dynamicCompression = false;
}
this.mimeTypeCompressible = opts.mimeTypeCompressible ?? compressible_1.default;
this.dynamicCompressionMinLength = opts.dynamicCompressionMinLength ?? 20;
this.defaultMimeType = opts.defaultMimeType ?? false;
this.maxRanges = opts.maxRanges ?? DEFAULT_MAX_RANGES;
this.weakEtags = opts.weakEtags === true;
}
/**
* Create last-mofified header value from storage information (uses mtimeMs)
* @param storageInfo - storage information
* @returns last-mofified header
*/
createLastModified(storageInfo) {
const { lastModified } = storageInfo;
if (lastModified) {
return lastModified;
}
const { mtimeMs } = storageInfo;
if (mtimeMs === undefined) {
return false;
}
return (0, utils_1.millisecondsToUTCString)(mtimeMs);
}
/**
* Create etag header value from storage information (uses mtimeMs, size and contentEncoding)
* @param storageInfo - storage information
* @returns etag header
*/
createEtag(storageInfo) {
const { etag } = storageInfo;
if (etag) {
return etag;
}
const { size, mtimeMs } = storageInfo;
if (size === undefined || mtimeMs === undefined) {
return false;
}
const { contentEncoding } = storageInfo;
return (0, utils_1.statsToEtag)(size, mtimeMs, contentEncoding, this.weakEtags);
}
/**
* Create cache-control header value from storage information (return always public, max-age=0 unless overriden)
* @param storageInfo - storage information
* @returns cache-control header
*/
createCacheControl(storageInfo) {
const { cacheControl } = storageInfo;
if (cacheControl) {
return cacheControl;
}
return 'public, max-age=0';
}
/**
* Create mime type for content-type header value from storage information
* @param storageInfo - storage information (unused unless overriden)
* @returns mime type
*/
createMimeType(storageInfo) {
const { mimeType } = storageInfo;
if (mimeType) {
return mimeType;
}
const { fileName } = storageInfo;
if (!fileName) {
return this.defaultMimeType;
}
const type = this.mimeTypeLookup(fileName);
if (!type) {
return this.defaultMimeType;
}
return type;
}
/**
* Create charset that will be appended with mime type into content-type header
* @param storageInfo - storage information (unused unless overriden)
* @param mimeType - mime type
* @returns charset
*/
createMimeTypeCharset(storageInfo, mimeType) {
if (storageInfo.mimeTypeCharset) {
return storageInfo.mimeTypeCharset;
}
return this.mimeTypeDefaultCharset(mimeType);
}
/**
* Create content-disposition header type from storage information (return always inline unless overriden)
* @param storageInfo - storage information (unused unless overriden)
* @returns content-disposition header type
*/
createContentDispositionType(storageInfo) {
const { contentDispositionType } = storageInfo;
if (contentDispositionType) {
return contentDispositionType;
}
return 'inline';
}
/**
* Create content-disposition header filename from storage information
* (return always the original filename unless overriden)
* @param storageInfo - storage information
* @returns content-disposition header filename
*/
createContentDispositionFilename(storageInfo) {
const { contentDispositionFilename } = storageInfo;
if (contentDispositionFilename) {
return contentDispositionFilename;
}
const { fileName } = storageInfo;
return fileName;
}
/**
* Prepare to send file
* @param reference - file reference
* @param req - request headers or request objects
* @param [opts] - options
* @returns status, response headers and body to use
* @throws when method is incorrect or when storage can not create the storage stream
*/
async prepareResponse(reference, req, opts = {}) {
let method;
let requestHeaders;
if (req instanceof node_http_1.IncomingMessage || req instanceof node_http2_1.Http2ServerRequest) {
method = req.method;
requestHeaders = req.headers;
}
else {
method = req[':method'];
requestHeaders = req;
}
if (!method) {
throw new Error('cannot send, method is missing');
}
const isGetMethod = method === 'GET';
const isHeadMethod = method === 'HEAD';
const isGetOrHead = isGetMethod || isHeadMethod;
const allowedMethods = opts.allowedMethods ?? DEFAULT_ALLOWED_METHODS;
if (!allowedMethods.includes(method)) {
return this.createMethodNotAllowedError(isHeadMethod, allowedMethods);
}
let earlyClose = false;
let storageInfo;
let contentLength;
let dynamicContentEncoding;
try {
storageInfo = await this.open(reference, requestHeaders);
}
catch (error) {
return this.createStorageError(isHeadMethod, error);
}
let stream;
try {
const responseHeaders = {};
const mimeType = opts.mimeType ?? this.createMimeType(storageInfo);
let mimeTypeCharset;
if (mimeType) {
storageInfo.mimeType = mimeType;
mimeTypeCharset = opts.mimeTypeCharset ?? this.createMimeTypeCharset(storageInfo, mimeType);
if (mimeTypeCharset) {
storageInfo.mimeTypeCharset = mimeTypeCharset;
}
}
const { dynamicCompression, dynamicCompressionMinLength } = this;
if (dynamicCompression
&& !storageInfo.contentEncoding
&& mimeType
&& this.mimeTypeCompressible(mimeType)
&& (storageInfo.size === undefined || storageInfo.size > dynamicCompressionMinLength)) {
storageInfo.vary = 'Accept-Encoding';
const [[preferedEncoding]] = (0, utils_1.acceptEncodings)(requestHeaders['accept-encoding'], dynamicCompression.encodingPreferences, dynamicCompression.identityEncodingPreference);
if (preferedEncoding !== 'identity') {
storageInfo.contentEncoding = preferedEncoding;
dynamicContentEncoding = preferedEncoding;
}
}
const lastModified = opts.lastModified ?? this.createLastModified(storageInfo);
if (lastModified) {
storageInfo.lastModified = lastModified;
}
const etag = opts.etag ?? this.createEtag(storageInfo);
if (etag) {
storageInfo.etag = etag;
}
const contentDispositionType = opts.contentDispositionType
?? this.createContentDispositionType(storageInfo);
let contentDispositionFilename;
if (contentDispositionType) {
storageInfo.contentDispositionType = contentDispositionType;
const { contentDispositionFilename: optsContentDispositionFilename } = opts;
contentDispositionFilename = optsContentDispositionFilename === undefined
? this.createContentDispositionFilename(storageInfo)
: optsContentDispositionFilename || undefined;
storageInfo.contentDispositionFilename = contentDispositionFilename;
}
const cacheControl = opts.cacheControl ?? this.createCacheControl(storageInfo);
if (cacheControl) {
storageInfo.cacheControl = cacheControl;
responseHeaders['Cache-Control'] = cacheControl;
}
if (storageInfo.vary) {
responseHeaders['Vary'] = storageInfo.vary;
}
const fullResponse = opts.statusCode !== undefined;
if (!fullResponse) {
if (lastModified) {
responseHeaders['Last-Modified'] = lastModified;
}
if (etag) {
responseHeaders['ETag'] = etag;
}
const freshStatus = (0, utils_1.getFreshStatus)(isGetOrHead, requestHeaders, etag, lastModified);
switch (freshStatus) {
case 304:
earlyClose = true;
return this.createNotModifiedResponse(responseHeaders, storageInfo);
case 412:
earlyClose = true;
return this.createPreconditionFailedError(isHeadMethod, storageInfo);
case 200:
break;
}
}
if (storageInfo.contentEncoding) {
responseHeaders['Content-Encoding'] = storageInfo.contentEncoding;
}
let contentTypeHeader;
if (mimeType) {
contentTypeHeader = mimeTypeCharset ? `${mimeType}; charset=${mimeTypeCharset}` : mimeType;
responseHeaders['Content-Type'] = contentTypeHeader;
responseHeaders['X-Content-Type-Options'] = 'nosniff';
}
if (contentDispositionType) {
responseHeaders['Content-Disposition'] = (0, content_disposition_1.default)(contentDispositionFilename, { type: contentDispositionType });
}
let statusCode = opts.statusCode ?? 200;
const { size } = storageInfo;
let rangeToUse;
if (size === undefined) {
responseHeaders['Accept-Ranges'] = 'none';
rangeToUse = undefined;
}
else {
const { maxRanges } = this;
if (maxRanges <= 0 || fullResponse || !isGetOrHead || dynamicContentEncoding) {
responseHeaders['Accept-Ranges'] = 'none';
rangeToUse = new utils_1.StreamRange(0, size - 1);
contentLength = size;
}
else {
responseHeaders['Accept-Ranges'] = 'bytes';
const { range: rangeHeader } = requestHeaders;
if (!rangeHeader
|| !(0, utils_1.isRangeFresh)(requestHeaders, etag, lastModified)) {
rangeToUse = new utils_1.StreamRange(0, size - 1);
contentLength = size;
}
else {
const parsedRanges = (0, range_parser_1.default)(size, rangeHeader, { combine: true });
if (parsedRanges === -1) {
earlyClose = true;
return this.createRangeNotSatisfiableError(isHeadMethod, size, storageInfo);
}
if (parsedRanges === -2
|| parsedRanges.type !== 'bytes'
|| parsedRanges.length > maxRanges) {
rangeToUse = new utils_1.StreamRange(0, size - 1);
contentLength = size;
}
else {
statusCode = 206;
if (parsedRanges.length === 1) {
const [singleRange] = parsedRanges;
responseHeaders['Content-Range'] = (0, utils_1.contentRange)('bytes', size, singleRange);
rangeToUse = new utils_1.StreamRange(singleRange.start, singleRange.end);
contentLength = singleRange.end + 1 - singleRange.start;
}
else {
const randomBytesBuffer = await (0, utils_1.randomBytes)(24);
const boundary = `----SendStreamBoundary${randomBytesBuffer.toString('hex')}`;
responseHeaders['Content-Type'] = `multipart/byteranges; boundary=${boundary}`;
responseHeaders['X-Content-Type-Options'] = 'nosniff';
rangeToUse = [];
contentLength = 0;
let first = true;
for (const range of parsedRanges) {
let header = `${first ? '' : '\r\n'}--${boundary}\r\n`;
first = false;
if (contentTypeHeader) {
header += `content-type: ${contentTypeHeader}\r\n`;
}
header += `content-range: ${(0, utils_1.contentRange)('bytes', size, range)}\r\n\r\n`;
const headerBuffer = Buffer.from(header);
rangeToUse.push(headerBuffer, new utils_1.StreamRange(range.start, range.end));
contentLength += headerBuffer.byteLength + range.end + 1 - range.start;
}
const footer = `\r\n--${boundary}--`;
const footerBuffer = Buffer.from(footer);
rangeToUse.push(footerBuffer);
contentLength += footerBuffer.byteLength;
}
}
}
}
if (!dynamicContentEncoding) {
responseHeaders['Content-Length'] = String(contentLength);
}
}
if (isHeadMethod) {
earlyClose = true;
stream = new streams_1.BufferStream();
}
else if (rangeToUse === undefined) {
stream = this.createReadableStream(storageInfo, undefined, true);
}
else if (rangeToUse instanceof utils_1.StreamRange) {
if (rangeToUse.end < rangeToUse.start) {
earlyClose = true;
stream = new streams_1.BufferStream();
}
else {
stream = this.createReadableStream(storageInfo, rangeToUse, true);
}
}
else {
const si = storageInfo;
stream = new streams_1.MultiStream(rangeToUse, range => {
if (range instanceof utils_1.StreamRange) {
return this.createReadableStream(si, range, false);
}
return new streams_1.BufferStream(range);
}, async () => this.close(si));
}
if (dynamicContentEncoding) {
stream = this.createCompressedStream(stream, dynamicContentEncoding, contentLength);
}
return this.createSuccessfulResponse(statusCode, responseHeaders, stream, storageInfo);
}
catch (err) {
if (stream) {
stream.destroy();
}
else {
earlyClose = true;
}
throw err;
}
finally {
if (earlyClose) {
await this.close(storageInfo);
}
}
}
/**
* Send file directly to response
* @param reference - file reference
* @param req - request headers or request objects
* @param res - http response
* @param [opts] - options
* @throws when method is incorrect or when storage can not create the storage stream
*/
async send(reference, req, res, opts = {}) {
const response = await this.prepareResponse(reference, req, opts);
try {
await response.send(res, opts);
}
finally {
response.dispose();
}
}
/**
* Create compressed stream
* (for gzip / brotli encodings only but this method can be overidden to eventually implement other encodings)
* @param stream - stream to compress
* @param contentEncoding - 'br' for brotli encoding or 'gzip' for gzip encoding, other values are not supported
* @param expectedSize - expected stream size
* @returns compressed stream
* @throws if content encoding is not supported
*/
createCompressedStream(stream, contentEncoding, expectedSize) {
switch (contentEncoding) {
case 'br': {
const res = (0, node_stream_1.pipeline)(stream, (0, node_zlib_1.createBrotliCompress)({
params: {
[node_zlib_1.constants.BROTLI_PARAM_MODE]: node_zlib_1.constants.BROTLI_MODE_TEXT,
[node_zlib_1.constants.BROTLI_PARAM_QUALITY]: 4,
[node_zlib_1.constants.BROTLI_PARAM_SIZE_HINT]: expectedSize ?? 0,
},
}), _err => {
// noop
});
return res.on('end', () => {
// force destroy on end
res.destroy();
});
}
case 'gzip': {
const res = (0, node_stream_1.pipeline)(stream, (0, node_zlib_1.createGzip)({ level: 6 }), _err => {
// noop
});
return res.on('end', () => {
// force destroy on end
res.destroy();
});
}
default:
throw new Error(`${contentEncoding} is not supported as dynamic compression encoding (you can override createCompressedStream to handle it)`);
}
}
/**
* Create Method Not Allowed error response
* @param isHeadMethod - true if HEAD method is used
* @param allowedMethods - allowed methods for Allow header
* @returns Method Not Allowed response
*/
createMethodNotAllowedError(isHeadMethod, allowedMethods) {
// Method Not Allowed
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const statusMessageBuffer = Buffer.from(node_http_1.STATUS_CODES['405']);
return new response_1.StreamResponse(405, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Length': String(statusMessageBuffer.byteLength),
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'text/plain; charset=UTF-8',
// eslint-disable-next-line @typescript-eslint/naming-convention
'X-Content-Type-Options': 'nosniff',
// eslint-disable-next-line @typescript-eslint/naming-convention
'Allow': allowedMethods.join(', '),
}, isHeadMethod ? new streams_1.BufferStream() : new streams_1.BufferStream(statusMessageBuffer), undefined, new errors_1.MethodNotAllowedStorageError('Method not allowed'));
}
/**
* Create storage error response (Not Found response usually)
* @param isHeadMethod - true if HEAD method is used
* @param error - the error causing this response
* @returns the error response
*/
createStorageError(isHeadMethod, error) {
// Not Found
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const statusMessageBuffer = Buffer.from(node_http_1.STATUS_CODES['404']);
return new response_1.StreamResponse(404, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Length': String(statusMessageBuffer.byteLength),
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'text/plain; charset=UTF-8',
// eslint-disable-next-line @typescript-eslint/naming-convention
'X-Content-Type-Options': 'nosniff',
}, isHeadMethod ? new streams_1.BufferStream() : new streams_1.BufferStream(statusMessageBuffer), undefined, error instanceof errors_1.StorageError ? error : new errors_1.StorageError('Unknown error', error));
}
/**
* Create Not Modified response
* @param responseHeaders - response headers
* @param storageInfo - the current storage info
* @returns the Not Modified response
*/
createNotModifiedResponse(responseHeaders, storageInfo) {
// Not Modified
return new response_1.StreamResponse(304, responseHeaders, new streams_1.BufferStream(), storageInfo);
}
/**
* Create the Precondition Failed error response
* @param isHeadMethod - true if HEAD method is used
* @param storageInfo - the current storage info
* @returns the Precondition Failed error response
*/
createPreconditionFailedError(isHeadMethod, storageInfo) {
// Precondition Failed
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const statusMessageBuffer = Buffer.from(node_http_1.STATUS_CODES['412']);
return new response_1.StreamResponse(412, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'text/plain; charset=UTF-8',
// eslint-disable-next-line @typescript-eslint/naming-convention
'X-Content-Type-Options': 'nosniff',
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Length': String(statusMessageBuffer.byteLength),
}, isHeadMethod ? new streams_1.BufferStream() : new streams_1.BufferStream(statusMessageBuffer), storageInfo, new errors_1.PreconditionFailedStorageError('Precondition failed', storageInfo.attachedData));
}
/**
* Create the Range Not Satisfiable error response
* @param isHeadMethod - true if HEAD method is used
* @param size - size of content for Content-Range header
* @param storageInfo - the current storage info
* @returns the Range Not Satisfiable error response
*/
createRangeNotSatisfiableError(isHeadMethod, size, storageInfo) {
// Range Not Satisfiable
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const statusMessageBuffer = Buffer.from(node_http_1.STATUS_CODES['416']);
return new response_1.StreamResponse(416, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Range': (0, utils_1.contentRange)('bytes', size),
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'text/plain; charset=UTF-8',
// eslint-disable-next-line @typescript-eslint/naming-convention
'X-Content-Type-Options': 'nosniff',
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Length': String(statusMessageBuffer.byteLength),
}, isHeadMethod ? new streams_1.BufferStream() : new streams_1.BufferStream(statusMessageBuffer), storageInfo, new errors_1.RangeNotSatisfiableStorageError('Range not satisfiable', storageInfo.attachedData));
}
/**
* Create the successful OK (200) or Partial Content (206) response
* (the http code could also be the one set in parameters)
* @param statusCode - 200 or 206 or the statusCode set in parameters
* @param responseHeaders - the response headers
* @param stream - the content stream
* @param storageInfo - the current storage info
* @returns the successful response
*/
createSuccessfulResponse(statusCode, responseHeaders, stream, storageInfo) {
// Ok | Partial Content
return new response_1.StreamResponse(statusCode, responseHeaders, stream, storageInfo);
}
}
exports.Storage = Storage;
//# sourceMappingURL=storage.js.map