JS Module Source Generator
BrowserApi.SourceGen is a Roslyn source generator that reads your JavaScript or TypeScript modules at build time and emits typed C# wrapper classes. You get full IntelliSense, compile-time parameter checking, and XML docs — with zero runtime overhead.
How It Works
Build time:
your.ts / your.d.ts / your.js → SourceGen → YourModule.g.cs (typed C# class)
Runtime:
YourModule.MethodAsync() → IJSRuntime.InvokeAsync() → your.js
The generated code is identical to what you'd write by hand — thin InvokeAsync wrappers around IJSObjectReference. No reflection, no runtime parsing.
Quick Start (Simple Setup)
For projects without a bundler (no Vite, no Webpack):
1. Add the package
dotnet add package BrowserApi.SourceGen
2. Register your JS files as AdditionalFiles
<!-- In your .csproj -->
<ItemGroup>
<AdditionalFiles Include="wwwroot/js/**/*.js" />
</ItemGroup>
3. Register in DI
// Program.cs
builder.Services.AddJsModules(); // auto-generated, registers all discovered modules
4. Use
// Your JS file: wwwroot/js/utils.js
// export function formatCurrency(amount, currency) { ... }
// In a component — "UtilsModule" was auto-generated from "utils.js"
@inject UtilsModule Utils
var price = await Utils.FormatCurrencyAsync(42.99, "USD");
That's it. The class name is derived from the filename: utils.js → UtilsModule, mw-dnd.js → MwDndModule.
TypeScript Support (.d.ts)
For much better type safety, use TypeScript declaration files. The generator parses them and produces:
| TypeScript | C# |
|---|---|
interface DragConfig { ... } |
sealed class DragConfig with [JsonPropertyName] |
'clone' \| 'template' \| 'none' |
enum with [JsonStringEnumConverter] |
Record<string, T> |
Dictionary<string, T> |
items: string[] |
string[] Items |
handle?: string |
string? Handle |
Promise<string> |
unwrapped to string (method returns Task<string>) |
id: int / id: Guid |
int Id / System.Guid Id (width aliases) |
Example
TypeScript declaration:
// wwwroot/js/src/mw-dnd.d.ts
/** Configuration for creating a drag-and-drop context. */
export interface DragConfig {
/** CSS selector for the container region. */
container: string;
/** CSS selector for drag source elements. */
sources: string;
/** Optional drag-handle selector within each source. */
handle?: string;
/** Pointer-move threshold in pixels before a drag starts. */
threshold?: number;
/** Selectors to watch for drag-over events. */
watch: string[];
/** Ghost element configuration. */
ghost?: GhostConfig;
}
/** Appearance and behavior of the drag ghost. */
export interface GhostConfig {
/** clone: deep-clone source | template: reuse hidden template | label: text label | moveSource: move the source element | none: no ghost */
mode: 'clone' | 'template' | 'label' | 'moveSource' | 'none';
/** CSS class applied to the source element during drag. */
sourceClass?: string;
/** Horizontal offset from the cursor in pixels. */
offsetX?: number;
/** Vertical offset from the cursor in pixels. */
offsetY?: number;
}
/** Create a new drag-and-drop context. */
export function createDrag(dotNetRef: DotNetObjectReference, config: DragConfig): number;
export function destroyDrag(contextId: number): void;
export function dispose(): void;
export function addClassToMatching(selector: string, className: string): void;
Generated C#:
// DragConfig.g.cs
// <auto-generated>
// Generated by BrowserApi.SourceGen from: wwwroot/js/src/mw-dnd.d.ts
// Do not edit this file directly. Edit the TypeScript source and rebuild.
// </auto-generated>
/// <summary>Configuration for creating a drag-and-drop context.</summary>
/// <remarks>Generated from the TypeScript interface <c>DragConfig</c>.</remarks>
public sealed class DragConfig {
/// <summary>CSS selector for the container region.</summary>
[JsonPropertyName("container")]
public required string Container { get; init; }
/// <summary>CSS selector for drag source elements.</summary>
[JsonPropertyName("sources")]
public required string Sources { get; init; }
/// <summary>Optional drag-handle selector within each source.</summary>
[JsonPropertyName("handle")]
public string? Handle { get; init; }
/// <summary>Pointer-move threshold in pixels before a drag starts.</summary>
[JsonPropertyName("threshold")]
public double? Threshold { get; init; }
/// <summary>Selectors to watch for drag-over events.</summary>
[JsonPropertyName("watch")]
public required string[] Watch { get; init; }
/// <summary>Ghost element configuration.</summary>
[JsonPropertyName("ghost")]
public GhostConfig? Ghost { get; init; }
}
// GhostConfigMode.g.cs
/// <summary>Generated from a TypeScript string-literal union.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<GhostConfigMode>))]
public enum GhostConfigMode {
/// <summary>Serializes to the TypeScript literal <c>"clone"</c>.</summary>
[JsonStringEnumMemberName("clone")]
Clone,
/// <summary>Serializes to the TypeScript literal <c>"template"</c>.</summary>
[JsonStringEnumMemberName("template")]
Template,
/// <summary>Serializes to the TypeScript literal <c>"label"</c>.</summary>
[JsonStringEnumMemberName("label")]
Label,
/// <summary>Serializes to the TypeScript literal <c>"moveSource"</c>.</summary>
[JsonStringEnumMemberName("moveSource")]
MoveSource,
/// <summary>Serializes to the TypeScript literal <c>"none"</c>.</summary>
[JsonStringEnumMemberName("none")]
None
}
// MwDndModule.g.cs
public partial class MwDndModule : IAsyncDisposable {
public MwDndModule(IJSRuntime js, IJsModulePathResolver? pathResolver = null);
/// <summary>Create a new drag-and-drop context.</summary>
public async Task<double> CreateDragAsync<TDotNetRef>(
DotNetObjectReference<TDotNetRef> dotNetRef,
DragConfig config) where TDotNetRef : class { ... }
public async Task DestroyDragAsync(double contextId) { ... }
public async Task DisposeModuleAsync() { ... }
public async Task AddClassToMatchingAsync(string selector, string className) { ... }
public async ValueTask DisposeAsync() { ... }
}
Hover config.Container in any component that uses DragConfig and IntelliSense shows "CSS selector for the container region." — the exact text from the TypeScript. One place to write documentation; three places it shows up (TS editor, C# IntelliSense, docfx site).
Setup — .ts or .d.ts or both
<!-- Feed your TypeScript source directly: no tsc step needed. -->
<ItemGroup>
<AdditionalFiles Include="wwwroot/js/src/*.ts" />
</ItemGroup>
Or if you prefer, hand-author .d.ts files (for example when wrapping a third-party .js library with no .ts source of your own):
<ItemGroup>
<AdditionalFiles Include="wwwroot/js/src/*.d.ts" />
</ItemGroup>
The generator handles all three file types with a typed pipeline for .ts and .d.ts, and a JSDoc-only fallback for .js. Priority per module (matched by filename stem):
.d.ts— pure typed declarations, parsed by the typed parser..ts— typed source with implementations, also parsed by the typed parser (function bodies are skipped). Recommended — no tsc step, JSDoc sits inline with your implementation..js— untyped fallback, parsed via JSDoc hints only.
If you have both mw-dnd.ts and mw-dnd.d.ts for the same module, the .d.ts wins — it's the canonical post-tsc type surface, and is typically the version your Vite/tsc build has just regenerated. If you want the .ts-first workflow, simply don't commit or ship a .d.ts for that module.
Both can coexist across modules: one module's interop can live in a .ts, another's in a .d.ts. You can migrate one file at a time.
Custom Class Names ([JsModule] Attribute)
By default, the class name comes from the filename. To choose your own:
[JsModule("./js/src/mw-dnd.js")]
public partial class DragDropService;
Now the generated class is DragDropService instead of MwDndModule. The attribute is optional — most projects don't need it.
Path Resolver (Vite / Bundler Integration)
The Problem
Build tools like Vite produce content-hashed filenames for cache busting:
wwwroot/js/src/mw-dnd.js → /js/dist/mw-dnd.a1b2c3d4.mjs
The generated code needs to import() the hashed path, not the source path.
The Solution: IJsModulePathResolver
The generator emits an IJsModulePathResolver interface. Implement it to hook into your build tool's manifest:
// Implement the interface (wraps your existing path service)
public class VitePathResolver : IJsModulePathResolver {
private readonly JSInteropPathService _pathService;
public VitePathResolver(JSInteropPathService pathService)
=> _pathService = pathService;
public string Resolve(string moduleName)
=> _pathService.GetScriptPath(moduleName);
}
Register it in DI:
// Program.cs
builder.Services.AddSingleton<IJsModulePathResolver, VitePathResolver>();
builder.Services.AddJsModules();
Now every generated module class automatically resolves "mw-dnd" → "/js/dist/mw-dnd.a1b2c3d4.mjs" via your Vite manifest. No per-module configuration needed.
Without a Path Resolver
If you don't register an IJsModulePathResolver, the generated code uses the raw file path from the AdditionalFiles entry. This works fine for development or projects without a bundler.
How It Works Internally
The generated constructor accepts the resolver as an optional parameter:
public MwDndModule(IJSRuntime js, IJsModulePathResolver? pathResolver = null) {
_js = js;
_modulePath = pathResolver?.Resolve("mw-dnd") ?? "./js/src/mw-dnd.js";
}
DI injects it if registered, otherwise the fallback path is used.
Module Loading
All generated modules use lazy loading via ES import():
private async Task<IJSObjectReference> GetModuleAsync() {
return _module ??= await _js.InvokeAsync<IJSObjectReference>("import", _modulePath);
}
The module JavaScript is fetched only when the first method is called, not at startup. This is the recommended pattern for Blazor — heavy JS modules don't block the initial page load.
Enum Serialization
String literal unions in TypeScript become C# enums with proper JSON serialization:
mode: 'clone' | 'template' | 'none'
[JsonConverter(typeof(JsonStringEnumConverter<GhostConfigMode>))]
public enum GhostConfigMode {
[JsonStringEnumMemberName("clone")]
Clone,
[JsonStringEnumMemberName("template")]
Template,
[JsonStringEnumMemberName("none")]
None
}
GhostConfigMode.Clone serializes to "clone" in JSON — matching what the JavaScript expects. No custom converters or naming policies needed.
JSDoc Support
For plain .js files (no .d.ts), the generator reads JSDoc comments:
/**
* Formats a number as currency.
* @param {number} amount - The amount to format.
* @param {string} currency - ISO 4217 currency code.
* @returns {string} The formatted string.
*/
export function formatCurrency(amount, currency) { ... }
This produces typed parameters (double amount, string currency) and XML doc comments. The type mapping:
| JSDoc | C# |
|---|---|
{number} |
double |
{string} |
string |
{boolean} |
bool |
{void} |
Task (no return) |
{Promise<T>} |
unwrapped to T |
{Array<T>} or {T[]} |
T[] |
{any} / {object} |
object |
| (missing) | object (fallback) |
Comparison: Simple vs Production Setup
| Simple | Production (Vite + TypeScript) | |
|---|---|---|
| csproj | <AdditionalFiles Include="**/*.js" /> |
<AdditionalFiles Include="**/*.d.ts" /> |
| Program.cs | AddJsModules() |
AddSingleton<IJsModulePathResolver, ViteResolver>() + AddJsModules() |
| Type safety | Basic (JSDoc types, unknown → object) |
Full (TS interfaces → records, unions → enums) |
| Import paths | Raw file paths | Hashed via manifest |
| Cache busting | No | Yes |
| Config | 2 lines | ~15 lines (one-time) |
| Runtime cost | Same | Same |
Limitations and support matrix
The generator is built for hand-written .d.ts / .js modules in your own wwwroot/js/ folder — the shape Blazor interop actually uses. It covers most common TypeScript constructs and emits a BAPI002 warning whenever it meets a type it can't map, so silent degradation to object never goes unnoticed.
The full support matrix — every pattern, what it maps to, and whether BAPI002 fires — is its own page: source-generator support matrix. A few headline items below; see the matrix for the rest.
- Numeric width aliases (
int,long,float,Guid, …) ship as ambient TypeScript declarations. Annotate parameters and properties withint,long,Guid, etc. and the C# side getsint,long,System.Guiddirectly — no casts at the storage site. The aliases live inbrowserapi.d.ts, copied into your project'sobj/browserapi-types/at build time. See the width aliases section of the support matrix for the full table and tsconfig wiring. DotNetObjectReferenceis also an ambient declaration. The samebrowserapi.d.tsprovides a richly-typedDotNetObjectReferenceinterface withinvokeMethodAsync<TResult>anddispose(). No more per-module stub declarations. The C# generator behavior is unchanged — top-levelDotNetObjectReferenceparameters still promote to generic overTDotNetRef.- Interfaces (exported or not) become sealed C# records with
[JsonPropertyName]. A stubinterface DotNetObjectReference {}is recognized as a Blazor primitive and skipped — no colliding class is emitted. - String literal unions (
'a' | 'b') become C# enums with properJsonStringEnumMemberNameserialization. - Not recognized:
export default, const-bound arrow exports, class methods, complex generics (conditional / mapped / intersection types), cross-file type references. Most can be worked around with a tiny named re-export or a local interface redeclaration.