-
Notifications
You must be signed in to change notification settings - Fork 1
Architecture
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.
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.
- ✅ 100% .NET - Zero external dependencies (Node.js, npm, etc)
- ✅ Self-Contained - ASP.NET Core serves and compiles everything
- ✅ Familiar - Routing via attributes (like Controllers)
- ✅ Modern - SPA experience with SSR when needed
- ✅ Performant - Intelligent compilation (static vs dynamic)
<!-- 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>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?
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)Challenge: We don't want a giant bundle.js, but we also don't want hundreds of small files.
Solution: Hybrid Compilation Strategy
What compiles statically:
- Component structure (Component 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; }
`,
};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
}
}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 : 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:
// 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 });
}
}Goal: Optimize loading without exploding request count.
/_equantic/runtime.js (~15kb gzipped)
- Minimal Virtual DOM
- Event system
- State management
- Server actions bridge
/_equantic/components.js (~30kb gzipped)
- Button, TextBox, Container, etc
- Components used by multiple pages
/_equantic/pages/Counter.js
- Counter.static.js (structure)
- Counter.logic.js (behavior)
- Counter-specific widgets
https://cdn.example.com/library.js
- Declared via IRequireAssets
- Deduplicated by AssetCollection
- Injected into <head>
/_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/components.js"></script>
<script src="/_equantic/pages/Counter.js"></script>
<!-- SPA Navigation: /counter → /todos -->
<!-- Only loads: -->
<script src="/_equantic/pages/TodoList.js"></script>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 : 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
}Compiler detects methods with [ServerAction]:
// 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:
// Counter.logic.js
export class CounterLogic {
async incrementOnServer(current) {
return await this._serverActions.invoke(
"Counter/IncrementOnServer", // Action ID
[current], // Arguments
);
}
}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);
}
}// 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;
}
}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.
Components declare their own external dependencies (scripts, stylesheets) without manual injection in the HTML shell. Two patterns are supported:
Pattern 1: IRequireAssets — component declares its own assets:
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> — external provider for third-party components:
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:
-
ServerRenderingServicewalks the component tree, collectingIRequireAssetsandIComponentAssetProvider<T>assets. -
AssetCollectiondeduplicates entries byKey(CSS first, then JS). -
UIExtensionsinjects the resulting tags into the<head>of the page. - Pages without asset-requiring components get zero extra tags.
See Asset Management for full documentation.
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
| 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) |
WebForms-like DX:
// 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:
// Compiled to optimized JS
// Runs in browser, no postback
// Only calls server when really needed// 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 { }// 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();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.
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
# 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 production1. Developer edits Counter.cs
↓
2. dotnet watch detects change
↓
3. MSBuild task recompiles Counter.cs → Counter.js
↓
4. File watcher notifies browser (SSE — `/_equantic/hmr`)
↓
5. Browser fetches updated Counter.js
↓
6. Hot Module Replacement
↓
7. UI updates without losing state
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 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.