ExisJS

Routing

Routes are responsible for handling incoming requests and sending responses back to the client. The routing mechanism determines which handler will process each request.

Functional vs OOP

ExisJS natively supports both a Functional Paradigm and a Class-Based OOP Paradigm (for dependency injection and decorators). You can mix and match them throughout your application.

Folder-Based Routing

ExisJS uses folder-based file-system routing instead of programmatic route declaration in a single entry file.

For a folder to become an accessible API endpoint, it must contain a route.ts file that exports a route definition.

In addition to route.ts, the ExisJS router utilizes two other special gatekeeper files:

  • server.ts: Placed at the root of your http directory, it is the entry point that boots your application, registers global plugins, and connects to databases.
  • gateway.ts: Can be placed in any folder. It acts as a gatekeeper, automatically applying middleware, CORS, headers, and dependencies to all routes within its directory and subdirectories.

Routing Conventions

The ExisJS file-system router natively supports advanced folder naming conventions to handle dynamic segments and logical grouping:

  • Dynamic Parameters ([param]): A folder named [id] creates a dynamic route segment (e.g., /:id). The value is accessible in your handler via ctx.params.id.
  • Catch-all Routes ([...param]): A folder named [...slug] catches all subsequent URL segments, matching anything that follows (e.g., /*slug).
  • Route Groups ((group)): A folder wrapped in parentheses (e.g., (admin)) is ignored in the final URL path. This is useful for grouping related routes or applying shared controller-level middleware without adding /admin to the URL.

The Application Entry Point (server.ts)

The server.ts file acts as the root configuration for your application. It provides lifecycle hooks to boot databases, register global plugins, and provide root-level dependencies.

Functional Paradigm

src/http/server.ts
import { exis } from 'exisjs'
export default exis({  async onStart(app) {    // Boot your database here!  }})

Class-Based (OOP) Paradigm

src/http/server.ts
import { Server } from 'exisjs/decorators'import type { App } from 'exisjs'
@Server({  providers: [['LoggerService', { useValue: console }]]})export default class RootServer {  async onStart(app: App) {    // Boot your database here!  }}

Folder Gateways (gateway.ts)

Gateways apply configurations to all routes in their directory and subdirectories.

Functional Paradigm

src/http/admin/gateway.ts
import { defineGateway } from 'exisjs/router'
export default defineGateway({  headers: { 'X-Admin-Area': 'true' }})

Class-Based (OOP) Paradigm

src/http/admin/gateway.ts
import { Gateway } from 'exisjs/decorators'
@Gateway({  headers: { 'X-Admin-Area': 'true' }})export default class AdminGateway {}

Route Definitions (route.ts)

For example, to create a simple /health endpoint, you create the folder src/http/health/ and place a route.ts file inside it:

Functional Paradigm

src/http/health/route.ts
import { controller, route } from 'exisjs/router'
export default controller({  check: route.get('/', {    handle() {      return { status: 'ok', timestamp: new Date().toISOString() }    }  })})

Class-Based (OOP) Paradigm

src/http/health/route.ts
import { Controller, Get } from 'exisjs/decorators'
@Controller()export default class HealthController {  @Get('/')  check() {    return { status: 'ok', timestamp: new Date().toISOString() }  }}

Supported Methods

ExisJS natively exposes standard HTTP methods alongside advanced routing paradigms like WebSockets and Server-Sent Events. Choose your paradigm below to see the available methods.

Functional Paradigm

MethodDescription
route.get() / post() / put()Standard HTTP methods. Also includes patch, delete, options, head, connect, and trace.
route.all()Matches any HTTP request method for the given path.
route.query()The new experimental HTTP QUERY method, solving complex idempotent fetches.
route.ws()Exposes a native WebSocket endpoint with automatic pub/sub support.
route.sse()Exposes a Server-Sent Events stream for one-way realtime data.
src/http/users/route.ts
import { controller, route } from 'exisjs/router'
export default controller({  list: route.get('/', {    handle() {      // Fetch all users from database      return [        { id: 1, name: 'Alice' },        { id: 2, name: 'Bob' }      ]    }  }),  create: route.post('/', {    handle({ req }) {      // Create a new user with parsed payload      const payload = req.body      return { id: 3, ...payload }    }  }),  preflight: route.options('/', {    handle({ res }) {      // Handle custom preflight logic if `preflightContinue: true` is configured in CORS      res.header('X-Custom-Options', 'true')      return { allowed: true }    }  })})

Class-Based (OOP) Paradigm

DecoratorDescription
@Get() / @Post() / @Put()Standard HTTP methods. Also includes @Patch, @Delete, @Options, @Head, @Connect, and @Trace.
@All()Matches any HTTP request method for the given path.
@Query()The new experimental HTTP QUERY method, solving complex idempotent fetches.
@Ws()Exposes a native WebSocket endpoint with automatic pub/sub support.
@Sse()Exposes a Server-Sent Events stream for one-way realtime data.
src/http/users/route.ts
import { Controller, Get, Post, Body } from 'exisjs/decorators'
@Controller()export default class UsersController {  @Get('/')  list() {    // Fetch all users from database    return [      { id: 1, name: 'Alice' },      { id: 2, name: 'Bob' }    ]  }
  @Post('/')  create(@Body() data: any) {    // Create a new user with parsed payload    return { id: 3, ...data }  }}

Sub-Paths in a Single File

While folder-based routing is standard, you can also define multiple sub-paths in a single route.ts file to group related endpoints.

Functional Paradigm

src/http/users/route.ts
import { controller, route } from 'exisjs/router'
export default controller({  list: route.get('/', {    handle() {      return { message: 'Fetching all users' }    }  }),  getSingle: route.get('/:id', {    handle({ params }) {      const { id } = params      return { message: \`Fetching user \${id}\` }    }  })})

Class-Based (OOP) Paradigm

src/http/users/route.ts
import { Controller, Get, Param } from 'exisjs/decorators'
@Controller()export default class UsersController {  @Get('/')  list() {    return { message: 'Fetching all users' }  }
  @Get('/:id')  getSingle(@Param('id') id: string) {    return { message: \`Fetching user \${id}\` }  }}

Route Parameters (Dynamic Routing)

Routes with static paths won’t work when you need to accept dynamic data as part of the request (e.g., GET /users/123). To create dynamic routes using folders, enclose the folder name in brackets [].

Functional Paradigm

src/http/users/[id]/route.ts
// Location: src/http/users/[id]/route.tsimport { controller, route } from 'exisjs/router'
export default controller({  getSingle: route.get('/', {    handle({ params }) {      const { id } = params      return { message: \`Fetching user \${id}\` }    }  })})

Class-Based (OOP) Paradigm

src/http/users/[id]/route.ts
// Location: src/http/users/[id]/route.tsimport { Controller, Get, Param } from 'exisjs/decorators'
@Controller()export default class SingleUserController {  @Get('/')  getSingle(@Param('id') id: string) {    return { message: \`Fetching user \${id}\` }  }}

Request & Response Objects

Handlers often need access to the client’s request details, or fine-grained control over the response headers and status codes. Every route handler natively receives powerful req and res objects.

Functional Paradigm

src/http/profile/route.ts
import { controller, route } from 'exisjs/router'
export default controller({  profile: route.get('/profile', {    handle({ req, res }) {      // Read Request details      const ip = req.ip      const headers = req.headers
      // Chain Response methods      res        .status(202)        .cookie('session', 'xyz123', { httpOnly: true, secure: true })        .header('X-Custom-Header', 'Exis')        .json({ message: 'Accepted' })    }  })})

Class-Based (OOP) Paradigm

src/http/profile/route.ts
import { Controller, Get, Req, Res } from 'exisjs/decorators'import type { Request, Response } from 'exisjs/router'
@Controller()export default class ProfileController {  @Get('/profile')  profile(@Req() req: Request, @Res() res: Response) {    // Read Request details    const ip = req.ip    const headers = req.headers
    // Chain Response methods    res      .status(202)      .cookie('session', 'xyz123', { httpOnly: true, secure: true })      .header('X-Custom-Header', 'Exis')      .json({ message: 'Accepted' })  }}
Default Responses

When a request handler returns a JavaScript object or array, it is automatically serialized to JSON and sent with a default 200 OK status code. You only need to use the res object when you want to explicitly modify headers, cookies, or status codes!

Payloads & Query Parameters

You can easily extract query strings from the URL or parsed JSON bodies from POST requests directly through the req object (or via decorators in OOP).

Functional Paradigm

src/http/search/route.ts
import { controller, route } from 'exisjs/router'
export default controller({  search: route.get('/search', {    handle({ req }) {      // e.g., GET /search?q=exis&limit=10      const { q, limit } = req.query      return { query: q, limit }    }  }),  create: route.post('/create', {    handle({ req }) {      // Access parsed JSON body natively      const data = req.body      return { received: data }    }  })})

Class-Based (OOP) Paradigm

src/http/search/route.ts
import { Controller, Get, Post, Query, Body } from 'exisjs/decorators'
@Controller()export default class SearchController {  @Get('/search')  search(@Query('q') q: string, @Query('limit') limit: number) {    // e.g., GET /search?q=exis&limit=10    return { query: q, limit }  }
  @Post('/create')  create(@Body() data: any) {    // Access parsed JSON body natively    return { received: data }  }}

Dependency Injection in Functional Controllers

ExisJS natively supports Request-Scoped Dependency Injection directly inside functional routes. You don't need to use classes to get the benefits of DI!

By destructuring resolve from the context, you can dynamically fetch services without relying on global imports. This perfectly solves the "Fat Handler" problem by encouraging you to abstract business logic into clean Service classes, and makes unit testing incredibly easy.

src/http/users/route.ts
import { controller, route } from 'exisjs/router'import { UsersService } from './service'
export default controller({  list: route.get('/', {    async handle({ resolve }) {      // 1. Resolves UsersService from the IoC Container      // 2. Maintains request-scope (the same instance is returned throughout this HTTP request)      const usersService = resolve(UsersService)            return usersService.getAll()    }  })})

The Context API (Advanced)

For applications where passing (req, res) arguments through multiple service layers becomes tedious, ExisJS offers a Next.js-inspired approach: The Context API. Powered by Node.js AsyncLocalStorage, ExisJS provides hooks to access the current request state from anywhere in the execution context.

Functional Paradigm

src/http/users/route.ts
import { controller, route, getContext, getRequest, getResponse, after } from 'exisjs/router'
export default controller({  create: route.post('/', {    async handle() {      // Access request/response without prop-drilling      const req = getRequest()      const res = getResponse()            // Access data injected by gateways      const { userId } = getContext<{ userId: string }>()            // Run background tasks after sending response to client      after(async () => {        await sendNotificationEmail(userId)      })
      res.status(201)      return { success: true }    }  })})

Class-Based (OOP) Paradigm

src/http/users/route.ts
import { Controller, Post } from 'exisjs/decorators'import { getContext, getRequest, getResponse, after } from 'exisjs/router'
@Controller()export default class OrdersController {  @Post('/')  async create() {    // You can still use the Context API seamlessly in OOP classes!    const req = getRequest()    const res = getResponse()    const { userId } = getContext<{ userId: string }>()        after(async () => {      await sendNotificationEmail(userId)    })
    res.status(201)    return { success: true }  }}

Modular Architecture

While putting all logic in route.ts is fine for small apps, ExisJS strongly encourages separating concerns alongside your route definition:

  • schema.ts: Defines expected input validation constraints.
  • service.ts: Pure functions that interact with your database.
  • controller.ts: Handles business orchestration.
  • route.ts: Wires it to the HTTP network.