node-fetch-event

0.0.4-b • Public • Published

node-fetch-event

The node-fetch-event library is an implementation of the FetchEvent paradigm used by several severless function providers. It is an open source means of:

  1. testing serverless functions in a local environment,

  2. moving serverless functions to alternate hosting operations should the capabilities of the severless provider not meet the business needs of the developer, e.g. memory or response time limits, access to additional NodeJS libraries, etc.

  3. developing and hosting services from scratch using the FetchEvent pattern.

The libray includes support for CacheStorage and Cache, TextDecoder, TextEncoder, Web Crypto, atob and btoa, routes, data stores (including a built-in Cloudflare compatible KVStore), and importing/requiring almost any NodeJS module.

The library exposes the ability to control the idle time, heap, stack, code size, response time, and CPU usage allowed for any request response.

The code is currently in an BETA state and has not yet been tested on Windows.

Acknowledgements

Installing

Writing Code

Running A Node Fetch Event Server

Cache

Environment Variables and Data Stores

Routes

Security

Internals

Release History

Installing

npm install node-fetch-event

Usage

The core functions behave as defined by Cloudflare:

  1. addEventListener

  2. event.respondWith

  3. event.waitUntil

  4. event.passThroughOnException is not supported. Use the global server option workerFailureMode instead.

Writing Code

Write your worker code as you normally would, except conditionalize target environment variables if you wish to continue deployment to serverless hosts while also running in the node-fetch-event environment:

 
// define vars for any items the target serverless environment may provide automatically
// for example, in Cloudflare this might be a KV store binding
var MYKV;
 
// define other variables you optionally need
var process,
    reverse;
    
 
// conditionally bind varibales based on something that will not be in the normal target environment
// requireFromUrl is provided by node-fetch-event
if(typeof(requireFromUrl)!=="undefined") {
 
    MYKV = new KVStore("mykv"); // KVStore is a Cloudflare compatible local key-value store
 
    // if your target environment supports node modules, you can require them
    // the node-fetch-event server supports all NodeJS modules
    process = require("process");
    
    // you can also use remote modules, 
    reverse = requireFromUrl("http://localhost:8082/reverse.js");
}
 
async function handleRequest(request) {
    return new Response("hello world");
}
 
addEventListener("fetch",(event) => {
    event.respondWith(handleRequest(event.request));
})

Running A Node Fetch Event Server

A server is provided as part of node-fetch-event. Just start it from the command line or require it and it will start running:

node -r esm ./node_modules/node-fetch-event/server.js

or

require("node-fetch-event")()

or

import {server} from "node-fetch-event";
server();

By default the server runs on http://localhost:3000 and looks for a worker.js file in the directory from which is was launched. So if you have the example code above in worker.js and type http://localhost:3000 into a browser you will see hello world.

Server Options

Of course, you can provide options to control the server, e.g. server(options). They have the surface:

{
    "protocol": "http" || "https" // https not yet supported
    "hostname": // defaults to localhost
    "port": // defaults to 3000
    "maxServers": // default:1, maximum is automatically reduced to the number of cores on the computer where server is run
                  // strongly recommended to use 2 or more to have a cluster that will restart on errors
    "defaultWorkerName": // default:"worker", the default worker file name (without .js extension) for routes ending in /
    "cacheWorkers": // default:false, a new worker is loaded for every request,
                    // if true (for production) the worker is cached after the first load,
                    // if a number, assumes to be seconds at which to invalidate cache for a worker
                    // can be overriden per route
    "workerSource": // optional, the host from which to serve workers, if not specifed tries files in `process.cwd()` and then `__directory`
    "workerFailureMode": "open" || "error" || "closed", 
                    // default: open, returns a 500 error with no message, 
                    // error returns a 500 error with Error thrown by worker, recommended while in BETA
                    // closed (or any other value) never responds, requesting client may hang,
    "workerLimits": { // default resource limits for Workers, may be overriden at route level
        "maxAge": // optional, max cachng time before a new verson of the worker is loaded
        "maxIdle": 60000, // default: 1 minute, worker terminated if no requests in the period
        "maxTime": 15000, // default: 15 seconds ,overages abort the request
        "cpuUsage": 10, // default: 10ms, max CPU usage for an individual request, overages abort the request
        "maxOldGenerationSizeMb": 256 // default: 256mb,coverages abort the request
        "maxYoungGenerationSizeMb": 256 // default: 256mb, overages abort the request
        "stackSizeMb": 4, // default: 4 MB, overages abort the request
        "codeRangeSizeMb": 2, // default: 2mb, overages abort the request
    },
    "keys": // https cert and key paths or values {certPath, keyPath, cert, key}, not yet supported
    "kvStorage": // a storage engine class to put behind the built in KVStore class. Not yet implemented. Will support a remote, centralized, eventually consistent server.
    "routes": // default:"/", optional, maps paths to specific workers, if missing, the value of defaultWorkerName is loaded
              // can also be a path or URL to a JSON file or a JavaScript file
}

One of the key advantages of FAAS providers is their CDNs or distributed hosting. If you have the resources to establish NodeJS servers in multiple locations, then you can effectively have your own CDN by designating one server to be the source of your workers in the workerSource option. The node-fetch-server will fetch new versions based on maxAge data in route specifications or cacheWorkers.

HINT: If you set cacheWorkers to false during development, you will not have to restart your server when you change the worker code, just reload your browser.

Note: workerFailureMode may not work as expected during BETA. With clustering and Workers, the node-fetch-event server is very resilient to crashes, but they could occur.

Cache

There is a CacheStorage implementaton as part of node-fetch-event. The node-fetch-event server always exposes CacheStorage, Cache, caches and caches.default.

Cache persists to disk as subdirectories of a the directory __directory/cache-storage. Cache-Control and Expires headers are respected.

Caches are shared across the server cluster and Workers.

In the current version of the BETA, the options {ignoreSearch,ignoreMethod,ignoreVary} are ignored. Only URL paths are matched.

Environment Variables and Data Stores

In some cases, e.g. Cloudflare, your hosting provider will automatically add variables to your serverless function. If not, you will also need to conditionally add them.

The node-fetch-event server exposes KVStore with the same API as Cloudflare. However, in the BETA the storage is just local to the server and the limit and cursor options to list are ignored.

var MYSECRET;
if(typeof(requireFromUrl)!=="undefined") {
    MYSECRET = "don't tell";
}

async function handleRequest(request) {
    return new Response(MYSECRET);
}

addEventListener("fetch",(event) => {
    event.respondWith(handleRequest(event.request));
})
// no need to conditionalize if you are not worried about hosting on something other than `node-fetch-event`s server.
var MYKV = new KVStore("testkv");

async function handleRequest(request) {
    await MYKV.put("test",{test:1});
    const value = await MYKV.get("test");
    return new Response(JSON.stringify(value),{headers:{"content-type":"application/json"}})
}

addEventListener("fetch",(event) => {
    event.respondWith(handleRequest(event.request));
})

Response Pseudo Streaming

If a Response is created with an undefined value, the Response objects in node-fetch-event have the additional methods:

  1. write

  2. end

These DO NOT immediately stream to the client. Internally, the Response creates a readable stream on a buffer into which data is pushed by write and end. This can be conveniently processed by the standard body reading methods.

Note: Use new Response() or new Response(undefined,options) not new Response(null) to get this behavior.

You can also create Responses with writable streams. When returned by respondWith, the stream is considered complete and the buffer underlying the stream is written to the client. Continued attempts to write to the buffer will result in illdefined behavior.

Routes

Routing is not strictly part of the FetchEvent paradigm, but you may need to be able to invoke different workers based on different requests; hence, a router is provided.

The server route specification is an object the keys of which are pathnames or methods to match the request URL. (Query strings are not used in the match for the BETA). The keys can contain substitution variables denoted by : like most routers. The route keys can also be regular expressions starting with "/" and ending with "/". Options flags are not supported. Ambiguity with path naming conventions and slashes is addressed by explicit path testing first, followed by trying the same key as a regular expression. Errors in this second test are simply ignored.

For basic use and route specification using just a JSON file, the values are objects that define the context in which a worker can run. They have the surface {path,useQuery,maxAge,timeout,maxIdle,maxTime,cpuUsage,maxOldGenerationSizeMb,maxYoungGenerationSizeMb,stackSizeMb,codeRangeSizeMb}. For advanced use where the route specification file is JavaScript, see advanced routes.

path - can be relative to the directory from which the server is running, or a remote URL.

useQuery - a boolean, if true tells the server to parse query string values into parameters to pass to the Worker. See Routes and Query Strings.

See the server options documentation, workerLimits, for definitions of other route options.

Here is a basic example:

{
    "/": {
        "path": "/worker.js"
    },
    "/hello": {
        "path": "/worker.js"
    },
    "/bye": {
        // workers can be at remote URLs, which overrides workerSource startup option
        "path": "http://localhost:8082/goodbye.js",
        // get a new copy of the worker after ms, the server option `cacheWorkers` must be set to true or a number (which this overrides)
        "maxAge": 36000,
        // return a timeout error to client after ms
        "maxTime": 2500,
         // stop if no requests recieved for ms, e.g. one minute
        "maxIdle": 60000
    }
}

Here is an example with a regular expression:

{
    "/": {
        "path": "/worker.js"
    },
    "/\/hello.*/": { // starts with hello
        "path": "/worker.js"
    },
    "/\/bye.*/": { // starts with bye
        "path": "/goodbye.js",
    }
}

Route values can be selected based on the lowercase form of the request method:

{
    "/": {
            "get": {
                "path": "worker"
            }
        },
    "/\/hello.*/": {
        "path": "worker"
    },
    "/\/bye.*/": {
        "path": "http://localhost:8082/goodbye.js",
        "maxAge": 36000
    }
}

Top level route selection can also be based on the lowercase form of the request method:

{
    "get": {
        "/": {
            "path": "/worker.js"
        },
        "/\/hello.*/": {
            "path": "worker"
        },
        "/\/bye.*/": {
            "path": "http://localhost:8082/goodbye.js",
            "maxAge": 36000
        }
    }
}

Method selection takes precedence over path matching, so the first route below will never match in the case of "GET". Likewise, "DELETE" will never match the second route value.

{
    "/": {
        "path": "/worker.js"
    },
    "get": {
        "/\/hello.*/": {
            "get": {
                "path": "worker",
            },
            "delete": {
                "path": "otherworker",
            }
        },
        "/\/bye.*/": {
            "path": "http://localhost:8082/goodbye.js",
            "maxAge": 36000
        }
    }
}

Routes and Query Strings

Query strings from the Request be parsed and passed to the worker as an object value of the Request property params, so long as useQuery is set to true. The useQuery option exists to prevent the use of query strings as attack vectors.

The routes:

{
    "/message": {
        "useQuery": true,
    }
}

The worker at /message:

async function handleRequest(request) {
  const response = new Response(request.params.content);
    response.headers.set("content-type","text/html");
    return response;
}
 
addEventListener("fetch",(event) => {
    event.respondWith(handleRequest(event.request));
})

The request URL:

http://localhost:3000/message?content=goodbye

Will invoke the worker with the Request object:

{
    "url": "http://localhost:3000/?content=goodbye",
    "params": {
        "message": "goodbye"
    }
}

Attempts are made to parse the query strings with JSON.parse, so numbers will actually be numbers, booleans are boolean and even {} or [] delimited things will be objects and arrays. Note: For objects and arrays you will need to quote properties.

Parameterized Routes

Routes can also contain parameters to serve as defaults, e.g.:

{
    "/message/:content": {
        "path": "message.js",
        "params":{"content":"hello world"}
    }
}

The values in a the client query string will take priority over parameters from the route.

The request URL:

http://localhost:3000/message

Will invoke the worker with the Request object:

{
    "url": "http://localhost:3000/message",
    "params": {
        "message": "hello world"
    },
    ... other properties ...
}

Advanced Routes

When loaded from a JavaScript file, route values can also be arrays of functions that behave like Express routes, except that next() can also take a worker specification as an argument in addition to nothing or 'route'. Alternatively, the array elements can be async functions that resolve to 'route', a worker specification, or undefined.

Here is a standard route:

 
{
    "/myroute": [ (req,res,next) => { res.write("not done); next(); },(req,res) => res.end("done") ]
}

Here is a worker route as an array of Express route functions:

 
{
    "/myroute": [ (req,res,next) => { if(securityCheck(request)) { next(); } else { next('route'); } },(req,res,next) => next({...someWorkerSpec}) ]
}

Here is the same route using async functions. There is no need to remember to call next()!:

 
{
    "/myroute": [ async (req,res) => { if(!securityCheck(request)) return 'route' },async (req,res) => {...someWorkerSpec} ]
}

When a worker specification is invoked via next or returned by an async route function, no writes to the response body should have occured. If a write has occured, an error will be thrown. If there are any functions after the one that calls next with the worker spec, the Response they receive will be the one created by the worker. Additionally, at this point the route is committed and calling next('route') will abort processing but NOT continue to the next route. Typically, the function calling the worker will be the last in the chain. There can only be ONE worker call in a route.

Security

Because the fetch-event server exposes the capability to load routes, worker configurations, and worker code from URLs, you should ensure that you control the source server for routes and worker configurations. Theoretically, the isolation created through the use of both a cluster server and worker threads should sufficiently isolate worker code so that you can load modules via URLs from friendly servers that you may not control. However, this is currently BETA software and caution should be used.

The webcrypto library used to support crypto in the workers is considered experimental by its author.

Internals

Internally, the node-fetch-event server isolates the execution of requested routes to Node worker_threads and runs it's http(s) request handler using Node cluster.

Acknowledgements

In addition to the dependencies in package.json, portions of this library use source code from the stellar node-fetch.

Release History (reverse chronological order)

2020-09-11 v0.0.4b Documentation updates. Router enhancements. Extracted worker code into service-worker.js file. Deprecated support for query string paramegters in a route definition for default. Use params instead. Query strings in route paths can now be used by remnote servers to configure the route source. Workers can now be pipelined in routes.

2020-09-09 v0.0.3b Documentation updates. Router enhancements.

2020-09-01 v0.0.2b Documentation updates.

2020-09-01 v0.0.1b Added unit tests. Fully implemented CacheStorage with exception of {ignoreSearch,ignoreMethod,ignoreVary}.

2020-08-31 v0.0.7a Added unit tests and fixed numerous issues as a result. Add CacheStorage. Eliminated setting backing store for Cache (for now). Added WriteableStream support to Response. Removed standalone option. Clustering is automatic if maxServers>=2, otherwise standalone Headers now properly returned by Workers Worker limits at server now properly set default and can be overriden by routes Anticipate this is the final ALPHA

2020-08-27 v0.0.6a Added additional missing imports for each worker, e.g. atob, TextEncoder, crypto, etc.

2020-08-27 v0.0.5a Added KVStore. Improved documentation.

2020-08-26 v0.0.4a Improved routing

2020-08-26 v0.0.3a Simplified stylized coding. Made workers into threads. Added regular expression routes. Removed streaming.

2020-08-24 v0.0.2a Added route and cache support

2020-08-24 v0.0.1a First public release

Dependencies (16)

Dev Dependencies (7)

Package Sidebar

Install

npm i node-fetch-event

Weekly Downloads

3

Version

0.0.4-b

License

AGPL, v3.0

Unpacked Size

135 kB

Total Files

40

Last publish

Collaborators

  • anywhichway