DEV Community

Cover image for Part 5: Getting ready for deployment & deploy
Evertvdw
Evertvdw

Posted on

Part 5: Getting ready for deployment & deploy

The whole code for this part can be found here

Let's get into the exciting stuff this part! I always find that deploying an app you are working on makes it more 'real'. It's also a good check to see if your setup works, as deployment will most likely require some tweaks. (Spoiler alert, it will😅)

Getting ready for deployment

Section git diff (as my commits were a mess 😅) here

Deploying the app means that we need to build every part and get the build files somewhere on a server and run the main process there. In my case the main file that will be run is the packages/server/index.ts file. This means that we have to serve up our portal and widget package from there.

Serving local files

To do this we have to add some code to this file:

// At the top:
import serveStatic from 'serve-static';
import history from 'connect-history-api-fallback';

// After app.use(cookieParser()):
app.use(history());
app.use(serveStatic('./../../dist/widget'));
app.use(serveStatic('./../../dist/portal'));
Enter fullscreen mode Exit fullscreen mode

Also add the dependencies necessary for this:

yarn workspace server add connect-history-api-fallback
yarn workspace server add -D @types/connect-history-api-fallback
Enter fullscreen mode Exit fullscreen mode

The history() function is needed to run our Vue app in history mode, meaning that you can navigate directly to /clients and get served the entry index.html no matter the initial url.

This will be refined later on. Also we're introducing a bug when we add it like this, which we will tackle later. Can you spot what will go wrong? Hint: the order of code is important 😇

Next, finding out your types sharing solution does not work well

Always fun to find out that some solution you chose is not really a solution at all, but hey, that happens! To me at least but I figure to all developers 🙂

Turns out that by specifying the project rootDir in the tsconfig.json will also affect where the files will be placed when building the project. I did some fiddling around with this and eventually came to the conclusion that moving the types to a separate 4th package in the project should work. This however was unknown territory for me, but I managed to get it to work.

So let's get to it! First off we create a packages/types/package.json file:

{
  "name": "types",
  "version": "0.0.0",
  "license": "MIT",
  "scripts": {
    "build": "tsc --build",
    "start": "tsc -w"
  },
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "dependencies": {},
  "devDependencies": {
    "typescript": "^4.6.4"
  }
}
Enter fullscreen mode Exit fullscreen mode

and a packages/types/tsconfig.json:

{
  "compilerOptions": {
    /* Basic Options */
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "target": "esnext",
    "module": "esnext"
  },
  "include": ["./src"]
}
Enter fullscreen mode Exit fullscreen mode

And adding { "path": "./packages/types" } to the references in the root tsconfig.json.

The types.ts file that was initially at the root of our project will move to packages/types/src/index.ts. That is basically it.

What we setup now is a separate package that will export some types that we can import in other projects by importing from types where this name is taken from the name key inside the package.json of that package. To make this work we do have to make sure that our types package is build, otherwise our IDE will complain.

To do that we are going to add and change some scripts in our root package.json:

// add
"types": "cd ./packages/types && yarn start && cd ../..",
"types:build": "cd ./packages/types && yarn build && cd ../.."

// change
"dev": "npm-run-all --parallel types portal server widget",
"build": "npm-run-all types:build portal:build widget:build
Enter fullscreen mode Exit fullscreen mode

Updating all types imports

Next we have to update our project everywhere we import from <relative_path>/types, this is needed in the following files:

  • packages/portal/src/components/ClientChat.vue
  • packages/portal/src/stores/client.ts
  • packages/server/types.ts
  • packages/widget/src/App.vue
  • packages/widget/src/stores/socket.ts

Also update the tsconfig.json of the other packages to remove the rootDir property and add "references": [{ "path": "../types" }] as a new property after the include array. Finally remove ../../types.ts from the include array in each file.

Checking if we can build

Let's run yarn run build to see what happens when all packages are build. You should see that a dist directory is created with 3 folders and a packages.json. If this is the first time you build the types packages you will see that some files inside a packages/types/dist folder are created. We need to commit those to the repository as well. But we do want to ignore those when linting, so in our .eslintignore we change /dist to dist. To ignore dist folders anywhere, not just at the root level.

We can run our server now by running:

node dist/server/index.js
Enter fullscreen mode Exit fullscreen mode

Which we will add as a script inside the root package.json as well for convenience: "start": "node dist/server/index.js",.

Getting ready for deployment - environment variables

Git diff for this section here

Our build server should run now but going to localhost:5000 will return Cannot GET / as our paths defined inside packages/server/index.ts are only correct for development 🤷. In fact it would make sense to only add this when we are running a build app, so a good use case to add environment variables to make some thing configurable based on development versus production, where with production I mean running the dist/server/index.js file produced by yarn run build.

Setting up environment variables

Two of our projects are Vite projects which will pick up .env files by default as documented here. I found out about this figuring out the best way to add environment variables, so I learned something new this part🎉.

We can create .env.<production|development> files which will be picked up by vite automatically at either build or development.

We will create the variable VITE_SOCKET_URL as that will not be the same during development and production.

Inside packages/portal/src/boot/socket.ts remove the URL declaration and instead do:

const socket = io(import.meta.env.VITE_SOCKET_URL, {
  autoConnect: false,
}); 
Enter fullscreen mode Exit fullscreen mode

Do the same for packages/widget/src/App.vue.

At this point typescript will complain so we have to inform it that we will supply this variable by adding to packages/widget/src/env.d.ts and packages/portal/src/env.d.ts:

interface ImportMetaEnv {
  readonly VITE_SOCKET_URL: string;
  // more env variables...
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
Enter fullscreen mode Exit fullscreen mode

And also add /// <reference types="vite/client" /> at the top of packages/portal/src/env.d.ts.

Providing the variables for widget and portal

Vite will pickup .env.development files when in development mode, so lets create packages/portal/.env.development and packages/widget/.env.development:

VITE_SOCKET_URL=http://localhost:5000
Enter fullscreen mode Exit fullscreen mode

To make VSCode highlight the files a bit better, add to .vscode/settings.json:

"files.associations": {
  "*.env.*": "env"
}
Enter fullscreen mode Exit fullscreen mode

Small improvement to package.json scripts

Along the way trying stuff out I found out that you can pass a cwd argument to yarn commands that will execute them in a specific working directory, eliminating the need to do cd <path> and cd ../.. in every script. So instead of:

cd ./packages/server && yarn start && cd ../..
Enter fullscreen mode Exit fullscreen mode

We can do:

yarn --cwd ./packages/server start
Enter fullscreen mode Exit fullscreen mode

Much better in my opinion so I changed all the scripts to use this pattern. Also I updated every script to call start when in development and build for building. This means changing the scripts inside the package.json of two packages.

In packages/widget/package.json rename the dev script to start, and update packages/portal/package.json scripts to contain:

"start": "quasar dev",
"build": "quasar build"
Enter fullscreen mode Exit fullscreen mode

Environment variables for the server

There is an important distinction between environment variables in the server compared to the widget and portal. The portal and the widget will run client side (in the browser) and any environment variables used there are read when the project is build, so they are compiled to static variables by rollup in our case. The server will run in nodeJS, which means that the variables mentioned there are not compiled at build time. They will need to be present at runtime. So at the place we start our index.js the environment variables have to be present.

For the server we will have three variables:

  1. APP_ENV - to signal to our code if we run in production or development
  2. PORT - the port our server will listen at
  3. JWT_SECRET - the secret that is used to create our jwt tokens

Define them for typescript inside packages/server/env.d.ts:

declare namespace NodeJS {
  interface ProcessEnv {
    PORT: string;
    JWT_SECRET: string;
    APP_ENV: 'development' | 'production';
  }
}
Enter fullscreen mode Exit fullscreen mode

For development we can use defaults (in the code) for these variables, so that means we only will have to define them when we are deploying the app.

Let's set defaults, inside packages/server/index.ts we read and use the PORT variable:

// add these lines
import path from 'path';

const port = process.env.PORT || 5000;

// change
server.listen(port, () => {
  console.log(
    `Server started on port ${port} at ${new Date().toLocaleString()}`
  );
});
Enter fullscreen mode Exit fullscreen mode

We also serve the portal and widget only when APP_ENV is equal to production:

if (process.env.APP_ENV === 'production') {
  app.use(serveStatic(path.join(__dirname, './../../dist/widget')));
  app.use(serveStatic(path.join(__dirname, './../../dist/portal')));
}
Enter fullscreen mode Exit fullscreen mode

Finally we want to prevent that we run in production with the default JWT_SECRET if we somehow fail to provide it, so lets add a check for it, inside the try-catch before we call server.listen:

if (process.env.APP_ENV === 'production' && !process.env.JWT_SECRET) {
  throw new Error('Should provide JWT_SECRET env variable');
}
Enter fullscreen mode Exit fullscreen mode

Next update the packages/server/middleware/socket.ts and packages/server/routes/auth.ts to use the JWT_SECRET if present by inserting process.env.JWT_SECRET || after secret =.

Deploying an Heroku app

If you do not have an account at Heroku, create one here. Also install the Heroku CLI, which we will use to deploy our app.

In your Heroku dashboard create a new app. Go to the Settings tab and to Config vars, in here we will create two variables for now:

  1. JWT_SECRET - set this one to some long string
  2. APP_ENV - set this to production

Doing the deploy

Deploying to Heroku is done by pushing code from a certain branch to a repository that comes with your heroku app. First login with the Heroku CLI if you have not done so yet:

heroku login
Enter fullscreen mode Exit fullscreen mode

After that we need to add our heroku app as an extra remote in git we can push to. We can do that by running:

heroku git:remote -a <name-of-your-app>
Enter fullscreen mode Exit fullscreen mode

Fill in the name of your app that you have chosen upon creating it, in my case that was embeddable-chat-widget-part-5. Once that is run you can check that a remote was added by running git remote -v, and you should see a remote called origin and a remote called heroku.

To push our code to heroku and start the deploy you need to run:

git push heroku main
// or
git push heroku <other-local-branch>:main
Enter fullscreen mode Exit fullscreen mode

and that will start the deploy, which will output in the command line.

Heroku will only deploy stuff you push to it's main branch or master branch. That is why you have to do :main if you want to push a different branch to a certain heroku app.

Fixes and stuff

If you have coded along and pushed the branch so far to heroku you will probably have seen a build error, and if not atleast things don't work as expected when opening the app. There are a couple of fixes needed, which I will highlight in the next sections.

Production .env file

When we were setting up environment variables we skipped defining them for production. We need to create two files packages/portal/.env.production and packages/widget/.env.production with the following content:

VITE_SOCKET_URL=https://<your-app-name>.herokuapp.com
Enter fullscreen mode Exit fullscreen mode

Where the URL should be the url of your heroku app.

Node engine

We currently specify in our root packages.json inside the engines property: "node": ">= 14" and Heroku will look at this to determine which node version to use when building our app. This will cause it to take the latest version available which is a non-lts version, which for some reason did not work for me. So change this to "node": "16.x", which will take the last version of version 16.

Using absolute path when serving portal and widget

Inside packages/server/index.ts we have to update the lines that use serveStatic

// Add at top
import path from 'path';

// Update
app.use(serveStatic(path.join(__dirname, './../../dist/widget')));
app.use(serveStatic(path.join(__dirname, './../../dist/portal')));
Enter fullscreen mode Exit fullscreen mode

Don't hardcode the login URL

Inside packages/portal/src/stores/auth.ts I forgot to update the login urls, which still harcode to localhost:5000, which will not work once deployed of course. We created an environment variable called VITE_SOCKET_URL for this.

// Replace login url to
`${import.meta.env.VITE_SOCKET_URL}/auth/login`

// Replace refresh_token url to
`${import.meta.env.VITE_SOCKET_URL}/auth/refresh_token`
Enter fullscreen mode Exit fullscreen mode

Widget package missing headers

When we get the widget package to use on a different site we have to send some headers along to allow different origins to use this package, so in packages/server/index.ts update:

app.use(serveStatic(path.join(__dirname, './../../dist/widget')));
// becomes
app.use(
  serveStatic(path.join(__dirname, './../../dist/widget'), {
    setHeaders: (res) => {
      res.header('Cross-Origin-Resource-Policy', 'cross-origin');
    },
  })
);
Enter fullscreen mode Exit fullscreen mode

Allow codepen origins

I want to demonstrate our setup later by importing the widget inside a codepen and using it there, to make that work we have to add 'https://cdpn.io' to our allowed cors origins inside packages/server/index.ts. Add it to both origin: [...] arrays in that file.

Fixing the bug mentioned earlier

Before I mentioned that by serving the portal and the widget caused a bug, and it has to do with the order of the code. When setting up express routes like /auth/<something> the order of setup matters. By using history mode and calling app.use(history()) it sets up a catch all listener for GET requests that will serve up the index.html. By placing this before the app.use('/auth') call, the GET routes inside of it will be intercepted by the history catch all listener.

So we have to move our serveStatic lines after the app.use('/auth'), in order to make it work as expected. I also placed the history() call inside the if statement, as that is only necessary when deploying.

// Move this
if (process.env.APP_ENV === 'production') {
  app.use(history());
  app.use(
    serveStatic(path.join(__dirname, './../../dist/widget'), {
      setHeaders: (res) => {
        res.header('Cross-Origin-Resource-Policy', 'cross-origin');
      },
    })
  );
Enter fullscreen mode Exit fullscreen mode

Wrapping up

After these changes you can push the changes to the heroku branch as before and it will redeploy.

Here is a video of it in action:

Demo of the app

You can check out my deployed app here. I made a test user account that you can login with:

There is also a codepen here which loads in the widget and displays it. This is done by including a script on the page with the source https://embeddable-chat-widget-part-5.herokuapp.com/widget.umd.js and then placing a <chat-widget/> element in the HTML, easy peasy👌

See you in the next part!

Top comments (0)