Servers and Clients

Who needs error handling? It'll never fail.
— Seconds before disaster

Basics

We've already discussed servers and clients at the beginning of this chapter. Additionally, we wrote a server and even used a few clients (like our browser and the curl tool).

However, because this is such an important concept we will use this section to cover a complete and standalone example of a server-client setup.

Remember our definition of servers and clients:

Servers and clients are nothing more than regular programs (like the ones you saw in the first chapter). For example, a server might be a JavaScript program that waits for HTTP requests and sends back HTTP responses. A client might be a browser on your laptop or your phone—it might even be a regular script. Often, the term "server" is also used to refer to the actual machine the software is running on.

We've also discussed that we will often need to transmit data from the client to the server (and the other way around). When writing a web application, the most common way to do so is by using the HTTP protocol.

Let's now dive into an example. We will create a simple server that exposes an API to add and list tasks and a simple client that uses the API.

Building the Server

First, we will create the server.

Create a new directory named server and enter it:

mkdir server
cd server

Initialize a new project and add the express dependency to it:

pnpm init
pnpm add express

Now, create an app.js file.

Here, we will create an express app that will listen on port 3000. We will also add a JSON middleware and a list that will store the added tasks:

const express = require('express');

const app = express();
const port = 3000;

// Middleware
app.use(express.json());

// In-memory store for tasks
const tasks = [];

// Start server
app.listen(port, () => {
  console.log(`Server is running at http://localhost:${port}`);
});

Next, we need two routes—a GET route for listing all tasks and a POST route for adding a new task. This is nothing new, we've already covered how to do this in the previous sections:

app.get('/tasks', (req, res) => {
  res.json(tasks);
});

app.post('/tasks', (req, res) => {
  const { name } = req.body;
  if (!name) {
    return res.status(400).json({ error: 'Name is required' });
  }
  const task = { id: tasks.length + 1, name };
  tasks.push(task);
  res.status(201).json(task);
});

Here is how the full server code looks like:

const express = require('express');

const app = express();
const port = 3000;

// Middleware
app.use(express.json());

// In-memory store for tasks
const tasks = [];

// Routes
app.get('/tasks', (req, res) => {
  res.json(tasks);
});

app.post('/tasks', (req, res) => {
  const { name } = req.body;
  if (!name) {
    return res.status(400).json({ error: 'Name is required' });
  }
  const task = { id: tasks.length + 1, name };
  tasks.push(task);
  res.status(201).json(task);
});

// Start server
app.listen(port, () => {
  console.log(`Server is running at http://localhost:${port}`);
});

Start the server by running:

node app.js

Building the Client

Return to the top-level directory, create a new directory named client and enter it:

mkdir client
cd client

Initialize a new project and add the node-fetch dependency to it. This will allow us to use the fetch function in Node.js:

pnpm init
pnpm add node-fetch

Finally, add {"type": "module"} to the package.json file.

Next, we will write a script client.js that will contain a CLI that allows us to list all tasks and add a task:

import fetch from 'node-fetch';

const baseUrl = 'http://localhost:3000/';

async function listTasks() {
  // Make a GET request to `${baseUrl}/tasks`
}

async function addTask(name) {
  // Make a POST request to `${baseUrl}/tasks`
}

function showHelp() {
  console.log(`
Usage:
  node client.js list               List all tasks
  node client.js add <name>         Add a new task
`);
}

const args = process.argv.slice(2);

if (args.length === 0) {
  showHelp();
} else if (args[0] === 'list') {
  listTasks();
} else if (args[0] === 'add' && args[1]) {
  addTask(args[1]);
} else {
  showHelp();
}

To make the requests, we will use the fetch function that we've already discussed in the JavaScript chapter. You already know how to make a GET request:

async function listTasks() {
  try {
    const response = await fetch(`${baseUrl}/tasks`);
    const tasks = await response.json();
    console.log('Tasks:', tasks);
  } catch (error) {
    console.error('Error fetching tasks:', error);
  }
}

However, POST requests using fetch are a bit more complex.

First, we will need to explicitly set the method to POST inside the fetch function and pass the request body.

However, that's not all. Since we want to pass a JSON in the request body, we will also need to explicitly specify that using the Content-Type header.

Therefore, this is how the fetch call should look like:

await fetch(`${baseUrl}/tasks`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name }),
});

And this is how the addTask function will look like in the end:

async function addTask(name) {
  try {
    const response = await fetch(`${baseUrl}/tasks`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name }),
    });
    const task = await response.json();
    console.log('Added task:', task);
  } catch (error) {
    console.error('Error adding task:', error);
  }
}

For your reference, here is how the full client should look like:

import fetch from 'node-fetch';

const baseUrl = 'http://localhost:3000/';

async function listTasks() {
  try {
    const response = await fetch(`${baseUrl}/tasks`);
    const tasks = await response.json();
    console.log('Tasks:', tasks);
  } catch (error) {
    console.error('Error fetching tasks:', error);
  }
}

async function addTask(name) {
  try {
    const response = await fetch(`${baseUrl}/tasks`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name }),
    });
    const task = await response.json();
    console.log('Added task:', task);
  } catch (error) {
    console.error('Error adding task:', error);
  }
}

function showHelp() {
  console.log(`
Usage:
  node client.js list               List all tasks
  node client.js add <name>         Add a new task
`);
}

const args = process.argv.slice(2);

if (args.length === 0) {
  showHelp();
} else if (args[0] === 'list') {
  listTasks();
} else if (args[0] === 'add' && args[1]) {
  addTask(args[1]);
} else {
  showHelp();
}

Let's now add a task using the client command line interface:

node client.js add example

This will output:

Added task: { id: 1, name: 'example' }

Let's also list the tasks using the CLI:

node client.js list

This will output:

Tasks: [ { id: 1, name: 'example' } ]

It works! In reality, our client would not be a command-line client, but a client running in our browser. Additionally, in practice we wouldn't store the tasks in an in-memory list (because this list would vanish once we stop the server). Instead, we would persist our tasks in a database, which is what we will cover in the next chapter.