Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ If set to `true`, `fastify-static` redirects to the directory with a trailing sl
This option cannot be set to `true` with `wildcard` set to `false` on a server
with `ignoreTrailingSlash` set to `true`.

If this option is set to `false`, then requesting directories without trailing
If this option is set to `false`, then requesting directories without trailing
slash will trigger your app's 404 handler using `reply.callNotFound()`.

#### `wildcard`
Expand Down Expand Up @@ -289,6 +289,24 @@ GET .../public/index
GET .../public/index.json
```

#### `preCompressed`

Default: `false`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious why this defaults to false. Is the expectation that people are typically serving precompressed assets with something like nginx in front of Fastify?

As for the general behaviour, what I've observed is that people typically dump all of their assets into a build dir of some sort (as you've shown with public/ below) and would expect those to be served by whatever is handling their static assets. I guess I'm concerned that this default is unintuitive, and will generate a bunch of "why doesn't this work" issues for the fastify team.

You may choose to skip compression for smaller files that don't benefit from it.

Usually build processes just avoid compressing these in the first place. I don't think the application should be making this decision, personally. All the webpack compression plugins for example, take a minimum size to compress & save.

This is all very anecdotal, so please feel free to correct me 🤓

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, just wanted to add, thanks so much for working on this!


Try to send the brotli encoded asset first (when supported within the `Accept-Encoding` headers), retry for gzip, then the fall back to the original `pathname`. You may choose to skip compression for smaller files that don't benefit from it.

Assume this structure with the compressed asset as a sibling of the un-compressed counterpart:

```
./public
├── main.js
├── main.js.br
├── main.js.gz
├── crit.css
├── crit.css.gz
└── index.html
```

#### Disable serving

If you would just like to use the reply decorator and not serve whole directories automatically, you can simply pass the option `{ serve: false }`. This will prevent the plugin from serving everything under `root`.
Expand Down
5 changes: 5 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export interface FastifyStaticOptions extends SendOptions {
wildcard?: boolean;
list?: boolean | ListOptions;
allowedPath?: (pathName: string, root?: string) => boolean;
/**
* @description
* Opt-in to looking for pre-compressed files
*/
preCompressed?: boolean;
Comment thread
chrstntdd marked this conversation as resolved.

// Passed on to `send`
acceptRanges?: boolean;
Expand Down
141 changes: 121 additions & 20 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const contentDisposition = require('content-disposition')
const fp = require('fastify-plugin')
const util = require('util')
const globPromise = util.promisify(glob)
const encodingNegotiator = require('encoding-negotiator')

const dirList = require('./lib/dirList')

Expand Down Expand Up @@ -42,7 +43,15 @@ async function fastifyStatic (fastify, opts) {

const allowedPath = opts.allowedPath

function pumpSendToReply (request, reply, pathname, rootPath, rootPathOffset = 0, pumpOptions = {}) {
function pumpSendToReply (
request,
reply,
pathname,
rootPath,
rootPathOffset = 0,
pumpOptions = {},
checkedExtensions
) {
const options = Object.assign({}, sendOptions, pumpOptions)

if (rootPath) {
Expand All @@ -57,7 +66,26 @@ async function fastifyStatic (fastify, opts) {
return reply.callNotFound()
}

const stream = send(request.raw, pathname, options)
let encodingExt
let pathnameForSend = pathname

if (opts.preCompressed) {
/**
* We conditionally create this structure to track our attempts
* at sending pre-compressed assets
*/
if (!checkedExtensions) {
checkedExtensions = new Set()
Comment thread
chrstntdd marked this conversation as resolved.
}

encodingExt = checkEncodingHeaders(request.headers, checkedExtensions)

if (encodingExt) {
pathnameForSend = pathname + '.' + encodingExt
}
}

const stream = send(request.raw, pathnameForSend, options)
let resolvedFilename
stream.on('file', function (file) {
resolvedFilename = file
Expand Down Expand Up @@ -94,7 +122,12 @@ async function fastifyStatic (fastify, opts) {
if (request.method === 'HEAD') {
wrap.on('finish', reply.send.bind(reply))
} else {
wrap.on('pipe', reply.send.bind(reply, wrap))
wrap.on('pipe', function () {
if (encodingExt) {
reply.header('content-encoding', encodingExt)
}
reply.send(wrap)
})
}

if (setHeaders !== undefined) {
Expand All @@ -103,7 +136,12 @@ async function fastifyStatic (fastify, opts) {

stream.on('directory', function (_, path) {
if (opts.list) {
return dirList.send({ reply, dir: path, options: opts.list, route: pathname })
return dirList.send({
reply,
dir: path,
options: opts.list,
route: pathname
})
}

if (opts.redirect === true) {
Expand All @@ -127,6 +165,19 @@ async function fastifyStatic (fastify, opts) {
return pumpSendToReply(request, reply, pathname, rootPath, rootPathOffset + 1)
}

if (opts.preCompressed && !checkedExtensions.has(encodingExt)) {
checkedExtensions.add(encodingExt)
return pumpSendToReply(
request,
reply,
pathname,
rootPath,
undefined,
undefined,
checkedExtensions
)
}

return reply.callNotFound()
}
reply.send(err)
Expand All @@ -142,7 +193,10 @@ async function fastifyStatic (fastify, opts) {
let prefix = opts.prefix

if (!opts.prefixAvoidTrailingSlash) {
prefix = opts.prefix[opts.prefix.length - 1] === '/' ? opts.prefix : (opts.prefix + '/')
prefix =
opts.prefix[opts.prefix.length - 1] === '/'
? opts.prefix
: opts.prefix + '/'
}

const errorHandler = (error, request, reply) => {
Expand All @@ -156,31 +210,44 @@ async function fastifyStatic (fastify, opts) {

// Set the schema hide property if defined in opts or true by default
const routeOpts = {
schema: { hide: typeof opts.schemaHide !== 'undefined' ? opts.schemaHide : true },
schema: {
hide: typeof opts.schemaHide !== 'undefined' ? opts.schemaHide : true
},
errorHandler: fastify.errorHandler ? errorHandler : undefined
}

if (opts.decorateReply !== false) {
fastify.decorateReply('sendFile', function (filePath, rootPath) {
pumpSendToReply(this.request, this, filePath, rootPath || sendOptions.root)
pumpSendToReply(
this.request,
this,
filePath,
rootPath || sendOptions.root
)
return this
})

fastify.decorateReply('download', function (filePath, fileName, options = {}) {
const { root, ...opts } = typeof fileName === 'object' ? fileName : options
fileName = typeof fileName === 'string' ? fileName : filePath
fastify.decorateReply(
'download',
function (filePath, fileName, options = {}) {
const { root, ...opts } =
typeof fileName === 'object' ? fileName : options
fileName = typeof fileName === 'string' ? fileName : filePath

// Set content disposition header
this.header('content-disposition', contentDisposition(fileName))
// Set content disposition header
this.header('content-disposition', contentDisposition(fileName))

pumpSendToReply(this.request, this, filePath, root, 0, opts)
pumpSendToReply(this.request, this, filePath, root, 0, opts)

return this
})
return this
}
)
}

if (opts.serve !== false) {
if (opts.wildcard && typeof opts.wildcard !== 'boolean') throw new Error('"wildcard" option must be a boolean')
if (opts.wildcard && typeof opts.wildcard !== 'boolean') {
throw new Error('"wildcard" option must be a boolean')
}
if (opts.wildcard === undefined || opts.wildcard === true) {
fastify.head(prefix + '*', routeOpts, function (req, reply) {
pumpSendToReply(req, reply, '/' + req.params['*'], sendOptions.root)
Expand All @@ -205,7 +272,9 @@ async function fastifyStatic (fastify, opts) {
const indexes = typeof opts.index === 'undefined' ? ['index.html'] : [].concat(opts.index)

for (let file of files) {
file = file.replace(rootPath.replace(/\\/g, '/'), '').replace(/^\//, '')
file = file
.replace(rootPath.replace(/\\/g, '/'), '')
.replace(/^\//, '')
const route = encodeURI(prefix + file).replace(/\/\//g, '/')
if (routes.has(route)) {
continue
Expand Down Expand Up @@ -257,14 +326,18 @@ function checkRootPathForErrors (fastify, rootPath) {
}

if (Array.isArray(rootPath)) {
if (!rootPath.length) { throw new Error('"root" option array requires one or more paths') }
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')
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))
rootPath.map((path) => checkPath(fastify, path))
return
}

Expand Down Expand Up @@ -301,6 +374,34 @@ function checkPath (fastify, rootPath) {
}
}

const supportedEncodings = ['br', 'gzip', 'deflate']

// Adapted from https://github.com/fastify/fastify-compress/blob/fa5c12a5394285c86d9f438cb39ff44f3d5cde79/index.js#L442
function checkEncodingHeaders (headers, checked) {
if (!('accept-encoding' in headers)) return

let ext
const header = headers['accept-encoding'].toLowerCase().replace('*', 'gzip')
const accepted = encodingNegotiator.negotiate(
header,
supportedEncodings.filter((enc) => !checked.has(enc))
)

switch (accepted) {
case 'br':
ext = 'br'
break

case 'gzip':
if (!checked.has('gz')) {
ext = 'gz'
break
}
}

return ext
}

module.exports = fp(fastifyStatic, {
fastify: '3.x',
name: 'fastify-static'
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"homepage": "https://github.com/fastify/fastify-static",
"dependencies": {
"content-disposition": "^0.5.3",
"encoding-negotiator": "^2.0.1",
"fastify-plugin": "^3.0.0",
"glob": "^7.1.4",
"readable-stream": "^3.4.0",
Expand Down
5 changes: 5 additions & 0 deletions test/static-pre-compressed/all-three.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<html>
<body>
the body
</body>
</html>
Binary file added test/static-pre-compressed/all-three.html.br
Binary file not shown.
Binary file added test/static-pre-compressed/all-three.html.gz
Binary file not shown.
3 changes: 3 additions & 0 deletions test/static-pre-compressed/gzip-only.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<html>
<body>foo</body>
</html>
Binary file added test/static-pre-compressed/gzip-only.html.gz
Binary file not shown.
3 changes: 3 additions & 0 deletions test/static-pre-compressed/uncompressed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<html>
<body>foobar</body>
</html>
Loading