Lesson 03-Node.js HTTP Applications and Analysis

Five-Layer Network Model

Overview of Network Models

In computer networking, a network model is a layered structure used to describe the process of network communication. Common models include the OSI seven-layer model and the TCP/IP four-layer model. Node.js, as an event-driven, non-blocking I/O framework, builds its network model on top of the TCP/IP four-layer model, primarily focusing on the application layer.

Node.js’s network model involves the following five layers:

  1. Application Layer: Handles application logic, such as HTTP, WebSocket, and other protocol implementations.
  2. Transport Layer: Ensures reliable data transmission, e.g., using the TCP protocol.
  3. Network Layer: Transfers data from the source to the destination host, e.g., using the IP protocol.
  4. Data Link Layer: Transmits data frames between adjacent nodes, e.g., using Ethernet protocols.
  5. Physical Layer: Manages signal transmission over physical media, such as cables or fiber optics.

Application Layer

In Node.js, application layer protocols are typically implemented using built-in modules like http, https, net, and dgram. These modules provide support for various protocols, enabling developers to build diverse network applications.

1. HTTP Protocol

The Hypertext Transfer Protocol (HTTP) is an application layer protocol for transmitting hypermedia documents (e.g., HTML). In Node.js, the http module is used to create HTTP servers and clients.

http-server.js

// Import the http module
const http = require('http');

// Create an HTTP server
const server = http.createServer((req, res) => {
  // Set response headers
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  // Send response data
  res.end('Hello, World!\n');
});

// Listen on a port
server.listen(3000, '127.0.0.1', () => {
  console.log('Server running at http://127.0.0.1:3000/');
});

Code Analysis:

  • http.createServer: Creates an HTTP server instance. The callback function receives req (request object) and res (response object).
  • res.writeHead: Sets the response headers, where 200 is the status code and 'Content-Type': 'text/plain' specifies the response content type as plain text.
  • res.end: Sends the response data and closes the response.
  • server.listen: Listens on the specified port and host, executing the callback when the server starts.

2. WebSocket Protocol

WebSocket is a protocol that enables full-duplex communication over a single TCP connection. In Node.js, the ws module is commonly used to create WebSocket servers and clients.

websocket-server.js

// Import the ws module
const WebSocket = require('ws');

// Create a WebSocket server
const wss = new WebSocket.Server({ port: 8080 });

// Listen for connection events
wss.on('connection', (ws) => {
  console.log('Client connected');

  // Listen for message events
  ws.on('message', (message) => {
    console.log(`Received message: ${message}`);
    // Send a response
    ws.send(`You sent: ${message}`);
  });

  // Listen for close events
  ws.on('close', () => {
    console.log('Client disconnected');
  });
});

Code Analysis:

  • WebSocket.Server: Creates a WebSocket server instance, listening on port 8080.
  • wss.on('connection', ...): Handles client connection events, where ws represents the client connection object.
  • ws.on('message', ...): Listens for messages from the client, where message is the received content.
  • ws.send: Sends a message to the client.
  • ws.on('close', ...): Handles client disconnection events.

Transport Layer

The transport layer ensures reliable data transmission, primarily using the TCP protocol in Node.js. TCP provides connection-oriented, reliable data transfer.

1. TCP Server

The net module in Node.js is used to create TCP servers.

tcp-server.js

// Import the net module
const net = require('net');

// Create a TCP server
const server = net.createServer((socket) => {
  console.log('Client connected');

  // Listen for data events
  socket.on('data', (data) => {
    console.log(`Received data: ${data}`);
    // Send data
    socket.write(`You sent: ${data}`);
  });

  // Listen for close events
  socket.on('close', () => {
    console.log('Client disconnected');
  });
});

// Listen on a port
server.listen(3000, '127.0.0.1', () => {
  console.log('Server running at tcp://127.0.0.1:3000');
});

Code Analysis:

  • net.createServer: Creates a TCP server instance, where socket represents the client connection object in the callback.
  • socket.on('data', ...): Listens for data sent by the client.
  • socket.write: Sends data to the client.
  • socket.on('close', ...): Handles client disconnection events.

2. TCP Client

The net module also supports creating TCP clients.

tcp-client.js

// Import the net module
const net = require('net');

// Create a TCP client
const client = new net.Socket();

// Connect to the server
client.connect(3000, '127.0.0.1', () => {
  console.log('Connected to server');
  // Send data
  client.write('Hello, Server!');
});

// Listen for data events
client.on('data', (data) => {
  console.log(`Received data: ${data}`);
  // Close the connection
  client.destroy();
});

// Listen for close events
client.on('close', () => {
  console.log('Connection closed');
});

Code Analysis:

  • net.Socket: Creates a TCP client instance.
  • client.connect: Connects to the specified server on port 3000 and IP 127.0.0.1.
  • client.write: Sends data to the server.
  • client.on('data', ...): Listens for data from the server.
  • client.destroy: Closes the connection.
  • client.on('close', ...): Handles connection closure events.

Network Layer

The network layer handles data transmission from the source to the destination host, primarily using the IP protocol. In Node.js, network layer functionality is managed by the operating system and network devices, requiring no direct developer intervention.

The data link layer transmits data frames between adjacent nodes, commonly using protocols like Ethernet. In Node.js, this layer is also handled by the operating system and network devices.

Physical Layer

The physical layer manages signal transmission over physical media (e.g., cables, fiber optics). In Node.js, this layer is handled by the operating system and network devices.

Three-Way Handshake/Four-Way Handshake

Three-Way Handshake

The three-way handshake establishes a TCP connection, ensuring reliable data transmission between the client and server.

  • First Handshake:
  • The client sends a SYN (synchronize) packet to the server, requesting a connection.
  • The client enters the SYN_SENT state.
  • Second Handshake:
  • The server receives the SYN packet and responds with a SYN+ACK (synchronize + acknowledgment) packet, agreeing to establish the connection and acknowledging the client’s SYN.
  • The server enters the SYN_RCVD state.
  • Third Handshake:
  • The client receives the SYN+ACK packet and sends an ACK (acknowledgment) packet, confirming the server’s SYN+ACK.
  • Both client and server enter the ESTABLISHED state, and the connection is established.

In Node.js, when using the http module to create a server, the underlying TCP connection automatically performs the three-way handshake.

http-server.js

const http = require('http');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Hello, World!\n');
});

server.listen(3000, '127.0.0.1', () => {
  console.log('Server running at http://127.0.0.1:3000/');
});

Code Analysis:

  • http.createServer: Creates an HTTP server instance, with the underlying TCP connection handling the three-way handshake automatically.
  • server.listen: Listens on the specified port and host, initiating the handshake when a client connects.

Four-Way Handshake

The four-way handshake closes a TCP connection, ensuring both parties can safely terminate the connection.

  • First Handshake:
  • The client sends a FIN (finish) packet to the server, requesting to close the connection.
  • The client enters the FIN_WAIT_1 state.
  • Second Handshake:
  • The server receives the FIN packet and responds with an ACK (acknowledgment) packet, indicating it has received the FIN but may still have data to send.
  • The server enters the CLOSE_WAIT state, and the client enters FIN_WAIT_2.
  • Third Handshake:
  • After sending all data, the server sends a FIN packet, requesting to close the connection.
  • The server enters the LAST_ACK state.
  • Fourth Handshake:
  • The client receives the FIN packet and sends an ACK packet, confirming the server’s FIN.
  • The client enters the TIME_WAIT state, waits briefly, and then closes the connection.
  • The server closes the connection upon receiving the ACK.

In Node.js, the four-way handshake is automatically performed when a client or server closes a connection.

http-client.js

const http = require('http');

const options = {
  hostname: '127.0.0.1',
  port: 3000,
  path: '/',
  method: 'GET'
};

const req = http.request(options, (res) => {
  res.on('data', (chunk) => {
    console.log(`Received data: ${chunk}`);
  });

  res.on('end', () => {
    console.log('Request completed');
  });
});

req.on('error', (e) => {
  console.error(`Request error: ${e.message}`);
});

req.end();

Code Analysis:

  • http.request: Creates an HTTP client request, automatically performing the four-way handshake when the request completes or encounters an error.
  • req.end: Sends the request and terminates it, triggering the four-way handshake.

URI/URL/URN

1. URI Parsing

A URI (Uniform Resource Identifier) is a string that identifies a resource on the internet. In Node.js, the url.parse() method is used to parse URIs.

const url = require('url');

const uri = 'http://example.com:8080/path?query=string#fragment';

const parsedUri = url.parse(uri);

console.log(parsedUri);

Code Analysis:

  • url.parse(uri): Parses the URI string into an object with the following properties:
  • protocol: Protocol (e.g., http:)
  • slashes: Presence of slashes (e.g., true)
  • auth: Authentication info (e.g., username:password)
  • host: Hostname and port (e.g., example.com:8080)
  • port: Port number (e.g., 8080)
  • hostname: Hostname (e.g., example.com)
  • hash: Hash fragment (e.g., #fragment)
  • search: Query string (e.g., ?query=string)
  • query: Query object (e.g., { query: 'string' })
  • pathname: Pathname (e.g., /path)
  • path: Path with query (e.g., /path?query=string)
  • href: Original URI string (e.g., http://example.com:8080/path?query=string#fragment)

2. URL Parsing

A URL (Uniform Resource Locator) is a specific type of URI that identifies a resource and provides its location. In Node.js, URLs can be parsed using url.parse() or the new URL() constructor.

const url = require('url');

const urlString = 'http://example.com:8080/path?query=string#fragment';

// Using url.parse()
const parsedUrl = url.parse(urlString);
console.log(parsedUrl);

// Using new URL() constructor
const urlObj = new URL(urlString);
console.log(urlObj);

Code Analysis:

  • url.parse(urlString): Similar to URI parsing, returns an object with URL components.
  • new URL(urlString): Creates a URL object with convenient methods for accessing components, such as:
  • urlObj.protocol: Protocol (e.g., http:)
  • urlObj.host: Hostname and port (e.g., example.com:8080)
  • urlObj.port: Port number (e.g., 8080)
  • urlObj.pathname: Pathname (e.g., /path)
  • urlObj.searchParams: Query parameters (e.g., URLSearchParams { 'query' => 'string' })

3. URN Parsing

A URN (Uniform Resource Name) is another type of URI that identifies a resource by name without specifying its location. In Node.js, URN parsing typically does not involve network requests, as URNs do not include location information.

const urn = 'urn:isbn:0451450523';

// URNs usually do not require parsing, as they only contain name information
console.log(urn);

Code Analysis:

  • urn:isbn:0451450523: An example URN representing a book’s ISBN. URNs typically do not require parsing, as they only identify a resource’s name without network-related information.

Cross-Origin Issues and Solutions

Formation of Cross-Origin Issues

Cross-origin issues arise when a browser restricts scripts from one origin (protocol, domain, or port) from accessing resources on a different origin. This is due to the browser’s same-origin policy, a security measure for JavaScript.

The same-origin policy requires that the protocol, domain, and port of two resources match. If any differ, a cross-origin issue occurs. Examples:

  • http://example.com vs. https://example.com (different protocol)
  • http://example.com vs. http://api.example.com (different domain)
  • http://example.com:8080 vs. http://example.com:3000 (different port)

Solutions for Cross-Origin Issues in Node.js

Node.js provides several methods to address cross-origin issues:

1. CORS (Cross-Origin Resource Sharing)

CORS is a W3C standard that allows browsers to make XMLHttpRequest calls to cross-origin servers, bypassing same-origin restrictions.

In Node.js, the cors middleware can be used with Express to enable CORS.

const express = require('express');
const cors = require('cors');

const app = express();

// Use CORS middleware
app.use(cors());

// Define a route
app.get('/api/data', (req, res) => {
  res.json({ message: 'Hello from CORS-enabled server!' });
});

// Start the server
app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Code Analysis:

  • const cors = require('cors');: Imports the cors middleware.
  • app.use(cors());: Applies the cors middleware to allow cross-origin requests from all origins.
  • app.get('/api/data', ...): Defines a simple API route returning a JSON response.

2. JSONP (JSON with Padding)

JSONP bypasses the same-origin policy by leveraging the fact that <script> tags are not subject to cross-origin restrictions.

In Node.js, JSONP can be implemented manually.

const express = require('express');
const app = express();

// Define a JSONP route
app.get('/api/data', (req, res) => {
  const callback = req.query.callback;
  const data = { message: 'Hello from JSONP-enabled server!' };
  const jsonp = `${callback}(${JSON.stringify(data)})`;
  res.send(jsonp);
});

// Start the server
app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Code Analysis:

  • const callback = req.query.callback;: Retrieves the callback function name from the query parameters.
  • const jsonp = ${callback}(${JSON.stringify(data)});: Wraps the data in the callback function to form a JSONP response.
  • res.send(jsonp);: Sends the JSONP response.

3. Proxy Server

A proxy server forwards cross-origin requests to the target server, bypassing the browser’s same-origin policy.

In Node.js, the http-proxy-middleware can be used to create a proxy server.

const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');

const app = express();

// Use proxy middleware
app.use('/api', createProxyMiddleware({
  target: 'http://example.com', // Target server address
  changeOrigin: true, // Modify the Origin header
  pathRewrite: {
    '^/api': '' // Rewrite the path
  }
}));

// Start the server
app.listen(3000, () => {
  console.log('Proxy server running on port 3000');
});

Code Analysis:

  • const { createProxyMiddleware } = require('http-proxy-middleware');: Imports the proxy middleware.
  • app.use('/api', createProxyMiddleware({ ... }));: Proxies all requests starting with /api to the target server.
  • target: 'http://example.com': Specifies the target server address.
  • changeOrigin: true: Modifies the Origin header to match the target server’s domain.
  • pathRewrite: { '^/api': '' }: Removes the /api prefix from the request path.

Cache-Control Header

In Node.js, the Cache-Control HTTP response header controls how browsers and other caching mechanisms cache and reuse HTTP responses. Properly setting Cache-Control can optimize website performance, reduce server load, and enhance user experience.

1. Cache-Control Directives

The Cache-Control header supports multiple directives to specify caching behavior:

  • public: The response can be cached by any cache (public or private).
  • private: The response can only be cached by private caches (e.g., browsers), not public caches (e.g., proxy servers).
  • no-cache: The response can be cached but must be validated before reuse.
  • no-store: The response cannot be cached; each request must fetch the latest response from the server.
  • max-age=<seconds>: Specifies the maximum caching duration in seconds.
  • s-maxage=<seconds>: Like max-age, but applies only to shared caches (e.g., proxy servers).

2. Setting Cache-Control in Node.js

The Cache-Control header can be set using the http module or the express framework.

Using the http Module

const http = require('http');

const server = http.createServer((req, res) => {
  // Set Cache-Control header
  res.setHeader('Cache-Control', 'public, max-age=3600');

  // Send response
  res.end('Hello, World!\n');
});

server.listen(3000, () => {
  console.log('Server running at http://localhost:3000/');
});

Code Analysis:

  • res.setHeader('Cache-Control', 'public, max-age=3600');: Sets the Cache-Control header to allow public caching with a maximum age of 3600 seconds (1 hour).

Using the Express Framework

const express = require('express');
const app = express();

// Set Cache-Control header
app.use((req, res, next) => {
  res.setHeader('Cache-Control', 'public, max-age=3600');
  next();
});

// Define a route
app.get('/', (req, res) => {
  res.send('Hello, World!\n');
});

// Start the server
app.listen(3000, () => {
  console.log('Server running at http://localhost:3000/');
});

Code Analysis:

  • app.use((req, res, next) => { ... });: Uses middleware to set the Cache-Control header for all responses.
  • res.setHeader('Cache-Control', 'public, max-age=3600');: Sets the Cache-Control header to allow public caching with a maximum age of 3600 seconds (1 hour).

3. Verifying Cache-Control

Use browser developer tools to verify the Cache-Control header. Open the network tab, inspect the response headers, and confirm the Cache-Control value.

Cookies and Sessions

1. Cookies

Cookies are a client-side mechanism for storing data, commonly used to track user sessions or store preferences. In Node.js, cookies can be set and read using the http module or express framework.

const http = require('http');

const server = http.createServer((req, res) => {
  // Set a cookie
  res.setHeader('Set-Cookie', 'username=John Doe; Max-Age=3600; HttpOnly');

  // Read cookies
  const cookies = req.headers.cookie;
  console.log('Cookies:', cookies);

  res.end('Hello, World!\n');
});

server.listen(3000, () => {
  console.log('Server running at http://localhost:3000/');
});

Code Analysis:

  • res.setHeader('Set-Cookie', 'username=John Doe; Max-Age=3600; HttpOnly');: Sets a cookie named username with a 3600-second (1-hour) lifespan and HttpOnly to prevent JavaScript access.
  • const cookies = req.headers.cookie;: Reads cookies from the request headers.

2. Sessions

Sessions store user data on the server, typically used in conjunction with cookies. In Node.js, the express-session middleware manages sessions.

const express = require('express');
const session = require('express-session');

const app = express();

// Use express-session middleware
app.use(session({
  secret: 'your-secret-key',
  resave: false,
  saveUninitialized: true,
  cookie: { secure: false } // Set to true for HTTPS-only cookies
}));

// Set a session
app.get('/', (req, res) => {
  req.session.username = 'John Doe';
  res.send('Session set!\n');
});

// Read a session
app.get('/read', (req, res) => {
  const username = req.session.username;
  res.send(`Hello, ${username}!\n`);
});

// Start the server
app.listen(3000, () => {
  console.log('Server running at http://localhost:3000/');
});

Code Analysis:

  • app.use(session({ ... }));: Configures the express-session middleware with options like secret for encrypting session IDs.
  • req.session.username = 'John Doe';: Sets the username property in the session.
  • const username = req.session.username;: Reads the username property from the session.

HTTP Persistent Connections

HTTP persistent connections (Keep-Alive) allow multiple HTTP requests and responses over a single TCP connection, reducing the overhead of establishing and closing connections. In Node.js, persistent connections are enabled via HTTP headers.

const http = require('http');

const server = http.createServer((req, res) => {
  // Set Keep-Alive headers
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Keep-Alive', 'timeout=5, max=1000');

  res.end('Hello, World!\n');
});

server.listen(3000, () => {
  console.log('Server running at http://localhost:3000/');
});

Code Analysis:

  • res.setHeader('Connection', 'keep-alive');: Enables persistent connections.
  • res.setHeader('Keep-Alive', 'timeout=5, max=1000');: Sets a 5-second timeout and a maximum of 1000 requests per connection.

HTTP/2 Protocol in Practice

HTTP/2 is the latest version of the HTTP protocol, offering improved performance and lower latency. In Node.js, the http2 module is used to create HTTP/2 servers.

const http2 = require('http2');
const fs = require('fs');

const server = http2.createSecureServer({
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.crt')
});

server.on('error', (err) => console.error(err));

server.on('stream', (stream, headers) => {
  // Handle the request
  stream.respond({
    'content-type': 'text/plain',
    ':status': 200
  });
  stream.end('Hello, World!\n');
});

server.listen(3000, () => {
  console.log('Server running at https://localhost:3000/');
});

Code Analysis:

  • http2.createSecureServer({ ... }): Creates an HTTP/2 server with SSL certificate and key for secure connections.
  • server.on('stream', (stream, headers) => { ... }): Handles HTTP/2 streams (requests).
  • stream.respond({ ... }): Sends response headers.
  • stream.end('Hello, World!\n'): Sends the response body.

Share your love