Goal: run Angular in the browser with no server and no build step, so it can become a LiveCodes language.
poc/index.html— working proof of concept. Open it directly (file://) or over any static server. TypeScript + Angular run entirely client-side. Verified in Chrome fromfile://with no server.- LiveCodes currently has no Angular language (
src/livecodes/languages/has react, vue, svelte, solid, stencil, … but noangular).
@angular/compiler— the runtime JIT template compiler. Pure JS/ESM, runs in browsers and workers. It compiles@Componentmetadata into component defs lazily, when a class'sɵcmpis first read (i.e. during bootstrap). It is not a.ts → .jsfile transform.@angular/compiler-cli(ngtsc) — the build-time file→file compiler. Node-only (needstypescript,fs, a program). Not usable in a browser.oxc-angular-compiler— build-time file→file and fast, but native NAPI only (see below).
So there is no browser-capable library that turns an Angular .ts file into runnable JS. What
you use directly is @angular/compiler, as a runtime dependency, plus a small runtime shim.
From napi/angular-compiler/package.json:
"napi": { "targets": [
"aarch64-apple-darwin", "aarch64-pc-windows-msvc",
"aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl",
"x86_64-apple-darwin", "x86_64-pc-windows-msvc",
"x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"
]}
- No
wasm32target, no WASM artifact, no browser bundle. Published as@oxc-angular/vite(a Vite plugin peer-depending onvite >=8) plus Node NAPI bindings. - Its
napi/playgroundis a Vite/Node app that calls the native binding — still not a browser build. - Making it work in the browser would mean adding a
wasm-bindgentarget, building the Rust crate towasm32-unknown-unknown, and shipping the.wasm— a separate project, not a PoC.
Everything is client-side; a CDN is used for distribution only.
- An import map maps
@angular/*andrxjsto ESM builds on esm.sh (esm.sh rewrites internal@angular/*imports to absolute URLs, so all packages share one@angular/coreinstance — required for JIT). - TypeScript (UMD from jsDelivr) transpiles the user's
.tswithexperimentalDecorators: trueanduseDefineForClassFields: false. No type-checking, so it is fast. - The emitted JS becomes a
Blobmodule and is loaded with dynamicimport(). Bare@angular/coreimports resolve through the page's import map. @angular/compileris imported for its side effect: it publishes the JIT compiler facade onglobalThis.ng.ɵcompilerFacade, which@angular/corereads when a component def is first needed.- The root component is found (default export) and bootstrapped with
bootstrapApplication+provideZonelessChangeDetection()(no zone.js needed). The host element is created from the component'sselector. - Re-running destroys the previous
ApplicationRefand clears the outlet.
ngJitMode is not required — Angular's JIT is enabled by default (ngJitMode = false is what
disables it). Verified: JIT works with typeof window.ngJitMode === 'undefined'.
Verified: signals, @if/@for, property/event bindings, computed signals, style encapsulation,
zoneless change detection, re-run with changed code, and JIT template errors surfacing in the log.
LiveCodes has the hooks. The split is just different from Vue/Svelte, which compile in the worker and only execute the result:
compiler.factory— strip types withts.transpileModule. SetexperimentalDecorators: trueanduseDefineForClassFields: false: LiveCodes'typescriptOptionssets neither, and TypeScript 5's default emit (standard decorators) is incompatible with Angular's legacy decorators.compiler.imports(orCompileInfo.imports) —@angular/core,@angular/compiler,@angular/platform-browser,@angular/common,rxjs→ esm.sh URLs. LiveCodes already turns this into the result document's import map.compiler.inlineModule— the ~30-line shim that runs in the result document: import@angular/compilerfor its side effect, import the user's module, create the host element from the component selector,bootstrapApplication(comp, { providers: [provideZonelessChangeDetection()] }), then destroy theApplicationRefon re-run.
Because the real compilation happens in the result document, template errors surface at runtime rather
than in the worker. Getting the Vue/Svelte model (compile in the worker → AOT JS) would require a WASM
build of ngtsc/oxc emitting partial declarations (ɵɵngDeclareComponent) — that is the only genuine
"wrapper" project, and oxc-angular-compiler has no WASM target today.
NG0912on repeat runs. Re-running the same code creates a new class with the same name and selector, and Angular caches component IDs globally, so it warns about an ID collision. Rendering is still correct. The real fix is a fresh realm per run — which is what LiveCodes already does with its result iframe.- JIT, not AOT. No template type-checking, no build-time optimizations.
- Single file. No multi-file /
templateUrl/ npm dependency resolution. - TypeScript is ~10 MB from CDN on first load.
- Pinned to Angular 20.3.31 (LTS);
provideZonelessChangeDetectionis stable there.
The PoC is strictly single-file, and this is not just missing UI — there are two independent limitations, both verified against the running PoC:
- Module graph. The transpiled code is loaded as a single
Blobmodule. Relative specifiers cannot resolve against ablob:base, soimport { Child } from './child.component'fails withFailed to resolve module specifier "./child.component". Invalid relative url or base scheme isn't hierarchical.Only bare specifiers (via the import map) work. - Component resources. Angular's JIT does not support
templateUrl/styleUrl(s)as-is; resources are queued and must be resolved before bootstrap, otherwise:Component 'AppComponent' is not resolved: - templateUrl: ./app.component.html / Did you run and wait for 'resolveComponentResources()'?
LiveCodes already ships the machinery to fix both:
- Bare imports —
createImportMap()maps them to esm.sh. Pin every@angular/*package to the same version so exactly one@angular/coreinstance exists. - Secondary modules — LiveCodes'
replaceSFCImports()pattern already does what is needed: fetch the dependency, compile it, convert it withtoDataUrl(), and add an import-map entry for the specifier. Bare specifiers inside adata:module still resolve through the import map, so@angular/corestays shared. - External templates/styles — either
(a) inline at compile time (rewrite
templateUrl/styleUrls/styleUrl→template/styles), which is simplest and surfaces errors earlier, or (b) keep real URLs and callresolveComponentResources(resourceResolver)beforebootstrapApplication. - Best fit for LiveCodes' multi-pane model — map the
markuppane to the component'stemplateand thestylepane to itsstyles. Users get separate HTML/CSS "files" with no virtual file system and noresolveComponentResourcesat all.
Angular-specific caveats:
- Instance identity is critical. Every
@angular/core,@angular/compiler,@angular/commonand@angular/platform-browserimport must resolve to the same URL everywhere. IfreplaceImportsinlines a full esm.sh URL into one file while another resolves the bare specifier through the import map, you can get two core instances and JIT breaks. - Same class object graph.
imports: [ChildComponent]needs that identical class; importing one source through two different specifiers yields two classes and Angular will complain. - The worker still cannot compile templates, so cross-file template errors only appear at runtime.
Yes — verified in poc/multifile.html with four files: counter.service.ts
(@Injectable({providedIn: 'root'})), format.pipe.ts (standalone @Pipe),
child.component.ts (imports the service and pipe, plus @Input/@Output) and
app.component.ts (imports ChildComponent, binds [label] and (bumped)).
Each file is transpiled to its own module and loaded with an absolute module URL; relative
specifiers are rewritten to the dependency's URL, while bare @angular/core stays bare and comes
from the import map. That mirrors LiveCodes' ~/… + data-URL mechanism.
Verified working:
- Cross-file component import with
imports: [ChildComponent]. - Cross-file standalone pipe (
{{ label | shout }}). - Cross-file
@Injectable— a single shared instance: incrementing from either component updates both. @Input()/@Output()(decorator form) in both directions.useDefineForClassFieldstrueandfalse— both work identically.
Signal input() / output() don't work under JIT — but this is fixable in the compiler, not a
reason to change compilers.
Root cause: JIT builds a component's inputs from decorator metadata (propMetadata plus any
explicit inputs), and a component def is created before any instance exists — so nothing can know
that a class field holds an InputSignal (and input() cannot know its own property name). AOT
doesn't have this problem because it emits the per-input signal flag into ɵɵdefineComponent; the
decorator/JIT path has no equivalent.
Measured in poc/multifile.html:
child declares label as |
result |
|---|---|
input('child') |
NG0303: Can't bind to 'label' since it isn't a known property of 'app-child'; output() bindings silently ignored |
@Input() label = input('child'), plus inputs: ['label'], outputs: ['bumped'], signals: true |
input registers, but Angular assigns the plain value over the InputSignal → child template renders partially and label() throws |
backing signal + get + @Input() set (below) |
works |
The working shim keeps the template unchanged ({{ label() }}) and keeps this.label.set(...)
working:
// from: label = input('child');
private readonly _label = signal('child');
get label() {
return this._label;
}
@Input() set label(value: string) {
this._label.set(value);
}output() maps directly onto @Output() x = new EventEmitter(). So the Angular language compiler
can rewrite signal I/O declarations into decorator form and the limitation goes away with the JIT
compiler we already have. Remaining variants to handle: input.required(), input(..., {alias, transform}), and model() (→ an @Input plus an @Output pair).
The only way to get true signal inputs and compile-time template diagnostics is to emit the
component definition (with signal flags) ourselves — real AOT in the browser. That requires a
browser-capable compiler frontend: ngtsc is Node-only and oxc-angular-compiler has no WASM target,
so it is a project, not a patch.
Everything Angular-specific lives behind one entry point, so the host does not see the implementation details — and so the JIT-shim approach can later be swapped for a real AOT compiler without the host changing:
import {
transformSignalIo, // signal I/O → decorator I/O
inlineResources, // templateUrl / styleUrl(s) → inline template / styles
resolvePath, // relative specifier → project path
compileAngularFile, // the steps above, plus type stripping and sourcemaps
angularImports, // pinned @angular/* + rxjs import-map entries
angularBootstrapModule, // the module that runs in the result document
ANGULAR_COMPILER_OPTIONS,
} from './angular-compiler/index.js';Constraints it keeps:
tsis injected, never imported — usable in a worker, in Node tests and in the browser, without loading TypeScript twice.- Pure functions over source text. Only
angularBootstrapModulereturns code meant to run elsewhere, as a string, because it must execute against the host's import map. resolve(path) => { content } | nullis the only project-layout assumption. Nothing here knows aboutconfig.files, data URLs, iframes or~/keys.- Zero dependencies, no build step.
src/language.angular.js is the thin glue for the multi-file-support branch: it maps
config.files onto resolve, forwards options.filename, applies compileAngularFile, and sets
compiler.imports / inlineModule / multiFileSupport. It also passes the entry specifier
(~/main.ts) in explicitly rather than baking it into the package.
Deliberately conservative: a class field is only rewritten when its initializer is literally a call
to input, output, model, viewChild or contentChild, resolved through the file's
@angular/core import bindings (so import { input as inp } works). Everything else is left
untouched, and the result reports unsupported / conflicts for anything it declined.
Emitted shapes — the template and the signal API are unchanged in every case:
| source | emitted |
|---|---|
label = input('child') |
backing signal + get label() + @Input() set label() |
greeting = input.required<string>() |
same, plus a throw until a value is set |
bumped = output<number>() |
@Output() bumped = new EventEmitter() |
value = model(0) |
backing signal + @Output() valueChange + callable with emitting .set/.update |
probe = viewChild('t') |
@ViewChild('t') + state signal + one ngAfterViewChecked sync hook per class |
alias and transform are passed through; read is passed through on queries. viewChildren() /
contentChildren() are reported, not rewritten — the reactive-array semantics cannot be reproduced
by a decorator shim. queries: false turns the query rewriting off entirely, for comparison.
Refuses to rewrite (and says why) when a needed name is already bound by something else, rather than silently binding to the wrong thing.
Covered by assertions in Node across the emitters, aliasing, options, import handling and the decline cases.
templateUrl → template, and styleUrl / styleUrls → styles, resolved against the importing
file and emitted as JSON string literals. Reported rather than rewritten when the file cannot be
resolved, when the value is not statically readable, or when the object already sets the inline
property (Angular rejects template together with templateUrl).
Component resources are the one thing LiveCodes' import rewriting cannot see: they are decorator
string literals, not import statements, and Angular's JIT refuses to bootstrap a component whose
resources are unresolved. Inlining is therefore a compiler concern — the alternative would be a
resolveComponentResources() loader at runtime, which needs a handle on resources the compiler step
has already discarded.
Non-CSS style sources are the caller's problem: run them through their own compiler before handing the content over.
poc/multifile.html is a six-file app (counter.service.ts, format.pipe.ts,
child.component.ts, child.component.html, child.component.css, app.component.ts) that consumes
the package exactly as LiveCodes would — the host supplies resolve, the package does the rest. In
Chrome:
input()/input.required()receive parent values;output()events reach the parent.model()two-way: "Bump local model" updates the child and the parent binding.- The cross-file
@Injectablestays one shared instance. viewChild()resolves with the shim; with?queries=offthe rawviewChild()never resolves (still undefined after a change-detection pass), confirming the query is never registered.templateUrl: './child.component.html'andstyleUrl: './child.component.css'are both inlined and applied, with view encapsulation intact (color: rgb(0, 128, 0), host carrying an_ng…attribute).- Console is clean — no
NG0303, noNG0100.
Gotcha found on the way: a template reference variable shadows a component property of the same
name, so {{ box() }} next to <p #box> calls the ElementRef, not the query. That confounded an
earlier reading of this test and is worth remembering when writing starter templates.
npm install --include=dev
npm test # test/signal-io.mjs, then test/collections.mjstest/signal-io.mjs covers the emitters, aliasing, options, import handling, resource inlining and
compileAngularFile. It runs the transformed code against a stub @angular/core (signals, decorator
metadata, an EventEmitter) instead of needing a browser, which is how two real bugs got caught.
test/collections.mjs is the spec for viewChildren() / contentChildren(), which are not
implemented yet. It ships two deliberately-naive fixtures — a hook sync with a !== guard, and a
subscription that never unsubscribes — and asserts that both pass the happy path (seed, add,
remove) and fail the scenario each exists to catch (writing on every check; writing after destroy).
That is what gives the five pending assertions teeth: they cannot be satisfied by an implementation
that merely looks plausible.
Note: this environment has NODE_ENV=production, which makes npm skip devDependencies, so a plain
npm install installs nothing. --include=dev is required.
- Wire
src/language.angular.js+src/angular-compiler/intomulti-file-supportassrc/livecodes/languages/angular/, bundling the package intolang-angular-compiler.js(the Vue language'simportScriptspattern). - Ship the starter template (
index.html+main.ts+ a component) and the Angular docs page, and note theinput/output/modelsupport and theviewChildrengap there. - Decide whether extra markup files become allowed:
inlineResourcesalready handlestemplateUrl, but multi-file currently permits only the main markup file, so a conventionalapp.component.htmlcannot exist yet. - Revisit
oxc-angular-compileronly if it gains a WASM build — that is the only route to true signal inputs and compile-time template diagnostics.