Skip to content

nDriaDev/vite-plugin-universal-api

Repository files navigation

Go to web site

vite-plugin-universal-api

Accelerate Your Development Journey with Mock APIs without changing your client code.

npm version npm bundle size npm downloads License: MIT

Statements Branches Functions Lines

Built with:

TypeScript Vite Vitest ESLint

Vite compatibility


πŸ“‹ Table of Contents


🎯 Overview

vite-plugin-universal-api is a comprehensive Vite plugin that transforms your development server into a powerful mock backend. It provides three complementary approaches to handle API requests:

  1. πŸ“ File-System Based API - Automatically serve mock data from your file system
  2. πŸ”„ REST API Handlers - Define custom programmatic handlers for dynamic responses
  3. ⚑ WebSocket Support - Real-time bidirectional communication with rooms and broadcast capabilities

Perfect for frontend developers who need to:

  • Develop without waiting for backend APIs
  • Test edge cases and error scenarios
  • Work offline or with unreliable backend connections
  • Prototype and demo features quickly
  • Simulate real-time features with WebSocket

✨ Features

🎨 File-System Based Mocking

  • Zero Configuration - Point to a directory and start serving files
  • Smart Path Mapping - Automatic mapping of URL paths to file paths
  • Multiple File Formats - JSON, HTML, XML, text files, and binary data
  • Directory Index - Automatic index.json lookup for directory requests
  • Built-in Pagination - Automatic pagination for JSON arrays via query params or body
  • Advanced Filtering - Filter JSON arrays by field values with type-safe comparisons
  • CRUD Operations - Full support for GET, POST, PUT, PATCH, DELETE on files

πŸ”§ REST API Handlers

  • Flexible Routing - Ant-style path patterns (/users/**, /items/{id})
  • HTTP Method Support - GET, POST, PUT, PATCH, DELETE, HEAD
  • Dynamic Responses - Programmatic handlers with full request/response control
  • Express-like Middleware - Pre-processing, authentication, logging
  • Custom Parsers - Compatible with Express body parsers
  • Error Handling - Dedicated error middleware chain
  • Hybrid Approach - Mix file-based and programmatic handlers

⚑ WebSocket Support

  • RFC 6455 Compliant - Full WebSocket protocol implementation
  • Room System - Group connections and broadcast to specific rooms
  • Compression - permessage-deflate extension support (RFC 7692)
  • Heartbeat/Keep-alive - Configurable ping/pong mechanism
  • Inactivity Timeout - Automatic connection cleanup
  • Event Handlers - onConnect, onMessage, onClose, onError, onPing, onPong
  • Pattern Matching - Ant-style patterns for WebSocket endpoints
  • Authentication - Custom authentication hook before upgrade
  • Sub-protocols - WebSocket sub-protocol negotiation

πŸ› οΈ Development Utilities

  • Simulated Latency - Add delays to test loading states
  • Gateway Timeout - Simulate server timeouts
  • Detailed Logging - Configurable log levels (debug, info, warn, error)
  • Hot Reload - Changes reflected immediately during development
  • TypeScript Support - Full type definitions included

πŸ“¦ Installation

# pnpm (recommended)
pnpm add -D @ndriadev/vite-plugin-universal-api

# npm
npm install -D @ndriadev/vite-plugin-universal-api

# yarn
yarn add -D @ndriadev/vite-plugin-universal-api

Requirements

  • Node.js: ^16.0.0 || ^18.0.0 || >=20.0.0
  • Vite: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || >=8.0.0

πŸš€ Quick Start

Minimal Setup

// vite.config.ts
import { defineConfig } from 'vite';
// import mockApi from '@ndriadev/vite-plugin-universal-api' //Default export
import { universalApi } from '@ndriadev/vite-plugin-universal-api' // Named export ;

export default defineConfig({
  plugins: [
    universalApi({
      endpointPrefix: '/api',
      fsDir: 'mock'
    })
  ]
});

Create a mock file:

// mock/users.json
[
  { "id": 1, "name": "John Doe", "email": "john@example.com" },
  { "id": 2, "name": "Jane Smith", "email": "jane@example.com" }
]

Access it:

curl http://localhost:5173/api/users
# Returns the JSON array

βš™οΈ Configuration

Basic Options

interface UniversalApiOptions {
  /**
   * Disable the entire plugin
   * @default false
   */
  disable?: boolean;

  /**
   * Logging verbosity level
   * @default 'info'
   */
  logLevel?: 'debug' | 'info' | 'warn' | 'error';

  /**
   * URL prefix(es) for API endpoints
   * Can be a single string or array of prefixes
   * @example '/api' or ['/api', '/mock']
   */
  endpointPrefix: string | string[];

  /**
   * Directory path for file-based mocking (relative to project root)
   * Set to null to disable file-based routing
   * @example 'mock' or 'src/mocks'
   */
  fsDir?: string | null;

  /**
   * Enable WebSocket support
   * When true, wsHandlers option becomes required
   * @default false
   */
  enableWs?: boolean;

  /**
   * Simulated response delay in milliseconds
   * Useful for testing loading states
   * @default 0
   */
  delay?: number;

  /**
   * Timeout for long-running handlers (in ms)
   * Returns 504 Gateway Timeout if exceeded
   * @default 30000 (30 seconds)
   */
  gatewayTimeout?: number;

  /**
   * Behavior for unmatched requests
   * - '404': Return 404 Not Found
   * - 'forward': Pass to next Vite middleware (e.g., serve static files)
   * @default '404'
   */
  noHandledRestFsRequestsAction?: '404' | 'forward';

  /**
   * Request body parsing configuration
   * @default true (built-in parser)
   */
  parser?: boolean | {
    parser: ParserFunction | ParserFunction[];
    transform: (req: IncomingMessage) => {
      body?: any;
      files?: { name: string; content: Buffer; contentType: string }[];
      query?: URLSearchParams;
    }
  };

  /**
   * Global middleware executed before all handlers
   * Similar to Express middleware
   */
  handlerMiddlewares?: MiddlewareFunction[];

  /**
   * Error handling middleware
   */
  errorMiddlewares?: ErrorHandlerFunction[];

  /**
   * REST API handler configurations
   */
  handlers?: RestHandler[];

  /**
   * WebSocket handler configurations (required when enableWs is true)
   */
  wsHandlers?: WebSocketHandler[];

  /**
   * Global pagination configuration for file-based endpoints
   */
  pagination?: Partial<Record<'ALL' | 'GET' | 'POST' | 'DELETE', PaginationConfig>>;

  /**
   * Global filter configuration for file-based endpoints
   */
  filters?: Partial<Record<'ALL' | 'GET' | 'POST' | 'DELETE', FilterConfig>>;
}

REST API Handlers

interface RestHandler {
  /**
   * URL pattern with Ant-style syntax
   * - * matches one path segment
   * - ** matches zero or more path segments
   * - {param} extracts a path parameter
   * @example '/users/{id}' or '/posts/**'
   */
  pattern: string;

  /**
   * HTTP method to handle
   */
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';

  /**
   * Handler function or 'FS' for file-system routing
   */
  handle: 'FS' | ((req: UniversalApiRequest, res: ServerResponse) => void | Promise<void>);

  /**
   * Disable this specific handler
   * @default false
   */
  disabled?: boolean;

  /**
   * Response delay override for this handler
   */
  delay?: number;

  /**
   * For 'FS' handlers: function called before reading the file
   * Can modify path, check permissions, etc.
   */
  preHandle?: (req: UniversalApiRequest, res: ServerResponse) => {
    continueHandle: boolean;
    path?: string;
  } | Promise<{...}>;

  /**
   * For 'FS' handlers: function called after reading the file
   * Can transform data, add headers, etc.
   */
  postHandle?: (req: UniversalApiRequest, res: ServerResponse, data: any) => {
    continueHandle: boolean;
    data?: any;
  } | Promise<{...}>;

  /**
   * Authentication check performed before the handler runs.
   *
   * - `false`    β€” No check. Request always passes through. (default)
   * - `true`     β€” The `authorization` header must be present and non-empty.
   * - `string`   β€” The specified header must be present and non-empty.
   * - `function` β€” Custom predicate; receives the request and must return
   *                `true` to allow or `false` to reject.
   *                Can be async for token validation, DB lookups, etc.
   *
   * A rejected request receives `401 Unauthorized`.
   * If the function throws, the response is `500 Internal Server Error`.
   *
   * @default false
   *
   * @example
   * // Require the Authorization header to be present
   * authenticate: true
   *
   * @example
   * // Require a custom header (e.g. an API key)
   * authenticate: 'x-api-key'
   *
   * @example
   * // Custom async validation
   * authenticate: async (req) => {
   *   const token = req.headers['authorization']?.replace('Bearer ', '');
   *   if (!token) return false;
   *   return await verifyToken(token);
   * }
   */
  authenticate?: false | true | string | ((req: IncomingMessage) => boolean | Promise<boolean>);

  /**
   * Pagination config for this handler (overrides global)
   */
  pagination?: PaginationConfig;

  /**
   * Filter config for this handler (overrides global)
   */
  filters?: FilterConfig;
}

Pagination Configuration

type PaginationConfig = {
  /**
   * Where to look for pagination params
   * - 'query-param': URL query string (?limit=10&skip=20)
   * - 'body': Request body
   */
  type: 'query-param' | 'body';

  /**
   * For body type: nested object path
   * @example 'pagination' for { pagination: { limit: 10 } }
   */
  root?: string;

  /**
   * Parameter name for limit/page size
   * @default 'limit'
   */
  limit?: string;

  /**
   * Parameter name for skip/offset
   * @default 'skip'
   */
  skip?: string;

  /**
   * Parameter name for sort field
   * @default 'sort'
   */
  sort?: string;

  /**
   * Parameter name for sort order ('asc' | 'desc')
   * @default 'order'
   */
  order?: string;
}

Filter Configuration

type FilterConfig = {
  type: 'query-param' | 'body';
  root?: string;
  filters: Array<{
    /**
     * Query param or body field name
     */
    key: string;

    /**
     * Field in the JSON object to filter by
     */
    field?: string;

    /**
     * Expected value type
     */
    valueType: 'string' | 'number' | 'boolean';

    /**
     * Comparison operator
     * - eq: equals
     * - ne: not equals
     * - gt: greater than
     * - gte: greater than or equal
     * - lt: less than
     * - lte: less than or equal
     * - in: value in array
     * - nin: value not in array
     * - regex: regular expression match (use `regexFlags` for flags, e.g. `"i"` for case-insensitive)
     */
    comparison: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'regex';
  }>;
}

WebSocket Handlers

interface WebSocketHandler {
  /**
   * URL pattern for WebSocket upgrade requests
   * @example '/ws/chat' or '/ws/**'
   */
  pattern: string;

  /**
   * Disable this handler
   * @default false
   */
  disabled?: boolean;

  /**
   * Authentication check executed before the WebSocket handshake completes.
   *
   * Accepts the same values as the REST handler `authenticate` option:
   *
   * - `false`    β€” No check. Upgrade always proceeds. (default)
   * - `true`     β€” The `authorization` header must be present and non-empty.
   * - `string`   β€” The specified header must be present and non-empty.
   * - `function` β€” Custom predicate; receives the HTTP upgrade request and must
   *                return `true` to allow the connection, `false` to reject it
   *                with `401 Unauthorized`. Can be async.
   *
   * If the function throws, the connection is rejected with `500 Internal Server Error`.
   *
   * @default false
   *
   * @example
   * // Require the Authorization header
   * authenticate: true
   *
   * @example
   * // Require a custom header
   * authenticate: 'x-api-key'
   *
   * @example
   * // Token-based async validation
   * authenticate: async (req) => {
   *   const token = req.headers['authorization']?.replace('Bearer ', '');
   *   if (!token) return false;
   *   try {
   *     return (await verifyToken(token)) !== null;
   *   } catch {
   *     return false;
   *   }
   * }
   */
  authenticate?: false | true | string | ((req: IncomingMessage) => boolean | Promise<boolean>);

  /**
   * Default room to join on connection
   */
  defaultRoom?: string;

  /**
   * Heartbeat interval in milliseconds
   * Sends ping frames to keep connection alive
   * Connection closed after 3 missed pongs
   */
  heartbeat?: number;

  /**
   * Inactivity timeout in milliseconds
   * Closes connection if no data received within this time
   */
  inactivityTimeout?: number;

  /**
   * WebSocket sub-protocols to accept
   * @example ['chat', 'v2.chat']
   */
  subprotocols?: string[];

  /**
   * permessage-deflate compression configuration
   * @default false (disabled)
   */
  perMessageDeflate?: boolean | {
    clientNoContextTakeover?: boolean;
    serverNoContextTakeover?: boolean;
    clientMaxWindowBits?: 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
    serverMaxWindowBits?: 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
    strict?: boolean;
  };

  /**
   * Custom data transformation function
   * Override default JSON/text parsing
   */
  transformRawData?: (data: Buffer) => any | Promise<any>;

  /**
   * Delay before processing messages (ms)
   */
  delay?: number;

  /**
   * Pattern-based automatic responses
   * Messages matching conditions trigger automatic replies
   */
  responses?: Array<{
    /**
     * Function to test if message matches
     */
    match: (connection: IWebSocketConnection, message: any) => boolean;

    /**
     * Response data or function to generate response
     */
    response: any | ((connection: IWebSocketConnection, message: any) => any | Promise<any>);

    /**
     * Broadcast response instead of sending to sender
     */
    broadcast?: boolean | {
      room?: string;
      includeSelf?: boolean;
    };
  }>;

  /** Called when connection is established */
  onConnect?: (connection: IWebSocketConnection, request: IncomingMessage) => void | Promise<void>;

  /** Called when message is received */
  onMessage?: (connection: IWebSocketConnection, message: any) => void | Promise<void>;

  /** Called when connection is closed */
  onClose?: (connection: IWebSocketConnection, code: number, reason: string, initiatedByClient: boolean) => void | Promise<void>;

  /** Called on errors */
  onError?: (connection: IWebSocketConnection, error: Error) => void | Promise<void>;

  /** Called on ping frame received */
  onPing?: (connection: IWebSocketConnection, payload: Buffer) => void | Promise<void>;

  /** Called on pong frame received */
  onPong?: (connection: IWebSocketConnection, payload: Buffer) => void | Promise<void>;
}

βš™οΈ How it works

vite-plugin-universal-apiΒ only affects development. In production, your application performs real HTTP requests. In a real application, you typically want to:

  • use local APIs during development
  • call real APIs in production

You can achieve this with a simple base URL configuration:

// api.ts
export const API_BASE_URL = import.meta.env.PROD
  ? 'https://api.example.com'
  : '/api'


// usage.ts
import {API_BASE_URL }from'./api'

export async function getUsers() {
    const res = await fetch(`${API_BASE_URL}/users`)
    return res.json()
}
  • In development
    • /api/usersΒ is intercepted byΒ vite-plugin-universal-api
    • you can return mocked or locally defined responses
  • In production
    • requests go directly toΒ https://api.example.com/users

This means you can develop without a backend and switch to real APIs without changing your code.

Vite proxy

Instead of defining local API endpoints, you can forward requests to a real backend during development using Vite's proxy.

// vite.config.ts
export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
}

You can then call your API as usual:

// in your file
...
fetch('/api/users')

When to use what

  • vite-plugin-universal-apiΒ β†’ mock or local APIs
  • Vite proxy β†’ real backend during development

Both approaches work with the same client code.

Environment variables

For more flexibility, useΒ .envΒ files:

# .env.development
VITE_API_BASE_URL=/api

# .env.production
VITE_API_BASE_URL=https://api.example.com
// api.ts
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL

// services/users.ts
import {API_BASE_URL }from'../api'

export async function getUsers() {
    const res = await fetch(`${API_BASE_URL}/users`)
    return res.json()
}

Result

  • Development β†’ mocked response from local files
  • Production β†’ real API calls

πŸ’‘ Usage Examples

File-Based Mocking

Basic File Structure

project/
β”œβ”€β”€ mock/
β”‚   β”œβ”€β”€ users.json              # GET /api/users
β”‚   β”œβ”€β”€ users/
β”‚   β”‚   β”œβ”€β”€ index.json          # GET /api/users/ (directory index)
β”‚   β”‚   └── profile.json        # GET /api/users/profile
β”‚   β”œβ”€β”€ posts/
β”‚   β”‚   └── {id}.json           # GET /api/posts/123 (dynamic parameter)
β”‚   └── data.xml                # GET /api/data (XML response)
└── vite.config.ts

Configuration

// vite.config.ts
export default defineConfig({
  plugins: [
    universalApi({
      endpointPrefix: '/api',
      fsDir: 'mock',
      // Global pagination for GET requests
      pagination: {
        GET: {
          type: 'query-param',
          limit: 'limit',
          skip: 'skip',
          sort: 'sortBy',
          order: 'order'
        }
      },
      // Global filters
      filters: {
        GET: {
          type: 'query-param',
          filters: [
            { key: 'status', valueType: 'string', comparison: 'eq' },
            { key: 'age', valueType: 'number', comparison: 'gte' }
          ]
        }
      }
    })
  ]
});

Requests

# Basic request
GET /api/users
# Returns: mock/users.json

# With pagination
GET /api/users?limit=10&skip=20&sortBy=name&order=desc
# Returns: Paginated and sorted array from users.json

# With filters
GET /api/users?status=active&age=25
# Returns: Filtered array (status === 'active' AND age >= 25)

# Dynamic path parameter
GET /api/posts/123
# Returns: mock/posts/123.json

# Directory index
GET /api/users/
# Returns: mock/users/index.json

# POST with body (creates/updates file)
POST /api/users
Content-Type: application/json

{"name": "New User", "email": "new@example.com"}
# Writes to: mock/users.json (appends to array if file exists)

# PUT (replaces file content)
PUT /api/users/123
# Writes to: mock/users/123.json (creates if not exists)

# DELETE (removes file)
DELETE /api/users/123
# Deletes: mock/users/123.json

Custom REST Handlers

Basic Handler

export default defineConfig({
  plugins: [
    universalApi({
      endpointPrefix: '/api',
      handlers: [
        {
          pattern: '/users/{id}',
          method: 'GET',
          handle: async (req, res) => {
            const userId = req.params?.id;

            // Simulate database lookup
            const user = await db.findUser(userId);

            if (!user) {
              res.writeHead(404, { 'Content-Type': 'application/json' });
              res.end(JSON.stringify({ error: 'User not found' }));
              return;
            }

            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify(user));
          }
        }
      ]
    })
  ]
});

Hybrid: Custom + File-System

handlers: [
  {
    pattern: '/users',
    method: 'GET',
    handle: 'FS', // Use file-system
    preHandle: async (req, res) => {
      // Check authentication before reading file
      if (!req.headers.authorization) {
        res.writeHead(401, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Unauthorized' }));
        return { continueHandle: false };
      }
      return { continueHandle: true };
    },
    postHandle: async (req, res, data) => {
      // Transform data after reading file
      const transformedData = data.map(user => ({
        ...user,
        fullName: `${user.firstName} ${user.lastName}`
      }));
      return { continueHandle: true, data: transformedData };
    }
  },
  {
    pattern: '/users',
    method: 'POST',
    handle: async (req, res) => {
      // Custom validation
      const { email, name } = req.body;

      if (!email || !email.includes('@')) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Invalid email' }));
        return;
      }

      const newUser = { id: Date.now(), email, name };

      res.writeHead(201, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(newUser));
    }
  }
]

With Middleware

export default defineConfig({
  plugins: [
    universalApi({
      endpointPrefix: '/api',
      // Global middleware for all handlers
      handlerMiddlewares: [
        // Logger
        async (req, res, next) => {
          console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
          next();
        },
        // Authentication
        async (req, res, next) => {
          const token = req.headers.authorization?.replace('Bearer ', '');

          if (!token) {
            res.writeHead(401, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ error: 'No token provided' }));
            return;
          }

          try {
            req.body.user = await verifyToken(token);
            next();
          } catch (err) {
            res.writeHead(401, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ error: 'Invalid token' }));
          }
        }
      ],
      // Error middleware
      errorMiddlewares: [
        (err, req, res, next) => {
          console.error('API Error:', err);

          if (err.name === 'ValidationError') {
            res.writeHead(400, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ error: err.message }));
          } else {
            res.writeHead(500, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ error: 'Internal server error' }));
          }
        }
      ],
      handlers: [
        {
          pattern: '/protected/data',
          method: 'GET',
          handle: async (req, res) => {
            // req.body.user available from auth middleware
            const user = req.body.user;
            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ message: `Hello ${user.name}` }));
          }
        }
      ]
    })
  ]
});

WebSocket Real-Time Communication

Basic Chat Server

export default defineConfig({
  plugins: [
    universalApi({
      endpointPrefix: '/api',
      enableWs: true,
      wsHandlers: [
        {
          pattern: '/ws/chat',
          defaultRoom: 'lobby',
          heartbeat: 30000, // Send ping every 30 seconds
          inactivityTimeout: 60000, // Close after 1 minute of inactivity

          onConnect: (conn, req) => {
            console.log(`User connected: ${conn.id}`);

            // Send welcome message
            conn.send({
              type: 'system',
              message: 'Welcome to the chat!'
            });

            // Notify others
            conn.broadcast({
              type: 'system',
              message: 'A user joined the chat'
            }, { includeSelf: false });
          },

          onMessage: (conn, msg) => {
            console.log('Received:', msg);

            if (msg.type === 'chat') {
              // Broadcast to all in same room
              conn.broadcast({
                type: 'chat',
                user: msg.user,
                message: msg.message,
                timestamp: Date.now()
              }, { includeSelf: true });
            }

            if (msg.type === 'join-room') {
              conn.leaveRoom('lobby');
              conn.joinRoom(msg.room);

              conn.send({
                type: 'system',
                message: `Joined room: ${msg.room}`
              });
            }
          },

          onClose: (conn, code, reason) => {
            console.log(`User disconnected: ${conn.id}`);

            conn.broadcast({
              type: 'system',
              message: 'A user left the chat'
            }, { includeSelf: false });
          },

          onError: (conn, error) => {
            console.error('WebSocket error:', error);
          }
        }
      ]
    })
  ]
});

Advanced: Game Server with Rooms

wsHandlers: [
  {
    pattern: '/ws/game',
    authenticate: async (req) => {
      const token = new URLSearchParams(req.url?.split('?')[1]).get('token');
      return token === 'valid-token';
    },

    perMessageDeflate: {
      serverNoContextTakeover: true,
      clientNoContextTakeover: true,
      serverMaxWindowBits: 15,
      clientMaxWindowBits: 15
    },

    heartbeat: 20000,

    // Automatic responses
    responses: [
      {
        match: (conn, msg) => msg.type === 'ping',
        response: { type: 'pong', timestamp: Date.now() }
      },
      {
        match: (conn, msg) => msg.type === 'get-rooms',
        response: (conn) => ({
          type: 'rooms',
          rooms: conn.getRooms()
        })
      }
    ],

    onConnect: (conn, req) => {
      // Extract game room from query params
      const url = new URL(req.url!, `http://${req.headers.host}`);
      const gameRoom = url.searchParams.get('room') || 'default';

      conn.joinRoom(gameRoom);
      conn.metadata.gameRoom = gameRoom;
      conn.metadata.username = url.searchParams.get('username') || 'Anonymous';

      // Send current game state
      conn.send({
        type: 'game-state',
        state: getGameState(gameRoom)
      });

      // Notify room members
      conn.broadcast({
        type: 'player-joined',
        username: conn.metadata.username
      }, { room: gameRoom, includeSelf: false });
    },

    onMessage: (conn, msg) => {
      const gameRoom = conn.metadata.gameRoom;

      switch (msg.type) {
        case 'move':
          // Update game state
          updateGameState(gameRoom, conn.metadata.username, msg.move);

          // Broadcast to all players in the same room
          conn.broadcast({
            type: 'player-moved',
            username: conn.metadata.username,
            move: msg.move
          }, { room: gameRoom, includeSelf: true });
          break;

        case 'chat':
          // Room-specific chat
          conn.broadcast({
            type: 'chat-message',
            username: conn.metadata.username,
            message: msg.message,
            timestamp: Date.now()
          }, { room: gameRoom, includeSelf: true });
          break;

        case 'leave-game':
          conn.leaveRoom(gameRoom);
          conn.send({ type: 'left-game' });
          break;
      }
    },

    onClose: (conn, code, reason, initiatedByClient) => {
      const gameRoom = conn.metadata.gameRoom;
      const username = conn.metadata.username;

      // Notify remaining players
      conn.broadcast({
        type: 'player-left',
        username: username,
        reason: reason || 'Connection closed'
      }, { room: gameRoom });

      // Cleanup game state
      removePlayerFromGame(gameRoom, username);
    }
  }
]

Client-Side Example

// Frontend WebSocket client
const ws = new WebSocket('ws://localhost:5173/api/ws/chat');

ws.onopen = () => {
  console.log('Connected to chat');

  // Send message
  ws.send(JSON.stringify({
    type: 'chat',
    user: 'John',
    message: 'Hello everyone!'
  }));
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);

  if (data.type === 'chat') {
    displayMessage(data.user, data.message);
  } else if (data.type === 'system') {
    displaySystemMessage(data.message);
  }
};

ws.onerror = (error) => {
  console.error('WebSocket error:', error);
};

ws.onclose = () => {
  console.log('Disconnected from chat');
};

Advanced Patterns

Custom Parser (Express Integration)

import express from 'express';
import multer from 'multer';

const upload = multer();

export default defineConfig({
  plugins: [
    universalApi({
      endpointPrefix: '/api',
      parser: {
        // Use Express parsers
        parser: [
          express.json(),
          express.urlencoded({ extended: true }),
          upload.any() // Handle multipart/form-data
        ],
        // Transform Express request to plugin format
        transform: (req: any) => ({
          body: req.body,
          files: req.files?.map((f: any) => ({
            name: f.originalname,
            content: f.buffer,
            contentType: f.mimetype
          })),
          query: new URLSearchParams(req.url.split('?')[1])
        })
      },
      handlers: [
        {
          pattern: '/upload',
          method: 'POST',
          handle: async (req, res) => {
            const files = req.files;
            if (files && files.length > 0) {
              console.log(`Received ${files.length} files`);
              files.forEach(file => {
                console.log(`- ${file.name} (${file.contentType})`);
              });
            }

            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({
              success: true,
              filesReceived: files?.length || 0
            }));
          }
        }
      ]
    })
  ]
});

Dynamic Mock Data Generation

import { faker } from '@faker-js/faker';

handlers: [
  {
    pattern: '/users/random',
    method: 'GET',
    handle: async (req, res) => {
      const count = parseInt(req.query.get('count') || '10');

      const users = Array.from({ length: count }, () => ({
        id: faker.string.uuid(),
        name: faker.person.fullName(),
        email: faker.internet.email(),
        avatar: faker.image.avatar(),
        address: {
          street: faker.location.streetAddress(),
          city: faker.location.city(),
          country: faker.location.country()
        }
      }));

      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(users));
    }
  }
]

Stateful Mock Server

// Maintain state across requests
const mockDatabase = {
  users: new Map<string, any>(),
  posts: new Map<string, any>()
};

handlers: [
  {
    pattern: '/users',
    method: 'GET',
    handle: async (req, res) => {
      const users = Array.from(mockDatabase.users.values());
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(users));
    }
  },
  {
    pattern: '/users',
    method: 'POST',
    handle: async (req, res) => {
      const newUser = {
        id: Date.now().toString(),
        ...req.body,
        createdAt: new Date().toISOString()
      };

      mockDatabase.users.set(newUser.id, newUser);

      res.writeHead(201, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(newUser));
    }
  },
  {
    pattern: '/users/{id}',
    method: 'GET',
    handle: async (req, res) => {
      const user = mockDatabase.users.get(req.params!.id);

      if (!user) {
        res.writeHead(404, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'User not found' }));
        return;
      }

      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(user));
    }
  },
  {
    pattern: '/users/{id}',
    method: 'DELETE',
    handle: async (req, res) => {
      const deleted = mockDatabase.users.delete(req.params!.id);

      if (!deleted) {
        res.writeHead(404, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'User not found' }));
        return;
      }

      res.writeHead(204);
      res.end();
    }
  }
]

πŸ“š API Reference

Request Object (UniversalApiRequest)

Extended IncomingMessage with additional properties:

interface UniversalApiRequest extends IncomingMessage {
  /** Parsed request body (JSON, form data, etc.) */
  body: any;

  /** Extracted route parameters from pattern */
  params: Record<string, string> | null;

  /** Parsed URL query parameters */
  query: URLSearchParams;

  /** Uploaded files (multipart/form-data) */
  files: Array<{
    name: string;
    content: Buffer<ArrayBuffer>;
    contentType: string;
  }> | null;
}

Examples

// Access parsed body
const { username, password } = req.body;

// Access route parameters
// Pattern: /users/{userId}/posts/{postId}
// URL: /users/123/posts/456
const userId = req.params.userId; // "123"
const postId = req.params.postId; // "456"

// Access query parameters
// URL: /search?q=typescript&page=2
const searchQuery = req.query.get('q'); // "typescript"
const page = parseInt(req.query.get('page') || '1'); // 2

// Access uploaded files
if (req.files && req.files.length > 0) {
  req.files.forEach(file => {
    console.log(`File: ${file.name}`);
    console.log(`Type: ${file.contentType}`);
    // Save file.content to disk
  });
}

⚑ WebSocket API

Connection Object (IWebSocketConnection)

interface IWebSocketConnection {
  /** Unique connection identifier */
  id: string;

  /** Request path that initiated the connection */
  path: string;

  /** Whether connection is closed */
  closed: boolean;

  /** Negotiated sub-protocol */
  subprotocol?: string;

  /** Custom metadata storage */
  metadata: Record<string, any>;

  /** Set of rooms this connection belongs to */
  rooms: Set<string>;

  /**
   * Send message to this connection
   * Automatically JSON-stringifies objects
   */
  send(data: any): Promise<void>;

  /**
   * Broadcast message to other connections
   * @param data Message to send
   * @param options Filtering options
   */
  broadcast(data: any, options?: {
    room?: string;
    includeSelf?: boolean;
  }): void;

  /**
   * Broadcast to all rooms this connection is in
   */
  broadcastAllRooms(data: any, includeSelf: boolean): void;

  /**
   * Join a room
   */
  joinRoom(room: string): void;

  /**
   * Leave a room
   */
  leaveRoom(room: string): void;

  /**
   * Check if in a room
   */
  isInRoom(room: string): boolean;

  /**
   * Get all rooms
   */
  getRooms(): string[];

  /**
   * Send ping frame
   */
  ping(payload?: string | Buffer): void;

  /**
   * Send pong frame
   */
  pong(payload?: string | Buffer): void;

  /**
   * Close connection
   * @param code WebSocket close code (default 1000)
   * @param reason Close reason string
   * @param initiatedByClient Whether client initiated close
   */
  close(code?: number, reason?: string, initiatedByClient?: boolean): Promise<void>;

  /**
   * Force close without handshake
   */
  forceClose(): void;

  /**
   * Reset missed pong counter
   */
  resetMissedPong(): void;

  /**
   * Decompress data (if compression enabled)
   */
  decompressData(data: Buffer): Promise<Buffer>;
}

WebSocket Close Codes

Standard close codes according to RFC 6455:

Code Name Description
1000 Normal Closure Successful operation / regular socket shutdown
1001 Going Away Server/client going down or navigating away
1002 Protocol Error Endpoint terminating due to protocol error
1003 Unsupported Data Received data type that cannot be accepted
1007 Invalid Payload Received inconsistent data (e.g., non-UTF-8)
1008 Policy Violation Received message violating policy
1009 Message Too Big Message too large to process
1010 Mandatory Extension Client requires extensions server doesn't support
1011 Internal Error Server encountered unexpected condition
3000-3999 Reserved Framework/library codes
4000-4999 Reserved Application codes

REST API Request Handling

Comprehensive table showing how different HTTP methods are handled in File-System mode and with handlers:

Method File Exists Body Allowed Files Allowed Pagination Filters Behavior Status Code Notes
GET βœ… Yes ❌ No ❌ No βœ… Yes (JSON arrays) βœ… Yes (JSON arrays) Returns file content 200 β€’ Supports pagination/filters for JSON arrays
β€’ Binary files returned as-is
β€’ Directory lookup for index.json
GET ❌ No ❌ No ❌ No ❌ No ❌ No Error 404 File not found
HEAD βœ… Yes ❌ No ❌ No βœ… Yes (JSON arrays) βœ… Yes (JSON arrays) Returns headers only 200 Same as GET but without body
HEAD ❌ No ❌ No ❌ No ❌ No ❌ No Error 404 File not found
POST ❌ No βœ… Yes βœ… Yes (single) ❌ No ❌ No Creates new file 201 β€’ Creates file with body or first file
β€’ Only first file is written
POST ❌ No ❌ No ❌ No ❌ No ❌ No Error 400 No data provided
POST βœ… Yes (JSON) βœ… Yes ❌ No βœ… Yes βœ… Yes Returns filtered data 200 β€’ File not modified
β€’ With pagination/filters: returns query results
β€’ Without pagination/filters and with body: returns 409 Conflict
POST βœ… Yes (JSON) ❌ No ❌ No βœ… Yes βœ… Yes Returns filtered data 200 No modification, returns data with pagination/filters applied
POST βœ… Yes (non-JSON) - - ❌ No ❌ No Error 400 POST not allowed for non-JSON files
POST - βœ… Yes βœ… Yes - - Error 400 Cannot send both body and files
POST - - βœ… Yes (multiple) - - Error 400 Only single file allowed
PUT ❌ No βœ… Yes βœ… Yes (single) ❌ No ❌ No Creates file 201 β€’ Body or first file becomes file content
β€’ Only first file is written
PUT βœ… Yes βœ… Yes βœ… Yes (single) ❌ No ❌ No Replaces file 200 Completely replaces file content
PUT - ❌ No ❌ No ❌ No ❌ No Error 400 No data provided
PUT - - βœ… Yes (multiple) - - Error 400 Only single file allowed
PATCH βœ… Yes (JSON) βœ… Yes (JSON) ❌ No ❌ No ❌ No Merges/patches file 200 β€’ Supports application/json (merge)
β€’ Supports application/json-patch+json (JSON Patch RFC 6902)
β€’ Supports application/merge-patch+json (Merge Patch RFC 7396)
PATCH ❌ No - - ❌ No ❌ No Error 404 Resource not found
PATCH βœ… Yes (non-JSON) - - ❌ No ❌ No Error 400 Only JSON files can be patched
PATCH - βœ… (non-JSON) - - - Error 415 Unsupported Content-Type
DELETE βœ… Yes ❌ No ❌ No ❌ No ❌ No Deletes file 204 β€’ Removes entire file
β€’ Returns X-Deleted-Count: 1 header
DELETE βœ… Yes (JSON) ❌ No ❌ No βœ… Yes βœ… Yes Partial delete 204 β€’ With pagination/filters: deletes matched items from array
β€’ If all items deleted: removes file
β€’ If some items remain: updates file
β€’ Returns X-Deleted-Count: N header
DELETE ❌ No - - ❌ No ❌ No Error 404 Resource not found
DELETE - βœ… Yes - - - Error 400 Body not allowed in DELETE
OPTIONS - - - ❌ No ❌ No Error 405 Method not allowed in FS mode

Legend

  • βœ… Yes: Feature is supported/allowed
  • ❌ No: Feature is not supported/will cause error
  • -: Not applicable for this scenario
  • Status Codes: HTTP status returned for the operation

Special Headers

The plugin uses custom headers for metadata:

Header Usage Example
X-Total-Count Total number of elements (before pagination) X-Total-Count: 150
X-Deleted-Count Number of elements deleted X-Deleted-Count: 5

Content-Type Requirements

Method Content-Type Required Notes
POST application/json No Auto-detected for JSON body
POST multipart/form-data Yes When sending files
POST Other Yes Must match file content
PUT Any Yes Must match file content
PATCH application/json Yes Merge patch
PATCH application/json-patch+json Yes JSON Patch (RFC 6902)
PATCH application/merge-patch+json Yes Merge Patch (RFC 7396)

Pagination & Filters Scope

Pagination and Filters work ONLY when ALL of these conditions are met:

  1. βœ… File exists and contains JSON array
  2. βœ… Method is GET, POST, HEAD, or DELETE
  3. βœ… Pagination/filters are configured (globally or per-handler)
  4. βœ… File is a valid JSON file

Pagination & Filters do NOT work for:

  • ❌ Non-JSON files (binary, XML, HTML, etc.)
  • ❌ JSON objects (not arrays)
  • ❌ PUT or PATCH methods
  • ❌ Custom programmatic handlers (unless explicitly implemented)

Example scenarios:

// βœ… Works with pagination/filters
[
  {"id": 1, "name": "John"},
  {"id": 2, "name": "Jane"}
]

// ❌ Does NOT work (object, not array)
{
  "users": [
    {"id": 1, "name": "John"}
  ]
}

// ❌ Does NOT work (not JSON)
<users>
  <user id="1">John</user>
</users>

POST Method Special Behavior

The POST method has complex behavior depending on file existence and request content:

File Does NOT Exist:

POST /api/users
Body: {"name": "John"}
β†’ Creates new file with body content
β†’ Status: 201 Created

File Exists (JSON) WITHOUT Pagination/Filters:

POST /api/users
Body: {"name": "John"}
β†’ Error: File already exists
β†’ Status: 409 Conflict

File Exists (JSON) WITH Pagination/Filters and NO Body:

POST /api/users?status=active&limit=10
(no body)
β†’ Returns filtered/paginated data
β†’ File is NOT modified
β†’ Status: 200 OK

File Exists (JSON) WITH Pagination/Filters and Body:

POST /api/users?status=active
Body: {"name": "John"}
β†’ If body contains ONLY pagination/filter params: returns filtered data
β†’ If body contains OTHER data: Error 409 Conflict
β†’ File is NOT modified

⚠️ Important POST Notes:

  1. When sending files (multipart/form-data), only the FIRST file is written. All other files are ignored.
  2. You cannot send both body and files in the same POST request (Error 400).
  3. Multiple files in a single POST request are not allowed (Error 400).
  4. For non-JSON files, POST is only allowed when file doesn't exist (Error 400 if file exists).

DELETE Method Special Behavior

DELETE behavior varies based on pagination/filters configuration:

Without Pagination/Filters:

DELETE /api/users/123
β†’ Deletes entire file
β†’ Status: 204 No Content
β†’ Header: X-Deleted-Count: 1

With Pagination/Filters (JSON Array):

DELETE /api/users?status=inactive
β†’ Deletes matching items from array
β†’ If array becomes empty: deletes file
β†’ If items remain: updates file with remaining items
β†’ Status: 204 No Content
β†’ Header: X-Deleted-Count: 5

File Lookup Behavior

When a request path doesn't exactly match a file, the plugin tries multiple strategies:

  1. Exact file match: /api/users β†’ mock/users (if exists)
  2. Directory with index: /api/users/ β†’ mock/users/index.json (if exists)
  3. File with extension: /api/data β†’ mock/data.json, mock/data.xml, etc.

Example:

Request: GET /api/users

Tries in order:
1. mock/users (exact match)
2. mock/users/index.json (directory index)
3. mock/users.json, mock/users.xml, etc. (with extensions)

πŸ”§ Middleware System

Middleware Execution Order

Request arrives
    ↓
[handlerMiddlewares] (in order)
    ↓
[parser] (if enabled)
    ↓
[handler function or FS routing]
    ↓
Response sent
    ↓
(If error occurs at any step)
    ↓
[errorMiddlewares] (in order)

Middleware Types

1. Handler Middleware

type MiddlewareFunction = (
  req: UniversalApiRequest,
  res: ServerResponse,
  next: () => void
) => void | Promise<void>;

Example use cases:

  • Authentication/Authorization
  • Request logging
  • Rate limiting
  • Request validation
  • Adding custom headers
  • Request timing

2. Error Middleware

type ErrorHandlerFunction = (
  err: any,
  req: UniversalApiRequest | IncomingMessage,
  res: ServerResponse,
  next: (err?: any) => void
) => void | Promise<void>;

Example use cases:

  • Error logging
  • Error transformation
  • Custom error responses
  • Error monitoring/tracking

Middleware Examples

// Request logger
const loggerMiddleware: MiddlewareFunction = (req, res, next) => {
  const start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.url} - ${res.statusCode} (${duration}ms)`);
  });

  next();
};

// API key authentication
const apiKeyMiddleware: MiddlewareFunction = (req, res, next) => {
  const apiKey = req.headers['x-api-key'];

  if (!apiKey || !isValidApiKey(apiKey)) {
    res.writeHead(401, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Invalid API key' }));
    return;
  }

  next();
};

// Request validation
const validateMiddleware: MiddlewareFunction = async (req, res, next) => {
  if (req.method === 'POST' || req.method === 'PUT') {
    if (!req.body) {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Request body required' }));
      return;
    }

    try {
      await validateSchema(req.body);
      next();
    } catch (err) {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: err.message }));
    }
  } else {
    next();
  }
};

// CORS middleware
const corsMiddleware: MiddlewareFunction = (req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

  if (req.method === 'OPTIONS') {
    res.writeHead(204);
    res.end();
    return;
  }

  next();
};

// Error handler
const errorHandler: ErrorHandlerFunction = (err, req, res, next) => {
  console.error('Error:', err);

  // Don't send response if already sent
  if (res.writableEnded) {
    return;
  }

  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal server error';

  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    error: message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  }));
};

πŸŽ“ Advanced Features

Pattern Matching

The plugin uses Ant-style path patterns for flexible routing:

Pattern Matches Example
/api/users Exact match /api/users
/api/* Single segment wildcard /api/users, /api/posts
/api/** Multi-segment wildcard /api/users/123, /api/posts/456/comments
/api/{id} Path parameter /api/123 (params.id = "123")
/api/users/{userId}/posts/{postId} Multiple parameters /api/users/1/posts/2
/api/**.json Extension match /api/data.json, /api/sub/data.json

Pagination Details

When pagination is enabled for a file-based handler:

  1. File must contain a JSON array
  2. Query params or body fields are extracted based on configuration
  3. Operations applied in order:
    • Filtering (if configured)
    • Sorting (if sort field provided)
    • Pagination (skip and limit)

Response format:

{
  "data": [...],
  "pagination": {
    "total": 100,
    "skip": 20,
    "limit": 10,
    "returned": 10
  }
}

Filtering Details

Supported comparison operators:

  • eq: Equals (==)
  • ne: Not equals (!=)
  • gt: Greater than (>)
  • gte: Greater than or equal (>=)
  • lt: Less than (<)
  • lte: Less than or equal (<=)
  • in: Value in array
  • nin: Value not in array
  • regex: regular expression match (use regexFlags for flags, e.g. "i" for case-insensitive)

Example:

filters: {
  GET: {
    type: 'query-param',
    filters: [
      { key: 'status', valueType: 'string', comparison: 'eq' },
      { key: 'minAge', valueType: 'number', comparison: 'gte' },
      { key: 'categories', valueType: 'string', comparison: 'in' }
    ]
  }
}

// Request: GET /api/users?status=active&minAge=18&categories=admin,moderator
// Filters: status === 'active' AND age >= 18 AND category IN ['admin', 'moderator']

WebSocket Compression

When perMessageDeflate is enabled:

  • Messages are compressed using DEFLATE algorithm (RFC 1951)
  • Reduces bandwidth usage for text-heavy messages
  • Configurable compression parameters
  • Note: Adds CPU overhead, use for text > 1KB

Compression Options:

perMessageDeflate: {
  // Client doesn't reuse compression context between messages
  clientNoContextTakeover: false,

  // Server doesn't reuse compression context between messages
  serverNoContextTakeover: false,

  // LZ77 sliding window size for client (8-15, higher = better compression)
  clientMaxWindowBits: 15,

  // LZ77 sliding window size for server
  serverMaxWindowBits: 15,

  // Reject handshake if client doesn't support these exact parameters
  strict: false
}

File-System Handler Details

POST Request Behavior

When handling POST to a file-based endpoint:

  1. If file exists and contains JSON array: Append new item
  2. If file exists and contains JSON object: Replace with new object
  3. If file doesn't exist: Create new file with body content
  4. If req.files exists: Write first file to the path (other files ignored)

⚠️ Important: When handling file-system POST requests, only the first file in req.files is written. Other files are ignored.

PUT/PATCH Request Behavior

  • PUT: Replace entire file content
  • PATCH: Merge with existing JSON object (if file exists)

DELETE Request Behavior

  • Deletes the file at the matched path
  • Returns 404 if file doesn't exist

Response Streaming

When manually handling responses with streams:

handle: async (req, res) => {
  const fileStream = fs.createReadStream('/path/to/large/file.zip');

  res.writeHead(200, {
    'Content-Type': 'application/zip',
    'Content-Disposition': 'attachment; filename="file.zip"'
  });

  fileStream.pipe(res);

  // ⚠️ IMPORTANT: Wait for stream to finish before returning
  await new Promise((resolve, reject) => {
    fileStream.on('end', resolve);
    fileStream.on('error', reject);
  });

  // Plugin checks res.writableEnded to determine if response is complete
}

⚠️ Important: If you manually use streams in your response, you must wait for them to finish before the handler function returns. Otherwise, the plugin might interfere with the response, causing unexpected behavior.


πŸ” Authentication

The authenticate option is available on both REST and WebSocket handlers. It is evaluated before the handler runs (or before the WebSocket upgrade completes) and rejects unauthenticated requests automatically.

Possible Values

Value Behaviour
false No check β€” every request is allowed through. Default.
true The authorization header must be present and non-empty.
string The named header (case-insensitive) must be present and non-empty.
function Custom predicate (req: IncomingMessage) => boolean | Promise<boolean>. Return true to allow, false to reject.

A rejected request receives 401 Unauthorized. If the function throws, the response is 500 Internal Server Error.

Usage Examples

Require the Authorization header (REST)

handlers: [
  {
    pattern: '/api/profile',
    method: 'GET',
    authenticate: true, // 401 if Authorization header is missing or empty
    handle: async (req, res) => {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ user: 'me' }));
    }
  }
]

Require a custom header (REST)

handlers: [
  {
    pattern: '/api/admin',
    method: 'GET',
    authenticate: 'x-api-key', // 401 if x-api-key header is missing or empty
    handle: async (req, res) => { /* ... */ }
  }
]

Custom async function (REST)

handlers: [
  {
    pattern: '/api/orders',
    method: 'GET',
    authenticate: async (req) => {
      const token = req.headers['authorization']?.replace('Bearer ', '');
      if (!token) return false;
      try {
        return (await verifyToken(token)) !== null;
      } catch {
        return false;
      }
    },
    handle: async (req, res) => { /* ... */ }
  }
]

Require the Authorization header (WebSocket)

wsHandlers: [
  {
    pattern: '/ws/private',
    authenticate: true,
    onConnect: (conn) => conn.send({ type: 'welcome' })
  }
]

Custom async function (WebSocket)

wsHandlers: [
  {
    pattern: '/ws/game',
    authenticate: async (req) => {
      const token = new URLSearchParams(req.url?.split('?')[1]).get('token');
      return token === 'valid-token';
    },
    onConnect: (conn) => { /* ... */ }
  }
]

⚠️ Important Notes

Handler Middleware Scope

handlerMiddlewares are executed only for handlers defined in the handlers array. They are NOT executed for:

  • Pure file-system requests (when no custom handler matches)
  • WebSocket requests
  • Requests forwarded via noHandledRestFsRequestsAction: 'forward'

Parser Scope

The parser option applies only to REST API requests. It is NOT used for:

  • WebSocket messages (use transformRawData instead)

Pagination and Filters Scope

Pagination and filters work only with:

  • File-based handlers returning JSON arrays
  • Handlers defined with handle: 'FS'

They do NOT work with:

  • Custom programmatic handlers
  • Non-JSON files
  • JSON objects (not arrays)

Pattern Matching Priority

When multiple handlers match a request:

  1. More specific patterns have priority
  2. First matching handler in the array is used
  3. handlers array order matters

WebSocket Pattern Collisions

Each WebSocket handler must have a unique pattern. Duplicate patterns will cause an error at startup.

Performance Considerations

  • File I/O: File-based routing reads from disk on each request. For production, use a real backend.
  • Compression: WebSocket compression adds CPU overhead. Enable only for text-heavy messages.
  • Heartbeat: Lower heartbeat intervals increase network traffic.
  • Large Files: Use streams for large file responses to avoid memory issues.

πŸ” Troubleshooting

Common Issues

1. Plugin Not Working

Symptoms: Requests return 404 or are handled by Vite's default handler

Solutions:

  • Check endpointPrefix matches your request URL
  • Verify disable option is not set to true
  • Ensure fsDir path exists (if using file-based routing)
  • Check Vite server logs for error messages
// Enable debug logging
universalApi({
  logLevel: 'debug',
  // ...
})

2. WebSocket Connection Fails

Symptoms: WebSocket upgrade fails with 401, 404, or 500

Solutions:

  • Ensure enableWs: true is set
  • Check wsHandlers pattern matches your WebSocket URL
  • Verify authenticate option (if set) allows the request β€” use authenticate: false to bypass it during debugging
  • Check browser console and Vite server logs
// Test authentication
wsHandlers: [{
  pattern: '/ws/test',
  authenticate: async (req) => {
    console.log('Auth check:', req.headers);
    return true; // Allow all during debugging
  }
}]

3. Request Body is Undefined

Symptoms: req.body is undefined or null

Solutions:

  • Ensure parser is not disabled
  • Check Content-Type header is set correctly
  • Verify request body is valid JSON (for built-in parser)
  • Try custom parser with logging
parser: {
  parser: (req, res, next) => {
    console.log('Content-Type:', req.headers['content-type']);
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      console.log('Raw body:', body);
      try {
        req.body = JSON.parse(body);
      } catch (e) {
        console.error('Parse error:', e);
      }
      next();
    });
  },
  transform: (req: any) => ({ body: req.body })
}

4. File Not Found

Symptoms: 404 error when accessing file-based endpoint

Solutions:

  • Check file path relative to fsDir
  • Verify file extension matches request
  • Check file permissions
  • Try absolute path in logs
handlers: [{
  pattern: '/test/**',
  method: 'GET',
  handle: 'FS',
  preHandle: (req, res) => {
    console.log('Looking for file:', req.url);
    console.log('fsDir:', options.fsDir);
    return { continueHandle: true };
  }
}]

5. CORS Errors

Symptoms: Browser blocks requests with CORS policy error

Solutions:

  • Add CORS middleware
handlerMiddlewares: [
  (req, res, next) => {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

    if (req.method === 'OPTIONS') {
      res.writeHead(204);
      res.end();
      return;
    }

    next();
  }
]

6. Pagination Not Working

Symptoms: Pagination returns entire array

Solutions:

  • Ensure file contains a JSON array, not an object
  • Check pagination parameter names match query params
  • Verify file contains more items than limit
// Debug pagination
pagination: {
  GET: {
    type: 'query-param',
    limit: 'limit',
    skip: 'skip'
  }
}

// Request: GET /api/users?limit=5&skip=0
// Should return first 5 items

7. WebSocket Messages Not Received

Symptoms: onMessage not called or messages lost

Solutions:

  • Ensure messages are valid JSON (if not using transformRawData)
  • Check WebSocket is fully connected before sending
  • Verify no errors in browser console
  • Add logging to onMessage
onMessage: (conn, msg) => {
  console.log('Received message:', msg);
  // Your logic here
}

Debug Mode

Enable maximum verbosity for troubleshooting:

universalApi({
  logLevel: 'debug',
  // ... other options
})

This will log:

  • Plugin initialization
  • Request matching attempts
  • File system operations
  • WebSocket lifecycle events
  • Middleware execution
  • Parser operations

πŸ“„ License

MIT Β© nDriaDev


πŸ™ Acknowledgments

  • Inspired by various mock server solutions
  • Built with Vite
  • Tested with Vitest

πŸ“ž Support


If you find this plugin useful, please consider giving it a ⭐ on GitHub!

Packages

 
 
 

Contributors