Skip to content

Latest commit

 

History

History
648 lines (502 loc) · 24.5 KB

File metadata and controls

648 lines (502 loc) · 24.5 KB

Step 3: Setup a basic Node.JS server with a basic REST endpoint

Currently we have a running app with a single screen which looks stylish and presents some data to the user.

There is something missing though - The data that we are displaying can't be changed in any way.

But even if we change the data there is still a more fundamental issue - all of the data lives on the client.

That means that each client has its own copy of the data and the data is not shared between them, if a client creates a new message, only that client will have the new message and not the client the message was sent to.

Also if the client shuts down, all the data will be lost.

So how can we have a place to put the data that is being shared between all clients?

We should find a central machine that all clients will connect to and get the data from. If some client wants to create a new message, it will create it on that central machine so that the next time another clients will ask for the available messages, all those messages will be available on the central machine.

That central machine that stores data is called a database and the machine that communicates between the database and the client is called a server.

In this step, we will write a NodeJS server (server that runs using the Javascript language) and will expose a REST endpoint that will serve the data-mock. We will build the REST application using Express. Later in this tutorial we will migrate to using a real data-base with real I/O from the user, because at this point, if the server shuts down all data will be lost.

The plan is to have a server up and running at localhost:4000 that will expose a GET /chats route. Unlike our client application, we're not gonna use any boilerplate and we're gonna set everything up manually.

Right outside the client project, we will create a new directory called whatsapp-clone-server in which we will start creating our server:

$ mkdir whatsapp-clone-server
$ cd whatsapp-clone-server

Then we will use Yarn to initialize a new project:

$ yarn init -yp

There's nothing special about this command, it only creates a basic package.json file. Just to make sure that things work, we will add an index.js file which will print "hello world" to the console.

Added index.js
@@ -0,0 +1 @@
+┊ ┊1┊console.log('hello world')

And we will add a startup script to the package.json file called start:

"scripts": {
  "start": "node index.js"
}

TODO: Format on save

NPM-scripts are just a way to define an alias for commands. Now we only have one simple script, but it can turn out to be something very complex depending on our server, so it can be very useful. More about npm-scripts can be found in the official NPM docs.

Now we can run our server by running $ yarn start and we should see the message "hello world" printed to the console, as expected.

Like in our client's app, we will be using TypeScript. In order to use TypeScript we will install a few packages:

$ yarn add --dev typescript ts-node @types/node

Note how we used the --dev flag. It is a good practice to separate between production dependencies and development dependencies. That way when you deploy your server to the real environment, you won't install the unnecessary development dependencies there. More about the --dev option can be read in the NPM-install docs.

  • The typescript package is TypeScript's core transpiler.
  • ts-node is an interpreter that will transpile required .ts files into JavaScript at runtime.
  • @types/node will make the appropriate definitions for a Node.JS environment.

You can read more about the @types monorepo in the official GitHub repository.

We will rename the index.js file to index.ts:

$ mv index.js index.ts

Now we need to compile the ts file to turn it into a Javascript file the Node can run.

For that we will use Typescript and its tsc command. The command has many options, but instead of writing them in the command line, we can specify them in a tsconfig.json file at the root of the project.

Our server is gonna use the following tsconfig.json file, feel free to make the necessary modifications based on your needs:

Added tsconfig.json
@@ -0,0 +1,13 @@
+┊  ┊ 1┊{
+┊  ┊ 2┊  "compilerOptions": {
+┊  ┊ 3┊    "target": "es2020",
+┊  ┊ 4┊    "module": "commonjs",
+┊  ┊ 5┊    "skipLibCheck": true,
+┊  ┊ 6┊    "strict": true,
+┊  ┊ 7┊    "strictFunctionTypes": false,
+┊  ┊ 8┊    "strictPropertyInitialization": false,
+┊  ┊ 9┊    "esModuleInterop": true,
+┊  ┊10┊    "experimentalDecorators": true,
+┊  ┊11┊    "emitDecoratorMetadata": true
+┊  ┊12┊  }
+┊  ┊13┊}

Now let's run tsc and see what happens.

We've got a new index.js file! Now let's run it by running node index.js.

That's great, but doing this work each time we change a file can be annoying, so let's use tools to track when files change and make them run the code automatically after.

Let's update the npm-script start to use ts-node, since we wanna use TypeScript, and not JavaScript directly:

"start": "ts-node index.ts"

We can test the startup of our server again by running $ yarn start and we should see the message "hello world" printed to the console.

The skeleton of the project is set and we can move on to implementing the REST API.

Like we said at the beginning, we will be using Express to setup the API. Express is a wrapper around the native Node.JS "http" library which is responsible for handling HTTP requests. Yes, it can also be used directly, but Express is much more comfortable and has an amazing ecosystem built around it. Let's install Express and its TypeScript definitions:

$ yarn add express
$ yarn add --dev @types/express

Before we implement the GET /chats route we will implement a GET /_ping route. This route will be used to determine whether the server is up and running, and how fast the connection is based on the response time. For every request sent to this route, we should expect a response saying "pong". Some call it "heartbeat", because this route is being tested repeatedly by the hosting machine to check if it's alive, just like a heartbeat in a way. This is how the route should look like:

Changed index.ts
@@ -1 +1,13 @@
-┊ 1┊  ┊console.log('hello world')
+┊  ┊ 1┊import express from 'express'
+┊  ┊ 2┊
+┊  ┊ 3┊const app = express()
+┊  ┊ 4┊
+┊  ┊ 5┊app.get('/_ping', (req, res) => {
+┊  ┊ 6┊  res.send('pong')
+┊  ┊ 7┊})
+┊  ┊ 8┊
+┊  ┊ 9┊const port = process.env.PORT || 4000
+┊  ┊10┊
+┊  ┊11┊app.listen(port, () => {
+┊  ┊12┊  console.log(`Server is listening on port ${port}`)
+┊  ┊13┊})

We can use the

    $ curl localhost:4000/_ping

command to send a request to the server and we should get a "pong", assuming that the server available on that URL.

Code formatting

Just like we talked in the first chapter, some developers write code in a different style than others and since we want to make it consistent, we're going to use Prettier.

$ yarn add --dev prettier

We're going to define a npm script called format, few styling rules and we're also going to ignore node_modules:

Added .prettierignore
@@ -0,0 +1,2 @@
+┊ ┊1┊node_modules
+┊ ┊2┊.prettierrc.yml🚫↵
Added .prettierrc.yml
@@ -0,0 +1,2 @@
+┊ ┊1┊singleQuote: true
+┊ ┊2┊parser: 'typescript'
Changed package.json
@@ -7,11 +7,13 @@
 ┊ 7┊ 7┊  },
 ┊ 8┊ 8┊  "private": true,
 ┊ 9┊ 9┊  "scripts": {
-┊10┊  ┊    "start": "ts-node index.ts"
+┊  ┊10┊    "start": "ts-node index.ts",
+┊  ┊11┊    "format": "prettier \"**/*.ts\" --write"
 ┊11┊12┊  },
 ┊12┊13┊  "devDependencies": {
 ┊13┊14┊    "@types/express": "4.17.6",
 ┊14┊15┊    "@types/node": "14.0.4",
+┊  ┊16┊    "prettier": "2.0.5",
 ┊15┊17┊    "ts-node": "8.10.1",
 ┊16┊18┊    "typescript": "3.9.3"
 ┊17┊19┊  },

Now let's run:

$ yarn format

Prettier should format your code:

Changed index.ts
@@ -1,13 +1,13 @@
-┊ 1┊  ┊import express from 'express'
+┊  ┊ 1┊import express from 'express';
 ┊ 2┊ 2┊
-┊ 3┊  ┊const app = express()
+┊  ┊ 3┊const app = express();
 ┊ 4┊ 4┊
 ┊ 5┊ 5┊app.get('/_ping', (req, res) => {
-┊ 6┊  ┊  res.send('pong')
-┊ 7┊  ┊})
+┊  ┊ 6┊  res.send('pong');
+┊  ┊ 7┊});
 ┊ 8┊ 8┊
-┊ 9┊  ┊const port = process.env.PORT || 4000
+┊  ┊ 9┊const port = process.env.PORT || 4000;
 ┊10┊10┊
 ┊11┊11┊app.listen(port, () => {
-┊12┊  ┊  console.log(`Server is listening on port ${port}`)
-┊13┊  ┊})
+┊  ┊12┊  console.log(`Server is listening on port ${port}`);
+┊  ┊13┊});

Remember to run yarn prettier before you comit your changes!

The GET /chats should be implemented similarly, only the response is different. Instead of returning "pong" we will return the data-mock for our chats:

Added db.ts
@@ -0,0 +1,51 @@
+┊  ┊ 1┊export const messages = [
+┊  ┊ 2┊  {
+┊  ┊ 3┊    id: '1',
+┊  ┊ 4┊    content: 'You on your way?',
+┊  ┊ 5┊    createdAt: new Date(new Date('1-1-2019').getTime() - 60 * 1000 * 1000),
+┊  ┊ 6┊  },
+┊  ┊ 7┊  {
+┊  ┊ 8┊    id: '2',
+┊  ┊ 9┊    content: "Hey, it's me",
+┊  ┊10┊    createdAt: new Date(new Date('1-1-2019').getTime() - 2 * 60 * 1000 * 1000),
+┊  ┊11┊  },
+┊  ┊12┊  {
+┊  ┊13┊    id: '3',
+┊  ┊14┊    content: 'I should buy a boat',
+┊  ┊15┊    createdAt: new Date(new Date('1-1-2019').getTime() - 24 * 60 * 1000 * 1000),
+┊  ┊16┊  },
+┊  ┊17┊  {
+┊  ┊18┊    id: '4',
+┊  ┊19┊    content: 'This is wicked good ice cream.',
+┊  ┊20┊    createdAt: new Date(
+┊  ┊21┊      new Date('1-1-2019').getTime() - 14 * 24 * 60 * 1000 * 1000
+┊  ┊22┊    ),
+┊  ┊23┊  },
+┊  ┊24┊];
+┊  ┊25┊
+┊  ┊26┊export const chats = [
+┊  ┊27┊  {
+┊  ┊28┊    id: '1',
+┊  ┊29┊    name: 'Ethan Gonzalez',
+┊  ┊30┊    picture: 'https://randomuser.me/api/portraits/thumb/men/1.jpg',
+┊  ┊31┊    lastMessage: '1',
+┊  ┊32┊  },
+┊  ┊33┊  {
+┊  ┊34┊    id: '2',
+┊  ┊35┊    name: 'Bryan Wallace',
+┊  ┊36┊    picture: 'https://randomuser.me/api/portraits/thumb/men/2.jpg',
+┊  ┊37┊    lastMessage: '2',
+┊  ┊38┊  },
+┊  ┊39┊  {
+┊  ┊40┊    id: '3',
+┊  ┊41┊    name: 'Avery Stewart',
+┊  ┊42┊    picture: 'https://randomuser.me/api/portraits/thumb/women/1.jpg',
+┊  ┊43┊    lastMessage: '3',
+┊  ┊44┊  },
+┊  ┊45┊  {
+┊  ┊46┊    id: '4',
+┊  ┊47┊    name: 'Katie Peterson',
+┊  ┊48┊    picture: 'https://randomuser.me/api/portraits/thumb/women/2.jpg',
+┊  ┊49┊    lastMessage: '4',
+┊  ┊50┊  },
+┊  ┊51┊];
Changed index.ts
@@ -1,4 +1,5 @@
 ┊1┊1┊import express from 'express';
+┊ ┊2┊import { chats } from './db';
 ┊2┊3┊
 ┊3┊4┊const app = express();
 ┊4┊5┊
@@ -6,6 +7,10 @@
 ┊ 6┊ 7┊  res.send('pong');
 ┊ 7┊ 8┊});
 ┊ 8┊ 9┊
+┊  ┊10┊app.get('/chats', (req, res) => {
+┊  ┊11┊  res.json(chats);
+┊  ┊12┊});
+┊  ┊13┊
 ┊ 9┊14┊const port = process.env.PORT || 4000;
 ┊10┊15┊
 ┊11┊16┊app.listen(port, () => {

TODO: Mention _req

Check that we can get the chats by running:

    $ curl localhost:4000/chats

Unlike the previous route, we used the .json() method this time around to send a response. This will simply stringify the given JSON and set the right headers. Similarly to the client, we've defined the db mock in a dedicated file, as this is easier to maintain and look at.

It's also recommended to connect a middleware called cors which will enable cross-origin requests. Without it we will only be able to make requests in localhost, something which is likely to limit us in the future because we would probably host our server somewhere separate than the client application. Without it it will also be impossible to call the server from our client app. Let's install the cors library and load it with the Express middleware() function:

$ yarn add cors

and its Typescript types:

$ yarn add --dev @types/cors
Changed index.ts
@@ -1,8 +1,11 @@
+┊  ┊ 1┊import cors from 'cors';
 ┊ 1┊ 2┊import express from 'express';
 ┊ 2┊ 3┊import { chats } from './db';
 ┊ 3┊ 4┊
 ┊ 4┊ 5┊const app = express();
 ┊ 5┊ 6┊
+┊  ┊ 7┊app.use(cors());
+┊  ┊ 8┊
 ┊ 6┊ 9┊app.get('/_ping', (req, res) => {
 ┊ 7┊10┊  res.send('pong');
 ┊ 8┊11┊});

The server is now ready to use!

So getting back to the client, first we will define our server's URL under the .env file:

Added .env
@@ -0,0 +1 @@
+┊ ┊1┊REACT_APP_SERVER_URL=http://localhost:4000🚫↵

This will make our server's URL available under the process.env.REACT_APP_SERVER_URL member expression and it will be replaced with a fixed value at build time, just like macros. The .env file is a file which will automatically be loaded to process.env by the dotenv NPM package. react-scripts then filters environment variables which have a REACT_APP_ prefix and provides the created JSON to a Webpack plugin called DefinePlugin, which will result in the macro effect.

Now let's move back into our React app folder. We will now replace the local data-mock usage with a fetch from the server. For that we can use the native fetch API, however, it needs to be used in the right life-cycle hook of the React.Component.

There are 2 naive approaches for that:

  • Calling fetch() outside the component, but this way that chats will be fetched even if we're not even intending to create an instance of the component.
fetch().then(() => /* ... */)
const MyComponent = () => {}
  • Calling fetch() inside the component, but then it will be invoked whenever the component is re-rendered.
const MyComponent = () => {
  fetch().then(() => /* ... */)
}

These 2 approaches indeed work, but they both fail to deliver what's necessary on the right time. In addition, there's no way to properly coordinate async function calls with the render method of the component.

Introducing: React hooks

With React hooks we can invoke the desired logic in the right life-cycle stage of the target component. This way we can avoid potential memory leaks or extra calculations. To implement a proper fetch(), we will be using 2 React hooks:

  • React.useState() - which is used to get and set a state of the component - will be used to store the chats fetched from the server.
const [value, setValue] = useState(initialValue);
  • React.useMemo() - which is used to run a computation only once certain conditions were met - will be used to run the fetch() function only once the component has mounted.
const memoizedValue = useMemo(calcFn, [cond1, cond2, ...conds]);

The result of that approach will look like this, in the context of our ChatsList component:

Changed src/components/ChatsListScreen/ChatsList.tsx
@@ -1,8 +1,8 @@
 ┊1┊1┊import React from 'react';
-┊2┊ ┊import { chats } from '../../db';
 ┊3┊2┊import moment from 'moment';
 ┊4┊3┊import { List, ListItem } from '@material-ui/core';
 ┊5┊4┊import styled from 'styled-components';
+┊ ┊5┊import { useState, useMemo } from 'react';
 ┊6┊6┊
 ┊7┊7┊const Container = styled.div`
 ┊8┊8┊  height: calc(100% - 56px);
@@ -56,27 +56,37 @@
 ┊56┊56┊  font-size: 13px;
 ┊57┊57┊`;
 ┊58┊58┊
-┊59┊  ┊const ChatsList = () => (
-┊60┊  ┊  <Container>
-┊61┊  ┊    <StyledList>
-┊62┊  ┊      {chats.map((chat) => (
-┊63┊  ┊        <StyledListItem key={chat.id} button>
-┊64┊  ┊          <ChatPicture src={chat.picture} alt="Profile" />
-┊65┊  ┊          <ChatInfo>
-┊66┊  ┊            <ChatName>{chat.name}</ChatName>
-┊67┊  ┊            {chat.lastMessage && (
-┊68┊  ┊              <React.Fragment>
-┊69┊  ┊                <MessageContent>{chat.lastMessage.content}</MessageContent>
-┊70┊  ┊                <MessageDate>
-┊71┊  ┊                  {moment(chat.lastMessage.createdAt).format('HH:mm')}
-┊72┊  ┊                </MessageDate>
-┊73┊  ┊              </React.Fragment>
-┊74┊  ┊            )}
-┊75┊  ┊          </ChatInfo>
-┊76┊  ┊        </StyledListItem>
-┊77┊  ┊      ))}
-┊78┊  ┊    </StyledList>
-┊79┊  ┊  </Container>
-┊80┊  ┊);
+┊  ┊59┊const ChatsList = () => {
+┊  ┊60┊  const [chats, setChats] = useState<any[]>([]);
+┊  ┊61┊
+┊  ┊62┊  useMemo(async () => {
+┊  ┊63┊    const body = await fetch(`${process.env.REACT_APP_SERVER_URL}/chats`);
+┊  ┊64┊    const chats = await body.json();
+┊  ┊65┊    setChats(chats);
+┊  ┊66┊  }, []);
+┊  ┊67┊
+┊  ┊68┊  return (
+┊  ┊69┊    <Container>
+┊  ┊70┊      <StyledList>
+┊  ┊71┊        {chats.map((chat) => (
+┊  ┊72┊          <StyledListItem key={chat!.id} button>
+┊  ┊73┊            <ChatPicture src={chat.picture} alt="Profile" />
+┊  ┊74┊            <ChatInfo>
+┊  ┊75┊              <ChatName>{chat.name}</ChatName>
+┊  ┊76┊              {chat.lastMessage && (
+┊  ┊77┊                <React.Fragment>
+┊  ┊78┊                  <MessageContent>{chat.lastMessage.content}</MessageContent>
+┊  ┊79┊                  <MessageDate>
+┊  ┊80┊                    {moment(chat.lastMessage.createdAt).format('HH:mm')}
+┊  ┊81┊                  </MessageDate>
+┊  ┊82┊                </React.Fragment>
+┊  ┊83┊              )}
+┊  ┊84┊            </ChatInfo>
+┊  ┊85┊          </StyledListItem>
+┊  ┊86┊        ))}
+┊  ┊87┊      </StyledList>
+┊  ┊88┊    </Container>
+┊  ┊89┊  );
+┊  ┊90┊};
 ┊81┊91┊
 ┊82┊92┊export default ChatsList;
Deleted src/db.ts
@@ -1,49 +0,0 @@
-┊ 1┊  ┊export const messages = [
-┊ 2┊  ┊  {
-┊ 3┊  ┊    id: '1',
-┊ 4┊  ┊    content: 'You on your way?',
-┊ 5┊  ┊    createdAt: new Date(Date.now() - 60 * 1000 * 1000),
-┊ 6┊  ┊  },
-┊ 7┊  ┊  {
-┊ 8┊  ┊    id: '2',
-┊ 9┊  ┊    content: "Hey, it's me",
-┊10┊  ┊    createdAt: new Date(Date.now() - 2 * 60 * 1000 * 1000),
-┊11┊  ┊  },
-┊12┊  ┊  {
-┊13┊  ┊    id: '3',
-┊14┊  ┊    content: 'I should buy a boat',
-┊15┊  ┊    createdAt: new Date(Date.now() - 24 * 60 * 1000 * 1000),
-┊16┊  ┊  },
-┊17┊  ┊  {
-┊18┊  ┊    id: '4',
-┊19┊  ┊    content: 'This is wicked good ice cream.',
-┊20┊  ┊    createdAt: new Date(Date.now() - 14 * 24 * 60 * 1000 * 1000),
-┊21┊  ┊  },
-┊22┊  ┊];
-┊23┊  ┊
-┊24┊  ┊export const chats = [
-┊25┊  ┊  {
-┊26┊  ┊    id: '1',
-┊27┊  ┊    name: 'Ethan Gonzalez',
-┊28┊  ┊    picture: 'https://randomuser.me/api/portraits/thumb/men/1.jpg',
-┊29┊  ┊    lastMessage: messages.find((m) => m.id === '1'),
-┊30┊  ┊  },
-┊31┊  ┊  {
-┊32┊  ┊    id: '2',
-┊33┊  ┊    name: 'Bryan Wallace',
-┊34┊  ┊    picture: 'https://randomuser.me/api/portraits/thumb/men/2.jpg',
-┊35┊  ┊    lastMessage: messages.find((m) => m.id === '2'),
-┊36┊  ┊  },
-┊37┊  ┊  {
-┊38┊  ┊    id: '3',
-┊39┊  ┊    name: 'Avery Stewart',
-┊40┊  ┊    picture: 'https://randomuser.me/api/portraits/thumb/women/1.jpg',
-┊41┊  ┊    lastMessage: messages.find((m) => m.id === '3'),
-┊42┊  ┊  },
-┊43┊  ┊  {
-┊44┊  ┊    id: '4',
-┊45┊  ┊    name: 'Katie Peterson',
-┊46┊  ┊    picture: 'https://randomuser.me/api/portraits/thumb/women/2.jpg',
-┊47┊  ┊    lastMessage: messages.find((m) => m.id === '4'),
-┊48┊  ┊  },
-┊49┊  ┊];

It's recommended to read about React hooks and their basic concept at the official React docs page.

At this point we can get rid of db.ts file in the client, since we don't use it anymore:

$ rm src/db.ts

That's it. Our ChatsListScreen is now connected to a working back-end. In the next step we will upgrade our REST API into a GraphQL API and we will create a basis for a more robust back-end.


TODO:

First, tsc has a --watch option so that if the Typescript files changed it will compile them again and spit new Javascript files.

Then we need to rerun the Node server everytime the output Javascript files has changed. nodemon is a tool that tracks file and if the files changed it will re-run our node server.

Let's create a new npm script called "watch" and make it run both tools:

TODO: New diff

TODO: https://stackoverflow.com/a/39172660/1426570

TODO: Better watch, also watch and copy schema files (maybe in a later chapter)?

TODO: concurrently - because it works on all environments

TODO: Explain what -r register command does in Node and in Jest

TODO: Talk about the difference between graphql-import and graphql-import-node

TODO: Show debugging

It's a bit annoying that we get the compiled file right next to our Typescript file, so let's move it into a separate folder:

TODO: New diff for the lib folder update

TODO: why useMemo(fn, [true]) instead of useEffect(fn, []) ?

TODO: Move to hooks in a separate commit and later change to call the server

< Previous Step Next Step >