Last active
May 6, 2023 23:23
-
-
Save Gerrit0/77498f286bd3ee2aba7ef2806412bbc0 to your computer and use it in GitHub Desktop.
A esbuild setup with optional watching + server with live reload
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @ts-check | |
const fs = require("fs"); | |
const http = require("http"); | |
const esbuild = require("esbuild"); | |
const { join } = require("path"); | |
// ===== Configuration ===== | |
const port = parseInt(process.argv[process.argv.indexOf("--port") + 1]) || 8080; | |
const watch = process.argv.includes("--watch"); | |
const serve = process.argv.includes("--serve"); | |
const sourceDir = "src"; | |
const outDir = "build"; | |
const staticDir = "public"; | |
/** @type {esbuild.BuildOptions} */ | |
const esbuildOptions = { | |
logLevel: "info", | |
entryPoints: [`${sourceDir}/index.ts`], | |
bundle: true, | |
outfile: `${outDir}/bundle.js`, | |
platform: "browser", | |
incremental: watch || serve, | |
}; | |
// ===== Implementation ===== | |
const green = "\033[32m"; | |
const red = "\033[31m"; | |
const reset = "\033[0m"; | |
// If defined, we very recently saw a change, and are waiting a bit to see if more | |
// changes show up before rebuilding and reloading the page. | |
/** @type {ReturnType<typeof setTimeout> | undefined} */ | |
let timeout = undefined; | |
// If this is defined, we're in the process of doing a build. | |
// In this case, we should not | |
/** @type {Promise<unknown> | undefined} */ | |
let buildPromise = undefined; | |
// The previous build result, for use in watch/serve | |
/** @type {esbuild.BuildResult | undefined} */ | |
let build = undefined; | |
// This is used for automatically reloading the page. | |
// browsers will listen to http://localhost:8080/live-reload | |
// and we'll send a reload message whenever something finishes building. | |
/** @type {Set<http.ServerResponse>} */ | |
const activeClients = new Set(); | |
// This sometimes matters (JS generally), so be consistent | |
// about always including a mime type. Will probably need to extend | |
// this with more later. | |
const mimeTypes = { | |
js: "text/javascript", | |
css: "text/css", | |
html: "text/html", | |
}; | |
function reloadBrowsers() { | |
for (const listener of activeClients) { | |
listener.write("data: reload\n\n"); | |
} | |
} | |
const listenerScript = `<script> | |
(function(e) { | |
e.onmessage = () => { | |
e.close(); | |
location.reload(); | |
} | |
}(new EventSource("http://localhost:${port}/live-reload"))) | |
</script>`; | |
/** | |
* @param {http.ServerResponse} res | |
* @param {string} file | |
*/ | |
function sendFile(res, file) { | |
const lastDot = file.lastIndexOf("."); | |
const ext = lastDot === -1 ? "" : file.substring(lastDot + 1); | |
if (ext in mimeTypes) { | |
res.setHeader("Content-Type", mimeTypes[ext]); | |
} else { | |
res.setHeader("Content-Type", "text/plain"); | |
} | |
if (file.endsWith(".html")) { | |
// Inject code to handle live reload | |
fs.readFile(file, "utf-8", (err, content) => { | |
if (err) throw err; | |
res.end(content.replace("</body>", `${listenerScript}</body>`)); | |
}); | |
} else { | |
fs.createReadStream(file).pipe(res, { | |
end: true, | |
}); | |
} | |
} | |
function startServer() { | |
const server = http.createServer(function processRequest(req, res) { | |
if (req.url === "/live-reload") { | |
const headers = { | |
"Content-Type": "text/event-stream", | |
Connection: "keep-alive", | |
"Cache-Control": "no-cache", | |
}; | |
res.addListener("close", () => activeClients.delete(res)); | |
res.writeHead(200, headers); | |
activeClients.add(res); | |
return; | |
} | |
// If in the middle of a build, don't respond with a file until the build is finished. | |
if (buildPromise) { | |
buildPromise.then(() => processRequest(req, res)); | |
return; | |
} | |
let page = req.url; | |
if (page === "/") page = "/index.html"; | |
page = page.substring(1); // strip off leading / | |
fs.readdir(staticDir, (_err, files) => { | |
// First check the static directory for the file | |
if (files?.includes(page)) { | |
sendFile(res, join(staticDir, page)); | |
} else { | |
// Then check the build directory | |
fs.readdir(outDir, (_err, files) => { | |
if (files?.includes(page)) { | |
sendFile(res, join(outDir, page)); | |
} else { | |
// If in neither, not found. | |
res.statusCode = 404; | |
res.end("404 not found."); | |
} | |
}); | |
} | |
}); | |
}); | |
server.listen(port); | |
startWatch(); | |
} | |
function startWatch() { | |
requestBuild(); | |
fs.watch(sourceDir, { recursive: true }).on("change", requestBuild); | |
fs.watch(staticDir, { recursive: true }).on("change", reloadBrowsers); | |
} | |
function requestBuild() { | |
if (buildPromise) { | |
buildPromise.then(requestBuild); | |
return; | |
} | |
if (timeout) { | |
clearTimeout(timeout); | |
} | |
// Wait a bit before actually doing the build, to avoid double builds when | |
// format on save is enabled. | |
timeout = setTimeout(doBuild, 100); | |
} | |
function doBuild() { | |
console.clear(); | |
const start = Date.now(); | |
timeout = undefined; | |
// If we have had a previous successful build, do a rebuild for a faster build | |
// Otherwise, build from scratch, and save the result if successful | |
buildPromise = build | |
? build.rebuild() | |
: esbuild.build(esbuildOptions).then((result) => (build = result)); | |
buildPromise | |
.then(() => { | |
// Successful build, reload the page. | |
reloadBrowsers(); | |
buildPromise = undefined; | |
console.log( | |
`\n${green}Finished build in ${Date.now() - start}ms${reset}\n` | |
); | |
if (serve) { | |
console.log(`Listening on http://localhost:${port}\n`); | |
} | |
}) | |
.catch(() => { | |
// Errors have already been logged to console, ignore passed in errors | |
buildPromise = undefined; | |
console.log( | |
`\n${red}Finished build in ${ | |
Date.now() - start | |
}ms with errors${reset}\n` | |
); | |
}); | |
} | |
async function main() { | |
if (serve) { | |
startServer(); | |
} else if (watch) { | |
startWatch(); | |
} else { | |
await esbuild.build(esbuildOptions); | |
} | |
} | |
main().catch((err) => { | |
console.error(err); | |
process.exit(1); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment