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.
This isn’t something I needed, as much as it was something I wanted for a demo I was working on at work. I ended up ditching this idea in the timeframe I had for the work project, but decided to give it a go in my free time.
The problem
There’s a worker for typescript embedded in monaco. It is, however, not extensible, not configurable beyond basic configuration and just flat out doesn’t support Angular.
There are really small things you can do to provide a slightly better experience, such as adding tslib to the worker’s compiler options. This works, but it doesn’t replace the full IDE experience you get from the language server in VSCode.
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:
- Overhead.: Why do I need an entire virtual node runtime just for syntax validation?
- Cost. If I commit to using WebContainers for any project, and it goes commercial, I’m now required to hand over my hard earned cash.
- Not FOSS. If stackblitz disappears, I’m left with either a massive tool I’ve got to support myself (if they decided to open-source it post-closure) or I’m dead in the water.
None of this was particularly appetising for me.
An alternative
I did a deep dive into the Angular repo and discovered a pretty major thing about their project structure. They publish their language service as an NPM package. This is post bundled code with no source maps, in that format that typescript emits when you ask for AMD and CommonJS. But there’s no native executables, no externals calls beyond what node offers. Meaning we’re in well tread territory for mocking and reimplementing the parts of it to get it running on the web.
Mocking the big bits
To give a bit of background on CommonJS. It’s an older format from the pre-module era designed explicitly for handling packages in node. You’ll rarely see handwritten CommonJS in 2026, it’s mostly just a legacy target for older versions of node. AMD is just the pre-modules browser equivalent; it’s a specific kind of asynchronous import system that’s be superseded but is ripe for messing with to get the behaviour we want.
CommonJS to ESM
First off we need to actually make the Angular language service importable into our worker. So if we go into the source code we can see the bit we need to change.
let $deferred;
function define(modules, callback) {
$deferred = {modules, callback};
}
module.exports = function(provided) {
const ts = provided['typescript'];
if (!ts) {
throw new Error('Caller does not provide typescript module');
}
const results = {};
const resolvedModules = $deferred.modules.map(m => {
if (m === 'exports') {
return results;
}
if (m === 'typescript') {
return ts;
}
return require(m);
});
$deferred.callback(...resolvedModules);
return results;
};
That module.exports object isn’t natively supported by the browser. We need to change it into ESM, as we’re using rolldown as our bundler we can add the following plugin.
{
name: "language-service-transform",
transform(code, id) {
if (!id.includes("language-service")) return;
return (
code.replace("module.exports = function", "export default function") +
"\n"
);
},
},
That will find all occurences of module.exports across our code and replace them with export default function making it importable like:
import ls from "@angular/language-service/bundles/language-service.js";
The define array
Next we can tackle the define array. Below is a factory function that’s present in the file that makes sure the dependencies that need to exist are present.
define([
'module',
'exports',
'os',
'typescript',
'fs',
'module',
'path',
'url',
'node:path',
'assert'
]...
This is a bit of tiresome process, it requires going through each node require and mocking it. The bulk of the calls are on the typescript module, for which we can use the worker version. The path module we can use a typescript shim for that like so:
const path = {
isAbsolute: typescript.pathIsAbsolute ?? ((p) => typescript.getRootLength(p) !== 0),
resolve: (...args) => typescript.resolvePath(args[0] ?? "", ...args.slice(1)),
join: (...args) => typescript.combinePaths(args[0] ?? "", ...args.slice(1)),
dirname: typescript.getDirectoryPath,
basename: (p, ext) => {
const base = typescript.getBaseFileName(p);
return ext && base.endsWith(ext) ? base.slice(0, -ext.length) : base;
},
extname: (p) => typescript.getAnyExtensionFromPath(p),
normalize: typescript.normalizePath,
relative: (from, to) =>
typescript.getRelativePathFromDirectory(from, to, /* ignoreCase */ false),
};
Everything else is mocked bit by bit. Have a look at the worker in the repo to see what was needed.
Implicit globals
Things like document, process and require aren’t supplied this way though. As we’re in a web worker environment we don’t get a document object, process and require are node builtins too. We can mock these the same way as before, but we need to expose them. 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 manoeuvring, but it’s a pretty simple solution. Finally after all this the worker stopped complaining.
Monaco related issues
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.
Final points
This was an experiment, but it’s now good enough to be used in a legitimate project. If you’re interested, follow along: https://github.com/sebheron/monaco-angular
Edit on 05/05/2026 - I decided to rewrite this article a bit. I initially fell into the trap when writing this of passing it through Claude AI over and over again, until I had something I was sure would be more interesting to read. The problem is it stripped all of my personality from the page, IMHO defeating the point of even writing it. So my new rule is: No “AI-assisted writing” on this blog.