-
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
[MVP] Watch mode #21
Comments
What about persistent cache and tool like this https://github.com/cortesi/modd ? |
I was going to pull esbuild into my hotweb project which is basically this. Not sure how you'd like to separate concerns or if you want to just borrow each others code or what: https://github.com/progrium/hotweb |
I do to do this very simply with browsersync with live reload bs.watch('src/**/*.js', function (event, file) {
require('esbuild').build({
stdio: 'inherit',
entryPoints: ['./src/scripts/index.js'],
outfile: `${dist}/assets/scripts${!('development' === process.env.NODE_ENV) ? '.min' : ''}.js`,
minify: !('development' === process.env.NODE_ENV),
bundle: true,
sourcemap: 'development' === process.env.NODE_ENV
}).then(() => bs.reload())
.catch(() => process.exit(1))
}) |
I understand the motivation and support it in principle, but I'd question prioritizing the watch mode over, say, tree shaking, aliases, or other vital features for a modern bundler. With ~100x performance boost over existing bundlers, using esbuild without watch mode is already faster (e.g. 50 ms for me) than other bundlers operating in watch mode (750ms for me with Webpack). As it stands, though, without tree shaking esbuild produces bundles multiple times bigger than webpack, hence, I can only use it in development. But if speeding up my dev builds was my only goal, I would have gone with Snowpack... and it would make my already complex webpack setup a nightmare come update time. That's where I think esbuild can help--reduce the complexity of our Snowpack+Webpack+Babel setups if it manages to give reasonable dev build speeds and feature coverage. Personally, I target modern browsers, use ES2019 and native web components with lit-element, thus, I require no compilation and ask my bundler only to, well, bundle all my code and minimize it at least by removing dead code I'm getting from third-party libraries. |
I’m totally with you. I’m actually already actively working on tree shaking, code splitting, and es6 export (they kind of have to all be done together since they are all related). Don’t worry! |
A workaround for watch mode: |
I'd rely on watchexec here - it's a really good utility to listen to file changes built with rust. Very lightweight, very fast, very simple. |
We have a fairly large code base that takes 1-2s to build, which is already amazing. But we're definitely interested to bring this down even more with a great watch mode. |
Any updates on this? I want to integrate esbuild into my development workflow (use esbuild for development and storybooks) but without watch it requires some extra effort to get it working and with 2s build time it's almost the same as with webpack. |
Our initial results here are cold build (~60s), warm build (~20s) and rebuild (~0.5s) all became ~2s. This alone is enough for us to make it worth the switch, even in watch mode, because it seems like the cold build savings (after a branch switch) make it a net win. That said, having a real watch mode would be amazing. |
I suppose the first step here is figuring out the plan for file watching. Unfortunately, it looks like fsnotnify, the most popular Golang file watcher (from what I can tell) doesn't yet support FSEvents on macOS. I think without this, a tool like esbuild is almost certainly going to run into issues with file descriptor limits. Personally, I think it'd be fine to just use watchman, but I can see how imposing a hard external dependency on users might be unpalatable. FWIW, Parcel implemented their own C++ file watcher to avoid a hard dependency on watchman. |
I've heard a lot about how flaky file watching is, which makes me hesitate to integrate file watching into esbuild itself. The primary mechanism that should be in esbuild core is incremental builds and a way to trigger them. Personally I've found that it's more reliable to trigger incremental builds for development by running a local server that does a build when a HTTP request comes in instead of using a file watcher. Then the file is never out of date due to a race between the build tool and the loading of the file. That's what I'm hoping to build first as far as watch mode. Perhaps something like The hard part is doing incremental builds. Once that is in place, it should be relatively straightforward to allow external code to trigger an incremental build (e.g. with a signal) and then use that to hook an existing external file watching library to esbuild without having to build the file watcher into esbuild itself. |
Maybe a balance could be struck between “an actual project build MVP needs file watching” and “esbuild should not contain all the logic for file watching” by maintaining, say, a lightly curated set of recipes for things like “esbuild+watchman”, “esbuild+chokidar”, and other (not necessary watch related) things that should not be part of esbuild but are the kinds of questions that everyone using it is likely to ask? |
The simplest possible form of incremental builds I can think of would be to merely skip re-bundling chunks that should not be changed at all. At least in cases where the dependency graph itself is unchanged, it should be straightforward to identify which chunks should be ultimately unaffected. This a rather coarse form of incremental builds, but in scenarios where the majority of the bundle is from node_modules, the amount of skipped work could be substantial (provided node_modules are in a separate chunk from the modified code). Given esbuild is already extremely fast, I wonder if it might be "good enough" in practice until something more granular could be implemented. I'm not too familiar with the bundler logic, but I wonder if this more limited form of incremental builds would be easier to implement without making too many changes. |
For now I'm watching my TS files with Chokidar CLI to trigger full esbuild builds at changes and it is so fast that it does the trick for me (I'm bundling Firebase with a web worker and a few files). |
I'm noticing this as an issue with my project, bundle time once I include ThreeJS as a module is around ~500-700ms, which is pretty substantial during development compared to my old browserify tools (due to incremental loading they bundle in < 150 ms). A watcher and server could be convenient to some users, but IMHO it would be better offloaded to another module/tool, and esbuild should just be a bundler. For example: my current use case wouldn't use esbuild's server or file watcher as I require a few custom things, and also I plan to make a pure client-side web version. I'd rather an interface (in plugins?), a way to skip loading and transforming files, and instead use the already-transformed contents from my own program memory or from disk, i.e. a |
Heads up that an incremental build API is coming soon (probably in the next release). Parsed ASTs are cached between builds and are not re-parsed if the contents don't change. Initial build time improvements are modest (~1.3x-1.5x faster) since linking still always happens, but further improvements are possible in the future. |
The incremental build API has been released. Documentation is here: https://esbuild.github.io/api/#incremental. |
Thanks Evan, in my simple ThreeJS test this drops the re-bundle time from ~100ms to ~40ms. 🎉 I now realize the reason my earlier builds were in the 500ms range was because of EDIT: https://gist.github.com/mattdesl/f6a3192c89e1d182c26ceed28130e92c |
I have just tried the new anyway, esbuild is absolutely amazing! |
Thanks for the feedback. I added more docs about this. The served files mirror the structure of what would be the output directory for a normal build. One helpful trick is that the web server also has a directory listing feature. If you're serving on port 8000, you can visit http://localhost:8000/ to see all files in the root directory. From there you can click on links to browse around and see all of the different files. |
Hi, guys! I'm created some wrapper around esbuild and implemented watch mode for it on golang. |
The I guess this is just for convenience but a small script to do that could look like |
@unki2aut I got very curious about using hot reload like you’ve demonstrated so I built out a minimal reproducible repo for anyone here to play with: https://github.com/zaydek/esbuild-hot-reload. Essentially, you just run Edit: I heavily annotated the esbuild source |
@unki2aut 's script is awesome. I find chokidar watch are called multiple times. |
In a project I used It was fast enough to be unnoticeable, so for my use case, that was good enough. Looking forward to seeing official support for incremental builds and seeing that manifest as either file watching or a server or both 🚀 |
So I spent a lot of time researching the ‘watch’ problem, that is, why are file watchers a bad idea. The way Evan implemented watch mode in esbuild took me a little while to understand. Essentially, he kicks off a long-lived HTTP server that incrementally recompiles your source code per request (e.g. browser refresh). This allows him to strategically delay the HTTP response so that there are no race conditions. This is intelligently designed but doesn’t solve for the use-case where you need to tell esbuild to compile / recompile based on file changes. So I studied this for my own use-case and thought I’d share my notes here:
package main
import (
"fmt"
"os"
"path/filepath"
"time"
)
func main() {
var (
// Maps modification timestamps to path names.
modMap = map[string]time.Time{}
// Channel that simply notifies for any recursive change.
ch = make(chan struct{})
)
// Starts a goroutine that polls every 100ms.
go func() {
for range time.Tick(100 * time.Millisecond) {
// Walk directory `src`. This means we are polling recursively.
if err := filepath.Walk("src", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Get the current path’s modification time; if no such modification time
// exists, simply create a first write.
if prev, ok := modMap[path]; !ok {
modMap[path] = info.ModTime()
} else {
// Path has been modified; therefore get the new modification time and
// update the map. Finally, emit an event on our channel.
if next := info.ModTime(); prev != next {
modMap[path] = next
ch <- struct{}{}
}
}
return nil
}); err != nil {
panic(err)
}
}
}()
for range ch {
fmt.Println("something changed")
}
} This doesn’t tell you what or why something changed -- simply that something did. For my use case, this is probably more than enough. And you can still parametrize the polling interval. Anyway, I hope this helps a soul. 🤠 |
Another option, which is built on the HTTP server approach, is to keep track of the modtime of all files that were used in the last build. Then, when the new request comes, to first check if any of those files have a different modtime. If none have a different modtime, then you can re-use the result from last time. Due to how the operating system caches const metafile = "_internal_metadata.json"
type ESBuildHandler struct {
options api.BuildOptions
result api.BuildResult
outdir string
modified map[string]time.Time
l sync.RWMutex
}
func NewESBuildHandler(options api.BuildOptions) *ESBuildHandler {
h := &ESBuildHandler{options: options}
// Use incremental building.
h.options.Incremental = true
// Export metadata so we know which files were accessed.
h.options.Metafile = metafile
// Keep track of the outdir so we can resolve incoming paths.
if outdir, err := filepath.Abs(h.options.Outdir); err == nil {
h.outdir = outdir
}
return h
}
func (h *ESBuildHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
name := filepath.Join(h.outdir, r.URL.Path)
if h.needsRegenerate() {
h.regenerate()
}
h.l.RLock()
defer h.l.RUnlock()
for _, file := range h.result.OutputFiles {
if file.Path == name {
http.ServeContent(w, r, r.URL.Path, time.Time{}, bytes.NewReader(file.Contents))
return
}
}
http.NotFound(w, r)
}
func (h *ESBuildHandler) needsRegenerate() bool {
h.l.RLock()
defer h.l.RUnlock()
if h.modified == nil || len(h.modified) == 0 {
return true
}
for path, modtime := range h.modified {
fi, err := os.Stat(path)
if err != nil || !fi.ModTime().Equal(modtime) {
return true
}
}
return false
}
func (h *ESBuildHandler) regenerate() {
h.l.Lock()
defer h.l.Unlock()
if h.result.Rebuild != nil {
h.result = h.result.Rebuild()
} else {
h.result = api.Build(h.options)
}
// Keep track of modtimes.
h.modified = make(map[string]time.Time)
for _, file := range h.result.OutputFiles {
if strings.HasSuffix(file.Path, metafile) {
var metadata struct {
Inputs map[string]struct{} `json:"inputs"`
}
json.Unmarshal(file.Contents, &metadata)
for input := range metadata.Inputs {
if fi, err := os.Stat(input); err == nil {
h.modified[input] = fi.ModTime()
}
}
return
}
}
} |
Watch mode has just been released in version 0.8.38. From the release notes:
More documentation including information about API options is available here: https://esbuild.github.io/api/#watch. |
In case someone using watchexec, this is my workaround for integrating it const $rebuild = flag('--rebuild-on', '-r')
const { build } = require('esbuild')
, pkg = require('../package.json')
, { compilerOptions: tsc, ...tsconfig } = require('../tsconfig.json')
/** @type {import('esbuild').BuildOptions} */
const options = {
format: 'cjs',
platform: 'node',
entryPoints: ['src/main.ts'],
bundle: true,
outdir: tsc.outDir,
sourcemap: true,
incremental: $rebuild.exists,
};
(async () => {
const { join, dirname: dir, basename: base, extname: ext } = require('path')
, { entryPoints, outdir, incremental } = options
const es = await build(options)
if (incremental) process.on($rebuild.values[0], () =>
es.rebuild())
})() This makes esbuild do rebuild on specific signal watchexec -nc --signal SIGCHLD -w src/ -- build.js --rebuild-on SIGCHLD |
@evanw I'm having some issues trying this out. I have a fairly straightforward build config:
Watch mode is being passed in as boolean Same result if I add this part also:
|
If anyone is interested in implementing server-sent events (in Go) with the new esbuild watch mode (this enables auto-reloading the browser tab on source changes), check this out: https://github.com/zaydek/esbuild-watch-demo. @evanw The watch mode works great! I’m pleased with your implementation. This is an awesome API. I don’t need to orchestrate rebuilds anymore and watching ‘just works’ because esbuild is already aware what the source files are. |
I just found out that it takes like ~100ms for "chokidar" to notify me of updates, ~50ms for my "watcher" to do the same (no idea why it's half the time), and that's using the native filesystem watcher that Node gives us access to under macOS! These are kind of ridiculous numbers really. By the time Node is able to tell me that something changed esbuild has already finished rebuilding the entire thing 🤣 So thanks for adding a watch mode I guess, not just because it's actually usable for many use cases, but also because it makes other alternatives seem incredibly slow. |
I want esbuild to demonstrate that it's possible for a normal web development workflow to have high-performance tools. I consider watch mode a part of my initial MVP feature set for esbuild since I believe development builds should be virtually instant. This issue tracks the watch mode part of my MVP.
Watch mode involves automatically rebuilding when files on disk have been changed. While esbuild is much faster than other bundlers, very large code bases may still take around a second to build. I consider that too long for a development workflow. Watch mode will keep the previous build in memory and only rebuild the changed files between builds.
This may or may not involve disabling certain cross-file optimizations while in watch mode depending on what the performance looks like. I may also implement a local HTTP server that can be queried for the latest build, which is a nice way to avoid a page reload accidentally picking up a stale build.
The text was updated successfully, but these errors were encountered: