From 70739d3541dbbee7cdfa879f7aa9716712fa0927 Mon Sep 17 00:00:00 2001 From: KaKa Date: Sun, 7 Jul 2024 02:46:41 +0800 Subject: [PATCH 1/5] refactor!: new implementation --- examples/simple.js | 7 +- index.js | 17 +- lib/SendStream.js | 905 ---------- lib/clearHeaders.js | 21 - lib/createHttpError.js | 23 - lib/send.js | 683 ++++++++ lib/setHeaders.js | 20 - test/SendStream-pipe.test.js | 1663 ------------------- test/constructor.test.js | 13 - test/{SendStream.test.js => send.1.test.js} | 79 +- test/send.2.test.js | 1126 +++++++++++++ test/utils.js | 6 +- types/index.d.ts | 100 +- types/index.test-d.ts | 53 +- 14 files changed, 1894 insertions(+), 2822 deletions(-) delete mode 100644 lib/SendStream.js delete mode 100644 lib/clearHeaders.js delete mode 100644 lib/createHttpError.js create mode 100644 lib/send.js delete mode 100644 lib/setHeaders.js delete mode 100644 test/SendStream-pipe.test.js delete mode 100644 test/constructor.test.js rename test/{SendStream.test.js => send.1.test.js} (86%) create mode 100644 test/send.2.test.js diff --git a/examples/simple.js b/examples/simple.js index 2c79119..5297b75 100644 --- a/examples/simple.js +++ b/examples/simple.js @@ -6,9 +6,10 @@ const path = require('node:path') const indexPath = path.join(__dirname, 'index.html') -const server = http.createServer(function onRequest (req, res) { - send(req, indexPath) - .pipe(res) +const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, indexPath) + res.writeHead(statusCode, headers) + stream.pipe(res) }) server.listen(3000) diff --git a/index.js b/index.js index b7b27f1..c1c2b01 100644 --- a/index.js +++ b/index.js @@ -13,21 +13,7 @@ */ const isUtf8MimeType = require('./lib/isUtf8MimeType').isUtf8MimeType const mime = require('mime') -const SendStream = require('./lib/SendStream') - -/** - * Return a `SendStream` for `req` and `path`. - * - * @param {object} req - * @param {string} path - * @param {object} [options] - * @return {SendStream} - * @public - */ - -function send (req, path, options) { - return new SendStream(req, path, options) -} +const send = require('./lib/send').send /** * Module exports. @@ -37,7 +23,6 @@ function send (req, path, options) { module.exports = send module.exports.default = send module.exports.send = send -module.exports.SendStream = SendStream module.exports.isUtf8MimeType = isUtf8MimeType module.exports.mime = mime diff --git a/lib/SendStream.js b/lib/SendStream.js deleted file mode 100644 index a88d95b..0000000 --- a/lib/SendStream.js +++ /dev/null @@ -1,905 +0,0 @@ -/*! - * send - * Copyright(c) 2012 TJ Holowaychuk - * Copyright(c) 2014-2022 Douglas Christopher Wilson - * MIT Licensed - */ - -'use strict' - -const fs = require('node:fs') -const path = require('node:path') -const Stream = require('node:stream') -const util = require('node:util') -const debug = require('node:util').debuglog('send') - -const decode = require('fast-decode-uri-component') -const escapeHtml = require('escape-html') -const mime = require('mime') -const ms = require('@lukeed/ms') - -const { clearHeaders } = require('./clearHeaders') -const { collapseLeadingSlashes } = require('./collapseLeadingSlashes') -const { containsDotFile } = require('./containsDotFile') -const { contentRange } = require('./contentRange') -const { createHtmlDocument } = require('./createHtmlDocument') -const { createHttpError } = require('./createHttpError') -const { isUtf8MimeType } = require('./isUtf8MimeType') -const { normalizeList } = require('./normalizeList') -const { parseBytesRange } = require('./parseBytesRange') -const { parseTokenList } = require('./parseTokenList') -const { setHeaders } = require('./setHeaders') - -/** - * Path function references. - * @private - */ - -const extname = path.extname -const join = path.join -const normalize = path.normalize -const resolve = path.resolve -const sep = path.sep - -/** - * Regular expression for identifying a bytes Range header. - * @private - */ - -const BYTES_RANGE_REGEXP = /^ *bytes=/ - -/** - * Maximum value allowed for the max age. - * @private - */ - -const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year - -/** - * Regular expression to match a path with a directory up component. - * @private - */ - -const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ - -const ERROR_RESPONSES = { - 400: createHtmlDocument('Error', 'Bad Request'), - 403: createHtmlDocument('Error', 'Forbidden'), - 404: createHtmlDocument('Error', 'Not Found'), - 412: createHtmlDocument('Error', 'Precondition Failed'), - 416: createHtmlDocument('Error', 'Range Not Satisfiable'), - 500: createHtmlDocument('Error', 'Internal Server Error') -} - -const validDotFilesOptions = [ - 'allow', - 'ignore', - 'deny' -] - -/** - * Initialize a `SendStream` with the given `path`. - * - * @param {Request} req - * @param {String} path - * @param {object} [options] - * @private - */ - -function SendStream (req, path, options) { - if (!new.target) { - return new SendStream(req, path, options) - } - Stream.call(this) - - const opts = options || {} - - this.options = opts - this.path = path - this.req = req - - this._acceptRanges = opts.acceptRanges !== undefined - ? Boolean(opts.acceptRanges) - : true - - this._cacheControl = opts.cacheControl !== undefined - ? Boolean(opts.cacheControl) - : true - - this._etag = opts.etag !== undefined - ? Boolean(opts.etag) - : true - - this._dotfiles = opts.dotfiles !== undefined - ? validDotFilesOptions.indexOf(opts.dotfiles) - : 1 // 'ignore' - - if (this._dotfiles === -1) { - throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') - } - - this._extensions = opts.extensions !== undefined - ? normalizeList(opts.extensions, 'extensions option') - : [] - - this._immutable = opts.immutable !== undefined - ? Boolean(opts.immutable) - : false - - this._index = opts.index !== undefined - ? normalizeList(opts.index, 'index option') - : ['index.html'] - - this._lastModified = opts.lastModified !== undefined - ? Boolean(opts.lastModified) - : true - - this._maxage = opts.maxAge || opts.maxage - this._maxage = typeof this._maxage === 'string' - ? ms.parse(this._maxage) - : Number(this._maxage) - // eslint-disable-next-line no-self-compare - this._maxage = this._maxage === this._maxage // fast path of isNaN(number) - ? Math.min(Math.max(0, this._maxage), MAX_MAXAGE) - : 0 - - this._root = opts.root - ? resolve(opts.root) - : null -} - -/** - * Inherits from `Stream`. - */ - -util.inherits(SendStream, Stream) - -/** - * Set root `path`. - * - * @param {String} path - * @return {SendStream} - * @api private - */ - -SendStream.prototype.root = function root (path) { - this._root = resolve(String(path)) - debug('root %s', this._root) - return this -} - -/** - * Emit error with `status`. - * - * @memberof SendStream - * @param {number} status - * @param {Error} [err] - * @this {Stream} - * @private - */ - -SendStream.prototype.error = function error (status, err) { - // emit if listeners instead of responding - if (this.listenerCount('error') > 0) { - return this.emit('error', createHttpError(status, err)) - } - - const res = this.res - - // clear existing headers - clearHeaders(res) - - // add error headers - if (err && err.headers) { - setHeaders(res, err.headers) - } - - const doc = ERROR_RESPONSES[status] - - // send basic response - res.statusCode = status - res.setHeader('Content-Type', 'text/html; charset=UTF-8') - res.setHeader('Content-Length', doc[1]) - res.setHeader('Content-Security-Policy', "default-src 'none'") - res.setHeader('X-Content-Type-Options', 'nosniff') - res.end(doc[0]) -} - -/** - * Check if the pathname ends with "/". - * - * @return {boolean} - * @private - */ - -SendStream.prototype.hasTrailingSlash = function hasTrailingSlash () { - return this.path[this.path.length - 1] === '/' -} - -/** - * Check if this is a conditional GET request. - * - * @return {Boolean} - * @api private - */ - -SendStream.prototype.isConditionalGET = function isConditionalGET () { - return this.req.headers['if-match'] || - this.req.headers['if-unmodified-since'] || - this.req.headers['if-none-match'] || - this.req.headers['if-modified-since'] -} - -SendStream.prototype.isNotModifiedFailure = function isNotModifiedFailure () { - const req = this.req - const res = this.res - - // Always return stale when Cache-Control: no-cache - // to support end-to-end reload requests - // https://tools.ietf.org/html/rfc2616#section-14.9.4 - if ( - 'cache-control' in req.headers && - req.headers['cache-control'].indexOf('no-cache') !== -1 - ) { - return false - } - - // if-none-match - if ('if-none-match' in req.headers) { - const ifNoneMatch = req.headers['if-none-match'] - - if (ifNoneMatch === '*') { - return true - } - - const etag = res.getHeader('etag') - - if (typeof etag !== 'string') { - return false - } - - const etagL = etag.length - const isMatching = parseTokenList(ifNoneMatch, function (match) { - const mL = match.length - - if ( - (etagL === mL && match === etag) || - (etagL > mL && 'W/' + match === etag) - ) { - return true - } - }) - - if (isMatching) { - return true - } - - /** - * A recipient MUST ignore If-Modified-Since if the request contains an - * If-None-Match header field; the condition in If-None-Match is considered - * to be a more accurate replacement for the condition in If-Modified-Since, - * and the two are only combined for the sake of interoperating with older - * intermediaries that might not implement If-None-Match. - * - * @see RFC 9110 section 13.1.3 - */ - return false - } - - // if-modified-since - if ('if-modified-since' in req.headers) { - const ifModifiedSince = req.headers['if-modified-since'] - const lastModified = res.getHeader('last-modified') - - if (!lastModified || (Date.parse(lastModified) <= Date.parse(ifModifiedSince))) { - return true - } - } - - return false -} - -/** - * Check if the request preconditions failed. - * - * @return {boolean} - * @private - */ - -SendStream.prototype.isPreconditionFailure = function isPreconditionFailure () { - const req = this.req - const res = this.res - - // if-match - const ifMatch = req.headers['if-match'] - if (ifMatch) { - const etag = res.getHeader('ETag') - - if (ifMatch !== '*') { - const isMatching = parseTokenList(ifMatch, function (match) { - if ( - match === etag || - 'W/' + match === etag - ) { - return true - } - }) || false - - if (isMatching !== true) { - return true - } - } - } - - // if-unmodified-since - if ('if-unmodified-since' in req.headers) { - const ifUnmodifiedSince = req.headers['if-unmodified-since'] - const unmodifiedSince = Date.parse(ifUnmodifiedSince) - // eslint-disable-next-line no-self-compare - if (unmodifiedSince === unmodifiedSince) { // fast path of isNaN(number) - const lastModified = Date.parse(res.getHeader('Last-Modified')) - if ( - // eslint-disable-next-line no-self-compare - lastModified !== lastModified ||// fast path of isNaN(number) - lastModified > unmodifiedSince - ) { - return true - } - } - } - - return false -} - -/** - * Strip various content header fields for a change in entity. - * - * @private - */ - -SendStream.prototype.removeContentHeaderFields = function removeContentHeaderFields () { - const res = this.res - - res.removeHeader('Content-Encoding') - res.removeHeader('Content-Language') - res.removeHeader('Content-Length') - res.removeHeader('Content-Range') - res.removeHeader('Content-Type') -} - -/** - * Respond with 304 not modified. - * - * @api private - */ - -SendStream.prototype.notModified = function notModified () { - const res = this.res - debug('not modified') - this.removeContentHeaderFields() - res.statusCode = 304 - res.end() -} - -/** - * Raise error that headers already sent. - * - * @api private - */ - -SendStream.prototype.headersAlreadySent = function headersAlreadySent () { - const err = new Error('Can\'t set headers after they are sent.') - debug('headers already sent') - this.error(500, err) -} - -/** - * Check if the request is cacheable, aka - * responded with 2xx or 304 (see RFC 2616 section 14.2{5,6}). - * - * @return {Boolean} - * @api private - */ - -SendStream.prototype.isCachable = function isCachable () { - const statusCode = this.res.statusCode - return (statusCode >= 200 && statusCode < 300) || - statusCode === 304 -} - -/** - * Handle stat() error. - * - * @param {Error} error - * @private - */ - -SendStream.prototype.onStatError = function onStatError (error) { - // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT - switch (error.code) { - /* c8 ignore start */ - case 'ENAMETOOLONG': - case 'ENOTDIR': - case 'ENOENT': - this.error(404, error) - break - /* c8 ignore stop */ - default: - this.error(500, error) - break - } -} - -/** - * Check if the range is fresh. - * - * @return {Boolean} - * @api private - */ - -SendStream.prototype.isRangeFresh = function isRangeFresh () { - if (!('if-range' in this.req.headers)) { - return true - } - - const ifRange = this.req.headers['if-range'] - - // if-range as etag - if (ifRange.indexOf('"') !== -1) { - const etag = this.res.getHeader('ETag') - return (etag && ifRange.indexOf(etag) !== -1) || false - } - - const ifRangeTimestamp = Date.parse(ifRange) - // eslint-disable-next-line no-self-compare - if (ifRangeTimestamp !== ifRangeTimestamp) { // fast path of isNaN(number) - return false - } - - // if-range as modified date - const lastModified = Date.parse(this.res.getHeader('Last-Modified')) - - return ( - // eslint-disable-next-line no-self-compare - lastModified !== lastModified || // fast path of isNaN(number) - lastModified <= ifRangeTimestamp - ) -} - -/** - * Redirect to path. - * - * @param {string} path - * @private - */ - -SendStream.prototype.redirect = function redirect (path) { - const res = this.res - - if (this.listenerCount('directory') > 0) { - this.emit('directory', res, path) - return - } - - if (this.hasTrailingSlash()) { - this.error(403) - return - } - - const loc = encodeURI(collapseLeadingSlashes(this.path + '/')) - const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + - escapeHtml(loc) + '') - - // redirect - res.statusCode = 301 - res.setHeader('Content-Type', 'text/html; charset=UTF-8') - res.setHeader('Content-Length', doc[1]) - res.setHeader('Content-Security-Policy', "default-src 'none'") - res.setHeader('X-Content-Type-Options', 'nosniff') - res.setHeader('Location', loc) - res.end(doc[0]) -} - -/** - * Pipe to `res. - * - * @param {Stream} res - * @return {Stream} res - * @api public - */ - -SendStream.prototype.pipe = function pipe (res) { - // root path - const root = this._root - - // references - this.res = res - - // decode the path - let path = decode(this.path) - if (path === null) { - this.error(400) - return res - } - - // null byte(s) - if (~path.indexOf('\0')) { - this.error(400) - return res - } - - let parts - if (root !== null) { - // normalize - if (path) { - path = normalize('.' + sep + path) - } - - // malicious path - if (UP_PATH_REGEXP.test(path)) { - debug('malicious path "%s"', path) - this.error(403) - return res - } - - // explode path parts - parts = path.split(sep) - - // join / normalize from optional root dir - path = normalize(join(root, path)) - } else { - // ".." is malicious without "root" - if (UP_PATH_REGEXP.test(path)) { - debug('malicious path "%s"', path) - this.error(403) - return res - } - - // explode path parts - parts = normalize(path).split(sep) - - // resolve the path - path = resolve(path) - } - - // dotfile handling - if ( - ( - debug.enabled || // if debugging is enabled, then check for all cases to log allow case - this._dotfiles !== 0 // if debugging is not enabled, then only check if 'deny' or 'ignore' is set - ) && - containsDotFile(parts) - ) { - switch (this._dotfiles) { - /* c8 ignore start */ /* unreachable, because NODE_DEBUG can not be set after process is running */ - case 0: // 'allow' - debug('allow dotfile "%s"', path) - break - /* c8 ignore stop */ - case 2: // 'deny' - debug('deny dotfile "%s"', path) - this.error(403) - return res - case 1: // 'ignore' - default: - debug('ignore dotfile "%s"', path) - this.error(404) - return res - } - } - - // index file support - if (this._index.length && this.hasTrailingSlash()) { - this.sendIndex(path) - return res - } - - this.sendFile(path) - return res -} - -/** - * Transfer `path`. - * - * @param {String} path - * @api public - */ - -SendStream.prototype.send = function send (path, stat) { - let len = stat.size - const options = this.options - const opts = {} - const res = this.res - const req = this.req - let offset = options.start || 0 - - if (res.headersSent) { - // impossible to send now - this.headersAlreadySent() - return - } - - debug('pipe "%s"', path) - - // set header fields - this.setHeader(path, stat) - - // set content-type - this.type(path) - - // conditional GET support - if (this.isConditionalGET()) { - if (this.isPreconditionFailure()) { - this.error(412) - return - } - - if (this.isCachable() && this.isNotModifiedFailure()) { - this.notModified() - return - } - } - - // adjust len to start/end options - len = Math.max(0, len - offset) - if (options.end !== undefined) { - const bytes = options.end - offset + 1 - if (len > bytes) len = bytes - } - - // Range support - if (this._acceptRanges) { - const rangeHeader = req.headers.range - - if ( - rangeHeader !== undefined && - BYTES_RANGE_REGEXP.test(rangeHeader) - ) { - // If-Range support - if (this.isRangeFresh()) { - // parse - const ranges = parseBytesRange(len, rangeHeader) - - // unsatisfiable - if (ranges.length === 0) { - debug('range unsatisfiable') - - // Content-Range - res.setHeader('Content-Range', contentRange('bytes', len)) - - // 416 Requested Range Not Satisfiable - return this.error(416, { - headers: { 'Content-Range': res.getHeader('Content-Range') } - }) - // valid (syntactically invalid/multiple ranges are treated as a regular response) - } else if (ranges.length === 1) { - debug('range %j', ranges) - - // Content-Range - res.statusCode = 206 - res.setHeader('Content-Range', contentRange('bytes', len, ranges[0])) - - // adjust for requested range - offset += ranges[0].start - len = ranges[0].end - ranges[0].start + 1 - } - } else { - debug('range stale') - } - } - } - - // clone options - for (const prop in options) { - opts[prop] = options[prop] - } - - // set read options - opts.start = offset - opts.end = Math.max(offset, offset + len - 1) - - // content-length - res.setHeader('Content-Length', len) - - // HEAD support - if (req.method === 'HEAD') { - res.end() - return - } - - this.stream(path, opts) -} - -/** - * Transfer file for `path`. - * - * @param {String} path - * @api private - */ -SendStream.prototype.sendFile = function sendFile (path) { - let i = 0 - const self = this - - debug('stat "%s"', path) - fs.stat(path, function onstat (err, stat) { - if (err && err.code === 'ENOENT' && !extname(path) && path[path.length - 1] !== sep) { - // not found, check extensions - return next(err) - } - if (err) return self.onStatError(err) - if (stat.isDirectory()) return self.redirect(path) - self.emit('file', path, stat) - self.send(path, stat) - }) - - function next (err) { - if (self._extensions.length <= i) { - return err - ? self.onStatError(err) - : self.error(404) - } - - const p = path + '.' + self._extensions[i++] - - debug('stat "%s"', p) - fs.stat(p, function (err, stat) { - if (err) return next(err) - if (stat.isDirectory()) return next() - self.emit('file', p, stat) - self.send(p, stat) - }) - } -} - -/** - * Transfer index for `path`. - * - * @param {String} path - * @api private - */ -SendStream.prototype.sendIndex = function sendIndex (path) { - let i = -1 - const self = this - - function next (err) { - if (++i >= self._index.length) { - if (err) return self.onStatError(err) - return self.error(404) - } - - const p = join(path, self._index[i]) - - debug('stat "%s"', p) - fs.stat(p, function (err, stat) { - if (err) return next(err) - if (stat.isDirectory()) return next() - self.emit('file', p, stat) - self.send(p, stat) - }) - } - - next() -} - -/** - * Stream `path` to the response. - * - * @param {String} path - * @param {Object} options - * @api private - */ - -SendStream.prototype.stream = function stream (path, options) { - const self = this - const res = this.res - - // pipe - const stream = fs.createReadStream(path, options) - this.emit('stream', stream) - stream.pipe(res) - - let destroyed = false - - // destroy piped stream - function destroy () { - if (destroyed) { - return - } - destroyed = true - stream.destroy() - } - - res.once('finish', destroy) - - // error handling - stream.on('error', function onerror (err) { - // clean up stream early - destroy() - - // error - self.onStatError(err) - }) - - // end - stream.on('end', function onend () { - self.emit('end') - }) -} - -/** - * Set content-type based on `path` - * if it hasn't been explicitly set. - * - * @param {String} path - * @api private - */ - -SendStream.prototype.type = function type (path) { - const res = this.res - - if (res.getHeader('Content-Type')) return - - const type = mime.getType(path) || mime.default_type - - if (!type) { - debug('no content-type') - return - } - - debug('content-type %s', type) - if (isUtf8MimeType(type)) { - res.setHeader('Content-Type', type + '; charset=UTF-8') - } else { - res.setHeader('Content-Type', type) - } -} - -/** - * Set response header fields, most - * fields may be pre-defined. - * - * @param {String} path - * @param {Object} stat - * @api private - */ - -SendStream.prototype.setHeader = function setHeader (path, stat) { - const res = this.res - - this.emit('headers', res, path, stat) - - if (this._acceptRanges && !res.getHeader('Accept-Ranges')) { - debug('accept ranges') - res.setHeader('Accept-Ranges', 'bytes') - } - - if (this._cacheControl && !res.getHeader('Cache-Control')) { - let cacheControl = 'public, max-age=' + Math.floor(this._maxage / 1000) - - if (this._immutable) { - cacheControl += ', immutable' - } - - debug('cache-control %s', cacheControl) - res.setHeader('Cache-Control', cacheControl) - } - - if (this._lastModified && !res.getHeader('Last-Modified')) { - const modified = stat.mtime.toUTCString() - debug('modified %s', modified) - res.setHeader('Last-Modified', modified) - } - - if (this._etag && !res.getHeader('ETag')) { - const etag = 'W/"' + stat.size.toString(16) + '-' + stat.mtime.getTime().toString(16) + '"' - debug('etag %s', etag) - res.setHeader('ETag', etag) - } -} - -/** - * Module exports. - * @public - */ - -module.exports = SendStream diff --git a/lib/clearHeaders.js b/lib/clearHeaders.js deleted file mode 100644 index 6d9ac92..0000000 --- a/lib/clearHeaders.js +++ /dev/null @@ -1,21 +0,0 @@ -/*! - * send - * Copyright(c) 2012 TJ Holowaychuk - * Copyright(c) 2014-2022 Douglas Christopher Wilson - * MIT Licensed - */ -'use strict' -/** - * Clear all headers from a response. - * - * @param {object} res - * @private - */ -function clearHeaders (res) { - const headers = res.getHeaderNames() - - for (let i = 0; i < headers.length; i++) { - res.removeHeader(headers[i]) - } -} -exports.clearHeaders = clearHeaders diff --git a/lib/createHttpError.js b/lib/createHttpError.js deleted file mode 100644 index ba7bcca..0000000 --- a/lib/createHttpError.js +++ /dev/null @@ -1,23 +0,0 @@ -'use strict' - -const createError = require('http-errors') - -/** - * Create a HttpError object from simple arguments. - * - * @param {number} status - * @param {Error|object} err - * @private - */ - -function createHttpError (status, err) { - if (!err) { - return createError(status) - } - - return err instanceof Error - ? createError(status, err, { expose: false }) - : createError(status, err) -} - -module.exports.createHttpError = createHttpError diff --git a/lib/send.js b/lib/send.js new file mode 100644 index 0000000..4cded22 --- /dev/null +++ b/lib/send.js @@ -0,0 +1,683 @@ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const stream = require('node:stream') +const debug = require('node:util').debuglog('send') + +const decode = require('fast-decode-uri-component') +const escapeHtml = require('escape-html') +const mime = require('mime') +const ms = require('@lukeed/ms') + +const { collapseLeadingSlashes } = require('./collapseLeadingSlashes') +const { containsDotFile } = require('../lib/containsDotFile') +const { contentRange } = require('../lib/contentRange') +const { createHtmlDocument } = require('../lib/createHtmlDocument') +const { isUtf8MimeType } = require('../lib/isUtf8MimeType') +const { normalizeList } = require('../lib/normalizeList') +const { parseBytesRange } = require('../lib/parseBytesRange') +const { parseTokenList } = require('./parseTokenList') + +/** + * Path function references. + * @private + */ + +const extname = path.extname +const join = path.join +const normalize = path.normalize +const resolve = path.resolve +const sep = path.sep + +/** + * Stream function references. + * @private + */ +const Readable = stream.Readable + +/** + * Regular expression for identifying a bytes Range header. + * @private + */ + +const BYTES_RANGE_REGEXP = /^ *bytes=/ + +/** + * Maximum value allowed for the max age. + * @private + */ + +const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year + +/** + * Regular expression to match a path with a directory up component. + * @private + */ + +const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ + +const ERROR_RESPONSES = { + 400: createHtmlDocument('Error', 'Bad Request'), + 403: createHtmlDocument('Error', 'Forbidden'), + 404: createHtmlDocument('Error', 'Not Found'), + 412: createHtmlDocument('Error', 'Precondition Failed'), + 416: createHtmlDocument('Error', 'Range Not Satisfiable'), + 500: createHtmlDocument('Error', 'Internal Server Error') +} + +const validDotFilesOptions = [ + 'allow', + 'ignore', + 'deny' +] + +function normalizeMaxAge (_maxage) { + let maxage + if (typeof _maxage === 'string') { + maxage = ms.parse(_maxage) + } else { + maxage = Number(_maxage) + } + + // eslint-disable-next-line no-self-compare + if (maxage !== maxage) { + // fast path of isNaN(number) + return 0 + } + + return Math.min(Math.max(0, maxage), MAX_MAXAGE) +} + +function normalizeOptions (options) { + options = options ?? {} + + const acceptRanges = options.acceptRanges !== undefined + ? Boolean(options.acceptRanges) + : true + + const cacheControl = options.cacheControl !== undefined + ? Boolean(options.cacheControl) + : true + + const etag = options.etag !== undefined + ? Boolean(options.etag) + : true + + const dotfiles = options.dotfiles !== undefined + ? validDotFilesOptions.indexOf(options.dotfiles) + : 1 // 'ignore' + if (dotfiles === -1) { + throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') + } + + const extensions = options.extensions !== undefined + ? normalizeList(options.extensions, 'extensions option') + : [] + + const immutable = options.immutable !== undefined + ? Boolean(options.immutable) + : false + + const index = options.index !== undefined + ? normalizeList(options.index, 'index option') + : ['index.html'] + + const lastModified = options.lastModified !== undefined + ? Boolean(options.lastModified) + : true + + const maxage = normalizeMaxAge(options.maxAge ?? options.maxage) + + const root = options.root + ? resolve(options.root) + : null + + return { + acceptRanges, + cacheControl, + etag, + dotfiles, + extensions, + immutable, + index, + lastModified, + maxage, + root, + start: options.start, + end: options.end + } +} + +function normalizePath (_path, root) { + // decode the path + let path = decode(_path) + if (path == null) { + return { statusCode: 400 } + } + + // null byte(s) + if (~path.indexOf('\0')) { + return { statusCode: 400 } + } + + let parts + if (root !== null) { + // normalize + if (path) { + path = normalize('.' + sep + path) + } + + // malicious path + if (UP_PATH_REGEXP.test(path)) { + debug('malicious path "%s"', path) + return { statusCode: 403 } + } + + // explode path parts + parts = path.split(sep) + + // join / normalize from optional root dir + path = normalize(join(root, path)) + } else { + // ".." is malicious without "root" + if (UP_PATH_REGEXP.test(path)) { + debug('malicious path "%s"', path) + return { statusCode: 403 } + } + + // explode path parts + parts = normalize(path).split(sep) + + // resolve the path + path = resolve(path) + } + + return { path, parts } +} + +/** + * Check if the pathname ends with "/". + * + * @return {boolean} + * @private + */ + +function hasTrailingSlash (path) { + return path[path.length - 1] === '/' +} + +/** + * Check if this is a conditional GET request. + * + * @return {Boolean} + * @api private + */ + +function isConditionalGET (request) { + return request.headers['if-match'] || + request.headers['if-unmodified-since'] || + request.headers['if-none-match'] || + request.headers['if-modified-since'] +} + +function isNotModifiedFailure (request, headers) { + // Always return stale when Cache-Control: no-cache + // to support end-to-end reload requests + // https://tools.ietf.org/html/rfc2616#section-14.9.4 + if ( + 'cache-control' in request.headers && + request.headers['cache-control'].indexOf('no-cache') !== -1 + ) { + return false + } + + // if-none-match + if ('if-none-match' in request.headers) { + const ifNoneMatch = request.headers['if-none-match'] + + if (ifNoneMatch === '*') { + return true + } + + const etag = headers.ETag + + if (typeof etag !== 'string') { + return false + } + + const etagL = etag.length + const isMatching = parseTokenList(ifNoneMatch, function (match) { + const mL = match.length + + if ( + (etagL === mL && match === etag) || + (etagL > mL && 'W/' + match === etag) + ) { + return true + } + }) + + if (isMatching) { + return true + } + + /** + * A recipient MUST ignore If-Modified-Since if the request contains an + * If-None-Match header field; the condition in If-None-Match is considered + * to be a more accurate replacement for the condition in If-Modified-Since, + * and the two are only combined for the sake of interoperating with older + * intermediaries that might not implement If-None-Match. + * + * @see RFC 9110 section 13.1.3 + */ + return false + } + + // if-modified-since + if ('if-modified-since' in request.headers) { + const ifModifiedSince = request.headers['if-modified-since'] + const lastModified = headers['Last-Modified'] + + if (!lastModified || (Date.parse(lastModified) <= Date.parse(ifModifiedSince))) { + return true + } + } + + return false +} + +/** + * Check if the request preconditions failed. + * + * @return {boolean} + * @private + */ + +function isPreconditionFailure (request, headers) { + // if-match + const ifMatch = request.headers['if-match'] + if (ifMatch) { + const etag = headers.ETag + + if (ifMatch !== '*') { + const isMatching = parseTokenList(ifMatch, function (match) { + if ( + match === etag || + 'W/' + match === etag + ) { + return true + } + }) || false + + if (isMatching !== true) { + return true + } + } + } + + // if-unmodified-since + if ('if-unmodified-since' in request.headers) { + const ifUnmodifiedSince = request.headers['if-unmodified-since'] + const unmodifiedSince = Date.parse(ifUnmodifiedSince) + // eslint-disable-next-line no-self-compare + if (unmodifiedSince === unmodifiedSince) { // fast path of isNaN(number) + const lastModified = Date.parse(headers['Last-Modified']) + if ( + // eslint-disable-next-line no-self-compare + lastModified !== lastModified ||// fast path of isNaN(number) + lastModified > unmodifiedSince + ) { + return true + } + } + } + + return false +} + +/** + * Check if the range is fresh. + * + * @return {Boolean} + * @api private + */ + +function isRangeFresh (request, headers) { + if (!('if-range' in request.headers)) { + return true + } + + const ifRange = request.headers['if-range'] + + // if-range as etag + if (ifRange.indexOf('"') !== -1) { + const etag = headers.ETag + return (etag && ifRange.indexOf(etag) !== -1) || false + } + + const ifRangeTimestamp = Date.parse(ifRange) + // eslint-disable-next-line no-self-compare + if (ifRangeTimestamp !== ifRangeTimestamp) { // fast path of isNaN(number) + return false + } + + // if-range as modified date + const lastModified = Date.parse(headers['Last-Modified']) + + return ( + // eslint-disable-next-line no-self-compare + lastModified !== lastModified || // fast path of isNaN(number) + lastModified <= ifRangeTimestamp + ) +} + +// we provide stat function that will always resolve +// without throwing +function tryStat (path) { + return new Promise((resolve) => { + fs.stat(path, function onstat (error, stat) { + resolve({ error, stat }) + }) + }) +} + +function sendError (statusCode, err) { + const headers = {} + + // add error headers + if (err && err.headers) { + for (const headerName in err.headers) { + headers[headerName] = err.headers[headerName] + } + } + + const doc = ERROR_RESPONSES[statusCode] + + // basic response + headers['Content-Type'] = 'text/html; charset=UTF-8' + headers['Content-Length'] = doc[1] + headers['Content-Security-Policy'] = "default-src 'none'" + headers['X-Content-Type-Options'] = 'nosniff' + + return { + statusCode, + headers, + stream: Readable.from(doc[0]) + } +} + +function sendStatError (err) { + // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT + /* istanbul ignore next */ + switch (err.code) { + case 'ENAMETOOLONG': + case 'ENOTDIR': + case 'ENOENT': + return sendError(404, err) + default: + return sendError(500, err) + } +} + +/** + * Respond with 304 not modified. + * + * @api private + */ + +function sendNotModified (headers) { + debug('not modified') + + delete headers['Content-Encoding'] + delete headers['Content-Language'] + delete headers['Content-Length'] + delete headers['Content-Range'] + delete headers['Content-Type'] + + return { + statusCode: 304, + headers, + stream: Readable.from('') + } +} + +function sendFileDirectly (request, path, stat, options) { + let len = stat.size + let offset = options.start ?? 0 + + let statusCode = 200 + const headers = {} + + debug('send "%s"', path) + + // set header fields + if (options.acceptRanges) { + debug('accept ranges') + headers['Accept-Ranges'] = 'bytes' + } + + if (options.cacheControl) { + let cacheControl = 'public, max-age=' + Math.floor(options.maxage / 1000) + + if (options.immutable) { + cacheControl += ', immutable' + } + + debug('cache-control %s', cacheControl) + headers['Cache-Control'] = cacheControl + } + + if (options.lastModified) { + const modified = stat.mtime.toUTCString() + debug('modified %s', modified) + headers['Last-Modified'] = modified + } + + if (options.etag) { + const etag = 'W/"' + stat.size.toString(16) + '-' + stat.mtime.getTime().toString(16) + '"' + debug('etag %s', etag) + headers.ETag = etag + } + + // set content-type + let type = mime.getType(path) || mime.default_type + debug('content-type %s', type) + if (type && isUtf8MimeType(type)) { + type += '; charset=UTF-8' + } + if (type) { + headers['Content-Type'] = type + } + + // conditional GET support + if (isConditionalGET(request)) { + if (isPreconditionFailure(request, headers)) { + return sendError(412) + } + + if (isNotModifiedFailure(request, headers)) { + return sendNotModified(headers) + } + } + + // adjust len to start/end options + len = Math.max(0, len - offset) + if (options.end !== undefined) { + const bytes = options.end - offset + 1 + if (len > bytes) len = bytes + } + + // Range support + if (options.acceptRanges) { + const rangeHeader = request.headers.range + + if ( + rangeHeader !== undefined && + BYTES_RANGE_REGEXP.test(rangeHeader) + ) { + // If-Range support + if (isRangeFresh(request, headers)) { + // parse + const ranges = parseBytesRange(len, rangeHeader) + + // unsatisfiable + if (ranges.length === 0) { + debug('range unsatisfiable') + + // Content-Range + headers['Content-Range'] = contentRange('bytes', len) + + // 416 Requested Range Not Satisfiable + return sendError(416, { + headers: { 'Content-Range': headers['Content-Range'] } + }) + // valid (syntactically invalid/multiple ranges are treated as a regular response) + } else if (ranges.length === 1) { + debug('range %j', ranges) + + // Content-Range + statusCode = 206 + headers['Content-Range'] = contentRange('bytes', len, ranges[0]) + + // adjust for requested range + offset += ranges[0].start + len = ranges[0].end - ranges[0].start + 1 + } + } else { + debug('range stale') + } + } + } + + // content-length + headers['Content-Length'] = len + + // HEAD support + if (request.method === 'HEAD') { + return { statusCode, headers, stream: Readable.from('') } + } + + const stream = fs.createReadStream(path, { + start: offset, + end: Math.max(offset, offset + len - 1) + }) + + return { statusCode, headers, stream } +} + +function sendRedirect (path) { + if (hasTrailingSlash(path)) { + return sendError(403) + } + + const loc = encodeURI(collapseLeadingSlashes(path + '/')) + const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + + escapeHtml(loc) + '') + + const headers = {} + headers['Content-Type'] = 'text/html; charset=UTF-8' + headers['Content-Length'] = doc[1] + headers['Content-Security-Policy'] = "default-src 'none'" + headers['X-Content-Type-Options'] = 'nosniff' + headers.Location = loc + + return { + statusCode: 301, + headers, + stream: Readable.from(doc[0]) + } +} + +async function sendIndex (request, path, options) { + let err + for (let i = 0; i < options.index.length; i++) { + const index = options.index[i] + const p = join(path, index) + const { error, stat } = await tryStat(p) + if (error) { + err = error + continue + } + if (stat.isDirectory()) continue + return sendFileDirectly(request, p, stat, options) + } + + if (err) { + return sendStatError(err) + } + + return sendError(404) +} + +async function sendFile (request, path, options) { + const { error, stat } = await tryStat(path) + if (error && error.code === 'ENOENT' && !extname(path) && path[path.length - 1] !== sep) { + let err = error + // not found, check extensions + for (let i = 0; i < options.extensions.length; i++) { + const extension = options.extensions[i] + const p = path + '.' + extension + const { error, stat } = await tryStat(p) + if (error) { + err = error + continue + } + if (stat.isDirectory()) { + err = null + continue + } + return sendFileDirectly(request, p, stat, options) + } + if (err) { + return sendStatError(err) + } + return sendError(404) + } + if (error) return sendStatError(error) + if (stat.isDirectory()) return sendRedirect(options.path) + return sendFileDirectly(request, path, stat, options) +} + +async function send (request, _path, options) { + const opts = normalizeOptions(options) + opts.path = _path + + const parsed = normalizePath(_path, opts.root) + const { path, parts } = parsed + if (parsed.statusCode !== undefined) { + return sendError(parsed.statusCode) + } + + // dotfile handling + if ( + ( + debug.enabled || // if debugging is enabled, then check for all cases to log allow case + opts.dotfiles !== 0 // if debugging is not enabled, then only check if 'deny' or 'ignore' is set + ) && + containsDotFile(parts) + ) { + switch (opts.dotfiles) { + /* istanbul ignore next: unreachable, because NODE_DEBUG can not be set after process is running */ + case 0: // 'allow' + debug('allow dotfile "%s"', path) + break + case 2: // 'deny' + debug('deny dotfile "%s"', path) + return sendError(403) + case 1: // 'ignore' + default: + debug('ignore dotfile "%s"', path) + return sendError(404) + } + } + + // index file support + if (opts.index.length && hasTrailingSlash(_path)) { + return sendIndex(request, path, opts) + } + + return sendFile(request, path, opts) +} + +module.exports.send = send diff --git a/lib/setHeaders.js b/lib/setHeaders.js deleted file mode 100644 index 4ddd6dc..0000000 --- a/lib/setHeaders.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict' - -/** - * Set an object of headers on a response. - * - * @param {object} res - * @param {object} headers - * @private - */ - -function setHeaders (res, headers) { - const keys = Object.keys(headers) - - for (let i = 0; i < keys.length; i++) { - const key = keys[i] - res.setHeader(key, headers[key]) - } -} - -module.exports.setHeaders = setHeaders diff --git a/test/SendStream-pipe.test.js b/test/SendStream-pipe.test.js deleted file mode 100644 index 0c58fa9..0000000 --- a/test/SendStream-pipe.test.js +++ /dev/null @@ -1,1663 +0,0 @@ -'use strict' - -const { test } = require('tap') -const after = require('after') -const http = require('node:http') -const path = require('node:path') -const request = require('supertest') -const SendStream = require('../lib/SendStream') -const os = require('node:os') -const { shouldNotHaveBody, createServer, shouldNotHaveHeader } = require('./utils') - -const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ -const fixtures = path.join(__dirname, 'fixtures') - -test('send(file).pipe(res)', function (t) { - t.plan(29) - - t.test('should stream the file contents', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Content-Length', '4') - .expect(200, 'tobi', err => t.error(err)) - }) - - t.test('should stream a zero-length file', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/empty.txt') - .expect('Content-Length', '0') - .expect(200, '', err => t.error(err)) - }) - - t.test('should decode the given path as a URI', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/some%20thing.txt') - .expect(200, 'hey', err => t.error(err)) - }) - - t.test('should serve files with dots in name', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/do..ts.txt') - .expect(200, '...', err => t.error(err)) - }) - - t.test('should treat a malformed URI as a bad request', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/some%99thing.txt') - .expect(400, 'Bad Request', err => t.error(err)) - }) - - t.test('should 400 on NULL bytes', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/some%00thing.txt') - .expect(400, 'Bad Request', err => t.error(err)) - }) - - t.test('should treat an ENAMETOOLONG as a 404', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - const path = Array(100).join('foobar') - request(app) - .get('/' + path) - .expect(404, err => t.error(err)) - }) - - t.test('should handle headers already sent error', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - res.write('0') - new SendStream(req, req.url, { root: fixtures }) - .on('error', function (err) { res.end(' - ' + err.message) }) - .pipe(res) - }) - request(app) - .get('/name.txt') - .expect(200, '0 - Can\'t set headers after they are sent.', err => t.error(err)) - }) - - t.test('should support HEAD', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .head('/name.txt') - .expect(200) - .expect('Content-Length', '4') - .expect(shouldNotHaveBody(t)) - .end(err => t.error(err)) - }) - - t.test('should add an ETag header field', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('etag', /^W\/"[^"]+"$/) - .end(err => t.error(err)) - }) - - t.test('should add a Date header field', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('date', dateRegExp, err => t.error(err)) - }) - - t.test('should add a Last-Modified header field', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('last-modified', dateRegExp, err => t.error(err)) - }) - - t.test('should add a Accept-Ranges header field', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Accept-Ranges', 'bytes', err => t.error(err)) - }) - - t.test('should 404 if the file does not exist', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/meow') - .expect(404, 'Not Found', err => t.error(err)) - }) - - t.test('should emit ENOENT if the file does not exist', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) - .pipe(res) - }) - - request(app) - .get('/meow') - .expect(200, '404 ENOENT', err => t.error(err)) - }) - - t.test('should emit ENAMETOOLONG if the filename is too long', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) - .pipe(res) - }) - - const longFilename = new Array(512).fill('a').join('') - - request(app) - .get('/' + longFilename) - .expect(200, os.platform() === 'win32' ? '404 ENOENT' : '404 ENAMETOOLONG', err => t.error(err)) - }) - - t.test('should emit ENOTDIR if the requested resource is not a directory', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) - .pipe(res) - }) - - request(app) - .get('/nums.txt/invalid') - .expect(200, os.platform() === 'win32' ? '404 ENOENT' : '404 ENOTDIR', err => t.error(err)) - }) - - t.test('should not override content-type', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - res.setHeader('Content-Type', 'application/x-custom') - new SendStream(req, req.url, { root: fixtures }).pipe(res) - }) - request(app) - .get('/name.txt') - .expect('Content-Type', 'application/x-custom', err => t.error(err)) - }) - - t.test('should set Content-Type via mime map', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect('Content-Type', 'text/plain; charset=UTF-8') - .expect(200, function (err) { - t.error(err) - request(app) - .get('/tobi.html') - .expect('Content-Type', 'text/html; charset=UTF-8') - .expect(200, err => t.error(err)) - }) - }) - - t.test('should 404 if file disappears after stat, before open', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: 'test/fixtures' }) - .on('file', function () { - // simulate file ENOENT after on open, after stat - const fn = this.send - this.send = function (path, stat) { - fn.call(this, (path + '__xxx_no_exist'), stat) - } - }) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(404, err => t.error(err)) - }) - - t.test('should 500 on file stream error', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: 'test/fixtures' }) - .on('stream', function (stream) { - // simulate file error - stream.on('open', function () { - stream.emit('error', new Error('boom!')) - }) - }) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(500, err => t.error(err)) - }) - - t.test('"headers" event', function (t) { - t.plan(7) - t.test('should fire when sending file', function (t) { - t.plan(1) - const cb = after(2, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/name.txt') - .expect(200, 'tobi', cb) - }) - - t.test('should not fire on 404', function (t) { - t.plan(1) - const cb = after(1, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/bogus') - .expect(404, cb) - }) - - t.test('should fire on index', function (t) { - t.plan(1) - const cb = after(2, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/pets/') - .expect(200, /tobi/, cb) - }) - - t.test('should not fire on redirect', function (t) { - t.plan(1) - const cb = after(1, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', function () { cb() }) - .pipe(res) - }) - - request(server) - .get('/pets') - .expect(301, cb) - }) - - t.test('should provide path', function (t) { - t.plan(3) - const cb = after(2, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', onHeaders) - .pipe(res) - }) - - function onHeaders (res, filePath) { - t.ok(filePath) - t.strictSame(path.normalize(filePath), path.normalize(path.join(fixtures, 'name.txt'))) - cb() - } - - request(server) - .get('/name.txt') - .expect(200, 'tobi', cb) - }) - - t.test('should provide stat', function (t) { - t.plan(4) - const cb = after(2, err => t.error(err)) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', onHeaders) - .pipe(res) - }) - - function onHeaders (res, path, stat) { - t.ok(stat) - t.ok('ctime' in stat) - t.ok('mtime' in stat) - cb() - } - - request(server) - .get('/name.txt') - .expect(200, 'tobi', cb) - }) - - t.test('should allow altering headers', function (t) { - t.plan(1) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('headers', onHeaders) - .pipe(res) - }) - - function onHeaders (res, path, stat) { - res.setHeader('Cache-Control', 'no-cache') - res.setHeader('Content-Type', 'text/x-custom') - res.setHeader('ETag', 'W/"everything"') - res.setHeader('X-Created', stat.ctime.toUTCString()) - } - - request(server) - .get('/name.txt') - .expect(200) - .expect('Cache-Control', 'no-cache') - .expect('Content-Type', 'text/x-custom') - .expect('ETag', 'W/"everything"') - .expect('X-Created', dateRegExp) - .expect('tobi') - .end(err => t.error(err)) - }) - }) - - t.test('when "directory" listeners are present', function (t) { - t.plan(2) - - t.test('should be called when sending directory', function (t) { - t.plan(1) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('directory', onDirectory) - .pipe(res) - }) - - function onDirectory (res) { - res.statusCode = 400 - res.end('No directory for you') - } - - request(server) - .get('/pets') - .expect(400, 'No directory for you', err => t.error(err)) - }) - - t.test('should be called with path', function (t) { - t.plan(1) - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('directory', onDirectory) - .pipe(res) - }) - - function onDirectory (res, dirPath) { - res.end(path.normalize(dirPath)) - } - - request(server) - .get('/pets') - .expect(200, path.normalize(path.join(fixtures, 'pets')), err => t.error(err)) - }) - }) - - t.test('when no "directory" listeners are present', function (t) { - t.plan(5) - - t.test('should redirect directories to trailing slash', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/pets') - .expect('Location', '/pets/') - .expect(301, err => t.error(err)) - }) - - t.test('should respond with an HTML redirect', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/pets') - .expect('Location', '/pets/') - .expect('Content-Type', /html/) - .expect(301, />Redirecting to \/pets\/<\/a> t.error(err)) - }) - - t.test('should respond with default Content-Security-Policy', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/pets') - .expect('Location', '/pets/') - .expect('Content-Security-Policy', "default-src 'none'") - .expect(301, err => t.error(err)) - }) - - t.test('should not redirect to protocol-relative locations', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('//pets') - .expect('Location', '/pets/') - .expect(301, err => t.error(err)) - }) - - t.test('should respond with an HTML redirect', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url.replace('/snow', '/snow ☃'), { root: 'test/fixtures' }) - .pipe(res) - }) - - request(app) - .get('/snow') - .expect('Location', '/snow%20%E2%98%83/') - .expect('Content-Type', /html/) - .expect(301, />Redirecting to \/snow%20%E2%98%83\/<\/a> t.error(err)) - }) - }) - - t.test('when no "error" listeners are present', function (t) { - t.plan(3) - - t.test('should respond to errors directly', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/foobar') - .expect(404, />Not Found t.error(err)) - }) - - t.test('should respond with default Content-Security-Policy', function (t) { - t.plan(1) - - request(createServer({ root: fixtures })) - .get('/foobar') - .expect('Content-Security-Policy', "default-src 'none'") - .expect(404, err => t.error(err)) - }) - - t.test('should remove all previously-set headers', function (t) { - t.plan(2) - - const server = createServer({ root: fixtures }, function (req, res) { - res.setHeader('X-Foo', 'bar') - }) - - request(server) - .get('/foobar') - .expect(shouldNotHaveHeader('X-Foo', t)) - .expect(404, err => t.error(err)) - }) - }) - - t.test('with conditional-GET', function (t) { - t.plan(7) - - t.test('should remove Content headers with 304', function (t) { - t.plan(5) - - const server = createServer({ root: fixtures }, function (req, res) { - res.setHeader('Content-Language', 'en-US') - res.setHeader('Content-Location', 'http://localhost/name.txt') - res.setHeader('Contents', 'foo') - }) - - request(server) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(server) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(shouldNotHaveHeader('Content-Language', t)) - .expect(shouldNotHaveHeader('Content-Length', t)) - .expect(shouldNotHaveHeader('Content-Type', t)) - .expect('Content-Location', 'http://localhost/name.txt') - .expect('Contents', 'foo') - .expect(304, err => t.error(err)) - }) - }) - - t.test('should remove Content headers with 304 /2', function (t) { - t.plan(5) - - const server = createServer({ root: fixtures }, function (req, res) { - res.setHeader('Content-Language', 'en-US') - res.setHeader('Content-Location', 'http://localhost/name.txt') - res.setHeader('Contents', 'foo') - res.statusCode = 304 - }) - - request(server) - .get('/name.txt') - .expect(304, function (err, res) { - t.error(err) - request(server) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(shouldNotHaveHeader('Content-Language', t)) - .expect(shouldNotHaveHeader('Content-Length', t)) - .expect(shouldNotHaveHeader('Content-Type', t)) - .expect('Content-Location', 'http://localhost/name.txt') - .expect('Contents', 'foo') - .expect(304, err => t.error(err)) - }) - }) - - t.test('should not remove all Content-* headers', function (t) { - t.plan(4) - - const server = createServer({ root: fixtures }, function (req, res) { - res.setHeader('Content-Location', 'http://localhost/name.txt') - res.setHeader('Content-Security-Policy', 'default-src \'self\'') - }) - - request(server) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(server) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(shouldNotHaveHeader('Content-Length', t)) - .expect(shouldNotHaveHeader('Content-Type', t)) - .expect('Content-Location', 'http://localhost/name.txt') - .expect('Content-Security-Policy', 'default-src \'self\'') - .expect(304, err => t.error(err)) - }) - }) - - t.test('where "If-Match" is set', function (t) { - t.plan(4) - - t.test('should respond with 200 when "*"', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .set('If-Match', '*') - .expect(200, err => t.error(err)) - }) - - t.test('should respond with 412 when ETag unmatched', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .set('If-Match', ' "foo",, "bar" ,') - .expect(412, err => t.error(err)) - }) - - t.test('should respond with 200 when ETag matched /1', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Match', '"foo", "bar", ' + res.headers.etag) - .expect(200, err => t.error(err)) - }) - }) - - t.test('should respond with 200 when ETag matched /2', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Match', '"foo", ' + res.headers.etag + ', "bar"') - .expect(200, err => t.error(err)) - }) - }) - }) - - t.test('where "If-Modified-Since" is set', function (t) { - t.plan(3) - - t.test('should respond with 304 when unmodified', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Modified-Since', res.headers['last-modified']) - .expect(304, err => t.error(err)) - }) - }) - - t.test('should respond with 200 when modified', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - const lmod = new Date(res.headers['last-modified']) - const date = new Date(lmod - 60000) - request(app) - .get('/name.txt') - .set('If-Modified-Since', date.toUTCString()) - .expect(200, 'tobi', err => t.error(err)) - }) - }) - - t.test('should respond with 200 when modified', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Modified-Since', res.headers['last-modified']) - .set('cache-control', 'no-cache') - .expect(200, 'tobi', err => t.error(err)) - }) - }) - }) - - t.test('where "If-None-Match" is set', function (t) { - t.plan(6) - - t.test('should respond with 304 when ETag matched', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .expect(304, err => t.error(err)) - }) - }) - - t.test('should respond with 200 when ETag unmatched', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', '"123"') - .expect(200, 'tobi', err => t.error(err)) - }) - }) - - t.test('should respond with 200 when ETag is not generated', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { etag: false, root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', '"123"') - .expect(200, 'tobi', err => t.error(err)) - }) - }) - - t.test('should respond with 306 Not Modified when using wildcard * on existing file', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { etag: false, root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', '*') - .expect(304, '', err => t.error(err)) - }) - }) - - t.test('should respond with 404 Not Found when using wildcard * on non-existing file', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { etag: false, root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/asdf.txt') - .set('If-None-Match', '*') - .expect(404, 'Not Found', err => t.error(err)) - }) - - t.test('should respond with 200 cache-control is set to no-cache', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-None-Match', res.headers.etag) - .set('cache-control', 'no-cache') - .expect(200, 'tobi', err => t.error(err)) - }) - }) - }) - - t.test('where "If-Unmodified-Since" is set', function (t) { - t.plan(3) - - t.test('should respond with 200 when unmodified', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', res.headers['last-modified']) - .expect(200, err => t.error(err)) - }) - }) - - t.test('should respond with 412 when modified', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .expect(200, function (err, res) { - t.error(err) - const lmod = new Date(res.headers['last-modified']) - const date = new Date(lmod - 60000).toUTCString() - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', date) - .expect(412, err => t.error(err)) - }) - }) - - t.test('should respond with 200 when invalid date', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/name.txt') - .set('If-Unmodified-Since', 'foo') - .expect(200, err => t.error(err)) - }) - }) - }) - - t.test('with Range request', function (t) { - t.plan(13) - - t.test('should support byte ranges', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=0-4') - .expect(206, '12345', err => t.error(err)) - }) - - t.test('should ignore non-byte ranges', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'items=0-4') - .expect(200, '123456789', err => t.error(err)) - }) - - t.test('should be inclusive', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=0-0') - .expect(206, '1', err => t.error(err)) - }) - - t.test('should set Content-Range', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-5') - .expect('Content-Range', 'bytes 2-5/9') - .expect(206, err => t.error(err)) - }) - - t.test('should support -n', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=-3') - .expect(206, '789', err => t.error(err)) - }) - - t.test('should support n-', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=3-') - .expect(206, '456789', err => t.error(err)) - }) - - t.test('should respond with 206 "Partial Content"', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=0-4') - .expect(206, err => t.error(err)) - }) - - t.test('should set Content-Length to the # of octets transferred', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-3') - .expect('Content-Length', '2') - .expect(206, '34', err => t.error(err)) - }) - - t.test('when last-byte-pos of the range is greater the length', function (t) { - t.plan(2) - - t.test('is taken to be equal to one less than the length', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-50') - .expect('Content-Range', 'bytes 2-8/9') - .expect(206, err => t.error(err)) - }) - - t.test('should adapt the Content-Length accordingly', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=2-50') - .expect('Content-Length', '7') - .expect(206, err => t.error(err)) - }) - }) - - t.test('when the first- byte-pos of the range is greater length', function (t) { - t.plan(2) - - t.test('should respond with 416', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=9-50') - .expect('Content-Range', 'bytes */9') - .expect(416, err => t.error(err)) - }) - - t.test('should emit error 416 with content-range header', function (t) { - t.plan(1) - - const server = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures }) - .on('error', function (err) { - res.setHeader('X-Content-Range', err.headers['Content-Range']) - res.statusCode = err.statusCode - res.end(err.message) - }) - .pipe(res) - }) - - request(server) - .get('/nums.txt') - .set('Range', 'bytes=9-50') - .expect('X-Content-Range', 'bytes */9') - .expect(416, err => t.error(err)) - }) - }) - - t.test('when syntactically invalid', function (t) { - t.plan(1) - - t.test('should respond with 200 and the entire contents', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'asdf') - .expect(200, '123456789', err => t.error(err)) - }) - }) - - t.test('when multiple ranges', function (t) { - t.plan(2) - - t.test('should respond with 200 and the entire contents', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=1-1,3-') - .expect(shouldNotHaveHeader('Content-Range', t)) - .expect(200, '123456789', err => t.error(err)) - }) - - t.test('should respond with 206 is all ranges can be combined', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('Range', 'bytes=1-2,3-5') - .expect('Content-Range', 'bytes 1-5/9') - .expect(206, '23456', err => t.error(err)) - }) - }) - - t.test('when if-range present', function (t) { - t.plan(5) - - t.test('should respond with parts when etag unchanged', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const etag = res.headers.etag - - request(app) - .get('/nums.txt') - .set('If-Range', etag) - .set('Range', 'bytes=0-0') - .expect(206, '1', err => t.error(err)) - }) - }) - - t.test('should respond with 200 when etag changed', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const etag = res.headers.etag.replace(/"(.)/, '"0$1') - - request(app) - .get('/nums.txt') - .set('If-Range', etag) - .set('Range', 'bytes=0-0') - .expect(200, '123456789', err => t.error(err)) - }) - }) - - t.test('should respond with parts when modified unchanged', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const modified = res.headers['last-modified'] - - request(app) - .get('/nums.txt') - .set('If-Range', modified) - .set('Range', 'bytes=0-0') - .expect(206, '1', err => t.error(err)) - }) - }) - - t.test('should respond with 200 when modified changed', function (t) { - t.plan(2) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .expect(200, function (err, res) { - t.error(err) - const modified = Date.parse(res.headers['last-modified']) - 20000 - - request(app) - .get('/nums.txt') - .set('If-Range', new Date(modified).toUTCString()) - .set('Range', 'bytes=0-0') - .expect(200, '123456789', err => t.error(err)) - }) - }) - - t.test('should respond with 200 when invalid value', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - function error (err) { - res.statusCode = err.status - res.end(http.STATUS_CODES[err.status]) - } - - new SendStream(req, req.url, { root: fixtures }) - .on('error', error) - .pipe(res) - }) - - request(app) - .get('/nums.txt') - .set('If-Range', 'foo') - .set('Range', 'bytes=0-0') - .expect(200, '123456789', err => t.error(err)) - }) - }) - }) - - t.test('when "options" is specified', function (t) { - t.plan(4) - - t.test('should support start/end', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 3, end: 5 })) - .get('/nums.txt') - .expect(200, '456', err => t.error(err)) - }) - - t.test('should adjust too large end', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 3, end: 90 })) - .get('/nums.txt') - .expect(200, '456789', err => t.error(err)) - }) - - t.test('should support start/end with Range request', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 0, end: 2 })) - .get('/nums.txt') - .set('Range', 'bytes=-2') - .expect(206, '23', err => t.error(err)) - }) - - t.test('should support start/end with unsatisfiable Range request', function (t) { - t.plan(1) - - request(createServer({ root: fixtures, start: 0, end: 2 })) - .get('/nums.txt') - .set('Range', 'bytes=5-9') - .expect('Content-Range', 'bytes */3') - .expect(416, err => t.error(err)) - }) - }) - - t.test('.root()', function (t) { - t.plan(1) - - t.test('should set root', function (t) { - t.plan(1) - - const app = http.createServer(function (req, res) { - new SendStream(req, req.url) - .root(fixtures) - .pipe(res) - }) - - request(app) - .get('/pets/../name.txt') - .expect(200, 'tobi', err => t.error(err)) - }) - }) -}) diff --git a/test/constructor.test.js b/test/constructor.test.js deleted file mode 100644 index fa7c5e2..0000000 --- a/test/constructor.test.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict' - -const { test } = require('tap') -const SendStream = require('../index').SendStream - -test('constructor', function (t) { - t.plan(1) - - t.test('SendStream without new returns SendStream instance', function (t) { - t.plan(1) - t.ok(SendStream({}, '/', {}) instanceof SendStream) - }) -}) diff --git a/test/SendStream.test.js b/test/send.1.test.js similarity index 86% rename from test/SendStream.test.js rename to test/send.1.test.js index fc5c1c1..672face 100644 --- a/test/SendStream.test.js +++ b/test/send.1.test.js @@ -5,14 +5,14 @@ const fs = require('node:fs') const http = require('node:http') const path = require('node:path') const request = require('supertest') -const SendStream = require('..').SendStream +const send = require('..').send const { shouldNotHaveHeader, createServer } = require('./utils') // test server const fixtures = path.join(__dirname, 'fixtures') -test('SendStream(file, options)', function (t) { +test('send(file, options)', function (t) { t.plan(10) t.test('acceptRanges', function (t) { @@ -184,14 +184,14 @@ test('SendStream(file, options)', function (t) { t.test('when "allow"', function (t) { t.plan(3) - t.test('should SendStream dotfile', function (t) { + t.test('should send dotfile', function (t) { t.plan(1) request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.hidden.txt') .expect(200, 'secret', err => t.error(err)) }) - t.test('should SendStream within dotfile directory', function (t) { + t.test('should send within dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'allow', root: fixtures })) .get('/.mine/name.txt') @@ -265,7 +265,7 @@ test('SendStream(file, options)', function (t) { .expect(403, err => t.error(err)) }) - t.test('should SendStream files in root dotfile directory', function (t) { + t.test('should send files in root dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) .get('/name.txt') @@ -274,8 +274,10 @@ test('SendStream(file, options)', function (t) { t.test('should 403 for dotfile without root', function (t) { t.plan(1) - const server = http.createServer(function onRequest (req, res) { - new SendStream(req, fixtures + '/.mine' + req.url, { dotfiles: 'deny' }).pipe(res) + const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + '/.mine' + req.url, { dotfiles: 'deny' }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(server) @@ -335,7 +337,7 @@ test('SendStream(file, options)', function (t) { .expect(404, err => t.error(err)) }) - t.test('should SendStream files in root dotfile directory', function (t) { + t.test('should send files in root dotfile directory', function (t) { t.plan(1) request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) @@ -346,8 +348,10 @@ test('SendStream(file, options)', function (t) { t.test('should 404 for dotfile without root', function (t) { t.plan(1) - const server = http.createServer(function onRequest (req, res) { - new SendStream(req, fixtures + '/.mine' + req.url, { dotfiles: 'ignore' }).pipe(res) + const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + '/.mine' + req.url, { dotfiles: 'ignore' }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(server) @@ -487,10 +491,11 @@ test('SendStream(file, options)', function (t) { t.test('should work without root', function (t) { t.plan(1) - const server = http.createServer(function (req, res) { + const server = http.createServer(async function (req, res) { const p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' - new SendStream(req, p, { index: ['index.html'] }) - .pipe(res) + const { statusCode, headers, stream } = await send(req, p, { index: ['index.html'] }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(server) @@ -515,9 +520,10 @@ test('SendStream(file, options)', function (t) { t.test('should work with trailing slash', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures + '/' }) - .pipe(res) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures + '/' }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(app) @@ -528,9 +534,10 @@ test('SendStream(file, options)', function (t) { t.test('should work with empty path', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, '', { root: fixtures }) - .pipe(res) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, '', { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(app) @@ -547,9 +554,10 @@ test('SendStream(file, options)', function (t) { t.test('should try as file with empty path', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, '', { root: path.join(fixtures, 'name.txt') }) - .pipe(res) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, '', { root: path.join(fixtures, 'name.txt') }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(app) @@ -561,20 +569,21 @@ test('SendStream(file, options)', function (t) { t.plan(1) request(createServer({ root: fixtures })) - .get('/pets/../../SendStream.js') + .get('/pets/../../send.js') .expect(403, err => t.error(err)) }) t.test('should allow .. in root', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, req.url, { root: fixtures + '/../fixtures' }) - .pipe(res) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures + '/../fixtures' }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(app) - .get('/pets/../../SendStream.js') + .get('/pets/../../send.js') .expect(403, err => t.error(err)) }) @@ -601,22 +610,24 @@ test('SendStream(file, options)', function (t) { t.test('should consider .. malicious', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, fixtures + req.url) - .pipe(res) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + req.url) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(app) - .get('/../SendStream.js') + .get('/../send.js') .expect(403, err => t.error(err)) }) t.test('should still serve files with dots in name', function (t) { t.plan(1) - const app = http.createServer(function (req, res) { - new SendStream(req, fixtures + req.url) - .pipe(res) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + req.url) + res.writeHead(statusCode, headers) + stream.pipe(res) }) request(app) diff --git a/test/send.2.test.js b/test/send.2.test.js new file mode 100644 index 0000000..a8e20cd --- /dev/null +++ b/test/send.2.test.js @@ -0,0 +1,1126 @@ +'use strict' + +const { test } = require('tap') +const http = require('node:http') +const path = require('node:path') +const request = require('supertest') +const send = require('../lib/send').send +const { shouldNotHaveBody, createServer, shouldNotHaveHeader } = require('./utils') + +const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ +const fixtures = path.join(__dirname, 'fixtures') + +test('send(file)', function (t) { + t.plan(22) + + t.test('should stream the file contents', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi', err => t.error(err)) + }) + + t.test('should stream a zero-length file', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/empty.txt') + .expect('Content-Length', '0') + .expect(200, '', err => t.error(err)) + }) + + t.test('should decode the given path as a URI', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/some%20thing.txt') + .expect(200, 'hey', err => t.error(err)) + }) + + t.test('should serve files with dots in name', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/do..ts.txt') + .expect(200, '...', err => t.error(err)) + }) + + t.test('should treat a malformed URI as a bad request', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/some%99thing.txt') + .expect(400, /Bad Request/, err => t.error(err)) + }) + + t.test('should 400 on NULL bytes', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/some%00thing.txt') + .expect(400, /Bad Request/, err => t.error(err)) + }) + + t.test('should treat an ENAMETOOLONG as a 404', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const path = Array(100).join('foobar') + request(app) + .get('/' + path) + .expect(404, err => t.error(err)) + }) + + t.test('should support HEAD', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .head('/name.txt') + .expect(200) + .expect('Content-Length', '4') + .expect(shouldNotHaveBody(t)) + .end(err => t.error(err)) + }) + + t.test('should add an ETag header field', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('etag', /^W\/"[^"]+"$/) + .end(err => t.error(err)) + }) + + t.test('should add a Date header field', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('date', dateRegExp, err => t.error(err)) + }) + + t.test('should add a Last-Modified header field', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('last-modified', dateRegExp, err => t.error(err)) + }) + + t.test('should add a Accept-Ranges header field', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Accept-Ranges', 'bytes', err => t.error(err)) + }) + + t.test('should 404 if the file does not exist', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/meow') + .expect(404, /Not Found/, err => t.error(err)) + }) + + t.test('should 404 if the filename is too long', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const longFilename = new Array(512).fill('a').join('') + + request(app) + .get('/' + longFilename) + .expect(404, /Not Found/, err => t.error(err)) + }) + + t.test('should 404 if the requested resource is not a directory', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt/invalid') + .expect(404, /Not Found/, err => t.error(err)) + }) + + t.test('should not override content-type', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, { + ...headers, + 'Content-Type': 'application/x-custom' + }) + stream.pipe(res) + }) + request(app) + .get('/name.txt') + .expect('Content-Type', 'application/x-custom', err => t.error(err)) + }) + + t.test('should set Content-Type via mime map', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect(200, function (err) { + t.error(err) + request(app) + .get('/tobi.html') + .expect('Content-Type', 'text/html; charset=UTF-8') + .expect(200, err => t.error(err)) + }) + }) + + t.test('send directory', function (t) { + t.plan(5) + + t.test('should redirect directories to trailing slash', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => t.error(err)) + }) + + t.test('should respond with an HTML redirect', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/pets\/<\/a> t.error(err)) + }) + + t.test('should respond with default Content-Security-Policy', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(301, err => t.error(err)) + }) + + t.test('should not redirect to protocol-relative locations', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('//pets') + .expect('Location', '/pets/') + .expect(301, err => t.error(err)) + }) + + t.test('should respond with an HTML redirect', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url.replace('/snow', '/snow ☃'), { root: 'test/fixtures' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/snow') + .expect('Location', '/snow%20%E2%98%83/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/snow%20%E2%98%83\/<\/a> t.error(err)) + }) + }) + + t.test('send error', function (t) { + t.plan(2) + + t.test('should respond to errors directly', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/foobar') + .expect(404, />Not Found t.error(err)) + }) + + t.test('should respond with default Content-Security-Policy', function (t) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/foobar') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(404, err => t.error(err)) + }) + }) + + t.test('with conditional-GET', function (t) { + t.plan(6) + + t.test('should remove Content headers with 304', function (t) { + t.plan(2) + + const server = createServer({ root: fixtures }, function (req, res) { + res.setHeader('Content-Language', 'en-US') + res.setHeader('Content-Location', 'http://localhost/name.txt') + res.setHeader('Contents', 'foo') + }) + + request(server) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Contents', 'foo') + .expect(304, err => t.error(err)) + }) + }) + + t.test('should not remove all Content-* headers', function (t) { + t.plan(2) + + const server = createServer({ root: fixtures }, function (req, res) { + res.setHeader('Content-Location', 'http://localhost/name.txt') + res.setHeader('Content-Security-Policy', 'default-src \'self\'') + }) + + request(server) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Content-Security-Policy', 'default-src \'self\'') + .expect(304, err => t.error(err)) + }) + }) + + t.test('where "If-Match" is set', function (t) { + t.plan(4) + + t.test('should respond with 200 when "*"', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .set('If-Match', '*') + .expect(200, err => t.error(err)) + }) + + t.test('should respond with 412 when ETag unmatched', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .set('If-Match', ' "foo",, "bar" ,') + .expect(412, err => t.error(err)) + }) + + t.test('should respond with 200 when ETag matched /1', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Match', '"foo", "bar", ' + res.headers.etag) + .expect(200, err => t.error(err)) + }) + }) + + t.test('should respond with 200 when ETag matched /2', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Match', '"foo", ' + res.headers.etag + ', "bar"') + .expect(200, err => t.error(err)) + }) + }) + }) + + t.test('where "If-Modified-Since" is set', function (t) { + t.plan(3) + + t.test('should respond with 304 when unmodified', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .expect(304, err => t.error(err)) + }) + }) + + t.test('should respond with 200 when modified', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + const lmod = new Date(res.headers['last-modified']) + const date = new Date(lmod - 60000) + request(app) + .get('/name.txt') + .set('If-Modified-Since', date.toUTCString()) + .expect(200, 'tobi', err => t.error(err)) + }) + }) + + t.test('should respond with 200 when modified', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .set('cache-control', 'no-cache') + .expect(200, 'tobi', err => t.error(err)) + }) + }) + }) + + t.test('where "If-None-Match" is set', function (t) { + t.plan(6) + + t.test('should respond with 304 when ETag matched', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect(304, err => t.error(err)) + }) + }) + + t.test('should respond with 200 when ETag unmatched', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi', err => t.error(err)) + }) + }) + + t.test('should respond with 200 when ETag is not generated', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi', err => t.error(err)) + }) + }) + + t.test('should respond with 306 Not Modified when using wildcard * on existing file', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', '*') + .expect(304, '', err => t.error(err)) + }) + }) + + t.test('should respond with 404 Not Found when using wildcard * on non-existing file', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/asdf.txt') + .set('If-None-Match', '*') + .expect(404, /Not Found/, err => t.error(err)) + }) + + t.test('should respond with 200 cache-control is set to no-cache', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .set('cache-control', 'no-cache') + .expect(200, 'tobi', err => t.error(err)) + }) + }) + }) + + t.test('where "If-Unmodified-Since" is set', function (t) { + t.plan(3) + + t.test('should respond with 200 when unmodified', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', res.headers['last-modified']) + .expect(200, err => t.error(err)) + }) + }) + + t.test('should respond with 412 when modified', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + t.error(err) + const lmod = new Date(res.headers['last-modified']) + const date = new Date(lmod - 60000).toUTCString() + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', date) + .expect(412, err => t.error(err)) + }) + }) + + t.test('should respond with 200 when invalid date', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', 'foo') + .expect(200, err => t.error(err)) + }) + }) + }) + + t.test('with Range request', function (t) { + t.plan(13) + + t.test('should support byte ranges', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, '12345', err => t.error(err)) + }) + + t.test('should ignore non-byte ranges', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'items=0-4') + .expect(200, '123456789', err => t.error(err)) + }) + + t.test('should be inclusive', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-0') + .expect(206, '1', err => t.error(err)) + }) + + t.test('should set Content-Range', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-5') + .expect('Content-Range', 'bytes 2-5/9') + .expect(206, err => t.error(err)) + }) + + t.test('should support -n', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=-3') + .expect(206, '789', err => t.error(err)) + }) + + t.test('should support n-', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=3-') + .expect(206, '456789', err => t.error(err)) + }) + + t.test('should respond with 206 "Partial Content"', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, err => t.error(err)) + }) + + t.test('should set Content-Length to the # of octets transferred', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-3') + .expect('Content-Length', '2') + .expect(206, '34', err => t.error(err)) + }) + + t.test('when last-byte-pos of the range is greater the length', function (t) { + t.plan(2) + + t.test('is taken to be equal to one less than the length', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Range', 'bytes 2-8/9') + .expect(206, err => t.error(err)) + }) + + t.test('should adapt the Content-Length accordingly', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Length', '7') + .expect(206, err => t.error(err)) + }) + }) + + t.test('when the first- byte-pos of the range is greater length', function (t) { + t.plan(2) + + t.test('should respond with 416', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('Content-Range', 'bytes */9') + .expect(416, err => t.error(err)) + }) + + t.test('should emit error 416 with content-range header', function (t) { + t.plan(1) + + const server = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, { + ...headers, + 'X-Content-Range': headers['Content-Range'] + }) + stream.pipe(res) + }) + + request(server) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('X-Content-Range', 'bytes */9') + .expect(416, err => t.error(err)) + }) + }) + + t.test('when syntactically invalid', function (t) { + t.plan(1) + + t.test('should respond with 200 and the entire contents', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'asdf') + .expect(200, '123456789', err => t.error(err)) + }) + }) + + t.test('when multiple ranges', function (t) { + t.plan(2) + + t.test('should respond with 200 and the entire contents', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-1,3-') + .expect(shouldNotHaveHeader('Content-Range', t)) + .expect(200, '123456789', err => t.error(err)) + }) + + t.test('should respond with 206 is all ranges can be combined', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-2,3-5') + .expect('Content-Range', 'bytes 1-5/9') + .expect(206, '23456', err => t.error(err)) + }) + }) + + t.test('when if-range present', function (t) { + t.plan(5) + + t.test('should respond with parts when etag unchanged', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const etag = res.headers.etag + + request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(206, '1', err => t.error(err)) + }) + }) + + t.test('should respond with 200 when etag changed', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const etag = res.headers.etag.replace(/"(.)/, '"0$1') + + request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => t.error(err)) + }) + }) + + t.test('should respond with parts when modified unchanged', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const modified = res.headers['last-modified'] + + request(app) + .get('/nums.txt') + .set('If-Range', modified) + .set('Range', 'bytes=0-0') + .expect(206, '1', err => t.error(err)) + }) + }) + + t.test('should respond with 200 when modified changed', function (t) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + t.error(err) + const modified = Date.parse(res.headers['last-modified']) - 20000 + + request(app) + .get('/nums.txt') + .set('If-Range', new Date(modified).toUTCString()) + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => t.error(err)) + }) + }) + + t.test('should respond with 200 when invalid value', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/nums.txt') + .set('If-Range', 'foo') + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => t.error(err)) + }) + }) + }) + + t.test('when "options" is specified', function (t) { + t.plan(4) + + t.test('should support start/end', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 3, end: 5 })) + .get('/nums.txt') + .expect(200, '456', err => t.error(err)) + }) + + t.test('should adjust too large end', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 3, end: 90 })) + .get('/nums.txt') + .expect(200, '456789', err => t.error(err)) + }) + + t.test('should support start/end with Range request', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=-2') + .expect(206, '23', err => t.error(err)) + }) + + t.test('should support start/end with unsatisfiable Range request', function (t) { + t.plan(1) + + request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=5-9') + .expect('Content-Range', 'bytes */3') + .expect(416, err => t.error(err)) + }) + }) +}) diff --git a/test/utils.js b/test/utils.js index 218d38f..864d5f3 100644 --- a/test/utils.js +++ b/test/utils.js @@ -10,10 +10,12 @@ module.exports.shouldNotHaveHeader = function shouldNotHaveHeader (header, t) { } module.exports.createServer = function createServer (opts, fn) { - return http.createServer(function onRequest (req, res) { + return http.createServer(async function onRequest (req, res) { try { fn && fn(req, res) - send(req, req.url, opts).pipe(res) + const { statusCode, headers, stream } = await send(req, req.url, opts) + res.writeHead(statusCode, headers) + stream.pipe(res) } catch (err) { res.statusCode = 500 res.end(String(err)) diff --git a/types/index.d.ts b/types/index.d.ts index 5f35c59..7f93448 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -5,13 +5,12 @@ /// import * as stream from "stream"; -import * as fs from "fs"; /** * Create a new SendStream for the given path to send to a res. * The req is the Node.js HTTP request and the path is a urlencoded path to send (urlencoded, not the actual file-system path). */ -declare function send(req: stream.Readable, path: string, options?: send.SendOptions): send.SendStream; +declare function send(req: stream.Readable, path: string, options?: send.SendOptions): Promise; type Send = typeof send; @@ -112,99 +111,10 @@ declare namespace send { start?: number | undefined; } - export class SendStream extends stream.Stream { - - constructor(req: stream.Readable, path: string, options?: SendOptions); - - /** - * Emit error with `status`. - */ - error(status: number, error?: Error): void; - - /** - * Check if the pathname ends with "/". - */ - hasTrailingSlash(): boolean; - - /** - * Check if this is a conditional GET request. - */ - isConditionalGET(): boolean; - - /** - * Strip content-* header fields. - */ - removeContentHeaderFields(): void; - - /** - * Respond with 304 not modified. - */ - notModified(): void; - - /** - * Raise error that headers already sent. - */ - headersAlreadySent(): void; - - /** - * Check if the request is cacheable, aka responded with 2xx or 304 (see RFC 2616 section 14.2{5,6}). - */ - isCachable(): boolean; - - /** - * Handle stat() error. - */ - onStatError(error: Error): void; - - /** - * Check if the cache is fresh. - */ - isFresh(): boolean; - - /** - * Check if the range is fresh. - */ - isRangeFresh(): boolean; - - /** - * Redirect to path. - */ - redirect(path: string): void; - - /** - * Pipe to `res`. - */ - pipe(res: T): T; - - /** - * Transfer `path`. - */ - send(path: string, stat?: fs.Stats): void; - - /** - * Transfer file for `path`. - */ - sendFile(path: string): void; - - /** - * Transfer index for `path`. - */ - sendIndex(path: string): void; - - /** - * Transfer index for `path`. - */ - stream(path: string, options?: {}): void; - - /** - * Set content-type based on `path` if it hasn't been explicitly set. - */ - type(path: string): void; - - /** - * Set response header fields, most fields may be pre-defined. - */ - setHeader(path: string, stat: fs.Stats): void; + export interface SendResult { + statusCode: number + headers: Record + stream: stream.Readable } export const send: Send diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 28698c5..2fc1d4c 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -1,6 +1,6 @@ -import { expectType } from 'tsd' -import send from '..' -import { SendStream } from '..'; +import { Readable } from 'stream'; +import { expectType } from 'tsd'; +import send, { SendResult } from '..'; send.mime.define({ 'application/x-my-type': ['x-mt', 'x-mtt'] @@ -10,27 +10,26 @@ expectType<(value: string) => boolean>(send.isUtf8MimeType) expectType(send.isUtf8MimeType('application/json')) const req: any = {} -const res: any = {} - -send(req, '/test.html', { - immutable: true, - maxAge: 0, - root: __dirname + '/wwwroot' -}).pipe(res); - -send(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot' }) - .on('error', (err: any) => { - res.statusCode = err.status || 500; - res.end(err.message); - }) - .on('directory', () => { - res.statusCode = 301; - res.setHeader('Location', req.url + '/'); - res.end(`Redirecting to ${req.url}/`); - }) - .on('headers', (res: any, path: string, stat: any) => { - res.setHeader('Content-Disposition', 'attachment'); - }) - .pipe(res); - -const test = new SendStream(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot' }); + +{ + const result = await send(req, '/test.html', { + immutable: true, + maxAge: 0, + root: __dirname + '/wwwroot' + }); + + expectType(result) + expectType(result.statusCode) + expectType>(result.headers) + expectType(result.stream) +} + +{ + const result = await send(req, '/test.html', { maxAge: 0, root: __dirname + '/wwwroot' }) + + expectType(result) + expectType(result.statusCode) + expectType>(result.headers) + expectType(result.stream) +} + From 8d34f1317e42b2ff5409d816409314114b529e8b Mon Sep 17 00:00:00 2001 From: KaKa Date: Sun, 7 Jul 2024 02:53:19 +0800 Subject: [PATCH 2/5] fixup --- lib/send.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/send.js b/lib/send.js index 4cded22..ce9f32e 100644 --- a/lib/send.js +++ b/lib/send.js @@ -409,14 +409,15 @@ function sendError (statusCode, err) { function sendStatError (err) { // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT - /* istanbul ignore next */ switch (err.code) { case 'ENAMETOOLONG': case 'ENOTDIR': case 'ENOENT': return sendError(404, err) + /* c8 ignore start */ default: return sendError(500, err) + /* c8 ignore stop */ } } @@ -658,10 +659,11 @@ async function send (request, _path, options) { containsDotFile(parts) ) { switch (opts.dotfiles) { - /* istanbul ignore next: unreachable, because NODE_DEBUG can not be set after process is running */ + /* c8 ignore start */ /* unreachable, because NODE_DEBUG can not be set after process is running */ case 0: // 'allow' debug('allow dotfile "%s"', path) break + /* c8 ignore stop */ case 2: // 'deny' debug('deny dotfile "%s"', path) return sendError(403) From bc0be1fa9ff8cf044619b1450c1541917d6bd773 Mon Sep 17 00:00:00 2001 From: KaKa Date: Sun, 7 Jul 2024 02:55:54 +0800 Subject: [PATCH 3/5] fixup --- lib/send.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/send.js b/lib/send.js index ce9f32e..f98d375 100644 --- a/lib/send.js +++ b/lib/send.js @@ -409,16 +409,16 @@ function sendError (statusCode, err) { function sendStatError (err) { // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT + /* c8 ignore start */ switch (err.code) { case 'ENAMETOOLONG': case 'ENOTDIR': case 'ENOENT': return sendError(404, err) - /* c8 ignore start */ default: return sendError(500, err) - /* c8 ignore stop */ } + /* c8 ignore stop */ } /** @@ -659,7 +659,7 @@ async function send (request, _path, options) { containsDotFile(parts) ) { switch (opts.dotfiles) { - /* c8 ignore start */ /* unreachable, because NODE_DEBUG can not be set after process is running */ + /* c8 ignore start */ /* unreachable, because NODE_DEBUG can not be set after process is running */ case 0: // 'allow' debug('allow dotfile "%s"', path) break From 50439dea12342970cb628dc03e8c096274d60d8a Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 8 Jul 2024 17:41:03 +0800 Subject: [PATCH 4/5] docs: update README.md --- README.md | 127 ++++++------------------------------------------------ 1 file changed, 14 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 9fb6d03..acf1d51 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,9 @@ var send = require('@fastify/send') ### send(req, path, [options]) -Create a new `SendStream` for the given path to send to a `res`. The `req` is -the Node.js HTTP request and the `path` is a urlencoded path to send (urlencoded, -not the actual file-system path). +Provide `statusCode`, `headers` and `stream` for the given path to send to a +`res`. The `req` is the Node.js HTTP request and the `path `is a urlencoded path +to send (urlencoded, not the actual file-system path). #### Options @@ -122,22 +122,6 @@ Serve files relative to `path`. Byte offset at which the stream starts, defaults to 0. The start is inclusive, meaning `start: 2` will include the 3rd byte in the stream. -#### Events - -The `SendStream` is an event emitter and will emit the following events: - - - `error` an error occurred `(err)` - - `directory` a directory was requested `(res, path)` - - `file` a file was requested `(path, stat)` - - `headers` the headers are about to be set on a file `(res, path, stat)` - - `stream` file streaming has started `(stream)` - - `end` streaming has completed - -#### .pipe - -The `pipe` method is used to pipe the response into the Node.js HTTP response -object, typically `send(req, path, options).pipe(res)`. - ### .mime The `mime` export is the global instance of the @@ -147,12 +131,6 @@ This is used to configure the MIME types that are associated with file extension as well as other options for how to resolve the MIME type of a file (like the default type to use for an unknown file extension). -## Error-handling - -By default when no `error` listeners are present an automatic response will be -made, otherwise you have full control over the response, aka you may show a 5xx -page etc. - ## Caching It does _not_ perform internal caching, you should use a reverse proxy cache @@ -185,9 +163,10 @@ This simple example will send a specific file to all requests. var http = require('node:http') var send = require('send') -var server = http.createServer(function onRequest (req, res) { - send(req, '/path/to/index.html') - .pipe(res) +var server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, '/path/to/index.html') + res.writeHead(statusCode, headers) + stream.pipe(res) }) server.listen(3000) @@ -204,9 +183,10 @@ var http = require('node:http') var parseUrl = require('parseurl') var send = require('@fastify/send') -var server = http.createServer(function onRequest (req, res) { - send(req, parseUrl(req).pathname, { root: '/www/public' }) - .pipe(res) +var server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) server.listen(3000) @@ -228,88 +208,9 @@ send.mime.define({ }) var server = http.createServer(function onRequest (req, res) { - send(req, parseUrl(req).pathname, { root: '/www/public' }) - .pipe(res) -}) - -server.listen(3000) -``` - -### Custom directory index view - -This is an example of serving up a structure of directories with a -custom function to render a listing of a directory. - -```js -var http = require('node:http') -var fs = require('node:fs') -var parseUrl = require('parseurl') -var send = require('@fastify/send') - -// Transfer arbitrary files from within /www/example.com/public/* -// with a custom handler for directory listing -var server = http.createServer(function onRequest (req, res) { - send(req, parseUrl(req).pathname, { index: false, root: '/www/public' }) - .once('directory', directory) - .pipe(res) -}) - -server.listen(3000) - -// Custom directory handler -function directory (res, path) { - var stream = this - - // redirect to trailing slash for consistent url - if (!stream.hasTrailingSlash()) { - return stream.redirect(path) - } - - // get directory list - fs.readdir(path, function onReaddir (err, list) { - if (err) return stream.error(err) - - // render an index for the directory - res.setHeader('Content-Type', 'text/plain; charset=UTF-8') - res.end(list.join('\n') + '\n') - }) -} -``` - -### Serving from a root directory with custom error-handling - -```js -var http = require('node:http') -var parseUrl = require('parseurl') -var send = require('@fastify/send') - -var server = http.createServer(function onRequest (req, res) { - // your custom error-handling logic: - function error (err) { - res.statusCode = err.status || 500 - res.end(err.message) - } - - // your custom headers - function headers (res, path, stat) { - // serve all files for download - res.setHeader('Content-Disposition', 'attachment') - } - - // your custom directory handling logic: - function redirect () { - res.statusCode = 301 - res.setHeader('Location', req.url + '/') - res.end('Redirecting to ' + req.url + '/') - } - - // transfer arbitrary files from within - // /www/example.com/public/* - send(req, parseUrl(req).pathname, { root: '/www/public' }) - .on('error', error) - .on('directory', redirect) - .on('headers', headers) - .pipe(res) + const { statusCode, headers, stream } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) + res.writeHead(statusCode, headers) + stream.pipe(res) }) server.listen(3000) From 64ba02017e980f4fa8001a8b174ce5eb92af4023 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 9 Jul 2024 00:17:00 +0800 Subject: [PATCH 5/5] chore: remove http-errors --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index b60bea4..55146ba 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "dependencies": { "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", - "http-errors": "2.0.0", "mime": "^3", "@lukeed/ms": "^2.0.2" },