Angular tooling outside the IDE
I recently spent a week getting Angular’s language service running inside a monaco-editor web worker without any kind of node runtime.
I needed this as we let users build mini angular apps as plugins inside our platform. An LLM plus a bit of context is usually enough for someone to produce a small app that does exactly what they want, and esbuild-wasm handles the compile step. It works nicely — until something breaks and they need to debug it.
The problem
Monaco’s default typescript worker doesn’t give any angular-specific hints — tslib you can solve easily enough with compiler options, but angular hints are nowhere to be found. Which means you can’t just see issues, you’re forced to solve them at runtime.
The current solution
Stackblitz already cracked this a while ago. The entire language server runs inside one of their WebContainers, and if you spin up an angular project there, it just works.
I had some problems with it though, big and small:
- Overhead. We don’t need the entire virtual node runtime for this. The mini app is already safely plugged into our setup, and honestly our lovely legacy app can’t handle any more weight.
- Cost. Stackblitz aren’t transparent about enterprise pricing, which isn’t unusual, neither are paid libraries. But the issue is scaling said price. We’d been blindsided by a price hike before and that left a bad taste.
- Not FOSS. No dig at Stackblitz — there’s heavy competition in this space and open-sourcing would be giving the product away. But if they disappeared or abandoned it, we’d be in a bind. For software that’s been around years and will be around a lot longer, that’s an expensive concern.
An alternative
Turns out Angular publish their language service as a package to npm, kept inline with their usual update schedule. The whole service is bundled into a single CommonJS module — which means it’s leverageable. All we need to do is figure out what it wants and hand it mocked, webworker-safe versions of everything.
Mocking the big bits
CommonJS is easy to bend to your will. Its structure comes from a pre-module era where scoping is done in javascript. For example here’s the define list:
define(['module', 'exports', 'os', 'typescript', 'fs', 'module', 'path', 'url', 'node:path', 'assert']...
We can mock each of those by handing off our own implementations. At the end of the day it’s all just javascript — it just needs to please the language service.
I rolled out my own solution for most of the modules, but used path-browserify-esm for path as it’s contextless.
Things like document, process and require aren’t supplied this way though. The fix is to use the rolldown intro field:
export default defineConfig({
output: {
intro: "var require;var document;var process;",
...
},
...
});
This shoves a chunk of code right into the top of the language service, giving us globals we can then populate in the worker before the service starts up:
document = { baseURI: "file:///" };
require = ...;
process = ...;
It does require some careful manouvering, but it’s a really easy solution. Additionally some rolldown plugins were written to do things like stripping the require polyfill (the rolldown setting doesn’t seem to work).
After all this the worker stopped complaining.
The more awkward parts
Monaco uses separate workers for typescript, css and html — each isolated, only fed relevant files on request, with the filtering done on the main thread during setup. Angular’s language server expects shared information across these files. That’s how you get the errors telling you a property doesn’t exist in your component markup, or that an html/css file referenced in the decorator is missing.
The workaround is to patch the models sent to the worker. There are a few places to do this; I chose the client retrieval step, where monaco’s worker manager requests a client. The trick is two things: return the same worker for html and typescript (so they’re linked on the main thread), and send all the html, typescript and css files through. That gives us full coverage.
Couple gotchas
One weird one: the file system was crapping out on the monaco prefix (file:/// in our case). It couldn’t reconcile it, so I had to handle the translation between monaco’s vfs and what angular expected — a “real” file system.
The other gotcha was how much had to be added to the worker to satisfy html’s requirements. Most could be copied over, but doValidation took some fiddling.
Final points
This was an experiment — it works, but needs hefty testing before it’s ready for real use. If you’re interested, follow along: https://github.com/sebheron/monaco-angular