Go-like channel in 10 lines of Javascript

For the Remix compiler, I wanted to code the browser and server builds so that they could be concurrent.

Specifically, there's an assets manifest that the browser build produces part way through that is needed somewhere in the server build.

async function compileBrowser() {
  // do stuff
  let assetsManifest = /* ... */
  // do more stuff
  return assetsManifest
}

async function compileServer(assetsManifest) {
  // do stuff
  let config = createServerConfig(assetsManifest)
  // do more stuff
}

function compile() {
  let assetsManifest = await compileBrowser()
  compileServer(assetsManifest)
}

The contents of the assets manifest aren't important for this conversation. It's just a value we need to coordinate the handoff for between the browser build and the server build.

Right now we have to wait for compileBrowser to finish before we even pass assetsManifest to compileServer:

Sequence diagram showing browser compilation and server compilation running in sequence

There's no hope of concurrency if we stick with this approach.

Ideally, compileBrowser and compileServer could initially run concurrently, then the server waits until the browser build produces the assets manifest, and then they can finish concurrently:

Sequence diagram showing browser compilation and server compilation running in parallel

Javascript has callbacks, promises, and async/await for concurrency. I could have used those, but they all felt like the wrong tool. In this situation, I found it more intuitive to use Go's concurrency model for channels.

Using channels for concurrency

With Go channels, functions can coordinate by writing and reading from the channel:

async function compileBrowser(channel: WriteChannel<AssetsManifest>) {
  // do stuff
  let assetsManifest = /* ... */
  channel.write(assetsManifest)
  // do more stuff
}

async function compileServer(channel: ReadChannel<AssetsManifest>) {
  // do stuff
  let assetsManifest = await channel.read() 
  let config = createServerConfig(assetsManifest)
  // do more stuff
}

async function compile() {
  let channel = createChannel<AssetsManifest>()
  let browserPromise = compileBrowser(channel)
  let serverPromise = compileServer(channel)
  Promise.all([browserPromise, serverPromise])
}

Note that in compile, we kick off both compileBrowser and compileServer asynchronously. We also get the added benefit that compileBrowser and compileServer explicitly state if they write or read the assets manifest.

If only we had Go-like channels in Javascript...

Implementing channels in Javascript

The insight is to realize channels are like remotely-resolvable promises:

export type WriteChannel<T> = {
  write: (data: T) => void;
};
export type ReadChannel<T> = {
  read: () => Promise<T>;
};
export type Channel<T> = WriteChannel<T> & ReadChannel<T>;

export const createChannel = <T>(): Channel<T> => {
  let promiseResolve: (value: T) => void;

  let promise = new Promise<T>((resolve) => {
    // save this promise's `resolve` for later
    promiseResolve = resolve;
  });

  return {
    write: promiseResolve!,
    read: () => promise,
  };
};

Ignoring the Typescript type definitions, its only 10 lines of Javascript!

To be fair, Go channels are more sophisticated and let you queue a bunch of writes into them, waiting to be read.1 If you need something like that checkout some of these packages.

What we've implemented is a "first write wins" channel. When channel.write(value) is called the first time, that value will persist. Any subsequent writes will be discarded.

That works great for this problem as we create a new channel for each build, so we only need to write to it once. And check out how similar our compile function is to the actual compile function in the Remix compiler!

Footnotes

  1. This is called the Producer-Consumer pattern.