Get esbuild to watch for changes, rebuild, and restart express server
Asked Answered
P

7

14

I am trying to create a simple SSR powered project using express + react. To do this, I need to simultaneously watch frontend and backend scripts during development.

The goal here is to use express routes to point to react page components. Now, I have this working, but I am having problems with DX.

Here are my package scripts:

    "build:client": "esbuild src/index.js --bundle --outfile=public/bundle.js --loader:.js=jsx",
    "build:server": "esbuild src/server.jsx --bundle --outfile=public/server.js --platform=node",
    "build": "npm run build:client && npm run build:server",
    "start": "node ./public/server.js"

Now this works if I do npm run build && npm run start, but the problem is that it doesn't watch for changes and rebuild the frontend bundle or restart the backend server.

Now, if I add --watch to the 2 build scripts, it only starts watching the index.js file and does not execute the other scripts.

So if I add nodemon to my start script, it doesn't matter because esbuild won't get past the first script due to the watcher.

Is there a simpler way of doing what I am trying to do here? I also want to add tailwind to this project once I figure this out, so any tips around that would be helpful as well.

Peters answered 13/7, 2022 at 19:33 Comment(2)
Hello did u manage to figure it out I'm having the same issue and actually the same structure as u. How did u add the server and client builds in the JavaScript version of building + watching for changes ?Sascha
@Sascha i ended up using a package called concurrently to run the scripts in parallel. you can also use npm-run-all package with the --parallel flagPeters
P
8

I would suggest using the JS interface to esbuild, i.e., write a small JS script that requires esbuild and runs it, and then use the functional version of https://esbuild.github.io/api/#watch. Something like this:

require('esbuild').build({
  entryPoints: ['app.js'],
  outfile: 'out.js',
  bundle: true,
  watch: {
    onRebuild(error, result) {
      if (error) console.error('watch build failed:', error)
      else { 
        console.log('watch build succeeded:', result)
        // HERE: somehow restart the server from here, e.g., by sending a signal that you trap and react to inside the server.
      }
    },
  },
}).then(result => {
  console.log('watching...')
})

Update

To get the same behavior in esbuild 0.17+:

const config = {
  // entryPoints:
  // ...
  plugins: [{
    name: 'rebuild-notify',
    setup(build) {
      build.onEnd(result => {
        console.log(`build ended with ${result.errors.length} errors`);
        // HERE: somehow restart the server from here, e.g., by sending a signal that you trap and react to inside the server.
      })
    },
  }],
};

const run = async () => {
  const ctx = await esbuild.context(config);
  await ctx.watch();
};

run();
Phocomelia answered 14/7, 2022 at 4:20 Comment(2)
Unfortunately, this API seems to have changed.Dilute
It looks like it was deprecated in version 0.17 and later and you now use context as in @Zahin's solution above.Pathogenic
R
22

I use this code snippet to watch my custom react and esbuild project

const esbuild = require("esbuild");
async function watch() {
  let ctx = await esbuild.context({
    entryPoints: ["./src/app.tsx"],
    minify: false,
    outfile: "./build/bundle.js",
    bundle: true,
    loader: { ".ts": "ts" },
  });
  await ctx.watch();
  console.log('Watching...');
}

// IMPORTANT: this call MUST NOT have an `await`.
watch();

// If the call above had an `await`, Node would return
// immediately and you would NOT have the watcher
// running. Alternative, you could use an iife[1]:
(async() => {
  // The same code from the `watch` function above.
  // Notice that it also doesn't have an `await` in
  // front of it.
})()

For more details: https://esbuild.github.io/api/#watch

[1] IIFE: Immediately Invoked Function Expression

Regardless answered 19/3, 2023 at 19:23 Comment(4)
Excellent ! Note that one can set logLevel in BuildOptions to eg info to be told when a rebuild succeeds after an error occured.Sihonn
It's working like a charm. But I think this is only proper for dev environment, if you want to run build for production, you may need another script file without watch.Andris
Not necessary. You can create and pass extra flag support like "--dev" or "--prod" with your script.Regardless
Regarding watch, note that you need to run the last async function without an await, otherwise node will execute everything and exit. I know this is basic, but I didn't notice at first and it cost me some time. Regarding modes, I usually do what Zahin said and create a very simple command line parse: const [mode, ...rest] = process.argv.slice(2), checking for mode === '--dev' (or --prod, or any string you prefer).Mayers
P
8

I would suggest using the JS interface to esbuild, i.e., write a small JS script that requires esbuild and runs it, and then use the functional version of https://esbuild.github.io/api/#watch. Something like this:

require('esbuild').build({
  entryPoints: ['app.js'],
  outfile: 'out.js',
  bundle: true,
  watch: {
    onRebuild(error, result) {
      if (error) console.error('watch build failed:', error)
      else { 
        console.log('watch build succeeded:', result)
        // HERE: somehow restart the server from here, e.g., by sending a signal that you trap and react to inside the server.
      }
    },
  },
}).then(result => {
  console.log('watching...')
})

Update

To get the same behavior in esbuild 0.17+:

const config = {
  // entryPoints:
  // ...
  plugins: [{
    name: 'rebuild-notify',
    setup(build) {
      build.onEnd(result => {
        console.log(`build ended with ${result.errors.length} errors`);
        // HERE: somehow restart the server from here, e.g., by sending a signal that you trap and react to inside the server.
      })
    },
  }],
};

const run = async () => {
  const ctx = await esbuild.context(config);
  await ctx.watch();
};

run();
Phocomelia answered 14/7, 2022 at 4:20 Comment(2)
Unfortunately, this API seems to have changed.Dilute
It looks like it was deprecated in version 0.17 and later and you now use context as in @Zahin's solution above.Pathogenic
G
7

@es-exec/esbuild-plugin-serve or @es-exec/esbuild-plugin-start are two esbuild plugins that can run your bundles or any command line script (similarly to nodemon) for you after building your project (supports watch mode for rebuilding and rerunning on file changes).

Doing this is as easy as:

import serve from '@es-exec/esbuild-plugin-serve';

/** @type import('@es-exec/esbuild-plugin-serve').ESServeOptions */
const options = {
  ... // Any options you want to provide.
};

export default {
    ..., // Other esbuild config options.
    plugins: [serve(options)],
};

The documentation can be found at the following:

Disclaimer: I am the author of these packages.

Gnostic answered 2/9, 2022 at 4:23 Comment(0)
S
1

Not exactly what OP asked, but if you need to bundle in dev and if you happen to use pnpm and node >= v18.11.0, you don’t need any extra packages because pnpm can run scripts parallel with a simple regex pattern, and node has a --watch flag to restart the server on changes:

{
  "scripts": {
    "dev": "pnpm run /^dev:.*/",
    "dev:watch": "node ./scripts/buildWatch.mjs",
    "dev:start": "node --watch-path=./src ./build/server.js",
  }
}

I needed to build the server and worker in separate files so my esbuild ended up looking like this:

// buildWatch.mjs
import esbuild from 'esbuild'

/** @type {import('esbuild').BuildOptions} */
const opts = {
  bundle: true,
  format: 'esm',
  logLevel: 'info',
  packages: 'external',
  platform: 'node',
  sourcemap: false,
  target: ['node20'],
}

/** @type {import('esbuild').BuildOptions} */
export const serverBuildOpts = {
  ...opts,
  entryPoints: ['./src/server.ts'],
  outfile: './build/server.js',
}

/** @type {import('esbuild').BuildOptions} */
export const workerBuildOpts = {
  ...opts,
  entryPoints: ['./src/worker.ts'],
  outfile: './build/worker.js',
}

async function watch() {
  const serverCtx = await esbuild.context(serverBuildOpts)
  const workerCtx = await esbuild.context(workerBuildOpts)

  await Promise.all([serverCtx.watch(), workerCtx.watch()])
}

watch()

Build script follows the same pattern:

{
  "scripts": {
    "build": "pnpm run /^build:.*/",
    "build:server": "node ./scripts/buildServer.mjs",
    "build:worker": "node ./scripts/buildWorker.mjs",
  }
}

I'm not 100% sure what oder pnpm run /^dev:.*/ runs the scripts, since I need the build to run before the server restarts, but so far it has worked.

Pretty neat. Thanks to all the answers in this thread.

Sulfaguanidine answered 5/2 at 11:20 Comment(0)
A
0

I had a very similar issue, and solved it with npm-run-all

In my case I was building a VS Code extension, so my package.json script line looks like this:

  "esbuild-watch": "npm-run-all --parallel esbuild-base esbuild-server -- --sourcemap --watch",
Atthia answered 22/2, 2023 at 13:2 Comment(0)
C
0

If you are using ESModules you can use this code:

import { context } from 'esbuild';
import { resolve } from 'path';

const entryPoints = ['source1', 'source2', 'source3', 'source4']
  .map(name => `./folder/js/${name}.js`);

(async function(){
  try {
    let ctx = await context({
      entryPoints,
      bundle: true,
      outdir: resolve('public/js'),
    });

    await ctx.watch();
    
    console.log("ESBuild Watching...");
  } catch (error) {
    process.exit(1)
  }
})();
Corticate answered 19/1 at 6:10 Comment(0)
P
0

I dunno about express, but i'm using fastify. Here's a minimal esbuild script to redeploy your fastify server (i'm sure very similar with express).

This is similar to another poster. This works for the more recent versions of esbuild (i believe >= 0.17).

const esbuild = require('esbuild')
const child_process = require('child_process')

const entryPoint = './src/app.ts'

const buildConfig = {
  entryPoints: [entryPoint],
  bundle: true,
  write: true,
  // sourceMap: 'inline',
  platform: 'node',
  outfile: 'dist/app.js',
  treeShaking: true,
  splitting: false,
  plugins: [],
  format: 'cjs'
}

const isWatching = true

;(async () => {

  let runningChild = null

  const watchPlugin = {
    name: 'watch-plugin',
    setup: (build) => {
      build.onStart(() => {
        console.log('Starting build...')
      })
      build.onEnd(() => {
        console.log('Finished building. (Re)Starting server...')
        const deployServer = () => {
          runningChild = child_process.spawn('node', ['dist/app.js'], {
            cwd: './',
            stdio: 'inherit'
          })
        }
        if (runningChild) {
          runningChild.kill('SIGINT')
          runningChild.on('exit', () => {
            // Make sure it's dead!
            console.log('Redeploying server in 500ms...')
            setTimeout(() => deployServer(), 500)
          })
        } else {
          deployServer()
        }
      })
    }
  }

  const config = {...buildConfig}
  if (isWatching) {
    config.plugins = [...config.plugins, watchPlugin]
  }

  const ctx = await esbuild.context(config)

  if (isWatching) {
    await ctx.watch()
  } else {
    await ctx.dispose()
  }

})()

// Server file looks something like this:
const app = fastify({})
// Final stuffs
closeWithGrace({ delay: 500 }, async ({ signal, err, manual }) => {
  console.log(`Got signal = ${signal}. Was manual? ${manual}`)
  if (err) {
    app.log.error('Failed to close server gracefully?')
    app.log.error(err)
  }
  app.log.info('Received kill signal, shutting down gracefully')
  await app.close()
})


Panettone answered 8/7 at 2:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.