-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Live Reload to the esbuild serve #802
Comments
I’m very inspired by @osdevisnot’s work and I wanted to share my collective findings here. Implementing SSE in Go can be a simple as this: https://github.com/zaydek/esbuild-watch-demo/blob/master/watch.go#L34: // ...
http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
// Add headers needed for server-sent events (SSE):
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
log.Fatalln("Your browser does not support server-sent events (SSE).")
return
}
for {
select {
case <-events:
// NOTE: I needed to add "data" to get server-sent events to work. YMMV.
fmt.Fprintf(w, "event: reload\ndata\n\n")
flusher.Flush()
case <-r.Context().Done():
// No-op
return
}
}
})
// ... Server sent events set some headers so requests are long-lived. Then you can use a for-select and listen to a channel to emit a new SSE event when something of interest happens, for example, a build result or build errors. Then you can delegate what to do on the client by amending a small Script tag implemented here: https://github.com/zaydek/esbuild-watch-demo/blob/master/index.html#L11. // ...
const events = new EventSource("/events")
events.addEventListener("reload", e => {
window.location.reload()
})
// ... I wrote this a few weeks ago to demonstrate an MVP implementation paired with esbuild: https://github.com/zaydek/esbuild-watch-demo. My work is very much inspired by @osdevisnot work on sørvør. If you’re planning to add ‘last mile’ features to esbuild to make it more accessible to frontend devs generally, I think SSE or websockets could be great. I personally favor SSE because websockets always seem like they need an external library and are more complex, but that being said they afford two-directional communication, not just one-way with SSE. Note that SSE can only emit events for something like 5-6 tabs at the same time. I believe this is a limitation with HTTP 1.1 but not HTTP 2 (not 100% sure about this). |
This is actually a browser limitation rather than underlying protocol. Both chromium and firefox have this issue marked as "won't fix". This limit is per browser + domain, so users of live reload based on SSE are limited to open 6 tabs max. |
One hack that comes to mind is to use cross-tab messaging via the local storage event.
Not at the moment. There are lots of ways of doing this and this can get pretty custom, especially with HMR. I'd like to avoid building this into esbuild itself for now and instead let people experiment with augmenting esbuild like what you have been doing. I think it would be interesting to think more about this when esbuild is further along and supports CSS and maybe HTML too. I wouldn't want to bake this into esbuild prematurely and end up with a suboptimal solution. For example, it could be nice to automatically swap in CSS live without reloading the page, but reload the page for JavaScript changes. Swapping out CSS live is safe because CSS is stateless while swapping out JS live will introduce bugs since JS is stateful. |
@evanw what do you think about exposing the ability to add a net/http Handler into the current |
Oh damn. I’ll try that out and report back with my findings. Thanks for the hint! |
closing this since original question has been answered. |
I just got around to implementing localStorage events as a solution for the server-sent events limitation. This ideas was originally recommended by Evan as a solution for the SSE limitation where multiple tabs can swallow events, thus making them unreliable. This thin script seems to work for my use-case. Posting here to illuminate the road for anyone else who finds themselves in the same situation. I basically concatenate this to the end of the HTML file being served. I was surprised to learn that localStorage events appear to only fire on inactive tabs, rather than the current one. So I needed to make sure I’m being greedy by creating and reacting to localStorage events so no server-sent events are dropped. <script type="module">
const dev = new EventSource("/~dev");
dev.addEventListener("reload", () => {
localStorage.setItem("/~dev", "" + Date.now());
window.location.reload();
});
dev.addEventListener("error", (e) => {
try {
console.error(JSON.parse(e.data));
} catch {}
});
window.addEventListener("storage", (e) => {
if (e.key === "/~dev") {
window.location.reload();
}
});
</script> Tagging @osdevisnot as this is relevant to his interests. Thanks @evanw, this was a great tip. |
If someone is looking for a pure Esbuild solution (without external dependencies) for a server with watch and livereload, you can try this one: import esbuild from 'esbuild'
import { createServer, request } from 'http'
import { spawn } from 'child_process'
const clients = []
esbuild
.build({
entryPoints: ['./index.tsx'],
bundle: true,
outfile: 'bundle.js',
banner: { js: ' (() => new EventSource("/esbuild").onmessage = () => location.reload())();' },
watch: {
onRebuild(error, result) {
clients.forEach((res) => res.write('data: update\n\n'))
clients.length = 0
console.log(error ? error : '...')
},
},
})
.catch(() => process.exit(1))
esbuild.serve({ servedir: './' }, {}).then(() => {
createServer((req, res) => {
const { url, method, headers } = req
if (req.url === '/esbuild')
return clients.push(
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
)
const path = ~url.split('/').pop().indexOf('.') ? url : `/index.html` //for PWA with router
req.pipe(
request({ hostname: '0.0.0.0', port: 8000, path, method, headers }, (prxRes) => {
res.writeHead(prxRes.statusCode, prxRes.headers)
prxRes.pipe(res, { end: true })
}),
{ end: true }
)
}).listen(3000)
setTimeout(() => {
const op = { darwin: ['open'], linux: ['xdg-open'], win32: ['cmd', '/c', 'start'] }
const ptf = process.platform
if (clients.length === 0) spawn(op[ptf][0], [...[op[ptf].slice(1)], `http://localhost:3000`])
}, 1000) //open the default browser only if it is not opened yet
}) |
It also works with Deno: // deno run --allow-env --allow-read --allow-write --allow-net --allow-run server.js
import * as esbuild from 'https://deno.land/x/esbuild/mod.js'
import { listenAndServe } from 'https://deno.land/std/http/server.ts'
const clients = []
esbuild
.build({
entryPoints: ['./index.tsx'],
bundle: true,
outfile: 'bundle.js',
banner: { js: ' (() => new EventSource("/esbuild").onmessage = () => location.reload())();' },
watch: {
onRebuild(error, result) {
clients.forEach((res) => res.write('data: update\n\n'))
clients.length = 0
console.log(error ? error : '...')
},
},
})
.then((result, error) => {})
.catch(() => process.exit(1))
esbuild.serve({ servedir: './' }, {}).then(() => {
listenAndServe({ port: 3000 }, async (req) => {
const { url, method, headers } = req
if (url === '/esbuild') {
req.write = (data) => {
req.w.write(new TextEncoder().encode(data))
req.w.flush()
}
req.write(
'HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nCache-Control: no-cache\r\nContent-Type: text/event-stream\r\n\r\n'
)
return clients.push(req)
}
const path = ~url.split('/').pop().indexOf('.') ? url : `/index.html` //for PWA with router
const res = await fetch('http://localhost:8000' + path, { method, headers })
const text = await res.text()
await req.respond({ body: text, statusCode: res.statusCode, headers: res.headers })
})
setTimeout(() => {
const open = { darwin: ['open'], linux: ['xdg-open'], windows: ['cmd', '/c', 'start'] }
if (clients.length === 0) Deno.run({ cmd: [...open[Deno.build.os], 'http://localhost:3000'] })
}, 2000) //open the default browser only if it is not opened yet
}) |
@dalcib Thanks for this awesomeness! I have not tried your deno solution yet, but I was wondering if you had seen the new native server in |
The solution of @dalcib work for me but I've to add |
If you are trying to use @dalcib 's beautiful proxy from the wsl, doctor your script like this:
|
@dalcib, dang. I wish I found this yesterday. I really really, REALLY love ESbuild and I'm totally all for how thin @evanw is aiming to keep it, but we almost almost need a cookbook or vetted/plugins site. The last two days were setting up PostCSS and LiveReload, metafile, and incremental build ( wasn't necessary but was in the mood for incremental). Due to the fast pace of the project and admittedly i'm not super in touch with server side node development, this took a lot longer than expected due to trying some plugins that weren't maintained and well, frankly bad suggestions I didn't know better than to follow from old issues and dated blog posts. I ended up stealing a lot from https://github.com/uralys/reactor (Great work there @uralys) and in the end I didn't use any specific esbuild-plugins except the svgr and PostCss. But once I understood the plugin framework, rolling everything by had was exceptionally pleasant and for the first I honestly believe I understand every single thing that happens during my build, not just a cobbled batch of babel and web pack plugins that I figured out by googling. I'm grateful for that. Never going back. |
I use a stripped version of @dalcib 's solution, without going through a reverse proxy (and so without relying on ESBuild serve). This means there's no special codepath (or URL) for development mode. import esbuild from "esbuild";
import { createServer } from "http";
const clients = [];
esbuild
.build({
entryPoints: ["./index.tsx"],
bundle: true,
outfile: "bundle.js",
banner: {
js: ' (() => new EventSource("http://localhost:8082").onmessage = () => location.reload())();',
},
watch: {
onRebuild(error) {
clients.forEach((res) => res.write("data: update\n\n"));
clients.length = 0;
console.log(error ? error : "...");
},
},
})
.catch(() => process.exit(1));
createServer((req, res) => {
return clients.push(
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*",
Connection: "keep-alive",
}),
);
}).listen(8082); |
I used @dalcib's solution for a while and it worked well, except that it modifies the generated JS file, so a separate production build was required. So instead of modifying the JS file as it's written to disk, I figured out how to modify the content as it's sent through the proxy. This way, there's no need for separate development and production builds. This code is based on @dalcib's; the only changes are some reformatting and the code inside of the import esbuild from "esbuild";
import { createServer, request } from "http";
import { spawn } from "child_process";
import process from "process";
const clients = [];
esbuild
.build({
entryPoints: ["./index.tsx"],
bundle: true,
outfile: "bundle.js",
watch: {
onRebuild(error, result) {
clients.forEach((res) => res.write("data: update\n\n"));
clients.length = 0;
console.log(error ? error : "...");
},
},
})
.catch(() => process.exit(1));
esbuild.serve({ servedir: "./" }, {}).then(() => {
createServer((req, res) => {
const { url, method, headers } = req;
if (req.url === "/esbuild")
return clients.push(
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
})
);
const path = ~url.split("/").pop().indexOf(".") ? url : `/index.html`; //for PWA with router
req.pipe(
request(
{ hostname: "0.0.0.0", port: 8000, path, method, headers },
(prxRes) => {
if (url === "/bundle.js") {
const jsReloadCode =
' (() => new EventSource("/esbuild").onmessage = () => location.reload())();';
const newHeaders = {
...prxRes.headers,
"content-length":
parseInt(prxRes.headers["content-length"], 10) +
jsReloadCode.length,
};
res.writeHead(prxRes.statusCode, newHeaders);
res.write(jsReloadCode);
} else {
res.writeHead(prxRes.statusCode, prxRes.headers);
}
prxRes.pipe(res, { end: true });
}
),
{ end: true }
);
}).listen(3000);
setTimeout(() => {
const op = {
darwin: ["open"],
linux: ["xdg-open"],
win32: ["cmd", "/c", "start"],
};
const ptf = process.platform;
if (clients.length === 0)
spawn(op[ptf][0], [...[op[ptf].slice(1)], `http://localhost:3000`]);
}, 1000); //open the default browser only if it is not opened yet
}); |
i am impressed with the creative solutions above, (and still, i really wish that esbuild could offer something that would work out of the box). i also found a slightly out of date but smoothly functioning sidecar wrapper and made a PR to bump it up to date: |
const esbuild = require('esbuild');
const { createServer, request, ServerResponse, IncomingMessage } = require('http');
const path = require('path');
// import { spawn } from 'child_process'
const clients = [];
/**
* @type {Map<string, string>}
*/
const contentMap = new Map();
const rebuild = (error, result) => {
contentMap.clear();
for (const content of result.outputFiles) {
contentMap.set(path.relative(".", content.path), content.text);
}
clients.forEach((res) => res.write('data: update\n\n'))
clients.length = 0
esbuild.analyzeMetafile(result.metafile)
.then((text) => {
console.log(text)
console.log(error ? error : `Rebuilt at ${new Date().toLocaleString()}`)
});
}
esbuild
.build({
entryPoints: ['./src/index.ts'],
bundle: true,
outfile: 'bundle.js',
write: false,
incremental: true,
metafile: true,
banner: { js: ' (() => new EventSource("/esbuild").onmessage = () => location.reload())();' },
watch: {
onRebuild(error, result) {
rebuild(error, result)
},
},
}).then(result => rebuild(null, result))
.catch(() => process.exit(1))
esbuild.serve({ servedir: './' }, {}).then(() => {
createServer((req, res) => {
const { url, method, headers } = req;
const isDir = url.endsWith('/') || url.endsWith('\\');
const relativeUrl = path.relative("/", url);
if (req.url === '/esbuild') {
return clients.push(
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
)
} else if (contentMap.has(relativeUrl)) {
res.write(contentMap.get(relativeUrl));
res.end();
return;
}
const readPath = `${relativeUrl}${isDir ? '/' : ''}`;
console.log(`Reading: ${readPath}`);
req.pipe(
request({ hostname: '0.0.0.0', port: 8000, path: `/${readPath}`, method, headers }, (prxRes) => {
res.writeHead(prxRes.statusCode || 0, prxRes.headers)
prxRes.pipe(res, { end: true })
}),
{ end: true }
)
}).listen(3000)
/* setTimeout(() => {
const op = { darwin: ['open'], linux: ['xdg-open'], win32: ['cmd', '/c', 'start'] }
const ptf = process.platform
if (clients.length === 0) spawn(op[ptf][0], [...[op[ptf].slice(1)], `http://localhost:3000`])
}, 1000) //open the default browser only if it is not opened yet */
}) modified for an in memory version. this code won't actually write files to disk |
I love the
servedir=
option added in latest [not yet released] release. This makes development lifecycle much easier without needing to start multiple servers.I also consider live reload functionality to be most essential to boost development lifecycle. A live reload server basically reloads the entire web page when src code changes. The underlying communication mechanism can be either SSE or websockets based.
Would you be open to add Live Reload to esbuild serve mode?
PS: I've been doing this outside esbuild with https://github.com/osdevisnot/sorvor and I'm open to try contributing this feature to esbuild.
The text was updated successfully, but these errors were encountered: