server-lilypads
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
- Calls the
responder
function. - 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.
lifetime
has expired
Fourth request, assuming provided - Immediately serves the previous result (compressed if compression is done).
- 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'
});
headers
A note on custom 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 3000
ms. 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.
lilypads
Multiple layers of 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 3000
ms. 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.
lilypad
(Object
)
Returns
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.