Check the new, 100% code covered, µcdn instead 👍
A tiny static files serving handler
This module has been developed on top of holdon utility.
While this script is the fastest on Internet of Things devices such Arduino Yun or Raspberry PI, it has also been tested on regular servers and it has demonstrated to be both reliable and on average 2X or more faster than common nodejs solutions.
npm install -g tiny-cdn
Used as module, tiny-cdn
exports a function with a public .create
method which is simply an alternative way to invoke the module.
The function will return a function usable as serving middle-ware.
var
tinyCDN = require('./tiny-cdn'),
siteCDN = tinyCDN({ ..configuration object ..})
;
require('http')
.createServer(function (request, response) {
if (/^\/(?:js|css|img|assets)/.test(request.url)) {
siteCDN(request, response);
} else {
// dynamic content generation in here
}
})
.listen(8080, '0.0.0.0')
;
If the purpose of the machine is to serve static files only, this is also possible.
require('http')
.createServer(require('./tiny-cdn').create({
// the configuration object
}))
.listen(8080, '0.0.0.0');
tinyCDN({...})
function accepts a configuration object.
Following a description of all accepted properties:
var tinyCDN = require('./tiny-cdn').create({
// where are original files ?
// this should be the root of client requests
// as example, /js/main.js should be a file
// originally saved in ./source/js/main.js
// the folder does not have to be publicly reachable
source: './source',
// where should ETags and Compressed files be stored ?
// this folder will be populated with respective etags
// per each requested file and their gzip or deflate version
dest: './dest',
// optional, if specified will create
// gzip and deflate version of every request
// in case the requesting UA is compatible.
//
// If specified as string,
// it can be one of these values:
// best // best compression
// speed // fastest compression
// default // default compression
// no // no compression ( kinda pointeless )
//
// If specified as number,
// it could be an integer from 1 to 9
// that will mirror the compression level
// accordingly with current zlib API compression values
compression: 'best',
// optional, if specified will activate compression
// and it will perform it only for the list of known extensions
// such list can be passed as Array of extensions
// with values like 'txt' or '.css'
// by default, if compression is enabled,
// the list of known files will be the following one
compress: [
'js', 'css', 'txt', 'html',
'svg', 'md', 'htm', 'xml', 'json', 'yml'
],
// optional, if specified will create
// an etag related file per each request kind
// (raw, gzip, deflate)
// it can be any valid crypt hash
// and by default it will be the sha256 one
etag: 'sha256',
// optional, if specified will limit
// the amount of connection that cpould be piped
// as streams when any file is served
maxListeners: Infinity, // as deafault
// optional, if specified will define
// the masimum age any ETag should last
maxAge: 30672000, // as deafault
// optional, if set every file will send
// the Access-Control-Allow-Origin header
// with the specified value
accessControlAllowOrigin: '', // as deafault
// if cluster is used and you'd like
// to not use optimizations made for multiple workers
// feel free to force this flag to true (false by default)
ignoreCluster: false, // as deafault
// optional, if specified will define
// the Not Found content per each page
// served accordingly with common readind standards
404: {
// either html or HTML, could be any HTML page
html: 'Not Found',
// or
HTML: 'Not Found',
// either json or JSON, could be any JSON
json: '{"error":"Not found"}',
// or
JSON: '{"error":"Not found"}',
// either txt or TXT, could be any TXT
txt: 'Not Found',
// or
TXT: 'Not Found'
},
// optional, if specified will be invoked
// every time an erro occurs.
// by default tinyCDN will take care of everything
onError: function (
// the error that triggered this callback
err,
// the requested url that generated the error
url,
// the list of requestes waiting for a response.
// do not use an error handler if you don't have
// a good way to serve failures.
// Use simply `onResponse` handler instead
// to be notified, if that's what you are looking for
held
) {
// do your stuff here ...
},
// optional, if specified will be invoked
// every time a request completes.
// It will eventually provide the error object
// and the url as second argument.
// But there won't be anything to do once this triggers
onResponse: function (
// the error, if any, that happened while serving
err,
// the url that successfully, eventually, got delivered
url
) {
// do your stuff here ...
}
});
If used directly as executable, tinyCDN
is capable of creating an http or https server, pre-build a file and its compressed versions, or generate a configuration JSON/object content to read and test.
./tiny-cdn [run] [options]
Mandatory options
-s | --source the source folder with static content
-d | --dest the destination folder for ETags / gzip
Configuration options
-c | --compression use compression and optionally specify its level
best, speed, default, or an integer
-cl | --compress-list a comma separated list of extension to compress
-e | --etag use etag and optionally specify its algorithm
by default it's sha256
-ma | --max-age the cache max-age header in seconds (default: 30672000)
-ml | --max-listeners the maximum amount of listeners to use per each stream
by default there is no limit
-ic | --ignore-cluster if true (or empty) will never use master/cluster logic
SSL options
-ssl-cert will use the provided cert file to run HTTPS instead of HTTP
-ssl-key will use the provided key file to run HTTPS instead of HTTP
Network options
-h | --host | -ip if specified, will be used as server address
-p | --port if specified, will be used as port
Build file option
-b | --build if specified will pre build a file
creating in the destination folder
the gzip and deflate version of the file
plus the ETag per each version
This is the minimum requirement to run an http server:
tiny-cdn run
Above snippet will start tinyCDN
static file serving in the current folder without creating etags or compressed files.
Handy to quickly test some directory content statically.
Please note that point at a generic /folder/
will automatically check for an index.html
file within that folder.
Reaching the default shown url will also look for an index.html
file and will return a Not Found error if none is provided.
It is possible to specify both source and destination though:
tiny-cdn run -s=./source -d=./dest
To use compression and etags, we can add related flags too:
tiny-cdn run -s=./source -d=./dest -e -c
To use a specific version of the etag or compression
tiny-cdn run -s=./source -d=./dest -e=md5 -c=speed
To specify a different host or port
tiny-cdn run -s=./source -d=./dest -h=192.168.1.10 -p=4321 -e -c
The build flag will create only what will be served to every request accordingly with the configuration options.
For instannce, assuming we have source/js/main.js
file in the source folder,
the following command will do basically nothing:
tiny-cdn -s=./source -d=./dest -b=/js/main.js
because neither etag nor compression flag is required.
However, if we use the ETag flag:
tiny-cdn -s=./source -d=./dest -b=/js/main.js -e
the builder will create the file ./dest/js/main.js.raw.sha256
with its ETag content.
Using the compression flag will create both gzip and deflate version of the file.
tiny-cdn -s=./source -d=./dest -b=/js/main.js -c
Now we'll have also ./dest/js/main.js.raw.gzip
and ./dest/js/main.js.raw.deflate
files.
Putting everything together will have also ./dest/js/main.js.raw.gzip.sha256
and ./dest/js/main.js.raw.deflate.sha256
files generated.
tiny-cdn -s=./source -d=./dest -b=/js/main.js -c -e
If the -ssl-cert=file.crt
and the -ssl-key=file.key
are provided, the CLI will create a https server instead.
Please note that if you created the certificate using your local network IP address, you might need to specify it as -h=192.168.1.5
in order to have a link in console that will point to the authorized page.
Otherwise you can always write down manually that IP address when visiting the CDN.
If you have no idea how to create a certificate, feel free to read this "How To" page.
Following an example on how to run SSL and HTTPS server
./tiny-cdn run \
-s=source/ \
-ssl-key=~/.server/192.168.1.5.key \
-ssl-cert=~/test/https/192.168.1.5.crt \
-h=192.168.1.5
# will log
# [tinyCDN] running on https://192.168.1.5:7151/
In case you'd like to automatically generate a configuration file for your CDN, or simply read how it looks like, skip run
and build options and just execute the cli
tiny-cdn -s=./source -d=./dest -c -e
/* [tinyCDN] */{
"source": "/home/yourname/cdn/source",
"dest": "/home/yourname/cdn/dest",
"compression": "best",
"etag": "sha256"
}
It is helpful to also understand defaults and verify folders.
The main reason tinyCDN is 2X faster is disk access which, even if executed in an asynchronous non-blocking way, is usually repeated per each request.
All common operations like file stats access, runtime etag or gzip compression, and even piping the output, are performed through a single-request model, which will serve every other connection that meanwhile asked for the same file.
This is a great model specially on Internet of Things devices, where every disk operation might have a huge cost, specially the writing one.
When the cluster
module is in place, we can use every available core to serve our CDN.
This is great, and it works already out of the box with common solutions, however there could be cases where a single file that should be compressed at runtime, will be asked potentially from different workers.
What usually happens is that each worker will perform the same operation if the file wasn't there, unable to know another worker maybe is going to create such file.
Due to the nature of cluster and workers, the holdon module (as it is) cannot coordinate in a single core requests from every CPU, so here it comes the converger, which role is to be a cluster director.
This helper does basically one thing: it forces asynchronous methods to be execute only in Master, providing a regular JS looking interface that is transparent to human eyes.
var
cluster = require('cluster'),
fs = require('fs'),
numCPUs = require('os').cpus().length,
Converger = require('tiny-cdn').Converger,
// it should be defined no matter if this is master or worker
// it will perform in master anyway
master = new Converger({
readFile: function (path, onFileRead) {
// just to be sure we are reading from master
console.log('reading from ' +
(cluster.isMaster ? 'Master' : 'Worker'));
fs.readFile(path, function (err, file) {
onFileRead(err, err || file.toString());
});
}
})
;
// if we have one CPU there's no point to fork this process
if (cluster.isMaster && 1 < numCPUs) {
while (numCPUs--) cluster.fork();
} else {
// but even with a single CPU we can use Converger as it is
master.readFile(__filename, function (err, content) {
// however, if we have multiple cores
// we will receive data per each of them
console.log(
'Read file with length ' + content.length +
(cluster.isMaster ? '' :
(' [worker ' + cluster.worker.id + ']'))
);
});
}
If we run above example we'll see this kind of output:
reading from Master
reading from Master
reading from Master
reading from Master
Read file with length 1098 [worker 2]
Read file with length 1098 [worker 1]
Read file with length 1098 [worker 3]
Read file with length 1098 [worker 4]
Which means the Converger
instance worked as expected, but we have 4 workers asking for the same file so that
in order to satisfy all of them the system will access and read from scratch each time.
Adding holdon to this equation will result in less reading operations.
// ...
// let's hold on readFile
heldFile = cluster.isMaster ?
// since we'd like to do this on master only ...
require('holdon').create(['callback']) : null,
// it should be defined no matter if this is master or worker
// it will perform in master anyway
master = new Converger({
readFile: function (path, onFileRead) {
// absolute path is a unique identifier
if (heldFile.add(path, onFileRead)) {
// only if added as new record
console.log('reading from ' +
(cluster.isMaster ? 'Master' : 'Worker'));
fs.readFile(path, function (err, file) {
// per each worker waiting for a result
heldFile.remove(path).callback.forEach(
function (callback) {
// send already processed arguments
callback.apply(null, this);
},
[err, err || file.toString()]
);
});
}
}
})
// ...
Accordingly with your machine performance, your output should be now like the following one:
reading from Master
Read file with length 1603 [worker 1]
Read file with length 1603 [worker 2]
Read file with length 1603 [worker 3]
Read file with length 1603 [worker 4]
Where reading from Master
could happen twice and the workers order could be completely different.
reading from Master
reading from Master
Read file with length 1603 [worker 2]
Read file with length 1603 [worker 3]
Read file with length 1603 [worker 1]
Read file with length 1603 [worker 4]
In latter case two workers asked in different reading time the same file ... which is OK, it means my machine was that fast (cache).
While this might not look like a huge win on the reading path, this might actually save your Raspberry PI, as well as your server, for all cases when you need some runtime operation like compressing and storing a file or creating its etag.
Those kind of slow operation that specially on SD cards might take "forever".
The writing is indeed a wonderful fit for Converger
which is, together with holdon
module,
capable of bringing in a very good performance compromise even for IoT projects.
However, the serialization channel can be very slow, and serving files entirely read on a single thread broadcasted per each worker becomes a bottleneck, rather than a feature.
Accordingly, beside little content read, how an ETag could be,
or some little information, as a fs.Stats
object could contain,
are a good fit for Converger
pattern but every other reading should be avoided.
The tinyCDN
strategy when it comes to file serving is to pipe all requests at once
when the read stream is ready, and the etag has been retrieved.
The compression, and the etag generation, is done only once, holding every request.
I also need to better benchmark the get stats path and see if it makes sense to pass through the channel, or if I could simply use the thread for every read-only opration.
Copyright (C) 2015 by Andrea Giammarchi - @WebReflection
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.