Skip to content

REST Handlers ​

REST Handlers let you define custom programmatic endpoints with full control over request processing and response generation.

Overview ​

REST Handlers support two approaches:

  1. Custom Function Handlers - Full programmatic control with custom logic
  2. File-System Handlers - Delegate to file-system with pattern matching and optional pre/post processing

While File-System API serves static files, REST Handlers let you:

  • ✅ Add custom business logic
  • ✅ Validate requests
  • ✅ Generate dynamic responses
  • ✅ Simulate errors and edge cases
  • ✅ Interact with databases or external APIs
  • ✅ Transform data on the fly
  • ✅ Combine pattern matching with file-system routing
  • ✅ Pre-process URLs before file lookup
  • ✅ Post-process file content before sending

Handler Types ​

Custom Function Handler ​

Full control with your own logic:

typescript
import { defineConfig } from 'vite'
import { universalApi } from '@ndriadev/vite-plugin-universal-api'

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

            // Your custom logic
            const user = {
              id: userId,
              name: `User ${userId}`
            }

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

File-System Handler ​

Delegate to file-system with pattern matching:

typescript
handlers: [
  {
    pattern: '/users/{id}',
    method: 'GET',
    handle: 'FS'  // Delegates to file-system
  }
]

How it works:

Request: GET /api/users/123

Pattern match: /users/{id} → id = "123"

File lookup tries (in order):
1. Exact path:           mock/users/123
2. Directory index:      mock/users/123/index.json
3. File with extension:  mock/users/123.<ext>  (first match in directory)

If found → Serves file
If not found → 404

File-System Handler with preHandle ​

Transform the URL before file lookup:

typescript
handlers: [
  {
    pattern: '/api/v2/users/{id}',
    method: 'GET',
    handle: 'FS',
    preHandle: {
      // Transform URL before looking up file
      transform: (url) => url.replace('/v2/', '/v1/')
    }
  }
]

Request flow:

Request:     GET /api/v2/users/123
preHandle:   Transform to /api/v1/users/123
File lookup: mock/api/v1/users/123.json
Response:    File content

Multiple replacements:

typescript
preHandle: {
  transform: [
    { searchValue: '/api/', replaceValue: '/data/' },
    { searchValue: '/v2/', replaceValue: '/v1/' }
  ]
}

File-System Handler with postHandle ​

Intercept the file lookup result and write the response manually:

typescript
handlers: [
  {
    pattern: '/users/{id}',
    method: 'GET',
    handle: 'FS',
    postHandle: async (req, res, data) => {
      if (!data) {
        // File not found
        res.writeHead(404, { 'Content-Type': 'application/json' })
        res.end(JSON.stringify({ error: 'User not found' }))
        return
      }

      // Wrap file content in envelope
      const response = {
        data: JSON.parse(data),
        timestamp: Date.now(),
        path: req.url,
        params: req.params
      }

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

Request flow:

Request:      GET /api/users/123
File lookup:  mock/users/123.json
File content: { "id": "123", "name": "Alice" }
postHandle:   Receives file content as `data`, wraps in envelope
Response:     { data: { ... }, timestamp: ..., path: ... }

Important: postHandle is called with the current file content on disk (or null if no file was found) regardless of the HTTP method. For POST, PUT, PATCH, and DELETE, data contains the file content before any write or delete operation. When postHandle is defined, the plugin skips all automatic processing — you are fully responsible for writing the response.

Note: When using postHandle, automatic pagination and filters are not available (you must implement them manually if needed).

Handler Configuration ​

Pattern Matching ​

Supports Ant-style path patterns:

typescript
{
  // Exact match
  pattern: '/users/profile',

  // Path parameters
  pattern: '/users/{id}',
  pattern: '/posts/{postId}/comments/{commentId}',

  // Wildcard (single segment)
  pattern: '/api/*/data',

  // Double wildcard (multiple segments)
  pattern: '/files/**',
}

HTTP Methods ​

typescript
{
  method: 'GET',    // Retrieve data
  method: 'POST',   // Create resource
  method: 'PUT',    // Update/replace resource
  method: 'PATCH',  // Partial update
  method: 'DELETE', // Remove resource
  method: 'HEAD',   // Get headers only
}

Request Object ​

typescript
interface UniversalApiRequest<TBody = unknown> {
  method: string
  url: string
  headers: Record<string, string>
  params: Record<string, string> | null  // From URL pattern
  query: URLSearchParams                  // Query parameters
  body: TBody                             // Parsed request body
  files: UploadedFile[] | null            // Uploaded files
}

The body field is typed via the generic TBody parameter (default: unknown). Pass a concrete type to get full type safety in your handler:

typescript
import type { UniversalApiSimpleHandler } from '@ndriadev/vite-plugin-universal-api'

interface CreateUserBody {
  name: string
  email: string
  role?: string
}

// Handler with typed body
const createUser: UniversalApiSimpleHandler<CreateUserBody> = async (req, res) => {
  const { name, email } = req.body  // ✅ fully typed — no cast needed
  // ...
}

When using the default (untyped) form, narrow body with a cast before use:

typescript
handle: async (req, res) => {
  const body = req.body as { name: string; email: string }
  // ...
}

preHandle Examples ​

URL Versioning ​

typescript
{
  pattern: '/api/v2/**',
  method: 'GET',
  handle: 'FS',
  preHandle: {
    // Map v2 requests to v1 files
    transform: (url) => url.replace('/v2/', '/v1/')
  }
}

// GET /api/v2/users → Looks for mock/api/v1/users.json
// GET /api/v2/posts/1 → Looks for mock/api/v1/posts/1.json

Path Normalization ​

typescript
{
  pattern: '/api/**',
  method: 'GET',
  handle: 'FS',
  preHandle: {
    transform: (url) => {
      // Remove trailing slashes
      return url.replace(/\/$/, '')
    }
  }
}

Multiple Transformations ​

typescript
{
  pattern: '/api/**',
  method: 'GET',
  handle: 'FS',
  preHandle: {
    transform: [
      // Replace API prefix
      { searchValue: '/api/', replaceValue: '/data/' },
      // Remove file extension from URL
      { searchValue: '.json', replaceValue: '' },
      // Replace dashes with underscores
      { searchValue: '-', replaceValue: '_' }
    ]
  }
}

// GET /api/user-profile.json
// → Transform to /data/user_profile
// → Looks for mock/data/user_profile.json

Dynamic Transformations ​

typescript
{
  pattern: '/cdn/{region}/files/**',
  method: 'GET',
  handle: 'FS',
  preHandle: {
    transform: (url) => {
      // Extract region from URL and remove it from path
      const match = url.match(/\/cdn\/(\w+)\/files\/(.+)/)
      if (match) {
        const [, region, filepath] = match
        // Map to region-specific directory
        return `/cdn/${region}/${filepath}`
      }
      return url
    }
  }
}

// GET /api/cdn/eu/files/image.png
// → Transform to /cdn/eu/image.png
// → Looks for mock/cdn/eu/image.png

postHandle Examples ​

Response Envelope ​

typescript
{
  pattern: '/users/{id}',
  method: 'GET',
  handle: 'FS',
  postHandle: async (req, res, data) => {
    if (!data) {
      res.writeHead(404)
      res.end(JSON.stringify({ error: 'Not found' }))
      return
    }

    const envelope = {
      success: true,
      data: JSON.parse(data),
      metadata: {
        timestamp: Date.now(),
        requestId: req.headers['x-request-id'],
        cached: false
      }
    }

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

Data Transformation ​

typescript
{
  pattern: '/products/{id}',
  method: 'GET',
  handle: 'FS',
  postHandle: async (req, res, data) => {
    if (!data) {
      res.writeHead(404)
      res.end()
      return
    }

    const product = JSON.parse(data)

    // Add computed fields
    product.discountedPrice = product.price * 0.9
    product.inStock = product.stock > 0
    product.url = `/products/${product.id}`

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

Content-Type Detection ​

typescript
{
  pattern: '/files/**',
  method: 'GET',
  handle: 'FS',
  postHandle: async (req, res, data) => {
    if (!data) {
      res.writeHead(404)
      res.end()
      return
    }

    // Detect content type based on URL
    const url = req.url
    let contentType = 'application/octet-stream'

    if (url.endsWith('.json')) contentType = 'application/json'
    else if (url.endsWith('.xml')) contentType = 'application/xml'
    else if (url.endsWith('.html')) contentType = 'text/html'
    else if (url.endsWith('.jpg') || url.endsWith('.jpeg')) contentType = 'image/jpeg'
    else if (url.endsWith('.png')) contentType = 'image/png'

    res.writeHead(200, { 'Content-Type': contentType })
    res.end(data)
  }
}

Custom Error Responses ​

typescript
{
  pattern: '/api/**',
  method: 'GET',
  handle: 'FS',
  postHandle: async (req, res, data) => {
    if (!data) {
      const error = {
        error: {
          code: 'RESOURCE_NOT_FOUND',
          message: `Resource '${req.url}' not found`,
          timestamp: new Date().toISOString(),
          requestId: Math.random().toString(36).substr(2, 9)
        }
      }

      res.writeHead(404, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(error))
      return
    }

    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(data)
  }
}

POST/PUT/DELETE with postHandle ​

When postHandle is used with mutating methods, data contains the existing file content before any write (the current state on disk), or null if no file was found. The plugin skips its automatic write/delete logic entirely — you are responsible for both the response and any file operations.

typescript
handlers: [
  // POST with postHandle — data is the pre-existing file content, or null
  {
    pattern: '/users',
    method: 'POST',
    handle: 'FS',
    postHandle: async (req, res, data) => {
      // data = existing file content before the POST would write anything
      // The plugin will NOT write any file; you manage the response entirely.
      const body = req.body as { name: string; email: string }

      if (!body.name || !body.email) {
        res.writeHead(400, { 'Content-Type': 'application/json' })
        res.end(JSON.stringify({ error: 'Missing required fields' }))
        return
      }

      const newUser = { id: Date.now().toString(), ...body }

      res.writeHead(201, {
        'Content-Type': 'application/json',
        'Location': `/api/users/${newUser.id}`
      })
      res.end(JSON.stringify({ success: true, data: newUser }))
    }
  },

  // DELETE with postHandle — data is the current file content, or null if not found
  {
    pattern: '/users/{id}',
    method: 'DELETE',
    handle: 'FS',
    postHandle: async (req, res, data) => {
      if (!data) {
        res.writeHead(404)
        res.end(JSON.stringify({ error: 'User not found' }))
        return
      }

      // data contains the current file content
      // The plugin will NOT delete any file; manage it yourself if needed.
      res.writeHead(204)
      res.end()
    }
  }
]

Combining preHandle and postHandle ​

You can use both together:

typescript
{
  pattern: '/api/v2/users/{id}',
  method: 'GET',
  handle: 'FS',

  // Transform URL before file lookup
  preHandle: {
    transform: (url) => url.replace('/v2/', '/v1/')
  },

  // Process response after file is loaded
  postHandle: async (req, res, data) => {
    if (!data) {
      res.writeHead(404)
      res.end(JSON.stringify({ error: 'User not found' }))
      return
    }

    const user = JSON.parse(data)

    // Add v2-specific fields
    user.version = 'v2'
    user.enhanced = true
    user.legacyData = false

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

Request flow:

Request:      GET /api/v2/users/123
preHandle:    Transform to /api/v1/users/123
File lookup:  mock/api/v1/users/123.json
File content: { "id": "123", "name": "Alice" }
postHandle:   Receives content, adds version fields
Response:     { "id": "123", "name": "Alice", "version": "v2", ... }

Important Notes ​

postHandle Limitations ​

⚠️ When using postHandle, these features are NOT available:

  • ❌ Automatic pagination
  • ❌ Automatic filters
  • ❌ Automatic file write/delete (for POST, PUT, PATCH, DELETE)

You must implement them manually in your postHandle function if needed.

Why? Because postHandle gives you full control over the response, the plugin delegates all processing to your callback.

preHandle vs Middleware ​

preHandle transforms URLs for specific handlers before file lookup.

Middleware runs for all handlers and has different purposes.

typescript
universalApi({
  // Middleware: runs for ALL handlers
  handlerMiddlewares: [
    async (req, res, next) => {
      console.log(`Request: ${req.method} ${req.url}`)
      next()
    }
  ],

  handlers: [
    {
      pattern: '/api/**',
      method: 'GET',
      handle: 'FS',
      // preHandle: runs ONLY for this handler
      preHandle: {
        transform: (url) => url.replace('/api/', '/data/')
      }
    }
  ]
})

Examples ​

GET - Retrieve Resource ​

typescript
{
  pattern: '/users/{id}',
  method: 'GET',
  handle: async (req, res) => {
    const user = database.users.find(u => u.id === 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))
  }
}

POST - Create Resource ​

typescript
interface CreateUserBody {
  name: string
  email: string
}

{
  pattern: '/users',
  method: 'POST',
  handle: async (req, res) => {
    const body = req.body as CreateUserBody

    if (!body.email || !body.name) {
      res.writeHead(400, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify({
        error: 'Validation failed',
        details: { email: 'required', name: 'required' }
      }))
      return
    }

    const newUser = {
      id: generateId(),
      ...body,
      createdAt: new Date().toISOString()
    }

    database.users.push(newUser)

    res.writeHead(201, {
      'Content-Type': 'application/json',
      'Location': `/api/users/${newUser.id}`
    })
    res.end(JSON.stringify(newUser))
  }
}

PUT - Update Resource ​

typescript
{
  pattern: '/users/{id}',
  method: 'PUT',
  handle: async (req, res) => {
    const userId = req.params.id
    const body = req.body as { name: string; email: string }

    const index = database.users.findIndex(u => u.id === userId)

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

    database.users[index] = { id: userId, ...body }

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

DELETE - Remove Resource ​

typescript
{
  pattern: '/users/{id}',
  method: 'DELETE',
  handle: async (req, res) => {
    const userId = req.params.id
    const index = database.users.findIndex(u => u.id === userId)

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

    database.users.splice(index, 1)

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

Protected Endpoint with authenticate ​

Use authenticate: true for a quick Bearer-token gate, or pass a function for full custom logic:

typescript
// Require the Authorization header to be present
{
  pattern: '/profile',
  method: 'GET',
  authenticate: true,
  handle: async (req, res) => {
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify({ user: 'me' }))
  }
}

// Token validation with async function
{
  pattern: '/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) => {
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify({ orders: [] }))
  }
}

Simulating Errors ​

typescript
{
  pattern: '/unstable',
  method: 'GET',
  handle: async (req, res) => {
    if (Math.random() < 0.3) {
      res.writeHead(503, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify({ error: 'Service temporarily unavailable' }))
      return
    }

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

Disabling a Handler ​

Use disabled: true to temporarily turn off a handler without removing it:

typescript
{
  pattern: '/users/{id}',
  method: 'GET',
  disabled: true,
  handle: async (req, res) => {
    // This handler is skipped; the request falls through to the next match
  }
}

Authentication ​

The authenticate option lets you protect individual handlers without writing authentication logic inside the handler body. It is evaluated before the handler runs, and automatically responds with 401 Unauthorized when the check fails.

typescript
type UniversalApiAuthenticate =
  | false                                                         // no check (default)
  | true                                                          // require Authorization header
  | string                                                        // require a specific header
  | ((req: IncomingMessage) => boolean | Promise<boolean>)        // custom predicate

Require the Authorization header:

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

Require a custom header (e.g. an API key):

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

Custom async validation:

typescript
{
  pattern: '/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) => { /* ... */ }
}

Note: If the authenticate function throws an uncaught error, the plugin responds with 500 Internal Server Error. Always handle expected error cases by returning false rather than throwing.

Note: authenticate runs before middleware and body parsing. The req object inside the predicate is the raw IncomingMessage — req.body, req.params, and req.query are not yet available.

Next Steps ​

Released under the MIT License.