Skip to content
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

Closed
osdevisnot opened this issue Feb 13, 2021 · 17 comments · Fixed by #2816
Closed

Add Live Reload to the esbuild serve #802

osdevisnot opened this issue Feb 13, 2021 · 17 comments · Fixed by #2816

Comments

@osdevisnot
Copy link
Contributor

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.

@zaydek
Copy link

zaydek commented Feb 13, 2021

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 or deferring to the user and let them handle events themselves.

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).

@osdevisnot
Copy link
Contributor Author

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.

@evanw
Copy link
Owner

evanw commented Feb 14, 2021

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).

One hack that comes to mind is to use cross-tab messaging via the local storage event.

Would you be open to add Live Reload to esbuild serve mode?

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.

@tmc
Copy link

tmc commented Feb 15, 2021

@evanw what do you think about exposing the ability to add a net/http Handler into the current --serve implementation in the go api? This would provide a lightweight means to experiment with different reloading approaches without reimplementing too much in terms of cli entrypoint.

@zaydek
Copy link

zaydek commented Feb 16, 2021

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).

One hack that comes to mind is to use cross-tab messaging via the local storage event.

Oh damn. I’ll try that out and report back with my findings. Thanks for the hint!

@osdevisnot
Copy link
Contributor Author

closing this since original question has been answered.

@zaydek
Copy link

zaydek commented Mar 20, 2021

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).

One hack that comes to mind is to use cross-tab messaging via the local storage event.

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.

@dalcib
Copy link

dalcib commented Apr 14, 2021

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
})

@dalcib
Copy link

dalcib commented Apr 14, 2021

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
})

@David-Else
Copy link

@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 deno 1.9? https://deno.com/blog/v1.9#native-http%2F2-web-server . Maybe it is possible to ditch https://deno.land/[email protected]/http/server.ts now?

@snettah
Copy link

snettah commented Oct 14, 2021

The solution of @dalcib work for me but I've to add esbuild.serve({ servedir: "./", host: "localhost" } ...

@httpete
Copy link

httpete commented Oct 22, 2021

If you are trying to use @dalcib 's beautiful proxy from the wsl, doctor your script like this:
First npm i is-wsl --save-dev.

import isWsl from "is-wsl";

```      const op = {
        darwin: ["open"],
        linux: ["xdg-open"],
        win32: ["cmd.exe", "/c", "start"],
      };
      let ptf = process.platform;
      if (isWsl) {
        ptf = "win32";
      }```

@jhirn
Copy link

jhirn commented Oct 28, 2021

@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.

@remko
Copy link

remko commented Oct 31, 2021

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);

@wch
Copy link

wch commented May 3, 2022

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 req.pipe(request((prxRes) => { ... }))) function.

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
});

@gotjoshua
Copy link

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:
nativew/esbuild-serve#6

@Zazck
Copy link

Zazck commented Dec 20, 2022

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.