@fastify/static 9.1.1 → 9.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ })
package/index.js CHANGED
@@ -117,12 +117,16 @@ async function fastifyStatic (fastify, opts) {
117
117
  throw new TypeError('"wildcard" option must be a boolean')
118
118
  }
119
119
  if (opts.wildcard === undefined || opts.wildcard === true) {
120
+ let matchRoutePrefix
121
+
120
122
  fastify.route({
121
123
  ...routeOpts,
122
124
  method: ['HEAD', 'GET'],
123
125
  path: prefix + '*',
124
126
  handler (req, reply) {
125
- const pathname = getPathnameForSend(req.raw.url, prefix)
127
+ matchRoutePrefix ??= createRoutePrefixMatcher(req.routeOptions.url)
128
+
129
+ const pathname = getPathnameForSend(req.raw.url, matchRoutePrefix)
126
130
  if (!pathname) {
127
131
  return reply.callNotFound()
128
132
  }
@@ -569,26 +573,114 @@ function getEncodingHeader (headers, checked) {
569
573
  }
570
574
 
571
575
  /**
572
- * @param {string} url
573
- * @param {string} prefix
574
- * @returns {string|undefined}
576
+ * @param {string} routePrefix
577
+ * @returns {Array<string|undefined>}
575
578
  */
576
- function getPathnameForSend (url, prefix) {
577
- const questionMark = url.indexOf('?')
578
- let pathname = questionMark === -1 ? url : url.slice(0, questionMark)
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
+ }
579
602
 
580
- if (prefix !== '/') {
581
- const routePrefix = prefix.endsWith('/')
582
- ? prefix.slice(0, -1)
583
- : prefix
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
+ }
584
631
 
585
- if (!pathname.startsWith(routePrefix)) {
632
+ continue
633
+ }
634
+
635
+ const tokenEnd = pathnameIndex + token.length
636
+ if (tokenEnd > pathnameEnd || !pathname.startsWith(token, pathnameIndex)) {
586
637
  return
587
638
  }
588
639
 
589
- pathname = pathname.slice(routePrefix.length)
640
+ pathnameIndex = tokenEnd
641
+ }
642
+
643
+ return pathnameIndex
644
+ }
645
+
646
+ /**
647
+ * @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
590
656
  }
591
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
671
+ * @returns {string|undefined}
672
+ */
673
+ function getPathnameForSend (url, matchRoutePrefix) {
674
+ const questionMark = url.indexOf('?')
675
+ const pathnameEnd = questionMark === -1 ? url.length : questionMark
676
+
677
+ const prefixLength = matchRoutePrefix(url, pathnameEnd)
678
+ if (prefixLength === undefined) {
679
+ return
680
+ }
681
+
682
+ let pathname = url.slice(prefixLength, pathnameEnd)
683
+
592
684
  if (pathname === '') {
593
685
  pathname = '/'
594
686
  } else if (!pathname.startsWith('/')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastify/static",
3
- "version": "9.1.1",
3
+ "version": "9.1.3",
4
4
  "description": "Plugin for serving static files as fast as possible.",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -12,7 +12,8 @@
12
12
  "test": "npm run test:unit && npm run test:typescript",
13
13
  "test:typescript": "tsd",
14
14
  "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"
16
17
  },
17
18
  "repository": {
18
19
  "type": "git",
@@ -68,6 +69,7 @@
68
69
  "devDependencies": {
69
70
  "@fastify/compress": "^8.0.0",
70
71
  "@types/node": "^25.0.3",
72
+ "autocannon": "^8.0.0",
71
73
  "borp": "^1.0.0",
72
74
  "c8": "^11.0.0",
73
75
  "concat-stream": "^2.0.0",
@@ -3590,6 +3590,146 @@ test(
3590
3590
  }
3591
3591
  )
3592
3592
 
3593
+ test('wildcard handler falls back to not found when the raw url does not match the route prefix', async (t) => {
3594
+ t.plan(2)
3595
+
3596
+ const fastify = Fastify()
3597
+ let wildcardHandler
3598
+
3599
+ fastify.addHook('onRoute', (route) => {
3600
+ if (route.url === '/static/*') {
3601
+ wildcardHandler = route.handler
3602
+ }
3603
+ })
3604
+
3605
+ fastify.register(fastifyStatic, {
3606
+ root: path.join(__dirname, '/static'),
3607
+ prefix: '/static'
3608
+ })
3609
+
3610
+ t.after(() => fastify.close())
3611
+
3612
+ await fastify.ready()
3613
+ t.assert.ok(wildcardHandler)
3614
+
3615
+ let notFoundCalled = false
3616
+ await wildcardHandler({
3617
+ raw: { url: '/nested/static/index.css' },
3618
+ routeOptions: { url: '/static/*' }
3619
+ }, {
3620
+ callNotFound () {
3621
+ notFoundCalled = true
3622
+ }
3623
+ })
3624
+
3625
+ t.assert.deepStrictEqual(notFoundCalled, true)
3626
+ })
3627
+
3628
+ test('wildcard handler falls back to not found when a param segment is empty', async (t) => {
3629
+ t.plan(2)
3630
+
3631
+ const fastify = Fastify()
3632
+ let wildcardHandler
3633
+
3634
+ fastify.addHook('onRoute', (route) => {
3635
+ if (route.url === '/app/:version/*') {
3636
+ wildcardHandler = route.handler
3637
+ }
3638
+ })
3639
+
3640
+ fastify.register(fastifyStatic, {
3641
+ root: path.join(__dirname, '/static'),
3642
+ prefix: '/app/:version'
3643
+ })
3644
+
3645
+ t.after(() => fastify.close())
3646
+
3647
+ await fastify.ready()
3648
+ t.assert.ok(wildcardHandler)
3649
+
3650
+ let notFoundCalled = false
3651
+ await wildcardHandler({
3652
+ raw: { url: '/app//index.css' },
3653
+ routeOptions: { url: '/app/:version/*' }
3654
+ }, {
3655
+ callNotFound () {
3656
+ notFoundCalled = true
3657
+ }
3658
+ })
3659
+
3660
+ t.assert.deepStrictEqual(notFoundCalled, true)
3661
+ })
3662
+
3663
+ test('wildcard handler falls back to not found when the wildcard remainder is malformed', async (t) => {
3664
+ t.plan(2)
3665
+
3666
+ const fastify = Fastify()
3667
+ let wildcardHandler
3668
+
3669
+ fastify.addHook('onRoute', (route) => {
3670
+ if (route.url === '/app/:version/*') {
3671
+ wildcardHandler = route.handler
3672
+ }
3673
+ })
3674
+
3675
+ fastify.register(fastifyStatic, {
3676
+ root: path.join(__dirname, '/static'),
3677
+ prefix: '/app/:version'
3678
+ })
3679
+
3680
+ t.after(() => fastify.close())
3681
+
3682
+ await fastify.ready()
3683
+ t.assert.ok(wildcardHandler)
3684
+
3685
+ let notFoundCalled = false
3686
+ await wildcardHandler({
3687
+ raw: { url: '/app/1.2.3/%E0%A4%A' },
3688
+ routeOptions: { url: '/app/:version/*' }
3689
+ }, {
3690
+ callNotFound () {
3691
+ notFoundCalled = true
3692
+ }
3693
+ })
3694
+
3695
+ t.assert.deepStrictEqual(notFoundCalled, true)
3696
+ })
3697
+
3698
+ test('wildcard handler falls back to not found when a literal segment after a param does not match', async (t) => {
3699
+ t.plan(2)
3700
+
3701
+ const fastify = Fastify()
3702
+ let wildcardHandler
3703
+
3704
+ fastify.addHook('onRoute', (route) => {
3705
+ if (route.url === '/app/:version/public/*') {
3706
+ wildcardHandler = route.handler
3707
+ }
3708
+ })
3709
+
3710
+ fastify.register(fastifyStatic, {
3711
+ root: path.join(__dirname, '/static'),
3712
+ prefix: '/app/:version/public'
3713
+ })
3714
+
3715
+ t.after(() => fastify.close())
3716
+
3717
+ await fastify.ready()
3718
+ t.assert.ok(wildcardHandler)
3719
+
3720
+ let notFoundCalled = false
3721
+ await wildcardHandler({
3722
+ raw: { url: '/app/1.2.3/private/index.css' },
3723
+ routeOptions: { url: '/app/:version/public/*' }
3724
+ }, {
3725
+ callNotFound () {
3726
+ notFoundCalled = true
3727
+ }
3728
+ })
3729
+
3730
+ t.assert.deepStrictEqual(notFoundCalled, true)
3731
+ })
3732
+
3593
3733
  test('does not serve static files with encoded path separators', async (t) => {
3594
3734
  t.plan(4)
3595
3735
 
@@ -3620,6 +3760,78 @@ test('does not serve static files with encoded path separators', async (t) => {
3620
3760
  t.assert.deepStrictEqual(encodedPathResponse.json().message, 'Route GET:/deep%2Fpath/for/test/purpose/foo.html not found')
3621
3761
  })
3622
3762
 
3763
+ test('serves wildcard files when registered in an encapsulated context', async (t) => {
3764
+ t.plan(3)
3765
+
3766
+ const fastify = Fastify()
3767
+
3768
+ t.after(() => fastify.close())
3769
+
3770
+ fastify.register(async function (childContext) {
3771
+ childContext.register(fastifyStatic, {
3772
+ root: path.join(__dirname, '/static'),
3773
+ prefix: '/public',
3774
+ decorateReply: false
3775
+ })
3776
+ }, { prefix: '/nested' })
3777
+
3778
+ const response = await fastify.inject({
3779
+ method: 'GET',
3780
+ url: '/nested/public/index.css'
3781
+ })
3782
+
3783
+ t.assert.deepStrictEqual(response.statusCode, 200)
3784
+ t.assert.deepStrictEqual(response.headers['content-type'], 'text/css; charset=utf-8')
3785
+ t.assert.deepStrictEqual(response.body, fs.readFileSync(path.join(__dirname, '/static/index.css'), 'utf8'))
3786
+ })
3787
+
3788
+ test('serves wildcard files when prefix contains a route param', async (t) => {
3789
+ t.plan(3)
3790
+
3791
+ const fastify = Fastify()
3792
+
3793
+ t.after(() => fastify.close())
3794
+
3795
+ fastify.register(fastifyStatic, {
3796
+ root: path.join(__dirname, '/static'),
3797
+ prefix: '/app/:version',
3798
+ decorateReply: false
3799
+ })
3800
+
3801
+ const response = await fastify.inject({
3802
+ method: 'GET',
3803
+ url: '/app/1.2.3/index.css'
3804
+ })
3805
+
3806
+ t.assert.deepStrictEqual(response.statusCode, 200)
3807
+ t.assert.deepStrictEqual(response.headers['content-type'], 'text/css; charset=utf-8')
3808
+ t.assert.deepStrictEqual(response.body, fs.readFileSync(path.join(__dirname, '/static/index.css'), 'utf8'))
3809
+ })
3810
+
3811
+ test('serves wildcard index files when a param prefix uses prefixAvoidTrailingSlash', async (t) => {
3812
+ t.plan(3)
3813
+
3814
+ const fastify = Fastify()
3815
+
3816
+ t.after(() => fastify.close())
3817
+
3818
+ fastify.register(fastifyStatic, {
3819
+ root: path.join(__dirname, '/static'),
3820
+ prefix: '/app/:version',
3821
+ prefixAvoidTrailingSlash: true,
3822
+ decorateReply: false
3823
+ })
3824
+
3825
+ const response = await fastify.inject({
3826
+ method: 'GET',
3827
+ url: '/app/1.2.3'
3828
+ })
3829
+
3830
+ t.assert.deepStrictEqual(response.statusCode, 200)
3831
+ t.assert.deepStrictEqual(response.headers['content-type'], 'text/html; charset=utf-8')
3832
+ t.assert.deepStrictEqual(response.body, fs.readFileSync(path.join(__dirname, '/static/index.html'), 'utf8'))
3833
+ })
3834
+
3623
3835
  test('content-length in head route should not return zero when using wildcard', async t => {
3624
3836
  t.plan(5)
3625
3837
 
@@ -1 +0,0 @@
1
- /none
@@ -1 +0,0 @@
1
- ../origin