eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArchitecture
eQuantic.UI - Architecture
Edit this page
4 min read
🌐 This page in: English · Português
[!NOTE] This page describes the web pipeline. The framework also targets native (macOS/iOS/Android) through the same component sources. See Write-Once Components for the shared architecture and Photon for the GPU engine.
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.
static shell
dynamic logic
C# components
eqc
SSR HTML
TypeScript
Embedded Bun
wwwroot/_equantic
Browser
The diagram above is a mermaid ` fence: GitHub draws it here, and the documentation site draws the SAME fence through the SDK's own Mermaid component.
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
1
2
3
4
5
6
<!-- 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:
1
2
3
4
5
6
7
8
9
10
11
<!-- 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
dotnet build
MSBuild Standard Pipeline (Microsoft.NET.Sdk.Web)
Custom Target: CompileEQuanticUI (BeforeTargets="Build")
1. Roslyn parse /Pages/**/*.cs
2. Detect StatefulComponent/StatelessComponent/ComponentState 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?
1
2
3
4
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:
1
2
3
4
5
6
7
# 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 (Component tree)
Layout/UI structure
Styles
Initial state
Routing metadata
Output: {ComponentName}.static.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// Pages/TodoList.cs
[Page("/todos")]
public class TodoList : StatefulComponent
{
// 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 : ComponentState<TodoList>
{
private List<Todo> _todos = [];
protected override void OnMount()
{
// Calls server action
_ = LoadInitialData();
}
private async Task LoadInitialData()
{
_todos = await Component.LoadTodos();
SetState(() => { });
}
private async Task HandleAdd(string title)
{
var newTodo = await Component.AddTodo(title);
SetState(() => _todos.Add(newTodo));
}
public override IComponent Build(RenderContext context)
{
return new Column {
Children = _todos.Select(t =>
(IComponent)new TodoItem { Todo = t }
).ToList()
};
}
}
Compilation:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 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)
1
2
3
4
5
/_equantic/runtime.js (~15kb gzipped)
- Minimal Virtual DOM
- Event system
- State management
- Server actions bridge
Level 2: Component Library (lazy load per route)
1
2
3
/_equantic/components.js (~30kb gzipped)
- Button, TextBox, Container, etc
- Components used by multiple pages
Level 3: Page Bundles (lazy load per route)
1
2
3
4
/_equantic/pages/Counter.js
- Counter.static.js (structure)
- Counter.logic.js (behavior)
- Counter-specific widgets
Level 4: External Assets (CDNs/Shared Scripts)
1
2
3
4
https://cdn.example.com/library.js
- Declared via IRequireAssets
- Deduplicated by AssetCollection
- Injected into <head>
Level 4: Shared Chunks (automatic code splitting)
1
2
3
/_equantic/chunks/
- auth.chunk.js (if multiple pages use auth)
- api.chunk.js (shared API logic)
Loading Example:
1
2
3
4
5
6
7
8
<!-- Request: GET /counter -->
<script src="/_equantic/runtime.js"></script>
<script src="/_equantic/components.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):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Page("/todos")]
public class TodoList : StatefulComponent
{
[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]:
1
2
3
4
5
6
7
8
9
10
11
// Counter.cs
public class Counter : StatefulComponent
{
[ServerAction]
public async Task<int> IncrementOnServer(int current)
{
// Simulate server-side logic
await Task.Delay(100);
return current + 1;
}
}
Generates:
1
2
3
4
5
6
7
8
9
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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 Real-Time Push: Scope
The current release does not include a [ServerEvent] subscription model: Server Actions are request/response. SignalR services are registered and used internally by the framework; real-time server→client push is on the Roadmap.
3.4 Asset Management
Components declare their own external dependencies (scripts, stylesheets) without manual injection in the HTML shell. Two patterns are supported:
Pattern 1: IRequireAssets, where a component declares its own assets:
1
2
3
4
5
6
7
8
9
public class CodeBlock : StatelessComponent, IRequireAssets
{
public void ConfigureAssets(AssetBuilder assets)
{
assets.AddStylesheet("https://cdn.example.com/prism.css", id: "prism-theme");
assets.AddScript("https://cdn.example.com/prism.js");
assets.AddInlineScript("function init(){ ... }");
}
}
Pattern 2: IComponentAssetProvider<T>, an external provider for third-party components:
1
2
3
4
5
6
7
public class ChartJsAssetProvider : IComponentAssetProvider<ChartCanvas>
{
public void ConfigureAssets(AssetBuilder assets)
{
assets.AddScript("https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js");
}
}
Providers are auto-registered during AddUI() via assembly scanning, or explicitly via WithAssetProvider<T>().
Framework Flow:
1.
ServerRenderingService walks the component tree, collecting IRequireAssets and IComponentAssetProvider<T> assets.
2.
AssetCollection deduplicates entries by Key (CSS first, then JS).
3.
UIExtensions injects the resulting tags into the <head> of the page.
4.
Pages without asset-requiring components get zero extra tags.
See Asset Management for full documentation.
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:
1
2
3
4
5
6
7
8
9
10
11
// Familiar for WebForms devs
public class Counter : StatefulComponent
{
private int _count = 0;
private void OnButtonClick() // ← Like WebForms!
{
_count++;
// But runs client-side, no postback!
}
}
Modern SPA Performance:
1
2
3
// Compiled to optimized JS
// Runs in browser, no postback
// Only calls server when really needed
5. Routing & Page System
5.1 Routing via Attributes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Pages/Counter.cs
[Page("/counter")]
[Page("/count")] // Multiple routes
public class Counter : StatefulComponent { }
// Pages/UserProfile.cs
[Page("/user/{id:int}")] // Route parameters
public class UserProfile : StatefulComponent
{
[Parameter]
public int Id { get; set; } // Auto-binding
}
// Pages/Admin/Dashboard.cs
[Page("/admin/dashboard")]
[Authorize(Roles = "Admin")] // Authorization
public class AdminDashboard : StatefulComponent { }
5.2 Program.cs Registration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddUI(options => {
options.ScanAssembly(typeof(Program).Assembly);
});
var app = builder.Build();
app.UseStaticFiles();
app.UseServerActions();
app.MapUI(); // Auto-discovery via [Page] attributes + SPA fallback
app.Run();
5.3 Navigation (Client-Side)
Client-side navigation is handled by the runtime router: link clicks and the Link component navigate without a reload, with typed route params, guards, prefetch and scroll restoration. A typed programmatic Navigator API is not part of the current release.
6. Developer Experience
6.1 Project Structure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
│ ├── components.js
│ └── pages/
│ ├── Counter.js
│ ├── Home.js
└── css/
└── site.css
6.2 CLI Commands
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 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
2
3
4
5
6
7
8
9
10
11
12
13
1. Developer edits Counter.cs
2. dotnet watch detects change
3. MSBuild task recompiles Counter.cs → Counter.js
4. File watcher notifies browser (SSE at `/_equantic/hmr`)
5. Browser fetches updated Counter.js
6. Hot Module Replacement
7. UI updates without losing state
7. Technical Decisions
7.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:
1
2
3
4
5
6
7
8
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:
1
2
3
4
5
6
7
dotnet build
MSBuild Task: CompileEQuanticUI
1. C# Roslyn parse (Pages/**/*.cs + library sources)
2. Generate TypeScript intermediate
3. Embedded Bun bundles → wwwroot/_equantic/
See Build Flow for the full pipeline, step by step.