unplugin-dotnet-wasm
v1.5.2
Published
Mount .NET WebAssembly output as virtual modules for Vite, Webpack, Rollup, esbuild, Rspack, Rsbuild, Rolldown, Farm, and Bun.
Downloads
1,416
Maintainers
Readme
unplugin-dotnet-wasm
unplugin-dotnet-wasm enables bundling .NET WebAssembly Browser Apps and Blazor WebAssembly Apps with your favorite JavaScript bundler. Enable WasmBundlerFriendlyBootConfig and install the plugin: no configuration surgery, it just works. Compatible with dotnet build output and hooks into each bundler's watch mode and/or dev server to keep your dev loop fast. When you are ready for release it'll bundle the optimized dotnet publish output all the same.
Built on unplugin to enable support for Vite, Webpack, Rollup, Rolldown, Rspack, Rsbuild, esbuild, Farm, and Bun.
[!TIP] unplugin-dotnet-wasm pairs great with TypeShim for seamless .NET + TypeScript interop.
Install
npm i -D unplugin-dotnet-wasm[!IMPORTANT]
Your .NET WebAssembly project must have WasmBundlerFriendlyBootConfig set totrue.
Usage
Register the plugin in your bundler config. The import path is the only thing that differs across bundlers; the DotnetWasm({...}) call is identical everywhere. Options are documented under Configuration.
Bundler examples
import { defineConfig } from 'vite';
import DotnetWasm from 'unplugin-dotnet-wasm/vite';
export default defineConfig({
// ...
plugins: [
DotnetWasm({
projectName: 'MyLibrary',
projectRoot: '../MyLibrary',
configuration: 'Debug',
targetFramework: 'net10.0',
isPublish: false,
}),
],
});Node target: vite build emits a browser bundle by default. For Node, use a server environment and emit assets so .wasm / .dat / .pdb land in dist:
export default defineConfig({
plugins: [
DotnetWasm({
projectName: 'MyLibrary',
projectRoot: '../MyLibrary',
configuration: 'Debug',
targetFramework: 'net10.0',
isPublish: false,
}),
],
environments: {
node: {
consumer: 'server',
build: {
emitAssets: true, // omitted from non-client builds otherwise
rolldownOptions: { // rollupOptions on Vite 5–7
input: 'src/entry.ts',
},
},
},
},
builder: {
buildApp: async (builder) => {
await builder.build(builder.environments.node); // skip the client environment
},
},
});import DotnetWasm from 'unplugin-dotnet-wasm/webpack';
export default {
// ...
plugins: [
DotnetWasm({
projectName: 'MyLibrary',
projectRoot: '../MyLibrary',
configuration: 'Debug',
targetFramework: 'net10.0',
isPublish: false,
}),
],
};Node target: emit ESM so the dotnet runtime's dynamic imports resolve at runtime:
export default {
// ...
target: 'node',
experiments: { outputModule: true },
output: { module: true },
};Rollup does not resolve bare module specifiers on its own, so the dotnet runtime's internal imports need @rollup/plugin-node-resolve:
import nodeResolve from '@rollup/plugin-node-resolve';
import DotnetWasm from 'unplugin-dotnet-wasm/rollup';
export default {
// ...
plugins: [
nodeResolve({ browser: true }), // omit `browser` when targeting Node
DotnetWasm({
projectName: 'MyLibrary',
projectRoot: '../MyLibrary',
configuration: 'Debug',
targetFramework: 'net10.0',
isPublish: false,
}),
],
};Node target: externalize Node built-ins so the dotnet runtime's Node-only paths don't get pulled into the graph:
import { builtinModules } from 'node:module';
export default {
// ...
external: [...builtinModules, ...builtinModules.map((m) => `node:${m}`)],
};import * as esbuild from 'esbuild';
import DotnetWasm from 'unplugin-dotnet-wasm/esbuild';
await esbuild.build({
// ...
plugins: [
DotnetWasm({
projectName: 'MyLibrary',
projectRoot: '../MyLibrary',
configuration: 'Debug',
targetFramework: 'net10.0',
isPublish: false,
}),
],
format: 'esm',
});Node target: set platform: 'node' (keep format: 'esm'; esbuild's Node default is CJS):
await esbuild.build({
// ...
platform: 'node',
format: 'esm',
});import DotnetWasm from 'unplugin-dotnet-wasm/rspack';
export default {
// ...
plugins: [
DotnetWasm({
projectName: 'MyLibrary',
projectRoot: '../MyLibrary',
configuration: 'Debug',
targetFramework: 'net10.0',
isPublish: false,
}),
],
};Node target: emit ESM so the dotnet runtime's dynamic imports resolve at runtime, and set publicPath: 'auto' so asset URLs resolve:
export default {
// ...
target: 'node',
experiments: { outputModule: true },
output: {
module: true,
publicPath: 'auto',
},
};import { defineConfig } from '@rsbuild/core';
import DotnetWasm from 'unplugin-dotnet-wasm/rsbuild';
export default defineConfig({
// ...
plugins: [
DotnetWasm({
projectName: 'MyLibrary',
projectRoot: '../MyLibrary',
configuration: 'Debug',
targetFramework: 'net10.0',
isPublish: false,
}),
],
});Node target: emit ESM so the dotnet runtime's dynamic imports resolve at runtime, and set publicPath: 'auto' so asset URLs resolve:
export default defineConfig({
// ...
output: { target: 'node' },
tools: {
rspack: (config) => {
config.experiments = { ...config.experiments, outputModule: true };
config.output = {
...config.output,
module: true,
publicPath: 'auto',
};
return config;
},
},
});import DotnetWasm from 'unplugin-dotnet-wasm/rolldown';
export default {
// ...
plugins: [
DotnetWasm({
projectName: 'MyLibrary',
projectRoot: '../MyLibrary',
configuration: 'Debug',
targetFramework: 'net10.0',
isPublish: false,
}),
],
};Node target: externalize Node built-ins so the dotnet runtime's Node-only paths don't get pulled into the graph:
import { builtinModules } from 'node:module';
export default {
// ...
external: [...builtinModules, ...builtinModules.map((m) => `node:${m}`)],
};Bun refuses to emit files with unknown extensions. Declare the three binary asset types the dotnet runtime references:
import DotnetWasm from 'unplugin-dotnet-wasm/bun';
await Bun.build({
// ...
plugins: [
DotnetWasm({
projectName: 'MyLibrary',
projectRoot: '../MyLibrary',
configuration: 'Debug',
targetFramework: 'net10.0',
isPublish: false,
}),
],
loader: {
'.wasm': 'file',
'.dat': 'file',
'.pdb': 'file',
},
});Node target: Bun's default target is the browser. For Node:
await Bun.build({
// ...
target: 'node',
});Farm parses unknown extensions as JavaScript by default and injects core-js polyfills. Declare the three binary asset types the dotnet runtime references, and set targetEnv to 'browser-esnext' (or 'node-next' on Node) to skip polyfill injection:
import { defineConfig } from '@farmfe/core';
import DotnetWasm from 'unplugin-dotnet-wasm/farm';
export default defineConfig({
// ...
plugins: [
DotnetWasm({
projectName: 'MyLibrary',
projectRoot: '../MyLibrary',
configuration: 'Debug',
targetFramework: 'net10.0',
isPublish: false,
}),
],
compilation: {
assets: {
include: ['wasm', 'dat', 'pdb'],
},
output: {
targetEnv: 'browser-esnext',
},
},
});Node target: keep the boot graph in a single chunk and force asset URLs (mode: 'browser'). Node's default asset mode emits filesystem paths, but the dotnet bootstrapper expects URLs (will still read from disk):
export default defineConfig({
// ...
compilation: {
assets: {
include: ['wasm', 'dat', 'pdb'],
mode: 'browser',
},
output: {
targetEnv: 'node-next',
},
partialBundling: {
enforceResources: [{ name: 'entry', test: ['.+'] }],
},
},
});Runtime usage
Once the plugin is configured, import .NET assets as regular ES modules. How you boot depends on your app type.
WebAssembly Browser App
Dotnet is imported from the _framework/dotnet module to create the runtime and start your app.
import { dotnet } from '_framework/dotnet';
const runtime = await dotnet.create();
runtime.runMain();Blazor WebAssembly App
Blazor boots through _framework/blazor.webassembly.js. Importing it assigns window.Blazor:
import '_framework/blazor.webassembly.js';
await window.Blazor.start(); // only if bundle loaded as module or with autostart=false[!IMPORTANT] Loading your bundle as
<script type="module">stops Blazor from autostarting. If your bundle is loaded as a classic script withoutautostart=falsethen Blazor will boot automatically.
Dev server
The plugin works with the bundler's dev server out of the box - start it as usual (vite, webpack serve, rspack serve, rsbuild dev, farm dev) and the .NET WASM app boots with no extra config. Assets are served with the exact Content-Type / Cache-Control / ETag the production runtime expects.
Watch mode
The plugin guarantees that the latest assets are served in watch modes (vite build --watch, webpack --watch, rsbuild -w etc). Supported bundlers are found in the support matrix.
Configuration
Pass either a project-discovery config or an explicit output dir config.
Project-discovery mode
Locates manifests under <projectRoot>/bin/<configuration>/<targetFramework>[/publish]/.
DotnetWasm({
projectName: 'MyLibrary', // * used to find manifest files
projectRoot: '../MyLibrary', // * path to the directory containing the .csproj
configuration: 'Debug', // * MSBuild configuration, e.g. 'Debug' or 'Release'
targetFramework: 'net10.0', // * target framework moniker, e.g. 'net10.0'
isPublish: false, // true = read from the dotnet publish output layout
logLevel: 'warn', // 'silent' | 'error' | 'warn' | 'info' | 'debug' (default: 'warn')
})All fields above are required except logLevel. configuration and isPublish typically pair as (Debug, false) for development and (Release, true) for production - set them to match your project's build pipeline.
Explicit output dir mode
Use dotnetOutputDir when the .NET output is at a non-default path like a custom publish directory or when dotnet's UseArtifactsOutput is enabled.
DotnetWasm({
projectName: 'MyLibrary', // * used to find manifest files
dotnetOutputDir: '../MyLibrary/my-out-dir', // * path to the .NET build/publish output dir
logLevel: 'warn', // 'silent' | 'error' | 'warn' | 'info' | 'debug' (default: 'warn')
})Bundler support
| Bundler | Browser | Node | Dev server | Watch mode | |---|---|---|---|---| | Vite | ✅ | ✅[^vite-node-env] | ✅ | ✅ | | Rollup | ✅ | ✅ | -[^rollup-family-no-dev-server] | ✅ | | Rolldown | ✅ | ✅ | -[^rollup-family-no-dev-server] | ✅ | | Webpack | ✅ | ✅[^webpack-node-esm] | ✅ | ✅ | | Rspack | ✅ | ✅[^rspack-node-esm] | ✅ | ✅ | | Rsbuild | ✅ | ✅[^rsbuild-node-esm] | ✅ | ✅ | | esbuild | ✅ | ✅ | -[^esbuild-no-dev-server] | ✅ | | Farm | ✅ | ✅[^farm-node-esm] | ✅ | ✅ | | Bun | ✅ | ✅ | -[^bun-no-dev-server] | -[^bun-no-watch] |
Status & roadmap
The plugin is build-time only today. Scope so far and what's planned:
Done
- Build-time integration for multple bundlers (table above)
- 9 on browser targets
- 9 on Node targets
- Both output layouts: scattered
dotnet buildand consolidateddotnet publish - Dev-server support for Vite, Webpack, Rspack, Rsbuild, and Farm (table above)
- Fingerprint-agnostic and multi-content-root asset resolution
- Binary asset emission (
.wasm,.dat,.pdb) through each bundler's native pipeline[^bundlers-wasm-binary-no-plugin-support] - Node built-ins externalized so the dotnet loader's Node paths don't break browser builds[^rollup-family-node-externals]
- IDE / language-server type support: editors and
tscare aware of the TypeScript emitted from your .NET WASM project like:- the SDK's own
dotnet.d.ts[^dotnet-dts-net11] - your own
.tsfiles underwwwroot - generated output like
typeshim.ts(TypeShim) - type-less
.jsassets (e.g. the SDK'sblazor.webassembly.jsboot module), compiled to a best-effort.d.tsso bare imports resolve without a hand-writtendeclare module[^js-shim-any] — reachable both with and without the.jsextension (e.g._framework/blazor.webassembly.jsand_framework/blazor.webassembly)[^shim-casing]
- the SDK's own
Planned
- Watch / HMR: re-read manifests and invalidate on
dotnet build/dotnet watchoutput changes - including live regeneration of the editor type shims so tsserver/tscstay in sync without a restart - Preload
<link>injection from the endpoints manifest's preload metadata - Support default exports in generated shim files for types of ts files from the .NET output, today only named imports (
import { dotnet }) are included (requires some .NET 11 SDK testing)
Design rationale for the decisions above lives in docs/architecture.md.
Requirements
- Node.js >= 24
- .NET SDK >= 10 (build output must exist before bundling)
- TypeScript >= 5 (optional - enables editor /
tsctype support for .NET WASM imports)
[^vite-node-env]: Node support requires a Vite server environment (consumer: 'server'), build.emitAssets: true, and builder.buildApp so the client bundle is skipped. See the Vite example above.
[^webpack-node-esm]: Node support requires ESM output (experiments.outputModule, output.module: true, target: 'node'). See the Webpack example above.
[^rspack-node-esm]: Node support requires ESM output (experiments.outputModule, output.module: true, output.publicPath: 'auto', target: 'node'). See the Rspack example above.
[^rsbuild-node-esm]: Node support requires ESM output - set output.target: 'node' and use tools.rspack to enable experiments.outputModule, output.module: true, and output.publicPath: 'auto'. See the Rsbuild example above.
[^farm-node-esm]: Node support requires a single-chunk Node build (output.targetEnv: 'node-next', compilation.assets.mode: 'browser', partialBundling.enforceResources). See the Farm example above.
[^bundlers-wasm-binary-no-plugin-support]: Bun and Farm can't be configured from within the plugin to emit .NET's binary assets (.wasm, .dat, .pdb); See the Bun and Farm examples above on how to configure it in the consuming project.
[^rollup-family-node-externals]: Rollup and Rolldown can't be configured from within the plugin to externalize Node built-ins; See the Rollup and Rolldown examples above on how to configure it in the consuming project.
[^rollup-family-no-dev-server]: Rollup and Rolldown have no standalone dev server; use Vite (same Rollup-family code path) for a dev-server workflow.
[^esbuild-no-dev-server]: esbuild's serve mode exposes no middleware API, so out-of-tree assets can't be served through the plugin.
[^bun-no-dev-server]: Bun's dev server (1.3+) is app-owned (Bun.serve) and exposes no plugin middleware hook, so the plugin can't serve out-of-tree assets through it. Middleware support is tracked upstream in oven-sh/bun#17608.
[^bun-no-watch]: Bun's build API has no watch mode, so the plugin can't re-run on source changes. Watch support is tracked upstream in oven-sh/bun#4689.
[^dotnet-dts-net11]: As of .NET 11, the MSBuild property WasmEmitTypeScriptDefinitions=true includes dotnet.d.ts in the build output.
[^js-shim-any]: A type-less .js asset is compiled through tsc --allowJs, so the types are whatever TypeScript can infer from the (often minified) source — frequently any. A no-export module (like blazor.webassembly.js) yields an empty declaration, which still satisfies side-effect imports (import '_framework/blazor.webassembly.js';), including under noUncheckedSideEffectImports.
[^shim-casing]: Generated specifiers are lower-cased (the plugin's asset lookup is case-insensitive), so a mixed-case asset such as _framework/Microsoft.DotNet.HotReload.WebAssembly.Browser.lib.module.js is only typed via its lower-cased specifier; import it in lower case, or add a manual declare module for the exact casing.
