DEV Community

Cover image for A Discord app with Fastify!
Manuel Spigolon
Manuel Spigolon

Posted on • Edited on

A Discord app with Fastify!

This simple demo application will show you in just a few minutes how to manage:

  • login with Discord using OAUTH2 code flow
  • secure the web application
  • serve web pages
  • test the web application
  • deploy the web application to Heroku

all this using Fastify v3!

Plugins

Fastify has great plugins that let you do more, with less.

We will use:

a loooot of things that will let you get a wide overview of the power of fastify!

The Application

The target is quite simple: to show to a user its Discord profile. The required pages are:

  • a root page to perform login
  • a page to show the profile
  • an error page (because it happens 😔)

The source code of this project is at your disposal on GitHub.

Project setup

Thanks to the awesome Fastify plugin system
all you need to do is create a simple file like this:

// app.js
module.exports = function app (fastify, opts, next) {
  fastify.get('/', function handler (request, reply) {
    reply.send('hello world')
  })
  next()
}
Enter fullscreen mode Exit fullscreen mode

then run it with the fastify-cli: fastify start app.js
and you're DONE! The server is online!

Homepage

Now, let's assume a great designer creates a stunning homepage.html for us:

// app.js
module.exports = function app (fastify, opts, next) {
  ...
  // every plugin must be registered
  fastify.register(require('fastify-static'), {
    root: path.join(__dirname, '../pages'), // the pages are stored in a directory
    serve: false // we don't want to expose only the static file because we do it ourself!
  })

  // everytime the user loads the site root, the homepage will be sent
  fastify.get('/', function serveHtml (request, reply) {
    reply.sendFile('homepage.html')
  })

  // and if the user types a wrong URL, the homepage will be loaded as a fallback
  fastify.setNotFoundHandler(function letsGoToHomepage (request, reply) {
    reply.redirect('/')
  })
  ...
}
Enter fullscreen mode Exit fullscreen mode

Now the homepage is online!

Login

In the homepage there is the "Login with Discord" button, but how can we manage the authentication?

First of all it is necessary to create a Discord Application
to get the credentials: CLIENT_ID and CLIENT_SECRET.
You will need to register the valid callback URI like http://localhost:3000/auth/discord/callback,
otherwise Discord will refuse to call back your application during the user login.

To load the configuration it is necessary to:

// app.js
module.exports = function app (fastify, opts, next) {
  ...
  // this schema is useful to get clear error on startup. 
  // So when you don't have a valid Env configured you will know WHY!
  const schema = {
    type: 'object',
    required: ['PORT', 'DISCORD_CLIENT_ID', 'DISCORD_SECRET'],
    properties: {
      BASE_URL: { type: 'string' },
      PORT: { type: 'integer', default: 3000 },
      DISCORD_CLIENT_ID: { type: 'string' },
      DISCORD_SECRET: { type: 'string' },
      DISCORD_PERMISSION: { type: 'string' }
    }
  }

  // register the plugin that will load the data
  fastify.register(require('fastify-env'), { schema, dotenv: true })
  ...
Enter fullscreen mode Exit fullscreen mode

In local env a .env file must be created as following:

BASE_URL=http://localhost:3000
DISCORD_CLIENT_ID=1234567890
DISCORD_SECRET=ABC123ABC
Enter fullscreen mode Exit fullscreen mode

Note: the BASE_URL parameter will be useful to deploy to Heroku

Now, to keep all our logic in order, we can create a new file to manage this flow..
and always export the same interface of the plugin system.

// auth.js
const oauth2 = require('fastify-oauth2')

module.exports = function auth (fastify, opts, next) {
  // fastify.config has been added by fastify-env - thanks to decorators!

  // register the OAUTH2 plugin.. and it is done!
  fastify.register(oauth2, {
    name: 'discordOAuth2',
    credentials: {
      client: {
        id: fastify.config.DISCORD_CLIENT_ID,
        secret: fastify.config.DISCORD_SECRET
      },
      auth: oauth2.DISCORD_CONFIGURATION
    },
    scope: ['identify'],
    startRedirectPath: '/discord',
    callbackUri: 'http://localhost:3000/discord/callback' // this URL must be exposed
  })

  // the callbackUri implementation
  fastify.get('/discord/callback', async function (request, reply) {
    // the response will have the `code`
    const token = await this.discordOAuth2.getAccessTokenFromAuthorizationCodeFlow(request)

    // in async handler it is possible to just return the payload!
    return token
  })

  next()
}
Enter fullscreen mode Exit fullscreen mode

It is possible to try out the login and since I'm lazy I can run fastify start -w app.js
That -w flag will reload the project whenever I save a file in the project itself!

Everything should work and the output of the login is an ugly JSON response!

SSR

Now let's proceed by adding a bit of server side rendering using the handlerbars engine.

We need to configure it first:

// app.js
module.exports = function bot (fastify, opts, next) {
  ...
  fastify.register(require('point-of-view'), {
    engine: {
      handlebars: require('handlebars')
    }
  })

  // now we can manage all unexpected errors in a nicer way!
  fastify.setErrorHandler(function (error, request, reply) {
    reply.view('/pages/error.hbs', error)
  })
  ...
Enter fullscreen mode Exit fullscreen mode

Too easy!

Now, the designer draws another page to show the user profile:

// auth.js
module.exports = function auth (fastify, opts, next) {
  ...
  fastify.get('/discord/callback', async function (request, reply) {
    const token = await this.discordOAuth2.getAccessTokenFromAuthorizationCodeFlow(request)

    // the token can be used to get the user data
    const userData = await got.get('https://discord.com/api/users/@me', {
      responseType: 'json',
      headers: {
        authorization: `${token.token_type} ${token.access_token}`
      }
    })

    // show the nice user profile
    reply.view('/pages/who-am-i.hbs', userData.body)
  })
  ...
Enter fullscreen mode Exit fullscreen mode

Security

Right now this application doesn't have many security concerns, since there is no information or cookie being stored: the token is read, used and deleted.

But how could we add security to the web pages?

// app.js
module.exports = function app (fastify, opts, next) {
  ...
  fastify.register(require('fastify-helmet'), {
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        baseUri: ["'self'"],
        fontSrc: ["'self'", 'https:', 'data:'],
        frameAncestors: ["'self'"],
        imgSrc: ["'self'", 'data:', 'via.placeholder.com', 'cdn.discordapp.com'], // list all the good source
        objectSrc: ["'none'"],
        scriptSrc: ["'self'", 'kit.fontawesome.com'], // list all the good source
        scriptSrcAttr: ["'none'"],
        styleSrc: ["'self'", 'https:', "'unsafe-inline'"]
      }
    }
  })
  ...
Enter fullscreen mode Exit fullscreen mode

Test

Thanks to Fastify, tests are easy to implement!

Here's an example with tap

const t = require('tap')
const nock = require('nock')
const Fastify = require('fastify')

const app = require('../app.js')

t.test('the application starts', async t => {
  const server = Fastify()
  server.register(app) // it is necessary to register the app entrypoint

  // then with .inject it is possible to execute HTTP requests to the server without starting it!
  const res = await server.inject('/')
  t.equal(res.payload, fs.readFileSync('./pages/homepage.html', 'utf8'))
})
Enter fullscreen mode Exit fullscreen mode

Deploy

The application is done. The last step is to deploy it to Heroku!

To do it, we have to add a start script like this in the package.json:

"start": "fastify start -a 0.0.0.0 app.js",
Enter fullscreen mode Exit fullscreen mode

in order to start correctly the server on the heroku platform.

Now you need to create the app in the heroku dashboard.
Following the instruction to connect this instance to your repository will be very easy!

heroku login
# add a new remote to my repository
heroku git:remote -a fastify-discord-app-demo
# check the remote addition
git remote -v
# push your master
git push heroku master
# in case your branch is not master:
git push heroku local-branch:master
Enter fullscreen mode Exit fullscreen mode

Remember to configure the ENV vars in the settings tab.

Now it is necessary to add the new callback URI https://fastify-discord-app-demo.herokuapp.com/auth/discord/callback
to the Discord OAUTH2 settings.

Congrats, the application is online fastify-discord-app-demo.herokuapp.com!!

End

In the next posts we will:

  • store the token in cookies
  • add a CI/CD pipeline
  • more tests - they are never enough
  • add other features to this demo discord application with Fastify!

Write comments below or open an issue on GitHub for any questions or feedback!
Thank you!

Acknowledgements

Thank you very much to @ezamagni for the great feedback!

Top comments (3)

Collapse
 
sachin_ak profile image
Sachin Ananthakumar • Edited

Hi Manuel, I loved reading this blog, I am shifting from express js to fastify and read the docs and gained lots of useful information. one thing which I can't figure out is why do I get the "plugin must be a function or promise" error when I try to run fastify start without deleting the async startApp function where I listen fastify.

I understand in docs for fastify cli there isn't a start function to start the fastify server, because fastify-cli takes care of it. but I don't understand why "plugin must be a function or promise" is caused. can you please explain?

Collapse
 
eomm profile image
Manuel Spigolon

Hi,
fastify-cli will use the exported function to start the bootstrap of the application. Since startApp is not exported, it will complain that it cannot run a file that doesn't export a plugin function or a promise

Collapse
 
sachin_ak profile image
Sachin Ananthakumar

thanks! I got it