23 septiembre, 2024

Simplificando el fullstack

Por ciertas circunstancias, he contado con unas semanas de "ajustes" laborales que me han permitido dedicarle tiempo a repensar el stack utilizado para desarrollar las aplicaciones corporativas. En los últimos años, abandoné PHP para abrazar un stack formado por un back-end que consistía en un API server hecho con Node y Koa. Dicho back-end servía peticiones realizadas desde un front-end estático (archivos html) que usaba Vanilla JS o bien Petite Vue o bien Vue (usado desde CDN y sin compilación y con el módulo httpVueLoader si necesito componentes SFC).

El caso es que el cambio más importante al pasar de una tecnología a otra ha sido pasar de una concepción centrada en contenido HTML (porque PHP está optimizado para ello) a una centrada en contenido JSON (y por tanto obligando al uso de JavaScript en el front-end). Que no se me malentienda, me encanta JavaScript, pero me encuentro entre los cada vez más numerosos desarrolladores que se preguntan si el camino hacia la extrema complejidad emprendido por la industria hacia frameworks como Angular, Vue o, especialmente, React (entre otros).

Siempre he tenido la teoría de que demasiados desarrolladores que vienen de lenguajes como Java, C, C++ o C# nunca se han encontrado "cómodos" con un lenguaje interpretado como Javascript (solo eso ya explica gran parte de la aceptación de TypeScript), pero a la larga, tampoco han aceptado HTML ni mucho menos CSS. Por ello tienden a sustituir todo lo que no "les gusta" por código Javascript, creando problemas graves de rendimiento y haciendo cada vez más grande una montaña de parches que solo pretenden aliviar ligeramente los problemas que no dejan de multiplicarse por el camino emprendido.

Digo esto porque tenía claro que mi movimiento anterior de estar centrado en HTML a estarlo en JSON me parece un error cada vez más evidente y que cualquier propuesta de nuevo cambio tenía que estar basado en volver a abrazar la herramienta básica: el navegador. Una aplicación extremadamente optimizada para trabajar con HTML y con el que es extremadamente rápido, y más con la potencia de los equipos/móviles de hoy día.

Así que con esa premisa y, no lo voy a negar, mis propias preferencia, me puse a diseñar un nuevo stack con el que me sintiese a gusto y que me simplificase en el futuro el desarrollo de webapps. Estas son mis especificaciones previas para dicho stack:


BACK-END


  • La idea principal es crear un código minúsculo para gestionar las peticiones web recibidas y que usase módulos para extender las funcionalidades.

  • Usé Node porque no deja de mejorar, por su comunidad y por su uso mayoritario. Aunque lo cierto es que estuve planteando también Bun y Deno como opciones. Finalmente quise aprovechar todo el ecosistema de herramientas y conocimiento alrededor la pieza de servidor de aplicaciones.

  • Respecto al framework para Node, descarté Koa. No tuve una mala experiencia, pero creo que es un proyecto medio abandonado y no me parecía lo más adecuado. En su momento lo elegí porque venía del equipo de Express pero corrigiendo muchos de los problemas que han tenido que mantener por compatibilidad (deuda técnica), prácticamente del mismo modo que Deno está basado en Node pero corrigiendo varios puntos que ya no se pueden corregir en Node. Estuve considerando primero Hyper-Express por su extremo rendimiento hasta que descubrí que tenía una dependencia que no me gustaba (interacción con una aplicación externa en C++) por lo que decidí abandonar esa vía. Después estuve probando con Fastify, pero me pareció excesivo para lo que trataba de hacer, y no me gustaban demasiado los compromisos adquiridos para conseguir mejorar el rendimiento. Finalmente descubrí Micro de Vercel. Es una opción muy minimalista, pero para lo que necesito creo que me viene como un guante. Nótese que ni siquiera trae un sistema para identificar rutas.

  • La idea de identificar peticiones es la siguiente: las aplicaciones estarán en su propio directorio, dentro de "public", que será el que use el backend. Cuando el servidor recibe una petición, intenta seguir manualmente cada segmento del path solicitado y busca por este orden:

    • un directorio con el mismo nombre que el segmento

    • archivo estático con el camino exacto (a excepción de archivos mjs o eta)

    • un archivo index.html en el directorio actual

    • un archivo llamado igual que el segmento comprobado pero con la extensión mjs

    • un archivo llamado igual que el segmento comprobado pero con la extensión eta (veremos esto después)

    • un archivo llamado igual que el método llamado (get/post/put/delete/...) y con la extensión mjs


    Esto me permite una flexibilidad tremenda a la hora de generar código personalizado en el lugar adecuado y organizar mejor el código.

  • Los archivo mjs són módulos JavaScript con una función exportada que recibirá los objetos (request y response) y devolverá el código HTML generado. Por ejemplo...

    
    /* check.mjs */
    export default async function handler(req, res) {
        const logged= (req.session?.id) ?1:0;
        res.end( logged.toString() );
    }
    


  • Los archivos eta son plantillas de código HTML que permiten generar contenido HTML pudiendo indicar segmentos de código Javascript (de nuevo tenemos request y response disponibles). Por ejemplo...

    
    <!DOCTYPE html>
    <html>
      <h3 title="[{{Object.keys(it)}}]">Eta file rendered</h3>
    {{JSON.stringify(it.params)}}
    {{{
        try {
            const result = await it.mssql.query('SELECT * FROM tabla WHERE offdate is null');
            // ...
    		} catch {}
    }}
    

    Hay tres tipos de expresión permitidos: interpolate: {{~ ... }} para establecer una cadena que debe ser enviada sin interpretar como html; exec: {{{ ... }} para código javascript que no genera una respuesta a enviar; y raw: {{ ... }} que envía como html el resultado de la ejecución de la expresión indicada.
    Este mecanismo me permite no definir rutas y basarlo todo en archivos y directorios, simplificando tremendamente la gestión.

  • He incluido una gestión de sesión extremadamente básica (actualmente basada en cookies para no depender de Javascript para el uso de JWT) y una gestión de errores que intenta dar información más explícita sobre los errores ocurridos, al estilo de PHP (aunque la idea es que pueda desactivarse para aplicaciones en producción). Con la información del error incluyo la posibilidad de solicitar a la api de OpenAI una sugerencia de resolución del error a partir del contexto de éste.

  • Me quedan pendientes de desarrollar varias ideas:

    • al igual que compruebo la existencia de un archivo mjs o eta, quiero hacerlo también de un archivo json con el nombre del segmento. Su función es especificar los pasos de un proceso bastante típico: origen de datos (archivo físico, recurso URL o consulta de base de datos), proceso de transformación a aplicarle a esos datos (indicando el archivo mjs que realizará la transformación) y, opcionalmente, un template eta para visualizar el resultado de la transformación.

    • sistema de caché de módulos para minimizar las consultas al sistema de archivos para comprobar la existencia del elemento a servir/ejecutar.

    • módulo de invalidación de caché para que Node vuelva a solicitar un módulo que ha sido modificado. La idea es que el editor de código envie una notificación a ese módulo con el archivo modificado, de forma que Node lo elimine de su caché de módulos y, por tanto, siempre se sirva la última versión.

    • facilitar el uso de SQLite a nivel de aplicación y de servidor.

    • módulo con mecanismo de subscripción y envío de notificaciones


    Código disponible en github


    FRONT-END

    Respecto al front-end, hay una serie de proyectos que he estado siguiendo y estudiando. Especialmente HTMX y sus simplificaciones htmf y incluso htmz. El concepto base de HTMX de recuperar el uso de HTML para las aplicaciones me encanta, pero veo demasiado pesado todo el sistema de triggers con lenguaje propio. También he visto una cantidad elevada de microframeworks frontend nuejs.org, strawberry.quest, https://github.com/reallygoodsoftware/minijs , https://github.com/anh-ld/nho , https://github.com/WebReflection/uce , alpinejs.dev entre otros muchos. Pero finalmente he optado por aprovechar PetiteVue pese a que es un proyecto "acabado" dado que lo he estado usando durante varios años sin incidencias remarcables.

    Mi idea es generar una extensión (la he llamado nanovue) basada principalmente en directivas (mecanismo del mismo Petite Vue) para incorporar estas características:

    • Atributos de carga parcial de contenido html. Se trata de los atributos v-get, v-post y v-target que contienen una URL y un selector CSS de elemento/s destino. La idea es que aplicados a un elemento FORM o BUTTON, capture el evento submit o click respectivamente para, mediante fetch, realizar una petición de contenido HTML que se asignará al elemento indicado.

    • Atributos de carga parcial de contenido html. Se trata de los atributos v-get-json, v-post-json, v-template y v-target que contienen una URL, un selector CSS para especificar un template y un selector CSS de elemento/s destino. La idea es que aplicados a un elemento FORM o BUTTON, capture el evento submit o click respectivamente para, mediante fetch, realizar una petición de contenido JSON que se aplicará al template Vue para generar un contenido HTML que se asignará al elemento indicado.

    • Autocarga de componentes web. PetiteVue no tiene componentes SFC, pero esto, en lugar de ser una limitación, la he convertido en una oportunidad. Mi idea es que al cargar la librería se compruebe si existen componentes web en el código HTML. De ser así, se intentará buscar un archivo con el mismo nombre que la etiqueta del componente y extensión html (por ejemplo click-counter.html). Si se encuentra, se asume que el archivo tiene tres etiquetas definidas: template con código HTML, script con código Javascript y style con código CSS. Se creará un web component a partir de esa definición. Si no encuentra el html, intentará la carga de un archivo js estándar con la definición del web component. Ya será responsabilidad del código registrar el componente. Se usa LocalStorage para guardar la ubicación del archivo cargado la primera vez, para evitar intentos de carga fallidos posteriormente. La idea de esta características es dar una versatilidad enorme a la hora de trabajar con componentes de frontend. Es posible que agregue la posibilidad de permitir cargar también ambos archivos (html o js) desde un repositorio centralizado para todas las aplicaciones.

    • Si no se especifica un atributo v-scope (inicialización de PetiteVue), uso el tag <body> para aplicar la funcionalidad de PetiteVue a toda la página.


      De nuevo, tengo pendientes distintas opciones:

      • Agregar la posibilidad de usar todos los atributos de vue con el prefijo "-". Por ejemplo, v-scope sería equivalente a -scope, y lo mismo con: -if -else -else-if -model -on-click -on-submit -on-mounted -class ...




    Código disponible en github