@amphibian/server-lilypads

blazing fast @amphibian/server requests

Stats

StarsIssuesVersionUpdatedCreatedSize
@amphibian/server-lilypads
3.1.03 years ago4 years agoMinified + gzip package size for @amphibian/server-lilypads in KB

Readme

server-lilypads

build status

blazing fast @amphibian/server requests

npm install @amphibian/server-lilypads

Why, and what it does

I found myself writing a lot of optimised handler functions that were repeating a lot of the optimization techniques over and over again. This module does it for you.

Provided a unique id, lilypads will, after the first request, ensure immediate responses from the server.

Upon an initial first request with the id user-34134-data, it will get the data from the provided responder function. However, next time the same request is made, lilypads will naively, immediately, send the same result as it got from the responder function earlier. lilypads immediately starts compressing results with iltorb, and will serve a compressed response if compression is finished.

In addition to this, lilypads has @amphibian/party built in, which ensures multiple requests to what would give the same response only trigger the responder function once.

Headers

lilypads will automatically set the last-modified header and respond with 304 provided a later or equal if-modified-since date. To set custom headers, see the example below.

Step by step

Assume a request to resource user-34134-data:

First request

  1. Calls the responder function.
  2. Serves the response.

Second request, assuming compression isn't done

Immediately serves the previous result of the responder function.

Third request, assuming compression is done

Immediately serves the previous result, compressed.

Fourth request, assuming provided lifetime has expired

  1. Immediately serves the previous result (compressed if compression is done).
  2. In the background, calls the responder function and swaps the current cache with the new one.

Usage

The simplest example possible looks like this:

import createServer from '@amphibian/server';
import {lilypads} from '@amphibian/server-lilypads';

const server = createServer({port: 4000});

async function indexGetHandler(context) {
    return lilypads(context, {id: 'index'}, () => ({
        success: true
    }));
}

server.registerRouteHandler(indexGetHandler, {method: 'get', path: '/'});

However, optimizing a persistent response is not where the big savings can be made.

import createServer from '@amphibian/server';
import {lilypads} from '@amphibian/server-lilypads';

import {getUserData} from './interfaces/user';

const server = createServer({port: 4000});

async function userGetHandler(context) {
    const {userId} = context.args;

    return lilypads(context, {
        id: `userGetHandler/${userId}`,
        lifetime: 5 * 60 * 1000 // 5 minutes
    }, async () => ({
        success: true,
        user: await getUserData(userId)
    }));
}

server.registerRouteHandler(userGetHandler, {
    method: 'get',
    path: '/user/:userId'
});

A note on custom headers

Setting custom headers should be done after the lilypads function has been called. If the responder function throws an error, you'd most likely not want the custom headers you would like for a response. For example, it doesn't make sense to cache an error for as long as a successful response.

async function userGetHandler(context) {
    const {userId} = context.args;

    await lilypads(context, {
        id: `userGetHandler/${userId}`,
        lifetime: 5 * 60 * 1000 // 5 minutes
    }, async () => ({
        success: true,
        user: await getUserData(userId) // this function might throw an error
    }));

    context.set('cache-control', 'public, max-age=180');
}

A note on error handling

If the lilypads responder encounters an error the first time it runs, it will throw an error. However, if it has already been run successfully, lilypads will swallow the error and send it to the optional errorHandler you can provide.

Consider the following code:

let shouldError = false;

function getUserData(userId) {
    if (shouldError) {
        throw new Error();
    }

    shouldError = true;
    return {user: 'test'};
}

async function userGetHandler(context) {
    const {userId} = context.args;

    await lilypads(context, {id: `userGetHandler/${userId}`}, () => ({
        success: true,
        user: getUserData(userId)
    }));
}

(async () => {
    await userGetHandler({args: {userId: '1'}});
    await userGetHandler({args: {userId: '1'}});
})();

No errors will be thrown because the responder function has already had a successful run. The error can be retrieved by implementing an errorHandler:

// ...

await lilypads(context, {id: `userGetHandler/${userId}`}, () => ({
    success: true,
    user: getUserData(userId)
}), (error) => {
    console.error('This error happened:', error);
});

// ...

However, if the error is thrown before the responder has been run once, successfully, the error is thrown “as normal”:

// ...

try {
    await lilypads(context, {id: `userGetHandler/${userId}`}, () => ({
        success: true,
        user: getUserData(userId)
    }), (error) => {
        console.error('This error happened:', error);
    });
} catch (error) {
    console.error('This error happened:', error);
}

// ...

A note on cache invalidation

Sometimes you make changes in your database that you would like to reflect immediately. Whether you're using lilypads, or leap directly, there's an option to force update a lilypad in the options object: forceUpdate.

It should be set to either sync or async depending on the desired effect. If you make a change that does not need immediate reflection, use async. If not, use sync.

// ...

function getUser(userId, options) {
    const lilypad = await leap({
        ...options,
        id: `getUser/${userId}`
    }, async() => ({
        success: true,
        user: await getUserDataFromDatabase(userId)
    }));

    return lilypad.original;
}

function updateUser(userId) {
    await updateUserInDatabase(userId, {email: 'test@bazinga.com'});
    return getUser(userId, {forceUpdate: 'sync'});
}

// ...

forceUpdate should only be set on the lilpad or leap call when you know there's been a change.

lilypads

lilypads(context, options, responder);

context (Object)

The request context object.

options (Object)

The options.

options.id (String) Required.

Should be unique, yet the same for requests that expect the same response. Function arguments used within responder should probably be represented here in some way. For example:

  • user-34134
  • article-213
options.lifetime (Number)

How long each responder result will live in milliseconds. If undefined, the result lives forever. If set to, eg., 3000, lilypads will get a new version after 3000ms. But it won't throw out the old one until the new one is ready.

options.disableCompression (Boolean)

To disable lilypads compression, set disableCompression to true.

options.forceUpdate (String): sync|async

To force update the lilypad, set forceUpdate to either sync or async. This will ensure the responder function is called.

You have two choices:

sync

The lilypad will call the responder function and resolve upon its completion.

async

The lilypad will resolve immediately, as normal, with the “old” responder result (if any) – but will, in the background, call the responder function to update the lilypad upon its completion.

responder (Function)

The function that returns the request response. It is given no arguments when called. Can return a Promise.

errorHandler (Function)

The function that is given any error encountered running the responder function.

Multiple layers of lilypads

Behind the scenes, the lilypads function is the layer of code that, primarily, interacts with the provided context object.

Sometimes it might be beneficial to add multiple layers of lilypads, without necessarily serving the final response to the context object.

One such scenario is batched requests. Imagine you have an API endpoint that can get the data of multiple user IDs. Given one lilypad, we'd cache that one request where the combination of user IDs was, eg., 123 and 124. In the future, we'd, naturally, like to avoid going to the database to get 123 and 124 when we know we already have them – but on a different lilypad.

You'd solve this if you had multiple layers of lilypads, and this is where leap comes into play.

leap

leap is the underlying functionality that caches, compresses, invalidates, and immediately returns responses if there were previous versions of it.

Example

Imagine you have an API endpoint that accepts everything from one to Infinity user IDs.

async function getUserData(id) {
    const lilypad = await leap({
        id: `getUserData/${id}`,
        lifetime: 5 * 60 * 1000 // 5 minutes
    }, () => userDatabase.get(id));

    return lilypad.original;
}

/**
 * Handler that can get multiple user IDs separated by a plus sign
**/
async function userGetHandler(context) {
    const {userId} = context.args;
    const lilypadsOptions = {
        id: `userGetHandler/${userId}`,
        lifetime: 5 * 60 * 1000 // 5 minutes
    };

    const userIds = userId.split('+');

    if (userIds.length === 1) {
        return lilypads(context, lilypadsOptions, async () => ({
            success: true,
            user: await getUserData(userId)
        }));
    }

    return lilypads(context, lilypadsOptions, async () => ({
        success: true,
        users: await Promise.all(userIds.map((id) => getUserData(id)))
    }));
}

server.registerRouteHandler(userGetHandler, {
    method: 'get',
    path: '/user/:userId'
});

It is also possible to get the lilypad without providing a responder function. Assuming the lilypad already exists, of course.

const id = 'test';
const lilypad = await leap({id}, () => 'my-content');

// No `responder` is provided. This, however, would throw, if lifetime is expired
console.log((await leap({id})).original); // > 'my-content'

Usage

leap(options, responder);
options (Object) Required.
options.id (String) Required.

Should be unique, yet the same for requests that expect the same response. Function arguments used within responder should probably be represented here in some way. For example:

  • user-34134
  • article-213
options.encodings (Array)

Superfluous for the use case in the example above (and should probably be avoided), but given an array of encodings, leap will encode the response, and return that as the body when it's ready.

Currently, br (brotli), is the only accepted compression algorithm.

options.lifetime (Number)

How long each responder result will live in milliseconds. If undefined, the result lives forever. If set to, eg., 3000, leap will get a new version after 3000ms. But it won't throw out the old one until the new one is ready.

responder (Function)

The function that returns the request response. It is given no arguments when called. Can return a Promise.

errorHandler (Function)

The function that is given any error encountered running the responder function.

Returns lilypad (Object)

lilypad.timestamp (Number)

When the current lilypad was created.

lilypad.headers (Object)

Contains the HTTP headers that should go with a response.

lilypad.body

Contains the output body. Might be a compressed Buffer.

lilypad.original

Contains the original response. Not compressed.

lilypad.isResolved (Boolean)

True if the lilypad was resolved. False if an error is thrown the first time it was run, but true otherwise.

lilypad.isCached (Boolean)

True if this version is served from cache.

lilypad.isCompressed (Boolean)

True if lilypad.body is compressed.

lilypad.getCompressedOutput (Function)

Function that returns a Promise that resolves with the compressed output when compressed output is available.

If you find any bugs or have a feature request, please open an issue on github!

The npm package download data comes from npm's download counts api and package details come from npms.io.