diff --git a/README.md b/README.md index 7bc4c2e..d7b473b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ The absolute path of the directory that contains the files to serve. The file to serve will be determined by combining `req.url` with the provided root directory. +You can also provide an array of directories containing files to serve. +This is useful for serving multiple static directories under a single prefix. Files are served in a "first found, first served" manner, so the order in which you list the directories is important. For best performance, you should always list your main asset directory first. Duplicate paths will raise an error. + #### `prefix` Default: `'/'` diff --git a/example/public2/test.css b/example/public2/test.css new file mode 100644 index 0000000..1bee9c0 --- /dev/null +++ b/example/public2/test.css @@ -0,0 +1,4 @@ +body { + background-color: black; + color: white; +} diff --git a/example/public2/test.html b/example/public2/test.html new file mode 100644 index 0000000..5480039 --- /dev/null +++ b/example/public2/test.html @@ -0,0 +1,8 @@ + + + + + +

Test 2

+ + diff --git a/example/server-compress.js b/example/server-compress.js index 1ace28a..901e922 100644 --- a/example/server-compress.js +++ b/example/server-compress.js @@ -4,9 +4,12 @@ const path = require('path') const fastify = require('fastify')({ logger: { level: 'trace' } }) fastify - // compress everything + // Compress everything. .register(require('fastify-compress'), { threshold: 0 }) - .register(require('../'), { root: path.join(__dirname, '/public') }) + .register(require('../'), { + // An absolute path containing static files to serve. + root: path.join(__dirname, '/public') + }) .listen(3000, err => { if (err) throw err }) diff --git a/example/server-dir-list.js b/example/server-dir-list.js index b458576..31f2665 100644 --- a/example/server-dir-list.js +++ b/example/server-dir-list.js @@ -5,35 +5,42 @@ const Handlebars = require('handlebars') const fastify = require('fastify')({ logger: { level: 'trace' } }) +// Handlebar template for listing files and directories. const template = ` - - -dirs - - - -list - - - + + + dirs + + + list + + + + ` const handlebarTemplate = Handlebars.compile(template) fastify .register(require('..'), { + // An absolute path containing static files to serve. root: path.join(__dirname, '/public'), + // Do not append a trailing slash to prefixes. prefixAvoidTrailingSlash: true, + // Return a directory listing with a handlebar template. list: { + // html or json response? html requires a render method. format: 'html', + // A list of filenames that trigger a directory list response. names: ['index', 'index.html', 'index.htm', '/'], + // You can provide your own render method as needed. render: (dirs, files) => handlebarTemplate({ dirs, files }) } }) diff --git a/example/server-multi-dir-list.js b/example/server-multi-dir-list.js new file mode 100644 index 0000000..ac918f2 --- /dev/null +++ b/example/server-multi-dir-list.js @@ -0,0 +1,49 @@ +'use strict' + +const path = require('path') +const Handlebars = require('handlebars') + +const fastify = require('fastify')({ logger: { level: 'trace' } }) + +// Handlebar template for listing files and directories. +const template = ` + + + dirs + + + list + + + + +` +const handlebarTemplate = Handlebars.compile(template) + +fastify + .register(require('..'), { + // Array of absolute paths containing static files to serve + root: [path.join(__dirname, '/public'), path.join(__dirname, '/public2')], + // Do not append a trailing slash to prefixes + prefixAvoidTrailingSlash: true, + // Return a directory listing with a handlebar template + list: { + // html or json response? html requires a render method. + format: 'html', + // A list of filenames that trigger a directory list response. + names: ['index', 'index.html', 'index.htm', '/'], + // You can provide your own render method as needed. + render: (dirs, files) => handlebarTemplate({ dirs, files }) + } + }) + .listen(3000, err => { + if (err) throw err + }) diff --git a/example/server.js b/example/server.js index 1c90680..84c0820 100644 --- a/example/server.js +++ b/example/server.js @@ -4,7 +4,10 @@ const path = require('path') const fastify = require('fastify')({ logger: { level: 'trace' } }) fastify - .register(require('../'), { root: path.join(__dirname, '/public') }) + .register(require('../'), { + // An absolute path containing static files to serve. + root: path.join(__dirname, '/public') + }) .listen(3000, err => { if (err) throw err }) diff --git a/index.d.ts b/index.d.ts index 677933e..73a9ff8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -31,7 +31,7 @@ interface ListOptions { } export interface FastifyStaticOptions { - root: string; + root: string | string[]; prefix?: string; prefixAvoidTrailingSlash?: boolean; serve?: boolean; diff --git a/index.js b/index.js index 9e5d42b..2edad46 100644 --- a/index.js +++ b/index.js @@ -7,22 +7,23 @@ const { PassThrough } = require('readable-stream') const glob = require('glob') const send = require('send') const fp = require('fastify-plugin') +const util = require('util') +const globPromise = util.promisify(glob) const dirList = require('./lib/dirList') -function fastifyStatic (fastify, opts, next) { - const error = checkRootPathForErrors(fastify, opts.root) - if (error !== undefined) return next(error) +async function fastifyStatic (fastify, opts) { + checkRootPathForErrors(fastify, opts.root) const setHeaders = opts.setHeaders if (setHeaders !== undefined && typeof setHeaders !== 'function') { - return next(new TypeError('The `setHeaders` option must be a function')) + throw new TypeError('The `setHeaders` option must be a function') } const invalidDirListOpts = dirList.validateOptions(opts.list) if (invalidDirListOpts) { - return next(invalidDirListOpts) + throw invalidDirListOpts } const sendOptions = { @@ -38,11 +39,15 @@ function fastifyStatic (fastify, opts, next) { maxAge: opts.maxAge } - function pumpSendToReply (request, reply, pathname, rootPath) { + function pumpSendToReply (request, reply, pathname, rootPath, rootPathOffset = 0) { const options = Object.assign({}, sendOptions) if (rootPath) { - options.root = rootPath + if (Array.isArray(rootPath)) { + options.root = rootPath[rootPathOffset] + } else { + options.root = rootPath + } } const stream = send(request.raw, pathname, options) @@ -108,6 +113,12 @@ function fastifyStatic (fastify, opts, next) { if (opts.list && dirList.handle(pathname, opts.list)) { return dirList.send({ reply, dir: dirList.path(opts.root, pathname), options: opts.list, route: pathname }) } + + // root paths left to try? + if (Array.isArray(rootPath) && rootPathOffset < (rootPath.length - 1)) { + return pumpSendToReply(request, reply, pathname, rootPath, rootPathOffset + 1) + } + return reply.callNotFound() } reply.send(err) @@ -152,7 +163,7 @@ function fastifyStatic (fastify, opts, next) { if (opts.serve !== false) { if (opts.wildcard === undefined || opts.wildcard === true) { fastify.get(prefix + '*', routeOpts, function (req, reply) { - pumpSendToReply(req, reply, '/' + req.params['*']) + pumpSendToReply(req, reply, '/' + req.params['*'], sendOptions.root) }) if (opts.redirect === true && prefix !== opts.prefix) { fastify.get(opts.prefix, routeOpts, function (req, reply) { @@ -163,57 +174,79 @@ function fastifyStatic (fastify, opts, next) { } } else { const globPattern = typeof opts.wildcard === 'string' ? opts.wildcard : '**/*' - glob(path.join(sendOptions.root, globPattern), { nodir: true }, function (err, files) { - if (err) { - return next(err) - } + + async function addGlobRoutes (rootPath) { + const files = await globPromise(path.join(rootPath, globPattern), { nodir: true }) const indexDirs = new Set() const indexes = typeof opts.index === 'undefined' ? ['index.html'] : [].concat(opts.index || []) + for (let file of files) { - file = file.replace(sendOptions.root.replace(/\\/g, '/'), '').replace(/^\//, '') + file = file.replace(rootPath.replace(/\\/g, '/'), '').replace(/^\//, '') const route = encodeURI(prefix + file).replace(/\/\//g, '/') fastify.get(route, routeOpts, function (req, reply) { - pumpSendToReply(req, reply, '/' + file) + pumpSendToReply(req, reply, '/' + file, rootPath) }) if (indexes.includes(path.posix.basename(route))) { indexDirs.add(path.posix.dirname(route)) } } + indexDirs.forEach(function (dirname) { const pathname = dirname + (dirname.endsWith('/') ? '' : '/') const file = '/' + pathname.replace(prefix, '') fastify.get(pathname, routeOpts, function (req, reply) { - pumpSendToReply(req, reply, file) + pumpSendToReply(req, reply, file, rootPath) }) if (opts.redirect === true) { fastify.get(pathname.replace(/\/$/, ''), routeOpts, function (req, reply) { - pumpSendToReply(req, reply, file.replace(/\/$/, '')) + pumpSendToReply(req, reply, file.replace(/\/$/, ''), rootPath) }) } }) - next() - }) + } - // return early to avoid calling next afterwards - return + if (Array.isArray(sendOptions.root)) { + await Promise.all(sendOptions.root.map(addGlobRoutes)) + } else { + await addGlobRoutes(sendOptions.root) + } } } - - next() } function checkRootPathForErrors (fastify, rootPath) { if (rootPath === undefined) { - return new Error('"root" option is required') + throw new Error('"root" option is required') + } + + if (Array.isArray(rootPath)) { + if (!rootPath.length) { throw new Error('"root" option array requires one or more paths') } + + if ([...new Set(rootPath)].length !== rootPath.length) { + throw new Error('"root" option array contains one or more duplicate paths') + } + + // check each path and fail at first invalid + rootPath.map(path => checkPath(fastify, path)) + return + } + + if (typeof rootPath === 'string') { + return checkPath(fastify, rootPath) } + + throw new Error('"root" option must be a string or array of strings') +} + +function checkPath (fastify, rootPath) { if (typeof rootPath !== 'string') { - return new Error('"root" option must be a string') + throw new Error('"root" option must be a string') } if (path.isAbsolute(rootPath) === false) { - return new Error('"root" option must be an absolute path') + throw new Error('"root" option must be an absolute path') } let pathStat @@ -226,11 +259,11 @@ function checkRootPathForErrors (fastify, rootPath) { return } - return e + throw e } if (pathStat.isDirectory() === false) { - return new Error('"root" option must point to a directory') + throw new Error('"root" option must point to a directory') } } diff --git a/test/static.test.js b/test/static.test.js index a774f54..88b931e 100644 --- a/test/static.test.js +++ b/test/static.test.js @@ -18,9 +18,11 @@ const proxyquire = require('proxyquire') const fastifyStatic = require('../') const indexContent = fs.readFileSync('./test/static/index.html').toString('utf8') +const index2Content = fs.readFileSync('./test/static2/index.html').toString('utf8') const foobarContent = fs.readFileSync('./test/static/foobar.html').toString('utf8') const deepContent = fs.readFileSync('./test/static/deep/path/for/test/purpose/foo.html').toString('utf8') const innerIndex = fs.readFileSync('./test/static/deep/path/for/test/index.html').toString('utf8') +const barContent = fs.readFileSync('./test/static2/bar.html').toString('utf8') const GENERIC_RESPONSE_CHECK_COUNT = 5 function genericResponseChecks (t, response) { @@ -488,6 +490,52 @@ t.test('register /static/', t => { }) }) +t.test('register /static and /static2', t => { + t.plan(3) + + const pluginOptions = { + root: [path.join(__dirname, '/static'), path.join(__dirname, '/static2')], + prefix: '/static' + } + const fastify = Fastify() + fastify.register(fastifyStatic, pluginOptions) + + t.tearDown(fastify.close.bind(fastify)) + + fastify.listen(0, err => { + t.error(err) + + fastify.server.unref() + + t.test('/static/index.html', t => { + t.plan(4 + GENERIC_RESPONSE_CHECK_COUNT) + simple.concat({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + '/static/index.html' + }, (err, response, body) => { + t.error(err) + t.strictEqual(response.statusCode, 200) + t.notStrictEqual(body.toString(), index2Content) + t.strictEqual(body.toString(), indexContent) + genericResponseChecks(t, response) + }) + }) + + t.test('/static/bar.html', t => { + t.plan(3 + GENERIC_RESPONSE_CHECK_COUNT) + simple.concat({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + '/static/bar.html' + }, (err, response, body) => { + t.error(err) + t.strictEqual(response.statusCode, 200) + t.strictEqual(body.toString(), barContent) + genericResponseChecks(t, response) + }) + }) + }) +}) + t.test('payload.filename is set', t => { t.plan(3) @@ -803,7 +851,7 @@ t.test('send options', t => { root: path.join(__dirname, '/static'), acceptRanges: 'acceptRanges', cacheControl: 'cacheControl', - dotfiles: 'allow', + dotfiles: 'dotfiles', etag: 'etag', extensions: 'extensions', immutable: 'immutable', @@ -818,7 +866,7 @@ t.test('send options', t => { t.strictEqual(options.root, path.join(__dirname, '/static')) t.strictEqual(options.acceptRanges, 'acceptRanges') t.strictEqual(options.cacheControl, 'cacheControl') - t.strictEqual(options.dotfiles, 'allow') + t.strictEqual(options.dotfiles, 'dotfiles') t.strictEqual(options.etag, 'etag') t.strictEqual(options.extensions, 'extensions') t.strictEqual(options.immutable, 'immutable') @@ -898,7 +946,7 @@ t.test('maxAge option', t => { }) t.test('errors', t => { - t.plan(5) + t.plan(11) t.test('no root', t => { t.plan(1) @@ -940,6 +988,66 @@ t.test('errors', t => { }) }) + t.test('root is an empty array', t => { + t.plan(1) + const pluginOptions = { root: [] } + const fastify = Fastify({ logger: false }) + fastify.register(fastifyStatic, pluginOptions) + .ready(err => { + t.equal(err.constructor, Error) + }) + }) + + t.test('root array does not contain strings', t => { + t.plan(1) + const pluginOptions = { root: [1] } + const fastify = Fastify({ logger: false }) + fastify.register(fastifyStatic, pluginOptions) + .ready(err => { + t.equal(err.constructor, Error) + }) + }) + + t.test('root array does not contain an absolute path', t => { + t.plan(1) + const pluginOptions = { root: ['./my/path'] } + const fastify = Fastify({ logger: false }) + fastify.register(fastifyStatic, pluginOptions) + .ready(err => { + t.equal(err.constructor, Error) + }) + }) + + t.test('root array path is not a directory', t => { + t.plan(1) + const pluginOptions = { root: [__filename] } + const fastify = Fastify({ logger: false }) + fastify.register(fastifyStatic, pluginOptions) + .ready(err => { + t.equal(err.constructor, Error) + }) + }) + + t.test('all root array paths must be valid', t => { + t.plan(1) + const pluginOptions = { root: [path.join(__dirname, '/static'), 1] } + const fastify = Fastify({ logger: false }) + fastify.register(fastifyStatic, pluginOptions) + .ready(err => { + t.equal(err.constructor, Error) + }) + }) + + t.test('duplicate root paths are not allowed', t => { + t.plan(1) + const pluginOptions = { root: [path.join(__dirname, '/static'), path.join(__dirname, '/static')] } + const fastify = Fastify({ logger: false }) + fastify.register(fastifyStatic, pluginOptions) + .ready(err => { + t.equal(err.constructor, Error) + }) + }) + t.test('setHeaders is not a function', t => { t.plan(1) const pluginOptions = { root: __dirname, setHeaders: 'headers' } @@ -1452,6 +1560,41 @@ t.test('register with wildcard "**/index.html"', t => { }) }) +t.test('register with wildcard "**/index.html" on multiple root paths', t => { + t.plan(2) + + const pluginOptions = { + root: [path.join(__dirname, '/static'), path.join(__dirname, '/static2')], + wildcard: '**/*.js' + } + const fastify = Fastify() + fastify.register(fastifyStatic, pluginOptions) + + fastify.get('/*', (request, reply) => { + reply.send({ hello: 'world' }) + }) + + t.tearDown(fastify.close.bind(fastify)) + + fastify.listen(0, err => { + t.error(err) + + fastify.server.unref() + + t.test('/index.html', t => { + t.plan(2 + GENERIC_ERROR_RESPONSE_CHECK_COUNT) + simple.concat({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + '/index.html' + }, (err, response, body) => { + t.error(err) + t.strictEqual(response.statusCode, 200) + genericErrorResponseChecks(t, response) + }) + }) + }) +}) + t.test('register with wildcard "**/foo.*"', t => { t.plan(8) @@ -2110,6 +2253,90 @@ t.test('trailing slash behavior with redirect = false', t => { }) }) +t.test('if dotfiles are properly served according to plugin options', t => { + t.plan(3) + const exampleContents = fs.readFileSync(path.join(__dirname, 'static', '.example'), { encoding: 'utf8' }).toString() + + t.test('freely serve dotfiles', (t) => { + t.plan(4) + const fastify = Fastify() + + const pluginOptions = { + root: path.join(__dirname, 'static'), + prefix: '/static/', + dotfiles: 'allow' + } + + fastify.register(fastifyStatic, pluginOptions) + + t.teardown(fastify.close.bind(fastify)) + fastify.listen(0, (err) => { + t.error(err) + + simple.concat({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + '/static/.example' + }, (err, response, body) => { + t.error(err) + t.strictEqual(response.statusCode, 200) + t.strictEqual(body.toString(), exampleContents) + }) + }) + }) + + t.test('ignore dotfiles', (t) => { + t.plan(3) + const fastify = Fastify() + + const pluginOptions = { + root: path.join(__dirname, 'static'), + prefix: '/static/', + dotfiles: 'ignore' + } + + fastify.register(fastifyStatic, pluginOptions) + + t.teardown(fastify.close.bind(fastify)) + fastify.listen(0, (err) => { + t.error(err) + + simple.concat({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + '/static/.example' + }, (err, response, body) => { + t.error(err) + t.strictEqual(response.statusCode, 404) + }) + }) + }) + + t.test('deny requests to serve a dotfile', (t) => { + t.plan(3) + const fastify = Fastify() + + const pluginOptions = { + root: path.join(__dirname, 'static'), + prefix: '/static/', + dotfiles: 'deny' + } + + fastify.register(fastifyStatic, pluginOptions) + + t.teardown(fastify.close.bind(fastify)) + fastify.listen(0, (err) => { + t.error(err) + + simple.concat({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + '/static/.example' + }, (err, response, body) => { + t.error(err) + t.strictEqual(response.statusCode, 403) + }) + }) + }) +}) + t.test('register with failing glob handler', t => { const fastifyStatic = proxyquire.noCallThru()('../', { glob: function globStub (pattern, options, cb) { @@ -2246,90 +2473,6 @@ t.test('routes should fallback to default errorHandler', t => { }) }) -t.test('if dotfiles are properly served according to plugin options', t => { - t.plan(3) - const exampleContents = fs.readFileSync(path.join(__dirname, 'static', '.example'), { encoding: 'utf8' }).toString() - - t.test('freely serve dotfiles', (t) => { - t.plan(4) - const fastify = Fastify() - - const pluginOptions = { - root: path.join(__dirname, 'static'), - prefix: '/static/', - dotfiles: 'allow' - } - - fastify.register(fastifyStatic, pluginOptions) - - t.teardown(fastify.close.bind(fastify)) - fastify.listen(0, (err) => { - t.error(err) - - simple.concat({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/static/.example' - }, (err, response, body) => { - t.error(err) - t.strictEqual(response.statusCode, 200) - t.strictEqual(body.toString(), exampleContents) - }) - }) - }) - - t.test('ignore dotfiles', (t) => { - t.plan(3) - const fastify = Fastify() - - const pluginOptions = { - root: path.join(__dirname, 'static'), - prefix: '/static/', - dotfiles: 'ignore' - } - - fastify.register(fastifyStatic, pluginOptions) - - t.teardown(fastify.close.bind(fastify)) - fastify.listen(0, (err) => { - t.error(err) - - simple.concat({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/static/.example' - }, (err, response, body) => { - t.error(err) - t.strictEqual(response.statusCode, 404) - }) - }) - }) - - t.test('deny requests to serve a dotfile', (t) => { - t.plan(3) - const fastify = Fastify() - - const pluginOptions = { - root: path.join(__dirname, 'static'), - prefix: '/static/', - dotfiles: 'deny' - } - - fastify.register(fastifyStatic, pluginOptions) - - t.teardown(fastify.close.bind(fastify)) - fastify.listen(0, (err) => { - t.error(err) - - simple.concat({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/static/.example' - }, (err, response, body) => { - t.error(err) - t.strictEqual(response.statusCode, 403) - }) - }) - }) -}) - t.test('routes use default errorHandler when fastify.errorHandler is not defined', t => { t.plan(3) diff --git a/test/static2/bar.html b/test/static2/bar.html new file mode 100644 index 0000000..00d3bc3 --- /dev/null +++ b/test/static2/bar.html @@ -0,0 +1,3 @@ + + bar + diff --git a/test/static2/index.html b/test/static2/index.html new file mode 100644 index 0000000..e3b8da0 --- /dev/null +++ b/test/static2/index.html @@ -0,0 +1,3 @@ + + index2 + diff --git a/test/types/index.ts b/test/types/index.ts index 7e5bc75..9c1d0ff 100644 --- a/test/types/index.ts +++ b/test/types/index.ts @@ -42,3 +42,15 @@ appWithHttp2 reply.sendFile('some-file-name') }) }) + +const multiRootAppWithImplicitHttp = fastify() +options.root = [''] + +multiRootAppWithImplicitHttp + .register(fastifyStatic, options) + .after(() => { + multiRootAppWithImplicitHttp.get('/', (request, reply) => { + reply.sendFile('some-file-name') + }) + }) + \ No newline at end of file