Development Server

New in version 2, LiTScript comes with an integrated web server that eliminates the need for additional tools and improves the development experience. The server supports live reloading of changed files.

server imports
import * as http from 'http'
import * as fs from 'fs'
import * as path from 'path'
import * as cfg from './config'
import * as log from './logging'
import * as bak from './backend'

Tracking Open Pages

We track opened pages with the Client objects. When the serve mode is on, outputted pages are augmented with a snippet of JS code that connects to the server side events. That enables us to track which pages are open and send events to them when output files change.

Each client is given an unique id that allows us to dispose it when the SSE connection is closed. The connection keeps open until client closes it. To send new data to the client, we also store the Response object.

interface Client {
    id: number
    response: http.ServerResponse
}
let clients: Client[] = []
let lastId = 0

Start Server

Server configuration is dead simple. We create an express app and make it serve static pages from the output directory. The other endpoint we set up is the event stream under the /litscript route.

Optionally, a backend module can implement custom server-side logic or API endpoints. If a backend is defined, requests are routed through it before serving static files, allowing you to extend the server with custom endpoints. If the backend handles the request, it should end the response.

Finally, we get the host and port from the config and start listening to it.

export function start(opts: cfg.Options) {
    const server = http.createServer((req, res) => {
        // SSE handler
        if (req.url == '/litscript') {
            notifyHandler(req, res)
            return
        }
        // Backend handler (if defined)
        bak.backend(req, res).then(() => {
            if (!res.writableEnded)
                serveStatic(req, opts, res) 
        })
    })
    let { host, port } = opts.serveOptions
    server.listen(port, host, () => console.log(
        `${log.Colors.Reset}Development server started at ${
        log.Colors.Green}http://${host}:${port}`))
}

Static Files

Serve a static file. Requests to / are mapped to index.html to serve the main page.

If the deployment mode is production, we serve the gzipped CSS and JS files generated by the bundler. We add a suffix .gz to the URL that is retrieved and set response headers to tell the browser that the response stream is compressed.

function serveStatic(req: http.IncomingMessage, opts: cfg.Options, 
    res: http.ServerResponse<http.IncomingMessage>) {
    let url = URL.parse(req.url || '/', `http://${req.headers.host}`)
    let fileUrl = url.pathname == '/' ? '/index.html' : url.pathname
    let filePath = path.join(opts.outDir, decodeURIComponent(fileUrl))
    let ext = path.extname(filePath)

    if (opts.deployMode == 'prod' && (ext == '.css' || ext == '.js')) {
        filePath += '.gz'
        res.setHeader('Content-Encoding', 'gzip')
    }
    res.setHeader('Content-Type', contentType(ext))
    serveFile(filePath, res)
}

This function maps common file extensions to their appropriate MIME types, ensuring that browsers interpret and display files correctly. If the extension is not recognized, it defaults to 'text/plain'.

function contentType(ext: string): string {
    switch (ext) {
        case '.css': return 'text/css; charset=UTF-8'
        case '.js': return 'application/javascript; charset=UTF-8'
        case '.html': return  'text/html; charset=UTF-8'
        case '.jpg': return 'image/jpeg'
        case '.png': return 'image/png'
        case '.gif': return 'image/gif'
        case '.svg': return 'image/svg+xml'
        case '.ico': return 'image/x-icon'
        case '.webp': return 'image/webp'
        case '.woff': return 'font/woff'
        case '.woff2': return 'font/woff2'
        case '.json':
        case '.map': return 'application/json'
        default: return 'text/plain'
    }
}

Check if a file exists and stream it to the response, if it does. Otherwise return 404 as HTTP response. Also check if the file has been modified since the last time it was requested. If it has not been modified, return 304.

function serveFile(filePath: string, res: http.ServerResponse) {
    fs.stat(filePath, (err, stats) => {
        if (err) {
            res.writeHead(404)
            res.end('Not found')
            return
        }
        const ifModifiedSince = res.req?.headers['if-modified-since']
        const lastModified = stats.mtime.toUTCString()
        res.setHeader('Last-Modified', lastModified)
        if (ifModifiedSince && 
            Date.parse(ifModifiedSince) >= Date.parse(lastModified)) {
            res.writeHead(304)
            res.end("Not modified")
            return
        }
        const stream = fs.createReadStream(filePath)
        stream.pipe(res)
    })
}

Notify Clients

When files change, we notify all connected clients about the changes. We send each client a message that specifies which files have changed.

export function notifyChanges(files: string[]) {
    clients.forEach(c =>
        c.response.write(`data: ${JSON.stringify(files)}\n\n`))
}

The handler for event stream returns headers and registers a new client. Then it sets up a close event handler that removes the client when it disconnects.

function notifyHandler(request: http.IncomingMessage, 
    response: http.ServerResponse) {
    const headers = {
        'Content-Type': 'text/event-stream',
        'Connection': 'keep-alive',
        'Cache-Control': 'no-cache'
    }
    response.writeHead(200, headers)
    let id = ++lastId
    clients.push({ id, response })
    log.info(`Client ${id} connected`)
    request.on('close', () => {
        log.info(`Client ${id} disconnected.`)
        clients = clients.filter(c => c.id != id)
    })
}