3
\$\begingroup\$

Dumbserver, or the simplest workable HTTP server. It is supposed to operate in the 'traditional' way, that is, mapping paths in requests to the directory structure.

Why did I write it: I needed a simple development server for a website I'm trying to make. Instead of using Express or something like that for this purpose, I thought I'd make my own simplistic server as an exercise.

I don't know Javascript very well and I suppose that especially here they may be multiple subtleties I don't know about and/or failed to handle. Pointing these out would be much appreciated.

Also I suppose that reading the whole file into memory and then writing it to the socket is a poor thing to do, I should instead pipe the file stream into the socket? But I'm not sure how to do this. createReadStream will only give me errors after I start reading it, but then it is too late: I need to set an erroneous HTTP code before I start writing to the response object ;/

const {createServer} = require('http');
const {join} = require('path')
const {readFile} = require('fs').promises
const {getType} = require('mime/lite')

const argv = require('minimist')(process.argv.slice(2))

const dir = argv.www
const host = argv.host
const port = argv.port
if(dir === undefined || host == undefined || port == undefined) {
    console.error(`Usage: ${process.argv[1]} --www=C:\\directory\\of\\your\\webpage --host=example.com --port=80`)
    process.exit(1)
}

function decodePathname(pathname, err) {
    try {
        return decodeURIComponent(pathname)
    } catch(e) {
        if(e instanceof(URIError)) {
            err.code = 400
            return
        } else {
            throw e
        }
    }
}

function splitPathname(pathname, err) {
    const decodedPathname = decodePathname(pathname, err)
    if(decodedPathname) {
        return pathname.split('/')
    }
}

function constructOsPath(pathname, err) {
    const parts = splitPathname(pathname, err)
    if(parts) {
        let res = dir
        for(const part of parts) {
            res = join(res, part)
            // Defense against escaping from dir by stacking ..'s in request URL
            // Checking this on each step to prevent guessing directory structure by requests such as http://example.com/../../var/www/index.html
            if(!res.startsWith(dir)) {
                err.code = 403
                return
            }
        }
        return {path: res, mime: getType(res)}
    }
}

async function getResponse(pathname, err) {
    const pathMime = constructOsPath(pathname, err)
    if(pathMime) {
        try {
            return {content: await readFile(pathMime.path), mime: pathMime.mime}
        } catch(e) {
            if(e instanceof(Error)) {
                if(e.code == 'ENOENT') {
                    err.code = 404
                    return
                } else if(e.code == 'EACCESS') {
                    err.code = 403
                    return
                } else if(e.code == 'EISDIR') {
                    err.code = 422 // Not sure about code here? Is 422 appropriate really?
                    return;
                } else {
                    throw e
                }
            } else {
                throw e
            }
        }
    }
}

async function processRequest(req, res) {
    if(!['GET', 'HEAD'].includes(req.method)) {
        res.code = 501
        return
    }
    
    const url = new URL(req.url, `http://${req.headers.host}`)
    const pathname = url.pathname
    
    const err = {}
    
    // Bad, I know, but idk what else can be done
    // - I should do a createReadStream and then pipe this stream to the response!
    //    - But then what if this stream errors out? I should send HTTP 500 but can't do so if I've already started sending a response
    // - I should not read the file when responding to a HEAD request!
    //    - I shouldn't, but otherwise it's hard to guarantee that HEAD returns same exact status as GET would
    const contentMime = await getResponse(pathname, err)
    
    if(contentMime) {
        res.setHeader('Content-Type', contentMime.mime ?? 'application/octet-stream')
        if(req.method == 'GET') {
            res.end(contentMime.content)
        } else if(req.method == 'HEAD') {
            res.end()
        }
    } else {
        res.statusCode = err.code
        res.end()
    }
}

const server = createServer((req, res) => {
    const now = new Date(Date.now())
    console.log(`${now.toISOString()} ${req.socket.remoteAddress} -> ${req.method} ${req.url}`)
        
    processRequest(req, res).catch(e => {
        console.error(e)
        
        res.code = 500
        res.end()
    })
})

server.on('error', e => console.error(e))

server.listen(port, host, () => console.log('Server listening'))
\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.