Skip to content

Repository files navigation

vb-wasm

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

How it works

There is no interpreter here — this is the actual VB compiler:

  1. The compiler is compiled to WebAssembly. Microsoft.CodeAnalysis.VisualBasic and Microsoft.CodeAnalysis ship as .wasm modules in the published bundle.
  2. Reference assemblies are embedded. The 167 BCL reference DLLs from the .NET targeting pack (including Microsoft.VisualBasic.dll and Microsoft.VisualBasic.Core.dll) are embedded in the app assembly and turned into MetadataReferences at startup — no file system is needed.
  3. Compile in memory. VisualBasicCompilation.Create(...).Emit(stream) produces a PE image in memory.
  4. Run it. Assembly.Load(bytes) loads it and the Sub Main entry point is invoked, with Console output captured and Console.In fed from the page's stdin box.

This mirrors the approach used by live-codes/fsharp-wasm and seth0x41/csharp-wasm.

Layout

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).

Prerequisites

  • .NET SDK 10 with the wasm-tools workload.
  • Node.js (only for the static file server).
dotnet --list-sdks
dotnet workload list      # must list wasm-tools

Which SDK to use

There 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 use

Both 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.ps1

The 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:

  1. dotnet workload repair — effective once any workload is installed.
  2. dotnet workload install wasm-tools — usually succeeds regardless, and installing is what makes step 1 work.
  3. 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.
  4. -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%\dotnet is not user-writable. -DotnetRoot and -SourceRoot override the target and the manifest donor.

Do not delete the workloadsets folder. The widely-copied advice to remove sdk-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.20 and Microsoft.NET.Sdk.tvOS 26.5.10315), which is what the error was really reporting.

Build and run

# 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 8080

index.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.

npm package (CDN loader)

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 public

The 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);
  • baseUrl is the only required option, and it can be omitted when vb-compiler.js sits 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 a blob: worker, where relative paths would resolve against the blob itself.
  • run(source, stdin?) resolves to { ok, output, errors[], warnings[] }; stdin feeds Console.ReadLine().
  • runner.init() / runner.ready / runner.restart() / runner.dispose() cover warm-up and teardown. There is also a lazy default instance, so VbRunner.run(code) works after VbRunner.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.

Package size

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:

  1. The pre-compressed .br/.gz siblings are dropped. A .NET publish emits every asset three times, but those files only help a web server configured to serve them (nginx brotli_static, ASP.NET static files). jsDelivr compresses responses itself — verified: a .wasm request returns Content-Encoding: br. Use -KeepCompressed for hosts that need the pre-compressed files.
  2. Assets the runtime never loads are pruned. _framework/dotnet.js embeds the manifest of everything it fetches, so re-publishing over an older output no longer ships dead content-hashed assemblies (the dist/ in this repo had two 5.79 MB VbRunner.*.wasm files; only one is referenced).

What the PoC supports

  • Standard console programs: a Module with Sub Main() / Sub Main(args As String()).
  • The SDK's default global imports (System, System.Linq, …), so no Imports needed.
  • Option Strict On and Option Infer On, matching a default VB project.
  • Console.WriteLine / Write / ReadLine / Console.In via the stdin box (one line per ReadLine()).
  • Compile errors with VB error codes, line and column; runtime exceptions report the real exception type, and output printed before the crash is preserved.

Findings

  • 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.SetIn works, but reading Console.In does not. The Console.In getter throws PlatformNotSupportedException on browser-wasm, so stdin is installed with SetIn only (never saved/restored).
  • <input type="text"> cannot hold newlines — the stdin control is a <textarea> so multiple ReadLine() calls work.
  • Bundle size is the cost. The publish is ~71 MB uncompressed (~63 MB of _framework), including Microsoft.CodeAnalysis.VisualBasic (~4.6 MB wasm), Microsoft.CodeAnalysis (~2.9 MB), the .NET runtime, and the BCL. The publish emits .gz/.br variants; a CDN will serve those.
  • Roslyn is stable across repeated compiles. Unlike F# — where FSharpChecker.Compile deadlocks 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.js only infers "sidecar" mode (this worker owns the runtime, rather than being a .NET worker thread) when no onmessage handler exists yet — so vb-worker.js sets self.dotnetSidecar = true before importing it. The worker must also be a classic worker: the runtime detects its host via importScripts, 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 a blob: worker that importScripts() the real vb-worker.js off the CDN.
  • jsDelivr compresses assets itself. Asking for a .wasm returns Content-Encoding: br with Content-Type: application/wasm, so the .br/.gz siblings 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 of vb-worker.js verbatim into a data: worker, and btoa throws 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.js has 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.

Next steps

  • Wrap the bundle into a CDN-loadable package (vb-compiler.js) like fsharp-wasm, and run it inside a Web Worker so the UI never blocks. Done.
  • Add a LiveCodes LanguageSpecs entry (src/livecodes/languages/vb-wasm). Done: the language, its starter template, docs and Monaco/CodeMirror support are in the LiveCodes repo, which also owns the livecodes.vb bridge and its own copy of the worker — see livecodes/.
  • The livecodes.vb bridge was dropped from vb-compiler.js at 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 reusing 0.1.0.
  • Consider dropping the 13 satellite culture assemblies (~4.3 MB, localized compiler messages) if package size ever becomes tight again.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages