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.
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'
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
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}`))
}
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)
})
}
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)
})
}