Skip to content

Architecture

Edgar Mesquita edited this page Feb 8, 2026 · 10 revisions

eQuantic.UI - Architecture & Implementation Plan

Overview

eQuantic.UI is a self-contained UI framework for .NET that compiles C# into optimized JavaScript, eliminating dependencies on Node.js, npm, Vite, or any external frontend tool.

Core Principles

  1. 100% .NET - Zero external dependencies (Node.js, npm, etc)
  2. Self-Contained - ASP.NET Core serves and compiles everything
  3. Familiar - Routing via attributes (like Controllers)
  4. Modern - SPA experience with SSR when needed
  5. Performant - Intelligent compilation (static vs dynamic)

1. SDK Architecture

1.1 SDK Hierarchy

<!-- eQuantic.UI.Sdk inherits Microsoft.NET.Sdk.Web -->
<Project Sdk="eQuantic.UI.Sdk/1.0.0">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
  </PropertyGroup>
</Project>

Under the hood:

<!-- eQuantic.UI.Sdk/Sdk/Sdk.props -->
<Project>
  <!-- Inherits full Web SDK -->
  <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />

  <!-- Adds UI compilation -->
  <PropertyGroup>
    <EnableEQuanticUICompilation>true</EnableEQuanticUICompilation>
    <EQuanticOutputPath>wwwroot/_equantic/</EQuanticOutputPath>
  </PropertyGroup>
</Project>

1.2 Build Pipeline Integration

dotnet build
    ↓
MSBuild Standard Pipeline (Microsoft.NET.Sdk.Web)
    ↓
Custom Target: CompileEQuanticUI (BeforeTargets="Build")
    ↓
    1. Roslyn parse /Pages/**/*.cs
    2. Detect StatefulWidget/StatelessWidget classes
    3. Generate TypeScript intermediate (.ts files)
       ├─ Type-safe
       ├─ Preserves semantics
       └─ Human-readable (debugging)
    4. Invoke embedded Bun
       ├─ bun build *.ts --outdir wwwroot/_equantic
       ├─ Automatic Tree-shaking
       ├─ Minification
       ├─ Source maps
       └─ Code splitting
    5. Generate manifest.json
    ↓
Continue standard build
    ↓
Output: bin/ + wwwroot/_equantic/

Why TypeScript Intermediate?

C# (source) → TypeScript (intermediate) → JavaScript (output)
     ↓                  ↓                        ↓
  Developer        Type Safety              Runtime
  writes C#      + Debug-friendly          Optimized

Benefits:

  • ✅ Two-layer type checking (C# + TS)
  • ✅ Source maps from C# → TS → JS (full debugging)
  • ✅ Leverage Bun's optimization engine
  • ✅ Future: could support direct TS authoring too

Bun Performance:

# Traditional Node.js build
$ npm run build
⏱️  15.3s

# Bun embedded build
$ dotnet build
⏱️  1.8s  ✅ (8.5x faster)

2. Compilation Strategy: Static vs Dynamic

2.1 Problem: Single Bundle vs Code Splitting

Challenge: We don't want a giant bundle.js, but we also don't want hundreds of small files.

Solution: Hybrid Compilation Strategy

A. Static Shell (Compiled at Build-Time)

What compiles statically:

  • Component structure (Widget tree)
  • Layout/UI structure
  • Styles
  • Initial state
  • Routing metadata

Output: {ComponentName}.static.js

// Counter.static.js (generated at build)
export const CounterStatic = {
  name: "Counter",
  route: "/counter",

  // Template structure (no runtime needed)
  template: {
    type: "Container",
    props: { className: "counter" },
    children: [
      { type: "Heading", props: { text: "Counter" } },
      { type: "TextInput", props: { id: "msg", placeholder: "..." } },
      {
        type: "Row",
        props: { gap: "8px" },
        children: [
          { type: "Button", props: { id: "dec", text: "-" } },
          { type: "Text", props: { id: "count", text: "0" } },
          { type: "Button", props: { id: "inc", text: "+" } },
        ],
      },
    ],
  },

  // Style (CSS-in-JS compiled)
  styles: `
    .counter { padding: 20px; }
    .count-display { font-size: 24px; font-weight: bold; }
  `,
};

B. Dynamic Logic (Compiled at Build-Time, runs Client-Side)

What compiles as dynamic logic:

  • Event handlers
  • State mutations
  • Computed properties
  • Lifecycle hooks

Output: {ComponentName}.logic.js

// Counter.logic.js (generated at build)
export class CounterLogic {
  constructor(component) {
    this._component = component;
    this._count = 0;
    this._message = "";
  }

  // Compiled handlers
  _increment() {
    this._count++;
    this._component.update({ count: this._count });
  }

  _decrement() {
    this._count--;
    this._component.update({ count: this._count });
  }

  _onMessageChange(value) {
    this._message = value;
    // No update needed if doesn't reflect in UI
  }
}

C. Server Actions (Run Server-Side)

What does NOT compile to JS:

  • Database queries
  • Complex business logic
  • Internal API calls
  • Authentication/Authorization

Solution: Server Actions Pattern

// Pages/TodoList.cs
[Page("/todos")]
public class TodoList : StatefulWidget
{
    // Server Action - runs on server
    [ServerAction]
    public async Task<List<Todo>> LoadTodos()
    {
        // Runs on server
        using var db = new AppDbContext();
        return await db.Todos.ToListAsync();
    }

    [ServerAction]
    public async Task<Todo> AddTodo(string title)
    {
        using var db = new AppDbContext();
        var todo = new Todo { Title = title };
        db.Todos.Add(todo);
        await db.SaveChangesAsync();
        return todo;
    }
}

public class TodoListState : State<TodoList>
{
    private List<Todo> _todos = [];

    protected override async Task OnMountedAsync()
    {
        // Calls server action
        _todos = await Widget.LoadTodos();
    }

    private async Task HandleAdd(string title)
    {
        var newTodo = await Widget.AddTodo(title);
        SetState(() => _todos.Add(newTodo));
    }

    public override Widget Build(BuildContext context)
    {
        return Column(
            children: _todos.Select(t =>
                TodoItem(todo: t)
            ).ToList()
        );
    }
}

Compilation:

// TodoList.logic.js
export class TodoListLogic {
  async onMounted() {
    // Generates call to server action
    this._todos = await this._serverActions.invoke("LoadTodos", []);
  }

  async handleAdd(title) {
    const newTodo = await this._serverActions.invoke("AddTodo", [title]);
    this._todos.push(newTodo);
    this._component.update({ todos: this._todos });
  }
}

2.2 Bundle Strategy

Goal: Optimize loading without exploding request count.

Level 1: Core Runtime (loads on all pages)

/_equantic/runtime.js (~15kb gzipped)
  - Minimal Virtual DOM
  - Event system
  - State management
  - Server actions bridge

Level 2: Component Library (lazy load per route)

/_equantic/widgets.js (~30kb gzipped)
  - Button, TextBox, Container, etc
  - Widgets used by multiple pages

Level 3: Page Bundles (lazy load per route)

/_equantic/pages/Counter.js
  - Counter.static.js (structure)
  - Counter.logic.js (behavior)
  - Counter-specific widgets

Level 4: External Assets (CDNs/Shared Scripts)

https://cdn.example.com/library.js
  - Declared via IRequireAssets
  - Deduplicated by AssetCollection
  - Injected into <head>

Level 4: Shared Chunks (automatic code splitting)

/_equantic/chunks/
  - auth.chunk.js (if multiple pages use auth)
  - api.chunk.js (shared API logic)

Loading Example:

<!-- Request: GET /counter -->
<script src="/_equantic/runtime.js"></script>
<script src="/_equantic/widgets.js"></script>
<script src="/_equantic/pages/Counter.js"></script>

<!-- SPA Navigation: /counter → /todos -->
<!-- Only loads: -->
<script src="/_equantic/pages/TodoList.js"></script>

3. Server Actions: Client ↔ Server Communication

3.1 Problem: Avoiding Manual Endpoints

Anti-pattern (we want to avoid):

// Backend
[ApiController]
public class TodoController : ControllerBase
{
    [HttpPost("/api/todos")]
    public Task<Todo> AddTodo([FromBody] AddTodoRequest req) { }
}

// Frontend (JS)
async function addTodo(title) {
    const response = await fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify({ title })
    });
    return await response.json();
}

We Want (type-safe, zero boilerplate):

[Page("/todos")]
public class TodoList : StatefulWidget
{
    [ServerAction]
    public async Task<Todo> AddTodo(string title)
    {
        // Backend logic here
    }
}

// In frontend:
private async Task HandleAdd()
{
    var todo = await Widget.AddTodo("New item");
    // ↑ Type-safe, auto-serialization
}

3.2 Implementation: Server Actions Bridge

A. Compilation Time

Compiler detects methods with [ServerAction]:

// Counter.cs
public class Counter : StatefulWidget
{
    [ServerAction]
    public async Task<int> IncrementOnServer(int current)
    {
        // Simulate server-side logic
        await Task.Delay(100);
        return current + 1;
    }
}

Generates:

// Counter.logic.js
export class CounterLogic {
  async incrementOnServer(current) {
    return await this._serverActions.invoke(
      "Counter/IncrementOnServer", // Action ID
      [current], // Arguments
    );
  }
}

B. Runtime: Server Actions Endpoint

Automatic middleware exposing /api/_equantic/actions:

// eQuantic.UI.Server/ServerActionsMiddleware.cs
public class ServerActionsMiddleware
{
    private readonly IServerActionRegistry _registry;

    public async Task InvokeAsync(HttpContext context)
    {
        if (context.Request.Path == "/api/_equantic/actions")
        {
            var request = await JsonSerializer
                .DeserializeAsync<ServerActionRequest>(context.Request.Body);

            // request.ActionId = "Counter/IncrementOnServer"
            // request.Arguments = [5]

            var action = _registry.GetAction(request.ActionId);

            // Invoke method via reflection (or compiled expression)
            var result = await action.InvokeAsync(request.Arguments);

            await context.Response.WriteAsJsonAsync(new {
                success = true,
                result = result
            });

            return;
        }

        await _next(context);
    }
}

C. Client Runtime Bridge

// runtime.js - Server Actions Bridge
class ServerActionsClient {
  async invoke(actionId, args) {
    const response = await fetch("/api/_equantic/actions", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ actionId, arguments: args }),
    });

    const data = await response.json();

    if (!data.success) {
      throw new Error(data.error);
    }

    return data.result;
  }
}

3.3 Advanced: SignalR for Real-Time

For cases needing server push:

[Page("/chat")]
public class ChatPage : StatefulWidget
{
    [ServerAction]
    public async Task SendMessage(string message)
    {
        // Broadcast to all connected
        await Clients.All.SendAsync("ReceiveMessage", message);
    }

    [ServerEvent("ReceiveMessage")] // Subscribe to SignalR event
    public void OnMessageReceived(string message)
    {
        // Updates UI automatically
        SetState(() => _messages.Add(message));
    }
}

3.4 Asset Management (IRequireAssets)

Components can declare their own external dependencies (scripts, stylesheets) without manual injection in the HTML shell.

public class CodeBlock : StatelessComponent, IRequireAssets
{
    public void ConfigureAssets(AssetBuilder assets)
    {
        // External assets (Deduplicated automatically)
        assets.AddStylesheet("https://cdn.example.com/prism.css");
        assets.AddScript("https://cdn.example.com/prism.js");

        // Inline logic
        assets.AddInlineScript("function init(){ ... }");
    }
}

Framework Flow:

  1. ServerRenderingService collects all IRequireAssets during component tree traversal.
  2. AssetCollection deduplicates entries by URL/Content.
  3. UIExtensions injects the resulting tags into the of the page.

4. Diff vs ASP.NET WebForms

4.1 Learnings from WebForms

What WebForms did well:

  • ✅ Event model (onClick, onChange)
  • ✅ Automatic ViewState
  • ✅ Server controls with state
  • ✅ Postback for server logic

What WebForms did poorly:

  • ❌ Giant ViewState (increases payload)
  • ❌ Full-page postback (not SPA)
  • ❌ HTML generated server-side (slow)
  • ❌ Limited/Difficult JavaScript

4.2 How eQuantic.UI Improves This

Aspect WebForms eQuantic.UI
State Management ViewState (hidden field) Client-side state + Server Actions
Rendering Server-side HTML generation Client-side rendering (Virtual DOM)
Updates Full postback Partial updates (SPA)
JS Integration UpdatePanel/ScriptManager Native JavaScript compilation
Event Handling Server postback Client-side + Server Actions selective
Performance Every click = server roundtrip Client-side logic, server when needed
Bundle Size N/A (server-rendered) Minimal (~15kb runtime)

4.3 Best of Both Worlds

WebForms-like DX:

// Familiar for WebForms devs
public class Counter : StatefulWidget
{
    private int _count = 0;

    private void OnButtonClick() // ← Like WebForms!
    {
        _count++;
        // But runs client-side, no postback!
    }
}

Modern SPA Performance:

// Compiled to optimized JS
// Runs in browser, no postback
// Only calls server when really needed

5. Routing & Page System

5.1 Routing via Attributes

// Pages/Counter.cs
[Page("/counter")]
[Page("/count")] // Multiple routes
public class Counter : StatefulWidget { }

// Pages/UserProfile.cs
[Page("/user/{id:int}")] // Route parameters
public class UserProfile : StatefulWidget
{
    [Parameter]
    public int Id { get; set; } // Auto-binding
}

// Pages/Admin/Dashboard.cs
[Page("/admin/dashboard")]
[Authorize(Roles = "Admin")] // Authorization
public class AdminDashboard : StatefulWidget { }

5.2 Program.cs Registration

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEQuanticUI(options => {
    options.ScanAssembly(typeof(Program).Assembly);
});

var app = builder.Build();

// Auto-discovery via attributes
app.MapEQuanticPages();

// Or manual:
app.MapEQuanticPages(routes => {
    routes.MapPage<Counter>("/counter");
    routes.MapPage<Home>("/");
});

app.Run();

5.3 Navigation (Client-Side)

public class MyComponent : StatefulWidget
{
    private void NavigateToProfile()
    {
        // Client-side navigation (SPA)
        Navigator.Push("/user/123");

        // Or with object
        Navigator.Push<UserProfile>(new { Id = 123 });
    }
}

Compiled to:

// Client-side router (no reload)
window.eQuantic.router.push("/user/123");

6. Developer Experience

6.1 Project Structure

MyApp/
├── MyApp.csproj                    # eQuantic.UI.Sdk
├── Program.cs                      # ASP.NET Core host
│
├── Pages/                          # Page components
│   ├── Home.cs                     # [Page("/")]
│   ├── Counter.cs                  # [Page("/counter")]
│   └── Admin/
│       └── Dashboard.cs            # [Page("/admin/dashboard")]
│
├── Components/                     # Reusable UI components
│   ├── Button.cs
│   ├── Card.cs
│   └── DataGrid.cs
│
├── Services/                       # Backend services (DI)
│   ├── UserService.cs
│   └── ApiClient.cs
│
├── Models/                         # Shared models
│   └── User.cs
│
└── wwwroot/                        # Static assets
    ├── _equantic/                  # Generated (build output)
    │   ├── runtime.js
    │   ├── widgets.js
    │   └── pages/
    │       ├── Counter.js
    │       ├── Home.js
    └── css/
        └── site.css

6.2 CLI Commands

# Install template
dotnet new install eQuantic.UI.Templates

# Create new app
dotnet new equantic-app -n MyApp
cd MyApp

# Create new page
dotnet new equantic-page -n UserProfile -o Pages

# Create component
dotnet new equantic-component -n DataGrid -o Components

# Development
dotnet watch run
# → Hot reload on .cs changes
# → Auto-recompile to JS
# → Browser auto-refresh

# Build
dotnet build
# → Compiles C# to JS
# → Optimizes bundles
# → Generates manifest

# Publish
dotnet publish -c Release
# → Minified JS
# → Tree-shaking
# → Ready for production

6.3 Hot Reload Flow

1. Developer edits Counter.cs
   ↓
2. dotnet watch detects change
   ↓
3. MSBuild task recompiles Counter.cs → Counter.js
   ↓
4. File watcher notifies browser (WebSocket)
   ↓
5. Browser fetches updated Counter.js
   ↓
6. Hot Module Replacement
   ↓
7. UI updates without losing state

7. Implementation Phases

Phase 1: Core Foundation (Weeks 1-4)

Week 1-2: Compiler Core

  • Roslyn-based C# parser
  • AST → JavaScript code generator
  • Basic Widget compilation (Container, Text, Button)
  • MSBuild task integration

Week 3-4: Runtime & Server

  • JavaScript runtime (Virtual DOM minimal)
  • State management
  • ASP.NET Core middleware
  • Page routing system
  • HTML generation

Deliverable: Counter app working end-to-end

Phase 2: Advanced Features (Weeks 5-8)

Week 5-6: Server Actions

  • [ServerAction] attribute detection
  • Server Actions middleware
  • Client-server bridge
  • Type-safe serialization

Week 7-8: Code Splitting & Optimization

  • Bundle strategy implementation
  • Lazy loading
  • Tree-shaking
  • Minification

Deliverable: TodoList app with server actions

Phase 3: Developer Experience (Weeks 9-12)

Week 9-10: Tooling

  • dotnet new templates
  • CLI commands
  • Hot reload
  • Error diagnostics

Week 11-12: Documentation & Samples

  • Getting started guide
  • API reference
  • Sample applications
  • Migration guide (Blazor/WebForms)

Deliverable: Public beta ready


8. Technical Decisions

8.1 Embedded Bun for TypeScript Compilation

Decision: Use Bun as Embedded Build Tool

Why Bun:

  • Single executable - distributes with SDK
  • Ultra fast - 10-100x faster than Node.js
  • Native TypeScript - compiles TS without config
  • Embedded Bundler - no need for Webpack/Vite
  • Self-contained - no npm install needed
  • Small footprint - ~90MB (vs Node.js ~200MB)

Architecture:

eQuantic.UI.Sdk/
├── tools/
│   ├── bun.exe (Windows)
│   ├── bun (Linux)
│   ├── bun (macOS)
│   └── eqc-compiler.ts    # TypeScript compiler wrapper
└── build/
    └── eQuantic.UI.Build.targets

Build Pipeline with Bun:

dotnet build
    ↓
MSBuild Task: CompileEQuanticUI
    ↓
1. C# Roslyn Parser
   - Parse Pages/**/*.cs

Clone this wiki locally