tinywars - TypeScript code hot swapping with state retention
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:
- Disable any live-editing functionality that reloads the page, as that would lose state.
- Build the frontend TypeScript source code in watch mode so the
.js.mapoutput files gets updated when you change a TypeScript source file.
- Start the server, serving the static frontend assets, including the
js.mapfiles compiled from the TypeScript sources.
- Open the page using the TypeScript code in Chrome, then open the Chrome DevTools on that page.
- Click the
Sourcestab, then switch to
Add folder to workspace, and add the folder(s) containing the
.tssource files, as well as the folder(s) containing the compiled
.js.mapfiles. For tinywars, that looks like this:
tinywars.tsget linked to
- 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:
- Build your backend TypeScript source code in watch mode.
- Start your Node.js server in debug mode with the
- Go to chrome://inspect, then click select your Node.js instance. This opens a Chrome DevTools instance attached to the Node.js server.
- Click the
Filesystemand add your Node.js server's source code folder containing
.tsfiles, as well as the build output folder containing the
- Edit and save the
.tsfiles 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.
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.
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:
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.
Prototype the networked lockstep simulation. Pinky promise.
Discuss this post on Twitter.