hotweby
Version:
Automatic hot-reloading webserver using websockets
213 lines (186 loc) • 5.71 kB
text/typescript
import express from "express"
import * as afs from "fs/promises"
import { IncomingMessage, Server, ServerResponse } from "http"
import * as path from "path"
import { WebSocket, WebSocketServer } from "ws"
import { TriggerHandler } from "./index.js"
export function createReloadHtmlCode() {
const reloadHtmlScript = async () => {
console.log("[HOTWEBY]: connect websocket...")
const connectWs = () => {
const wsUrl =
(location.protocol === "https:"
? "wss://"
: "ws://") + location.host
const ws = new WebSocket(wsUrl)
ws.onopen = () =>
console.log("[HOTWEBY]: websocket connected")
ws.onclose = () => {
console.log(
"[HOTWEBY]: websocket closed, reloading...",
)
setTimeout(() => location.reload(), 100)
}
ws.onerror = err => {
console.error(
"[HOTWEBY]: websocket error, reloading...",
err,
)
setTimeout(() => location.reload(), 1000)
}
}
connectWs()
}
const reloadHtmlCode = "" + reloadHtmlScript
return "<script>\n(" + reloadHtmlCode + ")()\n</script>"
}
export function createExpress(
targetDir: string,
reloadHtmlCode: string,
autoExtensionResolution: boolean,
verbose: boolean,
) {
const app = express()
app.use(async (req, res, next) => {
if (
!req.path.endsWith(".html") &&
!req.path.endsWith("/")
) {
return next()
}
let reqPath = req.path
if (reqPath.endsWith("/")) {
reqPath += "index.html"
}
try {
const data = await afs.readFile(
targetDir + reqPath,
"utf8",
)
res.status(200)
res.send(reloadHtmlCode + "\n" + data.toString())
verbose &&
console.info(
"Served html-file '" +
req.path +
"' from '" +
targetDir +
reqPath +
"'",
)
} catch (err) {
console.error(
"Cant read requested html-file '" +
req.path +
"'" +
"\nfrom '" +
targetDir +
reqPath +
"':\n",
err,
)
res.status(503)
res.setHeader("Content-Type", "text/plain")
res.setHeader("Retry-After", "5")
res.send(
"Cant read requested html-file '" +
req.path +
"'",
)
}
})
autoExtensionResolution &&
app.use(async (req, res, next) => {
const resolvedExtension =
await autoResolveExtensions(
req.url,
targetDir,
verbose,
)
if (resolvedExtension) {
req.url = resolvedExtension
}
next()
})
app.use(express.static(targetDir))
return app
}
export async function autoResolveExtensions(
reqUrl: string,
targetDir: string,
verbose: boolean,
): Promise<string | undefined> {
while (reqUrl.startsWith("/")) {
reqUrl = reqUrl.slice(1)
}
if (reqUrl === "") {
return undefined
}
try {
let realPath = targetDir + "/" + reqUrl
if ((await pathType(realPath)) === "none") {
const files = await afs.readdir(
path.dirname(realPath),
)
const baseName = path.basename(realPath)
for (const file of files) {
if (file.startsWith(baseName + ".")) {
verbose &&
console.info(
"Auto resolved extension for '" +
reqUrl +
"' to be '" +
path.dirname(reqUrl) +
"/" +
file +
"'",
)
return path.dirname(reqUrl) + "/" + file
}
}
}
} catch (err) {
verbose &&
console.error(
"Error while auto resolving extension for " +
reqUrl +
"\n",
err,
)
}
return undefined
}
export async function pathType(
path: string,
): Promise<"file" | "dir" | "none"> {
try {
const stat = await afs.stat(path)
return stat.isFile() ? "file" : "dir"
} catch {}
return "none"
}
export function createWebSocketServer(
httpServer: Server<
typeof IncomingMessage,
typeof ServerResponse
>,
registerTrigger: (triggerHandler: TriggerHandler) => void,
) {
const wsServer = new WebSocketServer({ noServer: true })
wsServer.on("connection", (ws, req) => {
registerTrigger(() => {
if (
ws.readyState === ws.OPEN ||
ws.readyState === ws.CONNECTING
) {
ws.close()
}
})
})
httpServer.on("upgrade", (req, socket, head) => {
wsServer.handleUpgrade(req, socket, head, ws => {
wsServer.emit("connection", ws, req)
})
})
return wsServer
}