diff --git a/example/server-benchmark.js b/example/server-benchmark.js new file mode 100644 index 00000000..4edbfa10 --- /dev/null +++ b/example/server-benchmark.js @@ -0,0 +1,39 @@ +'use strict' + +const path = require('node:path') +const fastify = require('fastify')({ logger: false }) +const fastifyStatic = require(process.env.PLUGIN_PATH || '../') + +const root = path.join(__dirname, '/public') +const port = Number(process.env.PORT || 3000) + +fastify.register(fastifyStatic, { + root, + prefix: '/static', + decorateReply: false +}) + +fastify.register(fastifyStatic, { + root, + prefix: '/app/:version', + decorateReply: false +}) + +fastify.register(async function (child) { + child.register(fastifyStatic, { + root, + prefix: '/public', + decorateReply: false + }) +}, { prefix: '/nested' }) + +fastify.listen({ port }, err => { + if (err) throw err + + console.log(`benchmark server listening on http://127.0.0.1:${port}`) + console.log('') + console.log('Try:') + console.log(` npx autocannon -c 100 -d 10 http://127.0.0.1:${port}/static/index.css`) + console.log(` npx autocannon -c 100 -d 10 http://127.0.0.1:${port}/app/1.2.3/index.css`) + console.log(` npx autocannon -c 100 -d 10 http://127.0.0.1:${port}/nested/public/index.css`) +}) diff --git a/index.js b/index.js index ea2c60f2..b2ffbb1c 100644 --- a/index.js +++ b/index.js @@ -117,12 +117,16 @@ async function fastifyStatic (fastify, opts) { throw new TypeError('"wildcard" option must be a boolean') } if (opts.wildcard === undefined || opts.wildcard === true) { + let matchRoutePrefix + fastify.route({ ...routeOpts, method: ['HEAD', 'GET'], path: prefix + '*', handler (req, reply) { - const pathname = getPathnameForSend(req.raw.url, req.routeOptions.url) + matchRoutePrefix ??= createRoutePrefixMatcher(req.routeOptions.url) + + const pathname = getPathnameForSend(req.raw.url, matchRoutePrefix) if (!pathname) { return reply.callNotFound() } @@ -569,23 +573,113 @@ function getEncodingHeader (headers, checked) { } /** - * @param {string} url + * @param {string} routePrefix + * @returns {Array} + */ +function createRoutePrefixTokens (routePrefix) { + const tokens = [] + let routeIndex = 0 + let segmentStart = 0 + + while (routeIndex < routePrefix.length) { + if (routePrefix[routeIndex] !== ':') { + routeIndex++ + continue + } + + if (segmentStart !== routeIndex) { + tokens.push(routePrefix.slice(segmentStart, routeIndex)) + } + + routeIndex++ + while (routeIndex < routePrefix.length && routePrefix[routeIndex] !== '/') { + routeIndex++ + } + + tokens.push(undefined) + segmentStart = routeIndex + } + + if (segmentStart !== routePrefix.length) { + tokens.push(routePrefix.slice(segmentStart)) + } + + return tokens +} + +/** + * @param {string} pathname + * @param {number} pathnameEnd + * @param {Array} tokens + * @returns {number|undefined} + */ +function getRoutePrefixMatchLength (pathname, pathnameEnd, tokens) { + let pathnameIndex = 0 + + for (const token of tokens) { + if (token === undefined) { + const segmentStart = pathnameIndex + const slashIndex = pathname.indexOf('/', pathnameIndex) + + pathnameIndex = slashIndex === -1 || slashIndex > pathnameEnd + ? pathnameEnd + : slashIndex + + if (pathnameIndex === segmentStart) { + return + } + + continue + } + + const tokenEnd = pathnameIndex + token.length + if (tokenEnd > pathnameEnd || !pathname.startsWith(token, pathnameIndex)) { + return + } + + pathnameIndex = tokenEnd + } + + return pathnameIndex +} + +/** * @param {string} route + * @returns {(pathname: string, pathnameEnd: number) => number|undefined} + */ +function createRoutePrefixMatcher (route) { + const routePrefix = route.replace(/\*$/u, '') + const routePrefixLength = routePrefix.length + + if (routePrefix === '/') { + return () => 0 + } + + if (routePrefix.includes(':') === false) { + return (pathname, pathnameEnd) => routePrefixLength <= pathnameEnd && pathname.startsWith(routePrefix) + ? routePrefixLength + : undefined + } + + const tokens = createRoutePrefixTokens(routePrefix) + return (pathname, pathnameEnd) => getRoutePrefixMatchLength(pathname, pathnameEnd, tokens) +} + +/** + * @param {string} url + * @param {(pathname: string, pathnameEnd: number) => number|undefined} matchRoutePrefix * @returns {string|undefined} */ -function getPathnameForSend (url, route) { +function getPathnameForSend (url, matchRoutePrefix) { const questionMark = url.indexOf('?') - let pathname = questionMark === -1 ? url : url.slice(0, questionMark) - - const routePrefix = route.endsWith('*') - ? route.slice(0, -1) - : route + const pathnameEnd = questionMark === -1 ? url.length : questionMark - if (routePrefix !== '/' && !pathname.startsWith(routePrefix)) { + const prefixLength = matchRoutePrefix(url, pathnameEnd) + if (prefixLength === undefined) { return } - pathname = pathname.slice(routePrefix.length) + let pathname = url.slice(prefixLength, pathnameEnd) if (pathname === '') { pathname = '/' diff --git a/package.json b/package.json index f4525549..648f128f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test": "npm run test:unit && npm run test:typescript", "test:typescript": "tsd", "test:unit": "borp -C --check-coverage --lines 100", - "example": "node example/server.js" + "example": "node example/server.js", + "example:benchmark": "node example/server-benchmark.js" }, "repository": { "type": "git", @@ -68,6 +69,7 @@ "devDependencies": { "@fastify/compress": "^8.0.0", "@types/node": "^25.0.3", + "autocannon": "^8.0.0", "borp": "^1.0.0", "c8": "^11.0.0", "concat-stream": "^2.0.0", diff --git a/test/static.test.js b/test/static.test.js index 23f3c6cf..ab52a244 100644 --- a/test/static.test.js +++ b/test/static.test.js @@ -6,7 +6,6 @@ const path = require('node:path') const fs = require('node:fs') const url = require('node:url') const http = require('node:http') -const Module = require('node:module') const { test } = require('node:test') const Fastify = require('fastify') const compress = require('@fastify/compress') @@ -82,16 +81,6 @@ if (typeof Promise.withResolvers === 'undefined') { } } -function loadInternalPathnameForSend () { - const modulePath = require.resolve('../index.js') - const source = fs.readFileSync(modulePath, 'utf8') + '\nmodule.exports.__getPathnameForSend = getPathnameForSend\n' - const testModule = new Module(modulePath) - testModule.filename = modulePath - testModule.paths = module.paths - testModule._compile(source, modulePath) - return testModule.exports.__getPathnameForSend -} - test('register /static prefixAvoidTrailingSlash', async t => { t.plan(11) @@ -3601,16 +3590,6 @@ test( } ) -test('getPathnameForSend returns undefined for mismatched and malformed routes', (t) => { - t.plan(3) - - const getPathnameForSend = loadInternalPathnameForSend() - - t.assert.deepStrictEqual(getPathnameForSend('/nested/public/index.css', '/public/*'), undefined) - t.assert.deepStrictEqual(getPathnameForSend('/static/%E0%A4%A', '/static/*'), undefined) - t.assert.deepStrictEqual(getPathnameForSend('/static/index.css', '/static'), '/index.css') -}) - test('wildcard handler falls back to not found when the raw url does not match the route prefix', async (t) => { t.plan(2) @@ -3634,7 +3613,7 @@ test('wildcard handler falls back to not found when the raw url does not match t t.assert.ok(wildcardHandler) let notFoundCalled = false - wildcardHandler({ + await wildcardHandler({ raw: { url: '/nested/static/index.css' }, routeOptions: { url: '/static/*' } }, { @@ -3646,6 +3625,111 @@ test('wildcard handler falls back to not found when the raw url does not match t t.assert.deepStrictEqual(notFoundCalled, true) }) +test('wildcard handler falls back to not found when a param segment is empty', async (t) => { + t.plan(2) + + const fastify = Fastify() + let wildcardHandler + + fastify.addHook('onRoute', (route) => { + if (route.url === '/app/:version/*') { + wildcardHandler = route.handler + } + }) + + fastify.register(fastifyStatic, { + root: path.join(__dirname, '/static'), + prefix: '/app/:version' + }) + + t.after(() => fastify.close()) + + await fastify.ready() + t.assert.ok(wildcardHandler) + + let notFoundCalled = false + await wildcardHandler({ + raw: { url: '/app//index.css' }, + routeOptions: { url: '/app/:version/*' } + }, { + callNotFound () { + notFoundCalled = true + } + }) + + t.assert.deepStrictEqual(notFoundCalled, true) +}) + +test('wildcard handler falls back to not found when the wildcard remainder is malformed', async (t) => { + t.plan(2) + + const fastify = Fastify() + let wildcardHandler + + fastify.addHook('onRoute', (route) => { + if (route.url === '/app/:version/*') { + wildcardHandler = route.handler + } + }) + + fastify.register(fastifyStatic, { + root: path.join(__dirname, '/static'), + prefix: '/app/:version' + }) + + t.after(() => fastify.close()) + + await fastify.ready() + t.assert.ok(wildcardHandler) + + let notFoundCalled = false + await wildcardHandler({ + raw: { url: '/app/1.2.3/%E0%A4%A' }, + routeOptions: { url: '/app/:version/*' } + }, { + callNotFound () { + notFoundCalled = true + } + }) + + t.assert.deepStrictEqual(notFoundCalled, true) +}) + +test('wildcard handler falls back to not found when a literal segment after a param does not match', async (t) => { + t.plan(2) + + const fastify = Fastify() + let wildcardHandler + + fastify.addHook('onRoute', (route) => { + if (route.url === '/app/:version/public/*') { + wildcardHandler = route.handler + } + }) + + fastify.register(fastifyStatic, { + root: path.join(__dirname, '/static'), + prefix: '/app/:version/public' + }) + + t.after(() => fastify.close()) + + await fastify.ready() + t.assert.ok(wildcardHandler) + + let notFoundCalled = false + await wildcardHandler({ + raw: { url: '/app/1.2.3/private/index.css' }, + routeOptions: { url: '/app/:version/public/*' } + }, { + callNotFound () { + notFoundCalled = true + } + }) + + t.assert.deepStrictEqual(notFoundCalled, true) +}) + test('does not serve static files with encoded path separators', async (t) => { t.plan(4) @@ -3701,6 +3785,53 @@ test('serves wildcard files when registered in an encapsulated context', async ( t.assert.deepStrictEqual(response.body, fs.readFileSync(path.join(__dirname, '/static/index.css'), 'utf8')) }) +test('serves wildcard files when prefix contains a route param', async (t) => { + t.plan(3) + + const fastify = Fastify() + + t.after(() => fastify.close()) + + fastify.register(fastifyStatic, { + root: path.join(__dirname, '/static'), + prefix: '/app/:version', + decorateReply: false + }) + + const response = await fastify.inject({ + method: 'GET', + url: '/app/1.2.3/index.css' + }) + + t.assert.deepStrictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.headers['content-type'], 'text/css; charset=utf-8') + t.assert.deepStrictEqual(response.body, fs.readFileSync(path.join(__dirname, '/static/index.css'), 'utf8')) +}) + +test('serves wildcard index files when a param prefix uses prefixAvoidTrailingSlash', async (t) => { + t.plan(3) + + const fastify = Fastify() + + t.after(() => fastify.close()) + + fastify.register(fastifyStatic, { + root: path.join(__dirname, '/static'), + prefix: '/app/:version', + prefixAvoidTrailingSlash: true, + decorateReply: false + }) + + const response = await fastify.inject({ + method: 'GET', + url: '/app/1.2.3' + }) + + t.assert.deepStrictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.headers['content-type'], 'text/html; charset=utf-8') + t.assert.deepStrictEqual(response.body, fs.readFileSync(path.join(__dirname, '/static/index.html'), 'utf8')) +}) + test('content-length in head route should not return zero when using wildcard', async t => { t.plan(5)