UNPKG

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
"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