Skip to content

Commit 7f92da5

Browse files
authored
fix: support wildcard prefixes with route params (#576)
* fix: support wildcard prefixes with route params * test: remove internal pathname helper * perf: precompute wildcard prefix matchers * perf: avoid slicing urls before prefix matching
1 parent b0a66d5 commit 7f92da5

4 files changed

Lines changed: 299 additions & 33 deletions

File tree

example/server-benchmark.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use strict'
2+
3+
const path = require('node:path')
4+
const fastify = require('fastify')({ logger: false })
5+
const fastifyStatic = require(process.env.PLUGIN_PATH || '../')
6+
7+
const root = path.join(__dirname, '/public')
8+
const port = Number(process.env.PORT || 3000)
9+
10+
fastify.register(fastifyStatic, {
11+
root,
12+
prefix: '/static',
13+
decorateReply: false
14+
})
15+
16+
fastify.register(fastifyStatic, {
17+
root,
18+
prefix: '/app/:version',
19+
decorateReply: false
20+
})
21+
22+
fastify.register(async function (child) {
23+
child.register(fastifyStatic, {
24+
root,
25+
prefix: '/public',
26+
decorateReply: false
27+
})
28+
}, { prefix: '/nested' })
29+
30+
fastify.listen({ port }, err => {
31+
if (err) throw err
32+
33+
console.log(`benchmark server listening on http://127.0.0.1:${port}`)
34+
console.log('')
35+
console.log('Try:')
36+
console.log(` npx autocannon -c 100 -d 10 http://127.0.0.1:${port}/static/index.css`)
37+
console.log(` npx autocannon -c 100 -d 10 http://127.0.0.1:${port}/app/1.2.3/index.css`)
38+
console.log(` npx autocannon -c 100 -d 10 http://127.0.0.1:${port}/nested/public/index.css`)
39+
})

index.js

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,16 @@ async function fastifyStatic (fastify, opts) {
117117
throw new TypeError('"wildcard" option must be a boolean')
118118
}
119119
if (opts.wildcard === undefined || opts.wildcard === true) {
120+
let matchRoutePrefix
121+
120122
fastify.route({
121123
...routeOpts,
122124
method: ['HEAD', 'GET'],
123125
path: prefix + '*',
124126
handler (req, reply) {
125-
const pathname = getPathnameForSend(req.raw.url, req.routeOptions.url)
127+
matchRoutePrefix ??= createRoutePrefixMatcher(req.routeOptions.url)
128+
129+
const pathname = getPathnameForSend(req.raw.url, matchRoutePrefix)
126130
if (!pathname) {
127131
return reply.callNotFound()
128132
}
@@ -569,23 +573,113 @@ function getEncodingHeader (headers, checked) {
569573
}
570574

571575
/**
572-
* @param {string} url
576+
* @param {string} routePrefix
577+
* @returns {Array<string|undefined>}
578+
*/
579+
function createRoutePrefixTokens (routePrefix) {
580+
const tokens = []
581+
let routeIndex = 0
582+
let segmentStart = 0
583+
584+
while (routeIndex < routePrefix.length) {
585+
if (routePrefix[routeIndex] !== ':') {
586+
routeIndex++
587+
continue
588+
}
589+
590+
if (segmentStart !== routeIndex) {
591+
tokens.push(routePrefix.slice(segmentStart, routeIndex))
592+
}
593+
594+
routeIndex++
595+
while (routeIndex < routePrefix.length && routePrefix[routeIndex] !== '/') {
596+
routeIndex++
597+
}
598+
599+
tokens.push(undefined)
600+
segmentStart = routeIndex
601+
}
602+
603+
if (segmentStart !== routePrefix.length) {
604+
tokens.push(routePrefix.slice(segmentStart))
605+
}
606+
607+
return tokens
608+
}
609+
610+
/**
611+
* @param {string} pathname
612+
* @param {number} pathnameEnd
613+
* @param {Array<string|undefined>} tokens
614+
* @returns {number|undefined}
615+
*/
616+
function getRoutePrefixMatchLength (pathname, pathnameEnd, tokens) {
617+
let pathnameIndex = 0
618+
619+
for (const token of tokens) {
620+
if (token === undefined) {
621+
const segmentStart = pathnameIndex
622+
const slashIndex = pathname.indexOf('/', pathnameIndex)
623+
624+
pathnameIndex = slashIndex === -1 || slashIndex > pathnameEnd
625+
? pathnameEnd
626+
: slashIndex
627+
628+
if (pathnameIndex === segmentStart) {
629+
return
630+
}
631+
632+
continue
633+
}
634+
635+
const tokenEnd = pathnameIndex + token.length
636+
if (tokenEnd > pathnameEnd || !pathname.startsWith(token, pathnameIndex)) {
637+
return
638+
}
639+
640+
pathnameIndex = tokenEnd
641+
}
642+
643+
return pathnameIndex
644+
}
645+
646+
/**
573647
* @param {string} route
648+
* @returns {(pathname: string, pathnameEnd: number) => number|undefined}
649+
*/
650+
function createRoutePrefixMatcher (route) {
651+
const routePrefix = route.replace(/\*$/u, '')
652+
const routePrefixLength = routePrefix.length
653+
654+
if (routePrefix === '/') {
655+
return () => 0
656+
}
657+
658+
if (routePrefix.includes(':') === false) {
659+
return (pathname, pathnameEnd) => routePrefixLength <= pathnameEnd && pathname.startsWith(routePrefix)
660+
? routePrefixLength
661+
: undefined
662+
}
663+
664+
const tokens = createRoutePrefixTokens(routePrefix)
665+
return (pathname, pathnameEnd) => getRoutePrefixMatchLength(pathname, pathnameEnd, tokens)
666+
}
667+
668+
/**
669+
* @param {string} url
670+
* @param {(pathname: string, pathnameEnd: number) => number|undefined} matchRoutePrefix
574671
* @returns {string|undefined}
575672
*/
576-
function getPathnameForSend (url, route) {
673+
function getPathnameForSend (url, matchRoutePrefix) {
577674
const questionMark = url.indexOf('?')
578-
let pathname = questionMark === -1 ? url : url.slice(0, questionMark)
579-
580-
const routePrefix = route.endsWith('*')
581-
? route.slice(0, -1)
582-
: route
675+
const pathnameEnd = questionMark === -1 ? url.length : questionMark
583676

584-
if (routePrefix !== '/' && !pathname.startsWith(routePrefix)) {
677+
const prefixLength = matchRoutePrefix(url, pathnameEnd)
678+
if (prefixLength === undefined) {
585679
return
586680
}
587681

588-
pathname = pathname.slice(routePrefix.length)
682+
let pathname = url.slice(prefixLength, pathnameEnd)
589683

590684
if (pathname === '') {
591685
pathname = '/'

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"test": "npm run test:unit && npm run test:typescript",
1313
"test:typescript": "tsd",
1414
"test:unit": "borp -C --check-coverage --lines 100",
15-
"example": "node example/server.js"
15+
"example": "node example/server.js",
16+
"example:benchmark": "node example/server-benchmark.js"
1617
},
1718
"repository": {
1819
"type": "git",
@@ -68,6 +69,7 @@
6869
"devDependencies": {
6970
"@fastify/compress": "^8.0.0",
7071
"@types/node": "^25.0.3",
72+
"autocannon": "^8.0.0",
7173
"borp": "^1.0.0",
7274
"c8": "^11.0.0",
7375
"concat-stream": "^2.0.0",

0 commit comments

Comments
 (0)