tinywars - TypeScript code hot swapping with state retention

August 24, 2021

Me. Learning new things.

tinywars - TypeScript code hot swapping with state retention

Last time, I went a little overboard on the number of words. I posited that there's simply no way to code hot swap JavaScript/TypeScript while retaining state, even though V8's inspector protocol appears to support exactly that.

Well, turns out I was wrong, as usual. An hour after publishing the blog post, I figured it out. Here's how I hot swap my TypeScript frontend code:

  1. Disable any live-editing functionality that reloads the page, as that would lose state.
  2. Build the frontend TypeScript source code in watch mode so the .js and .js.map output files gets updated when you change a TypeScript source file.
  3. Start the server, serving the static frontend assets, including the .js and js.map files compiled from the TypeScript sources.
  4. Open the page using the TypeScript code in Chrome, then open the Chrome DevTools on that page.
  5. Click the Sources tab, then switch to Filesystem.
  6. Click Add folder to workspace, and add the folder(s) containing the .ts source files, as well as the folder(s) containing the compiled .js and .js.map files. For tinywars, that looks like this:
    The green dot on a file icon indicates that Chrome was able to link the file to a file loaded from the server. In this case, both game.ts and tinywars.ts get linked to tinywars.js through the tinywars.js.map source map.
  7. Switch back to VS Code. Whenever you now edit and save one of the source files, Chrome will pull in the changes and apply them to the running app, retaining its state!

You can also use Chrome DevTools to do the same with a Node.js server:

  1. Build your backend TypeScript source code in watch mode.
  2. Start your Node.js server in debug mode with the --inspect CLI argument.
  3. Go to chrome://inspect, then click select your Node.js instance. This opens a Chrome DevTools instance attached to the Node.js server.
  4. Click the Sources tab, then Filesystem and add your Node.js server's source code folder containing .ts files, as well as the build output folder containing the .js and js.map files.
  5. Edit and save the .ts files in your code editor, and have the Node.js server receive and apply those code updates while retaining state.

You can of course also edit the source files directly in Chrome DevTools. However, that doesn't give you auto-completion and other goodies.

Here's a video demonstrating the process for those of you who hate reading.

Nice, right? Well, there are a few caveats that make this a bit less useful than other similar systems. The only thing that reliably works, is modifying the bodies of existing functions and methods. Adding new methods on the fly, or properties, does not work. If you want to also set breakpoints and step through and inspect your app's state, you have to use Chrome DevTools, as VS Code will get confused by the hot swapping. Kind of sad that VS Code doesn't offer this functionality out of the box.

Bonus content: why JavaScript code hot swapping will never be cool

Update: After posting this post, I wanted to better understand why V8's code hot swap isn't that great. I started with a theory that goes like this.

In languages like C, C#, Java, and consorts, there's a pretty clear way to match up changes between an old source file and a new one: symbols (as in unique names a linker can use to match things up). That can be a function or method signature, a variable or field signature, and so on. If a source file changes, you "simply" recompile it, then match the generated symbols to the symbols in the running app and swap out the implementation.

This works exceptionally well for many use cases, like modifying or adding functions and method. Just replace or add new code for function and method bodies to the running app and call it a day. For modifying types, i.e. adding struct or class fields, symbols are only half the solution. The hard part is "migrating" data of a modified type in the running app to the new layout. That's kinda similar to database schema migrations. And we all know how "automatic" than can be done.

JavaScript doesn't have these kind of cold hard symbols. What it does have is function literals that can be assigned to globally scoped variables, and prototypes, which is as close as one can get to something akin to symbols. But they are not unique identifiers at all.

And that means a bad time for anyone trying to implement code hot swapping. The only option is to match things by source code location, and that's error prone.

So what does V8 do when code hot swapping? The investigation starts with the V8 inspector protocol spec, as described in the last post. The protocol includes a setScriptSource endpoint, which is underdocumented, but appears to be meant for code hot swapping.

We can find a bunch of references to setScriptSource in V8's source code over on GitHub. Most of which are located in tests, so we ignore them. The last search result (literally on the last page, but hey, we got GitHub Autopilot!) is actually what we are interested in: Debug::SetScriptSource.

That merely delegates to LiveEdit::PatchScript(). That fetches all function literals from the abstract syntax trees of the old and new script, then calls LiveEdit::CalculateFunctionLiteralChanges() to identify possibly matching function literals. And what does that do? A fuzzy source code location based diff match of the function literals. The rest of LiveEdit::PatchScript() is making love to the JIT compiler to convince it to swap in the new code without crashing.

That's obviously very prone to errors and explains why V8's code hot swapping isn't all that great. I.e. adding a new function (literal) before an old function (literal) is enough to confuse it. For my use case, that's good enough. May whatever drives this universe have mercy on the poor souls that try this with something like React, Angular, or Vue. It's probably also why the Chome DevTools documentation doesn't mention this feature at all. It's simply not that useful in "professional" frontend development.

But that's not V8's fault. That's merely the tragedy of JavaScript. A language poised to give us super quick iteration times, but is entirely unfit to be coerced into a really nice live coding environment that allows state retention on code patching. Sad. Or to say it in the words of the Chrome DevTools documentation author:

JavaScript - not a care in the world.

Up next

Prototype the networked lockstep simulation. Pinky promise.

Discuss this post on Twitter.