April 2017: Web App, Step By Step

Web App, Step by Step

By Mark Volkmann, OCI Partner & Principal Engineer

APRIL 2017

Introduction

Many people talk about how easy it is to build web applications using Node.js. However, it's difficult to find resources that cover all the steps. We will do that here. Some details will be omitted. We want to focus on the primary steps.

This article makes specific technology/tool choices. Obviously, the steps may differ if different choices are made. The primary choices made here include Node.js, PostgreSQL, and React. This article was inspired by the Frontend Masters workshop "Zero to Production Node.js on Amazon Web Services", by Kevin Whinnery, where he made different tooling choices.

We will cover many topics including these:

  • Node.js
  • npm
  • Express
  • PostgreSQL
  • REST services
  • CORS
  • HTTPS
  • authentication with encrypted tokens
  • creating SSL certificates
  • React and create-react-app
  • socket.io for server to client communication
  • nodemon

The example application presented in this article is available at https://github.com/mvolkmann/ice-cream-app.

To get the most out of this article, I suggest that you run all the commands and download/create all the files as we walk through the process.

If you are working in Windows, most of the steps will be performed in a Command Prompt window. If you are working in macOS or Linux, most of the steps will be performed in a Terminal window. Throughout the remainder of this article, we'll refer to both as simply a "terminal."


Node.js

Node can be used to develop many kinds of applications, including command-line apps, web apps (with HTTP/HTTPS servers), and desktop apps (see http://electron.atom.io/). The main Node.js website is at https://nodejs.org/. It contains this excellent summary:

"Node.js is a JavaScript runtime built on Chrome's V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient."

The "DOCS" link at the top of this page contains excellent documentation. Refer to this later when reading through the implementation of the REST services.

On the main Node.js website, press the large green button that says "Current" to download the current version of Node.js. Double-click the file that is downloaded to run the installer. After the install completes, open a terminal and enter the following to verify that it worked:

  1. node -v

Installing Node also installs npm, which is the "Node Package Manager" (despite claims that it is not an acronym). npm makes it easy to install code libraries (a.k.a. packages) and tools from the npm repository. To discover the available packages, browse https://www.npmjs.com/. As of the date of this article, the npm repository hosts over 400,000 packages.

Node applications typically have a file named package.json that describes the app, including the packages on which it depends. To create a package.json file for a new app, enter "npm init". To install a dependency for an app and record it in package.json, enter "npm install -Spackage". For dependencies related to development tasks that are not needed when the app runs, use the -D option instead of -S.

Here are the steps to build and run a "Hello World" command-line app. It uses a library named "chalk" (https://www.npmjs.com/package/chalk) in order to demonstrate use of npm packages. Chalk bills itself as "Terminal string styling done right."

  1. Create a directory for the app and cd to it.
  2. Run npm init to create package.json. This will ask several questions. It is okay to accept all the defaults.
  3. To install chalk, enter "npm install -S chalk".
  4. Create the file index.js and enter the following code:
  1. const chalk = require('chalk');
  2. const name = process.argv[2];
  3. console.log('Hello, ' + chalk.red.bold(name) + '!');
  1. process.argv holds command-line arguments. The first value is the path to the node executable. The second value is the path to the file being executed. The third value (at index 2) is the first command-line argument.

  2. To run this, enter "node index.js your-name".


Express

A server is a program that listens for requests and produces responses. The most common is a HTTP server that uses request and response messages with a specific format. It can process requests for files and requests to perform specific operations. Often processing involves interacting with a database to perform CRUD operations. CRUD stands for "Create Retrieve Update Delete", which are the most common tasks performed with data.

Below is a simple example of a Node HTTP server that serves files from a directory named "public" and responds to requests for the current year. It uses the Express package, which is a "fast, unopinionated, minimalist web framework for Node". For more information, see https://github.com/expressjs/express. To install it, enter the following from your app directory: npm install -S express

Place the following code in a file named server.js:

  1. const express = require('express');
  2. const app = express();
  3.  
  4. // __dirname holds the file path to the current directory.
  5. app.use(express.static(__dirname + '/public'));
  6.  
  7. app.get('/year', (request, response) => {
  8. // request is an object that contains details
  9. // about the request that was received.
  10. // response is an object that contains methods
  11. // for populating the response to be sent.
  12. // These are often abbreviated as "req" and "res"
  13. // as you'll see in later code.
  14. // This explicitly passes a string to "send" because if a
  15. // number is passed it treats it as a response status code.
  16. response.send(new Date().getFullYear().toString());
  17. });
  18.  
  19. const listener = app.listen(1919, () => {
  20. console.log('browse http://localhost:' + listener.address().port);
  21. });

To run this, enter "node server.js" and browse localhost:1919/year.

To demonstrate serving files, follow these steps:

  1. Create a subdirectory named public.
  2. Create a file named demo.html in that directory.
  3. Add HTML to that file such as:

    Serving files works!

  4. Browse localhost:1919/demo.html


Database

The sample application uses the very popular, Open Source, relational database PostgreSQLNoSQL databases are another excellent choice. MongoDB is a NoSQL database that is popular in the Node community.

For help with PostgreSQL, see https://www.postgresql.org/docs/9.6/static/index.html.

For help on using the PostgreSQL psql command, which supports interacting with a database from a terminal, see http://postgresguide.com/utilities/psql.html.

For help with pg, an npm package for interacting with PostgreSQL databases, see https://github.com/brianc/node-postgres and https://github.com/brianc/node-postgres/wiki.

To install PostgreSQL in Windows, see https://www.postgresql.org/download/windows/.

To install PostgreSQL in macOS:

  1. Install Homebrew by following the instructions at http://brew.sh/.
  2. Enter the following: brew install postgresql

To start the database server, enter "pg_ctl -D /usr/local/var/postgres start".

To stop the database server later, enter "pg_ctl -D /usr/local/var/postgres stop -m fast".

We'll create three tables, ice_creamusers, and a table for mapping users to ice flavors named user_ice_creams. We'll also enable the pgcrypo extension so passwords can be encryped. Place the following Data Definition Language (DDL) commands in the file tables.ddl:

  1. create extension pgcrypto; -- needed to encrypt passwords
  2.  
  3. create table ice_creams (
  4. id serial primary key,
  5. flavor text
  6. );
  7.  
  8. create table users (
  9. username text primary key,
  10. password text -- encrypted
  11. );
  12.  
  13. create table user_ice_creams (
  14. username text references users(username),
  15. ice_cream_id integer references ice_creams(id)
  16. );

Next, we'll write a script that creates the database. It can also be used to recreate it in order to get a fresh start during testing.

  1. Create the script file.
    On Windows, enter "type null > recreatedb.cmd".
    On macOS and Linux, enter "touch recreatedb".
  2. Edit the file and add the following lines:
  1. db=ice_cream
  2. dropdb $db
  3. createdb $db
  4. psql -d $db -f tables.ddl
  1. On macOS and Linux, make this file executable by entering "chmod a+x recreatedb"
  2. Execute this file.
    On Windows, enter "recreatedb.cmd"
    On macOS and Linux, enter "./recreatedb"

Explore the newly created database with the following commands:

  1. To list the existing databases, enter "psql -l"
  2. To enter interactive mode for the ice_cream database, enter "psql -d ice_cream"
  3. To list the tables in the current database, enter "\d"
  4. To get information about the columns in the ice_creams table, enter "\d ice_creams"
  5. To get all the rows in the ice_creams table, enter "select * from ice_creams;"
    Of course, there is nothing to see yet, but try this again after the code adds data.
  6. To exit interactive mode, press ctrl-d.
     

Node and PostgreSQL

Many projects use Object/Relational Mapping (ORM) libraries to interact with relational databases rather than writing SQL. We forego that option here because SQL is a fairly universal skill and it is not hard to learn or use.

To interact with the PostgreSQL database from Node code, we use the npm package pg. More specifically, we use a set of functions provided by the npm package postgresql-easy that makes using the pg package even easier. Full disclosure, I created postgresql-easy while working on this article. Check it out! The code is fairly short and easy to read. To install it, enter "npm install -S postgresql-easy".

Another similar npm package to consider is pg-promise, which provides a promise-based API. If you aren't yet familiar with promises, you can find what you need to know at https://davidwalsh.name/promises.

Here is an example of performing some common operations on the database. This code uses the async and await keywords, which are being added to JavaScript in ES2017. They can be used now in Node version 7. The await keyword is placed in front of calls to functions that return a promise. It waits for the promise to be resolved before continuing. All functions that use await must be annotated with the async keyword.

  1. const PgConnection = require('postgresql-easy');
  2.  
  3. // Configure a connection to the database.
  4. const pg = new PgConnection({database: 'ice_cream'});
  5.  
  6. const tableName = 'ice_creams';
  7.  
  8. async function demo() {
  9. try {
  10. // Delete all rows from these tables:
  11. // user_ice_creams, users, and ice_cream.
  12. await pg.deleteAll('user_ice_creams');
  13. await pg.deleteAll('users');
  14. await pg.deleteAll(tableName);
  15.  
  16. // Insert three new rows corresponding to three flavors.
  17. // For each flavor it calls pg-insert which returns a promise.
  18. // flavors.map returns an array of these three promises.
  19. // Promise.all returns a promise that resolves when
  20. // all the promises in the array passed to it resolve.
  21. const flavors = ['vanilla', 'chocolate', 'strawberry'];
  22. const results = await Promise.all(
  23. flavors.map(f => pg.insert(tableName, {flavor: f})));
  24.  
  25. // Get an array of the ids of the new rows.
  26. const ids = results.map(result => result.rows[0].id);
  27. console.log('inserted records with ids', ids);
  28.  
  29. // Delete the first row (vanilla).
  30. await pg.deleteById(tableName, ids[0]);
  31.  
  32. // Change the flavor of the second row (chocolate) to "chocolate chip".
  33. await pg.updateById(tableName, ids[1], {flavor: 'chocolate chip'});
  34.  
  35. // Get all the rows in the table.
  36. const result = await pg.getAll(tableName);
  37.  
  38. // Output their ids and flavors.
  39. for (const row of result.rows) {
  40. console.log(row.id, row.flavor);
  41. }
  42.  
  43. // Catch and report any errors that occur in the previous steps.
  44. } catch (e) {
  45. throw e;
  46.  
  47. } finally {
  48. // Disconnect from the database.
  49. await pg.disconnect();
  50. }
  51. }
  52.  
  53. demo();

To run this code, enter node demo.js.


REST Services

Now that we have code for interacting with the database, we can implement services to do this and invoke them from a browser.

REST stands for "REpresentational State Transfer". To dig deeper into this, see this Wikipedia page. All you need to know for now is that REST services typically use the HTTP/HTTPS protocol for request and response messages, act on "resources", use carefully selected URLs to identify resources, and map HTTP verbs to CRUD operations. The mapping is as follows:

  • POST -> Create
  • GET -> Retrieve
  • PUT -> Update
  • DELETE -> Delete
  • POST -> everything else

There is an exception where PUT can be used to create a resource. Also, PATCH can be used for a partial update.

We will implement the REST services using the npm package Express that was described earlier.

These services require authentication. Note how each checks for this with a call to auth.authorize and returns if that returns false. This is implemented by the npm package node-token-auth that is described later.

At first glance, this may seem like a large amount of code. However, I think you'll agree that each piece is fairly small and simple.

  1. const auth = require('node-token-auth');
  2. const express = require('express');
  3. const PgConnection = require('postgresql-easy');
  4. const server = require('./server'); // described later
  5.  
  6. // Configure the algorithm and password used to encrypt auth tokens
  7. // and set the session timeout in minutes.
  8. const algorithm = 'aes-256-ctr';
  9. const key = 'V01kmann';
  10. const timeoutMinutes = 1;
  11. auth.configure(algorithm, key, timeoutMinutes);
  12.  
  13. const app = express();
  14. server.setup(app);
  15.  
  16. function handleError(res, err) {
  17. res.statusMessage = `${err.toString()}; ${err.detail}`;
  18. res.status(500).send();
  19. }
  20.  
  21. const pg = new PgConnection({
  22. database: 'ice_cream' // defaults to username
  23. //host: 'localhost', // default
  24. //idleTimeoutMillis: 30000, // before a client is closed (default is 30000)
  25. //max: 10, // max clients in pool (default is 10)
  26. //password: '', // not needed in this example
  27. //port: 5432, // the default
  28. //user: 'Mark' // not needed in this example
  29. });
  30.  
  31. /**
  32.  * Deletes an ice cream flavor from a given user.
  33.  * The URL will look like /ice-cream/some-user/some-flavor-id
  34.  * The "Authorization" request header must be set.
  35.  */
  36. app.delete('/ice-cream/:username/:id', async (req, res) => {
  37. if (!auth.authorize(req, res)) return;
  38.  
  39. // id is the id of the ice cream flavor to be deleted.
  40. const {id, username} = req.params;
  41.  
  42. const sql = `
  43. delete from user_ice_creams
  44. where username=$1 and ice_cream_id=$2`;
  45. try {
  46. await pg.query(sql, username, id);
  47. res.send();
  48. } catch (e) {
  49. handleError(res, e);
  50. }
  51. });
  52.  
  53. /**
  54.  * Retrieves all ice cream ids and flavors
  55.  * associated with a given user.
  56.  * The URL will look like /ice-cream/some-user
  57.  * The "Authorization" request header must be set.
  58.  */
  59. app.get('/ice-cream/:username', async (req, res) => {
  60. if (!auth.authorize(req, res)) return;
  61.  
  62. const {username} = req.params;
  63. const sql = `
  64. select ic.id, ic.flavor
  65. from ice_creams ic, user_ice_creams uic
  66. where uic.username='${username}'
  67. and uic.ice_cream_id=ic.id`;
  68. try {
  69. const result = await pg.query(sql);
  70. res.json(result.rows);
  71. } catch (e) {
  72. handleError(res, e);
  73. }
  74. });
  75.  
  76. /**
  77.  * Adds an ice cream flavor to a given user.
  78.  * The URL will look like /ice-cream/some-user?flavor=some-flavor
  79.  * The "Authorization" request header must be set.
  80.  * The response will contain the id of newly created user_ice_creams row.
  81.  */
  82. app.post('/ice-cream/:username', async (req, res) => {
  83. if (!auth.authorize(req, res)) return;
  84.  
  85. const {username} = req.params;
  86. const {flavor} = req.query;
  87.  
  88. // Creates a row in the user_ice_creams table
  89. // that associates an ice cream flavor with a user.
  90. async function associate(username, iceCreamId) {
  91. const sql = `
  92. insert into user_ice_creams (username, ice_cream_id)
  93. values ($1, $2)`;
  94. try {
  95. await pg.query(sql, username, iceCreamId);
  96. res.send(String(iceCreamId));
  97. } catch (e) {
  98. handleError(res, e);
  99. }
  100. }
  101.  
  102. // Get the id of the flavor if it already exists.
  103. let sql = 'select id from ice_creams where flavor=$1';
  104. try {
  105. const result = await pg.query(sql, flavor);
  106. const [row] = result.rows;
  107. if (row) { // The flavor exists.
  108. associate(username, row.id);
  109. } else { // The flavor doesn't exist, so create it.
  110. sql = 'insert into ice_creams (flavor) values ($1) returning id';
  111. const result = await pg.query(sql, flavor);
  112. const [row] = result.rows;
  113. if (row) {
  114. associate(username, row.id);
  115. } else {
  116. handleError(res, 'failed to create new flavor');
  117. }
  118. }
  119. } catch (e) {
  120. handleError(res, e);
  121. }
  122. });
  123.  
  124. /**
  125.  * Updates a record in the ice-cream table by id.
  126.  * The URL will look like /ice-cream/some-ice-cream-id?flavor=some-new-flavor
  127.  * The "Authorization" request header must be set.
  128.  */
  129. app.put('/ice-cream/:id', async (req, res) => {
  130. if (!auth.authorize(req, res)) return;
  131.  
  132. const {id} = req.params;
  133. const {flavor} = req.query;
  134. try {
  135. await pg.updateById('ice_creams', id, {flavor});
  136. res.send();
  137. } catch (e) {
  138. handleError(res, e);
  139. }
  140. });
  141.  
  142. /**
  143.  * Logs in a user.
  144.  * The username and password must be in the body
  145.  * and the content type must be "application/json".
  146.  */
  147. app.post('/login', async (req, res) => {
  148. const {username, password} = req.body;
  149. if (!username) {
  150. res.statusMessage = 'Missing Username';
  151. return res.status(400).send();
  152. }
  153. if (!password) {
  154. res.statusMessage = 'Missing Password';
  155. return res.status(400).send();
  156. }
  157.  
  158. const sql = `
  159. select password = crypt('${password}', password) as authenticated
  160. from users where username='${username}'`;
  161. try {
  162. const result = await pg.query(sql);
  163. const [row] = result.rows;
  164. if (row) {
  165. auth.generateToken(username, req, res);
  166. res.send(row.authenticated);
  167. } else {
  168. res.statusMessage = 'Username Not Found';
  169. res.status(404).send();
  170. }
  171. } catch (e) {
  172. handleError(res, e);
  173. }
  174. });
  175.  
  176. /**
  177.  * Logs out the current user.
  178.  * The "Authorization" request header must be set.
  179.  */
  180. app.post('/logout', (req, res) => {
  181. if (!auth.authorize(req, res)) return;
  182.  
  183. auth.deleteToken(req);
  184. res.send();
  185. });
  186.  
  187. /**
  188.  * Signs up a new user.
  189.  * The username and password must be in the body
  190.  * and the content type must be "application/json".
  191.  */
  192. app.post('/signup', async (req, res) => {
  193. const {username, password} = req.body;
  194. if (!username) {
  195. res.statusMessage = 'Missing Username';
  196. return res.status(400).send();
  197. }
  198. if (!password) {
  199. res.statusMessage = 'Missing Password';
  200. return res.status(400).send();
  201. }
  202.  
  203. // Encrypt password using a Blowfish-based ciper (bf)
  204. // performing 8 iterations.
  205. const sql = `
  206. insert into users (username, password)
  207. values('${username}', crypt('${password}', gen_salt('bf', 8)))`;
  208.  
  209. try {
  210. await pg.query(sql);
  211. auth.generateToken(username, req, res);
  212. res.send();
  213. } catch (e) {
  214. handleError(res, e);
  215. }
  216. });
  217.  
  218. server.start();

What about that "server" part we conveniently skipped over? When the server is hosting REST services and serving files needed by the browser (such as .html.css, and .js files), it can be very simple. However, if a separate server is used to serve those files then CORS must be configured to allow code in the browser to invoke REST services at a different domain.

CORS stands for "Cross-Origin Resource Sharing". It is needed to allow the client-side of a web app to retrieve resources (including REST service responses) that are hosted at a different domain than the client-side resources (such as HTML, CSS, JavaScript, and media files). A standard security feature in browsers prevents code downloaded from one domain from sending requests to another domain. CORS can selectively enable this. To learn more about CORS, see this page on the Mozilla Developer Network. For now, all you need to know is that the REST server needs to include certain headers in the responses it produces to enable this.

Another feature this server configures is the use of HTTPS instead of HTTP. This uses Transport Layer Security (TLS) to encrypt requests and decrypt responses. The default port for HTTPS is 443. The process that starts the server must have permission to use this port. Another option is to use a non-privileged port for HTTPS.

Here is the code in server.js that configures both CORS and HTTPS:

  1. const bodyParser = require('body-parser');
  2. const fs = require('fs');
  3. const https = require('https');
  4. const sio = require('socket.io');
  5.  
  6. let server;
  7.  
  8. function setup(app) {
  9. // Suppress the x-powered-by response header
  10. // so hackers don't get a clue that might help them.
  11. app.set('x-powered-by', false);
  12.  
  13. // Enable use of CORS.
  14. app.use((req, res, next) => {
  15. // This could be more selective!
  16. res.header('Access-Control-Allow-Origin', '*');
  17. res.header('Access-Control-Allow-Headers',
  18. 'Accept, Authorization, Content-Type, Origin, X-Requested-With');
  19. res.header('Access-Control-Expose-Headers',
  20. 'Authorization, Content-Type');
  21. res.header('Access-Control-Allow-Methods', 'GET,DELETE,POST,PUT');
  22. next();
  23. });
  24.  
  25. // The "extended" option of bodyParser enables parsing of objects and arrays.
  26. app.use(bodyParser.json({extended: true}));
  27.  
  28. // Enable use of HTTPS.
  29. // The two ".pem" files references are described later.
  30. const options = {
  31. key: fs.readFileSync('key.pem'),
  32. cert: fs.readFileSync('cert.pem'),
  33. passphrase: 'icecream'
  34. };
  35. server = https.createServer(options, app);
  36.  
  37. server.on('error', err => {
  38. console.error(err.code === 'EACCES' ? 'must use sudo' : err);
  39. });
  40. }
  41.  
  42. function start() {
  43. const PORT = 443;
  44. server.listen(PORT, () => console.log('listening on port', PORT));
  45.  
  46. // Configure Socket.IO to allow the server to communicate with the browser.
  47. // Our usage supports session timeouts.
  48. const io = sio.listen(server);
  49. io.on('connection', socket => global.socket = socket);
  50. }
  51.  
  52. module.exports = {setup, start};


Authentication and Authorization

We want to require users to register for an account before using the app. They do this by supplying a username and password of their choosing, after which they are automatically logged in. Login is required for all subsequent sessions.

Both registering a new account and logging into an existing account result in an authentication token being returned to the browser. The client-side code must include this token in all REST calls. This is supported by the npm package node-token-auth at https://www.npmjs.com/package/node-token-auth. The content of the encrypted tokens it creates is explained there. I created node-token-auth for this article. Check it out!

Another popular authentication option for Node is Passport at http://passportjs.org/.


Create a self-signed certificate

We want to use HTTPS for the website rather than HTTP for security reasons. This requires a SSL certificate. For a real website, this may be obtained for free using https://letsencrypt.org/. For testing purposes, an easier approach is to create a self-signed certificate using the openssl command as follows:

$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365
Generating a 4096 bit RSA private key
.................................++
.................................++
writing new private key to 'key.pem'
Enter PEM pass phrase:
Verifying - Enter PEM pass phrase:
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:{your-country-code}
State or Province Name (full name) [Some-State]:{your-state}
Locality Name (eg, city) []:{your-city}
Organization Name (eg, company) [Internet Widgits Pty Ltd]:{your-company}
Organizational Unit Name (eg, section) []:{your-division}
Common Name (e.g. server FQDN or YOUR name) []:{your-name}
Email Address []:{your-email}

This creates the files cert.pem and key.pem in the current directory.

The standard port to use with SSL is 443. Since this is a protected port, the server must be started with sudo. Otherwise, the following error message will be output.

Error: listen EACCES 0.0.0.0:443

If the passphrase supplied by the client is wrong, the following error will be reported:

Error: error:06065064:digital envelope routines:EVP_DecryptFinal_ex:bad decrypt

When the protocol is https and the default port (443) is used, the port does not need to be specified in URLs.

The curl command can be used test REST services like this:

curl -k https://localhost/{route}

The -k (or --insecure) option allows connections to SSL sites without certificates. This is useful when testing using self-signed certificates.


Running the Sample App

In the next section, we will discuss the implementation of a web UI. It will aid your understanding to install and run the app now. Here are the steps to do that:

  1. Open a terminal.
  2. Start the database daemon with "pg_ctl -D /usr/local/var/postgres start".
  3. cd to the directory where you'd like to install the project.
  4. git clone https://github.com/mvolkmann/ice-cream-app.git
  5. Enter "cd ice-cream-app/server".
  6. Enter "npm install".
    This installs all the dependencies found in ice-cream-app/server/package.json
  7. Start the REST server by entering "npm start".
    Another way to start the REST server is to use nodemon which is described later.
  8. Enter "cd .."
  9. Enter "npm install".
    This installs all the dependencies found in ice-cream-app/package.json.
  10. Configure the use HTTPS by the web server (instead of HTTP) by entering "export HTTPS=true".
  11. Start the web server by entering "npm start".
  12. Tell the browser to always trust the self-signed certificate by browsing https://localhost
  13. In Chrome, click "ADVANCED" and then "Proceed to localhost (unsafe)".
    In Firefox, press the "Advanced" button, press the "Add Exception..." button, and press the "Confirm Security Exception" button.
    In Safari, press "Show Certificate" button, select "Always Trust" in the "When using this certificate" dropdown, press the "Continue" button, and enter your password to confirm this action.
  14. Browse https://localhost:3000.


create-react-app

The easiest way to get started with a new React web app is to use create-react-app at https://github.com/facebookincubator/create-react-app.

  1. To install create-react-app, enter "npm install -g create-react-app".
  2. To create the web application, enter "create-react-app ice-cream-app".
    Depending on your computer, this will take around one minute.
  3. Enter "cd ice-cream-app".
  4. To run the generated React app, enter "npm start".
    You should see the following in your default web browser:
  1. Following the instructions on that page, edit src/App.js. Change the line "Welcome to React" to "Welcome to Ice Cream".
  2. When changes are saved, the browser window will automatically refresh to show the results.


Ice Cream App UI

The UI will consist of two pages. The first page is for user registration and login.


The second page is reached after successful user registration or login. It allows the user to add and delete ice cream flavors. Pressing the "Log out" button in the upper-right corner ends the session and returns the user to the login page.
 

Going into the details of React is beyond the scope of this article. However, hopefully the code will be readable even for those without React exposure. The code is heavily commented. If you are not particularly interested in learning about React, scan though the code to see where the REST calls are made and how the responses are handled.

public/index.html

This is a slightly modified version of the file generated by create-react-app:

  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta name="viewport" content="width=device-width, initial-scale=1">
  5.  
  6. <!--
  7. Notice the use of %PUBLIC_URL% in the tag below.
  8. It will be replaced with the URL of the `public` folder during the build.
  9. Only files inside the `public` folder can be referenced from the HTML.
  10. -->
  11. <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
  12.  
  13. <title>Ice Cream App</title>
  14. </head>
  15. <body>
  16. <!--
  17. This element is populated by React.
  18. See the reference to 'root' in src/index.js.
  19. -->
  20. <div id="root"></div>
  21. </body>
  22. </html>

src/index.js

This file was generated by create-react-app. It is the first JavaScript file that is loaded. The file index.html doesn't include a script tag for this. create-react-app causes this to be loaded. It is specified in node_modules/react-scripts/config/paths.js as appIndexJs and used inwebpack.config.*.js in that same directory. Note that this file imports App.js.

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import App from './App';
  4. import './index.css';
  5.  
  6. // This creates an instance of the App component and renders it
  7. // inside the div with an id of "root" in public/index.html.
  8. ReactDOM.render(
  9. <App />,
  10. document.getElementById('root')
  11. );

src/App.js

This is a heavily modified version of the file generated by create-react-app. See the comments for details.

  1. import React, {Component} from 'react';
  2. import Login from './login';
  3. import Main from './main';
  4. import 'whatwg-fetch'; // for REST calls
  5. import './App.css';
  6.  
  7. // This allows the client to listen to socket.io events
  8. // emitted from the server. It is used to proactively
  9. // terminate sessions when the session timeout expires.
  10. import io from 'socket.io-client';
  11.  
  12. class App extends Component {
  13. constructor() {
  14. super();
  15.  
  16. // Redux is a popular library for managing state in a React application.
  17. // This application, being somewhat small, opts for a simpler approach
  18. // where the top-most component manages all of the state.
  19. // Placing a bound version of the setState method on the React object
  20. // allows other components to call it in order to modify state.
  21. // Each call causes the UI to re-render,
  22. // using the "Virtual DOM" to make this efficient.
  23. React.setState = this.setState.bind(this);
  24.  
  25. // This gets a socket.io connection from the server
  26. // and registers for "session-timeout" events.
  27. // If one is received, the user is logged out.
  28. const socket = io('https://localhost', {secure: true});
  29. socket.on('session-timeout', () => {
  30. alert('Your session timed out.');
  31. this.logout();
  32. });
  33. }
  34.  
  35. // This is the initial state of the application.
  36. state = {
  37. authenticated: false,
  38. error: '',
  39. flavor: '',
  40. iceCreamMap: {},
  41. password: '',
  42. restUrl: 'https://localhost',
  43. route: 'login', // controls the current page
  44. token: '',
  45. username: ''
  46. };
  47.  
  48. /**
  49.   * Sends a logout POST request to the server
  50.   * and goes to the login page.
  51.   */
  52. logout = async () => {
  53. const url = `${this.state.restUrl}/logout`;
  54. const headers = {Authorization: this.state.token};
  55. try {
  56. await fetch(url, {method: 'POST', headers});
  57. React.setState({
  58. authenticated: false,
  59. route: 'login',
  60. password: '',
  61. username: ''
  62. });
  63. } catch (e) {
  64. React.setState({error: e.toString()});
  65. }
  66. };
  67.  
  68. render() {
  69. // Use destructuring to extract data from the state object.
  70. const {
  71. authenticated, error, flavor, iceCreamMap,
  72. password, restUrl, route, token, username
  73. } = this.state;
  74.  
  75. return (
  76. <div className="App">
  77. <header>
  78. <img className="header-img" src="ice-cream.png" alt="ice cream"/>
  79. Ice cream, we all scream for it!
  80. {
  81. authenticated ?
  82. <button onClick={this.logout}>Log out</button> :
  83. null
  84. }
  85. </header>
  86. <div className="App-body">
  87. {
  88. // This is an alternative to controlling routing to pages
  89. // that is far simpler than more full-blown solutions
  90. // like react-router.
  91. route === 'login' ?
  92. <Login
  93. username={username}
  94. password={password}
  95. restUrl={restUrl}
  96. /> :
  97. route === 'main' ?
  98. <Main
  99. flavor={flavor}
  100. iceCreamMap={iceCreamMap}
  101. restUrl={restUrl}
  102. token={token}
  103. username={username}
  104. /> :
  105. <div>Unknown route {route}</div>
  106. }
  107. {
  108. // If an error has occurred, render it at the bottom of any page.
  109. error ? <div className="error">{error}</div> : null
  110. }
  111. </div>
  112. </div>
  113. );
  114. }
  115. }
  116.  
  117. export default App;

src/App.css

All the CSS for this app resides here, with the exception of src/index.css, which is generated by create-react-app and doesn't require modification. It is outside of the scope of this article to cover CSS, but the source may be found in the Github repo.

src/login.js

This component allows new users to signup and existing users to log in.

  1. import React, {Component, PropTypes as t} from 'react';
  2. import 'whatwg-fetch';
  3.  
  4. function onChangePassword(event) {
  5. React.setState({password: event.target.value});
  6. }
  7.  
  8. function onChangeUsername(event) {
  9. React.setState({username: event.target.value});
  10. }
  11.  
  12. class Login extends Component {
  13.  
  14. static propTypes = {
  15. password: t.string.isRequired,
  16. restUrl: t.string.isRequired,
  17. username: t.string.isRequired
  18. };
  19.  
  20. // This is called when the "Log In" button is pressed.
  21. onLogin = async () => {
  22. const {password, restUrl, username} = this.props;
  23. const url = `${restUrl}/login`;
  24.  
  25. try {
  26. // Send username and password to login REST service.
  27. const res = await fetch(url, {
  28. method: 'POST',
  29. headers: {'Content-Type': 'application/json'},
  30. body: JSON.stringify({username, password})
  31. });
  32.  
  33. if (res.ok) { // successful login
  34. const token = res.headers.get('Authorization');
  35. const text = await res.text(); // returns a promise
  36. const authenticated = text === 'true';
  37. React.setState(
  38. authenticated ?
  39. {
  40. authenticated: true,
  41. error: null, // clear previous error
  42. route: 'main',
  43. token
  44. } :
  45. {
  46. error: 'Invalid username or password'
  47. });
  48. } else { // unsuccessful login
  49. const msg = /ECONNREFUSED/.test(res.statusText) ?
  50. 'Failed to connect to database' :
  51. res.statusText;
  52. React.setState({error: msg});
  53. }
  54. } catch (e) {
  55. React.setState({error: `${url}; ${e.message}`});
  56. }
  57. }
  58.  
  59. // This is called when the "Signup" button is pressed.
  60. onSignup = async () => {
  61. const {password, restUrl, username} = this.props;
  62. const url = `${restUrl}/signup`;
  63.  
  64. try {
  65. const res = await fetch(url, {
  66. method: 'POST',
  67. headers: {'Content-Type': 'application/json'},
  68. body: JSON.stringify({username, password})
  69. });
  70.  
  71. if (res.ok) { // successful signup
  72. const token = res.headers.get('Authorization');
  73. React.setState({
  74. authenticated: true,
  75. error: null, // clear previous error
  76. route: 'main',
  77. token,
  78. username
  79. });
  80. } else { // unsuccessful signup
  81. let text = res.statusText;
  82. if (/duplicate key/.test(text)) {
  83. text = `User ${username} already exists`;
  84. }
  85. React.setState({error: text});
  86. }
  87. } catch (e) {
  88. React.setState({error: `${url}; ${e.message}`});
  89. }
  90. }
  91.  
  92. render() {
  93. const {password, username} = this.props;
  94. const canSubmit = username && password;
  95.  
  96. // We are handling sending the username and password
  97. // to a REST service above, so we don't want
  98. // the HTML form to submit anything for us.
  99. // That is the reason for the call to preventDefault.
  100. return (
  101. <form className="login-form"
  102. onSubmit={event => event.preventDefault()}>
  103. <div className="row">
  104. <label>Username:</label>
  105. <input type="text"
  106. autoFocus
  107. onChange={onChangeUsername}
  108. value={username}
  109. />
  110. </div>
  111. <div className="row">
  112. <label>Password:</label>
  113. <input type="password"
  114. onChange={onChangePassword}
  115. value={password}
  116. />
  117. </div>
  118. <div className="row submit">
  119. {/* Pressing enter in either input invokes the first button. */}
  120. <button disabled={!canSubmit} onClick={this.onLogin}>
  121. Log In
  122. </button>
  123. <button disabled={!canSubmit} onClick={this.onSignup}>
  124. Signup
  125. </button>
  126. </div>
  127. </form>
  128. );
  129. }
  130. }
  131.  
  132. export default Login;

src/main.js

This is the main page of the app where users can view the ice cream flavors they like, enter new ones, and delete existing ones.

  1. import IceCreamEntry from './ice-cream-entry';
  2. import IceCreamList from './ice-cream-list';
  3. import React, {Component, PropTypes as t} from 'react';
  4. import 'whatwg-fetch';
  5.  
  6. function changeFlavor(event) {
  7. React.setState({flavor: event.target.value});
  8. }
  9.  
  10. function handleError(url, res) {
  11. React.setState(res.status === 440 ?
  12. {error: 'Session Timeout', route: 'login'} :
  13. {error: res.message});
  14. }
  15.  
  16. async function loadIceCreams(url, headers) {
  17. let res;
  18. try {
  19. res = await fetch(url, {headers});
  20. if (!res.ok) return handleError(url, res);
  21.  
  22. const iceCreams = await res.json();
  23. const iceCreamMap = {};
  24. for (const iceCream of iceCreams) {
  25. iceCreamMap[iceCream.id] = iceCream.flavor;
  26. }
  27. React.setState({iceCreamMap});
  28. } catch (e) {
  29. handleError(url, res);
  30. }
  31. }
  32.  
  33. class Main extends Component {
  34.  
  35. static propTypes = {
  36. flavor: t.string.isRequired,
  37. iceCreamMap: t.object.isRequired,
  38. restUrl: t.string.isRequired,
  39. token: t.string.isRequired,
  40. username: t.string.isRequired
  41. };
  42.  
  43. /**
  44.   * Gets the current list of ice cream flavors
  45.   * liked by the current user.
  46.   */
  47. componentDidMount() {
  48. const {restUrl, token, username} = this.props;
  49.  
  50. // This header is used in all REST calls.
  51. this.headers = {Authorization: token};
  52.  
  53. const url = `${restUrl}/ice-cream/${username}`;
  54. loadIceCreams(url, this.headers);
  55. }
  56.  
  57. /**
  58.   * Adds an ice cream flavor to the list
  59.   * of those liked by the current user.
  60.   */
  61. addIceCream = async flavor => {
  62. const {restUrl, username} = this.props;
  63. const url = `${restUrl}/ice-cream/${username}?flavor=${flavor}`;
  64. let res;
  65. try {
  66. res = await fetch(url, {method: 'POST', headers: this.headers});
  67. if (!res.ok) return handleError(url, res);
  68. let id = await res.text();
  69. if (!id) return;
  70.  
  71. // Now that it has been successfully added to the database,
  72. // add it in the UI.
  73. id = Number(id);
  74. const {iceCreamMap} = this.props;
  75. iceCreamMap[id] = flavor;
  76. React.setState({flavor: '', iceCreamMap});
  77. } catch (e) {
  78. handleError(url, res);
  79. }
  80. };
  81.  
  82. /**
  83.   * Deletes an ice cream flavor from the list
  84.   * of those liked by the current user.
  85.   */
  86. deleteIceCream = async id => {
  87. const {restUrl, username} = this.props;
  88. const url = `${restUrl}/ice-cream/${username}/${id}`;
  89. let res;
  90. try {
  91. res = await fetch(url, {method: 'DELETE', headers: this.headers});
  92. if (!res.ok) return handleError(url, res);
  93.  
  94. // Now that it has been successfully deleted from the database,
  95. // delete it from the UI.
  96. const {iceCreamMap} = this.props;
  97. delete iceCreamMap[id];
  98. React.setState({iceCreamMap});
  99. } catch (e) {
  100. handleError(url, res);
  101. }
  102. };
  103.  
  104. render() {
  105. const {flavor, iceCreamMap, username} = this.props;
  106. return (
  107. <div className="main">
  108. <IceCreamEntry
  109. addCb={this.addIceCream}
  110. changeCb={changeFlavor}
  111. flavor={flavor}
  112. />
  113. <label>{username}'s favorite flavors are:</label>
  114. <IceCreamList
  115. deleteCb={this.deleteIceCream}
  116. iceCreamMap={iceCreamMap}
  117. />
  118. </div>
  119. );
  120. }
  121. }
  122.  
  123. export default Main;

src/ice-cream-entry.js

This component renders the components needed to enter new ice cream flavors.

  1. import React, {PropTypes as t} from 'react';
  2.  
  3. const IceCreamEntry = ({addCb, changeCb, flavor}) =>
  4. <form
  5. className="ice-cream-entry"
  6. onSubmit={event => event.preventDefault()}
  7. >
  8. <label>Flavor</label>
  9. <input type="text" autoFocus onChange={changeCb} value={flavor}/>
  10. {/* using unicode heavy plus for button */}
  11. <button onClick={() => addCb(flavor)}></button>
  12. </form>;
  13.  
  14. IceCreamEntry.propTypes = {
  15. addCb: t.func.isRequired,
  16. changeCb: t.func.isRequired,
  17. flavor: t.string.isRequired,
  18. };
  19.  
  20. export default IceCreamEntry;

src/ice-cream-list.js

This component renders a sorted list of the ice cream flavors that the user likes and allows them to be deleted.

  1. import React, {PropTypes as t} from 'react';
  2. import IceCreamRow from './ice-cream-row';
  3.  
  4. const IceCreamList = ({deleteCb, iceCreamMap}) => {
  5. const list = Object.keys(iceCreamMap)
  6. .map(id => ({id, flavor: iceCreamMap[id]}));
  7. list.sort((a, b) => a.flavor.localeCompare(b.flavor));
  8.  
  9. return (
  10. <ul className="ice-cream-list">
  11. {
  12. list.map(iceCream =>
  13. <IceCreamRow
  14. deleteCb={deleteCb}
  15. id={iceCream.id}
  16. key={iceCream.id}
  17. flavor={iceCream.flavor}
  18. />)
  19. }
  20. </ul>
  21. );
  22. };
  23.  
  24. IceCreamList.propTypes = {
  25. deleteCb: t.func.isRequired,
  26. iceCreamMap: t.object.isRequired
  27. };
  28.  
  29. export default IceCreamList;

src/ice-cream-row.js

This component renders a single ice cream flavor that the user likes and allows it to be deleted.

  1. import React, {PropTypes as t} from 'react';
  2.  
  3. const IceCreamRow = ({deleteCb, flavor, id}) =>
  4. <li className="ice-cream-row">
  5. {/* using unicode heavy x for button */}
  6. <button onClick={() => deleteCb(id)}></button>
  7. {flavor}
  8. </li>;
  9.  
  10. IceCreamRow.propTypes = {
  11. deleteCb: t.func.isRequired,
  12. flavor: t.string,
  13. id: t.string
  14. };
  15.  
  16. export default IceCreamRow;

nodemon

While iteratively making changes to REST service code, it is convenient to have the REST server automatically restart when changes are saved. The npm package nodemon monitors files in and below the current directory for changes. When a change is detected, it restarts running of a specified JavaScript file.

To install it, enter "npm install -D nodemon"
To run it, enter "nodemon file-path"


SSL Certificates

To view SSL certificates from Chrome, select "Chrome ... Preferences...", select "Settings", scroll to the bottom, click "Show advanced settings...", scroll to "HTTPS/SSL", press the "Manage certificates..." button. In MacOS, this opens the "Keychain Access" application that is in Applications/Utilities. Certificates may be deleted from here.


Deploying

Web applications generated by create-react-app can be bundled for production deployment by entering "npm run build". This creates an optimized production build in a "build" directory. The contents of this directory can be deployed to any web server. The file index.html will contain references to one CSS file in the static/css directory and one JavaScript file in the static/js directory.

One option for deploying the finished app is to create a Docker image and deploy it to the cloud so that it runs in a Docker container. That is left as an exercise for the reader. ;-)


Summary

We have covered a lot of ground in this article! You have seen how to use Node.js, use PostgreSQL, implement REST services in Node, configure CORS, configure HTTPS, implement authentication and authorization, create self-signed SSL certificates, use create-react-app to create a React-based web UI, and implement common features in a React app.

Hopefully, you can apply some of the things learned here in building your own web applications, perhaps with different technologies choices, to achieve similar goals.

Please send feedback on this article to mark@objectcomputing.com.



 

The Software Engineering Tech Trends (SETT) is a monthly newsletter featuring emerging trends in software engineering. 
 

Check out OUR MOST POPULAR articles! 

New Articles Published Monthly

Subscribe Now

secret