Run VB.NET in the browser with no server — the real Roslyn VB compiler
(Microsoft.CodeAnalysis.VisualBasic) compiled to .NET WebAssembly, running
inside the page.
This repository holds both the proof-of-concept page and @live-codes/vb-wasm,
the npm package that publishes the compiler bundle. The package serves two kinds
of consumer: its own vb-compiler.js loader, for any web app, and LiveCodes, which
uses the _framework/ assets directly (see livecodes/).
Compilation and execution both happen in the browser; nothing is sent anywhere.
src/VbRunner/wwwroot/index.html the PoC page (low-level dotnet.js, main thread)
demo.html the packaged loader demo (worker-based)
vb-compiler.js the npm package loader — see "npm package" below
There is no interpreter here — this is the actual VB compiler:
- The compiler is compiled to WebAssembly.
Microsoft.CodeAnalysis.VisualBasicandMicrosoft.CodeAnalysisship as.wasmmodules in the published bundle. - Reference assemblies are embedded. The 167 BCL reference DLLs from the
.NET targeting pack (including
Microsoft.VisualBasic.dllandMicrosoft.VisualBasic.Core.dll) are embedded in the app assembly and turned intoMetadataReferences at startup — no file system is needed. - Compile in memory.
VisualBasicCompilation.Create(...).Emit(stream)produces a PE image in memory. - Run it.
Assembly.Load(bytes)loads it and theSub Mainentry point is invoked, withConsoleoutput captured andConsole.Infed from the page's stdin box.
This mirrors the approach used by live-codes/fsharp-wasm and seth0x41/csharp-wasm.
| Path | Purpose |
|---|---|
src/VbRunner |
The browser app (Microsoft.NET.Sdk.WebAssembly). Exposes [JSExport] VbRunner.RunVb(source, stdin). |
src/VbRunner/CompileService.cs |
The whole compile + execute pipeline. Shared with the prototype. |
src/VbRunner/refs |
Reference assemblies, generated by scripts/prepare-refs.ps1 (gitignored). |
prototype/VbProto |
Console harness that runs the same pipeline on desktop CoreCLR for fast iteration. |
scripts/serve.js |
Minimal static file server for the published bundle. |
vb-compiler.js |
The npm package loader. Environment-agnostic (window, worker or bundler). |
vb-worker.js |
Classic Web Worker that hosts the runtime, loaded via importScripts. |
vb-compiler.mjs |
ES module wrapper around vb-compiler.js. |
demo.html |
Standalone demo of the loader; ships inside the package. |
scripts/make-package.ps1 |
Clean-publishes the app and assembles the deployable npm package. |
scripts/repair-dotnet-workload.ps1 |
Diagnoses and repairs a wasm-tools workload with missing manifests. |
livecodes/ |
How LiveCodes integrates this bundle (what it needs from the package). |
- .NET SDK 10 with the
wasm-toolsworkload. - Node.js (only for the static file server).
dotnet --list-sdks
dotnet workload list # must list wasm-toolsThere are usually two installs on a Windows dev box, and they are not equivalent:
| Install | On PATH? |
Typically |
|---|---|---|
%ProgramFiles%\dotnet |
yes | lacks the workload |
%USERPROFILE%\.dotnet |
no | has wasm-tools |
Running the workload commands against the one on PATH fails with "Workload set
version … has missing manifests" even when a perfectly good SDK exists next to
it. Check both before assuming anything is broken:
& "$env:USERPROFILE\.dotnet\dotnet.exe" workload list # the one to useBoth scripts already prefer %USERPROFILE%\.dotnet: prepare-refs.ps1 defaults
its -SdkRoot to it, and make-package.ps1 runs that install's dotnet.exe
(pass -DotnetRoot <path> to point either at a different one).
How to fix it. A script walks the options in order:
# see exactly what is wrong and what would happen (changes nothing) powershell -ExecutionPolicy Bypass -File scripts\repair-dotnet-workload.ps1 -WhatIf # repair it, from an ELEVATED prompt powershell -ExecutionPolicy Bypass -File scripts\repair-dotnet-workload.ps1The obvious fix is
dotnet workload repair, but on an SDK with no workloads installed it prints "No workloads are installed, nothing to repair" and changes nothing — while the workload set goes on pinning the manifests that are missing. That deadlock is why the script escalates, re-checking after each step:
dotnet workload repair— effective once any workload is installed.dotnet workload install wasm-tools— usually succeeds regardless, and installing is what makes step 1 work.- Restore the missing manifests from another .NET install on the machine that already has those exact versions. A manifest for a given id/version is immutable published content, so an exact version match is a content match — no need to trust the source beyond that.
-ResetWorkloadSet— move the workload set marker aside. Opt-in, last resort: it drops the pinned manifest versions, which is exactly why it is not automatic.Nothing is deleted — step 4 renames the marker, and only when that flag is passed. Elevation is needed only because
%ProgramFiles%\dotnetis not user-writable.-DotnetRootand-SourceRootoverride the target and the manifest donor.Do not delete the
workloadsetsfolder. The widely-copied advice to removesdk-manifests\<band>\workloadsets\<version>only applies when that folder is empty or corrupt. When it still holds a valid workload set, leave it alone — removing it silently drops the pinned manifest versions, and it will very often not be the actual problem. In the case that prompted this script the workload set was perfectly intact and only two pinned manifests were missing (Microsoft.NET.Sdk.Maui 10.0.20andMicrosoft.NET.Sdk.tvOS 26.5.10315), which is what the error was really reporting.
# 0) One-time (re-run only when the .NET SDK changes)
powershell -ExecutionPolicy Bypass -File scripts\prepare-refs.ps1
# 1) Cross-check the compiler pipeline on desktop (fast, no wasm)
dotnet run --project prototype\VbProto
# 2) Publish the browser bundle — use the SDK that has wasm-tools (see above)
& "$env:USERPROFILE\.dotnet\dotnet.exe" publish src\VbRunner -c Release -o src\VbRunner\dist
# 3) Serve it and open http://localhost:8080
node scripts\serve.js src\VbRunner\dist\wwwroot 8080index.html there is the PoC page, which drives _framework/dotnet.js directly
on the main thread. For the packaged loader instead, build the package
(scripts\make-package.ps1 -SkipPublish -PublishDir src\VbRunner\dist) and open
http://localhost:8080/demo.html from the vb-package folder.
prepare-refs.ps1 assumes a user-local SDK; pass -SdkRoot <dotnet root> if
yours lives elsewhere.
scripts/make-package.ps1 publishes the app and assembles vb-package/, an npm
package whose files are served straight from a CDN:
package.json npm metadata (jsDelivr uses `baseUrl` = the package root)
vb-compiler.js the loader: VbRunner.create({ baseUrl }).run(source, stdin)
vb-compiler.mjs ES module wrapper
vb-worker.js worker host, loaded via importScripts
demo.html a working editor at <baseUrl>/demo.html
_framework/ .NET runtime, Roslyn VB compiler, BCL and reference assemblies
scripts\prepare-refs.ps1 # once per SDK upgrade
scripts\make-package.ps1 -Version 0.1.0 # clean publish + assemble → vb-package/
npm publish vb-package --access publicThe API is deliberately small:
const runner = VbRunner.create({ baseUrl: 'https://cdn.jsdelivr.net/npm/@live-codes/vb-wasm/' });
const { ok, output, errors, warnings } = await runner.run(source, stdin);baseUrlis the only required option, and it can be omitted whenvb-compiler.jssits next to_framework/(it is then read from the<script>tag). Relative values such as'./vb-package/'are fine: they are resolved to absolute URLs first, because the runtime is loaded from inside ablob:worker, where relative paths would resolve against the blob itself.run(source, stdin?)resolves to{ ok, output, errors[], warnings[] };stdinfeedsConsole.ReadLine().runner.init()/runner.ready/runner.restart()/runner.dispose()cover warm-up and teardown. There is also a lazy default instance, soVbRunner.run(code)works afterVbRunner.baseUrl = '…'.
Running from a Web Worker. There is no DOM access outside a window scope, so
the same file loads with importScripts() and the runtime is created on that
worker's own thread (a nested worker would not be portable):
// worker.js
importScripts('https://cdn.jsdelivr.net/npm/@live-codes/vb-wasm/vb-compiler.js');
const runner = VbRunner.create({ baseUrl: 'https://cdn.jsdelivr.net/npm/@live-codes/vb-wasm/' });
self.onmessage = async (e) => self.postMessage(await runner.run(e.data));In a window, a Web Worker is used automatically so the UI keeps painting.
This loader knows nothing about LiveCodes. The livecodes.vb bridge lives in
the host repository, next to the language spec
(src/livecodes/languages/vb-wasm/lang-vb-wasm-script.ts), and LiveCodes loads its
own bundled script rather than this file. The package is still pinned as LiveCodes'
vbWasmBaseUrl, but only so that _framework/ can be fetched from it.
jsDelivr refuses to serve packages above 150 MB, and @live-codes/fsharp-wasm
sits at 146.9 MB — one more stale asset away from breaking. Two things keep this
package at ~43 MB:
- The pre-compressed
.br/.gzsiblings are dropped. A .NET publish emits every asset three times, but those files only help a web server configured to serve them (nginxbrotli_static, ASP.NET static files). jsDelivr compresses responses itself — verified: a.wasmrequest returnsContent-Encoding: br. Use-KeepCompressedfor hosts that need the pre-compressed files. - Assets the runtime never loads are pruned.
_framework/dotnet.jsembeds the manifest of everything it fetches, so re-publishing over an older output no longer ships dead content-hashed assemblies (thedist/in this repo had two 5.79 MBVbRunner.*.wasmfiles; only one is referenced).
- Standard console programs: a
ModulewithSub Main()/Sub Main(args As String()). - The SDK's default global imports (
System,System.Linq, …), so noImportsneeded. Option Strict OnandOption Infer On, matching a default VB project.Console.WriteLine/Write/ReadLine/Console.Invia the stdin box (one line perReadLine()).- Compile errors with VB error codes, line and column; runtime exceptions report the real exception type, and output printed before the crash is preserved.
- The VB compiler runs on the single-threaded wasm runtime as long as
parallel binding is disabled (
WithConcurrentBuild(false)). Unlike the F# runner — which deadlocks after ~3 compiles and needs a worker respawn — Roslyn here is stable across repeated compiles. Console.SetInworks, but readingConsole.Indoes not. TheConsole.Ingetter throwsPlatformNotSupportedExceptionon browser-wasm, so stdin is installed withSetInonly (never saved/restored).<input type="text">cannot hold newlines — the stdin control is a<textarea>so multipleReadLine()calls work.- Bundle size is the cost. The publish is ~71 MB uncompressed (~63 MB of
_framework), includingMicrosoft.CodeAnalysis.VisualBasic(~4.6 MB wasm),Microsoft.CodeAnalysis(~2.9 MB), the .NET runtime, and the BCL. The publish emits.gz/.brvariants; a CDN will serve those. - Roslyn is stable across repeated compiles. Unlike F# — where
FSharpChecker.Compiledeadlocks after ~3 compiles and the worker has to be respawned — five back-to-back VB compiles in a single runtime all succeed, so the loader keeps one runtime alive and needs no respawn logic. - The .NET runtime needs two hints inside a worker.
dotnet.jsonly infers "sidecar" mode (this worker owns the runtime, rather than being a .NET worker thread) when noonmessagehandler exists yet — sovb-worker.jssetsself.dotnetSidecar = truebefore importing it. The worker must also be a classic worker: the runtime detects its host viaimportScripts, which does not exist in a module worker. - Cross-origin workers cannot be created directly.
new Worker(<cdn url>)is blocked by the same-origin policy, so the loader bootstraps ablob:worker thatimportScripts()the realvb-worker.jsoff the CDN. - jsDelivr compresses assets itself. Asking for a
.wasmreturnsContent-Encoding: brwithContent-Type: application/wasm, so the.br/.gzsiblings in the publish output are dead weight on a CDN — dropping them, plus pruning unreferenced assets, takes the package from ~78 MB (with a stale duplicate) to ~43 MB against jsDelivr's 150 MB ceiling. - A worker inlined into a
data:URL must be ASCII, or encoded as UTF-8. LiveCodes embeds its copy ofvb-worker.jsverbatim into adata:worker, andbtoathrows on any character outside Latin1 — an em dash in a comment is enough to fail the boot, with an error that points at the encoder rather than the comment. Encode the UTF-8 bytes, and keep the worker itself ASCII. vb-worker.jshas a mirrored copy in LiveCodes (src/livecodes/languages/vb-wasm/lang-vb-wasm-worker.raw.js), because LiveCodes owns its own integration glue. Both speak the same{ type: 'compile', id, source, stdin }protocol, so boot changes have to be made in both places or the two copies drift.
Wrap the bundle into a CDN-loadable package (Done.vb-compiler.js) likefsharp-wasm, and run it inside a Web Worker so the UI never blocks.Add a LiveCodesDone: the language, its starter template, docs and Monaco/CodeMirror support are in the LiveCodes repo, which also owns theLanguageSpecsentry (src/livecodes/languages/vb-wasm).livecodes.vbbridge and its own copy of the worker — seelivecodes/.- The
livecodes.vbbridge was dropped fromvb-compiler.jsat the same time, so the next published version is a breaking change for anyone loading this loader inside a LiveCodes result frame. Nobody does — LiveCodes loads its own script — but that is worth a version bump rather than reusing0.1.0. - Consider dropping the 13 satellite culture assemblies (~4.3 MB, localized compiler messages) if package size ever becomes tight again.