@fastify/static 9.1.2 → 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, req.routeOptions.url)
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,23 +573,113 @@ function getEncodingHeader (headers, checked) {
569
573
  }
570
574
 
571
575
  /**
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
+ /**
573
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
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
574
671
  * @returns {string|undefined}
575
672
  */
576
- function getPathnameForSend (url, route) {
673
+ function getPathnameForSend (url, matchRoutePrefix) {
577
674
  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
583
676
 
584
- if (routePrefix !== '/' && !pathname.startsWith(routePrefix)) {
677
+ const prefixLength = matchRoutePrefix(url, pathnameEnd)
678
+ if (prefixLength === undefined) {
585
679
  return
586
680
  }
587
681
 
588
- pathname = pathname.slice(routePrefix.length)
682
+ let pathname = url.slice(prefixLength, pathnameEnd)
589
683
 
590
684
  if (pathname === '') {
591
685
  pathname = '/'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastify/static",
3
- "version": "9.1.2",
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",
@@ -6,7 +6,6 @@ const path = require('node:path')
6
6
  const fs = require('node:fs')
7
7
  const url = require('node:url')
8
8
  const http = require('node:http')
9
- const Module = require('node:module')
10
9
  const { test } = require('node:test')
11
10
  const Fastify = require('fastify')
12
11
  const compress = require('@fastify/compress')
@@ -82,16 +81,6 @@ if (typeof Promise.withResolvers === 'undefined') {
82
81
  }
83
82
  }
84
83
 
85
- function loadInternalPathnameForSend () {
86
- const modulePath = require.resolve('../index.js')
87
- const source = fs.readFileSync(modulePath, 'utf8') + '\nmodule.exports.__getPathnameForSend = getPathnameForSend\n'
88
- const testModule = new Module(modulePath)
89
- testModule.filename = modulePath
90
- testModule.paths = module.paths
91
- testModule._compile(source, modulePath)
92
- return testModule.exports.__getPathnameForSend
93
- }
94
-
95
84
  test('register /static prefixAvoidTrailingSlash', async t => {
96
85
  t.plan(11)
97
86
 
@@ -3601,16 +3590,6 @@ test(
3601
3590
  }
3602
3591
  )
3603
3592
 
3604
- test('getPathnameForSend returns undefined for mismatched and malformed routes', (t) => {
3605
- t.plan(3)
3606
-
3607
- const getPathnameForSend = loadInternalPathnameForSend()
3608
-
3609
- t.assert.deepStrictEqual(getPathnameForSend('/nested/public/index.css', '/public/*'), undefined)
3610
- t.assert.deepStrictEqual(getPathnameForSend('/static/%E0%A4%A', '/static/*'), undefined)
3611
- t.assert.deepStrictEqual(getPathnameForSend('/static/index.css', '/static'), '/index.css')
3612
- })
3613
-
3614
3593
  test('wildcard handler falls back to not found when the raw url does not match the route prefix', async (t) => {
3615
3594
  t.plan(2)
3616
3595
 
@@ -3634,7 +3613,7 @@ test('wildcard handler falls back to not found when the raw url does not match t
3634
3613
  t.assert.ok(wildcardHandler)
3635
3614
 
3636
3615
  let notFoundCalled = false
3637
- wildcardHandler({
3616
+ await wildcardHandler({
3638
3617
  raw: { url: '/nested/static/index.css' },
3639
3618
  routeOptions: { url: '/static/*' }
3640
3619
  }, {
@@ -3646,6 +3625,111 @@ test('wildcard handler falls back to not found when the raw url does not match t
3646
3625
  t.assert.deepStrictEqual(notFoundCalled, true)
3647
3626
  })
3648
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
+
3649
3733
  test('does not serve static files with encoded path separators', async (t) => {
3650
3734
  t.plan(4)
3651
3735
 
@@ -3701,6 +3785,53 @@ test('serves wildcard files when registered in an encapsulated context', async (
3701
3785
  t.assert.deepStrictEqual(response.body, fs.readFileSync(path.join(__dirname, '/static/index.css'), 'utf8'))
3702
3786
  })
3703
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
+
3704
3835
  test('content-length in head route should not return zero when using wildcard', async t => {
3705
3836
  t.plan(5)
3706
3837