Módulo 27: Despliegue y herramientas con IA
Objetivos del módulo
- Entender qué genera
ng buildy por qué cada fichero está ahí - Distinguir los datos que se fijan al compilar de los que se fijan al arrancar
- Crear entornos con
ng generate environments,fileReplacementsydefine - Cargar una configuración en tiempo de ejecución con
config.jsonyprovideAppInitializer - Empaquetar la aplicación en una imagen Docker con varias fases y servirla con Nginx
- Configurar en Nginx las rutas del cliente, la caché, la compresión y el proxy hacia la API
- Publicar en GitHub Pages resolviendo
--base-hrefy las rutas profundas - Montar una integración continua con GitHub Actions
- Proteger la aplicación frente a XSS y CSRF, y añadir CSP y Trusted Types
- Situar cada herramienta de IA en su sitio: servidor MCP, Agent Skills y WebMCP
- Exponer herramientas de tu aplicación a un agente con WebMCP, sabiendo que es experimental
1. Qué significa desplegar una aplicación Angular
Hasta ahora has trabajado con ng serve, un servidor de desarrollo que compila en memoria, recarga el navegador y da mensajes de error detallados. No está pensado para producción: es lento comparado con un servidor web real, y su objetivo es ayudarte a ti, no atender a miles de usuarios.
Desplegar es poner la aplicación en un sitio al que lleguen los usuarios. En Angular, la pregunta clave es qué necesita el servidor para hacerlo:
| Modo | Qué genera ng build | Qué hace falta en el servidor | Dónde se explica |
|---|---|---|---|
| Aplicación de una sola página (SPA) | Ficheros estáticos | Cualquier servidor web: Nginx, Apache, GitHub Pages, un CDN | Este módulo |
| Prerenderizado completo | Ficheros estáticos, con HTML ya generado | Igual que la anterior | Módulo 24 |
| SSR | Estáticos y un servidor Node | Un proceso Node en marcha | Módulo 24 |
📘 Concepto: Una SPA compilada no ejecuta nada en el servidor. Son ficheros que el navegador descarga y ejecuta. Por eso se pueden servir desde sitios muy baratos, pero también por eso todo lo que hay en ellos es público.
Este módulo trabaja con una SPA. Si usas SSR, casi todo te sirve igual (entornos, CI, seguridad), pero Nginx solo ya no basta: la imagen Docker debe ejecutar Node con la orden que viste en el módulo 24, y Nginx, si lo usas, queda delante como proxy.
2. ng build por dentro
ng build
Initial chunk files | Names | Raw size | Estimated transfer size
chunk-CRQVtppu.js | - | 171.02 kB | 50.50 kB
main-K4P57BNK.js | main | 101.76 kB | 25.99 kB
styles-TSTX4CF4.css | styles | 195 bytes | 195 bytes
| Initial total | 272.98 kB | 76.69 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-CjH9sosY.js | seguridad | 2.47 kB | 1.14 kB
chunk-CrhuJs4l.js | lista-empleados | 1.53 kB | 784 bytes
chunk-387wfkAz.js | agente | 1.06 kB | 611 bytes
chunk-BP1vPNkC.js | detalle-empleado | 885 bytes | 885 bytes
Application bundle generation complete. [42.209 seconds]
Output location: dist/empresa-web
Lee la tabla de arriba abajo. Los ficheros iniciales se descargan siempre; los perezosos solo al entrar en su ruta, gracias a loadComponent. Raw size es lo que ocupa el fichero, y Estimated transfer size lo que viajaría comprimido. Para decidir si una aplicación pesa mucho, mira esta segunda columna. Cómo reducirla y cómo fijar presupuestos se explica en el módulo 23.
Esto es lo que queda en disco:
dist/empresa-web/
├── 3rdpartylicenses.txt licencias de las dependencias
└── browser/ lo único que se publica
├── chunk-CRQVtppu.js
├── chunk-CjH9sosY.js
├── config.json copiado desde public/
├── favicon.ico copiado desde public/
├── index.html
├── main-K4P57BNK.js
└── styles-TSTX4CF4.css
⚠️ Importante: Lo que se publica es
dist/empresa-web/browser, nodist/empresa-web. Si copias la carpeta equivocada, el servidor no encontraráindex.htmlen la raíz. Es de los primeros errores que comete todo el mundo.
La huella en el nombre
El fragmento K4P57BNK de main-K4P57BNK.js es una huella (hash) calculada a partir del contenido. Si cambia una sola letra del código, cambia el nombre del fichero. Lo controla outputHashing: "all" en la configuración production de angular.json.
Parece un detalle, pero resuelve el problema más difícil de la caché. Puedes decirle al navegador que guarde main-K4P57BNK.js durante un año, porque ese nombre siempre tendrá ese contenido. Cuando publiques una versión nueva, index.html apuntará a otro nombre y el navegador lo descargará. Esto obliga a una regla que aplicarás en Nginx: index.html nunca se guarda en caché a ciegas, porque es el único fichero con nombre fijo y el que señala a todos los demás.
Qué hace la configuración production
ng build usa production porque defaultConfiguration lo indica en angular.json:
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
Fíjate en lo que no aparece: optimization no se escribe en production porque ya vale true por defecto. Esa optimización elimina el código no usado (tree shaking), acorta nombres (minificación) y mete en index.html el CSS que se necesita para la primera pintura.
Mira el index.html generado, recortado:
<!doctype html>
<html lang="es" data-beasties-container>
<head>
<base href="/">
<style>body{margin:0;font-family:Segoe UI,Arial,sans-serif;color:#37474f}</style>
<link rel="stylesheet" href="styles-TSTX4CF4.css" media="print" data-beasties-media="all">
<noscript><link rel="stylesheet" href="styles-TSTX4CF4.css"></noscript>
</head>
<body>
<app-root></app-root>
<link rel="modulepreload" href="chunk-CRQVtppu.js">
<script src="main-K4P57BNK.js" type="module"></script>
<script>document.querySelectorAll('link[data-beasties-media]').forEach(function(l){l.media=l.getAttribute('data-beasties-media');l.removeAttribute('data-beasties-media')})</script>
</body>
</html>
Tres cosas de este HTML reaparecerán más adelante:
<base href="/">indica desde dónde se resuelven las URL relativas. Lo necesitarás en GitHub Pages.- El CSS global se carga con
media="print"para no bloquear la pintura, y un script en línea lo activa después. Ese script chocará con la política de seguridad de la sección 8. - El
<style>en línea contiene solo el CSS crítico, calculado por la herramienta Beasties.
Mapas de código en producción
Un mapa de código (source map) relaciona el JavaScript minificado con tu TypeScript original. Sin él, un error en producción dice at $$1.setProperty (main-TPWMBDKY.js:1:11826), que no ayuda mucho. Con él, una herramienta de seguimiento de errores te lleva a tu línea.
El problema es que publicar los mapas regala tu código fuente. La opción hidden los genera pero no los enlaza desde el JavaScript:
"sourceMap": { "scripts": true, "hidden": true }
Con esa opción, la compilación dejó 7 ficheros .map, y ningún fichero .js contenía la línea sourceMappingURL. Súbelos a tu servicio de errores y no los copies al servidor web.
3. Entornos: datos que se fijan al compilar
Tu aplicación se comporta distinto en desarrollo y en producción: quizá quieras trazas en consola solo en una de ellas. El CLI crea el mecanismo con una orden:
ng generate environments
CREATE src/environments/environment.ts (31 bytes)
CREATE src/environments/environment.development.ts (31 bytes)
UPDATE angular.json (2143 bytes)
Crea dos ficheros con la misma forma, y añade a la configuración development una sustitución:
"development": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
// src/environments/environment.ts
export const environment = {
nombre: 'produccion',
trazas: false,
};
// src/environments/environment.development.ts
export const environment = {
nombre: 'desarrollo',
trazas: true,
};
Tu código siempre importa environment.ts. Al compilar en desarrollo, el CLI cambia ese fichero por el otro antes de empaquetar:
import { environment } from '../environments/environment';
if (environment.trazas) {
console.log('Arrancando');
}
Como el valor se conoce al compilar, el optimizador puede borrar el if entero en producción. Esa es la gran ventaja de este mecanismo.
⚠️ Importante: Los dos ficheros deben tener las mismas propiedades. Si añades una solo a
environment.development.ts, el compilador usará el tipo deenvironment.tsy dará error, o peor, compilará en desarrollo y fallará al pasar a producción.
Añadir una configuración de preproducción
Preproducción (staging) es un entorno igual que producción donde se prueba antes de publicar. Se añade como otra configuración:
"staging": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.staging.ts"
}
],
"sourceMap": { "scripts": true, "hidden": true }
}
Una configuración nueva no hereda de production. Si compilas solo con staging, obtienes main.js sin huella. Para combinarlas, se separan por comas, y las de la derecha mandan:
ng build --configuration=production,staging
| Initial total | 315.49 kB | 86.67 kB
El resultado lleva huella (main-RVVGKSLT.js) y contiene el valor del entorno, que se ve incluso minificado:
var R={nombre:`preproduccion`,trazas:!0};
Constantes con define
define sustituye un identificador global por un valor, durante la compilación. Es cómodo para datos que no son del entorno, como la versión:
"options": {
"define": {
"VERSION_APP": "\"1.0.0\"",
"FECHA_COMPILACION": "\"2026-11-02\""
}
}
El valor es código, no texto. Por eso lleva dos capas de comillas: una para JSON y otra para que el resultado sea una cadena de JavaScript. TypeScript no conoce el identificador, así que hay que declararlo:
declare const VERSION_APP: string;
console.log(VERSION_APP); // en el fichero final queda: console.log("1.0.0")
También se pasa por línea de órdenes, lo que resulta útil en integración continua:
ng build --define "VERSION_APP='1.0.3'"
Nada de lo compilado es secreto
Da igual que un valor esté en environment.ts, en define o en un servicio: acaba en un fichero que descarga cualquiera. El ejemplo anterior lo demuestra. Pulsa F12, abre la pestaña Sources de cualquier web Angular y lo verás.
⚠️ Importante: Nunca pongas en un proyecto Angular una clave privada, una contraseña de base de datos o un token de un servicio de pago. Si la aplicación necesita usar un secreto, ponlo en tu servidor, y que el servidor haga la llamada. Una clave pensada para ser pública, como la de un mapa limitada a tu dominio, sí puede ir en el cliente.
4. Configuración en tiempo de ejecución
environment.ts tiene un defecto cuando el proyecto crece: para cambiar un valor hay que recompilar. Eso te lleva a tener un artefacto distinto por entorno, y el que pruebas en preproducción no es el que publicas en producción. Si algo falla solo allí, no sabes si ha sido el dato o la compilación.
Figura 1. El mismo dato puede fijarse al compilar o al arrancar. Elegir bien evita recompilar para cambiar una URL.
La regla práctica es esta:
| Pregunta | Mecanismo |
|---|---|
| ¿Cambia el código que se genera? (trazas, herramientas de depuración) | environment.ts |
| ¿Es un dato de la versión compilada? (número de versión, fecha) | define |
| ¿Depende de dónde se instale? (URL de la API, nombre del entorno) | config.json |
La idea de config.json es sencilla: un fichero junto a la aplicación que se lee al arrancar, antes de mostrar nada. Como no está dentro del JavaScript, puedes cambiarlo sin compilar.
{
"apiUrl": "http://localhost:3000/api",
"entorno": "desarrollo local"
}
Va en public/config.json, y el CLI lo copia tal cual a browser/. El valor sirve para trabajar en tu ordenador contra la API de ejemplo.
Un servicio guarda los datos:
// src/app/config.service.ts
import { Injectable, signal } from '@angular/core';
export interface ConfigApp {
apiUrl: string;
entorno: string;
}
@Injectable({ providedIn: 'root' })
export class ConfigService {
private readonly datos = signal<ConfigApp | undefined>(undefined);
async cargar(): Promise<void> {
const respuesta = await fetch('config.json', { cache: 'no-store' });
if (!respuesta.ok) {
throw new Error(`config.json: HTTP ${respuesta.status}`);
}
this.datos.set(await respuesta.json());
}
get valor(): ConfigApp {
const config = this.datos();
if (!config) {
throw new Error('La configuración aún no se ha cargado');
}
return config;
}
}
Y provideAppInitializer, que ya conociste en el módulo 08, hace que Angular espere a esa promesa antes de crear el primer componente:
// src/app/app.config.ts
import {
ApplicationConfig,
inject,
provideAppInitializer,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { ConfigService } from './config.service';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(),
provideAppInitializer(() => inject(ConfigService).cargar()),
],
};
Ya puede usarse en cualquier sitio. Aquí, con httpResource, que estudiaste en el módulo 13:
// src/app/empleados/lista-empleados.ts
import { httpResource } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ConfigService } from '../config.service';
interface Empleado {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
}
interface Pagina<T> {
contenido: T[];
total: number;
}
@Component({
selector: 'app-lista-empleados',
imports: [RouterLink],
template: `
<h1>Empleados</h1>
@if (empleados.isLoading()) {
<p>Cargando...</p>
} @else if (empleados.error()) {
<p role="alert">No se ha podido hablar con la API en {{ api }}.</p>
} @else if (empleados.hasValue()) {
<p>{{ empleados.value().total }} empleados en total, datos de {{ api }}</p>
<table>
<tr><th>Id</th><th>Nombre</th><th>Puesto</th></tr>
@for (e of empleados.value().contenido; track e.empId) {
<tr>
<td>{{ e.empId }}</td>
<td><a [routerLink]="['/empleados', e.empId]">{{ e.nombre }} {{ e.apellidos }}</a></td>
<td>{{ e.oficio }}</td>
</tr>
}
</table>
}
`,
})
export class ListaEmpleados {
protected readonly api = inject(ConfigService).valor.apiUrl;
protected readonly empleados = httpResource<Pagina<Empleado>>(
() => `${this.api}/empleados?tamano=6`,
);
}
💡 Consejo: Todo el proyecto de este módulo, salvo la parte de WebMCP de la sección 11, se ha compilado sin cambios con Angular 21.2. La única diferencia es que
httpResourceallí es experimental, como explica el módulo 13.
Tres detalles que evitan errores
La ruta es relativa. Se escribe fetch('config.json'), no fetch('/config.json'). La primera se resuelve contra <base href>, así que funciona también en una subcarpeta como la de GitHub Pages. La segunda busca siempre en la raíz del dominio.
No se usa HttpClient. Podrías, pero pasaría por los interceptores, y estos a menudo necesitan la configuración que aún no existe. fetch evita esa dependencia circular.
Si falla, hay que decirlo. Cuando un inicializador rechaza su promesa, bootstrapApplication también la rechaza y la pantalla queda en blanco. Muestra un mensaje en main.ts:
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((error) => {
console.error(error);
document.body.textContent = 'No se ha podido iniciar la aplicación.';
});
📘 Concepto: El precio de este patrón es una petición más antes de pintar. Si el fichero tarda, la aplicación también. Por eso conviene que sea muy pequeño y que lo sirva el mismo servidor que la aplicación.
La prueba unitaria es directa, porque basta con sustituir fetch. Las pruebas con Vitest se explican en el módulo 26:
// src/app/config.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConfigService } from './config.service';
describe('ConfigService', () => {
afterEach(() => vi.unstubAllGlobals());
it('expone los valores de config.json', async () => {
const respuesta = new Response('{"apiUrl":"/api","entorno":"prueba"}');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(respuesta));
const servicio = TestBed.inject(ConfigService);
await servicio.cargar();
expect(servicio.valor.apiUrl).toBe('/api');
});
it('falla si el fichero no existe', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 404 })));
await expect(TestBed.inject(ConfigService).cargar()).rejects.toThrow('HTTP 404');
});
});
5. Docker con Nginx
Una imagen Docker es un paquete con un programa y todo lo que necesita para funcionar: sistema base, librerías y ficheros. Un contenedor es esa imagen en marcha. La ventaja es que lo que funciona en tu ordenador funciona igual en el servidor, porque viajan juntos la aplicación y su entorno.
Nginx es un servidor web muy rápido sirviendo ficheros estáticos, justo lo que es una SPA compilada.
La imagen en varias fases
Para compilar hacen falta Node y node_modules, pero para servir el resultado no hace falta ninguno. Una construcción en varias fases (multi-stage build) usa una imagen grande para compilar y copia solo el resultado a una imagen pequeña:
Figura 2. La imagen final no contiene Node, ni el código fuente, ni las dependencias.
Empieza por .dockerignore. Excluye lo que no debe entrar en la imagen, lo que además acelera la construcción:
node_modules
dist
.angular
.git
.github
coverage
*.log
Dockerfile
docker-compose.yml
El Dockerfile:
# Fase 1: compilar
FROM node:24-alpine AS compilacion
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
FROM compilacion AS pruebas
CMD ["npx", "ng", "test", "--watch=false"]
FROM compilacion AS artefacto
ARG CONFIGURACION=production
RUN npx ng build --configuration="$CONFIGURACION"
# Fase 2: servir
FROM nginx:1.31-alpine
COPY docker/default.conf.template /etc/nginx/templates/default.conf.template
COPY docker/seguridad.inc /etc/nginx/conf.d/seguridad.inc
COPY docker/config.template.json /etc/empresa/config.template.json
COPY --chmod=755 docker/40-config.sh /docker-entrypoint.d/40-config.sh
COPY --from=artefacto /app/dist/empresa-web/browser /usr/share/nginx/html
ENV API_URL=/api ENTORNO=contenedor API_UPSTREAM=api:3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -q --spider http://127.0.0.1/salud || exit 1
Línea a línea, lo importante:
COPY package.json package-lock.json ./va antes de copiar el código. Docker guarda cada paso en caché, y repite desde el primero cuyos ficheros hayan cambiado. Así, si solo tocas un componente, no se reinstalan las dependencias. En nuestras pruebas,npm citardó varios minutos, y las reconstrucciones posteriores, entre uno y dos.npm ciinstala exactamente lo que dicepackage-lock.jsony falla si no coincide conpackage.json.npm installpodría actualizar versiones. Por eso el fichero de bloqueo debe estar en Git.- La fase
pruebashereda decompilacion, y solo se construye si la pides con--target. node:24-alpinecumple la versión que exige Angular 22.2 (^24.15.0), y también la de Angular 21.- La fase final usa una versión concreta de Nginx.
nginx:latestpodría cambiar sin avisarte el día menos oportuno.
docker build -t empresa-web:1.0.0 .
Compara lo que pesa cada fase:
| Imagen | Tamaño |
|---|---|
Fase compilacion, con Node y node_modules | 544 MB |
| Imagen final | 63,3 MB |
Solo 338 kB de esa imagen final son tu aplicación. El resto es Alpine y Nginx. Además de pesar menos, la imagen pequeña es más segura: un atacante que entrase no encontraría ni Node, ni el código fuente, ni npm.
La configuración de Nginx
Figura 3. Nginx trata cada petición de una forma distinta. Casi todos los errores de despliegue están en esta tabla mental.
Este es el fichero completo:
# docker/default.conf.template
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/css application/javascript application/json image/svg+xml;
location = /salud {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://${API_UPSTREAM};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /config.json {
add_header Cache-Control "no-store" always;
include /etc/nginx/conf.d/seguridad.inc;
}
location ~* \.(?:js|css|json|txt|woff2?|png|jpe?g|svg|webp|ico|map)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
include /etc/nginx/conf.d/seguridad.inc;
try_files $uri =404;
}
location / {
add_header Cache-Control "no-cache" always;
include /etc/nginx/conf.d/seguridad.inc;
try_files $uri $uri/ /index.html;
}
}
# docker/seguridad.inc
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Una petición entra en un solo bloque location. Nginx busca primero las coincidencias exactas (=), luego las expresiones regulares (~*), y usa el prefijo más largo si ninguna encaja. Veamos cada una.
Rutas del cliente: try_files
Este es el punto clave de toda SPA. El usuario está en /empleados/100 y pulsa F5. El navegador pide al servidor un fichero con ese nombre, que no existe, porque esa ruta solo la conoce el router de Angular. Sin configuración adicional, recibe un 404.
try_files $uri $uri/ /index.html dice: si existe el fichero, sírvelo; si no, devuelve index.html. Angular arranca, mira la URL y pinta la pantalla correcta.
Por qué los ficheros tienen su propio bloque
Si dejaras solo el bloque anterior, una petición a un .js inexistente también recibiría index.html, con código 200. Esto ocurre de verdad: un usuario tiene abierta la versión anterior, publicas una nueva y su navegador pide chunk-viejo.js, que ya no existe. Recibe HTML cuando esperaba JavaScript, y el error en consola es tan confuso como Failed to fetch dynamically imported module. try_files $uri =404 da un 404 real, que es lo honesto. La misma lógica se aplica a un .json que no existe: sin este bloque, fetch recibiría HTML con un 200 y fallaría al interpretarlo.
Caché
| Fichero | Cabecera | Motivo |
|---|---|---|
main-XXXX.js, styles-XXXX.css | public, max-age=31536000, immutable | La huella cambia con el contenido |
index.html y rutas del cliente | no-cache | Se puede guardar, pero hay que revalidar antes de usar |
config.json | no-store | Debe leerse siempre del servidor |
no-cache no significa «no guardar», aunque lo parezca. Significa «pregunta antes de usar», y no-store es el que prohíbe guardar.
⚠️ Importante: Esta configuración pone
immutablea cualquier fichero con esas extensiones. Si enpublic/tienes imágenes sin huella, comologo.png, y luego cambias su contenido, los navegadores seguirán con la antigua durante un año. Ponles versión en el nombre, o dales unlocationpropio con una caché corta.
Al escribir este módulo, la primera versión de la configuración tenía always también en la línea immutable. El resultado fue este:
== /chunk-viejo.js
HTTP/1.1 404 Not Found
Cache-Control: public, max-age=31536000, immutable
¡Un 404 guardado durante un año! Sin always, Nginx solo añade la cabecera a las respuestas correctas, y el mismo fichero responde ya sin ella. Por eso esa línea no lo lleva, y sí lo llevan las de seguridad, que deben ir también en los errores.
La trampa de add_header
Si un bloque tiene algún add_header, no hereda ninguno de los bloques superiores. Si pones las cabeceras de seguridad en server y un add_header de caché en location, las de seguridad desaparecen de esa ruta sin ningún aviso. Por eso están en un fichero aparte que cada bloque incluye.
Las tres cabeceras hacen lo siguiente:
| Cabecera | Efecto |
|---|---|
X-Content-Type-Options: nosniff | El navegador respeta el tipo declarado, y no adivina |
X-Frame-Options: DENY | Nadie puede meter tu web en un <iframe> para engañar al usuario |
Referrer-Policy | Limita qué URL se envía a otros dominios |
Compresión
gzip_types lista los tipos que se comprimen. No incluye text/html porque Nginx ya lo comprime siempre. main-LX5D53BC.js, de 139,86 kB, viajó en 39 193 bytes, algo menos del 30 %. No comprimas imágenes PNG o JPEG: ya lo están.
Proxy hacia la API
location /api/ reenvía las peticiones a otro servidor. Para el navegador, la API está en el mismo origen que la web, así que no hay CORS. proxy_pass no lleva ruta final, y por eso se conserva la URL completa: /api/empleados llega a la API como /api/empleados.
💡 Consejo: Las cabeceras
X-Forwarded-*le dicen a la API quién es el cliente real. Sin ellas, vería siempre la dirección de Nginx.
Escribir la configuración al arrancar
Aquí se juntan las secciones 4 y 5. La imagen oficial de Nginx ejecuta, antes de arrancar, los scripts de /docker-entrypoint.d/. Dos de ellos nos interesan:
20-envsubst-on-templates.sh, que ya trae la imagen: convierte cada/etc/nginx/templates/*.templateen un fichero deconf.d/, sustituyendo las variables de entorno definidas. Por eso${API_UPSTREAM}se sustituye, y$urio$hostno.40-config.sh, nuestro, que hace lo mismo conconfig.json.
{
"apiUrl": "${API_URL}",
"entorno": "${ENTORNO}"
}
#!/bin/sh
set -e
envsubst '${API_URL} ${ENTORNO}' \
< /etc/empresa/config.template.json \
> /usr/share/nginx/html/config.json
⚠️ Importante: Si trabajas en Windows y Git convierte los saltos de línea a CRLF, este script falla, y además con un mensaje que despista:
/tmp/c2.sh: not found, aunque el fichero exista. El sistema busca un intérprete llamado/bin/sh\r. Guárdalo con LF, y añade a.gitattributesla línea*.sh text eol=lf.
Ahora arranca el mismo artefacto en dos entornos. host.docker.internal es el nombre con el que un contenedor llega a tu ordenador, y aquí se usa porque la API está fuera de Docker. En Linux, añade --add-host=host.docker.internal:host-gateway.
docker run -d --name empresa -p 8080:80 \
-e API_URL=/api \
-e ENTORNO=preproduccion \
-e API_UPSTREAM=host.docker.internal:3000 \
empresa-web:1.0.0
docker exec empresa cat /usr/share/nginx/html/config.json
{
"apiUrl": "/api",
"entorno": "preproduccion"
}
Y comprueba el resultado con peticiones reales. curl -I pide solo las cabeceras:
== /
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache
X-Content-Type-Options: nosniff
== /empleados/100
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache
X-Content-Type-Options: nosniff
== /config.json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
X-Content-Type-Options: nosniff
== /main-QXGDMLVY.js
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
X-Content-Type-Options: nosniff
Content-Encoding: gzip
== /chunk-viejo.js
HTTP/1.1 404 Not Found
Content-Type: text/html
X-Content-Type-Options: nosniff
== /no/existe.json
HTTP/1.1 404 Not Found
Content-Type: text/html
X-Content-Type-Options: nosniff
/empleados/100 responde con HTML y un 200, porque es una ruta del cliente. /chunk-viejo.js responde con 404 y sin caché. Todo coincide con la figura 3.

Figura 4. Contenedor de preproducción. La tabla separa los datos fijados al compilar de los fijados al arrancar.
Arrancamos la misma imagen con otras variables, sin recompilar:
docker run -d --name empresa -p 8080:80 \
-e API_URL=https://api.empresa.es/api \
-e ENTORNO=produccion \
-e API_UPSTREAM=host.docker.internal:3000 \
empresa-web:1.0.0

Figura 5. La misma imagen con otras variables. Solo cambian las dos últimas filas. En el laboratorio, la API escuchaba en el puerto 3227.

Figura 6. La lista de empleados servida desde el contenedor, a través del proxy /api.
Con una URL absoluta, como en la figura 5, el navegador llama directamente a la API. Esa API debe entonces permitir CORS y resultar accesible desde Internet. Con /api, el contenedor hace de intermediario. La API de ejemplo devuelve Access-Control-Allow-Origin: *, y por eso funcionan las dos formas.
Si Nginx no arranca
Nginx resuelve los nombres de proxy_pass al iniciar. Si no encuentra el servidor, el contenedor se detiene:
nginx: [emerg] host not found in upstream "noexiste" in /etc/nginx/conf.d/default.conf:19
Es un error bastante útil, porque se ve al arrancar y no cuando llega el primer usuario. La primera orden que debes aprender cuando un contenedor no funciona es docker logs <nombre>.
Varios servicios con Docker Compose
En lugar de una orden docker run larga, Compose describe la instalación completa en un fichero:
# docker-compose.yml
services:
api:
image: node:24-alpine
working_dir: /srv
command: node server.mjs
volumes:
- ./empresa-api:/srv:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/estadisticas"]
interval: 5s
timeout: 3s
retries: 5
web:
build: .
image: empresa-web:1.0.0
ports:
- "8080:80"
environment:
API_URL: /api
ENTORNO: docker compose
API_UPSTREAM: api:3000
depends_on:
api:
condition: service_healthy
docker compose up -d --wait
docker compose ps
api Up 55 seconds (healthy)
web Up 46 seconds (healthy)
Fíjate en tres cosas:
API_UPSTREAM: api:3000: dentro de la red de Compose, cada servicio se llama por su nombre.- La API no publica ningún puerto. No es accesible desde fuera, solo a través de Nginx.
condition: service_healthyhace esperar awebhasta que la API responde. Sin esto, Nginx podría no encontrarapial iniciar. Recuerda el error anterior.
Para terminar, docker compose down borra contenedores y red. La imagen se queda, y se borra con docker rmi empresa-web:1.0.0.
6. GitHub Pages
GitHub Pages sirve ficheros estáticos gratis, y es ideal para portafolios y prácticas. Tiene dos limitaciones que condicionan una aplicación Angular:
- El proyecto queda en una subcarpeta:
https://usuario.github.io/empresa/. - No se puede configurar el servidor, así que no hay
try_filesni proxy.
Pages solo sirve ficheros, de modo que tu API debe estar publicada en otro sitio, con HTTPS y CORS.
--base-href
Si compilas normal y publicas en /empresa/, la página se queda en blanco. <base href="/"> hace que el navegador pida los ficheros en la raíz del dominio:
404 http://127.0.0.1:5327/chunk-CRQVtppu.js
404 http://127.0.0.1:5327/main-TPWMBDKY.js
La solución es indicar la subcarpeta. Lleva barra al principio y al final:
ng build --base-href /empresa/
<base href="/empresa/">
El router también usa este valor: routerLink="/empleados" lleva a /empresa/empleados, sin tocar tu código.
Rutas profundas con 404.html
GitHub Pages, cuando un fichero no existe, sirve el 404.html de tu sitio si lo tienes. Eso permite una solución muy simple: copiar index.html como 404.html. Angular arranca dentro de ese fichero, lee la URL y muestra la pantalla correcta.
cp dist/empresa-web/browser/index.html dist/empresa-web/browser/404.html
Hay que saber qué estás aceptando: la pantalla funciona, pero la respuesta lleva código 404. Para un usuario no se nota, y para un buscador es una página que no existe. Si te importa el posicionamiento en buscadores, usa prerenderizado (módulo 24).
Hemos reproducido ese comportamiento con Nginx, con la directiva error_page 404 /empresa/404.html, y este fue el resultado:
/empresa/ 200 text/html
/empresa/empleados/100 404 text/html
/empresa/config.json 200 application/json
En el navegador, /empresa/empleados/100 mostró a Ana García López, y «Volver a la lista» llevó a /empresa/empleados. config.json se encontró gracias a que la ruta es relativa.
La alternativa es withHashLocation(), que cambia las URL a /empresa/#/empleados/100. Como lo que hay tras # no llega al servidor, nunca hay un 404. A cambio, las URL son más feas, y es un mecanismo antiguo. Con 404.html no necesitas tocar el código.
Publicar con GitHub Actions
Esta es la forma recomendada hoy. En el repositorio, ve a Settings, Pages, y en Source elige GitHub Actions. Crea después este fichero:
# .github/workflows/pages.yml
name: Publicar en GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
compilar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npx ng build --base-href "/${{ github.event.repository.name }}/"
- name: Crear 404.html para las rutas del cliente
run: cp dist/empresa-web/browser/index.html dist/empresa-web/browser/404.html
- uses: actions/upload-pages-artifact@v5
with:
path: dist/empresa-web/browser
publicar:
needs: compilar
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.despliegue.outputs.page_url }}
steps:
- id: despliegue
uses: actions/deploy-pages@v5
permissions:pages: writeeid-token: writeson el mínimo que pide la publicación. El token de identidad demuestra a Pages que la petición viene de este flujo.concurrency: si haces dospushseguidos, se cancela el anterior.github.event.repository.nameevita escribir el nombre del repositorio a mano..nvmrcguarda la versión de Node, con solo una línea:24.- Si el repositorio se llama
usuario.github.io, el sitio queda en la raíz, y el--base-hrefdebe ser/.
💡 Consejo: Con
ng add angular-cli-ghpagespuedes publicar desde tu ordenador conng deploy. Es un paquete de la comunidad, y la versión 3.1.2 admite@angular/cli18 a 22. Va bien para proyectos pequeños, pero con Actions la publicación no depende del ordenador de nadie.
7. Integración continua
La integración continua (CI) ejecuta comprobaciones automáticas en cada cambio, en una máquina limpia. Evita el clásico «en mi ordenador funciona»: si el proyecto solo compila gracias a algo que tienes instalado, la CI lo descubre.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
comprobar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npx ng test --watch=false
- run: npx ng build
- uses: actions/upload-artifact@v7
with:
name: empresa-web
path: dist/empresa-web/browser
retention-days: 7
El orden no es casual: primero lo más barato de detectar. --watch=false es imprescindible, porque ng test en una terminal se queda vigilando cambios, y en CI no terminaría nunca. Como las pruebas usan jsdom, no hace falta instalar ningún navegador.
Comprobar un flujo subiéndolo una y otra vez es lento. Dos trucos locales:
# Valida la sintaxis de los flujos de GitHub Actions
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
# Ejecuta las pruebas en una máquina limpia, como haría la CI
docker build --target pruebas -t empresa-web-pruebas .
docker run --rm empresa-web-pruebas
Test Files 2 passed (2)
Tests 9 passed (9)
📘 Concepto: La entrega continua (CD) da un paso más: si la CI pasa, se publica sola. El flujo de la sección 6 ya lo es. Conviene proteger la rama
mainpara exigir que la CI pase antes de fusionar, en Settings, Branches.
Los secretos del flujo, como la contraseña de un registro de imágenes, van en Settings, Secrets and variables, Actions, y se leen con ${{ secrets.NOMBRE }}. No los escribas nunca en el YAML, y recuerda la sección 3: un secreto que se pasa a ng build acaba en el JavaScript publicado.
8. Seguridad
Una aplicación en Internet recibe ataques automáticos desde el primer día. Angular te protege de lo más común sin que hagas nada, pero conviene saber qué hace, y qué sigue siendo responsabilidad tuya.
⚠️ Importante: Toda comprobación en el navegador se puede saltar. Los guards del módulo 11 y las validaciones de formulario mejoran la experiencia de uso, pero la seguridad real está en el servidor, que debe comprobar cada petición.
XSS: cross-site scripting
Un ataque XSS consigue que tu página ejecute JavaScript del atacante. Imagina que alguien guarda este comentario en la base de datos, y tu aplicación se lo muestra a todos los usuarios:
<b>Buen trabajo</b><img src="x" onerror="window.robado = true"><script>window.robado = true</script>
Si ese HTML se insertase sin más, onerror se ejecutaría en el navegador de cada víctima. En un ataque real no pondría un true: leería el token de localStorage y lo mandaría a un servidor ajeno, o actuaría como si fuera el usuario.
Angular trata todo valor como no confiable por defecto. Compruébalo con este componente:
// src/app/seguridad/seguridad.ts
import { Component, inject, signal } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Component({
selector: 'app-seguridad',
template: `
<h2>Interpolación</h2>
<div class="caja">{{ comentario }}</div>
<h2>[innerHTML]: Angular lo sanea</h2>
<div class="caja" [innerHTML]="comentario"></div>
<h2>bypassSecurityTrustHtml: sin saneado</h2>
@if (mostrarPeligroso()) {
<div class="caja" [innerHTML]="confiado"></div>
} @else {
<button type="button" (click)="mostrarPeligroso.set(true)">Mostrar sin sanear</button>
}
<h2>[href] con javascript:</h2>
<a [href]="enlace">Ver perfil</a>
`,
styles: `
.caja { border: 1px solid #3f51b5; border-radius: 4px; padding: .5rem; background: #e8eaf6; }
`,
})
export class Seguridad {
protected readonly comentario =
'<b>Buen trabajo</b><img src="x" onerror="window.robado = true"><script>window.robado = true</script>';
protected readonly enlace = 'javascript:window.robado = true';
protected readonly confiado = inject(DomSanitizer).bypassSecurityTrustHtml(this.comentario);
protected readonly mostrarPeligroso = signal(false);
}

Figura 7. El mismo texto, tratado por Angular de tres formas.
Esto es lo que quedó en el DOM:
| Forma | Resultado | ¿Se ejecutó? |
|---|---|---|
{{ comentario }} | Texto literal, con los símbolos < visibles | No |
[innerHTML] | <b>Buen trabajo</b><img src="x"> | No: el saneador quita onerror y <script> |
[href] | unsafe:javascript:window.robado = true | No: Angular marca la URL y el navegador no la ejecuta |
bypassSecurityTrustHtml | El HTML íntegro, con onerror | Sí: window.robado pasó a true |
Sanear significa quitar de un valor lo que pueda ser peligroso, dejando el resto. Angular aplica un saneador distinto según el contexto: HTML, URL o estilo. Si elimina algo, avisa en la consola en modo de desarrollo: WARNING: sanitizing HTML stripped some content.
⚠️ Importante: Los métodos
bypassSecurityTrust...no «comprueban» nada. Son tu firma diciendo «me hago responsable». Solo tienen sentido con contenido que has generado tú, nunca con algo que venga del usuario o de la API. La regla de oro es: si en una revisión de código ves uno de estos métodos, pregunta de dónde viene el valor.
Si necesitas el HTML ya saneado en código, usa sanitize:
import { inject, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
const limpio = inject(DomSanitizer).sanitize(
SecurityContext.HTML,
'<b>Hola</b><img src="x" onerror="alert(1)">',
);
// limpio: <b>Hola</b><img src="x">
Tres vías que saltan esta protección sin que lo notes:
- Usar el DOM directamente:
elemento.nativeElement.innerHTML = valorno pasa por el saneador. - Construir plantillas con datos del usuario: el compilador confía en el texto de las plantillas. Por eso no se debe generar una plantilla concatenando cadenas.
- Dependencias con fallos: revisa con
npm audit, y actualiza Angular. Las notas de versión de 22 incluyen varias correcciones de saneado.
CSP: una segunda línea de defensa
¿Y si un fallo deja pasar un script? La política de seguridad de contenidos (Content Security Policy, CSP) es una cabecera que le dice al navegador qué puede cargar y ejecutar en tu página. Lo que no esté permitido se bloquea, venga de donde venga.
Probemos la política más estricta con el servidor de antes, añadiendo en Nginx Content-Security-Policy: default-src 'self':

Figura 8. La aplicación funciona, pero ha perdido todos sus estilos.
La consola explica por qué:
Applying inline style violates the following Content Security Policy directive 'default-src 'self''.
Executing inline script violates the following Content Security Policy directive 'default-src 'self''.
Angular inserta los estilos de los componentes con elementos <style>, y el build mete un <style> y un <script> en línea en index.html, los de la sección 2. Todos son contenido en línea, justo lo que una CSP estricta prohíbe.
La solución es un nonce, un valor aleatorio distinto en cada petición. El servidor lo pone en la cabecera, y el contenido que lo lleva se considera autorizado. Un atacante no puede adivinarlo, porque cambia cada vez.
En Angular se indica en index.html, con el atributo ngCspNonce. Para no tocar el original, se usa una variante de la configuración csp en angular.json:
<!-- src/index.csp.html -->
<app-root ngCspNonce="CSP_NONCE"></app-root>
"csp": {
"index": { "input": "src/index.csp.html", "output": "index.html" }
}
ng build --configuration=production,csp
El CLI extiende ese valor a todo lo que genera, incluidos el <style> crítico y los scripts:
<style nonce="CSP_NONCE">body{margin:0;...}</style>
<script src="main-K4P57BNK.js" type="module" nonce="CSP_NONCE"></script>
<script nonce="CSP_NONCE">document.querySelectorAll('link[data-beasties-media]')...</script>
Falta que el servidor cambie CSP_NONCE por un valor real en cada petición. Nginx lo hace con sub_filter, y su variable $request_id es un identificador aleatorio de 32 caracteres, distinto por petición:
location / {
add_header Cache-Control "no-store" always;
add_header Content-Security-Policy "script-src 'self' 'nonce-$request_id'; style-src 'self' 'nonce-$request_id'; img-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
sub_filter_once off;
sub_filter 'CSP_NONCE' '$request_id';
try_files $uri $uri/ /index.html;
}
⚠️ Importante: Con nonce,
index.htmldebe ir conno-store. Si se guardase en caché, el mismo valor se repetiría, y un nonce repetido no protege. Por eso existe también el tokenCSP_NONCEpara proporcionarlo por programa, si generas tú el valor al arrancar.
connect-src 'self' solo permite peticiones al propio origen. Si tu API está en otro dominio, añádelo ahí, o las llamadas fallarán. Esto encaja bien con el proxy /api.
autoCsp: la política generada al compilar
Si no puedes generar un nonce por petición, por ejemplo en un sitio estático, el CLI puede calcular una política basada en huellas de los scripts en línea:
"autocsp": {
"security": { "autoCsp": true }
}
Añade una etiqueta <meta> al principio de index.html:
<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-aSmGyjIUmpzdhVNc...' 'sha256-LMY6wYoFV9I4wWza...' https: 'unsafe-inline';object-src 'none';base-uri 'self';">
Y cambia la etiqueta <script src> por un pequeño script en línea que carga la aplicación. 'strict-dynamic' significa que un script autorizado por su huella puede cargar otros. Los navegadores modernos ignoran https: y 'unsafe-inline' cuando ven huellas, y esos valores quedan para los antiguos.
autoCsp solo cubre scripts, y sigue siendo una opción sin activar por defecto: el propio esquema dice que pasará a true cuando salga de su fase de pruebas. style-src y frame-ancestors debes enviarlos tú en una cabecera, porque los navegadores ignoran esta última en <meta>. Angular 21 genera la misma etiqueta.
Trusted Types
Los Trusted Types atacan el problema desde otro ángulo. Con ellos activados, el navegador rechaza asignar una cadena a sitios peligrosos como innerHTML: exige un objeto creado por una política con nombre. Así, una cadena corriente no puede llegar nunca a innerHTML.
Angular trae sus políticas, y basta con permitirlas en la cabecera:
require-trusted-types-for 'script'; trusted-types angular angular#bundler
| Política | Para qué sirve |
|---|---|
angular | El código ya revisado de Angular y sus saneadores |
angular#bundler | La carga de fragmentos perezosos |
angular#unsafe-bypass | Los métodos bypassSecurityTrust... |
angular#unsafe-jit | El compilador en tiempo de ejecución |
angular#unsafe-upgrade | Aplicaciones híbridas con AngularJS |
La parte interesante es que no permitas angular#unsafe-bypass. Con esa política, pulsar «Mostrar sin sanear» da este error, y el contenido peligroso no llega a la página:
Creating a TrustedTypePolicy named 'angular#unsafe-bypass' violates the following
Content Security policy directive: "trusted-types angular angular#bundler".
This document requires 'TrustedHTML' assignment. The action has been blocked.
ERROR TypeError: Failed to set the 'innerHTML' property on 'Element':
This document requires 'TrustedHTML' assignment.
Esta tabla resume lo que pasó en cada prueba, con Chromium 156 y Nginx real:
| Política | ¿Estilos bien? | [innerHTML] saneado | Con bypassSecurityTrustHtml |
|---|---|---|---|
| Ninguna | Sí | Correcto | El ataque se ejecuta |
default-src 'self', sin nonce | No | Correcto | HTML insertado, onerror bloqueado |
| CSP con nonce | Sí | Correcto | HTML insertado, onerror bloqueado |
| Con nonce y Trusted Types | Sí | Correcto | HTML rechazado, error en consola |
Lo anterior, permitiendo angular#unsafe-bypass | Sí | Correcto | HTML insertado, onerror bloqueado |
autoCsp y cabecera de estilos | Sí | Correcto | HTML insertado, onerror bloqueado |
Lee la última columna con calma. Incluso con el error más grave posible, que es confiar en contenido malicioso, CSP impide que se ejecute. A esto se le llama defensa en profundidad: varias capas independientes, de modo que el fallo de una no sea el final.
💡 Consejo: Para añadir CSP a una aplicación ya en producción, envía primero la cabecera
Content-Security-Policy-Report-Only. El navegador informa de lo que bloquearía, pero no bloquea nada. Cuando la consola esté limpia, cambia a la cabecera real.
CSRF
El ataque CSRF o XSRF (cross-site request forgery) aprovecha que el navegador envía las cookies automáticamente. Estás conectado en empresa.es, y visitas una web maliciosa con un formulario oculto que apunta a empresa.es/api/pedidos. Tu navegador añade tu cookie de sesión a esa petición, y el servidor cree que has sido tú. El atacante no ve la respuesta, pero la acción se ha hecho.
La defensa estándar, llamada double submit cookie, funciona así:
- El servidor envía una cookie legible por JavaScript:
XSRF-TOKEN. - El cliente la lee y la repite en una cabecera:
X-XSRF-TOKEN. - El servidor compara ambas. Una web ajena no puede leer tus cookies, de modo que no puede poner esa cabecera.
HttpClient hace el paso 2 solo, sin configurar nada, gracias a un interceptor interno. Si tu servidor usa otros nombres:
provideHttpClient(
withXsrfConfiguration({ cookieName: 'CSRF', headerName: 'X-CSRF' }),
);
Comprobamos el comportamiento con una petición real:
| Petición | Cabecera enviada |
|---|---|
POST a /api/auth/login, mismo origen | X-XSRF-TOKEN: token-de-prueba |
POST a http://127.0.0.1:3227/api/..., otro origen | Ninguna |
GET al mismo origen | Ninguna |
Angular nunca manda el valor a otro origen, porque eso filtraría el token. Y no lo manda en GET ni HEAD, porque esas peticiones no deben cambiar nada.
📘 Concepto: Si te autentificas con un JWT en la cabecera
Authorization, como haremos con la API EMPRESA en el módulo 29, CSRF no te afecta: el navegador no añade esa cabecera por su cuenta. A cambio, el token debe estar accesible para JavaScript, y un XSS podría robarlo. CSRF importa cuando la sesión va en una cookie. Y una cookie de sesión debe llevar ademásHttpOnly,SecureySameSite.
Angular solo pone la cabecera; el servidor debe emitir la cookie y comprobar la coincidencia. Si tu API no usa cookies, puedes quitar el interceptor con withNoXsrfProtection().
Otras medidas
| Medida | Cómo | Protege frente a |
|---|---|---|
| HTTPS | Certificado en el servidor o en el balanceador | Lectura y modificación del tráfico |
| Integridad de recursos | "subresourceIntegrity": true en angular.json | Ficheros alterados en un CDN |
| Hosts permitidos en SSR | security.allowedHosts (módulo 24) | Peticiones falsificadas desde el servidor |
| Dependencias | npm audit, y actualizar con ng update | Fallos conocidos en paquetes |
| Comprobación en el servidor | Autorización en cada punto de la API | Todo lo que se salte el cliente |
9. Las herramientas de IA en Angular
Hay tres piezas que suenan parecido, y conviene separarlas antes de seguir:
Figura 9. Agent Skills y el servidor MCP ayudan a quien programa. WebMCP afecta a quien usa tu web.
| Agent Skills | Servidor MCP | WebMCP | |
|---|---|---|---|
| Qué es | Instrucciones en ficheros | Programa local con herramientas | API del navegador |
| Quién lo usa | Asistente de código | Asistente de código | Agente en el navegador |
| Cuándo actúa | Mientras desarrollas | Mientras desarrollas | Con la aplicación en marcha |
| Va en producción | No | No | Sí |
| Estado en 22.2 | Disponible | Disponible | Experimental |
El servidor MCP de Angular CLI ya está explicado y comprobado en el módulo 01: qué es ng mcp, cómo configurarlo en tu editor, qué herramientas ofrece, cómo generar la configuración con ng g ai-config y cómo limitarlo con --read-only, --local-only y --root. No se repite aquí. Lo que añade este módulo es que un asistente con ese servidor puede compilar el proyecto con run_target, y por tanto comprobar lo que escribe. No lo conectes a nada que se ejecute en tu CI: sus herramientas están pensadas para tu sesión de trabajo.
10. Agent Skills
Un asistente de código sabe mucho de Angular, pero a menudo de versiones anteriores. Una skill es un paquete de instrucciones especializadas que el asistente carga solo cuando la tarea lo requiere. Es un fichero SKILL.md con una cabecera que describe cuándo usarla, y normalmente una carpeta references/ con más detalle.
Esa carga selectiva es lo que la diferencia de AGENTS.md o CLAUDE.md, que generó ng g ai-config en el módulo 01. Esos ficheros se leen siempre, y por eso deben ser breves. Una skill puede ser larga, porque el asistente lee primero solo su descripción.
El equipo de Angular publica dos en el repositorio angular/skills:
| Skill | Para qué sirve |
|---|---|
angular-developer | Genera código y da orientación de arquitectura: componentes, servicios, HTTP, señales |
angular-new-app | Crea una aplicación nueva con Angular CLI, con pautas para estructurarla bien |
La documentación propone instalarlas con una herramienta de la comunidad:
npx skills add https://github.com/angular/skills
Lo que indica angular-developer da una buena idea de lo que es una skill. Sus primeras reglas piden al asistente que compruebe la versión de Angular del proyecto antes de aconsejar, que use el CLI para generar código, y que al terminar ejecute ng build y corrija lo que falle. Para proyectos nuevos, propone por defecto la última versión estable y Signal Forms.
⚠️ Importante: Una skill son instrucciones que tu asistente va a seguir, así que instalar una equivale a darle órdenes escritas por otra persona. Las de
angular/skillsson del equipo de Angular. Antes de instalar las de otro origen, léelas, igual que leerías un script antes de ejecutarlo.
💡 Consejo: Si usas la LTS, las reglas de estas skills siguen siendo útiles, porque piden comprobar la versión del proyecto. Pero están escritas pensando en la última versión: revisa lo que propongan frente a las marcas 🆕 y 🔄 de este curso.
11. WebMCP experimental 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: Angular no ofrece equivalente. Solo podrías llamar tú a la API del navegador, sin tipos y sin la limpieza automática al destruir el componente. Como la especificación aún cambia, lo razonable es no usarlo todavía.
Hoy, un agente que maneja una web mira la pantalla, adivina dónde está cada botón y hace clic. Es lento y falla con facilidad. WebMCP es una propuesta de estándar para que la web diga directamente qué sabe hacer: «tengo una herramienta buscar_empleados, que recibe un texto». El agente, en lugar de imitar a una persona, llama a esa función.
La idea es la misma que la del servidor MCP, pero la herramienta vive en la página, y se ejecuta con la sesión del usuario que la tiene abierta.
⚠️ Importante: WebMCP es experimental en Angular 22.2, y sus funciones llevan
Experimentalen el nombre para que no se olvide. La documentación avisa de que la especificación es reciente y cambia a menudo, y de que estas API pueden cambiar incluso fuera de una versión mayor. Aprende la idea, y no lo uses aún en un proyecto real.
Declarar una herramienta
declareExperimentalWebMcpTool registra una herramienta y la retira cuando se destruye su contexto de inyección:
// src/app/agente/agente.ts
import { HttpClient } from '@angular/common/http';
import { Component, declareExperimentalWebMcpTool, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { ConfigService } from '../config.service';
interface PaginaEmpleados {
contenido: { empId: number; nombre: string; apellidos: string; oficio: string }[];
total: number;
}
@Component({
selector: 'app-agente',
template: `
<h1>Herramientas para un agente</h1>
<p>Última búsqueda del agente: <strong>{{ ultima() || 'ninguna' }}</strong></p>
`,
})
export class Agente {
private readonly http = inject(HttpClient);
private readonly api = inject(ConfigService).valor.apiUrl;
protected readonly ultima = signal('');
constructor() {
declareExperimentalWebMcpTool({
name: 'buscar_empleados',
description: 'Busca empleados de EMPRESA por nombre o apellidos y devuelve hasta cinco.',
inputSchema: {
type: 'object',
properties: {
texto: { type: 'string', description: 'Texto que se busca' },
},
required: ['texto'],
additionalProperties: false,
},
execute: async ({ texto }) => {
if (typeof texto !== 'string' || texto.length > 50) {
return 'El texto debe ser una cadena de 50 caracteres como máximo.';
}
this.ultima.set(texto);
const url = `${this.api}/empleados?q=${encodeURIComponent(texto)}&tamano=5`;
const pagina = await firstValueFrom(this.http.get<PaginaEmpleados>(url));
return JSON.stringify(pagina.contenido);
},
});
}
}
Las piezas son:
name: identificador único de la herramienta.description: es lo que lee el agente para decidir si la usa. Escríbela para él: qué hace, y qué devuelve.inputSchema: un JSON Schema de los argumentos. Angular deduce de aquí el tipo detexto, gracias a un genéricoconst.execute: la función. Lo que devuelve se entrega al agente, normalmente como texto.
Fíjate en que execute comprueba texto, aunque el tipo diga que es una cadena. La documentación avisa de que Angular no valida los argumentos contra el esquema, y lo que envía un agente hay que tratarlo como cualquier dato externo.
Si no hay soporte, no pasa nada: la implementación busca document.modelContext o navigator.modelContext, y si no los encuentra, no hace nada. Tampoco actúa en el servidor con SSR. Tu aplicación funciona igual para todo el mundo.
Herramientas globales
provideExperimentalWebMcpTools registra una lista cuando se crea el inyector, y la función execute se ejecuta en su contexto, de modo que puede usar inject():
provideExperimentalWebMcpTools([
{
name: 'estado_aplicacion',
description: 'Devuelve el nombre del entorno en el que se ejecuta la aplicación.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
execute: () => inject(ConfigService).valor.entorno,
},
]);
Anotaciones 🆕 v22.2
🆕 Desde Angular 22.2. No existe en la LTS (Angular 21). Si usas la LTS: no hay alternativa, porque WebMCP tampoco existe allí. En 22.0 y 22.1 tampoco hay anotaciones.
annotations describe el comportamiento de la herramienta, para que el agente sepa cuánto cuidado debe tener:
| Anotación | Significa |
|---|---|
readOnlyHint | No cambia nada que el usuario pueda ver |
untrustedContentHint | Devuelve contenido no confiable desde el punto de vista del autor |
consequentialHint | Tiene efectos importantes: modifica datos o actúa en el exterior |
annotations: { readOnlyHint: true },
Son pistas: nada impide que una herramienta anotada como de solo lectura borre datos. Un agente puede preferir las de solo lectura por ser más seguras, así que ser honesto aquí importa.
Formularios como herramientas
Signal Forms (módulo 18) puede publicar un formulario entero como herramienta. El esquema se deduce del modelo, y el formulario aporta sus validadores y su envío:
import { form, required } from '@angular/forms/signals';
private readonly modelo = signal({ asunto: '', mensaje: '' });
protected readonly sugerencia = form(
this.modelo,
(ruta) => {
required(ruta.asunto, { message: 'Falta el asunto' });
required(ruta.mensaje, { message: 'Falta el mensaje' });
},
{
experimentalWebMcpTool: {
name: 'enviar_sugerencia',
description: 'Envía una sugerencia al departamento de personal.',
},
submission: {
action: async (campos) => {
this.enviada.set(campos().value().asunto);
},
},
},
);
Hace falta activar la función con provideExperimentalWebMcpForms(), de @angular/forms/signals. La documentación la coloca en la raíz de la aplicación. También funcionó en los providers de la ruta:
{
path: 'agente',
loadComponent: () => import('./agente/agente').then((m) => m.Agente),
providers: [provideExperimentalWebMcpForms()],
}
El modelo necesita valores iniciales concretos, sin null ni undefined y sin arreglos vacíos, porque de ellos sale el esquema. La respuesta llega al agente con la misma forma que espera MCP. Con el formulario vacío, los validadores responden por ti:
{"content":[{"type":"text","text":"Form submission failed:\nasunto: Falta el asunto\nmensaje: Falta el mensaje"}]}
Con datos válidos:
{"content":[{"type":"text","text":"Form submitted successfully."}]}
Como tiene efectos, un formulario se registra siempre con readOnlyHint: false, aunque intentes otra cosa.
Probarlo
Chromium 156 incluye la API detrás de una opción. Sin ella, ni navigator.modelContext ni document.modelContext existen. Con --enable-features=WebMCP, aparece document.modelContext, con los métodos registerTool, getTools y executeTool:
chromium --enable-features=WebMCP
Abre la consola en la pantalla del agente y haz de agente:
const herramientas = await document.modelContext.getTools();
const buscar = herramientas.find((h) => h.name === 'buscar_empleados');
await document.modelContext.executeTool(buscar, { texto: 'garcia' });
[{"empId":100,"nombre":"Ana","apellidos":"García López",...

Figura 10. La página refleja lo que ha hecho el agente, porque las herramientas escriben en señales.
La herramienta actualiza una señal, y por tanto la pantalla. Es importante: si el agente trabaja en una pestaña abierta, el usuario debe ver lo que ocurre. Al salir de la ruta, Angular aborta el registro: registró las herramientas pasando un AbortSignal, y en la prueba con un registerTool propio no quedó ninguna al navegar a otra pantalla.
Como la API aún cambia, no des por buenos estos nombres en otra versión del navegador. Si solo quieres probar tu lógica, sustituye navigator.modelContext por un objeto propio con un registerTool, en una prueba.
Herramientas por ruta 🆕 v22.2
🆕 Desde Angular 22.2.
withAutoCleanupInjectorsno existe en la LTS (Angular 21), donde se llamawithExperimentalAutoCleanupInjectors. Si usas la LTS: ese nombre sirve para limpiar los inyectores de ruta, pero WebMCP no existe allí, así que no aplica a este apartado.
Las herramientas declaradas en un componente se retiran con él. Con provideExperimentalWebMcpTools en los providers de una ruta es distinto: el inyector de la ruta sobrevive a la navegación. La documentación recomienda withAutoCleanupInjectors para que se retiren al salir. Esta función se explica en el módulo 11.
provideRouter(routes, withAutoCleanupInjectors());
Antes de exponer algo a un agente
Una herramienta es una puerta nueva a tu aplicación, abierta con la sesión del usuario. Aplica lo de la sección 8:
- Valida los argumentos, porque Angular no lo hace.
- La API debe seguir comprobando permisos. Una herramienta no concede nada que el usuario no tenga.
- Pide confirmación para lo que sea irreversible.
- Ten cuidado con lo que devuelves. Si una herramienta devuelve texto escrito por otros usuarios, ese texto puede contener instrucciones dirigidas al agente. Se llama inyección de instrucciones (prompt injection), y es el equivalente al XSS en este mundo. Para eso sirve
untrustedContentHint.
12. Lista de comprobación y errores frecuentes
Antes de publicar:
npm ci,ng test --watch=falseyng buildpasan en una máquina limpia- Ningún secreto en
src/, enpublic/ni endefine index.htmlno se guarda en caché a ciegas, y los ficheros con huella sí- Las rutas profundas funcionan al pulsar F5
- Un fichero inexistente da 404, no
index.html - Sin
bypassSecurityTrust...sobre datos externos - CSP probada, primero con
Report-Only - Los mapas de código no están en el servidor público
| Síntoma | Causa | Solución |
|---|---|---|
Página en blanco; 404 en main-XXX.js | <base href> incorrecto | ng build --base-href /carpeta/ |
| 404 al pulsar F5 en una ruta | Falta la vuelta a index.html | try_files en Nginx o 404.html en Pages |
Failed to fetch dynamically imported module | Fragmento de una versión anterior | 404 real, y index.html sin caché |
| Tras publicar, siguen viendo la versión vieja | index.html en caché | no-cache |
Unexpected token '<' al leer config.json | El servidor devolvió index.html | Bloque para .json con try_files $uri =404 |
apiUrl vale ${API_URL} | El script no se ejecutó o la variable no llega | Revisa docker logs y -e |
not found en un script que existe | Saltos de línea CRLF | *.sh text eol=lf |
host not found in upstream | El otro servicio no se ve desde Nginx | Nombre correcto y depends_on |
| Sin estilos con CSP | Estilos en línea bloqueados | ngCspNonce |
| Las cabeceras de seguridad desaparecen en una ruta | add_header no se hereda | Fichero común incluido en cada bloque |
| Bloqueo por CORS en producción | URL absoluta a otro origen | Proxy /api, o configurar CORS en la API |
13. Ejercicios
Todos parten del proyecto del módulo, con la API en marcha.
Ejercicio 1: Configuración de preproducción
Crea environment.staging.ts con nombre: 'preproduccion', añade la configuración staging y comprueba, en los ficheros generados, que el JavaScript lleva huella y contiene ese valor.
Ver solución
"staging": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.staging.ts"
}
],
"sourceMap": { "scripts": true, "hidden": true }
}
ng build --configuration=production,staging
grep -l preproduccion dist/empresa-web/browser/*.js
dist/empresa-web/browser/main-RVVGKSLT.js
Hay que combinarla con production. Sin ella, outputHashing no se aplica y obtienes main.js, porque las configuraciones no se heredan.
Ejercicio 2: ¿Es secreto?
Un compañero propone guardar la clave de un servicio de pago en environment.ts, argumentando que el código va «compilado y minificado». Demuestra con una orden que se equivoca, y propón una solución.
Ver solución
La minificación acorta los nombres de variables, pero no toca el contenido de las cadenas. Como en el ejercicio anterior, una búsqueda de texto la encuentra:
grep -o '.\{10\}preproduccion.\{10\}' dist/empresa-web/browser/main-*.js
Cualquier usuario llega al mismo resultado desde las herramientas del navegador. La solución es que la clave solo la conozca tu servidor: la aplicación llama a tu API, y tu API llama al servicio de pago. Si la clave ya estuvo en un repositorio o en un fichero publicado, da por hecho que está comprometida y cámbiala.
Ejercicio 3: Validar config.json
Un fallo de despliegue puede dejar config.json con ${API_URL} sin sustituir, o devolver HTML. Escribe validarConfig(datos: unknown): ConfigApp, que lance un error claro en esos casos, y úsala en ConfigService.
Ver solución
// src/app/validar-config.ts
import { ConfigApp } from './config.service';
export function validarConfig(datos: unknown): ConfigApp {
if (typeof datos !== 'object' || datos === null) {
throw new Error('config.json no contiene un objeto');
}
const { apiUrl, entorno } = datos as Partial<ConfigApp>;
if (typeof apiUrl !== 'string' || apiUrl === '' || apiUrl.includes('${')) {
throw new Error(`apiUrl no es válida: ${apiUrl}`);
}
if (typeof entorno !== 'string' || entorno === '') {
throw new Error(`entorno no es válido: ${entorno}`);
}
return { apiUrl, entorno };
}
En ConfigService:
this.datos.set(validarConfig(await respuesta.json()));
describe('validarConfig', () => {
it('acepta una configuración correcta', () => {
const config = validarConfig({ apiUrl: '/api', entorno: 'prueba' });
expect(config.entorno).toBe('prueba');
});
it('rechaza una URL que el contenedor no ha sustituido', () => {
expect(() => validarConfig({ apiUrl: '${API_URL}', entorno: 'prueba' })).toThrow('apiUrl');
});
it('rechaza un valor que no es un objeto', () => {
expect(() => validarConfig('<!doctype html>')).toThrow('objeto');
});
});
Un config.json es un dato externo. TypeScript no comprueba nada en tiempo de ejecución: respuesta.json() devuelve lo que llegue, sea lo que sea.
Ejercicio 4: El 404 de un año
En un servidor, /chunk-viejo.js responde esto. ¿Qué problema tiene, y cómo se corrige?
HTTP/1.1 404 Not Found
Cache-Control: public, max-age=31536000, immutable
Ver solución
El navegador y los servidores intermedios pueden guardar esa respuesta de error durante un año. Si el fichero aparece luego, seguirán creyendo que no existe. La causa es el modificador always, que añade la cabecera a cualquier código de respuesta:
add_header Cache-Control "public, max-age=31536000, immutable" always;
Sin always, Nginx solo la añade a los códigos correctos, como 200 y 304:
add_header Cache-Control "public, max-age=31536000, immutable";
Las cabeceras de seguridad, en cambio, sí deben llevar always: un error también es una página.
Ejercicio 5: Compose con la API interna
Levanta EMPRESA con Compose, de forma que la API no sea accesible directamente desde tu ordenador, y comprueba que /api/empleados/100 responde a través de Nginx.
Ver solución
Descomprime empresa-api.zip en ./empresa-api y usa el docker-compose.yml de la sección 5. El servicio api no tiene ports, así que solo se ve dentro de la red de Compose.
docker compose up -d --wait
curl http://localhost:8080/config.json
curl http://localhost:8080/api/empleados/100
{"empId":100,"nombre":"Ana","apellidos":"García López","email":"ana.garcia@empresa.es", ...}
docker compose down
Solo Nginx es accesible desde fuera. Si un día la API necesita un puerto para depurarla, añádelo solo en un docker-compose.override.yml local.
Ejercicio 6: Subcarpeta
Publica en un servidor donde la aplicación quede en /empresa/. Explica por qué fetch('/config.json') fallaría, y comprueba que una ruta profunda funciona.
Ver solución
ng build --base-href /empresa/
cp dist/empresa-web/browser/index.html dist/empresa-web/browser/404.html
Para imitar GitHub Pages con Nginx:
server {
listen 80;
root /usr/share/nginx/html;
error_page 404 /empresa/404.html;
}
docker run --rm -p 8080:80 \
-v "$PWD/docker/pages.conf:/etc/nginx/conf.d/default.conf:ro" \
-v "$PWD/dist/empresa-web/browser:/usr/share/nginx/html/empresa:ro" \
nginx:1.31-alpine
/empresa/empleados/100 responde con código 404, y aun así Angular muestra la ficha del empleado.
fetch('/config.json') empieza por /, de modo que ignora <base href> y busca en la raíz del dominio, fuera de tu sitio. fetch('config.json') se resuelve contra la base, y queda en /empresa/config.json.
Ejercicio 7: CSP con nonce
Partiendo de la imagen, consigue que /seguridad se vea con todos sus estilos bajo una CSP sin 'unsafe-inline'. Comprueba que el nonce cambia en cada petición.
Ver solución
Añade ngCspNonce="CSP_NONCE" a <app-root> en una copia de index.html, la configuración csp y compila:
ng build --configuration=production,csp
En el bloque location /, añade el código de la sección 8. Después:
curl -sI http://localhost:8080/ | grep -i content-security
curl -sI http://localhost:8080/ | grep -i content-security
Dos peticiones deben mostrar un nonce- distinto. En la consola solo queda un error, y es el que quieres: al activar «Mostrar sin sanear», el navegador bloquea el onerror.
Executing inline event handler violates the following Content Security Policy directive
En el laboratorio, el color de la cabecera pasó de transparente a rgb(26, 35, 126), y los tres elementos <style> llevaban nonce.
Ejercicio 8: XSRF con otros nombres
Tu servidor usa la cookie CSRF y la cabecera X-CSRF. Configura HttpClient y escribe pruebas para las tres situaciones: POST al mismo origen, GET y POST a otro dominio.
Ver solución
import { HttpClient, provideHttpClient, withXsrfConfiguration } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it } from 'vitest';
describe('XSRF con nombres propios', () => {
afterEach(() => {
document.cookie = 'CSRF=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
});
function preparar() {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withXsrfConfiguration({ cookieName: 'CSRF', headerName: 'X-CSRF' })),
provideHttpClientTesting(),
],
});
return { http: TestBed.inject(HttpClient), control: TestBed.inject(HttpTestingController) };
}
it('envía la cabecera en un POST a la misma dirección', () => {
document.cookie = 'CSRF=abc123; path=/';
const { http, control } = preparar();
http.post('/api/pedidos', {}).subscribe();
expect(control.expectOne('/api/pedidos').request.headers.get('X-CSRF')).toBe('abc123');
});
it('no la envía en un GET', () => {
document.cookie = 'CSRF=abc123; path=/';
const { http, control } = preparar();
http.get('/api/pedidos').subscribe();
expect(control.expectOne('/api/pedidos').request.headers.has('X-CSRF')).toBe(false);
});
it('no la envía a otro dominio', () => {
document.cookie = 'CSRF=abc123; path=/';
const { http, control } = preparar();
http.post('https://otra-api.example/pedidos', {}).subscribe();
const peticion = control.expectOne('https://otra-api.example/pedidos');
expect(peticion.request.headers.has('X-CSRF')).toBe(false);
});
});
Los tres pasan. provideHttpClientTesting conserva los interceptores, y por eso el de XSRF actúa.
Ejercicio 9: Corregir un bypass
Este componente muestra la biografía que escribe cada empleado. Encuentra el fallo y corrígelo.
export class Perfil {
private readonly saneador = inject(DomSanitizer);
readonly biografia = input.required<string>();
protected readonly html = computed(() =>
this.saneador.bypassSecurityTrustHtml(this.biografia()),
);
}
<div [innerHTML]="html()"></div>
Ver solución
La biografía la escribe un usuario, de modo que es contenido no confiable, y bypassSecurityTrustHtml desactiva justo la protección que hace falta. Cualquier empleado podría atacar a quien abra su perfil.
Basta con quitar el bypass, porque [innerHTML] ya sanea:
export class Perfil {
readonly biografia = input.required<string>();
}
<div [innerHTML]="biografia()"></div>
Si necesitas el valor limpio en código, usa sanitize:
const limpio = inject(DomSanitizer).sanitize(SecurityContext.HTML, valor);
Con '<b>Hola</b><img src="x" onerror="alert(1)">', devuelve <b>Hola</b><img src="x">.
Ejercicio 10 (propuesto): Nginx sin privilegios
En la prueba de este módulo, Nginx se ejecuta como root dentro del contenedor. Cambia la imagen base por una que no use ese usuario. Ten en cuenta que no podrá escuchar en el puerto 80, ni escribir config.json si no ajustas los permisos.
Ejercicio 11 (propuesto): Publicar la imagen
Amplía la CI: cuando la rama main pase las pruebas, construye la imagen y publícala en GitHub Container Registry. Etiquétala con el identificador del commit, no con latest, para poder volver atrás.
Ejercicio 12 (propuesto): Herramienta de consulta
Solo en Angular 22. Añade una herramienta WebMCP consultar_stock, que reciba un identificador de producto y devuelva sus unidades. Anótala como de solo lectura, y rechaza los argumentos que no sean un entero positivo. Pruébala con executeTool en Chromium.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Compilar | ng build | Publica dist/<proyecto>/browser |
| Huella | outputHashing: "all" | Permite caché larga en todo salvo index.html |
| Mapas de código | sourceMap: { hidden: true } | Depurar sin publicar el código |
| Entornos | ng g environments, fileReplacements | Se fijan al compilar |
| Combinar | --configuration=production,staging | Las configuraciones no se heredan |
| Constantes | define | Sustituye un identificador por código |
| Secretos | Solo en el servidor | Todo lo compilado es público |
| Configuración | config.json, provideAppInitializer | Un artefacto para todos los entornos |
| Imagen | FROM ... AS, COPY --from | De 544 MB a 63 MB |
| Rutas | try_files $uri $uri/ /index.html | El router está en el navegador |
| Ficheros perdidos | try_files $uri =404 | Nunca HTML con un 200 para un .js |
| Plantillas | /etc/nginx/templates, envsubst | Configuración al arrancar el contenedor |
| Pages | --base-href /repo/, 404.html | Subcarpeta y rutas profundas |
| CI | npm ci, ng test --watch=false | Máquina limpia en cada cambio |
| XSS | [innerHTML], DomSanitizer | Angular sanea; bypass lo desactiva |
| CSP | ngCspNonce, CSP_NONCE, autoCsp | Lo no permitido no se ejecuta |
| Trusted Types | trusted-types angular angular#bundler | Las cadenas no llegan a innerHTML |
| CSRF | withXsrfConfiguration | Cabecera solo en mismo origen y escrituras |
| MCP | ng mcp (módulo 01) | Herramientas para el asistente |
| Skills | angular/skills | Instrucciones que se cargan al necesitarlas |
| WebMCP 🆕 | declareExperimentalWebMcpTool | Experimental: herramientas en la página |
| Anotaciones 🆕 | annotations | Pistas de comportamiento para el agente |
| Formularios 🆕 | experimentalWebMcpTool | El esquema se deduce del modelo |