node-fetch-event v0.0.4-b
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.
Running A Node Fetch Event Server
Environment Variables and Data Stores
Installing
npm install node-fetch-event
Usage
The core functions behave as defined by Cloudflare:
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