sirloin

Node web server for HTTP, web sockets and static files

Usage no npm install needed!

<script type="module">
  import sirloin from 'https://cdn.skypack.dev/sirloin';
</script>

README

Sirloin logo

Sirloin Node.js Web Server

This high performance easy to use web server includes:

  • HTTP server for your APIs and microservices
  • Support for file uploads and post body parsing
  • Fast and minimal, just around 300 lines of code
  • Integrated websocket server based on actions
  • Static file server with compression support
  • Redis pubsub for scaling your websockets
  • Ping pong support terminating dead web sockets
  • Full async / await support
  • HTTPS over SSL support
  • Cookie handling

Zero configuration required, create an HTTP API endpoint with only 3 lines of code. If you're using websockets, the wsrecon library is recommended as you'll get support for auto-reconnect and automatic JSON data handling out of the box.

The websockets are based on the excellent ws library, pubsub is based on ioredis, and the rest is pure vanilla NodeJS.

Install

npm i sirloin

HTTP Server

Supported request methods are GET, POST, PUT, DELETE and PATCH. The response and request parameters are standard Node.js HTTP server Incoming and Outgoing message instances.

The router is just based on string lookup to make it really fast.

const sirloin = require('sirloin')

// Default config shown
const server = sirloin({
  // Web server port
  port: 3000,

  // Static files root directory
  // Set to false to not serve static files
  dir: 'dist',

  // Redirect to this host if no match
  host: 'https://example.com',

  // Callback for websocket connect event
  // Can be used for adding data to the websocket client
  connect: async (client) => {},

  // Redis pubsub is not enabled by default
  pubsub: undefined,

  // HTTPS over SSL support
  ssl: {
    key: '/path/to/server.key',
    cert: '/path/to/server.crt'
  }
})

// Get request, whatever you return will be the response
server.get('/db', async (req, res) => {
  req.method       // Request method
  req.path         // Request path
  req.pathname     // Request path name
  req.url          // Request URL
  req.params       // Post body parameters
  req.query        // Query parameters
  req.files        // Uploaded files
  req.cookie       // Cookie handler
  return { hello: 'world' }
})
// See the documentation on Node.js 'incoming message' (req),
// 'outgoing message' (res) and 'url' for more on what's available.

// Post request, uploads must be post requests
server.post('/upload', async (req, res) => {
  req.files // Array of uploaded files if any
  return { success: true }
})

// Use the '*' for a catch all route
server.get('*', async (req, res) => {
  if (req.path === '/custom') {
    return { hello: 'custom' }
  }
  // Return nothing or undefined to send a 404
})

// Use 'all' to match all HTTP request methods
server.all('/all', async (req, res) => {
  if (['POST', 'GET'].includes(req.method)) {
    return { status: 'OK' }
  }
})

// Use 'any' to match selected HTTP request methods
// This matches 'post' and 'get' to the /any route
server.any('post', 'get', '/any', async (req, res) => {
  return { status: 'OK' }
})

// You can also return HTML templates, strings, numbers and boolean values
server.get('/projects', async (req, res) => {
  res.setHeader('Content-Type', 'text/html; charset=utf-8')
  return '<h1>Hello world</h1>'
})

Middleware

Use middleware to run a function before every request. You can return values from middleware as well and the rest of the middleware stack will be skipped.

// Middleware functions are run in the order that they are added
server.use(async (req, res) => {
  res.setHeader('Content-Type', 'text/html')

  // Enable CORS
  res.setHeader('Access-Control-Allow-Origin', '*')
  res.setHeader('Access-Control-Allow-Credentials', 'true')
  res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control')
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE')
})

// Return directly from middleware to skip further processing
server.use(async (req, res) => {
   const session = await db.session.find({ token: res.query.token })
   if (!session) {
     return { error: 'session not found' }
   }
})

Websockets

Websockets are used through actions, the URL path is irrelevant. Include $action: 'name' in the data you are sending to the server to match your action. Connection handling through ping and pong will automatically terminate dead clients.

Websocket connections are lazy loaded and enabled only if you specify an action. All websocket actions must return Javascript objects (sent as JSON).

// Websocket actions work like remote function calls
server.action('hello', async (data, client) => {
  data             // The data sent from the browser minus action
  client.id        // The id of this websocket client
  client.send()    // Use this function to send messages back to the browser
  client.req       // The request object used to connect to the websocket

  // Return a javascript object to send to the client
  return { hello: 'world' }
})

// Example socket client setup with wsrecon
const wsrecon = require('wsrecon')
const socket = await wsrecon('ws://example.com')

// Normal socket send from the browser, matches the action named 'hello'
socket.send({ $action: 'hello' })

// Get data from the web socket
socket.on('message', function(data) {
  console.log(data) // { hello: 'world' }
})

// Define a '*' action to not use actions
server.action('*', async (data, client) => {
  // Will send what you return
  return { hello: 'custom' }
})

// The client send function supports callbacks and promises
server.action('promise', async (data, client) => {
  // Unordered, the next line happens immediately
  client.send({ hello: 'send' })

  // With promise, the next line happens after this is done
  await client.send({ hello: 'promise' })

  // With callback, the next line happens immediately
  await client.send({ hello: 'promise' }, () => {
    console.log('In callback, sent it')
  })

  // ...
})

// All of the options in the ws library are supported for send
server.action('options', async (data, client) => {
  await client.send({ message: 'terminated' }, { compress: true })

  // Terminate the client when sending is done
  client.terminate()

  // ...
})

Redis Pubsub

If you have more than one app server for your websockets, you need pubsub to reliably publish messages to multiple clients. With pubsub, the messages go via a Redis server, a high performance key-value store.

Sirloin has built in support for pubsub, all you need to do is to install Redis and enable it in your Sirloin config:

// Default config options shown
const server = sirloin({
  pubsub: {
    port: 6379,          // Redis port
    host: 'localhost',   // Redis host URL
    path: null,          // Socket path
    family: 4,           // 4 (IPv4) or 6 (IPv6)
    password: null,      // Redis password
    db: 0,               // Redis database
    channel: 'messages'  // Subscription channel
  }
})

// To use the default options, this is all you need
// Make sure Redis is running before starting your application
const server = sirloin({ pubsub: true }) // or pubsub: {}

// First subscribe to a function
server.subscribe('live', async (data, client) => {
  // Publish data to all clients except publisher (client)
  server.websocket.clients.forEach(c => {
    if (client.id !== c.id) {
      c.send(data)
    }
  })
})

// Use the 'publish' function to publish messages to multiple clients
server.action('publish', async (data, client) => {
  // This will call the subscribed function named 'live' on every app server
  client.publish('live', { hello: 'world' })

  // Publish to all without client, in case you don't have it
  server.pubsub.publish('live', { hello: 'all' })
})

// The publish function works with await
await client.publish('live', { hello: 'world' })

// ... and callbacks
client.publish('live', { hello: 'world' }, () => {
  // Publish is done, notify the publisher
  client.send({ published: true })
})

Pubsub is disabled by default, remove the config or set to 'false' to send messages directly to the socket.

API & Configuration

The server object contains functions and properties that are useful as well:

server.http                        // The HTTP server reference
server.websocket                   // The Websocket server reference
server.websocket.clients           // The connected clients as an array
server.pubsub                      // The pubsub connection info
server.pubsub.channel              // The current pubsub channel name
server.pubsub.publisher            // The publishing pubsub connection
server.pubsub.subscriber           // The subscribing pubsub connection
server.config                      // The active config for the server

// For each client you can send data to the browser
server.websocket.clients.forEach(client => {
  client.send({ hello: 'world' })
})

// Find the client with the 'id' in id and send some data to it
const client = server.websocket.clients.find(c => c.id === id)
client.send({ data: { hello: 'found' } })

Static File Server

Static files will be served from the 'dist' directory by default. Routes have presedence over static files. If the file path ends with just a '/', then the server will serve the 'index.html' file if it exists.

// Set the static file directory via the 'dir' option, default is 'dist'
const server = sirloin({ dir: 'dist' })

// Change it to the name of your static files directory
const server = sirloin({ dir: 'public' })

// Set it to false to disable serving of static files
const server = sirloin({ dir: false })

If the given directory doesn't exist static files will be disabled automatically.

Mime types are automatically added to each file to make the browser behave correctly. The server enables browser caching by using the Last-Modified header returning a 304 response if the file is fresh. This speeds up delivery a lot.

Error Handling

Errors can be caught with try catch inside of middleware, routes and actions.

server.get('/crash', async (req, res) => {
  try {
    const user = await db.user.first()
  } catch (e) {
    console.log(e.message)
    return { error: 'find user crashed' }
  }
})

You can also collect errors in special routes and actions. The 'err' argument is a normal javascript Error instance.

// For middleware and http routes use 'error'
server.error(async (err, req, res) => {
  return { error: err.message }
})

// For websocket actions use 'fail'
server.fail(async (err, data, client) => {
  return { error: err.message }
})

// Trigger error from middleware, will go to 'error' if defined
server.use(async (req, res) => {
  throw new Error('middleware error!')
})

// Trigger error from http route, will go to 'error' if defined
server.post('/db', async (req, res) => {
  throw new Error('http error!')
})

// Trigger error from websocket action, will go to 'fail' if defined
server.action('db', async (data, client) => {
  throw new Error('websocket error!')
})

Examples of Use

Here are a few examples showing how easy to use Sirloin can be:

// File server running on port 3000 (yeah, only one line of code)
require('sirloin')()

// JSON API endpoint without routes (middleware only)
const server = require('sirloin')()
server.use(async (req, res) => {
  return { hello: 'world' }
})

// JSON API endpoint with routes
const sirloin = require('sirloin')
const server = sirloin()
server.get('/', async (req, res) => {
  return { hello: 'world' }
})

// JSON Websocket endpoint
const sirloin = require('sirloin')
const server = sirloin()
server.action('hello', async (data, client) => {
  return { hello: 'world' }
})

See the dev.js file for more examples.

License

MIT Licensed. Enjoy!