npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

chista-express

v2.1.1

Published

A lightweight, flexible REST API server framework built on Express.js with TypeScript support, designed for rapid API development with built-in dependency injection, session management, and WebSocket support

Readme

chista-express

npm version npm downloads TypeScript Bundle Size Known Vulnerabilities

A REST API builder for Express.js that uses the builder pattern to give you full control over middleware and route registration order.

Why Builder Pattern?

Express middleware executes in registration order. This matters because:

  • CORS headers must be set before any response
  • Authentication must run before protected routes
  • Error handlers must be registered after routes

The builder pattern solves this by separating configuration from route registration:

const builder = new ExpressRestApiBuilder({ ...config });
const app = builder.getApp();  // Standard Express Application

// 1. Add global middleware BEFORE routes
app.use(cors());
app.use(compression());

// 2. Register service routes
builder.build();

// 3. Add catch-all routes AFTER services
app.use('/static', express.static('public'));
app.use('*', (req, res) => res.status(404).send('Not found'));

Key benefit: getApp() returns a standard Express Application - no special types, no lock-in. You have full access to Express APIs.

Features

  • Builder Pattern - Control middleware order with before/after route registration
  • Standard Express - Returns plain Application, fully compatible with Express ecosystem
  • Service Pattern - Each endpoint is a class with run() method
  • Dependency Injection - Services receive dependencies via constructor
  • Session Management - Built-in session loading with loadSession
  • WebSocket Support - Integrated express-ws for WebSocket endpoints
  • Structured Errors - Custom error handling with RestApiError
  • Per-Route Options - Configure middlewares, input extraction, and service execution per route
  • File Upload Support - Easy integration with multer and other upload libraries

Installation

npm install chista-express

Examples

See the examples folder:

  • simple - Basic usage with plain service classes
  • chista - Integration with chista for validation and lifecycle hooks
  • file-upload - File uploads with multer middleware

Quick Start

import { ExpressRestApiBuilder, RestApiError } from 'chista-express';

// Define your service
class UsersList {
  constructor(private deps: { db: Database }) {}

  async run() {
    return this.deps.db.query('SELECT * FROM users');
  }
}

// Create builder
const builder = new ExpressRestApiBuilder({
  logger: console,

  // Service factory - inject your dependencies here
  createService: (Service, { session }) => {
    return new Service({ session, db: myDatabase });
  },

  // Required for authenticated routes (services)
  loadSession: async (req) => {
    const token = req.headers['x-access-token'];
    return { userId: 1, token };
  },

  services: [
    ['GET', '/users', UsersList]
  ]
});

// Get Express app and add custom middleware
const app = builder.getApp();

// Finalize routes
builder.build();

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

Extending the Express App

The builder gives you access to a standard Express app at two points:

Before build() - Global Middleware

Middleware added here runs before all service routes:

const builder = new ExpressRestApiBuilder({ ...config });
const app = builder.getApp();

// These run BEFORE any service route
app.use(cors({ origin: '*' }));
app.use(helmet());
app.use(compression());
app.use(morgan('combined'));

builder.build();

Use this for: CORS, security headers, compression, request logging, rate limiting, basic auth.

After build() - Additional Routes & Handlers

Routes added here are registered after service routes:

builder.build();

// These run AFTER service routes (only if no service matched)
app.use('/', express.static('public'));
app.use('/docs', swaggerUi.serve);
app.use('*', (req, res) => res.status(404).json({ error: 'Not found' }));
app.use((err, req, res, next) => {
  // Custom error handler
});

app.listen(3000);

Use this for: static files, documentation UI, catch-all 404, custom error handlers.

Graceful Shutdown

Capture the server instance and handle termination signals:

import type { Server } from 'http';

const server: Server = app.listen(3000);

function gracefulShutdown(signal: string) {
  console.log(`${signal} received`);
  server.close(() => {
    // Clean up resources (db connections, etc.)
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10000); // Force exit after timeout
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

See the examples for complete implementations.

Configuration Options

Basic Configuration

const builder = new ExpressRestApiBuilder({
  apiBaseUrl: '/api',                   // Base URL for authenticated routes (default: '/api')
  unauthenticatedApiBaseUrl: '/api/public',  // Base URL for unauthenticated routes (default: apiBaseUrl + '/public')
  logger: yourLogger,                   // Optional: Logger with info(string) and error(string)
  jsonParser: { limit: '10mb' }         // Optional: default value is 1mb
});

Service Execution

There are two ways to configure how services are executed:

Option 1: createService (Recommended)

Use createService for simple dependency injection with optional customization:

const builder = new ExpressRestApiBuilder({
  logger,

  // Factory function to instantiate services with dependencies
  createService: (Service, { session }) => {
    return new Service({
      session,
      db: database,
      mappers: dataMappers,
      userId: session?.userId
    });
  },

  // Optional: Transform domain errors to RestApiError
  mapError: (error) => {
    if (error instanceof MyDomainError) {
      return new RestApiError(error.toObject(), 200);
    }
    return undefined; // Use default 500 handling
  },

  // Optional: Customize input extraction (has sensible defaults)
  extractInput: (context) => ({
    ...context.request.query,
    ...context.request.params,
    ...context.request.body
  }),

  services: [...]
});

Option 2: runService (Full Control)

Use runService when you need complete control over service execution:

const builder = new ExpressRestApiBuilder({
  logger,

  runService: async (Service, context) => {
    const { request, session } = context;
    const service = new Service({ session, db });
    const input = { ...request.query, ...request.params, ...request.body };
    return await service.run(input);
  },

  services: [...]
});

Default Input Extraction

When using createService, the default extractInput provides:

{
  ...context.request.query,      // URL query parameters
  ...context.request.params,     // Route parameters
  ...context.request.body,       // Request body
  ws: context.ws,                // WebSocket instance (for WS routes)
  userAgent: '...',              // User-Agent header
  clientIp: '...',               // Client IP address
  file: context.request.file,    // Single file (from multer)
  files: context.request.files   // Multiple files (from multer)
}

Authentication with loadSession

Required when services is defined. The loadSession function runs before each authenticated route:

const builder = new ExpressRestApiBuilder({
  // ...

  loadSession: async (req) => {
    const token = req.headers['x-access-token'] || req.query.token;

    if (!token) {
      throw new RestApiError(
        { message: 'Unauthorized', code: 'NO_TOKEN' },
        401
      );
    }

    const session = await validateToken(token);
    return { userId: session.userId, token };
  },

  services: [...],                    // Routes requiring authentication (loadSession required)
  unauthenticatedServices: [...]      // Public routes (mounted at /api/public by default)
});

Service Pattern

Each service is a class with a run() method:

class UserCreate {
  constructor(private deps: { db: Database; userId: number }) {}

  async run(input: { name: string; email: string }) {
    return this.deps.db.insert('users', {
      ...input,
      createdBy: this.deps.userId
    });
  }
}

Route Definitions

Routes are defined as tuples:

const routes = [
  ['GET', '/users', UsersList],
  ['GET', '/users/:id', UsersShow],
  ['POST', '/users', UsersCreate],
  ['PUT', '/users/:id', UsersUpdate],
  ['DELETE', '/users/:id', UsersDelete],
  ['PATCH', '/users/:id', UsersPatch],
  ['WS', '/ws/chat/:roomId', ChatService]  // WebSocket route
];

Route Options

Routes can include an optional 4th element for per-route configuration:

const routes = [
  // Standard route (3-tuple)
  ['GET', '/users', UsersList],

  // Route with options (4-tuple)
  ['POST', '/upload', UploadService, {
    middlewares: [multer().single('file')],
    extractInput: (ctx) => ({ ...ctx.request.body, file: ctx.request.file }),
  }],
];

Available options:

| Option | Type | Description | |--------|------|-------------| | middlewares | RequestHandler[] | Express middlewares to run before the service | | runService | RunService | Override global runService for this route | | createService | CreateService | Override global createService for this route | | extractInput | ExtractInput | Override global extractInput for this route | | mapError | MapError | Override global mapError for this route |

File Uploads

The library supports file uploads through per-route middlewares. Use any upload middleware like multer:

npm install multer @types/multer

Basic File Upload

import { ExpressRestApiBuilder, RestApiError } from 'chista-express';
import multer from 'multer';

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});

class AvatarUpload {
  async run(input: { file?: Express.Multer.File }) {
    if (!input.file) {
      throw new RestApiError({ message: 'No file uploaded', code: 'NO_FILE' }, 400);
    }

    // Upload to cloud storage, save to disk, etc.
    return {
      filename: input.file.originalname,
      size: input.file.size,
      mimetype: input.file.mimetype,
    };
  }
}

const builder = new ExpressRestApiBuilder({
  createService: (Service, ctx) => new Service({ session: ctx.session }),
  loadSession: async () => ({ userId: 1 }),

  services: [
    // Regular JSON route
    ['GET', '/users', UsersList],

    // File upload route with multer middleware
    ['POST', '/avatar', AvatarUpload, {
      middlewares: [upload.single('avatar')],
    }],

    // Multiple files
    ['POST', '/documents', DocumentsUpload, {
      middlewares: [upload.array('documents', 10)],
    }],
  ],
});

The default extractInput automatically includes file and files from the request (attached by multer).

Streaming Uploads with Per-Route runService

For large files where you need to check permissions before streaming, use per-route runService to pass the raw request to your service:

class LargeFileService {
  constructor(private deps: { session: Session; db: Database }) {}

  async run(input: { folderId: string; request: Request }) {
    // 1. CHECK PERMISSIONS FIRST (before any streaming)
    const folder = await this.deps.db.folders.findById(input.folderId);
    if (folder.ownerId !== this.deps.session.userId) {
      throw new RestApiError({ message: 'Access denied' }, 403);
    }

    // 2. STREAM FILES (only if authorized)
    const files = await this.streamToStorage(input.request);
    return { files };
  }

  private streamToStorage(request: Request): Promise<UploadedFile[]> {
    // Use busboy, formidable, or other streaming parsers
  }
}

const builder = new ExpressRestApiBuilder({
  createService: (Service, ctx) => new Service({ session: ctx.session, db }),
  loadSession: async (req) => validateToken(req.headers.authorization),

  services: [
    // Standard routes use global createService
    ['GET', '/folders', FoldersList],

    // Streaming upload: per-route runService for raw request access
    ['POST', '/folders/:folderId/upload', LargeFileService, {
      runService: async (Service, context) => {
        const service = new Service({
          session: context.session,
          db,
        });
        return service.run({
          ...context.request.params,
          request: context.request, // Pass raw request for streaming
        });
      },
    }],
  ],
});

This pattern ensures:

  • Permission checks run before file streaming begins
  • If unauthorized, the request is rejected immediately (no wasted bandwidth)
  • The service controls streaming directly

Multipart Form Data

The JSON body parser automatically skips multipart/form-data requests, so file upload middleware can parse the body. No additional configuration needed.

WebSocket-Aware Middleware

Some middleware doesn't work with WebSocket upgrade requests. Use skipForWebSocket to bypass them:

import { ExpressRestApiBuilder, skipForWebSocket } from 'chista-express';
import basicAuth from 'express-basic-auth';

const app = builder.getApp();

// Basic auth breaks WebSocket handshake - skip it for WS requests
app.use(skipForWebSocket(basicAuth({
  users: { admin: 'password' },
  challenge: true
})));

builder.build();

API Response Format

Success:

{
  "success": true,
  "result": { ... }
}

Error:

{
  "success": false,
  "error": { ... }
}

WebSocket Support

WebSocket routes follow the same authentication pattern as HTTP routes:

  • Routes in services require authentication (loadSession is called)
  • Routes in unauthenticatedServices are public (loadSession is NOT called)
class ChatService {
  async run({ ws, roomId, session }) {
    ws.on('message', (msg) => {
      ws.send(`Echo: ${msg}`);
    });
  }
}

// WebSocket routes in services array
services: [
  ['GET', '/users', UsersList],
  ['WS', '/ws/chat/:roomId', ChatService]
]

Error Handling

Domain Errors with mapError

Transform your domain errors to API responses:

import { ServiceError } from './your-domain';

const builder = new ExpressRestApiBuilder({
  createService: (Service, { session }) => new Service({ session }),

  mapError: (error) => {
    if (error instanceof ServiceError) {
      return new RestApiError(error.toObject(), 200);  // Business error
    }
    // Return undefined to use default 500 handling
    return undefined;
  },
  // ...
});

RestApiError

For direct HTTP errors:

import { RestApiError } from 'chista-express';

throw new RestApiError(
  { message: 'User not found', code: 'NOT_FOUND' },
  404
);

Using with chista

The chista package is a great companion for building services with built-in LIVR validation and lifecycle hooks:

npm install chista
import { ServiceBase, ServiceError } from 'chista';

const validation = {
  name: ['required', { min_length: 1 }],
  email: ['required', 'email'],
} as const;

class UsersCreate extends ServiceBase {
  static validation = validation;

  async execute(data: { name: string; email: string }) {
    // Validation runs automatically before execute()
    return this.db.users.create(data);
  }
}

Map ServiceError to API responses:

import { ServiceError } from 'chista';

const builder = new ExpressRestApiBuilder({
  createService: (Service) => new Service({ db }),

  mapError: (error) => {
    if (error instanceof ServiceError) {
      return new RestApiError(error.toObject(), 200);
    }
    return undefined;
  },

  loadSession: async () => ({ userId: 1 }),

  services: [
    ['POST', '/users', UsersCreate],
  ],
});

See the chista example for a complete implementation.

Testing

This library is designed to be easily testable with supertest.

Basic Test Setup

import request from 'supertest';
import { ExpressRestApiBuilder, RestApiError } from 'chista-express';

describe('User API', () => {
  let builder: ExpressRestApiBuilder;
  const mockDb = new Map();

  beforeEach(() => {
    mockDb.clear();
    builder = new ExpressRestApiBuilder({
      loadSession: async () => ({ userId: 1 }),
      createService: (Service) => new Service({ db: mockDb }),
      services: [
        ['GET', '/users', UsersList],
        ['POST', '/users', UsersCreate],
      ],
    });
    builder.build();
  });

  test('should list users', async () => {
    const response = await request(builder.getApp())
      .get('/api/users')
      .expect(200);

    expect(response.body.success).toBe(true);
    expect(response.body.result).toBeInstanceOf(Array);
  });

  test('should create user', async () => {
    const response = await request(builder.getApp())
      .post('/api/users')
      .send({ name: 'John', email: '[email protected]' })
      .expect(200);

    expect(response.body.success).toBe(true);
    expect(response.body.result.name).toBe('John');
  });
});

Testing Error Responses

test('should return validation error', async () => {
  const response = await request(builder.getApp())
    .post('/api/users')
    .send({ email: 'invalid' }) // missing required name
    .expect(422);

  expect(response.body.success).toBe(false);
  expect(response.body.error.fields).toBeDefined();
});

test('should handle authentication errors', async () => {
  const authBuilder = new ExpressRestApiBuilder({
    loadSession: async () => {
      throw new RestApiError({ message: 'Unauthorized' }, 401);
    },
    createService: (Service) => new Service(),
    services: [['GET', '/protected', ProtectedService]],
  });
  authBuilder.build();

  await request(authBuilder.getApp())
    .get('/api/protected')
    .expect(401);
});

Testing with Authentication Headers

test('should accept token in header', async () => {
  const response = await request(builder.getApp())
    .get('/api/users')
    .set('x-access-token', 'valid-token')
    .expect(200);

  expect(response.body.success).toBe(true);
});

TypeScript Types

All types are exported for use in your TypeScript code:

import type {
  Logger,
  Session,
  RequestContext,
  Service,
  ServiceClass,
  CreateService,
  ExtractInput,
  MapError,
  RunService,
  RouteDefinition,
  RouteOptions,
  HttpMethod,
  RestApiServerConfig
} from 'chista-express';

Key Interfaces

RequestContext

The context object passed to callbacks contains the Express request and additional data:

interface RequestContext {
  request: Request;      // Express Request (has query, params, body, headers)
  session?: Session;     // Session data from loadSession
  ws?: any;              // WebSocket instance (for WS routes)
}

Access request data via context.request:

  • context.request.query - URL query parameters
  • context.request.params - Route parameters
  • context.request.body - Request body
  • context.request.headers - Request headers

CreateService

Factory function for dependency injection:

type CreateService = (Service: ServiceClass, context: RequestContext) => Service;

// Example usage
const createService: CreateService = (Service, context) => {
  return new Service({
    session: context.session,
    db: database,
    logger: console,
  });
};

RunService

For full control over service execution (same signature as CreateService):

type RunService = (Service: ServiceClass, context: RequestContext) => Promise<any>;

// Example usage
const runService: RunService = async (Service, context) => {
  const { request, session } = context;
  const input = { ...request.query, ...request.params, ...request.body };
  const service = new Service({ session, db: database });
  return await service.run(input);
};

ExtractInput

Customize how input is extracted from the request context:

type ExtractInput = (context: RequestContext) => Record<string, any>;

// Example usage
const extractInput: ExtractInput = (context) => ({
  ...context.request.query,
  ...context.request.params,
  ...context.request.body,
  ws: context.ws,
});

RouteDefinition

Routes are defined as tuples, with an optional 4th element for route options:

type RouteDefinition =
  | [method: HttpMethod, path: string, service: ServiceClass]
  | [method: HttpMethod, path: string, service: ServiceClass, options: RouteOptions];

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'WS';

// Example
const routes: RouteDefinition[] = [
  ['GET', '/users', UsersList],
  ['POST', '/users', UsersCreate],
  ['POST', '/upload', FileUpload, { middlewares: [upload.single('file')] }],
  ['WS', '/ws', WebSocketHandler],
];

RouteOptions

Per-route configuration options:

interface RouteOptions {
  middlewares?: RequestHandler[];  // Express middlewares for this route
  runService?: RunService;         // Override global runService
  createService?: CreateService;   // Override global createService
  extractInput?: ExtractInput;     // Override global extractInput
  mapError?: MapError;             // Override global mapError
}

Typed Service Example

import type { Service } from 'chista-express';

interface UserInput {
  name: string;
  email: string;
}

interface User {
  id: number;
  name: string;
  email: string;
}

class UsersCreate implements Service<UserInput, User> {
  constructor(private deps: { db: Database; session: Session }) {}

  async run(input: UserInput): Promise<User> {
    return this.deps.db.users.create({
      ...input,
      createdBy: this.deps.session.userId,
    });
  }
}

License

MIT