Ir al contenido

La aplicación

Al final de este módulo tendrás TaskFlow corriendo y entenderás qué produce el build, porque de eso depende toda la estrategia de caché que viene después.

El código de la aplicación viene casi completo. El tema del taller es AWS, no construir una librería de reactividad, así que aquí lees y cambias cosas puntuales. Donde vas a escribir de verdad es en Terraform y en el pipeline.

Ventana de terminal
cd taskflow-p01-app
make install
make dev

Qué deberías ver: http://localhost:5173 con el tablero. Crea un par de tareas y recarga: siguen ahí, porque se guardan en localStorage.

En este workshop la aplicación no tiene backend. La API con Lambda y DynamoDB es el workshop 02, y va a consumir exactamente esta misma SPA.

Ventana de terminal
make build

Qué deberías ver:

dist/index.html 0.94 kB │ gzip: 0.51 kB
dist/assets/index.BqbgjO9-.css 3.25 kB │ gzip: 1.16 kB
dist/assets/index.eXtURQbc.js 12.13 kB │ gzip: 4.79 kB

Doce kilobytes de JavaScript, menos de cinco comprimidos. Ese directorio dist/ es literalmente todo lo que vas a subir a S3: no hay proceso de servidor, no hay runtime que mantener, no hay nada que parchar.

Fíjate en el nombre: index.eXtURQbc.js. Ese hash del contenido es el contrato que permite cachear ese archivo un año entero sin miedo. Y el index.html, sin hash, es el único que nunca se cachea. Toda la estrategia del módulo 8 se apoya en esa diferencia.

Paso 3.3 · Cambia una línea y compruébalo

Sección titulada «Paso 3.3 · Cambia una línea y compruébalo»

Objetivo: ver el hash cambiar cuando cambia el contenido.

Anota el hash actual del JavaScript. Ahora cambia el título del tablero:

src/views/tablero.ts
seccion.append(
h("h1", {}, "Tablero"),
resumen,
formulario(scope),
filtros(scope),
);
seccion.append(
h("h1", {}, "Mis tareas"),
resumen,
formulario(scope),
filtros(scope),
);

Y vuelve a compilar:

Ventana de terminal
make build

Qué deberías ver: el hash del index...js es distinto. Eso es lo que hace que un navegador que tenía el archivo viejo pida el nuevo, sin necesidad de invalidar nada.

Objetivo: ver de dónde sale la versión que muestra la aplicación.

Abre /acerca en el navegador. Dice dev y local.

Ahora compila pasando los valores a mano, que es lo que hará el pipeline con el tag de git:

Ventana de terminal
make shell
Ventana de terminal
APP_VERSION=v9.9.9 APP_COMMIT=abc1234def npm run build
exit

Qué deberías ver: en /acerca, v9.9.9 y abc1234. El commit se recorta a siete caracteres.

Eso es lo que permitirá abrir el sitio en producción y saber exactamente qué versión está sirviendo el CDN. Vuelve a make build para dejarlo en dev.

  • index.html el único HTML, punto de entrada de Vite
  • Makefile comandos dockerizados
  • tsconfig.json tipos del navegador
  • tsconfig.node.json tipos de Node, solo para vite.config.ts
  • vite.config.ts
  • Directoriopublic/
    • favicon.svg se copia tal cual, sin procesar
  • Directoriosrc/
    • main.ts arranca la app y monta las vistas
    • styles.css
    • Directoriolib/
      • reactive.ts el puente entre los signals y el DOM
      • router.ts el router sobre la History API
    • Directoriostate/
      • tasks.ts el estado del dominio
    • Directorioviews/
      • tablero.ts
      • detalle.ts
      • acerca.ts
      • no-encontrada.ts

Tres carpetas con responsabilidades claras: state no sabe que existe el DOM, views no sabe cómo se guardan las tareas, y lib no sabe nada del dominio.

Sin framework, y por qué

TaskFlow usa Vite, TypeScript y una sola dependencia de runtime.

package.json
{
"name": "taskflow",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "SPA sin framework del Workshop 01: reactividad con signals sobre el DOM nativo",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json"
},
"dependencies": {
"@preact/signals-core": "^1.14.4"
},
"devDependencies": {
"@types/node": "^24.9.2",
"typescript": "^7.0.2",
"vite": "^8.2.2"
}
}

@preact/signals-core pesa alrededor de 1,6 kB y expone cinco funciones. No arrastra Preact ni React: es el paquete de reactividad suelto.

La razón es pedagógica. Un framework resuelve dos problemas: mantener el estado y reflejarlo en el DOM. Si los resuelves tú, en unas cien líneas, entiendes qué hace React por debajo y en qué casos no lo necesitas. Y de paso el bundle queda en unos pocos kilobytes, lo que hace que la lección de caché se vea con total claridad.

Esto no es una recomendación de no usar frameworks. Para una aplicación de cincuenta pantallas con formularios complejos, un framework te ahorra meses. El punto es que la decisión sea informada, y que veas que la parte de AWS es idéntica en ambos casos.

La configuración de Vite y los dos tsconfig
vite.config.ts
import { defineConfig } from 'vite';
/**
* La versión y el commit se inyectan en tiempo de build. El pipeline los pasa
* como variables de entorno a partir del tag de git, así la app puede mostrar
* exactamente qué release está sirviendo el CDN.
*/
const version = process.env.APP_VERSION ?? 'dev';
const commit = process.env.APP_COMMIT ?? 'local';
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(version),
__APP_COMMIT__: JSON.stringify(commit.slice(0, 7)),
},
build: {
// Nombres con hash: son la base de la estrategia de caché en CloudFront.
// Estos archivos se pueden cachear para siempre porque si cambian, cambia
// su nombre. El index.html es el único que nunca se cachea.
assetsDir: 'assets',
sourcemap: true,
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash][extname]',
},
},
},
server: {
port: 5173,
},
});

define reemplaza __APP_VERSION__ y __APP_COMMIT__ por literales en tiempo de build. Es lo que probaste en el paso 3.4.

entryFileNames con [hash] es lo que probaste en el paso 3.3, y la base de toda la estrategia de caché.

tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
// Solo tipos del navegador. Si algo en src/ intenta usar `process` o
// `fs`, el typecheck falla, que es exactamente lo que queremos: este
// código corre en un navegador, no en Node.
"types": ["vite/client"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}

Hay dos tsconfig, y no es capricho. Este cubre src/ y declara types: ["vite/client"]: si algo en src/ intentara usar process o fs, el typecheck fallaría, y eso es deseable porque ese código corre en un navegador.

vite.config.ts sí corre en Node y necesita process, así que vive en tsconfig.node.json con types: ["node"]. Es la convención de Vite y evita que los tipos de Node se filtren a la aplicación.

Tres opciones que normalmente no se activan y aquí sí: noUncheckedIndexedAccess hace que array[0] tenga tipo T | undefined, lo que elimina una categoría entera de errores en tiempo de ejecución; exactOptionalPropertyTypes distingue «la propiedad no está» de «la propiedad vale undefined»; y verbatimModuleSyntax obliga a escribir import type cuando importas solo un tipo.

El HTML, y qué no es reactivo
index.html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TaskFlow</title>
<meta name="description" content="Tablero de tareas del Workshop 01" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body>
<header class="cabecera">
<a href="/" data-link class="marca">TaskFlow</a>
<nav aria-label="Navegación principal">
<a href="/" data-link>Tablero</a>
<a href="/acerca" data-link>Acerca</a>
</nav>
</header>
<!-- El router monta cada vista aquí dentro. -->
<main id="app"></main>
<footer class="pie">
<span id="version" aria-label="Versión desplegada"></span>
</footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

Fíjate en lo que no es dinámico: la cabecera, la navegación y el pie son HTML estático. No todo en una SPA necesita ser reactivo, y el router monta las vistas solo dentro de <main id="app">.

Los enlaces llevan data-link. Ese atributo es la marca que el router usa para interceptar el clic y navegar sin recargar. Un enlace sin data-link provoca una navegación normal del navegador, lo cual sigue siendo útil para enlaces externos.

Ventana de terminal
make install # dependencias desde el lock
make dev # http://localhost:5173
make build # a dist/
make check # typecheck
make clean # si algo se corrompió

Ahora cómo funciona la reactividad.