// Copyright 2014-2025 Jesper Larsson
//
// This file is part of Klipspringer, <https://klipspringer.avadeaux.net/>
//
// Klipspringer is free software: you can redistribute it and/or modify it under the terms of the
// GNU General Public License as published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// Klipspringer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
// even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with Klipspringer. If
// not, see <https://www.gnu.org/licenses/>.
import * as http from 'http';
import * as path from 'path';
import { open } from 'fs/promises';
import { expandFile } from './macros.js';
import { log } from './log.js';
export const createHttpServer = (clientDir, opts) => {
const services = { }; // maps service nqmes to functions
let count = 0;
const srv = http.createServer((req, resp) => {
// Sends a response declared as the given content type ('text/plain' or
// 'text/html' or whatever) if resp expects a body, and otherwise only
// the header.
resp.passString = (contentType, s) => {
resp.writeHead(200, { 'Content-Type': contentType });
if (resp.expectsBody()) {
resp.end(s.length === undefined ? s.toString() : s);
} else {
resp.end();
}
}
// Sends an application/json response if resp expects a body, and
// otherwise only the header.
resp.passObj = (obj) => {
resp.writeHead(200, { 'Content-Type': "application/json" });
if (resp.expectsBody()) {
resp.end(JSON.stringify(obj));
} else {
resp.end();
}
}
// Sends a binary body in response if resp expects a body, and
// otherwise only the header. The type must be the full mime type such
// as 'audio/wav'
resp.passStream = (type, readStream) => {
resp.writeHead(200, { 'Content-Type': type });
if (resp.expectsBody()) {
readStream.on('data', chunk => {
resp.write(chunk, 'binary');
}).on('end', () => {
resp.end('', 'binary');
});
} else {
resp.end();
}
}
// Sends a binary body in response in the given type ('png' or 'jpeg'
// or whatever) if resp expects a body, and otherwise does nothing.
resp.passImage = (imgType, readStream) => resp.passStream('image/'+imgType, readStream);
// Sends a text body in response in the given type ('html' or 'css'
// or whatever) if resp expects a body, and otherwise does nothing.
resp.passProcessedTextFile = (textType, fileName, values) => Promise.resolve()
.then(() => resp.expectsBody() && expandFile(fileName, clientDir, values))
.then(s => { resp.passString('text/'+textType, s) });
// Sends an error message as text/plain if resp expects a body, and
// otherwise does nothing. The code defaults to 404 and the message to
// a "not found" text.
resp.httpError = (message, code) => {
log.debug("http error: "+message+" "+code);
if (resp.writableEnded) { return }
resp.writeHead(code || 404, { 'Content-Type': "text/plain" });
if (resp.expectsBody()) {
resp.end(message && message.toString() || "Not found/applicable." );
} else {
resp.end();
}
}
// Checks if resp wants a body, which it does if the request is GET.
resp.expectsBody = () => req.method === 'GET';
if (opts.debug > 1) { log.debug("url: " + req.url+" "+ ++count) }
const [, qpath, sstring, qstring] = req.url.match(/^(\/([^\/\?]*)[^\?]*)(?:\?(.*))?$/);
const query = { };
for (const m of (qstring || '').matchAll(/([^&=]+)(?:=([^[&]*))?/g)) {
query[m[1]] = m[2] === undefined ? true : decodeURIComponent(m[2]);
}
const service = services[sstring || 'index.html'];
if (service) {
Promise.resolve().then(() => service(qpath, qstring, query, resp, req.socket.remoteAddress)).catch(err => {
log.debug(sstring+": "+err+"\n"+(err.stack || "(No stack)"));
resp.httpError("Request of "+sstring+" resulted in error: "+err, 400);
});
} else {
resp.httpError("No service found for "+sstring);
}
});
srv.on('clientError', (err, sock) => {
if (sock.writable) { sock.end('HTTP/1.1 400 Bad Request\r\n\r\n') }
let msg = "[client] "+err.toString();
if (typeof err.bytesParsed !== 'undefined') { msg += ": " + err.bytesParsed }
if (err.code) { msg += ": " + err.code }
log.debug(msg);
});
// Adds a service. The callback function is passed (qpath, query, resp),
// where qpath is the full url and query is an object that maps parameters
// to values. Returns the server.
srv.addService = (name, callback) => {
services[name] = callback;
return srv;
}
return srv;
}
// Takes function that is to produce a file name, produces a function that can
// be plugged in as a service.
export const textFileService = (getFnam, predefValues) => (qpath, qstring, query, resp) => {
const fnam = getFnam(qpath, query);
if (fnam.indexOf(path.sep) >= 0) { resp.httpError("Not a plain file name: " + fnam, 400); return }
let type = fnam.substring(fnam.lastIndexOf(".") + 1);
switch (type) {
case 'html': break;
case 'css': break;
case 'js': type = 'javascript'; break;
default: resp.httpError("Unknown file type: " + type); return;
}
resp.passProcessedTextFile(type, fnam, { ...query, ...predefValues });
}
// Takes function that is to produce a file name, produces a function that can
// be plugged in as a service.
export const imageFileService = (getFnam, getDir) => (qpath, qstring, query, resp) => {
const fnam = getFnam(qpath, query);
if (!fnam) { resp.httpError("No file specified"); return }
if (fnam.indexOf(path.sep) >= 0) { resp.httpError("Not a plain file name: " + fnam, 400); return }
let type = query.file.substring(query.file.lastIndexOf(".") + 1).toLowerCase();
switch (type) {
case 'jpeg': break;
case 'jpg': type = 'jpeg'; break;
case 'gif': break;
case 'png': break;
case 'svg': type = 'svg+xml'; break;
default: resp.httpError("Unknown file type: " + type); return;
}
return open(path.join(getDir(qpath, query), fnam))
.then(fh => { resp.passImage(type, resp.expectsBody() && fh.createReadStream()) })
.catch(err => { resp.httpError("Could not load image "+fnam+": "+err) });
}
Version: v4.3.2.2 (2026-05-16T17:03:34+02:00)
Raw file
Source code overview
Klipspringer home