Incorporación de TypeScript
Hako es todo lo que necesitas
6 de noviembre de 2025
De ninguna manera es novedoso la idea de extender programas compilados con lenguajes interpretados en tiempo de ejecución. Los desarrolladores lo han estado haciendo durante años; Lua viene a la mente al extender juegos con mods y plugins.
Soy muy partidario de la creencia de que la elección del lenguaje que usas para extender tu software está directamente relacionada con la capacidad de tu software para construir un ecosistema saludable de extensiones hechas por terceros y la comunidad. JavaScript (y por extensión TypeScript) parece la elección obvia, así que ¿por qué no vemos más aplicaciones nativas que lo usen?
El estado de los enlaces específicos de plataforma para motores de JavaScript es lamentable. Esto no es una crítica contra los desarrolladores que se esfuerzan por crearlos; están tratando de construir enlaces para proyectos gigantescos sin garantías de una API estable o incluso ABI, donde los cambios que rompen son frecuentes y la carga de mantenimiento es increíblemente alta. Si buscas enlaces para V8 y JavaScriptCore, te encontrarás con repositorios que no han sido tocados en años.
Solo mira este parche que hice para agregar soporte de módulos ES a la API C de JavaScriptCore si quieres tener una idea de cuántos cambios que rompen tienes que manejar solo para usar estos proyectos de manera incrustable.
Pero “¡Andrew!”, te oigo gritar, “¡puedes usar QuickJS!”. Y tienes absolutamente razón. Aunque el estado de los enlaces específicos de plataforma para QuickJS no es mejor que el de los proyectos más grandes, su superficie de API es considerablemente más pequeña, lo que lo hace más mantenible, y el hecho de que fue diseñado para ser incrustable lo convierte en el mejor candidato con mucho.
Pero aquí radica el segundo problema al elegir el lenguaje de extensión adecuado: la seguridad. QuickJS, aunque es una impresionante hazaña de ingeniería, no está exento de problemas de seguridad. Recuerda, las extensiones por su naturaleza son código no confiable que se ejecuta en un entorno que no controlas. Si tu proceso se ejecuta con privilegios elevados y alguien crea JavaScript de la manera correcta para explotar las máquinas de tus usuarios, eso es culpa tuya.
¿Entonces qué hacemos?
Hako
Hako1 es un motor de JavaScript construido sobre QuickJS que se compila a WebAssembly, un entorno de ejecución seguro en memoria y enjaulado. Esto significa que aunque Hako está escrito en C, los programas que lo incrustan tienen una capa adicional de protección contra posibles vulnerabilidades de memoria. Lo que normalmente serían agujeros de seguridad explotables se convierten en ataques de denegación de servicio en el peor de los casos. Cuando tu extensión se bloquea, se bloquea la jaula; no tu proceso2.
Además de las protecciones básicas de WebAssembly, Hako proporciona sus propios mecanismos de enjaulamiento. Puedes restringir las capacidades de JavaScript a un nivel granular: deshabilitar completamente la asignación de memoria, eliminar características específicas del lenguaje o restringir lo que puede acceder el contexto de ejecución. Esto es más importante ahora que estamos en una era donde los agentes de IA ejecutan código arbitrario en las máquinas de las personas con mínima supervisión, y los desarrolladores están enviando código producido por modelos de lenguaje grandes que ocasionalmente alucinan APIs enteras o comandos peligrosos.
Construyendo la Capa de Traducción
QuickJS es excelente, pero llamarlo desde otros lenguajes puede ser un dolor. Hako actúa como una capa de traducción que se sitúa entre QuickJS y la aplicación anfitriona y hace que cada llamada de función sea explícita sobre la propiedad de la memoria. En lugar de tener que averiguar quién posee qué, las firmas de tipo te lo dicen todo:
//! Crea un nuevo valor de cadena de JavaScript
//! @param ctx Contexto
//! @param str Cadena C. El anfitrión posee.
//! @return Nuevo valor de cadena. El llamador posee
//! liberar con HAKO_FreeValuePointer.
HAKO_EXPORT(”HAKO_NewString”) extern JSValue* HAKO_NewString(JSContext* ctx, const char* str);
Pasas una cadena (que tú posees), obtienes un valor (que ahora posees y necesitas liberar). Sin ambigüedad. Por supuesto, a menos que estés implementando un anfitrión Hako, no necesitarás preocuparte por esto porque un anfitrión envuelve todo en las primitivas naturales de gestión de memoria de tu lenguaje.
Analizando los Enlaces
Se ha dicho por muchos que no puedes analizar archivos de encabezado C. Desafortunadamente, yo no estoy entre los muchos, y he decidido hacerlo con expresiones regulares. Para generar enlaces de anfitrión, el análisis sintáctico es suficiente; no necesito un compilador C completo, solo las firmas de función y sus documentos.
El análisis funciona porque controlamos el formato:
function parseHeaderFunction(lines: string[], exportLineIndex: number): HeaderFunctionInfo | null {
const exportLine = lines[exportLineIndex];
const exportMatch = exportLine.match(/HAKO_EXPORT\\(”([^”]+)”\\)/);
if (!exportMatch) return null;
const name = exportMatch[1];
const fullRegex = /HAKO_EXPORT\\(”[^”]+”\\)\\s+extern\\s+([\\w\\s*]+?)\\s+(\\w+)\\s*\\(([^)]*)\\);/;
const match = exportLine.match(fullRegex);
if (!match) return null;
const cReturnType = match[1].trim();
const paramsStr = match[3];
// Analizar tipos de parámetros y extraer documentos de comentarios...
}
También usamos wasm-objdump para extraer las firmas de funciones de WebAssembly reales del módulo compilado. Combina la información del encabezado C (tipos y propiedad) con las firmas de WebAssembly (diseño real de la función) y obtienes una especificación completa de enlace:
{
“name”: “HAKO_NewString”,
“funcIndex”: 62,
“wasmSignature”: {
“params”: [”i32”, “i32”],
“returns”: “i32”
},
“cReturnType”: “JSValue*”,
“cParams”: [
{
“name”: “ctx”,
“cType”: “JSContext*”,
“doc”: “Contexto”
},
{
“name”: “str”,
“cType”: “const char*”,
“doc”: “C string. Host owns.”
}
],
“summary”: “Creates a new JavaScript string value”
}
Generando Enlaces de Anfitrión
Con la especificación de enlace, podemos generar código específico del anfitrión. Para .NET, Hako abstrae el tiempo de ejecución de WebAssembly para que solo necesitemos dirigirnos a una interfaz simple. El código generado se ve así:
/// <summary>Creates a new JavaScript string value</summary>
/// <param name=”ctx”>Context</param>
/// <param name=”str”>C string. Host owns.</param>
public JSValuePointer NewString(JSContextPointer ctx, int str)
{
return Hako.Dispatcher.Invoke(() =>
{
if (_newString == null)
throw new InvalidOperationException(”HAKO_NewString not available”);
return _newString(ctx, str);
});
}
Todo esto hace que agregar nuevas funciones a Hako sea fácil: actualiza el encabezado C, recompila el WASM, vuelve a ejecutar la generación de código. Los enlaces se regeneran automáticamente con documentos y todo.
TypeScript para Todos
El sistema de tipos de TypeScript es excelente para definir contratos. Da tanto a los desarrolladores como a los modelos de IA el contexto que necesitan para escribir mejor código. El problema es que no puedes simplemente pasar esto a un motor de JavaScript:
function greet(name: string): void {
console.log(`Hello ${name}`);
}
Las anotaciones de tipo no son JavaScript válido. El motor lanzará un error de sintaxis.
Así que tienes algunas opciones. La primera es usar un empaquetador. O tus usuarios empaquetan su código ellos mismos (un paso de compilación adicional), o integramos un empaquetador directamente en Hako.
Seguí este camino inicialmente, experimentando con swc, esbuild e incluso rollup. Finalmente abandoné todas estas aproximaciones por una razón simple: Hako no está tratando de ser Bun o Deno para casos de uso incrustados. Necesitar tsconfig resolution, empaquetado de módulos y todas las otras complejidades que vienen con ser un empaquetador está muy fuera de alcance.
El enfoque que adoptamos es más simple: simplemente eliminar los tipos antes de evaluar. Esto es similar a lo que hace ts-blank-space, excepto que en lugar de depender del compilador de TypeScript, usamos tree-sitter y su árbol de sintaxis abstracta para reimplantar completamente la eliminación de tipos en C.
Al principio estaba preocupado por el rendimiento. Tree-sitter construye un AST y lo recorre recursivamente, lo que parecía que podría ser lento cuando se compila a WebAssembly. Resulta que una vez que el JIT de Wasmtime se activa, funciona aproximadamente tan rápido como swc (alrededor de 0.01-0.05ms para archivos típicos).
Construyendo el Eliminador de Tipos
La API del eliminador de tipos es sencilla. Creas un contexto, lo llamas para eliminar los tipos, y devuelve JavaScript:
typedef enum {
TS_STRIP_SUCCESS = 0,
TS_STRIP_ERROR_INVALID_INPUT,
TS_STRIP_ERROR_PARSE_FAILED,
TS_STRIP_ERROR_UNSUPPORTED,
TS_STRIP_ERROR_OUT_OF_MEMORY
} ts_strip_result_t;
ts_strip_ctx_t* ts_strip_ctx_new(void);
ts_strip_result_t ts_strip_with_ctx(
ts_strip_ctx_t* ctx,
const char* typescript_source,
char** javascript_out,
size_t* javascript_len
);
void ts_strip_ctx_delete(ts_strip_ctx_t* ctx);
La implementación recorre el AST de tree-sitter y borra la sintaxis solo de tipo. En lugar de eliminar completamente las anotaciones de tipo (lo que rompería los mapas de origen y los números de línea), las reemplazamos con espacios:
static inline bool blank_range(parse_ctx_t* ctx, uint32_t start, uint32_t end) {
return range_array_push(&ctx->ranges, ctx->allocator, FLAG_BLANK, start, end);
}
static bool blank_type_anno(parse_ctx_t* ctx, TSNode n) {
uint32_t start = ts_node_start_byte(n);
uint32_t end = ts_node_end_byte(n);
if (start > 0 && ctx->source[start - 1] == ‘:’) {
start--;
}
return blank_range(ctx, start, end);
}
Así que este TypeScript:
let x: string = ‘hello’;
Se convierte en este JavaScript:
let x = ‘hello’;
Mismo tamaño en bytes, mismos números de línea, solo que la anotación de tipo se reemplaza por espacios.
El patrón de visitante (del cual todos deberían saber que soy fan) maneja diferentes tipos de nodos:
static int visit_node(parse_ctx_t* ctx, TSNode n) {
const char* type = ts_node_type(n);
// Declaraciones solo de tipo se borran completamente
if (strcmp(type, “type_alias_declaration”) == 0 ||
strcmp(type, “interface_declaration”) == 0) {
blank_stmt(ctx, n);
return VISIT_BLANKED;
}
// Declaraciones de variables necesitan borrado selectivo
if (strcmp(type, “variable_declarator”) == 0) {
uint32_t count = ts_node_child_count(n);
for (uint32_t i = 0; i < count; i++) {
TSNode child = ts_node_child(n, i);
const char* child_type = ts_node_type(child);
if (strcmp(child_type, “type_annotation”) == 0) {
blank_type_anno(ctx, child);
} else {
visit_node(ctx, child);
}
}
return VISITED_JS;
}
// Por defecto: visitar hijos
visit_children(ctx, n);
return VISITED_JS;
}
Algunas características de TypeScript no se pueden eliminar porque tienen semánticas en tiempo de ejecución. Las enumeraciones, las propiedades de parámetros y la antigua sintaxis namespace generan código JavaScript. Cuando nos encontramos con estas, devolvemos TS_STRIP_ERROR_UNSUPPORTED :
// Declaración de enumeración
if (strcmp(type, “enum_declaration”) == 0) {
ctx->has_unsupported = true;
return VISITED_JS;
}
// Propiedades de parámetro en constructores
static bool has_param_props(TSNode params) {
uint32_t count = ts_node_child_count(params);
for (uint32_t i = 0; i < count; i++) {
TSNode param = ts_node_child(params, i);
// Verificar modificadores de accesibilidad
if (find_child_type(param, “accessibility_modifier”)) {
return true;
}
}
return false;
}
Integración con Hako
Para evitar realocar el analizador de tree-sitter en cada evaluación, almacenamos el contexto del eliminador como datos opacos en el tiempo de ejecución de QuickJS:
typedef struct {
ts_strip_ctx_t* stripper;
// ... otros datos de tiempo de ejecución
} hako_runtime_data_t;
HAKO_Status HAKO_InitTypeStripper(JSRuntime* rt) {
hako_runtime_data_t* data = JS_GetRuntimeOpaque(rt);
if (!data) {
return HAKO_STATUS_ERROR_INVALID_ARGS;
}
data->stripper = ts_strip_ctx_new_with_allocator(&hako_allocator);
if (!data->stripper) {
return HAKO_STATUS_ERROR_OUT_OF_MEMORY;
}
return HAKO_STATUS_SUCCESS;
}
La eliminación real ocurre en HAKO_StripTypes :
HAKO_Status HAKO_StripTypes(
JSRuntime* rt,
const char* typescript_source,
char** javascript_out,
size_t* javascript_len
) {
hako_runtime_data_t* data = JS_GetRuntimeOpaque(rt);
if (!data || !data->stripper) {
return HAKO_STATUS_ERROR_INVALID_ARGS;
}
ts_strip_result_t result = ts_strip_with_ctx(
data->stripper,
typescript_source,
javascript_out,
javascript_len
);
switch (result) {
case TS_STRIP_SUCCESS:
return HAKO_STATUS_SUCCESS;
case TS_STRIP_ERROR_PARSE_FAILED:
return HAKO_STATUS_ERROR_PARSE_FAILED;
case TS_STRIP_ERROR_UNSUPPORTED:
return HAKO_STATUS_ERROR_UNSUPPORTED;
default:
return HAKO_STATUS_ERROR_OUT_OF_MEMORY;
}
}
HAKO_Eval detecta automáticamente TypeScript verificando la extensión del nombre de archivo o las banderas de evaluación. Si termina en .ts, elimina los tipos antes de evaluar:
using var runtime = Hako.Initialize<WasmtimeEngine>();
using var realm = ru
``````text
ntime.CreateRealm().WithGlobals(g => g.WithConsole());
var result = await realm.EvalAsync<int>(@”
interface User {
name: string;
age: number;
}
function greet(user: User): string {
return `${user.name} is ${user.age} years old`;
}
const alice: User = { name: ‘Alice’, age: 30 };
console.log(greet(alice));
alice.age + 12;
“, new() { StripTypes = true });
Console.WriteLine($”Result: {result}”);
También puedes eliminar los tipos manualmente si necesitas más control:
var typescript = @”
type Operation = ‘add’ | ‘multiply’;
const calculate = (a: number, b: number, op: Operation): number => {
return op === ‘add’ ? a + b : a * b;
};
calculate(5, 3, ‘multiply’);
“;
var javascript = runtime.StripTypes(typescript);
var calcResult = await realm.EvalAsync(javascript);
Console.WriteLine($”Calculation: {calcResult}”);
Todo esto es sorprendentemente rápido. Para un archivo TypeScript típico (unas pocas cientos de líneas con interfaces, tipos y genéricos), la eliminación de tipos tarda aproximadamente 0.02ms. Es prácticamente gratis en comparación con el tiempo de evaluación real.
## Rendimiento
Voy a omitir la descomposición técnica. Si quieres ver todo lo que entra en una implementación completa del host con todos los azúcares para que se sienta como una extensión natural del lenguaje anfitrión, echa un vistazo a la implementación de .NET en GitHub ([aquí](https://github.com/6over3/hako/tree/main/hosts/dotnet)).
La pregunta más importante es: ¿qué tan rápido es todo esto?
En lugar de compartir pruebas sintéticas, voy a mostrar dos ejemplos reales.
### Visualización 3D con Raylib
El primer ejemplo es Hako interactuando con raylib para controlar una visualización 3D. El código TypeScript está controlando gráficos 3D en tiempo real a través de llamadas FFI de vuelta a C#, que luego llama a raylib nativo. Las definiciones de tipo se generan automáticamente desde el módulo C#:
declare module ‘raylib’ {
export class Vector3 {
constructor(x?: number, y?: number, z?: number);
x: number;
y: number;
z: number;
}
export class Camera3D {
constructor();
position: Vector3;
target: Vector3;
up: Vector3;
fovy: number;
projection: number;
}
export function beginMode3D(camera: Camera3D): void;
export function endMode3D(): void;
export function drawCube(position: Vector3, width: number, height: number, length: number, color: Color): void;
export function drawCubeWires(position: Vector3, width: number, height: number, length: number, color: Color): void;
// … etc
}
El demo renderiza más de 100 objetos animados a 60fps, con cada cuadro realizando cientos de llamadas FFI.
Aquí hay un fragmento del código TypeScript:
const objects: SceneObject = ;
// Generar una cuadrícula de objetos
const gridSize = 5;
for (let x = -gridSize; x <= gridSize; x++) {
for (let z = -gridSize; z <= gridSize; z++) {
const dist = Math.sqrt(x * x + z * z);
let type: ‘cube’ | ‘tower’ | ‘ring’ = ‘cube’;
if (dist < 2) type = ‘tower’;
else if (dist > 6 && Math.abs(x) % 2 === 0) type = ‘ring’;
objects.push({
pos: new Vector3(x * 3.5, 0, z * 3.5),
type: type,
offset: (x + z) * 0.5 + dist * 0.3
});
}
}
// Bucle principal
while (!windowShouldClose()) {
time += 0.016;
// Animar cámara
camera.position = new Vector3(
Math.cos(angle) * radius,
height,
Math.sin(angle) * radius
);
beginDrawing();
clearBackground(bgColor);
beginMode3D(camera);
// Dibujar todos los objetos con alturas animadas
for (let i = 0; i < objects.length; i++) {
const obj = objects[i];
const height = 2 + Math.sin(time * 2 - dist * 0.4 + obj.offset) * 2;
drawCube(new Vector3(obj.pos.x, height / 2, obj.pos.z),
1.5, height, 1.5, color);
drawCubeWires(new Vector3(obj.pos.x, height / 2, obj.pos.z),
1.5, height, 1.5, wireColor);
}
endMode3D();
endDrawing();
}
El lado de C# utiliza generadores de código para crear automáticamente los enlaces del módulo:
[JSModule(Name = “raylib”)]
[JSModuleClass(ClassType = typeof(Vector3), ExportName = “Vector3”)]
[JSModuleClass(ClassType = typeof(Camera3D), ExportName = “Camera3D”)]
internal partial class RaylibModule
{
[JSModuleMethod(Name = “drawCube”)]
public static void DrawCube(Vector3 position, double width, double height, double length, Color color)
{
var nativePos = new System.Numerics.Vector3((float)position.X, (float)position.Y, (float)position.Z);
var nativeColor = new Raylib_cs.Color(color.R, color.G, color.B, color.A);
Program.RunOnMainThread(() => Raylib.DrawCube(nativePos, (float)width, (float)height, (float)length, nativeColor));
}
// ... más métodos
}
### Aplicación Financiera para iOS
El segundo ejemplo es una aplicación de seguimiento financiero que funciona en iOS. [Toda la interfaz de usuario está escrita en JavaScript](https://gist.github.com/andrewmd5/f0be891c8a2b8c2ab3ad31517f423740) usando un marco de UI que creé, y el backend de renderizado está escrito en C. Hako (compilado a WASM, sin JIT) se encuentra en el medio.
La aplicación tiene animaciones suaves, cálculos de diseño, renderizado SVG, manejo de estado complejo y responde instantáneamente a la interacción del usuario (incluyendo inicio inmediato):
Ambos ejemplos muestran lo mismo: la sobrecarga de Hako es lo suficientemente baja como para construir aplicaciones reales con él. No son demostraciones, sino software real que la gente usaría. El límite FFI es rápido, el intérprete es rápido, y todo simplemente funciona — y es portable a cualquier plataforma; y ahora se integrará en gran parte de mi software.
## Conclusión
Eso es prácticamente todo. Hako es un motor de JavaScript que puedes incrustar en tus aplicaciones, con soporte para TypeScript, sandboxing con WebAssembly y una capa limpia de FFI. Funciona en .NET, iOS y cualquier otro lugar donde puedas ejecutar WebAssembly.
Para probarlo o ver el código detrás de todo, dirígete a https://github.com/6over3/hako
Gracias por leer.
Artículo original aquí: https://andrews.substack.com/p/embedding-typescript