Table of Contents

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.jsUtilsModule, mw-dnd.jsMwDndModule.

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

  1. .d.ts — pure typed declarations, parsed by the typed parser.
  2. .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.
  3. .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 with int, long, Guid, etc. and the C# side gets int, long, System.Guid directly — no casts at the storage site. The aliases live in browserapi.d.ts, copied into your project's obj/browserapi-types/ at build time. See the width aliases section of the support matrix for the full table and tsconfig wiring.
  • DotNetObjectReference is also an ambient declaration. The same browserapi.d.ts provides a richly-typed DotNetObjectReference interface with invokeMethodAsync<TResult> and dispose(). No more per-module stub declarations. The C# generator behavior is unchanged — top-level DotNetObjectReference parameters still promote to generic over TDotNetRef.
  • Interfaces (exported or not) become sealed C# records with [JsonPropertyName]. A stub interface DotNetObjectReference {} is recognized as a Blazor primitive and skipped — no colliding class is emitted.
  • String literal unions ('a' | 'b') become C# enums with proper JsonStringEnumMemberName serialization.
  • 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.