eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArquitectura
eQuantic.UI - Arquitectura
Edit this page
11 min read
eQuantic.UI es un framework de componentes para .NET en el que un componente se escribe una sola vez, en C#, y se realiza sobre dos clases de destino: la web, como DOM y CSS servidos por ASP.NET Core y conducidos en el navegador por JavaScript que el build compiló desde ese mismo C#; y ventanas y teléfonos nativos, como píxeles pintados por el motor GPU Photon. No hay un segundo lenguaje que aprender ni una cadena de herramientas externa que instalar: el compilador, el bundler y el motor viajan dentro de los propios paquetes del SDK.
TypeScript
Componentes C#, un vocabulario
eqc
Bun embebido
wwwroot/_equantic
SSR de ASP.NET Core
Navegador
Build .NET, AOT
Shell de Photon
Ventana / teléfono
El diagrama de arriba es una cerca mermaid `: GitHub lo dibuja aquí, y el sitio de documentación dibuja la MISMA cerca a través del componente Mermaid del propio SDK.
Esta página es el mapa. Componentes write-once explica a fondo el vocabulario compartido y los realizadores, Photon el motor nativo, Compilador y Flujo de build la pipeline web paso a paso, y Arquitectura de paquetes lo que lleva cada paquete. El repositorio guarda las auditorías medidas junto al código: docs/README.md es su índice.
Principios centrales
1.
✅ 100% .NET: sin Node.js, npm ni Vite en la máquina de quien programa; la cadena de herramientas viene embebida.
2.
✅ Escribir una vez: una clase de componente, realizada en el DOM y en una display list de GPU.
3.
✅ Autocontenido: ASP.NET Core sirve, renderiza y compila todo para la web.
4.
✅ Familiar: las rutas son atributos, los servicios vienen de IServiceCollection, la configuración de appsettings.json; quien programa en .NET ya conoce los modismos.
5.
✅ Ningún artefacto de plataforma: sin Swift, sin manifest, sin plist, sin CSS. El SDK deriva lo que necesita cada destino desde declaraciones en C#.
1. Las capas
Capa
Paquete
Lo que contiene
Puede referenciar
El vocabulario
eQuantic.UI.Primitives
los nodos visuales abstractos (Box, Row, Text, Pressable, …), los estilos tipados y los tokens de diseño, el modelo de componentes, los atributos de contrato
nada
La biblioteca
eQuantic.UI.Components, eQuantic.UI.Charts
los componentes write-once (Button, Card, DataTable, BarChart, …), compuestos solo a partir del vocabulario
Primitives
El realizador web
eQuantic.UI.Web
baja un árbol de nodos a HtmlElement/DOM y CSS atómico en el servidor; también la vía de escape al DOM (HtmlElement, DynamicElement, ClassBuilder)
Primitives
El host web
eQuantic.UI.Server
integración con ASP.NET Core: SSR, Server Actions, routing, metadatos, assets, hot reload
Web
El runtime del navegador
@equantic/runtime, empaquetado por eQuantic.UI.Server desde el TypeScript de eQuantic.UI.Runtime
el gemelo en TypeScript del realizador web, el reconciliador, el estado, el router, y los módulos transpilados de la propia biblioteca
nada
La vía nativa
eQuantic.UI.Native.Engine (+ Metal, Vulkan, Reference), Native.Framework, Native.Components, Native.Hosting, los shells
el motor GPU y sus backends, el motor de layout, el realizador y el host de Photon, el constructor de la aplicación, y un shell por plataforma
Primitives, y unos a otros hacia abajo
El compilador
eQuantic.UI.Compiler (eqc)
el transpilador de C# a TypeScript basado en Roslyn que ejecuta el SDK web
Codegen
Los SDKs
eQuantic.UI.Sdk, eQuantic.UI.Sdk.Native
SDKs de MSBuild: un atributo Sdk="…" trae todos los paquetes de arriba y conecta el build
nada
Dos reglas sostienen la tabla. El vocabulario no referencia nada, así que puede viajar a todos los destinos, incluidos el bundle del navegador y Native AOT. Un realizador nunca referencia la biblioteca que realiza: un componente llega a un realizador como el árbol que produjo su Build, así que Web, Email y Native.Components conocen el vocabulario y no Components. Las dos reglas son tests en el repositorio (AssemblyLayeringTests), no convenciones.
2. La pipeline web
2.1 El SDK
El archivo de proyecto de una aplicación web nombra el SDK y nada más, sin ningún PackageReference:
1
2
3
4
5
<Project Sdk="eQuantic.UI.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
La versión viene de global.json, como se versiona cualquier SDK de MSBuild:
1
2
3
4
5
{
"msbuild-sdks": {
"eQuantic.UI.Sdk": "0.2.0-preview.52"
}
}
Sdk.props importa Microsoft.NET.Sdk.Web, añade los paquetes del framework (Primitives, Code, Components, Charts, Web, Server, el generador de código, y el paquete de Bun embebido para el SO y la arquitectura de la máquina que compila), pone la superficie declarativa al alcance en todos los archivos (eQuantic.UI.Components.UI y eQuantic.UI.Charts.ChartsUI como usings estáticos), e incluye Pages/**/*.cs como los componentes que el build compila para el navegador. Junto a un checkout de las fuentes del framework cambia cada paquete por el proyecto correspondiente, así que los ejemplos compilan contra el árbol. Vea Flujo de build.
Recortado a lo que importa, ese archivo se lee así:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- eQuantic.UI.Sdk/Sdk/Sdk.props -->
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
<PropertyGroup>
<EnableEQuanticUICompilation>true</EnableEQuanticUICompilation>
<EQuanticOutputPath>wwwroot/_equantic/</EQuanticOutputPath>
</PropertyGroup>
<ItemGroup>
<!-- la superficie declarativa, al alcance en todos los archivos -->
<Using Include="eQuantic.UI.Components.UI" Static="true" />
<Using Include="eQuantic.UI.Charts.ChartsUI" Static="true" />
<!-- lo que el build compila para el navegador -->
<UIComponent Include="Pages/**/*.cs" Exclude="obj/**;bin/**" />
</ItemGroup>
</Project>
2.2 Lo que hace dotnet build
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dotnet build
↓
Microsoft.NET.Sdk.Web, la pipeline normal, hasta CoreCompile (el C# de la aplicación)
↓
ResolveBunPath el Bun embebido para esta máquina, extraído una vez de su paquete
↓
CompileEQuanticUI después de CoreCompile, y antes de definir los static web assets
1. eqc lee las fuentes del proyecto y las fuentes que entrega la biblioteca
2. Roslyn las liga. El modelo semántico es autoritativo; una llamada que no liga es error de build
3. un módulo TypeScript por página o componente
4. Bun empaqueta cada módulo; en Debug, con un source map de vuelta al C# (EQuanticSourceMaps)
↓
CopyEQuanticRuntime el runtime.js servido, desde el paquete Server
↓
Salida: wwwroot/_equantic/, definido como static web assets a partir de lo que está en disco, para
que la compresión, los manifests y el publish vean los archivos de este build
El intermedio en TypeScript es deliberado: el código emitido es legible, se verifica de tipos una segunda vez con el compilador de TypeScript, y se mapea de vuelta a la línea de C# para el depurador del navegador. El bundler es Bun, porque es un único ejecutable que empaqueta TypeScript sin configuración, y se entrega como un paquete por sistema operativo y arquitectura (eQuantic.UI.Runtime.OsxArm64, .Win64, .Linux64, …) que el SDK selecciona. Nadie lo instala.
Esto es lo que eqc emite para uno de los componentes de la propia biblioteca, Divider, exactamente como viaja dentro de runtime.js. El C# del que salió es Divider.cs en eQuantic.UI.Components: la misma clase, el mismo Build, los enums bajados a sus nombres en camelCase, el inicializador de objeto llegando como el props final:
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
import { Box, BoxStyle, BuildContext, EdgeInsets, SizeValue, StatelessComponent } from "../runtime-exports";
export class Divider extends StatelessComponent {
declare inset: string;
declare axis: string;
declare leadingInset: any;
constructor(inset: any = 'none', axis: any = 'horizontal', props?: any) {
super();
if (inset !== undefined) this.inset = inset;
if (axis !== undefined) this.axis = axis;
if (this.inset === undefined) this.inset = 'none';
if (this.axis === undefined) this.axis = 'horizontal';
this.inset = inset;
this.axis = axis;
if (props && typeof props === 'object') Object.assign(this, props);
}
build(context: BuildContext) {
let line = new Box(new BoxStyle({ width: this.axis === 'vertical' ? SizeValue.fixed(1) : SizeValue.fill, height: this.axis === 'vertical' ? SizeValue.fill : SizeValue.fixed(1), background: context.theme.border }));
if (this.inset === 'none') return line;
let padding = this.inset === 'leading' ? new EdgeInsets(this.leadingInset ?? 16, 0, 0, 0) : EdgeInsets.symmetric(16, 0);
return new Box(new BoxStyle({ width: SizeValue.fill, padding: padding }), line);
}
}
2.3 Lo que escribe un build
Aquí no se citan tamaños, porque se mueven con cada cambio: el tamaño gzip del runtime servido es un presupuesto que la suite hace cumplir (ServedRuntimeBudgetTests, contra el registro en tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json), y CI reporta el tamaño del módulo de cada página en cada pull request.
Archivo
Lo que es
runtime.js
el runtime del navegador: el reconciliador, los eventos, el estado, el puente de Server Actions, el router, Y la biblioteca de componentes compartida, cuyos módulos transpilados viajan dentro de él. La página lo alcanza como el módulo desnudo @equantic/runtime a través del import map del shell, y el Server responde esa URL con el bundle que embebe; este archivo son los mismos bytes, para las herramientas que cargan los módulos fuera de un servidor (la vista previa de VS Code lo lee)
<Component>.js, y .js.map en Debug
un módulo por página o componente, en plano. El arranque importa el módulo de la página al navegar, así que la carga diferida por ruta sale del propio grafo de módulos; no hay carpeta pages/. eqc ejecuta bun con --splitting, así que lo que dos o más módulos comparten es un chunk nombrado según uno de ellos con un hash de contenido: NotFoundScreen-<hash>.js en el ejemplo del dashboard es su shell de consola, no un segundo tipo con ese nombre. El map lleva el C# del que salió el módulo, así que EQuanticSourceMaps es full en Debug y none en todo lo demás (external escribe uno sin el C#, para un reportador de errores), y un publish nunca se lleva ninguno
strings/<culture>.json
los catálogos de cultura, cuando la aplicación tiene .resx
icons/
los tamaños del icono de la aplicación y el manifest web, cuando se declara un AppIcon
Los estilos no son un archivo: el servidor los escribe dentro de la página, la hoja de tokens generada y las clases atómicas (<style id="eq-atomic">) entre ellos.
2.4 Renderizado en el servidor e hidratación
El servidor renderiza el primer HTML desde el MISMO C#: WebRealizer baja el árbol del componente a HtmlElements, y las clases de estilo atómico que escribe hashean idénticas a las que escribe el lowering.ts del navegador, así que la hidratación compara nombres de clase y no repinta nada. Una página que implementa IServerPrefetch tiene sus datos cargados en el servidor ANTES del primer build, y la respuesta viaja al navegador con la página: la primera pintura ya tiene datos, y no hay estado de carga que diseñar. Vea Componentes write-once para el contrato de paridad e Integración con el servidor para las opciones de la pipeline.
3. La pipeline nativa
La misma clase de componente, construida por dotnet build en una aplicación .NET corriente (Native AOT al publicar), hospedada por Photon:
1
2
3
4
5
6
7
8
9
PhotonApplication.CreateBuilder(args) … .Build().UseRoot<Shell>()
↓
LayoutEngine las constraints bajan, los tamaños suben, los padres posicionan, todo en una pasada
↓
PhotonRealizer el árbol maquetado se vuelve una display list: cajas redondeadas, sombras, texto, imágenes, iconos
↓
RHI un renderizador sobre IRhiDevice; Metal en Apple, Vulkan en Windows y Android, un backend Reference por CPU en todas partes
↓
Shell macOS, iOS, Android o Windows: la ventana, la entrada, los servicios de texto, el puente de accesibilidad, las capacidades
El texto lo moldea la plataforma (CoreText, DirectWrite, el layout de texto de Android) detrás de un único contrato ITextMeasurer; las capacidades (cámara, ubicación, almacenamiento, biometría, …) son interfaces tipadas que el shell implementa y que un componente toma por su constructor o con GetService<T>(). El hot reload es dotnet watch: el runtime aplica la edición en proceso y la ventana se redibuja.
4. Server Actions
4.1 El problema
Una página que necesita datos no debería necesitar un controlador, un DTO, un fetch y un contrato JSON mantenidos a mano. Necesita un método.
Sin esto, la misma funcionalidad queda repartida en cuatro sitios que solo concuerdan por disciplina:
1
2
3
4
5
6
[ApiController]
public class TodoController : ControllerBase
{
[HttpPost("/api/todos")]
public Task<Todo> AddTodo([FromBody] AddTodoRequest request) { /* … */ }
}
1
2
3
4
async function addTodo(title) {
const response = await fetch("/api/todos", { method: "POST", body: JSON.stringify({ title }) });
return await response.json();
}
4.2 La forma
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
// Pages/TodoList.cs
[Page("/todos")]
public class TodoList : StatefulComponent
{
private List<Todo> _todos = [];
// Corre en el SERVIDOR. El cliente recibe una llamada, no el cuerpo.
[ServerAction]
public async Task<List<Todo>> LoadTodos()
{
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;
}
protected override async void OnMount() => await Load();
private async Task Load()
{
var loaded = await LoadTodos();
SetState(() => _todos = loaded);
}
private async Task Add(string title)
{
var added = await AddTodo(title); // con tipos, serializado por ti
SetState(() => _todos.Add(added));
}
public override VisualNode Build(ComponentContext context) =>
Column(gap: Space.S2, children: [.. _todos.Select(todo => TodoItem(todo))]);
}
El compilador reemplaza el cuerpo de cada [ServerAction] por una llamada al puente:
1
2
3
async loadTodos() {
return await this._serverActions.invoke("TodoList/LoadTodos", []);
}
4.3 El puente
app.UseServerActions() responde a POST /api/_equantic/actions. Solo son invocables los métodos marcados con [ServerAction] (una whitelist, no una convención), y [Authorize] se aplica antes de que el método corra. El tamaño del payload y los tipos de los argumentos se validan; vea Seguridad. Una Server Action es petición/respuesta; lo que el servidor publica mientras una página está abierta le llega por eventos del servidor.
En el cable es un POST por llamada, que vale saber cuando se lee la pestaña de red:
1
{ "actionId": "TodoList/LoadTodos", "arguments": [] }
1
{ "success": true, "result": [ { "id": 1, "title": "Buy milk" } ] }
4.4 Assets
Un componente que necesita un script o una hoja de estilos externa lo declara, y el servidor inyecta las etiquetas una vez por página, deduplicadas:
1
2
3
4
5
6
7
8
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");
}
}
IComponentAssetProvider<T> hace lo mismo para un componente que no escribiste, registrado en DI o encontrado por el escaneo de ensamblados de AddUI():
1
2
3
4
5
6
7
8
public class PrismAssetProvider : IComponentAssetProvider<CodeBlock>
{
public void ConfigureAssets(AssetBuilder assets) =>
assets.AddScript("https://cdn.example.com/prism.js");
}
// registro explícito, cuando el escaneo no debe ser quien lo encuentre
options.WithAssetProvider<PrismAssetProvider>();
El flujo: ServerRenderingService recorre el árbol renderizado recogiendo las dos clases, AssetCollection deduplica por clave (primero las hojas de estilos, después los scripts), y las etiquetas aterrizan en <head>. Una página sin ningún componente que pida assets no recibe ninguno. Vea Assets.
5. Routing
Una ruta es un atributo en la página, descubierto en tiempo de build, así que no hay tabla de rutas que mantener al paso de las páginas:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Page("/counter")]
[Page("/count")] // varias rutas, una página
public class Counter : StatefulComponent { }
[Page("/user/{id:int}")] // sintaxis de ruta de ASP.NET, parámetros incluidos
public class UserProfile : StatefulComponent
{
public override VisualNode Build(ComponentContext context)
{
var id = context.Route.Param("id"); // lo que dijo la ruta, sin ningún destino dentro
var page = context.Route.Query("page"); // y la query string
…
}
}
[Page("/admin/dashboard")]
[Authorize(Roles = "Admin")]
public class AdminDashboard : StatefulComponent { }
1
2
3
4
5
6
7
8
9
// 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(); // cada [Page], más el fallback de la SPA
app.Run();
En el navegador el router del runtime se encarga de la navegación sin recargar: un Link es un ancla real, y Navigator.Go(destination) es el gemelo programático para las rutas que un componente decide tomar por sí mismo. En Photon el mismo Link y el mismo Navigator alcanzan la costura de navegación del host.
6. Experiencia de quien programa
6.1 Un proyecto
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MiApp/
├── MiApp.csproj # <Project Sdk="eQuantic.UI.Sdk">
├── global.json # la versión del SDK
├── Program.cs # el host de ASP.NET Core
├── Pages/ # componentes con [Page]
│ ├── Home.cs
│ └── Counter.cs
├── Components/ # tus propios componentes write-once
├── Assets/ # AppIcon.cs, *.svg → vectores en tiempo de build
├── Resources/ # .resx → strings/<culture>.json
└── wwwroot/
└── _equantic/ # generado por el build; los .map en Debug
├── runtime.js
├── Counter.js Counter.js.map
└── Home.js Home.js.map
6.2 Comandos
1
2
3
4
5
6
dotnet new install eQuantic.UI.Templates # una vez
dotnet new equantic-app -n MiApp # una aplicación web
dotnet new equantic-native -n MiApp # una aplicación Photon
dotnet watch run # hot reload, web o nativo
dotnet build # C# → TypeScript → JavaScript, listo para SSR
dotnet publish -c Release # una aplicación publicable; Native AOT para Photon
6.3 Hot reload
1
2
3
4
5
6
7
8
1. Editas Counter.cs
2. el servidor, vigilando los .cs de la aplicación, ejecuta eqc otra vez y Counter.js se reescribe
en su sitio; mientras tanto dotnet watch aplica la misma edición al servidor en marcha
3. el servidor empuja un evento en /_equantic/hmr, server-sent events con un latido y reconexión
4. el navegador descarga el módulo nuevo y MONTA la página otra vez, con los campos que la página
tenía; el marcado viejo del servidor no se vuelve a hidratar
5. en Photon no hay paso 3: el runtime aplica la edición dentro del proceso en marcha,
y el host redibuja en el siguiente frame
Vea Depuración para el overlay de errores y los source maps que apuntan de vuelta a la línea de C#.
7. Comparado con WebForms
Aspecto
WebForms
eQuantic.UI
Estado
ViewState en un campo oculto
estado en la instancia del componente, conservado por el reconciliador; el servidor guarda datos, no estado de vista
Renderizado
HTML generado en el servidor en cada postback
primera pintura renderizada en el servidor, después un reconciliador en el cliente
Actualizaciones
postback de toda la página
en su sitio, en el navegador o en la ventana
Lógica de servidor
handlers de postback
métodos [ServerAction], llamados como métodos
Destinos
el navegador
el navegador, y ventanas y teléfonos nativos desde la misma clase
Lo que conserva de WebForms es la parte que estaba bien: un componente es una clase C# con estado y handlers, y es el framework, no quien programa, el que decide cómo llega a la pantalla.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Counter : StatefulComponent
{
private int _count;
// se lee como un handler de WebForms, y corre en el navegador, sin postback
private void Increment() => SetState(() => _count++);
public override VisualNode Build(ComponentContext context) =>
Row(gap: Space.S2, children: [
Text($"{_count}", TypeRole.Title),
Button("+", onPressed: Increment),
]);
}
8. Decisiones técnicas
•
TypeScript como intermedio: salida legible, una segunda verificación de tipos, y source maps de vuelta al C#.
•
Bun, embebido por plataforma: un ejecutable de bundler por SO y arquitectura, entregado como un paquete que el SDK selecciona; sin package.json, nada instalado.
1
2
3
eQuantic.UI.Runtime.OsxArm64/ un paquete por SO y arquitectura
├── tools/bun/bun-darwin.zip el ejecutable, extraído una vez en el primer build
└── build/ los props que el SDK lee para encontrarlo
•
Un solo motor de estilos: los estilos tipados en C# bajan a CSS atómico deduplicado en la web y a pintura GPU en Photon; no hay plano CSS que escribir. Vea Design System.
•
Escribir una vez, realizar por destino: el vocabulario es cerrado y se sitúa debajo de todos los realizadores; los realizadores son el punto de extensión, no los nodos. Las contrapartidas que esto cuesta, medidas, están en la auditoría de arquitectura del repositorio y en su compañera, cómo resuelve Flutter cada una de estas.