Reference repository for Medium story "How to build an Apollo GraphQL server with TypeScript and Webpack Hot Module Replacement".
Let’s build an Apollo GraphQL Server with TypeScript and Webpack HMR!
- Node.js with NPM installed on your computer. At the time of writing, Node.js version 6 or above is required by Apollo Server.
- Preferably basic understanding of the fundamental GraphQL principles.
- Preferably general knowledge with TypeScript. That being said, general JavaScript knowledge should be sufficient to understand the topics covered in this post. I will try my best to explain when it comes to TypeScript-specific concepts.
Create a new folder for the project and create a package.json
file by running npm-init
with default options:
$ mkdir apollo-server-demo && cd apollo-server-demo
$ npm init --yes
$ npm install --save-dev typescript
💡 Note:
package-lock.json
is automatically generated when you install any NPM package for the first time. This is a feature introduced since NPM version 5 and you SHOULD commitpackage-lock.json
along withpackage.json
if you are using source control like Git. James Quigley has written a post explaining whatpackage-lock.json
is, and why it is needed.
$ npx tsc --init --rootDir src --outDir dist --lib dom,es6 --module commonjs --removeComments
💡 Note:
npx
is a tool (bundled with NPM version 5.2 or above) for running NPM packages that are installed in localnode_modules
folder. This post covers npx usage in detail.
This will create a tsconfig.json
file in your project’s root directory. Add include
and exclude
options to tsconfig.json
. Your tsconfig.json
should look like this (comments omitted).
📄 File: tsconfig.json
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"lib": [ "dom","es6" ],
"outDir": "dist",
"rootDir": "src",
"removeComments": true
},
"exclude": [ "node_modules" ],
"include": [ "src/**/*.ts" ]
}
Create new a folder named src
and create a new file main.ts
in the folder:
📄 File: src/main.ts
console.log('Hello World!');
Now try to compile TypeScript codes:
$ npx tsc
Once compiled, you will notice a new dist
folder is created. TypeScript Compiler knows where to find the input TypeScript files and where to output the compiled file(s) because we have specified the options in tsconfig.json
from Step 2.
Now try to run the compiled JavaScript file:
$ node dist/main
And you should see the output:
> Hello World!
Now, let’s say we want to modify main.ts
to display a message other than Hello World
:
📄 File: src/main.ts
// console.log('Hello World!');
console.log('foo bar');
In order to see the changes in action, we will need to re-compile the TypeScript codes, and re-run the compiled JavaScript file:
$ npx tsc && node dist/main
> foo bar
As you can see, compile-and-run soon became a tedious task, especially during the development phase. We need to find a way to automate this process.
Webpack is a JavaScript module bundler. It is also capable of transforming, bundling, or packaging just about any resource or asset.
We are going to use Webpack to transform and bundle codes written in TypeScript to JavaScript. Also, we are going to create Webpack configuration files targeting specific environments (namely development
and production
). Moreover, we are going to enable Webpack’s Hot Module Replacement (HMR) to speed up development.
$ npm install --save-dev @types/webpack-env clean-webpack-plugin ts-loader webpack webpack-cli webpack-merge webpack-node-externals
📦 @types/webpack-env: TypeScript type definitions for Webpack.
📦 clean-webpack-plugin: We use this plugin to clean up our output dist
folder every time before Webpack builds our code.
📦 ts-loader: TypeScript loader for Webpack.
📦 webpack and webpack-cli: Webpack essentials.
📦 webpack-merge: We use this plugin to manage our environment-specific configuration files (development
, production
, etc. ).
📦 webpack-node-externals: Exclude Node modules that should not be bundled.
Up to this point, package.json
should look similar to this:
{
"name": "apollo-server-demo",
"private": true,
"devDependencies": {
"@types/webpack-env": "^1.14.1",
"clean-webpack-plugin": "^3.0.0",
"ts-loader": "^6.2.1",
"typescript": "^3.6.4",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.10",
"webpack-merge": "^4.2.2",
"webpack-node-externals": "^1.7.2"
}
}
We are going to create 3 Webpack configuration files:
🔧 webpack.development.js
: Development-specific Webpack configurations. For example, enable watch
option, Hot Module Replacement (HMR), etc.
🔧 webpack.production.js
: Production-specific Webpack configurations. For example, enable Webpack’s production mode.
🔧webpack.common.js
: Webpack configurations which apply to all environments. Both webpack.development.js
and webpack.production.js
“inherits” common configurations using the webpack-merge plugin.
💡 Note: You may refer to this guide from Webpack for more detailed setup.
📄 File: webpack.common.js
const path = require('path');
module.exports = {
module: {
rules: [
{
exclude: [path.resolve(__dirname, 'node_modules')],
test: /\.ts$/,
use: 'ts-loader'
}
]
},
output: {
filename: 'server.js',
path: path.resolve(__dirname, 'dist')
},
resolve: {
extensions: ['.ts', '.js']
},
target: 'node'
};
📄 File: webpack.development.js
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const path = require('path');
const webpack = require('webpack');
const common = require('./webpack.common.js');
module.exports = merge.smart(common, {
devtool: 'inline-source-map',
entry: ['webpack/hot/poll?1000', path.join(__dirname, 'src/main.ts')],
externals: [
nodeExternals({
whitelist: ['webpack/hot/poll?1000']
})
],
mode: 'development',
plugins: [new CleanWebpackPlugin(), new webpack.HotModuleReplacementPlugin()],
watch: true
});
📄 File: webpack.production.js
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const path = require('path');
const common = require('./webpack.common.js');
module.exports = merge(common, {
devtool: 'source-map',
entry: [path.join(__dirname, 'src/main.ts')],
externals: [nodeExternals({})],
mode: 'production',
plugins: [new CleanWebpackPlugin()]
});
File: src/main.ts
console.log('Hello World!');
// Hot Module Replacement
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => console.log('Module disposed. '));
}
In Step 2, we have decided to name Webpack configuration files as webpack.development.ts
and webpack.production.js
for a reason: development
and prodcution
are actually referring to the value of NODE_ENV
environment variable. By utilizing the value specified in NODE_ENV
, we can easily switch between different environment configurations with NPM scripts defined in package.json
. This approach is especially useful if you are setting up deployment strategies later on with Docker, Heroku, etc
(TODO: I am going to write a separate post to cover various application deployment strategies in detail. )
📄 File: package.json
{
"name": "apollo-server-demo",
"private": true,
"scripts": {
"build": "webpack --config webpack.$NODE_ENV.js",
"start": "node dist/server"
},
"devDependencies": {
"@types/webpack-env": "^1.14.1",
"clean-webpack-plugin": "^3.0.0",
"ts-loader": "^6.2.1",
"typescript": "^3.6.4",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.10",
"webpack-merge": "^4.2.2",
"webpack-node-externals": "^1.7.2"
}
}
For example, if we want to build with Webpack’s production configurations, we could simply run:
$ NODE_ENV=production npm run build
And you will see a compiled server.js
file in the dist
folder.
5️. Verify Webpack HMR is Setup Correctly:
Run NPM build with development
configurations:
$ NODE_ENV=development npm run build
which should display an output similar to:
webpack is watching the files…
Hash: 23976326d5ac19cc44e4
Version: webpack 4.28.3
Time: 1191ms
Built at: 01/01/2019 10:23:34 PM
Asset Size Chunks Chunk Names
main.js 76.3 KiB main [emitted] main
Entrypoint main = main.js
[0] multi webpack/hot/poll?1000 ./src/main.ts 40 bytes {main} [built]
[./node_modules/webpack/hot/log-apply-result.js] (webpack)/hot/log-apply-result.js 1.27 KiB {main} [built]
[./node_modules/webpack/hot/log.js] (webpack)/hot/log.js 1.11 KiB {main} [built]
[./node_modules/webpack/hot/poll.js?1000] (webpack)/hot/poll.js?1000 1.15 KiB {main} [built]
[./src/main.ts] 172 bytes {main} [built]
Now, open a new Terminal and run the compiled code:
$ npm start
Which should display output:
> Hello World!
To test Webpack’s HMR, try to modify src/main.ts
to output a message other than "Hello World"
.
📄 File: main.ts
// console.log('Hello World!');
console.log('foo bar');
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => console.log('Module disposed. '));
}
Once you’ve saved the changes, you will see the following output:
Module disposed.
foo bar
[HMR] Updated modules:
[HMR] - ./src/main.ts
[HMR] Update applied.
Now we confirmed Webpack HMR is working with TypeScript! You may press ctrl
+ c
on the keyboard to stop the build
and run
processes on both Terminals.
We have built the foundation with TypeScript and Webpack. It’s time to introduce Apollo Server!
Apollo Server is actually part of the Apollo GraphQL Platform. It is an open-sourced library from the Meteor Development Group (MeteorJS, anyone?), which provides a suite of tools to create GraphQL server embracing best practices for the industry.
In its simplest form, a GraphQL server is made up of three core components:
- GraphQL server library, such as Apollo Server
- Schemas: What types of data are available
- Resolvers: How to fetch the data required
To demonstrate how easy it is to set up an Apollo Server, we are going to create a minimal server which contains only one Query.
$ npm install --save apollo-server graphql
📦 apollo-server: The Apollo Server library.
📦 graphql: The library used to build and execute GraphQL schemas.
📄 File: src/type-defs.ts
import { gql } from 'apollo-server';
export default gql`
type Query {
"""
Test Message.
"""
testMessage: String!
}
`;
This file uses the Schema Definition Language (DSL) to define what type(s) of data is available from the server. In this case, we have defined a testMessage
query is available, which returns a string.
You may also notice the triple-double quotes “”” in the code. These are markdown-enabled comments within the schema supported by GraphQL. It helps data consumers to discover and understand the types of data provided by the server from tools like GraphQL Playground.
📄 File: src/resolvers.ts
export default {
Query: {
testMessage: (): string => 'Hello World!',
},
};
Resolvers are simply pure functions defining how data are fetched when requested. In general, every resolver in a GraphQL schema accepts four positional arguments:
fieldName(obj, args, context, info) { result }
That being said, in our example, since our testMessage
query always return a constant Hello World!
string, we do not need to worry about the arguments passing into the resolver function for now.
💡 Note: As your project grows, your Schemas and Resolvers will get more complex and will be difficult to maintain in one single file. Apollo has provided suggestions to modularize your code. Alternatively, the merge-graphql-schemas NPM package is another good option to consider when modularizing your GraphQL code.
UPDATE: Checkout GraphQL Modules, which provides toolset of libraries which helps to build modularized code that scales!
📄 File: src/main.ts
import { ApolloServer } from 'apollo-server';
import resolvers from './resolvers';
import typeDefs from './type-defs';
const server = new ApolloServer({ resolvers, typeDefs });
server.listen()
.then(({ url }) => console.log(`Server ready at ${url}. `));
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => server.stop());
}
We have created our first Apollo Server with the schema and resolver defined in Step 2 and Step 3.
We have fired up our Apollo Server with default options, which will be accessible via http://localhost:4000.
Finally, we have added the code to stop the server running when HMR kicks in, and re-start the server after when HMR completes.
Open a new Terminal to build the server in development
mode:
$ NODE_ENV=development npm run build
Open another Terminal to start the server:
$ npm start
Open a Web browser and navigate to http://localhost:4000, which should bring you to the Server’s GraphQL Playground.
Now if you try to input query and click the “Play”
query {
testMessage
}
It should output the result:
{
"data": {
"testMessage": "Hello World!"
}
}
You can also read the schema’s documentation by clicking the DOCS tag on the right-hand side of the screen.
Great! We have built our first Apollo Server! 👏🏻
Our Apollo server is now up and running which is great…but our server is running with default options. When building a real-world production app, chances are you will need to provide custom options when setting up your Apollo Server, especially when the Apollo Server API comes with a whole list of configuration options available for tweaking.
For example, you might want to use a port other than 4000
when running the server in production. Or for any reason, you might want to enable GraphQL Playground in production (GraphQL Playground is disabled when NODE_ENV
is set to production
by default).
We want our server to be flexible enough to handle various environment-specific configurations. How do we achieve this?
If you have experience in building Javascript applications, you probably already know you can access environment variables via the process.env
object in your JavaScript apps:
# Set environment variable `FOO` in Terminal
$ export FOO=bar
# Or in Windows Command Line
$ set FOO=bar
// your-app.js
console.log(provess.env.FOO); // OUTPUT: bar
This works fine…until you need to switch between projects with a list of environment variables with different values.
Luckily, there is an NPM package called dotenv which aims to address this issue.
The concept is pretty simple:
-
Define all your environment-specific variables in a single plain text file.
-
Reference the environment variables with the
process.env
object throughout your code. -
Run the code with
--require dotenv/config
option.
$ npm install --save-dev dotenv
📄 File: .env
PORT=4001
APOLLO_INTROSPECTION=true
APOLLO_PLAYGROUND=true
💡Note: You SHOULD NOT commit .env
if you are using any source control. For example, add .env
to .gitignore
if you are using Git.
While you could access environment variables directly with process.env
, I encourage you to create a file to hold all your environment variables. This will be much easier if you need to make any changes to the variables or refactoring down the track.
📄 File: src/environment.ts
const defaultPort = 4000;
interface Environment {
apollo: {
introspection: boolean,
playground: boolean
},
port: number|string;
}
export const environment: Environment = {
apollo: {
introspection: process.env.APOLLO_INTROSPECTION === 'true',
playground: process.env.APOLLO_PLAYGROUND === 'true'
},
port: process.env.PORT || defaultPort
};
We’ve added introspection and playground options to be controlled by environment variables. We’ve also asked our server to run on the port specified on the environment’s PORT
variable.
📄 File: main.ts
import { ApolloServer } from 'apollo-server';
import { environment } from './environment';
import resolvers from './resolvers';
import typeDefs from './schemas';
const server = new ApolloServer({
resolvers,
typeDefs,
introspection: environment.apollo.introspection,
playground: environment.apollo.playground
});
server.listen(environment.port)
.then(({ url }) => console.log(`Server ready at ${url}. `));
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => server.stop());
}
📄 File: package.json
{
"name": "apollo-server-demo",
"private": true,
"scripts": {
"build": "webpack --config webpack.$NODE_ENV.js",
"start": "node dist/server",
"start:env": "node --require dotenv/config dist/server",
},
"dependencies": {
"apollo-server": "^2.9.7",
"graphql": "^14.5.8"
},
"devDependencies": {
"@types/webpack-env": "^1.14.1",
"clean-webpack-plugin": "^3.0.0",
"dotenv": "^8.2.0",
"ts-loader": "^6.2.1",
"typescript": "^3.6.4",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.10",
"webpack-merge": "^4.2.2",
"webpack-node-externals": "^1.7.2"
}
}
Open a new Terminal to build the server in development
mode:
$ NODE_ENV=development npm run build
Open another Terminal to start the server with dotenv
:
$ npm run start:env
Your Apollo Server should be accessible via http://localhost:4001.
You can also try to change the APOLLO_PLAYGROUND
value in .env
to false, re-run your build
and start:env
NPM scripts and confirm GraphQL Playground is no longer accessible via http://localhost:4001 on the web browser.
In this post, we have covered how to build an Apollo GraphQL Server with TypeScript and Webpack HMR enabled with minimal setup.
I believe GraphQL is the next generation of API, and the Apollo GraphQL toolsets sound very promising. Therefore, I think it is worth to invest time and effort to learn this technology.
We are only scratching the surface here for GraphQL and the Apollo Platform. In fact, there are a lot more to be covered. Below are some of the topics that I am thinking of to be covered next. Please leave comments and let me know which ones you’re interested in 😉
🍃 Apollo Server: Connect to MongoDB with Mongoose
🔐 Apollo Server: Authentication and Authorization with JWT
✅ Apollo Server: Test schema and resolvers
🐳 Apollo Server: Deploy dockerized Apollo Server on Heroku
🌚 CircleCI: Test-build-deploy with CircleCI 2.1 Orbs
🏎Apollo Engine: Production-ready
🎨Apollo Client: Angular + NativeScript or React + ReactNative