Part 5: Getting ready for deployment & deploy
Evertvdw
Posted on May 18, 2022
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'));
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
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"
}
}
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"]
}
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
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
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,
});
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;
}
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
To make VSCode highlight the files a bit better, add to .vscode/settings.json
:
"files.associations": {
"*.env.*": "env"
}
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 ../..
We can do:
yarn --cwd ./packages/server start
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"
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:
- APP_ENV - to signal to our code if we run in production or development
- PORT - the port our server will listen at
- 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';
}
}
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()}`
);
});
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')));
}
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');
}
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:
- JWT_SECRET - set this one to some long string
- 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
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>
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
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
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')));
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`
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');
},
})
);
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');
},
})
);
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:
You can check out my deployed app here. I made a test user account that you can login with:
- email: admin@admin.nl
- password: admin
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!
Posted on May 18, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.