// 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