258 lines
5.8 KiB
JavaScript
258 lines
5.8 KiB
JavaScript
const {RequestHandler} = require('./RequestHandler');
|
|
const fs = require('fs');
|
|
const util = require('util');
|
|
const zlib = require('zlib');
|
|
|
|
const MATCH_INDEX = new RegExp('^(.*/)index\\.[^./]+$', 'i');
|
|
const PREF_ENCODINGS = ['gzip', 'deflate', 'identity'];
|
|
|
|
function passthroughMapper(file, type, data) {
|
|
return data;
|
|
}
|
|
|
|
function bufferIfBetter(buffer, check) {
|
|
if(buffer.byteLength < check.byteLength) {
|
|
return buffer;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getIndent(strings) {
|
|
let maxLen = 0;
|
|
for(const string of strings) {
|
|
maxLen = Math.max(maxLen, string.length);
|
|
}
|
|
let indent = '';
|
|
for(let i = 0; i < maxLen; ++ i) {
|
|
indent += ' ';
|
|
}
|
|
return indent;
|
|
}
|
|
|
|
class StaticRequestHandler extends RequestHandler {
|
|
constructor(baseUrlPattern) {
|
|
super('GET', new RegExp('^' + baseUrlPattern + '([^?]*)(\\?.*)?$'));
|
|
|
|
this.baseUrlPattern = baseUrlPattern;
|
|
this.mimes = new Map();
|
|
this.defaultMime = 'text/plain; charset=utf-8';
|
|
this.resources = new Map();
|
|
this.awaiting = [];
|
|
this.watchFiles = false;
|
|
this.watchers = [];
|
|
|
|
this.compressionOptions = {
|
|
level: 9,
|
|
memLevel: 8,
|
|
windowBits: 15,
|
|
};
|
|
}
|
|
|
|
_handleResource(req, res, resource, {pickEncoding, log}) {
|
|
log('SERVE ' + resource.path);
|
|
|
|
this.applyCommonHeaders(req, res);
|
|
const encoding = pickEncoding(
|
|
PREF_ENCODINGS.filter((enc) => (resource.encodings[enc] !== null))
|
|
);
|
|
if(encoding !== 'identity') {
|
|
res.setHeader('Content-Encoding', encoding);
|
|
}
|
|
res.setHeader('Content-Type', resource.type);
|
|
res.end(resource.encodings[encoding]);
|
|
}
|
|
|
|
handle(req, res, info) {
|
|
const resource = this.resources.get(info.match[1]);
|
|
if(!resource) {
|
|
return false;
|
|
}
|
|
return this._handleResource(req, res, resource, info);
|
|
}
|
|
|
|
_addWatchers(log) {
|
|
this.watchers.forEach((entry) => {
|
|
if(entry.watcher) {
|
|
return;
|
|
}
|
|
const makeWatcher = () => fs.watch(entry.file, () => {
|
|
/*
|
|
* If editor changed file by moving it, we are now
|
|
* watching the wrong file, so re-create watcher:
|
|
*/
|
|
entry.watcher.close();
|
|
entry.watcher = makeWatcher();
|
|
|
|
entry.fn().then((changed) => {
|
|
if(changed) {
|
|
log('RELOADED ' + entry.file);
|
|
}
|
|
});
|
|
});
|
|
entry.watcher = makeWatcher();
|
|
});
|
|
}
|
|
|
|
_add(path, type, content) {
|
|
let data = content;
|
|
if(typeof content === 'string') {
|
|
data = Buffer.from(content, 'utf8');
|
|
}
|
|
const existing = this.resources.get(path);
|
|
if(existing && data.equals(existing.encodings.identity)) {
|
|
return Promise.resolve(false);
|
|
}
|
|
return Promise.all([
|
|
data,
|
|
util.promisify(zlib.deflate)(data, this.compressionOptions),
|
|
util.promisify(zlib.gzip)(data, this.compressionOptions),
|
|
])
|
|
.then(([identity, deflate, gzip]) => {
|
|
const resource = {
|
|
encodings: {
|
|
deflate: bufferIfBetter(deflate, identity),
|
|
gzip: bufferIfBetter(gzip, identity),
|
|
identity,
|
|
},
|
|
path,
|
|
type,
|
|
};
|
|
|
|
const match = MATCH_INDEX.exec(path);
|
|
if(match) {
|
|
this.resources.set(match[1], resource);
|
|
}
|
|
this.resources.set(path, resource);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
_addFile(path, file, mapper) {
|
|
const type = this.mimeTypeFor(file);
|
|
const map = mapper || passthroughMapper;
|
|
const fn = () => util.promisify(fs.readFile)(file)
|
|
.then((data) => map(file, type, data))
|
|
.then((data) => this._add(path, type, data));
|
|
this.watchers.push({file, fn, watcher: null});
|
|
return fn();
|
|
}
|
|
|
|
_addDir(path, file, mapper) {
|
|
return util.promisify(fs.readdir)(file)
|
|
.then((subFiles) => this._addResources(
|
|
path + '/',
|
|
file + '/',
|
|
subFiles.filter((sub) => !sub.startsWith('.')),
|
|
mapper
|
|
));
|
|
}
|
|
|
|
_addResource(path, file, mapper) {
|
|
return util.promisify(fs.stat)(file)
|
|
.then((stats) => {
|
|
if(stats.isDirectory()) {
|
|
return this._addDir(path, file, mapper);
|
|
} else {
|
|
return this._addFile(path, file, mapper);
|
|
}
|
|
});
|
|
}
|
|
|
|
_addResources(basePath, baseFs, files, mapper) {
|
|
return Promise.all(files.map((file) => this._addResource(
|
|
basePath + file,
|
|
baseFs + file,
|
|
mapper
|
|
)));
|
|
}
|
|
|
|
addMimeType(extension, type) {
|
|
this.mimes.set(extension, type);
|
|
return this;
|
|
}
|
|
|
|
mimeTypeFor(file) {
|
|
const ext = file.substr(file.lastIndexOf('.') + 1);
|
|
return this.mimes.get(ext) || this.defaultMime;
|
|
}
|
|
|
|
add(path, type, content = null) {
|
|
let promise = null;
|
|
if(content === null) {
|
|
promise = this._add(path, this.mimeTypeFor(path), type);
|
|
} else {
|
|
promise = this._add(path, type, content);
|
|
}
|
|
this.awaiting.push(promise);
|
|
return this;
|
|
}
|
|
|
|
addFile(path, file, mapper = null) {
|
|
this.awaiting.push(this._addFile(path, file, mapper));
|
|
return this;
|
|
}
|
|
|
|
addDir(basePath, baseFile, mapper = null) {
|
|
this.awaiting.push(this._addDir(basePath, baseFile, mapper));
|
|
return this;
|
|
}
|
|
|
|
addResource(path, file, mapper = null) {
|
|
this.awaiting.push(this._addResource(path, file, mapper));
|
|
return this;
|
|
}
|
|
|
|
addResources(basePath, baseFs, files, mapper = null) {
|
|
this.awaiting.push(
|
|
this._addResources(basePath, baseFs, files, mapper)
|
|
);
|
|
return this;
|
|
}
|
|
|
|
setFileWatch(enabled) {
|
|
this.watchFiles = enabled;
|
|
return this;
|
|
}
|
|
|
|
printInfo(target) {
|
|
target.write(
|
|
'Serving static resources at ' +
|
|
(this.baseUrlPattern || '/') + ':\n'
|
|
);
|
|
|
|
const indent = getIndent(this.resources.keys());
|
|
|
|
for(const [path, res] of this.resources) {
|
|
const {encodings} = res;
|
|
target.write(path + indent.substr(path.length));
|
|
target.write(' ' + encodings.identity.byteLength + 'b');
|
|
for(const enc of ['gzip', 'deflate']) {
|
|
const buf = encodings[enc];
|
|
if(buf !== null) {
|
|
target.write(', ' + enc + ': ' + buf.byteLength + 'b');
|
|
}
|
|
}
|
|
target.write('\n');
|
|
}
|
|
}
|
|
|
|
begin({log}) {
|
|
return Promise.all(this.awaiting).then(() => {
|
|
if(this.watchFiles) {
|
|
this._addWatchers(log);
|
|
}
|
|
});
|
|
}
|
|
|
|
close() {
|
|
this.watchers.forEach((entry) => {
|
|
if(entry.watcher) {
|
|
entry.watcher.close();
|
|
entry.watcher = null;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = {StaticRequestHandler};
|