JSON-RPC Basic Client-Server Example

This example demonstrates how to create a simple JSON-RPC 2.0 server using Express.js and a client that communicates with it. You'll learn the fundamental patterns for implementing JSON-RPC in a JavaScript environment.

What You'll Learn

  • How to set up a basic JSON-RPC server using Express.js
  • How to implement method handlers on the server
  • How to create a client that sends JSON-RPC requests
  • How to handle responses and errors
  • The JSON-RPC 2.0 message structure and protocol flow

Basic Client-Server Implementation

Learn how to build a complete JSON-RPC client-server system with practical examples.

Overview

This guide demonstrates how to implement a complete JSON-RPC 2.0 client-server system using Node.js and Express. You'll learn how to create a server that handles multiple methods and a client that can communicate with it.

What You'll Build

  • JSON-RPC 2.0 compliant server
  • Client with request/notification support
  • Error handling and validation
  • Multiple method types
  • Batch request support

Technologies Used

  • Node.js & Express.js
  • JSON-RPC 2.0 specification
  • JavaScript ES6+
  • HTTP POST requests
  • Error handling patterns

Server Implementation

Let's start by creating a JSON-RPC server that can handle various types of requests.

server.js

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

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

// CORS middleware for development
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Content-Type');
  res.header('Access-Control-Allow-Methods', 'POST');
  next();
});

// In-memory data store
let users = [
  { id: 1, name: 'Alice', email: '[email protected]' },
  { id: 2, name: 'Bob', email: '[email protected]' }
];
let eventLog = [];

// Helper function to create JSON-RPC response
function createResponse(id, result, error = null) {
  const response = { jsonrpc: '2.0', id };
  if (error) {
    response.error = error;
  } else {
    response.result = result;
  }
  return response;
}

// Helper function to create error objects
function createError(code, message, data = null) {
  const error = { code, message };
  if (data) error.data = data;
  return error;
}

// JSON-RPC method handlers
const methods = {
  // Math operations
  add(params) {
    if (!Array.isArray(params) || params.length !== 2) {
      throw createError(-32602, 'Invalid params', 
        { expected: 'array of 2 numbers', received: params });
    }
    const [a, b] = params;
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw createError(-32602, 'Invalid params', 
        { expected: 'numbers', received: typeof a + ', ' + typeof b });
    }
    return a + b;
  },

  subtract(params) {
    if (!Array.isArray(params) || params.length !== 2) {
      throw createError(-32602, 'Invalid params');
    }
    const [a, b] = params;
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw createError(-32602, 'Invalid params');
    }
    return a - b;
  },

  multiply(params) {
    if (!Array.isArray(params) || params.length !== 2) {
      throw createError(-32602, 'Invalid params');
    }
    const [a, b] = params;
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw createError(-32602, 'Invalid params');
    }
    return a * b;
  },

  divide(params) {
    if (!Array.isArray(params) || params.length !== 2) {
      throw createError(-32602, 'Invalid params');
    }
    const [a, b] = params;
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw createError(-32602, 'Invalid params');
    }
    if (b === 0) {
      throw createError(-32000, 'Division by zero');
    }
    return a / b;
  },

  // User management
  getUser(params) {
    if (!params || typeof params.id !== 'number') {
      throw createError(-32602, 'Invalid params', 
        { expected: 'object with id property', received: params });
    }
    const user = users.find(u => u.id === params.id);
    if (!user) {
      throw createError(-32001, 'User not found', { id: params.id });
    }
    return user;
  },

  getUsers() {
    return users;
  },

  createUser(params) {
    if (!params || !params.name || !params.email) {
      throw createError(-32602, 'Invalid params', 
        { expected: 'object with name and email', received: params });
    }
    
    const newUser = {
      id: Math.max(...users.map(u => u.id)) + 1,
      name: params.name,
      email: params.email
    };
    users.push(newUser);
    return newUser;
  },

  updateUser(params) {
    if (!params || typeof params.id !== 'number') {
      throw createError(-32602, 'Invalid params');
    }
    
    const userIndex = users.findIndex(u => u.id === params.id);
    if (userIndex === -1) {
      throw createError(-32001, 'User not found', { id: params.id });
    }
    
    if (params.name) users[userIndex].name = params.name;
    if (params.email) users[userIndex].email = params.email;
    
    return users[userIndex];
  },

  deleteUser(params) {
    if (!params || typeof params.id !== 'number') {
      throw createError(-32602, 'Invalid params');
    }
    
    const userIndex = users.findIndex(u => u.id === params.id);
    if (userIndex === -1) {
      throw createError(-32001, 'User not found', { id: params.id });
    }
    
    const deletedUser = users.splice(userIndex, 1)[0];
    return { success: true, user: deletedUser };
  },

  // Utility methods
  echo(params) {
    return params;
  },

  getTime() {
    return new Date().toISOString();
  },

  // Notification handlers (no response)
  logEvent(params) {
    const event = {
      ...params,
      timestamp: new Date().toISOString()
    };
    eventLog.push(event);
    console.log('Event logged:', event);
    return true; // Won't be returned for notifications
  },

  getEventLog() {
    return eventLog;
  }
};

// Main JSON-RPC handler
app.post('/jsonrpc', (req, res) => {
  try {
    // Handle batch requests
    if (Array.isArray(req.body)) {
      const responses = [];
      
      for (const request of req.body) {
        const response = processRequest(request);
        // Only add response if it's not a notification
        if (response !== null) {
          responses.push(response);
        }
      }
      
      // If all requests were notifications, return 204 No Content
      if (responses.length === 0) {
        return res.status(204).end();
      }
      
      return res.json(responses);
    }
    
    // Handle single request
    const response = processRequest(req.body);
    
    // If it's a notification, return 204 No Content
    if (response === null) {
      return res.status(204).end();
    }
    
    res.json(response);
    
  } catch (error) {
    console.error('Unhandled error:', error);
    res.json(createResponse(null, null, createError(-32603, 'Internal error')));
  }
});

// Process individual JSON-RPC request
function processRequest(request) {
  // Validate JSON-RPC format
  if (!request || request.jsonrpc !== '2.0' || !request.method) {
    return createResponse(request?.id || null, null, 
      createError(-32600, 'Invalid Request'));
  }

  const { method, params, id } = request;
  const isNotification = id === undefined;

  try {
    // Check if method exists
    if (!methods[method]) {
      const error = createError(-32601, 'Method not found', 
        { method, available: Object.keys(methods) });
      return isNotification ? null : createResponse(id, null, error);
    }

    // Execute method
    const result = methods[method](params);
    
    // Return response (or null for notifications)
    return isNotification ? null : createResponse(id, result);
    
  } catch (error) {
    // Handle method execution errors
    if (isNotification) {
      console.error(`Error in notification ${method}:`, error);
      return null;
    }
    
    return createResponse(id, null, error);
  }
}

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ status: 'OK', timestamp: new Date().toISOString() });
});

// Start server
app.listen(PORT, () => {
  console.log(`JSON-RPC server running on http://localhost:${PORT}`);
  console.log('Available methods:', Object.keys(methods));
});

Client Implementation

Now let's create a flexible JSON-RPC client that can send requests and notifications.

client.js

class JsonRpcClient {
  constructor(url, options = {}) {
    this.url = url;
    this.options = options;
    this.requestId = 1;
    this.timeout = options.timeout || 5000;
  }

  // Generate unique request ID
  generateId() {
    return this.requestId++;
  }

  // Send HTTP request
  async sendRequest(payload) {
    try {
      const response = await fetch(this.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...this.options.headers
        },
        body: JSON.stringify(payload),
        signal: AbortSignal.timeout(this.timeout)
      });

      // For notifications, expect 204 No Content
      if (response.status === 204) {
        return null;
      }

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      return await response.json();
    } catch (error) {
      if (error.name === 'AbortError') {
        throw new Error('Request timeout');
      }
      throw error;
    }
  }

  // Make a regular JSON-RPC call (expects response)
  async call(method, params) {
    const request = {
      jsonrpc: '2.0',
      method,
      id: this.generateId()
    };

    if (params !== undefined) {
      request.params = params;
    }

    const response = await this.sendRequest(request);
    
    if (response.error) {
      const error = new Error(response.error.message);
      error.code = response.error.code;
      error.data = response.error.data;
      throw error;
    }

    return response.result;
  }

  // Send a notification (no response expected)
  async notify(method, params) {
    const notification = {
      jsonrpc: '2.0',
      method
    };

    if (params !== undefined) {
      notification.params = params;
    }

    await this.sendRequest(notification);
  }

  // Send batch request
  async batch(requests) {
    const batchRequest = requests.map(req => {
      const request = {
        jsonrpc: '2.0',
        method: req.method
      };

      if (req.params !== undefined) {
        request.params = req.params;
      }

      // Add ID only if it's not a notification
      if (!req.notification) {
        request.id = this.generateId();
      }

      return request;
    });

    const response = await this.sendRequest(batchRequest);
    
    // Handle case where all requests were notifications
    if (response === null) {
      return [];
    }

    // Process batch response
    const results = [];
    for (const res of response) {
      if (res.error) {
        const error = new Error(res.error.message);
        error.code = res.error.code;
        error.data = res.error.data;
        results.push({ error });
      } else {
        results.push({ result: res.result });
      }
    }

    return results;
  }
}

// Example usage
async function runExamples() {
  const client = new JsonRpcClient('http://localhost:3000/jsonrpc');

  try {
    console.log('=== Basic Math Operations ===');
    
    // Basic arithmetic
    const sum = await client.call('add', [10, 5]);
    console.log('10 + 5 =', sum);

    const difference = await client.call('subtract', [10, 5]);
    console.log('10 - 5 =', difference);

    const product = await client.call('multiply', [10, 5]);
    console.log('10 * 5 =', product);

    const quotient = await client.call('divide', [10, 5]);
    console.log('10 / 5 =', quotient);

    console.log('\n=== User Management ===');
    
    // Get all users
    const users = await client.call('getUsers');
    console.log('All users:', users);

    // Get specific user
    const user = await client.call('getUser', { id: 1 });
    console.log('User 1:', user);

    // Create new user
    const newUser = await client.call('createUser', {
      name: 'Charlie',
      email: '[email protected]'
    });
    console.log('Created user:', newUser);

    // Update user
    const updatedUser = await client.call('updateUser', {
      id: newUser.id,
      name: 'Charlie Brown'
    });
    console.log('Updated user:', updatedUser);

    console.log('\n=== Utility Methods ===');
    
    // Echo test
    const echo = await client.call('echo', { message: 'Hello, World!' });
    console.log('Echo:', echo);

    // Get server time
    const time = await client.call('getTime');
    console.log('Server time:', time);

    console.log('\n=== Notifications ===');
    
    // Send some notifications (no response)
    await client.notify('logEvent', {
      type: 'user_action',
      action: 'login',
      userId: 1
    });
    console.log('Login event notification sent');

    await client.notify('logEvent', {
      type: 'system',
      action: 'backup_started'
    });
    console.log('Backup event notification sent');

    // Check event log
    const eventLog = await client.call('getEventLog');
    console.log('Event log:', eventLog);

    console.log('\n=== Batch Requests ===');
    
    // Send batch request
    const batchResults = await client.batch([
      { method: 'add', params: [1, 2] },
      { method: 'multiply', params: [3, 4] },
      { method: 'getTime' },
      { method: 'logEvent', params: { type: 'batch_test' }, notification: true }
    ]);
    
    console.log('Batch results:', batchResults);

    console.log('\n=== Error Handling ===');
    
    try {
      await client.call('nonexistentMethod');
    } catch (error) {
      console.log('Expected error:', error.message, '(Code:', error.code + ')');
    }

    try {
      await client.call('divide', [10, 0]);
    } catch (error) {
      console.log('Division by zero error:', error.message);
    }

    try {
      await client.call('getUser', { id: 999 });
    } catch (error) {
      console.log('User not found error:', error.message);
    }

  } catch (error) {
    console.error('Unexpected error:', error);
  }
}

// Run examples if this script is executed directly
if (require.main === module) {
  runExamples();
}

module.exports = JsonRpcClient;

Package Dependencies

package.json

{
  "name": "jsonrpc-example",
  "version": "1.0.0",
  "description": "Basic JSON-RPC client-server example",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "client": "node client.js",
    "dev": "nodemon server.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  },
  "keywords": ["jsonrpc", "json-rpc", "api", "server", "client"],
  "author": "Your Name",
  "license": "MIT"
}

Running the Example

Step 1: Setup

# Create project directory
mkdir jsonrpc-example
cd jsonrpc-example

# Initialize npm project
npm init -y

# Install dependencies
npm install express
npm install -D nodemon

# Create the files
# (Copy server.js and client.js code from above)

Step 2: Start the Server

# Start the server
npm start

# Or with auto-reload during development
npm run dev

The server will start on http://localhost:3000

Step 3: Run the Client

# In a new terminal, run the client
npm run client

# Or directly
node client.js

Expected Output

=== Basic Math Operations ===
10 + 5 = 15
10 - 5 = 5
10 * 5 = 50
10 / 5 = 2

=== User Management ===
All users: [
  { id: 1, name: 'Alice', email: '[email protected]' },
  { id: 2, name: 'Bob', email: '[email protected]' }
]
User 1: { id: 1, name: 'Alice', email: '[email protected]' }
Created user: { id: 3, name: 'Charlie', email: '[email protected]' }
Updated user: { id: 3, name: 'Charlie Brown', email: '[email protected]' }

=== Utility Methods ===
Echo: { message: 'Hello, World!' }
Server time: 2024-01-15T10:30:00.123Z

=== Notifications ===
Login event notification sent
Backup event notification sent
Event log: [
  {
    type: 'user_action',
    action: 'login',
    userId: 1,
    timestamp: '2024-01-15T10:30:00.123Z'
  },
  {
    type: 'system',
    action: 'backup_started',
    timestamp: '2024-01-15T10:30:00.456Z'
  }
]

=== Batch Requests ===
Batch results: [
  { result: 3 },
  { result: 12 },
  { result: '2024-01-15T10:30:00.789Z' }
]

=== Error Handling ===
Expected error: Method not found (Code: -32601)
Division by zero error: Division by zero
User not found error: User not found

Key Features Demonstrated

Server Features

  • JSON-RPC 2.0 compliance
  • Request validation
  • Error handling with proper codes
  • Notification support
  • Batch request processing
  • CORS support for web clients
  • Method parameter validation

Client Features

  • Promise-based API
  • Request timeout handling
  • Error parsing and throwing
  • Notification sending
  • Batch request support
  • Automatic ID generation
  • Flexible parameter handling

Browser Usage

The client can also be used in browsers. Here's a simplified browser-compatible version:

browser-client.js

class JsonRpcClient {
  constructor(url) {
    this.url = url;
    this.requestId = 1;
  }

  async call(method, params) {
    const request = {
      jsonrpc: '2.0',
      method,
      id: this.requestId++
    };

    if (params !== undefined) {
      request.params = params;
    }

    const response = await fetch(this.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(request)
    });

    const data = await response.json();
    
    if (data.error) {
      throw new Error(`${data.error.message} (Code: ${data.error.code})`);
    }

    return data.result;
  }

  async notify(method, params) {
    const notification = { jsonrpc: '2.0', method };
    if (params !== undefined) notification.params = params;

    await fetch(this.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(notification)
    });
  }
}

// Usage in browser
const client = new JsonRpcClient('http://localhost:3000/jsonrpc');

// Example: Add two numbers
client.call('add', [5, 3]).then(result => {
  console.log('5 + 3 =', result);
});

// Example: Send notification
client.notify('logEvent', { 
  type: 'user_action', 
  action: 'button_click' 
});

Next Steps

Enhanced Features

  • Add authentication
  • Implement rate limiting
  • Add request logging
  • Set up SSL/TLS
  • Add input sanitization

Production Ready

  • Add database integration
  • Implement proper error handling
  • Add monitoring and metrics
  • Set up clustering
  • Add comprehensive tests

Advanced Topics

  • WebSocket transport
  • Real-time notifications
  • Load balancing
  • Microservices integration
  • API versioning